@gajae-code/coding-agent 0.18.12 → 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 (305) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/dist/types/capability/rule.d.ts +7 -2
  3. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  4. package/dist/types/cli/args.d.ts +7 -2
  5. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  6. package/dist/types/cli/customize-doctor.d.ts +4 -3
  7. package/dist/types/cli/public-command-errors.d.ts +1 -0
  8. package/dist/types/cli/root-flags.d.ts +0 -12
  9. package/dist/types/cli/update-cli.d.ts +7 -2
  10. package/dist/types/cli-main.d.ts +0 -6
  11. package/dist/types/commands/launch.d.ts +0 -6
  12. package/dist/types/commands/migrate.d.ts +1 -1
  13. package/dist/types/commands/sdk.d.ts +8 -0
  14. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  15. package/dist/types/config/autorouting-tier-map.d.ts +23 -1
  16. package/dist/types/config/config-hot-reload.d.ts +5 -0
  17. package/dist/types/config/file-lock.d.ts +44 -2
  18. package/dist/types/config/model-profile-activation.d.ts +19 -5
  19. package/dist/types/config/model-registry.d.ts +16 -3
  20. package/dist/types/config/model-resolver.d.ts +5 -4
  21. package/dist/types/config/settings-schema.d.ts +11 -1
  22. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  23. package/dist/types/customization/import.d.ts +2 -0
  24. package/dist/types/customization/types.d.ts +1 -1
  25. package/dist/types/dap/session.d.ts +11 -2
  26. package/dist/types/discovery/helpers.d.ts +7 -2
  27. package/dist/types/edit/index.d.ts +5 -0
  28. package/dist/types/edit/modes/patch.d.ts +9 -0
  29. package/dist/types/eval/py/runtime.d.ts +9 -0
  30. package/dist/types/export/ttsr.d.ts +19 -0
  31. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  32. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  33. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  34. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  36. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  37. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  38. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  39. package/dist/types/hindsight/content.d.ts +5 -6
  40. package/dist/types/internal-urls/types.d.ts +2 -0
  41. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  42. package/dist/types/main.d.ts +23 -18
  43. package/dist/types/migrate/action-planner.d.ts +10 -1
  44. package/dist/types/migrate/adapters/codex.d.ts +1 -1
  45. package/dist/types/migrate/adapters/index.d.ts +35 -3
  46. package/dist/types/migrate/hook-module.d.ts +23 -0
  47. package/dist/types/migrate/report.d.ts +2 -1
  48. package/dist/types/migrate/types.d.ts +69 -2
  49. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +43 -0
  51. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  52. package/dist/types/modes/utils/host-status.d.ts +15 -11
  53. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  54. package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
  55. package/dist/types/sdk/broker/broker.d.ts +22 -1
  56. package/dist/types/sdk/broker/ensure.d.ts +5 -2
  57. package/dist/types/sdk/broker/hop.d.ts +9 -0
  58. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  59. package/dist/types/sdk/broker/session-index.d.ts +2 -0
  60. package/dist/types/sdk/broker/transport.d.ts +2 -0
  61. package/dist/types/sdk/bus/index.d.ts +21 -0
  62. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
  63. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  64. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  65. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  66. package/dist/types/sdk/host/events.d.ts +4 -0
  67. package/dist/types/sdk/host/host.d.ts +2 -1
  68. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  69. package/dist/types/sdk/host/session-runtime.d.ts +8 -8
  70. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  71. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  72. package/dist/types/sdk/prompt-status.d.ts +11 -4
  73. package/dist/types/session/agent-session.d.ts +50 -8
  74. package/dist/types/session/session-manager.d.ts +16 -0
  75. package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
  76. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  77. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  78. package/dist/types/system-prompt.d.ts +3 -2
  79. package/dist/types/task/role-body.d.ts +11 -0
  80. package/dist/types/tools/awaiting-input.d.ts +28 -0
  81. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
  82. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  83. package/dist/types/tools/bash.d.ts +7 -0
  84. package/dist/types/tools/descriptors.d.ts +13 -2
  85. package/dist/types/tools/fetch.d.ts +6 -0
  86. package/dist/types/tools/irc.d.ts +5 -3
  87. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  88. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +2 -0
  90. package/dist/types/utils/file-mentions.d.ts +5 -0
  91. package/dist/types/utils/image-limits.d.ts +7 -0
  92. package/dist/types/utils/image-loading.d.ts +0 -1
  93. package/dist/types/utils/open.d.ts +0 -1
  94. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  95. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  96. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  97. package/dist/types/utils/title-generator.d.ts +13 -3
  98. package/dist/types/web/search/providers/codex.d.ts +6 -7
  99. package/package.json +9 -8
  100. package/scripts/acp-conformance-agent.ts +12 -2
  101. package/scripts/generate-sdk-operation-inventory.ts +16 -0
  102. package/src/capability/fs.ts +31 -3
  103. package/src/capability/rule.ts +11 -2
  104. package/src/cli/append-system-prompt.ts +12 -0
  105. package/src/cli/args.ts +11 -7
  106. package/src/cli/customize-doctor-precedence.ts +31 -0
  107. package/src/cli/customize-doctor.ts +49 -39
  108. package/src/cli/fast-help.ts +0 -2
  109. package/src/cli/migrate-cli.ts +14 -3
  110. package/src/cli/public-command-errors.ts +2 -0
  111. package/src/cli/root-flags.ts +0 -2
  112. package/src/cli/update-cli.ts +247 -6
  113. package/src/commands/harness.ts +25 -15
  114. package/src/commands/migrate.ts +3 -3
  115. package/src/commands/sdk.ts +81 -11
  116. package/src/commit/agentic/agent.ts +6 -6
  117. package/src/commit/analysis/conventional.ts +14 -5
  118. package/src/commit/map-reduce/index.ts +7 -7
  119. package/src/commit/pipeline.ts +12 -12
  120. package/src/config/autorouting-tier-map.ts +20 -1
  121. package/src/config/config-file.ts +106 -3
  122. package/src/config/config-hot-reload.ts +8 -0
  123. package/src/config/file-lock.ts +1431 -304
  124. package/src/config/keybindings.ts +154 -7
  125. package/src/config/model-profile-activation.ts +177 -39
  126. package/src/config/model-registry.ts +380 -55
  127. package/src/config/model-resolver.ts +49 -21
  128. package/src/config/settings-schema.ts +13 -1
  129. package/src/config/settings.ts +2 -0
  130. package/src/coordinator-mcp/server.ts +37 -2
  131. package/src/crash/sanitize.ts +3 -1
  132. package/src/customization/import.ts +276 -7
  133. package/src/customization/inventory.ts +24 -8
  134. package/src/customization/types.ts +1 -0
  135. package/src/dap/client.ts +3 -2
  136. package/src/dap/session.ts +161 -36
  137. package/src/debug/crash-diagnostics.ts +87 -5
  138. package/src/debug/report-bundle.ts +71 -2
  139. package/src/discovery/builtin.ts +4 -3
  140. package/src/discovery/helpers.ts +24 -14
  141. package/src/edit/index.ts +191 -5
  142. package/src/edit/modes/patch.ts +27 -0
  143. package/src/eval/py/runtime.ts +44 -4
  144. package/src/exec/non-interactive-env.ts +0 -1
  145. package/src/export/html/index.ts +60 -20
  146. package/src/export/ttsr.ts +176 -1
  147. package/src/extensibility/extensions/loader.ts +2 -0
  148. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  149. package/src/extensibility/extensions/types.ts +5 -1
  150. package/src/extensibility/hooks/loader.ts +10 -2
  151. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  152. package/src/extensibility/slash-commands.ts +5 -0
  153. package/src/gjc-runtime/gc-runtime.ts +4 -1
  154. package/src/gjc-runtime/launch-tmux.ts +23 -13
  155. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  156. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  157. package/src/gjc-runtime/state-writer.ts +109 -5
  158. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  159. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  160. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  161. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  162. package/src/harness-control-plane/finalize.ts +11 -0
  163. package/src/harness-control-plane/receipt-spool.ts +105 -28
  164. package/src/harness-control-plane/storage.ts +124 -6
  165. package/src/hindsight/content.ts +116 -16
  166. package/src/hindsight/mental-models.ts +22 -10
  167. package/src/hindsight/state.ts +7 -4
  168. package/src/hooks/native-skill-hook.ts +3 -1
  169. package/src/internal-urls/artifact-protocol.ts +3 -1
  170. package/src/internal-urls/docs-index.generated.ts +14 -14
  171. package/src/internal-urls/local-protocol.ts +28 -3
  172. package/src/internal-urls/types.ts +2 -0
  173. package/src/lsp/client.ts +2 -1
  174. package/src/lsp/clients/biome-client.ts +2 -0
  175. package/src/lsp/clients/swiftlint-client.ts +2 -0
  176. package/src/main.ts +330 -92
  177. package/src/memories/index.ts +25 -2
  178. package/src/migrate/action-planner.ts +517 -2
  179. package/src/migrate/adapters/claude-code.ts +141 -3
  180. package/src/migrate/adapters/codex.ts +57 -5
  181. package/src/migrate/adapters/index.ts +240 -5
  182. package/src/migrate/adapters/opencode.ts +4 -0
  183. package/src/migrate/executor.ts +141 -3
  184. package/src/migrate/hook-module.ts +193 -0
  185. package/src/migrate/report.ts +45 -1
  186. package/src/migrate/types.ts +73 -3
  187. package/src/modes/acp/acp-agent.ts +10 -1
  188. package/src/modes/components/agent-dashboard.ts +20 -4
  189. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  190. package/src/modes/components/customization/import-wizard.ts +29 -3
  191. package/src/modes/components/model-selector.ts +157 -103
  192. package/src/modes/components/welcome.ts +3 -1
  193. package/src/modes/controllers/command-controller.ts +271 -16
  194. package/src/modes/controllers/event-controller.ts +1 -1
  195. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  196. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  197. package/src/modes/controllers/input-controller.ts +2 -0
  198. package/src/modes/controllers/plan-mode-controller.ts +49 -4
  199. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  200. package/src/modes/controllers/selector-controller.ts +14 -1
  201. package/src/modes/interactive-mode.ts +55 -4
  202. package/src/modes/prompt-suggestion-controller.ts +1 -1
  203. package/src/modes/runtime-init.ts +5 -1
  204. package/src/modes/utils/host-status.ts +21 -10
  205. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  206. package/src/prompts/system/irc-incoming.md +3 -1
  207. package/src/prompts/tools/irc.md +1 -1
  208. package/src/runtime-mcp/manager.ts +8 -3
  209. package/src/runtime-mcp/project-host-values.ts +12 -8
  210. package/src/runtime-mcp/tool-bridge.ts +68 -4
  211. package/src/runtime-mcp/transports/stdio.ts +2 -1
  212. package/src/sdk/broker/broker-exit.ts +2 -0
  213. package/src/sdk/broker/broker.ts +143 -4
  214. package/src/sdk/broker/ensure.ts +74 -6
  215. package/src/sdk/broker/hop.ts +13 -1
  216. package/src/sdk/broker/lifecycle-ledger.ts +3 -0
  217. package/src/sdk/broker/lifecycle.ts +102 -6
  218. package/src/sdk/broker/session-index.ts +29 -4
  219. package/src/sdk/broker/transport.ts +17 -1
  220. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  221. package/src/sdk/bus/index.ts +408 -14
  222. package/src/sdk/bus/session-control.ts +2 -0
  223. package/src/sdk/bus/slack-daemon.ts +105 -9
  224. package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
  225. package/src/sdk/bus/telegram-daemon.ts +10 -2
  226. package/src/sdk/bus/threaded-render.ts +20 -1
  227. package/src/sdk/cli/session-cli.ts +2 -0
  228. package/src/sdk/host/control/dispatch.ts +13 -1
  229. package/src/sdk/host/control/operations.ts +10 -1
  230. package/src/sdk/host/events.ts +67 -4
  231. package/src/sdk/host/host.ts +8 -2
  232. package/src/sdk/host/prompt-image-upload.ts +438 -0
  233. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  234. package/src/sdk/host/session-runtime.test.ts +1537 -24
  235. package/src/sdk/host/session-runtime.ts +332 -36
  236. package/src/sdk/host/surface-policy.ts +4 -0
  237. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  238. package/src/sdk/host/websocket-transport.ts +19 -7
  239. package/src/sdk/lifecycle/service.ts +14 -1
  240. package/src/sdk/prompt-status.ts +6 -4
  241. package/src/sdk/protocol/adapter-validation.ts +28 -1
  242. package/src/sdk/protocol/operation-inventory.generated.json +160 -0
  243. package/src/sdk/protocol/operation-registry.ts +33 -3
  244. package/src/sdk/session.ts +123 -36
  245. package/src/session/agent-session.ts +1614 -329
  246. package/src/session/history-storage.ts +182 -12
  247. package/src/session/internal/managed-session-scope.ts +47 -18
  248. package/src/session/internal/managed-session-storage.ts +71 -1
  249. package/src/session/session-manager.ts +90 -5
  250. package/src/session-import/redact.ts +53 -2
  251. package/src/skill-state/workflow-mutation-guard.ts +1498 -18
  252. package/src/slash-commands/builtin-registry.ts +6 -3
  253. package/src/slash-commands/helpers/mcp.ts +11 -3
  254. package/src/ssh/ssh-executor.ts +9 -2
  255. package/src/stt/recorder.ts +26 -9
  256. package/src/system-prompt.ts +61 -16
  257. package/src/task/executor.ts +12 -8
  258. package/src/task/role-body.ts +84 -0
  259. package/src/task/token-log-mode.test.ts +162 -0
  260. package/src/task/token-log.ts +91 -2
  261. package/src/tools/archive-reader.ts +650 -37
  262. package/src/tools/ask.ts +1 -0
  263. package/src/tools/ast-grep.ts +7 -2
  264. package/src/tools/auto-generated-guard.ts +12 -3
  265. package/src/tools/awaiting-input.ts +114 -0
  266. package/src/tools/bash-allowed-prefixes.ts +61 -1
  267. package/src/tools/bash-skill-urls.ts +58 -5
  268. package/src/tools/bash.ts +99 -26
  269. package/src/tools/browser/tab-worker.ts +39 -13
  270. package/src/tools/browser.ts +17 -7
  271. package/src/tools/cron.ts +10 -1
  272. package/src/tools/debug.ts +9 -2
  273. package/src/tools/descriptors.test.ts +4 -4
  274. package/src/tools/descriptors.ts +16 -4
  275. package/src/tools/eval.ts +15 -0
  276. package/src/tools/fetch.ts +90 -2
  277. package/src/tools/gh.ts +27 -12
  278. package/src/tools/image-gen.ts +20 -6
  279. package/src/tools/irc.ts +5 -3
  280. package/src/tools/monitor.ts +9 -1
  281. package/src/tools/output-meta.ts +51 -3
  282. package/src/tools/read.ts +9 -2
  283. package/src/tools/recipe/runner.ts +23 -12
  284. package/src/tools/render-utils.ts +2 -2
  285. package/src/tools/search.ts +3 -2
  286. package/src/tools/ssh.ts +274 -8
  287. package/src/tools/subagent.ts +10 -4
  288. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  289. package/src/tools/tool-catalog.generated.ts +1 -1
  290. package/src/tools/ultragoal-ask-guard.ts +13 -1
  291. package/src/tools/write.ts +7 -0
  292. package/src/tui/tree-list.ts +15 -1
  293. package/src/utils/commit-message-generator.ts +3 -2
  294. package/src/utils/external-editor.ts +21 -7
  295. package/src/utils/file-mentions.ts +46 -6
  296. package/src/utils/git.ts +38 -7
  297. package/src/utils/image-limits.ts +8 -0
  298. package/src/utils/image-loading.ts +1 -1
  299. package/src/utils/open.ts +73 -4
  300. package/src/utils/pasted-image-loading.ts +9 -8
  301. package/src/utils/prompt-suggestion.ts +5 -4
  302. package/src/utils/shell-snapshot.ts +9 -1
  303. package/src/utils/title-generator.ts +65 -20
  304. package/src/web/search/index.ts +2 -1
  305. package/src/web/search/providers/codex.ts +29 -32
