@clear-capabilities/agentic-security-scanner 0.195.21 → 0.195.23

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 (46) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +5 -2
  3. package/dist/{7feb08356355c30181a1.json → 01087ded263953062654.json} +3 -3
  4. package/dist/550.index.js +9 -8
  5. package/dist/5620.index.js +59 -10
  6. package/dist/5937.index.js +88 -16
  7. package/dist/7838.index.js +32 -5
  8. package/dist/9441.index.js +50 -7
  9. package/dist/9824.index.js +19 -13
  10. package/dist/agentic-security.mjs +2 -2
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/package.json +3 -3
  13. package/src/discovery/CLAUDE.md +4 -0
  14. package/src/discovery/detector-spec-synthesis.js +203 -0
  15. package/src/discovery/llm-invoke.js +3 -3
  16. package/src/egress/transport.js +148 -0
  17. package/src/engine.js +9 -0
  18. package/src/integrations/index.js +18 -9
  19. package/src/llm-validator/index.js +4 -2
  20. package/src/llm-validator/local-endpoint.js +4 -1
  21. package/src/llm-validator/ollama-provider.js +8 -6
  22. package/src/pipeline/scan-health.js +15 -2
  23. package/src/posture/CLAUDE.md +1 -0
  24. package/src/posture/adversary-agent.js +27 -10
  25. package/src/posture/assurance/config.js +2 -0
  26. package/src/posture/assurance/decision.js +45 -0
  27. package/src/posture/assurance/rollout.js +3 -0
  28. package/src/posture/custom-rules.js +13 -1
  29. package/src/posture/fleet/cli.js +2 -4
  30. package/src/posture/fleet/fleet-manifest.js +4 -1
  31. package/src/posture/fleet/job-runner.js +28 -4
  32. package/src/posture/fleet/org-health.js +23 -0
  33. package/src/posture/fleet/scan-cache.js +1 -1
  34. package/src/posture/flow-narration.js +10 -8
  35. package/src/posture/llm-redteam.js +3 -1
  36. package/src/posture/provenance/coordinator.js +8 -0
  37. package/src/posture/provenance/origin-resolver.js +17 -4
  38. package/src/posture/rule-compiler/cli.js +56 -8
  39. package/src/posture/rule-compiler/corpus.js +10 -2
  40. package/src/posture/rule-compiler/ir.js +10 -1
  41. package/src/posture/rule-compiler/promotion.js +12 -2
  42. package/src/posture/rule-compiler/regression.js +1 -1
  43. package/src/posture/rule-compiler/store.js +27 -7
  44. package/src/posture/strategic-metrics.js +50 -7
  45. package/src/report/index.js +7 -6
  46. package/src/sca/llm-function-extract.js +17 -13
@@ -206,6 +206,14 @@ function describePartial(isScaLike, reason) {
206
206
  reasons: ['rename_detected_not_followed'],
207
207
  };
208
208
  }
209
+ // GA-05: the parent of the candidate commit could not be inspected (the replay there failed), so absence in it is unproven.
210
+ // This is not "we could not see far enough" (shallow) and not "it was never true" (never confirmed): it is "we do not know".
211
+ if (reason === 'parent-not-inspectable') {
212
+ return {
213
+ limitation: 'a candidate commit was found but the replay in its parent failed, so absence there is unproven; the introducing commit is the earliest observable, not a verified boundary',
214
+ reasons: ['parent_not_inspectable'],
215
+ };
216
+ }
209
217
  return {
210
218
  limitation: reason
211
219
  ? `earliest observable — history could not confirm a verified parent boundary (${reason})`
@@ -25,6 +25,9 @@
25
25
 
26
26
  import { candidateCommitsForLine, candidateCommitsForFiles, commitMeta, getBlobAtCommit, isAncestor } from './git-evidence.js';
27
27
  import { replayAt } from './predicate-replay.js';
28
+
29
+ /** Test seam: a replacement `replayAt` (same signature) so a replay failure in a parent commit can be produced on demand. */
30
+ export const _replayHooks = { replayAt: null };
28
31
  import { PROVENANCE_METHOD } from './schema.js';
29
32
  import { checkAbsentInSomeParent, detectRevert, detectCherryPick } from './dag-walk.js';
30
33
  import { loadRepoLineage } from './repo-lineage.js';
@@ -230,7 +233,7 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
230
233
  const replayCache = new Map();
231
234
  const replay = (sha) => {
232
235
  if (replayCache.has(sha)) return replayCache.get(sha);
233
- const p = replayAt(scanRoot, sha, files, stableId, { deep: needsDeepReplay(finding) });
236
+ const p = (_replayHooks.replayAt || replayAt)(scanRoot, sha, files, stableId, { deep: needsDeepReplay(finding) });
234
237
  replayCache.set(sha, p);
235
238
  return p;
236
239
  };
@@ -286,13 +289,23 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
286
289
  }
287
290
 
288
291
  const presentInParent = await replay(parent);
289
- const absentInParent = !presentInParent.present;
290
- if (!absentInParent) continue; // predicate already true in parent — keep walking older candidates
292
+ if (presentInParent.present) continue; // predicate already true in parent — keep walking older candidates
293
+ // GA-05: `present: false` is proof of absence only when the parent WAS inspected and the predicate did not reproduce
294
+ // (`stableId-not-reproduced`), or the file did not exist there at all (`no-files-at-commit`: a file that is new in this
295
+ // commit). A parent the replay could not inspect (`replay-error`) proves nothing, so the boundary is not verified and the
296
+ // answer is `partial` with that reason, never `complete`.
297
+ if (presentInParent.reason === 'replay-error') {
298
+ return {
299
+ status: 'partial', reason: 'parent-not-inspectable', commitsConsidered,
300
+ findingOrigin: originFrom(meta, { absentInParents: [] }),
301
+ method: PROVENANCE_METHOD.SEMANTIC_REPLAY, parentBoundaryVerified: false,
302
+ };
303
+ }
291
304
 
