@clear-capabilities/agentic-security-scanner 0.171.0 → 0.172.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +6 -0
  3. package/dist/1336.index.js +891 -719
  4. package/dist/550.index.js +548 -0
  5. package/dist/7405.index.js +885 -0
  6. package/dist/9382.index.js +4386 -0
  7. package/dist/9579.index.js +1197 -0
  8. package/dist/agentic-security.mjs +4 -4
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +4 -4
  11. package/src/context-intelligence/CLAUDE.md +25 -0
  12. package/src/context-intelligence/adapters/existing-ir.js +14 -0
  13. package/src/context-intelligence/adapters/haskell-hie.js +165 -0
  14. package/src/context-intelligence/build.js +68 -0
  15. package/src/context-intelligence/candidates.js +115 -0
  16. package/src/context-intelligence/canonical.js +120 -0
  17. package/src/context-intelligence/capabilities.js +33 -0
  18. package/src/context-intelligence/cli.js +426 -0
  19. package/src/context-intelligence/config.js +91 -0
  20. package/src/context-intelligence/controls.js +244 -0
  21. package/src/context-intelligence/diff.js +97 -0
  22. package/src/context-intelligence/entrypoints.js +336 -0
  23. package/src/context-intelligence/evaluation/index.js +235 -0
  24. package/src/context-intelligence/evaluation/runner.js +139 -0
  25. package/src/context-intelligence/evaluation/scripted-reader.js +289 -0
  26. package/src/context-intelligence/files.js +80 -0
  27. package/src/context-intelligence/graph-schema.js +227 -0
  28. package/src/context-intelligence/imports.js +123 -0
  29. package/src/context-intelligence/manifest.js +99 -0
  30. package/src/context-intelligence/packer.js +224 -0
  31. package/src/context-intelligence/persist.js +40 -0
  32. package/src/context-intelligence/prompts/packs.js +82 -0
  33. package/src/context-intelligence/queue.js +355 -0
  34. package/src/context-intelligence/reachability.js +188 -0
  35. package/src/context-intelligence/repo-info.js +16 -0
  36. package/src/context-intelligence/review.js +370 -0
  37. package/src/context-intelligence/spans.js +147 -0
  38. package/src/context-intelligence/store.js +203 -0
  39. package/src/posture/artifact-registry.js +2 -0
