@gajae-code/coding-agent 0.11.11 → 0.12.1

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 (344) hide show
  1. package/CHANGELOG.md +131 -4
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/cli/read-cli.d.ts +1 -0
  4. package/dist/types/commands/deep-interview.d.ts +9 -0
  5. package/dist/types/commands/harness.d.ts +22 -0
  6. package/dist/types/commands/read.d.ts +6 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/provider-auth-health.d.ts +14 -0
  9. package/dist/types/config/provider-ranking.d.ts +59 -0
  10. package/dist/types/config/settings-schema.d.ts +83 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/exa/mcp-client.d.ts +10 -1
  13. package/dist/types/exec/bash-executor.d.ts +45 -12
  14. package/dist/types/extensibility/extensions/types.d.ts +38 -4
  15. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  17. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  18. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  19. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  20. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  21. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  22. package/dist/types/extensibility/shared-events.d.ts +6 -0
  23. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  24. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  25. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  26. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  27. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  28. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  29. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  30. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +70 -1
  31. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  32. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  33. package/dist/types/gjc-runtime/team-runtime.d.ts +27 -1
  34. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  35. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  36. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  37. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +17 -0
  38. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  39. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  40. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  41. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  42. package/dist/types/main.d.ts +1 -1
  43. package/dist/types/memories/index.d.ts +2 -0
  44. package/dist/types/modes/acp/acp-agent.d.ts +10 -19
  45. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  46. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  47. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  48. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  49. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  50. package/dist/types/modes/components/session-observer-overlay.d.ts +22 -0
  51. package/dist/types/modes/components/settings-selector.d.ts +11 -0
  52. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  53. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  54. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  55. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  56. package/dist/types/modes/interactive-mode.d.ts +2 -0
  57. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  58. package/dist/types/modes/types.d.ts +2 -0
  59. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  60. package/dist/types/reminders/star-reminder.d.ts +3 -13
  61. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  62. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  63. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  64. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  65. package/dist/types/sdk/broker/transport.d.ts +2 -0
  66. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  68. package/dist/types/sdk/bus/config.d.ts +6 -2
  69. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  70. package/dist/types/sdk/bus/index.d.ts +44 -1
  71. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +44 -0
  72. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  73. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  74. package/dist/types/sdk/bus/reconciliation-store.d.ts +69 -0
  75. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  76. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  77. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  78. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  79. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  80. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  81. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  82. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  83. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  84. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  85. package/dist/types/sdk/host/host.d.ts +2 -0
  86. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  87. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  88. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  89. package/dist/types/sdk/prompt-status.d.ts +23 -3
  90. package/dist/types/sdk/session.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +82 -12
  92. package/dist/types/session/artifacts.d.ts +8 -0
  93. package/dist/types/session/blob-store.d.ts +57 -4
  94. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  95. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  96. package/dist/types/session/session-manager.d.ts +48 -19
  97. package/dist/types/session/session-storage.d.ts +4 -0
  98. package/dist/types/session/streaming-output.d.ts +96 -12
  99. package/dist/types/setup/credential-import.d.ts +12 -0
  100. package/dist/types/skill-state/active-state.d.ts +12 -4
  101. package/dist/types/skill-state/workflow-hud.d.ts +9 -0
  102. package/dist/types/task/executor.d.ts +3 -2
  103. package/dist/types/task/index.d.ts +3 -1
  104. package/dist/types/task/provider-retry-status.d.ts +8 -0
  105. package/dist/types/task/receipt.d.ts +7 -1
  106. package/dist/types/task/types.d.ts +23 -0
  107. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  108. package/dist/types/tools/bash-interactive.d.ts +4 -1
  109. package/dist/types/tools/bash.d.ts +3 -1
  110. package/dist/types/tools/browser/launch.d.ts +7 -0
  111. package/dist/types/tools/fetch.d.ts +17 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -0
  113. package/dist/types/tools/index.d.ts +4 -0
  114. package/dist/types/tools/output-meta.d.ts +44 -3
  115. package/dist/types/tools/path-utils.d.ts +1 -0
  116. package/dist/types/tools/read-internals.d.ts +12 -0
  117. package/dist/types/tools/read.d.ts +45 -7
  118. package/dist/types/tools/resource-gc.d.ts +4 -0
  119. package/dist/types/tools/review.d.ts +5 -7
  120. package/dist/types/tools/subagent.d.ts +2 -0
  121. package/dist/types/tools/tool-result.d.ts +7 -1
  122. package/dist/types/utils/gh.d.ts +13 -0
  123. package/dist/types/utils/image-resize.d.ts +1 -0
  124. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  125. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  126. package/dist/types/web/search/providers/xai.d.ts +2 -0
  127. package/package.json +11 -13
  128. package/scripts/acp-conformance-agent.ts +218 -0
  129. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  130. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  131. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  132. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  133. package/scripts/resident-memory-bench.ts +1341 -0
  134. package/scripts/run-acp-conformance.ts +285 -0
  135. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  136. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  137. package/src/async/job-manager.ts +78 -20
  138. package/src/capability/index.ts +8 -0
  139. package/src/cli/fast-help.ts +1 -1
  140. package/src/cli/plugin-cli.ts +176 -18
  141. package/src/cli/read-cli.ts +2 -1
  142. package/src/cli.ts +1 -0
  143. package/src/commands/deep-interview.ts +29 -9
  144. package/src/commands/harness.ts +35 -12
  145. package/src/commands/read.ts +11 -2
  146. package/src/commands/team.ts +20 -2
  147. package/src/config/file-lock.ts +11 -2
  148. package/src/config/model-registry.ts +21 -2
  149. package/src/config/provider-auth-health.ts +42 -0
  150. package/src/config/provider-ranking.ts +119 -0
  151. package/src/config/settings-schema.ts +74 -1
  152. package/src/config/settings.ts +246 -108
  153. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  154. package/src/coordinator-mcp/server.ts +5 -12
  155. package/src/defaults/gjc/skills/deep-interview/SKILL.md +230 -82
  156. package/src/defaults/gjc/skills/ralplan/SKILL.md +77 -41
  157. package/src/defaults/gjc/skills/team/SKILL.md +3 -1
  158. package/src/defaults/gjc/skills/ultragoal/SKILL.md +62 -39
  159. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  160. package/src/defaults/gjc-defaults.ts +4 -4
  161. package/src/eval/py/executor.ts +7 -2
  162. package/src/exa/mcp-client.ts +12 -3
  163. package/src/exec/bash-executor.ts +144 -27
  164. package/src/extensibility/extensions/runner.ts +15 -0
  165. package/src/extensibility/extensions/types.ts +46 -5
  166. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  167. package/src/extensibility/gjc-plugins/index.ts +19 -2
  168. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  169. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  170. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  171. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  172. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  173. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  174. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  175. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  176. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  177. package/src/extensibility/gjc-plugins/types.ts +141 -0
  178. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  179. package/src/extensibility/shared-events.ts +5 -0
  180. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  181. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  182. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  183. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  184. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  185. package/src/gjc-runtime/gc-render.ts +1 -0
  186. package/src/gjc-runtime/gc-runtime.ts +11 -1
  187. package/src/gjc-runtime/launch-tmux.ts +25 -6
  188. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  189. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  190. package/src/gjc-runtime/ralplan-runtime.ts +1149 -98
  191. package/src/gjc-runtime/state-runtime.ts +2 -1
  192. package/src/gjc-runtime/state-writer.ts +70 -222
  193. package/src/gjc-runtime/team-launch.ts +42 -5
  194. package/src/gjc-runtime/team-runtime.ts +1085 -40
  195. package/src/gjc-runtime/team-store.ts +32 -0
  196. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  197. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  198. package/src/gjc-runtime/team-workers.ts +1 -0
  199. package/src/gjc-runtime/tmux-owner-isolation.ts +28 -0
  200. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  201. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  202. package/src/gjc-runtime/ultragoal-runtime.ts +414 -618
  203. package/src/gjc-runtime/workflow-manifest.generated.json +105 -449
  204. package/src/gjc-runtime/workflow-manifest.ts +38 -247
  205. package/src/hooks/native-skill-hook.ts +17 -4
  206. package/src/hooks/skill-state.ts +1 -23
  207. package/src/internal-urls/docs-index.generated.ts +21 -22
  208. package/src/internal-urls/local-root-gc.ts +127 -0
  209. package/src/main.ts +9 -5
  210. package/src/memories/index.ts +10 -0
  211. package/src/modes/DESIGN.md +98 -0
  212. package/src/modes/acp/acp-agent.ts +339 -191
  213. package/src/modes/acp/acp-event-mapper.ts +14 -7
  214. package/src/modes/acp/acp-mode.ts +39 -3
  215. package/src/modes/components/assistant-message.ts +123 -22
  216. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  217. package/src/modes/components/irc-sidebar.ts +176 -62
  218. package/src/modes/components/model-selector.ts +61 -4
  219. package/src/modes/components/notifications-settings-editor.ts +12 -1
  220. package/src/modes/components/oauth-selector.ts +96 -28
  221. package/src/modes/components/read-tool-group.ts +82 -14
  222. package/src/modes/components/session-observer-overlay.ts +457 -83
  223. package/src/modes/components/settings-selector.ts +116 -23
  224. package/src/modes/components/status-line/gh.ts +16 -0
  225. package/src/modes/components/tool-execution.ts +138 -27
  226. package/src/modes/components/tool-status-header.ts +3 -14
  227. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  228. package/src/modes/controllers/command-controller.ts +4 -0
  229. package/src/modes/controllers/event-controller.ts +109 -27
  230. package/src/modes/controllers/extension-ui-controller.ts +26 -2
  231. package/src/modes/controllers/input-controller.ts +18 -3
  232. package/src/modes/controllers/selector-controller.ts +127 -36
  233. package/src/modes/interactive-mode.ts +62 -3
  234. package/src/modes/irc-observation-ledger.ts +71 -15
  235. package/src/modes/runtime-init.ts +22 -2
  236. package/src/modes/types.ts +2 -0
  237. package/src/modes/utils/ui-helpers.ts +94 -1
  238. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  239. package/src/prompts/agents/architect.md +9 -1
  240. package/src/prompts/agents/critic.md +9 -0
  241. package/src/prompts/system/eager-todo.md +8 -4
  242. package/src/prompts/system/system-prompt.md +11 -5
  243. package/src/prompts/tools/bash.md +5 -5
  244. package/src/prompts/tools/read.md +4 -2
  245. package/src/prompts/tools/search-tool-bm25.md +5 -0
  246. package/src/prompts/tools/task.md +1 -1
  247. package/src/reminders/star-reminder.ts +4 -42
  248. package/src/runtime-mcp/manager.ts +19 -4
  249. package/src/runtime-mcp/smithery-auth.ts +17 -2
  250. package/src/runtime-mcp/smithery-connect.ts +11 -1
  251. package/src/sdk/acp/adapter.ts +22 -6
  252. package/src/sdk/broker/lifecycle.ts +25 -6
  253. package/src/sdk/broker/transport.ts +35 -3
  254. package/src/sdk/bus/attachment-registry.ts +5 -1
  255. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  256. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  257. package/src/sdk/bus/config.ts +18 -2
  258. package/src/sdk/bus/control-drain-lease.ts +104 -0
  259. package/src/sdk/bus/index.ts +984 -146
  260. package/src/sdk/bus/kind-aware-reconciliation.ts +317 -0
  261. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  262. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  263. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  264. package/src/sdk/bus/reconciliation-store.ts +303 -0
  265. package/src/sdk/bus/rich-draft.ts +1 -1
  266. package/src/sdk/bus/rich-render.ts +2 -2
  267. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  268. package/src/sdk/bus/telegram-cli.ts +33 -8
  269. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  270. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  271. package/src/sdk/bus/telegram-daemon.ts +916 -23
  272. package/src/sdk/bus/telegram-reference.ts +40 -3
  273. package/src/sdk/bus/threaded-render.ts +48 -3
  274. package/src/sdk/bus/topic-registry.ts +20 -0
  275. package/src/sdk/host/control/dispatch.ts +5 -1
  276. package/src/sdk/host/control/operations.ts +1 -1
  277. package/src/sdk/host/host.ts +23 -1
  278. package/src/sdk/host/query/handlers.ts +55 -0
  279. package/src/sdk/host/query/revision-store.ts +36 -2
  280. package/src/sdk/host/reverse-leases.ts +44 -7
  281. package/src/sdk/mcp/server.ts +46 -18
  282. package/src/sdk/prompt-status.ts +16 -3
  283. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  284. package/src/sdk/protocol/operation-registry.ts +7 -2
  285. package/src/sdk/session.ts +101 -10
  286. package/src/session/agent-session.ts +1286 -220
  287. package/src/session/artifacts.ts +22 -0
  288. package/src/session/auth-broker-config.ts +10 -3
  289. package/src/session/blob-store.ts +915 -24
  290. package/src/session/internal/managed-session-scope.ts +44 -6
  291. package/src/session/internal/managed-session-storage.ts +48 -6
  292. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  293. package/src/session/session-manager.ts +2364 -438
  294. package/src/session/session-storage.ts +9 -1
  295. package/src/session/streaming-output.ts +500 -111
  296. package/src/setup/credential-import.ts +75 -13
  297. package/src/setup/provider-onboarding.ts +19 -4
  298. package/src/skill-state/active-state.ts +32 -10
  299. package/src/skill-state/workflow-hud.ts +42 -2
  300. package/src/skill-state/workflow-mutation-guard.ts +560 -8
  301. package/src/task/executor.ts +122 -39
  302. package/src/task/index.ts +23 -14
  303. package/src/task/provider-retry-status.ts +16 -0
  304. package/src/task/receipt.ts +23 -7
  305. package/src/task/render.ts +6 -2
  306. package/src/task/types.ts +108 -0
  307. package/src/tools/ask-answer-registry.ts +12 -6
  308. package/src/tools/ask.ts +169 -118
  309. package/src/tools/ast-edit.ts +1 -0
  310. package/src/tools/ast-grep.ts +1 -0
  311. package/src/tools/bash-interactive.ts +8 -3
  312. package/src/tools/bash.ts +503 -60
  313. package/src/tools/browser/launch.ts +41 -6
  314. package/src/tools/fetch.ts +114 -23
  315. package/src/tools/find.ts +1 -0
  316. package/src/tools/image-gen.ts +29 -5
  317. package/src/tools/index.ts +4 -0
  318. package/src/tools/output-meta.ts +281 -20
  319. package/src/tools/path-utils.ts +8 -3
  320. package/src/tools/read-internals.ts +44 -0
  321. package/src/tools/read.ts +1560 -310
  322. package/src/tools/resource-gc.ts +182 -18
  323. package/src/tools/review.ts +5 -7
  324. package/src/tools/search.ts +1 -0
  325. package/src/tools/subagent-render.ts +6 -2
  326. package/src/tools/subagent.ts +6 -0
  327. package/src/tools/telegram-send.ts +6 -3
  328. package/src/tools/tool-result.ts +14 -1
  329. package/src/utils/gh.ts +39 -0
  330. package/src/utils/image-resize.ts +3 -0
  331. package/src/web/search/providers/anthropic.ts +4 -4
  332. package/src/web/search/providers/codex.ts +1 -1
  333. package/src/web/search/providers/kimi.ts +15 -5
  334. package/src/web/search/providers/searxng.ts +47 -4
  335. package/src/web/search/providers/xai.ts +7 -2
  336. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  337. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  338. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  339. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  340. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  341. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  342. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  343. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
  344. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -57,6 +57,38 @@ const BASH_OPAQUE_INTERPRETER_WRITE_RE =
