@clear-capabilities/agentic-security-scanner 0.195.29 → 0.195.30

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 (44) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bin/agentic-security.js +6 -3
  3. package/dist/1340.index.js +11 -6
  4. package/dist/3500.index.js +943 -0
  5. package/dist/5616.index.js +146 -19
  6. package/dist/{c06a1b4c20155c06fb48.json → 7e58a58b8e6ccdde1659.json} +6 -5
  7. package/dist/9560.index.js +29 -5
  8. package/dist/agentic-security.mjs +2 -2
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +6 -5
  11. package/src/egress/policy.js +79 -1
  12. package/src/lineage/CLAUDE.md +3 -1
  13. package/src/lineage/deployment/adapter-cloud.js +5 -2
  14. package/src/lineage/deployment/attack-paths.js +107 -30
  15. package/src/lineage/deployment/boundaries-run.js +2 -2
  16. package/src/lineage/deployment/iam-eval.js +105 -0
  17. package/src/lineage/deployment/projection.js +10 -4
  18. package/src/lineage/deployment/proof-binding.js +79 -0
  19. package/src/llm-validator/bounded-context.js +2 -1
  20. package/src/mcp/CLAUDE.md +6 -0
  21. package/src/mcp/tools.js +27 -3
  22. package/src/pipeline/evidence-record.js +1 -1
  23. package/src/posture/CLAUDE.md +2 -0
  24. package/src/posture/assurance/candidate-qualification.js +155 -0
  25. package/src/posture/assurance/release-package-cli.js +44 -2
  26. package/src/posture/auditor-walkthrough.js +2 -1
  27. package/src/posture/dnr/detection-ir.js +2 -2
  28. package/src/posture/dnr/hunt.js +22 -6
  29. package/src/posture/dnr/log-reader.js +47 -3
  30. package/src/posture/dnr/matchers.js +43 -3
  31. package/src/posture/dnr/outputs.js +16 -2
  32. package/src/posture/dnr/payload-classes.js +12 -2
  33. package/src/posture/dnr/validate.js +17 -4
  34. package/src/posture/evaluation/report.js +2 -1
  35. package/src/posture/evaluation/score.js +24 -7
  36. package/src/posture/logic-claims.js +15 -14
  37. package/src/posture/sibling-queue.js +69 -0
  38. package/src/posture/triage-memory.js +106 -26
  39. package/src/posture/verification-separation.js +55 -7
  40. package/src/report/index.js +4 -0
  41. package/src/runScan.js +5 -0
  42. package/src/scope/flags.js +1 -0
  43. package/src/scope/inventory.js +17 -1
  44. package/src/scope/release-scope.js +80 -0
@@ -40,7 +40,7 @@
40
40
  // lenses live here in deterministic code rather than in the agent's prompt: a
41
41
  // reviewer asked to double-check itself is the same party voting twice.
42
42
 
43
- import { recordProducer, recordVerdict, consensusOf } from './verification-separation.js';
43
+ import { recordProducer, recordVerdict, consensusOf, refutationRemoves } from './verification-separation.js';
44
44
  import { inferredInvariant } from './invariants/lifecycle.js';
45
45
 
46
46
  export const PRODUCER = 'agent:logic-reviewer';
