diff --git a/hooks/hooks.metadata.json b/hooks/hooks.metadata.json index c5986e44c..89028f4a3 100644 --- a/hooks/hooks.metadata.json +++ b/hooks/hooks.metadata.json @@ -44,7 +44,7 @@ }, { "id": "pre:edit-write:gateguard-fact-force", - "description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing", + "description": "Fact-forcing gate: deny the first Edit/Write/MultiEdit of each file with class-specific questions (code, test, prose, config, instruction); a completed search naming the file in the current turn, or a same-turn sibling of a new file already gated, passes with a note instead; sensitive targets (secrets, keys, auth/payment code, migrations, CI workflows) are always gated", "fingerprint": "32c4a312b4c2" } ], diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index b23055b72..85633d774 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -9,10 +9,17 @@ * The act of investigation creates awareness that self-evaluation never did. * * Gates: - * - Edit/Write: list importers, affected API, verify data schemas, quote instruction + * - Edit/Write/MultiEdit (first touch of each file): questions chosen by + * target class (code: list importers, affected API, verify data schemas, + * quote instruction) * - Bash/PowerShell (destructive): list targets, rollback plan, quote instruction * - Bash/PowerShell (routine): quote current instruction (once per session) * + * A first touch already covered by a completed search in the current turn, + * or a new sibling of a file just gated, passes with an additionalContext + * note (never an "allow" decision). Sensitive targets are always denied, and + * anything ambiguous falls back to the denial. + * * Compatible with run-with-flags.js via module.exports.run(). * Cross-platform (Windows, macOS, Linux). * @@ -28,6 +35,20 @@ const path = require('path'); const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } = require('../lib/shell-substitution'); const { classifyPowerShellDestructiveCommand } = require('../lib/powershell-destructive-command'); const { stripHeredocBodies } = require('./gateguard-heredoc'); +const { + WINDOWS_PATH_PATTERN, + COLLAPSIBLE_CLASSES, + CLASS_QUESTIONS, + CLASS_CONDENSED_HINTS, + QUOTE_INSTRUCTION, + resolveTargetPath, + canonicalPathKey, + classifyTarget, + classifyTargetFor, + collapseGateDir, + isSensitiveTargetFor +} = require('../lib/gateguard-target-class'); +const { scanCurrentTurn, excludedBatchId, createTurnScanner, currentTurnId, transcriptPathFor } = require('../lib/gateguard-turn-scan'); // 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'); @@ -154,7 +175,7 @@ function getExemptMatchers() { 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 = /^[a-z]:[\\/]|^\\\\/i.test(projectRoot) ? path.win32 : path.posix; + const paths = 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); @@ -1416,6 +1437,12 @@ function saveState(state) { 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); try { if (fs.existsSync(stateFile)) { @@ -1427,6 +1454,12 @@ function saveState(state) { 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)); } } catch (_) { /* ignore malformed or transient disk state */ @@ -1435,7 +1468,13 @@ function saveState(state) { const finalState = { checked: pruneCheckedEntries(mergedChecked), last_active: Math.max(mergedLastActive, Date.now()), - fact_force_denials: mergedDenials + 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 }; // Atomic write: temp file + rename prevents partial reads @@ -1500,29 +1539,228 @@ function getFullDenialBudget() { return DEFAULT_FULL_DENIALS; } -function getDenialCount(state) { - const n = Number(state && state.fact_force_denials); +// --- Opt-in session cap on first-touch denials (GATEGUARD_FACT_FORCE_MAX_DENIALS) --- +// Validated whole, not with parseInt: a prefix parse would turn '3oops' into a +// cap. A malformed value leaves the gate uncapped and says so once on stderr. + +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; +} + +/** A non-negative integer count from untrusted state; anything else is 0. */ +function toCount(value) { + const n = Number(value); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0; } +function getDenialCount(state) { + return toCount(state && state.fact_force_denials); +} + +function getCreditedCount(state) { + return toCount(state && state.fact_force_credited); +} + +function getCapAllowCount(state) { + return toCount(state && state.cap_allows); +} + +/** Mark a target checked and apply `update` in one state write, so an event is never half-recorded. */ +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 }; +} + /** - * Record a first-touch target AND count the fact-force denial in the same - * state write. Returns the new denial ordinal (1-based) plus whether the - * write persisted. + * Record a denial (returns its ordinal) or, once denials reach `cap`, a cap pass + * (`denials` null). The cap check reads the state the write persists, so a lost + * concurrent update can add a denial but never passes a target early. */ -function markCheckedAndCountDenial(key) { - const state = loadState(); - if (!state.checked.includes(key)) { - state.checked.push(key); +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; +} + +// --- Sibling gates and per-class counters --- +// dir_gates maps `\u0000` to { turn, at, first, ordinal }. +// The state file is untrusted, so every map is null-prototype and rejects prototype keys. + +const MAX_DIR_GATES = 50; +const SIBLING_NO_TURN_WINDOW_MS = 120 * 1000; +const DIR_GATE_KEY_SEPARATOR = '\u0000'; +const UNSAFE_MAP_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isSafeKey(key) { + return typeof key === 'string' && key !== '' && !UNSAFE_MAP_KEYS.has(key); +} + +function nullMap(entries) { + const map = Object.create(null); + for (const [key, value] of entries) map[key] = value; + return map; +} + +function withEntry(map, key, value) { + return nullMap([...Object.entries(map), [key, value]]); +} + +function getClassCounts(state, field) { + const raw = state && state[field]; + if (!isPlainObject(raw)) return nullMap([]); + const entries = Object.keys(raw) + .filter(cls => isSafeKey(cls) && typeof raw[cls] === 'number' && toCount(raw[cls]) > 0) + .map(cls => [cls, toCount(raw[cls])]); + return nullMap(entries); +} + +function mergeClassCounts(a, b) { + const merged = nullMap(Object.entries(a)); + for (const [cls, n] of Object.entries(b)) { + merged[cls] = Math.max(Object.hasOwn(merged, cls) ? merged[cls] : 0, n); } - const denials = getDenialCount(state) + 1; - state.fact_force_denials = denials; - return { ok: saveState(state), denials }; + return merged; +} + +function incrementClassCount(counts, cls) { + if (!isSafeKey(cls)) return counts; + return withEntry(counts, cls, (Object.hasOwn(counts, cls) ? counts[cls] : 0) + 1); +} + +function getSiblingAllowCount(state) { + return toCount(state && state.sibling_allows); +} + +function dirGateKey(cls, dir) { + return `${cls}${DIR_GATE_KEY_SEPARATOR}${dir}`; +} + +function isDirGateKey(key) { + if (!isSafeKey(key)) return false; + const at = key.indexOf(DIR_GATE_KEY_SEPARATOR); + return at > 0 && at < key.length - 1 && COLLAPSIBLE_CLASSES.has(key.slice(0, at)); +} + +function isDirGateEntry(entry) { + return ( + isPlainObject(entry) && + typeof entry.at === 'number' && + Number.isFinite(entry.at) && + typeof entry.first === 'string' && + (entry.turn === null || entry.turn === undefined || typeof entry.turn === 'string') + ); +} + +function getDirGates(state) { + const raw = state && state.dir_gates; + if (!isPlainObject(raw)) return nullMap([]); + const entries = Object.keys(raw) + .filter(key => isDirGateKey(key) && isDirGateEntry(raw[key])) + .map(key => { + const entry = raw[key]; + return [key, { turn: entry.turn || null, at: entry.at, first: entry.first, ordinal: toCount(entry.ordinal) }]; + }); + return nullMap(entries); +} + +/** Union by key; the newer `at` wins, so concurrent writers keep the latest gate. */ +function mergeDirGates(older, newer) { + const merged = nullMap(Object.entries(older)); + for (const [key, entry] of Object.entries(newer)) { + if (!Object.hasOwn(merged, key) || entry.at >= merged[key].at) merged[key] = entry; + } + return merged; +} + +function capDirGates(gates) { + const entries = Object.entries(gates); + if (entries.length <= MAX_DIR_GATES) return gates; + return nullMap(entries.sort((a, b) => b[1].at - a[1].at).slice(0, MAX_DIR_GATES)); +} + +function markCheckedAndCountSibling(key) { + return markCheckedWith(key, state => ({ + state: { ...state, sibling_allows: getSiblingAllowCount(state) + 1 }, + value: undefined + })).ok; +} + +/** + * Open sibling gate for this key: same turn id, or (no turn id on either side) + * within 120 s. A gate stamped in the future or without a denial is ignored. + */ +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 found = state.checked.includes(key); + 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); } @@ -1745,21 +1983,70 @@ function writeGateMsg(filePath) { ].join('\n'); } +function classGateMsg(filePath, cls, isWrite) { + const safe = sanitizePath(filePath); + const questions = [...CLASS_QUESTIONS[cls](isWrite), QUOTE_INSTRUCTION]; + 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'); +} + +/** Full first-touch denial; code targets keep the editGateMsg/writeGateMsg text. */ +function firstTouchGateMsg(filePath, isWrite, cls) { + if (!CLASS_QUESTIONS[cls]) { + return isWrite ? writeGateMsg(filePath) : editGateMsg(filePath); + } + return classGateMsg(filePath, cls, isWrite); +} + /** * 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. + * Non-code classes swap the middle clause for a class-specific hint. */ -function condensedGateMsg(action, filePath, ordinal) { +function condensedGateMsg(action, filePath, ordinal, cls = 'code', sensitive = false) { const safe = sanitizePath(filePath); + const hint = CLASS_CONDENSED_HINTS[cls] + ? CLASS_CONDENSED_HINTS[cls](action === 'creation') + : "briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry."; return ( `[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` + - "briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry. " + + `${hint} ` + `${batchSiblingWarning(safe)} ` + - '(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; ECC_GATEGUARD=off disables this gate.)' + (sensitive ? `${SENSITIVE_TARGET_NOTE} ` : '') + + '(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 RETRY_LINE = 'Present the facts, then retry the same operation.'; + +function withSensitiveNote(message) { + const at = message.lastIndexOf(RETRY_LINE); + if (at < 0) return `${message}\n\n${SENSITIVE_TARGET_NOTE}`; + return `${message.slice(0, at)}${SENSITIVE_TARGET_NOTE}\n\n${message.slice(at)}`; +} + +function firstTouchDenial(filePath, { isWrite, denials, cls, sensitive }) { + if (denials > getFullDenialBudget()) { + const action = isWrite ? 'creation' : 'edit'; + return denyResult(condensedGateMsg(action, filePath, denials, cls, sensitive), { includeRecoveryHint: false }); + } + const message = firstTouchGateMsg(filePath, isWrite, cls); + return denyResult(sensitive ? withSensitiveNote(message) : message, { + narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT + }); +} + function destructiveBashMsg() { return [ '[Fact-Forcing Gate]', @@ -1836,6 +2123,812 @@ function allowWithStateWarning() { }; } +// --- Prior-search credit --- +// `Read` never counts: Claude Code already requires a Read before an Edit, so +// crediting it would disable the Edit gate. Any ambiguity or failure means no credit. + +const SHELL_SEARCH_COMMANDS = new Set([ + 'rg', + 'grep', + 'egrep', + 'fgrep', + 'git', + 'find', + 'fd', + 'ls', + 'tree', + 'get-childitem', + 'gci', + 'select-string', + 'sls' +]); +const GIT_SEARCH_SUBCOMMANDS = new Set(['grep', 'ls-files']); +const DIRECTORY_CHANGE_COMMANDS = new Set(['cd', 'pushd', 'popd', 'chdir', 'set-location', 'sl', 'push-location', 'pop-location']); +// Search kinds whose first positional argument is the pattern, not a path. +const PATTERN_FIRST_SEARCHES = new Set(['grep', 'egrep', 'fgrep', 'rg', 'git grep', 'select-string', 'sls', 'fd']); +const RECURSIVE_BY_DEFAULT = new Set(['rg', 'git grep', 'git ls-files', 'find', 'fd', 'tree']); +const GREP_FAMILY = new Set(['grep', 'egrep', 'fgrep']); +const POWERSHELL_SEARCHES = new Set(['get-childitem', 'gci', 'select-string', 'sls']); +// Flags whose value is the search pattern (so the first positional is a path). +const PATTERN_VALUE_FLAGS = new Set(['-e', '-f', '--regexp', '--file']); +// Flags that consume the next argument; that argument is never a path operand. +const VALUE_FLAGS = new Set([ + '-e', '-f', '-g', '-t', '-T', '-m', '-A', '-B', '-C', + '--regexp', '--file', '--type', '--glob', '--iglob', '--include', '--exclude', '--exclude-dir', '--exclude-from', + '--ignore-file', '--max-count', '--replace', + '-name', '-iname', '-path', '-ipath', '-type', '-maxdepth', '-mindepth', '-newer', '-regex', '-size', '-user', '-group', '-perm' +]); +// PowerShell parameters are case-insensitive. -Path/-LiteralPath values are paths +// (they count as operands for the stdin/target-only checks) but never directory evidence. +const LS_VALUE_FLAGS = new Set(['-I', '--ignore', '--hide']); +const TREE_VALUE_FLAGS = new Set(['-L', '-P', '-I', '-o']); +// fd: `-E/--exclude ` take a value (fd's `-e` is an extension, also a value). +const FD_VALUE_FLAGS = new Set([...VALUE_FLAGS, '-E']); +// Only these tools take the search pattern from -e/-f (fd's -e is an extension). +const PATTERN_FLAG_SEARCHES = new Set(['grep', 'egrep', 'fgrep', 'rg', 'git grep']); +const POWERSHELL_VALUE_FLAGS = new Set(['-path', '-literalpath', '-filter', '-include', '-exclude', '-depth', '-pattern']); +const POWERSHELL_PATH_FLAGS = new Set(['-path', '-literalpath']); +const GENERIC_STEMS = new Set([ + 'index', + 'main', + 'init', + '__init__', + 'utils', + 'util', + 'types', + 'readme', + 'test', + 'tests', + 'config', + 'mod', + 'lib', + 'setup', + 'app', + 'spec', + 'helpers', + 'common' +]); +const MIN_STEM_LENGTH = 4; +const MAX_SEARCH_COMMAND_CHARS = 8192; +const CREDIT_DETAIL_MAX_CHARS = 80; +// Command substitution, process substitution, heredocs and PowerShell +// backtick escapes make the searched text ambiguous: never credit them. +const AMBIGUOUS_SHELL_PATTERN = /`|\$\(|[<>]\(|<= MIN_STEM_LENGTH && !GENERIC_STEMS.has(stem) ? stem : null; +} + +/** Word-boundary stem matcher; an indexOf scan, never a RegExp built from untrusted text. */ +function stemMatcher(stem) { + if (!stem) return null; + return { test: text => containsWord(String(text), stem) }; +} + +function isStemWordChar(ch) { + return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')); +} + +function containsWord(text, word) { + for (let at = text.indexOf(word); at !== -1; at = text.indexOf(word, at + 1)) { + if (!isStemWordChar(text[at - 1]) && !isStemWordChar(text[at + word.length])) return true; + } + return false; +} + +/** Path-resolution context for a target; relative paths resolve against the tool `cwd`. */ +function dirContext(targetPath, data) { + const base = (data && typeof data.cwd === 'string' && data.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const isWin = WINDOWS_PATH_PATTERN.test(base) || WINDOWS_PATH_PATTERN.test(targetPath); + const paths = isWin ? path.win32 : path.posix; + if (!paths.isAbsolute(base)) return null; + const resolveNative = (...parts) => paths.resolve(base, ...parts); + const resolveDir = (...parts) => { + const dir = resolveNative(...parts).replace(/\\/g, '/'); + return isWin ? dir.toLowerCase() : dir; + }; + const targetKey = resolveDir(targetPath); + return { resolveDir, resolveNative, targetKey, targetDir: path.posix.dirname(targetKey) }; +} + +function isInsideDir(targetKey, dirKey) { + return targetKey.startsWith(dirKey.endsWith('/') ? dirKey : `${dirKey}/`); +} + +function isDirectoryNow(nativePath) { + try { + return fs.statSync(nativePath).isDirectory(); + } catch (_) { + return false; + } +} + +function globLiteralPrefix(pattern) { + const segments = pattern.split(/[\\/]/); + const firstGlob = segments.findIndex(segment => GLOB_CHARS_PATTERN.test(segment)); + const literal = firstGlob === -1 ? segments.slice(0, -1) : segments.slice(0, firstGlob); + const joined = literal.join('/'); + return joined || (literal.length > 0 && pattern.startsWith('/') ? '/' : ''); +} + +function stringsOf(...values) { + return values.filter(value => typeof value === 'string' && value); +} + +/** + * Evidence a search offers: stem `texts`, candidate `dirs`, the `scope`/`scopes` + * the target must lie in, and `operands` that must not all be the target itself. + */ +function searchEvidence(search, shellDirsTrusted) { + const input = search.input; + if (search.name === 'Glob') { + if (typeof input.pattern !== 'string' || !input.pattern) return []; + const explicit = typeof input.path === 'string' && input.path ? input.path : ''; + const prefix = globLiteralPrefix(input.pattern); + const scope = [explicit || '.', prefix || '.']; + if (!GLOB_CHARS_PATTERN.test(input.pattern)) { + // A single-file lookup names no directory, and looking up the target itself is not a search. + const lookup = [explicit || '.', input.pattern]; + return [{ texts: [input.pattern], dirs: [], scope, scopeExact: true, operands: [lookup], detail: input.pattern }]; + } + // An empty literal prefix with no explicit path names only the implicit cwd. + const dirs = prefix || explicit ? [scope] : []; + return [{ texts: [input.pattern], dirs, scope, detail: input.pattern }]; + } + if (search.name === 'Grep') { + if (typeof input.pattern !== 'string' || !input.pattern) return []; + const explicit = typeof input.path === 'string' && input.path ? input.path : ''; + const detail = typeof input.glob === 'string' && input.glob ? `${input.pattern} --glob ${input.glob}` : input.pattern; + const filters = grepToolFilters(input.glob); + return [{ + texts: stringsOf(input.pattern, ...filters.positives), + dirs: explicit ? [[explicit]] : [], + scope: [explicit || '.'], + detail, + ...filters + }]; + } + if (search.name === 'LS') { + return stringsOf(input.path).map(dir => ({ texts: [], dirs: [[dir]], scope: [dir], detail: dir })); + } + return shellSearchEvidence(input.command, shellDirsTrusted); +} + +/** + * Per segment, whether it reads piped input. Mirrors `quoteAwareSegments` + * splitting; `||` also marks the next segment, which only ever removes evidence. + */ +function segmentPipeFlags(input) { + const flags = []; + let quote = null; + let escaped = false; + let hasContent = false; + let piped = false; + for (const ch of String(input || '')) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + hasContent = true; + } else if (quote) { + if (ch === quote) quote = null; + } else if (ch === '"' || ch === "'") { + quote = ch; + hasContent = true; + } else if (SHELL_SEGMENT_SEPARATORS.has(ch)) { + if (hasContent) { + flags.push(piped); + piped = false; + hasContent = false; + } + piped = piped || ch === '|'; + } else if (!/\s/.test(ch)) { + hasContent = true; + } + } + if (hasContent) flags.push(piped); + return flags; +} + +/** Base for relative operands after a directory change; null unless it is a plain literal `cd `. */ +function baseAfterDirectoryChange(base, lead, tokens) { + if (!['cd', 'chdir', 'set-location', 'sl'].includes(lead) || tokens.length !== 2) return null; + const dir = tokens[1]; + if (!dir || /^[-$~]/.test(dir) || GLOB_CHARS_PATTERN.test(dir)) return null; + if (isAbsoluteShellPath(dir)) return [dir]; + return base === null ? null : base.concat(dir); +} + +function isAbsoluteShellPath(arg) { + return path.posix.isAbsolute(arg) || WINDOWS_PATH_PATTERN.test(arg); +} + +function operandScopePath(arg) { + if (!GLOB_CHARS_PATTERN.test(arg)) return arg; + const segments = arg.split(/[\\/]/); + const literal = segments.slice(0, segments.findIndex(segment => GLOB_CHARS_PATTERN.test(segment))); + if (literal.length === 0) return '.'; + return literal.join('/') || '/'; +} + +/** Scopes a shell segment searched; unresolvable operands (variables, `~`, unknown cwd) scope nothing. */ +function shellSearchScopes(parsed, base) { + if (parsed.operands.length === 0) return base === null ? [] : [base]; + const scopes = []; + for (const operand of parsed.operands) { + if (!operand || operand === '-' || /^[$~]/.test(operand)) continue; + const scoped = operandScopePath(operand); + if (isAbsoluteShellPath(scoped)) scopes.push([scoped]); + else if (base !== null) scopes.push(base.concat(scoped)); + } + return scopes; +} + +/** Evidence for each search segment of a shell command; ambiguous or oversized commands yield none. */ +function shellSearchEvidence(command, dirsTrusted) { + if (typeof command !== 'string' || !command || command.length > MAX_SEARCH_COMMAND_CHARS) return []; + if (AMBIGUOUS_SHELL_PATTERN.test(command)) return []; + const pipeFlags = segmentPipeFlags(command); + const evidence = []; + let trusted = dirsTrusted; + // A cd in any other call of the turn leaves the cwd of this one unknown. + let base = dirsTrusted ? [] : null; + quoteAwareSegments(command).forEach((tokens, index) => { + const lead = commandBasename(tokens[0]); + if (DIRECTORY_CHANGE_COMMANDS.has(lead)) { + trusted = false; + base = baseAfterDirectoryChange(base, lead, tokens); + return; + } + if (!SHELL_SEARCH_COMMANDS.has(lead)) return; + let kind = lead; + let args = tokens.slice(1); + if (lead === 'git') { + if (!GIT_SEARCH_SUBCOMMANDS.has(tokens[1])) return; + kind = `git ${tokens[1]}`; + args = tokens.slice(2); + } + const parsed = parseSearchArgs(kind, args); + // A segment that reads stdin (redirection, pipe, or no operand without recursion) is not a tree search. + if (parsed.stdin) return; + if (parsed.operands.length === 0 && (!parsed.recursive || pipeFlags[index] !== false)) return; + const scopes = shellSearchScopes(parsed, base); + if (scopes.length === 0) return; + const filters = shellSearchFilters(kind, args); + // Exclusion values (and the flags that introduce them) are never a stem source. + const stemText = tokens.slice(0, tokens.length - args.length).concat(args.filter((_, i) => !filters.dropped.has(i))).join(' '); + const dirs = trusted ? parsed.dirOperands.map(shellDirCandidate).filter(Boolean).map(dir => [dir]) : []; + evidence.push({ + texts: [stemText], + dirs, + dirsMustExist: true, + scopes, + operands: parsed.operands.map(op => [op]), + detail: tokens.join(' '), + exclusions: filters.exclusions, + includes: filters.includes, + opaque: filters.opaque + }); + }); + return evidence; +} + +function isRecursiveFlag(kind, arg) { + const lower = arg.toLowerCase(); + if (POWERSHELL_SEARCHES.has(kind)) return (kind === 'get-childitem' || kind === 'gci') && lower.startsWith('-rec'); + if (arg === '--recursive' || arg === '--dereference-recursive') return GREP_FAMILY.has(kind) || kind === 'ls'; + if (!/^-[a-zA-Z]+$/.test(arg)) return false; + if (GREP_FAMILY.has(kind)) return /[rR]/.test(arg); + return kind === 'ls' && arg.includes('R'); +} + +function valueFlagsFor(kind) { + if (kind === 'ls' || kind === 'git ls-files') return LS_VALUE_FLAGS; + if (kind === 'tree') return TREE_VALUE_FLAGS; + if (kind === 'fd') return FD_VALUE_FLAGS; + return VALUE_FLAGS; +} + +/** Arguments a flag consumes; single-dash clusters (`-rne PAT`) are read letter by letter. */ +function flagEffect(kind, arg) { + if (POWERSHELL_SEARCHES.has(kind)) { + const lower = arg.toLowerCase(); + return { consumes: POWERSHELL_VALUE_FLAGS.has(lower) ? 1 : 0, patternFlag: lower === '-pattern', pathFlag: POWERSHELL_PATH_FLAGS.has(lower) }; + } + const values = valueFlagsFor(kind); + const takesValue = flag => values.has(flag) || (kind === 'rg' && (flag === '-r' || flag === '--replace')); + const isPatternFlag = flag => PATTERN_FLAG_SEARCHES.has(kind) && PATTERN_VALUE_FLAGS.has(flag); + const name = arg.split('=')[0]; + if (arg.startsWith('--') || kind === 'find' || takesValue(arg)) { + return { consumes: !arg.includes('=') && takesValue(name) ? 1 : 0, patternFlag: isPatternFlag(name), pathFlag: false }; + } + for (let i = 1; i < arg.length; i++) { + const flag = `-${arg[i]}`; + if (takesValue(flag)) return { consumes: i === arg.length - 1 ? 1 : 0, patternFlag: isPatternFlag(flag), pathFlag: false }; + } + return { consumes: 0, patternFlag: false, pathFlag: false }; +} + +/** Path operands of a search command, skipping flag values and the pattern positional. */ +function parseSearchArgs(kind, args) { + const positionals = []; + const pathValues = []; + let recursive = RECURSIVE_BY_DEFAULT.has(kind); + let patternGiven = false; + let expressionStarted = false; + let inputRedirect = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (/^\d*[<>]/.test(arg)) { + inputRedirect = /^\d* 1 && arg.startsWith('-')) { + expressionStarted = true; + if (isRecursiveFlag(kind, arg)) recursive = true; + const effect = flagEffect(kind, arg); + if (effect.patternFlag) patternGiven = true; + if (effect.pathFlag && i + 1 < args.length) pathValues.push(args[i + 1]); + i += effect.consumes; + continue; + } + if (kind === 'find' && (expressionStarted || arg === '(' || arg === '!')) { + expressionStarted = true; + continue; + } + positionals.push(arg); + } + const dirOperands = PATTERN_FIRST_SEARCHES.has(kind) && !patternGiven ? positionals.slice(1) : positionals; + const operands = dirOperands.concat(pathValues); + return { operands, dirOperands, recursive, stdin: inputRedirect && operands.length === 0 }; +} + +function shellDirCandidate(arg) { + if (!arg || /^[$~]/.test(arg)) return ''; + const dir = arg.replace(/(?:[\\/]\*\*|[\\/]\*|[\\/])+$/, ''); + if (!dir || dir === '.' || GLOB_CHARS_PATTERN.test(dir)) return ''; + return dir; +} + +/** A directory change anywhere in the turn makes relative shell paths unreliable. */ +function turnChangesDirectory(shellCommands) { + if (!Array.isArray(shellCommands)) return false; + return shellCommands.some(command => { + if (typeof command !== 'string') return false; + if (command.length > MAX_SEARCH_COMMAND_CHARS) return true; + return quoteAwareSegments(command).some(tokens => DIRECTORY_CHANGE_COMMANDS.has(commandBasename(tokens[0]))); + }); +} + +function evidenceInScope(item, ctx) { + if (item.scopes) { + const covers = parts => { + const scope = ctx.resolveDir(...parts); + return scope === ctx.targetKey || isInsideDir(ctx.targetKey, scope); + }; + if (!item.scopes.some(covers)) return false; + } + if (item.scope) { + const scope = ctx.resolveDir(...item.scope); + if (scope === ctx.targetKey || !isInsideDir(ctx.targetKey, scope)) return false; + if (item.scopeExact && ctx.targetDir !== scope) return false; + } + if (item.operands && item.operands.length > 0 && item.operands.every(parts => ctx.resolveDir(...parts) === ctx.targetKey)) { + return false; // reading the target itself is not a search + } + return true; +} + +function evidenceNamesDir(item, ctx) { + return item.dirs.some( + parts => ctx.resolveDir(...parts) === ctx.targetDir && (!item.dirsMustExist || isDirectoryNow(ctx.resolveNative(...parts))) + ); +} + +// --- Search filters --- +// A search that excluded the target never saw it, so exclusions are never a stem +// source and one covering the target blocks credit; unreadable exclusion lists +// block it too. Globs are matched without RegExp and bounded; past the bounds they cover the target. + +const MAX_FILTER_GLOB_CHARS = 256; +const MAX_BRACE_ALTERNATIVES = 32; +const MAX_FILTERS_PER_SEARCH = 32; +const FIND_NAME_FLAGS = new Set(['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex']); +const FIND_BASENAME_FLAGS = new Set(['-name', '-iname']); +const OPAQUE_FILTER_FLAGS = new Set(['--exclude-from', '--ignore-file', '--exclude-per-directory']); +const EXCLUDE_LONG_FLAGS = new Set(['--exclude', '--exclude-dir', '--ignore', '--hide']); +const LS_ONLY_EXCLUDE_FLAGS = new Set(['--ignore', '--hide']); +const GIT_EXCLUDE_PATHSPEC = /^:(?:[!^]|\([^)]*\bexclude\b[^)]*\))/; + +function splitTopLevelCommas(value) { + const parts = []; + let depth = 0; + let current = ''; + for (const ch of String(value)) { + if (ch === '{') depth += 1; + else if (ch === '}' && depth > 0) depth -= 1; + if (ch === ',' && depth === 0) { + parts.push(current); + current = ''; + } else { + current += ch; + } + } + parts.push(current); + return parts.map(part => part.trim()).filter(Boolean); +} + +function expandBraces(glob) { + const open = glob.indexOf('{'); + if (open === -1) return [glob]; + let depth = 0; + for (let i = open; i < glob.length; i++) { + if (glob[i] === '{') depth += 1; + else if (glob[i] === '}' && --depth === 0) { + const head = glob.slice(0, open); + const tail = glob.slice(i + 1); + const results = []; + for (const option of splitTopLevelCommas(glob.slice(open + 1, i)).concat(glob[open + 1] === ',' ? [''] : [])) { + const expanded = expandBraces(`${head}${option}${tail}`); + if (!expanded) return null; + results.push(...expanded); + if (results.length > MAX_BRACE_ALTERNATIVES) return null; + } + return results; + } + } + return [glob]; // unbalanced: `{` is literal +} + +function globTokens(glob) { + const tokens = []; + for (let i = 0; i < glob.length; i++) { + const ch = glob[i]; + if (ch === '*' && glob[i + 1] === '*') { + const dirs = glob[i + 2] === '/'; + tokens.push({ type: dirs ? 'dirs' : 'deep' }); + i += dirs ? 2 : 1; + } else if (ch === '*') { + tokens.push({ type: 'star' }); + } else if (ch === '?') { + tokens.push({ type: 'one' }); + } else if (ch === '[' && glob.indexOf(']', i + 2) !== -1) { + tokens.push({ type: 'one' }); // any character class: over-approximated as one character + i = glob.indexOf(']', i + 2); + } else { + tokens.push({ type: 'lit', ch }); + } + } + return tokens; +} + +/** Glob match by dynamic programming, so a hostile glob cannot cause regex backtracking. */ +function wildcardMatch(glob, text) { + const tokens = globTokens(glob); + const n = text.length; + let next = new Array(n + 1).fill(false); + next[n] = true; + for (let t = tokens.length - 1; t >= 0; t--) { + const token = tokens[t]; + const row = new Array(n + 1).fill(false); + let afterSlash = false; // for 'dirs': some k > j with text[k-1] === '/' and next[k] + for (let j = n; j >= 0; j--) { + if (token.type === 'lit') row[j] = j < n && text[j] === token.ch && next[j + 1]; + else if (token.type === 'one') row[j] = j < n && text[j] !== '/' && next[j + 1]; + else if (token.type === 'star') row[j] = next[j] || (j < n && text[j] !== '/' && row[j + 1]); + else if (token.type === 'deep') row[j] = next[j] || (j < n && row[j + 1]); + else { + if (j < n && text[j] === '/' && next[j + 1]) afterSlash = true; + row[j] = next[j] || afterSlash; + } + } + next = row; + } + return next[0]; +} + +function normalizeFilterGlob(glob) { + return String(glob).toLowerCase().replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/^\/+/, '').replace(/\/+$/, ''); +} + +/** Null when the glob is past the length or brace bounds. */ +function globMatches(glob, text) { + if (glob.length > MAX_FILTER_GLOB_CHARS) return null; + const alternatives = expandBraces(glob); + if (!alternatives) return null; + return alternatives.some(alternative => wildcardMatch(alternative, text)); +} + +function targetSegments(ctx) { + return ctx.targetKey.toLowerCase().split('/').filter(Boolean); +} + +/** An exclusion covers the target when it names the stem or matches the file or any directory above it. */ +function exclusionCoversTarget(exclusion, segments, stem) { + const glob = normalizeFilterGlob(exclusion); + if (!glob) return true; + if (stem !== null && stem.test(glob)) return true; + for (let start = 0; start < segments.length; start++) { + for (let end = start + 1; end <= segments.length; end++) { + const matched = globMatches(glob, segments.slice(start, end).join('/')); + if (matched !== false) return true; + } + } + return false; +} + +function filtersAdmitTarget(item, ctx, stem) { + if (item.opaque) return false; + const exclusions = Array.isArray(item.exclusions) ? item.exclusions : []; + if (exclusions.length === 0) return true; + if (exclusions.length > MAX_FILTERS_PER_SEARCH) return false; + const segments = targetSegments(ctx); + return !exclusions.some(exclusion => exclusionCoversTarget(exclusion, segments, stem)); +} + +/** Basename include globs that all miss the target stop its stem from crediting; oversized globs admit. */ +function includesAdmitTarget(item, ctx) { + const includes = Array.isArray(item.includes) ? item.includes : []; + if (includes.length === 0) return true; + const segments = targetSegments(ctx); + const base = segments[segments.length - 1] || ''; + return includes.some(include => globMatches(normalizeFilterGlob(include).replace(/^(?:\*\*\/)+/, ''), base) !== false); +} + +function isBasenameGlob(glob) { + return !/[\\/]/.test(String(glob).replace(/^(?:\*\*[\\/])+/, '')); +} + +function grepToolFilters(glob) { + const filters = { positives: [], includes: [], exclusions: [], opaque: false }; + if (typeof glob !== 'string' || !glob) return filters; + for (const part of splitTopLevelCommas(glob)) { + if (part.startsWith('!')) filters.exclusions.push(part.slice(1)); + else { + filters.positives.push(part); + if (isBasenameGlob(part)) filters.includes.push(part); + } + } + return filters; +} + +function shellSearchFilters(kind, args) { + const filters = { exclusions: [], includes: [], opaque: false, dropped: new Set() }; + if (kind === 'find') return findSearchFilters(args, filters); + if (POWERSHELL_SEARCHES.has(kind)) return powershellSearchFilters(args, filters); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (/^\d*[<>]/.test(arg)) break; + const consumed = applyShellFilterArg(kind, args, i, filters); + i += consumed; + } + return filters; +} + +function applyShellFilterArg(kind, args, i, filters) { + const arg = args[i]; + const exclude = (value, indexes, separator) => { + for (const glob of separator ? String(value).split(separator) : [value]) { + if (glob) filters.exclusions.push(glob); + } + indexes.forEach(index => filters.dropped.add(index)); + }; + const include = value => { + if (isBasenameGlob(value)) filters.includes.push(value); + }; + if (kind.startsWith('git ') && GIT_EXCLUDE_PATHSPEC.test(arg)) { + exclude(arg.replace(GIT_EXCLUDE_PATHSPEC, ''), [i]); + return 0; + } + if (arg.startsWith('--')) { + const eq = arg.indexOf('='); + const name = eq > 0 ? arg.slice(0, eq) : arg; + const hasNext = eq <= 0 && i + 1 < args.length; + const value = eq > 0 ? arg.slice(eq + 1) : hasNext ? args[i + 1] : ''; + const indexes = eq > 0 || !hasNext ? [i] : [i, i + 1]; + const consumed = indexes.length - 1; + if (OPAQUE_FILTER_FLAGS.has(name)) { + filters.opaque = true; + return consumed; + } + if (name === '--glob' || name === '--iglob') { + if (value.startsWith('!')) exclude(value.slice(1), indexes); + else include(value); + return consumed; + } + if (name === '--include') { + include(value); + return consumed; + } + // `--ignore`/`--hide` hide names only for ls (rg's `--ignore` is a switch). + if (EXCLUDE_LONG_FLAGS.has(name) && (kind === 'ls' || !LS_ONLY_EXCLUDE_FLAGS.has(name))) { + exclude(value, indexes); + return consumed; + } + return 0; + } + if (!/^-[A-Za-z]/.test(arg)) return 0; + const letters = shortFilterLetters(kind); + const valueFlags = valueFlagsFor(kind); + for (let k = 1; k < arg.length; k++) { + const letter = arg[k]; + if (!letters.has(letter)) { + // Another value flag ends the cluster: the rest (or the next argument) is its value. + if (valueFlags.has(`-${letter}`)) return k === arg.length - 1 ? 1 : 0; + continue; + } + const attached = arg.slice(k + 1); + const value = attached || args[i + 1] || ''; + const indexes = attached || i + 1 >= args.length ? [i] : [i, i + 1]; + const consumed = indexes.length - 1; + applyShortFilter(kind, letter, value, indexes, { filters, exclude, include }); + return consumed; + } + return 0; +} + +function shortFilterLetters(kind) { + if (kind === 'rg') return new Set(['g']); + if (kind === 'fd') return new Set(['E']); + if (kind === 'ls') return new Set(['I']); + if (kind === 'tree') return new Set(['I', 'P']); + if (kind === 'git ls-files') return new Set(['x', 'X']); + return new Set(); +} + +function applyShortFilter(kind, letter, value, indexes, { filters, exclude, include }) { + if (kind === 'rg') { + if (value.startsWith('!')) exclude(value.slice(1), indexes); + else include(value); + } else if (kind === 'tree' && letter === 'P') { + value.split('|').forEach(include); + } else if (kind === 'git ls-files' && letter === 'X') { + filters.opaque = true; + } else { + exclude(value, indexes, kind === 'tree' ? '|' : ''); + } +} + +/** find: a negated or pruned name test is an exclusion; a plain `-name`/`-iname` is an include. */ +function findSearchFilters(args, filters) { + let negateNext = false; + let negatedDepth = 0; + let depth = 0; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (/^\d*[<>]/.test(arg)) break; + if (arg === '-not' || arg === '!') { + negateNext = true; + filters.dropped.add(i); + continue; + } + if (arg === '(') { + depth += 1; + if (negateNext && negatedDepth === 0) negatedDepth = depth; + negateNext = false; + continue; + } + if (arg === ')') { + if (negatedDepth === depth) negatedDepth = 0; + depth = Math.max(depth - 1, 0); + continue; + } + if (FIND_NAME_FLAGS.has(arg) && i + 1 < args.length) { + const value = args[i + 1]; + if (negateNext || negatedDepth > 0 || args[i + 2] === '-prune') { + filters.exclusions.push(value); + filters.dropped.add(i); + filters.dropped.add(i + 1); + } else if (FIND_BASENAME_FLAGS.has(arg)) { + filters.includes.push(value); + } + i += 1; + } + negateNext = false; + } + return filters; +} + +function powershellSearchFilters(args, filters) { + for (let i = 0; i < args.length - 1; i++) { + const name = args[i].toLowerCase(); + if (name === '-exclude') { + filters.exclusions.push(...splitTopLevelCommas(args[i + 1])); + filters.dropped.add(i); + filters.dropped.add(i + 1); + i += 1; + } else if (name === '-include' || name === '-filter') { + splitTopLevelCommas(args[i + 1]).filter(isBasenameGlob).forEach(glob => filters.includes.push(glob)); + i += 1; + } + } + return filters; +} + +/** + * A completed search in the current turn that names the target by stem (or by + * directory, for a new-file Write). Searches in the pending call's own batch + * never count: their results were not seen when the edit was decided. + */ +function findCreditingSearch(scan, targetPath, allowDirMatch, data) { + try { + if (!scan || !Array.isArray(scan.searches) || scan.searches.length === 0) return null; + if (typeof targetPath !== 'string' || !targetPath) return null; + const ctx = dirContext(targetPath, data); + if (!ctx) return null; + const stem = stemMatcher(eligibleStem(targetPath)); + const excluded = excludedBatchId(scan, data); + const shellDirsTrusted = !turnChangesDirectory(scan.shellCommands); + for (const search of scan.searches) { + if (typeof search.messageId !== 'string' || !search.messageId || search.messageId === excluded) continue; + for (const item of searchEvidence(search, shellDirsTrusted)) { + if (!evidenceInScope(item, ctx) || !filtersAdmitTarget(item, ctx, stem)) continue; + const byStem = stem !== null && includesAdmitTarget(item, ctx) && item.texts.some(text => stem.test(text.toLowerCase())); + const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx); + if (byStem || byDir) { + return { name: search.name, detail: item.detail, callsAgo: search.callsAgo }; + } + } + } + return null; + } catch (_) { + return null; + } +} + +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)}.` + ); +} + +/** Only a positively absent target is new; any other fs error counts as existing. */ +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 siblingNote(gate) { + return ( + `[Fact-Forcing Gate] Sibling of ${sanitizePath(gate.first)} (gated earlier at denial #${gate.ordinal} this session); ` + + 'proceeding without a repeat denial.' + ); +} + // --- Core logic (exported for run-with-flags.js) --- function run(rawInput) { @@ -1859,6 +2952,7 @@ function run(rawInput) { const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', bash: 'Bash', powershell: 'PowerShell' }; const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName; const inSubagent = isSubagentInvocation(data); + const getTurnScan = createTurnScanner(data); if (toolName === 'Edit' || toolName === 'Write') { const filePath = toolInput.file_path || ''; @@ -1870,18 +2964,40 @@ function run(rawInput) { return rawInput; // parent session already passed the first-touch file gate } - if (!isChecked(filePath)) { - const { ok, denials } = markCheckedAndCountDenial(filePath); + const fileKey = canonicalPathKey(filePath, data); + if (!isChecked([fileKey, filePath])) { + const isNewFile = toolName === 'Write' && isNewFileTarget(filePath, data); + const cls = classifyTargetFor(filePath, data); + // Sensitive targets skip credit, sibling collapse and the cap. + const sensitive = isSensitiveTargetFor(filePath, data); + const credit = sensitive ? null : findCreditingSearch(getTurnScan(), filePath, isNewFile, data); + if (credit) { + if (!markCheckedAndCountCredit(fileKey, cls)) { + return allowWithStateWarning(); + } + return { additionalContext: creditNote(credit, filePath), exitCode: 0 }; + } + const turnId = isNewFile && !sensitive ? currentTurnId(getTurnScan()) : null; + // The 120 s no-turn rule applies only without any transcript; an unusable one never collapses. + const collapsible = isNewFile && !sensitive && (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(); + } + return { additionalContext: siblingNote(sibling), exitCode: 0 }; + } + const dirGate = gateKey ? { key: gateKey, turn: turnId, first: sanitizePath(filePath) } : undefined; + const cap = sensitive ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); + const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, dirGate, cap }); if (!ok) { return allowWithStateWarning(); } - if (denials > getFullDenialBudget()) { - const action = toolName === 'Edit' ? 'edit' : 'creation'; - return denyResult(condensedGateMsg(action, filePath, denials), { includeRecoveryHint: false }); + if (denials === null) { + return rawInput; // session denial cap reached (GATEGUARD_FACT_FORCE_MAX_DENIALS) } - return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath), { - narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT - }); + return firstTouchDenial(filePath, { isWrite: toolName === 'Write', denials, cls, sensitive }); } return rawInput; // allow @@ -1893,21 +3009,36 @@ function run(rawInput) { } const edits = toolInput.edits || []; + const creditNotes = []; for (const edit of edits) { const filePath = edit.file_path || ''; - if (filePath && !isClaudeSettingsPath(filePath) && !isExemptPath(filePath, data) && !isChecked(filePath)) { - const { ok, denials } = markCheckedAndCountDenial(filePath); + if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) continue; + const fileKey = canonicalPathKey(filePath, data); + if (!isChecked([fileKey, filePath])) { + const cls = classifyTargetFor(filePath, data); + const sensitive = isSensitiveTargetFor(filePath, data); + const credit = sensitive ? null : findCreditingSearch(getTurnScan(), filePath, false, data); + if (credit) { + if (!markCheckedAndCountCredit(fileKey, cls)) { + return allowWithStateWarning(); + } + creditNotes.push(creditNote(credit, filePath)); + continue; + } + const cap = sensitive ? Number.POSITIVE_INFINITY : getMaxDenialBudget(); + const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, cap }); if (!ok) { return allowWithStateWarning(); } - if (denials > getFullDenialBudget()) { - return denyResult(condensedGateMsg('edit', filePath, denials), { includeRecoveryHint: false }); + if (denials === null) { + continue; // session denial cap reached (GATEGUARD_FACT_FORCE_MAX_DENIALS) } - return denyResult(editGateMsg(filePath), { - narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT - }); + return firstTouchDenial(filePath, { isWrite: false, denials, cls, sensitive }); } } + if (creditNotes.length > 0) { + return { additionalContext: creditNotes, exitCode: 0 }; + } return rawInput; // allow } @@ -1957,4 +3088,4 @@ function run(rawInput) { return rawInput; // allow } -module.exports = { classifyDestructiveCommand, run }; +module.exports = { classifyDestructiveCommand, classifyTarget, classifyTargetFor, findCreditingSearch, run, scanCurrentTurn }; diff --git a/scripts/lib/gateguard-target-class.js b/scripts/lib/gateguard-target-class.js new file mode 100644 index 000000000..f02030a96 --- /dev/null +++ b/scripts/lib/gateguard-target-class.js @@ -0,0 +1,378 @@ +/** + * GateGuard target identity and classification for first-touch + * Edit/Write/MultiEdit targets: canonical path keys, target classes and their + * questions, sensitive-target detection, and sibling-collapse eligibility. + * Stateless; filesystem access is limited to worktree `.git` checks and + * realpath/lstat of the target's parent chain. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const WINDOWS_PATH_PATTERN = /^[a-z]:[\\/]|^\\\\/i; + +/** Canonical checked-state key, so `a.py`, `./a.py` and the absolute path share one gate. */ +function canonicalPathKey(filePath, data) { + try { + const target = resolveTargetPath(filePath, data); + if (!target) return filePath; + const key = target.resolved.replace(/\\/g, '/'); + return target.isWin ? key.toLowerCase() : key; + } catch (_) { + return filePath; + } +} + +/** + * Resolve a target to the file the tool will touch. Relative paths resolve + * against the tool's `cwd` first; this deliberately differs from + * `isExemptPath`, whose globs are project-relative. + */ +function resolveTargetPath(filePath, data) { + const base = (data && data.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd(); + if (typeof base !== 'string' || typeof filePath !== 'string') return null; + const isWin = WINDOWS_PATH_PATTERN.test(base) || WINDOWS_PATH_PATTERN.test(filePath); + const paths = isWin ? path.win32 : path.posix; + if (!paths.isAbsolute(base)) return null; + return { resolved: paths.resolve(base, filePath), isWin }; +} + +// --- Target classes --- + +const INSTRUCTION_BASENAMES = new Set([ + 'claude.md', + 'agents.md', + 'agent.md', + 'gemini.md', + 'skill.md', + 'copilot-instructions.md', + '.cursorrules', + '.windsurfrules' +]); +// Cursor rule files are instructions wherever they live. +const INSTRUCTION_ANYWHERE_EXTS = new Set(['.mdc']); +const INSTRUCTION_DIRS = new Set(['.claude', 'agents', 'commands', 'skills', 'rules', 'hooks', '.cursor', '.codex', '.opencode']); +const INSTRUCTION_EXTS = new Set(['.md', '.mdx', '.txt']); +const TEST_DIRS = new Set(['tests', 'test', '__tests__']); +const TEST_BASENAME_PATTERN = /\.(test|spec)\.|^test_.*\.py$|_test\.(py|go)$/; +const PROSE_EXTS = new Set(['.md', '.mdx', '.txt', '.rst', '.adoc']); +const CONFIG_EXTS = new Set(['.json', '.jsonc', '.yaml', '.yml', '.toml', '.ini']); +const ENV_BASENAME_PATTERN = /^\.env($|\.)/; +// `.env.example.ts` and friends are source files that happen to start with `.env`. +const CODE_EXTS = new Set(['.ts', '.js', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb']); + +/** Classify a path; first match wins: instruction, test, prose, config, code (the fallback). */ +function classifyTarget(filePath) { + try { + const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase(); + const segments = normalized.split('/').filter(Boolean); + const base = segments.pop() || ''; + const ext = path.posix.extname(base); + if (INSTRUCTION_BASENAMES.has(base) || INSTRUCTION_ANYWHERE_EXTS.has(ext)) return 'instruction'; + if (INSTRUCTION_EXTS.has(ext) && segments.some(segment => INSTRUCTION_DIRS.has(segment))) return 'instruction'; + if (ext === '.md' && base.includes('instructions') && segments.includes('.github')) return 'instruction'; + if (TEST_BASENAME_PATTERN.test(base) || segments.some(segment => TEST_DIRS.has(segment))) return 'test'; + if (PROSE_EXTS.has(ext)) return 'prose'; + if (CONFIG_EXTS.has(ext) || (ENV_BASENAME_PATTERN.test(base) && !CODE_EXTS.has(ext))) return 'config'; + return 'code'; + } catch (_) { + return 'code'; + } +} + +const WORKTREE_PREFIX_PATTERN = /^\.claude\/worktrees\/([^/]+)\//; + +/** A Windows-style path has no real location on a POSIX host, and vice versa. */ +function isHostPathStyle(isWin) { + return isWin === (process.platform === 'win32'); +} + +function foldKey(nativePath, isWin) { + const key = nativePath.replace(/\\/g, '/'); + return isWin ? key.toLowerCase() : key; +} + +/** Project root, resolved the way `isExemptPath` resolves it (CLAUDE_PROJECT_DIR first). */ +function canonicalProjectRoot(data) { + const root = process.env.CLAUDE_PROJECT_DIR || (data && data.cwd) || process.cwd(); + if (typeof root !== 'string') return null; + const isWin = WINDOWS_PATH_PATTERN.test(root); + const paths = isWin ? path.win32 : path.posix; + if (!paths.isAbsolute(root)) return null; + const native = paths.resolve(root); + return { key: foldKey(native, isWin), native, isWin }; +} + +/** Only a real worktree (its `.git` exists) has its prefix stripped; otherwise the path stays under `.claude`. */ +function isRealWorktree(rootNative, isWin, name) { + if (!isHostPathStyle(isWin)) return false; + const paths = isWin ? path.win32 : path.posix; + return fs.existsSync(paths.join(rootNative, '.claude', 'worktrees', name, '.git')); +} + +// Windows ignores trailing dots/spaces and treats `name:stream` as `name`. +const STREAM_MARKER = '::$'; + +/** Normalize segments as Windows resolves them, so `CLAUDE.md.` and `CLAUDE.md::$DATA` name `CLAUDE.md`. */ +function normalizeWindowsSegments(classPath, isWin) { + const segments = classPath.split('/'); + const last = segments.length - 1; + return segments + .map((segment, index) => { + if (!isWin && !segment.includes(STREAM_MARKER)) return segment; + let name = segment; + const colon = name.indexOf(':'); + if (index === last && colon > 0) name = name.slice(0, colon); + name = name.replace(/[. ]+$/, ''); + return name || segment; + }) + .join('/'); +} + +/** Project-relative class path for a canonical key, or null when it is outside the root. */ +function projectRelativeClassPath(key, root, isWin) { + const target = isWin ? key.toLowerCase() : key; + const rootKey = isWin ? root.key.toLowerCase() : root.key; + const prefix = rootKey.endsWith('/') ? rootKey : `${rootKey}/`; + if (!target.startsWith(prefix)) return null; + let relative = key.slice(prefix.length); + const worktree = relative.match(WORKTREE_PREFIX_PATTERN); + if (worktree && isRealWorktree(root.native, root.isWin, worktree[1])) relative = relative.slice(worktree[0].length); + return normalizeWindowsSegments(relative, isWin); +} + +/** + * The path a target is classified by: project-relative, so an ancestor such as + * `/home/me/tests/proj` never leaks into the class, with a real worktree prefix + * stripped. Targets outside the root keep their absolute path. + */ +function classPathFor(filePath, data) { + try { + const key = canonicalPathKey(filePath, data); + const root = canonicalProjectRoot(data); + const isWin = Boolean(root && root.isWin) || WINDOWS_PATH_PATTERN.test(key); + if (!root) return normalizeWindowsSegments(key, isWin); + const relative = projectRelativeClassPath(key, root, isWin); + return relative === null ? normalizeWindowsSegments(key, isWin) : relative; + } catch (_) { + return filePath; + } +} + +/** Class of a hook target, judged on its project-relative path. */ +function classifyTargetFor(filePath, data) { + return classifyTarget(classPathFor(filePath, data)); +} + +const SEARCH_THE_TREE = '(search the tree — Glob/Grep, or find/grep via Bash)'; +const QUOTE_INSTRUCTION = "Quote the user's current instruction verbatim"; + +const CLASS_QUESTIONS = { + instruction: () => [ + 'Name the harness/loader that reads this file (Claude Code, Codex, Cursor, OpenCode, …) and when it loads it', + 'Describe what agent behaviour changes as a result', + `Confirm no existing instruction, skill, or agent file already covers this ${SEARCH_THE_TREE}` + ], + test: () => [ + 'Name what behaviour is under test and which module/function it exercises', + `Name the existing test file(s) covering this module, or confirm none exist ${SEARCH_THE_TREE}` + ], + prose: isWrite => + isWrite + ? [ + `Name any existing doc this supersedes or duplicates ${SEARCH_THE_TREE}`, + 'State where it will be linked or referenced from', + 'Explain why a new file rather than editing an existing one' + ] + : [ + `List other docs or code that reference the section being changed ${SEARCH_THE_TREE}`, + 'State what the change corrects or adds' + ], + config: () => [ + 'Name which process/tool reads this file and when', + 'Describe the effect of the change', + 'Confirm no secrets or credentials are being written in plain text' + ] +}; + +const CLASS_CONDENSED_HINTS = { + instruction: () => + "briefly state which harness loads this file, the agent behaviour it changes, that no existing instruction file covers it, and the user's verbatim instruction, then retry.", + test: () => + "briefly state the behaviour and module under test, existing test files for it (or none), and the user's verbatim instruction, then retry.", + prose: isWrite => + isWrite + ? "briefly state what this supersedes, where it is linked from, and the user's verbatim instruction, then retry." + : "briefly state what references the changed section, what the change corrects or adds, and the user's verbatim instruction, then retry.", + config: () => + "briefly state which process reads this file, the effect of the change, that no secrets are written in plain text, and the user's verbatim instruction, then retry." +}; + +// --- Sensitive targets --- +// Secrets, keys, auth/payment code, migrations and CI workflows always draw the +// first-touch denial. Basename and segment rules are exact, never substrings. + +const SENSITIVE_EXTS = new Set(['.pem', '.key', '.p12', '.pfx']); +const SENSITIVE_BASENAME_PATTERN = /^(\.env($|\.)|id_rsa|id_ed25519|credentials|secrets\.)/; +const SENSITIVE_SEGMENTS = new Set(['auth', 'authn', 'authz', 'security', 'secrets', 'payment', 'payments', 'billing', 'migrations']); +const SENSITIVE_PREFIX = '.github/workflows/'; + +/** True for a sensitive class path; any error or non-string input is sensitive. */ +function isSensitiveTarget(classPath) { + try { + if (typeof classPath !== 'string' || !classPath) return true; + const normalized = classPath.replace(/\\/g, '/').toLowerCase(); + const segments = normalized.split('/').filter(Boolean); + const base = segments[segments.length - 1] || ''; + if (SENSITIVE_BASENAME_PATTERN.test(base) || SENSITIVE_EXTS.has(path.posix.extname(base))) return true; + if (segments.some(segment => SENSITIVE_SEGMENTS.has(segment))) return true; + // Also matched below an ancestor (unverified worktree, target outside the root): fail safe. + return `/${segments.join('/')}`.includes(`/${SENSITIVE_PREFIX}`); + } catch (_) { + return true; + } +} + +/** + * Sensitive on the lexical path or on the real (symlink-resolved) location, so + * `src/tools -> ../auth` cannot launder `src/tools/login.py`. Any error is sensitive. + */ +function isSensitiveTargetFor(filePath, data) { + try { + if (typeof filePath !== 'string' || !filePath) return true; + if (isSensitiveTarget(classPathFor(filePath, data))) return true; + return isSensitiveRealTarget(filePath, data); + } catch (_) { + return true; + } +} + +/** Unresolvable real locations (dangling symlink, ENOTDIR, EACCES, loops) count as sensitive. */ +function isSensitiveRealTarget(filePath, data) { + const target = resolveTargetPath(filePath, data); + if (!target || !isHostPathStyle(target.isWin)) return false; + const paths = target.isWin ? path.win32 : path.posix; + const real = realTargetPath(target.resolved, paths); + if (!real) return true; + const realKey = foldKey(real, target.isWin); + if (realKey === foldKey(target.resolved, target.isWin)) return false; + const root = canonicalProjectRoot(data); + if (root && root.isWin === target.isWin) { + const realRoot = realpathOfNearestAncestor(root.native, paths); + if (!realRoot) return true; + const realRootInfo = { ...root, key: foldKey(realRoot, target.isWin), native: realRoot }; + const realClassPath = projectRelativeClassPath(realKey, realRootInfo, target.isWin); + if (realClassPath !== null) return isSensitiveTarget(realClassPath); + } + return isSensitiveTarget(normalizeWindowsSegments(realKey, target.isWin)); +} + +// --- Sibling collapse eligibility --- + +const COLLAPSIBLE_CLASSES = new Set(['code', 'test', 'prose']); +// 8.3 short names (`CLAUDE~1`) can alias any directory, including dot-directories. +const SHORT_NAME_PATTERN = /~\d/; + +/** Harness and tooling directories are dot-directories, so any dot segment blocks collapse. */ +function isCollapsibleClassPath(classPath, cls) { + if (!COLLAPSIBLE_CLASSES.has(cls)) return false; + const segments = String(classPath).toLowerCase().split('/').filter(Boolean); + if (segments.length === 0) return false; + return !segments.some(segment => segment.startsWith('.') || SHORT_NAME_PATTERN.test(segment)); +} + +/** Lexical collapse screen; `collapseGateDir` adds the real-directory screen. */ +function isCollapsibleTarget(filePath, data, cls) { + return isCollapsibleClassPath(classPathFor(filePath, data), cls); +} + +/** Realpath of a directory via its nearest existing ancestor; null on any error but a plain missing entry. */ +function realpathOfNearestAncestor(nativePath, paths) { + const realpath = fs.realpathSync.native || fs.realpathSync; + const missing = []; + let current = nativePath; + for (;;) { + try { + const real = realpath(current); + if (!fs.statSync(real).isDirectory()) return null; + return missing.length > 0 ? paths.join(real, ...missing.reverse()) : real; + } catch (error) { + if (!error || error.code !== 'ENOENT' || !isMissingEntry(current)) return null; + const parent = paths.dirname(current); + if (parent === current) return null; + missing.push(paths.basename(current)); + current = parent; + } + } +} + +/** Real path of a file target, or of its nearest existing ancestor plus the missing tail. */ +function realTargetPath(resolved, paths) { + const realpath = fs.realpathSync.native || fs.realpathSync; + try { + return realpath(resolved); + } catch (error) { + if (!error || error.code !== 'ENOENT' || !isMissingEntry(resolved)) return null; + } + const realDir = realpathOfNearestAncestor(paths.dirname(resolved), paths); + return realDir ? paths.join(realDir, paths.basename(resolved)) : null; +} + +/** True when nothing (not even a dangling symlink) exists at the path. */ +function isMissingEntry(nativePath) { + try { + fs.lstatSync(nativePath); + return false; + } catch (error) { + return Boolean(error) && error.code === 'ENOENT'; + } +} + +/** + * Real directory a new file's sibling gate is keyed by, or null when it may not + * collapse. A symlinked directory must keep the class and pass the same screen, + * so `src/tools -> .claude/hooks` never collapses. + */ +function collapseGateDir(filePath, data, cls) { + try { + if (!isCollapsibleTarget(filePath, data, cls)) return null; + const target = resolveTargetPath(filePath, data); + if (!target || !isHostPathStyle(target.isWin)) return null; + const paths = target.isWin ? path.win32 : path.posix; + const lexicalDir = paths.dirname(target.resolved); + const realDir = realpathOfNearestAncestor(lexicalDir, paths); + if (!realDir) return null; + const realKey = foldKey(realDir, target.isWin); + if (realKey === foldKey(lexicalDir, target.isWin)) return realKey; + const root = canonicalProjectRoot(data); + if (!root || root.isWin !== target.isWin) return null; + const realRoot = realpathOfNearestAncestor(root.native, paths); + if (!realRoot) return null; + const realTargetKey = foldKey(paths.join(realDir, paths.basename(target.resolved)), target.isWin); + const realClassPath = projectRelativeClassPath(realTargetKey, { ...root, key: foldKey(realRoot, target.isWin), native: realRoot }, target.isWin); + if (realClassPath === null || classifyTarget(realClassPath) !== cls) return null; + if (isSensitiveTarget(realClassPath)) return null; + return isCollapsibleClassPath(realClassPath, cls) ? realKey : null; + } catch (_) { + return null; + } +} + +module.exports = { + WINDOWS_PATH_PATTERN, + COLLAPSIBLE_CLASSES, + CLASS_QUESTIONS, + CLASS_CONDENSED_HINTS, + QUOTE_INSTRUCTION, + resolveTargetPath, + canonicalPathKey, + classifyTarget, + classPathFor, + classifyTargetFor, + isCollapsibleTarget, + collapseGateDir, + isSensitiveTarget, + isSensitiveTargetFor +}; diff --git a/scripts/lib/gateguard-turn-scan.js b/scripts/lib/gateguard-turn-scan.js new file mode 100644 index 000000000..30764abb6 --- /dev/null +++ b/scripts/lib/gateguard-turn-scan.js @@ -0,0 +1,255 @@ +/** + * GateGuard current-turn transcript scan: one bounded tail read of a Claude + * Code JSONL transcript, walked back to the latest human message or + * compaction. Yields the turn id and the turn's completed, non-error search + * calls. Shell parsing stays in the hook, which shares it with the + * destructive-command gate. + */ + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./transcript-context'); + +const TRANSCRIPT_SCAN_MAX_LINES = 2000; +const SEARCH_TOOL_NAMES = new Set(['Glob', 'Grep', 'LS', 'Bash', 'PowerShell']); +const SHELL_TOOL_NAMES = new Set(['Bash', 'PowerShell']); +const TOOL_USE_ERROR_PREFIX = ''; + +function isObject(value) { + return Boolean(value) && typeof value === 'object'; +} + +/** Exports never throw: an error here must not escape into the hook's credit or sibling logic. */ +function neverThrows(fn, fallback) { + return (...args) => { + try { + return fn(...args); + } catch (_) { + return fallback; + } + }; +} + +/** Transcript path from the hook payload or CLAUDE_TRANSCRIPT_PATH; '' when none. */ +function transcriptPathFor(data) { + const candidate = (isObject(data) && (data.transcript_path || data.transcriptPath)) || process.env.CLAUDE_TRANSCRIPT_PATH; + return typeof candidate === 'string' ? candidate : ''; +} + +function pendingToolUseId(data) { + return isObject(data) && typeof data.tool_use_id === 'string' ? data.tool_use_id : ''; +} + +function recordContent(record) { + const message = isObject(record) ? record.message : undefined; + return isObject(message) ? message.content : undefined; +} + +function isSkippedTranscriptRecord(record) { + return ( + !isObject(record) || + Array.isArray(record) || + record.isSidechain === true || + record.isMeta === true || + (record.type !== 'user' && record.type !== 'assistant') + ); +} + +/** Compaction discards the earlier turn: its summary (or boundary marker) starts a new one. */ +function isCompactionBoundary(record) { + return ( + Boolean(record) && + typeof record === 'object' && + record.isSidechain !== true && + (record.isCompactSummary === true || (record.type === 'system' && record.subtype === 'compact_boundary')) + ); +} + +const MAX_PROMPT_ID_CHARS = 128; + +// Claude Code stamps every user record of a turn with the same `promptId`, so it +// identifies the turn even when the boundary has scrolled out of the tail window. +function recordPromptId(record) { + const id = isObject(record) ? record.promptId : undefined; + return typeof id === 'string' && id && id.length <= MAX_PROMPT_ID_CHARS ? id : null; +} + +function boundaryTurnId(record, line) { + if (typeof record.uuid === 'string' && record.uuid) return record.uuid; + return `h:${crypto.createHash('sha256').update(line).digest('hex').slice(0, 16)}`; +} + +/** + * Only a non-empty array of `tool_result` blocks is a tool-result record; any + * other user record is a turn boundary, which can only shrink the credit window. + */ +function isToolResultRecord(record) { + const content = recordContent(record); + return ( + Array.isArray(content) && + content.length > 0 && + content.every(block => block && typeof block === 'object' && block.type === 'tool_result') + ); +} + +function toolResultText(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + const first = content.find(block => block && typeof block === 'object' && block.type === 'text'); + return first && typeof first.text === 'string' ? first.text : ''; +} + +function isErrorToolResult(block) { + if (!isObject(block)) return false; + if (block.is_error === true || block.is_error === 'true') return true; + return toolResultText(block.content).trimStart().startsWith(TOOL_USE_ERROR_PREFIX); +} + +/** + * Scan the transcript tail back to the latest human turn. Returns null when the + * transcript is unavailable, else the turn id, the turn's searches (newest + * first), batch ids, and shell commands. The tail is bounded to keep the hook fast. + */ +function scanCurrentTurn(transcriptPath, pendingId = '') { + if (typeof transcriptPath !== 'string' || !transcriptPath) return null; + let stat; + try { + stat = fs.statSync(transcriptPath); + } catch (_) { + return null; + } + if (!stat.isFile()) return null; + const tail = readFileTail(transcriptPath, DEFAULT_TRANSCRIPT_TAIL_BYTES); + if (!tail) return null; + + const lines = tail.text.split('\n'); + const firstLine = Math.max(tail.truncated ? 1 : 0, lines.length - TRANSCRIPT_SCAN_MAX_LINES); + const okResultIds = new Set(); + const toolUses = []; + const batchIds = new Map(); + let newestMessageId = null; + const windowPromptIds = new Set(); + const finish = turnId => ({ + turnId, + searches: collectTurnSearches(toolUses, okResultIds, pendingId), + batchIds, + newestMessageId, + shellCommands: collectShellCommands(toolUses) + }); + + for (let i = lines.length - 1; i >= firstLine; i--) { + const line = lines[i].trim(); + if (!line) continue; + let record; + try { + record = JSON.parse(line); + } catch (_) { + continue; + } + if (isCompactionBoundary(record)) return finish(recordPromptId(record) || boundaryTurnId(record, line)); + if (isObject(record) && record.type === 'user' && record.isSidechain !== true) { + const id = recordPromptId(record); + if (id) windowPromptIds.add(id); + } + if (isSkippedTranscriptRecord(record)) continue; + + if (record.type === 'user') { + if (!isToolResultRecord(record)) return finish(recordPromptId(record) || boundaryTurnId(record, line)); + for (const block of recordContent(record)) { + if (typeof block.tool_use_id === 'string' && !isErrorToolResult(block)) { + okResultIds.add(block.tool_use_id); + } + } + continue; + } + + if (!isObject(record.message)) continue; + const content = recordContent(record); + const messageId = typeof record.message.id === 'string' && record.message.id ? record.message.id : null; + if (newestMessageId === null && messageId) newestMessageId = messageId; + if (!Array.isArray(content)) continue; + for (let j = content.length - 1; j >= 0; j--) { + const block = content[j]; + if (!block || typeof block !== 'object' || block.type !== 'tool_use') continue; + toolUses.push({ block, messageId }); + if (messageId && typeof block.id === 'string') batchIds.set(block.id, messageId); + } + } + // No boundary: a clipped window is the current turn only if its promptIds agree; + // two or more mean an unrecognised turn start. An unclipped file has no turn. + if ((tail.truncated || firstLine > 0) && windowPromptIds.size <= 1) { + return finish(windowPromptIds.size === 1 ? [...windowPromptIds][0] : null); + } + return { turnId: null, searches: [], batchIds: new Map(), newestMessageId: null, shellCommands: [] }; +} + +/** Completed, non-error search calls whose id occurs once, newest first. */ +function collectTurnSearches(toolUsesNewestFirst, okResultIds, pendingId) { + const idCounts = new Map(); + for (const { block } of toolUsesNewestFirst) { + if (typeof block.id === 'string') idCounts.set(block.id, (idCounts.get(block.id) || 0) + 1); + } + const searches = []; + const priorCalls = toolUsesNewestFirst.filter(({ block }) => !pendingId || block.id !== pendingId); + priorCalls.forEach(({ block, messageId }, index) => { + if (typeof block.id !== 'string' || !okResultIds.has(block.id)) return; + if (idCounts.get(block.id) !== 1) return; // a reused id cannot be tied to one result + if (!SEARCH_TOOL_NAMES.has(block.name)) return; + if (!block.input || typeof block.input !== 'object') return; + searches.push({ name: block.name, input: block.input, callsAgo: index + 1, messageId }); + }); + return searches; +} + +function collectShellCommands(toolUses) { + return toolUses + .filter(({ block }) => SHELL_TOOL_NAMES.has(block.name) && block.input && typeof block.input === 'object') + .map(({ block }) => (typeof block.input.command === 'string' ? block.input.command : '')); +} + +/** Assistant message whose searches share the pending call's batch (their results were not yet seen). */ +function excludedBatchId(scan, data) { + if (!isObject(scan)) return null; + const pendingId = pendingToolUseId(data); + const located = pendingId && scan.batchIds instanceof Map ? scan.batchIds.get(pendingId) : undefined; + if (located) return located; + return typeof scan.newestMessageId === 'string' ? scan.newestMessageId : null; +} + +/** Lazily scans once per hook invocation, shared by every MultiEdit entry. */ +function createTurnScanner(data) { + let scanned = false; + let scan = null; + return () => { + if (!scanned) { + scanned = true; + try { + scan = scanCurrentTurn(transcriptPathFor(data), pendingToolUseId(data)); + } catch (_) { + scan = null; + } + } + return scan; + }; +} + +function currentTurnId(scan) { + return scan && typeof scan.turnId === 'string' && scan.turnId ? scan.turnId : null; +} + +module.exports = { + TRANSCRIPT_SCAN_MAX_LINES, + SEARCH_TOOL_NAMES, + SHELL_TOOL_NAMES, + transcriptPathFor: neverThrows(transcriptPathFor, ''), + pendingToolUseId: neverThrows(pendingToolUseId, ''), + isCompactionBoundary: neverThrows(isCompactionBoundary, false), + isToolResultRecord: neverThrows(isToolResultRecord, false), + isErrorToolResult: neverThrows(isErrorToolResult, false), + scanCurrentTurn: neverThrows(scanCurrentTurn, null), + excludedBatchId: neverThrows(excludedBatchId, null), + createTurnScanner: neverThrows(createTurnScanner, () => null), + currentTurnId: neverThrows(currentTurnId, null) +}; diff --git a/scripts/lib/transcript-context.js b/scripts/lib/transcript-context.js index 853a35b2a..b6665a986 100644 --- a/scripts/lib/transcript-context.js +++ b/scripts/lib/transcript-context.js @@ -281,6 +281,7 @@ module.exports = { DEFAULT_CONTEXT_THRESHOLD_LARGE, DEFAULT_CONTEXT_INTERVAL_TOKENS, DEFAULT_TRANSCRIPT_TAIL_BYTES, + readFileTail, readLatestContextTokens, resolveContextWindow, resolveContextWindowTokens, diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 3bd3ba797..68c19fe1d 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -145,6 +145,12 @@ function parseOutput(stdout) { } } +// Canonical state keys fold separators and case on Windows-style paths, so lookups go through this. +const WINDOWS_STYLE_PATH = /^[a-z]:[\\/]|^\\\\/i; +function stateKey(p) { + return WINDOWS_STYLE_PATH.test(p) ? p.replace(/\\/g, '/').toLowerCase() : p; +} + function loadDirectHook(env = {}) { delete require.cache[require.resolve(hookScript)]; Object.assign(process.env, { @@ -1808,7 +1814,11 @@ function runTests() { const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8')); assert.ok(persisted.checked.includes('/src/concurrent.js'), 'concurrent disk entry should be preserved'); - assert.ok(persisted.checked.includes('/src/new-edit.js'), 'new in-memory entry should be persisted'); + // Canonical key: '/src/new-edit.js' on POSIX, ':/src/new-edit.js' when cwd is a Windows path. + assert.ok( + persisted.checked.some(k => k === '/src/new-edit.js' || /^[a-z]:\/src\/new-edit\.js$/.test(k)), + 'new in-memory entry should be persisted' + ); }) ) passed++; @@ -3435,7 +3445,8 @@ function runTests() { test('consecutive condensed denials are textually different (ordinal advances)', () => { writeState({ checked: [], last_active: Date.now(), fact_force_denials: 5 }); const first = parseOutput(runHook({ tool_name: 'Write', tool_input: { file_path: '/src/damp-a.js', content: 'x' } }).stdout); - const second = parseOutput(runHook({ tool_name: 'Write', tool_input: { file_path: '/src/damp-b.js', content: 'x' } }).stdout); + // Different directory: a same-dir new-file Write would be a sibling allow, not a denial. + const second = parseOutput(runHook({ tool_name: 'Write', tool_input: { file_path: '/lib/damp-b.js', content: 'x' } }).stdout); const firstReason = first.hookSpecificOutput.permissionDecisionReason; const secondReason = second.hookSpecificOutput.permissionDecisionReason; assert.ok(firstReason.includes('denial #6'), `expected ordinal 6, got: ${firstReason}`); @@ -3480,6 +3491,133 @@ function runTests() { passed++; else failed++; + clearState(); + if ( + test('allows new paths after the session-wide denial budget', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 3 }); + const result = runHook( + { tool_name: 'Edit', tool_input: { file_path: '/src/after-budget.js' } }, + { GATEGUARD_FACT_FORCE_MAX_DENIALS: '3' } + ); + assert.strictEqual(result.code, 0); + const output = parseOutput(result.stdout); + assert.ok(output, 'should produce valid JSON output'); + assert.ok(!output.hookSpecificOutput, 'paths after the budget should pass through'); + assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('MultiEdit allows new paths after the session-wide denial budget', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 3 }); + const result = runHook( + { tool_name: 'MultiEdit', tool_input: { edits: [{ file_path: '/src/after-multi-budget.js', old_string: 'a', new_string: 'b' }] } }, + { GATEGUARD_FACT_FORCE_MAX_DENIALS: '3' } + ); + assert.strictEqual(result.code, 0); + const output = parseOutput(result.stdout); + assert.ok(output, 'should produce valid JSON output'); + assert.ok(!output.hookSpecificOutput, 'MultiEdit paths after the budget should pass through'); + assert.strictEqual(output.tool_name, 'MultiEdit', 'pass-through should preserve input'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('Write allows new paths after the session-wide denial budget', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 3 }); + const result = runHook( + { tool_name: 'Write', tool_input: { file_path: '/src/after-write-budget.js', content: 'x' } }, + { GATEGUARD_FACT_FORCE_MAX_DENIALS: '3' } + ); + assert.strictEqual(result.code, 0); + const output = parseOutput(result.stdout); + assert.ok(output, 'should produce valid JSON output'); + assert.ok(!output.hookSpecificOutput, 'Write paths after the budget should pass through'); + assert.strictEqual(output.tool_name, 'Write', 'pass-through should preserve input'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('denies at the exact budget and passes through on the next path', () => { + // Budget 3: the 3rd denial is still a denial; the 4th path is allowed. + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 2 }); + const atLimit = runHook( + { tool_name: 'Edit', tool_input: { file_path: '/src/at-budget.js' } }, + { GATEGUARD_FACT_FORCE_MAX_DENIALS: '3' } + ); + const atLimitOutput = parseOutput(atLimit.stdout); + assert.ok(atLimitOutput, 'should produce valid JSON output at the limit'); + assert.strictEqual( + atLimitOutput.hookSpecificOutput.permissionDecision, + 'deny', + 'the denial that reaches the budget should still deny' + ); + + const pastLimit = runHook( + { tool_name: 'Edit', tool_input: { file_path: '/src/past-budget.js' } }, + { GATEGUARD_FACT_FORCE_MAX_DENIALS: '3' } + ); + const pastLimitOutput = parseOutput(pastLimit.stdout); + assert.ok(pastLimitOutput, 'should produce valid JSON output past the limit'); + assert.ok(!pastLimitOutput.hookSpecificOutput, 'the next path should pass through'); + }) + ) + passed++; + else failed++; + + for (const malformed of ['3.5', '3oops', '0x3', ' 3 oops', '-1', '']) { + clearState(); + if ( + test(`leaves the gate uncapped for malformed budget ${JSON.stringify(malformed)}`, () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 9 }); + const result = runHook( + { tool_name: 'Edit', tool_input: { file_path: '/src/malformed-budget.js' } }, + { GATEGUARD_FACT_FORCE_MAX_DENIALS: malformed } + ); + const output = parseOutput(result.stdout); + assert.ok(output, 'should produce valid JSON output'); + assert.strictEqual( + output.hookSpecificOutput.permissionDecision, + 'deny', + 'a malformed budget must not silently become a finite cap' + ); + }) + ) + passed++; + else failed++; + } + + clearState(); + if ( + test('condensed denial names the session-wide denial cap', () => { + // Budget above the full-block budget so a condensed denial is still reached. + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 5 }); + const result = runHook( + { tool_name: 'Edit', tool_input: { file_path: '/src/condensed-budget.js' } }, + { GATEGUARD_FACT_FORCE_MAX_DENIALS: '10' } + ); + const output = parseOutput(result.stdout); + assert.ok(output, 'should produce valid JSON output'); + const reason = output.hookSpecificOutput.permissionDecisionReason; + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + assert.ok( + reason.includes('GATEGUARD_FACT_FORCE_MAX_DENIALS'), + 'condensed denial should name the session-wide denial cap' + ); + }) + ) + passed++; + else failed++; + clearState(); if ( test('malformed denial counter in state is treated as zero (full block, no crash)', () => { @@ -4016,6 +4154,2112 @@ function runTests() { passed++; else failed++; + // --- Canonical checked-path keys --- + const c1Decision = result => { + const output = parseOutput(result.stdout); + return output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + }; + const c1Denials = () => JSON.parse(fs.readFileSync(stateFile, 'utf8')).fact_force_denials; + + clearState(); + if ( + test('a.py, ./a.py and /a.py share one checked key (one denial)', () => { + const env = { CLAUDE_PROJECT_DIR: '/proj-c1' }; + const edit = file_path => runHook({ tool_name: 'Edit', tool_input: { file_path, old_string: 'a', new_string: 'b' } }, env); + assert.strictEqual(c1Decision(edit('a.py')), 'deny', 'first touch of a.py is denied'); + assert.notStrictEqual(c1Decision(edit('./a.py')), 'deny', './a.py is the same file'); + assert.notStrictEqual(c1Decision(edit('/proj-c1/a.py')), 'deny', 'absolute spelling is the same file'); + assert.strictEqual(c1Denials(), 1, 'exactly one denial counted'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('raw key from an older state file still counts as checked', () => { + writeState({ checked: ['a.py'], last_active: Date.now(), fact_force_denials: 1 }); + const result = runHook( + { tool_name: 'Edit', tool_input: { file_path: 'a.py', old_string: 'a', new_string: 'b' } }, + { CLAUDE_PROJECT_DIR: '/proj-c1' } + ); + assert.notStrictEqual(c1Decision(result), 'deny', 'raw legacy key must not be re-denied'); + assert.strictEqual(c1Denials(), 1, 'no new denial counted'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('Windows root folds separators and case into one key', () => { + const env = { CLAUDE_PROJECT_DIR: 'C:\\proj' }; + const write = file_path => runHook({ tool_name: 'Write', tool_input: { file_path, content: 'x' } }, env); + assert.strictEqual(c1Decision(write('src\\a.py')), 'deny', 'first touch is denied'); + assert.notStrictEqual(c1Decision(write('c:/proj/src/a.py')), 'deny', 'forward-slash lowercase spelling is the same file'); + assert.notStrictEqual(c1Decision(write('C:\\PROJ\\src\\A.py')), 'deny', 'case differs only on win32'); + assert.strictEqual(c1Denials(), 1, 'exactly one denial counted'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('MultiEdit entries use the canonical key', () => { + const env = { CLAUDE_PROJECT_DIR: '/proj-c1' }; + assert.strictEqual( + c1Decision(runHook({ tool_name: 'Edit', tool_input: { file_path: 'b.py', old_string: 'a', new_string: 'b' } }, env)), + 'deny' + ); + const multi = runHook( + { tool_name: 'MultiEdit', tool_input: { edits: [{ file_path: './b.py', old_string: 'a', new_string: 'b' }] } }, + env + ); + assert.notStrictEqual(c1Decision(multi), 'deny', 'MultiEdit on ./b.py matches the checked b.py'); + assert.strictEqual(c1Denials(), 1, 'exactly one denial counted'); + }) + ) + passed++; + else failed++; + + // --- Relative targets resolve against the tool cwd, not the project root --- + clearState(); + if ( + test('relative target resolves against data.cwd before CLAUDE_PROJECT_DIR', () => { + const env = { CLAUDE_PROJECT_DIR: '/proj-f1' }; + const edit = file_path => + runHook({ tool_name: 'Edit', cwd: '/proj-f1/sub', tool_input: { file_path, old_string: 'a', new_string: 'b' } }, env); + assert.strictEqual(c1Decision(edit('src/a.py')), 'deny', 'first touch of src/a.py (cwd sub) is denied'); + assert.notStrictEqual(c1Decision(edit('/proj-f1/sub/src/a.py')), 'deny', 'absolute spelling of the same file is checked'); + assert.strictEqual(c1Decision(edit('/proj-f1/src/a.py')), 'deny', 'a different file under the project root is gated'); + assert.strictEqual(c1Denials(), 2, 'two distinct files, two denials'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('without data.cwd a relative target still resolves against CLAUDE_PROJECT_DIR', () => { + const env = { CLAUDE_PROJECT_DIR: '/proj-f1' }; + const edit = file_path => runHook({ tool_name: 'Edit', tool_input: { file_path, old_string: 'a', new_string: 'b' } }, env); + assert.strictEqual(c1Decision(edit('src/a.py')), 'deny'); + assert.notStrictEqual(c1Decision(edit('/proj-f1/src/a.py')), 'deny', 'project-root fallback unchanged'); + }) + ) + passed++; + else failed++; + + // --- Questions by target class --- + const C2_ENV = { CLAUDE_PROJECT_DIR: '/proj-c2' }; + const QUOTE_LINE = "Quote the user's current instruction verbatim"; + const RETRY_LINE = 'Present the facts, then retry the same operation.'; + const c2Reason = (toolName, file_path, env = {}) => { + const tool_input = toolName === 'MultiEdit' + ? { edits: [{ file_path, old_string: 'a', new_string: 'b' }] } + : { file_path, old_string: 'a', new_string: 'b', content: 'x' }; + const output = parseOutput(runHook({ tool_name: toolName, tool_input }, { ...C2_ENV, ...env }).stdout); + assert.ok(output && output.hookSpecificOutput, `${toolName} ${file_path} should be denied`); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + return output.hookSpecificOutput.permissionDecisionReason; + }; + const assertNoCodeQuestions = reason => { + assert.ok(!reason.includes('call this new file'), 'must not ask the code Write questions'); + assert.ok(!reason.includes('import/require'), 'must not ask the code Edit questions'); + assert.ok(!reason.includes('public functions/classes'), 'must not ask the code Edit questions'); + }; + const assertFrame = (reason, verb, file) => { + assert.ok(reason.startsWith('[Fact-Forcing Gate]\n\n'), 'keeps the gate header'); + assert.ok(reason.includes(`Before ${verb} ${file}, present these facts:`), `header names ${verb} ${file}`); + assert.ok(reason.includes(QUOTE_LINE), 'list ends with the verbatim-instruction line'); + assert.ok(reason.includes(`If this call was sent in a parallel batch, other edits to ${file}`), 'keeps batch-sibling warning'); + assert.ok(reason.includes(RETRY_LINE), 'keeps the closing retry line'); + assert.ok(reason.includes('GATEGUARD_EXEMPT_GLOBS'), 'keeps the path-scoped recovery hint'); + }; + + clearState(); + if ( + test('first-touch Write of docs/guide.md asks prose questions, not code questions', () => { + const reason = c2Reason('Write', 'docs/guide.md'); + assertFrame(reason, 'creating', 'docs/guide.md'); + assert.ok(reason.includes('supersedes or duplicates'), 'asks what it supersedes'); + assert.ok(reason.includes('linked or referenced from'), 'asks where it is linked from'); + assert.ok(reason.includes('why a new file rather than editing an existing one'), 'asks why a new file'); + assert.ok(reason.includes(`4. ${QUOTE_LINE}`), 'quote is item 4'); + assertNoCodeQuestions(reason); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('first-touch Edit of docs/guide.md asks prose Edit questions', () => { + const reason = c2Reason('Edit', 'docs/guide.md'); + assertFrame(reason, 'editing', 'docs/guide.md'); + assert.ok(reason.includes('reference the section being changed'), 'asks what references the section'); + assert.ok(reason.includes('corrects or adds'), 'asks what the change corrects or adds'); + assert.ok(reason.includes(`3. ${QUOTE_LINE}`), 'quote is item 3'); + assert.ok(!reason.includes('supersedes'), 'Write-only prose question absent'); + assertNoCodeQuestions(reason); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('skills/x/SKILL.md and .claude/agents/a.md get instruction questions', () => { + for (const file of ['skills/x/SKILL.md', '.claude/agents/a.md', 'CLAUDE.md']) { + const reason = c2Reason('Write', file); + assertFrame(reason, 'creating', file); + assert.ok(reason.includes('harness/loader'), `${file}: asks which harness reads it`); + assert.ok(reason.includes('agent behaviour changes'), `${file}: asks what behaviour changes`); + assert.ok(reason.includes('instruction, skill, or agent file already covers'), `${file}: asks for existing coverage`); + assert.ok(reason.includes(`4. ${QUOTE_LINE}`), `${file}: quote is item 4`); + assertNoCodeQuestions(reason); + } + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('src/a.py Write and Edit keep the code text byte-identical', () => { + const writeReason = c2Reason('Write', 'src/a.py'); + const expectedWrite = [ + '[Fact-Forcing Gate]', + '', + 'Before creating src/a.py, present these facts:', + '', + '1. Name the file(s) and line(s) that will call this new file', + '2. Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash)', + '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', + "4. Quote the user's current instruction verbatim", + '', + 'If this call was sent in a parallel batch, other edits to src/a.py from that batch may already have been applied. Re-read the file before building on them.', + '', + 'Present the facts, then retry the same operation.', + '' + ].join('\n'); + assert.ok(writeReason.startsWith(expectedWrite), 'code Write block unchanged'); + const editReason = c2Reason('Edit', 'src/b.py'); + const expectedEdit = [ + '[Fact-Forcing Gate]', + '', + 'Before editing src/b.py, present these facts:', + '', + '1. List ALL files that import/require this file (search the tree — Glob/Grep, or find/grep via Bash)', + '2. List the public functions/classes affected by this change', + '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', + "4. Quote the user's current instruction verbatim", + '', + 'If this call was sent in a parallel batch, other edits to src/b.py from that batch may already have been applied. Re-read the file before building on them.', + '', + 'Present the facts, then retry the same operation.', + '' + ].join('\n'); + assert.ok(editReason.startsWith(expectedEdit), 'code Edit block unchanged'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('src/a.test.ts and tests/helpers.js get test questions', () => { + for (const file of ['src/a.test.ts', 'tests/helpers.js']) { + const reason = c2Reason('Write', file); + assertFrame(reason, 'creating', file); + assert.ok(reason.includes('behaviour is under test'), `${file}: asks what is under test`); + assert.ok(reason.includes('existing test file(s)'), `${file}: asks for existing test files`); + assert.ok(reason.includes(`3. ${QUOTE_LINE}`), `${file}: quote is item 3`); + assertNoCodeQuestions(reason); + } + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('config/app.yaml and .env.local get config questions', () => { + for (const file of ['config/app.yaml', '.env.local']) { + const reason = c2Reason('Write', file); + assertFrame(reason, 'creating', file); + assert.ok(reason.includes('process/tool reads this file'), `${file}: asks what reads it`); + assert.ok(reason.includes('effect of the change'), `${file}: asks for the effect`); + assert.ok(reason.includes('no secrets or credentials'), `${file}: asks about secrets`); + assert.ok(reason.includes(`4. ${QUOTE_LINE}`), `${file}: quote is item 4`); + assertNoCodeQuestions(reason); + } + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('scripts/hooks/x.js is code, not instruction', () => { + const reason = c2Reason('Write', 'scripts/hooks/x.js'); + assert.ok(reason.includes('call this new file'), 'code Write questions'); + assert.ok(!reason.includes('harness/loader'), 'not instruction questions'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('MultiEdit on a .md entry uses the prose Edit variant', () => { + const reason = c2Reason('MultiEdit', 'docs/multi.md'); + assertFrame(reason, 'editing', 'docs/multi.md'); + assert.ok(reason.includes('reference the section being changed'), 'prose Edit questions'); + assertNoCodeQuestions(reason); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('condensed denial for .md after the budget carries the prose hint and ordinal', () => { + const env = { GATEGUARD_FACT_FORCE_FULL_DENIALS: '0' }; + const writeReason = c2Reason('Write', 'docs/late.md', env); + assert.ok(!writeReason.includes('\n'), 'condensed denial is one line'); + assert.ok(writeReason.includes('(denial #1 this session) First creation of docs/late.md: '), 'keeps ordinal and target'); + assert.ok(writeReason.includes('briefly state what this supersedes, where it is linked from'), 'prose Write hint'); + assert.ok(!writeReason.includes('importers/callers'), 'no code hint'); + assert.ok(writeReason.includes('parallel batch'), 'keeps batch-sibling warning'); + assert.ok(writeReason.includes('GATEGUARD_EXEMPT_GLOBS') && writeReason.includes('ECC_GATEGUARD=off'), 'keeps exemption hint'); + const editReason = c2Reason('Edit', 'docs/late-edit.md', env); + assert.ok(editReason.includes('(denial #2 this session) First edit of docs/late-edit.md: '), 'ordinal advances'); + assert.ok(editReason.includes('what references the changed section'), 'prose Edit hint'); + const codeReason = c2Reason('Edit', 'src/late.js', env); + assert.ok(codeReason.includes('briefly state importers/callers, affected API, data schemas if any'), 'code hint unchanged'); + }) + ) + passed++; + else failed++; + + clearState(); + if ( + test('class denials still sanitize the path', () => { + const reason = c2Reason('Write', 'docs/hid\u200bden.md'); + assert.ok(!reason.includes('\u200b'), 'zero-width space stripped'); + assert.ok(reason.includes('supersedes or duplicates'), 'still prose questions'); + }) + ) + passed++; + else failed++; + + // --- Prior-search credit in the current human turn --- + const c3Root = '/proj-c3'; + // An ambient CLAUDE_TRANSCRIPT_PATH must not leak into cases that pass no transcript. + const c3Env = { CLAUDE_PROJECT_DIR: c3Root, CLAUDE_TRANSCRIPT_PATH: '' }; + const c3TranscriptDir = fs.mkdtempSync(path.join(tmpRoot, 'gateguard-c3-transcripts-')); + const c3Outputs = []; + let c3Seq = 0; + let c3Uuid = 0; + const c3NextUuid = () => `uuid-${++c3Uuid}`; + const c3Human = (text, extra = {}) => + ({ type: 'user', uuid: c3NextUuid(), message: { role: 'user', content: text }, ...extra }); + // Real assistant records carry message.id; a search without one is never credited. + const c3ToolUse = (id, name, input, extra = {}) => ({ + type: 'assistant', + uuid: c3NextUuid(), + message: { id: `msg_${id}`, role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, + ...extra + }); + const c3ToolResult = (id, isError = false, extra = {}) => ({ + type: 'user', + uuid: c3NextUuid(), + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: isError, content: 'ok' }] }, + ...extra + }); + const c3Search = (id, name, input) => [c3ToolUse(id, name, input), c3ToolResult(id)]; + // Like Claude Code, a fixture ends with the pending call's own assistant record and runners pass its id. + const c3PendingIds = new Map(); + const c3WriteTranscript = (records, { pending = true } = {}) => { + c3Seq += 1; + const file = path.join(c3TranscriptDir, `t-${c3Seq}.jsonl`); + const all = records.slice(); + if (pending) { + const id = `toolu_pending_${c3Seq}`; + all.push({ + type: 'assistant', + uuid: c3NextUuid(), + message: { id: `msg_pending_${c3Seq}`, role: 'assistant', content: [{ type: 'tool_use', id, name: 'Edit', input: {} }] } + }); + c3PendingIds.set(file, id); + } + const lines = all.map(r => (typeof r === 'string' ? r : JSON.stringify(r))); + fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8'); + return file; + }; + const c3Run = (toolName, toolInput, transcriptPath, env = {}) => { + const payload = { tool_name: toolName, tool_input: toolInput, cwd: c3Root }; + if (transcriptPath !== undefined) payload.transcript_path = transcriptPath; + if (c3PendingIds.has(transcriptPath)) payload.tool_use_id = c3PendingIds.get(transcriptPath); + const result = runHook(payload, { ...c3Env, ...env }); + c3Outputs.push(result.stdout); + const output = parseOutput(result.stdout); + const hso = output && output.hookSpecificOutput ? output.hookSpecificOutput : {}; + return { result, decision: hso.permissionDecision, context: hso.additionalContext || '', reason: hso.permissionDecisionReason || '' }; + }; + const c3Edit = (file_path, transcriptPath, env) => + c3Run('Edit', { file_path, old_string: 'a', new_string: 'b' }, transcriptPath, env); + const c3Write = (file_path, transcriptPath, env) => c3Run('Write', { file_path, content: 'x' }, transcriptPath, env); + const c3State = () => (fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) : {}); + const c3Case = (name, fn) => { + clearState(); + if (test(name, fn)) passed++; + else failed++; + }; + const c3AssertDenied = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny`); + assert.ok(!out.context.includes('Prior search seen'), `${label}: no credit note`); + }; + const c3AssertCredited = (out, label) => { + assert.notStrictEqual(out.decision, 'deny', `${label}: must not deny`); + assert.ok(out.context.includes('Prior search seen in this turn'), `${label}: credit note expected, got ${out.result.stdout}`); + }; + + c3Case('Grep stem in this turn credits the first Edit (note, no denial counted)', () => { + const t = c3WriteTranscript([ + c3Human('please fix the widget factory'), + ...c3Search('toolu_g1', 'Grep', { pattern: 'widget_factory', path: c3Root }) + ]); + const out = c3Edit(`${c3Root}/src/widget_factory.py`, t); + c3AssertCredited(out, 'Grep stem'); + assert.ok(out.context.includes('(Grep widget_factory, 1 tool call ago)'), out.context); + assert.ok(out.context.includes(`first-touch check satisfied for ${c3Root}/src/widget_factory.py`), out.context); + const state = c3State(); + assert.ok(!state.fact_force_denials, 'denial count unchanged'); + assert.strictEqual(state.fact_force_credited, 1, 'credit counted'); + assert.ok(state.checked.includes(stateKey(`${c3Root}/src/widget_factory.py`)), 'canonical key marked checked'); + const again = c3Edit(`${c3Root}/src/widget_factory.py`, t); + assert.notStrictEqual(again.decision, 'deny'); + assert.strictEqual(again.context, '', 'already checked: plain allow, no second note'); + assert.strictEqual(c3State().fact_force_credited, 1, 'no double credit'); + }); + + c3Case('Glob directory credits a Write of a new file there', () => { + const t = c3WriteTranscript([ + c3Human('add a component'), + ...c3Search('toolu_gl', 'Glob', { pattern: 'src/components/*.tsx', path: c3Root }) + ]); + c3AssertCredited(c3Write(`${c3Root}/src/components/NewThing.tsx`, t), 'Glob dir'); + assert.ok(!c3State().fact_force_denials); + }); + + c3Case('LS directory credits a Write there', () => { + const t = c3WriteTranscript([c3Human('add a page'), ...c3Search('toolu_ls', 'LS', { path: `${c3Root}/docs` })]); + c3AssertCredited(c3Write(`${c3Root}/docs/brand-new-page.md`, t), 'LS dir'); + }); + + c3Case('Bash rg stem credits an Edit', () => { + const t = c3WriteTranscript([c3Human('fix it'), ...c3Search('toolu_b1', 'Bash', { command: 'cd /proj-c3 && rg foo_bar src' })]); + const out = c3Edit(`${c3Root}/src/foo_bar.js`, t); + c3AssertCredited(out, 'Bash rg'); + assert.ok(out.context.includes('(Bash rg foo_bar src,'), out.context); + }); + + c3Case('PowerShell Get-ChildItem stem credits an Edit', () => { + const t = c3WriteTranscript([ + c3Human('fix it'), + ...c3Search('toolu_p1', 'PowerShell', { command: 'Get-ChildItem -Recurse -Filter *foo_bar*' }) + ]); + c3AssertCredited(c3Edit(`${c3Root}/src/foo_bar.ps1`, t), 'PowerShell gci'); + }); + + c3Case('git grep qualifies, git log does not', () => { + const ok = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_gg', 'Bash', { command: 'git grep -n foo_bar' })]); + c3AssertCredited(c3Edit(`${c3Root}/src/foo_bar.js`, ok), 'git grep'); + const no = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_gl2', 'Bash', { command: 'git log -- src/baz_qux.js' })]); + c3AssertDenied(c3Edit(`${c3Root}/src/baz_qux.js`, no), 'git log'); + }); + + c3Case('stem only in a non-search shell segment does not credit', () => { + const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_b2', 'Bash', { command: 'echo foo_bar | grep something; ls' })]); + c3AssertDenied(c3Edit(`${c3Root}/src/foo_bar.js`, t), 'stem in echo segment'); + }); + + c3Case('command substitution in a search command is ambiguous (no credit)', () => { + const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_b3', 'Bash', { command: 'rg $(echo foo_bar) src' })]); + c3AssertDenied(c3Edit(`${c3Root}/src/foo_bar.js`, t), 'substitution'); + }); + + c3Case('cd before a search disables directory credit (stem still counts)', () => { + const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_cd', 'Bash', { command: 'cd /elsewhere && ls docs' })]); + c3AssertDenied(c3Write(`${c3Root}/docs/brand-new-page.md`, t), 'cd then ls dir'); + }); + + c3Case('a search in the same assistant batch as the pending call never credits', () => { + const batch = (id, name, input) => ({ + type: 'assistant', + uuid: c3NextUuid(), + message: { id: 'msg_batch', role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } + }); + const t = c3WriteTranscript([ + c3Human('x'), + batch('toolu_bg', 'Grep', { pattern: 'widget_factory' }), + batch('toolu_be', 'Edit', { file_path: `${c3Root}/src/widget_factory.py` }), + c3ToolResult('toolu_bg') + ]); + const input = { file_path: `${c3Root}/src/widget_factory.py`, old_string: 'a', new_string: 'b' }; + const same = runHook({ tool_name: 'Edit', tool_use_id: 'toolu_be', cwd: c3Root, transcript_path: t, tool_input: input }, c3Env); + c3Outputs.push(same.stdout); + assert.strictEqual(parseOutput(same.stdout).hookSpecificOutput.permissionDecision, 'deny', 'same batch denied'); + clearState(); + const later = c3WriteTranscript([ + c3Human('x'), + batch('toolu_bg2', 'Grep', { pattern: 'widget_factory' }), + c3ToolResult('toolu_bg2'), + { type: 'assistant', uuid: c3NextUuid(), message: { id: 'msg_next', role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_be2', name: 'Edit', input }] } } + ]); + const next = runHook({ tool_name: 'Edit', tool_use_id: 'toolu_be2', cwd: c3Root, transcript_path: later, tool_input: input }, c3Env); + c3Outputs.push(next.stdout); + assert.ok(next.stdout.includes('Prior search seen'), 'search from an earlier message credits'); + }); + + c3Case('no search in the turn -> deny', () => { + const t = c3WriteTranscript([c3Human('edit it'), ...c3Search('toolu_r0', 'Read', { file_path: `${c3Root}/other.js` })]); + const out = c3Edit(`${c3Root}/src/widget_factory.py`, t); + c3AssertDenied(out, 'no search'); + assert.strictEqual(c3State().fact_force_denials, 1); + assert.ok(!c3State().fact_force_credited, 'no credit counted on deny'); + }); + + c3Case('search tool_use without a tool_result (same batch) -> deny', () => { + const t = c3WriteTranscript([c3Human('x'), c3ToolUse('toolu_pending', 'Grep', { pattern: 'widget_factory' })]); + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`, t), 'pending search'); + }); + + c3Case('search whose tool_result is_error -> deny', () => { + const t = c3WriteTranscript([ + c3Human('x'), + c3ToolUse('toolu_err', 'Grep', { pattern: 'widget_factory' }), + c3ToolResult('toolu_err', true) + ]); + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`, t), 'errored search'); + }); + + c3Case('search before the latest human message -> deny', () => { + const t = c3WriteTranscript([ + c3Human('first request'), + ...c3Search('toolu_old', 'Grep', { pattern: 'widget_factory' }), + c3Human('second request') + ]); + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`, t), 'previous turn search'); + }); + + c3Case('tool_result user records do not reset the turn', () => { + const t = c3WriteTranscript([ + c3Human('x'), + ...c3Search('toolu_s1', 'Grep', { pattern: 'widget_factory' }), + ...c3Search('toolu_s2', 'Read', { file_path: `${c3Root}/a.js` }), + ...c3Search('toolu_s3', 'Bash', { command: 'npm test' }) + ]); + const out = c3Edit(`${c3Root}/src/widget_factory.py`, t); + c3AssertCredited(out, 'tool_results between'); + assert.ok(out.context.includes('3 tool calls ago'), out.context); + }); + + c3Case('isMeta and sidechain user records are not turn boundaries', () => { + const t = c3WriteTranscript([ + c3Human('x'), + ...c3Search('toolu_m1', 'Grep', { pattern: 'widget_factory' }), + c3Human('meta', { isMeta: true }), + c3Human('subagent prompt', { isSidechain: true }) + ]); + c3AssertCredited(c3Edit(`${c3Root}/src/widget_factory.py`, t), 'meta/sidechain boundary'); + }); + + c3Case('sidechain search records are ignored', () => { + const t = c3WriteTranscript([ + c3Human('x'), + c3ToolUse('toolu_sc', 'Grep', { pattern: 'widget_factory' }, { isSidechain: true }), + c3ToolResult('toolu_sc', false, { isSidechain: true }) + ]); + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`, t), 'sidechain search'); + }); + + c3Case('Read of the target never credits an Edit', () => { + const t = c3WriteTranscript([ + c3Human('x'), + ...c3Search('toolu_rd', 'Read', { file_path: `${c3Root}/src/widget_factory.py` }) + ]); + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`, t), 'Read target'); + }); + + c3Case('directory match does not credit an Edit (stem only)', () => { + const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_ls2', 'LS', { path: `${c3Root}/src` })]); + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`, t), 'LS dir for Edit'); + }); + + c3Case('generic stems (index.js) are not credited', () => { + const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_ix', 'Grep', { pattern: 'index' })]); + c3AssertDenied(c3Edit(`${c3Root}/src/index.js`, t), 'generic stem'); + }); + + c3Case('stems shorter than 4 characters are not credited', () => { + const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_ab', 'Grep', { pattern: 'abc' })]); + c3AssertDenied(c3Edit(`${c3Root}/src/abc.js`, t), 'short stem'); + }); + + c3Case('missing or non-file transcript -> deny', () => { + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`, path.join(c3TranscriptDir, 'nope.jsonl')), 'missing file'); + c3AssertDenied(c3Edit(`${c3Root}/src/other_widget.py`, c3TranscriptDir), 'directory path'); + c3AssertDenied(c3Edit(`${c3Root}/src/third_widget.py`), 'no transcript_path'); + }); + + c3Case('garbage lines -> deny; garbage around a valid turn is skipped', () => { + const junk = c3WriteTranscript(['not json', '{"type":', '{"type":"assistant","message":null}', '[1,2]', 'null']); + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`, junk), 'garbage only'); + const mixed = c3WriteTranscript([ + 'garbage {', + c3Human('x'), + '{"broken":', + ...c3Search('toolu_gm', 'Grep', { pattern: 'other_widget' }), + '\u0000\u0001' + ]); + c3AssertCredited(c3Edit(`${c3Root}/src/other_widget.py`, mixed), 'garbage around valid turn'); + }); + + // A clipped window with no human record in it is entirely the current turn. + const c3Filler = prefix => { + const filler = []; + for (let i = 0; i < 40; i++) { + const id = `toolu_${prefix}${i}`; + filler.push(c3ToolUse(id, 'Bash', { command: 'npm test' })); + filler.push({ + type: 'user', + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'y'.repeat(10 * 1024) }] } + }); + } + return filler; + }; + + c3Case('tail truncated, no boundary in window, search + result inside -> credited (turn id null)', () => { + const t = c3WriteTranscript([ + c3Human('long ago'), + ...c3Filler('fill'), + ...c3Search('toolu_late', 'Grep', { pattern: 'widget_factory' }) + ]); + assert.ok(fs.statSync(t).size > 300 * 1024, 'fixture exceeds the tail window'); + const { scanCurrentTurn } = loadDirectHook(); + const scan = scanCurrentTurn(t); + assert.strictEqual(scan.turnId, null, 'no boundary: no turn id'); + assert.ok(scan.searches.some(s => s.name === 'Grep'), 'search inside the window is collected'); + c3AssertCredited(c3Edit(`${c3Root}/src/widget_factory.py`, t), 'clipped window is the current turn'); + }); + + c3Case('tail truncated, search tool_use before the window, result inside -> not credited', () => { + const t = c3WriteTranscript([ + c3Human('long ago'), + c3ToolUse('toolu_early', 'Grep', { pattern: 'widget_factory' }), + ...c3Filler('fill2'), + c3ToolResult('toolu_early') + ]); + assert.ok(fs.statSync(t).size > 300 * 1024, 'fixture exceeds the tail window'); + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`, t), 'tool_use outside the window'); + }); + + c3Case('whole file scanned with no boundary -> no credit', () => { + const t = c3WriteTranscript([...c3Search('toolu_nob', 'Grep', { pattern: 'widget_factory' })]); + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`, t), 'no human record at all'); + }); + + c3Case('MultiEdit credits per entry; first uncredited entry is denied', () => { + const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_me', 'Grep', { pattern: 'alpha_mod' })]); + const out = c3Run( + 'MultiEdit', + { + edits: [ + { file_path: `${c3Root}/src/alpha_mod.js`, old_string: 'a', new_string: 'b' }, + { file_path: `${c3Root}/src/beta_mod.js`, old_string: 'a', new_string: 'b' } + ] + }, + t + ); + assert.strictEqual(out.decision, 'deny'); + assert.ok(out.reason.includes('beta_mod.js'), 'uncredited entry is the one denied'); + const state = c3State(); + assert.ok(state.checked.includes(stateKey(`${c3Root}/src/alpha_mod.js`)), 'credited entry marked checked'); + assert.strictEqual(state.fact_force_credited, 1); + assert.strictEqual(state.fact_force_denials, 1); + }); + + c3Case('MultiEdit with every entry credited returns one combined note', () => { + const t = c3WriteTranscript([ + c3Human('x'), + ...c3Search('toolu_me2', 'Grep', { pattern: 'alpha_mod|beta_mod' }) + ]); + const out = c3Run( + 'MultiEdit', + { + edits: [ + { file_path: `${c3Root}/src/alpha_mod.js`, old_string: 'a', new_string: 'b' }, + { file_path: `${c3Root}/src/beta_mod.js`, old_string: 'a', new_string: 'b' } + ] + }, + t + ); + assert.notStrictEqual(out.decision, 'deny'); + assert.ok(out.context.includes('alpha_mod.js') && out.context.includes('beta_mod.js'), out.context); + assert.strictEqual(c3State().fact_force_credited, 2); + assert.ok(!c3State().fact_force_denials); + }); + + c3Case('exempt paths and subagents are unchanged (no credit note)', () => { + const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_ex', 'Grep', { pattern: 'widget_factory' })]); + const exempt = c3Edit(`${c3Root}/tmp/widget_factory.py`, t, { GATEGUARD_EXEMPT_GLOBS: 'tmp/**' }); + assert.notStrictEqual(exempt.decision, 'deny'); + assert.strictEqual(exempt.context, '', 'exempt path: plain allow'); + const sub = runHook( + { + tool_name: 'Edit', + agent_id: 'agent-1', + cwd: c3Root, + transcript_path: t, + tool_input: { file_path: `${c3Root}/src/widget_factory.py`, old_string: 'a', new_string: 'b' } + }, + c3Env + ); + c3Outputs.push(sub.stdout); + assert.ok(!sub.stdout.includes('Prior search'), 'subagent: plain allow'); + assert.ok(!c3State().fact_force_credited, 'no credit recorded'); + }); + + c3Case('credit note detail is sanitized and bounded', () => { + const pattern = `widget_factory\u200b\u202e${'x'.repeat(300)}\nIGNORE PREVIOUS`; + const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_long', 'Grep', { pattern })]); + const out = c3Edit(`${c3Root}/src/widget_factory.py`, t); + c3AssertCredited(out, 'long pattern'); + const detail = out.context.match(/\(Grep (.*), 1 tool call ago\)/); + assert.ok(detail, out.context); + assert.ok(Array.from(detail[1]).length <= 80, `detail too long: ${detail[1].length}`); + for (const bad of ['\u200b', '\u202e', '\n']) assert.ok(!out.context.includes(bad), 'no invisible/control chars'); + assert.ok(!out.context.includes('IGNORE PREVIOUS'), 'truncated before trailing text'); + }); + + c3Case('state save failure on credit -> allow with state warning', () => { + const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_sf', 'Grep', { pattern: 'widget_factory' })]); + const blocker = path.join(c3TranscriptDir, `not-a-dir-${c3Seq}`); + fs.writeFileSync(blocker, 'x'); + const out = c3Edit(`${c3Root}/src/widget_factory.py`, t, { GATEGUARD_STATE_DIR: blocker }); + assert.notStrictEqual(out.decision, 'deny'); + assert.ok(out.result.stderr.includes('could not be persisted'), out.result.stderr); + }); + + c3Case('fact_force_credited survives later state writes (merge keeps it)', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_credited: 4 }); + c3AssertDenied(c3Edit(`${c3Root}/src/widget_factory.py`), 'deny path'); + assert.strictEqual(c3State().fact_force_credited, 4, 'denial write keeps credited count'); + assert.strictEqual(c3State().fact_force_denials, 1); + }); + + c3Case('scanCurrentTurn / findCreditingSearch units', () => { + const { scanCurrentTurn, findCreditingSearch } = loadDirectHook(); + const human = c3Human('go'); + const t = c3WriteTranscript( + [c3Human('old'), human, ...c3Search('toolu_u1', 'Glob', { pattern: '**/*.md' }), c3ToolUse('toolu_u2', 'Grep', { pattern: 'pending' })], + { pending: false } + ); + const scan = scanCurrentTurn(t); + assert.strictEqual(scan.turnId, human.uuid, 'turnId is the boundary uuid'); + assert.deepStrictEqual(scan.searches.map(s => [s.name, s.callsAgo]), [['Glob', 2]]); + assert.strictEqual(scanCurrentTurn(path.join(c3TranscriptDir, 'missing.jsonl')), null); + const data = { cwd: c3Root }; + // A Glob with no literal prefix and no explicit path names no directory. + assert.strictEqual(findCreditingSearch(scan, `${c3Root}/new-notes.md`, true, data), null, 'Glob **/*.md gives no dir credit'); + const docsScan = scanCurrentTurn( + c3WriteTranscript([c3Human('go'), ...c3Search('toolu_u3', 'Glob', { pattern: 'docs/*.md' }), c3ToolUse('toolu_u4', 'Grep', { pattern: 'x' })], { + pending: false + }) + ); + assert.ok(findCreditingSearch(docsScan, `${c3Root}/docs/new-notes.md`, true, data), 'Glob literal prefix names the dir'); + assert.strictEqual(findCreditingSearch(docsScan, `${c3Root}/docs/sub/new-notes.md`, true, data), null, 'not a subdirectory'); + assert.strictEqual(findCreditingSearch(scan, `${c3Root}/new-notes.md`, false, data), null, 'Edit needs a stem match'); + assert.strictEqual(findCreditingSearch(null, `${c3Root}/x.md`, true, data), null); + const winScan = { turnId: null, searches: [{ name: 'LS', input: { path: 'C:\\Proj\\Src' }, callsAgo: 1, messageId: 'msg_w' }] }; + assert.ok(findCreditingSearch(winScan, 'c:/proj/src/new_file.js', true, { cwd: 'C:\\proj' }), 'win32 dir match folds case'); + const posixScan = { turnId: null, searches: [{ name: 'LS', input: { path: '/Proj/Src' }, callsAgo: 1, messageId: 'msg_p' }] }; + assert.strictEqual(findCreditingSearch(posixScan, '/proj/src/new_file.js', true, { cwd: '/proj' }), null, 'posix is case-sensitive'); + }); + + c3Case('50 MB transcript is scanned from the tail only (<= 256 KiB read)', () => { + const big = path.join(c3TranscriptDir, 'big.jsonl'); + const bigSize = 50 * 1024 * 1024; + const tail = [c3Human('now'), ...c3Search('toolu_big', 'Grep', { pattern: 'widget_factory' })]; + for (let i = 0; i < 50; i++) tail.push(...c3Search(`toolu_t${i}`, 'Read', { file_path: `${c3Root}/f${i}.js` })); + // Sparse 50 MB body (ftruncate) + the real tail: cheap to build, same shape for the hook. + const fd = fs.openSync(big, 'w'); + try { + fs.writeSync(fd, JSON.stringify(c3Human('ancient')) + '\n'); + fs.ftruncateSync(fd, bigSize); + fs.writeSync(fd, '\n' + tail.map(r => JSON.stringify(r)).join('\n') + '\n', bigSize); + } finally { + fs.closeSync(fd); + } + const savedRoot = process.env.CLAUDE_PROJECT_DIR; + const savedTranscript = process.env.CLAUDE_TRANSCRIPT_PATH; + const originalOpenSync = fs.openSync; + const originalReadSync = fs.readSync; + const bigFds = new Set(); + let bytesRead = 0; + let result; + let ms; + try { + assert.ok(fs.statSync(big).size > bigSize, 'fixture is > 50 MB'); + const hook = loadDirectHook({ CLAUDE_PROJECT_DIR: c3Root, CLAUDE_TRANSCRIPT_PATH: '' }); + const payload = { + tool_name: 'Edit', + cwd: c3Root, + transcript_path: big, + tool_input: { file_path: `${c3Root}/src/widget_factory.py`, old_string: 'a', new_string: 'b' } + }; + fs.openSync = function countingOpenSync(target) { + const opened = originalOpenSync.apply(fs, arguments); + if (String(target) === big) bigFds.add(opened); + return opened; + }; + fs.readSync = function countingReadSync(readFd) { + const n = originalReadSync.apply(fs, arguments); + if (bigFds.has(readFd)) bytesRead += n; + return n; + }; + const start = process.hrtime.bigint(); + result = hook.run(payload); + ms = Number(process.hrtime.bigint() - start) / 1e6; + } finally { + fs.openSync = originalOpenSync; + fs.readSync = originalReadSync; + if (savedRoot === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = savedRoot; + if (savedTranscript === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH; + else process.env.CLAUDE_TRANSCRIPT_PATH = savedTranscript; + fs.rmSync(big, { force: true }); + } + console.log(` (run() on 50 MB transcript: ${bytesRead} bytes read, ${ms.toFixed(1)} ms)`); + assert.ok(result && String(result.additionalContext || '').includes('Prior search seen'), 'credited from the tail'); + assert.ok(bigFds.size >= 1 && bytesRead > 0, 'transcript was read through fs.readSync'); + assert.ok(bytesRead <= 256 * 1024, `read ${bytesRead} bytes; must stay within the 256 KiB tail`); + assert.ok(ms < 1000, `wall-clock backstop: run() took ${ms.toFixed(1)} ms`); + }); + + c3Case('no prior-search credit output carries permissionDecision "allow"', () => { + assert.ok(c3Outputs.length > 20, 'collected outputs'); + for (const stdout of c3Outputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Exclusion globs never earn search credit --- + const r2Target = `${c3Root}/src/widget.py`; + let r2Seq = 0; + const r2Turn = (name, input) => { + r2Seq += 1; + return c3WriteTranscript([c3Human('fix the widget'), ...c3Search(`toolu_r2_${r2Seq}`, name, input)]); + }; + const r2Denied = [ + ['Grep glob !widget.py', 'Grep', { pattern: 'TODO', glob: '!widget.py', path: c3Root }], + ['Grep glob list with an exclusion', 'Grep', { pattern: 'widget', glob: '*.js,!widget.py', path: c3Root }], + ['Grep path-style exclusion covering the target', 'Grep', { pattern: 'widget', glob: '!src/**', path: c3Root }], + ['Grep positive basename glob that misses the target', 'Grep', { pattern: 'widget', glob: '*.md', path: c3Root }], + ["rg -g '!widget.py'", 'Bash', { command: "rg -g '!widget.py' TODO ." }], + ['rg --glob=!widget.py', 'Bash', { command: 'rg --glob=!widget.py TODO .' }], + ['rg --iglob exclusion, other case', 'Bash', { command: "rg --iglob '!WIDGET.py' TODO ." }], + ['rg -g exclusion of the directory', 'Bash', { command: "rg -g '!src/' widget ." }], + ['rg -g positive glob that misses the target', 'Bash', { command: "rg -g '*.md' widget ." }], + ['grep --exclude=widget.py', 'Bash', { command: 'grep -r --exclude=widget.py TODO .' }], + ['grep --exclude-dir of the target dir', 'Bash', { command: 'grep -rn --exclude-dir=src widget .' }], + ['grep --exclude-from (unknown exclusions)', 'Bash', { command: 'grep -r --exclude-from=skip.txt widget .' }], + ['grep --include that misses the target', 'Bash', { command: 'grep -r --include=*.md widget .' }], + ['find -not -name', 'Bash', { command: 'find . -not -name widget.py' }], + ['find ! -name', 'Bash', { command: 'find . ! -name widget.py -type f' }], + ['find -path ... -prune', 'Bash', { command: 'find . -path ./src/widget.py -prune -o -print' }], + ['find -name that misses the target', 'Bash', { command: 'find . -name widget.md' }], + ['fd -E widget.py', 'Bash', { command: 'fd -E widget.py py src' }], + ['fd --exclude widget.py', 'Bash', { command: 'fd --exclude widget.py py .' }], + ['ls -I widget.py', 'Bash', { command: 'ls -I widget.py src' }], + ["tree -I 'widget*'", 'Bash', { command: "tree -I 'widget*' src" }], + ['Get-ChildItem -Exclude', 'PowerShell', { command: 'Get-ChildItem -Recurse -Exclude widget.py' }], + ['rg brace exclusion', 'Bash', { command: "rg -g '!*.{py,js}' widget ." }], + ['rg deep directory exclusion', 'Bash', { command: "rg -g '!**/src/**' widget ." }], + ['rg character-class exclusion', 'Bash', { command: "rg -g '!w[i]dget.py' TODO ." }], + ['rg exclusion in a short-flag cluster', 'Bash', { command: "rg -ig'!widget.py' TODO ." }], + ['find negated group', 'Bash', { command: 'find . ! ( -name widget.py -o -name x.py ) -print' }], + ['git ls-files -x', 'Bash', { command: 'git ls-files -x widget.py src' }] + ]; + for (const [label, name, input] of r2Denied) { + c3Case(`an exclusion never credits the excluded target (${label})`, () => { + c3AssertDenied(c3Edit(r2Target, r2Turn(name, input)), label); + assert.ok(!c3State().fact_force_credited, `${label}: nothing credited`); + }); + } + const r2Credited = [ + ["rg -g '*.py' widget", 'Bash', { command: "rg -g '*.py' widget ." }], + ['Grep widget glob *.py', 'Grep', { pattern: 'widget', glob: '*.py', path: c3Root }], + ["rg -g '!*.md' widget (exclusion does not cover the target)", 'Bash', { command: "rg -g '!*.md' widget ." }], + ['Grep widget glob *.{py,js} (braces are one glob)', 'Grep', { pattern: 'widget', glob: '*.{py,js}', path: c3Root }], + ['Grep widget glob !node_modules/**', 'Grep', { pattern: 'widget', glob: '!node_modules/**', path: c3Root }], + ['grep --exclude-dir=node_modules widget', 'Bash', { command: 'grep -r --exclude-dir=node_modules widget .' }], + ['find -name widget.py', 'Bash', { command: 'find . -name widget.py' }], + ['Grep TODO glob widget.py (positive glob names the stem)', 'Grep', { pattern: 'TODO', glob: 'widget.py', path: c3Root }] + ]; + for (const [label, name, input] of r2Credited) { + c3Case(`control still credits (${label})`, () => { + c3AssertCredited(c3Edit(r2Target, r2Turn(name, input)), label); + }); + } + + // --- Same-turn sibling creations and counters --- + const c4Root = fs.realpathSync(fs.mkdtempSync(path.join(tmpRoot, 'gateguard-c4-proj-'))); + const c4Env = { CLAUDE_PROJECT_DIR: c4Root, CLAUDE_TRANSCRIPT_PATH: '' }; + const c4Outputs = []; + const c4Run = (toolName, toolInput, transcriptPath, env = {}) => { + const payload = { tool_name: toolName, tool_input: toolInput, cwd: c4Root }; + if (transcriptPath !== undefined) payload.transcript_path = transcriptPath; + if (c3PendingIds.has(transcriptPath)) payload.tool_use_id = c3PendingIds.get(transcriptPath); + const result = runHook(payload, { ...c4Env, ...env }); + c4Outputs.push(result.stdout); + const output = parseOutput(result.stdout); + const hso = output && output.hookSpecificOutput ? output.hookSpecificOutput : {}; + return { result, decision: hso.permissionDecision, context: hso.additionalContext || '', reason: hso.permissionDecisionReason || '' }; + }; + const c4Write = (file_path, transcriptPath, env) => c4Run('Write', { file_path, content: 'x' }, transcriptPath, env); + const c4Edit = (file_path, transcriptPath, env) => + c4Run('Edit', { file_path, old_string: 'a', new_string: 'b' }, transcriptPath, env); + const c4AssertSibling = (out, label) => { + assert.notStrictEqual(out.decision, 'deny', `${label}: must not deny (${out.result.stdout})`); + assert.notStrictEqual(out.decision, 'allow', `${label}: never permissionDecision allow`); + assert.ok(out.context.includes('[Fact-Forcing Gate] Sibling of '), `${label}: sibling note expected, got ${out.result.stdout}`); + }; + const c4AssertDenied = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny, got ${out.result.stdout}`); + assert.ok(!out.context.includes('Sibling of'), `${label}: no sibling note`); + }; + // dir_gates keys are `\u0000`. + const c4GateKey = (dir, cls = 'code') => `${cls}\u0000${stateKey(dir)}`; + const c4Turn = text => { + const human = c3Human(text); + return { human, transcript: c3WriteTranscript([human]) }; + }; + + c3Case('checked lookups under a simulated Windows root (both spellings share one key)', () => { + const winRoot = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\gateguard-c4-proj-abc'; + const savedRoot = process.env.CLAUDE_PROJECT_DIR; + const savedTranscript = process.env.CLAUDE_TRANSCRIPT_PATH; + let first; + let second; + try { + // Fixed synthetic paths: keys need no real disk, and the target is absent on any host. + const hook = loadDirectHook({ CLAUDE_PROJECT_DIR: winRoot, CLAUDE_TRANSCRIPT_PATH: '' }); + const write = file_path => hook.run({ tool_name: 'Write', cwd: winRoot, tool_input: { file_path, content: 'x' } }); + first = write(`${winRoot}/src/widgets/w0.js`); + second = write(`${winRoot}\\src\\widgets\\w1.js`); + } finally { + if (savedRoot === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = savedRoot; + if (savedTranscript === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH; + else process.env.CLAUDE_TRANSCRIPT_PATH = savedTranscript; + } + assert.strictEqual(parseOutput(first.stdout).hookSpecificOutput.permissionDecision, 'deny'); + const state = c3State(); + if (process.platform === 'win32') { + // On Windows the synthetic root resolves through C:\Users, so the second new file collapses. + assert.ok(second && /Sibling of/.test(String(second.additionalContext || '')), JSON.stringify(second)); + assert.strictEqual(Object.keys(state.dir_gates || {}).length, 1, 'one dir gate for both spellings'); + } else { + // A Windows path on a POSIX host has no real directory, so both spellings are denied. + assert.strictEqual(parseOutput(second.stdout).hookSpecificOutput.permissionDecision, 'deny', JSON.stringify(second)); + assert.deepStrictEqual(Object.keys(state.dir_gates || {}), [], 'no dir gate for an unresolvable directory'); + } + assert.ok(state.checked.includes(stateKey(`${winRoot}/src/widgets/w0.js`)), `w0 checked: ${JSON.stringify(state.checked)}`); + assert.ok(state.checked.includes(stateKey(`${winRoot}\\src\\widgets\\w1.js`)), 'w1 checked (backslash spelling)'); + }); + + c3Case('8 new files in one dir in the same turn -> 1 deny + 7 sibling notes', () => { + const { human, transcript } = c4Turn('scaffold the widgets'); + const first = c4Write(`${c4Root}/src/widgets/w0.js`, transcript); + c4AssertDenied(first, 'first file'); + for (let i = 1; i < 8; i++) { + const out = c4Write(`${c4Root}/src/widgets/w${i}.js`, transcript); + c4AssertSibling(out, `file ${i}`); + assert.strictEqual( + out.context, + `[Fact-Forcing Gate] Sibling of ${c4Root}/src/widgets/w0.js (gated earlier at denial #1 this session); proceeding without a repeat denial.` + ); + } + const state = c3State(); + assert.strictEqual(state.fact_force_denials, 1, 'one denial'); + assert.strictEqual(state.sibling_allows, 7, 'seven sibling allows'); + assert.ok(state.checked.includes(stateKey(`${c4Root}/src/widgets/w7.js`)), 'sibling marked checked (canonical key)'); + const gate = state.dir_gates[c4GateKey(`${c4Root}/src/widgets`)]; + assert.ok(gate, 'dir gate keyed by canonical dir'); + assert.strictEqual(gate.turn, human.uuid); + assert.strictEqual(gate.first, `${c4Root}/src/widgets/w0.js`); + assert.strictEqual(gate.ordinal, 1); + assert.ok(typeof gate.at === 'number' && gate.at > 0); + const retry = c4Write(`${c4Root}/src/widgets/w3.js`, transcript); + assert.strictEqual(retry.decision, undefined); + assert.strictEqual(retry.context, '', 'already checked: plain allow'); + }); + + c3Case('a different directory gets its own denial', () => { + const { transcript } = c4Turn('x'); + c4AssertDenied(c4Write(`${c4Root}/src/a/one.js`, transcript), 'dir a'); + c4AssertDenied(c4Write(`${c4Root}/src/b/two.js`, transcript), 'dir b'); + c4AssertDenied(c4Write(`${c4Root}/src/a/nested/three.js`, transcript), 'subdirectory is not a sibling'); + c4AssertSibling(c4Write(`${c4Root}/src/b/four.js`, transcript), 'dir b sibling'); + }); + + c3Case('the next human turn in the same dir is denied again', () => { + const turn1 = c3Human('first'); + const t1 = c3WriteTranscript([turn1]); + c4AssertDenied(c4Write(`${c4Root}/src/w/one.js`, t1), 'turn 1'); + const t2 = c3WriteTranscript([turn1, c3Human('second')]); + c4AssertDenied(c4Write(`${c4Root}/src/w/two.js`, t2), 'turn 2'); + c4AssertSibling(c4Write(`${c4Root}/src/w/three.js`, t2), 'turn 2 sibling'); + assert.ok(c4Write(`${c4Root}/src/w/three.js`, t2).context === '', 'checked'); + assert.strictEqual(c3State().dir_gates[c4GateKey(`${c4Root}/src/w`)].ordinal, 2, 'latest denial ordinal recorded'); + }); + + c3Case('a Write over an existing file in the gated dir is still denied (per-file)', () => { + fs.mkdirSync(path.join(c4Root, 'src', 'e'), { recursive: true }); + fs.writeFileSync(path.join(c4Root, 'src', 'e', 'existing.js'), 'old'); + const { transcript } = c4Turn('x'); + c4AssertDenied(c4Write(`${c4Root}/src/e/new.js`, transcript), 'new file'); + c4AssertDenied(c4Write(`${c4Root}/src/e/existing.js`, transcript), 'existing file'); + assert.ok(!c3State().sibling_allows, 'no sibling allow'); + }); + + c3Case('an existing-file Write denial does not open a dir gate', () => { + fs.mkdirSync(path.join(c4Root, 'src', 'f'), { recursive: true }); + fs.writeFileSync(path.join(c4Root, 'src', 'f', 'present.js'), 'old'); + const { transcript } = c4Turn('x'); + c4AssertDenied(c4Write(`${c4Root}/src/f/present.js`, transcript), 'existing file'); + c4AssertDenied(c4Write(`${c4Root}/src/f/brand_new.js`, transcript), 'new file after existing'); + }); + + c3Case('fs error resolving the target -> treated as existing (no collapse)', () => { + fs.mkdirSync(path.join(c4Root, 'src'), { recursive: true }); + fs.writeFileSync(path.join(c4Root, 'src', 'blocker'), 'not a dir'); + const { transcript } = c4Turn('x'); + c4AssertDenied(c4Write(`${c4Root}/src/blocker/one.js`, transcript), 'ENOTDIR first'); + // Windows reports ENOENT (not ENOTDIR) under a file, so the target reads as new there. + if (process.platform === 'win32') return; + c4AssertDenied(c4Write(`${c4Root}/src/blocker/two.js`, transcript), 'ENOTDIR second'); + assert.ok(!Object.keys(c3State().dir_gates || {}).some(k => k.endsWith(`${c4Root}/src/blocker`)), 'no dir gate recorded'); + }); + + c3Case('Edit and MultiEdit in a gated dir are never collapsed', () => { + const { transcript } = c4Turn('x'); + c4AssertDenied(c4Write(`${c4Root}/src/m/one.js`, transcript), 'write'); + c4AssertDenied(c4Edit(`${c4Root}/src/m/two.js`, transcript), 'edit sibling'); + const multi = c4Run( + 'MultiEdit', + { edits: [{ file_path: `${c4Root}/src/m/three.js`, old_string: 'a', new_string: 'b' }] }, + transcript + ); + c4AssertDenied(multi, 'multiedit sibling'); + c4AssertSibling(c4Write(`${c4Root}/src/m/four.js`, transcript), 'write sibling still works'); + }); + + c3Case('an Edit denial does not open a dir gate', () => { + const { transcript } = c4Turn('x'); + c4AssertDenied(c4Edit(`${c4Root}/src/n/one.js`, transcript), 'edit'); + c4AssertDenied(c4Write(`${c4Root}/src/n/two.js`, transcript), 'write after edit'); + }); + + c3Case('instruction-class siblings are each denied', () => { + const { transcript } = c4Turn('x'); + c4AssertDenied(c4Write(`${c4Root}/skills/foo/one.md`, transcript), 'instruction 1'); + c4AssertDenied(c4Write(`${c4Root}/skills/foo/two.md`, transcript), 'instruction 2'); + assert.ok(!Object.keys(c3State().dir_gates || {}).some(k => k.endsWith(`${c4Root}/skills/foo`)), 'instruction denial opens no dir gate'); + c4AssertDenied(c4Write(`${c4Root}/skills/foo/helper.js`, transcript), 'code file after instruction denials'); + c4AssertDenied(c4Write(`${c4Root}/skills/foo/three.md`, transcript), 'instruction after a code dir gate'); + }); + + c3Case('no transcript -> sibling within 120 s, denied after', () => { + c4AssertDenied(c4Write(`${c4Root}/src/nt/one.js`), 'first'); + c4AssertSibling(c4Write(`${c4Root}/src/nt/two.js`), 'within window'); + const state = c3State(); + assert.strictEqual(state.dir_gates[c4GateKey(`${c4Root}/src/nt`)].turn, null, 'turn null without transcript'); + state.dir_gates[c4GateKey(`${c4Root}/src/nt`)].at = Date.now() - 121000; + writeState(state); + c4AssertDenied(c4Write(`${c4Root}/src/nt/three.js`), 'after window'); + }); + + c3Case('a turn-scoped gate is not matched without a turn id, and vice versa', () => { + const { transcript } = c4Turn('x'); + c4AssertDenied(c4Write(`${c4Root}/src/tt/one.js`, transcript), 'turn gate'); + c4AssertDenied(c4Write(`${c4Root}/src/tt/two.js`), 'no-turn call vs turn gate'); + c4AssertDenied(c4Write(`${c4Root}/src/tt/three.js`, transcript), 'turn call vs null-turn gate'); + }); + + c3Case('a prior search credits before the sibling rule', () => { + const human = c3Human('x'); + const t = c3WriteTranscript([human]); + c4AssertDenied(c4Write(`${c4Root}/src/cr/one.js`, t), 'first'); + const t2 = c3WriteTranscript([human, ...c3Search('toolu_c4g', 'Grep', { pattern: 'widget_factory' })]); + const out = c4Write(`${c4Root}/src/cr/widget_factory.js`, t2); + assert.ok(out.context.includes('Prior search seen in this turn'), out.result.stdout); + assert.ok(!out.context.includes('Sibling of')); + assert.ok(!c3State().sibling_allows, 'credit is not a sibling allow'); + }); + + c3Case('counters record class names for denial, credit and sibling events', () => { + const human = c3Human('x'); + const t = c3WriteTranscript([human, ...c3Search('toolu_c4c', 'Grep', { pattern: 'widget_factory' })]); + c4AssertDenied(c4Write(`${c4Root}/src/cc/one.js`, t), 'code deny'); + c4AssertSibling(c4Write(`${c4Root}/src/cc/two.js`, t), 'code sibling'); + c4AssertDenied(c4Write(`${c4Root}/docs/guide_page.md`, t), 'prose deny'); + c4AssertDenied(c4Edit(`${c4Root}/tests/a.test.js`, t), 'test deny'); + c4AssertDenied(c4Write(`${c4Root}/skills/x/SKILL.md`, t), 'instruction deny'); + c3AssertCredited(c4Edit(`${c4Root}/lib/widget_factory.js`, t), 'code credit'); + const state = c3State(); + assert.deepStrictEqual(state.denials_by_class, { code: 1, prose: 1, test: 1, instruction: 1 }); + assert.deepStrictEqual(state.credited_by_class, { code: 1 }); + assert.strictEqual(state.sibling_allows, 1); + assert.strictEqual(state.fact_force_denials, 4); + assert.strictEqual(state.fact_force_credited, 1); + const json = JSON.stringify({ d: state.denials_by_class, c: state.credited_by_class }); + assert.ok(!json.includes('/'), 'counters hold class names only, never paths'); + }); + + c3Case('a state file without the counter fields loads and gains them', () => { + writeState({ checked: ['/elsewhere/x.js'], last_active: Date.now(), fact_force_denials: 2 }); + const { transcript } = c4Turn('x'); + c4AssertDenied(c4Write(`${c4Root}/src/old/one.js`, transcript), 'deny'); + c4AssertSibling(c4Write(`${c4Root}/src/old/two.js`, transcript), 'sibling'); + const state = c3State(); + assert.ok(state.checked.includes('/elsewhere/x.js')); + assert.strictEqual(state.fact_force_denials, 3); + assert.strictEqual(state.dir_gates[c4GateKey(`${c4Root}/src/old`)].ordinal, 3); + assert.deepStrictEqual(state.denials_by_class, { code: 1 }); + assert.deepStrictEqual(state.credited_by_class, {}); + assert.strictEqual(state.sibling_allows, 1); + assert.strictEqual(state.fact_force_credited, 0); + }); + + c3Case('malformed counter fields are tolerated (treated as empty/zero)', () => { + writeState({ + checked: [], + last_active: Date.now(), + dir_gates: { [c4GateKey(`${c4Root}/src/bad`)]: 'nope', [c4GateKey(`${c4Root}/src/bad2`)]: { at: 'x' }, other: [1, 2] }, + denials_by_class: [5], + credited_by_class: { code: -3, prose: 'x', test: 2 }, + sibling_allows: 'many' + }); + c4AssertDenied(c4Write(`${c4Root}/src/bad/one.js`), 'malformed gate ignored'); + c4AssertSibling(c4Write(`${c4Root}/src/bad/two.js`), 'fresh gate works'); + const state = c3State(); + assert.deepStrictEqual(Object.keys(state.dir_gates), [c4GateKey(`${c4Root}/src/bad`)]); + assert.deepStrictEqual(state.denials_by_class, { code: 1 }); + assert.deepStrictEqual(state.credited_by_class, { test: 2 }); + assert.strictEqual(state.sibling_allows, 1); + writeState({ checked: [], last_active: Date.now(), dir_gates: 'garbage', denials_by_class: null, sibling_allows: -1 }); + c4AssertDenied(c4Write(`${c4Root}/src/bad3/one.js`), 'garbage dir_gates'); + assert.strictEqual(c3State().sibling_allows, 0); + }); + + c3Case('dir_gates is capped at 50 (oldest evicted)', () => { + const now = Date.now(); + const gates = {}; + for (let i = 0; i < 50; i++) gates[c4GateKey(`/cap/d${i}`)] = { turn: null, at: now - 100000 + i, first: `/cap/d${i}/f.js`, ordinal: i + 1 }; + writeState({ checked: [], last_active: now, dir_gates: gates }); + c4AssertDenied(c4Write(`${c4Root}/src/cap/one.js`), 'new gate'); + const state = c3State(); + assert.strictEqual(Object.keys(state.dir_gates).length, 50, 'capped at 50'); + assert.ok(state.dir_gates[c4GateKey(`${c4Root}/src/cap`)], 'newest kept'); + assert.ok(!state.dir_gates[c4GateKey('/cap/d0')], 'oldest evicted'); + assert.ok(state.dir_gates[c4GateKey('/cap/d1')], 'second-oldest kept'); + }); + + c3Case('dir_gate first path is stored sanitized', () => { + const file_path = `${c4Root}/src/san/evil\u202ename\u200b.js`; + c4AssertDenied(c4Write(file_path), 'deny'); + const gate = c3State().dir_gates[c4GateKey(`${c4Root}/src/san`)]; + assert.ok(gate && !gate.first.includes('\u202e') && !gate.first.includes('\u200b'), JSON.stringify(gate)); + const out = c4Write(`${c4Root}/src/san/ok.js`); + c4AssertSibling(out, 'sibling'); + assert.ok(!out.context.includes('\u202e')); + }); + + c3Case('merges dir_gates and counters written by another process during save', () => { + const hook = loadDirectHook({ CLAUDE_PROJECT_DIR: c4Root, CLAUDE_TRANSCRIPT_PATH: '' }); + const originalMkdirSync = fs.mkdirSync; + const now = Date.now(); + let injected = false; + fs.mkdirSync = function patchedMkdirSync(target) { + const result = originalMkdirSync.apply(fs, arguments); + if (!injected && path.resolve(String(target)) === path.resolve(stateDir)) { + injected = true; + fs.writeFileSync( + stateFile, + JSON.stringify({ + checked: ['/src/concurrent.js'], + last_active: now, + fact_force_denials: 7, + dir_gates: { + [c4GateKey('/other/dir')]: { turn: 'u-other', at: now, first: '/other/dir/a.js', ordinal: 7 }, + [c4GateKey(`${c4Root}/src/conc`)]: { turn: 'u-old', at: 1, first: 'stale', ordinal: 1 } + }, + denials_by_class: { code: 5, prose: 2 }, + credited_by_class: { test: 3 }, + sibling_allows: 9 + }), + 'utf8' + ); + } + return result; + }; + const savedRoot = process.env.CLAUDE_PROJECT_DIR; + const savedTranscript = process.env.CLAUDE_TRANSCRIPT_PATH; + try { + const result = hook.run({ tool_name: 'Write', cwd: c4Root, tool_input: { file_path: `${c4Root}/src/conc/new.js`, content: 'x' } }); + assert.strictEqual(parseOutput(result.stdout).hookSpecificOutput.permissionDecision, 'deny'); + } finally { + fs.mkdirSync = originalMkdirSync; + if (savedRoot === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = savedRoot; + if (savedTranscript === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH; + else process.env.CLAUDE_TRANSCRIPT_PATH = savedTranscript; + } + const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + assert.ok(persisted.dir_gates[c4GateKey('/other/dir')], 'concurrent dir gate preserved'); + const mine = persisted.dir_gates[c4GateKey(`${c4Root}/src/conc`)]; + assert.ok(mine && mine.first === `${c4Root}/src/conc/new.js` && mine.turn === null, 'newer in-memory gate wins'); + assert.deepStrictEqual(persisted.denials_by_class, { code: 5, prose: 2 }, 'per-key max(disk, mem)'); + assert.deepStrictEqual(persisted.credited_by_class, { test: 3 }); + assert.strictEqual(persisted.sibling_allows, 9); + assert.ok(persisted.checked.includes('/src/concurrent.js')); + }); + + c3Case('sibling state save failure -> allow with state warning', () => { + c4AssertDenied(c4Write(`${c4Root}/src/sf/one.js`), 'deny'); + const hook = loadDirectHook({ CLAUDE_PROJECT_DIR: c4Root, CLAUDE_TRANSCRIPT_PATH: '' }); + const originalWriteFileSync = fs.writeFileSync; + fs.writeFileSync = function patchedWriteFileSync(target) { + if (String(target).includes('.json.tmp.')) throw Object.assign(new Error('EIO'), { code: 'EIO' }); + return originalWriteFileSync.apply(fs, arguments); + }; + const savedRoot = process.env.CLAUDE_PROJECT_DIR; + const savedTranscript = process.env.CLAUDE_TRANSCRIPT_PATH; + let result; + try { + result = hook.run({ tool_name: 'Write', cwd: c4Root, tool_input: { file_path: `${c4Root}/src/sf/two.js`, content: 'x' } }); + } finally { + fs.writeFileSync = originalWriteFileSync; + if (savedRoot === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = savedRoot; + if (savedTranscript === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH; + else process.env.CLAUDE_TRANSCRIPT_PATH = savedTranscript; + } + assert.ok(!result.stdout, 'no deny and no sibling note'); + assert.ok(String(result.stderr).includes('could not be persisted'), JSON.stringify(result)); + }); + + c3Case('clipped window keeps the promptId turn id; a sibling gate from earlier in the turn still matches', () => { + const { scanCurrentTurn } = loadDirectHook(); + const pid = 'prompt-heavy'; + const human = c3Human('scaffold', { promptId: pid }); + const early = c3WriteTranscript([human]); + assert.strictEqual(scanCurrentTurn(early).turnId, pid); + const gateDir = `${c4Root}/src/heavy`; + c4AssertDenied(c4Write(`${gateDir}/one.js`, early), 'first file, boundary in window'); + assert.strictEqual(c3State().dir_gates[c4GateKey(gateDir)].turn, pid, 'gate recorded under the promptId'); + const filler = c3Filler('pid').map(r => (r.type === 'user' ? { ...r, promptId: pid } : r)); + const late = c3WriteTranscript([human, ...filler]); + assert.ok(fs.statSync(late).size > 300 * 1024, 'boundary scrolled out of the tail'); + assert.strictEqual(scanCurrentTurn(late).turnId, pid, 'turn id stable after the turn outgrows the tail'); + c4AssertSibling(c4Write(`${gateDir}/two.js`, late), 'sibling after the boundary left the window'); + const next = c3WriteTranscript([c3Human('next', { promptId: 'prompt-next' })]); + c4AssertDenied(c4Write(`${gateDir}/three.js`, next), 'next prompt is a new turn'); + }); + + c3Case('a new human turn uses its own promptId even when a late tool_result carries the previous one', () => { + const t1Human = c3Human('turn one', { promptId: 'P1' }); + const t1 = c3WriteTranscript([t1Human]); + c4AssertDenied(c4Write(`${c4Root}/src/n3/aaa1.py`, t1), 'turn P1'); + const late = { ...c3ToolResult('toolu_n3late'), promptId: 'P1' }; + const t2 = c3WriteTranscript([t1Human, c3Human('turn two', { promptId: 'P2' }), c3ToolUse('toolu_n3late', 'Bash', { command: 'true' }), late]); + c4AssertDenied(c4Write(`${c4Root}/src/n3/aaa2.py`, t2), 'turn P2 with a late P1 tool_result'); + c4AssertSibling(c4Write(`${c4Root}/src/n3/aaa3.py`, t2), 'sibling within turn P2'); + assert.strictEqual(c3State().dir_gates[c4GateKey(`${c4Root}/src/n3`)].turn, 'P2', 'gate recorded under the boundary promptId'); + }); + + c3Case('a transcript that yields no turn id never falls back to the no-turn 120 s rule', () => { + const asDir = fs.mkdtempSync(path.join(tmpRoot, 'gateguard-n4-dir-')); + c4AssertDenied(c4Write(`${c4Root}/src/n4a/one.js`, asDir), 'transcript path is a directory (scan null)'); + c4AssertDenied(c4Write(`${c4Root}/src/n4a/two.js`, asDir), 'no collapse with a null scan'); + const noBoundary = c3WriteTranscript([c3ToolUse('toolu_n4x', 'Bash', { command: 'true' }), c3ToolResult('toolu_n4x')]); + c4AssertDenied(c4Write(`${c4Root}/src/n4b/one.js`, noBoundary), 'whole file without a boundary (turn id null)'); + c4AssertDenied(c4Write(`${c4Root}/src/n4b/two.js`, noBoundary), 'no collapse without a turn id'); + const missing = path.join(tmpRoot, 'gateguard-n4-missing.jsonl'); + c4AssertDenied(c4Write(`${c4Root}/src/n4c/one.js`, missing), 'missing transcript file'); + c4AssertDenied(c4Write(`${c4Root}/src/n4c/two.js`, missing), 'no collapse with a missing transcript'); + const malformed = path.join(tmpRoot, `gateguard-n4-mal-${process.pid}.jsonl`); + fs.writeFileSync(malformed, [c3Human('t1', { promptId: 'M1' }), c3Human('t2', { promptId: 'M2' })].map(r => JSON.stringify(r)).join('\n') + '\n{"type":"assistant"}\n'); + c4AssertDenied(c4Write(`${c4Root}/src/n4d/one.js`, malformed), 'malformed assistant record'); + c4AssertSibling(c4Write(`${c4Root}/src/n4d/two.js`, malformed), 'malformed record skipped: same turn M2 still collapses'); + assert.strictEqual(c3State().dir_gates[c4GateKey(`${c4Root}/src/n4d`)].turn, 'M2'); + fs.rmSync(asDir, { recursive: true, force: true }); + fs.rmSync(malformed, { force: true }); + }); + + c3Case('no sibling-collapse output carries permissionDecision "allow"', () => { + assert.ok(c4Outputs.length > 20, 'collected outputs'); + for (const stdout of c4Outputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Prior-search credit: scope and matching rules --- + const f2Root = fs.realpathSync(fs.mkdtempSync(path.join(tmpRoot, 'gateguard-f2-proj-'))); + // Shell fixtures use forward slashes: bash treats unquoted backslashes as escapes. + const f2RootSh = f2Root.replace(/\\/g, '/'); + for (const dir of ['src/handlers', 'handlers', 'f']) fs.mkdirSync(path.join(f2Root, dir), { recursive: true }); + for (const file of ['src/payment.py', 'src/auth.py', 'src/payment_service.py', 'top.py']) { + fs.writeFileSync(path.join(f2Root, file), 'x'); + } + const f2Env = { CLAUDE_PROJECT_DIR: f2Root, CLAUDE_TRANSCRIPT_PATH: '' }; + const f2Outputs = []; + let f2Session = 0; + // Each call gets a fresh session unless the case passes session_id explicitly. + const f2Run = (toolName, toolInput, transcriptPath, extra = {}) => { + f2Session += 1; + const payload = { tool_name: toolName, tool_input: toolInput, cwd: f2Root, transcript_path: transcriptPath, session_id: `f2-${f2Session}` }; + if (c3PendingIds.has(transcriptPath)) payload.tool_use_id = c3PendingIds.get(transcriptPath); + Object.assign(payload, extra); + if (payload.tool_use_id === null) delete payload.tool_use_id; + const result = runHook(payload, f2Env); + f2Outputs.push(result.stdout); + const output = parseOutput(result.stdout); + const hso = output && output.hookSpecificOutput ? output.hookSpecificOutput : {}; + return { result, decision: hso.permissionDecision, context: hso.additionalContext || '', reason: hso.permissionDecisionReason || '' }; + }; + const f2 = rel => `${f2Root}/${rel}`; + const f2Edit = (rel, t, extra) => f2Run('Edit', { file_path: f2(rel), old_string: 'a', new_string: 'b' }, t, extra); + const f2Write = (rel, t, extra) => f2Run('Write', { file_path: f2(rel), content: 'x' }, t, extra); + const f2Turn = (...records) => c3WriteTranscript([c3Human('fix'), ...records]); + const f2Bash = (id, command) => c3Search(id, 'Bash', { command }); + const f2Deny = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny, got ${out.result.stdout}`); + assert.ok(!out.context.includes('Prior search seen'), `${label}: no credit note`); + }; + const f2Credit = (out, label) => { + assert.notStrictEqual(out.decision, 'deny', `${label}: must not deny (${out.result.stdout})`); + assert.ok(out.context.includes('Prior search seen in this turn'), `${label}: credit note expected, got ${out.result.stdout}`); + }; + const f2Msg = (id, messageId, name, input) => ({ + type: 'assistant', + uuid: c3NextUuid(), + message: { id: messageId, role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } + }); + const f2Result = (id, extra) => ({ + type: 'user', + uuid: c3NextUuid(), + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'ok', ...extra }] } + }); + + c3Case('a directory listing does not credit a Write that overwrites an existing file', () => { + f2Deny(f2Write('src/payment.py', f2Turn(...c3Search('toolu_h1a', 'LS', { path: 'src' }))), 'LS src -> overwrite payment.py'); + f2Deny(f2Write('top.py', f2Turn(...f2Bash('toolu_h1b', 'grep -rn TODO .'))), 'grep -rn TODO . -> overwrite top.py'); + f2Deny(f2Write('src/handlers/../payment.py', f2Turn(...c3Search('toolu_h1c', 'Glob', { pattern: 'src/*.py' }))), 'Glob src/*.py -> overwrite'); + }); + + c3Case('shell patterns and flag values are not directories', () => { + f2Deny(f2Write('handlers/zzzz_new.py', f2Turn(...f2Bash('toolu_h2a', 'grep -r handlers src'))), 'grep pattern as dir'); + f2Deny(f2Write('f/qqqq_new.py', f2Turn(...f2Bash('toolu_h2b', 'find . -type f -name x'))), 'find -type value as dir'); + f2Deny(f2Write('handlers/rg_new.py', f2Turn(...f2Bash('toolu_h2c', 'rg -g handlers TODO src'))), 'rg -g value as dir'); + f2Deny(f2Write('handlers/sls_new.py', f2Turn(...c3Search('toolu_h2d', 'PowerShell', { command: 'Select-String handlers src/*.py' }))), 'sls pattern'); + }); + + c3Case('bare cwd operands and missing directories give no directory credit', () => { + f2Deny(f2Write('zzz_new.py', f2Turn(...f2Bash('toolu_h2e', 'grep -rn TODO .'))), 'grep -rn TODO .'); + f2Deny(f2Write('nothere/new_mod.py', f2Turn(...f2Bash('toolu_h2f', 'ls nothere'))), 'ls of a missing dir'); + f2Deny(f2Write('top.py/new_mod.py', f2Turn(...f2Bash('toolu_h2g', 'ls top.py'))), 'ls of a file, not a dir'); + }); + + c3Case('Glob with no literal prefix, or no glob characters, names no directory', () => { + f2Deny(f2Write('brandnew.py', f2Turn(...c3Search('toolu_h2h', 'Glob', { pattern: '**/*' }))), 'Glob **/*'); + f2Deny(f2Write('otherzz.py', f2Turn(...c3Search('toolu_h2i', 'Glob', { pattern: 'qqq.txt' }))), 'Glob qqq.txt'); + f2Deny(f2Write('src/handlers/brand_new.py', f2Turn(...c3Search('toolu_h2j', 'Glob', { pattern: 'src/handlers/x.py' }))), 'Glob file lookup'); + }); + + c3Case('stems match on word boundaries only', () => { + f2Deny(f2Edit('src/auth.py', f2Turn(...c3Search('toolu_m1a', 'Grep', { pattern: 'author' }))), 'author vs auth'); + f2Deny(f2Edit('src/payment.py', f2Turn(...c3Search('toolu_m1b', 'Grep', { pattern: 'TODO', glob: '**/payments/**' }))), 'payments glob'); + f2Credit(f2Edit('src/auth.py', f2Turn(...c3Search('toolu_m1c', 'Grep', { pattern: 'def auth(' }))), 'auth( boundary'); + }); + + c3Case('shell segments that only read stdin never credit', () => { + f2Deny(f2Edit('src/payment.py', f2Turn(...f2Bash('toolu_m1d', 'echo payment | grep payment'))), 'echo | grep'); + f2Deny(f2Edit('src/payment.py', f2Turn(...f2Bash('toolu_m1e', 'cat README | grep -c payment'))), 'cat | grep -c'); + f2Deny(f2Edit('src/payment.py', f2Turn(...f2Bash('toolu_m1f', 'echo payment | rg payment'))), 'piped rg with no path'); + }); + + c3Case('a shell segment that only reads the target is not a search', () => { + f2Deny(f2Edit('src/payment.py', f2Turn(...f2Bash('toolu_m1g', 'ls src/payment.py'))), 'ls target'); + f2Deny(f2Edit('src/payment.py', f2Turn(...f2Bash('toolu_m1h', 'grep -n . src/payment.py'))), 'grep . target'); + f2Deny(f2Edit('src/payment.py', f2Turn(...f2Bash('toolu_m1i', 'grep -n payment ./src/payment.py'))), 'grep stem in target only'); + f2Deny(f2Edit('src/payment.py', f2Turn(...c3Search('toolu_m1j', 'Grep', { pattern: 'payment', path: 'src/payment.py' }))), 'Grep path = target'); + }); + + c3Case('Grep/Glob/LS with an explicit path only credit targets inside it', () => { + f2Deny(f2Edit('src/payment.py', f2Turn(...c3Search('toolu_m1k', 'Grep', { pattern: 'payment', path: '/nonexistent/elsewhere' }))), 'Grep elsewhere'); + f2Deny(f2Edit('src/payment.py', f2Turn(...c3Search('toolu_m1l', 'Glob', { pattern: 'payment.md', path: '/nonexistent-f2' }))), 'Glob elsewhere'); + f2Deny(f2Edit('src/payment.py', f2Turn(...c3Search('toolu_m1m', 'Grep', { pattern: 'TODO', path: 'x', glob: 'src/payments/**' }))), 'D3'); + f2Deny(f2Edit('src/payment.py', f2Turn(...c3Search('toolu_m1n', 'Glob', { pattern: 'lib/**/payment*' }))), 'Glob prefix elsewhere'); + f2Deny(f2Edit('src/payment.py', f2Turn(...c3Search('toolu_m1o', 'Glob', { pattern: 'payment.md', path: f2Root }))), 'file lookup in an ancestor dir'); + f2Deny(f2Edit('src/payment.py', f2Turn(...c3Search('toolu_m1p', 'Glob', { pattern: 'src/payment.py' }))), 'lookup of the target itself'); + f2Credit(f2Edit('src/payment.py', f2Turn(...c3Search('toolu_m1q', 'Glob', { pattern: 'src/payment.md' }))), 'file lookup beside the target'); + }); + + c3Case('same-batch searches are excluded when the pending call cannot be located', () => { + const edit = f2(`src/payment.py`); + const sameBatch = c3WriteTranscript( + [c3Human('fix'), f2Msg('g1', 'msg_B', 'Grep', { pattern: 'payment' }), f2Msg('e1', 'msg_B', 'Edit', { file_path: edit }), f2Result('g1')], + { pending: false } + ); + f2Deny(f2Edit('src/payment.py', sameBatch), 'no tool_use_id: newest message excluded'); + f2Deny(f2Edit('src/payment.py', sameBatch, { tool_use_id: 'e1' }), 'tool_use_id located'); + const notYet = c3WriteTranscript([c3Human('fix'), f2Msg('g2', 'msg_C', 'Grep', { pattern: 'payment' }), f2Result('g2')], { pending: false }); + f2Deny(f2Edit('src/payment.py', notYet, { tool_use_id: 'e2' }), 'tool_use_id not in transcript yet'); + const noMsgId = c3WriteTranscript([ + c3Human('fix'), + { type: 'assistant', uuid: c3NextUuid(), message: { role: 'assistant', content: [{ type: 'tool_use', id: 'g3', name: 'Grep', input: { pattern: 'payment' } }] } }, + f2Result('g3') + ]); + f2Deny(f2Edit('src/payment.py', noMsgId), 'search without message.id'); + const earlier = c3WriteTranscript( + [c3Human('fix'), f2Msg('g4', 'msg_D', 'Grep', { pattern: 'payment' }), f2Result('g4'), f2Msg('e4', 'msg_E', 'Edit', { file_path: edit })], + { pending: false } + ); + f2Credit(f2Edit('src/payment.py', earlier), 'no tool_use_id: search in an older message still credits'); + }); + + c3Case('a boundary without uuid yields a hashed turn id; siblings do not cross turns', () => { + const { scanCurrentTurn } = loadDirectHook(); + const noUuid = text => ({ type: 'user', message: { role: 'user', content: text } }); + const t1 = c3WriteTranscript([noUuid('turn one')], { pending: false }); + const t2 = c3WriteTranscript([noUuid('turn one'), noUuid('turn TWO new request')], { pending: false }); + const id1 = scanCurrentTurn(t1).turnId; + const id2 = scanCurrentTurn(t2).turnId; + assert.ok(/^h:[0-9a-f]{16}$/.test(id1), `hashed turn id, got ${id1}`); + assert.notStrictEqual(id1, id2, 'different boundary lines, different turn ids'); + const w1 = f2Write('src/l1/hhh1.py', t1, { tool_use_id: null, session_id: 'f2-l1' }); + assert.strictEqual(w1.decision, 'deny', w1.result.stdout); + const w2 = f2Write('src/l1/hhh2.py', t2, { tool_use_id: null, session_id: 'f2-l1' }); + assert.strictEqual(w2.decision, 'deny', `turn 2 sibling must be denied: ${w2.result.stdout}`); + }); + + c3Case('compaction is a turn boundary', () => { + const pre = [c3Human('orig task'), ...c3Search('toolu_l2a', 'Grep', { pattern: 'payment' })]; + const summary = { type: 'user', uuid: c3NextUuid(), isCompactSummary: true, message: { role: 'user', content: 'Summary: ...' } }; + f2Deny(f2Edit('src/payment.py', c3WriteTranscript([...pre, { type: 'system', subtype: 'compact_boundary' }, summary])), 'summary'); + f2Deny(f2Edit('src/payment.py', c3WriteTranscript([...pre, { type: 'system', uuid: 'cb-1', subtype: 'compact_boundary' }])), 'boundary only'); + const { scanCurrentTurn } = loadDirectHook(); + assert.strictEqual(scanCurrentTurn(c3WriteTranscript([...pre, summary], { pending: false })).turnId, summary.uuid, 'summary uuid is the turn id'); + }); + + c3Case('error results are recognised without a boolean is_error', () => { + const errored = extra => f2Turn(c3ToolUse('toolu_l3', 'Grep', { pattern: 'payment' }), f2Result('toolu_l3', extra)); + f2Deny(f2Edit('src/payment.py', errored({ is_error: 'true', content: 'Error: denied' })), 'is_error "true"'); + f2Deny(f2Edit('src/payment.py', errored({ content: 'Permission denied' })), 'string content'); + f2Deny(f2Edit('src/payment.py', errored({ content: [{ type: 'text', text: ' x' }] })), 'text block'); + }); + + c3Case('a tool_use id seen more than once in the turn never qualifies', () => { + const t = c3WriteTranscript([ + c3Human('x'), + f2Msg('g', 'msg_1', 'Grep', { pattern: 'unrelated' }), + f2Result('g'), + f2Msg('g', 'msg_2', 'Grep', { pattern: 'payment' }) + ]); + f2Deny(f2Edit('src/payment.py', t), 'duplicate id'); + }); + + c3Case('a cd anywhere in the turn disables shell directory credit (stem counts with a known base)', () => { + const t = f2Turn(...f2Bash('toolu_l7a', 'cd src'), ...f2Bash('toolu_l7b', 'ls handlers')); + f2Deny(f2Write('handlers/new_mod.py', t), 'cd then ls handlers'); + const control = f2Turn(...f2Bash('toolu_l7c', 'ls handlers')); + f2Credit(f2Write('handlers/new_mod2.py', control), 'control without cd'); + // After a cd in another call, a relative operand's base is unknown, so it scopes nothing. + const stem = f2Turn(...f2Bash('toolu_l7d', 'cd src'), ...f2Bash('toolu_l7e', 'rg -n payment ..')); + f2Deny(f2Edit('src/payment.py', stem), 'relative operand after a cd in another call'); + const absolute = f2Turn(...f2Bash('toolu_l7f', 'cd src'), ...f2Bash('toolu_l7g', `rg -n payment ${f2RootSh}/src`)); + f2Credit(f2Edit('src/payment.py', absolute), 'stem after cd with an absolute operand'); + }); + + c3Case('documented search forms still credit', () => { + f2Credit(f2Edit('src/payment.py', f2Turn(...f2Bash('toolu_p1', 'rg -n payment src'))), 'rg -n payment src'); + f2Credit(f2Edit('src/payment_service.py', f2Turn(...c3Search('toolu_p2', 'Grep', { pattern: 'payment_service', path: 'src' }))), 'Grep path src'); + f2Credit(f2Write('src/handlers/new_handler.py', f2Turn(...c3Search('toolu_p3', 'Glob', { pattern: 'src/handlers/*.py' }))), 'Glob dir'); + f2Credit(f2Write('src/handlers/ls_new.py', f2Turn(...c3Search('toolu_p4', 'LS', { path: 'src/handlers' }))), 'LS dir'); + f2Credit(f2Write('src/handlers/x_new.py', f2Turn(...f2Bash('toolu_p5', 'find src/handlers -name "*.py"'))), 'find dir'); + f2Credit(f2Edit('src/auth.py', f2Turn(...f2Bash('toolu_p6', 'grep -r . -e auth'))), '-e supplies the pattern'); + f2Credit(f2Write('src/handlers/grep_new.py', f2Turn(...f2Bash('toolu_p7', 'grep -rn --include "*.py" TODO src/handlers/'))), 'grep dir'); + }); + + c3Case('a shell search only credits targets inside what it searched', () => { + const edit = (id, command) => f2Edit('src/payment.py', f2Turn(...f2Bash(id, command))); + f2Deny(edit('toolu_n1a', 'grep -rn payment /usr/share/doc'), 'grep -rn outside the project'); + f2Deny(edit('toolu_n1b', 'rg payment docs'), 'rg in another directory'); + f2Deny(edit('toolu_n1c', 'find /etc -name payment'), 'find outside the project'); + f2Deny(edit('toolu_n1d', 'rg payment < /etc/hostname'), 'rg reading stdin via <'); + f2Deny(edit('toolu_n1e', 'rg payment { + const edit = (id, command) => f2Edit('src/payment.py', f2Turn(...f2Bash(id, command))); + f2Credit(edit('toolu_n1p1', 'rg -n payment src'), 'rg -n payment src'); + f2Credit(edit('toolu_n1p2', 'grep -rn payment .'), 'grep -rn payment . (cwd scope)'); + f2Credit(edit('toolu_n1p3', 'find src -name "payment*"'), 'find src'); + f2Credit(edit('toolu_n1p4', 'rg payment'), 'rg with no operand searches the cwd'); + f2Credit(edit('toolu_n1p5', 'find . -name "payment*"'), 'find .'); + f2Credit(edit('toolu_n1p6', 'rg payment docs src'), 'one of several operands contains the target'); + f2Credit(edit('toolu_n1p7', 'rg -n payment src/payment.py src/auth.py'), 'the target itself among other operands'); + f2Credit(edit('toolu_n1p8', 'rg payment src < /dev/null'), 'operand before an input redirection'); + f2Credit(edit('toolu_n1p9', `cd ${f2RootSh} && rg payment src`), 'relative operand after an absolute cd into the project'); + f2Credit(edit('toolu_n1p10', `grep -rn payment ${f2RootSh}/src`), 'absolute operand'); + f2Credit(edit('toolu_n1p11', 'rg payment src/*.py'), 'glob operand scopes to its literal directory'); + }); + + c3Case('no credit-scoping output carries permissionDecision "allow"', () => { + assert.ok(f2Outputs.length > 30, 'collected outputs'); + for (const stdout of f2Outputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Sibling collapse keyed by target class --- + // The project root sits under an ancestor `tests/` on purpose: `src/a.py` must still be code. + const f3Base = fs.realpathSync(fs.mkdtempSync(path.join(tmpRoot, 'gateguard-f3-'))); + const f3Root = path.join(f3Base, 'tests', 'proj'); + fs.mkdirSync(f3Root, { recursive: true }); + const f3Env = { CLAUDE_PROJECT_DIR: f3Root, CLAUDE_TRANSCRIPT_PATH: '' }; + const f3Outputs = []; + const f3 = rel => `${f3Root}/${rel}`; + const f3Run = (toolName, toolInput, transcriptPath) => { + const payload = { tool_name: toolName, tool_input: toolInput, cwd: f3Root }; + if (transcriptPath !== undefined) payload.transcript_path = transcriptPath; + if (c3PendingIds.has(transcriptPath)) payload.tool_use_id = c3PendingIds.get(transcriptPath); + const result = runHook(payload, f3Env); + f3Outputs.push(result.stdout); + const output = parseOutput(result.stdout); + const hso = output && output.hookSpecificOutput ? output.hookSpecificOutput : {}; + return { result, decision: hso.permissionDecision, context: hso.additionalContext || '', reason: hso.permissionDecisionReason || '' }; + }; + const f3Write = (rel, t) => f3Run('Write', { file_path: f3(rel), content: 'x' }, t); + const f3Edit = (rel, t) => f3Run('Edit', { file_path: f3(rel), old_string: 'a', new_string: 'b' }, t); + const f3Turn = () => c3WriteTranscript([c3Human('scaffold it')]); + const f3GateKey = (cls, relDir) => `${cls}\u0000${stateKey(`${f3Root}/${relDir}`)}`; + const f3Deny = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny, got ${out.result.stdout}`); + assert.ok(!out.context.includes('Sibling of'), `${label}: no sibling note`); + }; + const f3Sibling = (out, label) => { + assert.notStrictEqual(out.decision, 'deny', `${label}: must not deny (${out.result.stdout})`); + assert.ok(out.context.includes('[Fact-Forcing Gate] Sibling of '), `${label}: sibling note expected, got ${out.result.stdout}`); + }; + + c3Case('a code denial does not collapse other classes in the same dir', () => { + const t = f3Turn(); + f3Deny(f3Write('src/newmod.py', t), 'code first'); + f3Deny(f3Write('src/.env.production', t), 'config sibling of code'); + f3Deny(f3Write('src/newmod.test.py', t), 'test sibling of code'); + f3Deny(f3Write('src/README.md', t), 'QA 2c prose sibling of code'); + f3Deny(f3Write('src/settings.yaml', t), 'QA 2c config sibling of code'); + f3Deny(f3Write('src/.env.local', t), 'QA 2c .env sibling of code'); + f3Sibling(f3Write('src/other.py', t), 'code sibling of code still collapses'); + f3Sibling(f3Write('src/other.test.py', t), 'test sibling of test collapses'); + f3Sibling(f3Write('src/CHANGES.md', t), 'prose sibling of prose collapses'); + }); + + c3Case('harness, instruction and config paths never collapse', () => { + const t = f3Turn(); + f3Deny(f3Write('.github/notes.md', t), '.github prose'); + f3Deny(f3Write('.github/more.md', t), '.github prose never collapses'); + const g5 = f3Write('.github/copilot-instructions.md', t); + f3Deny(g5, 'copilot-instructions'); + assert.ok(g5.reason.includes('harness/loader'), 'copilot-instructions.md is instruction class'); + f3Deny(f3Write('READMEX.md', t), 'root prose'); + f3Deny(f3Write('AGENT.md', t), 'AGENT.md'); + f3Deny(f3Write('.cursorrules', t), '.cursorrules'); + f3Deny(f3Write('.windsurfrules', t), '.windsurfrules'); + f3Deny(f3Write('.mcp.json', t), '.mcp.json'); + f3Sibling(f3Write('NOTES.md', t), 'ordinary prose sibling at root still collapses'); + f3Deny(f3Write('.claude/hooks/a.sh', t), '.claude/hooks'); + f3Deny(f3Write('.claude/hooks/evil.sh', t), '.claude/hooks sibling'); + f3Deny(f3Write('.husky/pre-commit', t), '.husky'); + f3Deny(f3Write('.husky/pre-push', t), '.husky sibling'); + f3Deny(f3Write('config/app.yaml', t), 'config'); + f3Deny(f3Write('config/db.yaml', t), 'config sibling'); + f3Deny(f3Write('lib/.hidden.js', t), 'dotfile'); + f3Deny(f3Write('lib/.other.js', t), 'dotfile sibling'); + const gates = c3State().dir_gates || {}; + for (const key of Object.keys(gates)) { + assert.ok(!/\.github|\.claude|\.husky|^config|^instruction/.test(key), `no gate recorded for ${JSON.stringify(key)}`); + } + }); + + c3Case('classification uses the project-relative path (ancestor tests/ dir, worktrees)', () => { + const t = f3Turn(); + const code = f3Write('src/a.py', t); + f3Deny(code, 'code'); + assert.ok(code.reason.includes('Name the file(s) and line(s) that will call this new file'), 'code questions'); + const state = c3State(); + assert.deepStrictEqual(state.denials_by_class, { code: 1 }, 'counter uses the same class as the questions'); + assert.ok(state.dir_gates[f3GateKey('code', 'src')], `gate key uses the same class: ${JSON.stringify(Object.keys(state.dir_gates))}`); + assert.ok(f3Edit('lib/b.py', t).reason.includes('List ALL files that import/require this file'), 'Edit code questions'); + // The prefix is stripped only for a real worktree (its .git file or dir exists). + fs.mkdirSync(f3('.claude/worktrees/feat-x'), { recursive: true }); + fs.writeFileSync(f3('.claude/worktrees/feat-x/.git'), 'gitdir: ../../../.git/worktrees/feat-x\n'); + const wt = f3Write('.claude/worktrees/feat-x/docs/guide.md', t); + f3Deny(wt, 'worktree prose'); + assert.ok(wt.reason.includes('supersedes or duplicates'), `worktree .md is prose: ${wt.reason}`); + f3Sibling(f3Write('.claude/worktrees/feat-x/docs/guide2.md', t), 'worktree prose sibling'); + const wtHook = f3Write('.claude/worktrees/feat-x/.claude/hooks/x.md', t); + f3Deny(wtHook, 'worktree harness'); + // 4th denial: condensed form; both forms name the loading harness only for instruction files. + assert.ok(wtHook.reason.includes('which harness loads this file'), `worktree .claude/hooks/*.md is instruction: ${wtHook.reason}`); + f3Deny(f3Write('.claude/worktrees/feat-x/.claude/hooks/y.md', t), 'worktree harness sibling'); + }); + + c3Case('sibling note wording and class-prefixed gate key', () => { + const t = f3Turn(); + f3Deny(f3Write('src/w/one.js', t), 'first'); + const out = f3Write('src/w/two.js', t); + f3Sibling(out, 'sibling'); + assert.strictEqual( + out.context, + `[Fact-Forcing Gate] Sibling of ${f3Root}/src/w/one.js (gated earlier at denial #1 this session); proceeding without a repeat denial.` + ); + const gates = c3State().dir_gates; + assert.deepStrictEqual(Object.keys(gates), [f3GateKey('code', 'src/w')]); + }); + + c3Case('a future `at` or a zero ordinal is never honored', () => { + const now = Date.now(); + const gate = (at, ordinal, turn = null) => ({ turn, at, first: '/forged.js', ordinal }); + writeState({ + checked: [], + last_active: now, + dir_gates: { + [f3GateKey('code', 'src/fut')]: gate(now + 3600 * 1000, 1), + [f3GateKey('code', 'src/zero')]: gate(now - 1000, 0), + [f3GateKey('code', 'src/ok')]: gate(now - 1000, 1) + } + }); + f3Deny(f3Write('src/fut/a.js'), 'future at (no turn)'); + f3Deny(f3Write('src/zero/a.js'), 'ordinal 0'); + f3Sibling(f3Write('src/ok/a.js'), 'control: valid gate still matches'); + const human = c3Human('x'); + const t = c3WriteTranscript([human]); + writeState({ checked: [], last_active: now, dir_gates: { [f3GateKey('code', 'src/futt')]: gate(now + 3600 * 1000, 1, human.uuid) } }); + f3Deny(f3Write('src/futt/a.js', t), 'future at (same turn)'); + }); + + c3Case('old-shape dir_gates keys (no class prefix) are ignored and dropped', () => { + const now = Date.now(); + writeState({ checked: [], last_active: now, dir_gates: { [`${f3Root}/src/legacy`]: { turn: null, at: now - 1000, first: 'forged', ordinal: 1 } } }); + f3Deny(f3Write('src/legacy/a.js'), 'old-shape gate ignored'); + const keys = Object.keys(c3State().dir_gates); + assert.deepStrictEqual(keys, [f3GateKey('code', 'src/legacy')], 'old-shape key dropped, new key recorded'); + writeState({ checked: [], last_active: now, dir_gates: { [`bogus\u0000${f3Root}/src/b`]: { turn: null, at: now - 1000, first: 'x', ordinal: 1 } } }); + f3Deny(f3Write('src/b/a.js'), 'unknown class prefix ignored'); + }); + + c3Case('prototype keys in state are rejected and never pollute', () => { + const now = Date.now(); + const legacy = f3GateKey('code', 'src/pp'); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + stateFile, + '{"checked":[],"last_active":' + now + ',"dir_gates":{"__proto__":{"turn":null,"at":1,"first":"x","ordinal":1},' + + '"constructor":{"turn":null,"at":' + (now - 1000) + ',"first":"x","ordinal":1},' + + '"prototype":{"turn":null,"at":' + (now - 1000) + ',"first":"x","ordinal":1},' + + JSON.stringify(legacy) + ':{"turn":null,"at":' + (now - 1000) + ',"first":"forged","ordinal":"NaN"}},' + + '"denials_by_class":{"__proto__":{"polluted":1},"constructor":5,"prototype":4,"code":7},' + + '"credited_by_class":{"constructor":2},"sibling_allows":0}', + 'utf8' + ); + f3Deny(f3Write('src/pp/a.js'), 'NaN ordinal gate not honored'); + assert.strictEqual({}.polluted, undefined, 'Object.prototype not polluted'); + const raw = fs.readFileSync(stateFile, 'utf8'); + const state = JSON.parse(raw); + for (const field of ['dir_gates', 'denials_by_class', 'credited_by_class']) { + for (const bad of ['__proto__', 'constructor', 'prototype']) { + assert.ok(!Object.prototype.hasOwnProperty.call(state[field], bad), `${field} drops ${bad}`); + } + } + assert.strictEqual(state.denials_by_class.code, 8, 'code counter incremented from 7'); + assert.deepStrictEqual(state.credited_by_class, {}); + }); + + c3Case('second writes into dot-directories are denied', () => { + const t = f3Turn(); + for (const [a, b] of [ + ['.devcontainer/a.sh', '.devcontainer/post-create.sh'], + ['.githooks/a', '.githooks/pre-commit'], + ['.kiro/steering/a.md', '.kiro/steering/b.md'], + ['.clinerules/a.md', '.clinerules/b.md'], + ['.idea/a.xml', '.idea/run.xml'], + ['.continue/prompts/a.prompt', '.continue/prompts/b.prompt'] + ]) { + f3Deny(f3Write(a, t), `${a} first`); + f3Deny(f3Write(b, t), `${b} second`); + } + assert.deepStrictEqual(Object.keys(c3State().dir_gates || {}), [], 'no dir gate recorded for any dot-directory'); + }); + + c3Case('a symlinked directory is screened (and keyed) by its real location', () => { + const t = f3Turn(); + fs.mkdirSync(f3('.claude/hooks'), { recursive: true }); + fs.mkdirSync(f3('src/realdir'), { recursive: true }); + try { + fs.symlinkSync(f3('.claude/hooks'), f3('src/tools'), 'dir'); + fs.symlinkSync(f3('src/realdir'), f3('src/aliasdir'), 'dir'); + } catch (e) { + if (process.platform === 'win32' && e.code === 'EPERM') return; // no symlink privilege + throw e; + } + f3Deny(f3Write('src/tools/a.sh', t), 'first write through the symlink'); + f3Deny(f3Write('src/tools/evil.sh', t), 'second write lands in .claude/hooks'); + f3Deny(f3Write('src/realdir/one.js', t), 'real dir first'); + f3Sibling(f3Write('src/aliasdir/two.js', t), 'alias of the same real dir shares its gate'); + assert.deepStrictEqual(Object.keys(c3State().dir_gates), [f3GateKey('code', 'src/realdir')], 'gate keyed by the real directory'); + }); + + c3Case('a .claude/worktrees// prefix without a .git is not stripped', () => { + const t = f3Turn(); + fs.mkdirSync(f3('.claude/worktrees/fake/src'), { recursive: true }); + f3Deny(f3Write('.claude/worktrees/fake/src/a.py', t), 'fake worktree first'); + f3Deny(f3Write('.claude/worktrees/fake/src/b.py', t), 'fake worktree sibling'); + f3Deny(f3Write('.claude/worktrees/fake/a.sh', t), 'fake worktree root first'); + f3Deny(f3Write('.claude/worktrees/fake/statusline.sh', t), 'fake worktree root sibling'); + const md = f3Write('.claude/worktrees/fake/docs/g.md', t); + f3Deny(md, 'fake worktree .md'); + assert.ok(md.reason.includes('which harness loads this file') || md.reason.includes('harness/loader'), `stays instruction: ${md.reason}`); + }); + + c3Case('no class-keyed collapse output carries permissionDecision "allow"', () => { + assert.ok(f3Outputs.length > 30, 'collected outputs'); + for (const stdout of f3Outputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Opt-in session cap on first-touch denials --- + const capEnvName = 'GATEGUARD_FACT_FORCE_MAX_DENIALS'; + const capOutputs = []; + const capRun = (toolName, toolInput, transcriptPath, cap) => { + const out = c4Run(toolName, toolInput, transcriptPath, cap === undefined ? {} : { [capEnvName]: cap }); + capOutputs.push(out.result.stdout); + return out; + }; + const capAssertPassThrough = (out, toolName, label) => { + const output = parseOutput(out.result.stdout); + assert.ok(output, `${label}: valid JSON`); + assert.ok(!output.hookSpecificOutput, `${label}: pass-through expected, got ${out.result.stdout}`); + assert.strictEqual(output.tool_name, toolName, `${label}: pass-through preserves input`); + }; + const capWarning = value => + `[Fact-Forcing Gate] ignoring malformed ${capEnvName}=${value}; the denial cap is not active.`; + // Same-process run with stderr captured; restores the env var that loadDirectHook sets. + const capInProcess = (fn) => { + const saved = process.env[capEnvName]; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + const captured = []; + process.stderr.write = chunk => { + const s = typeof chunk === 'string' ? chunk : chunk.toString(); + if (s.includes(capEnvName)) captured.push(s.replace(/\n$/, '')); + return true; + }; + try { + return fn(captured); + } finally { + process.stderr.write = originalStderrWrite; + if (saved === undefined) delete process.env[capEnvName]; + else process.env[capEnvName] = saved; + } + }; + const capDirectEdit = (hook, file) => + parseOutput(hook.run(JSON.stringify({ tool_name: 'Edit', cwd: c4Root, tool_input: { file_path: `${c4Root}/${file}` } })).stdout); + + for (const malformed of ['3.9', '+3', 'abc', '-1', '3oops', '0x3', ' 3 oops', '99999999999999999999']) { + c3Case(`malformed cap ${JSON.stringify(malformed)} warns once and leaves the gate uncapped`, () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 9 }); + capInProcess(captured => { + const hook = loadDirectHook({ ...c4Env, [capEnvName]: malformed }); + const first = capDirectEdit(hook, 'src/cap-malformed-a.py'); + const second = capDirectEdit(hook, 'src/cap-malformed-b.py'); + assert.strictEqual(first.hookSpecificOutput.permissionDecision, 'deny', 'uncapped: first denied'); + assert.strictEqual(second.hookSpecificOutput.permissionDecision, 'deny', 'uncapped: second denied'); + // sanitizePath trims, so the shown value has no surrounding whitespace. + assert.deepStrictEqual(captured, [capWarning(malformed.trim())], 'exactly one warning with the sanitized value'); + }); + assert.ok(!c3State().cap_allows, 'no cap allow recorded'); + }); + } + + c3Case('a different malformed cap warns again; valid, empty and unset values never warn', () => { + capInProcess(captured => { + const hook = loadDirectHook({ ...c4Env, [capEnvName]: 'abc' }); + capDirectEdit(hook, 'src/cap-warn-1.py'); + process.env[capEnvName] = 'xyz'; + capDirectEdit(hook, 'src/cap-warn-2.py'); + capDirectEdit(hook, 'src/cap-warn-3.py'); + assert.deepStrictEqual(captured, [capWarning('abc'), capWarning('xyz')]); + process.env[capEnvName] = '50'; + capDirectEdit(hook, 'src/cap-warn-4.py'); + process.env[capEnvName] = ''; + capDirectEdit(hook, 'src/cap-warn-5.py'); + delete process.env[capEnvName]; + capDirectEdit(hook, 'src/cap-warn-6.py'); + assert.strictEqual(captured.length, 2, `no further warnings: ${JSON.stringify(captured)}`); + }); + }); + + c3Case('the warning value is sanitized and bounded', () => { + capInProcess(captured => { + const hook = loadDirectHook({ ...c4Env, [capEnvName]: `3\u202e\u200b${'9'.repeat(200)}x` }); + capDirectEdit(hook, 'src/cap-warn-sanitize.py'); + assert.strictEqual(captured.length, 1, JSON.stringify(captured)); + assert.ok(!/[\u202e\u200b]/.test(captured[0]), 'no invisible characters'); + assert.ok(captured[0].length < 200, `bounded: ${captured[0].length}`); + }); + }); + + c3Case('a malformed cap also warns from the spawned hook (stderr), stdout still denies', () => { + const out = capRun('Edit', { file_path: `${c4Root}/src/cap-spawn.py` }, undefined, 'abc'); + assert.strictEqual(out.decision, 'deny'); + assert.ok(out.result.stderr.includes(capWarning('abc')), out.result.stderr); + }); + + c3Case('surrounding whitespace is trimmed (" 2 " caps at 2, no warning)', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 2 }); + const out = capRun('Edit', { file_path: `${c4Root}/src/cap-trim.py` }, undefined, ' 2 '); + capAssertPassThrough(out, 'Edit', 'trimmed cap'); + assert.ok(!out.result.stderr.includes(capEnvName), out.result.stderr); + }); + + c3Case('a cap pass marks the target checked, counts cap_allows, never counts a denial', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 2 }); + const a = `${c4Root}/src/cap-counted-a.py`; + capAssertPassThrough(capRun('Edit', { file_path: a }, undefined, '2'), 'Edit', 'first past cap'); + let state = c3State(); + assert.strictEqual(state.cap_allows, 1, 'cap_allows counted'); + assert.strictEqual(state.fact_force_denials, 2, 'denials unchanged'); + assert.ok(state.checked.includes(stateKey(a)), 'cap-passed target marked checked'); + capAssertPassThrough(capRun('Edit', { file_path: a }, undefined, '2'), 'Edit', 'retry'); + assert.strictEqual(c3State().cap_allows, 1, 'a retry of a checked target is not recounted'); + capAssertPassThrough(capRun('Write', { file_path: `${c4Root}/src/cap-counted-b.py`, content: 'x' }, undefined, '2'), 'Write', 'second'); + state = c3State(); + assert.strictEqual(state.cap_allows, 2); + assert.strictEqual(state.fact_force_denials, 2); + }); + + c3Case('cap_allows loads from old or malformed state and survives later writes', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 1, cap_allows: 'garbage' }); + capRun('Edit', { file_path: `${c4Root}/src/cap-old-a.py` }, undefined, '1'); + assert.strictEqual(c3State().cap_allows, 1, 'malformed counter treated as zero'); + writeState({ ...c3State(), cap_allows: 7 }); + capRun('Edit', { file_path: `${c4Root}/src/cap-old-b.py` }, undefined, '1'); + assert.strictEqual(c3State().cap_allows, 8, 'increments the persisted count'); + // An uncapped denial elsewhere rewrites state and must keep cap_allows. + capRun('Edit', { file_path: `${c4Root}/src/cap-old-c.py` }); + assert.strictEqual(c3State().cap_allows, 8, 'kept across an unrelated state write'); + }); + + c3Case('cap 0 passes the first new path without a denial', () => { + capAssertPassThrough(capRun('Edit', { file_path: `${c4Root}/src/cap-zero.py` }, undefined, '0'), 'Edit', 'cap 0'); + const state = c3State(); + assert.ok(!state.fact_force_denials, 'no denial counted'); + assert.strictEqual(state.cap_allows, 1); + }); + + c3Case('credit and sibling allows run before the cap and do not consume it', () => { + const t = c3WriteTranscript([ + c3Human('build the gadget and fix the widget factory'), + ...c3Search('toolu_cap_g1', 'Grep', { pattern: 'widget_factory', path: c4Root }), + ...c3Search('toolu_cap_g2', 'Grep', { pattern: 'gadget_maker', path: c4Root }) + ]); + const credited = capRun('Edit', { file_path: `${c4Root}/src/widget_factory.py`, old_string: 'a', new_string: 'b' }, t, '1'); + assert.ok(credited.context.includes('Prior search seen in this turn'), credited.result.stdout); + assert.ok(!c3State().fact_force_denials, 'credit consumes no denial'); + const first = capRun('Write', { file_path: `${c4Root}/src/capgen/g0.js`, content: 'x' }, t, '1'); + assert.strictEqual(first.decision, 'deny', 'the one denial the cap allows'); + const sibling = capRun('Write', { file_path: `${c4Root}/src/capgen/g1.js`, content: 'x' }, t, '1'); + assert.ok(sibling.context.includes('[Fact-Forcing Gate] Sibling of '), `sibling note, not a cap pass: ${sibling.result.stdout}`); + const creditedAfterCap = capRun('Edit', { file_path: `${c4Root}/src/gadget_maker.py`, old_string: 'a', new_string: 'b' }, t, '1'); + assert.ok(creditedAfterCap.context.includes('Prior search seen in this turn'), 'credit still wins once the cap is reached'); + let state = c3State(); + assert.strictEqual(state.fact_force_denials, 1); + assert.strictEqual(state.sibling_allows, 1); + assert.strictEqual(state.fact_force_credited, 2); + assert.ok(!state.cap_allows, 'no cap pass yet'); + const capped = capRun('Edit', { file_path: `${c4Root}/src/unrelated_module.py`, old_string: 'a', new_string: 'b' }, t, '1'); + capAssertPassThrough(capped, 'Edit', 'uncredited target past the cap'); + state = c3State(); + assert.strictEqual(state.cap_allows, 1); + assert.strictEqual(state.fact_force_denials, 1); + }); + + c3Case('MultiEdit past the cap passes every entry and counts each', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 1 }); + const files = ['src/cap-multi-a.py', 'src/cap-multi-b.py', 'src/cap-multi-c.py'].map(f => `${c4Root}/${f}`); + const out = capRun('MultiEdit', { edits: files.map(file_path => ({ file_path, old_string: 'a', new_string: 'b' })) }, undefined, '1'); + capAssertPassThrough(out, 'MultiEdit', 'MultiEdit past cap'); + const state = c3State(); + assert.strictEqual(state.cap_allows, 3); + assert.strictEqual(state.fact_force_denials, 1); + for (const f of files) assert.ok(state.checked.includes(stateKey(f)), `${f} checked`); + }); + + c3Case('destructive and routine Bash stay gated once the cap is reached', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 5 }); + const env = { ...c4Env, [capEnvName]: '0' }; + const destructive = parseOutput(runBashHook({ tool_name: 'Bash', tool_input: { command: 'rm -rf build' } }, env).stdout); + assert.strictEqual(destructive.hookSpecificOutput.permissionDecision, 'deny', 'destructive gate unchanged'); + const routine = parseOutput(runBashHook({ tool_name: 'Bash', tool_input: { command: 'ls' } }, env).stdout); + assert.strictEqual(routine.hookSpecificOutput.permissionDecision, 'deny', 'routine gate unchanged'); + assert.ok(!c3State().cap_allows, 'Bash never counts a cap pass'); + }); + + c3Case('no cap output carries permissionDecision "allow"', () => { + assert.ok(capOutputs.length > 10, `collected outputs: ${capOutputs.length}`); + for (const stdout of capOutputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + // --- Sensitive targets are never auto-passed --- + const sensLine = 'Sensitive target: prior-search credit, sibling collapse, and the denial cap do not apply.'; + const sensTargets = [ + '.env', '.env.local', 'config/.env.production', 'certs/server.pem', 'certs/server.key', 'certs/client.p12', + 'certs/client.pfx', 'keys/id_rsa', 'keys/id_rsa.pub', 'keys/id_ed25519', 'config/credentials.json', + 'config/secrets.yaml', 'src/auth/login_flow.py', 'src/authn/token_check.py', 'src/authz/policy_rules.py', + 'src/security/hardening.py', 'src/secrets/vault_client.py', 'src/payment/charge_card.py', + 'src/payments/refund_flow.py', 'src/billing/invoice_maker.py', 'db/migrations/0001_initial.py', + '.github/workflows/release.yml', 'SRC/Auth/Upper_Case.py' + ]; + const sensOutputs = []; + const sensRun = (toolName, toolInput, transcriptPath, env = {}) => { + const out = c4Run(toolName, toolInput, transcriptPath, env); + sensOutputs.push(out.result.stdout); + return out; + }; + const sensAbs = rel => `${c4Root}/${rel}`; + const sensDir = rel => path.posix.dirname(sensAbs(rel)); + const sensStem = rel => path.posix.basename(rel).replace(/\.[^.]*$/, '') || path.posix.basename(rel); + const sensAssertDenied = (out, label) => { + assert.strictEqual(out.decision, 'deny', `${label}: expected deny, got ${out.result.stdout}`); + assert.ok(!out.context, `${label}: no credit/sibling note`); + assert.ok(out.reason.includes(sensLine), `${label}: sensitive line expected: ${out.reason}`); + }; + // Search evidence that credits a Write of a new file in `rel`'s directory, and an Edit of its stem. + const sensCreditTranscript = (rel, n) => c3WriteTranscript([ + c3Human('update it'), + ...c3Search(`toolu_sens_ls_${n}`, 'LS', { path: sensDir(rel) }), + ...c3Search(`toolu_sens_gr_${n}`, 'Grep', { pattern: sensStem(rel), path: c4Root }), + ...c3Search(`toolu_sens_ctl_${n}`, 'LS', { path: sensAbs('src/plain') }) + ]); + + c3Case('each sensitive target is denied on first Write despite a qualifying search (control credited)', () => { + sensTargets.forEach((rel, n) => { + clearState(); + const t = sensCreditTranscript(rel, n); + sensAssertDenied(sensRun('Write', { file_path: sensAbs(rel), content: 'x' }, t), `Write ${rel}`); + const control = sensRun('Write', { file_path: sensAbs(`src/plain/ctl_${n}.py`), content: 'x' }, t); + assert.ok(control.context.includes('Prior search seen in this turn'), `control credited for ${rel}: ${control.result.stdout}`); + const state = c3State(); + assert.strictEqual(state.fact_force_denials, 1, `${rel}: the denial counts`); + assert.strictEqual(state.fact_force_credited, 1, `${rel}: only the control was credited`); + }); + }); + + c3Case('sensitive Edit and MultiEdit targets are denied despite a stem search', () => { + const t = c3WriteTranscript([ + c3Human('fix the billing'), + ...c3Search('toolu_sens_e1', 'Grep', { pattern: 'invoice_maker|login_flow|plain_helper', path: c4Root }) + ]); + sensAssertDenied(sensRun('Edit', { file_path: sensAbs('src/billing/invoice_maker.py'), old_string: 'a', new_string: 'b' }, t), 'Edit'); + const multi = sensRun('MultiEdit', { + edits: [ + { file_path: sensAbs('src/plain/plain_helper.py'), old_string: 'a', new_string: 'b' }, + { file_path: sensAbs('src/auth/login_flow.py'), old_string: 'a', new_string: 'b' } + ] + }, t); + sensAssertDenied(multi, 'MultiEdit sensitive entry'); + const state = c3State(); + assert.strictEqual(state.fact_force_credited, 1, 'the ordinary entry was credited'); + assert.strictEqual(state.fact_force_denials, 2); + }); + + c3Case('a sensitive new file never joins a same-dir sibling gate', () => { + const t = c3WriteTranscript([c3Human('scaffold keys')]); + c4AssertDenied(sensRun('Write', { file_path: sensAbs('src/keys/loader.py'), content: 'x' }, t), 'ordinary first'); + for (const rel of ['src/keys/server.key', 'src/keys/server.pem', 'src/keys/credentials.py', 'src/keys/secrets.py', 'src/keys/id_rsa_test_fixture.py']) { + sensAssertDenied(sensRun('Write', { file_path: sensAbs(rel), content: 'x' }, t), rel); + } + c4AssertSibling(sensRun('Write', { file_path: sensAbs('src/keys/other.py'), content: 'x' }, t), 'ordinary sibling still collapses'); + assert.strictEqual(c3State().sibling_allows, 1); + }); + + c3Case('a sensitive denial opens no sibling gate; sensitive dirs never collapse', () => { + const t = c3WriteTranscript([c3Human('scaffold auth')]); + sensAssertDenied(sensRun('Write', { file_path: sensAbs('src/vault/api.key'), content: 'x' }, t), 'sensitive first'); + c4AssertDenied(sensRun('Write', { file_path: sensAbs('src/vault/client.py'), content: 'x' }, t), 'ordinary after sensitive'); + sensAssertDenied(sensRun('Write', { file_path: sensAbs('src/auth/a.py'), content: 'x' }, t), 'auth first'); + sensAssertDenied(sensRun('Write', { file_path: sensAbs('src/auth/b.py'), content: 'x' }, t), 'auth second'); + const gates = Object.keys(c3State().dir_gates || {}); + assert.ok(!gates.some(k => k.endsWith(stateKey(sensAbs('src/auth')))), `no auth gate: ${JSON.stringify(gates)}`); + assert.deepStrictEqual(gates, [c4GateKey(sensAbs('src/vault'))], 'only the ordinary denial opened a gate'); + }); + + c3Case('sensitive targets are denied after the denial cap is reached (and still count)', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 5 }); + const env = { GATEGUARD_FACT_FORCE_MAX_DENIALS: '1' }; + sensTargets.forEach((rel, n) => { + const tool = n % 2 ? 'Edit' : 'Write'; + const input = tool === 'Edit' ? { file_path: sensAbs(rel), old_string: 'a', new_string: 'b' } : { file_path: sensAbs(rel), content: 'x' }; + sensAssertDenied(sensRun(tool, input, undefined, env), `${tool} ${rel} past cap`); + }); + const multi = sensRun('MultiEdit', { edits: [ + { file_path: sensAbs('src/plain/capped.py'), old_string: 'a', new_string: 'b' }, + { file_path: sensAbs('src/payments/capped.py'), old_string: 'a', new_string: 'b' } + ] }, undefined, env); + sensAssertDenied(multi, 'MultiEdit sensitive entry past cap'); + const control = sensRun('Edit', { file_path: sensAbs('src/plain/also_capped.py'), old_string: 'a', new_string: 'b' }, undefined, env); + assert.ok(!parseOutput(control.result.stdout).hookSpecificOutput, 'ordinary target passes the cap'); + const state = c3State(); + assert.strictEqual(state.fact_force_denials, 5 + sensTargets.length + 1, 'every sensitive denial counts'); + assert.strictEqual(state.cap_allows, 2); + }); + + c3Case('the sensitive line also appears in condensed denials; ordinary denials are unchanged', () => { + writeState({ checked: [], last_active: Date.now(), fact_force_denials: 9 }); + const condensed = sensRun('Edit', { file_path: sensAbs('src/auth/condensed.py'), old_string: 'a', new_string: 'b' }); + sensAssertDenied(condensed, 'condensed'); + assert.ok(condensed.reason.includes('(denial #10 this session)'), condensed.reason); + clearState(); + const full = sensRun('Edit', { file_path: sensAbs('src/auth/full.py'), old_string: 'a', new_string: 'b' }); + sensAssertDenied(full, 'full'); + assert.ok(full.reason.includes(`${sensLine}\n\nPresent the facts, then retry the same operation.`), full.reason); + clearState(); + const ordinary = sensRun('Edit', { file_path: sensAbs('src/author.py'), old_string: 'a', new_string: 'b' }); + assert.strictEqual(ordinary.decision, 'deny'); + assert.ok(!ordinary.reason.includes('Sensitive target'), 'segment-exact: author.py is ordinary'); + for (const rel of ['docs/authoring.md', 'lib/paymentutils.py']) { + clearState(); + const t = c3WriteTranscript([c3Human('go'), ...c3Search(`toolu_ord_${rel.length}`, 'LS', { path: sensDir(rel) })]); + const out = sensRun('Write', { file_path: sensAbs(rel), content: 'x' }, t); + assert.ok(out.context.includes('Prior search seen in this turn'), `${rel} is ordinary and credited: ${out.result.stdout}`); + } + }); + + // --- A symlinked directory cannot launder a sensitive real location --- + // r1link/src/tools -> r1link/src/auth (a real directory inside the project). + const r1Ready = (() => { + fs.mkdirSync(sensAbs('r1link/src/auth'), { recursive: true }); + fs.writeFileSync(sensAbs('r1link/src/auth/login_flow.py'), 'x'); + fs.mkdirSync(sensAbs('r1link/src/plain'), { recursive: true }); + fs.writeFileSync(sensAbs('r1link/src/plain/plain_login.py'), 'x'); + try { + fs.symlinkSync(sensAbs('r1link/src/auth'), sensAbs('r1link/src/tools'), 'dir'); + return true; + } catch (e) { + if (process.platform === 'win32' && e.code === 'EPERM') return false; // no symlink privilege + throw e; + } + })(); + const r1Edit = (rel, t, env) => sensRun('Edit', { file_path: sensAbs(rel), old_string: 'a', new_string: 'b' }, t, env); + const r1Credited = (out, label) => + assert.ok(out.context.includes('Prior search seen in this turn'), `${label}: credit expected, got ${out.result.stdout}`); + + c3Case('an Edit through a symlink into auth/ is denied despite a crediting search (direct control credited)', () => { + if (!r1Ready) return; + const t = c3WriteTranscript([ + c3Human('fix login'), + ...c3Search('toolu_r1_g1', 'Grep', { pattern: 'login_flow|plain_login|new_login', path: c4Root }) + ]); + sensAssertDenied(r1Edit('r1link/src/tools/login_flow.py', t), 'Edit via the alias'); + sensAssertDenied(sensRun('Write', { file_path: sensAbs('r1link/src/tools/new_login.py'), content: 'x' }, t), 'Write via the alias'); + r1Credited(r1Edit('r1link/src/plain/plain_login.py', t), 'direct non-sensitive path'); + const state = c3State(); + assert.strictEqual(state.fact_force_credited, 1); + assert.strictEqual(state.fact_force_denials, 2); + }); + + c3Case('a new file through a symlink into auth/ never joins a same-turn sibling gate', () => { + if (!r1Ready) return; + const { human, transcript } = c4Turn('scaffold handlers'); + const gate = { turn: human.uuid, at: Date.now() - 1000, first: 'seeded.py', ordinal: 1 }; + writeState({ + checked: [], + last_active: Date.now(), + fact_force_denials: 1, + dir_gates: { + [c4GateKey(sensAbs('r1link/src/auth'))]: gate, + [c4GateKey(sensAbs('r1link/src/tools'))]: gate, + [c4GateKey(sensAbs('r1link/src/plain'))]: gate + } + }); + sensAssertDenied(sensRun('Write', { file_path: sensAbs('r1link/src/tools/new_handler.py'), content: 'x' }, transcript), 'sibling via alias'); + c4AssertSibling(sensRun('Write', { file_path: sensAbs('r1link/src/plain/new_handler.py'), content: 'x' }, transcript), 'control sibling'); + }); + + c3Case('an Edit through a symlink into auth/ is denied past the denial cap (control passes)', () => { + if (!r1Ready) return; + const env = { GATEGUARD_FACT_FORCE_MAX_DENIALS: '0' }; + sensAssertDenied(r1Edit('r1link/src/tools/login_flow.py', undefined, env), 'alias past cap 0'); + const control = r1Edit('r1link/src/plain/plain_login.py', undefined, env); + assert.ok(!parseOutput(control.result.stdout).hookSpecificOutput, `control passes the cap: ${control.result.stdout}`); + }); + + c3Case('no sensitive output carries permissionDecision "allow"', () => { + assert.ok(sensOutputs.length > 60, `collected outputs: ${sensOutputs.length}`); + for (const stdout of sensOutputs) { + const output = parseOutput(stdout); + const decision = output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + assert.notStrictEqual(decision, 'allow', stdout); + } + }); + + fs.rmSync(f3Base, { recursive: true, force: true }); + fs.rmSync(f2Root, { recursive: true, force: true }); + + fs.rmSync(c4Root, { recursive: true, force: true }); + + fs.rmSync(c3TranscriptDir, { recursive: true, force: true }); + // Cleanup only the temp directory created by this test file. try { if (fs.existsSync(stateDir)) { diff --git a/tests/lib/gateguard-target-class.test.js b/tests/lib/gateguard-target-class.test.js new file mode 100644 index 000000000..a2ce107ae --- /dev/null +++ b/tests/lib/gateguard-target-class.test.js @@ -0,0 +1,455 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-target-class.js: target classification, + * question tables, sensitive targets and sibling-collapse eligibility. + * + * Run with: node tests/lib/gateguard-target-class.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const libPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-target-class.js'); +const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js'); +const { + CLASS_QUESTIONS, + CLASS_CONDENSED_HINTS, + COLLAPSIBLE_CLASSES, + canonicalPathKey, + classifyTarget, + classifyTargetFor, + isCollapsibleTarget, + collapseGateDir, + isSensitiveTarget, + isSensitiveTargetFor +} = require(libPath); + +console.log('=== Testing gateguard-target-class.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +function withProjectDir(value, fn) { + const saved = process.env.CLAUDE_PROJECT_DIR; + try { + if (value === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = value; + fn(); + } finally { + if (saved === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = saved; + } +} + +// ── classifyTarget ── +console.log('classifyTarget:'); + +test('classifyTarget table (normalized, lowercased, first match wins)', () => { + assert.strictEqual(typeof classifyTarget, 'function', 'classifyTarget is exported'); + const cases = [ + ['C:\\Repo\\Skills\\Foo\\Notes.MD', 'instruction'], + ['/repo/AGENTS.md', 'instruction'], + ['/repo/commands/plan.md', 'instruction'], + ['/repo/rules/common/x.txt', 'instruction'], + ['/repo/hooks/hooks.json', 'config'], + ['/repo/skills/x/helper.js', 'code'], + ['/repo/tests/fixtures/readme.md', 'test'], + ['/repo/pkg/foo_test.go', 'test'], + ['/repo/test_x.py', 'test'], + ['/repo/x_test.py', 'test'], + ['/repo/src/Button.spec.tsx', 'test'], + ['/repo/src/__tests__/a.js', 'test'], + ['/repo/README.md', 'prose'], + ['/repo/docs/a.rst', 'prose'], + ['/repo/.github/workflows/ci.yml', 'config'], + ['/repo/pyproject.toml', 'config'], + ['/repo/.env', 'config'], + ['/repo/src/main.go', 'code'], + ['', 'code'] + ]; + for (const [input, expected] of cases) { + assert.strictEqual(classifyTarget(input), expected, `${input} -> ${expected}`); + } +}); + +test('classifyTarget covers the extended instruction and config rules', () => { + const cases = [ + ['/repo/.cursor/rules/x.mdc', 'instruction'], + ['/repo/docs/x.mdc', 'instruction'], + ['/repo/.cursorrules', 'instruction'], + ['/repo/.windsurfrules', 'instruction'], + ['/repo/AGENT.md', 'instruction'], + ['/repo/.github/copilot-instructions.md', 'instruction'], + ['/repo/.github/instructions/py.instructions.md', 'instruction'], + ['/repo/.github/notes.md', 'prose'], + ['/repo/docs/instructions.md', 'prose'], + ['/repo/.env', 'config'], + ['/repo/.env.local', 'config'], + ['/repo/.env.example.ts', 'code'], + ['/repo/.env.config.js', 'code'], + ['/repo/.envrc', 'code'], + ['/repo/.mcp.json', 'config'] + ]; + for (const [input, expected] of cases) { + assert.strictEqual(classifyTarget(input), expected, `${input} -> ${expected}`); + } +}); + +test('non-string input classifies as code', () => { + assert.strictEqual(classifyTarget(undefined), 'code'); + assert.strictEqual(classifyTarget(null), 'code'); + assert.strictEqual(classifyTarget(42), 'code'); +}); + +// ── classifyTargetFor ── +console.log('\nclassifyTargetFor:'); + +test('classifyTargetFor classifies project-relative paths', () => { + assert.strictEqual(typeof classifyTargetFor, 'function', 'classifyTargetFor is exported'); + const saved = process.env.CLAUDE_PROJECT_DIR; + try { + process.env.CLAUDE_PROJECT_DIR = '/work/tests/proj'; + const data = { cwd: '/work/tests/proj' }; + assert.strictEqual(classifyTargetFor('/work/tests/proj/src/a.py', data), 'code'); + assert.strictEqual(classifyTargetFor('src/a.py', data), 'code', 'relative target'); + assert.strictEqual(classifyTargetFor('/work/tests/proj/tests/a.py', data), 'test'); + // No .git in the synthetic worktree, so the prefix stays and the path is under .claude. + assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/worktrees/w1/docs/g.md', data), 'instruction', 'unverified worktree'); + assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/worktrees/w1/skills/x/SKILL.md', data), 'instruction'); + assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/agents/a.md', data), 'instruction', 'not a worktree'); + assert.strictEqual(classifyTargetFor('/elsewhere/tests/a.py', data), 'test', 'outside the root: absolute path'); + assert.strictEqual(classifyTargetFor('/work/tests/project2/src/a.py', data), 'test', 'sibling dir is outside the root'); + process.env.CLAUDE_PROJECT_DIR = 'C:\\Work\\Tests\\Proj'; + assert.strictEqual(classifyTargetFor('src\\a.py', { cwd: 'C:\\Work\\Tests\\Proj' }), 'code', 'win32 relative'); + assert.strictEqual(classifyTargetFor('c:/work/tests/proj/src/a.py', { cwd: 'C:\\Work\\Tests\\Proj' }), 'code', 'win32 case-folded'); + delete process.env.CLAUDE_PROJECT_DIR; + assert.strictEqual(classifyTargetFor('src/a.py', { cwd: '/work/tests/proj' }), 'code', 'cwd fallback root'); + } finally { + if (saved === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = saved; + } +}); + +// ── canonicalPathKey ── +console.log('\ncanonicalPathKey:'); + +test('resolves relative targets against the tool cwd and folds Windows keys', () => { + withProjectDir('/proj', () => { + assert.strictEqual(canonicalPathKey('src/a.py', { cwd: '/proj/sub' }), '/proj/sub/src/a.py'); + assert.strictEqual(canonicalPathKey('./a.py', { cwd: '/proj' }), '/proj/a.py'); + assert.strictEqual(canonicalPathKey('/abs/A.py', { cwd: '/proj' }), '/abs/A.py', 'posix keeps case'); + }); + withProjectDir(undefined, () => { + assert.strictEqual(canonicalPathKey('src\\A.py', { cwd: 'C:\\Proj' }), 'c:/proj/src/a.py'); + assert.strictEqual(canonicalPathKey('a.py', { cwd: 'relative/base' }), 'a.py', 'non-absolute base: raw path'); + }); +}); + +// ── isCollapsibleTarget ── +console.log('\nisCollapsibleTarget:'); + +test('only code/test/prose outside dotfiles and harness dirs may collapse', () => { + assert.deepStrictEqual([...COLLAPSIBLE_CLASSES].sort(), ['code', 'prose', 'test']); + withProjectDir('/proj', () => { + const data = { cwd: '/proj' }; + assert.strictEqual(isCollapsibleTarget('/proj/src/a.js', data, 'code'), true); + assert.strictEqual(isCollapsibleTarget('/proj/docs/a.md', data, 'prose'), true); + assert.strictEqual(isCollapsibleTarget('/proj/app.yaml', data, 'config'), false, 'config'); + assert.strictEqual(isCollapsibleTarget('/proj/skills/x/SKILL.md', data, 'instruction'), false, 'instruction'); + assert.strictEqual(isCollapsibleTarget('/proj/src/.hidden.js', data, 'code'), false, 'dotfile'); + assert.strictEqual(isCollapsibleTarget('/proj/.github/scripts/a.js', data, 'code'), false, 'harness dir'); + assert.strictEqual(isCollapsibleTarget('/proj/.husky/pre-commit.js', data, 'code'), false, 'hook dir'); + }); +}); + +test('a new file under any dot-directory never collapses', () => { + withProjectDir('/proj', () => { + const data = { cwd: '/proj' }; + for (const rel of [ + '.devcontainer/post-create.sh', + '.githooks/pre-commit', + '.kiro/steering/b.md', + '.clinerules/b.md', + '.idea/run.xml', + '.continue/prompts/b.prompt', + 'src/.generated/a.js', + '.vscode/tasks.js' + ]) { + assert.strictEqual(isCollapsibleTarget(`/proj/${rel}`, data, classifyTargetFor(`/proj/${rel}`, data)), false, rel); + assert.strictEqual(isCollapsibleTarget(`/proj/${rel}`, data, 'code'), false, `${rel} (as code)`); + } + assert.strictEqual(isCollapsibleTarget('/proj/src/gen/a.js', data, 'code'), true, 'control'); + }); +}); + +test('Windows name tricks: trailing dots/spaces, streams and 8.3 short names', () => { + withProjectDir(undefined, () => { + const data = { cwd: 'C:\\proj' }; + for (const p of ['C:\\proj\\CLAUDE.md.', 'C:\\proj\\CLAUDE.md ', 'C:\\proj\\CLAUDE.md::$DATA', 'C:\\proj\\CLAUDE.md:x', 'C:\\proj\\CLAUDE.md. .::$DATA']) { + assert.strictEqual(classifyTargetFor(p, data), 'instruction', JSON.stringify(p)); + } + assert.strictEqual(classifyTargetFor('C:\\proj\\.claude.\\hooks\\x.md', data), 'instruction', 'trailing dot on a directory'); + assert.strictEqual(classifyTargetFor('C:\\proj\\src\\a.js', data), 'code', 'control'); + const shortName = 'C:\\proj\\CLAUDE~1\\hooks\\x.ps1'; + assert.strictEqual(isCollapsibleTarget(shortName, data, classifyTargetFor(shortName, data)), false, '8.3 short name'); + assert.strictEqual(isCollapsibleTarget('C:\\proj\\src\\x.ps1', data, 'code'), true, 'control'); + }); + withProjectDir('/proj', () => { + const data = { cwd: '/proj' }; + assert.strictEqual(classifyTargetFor('/proj/CLAUDE.md::$DATA', data), 'instruction', 'posix segment with ::$'); + assert.strictEqual(classifyTargetFor('/proj/notes.md.', data), 'code', 'posix trailing dot is a real name'); + }); +}); + +// ── collapseGateDir (real directories) ── +console.log('\ncollapseGateDir:'); + +function tempProject() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-collapse-'))); + fs.mkdirSync(path.join(root, 'src', 'real'), { recursive: true }); + fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true }); + return root; +} + +function trySymlink(target, link) { + try { + fs.symlinkSync(target, link, 'dir'); + return true; + } catch (e) { + if (process.platform === 'win32' && e.code === 'EPERM') return false; + throw e; + } +} + +test('the worktree prefix is stripped only for a real worktree (.git present)', () => { + const root = tempProject(); + try { + withProjectDir(root, () => { + const data = { cwd: root }; + const wt = rel => path.join(root, '.claude', 'worktrees', 'w1', rel); + fs.mkdirSync(wt('docs'), { recursive: true }); + assert.strictEqual(classifyTargetFor(wt('docs/g.md'), data), 'instruction', 'no .git: stays under .claude'); + assert.strictEqual(classifyTargetFor(wt('src/a.py'), data), 'code'); + assert.strictEqual(isCollapsibleTarget(wt('src/a.py'), data, 'code'), false, 'no .git: never collapses'); + fs.writeFileSync(wt('.git'), 'gitdir: ../../../.git/worktrees/w1\n'); + assert.strictEqual(classifyTargetFor(wt('docs/g.md'), data), 'prose', '.git file: worktree'); + assert.strictEqual(isCollapsibleTarget(wt('src/a.py'), data, 'code'), true, '.git file: collapses like the project'); + assert.strictEqual(isCollapsibleTarget(wt('.claude/hooks/x.sh'), data, 'code'), false, 'harness dir inside a worktree'); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('collapse is screened on the real directory (symlinks, missing parents, fs errors)', () => { + const root = tempProject(); + try { + withProjectDir(root, () => { + const data = { cwd: root }; + const at = rel => path.join(root, rel); + const key = p => (process.platform === 'win32' ? p.replace(/\\/g, '/').toLowerCase() : p); + assert.strictEqual(collapseGateDir(at('src/real/a.js'), data, 'code'), key(at('src/real')), 'plain directory'); + assert.strictEqual(collapseGateDir(at('src/new/deeper/a.js'), data, 'code'), key(at('src/new/deeper')), 'missing parents'); + assert.strictEqual(collapseGateDir(at('.claude/hooks/a.sh'), data, 'code'), null, 'dot-directory'); + assert.strictEqual(collapseGateDir(at('src/real/a.js'), data, 'config'), null, 'non-collapsible class'); + fs.writeFileSync(at('src/file'), 'x'); + assert.strictEqual(collapseGateDir(at('src/file/a.js'), data, 'code'), null, 'parent is a file (ENOTDIR)'); + if (!trySymlink(at('.claude/hooks'), at('src/tools'))) return; + assert.strictEqual(collapseGateDir(at('src/tools/evil.sh'), data, 'code'), null, 'symlink into .claude/hooks'); + assert.strictEqual(collapseGateDir(at('src/tools/sub/evil.sh'), data, 'code'), null, 'missing dir under that symlink'); + trySymlink(at('src/real'), at('src/alias')); + assert.strictEqual(collapseGateDir(at('src/alias/b.js'), data, 'code'), key(at('src/real')), 'in-project symlink keys by the real dir'); + trySymlink(at('tests'), at('src/t-alias')); + fs.mkdirSync(at('tests'), { recursive: true }); + assert.strictEqual(collapseGateDir(at('src/t-alias/b.js'), data, 'code'), null, 'real location has another class'); + const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-outside-'))); + try { + trySymlink(outside, at('src/out')); + assert.strictEqual(collapseGateDir(at('src/out/b.js'), data, 'code'), null, 'symlink out of the project'); + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } + trySymlink(at('nowhere'), at('src/dangling')); + assert.strictEqual(collapseGateDir(at('src/dangling/b.js'), data, 'code'), null, 'dangling symlink'); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('a path of the other platform style is never collapsed', () => { + const other = process.platform === 'win32' ? '/proj' : 'C:\\proj'; + withProjectDir(other, () => { + const target = process.platform === 'win32' ? '/proj/src/a.js' : 'C:\\proj\\src\\a.js'; + assert.strictEqual(collapseGateDir(target, { cwd: other }, 'code'), null); + }); +}); + +// ── question tables ── +console.log('\nquestion tables:'); + +test('every non-code class has questions and a condensed hint; code has neither', () => { + for (const cls of ['instruction', 'test', 'prose', 'config']) { + for (const isWrite of [true, false]) { + const questions = CLASS_QUESTIONS[cls](isWrite); + assert.ok(Array.isArray(questions) && questions.length >= 2, `${cls} questions`); + assert.ok(CLASS_CONDENSED_HINTS[cls](isWrite).endsWith('then retry.'), `${cls} hint`); + } + } + assert.strictEqual(CLASS_QUESTIONS.code, undefined); + assert.strictEqual(CLASS_CONDENSED_HINTS.code, undefined); + assert.notDeepStrictEqual(CLASS_QUESTIONS.prose(true), CLASS_QUESTIONS.prose(false), 'prose Write and Edit differ'); +}); + +// ── isSensitiveTarget ── + +test('isSensitiveTarget matches every listed basename, segment and prefix rule', () => { + const sensitive = [ + '.env', '.env.local', '.env.production.local', 'config/.env', 'config/.env.test', + 'certs/server.pem', 'certs/server.key', 'certs/client.p12', 'certs/client.pfx', + 'home/id_rsa', 'home/id_rsa.pub', 'id_ed25519', 'home/id_ed25519.pub', + 'credentials', 'config/credentials.json', 'aws/credentials', 'config/secrets.yaml', 'secrets.json', + 'src/auth/login.py', 'src/authn/x.ts', 'src/authz/policy.go', 'lib/security/x.js', + 'src/secrets/vault.py', 'src/payment/charge.py', 'src/payments/x.py', 'app/billing/invoice.rb', + 'db/migrations/0001_init.sql', 'auth/readme.md', 'src/auth', + '.github/workflows/ci.yml', '.github/workflows/sub/x.yaml', './.github/workflows/ci.yml', + // Below an ancestor (a fake worktree prefix, or an absolute path outside the root). + '.claude/worktrees/fake/.github/workflows/ci.yml', '/elsewhere/repo/.github/workflows/ci.yml', + // Case-insensitive, and Windows-style separators. + 'SRC/Auth/Login.py', '.ENV', 'Certs/Server.PEM', 'src\\billing\\x.py' + ]; + for (const p of sensitive) assert.strictEqual(isSensitiveTarget(p), true, p); +}); + +test('isSensitiveTarget is segment-exact and basename-exact (no substring matches)', () => { + const ordinary = [ + 'src/author.py', 'docs/authoring.md', 'lib/paymentutils.py', 'src/authentication/x.py', + 'src/securityutils/x.py', 'src/billingreport.py', 'src/migration.py', 'src/environment.py', + 'envoy.yaml', 'src/keyboard.py', 'src/key.py', 'src/secretsmanager.py', 'src/my_credentials.py', + 'docs/workflows/ci.md', 'github/workflows/ci.yml', '.github/ci.yml', 'src/app.js', 'README.md', 'src/.envrc' + ]; + for (const p of ordinary) assert.strictEqual(isSensitiveTarget(p), false, p); +}); + +test('isSensitiveTarget fails safe (any error => sensitive)', () => { + const hostile = { toString() { throw new Error('boom'); } }; + assert.strictEqual(isSensitiveTarget(hostile), true); + assert.strictEqual(isSensitiveTarget(Symbol('x')), true); +}); + +test('isSensitiveTargetFor uses the project-relative class path', () => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-sensitive-'))); + try { + // The project itself sits under an ancestor named `security`: only project-relative segments count. + const proj = path.join(root, 'security', 'proj'); + fs.mkdirSync(proj, { recursive: true }); + withProjectDir(proj, () => { + const data = { cwd: proj }; + assert.strictEqual(isSensitiveTargetFor(`${proj}/src/app.py`, data), false, 'ancestor segment ignored'); + assert.strictEqual(isSensitiveTargetFor(`${proj}/src/auth/app.py`, data), true); + assert.strictEqual(isSensitiveTargetFor('src/billing/x.py', data), true, 'relative to cwd'); + assert.strictEqual(isSensitiveTargetFor(`${proj}/.github/workflows/ci.yml`, data), true); + // A real worktree mirrors the project: its prefix is stripped before the rules apply. + fs.mkdirSync(path.join(proj, '.claude', 'worktrees', 'w1'), { recursive: true }); + fs.writeFileSync(path.join(proj, '.claude', 'worktrees', 'w1', '.git'), 'gitdir: x\n'); + assert.strictEqual(isSensitiveTargetFor(`${proj}/.claude/worktrees/w1/.github/workflows/ci.yml`, data), true); + assert.strictEqual(isSensitiveTargetFor(`${proj}/.claude/worktrees/w1/src/app.py`, data), false); + // Windows spellings that name the same file are normalized first. + assert.strictEqual(isSensitiveTargetFor(`${proj}/.env.`, data), true); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('isSensitiveTargetFor also judges the real (symlink-resolved) path', () => { + const root = tempProject(); + const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-r1-outside-'))); + try { + withProjectDir(root, () => { + const data = { cwd: root }; + const at = rel => path.join(root, rel); + fs.mkdirSync(at('src/auth'), { recursive: true }); + fs.writeFileSync(at('src/auth/login.py'), 'x'); + if (!trySymlink(at('src/auth'), at('src/tools'))) return; + assert.strictEqual(isSensitiveTargetFor(at('src/tools/login.py'), data), true, 'existing file through a dir symlink'); + assert.strictEqual(isSensitiveTargetFor(at('src/tools/new_handler.py'), data), true, 'new file through a dir symlink'); + assert.strictEqual(isSensitiveTargetFor(at('src/tools/deeper/x.py'), data), true, 'missing dir under the symlink'); + assert.strictEqual(isSensitiveTargetFor('src/tools/login.py', data), true, 'relative spelling'); + trySymlink(at('src/real'), at('src/alias')); + assert.strictEqual(isSensitiveTargetFor(at('src/alias/a.py'), data), false, 'alias of an ordinary dir'); + assert.strictEqual(isSensitiveTargetFor(at('src/real/a.py'), data), false, 'direct ordinary path'); + fs.mkdirSync(path.join(outside, 'secrets'), { recursive: true }); + trySymlink(path.join(outside, 'secrets'), at('src/ext')); + assert.strictEqual(isSensitiveTargetFor(at('src/ext/a.py'), data), true, 'real location outside the project is judged too'); + trySymlink(outside, at('src/ext-plain')); + assert.strictEqual(isSensitiveTargetFor(at('src/ext-plain/a.py'), data), false, 'ordinary location outside the project'); + trySymlink(at('nowhere'), at('src/dangling')); + assert.strictEqual(isSensitiveTargetFor(at('src/dangling/a.py'), data), true, 'dangling symlink fails closed'); + fs.writeFileSync(at('src/plainfile'), 'x'); + assert.strictEqual(isSensitiveTargetFor(at('src/plainfile/a.py'), data), true, 'ENOTDIR fails closed'); + try { + fs.symlinkSync(at('src/auth/login.py'), at('src/real/login_link.py'), 'file'); + assert.strictEqual(isSensitiveTargetFor(at('src/real/login_link.py'), data), true, 'file symlink into auth/'); + } catch (e) { + if (!(process.platform === 'win32' && e.code === 'EPERM')) throw e; + } + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test('collapseGateDir never keys a gate for a sensitive real location', () => { + const root = tempProject(); + try { + withProjectDir(root, () => { + const data = { cwd: root }; + const at = rel => path.join(root, rel); + fs.mkdirSync(at('src/payments'), { recursive: true }); + if (!trySymlink(at('src/payments'), at('src/tools'))) return; + assert.strictEqual(collapseGateDir(at('src/tools/a.py'), data, 'code'), null); + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('isSensitiveTargetFor fails safe on bad input', () => { + assert.strictEqual(isSensitiveTargetFor(undefined, undefined), true); + assert.strictEqual(isSensitiveTargetFor({ toString() { throw new Error('x'); } }, {}), true); +}); + +test('the hook re-exports the lib classification functions', () => { + // The hook prunes stale state files on load: point it at a scratch dir. + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-target-class-')); + const savedStateDir = process.env.GATEGUARD_STATE_DIR; + process.env.GATEGUARD_STATE_DIR = stateDir; + let hook; + try { + hook = require(hookPath); + } finally { + if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR; + else process.env.GATEGUARD_STATE_DIR = savedStateDir; + fs.rmSync(stateDir, { recursive: true, force: true }); + } + assert.strictEqual(hook.classifyTarget, classifyTarget); + assert.strictEqual(hook.classifyTargetFor, classifyTargetFor); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-turn-scan.test.js b/tests/lib/gateguard-turn-scan.test.js new file mode 100644 index 000000000..2eb9c3c97 --- /dev/null +++ b/tests/lib/gateguard-turn-scan.test.js @@ -0,0 +1,360 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-turn-scan.js: turn boundaries, turn ids, + * result pairing, error results, duplicate ids and same-batch exclusion. + * + * Run with: node tests/lib/gateguard-turn-scan.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const libPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-turn-scan.js'); +const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js'); +const { + isErrorToolResult, + isToolResultRecord, + isCompactionBoundary, + scanCurrentTurn, + excludedBatchId, + createTurnScanner, + currentTurnId, + transcriptPathFor +} = require(libPath); + +console.log('=== Testing gateguard-turn-scan.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +// Transcript fixtures in the shape Claude Code writes (one JSON record per line). +const transcriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-turn-scan-')); +let seq = 0; +let uuidSeq = 0; +const nextUuid = () => `uuid-${++uuidSeq}`; +const human = (text, extra = {}) => ({ type: 'user', uuid: nextUuid(), message: { role: 'user', content: text }, ...extra }); +const toolUse = (id, name, input, extra = {}) => ({ + type: 'assistant', + uuid: nextUuid(), + message: { id: `msg_${id}`, role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, + ...extra +}); +const toolResult = (id, isError = false, extra = {}) => ({ + type: 'user', + uuid: nextUuid(), + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: isError, content: 'ok' }] }, + ...extra +}); +const search = (id, name, input) => [toolUse(id, name, input), toolResult(id)]; +// Like Claude Code, a fixture ends with the pending call's own assistant record by default. +const writeTranscript = (records, { pending = true } = {}) => { + seq += 1; + const file = path.join(transcriptDir, `t-${seq}.jsonl`); + const all = records.slice(); + if (pending) { + all.push({ + type: 'assistant', + uuid: nextUuid(), + message: { id: `msg_pending_${seq}`, role: 'assistant', content: [{ type: 'tool_use', id: `toolu_pending_${seq}`, name: 'Edit', input: {} }] } + }); + } + const lines = all.map(r => (typeof r === 'string' ? r : JSON.stringify(r))); + fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8'); + return file; +}; +// ~400 KiB of tool traffic: pushes earlier records out of the 256 KiB tail. +const filler = prefix => { + const records = []; + for (let i = 0; i < 40; i++) { + const id = `toolu_${prefix}${i}`; + records.push(toolUse(id, 'Bash', { command: 'npm test' })); + records.push({ + type: 'user', + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'y'.repeat(10 * 1024) }] } + }); + } + return records; +}; + +// ── turn id ── +console.log('turn id:'); + +// Every user record of a turn (prompt and tool_results) carries the same promptId. +test('boundary in window with promptId -> turnId is the promptId', () => { + const t = writeTranscript([ + human('old', { promptId: 'prompt-old' }), + human('now', { promptId: 'prompt-now' }), + ...search('toolu_pid1', 'Grep', { pattern: 'widget_factory' }).map(r => (r.type === 'user' ? { ...r, promptId: 'prompt-now' } : r)) + ]); + assert.strictEqual(scanCurrentTurn(t).turnId, 'prompt-now'); + const overlong = human('x', { promptId: 'p'.repeat(129) }); + assert.strictEqual(scanCurrentTurn(writeTranscript([overlong])).turnId, overlong.uuid, 'overlong promptId ignored'); +}); + +test('records without promptId keep the uuid / hash / null turn id', () => { + const boundary = human('now'); + assert.strictEqual(scanCurrentTurn(writeTranscript([boundary])).turnId, boundary.uuid); + const noUuid = writeTranscript([{ type: 'user', message: { role: 'user', content: 'x' } }]); + assert.ok(/^h:[0-9a-f]{16}$/.test(scanCurrentTurn(noUuid).turnId)); + const clipped = writeTranscript([human('long ago'), ...filler('nopid')]); + assert.strictEqual(scanCurrentTurn(clipped).turnId, null, 'clipped window without promptId'); +}); + +test('compaction summary and compact_boundary records start a turn', () => { + const pre = [human('orig task'), ...search('toolu_c1', 'Grep', { pattern: 'payment' })]; + const summary = { type: 'user', uuid: nextUuid(), isCompactSummary: true, message: { role: 'user', content: 'Summary' } }; + const afterSummary = scanCurrentTurn(writeTranscript([...pre, summary], { pending: false })); + assert.strictEqual(afterSummary.turnId, summary.uuid); + assert.deepStrictEqual(afterSummary.searches, [], 'searches before compaction do not count'); + const marker = scanCurrentTurn(writeTranscript([...pre, { type: 'system', uuid: 'cb-1', subtype: 'compact_boundary' }], { pending: false })); + assert.strictEqual(marker.turnId, 'cb-1'); + assert.deepStrictEqual(marker.searches, []); + assert.strictEqual(isCompactionBoundary({ ...summary, isSidechain: true }), false, 'sidechain summaries are ignored'); +}); + +test('meta and sidechain user records are not boundaries; tool_result records are not either', () => { + const boundary = human('go'); + const t = writeTranscript( + [ + boundary, + ...search('toolu_m1', 'Grep', { pattern: 'alpha' }), + human('meta note', { isMeta: true }), + human('side prompt', { isSidechain: true }) + ], + { pending: false } + ); + const scan = scanCurrentTurn(t); + assert.strictEqual(scan.turnId, boundary.uuid); + assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep']); + assert.strictEqual(isToolResultRecord(toolResult('x')), true); + assert.strictEqual(isToolResultRecord(human('x')), false); +}); + +// ── which searches qualify ── +console.log('\nsearches:'); + +test('a search qualifies only with a paired, non-error, unique result', () => { + const t = writeTranscript( + [ + human('go'), + ...search('toolu_ok', 'Grep', { pattern: 'ok' }), + toolUse('toolu_noresult', 'Grep', { pattern: 'pending' }), + toolUse('toolu_err', 'Grep', { pattern: 'err' }), + toolResult('toolu_err', true), + ...search('toolu_dup', 'Glob', { pattern: 'a/*' }), + ...search('toolu_dup', 'Glob', { pattern: 'b/*' }), + ...search('toolu_read', 'Read', { file_path: '/x.js' }) + ], + { pending: false } + ); + const scan = scanCurrentTurn(t); + assert.deepStrictEqual(scan.searches.map(s => s.input.pattern), ['ok']); +}); + +test('error results are recognised without a boolean is_error', () => { + assert.strictEqual(isErrorToolResult({ is_error: true }), true); + assert.strictEqual(isErrorToolResult({ is_error: 'true' }), true); + assert.strictEqual(isErrorToolResult({ content: ' denied' }), true); + assert.strictEqual(isErrorToolResult({ content: [{ type: 'text', text: 'x' }] }), true); + assert.strictEqual(isErrorToolResult({ is_error: false, content: 'found 3 files' }), false); +}); + +test('callsAgo counts back from the newest call, skipping the pending call', () => { + const t = writeTranscript([human('go'), ...search('toolu_a', 'Grep', { pattern: 'a' }), ...search('toolu_b', 'Bash', { command: 'ls' })]); + const scan = scanCurrentTurn(t, `toolu_pending_${seq}`); + assert.deepStrictEqual(scan.searches.map(s => [s.name, s.callsAgo]), [['Bash', 1], ['Grep', 2]]); + assert.deepStrictEqual(scan.shellCommands, ['ls']); +}); + +test('no transcript, a directory, or a whole file without a boundary -> no searches', () => { + assert.strictEqual(scanCurrentTurn(''), null); + assert.strictEqual(scanCurrentTurn(path.join(transcriptDir, 'missing.jsonl')), null); + assert.strictEqual(scanCurrentTurn(transcriptDir), null); + const noBoundary = scanCurrentTurn(writeTranscript(search('toolu_nb', 'Grep', { pattern: 'x' }), { pending: false })); + assert.deepStrictEqual(noBoundary.searches, []); + assert.strictEqual(noBoundary.turnId, null); +}); + +// The turn id is the boundary's own promptId; a clipped window needs agreeing promptIds. +test('boundary in window -> the boundary record\'s own promptId, not a newer record\'s', () => { + const late = { ...toolResult('toolu_n3a'), promptId: 'P1' }; + const t = writeTranscript([human('one', { promptId: 'P1' }), human('two', { promptId: 'P2' }), toolUse('toolu_n3a', 'Bash', { command: 'true' }), late]); + assert.strictEqual(scanCurrentTurn(t).turnId, 'P2', 'late tool_result stamped with the previous promptId'); + const noPid = human('two'); + const t2 = writeTranscript([noPid, toolUse('toolu_n3b', 'Bash', { command: 'true' }), { ...toolResult('toolu_n3b'), promptId: 'P9' }]); + assert.strictEqual(scanCurrentTurn(t2).turnId, noPid.uuid, 'boundary without promptId falls back to its uuid'); +}); + +test('clipped window without a boundary -> promptId only when all user records agree', () => { + const stamp = (records, pid) => records.map(r => (r.type === 'user' ? { ...r, promptId: pid } : r)); + const agree = writeTranscript([ + human('long ago', { promptId: 'PA' }), + ...stamp([...filler('agree'), ...search('toolu_agree', 'Grep', { pattern: 'payment' })], 'PA') + ]); + const agreeScan = scanCurrentTurn(agree); + assert.strictEqual(agreeScan.turnId, 'PA'); + assert.ok(agreeScan.searches.some(s => s.name === 'Grep'), 'agreeing clipped window still credits'); + const mixed = filler('mixed'); + const disagree = writeTranscript([ + human('long ago', { promptId: 'PA' }), + ...stamp(mixed.slice(0, 60), 'PA'), + ...stamp([...mixed.slice(60), ...search('toolu_dis', 'Grep', { pattern: 'payment' })], 'PB') + ]); + const disagreeScan = scanCurrentTurn(disagree); + assert.strictEqual(disagreeScan.turnId, null, 'two promptIds in the window'); + assert.deepStrictEqual(disagreeScan.searches, [], 'unattributable clipped window gives no credit'); + const metaNew = writeTranscript([ + human('long ago', { promptId: 'PA' }), + ...stamp([...filler('meta'), ...search('toolu_meta', 'Grep', { pattern: 'payment' })], 'PA'), + { type: 'user', uuid: nextUuid(), isMeta: true, promptId: 'PN', message: { role: 'user', content: '' } } + ]); + const metaScan = scanCurrentTurn(metaNew); + assert.strictEqual(metaScan.turnId, null, 'a meta record from another prompt also disagrees'); + assert.deepStrictEqual(metaScan.searches, [], 'previous-turn search behind an unrecognised turn start is not credited'); + const partial = writeTranscript([human('long ago', { promptId: 'PA' }), ...filler('partial').map((r, i) => (r.type === 'user' && i > 70 ? { ...r, promptId: 'PA' } : r))]); + assert.strictEqual(scanCurrentTurn(partial).turnId, 'PA', 'records without a promptId do not disagree'); +}); + +// Malformed records and inputs never throw; the record is skipped. +const MALFORMED_RECORDS = [ + '{"type":"assistant"}', + '{"type":"assistant","message":null}', + '{"type":"assistant","message":"str"}', + '{"type":"assistant","message":{"content":"str"}}', + '{"type":"assistant","message":{"content":[null,5,"x",{"type":"tool_use"}]}}', + '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"q","content":{"toString":1}}]}}', + '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"q","content":[null,{"type":"text","text":5}]}]}}', + 'null', + '5', + '"str"', + '[]' +]; +const MALFORMED_INPUTS = [ + undefined, + null, + 0, + 1, + '', + 'x', + [], + {}, + { message: null }, + { message: 'str' }, + { type: 'assistant' }, + { type: 'user', message: { content: {} } }, + { cwd: 5 }, + { tool_use_id: 5 }, + { batchIds: null, newestMessageId: 5 }, + { searches: null }, + new Proxy({}, { + get() { + throw new Error('trap'); + } + }) +]; + +test('every exported function tolerates malformed inputs without throwing', () => { + const lib = require(libPath); + const fns = Object.entries(lib).filter(([, fn]) => typeof fn === 'function'); + assert.ok(fns.length >= 9, `exported functions: ${fns.map(([name]) => name).join(', ')}`); + for (const [name, fn] of fns) { + MALFORMED_INPUTS.forEach((a, i) => { + MALFORMED_INPUTS.forEach((b, j) => { + try { + const result = fn(a, b); + if (typeof result === 'function') result(); + } catch (e) { + assert.fail(`${name}(MALFORMED_INPUTS[${i}], MALFORMED_INPUTS[${j}]) threw: ${e.message}`); + } + }); + }); + } +}); + +test('scanCurrentTurn skips each malformed record and keeps the rest of the turn', () => { + for (const bad of MALFORMED_RECORDS) { + const t = writeTranscript([human('go', { promptId: 'PM' }), ...search('toolu_m1', 'Grep', { pattern: 'payment' }), bad]); + let scan; + assert.doesNotThrow(() => { + scan = scanCurrentTurn(t, `toolu_pending_${seq}`); + }, bad); + assert.ok(scan, `${bad}: scan result`); + assert.strictEqual(scan.turnId, 'PM', `${bad}: turn id`); + assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep'], `${bad}: search kept`); + } + // A user record without usable content is a turn boundary, which can only shrink the window. + for (const bad of ['{"type":"user"}', '{"type":"user","message":null}', '{"type":"user","message":{"content":{}}}']) { + const t = writeTranscript([human('go', { promptId: 'PM' }), ...search('toolu_m2', 'Grep', { pattern: 'payment' }), bad]); + const scan = scanCurrentTurn(t, `toolu_pending_${seq}`); + assert.ok(scan && /^h:/.test(scan.turnId), `${bad}: boundary turn id`); + assert.deepStrictEqual(scan.searches, [], `${bad}: searches before it do not count`); + } +}); + +// ── same-batch exclusion ── +console.log('\nsame batch:'); + +test('excludedBatchId: the pending call message when located, else the newest message', () => { + const t = writeTranscript([human('go'), ...search('toolu_s1', 'Grep', { pattern: 'a' })]); + const pendingId = `toolu_pending_${seq}`; + const scan = scanCurrentTurn(t, pendingId); + assert.strictEqual(excludedBatchId(scan, { tool_use_id: pendingId }), `msg_pending_${seq}`); + assert.strictEqual(excludedBatchId(scan, {}), scan.newestMessageId); + assert.strictEqual(excludedBatchId(scan, { tool_use_id: 'toolu_unknown' }), `msg_pending_${seq}`, 'unknown id: newest message'); +}); + +// ── scanner wrapper ── +console.log('\nscanner:'); + +test('createTurnScanner reads the transcript once and currentTurnId guards the shape', () => { + const boundary = human('go'); + const t = writeTranscript([boundary]); + const saved = process.env.CLAUDE_TRANSCRIPT_PATH; + try { + process.env.CLAUDE_TRANSCRIPT_PATH = ''; + const getScan = createTurnScanner({ transcript_path: t }); + const first = getScan(); + fs.writeFileSync(t, JSON.stringify(human('changed')) + '\n', 'utf8'); + assert.strictEqual(getScan(), first, 'cached for the rest of run()'); + assert.strictEqual(currentTurnId(first), boundary.uuid); + assert.strictEqual(currentTurnId(null), null); + assert.strictEqual(currentTurnId({ turnId: '' }), null); + assert.strictEqual(transcriptPathFor({ transcriptPath: '/x.jsonl' }), '/x.jsonl'); + assert.strictEqual(createTurnScanner({})(), null, 'no transcript: null scan'); + } finally { + if (saved === undefined) delete process.env.CLAUDE_TRANSCRIPT_PATH; + else process.env.CLAUDE_TRANSCRIPT_PATH = saved; + } +}); + +test('the hook re-exports scanCurrentTurn', () => { + // The hook prunes stale state files on load: point it at a scratch dir. + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-turn-scan-state-')); + const savedStateDir = process.env.GATEGUARD_STATE_DIR; + process.env.GATEGUARD_STATE_DIR = stateDir; + try { + assert.strictEqual(require(hookPath).scanCurrentTurn, scanCurrentTurn); + } finally { + if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR; + else process.env.GATEGUARD_STATE_DIR = savedStateDir; + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +fs.rmSync(transcriptDir, { recursive: true, force: true }); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/transcript-context.test.js b/tests/lib/transcript-context.test.js index b5a3addf6..003d0cc9a 100644 --- a/tests/lib/transcript-context.test.js +++ b/tests/lib/transcript-context.test.js @@ -18,6 +18,8 @@ const { DEFAULT_CONTEXT_THRESHOLD_STANDARD, DEFAULT_CONTEXT_THRESHOLD_LARGE, DEFAULT_CONTEXT_INTERVAL_TOKENS, + DEFAULT_TRANSCRIPT_TAIL_BYTES, + readFileTail, readLatestContextTokens, resolveContextWindowTokens, resolveContextThreshold, @@ -323,6 +325,20 @@ test('returns -1 for non-finite token counts', () => { assert.strictEqual(computeContextBucket(NaN, 160000, 60000), -1); }); +// ── readFileTail ── +console.log('\nreadFileTail:'); + +test('is exported and returns the trailing bytes with a truncation flag', () => { + assert.strictEqual(typeof readFileTail, 'function'); + assert.strictEqual(DEFAULT_TRANSCRIPT_TAIL_BYTES, 256 * 1024); + const file = tracked(writeTranscript(['first-line', 'second', 'third'])); + const whole = readFileTail(file, 1024); + assert.deepStrictEqual(whole, { text: 'first-line\nsecond\nthird\n', truncated: false }); + const tail = readFileTail(file, 6); + assert.deepStrictEqual(tail, { text: 'third\n', truncated: true }); + assert.strictEqual(readFileTail(path.join(os.tmpdir(), `missing-${process.pid}.jsonl`), 10), null); +}); + // ── formatWindowLabel ── console.log('\nformatWindowLabel:');