@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/bin/agentic-security.js +87 -0
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +1 -1
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +5 -2
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +1 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Label custody and leakage controls (QA-002.AC03).
|
|
2
|
+
//
|
|
3
|
+
// Two trust domains meet here. The CUSTODIAN (verifier domain) holds adjudicated
|
|
4
|
+
// labels. The engine under test, and any worker or model prompt, is on the other
|
|
5
|
+
// side and must never see an answer key. This module provides the custodian's
|
|
6
|
+
// store, the workspace staging that builds what the engine is allowed to read,
|
|
7
|
+
// and the audits that fail a run when anything label-shaped crosses over.
|
|
8
|
+
//
|
|
9
|
+
// Enforcement, honestly stated:
|
|
10
|
+
// - role checks use the existing trust-domain policy (`mayDo`): workers and
|
|
11
|
+
// targets are denied sealed-label read AND write;
|
|
12
|
+
// - staging and prompt audits are PATTERN checks over known protected terms and
|
|
13
|
+
// file names. They catch planted and accidental leaks of the kinds listed in
|
|
14
|
+
// `leakFixtures`; they are not a proof that no paraphrase gets through;
|
|
15
|
+
// - process-level read denial of the label directory is the sandbox trust
|
|
16
|
+
// boundary's job (`runInBoundary({ labelDirs })`), which this module only
|
|
17
|
+
// feeds. Where no backend proves that control the boundary reports `blocked`.
|
|
18
|
+
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import { DOMAINS, RESOURCES, mayDo, isUntrusted, scrubEnv } from '../../sandbox/trust-domains.js';
|
|
22
|
+
import { digestOf } from '../assurance/identity.js';
|
|
23
|
+
import { STATE_DIR_NAME } from '../state-dir.js';
|
|
24
|
+
import { validateDefectLabel, validateNegativeLabel } from './labels.js';
|
|
25
|
+
|
|
26
|
+
export const LABEL_FILE = 'adjudicated-labels.json';
|
|
27
|
+
|
|
28
|
+
// File names that look like an answer key. Regexes, not literals, so this file
|
|
29
|
+
// is not itself a reference to any particular key file.
|
|
30
|
+
const LEAK_FILENAME_PATTERNS = Object.freeze([
|
|
31
|
+
/(^|\/)(labels?|answers?|expected|ground[-_]?truth|answer[-_]?key|adjudicat\w*|oracle)[^/]*\.(json|jsonl|ya?ml|csv|txt)$/i,
|
|
32
|
+
/(^|\/)result\.json$/i,
|
|
33
|
+
/(^|\/)corpus-baseline[^/]*$/i,
|
|
34
|
+
/(^|\/)(advisory|advisories|ghsa|osv)[^/]*\.(json|md|txt|ya?ml)$/i,
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const BENCHMARK_NAMES = Object.freeze(['owasp benchmark', 'juliet', 'sard test', 'cve-replay', 'corpus-baseline', 'answer key', 'ground truth']);
|
|
38
|
+
const ADVISORY_ID = /\b(CVE-\d{4}-\d{4,}|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4})\b/i;
|
|
39
|
+
const MIN_TERM = 8;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Terms that must never reach the engine or a prompt: reviewer evidence refs and
|
|
43
|
+
* rationale, advisory ids and prose, root-cause ids, and fixed-source hints.
|
|
44
|
+
*/
|
|
45
|
+
export function protectedTermsFrom({ defects = [], negatives = [], advisoryText = [], fixedSourceHints = [], benchmarkNames = BENCHMARK_NAMES } = {}) {
|
|
46
|
+
const terms = new Set();
|
|
47
|
+
const add = (s) => { if (typeof s === 'string' && s.trim().length >= MIN_TERM) terms.add(s.trim().toLowerCase()); };
|
|
48
|
+
for (const d of defects) {
|
|
49
|
+
add(d.rootCauseId);
|
|
50
|
+
for (const e of d.evidence || []) add(e.ref);
|
|
51
|
+
for (const a of d.advisoryRefs || []) add(a);
|
|
52
|
+
}
|
|
53
|
+
for (const n of negatives) add(n.rationale);
|
|
54
|
+
for (const s of [...advisoryText, ...fixedSourceHints, ...benchmarkNames]) add(s);
|
|
55
|
+
return [...terms].sort();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Leaks in a piece of text (a prompt, a file body). */
|
|
59
|
+
export function auditText(text, terms, { strictAdvisoryIds = true } = {}) {
|
|
60
|
+
const hay = String(text ?? '');
|
|
61
|
+
const lower = hay.toLowerCase();
|
|
62
|
+
const leaks = [];
|
|
63
|
+
for (const t of terms || []) if (lower.includes(t)) leaks.push({ kind: 'protected-term', detail: t.length > 60 ? `${t.slice(0, 57)}...` : t });
|
|
64
|
+
if (strictAdvisoryIds) {
|
|
65
|
+
const m = ADVISORY_ID.exec(hay);
|
|
66
|
+
if (m) leaks.push({ kind: 'advisory-id', detail: m[1] });
|
|
67
|
+
}
|
|
68
|
+
return leaks;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Gate a model/scanner prompt. `ok:false` means the call must not be made. */
|
|
72
|
+
export function guardPrompt(prompt, terms, opts) {
|
|
73
|
+
const leaks = auditText(prompt, terms, opts);
|
|
74
|
+
return { ok: leaks.length === 0, leaks };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const SKIP_DIRS = new Set(['.git', STATE_DIR_NAME, 'node_modules']);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Audit a directory the engine may read: no answer-key file names, no symlinks
|
|
81
|
+
* (which could point outside it), no protected term or advisory id in content.
|
|
82
|
+
* Bounded so a hostile tree cannot make the audit itself the problem.
|
|
83
|
+
*/
|
|
84
|
+
export function auditWorkspace(dir, terms, { strictAdvisoryIds = true, maxFiles = 20000, maxFileBytes = 2 * 1024 * 1024 } = {}) {
|
|
85
|
+
const leaks = [];
|
|
86
|
+
let scanned = 0; let skippedLarge = 0; let truncated = false;
|
|
87
|
+
const walk = (d, rel) => {
|
|
88
|
+
let entries = [];
|
|
89
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
90
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
91
|
+
if (truncated) return;
|
|
92
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
93
|
+
if (e.isSymbolicLink()) { leaks.push({ kind: 'symlink', file: r, detail: 'symlinks are refused in an engine workspace' }); continue; }
|
|
94
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(path.join(d, e.name), r); continue; }
|
|
95
|
+
if (!e.isFile()) continue;
|
|
96
|
+
if (++scanned > maxFiles) { truncated = true; leaks.push({ kind: 'audit-truncated', file: r, detail: `more than ${maxFiles} files; the audit cannot vouch for the rest` }); return; }
|
|
97
|
+
if (LEAK_FILENAME_PATTERNS.some((re) => re.test(r))) { leaks.push({ kind: 'answer-key-file', file: r, detail: 'file name looks like a label, answer key or advisory record' }); continue; }
|
|
98
|
+
let st; try { st = fs.statSync(path.join(d, e.name)); } catch { continue; }
|
|
99
|
+
if (st.size > maxFileBytes) { skippedLarge++; continue; }
|
|
100
|
+
let body; try { body = fs.readFileSync(path.join(d, e.name), 'utf8'); } catch { continue; }
|
|
101
|
+
for (const l of auditText(body, terms, { strictAdvisoryIds })) leaks.push({ ...l, file: r });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
walk(dir, '');
|
|
105
|
+
return { ok: leaks.length === 0, leaks, scannedFiles: scanned, skippedLargeFiles: skippedLarge };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Copy a target tree into a fresh workspace for the engine, then audit it. On any
|
|
110
|
+
* leak the workspace is removed and the target is reported quarantined; the run
|
|
111
|
+
* must record that as a failure, never silently scan the tree anyway.
|
|
112
|
+
*/
|
|
113
|
+
export function stageWorkspace({ srcDir, destDir, terms, strictAdvisoryIds = true }) {
|
|
114
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
115
|
+
fs.cpSync(srcDir, destDir, {
|
|
116
|
+
recursive: true, dereference: false,
|
|
117
|
+
filter: (s) => !SKIP_DIRS.has(path.basename(s)) || s === srcDir,
|
|
118
|
+
});
|
|
119
|
+
const audit = auditWorkspace(destDir, terms, { strictAdvisoryIds });
|
|
120
|
+
if (!audit.ok) {
|
|
121
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
122
|
+
return { ok: false, quarantined: true, leaks: audit.leaks, destDir: null };
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, quarantined: false, leaks: [], destDir };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------- the custodian's store
|
|
128
|
+
|
|
129
|
+
/** Content hash binding a label set: key-order independent, order independent. */
|
|
130
|
+
export function sealLabels(defects, negatives) {
|
|
131
|
+
const sortById = (xs) => [...xs].sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
132
|
+
return digestOf({ kind: 'evaluation-labels', defects: sortById(defects || []), negatives: sortById(negatives || []) });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isCustodian = (actor) => actor?.domain === DOMAINS.VERIFIER && actor?.role === 'custodian' && !isUntrusted(actor.domain);
|
|
136
|
+
|
|
137
|
+
function denied(code, message) { return { ok: false, errors: [{ code, path: '', message }] }; }
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Write the label set. Only the custodian may; a worker or the target is denied
|
|
141
|
+
* by the trust-domain policy. Every label is validated first: one invalid label
|
|
142
|
+
* rejects the whole write.
|
|
143
|
+
*/
|
|
144
|
+
export function custodianWriteLabels(labelDir, actor, { defects = [], negatives = [] } = {}) {
|
|
145
|
+
if (!isCustodian(actor)) return denied('CUSTODY_DENIED', `domain '${actor?.domain}' role '${actor?.role}' may not write sealed labels`);
|
|
146
|
+
const errors = [];
|
|
147
|
+
for (const d of defects) { const v = validateDefectLabel(d); if (!v.ok) errors.push(...v.errors.map((e) => ({ ...e, path: `${d?.id}.${e.path}` }))); }
|
|
148
|
+
for (const n of negatives) { const v = validateNegativeLabel(n); if (!v.ok) errors.push(...v.errors.map((e) => ({ ...e, path: `${n?.id}.${e.path}` }))); }
|
|
149
|
+
if (errors.length) return { ok: false, errors };
|
|
150
|
+
fs.mkdirSync(labelDir, { recursive: true, mode: 0o700 });
|
|
151
|
+
try { fs.chmodSync(labelDir, 0o700); } catch { /* best effort */ }
|
|
152
|
+
const file = path.join(labelDir, LABEL_FILE);
|
|
153
|
+
fs.writeFileSync(file, JSON.stringify({ defects, negatives }), { mode: 0o600 });
|
|
154
|
+
return { ok: true, errors: [], labelsHash: sealLabels(defects, negatives), count: { defects: defects.length, negatives: negatives.length } };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Read the label set. Allowed only where the policy grants sealed-label read, and only if it still matches the sealed hash. */
|
|
158
|
+
export function custodianReadLabels(labelDir, actor, { expectedHash } = {}) {
|
|
159
|
+
if (!mayDo(actor?.domain, RESOURCES.SEALED_LABELS, 'read')) return denied('CUSTODY_DENIED', `domain '${actor?.domain}' may not read sealed labels`);
|
|
160
|
+
let data;
|
|
161
|
+
try { data = JSON.parse(fs.readFileSync(path.join(labelDir, LABEL_FILE), 'utf8')); } catch (e) { return denied('LABELS_UNREADABLE', `cannot read labels: ${e.code || e.message}`); }
|
|
162
|
+
const defects = Array.isArray(data?.defects) ? data.defects : []; const negatives = Array.isArray(data?.negatives) ? data.negatives : [];
|
|
163
|
+
const labelsHash = sealLabels(defects, negatives);
|
|
164
|
+
if (expectedHash && labelsHash !== expectedHash) return denied('LABELS_HASH_MISMATCH', 'labels changed after they were sealed');
|
|
165
|
+
return { ok: true, errors: [], defects, negatives, labelsHash };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// realpath of the nearest existing ancestor plus the not-yet-existing remainder, so a path that does not exist
|
|
169
|
+
// yet still compares correctly against one reached through a symlinked parent (macOS /var -> /private/var).
|
|
170
|
+
function real(p) {
|
|
171
|
+
let cur = path.resolve(p); const rest = [];
|
|
172
|
+
for (;;) {
|
|
173
|
+
try { return path.join(fs.realpathSync(cur), ...rest.reverse()); } catch { /* climb */ }
|
|
174
|
+
const parent = path.dirname(cur);
|
|
175
|
+
if (parent === cur) return path.resolve(p);
|
|
176
|
+
rest.push(path.basename(cur)); cur = parent;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A workspace that contains, or sits inside, the label directory would hand the engine the answer key by construction. */
|
|
181
|
+
export function assertCustodyIsolation(labelDir, workspaceDir) {
|
|
182
|
+
const a = real(labelDir); const b = real(workspaceDir);
|
|
183
|
+
if (a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep)) {
|
|
184
|
+
return denied('CUSTODY_OVERLAP', `workspace ${workspaceDir} overlaps the label directory ${labelDir}`);
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, errors: [] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Environment for the engine under test: secret-looking variables, evaluation variables and anything naming the label directory are removed. */
|
|
190
|
+
export function engineEnvironment(base, { labelDir } = {}) {
|
|
191
|
+
const scrubbed = scrubEnv(base || {});
|
|
192
|
+
const out = {};
|
|
193
|
+
const labelReal = labelDir ? real(labelDir) : null;
|
|
194
|
+
for (const [k, v] of Object.entries(scrubbed)) {
|
|
195
|
+
if (/^AGENTIC_(SECURITY_)?EVAL/i.test(k)) continue;
|
|
196
|
+
if (labelDir && typeof v === 'string' && (v.includes(labelDir) || v.includes(labelReal))) continue;
|
|
197
|
+
out[k] = v;
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// deployment-ablation.js: a frozen, SYNTHETIC deployment-context ablation (X-308).
|
|
2
|
+
//
|
|
3
|
+
// Question: does reading deployment configuration change which findings are worth acting on, compared with the source alone?
|
|
4
|
+
// The test set holds IDENTICAL source deployed under two boundary configurations per case, one exploitable and one not. Two
|
|
5
|
+
// arms are scored on the same instances:
|
|
6
|
+
// source-only the engine's findings on the source, every one reported (what the scan does today);
|
|
7
|
+
// graph-enabled the same findings plus the boundary context built from the deployment files by the shipped
|
|
8
|
+
// `agentic-security boundaries` code path (`runBoundaries`), under the frozen rule below.
|
|
9
|
+
//
|
|
10
|
+
// What this is NOT. The cases, their labels and the decision rule were all authored by the tooling's developers, the labels
|
|
11
|
+
// carry no independent adjudication, and the set is tiny. It is flagged `synthetic: true` everywhere and the real-code gates
|
|
12
|
+
// refuse it (gates.js). It checks a MECHANISM on constructed cases; it estimates nothing about real programs, and one case
|
|
13
|
+
// (the unresolved-ingress one) exists because the rule was tightened after the first run exposed a lost defect. Do not quote
|
|
14
|
+
// its figures as engine accuracy or as a benefit on real code.
|
|
15
|
+
//
|
|
16
|
+
// Frozen with the QA-001 machinery: the case set is bound into a protocol (`freezeProtocol`, hash-pinned, `synthetic: true`)
|
|
17
|
+
// whose target digests pin each case's source tree, and a separate `frozenHash` also covers every deployment tree digest, the
|
|
18
|
+
// labels and the decision rule. `verifyFrozenSet` recomputes both from disk. Nothing here counts toward a real-population gate.
|
|
19
|
+
//
|
|
20
|
+
// Uncertainty uses a SIMPLE PAIRED CASE BOOTSTRAP (resample cases, both of a case's instances travel together, fixed seed).
|
|
21
|
+
// It is not the grouped bootstrap the QA-004 report calls for; that code is not in this base. With a handful of cases the
|
|
22
|
+
// interval is wide and exploratory, and it says so.
|
|
23
|
+
|
|
24
|
+
import * as fs from 'node:fs';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import { digestOf, digestOfBytes } from '../assurance/identity.js';
|
|
27
|
+
import { resolveAssuranceConfig } from '../assurance/config.js';
|
|
28
|
+
import { runBoundaries } from '../../lineage/deployment/boundaries-run.js';
|
|
29
|
+
import { computeAttackPaths } from '../../lineage/deployment/attack-paths.js';
|
|
30
|
+
import { ADAPTER_NAMES } from '../../lineage/deployment/ingest.js';
|
|
31
|
+
import { digestTree, targetDigestOf, runEvaluation } from './runner.js';
|
|
32
|
+
import { freezeProtocol, validateProtocol, CORE_LANGUAGES, PREREGISTERED_THRESHOLDS, DEFAULT_MATCHING } from './protocol.js';
|
|
33
|
+
import { evaluateGates } from './gates.js';
|
|
34
|
+
|
|
35
|
+
export const ABLATION_SET_SCHEMA = 'agentic-security/deployment-ablation-set';
|
|
36
|
+
const ABLATION_REPORT_SCHEMA = 'agentic-security/deployment-ablation-report';
|
|
37
|
+
const ABLATION_VERSION = 1;
|
|
38
|
+
export const VARIANTS = Object.freeze(['exploitable', 'non-exploitable']);
|
|
39
|
+
export const FROZEN_FILE = 'frozen.json';
|
|
40
|
+
const BOOTSTRAP = Object.freeze({ method: 'paired-case-bootstrap', resamples: 4000, seed: 20261009, interval: 0.95 });
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The frozen decision rule of the graph-enabled arm. Changing any of it changes `frozenHash`.
|
|
44
|
+
* - A source finding is demoted (dropped from the reported set) only when its boundary context says exposure is `blocked`, or
|
|
45
|
+
* `none-found` AND the boundary graph has no gap at all. A gap can stand for a missing relationship, so it never licenses
|
|
46
|
+
* treating absence of a path as absence of exposure. An unbound finding, an unresolved or possible exposure is kept.
|
|
47
|
+
* - A deployment-exposure finding is added for a case when a `possible` or `runtime-supported` path leads from an exposed
|
|
48
|
+
* entry point to a privileged resource. A blocked or unresolved path adds nothing.
|
|
49
|
+
*/
|
|
50
|
+
export const DECISION_RULE = Object.freeze({
|
|
51
|
+
id: 'graph-arm-decision-rule', version: 1,
|
|
52
|
+
demoteSourceFindingWhen: Object.freeze(['exposure:blocked', 'exposure:none-found and graph-gap-count:0']),
|
|
53
|
+
keepSourceFindingWhen: Object.freeze(['exposure:possible', 'exposure:runtime-supported', 'exposure:unresolved', 'exposure:not-assessed']),
|
|
54
|
+
addDeploymentFindingWhen: Object.freeze(['path-state:possible or runtime-supported from an exposed entry to a privileged resource']),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const SYNTHETIC_LICENSE = 'synthetic-test-fixture';
|
|
58
|
+
const SHA = (c) => c.repeat(40);
|
|
59
|
+
|
|
60
|
+
function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
61
|
+
|
|
62
|
+
/** Read the case definitions under `<fixturesDir>/cases`. Structure problems are returned, never thrown. */
|
|
63
|
+
export function loadAblationCases(fixturesDir) {
|
|
64
|
+
const errors = [];
|
|
65
|
+
const root = path.join(fixturesDir, 'cases');
|
|
66
|
+
let names = [];
|
|
67
|
+
try { names = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort(); } catch (e) { return { cases: [], errors: [`cannot read ${root}: ${e.code || e.message}`] }; }
|
|
68
|
+
const cases = [];
|
|
69
|
+
for (const name of names) {
|
|
70
|
+
const dir = path.join(root, name);
|
|
71
|
+
let meta;
|
|
72
|
+
try { meta = readJson(path.join(dir, 'case.json')); } catch (e) { errors.push(`${name}: case.json unreadable (${e.message})`); continue; }
|
|
73
|
+
if (meta.id !== name) errors.push(`${name}: case.json id '${meta.id}' does not match the directory`);
|
|
74
|
+
if (!['source-finding', 'deployment-only'].includes(meta.kind)) errors.push(`${name}: kind must be source-finding or deployment-only`);
|
|
75
|
+
for (const v of VARIANTS) {
|
|
76
|
+
if (!fs.existsSync(path.join(dir, v))) errors.push(`${name}: missing deployment '${v}'`);
|
|
77
|
+
const label = meta.deployments?.[v]?.exploitable;
|
|
78
|
+
if (typeof label !== 'boolean') errors.push(`${name}: deployment '${v}' has no boolean exploitable label`);
|
|
79
|
+
else if (label !== (v === 'exploitable')) errors.push(`${name}: deployment '${v}' is labelled exploitable=${label}, which contradicts its directory name`);
|
|
80
|
+
}
|
|
81
|
+
if (!fs.existsSync(path.join(dir, 'source'))) errors.push(`${name}: missing source tree`);
|
|
82
|
+
cases.push({ ...meta, dir });
|
|
83
|
+
}
|
|
84
|
+
if (!cases.length && !errors.length) errors.push('no cases found');
|
|
85
|
+
return { cases, errors };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Identical source across both deployments is structural: the source tree lives once and no deployment tree carries code. */
|
|
89
|
+
export function deploymentTreesCarryNoSource(c) {
|
|
90
|
+
const bad = [];
|
|
91
|
+
for (const v of VARIANTS) {
|
|
92
|
+
const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (/\.(?:[cm]?js|ts|py|java|go|rb|php)$/i.test(e.name)) bad.push(path.relative(c.dir, p)); } };
|
|
93
|
+
walk(path.join(c.dir, v));
|
|
94
|
+
}
|
|
95
|
+
return bad;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function digestsOf(c) {
|
|
99
|
+
return {
|
|
100
|
+
source: digestTree(path.join(c.dir, 'source')),
|
|
101
|
+
deployments: Object.fromEntries(VARIANTS.map((v) => [v, digestTree(path.join(c.dir, v))])),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function caseRecord(c, d) {
|
|
106
|
+
return {
|
|
107
|
+
id: c.id, title: c.title, kind: c.kind, defect: c.defect, sourceDigest: d.source,
|
|
108
|
+
deployments: Object.fromEntries(VARIANTS.map((v) => [v, { digest: d.deployments[v], exploitable: c.deployments[v].exploitable, rationale: c.deployments[v].rationale }])),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Freeze the set: hash-pin every tree, the labels and the decision rule, and bind the cases into a QA-001 protocol. Never throws.
|
|
114
|
+
* @returns {{ ok: boolean, errors: string[], set: object|null }}
|
|
115
|
+
*/
|
|
116
|
+
export function freezeAblationSet({ fixturesDir } = {}) {
|
|
117
|
+
const loaded = loadAblationCases(fixturesDir);
|
|
118
|
+
const errors = [...loaded.errors];
|
|
119
|
+
for (const c of loaded.cases) { const bad = deploymentTreesCarryNoSource(c); if (bad.length) errors.push(`${c.id}: deployment trees must not carry source code (${bad.join(', ')})`); }
|
|
120
|
+
if (errors.length) return { ok: false, errors, set: null };
|
|
121
|
+
const records = loaded.cases.map((c) => caseRecord(c, digestsOf(c)));
|
|
122
|
+
const targets = loaded.cases.map((c, i) => ({
|
|
123
|
+
id: c.id, language: 'javascript', upstream: `example.invalid/synthetic/deployment-ablation/${c.id}`, pairId: `pair-${c.id}`, advisoryIds: [],
|
|
124
|
+
preCommit: SHA((i + 1).toString(16)[0]), postCommit: null, license: SYNTHETIC_LICENSE, digest: targetDigestOf({ pre: records[i].sourceDigest, post: null }),
|
|
125
|
+
}));
|
|
126
|
+
const draft = {
|
|
127
|
+
synthetic: true,
|
|
128
|
+
engine: { version: '0.0.0-synthetic', bundleDigest: digestOf('synthetic-deployment-ablation-engine') },
|
|
129
|
+
measurement: { commit: SHA('d'), cleanTree: true },
|
|
130
|
+
tools: { node: 'synthetic' },
|
|
131
|
+
models: [],
|
|
132
|
+
datasetLicenses: { [SYNTHETIC_LICENSE]: 'authored for tooling tests; not real-world code' },
|
|
133
|
+
scope: { languages: [...CORE_LANGUAGES], families: ['command-injection', 'deployment-exposure'] },
|
|
134
|
+
matching: { ...DEFAULT_MATCHING },
|
|
135
|
+
limits: { perTargetTimeoutMs: 120000, spendCeilingUsd: 0, replicates: 1 },
|
|
136
|
+
thresholds: { ...PREREGISTERED_THRESHOLDS },
|
|
137
|
+
targets,
|
|
138
|
+
splits: { dev: [], sealed: targets.map((t) => t.id).sort() },
|
|
139
|
+
grouping: { method: 'union-find', keys: ['pair', 'upstream', 'advisory', 'commit', 'template'], salt: 'synthetic-deployment-ablation' },
|
|
140
|
+
};
|
|
141
|
+
const frozen = freezeProtocol(draft);
|
|
142
|
+
if (!frozen.ok) return { ok: false, errors: frozen.errors.map((e) => `${e.path}: ${e.message}`), set: null };
|
|
143
|
+
const labels = records.map((r) => ({ id: r.id, exploitable: VARIANTS.map((v) => r.deployments[v].exploitable) }));
|
|
144
|
+
const body = {
|
|
145
|
+
schema: ABLATION_SET_SCHEMA, schemaVersion: ABLATION_VERSION, synthetic: true,
|
|
146
|
+
note: 'Authored by the tooling developers; labels are not independently adjudicated. Never counted toward a real-population gate.',
|
|
147
|
+
protocolHash: frozen.protocol.protocolHash, decisionRule: DECISION_RULE, labelsHash: digestOf(labels), cases: records,
|
|
148
|
+
};
|
|
149
|
+
const set = { ...body, protocol: frozen.protocol, frozenHash: digestOf(body) };
|
|
150
|
+
return { ok: true, errors: [], set };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Recompute the set from disk and compare it with a stored one. Names what moved. */
|
|
154
|
+
export function verifyFrozenSet({ fixturesDir, stored } = {}) {
|
|
155
|
+
const errors = [];
|
|
156
|
+
if (!stored || stored.schema !== ABLATION_SET_SCHEMA) return { ok: false, errors: ['no frozen set to verify against'] };
|
|
157
|
+
const fresh = freezeAblationSet({ fixturesDir });
|
|
158
|
+
if (!fresh.ok) return { ok: false, errors: fresh.errors };
|
|
159
|
+
const v = validateProtocol(stored.protocol);
|
|
160
|
+
if (!v.ok) errors.push(...v.errors.map((e) => `stored protocol: ${e.code} ${e.path}`));
|
|
161
|
+
if (stored.synthetic !== true) errors.push('stored set is not marked synthetic');
|
|
162
|
+
const byId = new Map(stored.cases.map((c) => [c.id, c]));
|
|
163
|
+
for (const c of fresh.set.cases) {
|
|
164
|
+
const s = byId.get(c.id);
|
|
165
|
+
if (!s) { errors.push(`${c.id}: not in the frozen set`); continue; }
|
|
166
|
+
if (s.sourceDigest !== c.sourceDigest) errors.push(`${c.id}: source tree changed since it was frozen`);
|
|
167
|
+
for (const x of VARIANTS) {
|
|
168
|
+
if (s.deployments?.[x]?.digest !== c.deployments[x].digest) errors.push(`${c.id}: ${x} deployment changed since it was frozen`);
|
|
169
|
+
if (s.deployments?.[x]?.exploitable !== c.deployments[x].exploitable) errors.push(`${c.id}: ${x} label changed since it was frozen`);
|
|
170
|
+
}
|
|
171
|
+
byId.delete(c.id);
|
|
172
|
+
}
|
|
173
|
+
for (const id of byId.keys()) errors.push(`${id}: in the frozen set but no longer on disk`);
|
|
174
|
+
if (stored.labelsHash !== fresh.set.labelsHash) errors.push('labels changed since they were frozen');
|
|
175
|
+
if (digestOf(stored.decisionRule) !== digestOf(DECISION_RULE)) errors.push('the decision rule changed since it was frozen');
|
|
176
|
+
if (stored.frozenHash !== fresh.set.frozenHash) errors.push('frozenHash does not match the recomputed set');
|
|
177
|
+
return { ok: errors.length === 0, errors };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ------------------------------------------------------------ arms
|
|
181
|
+
|
|
182
|
+
/** Does a graph source (file + digest recorded at ingest) still match the file on disk? Missing and changed are separate. */
|
|
183
|
+
export function graphFreshness(graph, dir) {
|
|
184
|
+
const stale = [], missing = [];
|
|
185
|
+
for (const s of graph.sources) {
|
|
186
|
+
let bytes;
|
|
187
|
+
try { bytes = fs.readFileSync(path.join(dir, s.file)); } catch { missing.push(s.file); continue; }
|
|
188
|
+
if (digestOfBytes(bytes) !== s.digest) stale.push(s.file);
|
|
189
|
+
}
|
|
190
|
+
return { stale: stale.sort(), missing: missing.sort() };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The graph arm's verdict on one source finding, from its boundary context. Pure. */
|
|
194
|
+
export function graphArmKeeps(row, graphGapCount) {
|
|
195
|
+
const state = row?.boundaryContext?.exposure?.state ?? 'not-assessed';
|
|
196
|
+
if (state === 'blocked') return { keep: false, state, why: 'every path from an exposed entry crosses an explicit deny' };
|
|
197
|
+
if (state === 'none-found' && graphGapCount === 0) return { keep: false, state, why: 'no path from an exposed entry in a graph with no gaps' };
|
|
198
|
+
return { keep: true, state, why: state === 'none-found' ? 'no path found, but the graph has gaps so absence is not shown' : `exposure is ${state}` };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function matchesDefect(finding, defect) { return defect.file ? finding.file === defect.file && finding.family === defect.family : false; }
|
|
202
|
+
|
|
203
|
+
const median = (xs) => { if (!xs.length) return null; const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
|
|
204
|
+
|
|
205
|
+
function graphArm({ c, variant, findings, config, now }) {
|
|
206
|
+
const dir = path.join(c.dir, variant);
|
|
207
|
+
const t0 = performance.now();
|
|
208
|
+
const res = runBoundaries({ config, from: dir, findings, now });
|
|
209
|
+
if (res.status !== 'ok') {
|
|
210
|
+
return { ok: false, reason: `${res.status}: ${res.reason}`, reported: [], demoted: [], ms: performance.now() - t0, coverage: null, adapters: [] };
|
|
211
|
+
}
|
|
212
|
+
const reported = [], demoted = [], states = [];
|
|
213
|
+
const rows = res.report.findings;
|
|
214
|
+
for (const [idx, row] of rows.entries()) {
|
|
215
|
+
const matched = matchesDefect(findings[idx] ?? {}, c.defect);
|
|
216
|
+
const verdict = graphArmKeeps(row, res.report.graph.gapCount);
|
|
217
|
+
states.push(verdict.state);
|
|
218
|
+
if (matched) (verdict.keep ? reported : demoted).push(`src:${c.id}`);
|
|
219
|
+
}
|
|
220
|
+
const paths = computeAttackPaths(res.graph).paths;
|
|
221
|
+
const exposed = paths.filter((p) => p.state === 'possible' || p.state === 'runtime-supported');
|
|
222
|
+
if (exposed.length) reported.push(`dep:${c.id}`);
|
|
223
|
+
const fresh = graphFreshness(res.graph, dir);
|
|
224
|
+
const bound = res.report.coverage.bound;
|
|
225
|
+
return {
|
|
226
|
+
ok: true, reported: [...new Set(reported)].sort(), demoted: [...new Set(demoted)].sort(), ms: performance.now() - t0,
|
|
227
|
+
adapters: [...new Set(res.report.graph.files.map((f) => f.adapter).filter(Boolean))].sort(),
|
|
228
|
+
coverage: {
|
|
229
|
+
findings: rows.length, bound, notAssessed: rows.length - bound, exposureStates: states.sort(),
|
|
230
|
+
unresolvedExposure: states.filter((s) => s === 'unresolved').length,
|
|
231
|
+
graphGaps: res.report.graph.gapCount, unresolvedNodes: res.report.graph.unresolvedNodeCount,
|
|
232
|
+
staleSources: fresh.stale.length, missingSources: fresh.missing.length,
|
|
233
|
+
pathsFound: paths.length, pathsUnresolved: paths.filter((p) => p.state === 'unresolved').length, pathsBlocked: paths.filter((p) => p.state === 'blocked').length,
|
|
234
|
+
traces: res.report.observation.status,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function prf(tp, fp, fn) {
|
|
240
|
+
const precision = tp + fp ? tp / (tp + fp) : null;
|
|
241
|
+
const recall = tp + fn ? tp / (tp + fn) : null;
|
|
242
|
+
const f1 = precision !== null && recall !== null && precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : null;
|
|
243
|
+
return { tp, fp, fn, precision, recall, f1 };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function mulberry32(seed) {
|
|
247
|
+
let a = seed >>> 0;
|
|
248
|
+
return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Simple paired case bootstrap of (graph - source) precision and recall. Deterministic for a fixed seed. */
|
|
252
|
+
export function pairedBootstrap(perCase, { resamples = BOOTSTRAP.resamples, seed = BOOTSTRAP.seed } = {}) {
|
|
253
|
+
const rnd = mulberry32(seed);
|
|
254
|
+
const n = perCase.length;
|
|
255
|
+
const sum = (idx, arm) => idx.reduce((a, i) => ({ tp: a.tp + perCase[i][arm].tp, fp: a.fp + perCase[i][arm].fp, fn: a.fn + perCase[i][arm].fn }), { tp: 0, fp: 0, fn: 0 });
|
|
256
|
+
const diffs = { precision: [], recall: [] };
|
|
257
|
+
const undefinedCount = { precision: 0, recall: 0 };
|
|
258
|
+
for (let b = 0; b < resamples && n; b++) {
|
|
259
|
+
const idx = Array.from({ length: n }, () => Math.floor(rnd() * n));
|
|
260
|
+
const s = sum(idx, 'source'), g = sum(idx, 'graph');
|
|
261
|
+
for (const k of ['precision', 'recall']) {
|
|
262
|
+
const a = prf(s.tp, s.fp, s.fn)[k], z = prf(g.tp, g.fp, g.fn)[k];
|
|
263
|
+
if (a === null || z === null) undefinedCount[k] += 1; else diffs[k].push(z - a);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const q = (xs, p) => { if (!xs.length) return null; const s = [...xs].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.max(0, Math.floor(p * s.length)))]; };
|
|
267
|
+
const all = Array.from({ length: n }, (_, i) => i);
|
|
268
|
+
const s0 = prf(...Object.values(sum(all, 'source'))), g0 = prf(...Object.values(sum(all, 'graph')));
|
|
269
|
+
const out = {};
|
|
270
|
+
for (const k of ['precision', 'recall']) {
|
|
271
|
+
out[k] = { sourceOnly: s0[k], graphEnabled: g0[k], difference: s0[k] === null || g0[k] === null ? null : g0[k] - s0[k], low: q(diffs[k], 0.025), high: q(diffs[k], 0.975), undefinedResamples: undefinedCount[k] };
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
method: BOOTSTRAP.method, resamples, seed, interval: BOOTSTRAP.interval, cases: n, ...out,
|
|
275
|
+
label: 'Simple paired case bootstrap (cases resampled with both of their instances, fixed seed). NOT the grouped bootstrap of the QA-004 report, which is not in this base. With this few cases the interval is wide and exploratory.',
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Which adapters have positive AND negative fixtures with no lost defect? Only those may be claimed as deployment-aware. */
|
|
280
|
+
export function adapterValidation(instances) {
|
|
281
|
+
const out = ADAPTER_NAMES.map((name) => ({ adapter: name, positives: 0, negatives: 0, lostDefects: 0, instances: 0 }));
|
|
282
|
+
const by = new Map(out.map((o) => [o.adapter, o]));
|
|
283
|
+
for (const i of instances) {
|
|
284
|
+
for (const a of i.adapters) {
|
|
285
|
+
const o = by.get(a);
|
|
286
|
+
if (!o) continue;
|
|
287
|
+
o.instances += 1;
|
|
288
|
+
const truthIds = new Set(i.truth);
|
|
289
|
+
const graphHit = i.graph.reported.some((id) => truthIds.has(id));
|
|
290
|
+
if (i.exploitable && graphHit) o.positives += 1;
|
|
291
|
+
if (!i.exploitable && i.graph.reported.length === 0) o.negatives += 1;
|
|
292
|
+
o.lostDefects += i.lost.length;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return out.map((o) => {
|
|
296
|
+
const validated = o.positives >= 1 && o.negatives >= 1 && o.lostDefects === 0;
|
|
297
|
+
const reason = validated ? 'positive and negative fixtures, no baseline defect lost'
|
|
298
|
+
: o.instances === 0 ? 'no fixture in the frozen set uses this adapter'
|
|
299
|
+
: o.lostDefects ? 'a baseline confirmed defect was lost'
|
|
300
|
+
: o.positives < 1 ? 'no positive fixture' : 'no negative fixture';
|
|
301
|
+
return { ...o, validated, reason };
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ------------------------------------------------------------ the evaluation
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Run both arms on every frozen instance and report paired counts. Refuses a set that does not verify against the disk.
|
|
309
|
+
*
|
|
310
|
+
* @param {object} o
|
|
311
|
+
* @param {string} o.fixturesDir
|
|
312
|
+
* @param {object} o.stored the committed frozen set
|
|
313
|
+
* @param {Function} [o.scanFn] injectable engine scan (tests); the default is the QA-003 child-process scan
|
|
314
|
+
* @param {string} [o.now] ISO clock for staleness judgements (fixed by default so the counts are deterministic)
|
|
315
|
+
*/
|
|
316
|
+
export async function runPairedAblation({ fixturesDir, stored, scanFn, now = '2026-10-09T00:00:00.000Z' } = {}) {
|
|
317
|
+
const verified = verifyFrozenSet({ fixturesDir, stored });
|
|
318
|
+
if (!verified.ok) return { ok: false, errors: verified.errors, report: null };
|
|
319
|
+
const loaded = loadAblationCases(fixturesDir);
|
|
320
|
+
const config = resolveAssuranceConfig({ scanRoot: fixturesDir, overrides: { features: { 'deployment-boundaries': true } } });
|
|
321
|
+
|
|
322
|
+
// source-only arm: one scan per case; the source is the same under both deployments.
|
|
323
|
+
const run = await runEvaluation({
|
|
324
|
+
protocol: stored.protocol, config: { layer: 'deterministic-only' }, split: 'all', allowSealed: true, protectedTerms: [],
|
|
325
|
+
resolveTarget: (t) => ({ dir: path.join(fixturesDir, 'cases', t.id, 'source') }), ...(scanFn ? { scanFn } : {}),
|
|
326
|
+
});
|
|
327
|
+
if (!run.ok) return { ok: false, errors: run.errors.map((e) => `${e.code}: ${e.message}`), report: null };
|
|
328
|
+
|
|
329
|
+
const instances = [], perCase = [], unscored = [];
|
|
330
|
+
const scanMs = [];
|
|
331
|
+
for (const c of loaded.cases) {
|
|
332
|
+
const oc = run.run.outcomes.find((o) => o.targetId === c.id && o.variant === 'pre');
|
|
333
|
+
const completed = oc?.status === 'completed';
|
|
334
|
+
const findings = completed ? oc.findings : [];
|
|
335
|
+
if (completed) scanMs.push(oc.durationMs);
|
|
336
|
+
const matched = findings.filter((f) => matchesDefect(f, c.defect));
|
|
337
|
+
for (const f of findings) if (!matchesDefect(f, c.defect)) unscored.push({ case: c.id, file: f.file, family: f.family, reason: 'a finding the case labels do not account for' });
|
|
338
|
+
const caseStats = { id: c.id, source: { tp: 0, fp: 0, fn: 0 }, graph: { tp: 0, fp: 0, fn: 0 } };
|
|
339
|
+
for (const variant of VARIANTS) {
|
|
340
|
+
const exploitable = c.deployments[variant].exploitable;
|
|
341
|
+
const defectId = c.kind === 'source-finding' ? `src:${c.id}` : `dep:${c.id}`;
|
|
342
|
+
const truth = exploitable ? [defectId] : [];
|
|
343
|
+
const sourceReported = matched.length ? [`src:${c.id}`] : [];
|
|
344
|
+
const g = graphArm({ c, variant, findings, config, now });
|
|
345
|
+
const t = new Set(truth);
|
|
346
|
+
const count = (reported, arm) => {
|
|
347
|
+
const r = new Set(reported);
|
|
348
|
+
for (const id of r) { if (t.has(id)) caseStats[arm].tp += 1; else caseStats[arm].fp += 1; }
|
|
349
|
+
for (const id of t) if (!r.has(id)) caseStats[arm].fn += 1;
|
|
350
|
+
};
|
|
351
|
+
count(sourceReported, 'source'); count(g.reported, 'graph');
|
|
352
|
+
const sr = new Set(sourceReported), gr = new Set(g.reported);
|
|
353
|
+
instances.push({
|
|
354
|
+
caseId: c.id, variant, exploitable, truth, adapters: g.adapters, graphOk: g.ok, graphFailure: g.ok ? null : g.reason,
|
|
355
|
+
sourceOnly: { reported: sourceReported, completed },
|
|
356
|
+
graph: { reported: g.reported, demoted: g.demoted, ms: g.ms, coverage: g.coverage },
|
|
357
|
+
fpReduced: sourceReported.filter((id) => !t.has(id) && !gr.has(id)),
|
|
358
|
+
fpAdded: g.reported.filter((id) => !t.has(id) && !sr.has(id)),
|
|
359
|
+
confirmedAdded: g.reported.filter((id) => t.has(id) && !sr.has(id)),
|
|
360
|
+
lost: sourceReported.filter((id) => t.has(id) && !gr.has(id)),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
perCase.push(caseStats);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const tot = (arm) => perCase.reduce((a, c) => ({ tp: a.tp + c[arm].tp, fp: a.fp + c[arm].fp, fn: a.fn + c[arm].fn }), { tp: 0, fp: 0, fn: 0 });
|
|
367
|
+
const s = tot('source'), g = tot('graph');
|
|
368
|
+
const sum = (key) => instances.reduce((a, i) => a + i[key].length, 0);
|
|
369
|
+
const cov = instances.map((i) => i.graph.coverage).filter(Boolean);
|
|
370
|
+
const graphMs = instances.map((i) => i.graph.ms);
|
|
371
|
+
const paired = {
|
|
372
|
+
instances: instances.length, cases: loaded.cases.length,
|
|
373
|
+
falsePositivesReduced: sum('fpReduced'), confirmedDefectsAdded: sum('confirmedAdded'),
|
|
374
|
+
baselineConfirmedDefectsLost: sum('lost'), falsePositivesAdded: sum('fpAdded'),
|
|
375
|
+
unchanged: instances.filter((i) => !i.fpReduced.length && !i.fpAdded.length && !i.confirmedAdded.length && !i.lost.length).length,
|
|
376
|
+
};
|
|
377
|
+
const gates = evaluateGates({ protocol: stored.protocol, score: null, defects: [], negatives: [] });
|
|
378
|
+
const report = {
|
|
379
|
+
schema: ABLATION_REPORT_SCHEMA, schemaVersion: ABLATION_VERSION, synthetic: true,
|
|
380
|
+
statement: 'SYNTHETIC cases authored by the tooling developers. These counts check a mechanism on constructed cases; they say nothing about benefit on real programs.',
|
|
381
|
+
frozenHash: stored.frozenHash, protocolHash: stored.protocol.protocolHash, runId: run.run.runId, decisionRule: DECISION_RULE,
|
|
382
|
+
arms: {
|
|
383
|
+
sourceOnly: { ...prf(s.tp, s.fp, s.fn) }, graphEnabled: { ...prf(g.tp, g.fp, g.fn) },
|
|
384
|
+
},
|
|
385
|
+
paired,
|
|
386
|
+
uncertainty: pairedBootstrap(perCase),
|
|
387
|
+
runtime: {
|
|
388
|
+
deterministic: false,
|
|
389
|
+
sourceOnlyScanMsMedian: median(scanMs), graphEnabledAddedMsMedian: median(graphMs),
|
|
390
|
+
note: 'Wall-clock milliseconds on the machine that ran this; they differ run to run and are not part of the frozen comparison. The graph-enabled arm costs its scan plus the added time.',
|
|
391
|
+
},
|
|
392
|
+
coverage: {
|
|
393
|
+
sourceOnly: { scansCompleted: loaded.cases.filter((c) => run.run.outcomes.find((o) => o.targetId === c.id)?.status === 'completed').length, of: loaded.cases.length, deploymentContext: 'none: every finding is reported without any deployment evidence' },
|
|
394
|
+
graphEnabled: {
|
|
395
|
+
graphBuilt: instances.filter((i) => i.graphOk).length, of: instances.length,
|
|
396
|
+
findingsWithContext: cov.reduce((a, c) => a + c.findings, 0), findingsBoundToAService: cov.reduce((a, c) => a + c.bound, 0), findingsNotAssessed: cov.reduce((a, c) => a + c.notAssessed, 0),
|
|
397
|
+
exposureUnresolved: cov.reduce((a, c) => a + c.unresolvedExposure, 0),
|
|
398
|
+
instancesWithGraphGaps: cov.filter((c) => c.graphGaps > 0).length, graphGapsTotal: cov.reduce((a, c) => a + c.graphGaps, 0), unresolvedNodesTotal: cov.reduce((a, c) => a + c.unresolvedNodes, 0),
|
|
399
|
+
staleSources: cov.reduce((a, c) => a + c.staleSources, 0), missingSources: cov.reduce((a, c) => a + c.missingSources, 0),
|
|
400
|
+
traces: 'no runtime traces are part of this set, so observed, sampled and stale trace coverage are all unmeasured here',
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
unscoredFindings: unscored,
|
|
404
|
+
adapters: adapterValidation(instances),
|
|
405
|
+
realCodeGates: { overall: gates.overall, note: 'the protocol is synthetic, so no real-code gate can pass' },
|
|
406
|
+
instances,
|
|
407
|
+
};
|
|
408
|
+
return { ok: true, errors: [], report };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The deterministic part of a report (no wall-clock figures), for comparison across runs and for the docs. */
|
|
412
|
+
export function deterministicView(report) {
|
|
413
|
+
const { runtime, instances, ...rest } = report;
|
|
414
|
+
void runtime;
|
|
415
|
+
return { ...rest, instances: instances.map(({ graph, ...i }) => ({ ...i, graph: { reported: graph.reported, demoted: graph.demoted, coverage: graph.coverage } })) };
|
|
416
|
+
}
|