@clear-capabilities/agentic-security-scanner 0.161.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 (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  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 +59 -8
  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 +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
@@ -0,0 +1,352 @@
1
+ // Does a guard actually DOMINATE the sink it is said to protect? (QA-005.AC02)
2
+ //
3
+ // `dropGuardedFindings` and the safe-sink-shape check recognise a guard by shape inside a window of source lines
4
+ // around the sink, then correlate on a shared identifier. That answers "is there guard-looking text near this sink",
5
+ // which is not the question that justifies dropping a finding: "is every path that reaches the sink forced through
6
+ // this guard". Text can be near a sink without protecting it:
7
+ // - a guard written AFTER the sink (the window reaches several lines below it);
8
+ // - a guard in a DIFFERENT function that happens to sit above;
9
+ // - a guard inside a block that has already closed (`if (dev) { check(x); } fetch(x)`);
10
+ // - an `if (allowed(x)) { log() }` whose failing branch does not stop anything, followed by an unconditional sink;
11
+ // - a membership test whose boolean result is computed and never branched on.
12
+ // This module decides that, over the same source lines, with no new dependency. It is deliberately conservative toward
13
+ // KEEPING a finding: when it cannot establish dominance it says so, and the caller leaves the finding in place. The
14
+ // cost of being wrong in that direction is a reviewable false positive; the other direction deletes a real defect.
15
+ //
16
+ // Pure: lines in, verdict out. It reads no file name beyond an extension, no label and no answer key, and works on text alone.
17
+ // It is NOT a control-flow graph: it recognises the structured shapes real handlers use (straight-line statements,
18
+ // early-exit checks, a sink nested in the guarded branch) and reports `unknown-shape` for anything else it cannot
19
+ // place, which counts as "not proven".
20
+
21
+ const TERMINATOR = /\b(?:return|throw|raise|continue|break|exit|abort|die|panic|halt|fail|next)\b|\b(?:sys\.exit|os\._exit|process\.exit|http\.Error|t\.Fatal|log\.Fatal)\b|\.\s*(?:status|sendStatus|abort|AbortWithStatus|AbortWithStatusJSON|Error)\s*\(/;
22
+ const CONDITIONAL_HEAD = /^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard|switch)\b/;
23
+ const MODIFIER_EXIT = /^\s*(?:return|raise|throw|next|break|continue|abort|halt|fail|die)\b[^\n]*\b(?:if|unless)\b|\b(?:or|\|\|)\s+(?:raise|die|throw|return|abort|exit)\b/;
24
+ // A function or method DECLARATION line, across the languages this engine reads. A guard above one is in another function.
25
+ const NOT_A_DECL_HEAD = /^\s*(?:return|await|new|throw|else|yield|typeof|delete|case|if|for|while|switch|catch|try|do|elif|elsif|unless|until|when|guard|print|echo)\b/;
26
+ const MODIFIER = /^(?:public|private|protected|internal|static|final|abstract|override|async|export|default|virtual|sealed|extern|pub|suspend)\s+/;
27
+ const ANNOTATION = /^@\w+(?:\([^)]*\))?\s*/;
28
+ const DECL_START = /^(?:function\s*\*?\s*\w+\s*\(|def\s+\w+|fn\s+\w+|fun\s+\w+|func\s+(?:\([^)]*\)\s*)?\w+\s*\(|sub\s+\w+)/;
29
+ // A typed method header (`public String find(String q) throws E {`) or a bare class-method shorthand (`handle(req, res) {`): the head before the
30
+ // parenthesis is a few words, the opening brace ends the line. Written without nested quantifiers and capped in length, because these run per line.
31
+ const BRACE_METHOD = /^([^(){};=]{0,120}?)\(([^;)]{0,300})\)\s*(?:throws\s+[\w.,\s]{1,120})?\s*\{\s*$/;
32
+ const MAX_DECL_LINE = 400;
33
+ function isFunctionBoundary(line) {
34
+ if (line.length > MAX_DECL_LINE || NOT_A_DECL_HEAD.test(line)) return false;
35
+ // peel annotations and modifiers one at a time (a loop, not a repeated group)
36
+ let rest = line.trimStart();
37
+ for (let n = 0; n < 8; n++) { const m = ANNOTATION.exec(rest); if (!m) break; rest = rest.slice(m[0].length); }
38
+ for (let n = 0; n < 8; n++) { const m = MODIFIER.exec(rest); if (!m) break; rest = rest.slice(m[0].length); }
39
+ if (DECL_START.test(rest)) return true;
40
+ const m = BRACE_METHOD.exec(line);
41
+ if (!m) return false;
42
+ const words = m[1].trim().split(/\s+/).filter(Boolean);
43
+ // the last word is the method name; any earlier words are modifiers or a return type
44
+ return words.length >= 1 && words.length <= 6 && /^\w+$/.test(words[words.length - 1]) && !/^(?:if|for|while|switch|catch|else|function|return)$/.test(words[words.length - 1]);
45
+ }
46
+ // A pure DATA declaration: a name bound to a literal, a collection or a constructor (`const ALLOWED = new Set([...])`, `DENY = ["a"]`,
47
+ // `static final Set<String> ALLOW = Set.of(...)`, `var allowed = map[string]bool{...}`). It defines the allow-list; it does not CHECK anything
48
+ // against it, so it cannot guard a sink however guard-shaped its name is.
49
+ const DATA_DECL = /^\s*(?:(?:export|public|private|protected|internal|static|final|readonly|const|let|var|val|def|pub|mut)\s+)*(?:[\w$.<>\[\],?:&* ]+?\s+)?[A-Za-z_$][\w$]*\s*(?::[^=\n]+)?(?::=|=)\s*(?:new\b|\[|\{|%[wi]|Set\.of|List\.of|Map\.of|Arrays\.asList|listOf|setOf|mapOf|hashSetOf|set\(|frozenset\(|list\(|dict\(|tuple\(|vec!|HashSet|map\[|[A-Z]\w*\{|["'`])(?!.*(?:=>|->|\bfunction\b))/;
50
+ // An assignment whose value is a membership/prefix/equality TEST: a boolean, which protects nothing until something branches on it.
51
+ const BOOLEAN_ASSIGN = /^\s*(?:(?:const|let|var|val|final|bool|boolean|auto)\s+)?([A-Za-z_$][\w$]*)\s*(?::=|=)\s*(?!.*\b(?:basename|abspath|realpath|resolve|normalize|secure_filename|safe_join|sanitize\w*|escape\w*)\b)[^=\n]*(?:\.\s*(?:has|includes|contains|startsWith|startswith|StartsWith|endsWith|HasPrefix|starts_with|match|test|matches)\s*\(|\bin\s|===|!==|==|!=)/;
52
+
53
+ /** Source line with string literals and trailing line comments blanked, so brace counting is not fooled by either. */
54
+ function code(line) {
55
+ return String(line ?? '')
56
+ .replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, '""')
57
+ .replace(/\/\/.*$/, '')
58
+ .replace(/(^|\s)#.*$/, '$1');
59
+ }
60
+
61
+ const indentOf = (line) => { const m = /^[ \t]*/.exec(line)[0]; return m.replace(/\t/g, ' ').length; };
62
+ const countBraces = (s) => ({ open: (s.match(/\{/g) || []).length, close: (s.match(/\}/g) || []).length });
63
+ const blank = (s) => !/\S/.test(s);
64
+
65
+ // Block style of the file: indentation (Python, Ruby) or braces. The extension decides when known; otherwise the whole file votes, never just
66
+ // the few lines between guard and sink (two brace-free lines of JavaScript are not an indentation language).
67
+ function looksIndented(lines, file) {
68
+ if (/\.(?:py|pyi|rb|rake|ru)$/i.test(file || '')) return true;
69
+ if (/\.[A-Za-z0-9]+$/.test(file || '')) return false;
70
+ let brace = 0; let colon = 0;
71
+ for (const l of lines) {
72
+ const c = code(l);
73
+ if (/[{}]\s*$/.test(c)) brace++;
74
+ else if (/^\s*(?:def|if|elif|else|for|while|class|try|except|finally|with)\b.*:\s*$/.test(c)) colon++;
75
+ }
76
+ return colon > brace;
77
+ }
78
+
79
+ function result(dominates, reason) { return { dominates, reason }; }
80
+
81
+ /**
82
+ * @param {object} o
83
+ * @param {string[]} o.lines the file's source lines (original text)
84
+ * @param {number} o.guardIdx 0-based index of the line the guard-shaped text matched on
85
+ * @param {number} o.sinkIdx 0-based index of the sink line
86
+ * @param {string} [o.file] path, used ONLY to pick the block style (indentation vs braces) from its extension
87
+ * @returns {{dominates: boolean, reason: string}}
88
+ */
89
+ export function guardDominatesSink({ lines, guardIdx, sinkIdx, file = null, depth = 0 }) {
90
+ if (!Array.isArray(lines) || !Number.isInteger(guardIdx) || !Number.isInteger(sinkIdx)) return result(false, 'unknown-shape');
91
+ if (guardIdx < 0 || sinkIdx < 0 || guardIdx >= lines.length || sinkIdx >= lines.length) return result(false, 'unknown-shape');
92
+ if (guardIdx > sinkIdx) return result(false, 'guard-after-sink');
93
+
94
+ const indentStyle = looksIndented(lines, file);
95
+ // Which named function holds the guard? A guard in a function that has ENDED before the sink protects the sink only through a call to
96
+ // that function (a validator helper); a guard at module level does not reach into a function declared after it.
97
+ const fn = enclosingFunction(lines, guardIdx, indentStyle);
98
+ if (fn && sinkIdx > fn.endIdx) return calleeGuardDominates({ lines, guardIdx, sinkIdx, fn, file, depth });
99
+ if (!fn) {
100
+ for (let i = guardIdx + 1; i <= sinkIdx; i++) if (isFunctionBoundary(code(lines[i]))) return result(false, 'different-function');
101
+ }
102
+ const gl = code(lines[guardIdx]);
103
+
104
+ // Same line: `if (!ok(x)) return; fetch(x)` or `x = safe(x); use(x)`. Order within the line is not tracked, but a guard that
105
+ // shares the sink's line is positioned by the author as part of the same statement run.
106
+ if (guardIdx === sinkIdx) return result(true, 'same-line');
107
+
108
+ // A line that only DEFINES the allow-list is not a check.
109
+ if (DATA_DECL.test(gl) && !/\.\s*(?:has|includes|contains|startsWith|startswith|StartsWith)\s*\(/.test(gl)) return result(false, 'data-declaration-not-a-check');
110
+
111
+ // A membership/equality result that is computed and never branched on protects nothing.
112
+ const boolAssign = BOOLEAN_ASSIGN.exec(gl);
113
+ if (boolAssign && !CONDITIONAL_HEAD.test(gl)) {
114
+ const v = boolAssign[1].replace(/[$]/g, '\\$');
115
+ const used = new RegExp(`(?:\\b(?:if|unless|while|assert|require|ensure|guard)\\b|&&|\\|\\||\\?|\\bnot\\b|!)[^\\n]*\\b${v}\\b|\\b${v}\\b[^\\n]*(?:&&|\\|\\||\\?)`);
116
+ let branched = false;
117
+ for (let i = guardIdx + 1; i <= sinkIdx && !branched; i++) branched = used.test(code(lines[i]));
118
+ if (!branched) return result(false, 'result-never-branched-on');
119
+ }
120
+
121
+ if (indentStyle) return indentDominates(lines, guardIdx, sinkIdx, gl);
122
+ return braceDominates(lines, guardIdx, sinkIdx, gl);
123
+ }
124
+
125
+ function declaredName(declLine) {
126
+ const c = code(declLine);
127
+ const kw = /(?:function\s*\*?\s*|def\s+|fn\s+|fun\s+|func\s+(?:\([^)]*\)\s*)?|sub\s+)([A-Za-z_$][\w$]*)/.exec(c);
128
+ if (kw) return kw[1];
129
+ const typed = /([A-Za-z_$][\w$]*)\s*\(/.exec(c);
130
+ return typed ? typed[1] : null;
131
+ }
132
+
133
+ /** The named function that contains line `idx`, as `{ declIdx, endIdx }`, or null at module level. */
134
+ function enclosingFunction(lines, idx, indentStyle) {
135
+ if (indentStyle) return enclosingFunctionByIndent(lines, idx, indentOf(lines[idx]));
136
+ let need = 0;
137
+ for (let i = idx - 1; i >= 0; i--) {
138
+ const c = code(lines[i]);
139
+ for (let k = c.length - 1; k >= 0; k--) {
140
+ if (c[k] === '}') need++;
141
+ else if (c[k] === '{') {
142
+ if (need > 0) { need--; continue; }
143
+ const head = isFunctionBoundary(c) ? i : (/^\s*\{\s*$/.test(c) && i > 0 && isFunctionBoundary(code(lines[i - 1])) ? i - 1 : -1);
144
+ if (head >= 0) {
145
+ let depth = 0; let end = lines.length - 1;
146
+ scan: for (let m = i; m < lines.length; m++) {
147
+ const t = code(lines[m]);
148
+ for (const ch of t) { if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { end = m; break scan; } } }
149
+ }
150
+ return { declIdx: head, endIdx: end };
151
+ }
152
+ }
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+
158
+ function enclosingFunctionByIndent(lines, idx, ind) {
159
+ for (let i = idx - 1; i >= 0; i--) {
160
+ if (blank(lines[i])) continue;
161
+ const cur = indentOf(lines[i]);
162
+ if (cur >= ind) continue;
163
+ if (isFunctionBoundary(code(lines[i]))) {
164
+ let end = i;
165
+ for (let k = i + 1; k < lines.length; k++) { if (blank(lines[k])) continue; if (indentOf(lines[k]) <= cur) break; end = k; }
166
+ return { declIdx: i, endIdx: end };
167
+ }
168
+ ind = cur;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /** The guard is in a helper function that ended before the sink. It protects the sink only if the sink's code calls that helper first and the guard cannot be skipped inside it. */
174
+ function calleeGuardDominates({ lines, guardIdx, sinkIdx, fn, file, depth }) {
175
+ if (depth >= 3) return result(false, 'call-chain-too-deep');
176
+ const name = declaredName(lines[fn.declIdx]);
177
+ if (!name) return result(false, 'different-function');
178
+ const call = new RegExp(`\\b${name.replace(/[$]/g, '\\$')}\\s*\\(`);
179
+ let callIdx = -1;
180
+ for (let i = fn.endIdx + 1; i <= sinkIdx; i++) { if (call.test(code(lines[i])) && !isFunctionBoundary(code(lines[i]))) { callIdx = i; break; } }
181
+ if (callIdx < 0) return result(false, 'different-function');
182
+ // 1. inside the helper the guard must be on every path to the helper's exit: at the function's own level, and either a straight-line
183
+ // statement or a check whose failing branch leaves. (The helper's exit is not a "sink", so this is judged on its own.)
184
+ const forced = guardForcedBeforeExit(lines, guardIdx, fn, looksIndented(lines, file));
185
+ if (!forced.ok) return result(false, `callee-guard-not-forced:${forced.reason}`);
186
+ // 2. in the sink's code the call must itself run before the sink
187
+ const outside = guardDominatesSink({ lines, guardIdx: callIdx, sinkIdx, file, depth: depth + 1 });
188
+ if (!outside.dominates) return result(false, `callee-not-forced-before-sink:${outside.reason}`);
189
+ return result(true, 'validator-helper-called-before-sink');
190
+ }
191
+
192
+ /** Is the guard on every path to the end of its own function? */
193
+ function guardForcedBeforeExit(lines, guardIdx, fn, indentStyle) {
194
+ const gl = code(lines[guardIdx]);
195
+ if (DATA_DECL.test(gl)) return { ok: false, reason: 'data-declaration-not-a-check' };
196
+ // The guard must sit at the function body's own nesting level, not inside an `if`, loop or `try` of its own.
197
+ if (indentStyle) {
198
+ let bodyIndent = -1;
199
+ for (let i = fn.declIdx + 1; i <= fn.endIdx; i++) if (!blank(lines[i])) { bodyIndent = indentOf(lines[i]); break; }
200
+ if (indentOf(lines[guardIdx]) !== bodyIndent) return { ok: false, reason: 'guard-nested-in-helper' };
201
+ } else {
202
+ let depth = 0; let opened = false;
203
+ for (let i = fn.declIdx; i < guardIdx; i++) {
204
+ for (const ch of code(lines[i])) { if (ch === '{') { depth++; opened = true; } else if (ch === '}') depth--; }
205
+ }
206
+ if (!opened || depth !== 1) return { ok: false, reason: 'guard-nested-in-helper' };
207
+ }
208
+ const conditional = CONDITIONAL_HEAD.test(gl);
209
+ if (!conditional) return { ok: true, reason: 'straight-line-in-helper' };
210
+ if (branchExits(lines, guardIdx, indentStyle)) return { ok: true, reason: 'failing-branch-exits' };
211
+ return { ok: false, reason: 'failing-branch-does-not-exit' };
212
+ }
213
+
214
+ const LITERAL_ASSIGN = /^\s*(?:[\w$.]+\s+)?([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\s*=\s*(?:'[^']*'|"[^"]*"|`[^`]*`|""|null|None|nil|undefined|false|0)\s*;?\s*$/;
215
+ const identsOf = (text) => new Set((String(text).match(/[A-Za-z_$][\w$]*/g) || []).map((x) => x.replace(/^\$/, '')));
216
+
217
+ /**
218
+ * Does the failing branch REPLACE the value the condition checked with a literal (`if (bad($next)) { $next = "/"; }`)? Then no path reaches
219
+ * the sink carrying the unchecked value, which is what a guard is for. The assigned name must be one the condition names.
220
+ */
221
+ function branchNeutralizes(lines, guardIdx, indentStyle) {
222
+ const gl = code(lines[guardIdx]);
223
+ const checked = identsOf(gl.replace(/^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard)\b/, ''));
224
+ const bodyIdx = [];
225
+ if (indentStyle) {
226
+ const gi = indentOf(lines[guardIdx]);
227
+ for (let i = guardIdx + 1; i < lines.length; i++) { if (blank(lines[i])) continue; if (indentOf(lines[i]) <= gi) break; bodyIdx.push(i); }
228
+ } else {
229
+ const block = branchRange(lines, guardIdx);
230
+ if (block) for (let i = block.start; i <= block.end; i++) bodyIdx.push(i);
231
+ }
232
+ for (const i of bodyIdx) {
233
+ const stmt = code(lines[i]).replace(/^[^{]*\{/, '').replace(/\}\s*$/, '');
234
+ for (const part of stmt.split(';')) {
235
+ const m = LITERAL_ASSIGN.exec(part.trim());
236
+ if (m && checked.has(m[1].replace(/^\$/, '').split('.')[0])) return true;
237
+ }
238
+ }
239
+ return false;
240
+ }
241
+
242
+ /** Does the branch a conditional guard opens leave the function (throw, return, exit, abort)? Polarity is not judged. */
243
+ function branchExits(lines, guardIdx, indentStyle) {
244
+ if (branchNeutralizes(lines, guardIdx, indentStyle)) return true;
245
+ const gl = code(lines[guardIdx]);
246
+ if (indentStyle) {
247
+ if (MODIFIER_EXIT.test(gl)) return true;
248
+ const gi = indentOf(lines[guardIdx]);
249
+ if (/:\s*\S/.test(gl) && TERMINATOR.test(gl.slice(gl.lastIndexOf(':') + 1))) return true;
250
+ const body = [];
251
+ for (let i = guardIdx + 1; i < lines.length; i++) { if (blank(lines[i])) continue; if (indentOf(lines[i]) <= gi) break; body.push(i); }
252
+ const inner = body.length ? indentOf(lines[body[0]]) : -1;
253
+ return body.some((i) => indentOf(lines[i]) === inner && TERMINATOR.test(code(lines[i])));
254
+ }
255
+ const block = branchRange(lines, guardIdx);
256
+ const afterCond = gl.replace(/^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard)\b/, '');
257
+ if (!block || block.start === guardIdx) {
258
+ if (TERMINATOR.test(afterCond.replace(/^\s*\([^)]*\)/, '')) || /\breturn\b|\bthrow\b/.test(afterCond)) return true;
259
+ }
260
+ return !!(block && branchTerminates(lines, block));
261
+ }
262
+
263
+ function braceDominates(lines, guardIdx, sinkIdx, gl) {
264
+ // Depth at the START of each line, relative to the guard line's own start.
265
+ const depthStart = new Array(sinkIdx - guardIdx + 2).fill(0);
266
+ let depth = 0;
267
+ for (let i = guardIdx; i <= sinkIdx; i++) {
268
+ depthStart[i - guardIdx] = depth;
269
+ const c = code(lines[i]);
270
+ const { open, close } = countBraces(c);
271
+ depth += open - close;
272
+ }
273
+ // The guard's enclosing block must still be open at the sink: nothing between may dip below the guard's own level.
274
+ for (let i = guardIdx + 1; i <= sinkIdx; i++) {
275
+ if (depthStart[i - guardIdx] < 0) return result(false, 'guard-block-closed-before-sink');
276
+ }
277
+
278
+ if (!CONDITIONAL_HEAD.test(gl) && !/\?[^:]*:/.test(gl)) return result(true, 'straight-line-before-sink');
279
+ if (/\?[^:]*:/.test(gl) && !CONDITIONAL_HEAD.test(gl)) return result(false, 'unknown-shape');
280
+
281
+ // A conditional guard. Case 1: the sink sits INSIDE the branch the condition opens.
282
+ const block = branchRange(lines, guardIdx);
283
+ if (block && sinkIdx > block.start && sinkIdx <= block.end) return result(true, 'sink-inside-guarded-branch');
284
+ // Case 2: the condition guards an early exit and the sink comes after it.
285
+ const afterCond = gl.replace(/^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard)\b/, '');
286
+ if (!block || block.start === guardIdx) {
287
+ // Single-line form: `if (!allowed(h)) return;` : an exit on the same line, after the condition.
288
+ if (TERMINATOR.test(afterCond.replace(/^\s*\([^)]*\)/, '')) || /\breturn\b|\bthrow\b/.test(afterCond)) return result(true, 'early-exit-on-guard-line');
289
+ }
290
+ if (block && branchTerminates(lines, block)) return result(true, 'failing-branch-exits');
291
+ if (branchNeutralizes(lines, guardIdx, false)) return result(true, 'failing-branch-neutralizes-value');
292
+ return result(false, 'failing-branch-does-not-exit');
293
+ }
294
+
295
+ /** [start, end] line indices of the `{ ... }` block opened at or after `idx`, or null. */
296
+ function branchRange(lines, idx) {
297
+ let depth = 0; let start = -1;
298
+ for (let i = idx; i < Math.min(lines.length, idx + 40); i++) {
299
+ const c = code(lines[i]);
300
+ for (const ch of c) {
301
+ if (ch === '{') { if (start < 0) start = i; depth++; }
302
+ else if (ch === '}') { depth--; if (start >= 0 && depth === 0) return { start, end: i }; }
303
+ }
304
+ // A brace-less single statement ends the search at the next line that is not part of the condition.
305
+ if (start < 0 && i > idx && !/[(&|,]\s*$/.test(code(lines[i - 1]))) return null;
306
+ }
307
+ return null;
308
+ }
309
+
310
+ function branchTerminates(lines, block) {
311
+ let depth = 0;
312
+ for (let i = block.start; i <= block.end; i++) {
313
+ const c = code(lines[i]);
314
+ // Only a statement at the branch's own top level counts: a `return` buried in a nested `if` inside it does not stop the branch.
315
+ const startDepth = depth;
316
+ depth += countBraces(c).open - countBraces(c).close;
317
+ const topLevel = i === block.start ? true : startDepth === 1;
318
+ if (topLevel && TERMINATOR.test(i === block.start ? c.slice(c.indexOf('{') + 1) : c)) return true;
319
+ }
320
+ return false;
321
+ }
322
+
323
+ function indentDominates(lines, guardIdx, sinkIdx, gl) {
324
+ const gi = indentOf(lines[guardIdx]);
325
+ // The guard's block must still enclose the sink: no non-blank line between may be LESS indented than the guard.
326
+ for (let i = guardIdx + 1; i <= sinkIdx; i++) {
327
+ if (blank(lines[i])) continue;
328
+ if (indentOf(lines[i]) < gi) return result(false, 'guard-block-closed-before-sink');
329
+ }
330
+ if (MODIFIER_EXIT.test(gl)) return result(true, 'early-exit-on-guard-line');
331
+ const head = CONDITIONAL_HEAD.test(gl) || /\bif\b|\bunless\b/.test(gl) && /:\s*$/.test(gl);
332
+ if (!head) return result(true, 'straight-line-before-sink');
333
+
334
+ // A conditional: collect the indented block that follows the header.
335
+ const bodyLines = [];
336
+ for (let i = guardIdx + 1; i < lines.length; i++) {
337
+ if (blank(lines[i])) continue;
338
+ if (indentOf(lines[i]) <= gi) break;
339
+ bodyLines.push(i);
340
+ }
341
+ if (bodyLines.includes(sinkIdx) || (bodyLines.length && sinkIdx > bodyLines[0] && sinkIdx <= bodyLines[bodyLines.length - 1])) {
342
+ return result(true, 'sink-inside-guarded-branch');
343
+ }
344
+ // One-liner `if not ok(x): raise ...`.
345
+ const afterColon = gl.slice(gl.lastIndexOf(':') + 1);
346
+ if (/:\s*\S/.test(gl) && TERMINATOR.test(afterColon)) return result(true, 'early-exit-on-guard-line');
347
+ // Direct statements of the block (one indent level in) that exit.
348
+ const inner = bodyLines.length ? indentOf(lines[bodyLines[0]]) : -1;
349
+ if (bodyLines.some((i) => indentOf(lines[i]) === inner && TERMINATOR.test(code(lines[i])))) return result(true, 'failing-branch-exits');
350
+ if (branchNeutralizes(lines, guardIdx, true)) return result(true, 'failing-branch-neutralizes-value');
351
+ return result(false, 'failing-branch-does-not-exit');
352
+ }
@@ -52,3 +52,21 @@ panel · `judge.js` shapes findings and dedupes against the prior scan ·
52
52
  `partition.js` `partitionNixFiles` groups `.nix` files by their import structure (a configuration and the modules it imports are
53
53
  one area), merged areas are capped, and hunter prompts for Haskell and Nix are redacted (split literals joined first) before they
54
54
  leave the process. `hunt` includes `.hs`, `.lhs` and `.nix`. With no endpoint every run is degraded and says so.
55
+
56
+ ## Invariant proposals (X-402)
57
+
58
+ `lenses.js` `invariantProposalFor(candidate)` maps a candidate from a state-oriented lens (`authz`, `business-logic`, and
59
+ `feature-abuse` when it is about tenants) to a PROPOSED invariant skeleton (`posture/invariants/lifecycle.js`): authored by the
60
+ model, with the candidate as source evidence and a high uncertainty. `runDiscovery` reports them as `invariantProposals` only when
61
+ `opts.invariantProposals = { config }` is passed AND the `invariant-scenarios` feature is enabled in that config; nothing else in the
62
+ report changes. A proposal is a hypothesis: it can be run as a scenario and its violations are reported as candidates, but only a
63
+ human reviewer's signed approval in the invariant ledger makes it a requirement, so a hunter never establishes its own ground truth.
64
+
65
+ ## Advisory/gating separation (X-207)
66
+
67
+ Hunt output stays advisory in code, not just by convention. `memory.js` writes only through
68
+ `posture/verification/advisory-state.js` (`writeAdvisoryState`: one allowlisted file, atomic rename, never through a link), and
69
+ `report/index.js` excludes every hunt hypothesis from findings and exit codes. A hypothesis crosses into a finding only through
70
+ `promoteHypothesis` (`posture/verification/hypothesis-promotion.js`), reached from `runDiscovery` only with
71
+ `opts.promote = { policy, verify }`: a verifier receipt the promotion code verifies, an explicit policy, and an audit record. The
72
+ panel's votes, a confidence score and the confirmation tier are never inputs to that decision.
@@ -8,12 +8,15 @@
8
8
  // pass that half failed and reports "no findings" is indistinguishable from a
9
9
  // clean codebase unless it says so.
10
10
  import { partitionCallGraph, partitionNixFiles } from './partition.js';
11
- import { LENSES, lensByKey } from './lenses.js';
11
+ import { LENSES, lensByKey, invariantProposalFor } from './lenses.js';
12
12
  import { runHunter } from './hunter.js';
13
13
  import { confirmAll } from './confirm.js';
14
14
  import { disprovePanel } from './disprove.js';
15
15
  import { judgeCandidates } from './judge.js';
16
16
  import { loadMemory, saveMemory, rememberRun, previouslyRefuted, nextWavePlan } from './memory.js';
17
+ import { emitVerification, headCommit } from '../posture/verification/emit.js';
18
+ import { promoteHypothesis } from '../posture/verification/hypothesis-promotion.js';
19
+ import { featureStatus } from '../posture/assurance/config.js';
17
20
 
18
21
  // Bridge a candidate to the deterministic layer. A taint finding at or within
19
22
  // two lines of the candidate corroborates it; a modelled sink on the line
@@ -283,11 +286,50 @@ export async function runDiscovery(ctx = {}, opts = {}) {
283
286
  }));
284
287
  }
285
288
 
289
+ // X-201: one version-1 verification record per fresh hypothesis, ADDITIVE and advisory (hunt output never enters
290
+ // last-scan.json). A taint-corroborated candidate is `inconclusive` (a static probe is corroboration, not
291
+ // execution) and an uncorroborated one is `not-run`; a model refutation panel is recorded as inference only.
292
+ const hypothesisCommit = headCommit(opts.scanRoot);
293
+ const verificationRecords = [];
294
+ for (const f of fresh) {
295
+ const e = emitVerification('hunt', f, { finding: f, commit: hypothesisCommit });
296
+ if (e.ok) verificationRecords.push(e.record);
297
+ }
298
+
299
+ // X-402: opt-in invariant proposals. A candidate from a state-oriented lens can suggest a contract; the suggestion is advisory,
300
+ // `proposed`, authored by the model and never approved here. Off unless `opts.invariantProposals = { config }` and the
301
+ // `invariant-scenarios` feature is enabled in that config.
302
+ let invariantProposals = null;
303
+ if (opts.invariantProposals && featureStatus(opts.invariantProposals.config, 'invariant-scenarios').status === 'ok') {
304
+ invariantProposals = [];
305
+ for (const f of fresh) {
306
+ const inv = invariantProposalFor({ lens: f.discovery?.lens, title: f.vuln, description: f.description, file: f.file, line: f.line });
307
+ if (inv) invariantProposals.push({ hypothesisId: f.stableId, invariant: inv });
308
+ }
309
+ }
310
+
311
+ // X-207: opt-in promotion. A hypothesis crosses into a finding ONLY through `promoteHypothesis` (trusted verifier receipt,
312
+ // explicit policy, audit record). `fresh` is unchanged; promoted findings and every decision's audit record are reported
313
+ // separately, and nothing here writes `last-scan.json`. `opts.promote = { policy, verify(hypothesis) -> runOracle result }`.
314
+ let promotions = null;
315
+ if (opts.promote && typeof opts.promote.verify === 'function') {
316
+ promotions = [];
317
+ for (const f of fresh) {
318
+ let result = null;
319
+ try { result = await opts.promote.verify(f); } catch { result = null; }
320
+ const p = promoteHypothesis({ hypothesis: f, result, policy: opts.promote.policy });
321
+ promotions.push({ hypothesisId: p.audit.sourceHypothesis.id, decision: p.decision, audit: p.audit, finding: p.finding });
322
+ }
323
+ }
324
+
286
325
  return {
287
326
  schema: 'agentic-security/discovery@1',
288
327
  focusAreas: areas.map(a => ({ id: a.id, label: a.label, files: a.files.length, size: a.size })),
289
328
  runs,
290
329
  fresh,
330
+ verificationRecords,
331
+ ...(promotions ? { promotions } : {}),
332
+ ...(invariantProposals ? { invariantProposals } : {}),
291
333
  duplicates,
292
334
  suppressed,
293
335
  // `refutedCandidates` holds RAW candidates straight from `disprovePanel`,
@@ -8,6 +8,7 @@
8
8
  // no single prompt reaches. `wildcard` exists because a fixed taxonomy is a
9
9
  // ceiling, and the classes worth finding are the ones not on the list.
10
10
  import { redactPayload } from '../egress/redact.js';
11
+ import { inferredInvariant } from '../posture/invariants/lifecycle.js';
11
12
  export const LENSES = Object.freeze([
12
13
  { key: 'injection', title: 'Injection', family: 'injection', cwe: 'CWE-74',
13
14
  brief: 'Untrusted input reaching an interpreter: SQL, shell, template, XPath, LDAP, or deserialization. Follow the value, not the function name.' },
@@ -25,6 +26,23 @@ export const LENSES = Object.freeze([
25
26
  brief: 'Anything the other lenses do not cover. Prefer the surprising and specific over the generic; report nothing rather than something already obvious.' },
26
27
  ]);
27
28
 
29
+ // X-402: the lenses that reason about who may do what to whose data can propose an INVARIANT for a candidate they raise. The
30
+ // proposal is a skeleton contract authored by the model (`proposed`, with the candidate as source evidence and a high uncertainty),
31
+ // advisory until a reviewer approves it in the signed ledger. A lens whose candidates are not about state (injection, crypto) has
32
+ // no mapping and proposes nothing.
33
+ const TENANT_HINT = /tenant|idor|ownership|object reference|cross[- ]?(?:account|user|org)|leak|another user|other user/i;
34
+ const WORKFLOW_HINT = /refund|order|status|workflow|state machine|out[- ]of[- ]order|step|approval|checkout|paid|ship/i;
35
+
36
+ export function invariantProposalFor(candidate) {
37
+ const text = `${candidate?.title || ''} ${candidate?.description || ''} ${candidate?.rationale || ''}`;
38
+ let cls = null;
39
+ if (candidate?.lens === 'authz') cls = TENANT_HINT.test(text) ? 'tenant-isolation' : 'privilege-constraint';
40
+ else if (candidate?.lens === 'feature-abuse' && TENANT_HINT.test(text)) cls = 'tenant-isolation';
41
+ else if (candidate?.lens === 'business-logic' && WORKFLOW_HINT.test(text)) cls = 'workflow-order';
42
+ if (!cls || typeof candidate.file !== 'string') return null;
43
+ return inferredInvariant({ class: cls, source: 'discovery-lens', evidence: { file: candidate.file, line: Number.isInteger(candidate.line) ? candidate.line : null, lens: candidate.lens }, uncertainty: 0.85 });
44
+ }
45
+
28
46
  export function lensByKey(key) {
29
47
  if (typeof key !== 'string') return null;
30
48
  return LENSES.find(l => l.key === key) || null;
@@ -40,6 +40,7 @@ import * as fs from 'node:fs';
40
40
  import * as path from 'node:path';
41
41
 
42
42
  import { stateWritesEnabled } from '../posture/state-dir.js';
43
+ import { writeAdvisoryState } from '../posture/verification/advisory-state.js';
43
44
  const MEMORY_SCHEMA = 'agentic-security/discovery-memory@1';
44
45
  export const MEMORY_FILE = path.join('.agentic-security', 'discovery-memory.json');
45
46
 
@@ -73,14 +74,15 @@ export function loadMemory(scanRoot) {
73
74
  }
74
75
  }
75
76
 
76
- /** Persist. Failure is non-fatal — the run still produced its report. */
77
+ /**
78
+ * Persist. Failure is non-fatal -- the run still produced its report. X-207: the write goes through the advisory-state writer
79
+ * (closed file-name allowlist, atomic rename, never through a link), so hunt output cannot reach `last-scan.json` or its
80
+ * signature even when a hostile tree planted a link at the memory file.
81
+ */
77
82
  export function saveMemory(scanRoot, memory) {
78
83
  try {
79
- const p = path.join(scanRoot, MEMORY_FILE);
80
84
  if (!stateWritesEnabled()) return;
81
- fs.mkdirSync(path.dirname(p), { recursive: true });
82
- fs.writeFileSync(p, JSON.stringify(memory, null, 2) + '\n');
83
- return true;
85
+ return writeAdvisoryState(scanRoot, path.basename(MEMORY_FILE), JSON.stringify(memory, null, 2) + '\n').ok;
84
86
  } catch {
85
87
  return false;
86
88
  }