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 +26 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +11 -11
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +63 -8
- package/src/agent/hooks.ts +2 -1
- package/src/agent/loop-guards.ts +18 -7
- package/src/agent/sandbox/authority.ts +105 -42
- package/src/agent/sandbox/bundle.ts +5 -2
- package/src/agent/sandbox/docker.ts +13 -3
- package/src/agent/sandbox/hands-source.text +540 -540
- package/src/agent/sandbox/policy.ts +23 -6
- package/src/agent/sandbox/supervisor.ts +21 -9
- package/src/agent/shell-lease.ts +2 -1
- package/src/agent/task-tool.ts +17 -3
- package/src/agent/tools.ts +2 -1
- package/src/commands/launch/input.ts +25 -3
- package/src/commands/launch.ts +26 -5
- package/src/commands/messenger.ts +2 -0
- package/src/commands/ralplan.ts +14 -1
- package/src/messenger/config.ts +27 -1
- package/src/messenger/discord-setup.ts +97 -17
- package/src/messenger/gateway.ts +58 -33
- package/src/messenger/types.ts +2 -0
- package/src/prompts/AGENTS.md +1 -1
- package/src/util/posix-tools.ts +66 -0
- package/src/util/retry.ts +12 -0
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.
|
|
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
package/src/agent/engine.ts
CHANGED
|
@@ -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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 && (
|
package/src/agent/hooks.ts
CHANGED
|
@@ -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",
|
package/src/agent/loop-guards.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
$
|
|
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
|
-
$
|
|
70
|
-
$
|
|
71
|
-
$
|
|
72
|
-
$
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 {
|
|
23
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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");
|