From a6ac0273e27f730cee3cc9fb257dff1ad7210b63 Mon Sep 17 00:00:00 2001 From: leoeletronics Date: Tue, 16 Jun 2026 02:58:50 -0300 Subject: [PATCH] fix: detect destructive find -exec commands in gateguard (#2267) * fix: detect destructive find exec commands in gateguard * chore: ignore aider local files --- .gitignore | 2 + scripts/hooks/gateguard-fact-force.js | 72 ++++++++++++++++++++++++ tests/hooks/gateguard-fact-force.test.js | 43 ++++++++++++++ 3 files changed, 117 insertions(+) diff --git a/.gitignore b/.gitignore index 2485c9f3..826994d3 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,5 @@ ecc2/target/ .opencode/package-lock.json .opencode/node_modules/ assets/images/security/badrudi-exploit.mp4 + +.aider* diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index d28d818c..30a589c1 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -461,6 +461,75 @@ function collectExecutableBodies(raw) { return bodies; } +/** + * Detect destructive commands inside `find ... -exec` invocations. + * Handles `-exec rm {} \;`, `-exec rm -rf {} \;`, `-exec rmdir {} \;`, + * `-exec unlink {} \;`, `-exec git reset --hard {} \;`. + * + * @param {string} command + * @returns {boolean} + */ +function isDestructiveFindExec(command) { + const raw = String(command || ''); + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + + // Tokenize the whole command line + const tokens = tokenize(trimmed); + if (!tokens || tokens.length === 0) { + return false; + } + + // Must start with `find` + if (commandBasename(tokens[0]) !== 'find') { + return false; + } + + // Find the `-exec` token + const execIndex = tokens.indexOf('-exec'); + if (execIndex === -1) { + return false; + } + + // Collect tokens after `-exec` until we hit a terminator (`;`, `\;`, or `+`) + const execTokens = []; + for (let i = execIndex + 1; i < tokens.length; i++) { + const token = tokens[i]; + if (token === ';' || token === '\\;' || token === '+') { + break; + } + execTokens.push(token); + } + + if (execTokens.length === 0) { + return false; + } + + const baseCmd = commandBasename(execTokens[0]); + + // Directly destructive commands inside -exec + if (baseCmd === 'rmdir' || baseCmd === 'unlink') { + return true; + } + + // `rm` with any flags (including none) inside -exec is destructive + if (baseCmd === 'rm') { + return true; + } + + // `git reset --hard` inside -exec + if (baseCmd === 'git') { + const sub = findGitSubcommand(execTokens); + if (sub && sub.command === 'reset' && sub.rest.includes('--hard')) { + return true; + } + } + + return false; +} + function isDestructiveBash(command) { // The SQL/dd phrases live in command bodies, not as flag-bearing // arguments, so we still match them by regex — but on the input @@ -476,6 +545,9 @@ function isDestructiveBash(command) { const extra = getExtraDestructiveRegex(); if (extra && extra.test(flattened)) return true; + // Check for destructive find -exec patterns + if (isDestructiveFindExec(raw)) return true; + const segments = collectExecutableBodies(raw).flatMap(splitCommandSegments); for (const segment of segments) { const stripped = stripQuotedStrings(segment); diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index f6b31739..759a3a97 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -1586,6 +1586,49 @@ function runTests() { 'custom phrase inside command substitution should be gated'); })) passed++; else failed++; + // --- find -exec destructive detection --- + + if (test('denies find -exec rm {} \\; as destructive', () => { + expectDestructiveDeny('find . -name "*.tmp" -exec rm {} \\;', + 'find -exec rm'); + })) passed++; else failed++; + + if (test('denies find -exec rm -rf {} \\; as destructive', () => { + expectDestructiveDeny('find . -name "*.tmp" -exec rm -rf {} \\;', + 'find -exec rm -rf'); + })) passed++; else failed++; + + if (test('denies find -exec rmdir {} \\; as destructive', () => { + expectDestructiveDeny('find . -name "*.tmp" -exec rmdir {} \\;', + 'find -exec rmdir'); + })) passed++; else failed++; + + if (test('denies find -exec unlink {} \\; as destructive', () => { + expectDestructiveDeny('find . -name "*.tmp" -exec unlink {} \\;', + 'find -exec unlink'); + })) passed++; else failed++; + + if (test('denies find -exec git reset --hard {} \\; as destructive', () => { + expectDestructiveDeny('find . -name "*.tmp" -exec git reset --hard {} \\;', + 'find -exec git reset --hard'); + })) passed++; else failed++; + + if (test('allows find -exec echo {} \\; (non-destructive, routine gate)', () => { + clearState(); + const input = { tool_name: 'Bash', tool_input: { command: 'find . -name "*.tmp" -exec echo {} \\;' } }; + const result = runBashHook(input); + assert.strictEqual(result.code, 0, 'exit code should be 0'); + const output = parseOutput(result.stdout); + assert.ok(output, 'should produce JSON output'); + // Should be denied by routine gate (first bash), not destructive gate + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny', + 'should be denied by routine gate'); + assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('Destructive'), + 'should not be the destructive deny message'); + assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('rollback'), + 'should not mention rollback'); + })) passed++; else failed++; + // --- Issue #2078 review fix: warning emitted once per *distinct* // invalid regex, not once per process. Verifies the same-process // path that the reviewers (CodeRabbit + cubic) flagged.