mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-17 21:55:34 +02:00
feat(gateguard): integrate evidence-aware first-touch questions
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user