jeo-code 0.12.13 → 0.12.16

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 (64) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.ja.md +33 -9
  3. package/README.ko.md +33 -9
  4. package/README.md +33 -9
  5. package/README.zh.md +33 -9
  6. package/package.json +4 -2
  7. package/src/agent/browser-tool.ts +9 -1
  8. package/src/agent/config-schema.ts +8 -0
  9. package/src/agent/context-files.ts +19 -0
  10. package/src/agent/engine.ts +25 -6
  11. package/src/agent/eval-tool.ts +6 -0
  12. package/src/agent/goal-verifier.ts +30 -6
  13. package/src/agent/hooks.ts +52 -4
  14. package/src/agent/job-registry.ts +110 -18
  15. package/src/agent/job-tool.ts +43 -9
  16. package/src/agent/loop-guards.ts +17 -2
  17. package/src/agent/memory.ts +151 -112
  18. package/src/agent/monitor-registry.ts +142 -30
  19. package/src/agent/monitor-tool.ts +34 -6
  20. package/src/agent/notify/discord-daemon.ts +135 -42
  21. package/src/agent/notify/session-endpoint.ts +27 -5
  22. package/src/agent/sandbox/assets.d.ts +4 -0
  23. package/src/agent/sandbox/authority.ts +358 -0
  24. package/src/agent/sandbox/bundle.ts +40 -0
  25. package/src/agent/sandbox/control.ts +49 -0
  26. package/src/agent/sandbox/docker.ts +293 -0
  27. package/src/agent/sandbox/hands-source.text +14548 -0
  28. package/src/agent/sandbox/hands-worker.ts +168 -0
  29. package/src/agent/sandbox/policy.ts +172 -0
  30. package/src/agent/sandbox/rpc.ts +90 -0
  31. package/src/agent/sandbox/supervisor.ts +210 -0
  32. package/src/agent/sandbox/types.ts +77 -0
  33. package/src/agent/shell-lease.ts +190 -0
  34. package/src/agent/state.ts +167 -0
  35. package/src/agent/subagents.ts +4 -3
  36. package/src/agent/task-tool.ts +8 -0
  37. package/src/agent/todo-tool.ts +30 -1
  38. package/src/agent/tool-schemas.ts +13 -0
  39. package/src/ai/model-discovery.ts +12 -0
  40. package/src/ai/providers/anthropic.ts +27 -2
  41. package/src/ai/providers/antigravity.ts +10 -2
  42. package/src/ai/providers/gemini.ts +20 -16
  43. package/src/ai/providers/ollama.ts +14 -0
  44. package/src/ai/providers/openai-responses.ts +22 -5
  45. package/src/ai/providers/openai.ts +23 -40
  46. package/src/cli/runner.ts +3 -0
  47. package/src/commands/approve.ts +37 -13
  48. package/src/commands/launch/flags.ts +2 -1
  49. package/src/commands/launch/jobs-slash.ts +1 -1
  50. package/src/commands/launch.ts +275 -35
  51. package/src/commands/messenger.ts +107 -39
  52. package/src/commands/notify.ts +2 -2
  53. package/src/commands/team.ts +44 -12
  54. package/src/commands/ultragoal.ts +6 -16
  55. package/src/messenger/activity.ts +2 -1
  56. package/src/messenger/config.ts +78 -10
  57. package/src/messenger/discord-setup.ts +216 -34
  58. package/src/messenger/discord.ts +37 -43
  59. package/src/messenger/gateway.ts +85 -37
  60. package/src/messenger/model-selection.ts +101 -0
  61. package/src/messenger/session-pool.ts +189 -35
  62. package/src/messenger/types.ts +5 -2
  63. package/src/skills/catalog.ts +207 -0
  64. package/src/util/retry.ts +12 -0