@@ -102,10 +102,68 @@ const BASH_IN_PLACE_MUTATION_COMMAND_RE = new RegExp(
102
102
  `(?:^|[;&|(\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|()]*\\/)?(?:sed|perl)\\b([^;&|()\\n]*)`,
103
103
  "gi",
104
104
  );
105
- const BASH_OPAQUE_INTERPRETER_WRITE_RE =
106
- /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:-c|-e)\b[^;&|\n]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
107
- const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE =
108
- /(?:^|[;&|\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
+ );
109
167
  const BASH_DD_OUTPUT_RE = new RegExp(
110
168
  `(?:^|[;&|\\n])\\s*(?:\\w+=[^\\s]+\\s+)*(?:sudo\\s+)?${BASH_COMMAND_WRAPPER_SOURCE}(?:[^\\s;&|]*\\/)?dd\\b([^;&|\\n]*)`,
111
169
  "gi",
@@ -311,7 +369,7 @@ interface ActivePlanningSkill {
311
369
  * reliably wins. Ties fall back to the resolved top-level `skill`, then to the
312
370
  * first entry, matching how the HUD/chain guard pick `activeSkills[0]`.
313
371
  */
314
- function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill: string): SkillActiveEntry {
372
+ export function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill: string): SkillActiveEntry {
315
373
  const ts = (entry: SkillActiveEntry): number => {
316
374
  const value = Date.parse(safeString(entry.updated_at) || safeString(entry.activated_at));
317
375
  return Number.isNaN(value) ? -1 : value;
@@ -1564,6 +1622,1310 @@ function extractSubstitutionBodies(command: string): SubstitutionBody[] {
1564
1622
  return bodies;
1565
1623
  }
1566
1624
 
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++;
1646
+ }
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++;
1660
+ }
1661
+ return undefined;
1662
+ }
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++;
1670
+ }
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++;
1679
+ }
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++;
1694
+ }
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;
1699
+ }
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;
1731
+ }
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
+ }
1741
+ }
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++;
1784
+ }
1785
+ return foundWrite ? foundPath : undefined;
1786
+ }
1787
+
1788
+ /**
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(...)`.
1792
+ */
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;
1831
+ }
1832
+
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;
1890
+ }
1891
+ }
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
+ }
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;
1942
+ }
1943
+
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
+
1567
2929
  function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
