@gajae-code/coding-agent 0.18.11 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (462) hide show
  1. package/CHANGELOG.md +760 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/capability/rule.d.ts +7 -2
  7. package/dist/types/cli/accounts-cli.d.ts +2 -2
  8. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  9. package/dist/types/cli/args.d.ts +7 -2
  10. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  11. package/dist/types/cli/customize-doctor.d.ts +4 -3
  12. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  13. package/dist/types/cli/install-activation.d.ts +10 -4
  14. package/dist/types/cli/public-command-errors.d.ts +2 -0
  15. package/dist/types/cli/root-flags.d.ts +0 -12
  16. package/dist/types/cli/update-cli.d.ts +21 -3
  17. package/dist/types/cli-main.d.ts +0 -6
  18. package/dist/types/cli-ordinary.d.ts +2 -2
  19. package/dist/types/commands/accounts.d.ts +2 -1
  20. package/dist/types/commands/launch.d.ts +0 -6
  21. package/dist/types/commands/migrate.d.ts +1 -1
  22. package/dist/types/commands/sdk.d.ts +13 -0
  23. package/dist/types/commit/changelog/index.d.ts +1 -0
  24. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  25. package/dist/types/config/autorouting-tier-map.d.ts +53 -1
  26. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  27. package/dist/types/config/config-hot-reload.d.ts +44 -0
  28. package/dist/types/config/file-lock.d.ts +52 -3
  29. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  30. package/dist/types/config/model-profile-activation.d.ts +79 -9
  31. package/dist/types/config/model-registry.d.ts +72 -8
  32. package/dist/types/config/model-resolver.d.ts +7 -4
  33. package/dist/types/config/models-config-schema.d.ts +10 -0
  34. package/dist/types/config/settings-schema.d.ts +57 -4
  35. package/dist/types/config/settings.d.ts +33 -1
  36. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  37. package/dist/types/customization/import.d.ts +2 -0
  38. package/dist/types/customization/types.d.ts +1 -1
  39. package/dist/types/dap/config.d.ts +8 -4
  40. package/dist/types/dap/session.d.ts +11 -2
  41. package/dist/types/debug/system-info.d.ts +6 -1
  42. package/dist/types/discovery/helpers.d.ts +7 -2
  43. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  44. package/dist/types/edit/index.d.ts +5 -0
  45. package/dist/types/edit/modes/patch.d.ts +14 -0
  46. package/dist/types/edit/modes/replace.d.ts +3 -0
  47. package/dist/types/eval/py/runtime.d.ts +9 -0
  48. package/dist/types/exec/bash-executor.d.ts +12 -0
  49. package/dist/types/export/ttsr.d.ts +19 -0
  50. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  51. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  52. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  53. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  54. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  55. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  56. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  57. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  58. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  59. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  60. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  61. package/dist/types/hindsight/content.d.ts +5 -6
  62. package/dist/types/internal-urls/types.d.ts +2 -0
  63. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  64. package/dist/types/main.d.ts +23 -18
  65. package/dist/types/migrate/action-planner.d.ts +10 -1
  66. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  67. package/dist/types/migrate/adapters/index.d.ts +42 -1
  68. package/dist/types/migrate/executor.d.ts +3 -1
  69. package/dist/types/migrate/hook-module.d.ts +23 -0
  70. package/dist/types/migrate/report.d.ts +2 -1
  71. package/dist/types/migrate/types.d.ts +82 -3
  72. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  73. package/dist/types/modes/components/model-selector.d.ts +43 -0
  74. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  75. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  76. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  77. package/dist/types/modes/theme/theme.d.ts +1 -0
  78. package/dist/types/modes/utils/host-status.d.ts +15 -11
  79. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  80. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  81. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  82. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  83. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  84. package/dist/types/sdk/broker/broker-exit.d.ts +3 -3
  85. package/dist/types/sdk/broker/broker.d.ts +29 -2
  86. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  87. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  88. package/dist/types/sdk/broker/ensure.d.ts +141 -9
  89. package/dist/types/sdk/broker/hop.d.ts +45 -0
  90. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  91. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  92. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  93. package/dist/types/sdk/broker/session-index.d.ts +6 -1
  94. package/dist/types/sdk/broker/transport.d.ts +2 -0
  95. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  96. package/dist/types/sdk/bus/index.d.ts +44 -0
  97. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  98. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -1
  99. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  100. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  101. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  102. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  103. package/dist/types/sdk/host/events.d.ts +4 -0
  104. package/dist/types/sdk/host/host.d.ts +2 -1
  105. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +40 -8
  107. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  109. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  111. package/dist/types/sdk/prompt-status.d.ts +11 -4
  112. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  113. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  114. package/dist/types/sdk/session-list.d.ts +1 -1
  115. package/dist/types/session/agent-session.d.ts +107 -9
  116. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  117. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  118. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  119. package/dist/types/session/messages.d.ts +5 -1
  120. package/dist/types/session/session-manager.d.ts +29 -3
  121. package/dist/types/session/startup-auth-config.d.ts +24 -2
  122. package/dist/types/session/streaming-output.d.ts +11 -0
  123. package/dist/types/session/terminal-abort.d.ts +3 -1
  124. package/dist/types/session-import/service.d.ts +1 -1
  125. package/dist/types/skill-state/workflow-mutation-guard.d.ts +20 -4
  126. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  127. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  128. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  129. package/dist/types/system-prompt.d.ts +3 -2
  130. package/dist/types/task/executor.d.ts +19 -2
  131. package/dist/types/task/role-body.d.ts +11 -0
  132. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  133. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  134. package/dist/types/tools/awaiting-input.d.ts +28 -0
  135. package/dist/types/tools/bash-allowed-prefixes.d.ts +17 -0
  136. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  137. package/dist/types/tools/bash.d.ts +7 -0
  138. package/dist/types/tools/descriptors.d.ts +15 -2
  139. package/dist/types/tools/fetch.d.ts +7 -1
  140. package/dist/types/tools/index.d.ts +2 -0
  141. package/dist/types/tools/irc.d.ts +5 -3
  142. package/dist/types/tools/python.d.ts +1 -1
  143. package/dist/types/tools/report-issue.d.ts +41 -0
  144. package/dist/types/tools/subagent.d.ts +23 -0
  145. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  146. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  147. package/dist/types/tools/write.d.ts +2 -0
  148. package/dist/types/utils/file-mentions.d.ts +5 -0
  149. package/dist/types/utils/image-limits.d.ts +7 -0
  150. package/dist/types/utils/image-loading.d.ts +0 -1
  151. package/dist/types/utils/open.d.ts +0 -1
  152. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  153. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  154. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  155. package/dist/types/utils/title-generator.d.ts +13 -3
  156. package/dist/types/web/scrapers/types.d.ts +8 -1
  157. package/dist/types/web/search/providers/codex.d.ts +6 -7
  158. package/dist/types/web/search/providers/insane.d.ts +7 -1
  159. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  160. package/package.json +10 -9
  161. package/scripts/acp-conformance-agent.ts +12 -2
  162. package/scripts/build-binary.ts +2 -1
  163. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  164. package/scripts/generate-docs-index.ts +8 -1
  165. package/scripts/generate-sdk-operation-inventory.ts +38 -4
  166. package/src/async/job-manager.ts +356 -56
  167. package/src/async/result-formatting.ts +40 -0
  168. package/src/autoresearch/git.ts +9 -2
  169. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  170. package/src/capability/fs.ts +58 -22
  171. package/src/capability/index.ts +2 -2
  172. package/src/capability/rule.ts +11 -2
  173. package/src/cli/accounts-cli.ts +176 -8
  174. package/src/cli/append-system-prompt.ts +12 -0
  175. package/src/cli/args.ts +11 -7
  176. package/src/cli/auth-broker-cli.ts +21 -11
  177. package/src/cli/customize-doctor-precedence.ts +31 -0
  178. package/src/cli/customize-doctor.ts +49 -39
  179. package/src/cli/doctor/install-repairs.ts +35 -5
  180. package/src/cli/fast-help.ts +1 -3
  181. package/src/cli/install-activation.ts +47 -4
  182. package/src/cli/migrate-cli.ts +19 -6
  183. package/src/cli/public-command-errors.ts +6 -0
  184. package/src/cli/root-flags.ts +2 -4
  185. package/src/cli/stats-cli.ts +9 -5
  186. package/src/cli/update-cli.ts +471 -13
  187. package/src/cli-ordinary.ts +7 -2
  188. package/src/commands/accounts.ts +10 -5
  189. package/src/commands/harness.ts +25 -15
  190. package/src/commands/migrate.ts +3 -3
  191. package/src/commands/sdk.ts +277 -50
  192. package/src/commands/ultragoal.ts +16 -8
  193. package/src/commit/agentic/agent.ts +6 -6
  194. package/src/commit/analysis/conventional.ts +14 -5
  195. package/src/commit/changelog/index.ts +39 -1
  196. package/src/commit/map-reduce/index.ts +7 -7
  197. package/src/commit/message.ts +10 -3
  198. package/src/commit/pipeline.ts +12 -12
  199. package/src/config/autorouting-tier-map.ts +50 -1
  200. package/src/config/config-file.ts +106 -3
  201. package/src/config/config-hot-reload-trust.ts +148 -0
  202. package/src/config/config-hot-reload.ts +742 -0
  203. package/src/config/file-lock.ts +1615 -328
  204. package/src/config/keybindings.ts +154 -7
  205. package/src/config/model-bindings-applier.ts +85 -47
  206. package/src/config/model-discovery-manager.ts +10 -0
  207. package/src/config/model-profile-activation.ts +889 -148
  208. package/src/config/model-registry.ts +1706 -205
  209. package/src/config/model-resolver.ts +52 -22
  210. package/src/config/models-config-schema.ts +6 -0
  211. package/src/config/settings-schema.ts +73 -4
  212. package/src/config/settings.ts +460 -24
  213. package/src/coordinator-mcp/policy.ts +26 -1
  214. package/src/coordinator-mcp/server.ts +37 -2
  215. package/src/crash/sanitize.ts +7 -1
  216. package/src/customization/import.ts +279 -8
  217. package/src/customization/inventory.ts +24 -8
  218. package/src/customization/types.ts +1 -0
  219. package/src/dap/client.ts +3 -2
  220. package/src/dap/config.ts +31 -11
  221. package/src/dap/defaults.json +1 -1
  222. package/src/dap/session.ts +161 -36
  223. package/src/debug/crash-diagnostics.ts +89 -6
  224. package/src/debug/report-bundle.ts +90 -2
  225. package/src/debug/system-info.ts +8 -3
  226. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  227. package/src/discovery/agents-md.ts +1 -1
  228. package/src/discovery/builtin.ts +4 -3
  229. package/src/discovery/helpers.ts +25 -16
  230. package/src/discovery/mcp-compat.ts +120 -17
  231. package/src/edit/apply-patch/index.ts +12 -1
  232. package/src/edit/index.ts +204 -22
  233. package/src/edit/modes/patch.ts +214 -8
  234. package/src/edit/modes/replace.ts +136 -0
  235. package/src/eval/js/context-manager.ts +29 -3
  236. package/src/eval/js/worker-entry.ts +51 -18
  237. package/src/eval/py/executor.ts +14 -4
  238. package/src/eval/py/runner.py +144 -5
  239. package/src/eval/py/runtime.ts +44 -4
  240. package/src/exec/bash-executor.ts +81 -4
  241. package/src/exec/bash-shell-guardian.ts +4 -1
  242. package/src/exec/bash-shell-supervisor.ts +8 -4
  243. package/src/exec/isolated-shell.ts +5 -1
  244. package/src/exec/non-interactive-env.ts +0 -1
  245. package/src/export/html/index.ts +86 -6
  246. package/src/export/ttsr.ts +176 -1
  247. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  248. package/src/extensibility/extensions/loader.ts +10 -1
  249. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  250. package/src/extensibility/extensions/types.ts +5 -1
  251. package/src/extensibility/hooks/loader.ts +10 -2
  252. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  253. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  254. package/src/extensibility/slash-commands.ts +5 -0
  255. package/src/gjc-runtime/gc-runtime.ts +37 -7
  256. package/src/gjc-runtime/launch-tmux.ts +30 -15
  257. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  258. package/src/gjc-runtime/launch-worktree.ts +101 -14
  259. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  260. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  261. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  262. package/src/gjc-runtime/session-resolution.ts +38 -6
  263. package/src/gjc-runtime/session-state-lock.ts +59 -14
  264. package/src/gjc-runtime/state-runtime.ts +23 -19
  265. package/src/gjc-runtime/state-writer.ts +109 -5
  266. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  267. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  268. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  269. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  270. package/src/harness-control-plane/finalize.ts +11 -0
  271. package/src/harness-control-plane/receipt-spool.ts +105 -28
  272. package/src/harness-control-plane/storage.ts +124 -6
  273. package/src/hindsight/content.ts +116 -16
  274. package/src/hindsight/mental-models.ts +22 -10
  275. package/src/hindsight/state.ts +7 -4
  276. package/src/hooks/native-skill-hook.ts +17 -12
  277. package/src/internal-urls/artifact-protocol.ts +3 -1
  278. package/src/internal-urls/docs-index.generated.ts +23 -22
  279. package/src/internal-urls/local-protocol.ts +167 -8
  280. package/src/internal-urls/types.ts +2 -0
  281. package/src/lsp/client.ts +2 -1
  282. package/src/lsp/clients/biome-client.ts +2 -0
  283. package/src/lsp/clients/swiftlint-client.ts +2 -0
  284. package/src/lsp/index.ts +29 -0
  285. package/src/lsp/workspace-path.ts +23 -0
  286. package/src/main.ts +330 -92
  287. package/src/memories/index.ts +25 -2
  288. package/src/migrate/action-planner.ts +574 -2
  289. package/src/migrate/adapters/claude-code.ts +162 -5
  290. package/src/migrate/adapters/codex.ts +77 -7
  291. package/src/migrate/adapters/index.ts +258 -5
  292. package/src/migrate/adapters/opencode.ts +10 -1
  293. package/src/migrate/executor.ts +222 -7
  294. package/src/migrate/hook-module.ts +193 -0
  295. package/src/migrate/report.ts +63 -2
  296. package/src/migrate/types.ts +84 -4
  297. package/src/modes/DESIGN.md +51 -0
  298. package/src/modes/acp/acp-agent.ts +10 -1
  299. package/src/modes/acp/permission-mode.ts +18 -1
  300. package/src/modes/components/agent-dashboard.ts +20 -4
  301. package/src/modes/components/assistant-message.ts +55 -1
  302. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  303. package/src/modes/components/customization/import-wizard.ts +29 -3
  304. package/src/modes/components/model-selector.ts +157 -103
  305. package/src/modes/components/settings-selector.ts +103 -37
  306. package/src/modes/components/welcome.ts +3 -1
  307. package/src/modes/controllers/command-controller.ts +300 -9
  308. package/src/modes/controllers/event-controller.ts +1 -1
  309. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  310. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  311. package/src/modes/controllers/input-controller.ts +2 -0
  312. package/src/modes/controllers/plan-mode-controller.ts +66 -11
  313. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  314. package/src/modes/controllers/selector-controller.ts +76 -7
  315. package/src/modes/interactive-mode.ts +123 -4
  316. package/src/modes/prompt-suggestion-controller.ts +1 -1
  317. package/src/modes/runtime-init.ts +5 -1
  318. package/src/modes/theme/theme.ts +15 -0
  319. package/src/modes/utils/host-status.ts +21 -10
  320. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  321. package/src/prompts/system/irc-incoming.md +3 -1
  322. package/src/prompts/tools/debug.md +1 -1
  323. package/src/prompts/tools/irc.md +1 -1
  324. package/src/prompts/tools/read.md +1 -1
  325. package/src/prompts/tools/report-issue.md +13 -0
  326. package/src/prompts/tools/task.md +1 -1
  327. package/src/runtime-credential-selector.ts +2 -2
  328. package/src/runtime-mcp/content-limits.ts +7 -0
  329. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  330. package/src/runtime-mcp/manager.ts +40 -3
  331. package/src/runtime-mcp/oauth-flow.ts +32 -6
  332. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  333. package/src/runtime-mcp/project-host-values.ts +12 -8
  334. package/src/runtime-mcp/render.ts +16 -1
  335. package/src/runtime-mcp/tool-bridge.ts +194 -21
  336. package/src/runtime-mcp/transports/stdio.ts +2 -1
  337. package/src/sdk/broker/broker-exit.ts +10 -5
  338. package/src/sdk/broker/broker.ts +484 -85
  339. package/src/sdk/broker/daemon-entry.ts +338 -116
  340. package/src/sdk/broker/doctor-restart.ts +2 -1
  341. package/src/sdk/broker/ensure.ts +1214 -268
  342. package/src/sdk/broker/hop.ts +286 -0
  343. package/src/sdk/broker/lifecycle-ledger.ts +38 -11
  344. package/src/sdk/broker/lifecycle.ts +102 -6
  345. package/src/sdk/broker/lock-host-identity.ts +29 -0
  346. package/src/sdk/broker/session-index.ts +91 -16
  347. package/src/sdk/broker/transport.ts +17 -1
  348. package/src/sdk/bus/chat-command-policy.ts +16 -3
  349. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  350. package/src/sdk/bus/index.ts +460 -35
  351. package/src/sdk/bus/reconciliation-store.ts +201 -8
  352. package/src/sdk/bus/session-control.ts +2 -0
  353. package/src/sdk/bus/slack-daemon.ts +105 -9
  354. package/src/sdk/bus/telegram-daemon-contract.ts +9 -1
  355. package/src/sdk/bus/telegram-daemon.ts +10 -2
  356. package/src/sdk/bus/threaded-render.ts +20 -1
  357. package/src/sdk/cli/session-cli.ts +23 -6
  358. package/src/sdk/host/control/dispatch.ts +13 -1
  359. package/src/sdk/host/control/operations.ts +10 -1
  360. package/src/sdk/host/events.ts +67 -4
  361. package/src/sdk/host/host.ts +8 -2
  362. package/src/sdk/host/prompt-image-upload.ts +438 -0
  363. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  364. package/src/sdk/host/session-runtime.test.ts +1737 -129
  365. package/src/sdk/host/session-runtime.ts +684 -83
  366. package/src/sdk/host/surface-policy.ts +4 -0
  367. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  368. package/src/sdk/host/websocket-transport.ts +19 -7
  369. package/src/sdk/lifecycle/service.ts +15 -1
  370. package/src/sdk/prompt-deadline-flush.ts +78 -5
  371. package/src/sdk/prompt-deadline-manager.ts +68 -14
  372. package/src/sdk/prompt-status.ts +6 -4
  373. package/src/sdk/protocol/adapter-validation.ts +81 -12
  374. package/src/sdk/protocol/operation-inventory.generated.json +261 -2
  375. package/src/sdk/protocol/operation-registry.ts +33 -3
  376. package/src/sdk/reconciliation-extensions.ts +1 -0
  377. package/src/sdk/session-list.ts +1 -0
  378. package/src/sdk/session.ts +237 -97
  379. package/src/session/agent-session.ts +2726 -479
  380. package/src/session/history-storage.ts +182 -12
  381. package/src/session/internal/managed-session-scope.ts +173 -57
  382. package/src/session/internal/managed-session-storage.ts +85 -12
  383. package/src/session/internal/native-publish-outcome.ts +5 -1
  384. package/src/session/messages.ts +47 -6
  385. package/src/session/session-manager.ts +326 -19
  386. package/src/session/startup-auth-config.ts +116 -8
  387. package/src/session/streaming-output.ts +262 -4
  388. package/src/session/terminal-abort.ts +14 -2
  389. package/src/session-import/redact.ts +53 -2
  390. package/src/session-import/service.ts +1 -0
  391. package/src/skill-state/workflow-mutation-guard.ts +2145 -206
  392. package/src/slash-commands/builtin-registry.ts +21 -6
  393. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  394. package/src/slash-commands/helpers/mcp.ts +11 -3
  395. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  396. package/src/ssh/ssh-executor.ts +9 -2
  397. package/src/stt/recorder.ts +26 -9
  398. package/src/system-prompt.ts +61 -16
  399. package/src/task/executor.ts +115 -20
  400. package/src/task/index.ts +389 -175
  401. package/src/task/role-body.ts +84 -0
  402. package/src/task/token-log-mode.test.ts +162 -0
  403. package/src/task/token-log.ts +91 -2
  404. package/src/tools/archive-reader.ts +650 -37
  405. package/src/tools/ask.ts +1 -0
  406. package/src/tools/ast-edit-write-path.ts +64 -0
  407. package/src/tools/ast-edit.ts +18 -1
  408. package/src/tools/ast-grep.ts +7 -2
  409. package/src/tools/atomic-file-write.ts +46 -15
  410. package/src/tools/auto-generated-guard.ts +15 -3
  411. package/src/tools/awaiting-input.ts +114 -0
  412. package/src/tools/bash-allowed-prefixes.ts +214 -6
  413. package/src/tools/bash-interactive.ts +14 -2
  414. package/src/tools/bash-skill-urls.ts +58 -5
  415. package/src/tools/bash.ts +113 -26
  416. package/src/tools/browser/tab-worker.ts +39 -13
  417. package/src/tools/browser.ts +17 -7
  418. package/src/tools/cron.ts +10 -1
  419. package/src/tools/debug.ts +16 -8
  420. package/src/tools/descriptors.test.ts +44 -0
  421. package/src/tools/descriptors.ts +50 -6
  422. package/src/tools/eval.ts +15 -0
  423. package/src/tools/fetch.ts +93 -4
  424. package/src/tools/gh.ts +27 -12
  425. package/src/tools/image-gen.ts +20 -6
  426. package/src/tools/index.ts +2 -0
  427. package/src/tools/irc.ts +5 -3
  428. package/src/tools/monitor.ts +9 -1
  429. package/src/tools/output-meta.ts +74 -5
  430. package/src/tools/python.ts +1 -1
  431. package/src/tools/read.ts +26 -37
  432. package/src/tools/recipe/runner.ts +23 -12
  433. package/src/tools/render-utils.ts +2 -2
  434. package/src/tools/report-issue.ts +184 -0
  435. package/src/tools/search.ts +3 -2
  436. package/src/tools/ssh.ts +274 -8
  437. package/src/tools/subagent-render.ts +114 -12
  438. package/src/tools/subagent.ts +118 -5
  439. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  440. package/src/tools/todo-write.ts +41 -3
  441. package/src/tools/tool-catalog.generated.ts +32 -4
  442. package/src/tools/ultragoal-ask-guard.ts +13 -1
  443. package/src/tools/write.ts +42 -3
  444. package/src/tui/tree-list.ts +15 -1
  445. package/src/utils/commit-message-generator.ts +12 -4
  446. package/src/utils/external-editor.ts +21 -7
  447. package/src/utils/file-mentions.ts +75 -7
  448. package/src/utils/git.ts +38 -7
  449. package/src/utils/image-limits.ts +8 -0
  450. package/src/utils/image-loading.ts +1 -1
  451. package/src/utils/open.ts +73 -4
  452. package/src/utils/pasted-image-loading.ts +9 -8
  453. package/src/utils/prompt-suggestion.ts +5 -4
  454. package/src/utils/shell-snapshot.ts +9 -1
  455. package/src/utils/title-generator.ts +72 -22
  456. package/src/web/scrapers/index.ts +3 -1
  457. package/src/web/scrapers/types.ts +8 -0
  458. package/src/web/scrapers/youtube.ts +3 -2
  459. package/src/web/search/index.ts +2 -1
  460. package/src/web/search/providers/codex.ts +29 -32
  461. package/src/web/search/providers/insane.ts +91 -28
  462. package/src/web/search/providers/searxng.ts +62 -10