@@ -0,0 +1,336 @@
1
+ //
2
+ // Entry points -> exact handler symbols.
3
+ //
4
+ // There is NO second route detector here. HTTP routes come from the engine's own route scan (`scanRoutes`) and, for
5
+ // Haskell, from the existing web model (`analyzeHaskellWeb`); non-HTTP surfaces come from the existing entry-point
6
+ // inventory. What this module adds is the join the context packer needs and the existing records do not carry: which
7
+ // declaration is the handler, which middleware wraps it, and an honest disposition for every route.
8
+ //
9
+ // Every detected entry point ends in exactly ONE disposition:
10
+ // resolved handler is a known declaration and nothing about how it is wired is in doubt
11
+ // partially_resolved a handler (or a registration block) is known, but something is not: a middleware that does
12
+ // not resolve, a handler matched by name only, a router whose mount point is not visible
13
+ // unresolved no handler could be identified; the manifest still exists and says so
14
+ // excluded_by_policy deliberately not processed (operator exclusion, or the entry-point cap)
15
+ import { hashOf, safeLabel } from './canonical.js';
16
+ import { callStatement, inlineBlockSpan, splitArgs } from './spans.js';
17
+ import { dependencyParams, signatureText } from './controls.js';
18
+ import { buildEntrypointInventory } from '../posture/entrypoint-inventory.js';
19
+
20
+ export const DISPOSITIONS = Object.freeze(['resolved', 'partially_resolved', 'unresolved', 'excluded_by_policy']);
21
+ export const ENTRYPOINT_KINDS = Object.freeze(['http', 'webhook', 'queue', 'cron', 'upload', 'cli']);
22
+
23
+ const JS_FILE = /\.(?:js|jsx|mjs|cjs|ts|tsx)$/i;
24
+ const HS_FILE = /\.l?hs$/i;
25
+ const INLINE_FN = /^(?:async\s+)?(?:function\b|\([^)]*\)\s*(?::\s*[\w<>\[\]|. ]+)?\s*=>|[A-Za-z_$][\w$]*\s*=>|\\)/;
26
+ const IDENT_CHAIN = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/;
27
+
28
+ function lastSeg(n) { const p = String(n).split(/[.:]/); return p[p.length - 1]; }
29
+
30
+ function globToRe(g) {
31
+ const esc = String(g).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\0').replace(/\*/g, '[^/]*').replace(/\0/g, '.*');
32
+ return new RegExp(`^${esc}$`);
33
+ }
34
+
35
+ function symbolIndexes(graph) {
36
+ const byFile = new Map();
37
+ const byName = new Map();
38
+ const byId = new Map();
39
+ const imports = new Map();
40
+ for (const i of graph.imports || []) { if (!imports.has(i.file)) imports.set(i.file, []); imports.get(i.file).push(i); }
41
+ for (const s of graph.symbols) {
42
+ byId.set(s.id, s);
43
+ if (!byFile.has(s.file)) byFile.set(s.file, []);
44
+ byFile.get(s.file).push(s);
45
+ if (s.kind === 'function' && !s.name.startsWith('<')) {
46
+ for (const key of new Set([s.name, lastSeg(s.name)])) {
47
+ if (!byName.has(key)) byName.set(key, []);
48
+ byName.get(key).push(s);
49
+ }
50
+ }
51
+ }
52
+ for (const arr of byFile.values()) arr.sort((a, b) => a.declLine - b.declLine || (a.id < b.id ? -1 : 1));
53
+ return { byFile, byName, byId, imports };
54
+ }
55
+
56
+ /** Resolve a referenced name to a symbol: same-file exact, then project-unique by name (low confidence). */
57
+ function resolveName(name, file, idx) {
58
+ // an import statement binds the name to a specific module: that beats any project-wide guess
59
+ const imp = (idx.imports.get(file) || []).find(i => i.local === name.split('.')[0]);
60
+ if (imp && imp.kind === 'repo') {
61
+ const member = name.includes('.') ? name.split('.').pop() : (imp.member === '*' || imp.member === 'default' ? name : imp.member);
62
+ const hit = (idx.byName.get(member) || []).filter(s => s.file === imp.target);
63
+ if (hit.length === 1) return { symbol: hit[0], resolution: 'resolved', via: 'import' };
64
+ }
65
+ const exact = (idx.byName.get(name) || []).filter(s => s.file === file);
66
+ if (exact.length === 1) return { symbol: exact[0], resolution: 'resolved' };
67
+ if (exact.length > 1) return { symbol: null, resolution: 'ambiguous', candidates: exact.map(s => s.id) };
68
+ const key = lastSeg(name);
69
+ const all = idx.byName.get(name) || idx.byName.get(key) || [];
70
+ if (all.length === 1) return { symbol: all[0], resolution: 'name-match' };
71
+ if (all.length > 1) return { symbol: null, resolution: 'ambiguous', candidates: all.slice(0, 5).map(s => s.id) };
72
+ return { symbol: null, resolution: 'unresolved' };
73
+ }
74
+
75
+ /** `X.use(...)` registrations in a JS/TS file: the global / prefix middleware a route can inherit. */
76
+ function collectUses(fileContents) {
77
+ const uses = [];
78
+ for (const [file, text] of Object.entries(fileContents)) {
79
+ if (!JS_FILE.test(file)) continue;
80
+ const lines = text.split('\n');
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const m = /^\s*([A-Za-z_$][\w$]*)\.use\s*\(/.exec(lines[i]);
83
+ if (!m) continue;
84
+ const st = callStatement(lines, i + 1);
85
+ if (!st) continue;
86
+ const args = splitArgs(st.text);
87
+ let prefix = '/';
88
+ let rest = args;
89
+ const q = args[0] && /^(['"`])(.*)\1$/.exec(args[0]);
90
+ if (q) { prefix = q[2]; rest = args.slice(1); }
91
+ const names = rest.map(a => { const id = IDENT_CHAIN.exec(a.replace(/^\[|\]$/g, '')); return id ? { name: id[0], text: a } : { name: null, text: a }; });
92
+ uses.push({ file, line: i + 1, receiver: m[1], prefix, args: names });
93
+ }
94
+ }
95
+ return uses;
96
+ }
97
+
98
+ function routeKey(r) { return `${r.file}|${r.line}|${r.method}|${r.path}`; }
99
+
100
+ async function collectRoutes(fileContents) {
101
+ const { scanRoutes } = await import('../engine.js');
102
+ const { analyzeHaskellWeb } = await import('../language/haskell-web.js');
103
+ const routes = [];
104
+ const seen = new Set();
105
+ const add = (r) => { const k = routeKey(r); if (!seen.has(k)) { seen.add(k); routes.push(r); } };
106
+ const hs = {};
107
+ for (const file of Object.keys(fileContents).sort()) {
108
+ if (HS_FILE.test(file)) { hs[file] = fileContents[file]; continue; }
109
+ let found = [];
110
+ try { found = scanRoutes(file, fileContents[file]) || []; } catch { found = []; }
111
+ for (const r of found) add({ ...r, language: 'generic' });
112
+ }
113
+ if (Object.keys(hs).length) {
114
+ let web = null;
115
+ try { web = analyzeHaskellWeb(hs); } catch { web = null; }
116
+ if (web) for (const r of web.routes) add({ method: r.method, path: r.path, framework: r.framework, file: r.file, line: r.line, hasAuth: r.auth.status === 'authenticated', language: 'haskell', handler: r.handler, webModel: { auth: r.auth, role: r.role, ownership: r.ownership, idLookups: r.idLookups, csrf: r.csrf, sensitive: r.sensitive, uploads: r.uploads } });
117
+ }
118
+ return routes;
119
+ }
120
+
121
+ function resolveHandler(route, ctx) {
122
+ const { fileContents, idx, graph } = ctx;
123
+ const lines = fileContents[route.file].split('\n');
124
+ const reasons = [];
125
+ const out = { handlerSymbol: null, handlerKind: 'unknown', rootSpan: null, middleware: [], reasons, confidence: 'none' };
126
+ const fileSyms = idx.byFile.get(route.file) || [];
127
+
128
+ if (route.language === 'haskell') {
129
+ const h = route.handler || {};
130
+ if (h.kind === 'named' && h.name) {
131
+ const r = resolveName(h.name, route.file, idx);
132
+ if (r.symbol) { out.handlerSymbol = r.symbol.id; out.handlerKind = 'named'; out.confidence = r.resolution === 'resolved' ? 'exact' : 'name-match'; if (r.resolution !== 'resolved') reasons.push('handler-matched-by-name-only'); }
133
+ else reasons.push(`handler-${r.resolution}`);
134
+ } else if (h.kind === 'inline') {
135
+ out.handlerKind = 'inline';
136
+ out.rootSpan = { file: route.file, ...inlineBlockSpan(lines, route.line) };
137
+ const enclosing = fileSyms.filter(s => s.kind === 'function' && s.startLine <= route.line && s.endLine >= route.line).sort((a, b) => (b.startLine - a.startLine))[0];
138
+ out.handlerSymbol = enclosing ? enclosing.id : null;
139
+ out.confidence = enclosing ? 'inline-block' : 'none';
140
+ if (!enclosing) reasons.push('inline-handler-without-enclosing-declaration');
141
+ } else {
142
+ reasons.push('handler-not-statically-known');
143
+ // the manifest still shows where the route is registered, so the gap is inspectable rather than absent
144
+ out.rootSpan = { file: route.file, ...inlineBlockSpan(lines, route.line) };
145
+ }
146
+ return out;
147
+ }
148
+
149
+ const rawLine = lines[route.line - 1] || '';
150
+ if (/^\s*@/.test(rawLine)) {
151
+ // decorator / annotation style: the handler is the next declaration
152
+ const decl = fileSyms.filter(s => s.kind === 'function' && s.declLine >= route.line && s.declLine <= route.line + 12).sort((a, b) => a.declLine - b.declLine)[0];
153
+ if (decl) {
154
+ out.handlerSymbol = decl.id; out.handlerKind = 'decorated'; out.confidence = 'exact';
155
+ // dependency-injected guards in the signature (`user=Depends(require_user)`) run before the body
156
+ for (const d of dependencyParams(signatureText(lines, decl.declLine))) {
157
+ const r = resolveName(d.dependency, route.file, idx);
158
+ out.middleware.push({ name: d.dependency, symbolId: r.symbol ? r.symbol.id : null, resolution: r.resolution, line: decl.declLine, dependency: true, param: d.param });
159
+ if (!r.symbol) reasons.push(`dependency-${r.resolution}:${d.dependency}`);
160
+ else if (r.resolution === 'name-match') reasons.push(`dependency-matched-by-name-only:${d.dependency}`);
161
+ }
162
+ }
163
+ else reasons.push('decorated-handler-not-found');
164
+ return out;
165
+ }
166
+
167
+ const st = JS_FILE.test(route.file) ? callStatement(lines, route.line) : null;
168
+ if (!st) {
169
+ // not a call-style registration we can parse: fall back to the enclosing declaration, flagged
170
+ const enclosing = fileSyms.filter(s => s.kind === 'function' && s.startLine <= route.line && s.endLine >= route.line).sort((a, b) => b.startLine - a.startLine)[0];
171
+ if (enclosing) { out.handlerSymbol = enclosing.id; out.handlerKind = 'enclosing'; out.confidence = 'enclosing'; reasons.push('registration-form-not-parsed'); }
172
+ else reasons.push('registration-form-not-parsed');
173
+ return out;
174
+ }
175
+ out.rootSpan = { file: route.file, startLine: route.line, endLine: st.endLine, precision: 'brace' };
176
+ const args = splitArgs(st.text).slice(1); // drop the path
177
+ const flat = [];
178
+ for (const a of args) {
179
+ if (a.startsWith('[') && a.endsWith(']')) flat.push(...splitArgs(`(${a.slice(1, -1)})`));
180
+ else flat.push(a);
181
+ }
182
+ if (!flat.length) { reasons.push('registration-has-no-handler-argument'); return out; }
183
+ const last = flat[flat.length - 1];
184
+ for (let i = 0; i < flat.length - 1; i++) {
185
+ const a = flat[i];
186
+ if (INLINE_FN.test(a)) { out.middleware.push({ name: '<inline>', symbolId: null, resolution: 'inline', line: route.line }); continue; }
187
+ const id = IDENT_CHAIN.exec(a);
188
+ if (!id) { out.middleware.push({ name: null, text: a.slice(0, 80), symbolId: null, resolution: 'unresolved', line: route.line }); reasons.push('middleware-not-an-identifier'); continue; }
189
+ const r = resolveName(id[0], route.file, idx);
190
+ out.middleware.push({ name: id[0], symbolId: r.symbol ? r.symbol.id : null, resolution: r.resolution, line: route.line, factory: /\(/.test(a) });
191
+ if (!r.symbol) reasons.push(`middleware-${r.resolution}:${id[0]}`);
192
+ else if (r.resolution === 'name-match') reasons.push(`middleware-matched-by-name-only:${id[0]}`);
193
+ }
194
+ if (INLINE_FN.test(last)) {
195
+ const cands = fileSyms.filter(s => s.kind === 'function' && s.name.startsWith('<anon>') === false && false);
196
+ const anon = fileSyms.filter(s => s.id.includes('::<anon>@') && s.declLine >= route.line && s.declLine <= st.endLine);
197
+ const outermost = anon.filter(a => !anon.some(b => b !== a && b.startLine <= a.startLine && b.endLine >= a.endLine && (b.startLine < a.startLine || b.endLine > a.endLine)));
198
+ void cands;
199
+ const pick = outermost.sort((a, b) => a.declLine - b.declLine || (a.id < b.id ? -1 : 1)).pop();
200
+ if (pick) { out.handlerSymbol = pick.id; out.handlerKind = 'inline'; out.confidence = 'exact'; }
201
+ else reasons.push('inline-handler-not-in-ir');
202
+ } else {
203
+ const id = IDENT_CHAIN.exec(last);
204
+ if (!id) { reasons.push('handler-not-an-identifier'); }
205
+ else {
206
+ const r = resolveName(id[0], route.file, idx);
207
+ if (r.symbol) { out.handlerSymbol = r.symbol.id; out.handlerKind = 'named'; out.confidence = r.resolution === 'resolved' ? 'exact' : 'name-match'; if (r.resolution !== 'resolved') reasons.push('handler-matched-by-name-only'); }
208
+ else reasons.push(`handler-${r.resolution}:${id[0]}`);
209
+ }
210
+ }
211
+ void graph;
212
+ return out;
213
+ }
214
+
215
+ function pathMatchesPrefix(routePath, prefix) {
216
+ if (prefix === '/' || prefix === '') return true;
217
+ const p = prefix.replace(/\/+$/, '');
218
+ return routePath === p || routePath.startsWith(p + '/') || routePath.startsWith(p);
219
+ }
220
+
221
+ /**
222
+ * Build the entry-point records.
223
+ * config.entrypointKinds, config.exclude (globs on file or route path), config.maxEntrypoints
224
+ */
225
+ export async function collectEntrypoints({ fileContents, graph, config = {} }) {
226
+ const kinds = new Set(config.entrypointKinds && config.entrypointKinds.length ? config.entrypointKinds : ['http']);
227
+ const idx = symbolIndexes(graph);
228
+ const uses = collectUses(fileContents);
229
+ const exclude = (config.exclude || []).map(globToRe);
230
+ const cap = Number.isInteger(config.maxEntrypoints) && config.maxEntrypoints > 0 ? config.maxEntrypoints : 5000;
231
+ const ctx = { fileContents, idx, graph };
232
+ const records = [];
233
+
234
+ if (kinds.has('http') || kinds.has('webhook')) {
235
+ for (const route of await collectRoutes(fileContents)) {
236
+ const isWebhook = /webhook/i.test(route.path || '');
237
+ if (isWebhook && !kinds.has('webhook') && !kinds.has('http')) continue;
238
+ const h = resolveHandler(route, ctx);
239
+ const reasons = [...h.reasons];
240
+ const middleware = [...h.middleware];
241
+ // inherited middleware registered with `X.use(...)` on the same receiver, earlier in the same file
242
+ if (JS_FILE.test(route.file)) {
243
+ const lines = fileContents[route.file].split('\n');
244
+ const recvM = /([A-Za-z_$][\w$]*)\.(?:get|post|put|patch|delete|all|options|head|route)\s*\(/.exec(lines[route.line - 1] || '');
245
+ const receiver = recvM ? recvM[1] : null;
246
+ for (const u of uses) {
247
+ if (u.file !== route.file || u.line >= route.line || u.receiver !== receiver) continue;
248
+ if (!pathMatchesPrefix(String(route.path || ''), u.prefix)) continue;
249
+ for (const a of u.args) {
250
+ if (!a.name) { middleware.push({ name: null, text: a.text.slice(0, 80), symbolId: null, resolution: 'unresolved', line: u.line, scope: u.prefix, inherited: true }); reasons.push('inherited-middleware-not-an-identifier'); continue; }
251
+ const r = resolveName(a.name, u.file, idx);
252
+ middleware.push({ name: a.name, symbolId: r.symbol ? r.symbol.id : null, resolution: r.resolution, line: u.line, scope: u.prefix, inherited: true });
253
+ if (!r.symbol) reasons.push(`inherited-middleware-${r.resolution}:${a.name}`);
254
+ }
255
+ }
256
+ if (receiver && /^(?:router|routes?|r|api|\w*Router)$/i.test(receiver) && !uses.some(u => u.receiver !== receiver && u.args.some(a => a.name === receiver))) {
257
+ reasons.push('router-mount-not-resolved');
258
+ }
259
+ }
260
+ const authScheme = route.webModel
261
+ ? { status: route.webModel.auth.status, kind: route.webModel.auth.kind || null, basis: 'haskell-web-model', role: route.webModel.role.status, ownership: route.webModel.ownership || null, idLookups: route.webModel.idLookups || [], csrf: !!route.webModel.csrf }
262
+ : { status: route.hasAuth ? 'authenticated' : 'none', basis: 'route-scan-heuristic', note: 'authentication seen near the registration; says nothing about ownership' };
263
+ const base = {
264
+ kind: isWebhook ? 'webhook' : 'http',
265
+ method: String(route.method || 'GET'),
266
+ pathTemplate: String(route.path || ''),
267
+ framework: route.framework || null,
268
+ language: route.language === 'haskell' ? 'haskell' : (JS_FILE.test(route.file) ? 'javascript' : (route.file.split('.').pop())),
269
+ registrationSite: { file: route.file, line: route.line },
270
+ handlerSymbol: h.handlerSymbol,
271
+ handlerKind: h.handlerKind,
272
+ handlerConfidence: h.confidence,
273
+ rootSpan: h.rootSpan,
274
+ middleware,
275
+ authScheme,
276
+ externalDependencies: [],
277
+ resolutionReasons: [...new Set(reasons)].sort(),
278
+ };
279
+ records.push(base);
280
+ }
281
+ }
282
+
283
+ // non-HTTP surfaces from the existing inventory
284
+ const wantInv = ['queue', 'cron', 'upload', 'cli', 'webhook'].filter(k => kinds.has(k));
285
+ if (wantInv.length) {
286
+ const inv = buildEntrypointInventory(fileContents, { routes: [] });
287
+ for (const e of inv.entrypoints) {
288
+ if (!wantInv.includes(e.type)) continue;
289
+ const fileSyms = idx.byFile.get(e.file) || [];
290
+ const enclosing = fileSyms.filter(s => s.kind === 'function' && s.startLine <= e.line && s.endLine >= e.line).sort((a, b) => b.startLine - a.startLine)[0];
291
+ records.push({
292
+ kind: e.type, method: '', pathTemplate: e.name, framework: null,
293
+ language: JS_FILE.test(e.file) ? 'javascript' : e.file.split('.').pop(),
294
+ registrationSite: { file: e.file, line: e.line },
295
+ handlerSymbol: enclosing ? enclosing.id : null,
296
+ handlerKind: enclosing ? 'enclosing' : 'unknown', handlerConfidence: enclosing ? 'enclosing' : 'none',
297
+ rootSpan: null, middleware: [],
298
+ authScheme: { status: e.trust === 'authenticated' ? 'authenticated' : 'none', basis: 'inventory-heuristic' },
299
+ externalDependencies: [],
300
+ resolutionReasons: enclosing ? ['non-http-handler-is-enclosing-declaration'] : ['no-enclosing-declaration'],
301
+ });
302
+ }
303
+ }
304
+
305
+ // ids, dispositions
306
+ records.sort((a, b) =>
307
+ (a.registrationSite.file < b.registrationSite.file ? -1 : a.registrationSite.file > b.registrationSite.file ? 1 : 0) ||
308
+ (a.registrationSite.line - b.registrationSite.line) || (a.method < b.method ? -1 : 1) || (a.pathTemplate < b.pathTemplate ? -1 : a.pathTemplate > b.pathTemplate ? 1 : 0));
309
+ const ord = new Map();
310
+ records.forEach((r, i) => {
311
+ const hname = r.handlerSymbol || `@${r.registrationSite.line}`;
312
+ const stem = hashOf({ k: r.kind, m: r.method, p: r.pathTemplate, f: r.registrationSite.file, h: r.handlerSymbol ? r.handlerSymbol : null });
313
+ const n = (ord.get(stem) || 0) + 1;
314
+ ord.set(stem, n);
315
+ r.id = `ep:${stem.slice(0, 20)}${n > 1 ? `-${n}` : ''}`;
316
+ r.label = safeLabel(`${r.method ? r.method + ' ' : ''}${r.pathTemplate}`) || r.kind;
317
+ void hname;
318
+ let disposition;
319
+ const excluded = exclude.some(re => re.test(r.registrationSite.file) || re.test(r.pathTemplate));
320
+ if (excluded) { disposition = 'excluded_by_policy'; r.resolutionReasons = [...r.resolutionReasons, 'excluded-by-operator-pattern']; }
321
+ else if (i >= cap) { disposition = 'excluded_by_policy'; r.resolutionReasons = [...r.resolutionReasons, 'max-entrypoints-cap']; }
322
+ else if (!r.handlerSymbol && !r.rootSpan) disposition = 'unresolved';
323
+ else if (!r.handlerSymbol) disposition = 'partially_resolved';
324
+ else if (r.resolutionReasons.length) disposition = 'partially_resolved';
325
+ else disposition = 'resolved';
326
+ r.disposition = disposition;
327
+ });
328
+ return records;
329
+ }
330
+
331
+ /** Counts per disposition. Every entry point is in exactly one bucket, so the buckets always sum to the total. */
332
+ export function countDispositions(records) {
333
+ const c = { total: records.length, resolved: 0, partially_resolved: 0, unresolved: 0, excluded_by_policy: 0 };
334
+ for (const r of records) c[r.disposition]++;
335
+ return c;
336
+ }
@@ -0,0 +1,235 @@
1
+ //
2
+ // `agentic-security context eval`: the frozen synthetic suite, scored.
3
+ //
4
+ // Honest by construction:
5
+ // * every scorecard says on its face that the suite and the reader are synthetic and that no accuracy claim follows;
6
+ // * intervals come from the repo's existing grouped bootstrap (a case is one group; fewer than 10 groups is "unmeasured");
7
+ // * the holdout split is scored only through a ledger that counts every use, and non-default prompt variants are refused
8
+ // on it unless the run is declared final;
9
+ // * a label-leakage audit checks that neither the corpus nor any prompt a system saw contains the label vocabulary.
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { sha256, canonicalJson, hashOf } from '../canonical.js';
14
+ import { writeStoreJson, readStoreFile } from '../store.js';
15
+ import { groupedBootstrap, wilsonInterval, f1Of, precisionOf, recallOf } from '../../posture/evaluation/interval.js';
16
+ import { runCase, SYSTEMS } from './runner.js';
17
+ import { readerIdentity } from './scripted-reader.js';
18
+
19
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
20
+ // The bundled synthetic suite lives in the source tree's fixtures, found from the package root. Scan code must not
21
+ // read evaluation labels, so this is reachable only from the explicit `context eval` command, and `--suite-dir` /
22
+ // AGENTIC_SECURITY_DCI_EVAL_SUITE can point at any other suite in the same layout.
23
+ function packageRoot(from) {
24
+ let dir = from;
25
+ for (let i = 0; i < 8; i++) {
26
+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
27
+ dir = path.dirname(dir);
28
+ }
29
+ return from;
30
+ }
31
+ export const DEFAULT_SUITE_DIR = process.env.AGENTIC_SECURITY_DCI_EVAL_SUITE
32
+ ? path.resolve(process.env.AGENTIC_SECURITY_DCI_EVAL_SUITE)
33
+ : path.join(packageRoot(HERE), ['test', 'fixtures', 'context-intelligence', 'eval'].join(path.sep));
34
+ export const VARIANTS = Object.freeze(['dci', 'dci-all-packs']);
35
+ export const FROZEN_VARIANT = 'dci';
36
+ const FLAG = (args, k) => (args && args[k] !== undefined ? args[k] : undefined);
37
+ const truthy = (v) => v === true || v === 'true' || v === '1';
38
+
39
+ const LEAK_TERMS = /\b(?:vulnerable|vulnerability|idor|bola|bfla|exploit|insecure|label|holdout|ground.?truth|expected result)\b/i;
40
+
41
+ function loadSuite(dir) {
42
+ const read = (f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
43
+ const labels = read('labels.json');
44
+ const split = read('split.json');
45
+ return { labels, split, dir };
46
+ }
47
+
48
+ function walk(dir, out = []) {
49
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
50
+ const p = path.join(dir, e.name);
51
+ if (e.isDirectory()) walk(p, out); else if (e.isFile()) out.push(p);
52
+ }
53
+ return out.sort();
54
+ }
55
+
56
+ function suiteHash(dir) {
57
+ const files = walk(dir).map(f => ({ f: path.relative(dir, f), h: sha256(fs.readFileSync(f)) }));
58
+ return hashOf(files);
59
+ }
60
+
61
+ /** The corpus and every prompt a system saw must not contain the label vocabulary. Returns hits (empty = clean). */
62
+ export function leakageAudit(dir, promptsBySystem) {
63
+ const hits = [];
64
+ for (const f of walk(path.join(dir, 'corpus'))) {
65
+ const text = fs.readFileSync(f, 'utf8');
66
+ const m = LEAK_TERMS.exec(text);
67
+ if (m) hits.push({ where: 'corpus', file: path.relative(dir, f), term: m[0] });
68
+ }
69
+ for (const [system, prompts] of Object.entries(promptsBySystem)) {
70
+ for (const p of prompts) {
71
+ const body = String(p);
72
+ const m = /\bc(?:0[1-9]|1[01])\b/.exec(body) || /\bdci-eval\b/.exec(body);
73
+ if (m) { hits.push({ where: `prompt:${system}`, term: m[0] }); break; }
74
+ }
75
+ }
76
+ return hits;
77
+ }
78
+
79
+ function tally(units) {
80
+ const c = { tp: 0, fp: 0, fn: 0, tn: 0 };
81
+ for (const u of units) { c.tp += u.tp; c.fp += u.fp; c.fn += u.fn; c.tn += u.tn; }
82
+ return c;
83
+ }
84
+
85
+ export function metricsFor(units, protocolHash, name) {
86
+ const c = tally(units);
87
+ const groupOf = Object.fromEntries(units.map(u => [u.id, u.caseId]));
88
+ const f1 = groupedBootstrap({ units, groupOf, statistic: ({ total }) => f1Of(total), name: `${name}:f1`, protocolHash });
89
+ const pn = c.tp + c.fp;
90
+ return {
91
+ counts: c, units: units.length,
92
+ precision: precisionOf(c), recall: recallOf(c), f1: f1Of(c),
93
+ precisionInterval: pn > 0 ? wilsonInterval(c.tp, pn) : { status: 'unmeasured', reason: 'nothing was reported' },
94
+ f1Interval: f1,
95
+ // A point estimate with no interval around it reads as a finding. It is reportable only when the interval is measured.
96
+ reportable: f1 && f1.status === 'measured',
97
+ };
98
+ }
99
+
100
+ /** Nearest-rank percentile of a numeric list (null when empty). */
101
+ export function percentile(values, q) {
102
+ if (!values.length) return null;
103
+ const v = [...values].sort((a, b) => a - b);
104
+ return v[Math.min(v.length - 1, Math.max(0, Math.ceil(q * v.length) - 1))];
105
+ }
106
+
107
+ /** Context-size, call and cost accounting for one system. Token figures are chars/4 estimates; no price is assumed. */
108
+ export function economicsOf({ promptSizes, calls, wallMs, metrics }) {
109
+ const tokens = promptSizes.map(n => Math.ceil(n / 4));
110
+ const tp = metrics.counts.tp;
111
+ return {
112
+ tokenizer: 'chars/4-estimate',
113
+ promptsPerCase: promptSizes.length,
114
+ contextTokens: { median: percentile(tokens, 0.5), p95: percentile(tokens, 0.95), p99: percentile(tokens, 0.99), total: tokens.reduce((a, b) => a + b, 0) },
115
+ modelCalls: calls,
116
+ callsPerTruePositive: tp > 0 ? calls / tp : null,
117
+ wallMs,
118
+ cost: { usd: null, basis: 'no price supplied; calls and estimated tokens are reported instead' },
119
+ };
120
+ }
121
+
122
+ function ledgerPath() { return 'reports/eval-ledger.json'; }
123
+ function readLedger(root) {
124
+ const r = readStoreFile(root, ledgerPath());
125
+ if (!r.ok) return { schemaVersion: 'eval-ledger/1', holdoutScoredRuns: 0, devScoredRuns: 0, runs: [] };
126
+ try { return JSON.parse(r.text); } catch { return { schemaVersion: 'eval-ledger/1', holdoutScoredRuns: 0, devScoredRuns: 0, runs: [], note: 'previous ledger unreadable; counts restart' }; }
127
+ }
128
+
129
+ const pct = (x) => (x === null || x === undefined ? 'n/a' : (x * 100).toFixed(0) + '%');
130
+ const interval = (i) => (!i || i.status !== 'measured' ? `unmeasured (${i && i.reason ? i.reason.split(',')[0] : 'no data'})` : `${pct(i.low)} to ${pct(i.high)}`);
131
+
132
+ // Precision, recall and F1 are printed only when their interval is measured; otherwise the counts stand alone.
133
+ const shown = (m, x) => (m.reportable ? pct(x) : 'withheld');
134
+
135
+ export function renderMarkdown(card) {
136
+ const L = [];
137
+ L.push('# Context Intelligence evaluation scorecard', '');
138
+ L.push('> SYNTHETIC SUITE, SCRIPTED READER. These cases and the reader were written by the same authors, with no independent adjudication and no real-world defect history. The numbers describe the context-building and verification machinery on synthetic cases. They are not evidence about any model, and they make no accuracy claim about real repositories.', '');
139
+ L.push(`- split: ${card.split} variant: ${card.variant} cases: ${card.caseCount}`);
140
+ L.push(`- suite hash: ${card.hashes.suite}`);
141
+ L.push(`- protocol hash: ${card.hashes.protocol}`);
142
+ L.push(`- reader: ${card.reader.kind} v${card.reader.version} ${card.reader.digest} (synthetic)`);
143
+ L.push(`- label leakage audit: ${card.leakage.length === 0 ? 'clean' : card.leakage.length + ' hit(s)'}`);
144
+ L.push(`- holdout scored runs so far (this machine's ledger): ${card.ledger.holdoutScoredRuns}`, '');
145
+ L.push('| system | status | TP | FP | FN | TN | precision | recall | F1 | F1 interval | model calls | prompt chars |', '|---|---|---|---|---|---|---|---|---|---|---|---|');
146
+ for (const s of card.systems) {
147
+ if (s.status !== 'measured') { L.push(`| ${s.system} | ${s.status}: ${s.reason} | | | | | | | | | | |`); continue; }
148
+ const m = s.metrics;
149
+ L.push(`| ${s.system} | measured | ${m.counts.tp} | ${m.counts.fp} | ${m.counts.fn} | ${m.counts.tn} | ${shown(m, m.precision)} | ${shown(m, m.recall)} | ${shown(m, m.f1)} | ${interval(m.f1Interval)} | ${s.calls} | ${s.promptChars} |`);
150
+ }
151
+ L.push('', '## Economics (estimated; no price supplied)', '', '| system | median ctx tokens | P95 | P99 | model calls | calls per true positive | wall ms |', '|---|---|---|---|---|---|---|');
152
+ for (const s of card.systems) if (s.economics) { const e = s.economics; L.push(`| ${s.system} | ${e.contextTokens.median ?? 'n/a'} | ${e.contextTokens.p95 ?? 'n/a'} | ${e.contextTokens.p99 ?? 'n/a'} | ${e.modelCalls} | ${e.callsPerTruePositive === null || !s.metrics || !s.metrics.reportable ? 'withheld' : e.callsPerTruePositive.toFixed(1)} | ${e.wallMs} |`); }
153
+ L.push('', 'Precision, recall, F1 and calls per true positive are withheld while their interval is unmeasured (fewer than 10 independent groups). The counts above are what was observed; they are not an estimate of anything.');
154
+ L.push('', `Replay: \`${card.replay}\``, '');
155
+ if (card.leakage.length) { L.push('## Leakage hits', ...card.leakage.map(h => `- ${h.where} ${h.file || ''} ${h.term}`), ''); }
156
+ return L.join('\n');
157
+ }
158
+
159
+ export function renderCsv(card) {
160
+ // The marker is a column, not a banner: a CSV is the format that gets pasted into a spreadsheet without its header text.
161
+ const rows = [['system', 'case', 'variant', 'vulnerable', 'flagged', 'outcome', 'calls', 'promptChars', 'synthetic']];
162
+ for (const s of card.systems) for (const u of (s.units || [])) rows.push([s.system, u.caseId, u.variant, u.vulnerable, u.flagged, u.outcome, u.calls, u.promptChars, card.synthetic === false ? false : true]);
163
+ return rows.map(r => r.join(',')).join('\n') + '\n';
164
+ }
165
+
166
+ export async function evaluateSuite({ suiteDir = DEFAULT_SUITE_DIR, split = 'dev', systems = ['deterministic', 'hunt', 'dci'], variant = FROZEN_VARIANT, root = null, final = false, onProgress = () => {} } = {}) {
167
+ const suite = loadSuite(suiteDir);
168
+ const ids = split === 'all' ? [...suite.split.dev, ...suite.split.holdout] : suite.split[split];
169
+ if (!ids) return { ok: false, reason: `unknown split '${split}' (dev, holdout, all)` };
170
+ if (!VARIANTS.includes(variant)) return { ok: false, reason: `unknown variant '${variant}' (${VARIANTS.join(', ')})` };
171
+ const touchesHoldout = split !== 'dev';
172
+ if (touchesHoldout && variant !== FROZEN_VARIANT && !final) return { ok: false, reason: `variant '${variant}' is not the frozen one; develop variants on the dev split. The holdout may only score the frozen variant unless the run is declared --final.` };
173
+
174
+ const protocol = { suite: suiteHash(suiteDir), split, variant, systems, reader: readerIdentity(), tolerance: 5 };
175
+ const protocolHash = hashOf(protocol);
176
+ const promptsBySystem = {};
177
+ const out = [];
178
+ for (const system of systems) {
179
+ if (!SYSTEMS.includes(system)) { out.push({ system, status: 'error', reason: 'unknown system' }); continue; }
180
+ const units = []; let calls = 0; let promptChars = 0; let blocked = null; const errors = []; const promptSizes = []; const t0 = Date.now();
181
+ for (const id of ids) {
182
+ const def = { id, ...suite.labels.cases[id] };
183
+ for (const key of Object.keys(def.variants).sort()) {
184
+ const v = def.variants[key];
185
+ onProgress({ system, case: id, variant: key });
186
+ const r = await runCase({ system, caseDef: def, variant: { key }, suiteDir, variantName: variant });
187
+ if (r.status === 'blocked') { blocked = r.reason; continue; }
188
+ if (r.status === 'error') { errors.push(`${id}/${key}: ${r.reason}`); continue; }
189
+ const flagged = !!r.flagged; const vuln = !!v.vulnerable;
190
+ const u = { id: `${id}/${key}`, caseId: id, variant: key, language: def.language, vulnerable: vuln, flagged, tp: vuln && flagged ? 1 : 0, fn: vuln && !flagged ? 1 : 0, fp: !vuln && flagged ? 1 : 0, tn: !vuln && !flagged ? 1 : 0, calls: r.calls, promptChars: r.promptChars, outcome: vuln ? (flagged ? 'tp' : 'fn') : (flagged ? 'fp' : 'tn'), detail: r.detail };
191
+ units.push(u); calls += r.calls; promptChars += r.promptChars;
192
+ (promptsBySystem[system] = promptsBySystem[system] || []).push(...r.prompts);
193
+ promptSizes.push(...r.prompts.map(x => String(x).length));
194
+ }
195
+ }
196
+ if (blocked && units.length === 0) { out.push({ system, status: 'blocked', reason: blocked }); continue; }
197
+ if (errors.length) { out.push({ system, status: 'error', reason: `${errors.length} case run(s) failed: ${errors.slice(0, 3).join('; ')}` }); continue; }
198
+ const metrics = metricsFor(units, protocolHash, system);
199
+ out.push({ system, status: 'measured', metrics, units, calls, promptChars, economics: economicsOf({ promptSizes, calls, wallMs: Date.now() - t0, metrics }) });
200
+ }
201
+ const leakage = leakageAudit(suiteDir, promptsBySystem);
202
+
203
+ let ledger = { holdoutScoredRuns: 0, devScoredRuns: 0 };
204
+ if (root) {
205
+ ledger = readLedger(root);
206
+ if (touchesHoldout) ledger.holdoutScoredRuns += 1; else ledger.devScoredRuns += 1;
207
+ ledger.runs = [...(ledger.runs || []).slice(-49), { protocolHash, split, variant, systems }];
208
+ writeStoreJson(root, ledgerPath(), ledger);
209
+ }
210
+ const replay = `agentic-security context eval --suite fixtures --split ${split} --variant ${variant} --baseline ${systems.join(',')}`;
211
+ const card = { schemaVersion: 'dci-eval-scorecard/1', synthetic: true, claim: 'machinery on synthetic cases only; no accuracy claim about real repositories', split, variant, caseCount: ids.length, hashes: { suite: protocol.suite, protocol: protocolHash }, reader: readerIdentity(), leakage, ledger: { holdoutScoredRuns: ledger.holdoutScoredRuns, devScoredRuns: ledger.devScoredRuns }, systems: out, replay };
212
+ card.hashes.scorecard = hashOf({ ...card, ledger: null, systems: card.systems.map(x => (x.economics ? { ...x, economics: { ...x.economics, wallMs: null } } : x)) });
213
+ return { ok: true, card };
214
+ }
215
+
216
+ export async function cmdEval(root, args, format) {
217
+ const suiteArg = FLAG(args, 'suite');
218
+ if (suiteArg !== undefined && suiteArg !== 'fixtures' && suiteArg !== true) { console.log("context eval: only '--suite fixtures' is available (the bundled synthetic suite)"); return 1; }
219
+ if (!fs.existsSync(path.join(DEFAULT_SUITE_DIR, 'labels.json'))) { console.log('context eval: the synthetic suite is not present in this install (it ships with the source tree)'); return 1; }
220
+ const baseline = FLAG(args, 'baseline') && FLAG(args, 'baseline') !== true ? String(FLAG(args, 'baseline')).split(',').map(s => s.trim()).filter(Boolean) : ['deterministic', 'hunt', 'dci'];
221
+ const variant = FLAG(args, 'variant') && FLAG(args, 'variant') !== true ? String(FLAG(args, 'variant')) : FROZEN_VARIANT;
222
+ const split = FLAG(args, 'split') && FLAG(args, 'split') !== true ? String(FLAG(args, 'split')) : 'dev';
223
+ const r = await evaluateSuite({ split, variant, systems: baseline, root, final: truthy(FLAG(args, 'final')) });
224
+ if (!r.ok) { console.log(`context eval: ${r.reason}`); return 1; }
225
+ const card = r.card;
226
+ const base = `reports/eval-${card.hashes.scorecard.slice(0, 16)}`;
227
+ const w = [writeStoreJson(root, `${base}.json`, card)];
228
+ const md = renderMarkdown(card);
229
+ const fileWrites = [['.md', md], ['.csv', renderCsv(card)]];
230
+ const { writeStoreFile } = await import('../store.js');
231
+ for (const [ext, text] of fileWrites) w.push(writeStoreFile(root, base + ext, text));
232
+ if (format === 'json') console.log(JSON.stringify({ ...card, files: w.map(x => x && x.ok !== false) }, null, 2));
233
+ else console.log(md + `\nWritten: .agentic-security/context-intelligence/${base}.{json,md,csv}`);
234
+ return card.leakage.length || card.systems.some(s => s.status === 'error') ? 1 : 0;
235
+ }