Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
165 lines
6.8 KiB
JavaScript
165 lines
6.8 KiB
JavaScript
/**
|
|
* ADR-0053 Phase C1 — code-level CRITICAL_DENIES.
|
|
*
|
|
* These patterns are immutable: not editable via API, never overridden by any
|
|
* DB rule, never bypassed by `--dangerously-skip-permissions`. They guard the
|
|
* cases where a single mistaken tool-call could destroy the cluster:
|
|
*
|
|
* - filesystem nukes (rm -rf /)
|
|
* - fork bombs (resource exhaustion)
|
|
* - irreversible DB ops (DROP DATABASE)
|
|
* - irreversible git ops (force-push to main)
|
|
* - PROD restarts (kills the very process the hook runs in)
|
|
* - PROD-only writes (`make prod`, edits to dist/ or .env on the PROD host)
|
|
* - direct PROD DB access from a dev shell
|
|
*
|
|
* Matched against either `tool_name` alone (for MCP-style tools) or
|
|
* `tool_name:command-or-path` (for Bash / Edit / Write). The hook builds the
|
|
* subject string once and runs all patterns against it.
|
|
*
|
|
* NEVER edit a deny pattern out of this file to "unblock" an agent. If an
|
|
* agent legitimately needs to run something here, the answer is a human
|
|
* running it, not a deny removal.
|
|
*/
|
|
|
|
// Each entry: { id, subject, pattern (RegExp), reason }.
|
|
// `subject` selects what string we test:
|
|
// - 'bash' → tool_input.command (Bash tool only)
|
|
// - 'path' → tool_input.file_path (Edit/Write)
|
|
// - 'tool' → tool_name itself (for catch-all by tool)
|
|
export const CRITICAL_DENIES = Object.freeze([
|
|
// ── 1. Filesystem nukes ──────────────────────────────────────────────────
|
|
{
|
|
id: 'fs-rm-rf-root',
|
|
subject: 'bash',
|
|
// Matches: rm -rf / ; rm -rf /foo ; rm -rf ~ ; rm -rf ~/x ; rm -rf $HOME
|
|
// Avoids matching rm -rf /tmp/foo by anchoring `/` to end-of-string or
|
|
// requiring it to NOT be followed by `tmp/`, `home/`, etc — too brittle;
|
|
// instead allow any `/` target except a clearly-safe subtree.
|
|
pattern: /(^|[\s;&|])rm\s+(-[rRfv]+\s+)*(--no-preserve-root\s+)?(\/(\s|$|;|&)|\/(?!tmp\/|var\/log\/|var\/cache\/)[^\s]|\/\*|\/\.|\$HOME($|[\s/;&|])|~($|[\s/;&|]))/,
|
|
reason: 'Refused: rm -rf targeting root, $HOME, or ~ — destroys the host.',
|
|
},
|
|
|
|
// ── 2. Fork bombs / resource exhaustion ──────────────────────────────────
|
|
{
|
|
id: 'fs-fork-bomb',
|
|
subject: 'bash',
|
|
pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/,
|
|
reason: 'Refused: classic shell fork bomb (:(){ :|:& };:) — exhausts host.',
|
|
},
|
|
|
|
// ── 3. Irreversible DB ops ───────────────────────────────────────────────
|
|
{
|
|
id: 'db-drop-database',
|
|
subject: 'bash',
|
|
pattern: /\bDROP\s+DATABASE\b/i,
|
|
reason: 'Refused: DROP DATABASE — destroys the godcrm_prod cluster.',
|
|
},
|
|
{
|
|
id: 'db-drop-schema-cascade',
|
|
subject: 'bash',
|
|
pattern: /\bDROP\s+SCHEMA\s+\S+\s+CASCADE\b/i,
|
|
reason: 'Refused: DROP SCHEMA … CASCADE — cluster-wide data loss.',
|
|
},
|
|
|
|
// ── 4. Irreversible git ops on main ──────────────────────────────────────
|
|
{
|
|
id: 'git-force-push-main',
|
|
subject: 'bash',
|
|
pattern: /\bgit\s+push\s+([^&|;]*\s)?(--force\b|-f\b|--force-with-lease\b).*\bmain\b/,
|
|
reason: 'Refused: force-push to main — rewrites shared history.',
|
|
},
|
|
{
|
|
id: 'git-push-mirror',
|
|
subject: 'bash',
|
|
pattern: /\bgit\s+push\s+([^&|;]*\s)?--mirror\b/,
|
|
reason: 'Refused: git push --mirror — wipes remote refs.',
|
|
},
|
|
|
|
// ── 5. PROD process restarts (kills our own process group) ───────────────
|
|
{
|
|
id: 'pm2-restart-godcrm-prod',
|
|
subject: 'bash',
|
|
// Match pm2 restart/reload/stop/delete targeting godcrm (the running process)
|
|
pattern: /\bpm2\s+(restart|reload|stop|delete|kill)\b.*\bgodcrm\b/,
|
|
reason: 'Refused: pm2 restart/reload/stop/delete of godcrm — kills the worker that spawned this agent.',
|
|
},
|
|
{
|
|
id: 'systemctl-godcrm',
|
|
subject: 'bash',
|
|
pattern: /\bsystemctl\s+(restart|reload|stop|disable)\b.*\b(godcrm|business-crm|nginx|postgresql)\b/,
|
|
reason: 'Refused: systemctl restart of godcrm/nginx/postgres — service-level outage.',
|
|
},
|
|
|
|
// ── 6. PROD deploys & PROD-only writes ───────────────────────────────────
|
|
{
|
|
id: 'make-prod',
|
|
subject: 'bash',
|
|
pattern: /\bmake\s+prod\b/,
|
|
reason: 'Refused: `make prod` rebuilds + restarts PROD — only humans run this.',
|
|
},
|
|
{
|
|
id: 'edit-env',
|
|
subject: 'path',
|
|
pattern: /(^|\/)\.env(\.[a-z]+)?$/,
|
|
reason: 'Refused: editing .env — secrets/keys belong in Settings → Secrets (vault).',
|
|
},
|
|
{
|
|
id: 'edit-dist',
|
|
subject: 'path',
|
|
pattern: /(^|\/)dist\//,
|
|
reason: 'Refused: editing dist/ — build artifact, regenerated by `npm run build`.',
|
|
},
|
|
|
|
// ── 7. Dependency mutation in deploy posture ─────────────────────────────
|
|
{
|
|
id: 'npm-install-production',
|
|
subject: 'bash',
|
|
pattern: /\bnpm\s+(install|i|ci)\b[^&|;]*--production\b/,
|
|
reason: 'Refused: `npm install --production` — strips vite/plugin-react needed for build.',
|
|
},
|
|
|
|
// ── 8. Direct PROD DB access (use the API; never bypass with raw psql) ───
|
|
{
|
|
id: 'psql-prod-host',
|
|
subject: 'bash',
|
|
// `\b` doesn't match between space and `-` (both non-word), so use \s.
|
|
pattern: /(^|[\s;&|])psql\b[^&|;]*(\s-h|--host[= ])\s*(109\.107\.184\.205|crm\.hltrn\.cc)\b/,
|
|
reason: 'Refused: direct psql to PROD host — schema/data writes must go through /api/v3.',
|
|
},
|
|
]);
|
|
|
|
/**
|
|
* Build the test subject for a given (tool_name, tool_input) pair.
|
|
* Returns one of `{ bash, path, tool }` keys → string to match.
|
|
*
|
|
* For tools we don't know how to read, we still expose `tool` so a
|
|
* catch-all by tool name (e.g. mcp__claude_ai_*) can deny.
|
|
*/
|
|
export function buildSubjects(toolName, toolInput) {
|
|
const subjects = { tool: String(toolName || '') };
|
|
if (!toolInput || typeof toolInput !== 'object') return subjects;
|
|
if (typeof toolInput.command === 'string') subjects.bash = toolInput.command;
|
|
if (typeof toolInput.file_path === 'string') subjects.path = toolInput.file_path;
|
|
return subjects;
|
|
}
|
|
|
|
/**
|
|
* Test all CRITICAL_DENIES against a (tool_name, tool_input).
|
|
* Returns the first match or null. Order matches the array above.
|
|
*
|
|
* @param {string} toolName
|
|
* @param {object} toolInput
|
|
* @returns {{ id: string, reason: string }|null}
|
|
*/
|
|
export function matchCriticalDeny(toolName, toolInput) {
|
|
const subjects = buildSubjects(toolName, toolInput);
|
|
for (const rule of CRITICAL_DENIES) {
|
|
const subject = subjects[rule.subject];
|
|
if (subject == null) continue;
|
|
if (rule.pattern.test(subject)) {
|
|
return { id: rule.id, reason: rule.reason };
|
|
}
|
|
}
|
|
return null;
|
|
}
|