@clear-capabilities/agentic-security-scanner 0.175.0 → 0.177.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 +25 -0
- package/bin/agentic-security.js +206 -28
- package/dist/1919.index.js +1 -1
- package/dist/2066.index.js +851 -0
- package/dist/2199.index.js +1 -1
- package/dist/2584.index.js +3350 -0
- package/dist/2923.index.js +13 -0
- package/dist/2946.index.js +81 -0
- package/dist/3196.index.js +27 -0
- package/dist/3727.index.js +292 -0
- package/dist/37d64df006576248d0d8.json +286 -0
- package/dist/4168.index.js +509 -0
- package/dist/4281.index.js +993 -0
- package/dist/5099.index.js +96 -0
- package/dist/5456.index.js +291 -0
- package/dist/5616.index.js +1471 -0
- package/dist/5620.index.js +2494 -0
- package/dist/5817.index.js +714 -0
- package/dist/5937.index.js +672 -0
- package/dist/6333.index.js +98 -0
- package/dist/6655.index.js +3221 -0
- package/dist/7838.index.js +491 -974
- package/dist/818.index.js +4 -3
- package/dist/83.index.js +159 -0
- package/dist/8551.index.js +82 -0
- package/dist/8633.index.js +93 -0
- package/dist/8714.index.js +76 -76
- package/dist/8825.index.js +84 -0
- package/dist/932.index.js +186 -0
- package/dist/9560.index.js +358 -19
- package/dist/9800.index.js +6 -1
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/b4289e728659450b5e19.js +32 -0
- package/package.json +8 -4
- package/src/artifact/CLAUDE.md +4 -0
- package/src/artifact/artifact-evidence.js +63 -0
- package/src/artifact/cosign.js +61 -0
- package/src/artifact/engine-source.js +72 -0
- package/src/artifact/oci-layout.js +47 -0
- package/src/artifact/registry.js +213 -0
- package/src/capabilities/tool-registry.js +2 -0
- package/src/lineage/CLAUDE.md +4 -0
- package/src/lineage/identity-token.js +11 -0
- package/src/lineage/observation-adapters.js +13 -0
- package/src/lineage/runtime-edge-states.js +139 -0
- package/src/lineage/runtime-explorer-view.js +82 -0
- package/src/lineage/runtime-observation.js +11 -0
- package/src/mcp/CLAUDE.md +3 -1
- package/src/mcp/proof-tools.js +132 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +3 -1
- package/src/posture/CLAUDE.md +23 -0
- package/src/posture/artifact-registry.js +4 -0
- package/src/posture/custom-rules.js +54 -8
- package/src/posture/dnr/attack-facts.js +31 -0
- package/src/posture/dnr/cli.js +138 -0
- package/src/posture/dnr/detection-ir.js +123 -0
- package/src/posture/dnr/exploit-correlation.js +25 -0
- package/src/posture/dnr/exposure-window.js +134 -0
- package/src/posture/dnr/hunt.js +84 -0
- package/src/posture/dnr/log-reader.js +189 -0
- package/src/posture/dnr/matchers.js +90 -0
- package/src/posture/dnr/outputs.js +262 -0
- package/src/posture/dnr/payload-classes.js +120 -0
- package/src/posture/dnr/redact.js +25 -0
- package/src/posture/dnr/store.js +57 -0
- package/src/posture/dnr/validate.js +64 -0
- package/src/posture/dnr/view.js +51 -0
- package/src/posture/evaluation/lab/arm-ollama.js +88 -0
- package/src/posture/evaluation/lab/arms.js +142 -0
- package/src/posture/evaluation/lab/cli.js +135 -0
- package/src/posture/evaluation/lab/dataset.js +139 -0
- package/src/posture/evaluation/lab/materialize.js +44 -0
- package/src/posture/evaluation/lab/neutralize.js +144 -0
- package/src/posture/evaluation/lab/report.js +194 -0
- package/src/posture/evaluation/lab/run.js +225 -0
- package/src/posture/evaluation/lab/score.js +135 -0
- package/src/posture/evaluation/lab/scoreboard.js +48 -0
- package/src/posture/evaluation/lab/stats.js +41 -0
- package/src/posture/evaluation/lab/store.js +53 -0
- package/src/posture/fleet/cli.js +260 -63
- package/src/posture/fleet/discover-edges.js +135 -0
- package/src/posture/fleet/enterprise-graph.js +189 -0
- package/src/posture/fleet/fleet-graph.js +9 -4
- package/src/posture/fleet/fleet-manifest.js +50 -5
- package/src/posture/fleet/job-runner.js +153 -0
- package/src/posture/fleet/job-store.js +138 -0
- package/src/posture/fleet/lineage-summary.js +55 -0
- package/src/posture/fleet/org-health.js +56 -0
- package/src/posture/fleet/scan-cache.js +112 -0
- package/src/posture/fleet/scan-child.js +32 -0
- package/src/posture/proof-fabric/cli.js +34 -6
- package/src/posture/proof-fabric/container.js +57 -0
- package/src/posture/proof-fabric/fix-proof.js +2 -1
- package/src/posture/proof-fabric/generate-python.js +129 -0
- package/src/posture/proof-fabric/generate.js +214 -0
- package/src/posture/proof-fabric/reproducer.js +3 -0
- package/src/posture/proof-fabric/runner.js +44 -13
- package/src/posture/proof-fabric/siblings.js +77 -0
- package/src/posture/routing/policy-router.js +103 -0
- package/src/posture/rule-compiler/cli.js +101 -9
- package/src/posture/rule-compiler/corpus.js +13 -9
- package/src/posture/rule-compiler/ir.js +52 -9
- package/src/posture/rule-compiler/mutation.js +72 -1
- package/src/posture/rule-compiler/promotion.js +21 -5
- package/src/posture/rule-compiler/proposal.js +137 -0
- package/src/posture/rule-compiler/regression.js +95 -0
- package/src/posture/rule-compiler/store.js +45 -1
- package/src/posture/rule-pack-signing.js +4 -2
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Workspace neutralization and the leakage gate (PRD 6, benchmark integrity).
|
|
2
|
+
//
|
|
3
|
+
// A model must be scored on whether it can find the defect, not on whether it can read the answer. Before any arm sees a
|
|
4
|
+
// workspace this module (1) copies it, (2) strips comments while keeping every line number (so a span still points at the same
|
|
5
|
+
// code), (3) renames files whose NAME carries the weakness class, and (4) runs the existing custody audit
|
|
6
|
+
// (`custody.auditWorkspace`: answer-key file names, symlinks, protected terms, advisory ids) plus three checks of its own:
|
|
7
|
+
// a weakness id anywhere in the content, a vulnerability-describing comment that was left in, and a weakness-bearing file name
|
|
8
|
+
// that the policy did not rename. Any hit quarantines the workspace: the entry is recorded as quarantined and is NOT scanned.
|
|
9
|
+
//
|
|
10
|
+
// What this is not: a proof that no paraphrase gets through. Comment stripping is best effort (a regular expression literal
|
|
11
|
+
// containing `//` can be mis-read), and an identifier that happens to describe the bug stays. The audit result is recorded on
|
|
12
|
+
// every run so a reader can see exactly what was checked.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { auditWorkspace } from '../custody.js';
|
|
16
|
+
import { STATE_DIR_NAME } from '../../state-dir.js';
|
|
17
|
+
|
|
18
|
+
const C_LIKE = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.java', '.c', '.h', '.cc', '.cpp', '.hpp', '.cs', '.go', '.rs', '.kt', '.kts', '.swift', '.scala', '.php']);
|
|
19
|
+
const HASH_LIKE = new Set(['.py', '.rb', '.sh', '.bash', '.pl', '.yaml', '.yml', '.tf', '.toml']);
|
|
20
|
+
const HASH_AND_C = new Set(['.php']);
|
|
21
|
+
// languages where a single quote opens a string of any length; elsewhere it is a short character literal (or a Rust lifetime)
|
|
22
|
+
const SQ_STRING = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.php']);
|
|
23
|
+
const SKIP = new Set(['.git', 'node_modules', STATE_DIR_NAME]);
|
|
24
|
+
const CWE_NAME = /cwe[-_]?\d{1,5}/i;
|
|
25
|
+
const CWE_CONTENT = /\bcwe[-_ ]?\d{2,5}\b/i;
|
|
26
|
+
const DESCRIPTIVE_COMMENT = /\b(vulnerab\w*|insecure|exploit\w*|potential flaw|flaw:|fixed by|should be (?:sanitized|escaped|validated)|\bBAD\b|\bGOOD\b|sink|taint\w*)\b/i;
|
|
27
|
+
|
|
28
|
+
/** Remove comments, keeping newlines. String-aware for ' " and ` quotes; best effort, see the file header. */
|
|
29
|
+
export function stripComments(text, ext) {
|
|
30
|
+
const cLike = C_LIKE.has(ext);
|
|
31
|
+
const hash = HASH_LIKE.has(ext) || HASH_AND_C.has(ext);
|
|
32
|
+
if (!cLike && !hash) return text;
|
|
33
|
+
const s = String(text);
|
|
34
|
+
let out = '';
|
|
35
|
+
let i = 0;
|
|
36
|
+
const n = s.length;
|
|
37
|
+
while (i < n) {
|
|
38
|
+
const c = s[i];
|
|
39
|
+
const two = s.substr(i, 2);
|
|
40
|
+
if (cLike && two === '//') { while (i < n && s[i] !== '\n') i++; continue; }
|
|
41
|
+
if (cLike && two === '/*') { i += 2; while (i < n && s.substr(i, 2) !== '*/') { if (s[i] === '\n') out += '\n'; i++; } i += 2; continue; }
|
|
42
|
+
if (hash && c === '#' && !(ext === '.php' && s[i + 1] === '[')) { while (i < n && s[i] !== '\n') i++; continue; }
|
|
43
|
+
if (c === '"' || c === '`' || (c === "'" && (hash || SQ_STRING.has(ext) || /^'(?:\\.|[^'\\\n]){1,12}'/.test(s.slice(i, i + 16))))) {
|
|
44
|
+
const triple = (hash && ext === '.py' && s.substr(i, 3) === c.repeat(3)) ? c.repeat(3) : null;
|
|
45
|
+
const q = triple || c;
|
|
46
|
+
out += q; i += q.length;
|
|
47
|
+
while (i < n) {
|
|
48
|
+
if (s[i] === '\\') { out += s.substr(i, 2); i += 2; continue; }
|
|
49
|
+
if (s.substr(i, q.length) === q) { out += q; i += q.length; break; }
|
|
50
|
+
if (!triple && q !== '`' && s[i] === '\n') break; // an unterminated string ends at the line
|
|
51
|
+
out += s[i]; i++;
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out += c; i++;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `*` matches within a path segment, `**` across segments; everything else is literal. */
|
|
61
|
+
export function globToRegExp(glob) {
|
|
62
|
+
let re = '';
|
|
63
|
+
for (let i = 0; i < glob.length; i++) {
|
|
64
|
+
const c = glob[i];
|
|
65
|
+
if (c === '*' && glob[i + 1] === '*') { re += '.*'; i++; if (glob[i + 1] === '/') i++; } else if (c === '*') re += '[^/]*'; else re += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
66
|
+
}
|
|
67
|
+
return new RegExp(`^${re}$`, 'i');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isText = (buf) => !buf.subarray(0, 4096).includes(0);
|
|
71
|
+
|
|
72
|
+
function copyTree(src, dest, files = []) {
|
|
73
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
74
|
+
for (const e of fs.readdirSync(src, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
75
|
+
if (SKIP.has(e.name)) continue;
|
|
76
|
+
const s = path.join(src, e.name); const d = path.join(dest, e.name);
|
|
77
|
+
if (e.isSymbolicLink()) { files.push({ rel: path.relative(dest, d), symlink: true }); fs.symlinkSync(fs.readlinkSync(s), d); continue; }
|
|
78
|
+
if (e.isDirectory()) { copyTree(s, d, files); continue; }
|
|
79
|
+
if (e.isFile()) fs.copyFileSync(s, d);
|
|
80
|
+
}
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function listFiles(root, rel = '', out = []) {
|
|
85
|
+
for (const e of fs.readdirSync(path.join(root, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
86
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
87
|
+
if (e.isDirectory()) listFiles(root, r, out); else if (e.isFile()) out.push(r);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @returns {{ok: boolean, quarantined: boolean, destDir: string|null, fileMap: Record<string,string>, leaks: object[], audit: object}}
|
|
94
|
+
* `fileMap` maps original relative paths to staged ones, so a label's span can be located without telling the arm.
|
|
95
|
+
*/
|
|
96
|
+
export function stageNeutralized({ srcDir, destDir, policy = {}, terms = [] }) {
|
|
97
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
98
|
+
copyTree(srcDir, destDir);
|
|
99
|
+
const leaks = [];
|
|
100
|
+
const fileMap = {};
|
|
101
|
+
const renamedStems = [];
|
|
102
|
+
let n = 0;
|
|
103
|
+
// 0. remove files the policy names (change logs, advisories and the like) before anything is judged
|
|
104
|
+
if (Array.isArray(policy.removePaths) && policy.removePaths.length) {
|
|
105
|
+
const res = policy.removePaths.map(globToRegExp);
|
|
106
|
+
for (const rel of listFiles(destDir)) if (res.some((re) => re.test(rel))) fs.rmSync(path.join(destDir, rel), { force: true });
|
|
107
|
+
}
|
|
108
|
+
// 1. rename weakness-bearing file names (and note the stem so class/module references can follow)
|
|
109
|
+
for (const rel of listFiles(destDir)) {
|
|
110
|
+
const base = path.basename(rel);
|
|
111
|
+
if (!CWE_NAME.test(rel)) { fileMap[rel] = rel; continue; }
|
|
112
|
+
if (!policy.renameFiles) { fileMap[rel] = rel; leaks.push({ kind: 'weakness-in-file-name', file: rel, detail: 'the file name states the weakness class and the policy does not rename files' }); continue; }
|
|
113
|
+
const ext = path.extname(base);
|
|
114
|
+
const stem = base.slice(0, base.length - ext.length);
|
|
115
|
+
const next = `case${String(++n).padStart(4, '0')}${ext}`;
|
|
116
|
+
const relNext = path.posix.join(path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel).replace(CWE_NAME, 'dir'), next);
|
|
117
|
+
fs.mkdirSync(path.dirname(path.join(destDir, relNext)), { recursive: true });
|
|
118
|
+
fs.renameSync(path.join(destDir, rel), path.join(destDir, relNext));
|
|
119
|
+
fileMap[rel] = relNext;
|
|
120
|
+
renamedStems.push([stem, next.slice(0, next.length - ext.length)]);
|
|
121
|
+
}
|
|
122
|
+
for (const d of fs.readdirSync(destDir, { withFileTypes: true })) if (d.isDirectory()) { try { fs.rmdirSync(path.join(destDir, d.name)); } catch { /* not empty */ } }
|
|
123
|
+
// 2. strip comments, follow renamed stems in text
|
|
124
|
+
for (const rel of listFiles(destDir)) {
|
|
125
|
+
const p = path.join(destDir, rel);
|
|
126
|
+
let buf; try { buf = fs.readFileSync(p); } catch { continue; }
|
|
127
|
+
if (!isText(buf)) continue;
|
|
128
|
+
let text = buf.toString('utf8');
|
|
129
|
+
const original = text;
|
|
130
|
+
if (policy.stripComments) text = stripComments(text, path.extname(rel).toLowerCase());
|
|
131
|
+
else {
|
|
132
|
+
const cm = text.split('\n').findIndex((l) => /(?:\/\/|\/\*|\*|#)/.test(l) && DESCRIPTIVE_COMMENT.test(l));
|
|
133
|
+
if (cm !== -1) leaks.push({ kind: 'descriptive-comment', file: rel, line: cm + 1, detail: 'a comment describes the weakness; set leakagePolicy.stripComments' });
|
|
134
|
+
}
|
|
135
|
+
for (const [from, to] of renamedStems) text = text.split(from).join(to);
|
|
136
|
+
if (CWE_CONTENT.test(text)) leaks.push({ kind: 'weakness-id-in-content', file: rel, detail: 'the content names a CWE id' });
|
|
137
|
+
if (text !== original) fs.writeFileSync(p, text);
|
|
138
|
+
}
|
|
139
|
+
// 3. the existing custody audit (answer-key names, symlinks, protected terms, advisory ids)
|
|
140
|
+
const audit = auditWorkspace(destDir, terms);
|
|
141
|
+
for (const l of audit.leaks) leaks.push(l);
|
|
142
|
+
if (leaks.length) { fs.rmSync(destDir, { recursive: true, force: true }); return { ok: false, quarantined: true, destDir: null, fileMap, leaks, audit: { scannedFiles: audit.scannedFiles, leaks: leaks.length } }; }
|
|
143
|
+
return { ok: true, quarantined: false, destDir, fileMap, leaks: [], audit: { scannedFiles: audit.scannedFiles, leaks: 0 } };
|
|
144
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Eval Lab reports (PRD 6): quality + proof + remediation + cost + latency + repeatability for each arm, and the metric the
|
|
2
|
+
// product is for, intelligence per dollar. Pure: no fs, no clock.
|
|
3
|
+
//
|
|
4
|
+
// Rules that keep the numbers honest:
|
|
5
|
+
// * a mean over fewer than three runs is printed with "not representative"; one lucky run is never a headline;
|
|
6
|
+
// * every spread (stdev, range, 95% interval) sits beside the mean it qualifies;
|
|
7
|
+
// * a dollar figure divides by MEASURED spend only, and when any attempt's cost is unknown the figure says so instead of
|
|
8
|
+
// treating unknown as free; a deterministic arm has no model cost, so a per-dollar figure for it is `n/a`, not infinite;
|
|
9
|
+
// * a quantity that was not measured is `n/a` (null), never 0;
|
|
10
|
+
// * the leakage audit and any incompleteness appear in the header of every format.
|
|
11
|
+
import { summarize, pct } from './stats.js';
|
|
12
|
+
|
|
13
|
+
export const REPORT_SCHEMA = 'agentic-security/eval-lab-report';
|
|
14
|
+
export const REPORT_FORMATS = Object.freeze(['json', 'csv', 'md', 'html', 'chart']);
|
|
15
|
+
|
|
16
|
+
const pick = (rs, task, path) => rs.map((r) => path.reduce((o, k) => (o == null ? null : o[k]), r.scores[task] ?? null));
|
|
17
|
+
|
|
18
|
+
const METRICS = {
|
|
19
|
+
detect: [['precision', ['precision']], ['recall', ['recall']], ['f1', ['f1']], ['macroF1', ['macroF1']], ['localizationAccuracy', ['localizationAccuracy']], ['completion', ['completion']]],
|
|
20
|
+
falsify: [['balancedAccuracy', ['balancedAccuracy']], ['falseRefutationRate', ['falseRefutationRate']], ['falseConfirmationRate', ['falseConfirmationRate']]],
|
|
21
|
+
exploit: [['pocGenerationSuccess', ['pocGenerationSuccess']], ['reproductionRate', ['reproductionRate']], ['confirmationRate', ['confirmationRate']], ['falseConfirmationRate', ['falseConfirmationRate']]],
|
|
22
|
+
patch: [['validPatchRate', ['validPatchRate']], ['exploitBlockedRate', ['exploitBlockedRate']], ['testPreservationRate', ['testPreservationRate']], ['regressionRate', ['regressionRate']]],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The one number a task is judged by when ranking or routing. Defined once, here. */
|
|
26
|
+
export const PRIMARY = Object.freeze({ detect: 'f1', falsify: 'balancedAccuracy', exploit: 'confirmationRate', patch: 'validPatchRate' });
|
|
27
|
+
|
|
28
|
+
function recallAtBudget(run, armId, repeat, budgetUsd) {
|
|
29
|
+
const mine = run.attempts.filter((a) => a.armId === armId && a.repeat === repeat && a.task === 'detect');
|
|
30
|
+
if (!mine.length) return null;
|
|
31
|
+
if (mine.some((a) => a.usage?.usd == null)) return { value: null, reason: 'some attempts have unknown cost' };
|
|
32
|
+
const positives = mine.filter((a) => a.entryKind === 'vulnerable');
|
|
33
|
+
if (!positives.length) return null;
|
|
34
|
+
const res = run.results.find((r) => r.armId === armId && r.repeat === repeat);
|
|
35
|
+
const hit = new Set((res.scores.detect.misses ?? []).map((m) => m.entryId));
|
|
36
|
+
let spent = 0; let found = 0;
|
|
37
|
+
for (const a of mine) { // attempts are in dataset order within a repeat
|
|
38
|
+
spent += a.usage.usd;
|
|
39
|
+
if (spent > budgetUsd) break;
|
|
40
|
+
if (a.entryKind === 'vulnerable' && a.status === 'completed' && !hit.has(a.entryId)) found++;
|
|
41
|
+
}
|
|
42
|
+
return { value: found / positives.length, spentUsd: spent, budgetUsd };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function summarizeRun(run, { budgetUsd = 10 } = {}) {
|
|
46
|
+
const arms = run.arms.map((arm) => {
|
|
47
|
+
const rs = run.results.filter((r) => r.armId === arm.id);
|
|
48
|
+
const tasks = {};
|
|
49
|
+
for (const [task, metrics] of Object.entries(METRICS)) {
|
|
50
|
+
if (!rs.some((r) => r.scores[task])) continue;
|
|
51
|
+
tasks[task] = Object.fromEntries(metrics.map(([name, path]) => [name, summarize(pick(rs, task, path))]));
|
|
52
|
+
}
|
|
53
|
+
const usdPerRepeat = rs.map((r) => (r.economics.attemptsWithUnknownCost > 0 ? null : r.economics.usdMeasured));
|
|
54
|
+
const econ = {
|
|
55
|
+
usd: summarize(usdPerRepeat), latencyMedianMs: summarize(rs.map((r) => r.economics.latencyMs.median)), latencyP95Ms: summarize(rs.map((r) => r.economics.latencyMs.p95)),
|
|
56
|
+
inputTokens: summarize(rs.map((r) => (r.economics.tokensUnknownAttempts > 0 ? null : r.economics.inputTokens))), outputTokens: summarize(rs.map((r) => (r.economics.tokensUnknownAttempts > 0 ? null : r.economics.outputTokens))),
|
|
57
|
+
timeouts: rs.reduce((s, r) => s + r.economics.timeouts, 0), failures: rs.reduce((s, r) => s + r.economics.failures, 0), unknownCostAttempts: rs.reduce((s, r) => s + r.economics.attemptsWithUnknownCost, 0),
|
|
58
|
+
};
|
|
59
|
+
const usdTotal = usdPerRepeat.every((x) => x != null) && usdPerRepeat.length ? usdPerRepeat.reduce((a, b) => a + b, 0) : null;
|
|
60
|
+
const modelCost = arm.kind === 'model';
|
|
61
|
+
const per = (count) => (!modelCost ? { value: null, reason: 'no model cost' } : usdTotal == null ? { value: null, reason: 'cost unknown for some attempts' } : usdTotal === 0 ? { value: null, reason: 'zero measured spend' } : { value: count / usdTotal * 100, perRun: count / rs.length });
|
|
62
|
+
const sum = (task, key) => rs.reduce((s, r) => s + (r.scores[task]?.counts?.[key] ?? 0), 0);
|
|
63
|
+
const ipd = {
|
|
64
|
+
confirmedVulnerabilitiesPer100Usd: tasks.exploit ? per(sum('exploit', 'confirmedVuln')) : { value: null, reason: 'exploit task not run' },
|
|
65
|
+
verifiedFixesPer100Usd: tasks.patch ? per(sum('patch', 'validStrict')) : { value: null, reason: 'patch task not run' },
|
|
66
|
+
truePositivesPer100Usd: tasks.detect ? per(sum('detect', 'tp')) : { value: null, reason: 'detect task not run' },
|
|
67
|
+
recallAtBudget: tasks.detect ? { budgetUsd, perRepeat: rs.map((r) => recallAtBudget(run, arm.id, r.repeat, budgetUsd)), summary: summarize(rs.map((r) => recallAtBudget(run, arm.id, r.repeat, budgetUsd)?.value ?? null)) } : null,
|
|
68
|
+
};
|
|
69
|
+
const dollarsPer = {
|
|
70
|
+
truePositive: tasks.detect && modelCost && usdTotal != null && sum('detect', 'tp') > 0 ? usdTotal / sum('detect', 'tp') : null,
|
|
71
|
+
confirmedExploit: tasks.exploit && modelCost && usdTotal != null && sum('exploit', 'confirmedVuln') > 0 ? usdTotal / sum('exploit', 'confirmedVuln') : null,
|
|
72
|
+
verifiedFix: tasks.patch && modelCost && usdTotal != null && sum('patch', 'validStrict') > 0 ? usdTotal / sum('patch', 'validStrict') : null,
|
|
73
|
+
};
|
|
74
|
+
const primary = Object.fromEntries(Object.entries(tasks).map(([t, m]) => [t, m[PRIMARY[t]]]));
|
|
75
|
+
return { id: arm.id, kind: arm.kind, model: arm.model, runs: rs.length, tasks, economics: econ, intelligencePerDollar: ipd, dollarsPer, primary };
|
|
76
|
+
});
|
|
77
|
+
// PRD 6 reliability: provider variance. The SAME model id reached through different providers is not necessarily the same behaviour
|
|
78
|
+
// (quantization, serving stack, sampling defaults), so when a run has such arms the spread between them is reported on its own.
|
|
79
|
+
const byModel = new Map();
|
|
80
|
+
for (const a of arms) if (a.kind === 'model' && a.model?.id) byModel.set(a.model.id, [...(byModel.get(a.model.id) ?? []), a]);
|
|
81
|
+
const providerVariance = [];
|
|
82
|
+
for (const [modelId, group] of byModel) {
|
|
83
|
+
if (new Set(group.map((g) => g.model.provider)).size < 2) continue;
|
|
84
|
+
for (const task of new Set(group.flatMap((g) => Object.keys(g.primary)))) {
|
|
85
|
+
const vals = group.map((g) => ({ arm: g.id, provider: g.model.provider, mean: g.primary[task]?.mean ?? null })).filter((v) => v.mean != null);
|
|
86
|
+
if (vals.length < 2) continue;
|
|
87
|
+
const means = vals.map((v) => v.mean);
|
|
88
|
+
providerVariance.push({ model: modelId, task, spread: Math.max(...means) - Math.min(...means), arms: vals, representative: group.every((g) => g.primary[task]?.representative) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
schema: REPORT_SCHEMA, version: 1, runId: run.runId, runDigest: run.digest, providerVariance,
|
|
93
|
+
header: { scannerCommit: run.scanner?.commit ?? null, scannerVersion: run.scanner?.version ?? null, datasetId: run.dataset.id, datasetVersion: run.dataset.version, datasetHash: run.dataset.hash, split: run.dataset.split, models: run.arms.filter((a) => a.model).map((a) => `${a.model.provider}/${a.model.id}${a.model.local ? ' (local)' : ''}`), config: run.config, machine: run.machine, startedAt: run.startedAt, finishedAt: run.finishedAt },
|
|
94
|
+
leakageAudit: run.leakageAudit, complete: run.complete, incompleteReasons: run.incompleteReasons, arms,
|
|
95
|
+
notes: arms.some((a) => Object.values(a.primary).some((s) => s && !s.representative)) ? ['At least one figure rests on fewer than three runs and is marked not representative.'] : [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const fmt = (s, kind = 'pct') => {
|
|
100
|
+
if (!s || s.mean == null) return 'n/a';
|
|
101
|
+
const base = kind === 'pct' ? pct(s.mean) : kind === 'usd' ? `$${s.mean.toFixed(4)}` : kind === 'ms' ? `${Math.round(s.mean)}ms` : String(s.mean);
|
|
102
|
+
const spread = s.stdev == null ? '' : ` ±${kind === 'pct' ? pct(s.stdev) : kind === 'usd' ? `$${s.stdev.toFixed(4)}` : kind === 'ms' ? `${Math.round(s.stdev)}ms` : s.stdev.toFixed(2)}`;
|
|
103
|
+
return `${base}${spread}${s.representative ? '' : ' (not representative)'}`;
|
|
104
|
+
};
|
|
105
|
+
const money = (x) => (x == null ? 'n/a' : x === 0 ? '$0.00' : x < 0.01 ? `$${x.toFixed(4)}` : `$${x.toFixed(2)}`);
|
|
106
|
+
|
|
107
|
+
export function toMarkdown(rep) {
|
|
108
|
+
const h = rep.header;
|
|
109
|
+
const L = [`# Eval Lab report ${rep.runId}`, '',
|
|
110
|
+
`* Scanner: ${h.scannerVersion ?? 'unknown'} @ ${h.scannerCommit ? h.scannerCommit.slice(0, 12) : 'unknown commit'}`,
|
|
111
|
+
`* Dataset: ${h.datasetId} ${h.datasetVersion} (${h.split}), hash ${String(h.datasetHash).slice(-16)}`,
|
|
112
|
+
`* Models: ${h.models.length ? h.models.join(', ') : 'none (deterministic only)'}`,
|
|
113
|
+
`* Config: tasks ${h.config.tasks.join(', ')}; repeats ${h.config.repeats}; timeout ${h.config.timeoutMs}ms; budget ${h.config.budgetUsd ?? 'none'}`,
|
|
114
|
+
`* Machine: ${h.machine.platform}/${h.machine.arch}, ${h.machine.cpus} cpus, ${h.machine.memoryGb} GB, node ${h.machine.node}`,
|
|
115
|
+
`* Run: ${h.startedAt} to ${h.finishedAt}`,
|
|
116
|
+
`* Leakage audit: ${rep.leakageAudit.status} (${rep.leakageAudit.usable}/${rep.leakageAudit.entries} entries usable; comments ${rep.leakageAudit.stripComments ? 'stripped' : 'kept'})`,
|
|
117
|
+
...(rep.complete ? [] : [`* **INCOMPLETE**: ${rep.incompleteReasons.join('; ')}`]), ''];
|
|
118
|
+
for (const task of Object.keys(METRICS)) {
|
|
119
|
+
const present = rep.arms.filter((a) => a.tasks[task]);
|
|
120
|
+
if (!present.length) continue;
|
|
121
|
+
const cols = METRICS[task].map(([n]) => n);
|
|
122
|
+
L.push(`## ${task}`, '', `| arm | runs | ${cols.join(' | ')} |`, `|---|---|${cols.map(() => '---').join('|')}|`);
|
|
123
|
+
for (const a of present) L.push(`| ${a.id} | ${a.runs} | ${cols.map((c) => fmt(a.tasks[task][c])).join(' | ')} |`);
|
|
124
|
+
L.push('');
|
|
125
|
+
}
|
|
126
|
+
L.push('## Cost, speed and reliability', '', '| arm | spend per run | median latency | p95 latency | timeouts | failures | unknown-cost attempts |', '|---|---|---|---|---|---|---|');
|
|
127
|
+
for (const a of rep.arms) L.push(`| ${a.id} | ${a.kind === 'deterministic' ? 'no model cost' : fmt(a.economics.usd, 'usd')} | ${fmt(a.economics.latencyMedianMs, 'ms')} | ${fmt(a.economics.latencyP95Ms, 'ms')} | ${a.economics.timeouts} | ${a.economics.failures} | ${a.economics.unknownCostAttempts} |`);
|
|
128
|
+
if (rep.providerVariance?.length) {
|
|
129
|
+
L.push('', '## Provider variance', '', 'The same model id reached through different providers:', '', '| model | task | spread | arms |', '|---|---|---|---|');
|
|
130
|
+
for (const v of rep.providerVariance) L.push(`| ${v.model} | ${v.task} | ${pct(v.spread)}${v.representative ? '' : ' (not representative)'} | ${v.arms.map((a) => `${a.arm} (${a.provider}) ${pct(a.mean)}`).join('; ')} |`);
|
|
131
|
+
}
|
|
132
|
+
L.push('', '## Intelligence per dollar', '', '| arm | confirmed vulnerabilities / $100 | verified fixes / $100 | true positives / $100 | recall at fixed budget |', '|---|---|---|---|---|');
|
|
133
|
+
for (const a of rep.arms) {
|
|
134
|
+
const i = a.intelligencePerDollar; const cell = (x) => (x?.value == null ? `n/a (${x?.reason ?? 'not measured'})` : x.value.toFixed(1));
|
|
135
|
+
const rb = i.recallAtBudget; L.push(`| ${a.id} | ${cell(i.confirmedVulnerabilitiesPer100Usd)} | ${cell(i.verifiedFixesPer100Usd)} | ${cell(i.truePositivesPer100Usd)} | ${rb ? `${fmt(rb.summary)} at $${rb.budgetUsd}` : 'n/a'} |`);
|
|
136
|
+
}
|
|
137
|
+
L.push('', '## Dollars per outcome', '', '| arm | per true positive | per confirmed exploit | per verified fix |', '|---|---|---|---|');
|
|
138
|
+
for (const a of rep.arms) L.push(`| ${a.id} | ${money(a.dollarsPer.truePositive)} | ${money(a.dollarsPer.confirmedExploit)} | ${money(a.dollarsPer.verifiedFix)} |`);
|
|
139
|
+
if (rep.notes.length) L.push('', ...rep.notes.map((n) => `> ${n}`));
|
|
140
|
+
return `${L.join('\n')}\n`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toCsv(rep) {
|
|
144
|
+
const q = (v) => { const s = v == null ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
145
|
+
const rows = [['arm', 'kind', 'task', 'metric', 'n', 'mean', 'stdev', 'min', 'max', 'ci95_low', 'ci95_high', 'representative']];
|
|
146
|
+
for (const a of rep.arms) {
|
|
147
|
+
for (const [task, ms] of Object.entries(a.tasks)) for (const [name, s] of Object.entries(ms)) rows.push([a.id, a.kind, task, name, s.n, s.mean, s.stdev, s.min, s.max, s.ci95?.low, s.ci95?.high, s.representative]);
|
|
148
|
+
for (const [name, s] of [['usd', a.economics.usd], ['latencyMedianMs', a.economics.latencyMedianMs], ['latencyP95Ms', a.economics.latencyP95Ms]]) rows.push([a.id, a.kind, 'economics', name, s.n, s.mean, s.stdev, s.min, s.max, s.ci95?.low, s.ci95?.high, s.representative]);
|
|
149
|
+
}
|
|
150
|
+
return `${rows.map((r) => r.map(q).join(',')).join('\n')}\n`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
154
|
+
export function toHtml(rep) {
|
|
155
|
+
const md = toMarkdown(rep).split('\n');
|
|
156
|
+
const out = ['<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Eval Lab report</title><style>body{font:14px/1.5 system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem}table{border-collapse:collapse;margin:.5rem 0 1.5rem}td,th{border:1px solid #8884;padding:.25rem .6rem;text-align:left}h1,h2{margin-top:1.5rem}blockquote{border-left:3px solid #c80;margin:1rem 0;padding:.1rem 1rem}</style></head><body>'];
|
|
157
|
+
let table = null; let list = false;
|
|
158
|
+
const closeList = () => { if (list) { out.push('</ul>'); list = false; } };
|
|
159
|
+
for (const line of md) {
|
|
160
|
+
if (line.startsWith('|')) {
|
|
161
|
+
closeList();
|
|
162
|
+
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
|
|
163
|
+
if (cells.every((c) => /^-+$/.test(c))) continue;
|
|
164
|
+
if (!table) { table = true; out.push(`<table><thead><tr>${cells.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>`); } else out.push(`<tr>${cells.map((c) => `<td>${esc(c)}</td>`).join('')}</tr>`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (table) { out.push('</tbody></table>'); table = null; }
|
|
168
|
+
if (line.startsWith('# ')) { closeList(); out.push(`<h1>${esc(line.slice(2))}</h1>`); } else if (line.startsWith('## ')) { closeList(); out.push(`<h2>${esc(line.slice(3))}</h2>`); } else if (line.startsWith('* ')) { if (!list) { out.push('<ul>'); list = true; } out.push(`<li>${esc(line.slice(2).replace(/\*\*/g, ''))}</li>`); } else if (line.startsWith('> ')) { closeList(); out.push(`<blockquote>${esc(line.slice(2))}</blockquote>`); } else if (line.trim()) { closeList(); out.push(`<p>${esc(line)}</p>`); }
|
|
169
|
+
}
|
|
170
|
+
closeList(); if (table) out.push('</tbody></table>');
|
|
171
|
+
out.push('</body></html>');
|
|
172
|
+
return `${out.join('\n')}\n`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Chart-ready series: one point per arm and metric, with the error bar the repeats support. */
|
|
176
|
+
export function toChartData(rep) {
|
|
177
|
+
const series = [];
|
|
178
|
+
for (const a of rep.arms) for (const [task, ms] of Object.entries(a.tasks)) for (const [metric, s] of Object.entries(ms)) {
|
|
179
|
+
if (s.mean == null) continue;
|
|
180
|
+
series.push({ arm: a.id, task, metric, value: s.mean, low: s.ci95?.low ?? null, high: s.ci95?.high ?? null, n: s.n, representative: s.representative });
|
|
181
|
+
}
|
|
182
|
+
const scatter = rep.arms.filter((a) => a.economics.usd.mean != null).flatMap((a) => Object.entries(a.primary).filter(([, s]) => s?.mean != null).map(([task, s]) => ({ arm: a.id, task, costUsd: a.economics.usd.mean, quality: s.mean, representative: s.representative })));
|
|
183
|
+
return { schema: 'agentic-security/eval-lab-chart-data', version: 1, runId: rep.runId, series, qualityVsCost: scatter };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function renderReport(rep, format) {
|
|
187
|
+
if (format === 'json') return `${JSON.stringify(rep, null, 2)}\n`;
|
|
188
|
+
if (format === 'csv') return toCsv(rep);
|
|
189
|
+
if (format === 'md') return toMarkdown(rep);
|
|
190
|
+
if (format === 'html') return toHtml(rep);
|
|
191
|
+
if (format === 'chart') return `${JSON.stringify(toChartData(rep), null, 2)}\n`;
|
|
192
|
+
throw new Error(`unknown report format: ${format}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// The Eval Lab run (PRD 6): arms x repeats x tasks x dataset entries, with the leakage controls in front and the scores behind.
|
|
2
|
+
//
|
|
3
|
+
// prepare materialize each entry's workspace, neutralize it, audit it (custody) -> staged dir, or quarantined and excluded
|
|
4
|
+
// execute one attempt per (arm, repeat, task, entry), bounded concurrency, per-attempt timeout, cancellable
|
|
5
|
+
// score detection, falsification, exploit proof and remediation, each recomputed from what the lab itself observed
|
|
6
|
+
//
|
|
7
|
+
// Mechanical evidence decides (the shared engineering contract): an arm's claim that its PoC works or its patch holds is never
|
|
8
|
+
// taken. The lab executes the arm's reproducer in the proof fabric and replays the original exploit against the arm's patch.
|
|
9
|
+
// An attempt that does not complete is recorded as such and never dropped, so a model cannot improve its score by failing.
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { digestOf } from '../../assurance/identity.js';
|
|
16
|
+
import { protectedTermsFrom } from '../custody.js';
|
|
17
|
+
import { superviseSpawn } from '../../../sandbox/supervise.js';
|
|
18
|
+
import { scrubEnv } from '../../../sandbox/trust-domains.js';
|
|
19
|
+
import { proveFinding } from '../../proof-fabric/orchestrator.js';
|
|
20
|
+
import { verifyFixWithProof } from '../../proof-fabric/fix-proof.js';
|
|
21
|
+
import { validateReproducer } from '../../proof-fabric/reproducer.js';
|
|
22
|
+
import { materializeEntry } from './materialize.js';
|
|
23
|
+
import { stageNeutralized } from './neutralize.js';
|
|
24
|
+
import { validateArm, runArmTask, TASKS } from './arms.js';
|
|
25
|
+
import { resolveInside, normalizeCwe } from './dataset.js';
|
|
26
|
+
import { scoreDetect, scoreFalsify, scoreExploit, scorePatch, scoreEconomics } from './score.js';
|
|
27
|
+
|
|
28
|
+
export const RUN_SCHEMA = 'agentic-security/eval-lab-run';
|
|
29
|
+
const RUN_VERSION = 1;
|
|
30
|
+
export const DEFAULT_REPEATS = 3;
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
|
|
33
|
+
const hasTarget = (e) => !!(e.kind === 'vulnerable' ? e.expectedSpan : e.lookalikeSpan);
|
|
34
|
+
const targetOf = (e) => (e.kind === 'vulnerable' ? { span: e.expectedSpan, cwe: normalizeCwe(e.cwe) } : { span: e.lookalikeSpan, cwe: normalizeCwe(e.resemblesCwe ?? e.cwe) });
|
|
35
|
+
|
|
36
|
+
function machineProfile() {
|
|
37
|
+
const cpus = os.cpus();
|
|
38
|
+
return { platform: os.platform(), arch: os.arch(), cpus: cpus.length, cpuModel: cpus[0]?.model ?? null, memoryGb: Math.round(os.totalmem() / 2 ** 30), node: process.version };
|
|
39
|
+
}
|
|
40
|
+
function scannerIdentity() {
|
|
41
|
+
let version = null; let commit = null;
|
|
42
|
+
try { version = require('../../../../package.json').version; } catch { /* bundled: unknown */ }
|
|
43
|
+
try {
|
|
44
|
+
const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: path.dirname(new URL(import.meta.url).pathname), encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
45
|
+
if (r.status === 0) commit = r.stdout.trim() || null;
|
|
46
|
+
} catch { /* not a checkout */ }
|
|
47
|
+
return { version, commit };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function pool(jobs, limit, run, signal) {
|
|
51
|
+
let next = 0;
|
|
52
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, jobs.length || 1)) }, async () => {
|
|
53
|
+
while (next < jobs.length && !signal?.aborted) { const j = jobs[next++]; await run(j); }
|
|
54
|
+
});
|
|
55
|
+
await Promise.all(workers);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Stage every entry's workspace. Returns the prepared map and the leakage-audit record for the run. */
|
|
59
|
+
export function prepareWorkspaces({ manifest, baseDir, workRoot }) {
|
|
60
|
+
const policy = manifest.dataset.leakagePolicy;
|
|
61
|
+
const terms = protectedTermsFrom({ advisoryText: policy.protectedTerms ?? [] });
|
|
62
|
+
const prepared = new Map(); const quarantined = []; const failed = [];
|
|
63
|
+
let filesAudited = 0;
|
|
64
|
+
for (const e of manifest.entries) {
|
|
65
|
+
const raw = path.join(workRoot, `raw-${prepared.size + quarantined.length + failed.length}`);
|
|
66
|
+
const m = materializeEntry(e, { baseDir, destDir: raw });
|
|
67
|
+
if (!m.ok) { failed.push({ entryId: e.id, reason: m.reason }); fs.rmSync(raw, { recursive: true, force: true }); continue; }
|
|
68
|
+
const dest = path.join(workRoot, `w${prepared.size + quarantined.length + failed.length}`);
|
|
69
|
+
const s = stageNeutralized({ srcDir: raw, destDir: dest, policy, terms });
|
|
70
|
+
fs.rmSync(raw, { recursive: true, force: true });
|
|
71
|
+
filesAudited += s.audit.scannedFiles;
|
|
72
|
+
if (!s.ok) { quarantined.push({ entryId: e.id, leaks: s.leaks.slice(0, 5).map((l) => ({ kind: l.kind, file: l.file ?? null })) }); continue; }
|
|
73
|
+
prepared.set(e.id, { dir: dest, fileMap: s.fileMap });
|
|
74
|
+
}
|
|
75
|
+
const status = failed.length || quarantined.length ? (prepared.size ? 'entries-excluded' : 'no-entries-usable') : 'clean';
|
|
76
|
+
return { prepared, terms, audit: { status, mode: 'enforce', stripComments: !!policy.stripComments, renameFiles: !!policy.renameFiles, entries: manifest.entries.length, usable: prepared.size, quarantined, unavailable: failed, filesAudited, termCount: terms.length, scope: 'workspace contents and every request the lab sends an arm; an arm\'s own internal prompts are not visible to the lab' } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function testsPass({ argv, dir, files, timeoutMs, signal }) {
|
|
80
|
+
if (!argv) return null;
|
|
81
|
+
const copy = fs.mkdtempSync(path.join(os.tmpdir(), 'eval-test-'));
|
|
82
|
+
try {
|
|
83
|
+
fs.cpSync(dir, copy, { recursive: true, dereference: false });
|
|
84
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
85
|
+
const t = path.resolve(copy, rel);
|
|
86
|
+
if (!t.startsWith(copy + path.sep)) return null;
|
|
87
|
+
fs.mkdirSync(path.dirname(t), { recursive: true }); fs.writeFileSync(t, content);
|
|
88
|
+
}
|
|
89
|
+
const r = await superviseSpawn(argv[0], argv.slice(1), { cwd: copy, env: scrubEnv(process.env), timeoutMs, signal });
|
|
90
|
+
if (r.spawnError || r.timedOut || r.cancelled) return null;
|
|
91
|
+
return r.exitCode === 0;
|
|
92
|
+
} finally { fs.rmSync(copy, { recursive: true, force: true }); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {object} o
|
|
97
|
+
* @param {{manifest: object, baseDir: string, hash: string}} o.dataset
|
|
98
|
+
* @param {object[]} o.arms
|
|
99
|
+
* @param {string[]} [o.tasks]
|
|
100
|
+
* @param {number} [o.repeats]
|
|
101
|
+
*/
|
|
102
|
+
export async function runEval({ dataset, arms, tasks = ['detect'], repeats = DEFAULT_REPEATS, concurrency = 2, timeoutMs = 120_000, budgetUsd = null, scanRoot = process.cwd(), signal, onProgress = () => {}, deps = {} } = {}) {
|
|
103
|
+
const now = deps.now ?? Date.now;
|
|
104
|
+
const startedAt = new Date(now()).toISOString();
|
|
105
|
+
const errors = [];
|
|
106
|
+
if (!Array.isArray(arms) || !arms.length) errors.push('at least one arm is required');
|
|
107
|
+
const ids = new Set();
|
|
108
|
+
for (const a of arms ?? []) { const v = validateArm(a); if (!v.ok) errors.push(`arm ${a?.id ?? '?'}: ${v.errors.join('; ')}`); if (ids.has(a?.id)) errors.push(`arm id ${a.id} is duplicated`); ids.add(a?.id); }
|
|
109
|
+
if (!tasks.length || !tasks.every((t) => TASKS.includes(t))) errors.push(`tasks must be some of ${TASKS.join(', ')}`);
|
|
110
|
+
if (!(Number.isInteger(repeats) && repeats >= 1 && repeats <= 50)) errors.push('repeats must be an integer from 1 to 50');
|
|
111
|
+
if (errors.length) return { ok: false, errors };
|
|
112
|
+
|
|
113
|
+
const { manifest, baseDir } = dataset;
|
|
114
|
+
const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'eval-lab-'));
|
|
115
|
+
try {
|
|
116
|
+
const prep = prepareWorkspaces({ manifest, baseDir, workRoot });
|
|
117
|
+
const entriesById = new Map(manifest.entries.map((e) => [e.id, e]));
|
|
118
|
+
const usable = manifest.entries.filter((e) => prep.prepared.has(e.id));
|
|
119
|
+
const attempts = [];
|
|
120
|
+
const spend = { usd: 0 };
|
|
121
|
+
const armPlans = arms.map((a) => ({ arm: a, repeats: a.kind === 'deterministic' ? 1 : repeats, tasks: tasks.filter((t) => a.tasks.includes(t)) }));
|
|
122
|
+
const jobs = [];
|
|
123
|
+
for (const plan of armPlans) for (let r = 0; r < plan.repeats; r++) for (const t of plan.tasks) {
|
|
124
|
+
for (const e of usable) {
|
|
125
|
+
if (t !== 'detect' && !hasTarget(e)) continue;
|
|
126
|
+
if (t === 'patch' && !(e.kind === 'vulnerable' && e.reproducer)) continue;
|
|
127
|
+
jobs.push({ arm: plan.arm, repeat: r, task: t, entry: e });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let completedJobs = 0;
|
|
131
|
+
const armLimit = new Map();
|
|
132
|
+
const take = async (arm) => { const cap = arm.maxConcurrency ?? (arm.kind === 'model' ? 1 : concurrency); const st = armLimit.get(arm.id) ?? { n: 0, q: [] }; armLimit.set(arm.id, st); if (st.n >= cap) await new Promise((res) => st.q.push(res)); st.n++; };
|
|
133
|
+
const give = (arm) => { const st = armLimit.get(arm.id); st.n--; st.q.shift()?.(); };
|
|
134
|
+
|
|
135
|
+
await pool(jobs, concurrency, async (job) => {
|
|
136
|
+
const { arm, task, entry } = job;
|
|
137
|
+
const rec = { armId: arm.id, repeat: job.repeat, task, entryId: entry.id, entryKind: entry.kind };
|
|
138
|
+
if (budgetUsd != null && spend.usd >= budgetUsd) { attempts.push({ ...rec, status: 'skipped-budget', reason: `the ${budgetUsd} USD budget was reached`, usage: { usd: null, latencyMs: 0 } }); return; }
|
|
139
|
+
await take(arm);
|
|
140
|
+
try {
|
|
141
|
+
const ws = prep.prepared.get(entry.id);
|
|
142
|
+
const t = targetOf(entry);
|
|
143
|
+
const mappedFile = t.span ? (ws.fileMap[t.span.file] ?? t.span.file) : null;
|
|
144
|
+
const base = { language: manifest.dataset.language, workspace: ws.dir };
|
|
145
|
+
let request = { schema: 'agentic-security/eval-request', task, ...base };
|
|
146
|
+
if (task === 'falsify') request = { ...request, candidates: [{ id: 'c1', file: mappedFile, line: t.span.startLine, cwe: t.cwe }] };
|
|
147
|
+
if (task === 'exploit' || task === 'patch') request = { ...request, target: { file: mappedFile, line: t.span.startLine, endLine: t.span.endLine, cwe: t.cwe } };
|
|
148
|
+
const res = await (deps.runArmTask ?? runArmTask)({ arm, task, request, workspaceDir: ws.dir, terms: prep.terms, scanRoot, signal, timeoutMs, egress: deps.egress, scanFn: deps.scanFn });
|
|
149
|
+
if (res.usage?.usd != null) spend.usd += res.usage.usd;
|
|
150
|
+
const out = { ...rec, status: res.status, reason: res.reason ?? null, usage: res.usage };
|
|
151
|
+
if (res.status === 'completed') {
|
|
152
|
+
if (task === 'detect') out.output = { findings: res.output.findings.slice(0, 200) };
|
|
153
|
+
if (task === 'falsify') out.output = { verdict: res.output.verdicts?.c1 ?? 'uncertain' };
|
|
154
|
+
if (task === 'exploit') {
|
|
155
|
+
const rp = res.output.reproducer;
|
|
156
|
+
const valid = rp && validateReproducer(rp).length === 0;
|
|
157
|
+
out.proof = { reproducerProvided: !!rp, reproducerValid: !!valid, executed: false, state: null, successes: null, runs: null };
|
|
158
|
+
if (valid) {
|
|
159
|
+
const proved = await (deps.proveFn ?? proveFinding)({ finding: { id: `eval-${entry.id}`, stableId: `eval-${entry.id}`, parser: 'IR-TAINT' }, reproducer: { ...rp, findingId: `eval-${entry.id}` }, sourceRoot: ws.dir, policy: { timeoutMs: Math.min(timeoutMs, 60_000) }, signal });
|
|
160
|
+
const ran = proved.attempts && proved.attempts.run > 0 && proved.environment?.mode !== 'unavailable';
|
|
161
|
+
out.proof = { ...out.proof, executed: !!ran, state: proved.state, successes: proved.attempts?.successes ?? null, runs: proved.attempts?.run ?? null, degraded: (proved.degradation ?? []).slice(0, 5) };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (task === 'patch') {
|
|
165
|
+
const files = res.output.files;
|
|
166
|
+
const rpFile = resolveInside(baseDir, entry.reproducer);
|
|
167
|
+
out.patch = { applicable: true, patchProvided: !!files && Object.keys(files).length > 0, exploitBlocked: null, testsPass: null, completeness: null, state: null, bytes: files ? Object.values(files).reduce((s, v) => s + v.length, 0) : 0, digest: files ? digestOf(files) : null };
|
|
168
|
+
if (out.patch.patchProvided && rpFile) {
|
|
169
|
+
let rp = JSON.parse(fs.readFileSync(rpFile, 'utf8'));
|
|
170
|
+
if (rp.sources) rp = { ...rp, sources: Object.fromEntries(Object.entries(rp.sources).map(([d, s]) => [d, ws.fileMap[s] ?? s])) };
|
|
171
|
+
const fp = await (deps.verifyPatchFn ?? verifyFixWithProof)({ finding: { id: `eval-${entry.id}`, stableId: `eval-${entry.id}` }, reproducer: { ...rp, findingId: `eval-${entry.id}` }, files, scanRoot: ws.dir, policy: { timeoutMs: Math.min(timeoutMs, 60_000) }, staticVerification: null, signal });
|
|
172
|
+
const pre = fp.legs?.prePatch?.state === 'EXPLOIT_CONFIRMED';
|
|
173
|
+
const post = fp.legs?.postPatch;
|
|
174
|
+
out.patch.state = fp.state; out.patch.completeness = fp.completeness ?? null;
|
|
175
|
+
out.patch.exploitBlocked = pre && post && post.attempts > 0 && post.completed === post.attempts ? post.exploitSucceeded === 0 : null;
|
|
176
|
+
out.patch.testsPass = await testsPass({ argv: entry.test ?? manifest.dataset.test ?? null, dir: ws.dir, files, timeoutMs: Math.min(timeoutMs, 120_000), signal });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
attempts.push(out);
|
|
181
|
+
} finally { give(arm); completedJobs++; onProgress({ done: completedJobs, total: jobs.length }); }
|
|
182
|
+
}, signal);
|
|
183
|
+
|
|
184
|
+
// ---- scoring: per arm, per repeat, per task
|
|
185
|
+
const mapFileFor = (entryId, f) => prep.prepared.get(entryId)?.fileMap[f] ?? f;
|
|
186
|
+
const results = [];
|
|
187
|
+
for (const plan of armPlans) {
|
|
188
|
+
for (let r = 0; r < plan.repeats; r++) {
|
|
189
|
+
const mine = attempts.filter((a) => a.armId === plan.arm.id && a.repeat === r);
|
|
190
|
+
const scores = {};
|
|
191
|
+
for (const t of plan.tasks) {
|
|
192
|
+
const ta = mine.filter((a) => a.task === t);
|
|
193
|
+
if (t === 'detect') scores.detect = scoreDetect({ entries: usable, attempts: new Map(ta.map((a) => [a.entryId, a])), mapFile: mapFileFor, targetCwes: manifest.dataset.cwe });
|
|
194
|
+
if (t === 'falsify') {
|
|
195
|
+
const truth = new Map(); const verdicts = new Map();
|
|
196
|
+
for (const a of ta) { const e = entriesById.get(a.entryId); truth.set(a.entryId, e.kind === 'vulnerable' ? 'vulnerable' : 'not-vulnerable'); verdicts.set(a.entryId, a.status === 'completed' ? a.output?.verdict ?? 'uncertain' : null); }
|
|
197
|
+
scores.falsify = scoreFalsify({ truth, verdicts });
|
|
198
|
+
}
|
|
199
|
+
if (t === 'exploit') scores.exploit = scoreExploit({ proofs: ta.map((a) => ({ entryKind: a.entryKind, reproducerValid: !!a.proof?.reproducerValid, executed: !!a.proof?.executed, state: a.proof?.state ?? null, successes: a.proof?.successes ?? null, runs: a.proof?.runs ?? null })) });
|
|
200
|
+
if (t === 'patch') scores.patch = scorePatch({ patches: ta.map((a) => a.patch ?? { applicable: true, patchProvided: false, exploitBlocked: null, testsPass: null, completeness: null }) });
|
|
201
|
+
}
|
|
202
|
+
results.push({ armId: plan.arm.id, repeat: r, scores, economics: scoreEconomics(mine) });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const finishedAt = new Date(now()).toISOString();
|
|
206
|
+
const config = { tasks, repeats, concurrency, timeoutMs, budgetUsd, lineTolerance: 3 };
|
|
207
|
+
const armsRecord = arms.map((a) => ({ id: a.id, kind: a.kind, tasks: a.tasks, model: a.model ? { id: a.model.id, provider: a.model.provider, local: a.model.local, version: a.model.version ?? null } : null, adapter: a.adapter.type, repeats: armPlans.find((p) => p.arm.id === a.id).repeats, configDigest: digestOf({ ...a, adapter: { ...a.adapter } }) }));
|
|
208
|
+
const run = {
|
|
209
|
+
schema: RUN_SCHEMA, version: RUN_VERSION,
|
|
210
|
+
runId: `elab-${digestOf({ d: dataset.hash, a: armsRecord, config, startedAt }).slice(-14)}`,
|
|
211
|
+
startedAt, finishedAt, scanner: scannerIdentity(), machine: machineProfile(),
|
|
212
|
+
dataset: { id: manifest.dataset.id, version: manifest.dataset.version, hash: dataset.hash, split: manifest.dataset.split, language: manifest.dataset.language, license: manifest.dataset.license, entries: manifest.entries.length },
|
|
213
|
+
config, arms: armsRecord, leakageAudit: prep.audit, attempts, results,
|
|
214
|
+
complete: jobs.length === attempts.length && !attempts.some((a) => a.status === 'skipped-budget') && prep.audit.status === 'clean',
|
|
215
|
+
incompleteReasons: [
|
|
216
|
+
...(prep.audit.status !== 'clean' ? [`leakage audit: ${prep.audit.status} (${prep.audit.quarantined.length} quarantined, ${prep.audit.unavailable.length} unavailable)`] : []),
|
|
217
|
+
...(attempts.some((a) => a.status === 'skipped-budget') ? ['budget reached; later attempts were skipped'] : []),
|
|
218
|
+
...(signal?.aborted ? ['cancelled'] : []),
|
|
219
|
+
],
|
|
220
|
+
spendUsdMeasured: spend.usd,
|
|
221
|
+
};
|
|
222
|
+
run.digest = digestOf({ ...run, digest: undefined });
|
|
223
|
+
return { ok: true, run };
|
|
224
|
+
} finally { fs.rmSync(workRoot, { recursive: true, force: true }); }
|
|
225
|
+
}
|