@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,24 @@
1
+ {
2
+ "firstScanDate": "2026-05-13T19:07:35.663Z",
3
+ "lastScanDate": "2026-05-20T15:34:20.296Z",
4
+ "totalScans": 223,
5
+ "daysCleanCritical": 0,
6
+ "lastCleanDate": "2026-05-19",
7
+ "lastCriticalDate": "2026-05-20",
8
+ "hasEverHadCritical": true,
9
+ "bestDaysCleanCritical": 2,
10
+ "totalFindingsAtFirstScan": 28,
11
+ "totalFindingsAtLastScan": 257,
12
+ "totalFixesInferred": 1,
13
+ "lastGrade": "C",
14
+ "bestGrade": "A",
15
+ "launchCheckPassedAt": null,
16
+ "achievements": [
17
+ "first-fix",
18
+ "first-scan",
19
+ "grade-a",
20
+ "scan-veteran-100",
21
+ "scan-veteran-25"
22
+ ],
23
+ "previousGrade": "C"
24
+ }
@@ -0,0 +1,42 @@
1
+ # scanner/src/posture/
2
+
3
+ Annotators that run **after** every detector has emitted, plus state stores read by slash commands. 90+ modules — almost all small. Pattern: each module exports a function the engine wires into the annotation pipeline (`annotateX(findings, ctx)`), or a state-read/write helper (`loadX(scanRoot)`).
4
+
5
+ ## What goes where (categories, not exhaustive)
6
+
7
+ **Annotation pipeline (mutate findings in place — order matters in `engine.js`)**
8
+ `finding-defaults` → `stable-id` → `clustering` → `reachability-filter` → `confidence` → `calibration` → `exploitability` → `mitigation-composite` → `persona-prioritization` → `why-fired`. The order is encoded in `engine.js`; if you add an annotator, decide whether it consumes upstream signals (confidence, family, parser) and place it after those.
9
+
10
+ **Calibration + held-out evaluation** — `calibration.js`, `calibration-drift.js`, `validator-metrics.js`, `holdout-eval.js`. The seed corpus lives at `calibration-seed.json`; held-out labels are taken via `loadLabeledJsonl`. Brier and ECE both live in `holdout-eval.js`; never reintroduce a "fit-on-the-table" version.
11
+
12
+ **Cross-language taint** — `cross-lang-{openapi,grpc,graphql,orm,queues,meta}.js`. Each parses a contract artifact (`openapi.json`, `*.proto`, `*.graphql`, queue config) and emits a chain finding when the same data crosses a language boundary into another module's finding.
13
+
14
+ **Risk amplification** — `epss.js`, `kev` (in `version.js`), `blast-radius.js`, `crown-jewels.js`, `exploitability.js`, `bounty-prediction.js`, `risk-in-dollars` (lives in `scripts/`, not here).
15
+
16
+ **Production-posture ingest** — `auth-posture-import.js`, `network-policy-import.js`, `telemetry-ingest.js`, `waf-ingest.js`, `feature-flags.js`. These read customer-side YAML and convert to mitigation flags consumed by `mitigation-composite.js`.
17
+
18
+ **Fix lifecycle** — `fix-history.js` (apply + backup + recover), `fix-verify.js` (closed-loop re-scan + lint), `fix-plan.js` (oversized-patch fallback), `regression-test-gen.js`.
19
+
20
+ **Agentic verification** — `verifier.js`, `verifier-target.js`, `verifier-ephemeral.js`, `harness-discovery.js`, `adversary-agent.js`, `defender-agent.js`, `auditor-agent.js`, `three-agent-pipeline.js`.
21
+
22
+ **Integrity + signing** — `integrity.js` (per-install HMAC for `last-scan.json`), `rule-pack-signing.js`. The HMAC key lives at `$XDG_CONFIG_HOME/agentic-security/scan-key`; override via `$AGENTIC_SECURITY_HMAC_KEY`. Premortem-derived; do not regress to hostname-derived.
23
+
24
+ **Rule lifecycle** — `custom-rules.js` (YAML pattern DSL), `rule-overrides.js` (`disable:` gated on signature), `rule-packs.js`, `rule-synthesis.js` (proposes suppressions from triage feedback), `ruleset-version.js`.
25
+
26
+ **Posture artifacts** — `sbom.js`, `aibom.js`, `api-inventory.js`, `threat-model.js`, `trust-boundary-diagram.js`, `stack-playbook.js`, `deploy-platform.js`, `license-policy.js`, `material-change.js`, `mttr.js`, `streak.js`, `scorecard.js`, `security-trend.js`.
27
+
28
+ **Why this fired** — `why-fired.js`. Runs LAST so it reflects every annotation. Customer-facing provenance.
29
+
30
+ ## Conventions
31
+
32
+ - **Mutate or copy?** Annotators that set finding fields mutate in place. Helpers that derive a *new* finding list (clustering, dead-code) return a new array.
33
+ - **State files.** All state goes under `.agentic-security/` (scan root) or `~/.config/agentic-security/` (per-install). Never write to the scanner source tree.
34
+ - **Annotation order matters.** If your annotator reads `f.confidence`, run it after `annotateConfidence`. If it reads `f.exploitability`, run it after `annotateExploitability`. Wire in `engine.js`, not in `index.js`.
35
+ - **No throwing.** Every annotation in `engine.js` is wrapped `try { … } catch (_) {}`. Your annotator must degrade gracefully — set `null` on the field and continue.
36
+ - **Dead-module test.** `npm run test:lifecycle` fails the build if you export a public symbol from a posture module that no other source file imports. Wire it in `engine.js` (or allowlist it with a written reason in `test/no-dead-modules.test.js`).
37
+
38
+ ## Gotchas
39
+
40
+ - The seed `calibration-seed.json` is small (n < 30 for several families). Don't treat it as a held-out set — that's `holdout-eval.js`'s job, against an externally-supplied JSONL.
41
+ - `learning.js` (active-learning loop) is **opt-in** behind `AGENTIC_SECURITY_LEARN=1` and has a quorum gate. Do not lower the quorum default without thinking about what a malicious-PR-author could suppress.
42
+ - `why-fired.js` is the provenance surface customers screenshot. If you change its shape, downstream reports break — bump a version string and migrate consumers.
@@ -0,0 +1,114 @@
1
+ // FR-LEARN-8 — Adversarial self-test (mutation harness).
2
+ //
3
+ // Generate adversarial source-code variants from known-vuln fixtures and
4
+ // re-scan them. Variants that the scanner FAILS to detect (false negatives)
5
+ // are surfaced as `rule-gap` records for review and prioritization in the
6
+ // next rule-pack release. Variants that the scanner still catches confirm
7
+ // robustness.
8
+ //
9
+ // This module exposes:
10
+ // mutateSnippet(code, family) — produces 1..N adversarial variants of `code`
11
+ // intended to defeat the family's detector
12
+ // buildMutationCorpus(fixtures) — applies mutations across the fixture set
13
+ //
14
+ // It does NOT run the scanner itself — that's a CLI / bench-runner concern.
15
+ // The orchestrator is `scripts/run-self-test.mjs` (created separately).
16
+ //
17
+ // Mutation strategies per family:
18
+ // sql-injection:
19
+ // - rename `query` → `q`, `db` → `client` (identifier obfuscation)
20
+ // - wrap concat in `String(x)` or `${x}` (template-literal obfuscation)
21
+ // - reorder: build the string in a helper that returns it
22
+ // command-injection:
23
+ // - swap `exec` → `execSync` → `spawn` (variant API surface)
24
+ // - introduce a no-op `sanitize(x)` that doesn't sanitize
25
+ // xss:
26
+ // - swap `innerHTML` for `outerHTML`, `insertAdjacentHTML`, `document.write`
27
+ // path-traversal:
28
+ // - introduce a noop `path.normalize` (allowed under the regex but still
29
+ // traversable when input is `..%2F` style)
30
+ // ssrf:
31
+ // - swap `fetch` for `axios`, `got`, `node-fetch`
32
+ // prototype-pollution:
33
+ // - swap `__proto__` for `["__proto__"]`, then for `["__pro" + "to__"]`
34
+
35
+ const STRATEGIES = {
36
+ 'sql-injection': [
37
+ (code) => code.replace(/\bquery\b/g, 'q').replace(/\bdb\b/g, 'client'),
38
+ (code) => code.replace(/'.*?\+.*?'/g, (m) => '`' + m.replace(/'/g, '') + '`'),
39
+ (code) => 'function buildQ(x) { return ' + JSON.stringify(code) + '; } buildQ(userInput);',
40
+ ],
41
+ 'command-injection': [
42
+ (code) => code.replace(/\bexec\b/g, 'execSync'),
43
+ (code) => code.replace(/\bexec\b/g, 'spawn'),
44
+ (code) => `function sanitize(x) { return x; }\n${code.replace(/exec\(([^)]+)\)/, 'exec(sanitize($1))')}`,
45
+ ],
46
+ 'xss': [
47
+ (code) => code.replace(/\.innerHTML\s*=/g, '.outerHTML ='),
48
+ (code) => code.replace(/\.innerHTML\s*=\s*(\w+)/, '.insertAdjacentHTML("beforeend", $1)'),
49
+ (code) => code.replace(/\.innerHTML\s*=\s*(\w+)/, 'document.write($1)'),
50
+ ],
51
+ 'path-traversal': [
52
+ (code) => `const path = require('path');\n${code.replace(/readFile\(/, 'readFile(path.normalize(')}`,
53
+ ],
54
+ 'ssrf': [
55
+ (code) => code.replace(/\bfetch\b/g, 'axios.get'),
56
+ (code) => code.replace(/\bfetch\b/g, 'require("got")'),
57
+ (code) => code.replace(/\bfetch\b/g, 'require("node-fetch")'),
58
+ ],
59
+ 'prototype-pollution': [
60
+ (code) => code.replace(/__proto__/g, '["__proto__"]'),
61
+ (code) => code.replace(/__proto__/g, '["__pro" + "to__"]'),
62
+ ],
63
+ 'webhook-no-signature': [
64
+ (code) => `${code}\nfunction verify(){ return true; }`,
65
+ ],
66
+ };
67
+
68
+ export function mutateSnippet(code, family) {
69
+ if (!code || typeof code !== 'string') return [];
70
+ const strategies = STRATEGIES[family] || [];
71
+ const variants = [];
72
+ for (const fn of strategies) {
73
+ try {
74
+ const v = fn(code);
75
+ if (v && v !== code) variants.push(v);
76
+ } catch {}
77
+ }
78
+ return variants;
79
+ }
80
+
81
+ // Build a mutation corpus from a fixture record { id, family, code }.
82
+ // Returns array of { fixtureId, family, originalCode, mutations: [{ strategy, code }] }.
83
+ export function buildMutationCorpus(fixtures) {
84
+ if (!Array.isArray(fixtures)) return [];
85
+ const out = [];
86
+ for (const fx of fixtures) {
87
+ if (!fx || !fx.family || !fx.code) continue;
88
+ const muts = mutateSnippet(fx.code, fx.family);
89
+ out.push({
90
+ fixtureId: fx.id || `${fx.family}-${fx.file || 'inline'}`,
91
+ family: fx.family,
92
+ originalCode: fx.code,
93
+ mutations: muts.map((c, i) => ({ strategy: `mut-${i + 1}`, code: c })),
94
+ });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ // Format a self-test report given a list of mutation runs and detector hits.
100
+ // Each run: { fixtureId, family, mutation, detectedByScanner }. The report
101
+ // lists ungapped strategies (still detected) and gaps (missed mutations).
102
+ export function summarizeSelfTest(runs) {
103
+ if (!Array.isArray(runs)) return { gaps: [], confirmed: [], totalRuns: 0 };
104
+ const gaps = runs.filter(r => r.detectedByScanner === false).map(r => ({
105
+ family: r.family,
106
+ fixtureId: r.fixtureId,
107
+ strategy: r.mutation?.strategy,
108
+ suggestion: `Add a fixture pair in scanner/test/fixtures/ that captures this mutation, then strengthen the ${r.family} detector to match.`,
109
+ }));
110
+ const confirmed = runs.filter(r => r.detectedByScanner === true).map(r => ({
111
+ family: r.family, fixtureId: r.fixtureId, strategy: r.mutation?.strategy,
112
+ }));
113
+ return { gaps, confirmed, totalRuns: runs.length };
114
+ }
@@ -0,0 +1,204 @@
1
+ // FR-ADV-1 — Multi-step adversary-agent skeleton.
2
+ //
3
+ // Given ONE finding and a live target URL (from FR-LIVE-HARNESS), an LLM
4
+ // agent operates with a bounded tool-call budget and emits a transcript
5
+ // showing what an attacker would actually DO with the finding.
6
+ //
7
+ // Tools available to the agent (each gated by an ACL — there is no shell,
8
+ // no filesystem write, no real DB. The agent operates against a sandboxed
9
+ // COPY of the target.):
10
+ //
11
+ // http.get(path, headers?) — read against the sandbox URL
12
+ // http.post(path, body, headers?) — write against the sandbox URL
13
+ // db.read_sandbox_copy(query) — read against the sandbox DB
14
+ // record_outcome(outcomeType, evidence) — terminate run, emit verdict
15
+ //
16
+ // Each tool call is hash-chained into the transcript so the trace is
17
+ // tamper-evident. Budget enforcement: ≤ MAX_CALLS calls, ≤ MAX_WALL_MS ms.
18
+ //
19
+ // This module is a SKELETON. It defines the transcript shape, the tool ACL,
20
+ // and the budget/timeout enforcement. It does NOT call any LLM endpoint by
21
+ // default — that wiring lives in the runner / `agentic-security verify
22
+ // --adversary-agent` CLI, which reads AGENTIC_SECURITY_LLM_ENDPOINT.
23
+ //
24
+ // When no LLM endpoint is configured, `runAgent` short-circuits with verdict
25
+ // `unverified-no-llm-endpoint` and the transcript records only the seed input.
26
+
27
+ import * as crypto from 'node:crypto';
28
+
29
+ const MAX_CALLS_DEFAULT = 50;
30
+ const MAX_WALL_MS_DEFAULT = 15 * 60 * 1000;
31
+
32
+ const TOOL_ACL = new Set([
33
+ 'http.get',
34
+ 'http.post',
35
+ 'db.read_sandbox_copy',
36
+ 'record_outcome',
37
+ ]);
38
+
39
+ const OUTCOMES = [
40
+ 'data-exfil',
41
+ 'priv-esc',
42
+ 'account-takeover',
43
+ 'financial-loss',
44
+ 'cleanup-traces',
45
+ 'failed',
46
+ 'aborted-budget',
47
+ 'aborted-timeout',
48
+ ];
49
+
50
+ function chainHash(prev, entry) {
51
+ const h = crypto.createHash('sha256');
52
+ h.update(prev || '');
53
+ h.update(JSON.stringify(entry));
54
+ return h.digest('hex').slice(0, 16);
55
+ }
56
+
57
+ export function startTranscript(finding, target) {
58
+ const seed = {
59
+ seedFinding: {
60
+ stableId: finding?.stableId || null,
61
+ file: finding?.file || null,
62
+ line: finding?.line || null,
63
+ vuln: finding?.vuln || null,
64
+ family: finding?.family || null,
65
+ },
66
+ target: target || null,
67
+ startedAt: new Date().toISOString(),
68
+ entries: [],
69
+ chainHead: '',
70
+ };
71
+ seed.chainHead = chainHash('', seed.seedFinding);
72
+ return seed;
73
+ }
74
+
75
+ export function appendEntry(transcript, entry) {
76
+ if (!transcript || !entry) return;
77
+ if (entry.tool && !TOOL_ACL.has(entry.tool)) {
78
+ entry = { ...entry, refused: true, refusedReason: `tool '${entry.tool}' is not in ACL` };
79
+ }
80
+ transcript.chainHead = chainHash(transcript.chainHead, entry);
81
+ transcript.entries.push({ ...entry, hash: transcript.chainHead });
82
+ }
83
+
84
+ export function isExceeded(transcript, budget) {
85
+ const calls = transcript.entries.filter(e => e.tool).length;
86
+ if (calls >= (budget.maxCalls ?? MAX_CALLS_DEFAULT)) return 'aborted-budget';
87
+ const startedAt = Date.parse(transcript.startedAt);
88
+ if (Number.isFinite(startedAt) && Date.now() - startedAt > (budget.maxWallMs ?? MAX_WALL_MS_DEFAULT)) {
89
+ return 'aborted-timeout';
90
+ }
91
+ return null;
92
+ }
93
+
94
+ // Default llmInvoke — calls AGENTIC_SECURITY_LLM_ENDPOINT with the transcript
95
+ // and parses the response as a single tool-call request. The endpoint is
96
+ // expected to follow an OpenAI-style chat-completions shape; we extract a
97
+ // JSON object of `{ tool, args }` from the model's reply.
98
+ export async function defaultLlmInvoke(transcript) {
99
+ const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
100
+ if (!endpoint) return null;
101
+ const model = process.env.AGENTIC_SECURITY_LLM_MODEL || 'gpt-4o-mini';
102
+ const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY || process.env.OPENAI_API_KEY || '';
103
+ const headers = { 'Content-Type': 'application/json' };
104
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
105
+ const tools = [...TOOL_ACL].map(t => ({ tool: t, description: TOOL_HELP[t] || '' }));
106
+ const body = {
107
+ model,
108
+ messages: [
109
+ { role: 'system', content: `You are an authorized security tester. You may ONLY call tools from this list: ${[...TOOL_ACL].join(', ')}. Reply with a single JSON object of the shape {"tool":"<name>","args":{...}}. Stop when you have evidence sufficient to call record_outcome.` },
110
+ { role: 'user', content: `Seed finding: ${JSON.stringify(transcript.seedFinding)}\nTarget: ${transcript.target}\nPrior entries: ${JSON.stringify(transcript.entries.slice(-8))}\nAvailable tools: ${JSON.stringify(tools)}` },
111
+ ],
112
+ max_tokens: 256,
113
+ temperature: 0.1,
114
+ };
115
+ let res;
116
+ try {
117
+ res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
118
+ } catch (e) {
119
+ return null;
120
+ }
121
+ if (!res.ok) return null;
122
+ let json;
123
+ try { json = await res.json(); } catch { return null; }
124
+ const text = json?.choices?.[0]?.message?.content ?? json?.message?.content ?? '';
125
+ if (!text) return null;
126
+ const m = /\{[\s\S]*\}/.exec(text);
127
+ if (!m) return null;
128
+ try { return JSON.parse(m[0]); } catch { return null; }
129
+ }
130
+
131
+ const TOOL_HELP = {
132
+ 'http.get': 'GET a path against the sandbox target URL',
133
+ 'http.post': 'POST a body to a path against the sandbox target URL',
134
+ 'db.read_sandbox_copy': 'Read-only query against the sandboxed DB copy',
135
+ 'record_outcome': 'Terminate the run with a verdict (outcome: one of OUTCOMES)',
136
+ };
137
+
138
+ // Default executeTool — wraps http.get / http.post against `transcript.target`.
139
+ // Falls back to refusal when the tool is db.read_sandbox_copy (we do not ship
140
+ // a sandboxed DB by default; the caller must supply one).
141
+ export async function defaultExecuteTool(call, transcript) {
142
+ if (!call || !TOOL_ACL.has(call.tool)) return { refused: true };
143
+ if (call.tool === 'http.get') {
144
+ const url = (transcript?.target || '').replace(/\/$/, '') + (call.args?.path || '/');
145
+ try {
146
+ const r = await fetch(url, { method: 'GET', headers: call.args?.headers || {} });
147
+ const body = await r.text();
148
+ return { status: r.status, headers: Object.fromEntries(r.headers), body: body.slice(0, 4000) };
149
+ } catch (e) { return { error: String(e?.message || e) }; }
150
+ }
151
+ if (call.tool === 'http.post') {
152
+ const url = (transcript?.target || '').replace(/\/$/, '') + (call.args?.path || '/');
153
+ try {
154
+ const r = await fetch(url, {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json', ...(call.args?.headers || {}) },
157
+ body: typeof call.args?.body === 'string' ? call.args.body : JSON.stringify(call.args?.body || {}),
158
+ });
159
+ const body = await r.text();
160
+ return { status: r.status, headers: Object.fromEntries(r.headers), body: body.slice(0, 4000) };
161
+ } catch (e) { return { error: String(e?.message || e) }; }
162
+ }
163
+ return { refused: true, reason: `tool ${call.tool} not implemented by defaultExecuteTool` };
164
+ }
165
+
166
+ // Run the agent loop. Without an `llmInvoke` callback AND without
167
+ // AGENTIC_SECURITY_LLM_ENDPOINT, this short-circuits to
168
+ // `unverified-no-llm-endpoint`. With either, it loops bounded by the budget.
169
+ export async function runAgent(finding, opts = {}) {
170
+ const transcript = startTranscript(finding, opts.target);
171
+ const budget = { maxCalls: opts.maxCalls, maxWallMs: opts.maxWallMs };
172
+
173
+ const llmInvoke = opts.llmInvoke || (process.env.AGENTIC_SECURITY_LLM_ENDPOINT ? defaultLlmInvoke : null);
174
+ const executeTool = opts.executeTool || (transcript.target ? (call) => defaultExecuteTool(call, transcript) : null);
175
+
176
+ if (typeof llmInvoke !== 'function' || typeof executeTool !== 'function') {
177
+ appendEntry(transcript, { phase: 'init', reason: 'no llmInvoke/executeTool supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set' });
178
+ return { transcript, outcome: 'unverified-no-llm-endpoint' };
179
+ }
180
+
181
+ let outcome = null;
182
+ while (!outcome) {
183
+ const reason = isExceeded(transcript, budget);
184
+ if (reason) { outcome = reason; break; }
185
+ let next;
186
+ try { next = await llmInvoke(transcript); }
187
+ catch (e) { appendEntry(transcript, { phase: 'llm-error', error: String(e?.message || e) }); outcome = 'failed'; break; }
188
+ if (!next || !next.tool) { outcome = 'failed'; appendEntry(transcript, { phase: 'no-tool', value: next }); break; }
189
+ if (!TOOL_ACL.has(next.tool)) { appendEntry(transcript, { tool: next.tool, refused: true }); continue; }
190
+ if (next.tool === 'record_outcome') {
191
+ const o = OUTCOMES.includes(next.args?.outcome) ? next.args.outcome : 'failed';
192
+ appendEntry(transcript, { tool: 'record_outcome', args: next.args || {} });
193
+ outcome = o; break;
194
+ }
195
+ let res;
196
+ try { res = await executeTool(next); }
197
+ catch (e) { res = { error: String(e?.message || e) }; }
198
+ appendEntry(transcript, { tool: next.tool, args: next.args, result: res });
199
+ }
200
+
201
+ return { transcript, outcome: outcome || 'failed' };
202
+ }
203
+
204
+ export { TOOL_ACL, OUTCOMES };
@@ -0,0 +1,135 @@
1
+ // AGENTS.md — writable continual-learning memory (harness-anatomy #2).
2
+ //
3
+ // LangChain post:
4
+ // "Harnesses support memory file standards like AGENTS.md which get
5
+ // injected into context on agent start. As agents add and edit this file,
6
+ // harnesses load the updated file into context. This is a form of
7
+ // continual learning where agents durably store knowledge from one
8
+ // session and inject that knowledge into future sessions."
9
+ //
10
+ // Distinct from CLAUDE.md:
11
+ // - CLAUDE.md = human-authored project conventions, gotchas, layout.
12
+ // - AGENTS.md = agent-authored notes ("what worked / didn't work / I'd try
13
+ // differently next time"). Append-only. Bounded.
14
+ //
15
+ // Lives at `<project>/.agentic-security/AGENTS.md`.
16
+ //
17
+ // Bounds:
18
+ // - MAX_BYTES (default 20 KB) — past this, the oldest entries rotate to
19
+ // `AGENTS.md.archive` (also bounded; oldest archive entries are dropped).
20
+ // - MAX_ENTRY_BYTES (default 2 KB) — caps a single appendage.
21
+ // - Entries are append-only with an ISO timestamp + section divider, so
22
+ // readers can grep / slice by date without parsing.
23
+ //
24
+ // We deliberately avoid tying AGENTS.md to a session-id namespace. The post's
25
+ // recommendation is FLAT continual learning — the whole project's agents see
26
+ // each other's notes. Subagents that want session-scoped scratch use the
27
+ // agent-scratchpad surface instead.
28
+
29
+ import * as fs from 'node:fs';
30
+ import * as path from 'node:path';
31
+
32
+ const MEMORY_FILE = '.agentic-security/AGENTS.md';
33
+ const ARCHIVE_FILE = '.agentic-security/AGENTS.md.archive';
34
+ const MAX_BYTES = 20 * 1024;
35
+ const MAX_ENTRY_BYTES = 2 * 1024;
36
+ const ARCHIVE_MAX_BYTES = 200 * 1024;
37
+ 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';
38
+
39
+ function _resolve(scanRoot) { return path.join(scanRoot, MEMORY_FILE); }
40
+ function _archivePath(scanRoot) { return path.join(scanRoot, ARCHIVE_FILE); }
41
+
42
+ export function readAgentsMemory(scanRoot) {
43
+ const fp = _resolve(scanRoot);
44
+ if (!fs.existsSync(fp)) return '';
45
+ try { return fs.readFileSync(fp, 'utf8'); } catch { return ''; }
46
+ }
47
+
48
+ export function appendAgentsMemory(scanRoot, { agent, body }) {
49
+ if (typeof agent !== 'string' || !agent.length) {
50
+ return { ok: false, reason: 'agent: required string' };
51
+ }
52
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(agent)) {
53
+ return { ok: false, reason: 'agent: must match [A-Za-z0-9_.-]{1,64}' };
54
+ }
55
+ if (typeof body !== 'string' || !body.trim().length) {
56
+ return { ok: false, reason: 'body: required non-empty string' };
57
+ }
58
+ let snippet = body.trim();
59
+ // Strip control chars and cap.
60
+ snippet = snippet.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, ' ');
61
+ if (snippet.length > MAX_ENTRY_BYTES) {
62
+ snippet = snippet.slice(0, MAX_ENTRY_BYTES) + '…';
63
+ }
64
+ const ts = new Date().toISOString();
65
+ const entry = `\n## ${ts} agent: ${agent}\n\n${snippet}\n`;
66
+ try {
67
+ const fp = _resolve(scanRoot);
68
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
69
+ if (!fs.existsSync(fp)) fs.writeFileSync(fp, HEADER);
70
+ fs.appendFileSync(fp, entry);
71
+ _maybeRotate(scanRoot);
72
+ const stat = fs.statSync(fp);
73
+ return { ok: true, entryBytes: entry.length, fileSize: stat.size };
74
+ } catch (e) {
75
+ return { ok: false, reason: `write-failed: ${e.message}` };
76
+ }
77
+ }
78
+
79
+ function _maybeRotate(scanRoot) {
80
+ const fp = _resolve(scanRoot);
81
+ let body;
82
+ try { body = fs.readFileSync(fp, 'utf8'); } catch { return; }
83
+ if (body.length <= MAX_BYTES) return;
84
+ // Split on the `## ` entry headers. Keep the most-recent N until the head
85
+ // (everything before the cut) drops below MAX_BYTES/2; move the head to
86
+ // the archive.
87
+ const head = HEADER;
88
+ const trailing = body.slice(head.length);
89
+ const sections = trailing.split(/(?=\n## )/g).filter(s => s.length);
90
+ // Walk from the end, accumulating until we have roughly MAX_BYTES/2 of
91
+ // recent entries. Everything else goes to the archive.
92
+ let kept = '', archive = '', accum = 0;
93
+ for (let i = sections.length - 1; i >= 0; i--) {
94
+ if (accum + sections[i].length <= MAX_BYTES / 2) {
95
+ kept = sections[i] + kept;
96
+ accum += sections[i].length;
97
+ } else {
98
+ archive = sections.slice(0, i + 1).join('') + archive;
99
+ break;
100
+ }
101
+ }
102
+ try {
103
+ fs.writeFileSync(fp, head + kept);
104
+ if (archive.length) {
105
+ const arcFp = _archivePath(scanRoot);
106
+ let existing = '';
107
+ try { existing = fs.existsSync(arcFp) ? fs.readFileSync(arcFp, 'utf8') : ''; } catch {}
108
+ let next = existing + archive;
109
+ if (next.length > ARCHIVE_MAX_BYTES) {
110
+ // Drop oldest entries until under cap.
111
+ const oldestSplit = next.split(/(?=\n## )/g).filter(s => s.length);
112
+ while (oldestSplit.length && next.length > ARCHIVE_MAX_BYTES) {
113
+ oldestSplit.shift();
114
+ next = oldestSplit.join('');
115
+ }
116
+ }
117
+ fs.writeFileSync(arcFp, next);
118
+ }
119
+ } catch { /* best-effort rotation */ }
120
+ }
121
+
122
+ // Public summary helper for the SessionStart hook. Returns a tail aligned
123
+ // to a section header (no leading partial entry, no leading newline).
124
+ export function summarizeForSession(scanRoot, { maxBytes = 6 * 1024 } = {}) {
125
+ const body = readAgentsMemory(scanRoot);
126
+ if (!body) return null;
127
+ if (body.length <= maxBytes) return body;
128
+ const tail = body.slice(-maxBytes);
129
+ const firstSection = tail.indexOf('\n## ');
130
+ if (firstSection < 0) return tail;
131
+ // Slice past the leading `\n` so the result starts with `## `.
132
+ return tail.slice(firstSection + 1);
133
+ }
134
+
135
+ export const _internals = { MAX_BYTES, MAX_ENTRY_BYTES, MEMORY_FILE, ARCHIVE_FILE };