@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1
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 +34 -0
- package/bin/agentic-security.js +87 -0
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +1 -1
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +30 -0
- package/src/dataflow/catalog.js +42 -1
- package/src/dataflow/engine.js +67 -2
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +5 -2
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +1 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Paired replay and the routing-promotion gate (X-605.AC02, X-605.AC03).
|
|
2
|
+
//
|
|
3
|
+
// The PRD section 5 routing row, in full:
|
|
4
|
+
// >= 200 paired adjudicated tasks overall and >= 30 per promoted stratum;
|
|
5
|
+
// lower 95% bound on the quality difference (proposed minus baseline) above -0.02;
|
|
6
|
+
// measured median cost >= 20% lower, OR quality >= +0.05 at no higher median cost;
|
|
7
|
+
// p95 latency <= 1.2x baseline; full failure reporting; retries, cache and tools accounted for.
|
|
8
|
+
//
|
|
9
|
+
// The comparison is over a FROZEN task set (`freezeTaskSet`): a hash of the task ids and strata fixed before any outcome is looked at.
|
|
10
|
+
// `replayPaired` pairs, for every frozen task, the baseline arm (the model production used) with the proposed arm (the model the
|
|
11
|
+
// shadow decision picked) from recorded outcomes. It issues no provider call unless the caller supplies BOTH an `invoke` function and a
|
|
12
|
+
// bounded authorization (a finite call count, a finite spend, a per-call ceiling for unknown costs); without both it is offline, and
|
|
13
|
+
// an `invoke` without a valid authorization is refused (`UNBOUNDED_REPLAY`). A replay that stops at its bound leaves tasks unpaired.
|
|
14
|
+
//
|
|
15
|
+
// What invalidates the gate (status `invalid`, whatever the figures say):
|
|
16
|
+
// cherry-picked the paired task set differs from the frozen set (an extra task, or a task not in the frozen set)
|
|
17
|
+
// dropped a frozen task has no pair: no shadow record, a missing or ambiguous outcome, or a replay that ran out of bound
|
|
18
|
+
// unbounded a replay that ran unauthorized, over its bounds, or was truncated
|
|
19
|
+
// edited the replay or frozen set no longer matches its own hash
|
|
20
|
+
// A FAILED arm is never dropped: a failure status, a timeout, a blocked proposal or a task the proposed policy left unserved counts as
|
|
21
|
+
// not-correct in the quality comparison and is reported. A pair where correctness is still unknown for either arm is counted
|
|
22
|
+
// (`unadjudicated`) and kept out of the quality statistic, never imputed.
|
|
23
|
+
//
|
|
24
|
+
// Synthetic data can never pass: the verdict is `unmeasured`. Nothing here claims an improvement.
|
|
25
|
+
|
|
26
|
+
import { groupedBootstrap } from '../evaluation/interval.js';
|
|
27
|
+
import { digestOf } from '../assurance/identity.js';
|
|
28
|
+
import { ROUTING_MINIMUMS, ROUTING_PROMOTION_TARGETS } from './calibration.js';
|
|
29
|
+
import { FAILURE_STATUSES, validateRoutingOutcome } from './outcomes.js';
|
|
30
|
+
|
|
31
|
+
export const FROZEN_SET_SCHEMA = 'agentic-security/routing-frozen-task-set';
|
|
32
|
+
export const REPLAY_SCHEMA = 'agentic-security/routing-paired-replay';
|
|
33
|
+
export const PROMOTION_SCHEMA = 'agentic-security/routing-promotion-verdict';
|
|
34
|
+
export const REPLAY_LIMITS = Object.freeze({ maxCalls: 1000, maxUsd: 1000 });
|
|
35
|
+
export const INVALIDATION_CODES = Object.freeze(['frozen-set-edited', 'replay-edited', 'cherry-picked', 'dropped-tasks', 'unbounded-replay', 'truncated-replay']);
|
|
36
|
+
|
|
37
|
+
const keyOf = (taskId, model) => `${taskId}\u0000${model}`;
|
|
38
|
+
|
|
39
|
+
/** Fix the evaluation tasks before any outcome is examined. `tasks` are routing task records (outcomes.js). */
|
|
40
|
+
export function freezeTaskSet(tasks) {
|
|
41
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const rows = [];
|
|
44
|
+
for (const t of list) {
|
|
45
|
+
if (!t || typeof t.taskId !== 'string' || typeof t.stratum !== 'string') return { ok: false, code: 'BAD_TASK', reason: 'every frozen task needs a taskId and a stratum' };
|
|
46
|
+
if (seen.has(t.taskId)) return { ok: false, code: 'DUPLICATE_TASK', reason: `task ${t.taskId} appears twice` };
|
|
47
|
+
seen.add(t.taskId);
|
|
48
|
+
rows.push({ taskId: t.taskId, stratum: t.stratum, synthetic: t.synthetic === true });
|
|
49
|
+
}
|
|
50
|
+
rows.sort((a, b) => (a.taskId < b.taskId ? -1 : 1));
|
|
51
|
+
const strata = {};
|
|
52
|
+
for (const r of rows) strata[r.stratum] = (strata[r.stratum] || 0) + 1;
|
|
53
|
+
const frozen = { schema: FROZEN_SET_SCHEMA, count: rows.length, tasks: rows, strata, synthetic: rows.some((r) => r.synthetic) };
|
|
54
|
+
frozen.hash = digestOf({ tasks: rows });
|
|
55
|
+
return { ok: true, frozen: Object.freeze(frozen) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const frozenIntact = (f) => !!f && Array.isArray(f.tasks) && digestOf({ tasks: f.tasks }) === f.hash;
|
|
59
|
+
|
|
60
|
+
function validAuthorization(a) {
|
|
61
|
+
const finite = (v, hi) => typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= hi;
|
|
62
|
+
return !!a && typeof a.authorizedBy === 'string' && a.authorizedBy.length > 0
|
|
63
|
+
&& Number.isInteger(a.maxCalls) && a.maxCalls >= 1 && a.maxCalls <= REPLAY_LIMITS.maxCalls
|
|
64
|
+
&& finite(a.maxUsd, REPLAY_LIMITS.maxUsd) && finite(a.perCallUsdCeiling, REPLAY_LIMITS.maxUsd);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function qualityOf(outcome, served) {
|
|
68
|
+
if (!served) return { quality: 0, kind: 'unserved' };
|
|
69
|
+
if (outcome.outcome === 'correct') return { quality: 1, kind: 'correct' };
|
|
70
|
+
if (outcome.outcome === 'incorrect') return { quality: 0, kind: 'incorrect' };
|
|
71
|
+
if (FAILURE_STATUSES.includes(outcome.status)) return { quality: 0, kind: 'failed' };
|
|
72
|
+
return { quality: null, kind: 'undecided' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function armOf(model, outcome, served) {
|
|
76
|
+
if (!served) return { model: null, modelVersion: null, served: false, outcomeId: null, status: 'unserved', quality: 0, kind: 'unserved', costUsd: 0, costStatus: 'measured', latencyMs: null, retries: 0, cacheState: 'unknown', synthetic: false, group: null };
|
|
77
|
+
const q = qualityOf(outcome, true);
|
|
78
|
+
return {
|
|
79
|
+
model, modelVersion: outcome.modelVersion ?? null, served: true, outcomeId: outcome.id, status: outcome.status, quality: q.quality, kind: q.kind,
|
|
80
|
+
costUsd: outcome.costUsd, costStatus: outcome.costStatus, latencyMs: outcome.latencyMs, retries: outcome.usage?.retries ?? 0, cacheState: outcome.cacheState,
|
|
81
|
+
synthetic: outcome.synthetic === true, group: outcome.group ?? null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pair every frozen task's baseline arm with its proposed arm.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} o
|
|
89
|
+
* @param {object} o.frozen freezeTaskSet().frozen
|
|
90
|
+
* @param {object[]} o.shadowRecords shadow.js records (the latest per task is used)
|
|
91
|
+
* @param {object[]} o.outcomes recorded routing outcomes (outcomes.js)
|
|
92
|
+
* @param {object} [o.authorization] { authorizedBy, maxCalls, maxUsd, perCallUsdCeiling }; only used with `invoke`
|
|
93
|
+
* @param {(req:{task:object, model:string})=>Promise<object>} [o.invoke] injected paid replay; absent means offline
|
|
94
|
+
* @param {object} [o.log] receipt log
|
|
95
|
+
*/
|
|
96
|
+
export async function replayPaired({ frozen, shadowRecords, outcomes, authorization = null, invoke = null, log = null }) {
|
|
97
|
+
if (!frozenIntact(frozen)) return { ok: false, code: 'FROZEN_SET_EDITED', reason: 'the frozen task set does not match its own hash' };
|
|
98
|
+
if (invoke && !validAuthorization(authorization)) {
|
|
99
|
+
return { ok: false, code: 'UNBOUNDED_REPLAY', reason: `a paid replay needs an authorization naming who approved it and finite maxCalls (<= ${REPLAY_LIMITS.maxCalls}), maxUsd (<= ${REPLAY_LIMITS.maxUsd}) and perCallUsdCeiling` };
|
|
100
|
+
}
|
|
101
|
+
const latest = new Map();
|
|
102
|
+
for (const r of Array.isArray(shadowRecords) ? shadowRecords : []) latest.set(r.taskId, r);
|
|
103
|
+
const pool = new Map();
|
|
104
|
+
for (const o of Array.isArray(outcomes) ? outcomes : []) {
|
|
105
|
+
if (!validateRoutingOutcome(o).ok) continue;
|
|
106
|
+
const k = keyOf(o.taskId, o.model);
|
|
107
|
+
if (!pool.has(k)) pool.set(k, []);
|
|
108
|
+
pool.get(k).push(o);
|
|
109
|
+
}
|
|
110
|
+
const replay = { authorized: !!invoke, authorizedBy: invoke ? authorization.authorizedBy : null, bounds: invoke ? { maxCalls: authorization.maxCalls, maxUsd: authorization.maxUsd, perCallUsdCeiling: authorization.perCallUsdCeiling } : null, calls: 0, spentUsd: 0, truncated: false, offline: !invoke };
|
|
111
|
+
const fetched = new Map(); // calls already made this replay, by (task, model)
|
|
112
|
+
|
|
113
|
+
async function obtain(task, model) {
|
|
114
|
+
const k = keyOf(task.taskId, model);
|
|
115
|
+
const have = pool.get(k) || [];
|
|
116
|
+
if (have.length > 1) return { problem: 'ambiguous-outcomes' };
|
|
117
|
+
if (have.length === 1) return { outcome: have[0] };
|
|
118
|
+
if (fetched.has(k)) return fetched.get(k);
|
|
119
|
+
if (!invoke) return { problem: 'missing-outcome' };
|
|
120
|
+
if (replay.calls >= authorization.maxCalls || replay.spentUsd + authorization.perCallUsdCeiling > authorization.maxUsd) {
|
|
121
|
+
replay.truncated = true;
|
|
122
|
+
return { problem: 'replay-bound-reached' };
|
|
123
|
+
}
|
|
124
|
+
replay.calls += 1;
|
|
125
|
+
let out;
|
|
126
|
+
try { out = await invoke({ task, model }); } catch { out = null; }
|
|
127
|
+
let res;
|
|
128
|
+
if (!out || !validateRoutingOutcome(out).ok || out.taskId !== task.taskId || out.model !== model) {
|
|
129
|
+
replay.spentUsd += authorization.perCallUsdCeiling; // a failed or malformed replay is charged at the ceiling, never at zero
|
|
130
|
+
res = { problem: 'invalid-replay-outcome' };
|
|
131
|
+
} else {
|
|
132
|
+
replay.spentUsd += typeof out.costUsd === 'number' ? out.costUsd : authorization.perCallUsdCeiling;
|
|
133
|
+
res = { outcome: out };
|
|
134
|
+
}
|
|
135
|
+
fetched.set(k, res);
|
|
136
|
+
return res;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pairs = [];
|
|
140
|
+
for (const t of frozen.tasks) {
|
|
141
|
+
const rec = latest.get(t.taskId);
|
|
142
|
+
if (!rec) { pairs.push({ taskId: t.taskId, stratum: t.stratum, status: 'dropped', dropReason: 'no-shadow-record' }); continue; }
|
|
143
|
+
const taskLike = { taskId: t.taskId, stratum: t.stratum };
|
|
144
|
+
const base = await obtain(taskLike, rec.production.model);
|
|
145
|
+
const servedProposed = rec.proposed.model !== null && rec.proposed.model !== undefined;
|
|
146
|
+
const prop = servedProposed ? await obtain(taskLike, rec.proposed.model) : { unserved: true };
|
|
147
|
+
if (base.problem || prop.problem) {
|
|
148
|
+
pairs.push({ taskId: t.taskId, stratum: t.stratum, status: 'dropped', dropReason: base.problem || prop.problem, side: base.problem ? 'baseline' : 'proposed' });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const a = armOf(rec.production.model, base.outcome, true);
|
|
152
|
+
const b = servedProposed ? armOf(rec.proposed.model, prop.outcome, true) : armOf(null, null, false);
|
|
153
|
+
pairs.push({
|
|
154
|
+
taskId: t.taskId, stratum: t.stratum, status: 'paired', group: a.group || b.group || t.taskId,
|
|
155
|
+
proposedStatus: rec.proposed.status, proposedCode: rec.proposed.code, baseline: a, proposed: b,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const out = { schema: REPLAY_SCHEMA, ok: true, frozenHash: frozen.hash, pairs, replay, paidCalls: replay.calls };
|
|
159
|
+
out.replayHash = digestOf({ ...out, replayHash: undefined });
|
|
160
|
+
if (log) log.append('replay', { frozenHash: frozen.hash, replayHash: out.replayHash, paired: pairs.filter((p) => p.status === 'paired').length, dropped: pairs.filter((p) => p.status === 'dropped').length, replay });
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------- statistics
|
|
165
|
+
|
|
166
|
+
const sortedNums = (xs) => xs.filter((x) => typeof x === 'number' && Number.isFinite(x)).sort((x, y) => x - y);
|
|
167
|
+
function median(xs) {
|
|
168
|
+
const s = sortedNums(xs);
|
|
169
|
+
if (!s.length) return null;
|
|
170
|
+
const m = s.length >> 1;
|
|
171
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
172
|
+
}
|
|
173
|
+
function nearestRank(xs, q) {
|
|
174
|
+
const s = sortedNums(xs);
|
|
175
|
+
return s.length ? s[Math.min(s.length - 1, Math.max(0, Math.ceil(q * s.length) - 1))] : null;
|
|
176
|
+
}
|
|
177
|
+
const round = (x) => (x === null || x === undefined ? null : Math.round(x * 1e9) / 1e9);
|
|
178
|
+
const tally = (arms, key, vocab) => { const o = Object.fromEntries(vocab.map((v) => [v, 0])); for (const a of arms) o[a[key]] = (o[a[key]] || 0) + 1; return o; };
|
|
179
|
+
|
|
180
|
+
/** One slice of pairs (overall or one stratum) against the section 5 criteria. */
|
|
181
|
+
function analyse(pairs, { replicates, protocolHash, name }) {
|
|
182
|
+
const paired = pairs.filter((p) => p.status === 'paired');
|
|
183
|
+
const adjudicated = paired.filter((p) => p.baseline.quality !== null && p.proposed.quality !== null);
|
|
184
|
+
const n = adjudicated.length;
|
|
185
|
+
const mean = (arm) => (n ? adjudicated.reduce((s, p) => s + p[arm].quality, 0) / n : null);
|
|
186
|
+
const units = adjudicated.map((p) => {
|
|
187
|
+
const pq = p.proposed.quality; const bq = p.baseline.quality;
|
|
188
|
+
return { id: p.taskId, language: 'all', tp: pq === 1 && bq === 0 ? 1 : 0, fn: pq === 0 && bq === 1 ? 1 : 0, fp: 0, tn: pq === bq ? 1 : 0 };
|
|
189
|
+
});
|
|
190
|
+
const groupOf = Object.fromEntries(adjudicated.map((p) => [p.taskId, p.group]));
|
|
191
|
+
const diffStat = (a) => { const t = a.total; const d = t.tp + t.fn + t.fp + t.tn; return d ? (t.tp - t.fn) / d : null; };
|
|
192
|
+
const interval = n === 0 ? { status: 'unmeasured', low: null, high: null, point: null, reason: 'no paired adjudicated task' }
|
|
193
|
+
: groupedBootstrap({ units, groupOf, statistic: diffStat, name, protocolHash, replicates });
|
|
194
|
+
const diffPoint = n ? units.reduce((s, u) => s + u.tp - u.fn, 0) / n : null;
|
|
195
|
+
|
|
196
|
+
const arms = (k) => adjudicated.map((p) => p[k]);
|
|
197
|
+
const costKnown = adjudicated.every((p) => p.baseline.costStatus === 'measured' && typeof p.baseline.costUsd === 'number' && p.proposed.costStatus === 'measured' && typeof p.proposed.costUsd === 'number');
|
|
198
|
+
const medBase = costKnown ? median(arms('baseline').map((a) => a.costUsd)) : null;
|
|
199
|
+
const medProp = costKnown ? median(arms('proposed').map((a) => a.costUsd)) : null;
|
|
200
|
+
const reduction = costKnown && medBase !== null && medBase > 0 ? 1 - medProp / medBase : null;
|
|
201
|
+
const served = (k) => arms(k).filter((a) => a.served);
|
|
202
|
+
const latencyKnown = n > 0 && ['baseline', 'proposed'].every((k) => served(k).every((a) => typeof a.latencyMs === 'number'));
|
|
203
|
+
const p95Base = latencyKnown ? nearestRank(served('baseline').map((a) => a.latencyMs), 0.95) : null;
|
|
204
|
+
const p95Prop = latencyKnown ? nearestRank(served('proposed').map((a) => a.latencyMs), 0.95) : null;
|
|
205
|
+
const p95Ratio = latencyKnown && p95Base > 0 ? p95Prop / p95Base : null;
|
|
206
|
+
|
|
207
|
+
const T = ROUTING_PROMOTION_TARGETS;
|
|
208
|
+
const qualityLowerMet = interval.status === 'measured' ? interval.low > T.qualityDifferenceLowerBound : null;
|
|
209
|
+
const costPath = reduction === null ? null : reduction >= T.medianCostReduction;
|
|
210
|
+
// The quality path is stricter than the PRD wording: the lower bound must also clear zero, so a gain cannot rest on a point estimate.
|
|
211
|
+
const qualityPath = interval.status === 'measured' && costKnown && medBase !== null ? (diffPoint >= T.qualityGain && interval.low > 0 && medProp <= medBase) : null;
|
|
212
|
+
const advantageMet = costPath === true || qualityPath === true ? true : (costPath === null && qualityPath === null ? null : false);
|
|
213
|
+
const latencyMet = p95Ratio === null ? null : p95Ratio <= T.p95LatencyRatio;
|
|
214
|
+
const all = qualityLowerMet === true && advantageMet === true && latencyMet === true;
|
|
215
|
+
const failuresOf = (k) => tally(paired.map((p) => p[k]), 'kind', ['correct', 'incorrect', 'failed', 'unserved', 'undecided']);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
pairedTasks: paired.length, adjudicated: n, unadjudicated: paired.length - n,
|
|
219
|
+
quality: { baseline: round(mean('baseline')), proposed: round(mean('proposed')), difference: round(diffPoint), interval: { method: interval.method ?? 'grouped-bootstrap-95', status: interval.status, low: round(interval.low), high: round(interval.high), groups: interval.groups ?? 0, ...(interval.reason ? { reason: interval.reason } : {}) } },
|
|
220
|
+
cost: { complete: costKnown, medianBaselineUsd: round(medBase), medianProposedUsd: round(medProp), medianReduction: round(reduction), totalBaselineUsd: costKnown ? round(arms('baseline').reduce((s, a) => s + a.costUsd, 0)) : null, totalProposedUsd: costKnown ? round(arms('proposed').reduce((s, a) => s + a.costUsd, 0)) : null, retriesBaseline: arms('baseline').reduce((s, a) => s + a.retries, 0), retriesProposed: arms('proposed').reduce((s, a) => s + a.retries, 0), basis: 'outcome cost covers input, output, cached input, retries and tool execution as measured by the adapter; an unknown cost makes the cost criterion unmet, never zero' },
|
|
221
|
+
cache: { baseline: tally(arms('baseline'), 'cacheState', ['hit', 'partial', 'miss', 'ineligible', 'unknown']), proposed: tally(arms('proposed').filter((a) => a.served), 'cacheState', ['hit', 'partial', 'miss', 'ineligible', 'unknown']) },
|
|
222
|
+
latencyMs: { complete: latencyKnown, baselineP50: latencyKnown ? nearestRank(served('baseline').map((a) => a.latencyMs), 0.5) : null, baselineP95: p95Base, proposedP50: latencyKnown ? nearestRank(served('proposed').map((a) => a.latencyMs), 0.5) : null, proposedP95: p95Prop, p95Ratio: round(p95Ratio) },
|
|
223
|
+
failures: { baseline: failuresOf('baseline'), proposed: failuresOf('proposed') },
|
|
224
|
+
criteria: { qualityLowerBound: { met: qualityLowerMet, need: `> ${T.qualityDifferenceLowerBound}`, value: round(interval.low) }, advantage: { met: advantageMet, costPath, qualityPath, needMedianCostReduction: T.medianCostReduction, needQualityGain: T.qualityGain }, p95Latency: { met: latencyMet, need: `<= ${T.p95LatencyRatio}x baseline`, value: round(p95Ratio) }, all },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Judge a paired replay against the section 5 routing-promotion row.
|
|
230
|
+
* `status`: `invalid` | `unmeasured` | `insufficient-population` | `fail` | `pass`, in that precedence. Never throws.
|
|
231
|
+
*/
|
|
232
|
+
export function evaluatePromotion({ frozen, replay, replicates = 1000, log = null } = {}) {
|
|
233
|
+
const invalidations = [];
|
|
234
|
+
const inval = (code, detail) => invalidations.push({ code, detail });
|
|
235
|
+
if (!frozenIntact(frozen)) inval('frozen-set-edited', 'the frozen task set does not match its own hash');
|
|
236
|
+
if (!replay || replay.schema !== REPLAY_SCHEMA || digestOf({ ...replay, replayHash: undefined }) !== replay.replayHash) inval('replay-edited', 'the replay does not match its own hash');
|
|
237
|
+
const pairs = Array.isArray(replay?.pairs) ? replay.pairs : [];
|
|
238
|
+
if (frozenIntact(frozen) && replay) {
|
|
239
|
+
if (replay.frozenHash !== frozen.hash) inval('cherry-picked', 'the replay was built against a different task set than the frozen one');
|
|
240
|
+
const frozenIds = new Set(frozen.tasks.map((t) => t.taskId));
|
|
241
|
+
const seen = new Map();
|
|
242
|
+
for (const p of pairs) seen.set(p.taskId, (seen.get(p.taskId) || 0) + 1);
|
|
243
|
+
const extras = [...seen.keys()].filter((id) => !frozenIds.has(id));
|
|
244
|
+
const repeated = [...seen.entries()].filter(([, c]) => c > 1).map(([id]) => id);
|
|
245
|
+
const absent = [...frozenIds].filter((id) => !seen.has(id));
|
|
246
|
+
if (extras.length || repeated.length) inval('cherry-picked', `${extras.length} task(s) outside the frozen set, ${repeated.length} repeated`);
|
|
247
|
+
if (absent.length) inval('dropped-tasks', `${absent.length} frozen task(s) are missing from the replay`);
|
|
248
|
+
}
|
|
249
|
+
const dropped = pairs.filter((p) => p.status === 'dropped');
|
|
250
|
+
if (dropped.length) {
|
|
251
|
+
const byReason = {};
|
|
252
|
+
for (const d of dropped) byReason[d.dropReason] = (byReason[d.dropReason] || 0) + 1;
|
|
253
|
+
inval('dropped-tasks', `${dropped.length} frozen task(s) could not be paired: ${Object.entries(byReason).map(([k, v]) => `${k} x${v}`).join(', ')}`);
|
|
254
|
+
}
|
|
255
|
+
const rp = replay?.replay;
|
|
256
|
+
if (rp) {
|
|
257
|
+
if (rp.truncated) inval('truncated-replay', 'the replay stopped at its bound, so tasks were left unpaired');
|
|
258
|
+
if (rp.calls > 0 && (!rp.authorized || !rp.bounds || rp.calls > rp.bounds.maxCalls || rp.spentUsd > rp.bounds.maxUsd)) inval('unbounded-replay', 'paid calls were made without a valid authorization or beyond its bounds');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const protocolHash = frozen?.hash ?? 'no-frozen-set';
|
|
262
|
+
const overall = analyse(pairs, { replicates, protocolHash, name: 'routing-promotion|overall' });
|
|
263
|
+
const strata = {};
|
|
264
|
+
for (const s of Object.keys(frozen?.strata ?? {}).sort()) {
|
|
265
|
+
const sub = analyse(pairs.filter((p) => p.stratum === s), { replicates, protocolHash, name: `routing-promotion|${s}` });
|
|
266
|
+
const enough = sub.adjudicated >= ROUTING_MINIMUMS.pairedTasksPerStratum;
|
|
267
|
+
strata[s] = { ...sub, frozenTasks: frozen.strata[s], promoted: enough && sub.criteria.all, reason: !enough ? `${sub.adjudicated} paired adjudicated task(s); ${ROUTING_MINIMUMS.pairedTasksPerStratum} are required` : sub.criteria.all ? 'criteria met' : 'criteria not met' };
|
|
268
|
+
}
|
|
269
|
+
const promotedStrata = Object.entries(strata).filter(([, v]) => v.promoted).map(([k]) => k);
|
|
270
|
+
const synthetic = (frozen?.synthetic === true) || pairs.some((p) => p.status === 'paired' && (p.baseline.synthetic || p.proposed.synthetic));
|
|
271
|
+
|
|
272
|
+
let status; let reason;
|
|
273
|
+
if (invalidations.length) { status = 'invalid'; reason = `the gate is invalidated: ${invalidations.map((i) => i.code).join(', ')}`; }
|
|
274
|
+
else if (synthetic) { status = 'unmeasured'; reason = 'the population is synthetic: it exercises the machinery and measures nothing about real routing'; }
|
|
275
|
+
else if (overall.adjudicated < ROUTING_MINIMUMS.pairedTasksOverall) { status = 'insufficient-population'; reason = `${overall.adjudicated} paired adjudicated task(s); ${ROUTING_MINIMUMS.pairedTasksOverall} are required`; }
|
|
276
|
+
else if (!Object.values(strata).some((v) => v.adjudicated >= ROUTING_MINIMUMS.pairedTasksPerStratum)) { status = 'insufficient-population'; reason = `no stratum has ${ROUTING_MINIMUMS.pairedTasksPerStratum} paired adjudicated tasks`; }
|
|
277
|
+
else if (!overall.criteria.all) { status = 'fail'; reason = 'the overall quality, cost or latency criteria are not met'; }
|
|
278
|
+
else if (!promotedStrata.length) { status = 'fail'; reason = 'no stratum meets the criteria on its own sample'; }
|
|
279
|
+
else { status = 'pass'; reason = `criteria met overall and in ${promotedStrata.length} stratum/strata`; }
|
|
280
|
+
|
|
281
|
+
const versions = [...new Set(pairs.filter((p) => p.status === 'paired').flatMap((p) => [p.baseline, p.proposed]).filter((a) => a.served).map((a) => `${a.model}@${a.modelVersion ?? 'unversioned'}`))].sort();
|
|
282
|
+
const verdict = {
|
|
283
|
+
schema: PROMOTION_SCHEMA, status, reason, invalidations, synthetic, frozenHash: frozen?.hash ?? null, replayHash: replay?.replayHash ?? null,
|
|
284
|
+
thresholds: { ...ROUTING_MINIMUMS, ...ROUTING_PROMOTION_TARGETS }, replicates,
|
|
285
|
+
overall, strata, promotedStrata, testedModelVersions: versions,
|
|
286
|
+
claim: status === 'pass' ? { allowed: true, scope: promotedStrata, modelVersions: versions, note: 'applies to the listed strata and model versions only' } : { allowed: false, note: 'no routing advantage is claimed' },
|
|
287
|
+
};
|
|
288
|
+
verdict.verdictHash = digestOf({ ...verdict, verdictHash: undefined });
|
|
289
|
+
if (log) log.append('promotion', { status, reason, invalidations, verdictHash: verdict.verdictHash, frozenHash: verdict.frozenHash, replayHash: verdict.replayHash });
|
|
290
|
+
return verdict;
|
|
291
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Routing decision receipts (X-605, X-606, X-608): an append-only, hash-chained log of every shadow decision, replay, drift
|
|
2
|
+
// assessment, canary event, rollback, promotion verdict and operator control change.
|
|
3
|
+
//
|
|
4
|
+
// Why a chain. A rollback must not lose the evidence of the decisions that led to it, and an exported log must be checkable by
|
|
5
|
+
// someone with only the file. Each receipt carries the hash of the previous one, so a missing, reordered or edited receipt breaks
|
|
6
|
+
// `verifyReceiptChain`. This is tamper EVIDENCE for whoever holds the export, not a signature and not independent certification.
|
|
7
|
+
//
|
|
8
|
+
// A receipt body is JSON-cloned on the way in (no live references, no functions), and callers pass sanitized decision metadata only:
|
|
9
|
+
// never a prompt, a response or source text. The clock is a parameter, so a reproduction can fix it.
|
|
10
|
+
|
|
11
|
+
import { digestOf } from '../assurance/identity.js';
|
|
12
|
+
|
|
13
|
+
export const ROUTING_RECEIPT_SCHEMA = 'agentic-security/routing-receipt-chain';
|
|
14
|
+
export const ROUTING_RECEIPT_KINDS = Object.freeze(['shadow-decision', 'replay', 'promotion', 'drift', 'canary', 'rollback', 'control', 'feedback']);
|
|
15
|
+
const MAX_RECEIPTS = 100_000;
|
|
16
|
+
const GENESIS = 'sha256:genesis';
|
|
17
|
+
|
|
18
|
+
export function createReceiptLog({ now = () => new Date().toISOString() } = {}) {
|
|
19
|
+
const receipts = [];
|
|
20
|
+
return {
|
|
21
|
+
append(kind, body) {
|
|
22
|
+
if (!ROUTING_RECEIPT_KINDS.includes(kind)) throw new Error(`unknown routing receipt kind '${kind}'`);
|
|
23
|
+
if (receipts.length >= MAX_RECEIPTS) throw new Error('routing receipt log is full');
|
|
24
|
+
const r = { seq: receipts.length, kind, at: now(), body: JSON.parse(JSON.stringify(body ?? null)), prev: receipts.length ? receipts[receipts.length - 1].hash : GENESIS };
|
|
25
|
+
r.hash = digestOf({ ...r, hash: undefined });
|
|
26
|
+
receipts.push(Object.freeze(r));
|
|
27
|
+
return r;
|
|
28
|
+
},
|
|
29
|
+
entries() { return receipts.slice(); },
|
|
30
|
+
head() { return receipts.length ? receipts[receipts.length - 1].hash : GENESIS; },
|
|
31
|
+
get length() { return receipts.length; },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Recompute every hash and link. Fails closed on any gap, reorder, edit or unknown kind. */
|
|
36
|
+
export function verifyReceiptChain(receipts) {
|
|
37
|
+
if (!Array.isArray(receipts)) return { ok: false, reason: 'not a list of receipts', brokenAt: null };
|
|
38
|
+
let prev = GENESIS;
|
|
39
|
+
for (let i = 0; i < receipts.length; i++) {
|
|
40
|
+
const r = receipts[i];
|
|
41
|
+
if (!r || typeof r !== 'object') return { ok: false, reason: `receipt ${i} is not an object`, brokenAt: i };
|
|
42
|
+
if (r.seq !== i) return { ok: false, reason: `receipt ${i} has sequence ${r.seq} (a receipt is missing or reordered)`, brokenAt: i };
|
|
43
|
+
if (!ROUTING_RECEIPT_KINDS.includes(r.kind)) return { ok: false, reason: `receipt ${i} has an unknown kind`, brokenAt: i };
|
|
44
|
+
if (r.prev !== prev) return { ok: false, reason: `receipt ${i} does not link to the one before it`, brokenAt: i };
|
|
45
|
+
if (digestOf({ ...r, hash: undefined }) !== r.hash) return { ok: false, reason: `receipt ${i} was modified after it was written`, brokenAt: i };
|
|
46
|
+
prev = r.hash;
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, reason: 'chain verified', brokenAt: null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The portable export: the chain plus its head, checkable offline with `verifyReceiptChain`. */
|
|
52
|
+
export function exportReceipts(log) {
|
|
53
|
+
const receipts = log.entries();
|
|
54
|
+
const byKind = Object.fromEntries(ROUTING_RECEIPT_KINDS.map((k) => [k, receipts.filter((r) => r.kind === k).length]));
|
|
55
|
+
return JSON.parse(JSON.stringify({
|
|
56
|
+
schema: ROUTING_RECEIPT_SCHEMA, schemaVersion: '1.0.0', count: receipts.length, byKind, headHash: log.head(), receipts,
|
|
57
|
+
disclosure: 'a hash-linked log: tamper evidence for the holder of the export, not a signature and not independent certification',
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Routing replay report and policy card (X-608.AC01, X-608.AC02).
|
|
2
|
+
//
|
|
3
|
+
// The report restates a promotion verdict (promotion.js) with exact denominators, so nobody has to rerun the arithmetic to see how
|
|
4
|
+
// many tasks stand behind a figure. It never adds a claim the verdict did not earn:
|
|
5
|
+
//
|
|
6
|
+
// - a quality difference always appears with its interval, or with the reason it could not be measured;
|
|
7
|
+
// - cost is the MEASURED median and total (retries and tool execution included), or `incomplete`; never a prediction;
|
|
8
|
+
// - cache conditions and latency percentiles for both arms sit beside the cost figure they qualify;
|
|
9
|
+
// - claims are restricted to the strata and model versions the gate actually passed. Unsupported strata and every unmet
|
|
10
|
+
// criterion stay listed, whatever the overall status.
|
|
11
|
+
//
|
|
12
|
+
// A synthetic population reads `unmeasured` and the report says no advantage is claimed. The output is deterministic: no clock (the
|
|
13
|
+
// caller passes `generatedAt` or nothing) and a content hash, so a rerun from the same manifests is checkable byte for byte.
|
|
14
|
+
|
|
15
|
+
import { digestOf } from '../assurance/identity.js';
|
|
16
|
+
import { DRIFT_KINDS } from './drift.js';
|
|
17
|
+
import { ROUTING_MODES } from './control.js';
|
|
18
|
+
|
|
19
|
+
export const REPORT_SCHEMA = 'agentic-security/routing-replay-report';
|
|
20
|
+
export const POLICY_CARD_SCHEMA = 'agentic-security/routing-policy-card';
|
|
21
|
+
|
|
22
|
+
const NOT_CLAIMED = Object.freeze([
|
|
23
|
+
'no routing cost or quality advantage is claimed outside the strata and model versions listed under claims.scope',
|
|
24
|
+
'no claim is made about task kinds, languages, vulnerability classes or context sizes that were not tested',
|
|
25
|
+
'no claim is made for a provider model version other than those tested, or for different prices or cache conditions',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function unmetCriteria(verdict) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const c = verdict.overall?.criteria;
|
|
31
|
+
if (verdict.status !== 'pass') out.push({ gate: 'routing-promotion', status: verdict.status, reason: verdict.reason });
|
|
32
|
+
if (c) {
|
|
33
|
+
if (c.qualityLowerBound.met !== true) out.push({ gate: 'quality-lower-bound', met: c.qualityLowerBound.met, need: c.qualityLowerBound.need, value: c.qualityLowerBound.value });
|
|
34
|
+
if (c.advantage.met !== true) out.push({ gate: 'cost-or-quality-advantage', met: c.advantage.met, needMedianCostReduction: c.advantage.needMedianCostReduction, needQualityGain: c.advantage.needQualityGain });
|
|
35
|
+
if (c.p95Latency.met !== true) out.push({ gate: 'p95-latency', met: c.p95Latency.met, need: c.p95Latency.need, value: c.p95Latency.value });
|
|
36
|
+
}
|
|
37
|
+
for (const i of verdict.invalidations || []) out.push({ gate: 'invalidation', code: i.code, detail: i.detail });
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} o
|
|
43
|
+
* @param {object} o.frozen freezeTaskSet().frozen
|
|
44
|
+
* @param {object} o.replay replayPaired() result
|
|
45
|
+
* @param {object} o.verdict evaluatePromotion() result
|
|
46
|
+
* @param {object} [o.receipts] exportReceipts() result, for its head hash
|
|
47
|
+
* @param {string|null} [o.generatedAt]
|
|
48
|
+
*/
|
|
49
|
+
export function buildRoutingReplayReport({ frozen, replay, verdict, receipts = null, generatedAt = null }) {
|
|
50
|
+
const pairs = replay?.pairs ?? [];
|
|
51
|
+
const byStratum = {};
|
|
52
|
+
const passed = verdict.status === 'pass';
|
|
53
|
+
for (const [s, v] of Object.entries(verdict.strata || {})) {
|
|
54
|
+
// a stratum is SUPPORTED only when the whole gate passed; meeting the criteria on its own sample is not enough while the gate reads
|
|
55
|
+
// unmeasured, invalid, insufficient-population or fail
|
|
56
|
+
byStratum[s] = {
|
|
57
|
+
frozenTasks: v.frozenTasks, pairedTasks: v.pairedTasks, adjudicated: v.adjudicated, unadjudicated: v.unadjudicated, supported: passed && v.promoted,
|
|
58
|
+
criteriaMetOnSample: v.promoted, reason: passed ? v.reason : (v.promoted ? `criteria met on this sample, but the gate reads '${verdict.status}'` : v.reason),
|
|
59
|
+
quality: v.quality, cost: v.cost, latencyMs: v.latencyMs, criteria: v.criteria,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const supported = Object.entries(byStratum).filter(([, v]) => v.supported).map(([k]) => k);
|
|
63
|
+
const unsupported = Object.entries(byStratum).filter(([, v]) => !v.supported).map(([k, v]) => ({ stratum: k, reason: v.reason, adjudicated: v.adjudicated }));
|
|
64
|
+
const o = verdict.overall;
|
|
65
|
+
const claims = verdict.claim.allowed
|
|
66
|
+
? {
|
|
67
|
+
allowed: true, scope: verdict.claim.scope, modelVersions: verdict.claim.modelVersions,
|
|
68
|
+
statements: verdict.claim.scope.map((s) => `Stratum ${s}: the proposed policy met the routing-promotion criteria on ${byStratum[s].adjudicated} paired adjudicated task(s), for model versions ${verdict.claim.modelVersions.join(', ')}, under the cache conditions shown.`),
|
|
69
|
+
}
|
|
70
|
+
: { allowed: false, scope: [], modelVersions: [], statements: [] };
|
|
71
|
+
const report = {
|
|
72
|
+
schema: REPORT_SCHEMA, schemaVersion: '1.0.0', generatedAt, synthetic: verdict.synthetic,
|
|
73
|
+
status: verdict.status, reason: verdict.reason,
|
|
74
|
+
denominators: {
|
|
75
|
+
frozenTasks: frozen.count, pairedTasks: o.pairedTasks, droppedTasks: pairs.filter((p) => p.status === 'dropped').length,
|
|
76
|
+
adjudicated: o.adjudicated, unadjudicated: o.unadjudicated,
|
|
77
|
+
replay: { offline: replay.replay.offline, paidCalls: replay.paidCalls, spentUsd: replay.replay.spentUsd, truncated: replay.replay.truncated, bounds: replay.replay.bounds },
|
|
78
|
+
},
|
|
79
|
+
comparison: { quality: o.quality, cost: o.cost, cache: o.cache, latencyMs: o.latencyMs, failures: o.failures },
|
|
80
|
+
strata: byStratum, supportedStrata: supported, unsupportedStrata: unsupported,
|
|
81
|
+
gate: { thresholds: verdict.thresholds, criteria: o.criteria, invalidations: verdict.invalidations },
|
|
82
|
+
unmetGates: unmetCriteria(verdict),
|
|
83
|
+
claims, notClaimed: NOT_CLAIMED.slice(),
|
|
84
|
+
reproduce: { frozenHash: frozen.hash, replayHash: verdict.replayHash, verdictHash: verdict.verdictHash, receiptsHead: receipts ? receipts.headHash : null, command: 'npm run reproduce:routing' },
|
|
85
|
+
disclosure: verdict.synthetic ? 'SYNTHETIC population: this exercises the machinery and measures nothing about real routing' : 'the figures describe the tested strata and model versions only',
|
|
86
|
+
};
|
|
87
|
+
report.reportHash = digestOf({ ...report, reportHash: undefined });
|
|
88
|
+
return report;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const pct = (x) => (x === null || x === undefined ? 'n/a' : `${(x * 100).toFixed(1)}%`);
|
|
92
|
+
const num = (x) => (x === null || x === undefined ? 'n/a' : String(x));
|
|
93
|
+
|
|
94
|
+
/** Plain-text rendering of the report. Same content as the object; nothing is added or softened. */
|
|
95
|
+
export function renderRoutingReport(r) {
|
|
96
|
+
const q = r.comparison.quality; const c = r.comparison.cost; const l = r.comparison.latencyMs;
|
|
97
|
+
const lines = [];
|
|
98
|
+
lines.push(`Routing replay report (${r.synthetic ? 'SYNTHETIC' : 'measured'}): ${r.status}`);
|
|
99
|
+
lines.push(r.reason);
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push(`Denominators: ${r.denominators.frozenTasks} frozen task(s), ${r.denominators.pairedTasks} paired, ${r.denominators.droppedTasks} dropped, ${r.denominators.adjudicated} paired adjudicated, ${r.denominators.unadjudicated} unadjudicated.`);
|
|
102
|
+
lines.push(`Replay: ${r.denominators.replay.offline ? 'offline (recorded outcomes)' : 'authorized and bounded'}, ${r.denominators.replay.paidCalls} paid call(s), $${r.denominators.replay.spentUsd}${r.denominators.replay.truncated ? ', TRUNCATED' : ''}.`);
|
|
103
|
+
lines.push(`Quality: baseline ${pct(q.baseline)}, proposed ${pct(q.proposed)}, difference ${num(q.difference)}, 95% interval ${q.interval.status === 'measured' ? `[${num(q.interval.low)}, ${num(q.interval.high)}]` : `unmeasured (${q.interval.reason || q.interval.status})`}.`);
|
|
104
|
+
lines.push(`Measured cost: median baseline $${num(c.medianBaselineUsd)}, proposed $${num(c.medianProposedUsd)}, reduction ${pct(c.medianReduction)}${c.complete ? '' : ' (INCOMPLETE: a cost was unknown)'}; retries ${c.retriesBaseline} vs ${c.retriesProposed}.`);
|
|
105
|
+
lines.push(`Cache (baseline): ${JSON.stringify(r.comparison.cache.baseline)}; (proposed): ${JSON.stringify(r.comparison.cache.proposed)}.`);
|
|
106
|
+
lines.push(`Latency ms: p50 ${num(l.baselineP50)} vs ${num(l.proposedP50)}, p95 ${num(l.baselineP95)} vs ${num(l.proposedP95)}, ratio ${num(l.p95Ratio)}${l.complete ? '' : ' (INCOMPLETE)'}.`);
|
|
107
|
+
lines.push(`Failures (baseline): ${JSON.stringify(r.comparison.failures.baseline)}; (proposed): ${JSON.stringify(r.comparison.failures.proposed)}.`);
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(`Supported strata: ${r.supportedStrata.length ? r.supportedStrata.join(', ') : 'none'}.`);
|
|
110
|
+
for (const u of r.unsupportedStrata) lines.push(` unsupported: ${u.stratum} (${u.reason})`);
|
|
111
|
+
lines.push('Unmet gates:');
|
|
112
|
+
if (!r.unmetGates.length) lines.push(' none'); else for (const g of r.unmetGates) lines.push(` ${g.gate}: ${JSON.stringify(g)}`);
|
|
113
|
+
lines.push('Claims:');
|
|
114
|
+
if (!r.claims.allowed) lines.push(' none. No routing advantage is claimed.'); else for (const s of r.claims.statements) lines.push(` ${s}`);
|
|
115
|
+
for (const n of r.notClaimed) lines.push(` not claimed: ${n}`);
|
|
116
|
+
lines.push(`Reproduce: ${r.reproduce.command} (frozen ${r.reproduce.frozenHash}, verdict ${r.reproduce.verdictHash}).`);
|
|
117
|
+
return lines.join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The policy card: what the policy is, what it was tested on, what is not claimed, and how an operator controls it. */
|
|
121
|
+
export function buildPolicyCard({ policy, report, control, canaryLimits = null, driftPolicy = null }) {
|
|
122
|
+
const card = {
|
|
123
|
+
schema: POLICY_CARD_SCHEMA, schemaVersion: '1.0.0', synthetic: report.synthetic,
|
|
124
|
+
policy: { version: policy.version, objective: policy.objective, minQualityLower: policy.minQualityLower, maxIntervalWidth: policy.maxIntervalWidth, maxEvidenceAgeDays: policy.maxEvidenceAgeDays, fallbackMode: policy.fallbackMode ?? 'baseline-unverified', budget: policy.budget },
|
|
125
|
+
constraintOrder: ['availability', 'capability', 'privacy', 'context', 'quality', 'uncertainty', 'freshness', 'budget'],
|
|
126
|
+
evidence: { gateStatus: report.status, reason: report.reason, testedStrata: report.supportedStrata, untestedOrUnsupportedStrata: report.unsupportedStrata, testedModelVersions: report.claims.modelVersions },
|
|
127
|
+
claims: report.claims, notClaimed: report.notClaimed, unmetGates: report.unmetGates,
|
|
128
|
+
controls: {
|
|
129
|
+
modes: ROUTING_MODES.slice(), current: control ? { mode: control.mode, pin: control.pin, source: control.source } : null,
|
|
130
|
+
howTo: ['AGENTIC_SECURITY_ROUTING=disabled', 'AGENTIC_SECURITY_ROUTING=pin:<model>', 'AGENTIC_SECURITY_ROUTING=adaptive', 'AGENTIC_SECURITY_NO_MODEL_ROUTING=1 (kill switch)'],
|
|
131
|
+
receipts: 'every shadow decision, replay, drift assessment, canary event and rollback is in an exportable hash-linked receipt log',
|
|
132
|
+
},
|
|
133
|
+
safety: {
|
|
134
|
+
driftTriggers: DRIFT_KINDS.slice(), onInvalidation: driftPolicy?.onInvalidation ?? 'fallback',
|
|
135
|
+
canary: canaryLimits ? { maxTasks: canaryLimits.maxTasks, budgetUsd: canaryLimits.budgetUsd, maxErrorRate: canaryLimits.maxErrorRate, maxConsecutiveErrors: canaryLimits.maxConsecutiveErrors, maxIncorrectRate: canaryLimits.maxIncorrectRate } : null,
|
|
136
|
+
},
|
|
137
|
+
dataHandling: 'feedback keeps an allowlist of metadata; source code and evidence reach a provider only when the task policy and the egress policy both allow it',
|
|
138
|
+
reproduce: report.reproduce,
|
|
139
|
+
};
|
|
140
|
+
card.cardHash = digestOf({ ...card, cardHash: undefined });
|
|
141
|
+
return card;
|
|
142
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Shadow mode (X-605.AC01): record the route the constrained policy WOULD pick, and change nothing.
|
|
2
|
+
//
|
|
3
|
+
// The recorder has no transport, no adapter and no client: it cannot issue a provider call, paid or otherwise, so "no extra paid
|
|
4
|
+
// calls by default" holds by construction rather than by a flag. `shadow()` returns the production route it was given, the same object
|
|
5
|
+
// and untouched; the proposal is only written to the record list (and the receipt log, when one is supplied). Whether a proposal is
|
|
6
|
+
// ever acted on is the promotion gate's decision (promotion.js), not this module's.
|
|
7
|
+
//
|
|
8
|
+
// If the operator disabled adaptive routing (control.js) nothing is decided or recorded.
|
|
9
|
+
|
|
10
|
+
import { routeConstrained } from './decide.js';
|
|
11
|
+
|
|
12
|
+
export const SHADOW_SCHEMA = 'agentic-security/routing-shadow-record';
|
|
13
|
+
|
|
14
|
+
export function createShadowRecorder({ log = null, control = null } = {}) {
|
|
15
|
+
const records = [];
|
|
16
|
+
return {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} o
|
|
19
|
+
* @param {object} o.task from buildRoutingTask
|
|
20
|
+
* @param {{model:string, effort?:string}} o.productionRoute the route production will use; returned unchanged
|
|
21
|
+
* @param {...object} o.rest the remaining routeConstrained inputs (candidates, calibration, policy, now, egress, ...)
|
|
22
|
+
*/
|
|
23
|
+
shadow({ task, productionRoute, ...decideArgs }) {
|
|
24
|
+
if (!productionRoute || typeof productionRoute.model !== 'string' || !productionRoute.model) {
|
|
25
|
+
throw new Error('shadow mode needs the production route it is shadowing');
|
|
26
|
+
}
|
|
27
|
+
if (control && control.mode === 'disabled') {
|
|
28
|
+
return { route: productionRoute, shadow: null, recorded: false, paidCalls: 0, reason: 'adaptive routing is disabled by the operator' };
|
|
29
|
+
}
|
|
30
|
+
const decision = routeConstrained({ ...decideArgs, task, baselineRoute: productionRoute });
|
|
31
|
+
const record = Object.freeze({
|
|
32
|
+
schema: SHADOW_SCHEMA, taskId: task?.taskId ?? null, stratum: task?.stratum ?? null,
|
|
33
|
+
production: Object.freeze({ model: productionRoute.model }),
|
|
34
|
+
proposed: Object.freeze({
|
|
35
|
+
status: decision.status, code: decision.code, model: decision.selected ? decision.selected.model : null,
|
|
36
|
+
modelVersion: decision.selected ? decision.selected.modelVersion : null, via: decision.selected ? decision.selected.via : null,
|
|
37
|
+
policyVersion: decision.policyVersion, decisionHash: decision.decisionHash,
|
|
38
|
+
}),
|
|
39
|
+
synthetic: decision.synthetic === true || task?.synthetic === true, paidCalls: 0,
|
|
40
|
+
});
|
|
41
|
+
records.push(record);
|
|
42
|
+
if (log) log.append('shadow-decision', record);
|
|
43
|
+
return { route: productionRoute, shadow: record, recorded: true, paidCalls: 0 };
|
|
44
|
+
},
|
|
45
|
+
records() { return records.slice(); },
|
|
46
|
+
/** The latest record for each task. A re-decision replaces the earlier one in this view; the earlier record stays in `records()`. */
|
|
47
|
+
latestByTask() {
|
|
48
|
+
const m = new Map();
|
|
49
|
+
for (const r of records) m.set(r.taskId, r);
|
|
50
|
+
return [...m.values()];
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
// isAdmin / requireAdmin → references admin role check
|
|
28
28
|
// requireAuth / mustBeLoggedIn → references auth middleware / login flag
|
|
29
29
|
|
|
30
|
+
import { inferredInvariant } from './invariants/lifecycle.js';
|
|
31
|
+
|
|
30
32
|
const NAME_FAMILIES = [
|
|
31
33
|
{
|
|
32
34
|
label: 'ownership-check',
|
|
@@ -168,3 +170,27 @@ export function scanSpecificationDrift(fileContents) {
|
|
|
168
170
|
}
|
|
169
171
|
return findings;
|
|
170
172
|
}
|
|
173
|
+
|
|
174
|
+
// X-402: the same name families, read as INTENT instead of as drift. A function named `validateOwnership` implies a tenant
|
|
175
|
+
// isolation contract whether or not its body satisfies it; a function named `requireAdmin` implies a privilege constraint. Each
|
|
176
|
+
// becomes a PROPOSED invariant (inferred, with its source and an uncertainty), a skeleton for a reviewer to revise. Nothing here
|
|
177
|
+
// approves anything: a mined contract is a hypothesis and is advisory until a reviewer approves it in the signed ledger.
|
|
178
|
+
const INVARIANT_CLASS_BY_FAMILY = Object.freeze({ 'ownership-check': 'tenant-isolation', 'authorization-check': 'privilege-constraint', 'admin-gate': 'privilege-constraint' });
|
|
179
|
+
|
|
180
|
+
export function mineInvariantProposals(fileContents) {
|
|
181
|
+
const out = [];
|
|
182
|
+
if (!fileContents || typeof fileContents !== 'object') return out;
|
|
183
|
+
for (const [fp, text] of Object.entries(fileContents)) {
|
|
184
|
+
const lang = inferLang(fp);
|
|
185
|
+
if (!lang) continue;
|
|
186
|
+
for (const fn of extractFunctionBodies(text, lang)) {
|
|
187
|
+
for (const fam of NAME_FAMILIES) {
|
|
188
|
+
const cls = INVARIANT_CLASS_BY_FAMILY[fam.label];
|
|
189
|
+
if (!cls || !fam.nameRe.test(fn.name)) continue;
|
|
190
|
+
const inv = inferredInvariant({ class: cls, source: 'specification-mining', evidence: { file: fp, line: fn.startLine, function: fn.name, family: fam.label }, uncertainty: 0.65, action: fn.name });
|
|
191
|
+
if (inv) out.push(inv);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|