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