/** * 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; }