jeo-code 0.12.15 → 0.12.17

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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,29 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.12.17] - 2026-11-04
13
+
14
+ ### Fixed
15
+ - **Windows sandbox unmapped host paths** — a host path that matches no approved mount (`C:\…`, drive-relative `C:name`, UNC `\\server\share`, or a leading `\`) was forwarded to the Linux guest unchanged, where it is a *relative* name; a write to it succeeded and created an oddly named file inside the read-write workspace mount instead of being denied. It stayed inside the mount (no host path was written), but it violated the "unmounted paths are denied" contract. The supervisor now rejects such paths (tool arguments, `cwd`, and `exec` cwd) with `SANDBOX_POLICY: host path is outside approved mounts` before anything reaches the guest. POSIX hosts and mounted paths are unchanged. Found by `verify:sandbox` (`unmounted-sentinel`) on a native Windows host.
16
+ - **Windows shell resolution** — the `bash` tool, background jobs/monitors, and post-turn hooks spawned a bare `bash`, which on Windows resolves to WSL's `System32\bash.exe` (no `bun`, Windows paths absent). They now use the existing Git for Windows resolver, so verification commands no longer fail with exit 127 and hooks no longer time out.
17
+ - **Mid-turn premature stop** — plain-text progress narration after tools have run, or while mutations/hooks are pending, now bounces for a JSON tool call instead of ending the turn; a post-verification completion grace step and verification-cycle step-budget extensions keep iterative edit-test-fix loops from being cut off.
18
+ - **Live input** — mouse reports and terminal capability replies are stripped before they can reach the live draft; mid-turn file paths (`/tmp/a.ts`) and dollar amounts (`$100`) are steered to the model rather than treated as commands that abort the turn.
19
+ - **Subagents** — executors, read-only roles, and critics get bounded step-budget extensions, and a subagent whose `done` lacks the required role-contract sections is bounced once to complete them.
20
+
21
+ ### Known issues
22
+ - **Windows `verify:discord`** — 27 of 101 focused Discord tests fail on a native Windows host, both with and without the changes in this release (measured on the pre-change commit; not measured on other platforms), so its authoritative report is `failed` there. At least the attachment-staging check rejects a private directory because Windows reports mode `0o666`; the remainder are not yet triaged.
23
+
24
+ ## [0.12.16] - 2026-11-04
25
+
26
+ ### Upgrade
27
+ - **Discord messenger:** Existing configurations whose `cwd` overlaps the messenger profile directory now fail closed. Set `cwd` to a directory disjoint from the profile before restarting; re-running setup preserves an explicitly configured `cwd` and does not relocate files. Changing `cwd` starts new sessions for existing threads.
28
+
29
+ ### Fixed
30
+ - **Discord concurrency** — independent threads can progress concurrently, and same-thread follow-ups wait without occupying a worker slot; rejected full-queue requests receive a failure status without creating a thread.
31
+ - **Discord sandbox setup** — create fresh workspaces outside private configuration paths, validate workspace admission before replacing settings, include `sandbox_request` in the default tool list, and keep messenger profile files outside workspace and permission grants.
32
+ - **Antigravity model responses** — retry malformed function-call responses within the existing retry limit without executing incomplete tool calls or replaying already-streamed output.
33
+ - **Windows sandbox** — preserve owner-only ACL protection and serialize authority directory lifecycle.
34
+
12
35
  ## [0.12.15] - 2026-11-03
13
36
 
14
37
  The GitHub-only 0.12.14 release was not published to npm; its changes (listed under [0.12.14]) are included in this release.
@@ -2671,7 +2694,9 @@ _Initial release._
2671
2694
  ### Added
2672
2695
  - Initial jeo-code agent and CLI.
2673
2696
 
2674
- [Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.6...HEAD
2697
+ [Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.17...HEAD
2698
+ [0.12.17]: https://github.com/akillness/jeo-code/releases/tag/v0.12.17
2699
+ [0.12.16]: https://github.com/akillness/jeo-code/releases/tag/v0.12.16
2675
2700
  [0.12.6]: https://github.com/akillness/jeo-code/releases/tag/v0.12.6
2676
2701
  [0.11.4]: https://github.com/akillness/jeo-code/releases/tag/v0.11.4
2677
2702
  [0.11.3]: https://github.com/akillness/jeo-code/releases/tag/v0.11.3
package/README.ja.md CHANGED
@@ -447,10 +447,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
447
447
 
448
448
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
449
449
  - **[Unreleased]**
450
+ - **[0.12.17]** (2026-11-04)
451
+ - **[0.12.16]** (2026-11-04)
450
452
  - **[0.12.15]** (2026-11-03)
451
453
  - **[0.12.14]** (2026-11-03)
452
- - **[0.12.13]** (2026-11-03)
453
- - **[0.12.12]** (2026-11-03)
454
454
 
455
455
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
456
456
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -502,10 +502,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
502
502
 
503
503
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
504
504
  - **[Unreleased]**
505
+ - **[0.12.17]** (2026-11-04)
506
+ - **[0.12.16]** (2026-11-04)
505
507
  - **[0.12.15]** (2026-11-03)
506
508
  - **[0.12.14]** (2026-11-03)
507
- - **[0.12.13]** (2026-11-03)
508
- - **[0.12.12]** (2026-11-03)
509
509
 
510
510
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
511
511
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -37,6 +37,15 @@
37
37
  <a href="README.zh.md">中文</a>
38
38
  </p>
39
39
 
40
+ ## Install
41
+
42
+ Requires Bun `1.3.14+`.
43
+
44
+ ```bash
45
+ bun install -g jeo-code
46
+ jeo --version
47
+ ```
48
+
40
49
  Run `jeo` inside a repository and it reads files, edits them, runs commands, and drives the task to completion — streaming every step live in an inline, scrollback-friendly TUI.
41
50
 
42
51
  ## Documentation
@@ -105,15 +114,6 @@ jeo-code is a **local-first AI coding agent CLI** designed to safely drive entir
105
114
  - **macOS low file-descriptor-limit warning** — a low `ulimit -n` (BSD's 256/1024 default) risks opaque `EMFILE` failures from file watching, the browser tool, or a broad repo scan; jeo now warns once at launch (stderr only, never piped `-p` output) with concrete `ulimit`/`launchctl` guidance. Opt out with `JEO_SKIP_NOFILE_CHECK=1`.
106
115
 
107
116
 
108
- ## Install
109
-
110
- Requires Bun `1.3.14+`.
111
-
112
- ```bash
113
- bun install -g jeo-code
114
- jeo --version
115
- ```
116
-
117
117
 
118
118
  ## Quick start
119
119
 
@@ -491,10 +491,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
491
491
 
492
492
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
493
493
  - **[Unreleased]**
494
+ - **[0.12.17]** (2026-11-04)
495
+ - **[0.12.16]** (2026-11-04)
494
496
  - **[0.12.15]** (2026-11-03)
495
497
  - **[0.12.14]** (2026-11-03)
496
- - **[0.12.13]** (2026-11-03)
497
- - **[0.12.12]** (2026-11-03)
498
498
 
499
499
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
500
500
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -445,10 +445,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
445
445
 
446
446
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
447
447
  - **[Unreleased]**
448
+ - **[0.12.17]** (2026-11-04)
449
+ - **[0.12.16]** (2026-11-04)
448
450
  - **[0.12.15]** (2026-11-03)
449
451
  - **[0.12.14]** (2026-11-03)
450
- - **[0.12.13]** (2026-11-03)
451
- - **[0.12.12]** (2026-11-03)
452
452
 
453
453
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
454
454
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.12.15",
3
+ "version": "0.12.17",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
 
@@ -35,6 +35,13 @@ import { jeoEnv } from "../util/env";
35
35
  import { GUARD_LIMITS, isVerificationSignal, verificationProducedEvidence, repeatHint, classifyDoneGate } from "./loop-guards";
36
36
  import { stripLeakedReasoningTags } from "../ai/think-tags";
37
37
 
38
+ const MID_TASK_PROSE_RE =
39
+ /\b(?:i\s+will\s+now|i'll\s+now|let\s+me\s+(?:now\s+)?(?:read|check|inspect|search|find|edit|write|run|verify|update|fix|implement|create|test)|next,?\s+i\s+(?:will|need\s+to)|now\s+i\s+(?:will|need\s+to))\b|(?:이제|다음으로|먼저|이어서)\s+[^\n]{0,80}(?:수정|작성|실행|확인|구현|검증|진행|추가|변경|점검)(?:하겠|할게|해\s*보겠)/i;
40
+
41
+ export function looksLikeMidTaskProse(text: string): boolean {
42
+ return MID_TASK_PROSE_RE.test(text.trim());
43
+ }
44
+
38
45
 
39
46
  async function invokeCallLlm(history: Message[], options: {
40
47
  jsonMode: boolean;
@@ -653,6 +660,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
653
660
  let mutVerSeq = 0;
654
661
  let lastMutationSeq = 0;
655
662
  let lastVerificationSeq = 0;
663
+ let verificationCycles = 0;
664
+ let completionGraceUsed = false;
656
665
  let doneCorrections = 0;
657
666
  // F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
658
667
  // diagnostics the model saw but has not yet resolved (a later clean hook run
@@ -715,12 +724,17 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
715
724
  if (step > budget.limit()) {
716
725
  const decision = budget.tryExtend();
717
726
  if (!decision.extend) {
718
- budgetStopReason = decision.reason;
719
- break;
727
+ if (!completionGraceUsed && sawMutation && sawVerification && lastMutationSeq < lastVerificationSeq && !pendingHookFailure) {
728
+ completionGraceUsed = true;
729
+ } else {
730
+ budgetStopReason = decision.reason;
731
+ break;
732
+ }
733
+ } else {
734
+ // One surface per sink: budget-aware consumers get onBudget; others the notice.
735
+ if (ev.onBudget) ev.onBudget(decision.limit, decision.reason);
736
+ else ev.onNotice?.(decision.reason);
720
737
  }
721
- // One surface per sink: budget-aware consumers get onBudget; others the notice.
722
- if (ev.onBudget) ev.onBudget(decision.limit, decision.reason);
723
- else ev.onNotice?.(decision.reason);
724
738
  }
725
739
  if (opts.signal?.aborted) {
726
740
  return finish({ done: false, steps: step - 1, doneReason: "Cancelled." });
@@ -1043,11 +1057,45 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1043
1057
  } catch (err) {
1044
1058
  // Preserve chat-style final answers, but subject them to the same completion
1045
1059
  // gates as explicit done calls rather than treating prose as proof of success.
1060
+ // Mid-turn prose (after tools have already run, while mutations/hooks are
1061
+ // pending, or when the text narrates an upcoming action) must bounce for a
1062
+ // JSON tool call first instead of prematurely terminating the turn on step 1.
1046
1063
  const trimmed = responseText.trim();
1047
1064
  parseFailures++;
1048
- if (trimmed && (!trimmed.includes("{") || parseFailures > MAX_PARSE_BOUNCES)) {
1065
+ const hasTools = Object.keys(tools).length > 0;
1066
+ const midTurnInProgress =
1067
+ (step > 1 && hasTools) ||
1068
+ sawMutation ||
1069
+ pendingHookFailure !== null ||
1070
+ (hasTools && looksLikeMidTaskProse(trimmed));
1071
+ if (trimmed && (parseFailures > MAX_PARSE_BOUNCES || (!trimmed.includes("{") && !midTurnInProgress))) {
1072
+ const candidateReason = stripLeakedReasoningTags(trimmed) || trimmed;
1073
+ if (parseFailures <= MAX_PARSE_BOUNCES && ev.onBeforeDone) {
1074
+ const preCheck = await ev.onBeforeDone(candidateReason, {
1075
+ sawMutation,
1076
+ sawVerification,
1077
+ verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
1078
+ });
1079
+ if (preCheck) {
1080
+ if (doneCorrections >= GUARD_LIMITS.MAX_DONE_CORRECTIONS) {
1081
+ return finish({ done: false, steps: step, doneReason: `Stopped: completion rejected after ${doneCorrections} corrections. ${preCheck}` });
1082
+ }
1083
+ doneCorrections++;
1084
+ ev.onNotice?.(`done deferred — completion correction ${doneCorrections}/${GUARD_LIMITS.MAX_DONE_CORRECTIONS}`);
1085
+ ev.onAssistant?.(responseText, null);
1086
+ pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1087
+ history.push({
1088
+ role: "user",
1089
+ content:
1090
+ `Your last reply was plain text instead of a JSON tool call, and completion cannot finish yet (${preCheck}). ` +
1091
+ `Do NOT apologize. Reply with ONLY the next JSON tool call: {"tool":"<name>","arguments":{...}}.`,
1092
+ });
1093
+ step++;
1094
+ continue;
1095
+ }
1096
+ }
1049
1097
  salvagedAnswer = true;
1050
- invocation = { tool: "done", arguments: { reason: stripLeakedReasoningTags(trimmed) || trimmed } };
1098
+ invocation = { tool: "done", arguments: { reason: candidateReason } };
1051
1099
  } else {
1052
1100
  ev.onAssistant?.(responseText, null);
1053
1101
  pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
@@ -1187,8 +1235,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1187
1235
  // coarse target" and let `budget.record` fall back to the exact-args signature
1188
1236
  // instead (unchanged legacy novelty for tools this helper doesn't specifically know).
1189
1237
  const callTargets = toolCalls.map(c => {
1238
+ if ((c.tool || "").toLowerCase() === "task") return undefined;
1190
1239
  const t = toolTarget(c.tool, c.arguments);
1191
- return t.toLowerCase() === (c.tool || "").toLowerCase() ? undefined : t;
1240
+ if (t.toLowerCase() === (c.tool || "").toLowerCase()) return undefined;
1241
+ return verificationCycles > 0 ? `${t}@v${verificationCycles}` : t;
1192
1242
  });
1193
1243
 
1194
1244
 
@@ -1484,9 +1534,11 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1484
1534
  const command = String(toolCalls[i].arguments?.command ?? toolCalls[i].arguments?.cmd ?? "");
1485
1535
  const verificationCall = t === "bash" && isVerificationSignal(command);
1486
1536
  if (verificationCall) {
1537
+ if (sawMutation && verificationCycles < 4) verificationCycles++;
1487
1538
  if (results[i].success && verificationProducedEvidence(command, String(results[i].output ?? ""))) {
1488
1539
  sawVerification = true;
1489
1540
  lastVerificationSeq = ++mutVerSeq;
1541
+ if (!pendingHookFailure) doneCorrections = 0;
1490
1542
  } else {
1491
1543
  sawVerification = false;
1492
1544
  if (results[i].mutationMayHaveOccurred === true) {
@@ -1496,6 +1548,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1496
1548
  }
1497
1549
  continue;
1498
1550
  }
1551
+ if (t === "todo" && results[i].success && doneCorrections > 0 && (!sawMutation || (sawVerification && lastMutationSeq < lastVerificationSeq)) && !pendingHookFailure) {
1552
+ doneCorrections = 0;
1553
+ }
1499
1554
 
1500
1555
  const output = String(results[i].output ?? "").trim();
1501
1556
  const noOpMutation = results[i].success && (
@@ -21,6 +21,7 @@ export function withSandboxExecution<T>(scope: SandboxExecutionScope, run: () =>
21
21
 
22
22
  import * as fs from "node:fs/promises";
23
23
  import * as path from "node:path";
24
+ import { resolvePosixTool } from "../util/posix-tools";
24
25
  import { readGlobalConfig } from "./state";
25
26
  import type { HookConfig } from "./state";
26
27
 
@@ -134,7 +135,7 @@ async function runHookCommand(
134
135
  };
135
136
  }
136
137
 
137
- const proc = Bun.spawn(["bash", "-c", runCmd], {
138
+ const proc = Bun.spawn([resolvePosixTool("bash"), "-c", runCmd], {
138
139
  cwd,
139
140
  stdin: "pipe",
140
141
  stdout: "pipe",
@@ -72,20 +72,31 @@ export const GUARD_LIMITS = Object.freeze({
72
72
  * Recognized standalone verification commands. Matching the full command prevents
73
73
  * filenames, prose, and shell output from impersonating verification evidence.
74
74
  */
75
- export const VERIFY_SIGNAL_RE = /^(?:(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?(?:test|tests|typecheck|lint|build|check|spec|tsc|eslint|vitest|jest)(?::[\w-]+)?|(?:npx|bunx)\s+(?:tsc|eslint|vitest|jest|pytest)|(?:tsc|eslint|vitest|jest|pytest)|(?:python(?:3(?:\.\d+)?)?|uv)\s+(?:-m\s+)?pytest|cargo\s+(?:test|check|build|clippy)|go\s+(?:test|vet|build)|(?:make|just)\s+(?:test|check|lint|build|typecheck))(?:\s+[A-Za-z0-9_./:@+*?,=-]+)*$/i;
75
+ export const VERIFY_SIGNAL_RE = /^(?:(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?(?:test|tests|typecheck|lint|build|check|spec|tsc|eslint|vitest|jest)(?::[\w-]+)?|(?:npx|bunx)\s+(?:tsc|eslint|vitest|jest|pytest)|(?:tsc|eslint|vitest|jest|pytest)|(?:python(?:3(?:\.\d+)?)?|uv)\s+(?:(?:-m\s+)?pytest|-m\s+(?:unittest|py_compile|mypy|flake8|ruff\s+check))|node\s+(?:--test|--check|-c)|deno\s+(?:test|check|lint)|cargo\s+(?:test|check|build|clippy)|go\s+(?:test|vet|build)|(?:make|just)\s+(?:test|check|lint|build|typecheck))(?:\s+[A-Za-z0-9_./:@+*?,=-]+)*$/i;
76
76
  const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--list(?:-tests|Tests)?|-list|--collect-only|--co|--show-?config|--init|--dry-run|--no-run|--passWithNoTests|--if-present)(?=$|\s|=)/i;
77
77
  const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--write|--update|-u|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
78
78
  const MUTATING_VERIFY_SCRIPT_RE = /^(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?[\w-]+:[^\s]*(?:fix|update|write|deploy|publish)[^\s]*/i;
79
79
 
80
+ function isSingleVerificationSegment(segment: string): boolean {
81
+ const command = segment.trim();
82
+ if (!command) return false;
83
+ const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(command)
84
+ || /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(command);
85
+ return !shortNoOp && !/[;&|<>`$(){}\n\r]/.test(command) && VERIFY_SIGNAL_RE.test(command) && !NONEXECUTING_VERIFY_FLAG_RE.test(command) && !MUTATING_VERIFY_FLAG_RE.test(command) && !MUTATING_VERIFY_SCRIPT_RE.test(command);
86
+ }
87
+
80
88
  /**
81
- * True only when the command itself is a recognized verifier invocation. Output
89
+ * True only when the command itself is a recognized verifier invocation (or a
90
+ * pure `&&` chain where every segment is a recognized verifier invocation). Output
82
91
  * is never evidence: arbitrary commands can print words such as "tests passed".
83
92
  */
