@clear-capabilities/agentic-security-scanner 0.74.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 (331) hide show
  1. package/CHANGELOG.md +1580 -0
  2. package/bin/.agentic-security/findings.json +1577 -0
  3. package/bin/.agentic-security/last-scan.json +1577 -0
  4. package/bin/.agentic-security/last-scan.json.sig +1 -0
  5. package/bin/.agentic-security/scan-history.json +465 -0
  6. package/bin/.agentic-security/streak.json +25 -0
  7. package/bin/agentic-security-audit.js +198 -0
  8. package/bin/agentic-security-consistency.js +80 -0
  9. package/bin/agentic-security-diff.js +136 -0
  10. package/bin/agentic-security-lsp.js +12 -0
  11. package/bin/agentic-security-mcp.js +40 -0
  12. package/bin/agentic-security-rule.js +153 -0
  13. package/bin/agentic-security.js +1683 -0
  14. package/dist/117.index.js +207 -0
  15. package/dist/178.index.js +250 -0
  16. package/dist/218.index.js +793 -0
  17. package/dist/227.index.js +192 -0
  18. package/dist/301.index.js +167 -0
  19. package/dist/384.index.js +18 -0
  20. package/dist/476.index.js +126 -0
  21. package/dist/513.index.js +373 -0
  22. package/dist/520.index.js +13 -0
  23. package/dist/601.index.js +1038 -0
  24. package/dist/634.index.js +1892 -0
  25. package/dist/637.index.js +216 -0
  26. package/dist/660.index.js +131 -0
  27. package/dist/675.index.js +451 -0
  28. package/dist/826.index.js +188 -0
  29. package/dist/830.index.js +133 -0
  30. package/dist/agentic-security.mjs +272 -0
  31. package/dist/agentic-security.mjs.sha256 +1 -0
  32. package/dist/calibration-seed.json +27 -0
  33. package/package.json +77 -0
  34. package/src/.agentic-security/findings.json +80844 -0
  35. package/src/.agentic-security/last-scan.json +80844 -0
  36. package/src/.agentic-security/last-scan.json.sig +1 -0
  37. package/src/.agentic-security/scan-history.json +8408 -0
  38. package/src/.agentic-security/streak.json +26 -0
  39. package/src/badge.js +188 -0
  40. package/src/compare.js +203 -0
  41. package/src/dataflow/.agentic-security/findings.json +3487 -0
  42. package/src/dataflow/.agentic-security/last-scan.json +3487 -0
  43. package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
  44. package/src/dataflow/.agentic-security/scan-history.json +735 -0
  45. package/src/dataflow/.agentic-security/streak.json +24 -0
  46. package/src/dataflow/CLAUDE.md +38 -0
  47. package/src/dataflow/access-paths.js +172 -0
  48. package/src/dataflow/async-sequencing.js +177 -0
  49. package/src/dataflow/backward.js +201 -0
  50. package/src/dataflow/catalog-expanded.js +485 -0
  51. package/src/dataflow/catalog.js +659 -0
  52. package/src/dataflow/cross-repo.js +219 -0
  53. package/src/dataflow/engine.js +588 -0
  54. package/src/dataflow/exception-flow.js +116 -0
  55. package/src/dataflow/exploit-prover.js +187 -0
  56. package/src/dataflow/higher-order.js +221 -0
  57. package/src/dataflow/ifds.js +347 -0
  58. package/src/dataflow/implicit-flow.js +129 -0
  59. package/src/dataflow/incremental.js +229 -0
  60. package/src/dataflow/index.js +181 -0
  61. package/src/dataflow/numeric-domain.js +192 -0
  62. package/src/dataflow/path-feasibility.js +114 -0
  63. package/src/dataflow/points-to.js +337 -0
  64. package/src/dataflow/polyglot.js +190 -0
  65. package/src/dataflow/proven-clean.js +159 -0
  66. package/src/dataflow/receiver-context.js +76 -0
  67. package/src/dataflow/sanitizer-proof.js +154 -0
  68. package/src/dataflow/soft-taint.js +140 -0
  69. package/src/dataflow/string-domain.js +234 -0
  70. package/src/dataflow/stub-aware-filter.js +100 -0
  71. package/src/dataflow/summaries.js +132 -0
  72. package/src/dataflow/symbolic-exec.js +238 -0
  73. package/src/dataflow/tabulation.js +135 -0
  74. package/src/engine.js +7763 -0
  75. package/src/history-scan.js +229 -0
  76. package/src/index.js +3 -0
  77. package/src/integrations/.agentic-security/findings.json +1504 -0
  78. package/src/integrations/.agentic-security/last-scan.json +1504 -0
  79. package/src/integrations/.agentic-security/scan-history.json +40 -0
  80. package/src/integrations/.agentic-security/streak.json +21 -0
  81. package/src/integrations/index.js +321 -0
  82. package/src/integrations/tickets.js +200 -0
  83. package/src/ir/.agentic-security/findings.json +3036 -0
  84. package/src/ir/.agentic-security/last-scan.json +3036 -0
  85. package/src/ir/.agentic-security/last-scan.json.sig +1 -0
  86. package/src/ir/.agentic-security/scan-history.json +364 -0
  87. package/src/ir/.agentic-security/streak.json +23 -0
  88. package/src/ir/CLAUDE.md +172 -0
  89. package/src/ir/callgraph.js +73 -0
  90. package/src/ir/class-hierarchy.js +195 -0
  91. package/src/ir/index.js +152 -0
  92. package/src/ir/parser-cs.js +260 -0
  93. package/src/ir/parser-java.js +286 -0
  94. package/src/ir/parser-js.js +413 -0
  95. package/src/ir/parser-kt.js +258 -0
  96. package/src/ir/parser-py-cst.js +136 -0
  97. package/src/ir/parser-py.helper.py +501 -0
  98. package/src/ir/parser-py.js +312 -0
  99. package/src/ir/ssa.js +315 -0
  100. package/src/ir/type-stubs.js +288 -0
  101. package/src/leaderboard.js +152 -0
  102. package/src/llm-validator/.agentic-security/findings.json +1891 -0
  103. package/src/llm-validator/.agentic-security/last-scan.json +1891 -0
  104. package/src/llm-validator/.agentic-security/last-scan.json.sig +1 -0
  105. package/src/llm-validator/.agentic-security/scan-history.json +168 -0
  106. package/src/llm-validator/.agentic-security/streak.json +20 -0
  107. package/src/llm-validator/consistency.js +141 -0
  108. package/src/llm-validator/index.js +437 -0
  109. package/src/lsp/.agentic-security/findings.json +28 -0
  110. package/src/lsp/.agentic-security/last-scan.json +28 -0
  111. package/src/lsp/.agentic-security/scan-history.json +79 -0
  112. package/src/lsp/.agentic-security/streak.json +22 -0
  113. package/src/lsp/server.js +275 -0
  114. package/src/mcp/.agentic-security/findings.json +8358 -0
  115. package/src/mcp/.agentic-security/last-scan.json +8358 -0
  116. package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
  117. package/src/mcp/.agentic-security/scan-history.json +1125 -0
  118. package/src/mcp/.agentic-security/streak.json +22 -0
  119. package/src/mcp/CLAUDE.md +54 -0
  120. package/src/mcp/audit.js +136 -0
  121. package/src/mcp/redact.js +75 -0
  122. package/src/mcp/server.js +158 -0
  123. package/src/mcp/stdio.js +83 -0
  124. package/src/mcp/tools.js +940 -0
  125. package/src/mcp/validate.js +49 -0
  126. package/src/personality.js +164 -0
  127. package/src/poc-video.js +239 -0
  128. package/src/posture/.agentic-security/findings.json +51239 -0
  129. package/src/posture/.agentic-security/last-scan.json +51239 -0
  130. package/src/posture/.agentic-security/last-scan.json.sig +1 -0
  131. package/src/posture/.agentic-security/scan-history.json +5557 -0
  132. package/src/posture/.agentic-security/streak.json +24 -0
  133. package/src/posture/CLAUDE.md +42 -0
  134. package/src/posture/adversarial-self-test.js +114 -0
  135. package/src/posture/adversary-agent.js +204 -0
  136. package/src/posture/agents-memory.js +135 -0
  137. package/src/posture/ai-code-fingerprint.js +171 -0
  138. package/src/posture/aibom.js +284 -0
  139. package/src/posture/api-inventory.js +96 -0
  140. package/src/posture/attack-playbooks.js +305 -0
  141. package/src/posture/auditor-agent.js +115 -0
  142. package/src/posture/auth-posture-import.js +135 -0
  143. package/src/posture/baseline-compare.js +114 -0
  144. package/src/posture/blast-radius.js +836 -0
  145. package/src/posture/bounty-prediction.js +141 -0
  146. package/src/posture/business-logic.js +239 -0
  147. package/src/posture/calibration-drift.js +93 -0
  148. package/src/posture/calibration-seed.json +27 -0
  149. package/src/posture/calibration.js +204 -0
  150. package/src/posture/clustering.js +75 -0
  151. package/src/posture/concurrency-checker.js +265 -0
  152. package/src/posture/confidence.js +65 -0
  153. package/src/posture/container-runtime.js +149 -0
  154. package/src/posture/counterfactual.js +109 -0
  155. package/src/posture/cross-lang-graphql.js +165 -0
  156. package/src/posture/cross-lang-grpc.js +166 -0
  157. package/src/posture/cross-lang-meta.js +101 -0
  158. package/src/posture/cross-lang-openapi.js +187 -0
  159. package/src/posture/cross-lang-orm.js +153 -0
  160. package/src/posture/cross-lang-queues.js +210 -0
  161. package/src/posture/crown-jewels.js +110 -0
  162. package/src/posture/custom-rules.js +361 -0
  163. package/src/posture/cve-alert-daemon.js +433 -0
  164. package/src/posture/cve-lookup.js +129 -0
  165. package/src/posture/dead-code.js +430 -0
  166. package/src/posture/defender-agent.js +158 -0
  167. package/src/posture/deploy-platform.js +204 -0
  168. package/src/posture/detector-fuzz.js +61 -0
  169. package/src/posture/deterministic.js +99 -0
  170. package/src/posture/drift.js +165 -0
  171. package/src/posture/epss.js +156 -0
  172. package/src/posture/exploitability-probability.js +212 -0
  173. package/src/posture/exploitability.js +121 -0
  174. package/src/posture/feature-flags.js +110 -0
  175. package/src/posture/finding-defaults.js +132 -0
  176. package/src/posture/fix-history.js +411 -0
  177. package/src/posture/fix-plan.js +121 -0
  178. package/src/posture/fix-verify-loop.js +157 -0
  179. package/src/posture/fix-verify.js +130 -0
  180. package/src/posture/flow-narration.js +105 -0
  181. package/src/posture/grader-calibration.js +156 -0
  182. package/src/posture/harness-discovery.js +113 -0
  183. package/src/posture/holdout-eval.js +144 -0
  184. package/src/posture/iac-reachability.js +163 -0
  185. package/src/posture/iam-policy.js +128 -0
  186. package/src/posture/integrity.js +97 -0
  187. package/src/posture/learning.js +166 -0
  188. package/src/posture/license-policy.js +109 -0
  189. package/src/posture/llm-redteam-prompts.js +418 -0
  190. package/src/posture/llm-redteam.js +303 -0
  191. package/src/posture/material-change.js +163 -0
  192. package/src/posture/mitigation-composite.js +55 -0
  193. package/src/posture/mttr.js +91 -0
  194. package/src/posture/network-policy-import.js +126 -0
  195. package/src/posture/path-predicates.js +99 -0
  196. package/src/posture/persona-prioritization.js +153 -0
  197. package/src/posture/poc-cwe-map.js +51 -0
  198. package/src/posture/poc-generator.js +500 -0
  199. package/src/posture/policy-gate.js +174 -0
  200. package/src/posture/pre-incident-archaeology.js +110 -0
  201. package/src/posture/profile.js +93 -0
  202. package/src/posture/reachability-filter.js +42 -0
  203. package/src/posture/regression-test-gen.js +200 -0
  204. package/src/posture/reverse-blast-radius.js +110 -0
  205. package/src/posture/router.js +109 -0
  206. package/src/posture/rule-overrides.js +198 -0
  207. package/src/posture/rule-pack-signing.js +209 -0
  208. package/src/posture/rule-packs.js +143 -0
  209. package/src/posture/rule-synthesis.js +108 -0
  210. package/src/posture/ruleset-version.js +71 -0
  211. package/src/posture/sbom.js +129 -0
  212. package/src/posture/schema-aware-bridge.js +207 -0
  213. package/src/posture/security-trend.js +87 -0
  214. package/src/posture/semantic-clone.js +114 -0
  215. package/src/posture/specification-mining.js +170 -0
  216. package/src/posture/stable-id.js +75 -0
  217. package/src/posture/stack-playbook.js +229 -0
  218. package/src/posture/streak.js +249 -0
  219. package/src/posture/suppressions.js +135 -0
  220. package/src/posture/telemetry-ingest.js +112 -0
  221. package/src/posture/threat-model.js +145 -0
  222. package/src/posture/three-agent-pipeline.js +74 -0
  223. package/src/posture/triage.js +146 -0
  224. package/src/posture/trust-boundary-diagram.js +115 -0
  225. package/src/posture/type-narrowing.js +129 -0
  226. package/src/posture/validator-metrics.js +179 -0
  227. package/src/posture/verifier-ephemeral.js +118 -0
  228. package/src/posture/verifier-target.js +147 -0
  229. package/src/posture/verifier.js +257 -0
  230. package/src/posture/version.js +75 -0
  231. package/src/posture/waf-ingest.js +200 -0
  232. package/src/posture/why-fired.js +141 -0
  233. package/src/pr-comment.js +172 -0
  234. package/src/pr-delta.js +198 -0
  235. package/src/report/.agentic-security/findings.json +79 -0
  236. package/src/report/.agentic-security/last-scan.json +79 -0
  237. package/src/report/.agentic-security/last-scan.json.sig +1 -0
  238. package/src/report/.agentic-security/scan-history.json +332 -0
  239. package/src/report/.agentic-security/streak.json +23 -0
  240. package/src/report/index.js +1136 -0
  241. package/src/report/mascot.js +42 -0
  242. package/src/runScan.js +141 -0
  243. package/src/sast/.agentic-security/findings.json +5051 -0
  244. package/src/sast/.agentic-security/last-scan.json +5051 -0
  245. package/src/sast/.agentic-security/last-scan.json.sig +1 -0
  246. package/src/sast/.agentic-security/scan-history.json +788 -0
  247. package/src/sast/.agentic-security/streak.json +23 -0
  248. package/src/sast/CLAUDE.md +39 -0
  249. package/src/sast/_comment-strip.js +46 -0
  250. package/src/sast/agent-tool-escalation.js +131 -0
  251. package/src/sast/auth-provider.js +171 -0
  252. package/src/sast/authz.js +236 -0
  253. package/src/sast/bench-shape/.agentic-security/findings.json +28 -0
  254. package/src/sast/bench-shape/.agentic-security/last-scan.json +28 -0
  255. package/src/sast/bench-shape/.agentic-security/scan-history.json +24 -0
  256. package/src/sast/bench-shape/.agentic-security/streak.json +22 -0
  257. package/src/sast/bench-shape/index.js +62 -0
  258. package/src/sast/claude-hook-injection.js +199 -0
  259. package/src/sast/claude-md-prompt-injection.js +170 -0
  260. package/src/sast/claude-settings.js +165 -0
  261. package/src/sast/client-side.js +149 -0
  262. package/src/sast/cpp-bench-extras.js +122 -0
  263. package/src/sast/cpp-dataflow.js +430 -0
  264. package/src/sast/cpp.js +248 -0
  265. package/src/sast/csharp.js +152 -0
  266. package/src/sast/csrf.js +82 -0
  267. package/src/sast/dart-flutter.js +173 -0
  268. package/src/sast/db-rls.js +147 -0
  269. package/src/sast/db-taint.js +215 -0
  270. package/src/sast/defi-deep.js +242 -0
  271. package/src/sast/deserialization-gadgets.js +113 -0
  272. package/src/sast/django-hardening.js +230 -0
  273. package/src/sast/env-hygiene.js +125 -0
  274. package/src/sast/fastapi-hardening.js +145 -0
  275. package/src/sast/go-extended.js +84 -0
  276. package/src/sast/host-header.js +106 -0
  277. package/src/sast/index.js +17 -0
  278. package/src/sast/java-ast-folding.js +561 -0
  279. package/src/sast/java-bench-extras.js +708 -0
  280. package/src/sast/java-collection-passthrough.js +178 -0
  281. package/src/sast/java-constant-fold.js +244 -0
  282. package/src/sast/java-deserialization.js +125 -0
  283. package/src/sast/jndi.js +104 -0
  284. package/src/sast/juliet-shape.js +324 -0
  285. package/src/sast/jwt-exp.js +104 -0
  286. package/src/sast/kotlin.js +82 -0
  287. package/src/sast/laravel-hardening.js +198 -0
  288. package/src/sast/ldap-injection.js +100 -0
  289. package/src/sast/llm-owasp.js +465 -0
  290. package/src/sast/llm-stored-prompt.js +103 -0
  291. package/src/sast/llm-trading-agent.js +161 -0
  292. package/src/sast/llm.js +308 -0
  293. package/src/sast/logic.js +140 -0
  294. package/src/sast/mass-assignment.js +101 -0
  295. package/src/sast/mcp-audit.js +242 -0
  296. package/src/sast/mobile-manifest.js +195 -0
  297. package/src/sast/model-load.js +164 -0
  298. package/src/sast/mutation-xss.js +87 -0
  299. package/src/sast/nosql-injection.js +82 -0
  300. package/src/sast/open-redirect.js +119 -0
  301. package/src/sast/php.js +91 -0
  302. package/src/sast/pipeline.js +122 -0
  303. package/src/sast/primary-cwe-java.js +155 -0
  304. package/src/sast/prompt-firewall.js +151 -0
  305. package/src/sast/prompt-template.js +157 -0
  306. package/src/sast/prototype-pollution.js +112 -0
  307. package/src/sast/python-sinks.js +195 -0
  308. package/src/sast/quarkus-hardening.js +102 -0
  309. package/src/sast/rag-poisoning.js +118 -0
  310. package/src/sast/rate-limit.js +128 -0
  311. package/src/sast/response-splitting.js +138 -0
  312. package/src/sast/ruby.js +108 -0
  313. package/src/sast/rust.js +105 -0
  314. package/src/sast/solidity.js +167 -0
  315. package/src/sast/springboot-hardening.js +186 -0
  316. package/src/sast/ssrf-cloud-metadata.js +80 -0
  317. package/src/sast/ssti.js +116 -0
  318. package/src/sast/swift.js +162 -0
  319. package/src/sast/toctou.js +95 -0
  320. package/src/sast/webhook.js +101 -0
  321. package/src/sast/xpath-injection.js +51 -0
  322. package/src/sast/xxe.js +140 -0
  323. package/src/sast/zip-slip.js +200 -0
  324. package/src/sca/base-images.json +45 -0
  325. package/src/sca/container.js +107 -0
  326. package/src/sca/dep-confusion.js +134 -0
  327. package/src/sca/index.js +6 -0
  328. package/src/sca/popular-packages.json +41 -0
  329. package/src/sca/sarif-ingest.js +187 -0
  330. package/src/sca/vuln-function-hints.json +89 -0
  331. package/src/secrets/index.js +4 -0