@@ -132,7 +132,7 @@ export function verifyLogicClaim(claim, fileContents) {
132
132
  // ── citation ──────────────────────────────────────────────────────────────
133
133
  if (content === null) {
134
134
  recordVerdict(finding, {
135
- verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted',
135
+ verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted', evidence: { kind: 'deterministic' },
136
136
  reason: `no file '${claim.file}' was scanned, so the cited location does not exist`,
137
137
  });
138
138
  finding.consensus = consensusOf(finding);
@@ -143,12 +143,12 @@ export function verifyLogicClaim(claim, fileContents) {
143
143
  const line = Number(claim.line);
144
144
  if (!Number.isInteger(line) || line < 1 || line > total) {
145
145
  recordVerdict(finding, {
146
- verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted',
146
+ verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted', evidence: { kind: 'deterministic' },
147
147
  reason: `cited line ${claim.line} is outside ${claim.file} (${total} lines)`,
148
148
  });
149
149
  } else {
150
150
  recordVerdict(finding, {
151
- verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'upheld',
151
+ verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'upheld', evidence: { kind: 'deterministic' },
152
152
  reason: `${claim.file}:${line} exists`,
153
153
  });
154
154
  }
@@ -158,7 +158,7 @@ export function verifyLogicClaim(claim, fileContents) {
158
158
  // snippet is UNDECIDED here, not upheld — silence is not corroboration.
159
159
  if (!claim.snippet || !String(claim.snippet).trim()) {
160
160
  recordVerdict(finding, {
161
- verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided',
161
+ verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided', evidence: { kind: 'deterministic' },
162
162
  reason: 'the claim quotes no source, so there is nothing to check it against',
163
163
  });
164
164
  } else {
@@ -170,19 +170,19 @@ export function verifyLogicClaim(claim, fileContents) {
170
170
  const anywhere = _normalize(content);
171
171
  if (window.includes(want)) {
172
172
  recordVerdict(finding, {
173
- verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'upheld',
173
+ verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'upheld', evidence: { kind: 'deterministic' },
174
174
  reason: 'the quoted source appears at the cited line',
175
175
  });
176
176
  } else if (anywhere.includes(want)) {
177
177
  // Right file, wrong line. Not a fabrication, but the location is not
178
178
  // usable as-is, so it is not corroboration either.
179
179
  recordVerdict(finding, {
180
- verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided',
180
+ verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided', evidence: { kind: 'deterministic' },
181
181
  reason: 'the quoted source is in the file but not at the cited line',
182
182
  });
183
183
  } else {
184
184
  recordVerdict(finding, {
185
- verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'refuted',
185
+ verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'refuted', evidence: { kind: 'deterministic' },
186
186
  reason: 'the quoted source does not appear in the cited file',
187
187
  });
188
188
  }
@@ -193,30 +193,31 @@ export function verifyLogicClaim(claim, fileContents) {
193
193
  if (finding.kind === 'missing-authentication') {
194
194
  const hit = AUTH_HINTS.find((re) => re.test(block));
195
195
  recordVerdict(finding, hit
196
- ? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted',
196
+ ? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted', evidence: { kind: 'deterministic' },
197
197
  reason: `the handler around this line does authenticate (${hit.source})` }
198
- : { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld',
198
+ : { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld', evidence: { kind: 'deterministic' },
199
199
  reason: 'no authentication marker anywhere in the enclosing handler' });
200
200
  } else if (finding.kind === 'missing-authorization' || finding.kind === 'missing-ownership-check') {
201
201
  const hit = OWNERSHIP_HINTS.find((re) => re.test(block));
202
202
  recordVerdict(finding, hit
203
- ? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted',
203
+ ? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted', evidence: { kind: 'deterministic' },
204
204
  reason: `the handler around this line does scope the record to a user (${hit.source})` }
205
- : { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld',
205
+ : { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld', evidence: { kind: 'deterministic' },
206
206
  reason: 'no ownership scoping in the enclosing handler' });
207
207
  } else {
208
208
  // No deterministic lens exists for this kind. Said out loud rather than
209
209
  // counted as agreement — an unchecked claim and a corroborated one must
210
210
  // not read the same in the consensus.
211
211
  recordVerdict(finding, {
212
- verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'undecided',
212
+ verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'undecided', evidence: { kind: 'deterministic' },
213
213
  reason: `no deterministic lens covers claim kind '${finding.kind}'`,
214
214
  });
215
215
  }
216
216
 
217
217
  finding.consensus = consensusOf(finding);
218
218
  // Quarantine, not deletion — the same contract falsification uses.
219
- finding.quarantined = finding.consensus.verdict === 'refuted';
219
+ // threat-model FR-04.3: a critical claim is quarantined only on a refutation with sufficient independent authority
220
+ finding.quarantined = refutationRemoves(finding, finding.consensus);
220
221
  return finding;
221
222
  }
222
223
 
@@ -0,0 +1,69 @@
1
+ // Sibling review queue for a confirmed root cause (threat-model PRD FR-05.3, AC-05C).
2
+ //
3
+ // The root-cause sweep (`root-cause-sweep.js`) finds structural siblings of a confirmed finding and counts them exactly. Its
4
+ // `mitigated` count means only that a DETECTOR already reports a finding at that location: detector coverage, not a repair. This
5
+ // queue turns one sweep into reviewable items, each with the predicate that discovered it and a binding to its source line, and keeps
6
+ // six states apart:
7
+ //
8
+ // detector-reported a detector reports this location (the sweep's legacy `mitigated`)
9
+ // candidate a structural sibling nobody has looked at
10
+ // independently-confirmed a reviewer or verifier other than the producer confirmed it
11
+ // repair-proposed a fix has been proposed for it
12
+ // repaired a fix has been applied to it
13
+ // verified a replay-verified repair record for THIS sibling (its own hypothesis id) exists
14
+ //
15
+ // A state is raised only by evidence that names the sibling by its id. A verification record for one corrected sibling never closes
16
+ // another, however similar. Totals come from the sweep's exact counts; the per-state counts cover the sampled items, and `unsampled`
17
+ // is what the sample left out, so the numbers reconcile when the sweep capped its list.
18
+ import crypto from 'node:crypto';
19
+ import { validateVerificationRecord } from './assurance/verification-record.js';
20
+
21
+ const SIBLING_STATES = Object.freeze(['detector-reported', 'candidate', 'independently-confirmed', 'repair-proposed', 'repaired', 'verified']);
22
+ const RANK = Object.fromEntries(SIBLING_STATES.map((s, i) => [s, i]));
23
+ const MAX_QUEUE = 100;
24
+
25
+ /** The stable id of one sibling: its file, line and the exact text of the matched line. */
26
+ export const siblingId = (file, line, snippet) => `sibling:${crypto.createHash('sha256').update(`${file}\u0000${line}\u0000${snippet}`).digest('hex').slice(0, 24)}`;
27
+
28
+ function raise(item, state, basis) { if (RANK[state] > RANK[item.state]) { item.state = state; item.basis = basis; } }
29
+
30
+ /**
31
+ * @param {object} sweep one entry of `sweepRootCauses(...).sweeps`
32
+ * @param {{evidence?: {siblingId:string, kind:'confirmation'|'repair-proposal'|'repair'|'verification', by?:string, record?:object}[], producer?:string}} o
33
+ */
34
+ export function buildSiblingQueue(sweep, { evidence = [], producer = null } = {}) {
35
+ if (!sweep || typeof sweep !== 'object') return { ok: false, reason: 'no sweep' };
36
+ const items = (sweep.instances || []).slice(0, MAX_QUEUE).map((m) => ({
37
+ id: siblingId(m.file, m.line, m.snippet), file: m.file, line: m.line,
38
+ discovery: { fromFindingId: sweep.fromFindingId ?? null, sinkPattern: sweep.sinkPattern ?? null, sourcePattern: sweep.sourcePattern ?? null },
39
+ state: m.status === 'mitigated-or-known' ? 'detector-reported' : 'candidate',
40
+ basis: m.status === 'mitigated-or-known' ? 'a detector reports a finding at this location (detector coverage, not a repair)' : 'structural sibling of the confirmed root cause',
41
+ rejected: [],
42
+ }));
43
+ const byId = new Map(items.map((it) => [it.id, it]));
44
+ for (const e of evidence) {
45
+ const it = e && byId.get(e.siblingId);
46
+ if (!it) continue; // evidence for a sibling not in this queue changes nothing here
47
+ if (e.kind === 'confirmation') {
48
+ if (!e.by || (producer && e.by === producer)) { it.rejected.push({ kind: e.kind, reason: 'a confirmation must come from a party other than the producer' }); continue; }
49
+ raise(it, 'independently-confirmed', `confirmed by ${String(e.by).slice(0, 60)}`);
50
+ } else if (e.kind === 'repair-proposal') raise(it, 'repair-proposed', 'a repair was proposed');
51
+ else if (e.kind === 'repair') raise(it, 'repaired', 'a repair was applied');
52
+ else if (e.kind === 'verification') {
53
+ const r = e.record;
54
+ const v = r ? validateVerificationRecord(r) : { ok: false };
55
+ if (!v.ok) { it.rejected.push({ kind: e.kind, reason: 'the verification record failed validation' }); continue; }
56
+ if (r.hypothesisId !== it.id) { it.rejected.push({ kind: e.kind, reason: 'the record verifies another sibling' }); continue; }
57
+ if (!r.repair || r.repair.status !== 'replay-verified') { it.rejected.push({ kind: e.kind, reason: 'the record is not a replay-verified repair' }); continue; }
58
+ raise(it, 'verified', `replay-verified repair record ${String(r.id).slice(0, 24)}`);
59
+ }
60
+ }
61
+ const counts = Object.fromEntries(SIBLING_STATES.map((s) => [s, 0]));
62
+ for (const it of items) counts[it.state]++;
63
+ const found = Number(sweep.found ?? items.length);
64
+ return {
65
+ ok: true, fromFindingId: sweep.fromFindingId ?? null, items,
66
+ totals: { found, detectorReported: Number(sweep.mitigated ?? 0), candidatesAtDiscovery: Number(sweep.candidates ?? 0), sampled: items.length, unsampled: Math.max(0, found - items.length), byState: counts },
67
+ note: 'detector-reported means a detector covers the location; only a verified item has a replay-verified repair of its own',
68
+ };
69
+ }
@@ -17,9 +17,18 @@
17
17
  // why") rather than on calibration math.
18
18
  //
19
19
  // Opt-out: AGENTIC_SECURITY_NO_TRIAGE_MEMORY=1
20
+ //
21
+ // Threat-model PRD FR-05 (TM-07): a family/directory bucket, and any legacy free-text record, is ADVISORY history. It is attached to
22
+ // a finding so a reviewer can see it, and it never changes the finding's confidence. Only an EXACT decision (the same stable finding
23
+ // id in the same file) applies, and for a critical or high finding it also needs a named reviewer, an expiry that has not passed, a
24
+ // scope that matches this scan, an unchanged source fingerprint and a signature made with this install's key. Each finding's
25
+ // `pastDecision.status` says which: applied, advisory, expired, stale, out-of-scope or rejected. The free-text reason stays in the
26
+ // memory file and is never copied into a report.
20
27
 
21
28
  import * as fs from 'node:fs';
22
29
  import * as path from 'node:path';
30
+ import * as crypto from 'node:crypto';
31
+ import { signLastScan } from './integrity.js';
23
32
 
24
33
  import { stateDir, stateWritesEnabled } from './state-dir.js';
25
34
  const MEMORY_FILE = 'triage-memory.jsonl';
@@ -40,11 +49,15 @@ function _bucketKey(finding) {
40
49
  * triage-memory.jsonl (structured). Idempotent — repeated calls add a
41
50
  * single new line; existing entries are not deduped (history is a feature).
42
51
  */
43
- export function recordDecision(scanRoot, finding, decision, reason) {
52
+ export function recordDecision(scanRoot, finding, decision, reason, opts = {}) {
44
53
  if (!scanRoot || !finding || !decision) return null;
45
54
  if (!['wont-fix', 'false-positive'].includes(decision)) return null;
46
55
  if (!stateWritesEnabled()) return false;
47
56
  try { fs.mkdirSync(_stateDir(scanRoot), { recursive: true }); } catch {}
57
+ // a retried call with the same decision for the same finding adds nothing (idempotent under retry)
58
+ const sid = finding.stableId || finding.id || null;
59
+ const prior = loadMemory(scanRoot).find((e) => e.stableId && e.stableId === sid && e.decision === decision && e.file === (finding.file || finding.file_path || '') && (e.reviewer || null) === (opts.reviewer || null) && (e.expires || null) === (opts.expires || null));
60
+ if (prior) return prior;
48
61
 
49
62
  const entry = {
50
63
  at: new Date().toISOString(),
@@ -58,7 +71,14 @@ export function recordDecision(scanRoot, finding, decision, reason) {
58
71
  file: finding.file || finding.file_path || '',
59
72
  line: finding.line || 0,
60
73
  id: finding.id || finding.stableId || null,
74
+ // exact binding (FR-05.1): the stable id, the source fingerprint the decision was made on, and its scope, reviewer and expiry
75
+ stableId: sid,
76
+ fingerprint: findingFingerprint(finding),
77
+ ...(opts.reviewer ? { reviewer: String(opts.reviewer).slice(0, 120) } : {}),
78
+ ...(opts.expires ? { expires: String(opts.expires) } : {}),
79
+ ...(opts.scope ? { scope: pickScope(opts.scope) } : {}),
61
80
  };
81
+ if (entry.reviewer && entry.expires) entry.sig = signEntry(entry);
62
82
  try { fs.appendFileSync(_memPath(scanRoot), JSON.stringify(entry) + '\n'); } catch {}
63
83
 
64
84
  // Narrative entry in AGENTS.md — short, human-readable, surfaced at SessionStart.
@@ -67,7 +87,7 @@ export function recordDecision(scanRoot, finding, decision, reason) {
67
87
  `- Finding: ${entry.vuln || entry.family || 'unknown'} at ${entry.file}:${entry.line}`,
68
88
  `- Bucket: \`${entry.bucket}\``,
69
89
  reason ? `- Why: ${entry.reason}` : null,
70
- `- Future scans should treat similar findings under this bucket as already-reviewed.`,
90
+ `- Advisory only: similar findings under this bucket are NOT reviewed by this decision; each new finding is judged on its own.`,
71
91
  '',
72
92
  ].filter(Boolean).join('\n');
73
93
  try { fs.appendFileSync(_agentsPath(scanRoot), '\n' + narrative); } catch {}
@@ -78,16 +98,59 @@ export function recordDecision(scanRoot, finding, decision, reason) {
78
98
  /**
79
99
  * Load past decisions from triage-memory.jsonl.
80
100
  */
81
- export function loadMemory(scanRoot) {
101
+ export function loadMemory(scanRoot) { return loadMemoryWithDiagnostics(scanRoot).entries; }
102
+
103
+ const MAX_MEMORY_BYTES = 8 * 1024 * 1024;
104
+ /** Load past decisions with bounded diagnostics: malformed lines (a torn write) are counted and skipped, never fatal. */
105
+ export function loadMemoryWithDiagnostics(scanRoot) {
82
106
  const fp = _memPath(scanRoot);
83
- if (!fs.existsSync(fp)) return [];
107
+ const out = { entries: [], malformed: 0, unreadable: false, truncated: false };
108
+ let text;
84
109
  try {
85
- return fs.readFileSync(fp, 'utf8')
86
- .split('\n').filter(Boolean)
87
- .map(l => { try { return JSON.parse(l); } catch { return null; } })
88
- .filter(Boolean);
89
- } catch { return []; }
110
+ const st = fs.statSync(fp);
111
+ if (st.size > MAX_MEMORY_BYTES) out.truncated = true;
112
+ const fd = fs.openSync(fp, 'r');
113
+ try { const buf = Buffer.alloc(Math.min(st.size, MAX_MEMORY_BYTES)); fs.readSync(fd, buf, 0, buf.length, 0); text = buf.toString('utf8'); } finally { fs.closeSync(fd); }
114
+ } catch (e) { if (e && e.code !== 'ENOENT') out.unreadable = true; return out; }
115
+ for (const l of text.split('\n')) {
116
+ if (!l.trim()) continue;
117
+ try { const e = JSON.parse(l); if (e && typeof e === 'object' && !Array.isArray(e)) out.entries.push(e); else out.malformed++; } catch { out.malformed++; }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ // ── exact decisions (threat-model FR-05) ─────────────────────────────────────────────────────────────────────────────────────────────
123
+ const SCOPE_KEYS = ['repository', 'environment', 'tenant'];
124
+ function pickScope(s) { const o = {}; for (const k of SCOPE_KEYS) if (s && s[k]) o[k] = String(s[k]).slice(0, 120); return o; }
125
+ /** The source fingerprint a decision is bound to: the finding record's source hash when there is one, else its location and snippet. */
126
+ function findingFingerprint(f) {
127
+ const h = f.findingRecord?.sourceHash || f.record?.sourceHash || f.sourceHash;
128
+ if (h) return String(h);
129
+ return crypto.createHash('sha256').update(JSON.stringify([f.file || '', f.line || 0, f.cwe || '', String(f.snippet || '').slice(0, 400)])).digest('hex');
130
+ }
131
+ const SIGNED_FIELDS = ['decision', 'stableId', 'file', 'fingerprint', 'reviewer', 'expires', 'scope'];
132
+ function canonicalEntry(e) { return JSON.stringify(SIGNED_FIELDS.map((k) => [k, e[k] ?? null])); }
133
+ function signEntry(e) { return signLastScan(canonicalEntry(e)); }
134
+ function signatureValid(e) {
135
+ if (typeof e.sig !== 'string') return false;
136
+ const want = signEntry(e);
137
+ return want.length === e.sig.length && crypto.timingSafeEqual(Buffer.from(want, 'hex'), Buffer.from(e.sig, 'hex'));
90
138
  }
139
+ const CONSEQUENTIAL = new Set(['critical', 'high']);
140
+
141
+ /** How one exact decision bears on one finding now: applied | expired | out-of-scope | stale | advisory | rejected, with a reason code. */
142
+ function judgeExact(e, f, ctx) {
143
+ const now = ctx.now ?? Date.now();
144
+ if (e.expires && !(Date.parse(e.expires) > now)) return { status: 'expired', code: 'expired' };
145
+ for (const k of SCOPE_KEYS) if (e.scope && e.scope[k] && ctx[k] && e.scope[k] !== ctx[k]) return { status: 'out-of-scope', code: `${k}-differs` };
146
+ if (e.fingerprint && e.fingerprint !== findingFingerprint(f)) return { status: 'stale', code: 'source-changed' };
147
+ if (CONSEQUENTIAL.has(f.severity)) {
148
+ if (!e.reviewer || !e.expires) return { status: 'advisory', code: 'unreviewed-or-unexpiring' };
149
+ if (!signatureValid(e)) return { status: 'rejected', code: e.sig ? 'signature-invalid' : 'unsigned' };
150
+ }
151
+ return { status: 'applied', code: 'exact-match' };
152
+ }
153
+ const reasonDigest = (r) => (r ? crypto.createHash('sha256').update(String(r)).digest('hex').slice(0, 16) : null);
91
154
 
92
155
  /**
93
156
  * Annotator: for each finding, check if its (family, file-glob) bucket
@@ -97,11 +160,18 @@ export function loadMemory(scanRoot) {
97
160
  * Does NOT remove findings — only annotates. UI / report layer decides
98
161
  * how to display.
99
162
  */
100
- export function suppressByPastDecisions(scanRoot, findings) {
163
+ export function suppressByPastDecisions(scanRoot, findings, ctx = {}) {
101
164
  if (process.env.AGENTIC_SECURITY_NO_TRIAGE_MEMORY === '1') return { applied: 0 };
102
165
  if (!Array.isArray(findings) || findings.length === 0) return { applied: 0 };
103
- const memory = loadMemory(scanRoot);
104
- if (!memory.length) return { applied: 0 };
166
+ const diag = loadMemoryWithDiagnostics(scanRoot);
167
+ const memory = diag.entries;
168
+ if (!memory.length) return { applied: 0, diagnostics: { malformed: diag.malformed, unreadable: diag.unreadable, truncated: diag.truncated } };
169
+ const exactById = new Map();
170
+ for (const e of memory) {
171
+ if (!e.stableId) continue;
172
+ const k = `${e.stableId}\u0000${e.file || ''}`;
173
+ if (!exactById.has(k) || exactById.get(k).at < e.at) exactById.set(k, e);
174
+ }
105
175
 
106
176
  // Build bucket → most-recent decision map.
107
177
  const bucketDecision = new Map();
@@ -111,24 +181,34 @@ export function suppressByPastDecisions(scanRoot, findings) {
111
181
  }
112
182
  }
113
183
 
114
- let applied = 0;
184
+ let applied = 0; let advisory = 0;
185
+ const byStatus = {};
115
186
  for (const f of findings) {
116
- const key = _bucketKey(f);
117
- const past = bucketDecision.get(key);
187
+ const exact = exactById.get(`${f.stableId || f.id || ''}\u0000${f.file || f.file_path || ''}`);
188
+ const past = exact || bucketDecision.get(_bucketKey(f));
118
189
  if (!past) continue;
119
- f.pastDecision = {
120
- decision: past.decision,
121
- at: past.at,
122
- reason: past.reason,
123
- sameBucket: true,
124
- };
125
- // Soft demote: drop confidence and add a tag explaining why.
126
- if (typeof f.confidence === 'number') f.confidence = Math.max(0.2, f.confidence * 0.5);
190
+ const j = exact ? judgeExact(exact, f, ctx) : { status: 'advisory', code: 'family-directory-bucket' };
191
+ byStatus[j.status] = (byStatus[j.status] || 0) + 1;
192
+ // the reason is a digest here: the free text stays in the memory file and never reaches a report
193
+ f.pastDecision = { status: j.status, basis: exact ? 'exact' : 'bucket', reasonCode: j.code, decision: past.decision, at: past.at, reasonDigest: reasonDigest(past.reason), ...(exact && exact.reviewer ? { reviewer: exact.reviewer } : {}), ...(exact && exact.expires ? { expires: exact.expires } : {}), sameBucket: !exact };
127
194
  f.tags = Array.isArray(f.tags) ? f.tags : [];
128
- if (!f.tags.includes('past-decision')) f.tags.push('past-decision');
129
- applied++;
195
+ if (j.status === 'applied') {
196
+ // an exact, in-scope decision: the soft demote and tag, as before
197
+ if (typeof f.confidence === 'number') f.confidence = Math.max(0.2, f.confidence * 0.5);
198
+ if (!f.tags.includes('past-decision')) f.tags.push('past-decision');
199
+ applied++;
200
+ } else {
201
+ if (!f.tags.includes('past-decision-advisory')) f.tags.push('past-decision-advisory');
202
+ advisory++;
203
+ }
130
204
  }
131
- return { applied, total: findings.length };
205
+ return { applied, advisory, byStatus, total: findings.length, diagnostics: { malformed: diag.malformed, unreadable: diag.unreadable, truncated: diag.truncated } };
206
+ }
207
+
208
+ /** True only for a past decision that applies; an advisory, expired, stale, out-of-scope or rejected one leaves the finding open. */
209
+ export function pastDecisionApplies(pd) {
210
+ if (pd === true) return true; // a legacy boolean marker set by an older caller
211
+ return !!pd && typeof pd === 'object' && (pd.status === undefined || pd.status === 'applied');
132
212
  }
133
213
 
134
214
  /**
@@ -86,7 +86,7 @@ export function assertSeparation(finding, verifierId) {
86
86
  * nothing — when separation fails or the verdict is not one of
87
87
  * VERIFICATION_VERDICTS. Never touches severity, never removes anything.
88
88
  */
89
- export function recordVerdict(finding, { verifierId, lens, verdict, reason } = {}) {
89
+ export function recordVerdict(finding, { verifierId, lens, verdict, reason, evidence } = {}) {
90
90
  if (!finding || typeof finding !== 'object') return _refuse('no finding');
91
91
  if (!lens) return _refuse('no lens');
92
92
  if (!VERIFICATION_VERDICTS.includes(verdict)) {
@@ -100,6 +100,7 @@ export function recordVerdict(finding, { verifierId, lens, verdict, reason } = {
100
100
  lens: String(lens),
101
101
  verdict,
102
102
  ...(reason ? { reason: String(reason) } : {}),
103
+ ...(evidence && typeof evidence === 'object' ? { evidence: _evidence(evidence) } : {}),
103
104
  };
104
105
  const list = finding.verification.verdicts;
105
106
  const i = list.findIndex(v => v.verifierId === entry.verifierId && v.lens === entry.lens);
@@ -107,10 +108,29 @@ export function recordVerdict(finding, { verifierId, lens, verdict, reason } = {
107
108
  return { ok: true, recorded: entry };
108
109
  }
109
110
 
111
+ // Threat-model PRD FR-04.3 (TM-06): what a verdict rests on. Votes that share an observation, a generator or a lineage are ONE piece of
112
+ // evidence however many lenses they come through; a verdict with none declared counts as its verifier's own. `kind` says what sort of
113
+ // evidence it is: 'effect' (an observed effect), 'deterministic' (a check of the code itself) or 'reasoning' (an argument, a model).
114
+ const EVIDENCE_KINDS = new Set(['effect', 'deterministic', 'reasoning']);
115
+ function _evidence(e) {
116
+ const out = {};
117
+ for (const k of ['observationId', 'generator', 'lineage']) if (e[k]) out[k] = String(e[k]).slice(0, 120);
118
+ if (EVIDENCE_KINDS.has(e.kind)) out.kind = e.kind;
119
+ return out;
120
+ }
121
+ function _groupKey(v) {
122
+ const e = v.evidence || {};
123
+ return e.observationId ? `observation:${e.observationId}` : e.lineage ? `lineage:${e.lineage}` : e.generator ? `generator:${e.generator}` : `verifier:${v.verifierId}`;
124
+ }
125
+
110
126
  /**
111
- * Majority across every recorded verdict. Ties — including "no verdicts at
112
- * all" — are 'undecided'. `lenses` is the sorted set of perspectives that
113
- * actually voted, so a caller can see whether a verdict is one-eyed or a panel.
127
+ * Majority across INDEPENDENT evidence groups (threat-model FR-04.3). Each group (a shared observation, generator or lineage, else one
128
+ * verifier) has one vote, the majority of its own verdicts; two lenses from one verifier are one vote. Ties, including "no verdicts at
129
+ * all", are 'undecided'. The per-lens counts (`upheld`, `refuted`, `undecided`) and `lenses` are kept for compatibility and for the
130
+ * reviewer. `conflict` is true when independent groups disagree. `refutationAuthority` says whether the refutation could remove a
131
+ * critical finding from the actionable queue: 'sufficient' only with no conflict and at least one refuting group backed by effect or
132
+ * deterministic evidence; a correlated majority or reasoning alone is 'advisory'. A generator that produced the finding cannot
133
+ * corroborate it.
114
134
  */
115
135
  export function consensusOf(finding) {
116
136
  const list = (finding && finding.verification && Array.isArray(finding.verification.verdicts))
@@ -124,8 +144,36 @@ export function consensusOf(finding) {
124
144
  else if (v.verdict === 'refuted') refuted++;
125
145
  else if (v.verdict === 'undecided') undecided++;
126
146
  }
147
+ const producer = finding && finding.verification && finding.verification.producer;
148
+ const groups = new Map();
149
+ let selfAuthored = 0;
150
+ for (const v of list) {
151
+ if (!v || !VERIFICATION_VERDICTS.includes(v.verdict)) continue;
152
+ if (producer && v.evidence && v.evidence.generator && `detector:${v.evidence.generator}` === producer) { selfAuthored++; continue; }
153
+ const k = _groupKey(v);
154
+ const g = groups.get(k) || { key: k, upheld: 0, refuted: 0, undecided: 0, kinds: new Set(), lenses: new Set() };
155
+ g[v.verdict]++; if (v.evidence && v.evidence.kind) g.kinds.add(v.evidence.kind); g.lenses.add(String(v.lens));
156
+ groups.set(k, g);
157
+ }
158
+ const vote = (g) => (g.upheld > g.refuted ? 'upheld' : g.refuted > g.upheld ? 'refuted' : 'undecided');
159
+ const independent = { upheld: 0, refuted: 0, undecided: 0, groups: groups.size };
160
+ for (const g of groups.values()) independent[vote(g)]++;
127
161
  let verdict = 'undecided';
128
- if (upheld > refuted) verdict = 'upheld';
129
- else if (refuted > upheld) verdict = 'refuted';
130
- return { verdict, upheld, refuted, undecided, lenses: [...lenses].sort() };
162
+ if (independent.upheld > independent.refuted) verdict = 'upheld';
163
+ else if (independent.refuted > independent.upheld) verdict = 'refuted';
164
+ const conflict = independent.upheld > 0 && independent.refuted > 0;
165
+ const strongRefuter = [...groups.values()].some((g) => vote(g) === 'refuted' && (g.kinds.has('effect') || g.kinds.has('deterministic')));
166
+ const refutationAuthority = verdict !== 'refuted' ? 'none' : (!conflict && strongRefuter ? 'sufficient' : 'advisory');
167
+ return {
168
+ verdict, upheld, refuted, undecided, lenses: [...lenses].sort(),
169
+ independent, conflict, refutationAuthority, ...(selfAuthored ? { selfAuthoredExcluded: selfAuthored } : {}),
170
+ groups: [...groups.values()].map((g) => ({ key: g.key, vote: vote(g), lenses: [...g.lenses].sort(), kinds: [...g.kinds].sort() })).sort((a, b) => a.key.localeCompare(b.key)),
171
+ };
172
+ }
173
+
174
+ /** Whether a refuted consensus may take a finding of this severity out of the actionable queue. Critical needs sufficient authority. */
175
+ export function refutationRemoves(finding, consensus = consensusOf(finding)) {
176
+ if (!consensus || consensus.verdict !== 'refuted') return false;
177
+ if (finding && finding.severity === 'critical') return consensus.refutationAuthority === 'sufficient';
178
+ return true;
131
179
  }
@@ -776,6 +776,10 @@ export function toJSON(scan, meta={}, opts={}){
776
776
  // hand-built `scan` object in a test), same convention as the S7 fields
777
777
  // above; a real scan always computes it.
778
778
  ...(scan.scanRecord ? { scanRecord: scan.scanRecord } : {}),
779
+ // Threat-model FR-01.1: the scan scope (policy, exact totals, reasons, overrides, coverage conditions, the inventory digest and
780
+ // bounded samples) whenever the scan computed one. Absent for a legacy (non-Git-scoped) scan, which therefore cannot satisfy a
781
+ // strict scope gate.
782
+ ...(scan.scope ? { scope: scan.scope } : {}),
779
783
  ...(scan.reportView ? { reportView: scan.reportView } : {}),
780
784
  ...(scan.dispositionCounts ? { dispositionCounts: scan.dispositionCounts } : {}),
781
785
  // PRD FR 05: every candidate stays discoverable. Equivalent claims grouped under a primary are listed here, and the
package/src/runScan.js CHANGED
@@ -301,11 +301,16 @@ export async function runScan(rootDir, opts = {}) {
301
301
  // The included list itself lets a later comparison tell "no longer reported because it was fixed" from "no longer reported
302
302
  // because a narrower scan did not look". Kept only when small enough not to bloat the record.
303
303
  ...(scopeInventory.included.length <= 5000 ? { included: scopeInventory.included } : { includedOmitted: true }),
304
+ // Threat-model FR-01.1: the digest covers every decision before the caps below; the lists are samples, the counts are exact.
305
+ inventoryDigest: scopeInventory.digest,
304
306
  skippedCount: scopeInventory.skipped.length,
305
307
  skippedByReason: reasons,
308
+ skippedSampled: scopeInventory.skipped.length > 500,
306
309
  skipped: scopeInventory.skipped.slice(0, 500),
307
310
  overrides: scopeInventory.overrides,
308
311
  coverageConditions: scopeInventory.skipped.filter((x) => x.coverageCondition).slice(0, 100),
312
+ // eligibility is not analysis: how many eligible files were actually read, and how many were too large to read
313
+ read: { files: Object.keys(fileContents || {}).length + Object.keys(depFileContents || {}).length, oversize: Array.isArray(sizeSkipped) ? sizeSkipped.length : 0 },
309
314
  };
310
315
  }
311
316
  // FR 10: additive evidence contract. Finding records carry the source hash and span; the scan record ties the run to a commit,
@@ -35,6 +35,7 @@ export function renderScopePreview(inv, { maxLines = 40 } = {}) {
35
35
  const p = inv.policy;
36
36
  L.push(`Scope mode: ${p.mode}${p.respectGitignore ? '' : ' (git ignore rules disabled)'}; tracked-file semantics: ${p.trackedFileSemantics ? 'yes' : 'no'}`);
37
37
  if (p.ruleDigest) L.push(`Effective ignore-rule digest: ${p.ruleDigest}`);
38
+ if (inv.digest) L.push(`Inventory digest: ${inv.digest}`);
38
39
  for (const d of p.disclosures) L.push(`Disclosure: ${d}`);
39
40
  L.push(`Included: ${inv.included.length} file(s)`);
40
41
  for (const f of inv.included.slice(0, maxLines)) L.push(` + ${f}`);
@@ -220,7 +220,23 @@ export function buildScanInventory(root, o = {}) {
220
220
  }
221
221
 
222
222
  included.sort();
223
- return { included, skipped, overrides, policy };
223
+ return { included, skipped, overrides, policy, digest: inventoryDigest({ included, skipped, overrides, policy }) };
224
+ }
225
+
226
+ /**
227
+ * Threat-model FR-01.1: a deterministic digest over the COMPLETE normalized eligibility decisions (every included path, every skipped
228
+ * path and its reason, every override, and the policy), computed before any display cap. Two scans with the same digest made the same
229
+ * eligibility decisions; an `--include-path` changes it. Eligibility is not analysis: a file can be eligible and still not be read.
230
+ */
231
+ export function inventoryDigest({ included, skipped, overrides, policy }) {
232
+ const canon = {
233
+ v: 1,
234
+ policy: { mode: policy.mode, respectGitignore: !!policy.respectGitignore, trackedFileSemantics: !!policy.trackedFileSemantics, ruleDigest: policy.ruleDigest ?? null },
235
+ included: [...included].sort(),
236
+ skipped: skipped.map((x) => `${x.path}\u0000${x.reason}`).sort(),
237
+ overrides: overrides.map((x) => `${x.path}\u0000${x.overrode}`).sort(),
238
+ };
239
+ return crypto.createHash('sha256').update(JSON.stringify(canon)).digest('hex');
224
240
  }
225
241
 
226
242
  function walkAll(root) {
@@ -0,0 +1,80 @@
1
+ // Release scope reconciliation (threat-model PRD FR-01.2, AC-01B, AC-01C).
2
+ //
3
+ // An ordinary scan reports its own scope and is never failed by this module. A QUALIFIED release claim reconciles the scan against
4
+ // what actually ships: the authoritative build or package inventory, the route manifest and the artifact's source binding. Every
5
+ // critical shipped unit and route ends in one of: covered, excluded (with the scope's reason), excepted (a scoped, independently
6
+ // verified compensating control that has not expired), unsupported or unknown. The gate is satisfied only when every critical item is
7
+ // covered or excepted and the binding is current. A report with no scope (a legacy or non-Git-scoped scan) cannot satisfy it.
8
+ //
9
+ // Pure: report and declarations in, decision out. No file is read.
10
+
11
+ const norm = (p) => String(p ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
12
+ // `/records/:id`, `/records/{id}` and `/records/<id>` are the same template
13
+ const routeKey = (method, path) => `${String(method ?? 'GET').toUpperCase()} ${String(path ?? '').replace(/(?::[A-Za-z_]\w*|\{[^}/]+\}|<[^>/]+>)/g, ':p').replace(/\/+$/, '') || '/'}`;
14
+
15
+ function exceptionFor(exceptions, kind, key, now) {
16
+ for (const e of exceptions || []) {
17
+ if (!e || e.kind !== kind || norm(e.target) !== norm(key)) continue;
18
+ const c = e.compensatingControl;
19
+ if (!c || !c.id || !c.verifiedBy || c.independent !== true) return { ok: false, reason: 'the exception names no independently verified compensating control' };
20
+ if (!e.expires || !(Date.parse(e.expires) > now)) return { ok: false, reason: e.expires ? 'the exception has expired' : 'the exception has no expiry' };
21
+ return { ok: true, control: c.id, expires: e.expires };
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /**
27
+ * @param {object} report the JSON report (`scope`, `routes`, `scanRecord`)
28
+ * @param {{shipped?:{path:string,critical?:boolean,provenance?:string}[], routes?:{method:string,path:string,critical?:boolean}[],
29
+ * artifact?:{sourceCommit?:string}|null, exceptions?:object[], now?:number}} d
30
+ */
31
+ export function evaluateReleaseScope(report, d = {}) {
32
+ const now = d.now ?? Date.now();
33
+ const reasons = [];
34
+ const items = [];
35
+ const scope = report && report.scope;
36
+ if (!scope) {
37
+ return { status: 'unresolved', reasons: [{ code: 'scope-missing', detail: 'the report carries no scan scope (a legacy or non-Git-scoped scan), so what it covered cannot be shown' }], items };
38
+ }
39
+ const included = Array.isArray(scope.included) ? new Set(scope.included.map(norm)) : null;
40
+ const skippedByPath = new Map((scope.skipped || []).map((s) => [norm(s.path), s]));
41
+ const skippedUnder = (p) => { for (const [k, s] of skippedByPath) if (k.endsWith('/') ? p.startsWith(k) : (p === k || p.startsWith(`${k}/`))) return s; return null; };
42
+
43
+ for (const u of d.shipped || []) {
44
+ const p = norm(u.path);
45
+ let outcome; let reason = null;
46
+ if (included && included.has(p)) outcome = 'covered';
47
+ else if (skippedUnder(p)) { outcome = 'excluded'; reason = skippedUnder(p).reason; }
48
+ else if (!included) { outcome = 'unknown'; reason = 'the scan recorded only counts for its included files (too many to list), so this file cannot be confirmed'; }
49
+ else { outcome = 'unknown'; reason = 'the shipped file is neither in the scan\'s inventory nor among its recorded exclusions'; }
50
+ if (outcome !== 'covered') {
51
+ const ex = exceptionFor(d.exceptions, 'file', p, now);
52
+ if (ex && ex.ok) { outcome = 'excepted'; reason = `compensating control ${ex.control} until ${ex.expires}`; }
53
+ else if (ex) reason = `${reason}; ${ex.reason}`;
54
+ }
55
+ items.push({ kind: 'file', target: p, critical: u.critical !== false, outcome, reason, provenance: u.provenance ?? 'build-inventory' });
56
+ }
57
+
58
+ const scanned = new Set((report.routes || []).filter((r) => r && r.path && r.path !== '(file-based)').map((r) => routeKey(r.method, r.path)));
59
+ for (const r of d.routes || []) {
60
+ const key = routeKey(r.method, r.path);
61
+ let outcome = scanned.has(key) ? 'covered' : 'unknown';
62
+ let reason = outcome === 'covered' ? null : 'no route in the scanned code matches this manifest entry';
63
+ if (outcome !== 'covered') {
64
+ const ex = exceptionFor(d.exceptions, 'route', key, now);
65
+ if (ex && ex.ok) { outcome = 'excepted'; reason = `compensating control ${ex.control} until ${ex.expires}`; }
66
+ else if (ex) reason = `${reason}; ${ex.reason}`;
67
+ }
68
+ items.push({ kind: 'route', target: key, critical: r.critical !== false, outcome, reason, provenance: 'route-manifest' });
69
+ }
70
+
71
+ const source = report.scanRecord && report.scanRecord.source;
72
+ if (d.artifact && d.artifact.sourceCommit) {
73
+ if (!source || !source.commit) reasons.push({ code: 'binding-unknown', detail: 'the report records no source commit, so the artifact cannot be bound to it' });
74
+ else if (source.commit !== d.artifact.sourceCommit) reasons.push({ code: 'stale-binding', detail: `the artifact was built from ${String(d.artifact.sourceCommit).slice(0, 12)} but the scan was of ${String(source.commit).slice(0, 12)}` });
75
+ else if (source.dirty) reasons.push({ code: 'dirty-input', detail: 'the scanned tree had uncommitted changes, so it is not exactly the artifact\'s source' });
76
+ } else reasons.push({ code: 'artifact-unbound', detail: 'no artifact source commit was declared, so the scan is not bound to what ships' });
77
+
78
+ for (const it of items) if (it.critical && it.outcome !== 'covered' && it.outcome !== 'excepted') reasons.push({ code: `${it.kind}-${it.outcome}`, detail: `${it.target}: ${it.reason}` });
79
+ return { status: reasons.length ? 'unresolved' : 'satisfied', reasons, items, inventoryDigest: scope.inventoryDigest ?? null };
80
+ }