Merge reviewed contributor PR #2829 into backlog batch

Source-PR: https://github.com/affaan-m/ECC/pull/2829
Source-Head: d811349224

Local integration checkpoint; aggregate review and hosted acceptance pending.
This commit is contained in:
affaan-m
2026-09-27 23:36:51 -04:00
2 changed files with 779 additions and 126 deletions
+348 -125
View File
@@ -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;
+431 -1
View File
@@ -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 <<EOF\n$(dd of=output)\nEOF'",
'find . -exec dd of=output \\;',
'dd if=./image of=./out',
'dd of=./out bs=1M if="./image"',
"'/bin/dd' if=input of=output",
'sudo -u root dd if=input',
'command dd if=input of=output',
'xargs dd if=input of=output',
'xargs -- dd if=input',
'xargs -0 -I{} dd if=input',
'xargs -n 2 -P4 dd if=input',
'xargs -a input --delimiter=, dd if=input',
'xargs --max-args 2 dd if=input',
'xargs -e dd if=input',
'xargs -i dd if=input',
'xargs --replace dd if=input',
'xargs -J{} dd if=input',
'timeout 2 dd if=input of=output',
'timeout -k 1 -s TERM 2s dd if=input',
'timeout --kill-after=1 --signal=TERM --foreground 2 dd if=input',
'timeout -- 2 dd if=input',
'nice dd if=input of=output',
'nice -n 5 dd if=input',
'nice --adjustment=-5 dd if=input',
'nice -5 dd if=input',
'nice --5 dd if=input',
'nohup dd if=input of=output',
'nohup -- dd if=input',
'command nice -n 2 timeout 3 env A=1 dd if=input',
'command -p dd if=input',
'command -- dd if=input',
'exec dd if=input of=output',
'exec -a ddname dd if=input',
'exec -addname dd if=input',
'exec -cl dd if=input',
'exec -- dd if=input',
'A=1 command -p exec -a ddname dd if=input',
'sudo --user=root dd if=input',
'sudo -uroot dd if=input',
'sudo -nu root dd if=input',
'sudo -nuroot dd if=input',
'doas -nu root dd if=input',
'sudo -g staff dd if=input',
'sudo --group staff dd if=input',
'sudo -C 3 dd if=input',
'sudo -D /tmp dd if=input',
'sudo -- dd if=input',
'doas -u root dd if=input',
'doas -C /tmp/doas.conf dd if=input',
'env -u FOO dd if=input',
'env -uFOO dd if=input',
'env -C /tmp dd if=input',
'env -C/tmp dd if=input',
'env --argv0 ddname dd if=input',
'env -a ddname dd if=input',
'env -addname dd if=input',
'env --unset=FOO --chdir=/tmp --argv0=ddname dd if=input',
'A=1 env B=2 sudo -u root dd if=input',
'sudo A=1 dd if=input',
"env -S 'dd if=input of=output'",
"env --split-string='dd if=input'",
'env -Sdd if=input',
"env -iS 'dd if=input'",
'env -iuFOO dd if=input',
"env -S 'sudo -u root dd' if=input",
"env -S 'sh -c \"dd if=input\"'",
"env -S 'env -S \"dd if=input\"'",
"env -S 'dd\\_if=input'",
"env -S 'dd if=input # trailing comment'",
String.raw`env -S 'dd "if=input\_file"'`,
String.raw`env -S 'dd "if=input\"quote"'`,
'echo $(dd if=input of=output)',
'echo "$(dd if=./input)"',
'echo `dd if=input`',
'(dd if=input)',
'{ dd if=input; }',
'echo $({ (dd if=input); })',
"sh -c 'echo $(dd if=input)'",
"sh -c 'echo `dd if=input`'",
"sh -c '(dd if=input)'",
"sh -c 'cat <<EOF\n$(dd if=input)\nEOF'",
"sh -c 'sh <<EOF\ndd if=input\nEOF'",
'find . -exec dd if=input of=output \\;',
'printf note; find . -exec dd if=input \\;',
'echo "note; passive text"; find . -exec dd if=input \\;',
"sh -c 'printf note; find . -exec dd if=input \\;'",
'find . -exec sudo -u root dd if=input \\;',
'find . -exec echo {} \\; -exec dd if=input of=output \\;',
'find . -exec echo {} + -exec dd if=input \\;',
'find . -exec echo + -exec dd if=argument \\; -exec dd if=actual \\;',
'find . -execdir dd if=input \\;',
'find . -ok dd if=input \\;',
'find . -okdir dd if=input \\;',
'find . -name -exec -exec dd if=input \\;',
"find . -printf '-exec' -exec dd if=input \\;",
"find . -fprintf '-exec' '-exec' -exec dd if=input \\;",
['cat <<EOF', '$(dd if=input)', 'EOF'].join('\n'),
['sh <<EOF', 'dd if=input', 'EOF'].join('\n'),
["cat <<'EOF'", 'dd if=input', 'EOF', 'dd if=after'].join('\n'),
'sudo -u postgres psql -c "drop table users"',
"env -S 'sudo -u postgres psql' -c 'drop table users'",
"sh -c 'psql -c \"drop table users\"'",
'git push --force origin main',
'git push --force-with-lease origin main',
`${'env '.repeat(40)}dd if=input`,
'git reset --hard',
'git stash clear',
'git restore tracked.txt',
"find . -exec 'rm' {} \\;"
];
const passive = [
'echo dd if=input',
'echo dd of=/dev/sda',
'grep dd of=output file',
"printf '%s' 'dd of=output'",
'dd count=0',
"timeout 2 echo 'sh -c dd of=output'",
"timeout 2 sh -c 'echo \"dd of=output\"'",
"nohup sh -c 'cat <<EOF\ndd of=output\nEOF'",
"nice -n 5 echo 'dd of=output'",
"xargs echo 'sh -c dd of=output'",
'echo "note; find . -exec dd of=output \\;"',
'command -v dd',
'command -v dd if=input',
'command -V dd if=input',
'command -V dd',
'command -pv dd',
'command -pV dd if=input',
'command echo dd if=input',
'xargs echo dd if=input',
'xargs -I dd echo if=input',
'xargs -d dd echo if=input',
'xargs --arg-file dd echo if=input',
'xargs -edd echo if=input',
'xargs --replace=dd echo if=input',
'xargs --help dd if=input',
'timeout 2 echo dd if=input',
'timeout -s dd 2 echo if=input',
'timeout --help dd if=input',
'nice -n 2 echo dd if=input',
'nice --version dd if=input',
'nohup echo dd if=input',
'nohup --help dd if=input',
'xargs command dd if=input',
'timeout 2 command dd if=input',
'exec -a dd echo if=input',
'exec -add echo if=input',
'exec echo dd if=input',
'command A=1 dd if=input',
"echo 'command dd if=input'",
"echo 'exec dd if=input'",
'sudo command dd if=input',
'env command dd if=input',
'exec command dd if=input',
'grep dd if=/dev/zero file',
"printf '%s' 'dd if=input'",
'echo add if=1',
'echo truncated',
'sudo -u dd echo if=input',
'sudo --user=dd echo if=input',
'sudo -udd echo if=input',
'sudo -nu dd echo if=input',
'doas -nu dd echo if=input',
'env -iu dd echo if=input',
'doas -u dd echo if=input',
'env -u dd echo if=input',
'env -C dd echo if=input',
'env --argv0 dd echo if=input',
'env -a dd echo if=input',
'env -- -u dd if=input',
'A=dd echo if=input',
'echo sudo -u root dd if=input',
"env -S 'echo dd if=input'",
String.raw`env -S 'echo "dd if=input\"quote"'`,
"env -S 'echo ok; dd if=input'",
"env -S 'echo ok | dd if=input'",
"env -S 'echo ok & dd if=input'",
"env -S 'echo $(dd if=input)'",
"env -S 'echo `dd if=input`'",
"env -S 'echo # dd if=input'",
"env -S 'echo\\c dd if=input'",
"env -S 'echo \\$(dd if=input)'",
"echo 'env -S dd if=input'",
"env -S ''",
'env -S',
"env -S '\"dd if=input'",
"env -S 'dd if=input\\q'",
"env -S '${UNREAD_HOST_COMMAND} if=input'",
"echo '$(dd if=input)'",
"echo '`dd if=input`'",
"echo '(dd if=input)'",
"echo '{ dd if=input; }'",
"sh -c 'echo \"dd if=input\"'",
"sh -c 'cat <<EOF\ndd if=input\nEOF'",
["cat <<'EOF'", 'dd if=input; $(dd if=input)', 'EOF'].join('\n'),
['cat <<EOF', 'dd if=input', 'EOF'].join('\n'),
['cat <<EOF', '\\$(dd if=input)', 'EOF'].join('\n'),
'psql -c "SELECT \'drop table\' FROM audit_log"',
'echo "drop table users"',
'git commit -m "drop table users"',
'git push --force-with-lease origin feature-branch',
'echo "note; find . -exec dd if=input \\;"',
"printf '%s' 'note; find . -exec dd if=input \\;'",
'echo "note | find . -exec dd if=input \\;"',
'echo "note & find . -exec dd if=input \\;"',
"find . -name 'x -exec dd if=input' -print",
'find . -exec echo -exec dd if=input \\;',
'find . -exec echo + -exec dd if=input \\;',
"find . -exec echo '-exec dd if=input' \\;",
'find . -execdir echo dd if=input \\;',
'find . -ok echo -exec dd if=input \\;',
'find . -okdir echo dd if=input \\;',
'find . -exec command dd if=input \\;',
'git status',
'git diff --stat',
'git restore --staged tracked.txt'
];
try {
hook = loadDirectHook();
for (const command of destructive) {
check(`dd/preservation destructive: ${JSON.stringify(command)}`, () => {
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();
}