@clear-capabilities/agentic-security-scanner 0.172.1 → 0.173.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 (194) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/agentic-security.js +37 -0
  3. package/dist/119.index.js +197 -0
  4. package/dist/128.index.js +129 -0
  5. package/dist/1293.index.js +5689 -0
  6. package/dist/1351.index.js +133 -0
  7. package/dist/1387.index.js +271 -0
  8. package/dist/1437.index.js +23 -0
  9. package/dist/1445.index.js +110 -0
  10. package/dist/1505.index.js +151 -0
  11. package/dist/1557.index.js +293 -0
  12. package/dist/1621.index.js +311 -0
  13. package/dist/1751.index.js +160 -0
  14. package/dist/1765.index.js +727 -0
  15. package/dist/1861.index.js +690 -0
  16. package/dist/1898.index.js +116 -0
  17. package/dist/1906.index.js +433 -0
  18. package/dist/1920.index.js +1 -291
  19. package/dist/208.index.js +1631 -0
  20. package/dist/2199.index.js +0 -239
  21. package/dist/2223.index.js +159 -0
  22. package/dist/2530.index.js +152 -0
  23. package/dist/2565.index.js +116 -0
  24. package/dist/2574.index.js +127 -0
  25. package/dist/2620.index.js +345 -0
  26. package/dist/2773.index.js +98 -0
  27. package/dist/2775.index.js +32 -0
  28. package/dist/2966.index.js +171 -0
  29. package/dist/2972.index.js +87 -0
  30. package/dist/2991.index.js +131 -0
  31. package/dist/3063.index.js +833 -0
  32. package/dist/3075.index.js +227 -0
  33. package/dist/3121.index.js +297 -0
  34. package/dist/3314.index.js +263 -0
  35. package/dist/3480.index.js +1822 -0
  36. package/dist/3481.index.js +24 -0
  37. package/dist/3506.index.js +268 -0
  38. package/dist/3533.index.js +591 -0
  39. package/dist/3653.index.js +450 -0
  40. package/dist/3822.index.js +131 -0
  41. package/dist/3868.index.js +292 -0
  42. package/dist/3917.index.js +152 -0
  43. package/dist/3918.index.js +192 -0
  44. package/dist/4090.index.js +288 -0
  45. package/dist/4121.index.js +1190 -0
  46. package/dist/4142.index.js +0 -239
  47. package/dist/4336.index.js +436 -0
  48. package/dist/4387.index.js +532 -0
  49. package/dist/4413.index.js +302 -0
  50. package/dist/4476.index.js +4510 -0
  51. package/dist/4491.index.js +133 -0
  52. package/dist/4538.index.js +185 -0
  53. package/dist/4684.index.js +319 -0
  54. package/dist/4740.index.js +165 -0
  55. package/dist/4780.index.js +209 -0
  56. package/dist/4802.index.js +225 -0
  57. package/dist/4812.index.js +379 -0
  58. package/dist/4835.index.js +126 -0
  59. package/dist/494.index.js +226 -0
  60. package/dist/4991.index.js +549 -0
  61. package/dist/5199.index.js +305 -0
  62. package/dist/5216.index.js +109 -0
  63. package/dist/5439.index.js +301 -0
  64. package/dist/5443.index.js +97 -0
  65. package/dist/5496.index.js +377 -0
  66. package/dist/550.index.js +2 -1
  67. package/dist/5801.index.js +588 -0
  68. package/dist/5858.index.js +223 -0
  69. package/dist/5933.index.js +143 -0
  70. package/dist/601.index.js +23 -0
  71. package/dist/6173.index.js +273 -0
  72. package/dist/6291.index.js +1871 -0
  73. package/dist/6312.index.js +666 -0
  74. package/dist/634.index.js +16182 -0
  75. package/dist/644.index.js +87 -0
  76. package/dist/6508.index.js +283 -0
  77. package/dist/6560.index.js +645 -0
  78. package/dist/6576.index.js +422 -0
  79. package/dist/6579.index.js +271 -0
  80. package/dist/6745.index.js +305 -0
  81. package/dist/6818.index.js +21642 -0
  82. package/dist/6972.index.js +167 -0
  83. package/dist/6995.index.js +163 -0
  84. package/dist/7249.index.js +191 -0
  85. package/dist/7372.index.js +1197 -0
  86. package/dist/7546.index.js +541 -0
  87. package/dist/76.index.js +448 -0
  88. package/dist/7879.index.js +163 -0
  89. package/dist/8025.index.js +130 -0
  90. package/dist/8054.index.js +248 -0
  91. package/dist/8097.index.js +201 -0
  92. package/dist/8126.index.js +191 -0
  93. package/dist/8178.index.js +78 -0
  94. package/dist/818.index.js +212 -0
  95. package/dist/8382.index.js +93 -0
  96. package/dist/8408.index.js +261 -0
  97. package/dist/8540.index.js +174 -0
  98. package/dist/8635.index.js +802 -0
  99. package/dist/8688.index.js +410 -0
  100. package/dist/8699.index.js +779 -0
  101. package/dist/8891.index.js +457 -0
  102. package/dist/8916.index.js +885 -0
  103. package/dist/9146.index.js +170 -0
  104. package/dist/9168.index.js +841 -0
  105. package/dist/9292.index.js +369 -0
  106. package/dist/9382.index.js +135 -11
  107. package/dist/9422.index.js +866 -0
  108. package/dist/9440.index.js +453 -0
  109. package/dist/9479.index.js +152 -0
  110. package/dist/9481.index.js +220 -0
  111. package/dist/9539.index.js +100 -0
  112. package/dist/9547.index.js +103 -0
  113. package/dist/9560.index.js +41 -61
  114. package/dist/9692.index.js +90 -0
  115. package/dist/9800.index.js +213 -1
  116. package/dist/9899.index.js +138 -0
  117. package/dist/9981.index.js +206 -0
  118. package/dist/999.index.js +117 -0
  119. package/dist/agentic-security.mjs +27 -27
  120. package/dist/agentic-security.mjs.sha256 +1 -1
  121. package/package.json +28 -3
  122. package/src/context-intelligence/candidates.js +2 -1
  123. package/src/context-intelligence/capabilities.js +2 -1
  124. package/src/context-intelligence/cli.js +2 -0
  125. package/src/context-intelligence/config.js +3 -1
  126. package/src/context-intelligence/controls.js +49 -1
  127. package/src/context-intelligence/counterexample.js +200 -0
  128. package/src/context-intelligence/cross-route.js +111 -0
  129. package/src/context-intelligence/findings.js +166 -0
  130. package/src/context-intelligence/invariants.js +197 -0
  131. package/src/context-intelligence/queue.js +6 -1
  132. package/src/context-intelligence/reachability.js +2 -2
  133. package/src/context-intelligence/review.js +68 -4
  134. package/src/dataflow/access-paths.js +11 -0
  135. package/src/dataflow/analysis-limits.js +66 -0
  136. package/src/dataflow/engine.js +272 -20
  137. package/src/dataflow/service-edges.js +174 -0
  138. package/src/dataflow/summaries.js +7 -1
  139. package/src/discovery/judge.js +2 -1
  140. package/src/engine.js +82 -14
  141. package/src/fix/apply-fix-service.js +55 -8
  142. package/src/fix/batch-journal.js +84 -0
  143. package/src/fix/exploit-replay.js +51 -0
  144. package/src/fix/footprint.js +65 -0
  145. package/src/fix/patch-policy.js +49 -0
  146. package/src/fix/secret-persistence.js +39 -0
  147. package/src/fix/test-commands.js +84 -0
  148. package/src/fix/verification-adapters.js +97 -0
  149. package/src/fix/verification-record.js +243 -0
  150. package/src/fix/verify-patch.js +89 -0
  151. package/src/ir/CLAUDE.md +5 -0
  152. package/src/ir/callgraph.js +23 -1
  153. package/src/ir/module-resolver.js +315 -0
  154. package/src/ir/parser-js.js +21 -2
  155. package/src/ir/semantic-graph.js +245 -0
  156. package/src/lineage/build-attested-correlation.js +63 -0
  157. package/src/lineage/deployment-identity.js +76 -0
  158. package/src/mcp/tools.js +13 -34
  159. package/src/pipeline/canonical-output.js +64 -0
  160. package/src/pipeline/detector-runner.js +7 -0
  161. package/src/pipeline/scan-health.js +14 -0
  162. package/src/pipeline/supervised-analyzers.js +125 -0
  163. package/src/pipeline/supervised-child.js +17 -0
  164. package/src/pipeline/telemetry.js +79 -0
  165. package/src/posture/accuracy-scorecard.js +98 -9
  166. package/src/posture/artifact-registry.js +5 -0
  167. package/src/posture/assurance/evidence-registry.js +215 -0
  168. package/src/posture/assurance/release-package-verify.js +135 -0
  169. package/src/posture/assurance/release-package.js +118 -0
  170. package/src/posture/assurance/release-verdict.js +94 -0
  171. package/src/posture/assurance/waiver.js +200 -0
  172. package/src/posture/compliance-truth.js +100 -0
  173. package/src/posture/evaluation/scan-child.js +3 -1
  174. package/src/posture/evaluation/why-missed.js +3 -1
  175. package/src/posture/exploitability-evidence.js +141 -0
  176. package/src/posture/fix-verify.js +4 -2
  177. package/src/posture/fleet/exposure-ranking.js +115 -0
  178. package/src/posture/fleet/service-chain-graph.js +150 -0
  179. package/src/posture/graph-reconciliation.js +154 -0
  180. package/src/posture/provenance/coverage.js +103 -0
  181. package/src/posture/provenance/git-evidence.js +84 -0
  182. package/src/posture/provenance/origin-resolver.js +7 -1
  183. package/src/posture/provenance/predicate-replay.js +14 -4
  184. package/src/posture/runtime-correlation.js +61 -15
  185. package/src/posture/runtime-explanation.js +116 -0
  186. package/src/posture/runtime-trace-ingest.js +194 -0
  187. package/src/posture/scan-checkpoint.js +38 -10
  188. package/src/posture/waf-ingest.js +7 -2
  189. package/src/report/index.js +4 -0
  190. package/src/report/oscal.js +38 -0
  191. package/src/report/perspectives.js +150 -0
  192. package/src/sast/_comment-strip.js +8 -1
  193. package/src/sast/ruby.js +26 -0
  194. package/src/util/string-memo.js +38 -0