@@ -0,0 +1,358 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { canonicalSandboxPath, createSandboxPolicy, sandboxPathContains, windowsSystemRoot } from "./policy";
5
+ import type { SandboxConfig, SandboxMount, SandboxSupervisor, SandboxTermination } from "./types";
6
+
7
+ /** Constructed by trusted parent entry points, never from model/tool arguments. */
8
+ export interface SandboxActor {
9
+ readonly origin: "terminal" | "discord" | "model";
10
+ readonly sessionKey: string;
11
+ readonly userId?: string;
12
+ }
13
+
14
+ export interface GrantRequest {
15
+ readonly id: string;
16
+ readonly sessionKey: string;
17
+ readonly revision: number;
18
+ readonly host: string;
19
+ readonly mode: "ro" | "rw";
20
+ readonly status: "pending" | "approved" | "denied" | "stale";
21
+ }
22
+
23
+ export interface GrantAuthoritySnapshot {
24
+ readonly sessionKey: string;
25
+ readonly revision: number;
26
+ readonly grants: readonly SandboxMount[];
27
+ readonly requests: readonly GrantRequest[];
28
+ readonly blocked: boolean;
29
+ readonly stateDirectory: string;
30
+ }
31
+
32
+ export interface GrantAuthority {
33
+ requestAccess(host: string, mode: "ro" | "rw"): Promise<GrantRequest>;
34
+ approve(requestId: string, actor: SandboxActor): Promise<GrantRequest>;
35
+ reject(requestId: string, actor: SandboxActor): Promise<GrantRequest>;
36
+ revoke(actor: SandboxActor): Promise<SandboxTermination>;
37
+ snapshot(actor?: SandboxActor): GrantAuthoritySnapshot;
38
+ awaitDecision(requestId: string, signal?: AbortSignal): Promise<GrantRequest>;
39
+ }
40
+
41
+ export interface GrantAuthorityOptions {
42
+ sessionKey: string;
43
+ cwd: string;
44
+ config: SandboxConfig;
45
+ supervisor: SandboxSupervisor;
46
+ allowedUserIds: readonly string[];
47
+ stateDirectory: string;
48
+ privateRoots?: readonly string[];
49
+ }
50
+
51
+ type DecisionWaiter = { resolve(value: GrantRequest): void; reject(reason: Error): void };
52
+
53
+ const pendingPrivateDirectories = new Map<string, Promise<string>>();
54
+
55
+ /** Create app-private state, or verify existing state without widening its ACL. */
56
+ export async function ensurePrivateSandboxDirectory(directory: string): Promise<string> {
57
+ const canonical = await canonicalSandboxPath(directory, false);
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 = `
70
+ $ErrorActionPreference = 'Stop'
71
+ $mutex = New-Object System.Threading.Mutex($false, 'Local\\jeo-sandbox-private-directory')
72
+ $locked = $false
73
+ try {
74
+ try { $locked = $mutex.WaitOne(30000) } catch [System.Threading.AbandonedMutexException] { $locked = $true }
75
+ if (-not $locked) { throw 'mutex timeout' }
76
+ $directory = $env:JEO_SANDBOX_PRIVATE_DIRECTORY
77
+ $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
78
+ $sid = $identity.User
79
+ $tokenOwner = $identity.Owner
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
86
+ if (-not [System.IO.Directory]::Exists($directory)) {
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
134
+ }
135
+ [Console]::Out.Write('JEO_PRIVATE_DIRECTORY_VERIFIED')
136
+ exit 0
137
+ } catch { exit 1 }
138
+ finally {
139
+ if ($locked) { try { $mutex.ReleaseMutex() } catch {} }
140
+ $mutex.Dispose()
141
+ }
142
+ `;
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);
170
+ }
171
+ }
172
+
173
+ /** All mutable authority remains in this parent closure, outside worker mounts. */
174
+ export async function createGrantAuthority(options: GrantAuthorityOptions): Promise<GrantAuthority> {
175
+ const sessionKey = options.sessionKey;
176
+ const supervisor = options.supervisor;
177
+ const allowedUserIds = new Set(options.allowedUserIds);
178
+ const cwd = await canonicalSandboxPath(options.cwd);
179
+ const stateDirectory = await canonicalSandboxPath(options.stateDirectory, false);
180
+ const privatePaths = await Promise.all([stateDirectory, ...(options.privateRoots ?? [])]
181
+ .map(value => canonicalSandboxPath(value, false)));
182
+ if (sandboxPathContains(cwd, stateDirectory) || sandboxPathContains(stateDirectory, cwd)) {
183
+ throw new Error("SANDBOX_STATE: authority state must be outside workspace mounts");
184
+ }
185
+ const initialPolicy = await createSandboxPolicy({
186
+ sessionKey, revision: 0, cwd, config: options.config, privatePaths,
187
+ });
188
+ await ensurePrivateSandboxDirectory(stateDirectory);
189
+ const config = initialPolicy.config;
190
+ let revision = initialPolicy.revision;
191
+ let grants: readonly SandboxMount[] = Object.freeze([]);
192
+ const requests = new Map<string, GrantRequest>();
193
+ const waiters = new Map<string, Set<DecisionWaiter>>();
194
+ let failed = false;
195
+ let transitioning = false;
196
+ let queue: Promise<unknown> = Promise.resolve();
197
+
198
+ function serialize<T>(operation: () => Promise<T> | T): Promise<T> {
199
+ const next = queue.then(operation);
200
+ queue = next.catch(() => undefined);
201
+ return next;
202
+ }
203
+
204
+ function authorize(actor: SandboxActor): void {
205
+ if (!actor || actor.origin === "model" ||
206
+ (actor.origin !== "terminal" && actor.origin !== "discord") ||
207
+ (actor.origin === "discord" && (!actor.userId || !allowedUserIds.has(actor.userId)))) {
208
+ throw new Error("UNAUTHORIZED: sandbox decisions require an authenticated human");
209
+ }
210
+ if (actor.sessionKey !== sessionKey) throw new Error("SESSION_MISMATCH: sandbox actor belongs to another session");
211
+ }
212
+
213
+ function requireAvailable(): void {
214
+ if (failed) throw new Error("REVOKE_INCOMPLETE: sandbox authority is blocked after an unconfirmed transition");
215
+ }
216
+
217
+ function pendingRequest(id: string): GrantRequest {
218
+ const request = requests.get(id);
219
+ if (!request) throw new Error("UNKNOWN_REQUEST: sandbox request does not exist in this session");
220
+ if (request.revision !== revision || request.status !== "pending") {
221
+ throw new Error("STALE_REQUEST: sandbox request is no longer pending at the current revision");
222
+ }
223
+ return request;
224
+ }
225
+
226
+ function decide(request: GrantRequest, status: GrantRequest["status"]): GrantRequest {
227
+ const decided = Object.freeze({ ...request, status });
228
+ requests.set(request.id, decided);
229
+ const listeners = waiters.get(request.id);
230
+ waiters.delete(request.id);
231
+ for (const waiter of listeners ?? []) waiter.resolve(decided);
232
+ return decided;
233
+ }
234
+
235
+ function stalePending(): void {
236
+ for (const request of requests.values()) {
237
+ if (request.status === "pending") decide(request, "stale");
238
+ }
239
+ }
240
+
241
+ function block(error: unknown): never {
242
+ failed = true;
243
+ stalePending();
244
+ throw new Error("REVOKE_INCOMPLETE: sandbox transition could not confirm removal of old authority", { cause: error });
245
+ }
246
+
247
+ return Object.freeze({
248
+ async requestAccess(host: string, mode: "ro" | "rw"): Promise<GrantRequest> {
249
+ return serialize(async () => {
250
+ requireAvailable();
251
+ if (mode !== "ro" && mode !== "rw") throw new Error("SANDBOX_POLICY: invalid access mode");
252
+ if (typeof host !== "string" || !host.trim()) throw new Error("SANDBOX_PATH: invalid host path");
253
+ const canonical = await canonicalSandboxPath(host, true, cwd);
254
+ // Reuse the policy validator for every private-path and mount restriction.
255
+ await createSandboxPolicy({ sessionKey, revision, cwd, config, grants: [...grants, { host: canonical, mode }], privatePaths });
256
+ const request: GrantRequest = Object.freeze({
257
+ id: randomUUID(), sessionKey, revision, host: canonical, mode, status: "pending",
258
+ });
259
+ requests.set(request.id, request);
260
+ return request;
261
+ });
262
+ },
263
+
264
+ async approve(requestId: string, actor: SandboxActor): Promise<GrantRequest> {
265
+ authorize(actor);
266
+ return serialize(async () => {
267
+ requireAvailable();
268
+ const request = pendingRequest(requestId);
269
+ try {
270
+ if (await canonicalSandboxPath(request.host) !== request.host) {
271
+ throw new Error("Requested resource changed its canonical path");
272
+ }
273
+ } catch (error) {
274
+ decide(request, "stale");
275
+ throw new Error("STALE_REQUEST: requested resource no longer has its original canonical path", { cause: error });
276
+ }
277
+ const policy = await createSandboxPolicy({
278
+ sessionKey, revision: revision + 1, cwd, config,
279
+ grants: [...grants, { host: request.host, mode: request.mode }], privatePaths,
280
+ });
281
+ transitioning = true;
282
+ try {
283
+ await supervisor.replacePolicy(policy);
284
+ revision = policy.revision;
285
+ grants = Object.freeze(policy.mounts.filter(mount => mount.host !== cwd));
286
+ const approved = decide(request, "approved");
287
+ stalePending();
288
+ return approved;
289
+ } catch (error) {
290
+ return block(error);
291
+ } finally {
292
+ transitioning = false;
293
+ }
294
+ });
295
+ },
296
+
297
+ async reject(requestId: string, actor: SandboxActor): Promise<GrantRequest> {
298
+ authorize(actor);
299
+ return serialize(() => {
300
+ requireAvailable();
301
+ return decide(pendingRequest(requestId), "denied");
302
+ });
303
+ },
304
+
305
+ async revoke(actor: SandboxActor): Promise<SandboxTermination> {
306
+ authorize(actor);
307
+ return serialize(async () => {
308
+ transitioning = true;
309
+ try {
310
+ const termination = await supervisor.revoke();
311
+ if (termination.gone !== true || termination.residual.length !== 0) {
312
+ throw new Error("REVOKE_INCOMPLETE: owned sandbox processes remain");
313
+ }
314
+ grants = Object.freeze([]);
315
+ revision++;
316
+ stalePending();
317
+ return Object.freeze({ gone: true, residual: Object.freeze([]) });
318
+ } catch (error) {
319
+ return block(error);
320
+ } finally {
321
+ transitioning = false;
322
+ }
323
+ });
324
+ },
325
+
326
+ snapshot(actor?: SandboxActor): GrantAuthoritySnapshot {
327
+ if (actor !== undefined) authorize(actor);
328
+ return Object.freeze({
329
+ sessionKey, revision, grants,
330
+ requests: Object.freeze([...requests.values()]),
331
+ blocked: failed || transitioning, stateDirectory,
332
+ });
333
+ },
334
+
335
+ async awaitDecision(requestId: string, signal?: AbortSignal): Promise<GrantRequest> {
336
+ const request = requests.get(requestId);
337
+ if (!request) throw new Error("UNKNOWN_REQUEST: sandbox request does not exist in this session");
338
+ if (signal?.aborted) throw new Error("ABORTED: waiting for sandbox decision was cancelled");
339
+ if (request.status !== "pending") return request;
340
+ return new Promise<GrantRequest>((resolve, reject) => {
341
+ const listeners = waiters.get(requestId) ?? new Set<DecisionWaiter>();
342
+ const cleanup = () => {
343
+ signal?.removeEventListener("abort", abort);
344
+ listeners.delete(waiter);
345
+ if (listeners.size === 0) waiters.delete(requestId);
346
+ };
347
+ const waiter: DecisionWaiter = {
348
+ resolve(value) { cleanup(); resolve(value); },
349
+ reject(error) { cleanup(); reject(error); },
350
+ };
351
+ const abort = () => waiter.reject(new Error("ABORTED: waiting for sandbox decision was cancelled"));
352
+ listeners.add(waiter);
353
+ waiters.set(requestId, listeners);
354
+ signal?.addEventListener("abort", abort, { once: true });
355
+ });
356
+ },
357
+ });
358
+ }
@@ -0,0 +1,40 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import type { FileHandle } from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { jeoHomeDir } from "../notify/paths";
6
+ import { ensurePrivateSandboxDirectory } from "./authority";
7
+ import source from "./hands-source.text";
8
+
9
+ /** The text asset is embedded by Bun's standalone compiler, not read from a release source tree. */
10
+ export async function prepareHandsBundle(): Promise<string> {
11
+ const directory = await ensurePrivateSandboxDirectory(path.join(jeoHomeDir(), "sandbox", "runtime"));
12
+ const digest = new Bun.CryptoHasher("sha256").update(source).digest("hex");
13
+ const file = path.join(await fs.realpath(directory), `${digest}.js`);
14
+ let existing: FileHandle;
15
+ try { existing = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
16
+ catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
18
+ const staging = await fs.mkdtemp(path.join(directory, ".bundle-"));
19
+ const temporary = path.join(staging, "hands.js");
20
+ try {
21
+ const output = await fs.open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
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(); }
27
+ try { await fs.link(temporary, file); }
28
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
29
+ } finally { await fs.rm(staging, { recursive: true, force: true }); }
30
+ existing = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
31
+ }
32
+ try {
33
+ const stat = await existing.stat();
34
+ if (!stat.isFile() || (process.platform !== "win32" && ((stat.mode & 0o022) !== 0 || stat.uid !== process.getuid?.())) ||
35
+ new Bun.CryptoHasher("sha256").update(await existing.readFile()).digest("hex") !== digest) {
36
+ throw new Error("SANDBOX_BUNDLE: trusted runtime content or ownership changed");
37
+ }
38
+ } finally { await existing.close(); }
39
+ return file;
40
+ }
@@ -0,0 +1,49 @@
1
+ import type { GrantAuthority, SandboxActor } from "./authority";
2
+
3
+ export interface SandboxControlResult {
4
+ handled: boolean;
5
+ output: string;
6
+ }
7
+
8
+ const USAGE = "Usage: /sandbox approve <requestId> | /sandbox deny <requestId> | /sandbox revoke | /sandbox status";
9
+
10
+ /** Only standalone, directly submitted parent controls can make human decisions. */
11
+ export async function handleSandboxControl(
12
+ text: string,
13
+ actor: SandboxActor,
14
+ authority: GrantAuthority,
15
+ ): Promise<SandboxControlResult> {
16
+ // Multiline prompts, quotations, and embedded examples belong to conversation.
17
+ if (/[\r\n]/.test(text) || !/^\/sandbox(?:[\t ]|$)/.test(text)) {
18
+ return { handled: false, output: "" };
19
+ }
20
+ const parts = text.trim().split(/[\t ]+/);
21
+ const command = parts[1];
22
+ try {
23
+ if ((command === "approve" || command === "deny") && parts.length === 3) {
24
+ const request = command === "approve"
25
+ ? await authority.approve(parts[2]!, actor)
26
+ : await authority.reject(parts[2]!, actor);
27
+ return { handled: true, output: `Sandbox request ${request.id}: ${request.status} (${request.mode} ${request.host}).` };
28
+ }
29
+ if (command === "revoke" && parts.length === 2) {
30
+ await authority.revoke(actor);
31
+ return { handled: true, output: "Sandbox access revoked; owned containers confirmed removed." };
32
+ }
33
+ if (command === "status" && parts.length === 2) {
34
+ const state = authority.snapshot(actor);
35
+ const pending = state.requests.filter(request => request.status === "pending");
36
+ const details = pending.map(request => `${request.id}: ${request.mode} ${request.host}`);
37
+ return {
38
+ handled: true,
39
+ output: [
40
+ `Sandbox session ${state.sessionKey}, revision ${state.revision}: ${state.blocked ? "blocked" : "ready"}; ${state.grants.length} grants, ${pending.length} pending requests.`,
41
+ ...details,
42
+ ].join("\n"),
43
+ };
44
+ }
45
+ return { handled: true, output: `Invalid sandbox control. ${USAGE}` };
46
+ } catch (error) {
47
+ return { handled: true, output: `Sandbox control error: ${error instanceof Error ? error.message : "Unknown error"}` };
48
+ }
49
+ }