#!/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 `, `--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 -- `, `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: `+[:]`. 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 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 ` deletes a ref directly. return rest.includes('-d') || rest.includes('--delete'); } if (command === 'restore') { // `git restore ` overwrites the working tree from the index // by default, the modern spelling of gated `git checkout -- `. // 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 , git show --stat, git show --name-only, // git show --stat, git show --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; } };