@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
@@ -155,15 +155,42 @@ function loadHieMetadata(rootReal, hieDir, { fileContents }) {
155
155
  return { status: 'ok', tool: doc.tool, compiler: doc.compiler, declarations, references, exportHash: (0,canonical/* hashOf */.qQ)({ declarations, references }) };
156
156
  }
157
157
 
158
+ function uncorroboratedReferences(graph, references, fileContents) {
159
+ const byName = new Map();
160
+ for (const sym of graph.symbols) {
161
+ if (sym.kind !== 'function' || sym.id.includes('::<anon>@')) continue;
162
+ if (!byName.has(sym.name)) byName.set(sym.name, []);
163
+ byName.get(sym.name).push(sym);
164
+ }
165
+ const bad = [];
166
+ for (const r of references) {
167
+ const owners = byName.get(r.from) || [];
168
+ if (owners.length !== 1) continue; // an ambiguous or unknown name is already counted as unmatched and adds nothing
169
+ const sym = owners[0];
170
+ const text = fileContents[sym.file];
171
+ const callee = String(r.to).split(/[.:]/).pop();
172
+ const lineText = typeof text === 'string' ? text.split('\n')[r.line - 1] : undefined;
173
+ const inside = r.line >= sym.startLine && r.line <= sym.endLine;
174
+ if (!inside || lineText === undefined || !callee || !lineText.includes(callee)) bad.push(`${r.from}->${r.to}@${r.line}`);
175
+ }
176
+ return bad;
177
+ }
178
+
158
179
  /**
159
180
  * Merge an imported export into the static graph. Returns { graph, semantic } where `semantic` carries the provenance
160
181
  * and the static-versus-compiler difference report.
161
182
  */
162
183
  function mergeHieIntoGraph(graph, meta, fileContents) {
163
- void fileContents;
164
184
  if (meta.status !== 'ok') {
165
185
  return { graph, semantic: { source: ['static-ir'], status: meta.status, reason: meta.reason || null, detail: meta.detail || null, diff: null } };
166
186
  }
187
+ // The export is untrusted even when its file hashes match: it is checked into the repository it describes. A reference
188
+ // is accepted only if the line it names, inside the declaration it names, actually mentions the callee. One that does
189
+ // not degrades the whole import, so a crafted export cannot add edges that the source does not contain.
190
+ const uncorroborated = uncorroboratedReferences(graph, meta.references, fileContents || {});
191
+ if (uncorroborated.length) {
192
+ return { graph, semantic: { source: ['static-ir'], status: 'degraded', reason: 'reference-not-in-source', detail: uncorroborated.slice(0, 5).join(', '), diff: null } };
193
+ }
167
194
  const idsByName = new Map();
168
195
  for (const s of graph.symbols) {
169
196
  if (s.kind !== 'function' || s.id.includes('::<anon>@')) continue;
@@ -394,7 +421,8 @@ async function adjudicate(candidates, opts = {}) {
394
421
  origin: 'context-intelligence', questionPack: c.questionPack, witnesses: c.witnesses, witnessCount: c.witnesses.length,
395
422
  sourceSpanEvidence: c.sourceSpanEvidence, scopeIntegrity: c.scopeIntegrity, reviewStatus: c.reviewStatus,
396
423
  uncertainties: c.uncertainties, recommendedValidation: c.recommendedValidation,
397
- note: 'A model hypothesis checked for citations, not a proof. Severity comes from the confirmation tier only.',
424
+ ...(c.deterministic ? { deterministic: c.deterministic } : {}),
425
+ note: c.deterministic ? 'Derived from the code analysis and a deterministic counterexample attempt; no model produced this. Severity comes from the confirmation tier only.' : 'A model hypothesis checked for citations, not a proof. Severity comes from the confirmation tier only.',
398
426
  },
399
427
  };
400
428
  };