@@ -0,0 +1,166 @@
1
+ // DCI findings through the EXISTING reporting path (E4-07).
2
+ //
3
+ // An invariant violation or a cross-route inconsistency is not reported in a format of its own. It becomes a candidate in the schema the `hunt`
4
+ // pipeline already uses, goes through the same confirmation and judging (`adjudicate`: confirm.js -> judge.js), and comes out as a normal finding with
5
+ // a stableId, a severity, a CWE and a `contextIntelligence` block. From there triage, SARIF (`toSARIF`), CI gates, the verified-fix policy and the
6
+ // privacy controls apply with no DCI-specific code.
7
+ //
8
+ // WHAT THE EXISTING TIERS MEAN HERE. Severity comes from the confirmation tier alone (taint-confirmed high, sink-adjacent medium, unconfirmed low).
9
+ // A deterministic counterexample maps onto those tiers like this, and only like this:
10
+ // proven -> taint-confirmed an executed local request showed the exposure
11
+ // plausible -> sink-adjacent a resolved path reaches the operation with no such control before it
12
+ // unresolved -> unconfirmed the analysis could not say
13
+ // refuted -> no finding the claim was contradicted; it is counted, not reported
14
+ //
15
+ // WHAT A DCI FINDING CARRIES (all under `contextIntelligence`, never as new top-level fields): the invariant violated (or the sibling-route rule),
16
+ // the affected authorization scope (resource, control, routes, files), the code that shows it, the counterexample state and trace, a fix
17
+ // recommendation, and a verification slot that `verifyDciFix` fills after a patch.
18
+ import { createHash } from 'node:crypto';
19
+ import { adjudicate } from './candidates.js';
20
+ import { assessCandidate, executeAttempt } from './counterexample.js';
21
+ import { findCrossRouteInconsistencies } from './cross-route.js';
22
+ import { evaluateInvariants } from './invariants.js';
23
+ import { buildContext } from './build.js';
24
+ import { normalizeConfig } from './config.js';
25
+ import { buildFixVerificationRecord, evidenceItem, leg } from '../fix/verification-record.js';
26
+ import { patchHashOf } from '../fix/verification-adapters.js';
27
+ import * as fs from 'node:fs';
28
+ import * as os from 'node:os';
29
+ import * as path from 'node:path';
30
+
31
+ const CWE = { ownership: 'CWE-639', 'tenant-scope': 'CWE-639', authentication: 'CWE-306', 'authorization-role': 'CWE-862' };
32
+ const TIER_OF = { proven: 'taint-confirmed', plausible: 'sink-adjacent', unresolved: 'unconfirmed' };
33
+ const FIX = {
34
+ ownership: 'Scope the data operation to the authenticated principal: add the owner to the query filter (for example `{ id, ownerId: req.user.id }`) or compare the loaded object\'s owner with the principal and reject on mismatch, the way the enforcing sibling route does.',
35
+ 'tenant-scope': 'Scope the data operation to the principal\'s tenant: add the tenant to the query filter or reject when the object\'s tenant differs from the principal\'s, the way the enforcing sibling route does.',
36
+ authentication: 'Require an authenticated principal before the sensitive operation, using the same guard the sibling routes use.',
37
+ 'authorization-role': 'Require the role the sibling routes require before the sensitive operation.',
38
+ };
39
+ const sha = (v) => createHash('sha256').update(JSON.stringify(v)).digest('hex');
40
+ const siteOf = (ep) => ({ file: ep?.registrationSite?.file || ep?.rootSpan?.file || '', line: ep?.registrationSite?.line || ep?.rootSpan?.startLine || 1 });
41
+
42
+ /**
43
+ * @param {{crossRoute?:object, invariantResults?:object[], bundles:object[], invariants?:object}} src
44
+ * @returns {object[]} candidates in the shared discovery schema, each with `deterministic` evidence
45
+ */
46
+ export function candidatesFromEvidence({ crossRoute, invariantResults = [], bundles }) {
47
+ const byLabel = new Map(bundles.filter((b) => b.manifest).map((b) => [b.entrypoint.label, b]));
48
+ const out = [];
49
+ for (const f of crossRoute?.findings || []) {
50
+ const b = byLabel.get(f.route); if (!b) continue;
51
+ const site = siteOf(b.entrypoint);
52
+ const id = sha(['xroute', f.resource, f.dimension, f.route, site.file]).slice(0, 12);
53
+ out.push({
54
+ id, focusAreaId: sha(['ctx', b.manifest.contextHash]).slice(0, 12), lens: 'dci-authz',
55
+ title: `Inconsistent ${f.dimension} enforcement on ${f.resource}: ${f.route}`, file: site.file, line: site.line, family: 'authorization', cwe: CWE[f.dimension] || 'CWE-285',
56
+ rationale: f.explanation, entryPoint: f.route, sink: `${f.operations.join('/')} of ${f.resource}`, origin: 'context-intelligence', questionPack: null, advisoryOnly: false,
57
+ witnesses: [{ entrypointId: b.entrypoint.id, label: f.route, contextHash: b.manifest.contextHash, questionPack: null }],
58
+ sourceSpanEvidence: f.enforcedAt.flatMap((e) => (e.evidence || []).map((x) => ({ role: 'guard', file: x.file, startLine: x.line, endLine: x.line }))),
59
+ scopeIntegrity: { contextIntegrity: b.manifest.context.integrity, standalone: true, deterministicAgreement: 'agrees', deterministicProtection: null },
60
+ reviewStatus: 'deterministic', uncertainties: [], recommendedValidation: `Run the same request as a principal who does not own the ${f.resource} against ${f.route} and against ${f.enforcedAt[0]?.route}; the sibling should refuse and this route should not.`,
61
+ remediation: FIX[f.dimension] || 'Enforce the same control as the sibling routes.',
62
+ deterministic: {
63
+ rule: { kind: 'cross-route-consistency', description: `Every route that touches a resource enforces what its sibling routes enforce` },
64
+ invariant: null,
65
+ scope: { resource: f.resource, control: f.dimension, operations: f.operations, route: f.route, enforcedAt: f.enforcedAt.map((e) => e.route), files: [...new Set([site.file, ...f.enforcedAt.flatMap((e) => (e.evidence || []).map((x) => x.file))])] },
66
+ counterexample: null, fix: { recommendation: FIX[f.dimension] || null }, verification: { status: 'pending' },
67
+ },
68
+ _bundle: b, _crossRoute: f,
69
+ });
70
+ }
71
+ for (const r of invariantResults) {
72
+ if (r.verdict !== 'violated') continue;
73
+ const b = byLabel.get(r.label); if (!b) continue;
74
+ const site = siteOf(b.entrypoint);
75
+ const dims = r.controls.filter((c) => c.state === 'absent').map((c) => c.dim);
76
+ const id = sha(['inv', r.invariantId, r.entrypointId]).slice(0, 12);
77
+ out.push({
78
+ id, focusAreaId: sha(['ctx', b.manifest.contextHash]).slice(0, 12), lens: 'dci-authz',
79
+ title: `Invariant ${r.invariantId} violated: ${r.label}`, file: site.file, line: site.line, family: 'authorization', cwe: CWE[dims[0]] || 'CWE-285',
80
+ rationale: `${r.title}. ${r.reasons.join('; ')}`, entryPoint: r.label, sink: dims.join(', ') + ' missing', origin: 'context-intelligence', questionPack: null, advisoryOnly: false,
81
+ witnesses: [{ entrypointId: r.entrypointId, label: r.label, contextHash: b.manifest.contextHash, questionPack: null }], sourceSpanEvidence: [],
82
+ scopeIntegrity: { contextIntegrity: b.manifest.context.integrity, standalone: true, deterministicAgreement: 'agrees', deterministicProtection: null },
83
+ reviewStatus: 'deterministic', uncertainties: [], recommendedValidation: `Request ${r.label} as a principal lacking: ${dims.join(', ')}.`,
84
+ remediation: dims.map((d) => FIX[d]).filter(Boolean).join(' ') || 'Enforce the controls the invariant requires.',
85
+ deterministic: { rule: { kind: 'invariant', id: r.invariantId, title: r.title, declaredSeverity: r.severity || null }, invariant: { id: r.invariantId, title: r.title, requires: dims }, scope: { resource: null, control: dims, route: r.label, files: [r.file] }, counterexample: null, fix: { recommendation: dims.map((d) => FIX[d]).filter(Boolean) }, verification: { status: 'pending' } },
86
+ _bundle: b, _invariant: r,
87
+ });
88
+ }
89
+ return out.sort((a, b) => (a.id < b.id ? -1 : 1));
90
+ }
91
+
92
+ /**
93
+ * Attempt a counterexample for every candidate that has a cross-route basis, set each candidate's confirmation tier from it, and run the shared
94
+ * adjudication. Refuted candidates are returned separately and produce no finding.
95
+ */
96
+ export async function dciFindings({ candidates, bundles, fixtures = {}, scanRoot = null }) {
97
+ const states = new Map(); const refuted = [];
98
+ const live = [];
99
+ for (const c of candidates) {
100
+ let a;
101
+ if (c._crossRoute) a = assessCandidate(c._crossRoute, { bundles, fixture: fixtures[c._crossRoute.route] || fixtures[c.id] });
102
+ else a = { state: 'plausible', trace: [{ kind: 'static', what: 'invariant evaluation', detail: 'the protection verdict for the required control is absent' }], reasons: ['no sibling route to attempt a counterexample against'] };
103
+ states.set(c.id, a);
104
+ const pub = { state: a.state, trace: a.trace, reasons: a.reasons };
105
+ if (a.state === 'refuted') { refuted.push({ id: c.id, title: c.title, file: c.file, line: c.line, refutation: pub }); continue; }
106
+ live.push({ ...c, deterministic: { ...c.deterministic, counterexample: pub } });
107
+ }
108
+ const clean = live.map(({ _bundle, _crossRoute, _invariant, ...rest }) => rest);
109
+ const taintProbe = async (cand) => ({ tier: TIER_OF[states.get(cand.id).state] || 'unconfirmed', evidence: { counterexample: states.get(cand.id).state } });
110
+ const adj = await adjudicate(clean, { taintProbe, scanRoot });
111
+ return { ...adj, counterexampleRefuted: refuted, assessments: Object.fromEntries([...states].map(([k, v]) => [k, v.state])) };
112
+ }
113
+
114
+ // ---- final verification of a fix ------------------------------------------------------------------------------------------------
115
+
116
+ async function analyse(files, invariants, now) {
117
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dci-verify-'));
118
+ try {
119
+ fs.writeFileSync(path.join(root, 'package.json'), '{"name":"p","version":"1.0.0"}');
120
+ for (const [f, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(root, f)), { recursive: true }); fs.writeFileSync(path.join(root, f), t); }
121
+ const built = await buildContext(root, { config: normalizeConfig({}).config });
122
+ if (!built.ok) return { ok: false, reason: built.reason };
123
+ const crossRoute = findCrossRouteInconsistencies(built.bundles, { now, invariants });
124
+ const inv = invariants ? evaluateInvariants(invariants, built.bundles, { now }) : { results: [], problems: [] };
125
+ return { ok: true, bundles: built.bundles, crossRoute, invariantResults: inv.results };
126
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
127
+ }
128
+
129
+ /**
130
+ * Settle a DCI finding after a patch: is the cross-route inconsistency (or the invariant violation) gone in the patched tree, and does the same
131
+ * executed attempt now fail? Returns a verdict and a patch-level verification record in the shared fix-verification schema, so the verified-fix policy
132
+ * (completeness tiers, residual risk) applies unchanged.
133
+ * @param {{finding:object, candidate:object, before:Record<string,string>, after:Record<string,string>, fixture?:object, invariants?:object, now?:Date}} o
134
+ */
135
+ export async function verifyDciFix({ finding, candidate, before, after, fixture = null, invariants = null, now = new Date() }) {
136
+ const post = await analyse(after, invariants, now);
137
+ const route = candidate._crossRoute?.route || candidate._invariant?.label;
138
+ const dim = candidate._crossRoute?.dimension;
139
+ const stillInconsistent = post.ok && post.crossRoute.findings.some((f) => f.route === route && (!dim || f.dimension === dim));
140
+ const stillViolated = post.ok && post.invariantResults.some((r) => r.verdict === 'violated' && r.label === route && (!candidate._invariant || r.invariantId === candidate._invariant.invariantId));
141
+ let after1 = null;
142
+ // the attempt is EXECUTED on the patched files even though a static look may already see the control: a fix is verified by the attack failing, not by the code looking right
143
+ if (candidate._crossRoute && fixture) after1 = executeAttempt(candidate._crossRoute, { ...fixture, files: after });
144
+ const execDenied = after1 && after1.state === 'refuted' && after1.trace.some((t) => t.kind === 'executed');
145
+ const execStillWorks = after1 && after1.state === 'proven';
146
+ const ev = []; const add = (kind, v) => { const e = evidenceItem({ kind, mediaType: 'application/json', sha256: sha(v) }); if (!ev.some((x) => x.id === e.id)) ev.push(e); return e.id; };
147
+ const before1 = finding?.contextIntelligence?.deterministic?.counterexample;
148
+ const securityAfter = !post.ok ? leg('blocked', { reason: `the patched tree could not be analysed (${post.reason})` })
149
+ : (stillInconsistent || stillViolated) ? leg('failed', { evidenceIds: [add('dci-reanalysis', { stillInconsistent, stillViolated })], reason: 'the same inconsistency or invariant violation is still reported on the patched tree' })
150
+ : leg('passed', { evidenceIds: [add('dci-reanalysis', { stillInconsistent, stillViolated })] });
151
+ const files = Object.keys(after).filter((f) => after[f] !== before[f]);
152
+ const record = buildFixVerificationRecord({
153
+ findingId: finding?.id || candidate.id, findingStableId: finding?.stableId, mode: 'verify', route: 'dci-reverify', patchHash: patchHashOf(Object.fromEntries(files.map((f) => [f, after[f]]))), postPatchHash: patchHashOf(after), affectedFiles: files.length ? files : ['(none)'],
154
+ securityBefore: leg('passed', { evidenceIds: [add('dci-finding', { id: finding?.id, scope: finding?.contextIntelligence?.deterministic?.scope })] }),
155
+ securityAfter,
156
+ exploitProofBefore: before1 && before1.state === 'proven' ? leg('passed', { evidenceIds: [add('dci-counterexample', before1)] }) : leg('not_run', { reason: 'the finding had no executed counterexample' }),
157
+ exploitProofAfter: execDenied ? leg('passed', { evidenceIds: [add('dci-counterexample-after', after1)] }) : execStillWorks ? leg('failed', { evidenceIds: [add('dci-counterexample-after', after1)], reason: 'the same executed attempt still succeeds on the patched tree' }) : leg('not_run', { reason: fixture ? 'the executed attempt could not be made on the patched tree' : 'no fixture was supplied' }),
158
+ newFindingDelta: post.ok ? (post.crossRoute.findings.filter((f) => !before1 || f.route !== route).length > 0 ? leg('not_run', { reason: 'other inconsistencies remain; not compared with the pre-patch set here' }) : leg('passed', { evidenceIds: [add('dci-reanalysis', { remaining: post.crossRoute.findings.length })] })) : leg('not_run', { reason: 'the patched tree could not be analysed' }),
159
+ behaviorPreservation: leg('not_run', { reason: 'run the project tests through the fix verifier (verifyPatch) to cover behaviour' }),
160
+ behaviorTests: leg('not_run', { reason: 'not run by this check' }),
161
+ // behaviour is required like everywhere else: a re-analysis and an attack that fails say the weakness is gone, not that the feature survived
162
+ requiredLegs: ['securityAfter', 'behaviorPreservation', 'behaviorTests', ...(fixture ? ['exploitProofAfter'] : [])], evidence: ev, tools: { engineVersion: null, verifier: 'dci-reverify' },
163
+ });
164
+ const status = (stillInconsistent || stillViolated || execStillWorks) ? 'failed' : (!post.ok ? 'inconclusive' : 'verified');
165
+ return { status, stillInconsistent, stillViolated, executedAfter: after1 ? { state: after1.state, trace: after1.trace } : null, record };
166
+ }
@@ -0,0 +1,197 @@
1
+ // First-class security invariants for Deterministic Context Intelligence (E4-02).
2
+ //
3
+ // An invariant is a rule a team states once, in a versioned file, and the analysis then checks on every entry point it applies to:
4
+ // "every /orders route must be authenticated and owner-scoped", "the health check is public by design". It replaces the reviewer's unwritten
5
+ // expectation with something that can be validated, scoped, expired and audited.
6
+ //
7
+ // FILE context-intelligence-invariants.json at the project root (or a path the caller passes)
8
+ // SCHEMA { schema: 'agentic-security/dci-invariants', schemaVersion: '1.0.0', invariants: [ ... ] }
9
+ // RULES 'requires' the matching entry points must carry every listed control before their first sensitive operation
10
+ // 'public-by-design' the matching entry points are deliberately open on the listed dimensions; this is the only way a missing control
11
+ // is accepted, and it needs a reason, an owner and a review date
12
+ // SCOPE appliesTo.routes / methods choose entry points by label; validity.paths limits the invariant to handlers in those files
13
+ // (path-scoped validity); validity.from / until bound it in time. An invariant that is out of scope for an entry point says
14
+ // `not_applicable`, never `satisfied`.
15
+ //
16
+ // VERDICTS satisfied | violated | unknown (the analysis could not resolve enough to say) | not_applicable | public_by_policy | expired
17
+ // A verdict never rests on a model. It reads the protection states DCI already computed, so it is as strong as that evidence and says so.
18
+ //
19
+ // The validator is closed-world: an unknown field, a duplicate id, an empty control list, a glob that is not a glob, a public-by-design with no
20
+ // reason, or an invariant already past its `until` date is reported with a path and a code, never silently dropped.
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { join as pathJoin } from 'node:path';
23
+
24
+ export const INVARIANTS_SCHEMA = 'agentic-security/dci-invariants';
25
+ export const INVARIANTS_VERSION = '1.0.0';
26
+ export const INVARIANTS_FILE = 'context-intelligence-invariants.json';
27
+ export const CONTROL_DIMENSIONS = Object.freeze(['authentication', 'authorization-role', 'ownership', 'tenant-scope']);
28
+ export const VERDICTS = Object.freeze(['satisfied', 'violated', 'unknown', 'not_applicable', 'public_by_policy', 'expired']);
29
+ const RULES = ['requires', 'public-by-design'];
30
+ const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ANY'];
31
+ const ID_RE = /^[A-Z][A-Z0-9-]{2,63}$/;
32
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
33
+ const TOP = ['schema', 'schemaVersion', 'invariants'];
34
+ const INV = ['id', 'title', 'rule', 'appliesTo', 'requires', 'waives', 'public', 'validity', 'severity'];
35
+ const APPLIES = ['routes', 'methods'];
36
+ const VALID = ['paths', 'from', 'until'];
37
+ const PUB = ['reason', 'owner', 'reviewBy'];
38
+ const SEVERITIES = ['critical', 'high', 'medium', 'low'];
39
+
40
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
41
+ const validGlob = (g) => typeof g === 'string' && g.length > 0 && g.length <= 200 && !/[\0\r\n]/.test(g) && !/\*\*\*/.test(g) && !g.includes('..');
42
+
43
+ /** Glob to RegExp: `**` any characters, `*` any characters except `/`, everything else literal. */
44
+ export function globToRegExp(glob) {
45
+ let re = '';
46
+ for (let i = 0; i < glob.length; i++) {
47
+ const c = glob[i];
48
+ if (c === '*') { if (glob[i + 1] === '*') { re += '.*'; i++; } else re += '[^/]*'; } else re += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
49
+ }
50
+ return new RegExp(`^${re}$`);
51
+ }
52
+
53
+ /** @returns {{ok:boolean, errors:{code:string,path:string,message:string}[], warnings:{code:string,path:string,message:string}[]}} */
54
+ export function validateInvariants(doc, { now = new Date() } = {}) {
55
+ const errors = []; const warnings = [];
56
+ const err = (code, path, message) => errors.push({ code, path, message });
57
+ const warn = (code, path, message) => warnings.push({ code, path, message });
58
+ if (!isObj(doc)) { err('NOT_AN_OBJECT', '', 'the invariants file must be a JSON object'); return { ok: false, errors, warnings }; }
59
+ for (const k of Object.keys(doc)) if (!TOP.includes(k)) err('UNKNOWN_FIELD', k, `field '${k}' is not part of this schema`);
60
+ if (doc.schema !== INVARIANTS_SCHEMA) { err('SCHEMA_MISMATCH', 'schema', `expected ${INVARIANTS_SCHEMA}`); return { ok: false, errors, warnings }; }
61
+ const m = typeof doc.schemaVersion === 'string' ? /^(\d+)\.(\d+)\.(\d+)$/.exec(doc.schemaVersion) : null;
62
+ if (!m) { err('BAD_VERSION', 'schemaVersion', 'must be a semantic version'); return { ok: false, errors, warnings }; }
63
+ if (Number(m[1]) !== 1) { err('UNSUPPORTED_MAJOR', 'schemaVersion', `major ${m[1]} is not supported (supported: 1)`); return { ok: false, errors, warnings }; }
64
+ if (!Array.isArray(doc.invariants)) { err('MISSING_FIELD', 'invariants', 'must be a list'); return { ok: false, errors, warnings }; }
65
+ if (doc.invariants.length > 500) err('TOO_MANY', 'invariants', 'at most 500 invariants');
66
+ const today = new Date(now); const iso = (d) => d.toISOString().slice(0, 10);
67
+ const seen = new Set();
68
+ doc.invariants.forEach((inv, i) => {
69
+ const p = `invariants[${i}]`;
70
+ if (!isObj(inv)) { err('BAD_TYPE', p, 'an invariant is an object'); return; }
71
+ for (const k of Object.keys(inv)) if (!INV.includes(k)) err('UNKNOWN_FIELD', `${p}.${k}`, `field '${k}' is not part of an invariant`);
72
+ if (typeof inv.id !== 'string' || !ID_RE.test(inv.id)) err('BAD_ID', `${p}.id`, 'id must look like ORDERS-001: capitals, digits and hyphens, 3 to 64 characters');
73
+ else if (seen.has(inv.id)) err('DUPLICATE_ID', `${p}.id`, `duplicate id ${inv.id}`); else seen.add(inv.id);
74
+ if (typeof inv.title !== 'string' || !inv.title.trim()) err('MISSING_FIELD', `${p}.title`, 'a title is required');
75
+ if (!RULES.includes(inv.rule)) err('UNKNOWN_ENUM', `${p}.rule`, `rule must be one of ${RULES.join(', ')}`);
76
+ if (inv.severity !== undefined && !SEVERITIES.includes(inv.severity)) err('UNKNOWN_ENUM', `${p}.severity`, `severity must be one of ${SEVERITIES.join(', ')}`);
77
+ const a = inv.appliesTo;
78
+ if (!isObj(a)) err('MISSING_FIELD', `${p}.appliesTo`, 'appliesTo is required');
79
+ else {
80
+ for (const k of Object.keys(a)) if (!APPLIES.includes(k)) err('UNKNOWN_FIELD', `${p}.appliesTo.${k}`, `field '${k}' is not part of appliesTo`);
81
+ if (!Array.isArray(a.routes) || !a.routes.length) err('MISSING_FIELD', `${p}.appliesTo.routes`, 'at least one route glob is required');
82
+ else a.routes.forEach((g, j) => { if (!validGlob(g)) err('BAD_GLOB', `${p}.appliesTo.routes[${j}]`, 'not a usable glob'); else if (g === '**' || g === '/**') warn('BROAD_SCOPE', `${p}.appliesTo.routes[${j}]`, 'matches every route'); });
83
+ if (a.methods !== undefined && (!Array.isArray(a.methods) || !a.methods.length || a.methods.some((x) => !METHODS.includes(x)))) err('UNKNOWN_ENUM', `${p}.appliesTo.methods`, `methods must be a non-empty list of ${METHODS.join(', ')}`);
84
+ }
85
+ const dimList = (field, value) => {
86
+ if (!Array.isArray(value) || !value.length) { err('MISSING_FIELD', `${p}.${field}`, `${field} must list at least one control`); return; }
87
+ if (new Set(value).size !== value.length) err('DUPLICATE_ID', `${p}.${field}`, 'a control is listed twice');
88
+ value.forEach((d, j) => { if (!CONTROL_DIMENSIONS.includes(d)) err('UNKNOWN_ENUM', `${p}.${field}[${j}]`, `control must be one of ${CONTROL_DIMENSIONS.join(', ')}`); });
89
+ };
90
+ if (inv.rule === 'requires') {
91
+ dimList('requires', inv.requires);
92
+ if (inv.waives !== undefined || inv.public !== undefined) err('RULE_VIOLATION', `${p}`, "'requires' does not take waives or public");
93
+ } else if (inv.rule === 'public-by-design') {
94
+ dimList('waives', inv.waives);
95
+ if (inv.requires !== undefined) err('RULE_VIOLATION', `${p}.requires`, "'public-by-design' does not take requires");
96
+ const pub = inv.public;
97
+ if (!isObj(pub)) err('MISSING_FIELD', `${p}.public`, 'a public-by-design invariant needs { reason, owner, reviewBy }');
98
+ else {
99
+ for (const k of Object.keys(pub)) if (!PUB.includes(k)) err('UNKNOWN_FIELD', `${p}.public.${k}`, `field '${k}' is not part of public`);
100
+ if (typeof pub.reason !== 'string' || pub.reason.trim().length < 10) err('MISSING_FIELD', `${p}.public.reason`, 'a reason of at least 10 characters is required: openness must be justified');
101
+ if (typeof pub.owner !== 'string' || !pub.owner.trim()) err('MISSING_FIELD', `${p}.public.owner`, 'an owner is required');
102
+ if (typeof pub.reviewBy !== 'string' || !DATE_RE.test(pub.reviewBy) || Number.isNaN(Date.parse(pub.reviewBy))) err('BAD_DATE', `${p}.public.reviewBy`, 'reviewBy must be a YYYY-MM-DD date');
103
+ else if (pub.reviewBy < iso(today)) err('EXPIRED', `${p}.public.reviewBy`, `the review date ${pub.reviewBy} has passed: public-by-design must be re-approved`);
104
+ }
105
+ }
106
+ const v = inv.validity;
107
+ if (v !== undefined) {
108
+ if (!isObj(v)) err('BAD_TYPE', `${p}.validity`, 'validity is an object');
109
+ else {
110
+ for (const k of Object.keys(v)) if (!VALID.includes(k)) err('UNKNOWN_FIELD', `${p}.validity.${k}`, `field '${k}' is not part of validity`);
111
+ if (v.paths !== undefined) { if (!Array.isArray(v.paths) || !v.paths.length) err('BAD_TYPE', `${p}.validity.paths`, 'paths must be a non-empty list of globs'); else v.paths.forEach((g, j) => { if (!validGlob(g)) err('BAD_GLOB', `${p}.validity.paths[${j}]`, 'not a usable glob'); }); }
112
+ for (const f of ['from', 'until']) if (v[f] !== undefined && (typeof v[f] !== 'string' || !DATE_RE.test(v[f]) || Number.isNaN(Date.parse(v[f])))) err('BAD_DATE', `${p}.validity.${f}`, `${f} must be a YYYY-MM-DD date`);
113
+ if (v.from && v.until && v.from > v.until) err('RULE_VIOLATION', `${p}.validity`, 'from is after until');
114
+ if (v.until && v.until < iso(today)) warn('PAST_UNTIL', `${p}.validity.until`, `until ${v.until} has passed: the invariant is reported as expired`);
115
+ }
116
+ }
117
+ });
118
+ return { ok: errors.length === 0, errors, warnings };
119
+ }
120
+
121
+ const norm = (s) => String(s || '').trim();
122
+ function routeMatches(a, label, method) {
123
+ if (Array.isArray(a.methods) && method && !a.methods.includes('ANY') && !a.methods.includes(method) && method !== 'ANY') return false;
124
+ // the label is `METHOD /path`; route globs name the path
125
+ const path = norm(label).replace(/^[A-Z]+\s+/, '');
126
+ return a.routes.some((g) => globToRegExp(g).test(path));
127
+ }
128
+ function handlerFile(ep) { return ep?.rootSpan?.file || ep?.registrationSite?.file || ''; }
129
+
130
+ /** Control state of one dimension for a bundle: present / absent / unresolved / none-to-protect. */
131
+ function dimensionState(bundle, dim) {
132
+ const prot = bundle.manifest?.protection || {};
133
+ if (dim === 'tenant-scope') {
134
+ const facts = bundle.reach?.facts || [];
135
+ const sens = facts.filter((f) => f.category === 'sensitive');
136
+ if (!sens.length) return { state: 'none-to-protect', why: 'no sensitive operation was seen' };
137
+ const first = Math.min(...sens.map((f) => f.effLine));
138
+ const ctl = facts.filter((f) => f.category === 'control' && f.kind === 'tenant-scope' && (f.effLine < first || (f.basis === 'query-filter' && f.effLine <= first)));
139
+ if (ctl.length) return { state: 'present', why: 'a tenant scope precedes the first sensitive operation', evidence: ctl.slice(0, 4).map((f) => ({ file: f.file, line: f.line, basis: f.basis })) };
140
+ const unresolved = (bundle.manifest?.unknowns || []).some((u) => u.material);
141
+ return unresolved ? { state: 'unresolved', why: 'an unresolved call could hide a tenant scope' } : { state: 'absent', why: 'the resolved context contains no tenant scope' };
142
+ }
143
+ const d = prot[dim];
144
+ if (!d) return { state: 'unresolved', why: 'the bundle carries no protection verdict for this dimension' };
145
+ if (d.state === 'present') return { state: 'present', why: d.why, evidence: d.evidence };
146
+ if (d.state === 'not_seen') return { state: 'none-to-protect', why: d.why };
147
+ if (d.state === 'not_present' || d.state === 'evaluated_and_ineffective') return { state: 'absent', why: d.why, evidence: d.evidence };
148
+ return { state: 'unresolved', why: d.why };
149
+ }
150
+
151
+ /**
152
+ * @param {object} doc a validated invariants document
153
+ * @param {{entrypoint:object, manifest:object, reach:object}[]} bundles the bundles buildContext returned (those with a manifest)
154
+ * @returns {{results:object[], problems:string[]}}
155
+ */
156
+ export function evaluateInvariants(doc, bundles, { now = new Date() } = {}) {
157
+ const v = validateInvariants(doc, { now });
158
+ if (!v.ok) return { results: [], problems: v.errors.map((e) => `${e.path}: ${e.code}`) };
159
+ const today = now.toISOString().slice(0, 10);
160
+ const results = [];
161
+ const publics = doc.invariants.filter((i) => i.rule === 'public-by-design');
162
+ for (const b of bundles.filter((x) => x.manifest)) {
163
+ const ep = b.entrypoint; const file = handlerFile(ep);
164
+ for (const inv of doc.invariants) {
165
+ if (inv.rule !== 'requires') continue;
166
+ const base = { invariantId: inv.id, title: inv.title, entrypointId: ep.id, label: ep.label, file, ...(inv.severity ? { severity: inv.severity } : {}) };
167
+ if (!routeMatches(inv.appliesTo, ep.label, ep.method)) continue;
168
+ const val = inv.validity || {};
169
+ if (Array.isArray(val.paths) && !val.paths.some((g) => globToRegExp(g).test(file))) { results.push({ ...base, verdict: 'not_applicable', reasons: [`the handler file ${file} is outside this invariant's validity paths`] }); continue; }
170
+ if (val.from && today < val.from) { results.push({ ...base, verdict: 'not_applicable', reasons: [`the invariant is valid from ${val.from}`] }); continue; }
171
+ if (val.until && today > val.until) { results.push({ ...base, verdict: 'expired', reasons: [`the invariant expired on ${val.until}`] }); continue; }
172
+ const dims = inv.requires.map((d) => ({ dim: d, ...dimensionState(b, d) }));
173
+ const waivedBy = (dim) => publics.find((p) => p.waives.includes(dim) && routeMatches(p.appliesTo, ep.label, ep.method) && (!p.validity?.paths || p.validity.paths.some((g) => globToRegExp(g).test(file))) && !(p.public.reviewBy < today));
174
+ const missing = dims.filter((x) => x.state === 'absent');
175
+ const unresolved = dims.filter((x) => x.state === 'unresolved');
176
+ const allNone = dims.every((x) => x.state === 'none-to-protect');
177
+ if (allNone) { results.push({ ...base, verdict: 'not_applicable', reasons: ['no sensitive operation was seen, so there is nothing for a control to protect'], controls: dims }); continue; }
178
+ const unwaived = missing.filter((x) => !waivedBy(x.dim));
179
+ const waived = missing.filter((x) => waivedBy(x.dim));
180
+ if (unwaived.length) results.push({ ...base, verdict: 'violated', reasons: unwaived.map((x) => `${x.dim}: ${x.why}`), controls: dims });
181
+ else if (waived.length) results.push({ ...base, verdict: 'public_by_policy', reasons: waived.map((x) => `${x.dim} is waived by ${waivedBy(x.dim).id}: ${waivedBy(x.dim).public.reason}`), waivedBy: [...new Set(waived.map((x) => waivedBy(x.dim).id))], controls: dims });
182
+ else if (unresolved.length) results.push({ ...base, verdict: 'unknown', reasons: unresolved.map((x) => `${x.dim}: ${x.why}`), controls: dims });
183
+ else results.push({ ...base, verdict: 'satisfied', reasons: dims.filter((x) => x.state === 'present').map((x) => `${x.dim}: ${x.why}`), controls: dims });
184
+ }
185
+ }
186
+ results.sort((a, b) => (a.invariantId + a.entrypointId < b.invariantId + b.entrypointId ? -1 : 1));
187
+ return { results, problems: [] };
188
+ }
189
+
190
+ /** Read and validate the invariants file of a project. A missing file is "no invariants", not an error; a malformed one reports every problem. */
191
+ export function loadInvariants(root, { file = INVARIANTS_FILE, now } = {}) {
192
+ const full = pathJoin(root, file);
193
+ if (!existsSync(full)) return { present: false, doc: null, validation: { ok: true, errors: [], warnings: [] } };
194
+ let doc;
195
+ try { doc = JSON.parse(readFileSync(full, 'utf8')); } catch (e) { return { present: true, doc: null, validation: { ok: false, errors: [{ code: 'BAD_JSON', path: '', message: String(e.message).slice(0, 200) }], warnings: [] } }; }
196
+ return { present: true, doc, validation: validateInvariants(doc, now ? { now } : undefined) };
197
+ }
@@ -143,6 +143,8 @@ export async function runQueue(jobs, ctx) {
143
143
  maxCalls: Math.min(HARD_LIMITS.maxCalls, Math.max(1, ctx.limits.maxCalls || 100)),
144
144
  maxWallMs: Math.min(HARD_LIMITS.maxWallSeconds, Math.max(1, ctx.limits.maxWallSeconds || 900)) * 1000,
145
145
  maxTokensPerJob: ctx.limits.maxTokensPerJob || 40000,
146
+ maxTokensPerRepo: Number.isFinite(ctx.limits.maxTokensPerRepo) ? ctx.limits.maxTokensPerRepo : null,
147
+ anonymizePrompts: ctx.limits.anonymizePrompts === true,
146
148
  maxRetries: Math.min(HARD_LIMITS.maxRetries, Math.max(0, ctx.limits.maxRetries ?? 3)),
147
149
  jobTimeoutMs: ctx.limits.jobTimeoutMs || 120000,
148
150
  maxCostUsd: Number.isFinite(ctx.limits.maxCostUsd) ? ctx.limits.maxCostUsd : null,
@@ -156,6 +158,7 @@ export async function runQueue(jobs, ctx) {
156
158
  const results = new Map();
157
159
  let calls = 0;
158
160
  let estCost = 0;
161
+ let tokensUsed = 0;
159
162
  let cooldownUntil = 0;
160
163
  let budgetStop = null;
161
164
  let persistChain = Promise.resolve();
@@ -205,6 +208,7 @@ export async function runQueue(jobs, ctx) {
205
208
  if (ctx.signal && ctx.signal.aborted) return 'cancelled';
206
209
  if (now() - started >= L.maxWallMs) return 'budget-exhausted:wall-time';
207
210
  if (calls >= L.maxCalls) return 'budget-exhausted:max-calls';
211
+ if (L.maxTokensPerRepo !== null && tokensUsed >= L.maxTokensPerRepo) return 'budget-exhausted:max-tokens-per-repo';
208
212
  if (L.maxCostUsd !== null && L.costPerCallUsd !== null && estCost + L.costPerCallUsd > L.maxCostUsd) return 'budget-exhausted:max-cost';
209
213
  return null;
210
214
  };
@@ -228,8 +232,9 @@ export async function runQueue(jobs, ctx) {
228
232
  state.jobs[job.key] = { state: 'running', reason: null, attempts, label: job.label };
229
233
  const out = await runReviewOnce({
230
234
  pack: loadedJob.pack, entrypoints: loadedJob.entrypoints, manifests: loadedJob.manifests, packageIndexes: job.packageIndexes, packageTexts: loadedJob.packageTexts,
231
- fileContents: ctx.fileContents, scanRoot: ctx.root, invoker: ctx.invoker, model: ctx.model, limits: { maxTokensPerJob: L.maxTokensPerJob, jobTimeoutMs: L.jobTimeoutMs },
235
+ fileContents: ctx.fileContents, scanRoot: ctx.root, invoker: ctx.invoker, model: ctx.model, limits: { maxTokensPerJob: L.maxTokensPerJob, jobTimeoutMs: L.jobTimeoutMs, anonymizePrompts: L.anonymizePrompts },
232
236
  });
237
+ if (out.usage) tokensUsed += (out.usage.inputTokensEstimated || 0) + (out.usage.outputTokensEstimated || 0);
233
238
  if (out.state === 'skipped_by_policy') calls--; // nothing was sent
234
239
  if (out.state === 'failed' && out.retryable && attempts <= L.maxRetries) {
235
240
  // exponential backoff with jitter; the shared cooldown slows every worker, which is what a 429 storm needs
@@ -11,7 +11,7 @@
11
11
  // * hide a limit. Hitting the symbol or depth cap marks the walk `capped` and lists what was left unvisited.
12
12
  // * lose a control. Middleware and every callee are walked, and each visited symbol's controls are collected by the
13
13
  // same classifier the packer uses to refuse pruning.
14
- import { analyzeSymbolText } from './controls.js';
14
+ import { analyzeSymbolText, blankComments } from './controls.js';
15
15
  import { cmpCodeUnit } from './canonical.js';
16
16
  import { MATERIAL_REASONS } from './graph-schema.js';
17
17
 
@@ -36,7 +36,7 @@ export function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
36
36
  const lim = { ...DEFAULT_LIMITS, ...limits };
37
37
  const { byId, edgesFrom } = indexGraph(graph);
38
38
  const linesCache = new Map();
39
- const linesOf = (f) => { if (!linesCache.has(f)) linesCache.set(f, (fileContents[f] || '').split('\n')); return linesCache.get(f); };
39
+ const linesOf = (f) => { if (!linesCache.has(f)) linesCache.set(f, blankComments(fileContents[f] || '', f).split('\n')); return linesCache.get(f); };
40
40
 
41
41
  const visited = [];
42
42
  const seen = new Set();
@@ -138,12 +138,69 @@ export function checkEgressForFiles(files, { scanRoot, model }) {
138
138
  // other endpoint needs --allow-remote (or review.allowRemote), on top of the egress policy below, never instead of it.
139
139
  if (model.remote && !model.remoteApproved) return { allowed: false, reason: 'remote-endpoint-not-approved (pass --allow-remote or set review.allowRemote after reviewing the egress policy)', file: files[0] || null, evaluated: false };
140
140
  for (const file of files) {
141
- const d = evaluateEgress({ scanRoot, purpose: 'context-review', endpoint: model.endpoint, provider: model.provider, model: model.model, path: file });
142
- if (!d.allowed) return { allowed: false, reason: d.reason, file, evaluated: true };
141
+ // Fail closed. A policy check that throws (a corrupt policy file, an unreadable path, a bug) is not an allow: the answer is "deny", with the
142
+ // reason, and nothing is sent. This is the only place that decides whether a prompt may leave the machine.
143
+ let d;
144
+ try { d = evaluateEgress({ scanRoot, purpose: 'context-review', endpoint: model.endpoint, provider: model.provider, model: model.model, path: file }); }
145
+ catch (err) { return { allowed: false, reason: `egress-policy-error: ${String((err && err.message) || err).slice(0, 120)}`, file, evaluated: true, failedClosed: true }; }
146
+ if (!d || d.allowed !== true) return { allowed: false, reason: (d && d.reason) || 'egress-policy-returned-no-decision', file, evaluated: true, ...(d ? {} : { failedClosed: true }) };
143
147
  }
144
148
  return { allowed: true, evaluated: true };
145
149
  }
146
150
 
151
+ // ---- model output hygiene -------------------------------------------------------------------------------------
152
+
153
+ /**
154
+ * A model's text is untrusted. It can carry a command for a human or a script to run ("run curl ... | sh to confirm"). Nothing here executes model text, but
155
+ * these strings reach reports, tickets and fix suggestions that people copy from, so shell-looking content is removed and counted, never passed on.
156
+ */
157
+ const FENCE = /```[\s\S]*?```|~~~[\s\S]*?~~~/g;
158
+ const CMD_WORD = '(?:curl|wget|bash|sh|zsh|sudo|su|rm|chmod|chown|nc|ncat|netcat|python3?|node|perl|ruby|php|powershell|pwsh|cmd(?:\\.exe)?|eval|exec|scp|ssh|dd|mkfs|kill|pkill|git\\s+push|npm\\s+(?:i|install|publish)|pip3?\\s+install|docker\\s+run)';
159
+ const INLINE_CMD = new RegExp('`[^`\\n]*\\b' + CMD_WORD + '\\b[^`\\n]*`|\\$\\([^)]*\\)|\\b(?:curl|wget)\\s+[^\\s]+[^\\n]*\\|\\s*(?:sh|bash)\\b|(?:^|\\n)\\s*(?:\\$|#)\\s+' + CMD_WORD + '\\b[^\\n]*', 'gi');
160
+ export function stripUntrustedCommands(text) {
161
+ if (typeof text !== 'string' || !text) return { text: text ?? '', stripped: 0 };
162
+ let n = 0;
163
+ let out = text.replace(FENCE, () => { n++; return '[command block removed]'; });
164
+ out = out.replace(INLINE_CMD, () => { n++; return '[command removed]'; });
165
+ return { text: out, stripped: n };
166
+ }
167
+ function sanitizeReviewStrings(review) {
168
+ let total = 0;
169
+ const walk = (v, key) => {
170
+ if (typeof v === 'string') {
171
+ if (['promptHash', 'responseHash', 'contextHash', 'entrypointId', 'file', 'role', 'kind', 'conclusion', 'likelihood', 'questionPack', 'status'].includes(key)) return v;
172
+ const r = stripUntrustedCommands(v); total += r.stripped; return r.text;
173
+ }
174
+ if (Array.isArray(v)) return v.map((x) => walk(x, key));
175
+ if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v)) o[k] = walk(x, k); return o; }
176
+ return v;
177
+ };
178
+ const out = walk(review, '');
179
+ if (total) out.untrustedCommandsStripped = total;
180
+ return out;
181
+ }
182
+
183
+ /** Where a suggestion came from and what checked it. Attached to every review so a reader never mistakes a model's words for a finding. */
184
+ function provenanceOf(model, promptHash) {
185
+ let host = null; try { host = model.endpoint ? new URL(model.endpoint).hostname : null; } catch { host = null; }
186
+ const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
187
+ return { producer: 'model', provider: model.provider ?? null, model: model.model ?? null, endpointClass: !model.endpoint ? 'none' : loopback ? 'loopback' : 'remote', promptHash, checkedBy: 'deterministic-citation-verifier', promotedToFinding: false };
188
+ }
189
+
190
+ // ---- prompt anonymization ---------------------------------------------------------------------------------------
191
+
192
+ /** Replace each real file path with a stable pseudonym (`f_<hash>.<ext>`). The mapping stays in memory; only the pseudonyms leave. */
193
+ export function pseudonymizer(files) {
194
+ const toPseudo = new Map(); const toReal = new Map();
195
+ for (const f of [...new Set(files)].sort()) {
196
+ const ext = (/\.[A-Za-z0-9]{1,6}$/.exec(f) || [''])[0];
197
+ const p = `f_${sha256(f).slice(0, 10)}${ext}`;
198
+ toPseudo.set(f, p); toReal.set(p, f);
199
+ }
200
+ const swap = (text, map) => { let t = text; for (const [a, b] of [...map].sort((x, y) => y[0].length - x[0].length)) t = t.split(a).join(b); return t; };
201
+ return { toPseudo: (t) => swap(t, toPseudo), toReal: (t) => swap(t, toReal), size: toPseudo.size };
202
+ }
203
+
147
204
  // ---- response parsing and verification ------------------------------------------------------------------------
148
205
 
149
206
  function extractJson(raw) {
@@ -352,7 +409,12 @@ export async function runReviewOnce({ pack, entrypoints, manifests, packageIndex
352
409
  const badRedact = redacted.find(r => !r.ok);
353
410
  if (badRedact) return { state: 'skipped_by_policy', reason: `redaction refused: ${badRedact.reason}` };
354
411
 
355
- const { prompt, promptHash } = buildReviewPrompt({ pack, entrypoints, packageTexts: redacted.map(r => r.text), contextHash: manifests[0].contextHash });
412
+ // Policy-driven anonymization: file paths are replaced by pseudonyms before the prompt is built, and mapped back in the answer before its citations
413
+ // are checked against the real snapshot. The model never sees a real path, and what is hashed is what was sent.
414
+ const anon = limits.anonymizePrompts ? pseudonymizer(files) : null;
415
+ const sendTexts = anon ? redacted.map(r => anon.toPseudo(r.text)) : redacted.map(r => r.text);
416
+ const sendEntrypoints = anon ? entrypoints.map(e => JSON.parse(anon.toPseudo(JSON.stringify(e)))) : entrypoints;
417
+ const { prompt, promptHash } = buildReviewPrompt({ pack, entrypoints: sendEntrypoints, packageTexts: sendTexts, contextHash: manifests[0].contextHash });
356
418
  const est = estimateTokens(prompt);
357
419
  if (est.tokens > limits.maxTokensPerJob) return { state: 'skipped_by_policy', reason: `prompt is about ${est.tokens} tokens, over the ${limits.maxTokensPerJob} per-job limit` };
358
420
 
@@ -364,7 +426,9 @@ export async function runReviewOnce({ pack, entrypoints, manifests, packageIndex
364
426
  return { state: 'failed', reason: `model call failed: ${String((err && err.message) || err).slice(0, 160)}`, retryable: isRetryableError(err) };
365
427
  }
366
428
  const usage = { inputTokensEstimated: est.tokens, outputTokensEstimated: Math.ceil(String(raw || '').length / 4), source: 'estimate', redactions: redacted.reduce((n, r) => n + r.redactions, 0) };
367
- const review = verifyReview({ raw, pack, entrypoints, manifests, packageIndexes, fileContents, promptHash });
429
+ const rawReal = anon && typeof raw === 'string' ? anon.toReal(raw) : raw;
430
+ const review0 = verifyReview({ raw: rawReal, pack, entrypoints, manifests, packageIndexes, fileContents, promptHash });
431
+ const review = { ...sanitizeReviewStrings(review0), modelProvenance: provenanceOf(model, promptHash), ...(anon ? { promptAnonymized: true } : {}) };
368
432
  if (review.status === 'declined') return { state: 'partial', reason: `answer declined: ${review.declineReasons.join(',')}`, review, usage };
369
433
  return { state: 'succeeded', reason: null, review, usage };
370
434
  }
@@ -67,6 +67,17 @@ export function pathIsCoveredByPrefix(path, prefix) {
67
67
  * - `set = {"x.z"}` does NOT cover "x.y" ✗
68
68
  * - `set = {"x.y.z"}` does NOT cover "x.y" ✗ (we don't propagate UP)
69
69
  */
70
+ /**
71
+ * True iff some path in `set` lies STRICTLY BELOW `path` (`o.cmd` is below `o`). Reading `o` as a whole value (passing it to a function, serialising
72
+ * it) reads every field, so a tainted field taints the whole read; reading `o.safe` does not, because `o.cmd` is not below `o.safe`.
73
+ */
74
+ export function hasTaintedDescendant(set, path) {
75
+ if (!set || typeof path !== 'string') return false;
76
+ const prefix = `${path}.`;
77
+ for (const p of set) if (typeof p === 'string' && p.startsWith(prefix)) return true;
78
+ return false;
79
+ }
80
+
70
81
  export function isCoveredBy(set, path) {
71
82
  if (!set || typeof path !== 'string') return false;
72
83
  if (set.has(path)) return true;
@@ -0,0 +1,66 @@
1
+ // Dynamic features the static analysis cannot resolve, reported instead of silently approximated (E2-03).
2
+ //
3
+ // Where the engine meets one of these it takes the sound direction (a write through an unknown key taints the whole container, a read through
4
+ // an unknown key reads the whole container) and loses precision. That is the right trade, but it must be visible: a result that depended on it is
5
+ // less precise than one that did not, and a reviewer should be able to see which functions contain such constructs.
6
+ //
7
+ // Pure: reads the Layer-1 IR only.
8
+ const LIMIT_KINDS = Object.freeze({
9
+ 'computed-write': 'a write through a key that is not a literal (`o[k] = v`): the engine taints the whole container `o`, so clean sibling fields read afterwards are over-approximated as tainted',
10
+ 'computed-read': 'a read through a key that is not a literal (`o[k]`): the engine cannot tell which field is read, so any tainted field of `o` reaches it',
11
+ 'eval-like': 'code built at run time (`eval`, `new Function`, `setTimeout` with a string): its content is not analysed',
12
+ 'dynamic-require': 'a module name computed at run time (`require(name)`, `import(name)`): the callee is unknown, so the call stays unresolved',
13
+ 'reflective-call': 'a call through reflection (`Reflect.apply`, `fn.call/apply` on a computed target, `obj[name]()`): the callee is unknown',
14
+ 'getter-or-proxy': 'property access that may run user code (`Proxy`, `Object.defineProperty` accessors): reads and writes may have effects the engine does not model',
15
+ });
16
+
17
+ const expressions = (e, acc = []) => {
18
+ if (!e || typeof e !== 'object') return acc;
19
+ acc.push(e);
20
+ for (const k of ['object', 'callee', 'left', 'right', 'value']) if (e[k] && typeof e[k] === 'object') expressions(e[k], acc);
21
+ for (const k of ['args', 'parts', 'elements', 'props', 'branches']) if (Array.isArray(e[k])) for (const x of e[k]) expressions(x && x.value && !x.kind ? x.value : x, acc);
22
+ return acc;
23
+ };
24
+ const nameOf = (c) => (typeof c === 'string' ? c : c && c.kind === 'ident' ? c.name : c && c.kind === 'member' ? `${nameOf(c.object)}.${c.prop}` : '');
25
+
26
+ /** @returns {{kind:string, line:number, detail:string, effect:string}[]} */
27
+ function limitsOfFunction(fn) {
28
+ const out = [];
29
+ const add = (kind, line, detail) => out.push({ kind, line: Number.isInteger(line) ? line : (fn.line || 0), detail, effect: LIMIT_KINDS[kind] });
30
+ for (const n of Object.values(fn.cfg?.nodes || {})) {
31
+ if (n.kind === 'assign') {
32
+ if (typeof n.target === 'string' && /\.\*$/.test(n.target)) add('computed-write', n.line, n.target);
33
+ for (const x of expressions(n.source)) inspect(x, n.line, add);
34
+ } else if (n.kind === 'call') {
35
+ inspect({ kind: 'call', callee: n.callee, args: n.args }, n.line, add);
36
+ for (const a of n.args || []) for (const x of expressions(a)) inspect(x, n.line, add);
37
+ } else if (n.kind === 'return' && n.value) for (const x of expressions(n.value)) inspect(x, n.line, add);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function inspect(x, line, add) {
43
+ if (x.kind === 'member' && x.prop === '*') add('computed-read', line, `${nameOf(x.object)}[…]`);
44
+ if (x.kind === 'call') {
45
+ const c = nameOf(x.callee);
46
+ if (/^(?:eval|Function|new Function|setTimeout|setInterval)$/.test(c)) { if (c === 'eval' || c === 'Function' || c === 'new Function' || (x.args && x.args[0] && x.args[0].kind !== 'function-value' && x.args[0].kind !== 'ident' && x.args[0].kind !== 'literal')) add('eval-like', line, c); else if ((c === 'setTimeout' || c === 'setInterval') && x.args && x.args[0] && x.args[0].kind === 'literal' && typeof x.args[0].value === 'string') add('eval-like', line, `${c}(string)`); }
47
+ if (c === 'require' && x.args && x.args[0] && x.args[0].kind !== 'literal') add('dynamic-require', line, 'require(<expression>)');
48
+ if (/^Reflect\.(?:apply|construct|get|set)$/.test(c)) add('reflective-call', line, c);
49
+ if (x.callee && x.callee.kind === 'member' && x.callee.prop === '*') add('reflective-call', line, `${nameOf(x.callee.object)}[…](…)`);
50
+ if (/^(?:Object\.defineProperty|Object\.defineProperties|Object\.setPrototypeOf)$/.test(c) || c === 'Proxy' || c === 'new Proxy') add('getter-or-proxy', line, c);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * @param {{perFile:Record<string,{functions:object[]}>}} projectIR
56
+ * @returns {{file:string, qid:string, function:string, kind:string, line:number, detail:string, effect:string}[]} sorted, stable
57
+ */
58
+ export function analysisLimits(projectIR) {
59
+ const out = [];
60
+ for (const file of Object.keys(projectIR?.perFile || {}).sort()) {
61
+ for (const fn of projectIR.perFile[file].functions || []) {
62
+ for (const l of limitsOfFunction(fn)) out.push({ file, qid: String(fn.qid).replace(/@\d+$/, ''), function: fn.name, ...l });
63
+ }
64
+ }
65
+ return out.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line || (a.kind < b.kind ? -1 : 1)));
66
+ }