@gajae-code/coding-agent 0.18.10 → 0.18.12

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 (270) hide show
  1. package/CHANGELOG.md +295 -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/cli/accounts-cli.d.ts +2 -2
  7. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  8. package/dist/types/cli/install-activation.d.ts +10 -4
  9. package/dist/types/cli/public-command-errors.d.ts +1 -0
  10. package/dist/types/cli/update-cli.d.ts +14 -1
  11. package/dist/types/cli-ordinary.d.ts +2 -2
  12. package/dist/types/commands/accounts.d.ts +2 -1
  13. package/dist/types/commands/migrate.d.ts +1 -1
  14. package/dist/types/commands/sdk.d.ts +5 -0
  15. package/dist/types/commit/changelog/index.d.ts +1 -0
  16. package/dist/types/config/autorouting-tier-map.d.ts +30 -0
  17. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  18. package/dist/types/config/config-hot-reload.d.ts +39 -0
  19. package/dist/types/config/file-lock.d.ts +8 -1
  20. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  21. package/dist/types/config/model-profile-activation.d.ts +62 -6
  22. package/dist/types/config/model-registry.d.ts +56 -5
  23. package/dist/types/config/model-resolver.d.ts +2 -0
  24. package/dist/types/config/models-config-schema.d.ts +10 -0
  25. package/dist/types/config/settings-schema.d.ts +46 -3
  26. package/dist/types/config/settings.d.ts +33 -1
  27. package/dist/types/dap/config.d.ts +8 -4
  28. package/dist/types/debug/system-info.d.ts +6 -1
  29. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  30. package/dist/types/edit/modes/patch.d.ts +5 -0
  31. package/dist/types/edit/modes/replace.d.ts +3 -0
  32. package/dist/types/exec/bash-executor.d.ts +12 -0
  33. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  35. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  36. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  37. package/dist/types/migrate/adapters/index.d.ts +10 -1
  38. package/dist/types/migrate/executor.d.ts +3 -1
  39. package/dist/types/migrate/types.d.ts +15 -3
  40. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  42. package/dist/types/modes/theme/theme.d.ts +1 -0
  43. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  44. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  45. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  46. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  47. package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
  48. package/dist/types/sdk/broker/broker.d.ts +7 -1
  49. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  50. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  51. package/dist/types/sdk/broker/ensure.d.ts +136 -7
  52. package/dist/types/sdk/broker/hop.d.ts +36 -0
  53. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  54. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +4 -1
  56. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  57. package/dist/types/sdk/bus/index.d.ts +23 -0
  58. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  59. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  60. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  61. package/dist/types/sdk/host/session-runtime.d.ts +32 -0
  62. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  63. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  64. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  65. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  66. package/dist/types/sdk/session-list.d.ts +1 -1
  67. package/dist/types/session/agent-session.d.ts +58 -2
  68. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  69. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  70. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  71. package/dist/types/session/messages.d.ts +5 -1
  72. package/dist/types/session/session-manager.d.ts +13 -3
  73. package/dist/types/session/startup-auth-config.d.ts +24 -2
  74. package/dist/types/session/streaming-output.d.ts +11 -0
  75. package/dist/types/session/terminal-abort.d.ts +3 -1
  76. package/dist/types/session-import/service.d.ts +1 -1
  77. package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
  78. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  79. package/dist/types/task/executor.d.ts +19 -2
  80. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  81. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  82. package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
  83. package/dist/types/tools/descriptors.d.ts +2 -0
  84. package/dist/types/tools/fetch.d.ts +1 -1
  85. package/dist/types/tools/index.d.ts +2 -0
  86. package/dist/types/tools/python.d.ts +1 -1
  87. package/dist/types/tools/report-issue.d.ts +41 -0
  88. package/dist/types/tools/subagent.d.ts +23 -0
  89. package/dist/types/web/scrapers/types.d.ts +8 -1
  90. package/dist/types/web/search/providers/insane.d.ts +7 -1
  91. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  92. package/package.json +8 -8
  93. package/scripts/build-binary.ts +2 -1
  94. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  95. package/scripts/generate-docs-index.ts +8 -1
  96. package/scripts/generate-sdk-operation-inventory.ts +22 -4
  97. package/src/async/job-manager.ts +356 -56
  98. package/src/async/result-formatting.ts +40 -0
  99. package/src/autoresearch/git.ts +9 -2
  100. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  101. package/src/capability/fs.ts +27 -19
  102. package/src/capability/index.ts +2 -2
  103. package/src/cli/accounts-cli.ts +176 -8
  104. package/src/cli/auth-broker-cli.ts +21 -11
  105. package/src/cli/doctor/install-repairs.ts +35 -5
  106. package/src/cli/fast-help.ts +1 -1
  107. package/src/cli/install-activation.ts +47 -4
  108. package/src/cli/migrate-cli.ts +7 -5
  109. package/src/cli/public-command-errors.ts +4 -0
  110. package/src/cli/root-flags.ts +2 -2
  111. package/src/cli/stats-cli.ts +9 -5
  112. package/src/cli/update-cli.ts +224 -7
  113. package/src/cli-ordinary.ts +7 -2
  114. package/src/commands/accounts.ts +10 -5
  115. package/src/commands/migrate.ts +3 -3
  116. package/src/commands/sdk.ts +199 -42
  117. package/src/commands/ultragoal.ts +16 -8
  118. package/src/commit/changelog/index.ts +39 -1
  119. package/src/commit/message.ts +10 -3
  120. package/src/config/autorouting-tier-map.ts +30 -0
  121. package/src/config/config-hot-reload-trust.ts +148 -0
  122. package/src/config/config-hot-reload.ts +734 -0
  123. package/src/config/file-lock.ts +216 -56
  124. package/src/config/model-bindings-applier.ts +85 -47
  125. package/src/config/model-discovery-manager.ts +10 -0
  126. package/src/config/model-profile-activation.ts +716 -113
  127. package/src/config/model-registry.ts +1334 -158
  128. package/src/config/model-resolver.ts +3 -1
  129. package/src/config/models-config-schema.ts +6 -0
  130. package/src/config/settings-schema.ts +60 -3
  131. package/src/config/settings.ts +458 -24
  132. package/src/coordinator-mcp/policy.ts +26 -1
  133. package/src/crash/sanitize.ts +4 -0
  134. package/src/customization/import.ts +3 -1
  135. package/src/dap/config.ts +31 -11
  136. package/src/dap/defaults.json +1 -1
  137. package/src/debug/crash-diagnostics.ts +2 -1
  138. package/src/debug/report-bundle.ts +19 -0
  139. package/src/debug/system-info.ts +8 -3
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  141. package/src/discovery/agents-md.ts +1 -1
  142. package/src/discovery/helpers.ts +1 -2
  143. package/src/discovery/mcp-compat.ts +120 -17
  144. package/src/edit/apply-patch/index.ts +12 -1
  145. package/src/edit/index.ts +13 -17
  146. package/src/edit/modes/patch.ts +187 -8
  147. package/src/edit/modes/replace.ts +136 -0
  148. package/src/eval/js/context-manager.ts +29 -3
  149. package/src/eval/js/worker-entry.ts +51 -18
  150. package/src/eval/py/executor.ts +14 -4
  151. package/src/eval/py/runner.py +144 -5
  152. package/src/exec/bash-executor.ts +81 -4
  153. package/src/exec/bash-shell-guardian.ts +4 -1
  154. package/src/exec/bash-shell-supervisor.ts +8 -4
  155. package/src/exec/isolated-shell.ts +5 -1
  156. package/src/export/html/index.ts +46 -6
  157. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  158. package/src/extensibility/extensions/loader.ts +8 -1
  159. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  160. package/src/gjc-runtime/gc-runtime.ts +33 -6
  161. package/src/gjc-runtime/launch-tmux.ts +7 -2
  162. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  163. package/src/gjc-runtime/launch-worktree.ts +101 -14
  164. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  165. package/src/gjc-runtime/session-resolution.ts +38 -6
  166. package/src/gjc-runtime/session-state-lock.ts +59 -14
  167. package/src/gjc-runtime/state-runtime.ts +23 -19
  168. package/src/hooks/native-skill-hook.ts +14 -11
  169. package/src/internal-urls/docs-index.generated.ts +14 -13
  170. package/src/internal-urls/local-protocol.ts +142 -8
  171. package/src/lsp/index.ts +29 -0
  172. package/src/lsp/workspace-path.ts +23 -0
  173. package/src/migrate/action-planner.ts +57 -0
  174. package/src/migrate/adapters/claude-code.ts +23 -4
  175. package/src/migrate/adapters/codex.ts +22 -4
  176. package/src/migrate/adapters/index.ts +22 -4
  177. package/src/migrate/adapters/opencode.ts +6 -1
  178. package/src/migrate/executor.ts +84 -7
  179. package/src/migrate/report.ts +19 -2
  180. package/src/migrate/types.ts +14 -4
  181. package/src/modes/DESIGN.md +51 -0
  182. package/src/modes/acp/permission-mode.ts +18 -1
  183. package/src/modes/components/assistant-message.ts +55 -1
  184. package/src/modes/components/settings-selector.ts +103 -37
  185. package/src/modes/controllers/command-controller.ts +38 -2
  186. package/src/modes/controllers/plan-mode-controller.ts +17 -7
  187. package/src/modes/controllers/selector-controller.ts +62 -6
  188. package/src/modes/interactive-mode.ts +68 -0
  189. package/src/modes/theme/theme.ts +15 -0
  190. package/src/prompts/tools/debug.md +1 -1
  191. package/src/prompts/tools/read.md +1 -1
  192. package/src/prompts/tools/report-issue.md +13 -0
  193. package/src/prompts/tools/task.md +1 -1
  194. package/src/runtime-credential-selector.ts +2 -2
  195. package/src/runtime-mcp/content-limits.ts +7 -0
  196. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  197. package/src/runtime-mcp/manager.ts +32 -0
  198. package/src/runtime-mcp/oauth-flow.ts +32 -6
  199. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  200. package/src/runtime-mcp/render.ts +16 -1
  201. package/src/runtime-mcp/tool-bridge.ts +126 -17
  202. package/src/sdk/broker/broker-exit.ts +8 -5
  203. package/src/sdk/broker/broker.ts +342 -82
  204. package/src/sdk/broker/daemon-entry.ts +338 -116
  205. package/src/sdk/broker/doctor-restart.ts +2 -1
  206. package/src/sdk/broker/ensure.ts +1143 -265
  207. package/src/sdk/broker/hop.ts +274 -0
  208. package/src/sdk/broker/lifecycle-ledger.ts +35 -11
  209. package/src/sdk/broker/lock-host-identity.ts +29 -0
  210. package/src/sdk/broker/session-index.ts +62 -12
  211. package/src/sdk/bus/chat-command-policy.ts +16 -3
  212. package/src/sdk/bus/index.ts +52 -21
  213. package/src/sdk/bus/reconciliation-store.ts +201 -8
  214. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  215. package/src/sdk/cli/session-cli.ts +21 -6
  216. package/src/sdk/host/session-runtime.test.ts +118 -23
  217. package/src/sdk/host/session-runtime.ts +352 -47
  218. package/src/sdk/lifecycle/service.ts +1 -0
  219. package/src/sdk/prompt-deadline-flush.ts +78 -5
  220. package/src/sdk/prompt-deadline-manager.ts +68 -14
  221. package/src/sdk/protocol/adapter-validation.ts +53 -11
  222. package/src/sdk/protocol/operation-inventory.generated.json +101 -2
  223. package/src/sdk/reconciliation-extensions.ts +1 -0
  224. package/src/sdk/session-list.ts +1 -0
  225. package/src/sdk/session.ts +114 -61
  226. package/src/session/agent-session.ts +1043 -81
  227. package/src/session/internal/managed-session-scope.ts +126 -39
  228. package/src/session/internal/managed-session-storage.ts +14 -11
  229. package/src/session/internal/native-publish-outcome.ts +5 -1
  230. package/src/session/messages.ts +47 -6
  231. package/src/session/session-manager.ts +236 -14
  232. package/src/session/startup-auth-config.ts +116 -8
  233. package/src/session/streaming-output.ts +262 -4
  234. package/src/session/terminal-abort.ts +14 -2
  235. package/src/session-import/service.ts +1 -0
  236. package/src/skill-state/workflow-mutation-guard.ts +491 -32
  237. package/src/slash-commands/builtin-registry.ts +15 -3
  238. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  239. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  240. package/src/task/executor.ts +103 -12
  241. package/src/task/index.ts +389 -175
  242. package/src/tools/ast-edit-write-path.ts +64 -0
  243. package/src/tools/ast-edit.ts +18 -1
  244. package/src/tools/atomic-file-write.ts +46 -15
  245. package/src/tools/auto-generated-guard.ts +3 -0
  246. package/src/tools/bash-allowed-prefixes.ts +153 -5
  247. package/src/tools/bash-interactive.ts +14 -2
  248. package/src/tools/bash.ts +14 -0
  249. package/src/tools/debug.ts +7 -6
  250. package/src/tools/descriptors.test.ts +44 -0
  251. package/src/tools/descriptors.ts +34 -2
  252. package/src/tools/fetch.ts +3 -2
  253. package/src/tools/index.ts +2 -0
  254. package/src/tools/output-meta.ts +23 -2
  255. package/src/tools/python.ts +1 -1
  256. package/src/tools/read.ts +17 -35
  257. package/src/tools/report-issue.ts +184 -0
  258. package/src/tools/subagent-render.ts +114 -12
  259. package/src/tools/subagent.ts +108 -1
  260. package/src/tools/todo-write.ts +41 -3
  261. package/src/tools/tool-catalog.generated.ts +31 -3
  262. package/src/tools/write.ts +35 -3
  263. package/src/utils/commit-message-generator.ts +9 -2
  264. package/src/utils/file-mentions.ts +29 -1
  265. package/src/utils/title-generator.ts +7 -2
  266. package/src/web/scrapers/index.ts +3 -1
  267. package/src/web/scrapers/types.ts +8 -0
  268. package/src/web/scrapers/youtube.ts +3 -2
  269. package/src/web/search/providers/insane.ts +91 -28
  270. 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,14 +89,15 @@ 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",