@@ -414,10 +442,13 @@ async function adjudicate(candidates, opts = {}) {
414
442
  /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
415
443
 
416
444
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
445
+ /* harmony export */ IW: () => (/* binding */ safeLabel),
417
446
  /* harmony export */ Qz: () => (/* binding */ safeRelPath),
447
+ /* harmony export */ bt: () => (/* binding */ cleanForTerminal),
418
448
  /* harmony export */ dj: () => (/* binding */ canonicalJson),
419
449
  /* harmony export */ fQ: () => (/* binding */ normalizeText),
420
450
  /* harmony export */ iq: () => (/* binding */ sliceLines),
451
+ /* harmony export */ kH: () => (/* binding */ cmpCodeUnit),
421
452
  /* harmony export */ qQ: () => (/* binding */ hashOf),
422
453
  /* harmony export */ sc: () => (/* binding */ sha256),
423
454
  /* harmony export */ v6: () => (/* binding */ readRepoFile),
@@ -443,6 +474,31 @@ function sha256(text) {
443
474
  return node_crypto__WEBPACK_IMPORTED_MODULE_0__.createHash('sha256').update(text).digest('hex');
444
475
  }
445
476
 
477
+ /**
478
+ * Code-unit order, independent of the host locale. `localeCompare` orders by the process locale, so the same checkout
479
+ * could be sorted differently on two machines; every ordering that reaches a hashed value goes through this instead.
480
+ */
481
+ function cmpCodeUnit(a, b) {
482
+ const x = String(a); const y = String(b);
483
+ return x < y ? -1 : x > y ? 1 : 0;
484
+ }
485
+
486
+ // Control characters (C0, DEL, C1), the bidirectional override/isolate marks and the line/paragraph separators. A
487
+ // repository controls the text of its own route strings and file names, so anything printed to a terminal or placed
488
+ // in a prompt outside the untrusted fence must not be able to carry an escape sequence or reorder what a reader sees.
489
+ const UNSAFE_DISPLAY = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
490
+
491
+ /** Make text safe to print: unsafe characters become '?'. Newline and tab are kept; use `safeLabel` for one-line values. */
492
+ function cleanForTerminal(text) {
493
+ return String(text).replace(UNSAFE_DISPLAY, '?');
494
+ }
495
+
496
+ /** A one-line, bounded, printable form of a name taken from source (a route path, a file name). */
497
+ function safeLabel(text, max = 120) {
498
+ const t = String(text).replace(/[\n\r\t]+/g, ' ').replace(UNSAFE_DISPLAY, '?').replace(/ {2,}/g, ' ').trim();
499
+ return t.length > max ? `${t.slice(0, max - 3)}...` : t;
500
+ }
501
+
446
502
  /** CRLF/CR -> LF so a Windows checkout and a Unix one produce the same spans and hashes. */
447
503
  function normalizeText(text) {
448
504
  return String(text).replace(/\r\n?/g, '\n');
@@ -719,7 +775,8 @@ const BUILD = [
719
775
  { language: 'javascript/typescript', kind: 'http', frameworks: 'Express-style call registration (get/post/put/patch/delete/all, router.use, app.use)', status: 'verified', evidence: [T('build-js.test.js')], limits: 'Router mounts in another file and middleware factories are not followed (reported as router-mount-not-resolved); destructured and named imports are followed, re-exports and tsconfig path aliases are not.' },
720
776
  { language: 'python', kind: 'http', frameworks: 'decorator registration (Flask/FastAPI-style @app.route / @router.get), FastAPI Depends()/Security() guards', status: 'experimental', evidence: [T('build-python-haskell.test.js'), T('frameworks.test.js')], limits: 'Decorator guards are read from the decorator lines and Depends() guards from the signature (only dependencies named like an auth or user provider bind the principal); framework-level before_request hooks and Django URL conf are not modelled.' },
721
777
  { language: 'haskell', kind: 'http', frameworks: 'Scotty, WAI, Servant, Yesod (through the existing web model)', status: 'verified', evidence: [T('build-python-haskell.test.js'), T('frameworks.test.js')], limits: 'Static source analysis; the existing web model decides what a route is. Dynamic routes are reported unresolved.' },
722
- { language: 'java/kotlin/c#/go/php/ruby/rust', kind: 'http', frameworks: 'whatever the engine route scan recognises', status: 'unsupported', evidence: [], limits: 'No fixture proves this, so it is listed as unsupported even though some routes may resolve: Handlers are resolved only for decorator/annotation-style registration; no dedicated fixtures here.' },
778
+ { language: 'php', kind: 'http', frameworks: 'Laravel Route:: registration (the entry point only)', status: 'experimental', evidence: ['test/engineering-excellence/e4-01-dci-audit.test.js'], limits: 'Measured by the E4-01 audit on one fixture: the routes resolve, but no control or sensitive operation was extracted from the handlers.' },
779
+ { language: 'java/kotlin/c#/go/ruby/rust', kind: 'http', frameworks: 'whatever the engine route scan recognises', status: 'unsupported', evidence: [], limits: 'Measured by the E4-01 audit (docs/engineering-excellence/DCI_MATURITY.md) on one fixture per language (Spring, net/http, ASP.NET, Rails): no entry point was found, so no bundle is built.' },
723
780
  { language: 'any with a recognised surface', kind: 'webhook/queue/cron/upload/cli', frameworks: 'existing entry-point inventory', status: 'experimental', evidence: [T('build-js.test.js')], limits: 'Handler is the enclosing declaration of the surface; no framework wiring beyond that.' },
724
781
  { language: 'haskell', kind: 'compiler metadata import', frameworks: 'hie-export/1 JSON export of GHC declarations and references', status: 'experimental', evidence: [T('hie-adapter.test.js')], limits: 'Reads an export; never runs GHC or hiedb. The export is validated against the source snapshot. No export produced by a real GHC run is checked in.' },
725
782
  { language: 'any', kind: 'compiler metadata build', frameworks: 'automatic metadata generation', status: 'blocked', evidence: [], limits: 'Running a compiler on untrusted source needs a sandbox proof this build does not have (Template Haskell, custom Setup and plugins run code).' },
@@ -762,25 +819,29 @@ var state_dir = __webpack_require__(31174);
762
819
 
763
820
 
764
821
 
822
+
765
823
  const flag = (a, k) => (a.flags[k] === undefined ? undefined : a.flags[k]);
766
824
  const truthy = (v) => v === true || (typeof v === 'string' && v !== 'false' && v !== '0');
767
825
  const num = (v) => (v === undefined || v === true ? undefined : Number(v));
768
826
 
827
+ /** Everything this command prints goes through here: repository-supplied names must not carry escape sequences to a terminal. */
828
+ function say(text) { console.log((0,canonical/* cleanForTerminal */.bt)(text)); }
829
+
769
830
  function out(format, text, obj) {
770
- if (format === 'json') console.log(JSON.stringify(obj, null, 2));
771
- else if (format === 'jsonl') console.log(JSON.stringify(obj));
772
- else console.log(text);
831
+ if (format === 'json') say(JSON.stringify(obj, null, 2));
832
+ else if (format === 'jsonl') say(JSON.stringify(obj));
833
+ else say(text);
773
834
  }
774
835
 
775
836
  function usage() {
776
- console.log([
837
+ say([
777
838
  'Usage: agentic-security context <subcommand> [--root <dir>] [--format text|json|jsonl]',
778
839
  '',
779
840
  ' build Resolve entry points and write one inspectable context bundle each (no model, no network)',
780
841
  ' [--entrypoints http,webhook,queue,cron,upload,cli] [--profile compact|standard|extended] [--max-tokens N] [--route <label|id>] [--no-write]',
781
842
  ' inspect Show one bundle: --route <label|id> | --hash <contextHash> [--show-context] [--package N]',
782
843
  ' diff Routes added/removed/changed between two refs: --from <ref> [--to <ref>] (omit --to for the working tree)',
783
- ' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--concurrency N]',
844
+ ' review Focused one-call reviews over frozen bundles: --question <pack[,pack]> [--route x] [--local-only] [--allow-remote] [--concurrency N]',
784
845
  ' [--max-calls N] [--max-wall-seconds N] [--max-cost-usd N --cost-per-call-usd N] [--retry-failed] [--refute] [--strict]',
785
846
  ' [--dry-run] [--export-batch] [--import-batch <results.jsonl> --batch-id <id>]',
786
847
  ' status Coverage so far: entry points, bundles, review jobs',
@@ -802,7 +863,7 @@ async function doBuild(root, args, { write = true } = {}) {
802
863
  if (flag(args, 'max-tokens')) config.context.maxTokens = num(flag(args, 'max-tokens'));
803
864
  const events = [];
804
865
  const jsonl = flag(args, 'format') === 'jsonl';
805
- const built = await (0,build/* buildContext */.g)(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl) console.log(JSON.stringify(e)); } });
866
+ const built = await (0,build/* buildContext */.g)(root, { config, route: entrypointFilter(flag(args, 'route')), onEvent: (e) => { events.push(e); if (jsonl) say(JSON.stringify(e)); } });
806
867
  if (!built.ok) return { ok: false, built, config, problems };
807
868
  let persisted = null;
808
869
  if (write) persisted = persistBuild(root, built, config);
@@ -895,7 +956,7 @@ async function cmdInspect(root, args, format) {
895
956
  ` boundaries (calls into libraries, not first-party code): ${m.boundaries.length}`,
896
957
  ];
897
958
  if (text) lines.push('', text.ok ? text.text : `(context text unavailable: ${text.code})`);
898
- console.log(lines.join('\n'));
959
+ say(lines.join('\n'));
899
960
  return 0;
900
961
  }
901
962
 
@@ -995,7 +1056,7 @@ async function cmdExport(root, args, format) {
995
1056
  if (!h || h === true) { out(format, 'context export: --hash <contextHash> is required', { ok: false }); return 1; }
996
1057
  const r = (0,store/* exportBundle */.Nl)(root, String(h));
997
1058
  if (!r.ok) { out(format, `context export: refused (${r.problems.join(', ')})`, { ok: false, problems: r.problems }); return 1; }
998
- console.log(JSON.stringify(r.export, null, 2));
1059
+ say(JSON.stringify(r.export, null, 2));
999
1060
  return 0;
1000
1061
  }
1001
1062
 
@@ -1043,7 +1104,7 @@ async function cmdReview(root, args, format) {
1043
1104
  const r = await doBuild(root, args, { write: true });
1044
1105
  if (!r.ok) { out(format, `context review: build failed (${r.built.reason})`, { ok: false, reason: r.built.reason }); return r.built.reason === 'too-many-files' ? 2 : 1; }
1045
1106
  const { built } = r;
1046
- const model = (0,review/* describeModel */.Vy)();
1107
+ const model = (0,review/* describeModel */.Vy)({ allowRemote: truthy(flag(args, 'allow-remote')) || r.config.review.allowRemote === true });
1047
1108
  const { jobs, droppedByJobLimit } = (0,queue/* planJobs */.KR)({ bundles: built.bundles, packs, model, maxJobs: Math.min(queue/* HARD_LIMITS */.BS.maxJobs, num(flag(args, 'max-jobs')) || queue/* HARD_LIMITS */.BS.maxJobs) });
1048
1109
  const loadJob = loadJobFrom(built, packs);
1049
1110
  const ctxBase = { root: built.root, fileContents: built.fileContents, model };
@@ -1081,12 +1142,14 @@ async function cmdReview(root, args, format) {
1081
1142
  try {
1082
1143
  result = await (0,queue/* runQueue */.rj)(jobs, {
1083
1144
  ...ctxBase, invoker, loadJob, signal: ac.signal, retryFailed: truthy(flag(args, 'retry-failed')),
1084
- onEvent: jsonl ? (e) => console.log(JSON.stringify(e)) : undefined,
1145
+ onEvent: jsonl ? (e) => say(JSON.stringify(e)) : undefined,
1085
1146
  limits: {
1086
1147
  concurrency: num(flag(args, 'concurrency')) || config.review.concurrency,
1087
1148
  maxCalls: num(flag(args, 'max-calls')) || config.review.maxCalls,
1088
1149
  maxWallSeconds: num(flag(args, 'max-wall-seconds')) || config.review.maxWallSeconds,
1089
1150
  maxTokensPerJob: config.review.maxTokensPerJob,
1151
+ maxTokensPerRepo: config.review.maxTokensPerRepo,
1152
+ anonymizePrompts: config.review.anonymizePrompts,
1090
1153
  maxRetries: config.review.maxRetries,
1091
1154
  jobTimeoutMs: (num(flag(args, 'job-timeout-seconds')) || 120) * 1000,
1092
1155
  maxCostUsd: num(flag(args, 'max-cost-usd')) ?? config.review.maxCostUsd,
@@ -1199,7 +1262,7 @@ const DEFAULT_CONFIG = Object.freeze({
1199
1262
  maxFiles: 3000,
1200
1263
  semanticMetadata: { mode: 'off', haskellHieDir: null },
1201
1264
  context: { profile: 'standard', maxTokens: null, pruneLowValue: true, maxSymbols: 60, maxDepth: 12 },
1202
- review: { enabled: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3 },
1265
+ review: { enabled: false, allowRemote: false, questionPacks: ['authz-idor'], concurrency: 2, maxCalls: 100, maxWallSeconds: 900, maxCostUsd: null, maxTokensPerJob: 40000, maxRetries: 3, maxTokensPerRepo: null, anonymizePrompts: false },
1203
1266
  replay: { contentAddressed: true, retention: 'inherit' },
1204
1267
  });
1205
1268
 
@@ -1240,6 +1303,7 @@ function normalizeConfig(raw) {
1240
1303
  },
1241
1304
  review: {
1242
1305
  enabled: rv.enabled === true,
1306
+ allowRemote: rv.allowRemote === true,
1243
1307
  questionPacks: Array.isArray(rv.questionPacks) && rv.questionPacks.length ? rv.questionPacks.filter(x => typeof x === 'string') : d.review.questionPacks,
1244
1308
  concurrency: clamp(rv.concurrency, 1, 16, d.review.concurrency),
1245
1309
  maxCalls: clamp(rv.maxCalls, 1, 5000, d.review.maxCalls),
@@ -1247,6 +1311,8 @@ function normalizeConfig(raw) {
1247
1311
  maxCostUsd: Number.isFinite(rv.maxCostUsd) && rv.maxCostUsd >= 0 ? rv.maxCostUsd : null,
1248
1312
  maxTokensPerJob: clamp(rv.maxTokensPerJob, 1000, 400000, d.review.maxTokensPerJob),
1249
1313
  maxRetries: clamp(rv.maxRetries, 0, 3, d.review.maxRetries),
1314
+ maxTokensPerRepo: Number.isFinite(rv.maxTokensPerRepo) && rv.maxTokensPerRepo >= 1000 ? Math.trunc(rv.maxTokensPerRepo) : null,
1315
+ anonymizePrompts: rv.anonymizePrompts === true,
1250
1316
  },
1251
1317
  replay: { contentAddressed: rp.contentAddressed !== false, retention: typeof rp.retention === 'string' ? rp.retention : 'inherit' },
1252
1318
  };
@@ -1273,12 +1339,13 @@ function loadConfig(root) {
1273
1339
  /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
1274
1340
 
1275
1341
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
1342
+ /* harmony export */ $E: () => (/* binding */ blankComments),
1276
1343
  /* harmony export */ TM: () => (/* binding */ looksLikeTelemetry),
1277
1344
  /* harmony export */ gs: () => (/* binding */ signatureText),
1278
1345
  /* harmony export */ kl: () => (/* binding */ analyzeSymbolText),
1279
1346
  /* harmony export */ qr: () => (/* binding */ dependencyParams)
1280
1347
  /* harmony export */ });
1281
- /* unused harmony exports CONTROL_KINDS, isSecuritySignificant */
1348
+ /* unused harmony exports CONTROL_KINDS, ownerBinding, isSecuritySignificant */
1282
1349
  //
1283
1350
  // Security-relevant facts inside ONE symbol's source: authentication / authorization / ownership / tenant controls,
1284
1351
  // and the sensitive operations they have to precede.
@@ -1328,7 +1395,46 @@ function signatureText(lines, declLine, maxLines = 12) {
1328
1395
  for (let i = declLine - 1; i < Math.min(lines.length, declLine - 1 + maxLines); i++) { t += ' ' + lines[i]; if (/\)\s*(?:->[^:]*)?:\s*(?:#.*)?$/.test(lines[i])) break; }
1329
1396
  return t;
1330
1397
  }
1331
- const OWNER_FIELD = /(?:owner|tenant|org(?:anization)?|account|created_?by|author|user_?id|userId|team|workspace)/i;
1398
+ // An owner/tenant FIELD NAME, not any word that merely contains one: `ownerId`, `owner_id`, `tenant`, `getOwnerId`
1399
+ // qualify; `owner_name` and `cols` do not (the trailing boundary rejects them).
1400
+ const OWNER_KEY = '\\w*?(?:[Oo]wner|[Tt]enant|[Oo]rg(?:ani[sz]ation)?|[Aa]ccount|[Cc]reated_?[Bb]y|[Aa]uthor|[Tt]eam|[Ww]orkspace)(?:_?(?:[Ii]d|ID))?\\b|[Uu]ser_?(?:[Ii]d|ID)\\b';
1401
+ const OWNER_KEY_RE = new RegExp(`(?<![\\w])(?:${OWNER_KEY})`);
1402
+ const TENANT_WORD = /tenant|org|workspace|team|account/i;
1403
+ // a SQL string comparing an owner column with a bound parameter: `owner_id = ?`, `owner_id = $1`, `tenant_id = :t`
1404
+ const SQL_OWNER_PLACEHOLDER = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*(?:=|\\bIN\\b|\\bin\\b)\\s*\\(?\\s*(?:\\?|\\$\\d+|:\\w+|%s|@\\w+)`);
1405
+ // an owner column compared with the principal written straight into the string: `owner_id = ${req.user.id}`
1406
+ const SQL_OWNER_INTERPOLATED = new RegExp(`(?<![\\w])(?:${OWNER_KEY})\\s*=\\s*(?:'?\\s*\\+\\s*|\\$\\{\\s*)(?:${QUALIFIED_PRINCIPAL.source}|${BARE_PRINCIPAL.source})`);
1407
+ const PRINCIPAL_GLOBAL = new RegExp(QUALIFIED_PRINCIPAL.source, 'g');
1408
+ const STRING_LITERAL = /(["'`])(?:\\.|(?!\1).)*\1/g;
1409
+
1410
+ /**
1411
+ * Is an owner/tenant FIELD actually tied to the principal in this text? The field and the principal must sit in the
1412
+ * same clause (split on , ; && || braces), so `{ audit: req.user.id, cols: 'owner_name' }` is a decoy and
1413
+ * `{ ownerId: req.user.id }` is a filter. A SQL string with an owner column against a bound placeholder counts when the
1414
+ * principal is passed in the same call. Returns null or { level: 'qualified'|'bare', tenant }.
1415
+ */
1416
+ function ownerBinding(text, extra = null) {
1417
+ let best = null;
1418
+ const take = (level, tenant) => { if (!best || (level === 'qualified' && best.level !== 'qualified')) best = { level, tenant }; };
1419
+ const code = text.replace(STRING_LITERAL, (m) => (SQL_OWNER_PLACEHOLDER.test(m) || SQL_OWNER_INTERPOLATED.test(m) ? m : '""'));
1420
+ for (const clause of code.split(/[,;{}]|&&|\|\|/)) {
1421
+ // the principal's own name (`req.user`, `current_user`) is not an owner field, so it is removed before looking for one
1422
+ const forKey = clause.replace(STRING_LITERAL, ' ').replace(PRINCIPAL_GLOBAL, ' ');
1423
+ if (!OWNER_KEY_RE.test(forKey)) continue;
1424
+ const pr = principalIn(clause, extra);
1425
+ if (pr) take(pr, TENANT_WORD.test(clause));
1426
+ }
1427
+ for (const m of text.match(STRING_LITERAL) || []) {
1428
+ const placeholder = SQL_OWNER_PLACEHOLDER.test(m);
1429
+ if (placeholder) {
1430
+ const pr = principalIn(text, extra);
1431
+ if (pr) take(pr, TENANT_WORD.test(m));
1432
+ }
1433
+ if (SQL_OWNER_INTERPOLATED.test(m)) take('qualified', TENANT_WORD.test(m));
1434
+ }
1435
+ return best;
1436
+ }
1437
+
1332
1438
  const COMPARE = /(?:===|!==|==|!=|\.equals\(|\bis not\b|\bis\b|\bnot in\b|\bin\b|\.includes\(|\.has\()/;
1333
1439
  const REJECT = /(?:\breturn\b|\bthrow\b|\braise\b|\babort\b|\bexit\b|\b40[0-9]\b|\bforbidden\b|\bunauthori[sz]ed\b|\bHttpError\b|\breject\b|\bnot_?allowed\b|\bdeny\b|\bdenied\b|throwError|\bfinish\b|\bhalt\b|\bPermissionDenied\b|\bAccessDenied\b|status\()/i;
1334
1440
 
@@ -1339,6 +1445,13 @@ const DB_RECEIVER = /(?:^|\b)(?:db|database|conn|connection|pool|client|cursor|s
1339
1445
  const SQL_TEXT = /\b(?:select\s[\s\S]{1,120}\sfrom|insert\s+into|update\s+\w+\s+set|delete\s+from)\b/i;
1340
1446
  const HS_SENSITIVE = /(?:^|\.)(?:runDB|selectList|selectFirst|getBy|getJust|insert_?|insertEntity|update|updateWhere|delete|deleteWhere|replace|upsert|query|query_|execute|execute_|readFile|writeFile|callProcess|readProcess)$|^(?:Database\.|System\.Process\.|System\.Directory\.)/;
1341
1447
 
1448
+ // Names that answer a question rather than enforce a rule.
1449
+ const PREDICATE_NAME = /^(?:is|has|can|cannot|allow|owned|belongs)/i;
1450
+ function bareCallStatement(trimmedLine, name) {
1451
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1452
+ return new RegExp(`^(?:await\\s+)?${esc}\\s*\\(`).test(trimmedLine) && /\)\s*;?\s*(?:\/\/.*|\/\*.*)?$/.test(trimmedLine);
1453
+ }
1454
+
1342
1455
  const WRITE_VERB = /(?:insert|update|upsert|delete|remove|destroy|save|create|write|append|unlink|rm|exec|spawn|system|popen|post|put|patch|replace)/i;
1343
1456
 
1344
1457
  function kindOfAuthName(name) {
@@ -1374,6 +1487,51 @@ function clip(s) { return s.trim().slice(0, 160); }
1374
1487
  * lines the FILE's lines (0-based array)
1375
1488
  * Returns [{ symbolId, line, category:'control'|'sensitive', kind, subkind, evidence, basis, rejectionSeen, text }]
1376
1489
  */
1490
+ /**
1491
+ * Blank the comments (and Python docstrings) of a source file, keeping every character position and newline, so line numbers do not move.
1492
+ * The analysis looks for calls and comparisons in code; a comment that SAYS `ownerId: req.user.id` or `requireUser(req)` is prose, and prose is the
1493
+ * easiest thing for a hostile or merely careless author to write. Strings are left alone (the principal and SQL rules already read around them).
1494
+ */
1495
+ function blankComments(text, file = '') {
1496
+ const src = String(text || '');
1497
+ const hash = /\.(?:py|rb|sh|php|pl|r)$/i.test(file);
1498
+ const dashes = /\.l?hs$/i.test(file);
1499
+ const cLike = !dashes && !/\.(?:py|rb|sh|pl|r)$/i.test(file);
1500
+ const out = src.split('');
1501
+ const n = src.length;
1502
+ let i = 0;
1503
+ const blank = (a, b) => { for (let k = a; k < b && k < n; k++) if (out[k] !== '\n') out[k] = ' '; };
1504
+ while (i < n) {
1505
+ const c = src[i]; const d = src[i + 1];
1506
+ if (c === '"' || c === "'" || c === '`') {
1507
+ if (/\.py$/i.test(file) && src.startsWith(c.repeat(3), i)) {
1508
+ const end = src.indexOf(c.repeat(3), i + 3); const stop = end < 0 ? n : end + 3;
1509
+ blank(i, stop); i = stop; continue; // a docstring or triple-quoted block is prose
1510
+ }
1511
+ let j = i + 1;
1512
+ while (j < n && src[j] !== c && !(src[j] === '\n' && c !== '`')) { if (src[j] === '\\') j++; j++; }
1513
+ i = j + 1; continue;
1514
+ }
1515
+ if (cLike && c === '/' && d === '*') { const end = src.indexOf('*/', i + 2); const stop = end < 0 ? n : end + 2; blank(i, stop); i = stop; continue; }
1516
+ if (cLike && c === '/' && d === '/' && (i === 0 || /[\s;,)}{\]]/.test(src[i - 1]))) { let j = src.indexOf('\n', i); if (j < 0) j = n; blank(i, j); i = j; continue; }
1517
+ if (hash && c === '#' && !(file.endsWith('.php') && d === '[')) { let j = src.indexOf('\n', i); if (j < 0) j = n; blank(i, j); i = j; continue; }
1518
+ if (dashes && c === '-' && d === '-' && !/[!#$%&*+./<=>?@\\^|~:]/.test(src[i + 2] || ' ')) { let j = src.indexOf('\n', i); if (j < 0) j = n; blank(i, j); i = j; continue; }
1519
+ if (dashes && c === '{' && d === '-') { const end = src.indexOf('-}', i + 2); const stop = end < 0 ? n : end + 2; blank(i, stop); i = stop; continue; }
1520
+ i++;
1521
+ }
1522
+ return out.join('');
1523
+ }
1524
+
1525
+ function bodyAfterSignature(raw) {
1526
+ const arrow = raw.indexOf('=>');
1527
+ let rest = null;
1528
+ if (arrow >= 0) rest = raw.slice(arrow + 2);
1529
+ else { const m = /\)\s*\{(.*)$/.exec(raw); if (m) rest = m[1]; }
1530
+ if (rest === null) return '';
1531
+ const brace = /^\s*\{(.*)$/.exec(rest);
1532
+ return brace ? brace[1] : rest;
1533
+ }
1534
+
1377
1535
  function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
1378
1536
  const out = [];
1379
1537
  const seen = new Set();
@@ -1406,15 +1564,22 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
1406
1564
  const calls = [];
1407
1565
  const re = /([A-Za-z_$][\w$]*(?:(?:\.|::)[A-Za-z_$][\w$]*)*)\s*\(/g;
1408
1566
  let m;
1409
- if (!isDeclLine) { while ((m = re.exec(raw))) calls.push(m[1]); }
1567
+ // A one-line handler (`app.get('/x', guard, async (req, res) => { res.json(await db.docs.findOne({ id })); })`) is its own declaration
1568
+ // line, so skipping the whole line also skipped its body. Only the SIGNATURE is skipped: the calls after the arrow or the opening brace count.
1569
+ const scanText = isDeclLine ? bodyAfterSignature(raw) : raw;
1570
+ while ((m = re.exec(scanText))) calls.push(m[1]);
1410
1571
  if (haskell && !isDeclLine) {
1411
1572
  const hre = /\b([A-Z][\w.]*\.[a-z][\w']*|[a-z][\w']*)\b/g;
1412
1573
  while ((m = hre.exec(raw))) if (AUTH_NAME.test(m[1]) || HS_SENSITIVE.test(m[1])) calls.push(m[1]);
1413
1574
  }
1414
1575
  for (const name of calls) {
1415
1576
  if (AUTH_NAME.test(name) && !/^(?:if|for|while|switch|catch|function)$/.test(name)) {
1416
- const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' ')) || /^(?:require|ensure|enforce|assert|authori[sz]e|authenticate|protect)/i.test(name.split(/[.:]/).pop());
1417
- push({ line: ln, category: 'control', kind: kindOfAuthName(name), subkind: null, evidence: 'inferred', basis: 'call-name', rejectionSeen: rej, text: clip(raw) });
1577
+ const lastSeg = name.split(/[.:]/).pop();
1578
+ // A predicate (isOwner, hasRole, can) only returns an answer. Called as a bare statement its answer is thrown away,
1579
+ // so it guards nothing, whatever the lines around it say.
1580
+ const discarded = !haskell && PREDICATE_NAME.test(lastSeg) && bareCallStatement(t, name);
1581
+ const rej = !discarded && (REJECT.test(lines.slice(ln - 1, ln + 3).join(' ')) || /^(?:require|ensure|enforce|assert|authori[sz]e|authenticate|protect)/i.test(lastSeg));
1582
+ push({ line: ln, category: 'control', kind: kindOfAuthName(name), subkind: null, evidence: 'inferred', basis: discarded ? 'call-name-result-discarded' : 'call-name', rejectionSeen: rej, text: clip(raw) });
1418
1583
  }
1419
1584
  const last = name.split(/[.:]/).pop();
1420
1585
  const recv = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : '';
@@ -1430,9 +1595,9 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
1430
1595
  push({ line: ln, category: 'sensitive', kind: sub, subkind: verb, evidence: 'observed', basis: 'call-name', rejectionSeen: null, text: clip(raw) });
1431
1596
  // a data operation whose own statement mentions the principal and an owner/tenant field is scoped by it
1432
1597
  const stmt = statementAt(lines, ln);
1433
- const pr = sub.startsWith('data-') && OWNER_FIELD.test(stmt) ? principalIn(stmt, extraPrincipal) : null;
1598
+ const pr = sub.startsWith('data-') ? ownerBinding(stmt, extraPrincipal) : null;
1434
1599
  if (pr) {
1435
- push({ line: ln, category: 'control', kind: /tenant|org|workspace|team|account/i.test(stmt) ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr === 'qualified' ? 'observed' : 'inferred', basis: pr === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
1600
+ push({ line: ln, category: 'control', kind: pr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr.level === 'qualified' ? 'observed' : 'inferred', basis: pr.level === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
1436
1601
  }
1437
1602
  }
1438
1603
  }
@@ -1440,17 +1605,17 @@ function analyzeSymbolText(symbol, lines, { haskell = false } = {}) {
1440
1605
  const verb = /\b(?:insert|update|delete)\b/i.test(raw) ? 'write' : 'read';
1441
1606
  push({ line: ln, category: 'sensitive', kind: `data-${verb}`, subkind: verb, evidence: 'observed', basis: 'sql-text', rejectionSeen: null, text: clip(raw) });
1442
1607
  const stmt = statementAt(lines, ln);
1443
- const pr = OWNER_FIELD.test(stmt) ? principalIn(stmt, extraPrincipal) : null;
1608
+ const pr = ownerBinding(stmt, extraPrincipal);
1444
1609
  if (pr) {
1445
- push({ line: ln, category: 'control', kind: /tenant|org|workspace|team|account/i.test(stmt) ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr === 'qualified' ? 'observed' : 'inferred', basis: pr === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
1610
+ push({ line: ln, category: 'control', kind: pr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: pr.level === 'qualified' ? 'observed' : 'inferred', basis: pr.level === 'qualified' ? 'query-filter' : 'query-filter-bare-identifier', rejectionSeen: null, text: clip(raw) });
1446
1611
  }
1447
1612
  }
1448
1613
 
1449
1614
  // a resource field compared with the principal: the ownership check itself
1450
- const cmpPr = COMPARE.test(raw) && OWNER_FIELD.test(raw) && /\b(?:if|unless|assert|when|guard|require|expect|where|elif|&&|\|\|)\b|\?/.test(raw) ? principalIn(raw, extraPrincipal) : null;
1615
+ const cmpPr = COMPARE.test(raw) && /\b(?:if|unless|assert|when|guard|require|expect|where|elif|&&|\|\|)\b|\?/.test(raw) ? ownerBinding(raw, extraPrincipal) : null;
1451
1616
  if (cmpPr) {
1452
1617
  const rej = REJECT.test(lines.slice(ln - 1, ln + 3).join(' '));
1453
- push({ line: ln, category: 'control', kind: /tenant|org|workspace|team|account/i.test(raw) ? 'tenant-scope' : 'ownership', subkind: null, evidence: cmpPr === 'qualified' ? 'observed' : 'inferred', basis: cmpPr === 'qualified' ? 'comparison' : 'comparison-bare-identifier', rejectionSeen: rej, text: clip(raw) });
1618
+ push({ line: ln, category: 'control', kind: cmpPr.tenant ? 'tenant-scope' : 'ownership', subkind: null, evidence: cmpPr.level === 'qualified' ? 'observed' : 'inferred', basis: cmpPr.level === 'qualified' ? 'comparison' : 'comparison-bare-identifier', rejectionSeen: rej, text: clip(raw) });
1454
1619
  }
1455
1620
  }
1456
1621
  // A function whose NAME says it is a guard and whose BODY shows a rejection path is a guard function. This is
@@ -1805,7 +1970,7 @@ async function collectEntrypoints({ fileContents, graph, config = {} }) {
1805
1970
  const n = (ord.get(stem) || 0) + 1;
1806
1971
  ord.set(stem, n);
1807
1972
  r.id = `ep:${stem.slice(0, 20)}${n > 1 ? `-${n}` : ''}`;
1808
- r.label = `${r.method ? r.method + ' ' : ''}${r.pathTemplate}`.trim() || r.kind;
1973
+ r.label = (0,_canonical_js__WEBPACK_IMPORTED_MODULE_0__/* .safeLabel */ .IW)(`${r.method ? r.method + ' ' : ''}${r.pathTemplate}`) || r.kind;
1809
1974
  void hname;
1810
1975
  let disposition;
1811
1976
  const excluded = exclude.some(re => re.test(r.registrationSite.file) || re.test(r.pathTemplate));
@@ -2911,6 +3076,8 @@ async function runQueue(jobs, ctx) {
2911
3076
  maxCalls: Math.min(HARD_LIMITS.maxCalls, Math.max(1, ctx.limits.maxCalls || 100)),
2912
3077
  maxWallMs: Math.min(HARD_LIMITS.maxWallSeconds, Math.max(1, ctx.limits.maxWallSeconds || 900)) * 1000,
2913
3078
  maxTokensPerJob: ctx.limits.maxTokensPerJob || 40000,
3079
+ maxTokensPerRepo: Number.isFinite(ctx.limits.maxTokensPerRepo) ? ctx.limits.maxTokensPerRepo : null,
3080
+ anonymizePrompts: ctx.limits.anonymizePrompts === true,
2914
3081
  maxRetries: Math.min(HARD_LIMITS.maxRetries, Math.max(0, ctx.limits.maxRetries ?? 3)),
2915
3082
  jobTimeoutMs: ctx.limits.jobTimeoutMs || 120000,
2916
3083
  maxCostUsd: Number.isFinite(ctx.limits.maxCostUsd) ? ctx.limits.maxCostUsd : null,
@@ -2924,6 +3091,7 @@ async function runQueue(jobs, ctx) {
2924
3091
  const results = new Map();
2925
3092
  let calls = 0;
2926
3093
  let estCost = 0;
3094
+ let tokensUsed = 0;
2927
3095
  let cooldownUntil = 0;
2928
3096
  let budgetStop = null;
2929
3097
  let persistChain = Promise.resolve();
@@ -2973,6 +3141,7 @@ async function runQueue(jobs, ctx) {
2973
3141
  if (ctx.signal && ctx.signal.aborted) return 'cancelled';
2974
3142
  if (now() - started >= L.maxWallMs) return 'budget-exhausted:wall-time';
2975
3143
  if (calls >= L.maxCalls) return 'budget-exhausted:max-calls';
3144
+ if (L.maxTokensPerRepo !== null && tokensUsed >= L.maxTokensPerRepo) return 'budget-exhausted:max-tokens-per-repo';
2976
3145
  if (L.maxCostUsd !== null && L.costPerCallUsd !== null && estCost + L.costPerCallUsd > L.maxCostUsd) return 'budget-exhausted:max-cost';
2977
3146
  return null;
2978
3147
  };
@@ -2996,8 +3165,9 @@ async function runQueue(jobs, ctx) {
2996
3165
  state.jobs[job.key] = { state: 'running', reason: null, attempts, label: job.label };
2997
3166
  const out = await (0,_review_js__WEBPACK_IMPORTED_MODULE_2__/* .runReviewOnce */ .z6)({
2998
3167
  pack: loadedJob.pack, entrypoints: loadedJob.entrypoints, manifests: loadedJob.manifests, packageIndexes: job.packageIndexes, packageTexts: loadedJob.packageTexts,
2999
- fileContents: ctx.fileContents, scanRoot: ctx.root, invoker: ctx.invoker, model: ctx.model, limits: { maxTokensPerJob: L.maxTokensPerJob, jobTimeoutMs: L.jobTimeoutMs },
3168
+ fileContents: ctx.fileContents, scanRoot: ctx.root, invoker: ctx.invoker, model: ctx.model, limits: { maxTokensPerJob: L.maxTokensPerJob, jobTimeoutMs: L.jobTimeoutMs, anonymizePrompts: L.anonymizePrompts },
3000
3169
  });
3170
+ if (out.usage) tokensUsed += (out.usage.inputTokensEstimated || 0) + (out.usage.outputTokensEstimated || 0);
3001
3171
  if (out.state === 'skipped_by_policy') calls--; // nothing was sent
3002
3172
  if (out.state === 'failed' && out.retryable && attempts <= L.maxRetries) {
3003
3173
  // exponential backoff with jitter; the shared cooldown slows every worker, which is what a 429 storm needs
@@ -3134,7 +3304,8 @@ function importBatch(root, batchId, resultsText, ctx) {
3134
3304
  /* harmony export */ });
3135
3305
  /* unused harmony exports DEFAULT_LIMITS, PROTECTION_DIMENSIONS */
3136
3306
  /* harmony import */ var _controls_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(17202);
3137
- /* harmony import */ var _graph_schema_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(44287);
3307
+ /* harmony import */ var _canonical_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(44652);
3308
+ /* harmony import */ var _graph_schema_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(44287);
3138
3309
  //
3139
3310
  // Deterministic, bounded reachability from an entry point's handler.
3140
3311
  //
@@ -3151,6 +3322,7 @@ function importBatch(root, batchId, resultsText, ctx) {
3151
3322
 
3152
3323
 
3153
3324
 
3325
+
3154
3326
  const DEFAULT_LIMITS = Object.freeze({ maxSymbols: 60, maxDepth: 12 });
3155
3327
 
3156
3328
  function indexGraph(graph) {
@@ -3172,7 +3344,7 @@ function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
3172
3344
  const lim = { ...DEFAULT_LIMITS, ...limits };
3173
3345
  const { byId, edgesFrom } = indexGraph(graph);
3174
3346
  const linesCache = new Map();
3175
- const linesOf = (f) => { if (!linesCache.has(f)) linesCache.set(f, (fileContents[f] || '').split('\n')); return linesCache.get(f); };
3347
+ const linesOf = (f) => { if (!linesCache.has(f)) linesCache.set(f, (0,_controls_js__WEBPACK_IMPORTED_MODULE_0__/* .blankComments */ .$E)(fileContents[f] || '', f).split('\n')); return linesCache.get(f); };
3176
3348
 
3177
3349
  const visited = [];
3178
3350
  const seen = new Set();
@@ -3207,7 +3379,7 @@ function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
3207
3379
  }
3208
3380
 
3209
3381
  // middleware runs before the handler: it is enqueued first and ordered at line -1
3210
- for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || String(a.name).localeCompare(String(b.name)))) {
3382
+ for (const mw of [...entrypoint.middleware].sort((a, b) => (a.line - b.line) || (0,_canonical_js__WEBPACK_IMPORTED_MODULE_1__/* .cmpCodeUnit */ .kH)(a.name, b.name))) {
3211
3383
  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');
3212
3384
  }
3213
3385
  if (root.symbolId) {
@@ -3239,7 +3411,7 @@ function reachFrom({ graph, fileContents, entrypoint, limits = {} }) {
3239
3411
  if (e.to) {
3240
3412
  if (cur.depth + 1 > lim.maxDepth) { capped = true; continue; }
3241
3413
  enqueue(e.to, cur.depth + 1, 'callee', via, [...cur.path, e.to], 'exact');
3242
- } else if (_graph_schema_js__WEBPACK_IMPORTED_MODULE_1__/* .MATERIAL_REASONS */ .U2.has(e.unresolvedReason)) {
3414
+ } else if (_graph_schema_js__WEBPACK_IMPORTED_MODULE_2__/* .MATERIAL_REASONS */ .U2.has(e.unresolvedReason)) {
3243
3415
  unknowns.push({ type: e.kind, site: `${sym.file}:${e.line}`, from: cur.symbolId, calleeName: e.calleeName, reason: e.unresolvedReason, material: true, candidates: e.candidates });
3244
3416
  // Candidates are shown to the model as CANDIDATES (low confidence); the unknown stays material regardless.
3245
3417
  if (e.candidates.length && e.candidates.length <= 3 && cur.depth + 1 <= lim.maxDepth) {
@@ -3300,11 +3472,14 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
3300
3472
  for (const dim of PROTECTION_DIMENSIONS) {
3301
3473
  const kinds = dim === 'ownership' ? ['ownership', 'tenant-scope'] : [dim];
3302
3474
  const ctl = facts.filter(f => f.category === 'control' && kinds.includes(f.kind));
3303
- const before = ctl.filter(f => firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens));
3475
+ // A control whose answer is visibly thrown away (a predicate called as a bare statement) guards nothing.
3476
+ const effective = (f) => f.rejectionSeen !== false;
3477
+ const before = ctl.filter(f => effective(f) && (firstSens === null || f.effLine < firstSens || (f.basis === 'query-filter' && f.effLine <= firstSens)));
3304
3478
  const after = ctl.filter(f => firstSens !== null && f.effLine > firstSens);
3305
3479
  let state; let why;
3306
3480
  if (before.length) { state = 'present'; why = 'a control precedes the first sensitive operation'; }
3307
3481
  else if (after.length) { state = 'evaluated_and_ineffective'; why = 'every control of this kind runs after the first sensitive operation'; }
3482
+ 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'; }
3308
3483
  else if (ctl.length) { state = 'evaluated_and_ineffective'; why = 'a control is present but is not ordered before the sensitive operation'; }
3309
3484
  else if (omittedWithControls) { state = 'omitted_due_to_budget'; why = 'a symbol holding a control was left out for size'; }
3310
3485
  else if (unknowns.some(u => u.material) || capped) { state = 'not_resolved'; why = 'an unresolved call or a traversal limit could hide a control'; }
@@ -3312,6 +3487,8 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
3312
3487
  else { state = 'not_present'; why = 'the resolved context is complete and contains no control of this kind'; }
3313
3488
  dims[dim] = {
3314
3489
  state, why, firstSensitiveLine: firstSens,
3490
+ // How the control was established: 'observed' = the text shows it, 'inferred' = only a name suggests it. Only meaningful for `present`.
3491
+ strength: state === 'present' ? (before.some(f => f.evidence === 'observed') ? 'observed' : 'inferred') : null,
3315
3492
  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 })),
3316
3493
  };
3317
3494
  }
@@ -3333,7 +3510,7 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
3333
3510
  /* harmony export */ sj: () => (/* binding */ verifyReview),
3334
3511
  /* harmony export */ z6: () => (/* binding */ runReviewOnce)
3335
3512
  /* harmony export */ });
3336
- /* unused harmony exports CONCLUSIONS, LIKELIHOODS, resolveCitation, isRetryableError */
3513
+ /* unused harmony exports CONCLUSIONS, LIKELIHOODS, isLoopbackEndpoint, stripUntrustedCommands, pseudonymizer, claimGrounded, resolveCitation, isRetryableError */
3337
3514
  /* harmony import */ var _util_untrusted_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(17097);
3338
3515
  /* harmony import */ var _egress_redact_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(11723);
3339
3516
  /* harmony import */ var _egress_policy_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(45712);
@@ -3371,7 +3548,7 @@ function evaluateProtection({ facts, unknowns, omittedWithControls = false, capp
3371
3548
  const CONCLUSIONS = Object.freeze(['potential_issue', 'no_issue_observed', 'insufficient_evidence']);
3372
3549
  const LIKELIHOODS = Object.freeze(['low', 'medium', 'high']);
3373
3550
  const FORBIDDEN_MODEL_FIELDS = Object.freeze(['severity', 'proofTier', 'proof_tier', 'confirmation', 'confirmed', 'status', 'verdict', 'shipVerdict', 'ship_verdict', 'exploitability', 'cvss', 'exploit_confirmed', 'EXPLOIT_CONFIRMED']);
3374
- const MAX_CITATION_SPAN = 200;
3551
+ const MAX_CITATION_SPAN = 60;
3375
3552
  const MAX_STR = 1200;
3376
3553
  const MAX_ITEMS = 24;
3377
3554
 
@@ -3409,8 +3586,10 @@ function buildReviewPrompt({ pack, entrypoints, packageTexts, contextHash }) {
3409
3586
  for (const g of pack.guidance) lines.push(`- ${g}`);
3410
3587
  lines.push('', 'OUTPUT (JSON, evidence listed first):', JSON.stringify(OUTPUT_SHAPE, null, 2), '');
3411
3588
  entrypoints.forEach((ep, i) => {
3412
- const fenced = (0,_util_untrusted_js__WEBPACK_IMPORTED_MODULE_0__/* .fenceUntrusted */ .C0)(packageTexts[i], `context-${i + 1}`);
3413
- lines.push(`ENTRY POINT ${i + 1}: ${ep.label} (id ${ep.id})`);
3589
+ // The label comes from a route string in the repository, so it is DATA: it goes inside the fence, sanitised, and never
3590
+ // into the instruction text around it.
3591
+ const fenced = (0,_util_untrusted_js__WEBPACK_IMPORTED_MODULE_0__/* .fenceUntrusted */ .C0)(`ENTRY POINT LABEL (data): ${(0,_canonical_js__WEBPACK_IMPORTED_MODULE_5__/* .safeLabel */ .IW)(ep.label, 120)}\n${packageTexts[i]}`, `context-${i + 1}`);
3592
+ lines.push(`ENTRY POINT ${i + 1} (id ${ep.id})`);
3414
3593
  lines.push(fenced.text, '');
3415
3594
  });
3416
3595
  const prompt = lines.join('\n');
@@ -3446,25 +3625,103 @@ function redactPackageText(text, { scanRoot }) {
3446
3625
  }
3447
3626
 
3448
3627
  /** Provider/model identity used in the job key, from the same resolver the rest of the discovery layer uses. */
3449
- function describeModel() {
3628
+ function isLoopbackEndpoint(endpoint) {
3629
+ if (!endpoint) return false;
3630
+ const parts = String(endpoint).split(',').map(x => x.trim()).filter(Boolean);
3631
+ if (!parts.length) return false;
3632
+ return parts.every(u => {
3633
+ try {
3634
+ const h = new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, '');
3635
+ return h === 'localhost' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h);
3636
+ } catch { return false; }
3637
+ });
3638
+ }
3639
+
3640
+ function describeModel({ allowRemote = false } = {}) {
3641
+ let m;
3450
3642
  try {
3451
3643
  const r = (0,_llm_validator_providers_js__WEBPACK_IMPORTED_MODULE_3__.resolveProvider)({ role: 'hunt' });
3452
- 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 };
3644
+ 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 };
3453
3645
  } catch { /* fall through */ }
3454
- const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
3455
- return { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
3646
+ if (!m) {
3647
+ const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
3648
+ m = { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
3649
+ }
3650
+ // `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.
3651
+ const remote = m.provider ? !(m.provider === 'local' || isLoopbackEndpoint(m.endpoint)) : false;
3652
+ return { ...m, remote, remoteApproved: allowRemote === true };
3456
3653
  }
3457
3654
 
3458
3655
  /** Evaluate the egress policy for every file that would appear in the prompt. Returns { allowed, reason }. */
3459
3656
  function checkEgressForFiles(files, { scanRoot, model }) {
3460
3657
  if (!model.endpoint) return { allowed: true, evaluated: false };
3658
+ // Source bundles leave the machine only when the operator has said so. A loopback endpoint needs no approval; any
3659
+ // other endpoint needs --allow-remote (or review.allowRemote), on top of the egress policy below, never instead of it.
3660
+ 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 };
3461
3661
  for (const file of files) {
3462
- const d = (0,_egress_policy_js__WEBPACK_IMPORTED_MODULE_2__/* .evaluateEgress */ .nn)({ scanRoot, purpose: 'context-review', endpoint: model.endpoint, provider: model.provider, model: model.model, path: file });
3463
- if (!d.allowed) return { allowed: false, reason: d.reason, file, evaluated: true };
3662
+ // 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
3663
+ // reason, and nothing is sent. This is the only place that decides whether a prompt may leave the machine.
3664
+ let d;
3665
+ try { d = (0,_egress_policy_js__WEBPACK_IMPORTED_MODULE_2__/* .evaluateEgress */ .nn)({ scanRoot, purpose: 'context-review', endpoint: model.endpoint, provider: model.provider, model: model.model, path: file }); }
3666
+ catch (err) { return { allowed: false, reason: `egress-policy-error: ${String((err && err.message) || err).slice(0, 120)}`, file, evaluated: true, failedClosed: true }; }
3667
+ if (!d || d.allowed !== true) return { allowed: false, reason: (d && d.reason) || 'egress-policy-returned-no-decision', file, evaluated: true, ...(d ? {} : { failedClosed: true }) };
3464
3668
  }
3465
3669
  return { allowed: true, evaluated: true };
3466
3670
  }
3467
3671
 
3672
+ // ---- model output hygiene -------------------------------------------------------------------------------------
3673
+
3674
+ /**
3675
+ * 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
3676
+ * these strings reach reports, tickets and fix suggestions that people copy from, so shell-looking content is removed and counted, never passed on.
3677
+ */
3678
+ const FENCE = /```[\s\S]*?```|~~~[\s\S]*?~~~/g;
3679
+ 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)';
3680
+ 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');
3681
+ function stripUntrustedCommands(text) {
3682
+ if (typeof text !== 'string' || !text) return { text: text ?? '', stripped: 0 };
3683
+ let n = 0;
3684
+ let out = text.replace(FENCE, () => { n++; return '[command block removed]'; });
3685
+ out = out.replace(INLINE_CMD, () => { n++; return '[command removed]'; });
3686
+ return { text: out, stripped: n };
3687
+ }
3688
+ function sanitizeReviewStrings(review) {
3689
+ let total = 0;
3690
+ const walk = (v, key) => {
3691
+ if (typeof v === 'string') {
3692
+ if (['promptHash', 'responseHash', 'contextHash', 'entrypointId', 'file', 'role', 'kind', 'conclusion', 'likelihood', 'questionPack', 'status'].includes(key)) return v;
3693
+ const r = stripUntrustedCommands(v); total += r.stripped; return r.text;
3694
+ }
3695
+ if (Array.isArray(v)) return v.map((x) => walk(x, key));
3696
+ if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v)) o[k] = walk(x, k); return o; }
3697
+ return v;
3698
+ };
3699
+ const out = walk(review, '');
3700
+ if (total) out.untrustedCommandsStripped = total;
3701
+ return out;
3702
+ }
3703
+
3704
+ /** 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. */
3705
+ function provenanceOf(model, promptHash) {
3706
+ let host = null; try { host = model.endpoint ? new URL(model.endpoint).hostname : null; } catch { host = null; }
3707
+ const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
3708
+ 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 };
3709
+ }
3710
+
3711
+ // ---- prompt anonymization ---------------------------------------------------------------------------------------
3712
+
3713
+ /** Replace each real file path with a stable pseudonym (`f_<hash>.<ext>`). The mapping stays in memory; only the pseudonyms leave. */
3714
+ function pseudonymizer(files) {
3715
+ const toPseudo = new Map(); const toReal = new Map();
3716
+ for (const f of [...new Set(files)].sort()) {
3717
+ const ext = (/\.[A-Za-z0-9]{1,6}$/.exec(f) || [''])[0];
3718
+ const p = `f_${(0,_canonical_js__WEBPACK_IMPORTED_MODULE_5__/* .sha256 */ .sc)(f).slice(0, 10)}${ext}`;
3719
+ toPseudo.set(f, p); toReal.set(p, f);
3720
+ }
3721
+ 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; };
3722
+ return { toPseudo: (t) => swap(t, toPseudo), toReal: (t) => swap(t, toReal), size: toPseudo.size };
3723
+ }
3724
+
3468
3725
  // ---- response parsing and verification ------------------------------------------------------------------------
3469
3726
 
3470
3727
  function extractJson(raw) {
@@ -3493,14 +3750,29 @@ function normalizeCitations(list) {
3493
3750
  return { items, malformed };
3494
3751
  }
3495
3752
 
3496
- /** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained. */
3753
+ 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']);
3754
+
3755
+ /**
3756
+ * A claim is grounded when at least one meaningful word in it (or the first five letters of a longer one, so "ownership"
3757
+ * meets `ownerId`) appears in the lines it cites. This does not prove the claim is true; it stops a citation that points at
3758
+ * real lines while describing something else, and an empty claim, from being accepted as evidence.
3759
+ */
3760
+ function claimGrounded(claim, citedText) {
3761
+ const hay = String(citedText).toLowerCase();
3762
+ const words = (String(claim).toLowerCase().match(/[a-z_][a-z0-9_]{3,}/g) || []).filter(w => !CLAIM_STOPWORDS.has(w));
3763
+ return words.some(w => hay.includes(w.length > 5 ? w.slice(0, 5) : w));
3764
+ }
3765
+
3766
+ /** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained, and its claim is about those lines. */
3497
3767
  function resolveCitation(c, segments, fileContents) {
3498
3768
  if (c.startLine < 1 || c.endLine < c.startLine || c.endLine - c.startLine > MAX_CITATION_SPAN) return 'range-invalid';
3499
3769
  const text = fileContents[c.file];
3500
3770
  if (typeof text !== 'string') return 'file-not-in-snapshot';
3501
- if (c.endLine > text.split('\n').length) return 'beyond-end-of-file';
3771
+ const fileLines = text.split('\n');
3772
+ if (c.endLine > fileLines.length) return 'beyond-end-of-file';
3502
3773
  const inside = segments.some(s => s.file === c.file && c.startLine >= s.startLine && c.endLine <= s.endLine);
3503
- return inside ? null : 'outside-provided-context';
3774
+ if (!inside) return 'outside-provided-context';
3775
+ return claimGrounded(c.claim, fileLines.slice(c.startLine - 1, c.endLine).join('\n')) ? null : 'claim-not-grounded-in-cited-lines';
3504
3776
  }
3505
3777
 
3506
3778
  function decline(base, reasons, extra = {}) {
@@ -3589,13 +3861,24 @@ function verifyReview({ raw, pack, entrypoints, manifests, packageIndexes, fileC
3589
3861
  out.downgradedFrom = 'no_issue_observed';
3590
3862
  out.conclusion = 'insufficient_evidence';
3591
3863
  out.downgradeReason = 'no-mitigation-cited';
3864
+ } else if (dim) {
3865
+ // The cited mitigation must be a control the deterministic pass also recognised, and one the text actually shows:
3866
+ // a model cannot clear a route by pointing at an arbitrary line or at a control known only from its name.
3867
+ 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)));
3868
+ const onlyInferred = manifests.every(m => (m.protection[dim] || {}).state === 'present' && (m.protection[dim] || {}).strength === 'inferred');
3869
+ if (!recognised || onlyInferred) {
3870
+ out.downgradedFrom = 'no_issue_observed';
3871
+ out.conclusion = 'insufficient_evidence';
3872
+ out.downgradeReason = !recognised ? 'mitigation-not-a-recognised-control' : 'only-inferred-controls';
3873
+ }
3592
3874
  }
3593
3875
  }
3594
3876
 
3595
3877
  // how the model's reading compares with the deterministic reading (metadata only; neither overrides the other)
3596
3878
  if (dim) {
3597
3879
  const states = manifests.map(m => m.protection[dim]?.state);
3598
- const present = states.every(s => s === 'present');
3880
+ // `present` counts as agreement only when the control is shown by the text; a control known only from its name is neutral.
3881
+ const present = manifests.every(m => m.protection[dim]?.state === 'present' && m.protection[dim]?.strength !== 'inferred');
3599
3882
  const absent = states.some(s => s === 'not_present' || s === 'evaluated_and_ineffective');
3600
3883
  out.deterministicProtection = { dimension: dim, states };
3601
3884
  out.deterministicAgreement = out.conclusion === 'potential_issue' ? (absent ? 'agrees' : present ? 'conflicts' : 'neutral') : (out.conclusion === 'no_issue_observed' ? (present ? 'agrees' : absent ? 'conflicts' : 'neutral') : 'neutral');
@@ -3647,7 +3930,12 @@ async function runReviewOnce({ pack, entrypoints, manifests, packageIndexes, pac
3647
3930
  const badRedact = redacted.find(r => !r.ok);
3648
3931
  if (badRedact) return { state: 'skipped_by_policy', reason: `redaction refused: ${badRedact.reason}` };
3649
3932
 
3650
- const { prompt, promptHash } = buildReviewPrompt({ pack, entrypoints, packageTexts: redacted.map(r => r.text), contextHash: manifests[0].contextHash });
3933
+ // Policy-driven anonymization: file paths are replaced by pseudonyms before the prompt is built, and mapped back in the answer before its citations
3934
+ // are checked against the real snapshot. The model never sees a real path, and what is hashed is what was sent.
3935
+ const anon = limits.anonymizePrompts ? pseudonymizer(files) : null;
3936
+ const sendTexts = anon ? redacted.map(r => anon.toPseudo(r.text)) : redacted.map(r => r.text);
3937
+ const sendEntrypoints = anon ? entrypoints.map(e => JSON.parse(anon.toPseudo(JSON.stringify(e)))) : entrypoints;
3938
+ const { prompt, promptHash } = buildReviewPrompt({ pack, entrypoints: sendEntrypoints, packageTexts: sendTexts, contextHash: manifests[0].contextHash });
3651
3939
  const est = (0,_packer_js__WEBPACK_IMPORTED_MODULE_6__/* .estimateTokens */ .bP)(prompt);
3652
3940
  if (est.tokens > limits.maxTokensPerJob) return { state: 'skipped_by_policy', reason: `prompt is about ${est.tokens} tokens, over the ${limits.maxTokensPerJob} per-job limit` };
3653
3941
 
@@ -3659,7 +3947,9 @@ async function runReviewOnce({ pack, entrypoints, manifests, packageIndexes, pac
3659
3947
  return { state: 'failed', reason: `model call failed: ${String((err && err.message) || err).slice(0, 160)}`, retryable: isRetryableError(err) };
3660
3948
  }
3661
3949
  const usage = { inputTokensEstimated: est.tokens, outputTokensEstimated: Math.ceil(String(raw || '').length / 4), source: 'estimate', redactions: redacted.reduce((n, r) => n + r.redactions, 0) };
3662
- const review = verifyReview({ raw, pack, entrypoints, manifests, packageIndexes, fileContents, promptHash });
3950
+ const rawReal = anon && typeof raw === 'string' ? anon.toReal(raw) : raw;
3951
+ const review0 = verifyReview({ raw: rawReal, pack, entrypoints, manifests, packageIndexes, fileContents, promptHash });
3952
+ const review = { ...sanitizeReviewStrings(review0), modelProvenance: provenanceOf(model, promptHash), ...(anon ? { promptAnonymized: true } : {}) };
3663
3953
  if (review.status === 'declined') return { state: 'partial', reason: `answer declined: ${review.declineReasons.join(',')}`, review, usage };
3664
3954
  return { state: 'succeeded', reason: null, review, usage };
3665
3955
  }