feat(gateguard): integrate evidence-aware first-touch questions

This commit is contained in:
haelyra
2026-10-10 17:46:23 -04:00
73 changed files with 29239 additions and 165 deletions
+632 -97
View File
@@ -9,7 +9,7 @@
* 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/NotebookEdit (first touch): questions chosen by target class
* - Bash/PowerShell (destructive): list targets, rollback plan, quote instruction
* - Bash/PowerShell (routine): quote current instruction (once per session)
*
@@ -29,12 +29,74 @@ const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups }
const { classifyPowerShellDestructiveCommand } = require('../lib/powershell-destructive-command');
const { stripHeredocBodies } = require('./gateguard-heredoc');
// --- Lazily loaded modules ---
// see docs/gateguard/design-notes.md#lazy-loading
function lazily(load) {
let loaded = null;
return () => loaded || (loaded = load());
}
const targetClass = lazily(() => require('../lib/gateguard-target-class'));
const turnScan = lazily(() => require('../lib/gateguard-turn-scan'));
const changeProfile = lazily(() => require('../lib/gateguard-change-profile'));
const metricsLib = lazily(() => require('../lib/gateguard-metrics'));
const readOnlyShell = lazily(() => require('../lib/gateguard-readonly-shell').createReadOnlyShell({ quoteAwareSegments }));
const searchEvidence = lazily(() =>
require('../lib/gateguard-search-evidence').createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS })
);
const questionIdsFor = (...args) => targetClass().questionIdsFor(...args);
const questionText = (...args) => targetClass().questionText(...args);
const condensedHintFor = (...args) => targetClass().condensedHintFor(...args);
const resolveTargetPath = (...args) => targetClass().resolveTargetPath(...args);
const canonicalPathKey = (...args) => targetClass().canonicalPathKey(...args);
const classifyTargetFor = (...args) => targetClass().classifyTargetFor(...args);
const collapseGateDir = (...args) => targetClass().collapseGateDir(...args);
const isSensitiveTargetFor = (...args) => targetClass().isSensitiveTargetFor(...args);
const isHardLinkedTargetFor = (...args) => targetClass().isHardLinkedTargetFor(...args);
const createTurnScanner = (...args) => turnScan().createTurnScanner(...args);
const currentTurnId = (...args) => turnScan().currentTurnId(...args);
const transcriptPathFor = (...args) => turnScan().transcriptPathFor(...args);
const profileChange = (...args) => changeProfile().profileChange(...args);
const appendMetrics = (...args) => metricsLib().appendMetrics(...args);
const metricsEvent = (...args) => metricsLib().metricsEvent(...args);
const isReadOnlyShellCommand = (...args) => readOnlyShell().isReadOnlyShellCommand(...args);
const hasEntries = list => Array.isArray(list) && list.length > 0;
const findCreditingSearch = (scan, filePath, allowDirMatch, data) =>
scan && hasEntries(scan.searches) && (allowDirMatch || turnScan().searchesMayNameTarget(scan, filePath))
? searchEvidence().findCreditingSearch(scan, filePath, allowDirMatch, data)
: null;
const findClosestMiss = (scan, filePath, allowDirMatch, data) =>
scan && (hasEntries(scan.reads) || (hasEntries(scan.searches) && (allowDirMatch || turnScan().searchesMayNameTarget(scan, filePath))))
? searchEvidence().findClosestMiss(scan, filePath, allowDirMatch, data)
: null;
const {
getDenialCount,
getCreditedCount,
getCapAllowCount,
getTrivialAllowCount,
getRoutineReadonlyPassCount,
getSiblingAllowCount,
getClassCounts,
mergeClassCounts,
incrementClassCount,
withEntry,
dirGateKey,
getDirGates,
mergeDirGates,
capDirGates
} = require('../lib/gateguard-state');
// Session state — scoped per session to avoid cross-session races.
const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
let activeStateFile = null;
let activeSessionKey = '';
// State expires after 30 minutes of inactivity
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
const SESSION_ID_TIMEOUT_MS = 8 * 60 * 60 * 1000;
const PROJECT_KEY_PREFIX = 'proj-';
let activeIdleWindowMs = SESSION_TIMEOUT_MS;
const READ_HEARTBEAT_MS = 60 * 1000;
// Maximum checked entries to prevent unbounded growth
@@ -110,7 +172,7 @@ function getExtraDestructiveRegex() {
// Operator-supplied path exemptions. Comma-separated globs (`GATEGUARD_EXEMPT_GLOBS`)
// matched against the normalized project-relative path (or full path for an
// explicitly absolute glob). First-touch
// fact-forcing is skipped for a matching Edit/Write/MultiEdit target — intended for
// fact-forcing is skipped for a matching Edit/Write/MultiEdit/NotebookEdit target — intended for
// low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports
// this / what schema" carries no signal. Memoized on the env value; malformed
// patterns are dropped without granting exemptions. `*` matches within a path segment,
@@ -154,7 +216,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 = targetClass().WINDOWS_PATH_PATTERN.test(projectRoot) ? path.win32 : path.posix;
if (!paths.isAbsolute(projectRoot)) return false;
const target = paths.resolve(projectRoot, filePath);
const relative = paths.relative(projectRoot, target);
@@ -1401,10 +1463,17 @@ function resolveSessionKey(data) {
return hashSessionKey('proj', path.resolve(projectFingerprint));
}
// see docs/gateguard/design-notes.md#idle-window
function idleWindowForKey(sessionKey) {
return sessionKey.startsWith(PROJECT_KEY_PREFIX) ? SESSION_TIMEOUT_MS : SESSION_ID_TIMEOUT_MS;
}
function getStateFile(data) {
if (!activeStateFile) {
const sessionKey = resolveSessionKey(data);
activeSessionKey = sessionKey;
activeStateFile = path.join(STATE_DIR, `state-${sessionKey}.json`);
activeIdleWindowMs = idleWindowForKey(sessionKey);
}
return activeStateFile;
}
@@ -1415,7 +1484,7 @@ function loadState() {
if (fs.existsSync(stateFile)) {
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
const lastActive = state.last_active || 0;
if (Date.now() - lastActive > SESSION_TIMEOUT_MS) {
if (Date.now() - lastActive > activeIdleWindowMs) {
try {
fs.unlinkSync(stateFile);
} catch (_) {
@@ -1455,6 +1524,14 @@ 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);
let mergedTrivialAllows = getTrivialAllowCount(state);
let mergedReadonlyPasses = getRoutineReadonlyPassCount(state);
try {
if (fs.existsSync(stateFile)) {
@@ -1466,6 +1543,14 @@ 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));
mergedTrivialAllows = Math.max(mergedTrivialAllows, getTrivialAllowCount(diskState));
mergedReadonlyPasses = Math.max(mergedReadonlyPasses, getRoutineReadonlyPassCount(diskState));
}
} catch (_) {
/* ignore malformed or transient disk state */
@@ -1474,7 +1559,15 @@ 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,
trivial_allows: mergedTrivialAllows,
routine_readonly_passes: mergedReadonlyPasses
};
// Atomic write: temp file + rename prevents partial reads
@@ -1539,60 +1632,135 @@ function getFullDenialBudget() {
return DEFAULT_FULL_DENIALS;
}
// --- Session cap on first-touch denials ---
const MAX_DENIALS_PATTERN = /^\d+$/;
const MAX_DENIALS_WARN_VALUE_CHARS = 64;
let maxDenialsWarnedFor = null;
function warnMalformedMaxDenials(raw) {
if (raw === maxDenialsWarnedFor) return;
maxDenialsWarnedFor = raw;
const chars = Array.from(sanitizePath(raw));
const shown = chars.length > MAX_DENIALS_WARN_VALUE_CHARS
? `${chars.slice(0, MAX_DENIALS_WARN_VALUE_CHARS - 3).join('')}...`
: chars.join('');
try {
process.stderr.write(
`[Fact-Forcing Gate] ignoring malformed GATEGUARD_FACT_FORCE_MAX_DENIALS=${shown}; the denial cap is not active.\n`
);
} catch (_) {
/* stderr write failure is non-fatal */
}
}
/**
* Session-wide ceiling on Edit/Write/MultiEdit fact-force denials, from
* GATEGUARD_FACT_FORCE_MAX_DENIALS. Opt-in: unset keeps the existing behavior
* of denying every new path, and the destructive-Bash gate is unaffected
* either way.
*
* The value is validated whole rather than with Number.parseInt, because a
* prefix parse turns '3.5', '3oops', and '0x3' into finite caps and would
* quietly weaken the gate on a typo. Anything that is not a complete
* non-negative decimal integer leaves the gate uncapped.
*
* @returns {number} the denial ceiling, or Number.POSITIVE_INFINITY when uncapped
*/
function getMaxDenialBudget() {
const raw = (process.env.GATEGUARD_FACT_FORCE_MAX_DENIALS || '').trim();
if (!MAX_DENIALS_PATTERN.test(raw)) {
return Number.POSITIVE_INFINITY;
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;
}
const parsed = Number(raw);
return Number.isSafeInteger(parsed) ? parsed : Number.POSITIVE_INFINITY;
warnMalformedMaxDenials(raw);
return Number.POSITIVE_INFINITY;
}
function getDenialCount(state) {
const n = Number(state && state.fact_force_denials);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
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.
*/
function markCheckedAndCountDenial(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;
}
// --- Trivial edits ---
// see docs/gateguard/change-profile.md#trivial-edits
const TRIVIAL_CLASSES = new Set(['code', 'test', 'prose']);
function countTrivialAllow() {
const state = loadState();
if (!state.checked.includes(key)) {
state.checked.push(key);
}
const denials = getDenialCount(state) + 1;
state.fact_force_denials = denials;
return { ok: saveState(state), denials };
return saveState({ ...state, trivial_allows: getTrivialAllowCount(state) + 1 });
}
// see docs/gateguard/change-profile.md#when-a-change-is-profiled
function needsProfile(toolName, cls) {
return targetClass().questionsUseProfile(cls) || ((toolName === 'Edit' || toolName === 'MultiEdit') && TRIVIAL_CLASSES.has(cls)) || isMetricsEnabled();
}
function isTrivialChange(cls, restricted, profile) {
return !restricted && TRIVIAL_CLASSES.has(cls) && Boolean(profile) && profile.known === true && profile.trivial === true;
}
// --- Read-only first shell command ---
function countRoutineReadonlyPass() {
const state = loadState();
return saveState({ ...state, routine_readonly_passes: getRoutineReadonlyPassCount(state) + 1 });
}
// --- Sibling gates ---
const SIBLING_NO_TURN_WINDOW_MS = 120 * 1000;
function markCheckedAndCountSibling(key) {
return markCheckedWith(key, state => ({
state: { ...state, sibling_allows: getSiblingAllowCount(state) + 1 },
value: undefined
})).ok;
}
// see docs/gateguard/design-notes.md#sibling-collapse
function findSiblingGate(key, turnId, now = Date.now()) {
const gates = getDirGates(loadState());
if (!Object.hasOwn(gates, key)) return null;
const entry = gates[key];
const age = now - entry.at;
if (!(age >= 0) || entry.ordinal < 1) return null;
if (turnId) return entry.turn === turnId ? entry : null;
return entry.turn === null && age <= SIBLING_NO_TURN_WINDOW_MS ? entry : null;
}
function isChecked(key) {
const state = loadState();
const 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);
}
return found;
}
// Prune stale session files older than 1 hour
(function pruneStaleFiles() {
try {
const files = fs.readdirSync(STATE_DIR);
@@ -1601,9 +1769,10 @@ function isChecked(key) {
const isStateFile = f.startsWith('state-') && (f.endsWith('.json') || f.includes('.json.tmp.'));
if (!isStateFile) continue;
const fp = path.join(STATE_DIR, f);
const window = f.endsWith('.json') ? idleWindowForKey(f.slice('state-'.length)) : SESSION_TIMEOUT_MS;
try {
const stat = fs.statSync(fp);
if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) {
if (now - stat.mtimeMs > window * 2) {
fs.unlinkSync(fp);
}
} catch (_) {
@@ -1776,35 +1945,15 @@ function batchSiblingWarning(safePath) {
);
}
function editGateMsg(filePath) {
function firstTouchGateMsg(filePath, isWrite, cls, profile) {
const safe = sanitizePath(filePath);
const questions = questionIdsFor(cls, isWrite, profile).map(questionText);
return [
'[Fact-Forcing Gate]',
'',
`Before editing ${safe}, present these facts:`,
`Before ${isWrite ? 'creating' : 'editing'} ${safe}, 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",
'',
batchSiblingWarning(safe),
'',
'Present the facts, then retry the same operation.'
].join('\n');
}
function writeGateMsg(filePath) {
const safe = sanitizePath(filePath);
return [
'[Fact-Forcing Gate]',
'',
`Before creating ${safe}, 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",
...questions.map((question, index) => `${index + 1}. ${question}`),
'',
batchSiblingWarning(safe),
'',
@@ -1817,16 +1966,61 @@ function writeGateMsg(filePath) {
* (#2142). Carries the denial ordinal so consecutive denials differ
* textually, and a one-line recovery hint instead of the multi-line block.
*/
function condensedGateMsg(action, filePath, ordinal) {
function condensedGateMsg(action, filePath, ordinal, cls = 'code', targetNote = '', profile = null, missNote = '') {
const safe = sanitizePath(filePath);
const hint = condensedHintFor(cls, action === 'creation', profile);
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)} ` +
(missNote ? `${missNote} ` : '') +
(targetNote ? `${targetNote} ` : '') +
'(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; GATEGUARD_FACT_FORCE_MAX_DENIALS caps denials per session; ECC_GATEGUARD=off disables this gate.)'
);
}
const SENSITIVE_TARGET_NOTE = 'Sensitive target: prior-search credit, sibling collapse, and the denial cap do not apply.';
const HARD_LINKED_TARGET_NOTE = 'Hard-linked target: prior-search credit, sibling collapse, and the denial cap do not apply.';
const RETRY_LINE = 'Present the facts, then retry the same operation.';
function withNoteBeforeRetry(message, note) {
if (!note) return message;
const at = message.lastIndexOf(RETRY_LINE);
if (at < 0) return `${message}\n\n${note}`;
return `${message.slice(0, at)}${note}\n\n${message.slice(at)}`;
}
function restrictedTargetNote(sensitive, linked) {
if (sensitive) return SENSITIVE_TARGET_NOTE;
return linked ? HARD_LINKED_TARGET_NOTE : '';
}
function denialReason(sensitive, linked, missNote, miss) {
if (sensitive) return 'sensitive';
if (linked) return 'hard-linked';
return missNote ? `near-miss:${miss.reason}` : 'first-touch';
}
function firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, linked = false, profile, miss, reason }) {
const targetNote = restrictedTargetNote(sensitive, linked);
const missNote = targetNote ? '' : closestMissNote(miss);
recordDecision('deny', reason || denialReason(sensitive, linked, missNote, miss), {
target: filePath,
cls,
sensitive,
profile,
questions: questionIdsFor(cls, isWrite, profile)
});
if (denials > getFullDenialBudget()) {
const action = isWrite ? 'creation' : 'edit';
return denyResult(condensedGateMsg(action, filePath, denials, cls, targetNote, profile, missNote), { includeRecoveryHint: false });
}
const message = withNoteBeforeRetry(firstTouchGateMsg(filePath, isWrite, cls, profile), missNote);
return denyResult(targetNote ? withNoteBeforeRetry(message, targetNote) : message, {
narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT
});
}
function destructiveBashMsg() {
return [
'[Fact-Forcing Gate]',
@@ -1897,15 +2091,241 @@ function denyResult(reason, options = {}) {
}
function allowWithStateWarning() {
recordDecision('pass', 'state-error');
return {
stderr: '[Fact-Forcing Gate] GateGuard state could not be persisted; allowing this operation to avoid a permanent retry loop. Check GATEGUARD_STATE_DIR or filesystem permissions.',
exitCode: 0
};
}
// --- Prior-search credit ---
const CREDIT_DETAIL_MAX_CHARS = 80;
function creditNote(match, filePath) {
const chars = Array.from(sanitizePath(match.detail).replace(/\s+/g, ' '));
const detail = chars.length > CREDIT_DETAIL_MAX_CHARS ? `${chars.slice(0, CREDIT_DETAIL_MAX_CHARS - 3).join('')}...` : chars.join('');
const calls = `${match.callsAgo} tool call${match.callsAgo === 1 ? '' : 's'} ago`;
return (
`[Fact-Forcing Gate] Prior search seen in this turn (${match.name} ${detail}, ${calls}); ` +
`first-touch check satisfied for ${sanitizePath(filePath)}.`
);
}
// --- Closest search that did not count ---
const MISS_DETAIL_MAX_CHARS = 60;
const MISS_REASON_TEXT = Object.freeze({
'same-batch': 'it was sent in the same batch as this call, so its result was not seen yet',
excluded: 'its filters exclude this file',
'out-of-scope': 'its search path does not contain this file',
'stdin-only': 'it searched piped input, not the tree',
'not-a-search': 'only Glob, Grep, LS and shell search commands count',
'generic-stem': 'this file name is too generic to match a search'
});
function closestMissNote(miss) {
if (!miss || !Object.hasOwn(MISS_REASON_TEXT, miss.reason)) return '';
const chars = Array.from(sanitizePath(miss.detail).replace(/\s+/g, ' ').trim());
const detail = chars.length > MISS_DETAIL_MAX_CHARS ? `${chars.slice(0, MISS_DETAIL_MAX_CHARS - 3).join('')}...` : chars.join('');
return `Closest search this turn did not count (${sanitizePath(miss.name)} ${detail}): ${MISS_REASON_TEXT[miss.reason]}.`;
}
function isMissingOnDisk(resolved) {
try {
fs.lstatSync(resolved);
return false;
} catch (error) {
return Boolean(error) && error.code === 'ENOENT';
}
}
function isNewFileTarget(filePath, data) {
try {
const target = resolveTargetPath(filePath, data);
return Boolean(target) && isMissingOnDisk(target.resolved);
} catch (_) {
return false;
}
}
function newFileGateKey(filePath, data, cls) {
try {
const dir = collapseGateDir(filePath, data, cls);
return dir ? dirGateKey(cls, dir) : null;
} catch (_) {
return null;
}
}
function trivialNote(filePath) {
return (
`[Fact-Forcing Gate] Comment or whitespace-only change to ${sanitizePath(filePath)}; no first-touch check needed. ` +
'The next change to this file that alters code is still checked.'
);
}
function siblingNote(gate) {
return (
`[Fact-Forcing Gate] Sibling of ${sanitizePath(gate.first)} (gated earlier at denial #${gate.ordinal} this session); ` +
'proceeding without a repeat denial.'
);
}
// --- Allowance eligibility ---
// see docs/gateguard/design-notes.md#hard-linked-targets
function allowanceFacts(filePath, data) {
const sensitive = isSensitiveTargetFor(filePath, data);
const linked = !sensitive && isHardLinkedTargetFor(filePath, data);
return { sensitive, linked, restricted: sensitive || linked };
}
// --- Subagents ---
// see docs/gateguard/design-notes.md#subagents
function subagentGateKey(fileKey) {
return `__subagent__${crypto.createHash('sha256').update(fileKey).digest('hex').slice(0, 16)}`;
}
function subagentRestrictedDenial(filePath, data, isWrite) {
const { sensitive, linked, restricted } = allowanceFacts(filePath, data);
if (!restricted) return null;
const fileKey = canonicalPathKey(filePath, data);
const subKey = subagentGateKey(fileKey);
if (isChecked([fileKey, filePath, subKey])) return null;
const cls = classifyTargetFor(filePath, data);
const { ok, denials } = markCheckedAndCountDenial(subKey, { cls });
if (!ok) return allowWithStateWarning();
const reason = sensitive ? 'subagent-sensitive' : 'subagent-hard-linked';
return firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, linked, profile: null, miss: null, reason });
}
// --- Change profile ---
// see docs/gateguard/change-profile.md#file-context
const MAX_CONTEXT_FILE_BYTES = 1024 * 1024;
function readTargetText(filePath, data) {
let fd = null;
try {
const target = resolveTargetPath(filePath, data);
if (!target) return null;
fd = fs.openSync(target.resolved, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
const stat = fs.fstatSync(fd);
if (!stat.isFile() || stat.size > MAX_CONTEXT_FILE_BYTES) return null;
const buffer = Buffer.alloc(stat.size);
let read = 0;
while (read < buffer.length) {
const n = fs.readSync(fd, buffer, read, buffer.length - read, read);
if (n === 0) break;
read += n;
}
return buffer.toString('utf8', 0, read).replace(/\r\n/g, '\n');
} catch (_) {
return null;
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch (_) {
/* ignore */
}
}
}
}
function changeProfileFor(toolName, filePath, edits, content, data) {
const tool = toolName === 'Write' ? 'Write' : 'Edit';
const fileText = tool === 'Edit' ? readTargetText(filePath, data) : null;
return profileChange({ filePath, tool, edits, content, fileText });
}
// see docs/gateguard/design-notes.md#multiedit-paths
function multiEditEntries(toolInput) {
const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
const shared = typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
return edits.map(edit => (edit && typeof edit === 'object' && !edit.file_path && shared ? { ...edit, file_path: shared } : edit));
}
function entriesFor(edits, fileKey, data) {
return edits.filter(edit => edit && typeof edit.file_path === 'string' && canonicalPathKey(edit.file_path, data) === fileKey);
}
// --- Metrics ---
// see docs/gateguard/design-notes.md#metrics
let pendingMetrics = null;
function isMetricsEnabled() {
return ECC_ENABLE_VALUES.has(normalizeEnvValue(process.env.GATEGUARD_METRICS));
}
function recordDecision(decision, reason, fields = {}) {
if (!pendingMetrics) return;
if (fields.key !== undefined) {
if (pendingMetrics.keys.has(fields.key)) return;
pendingMetrics.keys.add(fields.key);
}
pendingMetrics.entries.push({ ...fields, decision, reason });
}
function targetFacts(entry, data) {
if (!entry.target) {
return { cls: entry.cls === undefined ? null : entry.cls, sensitive: entry.sensitive === true };
}
try {
return {
cls: entry.cls === undefined ? classifyTargetFor(entry.target, data) : entry.cls,
sensitive: entry.sensitive === undefined ? isSensitiveTargetFor(entry.target, data) : entry.sensitive
};
} catch (_) {
return { cls: null, sensitive: false };
}
}
function flushMetrics() {
const batch = pendingMetrics;
pendingMetrics = null;
if (!batch || batch.entries.length === 0) return;
try {
const now = Date.now();
const events = batch.entries.map(entry =>
metricsEvent({
sessionKey: activeSessionKey,
tool: batch.tool,
...targetFacts(entry, batch.data),
decision: entry.decision,
reason: entry.reason,
questions: entry.questions,
profile: entry.profile,
now
})
);
appendMetrics(STATE_DIR, events);
} catch (_) {
/* ignore */
}
}
function exemptReason(filePath, data) {
if (!filePath) return 'no-path';
if (isClaudeSettingsPath(filePath)) return 'claude-settings';
return isExemptPath(filePath, data) ? 'exempt-glob' : '';
}
// --- Core logic (exported for run-with-flags.js) ---
function run(rawInput) {
pendingMetrics = null;
try {
return gate(rawInput);
} finally {
flushMetrics();
}
}
function gate(rawInput) {
let data;
try {
data = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
@@ -1923,63 +2343,148 @@ function run(rawInput) {
const rawToolName = data.tool_name || '';
const toolInput = data.tool_input || {};
// Normalize: case-insensitive matching via lookup map
const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', bash: 'Bash', powershell: 'PowerShell' };
const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', notebookedit: 'NotebookEdit', bash: 'Bash', powershell: 'PowerShell' };
const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName;
if (isMetricsEnabled()) {
pendingMetrics = { tool: toolName, data, entries: [], keys: new Set() };
}
const inSubagent = isSubagentInvocation(data);
let turnScanner = null;
const getTurnScan = () => (turnScanner || (turnScanner = createTurnScanner(data)))();
if (toolName === 'Edit' || toolName === 'Write') {
const filePath = toolInput.file_path || '';
if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) {
if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') {
const filePath = (toolName === 'NotebookEdit' ? toolInput.notebook_path : toolInput.file_path) || '';
const exempt = exemptReason(filePath, data);
if (exempt) {
recordDecision('pass-exempt', exempt, { target: filePath });
return rawInput; // allow
}
if (inSubagent) {
return rawInput; // parent session already passed the first-touch file gate
const denial = subagentRestrictedDenial(filePath, data, toolName === 'Write');
if (!denial) recordDecision('pass-subagent', 'subagent', { target: filePath });
return denial || rawInput;
}
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);
// see docs/gateguard/design-notes.md#sensitive-targets
const { sensitive, linked, restricted } = allowanceFacts(filePath, data);
const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, isNewFile, data);
if (credit) {
if (!markCheckedAndCountCredit(fileKey, cls)) {
return allowWithStateWarning();
}
recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive });
return { additionalContext: creditNote(credit, filePath), exitCode: 0 };
}
const profile = restricted || toolName === 'NotebookEdit' || !needsProfile(toolName, cls)
? null
: changeProfileFor(toolName, filePath, [toolInput], toolInput.content, data);
if (toolName === 'Edit' && isTrivialChange(cls, restricted, profile)) {
if (!countTrivialAllow()) {
return allowWithStateWarning();
}
recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile });
return { additionalContext: trivialNote(filePath), exitCode: 0 };
}
const turnId = isNewFile && !restricted ? currentTurnId(getTurnScan()) : null;
const collapsible = isNewFile && !restricted && (turnId !== null || !transcriptPathFor(data));
const gateKey = collapsible ? newFileGateKey(filePath, data, cls) : null;
const sibling = gateKey ? findSiblingGate(gateKey, turnId) : null;
if (sibling) {
if (!markCheckedAndCountSibling(fileKey)) {
return allowWithStateWarning();
}
recordDecision('sibling', 'same-turn-dir', { target: filePath, cls, sensitive, profile });
return { additionalContext: siblingNote(sibling), exitCode: 0 };
}
const dirGate = gateKey ? { key: gateKey, turn: turnId, first: sanitizePath(filePath) } : undefined;
const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget();
const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, dirGate, cap });
if (!ok) {
return allowWithStateWarning();
}
if (denials > getMaxDenialBudget()) {
if (denials === null) {
// see docs/gateguard/design-notes.md#denial-cap
recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile });
return rawInput;
}
if (denials > getFullDenialBudget()) {
const action = toolName === 'Edit' ? 'edit' : 'creation';
return denyResult(condensedGateMsg(action, filePath, denials), { includeRecoveryHint: false });
}
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath), {
narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT
});
const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, isNewFile, data);
return firstTouchDenial(filePath, { isWrite: toolName === 'Write', denials, cls, sensitive, linked, profile, miss });
}
recordDecision('pass-checked', 'checked', { target: filePath });
return rawInput; // allow
}
if (toolName === 'MultiEdit') {
const edits = multiEditEntries(toolInput);
if (inSubagent) {
return rawInput; // parent session already passed the first-touch file gate
for (const edit of edits) {
const filePath = (edit && edit.file_path) || '';
if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) continue;
const denial = subagentRestrictedDenial(filePath, data, false);
if (denial) return denial;
}
recordDecision('pass-subagent', 'subagent');
return rawInput;
}
const edits = toolInput.edits || [];
const notes = [];
const trivialKeys = new Set();
for (const edit of edits) {
const filePath = edit.file_path || '';
if (filePath && !isClaudeSettingsPath(filePath) && !isExemptPath(filePath, data) && !isChecked(filePath)) {
const { ok, denials } = markCheckedAndCountDenial(filePath);
const filePath = (edit && edit.file_path) || '';
const exempt = exemptReason(filePath, data);
if (exempt) {
recordDecision('pass-exempt', exempt, { target: filePath, key: `exempt:${filePath}` });
continue;
}
const fileKey = canonicalPathKey(filePath, data);
if (trivialKeys.has(fileKey)) continue;
if (!isChecked([fileKey, filePath])) {
const cls = classifyTargetFor(filePath, data);
const { sensitive, linked, restricted } = allowanceFacts(filePath, data);
const credit = restricted ? null : findCreditingSearch(getTurnScan(), filePath, false, data);
if (credit) {
if (!markCheckedAndCountCredit(fileKey, cls)) {
return allowWithStateWarning();
}
recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive, key: fileKey });
notes.push(creditNote(credit, filePath));
continue;
}
const profile = restricted || !needsProfile('MultiEdit', cls)
? null
: changeProfileFor('Edit', filePath, entriesFor(edits, fileKey, data), undefined, data);
if (isTrivialChange(cls, restricted, profile)) {
if (!countTrivialAllow()) {
return allowWithStateWarning();
}
trivialKeys.add(fileKey);
recordDecision('trivial', 'comment-whitespace', { target: filePath, cls, sensitive, profile, key: fileKey });
notes.push(trivialNote(filePath));
continue;
}
const cap = restricted ? Number.POSITIVE_INFINITY : getMaxDenialBudget();
const { ok, denials } = markCheckedAndCountDenial(fileKey, { cls, cap });
if (!ok) {
return allowWithStateWarning();
}
if (denials > getMaxDenialBudget()) {
return rawInput;
if (denials === null) {
// see docs/gateguard/design-notes.md#denial-cap
recordDecision('cap', 'max-denials', { target: filePath, cls, sensitive, profile, key: fileKey });
continue;
}
if (denials > getFullDenialBudget()) {
return denyResult(condensedGateMsg('edit', filePath, denials), { includeRecoveryHint: false });
}
return denyResult(editGateMsg(filePath), {
narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT
});
const miss = restricted ? null : findClosestMiss(getTurnScan(), filePath, false, data);
return firstTouchDenial(filePath, { isWrite: false, denials, cls, sensitive, linked, profile, miss });
}
recordDecision('pass-checked', 'checked', { target: filePath, key: fileKey });
}
if (notes.length > 0) {
return { additionalContext: notes, exitCode: 0 };
}
return rawInput; // allow
}
@@ -1987,6 +2492,7 @@ function run(rawInput) {
if (toolName === 'Bash' || toolName === 'PowerShell') {
const command = toolInput.command || '';
if (isReadOnlyGitIntrospection(command)) {
recordDecision('pass', 'readonly-git');
return rawInput;
}
@@ -1997,8 +2503,10 @@ function run(rawInput) {
if (!markChecked(key)) {
return allowWithStateWarning();
}
recordDecision('destructive-deny', 'destructive');
return denyResult(destructiveBashMsg(), { includeRecoveryHint: false });
}
recordDecision('pass-checked', 'destructive-retry');
return rawInput; // allow retry after facts presented
}
@@ -2007,13 +2515,22 @@ function run(rawInput) {
// (Cursor, OpenCode, etc.) where the once-per-session routine gate is
// friction without signal.
if (isRoutineBashGateDisabled()) {
recordDecision('pass-exempt', 'routine-disabled');
return rawInput; // routine gate opted out via env
}
if (!isChecked(ROUTINE_BASH_SESSION_KEY)) {
if (isReadOnlyShellCommand(toolName, command)) {
if (!countRoutineReadonlyPass()) {
return allowWithStateWarning();
}
recordDecision('routine-readonly', 'readonly');
return rawInput;
}
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
return allowWithStateWarning();
}
recordDecision('routine-deny', 'first-command');
const hookId = toolName === 'PowerShell' ? POWERSHELL_HOOK_ID : BASH_HOOK_ID;
const narrowRecoveryHint = toolName === 'PowerShell'
? ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT
@@ -2024,10 +2541,28 @@ function run(rawInput) {
});
}
recordDecision('pass-checked', 'routine-checked');
return rawInput; // allow
}
return rawInput; // allow
}
module.exports = { classifyDestructiveCommand, run };
module.exports = {
run,
classifyDestructiveCommand,
findCreditingSearch,
findClosestMiss,
get classifyTarget() {
return targetClass().classifyTarget;
},
get classifyTargetFor() {
return targetClass().classifyTargetFor;
},
get isReadOnlyShellCommand() {
return readOnlyShell().isReadOnlyShellCommand;
},
get scanCurrentTurn() {
return turnScan().scanCurrentTurn;
}
};