@clear-capabilities/agentic-security-scanner 0.174.0 → 0.176.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/bin/agentic-security.js +199 -28
  3. package/dist/2066.index.js +850 -0
  4. package/dist/2199.index.js +1 -1
  5. package/dist/2584.index.js +3332 -0
  6. package/dist/2923.index.js +13 -0
  7. package/dist/2946.index.js +81 -0
  8. package/dist/3196.index.js +27 -0
  9. package/dist/3727.index.js +292 -0
  10. package/dist/4168.index.js +509 -0
  11. package/dist/4281.index.js +993 -0
  12. package/dist/4978bec8fdf5c733dc85.json +283 -0
  13. package/dist/5099.index.js +96 -0
  14. package/dist/5616.index.js +1471 -0
  15. package/dist/5620.index.js +2370 -0
  16. package/dist/5817.index.js +714 -0
  17. package/dist/6333.index.js +98 -0
  18. package/dist/6655.index.js +3221 -0
  19. package/dist/7838.index.js +446 -974
  20. package/dist/818.index.js +4 -3
  21. package/dist/83.index.js +159 -0
  22. package/dist/8551.index.js +82 -0
  23. package/dist/8633.index.js +93 -0
  24. package/dist/8714.index.js +76 -76
  25. package/dist/8825.index.js +84 -0
  26. package/dist/932.index.js +186 -0
  27. package/dist/9560.index.js +358 -19
  28. package/dist/9800.index.js +6 -1
  29. package/dist/agentic-security.mjs +10 -10
  30. package/dist/agentic-security.mjs.sha256 +1 -1
  31. package/dist/df1e8683e7eda7b2b79c.js +29 -0
  32. package/package.json +7 -6
  33. package/src/artifact/CLAUDE.md +4 -0
  34. package/src/artifact/artifact-evidence.js +63 -0
  35. package/src/artifact/cosign.js +61 -0
  36. package/src/artifact/engine-source.js +72 -0
  37. package/src/artifact/oci-layout.js +47 -0
  38. package/src/artifact/registry.js +213 -0
  39. package/src/capabilities/tool-registry.js +2 -0
  40. package/src/language/haskell-security-rules.js +1 -1
  41. package/src/language/nixos-hardening.js +37 -0
  42. package/src/lineage/CLAUDE.md +4 -0
  43. package/src/lineage/identity-token.js +11 -0
  44. package/src/lineage/observation-adapters.js +13 -0
  45. package/src/lineage/runtime-edge-states.js +139 -0
  46. package/src/lineage/runtime-explorer-view.js +82 -0
  47. package/src/lineage/runtime-observation.js +11 -0
  48. package/src/mcp/CLAUDE.md +3 -1
  49. package/src/mcp/proof-tools.js +132 -0
  50. package/src/mcp/server.js +1 -1
  51. package/src/mcp/tools.js +3 -1
  52. package/src/posture/CLAUDE.md +23 -0
  53. package/src/posture/artifact-registry.js +4 -0
  54. package/src/posture/dnr/attack-facts.js +31 -0
  55. package/src/posture/dnr/cli.js +138 -0
  56. package/src/posture/dnr/detection-ir.js +123 -0
  57. package/src/posture/dnr/exposure-window.js +134 -0
  58. package/src/posture/dnr/hunt.js +84 -0
  59. package/src/posture/dnr/log-reader.js +189 -0
  60. package/src/posture/dnr/matchers.js +90 -0
  61. package/src/posture/dnr/outputs.js +262 -0
  62. package/src/posture/dnr/payload-classes.js +120 -0
  63. package/src/posture/dnr/redact.js +25 -0
  64. package/src/posture/dnr/store.js +57 -0
  65. package/src/posture/dnr/validate.js +64 -0
  66. package/src/posture/dnr/view.js +51 -0
  67. package/src/posture/evaluation/lab/arm-ollama.js +88 -0
  68. package/src/posture/evaluation/lab/arms.js +142 -0
  69. package/src/posture/evaluation/lab/cli.js +135 -0
  70. package/src/posture/evaluation/lab/dataset.js +139 -0
  71. package/src/posture/evaluation/lab/materialize.js +44 -0
  72. package/src/posture/evaluation/lab/neutralize.js +144 -0
  73. package/src/posture/evaluation/lab/report.js +176 -0
  74. package/src/posture/evaluation/lab/run.js +225 -0
  75. package/src/posture/evaluation/lab/score.js +135 -0
  76. package/src/posture/evaluation/lab/scoreboard.js +48 -0
  77. package/src/posture/evaluation/lab/stats.js +41 -0
  78. package/src/posture/evaluation/lab/store.js +53 -0
  79. package/src/posture/fleet/cli.js +248 -63
  80. package/src/posture/fleet/discover-edges.js +135 -0
  81. package/src/posture/fleet/enterprise-graph.js +148 -0
  82. package/src/posture/fleet/fleet-graph.js +9 -4
  83. package/src/posture/fleet/fleet-manifest.js +50 -5
  84. package/src/posture/fleet/job-runner.js +153 -0
  85. package/src/posture/fleet/job-store.js +136 -0
  86. package/src/posture/fleet/org-health.js +56 -0
  87. package/src/posture/fleet/scan-cache.js +112 -0
  88. package/src/posture/fleet/scan-child.js +29 -0
  89. package/src/posture/proof-fabric/cli.js +34 -6
  90. package/src/posture/proof-fabric/container.js +57 -0
  91. package/src/posture/proof-fabric/fix-proof.js +2 -1
  92. package/src/posture/proof-fabric/generate-python.js +129 -0
  93. package/src/posture/proof-fabric/generate.js +213 -0
  94. package/src/posture/proof-fabric/reproducer.js +3 -0
  95. package/src/posture/proof-fabric/runner.js +20 -11
  96. package/src/posture/proof-fabric/siblings.js +77 -0
  97. package/src/posture/routing/policy-router.js +103 -0
  98. package/src/posture/rule-compiler/cli.js +64 -6
  99. package/src/posture/rule-compiler/corpus.js +10 -2
  100. package/src/posture/rule-compiler/mutation.js +72 -1
  101. package/src/posture/rule-compiler/promotion.js +21 -5
  102. package/src/posture/rule-compiler/proposal.js +132 -0
  103. package/src/posture/rule-compiler/store.js +16 -0
