@clear-capabilities/agentic-security-scanner 0.172.0 → 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 (202) hide show
  1. package/CHANGELOG.md +29 -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/1336.index.js +101 -101
  7. package/dist/1351.index.js +133 -0
  8. package/dist/1387.index.js +271 -0
  9. package/dist/1437.index.js +23 -0
  10. package/dist/1445.index.js +110 -0
  11. package/dist/1505.index.js +151 -0
  12. package/dist/1557.index.js +293 -0
  13. package/dist/1621.index.js +311 -0
  14. package/dist/1751.index.js +160 -0
  15. package/dist/1765.index.js +727 -0
  16. package/dist/1861.index.js +690 -0
  17. package/dist/1898.index.js +116 -0
  18. package/dist/1906.index.js +433 -0
  19. package/dist/1920.index.js +1 -291
  20. package/dist/208.index.js +1631 -0
  21. package/dist/2199.index.js +0 -239
  22. package/dist/2223.index.js +159 -0
  23. package/dist/2530.index.js +152 -0
  24. package/dist/2565.index.js +116 -0
  25. package/dist/2574.index.js +127 -0
  26. package/dist/2620.index.js +345 -0
  27. package/dist/2773.index.js +98 -0
  28. package/dist/2775.index.js +32 -0
  29. package/dist/2966.index.js +171 -0
  30. package/dist/2972.index.js +87 -0
  31. package/dist/2991.index.js +131 -0
  32. package/dist/3063.index.js +833 -0
  33. package/dist/3075.index.js +227 -0
  34. package/dist/3121.index.js +297 -0
  35. package/dist/3314.index.js +263 -0
  36. package/dist/3480.index.js +1822 -0
  37. package/dist/3481.index.js +24 -0
  38. package/dist/3506.index.js +268 -0
  39. package/dist/3533.index.js +591 -0
  40. package/dist/3653.index.js +450 -0
  41. package/dist/3822.index.js +131 -0
  42. package/dist/3868.index.js +292 -0
  43. package/dist/3917.index.js +152 -0
  44. package/dist/3918.index.js +192 -0
  45. package/dist/4090.index.js +288 -0
  46. package/dist/4121.index.js +1190 -0
  47. package/dist/4142.index.js +0 -239
  48. package/dist/4336.index.js +436 -0
  49. package/dist/4387.index.js +532 -0
  50. package/dist/4413.index.js +302 -0
  51. package/dist/4476.index.js +4510 -0
  52. package/dist/4491.index.js +133 -0
  53. package/dist/4538.index.js +185 -0
  54. package/dist/4684.index.js +319 -0
  55. package/dist/4740.index.js +165 -0
  56. package/dist/4780.index.js +209 -0
  57. package/dist/4802.index.js +225 -0
  58. package/dist/4812.index.js +379 -0
  59. package/dist/4835.index.js +126 -0
  60. package/dist/494.index.js +226 -0
  61. package/dist/4991.index.js +549 -0
  62. package/dist/5199.index.js +305 -0
  63. package/dist/5216.index.js +109 -0
  64. package/dist/5439.index.js +301 -0
  65. package/dist/5443.index.js +97 -0
  66. package/dist/5496.index.js +377 -0
  67. package/dist/550.index.js +2 -1
  68. package/dist/5801.index.js +588 -0
  69. package/dist/5858.index.js +223 -0
  70. package/dist/5933.index.js +143 -0
  71. package/dist/601.index.js +23 -0
  72. package/dist/6173.index.js +273 -0
  73. package/dist/6291.index.js +1871 -0
  74. package/dist/6312.index.js +666 -0
  75. package/dist/634.index.js +16182 -0
  76. package/dist/644.index.js +87 -0
  77. package/dist/6508.index.js +283 -0
  78. package/dist/6560.index.js +645 -0
  79. package/dist/6576.index.js +422 -0
  80. package/dist/6579.index.js +271 -0
  81. package/dist/6745.index.js +305 -0
  82. package/dist/6818.index.js +21642 -0
  83. package/dist/6972.index.js +167 -0
  84. package/dist/6995.index.js +163 -0
  85. package/dist/7249.index.js +191 -0
  86. package/dist/7372.index.js +1197 -0
  87. package/dist/7405.index.js +23 -6
  88. package/dist/7546.index.js +541 -0
  89. package/dist/76.index.js +448 -0
  90. package/dist/7879.index.js +163 -0
  91. package/dist/8025.index.js +130 -0
  92. package/dist/8054.index.js +248 -0
  93. package/dist/8097.index.js +201 -0
  94. package/dist/8126.index.js +191 -0
  95. package/dist/8178.index.js +78 -0
  96. package/dist/818.index.js +212 -0
  97. package/dist/8382.index.js +93 -0
  98. package/dist/8408.index.js +261 -0
  99. package/dist/8540.index.js +174 -0
  100. package/dist/8635.index.js +802 -0
  101. package/dist/8688.index.js +410 -0
  102. package/dist/8699.index.js +779 -0
  103. package/dist/8891.index.js +457 -0
  104. package/dist/8916.index.js +885 -0
  105. package/dist/9146.index.js +170 -0
  106. package/dist/9168.index.js +841 -0
  107. package/dist/9292.index.js +369 -0
  108. package/dist/9382.index.js +338 -48
  109. package/dist/9422.index.js +866 -0
  110. package/dist/9440.index.js +453 -0
  111. package/dist/9479.index.js +152 -0
  112. package/dist/9481.index.js +220 -0
  113. package/dist/9539.index.js +100 -0
  114. package/dist/9547.index.js +103 -0
  115. package/dist/9560.index.js +41 -61
  116. package/dist/9692.index.js +90 -0
  117. package/dist/9800.index.js +213 -1
  118. package/dist/9899.index.js +138 -0
  119. package/dist/9981.index.js +206 -0
  120. package/dist/999.index.js +117 -0
  121. package/dist/agentic-security.mjs +28 -28
  122. package/dist/agentic-security.mjs.sha256 +1 -1
  123. package/package.json +31 -6
  124. package/src/context-intelligence/CLAUDE.md +4 -0
  125. package/src/context-intelligence/adapters/haskell-hie.js +28 -1
  126. package/src/context-intelligence/candidates.js +2 -1
  127. package/src/context-intelligence/canonical.js +25 -0
  128. package/src/context-intelligence/capabilities.js +2 -1
  129. package/src/context-intelligence/cli.js +16 -10
  130. package/src/context-intelligence/config.js +4 -1
  131. package/src/context-intelligence/controls.js +108 -10
  132. package/src/context-intelligence/counterexample.js +200 -0
  133. package/src/context-intelligence/cross-route.js +111 -0
  134. package/src/context-intelligence/entrypoints.js +2 -2
  135. package/src/context-intelligence/evaluation/index.js +11 -4
  136. package/src/context-intelligence/evaluation/scripted-reader.js +12 -2
  137. package/src/context-intelligence/findings.js +166 -0
  138. package/src/context-intelligence/invariants.js +197 -0
  139. package/src/context-intelligence/queue.js +6 -1
  140. package/src/context-intelligence/reachability.js +10 -4
  141. package/src/context-intelligence/review.js +129 -16
  142. package/src/dataflow/access-paths.js +11 -0
  143. package/src/dataflow/analysis-limits.js +66 -0
  144. package/src/dataflow/engine.js +272 -20
  145. package/src/dataflow/service-edges.js +174 -0
  146. package/src/dataflow/summaries.js +7 -1
  147. package/src/discovery/judge.js +2 -1
  148. package/src/engine.js +82 -14
  149. package/src/fix/apply-fix-service.js +55 -8
  150. package/src/fix/batch-journal.js +84 -0
  151. package/src/fix/exploit-replay.js +51 -0
  152. package/src/fix/footprint.js +65 -0
  153. package/src/fix/patch-policy.js +49 -0
  154. package/src/fix/secret-persistence.js +39 -0
  155. package/src/fix/test-commands.js +84 -0
  156. package/src/fix/verification-adapters.js +97 -0
  157. package/src/fix/verification-record.js +243 -0
  158. package/src/fix/verify-patch.js +89 -0
  159. package/src/ir/CLAUDE.md +5 -0
  160. package/src/ir/callgraph.js +23 -1
  161. package/src/ir/module-resolver.js +315 -0
  162. package/src/ir/parser-js.js +21 -2
  163. package/src/ir/semantic-graph.js +245 -0
  164. package/src/lineage/build-attested-correlation.js +63 -0
  165. package/src/lineage/deployment-identity.js +76 -0
  166. package/src/mcp/tools.js +13 -34
  167. package/src/pipeline/canonical-output.js +64 -0
  168. package/src/pipeline/detector-runner.js +7 -0
  169. package/src/pipeline/scan-health.js +14 -0
  170. package/src/pipeline/supervised-analyzers.js +125 -0
  171. package/src/pipeline/supervised-child.js +17 -0
  172. package/src/pipeline/telemetry.js +79 -0
  173. package/src/posture/accuracy-scorecard.js +98 -9
  174. package/src/posture/artifact-registry.js +5 -0
  175. package/src/posture/assurance/evidence-registry.js +215 -0
  176. package/src/posture/assurance/release-package-verify.js +135 -0
  177. package/src/posture/assurance/release-package.js +118 -0
  178. package/src/posture/assurance/release-verdict.js +94 -0
  179. package/src/posture/assurance/waiver.js +200 -0
  180. package/src/posture/compliance-truth.js +100 -0
  181. package/src/posture/evaluation/scan-child.js +3 -1
  182. package/src/posture/evaluation/why-missed.js +3 -1
  183. package/src/posture/exploitability-evidence.js +141 -0
  184. package/src/posture/fix-verify.js +4 -2
  185. package/src/posture/fleet/exposure-ranking.js +115 -0
  186. package/src/posture/fleet/service-chain-graph.js +150 -0
  187. package/src/posture/graph-reconciliation.js +154 -0
  188. package/src/posture/provenance/coverage.js +103 -0
  189. package/src/posture/provenance/git-evidence.js +84 -0
  190. package/src/posture/provenance/origin-resolver.js +7 -1
  191. package/src/posture/provenance/predicate-replay.js +14 -4
  192. package/src/posture/runtime-correlation.js +61 -15
  193. package/src/posture/runtime-explanation.js +116 -0
  194. package/src/posture/runtime-trace-ingest.js +194 -0
  195. package/src/posture/scan-checkpoint.js +38 -10
  196. package/src/posture/waf-ingest.js +7 -2
  197. package/src/report/index.js +4 -0
  198. package/src/report/oscal.js +38 -0
  199. package/src/report/perspectives.js +150 -0
  200. package/src/sast/_comment-strip.js +8 -1
  201. package/src/sast/ruby.js +26 -0
  202. package/src/util/string-memo.js +38 -0