1568
2930
  const record = getRecord(args);
1569
2931
  const command = safeString(record?.command);
@@ -1622,10 +2984,7 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
1622
2984
  targets.explicitMutation = true;
1623
2985
  targets.unknown = true;
1624
2986
  }
1625
- if (
1626
- BASH_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked) ||
1627
- BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)
1628
- ) {
2987
+ if (BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(heredoc.masked)) {
1629
2988
  targets.explicitMutation = true;
1630
2989
  targets.unknown = true;
1631
2990
  }
@@ -1707,6 +3066,11 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
1707
3066
  // fail closed rather than allowing a targetless mutation through planning.
1708
3067
  if (!extractedTarget) targets.unknown = true;
1709
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);
1710
3074
  return targets;
1711
3075
  }
1712
3076
  /**
@@ -1727,6 +3091,12 @@ function simpleLiteral(raw: string): string | null {
1727
3091
  return /[\\\n${]/.test(body) || body.includes(quote) ? null : body;
1728
3092
  }
1729
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
+
1730
3100
  function skipNoise(code: string, index: number, language: "py" | "js"): number {
1731
3101
  if (language === "py" && (code.startsWith('"""', index) || code.startsWith("'''", index))) {
1732
3102
  const quote = code.slice(index, index + 3);
@@ -1782,9 +3152,15 @@ function modeWrites(raw: string | undefined): boolean | null {
1782
3152
  return literal === null ? null : /[wax+]/.test(literal);
1783
3153
  }
1784
3154
 
1785
- const PY_OS_MUTATORS = new Set("remove unlink rename replace rmtree move copy copy2 copyfile".split(" "));
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
+ );
1786
3160
  /** Copy and rename write the destination, not only the source. */
