@gajae-code/coding-agent 0.16.7 → 0.17.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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -53,14 +53,43 @@ const BASH_EXEC_REDIRECT_RE = /(?:^|[;&|\n])\s*(?:sudo\s+)?exec\b[^;&|\n]*[<>]/i
53
53
  const ARCHIVE_OR_SQLITE_BASE_RE = /^(.+?\.(?:tar\.gz|sqlite3|sqlite|db3|zip|tgz|tar|db))(?:$|:)/i;
54
54
  const INTERNAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
55
55
  const VIM_FILE_SWITCH_RE = /^\s*:(?:e|e!|edit|edit!)(?:\s+([^<\r\n]+))?(?:<CR>|\r|\n|$)/i;
56
- const BASH_MUTATION_COMMAND_RE =
57
- /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(?:tee|touch|rm|mkdir|cp|mv|install|truncate)\b([^;&|\n]*)|(?:^|[^<>])(?:>>?|\d>>?)\s*([^\s;&|]+)/gi;
58
- const BASH_IN_PLACE_MUTATION_COMMAND_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:sed|perl)\b([^;&|\n]*)/gi;
56
+ /**
57
+ * Executable wrappers that run their operand as a command (`command rm x`,
58
+ * `env rm x`, `exec rm x`). Bash resolves through them, so the mutator that
59
+ * follows is a real write. Left unmodeled they produced no target and no
60
+ * unknown flag, letting a mutation cross the planning boundary. `env` may
61
+ * carry `NAME=value` assignments before the wrapped command.
62
+ */
63
+ const BASH_COMMAND_WRAPPER_SOURCE =
64
+ "(?:(?:command|builtin|exec|nohup|nice|ionice|stdbuf|setsid|time|env)\\s+(?:-[^\\s]+\\s+(?:\\d+\\s+)?|\\w+=[^\\s]*\\s+)*)*";
65
+ /**
66
+ * Shell keywords that open a NEW command position without a `;`/`|`/`&`
67
+ * separator in front of the command word (`then rm x`, `do rm x`, `else rm x`).
68
+ * Normalizing them to separators lets the existing mutation regexes see the
69
+ * command position instead of treating `then rm` as one opaque word.
70
+ */
71
+ const BASH_COMMAND_POSITION_KEYWORD_RE = /(?:^|[;&|(\n{}])\s*(?:then|else|elif|do|in|\{|!)(?=\s)/gi;
72
+ const BASH_MUTATION_COMMAND_RE = new RegExp(
73
+ `(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:tee|touch|rm|mkdir|cp|mv|install|truncate)\\b([^;&|()\\n]*)|(?:^|[^<>])(?:>>?|\\d>>?)\\s*([^\\s;&|]+)`,
74
+ "gi",
75
+ );
76
+ /** Command-word extractor; must accept the same prefixes as `BASH_MUTATION_COMMAND_RE`. */
77
+ const BASH_MUTATION_COMMAND_NAME_RE = new RegExp(
78
+ `(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(tee|touch|rm|mkdir|cp|mv|install|truncate)\\b`,
79
+ "i",
80
+ );
81
+ const BASH_IN_PLACE_MUTATION_COMMAND_RE = new RegExp(
82
+ `(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:sed|perl)\\b([^;&|()\\n]*)`,
83
+ "gi",
84
+ );
59
85
  const BASH_OPAQUE_INTERPRETER_WRITE_RE =
60
86
  /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:-c|-e)\b[^;&|\n]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
61
87
  const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE =
62
88
  /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:<<[-]?\s*['"]?\w+['"]?)[\s\S]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
63
- const BASH_DD_OUTPUT_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?dd\b([^;&|\n]*)/gi;
89
+ const BASH_DD_OUTPUT_RE = new RegExp(
90
+ `(?:^|[;&|\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|]*\\/)?dd\\b([^;&|\\n]*)`,
91
+ "gi",
92
+ );
64
93
  /** Literal `sh|bash|zsh -c '<script>'` payloads whose nested script must also be scanned. */
65
94
  const BASH_NESTED_SHELL_RE =
66
95
  /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(?:ba|z|da)?sh\s+(?:-[A-Za-z]+\s+)*-[A-Za-z]*c\s+(?:(')([^']*)'|(")([^"]*)")/g;
@@ -428,26 +457,344 @@ function cleanShellWord(value: string): string {
428
457
  }
429
458
 
430
459
  /**
431
- * Blank out single-quoted spans so shell metacharacters inside a literal
432
- * argument value (e.g. `--value 'uses `x`; a > b'`) are not misread as
433
- * redirections or separators. Single quotes suppress every expansion in POSIX
434
- * shells, so their contents are always inert data. Double-quoted spans are left
435
- * intact because they still expand `$(...)` and backticks.
460
+ * Blank out quoted spans so shell metacharacters inside a literal argument
461
+ * value (e.g. `--evidence "p99 500ms -> 120ms"`) are not misread as
462
+ * redirections or separators.
463
+ *
464
+ * Both quote styles are masked because in POSIX shells neither one can contain
465
+ * a redirection or a command separator: `>`, `|`, `;`, and `&` are always
466
+ * literal data inside quotes. Masking only single quotes made every `>` in
467
+ * double-quoted PROSE look like a real redirect, so ordinary evidence text
468
+ * (`"throughput > baseline"`, `"500ms -> 120ms"`) was scanned as a file write
469
+ * and blocked.
470
+ *
471
+ * Double quotes differ from single quotes in one way that matters here: they
472
+ * still expand `$(...)` and backticks, and that substitution IS live code. So
473
+ * the literal text of a double-quoted span is masked while any substitution
474
+ * inside it is preserved verbatim for the scanners that follow.
475
+ *
476
+ * One span is never prose: a quoted word that is the OPERAND of a redirection
477
+ * or of `of=` (`> "/dev/null"`, `>" src.ts"`, `of=" /dev/null"`) names a file.
478
+ * Masking it would erase the very path the sink and bypass checks must read, so
479
+ * operand spans are preserved verbatim. The returned dequoted view performs
480
+ * shell quote removal for executable-word/argument extraction while replacing
481
+ * quoted separators with inert placeholders.
436
482
  */
437
- function maskSingleQuotedSpans(command: string): string {
483
+ /**
484
+ * Does the double quote at `quoteIndex` open a redirection / `of=` operand
485
+ * rather than a prose argument? Only whitespace may sit between the operator
486
+ * and the quote, so `> "x"`, `>"x"`, `2>>"x"`, `>|"x"`, and `of="x"` all
487
+ * qualify, while `--evidence "a > b"` does not.
488
+ */
489
+ function opensRedirectionOperand(command: string, quoteIndex: number): boolean {
490
+ let cursor = quoteIndex - 1;
491
+ while (cursor >= 0 && (command[cursor] === " " || command[cursor] === "\t")) cursor -= 1;
492
+ if (cursor < 0) return false;
493
+ const previous = command[cursor];
494
+ if (previous === ">" || previous === "<" || previous === "|" || previous === "&") {
495
+ // `|`/`&` qualify only as part of a redirection operator (`>|`, `>&`).
496
+ if (previous === "|" || previous === "&") {
497
+ const before = command[cursor - 1];
498
+ return before === ">" || before === "<";
499
+ }
500
+ return true;
501
+ }
502
+ // `of="..."` (dd) names an output file the same way a redirection does.
503
+ return previous === "=" && /(?:^|[\s;&|])of=$/i.test(command.slice(0, cursor + 1));
504
+ }
505
+
506
+ interface MaskedQuotedSpans {
507
+ masked: string;
508
+ /**
509
+ * Quote removal preserves executable word characters while neutralizing
510
+ * quoted separators/metacharacters. This lets mutation extraction recognize
511
+ * `r"m"` and `rm "src/product.ts"` without re-exposing prose as syntax.
512
+ */
513
+ dequoted: string;
514
+ /** True when live syntax cannot be balanced by this lightweight parser. */
515
+ unmodeled: boolean;
516
+ }
517
+
518
+ type QuotedSpanFrame = {
519
+ kind: "root" | "paren" | "backtick";
520
+ quote: "none" | "single" | "double";
521
+ parenDepth: number;
522
+ quotedOperand: boolean;
523
+ /** An unquoted `#` comment consumes the rest of this live frame line. */
524
+ comment: boolean;
525
+ /** Last live byte in this frame, used for comment-boundary recognition. */
526
+ previous: string;
527
+ /** True while the frame is at a command-list boundary before its first word. */
528
+ expectCommand: boolean;
529
+ };
530
+
531
+ function maskQuotedSpans(command: string): MaskedQuotedSpans {
438
532
  let masked = "";
439
- let inSingle = false;
440
- for (const character of command) {
533
+ let dequoted = "";
534
+ let unmodeledSyntax = false;
535
+ const frames: QuotedSpanFrame[] = [
536
+ {
537
+ kind: "root",
538
+ quote: "none",
539
+ parenDepth: 0,
540
+ quotedOperand: false,
541
+ comment: false,
542
+ previous: "",
543
+ expectCommand: true,
544
+ },
545
+ ];
546
+ const dequoteData = (character: string): string => (/[\w./-]/.test(character) ? character : "_");
547
+ const appendLive = (frame: QuotedSpanFrame, character: string): void => {
548
+ masked += character;
549
+ dequoted += character;
550
+ frame.previous = character;
551
+ if (character === "\n" || ";|&".includes(character)) frame.expectCommand = true;
552
+ else if (character === "(" && frame.kind === "paren") frame.expectCommand = true;
553
+ else if (!/\s/.test(character)) frame.expectCommand = false;
554
+ };
555
+ const appendQuoted = (character: string, operand: boolean): void => {
556
+ if (operand) masked += character;
557
+ else masked += character === "\n" ? "\n" : " ";
558
+ dequoted += dequoteData(character);
559
+ };
560
+
561
+ for (let index = 0; index < command.length; index += 1) {
562
+ const frame = frames.at(-1) as QuotedSpanFrame;
563
+ const character = command[index] as string;
564
+ const next = command[index + 1];
565
+
566
+ // A comment inside a live substitution must not let a literal `)` close
567
+ // that substitution. Preserve the newline as syntax, but mask all bytes
568
+ // before it as inert comment data.
569
+ if (frame.comment) {
570
+ if (character === "\n") {
571
+ frame.comment = false;
572
+ appendLive(frame, character);
573
+ } else {
574
+ appendQuoted(character, false);
575
+ }
576
+ continue;
577
+ }
578
+
579
+ // Backslash escapes are quote syntax, not command boundaries. In the
580
+ // dequoted view the escaped byte survives only as data; in the masked
581
+ // view preserve the historical bytes so redirection extraction remains
582
+ // conservative.
583
+ if (character === "\\" && frame.quote !== "single") {
584
+ if (next === undefined) {
585
+ appendQuoted(character, frame.quote !== "none" && frame.quotedOperand);
586
+ continue;
587
+ }
588
+ if (next === "\n") {
589
+ index += 1;
590
+ continue;
591
+ }
592
+ if (frame.quote === "none" || frame.quotedOperand) masked += character + next;
593
+ else masked += " ";
594
+ dequoted += dequoteData(next);
595
+ frame.previous = "_";
596
+ if (frame.quote === "none") frame.expectCommand = false;
597
+ index += 1;
598
+ continue;
599
+ }
600
+
601
+ if (frame.quote === "single") {
602
+ if (character === "'") {
603
+ masked += character;
604
+ frame.quote = "none";
605
+ frame.quotedOperand = false;
606
+ frame.previous = character;
607
+ } else {
608
+ appendQuoted(character, frame.quotedOperand);
609
+ }
610
+ continue;
611
+ }
612
+
613
+ if (frame.quote === "double") {
614
+ if (character === '"') {
615
+ masked += character;
616
+ frame.quote = "none";
617
+ frame.quotedOperand = false;
618
+ frame.previous = character;
619
+ continue;
620
+ }
621
+ // A substitution is live even when it appears inside a double-quoted
622
+ // argument. Push a separate parser frame so quotes/parentheses inside
623
+ // the substitution cannot close the surrounding `"` prematurely.
624
+ if (character === "$" && next === "(") {
625
+ masked += "$(";
626
+ dequoted += "$(";
627
+ frame.previous = "(";
628
+ frame.expectCommand = false;
629
+ frames.push({
630
+ kind: "paren",
631
+ quote: "none",
632
+ parenDepth: 0,
633
+ quotedOperand: false,
634
+ comment: false,
635
+ previous: "",
636
+ expectCommand: true,
637
+ });
638
+ index += 1;
639
+ continue;
640
+ }
641
+ if (character === "`") {
642
+ masked += character;
643
+ dequoted += character;
644
+ frame.previous = character;
645
+ frame.expectCommand = false;
646
+ frames.push({
647
+ kind: "backtick",
648
+ quote: "none",
649
+ parenDepth: 0,
650
+ quotedOperand: false,
651
+ comment: false,
652
+ previous: "",
653
+ expectCommand: true,
654
+ });
655
+ continue;
656
+ }
657
+ appendQuoted(character, frame.quotedOperand);
658
+ continue;
659
+ }
660
+
661
+ // A command-substitution frame closes only at its own unquoted `)`.
662
+ // Parentheses nested inside `$((…))`/`$( (… ) …)` are tracked separately.
663
+ if (frame.kind === "paren" && character === ")") {
664
+ if (frame.parenDepth > 0) {
665
+ frame.parenDepth -= 1;
666
+ appendLive(frame, character);
667
+ } else {
668
+ appendLive(frame, character);
669
+ frames.pop();
670
+ const parent = frames.at(-1);
671
+ if (parent) {
672
+ // A substitution close belongs to the current shell word.
673
+ parent.previous = "_";
674
+ parent.expectCommand = false;
675
+ }
676
+ }
677
+ continue;
678
+ }
679
+ if (frame.kind === "backtick" && character === "`") {
680
+ appendLive(frame, character);
681
+ frames.pop();
682
+ const parent = frames.at(-1);
683
+ if (parent) {
684
+ parent.previous = character;
685
+ parent.expectCommand = false;
686
+ }
687
+ continue;
688
+ }
689
+
690
+ if (frame.quote === "none" && character === "#" && (frame.previous === "" || /[\s;|&()]/.test(frame.previous))) {
691
+ frame.comment = true;
692
+ appendQuoted(character, false);
693
+ continue;
694
+ }
695
+ if (frame.quote === "none" && frame.expectCommand) {
696
+ const ahead = command.slice(index, index + 9);
697
+ if (
698
+ frame.kind !== "root" &&
699
+ /^(?:case|if|for|while|until|select|function|time|!|\{)(?:[\s;|&()]|$)/.test(ahead)
700
+ ) {
701
+ // Compound commands have command positions the mutation regexes do
702
+ // not model; `case` also uses `)` as a pattern terminator. Balanced
703
+ // parentheses alone cannot authorize these live substitution bodies.
704
+ unmodeledSyntax = true;
705
+ } else if (frame.kind === "root" && /^(?:case|select|function)(?:[\s;|&()]|$)/.test(ahead)) {
706
+ // At the root the other compounds are recovered by command-position
707
+ // keyword normalization below, but `case` overloads `)` as a pattern
708
+ // terminator and `select`/`function` introduce command positions the
709
+ // regexes cannot attribute. Fail closed instead of scanning blind.
710
+ unmodeledSyntax = true;
711
+ }
712
+ }
441
713
  if (character === "'") {
442
- inSingle = !inSingle;
443
714
  masked += character;
715
+ frame.quote = "single";
716
+ // A single-quoted redirection/`of=` operand names a file exactly like a
717
+ // double-quoted one (`> '/dev/null'`, `of='/dev/null'`). Masking it would
718
+ // erase the path the sink and bypass checks must read.
719
+ frame.quotedOperand = opensRedirectionOperand(command, index);
720
+ frame.previous = character;
721
+ frame.expectCommand = false;
722
+ continue;
723
+ }
724
+ if (character === '"') {
725
+ masked += character;
726
+ frame.quote = "double";
727
+ frame.quotedOperand = opensRedirectionOperand(command, index);
728
+ frame.previous = character;
729
+ frame.expectCommand = false;
730
+ continue;
731
+ }
732
+ if (character === "$" && next === "(") {
733
+ masked += "$(";
734
+ dequoted += "$(";
735
+ frame.previous = "(";
736
+ frame.expectCommand = false;
737
+ frames.push({
738
+ kind: "paren",
739
+ quote: "none",
740
+ parenDepth: 0,
741
+ quotedOperand: false,
742
+ comment: false,
743
+ previous: "",
744
+ expectCommand: true,
745
+ });
746
+ index += 1;
444
747
  continue;
445
748
  }
446
- masked += inSingle && character !== "\n" ? " " : character;
749
+ // ANSI-C (`$'…'`) and locale-translated (`$"…"`) quoting: bash strips the
750
+ // `$` during quote removal, so `$'rm' src/product.ts` really executes `rm`.
751
+ // The dequoted view must drop the `$` too, otherwise the executable word
752
+ // keeps a leading `$` and misses the mutation regexes entirely. The masked
753
+ // (syntax) view keeps the byte so redirection offsets stay conservative.
754
+ if (character === "$" && (next === "'" || next === '"')) {
755
+ masked += character;
756
+ frame.quote = next === "'" ? "single" : "double";
757
+ masked += next;
758
+ frame.quotedOperand = opensRedirectionOperand(command, index);
759
+ frame.previous = next;
760
+ frame.expectCommand = false;
761
+ index += 1;
762
+ continue;
763
+ }
764
+ if (character === "`") {
765
+ appendLive(frame, character);
766
+ frames.push({
767
+ kind: "backtick",
768
+ quote: "none",
769
+ parenDepth: 0,
770
+ quotedOperand: false,
771
+ comment: false,
772
+ previous: "",
773
+ expectCommand: true,
774
+ });
775
+ continue;
776
+ }
777
+ if (frame.kind === "paren" && character === "(") frame.parenDepth += 1;
778
+ appendLive(frame, character);
779
+ }
780
+
781
+ // An unbalanced quote/substitution means the parse is unreliable; keep the
782
+ // original text so both passes remain fail-closed rather than going blind.
783
+ const root = frames[0];
784
+ const unbalanced = frames.length !== 1 || root?.quote !== "none";
785
+ if (unbalanced || unmodeledSyntax) {
786
+ return { masked: command, dequoted: command, unmodeled: true };
447
787
  }
448
- // An unbalanced quote means the parse is unreliable; keep the original text
449
- // so the scanner stays fail-closed rather than blind.
450
- return inSingle ? command : masked;
788
+ // `then rm x` / `do rm x` / `{ rm x` put a mutator in a command position that
789
+ // is not preceded by a `;`/`|`/`&` separator, so the mutation regexes would
790
+ // read `then rm` as a single opaque word. Replacing the keyword with a `;`
791
+ // keeps byte length (match offsets stay aligned) while exposing the position.
792
+ const exposeCommandPositions = (value: string): string =>
793
+ value.replace(BASH_COMMAND_POSITION_KEYWORD_RE, segment => {
794
+ const keyword = segment.trimEnd();
795
+ return `${keyword.slice(0, keyword.length - 1)};`.padEnd(segment.length, " ");
796
+ });
797
+ return { masked: exposeCommandPositions(masked), dequoted: exposeCommandPositions(dequoted), unmodeled: false };
451
798
  }
452
799
 
453
800
  function isDeviceSinkPath(value: string): boolean {
@@ -905,6 +1252,226 @@ function maskHeredocBodiesPass(
905
1252
  };
906
1253
  }
907
1254
 
1255
+ /**
1256
+ * A command-substitution body and whether its delimiters were balanced. An
1257
+ * unbalanced body is an unreliable parse and the caller fails closed on it.
1258
+ */
1259
+ interface SubstitutionBody {
1260
+ body: string;
1261
+ balanced: boolean;
1262
+ }
1263
+
1264
+ /** Keep parser-frame recursion bounded before extractBashTargets' semantic depth guard. */
1265
+ const SUBSTITUTION_PARSER_MAX_DEPTH = 64;
1266
+
1267
+ /**
1268
+ * Scan a `$(` body starting just after the opening paren. Quotes inside the
1269
+ * body are honoured: a `)` inside `'...'` or `"..."` is data, not the closer.
1270
+ * Nested substitutions are scanned with their own quote/comment frame, so a
1271
+ * `#` or quote inside `$(...)` nested in a double-quoted outer substitution
1272
+ * cannot desynchronise this frame. Returns the body and the index just past
1273
+ * its closing paren, or `end === command.length` with `balanced: false`.
1274
+ */
1275
+ function scanDollarParenBody(command: string, start: number, parserDepth = 0): SubstitutionBody & { end: number } {
1276
+ let depth = 1;
1277
+ let inSingle = false;
1278
+ let inDouble = false;
1279
+ let inComment = false;
1280
+ let previous = "";
1281
+ for (let cursor = start; cursor < command.length; cursor += 1) {
1282
+ const character = command[cursor];
1283
+ if (inComment) {
1284
+ if (character === "\n") {
1285
+ inComment = false;
1286
+ previous = character;
1287
+ }
1288
+ continue;
1289
+ }
1290
+ if (character === "\\" && !inSingle) {
1291
+ cursor += 1;
1292
+ previous = "x";
1293
+ continue;
1294
+ }
1295
+ if (character === "'" && !inDouble) {
1296
+ inSingle = !inSingle;
1297
+ previous = character;
1298
+ continue;
1299
+ }
1300
+ if (character === '"' && !inSingle) {
1301
+ inDouble = !inDouble;
1302
+ previous = character;
1303
+ continue;
1304
+ }
1305
+ if (inSingle) continue;
1306
+ if (character === "$" && command[cursor + 1] === "(") {
1307
+ if (parserDepth >= SUBSTITUTION_PARSER_MAX_DEPTH) {
1308
+ return { body: command.slice(start), balanced: false, end: command.length };
1309
+ }
1310
+ const nested = scanDollarParenBody(command, cursor + 2, parserDepth + 1);
1311
+ if (!nested.balanced) {
1312
+ return { body: command.slice(start), balanced: false, end: command.length };
1313
+ }
1314
+ cursor = nested.end - 1;
1315
+ previous = "x";
1316
+ continue;
1317
+ }
1318
+ if (character === "`") {
1319
+ if (parserDepth >= SUBSTITUTION_PARSER_MAX_DEPTH) {
1320
+ return { body: command.slice(start), balanced: false, end: command.length };
1321
+ }
1322
+ const nested = scanBacktickBody(command, cursor + 1, parserDepth + 1);
1323
+ if (!nested.balanced) {
1324
+ return { body: command.slice(start), balanced: false, end: command.length };
1325
+ }
1326
+ cursor = nested.end - 1;
1327
+ previous = "x";
1328
+ continue;
1329
+ }
1330
+ if (!inDouble && character === "#" && (previous === "" || /\s/.test(previous) || ";|&()".includes(previous))) {
1331
+ inComment = true;
1332
+ continue;
1333
+ }
1334
+ if (!inDouble && character === "(") depth += 1;
1335
+ else if (!inDouble && character === ")") {
1336
+ depth -= 1;
1337
+ if (depth === 0) {
1338
+ return { body: command.slice(start, cursor), balanced: true, end: cursor + 1 };
1339
+ }
1340
+ previous = ")";
1341
+ continue;
1342
+ }
1343
+ previous = character;
1344
+ }
1345
+ return { body: command.slice(start), balanced: false, end: command.length };
1346
+ }
1347
+
1348
+ /**
1349
+ * Scan a backtick command substitution with the same quote/comment rules as
1350
+ * `scanDollarParenBody`. Nested `$()` frames are skipped with their own
1351
+ * parser so backticks in those frames cannot close this one.
1352
+ */
1353
+ function scanBacktickBody(command: string, start: number, parserDepth = 0): SubstitutionBody & { end: number } {
1354
+ let inSingle = false;
1355
+ let inDouble = false;
1356
+ let inComment = false;
1357
+ let previous = "";
1358
+ for (let cursor = start; cursor < command.length; cursor += 1) {
1359
+ const character = command[cursor];
1360
+ if (inComment) {
1361
+ if (character === "\n") {
1362
+ inComment = false;
1363
+ previous = character;
1364
+ }
1365
+ continue;
1366
+ }
1367
+ if (character === "\\" && !inSingle) {
1368
+ cursor += 1;
1369
+ previous = "x";
1370
+ continue;
1371
+ }
1372
+ if (character === "'" && !inDouble) {
1373
+ inSingle = !inSingle;
1374
+ previous = character;
1375
+ continue;
1376
+ }
1377
+ if (character === '"' && !inSingle) {
1378
+ inDouble = !inDouble;
1379
+ previous = character;
1380
+ continue;
1381
+ }
1382
+ if (inSingle) continue;
1383
+ if (character === "$" && command[cursor + 1] === "(") {
1384
+ if (parserDepth >= SUBSTITUTION_PARSER_MAX_DEPTH) {
1385
+ return { body: command.slice(start), balanced: false, end: command.length };
1386
+ }
1387
+ const nested = scanDollarParenBody(command, cursor + 2, parserDepth + 1);
1388
+ if (!nested.balanced) {
1389
+ return { body: command.slice(start), balanced: false, end: command.length };
1390
+ }
1391
+ cursor = nested.end - 1;
1392
+ previous = "x";
1393
+ continue;
1394
+ }
1395
+ if (character === "`") {
1396
+ return { body: command.slice(start, cursor), balanced: true, end: cursor + 1 };
1397
+ }
1398
+ if (!inDouble && character === "#" && (previous === "" || /\s/.test(previous) || ";|&()".includes(previous))) {
1399
+ inComment = true;
1400
+ continue;
1401
+ }
1402
+ previous = character;
1403
+ }
1404
+ return { body: command.slice(start), balanced: false, end: command.length };
1405
+ }
1406
+
1407
+ /**
1408
+ * Collect the bodies of `$(...)` and backtick command substitutions. Their
1409
+ * contents are a live command list wherever they appear -- including inside a
1410
+ * double-quoted span -- so the caller rescans each one as its own script.
1411
+ *
1412
+ * Quote state is tracked the way the shell does it: an apostrophe inside a
1413
+ * double-quoted word is data, not a single-quote opener, so `"it's $(rm x)"`
1414
+ * still yields the substitution; a `)` inside a quoted argument does not close
1415
+ * the body, so `$(printf ')'; rm x)` yields the whole body. Single-quoted spans
1416
+ * are skipped because they suppress substitution entirely. Shell comments are
1417
+ * skipped only at word boundaries, and their quote-like text cannot poison the
1418
+ * surrounding frame. A body whose delimiters never balance is reported as such
1419
+ * so the caller fails closed.
1420
+ */
1421
+ function extractSubstitutionBodies(command: string): SubstitutionBody[] {
1422
+ const bodies: SubstitutionBody[] = [];
1423
+ let inSingle = false;
1424
+ let inDouble = false;
1425
+ let inComment = false;
1426
+ let previous = "";
1427
+ for (let index = 0; index < command.length; index += 1) {
1428
+ const character = command[index];
1429
+ if (inComment) {
1430
+ if (character === "\n") {
1431
+ inComment = false;
1432
+ previous = character;
1433
+ }
1434
+ continue;
1435
+ }
1436
+ if (character === "\\" && !inSingle) {
1437
+ index += 1;
1438
+ previous = "x";
1439
+ continue;
1440
+ }
1441
+ if (character === "'" && !inDouble) {
1442
+ inSingle = !inSingle;
1443
+ previous = character;
1444
+ continue;
1445
+ }
1446
+ if (character === '"' && !inSingle) {
1447
+ inDouble = !inDouble;
1448
+ previous = character;
1449
+ continue;
1450
+ }
1451
+ if (inSingle) continue;
1452
+ if (character === "$" && command[index + 1] === "(") {
1453
+ const scanned = scanDollarParenBody(command, index + 2);
1454
+ bodies.push({ body: scanned.body, balanced: scanned.balanced });
1455
+ index = scanned.end - 1;
1456
+ previous = "x";
1457
+ continue;
1458
+ }
1459
+ if (character === "`") {
1460
+ const scanned = scanBacktickBody(command, index + 1);
1461
+ bodies.push({ body: scanned.body, balanced: scanned.balanced });
1462
+ index = scanned.end - 1;
1463
+ previous = "x";
1464
+ continue;
1465
+ }
1466
+ if (!inDouble && character === "#" && (previous === "" || /\s/.test(previous) || ";|&()".includes(previous))) {
1467
+ inComment = true;
1468
+ continue;
1469
+ }
1470
+ previous = character;
1471
+ }
1472
+ return bodies;
1473
+ }
1474
+
908
1475
  function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
909
1476
  const record = getRecord(args);
910
1477
  const command = safeString(record?.command);
@@ -927,17 +1494,42 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
927
1494
  if (nested.unknown) targets.unknown = true;
928
1495
  if (nested.explicitMutation) targets.explicitMutation = true;
929
1496
  }
1497
+ // `$(...)` / backticks are a nested command list, exactly like `sh -c`: the
1498
+ // statement anchors below (`^`, `;`, `&`, `|`, newline) never see inside one,
1499
+ // so `echo "$(rm -rf src/x.ts)"` would otherwise scan clean. Heredoc masking
1500
+ // removes literal data-consumer bodies first; the extractor then ignores
1501
+ // inert `#` tails without touching quote/escape bytes needed by extraction.
1502
+ const heredoc = maskHeredocBodies(command);
1503
+ for (const substitution of extractSubstitutionBodies(heredoc.masked)) {
1504
+ if (depth >= 2 || !substitution.balanced) {
1505
+ targets.explicitMutation = true;
1506
+ targets.unknown = true;
1507
+ break;
1508
+ }
1509
+ const nested = extractBashTargets({ command: substitution.body }, depth + 1);
1510
+ for (const nestedPath of nested.paths) addPath(targets, nestedPath);
1511
+ if (nested.unknown) targets.unknown = true;
1512
+ if (nested.explicitMutation) targets.explicitMutation = true;
1513
+ }
930
1514
  // Heredoc bodies fed to data consumers are inert document payloads; mask them
931
1515
  // so spec/plan text cannot fake redirections. Script-consumer bodies survive
932
1516
  // the mask and are still scanned as live code below.
933
- const heredoc = maskHeredocBodies(command);
934
1517
  if (heredoc.opaqueExpansion || heredoc.mutatingConsumer) {
935
1518
  targets.explicitMutation = true;
936
1519
  targets.unknown = true;
937
1520
  }
938
- // Nested scripts were read from the raw text above; every scanner below works
939
- // on the masked view so quoted argument data cannot look like a redirection.
940
- const scanned = maskSingleQuotedSpans(heredoc.masked);
1521
+ // Nested scripts were read from the raw text above. Redirection extraction
1522
+ // uses the quote-preserving syntax view; command extraction uses its
1523
+ // dequoted executable-word view so quoted operands and split command words
1524
+ // remain visible without turning prose into syntax.
1525
+ const scanned = maskQuotedSpans(heredoc.masked);
1526
+ if (scanned.unmodeled) {
1527
+ // A live substitution construct we cannot balance (for example a
1528
+ // `case` pattern whose `)` is not the substitution close) must never
1529
+ // silently hide a mutation from the scanner.
1530
+ targets.explicitMutation = true;
1531
+ targets.unknown = true;
1532
+ }
941
1533
  if (
942
1534
  BASH_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked) ||
943
1535
  BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)
@@ -946,17 +1538,17 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
946
1538
  targets.unknown = true;
947
1539
  }
948
1540
  // `exec 1<>file` rebinds a descriptor, so a later `>/dev/null` may not reach the device at all.
949
- if (BASH_EXEC_REDIRECT_RE.test(scanned)) {
1541
+ if (BASH_EXEC_REDIRECT_RE.test(scanned.masked)) {
950
1542
  targets.explicitMutation = true;
951
1543
  targets.unknown = true;
952
1544
  }
953
- for (const match of scanned.matchAll(BASH_EXTENDED_WRITE_RE)) {
1545
+ for (const match of scanned.masked.matchAll(BASH_EXTENDED_WRITE_RE)) {
954
1546
  targets.explicitMutation = true;
955
1547
  const cleaned = cleanShellWord(match[1] ?? "");
956
1548
  if (!cleaned) targets.unknown = true;
957
1549
  else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
958
1550
  }
959
- for (const match of scanned.matchAll(BASH_DD_OUTPUT_RE)) {
1551
+ for (const match of scanned.masked.matchAll(BASH_DD_OUTPUT_RE)) {
960
1552
  targets.explicitMutation = true;
961
1553
  const parts = shellWords(match[1] ?? "").map(cleanShellWord);
962
1554
  // Every `of=` counts: GNU dd honors the last one, so `of=/dev/null of=real.ts` writes real.ts.
@@ -968,7 +1560,7 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
968
1560
  else if (!isDeviceSinkPath(outputPath)) addPath(targets, outputPath);
969
1561
  }
970
1562
  }
971
- for (const match of scanned.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
1563
+ for (const match of scanned.dequoted.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
972
1564
  const parts = shellWords(match[1] ?? "").map(cleanShellWord);
973
1565
  const hasInPlaceFlag = parts.some(part => /^-.*i/.test(part));
974
1566
  if (!hasInPlaceFlag) continue;
@@ -977,26 +1569,28 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
977
1569
  if (target) addPath(targets, target);
978
1570
  else targets.unknown = true;
979
1571
  }
980
- for (const match of scanned.matchAll(BASH_MUTATION_COMMAND_RE)) {
981
- targets.explicitMutation = true;
1572
+ for (const match of scanned.masked.matchAll(BASH_MUTATION_COMMAND_RE)) {
982
1573
  const redirected = match[2]?.trim();
983
- if (redirected) {
984
- const cleaned = cleanShellWord(redirected);
985
- // A capture that dequotes to nothing (e.g. `>" src.ts"`) is a truncated parse, not a safe no-op.
986
- if (!cleaned) targets.unknown = true;
987
- else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
988
- continue;
989
- }
1574
+ if (!redirected) continue;
1575
+ targets.explicitMutation = true;
1576
+ const cleaned = cleanShellWord(redirected);
1577
+ // A capture that dequotes to nothing (e.g. `>" src.ts"`) is a truncated parse, not a safe no-op.
1578
+ if (!cleaned) targets.unknown = true;
1579
+ else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
1580
+ }
1581
+ for (const match of scanned.dequoted.matchAll(BASH_MUTATION_COMMAND_RE)) {
1582
+ // Redirections are extracted from the quote-preserving syntax view above.
1583
+ // The dequoted view exists so quote-split command words (`r"m"`) and
1584
+ // quoted operands (`rm "src/product.ts"`) still reach this command branch.
1585
+ if (match[2]) continue;
1586
+ targets.explicitMutation = true;
990
1587
  const parts = shellWords(match[1] ?? "");
991
- const commandName = match[0]
992
- ?.match(
993
- /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(tee|touch|rm|mkdir|cp|mv|install|truncate)\b/i,
994
- )?.[1]
995
- ?.toLowerCase();
1588
+ const commandName = match[0]?.match(BASH_MUTATION_COMMAND_NAME_RE)?.[1]?.toLowerCase();
996
1589
  const targetParts =
997
1590
  commandName === "cp" || commandName === "mv" || commandName === "install" || commandName === "truncate"
998
1591
  ? parts.slice(-1)
999
1592
  : parts;
1593
+ let extractedTarget = false;
1000
1594
  for (const part of targetParts) {
1001
1595
  const cleaned = cleanShellWord(part);
1002
1596
  if (!cleaned || cleaned.startsWith("-")) continue;
@@ -1004,7 +1598,12 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
1004
1598
  // not argument paths; their targets are captured by the redirect scanners.
1005
1599
  if (/^\d*[<>]/.test(cleaned)) continue;
1006
1600
  addPath(targets, cleaned);
1601
+ extractedTarget = true;
1007
1602
  }
1603
+ // A recognized mutator with no attributable operand is not a safe no-op:
1604
+ // quote masking, unsupported expansions, and option-only invocations all
1605
+ // fail closed rather than allowing a targetless mutation through planning.
1606
+ if (!extractedTarget) targets.unknown = true;
1008
1607
  }
1009
1608
  return targets;
1010
1609
  }
@@ -1227,8 +1826,15 @@ export async function getWorkflowMutationDecision(
1227
1826
  // `.gjc/**` is runtime-owned and only the sanctioned CLI writes it). A branch
1228
1827
  // name is not authorization: an `autoresearch/*` branch isolates keep/discard
1229
1828
  // bookkeeping, it does not turn product edits into research.
1829
+ //
1830
+ // The allowance requires at least one named target: `every` is vacuously true
1831
+ // for an empty list, so an opaque mutation that resolves to NO target (e.g.
1832
+ // `python3 -c "open('src/product.ts','w').write('x')"`) would otherwise skip
1833
+ // the fail-closed check below and be waved through on a research mission.
1230
1834
  if (
1231
1835
  planning.skill === "autoresearch" &&
1836
+ targets.paths.length > 0 &&
1837
+ !targets.unknown &&
1232
1838
  targets.paths.every(p => isAutoresearchAuthorizedResearchPath(input.cwd, p))
1233
1839
  ) {
1234
1840
  return { blocked: false, targets: targets.paths };