@mxalbert/context-mode 2.0.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 (365) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +31 -0
  3. package/.codex-plugin/hooks.json +65 -0
  4. package/.codex-plugin/mcp.json +13 -0
  5. package/.codex-plugin/plugin.json +31 -0
  6. package/.openclaw-plugin/index.ts +12 -0
  7. package/.openclaw-plugin/openclaw.plugin.json +38 -0
  8. package/.openclaw-plugin/package.json +28 -0
  9. package/LICENSE +94 -0
  10. package/README.md +1619 -0
  11. package/bin/statusline.mjs +378 -0
  12. package/build/adapters/antigravity/index.d.ts +51 -0
  13. package/build/adapters/antigravity/index.js +169 -0
  14. package/build/adapters/antigravity-cli/index.d.ts +51 -0
  15. package/build/adapters/antigravity-cli/index.js +342 -0
  16. package/build/adapters/base.d.ts +92 -0
  17. package/build/adapters/base.js +135 -0
  18. package/build/adapters/claude-code/config.d.ts +8 -0
  19. package/build/adapters/claude-code/config.js +8 -0
  20. package/build/adapters/claude-code/hooks.d.ts +106 -0
  21. package/build/adapters/claude-code/hooks.js +183 -0
  22. package/build/adapters/claude-code/index.d.ts +80 -0
  23. package/build/adapters/claude-code/index.js +569 -0
  24. package/build/adapters/claude-code-base.d.ts +49 -0
  25. package/build/adapters/claude-code-base.js +113 -0
  26. package/build/adapters/client-map.d.ts +10 -0
  27. package/build/adapters/client-map.js +42 -0
  28. package/build/adapters/codex/config.d.ts +8 -0
  29. package/build/adapters/codex/config.js +8 -0
  30. package/build/adapters/codex/hooks.d.ts +55 -0
  31. package/build/adapters/codex/hooks.js +64 -0
  32. package/build/adapters/codex/index.d.ts +87 -0
  33. package/build/adapters/codex/index.js +995 -0
  34. package/build/adapters/codex/paths.d.ts +1 -0
  35. package/build/adapters/codex/paths.js +12 -0
  36. package/build/adapters/codex/usage.d.ts +107 -0
  37. package/build/adapters/codex/usage.js +227 -0
  38. package/build/adapters/copilot-base.d.ts +94 -0
  39. package/build/adapters/copilot-base.js +297 -0
  40. package/build/adapters/copilot-cli/hooks.d.ts +33 -0
  41. package/build/adapters/copilot-cli/hooks.js +64 -0
  42. package/build/adapters/copilot-cli/index.d.ts +48 -0
  43. package/build/adapters/copilot-cli/index.js +341 -0
  44. package/build/adapters/cursor/config.d.ts +4 -0
  45. package/build/adapters/cursor/config.js +4 -0
  46. package/build/adapters/cursor/hooks.d.ts +55 -0
  47. package/build/adapters/cursor/hooks.js +82 -0
  48. package/build/adapters/cursor/index.d.ts +64 -0
  49. package/build/adapters/cursor/index.js +517 -0
  50. package/build/adapters/detect.d.ts +136 -0
  51. package/build/adapters/detect.js +623 -0
  52. package/build/adapters/gemini-cli/config.d.ts +8 -0
  53. package/build/adapters/gemini-cli/config.js +8 -0
  54. package/build/adapters/gemini-cli/hooks.d.ts +83 -0
  55. package/build/adapters/gemini-cli/hooks.js +115 -0
  56. package/build/adapters/gemini-cli/index.d.ts +80 -0
  57. package/build/adapters/gemini-cli/index.js +483 -0
  58. package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
  59. package/build/adapters/jetbrains-copilot/config.js +8 -0
  60. package/build/adapters/jetbrains-copilot/hooks.d.ts +59 -0
  61. package/build/adapters/jetbrains-copilot/hooks.js +87 -0
  62. package/build/adapters/jetbrains-copilot/index.d.ts +31 -0
  63. package/build/adapters/jetbrains-copilot/index.js +131 -0
  64. package/build/adapters/kimi/config.d.ts +8 -0
  65. package/build/adapters/kimi/config.js +8 -0
  66. package/build/adapters/kimi/hooks.d.ts +28 -0
  67. package/build/adapters/kimi/hooks.js +34 -0
  68. package/build/adapters/kimi/index.d.ts +66 -0
  69. package/build/adapters/kimi/index.js +537 -0
  70. package/build/adapters/kimi/paths.d.ts +21 -0
  71. package/build/adapters/kimi/paths.js +52 -0
  72. package/build/adapters/kimi/usage.d.ts +82 -0
  73. package/build/adapters/kimi/usage.js +217 -0
  74. package/build/adapters/kiro/hooks.d.ts +61 -0
  75. package/build/adapters/kiro/hooks.js +94 -0
  76. package/build/adapters/kiro/index.d.ts +61 -0
  77. package/build/adapters/kiro/index.js +301 -0
  78. package/build/adapters/omp/index.d.ts +65 -0
  79. package/build/adapters/omp/index.js +182 -0
  80. package/build/adapters/omp/plugin.d.ts +81 -0
  81. package/build/adapters/omp/plugin.js +331 -0
  82. package/build/adapters/omp/usage.d.ts +49 -0
  83. package/build/adapters/omp/usage.js +110 -0
  84. package/build/adapters/openclaw/config.d.ts +8 -0
  85. package/build/adapters/openclaw/config.js +8 -0
  86. package/build/adapters/openclaw/hooks.d.ts +50 -0
  87. package/build/adapters/openclaw/hooks.js +61 -0
  88. package/build/adapters/openclaw/index.d.ts +74 -0
  89. package/build/adapters/openclaw/index.js +439 -0
  90. package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
  91. package/build/adapters/openclaw/mcp-tools.js +207 -0
  92. package/build/adapters/openclaw/plugin.d.ts +141 -0
  93. package/build/adapters/openclaw/plugin.js +818 -0
  94. package/build/adapters/openclaw/session-db.d.ts +55 -0
  95. package/build/adapters/openclaw/session-db.js +88 -0
  96. package/build/adapters/openclaw/usage.d.ts +34 -0
  97. package/build/adapters/openclaw/usage.js +52 -0
  98. package/build/adapters/openclaw/workspace-router.d.ts +29 -0
  99. package/build/adapters/openclaw/workspace-router.js +64 -0
  100. package/build/adapters/opencode/config.d.ts +8 -0
  101. package/build/adapters/opencode/config.js +8 -0
  102. package/build/adapters/opencode/hooks.d.ts +38 -0
  103. package/build/adapters/opencode/hooks.js +50 -0
  104. package/build/adapters/opencode/index.d.ts +144 -0
  105. package/build/adapters/opencode/index.js +638 -0
  106. package/build/adapters/opencode/plugin.d.ts +249 -0
  107. package/build/adapters/opencode/plugin.js +1536 -0
  108. package/build/adapters/opencode/v2.d.ts +117 -0
  109. package/build/adapters/opencode/v2.js +224 -0
  110. package/build/adapters/opencode/zod3tov4.d.ts +1 -0
  111. package/build/adapters/opencode/zod3tov4.js +132 -0
  112. package/build/adapters/pi/extension.d.ts +81 -0
  113. package/build/adapters/pi/extension.js +861 -0
  114. package/build/adapters/pi/index.d.ts +57 -0
  115. package/build/adapters/pi/index.js +173 -0
  116. package/build/adapters/pi/mcp-bridge.d.ts +268 -0
  117. package/build/adapters/pi/mcp-bridge.js +894 -0
  118. package/build/adapters/qwen-code/hooks.d.ts +26 -0
  119. package/build/adapters/qwen-code/hooks.js +29 -0
  120. package/build/adapters/qwen-code/index.d.ts +40 -0
  121. package/build/adapters/qwen-code/index.js +361 -0
  122. package/build/adapters/qwen-code/usage.d.ts +90 -0
  123. package/build/adapters/qwen-code/usage.js +222 -0
  124. package/build/adapters/types.d.ts +360 -0
  125. package/build/adapters/types.js +118 -0
  126. package/build/adapters/vscode-copilot/config.d.ts +8 -0
  127. package/build/adapters/vscode-copilot/config.js +8 -0
  128. package/build/adapters/vscode-copilot/hooks.d.ts +71 -0
  129. package/build/adapters/vscode-copilot/hooks.js +98 -0
  130. package/build/adapters/vscode-copilot/index.d.ts +32 -0
  131. package/build/adapters/vscode-copilot/index.js +222 -0
  132. package/build/adapters/zed/index.d.ts +40 -0
  133. package/build/adapters/zed/index.js +163 -0
  134. package/build/cli.d.ts +34 -0
  135. package/build/cli.js +1828 -0
  136. package/build/db-base.d.ts +250 -0
  137. package/build/db-base.js +756 -0
  138. package/build/executor.d.ts +72 -0
  139. package/build/executor.js +678 -0
  140. package/build/exit-classify.d.ts +19 -0
  141. package/build/exit-classify.js +12 -0
  142. package/build/fetch/blocks.d.ts +145 -0
  143. package/build/fetch/blocks.js +244 -0
  144. package/build/fetch/extract.d.ts +68 -0
  145. package/build/fetch/extract.js +123 -0
  146. package/build/fetch/page-store.d.ts +66 -0
  147. package/build/fetch/page-store.js +174 -0
  148. package/build/fetch-cache.d.ts +13 -0
  149. package/build/fetch-cache.js +15 -0
  150. package/build/lifecycle.d.ts +124 -0
  151. package/build/lifecycle.js +278 -0
  152. package/build/package-identity.d.ts +26 -0
  153. package/build/package-identity.js +28 -0
  154. package/build/runPool.d.ts +36 -0
  155. package/build/runPool.js +51 -0
  156. package/build/runtime.d.ts +71 -0
  157. package/build/runtime.js +659 -0
  158. package/build/search/auto-memory.d.ts +47 -0
  159. package/build/search/auto-memory.js +170 -0
  160. package/build/search/ctx-search-schema.d.ts +90 -0
  161. package/build/search/ctx-search-schema.js +135 -0
  162. package/build/search/flood-guard.d.ts +57 -0
  163. package/build/search/flood-guard.js +80 -0
  164. package/build/search/unified.d.ts +56 -0
  165. package/build/search/unified.js +104 -0
  166. package/build/security.d.ts +206 -0
  167. package/build/security.js +780 -0
  168. package/build/server.d.ts +240 -0
  169. package/build/server.js +5034 -0
  170. package/build/session/analytics.d.ts +773 -0
  171. package/build/session/analytics.js +2420 -0
  172. package/build/session/db.d.ts +468 -0
  173. package/build/session/db.js +1330 -0
  174. package/build/session/error-classifier.d.ts +87 -0
  175. package/build/session/error-classifier.js +303 -0
  176. package/build/session/event-emit.d.ts +48 -0
  177. package/build/session/event-emit.js +101 -0
  178. package/build/session/extract.d.ts +333 -0
  179. package/build/session/extract.js +2688 -0
  180. package/build/session/model-prices.json +429 -0
  181. package/build/session/persist-tool-calls.d.ts +54 -0
  182. package/build/session/persist-tool-calls.js +105 -0
  183. package/build/session/pricing.d.ts +64 -0
  184. package/build/session/pricing.js +151 -0
  185. package/build/session/project-attribution.d.ts +73 -0
  186. package/build/session/project-attribution.js +245 -0
  187. package/build/session/purge.d.ts +138 -0
  188. package/build/session/purge.js +240 -0
  189. package/build/session/retrieval-marker.d.ts +39 -0
  190. package/build/session/retrieval-marker.js +65 -0
  191. package/build/session/snapshot.d.ts +46 -0
  192. package/build/session/snapshot.js +532 -0
  193. package/build/store-directory.d.ts +56 -0
  194. package/build/store-directory.js +254 -0
  195. package/build/store.d.ts +147 -0
  196. package/build/store.js +1714 -0
  197. package/build/truncate.d.ts +57 -0
  198. package/build/truncate.js +142 -0
  199. package/build/types.d.ts +117 -0
  200. package/build/types.js +20 -0
  201. package/build/util/claude-config.d.ts +32 -0
  202. package/build/util/claude-config.js +84 -0
  203. package/build/util/hook-config.d.ts +27 -0
  204. package/build/util/hook-config.js +76 -0
  205. package/build/util/jsonc.d.ts +14 -0
  206. package/build/util/jsonc.js +104 -0
  207. package/build/util/plugin-cache-integrity.d.ts +51 -0
  208. package/build/util/plugin-cache-integrity.js +146 -0
  209. package/build/util/project-dir.d.ts +149 -0
  210. package/build/util/project-dir.js +340 -0
  211. package/build/util/sibling-mcp.d.ts +79 -0
  212. package/build/util/sibling-mcp.js +181 -0
  213. package/cli.bundle.mjs +1495 -0
  214. package/configs/antigravity/GEMINI.md +68 -0
  215. package/configs/antigravity/mcp_config.json +7 -0
  216. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  217. package/configs/antigravity-cli/hooks.json +37 -0
  218. package/configs/antigravity-cli/mcp_config.json +10 -0
  219. package/configs/antigravity-cli/plugin.json +14 -0
  220. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  221. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  222. package/configs/claude-code/CLAUDE.md +91 -0
  223. package/configs/codex/AGENTS.md +99 -0
  224. package/configs/codex/config.toml +8 -0
  225. package/configs/codex/hooks.json +47 -0
  226. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  227. package/configs/copilot-cli/.mcp.json +12 -0
  228. package/configs/copilot-cli/README.md +47 -0
  229. package/configs/copilot-cli/hooks.json +41 -0
  230. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  231. package/configs/cursor/context-mode.mdc +76 -0
  232. package/configs/cursor/hooks.json +21 -0
  233. package/configs/cursor/mcp.json +7 -0
  234. package/configs/gemini-cli/GEMINI.md +88 -0
  235. package/configs/gemini-cli/mcp.json +7 -0
  236. package/configs/gemini-cli/settings.json +60 -0
  237. package/configs/jetbrains-copilot/copilot-instructions.md +80 -0
  238. package/configs/jetbrains-copilot/hooks.json +16 -0
  239. package/configs/jetbrains-copilot/mcp.json +7 -0
  240. package/configs/kilo/AGENTS.md +84 -0
  241. package/configs/kilo/kilo.json +6 -0
  242. package/configs/kimi/hooks.json +54 -0
  243. package/configs/kiro/KIRO.md +84 -0
  244. package/configs/kiro/agent.json +18 -0
  245. package/configs/kiro/mcp.json +7 -0
  246. package/configs/omp/SYSTEM.md +85 -0
  247. package/configs/omp/mcp.json +7 -0
  248. package/configs/openclaw/AGENTS.md +87 -0
  249. package/configs/openclaw/openclaw.json +13 -0
  250. package/configs/opencode/AGENTS.md +84 -0
  251. package/configs/opencode/opencode.json +6 -0
  252. package/configs/pi/AGENTS.md +3 -0
  253. package/configs/qwen-code/QWEN.md +91 -0
  254. package/configs/vscode-copilot/copilot-instructions.md +80 -0
  255. package/configs/vscode-copilot/hooks.json +16 -0
  256. package/configs/vscode-copilot/mcp.json +7 -0
  257. package/configs/zed/AGENTS.md +68 -0
  258. package/hooks/antigravity-cli/payload.mjs +98 -0
  259. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  260. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  261. package/hooks/antigravity-cli/stop.mjs +58 -0
  262. package/hooks/auto-injection.mjs +102 -0
  263. package/hooks/cache-heal-utils.mjs +379 -0
  264. package/hooks/codex/platform.mjs +1 -0
  265. package/hooks/codex/posttooluse.mjs +66 -0
  266. package/hooks/codex/precompact.mjs +70 -0
  267. package/hooks/codex/pretooluse.mjs +42 -0
  268. package/hooks/codex/sessionstart.mjs +121 -0
  269. package/hooks/codex/stop.mjs +138 -0
  270. package/hooks/codex/userpromptsubmit.mjs +75 -0
  271. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  272. package/hooks/copilot-cli/precompact.mjs +66 -0
  273. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  274. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  275. package/hooks/copilot-cli/stop.mjs +59 -0
  276. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  277. package/hooks/core/codex-caps.mjs +112 -0
  278. package/hooks/core/formatters.mjs +356 -0
  279. package/hooks/core/mcp-ready.mjs +109 -0
  280. package/hooks/core/platform-detect.mjs +49 -0
  281. package/hooks/core/routing.mjs +1050 -0
  282. package/hooks/core/stdin.mjs +90 -0
  283. package/hooks/core/tool-naming.mjs +57 -0
  284. package/hooks/cursor/afteragentresponse.mjs +74 -0
  285. package/hooks/cursor/hooks.json +31 -0
  286. package/hooks/cursor/posttooluse.mjs +72 -0
  287. package/hooks/cursor/pretooluse.mjs +26 -0
  288. package/hooks/cursor/sessionstart.mjs +97 -0
  289. package/hooks/cursor/stop.mjs +48 -0
  290. package/hooks/ensure-deps.mjs +254 -0
  291. package/hooks/formatters/claude-code.mjs +113 -0
  292. package/hooks/formatters/cursor.mjs +37 -0
  293. package/hooks/formatters/gemini-cli.mjs +55 -0
  294. package/hooks/formatters/vscode-copilot.mjs +55 -0
  295. package/hooks/gemini-cli/aftermodel.mjs +70 -0
  296. package/hooks/gemini-cli/aftertool.mjs +61 -0
  297. package/hooks/gemini-cli/beforeagent.mjs +99 -0
  298. package/hooks/gemini-cli/beforetool.mjs +27 -0
  299. package/hooks/gemini-cli/precompress.mjs +55 -0
  300. package/hooks/gemini-cli/sessionstart.mjs +137 -0
  301. package/hooks/heal-partial-install.mjs +712 -0
  302. package/hooks/hooks.json +143 -0
  303. package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
  304. package/hooks/jetbrains-copilot/precompact.mjs +55 -0
  305. package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
  306. package/hooks/jetbrains-copilot/sessionstart.mjs +124 -0
  307. package/hooks/kimi/platform.mjs +1 -0
  308. package/hooks/kimi/posttooluse.mjs +72 -0
  309. package/hooks/kimi/precompact.mjs +80 -0
  310. package/hooks/kimi/pretooluse.mjs +42 -0
  311. package/hooks/kimi/sessionend.mjs +61 -0
  312. package/hooks/kimi/sessionstart.mjs +113 -0
  313. package/hooks/kimi/stop.mjs +132 -0
  314. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  315. package/hooks/kiro/agentspawn.mjs +97 -0
  316. package/hooks/kiro/posttooluse.mjs +51 -0
  317. package/hooks/kiro/pretooluse.mjs +64 -0
  318. package/hooks/kiro/userpromptsubmit.mjs +88 -0
  319. package/hooks/normalize-hooks.mjs +323 -0
  320. package/hooks/platform-bridge.mjs +340 -0
  321. package/hooks/posttooluse.mjs +225 -0
  322. package/hooks/precompact.mjs +100 -0
  323. package/hooks/pretooluse.mjs +227 -0
  324. package/hooks/qwen-code/platform.mjs +1 -0
  325. package/hooks/qwen-code/stop.mjs +168 -0
  326. package/hooks/routing-block.mjs +105 -0
  327. package/hooks/run-hook.mjs +95 -0
  328. package/hooks/security.bundle.mjs +2 -0
  329. package/hooks/session-attribution.bundle.mjs +1 -0
  330. package/hooks/session-db.bundle.mjs +145 -0
  331. package/hooks/session-directive.mjs +520 -0
  332. package/hooks/session-extract.bundle.mjs +3 -0
  333. package/hooks/session-helpers.mjs +428 -0
  334. package/hooks/session-loaders.mjs +448 -0
  335. package/hooks/session-snapshot.bundle.mjs +32 -0
  336. package/hooks/sessionstart.mjs +464 -0
  337. package/hooks/stop.mjs +82 -0
  338. package/hooks/suppress-stderr.mjs +25 -0
  339. package/hooks/userpromptsubmit.mjs +108 -0
  340. package/hooks/vscode-copilot/posttooluse.mjs +61 -0
  341. package/hooks/vscode-copilot/precompact.mjs +55 -0
  342. package/hooks/vscode-copilot/pretooluse.mjs +27 -0
  343. package/hooks/vscode-copilot/sessionstart.mjs +129 -0
  344. package/openclaw.plugin.json +38 -0
  345. package/package.json +132 -0
  346. package/scripts/heal-better-sqlite3.mjs +430 -0
  347. package/scripts/heal-installed-plugins.mjs +635 -0
  348. package/scripts/plugin-cache-integrity.mjs +248 -0
  349. package/scripts/postinstall.mjs +397 -0
  350. package/server.bundle.mjs +1421 -0
  351. package/server.js +5 -0
  352. package/skills/.ignore +7 -0
  353. package/skills/context-mode/SKILL.md +300 -0
  354. package/skills/context-mode/references/anti-patterns.md +283 -0
  355. package/skills/context-mode/references/patterns-javascript.md +298 -0
  356. package/skills/context-mode/references/patterns-python.md +304 -0
  357. package/skills/context-mode/references/patterns-shell.md +277 -0
  358. package/skills/ctx-doctor/SKILL.md +22 -0
  359. package/skills/ctx-index/SKILL.md +46 -0
  360. package/skills/ctx-insight/SKILL.md +24 -0
  361. package/skills/ctx-purge/SKILL.md +49 -0
  362. package/skills/ctx-search/SKILL.md +35 -0
  363. package/skills/ctx-stats/SKILL.md +26 -0
  364. package/skills/ctx-upgrade/SKILL.md +31 -0
  365. package/start.mjs +616 -0
