@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -1,8 +1,166 @@
1
- export const id = 985;
2
- export const ids = [985,752];
1
+ export const id = 2376;
2
+ export const ids = [2376,8218,9390,8752];
3
3
  export const modules = {
4
4
 
5
- /***/ 3985:
5
+ /***/ 9390:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
+ /* harmony export */ auditCall: () => (/* binding */ auditCall)
10
+ /* harmony export */ });
11
+ /* unused harmony export verifyAuditLog */
12
+ /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3024);
13
+ /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(6760);
14
+ /* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(7598);
15
+ /* harmony import */ var _redact_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(3468);
16
+ /* harmony import */ var _posture_state_dir_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(1174);
17
+ // Append-only audit log of MCP tool calls — OWASP MCP08.
18
+ //
19
+ // Format: one JSON object per line (NDJSON) at
20
+ // <sessionRoot>/.agentic-security/mcp-audit.log
21
+ //
22
+ // Each entry carries `prev` — the SHA-256 of the previous entry's serialized
23
+ // form. The first entry's prev is "GENESIS". Tampering with any line breaks
24
+ // the chain from that point forward; a reader can detect partial truncation
25
+ // or in-place edits.
26
+ //
27
+ // REMOTE SINK (post-recommendation #10). The local file alone cannot detect
28
+ // a total rewrite — an attacker with FS write can re-author the whole log
29
+ // with fresh hashes. Closing that blind spot requires an off-host witness.
30
+ // Set $AGENTIC_SECURITY_AUDIT_WEBHOOK to a POST endpoint; every entry is
31
+ // fire-and-forget POSTed there in addition to the local append. Failures
32
+ // to reach the webhook are best-effort — they NEVER block a tool call,
33
+ // because that would let a network outage become a denial of service. They
34
+ // DO get recorded as `_remoteSinkErr` on the local entry, so an operator
35
+ // reviewing the log later can spot a forging attempt that targeted the
36
+ // remote (any gap between local-sequence and remote-sequence is evidence).
37
+ //
38
+ // Argument blobs are redacted (OWASP MCP01/MCP10) so credentials passed in
39
+ // arguments cannot leak via the audit trail OR via the remote sink.
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+ const MAX_ARG_BYTES = 1024;
48
+ const GENESIS = 'GENESIS';
49
+ const REMOTE_TIMEOUT_MS = 1500;
50
+
51
+ // Per-process session ID (harness-anatomy #9). Stamped on every audit entry
52
+ // so downstream metrics can aggregate by session and surface outliers like
53
+ // "200 apply_fix calls in one session." The ID is `<pid>-<short-ts>` — not
54
+ // cryptographically unique, but enough to disambiguate concurrent runs on
55
+ // the same host. Stable for the lifetime of this Node process.
56
+ const SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;
57
+
58
+ function _summarize(args) {
59
+ let s;
60
+ try { s = JSON.stringify(args); } catch { s = '<unserializable>'; }
61
+ s = (0,_redact_js__WEBPACK_IMPORTED_MODULE_4__/* .redactArgsBlob */ .MC)(s);
62
+ if (s.length > MAX_ARG_BYTES) s = s.slice(0, MAX_ARG_BYTES) + `…(+${s.length - MAX_ARG_BYTES})`;
63
+ return s;
64
+ }
65
+
66
+ function _sha(s) { return node_crypto__WEBPACK_IMPORTED_MODULE_2__.createHash('sha256').update(s).digest('hex'); }
67
+
68
+ function _readLastEntryHash(logFile) {
69
+ if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(logFile)) return GENESIS;
70
+ try {
71
+ const all = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(logFile, 'utf8');
72
+ const lines = all.split('\n').filter(Boolean);
73
+ if (!lines.length) return GENESIS;
74
+ return _sha(lines[lines.length - 1]);
75
+ } catch { return GENESIS; }
76
+ }
77
+
78
+ // Fire-and-forget POST to the remote sink. Resolves to null on success,
79
+ // to a short error string on failure. Never throws; never blocks longer
80
+ // than REMOTE_TIMEOUT_MS. The local audit append happens regardless.
81
+ async function _postRemote(url, entry) {
82
+ try {
83
+ const controller = new AbortController();
84
+ const t = setTimeout(() => controller.abort(), REMOTE_TIMEOUT_MS);
85
+ const r = await fetch(url, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify(entry),
89
+ signal: controller.signal,
90
+ });
91
+ clearTimeout(t);
92
+ if (!r.ok) return `HTTP ${r.status}`;
93
+ return null;
94
+ } catch (e) {
95
+ return String((e && e.message) || e).slice(0, 200);
96
+ }
97
+ }
98
+
99
+ function auditCall({ sessionRoot, tool, args, outcome, reason }) {
100
+ if (!sessionRoot) return;
101
+ try {
102
+ // Safety: only write audit log if sessionRoot looks like a project root
103
+ const MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml', 'composer.json', 'Gemfile'];
104
+ let hasMarker = false;
105
+ for (const m of MARKERS) { try { if (node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(node_path__WEBPACK_IMPORTED_MODULE_1__.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
106
+ if (!hasMarker) return;
107
+ const dir = (0,_posture_state_dir_js__WEBPACK_IMPORTED_MODULE_3__/* .stateDir */ .Pn)(sessionRoot);
108
+ node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdirSync(dir, { recursive: true });
109
+ const logFile = node_path__WEBPACK_IMPORTED_MODULE_1__.join(dir, 'mcp-audit.log');
110
+ const entry = {
111
+ ts: new Date().toISOString(),
112
+ sessionId: SESSION_ID,
113
+ tool,
114
+ outcome,
115
+ ...(reason ? { reason } : {}),
116
+ args: _summarize(args),
117
+ prev: _readLastEntryHash(logFile),
118
+ };
119
+ node_fs__WEBPACK_IMPORTED_MODULE_0__.appendFileSync(logFile, JSON.stringify(entry) + '\n');
120
+ // Remote sink (post-recommendation #10). Fire-and-forget. We don't await
121
+ // the promise so the tool call returns immediately; the remote POST runs
122
+ // on its own microtask. Failures get logged to a sidecar file so the
123
+ // operator can detect when the sink is unreachable.
124
+ const webhook = process.env.AGENTIC_SECURITY_AUDIT_WEBHOOK;
125
+ if (webhook) {
126
+ _postRemote(webhook, entry).then((err) => {
127
+ if (!err) return;
128
+ try {
129
+ const errFile = node_path__WEBPACK_IMPORTED_MODULE_1__.join(dir, 'mcp-audit.remote-errors.log');
130
+ node_fs__WEBPACK_IMPORTED_MODULE_0__.appendFileSync(errFile, JSON.stringify({
131
+ ts: new Date().toISOString(), entryTs: entry.ts, tool, err,
132
+ }) + '\n');
133
+ } catch { /* nothing else to do */ }
134
+ });
135
+ }
136
+ } catch { /* audit failure must never break a tool call */ }
137
+ }
138
+
139
+ // Verify the chain from start to end. Returns
140
+ // { ok: true, entries: N } if intact
141
+ // { ok: false, brokenAt: <line-index>, expected, got } if any link breaks
142
+ // Reader/operator-facing tool.
143
+ function verifyAuditLog(logFile) {
144
+ if (!fs.existsSync(logFile)) return { ok: true, entries: 0 };
145
+ const text = fs.readFileSync(logFile, 'utf8');
146
+ const lines = text.split('\n').filter(Boolean);
147
+ let expectedPrev = GENESIS;
148
+ for (let i = 0; i < lines.length; i++) {
149
+ let entry;
150
+ try { entry = JSON.parse(lines[i]); }
151
+ catch { return { ok: false, brokenAt: i, reason: 'not JSON' }; }
152
+ if (entry.prev !== expectedPrev) {
153
+ return { ok: false, brokenAt: i, expected: expectedPrev, got: entry.prev };
154
+ }
155
+ expectedPrev = _sha(lines[i]);
156
+ }
157
+ return { ok: true, entries: lines.length };
158
+ }
159
+
160
+
161
+ /***/ }),
162
+
163
+ /***/ 2376:
6
164
  /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
165
 
8
166
 
@@ -23,87 +181,261 @@ var external_node_url_ = __webpack_require__(3136);
23
181
  var promises_ = __webpack_require__(1455);
24
182
  // EXTERNAL MODULE: ./src/posture/fix-history.js
25
183
  var fix_history = __webpack_require__(4407);
26
- // EXTERNAL MODULE: ./src/posture/integrity.js
27
- var integrity = __webpack_require__(1130);
28
- // EXTERNAL MODULE: ./src/posture/cache-economics.js
29
- var cache_economics = __webpack_require__(8752);
30
- ;// CONCATENATED MODULE: ./src/mcp/redact.js
31
- // Secret redactor for MCP tool outputs and audit log argument summaries.
184
+ // EXTERNAL MODULE: ./src/fix/apply-fix-service.js
185
+ var apply_fix_service = __webpack_require__(7730);
186
+ // EXTERNAL MODULE: ./src/posture/material-change.js
187
+ var material_change = __webpack_require__(4629);
188
+ // EXTERNAL MODULE: ./src/fix/approver-registry.js
189
+ var approver_registry = __webpack_require__(437);
190
+ ;// CONCATENATED MODULE: ./src/posture/deterministic-fix.js
191
+ // Deterministic fix synthesis (#1) — for the narrow set of vulnerability classes
192
+ // where a context-INDEPENDENT literal swap is a safe, correct fix, produce a
193
+ // full-file replacement from the current file content. No LLM, no guessing, no
194
+ // per-finding bloat in last-scan.json (the patch is materialized on demand by
195
+ // synthesize_fix from the live file, not stored on every finding).
32
196
  //
33
- // OWASP MCP01 + MCP10: the scanner reads source code, and findings carry
34
- // `snippet` / `description` / `trace` strings that may contain hardcoded
35
- // credentials, API keys, JWTs, private keys, etc. When those flow back to
36
- // the agent through tools/call responses they land in the agent's context
37
- // — exposing the secret to model logs, transcripts, and any downstream tool
38
- // the agent passes them to.
197
+ // Safety: every patch this produces is still gated by verify_fix before apply_fix
198
+ // writes it (original finding gone + no new ≥medium + lint clean). So a swap that
199
+ // a rule mis-attributed simply fails verification instead of landing a bad edit —
200
+ // this module widens the deterministic-fix surface without weakening the gate.
39
201
  //
40
- // We replace high-confidence secret shapes with [REDACTED:<kind>] before
41
- // emitting them. The original full content is still on disk (scanner
42
- // findings); the MCP surface is the bottleneck we control.
43
- //
44
- // Patterns deliberately stay narrow: high-precision so we don't garble
45
- // non-secret long strings (UUIDs, SHAs, base64-encoded scan IDs).
46
-
47
- const PATTERNS = [
48
- // Provider-specific high-entropy keys (anchored prefixes give very low FP)
49
- [/AKIA[0-9A-Z]{16}/g, 'aws-access-key'],
50
- [/ASIA[0-9A-Z]{16}/g, 'aws-temp-key'],
51
- [/gh[pousr]_[A-Za-z0-9]{36,255}/g, 'github-token'],
52
- [/xox[abprs]-[A-Za-z0-9-]{10,}/g, 'slack-token'],
53
- [/sk-ant-[A-Za-z0-9_-]{20,}/g, 'anthropic-key'],
54
- [/sk-proj-[A-Za-z0-9_-]{20,}/g, 'openai-project-key'],
55
- [/sk-[A-Za-z0-9]{32,}/g, 'openai-or-stripe-key'],
56
- [/sk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-key'],
57
- [/rk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-restricted-key'],
58
- [/SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, 'sendgrid-key'],
59
- [/AIza[0-9A-Za-z_-]{35}/g, 'google-api-key'],
60
- // JWT three dot-separated b64url segments starting with eyJ
61
- [/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'jwt'],
62
- // PEM-encoded private keys
63
- [/-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g, 'private-key-block'],
64
- // Authorization headers — common copy-paste shape
65
- [/(?:Authorization|authorization)\s*:\s*Bearer\s+[A-Za-z0-9._~+/-]{20,}={0,2}/g, 'bearer-token'],
66
- // Hardcoded password literals — assignment shape with quoted value
67
- [/(password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*["'][^"'\n]{6,}["']/gi, 'hardcoded-credential'],
202
+ // Returns { patch: { [relFile]: newContent }, ruleId } or null when no
203
+ // deterministic fix applies to the finding.
204
+
205
+ const JS_EXT = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
206
+ const PY_EXT = /\.py$/i;
207
+
208
+ // Each rule gates on the finding's cwe/family, then rewrites the whole-file
209
+ // content. transform() returns the new content, or null when nothing changed
210
+ // (e.g. the vulnerable token isn't literally present then we don't claim a fix).
211
+ const RULES = [
212
+ {
213
+ id: 'weak-hash-sha256',
214
+ // md5 / sha1 → sha256. Every occurrence in the file is a weak hash, so
215
+ // swapping them all is safe; the verifier confirms the weak-hash finding is
216
+ // gone and nothing worse appeared.
217
+ applies: (f) => /CWE-(?:327|328|916)/.test(f.cwe || '') || /weak.?hash/i.test(f.family || ''),
218
+ transform: (content, file) => {
219
+ let out = content;
220
+ if (JS_EXT.test(file)) {
221
+ out = out.replace(/(\bcreateHash\s*\(\s*['"`])(?:md5|sha1)(['"`])/gi, '$1sha256$2');
222
+ } else if (PY_EXT.test(file)) {
223
+ out = out.replace(/\bhashlib\.(?:md5|sha1)\s*\(/g, 'hashlib.sha256(');
224
+ }
225
+ return out !== content ? out : null;
226
+ },
227
+ },
228
+ {
229
+ id: 'tls-verify-on',
230
+ // Disabled TLS verification → enabled. rejectUnauthorized:false → true (JS),
231
+ // verify=False → verify=True (Python requests).
232
+ applies: (f) => /CWE-295/.test(f.cwe || '') || /tls.?no.?verify|cert.?(?:none|verify)/i.test(f.family || ''),
233
+ transform: (content, file) => {
234
+ let out = content;
235
+ if (JS_EXT.test(file)) {
236
+ out = out.replace(/(\brejectUnauthorized\s*:\s*)false\b/g, '$1true');
237
+ } else if (PY_EXT.test(file)) {
238
+ out = out.replace(/(\bverify\s*=\s*)False\b/g, '$1True');
239
+ }
240
+ return out !== content ? out : null;
241
+ },
242
+ },
68
243
  ];
69
244
 
70
- const SNIPPET_MAX = 2000;
71
- // OWASP A03 cap input before running 14 regex patterns over it. A forged
72
- // last-scan.json could plant a 50MB description string; without this cap a
73
- // single explain_finding/query_taint call would peg CPU. After truncation
74
- // the snippet still gets the final SNIPPET_MAX trim downstream.
75
- const INPUT_MAX = 100_000;
76
-
77
- function redactString(s) {
78
- if (typeof s !== 'string') return s;
79
- let out = s;
80
- if (out.length > INPUT_MAX) out = out.slice(0, INPUT_MAX) + `…(+${out.length - INPUT_MAX})`;
81
- for (const [re, kind] of PATTERNS) {
82
- out = out.replace(re, `[REDACTED:${kind}]`);
245
+ function synthesizeDeterministicPatch(finding, fileContent) {
246
+ if (!finding || typeof fileContent !== 'string' || !finding.file) return null;
247
+ for (const rule of RULES) {
248
+ try {
249
+ if (!rule.applies(finding)) continue;
250
+ const next = rule.transform(fileContent, finding.file);
251
+ if (next && next !== fileContent) return { patch: { [finding.file]: next }, ruleId: rule.id };
252
+ } catch { /* a single rule failing must never break synthesis */ }
83
253
  }
84
- if (out.length > SNIPPET_MAX) out = out.slice(0, SNIPPET_MAX) + `…(+${out.length - SNIPPET_MAX})`;
85
- return out;
254
+ return null;
86
255
  }
87
256
 
88
- // Deep-redact every string in a finding-like object (mutates returned copy).
89
- function redactFinding(f) {
90
- if (!f || typeof f !== 'object') return f;
91
- const out = { ...f };
92
- for (const k of ['snippet', 'description', 'remediation', 'title', 'vuln', 'message']) {
93
- if (typeof out[k] === 'string') out[k] = redactString(out[k]);
94
- }
95
- if (out.trace) {
96
- try { out.trace = JSON.parse(redactString(JSON.stringify(out.trace))); }
97
- catch { /* keep as-is if not round-trippable */ }
98
- }
99
- return out;
100
- }
257
+ // EXTERNAL MODULE: ./src/posture/integrity.js
258
+ var integrity = __webpack_require__(1130);
259
+ // EXTERNAL MODULE: ./src/posture/state-dir.js
260
+ var state_dir = __webpack_require__(1174);
261
+ // EXTERNAL MODULE: ./src/posture/cache-economics.js
262
+ var cache_economics = __webpack_require__(8752);
263
+ // EXTERNAL MODULE: ./src/mcp/redact.js
264
+ var redact = __webpack_require__(3468);
265
+ // EXTERNAL MODULE: ./src/report/index.js + 3 modules
266
+ var report = __webpack_require__(457);
267
+ // EXTERNAL MODULE: ./src/posture/provenance/schema.js
268
+ var schema = __webpack_require__(4594);
269
+ // EXTERNAL MODULE: ./src/server/graph-loader.js
270
+ var graph_loader = __webpack_require__(8218);
271
+ // EXTERNAL MODULE: ./src/server/routes.js
272
+ var routes = __webpack_require__(4268);
273
+ // EXTERNAL MODULE: ./src/lineage/redact-graph.js
274
+ var redact_graph = __webpack_require__(334);
275
+ // EXTERNAL MODULE: ./src/lineage/export-json.js
276
+ var export_json = __webpack_require__(859);
277
+ ;// CONCATENATED MODULE: ./src/mcp/dataflow-tools.js
278
+ // dataflow-tools.js — Milestone 4, sub-project MCP tools.
279
+ //
280
+ // Thin, read-only MCP adapter over the DataFlowGraph v1 artifact. Every
281
+ // piece of actual graph-loading and graph-query logic here is REUSED,
282
+ // unmodified, from scanner/src/server/ (built for the `explore` HTTP
283
+ // server, Milestone 3): loadSignedGraph does the signed-artifact
284
+ // load+verify, the four handleX functions do the lookups. This module
285
+ // adds nothing but MCP tool shape (name/description/inputSchema/handler)
286
+ // and MCP-appropriate error handling — no new graph-query logic is
287
+ // written here, on purpose (see this sub-project's own scoping doc).
288
+
289
+
290
+
101
291
 
102
- // Redact a freeform JSON-stringified argument blob (used by audit log).
103
- function redactArgsBlob(s) {
104
- return redactString(s);
292
+
293
+
294
+ const META = { source: 'agentic-security-mcp', untrusted_excerpts: true };
295
+
296
+ function _loadOrFailure(sessionRoot) {
297
+ const loaded = (0,graph_loader.loadSignedGraph)(sessionRoot);
298
+ if (loaded.ok) return { graph: loaded.graph };
299
+ return {
300
+ failure: {
301
+ _meta: META,
302
+ hasResult: false,
303
+ reason: loaded.reason,
304
+ message: loaded.message,
305
+ },
306
+ };
105
307
  }
106
308
 
309
+ // Milestone 5, large-graph pagination: an optional `filter` input narrows the
310
+ // returned graph via the exact same `validateFilterShape`/`_filterGraph`
311
+ // pair the CLI's own `--filter` and the `explore` server's new
312
+ // `POST /api/v1/query` endpoint both already use — one real, shared
313
+ // primitive, not a third drifting copy. KNOWN, DISCLOSED GAP (still open):
314
+ // an OMITTED filter still returns the whole graph inline, with the same
315
+ // stdio.js MAX_LINE_BYTES (4MB) risk on a very large, unfiltered scan as
316
+ // before this change — this increment adds an opt-in capability for a
317
+ // caller that supplies a filter, it does not add a forced fallback/offload
318
+ // for a caller that doesn't. That remains a follow-up increment.
319
+ // Final whole-branch review finding: `filter: {}` is NOT the same as
320
+ // omitting `filter` — `_filterGraph` treats an empty (but well-formed)
321
+ // filter object as "narrow to nothing" (empty nodeIds/edgeIds Sets), so
322
+ // `filter: {}` returns an EMPTY graph (zero nodes/edges/flows), not the
323
+ // whole one. Called out explicitly in this tool's own `description` below
324
+ // so an agent reaching for "no restriction" reaches for OMITTING the
325
+ // argument, never for `{}`.
326
+ const dataflow_get_graph = {
327
+ name: 'dataflow_get_graph',
328
+ description: 'Return the DataFlowGraph v1 artifact from the last signed, verified deep-mode scan: nodes, edges, flows, scope, coverage, and limitations. Requires a prior `AGENTIC_SECURITY_LINEAGE_DEEP=1 agentic-security scan`. Optional `filter: {nodeIds, edgeIds}` narrows the returned nodes/edges/flows/dataElements (same primitive as the CLI\'s `--filter` and the `explore` server\'s `POST /api/v1/query`). IMPORTANT: omit `filter` entirely for the whole graph — passing `filter: {}` returns an EMPTY graph (zero nodes/edges/flows), not the whole one, since an empty filter narrows to nothing rather than meaning "no restriction". KNOWN GAP: an OMITTED filter still returns the whole graph inline with no pagination/offload — may exceed the stdio transport line cap on a very large, unfiltered graph; supply a real, non-empty filter to narrow the response.',
329
+ inputSchema: {
330
+ type: 'object',
331
+ additionalProperties: false,
332
+ properties: {
333
+ filter: {
334
+ type: 'object',
335
+ additionalProperties: false,
336
+ properties: {
337
+ nodeIds: { type: 'array', items: { type: 'string' } },
338
+ edgeIds: { type: 'array', items: { type: 'string' } },
339
+ },
340
+ },
341
+ },
342
+ },
343
+ async handler(args, ctx) {
344
+ const { graph, failure } = _loadOrFailure(ctx.sessionRoot);
345
+ if (failure) return failure;
346
+ // Milestone 5, large-graph pagination: reuses the exact same
347
+ // validateFilterShape/_filterGraph pair the new POST /api/v1/query
348
+ // server endpoint and the CLI's own --filter both use — one real,
349
+ // shared primitive, not a third drifting copy.
350
+ const filterCheck = (0,export_json.validateFilterShape)(args?.filter);
351
+ if (!filterCheck.valid) {
352
+ return { _meta: META, hasResult: false, reason: 'invalid-filter', message: filterCheck.error };
353
+ }
354
+ const { status, body } = (0,routes/* handleGraph */.fn)(graph);
355
+ return {
356
+ _meta: META,
357
+ hasResult: true,
358
+ status,
359
+ data: (0,redact_graph/* _redactGraph */.zl)(args?.filter ? (0,export_json/* _filterGraph */.e)(body.data, args.filter) : body.data),
360
+ digest: body.digest,
361
+ schemaVersion: body.schemaVersion,
362
+ extensions: body.extensions,
363
+ scope: body.scope,
364
+ coverage: body.coverage,
365
+ limitations: body.limitations,
366
+ };
367
+ },
368
+ };
369
+
370
+ const dataflow_get_node = {
371
+ name: 'dataflow_get_node',
372
+ description: 'Look up one node by canonical id in the DataFlowGraph v1 artifact.',
373
+ inputSchema: {
374
+ type: 'object',
375
+ additionalProperties: false,
376
+ properties: { id: { type: 'string', minLength: 1, maxLength: 512 } },
377
+ required: ['id'],
378
+ },
379
+ async handler({ id }, ctx) {
380
+ const { graph, failure } = _loadOrFailure(ctx.sessionRoot);
381
+ if (failure) return failure;
382
+ const { status, body } = (0,routes/* handleNode */.d5)(graph, id);
383
+ return {
384
+ _meta: META,
385
+ hasResult: true,
386
+ notFound: status === 404,
387
+ data: (0,redact_graph/* _redactNode */.T2)(body.data),
388
+ canonicalIds: body.canonicalIds,
389
+ };
390
+ },
391
+ };
392
+
393
+ const dataflow_get_edge = {
394
+ name: 'dataflow_get_edge',
395
+ description: 'Look up one edge by canonical id in the DataFlowGraph v1 artifact.',
396
+ inputSchema: {
397
+ type: 'object',
398
+ additionalProperties: false,
399
+ properties: { id: { type: 'string', minLength: 1, maxLength: 512 } },
400
+ required: ['id'],
401
+ },
402
+ async handler({ id }, ctx) {
403
+ const { graph, failure } = _loadOrFailure(ctx.sessionRoot);
404
+ if (failure) return failure;
405
+ const { status, body } = (0,routes/* handleEdge */.Yu)(graph, id);
406
+ return {
407
+ _meta: META,
408
+ hasResult: true,
409
+ notFound: status === 404,
410
+ data: body.data,
411
+ canonicalIds: body.canonicalIds,
412
+ };
413
+ },
414
+ };
415
+
416
+ const dataflow_get_flow = {
417
+ name: 'dataflow_get_flow',
418
+ description: 'Look up one flow by canonical id in the DataFlowGraph v1 artifact, including its contributing node/edge canonical ids.',
419
+ inputSchema: {
420
+ type: 'object',
421
+ additionalProperties: false,
422
+ properties: { id: { type: 'string', minLength: 1, maxLength: 512 } },
423
+ required: ['id'],
424
+ },
425
+ async handler({ id }, ctx) {
426
+ const { graph, failure } = _loadOrFailure(ctx.sessionRoot);
427
+ if (failure) return failure;
428
+ const { status, body } = (0,routes/* handleFlow */.jg)(graph, id);
429
+ return {
430
+ _meta: META,
431
+ hasResult: true,
432
+ notFound: status === 404,
433
+ data: body.data,
434
+ canonicalIds: body.canonicalIds,
435
+ };
436
+ },
437
+ };
438
+
107
439
  ;// CONCATENATED MODULE: ./src/posture/agents-memory.js
108
440
  // AGENTS.md — writable continual-learning memory (harness-anatomy #2).
109
441
  //
@@ -136,6 +468,7 @@ function redactArgsBlob(s) {
136
468
 
137
469
 
138
470
 
471
+
139
472
  const MEMORY_FILE = '.agentic-security/AGENTS.md';
140
473
  const ARCHIVE_FILE = '.agentic-security/AGENTS.md.archive';
141
474
  const MAX_BYTES = 20 * 1024;
@@ -143,8 +476,8 @@ const MAX_ENTRY_BYTES = 2 * 1024;
143
476
  const ARCHIVE_MAX_BYTES = 200 * 1024;
144
477
  const HEADER = '# AGENTS.md\n\nAgent-authored continual-learning notes. Each entry: timestamp + agent name + one short paragraph. New entries appended at the bottom; oldest entries rotate to AGENTS.md.archive when this file exceeds 20 KB.\n\n';
145
478
 
146
- function _resolve(scanRoot) { return external_node_path_.join(scanRoot, MEMORY_FILE); }
147
- function _archivePath(scanRoot) { return external_node_path_.join(scanRoot, ARCHIVE_FILE); }
479
+ function _resolve(scanRoot) { return (0,state_dir.statePath)(scanRoot, 'AGENTS.md'); }
480
+ function _archivePath(scanRoot) { return (0,state_dir.statePath)(scanRoot, 'AGENTS.md.archive'); }
148
481
 
149
482
  function readAgentsMemory(scanRoot) {
150
483
  const fp = _resolve(scanRoot);
@@ -172,7 +505,8 @@ function appendAgentsMemory(scanRoot, { agent, body }) {
172
505
  const entry = `\n## ${ts} agent: ${agent}\n\n${snippet}\n`;
173
506
  try {
174
507
  const fp = _resolve(scanRoot);
175
- external_node_fs_.mkdirSync(external_node_path_.dirname(fp), { recursive: true });
508
+ if (!(0,state_dir.stateWritesEnabled)()) return;
509
+ external_node_fs_.mkdirSync(external_node_path_.dirname(fp), { recursive: true });
176
510
  if (!external_node_fs_.existsSync(fp)) external_node_fs_.writeFileSync(fp, HEADER);
177
511
  external_node_fs_.appendFileSync(fp, entry);
178
512
  _maybeRotate(scanRoot);
@@ -398,25 +732,36 @@ const cve_lookup_internals = { CACHE_DIR, CVE_RE, _stalenessTier };
398
732
 
399
733
 
400
734
 
401
- // Lazy-loaded: these transitively pull in npm packages (fast-glob,
402
- // @babel/core) that aren't available in the plugin-cache install path
735
+
736
+
737
+
738
+
739
+
740
+ // Git-origin provenance (Finding Provenance M0/M1). Distinct from
741
+ // `finding.provenance` (AI-authorship) and from an SCA entry's `provenance`
742
+ // (Sigstore/SLSA attestation) — see report/index.js's import comment.
743
+
744
+
745
+
746
+ // Lazy-loaded: these transitively pull in npm packages (@babel/core and
747
+ // friends) that aren't available in the plugin-cache install path
403
748
  // (no node_modules). Deferring keeps the MCP server bootable everywhere;
404
749
  // the import only runs when a tool that needs them is actually called.
405
750
  let _runScan;
406
751
  async function getRunScan() {
407
- if (!_runScan) _runScan = (await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 9099))).runScan;
752
+ if (!_runScan) _runScan = (await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 454))).runScan;
408
753
  return _runScan;
409
754
  }
410
755
  let _verifyFixCore;
411
756
  async function getVerifyFixCore() {
412
- if (!_verifyFixCore) _verifyFixCore = (await __webpack_require__.e(/* import() */ 838).then(__webpack_require__.bind(__webpack_require__, 7838))).verifyFix;
757
+ if (!_verifyFixCore) _verifyFixCore = (await __webpack_require__.e(/* import() */ 3526).then(__webpack_require__.bind(__webpack_require__, 3526))).verifyFix;
413
758
  return _verifyFixCore;
414
759
  }
415
760
 
416
761
  const MAX_FILES_PER_SCAN = 1024;
417
762
  const MAX_FILE_BYTES = 500_000;
418
763
  const MAX_TOTAL_SCAN_BYTES = 50_000_000;
419
- const META = { source: 'agentic-security-mcp', untrusted_excerpts: true };
764
+ const tools_META = { source: 'agentic-security-mcp', untrusted_excerpts: true };
420
765
 
421
766
  // OWASP A01 — refuse writes to paths that could subvert the security tool
422
767
  // itself or the host's source-control / dependency state. A forged finding
@@ -471,7 +816,29 @@ const RESERVED_WRITE_SUFFIXES = [
471
816
  '.tfvars',
472
817
  'docker-compose.yml',
473
818
  'docker-compose.yaml',
819
+ // _CONFINEMENT rule 3 — backup and lock files. The specific lock BASENAMES
820
+ // above cover the ecosystems we know; this catches the rest (`deps.lock`,
821
+ // `foo.bak`) without needing to enumerate them. Nothing an autofix should
822
+ // ever be rewriting: a `.bak` is someone's safety copy and a `.lock` is
823
+ // generated state.
824
+ '.bak',
825
+ '.lock',
474
826
  ];
827
+ // _CONFINEMENT rule 3 — build output. Matched as a PATH SEGMENT at any depth,
828
+ // not as a top-level prefix, because build output is routinely nested
829
+ // (`packages/web/dist/`, `services/api/target/`) and a top-level-only check
830
+ // would refuse the monorepo root and allow every package inside it.
831
+ //
832
+ // This matters most in THIS repository: `scanner/dist/` holds the shipped
833
+ // bundle, which carries its own SHA-256 integrity sidecar precisely because
834
+ // what it contains matters. Before this, `apply_fix` would rewrite it and
835
+ // report success.
836
+ //
837
+ // NOTE for a future change, deliberately not made here: the PREFIX list above
838
+ // (`node_modules/`, `.git/`, …) is still top-level-only, so a nested
839
+ // `packages/a/node_modules/` is not covered by it. That is a separate widening
840
+ // with its own blast radius and belongs in its own change with its own tests.
841
+ const RESERVED_WRITE_DIR_SEGMENTS = new Set(['dist', 'build', 'target']);
475
842
  function _isReservedWritePath(sessionRoot, absFile) {
476
843
  // Resolve sessionRoot symlinks so the relative path is computed against
477
844
  // the same canonical root as `absFile` (which _confine already realpath'd).
@@ -480,9 +847,15 @@ function _isReservedWritePath(sessionRoot, absFile) {
480
847
  const rootReal = external_node_fs_.realpathSync(external_node_path_.resolve(sessionRoot));
481
848
  const rel = external_node_path_.relative(rootReal, absFile).replace(/\\/g, '/');
482
849
  if (RESERVED_WRITE_PREFIXES.some(p => rel === p.replace(/\/$/, '') || rel.startsWith(p))) return true;
483
- const base = rel.split('/').pop() || '';
850
+ const segments = rel.split('/');
851
+ const base = segments[segments.length - 1] || '';
484
852
  if (RESERVED_WRITE_BASENAMES.has(base)) return true;
485
853
  if (RESERVED_WRITE_SUFFIXES.some(s => base === s || base.endsWith(s))) return true;
854
+ // Any DIRECTORY segment that names build output — checked over
855
+ // `segments.length - 1` so a source file legitimately called `build` or
856
+ // `dist` is not refused for its own name; only living inside such a
857
+ // directory counts.
858
+ if (segments.slice(0, -1).some(seg => RESERVED_WRITE_DIR_SEGMENTS.has(seg))) return true;
486
859
  return false;
487
860
  }
488
861
 
@@ -528,12 +901,17 @@ function _validateScratchpadPath(relPath) {
528
901
  return { ok: true, agent, session, fileParts };
529
902
  }
530
903
 
904
+ // Routes through the same lstat+realpath confinement every other write/
905
+ // path-taking tool uses (OWASP MCP05) — a lexical prefix/charset check
906
+ // alone doesn't stop a pre-planted symlink at any path component from
907
+ // relocating the write/read outside the session root. Throws on escape;
908
+ // callers must catch (see append_scratchpad / read_scratchpad).
531
909
  function _scratchpadAbs(sessionRoot, relPath) {
532
- return external_node_path_.resolve(sessionRoot, relPath.replace(/\\/g, '/'));
910
+ return _confine(sessionRoot, relPath.replace(/\\/g, '/'), 'scratchpad path');
533
911
  }
534
912
 
535
913
  function _scratchpadTotalBytes(sessionRoot) {
536
- const base = external_node_path_.join(sessionRoot, '.agentic-security', 'agent-scratchpad');
914
+ const base = (0,state_dir.statePath)(sessionRoot, 'agent-scratchpad');
537
915
  if (!external_node_fs_.existsSync(base)) return 0;
538
916
  let total = 0;
539
917
  const walk = (dir) => {
@@ -598,8 +976,8 @@ function _confine(sessionRoot, candidate, label) {
598
976
  }
599
977
 
600
978
  function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
601
- const stateDir = external_node_path_.join(sessionRoot, '.agentic-security');
602
- const scanFile = external_node_path_.join(stateDir, 'last-scan.json');
979
+ const stateDirPath = (0,state_dir/* stateDir */.Pn)(sessionRoot);
980
+ const scanFile = external_node_path_.join(stateDirPath, 'last-scan.json');
603
981
  const sigFile = scanFile + '.sig';
604
982
  if (!external_node_fs_.existsSync(scanFile)) return { scan: null, status: 'missing' };
605
983
  const body = external_node_fs_.readFileSync(scanFile, 'utf8');
@@ -672,6 +1050,18 @@ function _maybeOffload(sessionRoot, toolName, items) {
672
1050
  }
673
1051
 
674
1052
  // ─── scan_diff ───────────────────────────────────────────────────────────────
1053
+ // Test seam for the write boundary (PRD F6.4).
1054
+ //
1055
+ // `_confine` and `isReservedWrite` ARE the confinement contract in
1056
+ // agents/_CONFINEMENT.md. A boundary is only worth what its refusals are worth,
1057
+ // and refusals cannot be adversarially tested through the public tools without
1058
+ // also exercising a real scan, a real patch and a real filesystem write — so
1059
+ // the check would be measuring four things and attributing failure to one.
1060
+ //
1061
+ // Exported under the `_internals` convention this codebase already uses
1062
+ // (see posture/poc-inprocess.js). Not part of the MCP tool surface.
1063
+ const tools_internals = { _confine, isReservedWrite: _isReservedWritePath };
1064
+
675
1065
  const scan_diff = {
676
1066
  name: 'scan_diff',
677
1067
  description: 'Scan a list of files for security findings. Use BEFORE writing a Write/Edit to disk so the agent can self-correct. Returns findings with severity, file:line, title, remediation. Snippets are redacted of obvious secret patterns. Paths confined to the session root; symlinks are refused.',
@@ -708,16 +1098,52 @@ const scan_diff = {
708
1098
  fileContents[rel] = content;
709
1099
  }
710
1100
 
1101
+ // PRD R1 (docs/DETECTION_GAP_REMEDIATION_PRD.md): deep mode is default-on
1102
+ // for the interactive CLI scan but was never requested here, so an
1103
+ // agent's pre-write self-correction scan was regex/AST-only — blind to
1104
+ // any bug whose source and sink are connected only through a call
1105
+ // (`fileContents` scopes the deep engine's IR to exactly the files
1106
+ // passed in, same bound this tool already enforces via MAX_FILES_PER_SCAN
1107
+ // / MAX_TOTAL_SCAN_BYTES, so this does not turn scan_diff into a
1108
+ // full-project deep scan).
711
1109
  const runScan = await getRunScan();
712
- const result = await runScan(sessionRoot, { network: false, fileContents });
1110
+ // FR-704 (assurance-hardening PRD): this tool's own description promises
1111
+ // "runs scan in memory" — without this, runFullScan's own state writers
1112
+ // (dpia.md, ropa.md, privacy-framework.json, threat-model.json, and
1113
+ // others) fire unconditionally on every call, silently mutating the
1114
+ // user's real project on every pre-write self-correction scan. Confirmed
1115
+ // by direct execution before this fix (11 state artifacts written by a
1116
+ // single scan_diff-shaped call).
1117
+ const result = await (0,state_dir/* withStateWritesDisabled */.Ao)(() =>
1118
+ runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true }));
713
1119
  const wantSet = new Set(Object.keys(fileContents));
714
1120
  const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
715
1121
  const min = sevRank[severity] ?? 0;
716
- const findings = (result.scan.findings || [])
1122
+ // Stage 6 correctness audit (historical): this used to only read
1123
+ // result.scan.findings (the SAST channel) — scan.secrets and
1124
+ // scan.logicVulns are separate arrays on the raw runScan() result, and a
1125
+ // hand-rolled 3-channel concat here was a second, divergent copy of the
1126
+ // merge report/index.js's normalizeFindings() already does (four
1127
+ // channels, plus per-channel defaulting and remediation-string
1128
+ // resolution the old concat re-implemented separately and could drift
1129
+ // from). Assurance-hardening PRD FR-105 ("JSON, SARIF, HTML, CSV, JUnit,
1130
+ // and MCP outputs derive from the same validated object"): route through
1131
+ // the same canonical merge every other output format uses.
1132
+ //
1133
+ // This closes the field-mapping/dedup divergence, but does NOT make
1134
+ // scan_diff surface SCA/supply-chain findings end to end: this handler
1135
+ // never builds a `depFileContents` map (everything a caller passes in
1136
+ // `files`, manifests included, lands in `fileContents`), and manifest-
1137
+ // based supply-chain detection in engine.js reads only `depFileContents`
1138
+ // — so `result.scan.supplyChain` is always empty for this tool today
1139
+ // regardless of this fix. That is a separate, real limitation (scan_diff
1140
+ // was designed for pre-write code self-correction, not manifest
1141
+ // scanning), left as-is rather than silently claimed fixed here.
1142
+ const findings = (0,report.normalizeFindings)(result.scan)
717
1143
  .filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
718
- .map(f => redactFinding({
1144
+ .map(f => (0,redact/* redactFinding */.lE)({
719
1145
  id: f.id, severity: f.severity, file: f.file, line: f.line,
720
- title: f.title || f.vuln, cwe: f.cwe,
1146
+ title: f.vuln, cwe: f.cwe,
721
1147
  description: f.description, remediation: f.remediation,
722
1148
  }));
723
1149
  // Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
@@ -727,7 +1153,7 @@ const scan_diff = {
727
1153
  const off = _maybeOffload(sessionRoot, 'scan_diff', findings);
728
1154
  if (off.offloaded) {
729
1155
  return {
730
- _meta: META,
1156
+ _meta: tools_META,
731
1157
  scannedFiles: Object.keys(fileContents).length,
732
1158
  findingCount: off.total,
733
1159
  offloaded: true,
@@ -737,7 +1163,7 @@ const scan_diff = {
737
1163
  };
738
1164
  }
739
1165
  return {
740
- _meta: META,
1166
+ _meta: tools_META,
741
1167
  scannedFiles: Object.keys(fileContents).length,
742
1168
  findingCount: findings.length,
743
1169
  findings,
@@ -763,7 +1189,7 @@ const query_taint = {
763
1189
  async handler({ source, sink, limit, offset }, ctx) {
764
1190
  const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
765
1191
  if (!scan) {
766
- return { _meta: META, hasResult: false, status, message: `No usable scan state (${status}).` };
1192
+ return { _meta: tools_META, hasResult: false, status, message: `No usable scan state (${status}).` };
767
1193
  }
768
1194
  const lim = Number.isInteger(limit) ? Math.min(50, Math.max(1, limit)) : 10;
769
1195
  const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
@@ -778,13 +1204,13 @@ const query_taint = {
778
1204
  const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
779
1205
  return hay.includes(srcL) && hay.includes(sinkL);
780
1206
  });
781
- const page = all.slice(off, off + lim).map(f => redactFinding({
1207
+ const page = all.slice(off, off + lim).map(f => (0,redact/* redactFinding */.lE)({
782
1208
  id: f.id, severity: f.severity, file: f.file, line: f.line,
783
1209
  title: f.title || f.vuln, description: f.description,
784
1210
  trace: f.trace || null,
785
1211
  }));
786
1212
  return {
787
- _meta: META,
1213
+ _meta: tools_META,
788
1214
  hasResult: true,
789
1215
  integrity: status,
790
1216
  scanStartedAt: scan.startedAt || scan.meta?.startedAt || null,
@@ -816,7 +1242,7 @@ const explain_finding = {
816
1242
  if (!scan) throw new Error(`No usable scan state (${status}).`);
817
1243
  const f = _findById(scan, finding_id);
818
1244
  if (!f) throw new Error(`Finding not found: ${finding_id}`);
819
- const redacted = redactFinding({
1245
+ const redacted = (0,redact/* redactFinding */.lE)({
820
1246
  id: f.id, severity: f.severity, file: f.file, line: f.line,
821
1247
  title: f.title || f.vuln, cwe: f.cwe,
822
1248
  description: f.description, remediation: f.remediation,
@@ -841,7 +1267,7 @@ const explain_finding = {
841
1267
  }
842
1268
  }
843
1269
  return {
844
- _meta: META,
1270
+ _meta: tools_META,
845
1271
  ...redacted,
846
1272
  trace: traceTrimmed,
847
1273
  traceOffload: traceMeta,
@@ -861,6 +1287,21 @@ const explain_finding = {
861
1287
  epssScore: typeof f.epssScore === 'number' ? f.epssScore : null,
862
1288
  epssPercentile: typeof f.epssPercentile === 'number' ? f.epssPercentile : null,
863
1289
  exploitedNow: !!f.exploitedNow,
1290
+ // Which commit introduced this finding. `includeEmail` stays at its
1291
+ // DEFAULT (false) unconditionally — unlike the JSON report there is no
1292
+ // operator-set env escape for it here, because the consumer is an
1293
+ // agent that has no business receiving a committer's email address.
1294
+ // `pseudonymize`, by contrast, IS read back from the same env var
1295
+ // report/index.js's `_normalizedProvenance` reads
1296
+ // (AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 / --pseudonymize-authors) —
1297
+ // fix-round item 4: an operator who set that policy was still getting
1298
+ // raw committer names (and, via providerEnrichment, raw reviewer
1299
+ // logins/CODEOWNERS lines) through this MCP surface because this call
1300
+ // passed no options object at all, silently defeating their policy at
1301
+ // this one output boundary while report/index.js honoured it.
1302
+ findingProvenance: f.findingProvenance ? (0,schema/* redactFindingProvenance */.As)(f.findingProvenance, {
1303
+ pseudonymize: process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1',
1304
+ }) : null,
864
1305
  };
865
1306
  },
866
1307
  };
@@ -868,7 +1309,7 @@ const explain_finding = {
868
1309
  // ─── apply_fix ───────────────────────────────────────────────────────────────
869
1310
  const apply_fix = {
870
1311
  name: 'apply_fix',
871
- description: 'Apply the stored replacement fix for a finding. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if its file path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
1312
+ description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification.',
872
1313
  inputSchema: {
873
1314
  type: 'object',
874
1315
  additionalProperties: false,
@@ -876,27 +1317,222 @@ const apply_fix = {
876
1317
  finding_id: { type: 'string', minLength: 1, maxLength: 256 },
877
1318
  confirm: { type: 'boolean' },
878
1319
  dry_run: { type: 'boolean' },
1320
+ patch: {
1321
+ type: 'object',
1322
+ additionalProperties: { type: 'string', maxLength: 500_000 },
1323
+ minProperties: 1, maxProperties: 8,
1324
+ },
1325
+ // Stage 6 correctness audit: same gap and same fix as verify_fix — the
1326
+ // honesty gate is reachable but was never wired to any real caller.
1327
+ // Here it's stronger than advisory: the inline re-verify below already
1328
+ // gates the WRITE on `verdict.ok`, and verifyFixCore's own `ok`
1329
+ // formula already folds in `honesty.ok` when fixMeta is supplied — so
1330
+ // passing it through here makes a dishonest fixMeta (hand-wave
1331
+ // residual, uncited false-positive verdict) block the write itself,
1332
+ // not just report a verdict.
1333
+ fixMeta: {
1334
+ type: 'object',
1335
+ additionalProperties: false,
1336
+ properties: {
1337
+ residual: { type: 'string', maxLength: 2000 },
1338
+ verdict: { type: 'string', maxLength: 64 },
1339
+ evidence: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 },
1340
+ signals: {
1341
+ type: 'object',
1342
+ additionalProperties: false,
1343
+ properties: {
1344
+ sinkSignatureChanged: { type: 'boolean' },
1345
+ allCallersRouted: { type: 'boolean' },
1346
+ testDiscriminates: { type: 'boolean' },
1347
+ rateLimitOnly: { type: 'boolean' },
1348
+ docsOnly: { type: 'boolean' },
1349
+ logOnlyNoReject: { type: 'boolean' },
1350
+ partialSanitization: { type: 'boolean' },
1351
+ },
1352
+ },
1353
+ // FR-307/FR-1002: this schema had `additionalProperties: false`
1354
+ // and never declared `approval` — the property apply-fix-
1355
+ // service.js's high-impact-change gate has required since FR-307
1356
+ // was built. A real MCP caller supplying fixMeta.approval was
1357
+ // rejected by validate.js at the schema layer before the handler
1358
+ // ever ran, silently making the approval gate (and FR-1002's
1359
+ // identity check layered on it) unreachable from this tool's
1360
+ // only real production entry point. See D-0024.
1361
+ approval: {
1362
+ type: 'object',
1363
+ additionalProperties: false,
1364
+ properties: {
1365
+ approvedBy: { type: 'string', minLength: 1, maxLength: 200 },
1366
+ reason: { type: 'string', minLength: 1, maxLength: 1000 },
1367
+ },
1368
+ },
1369
+ // FR-1003: separation-of-duties. Self-reported the same way
1370
+ // approvedBy is — this tool has no way to determine who actually
1371
+ // wrote a patch, so `author` is a claim, checked against a
1372
+ // configurable policy the same way `approval` is.
1373
+ author: { type: 'string', minLength: 1, maxLength: 200 },
1374
+ },
1375
+ },
879
1376
  },
880
1377
  required: ['finding_id', 'confirm'],
881
1378
  },
882
- async handler({ finding_id, confirm, dry_run = false }, ctx) {
1379
+ async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null }, ctx) {
883
1380
  if (confirm !== true) {
884
- return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
1381
+ return { _meta: tools_META, applied: false, reason: 'apply_fix requires confirm: true.' };
885
1382
  }
886
1383
  const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
887
1384
  if (!scan) {
888
- return { _meta: META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
1385
+ return { _meta: tools_META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
889
1386
  }
890
1387
  const f = _findById(scan, finding_id);
891
- if (!f) return { _meta: META, applied: false, reason: `Finding not found: ${finding_id}` };
1388
+ if (!f) return { _meta: tools_META, applied: false, reason: `Finding not found: ${finding_id}` };
892
1389
  if (f._shadow === true) {
893
- return { _meta: META, applied: false, reason: 'shadow findings cannot be auto-applied' };
1390
+ return { _meta: tools_META, applied: false, reason: 'shadow findings cannot be auto-applied' };
894
1391
  }
1392
+
1393
+ // #3 — verifier-approved patch path. When the caller supplies `patch` (a
1394
+ // files map, same shape as verify_fix), apply_fix re-runs the verifier
1395
+ // INLINE and writes only if it passes: the original finding's stableId is
1396
+ // gone, no new ≥medium finding was introduced, and lint is clean. This lets
1397
+ // a deterministic OR LLM-synthesized patch be applied for the ~100% of
1398
+ // findings that ship only a template/description (no stored fix.replacement).
1399
+ // Security: all existing gates hold (confirm, last-scan HMAC, reserved
1400
+ // paths, confinement, fix-history backup + attempt budget); the write is
1401
+ // additionally gated on a FRESH verification, so a stale/forged patch can't
1402
+ // slip through — there is no token to replay, the verify runs here and now.
1403
+ if (patch && typeof patch === 'object' && Object.keys(patch).length) {
1404
+ if (!f.stableId) {
1405
+ return { _meta: tools_META, applied: false, reason: 'finding has no stableId — cannot verify a patch against it' };
1406
+ }
1407
+ const confinedAbs = {};
1408
+ for (const [rel, content] of Object.entries(patch)) {
1409
+ let abs;
1410
+ try { abs = _confine(ctx.sessionRoot, rel, 'patch key'); }
1411
+ catch (e) { return { _meta: tools_META, applied: false, reason: `path-escape refused: ${e.message}` }; }
1412
+ if (_isReservedWritePath(ctx.sessionRoot, abs)) {
1413
+ return { _meta: tools_META, applied: false, reason: `reserved path refused: ${rel}` };
1414
+ }
1415
+ confinedAbs[rel] = { abs, content: String(content) };
1416
+ }
1417
+ // Inline re-verify — the load-bearing gate. Must pass to write.
1418
+ let verdict;
1419
+ try {
1420
+ const _files = Object.fromEntries(Object.entries(confinedAbs).map(([rel, v]) => [rel, v.content]));
1421
+ if (process.env.AGENTIC_SECURITY_FIX_RUN_TESTS === '1') {
1422
+ // Addition #7 — connect the closed-loop verifier: add the project test
1423
+ // suite as a fourth verification leg (scan + lint + tests). Opt-in
1424
+ // because many repos have no runner and we must not fail-closed by
1425
+ // default. Normalized to the scan+lint verdict shape used below.
1426
+ const { verifyFixWithTests } = await __webpack_require__.e(/* import() */ 4113).then(__webpack_require__.bind(__webpack_require__, 4113));
1427
+ const t = await verifyFixWithTests({ scanRoot: ctx.sessionRoot, originalFindingStableId: f.stableId, files: _files });
1428
+ verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict };
1429
+ } else {
1430
+ const verifyFixCore = await getVerifyFixCore();
1431
+ verdict = await verifyFixCore({
1432
+ scanRoot: ctx.sessionRoot,
1433
+ originalFindingStableId: f.stableId,
1434
+ files: _files,
1435
+ fixMeta,
1436
+ });
1437
+ }
1438
+ } catch (e) {
1439
+ return { _meta: tools_META, applied: false, reason: `patch verification failed: ${e.message}` };
1440
+ }
1441
+ if (!verdict.ok) {
1442
+ return {
1443
+ _meta: tools_META, applied: false,
1444
+ reason: `patch rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
1445
+ verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
1446
+ };
1447
+ }
1448
+ // FR-307/FR-1002/D-0024: this caller-supplied-patch branch writes via
1449
+ // applyFixHistory() directly and never called applyVerifiedFix() — so
1450
+ // the high-impact-change approval gate (auth/authZ/crypto/PII/schema/
1451
+ // infra-privilege/public-API) built for the OTHER apply_fix branch
1452
+ // (stored fix.replacement) never ran here at all, for any input. Since
1453
+ // this is the branch the tool's own description calls the one that
1454
+ // covers "~100% of findings that ship only a template," that gap was
1455
+ // the larger of the two found this cycle. Same before/after content
1456
+ // shape `apply-fix-service.js` already uses — read-first-in-try/catch
1457
+ // (D-0012), never existsSync-then-readFileSync.
1458
+ const filesForMaterialClassification = {};
1459
+ for (const [rel, v] of Object.entries(confinedAbs)) {
1460
+ let before = '';
1461
+ try { before = await promises_.readFile(v.abs, 'utf8'); } catch { /* new file — before stays '' */ }
1462
+ filesForMaterialClassification[rel] = { before, after: v.content };
1463
+ }
1464
+ const materialClassification = (0,material_change/* classifyFixMaterialRisk */.kz)(filesForMaterialClassification);
1465
+ if (dry_run) {
1466
+ return { _meta: tools_META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, materialClassification };
1467
+ }
1468
+ if (materialClassification.highImpactCategories.length) {
1469
+ const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
1470
+ const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
1471
+ typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
1472
+ typeof approval.reason === 'string' && approval.reason.trim().length > 0);
1473
+ if (!hasApprovalEvidence) {
1474
+ return {
1475
+ _meta: tools_META, applied: false,
1476
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
1477
+ materialClassification,
1478
+ };
1479
+ }
1480
+ const approverRegistry = (0,approver_registry.loadApproverRegistry)(ctx.sessionRoot);
1481
+ const requiredRoles = (0,approver_registry/* requiredRolesFor */.K)(approverRegistry, materialClassification.highImpactCategories);
1482
+ const identityCheck = (0,approver_registry.verifyApprover)(approverRegistry, approval.approvedBy, requiredRoles);
1483
+ if (!identityCheck.verified) {
1484
+ return {
1485
+ _meta: tools_META, applied: false,
1486
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${identityCheck.reason}`,
1487
+ materialClassification,
1488
+ };
1489
+ }
1490
+ // FR-1003: separation-of-duties, same no-op-unless-configured gate
1491
+ // as apply-fix-service.js's own copy — see approver-registry.js.
1492
+ const sodCheck = (0,approver_registry.checkSeparationOfDuties)(approverRegistry, fixMeta?.author, approval.approvedBy);
1493
+ if (!sodCheck.ok) {
1494
+ return {
1495
+ _meta: tools_META, applied: false,
1496
+ reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${sodCheck.reason}`,
1497
+ materialClassification,
1498
+ };
1499
+ }
1500
+ }
1501
+ const written = [];
1502
+ try {
1503
+ for (const [rel, v] of Object.entries(confinedAbs)) {
1504
+ const fileExisted = external_node_fs_.existsSync(v.abs);
1505
+ const originalContent = fileExisted ? await promises_.readFile(v.abs, 'utf8') : '';
1506
+ const entry = await (0,fix_history/* applyFix */.oM)({
1507
+ scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content, fileExisted,
1508
+ findingId: f.id, stableId: f.stableId, ruleId: f.ruleId || f.cwe || f.family || null, vuln: f.vuln || f.title || null,
1509
+ findingProvenance: f.findingProvenance || null,
1510
+ });
1511
+ written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
1512
+ }
1513
+ } catch (e) {
1514
+ // FR-306: roll back every file THIS batch already wrote before the
1515
+ // failure — applyFixHistory already restored the one file that just
1516
+ // failed; this covers the rest, so a multi-file patch never leaves
1517
+ // some files patched and others not.
1518
+ for (const w of written) {
1519
+ try { await (0,fix_history/* revertEntryById */.rJ)(ctx.sessionRoot, w.historyId); } catch { /* best-effort; original error still propagates below */ }
1520
+ }
1521
+ if (e && e.name === 'FixAttemptBudgetExceededError') {
1522
+ return { _meta: tools_META, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
1523
+ }
1524
+ throw e;
1525
+ }
1526
+ let acceptance = null;
1527
+ try { acceptance = (0,fix_history/* fixAcceptanceRate */.XR)(ctx.sessionRoot); } catch { /* best-effort */ }
1528
+ return { _meta: tools_META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance, materialClassification };
1529
+ }
1530
+
895
1531
  if (typeof f.fix?.replacement !== 'string') {
896
1532
  // Premortem #2: templates are patch-shaped text. Same reasoning as
897
1533
  // the replacement path — do NOT pass through redactString here.
898
1534
  return {
899
- _meta: META, applied: false,
1535
+ _meta: tools_META, applied: false,
900
1536
  reason: 'No full replacement available — only a template. Apply the template manually.',
901
1537
  template: f.fix?.code || '',
902
1538
  file: f.file, line: f.line,
@@ -905,19 +1541,19 @@ const apply_fix = {
905
1541
  let absFile;
906
1542
  try { absFile = _confine(ctx.sessionRoot, f.file, 'finding.file'); }
907
1543
  catch (e) {
908
- return { _meta: META, applied: false, reason: `path-escape refused: ${e.message}` };
1544
+ return { _meta: tools_META, applied: false, reason: `path-escape refused: ${e.message}` };
909
1545
  }
910
1546
  if (_isReservedWritePath(ctx.sessionRoot, absFile)) {
911
- return { _meta: META, applied: false, reason: `reserved path refused: writes to .git/, .agentic-security/, or node_modules/ are not permitted via apply_fix` };
1547
+ return { _meta: tools_META, applied: false, reason: `reserved path refused: writes to .git/, .agentic-security/, or node_modules/ are not permitted via apply_fix` };
912
1548
  }
913
1549
  if (!external_node_fs_.existsSync(absFile)) {
914
- return { _meta: META, applied: false, reason: `File not found: ${absFile}` };
1550
+ return { _meta: tools_META, applied: false, reason: `File not found: ${absFile}` };
915
1551
  }
916
1552
  const originalContent = await promises_.readFile(absFile, 'utf8');
917
1553
 
918
1554
  if (dry_run) {
919
1555
  return {
920
- _meta: META,
1556
+ _meta: tools_META,
921
1557
  applied: false, dryRun: true,
922
1558
  file: f.file,
923
1559
  originalSize: originalContent.length,
@@ -926,41 +1562,47 @@ const apply_fix = {
926
1562
  };
927
1563
  }
928
1564
 
929
- let entry;
930
- try {
931
- entry = await (0,fix_history/* applyFix */.oM)({
932
- scanRoot: ctx.sessionRoot,
933
- file: f.file,
934
- originalContent,
935
- newContent: f.fix.replacement,
936
- findingId: f.id,
937
- stableId: f.stableId || null, // premortem 4R-8
938
- ruleId: f.rule || null,
939
- vuln: f.vuln || f.title || null,
940
- });
941
- } catch (e) {
942
- // Harness-engineering: step-budget refusal (post-derived). The
943
- // deterministic layer enforces at-most-N attempts per stableId. When
944
- // exceeded, surface it as a structured `budget-exceeded` outcome the
945
- // agent can recognize — not a generic error.
946
- if (e && e.name === 'FixAttemptBudgetExceededError') {
947
- return {
948
- _meta: META,
949
- applied: false,
950
- reason: `budget-exceeded: ${e.message}`,
951
- budgetExceeded: true,
952
- attempts: e.attempts,
953
- maxAttempts: e.max,
954
- key: e.key,
955
- };
1565
+ // FR-301/A-08 (assurance-hardening PRD): this branch used to write
1566
+ // f.fix.replacement straight to disk with NO fresh verification — no
1567
+ // rescan, no lint, nothing confirming the stored replacement actually
1568
+ // closes the finding it claims to fix. The caller-patch branch above
1569
+ // already required this; there is no reason a STORED fix should be
1570
+ // trusted more than a caller-supplied one just because it shipped with
1571
+ // the finding. Routed through the same applyVerifiedFix() service the
1572
+ // CLI's `fix --apply` now also uses (src/fix/apply-fix-service.js) —
1573
+ // confinement/reserved-path are re-checked there too (harmless
1574
+ // redundancy with the dry_run preview above, kept for that preview's
1575
+ // size-diff shape) but the load-bearing addition is the verification
1576
+ // gate before the write.
1577
+ if (!f.stableId) {
1578
+ return { _meta: tools_META, applied: false, reason: 'finding has no stableId — cannot verify a stored fix against it' };
1579
+ }
1580
+ const result = await (0,apply_fix_service/* applyVerifiedFix */.On)({
1581
+ scanRoot: ctx.sessionRoot,
1582
+ finding: f,
1583
+ files: { [f.file]: f.fix.replacement },
1584
+ fixMeta,
1585
+ });
1586
+ if (!result.ok) {
1587
+ if (result.budgetExceeded) {
1588
+ return { _meta: tools_META, applied: false, reason: result.reason, budgetExceeded: true, attempts: result.attempts, maxAttempts: result.maxAttempts, key: result.key };
956
1589
  }
957
- throw e;
1590
+ return { _meta: tools_META, applied: false, reason: result.reason, verify: result.verify || null };
958
1591
  }
959
1592
  // R25 (PRD §5): surface the running auto-fix acceptance rate after each
960
1593
  // applied fix, so the closed loop reports its own success metric.
961
1594
  let acceptance = null;
962
1595
  try { acceptance = (0,fix_history/* fixAcceptanceRate */.XR)(ctx.sessionRoot); } catch { /* metric is best-effort */ }
963
- return { _meta: META, applied: true, historyId: entry.id, file: f.file, backupPath: entry.backupPath, integrity: status, attemptOrdinal: entry.attemptOrdinal, acceptance };
1596
+ const entry = result.written[0];
1597
+ return {
1598
+ // FR-305: verifiedFull distinguishes "every required leg (lint, tests)
1599
+ // genuinely ran and passed" from "passed, but a required leg was
1600
+ // skipped or unavailable" — verified:true alone conflates them.
1601
+ _meta: tools_META, applied: true, verified: true, verifiedFull: result.verifiedFull,
1602
+ historyId: entry.historyId, file: entry.file, backupPath: entry.backupPath,
1603
+ integrity: status, attemptOrdinal: entry.attemptOrdinal, acceptance,
1604
+ verify: result.verify,
1605
+ };
964
1606
  },
965
1607
  };
966
1608
 
@@ -972,7 +1614,7 @@ const apply_fix = {
972
1614
  // proceed with apply_fix.
973
1615
  const verify_fix = {
974
1616
  name: 'verify_fix',
975
- description: 'Verify a proposed patch before applying. Re-scans the patched files in memory and runs the project linter. Returns { ok, rescan, lint, summary }. No filesystem writes.',
1617
+ description: 'Verify a proposed patch before applying. Re-scans the patched files in memory, runs the project linter, runs the project test suite, checks fix honesty (FULL/MITIGATION/WORKAROUND) when fixMeta is supplied, and re-runs the PoC when one exists. Returns { ok, rescan, lint, tests, honesty, poc, summary }. Does not write to the target project’s own files, but DOES append one record per attempt to .agentic-security/fix-metrics.jsonl for the measured fix-loop.',
976
1618
  inputSchema: {
977
1619
  type: 'object',
978
1620
  additionalProperties: false,
@@ -984,36 +1626,98 @@ const verify_fix = {
984
1626
  minProperties: 1,
985
1627
  maxProperties: 8,
986
1628
  },
1629
+ // Stage 6 correctness audit: posture/fix-honesty-gate.js's deterministic
1630
+ // honesty checks (vague-assurance residual prose, unbacked false-
1631
+ // positive verdicts, tier/residual consistency) were fully built and
1632
+ // fix-verify.js already consulted them when given a `fixMeta` — but
1633
+ // this schema never had a `fixMeta` property, so no call through the
1634
+ // MCP surface could ever supply one. The gate can only run against
1635
+ // claims the AGENT self-reports (residual risk, verdict, evidence,
1636
+ // completeness signals) — nothing here is server-computable — so
1637
+ // fixing this meant exposing the property, not inventing a lookup.
1638
+ fixMeta: {
1639
+ type: 'object',
1640
+ additionalProperties: false,
1641
+ properties: {
1642
+ residual: { type: 'string', maxLength: 2000 },
1643
+ verdict: { type: 'string', maxLength: 64 },
1644
+ evidence: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 },
1645
+ signals: {
1646
+ type: 'object',
1647
+ additionalProperties: false,
1648
+ properties: {
1649
+ sinkSignatureChanged: { type: 'boolean' },
1650
+ allCallersRouted: { type: 'boolean' },
1651
+ testDiscriminates: { type: 'boolean' },
1652
+ rateLimitOnly: { type: 'boolean' },
1653
+ docsOnly: { type: 'boolean' },
1654
+ logOnlyNoReject: { type: 'boolean' },
1655
+ partialSanitization: { type: 'boolean' },
1656
+ },
1657
+ },
1658
+ },
1659
+ },
987
1660
  },
988
1661
  required: ['stable_id', 'files'],
989
1662
  },
990
- async handler({ stable_id, files }, ctx) {
1663
+ async handler({ stable_id, files, fixMeta }, ctx) {
991
1664
  // Confine every file path before passing to the verifier.
992
1665
  const confined = {};
993
1666
  for (const [relPath, content] of Object.entries(files || {})) {
994
1667
  try {
995
1668
  _confine(ctx.sessionRoot, relPath, 'files key');
996
1669
  } catch (e) {
997
- return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` };
1670
+ return { _meta: tools_META, ok: false, reason: `path-escape refused: ${e.message}` };
998
1671
  }
999
1672
  confined[relPath] = String(content);
1000
1673
  }
1001
1674
  try {
1675
+ // The PoC-re-check leg (verifyFixCore's `pocLeg`) needs a `poc` param
1676
+ // to do anything — until now nothing supplied one, so it always
1677
+ // reported {status:'not-requested'} through this surface (see
1678
+ // posture/CLAUDE.md's disclosure). Rather than widening inputSchema
1679
+ // to make the CALLER pass PoC data back, look it up server-side: the
1680
+ // scan pipeline already attaches an HTTP-shaped f.poc to matching
1681
+ // findings by default (engine.js's annotatePocs), and last-scan.json
1682
+ // already carries it under the same stableId this handler receives.
1683
+ // Best-effort: a missing/unsigned/tampered scan just means no PoC is
1684
+ // available to re-check, not a verify_fix failure — the rescan/lint/
1685
+ // tests legs below are independent of this and still apply.
1686
+ let poc = null;
1687
+ try {
1688
+ const { scan: lastScan } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
1689
+ const orig = lastScan && (lastScan.findings || []).find(f => f.stableId === stable_id);
1690
+ if (orig && orig.poc && orig.poc.code) poc = { ...orig.poc, finding: orig };
1691
+ } catch { /* best-effort lookup; poc stays null */ }
1692
+
1002
1693
  const verifyFixCore = await getVerifyFixCore();
1003
1694
  const r = await verifyFixCore({
1004
1695
  scanRoot: ctx.sessionRoot,
1005
1696
  originalFindingStableId: stable_id,
1006
1697
  files: confined,
1698
+ poc,
1699
+ fixMeta,
1007
1700
  });
1008
1701
  return {
1009
- _meta: META,
1702
+ _meta: tools_META,
1010
1703
  ok: r.ok,
1011
1704
  rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
1012
- lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: redactString(r.lint.output || '').slice(0, 1500) },
1705
+ lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: (0,redact/* redactString */.rd)(r.lint.output || '').slice(0, 1500) },
1706
+ // verifyFix computes five legs, not two — tests/honesty/poc were
1707
+ // being silently dropped here, leaving an agent with no structured
1708
+ // way to see WHY verification failed when the failure was in one
1709
+ // of those three (only the free-text summary carried it).
1710
+ // test-runner.js's runProjectTests never returns raw stdout/stderr,
1711
+ // so no redaction is needed there; honesty.violations are static,
1712
+ // code-generated strings; poc.reason is redacted defensively since
1713
+ // it can echo proof-harness detail derived from scanned source.
1714
+ tests: r.tests,
1715
+ honesty: r.honesty,
1716
+ poc: r.poc ? { ...r.poc, reason: r.poc.reason ? (0,redact/* redactString */.rd)(r.poc.reason) : r.poc.reason } : r.poc,
1013
1717
  summary: r.summary,
1014
1718
  };
1015
1719
  } catch (e) {
1016
- return { _meta: META, ok: false, reason: `verify_fix failed: ${e.message}` };
1720
+ return { _meta: tools_META, ok: false, reason: `verify_fix failed: ${e.message}` };
1017
1721
  }
1018
1722
  },
1019
1723
  };
@@ -1036,11 +1740,11 @@ const synthesize_fix = {
1036
1740
  async handler({ finding_id }, ctx) {
1037
1741
  const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
1038
1742
  if (!scan) {
1039
- return { _meta: META, ok: false, reason: `last-scan.json failed integrity check: ${status}` };
1743
+ return { _meta: tools_META, ok: false, reason: `last-scan.json failed integrity check: ${status}` };
1040
1744
  }
1041
1745
  const f = _findById(scan, finding_id);
1042
- if (!f) return { _meta: META, ok: false, reason: `Finding not found: ${finding_id}` };
1043
- if (f._shadow === true) return { _meta: META, ok: false, reason: 'shadow findings have no synthesized fix' };
1746
+ if (!f) return { _meta: tools_META, ok: false, reason: `Finding not found: ${finding_id}` };
1747
+ if (f._shadow === true) return { _meta: tools_META, ok: false, reason: 'shadow findings have no synthesized fix' };
1044
1748
  const fix = f.fix || {};
1045
1749
  const hasReplacement = typeof fix.replacement === 'string' && fix.replacement.length > 0;
1046
1750
  // Patch bounds: count files touched + LoC delta.
@@ -1055,6 +1759,20 @@ const synthesize_fix = {
1055
1759
  locDelta = Math.abs(fix.replacement.split('\n').length - orig.split('\n').length);
1056
1760
  }
1057
1761
  const oversized = touchedFiles > 3 || locDelta > 100;
1762
+ // #1 — deterministic autofix: for classes with a safe context-independent
1763
+ // swap (weak hash, TLS verify-off), materialize a full-file patch from the
1764
+ // live file. The agent passes `autofix.patch` straight to apply_fix, which
1765
+ // re-verifies it (rescan-clean + no new ≥medium + lint) before writing — so
1766
+ // even a mis-attributed swap can't land a bad edit. No stored replacement,
1767
+ // no per-finding bloat in last-scan.json.
1768
+ let autofix = null;
1769
+ if (!hasReplacement) {
1770
+ try {
1771
+ const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
1772
+ const det = synthesizeDeterministicPatch(f, external_node_fs_.readFileSync(abs, 'utf8'));
1773
+ if (det) autofix = { deterministic: true, ruleId: det.ruleId, patch: det.patch };
1774
+ } catch { /* best-effort — no file / no rule → no autofix */ }
1775
+ }
1058
1776
  // Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
1059
1777
  // not a finding excerpt. Running it through redactString silently corrupts
1060
1778
  // valid patches whose content happens to match a secret-shape (e.g. a
@@ -1062,7 +1780,7 @@ const synthesize_fix = {
1062
1780
  // verbatim. Snippet/description/etc. continue to be redacted in
1063
1781
  // explain_finding / scan_diff — that's the right surface for redaction.
1064
1782
  return {
1065
- _meta: META,
1783
+ _meta: tools_META,
1066
1784
  ok: true,
1067
1785
  stable_id: f.stableId || null,
1068
1786
  file: f.file, line: f.line,
@@ -1071,9 +1789,21 @@ const synthesize_fix = {
1071
1789
  hasReplacement,
1072
1790
  replacement: hasReplacement ? fix.replacement : null,
1073
1791
  template: fix.code || null,
1792
+ autofix,
1793
+ // #15 — the regression test the scan annotator already generated for this
1794
+ // finding (present when a PoC was built). Surfaced here so the fix flow
1795
+ // writes the test alongside the patch; fix-verify-loop then runs it, so an
1796
+ // applied fix ships with a test that fails pre-fix and passes post-fix.
1797
+ regression_test: f.regression_test || null,
1074
1798
  remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
1075
1799
  patchBounds: { touchedFiles, locDelta, oversized },
1076
- recommendsFixPlan: oversized && !hasReplacement,
1800
+ // oversized can only be true when hasReplacement is true (locDelta is
1801
+ // only computed in that branch, and touchedFiles never varies) — a
1802
+ // `!hasReplacement` conjunct here was a structural contradiction that
1803
+ // made this permanently false. The correct signal: the stored
1804
+ // replacement itself is too big to trust auto-applying, and there's
1805
+ // no safer deterministic alternative.
1806
+ recommendsFixPlan: oversized && !autofix,
1077
1807
  };
1078
1808
  },
1079
1809
  };
@@ -1100,14 +1830,14 @@ const find_rule_module = {
1100
1830
  },
1101
1831
  async handler({ cwe, family }, ctx) {
1102
1832
  if (!cwe && !family) {
1103
- return { _meta: META, ok: false, reason: 'provide cwe (e.g. "CWE-89") or family (e.g. "sql-injection")' };
1833
+ return { _meta: tools_META, ok: false, reason: 'provide cwe (e.g. "CWE-89") or family (e.g. "sql-injection")' };
1104
1834
  }
1105
1835
  // Pattern enforcement — the mini-schema validator doesn't do `pattern`.
1106
1836
  if (cwe && !/^CWE-\d+$/.test(cwe)) {
1107
- return { _meta: META, ok: false, reason: 'cwe must match /^CWE-\\d+$/ (e.g. "CWE-89")' };
1837
+ return { _meta: tools_META, ok: false, reason: 'cwe must match /^CWE-\\d+$/ (e.g. "CWE-89")' };
1108
1838
  }
1109
1839
  if (family && !/^[a-z][a-z0-9-]+$/.test(family)) {
1110
- return { _meta: META, ok: false, reason: 'family must match /^[a-z][a-z0-9-]+$/ (e.g. "sql-injection")' };
1840
+ return { _meta: tools_META, ok: false, reason: 'family must match /^[a-z][a-z0-9-]+$/ (e.g. "sql-injection")' };
1111
1841
  }
1112
1842
  const sessionRoot = ctx.sessionRoot;
1113
1843
  const roots = [
@@ -1161,7 +1891,7 @@ const find_rule_module = {
1161
1891
  if (hits.length >= 20) break;
1162
1892
  }
1163
1893
  return {
1164
- _meta: META,
1894
+ _meta: tools_META,
1165
1895
  ok: true,
1166
1896
  query: { cwe: cwe || null, family: family || null },
1167
1897
  hitCount: hits.length,
@@ -1204,12 +1934,14 @@ const append_scratchpad = {
1204
1934
  },
1205
1935
  async handler({ path: relPath, content }, ctx) {
1206
1936
  const v = _validateScratchpadPath(relPath);
1207
- if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
1208
- const abs = _scratchpadAbs(ctx.sessionRoot, relPath);
1937
+ if (!v.ok) return { _meta: tools_META, ok: false, reason: v.reason };
1938
+ let abs;
1939
+ try { abs = _scratchpadAbs(ctx.sessionRoot, relPath); }
1940
+ catch (e) { return { _meta: tools_META, ok: false, reason: `path-escape refused: ${e.message}` }; }
1209
1941
  const total = _scratchpadTotalBytes(ctx.sessionRoot);
1210
1942
  if (total + content.length > SCRATCHPAD_MAX_TOTAL_BYTES) {
1211
1943
  return {
1212
- _meta: META, ok: false,
1944
+ _meta: tools_META, ok: false,
1213
1945
  reason: `scratchpad-total-exceeded: ${total} + ${content.length} > ${SCRATCHPAD_MAX_TOTAL_BYTES}. Clean up via "rm -rf .agentic-security/agent-scratchpad" or rotate sessions.`,
1214
1946
  };
1215
1947
  }
@@ -1217,7 +1949,7 @@ const append_scratchpad = {
1217
1949
  try { if (external_node_fs_.existsSync(abs)) existing = external_node_fs_.statSync(abs).size; } catch {}
1218
1950
  if (existing + content.length > SCRATCHPAD_MAX_FILE_BYTES) {
1219
1951
  return {
1220
- _meta: META, ok: false,
1952
+ _meta: tools_META, ok: false,
1221
1953
  reason: `scratchpad-file-exceeded: ${existing} + ${content.length} > ${SCRATCHPAD_MAX_FILE_BYTES}. Start a new file.`,
1222
1954
  };
1223
1955
  }
@@ -1225,12 +1957,12 @@ const append_scratchpad = {
1225
1957
  external_node_fs_.mkdirSync(external_node_path_.dirname(abs), { recursive: true });
1226
1958
  external_node_fs_.appendFileSync(abs, content);
1227
1959
  return {
1228
- _meta: META, ok: true,
1960
+ _meta: tools_META, ok: true,
1229
1961
  path: relPath, bytesWritten: content.length, fileSize: existing + content.length,
1230
1962
  scratchpadTotal: total + content.length,
1231
1963
  };
1232
1964
  } catch (e) {
1233
- return { _meta: META, ok: false, reason: `write-failed: ${e.message}` };
1965
+ return { _meta: tools_META, ok: false, reason: `write-failed: ${e.message}` };
1234
1966
  }
1235
1967
  },
1236
1968
  };
@@ -1250,12 +1982,14 @@ const read_scratchpad = {
1250
1982
  },
1251
1983
  async handler({ path: relPath, offset, limit }, ctx) {
1252
1984
  const v = _validateScratchpadPath(relPath);
1253
- if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
1254
- const abs = _scratchpadAbs(ctx.sessionRoot, relPath);
1255
- if (!external_node_fs_.existsSync(abs)) return { _meta: META, ok: false, reason: 'not-found' };
1985
+ if (!v.ok) return { _meta: tools_META, ok: false, reason: v.reason };
1986
+ let abs;
1987
+ try { abs = _scratchpadAbs(ctx.sessionRoot, relPath); }
1988
+ catch (e) { return { _meta: tools_META, ok: false, reason: `path-escape refused: ${e.message}` }; }
1989
+ if (!external_node_fs_.existsSync(abs)) return { _meta: tools_META, ok: false, reason: 'not-found' };
1256
1990
  let stat;
1257
- try { stat = external_node_fs_.statSync(abs); } catch (e) { return { _meta: META, ok: false, reason: `stat-failed: ${e.message}` }; }
1258
- if (!stat.isFile()) return { _meta: META, ok: false, reason: 'not-a-file' };
1991
+ try { stat = external_node_fs_.statSync(abs); } catch (e) { return { _meta: tools_META, ok: false, reason: `stat-failed: ${e.message}` }; }
1992
+ if (!stat.isFile()) return { _meta: tools_META, ok: false, reason: 'not-a-file' };
1259
1993
  const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
1260
1994
  const lim = Number.isInteger(limit) ? Math.min(64 * 1024, Math.max(1, limit)) : 4096;
1261
1995
  let buf;
@@ -1265,10 +1999,10 @@ const read_scratchpad = {
1265
1999
  const read = external_node_fs_.readSync(fd, tmp, 0, lim, off);
1266
2000
  external_node_fs_.closeSync(fd);
1267
2001
  buf = tmp.slice(0, read);
1268
- } catch (e) { return { _meta: META, ok: false, reason: `read-failed: ${e.message}` }; }
2002
+ } catch (e) { return { _meta: tools_META, ok: false, reason: `read-failed: ${e.message}` }; }
1269
2003
  const text = buf.toString('utf8');
1270
2004
  return {
1271
- _meta: META, ok: true,
2005
+ _meta: tools_META, ok: true,
1272
2006
  path: relPath,
1273
2007
  offset: off, limit: lim, bytesRead: buf.length,
1274
2008
  totalSize: stat.size,
@@ -1285,6 +2019,7 @@ const read_scratchpad = {
1285
2019
 
1286
2020
 
1287
2021
 
2022
+
1288
2023
  const append_agents_memory = {
1289
2024
  name: 'append_agents_memory',
1290
2025
  description: 'Append a short narrative entry to AGENTS.md — agent-authored continual-learning notes. Use at session end to record "what worked / what didn\'t / what I\'d try differently next time" so the next agent can pick up the lesson. Bounded: 2 KB per entry, 20 KB total before rotation to AGENTS.md.archive. Use sparingly — narrative, not structured data.',
@@ -1299,7 +2034,7 @@ const append_agents_memory = {
1299
2034
  },
1300
2035
  async handler({ agent, body }, ctx) {
1301
2036
  const r = appendAgentsMemory(ctx.sessionRoot, { agent, body });
1302
- return { _meta: META, ...r };
2037
+ return { _meta: tools_META, ...r };
1303
2038
  },
1304
2039
  };
1305
2040
 
@@ -1315,16 +2050,16 @@ const read_agents_memory = {
1315
2050
  },
1316
2051
  async handler({ full }, ctx) {
1317
2052
  const body = readAgentsMemory(ctx.sessionRoot);
1318
- if (!body) return { _meta: META, present: false };
1319
- if (full) return { _meta: META, present: true, length: body.length, content: body };
2053
+ if (!body) return { _meta: tools_META, present: false };
2054
+ if (full) return { _meta: tools_META, present: true, length: body.length, content: body };
1320
2055
  // Tail-only — same logic as summarizeForSession but inlined to avoid a
1321
2056
  // second import surface.
1322
2057
  const limit = 6 * 1024;
1323
- if (body.length <= limit) return { _meta: META, present: true, length: body.length, content: body };
2058
+ if (body.length <= limit) return { _meta: tools_META, present: true, length: body.length, content: body };
1324
2059
  const tail = body.slice(-limit);
1325
2060
  const firstSection = tail.indexOf('\n## ');
1326
2061
  const slice = firstSection >= 0 ? tail.slice(firstSection) : tail;
1327
- return { _meta: META, present: true, length: body.length, truncated: true, content: slice };
2062
+ return { _meta: tools_META, present: true, length: body.length, truncated: true, content: slice };
1328
2063
  },
1329
2064
  };
1330
2065
 
@@ -1346,9 +2081,22 @@ const query_triage_memory = {
1346
2081
  },
1347
2082
  async handler({ query }, ctx) {
1348
2083
  const { queryMemory } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 1905));
1349
- const results = queryMemory(ctx.sessionRoot, query || '');
2084
+ const raw = queryMemory(ctx.sessionRoot, query || '');
2085
+ // Stage 6 correctness audit: this returned queryMemory's output
2086
+ // verbatim, with no redaction pass — every other tool that echoes
2087
+ // scanned-source-derived text redacts it (mcp/CLAUDE.md's "Adding a new
2088
+ // tool" step 3). Round-trip through redactString the same way
2089
+ // redactFinding already does for its own opaque `.trace` field: results
2090
+ // here mix shapes (a triage decision's free-text `reason`, a finding's
2091
+ // `vuln`/`family`/file path), so scrubbing the whole serialized
2092
+ // structure catches secret-shaped substrings regardless of which field
2093
+ // they landed in, rather than hardcoding a field allowlist that could
2094
+ // miss one.
2095
+ let results;
2096
+ try { results = JSON.parse((0,redact/* redactString */.rd)(JSON.stringify(raw))); }
2097
+ catch { results = raw; }
1350
2098
  return {
1351
- _meta: META,
2099
+ _meta: tools_META,
1352
2100
  count: results.length,
1353
2101
  results,
1354
2102
  };
@@ -1373,8 +2121,17 @@ const query_findings_memory = {
1373
2121
  required: ['query'],
1374
2122
  },
1375
2123
  async handler({ query }, ctx) {
1376
- const { queryFindingsMemory } = await __webpack_require__.e(/* import() */ 839).then(__webpack_require__.bind(__webpack_require__, 3839));
1377
- return { _meta: META, ...queryFindingsMemory(ctx.sessionRoot, query || '') };
2124
+ const { queryFindingsMemory } = await __webpack_require__.e(/* import() */ 3839).then(__webpack_require__.bind(__webpack_require__, 3839));
2125
+ const raw = queryFindingsMemory(ctx.sessionRoot, query || '');
2126
+ // Stage 6 correctness audit — same redaction gap and same fix as
2127
+ // query_triage_memory just above: this mixes four differently-shaped
2128
+ // result kinds (finding / triage / history / AGENTS.md text), so a
2129
+ // whole-structure redactString round-trip is applied rather than a
2130
+ // per-field allowlist that could miss one of the four shapes.
2131
+ let body;
2132
+ try { body = JSON.parse((0,redact/* redactString */.rd)(JSON.stringify(raw))); }
2133
+ catch { body = raw; }
2134
+ return { _meta: tools_META, ...body };
1378
2135
  },
1379
2136
  };
1380
2137
 
@@ -1395,7 +2152,7 @@ const lookup_cve = {
1395
2152
  },
1396
2153
  async handler({ cve }, _ctx) {
1397
2154
  const r = lookupCve(cve);
1398
- return { _meta: META, ...r };
2155
+ return { _meta: tools_META, ...r };
1399
2156
  },
1400
2157
  };
1401
2158
 
@@ -1413,9 +2170,9 @@ const query_cache_telemetry = {
1413
2170
  },
1414
2171
  async handler({ transcript_path } = {}, ctx) {
1415
2172
  const result = (0,cache_economics.analyzeTranscript)({ transcriptPath: transcript_path, projectDir: ctx?.sessionRoot || process.cwd() });
1416
- if (!result.ok) return { _meta: META, ok: false, reason: result.reason };
2173
+ if (!result.ok) return { _meta: tools_META, ok: false, reason: result.reason };
1417
2174
  return {
1418
- _meta: META,
2175
+ _meta: tools_META,
1419
2176
  ok: true,
1420
2177
  metrics: result.metrics,
1421
2178
  leaks: result.leaks,
@@ -1431,7 +2188,7 @@ const query_cache_telemetry = {
1431
2188
  // ecosystem's native --dry-run command. Safe to call any number of times.
1432
2189
  let _scaUpgrade;
1433
2190
  async function _getScaUpgrade() {
1434
- if (!_scaUpgrade) _scaUpgrade = await __webpack_require__.e(/* import() */ 333).then(__webpack_require__.bind(__webpack_require__, 5333));
2191
+ if (!_scaUpgrade) _scaUpgrade = await __webpack_require__.e(/* import() */ 5333).then(__webpack_require__.bind(__webpack_require__, 5333));
1435
2192
  return _scaUpgrade;
1436
2193
  }
1437
2194
  const synthesize_sca_upgrade = {
@@ -1451,11 +2208,11 @@ const synthesize_sca_upgrade = {
1451
2208
  const f = _findById(scan, finding_id);
1452
2209
  if (!f) throw new Error(`Finding not found: ${finding_id}`);
1453
2210
  if (f.type !== 'vulnerable_dep') {
1454
- return { _meta: META, ok: false, reason: 'finding is not an SCA vulnerable_dep — use synthesize_fix for SAST findings' };
2211
+ return { _meta: tools_META, ok: false, reason: 'finding is not an SCA vulnerable_dep — use synthesize_fix for SAST findings' };
1455
2212
  }
1456
2213
  const { planScaUpgrade } = await _getScaUpgrade();
1457
2214
  const plan = await planScaUpgrade({ scanRoot: ctx.sessionRoot, finding: f });
1458
- return { _meta: META, ...plan };
2215
+ return { _meta: tools_META, ...plan };
1459
2216
  },
1460
2217
  };
1461
2218
 
@@ -1482,24 +2239,24 @@ const apply_sca_upgrade = {
1482
2239
  },
1483
2240
  async handler({ finding_id, confirm, run_tests = true }, ctx) {
1484
2241
  if (confirm !== true) {
1485
- return { _meta: META, applied: false, reason: 'apply_sca_upgrade requires confirm: true.' };
2242
+ return { _meta: tools_META, applied: false, reason: 'apply_sca_upgrade requires confirm: true.' };
1486
2243
  }
1487
2244
  const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
1488
2245
  if (!scan) {
1489
- return { _meta: META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
2246
+ return { _meta: tools_META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
1490
2247
  }
1491
2248
  const f = _findById(scan, finding_id);
1492
- if (!f) return { _meta: META, applied: false, reason: `Finding not found: ${finding_id}` };
2249
+ if (!f) return { _meta: tools_META, applied: false, reason: `Finding not found: ${finding_id}` };
1493
2250
  if (f.type !== 'vulnerable_dep') {
1494
- return { _meta: META, applied: false, reason: 'finding is not an SCA vulnerable_dep — use apply_fix for SAST findings' };
2251
+ return { _meta: tools_META, applied: false, reason: 'finding is not an SCA vulnerable_dep — use apply_fix for SAST findings' };
1495
2252
  }
1496
2253
  const { applyScaUpgrade } = await _getScaUpgrade();
1497
2254
  const result = await applyScaUpgrade({ scanRoot: ctx.sessionRoot, finding: f, runTests: run_tests });
1498
- return { _meta: META, ...result };
2255
+ return { _meta: tools_META, ...result };
1499
2256
  },
1500
2257
  };
1501
2258
 
1502
- const ALL_TOOLS = [scan_diff, query_taint, explain_finding, apply_fix, verify_fix, synthesize_fix, find_rule_module, append_scratchpad, read_scratchpad, append_agents_memory, read_agents_memory, lookup_cve, synthesize_sca_upgrade, apply_sca_upgrade, query_triage_memory, query_findings_memory, query_cache_telemetry];
2259
+ const ALL_TOOLS = [scan_diff, query_taint, explain_finding, apply_fix, verify_fix, synthesize_fix, find_rule_module, append_scratchpad, read_scratchpad, append_agents_memory, read_agents_memory, lookup_cve, synthesize_sca_upgrade, apply_sca_upgrade, query_triage_memory, query_findings_memory, query_cache_telemetry, dataflow_get_graph, dataflow_get_node, dataflow_get_edge, dataflow_get_flow];
1503
2260
 
1504
2261
  ;// CONCATENATED MODULE: ./src/mcp/validate.js
1505
2262
  // Minimal JSON Schema validator — just the subset our tool schemas use.
@@ -1552,149 +2309,8 @@ function validate(schema, value, path = 'arguments') {
1552
2309
  }
1553
2310
  }
1554
2311
 
1555
- ;// CONCATENATED MODULE: ./src/mcp/audit.js
1556
- // Append-only audit log of MCP tool calls — OWASP MCP08.
1557
- //
1558
- // Format: one JSON object per line (NDJSON) at
1559
- // <sessionRoot>/.agentic-security/mcp-audit.log
1560
- //
1561
- // Each entry carries `prev` — the SHA-256 of the previous entry's serialized
1562
- // form. The first entry's prev is "GENESIS". Tampering with any line breaks
1563
- // the chain from that point forward; a reader can detect partial truncation
1564
- // or in-place edits.
1565
- //
1566
- // REMOTE SINK (post-recommendation #10). The local file alone cannot detect
1567
- // a total rewrite — an attacker with FS write can re-author the whole log
1568
- // with fresh hashes. Closing that blind spot requires an off-host witness.
1569
- // Set $AGENTIC_SECURITY_AUDIT_WEBHOOK to a POST endpoint; every entry is
1570
- // fire-and-forget POSTed there in addition to the local append. Failures
1571
- // to reach the webhook are best-effort — they NEVER block a tool call,
1572
- // because that would let a network outage become a denial of service. They
1573
- // DO get recorded as `_remoteSinkErr` on the local entry, so an operator
1574
- // reviewing the log later can spot a forging attempt that targeted the
1575
- // remote (any gap between local-sequence and remote-sequence is evidence).
1576
- //
1577
- // Argument blobs are redacted (OWASP MCP01/MCP10) so credentials passed in
1578
- // arguments cannot leak via the audit trail OR via the remote sink.
1579
-
1580
-
1581
-
1582
-
1583
-
1584
-
1585
- const MAX_ARG_BYTES = 1024;
1586
- const GENESIS = 'GENESIS';
1587
- const REMOTE_TIMEOUT_MS = 1500;
1588
-
1589
- // Per-process session ID (harness-anatomy #9). Stamped on every audit entry
1590
- // so downstream metrics can aggregate by session and surface outliers like
1591
- // "200 apply_fix calls in one session." The ID is `<pid>-<short-ts>` — not
1592
- // cryptographically unique, but enough to disambiguate concurrent runs on
1593
- // the same host. Stable for the lifetime of this Node process.
1594
- const SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;
1595
-
1596
- function _summarize(args) {
1597
- let s;
1598
- try { s = JSON.stringify(args); } catch { s = '<unserializable>'; }
1599
- s = redactArgsBlob(s);
1600
- if (s.length > MAX_ARG_BYTES) s = s.slice(0, MAX_ARG_BYTES) + `…(+${s.length - MAX_ARG_BYTES})`;
1601
- return s;
1602
- }
1603
-
1604
- function _sha(s) { return external_node_crypto_.createHash('sha256').update(s).digest('hex'); }
1605
-
1606
- function _readLastEntryHash(logFile) {
1607
- if (!external_node_fs_.existsSync(logFile)) return GENESIS;
1608
- try {
1609
- const all = external_node_fs_.readFileSync(logFile, 'utf8');
1610
- const lines = all.split('\n').filter(Boolean);
1611
- if (!lines.length) return GENESIS;
1612
- return _sha(lines[lines.length - 1]);
1613
- } catch { return GENESIS; }
1614
- }
1615
-
1616
- // Fire-and-forget POST to the remote sink. Resolves to null on success,
1617
- // to a short error string on failure. Never throws; never blocks longer
1618
- // than REMOTE_TIMEOUT_MS. The local audit append happens regardless.
1619
- async function _postRemote(url, entry) {
1620
- try {
1621
- const controller = new AbortController();
1622
- const t = setTimeout(() => controller.abort(), REMOTE_TIMEOUT_MS);
1623
- const r = await fetch(url, {
1624
- method: 'POST',
1625
- headers: { 'Content-Type': 'application/json' },
1626
- body: JSON.stringify(entry),
1627
- signal: controller.signal,
1628
- });
1629
- clearTimeout(t);
1630
- if (!r.ok) return `HTTP ${r.status}`;
1631
- return null;
1632
- } catch (e) {
1633
- return String((e && e.message) || e).slice(0, 200);
1634
- }
1635
- }
1636
-
1637
- function auditCall({ sessionRoot, tool, args, outcome, reason }) {
1638
- if (!sessionRoot) return;
1639
- try {
1640
- // Safety: only write audit log if sessionRoot looks like a project root
1641
- const MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml', 'composer.json', 'Gemfile'];
1642
- let hasMarker = false;
1643
- for (const m of MARKERS) { try { if (external_node_fs_.existsSync(external_node_path_.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
1644
- if (!hasMarker) return;
1645
- const dir = external_node_path_.join(sessionRoot, '.agentic-security');
1646
- external_node_fs_.mkdirSync(dir, { recursive: true });
1647
- const logFile = external_node_path_.join(dir, 'mcp-audit.log');
1648
- const entry = {
1649
- ts: new Date().toISOString(),
1650
- sessionId: SESSION_ID,
1651
- tool,
1652
- outcome,
1653
- ...(reason ? { reason } : {}),
1654
- args: _summarize(args),
1655
- prev: _readLastEntryHash(logFile),
1656
- };
1657
- external_node_fs_.appendFileSync(logFile, JSON.stringify(entry) + '\n');
1658
- // Remote sink (post-recommendation #10). Fire-and-forget. We don't await
1659
- // the promise so the tool call returns immediately; the remote POST runs
1660
- // on its own microtask. Failures get logged to a sidecar file so the
1661
- // operator can detect when the sink is unreachable.
1662
- const webhook = process.env.AGENTIC_SECURITY_AUDIT_WEBHOOK;
1663
- if (webhook) {
1664
- _postRemote(webhook, entry).then((err) => {
1665
- if (!err) return;
1666
- try {
1667
- const errFile = external_node_path_.join(dir, 'mcp-audit.remote-errors.log');
1668
- external_node_fs_.appendFileSync(errFile, JSON.stringify({
1669
- ts: new Date().toISOString(), entryTs: entry.ts, tool, err,
1670
- }) + '\n');
1671
- } catch { /* nothing else to do */ }
1672
- });
1673
- }
1674
- } catch { /* audit failure must never break a tool call */ }
1675
- }
1676
-
1677
- // Verify the chain from start to end. Returns
1678
- // { ok: true, entries: N } if intact
1679
- // { ok: false, brokenAt: <line-index>, expected, got } if any link breaks
1680
- // Reader/operator-facing tool.
1681
- function verifyAuditLog(logFile) {
1682
- if (!fs.existsSync(logFile)) return { ok: true, entries: 0 };
1683
- const text = fs.readFileSync(logFile, 'utf8');
1684
- const lines = text.split('\n').filter(Boolean);
1685
- let expectedPrev = GENESIS;
1686
- for (let i = 0; i < lines.length; i++) {
1687
- let entry;
1688
- try { entry = JSON.parse(lines[i]); }
1689
- catch { return { ok: false, brokenAt: i, reason: 'not JSON' }; }
1690
- if (entry.prev !== expectedPrev) {
1691
- return { ok: false, brokenAt: i, expected: expectedPrev, got: entry.prev };
1692
- }
1693
- expectedPrev = _sha(lines[i]);
1694
- }
1695
- return { ok: true, entries: lines.length };
1696
- }
1697
-
2312
+ // EXTERNAL MODULE: ./src/mcp/audit.js
2313
+ var audit = __webpack_require__(9390);
1698
2314
  ;// CONCATENATED MODULE: ./src/mcp/server.js
1699
2315
  // MCP server core — JSON-RPC 2.0 handler for the Model Context Protocol.
1700
2316
  //
@@ -1741,7 +2357,7 @@ const TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map(t => [t.name, t]));
1741
2357
  function _codeFingerprint() {
1742
2358
  try {
1743
2359
  const here = external_node_path_.dirname((0,external_node_url_.fileURLToPath)(import.meta.url));
1744
- const files = ['server.js', 'tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
2360
+ const files = ['server.js', 'tools.js', 'dataflow-tools.js', 'stdio.js', 'audit.js', 'validate.js', 'redact.js'];
1745
2361
  const h = external_node_crypto_.createHash('sha256');
1746
2362
  for (const f of files) {
1747
2363
  try { h.update(f); h.update(external_node_fs_.readFileSync(external_node_path_.join(here, f))); } catch {}
@@ -1804,7 +2420,7 @@ function createServer({ sessionRoot = process.cwd() } = {}) {
1804
2420
  const name = msg.params?.name;
1805
2421
  const args = msg.params?.arguments ?? {};
1806
2422
  if (disabled) {
1807
- auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'server-disabled' });
2423
+ (0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'server-disabled' });
1808
2424
  return _ok(id, {
1809
2425
  content: [{ type: 'text', text: 'MCP server is disabled (AGENTIC_SECURITY_MCP_DISABLED=1).' }],
1810
2426
  isError: true,
@@ -1812,12 +2428,12 @@ function createServer({ sessionRoot = process.cwd() } = {}) {
1812
2428
  }
1813
2429
  const tool = TOOLS_BY_NAME[name];
1814
2430
  if (!tool) {
1815
- auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'unknown-tool' });
2431
+ (0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: 'unknown-tool' });
1816
2432
  return _err(id, -32602, `Unknown tool: ${name}`);
1817
2433
  }
1818
2434
  try { validate(tool.inputSchema, args); }
1819
2435
  catch (e) {
1820
- auditCall({ sessionRoot, tool: name, args, outcome: 'rejected', reason: `invalid-args: ${e.message}` });
2436
+ (0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'rejected', reason: `invalid-args: ${e.message}` });
1821
2437
  return _ok(id, {
1822
2438
  content: [{ type: 'text', text: `Invalid arguments: ${e.message}` }],
1823
2439
  isError: true,
@@ -1825,13 +2441,13 @@ function createServer({ sessionRoot = process.cwd() } = {}) {
1825
2441
  }
1826
2442
  try {
1827
2443
  const result = await tool.handler(args, ctx);
1828
- auditCall({ sessionRoot, tool: name, args, outcome: 'ok' });
2444
+ (0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'ok' });
1829
2445
  return _ok(id, {
1830
2446
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1831
2447
  isError: false,
1832
2448
  });
1833
2449
  } catch (e) {
1834
- auditCall({ sessionRoot, tool: name, args, outcome: 'error', reason: e.message });
2450
+ (0,audit.auditCall)({ sessionRoot, tool: name, args, outcome: 'error', reason: e.message });
1835
2451
  return _ok(id, {
1836
2452
  content: [{ type: 'text', text: `Error: ${e.message}` }],
1837
2453
  isError: true,
@@ -1983,9 +2599,11 @@ function money(n) {
1983
2599
 
1984
2600
  // Per-1M-token rates (input / output). Mirror hooks/model-cost-advisor.js MODELS.
1985
2601
  const MODEL_RATES = {
1986
- opus: { label: 'Opus 4.8', in: 5, out: 25 },
1987
- sonnet: { label: 'Sonnet 4.6', in: 3, out: 15 },
1988
- haiku: { label: 'Haiku 4.5', in: 1, out: 5 },
2602
+ fable: { label: 'Fable 5', in: 10, out: 50 },
2603
+ opus: { label: 'Opus 4.8', in: 5, out: 25 },
2604
+ sonnet5: { label: 'Sonnet 5', in: 3, out: 15 },
2605
+ sonnet: { label: 'Sonnet 4.6', in: 3, out: 15 },
2606
+ haiku: { label: 'Haiku 4.5', in: 1, out: 5 },
1989
2607
  };
1990
2608
  const CACHE_READ_MULT = 0.1; // cache read ≈ 0.1× input
1991
2609
  const CACHE_WRITE_MULT = 1.25; // 5-minute cache write ≈ 1.25× input
@@ -1997,8 +2615,9 @@ const TTL_MS = 5 * 60 * 1000;
1997
2615
  function rateFor(model) {
1998
2616
  if (typeof model !== 'string') return null;
1999
2617
  const s = model.toLowerCase();
2618
+ if (s.includes('fable') || s.includes('mythos')) return MODEL_RATES.fable;
2000
2619
  if (s.includes('haiku')) return MODEL_RATES.haiku;
2001
- if (s.includes('sonnet')) return MODEL_RATES.sonnet;
2620
+ if (s.includes('sonnet')) return (s.includes('sonnet-5') || s.includes('sonnet 5')) ? MODEL_RATES.sonnet5 : MODEL_RATES.sonnet;
2002
2621
  if (s.includes('opus')) return MODEL_RATES.opus;
2003
2622
  return null;
2004
2623
  }
@@ -2226,6 +2845,307 @@ const _internal = {
2226
2845
  };
2227
2846
 
2228
2847
 
2848
+ /***/ }),
2849
+
2850
+ /***/ 8218:
2851
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
2852
+
2853
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
2854
+ /* harmony export */ loadFreshLineageGraph: () => (/* binding */ loadFreshLineageGraph),
2855
+ /* harmony export */ loadSignedGraph: () => (/* binding */ loadSignedGraph)
2856
+ /* harmony export */ });
2857
+ /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3024);
2858
+ /* harmony import */ var _posture_state_dir_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(1174);
2859
+ /* harmony import */ var _posture_integrity_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(1130);
2860
+ // graph-loader.js — Milestone 3, sub-project Server, increment 1.
2861
+ //
2862
+ // Reads and VERIFIES the `.agentic-security/lineage-graph.json` artifact
2863
+ // before `explore` is allowed to serve a single byte of it. Reuses
2864
+ // `posture/integrity.js`'s `verifyLastScan` DIRECTLY (per the plan and the
2865
+ // root CLAUDE.md's own instruction) — this module does not implement any
2866
+ // signature comparison of its own. `verifyLastScan` already uses
2867
+ // `crypto.timingSafeEqual` internally.
2868
+ //
2869
+ // Loaded ONCE at server startup (see bin/agentic-security.js's cmdExplore)
2870
+ // and held in memory for the life of the process — this is a read-only,
2871
+ // single-scan-snapshot server; a change to the graph on disk mid-session is
2872
+ // out of scope for this increment (threat-model doc's own "P0 is
2873
+ // read-only" framing).
2874
+
2875
+
2876
+
2877
+
2878
+
2879
+ /**
2880
+ * @param {string} scanRoot
2881
+ * @returns {{ok:true, graph:object} | {ok:false, reason:'missing'|'unsigned'|'tampered'|'malformed', message:string}}
2882
+ *
2883
+ * Four, and only four, distinct failure reasons — each with its own clear
2884
+ * message so an operator knows exactly what to do next:
2885
+ * - 'missing' — no lineage-graph.json at all. Run a scan with
2886
+ * AGENTIC_SECURITY_LINEAGE_DEEP=1 first.
2887
+ * - 'unsigned' — the graph exists but its .sig sibling does not
2888
+ * (verifyLastScan returns null). Refuse to serve an
2889
+ * unverifiable graph.
2890
+ * - 'tampered' — the graph exists and has a .sig, but the signature does
2891
+ * not match the body (verifyLastScan returns false). The
2892
+ * file was modified after signing, or signed under a
2893
+ * different install key.
2894
+ * - 'malformed' — the body passed signature verification but is not
2895
+ * valid JSON. Should not happen from a normal scan; the
2896
+ * file may be corrupted on disk after signing.
2897
+ */
2898
+ function loadSignedGraph(scanRoot) {
2899
+ const graphPath = (0,_posture_state_dir_js__WEBPACK_IMPORTED_MODULE_1__.statePath)(scanRoot, 'lineage-graph.json');
2900
+ const sigPath = graphPath + '.sig';
2901
+
2902
+ if (!node_fs__WEBPACK_IMPORTED_MODULE_0__.existsSync(graphPath)) {
2903
+ return {
2904
+ ok: false,
2905
+ reason: 'missing',
2906
+ message: `No lineage graph found at ${graphPath}. Run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first (e.g. \`AGENTIC_SECURITY_LINEAGE_DEEP=1 agentic-security scan\`), then re-run \`agentic-security explore\`.`,
2907
+ };
2908
+ }
2909
+
2910
+ let body;
2911
+ try {
2912
+ body = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(graphPath, 'utf8');
2913
+ } catch (e) {
2914
+ return {
2915
+ ok: false,
2916
+ reason: 'missing',
2917
+ message: `Lineage graph found at ${graphPath} but could not be read: ${e && e.message ? e.message : e}.`,
2918
+ };
2919
+ }
2920
+
2921
+ const verified = (0,_posture_integrity_js__WEBPACK_IMPORTED_MODULE_2__/* .verifyLastScan */ .Ef)(body, sigPath);
2922
+ if (verified === null) {
2923
+ return {
2924
+ ok: false,
2925
+ reason: 'unsigned',
2926
+ message: `Lineage graph at ${graphPath} has no signature file (${sigPath} is missing). Refusing to serve an unverifiable graph. Re-run the scan (AGENTIC_SECURITY_LINEAGE_DEEP=1) to regenerate both files together.`,
2927
+ };
2928
+ }
2929
+ if (verified === false) {
2930
+ return {
2931
+ ok: false,
2932
+ reason: 'tampered',
2933
+ message: `Lineage graph at ${graphPath} FAILED signature verification — its contents do not match ${sigPath}. The file may have been modified after the scan, or signed under a different install key. Refusing to serve a tampered graph. Re-run the scan to regenerate it.`,
2934
+ };
2935
+ }
2936
+
2937
+ let graph;
2938
+ try {
2939
+ graph = JSON.parse(body);
2940
+ } catch (e) {
2941
+ return {
2942
+ ok: false,
2943
+ reason: 'malformed',
2944
+ message: `Lineage graph at ${graphPath} passed signature verification but is not valid JSON (${e && e.message ? e.message : e}). This should not happen from a normal scan — the file may be corrupted. Re-run the scan to regenerate it.`,
2945
+ };
2946
+ }
2947
+
2948
+ return { ok: true, graph };
2949
+ }
2950
+
2951
+ /**
2952
+ * Load .agentic-security/lineage-graph.json ONLY when it is genuinely
2953
+ * fresh for THIS scan — never merely because a file happens to exist on
2954
+ * disk. Shared by every caller that signs or narrates a graph:-derived
2955
+ * compliance claim (M4 sub-project 6c's final whole-branch review found
2956
+ * the identical staleness gap independently reachable from
2957
+ * `attest --obligations` AND `compliance --walkthrough`, and required
2958
+ * this predicate to live in exactly one place rather than being
2959
+ * copy-pasted per caller — a safety check that drifts between two
2960
+ * near-identical inline copies is worse than one shared bug).
2961
+ *
2962
+ * `.agentic-security/lineage-graph.json` is only rewritten when a scan
2963
+ * actually finishes building a graph (`if (scan.lineageGraph)` in
2964
+ * bin/agentic-security.js's persistence code) — an ordinary non-deep
2965
+ * rescan, or a deep scan whose lineage build fails, leaves whatever file
2966
+ * was there from an earlier successful deep scan untouched. Loading that
2967
+ * stale graph and joining it to the CURRENT scan's other data would let a
2968
+ * caller assert a graph-derived fact (e.g. "transit protected") about
2969
+ * code that has since changed.
2970
+ *
2971
+ * `enabled: true` in `scan.scanHealth.lineageAnalysis` does NOT by itself
2972
+ * mean the build succeeded — engine.js sets it the moment
2973
+ * AGENTIC_SECURITY_LINEAGE_DEEP=1 is read, before the build even starts,
2974
+ * and leaves it `true` even when the build later throws (only `failure`
2975
+ * gets set in that case). `requested && enabled` alone therefore still
2976
+ * accepts a stale graph after a failed rebuild — reproduced live via the
2977
+ * scan's own already-shipped fault-injection fixture
2978
+ * (test/lineage-fault-injection.test.js) before this `failure === null`
2979
+ * check was added.
2980
+ *
2981
+ * @param {string} scanRoot
2982
+ * @param {object} scan - the parsed last-scan.json for the CURRENT scan
2983
+ * @returns {{graph:object|null, fresh:boolean, loaded:ReturnType<typeof loadSignedGraph>}}
2984
+ * `fresh` is true only when a signed graph loaded successfully AND this
2985
+ * scan's own scanHealth confirms lineage analysis was requested,
2986
+ * enabled, and did not fail. `graph` is `loaded.graph` when fresh, else
2987
+ * `null` — never the stale file, even when one exists on disk.
2988
+ * `loaded` is the raw `loadSignedGraph` result, so a caller can still
2989
+ * distinguish "no file at all" from "a file exists but isn't fresh" for
2990
+ * its own disclosure message.
2991
+ */
2992
+ function loadFreshLineageGraph(scanRoot, scan) {
2993
+ const la = scan?.scanHealth?.lineageAnalysis;
2994
+ const requested = la?.requested === true;
2995
+ const enabled = la?.enabled === true;
2996
+ const failure = la?.failure ?? null;
2997
+ const loaded = loadSignedGraph(scanRoot);
2998
+ const fresh = loaded.ok && requested && enabled && failure === null;
2999
+ return { graph: fresh ? loaded.graph : null, fresh, loaded };
3000
+ }
3001
+
3002
+
3003
+ /***/ }),
3004
+
3005
+ /***/ 4268:
3006
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
3007
+
3008
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
3009
+ /* harmony export */ Yo: () => (/* binding */ handleScan),
3010
+ /* harmony export */ Yu: () => (/* binding */ handleEdge),
3011
+ /* harmony export */ d5: () => (/* binding */ handleNode),
3012
+ /* harmony export */ fn: () => (/* binding */ handleGraph),
3013
+ /* harmony export */ jg: () => (/* binding */ handleFlow),
3014
+ /* harmony export */ rR: () => (/* binding */ handleQuery)
3015
+ /* harmony export */ });
3016
+ /* unused harmony export wrapResponse */
3017
+ /* harmony import */ var _lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(859);
3018
+ // routes.js — Milestone 3, sub-project Server, increment 1.
3019
+ //
3020
+ // Five pure GET-endpoint handlers, each `(graph, ...) -> {status, body}`.
3021
+ // No req/res access anywhere in this file — that is what makes these
3022
+ // handlers unit-testable without an HTTP layer at all. http-server.js is
3023
+ // the only module that touches node:http and calls into these.
3024
+ //
3025
+ // Every response body is wrapped in `wrapResponse`, which adds the exact
3026
+ // envelope fields PRD line 1326 names (quoted in the implementation plan):
3027
+ // "base graph/snapshot digest, schema/extension versions, scope, coverage,
3028
+ // limitations, and contributing canonical IDs."
3029
+
3030
+
3031
+
3032
+ /**
3033
+ * Shared response envelope. Maps PRD line 1326's required fields onto the
3034
+ * graph's own real fields:
3035
+ * - digest -> graph.graphId (the base graph/snapshot digest)
3036
+ * - schemaVersion -> graph.schemaVersion
3037
+ * - extensions -> graph.extensions (schema/extension versions —
3038
+ * today always `{}`; see schema.js)
3039
+ * - scope -> graph.scope
3040
+ * - coverage -> graph.coverage
3041
+ * - limitations -> graph.limitations
3042
+ * - canonicalIds -> see the design note below
3043
+ *
3044
+ * "contributing canonical IDs" design decision (disclosed per the plan):
3045
+ * for `handleScan`/`handleGraph`, which describe the WHOLE graph rather
3046
+ * than one entity, `canonicalIds` is `null` — the response body for
3047
+ * `handleGraph` already IS the full nodes/edges/flows arrays, so echoing
3048
+ * every id again here would be pure duplication with no informational
3049
+ * gain, and for a large graph would materially bloat the response for
3050
+ * zero benefit. For `handleNode`/`handleEdge`, `canonicalIds` is the
3051
+ * single id the response is about. For `handleFlow`, `canonicalIds` is
3052
+ * the flow's own id PLUS the node/edge ids that flow's evidence draws
3053
+ * from (source, sink, edgeIds) — a flow is a derived record referencing
3054
+ * several underlying entities, and naming all of them here is genuinely
3055
+ * useful metadata a client would otherwise have to re-derive from the
3056
+ * flow body itself.
3057
+ */
3058
+ function wrapResponse(data, graph, { canonicalIds = null } = {}) {
3059
+ return {
3060
+ digest: graph?.graphId ?? null,
3061
+ schemaVersion: graph?.schemaVersion ?? null,
3062
+ extensions: graph?.extensions ?? {},
3063
+ scope: graph?.scope ?? null,
3064
+ coverage: graph?.coverage ?? null,
3065
+ limitations: graph?.limitations ?? [],
3066
+ canonicalIds,
3067
+ data,
3068
+ };
3069
+ }
3070
+
3071
+ function _findById(list, id) {
3072
+ if (!Array.isArray(list)) return null;
3073
+ return list.find((item) => item && item.id === id) ?? null;
3074
+ }
3075
+
3076
+ /** Scan/graph metadata — NOT the full node/edge arrays. */
3077
+ function handleScan(graph) {
3078
+ const data = {
3079
+ schemaVersion: graph?.schemaVersion ?? null,
3080
+ graphId: graph?.graphId ?? null,
3081
+ generatedAt: graph?.generatedAt ?? null,
3082
+ scope: graph?.scope ?? null,
3083
+ scanHealth: graph?.scanHealth ?? null,
3084
+ coverage: graph?.coverage ?? null,
3085
+ };
3086
+ return { status: 200, body: wrapResponse(data, graph, { canonicalIds: null }) };
3087
+ }
3088
+
3089
+ /** The full graph document, unfiltered. For a scoped/narrowed projection, use `handleQuery` (`POST /api/v1/query`, Milestone 5) below instead. */
3090
+ function handleGraph(graph) {
3091
+ return { status: 200, body: wrapResponse(graph, graph, { canonicalIds: null }) };
3092
+ }
3093
+
3094
+ /**
3095
+ * A deterministic typed projection query — Milestone 5's own
3096
+ * `POST /api/v1/query`, the S2 endpoint `handleGraph`'s own header
3097
+ * comment named and deferred. `filter` is the exact `{nodeIds, edgeIds}`
3098
+ * shape `dataflow export --filter`/`exportGraphJSON` already use — reused
3099
+ * via `_filterGraph`, never reimplemented. Final whole-branch review
3100
+ * finding: `undefined` (filter omitted entirely) returns the WHOLE graph,
3101
+ * identical to `handleGraph` — but `{}` (an empty, well-formed filter
3102
+ * object) is NOT the same thing, and does NOT mean "no restriction": both
3103
+ * `nodeIds`/`edgeIds` default to empty Sets inside `_filterGraph`, so `{}`
3104
+ * narrows the graph down to EMPTY node/edge/flow/dataElement arrays. A
3105
+ * caller that wants the whole graph must omit `filter` entirely, never
3106
+ * pass `{}` meaning "everything." A malformed filter is a 400, never a
3107
+ * thrown exception reaching the caller.
3108
+ */
3109
+ function handleQuery(graph, filter) {
3110
+ const check = (0,_lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__.validateFilterShape)(filter);
3111
+ if (!check.valid) {
3112
+ return { status: 400, body: { error: check.error } };
3113
+ }
3114
+ return { status: 200, body: wrapResponse((0,_lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__/* ._filterGraph */ .e)(graph, filter), graph, { canonicalIds: null }) };
3115
+ }
3116
+
3117
+ /** Look up one node by id. 404 with a clear body if not found. */
3118
+ function handleNode(graph, id) {
3119
+ const node = _findById(graph?.nodes, id);
3120
+ if (!node) {
3121
+ return { status: 404, body: wrapResponse({ error: `node not found: ${id}` }, graph, { canonicalIds: [] }) };
3122
+ }
3123
+ return { status: 200, body: wrapResponse(node, graph, { canonicalIds: [id] }) };
3124
+ }
3125
+
3126
+ /** Look up one edge by id. 404 with a clear body if not found. */
3127
+ function handleEdge(graph, id) {
3128
+ const edge = _findById(graph?.edges, id);
3129
+ if (!edge) {
3130
+ return { status: 404, body: wrapResponse({ error: `edge not found: ${id}` }, graph, { canonicalIds: [] }) };
3131
+ }
3132
+ return { status: 200, body: wrapResponse(edge, graph, { canonicalIds: [id] }) };
3133
+ }
3134
+
3135
+ /** Look up one flow by id. 404 with a clear body if not found. */
3136
+ function handleFlow(graph, id) {
3137
+ const flow = _findById(graph?.flows, id);
3138
+ if (!flow) {
3139
+ return { status: 404, body: wrapResponse({ error: `flow not found: ${id}` }, graph, { canonicalIds: [] }) };
3140
+ }
3141
+ const contributing = new Set([id]);
3142
+ if (flow.source) contributing.add(flow.source);
3143
+ if (flow.sink) contributing.add(flow.sink);
3144
+ for (const eid of (flow.edgeIds || [])) contributing.add(eid);
3145
+ return { status: 200, body: wrapResponse(flow, graph, { canonicalIds: [...contributing] }) };
3146
+ }
3147
+
3148
+
2229
3149
  /***/ })
2230
3150
 
2231
3151
  };