diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index fa5faead..19d8b01a 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -545,10 +545,20 @@ function isDestructiveBash(command) { const extra = getExtraDestructiveRegex(); if (extra && extra.test(flattened)) return true; - // Check for destructive find -exec patterns - if (isDestructiveFindExec(raw)) return true; + // Check for destructive find -exec patterns on raw body segments (before quote-stripping) + // so that quoted exec binaries and compound-command prefixes are both handled correctly. + // splitCommandSegments strips quotes before splitting, so passing its output to + // isDestructiveFindExec would turn `find . -exec 'rm' {} \;` into `find . -exec {} \;` + // — the binary name disappears and the check returns false. Using raw body text avoids + // that false-negative while also catching `&&`, `;`, `|`, and `||` compound forms. + const bodies = collectExecutableBodies(raw); + for (const body of bodies) { + for (const rawSeg of body.split(/[;|&]+/).map(s => s.trim()).filter(Boolean)) { + if (isDestructiveFindExec(rawSeg)) return true; + } + } - const segments = collectExecutableBodies(raw).flatMap(splitCommandSegments); + const segments = bodies.flatMap(splitCommandSegments); for (const segment of segments) { const stripped = stripQuotedStrings(segment); if (DESTRUCTIVE_SQL_DD.test(stripped)) return true; diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 610b782a..93dfd7ea 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -1613,6 +1613,26 @@ function runTests() { 'find -exec git reset --hard'); })) passed++; else failed++; + if (test('denies find -exec rm {} \\; preceded by && (bypass via compound command)', () => { + expectDestructiveDeny('echo x && find . -exec rm {} \\;', + 'compound command bypass: find -exec rm'); + })) passed++; else failed++; + + if (test('denies find -exec rm -rf {} \\; preceded by ; (bypass via semicolon)', () => { + expectDestructiveDeny('true; find . -name "*.log" -exec rm -rf {} \\;', + 'semicolon bypass: find -exec rm -rf'); + })) passed++; else failed++; + + if (test('denies find -exec rm {} \\; in pipeline (bypass via pipe)', () => { + expectDestructiveDeny('echo start | find . -exec rm {} \\;', + 'pipe bypass: find -exec rm'); + })) passed++; else failed++; + + if (test('denies find -exec rm {} \\; after || (OR-chain bypass)', () => { + expectDestructiveDeny('false || find . -exec rm {} \\;', + 'OR-chain bypass: find -exec rm'); + })) 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 {} \\;' } };