@@ -1667,10 +1678,20 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
1667
1678
  targets.explicitMutation = true;
1668
1679
  const parts = shellWords(match[1] ?? "");
1669
1680
  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;
1681
+ const chmodModeIndex =
1682
+ commandName === "chmod" ? parts.findIndex(part => BASH_CHMOD_MODE_RE.test(cleanShellWord(part))) : -1;
1683
+ let targetParts = parts;
1684
+ if (
1685
+ commandName === "cp" ||
1686
+ commandName === "mv" ||
1687
+ commandName === "install" ||
1688
+ commandName === "truncate" ||
1689
+ commandName === "ln"
1690
+ ) {
1691
+ targetParts = parts.slice(-1);
1692
+ } else if (commandName === "chmod" && chmodModeIndex >= 0) {
1693
+ targetParts = parts.filter((_part, index) => index !== chmodModeIndex);
1694
+ }
1674
1695
  let extractedTarget = false;
1675
1696
  for (const part of targetParts) {
1676
1697
  const cleaned = cleanShellWord(part);
@@ -1698,12 +1719,315 @@ function extractGithubTargets(args: unknown): ExtractedTargets {
1698
1719
  return { paths: [], unknown: true, explicitMutation: true };
1699
1720
  }
1700
1721
 
1722
+ function simpleLiteral(raw: string): string | null {
1723
+ const trimmed = raw.trim();
1724
+ const quote = trimmed[0];
1725
+ if ((quote !== "'" && quote !== '"') || trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) return null;
1726
+ const body = trimmed.slice(1, -1);
1727
+ return /[\\\n${]/.test(body) || body.includes(quote) ? null : body;
1728
+ }
1729
+
1730
+ function skipNoise(code: string, index: number, language: "py" | "js"): number {
1731
+ if (language === "py" && (code.startsWith('"""', index) || code.startsWith("'''", index))) {
1732
+ const quote = code.slice(index, index + 3);
1733
+ const end = code.indexOf(quote, index + 3);
1734
+ return end < 0 ? code.length : end + 3;
1735
+ }
1736
+ const character = code[index] ?? "";
1737
+ if (character === "'" || character === '"') {
1738
+ let cursor = index + 1;
1739
+ while (cursor < code.length && code[cursor] !== character) cursor += code[cursor] === "\\" ? 2 : 1;
1740
+ return Math.min(code.length, cursor + 1);
1741
+ }
1742
+ if (
1743
+ (language === "py" && character === "#") ||
1744
+ (language === "js" && character === "/" && code[index + 1] === "/")
1745
+ ) {
1746
+ const end = code.indexOf("\n", index);
1747
+ return end < 0 ? code.length : end;
1748
+ }
1749
+ if (language === "js" && character === "/" && code[index + 1] === "*") {
1750
+ const end = code.indexOf("*/", index + 2);
1751
+ return end < 0 ? code.length : end + 2;
1752
+ }
1753
+ return index;
1754
+ }
1755
+
1756
+ function readArg(code: string, index: number): { raw: string; end: number } | null {
1757
+ let cursor = index;
1758
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
1759
+ if (cursor >= code.length || code[cursor] === ")") return null;
1760
+ const start = cursor;
1761
+ let depth = 0;
1762
+ while (cursor < code.length) {
1763
+ const character = code[cursor];
1764
+ if (character === "(") depth += 1;
1765
+ else if ((character === "," || character === ")") && depth === 0) break;
1766
+ else if (character === ")") depth -= 1;
1767
+ else if (character === "'" || character === '"') {
1768
+ const quote = character;
1769
+ cursor += 1;
1770
+ while (cursor < code.length && code[cursor] !== quote) cursor += code[cursor] === "\\" ? 2 : 1;
1771
+ }
1772
+ cursor += 1;
1773
+ }
1774
+ const raw = code.slice(start, cursor).trim();
1775
+ return raw.length > 0 ? { raw, end: cursor } : null;
1776
+ }
1777
+
1778
+ /** Omitted mode is Python's default `"r"`. A non-literal mode cannot be proved read-only. */
1779
+ function modeWrites(raw: string | undefined): boolean | null {
1780
+ if (raw === undefined) return false;
1781
+ const literal = simpleLiteral(raw);
1782
+ return literal === null ? null : /[wax+]/.test(literal);
1783
+ }
1784
+
1785
+ const PY_OS_MUTATORS = new Set("remove unlink rename replace rmtree move copy copy2 copyfile".split(" "));
1786
+ /** 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(" "));
1788
+ const JS_FS_MUTATORS = new Set(
1789
+ "writeFileSync writeFile appendFileSync appendFile rmSync unlinkSync renameSync copyFileSync mkdirSync openSync".split(
1790
+ " ",
1791
+ ),
1792
+ );
1793
+
1794
+ function nextCallArg(code: string, end: number): { raw: string; end: number } | null {
1795
+ let cursor = end;
1796
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
1797
+ if (code[cursor] !== ",") return null;
1798
+ return readArg(code, cursor + 1);
1799
+ }
1800
+
1801
+ function openModeWrites(code: string, first: { raw: string; end: number }): boolean | null {
1802
+ let cursor = first.end;
1803
+ let positionalIndex = 1;
1804
+ let mode: string | undefined;
1805
+ let unknownMode = false;
1806
+ while (cursor < code.length) {
1807
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
1808
+ if (code[cursor] !== ",") break;
1809
+ const argument = readArg(code, cursor + 1);
1810
+ if (!argument) break;
1811
+ const raw = argument.raw.trim();
1812
+ if (raw.startsWith("*") || raw.startsWith("**")) {
1813
+ unknownMode = true;
1814
+ } else {
1815
+ const keyword = /^([A-Za-z_]\w*)\s*=\s*([\s\S]*)$/.exec(raw);
1816
+ if (keyword?.[1] === "mode") {
1817
+ if (mode !== undefined) unknownMode = true;
1818
+ else mode = keyword[2];
1819
+ } else if (!keyword && positionalIndex === 1) {
1820
+ if (mode !== undefined) unknownMode = true;
1821
+ else mode = raw;
1822
+ }
1823
+ }
1824
+ positionalIndex += 1;
1825
+ cursor = argument.end;
1826
+ }
1827
+ if (unknownMode) return null;
1828
+ return modeWrites(mode);
1829
+ }
1830
+
1831
+ /** Whitespace or a backslash-newline may sit before an attribute (`shutil .copy`). */
1832
+ function skipCallGap(code: string, index: number): number {
1833
+ let cursor = index;
1834
+ while (cursor < code.length) {
1835
+ const character = code[cursor] ?? "";
1836
+ if (/\s/.test(character)) {
1837
+ cursor += 1;
1838
+ continue;
1839
+ }
1840
+ if (character === "\\" && code[cursor + 1] === "\n") {
1841
+ cursor += 2;
1842
+ continue;
1843
+ }
1844
+ if (character === "\\" && code[cursor + 1] === "\r" && code[cursor + 2] === "\n") {
1845
+ cursor += 3;
1846
+ continue;
1847
+ }
1848
+ break;
1849
+ }
1850
+ return cursor;
1851
+ }
1852
+
1853
+ function literalWrite(targets: ExtractedTargets, raw: string | undefined, write: boolean | null): void {
1854
+ if (write === false) return;
1855
+ targets.explicitMutation = true;
1856
+ const literal = raw === undefined ? null : simpleLiteral(raw);
1857
+ if (write === null || literal === null || literal.length === 0) {
1858
+ targets.unknown = true;
1859
+ return;
1860
+ }
1861
+ addPath(targets, literal);
1862
+ }
1863
+
1864
+ /** Arguments of one Path() call, through its own closing parenthesis. */
1865
+ function readPathConstructor(code: string, argAt: number): { parts: string[]; after: number } | null {
1866
+ let cursor = argAt;
1867
+ const parts: string[] = [];
1868
+ while (cursor < code.length) {
1869
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
1870
+ if (code[cursor] === ")") return { parts, after: cursor + 1 };
1871
+ const argument = readArg(code, cursor);
1872
+ if (!argument) return null;
1873
+ parts.push(argument.raw);
1874
+ cursor = argument.end;
1875
+ while (/\s/.test(code[cursor] ?? "")) cursor += 1;
1876
+ if (code[cursor] === ",") {
1877
+ cursor += 1;
1878
+ continue;
1879
+ }
1880
+ if (code[cursor] === ")") return { parts, after: cursor + 1 };
1881
+ return null;
1882
+ }
1883
+ return null;
1884
+ }
1885
+
1886
+ /** pathlib keeps the last absolute segment. A non-literal segment is an unresolved receiver. */
1887
+ function literalPathReceiver(parts: string[]): string | null {
1888
+ if (parts.length === 0) return ".";
1889
+ let joined = "";
1890
+ for (const part of parts) {
1891
+ const literal = simpleLiteral(part);
1892
+ if (literal === null) return null;
1893
+ if (path.isAbsolute(literal)) joined = literal;
1894
+ else joined = joined ? path.join(joined, literal) : literal;
1895
+ }
1896
+ return joined.length === 0 ? "." : joined;
1897
+ }
1898
+
1899
+ /** Calls inside strings or comments are skipped. A non-literal write path fails closed. */
1900
+ function scanInterpreterWrites(code: string, language: "py" | "js", targets: ExtractedTargets): void {
1901
+ let index = 0;
1902
+ while (index < code.length) {
1903
+ const trivia = skipNoise(code, index, language);
1904
+ if (trivia !== index) {
1905
+ index = trivia;
1906
+ continue;
1907
+ }
1908
+ const previous = index === 0 ? "" : (code[index - 1] ?? "");
1909
+ if ((index > 0 && /[\w]/.test(previous)) || !/[A-Za-z_]/.test(code[index] ?? "")) {
1910
+ index += 1;
1911
+ continue;
1912
+ }
1913
+ const ident = /^[A-Za-z_]\w*/.exec(code.slice(index));
1914
+ if (!ident) {
1915
+ index += 1;
1916
+ continue;
1917
+ }
1918
+ const name = ident[0];
1919
+ if ((name === "write" || name === "append") && previous === ".") {
1920
+ index += name.length;
1921
+ continue;
1922
+ }
1923
+ let cursor = skipCallGap(code, index + name.length);
1924
+ let dotted = "";
1925
+ if (code[cursor] === ".") {
1926
+ const methodAt = skipCallGap(code, cursor + 1);
1927
+ const dot = /^\w+/.exec(code.slice(methodAt));
1928
+ dotted = dot?.[0] ?? "";
1929
+ cursor = methodAt + dotted.length;
1930
+ }
1931
+ const paren = /^\s*\(/.exec(code.slice(cursor));
1932
+ const helper = (name === "write" || name === "append") && dotted === "";
1933
+ const open = (name === "open" && language === "py") || (name === "openSync" && language === "js");
1934
+ const pathCtor = name === "Path" && dotted === "";
1935
+ const osCall =
1936
+ language === "py" &&
1937
+ ((name === "os" && PY_OS_MUTATORS.has(dotted)) || (name === "shutil" && PY_OS_MUTATORS.has(dotted)));
1938
+ const jsCall =
1939
+ language === "js" && ((JS_FS_MUTATORS.has(name) && dotted === "") || (name === "Bun" && dotted === "write"));
1940
+ if (!paren || (!helper && !(open && dotted === "") && !pathCtor && !osCall && !jsCall)) {
1941
+ index += name.length;
1942
+ continue;
1943
+ }
1944
+ const argAt = cursor + paren[0].length;
1945
+ const first = readArg(code, argAt);
1946
+ if (pathCtor) {
1947
+ const ctor = readPathConstructor(code, argAt);
1948
+ if (!ctor) {
1949
+ index += 1;
1950
+ continue;
1951
+ }
1952
+ const method = /^\s*\.\s*(write_text|write_bytes)\b/.exec(code.slice(ctor.after));
1953
+ 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);
1958
+ }
1959
+ // Extra segments stay in the argument walk so a nested mutator is still recorded.
1960
+ index =
1961
+ ctor.parts.length > 1 ? index + name.length : Math.max(ctor.after + (method?.[0].length ?? 0), index + 1);
1962
+ continue;
1963
+ }
1964
+ if (!first) {
1965
+ literalWrite(targets, undefined, null);
1966
+ index = Math.max(argAt, index + 1);
1967
+ continue;
1968
+ }
1969
+ if (open && dotted === "") {
1970
+ literalWrite(targets, first.raw, openModeWrites(code, first));
1971
+ index = Math.max(first.end, index + 1);
1972
+ continue;
1973
+ }
1974
+ literalWrite(targets, first.raw, true);
1975
+ let end = first.end;
1976
+ if (TWO_ARG_MUTATORS.has(dotted || name)) {
1977
+ const second = nextCallArg(code, first.end);
1978
+ if (!second) literalWrite(targets, undefined, null);
1979
+ else {
1980
+ literalWrite(targets, second.raw, true);
1981
+ end = second.end;
1982
+ }
1983
+ }
1984
+ index = Math.max(end, index + 1);
1985
+ }
1986
+ }
1987
+
1988
+ function extractCodeToolTargets(toolName: string, args: unknown): ExtractedTargets {
1989
+ const targets: ExtractedTargets = { paths: [], unknown: false };
1990
+ const record = getRecord(args);
1991
+ if (!record) {
1992
+ targets.unknown = true;
1993
+ targets.explicitMutation = true;
1994
+ return targets;
1995
+ }
1996
+ if (toolName === "python") {
1997
+ if ((safeString(record.action) || "execute") === "clear") return targets;
1998
+ if (typeof record.code !== "string" || record.code.trim() === "") return targets;
1999
+ scanInterpreterWrites(record.code, "py", targets);
2000
+ return targets;
2001
+ }
2002
+ if (!Array.isArray(record.cells)) {
2003
+ targets.unknown = true;
2004
+ targets.explicitMutation = true;
2005
+ return targets;
2006
+ }
2007
+ for (const cell of record.cells) {
2008
+ const cellRecord = getRecord(cell);
2009
+ if (typeof cellRecord?.code !== "string") {
2010
+ targets.unknown = true;
2011
+ targets.explicitMutation = true;
2012
+ continue;
2013
+ }
2014
+ if (cellRecord.language === "py") scanInterpreterWrites(cellRecord.code, "py", targets);
2015
+ else if (cellRecord.language === "js") scanInterpreterWrites(cellRecord.code, "js", targets);
2016
+ else {
2017
+ targets.unknown = true;
2018
+ targets.explicitMutation = true;
2019
+ }
2020
+ }
2021
+ return targets;
2022
+ }
2023
+
1701
2024
  function extractTargets(tool: ToolWithEditMode, args: unknown): ExtractedTargets {
1702
2025
  if (tool.name === "write") return extractWriteTargets(args);
1703
2026
  if (tool.name === "ast_edit") return extractAstEditTargets(args);
1704
2027
  if (tool.name === "edit") return extractEditTargets(args, tool);
1705
2028
  if (tool.name === "bash") return extractBashTargets(args);
1706
2029
  if (tool.name === "github") return extractGithubTargets(args);
2030
+ if (tool.name === "python" || tool.name === "eval") return extractCodeToolTargets(tool.name, args);
1707
2031
  return { paths: [], unknown: true };
1708
2032
  }
1709
2033
 
@@ -1766,11 +2090,23 @@ function firstBlockedWorkflowStateSkill(cwd: string, targets: ExtractedTargets):
1766
2090
  return null;
1767
2091
  }