@@ -0,0 +1,940 @@
1
+ // MCP tool implementations — PRD Feature 2, hardened against the OWASP MCP
2
+ // Top 10 (see ./redact.js, ./audit.js, ./server.js for sibling controls).
3
+ //
4
+ // Trust model:
5
+ // - Session root fixed at server boot. No per-call retargeting.
6
+ // - Path arguments lstat-checked (symlinks refused, OWASP MCP05) and
7
+ // realpath-confined to session root.
8
+ // - Tool outputs marked _meta.untrusted_excerpts:true (OWASP MCP03/MCP06)
9
+ // because they may contain text from scanned files, which is adversary-
10
+ // controlled in any context where the agent might read malicious code.
11
+ // - Secret-shaped strings redacted on the way out (OWASP MCP01/MCP10).
12
+ // - `apply_fix` requires confirm:true, valid HMAC signature on
13
+ // last-scan.json, non-shadow finding, and confined file path.
14
+
15
+ import * as fs from 'node:fs';
16
+ import * as fsp from 'node:fs/promises';
17
+ import * as path from 'node:path';
18
+ import * as crypto from 'node:crypto';
19
+ import { runScan } from '../runScan.js';
20
+ import { applyFix as applyFixHistory } from '../posture/fix-history.js';
21
+ import { verifyLastScan } from '../posture/integrity.js';
22
+ import { redactString, redactFinding } from './redact.js';
23
+ import { verifyFix as verifyFixCore } from '../posture/fix-verify.js';
24
+
25
+ const MAX_FILES_PER_SCAN = 1024;
26
+ const MAX_FILE_BYTES = 500_000;
27
+ const MAX_TOTAL_SCAN_BYTES = 50_000_000;
28
+ const META = { source: 'agentic-security-mcp', untrusted_excerpts: true };
29
+
30
+ // OWASP A01 — refuse writes to paths that could subvert the security tool
31
+ // itself or the host's source-control / dependency state. A forged finding
32
+ // could otherwise tell apply_fix to overwrite our own rules.yml, our audit
33
+ // log, a .git/hooks/post-commit payload, a CI workflow, an IaC file, or a
34
+ // dependency manifest (premortem #3 expansion).
35
+ //
36
+ // Two kinds of guard:
37
+ // - DIR-prefix matches anywhere under one of these directories
38
+ // - FILE-suffix matches any path whose basename ends with one of these
39
+ const RESERVED_WRITE_PREFIXES = [
40
+ '.git/',
41
+ '.github/',
42
+ '.gitlab/',
43
+ '.circleci/',
44
+ '.buildkite/',
45
+ '.agentic-security/',
46
+ 'node_modules/',
47
+ '.terraform/',
48
+ '.aws/',
49
+ 'k8s/',
50
+ 'kubernetes/',
51
+ ];
52
+ const RESERVED_WRITE_BASENAMES = new Set([
53
+ 'Dockerfile',
54
+ 'Jenkinsfile',
55
+ '.gitlab-ci.yml',
56
+ '.gitlab-ci.yaml',
57
+ 'package.json',
58
+ 'package-lock.json',
59
+ 'yarn.lock',
60
+ 'pnpm-lock.yaml',
61
+ 'pyproject.toml',
62
+ 'Pipfile',
63
+ 'Pipfile.lock',
64
+ 'poetry.lock',
65
+ 'requirements.txt',
66
+ 'go.mod',
67
+ 'go.sum',
68
+ 'Cargo.toml',
69
+ 'Cargo.lock',
70
+ 'composer.json',
71
+ 'composer.lock',
72
+ 'Gemfile',
73
+ 'Gemfile.lock',
74
+ 'pom.xml',
75
+ 'build.gradle',
76
+ 'build.gradle.kts',
77
+ ]);
78
+ const RESERVED_WRITE_SUFFIXES = [
79
+ '.tf',
80
+ '.tfvars',
81
+ 'docker-compose.yml',
82
+ 'docker-compose.yaml',
83
+ ];
84
+ function _isReservedWritePath(sessionRoot, absFile) {
85
+ // Resolve sessionRoot symlinks so the relative path is computed against
86
+ // the same canonical root as `absFile` (which _confine already realpath'd).
87
+ // On macOS /tmp → /private/tmp; without this normalization the relative
88
+ // would contain "../" and the prefix check would miss the reserved path.
89
+ const rootReal = fs.realpathSync(path.resolve(sessionRoot));
90
+ const rel = path.relative(rootReal, absFile).replace(/\\/g, '/');
91
+ if (RESERVED_WRITE_PREFIXES.some(p => rel === p.replace(/\/$/, '') || rel.startsWith(p))) return true;
92
+ const base = rel.split('/').pop() || '';
93
+ if (RESERVED_WRITE_BASENAMES.has(base)) return true;
94
+ if (RESERVED_WRITE_SUFFIXES.some(s => base === s || base.endsWith(s))) return true;
95
+ return false;
96
+ }
97
+
98
+ // LangChain harness-anatomy recommendation: the filesystem is the right
99
+ // collaboration / scratchpad surface for subagents. We carve out one writable
100
+ // directory inside the otherwise-reserved `.agentic-security/` tree —
101
+ // `.agentic-security/agent-scratchpad/<agent>/<session>/` — and expose
102
+ // `append_scratchpad` / `read_scratchpad` for in-progress agent state.
103
+ //
104
+ // Confinement rules:
105
+ // - relative path required (no absolute / no `..`)
106
+ // - must start with `agent-scratchpad/<agent>/<session>/`
107
+ // - `<agent>` and `<session>` are restricted to `[A-Za-z0-9_.-]{1,64}`
108
+ // (no slashes — keeps the prefix exactly three components deep)
109
+ // - file basename: same charset rules
110
+ // - max scratchpad bytes per file: SCRATCHPAD_MAX_FILE_BYTES
111
+ const SCRATCHPAD_PREFIX = '.agentic-security/agent-scratchpad/';
112
+ const SCRATCHPAD_NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/;
113
+ const SCRATCHPAD_MAX_FILE_BYTES = 2 * 1024 * 1024; // 2 MB per file
114
+ const SCRATCHPAD_MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB per scan root
115
+
116
+ function _validateScratchpadPath(relPath) {
117
+ if (typeof relPath !== 'string' || !relPath.length) {
118
+ return { ok: false, reason: 'path: not a string' };
119
+ }
120
+ if (path.isAbsolute(relPath)) return { ok: false, reason: 'path: must be relative' };
121
+ if (relPath.includes('..')) return { ok: false, reason: 'path: must not contain ..' };
122
+ const normalized = relPath.replace(/\\/g, '/');
123
+ if (!normalized.startsWith(SCRATCHPAD_PREFIX)) {
124
+ return { ok: false, reason: `path: must start with "${SCRATCHPAD_PREFIX}"` };
125
+ }
126
+ const rest = normalized.slice(SCRATCHPAD_PREFIX.length);
127
+ const parts = rest.split('/');
128
+ if (parts.length < 3) {
129
+ return { ok: false, reason: 'path: must be agent-scratchpad/<agent>/<session>/<file>' };
130
+ }
131
+ const [agent, session, ...fileParts] = parts;
132
+ if (!SCRATCHPAD_NAME_RE.test(agent)) return { ok: false, reason: `path: agent name "${agent}" not in [A-Za-z0-9_.-]{1,64}` };
133
+ if (!SCRATCHPAD_NAME_RE.test(session)) return { ok: false, reason: `path: session id "${session}" not in [A-Za-z0-9_.-]{1,64}` };
134
+ for (const p of fileParts) {
135
+ if (!SCRATCHPAD_NAME_RE.test(p)) return { ok: false, reason: `path: file part "${p}" not in [A-Za-z0-9_.-]{1,64}` };
136
+ }
137
+ return { ok: true, agent, session, fileParts };
138
+ }
139
+
140
+ function _scratchpadAbs(sessionRoot, relPath) {
141
+ return path.resolve(sessionRoot, relPath.replace(/\\/g, '/'));
142
+ }
143
+
144
+ function _scratchpadTotalBytes(sessionRoot) {
145
+ const base = path.join(sessionRoot, '.agentic-security', 'agent-scratchpad');
146
+ if (!fs.existsSync(base)) return 0;
147
+ let total = 0;
148
+ const walk = (dir) => {
149
+ let entries;
150
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
151
+ for (const e of entries) {
152
+ const fp = path.join(dir, e.name);
153
+ try {
154
+ if (e.isFile()) { total += fs.statSync(fp).size; }
155
+ else if (e.isDirectory()) walk(fp);
156
+ } catch { /* skip */ }
157
+ }
158
+ };
159
+ walk(base);
160
+ return total;
161
+ }
162
+
163
+ // ─── Path confinement ────────────────────────────────────────────────────────
164
+ // Lexical check + lstat symlink reject + realpath re-check. OWASP MCP05.
165
+ //
166
+ // For non-existent paths (apply_fix to a new file is a possible legitimate
167
+ // case; in practice we re-check existence at the use-site) we walk up the
168
+ // deepest existing ancestor and realpath that, so a parent-symlink can't
169
+ // silently relocate writes.
170
+ function _confine(sessionRoot, candidate, label) {
171
+ if (typeof candidate !== 'string' || !candidate) throw new Error(`${label}: not a string`);
172
+ const rootReal = fs.realpathSync(path.resolve(sessionRoot));
173
+ const abs = path.isAbsolute(candidate) ? candidate : path.resolve(rootReal, candidate);
174
+
175
+ // Lexical pre-check: rejects "../../etc/passwd" before any fs call.
176
+ const relLex = path.relative(rootReal, path.resolve(abs));
177
+ if (relLex === '' || relLex.startsWith('..') || path.isAbsolute(relLex)) {
178
+ throw new Error(`${label}: path "${candidate}" escapes session root`);
179
+ }
180
+
181
+ // If the path exists, the leaf must not be a symlink and its realpath
182
+ // must still be under rootReal.
183
+ if (fs.existsSync(abs)) {
184
+ if (fs.lstatSync(abs).isSymbolicLink()) {
185
+ throw new Error(`${label}: path "${candidate}" is a symbolic link (refused)`);
186
+ }
187
+ const real = fs.realpathSync(abs);
188
+ if (path.relative(rootReal, real).startsWith('..')) {
189
+ throw new Error(`${label}: path "${candidate}" resolves outside session root via symlink`);
190
+ }
191
+ return real;
192
+ }
193
+
194
+ // Path doesn't exist — walk up to the deepest existing ancestor and
195
+ // realpath that. If a parent dir is a symlink pointing outside rootReal
196
+ // we catch it here.
197
+ let parent = path.dirname(abs);
198
+ while (parent !== path.dirname(parent) && !fs.existsSync(parent)) {
199
+ parent = path.dirname(parent);
200
+ }
201
+ const parentReal = fs.realpathSync(parent);
202
+ if (path.relative(rootReal, parentReal).startsWith('..')) {
203
+ throw new Error(`${label}: path "${candidate}" parent resolves outside session root`);
204
+ }
205
+ const suffix = path.relative(parent, abs);
206
+ return path.resolve(parentReal, suffix);
207
+ }
208
+
209
+ function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
210
+ const stateDir = path.join(sessionRoot, '.agentic-security');
211
+ const scanFile = path.join(stateDir, 'last-scan.json');
212
+ const sigFile = scanFile + '.sig';
213
+ if (!fs.existsSync(scanFile)) return { scan: null, status: 'missing' };
214
+ const body = fs.readFileSync(scanFile, 'utf8');
215
+ const ok = verifyLastScan(body, sigFile);
216
+ if (ok === false) return { scan: null, status: 'tampered' };
217
+ if (ok === null && !allowUnsigned) return { scan: null, status: 'unsigned' };
218
+ let parsed;
219
+ try { parsed = JSON.parse(body); }
220
+ catch { return { scan: null, status: 'unparseable' }; }
221
+ return { scan: parsed, status: ok ? 'verified' : 'unsigned' };
222
+ }
223
+
224
+ function _findById(scan, id) {
225
+ if (!scan) return null;
226
+ return (scan.findings || []).find(f => f.id === id)
227
+ || (scan.secrets || []).find(f => f.id === id)
228
+ || null;
229
+ }
230
+
231
+ // ─── Tool-output offloading (harness-anatomy #1) ────────────────────────────
232
+ // LangChain post: "the harness keeps the head and tail tokens of tool outputs
233
+ // above a threshold number of tokens and offloads the full output to the
234
+ // filesystem." We apply this to any MCP tool response whose findings array
235
+ // exceeds OFFLOAD_THRESHOLD entries: write the full list to a scratchpad
236
+ // file, return only head[0..3] + tail[-2..] + total + path. The agent can
237
+ // call `read_scratchpad(path)` to page through the rest.
238
+ //
239
+ // Design choices:
240
+ // - Threshold is conservative (10) — anything bigger than a casual UI page
241
+ // gets offloaded. Tunable via $AGENTIC_SECURITY_MCP_OFFLOAD_THRESHOLD.
242
+ // - Offload location is the agent-scratchpad (not a separate dir) so the
243
+ // same cleanup + size caps apply.
244
+ // - File names are deterministic per response (sha256 of JSON.stringify)
245
+ // so two identical responses share the same offload file.
246
+ // - The session id is process.pid + boot timestamp short hash — collides
247
+ // only across restarts within a millisecond, which is fine for cache.
248
+ const OFFLOAD_THRESHOLD = (() => {
249
+ const v = parseInt(process.env.AGENTIC_SECURITY_MCP_OFFLOAD_THRESHOLD || '10', 10);
250
+ return Number.isFinite(v) && v >= 1 ? v : 10;
251
+ })();
252
+ const MCP_SESSION_ID = `${process.pid}-${Date.now().toString(36).slice(-6)}`;
253
+
254
+ function _maybeOffload(sessionRoot, toolName, items) {
255
+ if (!Array.isArray(items) || items.length <= OFFLOAD_THRESHOLD) {
256
+ return { offloaded: false, items, total: items.length };
257
+ }
258
+ const head = items.slice(0, 3);
259
+ const tail = items.slice(-2);
260
+ const json = JSON.stringify({ tool: toolName, total: items.length, items }, null, 2);
261
+ const hashShort = crypto.createHash('sha256').update(json).digest('hex').slice(0, 10);
262
+ const rel = `.agentic-security/agent-scratchpad/mcp-offload/${MCP_SESSION_ID}/${toolName}-${hashShort}.json`;
263
+ const abs = path.resolve(sessionRoot, rel);
264
+ try {
265
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
266
+ fs.writeFileSync(abs, json);
267
+ } catch (e) {
268
+ // If we can't write to disk for some reason, fall back to returning
269
+ // everything — the alternative would be silently dropping data, which
270
+ // is worse than blowing the context.
271
+ return { offloaded: false, items, total: items.length, offloadError: e.message };
272
+ }
273
+ return {
274
+ offloaded: true,
275
+ head, tail, total: items.length,
276
+ scratchpadPath: rel,
277
+ pagingHint: `call read_scratchpad({ path: "${rel}", offset, limit }) to page through; the file is { tool, total, items: [...] } JSON`,
278
+ };
279
+ }
280
+
281
+ // ─── scan_diff ───────────────────────────────────────────────────────────────
282
+ export const scan_diff = {
283
+ name: 'scan_diff',
284
+ 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.',
285
+ inputSchema: {
286
+ type: 'object',
287
+ additionalProperties: false,
288
+ properties: {
289
+ files: {
290
+ type: 'array', minItems: 1, maxItems: MAX_FILES_PER_SCAN,
291
+ items: { type: 'string', minLength: 1, maxLength: 4096 },
292
+ },
293
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
294
+ },
295
+ required: ['files'],
296
+ },
297
+ async handler({ files, severity }, ctx) {
298
+ const sessionRoot = ctx.sessionRoot;
299
+ const abs = files.map(f => _confine(sessionRoot, f, 'files[]'));
300
+
301
+ const fileContents = {};
302
+ let totalBytes = 0;
303
+ for (const a of abs) {
304
+ let stat;
305
+ try { stat = fs.statSync(a); } catch { continue; }
306
+ if (!stat.isFile()) continue;
307
+ if (stat.size > MAX_FILE_BYTES) continue;
308
+ totalBytes += stat.size;
309
+ if (totalBytes > MAX_TOTAL_SCAN_BYTES) {
310
+ throw new Error(`scan_diff: total scan size exceeds ${MAX_TOTAL_SCAN_BYTES} bytes`);
311
+ }
312
+ let content;
313
+ try { content = fs.readFileSync(a, 'utf8'); } catch { continue; }
314
+ const rel = path.relative(sessionRoot, a).replace(/\\/g, '/');
315
+ fileContents[rel] = content;
316
+ }
317
+
318
+ const result = await runScan(sessionRoot, { network: false, fileContents });
319
+ const wantSet = new Set(Object.keys(fileContents));
320
+ const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
321
+ const min = sevRank[severity] ?? 0;
322
+ const findings = (result.scan.findings || [])
323
+ .filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
324
+ .map(f => redactFinding({
325
+ id: f.id, severity: f.severity, file: f.file, line: f.line,
326
+ title: f.title || f.vuln, cwe: f.cwe,
327
+ description: f.description, remediation: f.remediation,
328
+ }));
329
+ // Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
330
+ // The agent gets a head+tail preview plus a path it can page through;
331
+ // the full finding list lives on disk. This is the documented fix for
332
+ // "context rot" — large tool outputs eat the model's attention budget.
333
+ const off = _maybeOffload(sessionRoot, 'scan_diff', findings);
334
+ if (off.offloaded) {
335
+ return {
336
+ _meta: META,
337
+ scannedFiles: Object.keys(fileContents).length,
338
+ findingCount: off.total,
339
+ offloaded: true,
340
+ head: off.head, tail: off.tail,
341
+ scratchpadPath: off.scratchpadPath,
342
+ pagingHint: off.pagingHint,
343
+ };
344
+ }
345
+ return {
346
+ _meta: META,
347
+ scannedFiles: Object.keys(fileContents).length,
348
+ findingCount: findings.length,
349
+ findings,
350
+ };
351
+ },
352
+ };
353
+
354
+ // ─── query_taint ─────────────────────────────────────────────────────────────
355
+ export const query_taint = {
356
+ name: 'query_taint',
357
+ description: 'Query whether the last verified scan found a taint path involving a given source and sink. Paginated — returns up to `limit` matches (default 10, max 50) starting at `offset` (default 0); set `truncated:true` and `totalMatches` tell you when to page.',
358
+ inputSchema: {
359
+ type: 'object',
360
+ additionalProperties: false,
361
+ properties: {
362
+ source: { type: 'string', minLength: 1, maxLength: 256 },
363
+ sink: { type: 'string', minLength: 1, maxLength: 256 },
364
+ limit: { type: 'integer', minimum: 1, maximum: 50 },
365
+ offset: { type: 'integer', minimum: 0, maximum: 10000 },
366
+ },
367
+ required: ['source', 'sink'],
368
+ },
369
+ async handler({ source, sink, limit, offset }, ctx) {
370
+ const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
371
+ if (!scan) {
372
+ return { _meta: META, hasResult: false, status, message: `No usable scan state (${status}).` };
373
+ }
374
+ const lim = Number.isInteger(limit) ? Math.min(50, Math.max(1, limit)) : 10;
375
+ const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
376
+ const srcL = String(source).toLowerCase();
377
+ const sinkL = String(sink).toLowerCase();
378
+ // Filter first (cheap), then paginate (so totalMatches is accurate).
379
+ // Harness-engineering note (post-derived): "context window != context
380
+ // attention." Returning hundreds of matches to the agent in one shot
381
+ // dilutes its reasoning; the agent receives a bounded slice plus the
382
+ // cursor to fetch the rest if it wants.
383
+ const all = (scan.findings || []).filter(f => {
384
+ const hay = [f.description, f.title, f.vuln, f.snippet, JSON.stringify(f.trace || '')].join(' ').toLowerCase();
385
+ return hay.includes(srcL) && hay.includes(sinkL);
386
+ });
387
+ const page = all.slice(off, off + lim).map(f => redactFinding({
388
+ id: f.id, severity: f.severity, file: f.file, line: f.line,
389
+ title: f.title || f.vuln, description: f.description,
390
+ trace: f.trace || null,
391
+ }));
392
+ return {
393
+ _meta: META,
394
+ hasResult: true,
395
+ integrity: status,
396
+ scanStartedAt: scan.startedAt || scan.meta?.startedAt || null,
397
+ totalMatches: all.length,
398
+ matchCount: page.length,
399
+ offset: off,
400
+ limit: lim,
401
+ truncated: off + page.length < all.length,
402
+ nextOffset: off + page.length < all.length ? off + page.length : null,
403
+ matches: page,
404
+ };
405
+ },
406
+ };
407
+
408
+ // ─── explain_finding ─────────────────────────────────────────────────────────
409
+ export const explain_finding = {
410
+ name: 'explain_finding',
411
+ description: 'Return full details for a single finding from the last verified scan. Snippet/description redacted of secret patterns.',
412
+ inputSchema: {
413
+ type: 'object',
414
+ additionalProperties: false,
415
+ properties: {
416
+ finding_id: { type: 'string', minLength: 1, maxLength: 256 },
417
+ },
418
+ required: ['finding_id'],
419
+ },
420
+ async handler({ finding_id }, ctx) {
421
+ const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
422
+ if (!scan) throw new Error(`No usable scan state (${status}).`);
423
+ const f = _findById(scan, finding_id);
424
+ if (!f) throw new Error(`Finding not found: ${finding_id}`);
425
+ const redacted = redactFinding({
426
+ id: f.id, severity: f.severity, file: f.file, line: f.line,
427
+ title: f.title || f.vuln, cwe: f.cwe,
428
+ description: f.description, remediation: f.remediation,
429
+ snippet: f.snippet || null,
430
+ trace: f.trace || null,
431
+ });
432
+ // Harness-anatomy #1: explain_finding's trace is the most-likely-large
433
+ // field on a single finding. Offload when it crosses the threshold so
434
+ // the agent gets a head/tail preview, not a 50-step trace dumped into
435
+ // its context.
436
+ let traceTrimmed = redacted.trace;
437
+ let traceMeta = null;
438
+ if (Array.isArray(redacted.trace) && redacted.trace.length > OFFLOAD_THRESHOLD) {
439
+ const off = _maybeOffload(ctx.sessionRoot, 'explain_finding-trace', redacted.trace);
440
+ if (off.offloaded) {
441
+ traceTrimmed = [...off.head, { _gap: `... ${off.total - off.head.length - off.tail.length} more steps elided; read scratchpad ...` }, ...off.tail];
442
+ traceMeta = {
443
+ totalSteps: off.total,
444
+ scratchpadPath: off.scratchpadPath,
445
+ pagingHint: off.pagingHint,
446
+ };
447
+ }
448
+ }
449
+ return {
450
+ _meta: META,
451
+ ...redacted,
452
+ trace: traceTrimmed,
453
+ traceOffload: traceMeta,
454
+ confidence: f.confidence ?? null,
455
+ hasReplacementFix: typeof f.fix?.replacement === 'string',
456
+ integrity: status,
457
+ };
458
+ },
459
+ };
460
+
461
+ // ─── apply_fix ───────────────────────────────────────────────────────────────
462
+ export const apply_fix = {
463
+ name: 'apply_fix',
464
+ 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.',
465
+ inputSchema: {
466
+ type: 'object',
467
+ additionalProperties: false,
468
+ properties: {
469
+ finding_id: { type: 'string', minLength: 1, maxLength: 256 },
470
+ confirm: { type: 'boolean' },
471
+ dry_run: { type: 'boolean' },
472
+ },
473
+ required: ['finding_id', 'confirm'],
474
+ },
475
+ async handler({ finding_id, confirm, dry_run = false }, ctx) {
476
+ if (confirm !== true) {
477
+ return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
478
+ }
479
+ const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
480
+ if (!scan) {
481
+ return { _meta: META, applied: false, reason: `last-scan.json failed integrity check: ${status}. Run a fresh scan.` };
482
+ }
483
+ const f = _findById(scan, finding_id);
484
+ if (!f) return { _meta: META, applied: false, reason: `Finding not found: ${finding_id}` };
485
+ if (f._shadow === true) {
486
+ return { _meta: META, applied: false, reason: 'shadow findings cannot be auto-applied' };
487
+ }
488
+ if (typeof f.fix?.replacement !== 'string') {
489
+ // Premortem #2: templates are patch-shaped text. Same reasoning as
490
+ // the replacement path — do NOT pass through redactString here.
491
+ return {
492
+ _meta: META, applied: false,
493
+ reason: 'No full replacement available — only a template. Apply the template manually.',
494
+ template: f.fix?.code || '',
495
+ file: f.file, line: f.line,
496
+ };
497
+ }
498
+ let absFile;
499
+ try { absFile = _confine(ctx.sessionRoot, f.file, 'finding.file'); }
500
+ catch (e) {
501
+ return { _meta: META, applied: false, reason: `path-escape refused: ${e.message}` };
502
+ }
503
+ if (_isReservedWritePath(ctx.sessionRoot, absFile)) {
504
+ return { _meta: META, applied: false, reason: `reserved path refused: writes to .git/, .agentic-security/, or node_modules/ are not permitted via apply_fix` };
505
+ }
506
+ if (!fs.existsSync(absFile)) {
507
+ return { _meta: META, applied: false, reason: `File not found: ${absFile}` };
508
+ }
509
+ const originalContent = await fsp.readFile(absFile, 'utf8');
510
+
511
+ if (dry_run) {
512
+ return {
513
+ _meta: META,
514
+ applied: false, dryRun: true,
515
+ file: f.file,
516
+ originalSize: originalContent.length,
517
+ newSize: f.fix.replacement.length,
518
+ diffSummary: `${originalContent.length} → ${f.fix.replacement.length} bytes`,
519
+ };
520
+ }
521
+
522
+ let entry;
523
+ try {
524
+ entry = await applyFixHistory({
525
+ scanRoot: ctx.sessionRoot,
526
+ file: f.file,
527
+ originalContent,
528
+ newContent: f.fix.replacement,
529
+ findingId: f.id,
530
+ stableId: f.stableId || null, // premortem 4R-8
531
+ ruleId: f.rule || null,
532
+ vuln: f.vuln || f.title || null,
533
+ });
534
+ } catch (e) {
535
+ // Harness-engineering: step-budget refusal (post-derived). The
536
+ // deterministic layer enforces at-most-N attempts per stableId. When
537
+ // exceeded, surface it as a structured `budget-exceeded` outcome the
538
+ // agent can recognize — not a generic error.
539
+ if (e && e.name === 'FixAttemptBudgetExceededError') {
540
+ return {
541
+ _meta: META,
542
+ applied: false,
543
+ reason: `budget-exceeded: ${e.message}`,
544
+ budgetExceeded: true,
545
+ attempts: e.attempts,
546
+ maxAttempts: e.max,
547
+ key: e.key,
548
+ };
549
+ }
550
+ throw e;
551
+ }
552
+ return { _meta: META, applied: true, historyId: entry.id, file: f.file, backupPath: entry.backupPath, integrity: status, attemptOrdinal: entry.attemptOrdinal };
553
+ },
554
+ };
555
+
556
+ // ─── verify_fix ──────────────────────────────────────────────────────────────
557
+ // Closed-loop verification of a proposed patch BEFORE the agent applies it.
558
+ // Re-scans the patched files in-memory (no disk write), confirms the original
559
+ // stableId is gone, and runs the project's existing linter on the patched
560
+ // files. Returns a structured verdict the agent can use to decide whether to
561
+ // proceed with apply_fix.
562
+ export const verify_fix = {
563
+ name: 'verify_fix',
564
+ 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.',
565
+ inputSchema: {
566
+ type: 'object',
567
+ additionalProperties: false,
568
+ properties: {
569
+ stable_id: { type: 'string', minLength: 8, maxLength: 64 },
570
+ files: {
571
+ type: 'object',
572
+ additionalProperties: { type: 'string', maxLength: 500_000 },
573
+ minProperties: 1,
574
+ maxProperties: 8,
575
+ },
576
+ },
577
+ required: ['stable_id', 'files'],
578
+ },
579
+ async handler({ stable_id, files }, ctx) {
580
+ // Confine every file path before passing to the verifier.
581
+ const confined = {};
582
+ for (const [relPath, content] of Object.entries(files || {})) {
583
+ try {
584
+ _confine(ctx.sessionRoot, relPath, 'files key');
585
+ } catch (e) {
586
+ return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` };
587
+ }
588
+ confined[relPath] = String(content);
589
+ }
590
+ try {
591
+ const r = await verifyFixCore({
592
+ scanRoot: ctx.sessionRoot,
593
+ originalFindingStableId: stable_id,
594
+ files: confined,
595
+ });
596
+ return {
597
+ _meta: META,
598
+ ok: r.ok,
599
+ rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
600
+ lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: redactString(r.lint.output || '').slice(0, 1500) },
601
+ summary: r.summary,
602
+ };
603
+ } catch (e) {
604
+ return { _meta: META, ok: false, reason: `verify_fix failed: ${e.message}` };
605
+ }
606
+ },
607
+ };
608
+
609
+ // ─── synthesize_fix ──────────────────────────────────────────────────────────
610
+ // Return the stored fix replacement + regression-test scaffold for a finding,
611
+ // WITHOUT applying anything. The agent can call verify_fix → apply_fix in
612
+ // sequence with the returned blob.
613
+ export const synthesize_fix = {
614
+ name: 'synthesize_fix',
615
+ description: 'Return the stored fix replacement for a finding (replacement text + remediation + plan if the patch is too large). Read-only; never writes to disk. Use verify_fix → apply_fix to deploy.',
616
+ inputSchema: {
617
+ type: 'object',
618
+ additionalProperties: false,
619
+ properties: {
620
+ finding_id: { type: 'string', minLength: 1, maxLength: 256 },
621
+ },
622
+ required: ['finding_id'],
623
+ },
624
+ async handler({ finding_id }, ctx) {
625
+ const { scan, status } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: false });
626
+ if (!scan) {
627
+ return { _meta: META, ok: false, reason: `last-scan.json failed integrity check: ${status}` };
628
+ }
629
+ const f = _findById(scan, finding_id);
630
+ if (!f) return { _meta: META, ok: false, reason: `Finding not found: ${finding_id}` };
631
+ if (f._shadow === true) return { _meta: META, ok: false, reason: 'shadow findings have no synthesized fix' };
632
+ const fix = f.fix || {};
633
+ const hasReplacement = typeof fix.replacement === 'string' && fix.replacement.length > 0;
634
+ // Patch bounds: count files touched + LoC delta.
635
+ let touchedFiles = 1;
636
+ let locDelta = 0;
637
+ if (hasReplacement) {
638
+ let orig = '';
639
+ try {
640
+ const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
641
+ orig = fs.readFileSync(abs, 'utf8');
642
+ } catch { /* ignore — counts will reflect new-only LoC */ }
643
+ locDelta = Math.abs(fix.replacement.split('\n').length - orig.split('\n').length);
644
+ }
645
+ const oversized = touchedFiles > 3 || locDelta > 100;
646
+ // Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
647
+ // not a finding excerpt. Running it through redactString silently corrupts
648
+ // valid patches whose content happens to match a secret-shape (e.g. a
649
+ // placeholder like `password = "loadFromEnv"`). Patches MUST pass through
650
+ // verbatim. Snippet/description/etc. continue to be redacted in
651
+ // explain_finding / scan_diff — that's the right surface for redaction.
652
+ return {
653
+ _meta: META,
654
+ ok: true,
655
+ stable_id: f.stableId || null,
656
+ file: f.file, line: f.line,
657
+ vuln: f.vuln,
658
+ severity: f.severity,
659
+ hasReplacement,
660
+ replacement: hasReplacement ? fix.replacement : null,
661
+ template: fix.code || null,
662
+ remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
663
+ patchBounds: { touchedFiles, locDelta, oversized },
664
+ recommendsFixPlan: oversized && !hasReplacement,
665
+ };
666
+ },
667
+ };
668
+
669
+ // ─── find_rule_module ───────────────────────────────────────────────────────
670
+ // Codebase-navigation helper (C.6). Answers "which file under scanner/src/
671
+ // implements the detector for CWE-X / family Y" by scanning the SAST and
672
+ // posture sources for `cwe:` / `family:` literals. Cheaper and more reliable
673
+ // than asking the agent to grep — premortem note: "grep for a common function
674
+ // name in a large codebase returns thousands of matches."
675
+ //
676
+ // Read-only; no findings consumed. Output is a list of file paths + the
677
+ // matching literal lines so the agent can verify before editing.
678
+ export const find_rule_module = {
679
+ name: 'find_rule_module',
680
+ description: 'Find the file(s) under scanner/src/{sast,posture}/ that emit findings for a given CWE id or family name. Use BEFORE editing a rule — answers "where is the SQL-injection detector?" without grepping the whole tree. Returns at most 20 hits; refine the query if too broad.',
681
+ inputSchema: {
682
+ type: 'object',
683
+ additionalProperties: false,
684
+ properties: {
685
+ cwe: { type: 'string', minLength: 5, maxLength: 16 },
686
+ family: { type: 'string', minLength: 2, maxLength: 64 },
687
+ },
688
+ },
689
+ async handler({ cwe, family }, ctx) {
690
+ if (!cwe && !family) {
691
+ return { _meta: META, ok: false, reason: 'provide cwe (e.g. "CWE-89") or family (e.g. "sql-injection")' };
692
+ }
693
+ // Pattern enforcement — the mini-schema validator doesn't do `pattern`.
694
+ if (cwe && !/^CWE-\d+$/.test(cwe)) {
695
+ return { _meta: META, ok: false, reason: 'cwe must match /^CWE-\\d+$/ (e.g. "CWE-89")' };
696
+ }
697
+ if (family && !/^[a-z][a-z0-9-]+$/.test(family)) {
698
+ return { _meta: META, ok: false, reason: 'family must match /^[a-z][a-z0-9-]+$/ (e.g. "sql-injection")' };
699
+ }
700
+ const sessionRoot = ctx.sessionRoot;
701
+ const roots = [
702
+ path.join(sessionRoot, 'scanner', 'src', 'sast'),
703
+ path.join(sessionRoot, 'scanner', 'src', 'posture'),
704
+ ];
705
+ const hits = [];
706
+ const cweLit = cwe ? new RegExp(`['"\`]${cwe.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`) : null;
707
+ // Family match is broader on purpose: detectors often emit findings
708
+ // without an explicit `family:` field (it's backfilled by
709
+ // posture/finding-defaults.js). We match the family literal anywhere in
710
+ // the file (vuln-name strings, comments, ids) so e.g. searching for "csrf"
711
+ // surfaces sast/csrf.js even though it doesn't tag findings with the field.
712
+ const famLit = family ? new RegExp(`\\b${family.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '[-_ ]?')}\\b`, 'i') : null;
713
+ // Also try a filename-stem match when only family is given.
714
+ const famFilename = family ? family.toLowerCase() : null;
715
+ for (const root of roots) {
716
+ if (!fs.existsSync(root)) continue;
717
+ let entries;
718
+ try { entries = fs.readdirSync(root); } catch { continue; }
719
+ for (const entry of entries) {
720
+ if (!entry.endsWith('.js')) continue;
721
+ const abs = path.join(root, entry);
722
+ let stat;
723
+ try { stat = fs.statSync(abs); } catch { continue; }
724
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue;
725
+ let body;
726
+ try { body = fs.readFileSync(abs, 'utf8'); } catch { continue; }
727
+ const lines = body.split('\n');
728
+ const matches = [];
729
+ const stem = entry.replace(/\.js$/, '').toLowerCase();
730
+ const filenameMatchesFamily = famFilename && (stem === famFilename || stem.includes(famFilename));
731
+ if (filenameMatchesFamily) {
732
+ matches.push({ line: 1, text: `<filename "${entry}" matches family>`, kind: 'filename' });
733
+ }
734
+ for (let i = 0; i < lines.length; i++) {
735
+ const line = lines[i];
736
+ if (cweLit && cweLit.test(line)) matches.push({ line: i + 1, text: line.trim().slice(0, 200), kind: 'cwe' });
737
+ else if (famLit && famLit.test(line)) matches.push({ line: i + 1, text: line.trim().slice(0, 200), kind: 'family' });
738
+ if (matches.length >= 5) break;
739
+ }
740
+ if (matches.length) {
741
+ hits.push({
742
+ file: path.relative(sessionRoot, abs).replace(/\\/g, '/'),
743
+ matchCount: matches.length,
744
+ matches,
745
+ });
746
+ if (hits.length >= 20) break;
747
+ }
748
+ }
749
+ if (hits.length >= 20) break;
750
+ }
751
+ return {
752
+ _meta: META,
753
+ ok: true,
754
+ query: { cwe: cwe || null, family: family || null },
755
+ hitCount: hits.length,
756
+ hits,
757
+ truncated: hits.length >= 20,
758
+ };
759
+ },
760
+ };
761
+
762
+ // ─── append_scratchpad / read_scratchpad ───────────────────────────────────
763
+ // LangChain harness-anatomy: the filesystem is the durable agent scratchpad.
764
+ // These tools expose a tightly-confined slice of the project tree for
765
+ // in-progress agent state: PLAN.md decompositions, offloaded tool outputs,
766
+ // session notes that survive context resets.
767
+ //
768
+ // Confinement (validated in `_validateScratchpadPath`):
769
+ // ALL paths must start with `.agentic-security/agent-scratchpad/<agent>/<session>/`
770
+ // and consist of [A-Za-z0-9_.-]{1,64} path components — no `..`, no
771
+ // absolute paths, no shell metacharacters. This is the ONE place inside
772
+ // the otherwise-reserved `.agentic-security/` tree where agents can write.
773
+ // Limits:
774
+ // - 2 MB per file (write attempts beyond this are refused).
775
+ // - 50 MB total across the scratchpad — protects against runaway agents.
776
+ // Operators who want to clean up: `rm -rf .agentic-security/agent-scratchpad`.
777
+ //
778
+ // The post: "Agents can store intermediate outputs and maintain state that
779
+ // outlasts a single session." This is that mechanism.
780
+
781
+ export const append_scratchpad = {
782
+ name: 'append_scratchpad',
783
+ description: 'Append text to a file under .agentic-security/agent-scratchpad/<agent>/<session>/. The ONLY writable location for in-progress agent state (PLAN.md, notes, offloaded tool outputs, decision logs). Path must start with that prefix; <agent>/<session>/file parts are restricted to [A-Za-z0-9_.-]{1,64}. Caps: 2 MB per file, 50 MB total across the scratchpad.',
784
+ inputSchema: {
785
+ type: 'object',
786
+ additionalProperties: false,
787
+ properties: {
788
+ path: { type: 'string', minLength: 1, maxLength: 256 },
789
+ content: { type: 'string', minLength: 1, maxLength: 256 * 1024 },
790
+ },
791
+ required: ['path', 'content'],
792
+ },
793
+ async handler({ path: relPath, content }, ctx) {
794
+ const v = _validateScratchpadPath(relPath);
795
+ if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
796
+ const abs = _scratchpadAbs(ctx.sessionRoot, relPath);
797
+ const total = _scratchpadTotalBytes(ctx.sessionRoot);
798
+ if (total + content.length > SCRATCHPAD_MAX_TOTAL_BYTES) {
799
+ return {
800
+ _meta: META, ok: false,
801
+ reason: `scratchpad-total-exceeded: ${total} + ${content.length} > ${SCRATCHPAD_MAX_TOTAL_BYTES}. Clean up via "rm -rf .agentic-security/agent-scratchpad" or rotate sessions.`,
802
+ };
803
+ }
804
+ let existing = 0;
805
+ try { if (fs.existsSync(abs)) existing = fs.statSync(abs).size; } catch {}
806
+ if (existing + content.length > SCRATCHPAD_MAX_FILE_BYTES) {
807
+ return {
808
+ _meta: META, ok: false,
809
+ reason: `scratchpad-file-exceeded: ${existing} + ${content.length} > ${SCRATCHPAD_MAX_FILE_BYTES}. Start a new file.`,
810
+ };
811
+ }
812
+ try {
813
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
814
+ fs.appendFileSync(abs, content);
815
+ return {
816
+ _meta: META, ok: true,
817
+ path: relPath, bytesWritten: content.length, fileSize: existing + content.length,
818
+ scratchpadTotal: total + content.length,
819
+ };
820
+ } catch (e) {
821
+ return { _meta: META, ok: false, reason: `write-failed: ${e.message}` };
822
+ }
823
+ },
824
+ };
825
+
826
+ export const read_scratchpad = {
827
+ name: 'read_scratchpad',
828
+ description: 'Read a file under .agentic-security/agent-scratchpad/<agent>/<session>/. Paginated for large files via `offset` (default 0) and `limit` (default 4096 bytes, max 64 KB). Returns bytesRead, truncated, nextOffset for paging.',
829
+ inputSchema: {
830
+ type: 'object',
831
+ additionalProperties: false,
832
+ properties: {
833
+ path: { type: 'string', minLength: 1, maxLength: 256 },
834
+ offset: { type: 'integer', minimum: 0, maximum: 100 * 1024 * 1024 },
835
+ limit: { type: 'integer', minimum: 1, maximum: 64 * 1024 },
836
+ },
837
+ required: ['path'],
838
+ },
839
+ async handler({ path: relPath, offset, limit }, ctx) {
840
+ const v = _validateScratchpadPath(relPath);
841
+ if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
842
+ const abs = _scratchpadAbs(ctx.sessionRoot, relPath);
843
+ if (!fs.existsSync(abs)) return { _meta: META, ok: false, reason: 'not-found' };
844
+ let stat;
845
+ try { stat = fs.statSync(abs); } catch (e) { return { _meta: META, ok: false, reason: `stat-failed: ${e.message}` }; }
846
+ if (!stat.isFile()) return { _meta: META, ok: false, reason: 'not-a-file' };
847
+ const off = Number.isInteger(offset) ? Math.max(0, offset) : 0;
848
+ const lim = Number.isInteger(limit) ? Math.min(64 * 1024, Math.max(1, limit)) : 4096;
849
+ let buf;
850
+ try {
851
+ const fd = fs.openSync(abs, 'r');
852
+ const tmp = Buffer.alloc(lim);
853
+ const read = fs.readSync(fd, tmp, 0, lim, off);
854
+ fs.closeSync(fd);
855
+ buf = tmp.slice(0, read);
856
+ } catch (e) { return { _meta: META, ok: false, reason: `read-failed: ${e.message}` }; }
857
+ const text = buf.toString('utf8');
858
+ return {
859
+ _meta: META, ok: true,
860
+ path: relPath,
861
+ offset: off, limit: lim, bytesRead: buf.length,
862
+ totalSize: stat.size,
863
+ truncated: off + buf.length < stat.size,
864
+ nextOffset: off + buf.length < stat.size ? off + buf.length : null,
865
+ content: text,
866
+ };
867
+ },
868
+ };
869
+
870
+ // ─── append_agents_memory / read_agents_memory ─────────────────────────────
871
+ // LangChain harness-anatomy #2: AGENTS.md as continual-learning surface.
872
+ // Lazy-import to keep the MCP module dependency-light.
873
+ import { appendAgentsMemory as _appendAgentsMemory, readAgentsMemory as _readAgentsMemory } from '../posture/agents-memory.js';
874
+ import { lookupCve as _lookupCve } from '../posture/cve-lookup.js';
875
+
876
+ export const append_agents_memory = {
877
+ name: 'append_agents_memory',
878
+ 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.',
879
+ inputSchema: {
880
+ type: 'object',
881
+ additionalProperties: false,
882
+ properties: {
883
+ agent: { type: 'string', minLength: 1, maxLength: 64 },
884
+ body: { type: 'string', minLength: 1, maxLength: 4096 },
885
+ },
886
+ required: ['agent', 'body'],
887
+ },
888
+ async handler({ agent, body }, ctx) {
889
+ const r = _appendAgentsMemory(ctx.sessionRoot, { agent, body });
890
+ return { _meta: META, ...r };
891
+ },
892
+ };
893
+
894
+ export const read_agents_memory = {
895
+ name: 'read_agents_memory',
896
+ description: 'Read the AGENTS.md continual-learning file (and AGENTS.md.archive if needed). Returns the most-recent ~6 KB tail by default; pass `full: true` for everything. The SessionStart hook already surfaces a summary; use this when an agent wants to look up specifics mid-session.',
897
+ inputSchema: {
898
+ type: 'object',
899
+ additionalProperties: false,
900
+ properties: {
901
+ full: { type: 'boolean' },
902
+ },
903
+ },
904
+ async handler({ full }, ctx) {
905
+ const body = _readAgentsMemory(ctx.sessionRoot);
906
+ if (!body) return { _meta: META, present: false };
907
+ if (full) return { _meta: META, present: true, length: body.length, content: body };
908
+ // Tail-only — same logic as summarizeForSession but inlined to avoid a
909
+ // second import surface.
910
+ const limit = 6 * 1024;
911
+ if (body.length <= limit) return { _meta: META, present: true, length: body.length, content: body };
912
+ const tail = body.slice(-limit);
913
+ const firstSection = tail.indexOf('\n## ');
914
+ const slice = firstSection >= 0 ? tail.slice(firstSection) : tail;
915
+ return { _meta: META, present: true, length: body.length, truncated: true, content: slice };
916
+ },
917
+ };
918
+
919
+ // ─── lookup_cve ────────────────────────────────────────────────────────────
920
+ // LangChain harness-anatomy #8: bridge the knowledge-cutoff gap by exposing
921
+ // the local OSV / KEV / EPSS cache as a structured tool. Read-only — never
922
+ // triggers a network fetch from the MCP path.
923
+ export const lookup_cve = {
924
+ name: 'lookup_cve',
925
+ description: 'Look up a CVE id in the local OSV / KEV / EPSS caches. Returns staleness-tiered cached data (fresh / recent / stale / very-stale). Read-only — does NOT fetch fresh data; the scan pipeline is the only thing that populates the cache. Use to inform reasoning about an SCA finding without relying on the model\'s training cutoff.',
926
+ inputSchema: {
927
+ type: 'object',
928
+ additionalProperties: false,
929
+ properties: {
930
+ cve: { type: 'string', minLength: 9, maxLength: 20 },
931
+ },
932
+ required: ['cve'],
933
+ },
934
+ async handler({ cve }, _ctx) {
935
+ const r = _lookupCve(cve);
936
+ return { _meta: META, ...r };
937
+ },
938
+ };
939
+
940
+ export 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];