diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 8f0794bdf..049591d00 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -530,7 +530,7 @@ function wrapperValueOption(arg, valueFlags) { return null; } -// Explicit external-launcher argv grammars used only by the dd classifier. +// Explicit external-launcher argv grammars for dd and shell-wrapper discovery. // Unknown flags do not justify guessing which later argument executes. const DD_LAUNCHER_OPTIONS = { xargs: { @@ -735,7 +735,7 @@ function isDestructiveQuoteAware(raw, depth = 0) { if (isDestructiveDd(tokens)) return true; if (isDestructiveSqlClient(tokens)) return true; if (isDestructiveFindExec(tokens)) return true; - const argv = unwrapLeadWrappers(tokens); + const argv = unwrapLeadWrappers(tokens, true, true); if (SHELL_WRAPPERS.has(commandBasename(argv[0]))) { const ci = argv.indexOf('-c', 1); if (ci !== -1 && argv[ci + 1] && isDestructiveQuoteAware(argv[ci + 1], depth + 1)) { @@ -763,7 +763,8 @@ function commandBasename(token) { } /** - * Detect a `dd` invocation carrying an `if=` operand. + * Detect a `dd` invocation carrying an `if=` or `of=` operand. + * Keep the existing input-file gate and include output-only writes from stdin. * * Token-based rather than a regex arm because the verdict has to depend on * `dd` being the command, not on `dd if=` appearing anywhere in the line: @@ -779,7 +780,7 @@ function commandBasename(token) { */ function isDestructiveDd(tokens, allowShellBuiltins = true) { const argv = unwrapLeadWrappers(tokens, allowShellBuiltins, true); - return commandBasename(argv[0]) === 'dd' && argv.slice(1).some(operand => /^if=/i.test(operand)); + return commandBasename(argv[0]) === 'dd' && argv.slice(1).some(operand => /^(?:if|of)=/i.test(operand)); } /** diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 9d85cd61f..426e4efbe 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -185,6 +185,18 @@ function runDdRegressionTests() { }; const destructive = [ 'dd if=/dev/zero of=/dev/sda', + 'dd of=/dev/sda bs=1M', + 'cat /dev/zero | dd of=/dev/sda', + 'sudo dd of=/dev/sda < /dev/zero', + 'dd bs=1M of="./output"', + "timeout 60 bash -c 'dd if=/dev/zero of=/dev/sda'", + "nohup sh -c 'dd if=input'", + "nice -n 5 sh -c 'dd if=input'", + "xargs sh -c 'dd if=input'", + "timeout 2 sh -c 'dd of=output'", + "nohup sh -c 'echo $(dd of=output)'", + "nice -n 5 sh -c 'cat < { fs.rmSync(stateDir, { recursive: true, force: true }); @@ -617,6 +641,7 @@ function runTests() { if ( test(`denies dd whose input path is not word-initial: ${command}`, () => { const result = runBashHook({ tool_name: 'Bash', tool_input: { command } }); + assert.strictEqual(result.code, 0, `hook should exit successfully for ${command}`); const output = parseOutput(result.stdout); assert.ok(output, 'hook should produce JSON output'); assert.ok(output.hookSpecificOutput, 'hook should return a permission decision');