@@ -0,0 +1,88 @@
1
+ // A built-in model arm for a LOCAL Ollama server (PRD 6: "model adapters"). It reuses the repository's own Ollama provider
2
+ // (`llm-validator/ollama-provider.js`): loopback is enforced there, a failure is a typed error, and nothing here ever falls back to
3
+ // another provider. The lab adds what an evaluation needs on top: the request is built from the already neutralised, already audited
4
+ // workspace, passed through the leakage guard, and the model's JSON answer goes through the same validation as a command arm's.
5
+ //
6
+ // Only loopback hosts are accepted by this adapter. A remote or cloud model is reached through the `command` adapter, where the
7
+ // egress policy decides before anything leaves the machine.
8
+ //
9
+ // What this proves and does not: against a stand-in Ollama server (test/fixtures/eval-lab/fake-ollama.mjs) it proves the request shape,
10
+ // the leakage guard, the timeout and error mapping, the answer validation and the usage accounting. It says nothing about how any
11
+ // real model performs; that is what running the lab with a real model measures.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { callOllamaChat, DEFAULT_OLLAMA_HOST } from '../../../llm-validator/ollama-provider.js';
15
+ import { isLoopbackUrl } from '../../../llm-validator/local-endpoint.js';
16
+ import { guardPrompt } from '../custody.js';
17
+
18
+ const MAX_FILES = 12;
19
+ const MAX_FILE_BYTES = 12 * 1024;
20
+ const MAX_PROMPT_BYTES = 96 * 1024;
21
+ const CODE_EXT = /\.(?:js|mjs|cjs|jsx|ts|tsx|py|java|go|rb|php|c|h|cc|cpp|cs|rs|kt|swift|sh)$/i;
22
+
23
+ function listSources(dir, limit = 400) {
24
+ const out = []; const stack = [''];
25
+ while (stack.length && out.length < limit) {
26
+ const rel = stack.pop();
27
+ let ents; try { ents = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }); } catch { continue; }
28
+ for (const e of ents.sort((a, b) => (a.name < b.name ? -1 : 1))) {
29
+ if (e.isSymbolicLink() || e.name.startsWith('.') || e.name === 'node_modules') continue;
30
+ const r = rel ? `${rel}/${e.name}` : e.name;
31
+ if (e.isDirectory()) stack.push(r); else if (e.isFile() && CODE_EXT.test(e.name)) out.push(r);
32
+ }
33
+ }
34
+ return out.sort();
35
+ }
36
+
37
+ /** Numbered source for the files a task needs: the target file whole (bounded) for single-target tasks, else the first few files. */
38
+ function sourceBlock(workspaceDir, wanted) {
39
+ const files = wanted?.length ? wanted : listSources(workspaceDir).slice(0, MAX_FILES);
40
+ const parts = [];
41
+ for (const f of files.slice(0, MAX_FILES)) {
42
+ const abs = path.resolve(workspaceDir, f);
43
+ if (!abs.startsWith(path.resolve(workspaceDir) + path.sep)) continue;
44
+ let text; try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
45
+ const clipped = text.length > MAX_FILE_BYTES ? `${text.slice(0, MAX_FILE_BYTES)}\n/* truncated */` : text;
46
+ parts.push(`### ${f}\n${clipped.split('\n').map((l, i) => `${String(i + 1).padStart(4)}| ${l}`).join('\n')}`);
47
+ }
48
+ return parts.join('\n\n');
49
+ }
50
+
51
+ const SCHEMAS = {
52
+ detect: { type: 'object', required: ['findings'], properties: { findings: { type: 'array', items: { type: 'object', required: ['file', 'line', 'cwe'], properties: { file: { type: 'string' }, line: { type: 'integer' }, cwe: { type: 'string' }, confidence: { type: 'number' } } } } } },
53
+ falsify: { type: 'object', required: ['verdicts'], properties: { verdicts: { type: 'object', additionalProperties: { type: 'string', enum: ['confirmed', 'refuted', 'uncertain'] } } } },
54
+ exploit: { type: 'object', required: ['reproducer'], properties: { reproducer: { type: 'object' } } },
55
+ patch: { type: 'object', required: ['files'], properties: { files: { type: 'object', additionalProperties: { type: 'string' } } } },
56
+ };
57
+
58
+ function promptFor(task, request, workspaceDir) {
59
+ const t = request.target;
60
+ const code = sourceBlock(workspaceDir, t?.file ? [t.file] : request.candidates?.map((c) => c.file).filter(Boolean));
61
+ const head = 'You are a security analyst. Answer with ONE JSON object and nothing else. Do not explain.';
62
+ if (task === 'detect') return `${head}\nFind security vulnerabilities in the code below. Report each as {"file","line","cwe","confidence"} (cwe like "CWE-89"; confidence 0 to 1). Report only what you can point to a line for. If there are none, return {"findings":[]}.\n\n${code}`;
63
+ if (task === 'falsify') return `${head}\nFor each candidate, decide whether it is a real vulnerability: "confirmed", "refuted" or "uncertain". Return {"verdicts":{"<id>":"<verdict>"}}.\nCandidates: ${JSON.stringify(request.candidates)}\n\n${code}`;
64
+ if (task === 'exploit') return `${head}\nWrite a minimal reproducer for the ${t.cwe ?? 'vulnerability'} at ${t.file}:${t.line}. Return {"reproducer":{"schema":"agentic-security/reproducer","version":1,"findingId":"eval","lang":"javascript","files":{"<name>":"<source>"},"run":["{{NODE}}","<name>","{{CANARY:command}}"],"oracle":{"type":"command_execution","params":{"canary":"command"}}}}. The program must perform the dangerous action only if the vulnerability is real.\n\n${code}`;
65
+ return `${head}\nFix the vulnerability at ${t.file}:${t.line} (${t.cwe ?? 'unspecified'}). Return {"files":{"<path>":"<the complete new file contents>"}} containing only the files you change.\n\n${code}`;
66
+ }
67
+
68
+ /**
69
+ * @returns {Promise<{status: 'completed'|'timeout'|'error'|'blocked'|'unavailable', reason?: string, body?: object, reported?: object}>}
70
+ */
71
+ export async function runOllamaArm({ arm, task, request, workspaceDir, terms = [], timeoutMs = 120_000, signal, callChat = callOllamaChat }) {
72
+ const host = String(arm.adapter.host ?? arm.model.endpoint ?? DEFAULT_OLLAMA_HOST).replace(/\/+$/, '');
73
+ if (!isLoopbackUrl(host)) return { status: 'blocked', reason: 'the ollama adapter only talks to a loopback server; reach a remote model through the command adapter so the egress policy decides' };
74
+ const prompt = promptFor(task, request, workspaceDir);
75
+ if (Buffer.byteLength(prompt) > MAX_PROMPT_BYTES) return { status: 'error', reason: 'the task needs more source than the adapter sends in one prompt' };
76
+ const guard = guardPrompt(prompt, terms);
77
+ if (!guard.ok) return { status: 'blocked', reason: `leakage guard: the prompt contained a protected term (${guard.leaks[0]?.kind})` };
78
+ if (signal?.aborted) return { status: 'error', reason: 'cancelled' };
79
+ const r = await Promise.race([
80
+ callChat({ host, model: arm.model.id, messages: [{ role: 'user', content: prompt }], maxTokens: arm.adapter.maxTokens ?? 2048, schema: SCHEMAS[task], timeouts: { connectTimeoutMs: 3000, requestTimeoutMs: arm.adapter.timeoutMs ?? timeoutMs } }),
81
+ new Promise((resolve) => signal?.addEventListener('abort', () => resolve({ ok: false, code: 'cancelled', reason: 'cancelled' }), { once: true })),
82
+ ]);
83
+ if (!r.ok) return { status: r.code === 'ollama-timeout' ? 'timeout' : r.code === 'ollama-unreachable' || r.code === 'ollama-model-not-installed' ? 'unavailable' : 'error', reason: `${r.code}: ${String(r.reason).slice(0, 160)}` };
84
+ let body;
85
+ try { body = JSON.parse(r.result.text); } catch { return { status: 'error', reason: 'invalid-response: the model did not return JSON', reported: r.result.usage }; }
86
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 'error', reason: 'invalid-response: expected a JSON object', reported: r.result.usage };
87
+ return { status: 'ok', body: { ...body, usage: r.result.usage ?? undefined } };
88
+ }
@@ -0,0 +1,142 @@
1
+ // Arms: the things the Eval Lab measures (PRD 6). An arm is the deterministic scanner, or a model reached through a small
2
+ // operator-supplied command, or a LOCAL Ollama server through the repository's own provider. There is deliberately no built-in client
3
+ // for a remote model: provider calls in this repository go through the feature gate, the egress policy, redaction and the audit chain,
4
+ // and the lab must not become a second path around them.
5
+ // An arm whose model is not local is therefore checked against the egress policy BEFORE it runs, and a denial is recorded as
6
+ // `blocked`, not as a score.
7
+ //
8
+ // Honesty rules enforced here:
9
+ // * `local` is declared, never inferred, and a "local" arm that names a non-loopback endpoint is refused as a lie;
10
+ // * there is no fallback from one arm to another, so a local model can never silently become a cloud call;
11
+ // * the command receives its request in a FILE and answers on stdout; it never reads stdin, and it runs with a scrubbed
12
+ // environment, a wall-clock timeout and cleanup of its whole process tree (`superviseSpawn`);
13
+ // * everything the lab sends is passed through the leakage guard first, and a hit blocks the call;
14
+ // * token and dollar usage are whatever the arm reports and are labelled `reported`; absent usage is `unknown`, never zero.
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import os from 'node:os';
18
+ import { superviseSpawn } from '../../../sandbox/supervise.js';
19
+ import { scrubEnv } from '../../../sandbox/trust-domains.js';
20
+ import { evaluateEgress } from '../../../egress/policy.js';
21
+ import { guardPrompt } from '../custody.js';
22
+ import { defaultScanFn } from '../runner.js';
23
+ import { LAYER_ENV } from '../scan-child.js';
24
+ import { runOllamaArm } from './arm-ollama.js';
25
+
26
+ export const TASKS = Object.freeze(['detect', 'falsify', 'exploit', 'patch']);
27
+ const ARM_KINDS = Object.freeze(['deterministic', 'model']);
28
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
29
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
30
+ const LOOPBACK = /^(?:https?:\/\/)?(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:\/|$)/i;
31
+
32
+ export function validateArm(a) {
33
+ const errors = [];
34
+ const bad = (m) => errors.push(m);
35
+ if (!a || typeof a !== 'object') return { ok: false, errors: ['arm must be an object'] };
36
+ if (typeof a.id !== 'string' || !ID_RE.test(a.id)) bad('arm.id must be a lower-case identifier');
37
+ if (!ARM_KINDS.includes(a.kind)) bad(`arm.kind must be one of ${ARM_KINDS.join(', ')}`);
38
+ if (!Array.isArray(a.tasks) || !a.tasks.length || !a.tasks.every((t) => TASKS.includes(t))) bad(`arm.tasks must list some of ${TASKS.join(', ')}`);
39
+ const ad = a.adapter;
40
+ if (!ad || typeof ad !== 'object') bad('arm.adapter is required');
41
+ else if (ad.type === 'builtin-scanner') {
42
+ if (a.kind !== 'deterministic') bad('the builtin-scanner adapter is the deterministic arm');
43
+ if (a.tasks?.some((t) => t !== 'detect')) bad('the deterministic scanner only performs the detect task');
44
+ if (ad.layer !== undefined && !['deterministic-only', 'deep-taint'].includes(ad.layer)) bad('adapter.layer must be deterministic-only or deep-taint (a model layer is a model arm)');
45
+ } else if (ad.type === 'command') {
46
+ if (!Array.isArray(ad.argv) || !ad.argv.length || !ad.argv.every((x) => typeof x === 'string' && x.length < 2000 && !x.includes('\0'))) bad('adapter.argv must be a non-empty array of strings (no shell string)');
47
+ if (ad.timeoutMs !== undefined && !(Number.isInteger(ad.timeoutMs) && ad.timeoutMs >= 1000 && ad.timeoutMs <= 3_600_000)) bad('adapter.timeoutMs must be between 1000 and 3600000');
48
+ } else if (ad.type === 'ollama') {
49
+ if (a.kind !== 'model') bad('the ollama adapter is a model arm');
50
+ if (a.model?.local !== true) bad('the ollama adapter talks to a local server, so model.local must be true');
51
+ if (ad.host !== undefined && !LOOPBACK.test(String(ad.host))) bad('adapter.host must be a loopback address; use the command adapter for a remote model');
52
+ if (ad.timeoutMs !== undefined && !(Number.isInteger(ad.timeoutMs) && ad.timeoutMs >= 1000 && ad.timeoutMs <= 3_600_000)) bad('adapter.timeoutMs must be between 1000 and 3600000');
53
+ } else bad('adapter.type must be builtin-scanner, command or ollama');
54
+ if (a.kind === 'model') {
55
+ const m = a.model;
56
+ if (!m || typeof m.id !== 'string' || !m.id || typeof m.provider !== 'string' || !m.provider) bad('a model arm needs model.id and model.provider');
57
+ else {
58
+ if (typeof m.local !== 'boolean') bad('model.local must be true or false; it is declared, never inferred');
59
+ if (m.local === true && m.endpoint && !LOOPBACK.test(String(m.endpoint))) bad('model.local is true but model.endpoint is not a loopback address');
60
+ if (m.local === false && (typeof m.endpoint !== 'string' || !m.endpoint)) bad('a non-local model arm must name its endpoint so the egress policy can judge it');
61
+ }
62
+ if (a.kind === 'model' && ad?.type === 'builtin-scanner') bad('a model arm needs a command or ollama adapter');
63
+ }
64
+ if (a.price !== undefined) {
65
+ const p = a.price;
66
+ if (!p || !(Number.isFinite(p.inputPerMTok) && p.inputPerMTok >= 0 && Number.isFinite(p.outputPerMTok) && p.outputPerMTok >= 0)) bad('arm.price needs non-negative inputPerMTok and outputPerMTok');
67
+ }
68
+ return { ok: errors.length === 0, errors };
69
+ }
70
+
71
+ /** Dollars from reported tokens and a declared price, or the arm's own reported figure. `unknown` is null, never zero. */
72
+ export function usageOf(arm, reported, latencyMs) {
73
+ const u = reported && typeof reported === 'object' ? reported : {};
74
+ const num = (x) => (Number.isFinite(x) && x >= 0 ? x : null);
75
+ const inputTokens = num(u.inputTokens); const outputTokens = num(u.outputTokens);
76
+ let usd = num(u.usd); let source = usd != null ? 'reported' : 'unknown';
77
+ if (usd == null && arm.price && inputTokens != null && outputTokens != null) {
78
+ usd = (inputTokens * arm.price.inputPerMTok + outputTokens * arm.price.outputPerMTok) / 1e6; source = 'computed-from-reported-tokens';
79
+ }
80
+ if (arm.kind === 'deterministic') { usd = 0; source = 'no-model'; }
81
+ return { inputTokens, outputTokens, usd, usdSource: source, latencyMs };
82
+ }
83
+
84
+ const normalizeFinding = (f) => ({ file: String(f.file ?? ''), line: Number.isInteger(f.line) ? f.line : null, cwe: f.cwe ?? null, confidence: typeof f.confidence === 'number' ? f.confidence : null });
85
+
86
+ /** Turn an arm's parsed JSON answer into the task's output, or an `error` result. Shared by every adapter, so a model gets no softer validation than a command. */
87
+ function shapeBody(task, body, done) {
88
+ const out = {};
89
+ if (task === 'detect') { if (!Array.isArray(body.findings)) return done('error', { reason: 'invalid-response: detect needs "findings"', reported: body.usage }); out.findings = body.findings.slice(0, 5000).filter((f) => f && typeof f === 'object').map(normalizeFinding); }
90
+ else if (task === 'falsify') { if (!body.verdicts || typeof body.verdicts !== 'object') return done('error', { reason: 'invalid-response: falsify needs "verdicts"', reported: body.usage }); out.verdicts = Object.fromEntries(Object.entries(body.verdicts).slice(0, 5000).map(([k, v]) => [k, ['confirmed', 'refuted', 'uncertain'].includes(v) ? v : 'uncertain'])); }
91
+ else if (task === 'exploit') { if (!body.reproducer || typeof body.reproducer !== 'object') return done('completed', { output: { reproducer: null }, reported: body.usage }); out.reproducer = body.reproducer; }
92
+ else if (task === 'patch') { if (!body.files || typeof body.files !== 'object' || Array.isArray(body.files)) return done('completed', { output: { files: null }, reported: body.usage }); out.files = Object.fromEntries(Object.entries(body.files).slice(0, 64).filter(([k, v]) => typeof k === 'string' && typeof v === 'string' && v.length < 1_000_000)); }
93
+ return done('completed', { output: out, reported: body.usage });
94
+ }
95
+
96
+ /**
97
+ * Run one task for one arm. Never throws, never returns a score: it returns what happened.
98
+ * @returns {{status: 'completed'|'timeout'|'error'|'blocked'|'unavailable', reason?: string, output?: object, usage: object}}
99
+ */
100
+ export async function runArmTask({ arm, task, request, workspaceDir, terms = [], scanRoot, signal, timeoutMs = 120_000, egress = evaluateEgress, scanFn = defaultScanFn }) {
101
+ const t0 = Date.now();
102
+ const done = (status, extra = {}) => ({ status, usage: usageOf(arm, extra.reported, Date.now() - t0), ...extra, reported: undefined });
103
+ if (!arm.tasks.includes(task)) return done('unavailable', { reason: `arm does not perform ${task}` });
104
+ if (arm.adapter.type === 'builtin-scanner') {
105
+ const layer = arm.adapter.layer ?? 'deep-taint';
106
+ try {
107
+ const res = await scanFn(workspaceDir, { layer, timeoutMs, env: { ...scrubEnv(process.env), ...LAYER_ENV[layer] } });
108
+ return done('completed', { output: { findings: (res.findings || []).map(normalizeFinding) } });
109
+ } catch (e) {
110
+ return done(e.code === 'TIMEOUT' ? 'timeout' : e.code === 'UNAVAILABLE' ? 'unavailable' : 'error', { reason: String(e.message).slice(0, 200) });
111
+ }
112
+ }
113
+ // model arm through the command or ollama adapter
114
+ if (arm.model.local === false) {
115
+ const d = egress({ scanRoot, purpose: 'eval-lab', endpoint: arm.model.endpoint, model: arm.model.id, provider: arm.model.provider, role: `eval-${task}`, dataClass: 'source-code' });
116
+ if (!d.allowed) return done('blocked', { reason: `egress policy: ${d.reason}` });
117
+ }
118
+ if (arm.adapter.type === 'ollama') {
119
+ const o = await runOllamaArm({ arm, task, request, workspaceDir, terms, timeoutMs, signal });
120
+ if (o.status !== 'ok') return done(o.status, { reason: o.reason, reported: o.reported });
121
+ return shapeBody(task, o.body, done);
122
+ }
123
+ const guard = guardPrompt(JSON.stringify(request), terms);
124
+ if (!guard.ok) return done('blocked', { reason: `leakage guard: the request contained a protected term (${guard.leaks[0]?.kind})` });
125
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'eval-arm-'));
126
+ try {
127
+ const reqFile = path.join(dir, 'request.json');
128
+ fs.writeFileSync(reqFile, JSON.stringify(request));
129
+ const argv = arm.adapter.argv.map((x) => x.split('{{REQUEST}}').join(reqFile));
130
+ const env = { ...scrubEnv(process.env), AGENTIC_EVAL_REQUEST: reqFile, AGENTIC_EVAL_TASK: task };
131
+ const r = await superviseSpawn(argv[0], argv.slice(1), { cwd: dir, env, timeoutMs: arm.adapter.timeoutMs ?? timeoutMs, signal, maxOutputBytes: MAX_RESPONSE_BYTES });
132
+ if (r.spawnError) return done('error', { reason: `could not start the arm: ${r.spawnError}` });
133
+ if (r.timedOut) return done('timeout', { reason: 'the arm did not answer within its time limit' });
134
+ if (r.cancelled) return done('error', { reason: 'cancelled' });
135
+ if (r.outputCapped) return done('error', { reason: 'the arm answered with more output than allowed' });
136
+ if (r.exitCode !== 0) return done('error', { reason: `the arm exited ${r.exitCode}${r.exitSignal ? ` (${r.exitSignal})` : ''}` });
137
+ let body;
138
+ try { body = JSON.parse(r.stdout); } catch { return done('error', { reason: 'invalid-response: the arm did not print JSON' }); }
139
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return done('error', { reason: 'invalid-response: expected a JSON object' });
140
+ return shapeBody(task, body, done);
141
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
142
+ }
@@ -0,0 +1,135 @@
1
+ // CLI for the Eval Lab and the model router: `eval run|compare|report|list`, `models benchmark`, `models route explain`.
2
+ // Non-interactive (nothing reads stdin), bounded (every attempt has a timeout, the run is cancellable with SIGINT), and local:
3
+ // the lab itself makes no network call. A model arm that is not local needs `--allow-external`, and even then the egress policy
4
+ // is consulted for it before it runs.
5
+ //
6
+ // Exit codes: 0 done, 1 done but incomplete or no recommendation, 4 usage or precondition.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { load as loadYaml } from '../../../util/yaml.js';
10
+ import { loadDatasetFile } from './dataset.js';
11
+ import { runEval, DEFAULT_REPEATS } from './run.js';
12
+ import { validateArm, TASKS } from './arms.js';
13
+ import { summarizeRun, renderReport, REPORT_FORMATS, PRIMARY } from './report.js';
14
+ import { saveRun, loadRun, listRuns, saveScoreboard, loadScoreboard } from './store.js';
15
+ import { scoreboardFromRun, mergeScoreboard } from './scoreboard.js';
16
+ import { routeTask, explainRoute, ROUTER_TASKS, ROUTER_POLICIES } from '../../routing/policy-router.js';
17
+
18
+ const MAX_ARMS_BYTES = 1024 * 1024;
19
+
20
+ function readArms(file) {
21
+ let doc;
22
+ try {
23
+ const st = fs.statSync(file);
24
+ if (!st.isFile() || st.size > MAX_ARMS_BYTES) return { ok: false, errors: [`${file} is not a regular file under ${MAX_ARMS_BYTES} bytes`] };
25
+ const text = fs.readFileSync(file, 'utf8');
26
+ doc = /\.json$/i.test(file) ? JSON.parse(text) : loadYaml(text);
27
+ } catch (e) { return { ok: false, errors: [`cannot read ${file}: ${String(e.message).split('\n')[0]}`] }; }
28
+ const arms = Array.isArray(doc) ? doc : doc?.arms;
29
+ if (!Array.isArray(arms) || !arms.length) return { ok: false, errors: ['the arms file needs a list of arms (or { "arms": [...] })'] };
30
+ const errors = arms.flatMap((a, i) => { const v = validateArm(a); return v.ok ? [] : v.errors.map((e) => `arms[${i}]: ${e}`); });
31
+ return errors.length ? { ok: false, errors } : { ok: true, arms };
32
+ }
33
+
34
+ const usageRun = () => { console.error('usage: eval run --dataset <file> --arms <file> [--tasks detect,falsify,exploit,patch] [--repeats N] [--concurrency N] [--timeout S] [--budget USD] [--allow-external]\n eval report <run-id> [--format md|json|csv|html|chart] [--out <file>]\n eval compare <run-id> [<other-run-id>]\n eval list'); return 4; };
35
+
36
+ async function doRun(args, root, { updateScoreboard }) {
37
+ const f = args.flags;
38
+ if (!f.dataset || !f.arms) return usageRun();
39
+ const ds = loadDatasetFile(path.resolve(String(f.dataset)));
40
+ if (!ds.ok) { console.error(`Invalid dataset:\n ${ds.errors.join('\n ')}`); return 4; }
41
+ const ar = readArms(path.resolve(String(f.arms)));
42
+ if (!ar.ok) { console.error(`Invalid arms:\n ${ar.errors.join('\n ')}`); return 4; }
43
+ const external = ar.arms.filter((a) => a.kind === 'model' && a.model.local === false);
44
+ if (external.length && !f['allow-external']) { console.error(`These arms send source code to a non-local model: ${external.map((a) => a.id).join(', ')}. Pass --allow-external to run them; the egress policy is still consulted for each.`); return 4; }
45
+ const tasks = f.tasks ? String(f.tasks).split(',').map((s) => s.trim()).filter(Boolean) : ['detect'];
46
+ if (!tasks.every((t) => TASKS.includes(t))) { console.error(`--tasks must be some of ${TASKS.join(', ')}`); return 4; }
47
+ const num = (v, name, lo, hi) => { if (v === undefined) return undefined; const n = Number(v); if (!Number.isFinite(n) || n < lo || n > hi) { console.error(`--${name} must be a number from ${lo} to ${hi}`); return null; } return n; };
48
+ const repeats = num(f.repeats, 'repeats', 1, 50); const concurrency = num(f.concurrency, 'concurrency', 1, 32); const timeoutS = num(f.timeout, 'timeout', 1, 3600); const budget = num(f.budget, 'budget', 0, 1e6);
49
+ if (repeats === null || concurrency === null || timeoutS === null || budget === null) return 4;
50
+ const ac = new AbortController(); const onSig = () => ac.abort(); process.once('SIGINT', onSig);
51
+ let res;
52
+ try {
53
+ res = await runEval({ dataset: ds, arms: ar.arms, tasks, repeats: repeats ?? DEFAULT_REPEATS, concurrency: concurrency ?? 2, timeoutMs: (timeoutS ?? 120) * 1000, budgetUsd: budget ?? null, scanRoot: root, signal: ac.signal });
54
+ } finally { process.off('SIGINT', onSig); }
55
+ if (!res.ok) { console.error(`Cannot run:\n ${res.errors.join('\n ')}`); return 4; }
56
+ const run = res.run;
57
+ const saved = saveRun(root, run);
58
+ let sbNote = '';
59
+ if (updateScoreboard) {
60
+ const cur = loadScoreboard(root);
61
+ if (cur.tampered) sbNote = 'The existing scoreboard failed its integrity check and was not updated; remove it to start again.';
62
+ else { const w = saveScoreboard(root, mergeScoreboard(cur.doc, scoreboardFromRun(run, ar.arms))); sbNote = w.ok ? 'Scoreboard updated.' : `Scoreboard not stored: ${w.reason}`; }
63
+ }
64
+ const rep = summarizeRun(run);
65
+ if (f.json) console.log(JSON.stringify({ runId: run.runId, stored: saved.ok ? saved.file : null, complete: run.complete, report: rep }, null, 2));
66
+ else { console.log(renderReport(rep, 'md')); console.log(saved.ok ? `Run stored: ${run.runId}` : `(run not stored: ${saved.reason})`); if (sbNote) console.log(sbNote); }
67
+ return run.complete ? 0 : 1;
68
+ }
69
+
70
+ export async function cmdEval(args, root) {
71
+ const sub = args._[1];
72
+ if (sub === 'run') return doRun(args, root, { updateScoreboard: false });
73
+ if (sub === 'list') {
74
+ const runs = listRuns(root);
75
+ if (args.flags.json) { console.log(JSON.stringify(runs, null, 2)); return 0; }
76
+ if (!runs.length) console.log('No eval runs yet. Start one with: eval run --dataset <file> --arms <file>');
77
+ for (const r of runs) console.log(`${r.runId} ${r.startedAt} ${r.dataset} arms: ${r.arms.join(', ')}${r.tampered ? ' (FAILED INTEGRITY CHECK)' : ''}`);
78
+ return 0;
79
+ }
80
+ if (sub === 'report' || sub === 'compare') {
81
+ const id = String(args._[2] ?? '');
82
+ if (!id) return usageRun();
83
+ const a = loadRun(root, id);
84
+ if (a.missing) { console.error(`No run ${id}. List runs with: eval list`); return 4; }
85
+ if (a.tampered) { console.error(`Run ${id} failed its integrity check and is not used.`); return 1; }
86
+ if (sub === 'report') {
87
+ const format = String(args.flags.format ?? 'md');
88
+ if (!REPORT_FORMATS.includes(format)) { console.error(`--format must be one of ${REPORT_FORMATS.join(', ')}`); return 4; }
89
+ const text = renderReport(summarizeRun(a.doc), format);
90
+ if (args.flags.out) { fs.writeFileSync(path.resolve(String(args.flags.out)), text); console.log(`Wrote ${format} report to ${path.resolve(String(args.flags.out))}`); } else process.stdout.write(text);
91
+ return 0;
92
+ }
93
+ const other = args._[3] ? loadRun(root, String(args._[3])) : null;
94
+ if (other && (other.missing || other.tampered)) { console.error(`Run ${args._[3]} is ${other.missing ? 'missing' : 'not intact'}.`); return other.missing ? 4 : 1; }
95
+ return compare(a.doc, other?.doc ?? null, !!args.flags['allow-different-datasets'], !!args.flags.json);
96
+ }
97
+ return usageRun();
98
+ }
99
+
100
+ function compare(a, b, allowDiff, json) {
101
+ const ra = summarizeRun(a);
102
+ if (!b) {
103
+ const rows = ra.arms.map((arm) => ({ arm: arm.id, kind: arm.kind, runs: arm.runs, ...Object.fromEntries(Object.entries(arm.primary).map(([t, s]) => [`${t}:${PRIMARY[t]}`, s?.mean ?? null])), usdPerRun: arm.economics.usd.mean, medianLatencyMs: arm.economics.latencyMedianMs.mean, representative: Object.values(arm.primary).every((s) => s?.representative) }));
104
+ if (json) console.log(JSON.stringify({ runId: a.runId, rows }, null, 2)); else console.log(renderReport(ra, 'md'));
105
+ return 0;
106
+ }
107
+ if (a.dataset.hash !== b.dataset.hash && !allowDiff) { console.error(`These runs used different datasets (${a.dataset.id} ${String(a.dataset.hash).slice(-8)} vs ${b.dataset.id} ${String(b.dataset.hash).slice(-8)}); a comparison would not mean anything. Pass --allow-different-datasets to see the numbers anyway.`); return 1; }
108
+ const rb = summarizeRun(b);
109
+ const out = [];
110
+ for (const arm of ra.arms) {
111
+ const other = rb.arms.find((x) => x.id === arm.id);
112
+ if (!other) continue;
113
+ for (const [t, s] of Object.entries(arm.primary)) { const o = other.primary[t]; if (s?.mean == null || o?.mean == null) continue; out.push({ arm: arm.id, task: t, metric: PRIMARY[t], a: s.mean, b: o.mean, delta: o.mean - s.mean, representative: !!(s.representative && o.representative) }); }
114
+ }
115
+ if (json) console.log(JSON.stringify({ a: a.runId, b: b.runId, sameDataset: a.dataset.hash === b.dataset.hash, rows: out }, null, 2));
116
+ else { console.log(`${a.runId} -> ${b.runId}${a.dataset.hash === b.dataset.hash ? '' : ' (DIFFERENT DATASETS)'}`); for (const r of out) console.log(` ${r.arm} ${r.task}/${r.metric}: ${(r.a * 100).toFixed(1)}% -> ${(r.b * 100).toFixed(1)}% (${r.delta >= 0 ? '+' : ''}${(r.delta * 100).toFixed(1)} points)${r.representative ? '' : ' not representative'}`); if (!out.length) console.log(' no arm appears in both runs'); }
117
+ return 0;
118
+ }
119
+
120
+ export async function cmdModelsLab(args, root) {
121
+ const sub = args._[1];
122
+ if (sub === 'benchmark') return doRun(args, root, { updateScoreboard: true });
123
+ if (sub === 'route') {
124
+ if (args._[2] !== 'explain') { console.error(`usage: models route explain <${Object.keys(ROUTER_TASKS).join('|')}> [--policy ${ROUTER_POLICIES.join('|')}] [--threshold 0.8] [--tolerance 0.02] [--dataset-hash <hash>]`); return 4; }
125
+ const task = String(args._[3] ?? '');
126
+ if (!task) { console.error(`models route explain needs a task: ${Object.keys(ROUTER_TASKS).join(', ')}`); return 4; }
127
+ const sb = loadScoreboard(root);
128
+ if (sb.tampered) { console.error('The scoreboard failed its integrity check and is not used for routing. Remove .agentic-security/eval-lab/scoreboard.json and re-run: models benchmark'); return 1; }
129
+ const n = (v) => (v === undefined ? undefined : Number(v));
130
+ const r = routeTask({ task, policy: args.flags.policy ? String(args.flags.policy) : 'balanced', scoreboard: sb.doc, qualityThreshold: n(args.flags.threshold) ?? null, ...(args.flags.tolerance !== undefined ? { tolerance: n(args.flags.tolerance) } : {}), ...(args.flags['min-runs'] !== undefined ? { minRuns: n(args.flags['min-runs']) } : {}), datasetHash: args.flags['dataset-hash'] ? String(args.flags['dataset-hash']) : null, scanRoot: root });
131
+ if (args.flags.json) console.log(JSON.stringify(r, null, 2)); else console.log(explainRoute(r));
132
+ return r.ok ? 0 : (r.blocked === 'unknown-task' || r.blocked === 'invalid-policy' ? 4 : 1);
133
+ }
134
+ return null;
135
+ }
@@ -0,0 +1,139 @@
1
+ // Dataset manifest for the Eval Lab (PRD 6, requirements 1, 3 and 4).
2
+ //
3
+ // A dataset is the thing a model is measured on, so everything that could make a score meaningless is a validation error here,
4
+ // not a default:
5
+ // * `leakagePolicy.mode` must be `enforce`. There is no `off`; a dataset cannot opt out of the leakage controls.
6
+ // * a real-code dataset (`git-pairs`) must name a license that permits use. An unknown license refuses the dataset.
7
+ // * every vulnerable entry carries the exact span the defect occupies (`expectedSpan`), so detection is scored on LOCATION, not
8
+ // on "a finding of the right class exists somewhere in the file".
9
+ // * every hard negative says what it resembles and which kind of safe code it is (sanitization, authorization, ...), because a
10
+ // negative that does not look like a vulnerability measures nothing.
11
+ //
12
+ // Labels (kind, cwe, span, category, reproducer) live in the manifest and are NEVER written into a workspace an arm can read.
13
+ // A workspace reference is a directory inside the manifest's own folder, or a revision of a local git repository.
14
+ //
15
+ // Pure except `loadDatasetFile`, which reads one bounded file.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { load as loadYaml } from '../../../util/yaml.js';
19
+ import { digestOf } from '../../assurance/identity.js';
20
+
21
+ const DATASET_SCHEMA = 'agentic-security/eval-dataset';
22
+ export const SPLITS = Object.freeze(['dev', 'validation', 'sealed']);
23
+ const ENTRY_KINDS = Object.freeze(['vulnerable', 'fixed', 'hard-negative']);
24
+ export const HARD_NEGATIVE_CATEGORIES = Object.freeze(['sanitization', 'authorization', 'parameterization', 'bounds-check', 'correct-crypto', 'safe-wrapper']);
25
+ const SOURCE_KINDS = Object.freeze(['directory', 'git-pairs']);
26
+ const PERMITTED_LICENSES = Object.freeze(['MIT', 'Apache-2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'ISC', 'MPL-2.0', '0BSD', 'Unlicense', 'CC0-1.0', 'GPL-2.0-only', 'GPL-2.0-or-later', 'GPL-3.0-only', 'GPL-3.0-or-later', 'LGPL-2.1-only', 'LGPL-2.1-or-later', 'LGPL-3.0-only', 'LGPL-3.0-or-later', 'AGPL-3.0-only', 'AGPL-3.0-or-later']);
27
+ const MAX_MANIFEST_BYTES = 5 * 1024 * 1024;
28
+ const MAX_ENTRIES = 20000;
29
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;
30
+ const CWE_RE = /^CWE-\d{1,5}$/;
31
+ const REV_RE = /^[0-9a-f]{7,64}$/i;
32
+
33
+ export const normalizeCwe = (c) => { const m = /(\d{1,5})/.exec(String(c ?? '')); return m ? `CWE-${Number(m[1])}` : null; };
34
+
35
+ function spanErrors(span, where) {
36
+ const errs = [];
37
+ if (!span || typeof span !== 'object') return [`${where} is required`];
38
+ if (typeof span.file !== 'string' || !span.file || span.file.includes('\0') || span.file.startsWith('/') || span.file.split(/[\\/]/).includes('..')) errs.push(`${where}.file must be a relative path inside the workspace`);
39
+ if (!Number.isInteger(span.startLine) || span.startLine < 1) errs.push(`${where}.startLine must be a positive integer`);
40
+ if (!Number.isInteger(span.endLine) || span.endLine < span.startLine) errs.push(`${where}.endLine must be an integer at or after startLine`);
41
+ return errs;
42
+ }
43
+
44
+ const argvOk = (a) => Array.isArray(a) && a.length > 0 && a.length <= 64 && a.every((x) => typeof x === 'string' && x.length < 2000 && !x.includes('\0'));
45
+
46
+ /** @returns {{ok: boolean, errors: string[]}} */
47
+ export function validateDatasetManifest(m) {
48
+ const errors = [];
49
+ const bad = (msg) => errors.push(msg);
50
+ if (!m || typeof m !== 'object' || Array.isArray(m)) return { ok: false, errors: ['manifest must be an object'] };
51
+ const d = m.dataset;
52
+ if (!d || typeof d !== 'object') return { ok: false, errors: ['manifest needs a "dataset" object'] };
53
+ if (typeof d.id !== 'string' || !ID_RE.test(d.id)) bad('dataset.id must be a lower-case identifier');
54
+ if (typeof d.version !== 'string' || !/^[0-9A-Za-z][0-9A-Za-z._+-]{0,39}$/.test(d.version)) bad('dataset.version is required');
55
+ const langs = Array.isArray(d.language) ? d.language : [d.language];
56
+ if (!langs.length || !langs.every((l) => typeof l === 'string' && /^[a-z][a-z0-9+#-]{0,19}$/.test(l))) bad('dataset.language must name one or more languages');
57
+ if (!d.source || typeof d.source !== 'object' || !SOURCE_KINDS.includes(d.source.kind)) bad(`dataset.source.kind must be one of ${SOURCE_KINDS.join(', ')}`);
58
+ if (!SPLITS.includes(d.split)) bad(`dataset.split must be one of ${SPLITS.join(', ')}`);
59
+ if (typeof d.license !== 'string' || !d.license.trim()) bad('dataset.license is required');
60
+ else if (d.source?.kind === 'git-pairs' && !PERMITTED_LICENSES.includes(d.license)) bad(`a real-code dataset needs a license that permits use (one of ${PERMITTED_LICENSES.slice(0, 6).join(', ')}, ...); "${d.license}" is not accepted`);
61
+ if (!Array.isArray(d.cwe) || !d.cwe.length || !d.cwe.every((c) => CWE_RE.test(String(c)))) bad('dataset.cwe must list CWE ids such as CWE-89');
62
+ const lp = d.leakagePolicy;
63
+ if (!lp || typeof lp !== 'object') bad('dataset.leakagePolicy is required');
64
+ else {
65
+ if (lp.mode !== 'enforce') bad('dataset.leakagePolicy.mode must be "enforce"; a dataset cannot turn the leakage controls off');
66
+ for (const k of ['stripComments', 'renameFiles']) if (lp[k] !== undefined && typeof lp[k] !== 'boolean') bad(`dataset.leakagePolicy.${k} must be a boolean`);
67
+ if (lp.removePaths !== undefined && !(Array.isArray(lp.removePaths) && lp.removePaths.every((g) => typeof g === 'string' && g.length >= 1 && g.length <= 120 && !g.includes('\0')))) bad('dataset.leakagePolicy.removePaths must be a list of simple globs');
68
+ if (lp.protectedTerms !== undefined && !(Array.isArray(lp.protectedTerms) && lp.protectedTerms.every((t) => typeof t === 'string' && t.length >= 4 && t.length <= 200))) bad('dataset.leakagePolicy.protectedTerms must be a list of strings of at least 4 characters');
69
+ }
70
+ if (d.build !== undefined && !argvOk(d.build)) bad('dataset.build must be an argv array');
71
+ if (d.test !== undefined && !argvOk(d.test)) bad('dataset.test must be an argv array');
72
+
73
+ const entries = m.entries;
74
+ if (!Array.isArray(entries) || !entries.length) bad('entries must be a non-empty list');
75
+ else if (entries.length > MAX_ENTRIES) bad(`entries is limited to ${MAX_ENTRIES}`);
76
+ else {
77
+ const seen = new Set();
78
+ entries.forEach((e, i) => {
79
+ const w = `entries[${i}]`;
80
+ if (!e || typeof e !== 'object') { bad(`${w} must be an object`); return; }
81
+ if (typeof e.id !== 'string' || !ID_RE.test(e.id)) bad(`${w}.id must be a lower-case identifier`);
82
+ else if (seen.has(e.id)) bad(`${w}.id "${e.id}" is duplicated`);
83
+ else seen.add(e.id);
84
+ if (!ENTRY_KINDS.includes(e.kind)) bad(`${w}.kind must be one of ${ENTRY_KINDS.join(', ')}`);
85
+ if (!e.workspace || typeof e.workspace !== 'object') bad(`${w}.workspace is required`);
86
+ else if (typeof e.workspace.dir === 'string') {
87
+ if (e.workspace.dir.startsWith('/') || e.workspace.dir.includes('\0') || e.workspace.dir.split(/[\\/]/).includes('..')) bad(`${w}.workspace.dir must stay inside the manifest folder`);
88
+ } else if (e.workspace.git && typeof e.workspace.git === 'object') {
89
+ if (typeof e.workspace.git.repo !== 'string' || !e.workspace.git.repo) bad(`${w}.workspace.git.repo is required`);
90
+ if (typeof e.workspace.git.rev !== 'string' || !REV_RE.test(e.workspace.git.rev)) bad(`${w}.workspace.git.rev must be a commit id (a moving ref such as a branch is not reproducible)`);
91
+ } else bad(`${w}.workspace needs "dir" or "git"`);
92
+ if (e.kind === 'vulnerable') {
93
+ if (!CWE_RE.test(String(e.cwe))) bad(`${w}.cwe is required for a vulnerable entry`);
94
+ errors.push(...spanErrors(e.expectedSpan, `${w}.expectedSpan`));
95
+ if (e.category !== undefined || e.lookalikeSpan !== undefined) bad(`${w}: category and lookalikeSpan belong to negative entries`);
96
+ } else if (e.kind === 'hard-negative') {
97
+ if (!HARD_NEGATIVE_CATEGORIES.includes(e.category)) bad(`${w}.category must be one of ${HARD_NEGATIVE_CATEGORIES.join(', ')}`);
98
+ if (!CWE_RE.test(String(e.resemblesCwe))) bad(`${w}.resemblesCwe must name the weakness it looks like`);
99
+ errors.push(...spanErrors(e.lookalikeSpan, `${w}.lookalikeSpan`));
100
+ if (e.expectedSpan !== undefined) bad(`${w}: a hard negative has no expectedSpan; it is not vulnerable`);
101
+ } else if (e.kind === 'fixed') {
102
+ if (!CWE_RE.test(String(e.resemblesCwe ?? e.cwe))) bad(`${w}.resemblesCwe must name the weakness that was fixed`);
103
+ if (e.lookalikeSpan !== undefined) errors.push(...spanErrors(e.lookalikeSpan, `${w}.lookalikeSpan`));
104
+ }
105
+ if (e.reproducer !== undefined && (typeof e.reproducer !== 'string' || e.reproducer.startsWith('/') || e.reproducer.split(/[\\/]/).includes('..'))) bad(`${w}.reproducer must be a relative path inside the manifest folder`);
106
+ if (e.test !== undefined && !argvOk(e.test)) bad(`${w}.test must be an argv array`);
107
+ });
108
+ if (!entries.some((e) => e?.kind === 'vulnerable')) bad('a dataset needs at least one vulnerable entry');
109
+ if (!entries.some((e) => e?.kind === 'hard-negative' || e?.kind === 'fixed')) bad('a dataset needs at least one negative entry (fixed or hard-negative); without one, precision cannot be measured');
110
+ }
111
+ return { ok: errors.length === 0, errors };
112
+ }
113
+
114
+ /** Content hash of everything that defines what is measured. Key order does not matter. */
115
+ export const datasetHash = (m) => digestOf({ dataset: m.dataset, entries: m.entries });
116
+
117
+ /** Read and validate a manifest (JSON or YAML), bounded. Entry paths resolve against the manifest's own folder. */
118
+ export function loadDatasetFile(file) {
119
+ let st;
120
+ try { st = fs.statSync(file); } catch { return { ok: false, errors: [`cannot read ${file}`] }; }
121
+ if (!st.isFile() || st.size > MAX_MANIFEST_BYTES) return { ok: false, errors: [`${file} is not a regular file under ${MAX_MANIFEST_BYTES} bytes`] };
122
+ let doc;
123
+ try {
124
+ const text = fs.readFileSync(file, 'utf8');
125
+ doc = /\.json$/i.test(file) ? JSON.parse(text) : loadYaml(text);
126
+ } catch (e) { return { ok: false, errors: [`cannot parse ${path.basename(file)}: ${String(e.message).split('\n')[0]}`] }; }
127
+ const v = validateDatasetManifest(doc);
128
+ if (!v.ok) return { ok: false, errors: v.errors };
129
+ return { ok: true, manifest: doc, baseDir: path.dirname(path.resolve(file)), hash: datasetHash(doc) };
130
+ }
131
+
132
+ /** Resolve a manifest-relative path and confirm it did not escape (symlinks included). Returns null when it did. */
133
+ export function resolveInside(baseDir, rel) {
134
+ const target = path.resolve(baseDir, rel);
135
+ let real;
136
+ try { real = fs.realpathSync(target); } catch { return null; }
137
+ const root = fs.realpathSync(baseDir);
138
+ return real === root || real.startsWith(root + path.sep) ? real : null;
139
+ }
@@ -0,0 +1,44 @@
1
+ // Turn a dataset entry's workspace reference into a directory of files (PRD 6, requirement 3: vulnerable commit -> fixing commit).
2
+ // Two sources: a folder inside the manifest's own directory, or a revision of a LOCAL git repository read with `git archive`
3
+ // (so no checkout, no hooks and no working tree are involved) and unpacked through the hardened tar reader. Nothing is fetched
4
+ // from the network, a moving ref is refused at validation time, and every limit is a typed failure rather than a truncation.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { readTarEntries, TarSafetyError } from '../../../artifact/safe-tar.js';
9
+ import { resolveInside } from './dataset.js';
10
+
11
+ const MATERIALIZE_LIMITS = Object.freeze({ gitTimeoutMs: 120_000, maxArchiveBytes: 512 * 1024 * 1024, maxFiles: 50_000, maxFileBytes: 16 * 1024 * 1024 });
12
+
13
+ export function materializeEntry(entry, { baseDir, destDir, limits = MATERIALIZE_LIMITS } = {}) {
14
+ fs.rmSync(destDir, { recursive: true, force: true });
15
+ fs.mkdirSync(destDir, { recursive: true });
16
+ const ws = entry.workspace;
17
+ if (ws.dir !== undefined) {
18
+ const src = resolveInside(baseDir, ws.dir);
19
+ if (!src || !fs.statSync(src).isDirectory()) return { ok: false, reason: 'workspace directory is missing or outside the manifest folder' };
20
+ fs.cpSync(src, destDir, { recursive: true, dereference: false, filter: (s) => !['.git', 'node_modules'].includes(path.basename(s)) });
21
+ return { ok: true, source: 'directory' };
22
+ }
23
+ const repo = resolveInside(baseDir, ws.git.repo) ?? (path.isAbsolute(ws.git.repo) && fs.existsSync(ws.git.repo) ? fs.realpathSync(ws.git.repo) : null);
24
+ if (!repo) return { ok: false, reason: 'git repository not found' };
25
+ const r = spawnSync('git', ['-C', repo, 'archive', '--format=tar', ws.git.rev], { timeout: limits.gitTimeoutMs, killSignal: 'SIGKILL', maxBuffer: limits.maxArchiveBytes, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } });
26
+ if (r.error) return { ok: false, reason: r.error.code === 'ETIMEDOUT' ? 'git archive timed out' : r.error.code === 'ENOBUFS' ? 'archive exceeds the size limit' : `git archive failed: ${r.error.code || r.error.message}` };
27
+ if (r.status !== 0) return { ok: false, reason: `git archive failed: ${String(r.stderr).split('\n')[0].slice(0, 160)}` };
28
+ let files = 0;
29
+ try {
30
+ for (const e of readTarEntries(r.stdout)) {
31
+ if (e.unsafe) return { ok: false, reason: `unsafe path in the archive: ${String(e.name).slice(0, 80)}` };
32
+ if (e.typeflag !== '0' && e.typeflag !== '\0') continue; // directories are implied; links and devices are never created
33
+ if (++files > limits.maxFiles) return { ok: false, reason: `more than ${limits.maxFiles} files` };
34
+ if (e.size > limits.maxFileBytes) return { ok: false, reason: `a file exceeds ${limits.maxFileBytes} bytes` };
35
+ const target = path.resolve(destDir, e.name);
36
+ if (target !== destDir && !target.startsWith(destDir + path.sep)) return { ok: false, reason: 'archive path escapes the workspace' };
37
+ fs.mkdirSync(path.dirname(target), { recursive: true });
38
+ fs.writeFileSync(target, e.data);
39
+ }
40
+ } catch (err) {
41
+ return { ok: false, reason: err instanceof TarSafetyError ? `archive refused: ${err.message}` : `archive unreadable: ${err.message}` };
42
+ }
43
+ return { ok: true, source: 'git', files };
44
+ }