1768
2092
 
1769
- function isAllowlistedPath(cwd: string, rawPath: string): boolean {
2093
+ function planningSessionDirName(sessionId: string | null | undefined): string | undefined {
2094
+ const trimmed = sessionId?.trim();
2095
+ if (!trimmed) return undefined;
2096
+ return sessionDirName(trimmed);
2097
+ }
2098
+
2099
+ /** Specs and plans are planning-writable. A `_session-` directory must be this session's encoded name. */
2100
+ function isAllowlistedPath(cwd: string, rawPath: string, sessionId?: string | null): boolean {
1770
2101
  const segments = relativeGjcSegments(cwd, rawPath);
1771
2102
  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";
2103
+ const head = segments[1];
2104
+ if (head?.startsWith(GJC_SESSION_PREFIX)) {
2105
+ const bound = planningSessionDirName(sessionId);
2106
+ if (!bound || head !== bound) return false;
2107
+ return segments[2] === "specs" || segments[2] === "plans";
2108
+ }
2109
+ return head === "specs" || head === "plans";
1774
2110
  }
1775
2111
  function isBlockedGjcPath(cwd: string, rawPath: string): boolean {
1776
2112
  const segments = relativeGjcSegments(cwd, rawPath);
@@ -1781,21 +2117,119 @@ function hasBlockedGjcTarget(cwd: string, targets: ExtractedTargets): boolean {
1781
2117
  return targets.paths.some(rawPath => isBlockedGjcPath(cwd, rawPath));
1782
2118
  }
1783
2119
 
1784
- function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean {
2120
+ function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets, sessionId?: string | null): boolean {
1785
2121
  return (
1786
- !targets.unknown && targets.paths.length > 0 && targets.paths.every(rawPath => isAllowlistedPath(cwd, rawPath))
2122
+ !targets.unknown &&
2123
+ targets.paths.length > 0 &&
2124
+ targets.paths.every(rawPath => isAllowlistedPath(cwd, rawPath, sessionId))
1787
2125
  );
1788
2126
  }
1789
2127
 
1790
- function neutralTempRoots(): string[] {
2128
+ /** POSIX directories that are temp roots even when `TMPDIR` is unset or poisoned. */
2129
+ const POSIX_SYSTEM_TEMP_ROOTS = ["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp"];
2130
+
2131
+ /**
2132
+ * `os.tmpdir()` follows `TMPDIR`, and Bun copies `cwd/.env` into the environment
2133
+ * when that variable was unset. A repository can therefore name an arbitrary
2134
+ * directory. Declarations below are not neutral scratch roots.
2135
+ */
2136
+ function tempEnvNames(): readonly string[] {
2137
+ return process.platform === "win32" ? ["TEMP", "TMP", "TMPDIR"] : ["TMPDIR"];
2138
+ }
2139
+
2140
+ const PROJECT_TEMP_DOTENV_MAX_FILE_BYTES = 64 * 1024;
2141
+
2142
+ // The snapshot is the cwd at module import, not a directory created later or a chdir before this module is imported.
2143
+ const startupTempSnapshot = projectEnvSnapshot(process.cwd(), {
2144
+ maxFileBytes: PROJECT_TEMP_DOTENV_MAX_FILE_BYTES,
2145
+ });
2146
+
2147
+ function projectTempSnapshots(cwd: string): ProjectEnvSnapshot[] {
2148
+ // Check both the cwd's .env and the startup snapshot (for module-import-time cwd).
2149
+ // The cwd's .env catches project-declared TMPDIR in the current working directory.
2150
+ // The startup snapshot is a fallback for when the cwd's .env is deleted or inaccessible
2151
+ // (e.g., after unlink or chdir), and also handles the case where cwd differs from the
2152
+ // module-import-time cwd. These workspace-controlled files are read as bounded regular files.
2153
+ const snapshots: ProjectEnvSnapshot[] = [
2154
+ projectEnvSnapshot(cwd, { maxFileBytes: PROJECT_TEMP_DOTENV_MAX_FILE_BYTES }),
2155
+ startupTempSnapshot,
2156
+ ];
2157
+ return snapshots;
2158
+ }
2159
+
2160
+ /** macOS account temp directory. `getconf` does not read `TMPDIR`. */
2161
+ let darwinUserTempDirCache: string | undefined | null = null;
2162
+ function darwinUserTempDir(): string | undefined {
2163
+ if (process.platform !== "darwin") return undefined;
2164
+ if (darwinUserTempDirCache !== null) return darwinUserTempDirCache;
2165
+ darwinUserTempDirCache = undefined;
2166
+ try {
2167
+ const result = Bun.spawnSync({
2168
+ cmd: ["/usr/bin/getconf", "DARWIN_USER_TEMP_DIR"],
2169
+ env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C" },
2170
+ stdout: "pipe",
2171
+ stderr: "ignore",
2172
+ });
2173
+ if (result.exitCode !== 0) return undefined;
2174
+ const value = new TextDecoder().decode(result.stdout).trim();
2175
+ if (value) darwinUserTempDirCache = value;
2176
+ } catch {
2177
+ // Fixed `/tmp` roots still cover the POSIX default.
2178
+ }
2179
+ return darwinUserTempDirCache ?? undefined;
2180
+ }
2181
+
2182
+ function addResolvedTempRoot(roots: Set<string>, value: string | undefined): void {
2183
+ const trimmed = value?.trim();
2184
+ if (trimmed) roots.add(path.resolve(trimmed));
2185
+ }
2186
+
2187
+ function isPlatformTempPath(resolved: string): boolean {
2188
+ for (const root of POSIX_SYSTEM_TEMP_ROOTS) {
2189
+ const resolvedRoot = path.resolve(root);
2190
+ if (resolved === resolvedRoot || isPathWithin(resolvedRoot, resolved)) return true;
2191
+ }
2192
+ const darwin = darwinUserTempDir();
2193
+ if (!darwin) return false;
2194
+ const resolvedDarwin = path.resolve(darwin);
2195
+ return resolved === resolvedDarwin || isPathWithin(resolvedDarwin, resolved);
2196
+ }
2197
+
2198
+ /** True when `live` is declared, or cannot be trusted because a dotenv layer was unsafe to read. */
2199
+ function projectDeclaresTempPath(snapshots: readonly ProjectEnvSnapshot[], live: string): boolean {
2200
+ const trimmedLive = live.trim();
2201
+ if (!trimmedLive) return false;
2202
+ const resolvedLive = path.resolve(trimmedLive);
2203
+ for (const snapshot of snapshots) {
2204
+ if (snapshot.incomplete && !isPlatformTempPath(resolvedLive)) return true;
2205
+ for (const name of tempEnvNames()) {
2206
+ const key = canonicalEnvKey(name);
2207
+ const declared = snapshot.values[key];
2208
+ if (declared === undefined) continue;
2209
+ if (snapshot.dynamic.has(key)) {
2210
+ // Expansion is not comparable. A dynamic value that landed on a
2211
+ // real system temp dir is already allowed through those roots.
2212
+ if (!isPlatformTempPath(resolvedLive)) return true;
2213
+ continue;
2214
+ }
2215
+ const trimmedDeclared = declared.trim();
2216
+ if (trimmedDeclared && path.resolve(trimmedDeclared) === resolvedLive) return true;
2217
+ }
2218
+ }
2219
+ return false;
2220
+ }
2221
+
2222
+ function neutralTempRoots(cwd: string): string[] {
1791
2223
  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);
2224
+ for (const fixed of POSIX_SYSTEM_TEMP_ROOTS) addResolvedTempRoot(roots, fixed);
2225
+ addResolvedTempRoot(roots, darwinUserTempDir());
2226
+ const candidates = [os.tmpdir(), process.env.TMPDIR];
2227
+ if (process.platform === "win32") candidates.push(process.env.TEMP, process.env.TMP);
2228
+ const snapshots = projectTempSnapshots(cwd);
2229
+ for (const candidate of candidates) {
2230
+ if (!candidate || projectDeclaresTempPath(snapshots, candidate)) continue;
2231
+ addResolvedTempRoot(roots, candidate);
2232
+ }
1799
2233
  return [...roots];
1800
2234
  }
1801
2235
 
@@ -1849,18 +2283,27 @@ async function isNeutralTempPath(cwd: string, rawPath: string): Promise<boolean>
1849
2283
  if (unknown || !absolutePath) return false;
1850
2284
  const resolvedCwd = path.resolve(cwd);
1851
2285
  if (isPathWithin(resolvedCwd, absolutePath)) return false;
1852
- if (!neutralTempRoots().some(root => isPathWithin(root, absolutePath))) return false;
2286
+ const roots = neutralTempRoots(cwd);
2287
+ const realRoots = await Promise.all(roots.map(realpathOrSelf));
2288
+ // Accept both spellings (`/var/folders` and `/private/var/folders`) before the
2289
+ // canonical re-check. The re-check still rejects a temp path whose real
2290
+ // target is inside the project.
2291
+ const lexicalRoots = new Set([...roots, ...realRoots]);
2292
+ if (![...lexicalRoots].some(root => isPathWithin(root, absolutePath))) return false;
1853
2293
  const realTarget = await canonicalizeForContainment(absolutePath);
1854
2294
  if (isPathWithin(await realpathOrSelf(resolvedCwd), realTarget)) return false;
1855
- const realRoots = await Promise.all(neutralTempRoots().map(realpathOrSelf));
1856
2295
  return realRoots.some(root => isPathWithin(root, realTarget));
1857
2296
  }
1858
2297
 
1859
2298
  /** Targets that remain disallowed during a planning phase (excludes neutral temp scratch). */
1860
- async function planningBlockedTargets(cwd: string, targets: ExtractedTargets): Promise<string[]> {
2299
+ async function planningBlockedTargets(
2300
+ cwd: string,
2301
+ targets: ExtractedTargets,
2302
+ sessionId?: string | null,
2303
+ ): Promise<string[]> {
1861
2304
  const blocked: string[] = [];
1862
2305
  for (const rawPath of targets.paths) {
1863
- if (isAllowlistedPath(cwd, rawPath)) continue;
2306
+ if (isAllowlistedPath(cwd, rawPath, sessionId)) continue;
1864
2307
  if (!(await isNeutralTempPath(cwd, rawPath))) blocked.push(rawPath);
1865
2308
  }
1866
2309
  return blocked;
@@ -1882,12 +2325,15 @@ export async function assertWorkflowMutationRawPathsAllowed(input: {
1882
2325
  throw new ToolError(`${WORKFLOW_STATE_MUTATION_BLOCK_MESSAGE}\nUse: ${command}`);
1883
2326
  }
1884
2327
  if (input.forceOverride) return;
1885
- const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId, input.guardContext);
2328
+ const guardContext =
2329
+ input.guardContext ??
2330
+ (await readWorkflowGuardContext(input.cwd, { sessionId: input.sessionId, threadId: input.threadId }));
2331
+ const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId, guardContext);
1886
2332
 
1887
2333
  if (!planning) return;
1888
2334
  const message = planningPhaseBlockMessage(planning.skill);
1889
2335
  if (input.rawPaths.length === 0) throw new ToolError(message);
1890
- const blocked = await planningBlockedTargets(input.cwd, targets);
2336
+ const blocked = await planningBlockedTargets(input.cwd, targets, guardContext.sessionId);
1891
2337
  if (blocked.length > 0) throw new ToolError(message);
1892
2338
  }
1893
2339
 
@@ -1907,7 +2353,10 @@ export async function getWorkflowMutationDecision(
1907
2353
  command,
1908
2354
  };
1909
2355
  }
