@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,135 @@
|
|
|
1
|
+
// Scoring for the Eval Lab (PRD 6): detection, falsification, exploit proof, remediation, economics. Pure functions over
|
|
2
|
+
// recorded attempts and the dataset's labels; no fs, no clock, no process.
|
|
3
|
+
//
|
|
4
|
+
// Principles carried over from the rest of the evaluation code:
|
|
5
|
+
// * a figure never appears without the counts behind it;
|
|
6
|
+
// * an attempt that timed out, errored or was blocked is a recorded NO-RESULT. For a known positive it is a miss; for a
|
|
7
|
+
// negative it is neither a true negative nor a false positive (it is `unscored`), so a model cannot look precise by crashing;
|
|
8
|
+
// * a quantity that could not be measured is `null` with a reason, never 0;
|
|
9
|
+
// * detection is scored on LOCATION. A finding of the right class in the right file but outside the labelled span is a
|
|
10
|
+
// file-level hit and a localization miss, and only a localized hit is a true positive.
|
|
11
|
+
import { normalizeCwe } from './dataset.js';
|
|
12
|
+
import { ratio, f1 } from './stats.js';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_LINE_TOLERANCE = 3;
|
|
15
|
+
|
|
16
|
+
const resultOf = (a) => a && a.status === 'completed';
|
|
17
|
+
|
|
18
|
+
/** @param {object[]} entries dataset entries @param {Map<string, object>} attempts entryId -> attempt @param {Map<string,string>} fileMaps `${entryId}\0${path}` -> staged path */
|
|
19
|
+
export function scoreDetect({ entries, attempts, mapFile = (entryId, f) => f, tolerance = DEFAULT_LINE_TOLERANCE, targetCwes = null }) {
|
|
20
|
+
const cweSet = targetCwes ? new Set(targetCwes.map(normalizeCwe)) : null;
|
|
21
|
+
const perCwe = new Map();
|
|
22
|
+
const slot = (c) => { if (!perCwe.has(c)) perCwe.set(c, { tp: 0, fn: 0, fp: 0, fileHits: 0 }); return perCwe.get(c); };
|
|
23
|
+
const byCategory = {};
|
|
24
|
+
const c = { tp: 0, fn: 0, fp: 0, tn: 0, unscoredNegatives: 0, noResultPositives: 0, fileHits: 0, positives: 0, negatives: 0, attempted: 0, completed: 0 };
|
|
25
|
+
const misses = [];
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
const a = attempts.get(e.id);
|
|
28
|
+
if (!a) continue;
|
|
29
|
+
c.attempted++;
|
|
30
|
+
if (resultOf(a)) c.completed++;
|
|
31
|
+
const found = resultOf(a) ? (a.output?.findings ?? []) : [];
|
|
32
|
+
if (e.kind === 'vulnerable') {
|
|
33
|
+
c.positives++;
|
|
34
|
+
const cwe = normalizeCwe(e.cwe); const s = slot(cwe);
|
|
35
|
+
if (!resultOf(a)) { c.fn++; s.fn++; c.noResultPositives++; misses.push({ entryId: e.id, reason: `no-result:${a.status}` }); continue; }
|
|
36
|
+
const file = mapFile(e.id, e.expectedSpan.file);
|
|
37
|
+
const sameFile = found.filter((f) => f.file === file && normalizeCwe(f.cwe) === cwe);
|
|
38
|
+
const loc = sameFile.some((f) => Number.isInteger(f.line) && f.line >= e.expectedSpan.startLine - tolerance && f.line <= e.expectedSpan.endLine + tolerance);
|
|
39
|
+
if (sameFile.length) { c.fileHits++; s.fileHits++; }
|
|
40
|
+
if (loc) { c.tp++; s.tp++; } else { c.fn++; s.fn++; misses.push({ entryId: e.id, reason: sameFile.length ? 'wrong-location' : found.some((f) => f.file === file) ? 'wrong-class' : 'not-found' }); }
|
|
41
|
+
} else {
|
|
42
|
+
c.negatives++;
|
|
43
|
+
const cat = e.kind === 'hard-negative' ? e.category : 'fixed';
|
|
44
|
+
byCategory[cat] ??= { entries: 0, falsePositives: 0, unscored: 0 };
|
|
45
|
+
byCategory[cat].entries++;
|
|
46
|
+
if (!resultOf(a)) { c.unscoredNegatives++; byCategory[cat].unscored++; continue; }
|
|
47
|
+
const relevant = found.filter((f) => !cweSet || f.cwe == null || cweSet.has(normalizeCwe(f.cwe)));
|
|
48
|
+
if (relevant.length) {
|
|
49
|
+
c.fp++; byCategory[cat].falsePositives++;
|
|
50
|
+
for (const k of new Set(relevant.map((f) => normalizeCwe(f.cwe) ?? 'unclassified'))) slot(k).fp++;
|
|
51
|
+
} else c.tn++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const precision = ratio(c.tp, c.tp + c.fp); const recall = ratio(c.tp, c.tp + c.fn);
|
|
55
|
+
const per = {};
|
|
56
|
+
for (const [k, v] of [...perCwe.entries()].sort()) { const p = ratio(v.tp, v.tp + v.fp); const r = ratio(v.tp, v.tp + v.fn); per[k] = { ...v, precision: p, recall: r, f1: v.tp + v.fn > 0 ? f1(p ?? (v.tp + v.fn > 0 ? 0 : null), r) : null }; }
|
|
57
|
+
const f1s = Object.values(per).filter((p) => p.tp + p.fn > 0 && p.f1 != null).map((p) => p.f1);
|
|
58
|
+
return {
|
|
59
|
+
counts: c, precision, recall, f1: c.positives > 0 && c.tp === 0 ? 0 : f1(precision, recall),
|
|
60
|
+
macroF1: f1s.length ? f1s.reduce((a, b) => a + b, 0) / f1s.length : null, perCwe: per,
|
|
61
|
+
fileLevelRecall: ratio(c.fileHits, c.positives), localizationAccuracy: ratio(c.tp, c.fileHits),
|
|
62
|
+
hardNegativeFalsePositiveRate: Object.fromEntries(Object.entries(byCategory).map(([k, v]) => [k, { ...v, rate: ratio(v.falsePositives, v.entries - v.unscored) }])),
|
|
63
|
+
completion: ratio(c.completed, c.attempted), misses, tolerance,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** truth: Map candidateId -> 'vulnerable' | 'not-vulnerable'. verdicts: Map candidateId -> attempt-level verdict or null (no result). */
|
|
68
|
+
export function scoreFalsify({ truth, verdicts }) {
|
|
69
|
+
const c = { vulnerable: 0, negative: 0, confirmedVuln: 0, refutedVuln: 0, abstainedVuln: 0, refutedNeg: 0, confirmedNeg: 0, abstainedNeg: 0, noResult: 0 };
|
|
70
|
+
for (const [id, t] of truth) {
|
|
71
|
+
const v = verdicts.get(id) ?? null;
|
|
72
|
+
if (t === 'vulnerable') c.vulnerable++; else c.negative++;
|
|
73
|
+
if (v == null) { c.noResult++; continue; }
|
|
74
|
+
if (t === 'vulnerable') { if (v === 'confirmed') c.confirmedVuln++; else if (v === 'refuted') c.refutedVuln++; else c.abstainedVuln++; }
|
|
75
|
+
else if (v === 'refuted') c.refutedNeg++; else if (v === 'confirmed') c.confirmedNeg++; else c.abstainedNeg++;
|
|
76
|
+
}
|
|
77
|
+
const tpr = ratio(c.confirmedVuln, c.vulnerable); const tnr = ratio(c.refutedNeg, c.negative);
|
|
78
|
+
return { counts: c, truePositiveRate: tpr, trueNegativeRate: tnr, balancedAccuracy: tpr == null || tnr == null ? null : (tpr + tnr) / 2, falseRefutationRate: ratio(c.refutedVuln, c.vulnerable), falseConfirmationRate: ratio(c.confirmedNeg, c.negative) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** proofs: array of {entryKind, reproducerValid, state, successes, runs, status}. */
|
|
82
|
+
export function scoreExploit({ proofs }) {
|
|
83
|
+
const vuln = proofs.filter((p) => p.entryKind === 'vulnerable');
|
|
84
|
+
const neg = proofs.filter((p) => p.entryKind !== 'vulnerable');
|
|
85
|
+
const valid = proofs.filter((p) => p.reproducerValid);
|
|
86
|
+
const executed = valid.filter((p) => p.executed);
|
|
87
|
+
// reproduction is a property of the VULNERABLE code: a reproducer that (rightly) does nothing to fixed code is not a failure to reproduce
|
|
88
|
+
const executedVuln = executed.filter((p) => p.entryKind === 'vulnerable');
|
|
89
|
+
const reproduced = executedVuln.filter((p) => (p.successes ?? 0) > 0);
|
|
90
|
+
const confirmedVuln = vuln.filter((p) => p.state === 'EXPLOIT_CONFIRMED').length;
|
|
91
|
+
const confirmedNeg = neg.filter((p) => p.state === 'EXPLOIT_CONFIRMED').length;
|
|
92
|
+
return {
|
|
93
|
+
counts: { attempted: proofs.length, vulnerable: vuln.length, negative: neg.length, reproducerValid: valid.length, executed: executed.length, notExecuted: valid.length - executed.length, reproduced: reproduced.length, confirmedVuln, confirmedNeg },
|
|
94
|
+
pocGenerationSuccess: ratio(valid.length, proofs.length),
|
|
95
|
+
reproductionRate: ratio(reproduced.length, executedVuln.length),
|
|
96
|
+
confirmationRate: ratio(confirmedVuln, vuln.length),
|
|
97
|
+
falseConfirmationRate: ratio(confirmedNeg, neg.length),
|
|
98
|
+
unmeasured: valid.length > executed.length ? `${valid.length - executed.length} reproducer(s) could not be executed here (no execution environment); they are not counted as failures` : null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** patches: array of {applicable, patchProvided, exploitBlocked: true|false|null, testsPass: true|false|null, completeness, state}. */
|
|
103
|
+
export function scorePatch({ patches }) {
|
|
104
|
+
const app = patches.filter((p) => p.applicable);
|
|
105
|
+
const provided = app.filter((p) => p.patchProvided);
|
|
106
|
+
const verified = provided.filter((p) => p.exploitBlocked !== null);
|
|
107
|
+
const blocked = verified.filter((p) => p.exploitBlocked === true);
|
|
108
|
+
const testable = provided.filter((p) => p.testsPass !== null);
|
|
109
|
+
const strict = provided.filter((p) => p.exploitBlocked === true && p.testsPass === true);
|
|
110
|
+
const tiers = { FULL: 0, DEGRADED: 0, INCOMPLETE: 0 };
|
|
111
|
+
for (const p of provided) if (tiers[p.completeness] !== undefined) tiers[p.completeness]++;
|
|
112
|
+
return {
|
|
113
|
+
counts: { applicable: app.length, patchProvided: provided.length, verifiable: verified.length, exploitBlocked: blocked.length, testsMeasured: testable.length, testsPassed: testable.filter((p) => p.testsPass).length, validStrict: strict.length },
|
|
114
|
+
patchRate: ratio(provided.length, app.length),
|
|
115
|
+
validPatchRate: ratio(strict.length, app.length),
|
|
116
|
+
exploitBlockedRate: ratio(blocked.length, app.length),
|
|
117
|
+
testPreservationRate: ratio(testable.filter((p) => p.testsPass).length, testable.length),
|
|
118
|
+
regressionRate: ratio(testable.filter((p) => !p.testsPass).length, testable.length),
|
|
119
|
+
completenessTier: tiers,
|
|
120
|
+
unmeasured: testable.length < provided.length ? `${provided.length - testable.length} patch(es) had no runnable test command, so test preservation is unmeasured for them` : null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Sum what the attempts cost. Unknown usage is counted, never read as zero. */
|
|
125
|
+
export function scoreEconomics(attempts) {
|
|
126
|
+
const known = attempts.filter((a) => a.usage?.usd != null);
|
|
127
|
+
const lat = attempts.map((a) => a.usage?.latencyMs).filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
|
|
128
|
+
const sum = (k) => attempts.reduce((s, a) => s + (a.usage?.[k] ?? 0), 0);
|
|
129
|
+
return {
|
|
130
|
+
attempts: attempts.length, usdMeasured: known.reduce((s, a) => s + a.usage.usd, 0), attemptsWithUnknownCost: attempts.length - known.length,
|
|
131
|
+
inputTokens: sum('inputTokens'), outputTokens: sum('outputTokens'), tokensUnknownAttempts: attempts.filter((a) => a.usage?.inputTokens == null || a.usage?.outputTokens == null).length,
|
|
132
|
+
latencyMs: { median: lat.length ? (lat.length % 2 ? lat[lat.length >> 1] : (lat[(lat.length >> 1) - 1] + lat[lat.length >> 1]) / 2) : null, p95: lat.length ? lat[Math.min(lat.length - 1, Math.ceil(lat.length * 0.95) - 1)] : null, total: lat.reduce((a, b) => a + b, 0) },
|
|
133
|
+
timeouts: attempts.filter((a) => a.status === 'timeout').length, failures: attempts.filter((a) => ['error', 'blocked', 'unavailable'].includes(a.status)).length,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// From measured runs to the table the router reads (PRD 6, requirement 7). One entry per (model, task, dataset): the task's
|
|
2
|
+
// primary quality with its spread and run count, the MEDIAN cost and latency of a single task call, where the evidence came from,
|
|
3
|
+
// and whether the model is local. Only model arms appear; the deterministic baseline is a comparison point, not a routing choice.
|
|
4
|
+
// Pure.
|
|
5
|
+
import { summarize, median } from './stats.js';
|
|
6
|
+
import { PRIMARY } from './report.js';
|
|
7
|
+
|
|
8
|
+
const SCOREBOARD_SCHEMA = 'agentic-security/model-scoreboard';
|
|
9
|
+
export const ROUTER_TASK_OF = Object.freeze({ hunt: 'detect', falsification: 'falsify', 'exploit-generation': 'exploit', 'patch-generation': 'patch' });
|
|
10
|
+
const EVAL_TASK_OF_ROUTER = Object.fromEntries(Object.entries(ROUTER_TASK_OF).map(([k, v]) => [v, k]));
|
|
11
|
+
|
|
12
|
+
const primaryValue = (score, evalTask) => {
|
|
13
|
+
const s = score?.[PRIMARY[evalTask]];
|
|
14
|
+
return typeof s === 'number' && Number.isFinite(s) ? s : null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function scoreboardFromRun(run, armDefs = []) {
|
|
18
|
+
const entries = [];
|
|
19
|
+
for (const arm of run.arms.filter((a) => a.model)) {
|
|
20
|
+
const def = armDefs.find((d) => d.id === arm.id);
|
|
21
|
+
const rs = run.results.filter((r) => r.armId === arm.id);
|
|
22
|
+
for (const evalTask of ['detect', 'falsify', 'exploit', 'patch']) {
|
|
23
|
+
if (!rs.some((r) => r.scores[evalTask])) continue;
|
|
24
|
+
const q = summarize(rs.map((r) => primaryValue(r.scores[evalTask], evalTask)));
|
|
25
|
+
const calls = run.attempts.filter((a) => a.armId === arm.id && a.task === evalTask && a.status !== 'skipped-budget');
|
|
26
|
+
const costs = calls.map((a) => a.usage?.usd);
|
|
27
|
+
const costKnown = costs.length > 0 && costs.every((x) => x != null);
|
|
28
|
+
entries.push({
|
|
29
|
+
model: `${arm.model.provider}/${arm.model.id}`, armId: arm.id, local: arm.model.local, endpoint: def?.model?.endpoint ?? null,
|
|
30
|
+
routerTask: EVAL_TASK_OF_ROUTER[evalTask], evalTask, datasetHash: run.dataset.hash, datasetId: run.dataset.id,
|
|
31
|
+
quality: { mean: q.mean, stdev: q.stdev, ci95: q.ci95, n: q.n, representative: q.representative, metric: PRIMARY[evalTask] },
|
|
32
|
+
medianCostUsd: costKnown ? median(costs) : null, costKnown,
|
|
33
|
+
medianLatencyMs: median(calls.map((a) => a.usage?.latencyMs)),
|
|
34
|
+
failures: calls.filter((a) => a.status !== 'completed').length, calls: calls.length,
|
|
35
|
+
evidence: { runId: run.runId, runDigest: run.digest, measuredAt: run.finishedAt },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return entries;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Merge a run's entries into a scoreboard document; a newer entry for the same (model, task, dataset) replaces the older. */
|
|
43
|
+
export function mergeScoreboard(existing, entries) {
|
|
44
|
+
const key = (e) => `${e.model}|${e.routerTask}|${e.datasetHash}`;
|
|
45
|
+
const map = new Map((existing?.entries ?? []).map((e) => [key(e), e]));
|
|
46
|
+
for (const e of entries) map.set(key(e), e);
|
|
47
|
+
return { schema: SCOREBOARD_SCHEMA, version: 1, updatedAt: new Date().toISOString(), entries: [...map.values()].sort((a, b) => key(a).localeCompare(key(b))) };
|
|
48
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Small, dependency-free statistics for repeated runs (PRD 6, requirement 6: never publish one lucky run as representative).
|
|
2
|
+
// Pure: no fs, no clock. A summary of fewer than MIN_REPRESENTATIVE_RUNS values carries `representative: false` and says why,
|
|
3
|
+
// so no consumer can print its mean as a headline without also printing that.
|
|
4
|
+
|
|
5
|
+
export const MIN_REPRESENTATIVE_RUNS = 3;
|
|
6
|
+
|
|
7
|
+
// two-sided 95% Student-t critical values for 1..30 degrees of freedom; 1.96 beyond
|
|
8
|
+
const T95 = [12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, 2.201, 2.179, 2.160, 2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086, 2.080, 2.074, 2.069, 2.064, 2.060, 2.056, 2.052, 2.048, 2.045, 2.042];
|
|
9
|
+
|
|
10
|
+
const finite = (xs) => xs.filter((x) => typeof x === 'number' && Number.isFinite(x));
|
|
11
|
+
|
|
12
|
+
export function median(xs) {
|
|
13
|
+
const v = finite(xs).sort((a, b) => a - b);
|
|
14
|
+
if (!v.length) return null;
|
|
15
|
+
const m = v.length >> 1;
|
|
16
|
+
return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** mean, sample standard deviation, range and a 95% interval of the mean. Unmeasured (null) values are excluded and counted. */
|
|
20
|
+
export function summarize(values) {
|
|
21
|
+
const all = Array.isArray(values) ? values : [];
|
|
22
|
+
const v = finite(all);
|
|
23
|
+
const unmeasured = all.length - v.length;
|
|
24
|
+
if (!v.length) return { n: 0, unmeasured, mean: null, stdev: null, min: null, max: null, ci95: null, representative: false, note: 'no measured value' };
|
|
25
|
+
const n = v.length;
|
|
26
|
+
const mean = v.reduce((a, b) => a + b, 0) / n;
|
|
27
|
+
const stdev = n > 1 ? Math.sqrt(v.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1)) : null;
|
|
28
|
+
const t = n > 1 ? (T95[n - 2] ?? 1.96) : null;
|
|
29
|
+
const half = stdev == null ? null : t * stdev / Math.sqrt(n);
|
|
30
|
+
const representative = n >= MIN_REPRESENTATIVE_RUNS;
|
|
31
|
+
return {
|
|
32
|
+
n, unmeasured, mean, stdev, min: Math.min(...v), max: Math.max(...v),
|
|
33
|
+
ci95: half == null ? null : { low: mean - half, high: mean + half },
|
|
34
|
+
representative,
|
|
35
|
+
...(representative ? {} : { note: `${n} run${n === 1 ? '' : 's'}: fewer than ${MIN_REPRESENTATIVE_RUNS}, not representative` }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const ratio = (num, den) => (den > 0 ? num / den : null);
|
|
40
|
+
export const f1 = (p, r) => (p == null || r == null ? null : p + r === 0 ? 0 : (2 * p * r) / (p + r));
|
|
41
|
+
export const pct = (x, digits = 1) => (x == null ? 'n/a' : `${(x * 100).toFixed(digits)}%`);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Persistence for the Eval Lab: `.agentic-security/eval-lab/runs/<runId>.json` and `.agentic-security/eval-lab/scoreboard.json`.
|
|
2
|
+
// Same machinery as the proof and detection stores (safe state writes, the encryption policy, retention as evidence, a secret
|
|
3
|
+
// scrub before anything touches disk). A run or scoreboard whose content no longer matches its own digest is returned flagged
|
|
4
|
+
// and never used for routing: a hand-edited scoreboard must not be able to steer which model gets the work.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { statePath, ensureStateDir, safeWriteState } from '../../state-dir.js';
|
|
8
|
+
import { maybeEncryptForWrite, maybeDecryptForRead } from '../../encryption-provider.js';
|
|
9
|
+
import { digestOf } from '../../assurance/identity.js';
|
|
10
|
+
import { scrubDeep } from '../../proof-fabric/store.js';
|
|
11
|
+
|
|
12
|
+
const DIR = 'eval-lab';
|
|
13
|
+
const MAX_BYTES = 64 * 1024 * 1024;
|
|
14
|
+
const safeId = (id) => /^[A-Za-z0-9._-]{1,80}$/.test(id);
|
|
15
|
+
|
|
16
|
+
function write(root, parts, doc) {
|
|
17
|
+
const safe = scrubDeep(doc);
|
|
18
|
+
safe.digest = digestOf({ ...safe, digest: undefined });
|
|
19
|
+
const text = JSON.stringify(safe);
|
|
20
|
+
if (Buffer.byteLength(text) > MAX_BYTES) return { ok: false, reason: 'record exceeds the size limit' };
|
|
21
|
+
if (!ensureStateDir(root)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
22
|
+
const enc = maybeEncryptForWrite(root, DIR, text);
|
|
23
|
+
if (!enc.ok) return { ok: false, reason: enc.reason };
|
|
24
|
+
const file = statePath(root, DIR, ...parts);
|
|
25
|
+
if (!safeWriteState(file, enc.content)) return { ok: false, reason: 'state write refused' };
|
|
26
|
+
return { ok: true, file, digest: safe.digest, encrypted: enc.encrypted };
|
|
27
|
+
}
|
|
28
|
+
function read(file) {
|
|
29
|
+
try {
|
|
30
|
+
const doc = JSON.parse(maybeDecryptForRead(fs.readFileSync(file, 'utf8')));
|
|
31
|
+
return { doc, tampered: doc.digest !== digestOf({ ...doc, digest: undefined }) };
|
|
32
|
+
} catch { return { doc: null, tampered: true }; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const saveRun = (root, run) => (safeId(run.runId) ? write(root, ['runs', `${run.runId}.json`], run) : { ok: false, reason: 'unsafe run id' });
|
|
36
|
+
export function loadRun(root, runId) {
|
|
37
|
+
if (!safeId(String(runId))) return { doc: null, tampered: false, missing: true };
|
|
38
|
+
const file = statePath(root, DIR, 'runs', `${runId}.json`);
|
|
39
|
+
if (!fs.existsSync(file)) return { doc: null, tampered: false, missing: true };
|
|
40
|
+
return read(file);
|
|
41
|
+
}
|
|
42
|
+
export function listRuns(root) {
|
|
43
|
+
const dir = statePath(root, DIR, 'runs');
|
|
44
|
+
let names = [];
|
|
45
|
+
try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')); } catch { return []; }
|
|
46
|
+
return names.map((n) => { const { doc, tampered } = read(path.join(dir, n)); return { runId: n.replace(/\.json$/, ''), tampered, startedAt: doc?.startedAt ?? null, dataset: doc?.dataset?.id ?? null, arms: doc?.arms?.map((a) => a.id) ?? [] }; }).sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
|
|
47
|
+
}
|
|
48
|
+
export const saveScoreboard = (root, sb) => write(root, ['scoreboard.json'], sb);
|
|
49
|
+
export function loadScoreboard(root) {
|
|
50
|
+
const file = statePath(root, DIR, 'scoreboard.json');
|
|
51
|
+
if (!fs.existsSync(file)) return { doc: null, tampered: false, missing: true };
|
|
52
|
+
return read(file);
|
|
53
|
+
}
|