1787
- const TWO_ARG_MUTATORS = new Set("rename replace move copy copy2 copyfile renameSync copyFileSync".split(" "));
3161
+ const TWO_ARG_MUTATORS = new Set(
3162
+ "rename replace move copy copy2 copyfile symlink link renameSync copyFileSync".split(" "),
3163
+ );
1788
3164
  const JS_FS_MUTATORS = new Set(
1789
3165
  "writeFileSync writeFile appendFileSync appendFile rmSync unlinkSync renameSync copyFileSync mkdirSync openSync".split(
1790
3166
  " ",
@@ -1896,15 +3272,71 @@ function literalPathReceiver(parts: string[]): string | null {
1896
3272
  return joined.length === 0 ? "." : joined;
1897
3273
  }
1898
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
+
1899
3317
  /** Calls inside strings or comments are skipped. A non-literal write path fails closed. */
1900
3318
  function scanInterpreterWrites(code: string, language: "py" | "js", targets: ExtractedTargets): void {
1901
3319
  let index = 0;
3320
+ const recognizedPathMethodIndexes = new Set<number>();
1902
3321
  while (index < code.length) {
1903
3322
  const trivia = skipNoise(code, index, language);
1904
3323
  if (trivia !== index) {
3324
+ if (language === "py" && pythonFStringContainsDynamicExecution(code, index, trivia)) {
3325
+ targets.explicitMutation = true;
3326
+ targets.unknown = true;
3327
+ return;
3328
+ }
1905
3329
  index = trivia;
1906
3330
  continue;
1907
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
+ }
1908
3340
  const previous = index === 0 ? "" : (code[index - 1] ?? "");
1909
3341
  if ((index > 0 && /[\w]/.test(previous)) || !/[A-Za-z_]/.test(code[index] ?? "")) {
1910
3342
  index += 1;
@@ -1916,6 +3348,29 @@ function scanInterpreterWrites(code: string, language: "py" | "js", targets: Ext
1916
3348
  continue;
1917
3349
  }
1918
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
+ }
1919
3374
  if ((name === "write" || name === "append") && previous === ".") {
1920
3375
  index += name.length;
1921
3376
  continue;
@@ -1931,7 +3386,8 @@ function scanInterpreterWrites(code: string, language: "py" | "js", targets: Ext
1931
3386
  const paren = /^\s*\(/.exec(code.slice(cursor));
1932
3387
  const helper = (name === "write" || name === "append") && dotted === "";
1933
3388
  const open = (name === "open" && language === "py") || (name === "openSync" && language === "js");
1934
- const pathCtor = name === "Path" && dotted === "";
3389
+ const pathCtor =
3390
+ (name === "Path" && dotted === "") || (language === "py" && name === "pathlib" && dotted === "Path");
1935
3391
  const osCall =
1936
3392
  language === "py" &&
1937
3393
  ((name === "os" && PY_OS_MUTATORS.has(dotted)) || (name === "shutil" && PY_OS_MUTATORS.has(dotted)));
@@ -1949,12 +3405,18 @@ function scanInterpreterWrites(code: string, language: "py" | "js", targets: Ext
1949
3405
  index += 1;
1950
3406
  continue;
1951
3407
  }
1952
- const method = /^\s*\.\s*(write_text|write_bytes)\b/.exec(code.slice(ctor.after));
3408
+ const method = /^\s*\.\s*(write_\w+|open|touch)\s*\(/.exec(code.slice(ctor.after));
1953
3409
  if (method) {
1954
- targets.explicitMutation = true;
1955
- const receiver = literalPathReceiver(ctor.parts);
1956
- if (receiver === null || receiver.length === 0) targets.unknown = true;
1957
- else addPath(targets, receiver);
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
+ }
1958
3420
  }
1959
3421
  // Extra segments stay in the argument walk so a nested mutator is still recorded.
1960
3422
  index =
@@ -2295,6 +3757,19 @@ async function isNeutralTempPath(cwd: string, rawPath: string): Promise<boolean>
2295
3757
  return realRoots.some(root => isPathWithin(root, realTarget));
2296
3758
  }
2297
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
+
2298
3773
  /** Targets that remain disallowed during a planning phase (excludes neutral temp scratch). */
2299
3774
  async function planningBlockedTargets(
2300
3775
  cwd: string,
@@ -2303,6 +3778,10 @@ async function planningBlockedTargets(
2303
3778
  ): Promise<string[]> {
2304
3779
  const blocked: string[] = [];
2305
3780
  for (const rawPath of targets.paths) {
3781
+ if (!(await hasUnsharedFileTarget(cwd, rawPath))) {
3782
+ blocked.push(rawPath);
3783
+ continue;
3784
+ }
2306
3785
  if (isAllowlistedPath(cwd, rawPath, sessionId)) continue;
2307
3786
  if (!(await isNeutralTempPath(cwd, rawPath))) blocked.push(rawPath);
2308
3787
  }
@@ -2384,7 +3863,8 @@ export async function getWorkflowMutationDecision(
2384
3863
  AUTORESEARCH_HARNESS_FILE_TOOL_NAMES.has(input.tool.name) &&
2385
3864
  targets.paths.length > 0 &&
2386
3865
  !targets.unknown &&
2387
- 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)
2388
3868
  ) {
2389
3869
  return { blocked: false, targets: targets.paths };
2390
3870
  }