84
93
  export function isVerificationSignal(cmd: string): boolean {
85
94
  const command = cmd.trim();
86
- const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(command)
87
- || /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(command);
88
- return !shortNoOp && !/[;&|<>`$(){}\n\r]/.test(command) && VERIFY_SIGNAL_RE.test(command) && !NONEXECUTING_VERIFY_FLAG_RE.test(command) && !MUTATING_VERIFY_FLAG_RE.test(command) && !MUTATING_VERIFY_SCRIPT_RE.test(command);
95
+ if (!command) return false;
96
+ const withoutAndChain = command.replace(/&&/g, " ");
97
+ if (/[;&|<>`$(){}\n\r]/.test(withoutAndChain)) return false;
98
+ const segments = command.split("&&").map(s => s.trim());
99
+ return segments.length > 0 && segments.every(isSingleVerificationSegment);
89
100
  }
90
101
 
91
102
  /** Require test-run evidence for Go and Cargo's recognized text output. */
@@ -181,7 +192,7 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
181
192
  block: true,
182
193
  message:
183
194
  "You verified earlier, but then modified files again — your last passing test/build no longer reflects the current tree. " +
184
- "Re-run the narrowest verification command against the latest changes, then call done.",
195
+ "Re-run the narrowest verification command against the latest changes (e.g. a standalone bash call such as 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done.",
185
196
  };
