@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. package/src/util/git-hardening.js +128 -0
@@ -0,0 +1,233 @@
1
+ // Privacy data-classification taxonomy (assurance-hardening PRD FR-402).
2
+ //
3
+ // `privacy-taint.js` used to carry a single hardcoded PII_PATTERNS const
4
+ // with exactly four buckets (PII/PHI/PCI/FIN) and no version — extending it
5
+ // (a new regulated-data class, a missed field-name pattern for an existing
6
+ // class) meant editing this package's source. FR-402's acceptance criterion
7
+ // is specifically that the taxonomy is versioned AND customizable without a
8
+ // source change, so this module splits the taxonomy out as data:
9
+ //
10
+ // - DEFAULT_TAXONOMY ships the seven classes the PRD names by name (PII,
11
+ // PHI, PCI, FIN, CREDENTIALS, GEOLOCATION, DEVICE_ID), each carrying a
12
+ // default severity and a list of field-name regex patterns.
13
+ // - loadPrivacyTaxonomy(scanRoot) reads an OPTIONAL operator config at
14
+ // .agentic-security/privacy-taxonomy.json and merges it over the
15
+ // defaults: a class name that already exists gets its patterns
16
+ // APPENDED (mode:'extend', the default) or REPLACED (mode:'replace');
17
+ // a class name that doesn't exist is added as a brand-new
18
+ // organization-defined class. No scanner source file is touched by
19
+ // either path.
20
+ //
21
+ // Degrades the same way every other operator-config reader in this
22
+ // codebase does (see posture/network-policy-import.js, custom-rules.js):
23
+ // a missing file returns the built-in taxonomy unchanged; a malformed one
24
+ // logs a warning and falls back to the built-in taxonomy rather than
25
+ // throwing and aborting the scan.
26
+
27
+ import * as fs from 'node:fs';
28
+ import { statePath } from '../posture/state-dir.js';
29
+
30
+ export const BUILTIN_TAXONOMY_VERSION = '1.0.0';
31
+
32
+ // Severity ranking used to pick the worst-case severity when a single
33
+ // field/finding matches more than one class (e.g. a "ssn_and_password"
34
+ // field matching both PII and CREDENTIALS).
35
+ export const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
36
+
37
+ export const DEFAULT_TAXONOMY = Object.freeze({
38
+ PII: {
39
+ severity: 'medium',
40
+ patterns: [
41
+ '\\bfirst[_-]?name\\b', '\\blast[_-]?name\\b', '\\bfull[_-]?name\\b',
42
+ '\\bemail([_-]?address)?\\b', '\\bphone([_-]?number)?\\b', '\\bmobile\\b',
43
+ '\\baddress(?:_?(?:line|street|city|zip|postal))?\\b',
44
+ '\\bdob\\b', '\\bdate[_-]?of[_-]?birth\\b', '\\bbirthday\\b', '\\bbirthdate\\b',
45
+ '\\bage\\b', '\\bgender\\b', '\\bethnicity\\b', '\\brace\\b', '\\bnationality\\b',
46
+ '\\bssn\\b', '\\bsocial[_-]?security', '\\bnational[_-]?id', '\\bpassport\\b',
47
+ '\\bdriver[_-]?license\\b', '\\btax[_-]?id\\b', '\\bgovernment[_-]?id\\b',
48
+ '\\bip[_-]?address\\b',
49
+ ],
50
+ },
51
+ PHI: {
52
+ severity: 'high',
53
+ patterns: [
54
+ '\\b(?:medical|patient|health)[_-]?record\\b',
55
+ '\\bdiagnosis\\b', '\\bcondition\\b', '\\bsymptom\\b', '\\btreatment\\b',
56
+ '\\bmedication\\b', '\\bprescription\\b', '\\bdosage\\b',
57
+ '\\bicd[_-]?(?:9|10|11)\\b', '\\bcpt[_-]?code\\b', '\\bmrn\\b',
58
+ '\\bmedical[_-]?record[_-]?number\\b', '\\bdoctor[_-]?name\\b',
59
+ '\\bphysician\\b', '\\binsurance[_-]?id\\b', '\\bhealth[_-]?plan\\b',
60
+ ],
61
+ },
62
+ PCI: {
63
+ severity: 'high',
64
+ patterns: [
65
+ '\\bcredit[_-]?card[_-]?(?:number|num|no)?\\b',
66
+ '\\bcard[_-]?(?:number|num|no)\\b',
67
+ '\\b(?:cvc|cvv)2?\\b', '\\bcvc[_-]?code\\b',
68
+ '\\bexp(?:iry|iration)?(?:_?date)?\\b',
69
+ '\\bcardholder[_-]?name\\b', '\\bpan\\b',
70
+ '\\biban\\b', '\\brouting[_-]?number\\b',
71
+ '\\baccount[_-]?number\\b',
72
+ ],
73
+ },
74
+ FIN: {
75
+ severity: 'medium',
76
+ patterns: [
77
+ '\\bsalary\\b', '\\bincome\\b', '\\bbalance\\b', '\\btransaction[_-]?amount\\b',
78
+ '\\bbank[_-]?account\\b',
79
+ '\\bcredit[_-]?score\\b', '\\bnet[_-]?worth\\b',
80
+ ],
81
+ },
82
+ CREDENTIALS: {
83
+ severity: 'critical',
84
+ patterns: [
85
+ '\\bpassword\\b', '\\bpasswd\\b', '\\bapi[_-]?key\\b', '\\bsecret[_-]?key\\b',
86
+ '\\baccess[_-]?token\\b', '\\brefresh[_-]?token\\b', '\\bauth[_-]?token\\b',
87
+ '\\bprivate[_-]?key\\b', '\\bclient[_-]?secret\\b', '\\bsession[_-]?token\\b',
88
+ '\\bsecurity[_-]?answer\\b', '\\bpin[_-]?code\\b',
89
+ ],
90
+ },
91
+ GEOLOCATION: {
92
+ severity: 'medium',
93
+ patterns: [
94
+ '\\bgeo[_-]?location\\b', '\\blatitude\\b', '\\blongitude\\b',
95
+ '\\bgps[_-]?coord(?:inates?)?\\b', '\\bprecise[_-]?location\\b',
96
+ '\\bcurrent[_-]?location\\b',
97
+ ],
98
+ },
99
+ DEVICE_ID: {
100
+ severity: 'low',
101
+ patterns: [
102
+ '\\bdevice[_-]?id\\b', '\\bimei\\b', '\\budid\\b', '\\bmac[_-]?address\\b',
103
+ '\\badvertising[_-]?id\\b', '\\bidfa\\b', '\\bandroid[_-]?id\\b',
104
+ ],
105
+ },
106
+ });
107
+
108
+ function _taxonomyStatePath(scanRoot) {
109
+ return statePath(scanRoot, 'privacy-taxonomy.json');
110
+ }
111
+
112
+ /**
113
+ * Compile a taxonomy (class -> {severity, patterns: string[]}) into
114
+ * class -> {severity, regexes: RegExp[]} for repeated use across a scan.
115
+ */
116
+ export function compileTaxonomy(taxonomy) {
117
+ const compiled = {};
118
+ for (const [cls, def] of Object.entries(taxonomy || {})) {
119
+ const patterns = Array.isArray(def?.patterns) ? def.patterns : [];
120
+ const regexes = [];
121
+ for (const p of patterns) {
122
+ try { regexes.push(new RegExp(p, 'i')); } catch { /* skip an invalid operator-supplied pattern */ }
123
+ }
124
+ compiled[cls] = { severity: def?.severity || 'medium', regexes };
125
+ }
126
+ return compiled;
127
+ }
128
+
129
+ const _BUILTIN_COMPILED = compileTaxonomy(DEFAULT_TAXONOMY);
130
+
131
+ /**
132
+ * Merge an operator-supplied taxonomy config over DEFAULT_TAXONOMY.
133
+ * Never mutates DEFAULT_TAXONOMY. `raw.classes[name].mode` controls how an
134
+ * existing class is merged: 'extend' (default) appends patterns to the
135
+ * built-in list; 'replace' discards the built-in patterns for that class.
136
+ * A class name not present in DEFAULT_TAXONOMY is added as-is (a new
137
+ * organization-defined class) and defaults to severity 'medium' if unset.
138
+ */
139
+ function _mergeTaxonomy(raw) {
140
+ const merged = {};
141
+ for (const [cls, def] of Object.entries(DEFAULT_TAXONOMY)) {
142
+ merged[cls] = { severity: def.severity, patterns: [...def.patterns] };
143
+ }
144
+ const classes = raw && typeof raw.classes === 'object' && raw.classes ? raw.classes : {};
145
+ let customized = false;
146
+ for (const [cls, def] of Object.entries(classes)) {
147
+ const patterns = Array.isArray(def?.patterns) ? def.patterns.filter(p => typeof p === 'string') : [];
148
+ if (!patterns.length) continue;
149
+ customized = true;
150
+ const existing = merged[cls];
151
+ if (existing && def?.mode !== 'replace') {
152
+ merged[cls] = {
153
+ severity: typeof def?.severity === 'string' ? def.severity : existing.severity,
154
+ patterns: [...existing.patterns, ...patterns],
155
+ };
156
+ } else {
157
+ merged[cls] = {
158
+ severity: typeof def?.severity === 'string' ? def.severity : (existing?.severity || 'medium'),
159
+ patterns,
160
+ };
161
+ }
162
+ }
163
+ return { taxonomy: merged, customized };
164
+ }
165
+
166
+ /**
167
+ * Load the effective privacy taxonomy for a scan: the built-in defaults,
168
+ * merged with .agentic-security/privacy-taxonomy.json when present. Never
169
+ * throws — a missing file returns the built-in taxonomy; a malformed one
170
+ * logs a warning and falls back to the built-in taxonomy untouched.
171
+ *
172
+ * Returns { version, taxonomy, compiled, customized }.
173
+ */
174
+ export function loadPrivacyTaxonomy(scanRoot) {
175
+ if (!scanRoot) {
176
+ return { version: BUILTIN_TAXONOMY_VERSION, taxonomy: DEFAULT_TAXONOMY, compiled: _BUILTIN_COMPILED, customized: false };
177
+ }
178
+ const fp = _taxonomyStatePath(scanRoot);
179
+ // Read-first, not existsSync()-then-readFileSync() — the file can vanish
180
+ // between those two calls (TOCTOU). ENOENT is the expected "no config"
181
+ // case and degrades silently; anything else (bad JSON, permission
182
+ // denied) logs a warning and falls back the same way.
183
+ let raw = null;
184
+ try {
185
+ raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
186
+ } catch (e) {
187
+ if (e.code !== 'ENOENT') {
188
+ console.error(`agentic-security: bad JSON in privacy-taxonomy.json — falling back to the built-in taxonomy (${e.message})`);
189
+ }
190
+ raw = null;
191
+ }
192
+ if (!raw) {
193
+ return { version: BUILTIN_TAXONOMY_VERSION, taxonomy: DEFAULT_TAXONOMY, compiled: _BUILTIN_COMPILED, customized: false };
194
+ }
195
+ const { taxonomy, customized } = _mergeTaxonomy(raw);
196
+ const version = typeof raw.taxonomyVersion === 'string' && raw.taxonomyVersion
197
+ ? raw.taxonomyVersion
198
+ : (customized ? `${BUILTIN_TAXONOMY_VERSION}+custom` : BUILTIN_TAXONOMY_VERSION);
199
+ return { version, taxonomy, compiled: compileTaxonomy(taxonomy), customized };
200
+ }
201
+
202
+ /**
203
+ * Classify a field/variable name against a compiled taxonomy (see
204
+ * compileTaxonomy). Returns an array of matched class names, in the
205
+ * taxonomy's own key order — defaults to the built-in taxonomy when none
206
+ * is supplied, so existing callers with a single argument are unaffected.
207
+ */
208
+ export function classifyFieldAgainst(name, compiled) {
209
+ if (!name) return [];
210
+ const out = [];
211
+ for (const [cls, def] of Object.entries(compiled || _BUILTIN_COMPILED)) {
212
+ for (const re of def.regexes) {
213
+ if (re.test(name)) { out.push(cls); break; }
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * Worst-case (highest) severity among a set of matched class names, per
221
+ * the given compiled taxonomy. Falls back to 'medium' for an empty or
222
+ * unrecognized class list — same default the pre-FR-402 code used.
223
+ */
224
+ export function severityForClasses(classes, compiled) {
225
+ const table = compiled || _BUILTIN_COMPILED;
226
+ let best = null;
227
+ for (const cls of classes || []) {
228
+ const sev = table[cls]?.severity;
229
+ if (!sev) continue;
230
+ if (!best || (SEVERITY_RANK[sev] || 0) > (SEVERITY_RANK[best] || 0)) best = sev;
231
+ }
232
+ return best || 'medium';
233
+ }
@@ -13,7 +13,7 @@
13
13
  // not vote, and is excluded from the denominator rather than counted as
14
14
  // agreement. If nobody votes the panel is `undecided` and the candidate
15
15
  // SURVIVES — an outage must not quietly delete findings.
16
- import { resolveLlmInvoke } from './llm-invoke.js';
16
+ import { resolveLlmInvokeWithDecision } from './llm-invoke.js';
17
17
 
18
18
  const DEFAULT_ANGLES = ['reachability', 'preconditions', 'sanitization'];
19
19
  export const REFUTE_ANGLES = Object.freeze([...DEFAULT_ANGLES]);
@@ -51,7 +51,11 @@ function parseVote(raw) {
51
51
 
52
52
  export async function disproveCandidate(candidate, opts = {}) {
53
53
  const angles = Array.isArray(opts.angles) && opts.angles.length ? opts.angles : DEFAULT_ANGLES;
54
- const llmInvoke = resolveLlmInvoke(opts);
54
+ // FR-601: an egress-policy denial resolves llmInvoke to null the same way a
55
+ // missing endpoint always has, so it falls straight into this module's own
56
+ // pre-existing rule — "silence never refutes" — with zero votes cast and no
57
+ // prompt ever built for a denied endpoint.
58
+ const { invoke: llmInvoke, decision: egressDecision } = resolveLlmInvokeWithDecision({ ...opts, purpose: 'discovery-disprove' });
55
59
 
56
60
  const votes = [];
57
61
  if (typeof llmInvoke === 'function') {
@@ -66,7 +70,7 @@ export async function disproveCandidate(candidate, opts = {}) {
66
70
  const refuteCount = votes.filter(v => v.refuted).length;
67
71
  const undecided = voterCount === 0;
68
72
  const refuted = !undecided && refuteCount * 2 > voterCount;
69
- return { ...candidate, refutation: { votes, voterCount, refuteCount, refuted, undecided } };
73
+ return { ...candidate, refutation: { votes, voterCount, refuteCount, refuted, undecided, egressDecision: egressDecision || undefined } };
70
74
  }
71
75
 
72
76
  export async function disprovePanel(candidates, opts = {}) {
@@ -11,7 +11,7 @@
11
11
  // discovery pass that cannot run must leave the rest of the scan intact.
12
12
  import * as crypto from 'node:crypto';
13
13
  import { buildHunterPrompt } from './lenses.js';
14
- import { resolveLlmInvoke } from './llm-invoke.js';
14
+ import { resolveLlmInvokeWithDecision } from './llm-invoke.js';
15
15
 
16
16
  function appendEntry(transcript, entry) {
17
17
  const prev = transcript.length ? transcript[transcript.length - 1].hash : null;
@@ -75,12 +75,16 @@ export async function runHunter(focusArea, lens, ctx = {}, opts = {}) {
75
75
  const transcript = [];
76
76
  const lensKey = lens?.key || 'unknown';
77
77
  const base = { focusAreaId: focusArea.id, lens: lensKey, transcript };
78
- const llmInvoke = resolveLlmInvoke(opts);
78
+ const { invoke: llmInvoke, decision: egressDecision } = resolveLlmInvokeWithDecision({ ...opts, purpose: 'discovery-hunter' });
79
79
 
80
80
  if (typeof llmInvoke !== 'function') {
81
- const reason = 'no llmInvoke supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
82
- appendEntry(transcript, { phase: 'init', reason });
83
- return { ...base, candidates: [], degraded: true, reason };
81
+ // FR-601: distinguish "policy denied a configured endpoint" from "nothing
82
+ // was configured at all" — the reason must reflect which actually happened.
83
+ const reason = egressDecision
84
+ ? `egress policy denied this call: ${egressDecision.reason}`
85
+ : 'no llmInvoke supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
86
+ appendEntry(transcript, { phase: 'init', reason, egressDecision: egressDecision || undefined });
87
+ return { ...base, candidates: [], degraded: true, reason, egressDecision };
84
88
  }
85
89
 
86
90
  let prompt;
@@ -169,7 +169,7 @@ export async function runDiscovery(ctx = {}, opts = {}) {
169
169
  for (const area of areas) {
170
170
  let areaDegradedCount = 0;
171
171
  for (const lens of lenses) {
172
- const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke });
172
+ const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke, scanRoot: opts.scanRoot });
173
173
  runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
174
174
  if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
175
175
  if (run.degraded) areaDegradedCount += 1;
@@ -231,7 +231,7 @@ export async function runDiscovery(ctx = {}, opts = {}) {
231
231
  'every candidate is reported unconfirmed and severity is not evidence-derived');
232
232
  }
233
233
  const confirmed = await confirmAll(candidates, { taintProbe });
234
- const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke });
234
+ const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke, scanRoot: opts.scanRoot });
235
235
  const { fresh, duplicates, suppressed } = judgeCandidates(survivors, ctx.priorScan, ctx.triageFeedback);
236
236
 
237
237
  // A spent budget is a coverage gap, stated once at the top level rather than
@@ -5,6 +5,8 @@
5
5
  // other does not, the bug stays buried in one direction.
6
6
  //
7
7
 
8
+ import { evaluateEgress } from '../egress/policy.js';
9
+
8
10
  const DEFAULT_TIMEOUT_MS = 60000;
9
11
 
10
12
  export async function defaultLlmInvoke(prompt, opts = {}) {
@@ -88,12 +90,27 @@ export function consensusOf(responses) {
88
90
  * An llmInvoke that queries several endpoints and returns the consensus answer.
89
91
  * Returns null when no endpoint answered — the callers already treat a null or
90
92
  * a throw as degradation, so an all-providers-down run degrades honestly.
93
+ *
94
+ * FR-605 (assurance-hardening PRD): each endpoint gets its OWN egress
95
+ * decision before being included — `mode: local-only` / `deniedProviders`
96
+ * must not be smuggled past just because the SINGLE-endpoint path already
97
+ * checks it. A policy-denied endpoint is EXCLUDED from the vote, the exact
98
+ * same treatment an unreachable endpoint already gets a few lines below
99
+ * (never counted as dissent) — the module's own established pattern
100
+ * extended to a second exclusion reason. Returns `{invoke, decisions}`:
101
+ * `invoke` is null only when EVERY endpoint was denied (the all-down
102
+ * equivalent); `decisions` is the full per-endpoint array for a caller
103
+ * that wants it, kept alongside the single aggregate `decision` the
104
+ * pre-existing callers already read.
91
105
  */
92
- function makeConsensusInvoke(endpoints, { timeoutMs } = {}) {
106
+ function makeConsensusInvoke(endpoints, { timeoutMs, scanRoot, purpose } = {}) {
93
107
  const list = parseEndpoints(endpoints);
94
- if (list.length === 0) return null;
95
- return async (prompt) => {
96
- const answers = await Promise.all(list.map(async (url) => {
108
+ if (list.length === 0) return { invoke: null, decisions: [] };
109
+ const decisions = list.map(url => evaluateEgress({ scanRoot, purpose: purpose || 'discovery-consensus', endpoint: url }));
110
+ const allowedList = list.filter((_, i) => decisions[i].allowed);
111
+ if (allowedList.length === 0) return { invoke: null, decisions };
112
+ const invoke = async (prompt) => {
113
+ const answers = await Promise.all(allowedList.map(async (url) => {
97
114
  try { return await defaultLlmInvoke(prompt, { timeoutMs, endpoint: url }); }
98
115
  catch { return null; } // excluded from the vote, never counted as dissent
99
116
  }));
@@ -101,20 +118,59 @@ function makeConsensusInvoke(endpoints, { timeoutMs } = {}) {
101
118
  if (value === null) throw new Error('no LLM endpoint answered');
102
119
  return value;
103
120
  };
121
+ return { invoke, decisions };
104
122
  }
105
123
 
106
- export function resolveLlmInvoke(opts = {}) {
107
- // Precedence, most explicit first: an injected callback beats configuration,
108
- // and a multi-endpoint list beats a single endpoint. A caller who supplied
109
- // their own function must always get exactly that function.
110
- if (opts.llmInvoke) return opts.llmInvoke;
124
+ // FR-601: egress policy is evaluated here, BEFORE returning a callable and
125
+ // therefore before either caller (hunter.js, disprove.js) builds a prompt —
126
+ // a denied decision makes this resolve to `invoke: null`, which both callers
127
+ // already treat as "nothing to call" via their existing degrade path, so a
128
+ // denial produces no network request through the exact same code path a
129
+ // missing endpoint always has. `decision` carries the machine-readable
130
+ // reason so callers can distinguish "not configured" from "configured but
131
+ // policy-denied" in their own degrade message, rather than reporting a
132
+ // generic "not set" that would be actively wrong once policy is what
133
+ // blocked the call.
134
+ //
135
+ // FR-605 (assurance-hardening PRD): consensus mode (multiple endpoints) is
136
+ // now egress-filtered per endpoint, same as the single-endpoint path below —
137
+ // this is what closes the actual "a remote URL cannot be smuggled into
138
+ // local-only configuration" gap the paragraph below used to describe as
139
+ // open. Per-endpoint CONSTRAINT dimensions beyond allow/deny/local-only
140
+ // (role/region/repository/path/data-class — one provider allowed, another
141
+ // denied for a REASON beyond the deny-list) remain FR-602's separate scope;
142
+ // what changed here is that the single allow/deny/local-only gate FR-601
143
+ // already built is no longer bypassable just by using multiple endpoints
144
+ // instead of one.
145
+ export function resolveLlmInvokeWithDecision(opts = {}) {
146
+ // An injected callback is a test/consumer-controlled escape hatch — it
147
+ // bypasses egress the same way it already bypasses endpoint resolution,
148
+ // because there is no real endpoint here for a policy to evaluate.
149
+ if (opts.llmInvoke) return { invoke: opts.llmInvoke, decision: null };
111
150
 
112
151
  const multi = opts.endpoints || process.env[DEFAULT_CONSENSUS_ENV];
113
152
  if (multi) {
114
- const consensus = makeConsensusInvoke(multi, { timeoutMs: opts.timeoutMs });
115
- if (consensus) return consensus;
153
+ const { invoke, decisions } = makeConsensusInvoke(multi, { timeoutMs: opts.timeoutMs, scanRoot: opts.scanRoot, purpose: opts.purpose });
154
+ // A single aggregate `decision` for the pre-existing callers (hunter.js,
155
+ // disprove.js), which only ever read `.reason` when `invoke` is null —
156
+ // the all-denied case. The full per-endpoint detail is on `decisions`
157
+ // for a caller that wants it.
158
+ const decision = decisions.find(d => !d.allowed) || decisions[0] || null;
159
+ return { invoke, decision, decisions };
116
160
  }
117
161
 
118
- if (!process.env.AGENTIC_SECURITY_LLM_ENDPOINT) return null;
119
- return (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs });
162
+ const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
163
+ if (!endpoint) return { invoke: null, decision: null };
164
+
165
+ const decision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: opts.purpose || 'discovery', endpoint });
166
+ if (!decision.allowed) return { invoke: null, decision };
167
+
168
+ return { invoke: (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs }), decision };
169
+ }
170
+
171
+ export function resolveLlmInvoke(opts = {}) {
172
+ // Precedence, most explicit first: an injected callback beats configuration,
173
+ // and a multi-endpoint list beats a single endpoint. A caller who supplied
174
+ // their own function must always get exactly that function.
175
+ return resolveLlmInvokeWithDecision(opts).invoke;
120
176
  }
@@ -0,0 +1,147 @@
1
+ // Per-call egress audit log (assurance-hardening PRD FR-604).
2
+ //
3
+ // FR-601 gives every outbound LLM call a machine-readable in-memory
4
+ // decision object. FR-604's acceptance criterion asks for more than that:
5
+ // a PERSISTED, per-call audit record — "purpose, provider, model, region if
6
+ // known, byte/token counts, policy, hashes, and outcome" — WITHOUT ever
7
+ // retaining the prohibited prompt content itself. This module is that
8
+ // record.
9
+ //
10
+ // Design: intentionally mirrors mcp/audit.js's proven OWASP-MCP08
11
+ // technique (append-only NDJSON, each entry's `prev` is the SHA-256 of the
12
+ // previous line, so tampering breaks the chain from that point forward) —
13
+ // same shape of guarantee this codebase already ships and tests for MCP
14
+ // tool calls, now for egress calls. Deliberately a SEPARATE, self-contained
15
+ // implementation rather than an import from mcp/audit.js: that module's
16
+ // public surface (`auditCall`) is shaped around {tool, args}, not
17
+ // {purpose, provider, model, region, policy, hashes}, and its hash-chain
18
+ // helpers are private (`_sha`/`_readLastEntryHash`/`_postRemote`, not
19
+ // exported) — reshaping that already-tested, security-relevant module to
20
+ // export shared primitives is a real, separate refactor, not something to
21
+ // fold into one FR cycle. The ~15 lines of hash-chaining logic duplicated
22
+ // here are a well-understood idiom (one sha256 call), not a place where
23
+ // drift risk between the two copies matters in practice.
24
+ //
25
+ // CONTENT NEVER RETAINED: this module accepts a byte count, a token
26
+ // estimate, and a content HASH — never the text itself. A caller that
27
+ // passes raw prompt text here has misused the API; there is no parameter
28
+ // that accepts it.
29
+
30
+ import * as fs from 'node:fs';
31
+ import * as path from 'node:path';
32
+ import * as crypto from 'node:crypto';
33
+ import { stateDir } from '../posture/state-dir.js';
34
+
35
+ const GENESIS = 'GENESIS';
36
+ const LOG_FILE_NAME = 'egress-audit.log';
37
+ const SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;
38
+
39
+ function _sha(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
40
+
41
+ function _hasProjectMarker(sessionRoot) {
42
+ const MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml', 'composer.json', 'Gemfile'];
43
+ for (const m of MARKERS) {
44
+ try { if (fs.existsSync(path.join(sessionRoot, m))) return true; } catch { /* keep checking */ }
45
+ }
46
+ return false;
47
+ }
48
+
49
+ // Read-first, not existsSync()-then-readFileSync() — ENOENT is the expected
50
+ // "no prior log" case (see this session's D-0012: every optional-file
51
+ // reader in this codebase follows this shape from the start).
52
+ function _readLastEntryHash(logFile) {
53
+ let all;
54
+ try {
55
+ all = fs.readFileSync(logFile, 'utf8');
56
+ } catch {
57
+ return GENESIS;
58
+ }
59
+ const lines = all.split('\n').filter(Boolean);
60
+ if (!lines.length) return GENESIS;
61
+ return _sha(lines[lines.length - 1]);
62
+ }
63
+
64
+ /**
65
+ * Compute the {byteCount, tokenCount, contentHash} triple for an outbound
66
+ * payload WITHOUT the caller having to hash/measure it inline at every call
67
+ * site. Never returns or logs the text itself.
68
+ */
69
+ export function payloadMetrics(text) {
70
+ if (typeof text !== 'string' || text.length === 0) {
71
+ return { byteCount: 0, tokenCount: 0, contentHash: null };
72
+ }
73
+ return {
74
+ byteCount: Buffer.byteLength(text, 'utf8'),
75
+ tokenCount: Math.ceil(text.length / 4), // same rough estimate llm-validator/index.js's own R12 ceiling uses
76
+ contentHash: _sha(text),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Append one audit entry for an evaluateEgress() decision.
82
+ *
83
+ * @param {object} opts
84
+ * @param {string} opts.scanRoot - project root; entries are only written
85
+ * when it looks like a real project (same safety check mcp/audit.js
86
+ * uses) so a scratch/temp scanRoot never accumulates an audit file.
87
+ * @param {object} opts.decision - the object evaluateEgress() returned:
88
+ * { allowed, decision, reason?, provider, policySource, purpose }.
89
+ * @param {object} [opts.ctx] - the SAME ctx object passed to evaluateEgress
90
+ * — only `model` and `region` are read from it (if present); nothing
91
+ * else is retained.
92
+ * @param {object} [opts.metrics] - the result of payloadMetrics(text), or
93
+ * omitted entirely for a denied call where no payload was ever built.
94
+ */
95
+ export function recordEgressCall({ scanRoot, decision, ctx = {}, metrics = null } = {}) {
96
+ if (!scanRoot || !decision) return;
97
+ try {
98
+ if (!_hasProjectMarker(scanRoot)) return;
99
+ const dir = stateDir(scanRoot);
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ const logFile = path.join(dir, LOG_FILE_NAME);
102
+ const entry = {
103
+ ts: new Date().toISOString(),
104
+ sessionId: SESSION_ID,
105
+ purpose: decision.purpose || ctx.purpose || 'unknown',
106
+ provider: decision.provider || 'unknown',
107
+ model: typeof ctx.model === 'string' ? ctx.model : null,
108
+ region: typeof ctx.region === 'string' ? ctx.region : null,
109
+ policy: { policySource: decision.policySource || 'default' },
110
+ outcome: decision.decision || (decision.allowed ? 'allow' : 'deny'),
111
+ ...(decision.reason ? { reason: decision.reason } : {}),
112
+ byteCount: metrics ? metrics.byteCount : null,
113
+ tokenCount: metrics ? metrics.tokenCount : null,
114
+ contentHash: metrics ? metrics.contentHash : null,
115
+ prev: _readLastEntryHash(logFile),
116
+ };
117
+ fs.appendFileSync(logFile, JSON.stringify(entry) + '\n');
118
+ } catch { /* audit failure must never break the call it is auditing */ }
119
+ }
120
+
121
+ // Verify the chain from start to end — same contract as mcp/audit.js's
122
+ // verifyAuditLog: { ok: true, entries: N } if intact, or
123
+ // { ok: false, brokenAt, expected, got } at the first broken link.
124
+ export function verifyEgressAuditLog(logFile) {
125
+ // Read-first, not existsSync()-then-readFileSync() (D-0012) — the file can
126
+ // vanish between those two calls; ENOENT is the expected "no log yet" case.
127
+ let text;
128
+ try {
129
+ text = fs.readFileSync(logFile, 'utf8');
130
+ } catch {
131
+ return { ok: true, entries: 0 };
132
+ }
133
+ const lines = text.split('\n').filter(Boolean);
134
+ let expectedPrev = GENESIS;
135
+ for (let i = 0; i < lines.length; i++) {
136
+ let entry;
137
+ try { entry = JSON.parse(lines[i]); }
138
+ catch { return { ok: false, brokenAt: i, reason: 'not JSON' }; }
139
+ if (entry.prev !== expectedPrev) {
140
+ return { ok: false, brokenAt: i, expected: expectedPrev, got: entry.prev };
141
+ }
142
+ expectedPrev = _sha(lines[i]);
143
+ }
144
+ return { ok: true, entries: lines.length };
145
+ }
146
+
147
+ export const _internals = { LOG_FILE_NAME, GENESIS, _hasProjectMarker, _readLastEntryHash };