57
57
  const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE =
58
58
  /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:<<[-]?\s*['"]?\w+['"]?)[\s\S]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
59
59
  const BASH_DD_OUTPUT_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?dd\b([^;&|\n]*)/gi;
60
+ /** Literal `sh|bash|zsh -c '<script>'` payloads whose nested script must also be scanned. */
61
+ const BASH_NESTED_SHELL_RE =
62
+ /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(?:ba|z|da)?sh\s+(?:-[A-Za-z]+\s+)*-[A-Za-z]*c\s+(?:(')([^']*)'|(")([^"]*)")/g;
63
+ /** Heredoc opener (`<<`/`<<-` plus an optionally quoted delimiter); `<<<` here-strings excluded. */
64
+ const BASH_HEREDOC_OPEN_RE = /(?<!<)<<(?!<)(-?)\s*(?:'([^'\s]+)'|"([^"\s]+)"|(\\)?([A-Za-z_][\w.-]*))/g;
65
+ /** Consumers whose stdin is inert data: their heredoc bodies are safe to mask. Anything else stays live. */
66
+ const HEREDOC_DATA_CONSUMERS = new Set([
67
+ "cat",
68
+ "tee",
69
+ "head",
70
+ "tail",
71
+ "wc",
72
+ "sort",
73
+ "uniq",
74
+ "tr",
75
+ "cut",
76
+ "grep",
77
+ "column",
78
+ "nl",
79
+ "fold",
80
+ "fmt",
81
+ "base64",
82
+ "md5sum",
83
+ "sha1sum",
84
+ "sha256sum",
85
+ "sha512sum",
86
+ "shasum",
87
+ "jq",
88
+ "yq",
89
+ ]);
90
+ /** Stdin appliers whose heredoc body mutates files in ways no body scan can model. */
91
+ const HEREDOC_MUTATING_CONSUMERS = new Set(["patch", "ed", "ex", "sqlite3"]);
60
92
 