@@ -0,0 +1,1050 @@
1
+ /**
2
+ * Pure routing logic for PreToolUse hooks.
3
+ * Returns NORMALIZED decision objects (NOT platform-specific format).
4
+ *
5
+ * Decision types:
6
+ * - { action: "deny", reason: string }
7
+ * - { action: "ask" }
8
+ * - { action: "modify", updatedInput: object }
9
+ * - { action: "context", additionalContext: string }
10
+ * - null (passthrough)
11
+ */
12
+
13
+ import {
14
+ ROUTING_BLOCK, READ_GUIDANCE, GREP_GUIDANCE, BASH_GUIDANCE, EXTERNAL_MCP_GUIDANCE,
15
+ createRoutingBlock, createReadGuidance, createGrepGuidance, createBashGuidance,
16
+ createExternalMcpGuidance,
17
+ } from "../routing-block.mjs";
18
+ import { createToolNamer } from "./tool-naming.mjs";
19
+ import { isMCPReady } from "./mcp-ready.mjs";
20
+ import { existsSync, mkdirSync, rmSync, rmdirSync, readdirSync, unlinkSync, openSync, closeSync, readFileSync, writeFileSync, statSync, constants as fsConstants } from "node:fs";
21
+
22
+ /**
23
+ * Guard for actions that redirect to MCP tools (#230).
24
+ * If the ctx_* tools are NOT reachable, returns null (passthrough) instead
25
+ * of the redirect action — prevents agent from getting stuck when MCP tools
26
+ * are unavailable. Applies to deny and modify actions that mention MCP alternatives.
27
+ *
28
+ * Availability = EITHER the MCP readiness sentinel (v1: the plugin always
29
+ * runs the MCP server) OR the in-process NATIVE registration signal
30
+ * (opencode v2: the plugin registers ctx_* tools via ctx.tool.transform).
31
+ * Caller-level suppression (mcpToolsAvailable=false, e.g. subagent
32
+ * contexts) is preserved — the flag only extends the availability probe,
33
+ * never overrides an explicit "not available" from the caller.
34
+ *
35
+ * Process-local by design: the CLI hook processes (hooks/pretooluse.mjs)
36
+ * never set the native flag, so every non-plugin platform keeps gating
37
+ * purely on isMCPReady() — default behavior unchanged for them.
38
+ */
39
+ let nativeContextModeToolsAvailable = false;
40
+
41
+ /**
42
+ * Set the in-process signal that ctx_* tools are reachable WITHOUT an MCP
43
+ * server (the opencode v2 plugin calls this after a successful native
44
+ * registration). Pass false on teardown so redirects never point at dead
45
+ * tools. Ignored by non-plugin hosts (flag stays false → unchanged).
46
+ */
47
+ export function setContextModeToolsAvailable(available) {
48
+ nativeContextModeToolsAvailable = available === true;
49
+ }
50
+
51
+ /** Reset the native availability signal to the default (false) — tests. */
52
+ export function resetContextModeToolsAvailable() {
53
+ nativeContextModeToolsAvailable = false;
54
+ }
55
+
56
+ /** Introspection for tests / the plugin's sync helper. */
57
+ export function isContextModeToolsAvailable() {
58
+ return nativeContextModeToolsAvailable;
59
+ }
60
+
61
+ function mcpRedirect(result, mcpToolsAvailable = true) {
62
+ if (!mcpToolsAvailable) return null;
63
+ if (!isMCPReady() && !nativeContextModeToolsAvailable) return null;
64
+ return result;
65
+ }
66
+ import { homedir, tmpdir } from "node:os";
67
+ import { resolve } from "node:path";
68
+
69
+ // Guidance throttle: show each advisory type at most once per session.
70
+ // Hybrid approach:
71
+ // - In-memory Set for same-process (OpenCode ts-plugin, vitest)
72
+ // - File-based markers with O_EXCL for cross-process atomicity
73
+ // (Claude Code, Gemini, Cursor, VS Code Copilot)
74
+ //
75
+ // Session identity is resolved in this order:
76
+ // 1. sessionId passed in by the caller (stable across hook invocations)
77
+ // 2. process.ppid fallback (works on macOS/Linux — host PID is stable)
78
+ //
79
+ // The ppid fallback is unreliable on Windows + Git Bash, where each hook
80
+ // invocation spawns a fresh bash.exe with a different PID (#298). Callers
81
+ // that have a stable session identifier (e.g. from the hook payload) should
82
+ // pass it to routePreToolUse so the marker directory stays consistent across
83
+ // invocations of the same logical session.
84
+ const _guidanceShown = new Set();
85
+
86
+ // Periodic-guidance counters: how many times each (sessionId, type) pair has
87
+ // fired the periodic branch. Keyed by `${sessionId-or-ppid}::${type}`.
88
+ // File-backed for cross-process so hook invocations from the same logical
89
+ // session keep the counter coherent.
90
+ const _guidanceCounters = new Map();
91
+
92
+ // External-MCP nudge cadence — fire every N matching tool calls.
93
+ // Default 10: keeps the guidance fresh in long MCP-heavy sessions (e.g. a
94
+ // Jira/Slack/Notion run with 50+ tool calls — see #567 follow-up) without
95
+ // flooding context with repeat nudges. Bounds [1, 100]; invalid env values
96
+ // fall back to default. period=1 means "fire every call" (opt-in only).
97
+ const EXTERNAL_MCP_NUDGE_DEFAULT = 10;
98
+ const EXTERNAL_MCP_NUDGE_MIN = 1;
99
+ const EXTERNAL_MCP_NUDGE_MAX = 100;
100
+ const EXTERNAL_MCP_NUDGE_ENV = "CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY";
101
+
102
+ function getExternalMcpNudgeEvery() {
103
+ const raw = process.env[EXTERNAL_MCP_NUDGE_ENV];
104
+ if (raw == null || raw === "") return EXTERNAL_MCP_NUDGE_DEFAULT;
105
+ const parsed = Number.parseInt(raw, 10);
106
+ if (!Number.isFinite(parsed) || parsed < EXTERNAL_MCP_NUDGE_MIN || parsed > EXTERNAL_MCP_NUDGE_MAX) {
107
+ return EXTERNAL_MCP_NUDGE_DEFAULT;
108
+ }
109
+ return parsed;
110
+ }
111
+
112
+ // #817: size threshold so small Bash calls skip the routing nudge.
113
+ //
114
+ // PreToolUse fires BEFORE the command runs, so the actual output size is
115
+ // unknowable here. The only deterministic pre-execution signal is the command
116
+ // string itself. The Gemini CLI adapter solves the same over-interception
117
+ // problem with a matcher that only fires on large-output tools — "avoids
118
+ // unnecessary hook overhead on lightweight tools" (README). We mirror that at
119
+ // the routing layer: when CONTEXT_MODE_BASH_NUDGE_MIN_COMMAND_BYTES is set to
120
+ // N>0, an unbounded Bash command whose UTF-8 byte length is below N is treated
121
+ // as expected-lightweight and the generic routing nudge is suppressed.
122
+ //
123
+ // Default is 0 (unset) → CURRENT BEHAVIOR: every unbounded command is nudged.
124
+ // This preserves the context-saving guarantee for large outputs by default —
125
+ // the threshold is strictly opt-in. Bounds [0, 100000]; invalid/zero/negative
126
+ // values fall back to 0 (disabled). The threshold gates ONLY the generic Bash
127
+ // nudge — curl/wget, inline-HTTP, and build-tool redirects run earlier and are
128
+ // never relaxed, because those are deterministic floods regardless of command
129
+ // length.
130
+ const BASH_NUDGE_MIN_BYTES_ENV = "CONTEXT_MODE_BASH_NUDGE_MIN_COMMAND_BYTES";
131
+ const BASH_NUDGE_MIN_BYTES_MAX = 100_000;
132
+
133
+ function getBashNudgeMinCommandBytes() {
134
+ const raw = process.env[BASH_NUDGE_MIN_BYTES_ENV];
135
+ if (raw == null || raw === "") return 0;
136
+ const parsed = Number.parseInt(raw, 10);
137
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > BASH_NUDGE_MIN_BYTES_MAX) {
138
+ return 0;
139
+ }
140
+ return parsed;
141
+ }
142
+
143
+ function defaultGuidanceId() {
144
+ return process.env.VITEST_WORKER_ID
145
+ ? `${process.ppid}-w${process.env.VITEST_WORKER_ID}`
146
+ : String(process.ppid);
147
+ }
148
+
149
+ function guidanceDirFor(sessionId) {
150
+ const id = sessionId ? `s-${sessionId}` : defaultGuidanceId();
151
+ return resolve(tmpdir(), `context-mode-guidance-${id}`);
152
+ }
153
+
154
+ function guidanceOnce(type, content, sessionId) {
155
+ // Fast path: in-memory (same process)
156
+ if (_guidanceShown.has(type)) return null;
157
+
158
+ // Resolve marker directory for this session (stable even on Windows/Git Bash
159
+ // where process.ppid shifts every invocation — see #298).
160
+ const dir = guidanceDirFor(sessionId);
161
+ try { mkdirSync(dir, { recursive: true }); } catch {}
162
+
163
+ // Atomic create-or-fail: O_CREAT | O_EXCL | O_WRONLY
164
+ // First process to create the file wins; others get EEXIST.
165
+ const marker = resolve(dir, type);
166
+ try {
167
+ const fd = openSync(marker, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY);
168
+ closeSync(fd);
169
+ } catch {
170
+ // EEXIST = another process already created it, or we did in-memory
171
+ _guidanceShown.add(type);
172
+ return null;
173
+ }
174
+
175
+ _guidanceShown.add(type);
176
+ return { action: "context", additionalContext: content };
177
+ }
178
+
179
+ /**
180
+ * Like guidanceOnce, but fires on a periodic cadence (calls 1, period+1,
181
+ * 2·period+1, …) rather than once per session.
182
+ *
183
+ * Motivation: external-MCP tool runs can span 50+ calls (e.g. a Jira/Slack
184
+ * search loop — see #567 follow-up). A single one-shot nudge gets lost
185
+ * after the model's context compaction kicks in, and subsequent large MCP
186
+ * payloads flood context unchecked. Re-firing the nudge every N calls
187
+ * keeps the guidance in the model's recent window without saturating it.
188
+ *
189
+ * Counter state is process-aware: in-memory Map for same-process callers,
190
+ * file-backed `<guidanceDir>/<type>.count` for cross-process hook
191
+ * invocations. On any IO/parse failure we fall back to firing — losing a
192
+ * counter is preferable to silently dropping the advisory.
193
+ */
194
+ function guidancePeriodic(type, content, sessionId, period) {
195
+ const safePeriod = Math.max(1, period | 0);
196
+ const id = sessionId ? `s-${sessionId}` : defaultGuidanceId();
197
+ const key = `${id}::${type}`;
198
+
199
+ // Read counter from memory first; fall through to disk on miss.
200
+ let count = _guidanceCounters.get(key);
201
+ const dir = guidanceDirFor(sessionId);
202
+ const counterPath = resolve(dir, `${type}.count`);
203
+
204
+ if (count == null) {
205
+ try {
206
+ const parsed = Number.parseInt(readFileSync(counterPath, "utf8"), 10);
207
+ count = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
208
+ } catch {
209
+ count = 0;
210
+ }
211
+ }
212
+
213
+ const next = count + 1;
214
+ _guidanceCounters.set(key, next);
215
+
216
+ try {
217
+ mkdirSync(dir, { recursive: true });
218
+ writeFileSync(counterPath, String(next), "utf8");
219
+ } catch {
220
+ // Best-effort: cross-process counter may drift on FS failure, but we
221
+ // still return a decision based on the in-memory tick.
222
+ }
223
+
224
+ // Fire on the 1st, (period+1)th, (2·period+1)th… call.
225
+ if ((next - 1) % safePeriod !== 0) return null;
226
+ return { action: "context", additionalContext: content };
227
+ }
228
+
229
+ /**
230
+ * Robust recursive delete. On Windows, `fs.rmSync` on directories under a
231
+ * tmpdir whose path contains non-ASCII characters (e.g. a Chinese / Japanese /
232
+ * Korean username) silently no-ops without throwing — see #454. Fall back to a
233
+ * manual unlink + rmdir walk so the marker dir actually goes away.
234
+ */
235
+ function rmSyncRobust(dir) {
236
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
237
+ if (!existsSync(dir)) return;
238
+ // Manual fallback for Windows + non-ASCII tmpdir paths
239
+ try {
240
+ for (const name of readdirSync(dir)) {
241
+ try { unlinkSync(resolve(dir, name)); } catch {}
242
+ }
243
+ rmdirSync(dir);
244
+ } catch {}
245
+ }
246
+
247
+ export function resetGuidanceThrottle(sessionId) {
248
+ _guidanceShown.clear();
249
+ _guidanceCounters.clear();
250
+ // Clear ppid-based dir (legacy / fallback callers) and the sessionId dir if given
251
+ rmSyncRobust(guidanceDirFor());
252
+ if (sessionId) {
253
+ rmSyncRobust(guidanceDirFor(sessionId));
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Strip heredoc content from a shell command.
259
+ * Handles: <<EOF, <<"EOF", <<'EOF', <<-EOF (indented), with optional spaces.
260
+ */
261
+ function stripHeredocs(cmd) {
262
+ return cmd.replace(/<<-?\s*["']?(\w+)["']?[\s\S]*?\n\s*\1/g, "");
263
+ }
264
+
265
+ /**
266
+ * Strip ALL quoted content from a shell command so regex only matches command tokens.
267
+ * Removes heredocs, single-quoted strings, and double-quoted strings.
268
+ * This prevents false positives like: gh issue edit --body "text with curl in it"
269
+ */
270
+ function stripQuotedContent(cmd) {
271
+ return stripHeredocs(cmd)
272
+ .replace(/'[^']*'/g, "''") // single-quoted strings
273
+ .replace(/"[^"]*"/g, '""'); // double-quoted strings
274
+ }
275
+
276
+ /**
277
+ * Built-in allowlist of structurally-bounded Bash commands (#463).
278
+ *
279
+ * The PreToolUse Bash nudge ("May produce large output. Use ctx_…") is
280
+ * tuned for unbounded commands like `find /` or `cat large-file`. On
281
+ * commands whose stdout is structurally bounded (system probes, version
282
+ * checks, simple git read subcommands), the nudge is pure noise — a
283
+ * recurring ~85 tokens that trains the agent to ignore the warning.
284
+ *
285
+ * isStructurallyBounded() returns true ONLY when the command:
286
+ * 1. Has no shell control operators (pipe, redirect, command
287
+ * substitution, &&, ||, ;) — any of those can compose with an
288
+ * unbounded command and re-introduce flooding.
289
+ * 2. Matches one of the conservative patterns below.
290
+ *
291
+ * Unknown commands are treated as unbounded (false) — fail-safe default.
292
+ */
293
+ const SAFE_COMMAND_PATTERNS = [
294
+ // System probes (no stdout, or one short line)
295
+ // Defense-in-depth (#470): trailing wildcards use `[^\r\n]+` instead of
296
+ // `.+`. The primary gate is SHELL_CONTROL_OPERATORS, which already rejects
297
+ // `\n` / `\r`, but in JS regex `\s` matches LF/CR too — so a pattern like
298
+ // `\s+.+$` would silently span a newline if the operator gate ever
299
+ // regressed. Anchoring `.+` to a single line removes that latent footgun.
300
+ /^pwd$/,
301
+ /^whoami$/,
302
+ /^hostname(?:\s+-[a-zA-Z]+)?$/,
303
+ // uname (#517): short-flag probes only (`-a`, `-srm`). No path operands —
304
+ // uname doesn't take any, and refusing them keeps the pattern strict.
305
+ /^uname(?:\s+-[a-zA-Z]+)?$/,
306
+ // id (#517): bare `id`, single short flag (`-u`, `-g`), or single user
307
+ // operand (`id mksglu`). Output is one line — bounded by definition.
308
+ /^id(?:\s+\S+)?$/,
309
+ /^date(?:\s+[^\r\n]+)?$/,
310
+ /^echo\s/,
311
+ /^printf\s/,
312
+ /^which\s+\S+(?:\s+\S+)*$/,
313
+ /^type\s+\S+(?:\s+\S+)*$/,
314
+ /^command\s+-v\s+\S+(?:\s+\S+)*$/,
315
+ /^readlink(?:\s+[^\r\n]+)?$/,
316
+ /^basename(?:\s+[^\r\n]+)?$/,
317
+ /^dirname(?:\s+[^\r\n]+)?$/,
318
+ // realpath (#517): canonical path resolution prints one line per operand.
319
+ // Same shape as readlink — single-line `[^\r\n]+` to mirror the operator-gate
320
+ // defense-in-depth from #470.
321
+ /^realpath(?:\s+[^\r\n]+)?$/,
322
+ // Filesystem ops (silent on success, errors on stderr only).
323
+ // For cp / mv / rm we explicitly refuse `-v` / `--verbose`: verbose
324
+ // mode prints one line per file and can flood on big trees
325
+ // (recursive copy of /etc, mass rename, etc.). The "silent on
326
+ // success" invariant only holds without -v.
327
+ /^cd(?:\s+[^\r\n]+)?$/,
328
+ /^mkdir(?:\s+[^\r\n]+)?$/,
329
+ /^touch\s+[^\r\n]+$/,
330
+ // #517 follow-up: the original `(?!\s+-[a-zA-Z]*v\b)` required `v` to be
331
+ // the LAST alpha char in the flag bundle, so `-vs`, `-vfr`, `-rvf`,
332
+ // `-sfvr`, etc. silently slipped past the carve-out and flooded.
333
+ // `(?!\s+-[a-zA-Z]*v[a-zA-Z]*)` catches `v` anywhere in the bundle.
334
+ /^mv(?!\s+-[a-zA-Z]*v[a-zA-Z]*)(?!\s+--verbose\b)\s+[^\r\n]+$/,
335
+ /^cp(?!\s+-[a-zA-Z]*v[a-zA-Z]*)(?!\s+--verbose\b)\s+[^\r\n]+$/,
336
+ /^rm(?!\s+-[a-zA-Z]*v[a-zA-Z]*)(?!\s+--verbose\b)\s+[^\r\n]+$/,
337
+ // ln (#517): silent on success — same `-v` / `--verbose` carve-out as
338
+ // cp/mv/rm. Bulk symlink operations with -v flood one line per link.
339
+ /^ln(?!\s+-[a-zA-Z]*v[a-zA-Z]*)(?!\s+--verbose\b)\s+[^\r\n]+$/,
340
+ // ls — refuse recursive (-R / --recursive) to keep output bounded.
341
+ /^ls(?!\s+-[a-zA-Z]*R)(?!\s+--recursive)(?:\s+[^\r\n]+)?$/,
342
+ // git read-only / status subcommands
343
+ /^git\s+status(?:\s+[^\r\n]+)?$/,
344
+ /^git\s+rev-parse(?:\s+[^\r\n]+)?$/,
345
+ /^git\s+remote(?:\s+-v|\s+show\s+\S+)?$/,
346
+ /^git\s+branch(?:\s+[^\r\n]+)?$/,
347
+ /^git\s+config\s+--get(?:\s+[^\r\n]+)?$/,
348
+ /^git\s+diff\s+--stat(?:\s+[^\r\n]+)?$/,
349
+ /^git\s+diff\s+--name-only(?:\s+[^\r\n]+)?$/,
350
+ /^git\s+stash\s+list$/,
351
+ /^git\s+tag(?:\s+-l(?:\s+[^\r\n]+)?)?$/,
352
+ // git log only when explicitly bounded by -<N> with N up to two digits
353
+ /^git\s+log\s+-\d{1,2}(?:\s+[^\r\n]+)?$/,
354
+ // Version probes (--version anywhere, or `cmd -V`)
355
+ /(?:^|\s)--version(?:\s|$)/,
356
+ /^\S+\s+-V(?:\s|$)/,
357
+ ];
358
+
359
+ // Bash shell control operators that can compose a safe command with an
360
+ // unbounded sink. Any match disqualifies the command from the allowlist.
361
+ //
362
+ // Note `&` (single — background + sequence): listed BEFORE `&&` in the
363
+ // alternation so the regex engine doesn't accidentally short-match `&&`
364
+ // when `&` is itself a separator (`date & cat huge.log`). Without this,
365
+ // `^date(?:\s+.+)?$` would match the whole string and bypass the gate.
366
+ //
367
+ // `\n` / `\r` (newline injection — #470): bash treats LF as a statement
368
+ // separator equivalent to `;`. CRLF (Windows clipboard paste) and bare CR
369
+ // fall in the same defect class. Without these, `git status\nfind /`
370
+ // would short-match the single-line `^git\s+status` pattern and bypass
371
+ // the gate entirely.
372
+ const SHELL_CONTROL_OPERATORS = /[|`\n\r]|\$\(|>>|>|<(?!<)|&(?!&)|&&|\|\||;/;
373
+
374
+ /**
375
+ * @param {string} command Raw Bash command string from the hook payload.
376
+ * @returns {boolean} true when the command's output is bounded enough that
377
+ * the routing nudge would be noise. Conservative — unknown commands
378
+ * return false.
379
+ */
380
+ export function isStructurallyBounded(command) {
381
+ if (!command) return false;
382
+ const trimmed = command.trim();
383
+ if (SHELL_CONTROL_OPERATORS.test(trimmed)) return false;
384
+ return SAFE_COMMAND_PATTERNS.some(rx => rx.test(trimmed));
385
+ }
386
+
387
+ // Try to import security module — may not exist
388
+ let security = null;
389
+ let securityInitFailed = false;
390
+
391
+ /**
392
+ * @returns {boolean} true if security module loaded successfully.
393
+ *
394
+ * Loud fail: if neither the esbuild bundle nor `build/security.js` is
395
+ * importable, log a clear stderr warning instead of swallowing the error
396
+ * silently. Without this, user-configured `permissions.deny` patterns
397
+ * (#466) become no-ops with no indication that policy enforcement is
398
+ * disabled — a fail-open security regression.
399
+ *
400
+ * ─── Resolution order (#558) ───────────────────────────────────────────
401
+ *
402
+ * 1. `hooks/security.bundle.mjs` — esbuild output, sibling of routing.mjs's
403
+ * parent. Marketplace installs (`git clone` install path) ship this
404
+ * bundle via CI's `git add -f`, so it's the only artifact reliably
405
+ * present across BOTH `npm install` (build/ generated by tsc) AND
406
+ * marketplace install (build/ excluded by .gitignore, never built).
407
+ *
408
+ * 2. `<buildDir>/security.js` — tsc output. Present after `npm run build`.
409
+ * Kept as a fallback so source checkouts that bypass `npm run bundle`
410
+ * still degrade gracefully to the tsc-emitted module.
411
+ *
412
+ * Bundle path is computed from `import.meta.url` (sibling layout:
413
+ * `hooks/core/routing.mjs` → `hooks/security.bundle.mjs`).
414
+ * `CONTEXT_MODE_SECURITY_BUNDLE_PATH` is a test seam — it lets
415
+ * subprocess-based tests stage a bundle in tmpdir without polluting the
416
+ * repo's hooks/ directory.
417
+ */
418
+ export async function initSecurity(buildDir) {
419
+ const { existsSync } = await import("node:fs");
420
+ const { resolve, dirname } = await import("node:path");
421
+ const { fileURLToPath, pathToFileURL } = await import("node:url");
422
+
423
+ // Default: <hooks/core/ dir>/../security.bundle.mjs → hooks/security.bundle.mjs.
424
+ const defaultBundlePath = resolve(
425
+ dirname(fileURLToPath(import.meta.url)),
426
+ "..",
427
+ "security.bundle.mjs",
428
+ );
429
+ const bundlePath = process.env.CONTEXT_MODE_SECURITY_BUNDLE_PATH || defaultBundlePath;
430
+ const secPath = resolve(buildDir, "security.js");
431
+
432
+ // Bundle-first: marketplace installs ship the bundle, never the build/ dir.
433
+ if (existsSync(bundlePath)) {
434
+ try {
435
+ security = await import(pathToFileURL(bundlePath).href);
436
+ return true;
437
+ } catch (err) {
438
+ if (!securityInitFailed && !process.env.CONTEXT_MODE_SUPPRESS_SECURITY_WARNING) {
439
+ process.stderr.write(
440
+ `[context-mode] WARNING: failed to load security bundle (${bundlePath}) — deny patterns NOT enforced: ${err?.message ?? err}\n`,
441
+ );
442
+ }
443
+ securityInitFailed = true;
444
+ return false;
445
+ }
446
+ }
447
+
448
+ // Fallback: tsc-emitted build/security.js (source checkout + `npm run build`).
449
+ if (existsSync(secPath)) {
450
+ try {
451
+ security = await import(pathToFileURL(secPath).href);
452
+ return true;
453
+ } catch (err) {
454
+ if (!securityInitFailed && !process.env.CONTEXT_MODE_SUPPRESS_SECURITY_WARNING) {
455
+ process.stderr.write(
456
+ `[context-mode] WARNING: failed to load security module — deny patterns NOT enforced: ${err?.message ?? err}\n`,
457
+ );
458
+ }
459
+ securityInitFailed = true;
460
+ return false;
461
+ }
462
+ }
463
+
464
+ // Neither artifact present — preserve fail-open with an actionable warning
465
+ // that mentions BOTH paths so users on either install model can self-diagnose.
466
+ if (!securityInitFailed && !process.env.CONTEXT_MODE_SUPPRESS_SECURITY_WARNING) {
467
+ process.stderr.write(
468
+ `[context-mode] WARNING: security module not found — security deny patterns will NOT be enforced.\n` +
469
+ ` Searched: ${bundlePath} (bundle) and ${secPath} (build).\n` +
470
+ ` Marketplace installs ship hooks/security.bundle.mjs via CI; for source checkouts run \`npm run bundle\` (or \`npm run build\`).\n` +
471
+ ` Set CONTEXT_MODE_SUPPRESS_SECURITY_WARNING=1 to silence.\n`,
472
+ );
473
+ }
474
+ securityInitFailed = true;
475
+ return false;
476
+ }
477
+
478
+ /** @returns {boolean} true if a previous initSecurity() call failed to load the module. */
479
+ export function isSecurityInitFailed() {
480
+ return securityInitFailed;
481
+ }
482
+
483
+ /**
484
+ * Build the agent-facing additionalContext block surfacing the security
485
+ * init failure (#558).
486
+ *
487
+ * Pre-558 the only signal of a fail-open security regression was a
488
+ * stderr WARNING line that adapters typically suppress / discard. The
489
+ * user had no in-band signal that `permissions.deny` was no-op'd.
490
+ *
491
+ * Returns a structured XML-ish block when initSecurity() has failed,
492
+ * `null` otherwise. SessionStart hooks append the block to their
493
+ * additionalContext so the agent (and through the agent, the user)
494
+ * sees the warning the next time they view the session — not just in
495
+ * suppressed stderr.
496
+ *
497
+ * The block format intentionally mirrors the `<context_guidance>`
498
+ * shape used elsewhere in routing so existing prompt-template
499
+ * scaffolding picks it up without special-casing.
500
+ */
501
+ export function buildSecurityWarningContext() {
502
+ if (!securityInitFailed) return null;
503
+ return [
504
+ "<context_mode_security_warning>",
505
+ " <severity>HIGH</severity>",
506
+ " <issue>",
507
+ " The context-mode security module failed to load.",
508
+ " User-configured `permissions.deny` patterns are NOT being enforced.",
509
+ " Bash commands and file operations bypass the deny gate (fail-open).",
510
+ " </issue>",
511
+ " <root_cause>",
512
+ " `hooks/security.bundle.mjs` (and `build/security.js`) are absent or unloadable.",
513
+ " Common on marketplace installs where `build/` is gitignored and the",
514
+ " bundle was missing prior to v1.0.127.",
515
+ " </root_cause>",
516
+ " <fix>",
517
+ " Run `npm run bundle` from the context-mode source checkout, OR",
518
+ " upgrade context-mode to v1.0.127+ (which ships hooks/security.bundle.mjs",
519
+ " via CI). To opt in to fail-CLOSED instead, set CONTEXT_MODE_REQUIRE_SECURITY=1.",
520
+ " To silence this warning while you investigate, set CONTEXT_MODE_SUPPRESS_SECURITY_WARNING=1.",
521
+ " </fix>",
522
+ "</context_mode_security_warning>",
523
+ ].join("\n");
524
+ }
525
+
526
+ /**
527
+ * Normalize platform-specific tool names to canonical (Claude Code) names.
528
+ *
529
+ * Evidence:
530
+ * - Gemini CLI: https://github.com/google-gemini/gemini-cli (run_shell_command, read_file, grep_search, web_fetch, activate_skill)
531
+ * - OpenCode: https://github.com/opencode-ai/opencode (bash, view, grep, fetch, agent)
532
+ * - Codex CLI: https://github.com/openai/codex (shell, read_file, grep_files, container.exec)
533
+ * - VS Code Copilot: run_in_terminal (command field), read_file, run_vs_code_task
534
+ */
535
+ const TOOL_ALIASES = {
536
+ // Gemini CLI / Qwen Code (share native tool names — Qwen is Gemini fork:
537
+ // refs/platforms/qwen-code/packages/core/src/tools/tool-names.ts)
538
+ "run_shell_command": "Bash",
539
+ "read_file": "Read",
540
+ "read_many_files": "Read",
541
+ "grep_search": "Grep",
542
+ "search_file_content": "Grep",
543
+ "web_fetch": "WebFetch",
544
+ "read_url_content": "WebFetch",
545
+ // Antigravity CLI (`agy`) native tool names. Keep in sync with the two other
546
+ // agy maps: hooks/antigravity-cli/payload.mjs (normalizeAgyToolName) and
547
+ // src/session/extract.ts (TOOL_NAME_NORMALIZE).
548
+ "run_command": "Bash",
549
+ "view_file": "Read",
550
+ "list_dir": "LS",
551
+ "search_web": "WebSearch",
552
+ // Qwen Code additional tool names (no routing branch yet but normalized
553
+ // so future routing logic works without per-platform fallback):
554
+ "write_file": "Write",
555
+ "edit": "Edit",
556
+ "glob": "Glob",
557
+ "todo_write": "TodoWrite",
558
+ "ask_user_question": "AskUserQuestion",
559
+ "list_directory": "LS",
560
+ "save_memory": "Memory",
561
+ "skill": "Skill",
562
+ "exit_plan_mode": "ExitPlanMode",
563
+ // OpenCode
564
+ "bash": "Bash",
565
+ "view": "Read",
566
+ "grep": "Grep",
567
+ "fetch": "WebFetch",
568
+ "agent": "Agent",
569
+ // Codex CLI
570
+ "shell": "Bash",
571
+ "shell_command": "Bash",
572
+ "exec_command": "Bash",
573
+ "container.exec": "Bash",
574
+ "local_shell": "Bash",
575
+ "grep_files": "Grep",
576
+ // OpenClaw native tools
577
+ "exec": "Bash",
578
+ "read": "Read",
579
+ "grep": "Grep",
580
+ "search": "Grep",
581
+ // Cursor
582
+ "mcp_web_fetch": "WebFetch",
583
+ "mcp_fetch_tool": "WebFetch",
584
+ "Shell": "Bash",
585
+ // VS Code Copilot
586
+ "run_in_terminal": "Bash",
587
+ // Kiro CLI (https://kiro.dev/docs/cli/hooks/)
588
+ "fs_read": "Read",
589
+ "fs_write": "Write",
590
+ "execute_bash": "Bash",
591
+ };
592
+
593
+ function toolLeafName(toolName) {
594
+ const raw = String(toolName ?? "");
595
+ const withoutMcpPrefix = raw.startsWith("MCP:") ? raw.slice(4) : raw;
596
+ const parts = withoutMcpPrefix.split(/__|\//).filter(Boolean);
597
+ return parts.at(-1) ?? withoutMcpPrefix;
598
+ }
599
+
600
+ function matchesContextModeTool(toolName, ctxName, legacyName) {
601
+ const raw = String(toolName ?? "");
602
+ const leaf = toolLeafName(raw);
603
+ if (leaf === ctxName) return true;
604
+ if (raw.startsWith("MCP:") && leaf === legacyName) return true;
605
+ return raw.includes("context-mode") && leaf === legacyName;
606
+ }
607
+
608
+ // External MCP detection (#529 + 15-adapter coverage follow-up).
609
+ //
610
+ // MCP-namespaced tool names follow per-platform conventions (see
611
+ // core/tool-naming.mjs):
612
+ // - `mcp__<server>__<tool>` Claude Code / Gemini CLI / Antigravity / Qwen Code / Codex
613
+ // - `MCP:<tool>` Cursor
614
+ // - `@<server>/<tool>` Kiro
615
+ //
616
+ // Tools belonging to context-mode itself are excluded — they have dedicated
617
+ // routing branches above (ctx_execute, ctx_execute_file, ctx_batch_execute)
618
+ // and re-routing them here would double-process the call.
619
+ const MCP_PREFIX = "mcp__";
620
+ const CURSOR_MCP_PREFIX = "MCP:";
621
+ const KIRO_MCP_PREFIX = "@";
622
+ const CTX_TOOL_PREFIX = "ctx_";
623
+ const CONTEXT_MODE_SUBSTRING = "context-mode";
624
+
625
+ function isExternalMcpTool(toolName) {
626
+ const raw = String(toolName ?? "");
627
+
628
+ // Claude / Codex / Gemini / Qwen / Antigravity wire shape.
629
+ if (raw.startsWith(MCP_PREFIX)) {
630
+ const server = raw.slice(MCP_PREFIX.length).split("__")[0];
631
+ if (!server) return false;
632
+ return !server.includes(CONTEXT_MODE_SUBSTRING);
633
+ }
634
+
635
+ // Cursor wire shape: `MCP:<tool>` — own tools are `MCP:ctx_*`. There is no
636
+ // server segment, so the discriminator is the tool-leaf prefix.
637
+ if (raw.startsWith(CURSOR_MCP_PREFIX)) {
638
+ const tool = raw.slice(CURSOR_MCP_PREFIX.length);
639
+ return tool.length > 0 && !tool.startsWith(CTX_TOOL_PREFIX);
640
+ }
641
+
642
+ // Kiro wire shape: `@<server>/<tool>` — own tools are `@context-mode/ctx_*`.
643
+ if (raw.startsWith(KIRO_MCP_PREFIX) && raw.includes("/")) {
644
+ const server = raw.slice(KIRO_MCP_PREFIX.length).split("/")[0];
645
+ if (!server) return false;
646
+ return !server.includes(CONTEXT_MODE_SUBSTRING);
647
+ }
648
+
649
+ return false;
650
+ }
651
+
652
+ function getShellCommand(toolInput) {
653
+ if (!toolInput || typeof toolInput !== "object") return "";
654
+ if (typeof toolInput.command === "string") return toolInput.command;
655
+ if (typeof toolInput.cmd === "string") return toolInput.cmd;
656
+ if (typeof toolInput.CommandLine === "string") return toolInput.CommandLine;
657
+ return "";
658
+ }
659
+
660
+ function getReadFilePath(toolInput) {
661
+ if (!toolInput || typeof toolInput !== "object") return "";
662
+ if (typeof toolInput.file_path === "string") return toolInput.file_path;
663
+ if (typeof toolInput.path === "string") return toolInput.path;
664
+ if (typeof toolInput.AbsolutePath === "string") return toolInput.AbsolutePath;
665
+ if (typeof toolInput.FilePath === "string") return toolInput.FilePath;
666
+ return "";
667
+ }
668
+
669
+ function getWebFetchUrl(toolInput) {
670
+ if (!toolInput || typeof toolInput !== "object") return "";
671
+ if (typeof toolInput.url === "string") return toolInput.url;
672
+ if (typeof toolInput.URL === "string") return toolInput.URL;
673
+ if (typeof toolInput.Url === "string") return toolInput.Url;
674
+ return "";
675
+ }
676
+
677
+ function getCodexConfigDir(env = process.env) {
678
+ const codexHome = env.CODEX_HOME;
679
+ if (codexHome && codexHome.trim() !== "") return resolve(codexHome);
680
+ return resolve(homedir(), ".codex");
681
+ }
682
+
683
+ function getPlatformSettingsPath(platform) {
684
+ if (platform === "codex") return resolve(getCodexConfigDir(), "settings.json");
685
+ return undefined;
686
+ }
687
+
688
+ /**
689
+ * Route a PreToolUse event. Returns normalized decision object or null for passthrough.
690
+ *
691
+ * @param {string} toolName - The tool name as reported by the platform
692
+ * @param {object} toolInput - The tool input/parameters
693
+ * @param {string} [projectDir] - Project directory for security policy lookup
694
+ * @param {string} [platform="claude-code"] - Platform ID for tool name formatting
695
+ * @param {string} [sessionId] - Stable session identifier from hook payload. When
696
+ * provided, the guidance throttle uses it to scope marker files across hook
697
+ * invocations even when process.ppid shifts (Windows/Git Bash — see #298).
698
+ * @param {object} [options] - Runtime routing context from the adapter.
699
+ * @param {boolean} [options.mcpToolsAvailable=true] - False when the current
700
+ * caller context cannot invoke ctx_* MCP tools even though an MCP server is
701
+ * live on the machine (Claude Code fixed-tool subagents — #794).
702
+ */
703
+ export function routePreToolUse(toolName, toolInput, projectDir, platform, sessionId, options = {}) {
704
+ const mcpToolsAvailable = options.mcpToolsAvailable !== false;
705
+
706
+ // ─── Opt-in fail-closed gate (#468 follow-up) ───
707
+ // Default behavior on security-module load failure is fail-OPEN (a stderr
708
+ // warning is emitted but routing continues). Security-conscious users can
709
+ // opt in to fail-CLOSED via CONTEXT_MODE_REQUIRE_SECURITY=1 — every PreToolUse
710
+ // event is denied with a clear reason until the security module loads cleanly.
711
+ // Universal gate (applies to all tools, not just Bash) since user `permissions.deny`
712
+ // patterns may target Read/Write paths that would otherwise leak before security loads.
713
+ if (process.env.CONTEXT_MODE_REQUIRE_SECURITY === "1" && securityInitFailed) {
714
+ return {
715
+ action: "deny",
716
+ reason:
717
+ "context-mode: security module unavailable and CONTEXT_MODE_REQUIRE_SECURITY=1 — fail-closed engaged. " +
718
+ "Run `npm run build` (or reinstall context-mode) to restore security enforcement. " +
719
+ "To bypass, unset or set CONTEXT_MODE_REQUIRE_SECURITY=0.",
720
+ };
721
+ }
722
+
723
+ // Build platform-specific tool namer (defaults to claude-code for backward compat)
724
+ const t = createToolNamer(platform || "claude-code");
725
+
726
+ // Build platform-specific guidance/routing content
727
+ const routingBlock = platform ? createRoutingBlock(t) : ROUTING_BLOCK;
728
+ const readGuidance = platform ? createReadGuidance(t) : READ_GUIDANCE;
729
+ const grepGuidance = platform ? createGrepGuidance(t) : GREP_GUIDANCE;
730
+ const bashGuidance = platform ? createBashGuidance(t) : BASH_GUIDANCE;
731
+
732
+ // Normalize platform-specific tool name to canonical
733
+ const canonical = TOOL_ALIASES[toolName] ?? toolName;
734
+ const platformSettingsPath = getPlatformSettingsPath(platform);
735
+
736
+ // ─── Bash: Stage 1 security check, then Stage 2 routing ───
737
+ if (canonical === "Bash") {
738
+ const command = getShellCommand(toolInput);
739
+
740
+ // Stage 1: Security check against user's deny/allow patterns.
741
+ // Only act when an explicit pattern matched. When no pattern matches,
742
+ // evaluateCommand returns { decision: "ask" } with no matchedPattern —
743
+ // in that case fall through so other hooks and the platform's native engine can decide.
744
+ if (security) {
745
+ const policies = security.readBashPolicies(projectDir, platformSettingsPath);
746
+ if (policies.length > 0) {
747
+ const result = security.evaluateCommand(command, policies);
748
+ if (result.decision === "deny") {
749
+ return { action: "deny", reason: `Blocked by security policy: matches deny pattern ${result.matchedPattern}` };
750
+ }
751
+ if (result.decision === "ask" && result.matchedPattern) {
752
+ return { action: "ask" };
753
+ }
754
+ // "allow" or no match → fall through to Stage 2
755
+ }
756
+ }
757
+
758
+ // Stage 2: Context-mode routing (existing behavior)
759
+
760
+ // curl/wget detection: strip quoted content first to avoid false positives
761
+ // like `gh issue edit --body "text with curl in it"` (Issue #63).
762
+ const stripped = stripQuotedContent(command);
763
+
764
+ // curl/wget — allow silent file-output downloads, block stdout floods (#166).
765
+ // Algorithm: split chained commands, evaluate each segment independently.
766
+ if (/(^|\s|&&|\||\;)(curl|wget)\s/i.test(stripped)) {
767
+ // Split on chain operators (&&, ||, ;) to evaluate each segment
768
+ const segments = stripped.split(/\s*(?:&&|\|\||;)\s*/);
769
+ const hasDangerousSegment = segments.some(seg => {
770
+ const s = seg.trim();
771
+ // Only evaluate segments that contain curl or wget
772
+ if (!/(^|\s)(curl|wget)\s/i.test(s)) return false;
773
+
774
+ const isCurl = /\bcurl\b/i.test(s);
775
+ const isWget = /\bwget\b/i.test(s);
776
+
777
+ // Check for file output flags
778
+ const hasFileOutput = isCurl
779
+ ? /\s(-o|--output)\s/.test(s) || /\s*>\s*/.test(s) || /\s*>>\s*/.test(s)
780
+ : /\s(-O|--output-document)\s/.test(s) || /\s*>\s*/.test(s) || /\s*>>\s*/.test(s);
781
+
782
+ if (!hasFileOutput) return true; // no file output → dangerous
783
+
784
+ // Stdout aliases: -o -, -o /dev/stdout, -O -
785
+ if (isCurl && /\s(-o|--output)\s+(-|\/dev\/stdout)(\s|$)/.test(s)) return true;
786
+ if (isWget && /\s(-O|--output-document)\s+(-|\/dev\/stdout)(\s|$)/.test(s)) return true;
787
+
788
+ // Verbose/trace flags flood stderr → context
789
+ if (/\s(-v|--verbose|--trace|-D\s+-)\b/.test(s)) return true;
790
+
791
+ // Must be silent (curl: -s/--silent, wget: -q/--quiet) to prevent progress bar stderr flood
792
+ const isSilent = isCurl
793
+ ? /\s-[a-zA-Z]*s|--silent/.test(s)
794
+ : /\s-[a-zA-Z]*q|--quiet/.test(s);
795
+ if (!isSilent) return true;
796
+
797
+ return false; // safe: silent + file output + no verbose + no stdout alias
798
+ });
799
+
800
+ if (hasDangerousSegment) {
801
+ return mcpRedirect({
802
+ action: "modify",
803
+ updatedInput: {
804
+ command: `echo "context-mode: curl/wget redirected. Call ${t("ctx_execute")}(language, code) to fetch the URL, derive your answer in code, and print only the result — the raw HTTP body stays in the sandbox instead of entering your conversation. Or call ${t("ctx_fetch_and_index")}(url, source) when you want to query the response later via ${t("ctx_search")}. Both have full network access. Retry the same call on a transient DNS error (EAI_AGAIN, ETIMEDOUT, ENETUNREACH)."`,
805
+ },
806
+ // D2 PRD Phase 3.1: marker payload for PostToolUse byte accounting.
807
+ redirectMeta: {
808
+ tool: "Bash",
809
+ type: "bash-redirected",
810
+ // 8192 byte default — typical curl/wget HTTP body the agent would
811
+ // have spilled into the model's context window had we not blocked.
812
+ bytesAvoided: 8192,
813
+ commandSummary: command.slice(0, 200),
814
+ },
815
+ }, mcpToolsAvailable);
816
+ }
817
+ // All segments safe → allow through
818
+ return null;
819
+ }
820
+
821
+ // Inline HTTP detection: strip only heredocs (not quotes) so that
822
+ // code passed via -e/-c flags is still visible to the regex, while
823
+ // heredoc content (e.g. cat << EOF ... requests.get ... EOF) is removed.
824
+ // These patterns are specific enough that false positives in quoted
825
+ // text are rare, unlike single-word "curl"/"wget" (Issue #63).
826
+ const noHeredoc = stripHeredocs(command);
827
+ if (
828
+ /fetch\s*\(\s*['"](https?:\/\/|http)/i.test(noHeredoc) ||
829
+ /requests\.(get|post|put)\s*\(/i.test(noHeredoc) ||
830
+ /http\.(get|request)\s*\(/i.test(noHeredoc)
831
+ ) {
832
+ return mcpRedirect({
833
+ action: "modify",
834
+ updatedInput: {
835
+ command: `echo "context-mode: Inline HTTP redirected. Call ${t("ctx_execute")}(language, code) to fetch, derive your answer in code, and console.log() only the result — the raw response body stays in the sandbox instead of entering your conversation. Full network access. Retry the same call on a transient DNS error (EAI_AGAIN, ETIMEDOUT, ENETUNREACH)."`,
836
+ },
837
+ }, mcpToolsAvailable);
838
+ }
839
+
840
+ // Build tools (gradle, maven, sbt) → redirect to execute sandbox (Issue #38, #406).
841
+ // These produce extremely verbose output that should stay in sandbox.
842
+ // Word-boundary guard prevents matching `gradle-wrapper-config`, `mvnDocker`, etc.
843
+ if (/(^|\s|&&|\||\;)(\.\/gradlew|gradlew|gradle|\.\/mvnw|mvnw|mvn|\.\/sbt|sbt)(\s|$)/i.test(stripped)) {
844
+ const safeCmd = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
845
+ return mcpRedirect({
846
+ action: "modify",
847
+ updatedInput: {
848
+ command: `echo "context-mode: Build tool redirected. Call ${t("ctx_execute")}(language: \\"shell\\", code: \\"${safeCmd} 2>&1 | tail -30\\") to run the build and print only the tail — the verbose build log stays in the sandbox instead of entering your conversation. For more targeted output, replace \\"tail -30\\" with \\"grep -E '(error|warning|FAIL|✗|×)'\\" or similar, so only the lines that matter come back."`,
849
+ },
850
+ }, mcpToolsAvailable);
851
+ }
852
+
853
+ // Skip the routing nudge for commands whose output is structurally
854
+ // bounded (#463) — pwd, whoami, git status, --version probes, etc.
855
+ // Conservative: any pipe/redirect/chain disqualifies, unknown commands
856
+ // still get the nudge.
857
+ if (isStructurallyBounded(command)) {
858
+ return null;
859
+ }
860
+
861
+ // #817: opt-in size threshold. When the operator configures
862
+ // CONTEXT_MODE_BASH_NUDGE_MIN_COMMAND_BYTES, a short unbounded command is
863
+ // treated as expected-lightweight and passes through untouched — reserving
864
+ // the nudge for commands large/complex enough to plausibly flood context.
865
+ // Default (0) preserves current behavior, so large-output savings are not
866
+ // weakened unless the operator explicitly opts in.
867
+ const minCommandBytes = getBashNudgeMinCommandBytes();
868
+ if (minCommandBytes > 0 && Buffer.byteLength(command, "utf8") < minCommandBytes) {
869
+ return null;
870
+ }
871
+
872
+ // allow all other Bash commands, but inject routing nudge (once per session)
873
+ return guidanceOnce("bash", bashGuidance, sessionId);
874
+ }
875
+
876
+ // ─── Read: nudge toward execute_file + large-file byte accounting ───
877
+ // D2 PRD Phase 4 (slices 4.4–4.6): when the file is large enough to flood
878
+ // context, attach `redirectMeta` so PostToolUse can emit a `read-redirected`
879
+ // event with the actual file size as bytes_avoided. Threshold = 50 000 bytes;
880
+ // smaller reads stay on the existing one-shot guidance nudge.
881
+ if (canonical === "Read") {
882
+ const filePath = getReadFilePath(toolInput);
883
+ if (filePath) {
884
+ try {
885
+ const st = statSync(filePath);
886
+ if (st.isFile() && st.size > 50_000) {
887
+ const decision = guidanceOnce("read", readGuidance, sessionId)
888
+ ?? { action: "context", additionalContext: readGuidance };
889
+ decision.redirectMeta = {
890
+ tool: "Read",
891
+ type: "read-redirected",
892
+ bytesAvoided: st.size,
893
+ commandSummary: String(filePath).slice(0, 200),
894
+ };
895
+ return decision;
896
+ }
897
+ } catch { /* file missing or unreadable — fall through to plain guidance */ }
898
+ }
899
+ return guidanceOnce("read", readGuidance, sessionId);
900
+ }
901
+
902
+ // ─── Grep: nudge toward execute (once per session) ───
903
+ if (canonical === "Grep") {
904
+ return guidanceOnce("grep", grepGuidance, sessionId);
905
+ }
906
+
907
+ // ─── WebFetch: deny + redirect to sandbox ───
908
+ if (canonical === "WebFetch") {
909
+ const url = getWebFetchUrl(toolInput);
910
+ return mcpRedirect({
911
+ action: "deny",
912
+ reason: `context-mode: WebFetch redirected. Call ${t("ctx_fetch_and_index")}(url: "${url}", source: "...") to fetch + index the page, then ${t("ctx_search")}(queries: [...]) to query the indexed content — the raw page bytes stay in storage instead of entering your conversation. Or call ${t("ctx_execute")}(language, code) when you want to derive your answer in one round trip (parse, extract, count) without persisting the response. Both have full network access. Retry the same call on a transient DNS error (EAI_AGAIN, ETIMEDOUT, ENETUNREACH).`,
913
+ // D2 PRD Phase 4.1: marker payload for PostToolUse byte accounting.
914
+ redirectMeta: {
915
+ tool: "WebFetch",
916
+ type: "webfetch-redirected",
917
+ // 16384 = typical web page body bytes prevented from entering the
918
+ // model's context window.
919
+ bytesAvoided: 16384,
920
+ commandSummary: String(url).slice(0, 200),
921
+ },
922
+ }, mcpToolsAvailable);
923
+ }
924
+
925
+ // ─── Agent: inject context-mode routing into subagent prompts ───
926
+ // Subagents cannot use ctx commands (stats/doctor/upgrade/purge) — omit that section (#233)
927
+ if (canonical === "Agent") {
928
+ const subagentType = toolInput.subagent_type ?? "";
929
+ // Detect the correct field name for the prompt/request/objective/question/query
930
+ const fieldName = ["prompt", "request", "objective", "question", "query", "task"].find(f => f in toolInput) ?? "prompt";
931
+ const prompt = toolInput[fieldName] ?? "";
932
+
933
+ // Claude Code surfaces ctx_* as DEFERRED tools (schemas loaded via ToolSearch).
934
+ // Without a bootstrap step the subagent is told to use ctx_* tools it cannot yet
935
+ // invoke and stalls (see #724). Prepend the ToolSearch bootstrap for claude-code
936
+ // (the default when platform is unset). Other platforms don't defer, so skip it.
937
+ const isClaudeCode = !platform || platform === "claude-code";
938
+ const subagentBlock = createRoutingBlock(t, {
939
+ includeCommands: false,
940
+ toolSearchBootstrap: isClaudeCode,
941
+ });
942
+
943
+ const updatedInput =
944
+ subagentType === "Bash"
945
+ ? { ...toolInput, [fieldName]: prompt + subagentBlock, subagent_type: "general-purpose" }
946
+ : { ...toolInput, [fieldName]: prompt + subagentBlock };
947
+
948
+ return { action: "modify", updatedInput };
949
+ }
950
+
951
+ // ─── MCP execute: security check for shell commands ───
952
+ // Match bare, generic MCP, and legacy context-mode execute tool names.
953
+ const shouldPinClaudeExecutorCwd =
954
+ platform === "claude-code" &&
955
+ typeof projectDir === "string" &&
956
+ projectDir.length > 0;
957
+
958
+ if (matchesContextModeTool(toolName, "ctx_execute", "execute")) {
959
+ if (security && toolInput.language === "shell") {
960
+ const code = toolInput.code ?? "";
961
+ const policies = security.readBashPolicies(projectDir, platformSettingsPath);
962
+ if (policies.length > 0) {
963
+ const result = security.evaluateCommand(code, policies);
964
+ if (result.decision === "deny") {
965
+ return { action: "deny", reason: `Blocked by security policy: shell code matches deny pattern ${result.matchedPattern}` };
966
+ }
967
+ if (result.decision === "ask" && result.matchedPattern) {
968
+ return { action: "ask" };
969
+ }
970
+ }
971
+ }
972
+ if (toolInput.language === "shell" && shouldPinClaudeExecutorCwd && typeof toolInput.cwd !== "string") {
973
+ return { action: "modify", updatedInput: { ...toolInput, cwd: projectDir } };
974
+ }
975
+ return null;
976
+ }
977
+
978
+ // ─── MCP execute_file: check file path + code against deny patterns ───
979
+ if (matchesContextModeTool(toolName, "ctx_execute_file", "execute_file")) {
980
+ if (security) {
981
+ // Check file path against Read deny patterns
982
+ const filePath = toolInput.path ?? "";
983
+ const denyGlobs = security.readToolDenyPatterns("Read", projectDir, platformSettingsPath);
984
+ const evalResult = security.evaluateFilePath(filePath, denyGlobs);
985
+ if (evalResult.denied) {
986
+ return { action: "deny", reason: `Blocked by security policy: file path matches Read deny pattern ${evalResult.matchedPattern}` };
987
+ }
988
+
989
+ // Check code parameter against Bash deny patterns (same as execute)
990
+ const lang = toolInput.language ?? "";
991
+ const code = toolInput.code ?? "";
992
+ if (lang === "shell") {
993
+ const policies = security.readBashPolicies(projectDir, platformSettingsPath);
994
+ if (policies.length > 0) {
995
+ const result = security.evaluateCommand(code, policies);
996
+ if (result.decision === "deny") {
997
+ return { action: "deny", reason: `Blocked by security policy: shell code matches deny pattern ${result.matchedPattern}` };
998
+ }
999
+ if (result.decision === "ask" && result.matchedPattern) {
1000
+ return { action: "ask" };
1001
+ }
1002
+ }
1003
+ }
1004
+ }
1005
+ return null;
1006
+ }
1007
+
1008
+ // ─── MCP batch_execute: check each command individually ───
1009
+ if (matchesContextModeTool(toolName, "ctx_batch_execute", "batch_execute")) {
1010
+ if (security) {
1011
+ const commands = toolInput.commands ?? [];
1012
+ const policies = security.readBashPolicies(projectDir, platformSettingsPath);
1013
+ if (policies.length > 0) {
1014
+ for (const entry of commands) {
1015
+ const cmd = entry.command ?? "";
1016
+ const result = security.evaluateCommand(cmd, policies);
1017
+ if (result.decision === "deny") {
1018
+ return { action: "deny", reason: `Blocked by security policy: batch command "${entry.label ?? cmd}" matches deny pattern ${result.matchedPattern}` };
1019
+ }
1020
+ if (result.decision === "ask" && result.matchedPattern) {
1021
+ return { action: "ask" };
1022
+ }
1023
+ }
1024
+ }
1025
+ }
1026
+ if (shouldPinClaudeExecutorCwd && typeof toolInput.cwd !== "string") {
1027
+ return { action: "modify", updatedInput: { ...toolInput, cwd: projectDir } };
1028
+ }
1029
+ return null;
1030
+ }
1031
+
1032
+ // ─── External MCP tools: periodic guidance about routing large payloads ─── (#529, #567 follow-up)
1033
+ // hooks/hooks.json registers a `mcp__(?!plugin_context-mode_)` matcher so this
1034
+ // branch fires for slack/telegram/gdrive/notion-style MCPs whose results would
1035
+ // otherwise spill into context. We don't deny or modify — the agent still needs
1036
+ // the tool's output; we just nudge it to pipe large results through ctx_execute.
1037
+ //
1038
+ // Cadence: every N calls (default 10, tunable via CONTEXT_MODE_EXTERNAL_MCP_NUDGE_EVERY).
1039
+ // The original one-shot nudge (#529) was lost after context compaction in
1040
+ // MCP-heavy sessions (e.g. 50+ Jira calls in #567 follow-up), letting later
1041
+ // payloads flood context unchecked. Re-firing periodically keeps the guidance
1042
+ // in the model's recent window without saturating it.
1043
+ if (isExternalMcpTool(toolName)) {
1044
+ const externalMcpGuidance = platform ? createExternalMcpGuidance(t) : EXTERNAL_MCP_GUIDANCE;
1045
+ return guidancePeriodic("external-mcp", externalMcpGuidance, sessionId, getExternalMcpNudgeEvery());
1046
+ }
1047
+
1048
+ // Unknown tool — pass through
1049
+ return null;
1050
+ }