@@ -3,9 +3,14 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { AgentTool } from "@gajae-code/agent-core";
5
5
  import { logger } from "@gajae-code/utils";
6
+ import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
6
7
  import { isAutoresearchAuthorizedResearchPath } from "../autoresearch/git";
7
8
  import { expandApplyPatchToEntries } from "../edit/modes/apply-patch";
8
- import { GJC_SESSION_PREFIX, modeStatePath as sessionModeStatePath } from "../gjc-runtime/session-layout";
9
+ import {
10
+ GJC_SESSION_PREFIX,
11
+ sessionDirName,
12
+ modeStatePath as sessionModeStatePath,
13
+ } from "../gjc-runtime/session-layout";
9
14
  import { resolveGjcSessionForRead } from "../gjc-runtime/session-resolution";
10
15
  import { ModeStateSchema } from "../gjc-runtime/state-schema";
11
16
  import { workflowEnvelopeChecksumStatus } from "../gjc-runtime/state-writer";
@@ -30,7 +35,7 @@ export const RALPLAN_MUTATION_BLOCK_MESSAGE =
30
35
  export const ULTRAGOAL_GOAL_PLANNING_MUTATION_BLOCK_MESSAGE =
31
36
  "Ultragoal goal-planning phase boundary: finish goal planning and record goals through `gjc ultragoal` before editing code. Product-code mutation tools and patch execution are blocked until goal planning completes and execution begins.";
32
37
  export const AUTORESEARCH_MUTATION_BLOCK_MESSAGE =
33
- "Autoresearch research-only boundary: this workflow produces findings, evidence, and a verdict — never product code. Product-code mutation tools and patch execution are blocked everywhere, on every branch. Mission research artifacts (`autoresearch.sh` at the workdir root and mission state through the sanctioned `gjc autoresearch` CLI) stay writable; run experiments through the `python` tool, or finish the mission and hand off to an approved implementation workflow.";
38
+ "Autoresearch research-only boundary: this workflow produces findings, evidence, and a verdict — never product code. Product-code mutation tools and patch execution are blocked everywhere, on every branch. Write the root `autoresearch.sh` only with direct file tools; mission state stays writable through the sanctioned `gjc autoresearch` CLI. Run experiments through the `python` tool, or finish the mission and hand off to an approved implementation workflow.";
34
39
 
35
40
  /** Resolve the phase-boundary block message for the active planning skill. */