186
197
  }
187
198
  return {
@@ -189,6 +200,6 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
189
200
  block: true,
190
201
  message:
191
202
  "You modified files this turn but have no passing verification (no test/build/typecheck command succeeded, or the latest recognized verification failed). " +
192
- "Run the narrowest command that proves your change works, then call done.",
203
+ "Run the narrowest command that proves your change works (e.g. a standalone bash call such as 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done.",
193
204
  };
194
205
  }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { canonicalSandboxPath, createSandboxPolicy, sandboxPathContains } from "./policy";
4
+ import { canonicalSandboxPath, createSandboxPolicy, sandboxPathContains, windowsSystemRoot } from "./policy";
5
5
  import type { SandboxConfig, SandboxMount, SandboxSupervisor, SandboxTermination } from "./types";
6
6
 
7
7
  /** Constructed by trusted parent entry points, never from model/tool arguments. */
@@ -50,61 +50,124 @@ export interface GrantAuthorityOptions {
50
50
 
51
51
  type DecisionWaiter = { resolve(value: GrantRequest): void; reject(reason: Error): void };
52
52
 
53
+ const pendingPrivateDirectories = new Map<string, Promise<string>>();
54
+
53
55
  /** Create app-private state, or verify existing state without widening its ACL. */
54
56
  export async function ensurePrivateSandboxDirectory(directory: string): Promise<string> {
55
57
  const canonical = await canonicalSandboxPath(directory, false);
56
- if (process.platform === "win32") {
57
- const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
58
- if (!systemRoot || !path.isAbsolute(systemRoot)) throw new Error("SANDBOX_STATE: Windows system directory is unavailable");
59
- await fs.mkdir(path.dirname(canonical), { recursive: true });
60
- // Fixed parent-owned program; paths are data, never interpolated into code.
61
- // Windows PowerShell 5.1 provides .NET Framework's atomic ACL creation API.
62
- const script = `
58
+ const key = process.platform === "win32" ? canonical.toLowerCase() : canonical;
59
+ const active = pendingPrivateDirectories.get(key);
60
+ if (active) return active;
61
+ const operation = (async (): Promise<string> => {
62
+ if (process.platform === "win32") {
63
+ const systemRoot = windowsSystemRoot();
64
+ await fs.mkdir(path.dirname(canonical), { recursive: true });
65
+ // Fixed parent-owned program; paths are data, never interpolated into code.
66
+ // Windows PowerShell 5.1 provides .NET Framework's ACL creation and SetAccessControl APIs.
67
+ // Note: Win32 CreateDirectoryW applies explicit ACEs from SECURITY_ATTRIBUTES but strips
68
+ // SE_DACL_PROTECTED unless SetAccessControl (SetNamedSecurityInfoW) is called afterwards.
69
+ const script = `
63
70
  $ErrorActionPreference = 'Stop'
71
+ $mutex = New-Object System.Threading.Mutex($false, 'Local\\jeo-sandbox-private-directory')
72
+ $locked = $false
64
73
  try {
74
+ try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
75
+ if (-not $locked) { throw 'mutex timeout' }
65
76
  $directory = $env:JEO_SANDBOX_PRIVATE_DIRECTORY
66
- $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
77
+ $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
78
+ $sid = $identity.User
79
+ $tokenOwner = $identity.Owner
67
80
  $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
81
+ $accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', $inherit, 'None', 'Allow')
82
+ $daclSecurity = New-Object System.Security.AccessControl.DirectorySecurity
83
+ $daclSecurity.SetAccessRuleProtection($true, $false)
84
+ $daclSecurity.AddAccessRule($accessRule)
85
+ $created = $false
68
86
  if (-not [System.IO.Directory]::Exists($directory)) {
69
- $security = New-Object System.Security.AccessControl.DirectorySecurity
70
- $security.SetAccessRuleProtection($true, $false)
71
- $security.SetOwner($sid)
72
- $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', $inherit, 'None', 'Allow')
73
- $security.AddAccessRule($rule)
74
- [void][System.IO.Directory]::CreateDirectory($directory, $security)
87
+ $createSecurity = New-Object System.Security.AccessControl.DirectorySecurity
88
+ $createSecurity.SetAccessRuleProtection($true, $false)
89
+ $createSecurity.SetOwner($sid)
90
+ $createSecurity.AddAccessRule($accessRule)
91
+ [void][System.IO.Directory]::CreateDirectory($directory, $createSecurity)
92
+ $created = $true
93
+ }
94
+ try {
95
+ if (([System.IO.File]::GetAttributes($directory) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'reparse point' }
96
+ if ($created) {
97
+ [System.IO.Directory]::SetAccessControl($directory, $daclSecurity)
98
+ $currentOwner = [System.IO.Directory]::GetAccessControl($directory).GetOwner([System.Security.Principal.SecurityIdentifier])
99
+ if ($currentOwner.Value -ne $sid.Value) {
100
+ $ownerSecurity = [System.IO.Directory]::GetAccessControl($directory)
101
+ $ownerSecurity.SetAccessRuleProtection($true, $false)
102
+ $ownerSecurity.SetOwner($sid)
103
+ [System.IO.Directory]::SetAccessControl($directory, $ownerSecurity)
104
+ }
105
+ } else {
106
+ $existing = [System.IO.Directory]::GetAccessControl($directory)
107
+ $existingOwner = $existing.GetOwner([System.Security.Principal.SecurityIdentifier])
108
+ $existingRules = $existing.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
109
+ if ($existingRules.Count -eq 1) {
110
+ $existingRule = $existingRules[0]
111
+ $ownerMatchesToken = ($existingOwner.Value -eq $sid.Value) -or ($null -ne $tokenOwner -and $existingOwner.Value -eq $tokenOwner.Value)
112
+ if ($ownerMatchesToken -and $existingRule.IdentityReference.Value -eq $sid.Value -and -not $existingRule.IsInherited -and $existingRule.AccessControlType -eq 'Allow' -and $existingRule.FileSystemRights -eq 'FullControl' -and $existingRule.InheritanceFlags -eq $inherit -and $existingRule.PropagationFlags -eq 'None') {
113
+ if (-not $existing.AreAccessRulesProtected) {
114
+ [System.IO.Directory]::SetAccessControl($directory, $daclSecurity)
115
+ }
116
+ if ($existingOwner.Value -ne $sid.Value) {
117
+ $ownerSecurity = [System.IO.Directory]::GetAccessControl($directory)
118
+ $ownerSecurity.SetAccessRuleProtection($true, $false)
119
+ $ownerSecurity.SetOwner($sid)
120
+ [System.IO.Directory]::SetAccessControl($directory, $ownerSecurity)
121
+ }
122
+ }
123
+ }
124
+ }
125
+ $actual = [System.IO.Directory]::GetAccessControl($directory)
126
+ if (-not $actual.AreAccessRulesProtected -or $actual.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'owner or inheritance' }
127
+ $rules = $actual.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
128
+ if ($rules.Count -ne 1) { throw 'unexpected access rules' }
129
+ $rule = $rules[0]
130
+ if ($rule.IdentityReference.Value -ne $sid.Value -or $rule.IsInherited -or $rule.AccessControlType -ne 'Allow' -or $rule.FileSystemRights -ne 'FullControl' -or $rule.InheritanceFlags -ne $inherit -or $rule.PropagationFlags -ne 'None') { throw 'non-private rule' }
131
+ } catch {
132
+ if ($created) { try { [System.IO.Directory]::Delete($directory, $false) } catch {} }
133
+ throw
75
134
  }
76
- if (([System.IO.File]::GetAttributes($directory) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'reparse point' }
77
- $actual = [System.IO.Directory]::GetAccessControl($directory)
78
- if (-not $actual.AreAccessRulesProtected -or $actual.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'owner or inheritance' }
79
- $rules = $actual.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
80
- if ($rules.Count -ne 1) { throw 'unexpected access rules' }
81
- $rule = $rules[0]
82
- if ($rule.IdentityReference.Value -ne $sid.Value -or $rule.IsInherited -or $rule.AccessControlType -ne 'Allow' -or $rule.FileSystemRights -ne 'FullControl' -or $rule.InheritanceFlags -ne $inherit -or $rule.PropagationFlags -ne 'None') { throw 'non-private rule' }
83
135
  [Console]::Out.Write('JEO_PRIVATE_DIRECTORY_VERIFIED')
84
136
  exit 0
85
137
  } catch { exit 1 }
138
+ finally {
139
+ if ($locked) { try { $mutex.ReleaseMutex() } catch {} }
140
+ $mutex.Dispose()
141
+ }
86
142
  `;
87
- const executable = path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
88
- const child = Bun.spawn([executable, "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
89
- env: { SystemRoot: systemRoot, JEO_SANDBOX_PRIVATE_DIRECTORY: canonical },
90
- stdin: "ignore", stdout: "pipe", stderr: "ignore",
91
- });
92
- const timer = setTimeout(() => child.kill(), 30_000);
93
- try {
94
- const [exitCode, output] = await Promise.all([child.exited, new Response(child.stdout).text()]);
95
- if (exitCode !== 0 || output !== "JEO_PRIVATE_DIRECTORY_VERIFIED") {
96
- throw new Error("SANDBOX_STATE: private Windows owner-only ACL could not be verified");
97
- }
98
- } finally { clearTimeout(timer); }
99
- } else {
100
- await fs.mkdir(canonical, { recursive: true, mode: 0o700 });
101
- }
102
- const stat = await fs.lstat(canonical);
103
- if (!stat.isDirectory() || stat.isSymbolicLink() ||
104
- (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()))) {
105
- throw new Error("SANDBOX_STATE: authority state must be a private owner-only directory");
143
+ const executable = path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
144
+ const child = Bun.spawn([executable, "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
145
+ env: { SystemRoot: systemRoot, JEO_SANDBOX_PRIVATE_DIRECTORY: canonical },
146
+ stdin: "ignore", stdout: "pipe", stderr: "ignore",
147
+ });
148
+ const timer = setTimeout(() => child.kill(), 30_000);
149
+ try {
150
+ const [exitCode, output] = await Promise.all([child.exited, new Response(child.stdout).text()]);
151
+ if (exitCode !== 0 || output !== "JEO_PRIVATE_DIRECTORY_VERIFIED") {
152
+ throw new Error("SANDBOX_STATE: private Windows owner-only ACL could not be verified");
153
+ }
154
+ } finally { clearTimeout(timer); }
155
+ } else {
156
+ await fs.mkdir(canonical, { recursive: true, mode: 0o700 });
157
+ }
158
+ const stat = await fs.lstat(canonical);
159
+ if (!stat.isDirectory() || stat.isSymbolicLink() ||
160
+ (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()))) {
161
+ throw new Error("SANDBOX_STATE: authority state must be a private owner-only directory");
162
+ }
163
+ return canonical;
164
+ })();
165
+ pendingPrivateDirectories.set(key, operation);
166
+ try {
167
+ return await operation;
168
+ } finally {
169
+ if (pendingPrivateDirectories.get(key) === operation) pendingPrivateDirectories.delete(key);
106
170
  }
107
- return canonical;
108
171
  }
109
172
 
110
173
  /** All mutable authority remains in this parent closure, outside worker mounts. */
@@ -19,8 +19,11 @@ export async function prepareHandsBundle(): Promise<string> {
19
19
  const temporary = path.join(staging, "hands.js");
20
20
  try {
21
21
  const output = await fs.open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
22
- try { await output.writeFile(source); await output.sync(); await output.chmod(0o400); }
23
- finally { await output.close(); }
22
+ try {
23
+ await output.writeFile(source);
24
+ await output.sync();
25
+ if (process.platform !== "win32") await output.chmod(0o400);
26
+ } finally { await output.close(); }
24
27
  try { await fs.link(temporary, file); }
25
28
  catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
26
29
  } finally { await fs.rm(staging, { recursive: true, force: true }); }
@@ -76,6 +76,14 @@ export function createDockerBackend(options: DockerBackendOptions = {}): Sandbox
76
76
  Bun.spawn([...argv], { env: environment, stdin: "pipe", stdout: "pipe", stderr: "pipe" }) as unknown as DockerChild);
77
77
  const command = (args: readonly string[]) => run([executable, ...args]);
78
78
 
79
+ function normalizeMountSource(value: string): string {
80
+ if (platform !== "win32") return value;
81
+ const match = /^\/(?:run\/desktop\/mnt\/host|host_mnt)\/([a-zA-Z])(?:\/(.*))?$/.exec(value.replace(/\\/g, "/"));
82
+ if (!match) return value;
83
+ const drive = `${match[1]!.toUpperCase()}:\\`;
84
+ return match[2] ? path.win32.join(drive, ...match[2].split("/").filter(Boolean)) : drive;
85
+ }
86
+
79
87
  async function checked(args: readonly string[], context: string): Promise<string> {
80
88
  let result: DockerCommandResult;
81
89
  try { result = await command(args); }
@@ -158,8 +166,9 @@ export function createDockerBackend(options: DockerBackendOptions = {}): Sandbox
158
166
  if (!labels?.[OWNER] || !labels[SESSION] || !/^\d+$/.test(labels[REVISION] ?? "") || !Array.isArray(record.Mounts)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed ownership or mounts");
159
167
  for (const mount of record.Mounts) {
160
168
  if (mount.Type === "tmpfs" && mount.Destination === "/tmp") continue;
161
- if (mount.Type !== "bind" || typeof mount.Source !== "string" || !path.isAbsolute(mount.Source)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed mount metadata");
162
- const source = await canonicalSandboxPath(mount.Source);
169
+ const normalizedSource = typeof mount.Source === "string" ? normalizeMountSource(mount.Source) : "";
170
+ if (mount.Type !== "bind" || !normalizedSource || !path.isAbsolute(normalizedSource)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed mount metadata");
171
+ const source = await canonicalSandboxPath(normalizedSource);
163
172
  if (sources.some(candidate => candidate !== source && (sandboxPathContains(candidate, source) || sandboxPathContains(source, candidate)))) {
164
173
  throw new Error("SANDBOX_MOUNT_OVERLAP: managed mount roots must be identical or disjoint");
165
174
  }
@@ -262,7 +271,8 @@ export function createDockerBackend(options: DockerBackendOptions = {}): Sandbox
262
271
  if (actual.length !== expected.length) throw new Error("SANDBOX_UNAVAILABLE: unexpected container bind mounts");
263
272
  for (const mount of expected) {
264
273
  const observed = actual.find(candidate => candidate.Destination === mount.guest);
265
- if (!observed?.Source || await canonicalSandboxPath(observed.Source) !== mount.source || observed.RW !== mount.writable) throw new Error("SANDBOX_UNAVAILABLE: actual container bind configuration changed");
274
+ const normalizedObserved = observed?.Source ? normalizeMountSource(observed.Source) : "";
275
+ if (!normalizedObserved || await canonicalSandboxPath(normalizedObserved) !== mount.source || observed?.RW !== mount.writable) throw new Error("SANDBOX_UNAVAILABLE: actual container bind configuration changed");
266
276
  }
267
277
  if (record.State?.Running === true) break;
268
278
  if (Date.now() >= deadline) throw new Error("SANDBOX_UNAVAILABLE: container did not start");