@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.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 (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +29 -0
  44. package/src/dataflow/catalog.js +21 -0
  45. package/src/dataflow/engine.js +20 -0
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -14,6 +14,7 @@ import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
15
  import * as yaml from '../util/yaml.js';
16
16
  import { statePath } from '../posture/state-dir.js';
17
+ import { portfolioAssuranceEnabled, HEADLINE, BLOCKED_HEADLINE } from '../posture/portfolio/wording.js';
17
18
 
18
19
  function _configPath(scanRoot) {
19
20
  return statePath(scanRoot, 'integrations.yml');
@@ -36,12 +37,14 @@ export function buildSlackDigest(findings, summary, options = {}) {
36
37
  const project = options.project || 'project';
37
38
  const status = (summary.critical || 0) === 0 && (summary.high || 0) === 0
38
39
  ? '✅ safe to deploy' : '❌ not safe to deploy';
40
+ const statusLine = portfolioAssuranceEnabled({ option: options.portfolioAssurance })
41
+ ? (status.startsWith('✅') ? `✅ ${HEADLINE}` : `❌ ${BLOCKED_HEADLINE}`) + ' (completed supported checks only)' : status;
39
42
  const top = (findings || []).slice(0, 3);
40
43
 
41
44
  const lines = [
42
45
  `*🛡 agentic-security daily — ${new Date().toISOString().slice(0,10)}*`,
43
46
  `Project: \`${project}\``,
44
- `Status: ${status}`,
47
+ `Status: ${statusLine}`,
45
48
  '',
46
49
  `*Findings:* ${summary.critical || 0} critical · ${summary.high || 0} high · ${summary.medium || 0} medium`,
47
50
  ];
@@ -66,7 +69,7 @@ export function buildDiscordDigest(findings, summary, options = {}) {
66
69
  embeds: [{
67
70
  title: `🛡 agentic-security — ${project}`,
68
71
  color: safe ? 0x2ecc71 : 0xe74c3c,
69
- description: safe ? '✅ safe to deploy' : '❌ not safe to deploy',
72
+ description: portfolioAssuranceEnabled({ option: options.portfolioAssurance }) ? (safe ? `✅ ${HEADLINE} (completed supported checks only)` : `❌ ${BLOCKED_HEADLINE}`) : (safe ? '✅ safe to deploy' : '❌ not safe to deploy'),
70
73
  fields: [
71
74
  { name: 'Critical', value: String(summary.critical || 0), inline: true },
72
75
  { name: 'High', value: String(summary.high || 0), inline: true },
package/src/ir/CLAUDE.md CHANGED
@@ -34,6 +34,20 @@ consumed by `scanner/src/dataflow/` for taint analysis.
34
34
  | Rust | `parser-rust.js` | Hand-rolled, the `parser-go.js`/`parser-cs.js` pattern (preprocess: comments blanked, `::` paths flattened to dots, turbofish/`?`/`.await` stripped; then statement-split + recursive CFG build). **SARD 80% F1 push, first-class from day one — no prior IR existed for Rust at all** (only the opt-in, WASM-based `tree-sitter-loader.js` long-tail path below, which nothing in the taint engine consumes). Models: `fn` items (free/impl/trait, qualifiers, generics, `impl Type` methods named `Type.method`), `let`/destructuring/compound-assign, method chains (args accumulated innermost-first, so `Command::new("sh").arg("-c").arg(x)` has the literal program name at arg 0 regardless of the chain's terminal method), macros (`format!`/`write!`/etc. lower to a template with inline `{ident}` and positional args; `vec!` to an array; every other macro keeps its own `name!` callee so `sqlx::query!` — the compile-checked, parameterized macro — never collides with the `query` sink); `if`/`if let`/`while`/`while let`/`loop`/`for`/`match` (pattern bindings assigned from the scrutinee) including expression-position control flow (`let x = match … {}` yields a `union` of the branch tails); trailing closures inlined with their parameter bound to the receiver; extractor-typed parameters (`Query(q): Query<P>`) and route-attributed handlers (`#[get("/<x>")]`) emit `fn.paramAnnotations`. **`.unwrap()`/`.expect(msg)` are popped from the chain, not just taint-transparent** — leaving them in shifts the terminal segment away from the real sink name (`sqlx::query(&sql).fetch_all(pool).await.unwrap()` would otherwise end in `.unwrap`, never `.fetch_all`, and no catalog entry could ever match it), the same "terminal segment shift" defect class documented for Go/Java/Kotlin above; a macro-only statement whose sole continuation is a popped `.unwrap()` (`write!(w, "…", x).unwrap();`) still counts as a call, tracked via a separate `_baseIsCall` flag so it doesn't fall through to "no call happened here" once the passthrough is gone. Three self-scan-flagged regexes (`(?:#\[[^\]]*\]\s*)+`, the attribute-skip pattern, appearing at three call sites) match the "optional/quantified group flanked by `\s*`" ReDoS heuristic shape but are linear by construction — each iteration is bounded by the mandatory literal `#[`…`]`, no shared character class exists for the engine to backtrack across — confirmed by direct timing (50 000 back-to-back attributes, 200 000 non-matching whitespace chars: both single-digit milliseconds), not merely asserted; see the comment at `_parseParams` in `parser-rust.js`. Guarded by `test/parser-rust.test.js` (CFG shape) and `test/rust-taint-flow.test.js` (end-to-end taint flow + precision cases). **Measured `bench/layer-recall` impact: 0 → 5/5 (100%) whole-corpus, 0 → 3/3 (100%) deep-tier** on first measurement — the corpus is small and every entry is annotation-sourced (axum/actix/rocket extractor parameters route directly into `paramAnnotations`), so 100% reflects the corpus's own composition, not general Rust ecosystem coverage; see `docs/METRICS.md`'s own disclosure. |
35
35
  | Long-tail (solidity/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. Rust's own tree-sitter grammar remains loadable here too (unaffected by `parser-rust.js` existing), but the hand-rolled parser above is what feeds the taint engine — this path is now redundant for Rust specifically, kept only because removing a language from `tree-sitter-sinks.js`'s opt-in surface is out of scope for this change. |
36
36
 
37
+ ## Implicit handler parameters and Go line attribution (QA-006)
38
+
39
+ `implicit-handler-params.js` is the pure decision "is this parameter request-bound without any annotation". `parser-cs.js` emits a
40
+ `paramAnnotations` entry with decorator `ImplicitMvcActionParam` for a string-shaped parameter of a public, non-static, non-`[NonAction]` method of a
41
+ controller class (derived from Controller/ControllerBase/ApiController, or named `*Controller`) unless it carries `[FromServices]` or an explicit
42
+ `[From*]`; `parser-java.js` emits `ImplicitSpringMappedParam` for a plain `String` parameter of a method annotated `@GetMapping`/`@PostMapping`/
43
+ `@RequestMapping`/... The catalog turns those two names into sources; the IR states the fact and the catalog decides what is a source. Nothing in the
44
+ module reads a path, label or environment variable.
45
+
46
+ `parser-go.js` line attribution: `FUNC_RE`'s leading `\s*` swallows blank lines, so the function used to be placed at the first newline of the run above
47
+ it, and `_splitStatements` dropped blank and comment lines so every later statement was pulled upward by their count. Statement lines are now start-offset
48
+ based (`_splitStatements(...).lineOffsets`, `_buildCfg` takes the line the body BEGINS on), a `//` comment no longer swallows the newline that ends its
49
+ line, and nested if/for/closure bodies take their base from the real brace position. Tests: `test/evaluation/engine-mechanisms.test.js`.
50
+
37
51
  ## Python parser — dual-path with auto fallback
38
52
 
39
53
  The Python pipeline has two implementations. The dispatcher in `index.js`
@@ -0,0 +1,74 @@
1
+ // Implicit request-bound handler parameters (QA-006, mechanism "framework entry points").
2
+ //
3
+ // A web framework binds the parameters of a request-handler method from the
4
+ // request WITHOUT any annotation on the parameter itself:
5
+ // - ASP.NET MVC / Core: a public action method of a controller binds `string next`
6
+ // from the route, query string or form unless it carries `[FromServices]`.
7
+ // - Spring MVC: a method carrying `@GetMapping` / `@RequestMapping` / ... binds a plain
8
+ // `String q` parameter from the request parameters (`@RequestParam` is optional).
9
+ //
10
+ // The taint engine's annotation channel (`fn.paramAnnotations`, matched by the catalog's
11
+ // `type: 'annotation'` sources) only ever saw the EXPLICIT forms (`[FromQuery]`,
12
+ // `@RequestParam`), so a handler written in the equally common implicit form had no
13
+ // source at all and the flow was invisible to the taint layer. This module is the pure
14
+ // decision of "is this parameter implicitly request-bound", shared by the C# and Java
15
+ // frontends; each frontend turns a positive answer into a `paramAnnotations` entry whose
16
+ // decorator is one of the two names below, which the catalog binds to a source.
17
+ //
18
+ // Scope, stated so nothing is over-read:
19
+ // - ONLY string-shaped parameters. A numeric or Guid parameter cannot carry an injection
20
+ // payload, and a complex model parameter's field-level taint is out of scope here.
21
+ // - ONLY inside a class the framework would treat as a handler (a Controller-derived
22
+ // class, or any class whose name ends in `Controller`; for Spring, the MAPPING
23
+ // annotation on the method is what makes it a handler, not the class).
24
+ // - A DI-injected parameter is not request-bound: `[FromServices]` opts a parameter out.
25
+ // - Nothing here looks at a file name, path, comment or label.
26
+
27
+ export const IMPLICIT_MVC_PARAM = 'ImplicitMvcActionParam';
28
+ export const IMPLICIT_SPRING_PARAM = 'ImplicitSpringMappedParam';
29
+
30
+ const CS_CONTROLLER_BASE = /^(?:Controller|ControllerBase|ApiController|AsyncController)$/;
31
+ const CS_STRINGISH = /^(?:(?:System\.)?[Ss]tring\??(?:\[\])?|(?:List|IList|IEnumerable|ICollection|IReadOnlyList)<\s*(?:System\.)?[Ss]tring\??\s*>)$/;
32
+ const CS_DI_OPT_OUT = new Set(['FromServices', 'FromKeyedServices']);
33
+
34
+ /** True when a C# class record (`{ name, bases }`) is one the framework treats as a controller. */
35
+ function isCsControllerClass(range) {
36
+ if (!range) return false;
37
+ if (/Controller$/.test(range.name || '')) return true;
38
+ return (range.bases || []).some((b) => CS_CONTROLLER_BASE.test(b));
39
+ }
40
+
41
+ /**
42
+ * Should a C# parameter be treated as implicitly request-bound?
43
+ * @param {object} o
44
+ * @param {string} o.modifiers the method's modifier text (the declaration prefix), e.g. 'public async'
45
+ * @param {string} o.rawType the parameter's declared type text, e.g. 'string', 'List<string>'
46
+ * @param {string[]} o.decorators the parameter's attribute names
47
+ * @param {boolean} o.nonAction the method carries [NonAction]
48
+ * @param {object} o.classRange the enclosing class record
49
+ */
50
+ export function isImplicitCsActionParam({ modifiers = '', rawType = '', decorators = [], nonAction = false, classRange = null }) {
51
+ if (nonAction) return false;
52
+ if (!isCsControllerClass(classRange)) return false;
53
+ if (!/\bpublic\b/.test(modifiers) || /\bstatic\b/.test(modifiers)) return false;
54
+ if (decorators.some((d) => CS_DI_OPT_OUT.has(d))) return false;
55
+ // An explicit binding attribute is handled by the explicit annotation sources.
56
+ if (decorators.some((d) => /^From/.test(d))) return false;
57
+ return CS_STRINGISH.test(String(rawType).trim());
58
+ }
59
+
60
+ const SPRING_MAPPING = /^(?:RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)$/;
61
+ const SPRING_DI_OR_BINDING = /^(?:RequestParam|PathVariable|RequestBody|RequestHeader|CookieValue|ModelAttribute|RequestAttribute|SessionAttribute|RequestPart|Value|Autowired)$/;
62
+
63
+ /**
64
+ * Should a Java parameter be treated as implicitly request-bound?
65
+ * @param {string[]} o.methodAnnotations annotation simple names on the method
66
+ * @param {string} o.paramType the parameter's simple type name ('String')
67
+ * @param {string[]} o.decorators annotation names already on the parameter
68
+ */
69
+ export function isImplicitSpringMappedParam({ methodAnnotations = [], paramType = '', decorators = [] }) {
70
+ if (!methodAnnotations.some((a) => SPRING_MAPPING.test(a))) return false;
71
+ if (paramType !== 'String') return false;
72
+ if (decorators.some((d) => SPRING_DI_OR_BINDING.test(d))) return false;
73
+ return true;
74
+ }
@@ -45,6 +45,7 @@
45
45
  // This is a v1. Promoted to a Roslyn-backed CST parser (analogous to
46
46
  // parser-py-cst.js) once we have a dotnet capability probe.
47
47
 
48
+ import { IMPLICIT_MVC_PARAM, isImplicitCsActionParam } from './implicit-handler-params.js';
48
49
  import * as crypto from 'node:crypto';
49
50
  import { callSitesFromCfg } from './call-sites.js';
50
51
  import { matchBalancedCall } from './balanced-call.js';
@@ -270,6 +271,16 @@ function _extractClassFields(code, range) {
270
271
  // Innermost class whose range contains `pos` (a method declaration's match
271
272
  // offset), or null when the method sits outside any class (top-level
272
273
  // statements — legal but rare).
274
+ function _enclosingClassRange(ranges, pos) {
275
+ let best = null;
276
+ for (const r of ranges) {
277
+ if (pos >= r.start && pos < r.end) {
278
+ if (!best || (r.end - r.start) < (best.end - best.start)) best = r;
279
+ }
280
+ }
281
+ return best;
282
+ }
283
+
273
284
  function _enclosingClassName(ranges, pos) {
274
285
  let best = null;
275
286
  for (const r of ranges) {
@@ -1537,7 +1548,12 @@ export function parseCSharpFile(file, code) {
1537
1548
  while ((m = METHOD_RE.exec(code)) !== null) {
1538
1549
  const name = m[2];
1539
1550
  const className = _enclosingClassName(classRanges, m.index);
1551
+ const classRange = _enclosingClassRange(classRanges, m.index);
1540
1552
  const paramsText = m[3] || '';
1553
+ // [NonAction] sits in the attribute region just before the declaration.
1554
+ // Only the attribute region of THIS member counts: cut at the previous statement or block end.
1555
+ const attrRegion = code.slice(Math.max(0, m.index - 300), m.index + 1);
1556
+ const nonAction = /\bNonAction\b/.test(attrRegion.slice(Math.max(attrRegion.lastIndexOf('}'), attrRegion.lastIndexOf(';')) + 1));
1541
1557
  const paramAnnotations = [];
1542
1558
  const paramTypes = {};
1543
1559
  // `keptIdx` tracks the parameter's position in the FILTERED array — the
@@ -1616,6 +1632,10 @@ export function parseCSharpFile(file, code) {
1616
1632
  paramAnnotations.push({ index: keptIdx, name: paramName, decorator });
1617
1633
  }
1618
1634
  if (paramType) paramTypes[paramName] = paramType;
1635
+ // QA-006: a controller action's plain string parameter is bound from the request with no attribute at all.
1636
+ if (isImplicitCsActionParam({ modifiers: m[0], rawType: nameTokens.filter(w => !/^(?:ref|out|params|in|this)$/.test(w)).join(' '), decorators, nonAction, classRange })) {
1637
+ paramAnnotations.push({ index: keptIdx, name: paramName, decorator: IMPLICIT_MVC_PARAM });
1638
+ }
1619
1639
  keptIdx++;
1620
1640
  }
1621
1641
  return paramName;
@@ -34,6 +34,10 @@ const FUNC_RE = new RegExp(
34
34
 
35
35
  function _splitStatements(body) {
36
36
  const out = [];
37
+ // Index (in `body`) of each statement's first character, so a caller can recover its real line. Blank lines and comment-only
38
+ // lines yield no statement, so counting statements undercounts lines wherever a function body has either.
39
+ const starts = [];
40
+ let bufStart = 0;
37
41
  let buf = '';
38
42
  let depth = 0;
39
43
  let inStr = null;
@@ -57,19 +61,24 @@ function _splitStatements(body) {
57
61
  if (c === '`') { inRaw = true; buf += c; continue; }
58
62
  if (c === '/' && body[i + 1] === '/') {
59
63
  while (i < body.length && body[i] !== '\n') i++;
64
+ // Leave the newline for the loop to see: swallowing it meant a comment line never ended its statement, so the next line was
65
+ // glued to whatever came before the comment and its line number was counted from the wrong place.
66
+ i--;
60
67
  continue;
61
68
  }
62
69
  if (c === '{' || c === '(' || c === '[') depth++;
63
70
  if (c === '}' || c === ')' || c === ']') depth--;
64
71
  if ((c === '\n' || c === ';') && depth === 0) {
65
72
  const t = buf.trim();
66
- if (t) out.push(t);
73
+ if (t) { out.push(t); starts.push(bufStart + (buf.length - buf.trimStart().length)); }
67
74
  buf = '';
75
+ bufStart = i + 1;
68
76
  continue;
69
77
  }
70
78
  buf += c;
71
79
  }
72
- if (buf.trim()) out.push(buf.trim());
80
+ if (buf.trim()) { out.push(buf.trim()); starts.push(bufStart + (buf.length - buf.trimStart().length)); }
81
+ out.lineOffsets = starts.map((idx) => _countNewlinesUpTo(body, idx));
73
82
  return out;
74
83
  }
75
84
 
@@ -399,17 +408,21 @@ function _link(nodes, src, dst) {
399
408
  if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
400
409
  }
401
410
 
411
+ // `startLine` is the line on which `bodyText` BEGINS (the line of its opening brace); each statement's line is that plus the
412
+ // newlines before the statement, so blank lines and comments inside the body no longer pull later statements upward.
402
413
  function _buildCfg(bodyText, nodes, prevId, startLine) {
403
414
  const stmts = _splitStatements(bodyText);
404
415
  let prev = prevId;
405
416
  let line = startLine;
406
- for (const stmt of stmts) {
417
+ for (let si = 0; si < stmts.length; si++) {
418
+ const stmt = stmts[si];
419
+ line = startLine + (stmts.lineOffsets ? stmts.lineOffsets[si] : 0);
407
420
  const s = stmt.trim();
408
421
  if (!s || s.startsWith('//')) { line++; continue; }
409
422
 
410
423
  // if statement with brace body
411
- const ifMatch = s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?\s*$/s) ||
412
- s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}\s*$/s);
424
+ const ifMatch = s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?\s*$/sd) ||
425
+ s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}\s*$/sd);
413
426
  if (ifMatch) {
414
427
  const condText = ifMatch[1].replace(/;[^;]*$/, '').trim();
415
428
  const thenBody = ifMatch[2];
@@ -417,10 +430,10 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
417
430
  const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(condText), line });
418
431
  _link(nodes, prev, ifNode);
419
432
  const join = _addNode(nodes, { kind: 'noop', line });
420
- const thenTail = _buildCfg(thenBody, nodes, ifNode, line + 1);
433
+ const thenTail = _buildCfg(thenBody, nodes, ifNode, line + _countNewlinesUpTo(s, ifMatch.indices[2][0]));
421
434
  _link(nodes, thenTail, join);
422
435
  if (elseBody) {
423
- const elseTail = _buildCfg(elseBody, nodes, ifNode, line + 1);
436
+ const elseTail = _buildCfg(elseBody, nodes, ifNode, line + _countNewlinesUpTo(s, ifMatch.indices[3][0]));
424
437
  _link(nodes, elseTail, join);
425
438
  } else {
426
439
  _link(nodes, ifNode, join);
@@ -431,7 +444,7 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
431
444
  }
432
445
 
433
446
  // for loop with brace body
434
- const forMatch = s.match(/^for\s+([\s\S]*?)\s*\{([\s\S]*)\}\s*$/s);
447
+ const forMatch = s.match(/^for\s+([\s\S]*?)\s*\{([\s\S]*)\}\s*$/sd);
435
448
  if (forMatch) {
436
449
  const header = _addNode(nodes, { kind: 'loop-header', line });
437
450
  _link(nodes, prev, header);
@@ -446,7 +459,7 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
446
459
  _link(nodes, header, assignId);
447
460
  bodyPrev = assignId;
448
461
  }
449
- const bodyTail = _buildCfg(loopBody, nodes, bodyPrev, line + 1);
462
+ const bodyTail = _buildCfg(loopBody, nodes, bodyPrev, line + _countNewlinesUpTo(s, forMatch.indices[2][0]));
450
463
  _link(nodes, bodyTail, header);
451
464
  const join = _addNode(nodes, { kind: 'noop', line });
452
465
  _link(nodes, header, join);
@@ -476,8 +489,9 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
476
489
  const callId = _addNode(nodes, { kind: 'call', line, callee: closureCall.callee, args: outerArgs });
477
490
  _link(nodes, prev, callId);
478
491
  const closureOffset = s.indexOf(closure.closureText);
479
- const bodyStartLine = closureOffset >= 0
480
- ? line + _countNewlinesUpTo(s, closureOffset)
492
+ const closureBodyIdx = closureOffset >= 0 ? s.indexOf(closure.body, closureOffset) : -1;
493
+ const bodyStartLine = closureBodyIdx >= 0
494
+ ? line + _countNewlinesUpTo(s, closureBodyIdx)
481
495
  : line;
482
496
  prev = _buildCfg(closure.body, nodes, callId, bodyStartLine);
483
497
  line += (s.match(/\n/g) || []).length + 1;
@@ -517,11 +531,14 @@ export function parseGoFile(file, code) {
517
531
  if (braceIdx < 0) continue;
518
532
  const extracted = _extractBody(code, braceIdx);
519
533
  if (!extracted) continue;
520
- const startLine = _lineAt(code, m.index);
534
+ // FUNC_RE's leading boundary is a newline and its `\s*` then swallows every blank line before `func`, so m.index can sit
535
+ // several lines ABOVE the declaration (a blank line before a function is the gofmt norm). Every line below, the function's
536
+ // own and each statement's, is counted from here, so the keyword's offset is what must be measured.
537
+ const startLine = _lineAt(code, m.index + Math.max(0, m[0].indexOf('func')));
521
538
  const nodes = {};
522
539
  const entry = _addNode(nodes, { kind: 'entry', line: startLine });
523
540
  const exit = _addNode(nodes, { kind: 'exit', line: startLine });
524
- const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
541
+ const tail = _buildCfg(extracted.body, nodes, entry, _lineAt(code, braceIdx + 1));
525
542
  _link(nodes, tail, exit);
526
543
  const cfg = { entry, exit, nodes };
527
544
  functions.push({
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { blankComments } from '../sast/_comment-strip.js';
25
25
  import { callSitesFromCfg } from './call-sites.js';
26
+ import { IMPLICIT_SPRING_PARAM, isImplicitSpringMappedParam } from './implicit-handler-params.js';
26
27
 
27
28
  let _nodeIdSeq = 0;
28
29
  function nextNodeId() { return 'jn' + (++_nodeIdSeq); }
@@ -1082,6 +1083,11 @@ export async function parseJavaFile(file, raw) {
1082
1083
  || md.children?.methodDeclarator?.[0]?.children?.formalParameterList?.[0];
1083
1084
  const paramAnnotations = [];
1084
1085
  const paramTypes = {};
1086
+ // QA-006: annotation simple names on the METHOD itself (`@GetMapping`), read from its modifiers.
1087
+ const methodAnnotations = (md.children?.methodModifier || []).map((mm) => {
1088
+ const ids = mm.children?.annotation?.[0]?.children?.typeName?.[0]?.children?.Identifier;
1089
+ return ids?.length ? ids[ids.length - 1]?.image : null;
1090
+ }).filter(Boolean);
1085
1091
  const params = (fpl?.children?.formalParameter || []).map((fp, idx) => {
1086
1092
  // Regular parameters nest under `variableParaRegularParameter`.
1087
1093
  // Varargs (`String... args`) instead use a distinct
@@ -1119,6 +1125,7 @@ export async function parseJavaFile(file, raw) {
1119
1125
  // the first, or a stacked annotation is silently dropped (the
1120
1126
  // same class of bug Task 3's C# regex needed a fix round for).
1121
1127
  const variableModifiers = vp?.children?.variableModifier || [];
1128
+ const ownDecorators = [];
1122
1129
  for (const vm of variableModifiers) {
1123
1130
  const ann = vm.children?.annotation?.[0];
1124
1131
  // `typeName.children.Identifier` is an array of ALL
@@ -1133,9 +1140,13 @@ export async function parseJavaFile(file, raw) {
1133
1140
  const identifiers = ann?.children?.typeName?.[0]?.children?.Identifier;
1134
1141
  const decoratorName = identifiers?.length ? identifiers[identifiers.length - 1]?.image : undefined;
1135
1142
  if (decoratorName && paramName) {
1143
+ ownDecorators.push(decoratorName);
1136
1144
  paramAnnotations.push({ index: idx, name: paramName, decorator: decoratorName });
1137
1145
  }
1138
1146
  }
1147
+ if (paramName && isImplicitSpringMappedParam({ methodAnnotations, paramType, decorators: ownDecorators })) {
1148
+ paramAnnotations.push({ index: idx, name: paramName, decorator: IMPLICIT_SPRING_PARAM });
1149
+ }
1139
1150
  return paramName;
1140
1151
  }).filter(Boolean);
1141
1152
  const body = md.children?.methodBody?.[0]?.children?.block?.[0];
@@ -148,6 +148,15 @@ const un=net.connect(sock);un.on('connect',()=>{r.daemonSocket='CONNECTED';un.de
148
148
  setTimeout(()=>{r.timeout=true;console.log(JSON.stringify(r));process.exit(0)},8000);
149
149
  `;
150
150
 
151
+ /** server.listen that settles: it resolves when listening and rejects on the server's error (a bare callback never fires on failure). */
152
+ function listenOrReject(server, ...args) {
153
+ return new Promise((resolve, reject) => {
154
+ const onError = (e) => reject(e);
155
+ server.once('error', onError);
156
+ server.listen(...args, () => { server.off('error', onError); resolve(); });
157
+ });
158
+ }
159
+
151
160
  /**
152
161
  * Prove the isolation of a backend with real attempts, independent of any evaluator flag. The result is
153
162
  * `verified` only if the one permitted read worked, the scratch write worked and every other attempt was refused.
@@ -160,12 +169,31 @@ export async function probeSandbox(sb, opts = {}) {
160
169
  for (const d of [allowed, scratch, outside]) mkdirSync(d);
161
170
  writeFileSync(join(allowed, 'a.txt'), 'allowed'); writeFileSync(join(outside, 'secret.txt'), 'secret');
162
171
  const script = join(base, 'probe.cjs'); writeFileSync(script, PROBE_SOURCE);
163
- const sockPath = join(base, 'daemon.sock');
172
+ let sockPath = join(base, 'daemon.sock');
173
+ let sockDir = null;
164
174
  let connections = 0;
165
175
  const tcp = netServer((s) => { connections++; s.destroy(); });
166
- const uds = netServer((s) => { connections++; s.destroy(); });
167
- await new Promise((r) => tcp.listen(0, '127.0.0.1', r));
168
- await new Promise((r) => uds.listen(sockPath, r));
176
+ let uds = netServer((s) => { connections++; s.destroy(); });
177
+ try {
178
+ await listenOrReject(tcp, 0, '127.0.0.1');
179
+ try {
180
+ await listenOrReject(uds, sockPath);
181
+ } catch (e) {
182
+ // A unix socket path is limited to about 104 bytes, and a temporary directory can easily be longer than that (macOS, CI runners).
183
+ // Without an error handler the listen call never completed and the whole evaluation hung. Retry in a short directory; the probe
184
+ // proves that the sandboxed process is refused, which does not depend on where the socket lives.
185
+ if (!['EINVAL', 'ENAMETOOLONG'].includes(e && e.code) || !existsSync('/tmp')) throw e;
186
+ uds.close();
187
+ uds = netServer((s) => { connections++; s.destroy(); });
188
+ sockDir = mkdtempSync('/tmp/nep-');
189
+ sockPath = join(sockDir, 'd.sock');
190
+ await listenOrReject(uds, sockPath);
191
+ }
192
+ } catch (e) {
193
+ tcp.close(); uds.close(); rmSync(base, { recursive: true, force: true });
194
+ if (sockDir) rmSync(sockDir, { recursive: true, force: true });
195
+ return { verified: false, backend: sb.backend, reason: `the probe could not create its listeners (${(e && e.code) || 'error'}): isolation is not verified`, attempts: {} };
196
+ }
169
197
  const port = tcp.address().port;
170
198
  try {
171
199
  const exe = opts.nodePath || process.execPath;
@@ -186,7 +214,7 @@ export async function probeSandbox(sb, opts = {}) {
186
214
  if (attempts.credentialEnv && attempts.credentialEnv !== 'absent') insufficient.push('a credential environment variable was visible');
187
215
  if (attempts.timeout) insufficient.push('the probe timed out');
188
216
  return { verified: insufficient.length === 0 && connections === 0, backend: sb.backend, reason: insufficient.length ? insufficient.join('; ') : (connections ? 'the probe listeners saw a connection' : null), attempts, listenerConnections: connections, insufficient };
189
- } finally { tcp.close(); uds.close(); rmSync(base, { recursive: true, force: true }); }
217
+ } finally { tcp.close(); uds.close(); rmSync(base, { recursive: true, force: true }); if (sockDir) rmSync(sockDir, { recursive: true, force: true }); }
190
218
  }
191
219
 
192
220
  // ── 3. evaluator feature detection ───────────────────────────────────────────
@@ -1182,6 +1182,37 @@ established operator-config trust model this whole package uses
1182
1182
  everywhere else (a hand-edited config file is trusted as declared, the
1183
1183
  way `recipient-profiles.json`/`privacy-policy.json` already are).
1184
1184
 
1185
+ ## Deployment boundary graph (differentiation PRD, X-301 to X-308)
1186
+
1187
+ `deployment/` is a SEPARATE graph from `DataFlowGraph v1`: it answers "what can reach what, as deployed" (repositories,
1188
+ services, routes, identities, tenants, resources, environments), not "where does a data element flow". It shares no mutable
1189
+ state with the taint or vulnerability engines. It imports only pure helpers (the assurance schema kit, canonical
1190
+ serialization and content hashing from `posture/assurance/`) and three assurance enums (provenance, path state,
1191
+ observation completeness), so the verification layer and this graph speak one vocabulary. It is gated by the
1192
+ `deployment-boundaries` feature (off by default); only the two file-reading entry points are gated, everything else is a
1193
+ pure function of text. Suite: `npm run test:deployment`, with fixtures under `test/deployment/fixtures/`.
1194
+
1195
+ | Module | Responsibility |
1196
+ |---|---|
1197
+ | `deployment/boundary-graph.js` | X-301. The versioned, closed-world contract. Seven node kinds, a relation table giving each relation its legal endpoint kinds, the trust boundary it crosses and its meaning (`RELATION_SEMANTICS`), and `validateBoundaryGraph` (ids are recomputed, a tampered digest or id is refused). **Three provenance types are separate**: `static-config`, `runtime-observation`, `inferred` (part of an edge's id, so a configured and an observed edge between the same nodes are two edges). A path is `possible`, `blocked`, `unresolved` or `runtime-supported`; `blocked` requires an explicit deny and a deny is always blocked; absence never blocks. The edge completeness enum has no `complete` value, so no producer can assert complete traffic coverage. Node ids hash (kind, name, environment, tenant), so two environments or tenants are two nodes; the owning repository is NOT in the id, so a reference and its declaration coincide and two repositories claiming one identity collide detectably. `buildBoundaryGraph` normalizes, merges identical declarations, records conflicts as gaps (conflicting allow becomes `unresolved`, deny kept) and never throws; `exportBoundaryGraph`/`importBoundaryGraph` round-trip byte-identically; `findPath` is a bounded, cycle-safe shortest path (shared with the correlation and cross-repository code, and the base for X-305). |
1198
+ | `deployment/adapter-kit.js` | Shared adapter context. Every static edge carries `{file, digest, parser, parserVersion}` and the revision; every declared node carries `declaredBy` file references; an unresolved reference becomes an unresolved node plus a typed gap. YAML is read with the scanner's safe loader (unknown tags refused), more than 100 aliases is refused, JSON with `JSON.parse`; nothing is evaluated. |
1199
+ | `deployment/adapter-kubernetes.js`, `adapter-compose.js`, `adapter-cloud.js` | X-302 adapters: Kubernetes (workloads, Service, Ingress, Gateway API HTTPRoute, ServiceAccount, Role/ClusterRole bindings, NetworkPolicy), compose files, Terraform plan JSON (`terraform show -json`, with `configuration` references used to join resources and `after_unknown` honoured) and standalone IAM policy documents. Secrets, ConfigMaps, environment values, env files and images are never read. A selector that matches nothing, a missing ServiceAccount/Role/Service, a value known only after apply, `NotAction`/`NotResource`, a YAML merge key and unknown resource types are typed gaps, never edges. A `<<` merge key is reported, not expanded. |
1200
+ | `deployment/ingest.js` | `ingestDeploymentConfig` (pure: text in, graph out; an adapter that throws is contained to that file), `ingestDeploymentFiles` (reads named files under one root, refusing traversal, absolute paths, symlinks and oversize files; feature gated), `detectFormat` (HCL, proxy configuration and TOML/XML are typed unsupported syntax) and `invalidateChangedSources` (drops the relations a changed or removed file supported, using the recorded digests; a node only that file declared stops being resolved or is dropped). |
1201
+ | `deployment/trace-correlation.js` | X-303. `importTraceLines` keeps an allowlist of topology metadata and removes every other field WITHOUT storing it, even its name (only counts, split credential-shaped, payload-shaped and other); strips query strings; turns opaque path segments into `:id`; rejects a record whose kept value is not identifier-shaped; refuses other-environment records instead of relabelling them; merges duplicates; and is bounded (bytes, line length, lines, distinct observations with oldest-first eviction reported). `correlateObservations` matches with numbered rules R1 to R4 (exact service pair, route chain, identity, naming convention); R4 yields an `inferred` edge, R1 to R3 a `runtime-observation` edge, and static edges are never edited. `coverage.trafficCoverage` is always `not-established`; observed, sampled, stale and missing are separate lists; a static edge with no observation stays `possible`. `applyCorrelation` folds the result into a new validated graph. It does NOT persist anything: callers store `exportObservationSet` output and read it back with `parseObservationSet` (closed world, bounded, digest-checked). This sits beside, not on top of, the existing data-flow `runtime-observation.js` records, whose graph-id oriented contract is unchanged; only `EVENT_COUNT_BANDS` is shared. |
1202
+ | `deployment/cross-repo.js` | X-304. `resolveCrossRepoBoundaries` takes graph exports the operator supplies, declared dependencies (each binding an exact 40/64 hex revision for both ends) and an access scope; it has no network or file access (a test pins its import list). A link resolves only when both repositories are in scope and supplied, each graph validates and names the repository it was supplied as, the graph revision equals the bound revision, the declared service exists resolved in its repository and in no other supplied repository, and a declared entry route reaches it. Every failure is a typed gap (`access-denied`, `repository-unavailable`, `version-mismatch`, `revision-unknown`, `identity-collision`, `unresolved-identity`, `graph-integrity`, `malformed-input`) and the dependency is kept as an unresolved edge to an unresolved callee. A resolved link carries evidence (declaration file and digest, both graph digests, the declaring files on each side, the route chain) and transitions (entry route, caller identities, callee identity and its grants, boundary crossings). It extends the idea of `cross-repo-link.js` to this graph; that module and `federation-loader.js` are unchanged. |
1203
+ | `deployment/attack-paths.js` | X-305. `computeAttackPaths` walks bounded, cycle-safe simple paths from entry points (resolved public routes and services) to targets (resources an identity is granted, or in the privileged zone) over `routes-to`, `calls`, `depends-on`, `network-allows`, `assumes` and `grants`. A hop is `blocked` only when an explicit deny edge sits between its endpoints; a path is `blocked` if any hop is, `unresolved` if a hop or endpoint is unresolved or a tenant transition makes it CONDITIONAL (a hop whose endpoints carry different tenants, listed as a `tenant-binding` condition), `runtime-supported` only if every network hop was observed (identity hops stay configuration), else `possible`. Each path lists supporting edges (with source file, digest, revision), assumptions, blockers, environments, boundary crossings and a coverage block (observed, sampled, stale, unobserved hops; `trafficCoverage` is always `not-established`; staleness is judged only when the caller passes `now`). Path ids hash the edge ids along the path, not the state, so they survive reordering and state changes. `bindFindingToService` ties a finding to a service ONLY through an explicit path-prefix binding (longest prefix wins; a tie, an absent service or no binding is a typed non-bound status and nothing is guessed). `analyzeFindingBoundaries` builds the per-finding `boundary-context` (exposure paths to the bound service, impact paths onward, verification assessments, uncovered boundaries, warnings). **Exploitability is never derived from reachability**: `assessVerification` accepts a record only if it validates, its outcome is `confirmed`, its confirmation level RECOMPUTED from evidence is `runtime-confirmed`, and its oracle is the one the finding's non-taint class names (an unsupported class has none); the label is `exploitable-confirmed` only then, and a blocked or unresolved path keeps that fact in an `oracle-confirmed-path-*` label. `exposureReach` is the path-free reachability the drift diff compares. |
1204
+ | `deployment/drift.js` | X-306. `diffBoundaryGraphs` compares two validated graphs of one repository at stable ids: new exposure (newly reachable from an entry, by reachability not by edge), changed privilege (new or changed grant, new identity assumption), tenant weakening (a dropped tenant binding, found by (kind, name, environment) because the tenant is part of the node id, or a new cross-tenant edge) and removed controls (a removed deny) are `material`; other relation changes are reported but not material. It also diffs the SOURCES and names the edges each moved file supported. `planRescan` maps changes to hypotheses through the bound service: a hypothesis is affected when its service is a seed of a change or can reach or be reached from one (union of both revisions, bounded closure); everything else is `skipped` with a reason and NOT rescanned. A hypothesis that cannot be tied to a service cannot be proven independent, so it makes the plan `incomplete` (only when something changed), as does a graph gap that stands for a missing dependency (`DEPENDENCY_GAP_CODES`). `incrementalAnnotate` recomputes only the planned hypotheses; `compareScoped` checks the result against a complete recomputation on `scopedSignature` (which leaves out graph-wide metadata) and never reports an incomplete result as equivalent. `invalidatePathsForSources` reuses `invalidateChangedSources` and drops exactly the paths whose supporting edges or node declarations cite a changed file. |
1205
+ | `deployment/projection.js` | X-307. The ONE projection of a boundary context, modelled on `posture/verification/projection.js`: `projectBoundaryContext` (closed-world validation first: an unknown field, a credential-shaped or payload-shaped string, a control character or an over-long string refuses the whole context), `filterBoundaryView` (service, repository, environment, tenant; keeps scope, uncovered boundaries and every earlier warning and adds a warning saying how many paths it hid), `boundaryFields` (the additive report fields; the context itself is echoed only when it validated), `boundaryCoverage` (run accounting) and `attachBoundaryContext`, the ONLY producer, gated by the `deployment-boundaries` feature (off returns the same findings array untouched). The text states what was and was not established, never says safe, protected or unreachable (a lint-style test sweeps every state), and a context with no path says "not shown to exist". Consumed by `report/index.js` (`normalizeFindings` adds `boundaryContext` and `boundaryView` only when a finding carries a context, a top-level `boundaryCoverage`, a Markdown and a terminal block) and by MCP `explain_finding`. Previous output schemas are pinned in `test/fixtures/boundary-compat/` (captured before the wiring). |
1206
+ | `deployment/boundaries-run.js` | X-308 wiring. The one operator entry point: `agentic-security boundaries --from <dir> [--findings <last-scan.json>] [--environment] [--repository] [--revision] [--root] [--out] [--json] [--now]`. `runBoundaries` (gated by `deployment-boundaries`, off by default; off returns a typed `disabled` BEFORE any file is opened) lists configuration files under the directory (bounded walk, symlinks never followed or listed), reads the reserved inputs `service-bindings.json`, `identities.json` and `traces.jsonl`/`traces.ndjson` (never ingested as configuration), ingests through `ingestDeploymentFiles`, correlates sanitized traces, reads findings from a scan result (a sibling `.sig` that exists must verify, else exit 1; an absent one is read and labelled unsigned) or from memory, and attaches contexts with `attachBoundaryContext`. Local files only, no scan, no execution; the only write is `--out`, refused when the target is a symbolic link. Exit codes: 0 report, 1 failed, 2 usage, 3 feature off, blocked or unsupported. `renderBoundariesText` reuses each context's own projected text. Suite: `test/deployment/boundaries-cli.test.js`. |
1207
+
1208
+ **Disclosed limits.** Kubernetes `matchExpressions`, namespace-only selectors and ExternalName targets are not evaluated
1209
+ (reported as gaps). Terraform modules other than the root are joined only through the plan's flat `resource_changes`.
1210
+ IAM conditions are not interpreted (a conditioned statement is recorded at low confidence with `conditioned: true`).
1211
+ The digest of an ingested file is over the UTF-8 text that was parsed. Observation persistence is not wired. The presentation (X-307) is consumed by the report and MCP `explain_finding` only when a finding carries a
1212
+ `boundaryContext`; nothing in the default scan produces one. `attachBoundaryContext` is a feature-gated helper; its one operator entry point is
1213
+ `agentic-security boundaries` (`boundaries-run.js`, explicit, read-only, off by default), so default output is unchanged. The boundary graph attribute grammar admits credential-shaped values such as `password=x` (it only
1214
+ restricts characters); the projection refuses them, but the graph contract itself does not.
1215
+
1185
1216
  ## Conventions
1186
1217
 
1187
1218
  - Every enum here is a single source of truth for its concept. If you add