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

This commit is contained in:
haelyra
2026-10-10 17:46:23 -04:00
73 changed files with 29239 additions and 165 deletions
+277
View File
@@ -0,0 +1,277 @@
/**
* Tests for scripts/dev/gateguard-eval.js argument parsing and SARIF output.
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { pathToFileURL } = require('url');
const {
parseArgs, renderSarif, renderMarkdown, loadCorpus, runCorpus, summarize, measureColdLatency, coldSummary, gateFails
} = require('../../scripts/dev/gateguard-eval');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
passed++;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
failed++;
}
}
function step(overrides) {
return {
scenario: 'sensitive-targets',
file: '09-sensitive-targets.json',
step: 'edit-env',
expect: 'deny',
decision: 'deny',
kind: 'deny',
mustDeny: true,
redundant: false,
...overrides
};
}
function summary(steps, totals = {}) {
return {
totals: { steps: steps.length, denials: 0, mustDenyBypasses: 0, mismatches: 0, explicitAllows: 0, errors: 0, skippedScenarios: 0, ...totals },
latency: { p50Ms: 1.234, p90Ms: 4.321, p95Ms: 5.678 },
perScenario: [],
steps
};
}
function report(workingSteps, baselineTotals = { mustDenyBypasses: 8 }) {
return {
corpus: { scenarios: 1, steps: workingSteps.length },
hooks: [
{ label: 'working tree', ref: null, summary: summary(workingSteps) },
{ label: 'upstream/main', ref: 'upstream/main', summary: summary([], baselineTotals) }
]
};
}
function results(log) {
return log.runs[0].results;
}
console.log('\n=== Testing gateguard-eval ===\n');
test('--sarif takes a file path and keeps the other options', () => {
const options = parseArgs(['--json', '--sarif', 'out/gate.sarif', '--baseline', 'main']);
assert.strictEqual(options.format, 'json');
assert.strictEqual(options.sarif, path.resolve('out/gate.sarif'));
assert.deepStrictEqual(options.baselines, ['main']);
});
test('--sarif without a value is rejected', () => {
assert.throws(() => parseArgs(['--sarif']), /unknown or incomplete argument: --sarif/);
});
test('a clean working tree produces a valid SARIF log with no results', () => {
const log = JSON.parse(renderSarif(report([step({}), step({ step: 'read-only', mustDeny: false, expect: 'allow', decision: 'allow', kind: 'pass' })])));
assert.strictEqual(log.version, '2.1.0');
assert.strictEqual(log.runs.length, 1);
assert.strictEqual(log.runs[0].tool.driver.name, 'gateguard-eval');
assert.deepStrictEqual(results(log), []);
});
test('every rule declares a default level', () => {
const rules = JSON.parse(renderSarif(report([]))).runs[0].tool.driver.rules;
assert.deepStrictEqual(
rules.map(rule => [rule.id, rule.defaultConfiguration.level]),
[
['gateguard/must-deny-bypass', 'error'],
['gateguard/explicit-allow', 'error'],
['gateguard/hook-error', 'error'],
['gateguard/expectation-mismatch', 'warning']
]
);
});
test('a must-deny step that was allowed is an error on its scenario file', () => {
const log = JSON.parse(renderSarif(report([step({ decision: 'allow', kind: 'credit' })])));
assert.strictEqual(results(log).length, 1);
const [result] = results(log);
assert.strictEqual(result.ruleId, 'gateguard/must-deny-bypass');
assert.strictEqual(result.level, 'error');
assert.strictEqual(result.locations[0].physicalLocation.artifactLocation.uri, 'tests/fixtures/gateguard-scenarios/09-sensitive-targets.json');
assert.strictEqual(result.locations[0].physicalLocation.artifactLocation.uriBaseId, '%SRCROOT%');
assert.match(result.message.text, /sensitive-targets \/ edit-env: expected deny, got allow \(credit\)/);
});
test('a finding from another corpus inside the repository links to that corpus', () => {
const failing = report([step({ decision: 'allow', kind: 'credit', file: 'custom.json' })]);
failing.corpusDir = path.join(__dirname, '..', 'fixtures', 'other-scenarios');
const location = results(JSON.parse(renderSarif(failing)))[0].locations[0].physicalLocation.artifactLocation;
assert.deepStrictEqual(location, { uri: 'tests/fixtures/other-scenarios/custom.json', uriBaseId: '%SRCROOT%' });
});
test('a finding from a corpus outside the repository links to its absolute file', () => {
const failing = report([step({ decision: 'allow', kind: 'credit', file: 'custom.json' })]);
failing.corpusDir = path.join(os.tmpdir(), 'gateguard-corpus');
const location = results(JSON.parse(renderSarif(failing)))[0].locations[0].physicalLocation.artifactLocation;
assert.deepStrictEqual(location, { uri: pathToFileURL(path.join(os.tmpdir(), 'gateguard-corpus', 'custom.json')).href });
});
test('an explicit allow and a hook error are reported as errors', () => {
const log = JSON.parse(
renderSarif(
report([
step({ step: 'a', mustDeny: false, expect: 'allow', decision: 'allow', kind: 'pass', explicitAllow: true }),
step({ step: 'b', mustDeny: false, error: 'boom' })
])
)
);
assert.deepStrictEqual(results(log).map(result => [result.ruleId, result.level]), [
['gateguard/explicit-allow', 'error'],
['gateguard/hook-error', 'error']
]);
});
test('a wrong decision that is not a bypass is a warning', () => {
const log = JSON.parse(renderSarif(report([step({ mustDeny: false, expect: 'allow', decision: 'deny', kind: 'deny' })])));
assert.deepStrictEqual(results(log).map(result => [result.ruleId, result.level]), [['gateguard/expectation-mismatch', 'warning']]);
});
test('a bypass is not reported a second time as a mismatch', () => {
const log = JSON.parse(renderSarif(report([step({ decision: 'allow', kind: 'pass' })])));
assert.deepStrictEqual(results(log).map(result => result.ruleId), ['gateguard/must-deny-bypass']);
});
test('baseline totals are recorded without their failures becoming results', () => {
const log = JSON.parse(renderSarif(report([step({})])));
const hooks = log.runs[0].properties.hooks;
assert.deepStrictEqual(hooks.map(hook => [hook.label, hook.totals.mustDenyBypasses]), [
['working tree', 0],
['upstream/main', 8]
]);
assert.deepStrictEqual(results(log), []);
});
test('output leaves out timing so reruns are byte-identical', () => {
const text = renderSarif(report([step({ latencyMs: 3.21 })]));
assert.ok(!/latency|p50|p95|Time/i.test(text), 'timing leaked into SARIF');
const slower = report([step({ latencyMs: 9.87 })]);
slower.hooks[0].summary.latency = { p50Ms: 9, p90Ms: 90, p95Ms: 99 };
assert.strictEqual(renderSarif(slower), text);
});
test('--no-cold turns off the fresh-process pass', () => {
assert.strictEqual(parseArgs([]).cold, true);
assert.strictEqual(parseArgs(['--no-cold']).cold, false);
});
// A fresh-process failure is recorded as an allow. Where the worker also allowed the
// step it is no disagreement, so before failures were counted on their own it left
// no trace in the report, the latency or the exit status.
test('a fresh-process failure on a step the worker allows is still counted', () => {
const cold = coldSummary(
[{ scenario: 's', step: 'a', latencyMs: null, decision: 'allow', error: 'killed' }],
[{ scenario: 's', step: 'a', decision: 'allow' }]
);
assert.strictEqual(cold.disagreements, 0);
assert.strictEqual(cold.errors, 1);
assert.strictEqual(cold.samples, 0);
assert.strictEqual(cold.p50Ms, null, 'no measured step must not read as a 0 ms latency');
});
test('a fresh-process failure fails the gate when nothing else does', () => {
const clean = report([step()]);
clean.hooks[0].summary.cold = { p50Ms: 1, p90Ms: 1, p95Ms: 1, samples: 1, errors: 0, disagreements: 0 };
assert.strictEqual(gateFails(clean), false);
const failed = report([step()]);
failed.hooks[0].summary.cold = { p50Ms: null, p90Ms: null, p95Ms: null, samples: 0, errors: 1, disagreements: 0 };
assert.strictEqual(gateFails(failed), true);
});
test('the report names fresh-process failures and invents no latency', () => {
// summarize([]) is the real summary shape with every count at zero, so this keeps
// working as metrics are added, which a hand-built totals object would not.
const working = { ...summarize([]), cold: { p50Ms: null, p90Ms: null, p95Ms: null, samples: 0, errors: 1, disagreements: 0 } };
const failed = {
corpus: { scenarios: 0, steps: 0 },
hooks: [
{ label: 'working tree', ref: null, summary: working },
{ label: 'upstream/main', ref: 'upstream/main', summary: summarize([]) }
]
};
const text = renderMarkdown(failed);
assert.match(text, /1 step\(s\) failed in a fresh process/);
assert.match(text, /no valid sample/);
assert.ok(!/fresh process \(ms\)[^\n]*\b0\.00\b/.test(text), 'a latency was reported with no measured step');
});
async function coldTests() {
const corpusDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-eval-corpus-'));
try {
fs.copyFileSync(
path.join(__dirname, '..', 'fixtures', 'gateguard-scenarios', '11-first-shell-commands.json'),
path.join(corpusDir, 'shell.json')
);
const scenarios = loadCorpus(corpusDir);
const hookFile = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js');
const warm = summarize(await runCorpus(hookFile, scenarios));
const cold = measureColdLatency(hookFile, scenarios);
test('the fresh-process pass times every step', () => {
assert.strictEqual(cold.length, scenarios[0].steps.length);
assert.ok(cold.every(step => Number.isFinite(step.latencyMs) && step.latencyMs > 0));
});
test('fresh processes decide every step as the worker does', () => {
assert.deepStrictEqual(cold.map(step => step.decision), warm.steps.map(step => step.decision));
});
// A hook that throws used to end the fresh-process pass, so neither the report
// nor the SARIF output was written for a run that had hit one bad step.
const throwingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-eval-throw-'));
try {
const throwingHook = path.join(throwingDir, 'throwing-hook.js');
fs.writeFileSync(throwingHook, "'use strict';\nmodule.exports = { run() { throw new Error('boom'); } };\n");
const warmThrow = summarize(await runCorpus(throwingHook, scenarios));
let coldThrow = null;
let escaped = null;
try {
coldThrow = measureColdLatency(throwingHook, scenarios);
} catch (error) {
escaped = error;
}
test('a hook that throws does not end the fresh-process pass', () => {
assert.strictEqual(escaped, null, escaped && escaped.message);
assert.strictEqual(coldThrow.length, scenarios[0].steps.length);
assert.ok(coldThrow.every(step => step.error === 'boom'), JSON.stringify(coldThrow.map(step => step.error)));
});
test('a throwing step is decided as the worker decides it, so it is no disagreement', () => {
assert.deepStrictEqual(coldThrow.map(step => step.decision), warmThrow.steps.map(step => step.decision));
});
test('every throwing step is reported as a fresh-process failure', () => {
assert.strictEqual(coldSummary(coldThrow, warmThrow.steps).errors, coldThrow.length);
});
} finally {
fs.rmSync(throwingDir, { recursive: true, force: true });
}
} finally {
fs.rmSync(corpusDir, { recursive: true, force: true });
}
}
coldTests().then(finish, error => {
console.log(` ✗ fresh-process pass failed: ${error.message}`);
failed++;
finish();
});
function finish() {
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exitCode = failed > 0 ? 1 : 0;
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Tests for scripts/dev/gateguard-latency.js.
*/
'use strict';
const assert = require('assert');
const path = require('path');
const { quantile, bootstrapDifference, verdict, summarize, measure, parseArgs } = require('../../scripts/dev/gateguard-latency');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
passed++;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
failed++;
}
}
console.log('\n=== Testing gateguard-latency ===\n');
test('quantiles interpolate between ranks', () => {
assert.strictEqual(quantile([1, 2, 3, 4], 0.5), 2.5);
assert.strictEqual(quantile([4, 1, 3, 2], 0), 1);
assert.strictEqual(quantile([4, 1, 3, 2], 1), 4);
assert.ok(Math.abs(quantile([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 0.9) - 9.1) < 1e-9);
assert.ok(Number.isNaN(quantile([], 0.5)));
});
const slow = Array.from({ length: 40 }, (_, i) => 20 + (i % 5));
const fast = Array.from({ length: 40 }, (_, i) => 10 + (i % 5));
const same = Array.from({ length: 40 }, (_, i) => 20 + ((i * 7) % 5));
test('a clearly faster sample is reported faster at p50 and p90', () => {
assert.strictEqual(verdict(bootstrapDifference(slow, fast, 0.5)), 'faster');
assert.strictEqual(verdict(bootstrapDifference(slow, fast, 0.9)), 'faster');
});
test('a clearly slower sample is reported slower', () => {
assert.strictEqual(verdict(bootstrapDifference(fast, slow, 0.5)), 'slower');
});
test('samples from the same distribution show no measurable change', () => {
assert.strictEqual(verdict(bootstrapDifference(slow, same, 0.5)), 'no measurable change');
assert.strictEqual(verdict(bootstrapDifference(slow, same, 0.9)), 'no measurable change');
});
test('the interval contains the observed difference and is reproducible', () => {
const interval = bootstrapDifference(slow, fast, 0.5);
const observed = quantile(fast, 0.5) - quantile(slow, 0.5);
assert.ok(interval[0] <= observed && observed <= interval[1], `${observed} outside ${interval}`);
assert.deepStrictEqual(bootstrapDifference(slow, fast, 0.5), interval);
});
test('summaries compare the working tree with every baseline', () => {
const rows = summarize({ call: { 'working tree': fast, main: slow } }, ['working tree', 'main']);
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows[0].comparisons[0].baseline, 'main');
assert.strictEqual(rows[0].comparisons[0].p50Verdict, 'faster');
assert.strictEqual(rows[0].quantiles.main.p50, quantile(slow, 0.5));
});
test('arguments default to upstream/main and validate --runs', () => {
assert.deepStrictEqual(parseArgs([]).baselines, ['upstream/main']);
assert.strictEqual(parseArgs(['--runs', '5']).runs, 5);
assert.throws(() => parseArgs(['--runs', '0']), /incomplete argument/);
assert.throws(() => parseArgs(['--runs']), /incomplete argument/);
});
test('one round times every call type in fresh processes', () => {
const hookFile = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js');
const samples = measure([{ label: 'working tree', file: hookFile }], { runs: 1 });
const calls = Object.keys(samples);
assert.strictEqual(calls.length, 7);
for (const call of calls) {
assert.strictEqual(samples[call]['working tree'].length, 1);
assert.ok(samples[call]['working tree'][0] > 0, call);
}
});
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exitCode = failed > 0 ? 1 : 0;
+182
View File
@@ -0,0 +1,182 @@
/**
* Tests for scripts/gateguard-report.js
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'gateguard-report.js');
const FIXTURE_DIR = path.join(__dirname, '..', 'fixtures', 'gateguard-metrics');
const { loadMetrics, summarize, formatReport, parseArgs } = require('../../scripts/gateguard-report');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
passed++;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
failed++;
}
}
function run(args, env = {}) {
const result = spawnSync('node', [SCRIPT, ...args], {
encoding: 'utf8',
env: { ...process.env, ...env },
timeout: 10000
});
return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '' };
}
console.log('\n=== Testing gateguard-report ===\n');
const loaded = loadMetrics(FIXTURE_DIR);
const report = summarize(loaded.events);
test('reads the rotated file first, then the current file', () => {
assert.strictEqual(loaded.events.length, 12);
assert.strictEqual(loaded.events[0].ts, '2026-09-01T10:00:00.000Z');
assert.strictEqual(loaded.events[11].decision, 'pass-checked');
assert.deepStrictEqual(loaded.files.map(f => path.basename(f)), ['metrics.jsonl.1', 'metrics.jsonl']);
});
test('skips malformed, unknown-version, unknown-decision and non-code lines', () => {
assert.strictEqual(loaded.skipped, 6);
assert.ok(loaded.events.every(e => e.session === 'aaaaaaaaaaaa' || e.session === 'bbbbbbbbbbbb'));
assert.ok(!loaded.events.some(e => String(e.reason).includes('/')));
});
test('counts decisions by type', () => {
assert.deepStrictEqual(report.total.decisions, {
deny: 4,
cap: 1,
credit: 1,
'destructive-deny': 1,
'pass-checked': 1,
'routine-deny': 1,
'routine-readonly': 1,
sibling: 1,
trivial: 1
});
assert.strictEqual(report.total.events, 12);
});
test('breaks denials down by class and reason', () => {
assert.strictEqual(report.total.denials.total, 6);
assert.deepStrictEqual(report.total.denials.byClass, { code: 3, shell: 2, config: 1 });
assert.deepStrictEqual(report.total.denials.byReason, {
'first-touch': 2,
destructive: 1,
'first-command': 1,
'near-miss:out-of-scope': 1,
sensitive: 1
});
});
test('reports the near-miss share of first-touch denials', () => {
assert.deepStrictEqual(report.total.nearMiss, { count: 1, of: 4, share: 0.25 });
});
test('reports first-touch outcome rates', () => {
const { firstTouch } = report.total;
assert.strictEqual(firstTouch.total, 8);
assert.deepStrictEqual(firstTouch.counts, { deny: 4, credit: 1, sibling: 1, cap: 1, trivial: 1 });
assert.strictEqual(firstTouch.rates.deny, 0.5);
assert.strictEqual(firstTouch.rates.trivial, 0.125);
});
test('counts routine read-only passes and ranks the most-asked questions', () => {
assert.strictEqual(report.total.routineReadonlyPasses, 1);
assert.deepStrictEqual(report.total.topQuestions.slice(0, 3), [
['quote-instruction', 4],
['callers', 1],
['config-effect', 1]
]);
});
test('summarizes each session separately in order of first appearance', () => {
assert.deepStrictEqual(Object.keys(report.sessions), ['aaaaaaaaaaaa', 'bbbbbbbbbbbb']);
const a = report.sessions.aaaaaaaaaaaa;
assert.strictEqual(a.events, 6);
assert.strictEqual(a.denials.total, 3);
assert.deepStrictEqual(a.nearMiss, { count: 1, of: 2, share: 0.5 });
assert.strictEqual(a.firstTouch.total, 4);
assert.strictEqual(a.first, '2026-09-01T10:00:00.000Z');
assert.strictEqual(a.last, '2026-09-01T10:05:00.000Z');
const b = report.sessions.bbbbbbbbbbbb;
assert.strictEqual(b.events, 6);
assert.deepStrictEqual(b.denials.byClass, { code: 1, config: 1, shell: 1 });
assert.strictEqual(b.routineReadonlyPasses, 0);
});
test('reports empty rates as null instead of dividing by zero', () => {
const empty = summarize([]);
assert.strictEqual(empty.total.events, 0);
assert.strictEqual(empty.total.nearMiss.share, null);
assert.strictEqual(empty.total.firstTouch.rates.deny, null);
});
test('formats a readable text report', () => {
const text = formatReport({ ...loaded, report });
assert.ok(text.includes('12 events in 2 sessions (6 malformed lines skipped)'));
assert.ok(text.includes('Total'));
assert.ok(text.includes('Session aaaaaaaaaaaa'));
assert.ok(text.includes('near-miss share of first-touch denials: 25.0% (1 of 4)'));
assert.ok(text.includes('routine read-only passes: 1'));
assert.ok(text.includes('quote-instruction 4'));
});
test('parses --dir, --dir=, --json and --help', () => {
assert.deepStrictEqual(parseArgs(['--dir', '/x', '--json']), { dir: '/x', json: true, help: false });
assert.deepStrictEqual(parseArgs(['--dir=/y']), { dir: '/y', json: false, help: false });
assert.strictEqual(parseArgs(['-h']).help, true);
assert.throws(() => parseArgs(['--dir']), /Missing value for --dir/);
assert.throws(() => parseArgs(['--bogus']), /Unknown argument: --bogus/);
});
test('CLI prints JSON with --json', () => {
const result = run(['--dir', FIXTURE_DIR, '--json']);
assert.strictEqual(result.status, 0, result.stderr);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.events, 12);
assert.strictEqual(parsed.skipped, 6);
assert.strictEqual(parsed.total.denials.total, 6);
assert.deepStrictEqual(Object.keys(parsed.sessions), ['aaaaaaaaaaaa', 'bbbbbbbbbbbb']);
});
test('CLI reads GATEGUARD_STATE_DIR when --dir is not given', () => {
const result = run([], { GATEGUARD_STATE_DIR: FIXTURE_DIR });
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(result.stdout.includes('12 events in 2 sessions'));
});
test('CLI explains how to enable metrics when none are found', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-report-'));
try {
const result = run(['--dir', dir]);
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(result.stdout.includes('No GateGuard metrics'));
assert.ok(result.stdout.includes('GATEGUARD_METRICS=1'));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('CLI exits 1 on an unknown argument', () => {
const result = run(['--bogus']);
assert.strictEqual(result.status, 1);
assert.ok(result.stderr.includes('Unknown argument: --bogus'));
});
console.log(`\n ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
@@ -108,6 +108,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/welcome.js",
"scripts/gemini-adapt-agents.js",
"scripts/qwen-adapt-agents.js",
"scripts/gateguard-report.js",
"scripts/sync-ecc-to-codex.sh",
"scripts/codex/legacy-sync-state.js",
"scripts/codex/install-global-git-hooks.sh",