1910
- const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId, input.guardContext);
2356
+ const guardContext =
2357
+ input.guardContext ??
2358
+ (await readWorkflowGuardContext(input.cwd, { sessionId: input.sessionId, threadId: input.threadId }));
2359
+ const planning = await getActivePlanningSkill(input.cwd, input.sessionId, input.threadId, guardContext);
1911
2360
  if (!planning) {
1912
2361
  return { blocked: false, targets: [] };
1913
2362
  }
@@ -1919,12 +2368,20 @@ export async function getWorkflowMutationDecision(
1919
2368
  // name is not authorization: an `autoresearch/*` branch isolates keep/discard
1920
2369
  // bookkeeping, it does not turn product edits into research.
1921
2370
  //
2371
+ // Shell and code-execution tools can run multiple operations before writing,
2372
+ // so checking the harness once cannot protect the later write destination.
2373
+ // Only direct file-mutation tools may use the harness exception.
1922
2374
  // The allowance requires at least one named target: `every` is vacuously true
1923
2375
  // for an empty list, so an opaque mutation that resolves to NO target (e.g.
1924
2376
  // `python3 -c "open('src/product.ts','w').write('x')"`) would otherwise skip
1925
2377
  // the fail-closed check below and be waved through on a research mission.
2378
+ // Bash is a compound command list, not a write boundary: an earlier `ln` or
2379
+ // `mv` can retarget the harness before a later redirection. Harness changes
2380
+ // must use a single-target file tool so the final path is checked for symlinks
2381
+ // immediately before that write operation.
1926
2382
  if (
1927
2383
  planning.skill === "autoresearch" &&
2384
+ AUTORESEARCH_HARNESS_FILE_TOOL_NAMES.has(input.tool.name) &&
1928
2385
  targets.paths.length > 0 &&
1929
2386
  !targets.unknown &&
1930
2387
  targets.paths.every(p => isAutoresearchAuthorizedResearchPath(input.cwd, p))
@@ -1946,7 +2403,7 @@ export async function getWorkflowMutationDecision(
1946
2403
  // Neutral temp scratch (outside the project tree) stays writable so agents can
1947
2404
  // stage artifacts and feed their path to the sanctioned `gjc ... --write` CLIs.
1948
2405
  // Read-only / `gjc` bash extract no targets and fall through to allowed here.
1949
- const blockedTargets = await planningBlockedTargets(input.cwd, targets);
2406
+ const blockedTargets = await planningBlockedTargets(input.cwd, targets, guardContext.sessionId);
1950
2407
  if (blockedTargets.length === 0) {
1951
2408
  return { blocked: false, targets: targets.paths };
1952
2409
  }
@@ -1954,7 +2411,9 @@ export async function getWorkflowMutationDecision(
1954
2411
  blocked: true,
1955
2412
  message,
1956
2413
  targets: targets.paths,
1957
- reason: allTargetsAllowlisted(input.cwd, targets) ? "handoff-artifact-tool-target" : "phase-boundary",
2414
+ reason: allTargetsAllowlisted(input.cwd, targets, guardContext.sessionId)
2415
+ ? "handoff-artifact-tool-target"
2416
+ : "phase-boundary",
1958
2417
  };
1959
2418
  }
1960
2419