godcrm/backend/workers/bdd-gates.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

241 lines
9.8 KiB
JavaScript

/**
* ADR-156 Appendix C §2.1 — Per-runner security gates.
*
* Three gates, one per runner kind. Each returns either
* { ok: true }
* or
* { ok: false, reason: '<human-readable denial>' }
*
* These are synchronous, pure functions: no DB, no I/O, no side effects. The
* worker calls the right one based on the `bdd_tests.runner` value.
*
* Design notes
* ------------
* Appendix C lays out a more ambitious bash allowlist (regex per permitted
* shell invocation). This iteration 2 delivery keeps the per-test anchored
* allowlist model (Appendix C §"Adding to the allowlist") but moves the
* hardcoded denylist into a single place for auditability, and keeps the
* allow patterns per-test (stored in `bdd_tests.data.runner_config.allow`).
*
* The denylist below is checked FIRST and is non-negotiable — even if an
* author's allow pattern technically matches, any denylist hit aborts.
*
* For SQL we accept `SELECT ...` and `WITH <cte> ... SELECT ...` after
* stripping block/line comments. DDL / DML / PL-SQL blocks are rejected.
*
* For MCP we accept only read-shaped tool names from the supplied allowlist
* (the caller passes `runner_config.tools`) and reject any tool name whose
* prefix indicates writes — regardless of whether it appears in the allow
* list. Belt and braces.
*/
// -- BASH ---------------------------------------------------------------------
/**
* Tokens / substrings that, if present ANYWHERE in a bash command, are refused
* unconditionally. This is checked before the per-test allow regex list, so
* a sloppy allow pattern cannot accidentally grant dangerous shell.
*
* These are substring or regex matches (not anchored). Order does not matter.
*/
const BASH_DENY_PATTERNS = [
{ name: 'rm_rf', re: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\b/i },
{ name: 'rm_rf_slash', re: /\brm\s+-rf\s+\/(?:\s|$)/i },
{ name: 'dd', re: /\bdd\s+if=/i },
{ name: 'mkfs', re: /\bmkfs(\.|\s)/i },
{ name: 'fork_bomb', re: /:\(\)\s*\{\s*:\s*\|\s*:?\s*&\s*\}\s*;?\s*:/ },
{ name: 'curl_pipe_sh', re: /curl\b[^|]*\|\s*(sh|bash)\b/i },
{ name: 'wget_pipe_sh', re: /wget\b[^|]*\|\s*(sh|bash)\b/i },
{ name: 'eval', re: /\beval\b/ },
{ name: 'sudo', re: /\bsudo\b/ },
{ name: 'su', re: /(^|\s|;|\|)su\b/ },
{ name: 'chmod_777', re: /\bchmod\s+[0-7]*777/ },
{ name: 'write_dev', re: />\s*\/dev\// },
{ name: 'read_passwd', re: /\/etc\/passwd/ },
{ name: 'killall', re: /\bkillall\b/ },
// Secondary defence — these usually indicate smuggling via decoded payloads.
{ name: 'bash_c', re: /\b(bash|sh)\s+-c\b/ },
{ name: 'base64_to_sh', re: /base64\b[^|]*\|\s*(sh|bash)\b/i },
// ADR-156 iter-5 Task 3 — curl-specific denylist. Blocks local-file upload
// (`-T` / `--upload-file` / `--data-binary @/path`), writes into sensitive
// roots, and dangerous URL schemes. Safe args like `-s -S -f -L --max-time`
// remain allowed.
{ name: 'curl_upload', re: /\bcurl\b[^\n]*\s(-T|--upload-file)\b/i },
{ name: 'curl_databin_file', re: /\bcurl\b[^\n]*\s--data-binary\s+@/i },
{ name: 'curl_write_etc', re: /\bcurl\b[^\n]*\s-o\s+\/etc\//i },
{ name: 'curl_write_root', re: /\bcurl\b[^\n]*\s-o\s+\/root\//i },
{ name: 'curl_file_scheme', re: /\bcurl\b[^\n]*\sfile:\/\//i },
{ name: 'curl_gopher', re: /\bcurl\b[^\n]*\sgopher:\/\//i },
{ name: 'curl_dict', re: /\bcurl\b[^\n]*\sdict:\/\//i },
];
/**
* Default safe curl allow pattern (ADR-156 iter-5 Task 3). Tests whose
* runner_config.allow doesn't include a curl pattern may opt in by pushing
* this exact string. Authors can also write their own tighter patterns.
*
* Shape: curl with required safety flags (-s -S -f -L --max-time <=30
* --max-filesize <=1048576) followed by an https URL. Anchored.
*/
const CURL_SAFE_ALLOW_PATTERN = String.raw`^curl\s+-s\s+-S\s+-f\s+-L\s+--max-time\s+(?:[1-9]|[12]\d|30)\s+--max-filesize\s+(?:10[0-3]\d{4}|104[0-7]\d{3}|1048[0-5]\d{2}|104857[0-6])\s+https?://\S+$`;
/**
* gateBash(command, allowPatterns)
*
* @param {string} command the full command string to run
* @param {string[]} allowPatterns per-test list of anchored regex strings
* (stored in bdd_tests.data.runner_config.allow).
* Each pattern is applied with the `^...$` anchors
* implied — callers must already anchor when authoring.
* @returns {{ok:true}|{ok:false, reason:string}}
*/
function gateBash(command, allowPatterns) {
if (typeof command !== 'string' || command.trim() === '') {
return { ok: false, reason: 'empty command' };
}
const cmd = command.trim();
// 1. Denylist — blanket refusal, no exceptions.
for (const d of BASH_DENY_PATTERNS) {
if (d.re.test(cmd)) {
return { ok: false, reason: `denylist:${d.name}` };
}
}
// 2. Per-test allowlist. Must match at least one.
if (!Array.isArray(allowPatterns) || allowPatterns.length === 0) {
return { ok: false, reason: 'no allow patterns configured for this test' };
}
for (const pat of allowPatterns) {
if (typeof pat !== 'string' || pat === '') continue;
let re;
try {
// Author stores anchors explicitly; we do not wrap. Bad regexes are
// caller errors but we refuse rather than throw.
re = new RegExp(pat);
} catch (e) {
return { ok: false, reason: `allow-pattern-invalid: ${pat}` };
}
if (re.test(cmd)) return { ok: true };
}
return { ok: false, reason: 'no allow pattern matched' };
}
// -- SQL ----------------------------------------------------------------------
/**
* Strip SQL comments (both -- line and /* block *\/) so our leading-token
* detection isn't spoofed by `/* DELETE * / SELECT ...`.
*/
function stripSqlComments(sql) {
// Remove /* block comments */ (non-greedy, multiline).
let out = sql.replace(/\/\*[\s\S]*?\*\//g, ' ');
// Remove -- line comments to end-of-line.
out = out.replace(/--[^\n]*\n?/g, ' ');
return out;
}
/**
* gateSql(statement)
*
* Accepts only a single top-level statement that begins with SELECT or
* WITH ... SELECT after comment stripping. Rejects DDL, DML, and PL blocks.
* The runner is expected to connect with the `bdd_readonly` role which has
* SELECT-only grants; this check is belt-and-braces.
*
* @param {string} statement
* @returns {{ok:true}|{ok:false, reason:string}}
*/
function gateSql(statement) {
if (typeof statement !== 'string' || statement.trim() === '') {
return { ok: false, reason: 'empty statement' };
}
const stripped = stripSqlComments(statement).trim();
if (stripped === '') return { ok: false, reason: 'statement is comments only' };
// Refuse multi-statement batches. A lone trailing `;` is allowed.
const withoutTrailingSemi = stripped.replace(/;\s*$/, '');
if (withoutTrailingSemi.indexOf(';') !== -1) {
return { ok: false, reason: 'multiple statements not allowed' };
}
const head = withoutTrailingSemi.slice(0, 6).toUpperCase();
if (head.startsWith('SELECT')) return { ok: true };
if (head.startsWith('WITH')) {
// A WITH ... SELECT/VALUES chain. Require the final keyword to be SELECT.
const upper = withoutTrailingSemi.toUpperCase();
// Disallow any obvious DML kicker inside a WITH chain.
if (/\b(INSERT|UPDATE|DELETE|MERGE|TRUNCATE|DROP|ALTER|CREATE|GRANT|REVOKE|COPY|CALL|DO)\b/.test(upper)) {
return { ok: false, reason: 'non-SELECT keyword found inside WITH chain' };
}
if (/\bSELECT\b/.test(upper)) return { ok: true };
return { ok: false, reason: 'WITH chain without final SELECT' };
}
return { ok: false, reason: `statement must begin with SELECT or WITH (got: ${head.toLowerCase().trim()})` };
}
// -- MCP ----------------------------------------------------------------------
/**
* Tool-name prefixes / substrings that indicate a write-capable MCP tool.
* Tools matching any of these are refused even if they appear in the per-test
* allow list, mirroring Appendix C §2.1 "Write-capable MCP tools are denied".
*/
const MCP_DENY_SUBSTRINGS = [
'create_', 'update_', 'delete_',
'batch_update_', 'batch_delete_',
'add_', 'send_', 'upload_', 'manage_columns',
];
/**
* gateMcp(toolName, allowedTools)
*
* @param {string} toolName the MCP tool the test wants to invoke
* @param {string[]} allowedTools per-test allowlist (from runner_config.tools)
* @returns {{ok:true}|{ok:false, reason:string}}
*/
function gateMcp(toolName, allowedTools) {
if (typeof toolName !== 'string' || toolName.trim() === '') {
return { ok: false, reason: 'empty tool name' };
}
const name = toolName.trim();
// Normalise any `mcp__godcrm__foo` prefix the agent may include verbatim.
const bareName = name.replace(/^mcp__[^_]+__/, '');
// 1. Hardcoded denylist — write-capable tools refused always.
for (const sub of MCP_DENY_SUBSTRINGS) {
if (bareName.startsWith(sub) || bareName.includes(sub)) {
return { ok: false, reason: `mcp-write-denied:${sub}` };
}
}
// 2. Per-test allowlist.
if (!Array.isArray(allowedTools) || allowedTools.length === 0) {
return { ok: false, reason: 'no mcp tools configured for this test' };
}
const match = allowedTools.some((t) => {
if (typeof t !== 'string' || t === '') return false;
const bareT = t.replace(/^mcp__[^_]+__/, '');
return bareT === bareName || t === name;
});
if (!match) return { ok: false, reason: `mcp tool not in allowlist: ${bareName}` };
return { ok: true };
}
// ---------------------------------------------------------------------------
export {
gateBash,
gateSql,
gateMcp,
// Exported for tests / audit.
BASH_DENY_PATTERNS,
MCP_DENY_SUBSTRINGS,
CURL_SAFE_ALLOW_PATTERN,
};
export default { gateBash, gateSql, gateMcp, BASH_DENY_PATTERNS, MCP_DENY_SUBSTRINGS, CURL_SAFE_ALLOW_PATTERN };