292
305
  return {
293
306
  status: 'complete', method: PROVENANCE_METHOD.SEMANTIC_REPLAY, commitsConsidered,
294
307
  findingOrigin: originFrom(meta, { absentInParents: [parent] }),
295
- parentBoundaryVerified: true,
308
+ parentBoundaryVerified: true, absenceEvidence: presentInParent.reason,
296
309
  };
297
310
  }
298
311
 
@@ -8,9 +8,11 @@
8
8
  // model call — this is the deterministic half; a model may PROPOSE the candidate pattern this CLI is handed, but
9
9
  // it never decides promotion).
10
10
  import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { execFileSync } from 'node:child_process';
11
13
  import { statePath } from '../state-dir.js';
12
14
  import { packTrust, signPublishedRule } from './store.js';
13
- import { sharedFalsePositiveCorpus, falsePositiveHits, regressRules } from './regression.js';
15
+ import { sharedFalsePositiveCorpus, falsePositiveHits, regressRules, promotedRuleIds } from './regression.js';
14
16
  import { draftRuleIR, validateRuleIR, ruleIRDigest } from './ir.js';
15
17
  import { checkEligibility } from './eligibility.js';
16
18
  import { buildCorpus } from './corpus.js';
@@ -35,6 +37,12 @@ function readJsonArg(pathOrInline) {
35
37
 
36
38
  export async function cmdRules(args, root) {
37
39
  const sub = args._[1];
40
+ // GA-07: a ledger that failed its integrity check (broken chain, bad or missing signature) refuses EVERY command, not only the
41
+ // ones that change state; nothing below can read a tampered history as fact or write over it.
42
+ if (['synthesize', 'evaluate', 'promote', 'status', 'rollback', 'cost', 'regress'].includes(sub)) {
43
+ const guard = loadLedger(root);
44
+ if (guard.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${guard.tamperReason}).`); return 4; }
45
+ }
38
46
  if (sub === 'synthesize') return cmdSynthesize(args, root);
39
47
  if (sub === 'evaluate') return cmdEvaluate(args, root);
40
48
  if (sub === 'promote') return cmdPromote(args, root);
@@ -42,7 +50,7 @@ export async function cmdRules(args, root) {
42
50
  if (sub === 'rollback') return cmdRollback(args, root);
43
51
  if (sub === 'cost') return cmdCost(args, root);
44
52
  if (sub === 'regress') return cmdRegress(args, root);
45
- console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> | status [rule-id] | rollback <rule-id> | cost <rule-id>');
53
+ console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> --reviewed-by <person> [--scope <text>] | status [rule-id] | rollback <rule-id> | cost <rule-id> | regress [rule-id]');
46
54
  return 4;
47
55
  }
48
56
 
@@ -61,7 +69,7 @@ async function cmdSynthesize(args, root) {
61
69
  // The candidate pattern is the model's or the person's, when they gave one. Without one, or with --auto, the synthesis loop proposes
62
70
  // from the vulnerable and patched code, scores each attempt on the real corpus and its mutations, and revises (see proposal.js).
63
71
  let finalSpec = spec; let synthesis = null;
64
- const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [] };
72
+ const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [], independentVulnerableCode: spec.independentVulnerableCode ?? [], independentCleanCode: spec.independentCleanCode ?? [] };
65
73
  const leak = checkLeakage(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: spec.fileNames ?? [] });
66
74
  if (!leak.ok) { console.error(`Refusing: the material for this rule shows benchmark or answer-key signals (${leak.offendingTerms.join('; ')}). Nothing was written.`); return 4; }
67
75
  if (args.flags.auto || (!spec.match && !spec.sink)) {
@@ -120,7 +128,7 @@ async function cmdEvaluate(args, root) {
120
128
  originatingFinding: ir.provenance.originatingFindingId, originatingCommit: ir.provenance.originatingCommit, evidenceState: ir.provenance.evidenceState,
121
129
  generatingModel: ir.provenance.model, promptConfigVersion: ir.provenance.promptConfigVersion,
122
130
  positiveCorpusHash: corpusHash(corpus.positives), negativeCorpusHash: corpusHash(corpus.negatives),
123
- benchmarkResults: { precision: gate.evaluation.score.precision, recall: gate.evaluation.score.recall, tp: gate.evaluation.score.tp, fp: gate.evaluation.score.fp, fn: gate.evaluation.score.fn, tn: gate.evaluation.score.tn, protectedFailures: gate.evaluation.score.protectedFailures, thresholds: gate.evaluation.thresholds, gate: gate.pass ? 'passed' : 'failed', reasons: gate.reasons },
131
+ benchmarkResults: { precision: gate.evaluation.score.precision, recall: gate.evaluation.score.recall, tp: gate.evaluation.score.tp, fp: gate.evaluation.score.fp, fn: gate.evaluation.score.fn, tn: gate.evaluation.score.tn, protectedFailures: gate.evaluation.score.protectedFailures, independent: gate.independent, thresholds: gate.evaluation.thresholds, gate: gate.pass ? 'passed' : 'failed', reasons: gate.reasons },
124
132
  leakageCheck: leakage, mutatorCoverage: mutatorCoverage(corpusSpec.originalCode), discoveryCost: corpusSpec.discoveryCost ?? prev?.discoveryCost ?? null,
125
133
  promotionEvent: prev?.promotionEvent ?? null, evaluatedAt: new Date().toISOString(),
126
134
  });
@@ -136,23 +144,63 @@ async function cmdEvaluate(args, root) {
136
144
  function isStr(v) { return typeof v === 'string' && v.length > 0; }
137
145
  const fmtPct = (n) => (n == null ? 'unmeasured' : `${(n * 100).toFixed(1)}%`);
138
146
 
147
+ /** Does this commit exist in the project's history? `true`, `false`, or `null` when the project is not a git repository. */
148
+ function commitExists(root, sha) {
149
+ if (!fs.existsSync(path.join(root, '.git'))) return null;
150
+ try { execFileSync('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: root, stdio: 'ignore' }); return true; } catch { return false; }
151
+ }
152
+
153
+ const NOT_A_REVIEWER = /^(?:agent|model|auto|bot|llm|ai)$/i;
154
+
155
+ // Promotion is a reviewed, human decision (GA-07). It needs the reviewer's name, a rule that is exactly the one that was evaluated, a
156
+ // provenance that checks out, and no conflict with a rule that already exists; a model's recommendation on its own promotes nothing.
139
157
  async function cmdPromote(args, root) {
140
158
  const id = args._[2];
141
- if (!id) { console.error('usage: rules promote <rule-id>'); return 4; }
159
+ if (!id) { console.error('usage: rules promote <rule-id> --reviewed-by <person> [--scope <text>] [--reason <text>]'); return 4; }
160
+ const reviewer = args.flags['reviewed-by'];
161
+ if (!isStr(reviewer) || NOT_A_REVIEWER.test(reviewer.trim())) {
162
+ console.error('usage: rules promote <rule-id> --reviewed-by <person>. A promotion records the person who reviewed the rule and its scope; a model or agent recommendation is not a review, so nothing is promoted without a named reviewer.');
163
+ return 4;
164
+ }
142
165
  const ledger = loadLedger(root);
143
166
  if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
144
167
  if (stateOf(ledger, id) !== 'VALIDATED') { console.error(`Rule ${id} is not VALIDATED (run rules evaluate first); refusing to promote.`); return 4; }
145
168
  const ir = loadIR(root, id);
146
169
  if (!ir) { console.error(`No rule IR found for ${id}.`); return 4; }
170
+ // the rule promoted must be the rule evaluated: an IR edited after `rules evaluate` has no evidence
171
+ const prov = loadProvenance(root, id);
172
+ if (!prov) { console.error(`Promotion refused: no evaluation record for ${id}; run rules evaluate first.`); return 4; }
173
+ if (prov.ruleHash !== ruleIRDigest(ir)) { console.error(`Promotion refused: the rule ${id} changed after it was evaluated (evaluated ${String(prov.ruleHash).slice(0, 12)}, now ${ruleIRDigest(ir).slice(0, 12)}); run rules evaluate again.`); return 4; }
174
+ // a named originating commit must be real history, not a typed-in string
175
+ const commit = ir.provenance?.originatingCommit;
176
+ if (commit) {
177
+ const exists = commitExists(root, commit);
178
+ if (exists === false) { console.error(`Promotion refused: originating commit ${commit} is not in this repository's history (invalid provenance).`); return 4; }
179
+ if (exists === null) { console.error(`Promotion refused: originating commit ${commit} cannot be verified because this project is not a git repository.`); return 4; }
180
+ }
181
+ // conflicts: a hand-written rule file with the same id, or a promoted rule with the identical match
182
+ const live = statePath(root, 'rules', `${id}.yml`);
183
+ let firstLine = null; // null: no file there (or unreadable, which is treated as absent below only when the read fails with ENOENT)
184
+ try { firstLine = fs.readFileSync(live, 'utf8').split('\n')[0]; } catch (e) { if (e.code !== 'ENOENT') firstLine = ''; }
185
+ if (firstLine !== null && !firstLine.startsWith('# Compiled by the rule compiler')) { console.error(`Promotion refused: a rule file that the compiler did not write already exists at ${live}; choose another rule id or remove that file deliberately.`); return 4; }
186
+ for (const other of promotedRuleIds(ledger)) {
187
+ if (other === id) continue;
188
+ const oir = loadIR(root, other);
189
+ if (oir && JSON.stringify(oir.match) === JSON.stringify(ir.match) && JSON.stringify(oir.languages) === JSON.stringify(ir.languages)) {
190
+ console.error(`Promotion refused: ${id} duplicates the promoted rule ${other} (identical match on the same languages); roll one back or narrow this one.`);
191
+ return 4;
192
+ }
193
+ }
147
194
  // other rules may have been promoted since this one was evaluated: hold it to the false-positive corpus as it is NOW
148
195
  const fpNow = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
149
196
  if (fpNow.length) { console.error(`Promotion refused: ${id} fires on ${fpNow.length} item(s) of the shared false-positive corpus (${fpNow.slice(0, 3).map((h) => h.label).join(', ')}). Re-evaluate or narrow the rule.`); return 1; }
150
197
  const pub = publishCustomRule(root, ir);
151
198
  if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
152
- const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', actor: args.flags.actor || 'operator' });
199
+ const scope = isStr(args.flags.scope) ? args.flags.scope : null;
200
+ const review = { reviewer: reviewer.trim(), scope, independent: prov.benchmarkResults?.independent ?? null };
201
+ const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', evidence: review, actor: args.flags.actor || reviewer.trim() });
153
202
  saveLedger(root, ledger);
154
- const prov = loadProvenance(root, id);
155
- if (prov) saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || 'operator', reason: args.flags.reason || 'promoted', ruleHash: ruleIRDigest(ir) } });
203
+ saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || reviewer.trim(), reviewer: reviewer.trim(), scope, reason: args.flags.reason || 'promoted', ruleHash: ruleIRDigest(ir) } });
156
204
  console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
