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
+777
View File
@@ -0,0 +1,777 @@
#!/usr/bin/env node
'use strict';
const { execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { performance } = require('perf_hooks');
const { pathToFileURL } = require('url');
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
const REPO_ROOT = path.join(__dirname, '..', '..');
const HOOK_RELATIVE_PATH = 'scripts/hooks/gateguard-fact-force.js';
const DEFAULT_CORPUS_DIR = path.join(REPO_ROOT, 'tests', 'fixtures', 'gateguard-scenarios');
const DEFAULT_BASELINE = 'upstream/main';
const EXPECTATIONS = new Set(['deny', 'allow']);
const TOKEN_CHARS = 4;
const CONDENSED_PREFIX = /^\[Fact-Forcing Gate\] \(denial #\d+ this session\) First (edit|creation) of /;
const QUESTION_IDS = Object.freeze([
'importers',
'public-api',
'local-callers',
'callers',
'no-duplicate',
'data-schema',
'external-contract',
'loader',
'behaviour-change',
'no-duplicate-instruction',
'under-test',
'existing-tests',
'supersedes',
'linked-from',
'why-new-file',
'references',
'corrects-or-adds',
'config-reader',
'config-effect',
'no-plaintext-secrets',
'quote-instruction'
]);
const LEGACY_CONDENSED_PHRASES = Object.freeze([
{ phrase: 'call sites in this file or its module', ids: { edit: 'local-callers', creation: 'local-callers' } },
{ phrase: 'importers/callers', ids: { edit: 'importers', creation: 'callers' } },
{ phrase: 'affected API', ids: { edit: 'public-api', creation: 'public-api' } },
{ phrase: 'data schemas', ids: { edit: 'data-schema', creation: 'data-schema' } },
{ phrase: 'verbatim instruction', ids: { edit: 'quote-instruction', creation: 'quote-instruction' } }
]);
// --- corpus ---
function fail(message) {
throw new Error(message);
}
function validateStep(scenario, step, index) {
const where = `${scenario.name} step ${index + 1}`;
if (!step || typeof step !== 'object') fail(`${where}: not an object`);
if (typeof step.id !== 'string' || !step.id) fail(`${where}: missing id`);
if (!step.payload || typeof step.payload !== 'object') fail(`${where}: missing payload`);
if (typeof step.payload.tool_name !== 'string') fail(`${where}: payload.tool_name must be a string`);
if (!EXPECTATIONS.has(step.expect)) fail(`${where}: expect must be deny or allow`);
if (typeof step.mustDeny !== 'boolean') fail(`${where}: mustDeny must be a boolean`);
if (typeof step.redundant !== 'boolean') fail(`${where}: redundant must be a boolean`);
if (step.mustDeny && step.expect !== 'deny') fail(`${where}: mustDeny requires expect deny`);
if (step.redundant && step.expect !== 'allow') fail(`${where}: redundant requires expect allow`);
if (step.relevantQuestions !== undefined) {
const ids = step.relevantQuestions;
if (!Array.isArray(ids) || ids.some(id => !QUESTION_IDS.includes(id))) fail(`${where}: unknown relevantQuestions id`);
}
if (step.transcript !== undefined && !Array.isArray(step.transcript)) fail(`${where}: transcript must be an array`);
}
function validateScenario(scenario, file) {
if (!scenario || typeof scenario !== 'object') fail(`${file}: not an object`);
if (typeof scenario.name !== 'string' || !scenario.name) fail(`${file}: missing name`);
if (!Array.isArray(scenario.steps) || scenario.steps.length === 0) fail(`${file}: steps must be a non-empty array`);
const ids = new Set();
scenario.steps.forEach((step, index) => {
validateStep(scenario, step, index);
if (ids.has(step.id)) fail(`${scenario.name}: duplicate step id ${step.id}`);
ids.add(step.id);
});
}
/** Load and validate every scenario file in a corpus directory, sorted by file name. */
function loadCorpus(dir = DEFAULT_CORPUS_DIR) {
return fs
.readdirSync(dir)
.filter(name => name.endsWith('.json'))
.sort()
.map(name => {
const scenario = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
validateScenario(scenario, name);
return { ...scenario, file: name };
});
}
// --- baseline materialisation ---
function gitShow(ref, relativePath) {
return execFileSync('git', ['show', `${ref}:${relativePath}`], {
cwd: REPO_ROOT,
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe']
});
}
function relativeRequires(source) {
const specs = [];
const pattern = /require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g;
for (let match = pattern.exec(source); match; match = pattern.exec(source)) specs.push(match[1]);
return specs;
}
function requireTarget(fromFile, spec) {
const joined = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
return path.posix.extname(joined) ? joined : `${joined}.js`;
}
/** Write the hook at a git ref, and every file it requires relatively, into a temp tree; returns the hook path. */
function materializeHook(ref, outDir) {
const seen = new Set();
const queue = [HOOK_RELATIVE_PATH];
while (queue.length > 0) {
const relative = queue.shift();
if (seen.has(relative)) continue;
seen.add(relative);
let source;
try {
source = gitShow(ref, relative);
} catch (error) {
fail(`cannot read ${relative} at ${ref} (fetch the ref first): ${String(error.stderr || error.message).trim()}`);
}
const target = path.join(outDir, ...relative.split('/'));
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, source, 'utf8');
if (relative.endsWith('.js')) relativeRequires(source).forEach(spec => queue.push(requireTarget(relative, spec)));
}
return path.join(outDir, ...HOOK_RELATIVE_PATH.split('/'));
}
// --- scenario setup ---
function substitute(value, vars) {
if (typeof value === 'string') return value.replace(/\{\{(root|transcript)\}\}/g, (_, name) => vars[name]);
if (Array.isArray(value)) return value.map(item => substitute(item, vars));
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substitute(item, vars)]));
}
return value;
}
function createSymlink(root, link, target) {
const linkPath = path.join(root, ...link.split('/'));
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
const resolved = path.resolve(path.dirname(linkPath), ...target.split('/'));
const isDir = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory();
if (process.platform === 'win32' && isDir) fs.symlinkSync(resolved, linkPath, 'junction');
else fs.symlinkSync(target, linkPath, isDir ? 'dir' : 'file');
}
function createHardLink(root, link, target) {
const linkPath = path.join(root, ...link.split('/'));
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
fs.linkSync(path.join(root, ...target.split('/')), linkPath);
}
function prepareScenario(scenario, workDir) {
const root = scenario.root || fs.realpathSync(fs.mkdtempSync(path.join(workDir, 'project-')));
if (!scenario.root) {
for (const [relative, content] of Object.entries(scenario.files || {})) {
const file = path.join(root, ...relative.split('/'));
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content, 'utf8');
}
for (const dir of scenario.dirs || []) fs.mkdirSync(path.join(root, ...dir.split('/')), { recursive: true });
try {
for (const [link, target] of Object.entries(scenario.symlinks || {})) createSymlink(root, link, target);
} catch (error) {
return { skipped: `symlinks unavailable: ${error.code || error.message}` };
}
try {
for (const [link, target] of Object.entries(scenario.hardlinks || {})) createHardLink(root, link, target);
} catch (error) {
return { skipped: `hard links unavailable: ${error.code || error.message}` };
}
}
const stateDir = fs.mkdtempSync(path.join(workDir, 'state-'));
const home = fs.mkdtempSync(path.join(workDir, 'home-'));
const transcript = path.join(fs.mkdtempSync(path.join(workDir, 'transcript-')), 'session.jsonl');
const vars = { root, transcript };
const env = {
PATH: process.env.PATH || '',
HOME: home,
USERPROFILE: home,
GATEGUARD_STATE_DIR: stateDir,
CLAUDE_PROJECT_DIR: root,
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
...(scenario.env || {})
};
const steps = scenario.steps.map(step => ({
id: step.id,
records: substitute(step.transcript || [], vars),
payload: substitute(step.payload, vars)
}));
return { env, transcript, steps };
}
// --- hook execution ---
function runStepsInWorker(hookFile, prepared) {
return new Promise((resolve, reject) => {
const worker = new Worker(__filename, {
env: prepared.env,
workerData: { gateguardEval: true, hookFile, transcript: prepared.transcript, steps: prepared.steps }
});
let outcome = null;
worker.once('message', message => {
outcome = message;
});
worker.once('error', reject);
worker.once('exit', code => {
if (outcome) resolve(outcome);
else reject(new Error(`scenario worker exited with code ${code}`));
});
});
}
function hookOutput(result) {
if (!result || typeof result !== 'object') return { decision: null, reason: '', context: '' };
let parsed = null;
if (typeof result.stdout === 'string' && result.stdout) {
try {
parsed = JSON.parse(result.stdout);
} catch (_) {
parsed = null;
}
}
const specific = parsed && parsed.hookSpecificOutput ? parsed.hookSpecificOutput : {};
const context = [].concat(result.additionalContext || specific.additionalContext || []).join('\n');
return { decision: specific.permissionDecision || null, reason: specific.permissionDecisionReason || '', context };
}
function allowKind(context) {
if (context.includes('Prior search seen in this turn')) return 'credit';
if (context.includes('Sibling of ')) return 'sibling';
if (context.includes('Comment or whitespace-only change')) return 'trivial';
return 'pass';
}
function workerMain() {
const { hookFile, transcript, steps } = workerData;
const hook = require(hookFile);
const results = [];
for (const step of steps) {
if (step.records.length > 0) {
fs.appendFileSync(transcript, step.records.map(record => `${JSON.stringify(record)}\n`).join(''), 'utf8');
}
const raw = JSON.stringify(step.payload);
let result;
let error = null;
const started = performance.now();
try {
result = hook.run(raw);
} catch (thrown) {
error = String(thrown && thrown.message ? thrown.message : thrown);
result = raw;
}
const latencyMs = performance.now() - started;
const output = hookOutput(result);
const denied = output.decision === 'deny';
results.push({
id: step.id,
decision: denied ? 'deny' : 'allow',
kind: denied ? 'deny' : allowKind(output.context),
explicitAllow: output.decision === 'allow',
reason: output.reason,
latencyMs,
error
});
}
parentPort.postMessage({ results });
}
/** Run every scenario against one hook file; each scenario gets its own project, state dir, transcript and clean env. */
async function runCorpus(hookFile, scenarios) {
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-eval-'));
try {
const runs = [];
for (const scenario of scenarios) {
const prepared = prepareScenario(scenario, workDir);
if (prepared.skipped) {
runs.push({ scenario, skipped: prepared.skipped, results: [] });
continue;
}
const { results } = await runStepsInWorker(hookFile, prepared);
runs.push({ scenario, skipped: null, results });
}
return runs;
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
}
// --- cold latency ---
// see docs/gateguard/evaluation.md#latency
// A hook that throws is recorded the way workerMain records it - the raw payload
// stands in for the result, so the step reads as an allow - rather than ending the
// run. Otherwise one throwing step aborted the whole replay and no report was written.
const COLD_PROBE = [
"const { performance } = require('perf_hooks');",
'const started = performance.now();',
'let result;',
'let error = null;',
'try {',
' result = require(process.argv[1]).run(process.argv[2]);',
'} catch (thrown) {',
' error = String(thrown && thrown.message ? thrown.message : thrown);',
' result = process.argv[2];',
'}',
'const ms = performance.now() - started;',
'process.stdout.write(JSON.stringify({ ms, result: result === undefined ? null : result, error }));'
].join('\n');
/** Replay every scenario with one fresh Node process per step; returns per-step require+run latency and decisions. */
function measureColdLatency(hookFile, scenarios) {
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-cold-'));
try {
const steps = [];
for (const scenario of scenarios) {
const prepared = prepareScenario(scenario, workDir);
if (prepared.skipped) continue;
for (const step of prepared.steps) {
if (step.records.length > 0) {
fs.appendFileSync(prepared.transcript, step.records.map(record => `${JSON.stringify(record)}\n`).join(''), 'utf8');
}
const raw = JSON.stringify(step.payload);
let probe;
try {
probe = JSON.parse(execFileSync(process.execPath, ['-e', COLD_PROBE, hookFile, raw], {
env: prepared.env,
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'ignore']
}));
} catch (failed) {
// The process died or wrote nothing parseable, so there is no latency to
// report; the step still counts, as an allow, matching a throwing hook.
probe = { ms: null, result: raw, error: String(failed && failed.message ? failed.message : failed) };
}
const output = hookOutput(probe.result);
steps.push({
scenario: scenario.name,
step: step.id,
latencyMs: probe.ms,
decision: output.decision === 'deny' ? 'deny' : 'allow',
error: probe.error || null
});
}
}
return steps;
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
}
function coldSummary(coldSteps, warmSteps) {
// A step whose process died has no latency; it still counts toward disagreements
// and errors. Failures are counted on their own: a failed step is recorded as an
// allow, so where the worker also allowed it, it is no disagreement and would
// otherwise vanish from the report and the exit status.
const sorted = coldSteps.map(step => step.latencyMs).filter(Number.isFinite).sort((a, b) => a - b);
const warmDecisions = new Map(warmSteps.map(step => [`${step.scenario}/${step.step}`, step.decision]));
const disagreements = coldSteps.filter(step => warmDecisions.get(`${step.scenario}/${step.step}`) !== step.decision).length;
// With no measured step there is no latency to report; 0 would read as a fast hook.
const at = fraction => (sorted.length ? Number(percentile(sorted, fraction).toFixed(3)) : null);
return {
p50Ms: at(0.5),
p90Ms: at(0.9),
p95Ms: at(0.95),
samples: sorted.length,
errors: coldSteps.filter(step => step.error).length,
disagreements
};
}
// --- question mapping ---
function questionTextTable() {
const { questionText, condensedQuestionPhrase } = require(path.join(REPO_ROOT, 'scripts', 'lib', 'gateguard-target-class'));
const table = new Map(QUESTION_IDS.map(id => [questionText(id), id]));
table.condensed = [
...QUESTION_IDS.filter(id => condensedQuestionPhrase(id)).map(id => ({ phrase: condensedQuestionPhrase(id), ids: { edit: id, creation: id } })),
...LEGACY_CONDENSED_PHRASES
];
return table;
}
/** Question ids a denial asked, read from its numbered list or its condensed hint. */
function questionsAsked(reason, table) {
const numbered = reason
.split('\n')
.map(line => line.match(/^\d+\. (.+)$/))
.filter(Boolean)
.map(match => table.get(match[1]) || `unmapped:${match[1]}`);
if (numbered.length > 0) return numbered;
const condensed = reason.match(CONDENSED_PREFIX);
if (!condensed) return [];
const phrases = table.condensed || LEGACY_CONDENSED_PHRASES;
return [...new Set(phrases.filter(({ phrase }) => reason.includes(phrase)).map(({ ids }) => ids[condensed[1]]))];
}
// --- metrics ---
function percentile(sorted, fraction) {
if (sorted.length === 0) return 0;
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
return sorted[index];
}
/** Metrics for one hook's run of the corpus, plus per-step detail. */
function summarize(runs, table = questionTextTable()) {
const totals = {
scenarios: 0,
skippedScenarios: 0,
steps: 0,
denials: 0,
redundantDenials: 0,
mustDenyBypasses: 0,
mismatches: 0,
irrelevantQuestions: 0,
irrelevantInCondensed: 0,
missedQuestions: 0,
unmappedQuestions: 0,
explicitAllows: 0,
errors: 0,
denialTokens: 0,
allowsByKind: { credit: 0, sibling: 0, trivial: 0, pass: 0 }
};
const latencies = [];
const steps = [];
const perScenario = [];
for (const run of runs) {
if (run.skipped) {
totals.skippedScenarios += 1;
perScenario.push({ name: run.scenario.name, skipped: run.skipped, steps: 0, denials: 0 });
continue;
}
totals.scenarios += 1;
let scenarioDenials = 0;
run.results.forEach((result, index) => {
const step = run.scenario.steps[index];
const denied = result.decision === 'deny';
const relevant = Array.isArray(step.relevantQuestions) ? step.relevantQuestions : null;
const asked = denied && relevant ? questionsAsked(result.reason, table) : [];
const irrelevant = denied && relevant ? asked.filter(id => !relevant.includes(id)) : [];
const missed = denied && relevant ? relevant.filter(id => !asked.includes(id)) : [];
totals.steps += 1;
latencies.push(result.latencyMs);
if (denied) {
totals.denials += 1;
scenarioDenials += 1;
totals.denialTokens += Math.ceil(result.reason.length / TOKEN_CHARS);
if (step.redundant) totals.redundantDenials += 1;
} else {
totals.allowsByKind[result.kind] += 1;
if (step.mustDeny) totals.mustDenyBypasses += 1;
}
if (result.decision !== step.expect) totals.mismatches += 1;
const condensed = denied && CONDENSED_PREFIX.test(result.reason);
totals.irrelevantQuestions += irrelevant.length;
if (condensed) totals.irrelevantInCondensed += irrelevant.length;
totals.missedQuestions += missed.length;
totals.unmappedQuestions += asked.filter(id => id.startsWith('unmapped:')).length;
if (result.explicitAllow) totals.explicitAllows += 1;
if (result.error) totals.errors += 1;
steps.push({
scenario: run.scenario.name,
file: run.scenario.file,
step: step.id,
expect: step.expect,
decision: result.decision,
kind: result.kind,
mustDeny: step.mustDeny,
redundant: step.redundant,
condensed,
questions: asked,
irrelevant,
missed,
latencyMs: Number(result.latencyMs.toFixed(3)),
...(result.explicitAllow ? { explicitAllow: true } : {}),
...(result.error ? { error: result.error } : {})
});
});
perScenario.push({ name: run.scenario.name, skipped: null, steps: run.results.length, denials: scenarioDenials });
}
const sorted = latencies.slice().sort((a, b) => a - b);
return {
totals,
latency: {
p50Ms: Number(percentile(sorted, 0.5).toFixed(3)),
p90Ms: Number(percentile(sorted, 0.9).toFixed(3)),
p95Ms: Number(percentile(sorted, 0.95).toFixed(3))
},
perScenario,
steps
};
}
// --- reporting ---
/** A fresh-process percentile, or why there is none: not run, or no step measured. */
function coldMs(s, key) {
if (!s.cold) return 'not run';
return s.cold[key] === null ? 'no valid sample' : s.cold[key].toFixed(2);
}
const METRIC_ROWS = [
['Steps', s => s.totals.steps],
['Denials', s => s.totals.denials],
['Redundant denials', s => s.totals.redundantDenials],
['Must-deny bypasses', s => s.totals.mustDenyBypasses],
['Expectation mismatches', s => s.totals.mismatches],
['Irrelevant questions asked', s => s.totals.irrelevantQuestions],
['Irrelevant questions in condensed denials', s => s.totals.irrelevantInCondensed],
['Warranted questions not asked', s => s.totals.missedQuestions],
['Estimated denial tokens', s => s.totals.denialTokens],
['Allows with a credit note', s => s.totals.allowsByKind.credit],
['Allows with a sibling note', s => s.totals.allowsByKind.sibling],
['Allows with a trivial-edit note', s => s.totals.allowsByKind.trivial],
['Hook latency p50, fresh process (ms)', s => coldMs(s, 'p50Ms')],
['Hook latency p90, fresh process (ms)', s => coldMs(s, 'p90Ms')],
['Hook latency p95, fresh process (ms)', s => coldMs(s, 'p95Ms')],
['run() latency p50, warm (ms)', s => s.latency.p50Ms.toFixed(2)],
['run() latency p90, warm (ms)', s => s.latency.p90Ms.toFixed(2)],
['run() latency p95, warm (ms)', s => s.latency.p95Ms.toFixed(2)]
];
function markdownTable(header, rows) {
const line = cells => `| ${cells.join(' | ')} |`;
return [line(header), line(header.map((_, i) => (i === 0 ? '---' : '---:'))), ...rows.map(line)].join('\n');
}
/** Markdown report: metric table, per-scenario denials, and any working-tree mismatches. */
function renderMarkdown(report) {
const labels = report.hooks.map(hook => hook.label);
const lines = [
`Corpus: ${report.corpus.scenarios} scenarios, ${report.corpus.steps} steps.`,
'',
markdownTable(['Metric', ...labels], METRIC_ROWS.map(([name, pick]) => [name, ...report.hooks.map(hook => String(pick(hook.summary)))])),
'',
markdownTable(
['Scenario', 'Steps', ...labels.map(label => `Denials: ${label}`)],
report.hooks[0].summary.perScenario.map((row, index) => [
row.name,
String(row.steps),
...report.hooks.map(hook => {
const entry = hook.summary.perScenario[index];
return entry.skipped ? 'skipped' : String(entry.denials);
})
])
)
];
const working = report.hooks[0].summary;
const mismatched = working.steps.filter(step => step.decision !== step.expect);
if (mismatched.length > 0) {
lines.push('', `${report.hooks[0].label} mismatches:`, '');
mismatched.forEach(step => lines.push(`- ${step.scenario} / ${step.step}: expected ${step.expect}, got ${step.decision} (${step.kind})`));
}
const cold = report.hooks[0].summary.cold;
if (cold && cold.disagreements > 0) {
lines.push('', `${report.hooks[0].label}: ${cold.disagreements} step(s) decided differently in a fresh process.`);
}
if (cold && cold.errors > 0) {
lines.push('', `${report.hooks[0].label}: ${cold.errors} step(s) failed in a fresh process.`);
}
return `${lines.join('\n')}\n`;
}
const SARIF_RULES = Object.freeze([
{
id: 'gateguard/must-deny-bypass',
level: 'error',
name: 'MustDenyBypass',
text: 'A step that must be denied (sensitive target, bypass attempt, or mutating first command) was allowed.'
},
{
id: 'gateguard/explicit-allow',
level: 'error',
name: 'ExplicitAllow',
text: 'The hook emitted permissionDecision "allow", which skips the host permission prompt.'
},
{
id: 'gateguard/hook-error',
level: 'error',
name: 'HookError',
text: 'The hook threw or produced unparseable output for a step.'
},
{
id: 'gateguard/expectation-mismatch',
level: 'warning',
name: 'ExpectationMismatch',
text: 'The hook decision differs from the decision the scenario expects.'
}
]);
function sarifFindings(step) {
const findings = [];
if (step.mustDeny && step.decision !== 'deny') findings.push('gateguard/must-deny-bypass');
if (step.explicitAllow) findings.push('gateguard/explicit-allow');
if (step.error) findings.push('gateguard/hook-error');
if (step.decision !== step.expect && findings.length === 0) findings.push('gateguard/expectation-mismatch');
return findings;
}
function sarifArtifactLocation(corpusDir, file) {
const absolute = path.resolve(corpusDir || DEFAULT_CORPUS_DIR, file || '');
const relative = path.relative(REPO_ROOT, absolute);
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
return { uri: relative.split(path.sep).join('/'), uriBaseId: '%SRCROOT%' };
}
return { uri: pathToFileURL(absolute).href };
}
function sarifTotals(summary) {
const { steps, denials, mustDenyBypasses, mismatches, explicitAllows, errors, skippedScenarios } = summary.totals;
return { steps, denials, mustDenyBypasses, mismatches, explicitAllows, errors, skippedScenarios };
}
/** SARIF 2.1.0 log of working-tree gate failures, with per-hook totals; timing is omitted so output is reproducible. */
function renderSarif(report) {
const working = report.hooks[0].summary;
const levels = new Map(SARIF_RULES.map(rule => [rule.id, rule.level]));
const results = [];
for (const step of working.steps) {
for (const ruleId of sarifFindings(step)) {
results.push({
ruleId,
level: levels.get(ruleId),
message: { text: `${step.scenario} / ${step.step}: expected ${step.expect}, got ${step.decision} (${step.kind})` },
locations: [
{
physicalLocation: { artifactLocation: sarifArtifactLocation(report.corpusDir, step.file) }
}
]
});
}
}
const log = {
version: '2.1.0',
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
runs: [
{
tool: {
driver: {
name: 'gateguard-eval',
informationUri: 'https://github.com/affaan-m/ECC/blob/main/docs/gateguard/evaluation.md',
rules: SARIF_RULES.map(rule => ({
id: rule.id,
name: rule.name,
shortDescription: { text: rule.text },
defaultConfiguration: { level: rule.level }
}))
}
},
automationDetails: { id: 'gateguard/scenario-corpus' },
invocations: [{ executionSuccessful: true }],
properties: {
corpus: report.corpus,
hooks: report.hooks.map(hook => ({ label: hook.label, totals: sarifTotals(hook.summary) }))
},
results
}
]
};
return `${JSON.stringify(log, null, 2)}\n`;
}
// --- CLI ---
const USAGE = [
'Usage: node scripts/dev/gateguard-eval.js [--markdown | --json] [--baseline <ref>]... [--corpus <dir>] [--sarif <file>]',
'',
' --baseline <ref> git ref whose hook is compared with the working tree (repeatable;',
` default ${DEFAULT_BASELINE})`,
' --corpus <dir> scenario directory (default tests/fixtures/gateguard-scenarios)',
' --markdown print markdown tables (default)',
' --json print the full report as JSON',
' --sarif <file> also write working-tree gate failures as SARIF 2.1.0',
' --no-cold skip the fresh-process latency pass (one Node process per step)'
].join('\n');
function parseArgs(argv) {
const options = { format: 'markdown', baselines: [], corpus: DEFAULT_CORPUS_DIR, sarif: null, cold: true, help: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--markdown') options.format = 'markdown';
else if (arg === '--json') options.format = 'json';
else if (arg === '--help' || arg === '-h') options.help = true;
else if (arg === '--no-cold') options.cold = false;
else if ((arg === '--baseline' || arg === '--corpus' || arg === '--sarif') && i + 1 < argv.length) {
if (arg === '--baseline') options.baselines.push(argv[++i]);
else if (arg === '--sarif') options.sarif = path.resolve(argv[++i]);
else options.corpus = path.resolve(argv[++i]);
} else fail(`unknown or incomplete argument: ${arg}\n\n${USAGE}`);
}
if (options.baselines.length === 0) options.baselines.push(DEFAULT_BASELINE);
return options;
}
/** Evaluate the working-tree hook and each baseline ref over a corpus. */
async function evaluate({ baselines = [DEFAULT_BASELINE], corpus = DEFAULT_CORPUS_DIR, cold = false } = {}) {
const scenarios = loadCorpus(corpus);
const table = questionTextTable();
const hooks = [{ label: 'working tree', ref: null, file: path.join(REPO_ROOT, ...HOOK_RELATIVE_PATH.split('/')) }];
const materialized = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-baseline-'));
try {
baselines.forEach((ref, index) => {
hooks.push({ label: ref, ref, file: materializeHook(ref, path.join(materialized, String(index))) });
});
const evaluated = [];
for (const hook of hooks) {
const summary = summarize(await runCorpus(hook.file, scenarios), table);
if (cold) summary.cold = coldSummary(measureColdLatency(hook.file, scenarios), summary.steps);
evaluated.push({ label: hook.label, ref: hook.ref, summary });
}
return {
corpus: { scenarios: scenarios.length, steps: scenarios.reduce((sum, scenario) => sum + scenario.steps.length, 0) },
corpusDir: corpus,
hooks: evaluated
};
} finally {
fs.rmSync(materialized, { recursive: true, force: true });
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
process.stdout.write(`${USAGE}\n`);
return;
}
const report = await evaluate(options);
process.stdout.write(options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : renderMarkdown(report));
if (options.sarif) fs.writeFileSync(options.sarif, renderSarif(report), 'utf8');
if (gateFails(report)) process.exitCode = 1;
}
/**
* True when the working tree must not pass: a bypass, a mismatch, an explicit allow,
* a hook error in either pass, or a step a fresh process decided differently.
*/
function gateFails(report) {
const working = report.hooks[0].summary.totals;
const cold = report.hooks[0].summary.cold || {};
return working.mustDenyBypasses > 0 || working.mismatches > 0 || working.explicitAllows > 0 ||
working.errors > 0 || cold.disagreements > 0 || cold.errors > 0;
}
if (!isMainThread && workerData && workerData.gateguardEval) {
workerMain();
} else if (require.main === module) {
main().catch(error => {
process.stderr.write(`[gateguard-eval] ${error.message}\n`);
process.exitCode = 2;
});
}
module.exports = {
loadCorpus, materializeHook, runCorpus, measureColdLatency, coldSummary, summarize, questionsAsked,
renderMarkdown, renderSarif, parseArgs, evaluate, gateFails
};
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env node
'use strict';
const { execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..', '..');
const HOOK_FILE = path.join(REPO_ROOT, 'scripts', 'hooks', 'gateguard-fact-force.js');
const DEFAULT_BASELINE = 'upstream/main';
const DEFAULT_RUNS = 30;
const BOOTSTRAP_ROUNDS = 4000;
// --- statistics ---
/** Linear-interpolated quantile of a numeric sample. */
function quantile(values, p) {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length === 0) return NaN;
const at = (sorted.length - 1) * p;
const lo = Math.floor(at);
const hi = Math.ceil(at);
return sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo);
}
function xorshift(seed) {
let state = seed >>> 0 || 1;
return n => {
state ^= state << 13;
state >>>= 0;
state ^= state >>> 17;
state ^= state << 5;
state >>>= 0;
return Math.floor((state / 4294967296) * n);
};
}
/** 95% percentile-bootstrap interval of quantile(b, p) - quantile(a, p); deterministic for a given seed. */
function bootstrapDifference(a, b, p, { rounds = BOOTSTRAP_ROUNDS, seed = 0x9e3779b9 } = {}) {
const random = xorshift(seed);
const resample = values => values.map(() => values[random(values.length)]);
const differences = [];
for (let i = 0; i < rounds; i++) differences.push(quantile(resample(b), p) - quantile(resample(a), p));
differences.sort((x, y) => x - y);
return [differences[Math.floor(rounds * 0.025)], differences[Math.ceil(rounds * 0.975) - 1]];
}
function verdict([low, high]) {
if (low > 0) return 'slower';
if (high < 0) return 'faster';
return 'no measurable change';
}
// --- fixture ---
function buildFixture(workDir) {
const root = fs.realpathSync(fs.mkdtempSync(path.join(workDir, 'project-')));
fs.mkdirSync(path.join(root, 'src'));
fs.writeFileSync(path.join(root, 'src', 'widget.js'), 'function widgetValue(x) {\n return x;\n}\n// note\n'.repeat(50));
let big = '';
for (let i = 0; i < 4000; i++) {
big += `// helper ${i}\nfunction helper${i}(a, b) {\n const s = "value ${i}"; /* note */\n return a + b * ${i};\n}\n`;
}
fs.writeFileSync(path.join(root, 'src', 'big.js'), `${big}function target(x) {\n return x;\n}\n`);
fs.writeFileSync(path.join(root, 'CLAUDE.md'), '# Rules\n\n- be careful\n');
const transcript = path.join(root, 'session.jsonl');
const records = [{ type: 'user', uuid: 'u1', promptId: 'p1', message: { role: 'user', content: 'fix the widget' } }];
for (let i = 0; i < 20; i++) {
records.push({
type: 'assistant',
uuid: `a${i}`,
message: { id: `m${i}`, role: 'assistant', content: [{ type: 'tool_use', id: `t${i}`, name: 'Bash', input: { command: `rg -g '!*.md' other${i} .` } }] }
});
records.push({ type: 'user', uuid: `r${i}`, promptId: 'p1', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: `t${i}`, content: 'ok' }] } });
}
fs.writeFileSync(transcript, `${records.map(record => JSON.stringify(record)).join('\n')}\n`);
const base = { session_id: 'latency', transcript_path: transcript, cwd: root, hook_event_name: 'PreToolUse', tool_use_id: 'pending' };
const edit = (file, oldString, newString, extra = {}) => ({
...base,
tool_name: 'Edit',
tool_input: { file_path: path.join(root, file), old_string: oldString, new_string: newString, ...extra }
});
const bash = command => ({ ...base, tool_name: 'Bash', tool_input: { command } });
const cases = [
['Shell command, first of session', bash('npm test'), []],
['Shell command, routine gate passed', bash('npm run build'), [bash('npm test')]],
['Edit of a file already checked', edit('src/widget.js', ' return x;', ' return x + 1;'), [edit('src/widget.js', ' return x;', ' return x + 1;')]],
['First edit, code, denied', edit('src/widget.js', ' return x;', ' return x + 1;'), []],
['First edit, comment-only pass', edit('src/widget.js', '// note', '// note!', { replace_all: true }), []],
['First edit, CLAUDE.md, denied', edit('CLAUDE.md', '- be careful', '- be very careful'), []],
['First edit, 406 KiB file, denied', edit('src/big.js', ' return x;', ' return x + 1;'), []]
];
return { root, cases };
}
// --- measurement ---
const SETUP_PROBE = 'const hook = require(process.argv[1]); for (const step of JSON.parse(process.argv[2])) hook.run(JSON.stringify(step));';
const TIMED_PROBE = [
"const { performance } = require('perf_hooks');",
'const started = performance.now();',
'require(process.argv[1]).run(process.argv[2]);',
'process.stdout.write(String(performance.now() - started));'
].join('\n');
function sampleOnce(hookFile, payload, setup, root, workDir) {
const stateDir = fs.mkdtempSync(path.join(workDir, 'state-'));
try {
const env = { PATH: process.env.PATH || '', HOME: root, USERPROFILE: root, GATEGUARD_STATE_DIR: stateDir, CLAUDE_PROJECT_DIR: root };
if (process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot;
if (setup.length > 0) execFileSync(process.execPath, ['-e', SETUP_PROBE, hookFile, JSON.stringify(setup)], { env, stdio: 'ignore' });
return Number(execFileSync(process.execPath, ['-e', TIMED_PROBE, hookFile, JSON.stringify(payload)], { env, encoding: 'utf8' }));
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
}
/** Fresh-process require()+run() samples per call type and hook, interleaved in a shuffled order each round. */
function measure(hooks, { runs = DEFAULT_RUNS, seed = 7, onRound = () => {} } = {}) {
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-latency-'));
try {
const { root, cases } = buildFixture(workDir);
const random = xorshift(seed);
const samples = Object.fromEntries(cases.map(([name]) => [name, Object.fromEntries(hooks.map(hook => [hook.label, []]))]));
for (let round = 0; round < runs; round++) {
for (const [name, payload, setup] of cases) {
const order = hooks.slice();
for (let i = order.length - 1; i > 0; i--) {
const j = random(i + 1);
[order[i], order[j]] = [order[j], order[i]];
}
for (const hook of order) samples[name][hook.label].push(sampleOnce(hook.file, payload, setup, root, workDir));
}
onRound(round + 1);
}
return samples;
} finally {
fs.rmSync(workDir, { recursive: true, force: true });
}
}
/** p50/p90 per hook and the bootstrap interval of each baseline-to-working-tree difference. */
function summarize(samples, labels) {
const [working, ...baselines] = labels;
return Object.entries(samples).map(([name, byHook]) => ({
name,
quantiles: Object.fromEntries(labels.map(label => [label, { p50: quantile(byHook[label], 0.5), p90: quantile(byHook[label], 0.9) }])),
comparisons: baselines.map(baseline => {
const p50 = bootstrapDifference(byHook[baseline], byHook[working], 0.5);
const p90 = bootstrapDifference(byHook[baseline], byHook[working], 0.9);
return { baseline, p50, p90, p50Verdict: verdict(p50), p90Verdict: verdict(p90) };
})
}));
}
function renderMarkdown(rows, labels, runs) {
const fmt = value => value.toFixed(1);
const lines = [
`${runs} fresh processes per call type and hook, interleaved; require() + run() in ms; 95% bootstrap intervals of the working tree minus the baseline.`,
'',
`| Call | ${labels.map(label => `${label} p50 / p90`).join(' | ')} |`,
`| --- | ${labels.map(() => '---:').join(' | ')} |`,
...rows.map(row => `| ${row.name} | ${labels.map(label => `${fmt(row.quantiles[label].p50)} / ${fmt(row.quantiles[label].p90)}`).join(' | ')} |`),
''
];
for (const baseline of labels.slice(1)) {
lines.push(`| Call | p50 change vs ${baseline} | p90 change vs ${baseline} |`, '| --- | --- | --- |');
for (const row of rows) {
const c = row.comparisons.find(entry => entry.baseline === baseline);
lines.push(`| ${row.name} | [${fmt(c.p50[0])}, ${fmt(c.p50[1])}] ${c.p50Verdict} | [${fmt(c.p90[0])}, ${fmt(c.p90[1])}] ${c.p90Verdict} |`);
}
lines.push('');
}
return `${lines.join('\n')}`;
}
// --- CLI ---
const USAGE = [
'Usage: node scripts/dev/gateguard-latency.js [--baseline <ref>]... [--runs <n>] [--json]',
'',
' --baseline <ref> git ref whose hook is compared with the working tree (repeatable;',
` default ${DEFAULT_BASELINE})`,
` --runs <n> fresh processes per call type and hook (default ${DEFAULT_RUNS})`,
' --json print samples and summary as JSON'
].join('\n');
function parseArgs(argv) {
const options = { baselines: [], runs: DEFAULT_RUNS, format: 'markdown', help: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--json') options.format = 'json';
else if (arg === '--help' || arg === '-h') options.help = true;
else if (arg === '--baseline' && i + 1 < argv.length) options.baselines.push(argv[++i]);
else if (arg === '--runs' && i + 1 < argv.length && /^[1-9]\d*$/.test(argv[i + 1])) options.runs = Number(argv[++i]);
else throw new Error(`unknown or incomplete argument: ${arg}\n\n${USAGE}`);
}
if (options.baselines.length === 0) options.baselines.push(DEFAULT_BASELINE);
return options;
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
process.stdout.write(`${USAGE}\n`);
return;
}
const { materializeHook } = require('./gateguard-eval');
const materialized = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-latency-hooks-'));
try {
const hooks = [{ label: 'working tree', file: HOOK_FILE }].concat(
options.baselines.map((ref, index) => ({ label: ref, file: materializeHook(ref, path.join(materialized, String(index))) }))
);
const samples = measure(hooks, { runs: options.runs, onRound: round => process.stderr.write(`\rround ${round}/${options.runs}`) });
process.stderr.write('\n');
const labels = hooks.map(hook => hook.label);
const rows = summarize(samples, labels);
process.stdout.write(options.format === 'json' ? `${JSON.stringify({ runs: options.runs, samples, rows }, null, 2)}\n` : `${renderMarkdown(rows, labels, options.runs)}\n`);
} finally {
fs.rmSync(materialized, { recursive: true, force: true });
}
}
if (require.main === module) {
try {
main();
} catch (error) {
process.stderr.write(`[gateguard-latency] ${error.message}\n`);
process.exitCode = 2;
}
}
module.exports = { quantile, bootstrapDifference, verdict, summarize, measure, parseArgs };
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { METRICS_FILE_NAME, isMetricsEvent } = require('./lib/gateguard-metrics');
const DENIAL_DECISIONS = new Set(['deny', 'routine-deny', 'destructive-deny']);
const FIRST_TOUCH_DECISIONS = ['deny', 'credit', 'sibling', 'cap', 'trivial'];
const NEAR_MISS_PREFIX = 'near-miss:';
const TOP_QUESTIONS = 10;
function showHelp() {
console.log(`
Usage: node scripts/gateguard-report.js [options]
Summarises GateGuard decision metrics (written when GATEGUARD_METRICS=1).
Options:
--dir <path> Directory holding metrics.jsonl (default: GATEGUARD_STATE_DIR or ~/.gateguard)
--json Emit machine-readable JSON
--help Show this help text
`);
}
function parseArgs(argv) {
const options = { dir: null, json: false, help: false };
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === '--json') {
options.json = true;
} else if (arg === '--help' || arg === '-h') {
options.help = true;
} else if (arg === '--dir') {
const value = argv[index + 1];
if (!value || value.startsWith('--')) throw new Error('Missing value for --dir');
options.dir = value;
index++;
} else if (arg.startsWith('--dir=')) {
const value = arg.slice('--dir='.length);
if (!value) throw new Error('Missing value for --dir');
options.dir = value;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function defaultDir() {
return process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
}
/** Valid events from `metrics.jsonl.1` then `metrics.jsonl` in `dir`, with the count of skipped lines. */
function loadMetrics(dir) {
const current = path.join(dir, METRICS_FILE_NAME);
const files = [`${current}.1`, current].filter(file => {
try {
return fs.statSync(file).isFile();
} catch (_) {
return false;
}
});
const events = [];
let skipped = 0;
for (const file of files) {
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line.trim()) continue;
let parsed = null;
try {
parsed = JSON.parse(line);
} catch (_) {
parsed = null;
}
if (isMetricsEvent(parsed)) {
events.push(parsed);
} else {
skipped++;
}
}
}
return { dir, files, events, skipped };
}
function increment(counts, key) {
counts[key] = (counts[key] || 0) + 1;
}
function byCount(counts) {
return Object.fromEntries(Object.entries(counts).sort(([a, x], [b, y]) => y - x || (a < b ? -1 : a > b ? 1 : 0)));
}
function ratio(count, total) {
return total > 0 ? count / total : null;
}
function summarizeGroup(events) {
const decisions = {};
const denialClasses = {};
const denialReasons = {};
const questions = {};
const firstTouchCounts = Object.fromEntries(FIRST_TOUCH_DECISIONS.map(decision => [decision, 0]));
let denials = 0;
let nearMiss = 0;
for (const event of events) {
increment(decisions, event.decision);
if (Object.hasOwn(firstTouchCounts, event.decision)) firstTouchCounts[event.decision]++;
if (DENIAL_DECISIONS.has(event.decision)) {
denials++;
increment(denialClasses, event.class || 'shell');
increment(denialReasons, event.reason || 'unknown');
}
if (event.decision === 'deny') {
if (typeof event.reason === 'string' && event.reason.startsWith(NEAR_MISS_PREFIX)) nearMiss++;
for (const id of event.questions || []) increment(questions, id);
}
}
const firstTouchTotal = FIRST_TOUCH_DECISIONS.reduce((sum, decision) => sum + firstTouchCounts[decision], 0);
return {
events: events.length,
first: events.length > 0 ? events[0].ts : null,
last: events.length > 0 ? events[events.length - 1].ts : null,
decisions: byCount(decisions),
denials: { total: denials, byClass: byCount(denialClasses), byReason: byCount(denialReasons) },
nearMiss: { count: nearMiss, of: firstTouchCounts.deny, share: ratio(nearMiss, firstTouchCounts.deny) },
firstTouch: {
total: firstTouchTotal,
counts: firstTouchCounts,
rates: Object.fromEntries(FIRST_TOUCH_DECISIONS.map(decision => [decision, ratio(firstTouchCounts[decision], firstTouchTotal)]))
},
routineReadonlyPasses: decisions['routine-readonly'] || 0,
topQuestions: Object.entries(byCount(questions)).slice(0, TOP_QUESTIONS)
};
}
/** Totals and per-session summaries of metrics events. */
function summarize(events) {
const bySession = new Map();
for (const event of events) {
if (!bySession.has(event.session)) bySession.set(event.session, []);
bySession.get(event.session).push(event);
}
const sessions = {};
for (const [session, sessionEvents] of bySession) sessions[session] = summarizeGroup(sessionEvents);
return { total: summarizeGroup(events), sessions };
}
function percent(value) {
return value === null ? 'n/a' : `${(value * 100).toFixed(1)}%`;
}
function counts(entries) {
const list = Array.isArray(entries) ? entries : Object.entries(entries);
return list.length > 0 ? list.map(([key, count]) => `${key} ${count}`).join(', ') : 'none';
}
function formatGroup(title, group) {
const rates = FIRST_TOUCH_DECISIONS.map(decision => `${decision} ${percent(group.firstTouch.rates[decision])}`).join(', ');
return [
title,
` decisions: ${counts(group.decisions)}`,
` denials: ${group.denials.total}`,
` denials by class: ${counts(group.denials.byClass)}`,
` denials by reason: ${counts(group.denials.byReason)}`,
` near-miss share of first-touch denials: ${percent(group.nearMiss.share)} (${group.nearMiss.count} of ${group.nearMiss.of})`,
` first-touch outcomes (${group.firstTouch.total}): ${rates}`,
` routine read-only passes: ${group.routineReadonlyPasses}`,
` most-asked questions: ${counts(group.topQuestions)}`
].join('\n');
}
/** Plain-text report for `loadMetrics` output plus its `summarize` report. */
function formatReport({ dir, events, skipped, report }) {
const sessionIds = Object.keys(report.sessions);
const header =
`GateGuard metrics${dir ? ` in ${dir}` : ''}: ${events.length} events in ${sessionIds.length} sessions` +
(skipped > 0 ? ` (${skipped} malformed lines skipped)` : '');
const sections = [header, '', formatGroup('Total', report.total)];
for (const session of sessionIds) {
const group = report.sessions[session];
sections.push('', formatGroup(`Session ${session} (${group.first} to ${group.last})`, group));
}
return `${sections.join('\n')}\n`;
}
function main() {
try {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
showHelp();
return;
}
const loaded = loadMetrics(options.dir || defaultDir());
const report = summarize(loaded.events);
if (options.json) {
const output = { dir: loaded.dir, files: loaded.files, events: loaded.events.length, skipped: loaded.skipped, ...report };
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
} else if (loaded.events.length === 0) {
process.stdout.write(
`No GateGuard metrics in ${loaded.dir}` +
(loaded.skipped > 0 ? ` (${loaded.skipped} malformed lines skipped)` : '') +
'. Set GATEGUARD_METRICS=1 to record gate decisions.\n'
);
} else {
process.stdout.write(formatReport({ ...loaded, report }));
}
} catch (error) {
process.stderr.write(`Error: ${error.message}\n`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { formatReport, loadMetrics, parseArgs, summarize };
+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;
}
};
+41
View File
@@ -0,0 +1,41 @@
'use strict';
const fs = require('fs');
const DEFAULT_TRANSCRIPT_TAIL_BYTES = 256 * 1024;
/** Last `tailBytes` of a file as UTF-8 text, with whether earlier bytes were skipped; null when unreadable. */
function readFileTail(filePath, tailBytes) {
let fd;
try {
fd = fs.openSync(filePath, 'r');
} catch {
return null;
}
try {
const size = fs.fstatSync(fd).size;
const start = Math.max(0, size - tailBytes);
const length = size - start;
if (length <= 0) {
return { text: '', truncated: false };
}
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return {
text: buffer.toString('utf8', 0, bytesRead),
truncated: start > 0
};
} catch {
return null;
} finally {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
module.exports = { DEFAULT_TRANSCRIPT_TAIL_BYTES, readFileTail };
+457
View File
@@ -0,0 +1,457 @@
'use strict';
const { isSpace, isIdentChar, isLetter, shCodeLines, psCodeLines, batchCodeLines, normalizeShellLine } = require('./gateguard-code-lexer');
const { MAX_FILE_CHARS, lineEndAt, lineStartOf, cFamilyStartsFresh, pythonStartsFresh, shStartsFresh, psStartsFresh, batchStartsFresh, trivialInFile } = require('./gateguard-file-context');
// see docs/gateguard/change-profile.md#change-profile
const MAX_SIDE_BYTES = 64 * 1024;
const MAX_TOTAL_BYTES = 256 * 1024;
const MAX_EDITS = 64;
const UNKNOWN_PROFILE = Object.freeze({ known: false, language: null, touchesPublicSurface: true, touchesData: true, trivial: false });
const LANGUAGE_BY_EXT = new Map([
['.js', 'js'], ['.mjs', 'js'], ['.cjs', 'js'], ['.jsx', 'js'], ['.ts', 'js'], ['.tsx', 'js'], ['.mts', 'js'], ['.cts', 'js'],
['.py', 'python'], ['.pyi', 'python'],
['.go', 'go'],
['.rs', 'rust'],
['.java', 'java'],
['.kt', 'kotlin'], ['.kts', 'kotlin'],
['.cs', 'csharp'],
['.c', 'c'], ['.h', 'c'],
['.cc', 'cpp'], ['.cpp', 'cpp'], ['.cxx', 'cpp'], ['.hpp', 'cpp'], ['.hh', 'cpp'], ['.hxx', 'cpp'],
['.sh', 'shell'], ['.bash', 'shell'], ['.zsh', 'shell'],
['.ps1', 'powershell'], ['.psm1', 'powershell'],
['.bat', 'batch'], ['.cmd', 'batch']
]);
// --- Lexing ---
// see docs/gateguard/change-profile.md#trivial-edits
const C_FAMILY = { lineComment: '//', blockComment: true, quotes: '"\'', indentSensitive: false };
const C_CONTEXT = { ...C_FAMILY, startsFresh: cFamilyStartsFresh };
const LEX_SPECS = {
js: { ...C_CONTEXT, flavor: 'js', forbiddenCode: '`/', jsx: true },
go: { ...C_CONTEXT, flavor: 'go', forbiddenCode: '`' },
rust: { ...C_CONTEXT, flavor: 'rust', quotes: '"', rustChars: true, rawPrefixes: 'r#', rustDocs: true },
java: { ...C_CONTEXT, flavor: 'java', tripleQuotes: true },
kotlin: { ...C_CONTEXT, flavor: 'kotlin', tripleQuotes: true, noDollarInStrings: true },
csharp: { ...C_CONTEXT, flavor: 'csharp', tripleQuotes: true, rawPrefixes: '@$' },
c: { ...C_CONTEXT, flavor: 'c', rawPrefixes: 'R' },
cpp: { ...C_CONTEXT, flavor: 'c', rawPrefixes: 'R' },
python: { lineComment: '#', blockComment: false, quotes: '"\'', indentSensitive: true, tripleQuotes: true, fStrings: true, startsFresh: pythonStartsFresh },
shell: { lexer: shCodeLines, normalize: line => normalizeShellLine(line, '\\'), startsFresh: shStartsFresh },
powershell: { lexer: psCodeLines, normalize: line => normalizeShellLine(line, '`'), startsFresh: psStartsFresh },
batch: { lexer: batchCodeLines, normalize: line => line, startsFresh: batchStartsFresh }
};
const TS_EXTS = new Set(['.ts', '.mts', '.cts']);
const TS_SPEC = { ...LEX_SPECS.js, jsx: false };
function lexSpecFor(filePath, language) {
if (language !== 'js') return LEX_SPECS[language];
const base = baseName(filePath);
return TS_EXTS.has(base.slice(base.lastIndexOf('.'))) ? TS_SPEC : LEX_SPECS.js;
}
// --- Public surface ---
function leadingWord(text, word) {
return text.startsWith(word) && !isIdentChar(text[word.length]);
}
function containsWord(text, word) {
for (let at = text.indexOf(word); at !== -1; at = text.indexOf(word, at + 1)) {
if (!isIdentChar(text[at - 1]) && !isIdentChar(text[at + word.length])) return true;
}
return false;
}
function identifierAt(text, start) {
let end = start;
while (end < text.length && isIdentChar(text[end])) end++;
return text.slice(start, end);
}
function skipSpaces(text, at) {
while (at < text.length && isSpace(text[at])) at++;
return at;
}
function isUpper(ch) {
return ch !== undefined && ch >= 'A' && ch <= 'Z';
}
function jsLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
return leadingWord(t, 'export') || containsWord(t, 'exports') || leadingWord(t, 'public') || leadingWord(t, 'declare');
}
function pythonNameIsPublic(name) {
if (!name) return false;
return !name.startsWith('_') || (name.length > 4 && name.startsWith('__') && name.endsWith('__'));
}
function pythonLineIsSurface(line, index) {
const start = skipSpaces(line, 0);
const t = line.slice(start);
if (t.includes('__all__')) return true;
for (const keyword of ['def', 'class']) {
if (leadingWord(t, keyword)) return pythonNameIsPublic(identifierAt(t, skipSpaces(t, keyword.length)));
}
if (leadingWord(t, 'async')) {
const rest = t.slice(skipSpaces(t, 5));
if (leadingWord(rest, 'def')) return pythonNameIsPublic(identifierAt(rest, skipSpaces(rest, 3)));
}
if (start !== 0) return false;
const name = identifierAt(t, 0);
const rest = skipSpaces(t, name.length);
const after = t[rest];
const assigned = (after === '=' && t[rest + 1] !== '=') || (after === ':' && skipSpaces(t, rest + 1) < t.length);
if (!name || !isLetter(name[0]) || !assigned) return false;
return index > 0 ? pythonNameIsPublic(name) : name === name.toUpperCase();
}
function goLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
if (isUpper(t[0])) return true;
if (leadingWord(t, 'package')) return true;
for (const keyword of ['type', 'var', 'const']) {
if (leadingWord(t, keyword)) return isUpper(t[skipSpaces(t, keyword.length)]);
}
if (!leadingWord(t, 'func')) return false;
let at = skipSpaces(t, 4);
if (t[at] === '(') {
const close = t.indexOf(')', at);
if (close === -1) return true;
at = skipSpaces(t, close + 1);
}
return isUpper(t[at]);
}
const RUST_SURFACE_ATTRIBUTES = ['#[macro_export', '#[derive', '#[repr', '#[no_mangle', '#[unsafe(no_mangle', '#[export_name', '#[unsafe(export_name'];
function rustLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
return leadingWord(t, 'pub') || leadingWord(t, 'impl') || leadingWord(t, 'trait') || leadingWord(t, 'extern') ||
RUST_SURFACE_ATTRIBUTES.some(attribute => t.startsWith(attribute));
}
function shellFunctionName(t) {
let end = 0;
while (end < t.length && (isIdentChar(t[end]) || t[end] === '-' || t[end] === ':' || t[end] === '.')) end++;
if (end === 0) return false;
const open = skipSpaces(t, end);
return t[open] === '(' && t[skipSpaces(t, open + 1)] === ')';
}
function shellLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
if (leadingWord(t, 'export') || leadingWord(t, 'function')) return true;
if (leadingWord(t, 'declare') || leadingWord(t, 'typeset')) {
const flags = t.slice(skipSpaces(t, 7));
return flags[0] === '-' && identifierAt(flags, 1).includes('x');
}
return shellFunctionName(t);
}
const POWERSHELL_SURFACE_WORDS = ['function', 'filter', 'workflow', 'class', 'enum', 'param', 'export-modulemember', '[cmdletbinding', '$global:'];
function powershellLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0)).toLowerCase();
return POWERSHELL_SURFACE_WORDS.some(word => t.startsWith(word) && (word.endsWith(':') || !isIdentChar(t[word.length])));
}
const SURFACE_BY_LANGUAGE = {
js: jsLineIsSurface,
python: pythonLineIsSurface,
go: goLineIsSurface,
rust: rustLineIsSurface,
shell: shellLineIsSurface,
powershell: powershellLineIsSurface
};
function baseName(filePath) {
return String(filePath).replace(/\\/g, '/').split('/').pop().toLowerCase();
}
function wholeFileIsSurface(filePath, language) {
const base = baseName(filePath);
return (language === 'js' && (base.endsWith('.d.ts') || base.endsWith('.d.mts') || base.endsWith('.d.cts'))) ||
(language === 'python' && base === '__init__.py');
}
function textTouchesSurface(text, lineIsSurface) {
let index = 0;
let start = 0;
for (let i = 0; i <= text.length; i++) {
if (i === text.length || text[i] === '\n' || text[i] === '\r') {
if (lineIsSurface(text.slice(start, i), index)) return true;
index++;
start = i + 1;
}
}
return false;
}
// --- Container members ---
// see docs/gateguard/change-profile.md#public-surface
const MAX_OPENER_SCAN = 4 * 1024 * 1024;
const JS_BODY_KINDS = ['function', 'async'];
const JS_MEMBER_KINDS = ['class', 'abstract'];
const JS_CONTAINER_KINDS = ['interface', 'enum', 'type', 'namespace', 'module', 'const', 'let', 'var', 'declare', '{'];
const RUST_CONTAINER_KINDS = ['enum', 'struct', 'union', 'trait', 'use'];
function indentOf(line) {
return line.slice(0, skipSpaces(line, 0));
}
function lineAt(text, start) {
const end = lineEndAt(text, start);
return { text: text.slice(start, end), next: end + (text[end] === '\r' && text[end + 1] === '\n' ? 2 : 1) };
}
function isCommentLine(t) {
return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('#') || t.startsWith('@');
}
function enclosingOpener(text, at, budget) {
let end = lineStartOf(text, at);
while (end > 0) {
let last = end - 1;
if (text[last] === '\n' && text[last - 1] === '\r') last--;
const start = lineStartOf(text, last);
budget.left -= end - start;
if (budget.left < 0) return null;
const line = text.slice(start, last);
end = start;
if (!line.trim() || isSpace(line[0])) continue;
if ('}])'.includes(line[0])) return null;
if (isCommentLine(line)) continue;
return { line, start };
}
return null;
}
function jsContainerKind(opener) {
const t = opener.trimEnd();
const bodyLike = t.includes('=>') || containsWord(t, 'function');
if (containsWord(t, 'exports')) return !bodyLike && /[{[(]$/.test(t) ? 'all' : null;
if (!leadingWord(t, 'export')) return null;
let rest = t.slice(skipSpaces(t, 6));
if (leadingWord(rest, 'default')) rest = rest.slice(skipSpaces(rest, 7));
if (JS_BODY_KINDS.some(word => leadingWord(rest, word))) return null;
if (JS_MEMBER_KINDS.some(word => leadingWord(rest, word))) return 'members';
if (rest.startsWith('{')) return 'all';
if (!JS_CONTAINER_KINDS.some(word => leadingWord(rest, word))) return null;
return bodyLike ? null : 'all';
}
function containerKind(opener, language) {
if (language === 'js') return jsContainerKind(opener);
if (language === 'python') {
if (opener.includes('__all__')) return 'all';
return leadingWord(opener, 'class') && pythonNameIsPublic(identifierAt(opener, skipSpaces(opener, 5))) ? 'fields' : null;
}
if (language === 'rust' && leadingWord(opener, 'pub')) {
const words = opener.split(/[^A-Za-z0-9_]+/);
return RUST_CONTAINER_KINDS.some(word => words.includes(word)) ? 'members' : null;
}
return null;
}
function memberIndent(text, opener) {
let at = lineAt(text, opener.start).next;
while (at < text.length) {
const line = lineAt(text, at);
if (line.text.trim()) return indentOf(line.text);
at = line.next;
}
return null;
}
function isMemberLine(line, indent, kind) {
if (!indent || indentOf(line) !== indent) return false;
const t = line.slice(indent.length);
if (!t || '}])'.includes(t[0]) || isCommentLine(t)) return false;
if (kind !== 'fields') return true;
const name = identifierAt(t, 0);
const after = t[skipSpaces(t, name.length)];
return pythonNameIsPublic(name) && isLetter(name[0]) && (after === ':' || (after === '=' && t[skipSpaces(t, name.length) + 1] !== '='));
}
function containerTouchesSurface(pairs, fileText, language) {
if (typeof fileText !== 'string' || fileText.length > MAX_FILE_CHARS) return false;
const budget = { left: MAX_OPENER_SCAN };
for (const [oldString, newString] of pairs) {
const at = oldString ? fileText.indexOf(oldString) : -1;
if (at === -1) continue;
const opener = enclosingOpener(fileText, at, budget);
if (budget.left < 0) return true;
const kind = opener ? containerKind(opener.line, language) : null;
if (!kind) continue;
if (kind === 'all') return true;
const indent = memberIndent(fileText, opener);
const start = lineStartOf(fileText, at);
const end = lineEndAt(fileText, at + oldString.length);
const before = fileText.slice(start, end);
const after = `${fileText.slice(start, at)}${newString}${fileText.slice(at + oldString.length, end)}`;
if (`${before}\n${after}`.split(/\r\n|\r|\n/).some(line => isMemberLine(line, indent, kind))) return true;
}
return false;
}
// --- Data handling ---
const DATA_WORDS = new Set([
'json', 'jsonl', 'ndjson', 'yaml', 'yml', 'csv', 'tsv', 'xml', 'toml', 'ini', 'parquet', 'avro', 'arrow', 'pickle', 'protobuf',
'serde', 'serialize', 'serializer', 'serialise', 'deserialize', 'deserializer', 'deserialise', 'marshal', 'unmarshal',
'schema', 'schemas', 'sql', 'sqlite', 'sqlite3', 'postgres', 'mysql', 'mongo', 'mongodb', 'database', 'db', 'cursor',
'migration', 'migrations', 'orm', 'dataframe', 'pandas',
'date', 'dates', 'datetime', 'timestamp', 'timestamps', 'strftime', 'strptime', 'isoformat', 'iso', 'iso8601', 'timezone',
'tz', 'utc', 'epoch', 'dayjs', 'moment', 'luxon', 'instant',
'fs', 'fopen', 'fread', 'fwrite', 'fgets', 'ioutil', 'bufio', 'pathlib', 'shutil', 'readfile', 'writefile', 'fstream',
'ifstream', 'ofstream',
'chrono', 'zoneinfo', 'pytz', 'tzinfo', 'temporal', 'base64', 'encoding', 'charset', 'codec', 'msgpack', 'bson', 'cbor',
'xlsx', 'storage', 'cookie', 'cookies', 'redis', 'prisma', 'knex', 'sequelize', 'mongoose', 'typeorm', 'drizzle',
'sqlalchemy', 'jdbc', 'firestore', 'dynamodb'
]);
const DATA_PAIRS = [
['read', 'file'], ['write', 'file'], ['append', 'file'], ['open', 'file'], ['read', 'text'], ['write', 'text'],
['read', 'bytes'], ['write', 'bytes'], ['read', 'to'], ['read', 'lines'], ['write', 'lines'], ['read', 'all'],
['file', 'reader'], ['file', 'writer'], ['file', 'stream'], ['read', 'csv'], ['to', 'csv'],
['time', 'now'], ['time', 'parse'], ['time', 'unix'], ['time', 'since'], ['time', 'format'], ['time', 'time'],
['time', 'duration'], ['time', 'zone'], ['time', 'stamp'], ['system', 'time']
];
const SQL_PAIRS = [['select', 'from'], ['insert', 'into'], ['delete', 'from'], ['create', 'table'], ['alter', 'table'], ['drop', 'table'], ['update', 'set']];
function* words(text) {
let word = '';
let prev = '';
for (let i = 0; i <= text.length; i++) {
const ch = text[i];
const alnum = ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9'));
const boundary = alnum && word && (
(isUpper(ch) && !isUpper(prev) && !(prev >= '0' && prev <= '9')) ||
(isUpper(ch) && isUpper(prev) && text[i + 1] >= 'a' && text[i + 1] <= 'z')
);
if (!alnum || boundary) {
if (word) yield { word: word.toLowerCase(), call: !alnum && text[skipSpaces(text, i)] === '(' };
word = alnum ? ch : '';
} else {
word += ch;
}
prev = ch;
}
}
const SHELL_LANGUAGES = new Set(['shell', 'powershell', 'batch']);
const SHELL_DATA_WORDS = new Set(['curl', 'wget', 'jq', 'yq', 'xmllint', 'psql', 'tee', 'iwr', 'irm', 'clixml']);
const SHELL_DATA_PAIRS = [['out', 'file'], ['get', 'content'], ['set', 'content'], ['add', 'content'], ['web', 'request'], ['rest', 'method']];
const NULL_TARGETS = ['/dev/null', '/dev/stdout', '/dev/stderr', '$null', 'nul'];
function redirectTargetIsFile(text, at) {
const start = skipSpaces(text, at);
const ch = text[start];
if (ch === undefined || ch === '\n' || ch === '\r' || ch === '&') return false;
const rest = text.slice(start, start + 12).toLowerCase();
return !NULL_TARGETS.some(target => rest.startsWith(target) && !isIdentChar(rest[target.length]) && rest[target.length] !== '.');
}
function shellRedirectsFile(text) {
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '>') {
const prev = text[i - 1];
if (prev === '-' || prev === '=' || prev === '>') continue;
let at = i + 1;
if (text[at] === '>' || text[at] === '|') at++;
if (text[at] === '=' || text[at] === '&') continue;
if (redirectTargetIsFile(text, at)) return true;
} else if (ch === '<') {
const next = text[i + 1];
if (text[i - 1] === '<' || next === '<' || next === '(' || next === '&' || next === '#' || next === '=') continue;
if (redirectTargetIsFile(text, i + 1)) return true;
}
}
return false;
}
function textTouchesData(text, language) {
const shell = SHELL_LANGUAGES.has(language);
if (shell && shellRedirectsFile(text)) return true;
const seen = new Set();
let previous = '';
for (const { word, call } of words(text)) {
if (DATA_WORDS.has(word)) return true;
if (shell && (SHELL_DATA_WORDS.has(word) || SHELL_DATA_PAIRS.some(([a, b]) => previous === a && word === b))) return true;
if (word === 'open' && call) return true;
if (DATA_PAIRS.some(([a, b]) => previous === a && word === b)) return true;
seen.add(word);
previous = word;
}
return SQL_PAIRS.some(([a, b]) => seen.has(a) && seen.has(b));
}
// --- Profile ---
/** Language of a target by extension, or null when unsupported. */
function languageFor(filePath) {
if (typeof filePath !== 'string' || !filePath) return null;
const base = baseName(filePath);
const dot = base.lastIndexOf('.');
if (dot <= 0) return null;
return LANGUAGE_BY_EXT.get(base.slice(dot)) || null;
}
function changeTexts(input) {
if (input.tool === 'Write') {
return typeof input.content === 'string' ? { sides: [input.content], pairs: [] } : null;
}
if (input.tool !== 'Edit' || !Array.isArray(input.edits) || input.edits.length === 0 || input.edits.length > MAX_EDITS) return null;
const pairs = [];
for (const entry of input.edits) {
if (!entry || typeof entry !== 'object') return null;
const oldString = entry.old_string;
const newString = entry.new_string;
if (typeof oldString !== 'string' || typeof newString !== 'string') return null;
pairs.push([oldString, newString, entry.replace_all === true]);
}
return { sides: pairs.flatMap(([oldString, newString]) => [oldString, newString]), pairs };
}
function withinBounds(sides) {
let total = 0;
for (const side of sides) {
if (side.length > MAX_SIDE_BYTES) return false;
const bytes = Buffer.byteLength(side, 'utf8');
if (bytes > MAX_SIDE_BYTES) return false;
total += bytes;
if (total > MAX_TOTAL_BYTES) return false;
}
return true;
}
/** Profile of one target's change from Edit/MultiEdit entries or Write content, judged against `fileText` when given; unknown on any doubt. */
function profileChange(input) {
try {
if (!input || typeof input !== 'object') return UNKNOWN_PROFILE;
const language = languageFor(input.filePath);
if (!language) return UNKNOWN_PROFILE;
const texts = changeTexts(input);
if (!texts || !withinBounds(texts.sides)) return UNKNOWN_PROFILE;
const lineIsSurface = SURFACE_BY_LANGUAGE[language];
const touchesPublicSurface = input.tool === 'Write' || !lineIsSurface || wholeFileIsSurface(input.filePath, language) ||
texts.sides.some(side => textTouchesSurface(side, lineIsSurface)) || containerTouchesSurface(texts.pairs, input.fileText, language);
const touchesData = texts.sides.some(side => textTouchesData(side, language));
const trivial = input.tool === 'Edit' && trivialInFile(texts.pairs, input.fileText, lexSpecFor(input.filePath, language));
return Object.freeze({ known: true, language, touchesPublicSurface, touchesData, trivial });
} catch (_) {
return UNKNOWN_PROFILE;
}
}
module.exports = { MAX_SIDE_BYTES, MAX_TOTAL_BYTES, MAX_EDITS, UNKNOWN_PROFILE, languageFor, profileChange };
+560
View File
@@ -0,0 +1,560 @@
'use strict';
// --- Code lines ---
// see docs/gateguard/change-profile.md#trivial-edits
function isSpace(ch) {
return ch === ' ' || ch === '\t' || ch === '\f' || ch === '\v';
}
function isIdentChar(ch) {
return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch === '_' || ch === '$');
}
function isLetter(ch) {
return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'));
}
function rustCharEnd(text, i) {
if (text[i + 1] === '\\') {
if (text[i + 2] === "'") return text[i + 3] === "'" ? i + 3 : -1;
const end = text.indexOf("'", i + 3);
return end !== -1 && end <= i + 12 ? end : -1;
}
const cp = text.codePointAt(i + 1);
if (cp === undefined || text[i + 1] === '\n' || text[i + 1] === '\r' || text[i + 1] === "'") return -1;
const end = i + (cp > 0xffff ? 3 : 2);
return text[end] === "'" ? end : -1;
}
function stringPrefixIsUnsafe(code, spec) {
const last = code[code.length - 1];
if (spec.rawPrefixes && last !== undefined && spec.rawPrefixes.includes(last)) return true;
if (!spec.fStrings || !isLetter(last)) return false;
const prefix = isLetter(code[code.length - 2]) ? code.slice(-2) : last;
return prefix.toLowerCase().includes('f');
}
// --- Directive comments ---
// see docs/gateguard/change-profile.md#directive-comments
const DIRECTIVE_LEADS = ['@', '#', '<', '+', '!', 'type:', 'global ', 'globals ', 'exported ', 'requires'];
const DIRECTIVE_RAW_LEADS = ['!', '/', 'go:', 'export ', 'extern ', 'line '];
const DIRECTIVE_WORDS = [
'lint', 'jshint', 'ts-', 'noqa', 'nosec', 'semgrep', 'sonar', 'noinspection', 'pragma', 'coding:', 'coding=', 'fmt:', 'isort:',
'mypy:', 'pyright:', 'pyre-', 'yapf:', 'ruff:', 'flake8', 'bandit', 'no cover', 'nocover', 'istanbul', 'c8 ', 'v8 ',
'prettier-', 'biome-', 'deno-', 'dprint-', 'rome-', 'webpack', 'vite-', '__pure__', '__no_side_effects__',
'__inline__', '@preserve', '@license', 'sourcemappingurl', 'sourceurl', '+build', 'cgo', 'clang-', 'cppcheck',
'coverity', 'iwyu', 'fallthrough', 'fall through', 'fall-through', 'fallthru', 'fall thru', 'shellcheck',
'psscriptanalyzer', 'suppress', 'jscs', 'checkstyle', 'spotbugs', 'findbugs', 'nopmd', 'codeql', 'lgtm', 'gitleaks',
'trufflehog', 'detect-secrets', 'allowlist', 'vim:', 'vi:', ' ex:', '-*-', 'code generated', 'do not edit',
'rubocop', 'swiftlint', 'resharper', '@ts-', '@type', '@typedef', '@template', '@satisfies', '@import', '@callback',
'@overload', '@enum', '@this', '@implements', '@extends', '@augments', '@jsx', '@flow', '@noflow', '@generated',
'compdef', 'autoload', 'output:'
];
function isDirectiveComment(body, spec) {
if (spec.rustDocs && (body[0] === '/' || body[0] === '!' || body[0] === '*')) return true;
if (DIRECTIVE_RAW_LEADS.some(lead => body.startsWith(lead))) return true;
const lower = body.toLowerCase();
let start = 0;
while (start < lower.length && (isSpace(lower[start]) || lower[start] === '*' || lower[start] === '/')) start++;
const lead = lower.slice(start);
if (DIRECTIVE_LEADS.some(word => lead.startsWith(word))) return true;
if (DIRECTIVE_WORDS.some(word => lower.includes(word))) return true;
return lower.includes('@') && lower.includes('{');
}
function directiveMarker(body, spec) {
return isDirectiveComment(body, spec) ? ` \u0001${Buffer.from(body, 'utf8').toString('hex')}\u0001 ` : '';
}
function endsWithContinuation(code) {
let end = code.length;
while (end > 0 && isSpace(code[end - 1])) end--;
return code[end - 1] === '\\';
}
const CODE_SPECIALS = new WeakMap();
function escapeClass(chars) {
return chars.replace(/[\\\]^-]/g, ch => `\\${ch}`);
}
function codeSpecials(spec) {
let specials = CODE_SPECIALS.get(spec);
if (!specials) {
const chars = `\n\r/${spec.lineComment === '#' ? '#' : ''}${spec.quotes || ''}${spec.forbiddenCode || ''}${spec.jsx !== undefined ? '<-' : ''}${spec.rustChars ? "'" : ''}`;
specials = { pattern: new RegExp(`[${escapeClass(chars)}]`, 'g'), chars: new Set(chars) };
CODE_SPECIALS.set(spec, specials);
}
return specials;
}
function nextSpecial(pattern, text, i) {
pattern.lastIndex = i;
const match = pattern.exec(text);
return match ? match.index : text.length;
}
const LINE_BREAK = /[\n\r]/g;
const BLOCK_SPECIALS = /[\n\r*/]/g;
const STRING_SPECIALS = new Map();
function stringSpecials(quote) {
let pattern = STRING_SPECIALS.get(quote);
if (!pattern) {
pattern = new RegExp(`[\\n\\r\\\\${escapeClass(quote)}]`, 'g');
STRING_SPECIALS.set(quote, pattern);
}
return pattern;
}
function lexCodeLines(text, spec) {
const specials = codeSpecials(spec);
const lines = [];
let code = '';
let state = 'code';
let quote = '';
let stringBody = '';
let commentStart = 0;
let tail = '';
const append = piece => {
code += piece;
tail = (tail + piece).slice(-2);
};
const endComment = end => {
const marker = directiveMarker(text.slice(commentStart, end), spec);
if (marker) append(marker);
};
const endLine = () => {
if (endsWithContinuation(code)) return false;
lines.push(code);
code = '';
tail = '';
return true;
};
if (spec.jsx !== undefined && text.startsWith('#!')) state = 'line';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '\n' || ch === '\r') {
if (state === 'string') return null;
if (state === 'line') {
if (text.slice(commentStart, i).trimEnd().endsWith('\\')) return null;
endComment(i);
state = 'code';
}
if (state === 'block') {
lines.push(code);
code = '';
tail = '';
} else if (!endLine()) {
return null;
}
if (ch === '\r' && text[i + 1] === '\n') i++;
continue;
}
if (state === 'line') {
i = nextSpecial(LINE_BREAK, text, i) - 1;
continue;
}
if (state === 'block') {
if (ch !== '*' && ch !== '/') {
i = nextSpecial(BLOCK_SPECIALS, text, i + 1) - 1;
continue;
}
if (ch === '*' && text[i + 1] === '/') {
endComment(i);
state = 'code';
append(' ');
i++;
} else if (ch === '/' && text[i + 1] === '*') {
return null;
}
continue;
}
if (state === 'string') {
if (ch !== '\\' && ch !== quote) {
const end = nextSpecial(stringSpecials(quote), text, i + 1);
const run = text.slice(i, end);
append(run);
stringBody += run;
i = end - 1;
continue;
}
append(ch);
if (ch === '\\') {
const next = text[i + 1];
if (next === undefined || next === '\n' || next === '\r') return null;
append(next);
i++;
} else if (ch === quote) {
if (spec.noDollarInStrings && stringBody.includes('$')) return null;
state = 'code';
} else {
stringBody += ch;
}
continue;
}
if (!specials.chars.has(ch)) {
const end = nextSpecial(specials.pattern, text, i + 1);
append(text.slice(i, end));
i = end - 1;
continue;
}
if (spec.lineComment === '//' && ch === '/' && text[i + 1] === '/') {
state = 'line';
commentStart = i + 2;
i++;
continue;
}
if (spec.lineComment === '#' && ch === '#') {
state = 'line';
commentStart = i + 1;
continue;
}
if (spec.blockComment && ch === '/' && text[i + 1] === '*') {
state = 'block';
commentStart = i + 2;
i++;
continue;
}
if (spec.forbiddenCode && spec.forbiddenCode.includes(ch)) return null;
if (spec.jsx && ch === '<' && (isLetter(text[i + 1]) || text[i + 1] === '/' || text[i + 1] === '>' || text[i + 1] === '!')) return null;
if (spec.jsx !== undefined && ch === '-' && text[i + 1] === '-' && text[i + 2] === '>') return null;
if (ch === "'" && spec.rustChars) {
const end = rustCharEnd(text, i);
append(end === -1 ? ch : text.slice(i, end + 1));
if (end !== -1) i = end;
continue;
}
if (spec.quotes.includes(ch)) {
if (spec.tripleQuotes && text[i + 1] === ch && text[i + 2] === ch) return null;
if (stringPrefixIsUnsafe(tail, spec)) return null;
state = 'string';
quote = ch;
stringBody = '';
append(ch);
continue;
}
append(ch);
}
if (state === 'string' || state === 'block') return null;
if (state === 'line') {
if (text.slice(commentStart).trimEnd().endsWith('\\')) return null;
endComment(text.length);
}
return endLine() ? lines : null;
}
function normalizeCodeLine(line, spec) {
let out = '';
let pendingSpace = false;
let quote = '';
let indent = '';
let leading = true;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (quote) {
out += ch;
if (ch === '\\' && i + 1 < line.length) out += line[++i];
else if (ch === quote) quote = '';
continue;
}
if (isSpace(ch)) {
if (leading) indent += ch;
else pendingSpace = true;
continue;
}
leading = false;
if (pendingSpace && out) out += ' ';
pendingSpace = false;
if (ch === "'" && spec.rustChars) {
const end = rustCharEnd(line, i);
out += end === -1 ? ch : line.slice(i, end + 1);
if (end !== -1) i = end;
continue;
}
if (spec.quotes.includes(ch)) quote = ch;
out += ch;
}
if (!out) return '';
return spec.indentSensitive ? `${indent}\u0000${out}` : out;
}
function hasSpaceAfterContinuation(line) {
let end = line.length;
while (end > 0 && isSpace(line[end - 1])) end--;
return end < line.length && line[end - 1] === '\\';
}
function codeSignature(text, spec) {
const lines = spec.lexer ? spec.lexer(text) : lexCodeLines(text, spec);
if (lines === null) return null;
const signature = [];
for (const line of lines) {
if (hasSpaceAfterContinuation(line)) return null;
const normalized = spec.normalize ? spec.normalize(line) : normalizeCodeLine(line, spec);
if (normalized === null) return null;
if (normalized) signature.push(normalized);
}
return signature;
}
function isTrivialEdit(oldString, newString, spec) {
if (spec.lineComment === '//' && (oldString.includes('??/') || newString.includes('??/'))) return false;
const before = codeSignature(oldString, spec);
if (before === null) return false;
const after = codeSignature(newString, spec);
if (after === null || before.length !== after.length) return false;
return before.every((line, index) => line === after[index]);
}
// --- Shell lexing ---
// see docs/gateguard/change-profile.md#shell-scripts
const SHELL_SPEC = Object.freeze({});
function isLineStart(text, i) {
return i === 0 || text[i - 1] === '\n' || text[i - 1] === '\r';
}
function hashStartsComment(text, i) {
if (isLineStart(text, i)) return true;
const prev = text[i - 1];
if (prev === ' ' || prev === '\t' || prev === ';') return true;
return '()|&<>'.includes(prev) ? null : false;
}
function shDoubleQuoteEnd(text, i) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '"') return j;
if (ch === '\n' || ch === '\r' || ch === '`') return -1;
if (ch === '\\') {
if (j + 1 >= text.length || text[j + 1] === '\n' || text[j + 1] === '\r') return -1;
j++;
} else if (ch === '$' && text[j + 1] === '(') {
return -1;
} else if (ch === '$' && text[j + 1] === '{') {
const close = text.indexOf('}', j + 2);
if (close === -1) return -1;
for (let k = j + 2; k < close; k++) {
if ('"\'\n\r'.includes(text[k])) return -1;
}
j = close;
}
}
return -1;
}
function hasLoneCarriageReturn(text) {
for (let at = text.indexOf('\r'); at !== -1; at = text.indexOf('\r', at + 1)) {
if (text[at + 1] !== '\n') return true;
}
return false;
}
function isBlank(ch) {
return ch === ' ' || ch === '\t';
}
function shCodeLines(text) {
if (text.includes('<<') || text.includes('`') || hasLoneCarriageReturn(text)) return null;
const lines = [];
let code = '';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '\n' || ch === '\r') {
lines.push(code);
code = '';
if (ch === '\r' && text[i + 1] === '\n') i++;
continue;
}
if (ch === '\\') {
const next = text[i + 1];
if (next === undefined || next === '\n' || next === '\r') return null;
code += ch + next;
i++;
continue;
}
if (ch === "'") {
if (text[i - 1] === '$') return null;
const end = text.indexOf("'", i + 1);
if (end === -1) return null;
const body = text.slice(i, end + 1);
if (body.includes('\n') || body.includes('\r')) return null;
code += body;
i = end;
continue;
}
if (ch === '"') {
const end = shDoubleQuoteEnd(text, i);
if (end === -1) return null;
code += text.slice(i, end + 1);
i = end;
continue;
}
if (ch === '#') {
const starts = hashStartsComment(text, i);
if (starts === null) return null;
if (starts) {
const start = i + 1;
while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++;
code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC);
continue;
}
}
code += ch;
}
lines.push(code);
return lines;
}
const PS_SMART_QUOTES = /[\u2018\u2019\u201a\u201b\u201c\u201d\u201e]/;
function psHashStartsComment(text, i) {
if (isLineStart(text, i)) return true;
const prev = text[i - 1];
return prev === ' ' || prev === '\t' || prev === ';';
}
function psQuoteEnd(text, i) {
const quote = text[i];
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '\n' || ch === '\r') return -1;
if (quote === '"' && ch === '`') {
if (j + 1 >= text.length || text[j + 1] === '\n' || text[j + 1] === '\r') return -1;
j++;
} else if (quote === '"' && ch === '$' && text[j + 1] === '(') {
return -1;
} else if (ch === quote) {
if (text[j + 1] !== quote) return j;
j++;
}
}
return -1;
}
function psCodeLines(text) {
if (text.includes('@"') || text.includes("@'") || PS_SMART_QUOTES.test(text)) return null;
const lines = [];
let code = '';
let block = false;
let blockStart = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '\n' || ch === '\r') {
lines.push(code);
code = '';
if (ch === '\r' && text[i + 1] === '\n') i++;
continue;
}
if (block) {
if (ch === '<' && text[i + 1] === '#') return null;
if (ch === '#' && text[i + 1] === '>') {
block = false;
code += directiveMarker(text.slice(blockStart, i), SHELL_SPEC) || ' ';
i++;
}
continue;
}
if (ch === '`') {
const next = text[i + 1];
if (next === undefined || next === '\n' || next === '\r') return null;
code += ch + next;
i++;
continue;
}
if (ch === "'" || ch === '"') {
const end = psQuoteEnd(text, i);
if (end === -1) return null;
code += text.slice(i, end + 1);
i = end;
continue;
}
if (ch === '<' && text[i + 1] === '#') {
if (!psHashStartsComment(text, i)) return null;
block = true;
blockStart = i + 2;
i++;
continue;
}
if (ch === '#') {
if (!psHashStartsComment(text, i)) return null;
const start = i + 1;
while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++;
code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC);
continue;
}
code += ch;
}
if (block) return null;
lines.push(code);
return lines;
}
function batchCodeLines(text) {
const lines = [];
for (const line of text.split(/\r\n|\r|\n/)) {
if (line.endsWith('^')) return null;
let start = 0;
while (isBlank(line[start])) start++;
if (line[start] === '@') start++;
while (isBlank(line[start])) start++;
const word = line.slice(start, start + 3).toLowerCase();
const after = line[start + 3];
if (word === 'rem' && (after === undefined || isBlank(after))) {
if (/[%^&|<>()]/.test(line)) return null;
lines.push(directiveMarker(line.slice(start + 4), SHELL_SPEC));
} else {
lines.push(line.trim() ? line : '');
}
}
return lines;
}
function normalizeShellLine(line, escape) {
let out = '';
let pendingSpace = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (isBlank(ch)) {
pendingSpace = true;
continue;
}
if (pendingSpace && out) out += ' ';
pendingSpace = false;
if (ch === escape && i + 1 < line.length) {
out += ch + line[++i];
} else if (ch === "'" || ch === '"') {
const end = escape === '`' ? psQuoteEnd(line, i) : ch === "'" ? line.indexOf("'", i + 1) : shDoubleQuoteEnd(line, i);
if (end === -1) return null;
out += line.slice(i, end + 1);
i = end;
} else {
out += ch;
}
}
return out;
}
module.exports = {
isSpace,
isIdentChar,
isLetter,
rustCharEnd,
isBlank,
hashStartsComment,
hasLoneCarriageReturn,
PS_SMART_QUOTES,
psHashStartsComment,
isTrivialEdit,
shCodeLines,
psCodeLines,
batchCodeLines,
normalizeShellLine
};
+735
View File
@@ -0,0 +1,735 @@
'use strict';
const { isSpace, isIdentChar, isLetter, rustCharEnd, isBlank, hashStartsComment, hasLoneCarriageReturn, PS_SMART_QUOTES, psHashStartsComment, isTrivialEdit } = require('./gateguard-code-lexer');
// --- File context ---
// see docs/gateguard/change-profile.md#file-context
const REPLACEMENT_PATTERN = /\$[$&'`<0-9]/;
const MAX_FILE_CHARS = 1024 * 1024;
const MAX_WINDOW_CHARS = 128 * 1024;
const MAX_CONTEXT_WORK = 8 * 1024 * 1024;
const JS_REGEX_KEYWORDS = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await']);
const JS_CONTROL_HEADERS = new Set(['if', 'while', 'for', 'with']);
const CPP_RAW_PREFIXES = new Set(['R', 'u8R', 'uR', 'UR', 'LR']);
function isNewline(ch) {
return ch === '\n' || ch === '\r';
}
function lineEndAt(text, i) {
let j = i;
while (j < text.length && !isNewline(text[j])) j++;
return j;
}
function quotedEnd(text, i, quote, multiline) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '\\') {
j++;
if (text[j] === '\r' && text[j + 1] === '\n') j++;
} else if (ch === quote) {
return j + 1;
} else if (isNewline(ch) && !multiline) {
return -1;
}
}
return -1;
}
function regexEnd(text, i) {
let inClass = false;
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (isNewline(ch)) return -1;
if (ch === '\\') {
if (j + 1 >= text.length || isNewline(text[j + 1])) return -1;
j++;
} else if (inClass) {
if (ch === ']') inClass = false;
} else if (ch === '[') {
inClass = true;
} else if (ch === '/') {
let k = j + 1;
while (isIdentChar(text[k])) k++;
return k;
}
}
return -1;
}
function nestedBlockEnd(text, i) {
let depth = 0;
for (let j = i; j + 1 < text.length; j++) {
if (text[j] === '/' && text[j + 1] === '*') {
depth++;
j++;
} else if (text[j] === '*' && text[j + 1] === '/') {
depth--;
j++;
if (depth === 0) return j + 1;
}
}
return -1;
}
function textBlockEnd(text, i, escapes) {
for (let j = i + 3; j < text.length; j++) {
if (escapes && text[j] === '\\') {
j++;
} else if (!escapes && text[j] === '$' && text[j + 1] === '{') {
return -1;
} else if (text.startsWith('"""', j)) {
let end = j + 3;
while (text[end] === '"') end++;
return end;
}
}
return -1;
}
function kotlinStringEnd(text, i) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '\\') j++;
else if (ch === '$' && text[j + 1] === '{') return -1;
else if (ch === '"') return j + 1;
else if (isNewline(ch)) return -1;
}
return -1;
}
function csharpStringEnd(text, i) {
let j = i;
let verbatim = false;
let interpolated = false;
while (text[j] === '@' || text[j] === '$') {
if (text[j] === '@') verbatim = true;
else interpolated = true;
j++;
}
if (text.startsWith('"""', j)) return -1;
for (let k = j + 1; k < text.length; k++) {
const ch = text[k];
if (ch === '"') {
if (verbatim && text[k + 1] === '"') {
k++;
continue;
}
return k + 1;
}
if (!verbatim && ch === '\\') {
k++;
} else if (!verbatim && isNewline(ch)) {
return -1;
} else if (interpolated && ch === '{') {
if (text[k + 1] === '{') {
k++;
continue;
}
let m = k + 1;
while (m < text.length && text[m] !== '}') {
if ('"\'{/\n\r'.includes(text[m])) return -1;
m++;
}
if (m >= text.length) return -1;
k = m;
}
}
return -1;
}
function cppRawEnd(text, quote) {
const open = text.indexOf('(', quote + 1);
if (open === -1 || open - quote - 1 > 16) return -1;
const delimiter = text.slice(quote + 1, open);
for (const ch of delimiter) {
if (isSpace(ch) || isNewline(ch) || '()\\"'.includes(ch)) return -1;
}
const end = text.indexOf(`)${delimiter}"`, open + 1);
return end === -1 ? -1 : end + delimiter.length + 2;
}
function rustRawEnd(text, i) {
let j = i + (text[i] === 'r' ? 1 : 2);
let hashes = '';
while (text[j] === '#') {
hashes += '#';
j++;
}
if (text[j] !== '"') return null;
const end = text.indexOf(`"${hashes}`, j + 1);
return end === -1 ? -1 : end + 1 + hashes.length;
}
function wordStart(text, i) {
let start = i;
while (start > 0 && isIdentChar(text[start - 1])) start--;
return start;
}
function cFamilyStringEnd(text, i, spec) {
const ch = text[i];
const flavor = spec.flavor;
if (ch === '`') {
if (flavor === 'kotlin') return quotedEnd(text, i, '`', false);
if (flavor !== 'go') return -1;
const end = text.indexOf('`', i + 1);
return end === -1 ? -1 : end + 1;
}
if (ch === "'") {
if (flavor === 'rust') {
const end = rustCharEnd(text, i);
return end === -1 ? null : end + 1;
}
if (flavor === 'c' && isIdentChar(text[i - 1]) && isIdentChar(text[i + 1])) {
const first = text[wordStart(text, i)];
if (first >= '0' && first <= '9') return null;
}
return quotedEnd(text, i, "'", false);
}
if (flavor === 'c') {
const start = wordStart(text, i);
const prefix = text.slice(start, i);
if (CPP_RAW_PREFIXES.has(prefix)) return cppRawEnd(text, i);
if (prefix.endsWith('R')) return -1;
}
if (flavor === 'java' && text.startsWith('"""', i)) return textBlockEnd(text, i, true);
if (flavor === 'kotlin') return text.startsWith('"""', i) ? textBlockEnd(text, i, false) : kotlinStringEnd(text, i);
if (flavor === 'csharp' && text.startsWith('"""', i)) return -1;
return quotedEnd(text, i, '"', flavor === 'rust');
}
function cFamilyStartsFresh(text, spec) {
const js = spec.flavor === 'js';
const frames = [{ template: false, depth: 0 }];
const parens = [];
let lastWord = '';
let lastSig = 'op';
let lineEnd = '';
let continued = false;
let i = js && text.startsWith('#!') ? lineEndAt(text, 0) : 0;
while (i < text.length) {
const frame = frames[frames.length - 1];
const ch = text[i];
const next = text[i + 1];
if (frame.template) {
if (ch === '\\') {
i += 2;
} else if (ch === '`') {
frames.pop();
lastSig = 'value';
i++;
} else if (ch === '$' && next === '{') {
frames.push({ template: false, depth: 0 });
lastSig = 'op';
i += 2;
} else {
i++;
}
continue;
}
if (isNewline(ch)) {
continued = lineEnd === '\\';
lineEnd = '';
i++;
continue;
}
if (isSpace(ch)) {
i++;
continue;
}
if (ch === '/' && next === '/') {
const end = lineEndAt(text, i);
if (text.slice(i, end).trimEnd().endsWith('\\')) return false;
i = end;
continue;
}
if (ch === '/' && next === '*') {
const end = spec.rustDocs ? nestedBlockEnd(text, i) : text.indexOf('*/', i + 2);
if (end === -1) return false;
i = spec.rustDocs ? end : end + 2;
lineEnd = '';
continue;
}
lineEnd = ch;
if (js) {
if (ch === '`') {
frames.push({ template: true, depth: 0 });
i++;
continue;
}
if (ch === '/') {
if (lastSig === 'op') {
const end = regexEnd(text, i);
if (end === -1) return false;
lineEnd = text[end - 1];
lastSig = 'value';
i = end;
continue;
}
if (lastSig === 'close' && /[/'"`]/.test(text.slice(i + 1, lineEndAt(text, i)))) return false;
lastSig = 'op';
i++;
continue;
}
if (ch === '<' && (next === '!' || (spec.jsx && (isLetter(next) || next === '/' || next === '>')))) return false;
if (ch === '-' && next === '-' && text[i + 2] === '>') return false;
if (frames.length > 1 && ch === '{') frame.depth++;
if (frames.length > 1 && ch === '}') {
if (frame.depth === 0) {
frames.pop();
i++;
continue;
}
frame.depth--;
}
}
if (spec.flavor === 'csharp' && (ch === '@' || ch === '$') && /^[@$]{1,3}"/.test(text.slice(i, i + 4))) {
const end = csharpStringEnd(text, i);
if (end === -1) return false;
lineEnd = '"';
i = end;
continue;
}
if (spec.flavor === 'rust' && (ch === 'r' || ((ch === 'b' || ch === 'c') && next === 'r')) && !isIdentChar(text[i - 1])) {
const end = rustRawEnd(text, i);
if (end === -1) return false;
if (end !== null) {
lineEnd = text[end - 1];
lastSig = 'value';
i = end;
continue;
}
}
if (ch === '"' || ch === "'" || ch === '`') {
const end = cFamilyStringEnd(text, i, spec);
if (end === -1) return false;
if (end !== null) {
lineEnd = text[end - 1];
lastSig = 'value';
i = end;
continue;
}
i++;
continue;
}
if (isIdentChar(ch)) {
let end = i;
while (end < text.length && isIdentChar(text[end])) end++;
lastWord = text.slice(i, end);
lastSig = js && JS_REGEX_KEYWORDS.has(lastWord) ? 'op' : 'value';
lineEnd = text[end - 1];
i = end;
continue;
}
if (ch === '(') {
parens.push(lastSig === 'value' && JS_CONTROL_HEADERS.has(lastWord));
lastSig = 'op';
} else if (ch === ')') {
lastSig = parens.pop() ? 'op' : 'value';
} else {
lastSig = ch === '}' ? 'close' : ch === ']' ? 'value' : 'op';
}
lastWord = '';
i++;
}
return frames.length === 1 && !continued;
}
function pythonStringEnd(text, i, prefix) {
const quote = text[i];
const triple = text[i + 1] === quote && text[i + 2] === quote;
const fString = prefix.toLowerCase().includes('f');
let depth = 0;
for (let j = i + (triple ? 3 : 1); j < text.length; j++) {
const ch = text[j];
if (fString && depth > 0) {
if (ch === '{') {
depth++;
} else if (ch === '}') {
depth--;
} else if ((ch === '"' || ch === "'") && ch !== quote) {
let k = j + 1;
while (k < text.length && text[k] !== ch) {
if (text[k] === quote || text[k] === '\\' || isNewline(text[k])) return -1;
k++;
}
if (k >= text.length) return -1;
j = k;
} else if (ch === quote || ch === '#' || ch === '\\' || isNewline(ch)) {
return -1;
}
continue;
}
if (ch === '\\') {
j++;
if (text[j] === '\r' && text[j + 1] === '\n') j++;
} else if (fString && ch === '{') {
if (text[j + 1] === '{') j++;
else depth = 1;
} else if (isNewline(ch) && !triple) {
return -1;
} else if (ch === quote && (!triple || (text[j + 1] === quote && text[j + 2] === quote))) {
return j + (triple ? 3 : 1);
}
}
return -1;
}
function pythonStartsFresh(text) {
let lineEnd = '';
let continued = false;
let i = 0;
while (i < text.length) {
const ch = text[i];
if (isNewline(ch)) {
continued = lineEnd === '\\';
lineEnd = '';
i++;
} else if (isSpace(ch)) {
i++;
} else if (ch === '#') {
i = lineEndAt(text, i);
} else if (ch === '"' || ch === "'") {
const start = wordStart(text, i);
const prefix = text.slice(start, i);
if (prefix.length > 2 || /[^rbuf]/i.test(prefix)) return false;
const end = pythonStringEnd(text, i, prefix);
if (end === -1) return false;
lineEnd = ch;
i = end;
} else {
lineEnd = ch;
i++;
}
}
return !continued;
}
function heredocDelimiter(text, i) {
let j = i + 2;
const stripTabs = text[j] === '-';
if (stripTabs) j++;
while (isBlank(text[j])) j++;
let word = '';
while (j < text.length && !isBlank(text[j]) && !isNewline(text[j]) && !';|&<>()'.includes(text[j])) {
if (!'\'"\\'.includes(text[j])) word += text[j];
j++;
}
return word ? { word, stripTabs, end: j } : null;
}
function skipHeredocs(text, i, pending) {
let at = i;
for (const doc of pending) {
for (;;) {
if (at >= text.length) return -1;
const end = lineEndAt(text, at);
let line = text.slice(at, end);
if (doc.stripTabs) line = line.replace(/^\t+/, '');
at = end + (text[end] === '\r' && text[end + 1] === '\n' ? 2 : 1);
if (line === doc.word) break;
}
}
return at;
}
const MAX_NESTING = 8;
function shSingleQuoteEnd(text, i) {
if (text[i - 1] === '$') return quotedEnd(text, i, "'", true);
return text.indexOf("'", i + 1) + 1 || -1;
}
function shSubstitutionEnd(text, i, level) {
if (level > MAX_NESTING) return -1;
let depth = 0;
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '\\') {
j++;
} else if (ch === '(') {
depth++;
} else if (ch === ')') {
depth--;
if (depth === 0) return j + 1;
} else if (ch === "'" || ch === '"') {
const end = ch === "'" ? shSingleQuoteEnd(text, j) : shDoubleQuoteScan(text, j, level + 1);
if (end === -1) return -1;
j = end - 1;
} else if (ch === '`' || (ch === '<' && text[j + 1] === '<') || (ch === '#' && hashStartsComment(text, j) !== false)) {
return -1;
} else if (ch === 'c' && text.startsWith('case', j) && !isIdentChar(text[j - 1]) && !isIdentChar(text[j + 4])) {
return -1;
}
}
return -1;
}
function shDoubleQuoteScan(text, i, level) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '"') return j + 1;
if (ch === '\\') {
j++;
} else if (ch === '`') {
return -1;
} else if (ch === '$' && text[j + 1] === '(') {
const end = shSubstitutionEnd(text, j, level);
if (end === -1) return -1;
j = end - 1;
} else if (ch === '$' && text[j + 1] === '{') {
const close = text.indexOf('}', j + 2);
if (close === -1 || /["'\n\r]/.test(text.slice(j + 2, close))) return -1;
j = close;
}
}
return -1;
}
function shStartsFresh(text) {
if (hasLoneCarriageReturn(text)) return false;
const pending = [];
let lineEnd = '';
let continued = false;
let i = 0;
while (i < text.length) {
const ch = text[i];
if (isNewline(ch)) {
continued = lineEnd === '\\';
lineEnd = '';
i += ch === '\r' && text[i + 1] === '\n' ? 2 : 1;
if (pending.length > 0) {
i = skipHeredocs(text, i, pending);
if (i === -1) return false;
pending.length = 0;
}
continue;
}
if (isBlank(ch)) {
i++;
continue;
}
lineEnd = ch;
if (ch === '\\') {
i += isNewline(text[i + 1]) ? 1 : 2;
} else if (ch === "'") {
const end = shSingleQuoteEnd(text, i);
if (end === -1) return false;
i = end;
} else if (ch === '"') {
const end = shDoubleQuoteScan(text, i, 0);
if (end === -1) return false;
i = end;
} else if (ch === '`') {
const end = text.indexOf('`', i + 1);
if (end === -1 || /['"\\#\n\r]/.test(text.slice(i + 1, end))) return false;
i = end + 1;
} else if (ch === '#') {
const starts = hashStartsComment(text, i);
if (starts === null) return false;
i = starts ? lineEndAt(text, i) : i + 1;
if (starts) lineEnd = '';
} else if (ch === '<' && text[i + 1] === '<') {
if (text[i + 2] === '<') {
i += 3;
continue;
}
const doc = heredocDelimiter(text, i);
if (!doc) return false;
pending.push(doc);
i = doc.end;
} else {
i++;
}
}
return !continued && pending.length === 0;
}
function psSingleQuoteEnd(text, i) {
for (let j = i + 1; j < text.length; j++) {
if (text[j] !== "'") continue;
if (text[j + 1] !== "'") return j + 1;
j++;
}
return -1;
}
function psSubexpressionEnd(text, i, level) {
if (level > MAX_NESTING) return -1;
let depth = 0;
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '`') {
j++;
} else if (ch === '(') {
depth++;
} else if (ch === ')') {
depth--;
if (depth === 0) return j + 1;
} else if (ch === "'" || ch === '"') {
const end = ch === "'" ? psSingleQuoteEnd(text, j) : psDoubleQuoteScan(text, j, level + 1);
if (end === -1) return -1;
j = end - 1;
} else if (ch === '#' || (ch === '@' && (text[j + 1] === '"' || text[j + 1] === "'"))) {
return -1;
}
}
return -1;
}
function psDoubleQuoteScan(text, i, level) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '`') {
j++;
} else if (ch === '$' && text[j + 1] === '(') {
const end = psSubexpressionEnd(text, j, level);
if (end === -1) return -1;
j = end - 1;
} else if (ch === '"') {
if (text[j + 1] !== '"') return j + 1;
j++;
}
}
return -1;
}
function psStartsFresh(text) {
if (PS_SMART_QUOTES.test(text)) return false;
let lineEnd = '';
let continued = false;
let i = 0;
while (i < text.length) {
const ch = text[i];
const next = text[i + 1];
if (isNewline(ch)) {
continued = lineEnd === '`';
lineEnd = '';
i++;
continue;
}
if (isBlank(ch)) {
i++;
continue;
}
lineEnd = ch;
if (ch === '`') {
i += isNewline(next) ? 1 : 2;
} else if (ch === '@' && (next === '"' || next === "'")) {
const open = lineEndAt(text, i);
if (text.slice(i + 2, open).trim() !== '') return false;
let at = open;
let found = -1;
while (at < text.length) {
at += text[at] === '\r' && text[at + 1] === '\n' ? 2 : 1;
if (text.startsWith(`${next}@`, at)) {
found = at + 2;
break;
}
at = lineEndAt(text, at);
}
if (found === -1) return false;
i = found;
} else if (ch === "'" || ch === '"') {
const end = ch === "'" ? psSingleQuoteEnd(text, i) : psDoubleQuoteScan(text, i, 0);
if (end === -1) return false;
i = end;
} else if (ch === '<' && next === '#') {
if (!psHashStartsComment(text, i)) return false;
const end = text.indexOf('#>', i + 2);
if (end === -1) return false;
i = end + 2;
lineEnd = '';
} else if (ch === '#') {
if (psHashStartsComment(text, i)) {
i = lineEndAt(text, i);
lineEnd = '';
} else if (isIdentChar(text[i - 1]) || '-.:'.includes(text[i - 1])) {
i++;
} else {
return false;
}
} else {
i++;
}
}
return !continued;
}
function batchStartsFresh(text) {
const lines = text.split(/\r\n|\r|\n/);
const last = lines.length > 1 ? lines[lines.length - 2] : '';
return !last.endsWith('^');
}
function lineStartOf(text, i) {
let start = i;
while (start > 0 && !isNewline(text[start - 1])) start--;
return start;
}
function windowStart(text, at) {
const start = lineStartOf(text, at);
if (start === 0) return 0;
let end = start - 1;
if (text[end] === '\n' && text[end - 1] === '\r') end--;
return lineStartOf(text, end);
}
function windowEnd(text, at) {
const end = lineEndAt(text, at);
if (end >= text.length) return text.length;
return end + (text[end] === '\r' && text[end + 1] === '\n' ? 2 : 1);
}
function occurrences(text, needle, replaceAll) {
const first = text.indexOf(needle);
if (first === -1) return null;
let last = first;
for (let at = text.indexOf(needle, first + needle.length); at !== -1; at = text.indexOf(needle, at + needle.length)) {
if (!replaceAll) return null;
last = at;
}
return { first, last };
}
function trivialInFile(pairs, fileText, spec) {
if (typeof fileText !== 'string' || fileText.length > MAX_FILE_CHARS || !spec.startsFresh) return false;
if (spec.flavor === 'go' && fileText.includes('"C"')) return false;
let text = fileText;
let work = 0;
for (const [oldString, newString, replaceAll] of pairs) {
if (!oldString || REPLACEMENT_PATTERN.test(newString)) return false;
const found = occurrences(text, oldString, replaceAll);
if (!found) return false;
const start = windowStart(text, found.first);
const end = windowEnd(text, found.last + oldString.length);
work += start + 2 * (end - start);
if (end - start > MAX_WINDOW_CHARS || work > MAX_CONTEXT_WORK) return false;
const before = text.slice(start, end);
const after = replaceAll
? before.split(oldString).join(newString)
: `${text.slice(start, found.first)}${newString}${text.slice(found.first + oldString.length, end)}`;
// see docs/gateguard/change-profile.md#file-context
if (!isTrivialEdit(before, after, spec) || !spec.startsFresh(text.slice(0, start), spec)) return false;
text = `${text.slice(0, start)}${after}${text.slice(end)}`;
if (text.length > MAX_FILE_CHARS) return false;
}
return true;
}
module.exports = {
MAX_FILE_CHARS,
lineEndAt,
lineStartOf,
cFamilyStartsFresh,
pythonStartsFresh,
shStartsFresh,
psStartsFresh,
batchStartsFresh,
trivialInFile
};
+133
View File
@@ -0,0 +1,133 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const METRICS_SCHEMA_VERSION = 1;
const METRICS_FILE_NAME = 'metrics.jsonl';
const METRICS_MAX_BYTES = 1024 * 1024;
const SESSION_DIGEST_LENGTH = 12;
const CODE_MAX_LENGTH = 48;
const CODE_PATTERN = /^[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?$/;
const SESSION_PATTERN = /^[0-9a-f]{12}$/;
const APPEND_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
const DECISIONS = Object.freeze([
'deny',
'credit',
'sibling',
'cap',
'trivial',
'pass-checked',
'pass-exempt',
'pass-subagent',
'routine-deny',
'routine-readonly',
'destructive-deny',
'pass'
]);
const DECISION_SET = new Set(DECISIONS);
const TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell']);
function isCode(value) {
return typeof value === 'string' && value.length <= CODE_MAX_LENGTH && CODE_PATTERN.test(value);
}
function codeOrNull(value) {
return isCode(value) ? value : null;
}
function sessionDigest(sessionKey) {
return crypto.createHash('sha256').update(String(sessionKey || '')).digest('hex').slice(0, SESSION_DIGEST_LENGTH);
}
function profileFields(profile) {
if (!profile || typeof profile !== 'object') return null;
return {
known: profile.known === true,
language: codeOrNull(profile.language),
touchesPublicSurface: profile.touchesPublicSurface === true,
touchesData: profile.touchesData === true,
trivial: profile.trivial === true
};
}
/** One metrics line for a gate decision, or null when the decision or tool is not recorded. */
function metricsEvent({ sessionKey, tool, cls, decision, reason, questions, sensitive, profile, now = Date.now() }) {
if (!DECISION_SET.has(decision) || !TOOLS.has(tool)) return null;
return {
v: METRICS_SCHEMA_VERSION,
ts: new Date(now).toISOString(),
session: sessionDigest(sessionKey),
tool,
class: codeOrNull(cls),
decision,
reason: codeOrNull(reason),
questions: Array.isArray(questions) ? questions.filter(isCode) : null,
sensitive: sensitive === true,
profile: profileFields(profile)
};
}
/** Whether a parsed line has the shape `metricsEvent` writes. */
function isMetricsEvent(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
if (value.v !== METRICS_SCHEMA_VERSION || typeof value.ts !== 'string') return false;
if (typeof value.session !== 'string' || !SESSION_PATTERN.test(value.session)) return false;
if (!TOOLS.has(value.tool) || !DECISION_SET.has(value.decision)) return false;
if (value.class !== null && !isCode(value.class)) return false;
if (value.reason !== null && !isCode(value.reason)) return false;
if (value.questions !== null && !(Array.isArray(value.questions) && value.questions.every(isCode))) return false;
return typeof value.sensitive === 'boolean';
}
/** Append events to `<dir>/metrics.jsonl` in one write, rotating to `.1` past the size cap; never throws. */
function appendMetrics(dir, events) {
let fd = null;
try {
const lines = events
.filter(Boolean)
.map(event => `${JSON.stringify(event)}\n`)
.join('');
if (!lines) return false;
const file = path.join(dir, METRICS_FILE_NAME);
fs.mkdirSync(dir, { recursive: true });
let size = 0;
try {
const stat = fs.lstatSync(file);
if (!stat.isFile()) return false;
size = stat.size;
} catch (_) {
size = 0;
}
if (size > 0 && size + Buffer.byteLength(lines) > METRICS_MAX_BYTES) {
fs.renameSync(file, `${file}.1`);
}
fd = fs.openSync(file, APPEND_FLAGS, 0o600);
if (!fs.fstatSync(fd).isFile()) return false;
fs.writeSync(fd, lines);
return true;
} catch (_) {
return false;
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch (_) {
/* ignore */
}
}
}
}
module.exports = {
DECISIONS,
METRICS_FILE_NAME,
METRICS_MAX_BYTES,
METRICS_SCHEMA_VERSION,
appendMetrics,
isMetricsEvent,
metricsEvent,
sessionDigest
};
+186
View File
@@ -0,0 +1,186 @@
'use strict';
// see docs/gateguard/design-notes.md#read-only-first-shell-command
const MAX_COMMAND_CHARS = 4096;
// --- character screen ---
const BASH_UNQUOTED_REJECT = new Set(['\\', '`', '$', '<', '>', '(', ')', '{', '}']);
const BASH_DOUBLE_QUOTED_REJECT = new Set(['\\', '`', '$']);
const BASH_SINGLE_QUOTED_REJECT = new Set(['\\']);
const PS_UNQUOTED_REJECT = new Set(['`', '$', '<', '>', '(', ')', '{', '}', '@', '[', ']']);
const PS_DOUBLE_QUOTED_REJECT = new Set(['`', '$']);
const PS_SINGLE_QUOTED_REJECT = new Set();
const DIALECTS = Object.freeze({
Bash: { unquoted: BASH_UNQUOTED_REJECT, double: BASH_DOUBLE_QUOTED_REJECT, single: BASH_SINGLE_QUOTED_REJECT },
PowerShell: { unquoted: PS_UNQUOTED_REJECT, double: PS_DOUBLE_QUOTED_REJECT, single: PS_SINGLE_QUOTED_REJECT }
});
function isPlainAscii(code) {
return code === 0x09 || (code >= 0x20 && code < 0x7f);
}
function passesCharacterScreen(command, dialect) {
let quote = null;
for (let index = 0; index < command.length; index++) {
const ch = command[index];
if (!isPlainAscii(ch.charCodeAt(0))) return false;
if (quote === "'") {
if (dialect.single.has(ch)) return false;
if (ch === "'") quote = null;
continue;
}
if (quote === '"') {
if (dialect.double.has(ch)) return false;
if (ch === '"') quote = null;
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
continue;
}
if (dialect.unquoted.has(ch)) return false;
if (ch === '&') {
if (command[index + 1] !== '&') return false;
index++;
}
}
return quote === null;
}
// --- per-command rules ---
const anyArgs = () => true;
const noArg = pattern => args => !args.some(arg => pattern.test(arg));
const FIND_ACTIONS = /^-(exec|ok|delete|fprint|fls)/;
const FD_EXEC = /^(--exec|-[^-]*[xX])/;
const RG_EXEC = /^(--(pre|hostname-bin|search-zip)|-[^-]*z)/;
const TREE_WRITE = /^(-[^-]*[oR]|--o)/;
const BASH_COMMANDS = new Map([
['ls', anyArgs],
['pwd', anyArgs],
['cat', anyArgs],
['head', anyArgs],
['tail', anyArgs],
['wc', anyArgs],
['grep', anyArgs],
['rg', noArg(RG_EXEC)],
['find', noArg(FIND_ACTIONS)],
['fd', noArg(FD_EXEC)],
['tree', noArg(TREE_WRITE)],
['git', isReadOnlyGit]
]);
const POWERSHELL_COMMANDS = new Map([
['get-childitem', anyArgs],
['gci', anyArgs],
['dir', anyArgs],
['ls', anyArgs],
['get-content', anyArgs],
['gc', anyArgs],
['cat', anyArgs],
['type', anyArgs],
['select-string', anyArgs],
['sls', anyArgs],
['get-location', anyArgs],
['gl', anyArgs],
['pwd', anyArgs],
['rg', noArg(RG_EXEC)],
['git', isReadOnlyGit]
]);
// --- git ---
const GIT_LOG_VALUE_FLAGS = /^--(max-count=\d+|since=.*|until=.*|author=.*|grep=.*|format=.*|pretty=.*|date=.*)$/;
const GIT_DIFF_VALUE_FLAGS = /^(-U\d+|--unified=\d+)$/;
const GIT_SUBCOMMANDS = new Map([
['status', { positional: true, flags: ['--short', '-s', '--branch', '-b', '--porcelain', '--porcelain=v1', '--porcelain=v2', '-uno', '-unormal', '-uall'] }],
[
'log',
{
positional: true,
flags: ['--oneline', '--stat', '--name-only', '--name-status', '--graph', '--decorate', '--all', '-p', '--patch', '--no-color', '--reverse', '--first-parent', '--no-merges', '--merges', '--follow', '--abbrev-commit', '-n'],
pattern: value => /^-\d+$/.test(value) || GIT_LOG_VALUE_FLAGS.test(value)
}
],
[
'diff',
{
positional: true,
flags: ['--name-only', '--name-status', '--cached', '--staged', '--stat', '--numstat', '--shortstat', '--no-color', '-w', '--ignore-all-space', '--word-diff', '--merge-base', '--check'],
pattern: value => GIT_DIFF_VALUE_FLAGS.test(value)
}
],
[
'show',
{
positional: true,
flags: ['--stat', '--name-only', '--name-status', '--oneline', '--no-color', '-p', '--patch', '--no-patch', '-s'],
pattern: value => /^--(format|pretty)=/.test(value)
}
],
['ls-files', { positional: true, flags: ['--others', '-o', '--exclude-standard', '--cached', '-c', '--modified', '-m', '--deleted', '-d', '--stage', '-s'] }],
['rev-parse', { positional: true, flags: ['--abbrev-ref', '--show-toplevel', '--git-dir', '--short', '--verify', '--is-inside-work-tree', '--show-prefix', '--symbolic-full-name'] }],
['branch', { positional: false, flags: ['--show-current', '-a', '--all', '-r', '--remotes', '--list', '-v', '-vv', '--no-color'] }]
]);
function isReadOnlyGit(args) {
if (args.length === 0) return false;
const rule = GIT_SUBCOMMANDS.get(args[0]);
if (!rule) return false;
let pathspec = false;
for (const arg of args.slice(1)) {
if (pathspec) continue;
if (arg === '--') {
if (!rule.positional) return false;
pathspec = true;
continue;
}
if (arg.startsWith('-')) {
if (rule.flags.includes(arg) || (rule.pattern && rule.pattern(arg))) continue;
return false;
}
if (!rule.positional) return false;
}
return true;
}
// --- entry ---
function createReadOnlyShell({ quoteAwareSegments }) {
function segmentsFor(command, toolName) {
return quoteAwareSegments(toolName === 'PowerShell' ? command.replace(/\\/g, '\\\\') : command);
}
function isReadOnlySegment(words, toolName) {
const [name, ...args] = words;
if (!name || /[=/\\*?[]/.test(name)) return false;
const table = toolName === 'PowerShell' ? POWERSHELL_COMMANDS : BASH_COMMANDS;
const rule = table.get(toolName === 'PowerShell' ? name.toLowerCase() : name);
if (!rule) return false;
if (toolName === 'PowerShell' && args.includes('--%')) return false;
return rule(args);
}
/** True only when every segment of the command is allowlisted read-only introspection. */
function isReadOnlyShellCommand(toolName, command) {
try {
const dialect = Object.hasOwn(DIALECTS, toolName) ? DIALECTS[toolName] : null;
if (!dialect || typeof command !== 'string' || command.length > MAX_COMMAND_CHARS) return false;
if (!passesCharacterScreen(command, dialect)) return false;
const segments = segmentsFor(command, toolName);
return segments.length > 0 && segments.every(words => isReadOnlySegment(words, toolName));
} catch (_) {
return false;
}
}
return { isReadOnlyShellCommand };
}
module.exports = { createReadOnlyShell };
+534
View File
@@ -0,0 +1,534 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { WINDOWS_PATH_PATTERN, classifyTargetFor } = require('./gateguard-target-class');
const { excludedBatchId } = require('./gateguard-turn-scan');
const {
POWERSHELL_SEARCHES,
isInsideDir,
valueFlagsFor,
powershellFilterArg,
grepToolFilters,
shellSearchFilters,
filtersAdmitTarget,
includesAdmitTarget
} = require('./gateguard-search-filters');
// --- Search commands and flags ---
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']);
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 PATTERN_VALUE_FLAGS = new Set(['-e', '-f', '--regexp', '--file']);
const PATTERN_FLAG_SEARCHES = new Set(['grep', 'egrep', 'fgrep', 'rg', 'git grep']);
const POWERSHELL_VALUE_FLAGS = new Set(['-path', '-literalpath', '-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;
// see docs/gateguard/design-notes.md#ambiguous-shell-is-not-evidence
const AMBIGUOUS_SHELL_PATTERN = /`|\$\(|[<>]\(|<</;
const GLOB_CHARS_PATTERN = /[*?[{]/;
// see docs/gateguard/design-notes.md#test-stems
function bareStem(filePath, data) {
const base = (String(filePath).split(/[\\/]/).pop() || '').toLowerCase();
const ext = path.posix.extname(base);
const stem = ext ? base.slice(0, -ext.length) : base;
if (classifyTargetFor(filePath, data) !== 'test') return stem;
if (stem.endsWith('.test') || stem.endsWith('.spec')) return stem.slice(0, -5);
if (ext === '.py' && stem.startsWith('test_')) return stem.slice(5);
if ((ext === '.py' || ext === '.go') && stem.endsWith('_test')) return stem.slice(0, -5);
return stem;
}
function eligibleStem(filePath, data) {
const stem = bareStem(filePath, data);
return stem.length >= MIN_STEM_LENGTH && !GENERIC_STEMS.has(stem) ? stem : null;
}
// see docs/gateguard/design-notes.md#search-prefilter
function inputMayMention(input, word) {
if (!input || typeof input !== 'object') return false;
let text = '';
for (const value of Object.values(input)) {
if (typeof value === 'string') text += `\n${value}`;
}
return text.toLowerCase().replace(/["'\\]/g, '').includes(word);
}
// see docs/gateguard/design-notes.md#stem-matching
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;
}
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 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);
}
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('/') || '/';
}
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;
}
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 flagEffect(kind, args, i) {
const arg = args[i];
if (POWERSHELL_SEARCHES.has(kind)) {
const lower = arg.toLowerCase();
const filter = powershellFilterArg(kind, args, i);
const consumes = filter ? filter.end - i : POWERSHELL_VALUE_FLAGS.has(lower) ? 1 : 0;
return { consumes, 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 };
}
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*</.test(arg);
break;
}
if (arg.includes('<')) {
inputRedirect = true;
break;
}
if (arg.length > 1 && arg.startsWith('-')) {
expressionStarted = true;
if (isRecursiveFlag(kind, arg)) recursive = true;
const effect = flagEffect(kind, args, i);
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;
}
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;
}
// see docs/gateguard/design-notes.md#search-scope
if (item.operands && item.operands.length > 0 && item.operands.every(parts => ctx.resolveDir(...parts) === ctx.targetKey)) {
return false;
}
return true;
}
function evidenceNamesDir(item, ctx) {
return item.dirs.some(
parts => ctx.resolveDir(...parts) === ctx.targetDir && (!item.dirsMustExist || isDirectoryNow(ctx.resolveNative(...parts)))
);
}
// --- Prior-search credit ---
const MIN_GENERIC_MISS_STEM = 3;
const MISS_RANK = Object.freeze({ 'same-batch': 0, excluded: 1, 'out-of-scope': 2, 'stdin-only': 3, 'not-a-search': 4, 'generic-stem': 5 });
/** Build the prior-search matcher around the hook's shell segmenter. */
function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS }) {
function searchEvidence(search, shellDirsTrusted, misses) {
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)) {
const lookup = [explicit || '.', input.pattern];
return [{ texts: [input.pattern], dirs: [], scope, scopeExact: true, operands: [lookup], detail: input.pattern }];
}
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,
pathIncludes: true,
...filters
}];
}
if (search.name === 'LS') {
return stringsOf(input.path).map(dir => ({ texts: [], dirs: [[dir]], scope: [dir], detail: dir }));
}
return shellSearchEvidence(input.command, shellDirsTrusted, misses);
}
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;
}
function shellSearchEvidence(command, dirsTrusted, misses) {
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;
// see docs/gateguard/design-notes.md#directory-changes
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;
}
const miss = reason => {
if (misses) misses.push({ texts: [tokens.join(' ')], detail: tokens.join(' '), reason });
};
if (!SHELL_SEARCH_COMMANDS.has(lead) || (lead === 'git' && !GIT_SEARCH_SUBCOMMANDS.has(tokens[1]))) {
miss('not-a-search');
return;
}
const kind = lead === 'git' ? `git ${tokens[1]}` : lead;
const args = tokens.slice(lead === 'git' ? 2 : 1);
const parsed = parseSearchArgs(kind, args);
// see docs/gateguard/design-notes.md#stdin-is-not-a-tree-search
if (parsed.stdin || (parsed.operands.length === 0 && (!parsed.recursive || pipeFlags[index] !== false))) {
miss('stdin-only');
return;
}
const scopes = shellSearchScopes(parsed, base);
if (scopes.length === 0) return;
const filters = shellSearchFilters(kind, args);
// see docs/gateguard/design-notes.md#search-filters
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,
pathIncludes: kind === 'rg',
opaque: filters.opaque
});
});
return evidence;
}
const evidenceCache = new WeakMap();
const trustCache = new WeakMap();
function cachedEvidence(search, shellDirsTrusted) {
if (!search || typeof search !== 'object') return { items: [], misses: [] };
let byTrust = evidenceCache.get(search);
if (!byTrust) {
byTrust = new Map();
evidenceCache.set(search, byTrust);
}
let entry = byTrust.get(shellDirsTrusted);
if (!entry) {
const misses = [];
entry = { items: searchEvidence(search, shellDirsTrusted, misses), misses };
byTrust.set(shellDirsTrusted, entry);
}
return entry;
}
function shellDirsTrustedFor(scan) {
if (!trustCache.has(scan)) trustCache.set(scan, !turnChangesDirectory(scan.shellCommands));
return trustCache.get(scan);
}
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])));
});
}
/** First completed search of the current turn that credits the target, else null. */
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 eligible = eligibleStem(targetPath, data);
if (eligible === null && !allowDirMatch) return null;
const stem = stemMatcher(eligible);
// see docs/gateguard/design-notes.md#same-batch-searches
const excluded = excludedBatchId(scan, data);
let shellDirsTrusted = null;
for (const search of scan.searches) {
if (typeof search.messageId !== 'string' || !search.messageId || search.messageId === excluded) continue;
if (!allowDirMatch && !inputMayMention(search.input, eligible)) continue;
if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan);
for (const item of cachedEvidence(search, shellDirsTrusted).items) {
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;
}
}
// --- Closest non-qualifying search ---
// see docs/gateguard/design-notes.md#closest-search-that-did-not-count
function mentionsTarget(texts, stem, rawStem) {
const matcher = stem || rawStem;
return Boolean(matcher) && texts.some(text => typeof text === 'string' && matcher.test(text.toLowerCase()));
}
function itemMiss(item, ctx, stem, rawStem, allowDirMatch, sameBatch) {
if (stem === null) return mentionsTarget(item.texts, null, rawStem) ? 'generic-stem' : null;
const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx);
const mentioned = mentionsTarget(item.texts, stem, null);
if (!mentioned && !byDir) return null;
const inScope = evidenceInScope(item, ctx);
const admitted = filtersAdmitTarget(item, ctx, stem);
const included = includesAdmitTarget(item, ctx);
if (inScope && admitted && (byDir || included)) return sameBatch ? 'same-batch' : null;
if (!inScope) return 'out-of-scope';
return 'excluded';
}
/** Closest search of the current turn that did not credit the target, with a reason code, else null. */
function findClosestMiss(scan, targetPath, allowDirMatch, data) {
try {
if (!scan || !Array.isArray(scan.searches) || typeof targetPath !== 'string' || !targetPath) return null;
const ctx = dirContext(targetPath, data);
if (!ctx) return null;
const eligible = eligibleStem(targetPath, data);
const stem = stemMatcher(eligible);
const bare = bareStem(targetPath, data);
const rawStem = eligible === null && bare.length >= MIN_GENERIC_MISS_STEM ? stemMatcher(bare) : null;
const excluded = excludedBatchId(scan, data);
let shellDirsTrusted = null;
let best = null;
const consider = (name, detail, reason) => {
if (reason && (best === null || MISS_RANK[reason] < MISS_RANK[best.reason])) best = { name, detail, reason };
};
for (const search of scan.searches) {
if (!search || typeof search.messageId !== 'string' || !search.messageId) continue;
if (!allowDirMatch && (!bare || !inputMayMention(search.input, bare))) continue;
if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan);
const { items, misses } = cachedEvidence(search, shellDirsTrusted);
for (const item of items) {
consider(search.name, item.detail, itemMiss(item, ctx, stem, rawStem, allowDirMatch, search.messageId === excluded));
}
for (const miss of misses) {
if (mentionsTarget(miss.texts, stem, rawStem)) consider(search.name, miss.detail, stem === null ? 'generic-stem' : miss.reason);
}
}
for (const read of Array.isArray(scan.reads) ? scan.reads : []) {
if (!read || typeof read.path !== 'string' || !read.path) continue;
const readsTarget = ctx.resolveDir(read.path) === ctx.targetKey || mentionsTarget([read.path], stem, null);
if (readsTarget) consider('Read', read.path, 'not-a-search');
}
return best;
} catch (_) {
return null;
}
}
return { findCreditingSearch, findClosestMiss };
}
module.exports = { createSearchEvidence };
+459
View File
@@ -0,0 +1,459 @@
'use strict';
// --- Search flags ---
const POWERSHELL_SEARCHES = new Set(['get-childitem', 'gci', 'select-string', 'sls']);
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'
]);
const LS_VALUE_FLAGS = new Set(['-I', '--ignore', '--hide']);
const TREE_VALUE_FLAGS = new Set(['-L', '-P', '-I', '-o']);
const FD_VALUE_FLAGS = new Set([...VALUE_FLAGS, '-E']);
const POWERSHELL_FILTER_PARAMS = new Set(['filter', 'include', 'exclude']);
const POWERSHELL_COMMON_PARAMS = [
'verbose', 'debug', 'erroraction', 'warningaction', 'informationaction', 'progressaction',
'errorvariable', 'warningvariable', 'informationvariable', 'outvariable', 'outbuffer', 'pipelinevariable'
];
const POWERSHELL_CHILDITEM_PARAMS = [
'path', 'literalpath', 'filter', 'include', 'exclude', 'recurse', 'depth', 'force', 'name',
'attributes', 'directory', 'file', 'hidden', 'readonly', 'system', 'followsymlink'
];
const POWERSHELL_SELECTSTRING_PARAMS = [
'pattern', 'path', 'literalpath', 'inputobject', 'simplematch', 'casesensitive', 'quiet', 'list', 'noemphasis',
'include', 'exclude', 'notmatch', 'allmatches', 'encoding', 'context', 'raw', 'culture'
];
const POWERSHELL_PARAM_ALIASES = new Set([
's', 'ad', 'd', 'af', 'ah', 'h', 'ar', 'as', 'lp', 'pspath',
'vb', 'db', 'ea', 'wa', 'infa', 'proga', 'ev', 'wv', 'iv', 'ov', 'ob', 'pv'
]);
function isInsideDir(targetKey, dirKey) {
return targetKey.startsWith(dirKey.endsWith('/') ? dirKey : `${dirKey}/`);
}
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;
}
// --- Search filters ---
// see docs/gateguard/design-notes.md#search-filters
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];
}
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 === '[') {
const parsed = bracketClass(glob, i);
if (!parsed) return null;
tokens.push(parsed.token);
i = parsed.end;
} else {
tokens.push({ type: 'lit', ch });
}
}
return tokens;
}
function bracketClass(glob, open) {
let i = open + 1;
const negated = glob[i] === '!' || glob[i] === '^';
if (negated) i += 1;
const ranges = [];
for (let first = true; i < glob.length && (first || glob[i] !== ']'); first = false) {
if (glob[i + 1] === '-' && i + 2 < glob.length && glob[i + 2] !== ']') {
ranges.push([glob[i], glob[i + 2]]);
i += 3;
} else {
ranges.push([glob[i], glob[i]]);
i += 1;
}
}
if (i >= glob.length) return null;
return { token: { type: 'class', negated, ranges }, end: i };
}
function classMatches(token, ch) {
if (ch === '/') return false;
const forms = [ch, ch.toLowerCase(), ch.toUpperCase()];
const member = token.ranges.some(([lo, hi]) => forms.some(form => form >= lo && form <= hi));
return member !== token.negated;
}
// see docs/gateguard/design-notes.md#glob-matching-without-regexp
function wildcardMatch(glob, text) {
const tokens = globTokens(glob);
if (!tokens) return null;
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 (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 === 'class') row[j] = j < n && classMatches(token, 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(/\/+$/, '');
}
function globMatches(glob, text, malformed) {
if (glob.length > MAX_FILTER_GLOB_CHARS) return null;
const alternatives = expandBraces(glob);
if (!alternatives) return null;
return alternatives.some(alternative => {
const matched = wildcardMatch(alternative, text);
return matched === null ? malformed : matched;
});
}
function targetSegments(ctx) {
return ctx.targetKey.toLowerCase().split('/').filter(Boolean);
}
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('/'), true);
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));
}
function scopeRelativeSegments(item, ctx) {
const scopes = item.scopes || (item.scope ? [item.scope] : []);
const relatives = [];
for (const parts of scopes) {
const scope = ctx.resolveDir(...parts);
if (isInsideDir(ctx.targetKey, scope)) relatives.push(ctx.targetKey.slice(scope.replace(/\/+$/, '').length + 1).toLowerCase().split('/'));
}
return relatives;
}
function includeAdmitsTarget(include, item, ctx) {
const glob = normalizeFilterGlob(include);
const unanchored = glob.replace(/^(?:\*\*\/)+/, '');
if (!unanchored) return false;
if (isBasenameGlob(unanchored)) {
const segments = targetSegments(ctx);
return globMatches(unanchored, segments[segments.length - 1] || '', false) === true;
}
if (!item.pathIncludes) return false;
return scopeRelativeSegments(item, ctx).some(segments => {
const starts = unanchored === glob ? [0] : segments.map((_, start) => start);
return starts.some(start => globMatches(unanchored, segments.slice(start).join('/'), false) === true);
});
}
// see docs/gateguard/design-notes.md#include-filters
function includesAdmitTarget(item, ctx) {
const includes = Array.isArray(item.includes) ? item.includes : [];
if (includes.length === 0) return true;
if (includes.length > MAX_FILTERS_PER_SEARCH) return false;
return includes.some(include => includeAdmitsTarget(include, item, ctx));
}
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);
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(kind, 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 (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;
}
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)) {
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' ? '|' : '');
}
}
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 powershellParam(kind, arg) {
const colon = arg.indexOf(':');
const spelled = (colon === -1 ? arg.slice(1) : arg.slice(1, colon)).toLowerCase();
const inline = colon === -1 ? null : arg.slice(colon + 1);
const own = kind === 'get-childitem' || kind === 'gci' ? POWERSHELL_CHILDITEM_PARAMS : POWERSHELL_SELECTSTRING_PARAMS;
const params = own.concat(POWERSHELL_COMMON_PARAMS);
if (!spelled) return { name: null, inline };
if (params.includes(spelled) || POWERSHELL_PARAM_ALIASES.has(spelled)) return { name: spelled, inline };
const matches = params.filter(param => param.startsWith(spelled));
return { name: matches.length === 1 ? matches[0] : null, inline };
}
function powershellFilterArg(kind, args, i) {
if (!/^-[A-Za-z]/.test(args[i])) return null;
const { name, inline } = powershellParam(kind, args[i]);
if (!POWERSHELL_FILTER_PARAMS.has(name)) return null;
const first = inline ? i : i + 1;
if (first >= args.length) return { name, globs: [], end: i };
let end = first;
while (end + 1 < args.length && (args[end].endsWith(',') || args[end + 1].startsWith(','))) end += 1;
const parts = [inline || args[first]].concat(args.slice(first + 1, end + 1));
return { name, globs: splitTopLevelCommas(parts.join(',')), end };
}
function powershellSearchFilters(kind, args, filters) {
for (let i = 0; i < args.length; i++) {
const filter = powershellFilterArg(kind, args, i);
if (filter) {
if (filter.name === 'exclude') {
filters.exclusions.push(...filter.globs);
for (let k = i; k <= filter.end; k++) filters.dropped.add(k);
} else {
filter.globs.filter(Boolean).forEach(glob => filters.includes.push(glob));
}
i = filter.end;
} else if (/^-[A-Za-z]/.test(args[i]) && powershellParam(kind, args[i]).name === null) {
// see docs/gateguard/design-notes.md#powershell-parameter-binding
filters.dropped.add(i);
if (!args[i].includes(':') && i + 1 < args.length && !args[i + 1].startsWith('-')) filters.dropped.add(i + 1);
}
}
return filters;
}
module.exports = {
POWERSHELL_SEARCHES,
isInsideDir,
valueFlagsFor,
powershellFilterArg,
grepToolFilters,
shellSearchFilters,
filtersAdmitTarget,
includesAdmitTarget
};
+143
View File
@@ -0,0 +1,143 @@
'use strict';
// --- Counters ---
// see docs/gateguard/design-notes.md#state-file-is-untrusted
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);
}
function getTrivialAllowCount(state) {
return toCount(state && state.trivial_allows);
}
function getRoutineReadonlyPassCount(state) {
return toCount(state && state.routine_readonly_passes);
}
// --- Sibling dir gates ---
const MAX_DIR_GATES = 50;
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);
}
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 && require('./gateguard-target-class').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);
}
/** Merge two dir-gate maps; the newer `at` wins per key. */
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));
}
module.exports = {
getDenialCount,
getCreditedCount,
getCapAllowCount,
getTrivialAllowCount,
getRoutineReadonlyPassCount,
getSiblingAllowCount,
getClassCounts,
mergeClassCounts,
incrementClassCount,
withEntry,
dirGateKey,
getDirGates,
mergeDirGates,
capDirGates
};
+442
View File
@@ -0,0 +1,442 @@
'use strict';
const fs = require('fs');
const path = require('path');
const WINDOWS_PATH_PATTERN = /^[a-z]:[\\/]|^\\\\/i;
/** Canonical checked-state key for a target. */
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. */
// see docs/gateguard/design-notes.md#target-resolution
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'
]);
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($|\.)/;
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\/([^/]+)\//;
function isHostPathStyle(isWin) {
return isWin === (process.platform === 'win32');
}
function foldKey(nativePath, isWin) {
const key = nativePath.replace(/\\/g, '/');
return isWin ? key.toLowerCase() : key;
}
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 };
}
// see docs/gateguard/design-notes.md#worktree-prefix
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'));
}
const STREAM_MARKER = '::$';
// see docs/gateguard/design-notes.md#windows-name-normalization
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('/');
}
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);
}
/** Project-relative path a target is classified by. */
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 QUESTION_TEXT = Object.freeze({
importers: `List ALL files that import/require this file ${SEARCH_THE_TREE}`,
'public-api': 'List the public functions/classes affected by this change',
'local-callers': `List the call sites in this file or its module that rely on the changed behaviour ${SEARCH_THE_TREE}`,
callers: 'Name the file(s) and line(s) that will call this new file',
'no-duplicate': `Confirm no existing file serves the same purpose ${SEARCH_THE_TREE}`,
'data-schema':
'If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
'external-contract':
'Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained',
loader: 'Name the harness/loader that reads this file (Claude Code, Codex, Cursor, OpenCode, …) and when it loads it',
'behaviour-change': 'Describe what agent behaviour changes as a result',
'no-duplicate-instruction': `Confirm no existing instruction, skill, or agent file already covers this ${SEARCH_THE_TREE}`,
'under-test': 'Name what behaviour is under test and which module/function it exercises',
'existing-tests': `Name the existing test file(s) covering this module, or confirm none exist ${SEARCH_THE_TREE}`,
supersedes: `Name any existing doc this supersedes or duplicates ${SEARCH_THE_TREE}`,
'linked-from': 'State where it will be linked or referenced from',
'why-new-file': 'Explain why a new file rather than editing an existing one',
references: `List other docs or code that reference the section being changed ${SEARCH_THE_TREE}`,
'corrects-or-adds': 'State what the change corrects or adds',
'config-reader': 'Name which process/tool reads this file and when',
'config-effect': 'Describe the effect of the change',
'no-plaintext-secrets': 'Confirm no secrets or credentials are being written in plain text',
'quote-instruction': QUOTE_INSTRUCTION
});
const CLASS_QUESTION_IDS = {
instruction: () => ['loader', 'behaviour-change', 'no-duplicate-instruction'],
test: () => ['under-test', 'existing-tests'],
prose: isWrite => (isWrite ? ['supersedes', 'linked-from', 'why-new-file'] : ['references', 'corrects-or-adds']),
config: () => ['config-reader', 'config-effect', 'no-plaintext-secrets']
};
const CLASS_QUESTIONS = Object.fromEntries(
Object.entries(CLASS_QUESTION_IDS).map(([cls, ids]) => [cls, isWrite => ids(isWrite).map(id => QUESTION_TEXT[id])])
);
// see docs/gateguard/change-profile.md#questions-from-the-change-profile
function codeQuestionIds(isWrite, profile) {
const known = Boolean(profile) && profile.known === true;
const surface = !known || profile.touchesPublicSurface !== false;
const data = !known || profile.touchesData !== false;
const opening = isWrite ? ['callers', 'no-duplicate'] : surface ? ['importers', 'public-api'] : ['local-callers'];
// A change that handles data is usually constrained by something outside the
// tree - a consumer's dialect, a reporting timezone, a protocol's semantics -
// and the repository cannot settle it. data-schema asks what the change
// touches; external-contract asks who decides what it has to be.
return data ? [...opening, 'data-schema', 'external-contract'] : opening;
}
/** True when a class's questions depend on the change profile. */
function questionsUseProfile(cls) {
return !Object.hasOwn(CLASS_QUESTION_IDS, cls);
}
/** Stable ids of the first-touch questions for a class, action and change profile. */
function questionIdsFor(cls, isWrite, profile) {
const ids = Object.hasOwn(CLASS_QUESTION_IDS, cls) ? CLASS_QUESTION_IDS[cls](Boolean(isWrite)) : codeQuestionIds(Boolean(isWrite), profile);
return [...ids, 'quote-instruction'];
}
/** Question text for an id from `questionIdsFor`. */
function questionText(id) {
return Object.hasOwn(QUESTION_TEXT, id) ? QUESTION_TEXT[id] : '';
}
const CODE_CONDENSED_HINT =
"briefly state importers/callers, affected API, data schemas if any, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry.";
const CONDENSED_PHRASES = Object.freeze({
importers: 'the files that import this file',
'public-api': 'the public functions/classes affected',
'local-callers': 'the call sites in this file or its module that rely on the change',
callers: 'the file(s) and line(s) that will call it',
'no-duplicate': 'that no existing file serves the same purpose',
'data-schema': 'the data schemas it reads or writes',
'external-contract': 'what outside the repository fixes the format or semantics',
'quote-instruction': "the user's verbatim instruction"
});
/** Condensed-hint phrase for a code question id, or '' when it has none. */
function condensedQuestionPhrase(id) {
return Object.hasOwn(CONDENSED_PHRASES, id) ? CONDENSED_PHRASES[id] : '';
}
function joinPhrases(phrases) {
if (phrases.length <= 2) return phrases.join(' and ');
return `${phrases.slice(0, -1).join(', ')}, and ${phrases[phrases.length - 1]}`;
}
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."
};
/** One-line condensed hint matching `questionIdsFor`. */
function condensedHintFor(cls, isWrite, profile) {
if (Object.hasOwn(CLASS_CONDENSED_HINTS, cls)) return CLASS_CONDENSED_HINTS[cls](Boolean(isWrite));
if (!isWrite && !(profile && profile.known === true)) return CODE_CONDENSED_HINT;
const phrases = questionIdsFor(cls, isWrite, profile).map(condensedQuestionPhrase).filter(Boolean);
return `briefly state ${joinPhrases(phrases)}, then retry.`;
}
// --- Sensitive targets ---
// see docs/gateguard/design-notes.md#sensitive-targets
const SENSITIVE_EXTS = new Set(['.pem', '.key', '.p12', '.pfx']);
const SENSITIVE_BASENAME_PATTERN = /^(\.env($|\.)|id_rsa|id_ed25519|id_ecdsa|id_dsa|\.netrc$|\.pgpass$|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;
return `/${segments.join('/')}`.includes(`/${SENSITIVE_PREFIX}`);
} catch (_) {
return true;
}
}
/** Sensitive on the lexical or the real (symlink-resolved) path; 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;
}
}
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));
}
// --- Hard-linked targets ---
// see docs/gateguard/design-notes.md#hard-linked-targets
const MISSING_TARGET_CODES = new Set(['ENOENT', 'ENOTDIR']);
function hasOtherLinks(stat) {
return !stat.isDirectory() && stat.nlink > 1;
}
/** True when the target file has more than one hard link; a stat error other than a missing file counts as linked. */
function isHardLinkedTargetFor(filePath, data) {
try {
const target = resolveTargetPath(filePath, data);
if (!target) return true;
if (!isHostPathStyle(target.isWin)) return false;
const stat = fs.lstatSync(target.resolved);
if (!stat.isSymbolicLink()) return hasOtherLinks(stat);
return hasOtherLinks(fs.statSync(target.resolved));
} catch (error) {
return !(error && MISSING_TARGET_CODES.has(error.code));
}
}
// --- Sibling collapse eligibility ---
// see docs/gateguard/design-notes.md#sibling-collapse
const COLLAPSIBLE_CLASSES = new Set(['code', 'test', 'prose']);
const SHORT_NAME_PATTERN = /~\d/;
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);
}
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;
}
}
}
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;
}
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. */
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 = {
questionsUseProfile,
WINDOWS_PATH_PATTERN,
COLLAPSIBLE_CLASSES,
CLASS_QUESTIONS,
CLASS_CONDENSED_HINTS,
QUOTE_INSTRUCTION,
questionIdsFor,
questionText,
condensedHintFor,
condensedQuestionPhrase,
resolveTargetPath,
canonicalPathKey,
classifyTarget,
classPathFor,
classifyTargetFor,
isCollapsibleTarget,
collapseGateDir,
isSensitiveTarget,
isSensitiveTargetFor,
isHardLinkedTargetFor
};
+280
View File
@@ -0,0 +1,280 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./file-tail');
const TRANSCRIPT_SCAN_MAX_LINES = 2000;
// see docs/gateguard/design-notes.md#read-is-not-evidence
const SEARCH_TOOL_NAMES = new Set(['Glob', 'Grep', 'LS', 'Bash', 'PowerShell']);
const SHELL_TOOL_NAMES = new Set(['Bash', 'PowerShell']);
const TOOL_USE_ERROR_PREFIX = '<tool_use_error>';
function isObject(value) {
return Boolean(value) && typeof value === 'object';
}
// see docs/gateguard/design-notes.md#fail-to-deny
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')
);
}
/** True for a compaction summary or boundary record. */
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;
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)}`;
}
/** True for a user record made only of `tool_result` blocks. */
// see docs/gateguard/design-notes.md#turn-boundaries
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 start of the current turn; null when unavailable. */
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),
reads: collectTurnReads(toolUses, pendingId)
});
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);
}
}
// see docs/gateguard/design-notes.md#turn-identity
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: [], reads: [] };
}
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;
// see docs/gateguard/design-notes.md#tool-result-pairing
if (idCounts.get(block.id) !== 1) return;
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 collectTurnReads(toolUsesNewestFirst, pendingId) {
const reads = [];
toolUsesNewestFirst
.filter(({ block }) => !pendingId || block.id !== pendingId)
.forEach(({ block }, index) => {
if (block.name !== 'Read' || !isObject(block.input) || typeof block.input.file_path !== 'string') return;
reads.push({ name: 'Read', path: block.input.file_path, callsAgo: index + 1 });
});
return reads;
}
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 : ''));
}
/** Message id of the pending call's batch, whose searches never credit. */
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;
};
}
// see docs/gateguard/design-notes.md#search-prefilter
function targetNeedle(filePath) {
const base = (String(filePath).split(/[\\/]/).pop() || '').toLowerCase();
const ext = path.posix.extname(base);
let stem = ext ? base.slice(0, -ext.length) : base;
if (stem.endsWith('.test') || stem.endsWith('.spec')) stem = stem.slice(0, -5);
if (stem.startsWith('test_')) stem = stem.slice(5);
if (stem.endsWith('_test')) stem = stem.slice(0, -5);
return stem;
}
/** False only when no search of the turn can name the target's stem, so matching can be skipped. */
function searchesMayNameTarget(scan, filePath) {
if (!scan || !Array.isArray(scan.searches) || scan.searches.length === 0) return false;
const needle = targetNeedle(filePath);
if (!needle) return true;
return scan.searches.some(search => {
const input = search && search.input;
if (!isObject(input)) return false;
let text = '';
for (const value of Object.values(input)) {
if (typeof value === 'string') text += `\n${value}`;
}
return text.toLowerCase().replace(/["'\\]/g, '').includes(needle);
});
}
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),
searchesMayNameTarget: neverThrows(searchesMayNameTarget, true)
};
+2 -34
View File
@@ -17,14 +17,13 @@
* keeping the PreToolUse hook fast even for very large sessions.
*/
const fs = require('fs');
const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./file-tail');
const STANDARD_CONTEXT_WINDOW_TOKENS = 200000;
const LARGE_CONTEXT_WINDOW_TOKENS = 1000000;
const DEFAULT_CONTEXT_THRESHOLD_STANDARD = 160000;
const DEFAULT_CONTEXT_THRESHOLD_LARGE = 250000;
const DEFAULT_CONTEXT_INTERVAL_TOKENS = 60000;
const DEFAULT_TRANSCRIPT_TAIL_BYTES = 256 * 1024;
const MAX_TOKEN_SETTING = 10000000;
const LARGE_WINDOW_MODEL_MARKER = '[1m]';
@@ -59,38 +58,6 @@ function isKnownModelFamilyMatch(model, familyId) {
* Read the trailing `tailBytes` of a file as UTF-8.
* Returns null when the file is missing or unreadable.
*/
function readFileTail(filePath, tailBytes) {
let fd;
try {
fd = fs.openSync(filePath, 'r');
} catch {
return null;
}
try {
const size = fs.fstatSync(fd).size;
const start = Math.max(0, size - tailBytes);
const length = size - start;
if (length <= 0) {
return { text: '', truncated: false };
}
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
return {
text: buffer.toString('utf8', 0, bytesRead),
truncated: start > 0
};
} catch {
return null;
} finally {
try {
fs.closeSync(fd);
} catch {
/* ignore */
}
}
}
/**
* Extract the context token total from a transcript record's usage block.
@@ -281,6 +248,7 @@ module.exports = {
DEFAULT_CONTEXT_THRESHOLD_LARGE,
DEFAULT_CONTEXT_INTERVAL_TOKENS,
DEFAULT_TRANSCRIPT_TAIL_BYTES,
readFileTail,
readLatestContextTokens,
resolveContextWindow,
resolveContextWindowTokens,