Files
ECC/scripts/hooks/gateguard-fact-force.js
T

2575 lines
92 KiB
JavaScript

#!/usr/bin/env node
/**
* PreToolUse Hook: GateGuard Fact-Forcing Gate
*
* Forces Claude to investigate before editing files or running commands.
* Instead of asking "are you sure?" (which LLMs always answer "yes"),
* this hook demands concrete facts: importers, public API, data schemas.
*
* The act of investigation creates awareness that self-evaluation never did.
*
* Gates:
* - Edit/Write/MultiEdit/NotebookEdit (first touch): questions chosen by target class
* - Bash/PowerShell (destructive): list targets, rollback plan, quote instruction
* - Bash/PowerShell (routine): quote current instruction (once per session)
*
* Compatible with run-with-flags.js via module.exports.run().
* Cross-platform (Windows, macOS, Linux).
*
* Full package with config support: pip install gateguard-ai
* Repo: https://github.com/zunoworks/gateguard
*/
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } = require('../lib/shell-substitution');
const { classifyPowerShellDestructiveCommand } = require('../lib/powershell-destructive-command');
const { stripHeredocBodies } = require('./gateguard-heredoc');
// --- Lazily loaded modules ---
// see docs/gateguard/design-notes.md#lazy-loading
function lazily(load) {
let loaded = null;
return () => loaded || (loaded = load());
}
const targetClass = lazily(() => require('../lib/gateguard-target-class'));
const turnScan = lazily(() => require('../lib/gateguard-turn-scan'));
const changeProfile = lazily(() => require('../lib/gateguard-change-profile'));
const metricsLib = lazily(() => require('../lib/gateguard-metrics'));
const readOnlyShell = lazily(() => require('../lib/gateguard-readonly-shell').createReadOnlyShell({ quoteAwareSegments }));
const searchEvidence = lazily(() =>
require('../lib/gateguard-search-evidence').createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS })
);
const questionIdsFor = (...args) => targetClass().questionIdsFor(...args);
const questionText = (...args) => targetClass().questionText(...args);
const condensedHintFor = (...args) => targetClass().condensedHintFor(...args);
const resolveTargetPath = (...args) => targetClass().resolveTargetPath(...args);
const canonicalPathKey = (...args) => targetClass().canonicalPathKey(...args);
const classifyTargetFor = (...args) => targetClass().classifyTargetFor(...args);
const collapseGateDir = (...args) => targetClass().collapseGateDir(...args);
const isSensitiveTargetFor = (...args) => targetClass().isSensitiveTargetFor(...args);
const isHardLinkedTargetFor = (...args) => targetClass().isHardLinkedTargetFor(...args);
const createTurnScanner = (...args) => turnScan().createTurnScanner(...args);
const currentTurnId = (...args) => turnScan().currentTurnId(...args);
const transcriptPathFor = (...args) => turnScan().transcriptPathFor(...args);
const profileChange = (...args) => changeProfile().profileChange(...args);
const appendMetrics = (...args) => metricsLib().appendMetrics(...args);
const metricsEvent = (...args) => metricsLib().metricsEvent(...args);
const isReadOnlyShellCommand = (...args) => readOnlyShell().isReadOnlyShellCommand(...args);
const hasEntries = list => Array.isArray(list) && list.length > 0;
const findCreditingSearch = (scan, filePath, allowDirMatch, data) =>
scan && hasEntries(scan.searches) && (allowDirMatch || turnScan().searchesMayNameTarget(scan, filePath))
? searchEvidence().findCreditingSearch(scan, filePath, allowDirMatch, data)
: null;
const findClosestMiss = (scan, filePath, allowDirMatch, data) =>
scan && (hasEntries(scan.reads) || (hasEntries(scan.searches) && (allowDirMatch || turnScan().searchesMayNameTarget(scan, filePath))))
? searchEvidence().findClosestMiss(scan, filePath, allowDirMatch, data)
: null;
const {
getDenialCount,
getCreditedCount,
getCapAllowCount,
getTrivialAllowCount,
getRoutineReadonlyPassCount,
getSiblingAllowCount,
getClassCounts,
mergeClassCounts,
incrementClassCount,
withEntry,
dirGateKey,
getDirGates,
mergeDirGates,
capDirGates
} = require('../lib/gateguard-state');
// Session state — scoped per session to avoid cross-session races.
const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
let activeStateFile = null;
let activeSessionKey = '';
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
const SESSION_ID_TIMEOUT_MS = 8 * 60 * 60 * 1000;
const PROJECT_KEY_PREFIX = 'proj-';
let activeIdleWindowMs = SESSION_TIMEOUT_MS;
const READ_HEARTBEAT_MS = 60 * 1000;
// Maximum checked entries to prevent unbounded growth
const MAX_CHECKED_ENTRIES = 500;
const MAX_SESSION_KEYS = 50;
const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
const EDIT_WRITE_HOOK_ID = 'pre:edit-write:gateguard-fact-force';
const BASH_HOOK_ID = 'pre:bash:gateguard-fact-force';
const POWERSHELL_HOOK_ID = 'pre:powershell:gateguard-fact-force';
const EDIT_WRITE_NARROW_RECOVERY_HINT =
'Narrow recovery: add a matching path glob to `GATEGUARD_EXEMPT_GLOBS` to skip first-touch Edit/Write checks without disabling destructive Bash checks.';
const ROUTINE_BASH_NARROW_RECOVERY_HINT =
'Narrow recovery: set `GATEGUARD_BASH_ROUTINE_DISABLED=1`; destructive Bash checks remain active.';
const ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT =
'Narrow recovery: set `GATEGUARD_BASH_ROUTINE_DISABLED=1`; destructive Bash and PowerShell checks remain active.';
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
const ECC_ENABLE_VALUES = new Set(['1', 'true', 'on', 'enabled', 'enable', 'yes']);
// 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
// memoized keyed by the env-var value so a test or long-running process
// that flips the env between calls re-reads it without paying for a
// recompile on every invocation. A malformed regex is treated as
// "not configured" (the gate falls back to the built-in patterns) and
// the parse failure is logged once via `[gateguard-fact-force]` to
// stderr — hooks must never crash tool execution because of operator
// config errors.
let extraDestructiveCacheKey = null;
let extraDestructiveCacheRegex = null;
let extraDestructiveWarnLogged = false;
function getExtraDestructiveRegex() {
const raw = process.env.GATEGUARD_BASH_EXTRA_DESTRUCTIVE || '';
if (!raw) {
extraDestructiveCacheKey = '';
extraDestructiveCacheRegex = null;
return null;
}
if (raw === extraDestructiveCacheKey) {
return extraDestructiveCacheRegex;
}
// The env value just changed; reset the once-per-pattern warning gate
// so a subsequent *different* invalid regex is also reported once. The
// previous shape kept the flag sticky and silently swallowed the
// second bad pattern in a long-running process.
extraDestructiveCacheKey = raw;
extraDestructiveWarnLogged = false;
try {
extraDestructiveCacheRegex = new RegExp(raw, 'i');
} catch (err) {
extraDestructiveCacheRegex = null;
if (!extraDestructiveWarnLogged) {
try {
process.stderr.write(`[gateguard-fact-force] ignoring invalid GATEGUARD_BASH_EXTRA_DESTRUCTIVE regex: ${err.message}\n`);
} catch (_) {
/* stderr write failure is non-fatal */
}
extraDestructiveWarnLogged = true;
}
}
return extraDestructiveCacheRegex;
}
// Operator-supplied path exemptions. Comma-separated globs (`GATEGUARD_EXEMPT_GLOBS`)
// matched against the normalized project-relative path (or full path for an
// explicitly absolute glob). First-touch
// fact-forcing is skipped for a matching Edit/Write/MultiEdit/NotebookEdit target — intended for
// low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports
// this / what schema" carries no signal. Memoized on the env value; malformed
// patterns are dropped without granting exemptions. `*` matches within a path segment,
// `**` across segments, `?` a single char.
let exemptCacheKey = null;
let exemptCacheRegexes = null;
function getExemptMatchers() {
const raw = process.env.GATEGUARD_EXEMPT_GLOBS || '';
if (raw === exemptCacheKey) {
return exemptCacheRegexes;
}
exemptCacheKey = raw;
exemptCacheRegexes = raw
.split(',')
.map(s => normalizeForMatch(s.trim()))
.filter(Boolean)
.map(glob => {
let source = '';
for (let index = 0; index < glob.length; index++) {
const char = glob[index];
if (char === '*' && glob[index + 1] === '*') {
index++;
if (glob[index + 1] === '/') {
source += '(?:.*/)?';
index++;
} else source += '.*';
} else if (char === '*') source += '[^/]*';
else if (char === '?') source += '[^/]';
else source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
try {
return { regex: new RegExp(`^${source}$`), absolute: path.posix.isAbsolute(glob) || path.win32.isAbsolute(glob) };
} catch (_) {
return null;
}
})
.filter(Boolean);
return exemptCacheRegexes;
}
function isExemptPath(filePath, data) {
const projectRoot = process.env.CLAUDE_PROJECT_DIR || data.cwd || process.cwd();
if (typeof projectRoot !== 'string' || typeof filePath !== 'string') return false;
const paths = targetClass().WINDOWS_PATH_PATTERN.test(projectRoot) ? path.win32 : path.posix;
if (!paths.isAbsolute(projectRoot)) return false;
const target = paths.resolve(projectRoot, filePath);
const relative = paths.relative(projectRoot, target);
const contained = relative !== '..' && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative);
return getExemptMatchers().some(({ regex, absolute }) =>
absolute ? regex.test(normalizeForMatch(target)) : contained && regex.test(normalizeForMatch(relative))
);
}
function isRoutineBashGateDisabled() {
return ECC_ENABLE_VALUES.has(normalizeEnvValue(process.env.GATEGUARD_BASH_ROUTINE_DISABLED));
}
/**
* Strip the contents of single- and double-quoted strings so phrases
* mentioned inside a commit message or echoed argument do not trigger
* the destructive detector. Command substitutions are scanned separately
* before this runs because they execute even inside double quotes.
*
* @param {string} input
* @returns {string}
*/
function stripQuotedStrings(input) {
return input.replace(/'(?:[^'\\]|\\.)*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""');
}
/**
* Promote subshell delimiters to top-level segment separators so the
* destructive check applies inside `$(...)` and backtick subshells.
* Without this, `echo y | $(rm -rf /tmp)` and ``echo y | `rm -rf /tmp` ``
* slip past the segment splitter because the destructive command lives
* inside a sub-expression. Run iteratively to handle a layer of nesting.
*
* @param {string} input
* @returns {string}
*/
function explodeSubshells(input) {
let out = input;
for (let i = 0; i < 4; i += 1) {
const before = out;
out = out.replace(/\$\(([^()`]*)\)/g, ';$1;');
out = out.replace(/`([^`]*)`/g, ';$1;');
if (out === before) break;
}
return out;
}
/**
* Split a command line into top-level segments at unquoted shell
* separators (`;`, `|`, `&`, `&&`, `||`) and across subshells
* (`$(...)` / backticks). Quoted strings are stripped first so
* separators inside quotes are not split on. Per-segment comments
* are also stripped.
*
* @param {string} input
* @returns {string[]}
*/
function splitCommandSegments(input) {
const stripped = explodeSubshells(stripQuotedStrings(input));
return stripped
.split(/[;|&]+/)
.map(segment => segment.replace(/(^|\s)#.*/, '$1').trim())
.filter(Boolean);
}
/**
* Tokenize a single command segment by whitespace. Quoted strings
* are already collapsed to empty quotes by `stripQuotedStrings`, so
* naive whitespace splitting is sufficient.
*
* @param {string} segment
* @returns {string[]}
*/
function tokenize(segment) {
return segment.split(/\s+/).filter(Boolean);
}
/**
* Tokenize a short allowlisted shell command while preserving quoted
* arguments. This is intentionally smaller than a full shell parser: the
* caller rejects shell control characters before invoking it, so this only
* needs to keep spaces inside quotes together for read-only git commands.
*
* @param {string} input
* @returns {string[] | null}
*/
function tokenizeAllowlistedShellWords(input) {
const tokens = [];
let current = '';
let quote = null;
let escaped = false;
for (const char of String(input || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = '';
}
continue;
}
current += char;
}
if (escaped) current += '\\';
if (quote) return null;
if (current) tokens.push(current);
return tokens;
}
const SHELL_SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']);
// Keep only the lexical information needed for Bash's reserved word `time`.
// Quoted/escaped `time` is an external command, not a shell pipeline prefix.
const SHELL_TIME_TOKENS = new WeakMap();
/**
* Quote-aware split of a command line into segments, with quotes removed from
* the resulting words. Splits only on UNQUOTED `;`, `|`, `&`, and newlines so:
* - a quoted command word (`'rm'`, `"rm"`) normalizes to `rm` (the shell
* treats quotes around a command name as transparent), and
* - a newline behaves as a command separator (the shell runs each line),
* neither of which `stripQuotedStrings` + naive splitting handles — both were
* destructive-classifier bypasses (GHSA-4v57-ph3x-gf55).
*
* @param {string} input
* @returns {string[][]} array of dequoted token arrays, one per segment
*/
function quoteAwareSegments(input) {
const segments = [];
let words = [];
let timeTokens = new Set();
let current = '';
let hasWord = false;
let literalWord = true;
let quote = null;
let escaped = false;
const flushWord = () => {
if (hasWord) {
if (literalWord && ['time', '-p', '--'].includes(current)) timeTokens.add(words.length);
words.push(current);
}
current = '';
hasWord = false;
literalWord = true;
};
const flushSegment = () => {
flushWord();
if (words.length) {
if (timeTokens.size) SHELL_TIME_TOKENS.set(words, timeTokens);
segments.push(words);
}
words = [];
timeTokens = new Set();
};
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 === '\\' && 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;
}
literalWord = false;
escaped = true;
hasWord = true;
continue;
}
if (quote) {
if (ch === quote) quote = null;
else current += ch;
hasWord = true;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
literalWord = false;
hasWord = true; // entering a quote starts a word, even if its content is empty
continue;
}
if (SHELL_SEGMENT_SEPARATORS.has(ch)) {
flushSegment();
continue;
}
if (/\s/.test(ch)) {
flushWord();
continue;
}
current += ch;
hasWord = true;
}
flushSegment();
return segments;
}
const SHELL_WRAPPERS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh']);
/**
* SQL clients whose `-c`/`-e`/positional arguments carry SQL statements.
* Quoted SQL (e.g. `psql -c "drop table users"`) is invisible to the
* quote-stripping SQL regex, so it is re-checked here against dequoted
* tokens where quoted content is preserved (issue #3024). Restricted to
* known clients so `git commit -m "drop table"` and `echo "drop table"`
* stay allowed.
*/
const SQL_CLIENT_COMMANDS = new Set([
'psql',
'postgres',
'mysql',
'mariadb',
'sqlite3',
'sqlite',
'sqlcmd',
'isql',
'pgcli',
'mycli',
'duckdb',
'bq',
]);
/**
* Strip SQL string literals so phrases inside query data do not trigger
* the destructive detector (e.g. `SELECT 'drop table' ...` is a read).
* Handles single-quoted literals with '' escapes, double-quoted
* identifiers, and dollar-quoted blocks ($$...$$ and $tag$...$tag$).
*
* @param {string} input
* @returns {string}
*/
function stripSqlLiterals(input) {
return String(input || '')
.replace(/'(?:[^']|'')*'/g, "''")
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/(\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$)[\s\S]*?\1/g, '$$$$');
}
const SUDO_VALUE_FLAGS = new Set([
'-u',
'--user',
'-g',
'--group',
'-U',
'--other-user',
'-p',
'--prompt',
'-C',
'--close-from',
'-D',
'--chdir',
'-h',
'--host',
'-r',
'--role',
'-t',
'--type',
'-T',
'--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_]*=/;
/**
* 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, SQL clients and shell-wrapper discovery.
// Unknown flags do not justify guessing which later argument executes.
// This literal allowlist cannot prove arbitrary custom-wrapper semantics or
// resolve dynamically selected executables; quoted operand text stays data.
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() },
time: {
values: new Set(['-f', '--format', '-o', '--output-file']),
optional: new Set(),
flags: new Set(['-p', '--portability', '-a', '--append', '-q', '--quiet', '-v', '--verbose']),
nonCommand: new Set(['--help', '-V', '--version'])
},
stdbuf: {
values: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
optional: new Set(), flags: new Set()
},
ionice: {
values: new Set(['-c', '--class', '-n', '--classdata']),
optional: new Set(), flags: new Set(['-t', '--ignore']),
// These modes query/change existing processes rather than launch argv.
nonCommand: new Set(['-p', '--pid', '-P', '--pgid', '-u', '--uid'])
},
setsid: {
values: new Set(), optional: new Set(),
flags: new Set(['-c', '--ctty', '-f', '--fork', '-w', '--wait'])
},
taskset: {
values: new Set(), optional: new Set(),
flags: new Set(['-a', '--all-tasks', '-c', '--cpu-list'])
}
};
/** Return the command position after one explicitly supported launcher's options. */
function ddLauncherCommandIndex(argv, index, name) {
const { values, optional, flags, nonCommand } = DD_LAUNCHER_OPTIONS[name];
// GNU time uses getopt_long: unique prefixes resolve against its complete
// eight-option table, including terminating help/version. Other launchers
// retain their explicit spellings; this does not affect shell-keyword time.
const timeLongOptions = name === 'time'
? [...values, ...flags, ...nonCommand].filter(flag => flag.startsWith('--'))
: null;
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('=');
let flag = separator === -1 ? arg : arg.slice(0, separator);
if (timeLongOptions && !timeLongOptions.includes(flag)) {
const matches = timeLongOptions.filter(option => option.startsWith(flag));
if (matches.length !== 1) return argv.length;
[flag] = matches;
}
if (nonCommand && nonCommand.has(flag)) return argv.length;
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 (nonCommand && nonCommand.has(flag)) return argv.length;
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 and taskset's mask or CPU list are data, followed by
// exactly one executable position.
return name === 'timeout' || name === 'taskset' ? 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
* @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, allowShellBuiltins = true, allowDdLaunchers = false) {
let argv = tokens.slice();
let index = 0;
let allowAssignments = true;
let allowShellTime = allowShellBuiltins;
const timeTokens = SHELL_TIME_TOKENS.get(tokens);
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;
allowShellTime = false;
}
if (index >= argv.length) return [];
const base = commandBasename(argv[index]);
if (allowDdLaunchers && allowShellTime && argv[index] === 'time' && timeTokens && timeTokens.has(index)) {
// Current Bash accepts only raw -p and -- as reserved-time options.
// Quotes/escapes make them executable words, unlike external time argv.
// The next command/exec builtin or assignment keeps shell semantics.
index += 1;
if (argv[index] === '-p' && timeTokens.has(index)) index += 1;
if (argv[index] === '--' && timeTokens.has(index)) index += 1;
continue;
}
if (allowShellBuiltins && base === 'command') {
allowShellTime = false;
index += 1;
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') {
allowShellTime = false;
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);
allowShellTime = false;
allowShellBuiltins = false;
allowAssignments = false;
continue;
}
if (base === 'sudo' || base === 'doas') {
allowShellTime = false;
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;
const option = wrapperValueOption(flag, valueFlags);
index += option && option.value === undefined ? 2 : 1;
}
continue;
}
if (base === 'env') {
allowShellTime = false;
allowShellBuiltins = false;
allowAssignments = true;
index += 1;
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 (option) {
index += option.value === undefined ? 2 : 1;
continue;
}
if (arg.startsWith('-') || SHELL_ASSIGNMENT.test(arg)) {
index += 1;
continue;
}
break;
}
continue;
}
return argv.slice(index);
}
return [];
}
/**
* The command lines `su` may run through the target user's shell: the value of
* every `-c`/`--command`, or of a short-option cluster ending in `c` (`-lc`).
* `su` runs only the last one, so each is checked rather than guessing which
* wins. `su` passes the arguments after `--` to that shell, which runs a `-c`
* there too, so the scan does not stop at `--`.
*
* @param {string[]} argv command argv starting at `su`
* @returns {string[]}
*/
function suCommandLines(argv) {
const commandLines = [];
for (let i = 1; i < argv.length; i += 1) {
const arg = argv[i];
if (arg.startsWith('--command=')) {
commandLines.push(arg.slice('--command='.length));
} else if ((arg === '--command' || /^-[A-Za-z]*c$/.test(arg)) && i + 1 < argv.length) {
commandLines.push(argv[i + 1]);
i += 1;
}
}
return commandLines;
}
/**
* Detect destructive SQL passed as (possibly quoted) arguments to a known
* SQL client. Operates on dequoted tokens from `quoteAwareSegments`, so
* `psql -c "drop table users"` joins back to matchable text.
*
* @param {string[]} tokens dequoted tokens for one segment
* @returns {boolean}
*/
function isDestructiveSqlClient(tokens) {
if (!tokens || tokens.length === 0) return false;
const argv = unwrapLeadWrappers(tokens, true, true);
if (!SQL_CLIENT_COMMANDS.has(commandBasename(argv[0]))) return false;
return DESTRUCTIVE_SQL.test(stripSqlLiterals(argv.join(' ')));
}
/**
* Quote-aware destructive check: catches quoted command words, newline
* separators, quoted `find -exec`, and `sh -c`/`bash -c` wrappers that evade
* the quote-stripping path (GHSA-4v57-ph3x-gf55).
*
* Past the recursion limit the nested command is not visible, so the check
* fails closed: a guard that allowed it would be bypassed by one more level.
*
* @param {string} raw
* @param {number} [depth] recursion guard for shell -c wrappers
* @returns {boolean}
*/
function isDestructiveQuoteAware(raw, depth = 0) {
if (depth > 4) 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);
const base = commandBasename(argv[0]);
if (SHELL_WRAPPERS.has(base)) {
// `-c`, or a short-option cluster that includes it (`-lc`, `-ec`).
const ci = argv.findIndex((arg, i) => i > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(arg));
if (ci !== -1 && argv[ci + 1] && isDestructiveQuoteAware(argv[ci + 1], depth + 1)) {
return true;
}
}
if (base === 'su') {
for (const commandLine of suCommandLines(argv)) {
if (isDestructiveQuoteAware(commandLine, depth + 1)) return true;
}
}
}
}
return false;
}
/**
* Strip a leading path and trailing `.exe` from a command token so
* `/usr/bin/git`, `git.exe`, and `GIT` all normalize to `git`.
*
* @param {string} token
* @returns {string}
*/
function commandBasename(token) {
if (!token) return '';
return token
.replace(/^.*[\\/]/, '')
.replace(/\.exe$/i, '')
.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.
*
* @param {string[]} tokens
* @returns {boolean}
*/
function isDestructiveRm(tokens) {
if (tokens.length === 0 || commandBasename(tokens[0]) !== 'rm') return false;
let hasR = false;
let hasF = false;
for (const t of tokens.slice(1)) {
if (t === '--recursive') {
hasR = true;
continue;
}
if (t === '--force') {
hasF = true;
continue;
}
if (!t.startsWith('-') || t.startsWith('--')) continue;
const body = t.slice(1);
if (/[rR]/.test(body)) hasR = true;
if (/f/.test(body)) hasF = true;
}
return hasR && hasF;
}
/**
* Locate the git subcommand within a token list, skipping over git's
* global options like `-c key=value`, `-C <path>`, `--git-dir=...`,
* `--work-tree=...`, `--namespace=...`, `--super-prefix=...`.
*
* @param {string[]} tokens
* @returns {{ command: string, rest: string[] } | null}
*/
function findGitSubcommand(tokens) {
if (tokens.length === 0 || commandBasename(tokens[0]) !== 'git') return null;
const valueConsumingShort = new Set(['-c', '-C']);
const valueConsumingLong = new Set(['--git-dir', '--work-tree', '--namespace', '--super-prefix']);
let i = 1;
while (i < tokens.length) {
const t = tokens[i];
if (valueConsumingShort.has(t) || valueConsumingLong.has(t)) {
i += 2;
continue;
}
if (t.startsWith('--git-dir=') || t.startsWith('--work-tree=') || t.startsWith('--namespace=') || t.startsWith('--super-prefix=')) {
i += 1;
continue;
}
if (t.startsWith('-')) {
// Unknown global option — skip without consuming a value.
i += 1;
continue;
}
return { command: t.toLowerCase(), rest: tokens.slice(i + 1) };
}
return null;
}
/**
* Branch names treated as shared history: a forced update of one of
* these rewrites commits other clones build on, even when the push is
* lease-checked.
*/
const SHARED_GIT_BRANCHES = new Set(['main', 'master', 'develop', 'trunk']);
/**
* Decide whether the positional arguments of a `git push` name a shared
* branch as the destination of a refspec. The first positional token is
* the remote (unless the remote came from `--repo`); every later
* positional token is a refspec whose destination is the part after
* `:` (or the whole token when there is no `:`). A leading `+` force
* marker is stripped. When no refspec is given the target is the
* current branch, which the hook cannot know, so this returns false.
*
* @param {string[]} rest tokens after `push`
* @returns {boolean}
*/
function pushTargetsSharedBranch(rest) {
const valueConsuming = new Set(['-o', '--push-option', '--receive-pack', '--exec']);
const positional = [];
let remoteViaFlag = false;
for (let i = 0; i < rest.length; i++) {
const t = rest[i];
if (t === '--repo') {
remoteViaFlag = true;
i += 1;
continue;
}
if (t.startsWith('--repo=')) {
remoteViaFlag = true;
continue;
}
if (valueConsuming.has(t)) {
i += 1;
continue;
}
if (t.startsWith('-')) continue;
positional.push(t);
}
// Unless the remote came from --repo, positional[0] is the remote and
// the rest are refspecs.
const refspecs = remoteViaFlag ? positional : positional.slice(1);
for (const refspec of refspecs) {
const cleaned = refspec.startsWith('+') ? refspec.slice(1) : refspec;
const dst = cleaned.includes(':') ? cleaned.slice(cleaned.indexOf(':') + 1) : cleaned;
const branch = dst.startsWith('refs/heads/') ? dst.slice('refs/heads/'.length) : dst;
if (SHARED_GIT_BRANCHES.has(branch)) return true;
}
return false;
}
/**
* Detect destructive `git` invocations: `reset --hard`, `checkout --`,
* `clean -f...`, `push --force` (`--force-with-lease` only to a shared
* branch), `commit --amend`, `rm -rf`, `branch -D`, `stash drop` /
* `stash clear`, `reflog expire` / `reflog delete`, `update-ref -d`,
* and `restore` against the worktree.
*
* @param {string[]} tokens
* @returns {boolean}
*/
function isDestructiveGit(tokens) {
const sub = findGitSubcommand(tokens);
if (!sub) return false;
const { command, rest } = sub;
if (command === 'reset') {
return rest.includes('--hard');
}
if (command === 'checkout') {
// `git checkout -- <path>`, `git checkout .`, and the force forms
// (`--force` / `-f`) all discard uncommitted working-tree changes,
// mirroring the `switch` handler below.
return rest.some(t => {
if (t === '--' || t === '.' || t === '--force') return true;
if (!t.startsWith('-') || t.startsWith('--')) return false;
return t.slice(1).includes('f');
});
}
if (command === 'clean') {
// `git clean -f`, `-fd`, `-fdx`, `-df`, `--force`
return rest.some(t => {
if (t === '--force') return true;
if (!t.startsWith('-') || t.startsWith('--')) return false;
return t.slice(1).includes('f');
});
}
if (command === 'push') {
// Only `--force-with-lease` qualifies as a safety-checked force.
// `--force-if-includes` is a no-op when used WITHOUT
// `--force-with-lease` (per git-scm.com/docs/git-push), and when
// combined with a bare `--force` the bare force is still in effect.
// So `--force --force-if-includes` must be treated as destructive.
//
// A `+` refspec prefix (e.g. `git push origin +main`,
// `+refs/heads/main:refs/heads/main`) also forces a non-fast-forward
// update of that ref and is destructive on its own.
let withLease = false;
let bareForce = false;
let plusRefspecForce = false;
for (const t of rest) {
if (t === '--force-with-lease' || t.startsWith('--force-with-lease=')) {
withLease = true;
continue;
}
if (t === '--force' || t.startsWith('--force=')) {
bareForce = true;
continue;
}
if (t.startsWith('-') && !t.startsWith('--') && t.slice(1).includes('f')) {
bareForce = true;
continue;
}
// Refspec prefix: `+<src>[:<dst>]`. Match tokens like `+main`,
// `+refs/heads/main`, `+HEAD:branch`, `+:branch`. Exclude bare
// `+` and numeric-only `+123` which are not refspecs.
if (t.startsWith('+') && t.length > 1 && /^\+(?:[a-zA-Z_/.:]|HEAD)/.test(t)) {
plusRefspecForce = true;
}
}
if (bareForce || (plusRefspecForce && !withLease)) return true;
// A lease-checked force still rewrites a shared branch's history.
return withLease && pushTargetsSharedBranch(rest);
}
if (command === 'commit') {
return rest.includes('--amend');
}
if (command === 'rm') {
// `git rm -r` / `-rf` / `-r -f` — destructive within the index too.
let hasR = false;
for (const t of rest) {
if (!t.startsWith('-') || t.startsWith('--')) continue;
if (/[rR]/.test(t.slice(1))) hasR = true;
}
return hasR;
}
if (command === 'switch') {
// `git switch` can discard local working-tree changes in three forms:
// --discard-changes explicit discard
// --force / -f ignore conflicts and overwrite
// -C <branch> force-create (overwrites existing branch)
return rest.some(t => {
if (t === '--discard-changes' || t === '--force') return true;
if (!t.startsWith('-') || t.startsWith('--')) return false;
// Short combined form: -f, -fC, -Cf, -C
const body = t.slice(1);
return /[fC]/.test(body);
});
}
if (command === 'branch') {
// `git branch -D` (long spelling: `--delete --force`) deletes a
// branch even when it is unmerged, orphaning its commits. Plain
// `-d` refuses when unmerged, so it is safe to leave ungated.
let del = false;
let force = false;
for (const t of rest) {
if (t === '--delete') { del = true; continue; }
if (t === '--force') { force = true; continue; }
if (!t.startsWith('-') || t.startsWith('--')) continue;
const body = t.slice(1);
if (body.includes('D')) return true;
if (body.includes('d')) del = true;
if (body.includes('f')) force = true;
}
return del && force;
}
if (command === 'stash') {
// `drop` destroys one stash entry, `clear` the entire stash.
// `list`, `show`, `pop` and `apply` keep the entries recoverable.
return rest[0] === 'drop' || rest[0] === 'clear';
}
if (command === 'reflog') {
// `expire` and `delete` remove the recovery net that makes every
// other gated git command recoverable.
return rest[0] === 'expire' || rest[0] === 'delete';
}
if (command === 'update-ref') {
// `git update-ref -d <ref>` deletes a ref directly.
return rest.includes('-d') || rest.includes('--delete');
}
if (command === 'restore') {
// `git restore <path>` overwrites the working tree from the index
// by default, the modern spelling of gated `git checkout -- <path>`.
// Only `--staged` alone is non-destructive (it leaves the file on
// disk untouched); `--worktree` (the default target) is destructive.
const has = (long, short) => rest.some(t =>
t === long || (t.startsWith('-') && !t.startsWith('--') && t.slice(1).includes(short)));
const staged = has('--staged', 'S');
const worktree = has('--worktree', 'W');
return worktree || !staged;
}
return false;
}
/**
* Decide whether a bash command line contains a destructive action
* the fact-forcing gate should challenge. Combines SQL-keyword
* detection (regex on quote-stripped input) with per-segment shell
* tokenization for shell commands.
*
* @param {string} command
* @returns {boolean}
*/
/**
* Walk every executable body reachable from a raw command line and
* return them as a flat list. Bodies that bash will execute live in
* three different syntactic constructs, each handled by a sibling
* extractor in `scripts/lib/shell-substitution.js`:
* - `$(...)` and backticks via `extractCommandSubstitutions`
* - plain `(...)` subshells via `extractSubshellGroups`
* - `{ ...; }` brace groups via `extractBraceGroups`
*
* Each extractor recurses into its own syntax. The BFS here adds
* cross-syntax discovery — e.g. a `(...)` inside a `$(...)` body, or
* a `{ ...; }` inside a `(...)` body — by feeding every harvested
* body back through all three extractors. A `seen` set bounds the
* cost to O(unique bodies).
*
* @param {string} raw
* @returns {string[]}
*/
function collectExecutableBodies(raw) {
const bodies = [raw];
const queue = [raw];
const seen = new Set();
while (queue.length) {
const current = queue.shift();
if (seen.has(current)) continue;
seen.add(current);
for (const body of extractCommandSubstitutions(current)) {
if (seen.has(body)) continue;
bodies.push(body);
queue.push(body);
}
for (const body of extractSubshellGroups(current)) {
if (seen.has(body)) continue;
bodies.push(body);
queue.push(body);
}
for (const body of extractBraceGroups(current)) {
if (seen.has(body)) continue;
bodies.push(body);
queue.push(body);
}
}
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']);
/**
* 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 | string[]} command raw segment or already dequoted argv
* @returns {boolean}
*/
function isDestructiveFindExec(command) {
const quoteAware = Array.isArray(command);
const tokens = quoteAware ? command : tokenize(String(command || '').trim());
if (commandBasename(tokens[0]) !== 'find') return false;
for (let index = 1; index < tokens.length; index += 1) {
const action = tokens[index];
if (action === '-fprintf') {
index += 2;
continue;
}
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) {
// 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.test(flattened)) return true;
// Operator-supplied additional destructive patterns. Same scope as the
// 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;
// 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(executable);
for (const body of bodies) {
for (const rawSeg of body
.split(/[;|&]+/)
.map(s => s.trim())
.filter(Boolean)) {
if (isDestructiveFindExec(rawSeg)) return true;
}
}
const segments = bodies.flatMap(splitCommandSegments);
for (const segment of segments) {
const stripped = stripQuotedStrings(segment);
if (DESTRUCTIVE_SQL.test(stripped)) return true;
if (extra && extra.test(stripped)) return true;
const tokens = tokenize(segment);
if (isDestructiveRm(tokens)) return true;
if (isDestructiveGit(tokens)) return true;
}
// Quote-aware pass: closes the quoted-command-word, newline-separator,
// quoted-find-exec, and sh/bash -c bypasses (GHSA-4v57-ph3x-gf55).
if (isDestructiveQuoteAware(executable)) return true;
return false;
}
/**
* Return the stable, non-sensitive rule IDs that drive the destructive gate.
* PowerShell also passes through the existing Bash-compatible classifier so
* shell-agnostic git, SQL, and operator-configured rules retain coverage.
* Governance consumes this exact decision for PowerShell approval evidence.
*
* @param {string} toolName
* @param {string} command
* @returns {string[]}
*/
function classifyDestructiveCommand(toolName, command) {
const normalizedTool = String(toolName || '').toLowerCase();
if (normalizedTool !== 'bash' && normalizedTool !== 'powershell') return [];
const findings = [
...(isDestructiveBash(command) ? ['gateguard.bash-compatible-destructive'] : []),
...(normalizedTool === 'powershell' ? classifyPowerShellDestructiveCommand(command) : []),
];
return [...new Set(findings)];
}
// --- State management (per-session, atomic writes, bounded) ---
function normalizeEnvValue(value) {
return String(value || '')
.trim()
.toLowerCase();
}
function isGateGuardDisabled() {
if (normalizeEnvValue(process.env.GATEGUARD_DISABLED) === '1') {
return true;
}
return ECC_DISABLE_VALUES.has(normalizeEnvValue(process.env.ECC_GATEGUARD));
}
function sanitizeSessionKey(value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
const sanitized = raw.replace(/[^a-zA-Z0-9_-]/g, '_');
if (sanitized && sanitized.length <= 64) {
return sanitized;
}
return hashSessionKey('sid', raw);
}
function hashSessionKey(prefix, value) {
return `${prefix}-${crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 24)}`;
}
function resolveSessionKey(data) {
const directCandidates = [data && data.session_id, data && data.sessionId, data && data.session && data.session.id, process.env.CLAUDE_SESSION_ID, process.env.ECC_SESSION_ID];
for (const candidate of directCandidates) {
const sanitized = sanitizeSessionKey(candidate);
if (sanitized) {
return sanitized;
}
}
const transcriptPath = (data && (data.transcript_path || data.transcriptPath)) || process.env.CLAUDE_TRANSCRIPT_PATH;
if (transcriptPath && String(transcriptPath).trim()) {
return hashSessionKey('tx', path.resolve(String(transcriptPath).trim()));
}
const projectFingerprint = process.env.CLAUDE_PROJECT_DIR || process.cwd();
return hashSessionKey('proj', path.resolve(projectFingerprint));
}
// see docs/gateguard/design-notes.md#idle-window
function idleWindowForKey(sessionKey) {
return sessionKey.startsWith(PROJECT_KEY_PREFIX) ? SESSION_TIMEOUT_MS : SESSION_ID_TIMEOUT_MS;
}
function getStateFile(data) {
if (!activeStateFile) {
const sessionKey = resolveSessionKey(data);
activeSessionKey = sessionKey;
activeStateFile = path.join(STATE_DIR, `state-${sessionKey}.json`);
activeIdleWindowMs = idleWindowForKey(sessionKey);
}
return activeStateFile;
}
function loadState() {
const stateFile = getStateFile();
try {
if (fs.existsSync(stateFile)) {
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
const lastActive = state.last_active || 0;
if (Date.now() - lastActive > activeIdleWindowMs) {
try {
fs.unlinkSync(stateFile);
} catch (_) {
/* ignore */
}
return { checked: [], last_active: Date.now() };
}
return state;
}
} catch (_) {
/* ignore */
}
return { checked: [], last_active: Date.now() };
}
function pruneCheckedEntries(checked) {
if (checked.length <= MAX_CHECKED_ENTRIES) {
return checked;
}
const preserved = checked.includes(ROUTINE_BASH_SESSION_KEY) ? [ROUTINE_BASH_SESSION_KEY] : [];
const sessionKeys = checked.filter(k => k.startsWith('__') && k !== ROUTINE_BASH_SESSION_KEY);
const fileKeys = checked.filter(k => !k.startsWith('__'));
const remainingSessionSlots = Math.max(MAX_SESSION_KEYS - preserved.length, 0);
const cappedSession = sessionKeys.slice(-remainingSessionSlots);
const remainingFileSlots = Math.max(MAX_CHECKED_ENTRIES - preserved.length - cappedSession.length, 0);
const cappedFiles = fileKeys.slice(-remainingFileSlots);
return [...preserved, ...cappedSession, ...cappedFiles];
}
function saveState(state) {
const stateFile = getStateFile();
let tmpFile = null;
try {
fs.mkdirSync(STATE_DIR, { recursive: true });
let mergedChecked = Array.isArray(state.checked) ? state.checked : [];
let mergedLastActive = typeof state.last_active === 'number' ? state.last_active : 0;
let mergedDenials = getDenialCount(state);
let mergedCredited = getCreditedCount(state);
let mergedDirGates = getDirGates(state);
let mergedDenialsByClass = getClassCounts(state, 'denials_by_class');
let mergedCreditedByClass = getClassCounts(state, 'credited_by_class');
let mergedSiblingAllows = getSiblingAllowCount(state);
let mergedCapAllows = getCapAllowCount(state);
let mergedTrivialAllows = getTrivialAllowCount(state);
let mergedReadonlyPasses = getRoutineReadonlyPassCount(state);
try {
if (fs.existsSync(stateFile)) {
const diskState = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
if (Array.isArray(diskState.checked)) {
mergedChecked = Array.from(new Set([...diskState.checked, ...mergedChecked]));
}
if (typeof diskState.last_active === 'number') {
mergedLastActive = Math.max(mergedLastActive, diskState.last_active);
}
mergedDenials = Math.max(mergedDenials, getDenialCount(diskState));
mergedCredited = Math.max(mergedCredited, getCreditedCount(diskState));
mergedDirGates = mergeDirGates(getDirGates(diskState), mergedDirGates);
mergedDenialsByClass = mergeClassCounts(getClassCounts(diskState, 'denials_by_class'), mergedDenialsByClass);
mergedCreditedByClass = mergeClassCounts(getClassCounts(diskState, 'credited_by_class'), mergedCreditedByClass);
mergedSiblingAllows = Math.max(mergedSiblingAllows, getSiblingAllowCount(diskState));
mergedCapAllows = Math.max(mergedCapAllows, getCapAllowCount(diskState));
mergedTrivialAllows = Math.max(mergedTrivialAllows, getTrivialAllowCount(diskState));
mergedReadonlyPasses = Math.max(mergedReadonlyPasses, getRoutineReadonlyPassCount(diskState));
}
} catch (_) {
/* ignore malformed or transient disk state */
}
const finalState = {
checked: pruneCheckedEntries(mergedChecked),
last_active: Math.max(mergedLastActive, Date.now()),
fact_force_denials: mergedDenials,
fact_force_credited: mergedCredited,
dir_gates: capDirGates(mergedDirGates),
denials_by_class: mergedDenialsByClass,
credited_by_class: mergedCreditedByClass,
sibling_allows: mergedSiblingAllows,
cap_allows: mergedCapAllows,
trivial_allows: mergedTrivialAllows,
routine_readonly_passes: mergedReadonlyPasses
};
// Atomic write: temp file + rename prevents partial reads
tmpFile = `${stateFile}.tmp.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
fs.writeFileSync(tmpFile, JSON.stringify(finalState, null, 2), 'utf8');
try {
fs.renameSync(tmpFile, stateFile);
} catch (error) {
if (error && (error.code === 'EEXIST' || error.code === 'EPERM')) {
try {
fs.unlinkSync(stateFile);
} catch (_) {
/* ignore */
}
fs.renameSync(tmpFile, stateFile);
} else {
throw error;
}
}
tmpFile = null;
return true;
} catch (_) {
if (tmpFile) {
try {
fs.unlinkSync(tmpFile);
} catch (_) {
/* ignore */
}
}
return false;
}
}
function markChecked(key) {
const state = loadState();
if (!state.checked.includes(key)) {
state.checked.push(key);
return saveState(state);
}
return true;
}
// --- Fact-force denial dampening (#2142) ---
//
// In long sessions the near-identical four-fact deny blocks accumulate in
// the context window and measurably raise the odds of the model dropping
// into a degenerate repetition loop. Emit the full four-fact block only for
// the first GATEGUARD_FACT_FORCE_FULL_DENIALS denials per session (default
// 3); afterwards emit a condensed single-line denial that carries the
// denial ordinal, so consecutive denials are structurally different and
// never textually identical. True retries of an already-gated target are
// unaffected (they were always allowed). Destructive shell and routine shell
// gates are not denial-dampened.
const DEFAULT_FULL_DENIALS = 3;
function getFullDenialBudget() {
const raw = Number.parseInt(process.env.GATEGUARD_FACT_FORCE_FULL_DENIALS || '', 10);
if (Number.isInteger(raw) && raw >= 0) {
return raw;
}
return DEFAULT_FULL_DENIALS;
}
// --- Session cap on first-touch denials ---
const MAX_DENIALS_PATTERN = /^\d+$/;
const MAX_DENIALS_WARN_VALUE_CHARS = 64;
let maxDenialsWarnedFor = null;
function warnMalformedMaxDenials(raw) {
if (raw === maxDenialsWarnedFor) return;
maxDenialsWarnedFor = raw;
const chars = Array.from(sanitizePath(raw));
const shown = chars.length > MAX_DENIALS_WARN_VALUE_CHARS
? `${chars.slice(0, MAX_DENIALS_WARN_VALUE_CHARS - 3).join('')}...`
: chars.join('');
try {
process.stderr.write(
`[Fact-Forcing Gate] ignoring malformed GATEGUARD_FACT_FORCE_MAX_DENIALS=${shown}; the denial cap is not active.\n`
);
} catch (_) {
/* stderr write failure is non-fatal */
}
}
function getMaxDenialBudget() {
const raw = process.env.GATEGUARD_FACT_FORCE_MAX_DENIALS || '';
if (!raw) return Number.POSITIVE_INFINITY;
const trimmed = raw.trim();
if (MAX_DENIALS_PATTERN.test(trimmed)) {
const parsed = Number(trimmed);
if (Number.isSafeInteger(parsed)) return parsed;
}
warnMalformedMaxDenials(raw);
return Number.POSITIVE_INFINITY;
}
function markCheckedWith(key, update) {
const loaded = loadState();
const withKey = loaded.checked.includes(key) ? loaded : { ...loaded, checked: [...loaded.checked, key] };
const { state, value } = update(withKey);
return { ok: saveState(state), value };
}
function markCheckedAndCountDenial(key, { cls, dirGate, cap = Number.POSITIVE_INFINITY } = {}) {
const { ok, value: denials } = markCheckedWith(key, state => {
if (getDenialCount(state) >= cap) {
return { state: { ...state, cap_allows: getCapAllowCount(state) + 1 }, value: null };
}
const ordinal = getDenialCount(state) + 1;
const next = {
...state,
fact_force_denials: ordinal,
...(cls ? { denials_by_class: incrementClassCount(getClassCounts(state, 'denials_by_class'), cls) } : {}),
...(dirGate
? { dir_gates: withEntry(getDirGates(state), dirGate.key, { turn: dirGate.turn || null, at: Date.now(), first: dirGate.first, ordinal }) }
: {})
};
return { state: next, value: ordinal };
});
return { ok, denials };
}
function markCheckedAndCountCredit(key, cls) {
return markCheckedWith(key, state => ({
state: {
...state,
fact_force_credited: getCreditedCount(state) + 1,
...(cls ? { credited_by_class: incrementClassCount(getClassCounts(state, 'credited_by_class'), cls) } : {})
},
value: undefined
})).ok;
}
// --- Trivial edits ---
// see docs/gateguard/change-profile.md#trivial-edits
const TRIVIAL_CLASSES = new Set(['code', 'test', 'prose']);
function countTrivialAllow() {
const state = loadState();
return saveState({ ...state, trivial_allows: getTrivialAllowCount(state) + 1 });
}
// see docs/gateguard/change-profile.md#when-a-change-is-profiled
function needsProfile(toolName, cls) {
return targetClass().questionsUseProfile(cls) || ((toolName === 'Edit' || toolName === 'MultiEdit') && TRIVIAL_CLASSES.has(cls)) || isMetricsEnabled();
}
function isTrivialChange(cls, restricted, profile) {
return !restricted && TRIVIAL_CLASSES.has(cls) && Boolean(profile) && profile.known === true && profile.trivial === true;
}
// --- Read-only first shell command ---
function countRoutineReadonlyPass() {
const state = loadState();
return saveState({ ...state, routine_readonly_passes: getRoutineReadonlyPassCount(state) + 1 });
}
// --- Sibling gates ---
const SIBLING_NO_TURN_WINDOW_MS = 120 * 1000;
function markCheckedAndCountSibling(key) {
return markCheckedWith(key, state => ({
state: { ...state, sibling_allows: getSiblingAllowCount(state) + 1 },
value: undefined
})).ok;
}
// see docs/gateguard/design-notes.md#sibling-collapse
function findSiblingGate(key, turnId, now = Date.now()) {
const gates = getDirGates(loadState());
if (!Object.hasOwn(gates, key)) return null;
const entry = gates[key];
const age = now - entry.at;
if (!(age >= 0) || entry.ordinal < 1) return null;
if (turnId) return entry.turn === turnId ? entry : null;
return entry.turn === null && age <= SIBLING_NO_TURN_WINDOW_MS ? entry : null;
}
function isChecked(key) {
const state = loadState();
const keys = Array.isArray(key) ? key : [key];
const found = keys.some(k => state.checked.includes(k));
if (found && Date.now() - (state.last_active || 0) > READ_HEARTBEAT_MS) {
saveState(state);
}
return found;
}
(function pruneStaleFiles() {
try {
const files = fs.readdirSync(STATE_DIR);
const now = Date.now();
for (const f of files) {
const isStateFile = f.startsWith('state-') && (f.endsWith('.json') || f.includes('.json.tmp.'));
if (!isStateFile) continue;
const fp = path.join(STATE_DIR, f);
const window = f.endsWith('.json') ? idleWindowForKey(f.slice('state-'.length)) : SESSION_TIMEOUT_MS;
try {
const stat = fs.statSync(fp);
if (now - stat.mtimeMs > window * 2) {
fs.unlinkSync(fp);
}
} catch (_) {
// Ignore files that disappear between readdir/stat/unlink.
}
}
} catch (_) {
/* ignore */
}
})();
// --- Sanitize file path against injection ---
// Unicode policy for sanitizePath starts with the repo-wide dangerous set in
// scripts/ci/check-unicode-safety.js and adds display-ambiguity characters such
// as soft hyphen. Source text may use those legitimately, but denial paths must
// remain visually unambiguous. Named constants keep that stricter boundary
// auditable.
const ASCII_CONTROL_MAX = 0x1f;
const ASCII_DELETE = 0x7f;
const C1_CONTROLS = [0x80, 0x9f]; // Unicode C1 control block (U+0080..U+009F)
const BIDI_MARKS = [0x200e, 0x200f]; // LRM/RLM
const BIDI_EMBEDDINGS = [0x202a, 0x202e]; // LRE..PDF
const BIDI_ISOLATES = [0x2066, 0x2069]; // LRI..PDI
const ZERO_WIDTHS = [0x200b, 0x200d]; // ZWSP..ZWJ
const WORD_JOINER = 0x2060;
const BYTE_ORDER_MARK = 0xfeff;
const SOFT_HYPHEN = 0x00ad;
const VARIATION_SELECTORS = [0xfe00, 0xfe0f];
const VARIATION_SUPPLEMENTS = [0xe0100, 0xe01ef]; // MONGOLIAN..TAGS (VS17..VS256)
const TAG_BLOCK = [0xe0000, 0xe007f]; // ASCII-smuggling tag characters
const MONGOLIAN_VOWEL_SEPARATOR = 0x180e;
const HANGUL_CHOSEONG_FILLER = 0x115f;
const HANGUL_JUNGSEONG_FILLER = 0x1160;
const HANGUL_FILLER = 0x3164;
const INVISIBLE_MATH_OPERATORS = [0x2061, 0x2064]; // FUNCTION APPLICATION..INVISIBLE PLUS
const LINE_SEPARATOR = 0x2028;
const PARAGRAPH_SEPARATOR = 0x2029;
const SANITIZED_PATH_MAX_LENGTH = 500;
function inRange(code, [lo, hi]) {
return code >= lo && code <= hi;
}
function sanitizePath(filePath) {
// Strip control chars (including null), bidi overrides, separators,
// and the dangerous invisible characters defined by the constants
// above (mirroring scripts/ci/check-unicode-safety.js), so a denial
// message cannot carry content a human reviewer cannot see.
let sanitized = '';
for (const char of String(filePath || '')) {
const code = char.codePointAt(0);
const isAsciiControl =
code <= ASCII_CONTROL_MAX || code === ASCII_DELETE || inRange(code, C1_CONTROLS);
const isBidiOverride =
inRange(code, BIDI_MARKS) || inRange(code, BIDI_EMBEDDINGS) || inRange(code, BIDI_ISOLATES);
const isUnicodeSeparator = code === LINE_SEPARATOR || code === PARAGRAPH_SEPARATOR;
const isDangerousInvisible =
inRange(code, ZERO_WIDTHS) ||
code === WORD_JOINER ||
code === BYTE_ORDER_MARK ||
code === SOFT_HYPHEN ||
inRange(code, VARIATION_SELECTORS) ||
inRange(code, VARIATION_SUPPLEMENTS) ||
inRange(code, TAG_BLOCK) ||
code === MONGOLIAN_VOWEL_SEPARATOR ||
code === HANGUL_CHOSEONG_FILLER ||
code === HANGUL_JUNGSEONG_FILLER ||
code === HANGUL_FILLER ||
inRange(code, INVISIBLE_MATH_OPERATORS);
sanitized += isAsciiControl || isBidiOverride || isUnicodeSeparator || isDangerousInvisible ? ' ' : char;
}
return sanitized.trim().slice(0, SANITIZED_PATH_MAX_LENGTH);
}
function normalizeForMatch(value) {
return String(value || '')
.replace(/\\/g, '/')
.toLowerCase();
}
function isClaudeSettingsPath(filePath) {
const normalized = normalizeForMatch(filePath);
return /(^|\/)\.claude\/settings(?:\.[^/]+)?\.json$/.test(normalized);
}
function isReadOnlyGitIntrospection(command) {
const trimmed = String(command || '').trim();
if (!trimmed || /[\r\n;&|><`$()]/.test(trimmed)) {
return false;
}
const segments = splitCommandSegments(trimmed);
if (segments.length !== 1) {
return false;
}
const tokens = tokenizeAllowlistedShellWords(trimmed);
if (!tokens) {
return false;
}
if (commandBasename(tokens[0]) !== 'git' || tokens.length < 2) {
return false;
}
const subcommand = tokens[1].toLowerCase();
const args = tokens.slice(2);
if (subcommand === 'status') {
return args.every(arg => ['--porcelain', '--short', '--branch'].includes(arg));
}
if (subcommand === 'diff') {
const allowedDiffArgs = new Set(['--name-only', '--name-status', '--cached', '--staged', '--stat']);
// git diff without arguments is read-only introspection
if (args.length === 0) return true;
return args.length <= 2 && args.every(arg => allowedDiffArgs.has(arg));
}
if (subcommand === 'log') {
return args.every(arg => arg === '--oneline' || /^--max-count=\d+$/.test(arg));
}
if (subcommand === 'show') {
// Permite: git show <ref>, git show --stat, git show --name-only,
// git show <ref> --stat, git show <ref> --name-only
if (args.length === 0) return false;
if (args.length === 1) {
const arg = args[0];
if (arg === '--stat' || arg === '--name-only') return true;
// ref
return !arg.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(arg);
}
if (args.length === 2) {
const [first, second] = args;
// ref + flag
if (!first.startsWith('--') && /^[a-zA-Z0-9._:/ -]+$/.test(first) && (second === '--stat' || second === '--name-only')) {
return true;
}
return false;
}
return false;
}
if (subcommand === 'branch') {
return args.length === 1 && args[0] === '--show-current';
}
if (subcommand === 'rev-parse') {
return args.length === 2 && args[0] === '--abbrev-ref' && /^head$/i.test(args[1]);
}
return false;
}
// --- Gate messages ---
/**
* Batch-consistency warning (#3136). A first-touch denial marks the file
* checked so the retry passes; a parallel batch of edits to one
* not-yet-touched file therefore partially applies (first call denied,
* siblings allowed). Hooks see calls one at a time and cannot lock a
* batch, so the denial must say this out loud: name the file and tell
* the agent that siblings may already have been applied.
*/
function batchSiblingWarning(safePath) {
return (
`If this call was sent in a parallel batch, other edits to ${safePath} from that batch ` +
'may already have been applied. Re-read the file before building on them.'
);
}
// see docs/gateguard/change-profile.md#external-contract-question
function questionOptions() {
return { externalContract: ECC_ENABLE_VALUES.has(normalizeEnvValue(process.env.GATEGUARD_EXTERNAL_CONTRACT_QUESTION)) };
}
function firstTouchGateMsg(filePath, isWrite, cls, profile) {
const safe = sanitizePath(filePath);
const questions = questionIdsFor(cls, isWrite, profile, questionOptions()).map(questionText);
return [
'[Fact-Forcing Gate]',
'',
`Before ${isWrite ? 'creating' : 'editing'} ${safe}, present these facts:`,
'',
...questions.map((question, index) => `${index + 1}. ${question}`),
'',
batchSiblingWarning(safe),
'',
'Present the facts, then retry the same operation.'
].join('\n');
}
/**
* Condensed single-line denial used after the full-block budget is spent
* (#2142). Carries the denial ordinal so consecutive denials differ
* textually, and a one-line recovery hint instead of the multi-line block.
*/
function condensedGateMsg(action, filePath, ordinal, cls = 'code', targetNote = '', profile = null, missNote = '') {
const safe = sanitizePath(filePath);
const hint = condensedHintFor(cls, action === 'creation', profile, questionOptions());
return (
`[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` +
`${hint} ` +
`${batchSiblingWarning(safe)} ` +
(missNote ? `${missNote} ` : '') +
(targetNote ? `${targetNote} ` : '') +
'(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; GATEGUARD_FACT_FORCE_MAX_DENIALS caps denials per session; ECC_GATEGUARD=off disables this gate.)'
);
}
const SENSITIVE_TARGET_NOTE = 'Sensitive target: prior-search credit, sibling collapse, and the denial cap do not apply.';
const HARD_LINKED_TARGET_NOTE = 'Hard-linked target: prior-search credit, sibling collapse, and the denial cap do not apply.';
const RETRY_LINE = 'Present the facts, then retry the same operation.';
function withNoteBeforeRetry(message, note) {
if (!note) return message;
const at = message.lastIndexOf(RETRY_LINE);
if (at < 0) return `${message}\n\n${note}`;
return `${message.slice(0, at)}${note}\n\n${message.slice(at)}`;
}
function restrictedTargetNote(sensitive, linked) {
if (sensitive) return SENSITIVE_TARGET_NOTE;
return linked ? HARD_LINKED_TARGET_NOTE : '';
}
function denialReason(sensitive, linked, missNote, miss) {
if (sensitive) return 'sensitive';
if (linked) return 'hard-linked';
return missNote ? `near-miss:${miss.reason}` : 'first-touch';
}
function firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, linked = false, profile, miss, reason }) {
const targetNote = restrictedTargetNote(sensitive, linked);
const missNote = targetNote ? '' : closestMissNote(miss);
recordDecision('deny', reason || denialReason(sensitive, linked, missNote, miss), {
target: filePath,
cls,
sensitive,
profile,
questions: questionIdsFor(cls, isWrite, profile, questionOptions())
});
if (denials > getFullDenialBudget()) {
const action = isWrite ? 'creation' : 'edit';
return denyResult(condensedGateMsg(action, filePath, denials, cls, targetNote, profile, missNote), { includeRecoveryHint: false });
}
const message = withNoteBeforeRetry(firstTouchGateMsg(filePath, isWrite, cls, profile), missNote);
return denyResult(targetNote ? withNoteBeforeRetry(message, targetNote) : message, {
narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT
});
}
function destructiveBashMsg() {
return [
'[Fact-Forcing Gate]',
'',
'Destructive command detected. Before running, present:',
'',
'1. List all files/data this command will modify or delete',
'2. Write a one-line rollback procedure',
"3. Quote the user's current instruction verbatim",
'',
'Present the facts, then retry the same operation.'
].join('\n');
}
function routineShellMsg(toolName) {
const shellName = toolName === 'PowerShell' ? 'PowerShell' : 'Bash';
return [
'[Fact-Forcing Gate]',
'',
`Before the first ${shellName} command this session, present these facts:`,
'',
'1. The current user request in one sentence',
'2. What this specific command verifies or produces',
'',
'Present the facts, then retry the same operation.'
].join('\n');
}
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID], narrowRecoveryHint = '') {
const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or ');
const recoveryLines = narrowRecoveryHint ? [narrowRecoveryHint, ''] : [];
return [
message,
'',
...recoveryLines,
`Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`
].join('\n');
}
function isSubagentInvocation(data) {
if (!data || typeof data !== 'object') {
return false;
}
const candidates = [data.agent_id, data.agentId, data.parent_tool_use_id, data.parentToolUseId];
return candidates.some(candidate => typeof candidate === 'string' && candidate.trim());
}
// --- Deny helper ---
function denyResult(reason, options = {}) {
const includeRecoveryHint = options.includeRecoveryHint !== false;
const hookIds = Array.isArray(options.hookIds) && options.hookIds.length > 0 ? options.hookIds : [EDIT_WRITE_HOOK_ID];
const narrowRecoveryHint = typeof options.narrowRecoveryHint === 'string' ? options.narrowRecoveryHint : '';
return {
stdout: JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: includeRecoveryHint
? withRecoveryHint(reason, hookIds, narrowRecoveryHint)
: reason
}
}),
exitCode: 0
};
}
function allowWithStateWarning() {
recordDecision('pass', 'state-error');
return {
stderr: '[Fact-Forcing Gate] GateGuard state could not be persisted; allowing this operation to avoid a permanent retry loop. Check GATEGUARD_STATE_DIR or filesystem permissions.',
exitCode: 0
};
}
// --- Prior-search credit ---
const CREDIT_DETAIL_MAX_CHARS = 80;
function creditNote(match, filePath) {
const chars = Array.from(sanitizePath(match.detail).replace(/\s+/g, ' '));
const detail = chars.length > CREDIT_DETAIL_MAX_CHARS ? `${chars.slice(0, CREDIT_DETAIL_MAX_CHARS - 3).join('')}...` : chars.join('');
const calls = `${match.callsAgo} tool call${match.callsAgo === 1 ? '' : 's'} ago`;
return (
`[Fact-Forcing Gate] Prior search seen in this turn (${match.name} ${detail}, ${calls}); ` +
`first-touch check satisfied for ${sanitizePath(filePath)}.`
);
}
// --- Closest search that did not count ---
const MISS_DETAIL_MAX_CHARS = 60;
const MISS_REASON_TEXT = Object.freeze({
'same-batch': 'it was sent in the same batch as this call, so its result was not seen yet',
lookup: 'it only looked this file up by its own name, so it found nothing that uses it',
excluded: 'its filters exclude this file',
'out-of-scope': 'its search path does not contain this file',
'stdin-only': 'it searched piped input, not the tree',
'not-a-search': 'only Glob, Grep, LS and shell search commands count',
'generic-stem': 'this file name is too generic to match a search'
});
function closestMissNote(miss) {
if (!miss || !Object.hasOwn(MISS_REASON_TEXT, miss.reason)) return '';
const chars = Array.from(sanitizePath(miss.detail).replace(/\s+/g, ' ').trim());
const detail = chars.length > MISS_DETAIL_MAX_CHARS ? `${chars.slice(0, MISS_DETAIL_MAX_CHARS - 3).join('')}...` : chars.join('');
return `Closest search this turn did not count (${sanitizePath(miss.name)} ${detail}): ${MISS_REASON_TEXT[miss.reason]}.`;
}
function isMissingOnDisk(resolved) {
try {
fs.lstatSync(resolved);
return false;
} catch (error) {
return Boolean(error) && error.code === 'ENOENT';
}
}
function isNewFileTarget(filePath, data) {
try {
const target = resolveTargetPath(filePath, data);
return Boolean(target) && isMissingOnDisk(target.resolved);
} catch (_) {
return false;
}
}
function newFileGateKey(filePath, data, cls) {
try {
const dir = collapseGateDir(filePath, data, cls);
return dir ? dirGateKey(cls, dir) : null;
} catch (_) {
return null;
}
}
function trivialNote(filePath) {
return (
`[Fact-Forcing Gate] Comment or whitespace-only change to ${sanitizePath(filePath)}; no first-touch check needed. ` +
'The next change to this file that alters code is still checked.'
);
}
function siblingNote(gate) {
return (
`[Fact-Forcing Gate] Sibling of ${sanitizePath(gate.first)} (gated earlier at denial #${gate.ordinal} this session); ` +
'proceeding without a repeat denial.'
);
}
// --- Allowance eligibility ---
// see docs/gateguard/design-notes.md#hard-linked-targets
function allowanceFacts(filePath, data) {
const sensitive = isSensitiveTargetFor(filePath, data);
const linked = !sensitive && isHardLinkedTargetFor(filePath, data);
return { sensitive, linked, restricted: sensitive || linked };
}
// --- Subagents ---
// see docs/gateguard/design-notes.md#subagents
function subagentGateKey(fileKey) {
return `__subagent__${crypto.createHash('sha256').update(fileKey).digest('hex').slice(0, 16)}`;
}
function subagentRestrictedDenial(filePath, data, isWrite) {
const { sensitive, linked, restricted } = allowanceFacts(filePath, data);
if (!restricted) return null;
const fileKey = canonicalPathKey(filePath, data);
const subKey = subagentGateKey(fileKey);
if (isChecked([fileKey, filePath, subKey])) return null;
const cls = classifyTargetFor(filePath, data);
const { ok, denials } = markCheckedAndCountDenial(subKey, { cls });
if (!ok) return allowWithStateWarning();
const reason = sensitive ? 'subagent-sensitive' : 'subagent-hard-linked';
return firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, linked, profile: null, miss: null, reason });
}
// --- Change profile ---
// see docs/gateguard/change-profile.md#file-context
const MAX_CONTEXT_FILE_BYTES = 1024 * 1024;
function readTargetText(filePath, data) {
let fd = null;
try {
const target = resolveTargetPath(filePath, data);
if (!target) return null;
fd = fs.openSync(target.resolved, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
const stat = fs.fstatSync(fd);
if (!stat.isFile() || stat.size > MAX_CONTEXT_FILE_BYTES) return null;
const buffer = Buffer.alloc(stat.size);
let read = 0;
while (read < buffer.length) {
const n = fs.readSync(fd, buffer, read, buffer.length - read, read);
if (n === 0) break;
read += n;
}
return buffer.toString('utf8', 0, read).replace(/\r\n/g, '\n');
} catch (_) {
return null;
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch (_) {
/* ignore */
}
}
}
}
function changeProfileFor(toolName, filePath, edits, content, data) {
const tool = toolName === 'Write' ? 'Write' : 'Edit';
const fileText = tool === 'Edit' ? readTargetText(filePath, data) : null;
return profileChange({ filePath, tool, edits, content, fileText });
}
// see docs/gateguard/design-notes.md#multiedit-paths
function multiEditEntries(toolInput) {
const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
const shared = typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
return edits.map(edit => (edit && typeof edit === 'object' && !edit.file_path && shared ? { ...edit, file_path: shared } : edit));
}
function entriesFor(edits, fileKey, data) {
return edits.filter(edit => edit && typeof edit.file_path === 'string' && canonicalPathKey(edit.file_path, data) === fileKey);
}
// --- Metrics ---
// see docs/gateguard/design-notes.md#metrics
let pendingMetrics = null;
function isMetricsEnabled() {
return ECC_ENABLE_VALUES.has(normalizeEnvValue(process.env.GATEGUARD_METRICS));
}
function recordDecision(decision, reason, fields = {}) {
if (!pendingMetrics) return;
if (fields.key !== undefined) {
if (pendingMetrics.keys.has(fields.key)) return;
pendingMetrics.keys.add(fields.key);
}
pendingMetrics.entries.push({ ...fields, decision, reason });
}
function targetFacts(entry, data) {
if (!entry.target) {
return { cls: entry.cls === undefined ? null : entry.cls, sensitive: entry.sensitive === true };
}
try {
return {
cls: entry.cls === undefined ? classifyTargetFor(entry.target, data) : entry.cls,
sensitive: entry.sensitive === undefined ? isSensitiveTargetFor(entry.target, data) : entry.sensitive
};
} catch (_) {
return { cls: null, sensitive: false };
}
}
function flushMetrics() {
const batch = pendingMetrics;
pendingMetrics = null;
if (!batch || batch.entries.length === 0) return;
try {
const now = Date.now();
const events = batch.entries.map(entry =>
metricsEvent({
sessionKey: activeSessionKey,
tool: batch.tool,
...targetFacts(entry, batch.data),
decision: entry.decision,
reason: entry.reason,
questions: entry.questions,
profile: entry.profile,
now
})
);
appendMetrics(STATE_DIR, events);
} catch (_) {
/* ignore */
}
}
function exemptReason(filePath, data) {
if (!filePath) return 'no-path';
if (isClaudeSettingsPath(filePath)) return 'claude-settings';
return isExemptPath(filePath, data) ? 'exempt-glob' : '';
}
// --- Core logic (exported for run-with-flags.js) ---
function run(rawInput) {
pendingMetrics = null;
try {
return gate(rawInput);
} finally {
flushMetrics();
}
}
function gate(rawInput) {
let data;
try {
data = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
} catch (_) {
return rawInput; // allow on parse error
}
if (isGateGuardDisabled()) {
return rawInput;
}
activeStateFile = null;
getStateFile(data);
const rawToolName = data.tool_name || '';
const toolInput = data.tool_input || {};
// Normalize: case-insensitive matching via lookup map
const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', notebookedit: 'NotebookEdit', bash: 'Bash', powershell: 'PowerShell' };
const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName;
if (isMetricsEnabled()) {
pendingMetrics = { tool: toolName, data, entries: [], keys: new Set() };
}
const inSubagent = isSubagentInvocation(data);
let turnScanner = null;
const getTurnScan = () => (turnScanner || (turnScanner = createTurnScanner(data)))();
if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') {
const filePath = (toolName === 'NotebookEdit' ? toolInput.notebook_path : toolInput.file_path) || '';
const exempt = exemptReason(filePath, data);
if (exempt) {
recordDecision('pass-exempt', exempt, { target: filePath });
return rawInput; // allow
}
if (inSubagent) {
const denial = subagentRestrictedDenial(filePath, data, toolName === 'Write');
if (!denial) recordDecision('pass-subagent', 'subagent', { target: filePath });
return denial || rawInput;
}
const fileKey = canonicalPathKey(filePath, data);
if (!isChecked([fileKey, filePath])) {
const isNewFile = toolName === 'Write' && isNewFileTarget(filePath, data);
const cls = classifyTargetFor(filePath, data);
// see docs/gateguard/design-notes.md#sensitive-targets
const { sensitive, linked, restricted } = allowanceFacts(filePath, data);
const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, isNewFile, data);
if (credit) {
if (!markCheckedAndCountCredit(fileKey, cls)) {
return allowWithStateWarning();
}
recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive });
return { additionalContext: creditNote(credit, filePath), exitCode: 0 };
}
const profile = restricted || toolName === 'NotebookEdit' || !needsProfile(toolName, cls)
? null
: changeProfileFor(toolName, filePath, [toolInput], toolInput.content, data);
if (toolName === 'Edit' && isTrivialChange(cls, restricted, profile)) {
if (!countTrivialAllow()) {
return allowWithStateWarning();
}
recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile });
return { additionalContext: trivialNote(filePath), exitCode: 0 };
}
const turnId = isNewFile && !restricted ? currentTurnId(getTurnScan()) : null;
const collapsible = isNewFile && !restricted && (turnId !== null || !transcriptPathFor(data));
const gateKey = collapsible ? newFileGateKey(filePath, data, cls) : null;
const sibling = gateKey ? findSiblingGate(gateKey, turnId) : null;
if (sibling) {
if (!markCheckedAndCountSibling(fileKey)) {
return allowWithStateWarning();
}
recordDecision('sibling', 'same-turn-dir', { target: filePath, cls, sensitive, profile });
return { additionalContext: siblingNote(sibling), exitCode: 0 };
}
const dirGate = gateKey ? { key: gateKey, turn: turnId, first: sanitizePath(filePath) } : undefined;
const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget();
const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, dirGate, cap });
if (!ok) {
return allowWithStateWarning();
}
if (denials === null) {
// see docs/gateguard/design-notes.md#denial-cap
recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile });
return rawInput;
}
const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, isNewFile, data);
return firstTouchDenial(filePath, { isWrite: toolName === 'Write', denials, cls, sensitive, linked, profile, miss });
}
recordDecision('pass-checked', 'checked', { target: filePath });
return rawInput; // allow
}
if (toolName === 'MultiEdit') {
const edits = multiEditEntries(toolInput);
if (inSubagent) {
for (const edit of edits) {
const filePath = (edit && edit.file_path) || '';
if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) continue;
const denial = subagentRestrictedDenial(filePath, data, false);
if (denial) return denial;
}
recordDecision('pass-subagent', 'subagent');
return rawInput;
}
const notes = [];
const trivialKeys = new Set();
for (const edit of edits) {
const filePath = (edit && edit.file_path) || '';
const exempt = exemptReason(filePath, data);
if (exempt) {
recordDecision('pass-exempt', exempt, { target: filePath, key: `exempt:${filePath}` });
continue;
}
const fileKey = canonicalPathKey(filePath, data);
if (trivialKeys.has(fileKey)) continue;
if (!isChecked([fileKey, filePath])) {
const cls = classifyTargetFor(filePath, data);
const { sensitive, linked, restricted } = allowanceFacts(filePath, data);
const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, false, data);
if (credit) {
if (!markCheckedAndCountCredit(fileKey, cls)) {
return allowWithStateWarning();
}
recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive, key: fileKey });
notes.push(creditNote(credit, filePath));
continue;
}
const profile = restricted || !needsProfile('MultiEdit', cls)
? null
: changeProfileFor('Edit', filePath, entriesFor(edits, fileKey, data), undefined, data);
if (isTrivialChange(cls, restricted, profile)) {
if (!countTrivialAllow()) {
return allowWithStateWarning();
}
trivialKeys.add(fileKey);
recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile, key: fileKey });
notes.push(trivialNote(filePath));
continue;
}
const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget();
const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, cap });
if (!ok) {
return allowWithStateWarning();
}
if (denials === null) {
// see docs/gateguard/design-notes.md#denial-cap
recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile, key: fileKey });
continue;
}
const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, false, data);
return firstTouchDenial(filePath, { isWrite: false, denials, cls, sensitive, linked, profile, miss });
}
recordDecision('pass-checked', 'checked', { target: filePath, key: fileKey });
}
if (notes.length > 0) {
return { additionalContext: notes, exitCode: 0 };
}
return rawInput; // allow
}
if (toolName === 'Bash' || toolName === 'PowerShell') {
const command = toolInput.command || '';
if (isReadOnlyGitIntrospection(command)) {
recordDecision('pass', 'readonly-git');
return rawInput;
}
if (classifyDestructiveCommand(toolName, command).length > 0) {
// Gate destructive commands on first attempt; allow retry after facts presented
const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16);
if (!isChecked(key)) {
if (!markChecked(key)) {
return allowWithStateWarning();
}
recordDecision('destructive-deny', 'destructive');
return denyResult(destructiveBashMsg(), { includeRecoveryHint: false });
}
recordDecision('pass-checked', 'destructive-retry');
return rawInput; // allow retry after facts presented
}
// Operator opt-out: skip the routine shell gate entirely. The destructive
// gate above still fires. This is the documented escape hatch for hosts
// (Cursor, OpenCode, etc.) where the once-per-session routine gate is
// friction without signal.
if (isRoutineBashGateDisabled()) {
recordDecision('pass-exempt', 'routine-disabled');
return rawInput; // routine gate opted out via env
}
if (!isChecked(ROUTINE_BASH_SESSION_KEY)) {
if (isReadOnlyShellCommand(toolName, command)) {
if (!countRoutineReadonlyPass()) {
return allowWithStateWarning();
}
recordDecision('routine-readonly', 'readonly');
return rawInput;
}
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
return allowWithStateWarning();
}
recordDecision('routine-deny', 'first-command');
const hookId = toolName === 'PowerShell' ? POWERSHELL_HOOK_ID : BASH_HOOK_ID;
const narrowRecoveryHint = toolName === 'PowerShell'
? ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT
: ROUTINE_BASH_NARROW_RECOVERY_HINT;
return denyResult(routineShellMsg(toolName), {
hookIds: [hookId],
narrowRecoveryHint
});
}
recordDecision('pass-checked', 'routine-checked');
return rawInput; // allow
}
return rawInput; // allow
}
module.exports = {
run,
classifyDestructiveCommand,
findCreditingSearch,
findClosestMiss,
get classifyTarget() {
return targetClass().classifyTarget;
},
get classifyTargetFor() {
return targetClass().classifyTargetFor;
},
get isReadOnlyShellCommand() {
return readOnlyShell().isReadOnlyShellCommand;
},
get scanCurrentTurn() {
return turnScan().scanCurrentTurn;
}
};