|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * Danger classifier for autopilot (see the run_command gate in agent.js). |
| 3 | + * |
| 4 | + * In autopilot the agent runs shell commands WITHOUT asking — except the ones this flags, which |
| 5 | + * still show the approval card. This is a SECURITY gate, so it is deliberately biased toward flagging: |
| 6 | + * a false positive costs one extra prompt; a false negative auto-runs something irreversible. When in |
| 7 | + * doubt, flag. Matching is word-boundaried and scans the WHOLE command (so `echo hi && rm -rf x` and |
| 8 | + * `xargs rm` are caught wherever the dangerous token sits), accepting that a dangerous word inside a |
| 9 | + * quoted string ("rm is scary") will over-flag — that is the safe direction. |
| 10 | + * |
| 11 | + * Scope (user-chosen "Deletion + irreversible"): file deletion, discarding uncommitted work, plus a |
| 12 | + * small set of hard-to-undo, high-blast-radius ops — sudo, force-push / history rewrite, piping a |
| 13 | + * remote script into a shell, irreversible publishes, and writes into system directories. Pure + |
| 14 | + * dependency-free so the boundary is unit-testable (test/commandSafety.test.js) without booting the extension. |
| 15 | + * |
| 16 | + * Known limitations (by design — this is a good-faith guardrail, not a sandbox): it matches command |
| 17 | + * strings, so it does NOT catch deletion smuggled through an interpreter (`node -e fs.rmSync(...)`, |
| 18 | + * `python -c shutil.rmtree(...)`), a bare truncating redirect (`> important.txt`), or a command |
| 19 | + * deliberately obfuscated to evade it. Prompt injection that steers the model into one of those forms |
| 20 | + * can therefore reach the shell unprompted in autopilot. It defends against the common case — an |
| 21 | + * obviously-destructive command the model emits in good faith — not against an adversary evading it. |
| 22 | + *--------------------------------------------------------------------------------------------*/ |
| 23 | +'use strict'; |
| 24 | + |
| 25 | +// Each rule: [category, regex]. Ordered so the most specific/telling category wins the report. |
| 26 | +const RULES = [ |
| 27 | + // --- file deletion --- |
| 28 | + ['deletion', /\brm\b/i], // rm / git rm / sudo rm / xargs rm (any form) |
| 29 | + ['deletion', /\brmdir\b/i], |
| 30 | + ['deletion', /\bunlink\b/i], |
| 31 | + ['deletion', /\bshred\b/i], |
| 32 | + ['deletion', /\brimraf\b/i], // the idiomatic Node recursive delete — no \brm\b boundary inside "rimraf" |
| 33 | + ['deletion', /\bgit\s+clean\b/i], // -f/-d/-x wipe untracked files |
| 34 | + ['deletion', /\bfind\b[\s\S]*?-delete\b/i], |
| 35 | + ['deletion', /\bfind\b[\s\S]*?-exec\s+rm\b/i], |
| 36 | + ['deletion', /\btruncate\b/i], // -s 0 empties a file |
| 37 | + ['deletion', /\bdd\b/i], // disk-destroyer |
| 38 | + ['deletion', /\bmkfs\b/i], |
| 39 | + ['deletion', />\s*\/dev\/(sd|disk|nvme|null\/)/i], // redirect over a device node |
| 40 | + |
| 41 | + // --- discarding uncommitted work (same irreversible effect as reset --hard; NOT in the reflog) --- |
| 42 | + ['discard-changes', /\bgit\s+reset\s+--hard\b/i], // discards the working tree |
| 43 | + // `git checkout` that targets a path/HEAD/force (not a branch switch, which is safe): |
| 44 | + ['discard-changes', /\bgit\s+checkout\b[^&|;\n]*(\s--(\s|$)|\s\.(\s|$)|\bHEAD\b|--force\b|\s-f\b)/i], |
| 45 | + // `git restore <path>` overwrites the working tree; `git restore --staged` only unstages (safe) → excluded: |
| 46 | + ['discard-changes', /\bgit\s+restore\b(?![^\n&|;]*--staged)/i], |
| 47 | + |
| 48 | + // --- irreversible / high blast radius --- |
| 49 | + ['sudo', /\bsudo\b/i], |
| 50 | + ['sudo', /\bdoas\b/i], |
| 51 | + ['force-push', /\bgit\s+push\b[\s\S]*?(--force\b|--force-with-lease\b|--mirror\b|\s-f\b)/i], |
| 52 | + ['history-rewrite', /\bgit\s+filter-(branch|repo)\b/i], |
| 53 | + ['history-rewrite', /\bgit\s+reflog\s+expire\b/i], |
| 54 | + ['history-rewrite', /\bgit\s+gc\b[\s\S]*?--prune/i], |
| 55 | + ['remote-exec', /\b(curl|wget|fetch)\b[\s\S]*?\|\s*(sudo\s+)?(sh|bash|zsh|ksh|fish|python3?|node|ruby|perl)\b/i], |
| 56 | + ['publish', /\b(npm|yarn|pnpm)\s+publish\b/i], |
| 57 | + |
| 58 | + // --- writes that escape the project into system dirs --- |
| 59 | + ['system-write', />>?\s*\/(etc|usr|bin|sbin|System|Library|var|boot|opt)\b/i], |
| 60 | + ['system-write', /\b(rm|mv|cp|chmod|chown|tee)\b[\s\S]*?\s\/(etc|usr|bin|sbin|System|boot)\b/i], |
| 61 | +]; |
| 62 | + |
| 63 | +/** |
| 64 | + * Classify a shell command for the autopilot gate. |
| 65 | + * @param {string} command |
| 66 | + * @returns {{ dangerous: boolean, category: string|null }} |
| 67 | + */ |
| 68 | +function classifyCommand(command) { |
| 69 | + const s = String(command || ''); |
| 70 | + for (const [category, re] of RULES) { |
| 71 | + if (re.test(s)) { return { dangerous: true, category }; } |
| 72 | + } |
| 73 | + return { dangerous: false, category: null }; |
| 74 | +} |
| 75 | + |
| 76 | +/** Convenience boolean wrapper. */ |
| 77 | +function isDangerousCommand(command) { |
| 78 | + return classifyCommand(command).dangerous; |
| 79 | +} |
| 80 | + |
| 81 | +/** Short human label for the approval card ("why is autopilot still asking?"). */ |
| 82 | +function dangerLabel(category) { |
| 83 | + switch (category) { |
| 84 | + case 'deletion': return 'deletes files'; |
| 85 | + case 'discard-changes': return 'discards uncommitted changes'; |
| 86 | + case 'sudo': return 'runs as root (sudo)'; |
| 87 | + case 'force-push': return 'force-pushes / rewrites remote history'; |
| 88 | + case 'history-rewrite': return 'rewrites git history'; |
| 89 | + case 'remote-exec': return 'pipes a remote script into a shell'; |
| 90 | + case 'publish': return 'publishes a package'; |
| 91 | + case 'system-write': return 'writes outside the project'; |
| 92 | + default: return 'is potentially destructive'; |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +module.exports = { classifyCommand, isDangerousCommand, dangerLabel }; |
0 commit comments