61
93
  type ToolWithEditMode = AgentTool & {
62
94
  mode?: unknown;
@@ -77,6 +109,12 @@ export interface WorkflowMutationGuardInput {
77
109
  interface ExtractedTargets {
78
110
  paths: string[];
79
111
  unknown: boolean;
112
+ /**
113
+ * Set when a bash command was recognized as performing a filesystem write.
114
+ * During a planning phase an unrecognized bash command is allowed (reading
115
+ * and inspection must not be blocked); only a recognized mutation blocks.
116
+ */
117
+ explicitMutation?: boolean;
80
118
  }
81
119
 
82
120
  export interface WorkflowMutationDecision {
@@ -382,11 +420,485 @@ function cleanShellWord(value: string): string {
382
420
  return value.replace(/^['"]|['"]$/g, "");
383
421
  }
384
422
 
423
+ /**
424
+ * Blank out single-quoted spans so shell metacharacters inside a literal
425
+ * argument value (e.g. `--value 'uses `x`; a > b'`) are not misread as
426
+ * redirections or separators. Single quotes suppress every expansion in POSIX
427
+ * shells, so their contents are always inert data. Double-quoted spans are left
428
+ * intact because they still expand `$(...)` and backticks.
429
+ */
430
+ function maskSingleQuotedSpans(command: string): string {
431
+ let masked = "";
432
+ let inSingle = false;
433
+ for (const character of command) {
434
+ if (character === "'") {
435
+ inSingle = !inSingle;
436
+ masked += character;
437
+ continue;
438
+ }
439
+ masked += inSingle && character !== "\n" ? " " : character;
440
+ }
441
+ // An unbalanced quote means the parse is unreliable; keep the original text
442
+ // so the scanner stays fail-closed rather than blind.
443
+ return inSingle ? command : masked;
444
+ }
445
+
385
446
  function isDeviceSinkPath(value: string): boolean {
386
447
  return DEVICE_SINK_PATHS.has(cleanShellWord(value));
387
448
  }
388
449
 
389
- function extractBashTargets(args: unknown): ExtractedTargets {
450
+ interface HeredocMaskResult {
451
+ masked: string;
452
+ /** An unquoted-delimiter heredoc body carried `$(…)`/backtick expansion — live code the scanner cannot model. */
453
+ opaqueExpansion: boolean;
454
+ /** The heredoc feeds a stdin applier (patch/ed/ex/sqlite3) whose body mutates files no scan can attribute. */
455
+ mutatingConsumer: boolean;
456
+ }
457
+
458
+ /** Quote/substitution state carried ACROSS physical lines so multiline constructs cannot fake heredoc openers. */
459
+ interface ShellQuoteState {
460
+ inSingle: boolean;
461
+ inDouble: boolean;
462
+ /** The open single quote is ANSI-C (`$'…'`): backslash escapes inside it decode at runtime. */
463
+ inAnsiC: boolean;
464
+ /**
465
+ * Paren stack inside substitutions: `true` for a `$(`/`<(`/`>(` opener
466
+ * (its `)` is a word character), `false` for a plain nested paren (its `)`
467
+ * is an operator, so a following `#` starts a comment).
468
+ */
469
+ parenStack: boolean[];
470
+ }
471
+
472
+ /**
473
+ * Mask one physical line for syntax analysis, carrying `state` across lines:
474
+ * quoted spans and backslash-escaped characters become spaces (they are data,
475
+ * never syntax), so a `<<` inside `"…"`/`'…'` — even when the quote opened on a
476
+ * PREVIOUS line — or an escaped separator like `\|` is never misread. An
477
+ * unquoted `#` at a word boundary starts a comment: the rest of the line is
478
+ * blanked without touching quote state, so a stray quote inside a comment
479
+ * cannot poison later lines. A `)` closing a `$(`/`<(`/`>(` substitution is
480
+ * part of the current word (`x=$(true)#literal`), not a comment boundary.
481
+ * `masked` is the same length as the input so match offsets stay aligned.
482
+ * `dequoted` applies bash-like QUOTE REMOVAL instead — quote/backslash marks
483
+ * drop, quoted word characters stay, quoted metacharacters neutralize to `_` —
484
+ * so obfuscated spellings (`e\val`, `'ev'al`) reassemble into their real
485
+ * command word without quoted data ever forming fake command boundaries. A
486
+ * leading `$` immediately before `'…'` (ANSI-C) or `"…"` (locale-translated)
487
+ * is stripped during quote removal too, so `$'eval'`/`$"eval"` dequote to
488
+ * plain `eval`, matching real bash quote-removal semantics for both forms.
489
+ * `continued` reports an unescaped trailing backslash outside quotes (logical
490
+ * line continuation).
491
+ */
492
+ function maskLineForSyntax(
493
+ line: string,
494
+ state: ShellQuoteState,
495
+ ): { masked: string; dequoted: string; continued: boolean; ansiCEscape: boolean } {
496
+ let masked = "";
497
+ let dequoted = "";
498
+ let escaped = false;
499
+ let previous = "";
500
+ let ansiCEscape = false;
501
+ // Quoted data keeps word characters (so `'ev'al` reassembles to `eval`) but
502
+ // neutralizes everything else to `_` so it can never form a command boundary.
503
+ const dequoteData = (character: string): string => (/[\w./-]/.test(character) ? character : "_");
504
+ for (const character of line) {
505
+ if (state.inSingle) {
506
+ if (character === "'") {
507
+ state.inSingle = false;
508
+ state.inAnsiC = false;
509
+ } else {
510
+ // ANSI-C escapes (`$'\145val'`) decode at runtime into characters the
511
+ // dequoted view cannot reproduce — flag them so the caller fails closed.
512
+ if (state.inAnsiC && character === "\\") ansiCEscape = true;
513
+ masked += " ";
514
+ dequoted += dequoteData(character);
515
+ previous = character;
516
+ continue;
517
+ }
518
+ masked += "'";
519
+ previous = character;
520
+ continue;
521
+ }
522
+ if (escaped) {
523
+ escaped = false;
524
+ masked += " ";
525
+ dequoted += dequoteData(character);
526
+ previous = character;
527
+ continue;
528
+ }
529
+ if (character === "\\") {
530
+ escaped = true;
531
+ masked += " ";
532
+ previous = character;
533
+ continue;
534
+ }
535
+ if (state.inDouble) {
536
+ if (character === '"') state.inDouble = false;
537
+ else {
538
+ masked += " ";
539
+ dequoted += dequoteData(character);
540
+ previous = character;
541
+ continue;
542
+ }
543
+ masked += '"';
544
+ previous = character;
545
+ continue;
546
+ }
547
+ if (
548
+ character === "(" &&
549
+ (previous === "$" || previous === "<" || previous === ">" || state.parenStack.length > 0)
550
+ ) {
551
+ // Substitution opener, or any paren nested INSIDE one (e.g. `$((1))`,
552
+ // `$(f (x))`): both must be balanced before the substitution closes.
553
+ state.parenStack.push(previous === "$" || previous === "<" || previous === ">");
554
+ masked += character;
555
+ dequoted += character;
556
+ previous = character;
557
+ continue;
558
+ }
559
+ if (character === ")" && state.parenStack.length > 0) {
560
+ const wasSubstitutionOpener = state.parenStack.pop() === true;
561
+ masked += character;
562
+ dequoted += character;
563
+ // A substitution close binds to the surrounding word (`x=$(true)#lit`);
564
+ // a nested subshell close is an operator, so `#` after it comments.
565
+ previous = wasSubstitutionOpener ? "x" : ")";
566
+ continue;
567
+ }
568
+ if (
569
+ character === "#" &&
570
+ (previous === "" || previous === " " || previous === "\t" || ";|&()".includes(previous))
571
+ ) {
572
+ // Comment: blank the remainder without evaluating quotes inside it.
573
+ masked += " ".repeat(line.length - masked.length);
574
+ return { masked, dequoted, continued: false, ansiCEscape };
575
+ }
576
+ if (character === "'") {
577
+ state.inSingle = true;
578
+ masked += "'";
579
+ // ANSI-C quoting `$'…'`: bash strips the `$` during quote removal, and
580
+ // escapes inside decode at runtime.
581
+ if (dequoted.endsWith("$")) {
582
+ dequoted = dequoted.slice(0, -1);
583
+ state.inAnsiC = true;
584
+ }
585
+ previous = character;
586
+ continue;
587
+ }
588
+ if (character === '"') {
589
+ state.inDouble = true;
590
+ masked += '"';
591
+ // `$"…"` locale-translated strings undergo the same quote removal as
592
+ // ANSI-C `$'…'`: bash drops the `$`, so `$"eval"` dequotes to `eval`.
593
+ if (dequoted.endsWith("$")) dequoted = dequoted.slice(0, -1);
594
+ previous = character;
595
+ continue;
596
+ }
597
+ masked += character;
598
+ dequoted += character;
599
+ previous = character;
600
+ }
601
+ return { masked, dequoted, continued: escaped, ansiCEscape };
602
+ }
603
+
604
+ const DATA_CONSUMER_NAMES = new Set(HEREDOC_DATA_CONSUMERS);
605
+ /**
606
+ * Command-position words that force the fail-closed pass. `eval`/`source`/`.`
607
+ * can install a shadow from data the syntax view has blanked (quoted strings,
608
+ * /dev/stdin). `case` introduces pattern `)` terminators that desync the
609
+ * substitution-depth scanner (`$(case x in x) …;; esac)`), making comment
610
+ * detection unreliable — masking is disabled rather than guessing.
611
+ */
612
+ const SHELL_EVALUATED_COMMANDS = new Set(["eval", "source", ".", "case"]);
613
+
614
+ /** Wrappers whose next word is still the effective command (`builtin eval …`, `command cat …`). */
615
+ const SHELL_COMMAND_WRAPPER_WORDS = new Set(["sudo", "builtin", "command", "exec", "nohup", "nice"]);
616
+
617
+ /** Reserved words that introduce a nested command position (`if eval …`, `while eval …`). */
618
+ const SHELL_RESERVED_PREFIX_WORDS = new Set([
619
+ "if",
620
+ "then",
621
+ "else",
622
+ "elif",
623
+ "while",
624
+ "until",
625
+ "do",
626
+ "time",
627
+ "coproc",
628
+ "{",
629
+ "!",
630
+ ]);
631
+
632
+ /** A redirection token (`</dev/null`, `2>err`, `>out`) — not a command word. */
633
+ const SHELL_REDIRECTION_TOKEN_RE = /^\d*[<>]/;
634
+
635
+ /** Command-position words of one command-list segment: the first real word plus words after reserved prefixes. */
636
+ function commandPositionWords(segment: string): string[] {
637
+ const words: string[] = [];
638
+ let expectCommand = true;
639
+ let afterWrapper = false;
640
+ let afterBareRedirection = false;
641
+ for (const word of segment.trim().split(/\s+/)) {
642
+ if (!word) continue;
643
+ // A bare redirection operator (`<`, `2>`) consumes the NEXT word as its target.
644
+ if (afterBareRedirection) {
645
+ afterBareRedirection = false;
646
+ continue;
647
+ }
648
+ if (expectCommand) {
649
+ if (/^\w+=/.test(word)) continue;
650
+ // Leading redirections precede the command word (`</dev/null eval …`).
651
+ // A bare operator (`<`, `2>`) additionally consumes its separated target.
652
+ if (SHELL_REDIRECTION_TOKEN_RE.test(word)) {
653
+ if (/^\d*[<>]+$/.test(word)) afterBareRedirection = true;
654
+ continue;
655
+ }
656
+ if (SHELL_COMMAND_WRAPPER_WORDS.has(word.toLowerCase())) {
657
+ // The wrapper's effective command follows, possibly after options/`--`.
658
+ afterWrapper = true;
659
+ continue;
660
+ }
661
+ if (afterWrapper && (word === "--" || word.startsWith("-"))) {
662
+ // `command -v`/`-V` only DESCRIBES its operand — the rest is data.
663
+ if (/^-[a-zA-Z]*[vV]/.test(word)) return words;
664
+ // Other wrapper options (`command -p`, `builtin --`) precede the real command.
665
+ continue;
666
+ }
667
+ afterWrapper = false;
668
+ if (SHELL_RESERVED_PREFIX_WORDS.has(word.toLowerCase())) {
669
+ // Reserved word keeps the NEXT word in command position.
670
+ continue;
671
+ }
672
+ words.push(word.split("/").pop()?.toLowerCase() ?? "");
673
+ expectCommand = false;
674
+ }
675
+ // Argument position: only real list operators (handled by the segment
676
+ // split) restore command position — reserved-word TEXT here is data.
677
+ }
678
+ return words;
679
+ }
680
+
681
+ /** Command-list segments of a dequoted view (split at `;`, `|`, `&`, `(`, backtick, newline). */
682
+ function commandListSegments(dequotedView: string): string[] {
683
+ return dequotedView.split(/[;|&(`\n]/);
684
+ }
685
+
686
+ /**
687
+ * True when any command-position word of the dequoted syntax view is
688
+ * eval/source/`.` — including nested positions after reserved words
689
+ * (`if eval …`). `echo eval` and `find .` never match because their
690
+ * tokens sit in argument position.
691
+ */
692
+ function hasShellEvaluatedCommand(dequotedView: string): boolean {
693
+ return commandListSegments(dequotedView).some(segment =>
694
+ commandPositionWords(segment).some(word => SHELL_EVALUATED_COMMANDS.has(word)),
695
+ );
696
+ }
697
+
698
+ /**
699
+ * True when live syntax actually DECLARES a shadow of an allowlisted
700
+ * data-consumer name: `name()` at a command-list start, `function name`,
701
+ * or `alias name=…` — each in command position, so `echo function cat`
702
+ * (argument position) never matches.
703
+ */
704
+ function hasDataConsumerShadow(dequotedView: string): boolean {
705
+ for (const segment of commandListSegments(dequotedView)) {
706
+ const words = commandPositionWords(segment);
707
+ for (let index = 0; index < words.length; index++) {
708
+ const word = words[index] ?? "";
709
+ if (word === "function") {
710
+ // commandPositionWords stops after the first non-reserved word, so
711
+ // re-inspect the raw segment for the name following `function`.
712
+ const rawWords = segment.trim().split(/\s+/).filter(Boolean);
713
+ const at = rawWords.findIndex(raw => raw.toLowerCase() === "function");
714
+ const name = rawWords[at + 1]?.split("/").pop()?.toLowerCase() ?? "";
715
+ if (DATA_CONSUMER_NAMES.has(name)) return true;
716
+ }
717
+ if (word === "alias") {
718
+ const rawWords = segment.trim().split(/\s+/).filter(Boolean);
719
+ if (rawWords.some(raw => DATA_CONSUMER_NAMES.has(raw.split("=")[0]?.toLowerCase() ?? ""))) return true;
720
+ }
721
+ }
722
+ }
723
+ // `name()` / `name ()` in command position — allow reserved-word prefixes
724
+ // (`then cat() { … }`) before the name (POSIX function definition).
725
+ const nameGroup = [...DATA_CONSUMER_NAMES].join("|");
726
+ const reservedGroup = [...SHELL_RESERVED_PREFIX_WORDS].filter(word => /^\w+$/.test(word)).join("|");
727
+ return new RegExp(
728
+ `(?:^|[;|&{(\`\\n])\\s*(?:(?:${reservedGroup}|\\{|!)\\s+)*(?:${nameGroup})\\s*\\(\\s*\\)`,
729
+ "i",
730
+ ).test(dequotedView);
731
+ }
732
+
733
+ /** First command word of a pipeline-segment slice of a quote-masked opener line. */
734
+ function firstCommandWord(segment: string): string {
735
+ return commandPositionWords(segment)[0] ?? "";
736
+ }
737
+
738
+ /**
739
+ * Classify the command consuming the heredoc at offset `at` of the comment-cut,
740
+ * quote-masked opener line — including every DOWNSTREAM pipe stage, because
741
+ * `cat <<'EOF' | bash` hands the body to the interpreter even though `cat` is
742
+ * inert. Only a pipeline whose every stage is an explicitly inert data consumer
743
+ * qualifies for body masking; any stdin applier stage fails closed; anything
744
+ * else (interpreters, awk, unknown binaries) keeps the body live.
745
+ */
746
+ function heredocConsumerKind(syntaxLine: string, at: number): "data" | "mutating" | "other" {
747
+ let start = 0;
748
+ for (let index = at - 1; index >= 0; index--) {
749
+ const character = syntaxLine[index] ?? "";
750
+ if (";|&(`".includes(character) || character === "\n") {
751
+ start = index + 1;
752
+ break;
753
+ }
754
+ }
755
+ // The heredoc's own simple command plus every downstream `|` stage until the
756
+ // command list ends (`;`, `&&`, `||`, `&`, backtick, or subshell close).
757
+ const segments: string[] = [syntaxLine.slice(start, at)];
758
+ let cursor = at;
759
+ while (cursor < syntaxLine.length) {
760
+ const character = syntaxLine[cursor] ?? "";
761
+ if (character === ";" || character === "&" || character === "`" || character === ")") break;
762
+ if (character === "|") {
763
+ if (syntaxLine[cursor + 1] === "|") break;
764
+ let stageEnd = cursor + 1;
765
+ while (stageEnd < syntaxLine.length && !";|&`)".includes(syntaxLine[stageEnd] ?? "")) stageEnd++;
766
+ segments.push(syntaxLine.slice(cursor + 1, stageEnd));
767
+ cursor = stageEnd;
768
+ continue;
769
+ }
770
+ cursor++;
771
+ }
772
+ let kind: "data" | "mutating" | "other" = "data";
773
+ for (const segment of segments) {
774
+ const base = firstCommandWord(segment);
775
+ if (HEREDOC_MUTATING_CONSUMERS.has(base)) return "mutating";
776
+ if (!HEREDOC_DATA_CONSUMERS.has(base)) kind = "other";
777
+ }
778
+ return kind;
779
+ }
780
+
781
+ /**
782
+ * Blank out heredoc BODY lines so document payloads (markdown specs, plans,
783
+ * fixtures) piped to an explicitly inert data consumer (`cat <<'EOF' >
784
+ * /tmp/spec.md`) are not misread as shell commands: body text like `a > b` or
785
+ * stray apostrophes must not register redirection targets or unbalance the
786
+ * quote masker. Bodies stay UNMASKED (scanned as today) for every other
787
+ * consumer — interpreters, awk, unknown binaries — because there the body may
788
+ * be live code. Stdin appliers (patch/ed/ex/sqlite3) flag `mutatingConsumer`
789
+ * and the caller fails closed. Unquoted-delimiter bodies still expand
790
+ * `$(…)`/backticks in real shells, so masked ones flag `opaqueExpansion`. A
791
+ * `<<` inside a comment or a quoted span (including a quote opened on a
792
+ * previous line) is not an opener; a line-continued opener, a shadowed
793
+ * data-consumer name (`cat() { … }`), and an unterminated heredoc all keep
794
+ * their bodies live so the scanner stays fail-closed rather than blind.
795
+ */
796
+ function maskHeredocBodies(command: string): HeredocMaskResult {
797
+ if (!command.includes("<<")) return { masked: command, opaqueExpansion: false, mutatingConsumer: false };
798
+ const first = maskHeredocBodiesPass(command, false);
799
+ // Fail closed on live-syntax constructs that can install a shadow the syntax
800
+ // view cannot see: a function/alias definition of an allowlisted consumer
801
+ // name, or a command-position eval/source/`.` whose argument is blanked
802
+ // quoted data. Both run against the DEQUOTED view so quoting tricks
803
+ // (`e\val`, `'ev'al`) cannot hide the word, while heredoc bodies and
804
+ // comments stay excluded — a spec that merely DOCUMENTS `cat() { … }` or
805
+ // mentions "eval" in prose stays inert.
806
+ if (first.ansiCEscape || hasDataConsumerShadow(first.dequotedView) || hasShellEvaluatedCommand(first.dequotedView)) {
807
+ return maskHeredocBodiesPass(command, true).result;
808
+ }
809
+ return first.result;
810
+ }
811
+
812
+ function maskHeredocBodiesPass(
813
+ command: string,
814
+ shadowed: boolean,
815
+ ): { result: HeredocMaskResult; dequotedView: string; ansiCEscape: boolean } {
816
+ const lines = command.split("\n");
817
+ const out: string[] = [];
818
+ const dequotedLines: string[] = [];
819
+ const state: ShellQuoteState = { inSingle: false, inDouble: false, inAnsiC: false, parenStack: [] };
820
+ let previousContinued = false;
821
+ let opaqueExpansion = false;
822
+ let mutatingConsumer = false;
823
+ let sawAnsiCEscape = false;
824
+ for (let index = 0; index < lines.length; index++) {
825
+ const line = lines[index] ?? "";
826
+ out.push(line);
827
+ // Cross-line quote masking decides whether a `<<` is real syntax or inert
828
+ // data — even when the enclosing quote opened on a previous line.
829
+ const { masked, dequoted, continued, ansiCEscape } = maskLineForSyntax(line, state);
830
+ if (ansiCEscape) sawAnsiCEscape = true;
831
+ const syntax = masked;
832
+ const isContinuationLine = previousContinued;
833
+ // Bash removes a backslash-newline pair entirely: fold a continuation into
834
+ // the previous dequoted line so split command words (`e\` + `val`)
835
+ // reassemble for command/shadow detection.
836
+ if (isContinuationLine && dequotedLines.length > 0) {
837
+ let previousDequoted = dequotedLines[dequotedLines.length - 1] ?? "";
838
+ // A continuation can also split ANSI-C/locale quoting (`$\` + `'eval'` or
839
+ // `$\` + `"eval"`): the rejoined `$'`/`$"` still drops its `$` during
840
+ // quote removal.
841
+ if (previousDequoted.endsWith("$") && (line.startsWith("'") || line.startsWith('"'))) {
842
+ previousDequoted = previousDequoted.slice(0, -1);
843
+ // The masker already opened this quote as ordinary (it ran before the
844
+ // fold), so re-check the rejoined ANSI-C span for runtime-decoding
845
+ // escapes (`$\` + `'\145val'`) it could not have flagged.
846
+ if (line.startsWith("'") && /^'[^']*\\/.test(line)) sawAnsiCEscape = true;
847
+ }
848
+ dequotedLines[dequotedLines.length - 1] = previousDequoted + dequoted;
849
+ } else {
850
+ dequotedLines.push(dequoted);
851
+ }
852
+ previousContinued = continued;
853
+ for (const match of line.matchAll(BASH_HEREDOC_OPEN_RE)) {
854
+ const at = match.index ?? 0;
855
+ if (syntax.slice(at, at + 2) !== "<<") continue;
856
+ const delimiter = match[2] ?? match[3] ?? match[5] ?? "";
857
+ if (!delimiter) continue;
858
+ let kind = heredocConsumerKind(syntax, at);
859
+ // A continued/continuation opener line hides part of the pipeline
860
+ // (`cat <<'EOF' \` / `| bash`); a shadowed consumer name is untrustworthy.
861
+ // Both downgrade masking eligibility, never block eligibility.
862
+ if (kind === "data" && (shadowed || continued || isContinuationLine)) kind = "other";
863
+ if (kind === "mutating") mutatingConsumer = true;
864
+ const quoted = match[2] !== undefined || match[3] !== undefined || match[4] !== undefined;
865
+ const stripTabs = match[1] === "-";
866
+ let end = -1;
867
+ for (let scan = index + 1; scan < lines.length; scan++) {
868
+ const candidate = stripTabs ? (lines[scan] ?? "").replace(/^\t+/, "") : (lines[scan] ?? "");
869
+ if (candidate === delimiter) {
870
+ end = scan;
871
+ break;
872
+ }
873
+ }
874
+ if (end === -1) {
875
+ return {
876
+ result: { masked: command, opaqueExpansion, mutatingConsumer },
877
+ dequotedView: dequotedLines.join("\n"),
878
+ ansiCEscape: sawAnsiCEscape,
879
+ };
880
+ }
881
+ const maskBody = kind === "data" || kind === "mutating";
882
+ for (let body = index + 1; body < end; body++) {
883
+ const bodyLine = lines[body] ?? "";
884
+ if (maskBody && !quoted && /\$\(|`/.test(bodyLine)) opaqueExpansion = true;
885
+ out.push(maskBody ? "" : bodyLine);
886
+ }
887
+ out.push(lines[end] ?? "");
888
+ index = end;
889
+ // Heredoc bodies are data: they never affect the shell quote state or
890
+ // the syntax view used for shadow detection.
891
+ previousContinued = false;
892
+ }
893
+ }
894
+ return {
895
+ result: { masked: out.join("\n"), opaqueExpansion, mutatingConsumer },
896
+ dequotedView: dequotedLines.join("\n"),
897
+ ansiCEscape: sawAnsiCEscape,
898
+ };
899
+ }
900
+
901
+ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
390
902
  const record = getRecord(args);
391
903
  const command = safeString(record?.command);
392
904
  const targets: ExtractedTargets = { paths: [], unknown: false };
@@ -394,19 +906,51 @@ function extractBashTargets(args: unknown): ExtractedTargets {
394
906
  targets.unknown = true;
395
907
  return targets;
396
908
  }
397
- if (BASH_OPAQUE_INTERPRETER_WRITE_RE.test(command) || BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(command)) {
909
+ // A literal `sh|bash|zsh -c '<script>'` payload is a real command list. Its
910
+ // mutations never match the top-level patterns, so recurse into it; anything
911
+ // deeper than a bounded depth is not statically analyzable and fails closed.
912
+ for (const match of command.matchAll(BASH_NESTED_SHELL_RE)) {
913
+ if (depth >= 2) {
914
+ targets.explicitMutation = true;
915
+ targets.unknown = true;
916
+ break;
917
+ }
918
+ const nested = extractBashTargets({ command: match[2] ?? match[4] ?? "" }, depth + 1);
919
+ for (const nestedPath of nested.paths) addPath(targets, nestedPath);
920
+ if (nested.unknown) targets.unknown = true;
921
+ if (nested.explicitMutation) targets.explicitMutation = true;
922
+ }
923
+ // Heredoc bodies fed to data consumers are inert document payloads; mask them
924
+ // so spec/plan text cannot fake redirections. Script-consumer bodies survive
925
+ // the mask and are still scanned as live code below.
926
+ const heredoc = maskHeredocBodies(command);
927
+ if (heredoc.opaqueExpansion || heredoc.mutatingConsumer) {
928
+ targets.explicitMutation = true;
929
+ targets.unknown = true;
930
+ }
931
+ // Nested scripts were read from the raw text above; every scanner below works
932
+ // on the masked view so quoted argument data cannot look like a redirection.
933
+ const scanned = maskSingleQuotedSpans(heredoc.masked);
934
+ if (
935
+ BASH_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked) ||
936
+ BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)
937
+ ) {
938
+ targets.explicitMutation = true;
398
939
  targets.unknown = true;
399
940
  }
400
941
  // `exec 1<>file` rebinds a descriptor, so a later `>/dev/null` may not reach the device at all.
401
- if (BASH_EXEC_REDIRECT_RE.test(command)) {
942
+ if (BASH_EXEC_REDIRECT_RE.test(scanned)) {
943
+ targets.explicitMutation = true;
402
944
  targets.unknown = true;
403
945
  }
404
- for (const match of command.matchAll(BASH_EXTENDED_WRITE_RE)) {
946
+ for (const match of scanned.matchAll(BASH_EXTENDED_WRITE_RE)) {
947
+ targets.explicitMutation = true;
405
948
  const cleaned = cleanShellWord(match[1] ?? "");
406
949
  if (!cleaned) targets.unknown = true;
407
950
  else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
408
951
  }
409
- for (const match of command.matchAll(BASH_DD_OUTPUT_RE)) {
952
+ for (const match of scanned.matchAll(BASH_DD_OUTPUT_RE)) {
953
+ targets.explicitMutation = true;
410
954
  const parts = shellWords(match[1] ?? "").map(cleanShellWord);
411
955
  // Every `of=` counts: GNU dd honors the last one, so `of=/dev/null of=real.ts` writes real.ts.
412
956
  const outputs = parts.filter(part => part.startsWith("of="));
@@ -417,15 +961,17 @@ function extractBashTargets(args: unknown): ExtractedTargets {
417
961
  else if (!isDeviceSinkPath(outputPath)) addPath(targets, outputPath);
418
962
  }
419
963
  }
420
- for (const match of command.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
964
+ for (const match of scanned.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
421
965
  const parts = shellWords(match[1] ?? "").map(cleanShellWord);
422
966
  const hasInPlaceFlag = parts.some(part => /^-.*i/.test(part));
423
967
  if (!hasInPlaceFlag) continue;
968
+ targets.explicitMutation = true;
424
969
  const target = [...parts].reverse().find(part => part && !part.startsWith("-"));
425
970
  if (target) addPath(targets, target);
426
971
  else targets.unknown = true;
427
972
  }
428
- for (const match of command.matchAll(BASH_MUTATION_COMMAND_RE)) {
973
+ for (const match of scanned.matchAll(BASH_MUTATION_COMMAND_RE)) {
974
+ targets.explicitMutation = true;
429
975
  const redirected = match[2]?.trim();
430
976
  if (redirected) {
431
977
  const cleaned = cleanShellWord(redirected);
@@ -447,6 +993,9 @@ function extractBashTargets(args: unknown): ExtractedTargets {
447
993
  for (const part of targetParts) {
448
994
  const cleaned = cleanShellWord(part);
449
995
  if (!cleaned || cleaned.startsWith("-")) continue;
996
+ // Redirection/heredoc operator words (`>/dev/null`, `<<'DOC'`, `2>&1`) are
997
+ // not argument paths; their targets are captured by the redirect scanners.
998
+ if (/^\d*[<>]/.test(cleaned)) continue;
450
999
  addPath(targets, cleaned);
451
1000
  }
452
1001
  }
@@ -666,7 +1215,10 @@ export async function getWorkflowMutationDecision(
666
1215
  }
667
1216
  if (input.forceOverride) return { blocked: false, targets: [] };
668
1217
  const message = planningPhaseBlockMessage(planning.skill);
669
- if (targets.unknown) {
1218
+ // Bash during a planning phase blocks only when a filesystem mutation was
1219
+ // actually recognized. Inspection, reads, and CLI invocations the scanner does
1220
+ // not model stay usable; other tools keep failing closed on unknown targets.
1221
+ if (targets.unknown && (input.tool.name !== "bash" || targets.explicitMutation)) {
670
1222
  return {
671
1223
  blocked: true,
672
1224
  message,