@@ -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,8 @@
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
+ import { cmpCodeUnit } from './canonical.js';
15
16
  import { MATERIAL_REASONS } from './graph-schema.js';
16
17
 
17
18
  export const DEFAULT_LIMITS = Object.freeze({ maxSymbols: 60, maxDepth: 12 });
@@ -35,7 +36,7 @@ export function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
35
36
  const lim = { ...DEFAULT_LIMITS, ...limits };
36
37
  const { byId, edgesFrom } = indexGraph(graph);
37
38
  const linesCache = new Map();
38
- 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); };
39
40
 
40
41
  const visited = [];
41
42
  const seen = new Set();
@@ -70,7 +71,7 @@ export function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
70
71
  }
71
72
 
72
73
  // middleware runs before the handler: it is enqueued first and ordered at line -1
73
- for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || String(a.name).localeCompare(String(b.name)))) {
74
+ for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || cmpCodeUnit(a.name, b.name))) {
74
75
  if (mw.symbolId && byId.has(mw.symbolId)) enqueue(mw.symbolId, 0, mw.inherited ? 'inherited-middleware' : 'middleware', -1, [mw.symbolId], mw.resolution === 'resolved' ? 'exact' : 'name-match');
75
76
  }
76
77
  if (root.symbolId) {
@@ -163,11 +164,14 @@ export function evaluateProtection({ facts, unknowns, omittedWithControls = fals
163
164
  for (const dim of PROTECTION_DIMENSIONS) {
164
165
  const kinds = dim === 'ownership' ? ['ownership', 'tenant-scope'] : [dim];
165
166
  const ctl = facts.filter(f => f.category === 'control' && kinds.includes(f.kind));
166
- const before = ctl.filter(f => firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens));
167
+ // A control whose answer is visibly thrown away (a predicate called as a bare statement) guards nothing.
168
+ const effective = (f) => f.rejectionSeen !== false;
169
+ const before = ctl.filter(f => effective(f) && (firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens)));
167
170
  const after = ctl.filter(f => firstSens !== null && f.effLine > firstSens);
168
171
  let state; let why;
169
172
  if (before.length) { state = 'present'; why = 'a control precedes the first sensitive operation'; }
170
173
  else if (after.length) { state = 'evaluated_and_ineffective'; why = 'every control of this kind runs after the first sensitive operation'; }
174
+ else if (ctl.length && ctl.every(f => !effective(f))) { state = 'evaluated_and_ineffective'; why = 'a check is called but nothing in view acts on its answer'; }
171
175
  else if (ctl.length) { state = 'evaluated_and_ineffective'; why = 'a control is present but is not ordered before the sensitive operation'; }
172
176
  else if (omittedWithControls) { state = 'omitted_due_to_budget'; why = 'a symbol holding a control was left out for size'; }
173
177
  else if (unknowns.some(u => u.material) || capped) { state = 'not_resolved'; why = 'an unresolved call or a traversal limit could hide a control'; }
@@ -175,6 +179,8 @@ export function evaluateProtection({ facts, unknowns, omittedWithControls = fals
175
179
  else { state = 'not_present'; why = 'the resolved context is complete and contains no control of this kind'; }
176
180
  dims[dim] = {
177
181
  state, why, firstSensitiveLine: firstSens,
182
+ // How the control was established: 'observed' = the text shows it, 'inferred' = only a name suggests it. Only meaningful for `present`.
183
+ strength: state === 'present' ? (before.some(f => f.evidence === 'observed') ? 'observed' : 'inferred') : null,
178
184
  evidence: ctl.slice(0, 6).map(f => ({ file: f.file, line: f.line, kind: f.kind, evidence: f.evidence, basis: f.basis, order: firstSens === null ? 'no-sensitive-op' : f.effLine < firstSens ? 'before-sensitive' : f.effLine > firstSens ? 'after-sensitive' : 'same-step', rejectionSeen: f.rejectionSeen })),
179
185
  };
180
186
  }
@@ -20,14 +20,14 @@ import { redactPayload } from '../egress/redact.js';
20
20
  import { evaluateEgress } from '../egress/policy.js';
21
21
  import { resolveProvider } from '../llm-validator/providers.js';
22
22
  import { resolveLlmInvokeWithDecision } from '../discovery/llm-invoke.js';
23
- import { hashOf, safeRelPath, sha256 } from './canonical.js';
23
+ import { hashOf, safeLabel, safeRelPath, sha256 } from './canonical.js';
24
24
  import { estimateTokens } from './packer.js';
25
25
  import { REVIEW_SCHEMA_VERSION, packRef } from './prompts/packs.js';
26
26
 
27
27
  export const CONCLUSIONS = Object.freeze(['potential_issue', 'no_issue_observed', 'insufficient_evidence']);
28
28
  export const LIKELIHOODS = Object.freeze(['low', 'medium', 'high']);
29
29
  const FORBIDDEN_MODEL_FIELDS = Object.freeze(['severity', 'proofTier', 'proof_tier', 'confirmation', 'confirmed', 'status', 'verdict', 'shipVerdict', 'ship_verdict', 'exploitability', 'cvss', 'exploit_confirmed', 'EXPLOIT_CONFIRMED']);
30
- const MAX_CITATION_SPAN = 200;
30
+ const MAX_CITATION_SPAN = 60;
31
31
  const MAX_STR = 1200;
32
32
  const MAX_ITEMS = 24;
33
33
 
@@ -65,8 +65,10 @@ export function buildReviewPrompt({ pack, entrypoints, packageTexts, contextHash
65
65
  for (const g of pack.guidance) lines.push(`- ${g}`);
66
66
  lines.push('', 'OUTPUT (JSON, evidence listed first):', JSON.stringify(OUTPUT_SHAPE, null, 2), '');
67
67
  entrypoints.forEach((ep, i) => {
68
- const fenced = fenceUntrusted(packageTexts[i], `context-${i + 1}`);
69
- lines.push(`ENTRY POINT ${i + 1}: ${ep.label} (id ${ep.id})`);
68
+ // The label comes from a route string in the repository, so it is DATA: it goes inside the fence, sanitised, and never
69
+ // into the instruction text around it.
70
+ const fenced = fenceUntrusted(`ENTRY POINT LABEL (data): ${safeLabel(ep.label, 120)}\n${packageTexts[i]}`, `context-${i + 1}`);
71
+ lines.push(`ENTRY POINT ${i + 1} (id ${ep.id})`);
70
72
  lines.push(fenced.text, '');
71
73
  });
72
74
  const prompt = lines.join('\n');
@@ -102,25 +104,103 @@ export function redactPackageText(text, { scanRoot }) {
102
104
  }
103
105
 
104
106
  /** Provider/model identity used in the job key, from the same resolver the rest of the discovery layer uses. */
105
- export function describeModel() {
107
+ export function isLoopbackEndpoint(endpoint) {
108
+ if (!endpoint) return false;
109
+ const parts = String(endpoint).split(',').map(x => x.trim()).filter(Boolean);
110
+ if (!parts.length) return false;
111
+ return parts.every(u => {
112
+ try {
113
+ const h = new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, '');
114
+ return h === 'localhost' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h);
115
+ } catch { return false; }
116
+ });
117
+ }
118
+
119
+ export function describeModel({ allowRemote = false } = {}) {
120
+ let m;
106
121
  try {
107
122
  const r = resolveProvider({ role: 'hunt' });
108
- if (r.ok) return { provider: r.config.provider, model: r.config.model || process.env.AGENTIC_SECURITY_LLM_MODEL || 'endpoint-default', endpoint: r.config.endpoint || null };
123
+ if (r.ok) m = { provider: r.config.provider, model: r.config.model || process.env.AGENTIC_SECURITY_LLM_MODEL || 'endpoint-default', endpoint: r.config.endpoint || null };
109
124
  } catch { /* fall through */ }
110
- const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
111
- return { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
125
+ if (!m) {
126
+ const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
127
+ m = { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
128
+ }
129
+ // `remote` is true for any endpoint that is not loopback; an endpoint-less provider is judged remote too, since its host is not visible here.
130
+ const remote = m.provider ? !(m.provider === 'local' || isLoopbackEndpoint(m.endpoint)) : false;
131
+ return { ...m, remote, remoteApproved: allowRemote === true };
112
132
  }
113
133
 
114
134
  /** Evaluate the egress policy for every file that would appear in the prompt. Returns { allowed, reason }. */
115
135
  export function checkEgressForFiles(files, { scanRoot, model }) {
116
136
  if (!model.endpoint) return { allowed: true, evaluated: false };
137
+ // Source bundles leave the machine only when the operator has said so. A loopback endpoint needs no approval; any
138
+ // other endpoint needs --allow-remote (or review.allowRemote), on top of the egress policy below, never instead of it.
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 };
117
140
  for (const file of files) {
118
- const d = evaluateEgress({ scanRoot, purpose: 'context-review', endpoint: model.endpoint, provider: model.provider, model: model.model, path: file });
119
- 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 }) };
120
147
  }
121
148
  return { allowed: true, evaluated: true };
122
149
  }
123
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
+
124
204
  // ---- response parsing and verification ------------------------------------------------------------------------
125
205
 
126
206
  function extractJson(raw) {
@@ -149,14 +229,29 @@ function normalizeCitations(list) {
149
229
  return { items, malformed };
150
230
  }
151
231
 
152
- /** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained. */
232
+ const CLAIM_STOPWORDS = new Set(['this', 'that', 'with', 'from', 'which', 'where', 'when', 'does', 'have', 'been', 'would', 'could', 'should', 'into', 'over', 'only', 'also', 'shows', 'show', 'line', 'lines', 'code', 'file', 'function']);
233
+
234
+ /**
235
+ * A claim is grounded when at least one meaningful word in it (or the first five letters of a longer one, so "ownership"
236
+ * meets `ownerId`) appears in the lines it cites. This does not prove the claim is true; it stops a citation that points at
237
+ * real lines while describing something else, and an empty claim, from being accepted as evidence.
238
+ */
239
+ export function claimGrounded(claim, citedText) {
240
+ const hay = String(citedText).toLowerCase();
241
+ const words = (String(claim).toLowerCase().match(/[a-z_][a-z0-9_]{3,}/g) || []).filter(w => !CLAIM_STOPWORDS.has(w));
242
+ return words.some(w => hay.includes(w.length > 5 ? w.slice(0, 5) : w));
243
+ }
244
+
245
+ /** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained, and its claim is about those lines. */
153
246
  export function resolveCitation(c, segments, fileContents) {
154
247
  if (c.startLine < 1 || c.endLine < c.startLine || c.endLine - c.startLine > MAX_CITATION_SPAN) return 'range-invalid';
155
248
  const text = fileContents[c.file];
156
249
  if (typeof text !== 'string') return 'file-not-in-snapshot';
157
- if (c.endLine > text.split('\n').length) return 'beyond-end-of-file';
250
+ const fileLines = text.split('\n');
251
+ if (c.endLine > fileLines.length) return 'beyond-end-of-file';
158
252
  const inside = segments.some(s => s.file === c.file && c.startLine >= s.startLine && c.endLine <= s.endLine);
159
- return inside ? null : 'outside-provided-context';
253
+ if (!inside) return 'outside-provided-context';
254
+ return claimGrounded(c.claim, fileLines.slice(c.startLine - 1, c.endLine).join('\n')) ? null : 'claim-not-grounded-in-cited-lines';
160
255
  }
161
256
 
162
257
  function decline(base, reasons, extra = {}) {
@@ -245,13 +340,24 @@ export function verifyReview({ raw, pack, entrypoints, manifests, packageIndexes
245
340
  out.downgradedFrom = 'no_issue_observed';
246
341
  out.conclusion = 'insufficient_evidence';
247
342
  out.downgradeReason = 'no-mitigation-cited';
343
+ } else if (dim) {
344
+ // The cited mitigation must be a control the deterministic pass also recognised, and one the text actually shows:
345
+ // a model cannot clear a route by pointing at an arbitrary line or at a control known only from its name.
346
+ const recognised = mit.items.some(c => manifests.some(m => ((m.protection[dim] || {}).evidence || []).some(e => e.file === c.file && e.line >= c.startLine && e.line <= c.endLine)));
347
+ const onlyInferred = manifests.every(m => (m.protection[dim] || {}).state === 'present' && (m.protection[dim] || {}).strength === 'inferred');
348
+ if (!recognised || onlyInferred) {
349
+ out.downgradedFrom = 'no_issue_observed';
350
+ out.conclusion = 'insufficient_evidence';
351
+ out.downgradeReason = !recognised ? 'mitigation-not-a-recognised-control' : 'only-inferred-controls';
352
+ }
248
353
  }
249
354
  }
250
355
 
251
356
  // how the model's reading compares with the deterministic reading (metadata only; neither overrides the other)
252
357
  if (dim) {
253
358
  const states = manifests.map(m => m.protection[dim]?.state);
254
- const present = states.every(s => s === 'present');
359
+ // `present` counts as agreement only when the control is shown by the text; a control known only from its name is neutral.
360
+ const present = manifests.every(m => m.protection[dim]?.state === 'present' && m.protection[dim]?.strength !== 'inferred');
255
361
  const absent = states.some(s => s === 'not_present' || s === 'evaluated_and_ineffective');
256
362
  out.deterministicProtection = { dimension: dim, states };
257
363
  out.deterministicAgreement = out.conclusion === 'potential_issue' ? (absent ? 'agrees' : present ? 'conflicts' : 'neutral') : (out.conclusion === 'no_issue_observed' ? (present ? 'agrees' : absent ? 'conflicts' : 'neutral') : 'neutral');
@@ -303,7 +409,12 @@ export async function runReviewOnce({ pack, entrypoints, manifests, packageIndex
303
409
  const badRedact = redacted.find(r => !r.ok);
304
410
  if (badRedact) return { state: 'skipped_by_policy', reason: `redaction refused: ${badRedact.reason}` };
305
411
 
306
- 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 });
307
418
  const est = estimateTokens(prompt);
308
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` };
309
420
 
@@ -315,7 +426,9 @@ export async function runReviewOnce({ pack, entrypoints, manifests, packageIndex
315
426
  return { state: 'failed', reason: `model call failed: ${String((err && err.message) || err).slice(0, 160)}`, retryable: isRetryableError(err) };
316
427
  }
317
428
  const usage = { inputTokensEstimated: est.tokens, outputTokensEstimated: Math.ceil(String(raw || '').length / 4), source: 'estimate', redactions: redacted.reduce((n, r) => n + r.redactions, 0) };
318
- 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 } : {}) };
319
432
  if (review.status === 'declined') return { state: 'partial', reason: `answer declined: ${review.declineReasons.join(',')}`, review, usage };
320
433
  return { state: 'succeeded', reason: null, review, usage };
321
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
+ }