diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 6756a0b79..049591d00 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -53,11 +53,15 @@ const ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT = const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']); const ECC_ENABLE_VALUES = new Set(['1', 'true', 'on', 'enabled', 'enable', 'yes']); -// SQL-keyword + dd patterns stay as a single regex — they are stable -// phrases without shell-flag ordering concerns. Quoted strings are -// stripped before this regex runs so a commit message mentioning -// "drop table" no longer triggers a false positive. -const DESTRUCTIVE_SQL_DD = /\b(drop\s+table|delete\s+from|truncate|dd\s+if=)\b/i; +// SQL keywords remain a phrase check. Quoted strings are stripped before +// this regex runs so a commit message mentioning "drop table" stays passive. +// `dd if=` used to be a fourth arm here. Matching it as text could not work: +// the arm ended in `=`, so the shared trailing \b required the NEXT character +// to be a word character and `dd if=/dev/zero` slipped through while +// `echo dd if=x` — which runs no dd at all — was gated. The boundary decided +// the verdict instead of the command position, so dd moved to isDestructiveDd() +// alongside the other token-based detectors (#2642). +const DESTRUCTIVE_SQL = /\b(drop\s+table|delete\s+from|truncate)\b/i; // Operator-supplied additional destructive patterns. Lazily compiled from // `GATEGUARD_BASH_EXTRA_DESTRUCTIVE` (regex source) on first use, then @@ -319,14 +323,28 @@ function quoteAwareSegments(input) { words = []; }; - for (const ch of String(input || '')) { + const source = String(input || ''); + for (let i = 0; i < source.length; i += 1) { + const ch = source[i]; if (escaped) { current += ch; hasWord = true; escaped = false; continue; } - if (ch === '\\') { + if (ch === '\\' && quote !== "'") { + const next = source[i + 1]; + // Single quotes preserve every backslash; double quotes only escape + // shell-special characters. env -S must receive those literal bytes. + if (quote === '"' && next && !['$', '`', '"', '\\', '\n'].includes(next)) { + current += ch; + hasWord = true; + continue; + } + if (next === '\n') { + i += 1; + continue; + } escaped = true; hasWord = true; continue; @@ -421,65 +439,253 @@ const SUDO_VALUE_FLAGS = new Set([ '--command-timeout', ]); +const DOAS_VALUE_FLAGS = new Set(['-u', '-C']); +const EXEC_VALUE_FLAGS = new Set(['-a']); +const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir', '-a', '--argv0', '-S', '--split-string']); +const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/; + /** - * Advance past `sudo`/`doas`/`env` wrappers including their flags and - * `VAR=value` assignments, so `sudo -u postgres psql ...` and - * `env PGUSER=postgres psql ...` still resolve to the real command. + * Split one literal env -S argument into argv, never into shell programs. + * Operators, substitutions and variable spellings stay literal text; no host + * environment is read. A dynamic executable name therefore remains opaque. + * Unterminated quotes, unknown escapes and invalid quoted \c return null. + * This is bounded literal parsing, not GNU env variable interpolation. + * + * @param {string} source + * @returns {string[] | null} + */ +function splitEnvWords(source) { + const words = []; + let word = ''; + let hasWord = false; + let quote = null; + const flush = () => { + if (hasWord) words.push(word); + word = ''; + hasWord = false; + }; + const escapes = { f: '\f', n: '\n', r: '\r', t: '\t', v: '\v' }; + for (let i = 0; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '\\' && quote !== "'") { + const next = source[++i]; + if (next === undefined) return null; + if (next === 'c') { + if (quote) return null; + flush(); + return words; + } + if (next === '_') { + if (quote) { + word += ' '; + hasWord = true; + } else flush(); + continue; + } + if (Object.prototype.hasOwnProperty.call(escapes, next)) word += escapes[next]; + else if (['#', '$', '"', "'", '\\'].includes(next)) word += next; + else return null; + hasWord = true; + continue; + } + if (quote) { + if (ch === quote) quote = null; + else word += ch; + hasWord = true; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + hasWord = true; + } else if (ch === '#' && !hasWord) { + break; + } else if (/\s/.test(ch)) { + flush(); + } else { + word += ch; + hasWord = true; + } + } + if (quote) return null; + flush(); + return words; +} + +/** Locate a value-taking flag, including the tail of a short-option cluster. */ +function wrapperValueOption(arg, valueFlags) { + if (arg.startsWith('--')) { + const separator = arg.indexOf('='); + const name = separator === -1 ? arg : arg.slice(0, separator); + return valueFlags.has(name) + ? { name, value: separator === -1 ? undefined : arg.slice(separator + 1) } + : null; + } + if (!arg.startsWith('-')) return null; + for (let i = 1; i < arg.length; i += 1) { + const name = `-${arg[i]}`; + if (valueFlags.has(name)) { + return { name, value: i + 1 < arg.length ? arg.slice(i + 1) : undefined }; + } + } + return null; +} + +// 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: { + values: new Set(['-a', '--arg-file', '-d', '--delimiter', '-E', '-I', '-J', '-L', '-n', '--max-args', '-P', '--max-procs', '-s', '--max-chars', '--process-slot-var']), + optional: new Set(['-e', '--eof', '-i', '--replace', '-l', '--max-lines']), + flags: new Set(['-0', '--null', '-r', '--no-run-if-empty', '-t', '--verbose', '-p', '--interactive', '-x', '--exit', '-o', '--open-tty', '--show-limits']) + }, + timeout: { + values: new Set(['-k', '--kill-after', '-s', '--signal']), + optional: new Set(), + flags: new Set(['-v', '--verbose', '--foreground', '--preserve-status']) + }, + nice: { values: new Set(['-n', '--adjustment']), optional: new Set(), flags: new Set() }, + nohup: { values: new Set(), optional: new Set(), flags: new Set() } +}; + +/** Return the command position after one explicitly supported launcher's options. */ +function ddLauncherCommandIndex(argv, index, name) { + const { values, optional, flags } = DD_LAUNCHER_OPTIONS[name]; + index += 1; + while (index < argv.length) { + const arg = argv[index]; + if (arg === '--') { + index += 1; + break; + } + if (!arg.startsWith('-') || arg === '-') break; + // nice retains the historical -N / --N priority spellings. + if (name === 'nice' && /^--?\d+$/.test(arg)) { + index += 1; + continue; + } + if (arg.startsWith('--')) { + const separator = arg.indexOf('='); + const flag = separator === -1 ? arg : arg.slice(0, separator); + if (values.has(flag)) index += separator === -1 ? 2 : 1; + else if (optional.has(flag) || (separator === -1 && flags.has(flag))) index += 1; + else return argv.length; + continue; + } + let consumesNext = false; + for (let offset = 1; offset < arg.length; offset += 1) { + const flag = `-${arg[offset]}`; + if (values.has(flag)) { + consumesNext = offset + 1 === arg.length; + break; + } + if (optional.has(flag)) break; + if (!flags.has(flag)) return argv.length; + } + index += consumesNext ? 2 : 1; + } + // timeout's duration is data, followed by exactly one executable position. + return name === 'timeout' ? index + 1 : index; +} + +/** + * Resolve leading assignments, shell prefixes and sudo/doas/env into command + * argv. Wrapper-specific option values never become executable names. Every + * wrapper/split consumes source bytes, so the input-size budget bounds nested + * expansion without rejecting a valid long chain at an arbitrary depth. * * @param {string[]} tokens dequoted tokens for one segment - * @returns {number} index of the real command token + * @param {boolean} [allowShellBuiltins] false for external argv (e.g. find -exec) + * @param {boolean} [allowDdLaunchers] opt-in; other shared callers retain their grammar + * @returns {string[]} normalized argv, or [] when no literal command resolves */ -function unwrapLeadWrappers(tokens) { +function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers = false) { + let argv = tokens.slice(); let index = 0; - for (let guard = 0; guard < 4; guard += 1) { - if (index >= tokens.length) return index; - const base = commandBasename(tokens[index]); - if (base === 'sudo' || base === 'doas') { + let allowAssignments = true; + let budget = tokens.reduce((size, token) => size + token.length + 1, 1); + while (index < argv.length && budget-- > 0) { + while (allowAssignments && index < argv.length && SHELL_ASSIGNMENT.test(argv[index])) index += 1; + if (index >= argv.length) return []; + const base = commandBasename(argv[index]); + if (allowShellBuiltins && base === 'command') { index += 1; - while (index < tokens.length) { - const flag = tokens[index]; + while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { + const flag = argv[index++]; + if (flag === '--') break; + // -v/-V (including -pv) only describe names; no command executes. + if (!/^-[pVv]+$/.test(flag) || /[vV]/.test(flag)) return []; + } + allowAssignments = false; + continue; + } + if (allowShellBuiltins && base === 'exec') { + index += 1; + while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { + const flag = argv[index]; + if (flag === '--') { + index += 1; + break; + } + const option = wrapperValueOption(flag, EXEC_VALUE_FLAGS); + const flagLetters = option ? flag.slice(1, flag.indexOf('a')) : flag.slice(1); + if (!/^[cl]*$/.test(flagLetters)) return []; + index += option && option.value === undefined ? 2 : 1; + } + // exec replaces the shell with an external executable; its argument + // 'command' is not the shell's builtin, and A=1 is not an assignment. + allowShellBuiltins = false; + allowAssignments = false; + continue; + } + if (allowDdLaunchers && Object.prototype.hasOwnProperty.call(DD_LAUNCHER_OPTIONS, base)) { + index = ddLauncherCommandIndex(argv, index, base); + allowShellBuiltins = false; + allowAssignments = false; + continue; + } + if (base === 'sudo' || base === 'doas') { + allowShellBuiltins = false; + allowAssignments = true; + const valueFlags = base === 'sudo' ? SUDO_VALUE_FLAGS : DOAS_VALUE_FLAGS; + index += 1; + while (index < argv.length) { + const flag = argv[index]; if (flag === '--') { index += 1; break; } if (flag === '-' || !flag.startsWith('-')) break; - if (SUDO_VALUE_FLAGS.has(flag)) { - index += 2; - continue; - } - if (/^--[^=]+=.*$/.test(flag)) { - index += 1; - continue; - } - index += 1; + const option = wrapperValueOption(flag, valueFlags); + index += option && option.value === undefined ? 2 : 1; } continue; } if (base === 'env') { + allowShellBuiltins = false; + allowAssignments = true; index += 1; - while (index < tokens.length) { - const arg = tokens[index]; - if (arg === '--' || arg === '-' || arg === '-i' || arg === '--ignore-environment') { + while (index < argv.length) { + const arg = argv[index]; + if (arg === '--') { index += 1; + break; + } + const option = wrapperValueOption(arg, ENV_VALUE_FLAGS); + if (option && (option.name === '-S' || option.name === '--split-string')) { + const separate = option.value === undefined; + const source = separate ? argv[index + 1] : option.value; + if (source === undefined || budget-- <= 0) return []; + const expanded = splitEnvWords(source); + if (!expanded) return []; + argv = [...expanded, ...argv.slice(index + (separate ? 2 : 1))]; + index = 0; continue; } - if (arg === '-u' || arg === '--unset') { - index += 2; + if (option) { + index += option.value === undefined ? 2 : 1; continue; } - if (arg === '-C' || arg === '--chdir') { - index += 2; - continue; - } - if (/^--unset=.*$/.test(arg) || /^--chdir=.*$/.test(arg) || /^--argv0=.*$/.test(arg)) { - index += 1; - continue; - } - if (arg.startsWith('-') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) { - index += 1; - continue; - } - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) { + if (arg.startsWith('-') || SHELL_ASSIGNMENT.test(arg)) { index += 1; continue; } @@ -487,9 +693,9 @@ function unwrapLeadWrappers(tokens) { } continue; } - break; + return argv.slice(index); } - return index; + return []; } /** @@ -502,10 +708,9 @@ function unwrapLeadWrappers(tokens) { */ function isDestructiveSqlClient(tokens) { if (!tokens || tokens.length === 0) return false; - const start = unwrapLeadWrappers(tokens); - if (start >= tokens.length) return false; - if (!SQL_CLIENT_COMMANDS.has(commandBasename(tokens[start]))) return false; - return DESTRUCTIVE_SQL_DD.test(stripSqlLiterals(tokens.slice(start).join(' '))); + const argv = unwrapLeadWrappers(tokens); + if (!SQL_CLIENT_COMMANDS.has(commandBasename(argv[0]))) return false; + return DESTRUCTIVE_SQL.test(stripSqlLiterals(argv.join(' '))); } /** @@ -519,18 +724,23 @@ function isDestructiveSqlClient(tokens) { */ function isDestructiveQuoteAware(raw, depth = 0) { if (depth > 4) return false; - for (const tokens of quoteAwareSegments(raw)) { - if (tokens.length === 0) continue; - if (isDestructiveRm(tokens)) return true; - if (isDestructiveGit(tokens)) return true; - if (isDestructiveSqlClient(tokens)) return true; - if (isDestructiveFindExec(tokens.join(' '))) return true; - const wi = unwrapLeadWrappers(tokens); - const base = wi < tokens.length ? commandBasename(tokens[wi]) : ''; - if (SHELL_WRAPPERS.has(base)) { - const ci = tokens.indexOf('-c', wi); - if (ci !== -1 && tokens[ci + 1] && isDestructiveQuoteAware(tokens[ci + 1], depth + 1)) { - return true; + // The outer command was preprocessed already; shell -c introduces a new + // program whose literal heredoc data must also stay outside execution. + const executable = depth === 0 ? raw : stripHeredocBodies(raw); + for (const body of collectExecutableBodies(executable)) { + for (const tokens of quoteAwareSegments(body)) { + if (tokens.length === 0) continue; + if (isDestructiveRm(tokens)) return true; + if (isDestructiveGit(tokens)) return true; + if (isDestructiveDd(tokens)) return true; + if (isDestructiveSqlClient(tokens)) return true; + if (isDestructiveFindExec(tokens)) return true; + 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)) { + return true; + } } } } @@ -552,6 +762,27 @@ function commandBasename(token) { .toLowerCase(); } +/** + * 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: + * `echo dd if=/dev/zero` executes nothing. dd operands are order-free, so + * `dd of=/dev/sda if=/dev/zero` counts too — a text pattern anchored on + * `dd\s+if=` missed that spelling entirely. + * + * Leading `sudo` / `doas` / `env`, their flags, and `VAR=value` assignment + * prefixes are skipped so `sudo dd if=/dev/zero` stays the dd invocation it is. + * + * @param {string[]} tokens + * @returns {boolean} + */ +function isDestructiveDd(tokens, allowShellBuiltins = true) { + const argv = unwrapLeadWrappers(tokens, allowShellBuiltins, true); + return commandBasename(argv[0]) === 'dd' && argv.slice(1).some(operand => /^(?:if|of)=/i.test(operand)); +} + /** * Detect `rm` invocations that recursively force-delete files. Handles * combined (`-rf`, `-fr`, `-Rf`) and split (`-r -f`) flag forms. @@ -878,87 +1109,79 @@ function collectExecutableBodies(raw) { return bodies; } +// Find predicates consume their arguments even when a value spells '-exec'. +const FIND_VALUE_PREDICATES = new Set([ + '-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex', + '-type', '-xtype', '-maxdepth', '-mindepth', '-mtime', '-mmin', '-atime', '-amin', + '-ctime', '-cmin', '-newer', '-anewer', '-cnewer', '-used', '-uid', '-gid', '-user', + '-group', '-perm', '-size', '-inum', '-links', '-fstype', '-context', '-lname', '-ilname', + '-printf', '-fprint', '-fprint0', '-fls', '-samefile', '-files0-from', '-regextype' +]); +const FIND_EXEC_ACTIONS = new Set(['-exec', '-execdir', '-ok', '-okdir']); + /** - * Detect destructive commands inside `find ... -exec` invocations. - * Handles `-exec rm {} \;`, `-exec rm -rf {} \;`, `-exec rmdir {} \;`, - * `-exec unlink {} \;`, `-exec git reset --hard {} \;`. + * Inspect each find executable action without mistaking its argv for another + * action. -ok/-okdir still run the command after their own confirmation, so + * they retain the explicit destructive gate. A + terminates exec/execdir only + * after {}; elsewhere it remains an ordinary argument. * - * @param {string} command + * @param {string | string[]} command raw segment or already dequoted argv * @returns {boolean} */ function isDestructiveFindExec(command) { - const raw = String(command || ''); - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } + const quoteAware = Array.isArray(command); + const tokens = quoteAware ? command : tokenize(String(command || '').trim()); + if (commandBasename(tokens[0]) !== 'find') 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; + for (let index = 1; index < tokens.length; index += 1) { + const action = tokens[index]; + if (action === '-fprintf') { + index += 2; + continue; } - 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; + if (FIND_VALUE_PREDICATES.has(action) || /^-newer[a-zA-Z]{2}$/.test(action)) { + index += 1; + continue; + } + if (!FIND_EXEC_ACTIONS.has(action)) continue; + const execTokens = []; + for (index += 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === ';' || token === '\\;' || ( + token === '+' && (action === '-exec' || action === '-execdir') && + execTokens[execTokens.length - 1] === '{}' + )) break; + execTokens.push(token); + } + if (execTokens.length === 0) continue; + // The legacy raw fallback can split quoted prose into apparent actions. + // Preserve its old rm/Git coverage, but classify dd only from real argv. + if (quoteAware && isDestructiveDd(execTokens, false)) return true; + const baseCmd = commandBasename(execTokens[0]); + // Preserve main's existing rm/rmdir/unlink and git-reset handling. + if (baseCmd === 'rm' || baseCmd === 'rmdir' || baseCmd === 'unlink') return true; + 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 + // SQL phrases live in command bodies, not as flag-bearing + // arguments, so we still match them by regex - but on the input // after quoting AND subshell delimiters are normalized so phrases // inside `$(...)` or backticks are also caught. const raw = String(command || ''); + // Keep main's heredoc stripping: a phrase inside a heredoc body is data, not a + // command. dd is no longer part of this regex — see DESTRUCTIVE_SQL. const executable = stripHeredocBodies(raw); const flattened = explodeSubshells(stripQuotedStrings(executable)); - if (DESTRUCTIVE_SQL_DD.test(flattened)) return true; + if (DESTRUCTIVE_SQL.test(flattened)) return true; // Operator-supplied additional destructive patterns. Same scope as the - // built-in SQL/dd regex: matched against the quote-stripped, subshell- + // built-in SQL regex: matched against the quote-stripped, subshell- // exploded command so a phrase inside `$(...)` or backticks is caught. const extra = getExtraDestructiveRegex(); if (extra && extra.test(flattened)) return true; @@ -982,7 +1205,7 @@ function isDestructiveBash(command) { const segments = bodies.flatMap(splitCommandSegments); for (const segment of segments) { const stripped = stripQuotedStrings(segment); - if (DESTRUCTIVE_SQL_DD.test(stripped)) return true; + if (DESTRUCTIVE_SQL.test(stripped)) return true; if (extra && extra.test(stripped)) return true; const tokens = tokenize(segment); if (isDestructiveRm(tokens)) return true; diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 9e6a18334..426e4efbe 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -155,6 +155,342 @@ function loadDirectHook(env = {}) { return require(hookScript); } + +// Fast, pure classification matrix. These strings are input data, never shell commands. +function runDdRegressionTests() { + const environment = { + GATEGUARD_STATE_DIR: stateDir, + CLAUDE_SESSION_ID: TEST_SESSION_ID, + GATEGUARD_DISABLED: '', + ECC_GATEGUARD: 'on', + GATEGUARD_BASH_EXTRA_DESTRUCTIVE: '', + GATEGUARD_BASH_ROUTINE_DISABLED: '1', + GATEGUARD_EXEMPT_GLOBS: '', + ECC_HOOKS_ENABLED: 'true', + ECC_HOOK_PROFILE: 'standard', + ECC_DISABLED_HOOKS: '', + ECC_DRY_RUN: '0', + ECC_HOOK_CONFIG: path.join(stateDir, 'no-managed-config.json'), + CLAUDE_PLUGIN_ROOT: path.resolve(__dirname, '../..'), + ECC_PLUGIN_ROOT: path.resolve(__dirname, '../..') + }; + const original = Object.fromEntries(Object.keys(environment).map(key => [key, process.env[key]])); + Object.assign(process.env, environment); + let hook; + let passed = 0; + let failed = 0; + const check = (name, fn) => { + if (test(name, fn)) passed++; + else failed++; + }; + 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 < { + assert.deepStrictEqual(hook.classifyDestructiveCommand('Bash', command), [ + 'gateguard.bash-compatible-destructive' + ]); + }); + } + for (const command of passive) { + check(`dd/preservation passive: ${JSON.stringify(command)}`, () => { + assert.deepStrictEqual(hook.classifyDestructiveCommand('Bash', command), []); + }); + } + for (const [command, denied] of [ + ['sudo -u root dd if=input', true], + ["sh -c 'echo $(dd if=input)'", true], + ['sudo -u dd echo if=input', false], + ["env -S 'echo ok; dd if=input'", false], + ['find . -exec echo {} \\; -exec dd if=input \\;', true], + ['command -pv dd', false], + ["timeout 2 sh -c 'dd if=input'", true], + ['cat /dev/zero | dd of=/dev/sda', true] + ]) { + check(`dd hook-input contract: ${command}`, () => { + fs.rmSync(stateDir, { recursive: true, force: true }); + fs.mkdirSync(stateDir, { recursive: true }); + const input = { tool_name: 'Bash', tool_input: { command } }; + const result = spawnSync(process.execPath, [runner, 'pre:bash:gateguard-fact-force', + 'scripts/hooks/gateguard-fact-force.js', 'standard,strict'], { + input: JSON.stringify(input), encoding: 'utf8', timeout: 3000, + env: { ...process.env, ...environment }, stdio: ['pipe', 'pipe', 'pipe'] + }); + assert.ifError(result.error); + assert.strictEqual(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + if (denied) { + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + assert.match(output.hookSpecificOutput.permissionDecisionReason, /Destructive/); + } else { + assert.deepStrictEqual(output, input, 'allow must be actual JSON pass-through, not silence'); + } + }); + } + check('main batch warning and invisible-path sanitizer stay intact', () => { + fs.rmSync(stateDir, { recursive: true, force: true }); + fs.mkdirSync(stateDir, { recursive: true }); + const result = hook.run({ tool_name: 'Write', tool_input: { file_path: '/src/a\u0091b\u200bc.js' } }); + assert.strictEqual(result.exitCode, 0); + const output = JSON.parse(result.stdout).hookSpecificOutput; + assert.strictEqual(output.permissionDecision, 'deny'); + assert.match(output.permissionDecisionReason, /parallel batch/); + assert.ok(!output.permissionDecisionReason.includes('\u0091')); + assert.ok(!output.permissionDecisionReason.includes('\u200b')); + assert.match(output.permissionDecisionReason, /c\.js/); + }); + check('disabled hook remains silent through the routing wrapper', () => { + const result = spawnSync(process.execPath, [runner, 'pre:bash:gateguard-fact-force', + 'scripts/hooks/gateguard-fact-force.js', 'standard,strict'], { + input: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'dd if=input' } }), + encoding: 'utf8', timeout: 3000, + env: { ...process.env, ...environment, ECC_DISABLED_HOOKS: 'pre:bash:gateguard-fact-force' }, + stdio: ['pipe', 'pipe', 'pipe'] + }); + assert.ifError(result.error); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, ''); + }); + return { passed, failed }; + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + for (const [key, value] of Object.entries(original)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + delete require.cache[require.resolve(hookScript)]; + } +} + function runTests() { console.log('\n=== Testing gateguard-fact-force ===\n'); @@ -281,6 +617,91 @@ function runTests() { passed++; else failed++; + // --- Test 4b: dd targets that do not start with a word character --- + /** + * #2642: DESTRUCTIVE_SQL_DD carried one trailing \b across every alternation + * arm. `dd\s+if=` ends in `=`, so that \b demanded the NEXT character be a + * word character: `dd if=x` was denied while the disk-wipe spelling + * `dd if=/dev/zero of=/dev/sda` and the relative `dd if=./img` were allowed. + * These run through the real hook, since the report is specifically that the + * published hook lets the slash-prefixed form through. + */ + for (const command of [ + 'dd if=/dev/zero of=/dev/sda', + 'dd if=./disk.img of=/dev/sdb', + 'dd if="/dev/zero" of=/dev/sda', + // Wrapped invocations must still resolve to the dd command word. + 'sudo dd if=/dev/zero of=/dev/sda', + // dd operands are order-free; a text pattern anchored on `dd if=` missed + // both the reversed and the intervening-option spellings. + 'dd of=/dev/sda if=/dev/zero', + 'dd bs=1M if=/dev/zero of=/dev/sda' + ]) { + clearState(); + 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'); + assert.strictEqual( + output.hookSpecificOutput.permissionDecision, + 'deny', + `${command} must be gated as destructive` + ); + assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Destructive')); + }) + ) + passed++; + else failed++; + } + + // --- Test 4c: widening the dd arm must not gate ordinary commands --- + /** + * SQL keywords retain their word boundaries; dd is checked only at command + * position. `truncated`, `add if=` and prose mentioning dd stay passive. + */ + for (const command of [ + 'echo add if=1', + 'echo truncated output', + 'git status', + // `dd if=` as another command's argument runs no dd at all. The old text + // match gated these; the command-word check is what keeps them out. + 'echo dd if=/dev/zero', + 'grep dd if=/dev/zero file', + 'echo dd if=x' + ]) { + clearState(); + if ( + test(`does not gate as destructive: ${command}`, () => { + // Prime the session so the separate first-command routine gate cannot + // be mistaken for a destructive denial. + runBashHook({ tool_name: 'Bash', tool_input: { command: 'printf ready' } }); + const result = runBashHook({ tool_name: 'Bash', tool_input: { command } }); + // Assert the hook actually answered before reading the decision: a + // crashed or silent hook makes parseOutput return null, and a bare + // `if (output)` would let this case pass without testing anything. + assert.strictEqual(result.code, 0, `hook should exit 0 for ${command}`); + const output = parseOutput(result.stdout); + assert.ok(output, `hook should produce JSON output for ${command}`); + const decision = output.hookSpecificOutput; + if (decision) { + const reason = decision.permissionDecisionReason || ''; + assert.ok( + decision.permissionDecision !== 'deny' || !reason.includes('Destructive'), + `${command} must not be gated as destructive` + ); + } else { + // Pass-through echoes the input back unchanged. + assert.strictEqual(output.tool_name, 'Bash', 'pass-through should preserve input'); + } + }) + ) + passed++; + else failed++; + } + // --- Test 5: denies first routine Bash, allows second --- clearState(); if ( @@ -3397,8 +3818,17 @@ function runTests() { failed++; } + const ddResults = runDdRegressionTests(); + passed += ddResults.passed; + failed += ddResults.failed; console.log(`\n ${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0); } -runTests(); +if (process.argv.includes('--dd-only')) { + const { passed, failed } = runDdRegressionTests(); + console.log(`\n ${passed} passed, ${failed} failed\n`); + process.exitCode = failed > 0 ? 1 : 0; +} else { + runTests(); +}