36
41
  function planningPhaseBlockMessage(skill: CanonicalGjcWorkflowSkill): string {
@@ -40,10 +45,15 @@ function planningPhaseBlockMessage(skill: CanonicalGjcWorkflowSkill): string {
40
45
  return DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE;
41
46
  }
42
47
 
43
- /** Tools whose execute path can mutate product state. `github` is included so side-effect ops share this guard. */
44
- export const WORKFLOW_MUTATION_TOOL_NAMES = ["edit", "write", "ast_edit", "bash", "github"] as const;
48
+ /**
49
+ * Tools whose execute path can mutate product state. `github` is included so
50
+ * side-effect ops share this guard. `eval` and `python` run code that can write
51
+ * files without going through edit, write, or bash.
52
+ */
53
+ export const WORKFLOW_MUTATION_TOOL_NAMES = ["edit", "write", "ast_edit", "bash", "github", "eval", "python"] as const;
45
54
 
46
55
  const BLOCKED_TOOL_NAMES = new Set<string>(WORKFLOW_MUTATION_TOOL_NAMES);
56
+ const AUTORESEARCH_HARNESS_FILE_TOOL_NAMES = new Set(["edit", "write"]);
47
57
 
48
58
  export function isWorkflowMutationTool(name: string): boolean {
49
59
  return BLOCKED_TOOL_NAMES.has(name);
@@ -79,22 +89,81 @@ const BASH_COMMAND_WRAPPER_SOURCE =
79
89
  */
80
90
  const BASH_COMMAND_POSITION_KEYWORD_RE = /(?:^|[;&|(\n{}])\s*(?:then|else|elif|do|in|\{|!)(?=\s)/gi;
81
91
  const BASH_MUTATION_COMMAND_RE = new RegExp(
82
- `(?:^|[;&|(\\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;&|]+)`,
92
+ `(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:tee|touch|rm|mkdir|cp|mv|install|truncate|chmod|ln)\\b([^;&|()\\n]*)|(?:^|[^<>])(?:>>?|\\d>>?)\\s*([^\\s;&|]+)`,
83
93
  "gi",
84
94
  );
85
95
  /** Command-word extractor; must accept the same prefixes as `BASH_MUTATION_COMMAND_RE`. */
86
96
  const BASH_MUTATION_COMMAND_NAME_RE = new RegExp(
87
- `(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(tee|touch|rm|mkdir|cp|mv|install|truncate)\\b`,
97
+ `(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(tee|touch|rm|mkdir|cp|mv|install|truncate|chmod|ln)\\b`,
88
98
  "i",
89
99
  );
100
+ const BASH_CHMOD_MODE_RE = /^(?:[0-7]{1,4}|(?:[ugoa]*[+=-][rwxXstugo]*)(?:,[ugoa]*[+=-][rwxXstugo]*)*)$/;
90
101
  const BASH_IN_PLACE_MUTATION_COMMAND_RE = new RegExp(
91
102
  `(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:sed|perl)\\b([^;&|()\\n]*)`,
92
103
  "gi",
93
104
  );
94
- const BASH_OPAQUE_INTERPRETER_WRITE_RE =
95
- /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:-c|-e)\b[^;&|\n]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
96
- const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE =
97
- /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:<<[-]?\s*['"]?\w+['"]?)[\s\S]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
105
+ /**
106
+ * Write APIs a planning one-liner cannot be allowed to call. The destination is
107
+ * not a static path the temp allowlist can trust, so a match fails closed the
108
+ * same way `open(` / `writeFile(` / `.write(` already do. `perl` is included
109
+ * because `perl -e 'open(...)'` is that same call.
110
+ */
111
+ const BASH_INTERPRETER_WRITE_API_PATTERNS = [
112
+ "open\\s*\\(",
113
+ "writeFile(?:Sync)?\\s*\\(",
114
+ "appendFile(?:Sync)?\\s*\\(",
115
+ "file_put_contents\\s*\\(",
116
+ "fwrite\\s*\\(",
117
+ "fputs\\s*\\(",
118
+ "unlink\\s*\\(",
119
+ "rename\\s*\\(",
120
+ "mkdir\\s*\\(",
121
+ "rmdir\\s*\\(",
122
+ "copy\\s*\\(",
123
+ "symlink\\s*\\(",
124
+ "link\\s*\\(",
125
+ "move_uploaded_file\\s*\\(",
126
+ "truncate\\s*\\(",
127
+ "touch\\s*\\(",
128
+ "\\.write\\s*\\(",
129
+ "shutil\\s*\\.\\s*(?:copy2?|copyfileobj|copyfile)\\s*\\(",
130
+ "os\\s*\\.\\s*symlink\\s*\\(",
131
+ ] as const;
132
+ const BASH_INTERPRETER_WRITE_API = `(?:${BASH_INTERPRETER_WRITE_API_PATTERNS.join("|")})`;
133
+ const BASH_INTERPRETER_WRITE_API_EXCLUDING_OPEN = `(?:${BASH_INTERPRETER_WRITE_API_PATTERNS.slice(1).join("|")})`;
134
+ const INTERPRETER_OUTPUT_METHOD_ASSIGNMENT_RE =
135
+ /^\b(?:(?:process|Bun|Deno|sys)(?:\s*\.\s*(?:stdout|stderr)|\s*\[\s*["'`](?:stdout|stderr)["'`]\s*\])|(?:process|Bun|Deno|sys)\s*\[\s*[^\]]+\s*\]\s*(?:\.\s*write|\[\s*["'`]write["'`]\s*\])|(?:(?:globalThis|global)\s*\.\s*)?console)(?:\s*(?:\.\s*[\w$]+|\[\s*[^\]]+\s*\]))?\s*(?:\|\|=|&&=|\?\?=|[+\-*/%&|^]?=(?!=|>))/;
136
+
137
+ /**
138
+ * A stream object can also be mutated reflectively, so an exempted
139
+ * `process.stdout.write(...)` is only trustworthy when nothing in the source can
140
+ * have replaced that method (`Object.assign(process.stdout, {write: ...})`).
141
+ */
142
+ const INTERPRETER_OUTPUT_STREAM_MUTATION_RE =
143
+ /\b(?:Object\s*\.\s*(?:assign|definePropert(?:y|ies))|Reflect\s*\.\s*(?:set|defineProperty))\s*\(\s*(?:process|Bun|Deno|sys)\s*(?:\.\s*(?:stdout|stderr)|\[\s*["'`](?:stdout|stderr)["'`]\s*\])/;
144
+ /**
145
+ * Inline interpreter source is opaque whenever it exposes code evaluation or
146
+ * reflective loading. This deliberately scans the complete source spelling,
147
+ * including quoted spans, because another language may evaluate those spans.
148
+ */
149
+ const INTERPRETER_DYNAMIC_EXECUTION_RE =
150
+ /\b(?:eval|exec|execfile|Function|instance_eval|instance_exec|class_eval|class_exec|module_eval|module_exec|send|public_send|__send__|method_missing|__import__|exec_module|import_module|run_path|run_module|loadstring|dofile|loadfile|load|source|include|include_once|require_once|create_function|call_user_func(?:_array)?|setTimeout|setInterval|runInThisContext|runInNewContext|runInContext|getattr|setattr|__getattr__|__getattribute__|ReflectionClass|ReflectionFunction|ReflectionMethod|invokeMethod|invokeArgs)\b/;
151
+ const INTERPRETER_FILESYSTEM_REQUIRE_RE =
152
+ /\b(?:require|import|from)\s*(?:\(\s*)?(['"\x60])(?:node:)?(?:fs(?:\/promises)?|fs-extra|graceful-fs|child_process|fileutils|io|lfs)\1(?:\s*\))?/i;
153
+ const INTERPRETER_NONLITERAL_REQUIRE_RE = /\brequire\s*(?:\(\s*(?!['"\x60])|\s+(?!['"\x60]))/i;
154
+ const SHELL_DOT_SOURCE_RE = /(?:^|[;&|\n])\s*(?:(?:if|then|do)\s+)?\.\s+(?:["'$`]|[A-Za-z0-9_./~])/;
155
+ /** Executable Python source evaluation and dynamic imports make payloads opaque; compile() alone is inert. */
156
+ const PYTHON_DYNAMIC_EXECUTION_IDENTIFIER_RE =
157
+ /^(?:exec|eval|execfile|__import__|exec_module|import_module|run_path|run_module|getattr|setattr|__getattr__|__getattribute__)\b/;
158
+ const PYTHON_DYNAMIC_EXECUTION_TOKEN_RE =
159
+ /\b(?:exec|eval|execfile|__import__|exec_module|import_module|run_path|run_module|getattr|setattr|__getattr__|__getattribute__)\b/;
160
+ /** JavaScript eval and Function construct executable code from runtime strings. */
161
+ const JAVASCRIPT_DYNAMIC_EXECUTION_IDENTIFIER_RE = /^(?:eval|Function)\b/;
162
+ const JAVASCRIPT_DYNAMIC_EXECUTION_TOKEN_RE = /\b(?:eval|Function)\b/;
163
+ const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE = new RegExp(
164
+ `(?:^|[;&|\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?(?:python3?|node|ruby|perl)\\b[^;&|\\n]*(?:<<[-]?\\s*['"]?\\w+['"]?)[\\s\\S]*${BASH_INTERPRETER_WRITE_API}`,
165
+ "i",
166
+ );
98
167
  const BASH_DD_OUTPUT_RE = new RegExp(
99
168
  `(?:^|[;&|\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|]*\\/)?dd\\b([^;&|\\n]*)`,
100
169
  "gi",
@@ -300,7 +369,7 @@ interface ActivePlanningSkill {
300
369
  * reliably wins. Ties fall back to the resolved top-level `skill`, then to the
301
370
  * first entry, matching how the HUD/chain guard pick `activeSkills[0]`.
302
371
  */
303
- function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill: string): SkillActiveEntry {
372
+ export function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill: string): SkillActiveEntry {
304
373
  const ts = (entry: SkillActiveEntry): number => {
305
374
  const value = Date.parse(safeString(entry.updated_at) || safeString(entry.activated_at));
306
375
  return Number.isNaN(value) ? -1 : value;
@@ -1553,198 +1622,1915 @@ function extractSubstitutionBodies(command: string): SubstitutionBody[] {
1553
1622
  return bodies;
1554
1623
  }
1555
1624
 
1556
- function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
1557
- const record = getRecord(args);
1558
- const command = safeString(record?.command);
1559
- const targets: ExtractedTargets = { paths: [], unknown: false };
1560
- if (!command.trim()) {
1561
- targets.unknown = true;
1562
- return targets;
1563
- }
1564
- // A literal `sh|bash|zsh -c '<script>'` payload is a real command list. Its
1565
- // mutations never match the top-level patterns, so recurse into it; anything
1566
- // deeper than a bounded depth is not statically analyzable and fails closed.
1567
- for (const match of command.matchAll(BASH_NESTED_SHELL_RE)) {
1568
- if (depth >= 2) {
1569
- targets.explicitMutation = true;
1570
- targets.unknown = true;
1571
- break;
1572
- }
1573
- const nested = extractBashTargets({ command: match[2] ?? match[4] ?? "" }, depth + 1);
1574
- for (const nestedPath of nested.paths) addPath(targets, nestedPath);
1575
- if (nested.unknown) targets.unknown = true;
1576
- if (nested.explicitMutation) targets.explicitMutation = true;
1625
+ const BASH_SED_COMMAND_RE = new RegExp(
1626
+ `(?:^|[;&|(\\n])\\s*(?:(?:if|then|else|elif|do|in|while|until)\\s+)*(?:!\\s+)?(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?sed\\b((?:'[^']*'|"(?:\\\\.|[^"\\\\])*"|\\\\.|[^'";&|()\\n\\\\])*)`,
1627
+ "gi",
1628
+ );
1629
+ const BASH_AWK_COMMAND_RE = new RegExp(
1630
+ `(?:^|[;&|(\\n])\\s*(?:(?:if|then|else|elif|do|in)\\s+)*(?:!\\s+)?(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:awk|gawk|mawk|nawk|busybox\\s+awk)\\b((?:'[^']*'|"(?:\\\\.|[^"\\\\])*"|\\\\.|[^'";&|()\\n\\\\])*)`,
1631
+ "gi",
1632
+ );
1633
+
1634
+ /** `true` when index sits at a sed command letter, not inside a pattern. */
1635
+ function sedCommandPosition(script: string, index: number): boolean {
1636
+ if (index === 0) return true;
1637
+ return /[\s;{}\n\d$,]/.test(script[index - 1] ?? "");
1638
+ }
1639
+
1640
+ function sedWriteName(script: string, index: number): { path: string | null; next: number } {
1641
+ let cursor = index;
1642
+ while (cursor < script.length && (script[cursor] === " " || script[cursor] === "\t")) cursor++;
1643
+ const start = cursor;
1644
+ while (cursor < script.length && script[cursor] !== ";" && script[cursor] !== "\n" && script[cursor] !== "}") {
1645
+ cursor++;
1577
1646
  }
1578
- // `$(...)` / backticks are a nested command list, exactly like `sh -c`: the
1579
- // statement anchors below (`^`, `;`, `&`, `|`, newline) never see inside one,
1580
- // so `echo "$(rm -rf src/x.ts)"` would otherwise scan clean. Heredoc masking
1581
- // removes literal data-consumer bodies first; the extractor then ignores
1582
- // inert `#` tails without touching quote/escape bytes needed by extraction.
1583
- const heredoc = maskHeredocBodies(command);
1584
- for (const substitution of extractSubstitutionBodies(heredoc.masked)) {
1585
- if (depth >= 2 || !substitution.balanced) {
1586
- targets.explicitMutation = true;
1587
- targets.unknown = true;
1588
- break;
1647
+ return { path: cursor > start ? script.slice(start, cursor) : null, next: cursor };
1648
+ }
1649
+
1650
+ function sedAddressEnd(script: string, start: number): number | undefined {
1651
+ if (script[start] === "/") {
1652
+ let cursor = start + 1;
1653
+ while (cursor < script.length) {
1654
+ if (script[cursor] === "\\") {
1655
+ cursor += 2;
1656
+ continue;
1657
+ }
1658
+ if (script[cursor] === "/") return cursor + 1;
1659
+ cursor++;
1589
1660
  }
1590
- const nested = extractBashTargets({ command: substitution.body }, depth + 1);
1591
- for (const nestedPath of nested.paths) addPath(targets, nestedPath);
1592
- if (nested.unknown) targets.unknown = true;
1593
- if (nested.explicitMutation) targets.explicitMutation = true;
1594
- }
1595
- // Heredoc bodies fed to data consumers are inert document payloads; mask them
1596
- // so spec/plan text cannot fake redirections. Script-consumer bodies survive
1597
- // the mask and are still scanned as live code below.
1598
- if (heredoc.opaqueExpansion || heredoc.mutatingConsumer) {
1599
- targets.explicitMutation = true;
1600
- targets.unknown = true;
1661
+ return undefined;
1601
1662
  }
1602
- // Nested scripts were read from the raw text above. Redirection extraction
1603
- // uses the quote-preserving syntax view; command extraction uses its
1604
- // dequoted executable-word view so quoted operands and split command words
1605
- // remain visible without turning prose into syntax.
1606
- const scanned = maskQuotedSpans(heredoc.masked);
1607
- if (scanned.unmodeled) {
1608
- // A live substitution construct we cannot balance (for example a
1609
- // `case` pattern whose `)` is not the substitution close) must never
1610
- // silently hide a mutation from the scanner.
1611
- targets.explicitMutation = true;
1612
- targets.unknown = true;
1663
+ if (script[start] === "$") return start + 1;
1664
+ if (!/\d/.test(script[start] ?? "")) return undefined;
1665
+ let cursor = start + 1;
1666
+ while (/\d/.test(script[cursor] ?? "")) cursor++;
1667
+ if (script[cursor] === "~") {
1668
+ cursor++;
1669
+ while (/\d/.test(script[cursor] ?? "")) cursor++;
1613
1670
  }
1614
- if (
1615
- BASH_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked) ||
1616
- BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)
1617
- ) {
1618
- targets.explicitMutation = true;
1619
- targets.unknown = true;
1671
+ return cursor;
1672
+ }
1673
+
1674
+ function sedAddressWithModifiersEnd(script: string, start: number): number | undefined {
1675
+ let cursor = sedAddressEnd(script, start);
1676
+ if (cursor === undefined) return undefined;
1677
+ if (script[start] === "/") {
1678
+ while (script[cursor] === "I" || script[cursor] === "M") cursor++;
1620
1679
  }
1621
- // `exec 1<>file` rebinds a descriptor, so a later `>/dev/null` may not reach the device at all.
1622
- if (BASH_EXEC_REDIRECT_RE.test(scanned.masked)) {
1623
- targets.explicitMutation = true;
1624
- targets.unknown = true;
1680
+ return cursor;
1681
+ }
1682
+
1683
+ function sedRegexAddressWritePath(script: string, start: number): { path: string | null; next: number } | undefined {
1684
+ let cursor = sedAddressWithModifiersEnd(script, start);
1685
+ if (cursor === undefined) return undefined;
1686
+ while (script[cursor] === " " || script[cursor] === "\t") cursor++;
1687
+ if (script[cursor] === ",") {
1688
+ cursor++;
1689
+ while (script[cursor] === " " || script[cursor] === "\t") cursor++;
1690
+ const rangeEnd = sedAddressWithModifiersEnd(script, cursor);
1691
+ if (rangeEnd === undefined) return undefined;
1692
+ cursor = rangeEnd;
1693
+ while (script[cursor] === " " || script[cursor] === "\t") cursor++;
1625
1694
  }
1626
- for (const match of scanned.masked.matchAll(BASH_EXTENDED_WRITE_RE)) {
1627
- targets.explicitMutation = true;
1628
- const cleaned = cleanShellWord(match[1] ?? "");
1629
- if (!cleaned) targets.unknown = true;
1630
- else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
1695
+ if (script[cursor] === "!") cursor++;
1696
+ while (script[cursor] === " " || script[cursor] === "\t") cursor++;
1697
+ if ((script[cursor] !== "w" && script[cursor] !== "W") || !" \t;\n}".includes(script[cursor + 1] ?? "\n")) {
1698
+ return undefined;
1631
1699
  }
1632
- for (const match of scanned.masked.matchAll(BASH_DD_OUTPUT_RE)) {
1633
- targets.explicitMutation = true;
1634
- const parts = shellWords(match[1] ?? "").map(cleanShellWord);
1635
- // Every `of=` counts: GNU dd honors the last one, so `of=/dev/null of=real.ts` writes real.ts.
1636
- const outputs = parts.filter(part => part.startsWith("of="));
1637
- if (outputs.length === 0) targets.unknown = true;
1638
- for (const output of outputs) {
1639
- const outputPath = cleanShellWord(output.slice(3));
1640
- if (!outputPath) targets.unknown = true;
1641
- else if (!isDeviceSinkPath(outputPath)) addPath(targets, outputPath);
1700
+ return sedWriteName(script, cursor + 1);
1701
+ }
1702
+
1703
+ /**
1704
+ * Filename a sed script writes with the `w`/`W` command or an `s///w file` flag.
1705
+ * `undefined` means this text does not write. `null` means it writes without a
1706
+ * literal filename.
1707
+ */
1708
+ /**
1709
+ * GNU sed can execute shell commands: the `e` command replaces the pattern
1710
+ * space with a shell command's output and `s///e` runs the result. Neither form
1711
+ * names a file, so their write targets cannot be derived and the whole program
1712
+ * must be treated as an unclassified mutation.
1713
+ */
1714
+ function sedScriptRunsShellCommand(script: string): boolean {
1715
+ // GNU sed accepts a numeric, `$`, or regular-expression address (`/re/e cmd`).
1716
+ if (/(?:^|[;{}\n])\s*(?:(?:[0-9$]+(?:,[0-9$]+)?|\/(?:\\[\s\S]|[^\\/\n])*\/)\s*!?\s*)?e(?=[\s;}\n]|$)/.test(script))
1717
+ return true;
1718
+ return /\bs(\W)(?:\\.|(?!\1)[^\n])*?\1(?:\\.|(?!\1)[^\n])*?\1[gpiImM0-9]*e(?![A-Za-z])/.test(script);
1719
+ }
1720
+
1721
+ function sedScriptWritePath(script: string): string | null | undefined {
1722
+ if (sedScriptRunsShellCommand(script)) return null;
1723
+ let index = 0;
1724
+ let foundWrite = false;
1725
+ let foundPath: string | null | undefined;
1726
+ while (index < script.length) {
1727
+ const character = script[index] ?? "";
1728
+ if (" \t\n;{}".includes(character)) {
1729
+ index++;
1730
+ continue;
1642
1731
  }
1643
- }
1644
- for (const match of scanned.dequoted.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
1645
- const parts = shellWords(match[1] ?? "").map(cleanShellWord);
1646
- const hasInPlaceFlag = parts.some(part => /^-.*i/.test(part));
1647
- if (!hasInPlaceFlag) continue;
1648
- targets.explicitMutation = true;
1649
- const target = [...parts].reverse().find(part => part && !part.startsWith("-"));
1650
- if (target) addPath(targets, target);
1651
- else targets.unknown = true;
1652
- }
1653
- for (const match of scanned.masked.matchAll(BASH_MUTATION_COMMAND_RE)) {
1654
- const redirected = match[2]?.trim();
1655
- if (!redirected) continue;
1656
- targets.explicitMutation = true;
1657
- const cleaned = cleanShellWord(redirected);
1658
- // A capture that dequotes to nothing (e.g. `>" src.ts"`) is a truncated parse, not a safe no-op.
1659
- if (!cleaned) targets.unknown = true;
1660
- else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
1661
- }
1662
- for (const match of scanned.dequoted.matchAll(BASH_MUTATION_COMMAND_RE)) {
1663
- // Redirections are extracted from the quote-preserving syntax view above.
1664
- // The dequoted view exists so quote-split command words (`r"m"`) and
1665
- // quoted operands (`rm "src/product.ts"`) still reach this command branch.
1666
- if (match[2]) continue;
1667
- targets.explicitMutation = true;
1668
- const parts = shellWords(match[1] ?? "");
1669
- const commandName = match[0]?.match(BASH_MUTATION_COMMAND_NAME_RE)?.[1]?.toLowerCase();
1670
- const targetParts =
1671
- commandName === "cp" || commandName === "mv" || commandName === "install" || commandName === "truncate"
1672
- ? parts.slice(-1)
1673
- : parts;
1674
- let extractedTarget = false;
1675
- for (const part of targetParts) {
1676
- const cleaned = cleanShellWord(part);
1677
- if (!cleaned || cleaned.startsWith("-")) continue;
1678
- // Redirection/heredoc operator words (`>/dev/null`, `<<'DOC'`, `2>&1`) are
1679
- // not argument paths; their targets are captured by the redirect scanners.
1680
- if (/^\d*[<>]/.test(cleaned)) continue;
1681
- addPath(targets, cleaned);
1682
- extractedTarget = true;
1732
+ if (character === "/" && sedCommandPosition(script, index)) {
1733
+ const written = sedRegexAddressWritePath(script, index);
1734
+ if (written) {
1735
+ if (foundWrite) return null;
1736
+ foundWrite = true;
1737
+ foundPath = written.path;
1738
+ index = Math.max(written.next, index + 1);
1739
+ continue;
1740
+ }
1683
1741
  }
1684
- // A recognized mutator with no attributable operand is not a safe no-op:
1685
- // quote masking, unsupported expansions, and option-only invocations all
1686
- // fail closed rather than allowing a targetless mutation through planning.
1687
- if (!extractedTarget) targets.unknown = true;
1742
+ if ((character === "w" || character === "W") && sedCommandPosition(script, index)) {
1743
+ const next = script[index + 1];
1744
+ if (next === undefined || " \t;\n}".includes(next)) {
1745
+ const taken = sedWriteName(script, index + 1);
1746
+ if (foundWrite) return null;
1747
+ foundWrite = true;
1748
+ foundPath = taken.path;
1749
+ index = Math.max(taken.next, index + 1);
1750
+ continue;
1751
+ }
1752
+ }
1753
+ if (character === "s" && index + 1 < script.length) {
1754
+ const delimiter = script[index + 1] ?? "";
1755
+ if (delimiter && !/[\s\\]/.test(delimiter)) {
1756
+ index += 2;
1757
+ let fields = 1;
1758
+ while (index < script.length && fields < 3) {
1759
+ if (script[index] === "\\") {
1760
+ index += 2;
1761
+ continue;
1762
+ }
1763
+ if (script[index] === delimiter) fields++;
1764
+ index++;
1765
+ }
1766
+ while (index < script.length && script[index] !== ";" && script[index] !== "\n" && script[index] !== "}") {
1767
+ if (
1768
+ (script[index] === "w" || script[index] === "W") &&
1769
+ (script[index + 1] === " " || script[index + 1] === "\t")
1770
+ ) {
1771
+ const taken = sedWriteName(script, index + 1);
1772
+ if (foundWrite) return null;
1773
+ foundWrite = true;
1774
+ foundPath = taken.path;
1775
+ index = Math.max(taken.next, index + 1);
1776
+ break;
1777
+ }
1778
+ index++;
1779
+ }
1780
+ continue;
1781
+ }
1782
+ }
1783
+ index++;
1688
1784
  }
1689
- return targets;
1785
+ return foundWrite ? foundPath : undefined;
1690
1786
  }
1787
+
1691
1788
  /**
1692
- * Read-only github ops extract no mutation. Every other op fails closed during
1693
- * planning: remote push, PR creation, and worktree/config writes are not a
1694
- * filesystem path the planning allowlist can clear.
1789
+ * `-c`/`-e` bodies are one shell word, so a `;` inside the quotes is not a
1790
+ * statement break. The line-oriented interpreter regex stops at that `;` and
1791
+ * misses `import shutil; shutil.copy(...)`.
1695
1792
  */
1696
- function extractGithubTargets(args: unknown): ExtractedTargets {
1697
- if (isGithubReadOnlyArgs(args)) return { paths: [], unknown: false };
1698
- return { paths: [], unknown: true, explicitMutation: true };
1699
- }
1700
-
1701
- function extractTargets(tool: ToolWithEditMode, args: unknown): ExtractedTargets {
1702
- if (tool.name === "write") return extractWriteTargets(args);
1703
- if (tool.name === "ast_edit") return extractAstEditTargets(args);
1704
- if (tool.name === "edit") return extractEditTargets(args, tool);
1705
- if (tool.name === "bash") return extractBashTargets(args);
1706
- if (tool.name === "github") return extractGithubTargets(args);
1707
- return { paths: [], unknown: true };
1708
- }
1709
-
1710
- function stripSelectorBase(rawPath: string): string {
1711
- const archiveOrSqlite = rawPath.match(ARCHIVE_OR_SQLITE_BASE_RE);
1712
- if (archiveOrSqlite?.[1]) return archiveOrSqlite[1];
1713
- return rawPath;
1793
+ function interpreterStatementEnd(command: string, start: number): number {
1794
+ let index = start;
1795
+ let inSingle = false;
1796
+ let inDouble = false;
1797
+ while (index < command.length) {
1798
+ const character = command[index] ?? "";
1799
+ if (inSingle) {
1800
+ if (character === "'") inSingle = false;
1801
+ index++;
1802
+ continue;
1803
+ }
1804
+ if (inDouble) {
1805
+ if (character === "\\") {
1806
+ index += 2;
1807
+ continue;
1808
+ }
1809
+ if (character === '"') inDouble = false;
1810
+ index++;
1811
+ continue;
1812
+ }
1813
+ if (character === "'") {
1814
+ inSingle = true;
1815
+ index++;
1816
+ continue;
1817
+ }
1818
+ if (character === '"') {
1819
+ inDouble = true;
1820
+ index++;
1821
+ continue;
1822
+ }
1823
+ if (character === "\\") {
1824
+ index += 2;
1825
+ continue;
1826
+ }
1827
+ if (character === ";" || character === "&" || character === "|" || character === "\n") break;
1828
+ index++;
1829
+ }
1830
+ return index;
1714
1831
  }
1715
1832
 
1716
- function resolveRawPath(cwd: string, rawPath: string): { absolutePath?: string; unknown: boolean } {
1717
- const normalized = rawPath.trim();
1718
- if (!normalized) return { unknown: true };
1719
- if (normalized === ".") return { absolutePath: path.resolve(cwd), unknown: false };
1720
- if (normalized.startsWith("local://") || normalized.startsWith("local:/")) {
1721
- const options = LocalProtocolHandler.resolveOptions();
1722
- if (!options) return { unknown: true };
1723
- try {
1724
- return { absolutePath: resolveLocalUrlToPath(normalized, options), unknown: false };
1725
- } catch {
1726
- return { unknown: true };
1833
+ function pythonSystemCallArguments(script: string): { starts: number[]; unclassifiable: boolean } {
1834
+ const starts: number[] = [];
1835
+ let quote: "'" | '"' | null = null;
1836
+ let tripleQuote = false;
1837
+ let inComment = false;
1838
+ let unclassifiable = false;
1839
+ for (let index = 0; index < script.length; index++) {
1840
+ const character = script[index] ?? "";
1841
+ if (inComment) {
1842
+ if (character === "\n") inComment = false;
1843
+ continue;
1844
+ }
1845
+ if (quote) {
1846
+ if (character === "\\") {
1847
+ index++;
1848
+ continue;
1849
+ }
1850
+ if (tripleQuote && script.startsWith(quote.repeat(3), index)) {
1851
+ quote = null;
1852
+ tripleQuote = false;
1853
+ index += 2;
1854
+ continue;
1855
+ }
1856
+ if (!tripleQuote && character === quote) quote = null;
1857
+ continue;
1858
+ }
1859
+ if (character === "#") {
1860
+ inComment = true;
1861
+ continue;
1862
+ }
1863
+ if (character === "'" || character === '"') {
1864
+ const isFString = /(?:^|[^\w])(?:[rR]?[fF]|[fF][rR])$/.test(script.slice(0, index));
1865
+ tripleQuote = script.startsWith(character.repeat(3), index);
1866
+ quote = character;
1867
+ if (tripleQuote) {
1868
+ const close = script.indexOf(character.repeat(3), index + 3);
1869
+ const body = close < 0 ? script.slice(index + 3) : script.slice(index + 3, close);
1870
+ if (isFString && /\bos\s*\.\s*system\s*\(/.test(body)) unclassifiable = true;
1871
+ index += 2;
1872
+ } else if (isFString) {
1873
+ let close = index + 1;
1874
+ while (close < script.length) {
1875
+ if (script[close] === "\\") {
1876
+ close += 2;
1877
+ continue;
1878
+ }
1879
+ if (script[close] === character) break;
1880
+ close++;
1881
+ }
1882
+ if (/\bos\s*\.\s*system\s*\(/.test(script.slice(index + 1, close))) unclassifiable = true;
1883
+ }
1884
+ continue;
1885
+ }
1886
+ const call = script.slice(index).match(/^\bos\s*\.\s*system\s*\(/);
1887
+ if (call) {
1888
+ starts.push(index + call[0].length);
1889
+ index += call[0].length - 1;
1727
1890
  }
1728
1891
  }
1729
- if (INTERNAL_SCHEME_RE.test(normalized)) return { unknown: true };
1730
-
1731
- const basePath = stripSelectorBase(normalized);
1732
- try {
1733
- return { absolutePath: resolveToCwd(basePath, cwd), unknown: false };
1734
- } catch {
1735
- return { unknown: true };
1892
+ // `os.system` can reach the shell under another name: a captured callable
1893
+ // (`run = os.system`), a module alias (`import os as o`), a direct import alias
1894
+ // (`from os import system as run`), or any other `<obj>.system(...)`. Neither form
1895
+ // names its command here, so the shell payload is unclassifiable rather than invisible.
1896
+ const systemCalls = (script.match(/\.\s*system\s*\(/g) ?? []).length;
1897
+ const osSystemCalls = (script.match(/\bos\s*\.\s*system\s*\(/g) ?? []).length;
1898
+ if (systemCalls > osSystemCalls) unclassifiable = true;
1899
+ if (/\b[A-Za-z_]\w*\s*=\s*(?:os\s*\.\s*(?:system|popen)|subprocess\s*\.\s*\w+)\b/.test(script))
1900
+ unclassifiable = true;
1901
+ // Detect `from os import system as <name>` and then calls to `<name>(...)`
1902
+ const fromOsImport = /\bfrom\s+os\s+import\s+system\s+as\s+([A-Za-z_]\w*)\b/.exec(script);
1903
+ if (fromOsImport) {
1904
+ const importedName = fromOsImport[1];
1905
+ const importedCalls = new RegExp(`\\b${importedName}\\s*\\(`, "g");
1906
+ if (importedCalls.test(script)) {
1907
+ unclassifiable = true;
1908
+ }
1736
1909
  }
1910
+ return { starts, unclassifiable };
1911
+ }
1912
+
1913
+ function pythonSystemCommand(script: string, start: number): string | undefined {
1914
+ let index = start;
1915
+ while (/\s/.test(script[index] ?? "")) index++;
1916
+ const quote = script[index];
1917
+ if (quote !== "'" && quote !== '"') return undefined;
1918
+ const tripleQuote = script.startsWith(quote.repeat(3), index);
1919
+ index += tripleQuote ? 3 : 1;
1920
+ let value = "";
1921
+ let hasEscape = false;
1922
+ while (index < script.length) {
1923
+ if (script[index] === "\\") {
1924
+ hasEscape = true;
1925
+ index += 2;
1926
+ continue;
1927
+ }
1928
+ if (tripleQuote && script.startsWith(quote.repeat(3), index)) {
1929
+ index += 3;
1930
+ while (/\s/.test(script[index] ?? "")) index++;
1931
+ return !hasEscape && script[index] === ")" ? value : undefined;
1932
+ }
1933
+ if (!tripleQuote && script[index] === quote) {
1934
+ index++;
1935
+ while (/\s/.test(script[index] ?? "")) index++;
1936
+ return !hasEscape && script[index] === ")" ? value : undefined;
1937
+ }
1938
+ value += script[index];
1939
+ index++;
1940
+ }
1941
+ return undefined;
1737
1942
  }
1738
1943
 
1739
- function relativeGjcSegments(cwd: string, rawPath: string): string[] | null {
1740
- const { absolutePath, unknown } = resolveRawPath(cwd, rawPath);
1741
- if (unknown || !absolutePath) return null;
1742
- const relative = path.relative(path.resolve(cwd), path.resolve(absolutePath));
1743
- if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
1744
- return normalizePosix(relative).split("/").filter(Boolean);
1745
- }
1746
-
1747
- function blockedWorkflowStateSkill(cwd: string, rawPath: string): CanonicalGjcWorkflowSkill | null {
1944
+ function collectPythonSystemWrites(script: string, targets: ExtractedTargets, depth: number): void {
1945
+ const calls = pythonSystemCallArguments(script);
1946
+ if (calls.unclassifiable) {
1947
+ targets.explicitMutation = true;
1948
+ targets.unknown = true;
1949
+ }
1950
+ for (const start of calls.starts) {
1951
+ const payload = pythonSystemCommand(script, start);
1952
+ if (payload === undefined || depth >= 2) {
1953
+ targets.explicitMutation = true;
1954
+ targets.unknown = true;
1955
+ continue;
1956
+ }
1957
+ const nested = extractBashTargets({ command: payload }, depth + 1);
1958
+ for (const nestedPath of nested.paths) addPath(targets, nestedPath);
1959
+ if (nested.unknown) {
1960
+ targets.explicitMutation = true;
1961
+ targets.unknown = true;
1962
+ }
1963
+ if (nested.explicitMutation) targets.explicitMutation = true;
1964
+ }
1965
+ }
1966
+
1967
+ function cleanInterpreterCodeWord(word: string): string | undefined {
1968
+ if (!word.startsWith("$'")) {
1969
+ const fullyQuoted = (/^'[^']*'$/.test(word) || /^"(?:\\.|[^"\\])*"$/.test(word)) && word.length >= 2;
1970
+ if (word.includes("$'") && !fullyQuoted) return undefined;
1971
+ return cleanShellWord(word);
1972
+ }
1973
+ if (word.length < 3 || !word.endsWith("'")) return undefined;
1974
+
1975
+ let decoded = "";
1976
+ for (let index = 2; index < word.length - 1; index++) {
1977
+ const character = word[index] ?? "";
1978
+ if (character !== "\\") {
1979
+ if (character === "'") return undefined;
1980
+ decoded += character;
1981
+ continue;
1982
+ }
1983
+ const escapeCode = word[++index];
1984
+ if (escapeCode === undefined) return undefined;
1985
+ switch (escapeCode) {
1986
+ case "a":
1987
+ decoded += "\x07";
1988
+ break;
1989
+ case "b":
1990
+ decoded += "\b";
1991
+ break;
1992
+ case "e":
1993
+ case "E":
1994
+ decoded += "\x1b";
1995
+ break;
1996
+ case "f":
1997
+ decoded += "\f";
1998
+ break;
1999
+ case "n":
2000
+ decoded += "\n";
2001
+ break;
2002
+ case "r":
2003
+ decoded += "\r";
2004
+ break;
2005
+ case "t":
2006
+ decoded += "\t";
2007
+ break;
2008
+ case "v":
2009
+ decoded += "\v";
2010
+ break;
2011
+ case "\\":
2012
+ case "'":
2013
+ case '"':
2014
+ case "?":
2015
+ decoded += escapeCode;
2016
+ break;
2017
+ case "c": {
2018
+ const control = word[++index];
2019
+ if (control === undefined || control.charCodeAt(0) > 0x7f) return undefined;
2020
+ const code = control === "?" ? 0x7f : control.toUpperCase().charCodeAt(0) & 0x1f;
2021
+ if (code === 0) return undefined;
2022
+ decoded += String.fromCharCode(code);
2023
+ break;
2024
+ }
2025
+ case "x": {
2026
+ let digits = "";
2027
+ while (digits.length < 2 && /[\da-f]/i.test(word[index + 1] ?? "")) {
2028
+ digits += word[++index];
2029
+ }
2030
+ if (!digits) return undefined;
2031
+ const code = Number.parseInt(digits, 16);
2032
+ if (code === 0) return undefined;
2033
+ decoded += String.fromCharCode(code);
2034
+ break;
2035
+ }
2036
+ case "u":
2037
+ case "U": {
2038
+ const width = escapeCode === "u" ? 4 : 8;
2039
+ const digits = word.slice(index + 1, index + 1 + width);
2040
+ if (digits.length !== width || !new RegExp(`^[\\da-f]{${width}}$`, "i").test(digits)) return undefined;
2041
+ const code = Number.parseInt(digits, 16);
2042
+ if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return undefined;
2043
+ decoded += String.fromCodePoint(code);
2044
+ index += width;
2045
+ break;
2046
+ }
2047
+ default: {
2048
+ if (!/[0-7]/.test(escapeCode)) return undefined;
2049
+ let digits = escapeCode;
2050
+ while (digits.length < 3 && /[0-7]/.test(word[index + 1] ?? "")) {
2051
+ digits += word[++index];
2052
+ }
2053
+ const code = Number.parseInt(digits, 8);
2054
+ if (code === 0 || code > 0xff) return undefined;
2055
+ decoded += String.fromCharCode(code);
2056
+ break;
2057
+ }
2058
+ }
2059
+ }
2060
+ return decoded;
2061
+ }
2062
+
2063
+ function collectInterpreterOneLinerWrites(command: string, targets: ExtractedTargets, depth: number): void {
2064
+ const api = new RegExp(BASH_INTERPRETER_WRITE_API, "i");
2065
+ const names = new RegExp(
2066
+ `(?:^|[;&|\\n])\\s*(?:(?:if|then|else|elif|do|in|while|until)\\s+)*(?:!\\s+)?(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:busybox\\s+)?(python(?:\\d+(?:\\.\\d+)*)?|pypy(?:\\d+(?:\\.\\d+)*)?|node(?:js)?|bun|deno|ruby|jruby|perl|php(?:\\d+(?:\\.\\d+)*)?|lua(?:\\d+(?:\\.\\d+)*)?|bash|ash|sh|zsh|dash|ksh|fish)\\b`,
2067
+ "gi",
2068
+ );
2069
+ for (const match of command.matchAll(names)) {
2070
+ const start = (match.index ?? 0) + match[0].length;
2071
+ const tail = command.slice(start, interpreterStatementEnd(command, start));
2072
+ const words = shellWords(tail);
2073
+ const parts = words.map(cleanShellWord);
2074
+ const binary = (match[1] ?? "").toLowerCase();
2075
+ const options = interpreterCodeOptions(binary);
2076
+ for (let index = 0; index < parts.length; index++) {
2077
+ const part = parts[index] ?? "";
2078
+ let rawScript: string | undefined;
2079
+ const option = options.find(
2080
+ candidate =>
2081
+ part === candidate || (candidate.length === 2 && part.startsWith(candidate) && part.length > 2),
2082
+ );
2083
+ if (option && part === option) {
2084
+ rawScript = words[++index];
2085
+ } else if (option) {
2086
+ const rawWord = words[index] ?? "";
2087
+ rawScript = rawWord.startsWith("-") ? rawWord.slice(option.length) : part.slice(option.length);
2088
+ } else {
2089
+ const longOption = options.find(
2090
+ candidate => candidate.startsWith("--") && part.startsWith(`${candidate}=`),
2091
+ );
2092
+ if (!longOption) continue;
2093
+ rawScript = words[index]?.slice(longOption.length + 1);
2094
+ }
2095
+ if (rawScript === undefined) continue;
2096
+ // Node preload options (-r/--require) load external files that cannot be scanned,
2097
+ // so fail closed when encountered.
2098
+ if ((option === "-r" || option === "--require") && /^(?:node|bun)$/.test(binary)) {
2099
+ targets.explicitMutation = true;
2100
+ targets.unknown = true;
2101
+ return;
2102
+ }
2103
+ const script = cleanInterpreterCodeWord(rawScript);
2104
+ if (
2105
+ script === undefined ||
2106
+ (!rawScript.startsWith("$'") && shellWordHasExpansion(rawScript)) ||
2107
+ containsOpaqueInterpreterSource(script, binary)
2108
+ ) {
2109
+ targets.explicitMutation = true;
2110
+ targets.unknown = true;
2111
+ return;
2112
+ }
2113
+ if (/^(?:bash|ash|sh|zsh|dash|ksh|fish)$/.test(binary)) {
2114
+ if (depth >= 2) {
2115
+ targets.explicitMutation = true;
2116
+ targets.unknown = true;
2117
+ return;
2118
+ }
2119
+ const nested = extractBashTargets({ command: script }, depth + 1);
2120
+ for (const nestedPath of nested.paths) addPath(targets, nestedPath);
2121
+ if (nested.unknown) targets.unknown = true;
2122
+ if (nested.explicitMutation) targets.explicitMutation = true;
2123
+ continue;
2124
+ }
2125
+ const language = /^(?:python|pypy)/.test(binary)
2126
+ ? "py"
2127
+ : /^(?:node|bun|deno)$/.test(binary)
2128
+ ? "js"
2129
+ : /^(?:ruby|jruby)$/.test(binary)
2130
+ ? "ruby"
2131
+ : "perl";
2132
+ if (/^(?:python|pypy)/.test(binary)) {
2133
+ scanInterpreterWrites(script, "py", targets);
2134
+ if (
2135
+ interpreterCodeContainsWriteApi(script, "py", new RegExp(BASH_INTERPRETER_WRITE_API_EXCLUDING_OPEN, "i"))
2136
+ ) {
2137
+ targets.explicitMutation = true;
2138
+ targets.unknown = true;
2139
+ }
2140
+ collectPythonSystemWrites(script, targets, depth);
2141
+ continue;
2142
+ }
2143
+ if (interpreterCodeContainsWriteApi(script, language, api)) {
2144
+ targets.explicitMutation = true;
2145
+ targets.unknown = true;
2146
+ return;
2147
+ }
2148
+ }
2149
+ }
2150
+ }
2151
+
2152
+ function interpreterCodeOptions(binary: string): string[] {
2153
+ if (/^(?:python|pypy)/.test(binary)) return ["-c"];
2154
+ if (/^(?:node|bun)$/.test(binary)) return ["-r", "--require", "-e", "--eval", "-p", "--print"];
2155
+ if (binary === "deno") return ["eval", "-e", "--eval"];
2156
+ if (/^(?:ruby|jruby|perl|lua)/.test(binary)) return ["-e"];
2157
+ if (/^php/.test(binary)) return ["-r"];
2158
+ if (/^(?:bash|ash|sh|zsh|dash|ksh|fish)$/.test(binary)) return ["-c"];
2159
+ return [];
2160
+ }
2161
+
2162
+ function containsOpaqueInterpreterSource(source: string, binary: string): boolean {
2163
+ return (
2164
+ (!/^(?:python|pypy)/.test(binary) && INTERPRETER_DYNAMIC_EXECUTION_RE.test(source)) ||
2165
+ INTERPRETER_FILESYSTEM_REQUIRE_RE.test(source) ||
2166
+ INTERPRETER_NONLITERAL_REQUIRE_RE.test(source) ||
2167
+ (/^php/.test(binary) && /\bassert\b/i.test(source)) ||
2168
+ (/^(?:bash|ash|sh|zsh|dash|ksh|fish)$/.test(binary) && SHELL_DOT_SOURCE_RE.test(source))
2169
+ );
2170
+ }
2171
+
2172
+ function interpreterCodeContainsWriteApi(
2173
+ script: string,
2174
+ language: "py" | "js" | "ruby" | "perl",
2175
+ api: RegExp,
2176
+ ): boolean {
2177
+ const apiAtStart = new RegExp(`^(?:${api.source})`, "i");
2178
+ // Reflective mutation of a stream object defeats the stdout/stderr write
2179
+ // exemption: the method that runs may write to a file. Fail closed instead.
2180
+ const streamMutated =
2181
+ INTERPRETER_OUTPUT_METHOD_ASSIGNMENT_RE.test(script) || INTERPRETER_OUTPUT_STREAM_MUTATION_RE.test(script);
2182
+ let index = 0;
2183
+ while (index < script.length) {
2184
+ if (language === "py" || language === "js") {
2185
+ const next =
2186
+ language === "js" && script[index] === "`"
2187
+ ? skipJavaScriptTemplate(script, index)
2188
+ : skipNoise(script, index, language);
2189
+ if (next !== index) {
2190
+ const skipped = script.slice(index, next);
2191
+ const isInterpolated =
2192
+ (language === "js" && script[index] === "`" && skipped.includes("${")) ||
2193
+ (language === "py" &&
2194
+ /(?:^|[^\w])(?:[rR]?[fF]|[fF][rR])$/.test(script.slice(Math.max(0, index - 3), index)) &&
2195
+ skipped.includes("{"));
2196
+ if (
2197
+ isInterpolated &&
2198
+ (api.test(skipped) ||
2199
+ (language === "py" && PYTHON_DYNAMIC_EXECUTION_TOKEN_RE.test(skipped)) ||
2200
+ (language === "js" && JAVASCRIPT_DYNAMIC_EXECUTION_TOKEN_RE.test(skipped)))
2201
+ )
2202
+ return true;
2203
+ index = next;
2204
+ continue;
2205
+ }
2206
+ } else {
2207
+ const next = skipOtherInterpreterNoise(script, index);
2208
+ if (next !== index) {
2209
+ const skipped = script.slice(index, next);
2210
+ if (language === "ruby" && script[index] !== "'" && skipped.includes("#{") && api.test(skipped))
2211
+ return true;
2212
+ index = next;
2213
+ continue;
2214
+ }
2215
+ }
2216
+ if (INTERPRETER_OUTPUT_METHOD_ASSIGNMENT_RE.test(script.slice(index))) return true;
2217
+ if (language === "py" && PYTHON_DYNAMIC_EXECUTION_IDENTIFIER_RE.test(script.slice(index))) return true;
2218
+ if (language === "js" && JAVASCRIPT_DYNAMIC_EXECUTION_IDENTIFIER_RE.test(script.slice(index))) return true;
2219
+ if (language === "perl" && /^open\b/.test(script.slice(index))) {
2220
+ // Both `open(...)` and the list form `open H, MODE, EXPR` matter; only an
2221
+ // explicit read mode is safe, and an unparsed open fails closed.
2222
+ if (!isReadOnlyPerlOpen(script, index)) return true;
2223
+ index += "open".length;
2224
+ continue;
2225
+ }
2226
+ if (apiAtStart.test(script.slice(index))) {
2227
+ const outputWrite =
2228
+ !streamMutated &&
2229
+ /^\.\s*write\s*\(/.test(script.slice(index)) &&
2230
+ /\b(?:process|Bun)\s*\.\s*(?:stdout|stderr)\s*$/.test(script.slice(0, index));
2231
+ // Aliased mutation: if process.stdout is passed through a function or parameter,
2232
+ // it may be rebound. Fail closed when there are function definitions or calls
2233
+ // that could capture and mutate the stream object.
2234
+ const hasAliasVectors =
2235
+ outputWrite &&
2236
+ /[([]\s*[A-Za-z_]\w*\s*=>|\([^)]*(?:process|Bun)\s*\.\s*(?:stdout|stderr)[^)]*\)/.test(script);
2237
+ if (!outputWrite || hasAliasVectors) return true;
2238
+ }
2239
+ index += 1;
2240
+ }
2241
+ return false;
2242
+ }
2243
+
2244
+ /**
2245
+ * Perl `open` accepts two spellings — `open(H, MODE, EXPR)` and the list form
2246
+ * `open H, MODE, EXPR` — and only an explicit read mode is safe. Any other shape
2247
+ * (write/pipe modes, a variable mode, or an unparsed statement) fails closed.
2248
+ */
2249
+ function isReadOnlyPerlOpen(script: string, index: number): boolean {
2250
+ const rest = script.slice(index);
2251
+ if (!/^open\b/.test(rest)) return false;
2252
+ const parenthesized = /^open\s*\(/.exec(rest);
2253
+ const body = parenthesized ? rest.slice(parenthesized[0].length) : rest.slice("open".length);
2254
+ const statementEnd = body.search(/[;\n}]/);
2255
+ const statement = statementEnd < 0 ? body : body.slice(0, statementEnd);
2256
+
2257
+ // Parse arguments properly: count commas to find the MODE position (second argument)
2258
+ // and extract only that argument.
2259
+ let depth = 0;
2260
+ let inString = false;
2261
+ let stringChar = "";
2262
+ let commaCount = 0;
2263
+ let modeStart = -1;
2264
+ let modeEnd = -1;
2265
+
2266
+ for (let i = 0; i < statement.length; i++) {
2267
+ const ch = statement[i];
2268
+
2269
+ if (inString) {
2270
+ if (ch === "\\" && i + 1 < statement.length) {
2271
+ i++; // skip escape sequence
2272
+ continue;
2273
+ }
2274
+ if (ch === stringChar) {
2275
+ inString = false;
2276
+ }
2277
+ continue;
2278
+ }
2279
+
2280
+ if (ch === "'" || ch === '"') {
2281
+ inString = true;
2282
+ stringChar = ch;
2283
+ continue;
2284
+ }
2285
+
2286
+ if (ch === "(") depth++;
2287
+ else if (ch === ")") depth--;
2288
+ else if (ch === "," && depth === 0) {
2289
+ commaCount++;
2290
+ if (commaCount === 1) {
2291
+ // This comma separates first and second arguments
2292
+ modeStart = i + 1;
2293
+ }
2294
+ if (commaCount === 2) {
2295
+ // This comma ends the mode argument
2296
+ modeEnd = i;
2297
+ break;
2298
+ }
2299
+ }
2300
+ }
2301
+
2302
+ if (modeStart === -1) return false; // Can't find MODE argument
2303
+ if (modeEnd === -1) modeEnd = statement.length;
2304
+
2305
+ const modeArg = statement.slice(modeStart, modeEnd).trim();
2306
+
2307
+ // Extract the mode value if it's a quoted string; if it's a variable, fail closed
2308
+ const quotedMode = /^(['"])(.*?)\1$/.exec(modeArg);
2309
+ if (!quotedMode) return false; // Variable mode, fail closed
2310
+
2311
+ const mode = quotedMode[2].trim();
2312
+ return mode.startsWith("<") && !mode.startsWith("<+");
2313
+ }
2314
+
2315
+ function skipJavaScriptTemplate(script: string, index: number): number {
2316
+ let cursor = index + 1;
2317
+ while (cursor < script.length) {
2318
+ if (script[cursor] === "\\") {
2319
+ cursor += 2;
2320
+ continue;
2321
+ }
2322
+ if (script[cursor] === "`") return cursor + 1;
2323
+ cursor += 1;
2324
+ }
2325
+ return script.length;
2326
+ }
2327
+
2328
+ function skipOtherInterpreterNoise(script: string, index: number): number {
2329
+ const character = script[index] ?? "";
2330
+ if (character === "#") {
2331
+ const end = script.indexOf("\n", index);
2332
+ return end < 0 ? script.length : end;
2333
+ }
2334
+ if (character !== "'" && character !== '"' && character !== "`") return index;
2335
+ let cursor = index + 1;
2336
+ while (cursor < script.length) {
2337
+ if (script[cursor] === "\\") {
2338
+ cursor += 2;
2339
+ continue;
2340
+ }
2341
+ if (script[cursor] === character) return cursor + 1;
2342
+ cursor += 1;
2343
+ }
2344
+ return script.length;
2345
+ }
2346
+
2347
+ function collectSedScriptWrites(command: string, targets: ExtractedTargets): void {
2348
+ for (const match of command.matchAll(BASH_SED_COMMAND_RE)) {
2349
+ const matchIndex = match.index ?? 0;
2350
+ if (!shellSyntaxPosition(command, matchIndex)) continue;
2351
+ const words = shellWords(match[1] ?? "").map(cleanShellWord);
2352
+ const parsed = sedScriptOperands(words);
2353
+ if (parsed.unknown) {
2354
+ targets.explicitMutation = true;
2355
+ targets.unknown = true;
2356
+ }
2357
+ for (const script of parsed.scripts) {
2358
+ const writePath = sedScriptWritePath(script);
2359
+ if (writePath === undefined) continue;
2360
+ targets.explicitMutation = true;
2361
+ if (writePath && !isDeviceSinkPath(writePath)) addPath(targets, writePath);
2362
+ else if (!writePath) targets.unknown = true;
2363
+ }
2364
+ }
2365
+ }
2366
+
2367
+ function sedScriptOperands(words: string[]): { scripts: string[]; unknown: boolean } {
2368
+ const scripts: string[] = [];
2369
+ let hasBareScript = false;
2370
+ let unknown = false;
2371
+ for (let index = 0; index < words.length; index++) {
2372
+ const word = words[index] ?? "";
2373
+ if (!word) continue;
2374
+ if (word === "--") {
2375
+ if (!hasBareScript && scripts.length === 0 && words[index + 1]) scripts.push(words[index + 1] ?? "");
2376
+ break;
2377
+ }
2378
+ if (word === "-e" || word === "--expression") {
2379
+ const script = words[index + 1];
2380
+ if (script) scripts.push(script);
2381
+ index += 1;
2382
+ continue;
2383
+ }
2384
+ if (word.startsWith("--expression=")) {
2385
+ scripts.push(word.slice("--expression=".length));
2386
+ continue;
2387
+ }
2388
+ if (word.startsWith("-e") && word.length > 2) {
2389
+ scripts.push(word.slice(2));
2390
+ continue;
2391
+ }
2392
+ if (word === "-f" || word === "--file") {
2393
+ // The script contents are not present in the command, so their write behavior is opaque.
2394
+ unknown = true;
2395
+ index += 1;
2396
+ continue;
2397
+ }
2398
+ if (word.startsWith("--file=") || (word.startsWith("-f") && word.length > 2)) {
2399
+ unknown = true;
2400
+ continue;
2401
+ }
2402
+ if (word === "-l" || word === "--line-length") {
2403
+ index += 1;
2404
+ continue;
2405
+ }
2406
+ if (word.startsWith("-")) continue;
2407
+ if (!hasBareScript && scripts.length === 0) {
2408
+ scripts.push(word);
2409
+ hasBareScript = true;
2410
+ }
2411
+ }
2412
+ return { scripts, unknown };
2413
+ }
2414
+
2415
+ function shellSyntaxPosition(command: string, targetIndex: number, allowComment = false): boolean {
2416
+ let quote: "'" | '"' | null = null;
2417
+ let inComment = false;
2418
+ let previous = "";
2419
+ for (let index = 0; index < targetIndex; index++) {
2420
+ const character = command[index] ?? "";
2421
+ if (inComment) {
2422
+ if (character === "\n") {
2423
+ inComment = false;
2424
+ previous = character;
2425
+ }
2426
+ continue;
2427
+ }
2428
+ if (quote === "'") {
2429
+ if (character === "'") quote = null;
2430
+ continue;
2431
+ }
2432
+ if (quote === '"') {
2433
+ if (character === "\\") {
2434
+ index++;
2435
+ continue;
2436
+ }
2437
+ if (character === '"') quote = null;
2438
+ continue;
2439
+ }
2440
+ if (character === "\\") {
2441
+ index++;
2442
+ continue;
2443
+ }
2444
+ if (character === "'" || character === '"') {
2445
+ quote = character;
2446
+ continue;
2447
+ }
2448
+ if (character === "#" && (previous === "" || /\s/.test(previous) || ";|&()".includes(previous))) {
2449
+ inComment = true;
2450
+ continue;
2451
+ }
2452
+ previous = character;
2453
+ }
2454
+ if (command[targetIndex] === "\n" && inComment) return quote === null;
2455
+ return quote === null && (allowComment || !inComment);
2456
+ }
2457
+
2458
+ function awkRegexCanStart(tokens: AwkToken[]): boolean {
2459
+ const previous = tokens.at(-1);
2460
+ if (!previous) return true;
2461
+ if (previous.kind === "separator") return true;
2462
+ return (
2463
+ (previous.kind === "symbol" &&
2464
+ ["{", "(", "[", ",", "?", ":", "=", "==", "!=", "!", "&&", "||", "~", "!~"].includes(previous.value)) ||
2465
+ (previous.kind === "word" && ["print", "printf", "return"].includes(previous.value))
2466
+ );
2467
+ }
2468
+
2469
+ function awkRegexEnd(program: string, start: number): number | undefined {
2470
+ let inCharacterClass = false;
2471
+ for (let index = start + 1; index < program.length; index++) {
2472
+ const character = program[index] ?? "";
2473
+ if (character === "\\") {
2474
+ index++;
2475
+ continue;
2476
+ }
2477
+ if (character === "[" && !inCharacterClass) inCharacterClass = true;
2478
+ else if (character === "]" && inCharacterClass) inCharacterClass = false;
2479
+ else if (character === "/" && !inCharacterClass) return index + 1;
2480
+ else if (character === "\n") return undefined;
2481
+ }
2482
+ return undefined;
2483
+ }
2484
+
2485
+ interface AwkToken {
2486
+ kind: "word" | "string" | "symbol" | "separator";
2487
+ value: string;
2488
+ unsafeEscapes?: boolean;
2489
+ }
2490
+
2491
+ function tokenizeAwkProgram(program: string): { tokens: AwkToken[]; balanced: boolean } {
2492
+ const tokens: AwkToken[] = [];
2493
+ let balanced = true;
2494
+ for (let index = 0; index < program.length; ) {
2495
+ const character = program[index] ?? "";
2496
+ if (character === "\n") {
2497
+ tokens.push({ kind: "separator", value: character });
2498
+ index++;
2499
+ continue;
2500
+ }
2501
+ if (/\s/.test(character)) {
2502
+ index++;
2503
+ continue;
2504
+ }
2505
+ if (character === "#") {
2506
+ while (index < program.length && program[index] !== "\n") index++;
2507
+ continue;
2508
+ }
2509
+ if (character === '"') {
2510
+ let value = "";
2511
+ let unsafeEscapes = false;
2512
+ let closed = false;
2513
+ index++;
2514
+ while (index < program.length) {
2515
+ const next = program[index] ?? "";
2516
+ if (next === "\\") {
2517
+ const escapedChar = program[index + 1] ?? "";
2518
+ if (escapedChar === "\\" || escapedChar === '"') {
2519
+ value += escapedChar;
2520
+ index += 2;
2521
+ continue;
2522
+ }
2523
+ switch (escapedChar) {
2524
+ case "a":
2525
+ value += "\u0007";
2526
+ index += 2;
2527
+ continue;
2528
+ case "b":
2529
+ value += "\b";
2530
+ index += 2;
2531
+ continue;
2532
+ case "f":
2533
+ value += "\f";
2534
+ index += 2;
2535
+ continue;
2536
+ case "n":
2537
+ value += "\n";
2538
+ index += 2;
2539
+ continue;
2540
+ case "r":
2541
+ value += "\r";
2542
+ index += 2;
2543
+ continue;
2544
+ case "t":
2545
+ value += "\t";
2546
+ index += 2;
2547
+ continue;
2548
+ case "v":
2549
+ value += "\u000b";
2550
+ index += 2;
2551
+ continue;
2552
+ }
2553
+ if (/[0-7]/.test(escapedChar)) {
2554
+ let end = index + 1;
2555
+ while (end < program.length && end < index + 4 && /[0-7]/.test(program[end] ?? "")) end++;
2556
+ value += String.fromCharCode(Number.parseInt(program.slice(index + 1, end), 8));
2557
+ index = end;
2558
+ continue;
2559
+ }
2560
+ if (escapedChar === "x") {
2561
+ let end = index + 2;
2562
+ while (end < program.length && /[\da-f]/i.test(program[end] ?? "")) end++;
2563
+ const hex = program.slice(index + 2, end);
2564
+ const codePoint = hex ? Number.parseInt(hex, 16) : Number.NaN;
2565
+ if (hex && codePoint <= 0xffff) value += String.fromCharCode(codePoint);
2566
+ else unsafeEscapes = true;
2567
+ index = end;
2568
+ continue;
2569
+ }
2570
+ unsafeEscapes = true;
2571
+ if (escapedChar) value += escapedChar;
2572
+ index += escapedChar ? 2 : 1;
2573
+ continue;
2574
+ }
2575
+ if (next === '"') {
2576
+ closed = true;
2577
+ index++;
2578
+ break;
2579
+ }
2580
+ value += next;
2581
+ index++;
2582
+ }
2583
+ if (!closed) balanced = false;
2584
+ tokens.push({ kind: "string", value, unsafeEscapes });
2585
+ continue;
2586
+ }
2587
+ if (character === "/" && awkRegexCanStart(tokens)) {
2588
+ const end = awkRegexEnd(program, index);
2589
+ if (end === undefined) balanced = false;
2590
+ else {
2591
+ tokens.push({ kind: "symbol", value: "regex" });
2592
+ index = end;
2593
+ continue;
2594
+ }
2595
+ }
2596
+ if (/[A-Za-z_]/.test(character)) {
2597
+ const start = index++;
2598
+ while (/[A-Za-z0-9_]/.test(program[index] ?? "")) index++;
2599
+ tokens.push({ kind: "word", value: program.slice(start, index) });
2600
+ continue;
2601
+ }
2602
+ if (character === ";") {
2603
+ tokens.push({ kind: "separator", value: character });
2604
+ index++;
2605
+ continue;
2606
+ }
2607
+ const pair = program.slice(index, index + 2);
2608
+ const operator = [">>", ">=", "==", "!=", "<=", "&&", "||", "!~", "++", "--"].includes(pair) ? pair : character;
2609
+ tokens.push({ kind: "symbol", value: operator });
2610
+ index += operator.length;
2611
+ }
2612
+ return { tokens, balanced };
2613
+ }
2614
+
2615
+ interface AwkProgramSource {
2616
+ source: string;
2617
+ expanded: boolean;
2618
+ }
2619
+
2620
+ function shellWordHasExpansion(word: string): boolean {
2621
+ let quote: "'" | '"' | null = null;
2622
+ for (let index = 0; index < word.length; index++) {
2623
+ const character = word[index] ?? "";
2624
+ if (quote === "'") {
2625
+ if (character === "'") quote = null;
2626
+ continue;
2627
+ }
2628
+ if (quote === '"') {
2629
+ if (character === "\\") {
2630
+ index++;
2631
+ continue;
2632
+ }
2633
+ if (character === '"') quote = null;
2634
+ else if (character === "$" || character === "`") return true;
2635
+ continue;
2636
+ }
2637
+ if (character === "\\") {
2638
+ index++;
2639
+ continue;
2640
+ }
2641
+ if (character === "'" || character === '"') quote = character;
2642
+ else if (character === "$" || character === "`") return true;
2643
+ }
2644
+ return quote !== null;
2645
+ }
2646
+
2647
+ function awkPrograms(words: string[]): { programs: AwkProgramSource[]; unknown: boolean } {
2648
+ const programs: AwkProgramSource[] = [];
2649
+ const noOperandOptions = new Set([
2650
+ "-b",
2651
+ "-c",
2652
+ "-C",
2653
+ "-g",
2654
+ "-h",
2655
+ "-M",
2656
+ "-n",
2657
+ "-N",
2658
+ "-O",
2659
+ "-P",
2660
+ "-r",
2661
+ "-s",
2662
+ "-S",
2663
+ "-t",
2664
+ "-V",
2665
+ "--characters-as-bytes",
2666
+ "--csv",
2667
+ "--help",
2668
+ "--lint",
2669
+ "--lint-no-ext",
2670
+ "--lint-old",
2671
+ "--posix",
2672
+ "--sandbox",
2673
+ "--traditional",
2674
+ "--version",
2675
+ ]);
2676
+ let unknown = false;
2677
+ let hasFileProgram = false;
2678
+ let hasSourceOption = false;
2679
+ const programSource = (raw: string): AwkProgramSource => ({
2680
+ source: cleanShellWord(raw),
2681
+ expanded: shellWordHasExpansion(raw),
2682
+ });
2683
+ const operand = (index: number): string | undefined => {
2684
+ const next = words[index + 1];
2685
+ if (!next) {
2686
+ unknown = true;
2687
+ return undefined;
2688
+ }
2689
+ return next;
2690
+ };
2691
+ for (let index = 0; index < words.length; index++) {
2692
+ const word = words[index];
2693
+ if (!word) continue;
2694
+ if (/^(?:\d+)?(?:>>?|<<<?|&>)/.test(word)) {
2695
+ if (/^(?:\d+)?(?:>>?|<<<?)$/.test(word)) index++;
2696
+ continue;
2697
+ }
2698
+ const part = cleanShellWord(word);
2699
+ if (part === "--") {
2700
+ if (hasFileProgram || hasSourceOption) break;
2701
+ const program = words[index + 1];
2702
+ if (!program) unknown = true;
2703
+ else programs.push(programSource(program));
2704
+ break;
2705
+ }
2706
+ if (part === "-e" || part === "--source") {
2707
+ const program = operand(index);
2708
+ if (program) programs.push(programSource(program));
2709
+ hasSourceOption = true;
2710
+ index++;
2711
+ continue;
2712
+ }
2713
+ if (part.startsWith("--source=")) {
2714
+ programs.push(programSource(part.slice("--source=".length)));
2715
+ hasSourceOption = true;
2716
+ continue;
2717
+ }
2718
+ if (part === "-v" || part === "--assign" || part === "-F" || part === "--field-separator") {
2719
+ if (!operand(index)) continue;
2720
+ index++;
2721
+ continue;
2722
+ }
2723
+ if (part === "-W") {
2724
+ const extension = operand(index);
2725
+ if (extension !== "version" && extension !== "lint" && extension !== "posix") {
2726
+ unknown = true;
2727
+ break;
2728
+ }
2729
+ index++;
2730
+ continue;
2731
+ }
2732
+ if (part.startsWith("-W")) {
2733
+ const extension = part.slice(2);
2734
+ if (extension !== "version" && extension !== "lint" && extension !== "posix") {
2735
+ unknown = true;
2736
+ break;
2737
+ }
2738
+ continue;
2739
+ }
2740
+ if (part.startsWith("--assign=") || part.startsWith("--field-separator=")) continue;
2741
+ if (part === "-f" || part === "--file" || part === "-E") {
2742
+ if (!operand(index)) continue;
2743
+ hasFileProgram = true;
2744
+ index++;
2745
+ continue;
2746
+ }
2747
+ if (part.startsWith("--file=") || (part.startsWith("-f") && part.length > 2)) {
2748
+ hasFileProgram = true;
2749
+ continue;
2750
+ }
2751
+ if (part.startsWith("-e") && part.length > 2) {
2752
+ programs.push(programSource(part.slice(2)));
2753
+ hasSourceOption = true;
2754
+ continue;
2755
+ }
2756
+ if (part.startsWith("-v") && part.length > 2) continue;
2757
+ if (part.startsWith("-F") && part.length > 2) continue;
2758
+ if (part.startsWith("-") && noOperandOptions.has(part)) continue;
2759
+ if (part.startsWith("-") && /^-[bcCghMnNPrStV]+$/.test(part)) continue;
2760
+ if (part.startsWith("-")) {
2761
+ unknown = true;
2762
+ break;
2763
+ }
2764
+ if (hasFileProgram || hasSourceOption) break;
2765
+ programs.push(programSource(word));
2766
+ break;
2767
+ }
2768
+ if (hasFileProgram) unknown = true;
2769
+ return { programs, unknown };
2770
+ }
2771
+
2772
+ function analyzeAwkProgram(program: string): { paths: string[]; unknown: boolean } {
2773
+ const { tokens, balanced } = tokenizeAwkProgram(program);
2774
+ const paths: string[] = [];
2775
+ let unknown =
2776
+ !balanced ||
2777
+ tokens.some(
2778
+ (token, index) => token.kind === "word" && token.value === "system" && tokens[index + 1]?.value === "(",
2779
+ );
2780
+ let braceDepth = 0;
2781
+ let parenDepth = 0;
2782
+ let bracketDepth = 0;
2783
+ let statementStart = true;
2784
+ let pendingControlParen = false;
2785
+ const controlParens: boolean[] = [];
2786
+ let printStatement: { braceDepth: number; parenDepth: number; bracketDepth: number } | undefined;
2787
+ for (let index = 0; index < tokens.length; index++) {
2788
+ const token = tokens[index];
2789
+ if (!token) continue;
2790
+ if (token.kind === "separator") {
2791
+ if (parenDepth === 0 && bracketDepth === 0) {
2792
+ printStatement = undefined;
2793
+ statementStart = true;
2794
+ pendingControlParen = false;
2795
+ }
2796
+ continue;
2797
+ }
2798
+ if (token.kind === "string" || token.kind === "word") {
2799
+ if (token.kind === "word" && statementStart && (token.value === "print" || token.value === "printf")) {
2800
+ printStatement = { braceDepth, parenDepth, bracketDepth };
2801
+ statementStart = false;
2802
+ pendingControlParen = false;
2803
+ continue;
2804
+ }
2805
+ if (token.kind === "word" && statementStart && ["for", "if", "while"].includes(token.value)) {
2806
+ pendingControlParen = true;
2807
+ statementStart = false;
2808
+ continue;
2809
+ }
2810
+ if (token.kind === "word" && statementStart && ["do", "else"].includes(token.value)) {
2811
+ statementStart = true;
2812
+ pendingControlParen = false;
2813
+ continue;
2814
+ }
2815
+ statementStart = false;
2816
+ pendingControlParen = false;
2817
+ continue;
2818
+ }
2819
+ if (token.value === "{") {
2820
+ braceDepth++;
2821
+ printStatement = undefined;
2822
+ statementStart = true;
2823
+ pendingControlParen = false;
2824
+ continue;
2825
+ }
2826
+ if (token.value === "}") {
2827
+ braceDepth--;
2828
+ if (braceDepth < 0) unknown = true;
2829
+ printStatement = undefined;
2830
+ statementStart = true;
2831
+ pendingControlParen = false;
2832
+ continue;
2833
+ }
2834
+ if (token.value === "(") {
2835
+ controlParens.push(pendingControlParen);
2836
+ pendingControlParen = false;
2837
+ parenDepth++;
2838
+ statementStart = false;
2839
+ continue;
2840
+ }
2841
+ if (token.value === ")") {
2842
+ parenDepth--;
2843
+ if (parenDepth < 0) unknown = true;
2844
+ statementStart = controlParens.pop() ?? false;
2845
+ continue;
2846
+ }
2847
+ if (token.value === "[") {
2848
+ bracketDepth++;
2849
+ statementStart = false;
2850
+ continue;
2851
+ }
2852
+ if (token.value === "]") {
2853
+ bracketDepth--;
2854
+ if (bracketDepth < 0) unknown = true;
2855
+ statementStart = false;
2856
+ continue;
2857
+ }
2858
+ if (token.value === ">" || token.value === ">>") {
2859
+ if (
2860
+ printStatement &&
2861
+ braceDepth === printStatement.braceDepth &&
2862
+ parenDepth === printStatement.parenDepth &&
2863
+ bracketDepth === printStatement.bracketDepth
2864
+ ) {
2865
+ const destination = tokens[index + 1];
2866
+ const trailing = tokens[index + 2];
2867
+ if (
2868
+ destination?.kind !== "string" ||
2869
+ destination.unsafeEscapes ||
2870
+ (destination.value !== "" && trailing && trailing.kind !== "separator" && trailing.value !== "}")
2871
+ ) {
2872
+ unknown = true;
2873
+ } else if (destination.value) {
2874
+ paths.push(destination.value);
2875
+ } else {
2876
+ unknown = true;
2877
+ }
2878
+ }
2879
+ statementStart = false;
2880
+ continue;
2881
+ }
2882
+ if (token.value === "|") {
2883
+ // AWK pipe I/O executes a command; print, printf, and getline effects are opaque.
2884
+ unknown = true;
2885
+ statementStart = false;
2886
+ continue;
2887
+ }
2888
+ if (token.value === ";" && parenDepth === 0 && bracketDepth === 0) {
2889
+ printStatement = undefined;
2890
+ statementStart = true;
2891
+ pendingControlParen = false;
2892
+ continue;
2893
+ }
2894
+ statementStart = false;
2895
+ pendingControlParen = false;
2896
+ }
2897
+ if (braceDepth !== 0 || parenDepth !== 0 || bracketDepth !== 0 || controlParens.length > 0) unknown = true;
2898
+ return { paths, unknown };
2899
+ }
2900
+
2901
+ function collectAwkProgramWrites(command: string, targets: ExtractedTargets): void {
2902
+ for (const match of command.matchAll(BASH_AWK_COMMAND_RE)) {
2903
+ const matchIndex = match.index ?? 0;
2904
+ if (!shellSyntaxPosition(command, matchIndex)) continue;
2905
+ const matchEnd = matchIndex + match[0].length;
2906
+ const { programs, unknown } = awkPrograms(shellWords(match[1] ?? ""));
2907
+ if (!shellSyntaxPosition(command, matchEnd, true) || unknown) {
2908
+ targets.explicitMutation = true;
2909
+ targets.unknown = true;
2910
+ }
2911
+ for (const program of programs) {
2912
+ if (program.expanded) {
2913
+ targets.explicitMutation = true;
2914
+ targets.unknown = true;
2915
+ }
2916
+ const analyzed = analyzeAwkProgram(program.source);
2917
+ if (analyzed.unknown) {
2918
+ targets.explicitMutation = true;
2919
+ targets.unknown = true;
2920
+ }
2921
+ for (const destination of analyzed.paths) {
2922
+ targets.explicitMutation = true;
2923
+ if (!isDeviceSinkPath(destination)) addPath(targets, destination);
2924
+ }
2925
+ }
2926
+ }
2927
+ }
2928
+
2929
+ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
2930
+ const record = getRecord(args);
2931
+ const command = safeString(record?.command);
2932
+ const targets: ExtractedTargets = { paths: [], unknown: false };
2933
+ if (!command.trim()) {
2934
+ targets.unknown = true;
2935
+ return targets;
2936
+ }
2937
+ // A literal `sh|bash|zsh -c '<script>'` payload is a real command list. Its
2938
+ // mutations never match the top-level patterns, so recurse into it; anything
2939
+ // deeper than a bounded depth is not statically analyzable and fails closed.
2940
+ for (const match of command.matchAll(BASH_NESTED_SHELL_RE)) {
2941
+ if (depth >= 2) {
2942
+ targets.explicitMutation = true;
2943
+ targets.unknown = true;
2944
+ break;
2945
+ }
2946
+ const nested = extractBashTargets({ command: match[2] ?? match[4] ?? "" }, depth + 1);
2947
+ for (const nestedPath of nested.paths) addPath(targets, nestedPath);
2948
+ if (nested.unknown) targets.unknown = true;
2949
+ if (nested.explicitMutation) targets.explicitMutation = true;
2950
+ }
2951
+ // `$(...)` / backticks are a nested command list, exactly like `sh -c`: the
2952
+ // statement anchors below (`^`, `;`, `&`, `|`, newline) never see inside one,
2953
+ // so `echo "$(rm -rf src/x.ts)"` would otherwise scan clean. Heredoc masking
2954
+ // removes literal data-consumer bodies first; the extractor then ignores
2955
+ // inert `#` tails without touching quote/escape bytes needed by extraction.
2956
+ const heredoc = maskHeredocBodies(command);
2957
+ for (const substitution of extractSubstitutionBodies(heredoc.masked)) {
2958
+ if (depth >= 2 || !substitution.balanced) {
2959
+ targets.explicitMutation = true;
2960
+ targets.unknown = true;
2961
+ break;
2962
+ }
2963
+ const nested = extractBashTargets({ command: substitution.body }, depth + 1);
2964
+ for (const nestedPath of nested.paths) addPath(targets, nestedPath);
2965
+ if (nested.unknown) targets.unknown = true;
2966
+ if (nested.explicitMutation) targets.explicitMutation = true;
2967
+ }
2968
+ // Heredoc bodies fed to data consumers are inert document payloads; mask them
2969
+ // so spec/plan text cannot fake redirections. Script-consumer bodies survive
2970
+ // the mask and are still scanned as live code below.
2971
+ if (heredoc.opaqueExpansion || heredoc.mutatingConsumer) {
2972
+ targets.explicitMutation = true;
2973
+ targets.unknown = true;
2974
+ }
2975
+ // Nested scripts were read from the raw text above. Redirection extraction
2976
+ // uses the quote-preserving syntax view; command extraction uses its
2977
+ // dequoted executable-word view so quoted operands and split command words
2978
+ // remain visible without turning prose into syntax.
2979
+ const scanned = maskQuotedSpans(heredoc.masked);
2980
+ if (scanned.unmodeled) {
2981
+ // A live substitution construct we cannot balance (for example a
2982
+ // `case` pattern whose `)` is not the substitution close) must never
2983
+ // silently hide a mutation from the scanner.
2984
+ targets.explicitMutation = true;
2985
+ targets.unknown = true;
2986
+ }
2987
+ if (BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)) {
2988
+ targets.explicitMutation = true;
2989
+ targets.unknown = true;
2990
+ }
2991
+ // `exec 1<>file` rebinds a descriptor, so a later `>/dev/null` may not reach the device at all.
2992
+ if (BASH_EXEC_REDIRECT_RE.test(scanned.masked)) {
2993
+ targets.explicitMutation = true;
2994
+ targets.unknown = true;
2995
+ }
2996
+ for (const match of scanned.masked.matchAll(BASH_EXTENDED_WRITE_RE)) {
2997
+ targets.explicitMutation = true;
2998
+ const cleaned = cleanShellWord(match[1] ?? "");
2999
+ if (!cleaned) targets.unknown = true;
3000
+ else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
3001
+ }
3002
+ for (const match of scanned.masked.matchAll(BASH_DD_OUTPUT_RE)) {
3003
+ targets.explicitMutation = true;
3004
+ const parts = shellWords(match[1] ?? "").map(cleanShellWord);
3005
+ // Every `of=` counts: GNU dd honors the last one, so `of=/dev/null of=real.ts` writes real.ts.
3006
+ const outputs = parts.filter(part => part.startsWith("of="));
3007
+ if (outputs.length === 0) targets.unknown = true;
3008
+ for (const output of outputs) {
3009
+ const outputPath = cleanShellWord(output.slice(3));
3010
+ if (!outputPath) targets.unknown = true;
3011
+ else if (!isDeviceSinkPath(outputPath)) addPath(targets, outputPath);
3012
+ }
3013
+ }
3014
+ for (const match of scanned.dequoted.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
3015
+ const parts = shellWords(match[1] ?? "").map(cleanShellWord);
3016
+ const hasInPlaceFlag = parts.some(part => /^-.*i/.test(part));
3017
+ if (!hasInPlaceFlag) continue;
3018
+ targets.explicitMutation = true;
3019
+ const target = [...parts].reverse().find(part => part && !part.startsWith("-"));
3020
+ if (target) addPath(targets, target);
3021
+ else targets.unknown = true;
3022
+ }
3023
+ for (const match of scanned.masked.matchAll(BASH_MUTATION_COMMAND_RE)) {
3024
+ const redirected = match[2]?.trim();
3025
+ if (!redirected) continue;
3026
+ targets.explicitMutation = true;
3027
+ const cleaned = cleanShellWord(redirected);
3028
+ // A capture that dequotes to nothing (e.g. `>" src.ts"`) is a truncated parse, not a safe no-op.
3029
+ if (!cleaned) targets.unknown = true;
3030
+ else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
3031
+ }
3032
+ for (const match of scanned.dequoted.matchAll(BASH_MUTATION_COMMAND_RE)) {
3033
+ // Redirections are extracted from the quote-preserving syntax view above.
3034
+ // The dequoted view exists so quote-split command words (`r"m"`) and
3035
+ // quoted operands (`rm "src/product.ts"`) still reach this command branch.
3036
+ if (match[2]) continue;
3037
+ targets.explicitMutation = true;
3038
+ const parts = shellWords(match[1] ?? "");
3039
+ const commandName = match[0]?.match(BASH_MUTATION_COMMAND_NAME_RE)?.[1]?.toLowerCase();
3040
+ const chmodModeIndex =
3041
+ commandName === "chmod" ? parts.findIndex(part => BASH_CHMOD_MODE_RE.test(cleanShellWord(part))) : -1;
3042
+ let targetParts = parts;
3043
+ if (
3044
+ commandName === "cp" ||
3045
+ commandName === "mv" ||
3046
+ commandName === "install" ||
3047
+ commandName === "truncate" ||
3048
+ commandName === "ln"
3049
+ ) {
3050
+ targetParts = parts.slice(-1);
3051
+ } else if (commandName === "chmod" && chmodModeIndex >= 0) {
3052
+ targetParts = parts.filter((_part, index) => index !== chmodModeIndex);
3053
+ }
3054
+ let extractedTarget = false;
3055
+ for (const part of targetParts) {
3056
+ const cleaned = cleanShellWord(part);
3057
+ if (!cleaned || cleaned.startsWith("-")) continue;
3058
+ // Redirection/heredoc operator words (`>/dev/null`, `<<'DOC'`, `2>&1`) are
3059
+ // not argument paths; their targets are captured by the redirect scanners.
3060
+ if (/^\d*[<>]/.test(cleaned)) continue;
3061
+ addPath(targets, cleaned);
3062
+ extractedTarget = true;
3063
+ }
3064
+ // A recognized mutator with no attributable operand is not a safe no-op:
3065
+ // quote masking, unsupported expansions, and option-only invocations all
3066
+ // fail closed rather than allowing a targetless mutation through planning.
3067
+ if (!extractedTarget) targets.unknown = true;
3068
+ }
3069
+ // `sed w file` and `s///w file` write without `-i`. `awk 'print > "file"'`
3070
+ // writes inside the program, which is not a shell redirection.
3071
+ collectInterpreterOneLinerWrites(heredoc.masked, targets, depth);
3072
+ collectSedScriptWrites(heredoc.masked, targets);
3073
+ collectAwkProgramWrites(heredoc.masked, targets);
3074
+ return targets;
3075
+ }
3076
+ /**
3077
+ * Read-only github ops extract no mutation. Every other op fails closed during
3078
+ * planning: remote push, PR creation, and worktree/config writes are not a
3079
+ * filesystem path the planning allowlist can clear.
3080
+ */
3081
+ function extractGithubTargets(args: unknown): ExtractedTargets {
3082
+ if (isGithubReadOnlyArgs(args)) return { paths: [], unknown: false };
3083
+ return { paths: [], unknown: true, explicitMutation: true };
3084
+ }
3085
+
3086
+ function simpleLiteral(raw: string): string | null {
3087
+ const trimmed = raw.trim();
3088
+ const quote = trimmed[0];
3089
+ if ((quote !== "'" && quote !== '"') || trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) return null;
3090
+ const body = trimmed.slice(1, -1);
3091
+ return /[\\\n${]/.test(body) || body.includes(quote) ? null : body;
3092
+ }
3093
+
3094
+ function pythonFStringContainsDynamicExecution(code: string, start: number, end: number): boolean {
3095
+ const source = code.slice(start, end);
3096
+ const isFString = /(?:^|[^\w])(?:[rR]?[fF]|[fF][rR])$/.test(code.slice(Math.max(0, start - 3), start));
3097
+ return isFString && source.includes("{") && PYTHON_DYNAMIC_EXECUTION_TOKEN_RE.test(source);
3098
+ }
3099
+
3100
+ function skipNoise(code: string, index: number, language: "py" | "js"): number {
3101
+ if (language === "py" && (code.startsWith('"""', index) || code.startsWith("'''", index))) {
3102
+ const quote = code.slice(index, index + 3);
3103
+ const end = code.indexOf(quote, index + 3);
3104
+ return end < 0 ? code.length : end + 3;
3105
+ }
3106
+ const character = code[index] ?? "";
3107
+ if (character === "'" || character === '"') {
3108
+ let cursor = index + 1;
3109
+ while (cursor < code.length && code[cursor] !== character) cursor += code[cursor] === "\\" ? 2 : 1;
3110
+ return Math.min(code.length, cursor + 1);
3111
+ }
3112
+ if (
3113
+ (language === "py" && character === "#") ||
3114
+ (language === "js" && character === "/" && code[index + 1] === "/")
3115
+ ) {
3116
+ const end = code.indexOf("\n", index);
3117
+ return end < 0 ? code.length : end;
3118
+ }
3119
+ if (language === "js" && character === "/" && code[index + 1] === "*") {
3120
+ const end = code.indexOf("*/", index + 2);
3121
+ return end < 0 ? code.length : end + 2;
3122
+ }
3123
+ return index;
3124
+ }
3125
+
3126
+ function readArg(code: string, index: number): { raw: string; end: number } | null {
3127
+ let cursor = index;
3128
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
3129
+ if (cursor >= code.length || code[cursor] === ")") return null;
3130
+ const start = cursor;
3131
+ let depth = 0;
3132
+ while (cursor < code.length) {
3133
+ const character = code[cursor];
3134
+ if (character === "(") depth += 1;
3135
+ else if ((character === "," || character === ")") && depth === 0) break;
3136
+ else if (character === ")") depth -= 1;
3137
+ else if (character === "'" || character === '"') {
3138
+ const quote = character;
3139
+ cursor += 1;
3140
+ while (cursor < code.length && code[cursor] !== quote) cursor += code[cursor] === "\\" ? 2 : 1;
3141
+ }
3142
+ cursor += 1;
3143
+ }
3144
+ const raw = code.slice(start, cursor).trim();
3145
+ return raw.length > 0 ? { raw, end: cursor } : null;
3146
+ }
3147
+
3148
+ /** Omitted mode is Python's default `"r"`. A non-literal mode cannot be proved read-only. */
3149
+ function modeWrites(raw: string | undefined): boolean | null {
3150
+ if (raw === undefined) return false;
3151
+ const literal = simpleLiteral(raw);
3152
+ return literal === null ? null : /[wax+]/.test(literal);
3153
+ }
3154
+
3155
+ const PY_OS_MUTATORS = new Set(
3156
+ "remove unlink rename replace rmtree move copy copy2 copyfile symlink link mkdir makedirs rmdir truncate open".split(
3157
+ " ",
3158
+ ),
3159
+ );
3160
+ /** Copy and rename write the destination, not only the source. */
3161
+ const TWO_ARG_MUTATORS = new Set(
3162
+ "rename replace move copy copy2 copyfile symlink link renameSync copyFileSync".split(" "),
3163
+ );
3164
+ const JS_FS_MUTATORS = new Set(
3165
+ "writeFileSync writeFile appendFileSync appendFile rmSync unlinkSync renameSync copyFileSync mkdirSync openSync".split(
3166
+ " ",
3167
+ ),
3168
+ );
3169
+
3170
+ function nextCallArg(code: string, end: number): { raw: string; end: number } | null {
3171
+ let cursor = end;
3172
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
3173
+ if (code[cursor] !== ",") return null;
3174
+ return readArg(code, cursor + 1);
3175
+ }
3176
+
3177
+ function openModeWrites(code: string, first: { raw: string; end: number }): boolean | null {
3178
+ let cursor = first.end;
3179
+ let positionalIndex = 1;
3180
+ let mode: string | undefined;
3181
+ let unknownMode = false;
3182
+ while (cursor < code.length) {
3183
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
3184
+ if (code[cursor] !== ",") break;
3185
+ const argument = readArg(code, cursor + 1);
3186
+ if (!argument) break;
3187
+ const raw = argument.raw.trim();
3188
+ if (raw.startsWith("*") || raw.startsWith("**")) {
3189
+ unknownMode = true;
3190
+ } else {
3191
+ const keyword = /^([A-Za-z_]\w*)\s*=\s*([\s\S]*)$/.exec(raw);
3192
+ if (keyword?.[1] === "mode") {
3193
+ if (mode !== undefined) unknownMode = true;
3194
+ else mode = keyword[2];
3195
+ } else if (!keyword && positionalIndex === 1) {
3196
+ if (mode !== undefined) unknownMode = true;
3197
+ else mode = raw;
3198
+ }
3199
+ }
3200
+ positionalIndex += 1;
3201
+ cursor = argument.end;
3202
+ }
3203
+ if (unknownMode) return null;
3204
+ return modeWrites(mode);
3205
+ }
3206
+
3207
+ /** Whitespace or a backslash-newline may sit before an attribute (`shutil .copy`). */
3208
+ function skipCallGap(code: string, index: number): number {
3209
+ let cursor = index;
3210
+ while (cursor < code.length) {
3211
+ const character = code[cursor] ?? "";
3212
+ if (/\s/.test(character)) {
3213
+ cursor += 1;
3214
+ continue;
3215
+ }
3216
+ if (character === "\\" && code[cursor + 1] === "\n") {
3217
+ cursor += 2;
3218
+ continue;
3219
+ }
3220
+ if (character === "\\" && code[cursor + 1] === "\r" && code[cursor + 2] === "\n") {
3221
+ cursor += 3;
3222
+ continue;
3223
+ }
3224
+ break;
3225
+ }
3226
+ return cursor;
3227
+ }
3228
+
3229
+ function literalWrite(targets: ExtractedTargets, raw: string | undefined, write: boolean | null): void {
3230
+ if (write === false) return;
3231
+ targets.explicitMutation = true;
3232
+ const literal = raw === undefined ? null : simpleLiteral(raw);
3233
+ if (write === null || literal === null || literal.length === 0) {
3234
+ targets.unknown = true;
3235
+ return;
3236
+ }
3237
+ addPath(targets, literal);
3238
+ }
3239
+
3240
+ /** Arguments of one Path() call, through its own closing parenthesis. */
3241
+ function readPathConstructor(code: string, argAt: number): { parts: string[]; after: number } | null {
3242
+ let cursor = argAt;
3243
+ const parts: string[] = [];
3244
+ while (cursor < code.length) {
3245
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
3246
+ if (code[cursor] === ")") return { parts, after: cursor + 1 };
3247
+ const argument = readArg(code, cursor);
3248
+ if (!argument) return null;
3249
+ parts.push(argument.raw);
3250
+ cursor = argument.end;
3251
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
3252
+ if (code[cursor] === ",") {
3253
+ cursor += 1;
3254
+ continue;
3255
+ }
3256
+ if (code[cursor] === ")") return { parts, after: cursor + 1 };
3257
+ return null;
3258
+ }
3259
+ return null;
3260
+ }
3261
+
3262
+ /** pathlib keeps the last absolute segment. A non-literal segment is an unresolved receiver. */
3263
+ function literalPathReceiver(parts: string[]): string | null {
3264
+ if (parts.length === 0) return ".";
3265
+ let joined = "";
3266
+ for (const part of parts) {
3267
+ const literal = simpleLiteral(part);
3268
+ if (literal === null) return null;
3269
+ if (path.isAbsolute(literal)) joined = literal;
3270
+ else joined = joined ? path.join(joined, literal) : literal;
3271
+ }
3272
+ return joined.length === 0 ? "." : joined;
3273
+ }
3274
+
3275
+ /** `Path.open()` defaults to read mode; a dynamic mode cannot be proven read-only. */
3276
+ function pathlibOpenModeWrites(code: string, methodAt: number): boolean | null {
3277
+ const call = /^\s*\.\s*open\s*\(/.exec(code.slice(methodAt));
3278
+ if (!call) return false;
3279
+ let cursor = methodAt + call[0].length;
3280
+ let mode: string | undefined;
3281
+ let modeSeen = false;
3282
+ let unknownMode = false;
3283
+ let positionalIndex = 0;
3284
+ while (cursor < code.length) {
3285
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
3286
+ if (code[cursor] === ")") break;
3287
+ const argument = readArg(code, cursor);
3288
+ if (!argument) return null;
3289
+ const raw = argument.raw.trim();
3290
+ if (raw.startsWith("*") || raw.startsWith("**")) {
3291
+ unknownMode = true;
3292
+ } else {
3293
+ const keyword = /^([A-Za-z_]\w*)\s*=\s*([\s\S]*)$/.exec(raw);
3294
+ if (keyword?.[1] === "mode") {
3295
+ if (modeSeen) unknownMode = true;
3296
+ else {
3297
+ mode = keyword[2];
3298
+ modeSeen = true;
3299
+ }
3300
+ } else if (!keyword && positionalIndex === 0) {
3301
+ if (modeSeen) unknownMode = true;
3302
+ else {
3303
+ mode = raw;
3304
+ modeSeen = true;
3305
+ }
3306
+ }
3307
+ }
3308
+ positionalIndex += 1;
3309
+ cursor = argument.end;
3310
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
3311
+ if (code[cursor] === ",") cursor += 1;
3312
+ else if (code[cursor] !== ")") return null;
3313
+ }
3314
+ return unknownMode ? null : modeWrites(mode);
3315
+ }
3316
+
3317
+ /** Calls inside strings or comments are skipped. A non-literal write path fails closed. */
3318
+ function scanInterpreterWrites(code: string, language: "py" | "js", targets: ExtractedTargets): void {
3319
+ let index = 0;
3320
+ const recognizedPathMethodIndexes = new Set<number>();
3321
+ while (index < code.length) {
3322
+ const trivia = skipNoise(code, index, language);
3323
+ if (trivia !== index) {
3324
+ if (language === "py" && pythonFStringContainsDynamicExecution(code, index, trivia)) {
3325
+ targets.explicitMutation = true;
3326
+ targets.unknown = true;
3327
+ return;
3328
+ }
3329
+ index = trivia;
3330
+ continue;
3331
+ }
3332
+ if (
3333
+ INTERPRETER_OUTPUT_METHOD_ASSIGNMENT_RE.test(code.slice(index)) ||
3334
+ INTERPRETER_OUTPUT_STREAM_MUTATION_RE.test(code.slice(index))
3335
+ ) {
3336
+ targets.explicitMutation = true;
3337
+ targets.unknown = true;
3338
+ return;
3339
+ }
3340
+ const previous = index === 0 ? "" : (code[index - 1] ?? "");
3341
+ if ((index > 0 && /[\w]/.test(previous)) || !/[A-Za-z_]/.test(code[index] ?? "")) {
3342
+ index += 1;
3343
+ continue;
3344
+ }
3345
+ const ident = /^[A-Za-z_]\w*/.exec(code.slice(index));
3346
+ if (!ident) {
3347
+ index += 1;
3348
+ continue;
3349
+ }
3350
+ const name = ident[0];
3351
+ if (language === "py" && PYTHON_DYNAMIC_EXECUTION_IDENTIFIER_RE.test(name)) {
3352
+ targets.explicitMutation = true;
3353
+ targets.unknown = true;
3354
+ return;
3355
+ }
3356
+ let receiverDot = index - 1;
3357
+ while (receiverDot >= 0 && /\s/.test(code[receiverDot] ?? "")) receiverDot -= 1;
3358
+ const unresolvedPathMethod =
3359
+ language === "py" &&
3360
+ code[receiverDot] === "." &&
3361
+ (/^write_\w+$/.test(name) || name === "touch" || name === "open");
3362
+ if (unresolvedPathMethod && !recognizedPathMethodIndexes.has(index)) {
3363
+ const callAt = skipCallGap(code, index + name.length);
3364
+ if (code[callAt] === "(") {
3365
+ const writes = name === "open" ? pathlibOpenModeWrites(code, receiverDot) : true;
3366
+ if (writes !== false) {
3367
+ targets.explicitMutation = true;
3368
+ targets.unknown = true;
3369
+ }
3370
+ index = Math.max(callAt + 1, index + name.length);
3371
+ continue;
3372
+ }
3373
+ }
3374
+ if ((name === "write" || name === "append") && previous === ".") {
3375
+ index += name.length;
3376
+ continue;
3377
+ }
3378
+ let cursor = skipCallGap(code, index + name.length);
3379
+ let dotted = "";
3380
+ if (code[cursor] === ".") {
3381
+ const methodAt = skipCallGap(code, cursor + 1);
3382
+ const dot = /^\w+/.exec(code.slice(methodAt));
3383
+ dotted = dot?.[0] ?? "";
3384
+ cursor = methodAt + dotted.length;
3385
+ }
3386
+ const paren = /^\s*\(/.exec(code.slice(cursor));
3387
+ const helper = (name === "write" || name === "append") && dotted === "";
3388
+ const open = (name === "open" && language === "py") || (name === "openSync" && language === "js");
3389
+ const pathCtor =
3390
+ (name === "Path" && dotted === "") || (language === "py" && name === "pathlib" && dotted === "Path");
3391
+ const osCall =
3392
+ language === "py" &&
3393
+ ((name === "os" && PY_OS_MUTATORS.has(dotted)) || (name === "shutil" && PY_OS_MUTATORS.has(dotted)));
3394
+ const jsCall =
3395
+ language === "js" && ((JS_FS_MUTATORS.has(name) && dotted === "") || (name === "Bun" && dotted === "write"));
3396
+ if (!paren || (!helper && !(open && dotted === "") && !pathCtor && !osCall && !jsCall)) {
3397
+ index += name.length;
3398
+ continue;
3399
+ }
3400
+ const argAt = cursor + paren[0].length;
3401
+ const first = readArg(code, argAt);
3402
+ if (pathCtor) {
3403
+ const ctor = readPathConstructor(code, argAt);
3404
+ if (!ctor) {
3405
+ index += 1;
3406
+ continue;
3407
+ }
3408
+ const method = /^\s*\.\s*(write_\w+|open|touch)\s*\(/.exec(code.slice(ctor.after));
3409
+ if (method) {
3410
+ if (ctor.parts.length > 1) {
3411
+ recognizedPathMethodIndexes.add(ctor.after + method[0].indexOf(method[1]));
3412
+ }
3413
+ const writes = method[1] === "open" ? pathlibOpenModeWrites(code, ctor.after) : true;
3414
+ if (writes !== false) {
3415
+ targets.explicitMutation = true;
3416
+ const receiver = literalPathReceiver(ctor.parts);
3417
+ if (writes === null || receiver === null || receiver.length === 0) targets.unknown = true;
3418
+ else addPath(targets, receiver);
3419
+ }
3420
+ }
3421
+ // Extra segments stay in the argument walk so a nested mutator is still recorded.
3422
+ index =
3423
+ ctor.parts.length > 1 ? index + name.length : Math.max(ctor.after + (method?.[0].length ?? 0), index + 1);
3424
+ continue;
3425
+ }
3426
+ if (!first) {
3427
+ literalWrite(targets, undefined, null);
3428
+ index = Math.max(argAt, index + 1);
3429
+ continue;
3430
+ }
3431
+ if (open && dotted === "") {
3432
+ literalWrite(targets, first.raw, openModeWrites(code, first));
3433
+ index = Math.max(first.end, index + 1);
3434
+ continue;
3435
+ }
3436
+ literalWrite(targets, first.raw, true);
3437
+ let end = first.end;
3438
+ if (TWO_ARG_MUTATORS.has(dotted || name)) {
3439
+ const second = nextCallArg(code, first.end);
3440
+ if (!second) literalWrite(targets, undefined, null);
3441
+ else {
3442
+ literalWrite(targets, second.raw, true);
3443
+ end = second.end;
3444
+ }
3445
+ }
3446
+ index = Math.max(end, index + 1);
3447
+ }
3448
+ }
3449
+
3450
+ function extractCodeToolTargets(toolName: string, args: unknown): ExtractedTargets {
3451
+ const targets: ExtractedTargets = { paths: [], unknown: false };
3452
+ const record = getRecord(args);
3453
+ if (!record) {
3454
+ targets.unknown = true;
3455
+ targets.explicitMutation = true;
3456
+ return targets;
3457
+ }
3458
+ if (toolName === "python") {
3459
+ if ((safeString(record.action) || "execute") === "clear") return targets;
3460
+ if (typeof record.code !== "string" || record.code.trim() === "") return targets;
3461
+ scanInterpreterWrites(record.code, "py", targets);
3462
+ return targets;
3463
+ }
3464
+ if (!Array.isArray(record.cells)) {
3465
+ targets.unknown = true;
3466
+ targets.explicitMutation = true;
3467
+ return targets;
3468
+ }
3469
+ for (const cell of record.cells) {
3470
+ const cellRecord = getRecord(cell);
3471
+ if (typeof cellRecord?.code !== "string") {
3472
+ targets.unknown = true;
3473
+ targets.explicitMutation = true;
3474
+ continue;
3475
+ }
3476
+ if (cellRecord.language === "py") scanInterpreterWrites(cellRecord.code, "py", targets);
3477
+ else if (cellRecord.language === "js") scanInterpreterWrites(cellRecord.code, "js", targets);
3478
+ else {
3479
+ targets.unknown = true;
3480
+ targets.explicitMutation = true;
3481
+ }
3482
+ }
3483
+ return targets;
3484
+ }
3485
+
3486
+ function extractTargets(tool: ToolWithEditMode, args: unknown): ExtractedTargets {
3487
+ if (tool.name === "write") return extractWriteTargets(args);
3488
+ if (tool.name === "ast_edit") return extractAstEditTargets(args);
3489
+ if (tool.name === "edit") return extractEditTargets(args, tool);
3490
+ if (tool.name === "bash") return extractBashTargets(args);
3491
+ if (tool.name === "github") return extractGithubTargets(args);
3492
+ if (tool.name === "python" || tool.name === "eval") return extractCodeToolTargets(tool.name, args);
3493
+ return { paths: [], unknown: true };
3494
+ }
3495
+
3496
+ function stripSelectorBase(rawPath: string): string {
3497
+ const archiveOrSqlite = rawPath.match(ARCHIVE_OR_SQLITE_BASE_RE);
3498
+ if (archiveOrSqlite?.[1]) return archiveOrSqlite[1];
3499
+ return rawPath;
3500
+ }
3501
+
3502
+ function resolveRawPath(cwd: string, rawPath: string): { absolutePath?: string; unknown: boolean } {
3503
+ const normalized = rawPath.trim();
3504
+ if (!normalized) return { unknown: true };
3505
+ if (normalized === ".") return { absolutePath: path.resolve(cwd), unknown: false };
3506
+ if (normalized.startsWith("local://") || normalized.startsWith("local:/")) {
3507
+ const options = LocalProtocolHandler.resolveOptions();
3508
+ if (!options) return { unknown: true };
3509
+ try {
3510
+ return { absolutePath: resolveLocalUrlToPath(normalized, options), unknown: false };
3511
+ } catch {
3512
+ return { unknown: true };
3513
+ }
3514
+ }
3515
+ if (INTERNAL_SCHEME_RE.test(normalized)) return { unknown: true };
3516
+
3517
+ const basePath = stripSelectorBase(normalized);
3518
+ try {
3519
+ return { absolutePath: resolveToCwd(basePath, cwd), unknown: false };
3520
+ } catch {
3521
+ return { unknown: true };
3522
+ }
3523
+ }
3524
+
3525
+ function relativeGjcSegments(cwd: string, rawPath: string): string[] | null {
3526
+ const { absolutePath, unknown } = resolveRawPath(cwd, rawPath);
3527
+ if (unknown || !absolutePath) return null;
3528
+ const relative = path.relative(path.resolve(cwd), path.resolve(absolutePath));
3529
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
3530
+ return normalizePosix(relative).split("/").filter(Boolean);
3531
+ }
3532
+
3533
+ function blockedWorkflowStateSkill(cwd: string, rawPath: string): CanonicalGjcWorkflowSkill | null {
1748
3534
  const segments = relativeGjcSegments(cwd, rawPath);
1749
3535
  if (segments?.[0] !== ".gjc") return null;
1750
3536
  const generatedRoot = segments[1]?.startsWith(GJC_SESSION_PREFIX) ? segments[2] : segments[1];
@@ -1766,11 +3552,23 @@ function firstBlockedWorkflowStateSkill(cwd: string, targets: ExtractedTargets):
1766
3552
  return null;
1767
3553
  }
1768
3554
 
1769
- function isAllowlistedPath(cwd: string, rawPath: string): boolean {
3555
+ function planningSessionDirName(sessionId: string | null | undefined): string | undefined {
3556
+ const trimmed = sessionId?.trim();
3557
+ if (!trimmed) return undefined;
3558
+ return sessionDirName(trimmed);
3559
+ }
3560
+
3561
+ /** Specs and plans are planning-writable. A `_session-` directory must be this session's encoded name. */
3562
+ function isAllowlistedPath(cwd: string, rawPath: string, sessionId?: string | null): boolean {
1770
3563
  const segments = relativeGjcSegments(cwd, rawPath);
1771
3564
  if (segments?.[0] !== ".gjc") return false;
1772
- const generatedRoot = segments[1]?.startsWith(GJC_SESSION_PREFIX) ? segments[2] : segments[1];
1773
- return generatedRoot === "specs" || generatedRoot === "plans";
3565
+ const head = segments[1];
3566
+ if (head?.startsWith(GJC_SESSION_PREFIX)) {
3567
+ const bound = planningSessionDirName(sessionId);
3568
+ if (!bound || head !== bound) return false;
3569
+ return segments[2] === "specs" || segments[2] === "plans";
3570
+ }
3571
+ return head === "specs" || head === "plans";
1774
3572
  }
1775
3573
  function isBlockedGjcPath(cwd: string, rawPath: string): boolean {
1776
3574
  const segments = relativeGjcSegments(cwd, rawPath);
@@ -1781,21 +3579,119 @@ function hasBlockedGjcTarget(cwd: string, targets: ExtractedTargets): boolean {
1781
3579
  return targets.paths.some(rawPath => isBlockedGjcPath(cwd, rawPath));
1782
3580
  }
1783
3581
 
1784
- function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean {
3582
+ function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets, sessionId?: string | null): boolean {
1785
3583
  return (
1786
- !targets.unknown && targets.paths.length > 0 && targets.paths.every(rawPath => isAllowlistedPath(cwd, rawPath))
3584
+ !targets.unknown &&
3585
+ targets.paths.length > 0 &&
3586
+ targets.paths.every(rawPath => isAllowlistedPath(cwd, rawPath, sessionId))
1787
3587
  );
1788
3588
  }
1789
3589
 
1790
- function neutralTempRoots(): string[] {
3590
+ /** POSIX directories that are temp roots even when `TMPDIR` is unset or poisoned. */
3591
+ const POSIX_SYSTEM_TEMP_ROOTS = ["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp"];
3592
+
3593
+ /**
3594
+ * `os.tmpdir()` follows `TMPDIR`, and Bun copies `cwd/.env` into the environment
3595
+ * when that variable was unset. A repository can therefore name an arbitrary
3596
+ * directory. Declarations below are not neutral scratch roots.
3597
+ */
3598
+ function tempEnvNames(): readonly string[] {
3599
+ return process.platform === "win32" ? ["TEMP", "TMP", "TMPDIR"] : ["TMPDIR"];
3600
+ }
3601
+
3602
+ const PROJECT_TEMP_DOTENV_MAX_FILE_BYTES = 64 * 1024;
3603
+
3604
+ // The snapshot is the cwd at module import, not a directory created later or a chdir before this module is imported.
3605
+ const startupTempSnapshot = projectEnvSnapshot(process.cwd(), {
3606
+ maxFileBytes: PROJECT_TEMP_DOTENV_MAX_FILE_BYTES,
3607
+ });
3608
+
3609
+ function projectTempSnapshots(cwd: string): ProjectEnvSnapshot[] {
3610
+ // Check both the cwd's .env and the startup snapshot (for module-import-time cwd).
3611
+ // The cwd's .env catches project-declared TMPDIR in the current working directory.
3612
+ // The startup snapshot is a fallback for when the cwd's .env is deleted or inaccessible
3613
+ // (e.g., after unlink or chdir), and also handles the case where cwd differs from the
3614
+ // module-import-time cwd. These workspace-controlled files are read as bounded regular files.
3615
+ const snapshots: ProjectEnvSnapshot[] = [
3616
+ projectEnvSnapshot(cwd, { maxFileBytes: PROJECT_TEMP_DOTENV_MAX_FILE_BYTES }),
3617
+ startupTempSnapshot,
3618
+ ];
3619
+ return snapshots;
3620
+ }
3621
+
3622
+ /** macOS account temp directory. `getconf` does not read `TMPDIR`. */
3623
+ let darwinUserTempDirCache: string | undefined | null = null;
3624
+ function darwinUserTempDir(): string | undefined {
3625
+ if (process.platform !== "darwin") return undefined;
3626
+ if (darwinUserTempDirCache !== null) return darwinUserTempDirCache;
3627
+ darwinUserTempDirCache = undefined;
3628
+ try {
3629
+ const result = Bun.spawnSync({
3630
+ cmd: ["/usr/bin/getconf", "DARWIN_USER_TEMP_DIR"],
3631
+ env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C" },
3632
+ stdout: "pipe",
3633
+ stderr: "ignore",
3634
+ });
3635
+ if (result.exitCode !== 0) return undefined;
3636
+ const value = new TextDecoder().decode(result.stdout).trim();
3637
+ if (value) darwinUserTempDirCache = value;
3638
+ } catch {
3639
+ // Fixed `/tmp` roots still cover the POSIX default.
3640
+ }
3641
+ return darwinUserTempDirCache ?? undefined;
3642
+ }
3643
+
3644
+ function addResolvedTempRoot(roots: Set<string>, value: string | undefined): void {
3645
+ const trimmed = value?.trim();
3646
+ if (trimmed) roots.add(path.resolve(trimmed));
3647
+ }
3648
+
3649
+ function isPlatformTempPath(resolved: string): boolean {
3650
+ for (const root of POSIX_SYSTEM_TEMP_ROOTS) {
3651
+ const resolvedRoot = path.resolve(root);
3652
+ if (resolved === resolvedRoot || isPathWithin(resolvedRoot, resolved)) return true;
3653
+ }
3654
+ const darwin = darwinUserTempDir();
3655
+ if (!darwin) return false;
3656
+ const resolvedDarwin = path.resolve(darwin);
3657
+ return resolved === resolvedDarwin || isPathWithin(resolvedDarwin, resolved);
3658
+ }
3659
+
3660
+ /** True when `live` is declared, or cannot be trusted because a dotenv layer was unsafe to read. */
3661
+ function projectDeclaresTempPath(snapshots: readonly ProjectEnvSnapshot[], live: string): boolean {
3662
+ const trimmedLive = live.trim();
3663
+ if (!trimmedLive) return false;
3664
+ const resolvedLive = path.resolve(trimmedLive);
3665
+ for (const snapshot of snapshots) {
3666
+ if (snapshot.incomplete && !isPlatformTempPath(resolvedLive)) return true;
3667
+ for (const name of tempEnvNames()) {
3668
+ const key = canonicalEnvKey(name);
3669
+ const declared = snapshot.values[key];
3670
+ if (declared === undefined) continue;
3671
+ if (snapshot.dynamic.has(key)) {
3672
+ // Expansion is not comparable. A dynamic value that landed on a
3673
+ // real system temp dir is already allowed through those roots.
3674
+ if (!isPlatformTempPath(resolvedLive)) return true;
3675
+ continue;
3676
+ }
3677
+ const trimmedDeclared = declared.trim();
3678
+ if (trimmedDeclared && path.resolve(trimmedDeclared) === resolvedLive) return true;
3679
+ }
3680
+ }
3681
+ return false;
3682
+ }
3683
+
3684
+ function neutralTempRoots(cwd: string): string[] {
1791
3685
  const roots = new Set<string>();
1792
- const add = (value: string | undefined): void => {
1793
- const trimmed = value?.trim();
1794
- if (trimmed) roots.add(path.resolve(trimmed));
1795
- };
1796
- add(os.tmpdir());
1797
- add(process.env.TMPDIR);
1798
- for (const fixed of ["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp"]) add(fixed);
3686
+ for (const fixed of POSIX_SYSTEM_TEMP_ROOTS) addResolvedTempRoot(roots, fixed);
3687
+ addResolvedTempRoot(roots, darwinUserTempDir());
3688
+ const candidates = [os.tmpdir(), process.env.TMPDIR];
3689
+ if (process.platform === "win32") candidates.push(process.env.TEMP, process.env.TMP);
3690
+ const snapshots = projectTempSnapshots(cwd);
3691
+ for (const candidate of candidates) {
3692
+ if (!candidate || projectDeclaresTempPath(snapshots, candidate)) continue;
3693
+ addResolvedTempRoot(roots, candidate);
3694
+ }
1799
3695
  return [...roots];
1800
3696
  }
1801
3697
 
@@ -1849,18 +3745,44 @@ async function isNeutralTempPath(cwd: string, rawPath: string): Promise<boolean>
1849
3745
  if (unknown || !absolutePath) return false;
1850
3746
  const resolvedCwd = path.resolve(cwd);
1851
3747
  if (isPathWithin(resolvedCwd, absolutePath)) return false;
1852
- if (!neutralTempRoots().some(root => isPathWithin(root, absolutePath))) return false;
3748
+ const roots = neutralTempRoots(cwd);
3749
+ const realRoots = await Promise.all(roots.map(realpathOrSelf));
3750
+ // Accept both spellings (`/var/folders` and `/private/var/folders`) before the
3751
+ // canonical re-check. The re-check still rejects a temp path whose real
3752
+ // target is inside the project.
3753
+ const lexicalRoots = new Set([...roots, ...realRoots]);
3754
+ if (![...lexicalRoots].some(root => isPathWithin(root, absolutePath))) return false;
1853
3755
  const realTarget = await canonicalizeForContainment(absolutePath);
1854
3756
  if (isPathWithin(await realpathOrSelf(resolvedCwd), realTarget)) return false;
1855
- const realRoots = await Promise.all(neutralTempRoots().map(realpathOrSelf));
1856
3757
  return realRoots.some(root => isPathWithin(root, realTarget));
1857
3758
  }
1858
3759
 
3760
+ /** realpath cannot expose another name for the same inode outside a writable root. */
3761
+ async function hasUnsharedFileTarget(cwd: string, rawPath: string): Promise<boolean> {
3762
+ const { absolutePath, unknown } = resolveRawPath(cwd, rawPath);
3763
+ if (unknown || !absolutePath) return false;
3764
+ try {
3765
+ const target = await fs.stat(absolutePath);
3766
+ return !target.isFile() || target.nlink === 1;
3767
+ } catch (error) {
3768
+ // New scratch files remain writable; inaccessible or unresolvable targets fail closed.
3769
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
3770
+ }
3771
+ }
3772
+
1859
3773
  /** Targets that remain disallowed during a planning phase (excludes neutral temp scratch). */
1860
- async function planningBlockedTargets(cwd: string, targets: ExtractedTargets): Promise<string[]> {
3774
+ async function planningBlockedTargets(
3775
+ cwd: string,
3776
+ targets: ExtractedTargets,
3777
+ sessionId?: string | null,
3778
+ ): Promise<string[]> {
1861
3779
  const blocked: string[] = [];
1862
3780
  for (const rawPath of targets.paths) {
1863
- if (isAllowlistedPath(cwd, rawPath)) continue;
3781
+ if (!(await hasUnsharedFileTarget(cwd, rawPath))) {
3782
+ blocked.push(rawPath);
3783
+ continue;
3784
+ }
3785
+ if (isAllowlistedPath(cwd, rawPath, sessionId)) continue;
1864
3786
  if (!(await isNeutralTempPath(cwd, rawPath))) blocked.push(rawPath);
1865
3787
  }
1866
3788
  return blocked;
@@ -1882,12 +3804,15 @@ export async function assertWorkflowMutationRawPathsAllowed(input: {
1882
3804
  throw new ToolError(`${WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE}\nUse: ${command}`);
1883
3805
  }
1884
3806
  if (input.forceOverride) return;
1885
- const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId, input.guardContext);
3807
+ const guardContext =
3808
+ input.guardContext ??
3809
+ (await readWorkflowGuardContext(input.cwd, { sessionId: input.sessionId, threadId: input.threadId }));
3810
+ const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId, guardContext);
1886
3811
 
1887
3812
  if (!planning) return;
1888
3813
  const message = planningPhaseBlockMessage(planning.skill);
1889
3814
  if (input.rawPaths.length === 0) throw new ToolError(message);
1890
- const blocked = await planningBlockedTargets(input.cwd, targets);
3815
+ const blocked = await planningBlockedTargets(input.cwd, targets, guardContext.sessionId);
1891
3816
  if (blocked.length > 0) throw new ToolError(message);
1892
3817
  }
1893
3818
 
@@ -1907,7 +3832,10 @@ export async function getWorkflowMutationDecision(
1907
3832
  command,
1908
3833
  };
1909
3834
  }
1910
- const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId, input.guardContext);
3835
+ const guardContext =
3836
+ input.guardContext ??
3837
+ (await readWorkflowGuardContext(input.cwd, { sessionId: input.sessionId, threadId: input.threadId }));
3838
+ const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId, guardContext);
1911
3839
  if (!planning) {
1912
3840
  return { blocked: false, targets: [] };
1913
3841
  }
@@ -1919,15 +3847,24 @@ export async function getWorkflowMutationDecision(
1919
3847
  // name is not authorization: an `autoresearch/*` branch isolates keep/discard
1920
3848
  // bookkeeping, it does not turn product edits into research.
1921
3849
  //
3850
+ // Shell and code-execution tools can run multiple operations before writing,
3851
+ // so checking the harness once cannot protect the later write destination.
3852
+ // Only direct file-mutation tools may use the harness exception.
1922
3853
  // The allowance requires at least one named target: `every` is vacuously true
1923
3854
  // for an empty list, so an opaque mutation that resolves to NO target (e.g.
1924
3855
  // `python3 -c "open('src/product.ts','w').write('x')"`) would otherwise skip
1925
3856
  // the fail-closed check below and be waved through on a research mission.
3857
+ // Bash is a compound command list, not a write boundary: an earlier `ln` or
3858
+ // `mv` can retarget the harness before a later redirection. Harness changes
3859
+ // must use a single-target file tool so the final path is checked for symlinks
3860
+ // immediately before that write operation.
1926
3861
  if (
1927
3862
  planning.skill === "autoresearch" &&
3863
+ AUTORESEARCH_HARNESS_FILE_TOOL_NAMES.has(input.tool.name) &&
1928
3864
  targets.paths.length > 0 &&
1929
3865
  !targets.unknown &&
1930
- targets.paths.every(p => isAutoresearchAuthorizedResearchPath(input.cwd, p))
3866
+ targets.paths.every(p => isAutoresearchAuthorizedResearchPath(input.cwd, p)) &&
3867
+ (await Promise.all(targets.paths.map(p => hasUnsharedFileTarget(input.cwd, p)))).every(Boolean)
1931
3868
  ) {
1932
3869
  return { blocked: false, targets: targets.paths };
1933
3870
  }
@@ -1946,7 +3883,7 @@ export async function getWorkflowMutationDecision(
1946
3883
  // Neutral temp scratch (outside the project tree) stays writable so agents can
1947
3884
  // stage artifacts and feed their path to the sanctioned `gjc ... --write` CLIs.
1948
3885
  // Read-only / `gjc` bash extract no targets and fall through to allowed here.
1949
- const blockedTargets = await planningBlockedTargets(input.cwd, targets);
3886
+ const blockedTargets = await planningBlockedTargets(input.cwd, targets, guardContext.sessionId);
1950
3887
  if (blockedTargets.length === 0) {
1951
3888
  return { blocked: false, targets: targets.paths };
1952
3889
  }
@@ -1954,7 +3891,9 @@ export async function getWorkflowMutationDecision(
1954
3891
  blocked: true,
1955
3892
  message,
1956
3893
  targets: targets.paths,
1957
- reason: allTargetsAllowlisted(input.cwd, targets) ? "handoff-artifact-tool-target" : "phase-boundary",
3894
+ reason: allTargetsAllowlisted(input.cwd, targets, guardContext.sessionId)
3895
+ ? "handoff-artifact-tool-target"
3896
+ : "phase-boundary",
1958
3897
  };
1959
3898
  }
1960
3899