157
205
  // Publishing is not trusting. Rule packs are signature-checked so a rule dropped into a repository cannot run in someone's CI; sign this
158
206
  // one with the operator's key when there is one, and say plainly whether the scanner will load it.
@@ -20,11 +20,15 @@ function toRuntimeRule(ir) {
20
20
 
21
21
  const EXT = { javascript: 'x.js', typescript: 'x.ts', python: 'x.py', go: 'x.go', ruby: 'x.rb', java: 'x.java', rust: 'x.rs', csharp: 'x.cs', php: 'x.php', any: 'x.txt' };
22
22
 
23
- export function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [] } = {}) {
23
+ export function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [], independentVulnerableCode = [], independentCleanCode = [] } = {}) {
24
24
  const positives = [{ label: 'original', code: originalCode }];
25
25
  for (const v of generateVariants(originalCode)) positives.push({ label: `sibling:${v.mutator}`, code: v.code });
26
+ // Independent examples (GA-07): vulnerable and safe code that is NOT the source finding and NOT a mutation of it, supplied by the
27
+ // reviewer from other places. They are never mutated, so they score as what they are: evidence from outside the rule's own material.
28
+ independentVulnerableCode.forEach((code, i) => positives.push({ label: `independent-vulnerable-${i}`, code }));
26
29
 
27
30
  const negatives = [];
31
+ independentCleanCode.forEach((code, i) => negatives.push({ label: `independent-clean-${i}`, code }));
28
32
  if (patchedCode) {
29
33
  negatives.push({ label: 'patched', code: patchedCode });
30
34
  for (const v of generateVariants(patchedCode)) negatives.push({ label: `patched-sibling:${v.mutator}`, code: v.code });
@@ -60,6 +64,10 @@ export function scoreRule(ir, corpus) {
60
64
  const recall = corpus.positives.length === 0 ? null : tp / corpus.positives.length;
61
65
  // "Protected negative failures" per PRD 3: a rule firing on a sanitizer/guard-carrying variant specifically.
62
66
  const protectedFailures = negResults.filter((n) => n.label.startsWith('protected-') && n.fired).length;
67
+ // Independent evidence, counted apart: how many examples from outside the finding's own material there were, and how they scored.
68
+ const indPos = posResults.filter((p) => p.label.startsWith('independent-vulnerable-'));
69
+ const indNeg = negResults.filter((n) => n.label.startsWith('independent-clean-'));
70
+ const independent = { positives: indPos.length, negatives: indNeg.length, detected: indPos.filter((p) => p.fired).length, falseAlarms: indNeg.filter((n) => n.fired).length };
63
71
 
64
- return { precision, recall, tp, fp, tn, fn, protectedFailures, positives: posResults, negatives: negResults };
72
+ return { precision, recall, tp, fp, tn, fn, protectedFailures, independent, positives: posResults, negatives: negResults };
65
73
  }
@@ -74,9 +74,18 @@ export function validateRuleIR(ir) {
74
74
  return errs;
75
75
  }
76
76
 
77
+ /** JSON with every object's keys sorted at every depth, so the digest does not depend on insertion order. */
78
+ function stableStringify(v) {
79
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
80
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(',')}}`;
81
+ return JSON.stringify(v);
82
+ }
83
+
77
84
  export function ruleIRDigest(ir) {
78
85
  const { state: _s, ...rest } = ir;
79
- return sha256(JSON.stringify(rest, Object.keys(rest).sort()));
86
+ // Until 0.195.23 this passed the TOP-LEVEL key list as the JSON replacer, which also filters nested objects: `match.pattern` and
87
+ // every structural regex were serialized as `{}`, so the digest never covered the one thing a rule is. Sorted at every depth now.
88
+ return sha256(stableStringify(rest));
80
89
  }
81
90
 
82
91
  /** Build a DRAFT rule IR. Caller supplies the candidate pattern — this module never invents one. */
@@ -4,7 +4,11 @@
4
4
  // actually recorded, and nothing in this module writes to it.
5
5
  import { scoreRule } from './corpus.js';
6
6
 
7
- export const PROMOTION_DEFAULTS = Object.freeze({ minPrecision: 0.95, minRecall: 0.80, maxProtectedFailures: 0 });
7
+ // Since 0.195.23 (GA-07) promotion also needs independent evidence: at least one vulnerable and one safe example that are not the
8
+ // source finding or a mutation of it, and the rule must detect every independent vulnerable example and stay quiet on every
9
+ // independent safe one. The synthesis loop still scores attempts without them (`evaluateRule`); only the promotion decision
10
+ // (`gatePromotion`) requires them, so a candidate can be proposed before a reviewer has gathered the outside examples.
11
+ export const PROMOTION_DEFAULTS = Object.freeze({ minPrecision: 0.95, minRecall: 0.80, maxProtectedFailures: 0, minIndependentVulnerable: 1, minIndependentClean: 1 });
8
12
 
9
13
  // Deliberately independent of posture/evaluation/custody.js: that module's term list and guard are reserved for
10
14
  // the evaluation custodian's sealed-label boundary (QA-002.AC03), and a test enforces that nothing outside
@@ -59,5 +63,11 @@ export function gatePromotion({ ir, corpus, leakageCheck, thresholds } = {}) {
59
63
  const evalResult = evaluateRule(ir, corpus, thresholds);
60
64
  const reasons = [...evalResult.reasons];
61
65
  if (!leakageCheck || leakageCheck.ok !== true) reasons.push(leakageCheck ? `leakage check failed: ${leakageCheck.offendingTerms.join(', ')}` : 'leakage check was not run');
62
- return { pass: reasons.length === 0, reasons, evaluation: evalResult };
66
+ const t = evalResult.thresholds;
67
+ const ind = evalResult.score.independent ?? { positives: 0, negatives: 0, detected: 0, falseAlarms: 0 };
68
+ if (ind.positives < t.minIndependentVulnerable) reasons.push(`${ind.positives} independent vulnerable example(s) beyond the source finding and its mutations (min ${t.minIndependentVulnerable}): supply independentVulnerableCode`);
69
+ else if (ind.detected < ind.positives) reasons.push(`misses ${ind.positives - ind.detected} of ${ind.positives} independent vulnerable example(s)`);
70
+ if (ind.negatives < t.minIndependentClean) reasons.push(`${ind.negatives} independent safe example(s) (min ${t.minIndependentClean}): supply independentCleanCode`);
71
+ else if (ind.falseAlarms > 0) reasons.push(`fires on ${ind.falseAlarms} of ${ind.negatives} independent safe example(s)`);
72
+ return { pass: reasons.length === 0, reasons, evaluation: evalResult, independent: ind };
63
73
  }
@@ -41,7 +41,7 @@ function operatorFalsePositives(root) {
41
41
  return out;
42
42
  }
43
43
 
44
- function promotedRuleIds(ledger) {
44
+ export function promotedRuleIds(ledger) {
45
45
  return [...new Set(ledger.entries.map((e) => e.ruleId))].filter((id) => stateOf(ledger, id) === 'PROMOTED');
46
46
  }
47
47
 
@@ -7,6 +7,7 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
9
9
  import { newLedger, verifyLedger } from './lifecycle.js';
10
+ import { signLastScan, verifyLastScan } from '../integrity.js';
10
11
  import { compileToCustomRule } from './ir.js';
11
12
  import { verifyRulePack, loadTrustedKeys, signRulePack } from '../rule-pack-signing.js';
12
13
 
@@ -15,20 +16,39 @@ const EXT = { javascript: 'js', typescript: 'ts', python: 'py', go: 'go', ruby:
15
16
 
16
17
  function ruleDir(scanRoot, id) { return statePath(scanRoot, DIR, id); }
17
18
 
18
- export function loadLedger(scanRoot) {
19
+ /**
20
+ * The lifecycle ledger is hash-chained (lifecycle.js) AND signed with the per-install HMAC key (the same key as last-scan.json),
21
+ * since 0.195.23 (GA-07): the chain alone could be recomputed by anyone who could edit the file. A ledger that fails either check,
22
+ * or that cannot be parsed, is returned WITH `tampered: true` and its reason, and `saveLedger` refuses to write it, so a bad ledger
23
+ * is never quietly replaced by a fresh one and its history lost. An unsigned ledger with entries (written before 0.195.23) is
24
+ * accepted only with AGENTIC_SECURITY_RULES_UNSIGNED=1, the same switch that accepts an unsigned rules override, and is signed on
25
+ * its next save.
26
+ */
27
+ export function loadLedger(scanRoot, env = process.env) {
19
28
  const f = statePath(scanRoot, DIR, 'ledger.json');
20
29
  if (!fs.existsSync(f)) return newLedger();
21
- try {
22
- const ledger = JSON.parse(fs.readFileSync(f, 'utf8'));
23
- const v = verifyLedger(ledger);
24
- return v.ok ? ledger : { ...newLedger(), tampered: true, tamperReason: v.reason };
25
- } catch { return newLedger(); }
30
+ let text;
31
+ try { text = fs.readFileSync(f, 'utf8'); } catch (e) { return { ...newLedger(), tampered: true, tamperReason: `cannot read the ledger: ${e.message}` }; }
32
+ let ledger;
33
+ try { ledger = JSON.parse(text); } catch (e) { return { ...newLedger(), tampered: true, tamperReason: `the ledger is not valid JSON: ${e.message}` }; }
34
+ if (!ledger || !Array.isArray(ledger.entries)) return { ...newLedger(), tampered: true, tamperReason: 'the ledger has no entries array' };
35
+ const v = verifyLedger(ledger);
36
+ if (!v.ok) return { ...ledger, tampered: true, tamperReason: v.reason };
37
+ const sig = verifyLastScan(text, `${f}.sig`);
38
+ if (sig === false) return { ...ledger, tampered: true, tamperReason: 'the ledger signature does not verify under this install key' };
39
+ if (sig === null && ledger.entries.length && env.AGENTIC_SECURITY_RULES_UNSIGNED !== '1') {
40
+ return { ...ledger, tampered: true, tamperReason: 'the ledger is unsigned (written before 0.195.23 or by another tool); set AGENTIC_SECURITY_RULES_UNSIGNED=1 once to accept and re-sign it' };
41
+ }
42
+ return ledger;
26
43
  }
27
44
 
28
45
  export function saveLedger(scanRoot, ledger) {
46
+ if (ledger?.tampered) return { ok: false, reason: `refusing to overwrite a ledger that failed its integrity check (${ledger.tamperReason})` };
29
47
  if (!ensureStateDir(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
30
48
  const f = statePath(scanRoot, DIR, 'ledger.json');
31
- return { ok: safeWriteState(f, JSON.stringify(ledger, null, 2)) };
49
+ const text = JSON.stringify(ledger, null, 2);
50
+ const ok = safeWriteState(f, text) && safeWriteState(`${f}.sig`, signLastScan(text));
51
+ return { ok };
32
52
  }
33
53
 
34
54
  export function saveIR(scanRoot, ir) {
@@ -63,7 +63,7 @@ export function evidenceConversion(findings) {
63
63
  const byState = Object.fromEntries(EVIDENCE_STATES.map((s) => [s, 0]));
64
64
  for (const f of list) byState[evidenceStateOf(f)]++;
65
65
  const converted = list.length - byState['single-signal'];
66
- return { ...rate(converted, list.length), byState, basis: 'findings that reached corroborated or stronger, over all findings' };
66
+ return { status: list.length ? 'measured' : 'unmeasured', ...rate(converted, list.length), byState, basis: 'findings that reached corroborated or stronger, over all findings of the last scan' };
67
67
  }
68
68
 
69
69
  const strongest = (a) => {
@@ -72,21 +72,52 @@ const strongest = (a) => {
72
72
  return disappeared && a.testsRan === true && a.testsOk !== false && a.pocOk === true;
73
73
  };
74
74
 
75
+ const isoOrNull = (v) => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? v : null);
76
+
77
+ /**
78
+ * One attempt per finding (GA-11): a fix tried five times is one remediation, not five, so the LATEST recorded attempt for each
79
+ * finding id is the one counted and the earlier ones are reported as `repeated`. An attempt with no finding id cannot be matched to
80
+ * another and counts on its own. Order is by `at` where every record has one, else by position in the log.
81
+ */
82
+ function dedupeAttempts(attempts) {
83
+ const list = Array.isArray(attempts) ? attempts.filter((a) => a && typeof a === 'object') : [];
84
+ const latest = new Map();
85
+ const loose = [];
86
+ list.forEach((a, i) => {
87
+ const key = typeof a.stableId === 'string' && a.stableId ? a.stableId : (typeof a.findingId === 'string' && a.findingId ? a.findingId : null);
88
+ if (!key) { loose.push(a); return; }
89
+ const prev = latest.get(key);
90
+ const newer = !prev || (isoOrNull(a.at) && isoOrNull(prev.at) ? Date.parse(a.at) >= Date.parse(prev.at) : i > prev._i);
91
+ if (newer) latest.set(key, Object.assign(Object.create(null), a, { _i: i }));
92
+ });
93
+ const kept = [...latest.values()].map((a) => { const { _i, ...rest } = a; return rest; }).concat(loose);
94
+ return { attempts: kept, recorded: list.length, repeated: list.length - kept.length };
95
+ }
96
+
97
+ /** The time span the recorded attempts cover: `from` and `to` are the earliest and latest `at`, or `null` when none carries one. */
98
+ function attemptsWindow(attempts) {
99
+ const times = (Array.isArray(attempts) ? attempts : []).map((a) => a && isoOrNull(a.at)).filter(Boolean).sort();
100
+ return { from: times[0] ?? null, to: times[times.length - 1] ?? null };
101
+ }
102
+
75
103
  /** Of the fix attempts, how many reached the strongest verification tier. A lower bound: some classes have no independent proof to run. */
76
104
  export function verifiedRemediationRate(attempts) {
77
- const list = Array.isArray(attempts) ? attempts.filter((a) => a && typeof a === 'object') : [];
105
+ const { attempts: list, recorded, repeated } = dedupeAttempts(attempts);
78
106
  const full = list.filter(strongest).length;
79
107
  const disappearedOnly = list.filter((a) => typeof a.verifiedFull !== 'boolean' && (a.rescanOk === true || (a.ok === true && a.rescanOk !== false)) && !(a.testsRan === true && a.testsOk !== false) && a.pocOk !== true).length;
108
+ const failed = list.filter((a) => a.ok === false || a.verifiedFull === false).length;
80
109
  return {
110
+ status: list.length ? 'measured' : 'unmeasured',
81
111
  ...rate(full, list.length),
82
- disappearedOnly,
83
- basis: 'attempts that passed the re-scan, the project tests and the independent attack replay, over all attempts. A lower bound where a class has no replay',
112
+ recorded, repeated, failed, disappearedOnly,
113
+ window: attemptsWindow(attempts),
114
+ basis: 'the latest attempt per finding that passed the re-scan, the project tests and the independent attack replay, over the latest attempt per finding. A lower bound where a class has no replay',
84
115
  };
85
116
  }
86
117
 
87
- /** Nothing measures it independently of the rule compiler's own corpus, so it is reported as not measured. */
118
+ /** Nothing measures it independently of the rule compiler's own corpus, so it is reported as unmeasured, with the reason. */
88
119
  export function securityLearningRate() {
89
- return { status: 'not-measured', reason: 'the rule compiler grades its rules against a corpus built from the same finding; no independent measurement of novel weaknesses converted into reusable knowledge exists' };
120
+ return { status: 'unmeasured', reason: 'the rule compiler grades its rules against a corpus built from the same finding and the reviewer-supplied independent examples; no independent longitudinal measurement of novel weaknesses converted into reusable knowledge exists' };
90
121
  }
91
122
 
92
123
  /**
@@ -95,11 +126,21 @@ export function securityLearningRate() {
95
126
  * @param {object[]} [o.fixAttempts] fix verification attempts
96
127
  */
97
128
  export function computeStrategicMetrics({ scan = null, fixAttempts = [] } = {}) {
129
+ const attempts = Array.isArray(fixAttempts) ? fixAttempts : [];
98
130
  return {
99
131
  schema: STRATEGIC_METRICS_SCHEMA, version: STRATEGIC_METRICS_VERSION,
132
+ // The scope and time basis (GA-11): these are per-scan figures for ONE scan plus the attempts recorded on this machine, never an
133
+ // organisation trend. The scan is named by its id and start time; the attempts by the span their timestamps cover.
134
+ scope: {
135
+ kind: 'per-scan',
136
+ scanId: scan && typeof scan.scanId === 'string' ? scan.scanId : null,
137
+ scanStartedAt: scan ? isoOrNull(scan.startedAt) : null,
138
+ attempts: { ...attemptsWindow(attempts), recorded: attempts.filter((a) => a && typeof a === 'object').length },
139
+ trend: 'not a trend: one scan and this machine\'s recorded attempts; compare scans by scanId to see movement',
140
+ },
100
141
  assuranceCoverage: assuranceCoverage(scan && scan.scanHealth),
101
142
  evidenceConversion: evidenceConversion(scan && scan.findings),
102
- verifiedRemediationRate: verifiedRemediationRate(fixAttempts),
143
+ verifiedRemediationRate: verifiedRemediationRate(attempts),
103
144
  securityLearningRate: securityLearningRate(),
104
145
  };
105
146
  }
@@ -109,6 +150,7 @@ const pc = (r) => (r && r.percent !== null && r.percent !== undefined ? `${r.per
109
150
  export function renderStrategicMetrics(m) {
110
151
  const ac = m.assuranceCoverage; const ec = m.evidenceConversion; const vr = m.verifiedRemediationRate;
111
152
  const L = [];
153
+ if (m.scope) L.push(`Scope: one scan${m.scope.scanId ? ` (${m.scope.scanId}${m.scope.scanStartedAt ? `, started ${m.scope.scanStartedAt}` : ''})` : ''} and ${m.scope.attempts.recorded} fix attempt(s) recorded on this machine${m.scope.attempts.from ? ` between ${m.scope.attempts.from} and ${m.scope.attempts.to}` : ''}; not a trend`);
112
154
  L.push(`Assurance Coverage: ${ac.percent === null ? 'not measurable' : `${ac.percent}% (the lower of the two components)`}${ac.scanStatus ? `; scan ${ac.scanStatus}` : ''}`);
113
155
  if (ac.files) L.push(` files: ${ac.files.numerator} of ${ac.files.denominator} completed (${pc(ac.files)}); ${ac.files.skipped} skipped, ${ac.files.unanalysed} not analysed, ${ac.files.timedOut} timed out`);
114
156
  if (ac.analyzers) L.push(` analyzers: ${ac.analyzers.numerator} of ${ac.analyzers.denominator} completed (${pc(ac.analyzers)}); ${ac.analyzers.failed} failed, ${ac.analyzers.timedOut} timed out`);
@@ -117,6 +159,7 @@ export function renderStrategicMetrics(m) {
117
159
  L.push(` ${EVIDENCE_STATES.map((s) => `${s} ${ec.byState[s]}`).join(', ')}`);
118
160
  L.push(`Verified Remediation Rate: ${vr.numerator} of ${vr.denominator} fix attempts reached the strongest verification tier (${pc(vr)})${vr.denominator ? '; a lower bound where a class has no attack replay' : ''}`);
119
161
  if (vr.disappearedOnly) L.push(` ${vr.disappearedOnly} satisfied only the re-scan, which is also what deleting the code produces`);
162
+ if (vr.repeated) L.push(` ${vr.recorded} attempt(s) recorded; ${vr.repeated} earlier attempt(s) on the same finding counted once, by the latest`);
120
163
  L.push(`Security Learning Rate: not measured. ${m.securityLearningRate.reason}`);
121
164
  return L.join('\n');
122
165
  }
@@ -5,6 +5,7 @@ import { alertFace, approveFace } from './mascot.js';
5
5
  import { SCANNER_VERSION } from '../posture/version.js';
6
6
  import { proofBlock } from '../posture/proof-artifact.js';
7
7
  import { portfolioAssuranceEnabled, scanVerdictWording, NOT_A_GUARANTEE } from '../posture/portfolio/wording.js';
8
+ import { assuranceDecision } from '../posture/assurance/decision.js';
8
9
  import { applyLegacyCompat, legacyFieldDeprecationNotice } from '../pipeline/legacy-compat.js';
9
10
  // Finding Provenance (M0/M1). NOTE the name: `findingProvenance` is the
10
11
  // git-origin record from posture/provenance/. It is NOT `finding.provenance`
@@ -1661,11 +1662,13 @@ export function toShipVerdict(scan, options = {}) {
1661
1662
  // findings on a scan that didn't finish cleanly is "we don't know," not
1662
1663
  // "safe."
1663
1664
  const scanIncomplete = scan.scanHealth && scan.scanHealth.status && scan.scanHealth.status !== 'complete';
1665
+ // One decision for every surface (posture/assurance/decision.js, 0.195.22): a result with NO scan health is `unknown`, never clean.
1666
+ const decision = assuranceDecision({ actionable: actionable.length, scanHealth: scan.scanHealth || null });
1664
1667
 
1665
1668
  const lines = [];
1666
1669
  const bar = '─────────────────────────────────────────';
1667
1670
  // Patch the mascot reacts to the result — APPROVE if clean, ALERT if findings.
1668
- const clean = actionable.length === 0 && !scanIncomplete;
1671
+ const clean = decision.decision === 'deployable';
1669
1672
  lines.push(clean ? approveFace({ color }) : alertFace({ color }));
1670
1673
  lines.push(bar);
1671
1674
  // X-703.AC03: with the `portfolio-assurance` feature on, the headline is the bounded claim followed by scope and gaps. Off
@@ -1675,14 +1678,12 @@ export function toShipVerdict(scan, options = {}) {
1675
1678
  const w = scanVerdictWording({ clean, scanIncomplete: !!scanIncomplete, actionableCount: actionable.length });
1676
1679
  lines.push(c(` ${w.icon} ${w.headline}`, w.tier === 'ok' ? SEV_COLOR.low + BOLD : w.tier === 'incomplete' ? SEV_COLOR.high + BOLD : SEV_COLOR.critical + BOLD));
1677
1680
  lines.push(c(` Scope: completed supported checks only${scanIncomplete ? '; gaps listed below' : '; no gaps recorded by scan health'}. ${NOT_A_GUARANTEE}`, DIM));
1678
- } else if (clean) {
1679
- lines.push(c(' ✅ Safe to deploy', SEV_COLOR.low + BOLD));
1680
- } else if (actionable.length === 0) {
1681
- lines.push(c(' ⚠️ Scan incomplete — cannot confirm safe to deploy', SEV_COLOR.high + BOLD));
1682
1681
  } else {
1683
- lines.push(c(' ❌ Not safe to deploy', SEV_COLOR.critical + BOLD));
1682
+ const tone = decision.decision === 'deployable' ? SEV_COLOR.low : decision.decision === 'not_deployable' ? SEV_COLOR.critical : SEV_COLOR.high;
1683
+ lines.push(c(` ${decision.icon} ${decision.headline}`, tone + BOLD));
1684
1684
  }
1685
1685
  lines.push(bar);
1686
+ if (decision.decision === 'unknown') lines.push(c(` ${decision.reason}`, SEV_COLOR.high));
1686
1687
  if (scanIncomplete) {
1687
1688
  const conditions = Array.isArray(scan.scanHealth.conditions) ? scan.scanHealth.conditions : [];
1688
1689
  lines.push(c(` ${scan.scanHealth.status}: ${conditions[0] || 'analysis did not complete cleanly'}${conditions.length > 1 ? ` (+${conditions.length - 1} more)` : ''}`, SEV_COLOR.high));
@@ -10,6 +10,8 @@ import * as crypto from 'node:crypto';
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
12
  import { evaluateEgress } from '../egress/policy.js';
13
+ import { modelFetch } from '../egress/transport.js';
14
+ import { resolveProvider, buildProviderRequest } from '../llm-validator/providers.js';
13
15
 
14
16
  const CACHE_DIR = process.env.XDG_CONFIG_HOME
15
17
  ? path.join(process.env.XDG_CONFIG_HOME, 'agentic-security', 'llm-sca-cache')
@@ -37,24 +39,26 @@ export function isLlmScaEnabled() {
37
39
  return process.env.AGENTIC_SECURITY_LLM_SCA === '1';
38
40
  }
39
41
 
40
- function _endpointConfig() {
41
- const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
42
- const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY;
43
- const model = process.env.AGENTIC_SECURITY_LLM_MODEL || 'unknown';
44
- return endpoint ? { endpoint, apiKey, model } : null;
42
+ // The provider is resolved the same way the validator resolves it (GA-06.3): the `local` preset refuses a non-loopback endpoint here
43
+ // too, instead of this module reading the raw endpoint variable and calling it regardless. The Ollama provider speaks a different
44
+ // protocol and this extractor does not implement it, so it is treated as not configured.
45
+ function _endpointConfig(env = process.env) {
46
+ const r = resolveProvider({ role: 'validate', env });
47
+ if (!r.ok || r.config.provider === 'ollama') return null;
48
+ return { endpoint: r.config.endpoint, apiKey: r.config.apiKey, model: r.config.model || 'unknown', shape: r.config.shape };
45
49
  }
46
50
 
47
51
  async function _askLlm(prompt, config) {
48
- const headers = { 'Content-Type': 'application/json' };
49
- if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
50
- const resp = await fetch(config.endpoint, {
52
+ const req = buildProviderRequest(config, prompt, 256);
53
+ const resp = await modelFetch(config.endpoint, {
51
54
  method: 'POST',
52
- headers,
53
- body: JSON.stringify({ prompt, model: config.model }),
54
- signal: AbortSignal.timeout(15000),
55
- });
55
+ headers: req.headers,
56
+ body: JSON.stringify(req.body),
57
+ }, { timeoutMs: 15000 });
56
58
  if (!resp.ok) return null;
57
- const text = await resp.text();
59
+ let text = await resp.text();
60
+ // a vendor shape wraps the model's text in its own envelope; the generic shape's reply is searched as it came
61
+ try { const t = req.extractText(JSON.parse(text)); if (typeof t === 'string' && t) text = t; } catch { /* not an envelope */ }
58
62
  const jsonMatch = text.match(/\{[^{}]*"functions"\s*:\s*\[[^\]]*\][^{}]*\}/);
59
63
  if (!jsonMatch) return null;
60
64
  try { return JSON.parse(jsonMatch[0]); } catch { return null; }