agent-lyceum 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude.js +11 -0
- package/dist/adapters/codex.js +2 -0
- package/dist/call.js +3 -2
- package/dist/config.d.ts +23 -1
- package/dist/config.js +38 -2
- package/dist/fallback.d.ts +10 -0
- package/dist/fallback.js +23 -2
- package/dist/mailbox.js +19 -2
- package/dist/policy.d.ts +33 -0
- package/dist/policy.js +80 -0
- package/dist/run-session.js +17 -3
- package/dist/sandbox-invocation.d.ts +35 -0
- package/dist/sandbox-invocation.js +186 -0
- package/dist/schema.d.ts +27 -1
- package/dist/schema.js +14 -1
- package/dist/validate.js +5 -0
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { repoDirFor, scratchCwd, writePolicy } from "../policy.js";
|
|
4
|
+
import { canonicalPath } from "../paths.js";
|
|
4
5
|
/** Claude Code path rules: `//abs/path` — a leading "/" plus the absolute path. */
|
|
5
6
|
const rule = (tool, p) => `${tool}(/${p})`;
|
|
6
7
|
function denyRules(p) {
|
|
@@ -34,6 +35,15 @@ export function buildClaudeSettings(project, agent, override) {
|
|
|
34
35
|
for (const f of pol.allowFiles)
|
|
35
36
|
allow.push(rule("Edit", f));
|
|
36
37
|
const deny = pol.deny.flatMap(denyRules);
|
|
38
|
+
// Spec 25: network allowances; never with a policy override (a minimal-authority wake).
|
|
39
|
+
const sb = override ? undefined : agent.sandbox;
|
|
40
|
+
const network = {};
|
|
41
|
+
if (sb?.network.length)
|
|
42
|
+
network.allowedDomains = sb.network;
|
|
43
|
+
if (sb?.localBinding)
|
|
44
|
+
network.allowLocalBinding = true;
|
|
45
|
+
if (sb?.unixSockets.length)
|
|
46
|
+
network.allowUnixSockets = sb.unixSockets.map(canonicalPath);
|
|
37
47
|
return {
|
|
38
48
|
// The user's own and plugin hooks (e.g. a hook that asks the user a question) would block every tool call of a headless wake-up.
|
|
39
49
|
disableAllHooks: true,
|
|
@@ -45,6 +55,7 @@ export function buildClaudeSettings(project, agent, override) {
|
|
|
45
55
|
allowWrite: [override?.cwd ?? ws?.root ?? project.dir, ...pol.allowDirs, ...pol.allowFiles],
|
|
46
56
|
denyWrite: pol.deny,
|
|
47
57
|
},
|
|
58
|
+
...(Object.keys(network).length ? { network } : {}),
|
|
48
59
|
},
|
|
49
60
|
};
|
|
50
61
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -63,6 +63,8 @@ export function buildCodexInvocation(input) {
|
|
|
63
63
|
'approval_policy="never"',
|
|
64
64
|
"-c",
|
|
65
65
|
`sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`,
|
|
66
|
+
// Codex has one switch for all network access; spec 25 never uses it under a policy override.
|
|
67
|
+
...(!input.policy && agent.sandbox?.network.length ? ["-c", "sandbox_workspace_write.network_access=true"] : []),
|
|
66
68
|
"--json",
|
|
67
69
|
"-o",
|
|
68
70
|
lastFile,
|
package/dist/call.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { invokeWithSandboxChecks } from "./sandbox-invocation.js";
|
|
3
4
|
import { realInvoker } from "./adapters/index.js";
|
|
4
5
|
import { must } from "./assert.js";
|
|
5
6
|
import { ConfigError, loadGlobal, resolveProjectFrom } from "./config.js";
|
|
@@ -157,7 +158,7 @@ export async function callAgent(o) {
|
|
|
157
158
|
return;
|
|
158
159
|
say(` ${agent.name} falling back to ${to.model ? `${to.runtime}/${to.model}` : to.runtime} (${kind ?? "unknown error"})`);
|
|
159
160
|
},
|
|
160
|
-
invoke: (c) => invoke
|
|
161
|
+
invoke: (c, ctx) => invokeWithSandboxChecks(invoke, {
|
|
161
162
|
project,
|
|
162
163
|
agent: agentForCandidate(agent, c),
|
|
163
164
|
workDir: callDir,
|
|
@@ -166,7 +167,7 @@ export async function callAgent(o) {
|
|
|
166
167
|
timeoutSec: project.dispatcher.wake_timeout_sec,
|
|
167
168
|
signal: o.signal,
|
|
168
169
|
logDir: logDirFor(c),
|
|
169
|
-
}).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
|
|
170
|
+
}, agent, { event: (name, fields) => say(JSON.stringify({ event: name, ...fields })) }, ctx.attempt).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
|
|
170
171
|
});
|
|
171
172
|
const result = must(outcome.result, "the call's result");
|
|
172
173
|
const tried = chain ? outcome.tried : undefined;
|
package/dist/config.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { type ProjectPaths } from "./paths.js";
|
|
2
2
|
import type { AgentWorkspace } from "./worktree.js";
|
|
3
|
-
import { type DispatcherSettings, type Effort, type FallbackEntry, type Runtime, type WebLevel } from "./schema.js";
|
|
3
|
+
import { type DispatcherSettings, type Effort, type FallbackEntry, type SandboxPartialT, type Runtime, type WebLevel } from "./schema.js";
|
|
4
4
|
export declare class ConfigError extends Error {
|
|
5
5
|
}
|
|
6
6
|
export type Source = "global" | "project" | "default" | "model";
|
|
7
|
+
export interface SandboxAllowances {
|
|
8
|
+
write: string[];
|
|
9
|
+
network: string[];
|
|
10
|
+
localBinding: boolean;
|
|
11
|
+
unixSockets: string[];
|
|
12
|
+
}
|
|
7
13
|
export interface ResolvedAgent {
|
|
8
14
|
name: string;
|
|
9
15
|
runtime?: Runtime;
|
|
@@ -22,6 +28,8 @@ export interface ResolvedAgent {
|
|
|
22
28
|
canAskUser: boolean;
|
|
23
29
|
/** Web access for this agent; undefined = not configured, so the runtime keeps its own default. */
|
|
24
30
|
allowWeb?: WebLevel;
|
|
31
|
+
/** Spec 25 allowances; unset when none is requested (the only switch that keeps output identical). Paths are absolute, `~` expanded, not yet canonical. */
|
|
32
|
+
sandbox?: SandboxAllowances;
|
|
25
33
|
/** Ordered failover chain (runtime and model filled in); empty = no fallback. */
|
|
26
34
|
fallback: FallbackEntry[];
|
|
27
35
|
/** True when the chain came from `dispatcher.fallback` rather than the agent's own setting. */
|
|
@@ -102,6 +110,12 @@ export declare function loadGlobal(home: string): {
|
|
|
102
110
|
model?: string | undefined;
|
|
103
111
|
effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
|
|
104
112
|
}[] | undefined;
|
|
113
|
+
sandbox?: {
|
|
114
|
+
write?: string[] | undefined;
|
|
115
|
+
network?: string[] | undefined;
|
|
116
|
+
local_binding?: boolean | undefined;
|
|
117
|
+
unix_sockets?: string[] | undefined;
|
|
118
|
+
} | undefined;
|
|
105
119
|
owns?: unknown;
|
|
106
120
|
}>;
|
|
107
121
|
};
|
|
@@ -150,6 +164,12 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
150
164
|
model?: string | undefined;
|
|
151
165
|
effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
|
|
152
166
|
}[] | undefined;
|
|
167
|
+
sandbox?: {
|
|
168
|
+
write?: string[] | undefined;
|
|
169
|
+
network?: string[] | undefined;
|
|
170
|
+
local_binding?: boolean | undefined;
|
|
171
|
+
unix_sockets?: string[] | undefined;
|
|
172
|
+
} | undefined;
|
|
153
173
|
owns?: unknown;
|
|
154
174
|
}>;
|
|
155
175
|
};
|
|
@@ -177,6 +197,8 @@ export type SourceInfo = {
|
|
|
177
197
|
} | {
|
|
178
198
|
default: true;
|
|
179
199
|
};
|
|
200
|
+
/** Spec 25.4.2: validated, deduplicated allowances; undefined when nothing is requested. */
|
|
201
|
+
export declare function resolveSandbox(agent: string, s: SandboxPartialT | undefined): SandboxAllowances | undefined;
|
|
180
202
|
/** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
|
|
181
203
|
export declare function resolveProjectWithSources(home: string, name: string): {
|
|
182
204
|
project: ResolvedProject;
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
|
-
import { absPath, escapesParent, homePaths, projectPaths, } from "./paths.js";
|
|
4
|
+
import { absPath, canonicalPath, escapesParent, expandHome, homePaths, projectPaths, } from "./paths.js";
|
|
5
5
|
import { availableTiers } from "./tiers.js";
|
|
6
6
|
import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, RUNTIMES, } from "./schema.js";
|
|
7
7
|
export class ConfigError extends Error {
|
|
@@ -162,6 +162,7 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
162
162
|
const canEdit = pick("can_edit_agent_md");
|
|
163
163
|
const canAsk = pick("can_ask_user");
|
|
164
164
|
const allowWeb = pick("allow_web");
|
|
165
|
+
const sandbox = resolveSandbox(agentName, pick("sandbox"));
|
|
165
166
|
for (const [k, v] of [
|
|
166
167
|
["resume", resume],
|
|
167
168
|
["can_message", canMessage],
|
|
@@ -183,6 +184,7 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
183
184
|
canEditAgentMd: canEdit ?? isLead,
|
|
184
185
|
canAskUser: canAsk ?? isLead,
|
|
185
186
|
allowWeb,
|
|
187
|
+
...(sandbox ? { sandbox } : {}),
|
|
186
188
|
fallback,
|
|
187
189
|
fallbackShared,
|
|
188
190
|
fallbackInvalid,
|
|
@@ -274,7 +276,39 @@ export function findProjectForCwd(home, cwd) {
|
|
|
274
276
|
}
|
|
275
277
|
return best?.name;
|
|
276
278
|
}
|
|
277
|
-
const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web", "fallback"];
|
|
279
|
+
const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web", "fallback", "sandbox"];
|
|
280
|
+
/** Claude pattern metacharacters and control characters: refused in sandbox paths instead of escaped (spec 25.4.1). */
|
|
281
|
+
const PATH_CHARS = /[*?[\]{}()!\\]/;
|
|
282
|
+
const PATH_META = { test: (p) => PATH_CHARS.test(p) || [...p].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) };
|
|
283
|
+
function sandboxPath(agent, key, i, raw) {
|
|
284
|
+
const at = `agents.${agent}.sandbox.${key}.${i}`;
|
|
285
|
+
if (PATH_META.test(raw))
|
|
286
|
+
throw new ConfigError(`${at}: must not contain pattern characters (* ? [ ] { } ( ) !), backslashes or control characters (got ${JSON.stringify(raw)})`);
|
|
287
|
+
if (/^\$|\$\{?[A-Za-z_]/.test(raw))
|
|
288
|
+
throw new ConfigError(`${at}: environment variables are not expanded; write the absolute path (got "${raw}")`);
|
|
289
|
+
const expanded = expandHome(raw);
|
|
290
|
+
if (!path.isAbsolute(expanded))
|
|
291
|
+
throw new ConfigError(`${at}: must be an absolute path or start with ~/ (got "${raw}")`);
|
|
292
|
+
const resolved = path.resolve(expanded);
|
|
293
|
+
const canon = canonicalPath(resolved);
|
|
294
|
+
for (const p of [resolved, canon])
|
|
295
|
+
if (PATH_META.test(p))
|
|
296
|
+
throw new ConfigError(`${at}: resolved path contains pattern characters (${JSON.stringify(p)})`);
|
|
297
|
+
return resolved;
|
|
298
|
+
}
|
|
299
|
+
/** Spec 25.4.2: validated, deduplicated allowances; undefined when nothing is requested. */
|
|
300
|
+
export function resolveSandbox(agent, s) {
|
|
301
|
+
if (!s)
|
|
302
|
+
return undefined;
|
|
303
|
+
const uniq = (xs) => [...new Set(xs)];
|
|
304
|
+
const write = uniq((s.write ?? []).map((p, i) => sandboxPath(agent, "write", i, p)));
|
|
305
|
+
const unixSockets = uniq((s.unix_sockets ?? []).map((p, i) => sandboxPath(agent, "unix_sockets", i, p)));
|
|
306
|
+
const network = uniq(s.network ?? []);
|
|
307
|
+
const localBinding = s.local_binding === true;
|
|
308
|
+
if (!write.length && !unixSockets.length && !network.length && !localBinding)
|
|
309
|
+
return undefined;
|
|
310
|
+
return { write, network, localBinding, unixSockets };
|
|
311
|
+
}
|
|
278
312
|
/** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
|
|
279
313
|
export function resolveProjectWithSources(home, name) {
|
|
280
314
|
const project = resolveProject(home, name);
|
|
@@ -361,6 +395,8 @@ export function flattenResolved(project) {
|
|
|
361
395
|
v[`${p}.allow_web`] = a.allowWeb;
|
|
362
396
|
if (a.fallback.length)
|
|
363
397
|
v[`${p}.fallback`] = a.fallback;
|
|
398
|
+
if (a.sandbox)
|
|
399
|
+
v[`${p}.sandbox`] = { write: a.sandbox.write, network: a.sandbox.network, local_binding: a.sandbox.localBinding, unix_sockets: a.sandbox.unixSockets };
|
|
364
400
|
if (a.tiers)
|
|
365
401
|
v[`${p}.model_tiers`] = a.tiers;
|
|
366
402
|
}
|
package/dist/fallback.d.ts
CHANGED
|
@@ -42,6 +42,12 @@ export declare class Cooldowns {
|
|
|
42
42
|
}
|
|
43
43
|
/** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
|
|
44
44
|
export declare function planAttempts(all: readonly Candidate[], cooldowns: Cooldowns, now: number): Candidate[];
|
|
45
|
+
/**
|
|
46
|
+
* The candidates `planAttempts` left out because they are cooling down, soonest to recover first. They are tried last, after
|
|
47
|
+
* waiting for their cooldown to end, so a wake-up whose warm models all fail is not lost to a cooldown that ends moments later.
|
|
48
|
+
* A tier's cheaper model is not one of them: stepping back down to it after the ceiling failed would be wrong.
|
|
49
|
+
*/
|
|
50
|
+
export declare function deferredAttempts(all: readonly Candidate[], cooldowns: Cooldowns, now: number): Candidate[];
|
|
45
51
|
export type Step = "retry-same" | "next-candidate" | "stop";
|
|
46
52
|
/**
|
|
47
53
|
* What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
|
|
@@ -80,6 +86,8 @@ export interface Tried {
|
|
|
80
86
|
export interface RunWithFallbackOptions<R extends AttemptResult> {
|
|
81
87
|
/** From `planAttempts`; tried in order. */
|
|
82
88
|
candidates: Candidate[];
|
|
89
|
+
/** From `deferredAttempts`: tried after `candidates`, each one only once its cooldown has ended. */
|
|
90
|
+
deferred?: Candidate[];
|
|
83
91
|
/** The agent has no fallback chain (see `nextStep`): no backoff, no cooldowns. */
|
|
84
92
|
legacy: boolean;
|
|
85
93
|
/** Retries per candidate (`dispatcher.retry`). */
|
|
@@ -93,6 +101,8 @@ export interface RunWithFallbackOptions<R extends AttemptResult> {
|
|
|
93
101
|
/** Runs after each call, awaited before the next decision. */
|
|
94
102
|
afterAttempt?(candidate: Candidate, result: R, ctx: AttemptCtx<R>): void | Promise<void>;
|
|
95
103
|
onSwitch?(from: Candidate, to: Candidate, kind: ErrorKind | undefined, result: R): void;
|
|
104
|
+
/** Runs before the loop sleeps `ms` for `candidate`'s cooldown to end. */
|
|
105
|
+
onCooldownWait?(candidate: Candidate, ms: number): void;
|
|
96
106
|
sleep?(ms: number, signal?: AbortSignal): Promise<void>;
|
|
97
107
|
now?(): number;
|
|
98
108
|
signal?: AbortSignal;
|
package/dist/fallback.js
CHANGED
|
@@ -75,12 +75,25 @@ export function planAttempts(all, cooldowns, now) {
|
|
|
75
75
|
const warm = all.filter((c) => cooldowns.active(c.key, now) === 0);
|
|
76
76
|
return warm.length ? warm : [...all];
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The candidates `planAttempts` left out because they are cooling down, soonest to recover first. They are tried last, after
|
|
80
|
+
* waiting for their cooldown to end, so a wake-up whose warm models all fail is not lost to a cooldown that ends moments later.
|
|
81
|
+
* A tier's cheaper model is not one of them: stepping back down to it after the ceiling failed would be wrong.
|
|
82
|
+
*/
|
|
83
|
+
export function deferredAttempts(all, cooldowns, now) {
|
|
84
|
+
return all
|
|
85
|
+
.filter((c) => !c.tiered && cooldowns.active(c.key, now) > 0)
|
|
86
|
+
.sort((a, b) => cooldowns.active(a.key, now) - cooldowns.active(b.key, now));
|
|
87
|
+
}
|
|
78
88
|
/**
|
|
79
89
|
* What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
|
|
80
90
|
* candidate, the failed one included; each candidate gets `retry` retries. `legacy` (the agent has no fallback chain)
|
|
81
91
|
* keeps the old loop: every kind is retried `retry` times, then the wake fails.
|
|
82
92
|
*/
|
|
83
93
|
export function nextStep(kind, attemptsOnCurrent, retry, hasNext, legacy) {
|
|
94
|
+
// A refused configuration cannot get better by trying again or on another model (spec 25.19.1).
|
|
95
|
+
if (kind === "config")
|
|
96
|
+
return "stop";
|
|
84
97
|
const retriesLeft = attemptsOnCurrent <= retry;
|
|
85
98
|
if (legacy)
|
|
86
99
|
return retriesLeft ? "retry-same" : "stop";
|
|
@@ -122,7 +135,8 @@ export function defaultSleep(ms, signal) {
|
|
|
122
135
|
* retry it or move on as `nextStep` says, back off between retries. Success and cancellation end the loop as they are.
|
|
123
136
|
*/
|
|
124
137
|
export async function runWithFallback(o) {
|
|
125
|
-
const
|
|
138
|
+
const list = [...o.candidates, ...(o.deferred ?? [])];
|
|
139
|
+
const first = list[0];
|
|
126
140
|
if (!first)
|
|
127
141
|
throw new Error("runWithFallback needs at least one candidate");
|
|
128
142
|
const sleep = o.sleep ?? defaultSleep;
|
|
@@ -142,7 +156,7 @@ export async function runWithFallback(o) {
|
|
|
142
156
|
tried.push({ runtime: used.runtime, model: used.model, kind: result.kind, error: result.error });
|
|
143
157
|
if (!o.legacy && (result.kind === "rate_limit" || result.kind === "quota"))
|
|
144
158
|
o.cooldowns.mark(used.key, result.kind, now(), o.cooldownSec);
|
|
145
|
-
const next =
|
|
159
|
+
const next = list[index + 1];
|
|
146
160
|
const step = nextStep(result.kind, ctx.candidateAttempt + 1, o.retry, !!next, o.legacy);
|
|
147
161
|
if (step === "stop")
|
|
148
162
|
return { result, tried, used };
|
|
@@ -157,6 +171,13 @@ export async function runWithFallback(o) {
|
|
|
157
171
|
}
|
|
158
172
|
const to = next;
|
|
159
173
|
o.onSwitch?.(used, to, result.kind, result);
|
|
174
|
+
const wait = o.legacy ? 0 : o.cooldowns.active(to.key, now());
|
|
175
|
+
if (wait > 0) {
|
|
176
|
+
o.onCooldownWait?.(to, wait);
|
|
177
|
+
await sleep(wait, o.signal);
|
|
178
|
+
if (o.signal?.aborted)
|
|
179
|
+
return { result, tried, used, stopped: "aborted" };
|
|
180
|
+
}
|
|
160
181
|
ctx = { attempt: ctx.attempt + 1, candidateAttempt: 0, previous: result, switchedFrom: used };
|
|
161
182
|
index++;
|
|
162
183
|
used = to;
|
package/dist/mailbox.js
CHANGED
|
@@ -27,14 +27,31 @@ export function serialize(meta, body) {
|
|
|
27
27
|
const fm = YAML.stringify(meta).trimEnd();
|
|
28
28
|
return `---\n${fm}\n---\n\n${body.trim()}\n`;
|
|
29
29
|
}
|
|
30
|
+
const ID_KEYS = new Set(["id", "reply_to", "thread", "about", "run_id"]);
|
|
30
31
|
export function parseRaw(text) {
|
|
31
32
|
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
32
33
|
if (!m)
|
|
33
34
|
throw new Error("missing frontmatter (--- ... ---)");
|
|
34
|
-
const
|
|
35
|
+
const fm = m[1] ?? "";
|
|
36
|
+
const data = YAML.parse(fm);
|
|
35
37
|
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
36
38
|
throw new Error("frontmatter is not a mapping");
|
|
37
|
-
|
|
39
|
+
const out = data;
|
|
40
|
+
// Ids are 8 hex characters: `79e76911` parses as Infinity and `01234567` as 1234567.
|
|
41
|
+
// Only an id key that came out as a number is re-read from its source text; everything else stays as YAML.parse returned it.
|
|
42
|
+
if (Object.entries(out).some(([k, v]) => ID_KEYS.has(k) && typeof v === "number")) {
|
|
43
|
+
const doc = YAML.parseDocument(fm);
|
|
44
|
+
if (YAML.isMap(doc.contents)) {
|
|
45
|
+
for (const pair of doc.contents.items) {
|
|
46
|
+
const key = YAML.isScalar(pair.key) ? String(pair.key.value) : "";
|
|
47
|
+
if (!ID_KEYS.has(key) || typeof out[key] !== "number" || !YAML.isScalar(pair.value))
|
|
48
|
+
continue;
|
|
49
|
+
if (pair.value.type === "PLAIN" && typeof pair.value.source === "string")
|
|
50
|
+
out[key] = pair.value.source;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { data: out, body: (m[2] ?? "").trim() };
|
|
38
55
|
}
|
|
39
56
|
export function ensureProjectDirs(project) {
|
|
40
57
|
fs.mkdirSync(path.join(project.paths.shared, "common"), { recursive: true });
|
package/dist/policy.d.ts
CHANGED
|
@@ -50,3 +50,36 @@ export declare function setOsSandboxForTest(available: boolean | undefined): voi
|
|
|
50
50
|
export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
|
|
51
51
|
/** Can `agent` legitimately change this protected file? */
|
|
52
52
|
export declare function mayEditProtected(project: ResolvedProject, agent: ResolvedAgent, file: string): boolean;
|
|
53
|
+
export type SandboxRelation = "equal" | "inside" | "contains";
|
|
54
|
+
export interface SandboxPathConflict {
|
|
55
|
+
key: "write" | "unix_sockets";
|
|
56
|
+
index: number;
|
|
57
|
+
path: string;
|
|
58
|
+
protectedPath: string;
|
|
59
|
+
reason: string;
|
|
60
|
+
relation: SandboxRelation;
|
|
61
|
+
}
|
|
62
|
+
/** Pure (spec 25.4.3): which canonical sandbox paths equal, sit inside or contain a protected path. Callers canonicalize. */
|
|
63
|
+
export declare function sandboxPathConflicts(entries: {
|
|
64
|
+
key: "write" | "unix_sockets";
|
|
65
|
+
index: number;
|
|
66
|
+
path: string;
|
|
67
|
+
}[], protectedPaths: {
|
|
68
|
+
path: string;
|
|
69
|
+
reason: string;
|
|
70
|
+
}[]): SandboxPathConflict[];
|
|
71
|
+
/** What a runtime really applies of an agent's allowances (spec 25.9): the tuple a fallback must not exceed. */
|
|
72
|
+
export interface SandboxCaps {
|
|
73
|
+
network: "none" | "domains" | "all";
|
|
74
|
+
localBinding: boolean;
|
|
75
|
+
unixSockets: boolean;
|
|
76
|
+
}
|
|
77
|
+
export declare const NETWORK_RANK: {
|
|
78
|
+
readonly none: 0;
|
|
79
|
+
readonly domains: 1;
|
|
80
|
+
readonly all: 2;
|
|
81
|
+
};
|
|
82
|
+
/** Without an OS sandbox nothing is applied, so every runtime is equal there. */
|
|
83
|
+
export declare function sandboxCaps(agent: ResolvedAgent, runtime?: string | undefined): SandboxCaps;
|
|
84
|
+
/** Components in which `backup` applies more than `primary`, as `name: primary -> backup`. */
|
|
85
|
+
export declare function sandboxCapExcess(primary: SandboxCaps, backup: SandboxCaps): string[];
|
package/dist/policy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { findOnPath } from "./fs-util.js";
|
|
4
|
+
import { canonicalPath } from "./paths.js";
|
|
4
5
|
export function commonFile(project) {
|
|
5
6
|
return path.join(project.paths.shared, "common", "COMMON.md");
|
|
6
7
|
}
|
|
@@ -88,6 +89,8 @@ export function writePolicy(project, agent) {
|
|
|
88
89
|
...(ws ? [ws.gitDir] : []),
|
|
89
90
|
...(shared && mayWriteShared ? [shared] : []),
|
|
90
91
|
...(isLead ? [recordsDir(project)] : []),
|
|
92
|
+
// Spec 25: canonical, because the OS sandbox compares real paths (`/tmp` is `/private/tmp` on macOS).
|
|
93
|
+
...(agent.sandbox?.write ?? []).map(canonicalPath),
|
|
91
94
|
];
|
|
92
95
|
const allowFiles = [];
|
|
93
96
|
if (isLead)
|
|
@@ -180,6 +183,8 @@ export function enforcementFor(project, agent) {
|
|
|
180
183
|
if (agent.canEditAgentMd)
|
|
181
184
|
notes.push("can_edit_agent_md widens Codex writable roots to whole agent directories.");
|
|
182
185
|
}
|
|
186
|
+
if (agent.sandbox)
|
|
187
|
+
notes.push(...sandboxNotes(agent, os));
|
|
183
188
|
return {
|
|
184
189
|
agent: agent.name,
|
|
185
190
|
runtime: agent.runtime ?? "?",
|
|
@@ -188,7 +193,82 @@ export function enforcementFor(project, agent) {
|
|
|
188
193
|
notes,
|
|
189
194
|
};
|
|
190
195
|
}
|
|
196
|
+
/** Doctor lines for an agent's sandbox allowances (spec 25.7, 25.10): what is requested and what its runtime really applies. */
|
|
197
|
+
function sandboxNotes(agent, os) {
|
|
198
|
+
const s = agent.sandbox;
|
|
199
|
+
if (!s)
|
|
200
|
+
return [];
|
|
201
|
+
const claude = agent.runtime === "claude-code";
|
|
202
|
+
const notes = [];
|
|
203
|
+
if (!os) {
|
|
204
|
+
notes.push(claude
|
|
205
|
+
? "no OS sandbox; sandbox.write changes Edit tool rules only; network/local_binding/unix_sockets have no effect."
|
|
206
|
+
: "no OS sandbox; enforcement is prompt-only; sandbox allowances provide no OS enforcement.");
|
|
207
|
+
}
|
|
208
|
+
if (s.network.length) {
|
|
209
|
+
notes.push(`network: ${s.network.join(", ")} (${claude ? "claude-code: per-domain" : "codex: all hosts"})`);
|
|
210
|
+
if (!claude)
|
|
211
|
+
notes.push(`warn: sandbox.network is all-or-nothing on Codex: this agent may reach any host, not only ${s.network.join(", ")}.`);
|
|
212
|
+
}
|
|
213
|
+
if (s.localBinding) {
|
|
214
|
+
notes.push(claude ? (process.platform === "darwin" ? "local_binding: on" : "local_binding: requested, no effect on this platform (macOS only)") : "local_binding: requested, not supported by codex");
|
|
215
|
+
if (!claude)
|
|
216
|
+
notes.push("warn: sandbox.local_binding has no Codex setting; whether a workspace-write sandbox lets it bind ports is unverified. sandbox.network turns on all network access, not only binding.");
|
|
217
|
+
}
|
|
218
|
+
if (s.unixSockets.length) {
|
|
219
|
+
notes.push(`unix_sockets: ${s.unixSockets.map(canonicalPath).join(", ")} (${claude ? "applied" : "not applied by codex"})`);
|
|
220
|
+
if (!claude)
|
|
221
|
+
notes.push("warn: sandbox.unix_sockets has no per-socket Codex control; no setting emitted.");
|
|
222
|
+
}
|
|
223
|
+
for (const [key, list] of [["write", s.write], ["unix_sockets", s.unixSockets]]) {
|
|
224
|
+
list.forEach((p, i) => {
|
|
225
|
+
if (!fs.existsSync(p))
|
|
226
|
+
notes.push(`info: sandbox.${key}[${i}] ${p} does not exist yet`);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return notes;
|
|
230
|
+
}
|
|
191
231
|
/** Can `agent` legitimately change this protected file? */
|
|
192
232
|
export function mayEditProtected(project, agent, file) {
|
|
193
233
|
return writePolicy(project, agent).allowFiles.includes(file);
|
|
194
234
|
}
|
|
235
|
+
const within = (parent, p) => p === parent || p.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
|
|
236
|
+
/** Pure (spec 25.4.3): which canonical sandbox paths equal, sit inside or contain a protected path. Callers canonicalize. */
|
|
237
|
+
export function sandboxPathConflicts(entries, protectedPaths) {
|
|
238
|
+
const out = [];
|
|
239
|
+
for (const e of entries) {
|
|
240
|
+
for (const pp of protectedPaths) {
|
|
241
|
+
const relation = e.path === pp.path ? "equal" : within(pp.path, e.path) ? "inside" : within(e.path, pp.path) ? "contains" : undefined;
|
|
242
|
+
if (relation)
|
|
243
|
+
out.push({ ...e, protectedPath: pp.path, reason: pp.reason, relation });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
export const NETWORK_RANK = { none: 0, domains: 1, all: 2 };
|
|
249
|
+
/** Without an OS sandbox nothing is applied, so every runtime is equal there. */
|
|
250
|
+
export function sandboxCaps(agent, runtime = agent.runtime) {
|
|
251
|
+
const s = agent.sandbox;
|
|
252
|
+
const none = { network: "none", localBinding: false, unixSockets: false };
|
|
253
|
+
if (!s || !osSandboxAvailable())
|
|
254
|
+
return none;
|
|
255
|
+
if (runtime === "claude-code") {
|
|
256
|
+
return {
|
|
257
|
+
network: s.network.length ? "domains" : "none",
|
|
258
|
+
localBinding: s.localBinding && process.platform === "darwin",
|
|
259
|
+
unixSockets: s.unixSockets.length > 0,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return { ...none, network: s.network.length ? "all" : "none" };
|
|
263
|
+
}
|
|
264
|
+
/** Components in which `backup` applies more than `primary`, as `name: primary -> backup`. */
|
|
265
|
+
export function sandboxCapExcess(primary, backup) {
|
|
266
|
+
const out = [];
|
|
267
|
+
if (NETWORK_RANK[backup.network] > NETWORK_RANK[primary.network])
|
|
268
|
+
out.push(`network: ${primary.network} -> ${backup.network}`);
|
|
269
|
+
if (backup.localBinding && !primary.localBinding)
|
|
270
|
+
out.push("local_binding: off -> on");
|
|
271
|
+
if (backup.unixSockets && !primary.unixSockets)
|
|
272
|
+
out.push("unix_sockets: off -> on");
|
|
273
|
+
return out;
|
|
274
|
+
}
|
package/dist/run-session.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { invokeWithSandboxChecks } from "./sandbox-invocation.js";
|
|
3
4
|
import { realInvoker } from "./adapters/index.js";
|
|
4
|
-
import { agentForCandidate, candidatesFor, Cooldowns, defaultSleep, planAttempts, runWithFallback } from "./fallback.js";
|
|
5
|
+
import { agentForCandidate, candidatesFor, Cooldowns, defaultSleep, deferredAttempts, planAttempts, runWithFallback } from "./fallback.js";
|
|
5
6
|
import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./format.js";
|
|
6
7
|
import { ProtectedGuard } from "./guard.js";
|
|
7
8
|
import { listRunLocks, lockHolderAlive } from "./project-lock.js";
|
|
@@ -580,6 +581,8 @@ export class RunSession {
|
|
|
580
581
|
const all = candidatesFor(agent, tier && agent.tiers ? modelForTier(agent.tiers, tier) : undefined);
|
|
581
582
|
const chain = all.length > 1;
|
|
582
583
|
const candidates = chain ? planAttempts(all, cooldowns, Date.now()) : all;
|
|
584
|
+
// Models cooling down at the start are not dropped for good: when the warm ones fail they are tried last, once recovered.
|
|
585
|
+
const deferred = chain && candidates.length < all.length ? deferredAttempts(all, cooldowns, Date.now()).filter((c) => !candidates.includes(c)) : [];
|
|
583
586
|
const ceiling = must(all.find((c) => c.primary), "the agent's own model");
|
|
584
587
|
const tieredFirst = all[0]?.tiered ? all[0] : undefined;
|
|
585
588
|
// The model a backup's calls stand in for: the one switched away from in this wake, or the cooling-down primary it started past.
|
|
@@ -594,6 +597,7 @@ export class RunSession {
|
|
|
594
597
|
let stoppedByCancel = false;
|
|
595
598
|
const outcome = await runWithFallback({
|
|
596
599
|
candidates,
|
|
600
|
+
deferred,
|
|
597
601
|
legacy: !chain,
|
|
598
602
|
retry: cfg.retry,
|
|
599
603
|
cooldowns,
|
|
@@ -609,6 +613,12 @@ export class RunSession {
|
|
|
609
613
|
// The round limit (when one is set) ends the run; this message stays unread for the resume.
|
|
610
614
|
return state.max_rounds > 0 && state.rounds >= state.max_rounds ? "stop" : undefined;
|
|
611
615
|
},
|
|
616
|
+
onCooldownWait: (c, ms) => {
|
|
617
|
+
if (opts.signal?.aborted)
|
|
618
|
+
return;
|
|
619
|
+
say(` ${agent.name}: no other model is available; waiting ${Math.ceil(ms / 1000)}s for ${modelLabel(c)} to recover`);
|
|
620
|
+
log("cooldown_wait", { agent: agent.name, model: modelLabel(c), wait_ms: ms });
|
|
621
|
+
},
|
|
612
622
|
onSwitch: (from, to, kind) => {
|
|
613
623
|
if (!from.tiered)
|
|
614
624
|
replaced = from;
|
|
@@ -651,14 +661,14 @@ export class RunSession {
|
|
|
651
661
|
const activeHandling = unread.map((m, i) => ({ ...handling[i], digest: digestOf(m.meta.type, m.body, true) }));
|
|
652
662
|
state.active[agent.name] = { round, since: new Date(started).toISOString(), handling: activeHandling };
|
|
653
663
|
this.saveState();
|
|
654
|
-
const result = await invoke
|
|
664
|
+
const result = await invokeWithSandboxChecks(invoke, {
|
|
655
665
|
...base,
|
|
656
666
|
agent: runAs,
|
|
657
667
|
signal: opts.signal,
|
|
658
668
|
logDir: attemptLogDir(runDir, att.id),
|
|
659
669
|
userPrompt,
|
|
660
670
|
sessionId,
|
|
661
|
-
}).catch((e) => ({
|
|
671
|
+
}, agent, { event: (name, fields) => log(name, fields) }, attempt).catch((e) => ({
|
|
662
672
|
ok: false,
|
|
663
673
|
text: "",
|
|
664
674
|
exitCode: null,
|
|
@@ -724,6 +734,10 @@ export class RunSession {
|
|
|
724
734
|
fault("park:before-finish");
|
|
725
735
|
finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
|
|
726
736
|
say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
|
|
737
|
+
if (result.kind === "config" && result.error?.startsWith("sandbox refused")) {
|
|
738
|
+
this.note(`${agent.name} attempt ${att.n} was refused before it started (${result.error}); nothing was run, so there are no side effects to undo.`);
|
|
739
|
+
return result;
|
|
740
|
+
}
|
|
727
741
|
this.note(`${agent.name} attempt ${att.n} failed (${result.error ?? "unknown error"})${parked.length ? `; ${parked.length} output file(s) set aside in ${path.join("mail", "attempts", att.id, "outbox")}` : ""}. ` +
|
|
728
742
|
`Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
|
|
729
743
|
return result;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { WakeInput, WakeResult, Invoker } from "./adapters/types.js";
|
|
2
|
+
import type { ResolvedAgent, ResolvedProject } from "./config.js";
|
|
3
|
+
import { type SandboxPathConflict } from "./policy.js";
|
|
4
|
+
/** Where the `sandbox` and `sandbox-refused` events go: the run's log, or stderr for `call` (spec 25.8). */
|
|
5
|
+
export interface SandboxSink {
|
|
6
|
+
event(name: string, fields: Record<string, unknown>): void;
|
|
7
|
+
}
|
|
8
|
+
export interface ProtectedPath {
|
|
9
|
+
path: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Everything a sandbox allowance must not reach (spec 25.4.3), canonical. `deep` adds the per-invocation items: the concrete
|
|
14
|
+
* write policy's deny list, bound workspaces and the git common dir. The agent's own checkout is left out: it is already writable.
|
|
15
|
+
*/
|
|
16
|
+
export declare function protectedPathsFor(project: ResolvedProject, agent: ResolvedAgent, deep: boolean): ProtectedPath[];
|
|
17
|
+
export declare function describeConflict(agent: string, c: SandboxPathConflict, when: "load" | "invocation"): string;
|
|
18
|
+
/** Conflicts of one agent's `write`/`unix_sockets` with the protected set. */
|
|
19
|
+
export declare function sandboxConflictsOf(project: ResolvedProject, agent: ResolvedAgent, deep: boolean): SandboxPathConflict[];
|
|
20
|
+
/** Capability excess of each backup of `agent` over its primary (spec 25.9): `[candidate label, excess...]`. */
|
|
21
|
+
export declare function fallbackCapExcess(agent: ResolvedAgent): {
|
|
22
|
+
label: string;
|
|
23
|
+
excess: string[];
|
|
24
|
+
}[];
|
|
25
|
+
/** The refusal, if any, for this invocation. */
|
|
26
|
+
export declare function sandboxRefusal(input: WakeInput, base: ResolvedAgent): {
|
|
27
|
+
reason: string;
|
|
28
|
+
path?: string;
|
|
29
|
+
} | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* The one entry every adapter invocation goes through (spec 25.4.3, 25.19.1): refuses (returning a `config` failure, never
|
|
32
|
+
* throwing, never spawning) when an allowance reaches a protected path or a backup would widen them; otherwise records
|
|
33
|
+
* the `sandbox` event and calls `invoke`. An agent without `sandbox`, and a policy-override wake, go straight through.
|
|
34
|
+
*/
|
|
35
|
+
export declare function invokeWithSandboxChecks(invoke: Invoker, input: WakeInput, base: ResolvedAgent, sink: SandboxSink, attempt: number): Promise<WakeResult>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { canonicalPath } from "./paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* `canonicalPath` for the sandbox check: a path that is missing (ENOENT/ENOTDIR) falls back to its nearest existing ancestor as
|
|
8
|
+
* usual, but any other realpath error (EACCES, ELOOP, ...) throws so the invocation is refused (fail closed).
|
|
9
|
+
*/
|
|
10
|
+
function strictCanonical(p) {
|
|
11
|
+
for (let head = path.resolve(p);; head = path.dirname(head)) {
|
|
12
|
+
try {
|
|
13
|
+
fs.realpathSync.native(head);
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
const code = e.code;
|
|
18
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
19
|
+
throw new Error(`cannot resolve ${head} (${code ?? e.message})`);
|
|
20
|
+
if (path.dirname(head) === head)
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return canonicalPath(p);
|
|
25
|
+
}
|
|
26
|
+
import { inboxDir, memoryDirs, osSandboxAvailable, outboxDir, recordsDir, repoDirFor, repoInstructionFiles, sandboxCapExcess, sandboxCaps, sandboxPathConflicts, sharedContextFiles, writePolicy, } from "./policy.js";
|
|
27
|
+
/** Whether `cwd` or a parent holds a `.git` entry (a directory, or the file of a linked worktree). */
|
|
28
|
+
function insideGitCheckout(cwd) {
|
|
29
|
+
for (let d = path.resolve(cwd);; d = path.dirname(d)) {
|
|
30
|
+
if (fs.existsSync(path.join(d, ".git")))
|
|
31
|
+
return true;
|
|
32
|
+
if (path.dirname(d) === d)
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `git rev-parse --git-common-dir` of `cwd`, absolute; undefined when `cwd` is not in a git checkout. Fails closed: when it is a
|
|
38
|
+
* checkout but the probe fails, throws, so the invocation is refused instead of running with the shared git dir unprotected.
|
|
39
|
+
*/
|
|
40
|
+
function gitCommonDir(cwd) {
|
|
41
|
+
try {
|
|
42
|
+
const out = execFileSync("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000 }).trim();
|
|
43
|
+
if (out)
|
|
44
|
+
return path.resolve(cwd, out);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (!insideGitCheckout(cwd))
|
|
48
|
+
return undefined;
|
|
49
|
+
throw new Error(`cannot find the git common dir of ${cwd} (git rev-parse --git-common-dir failed: ${e.message.split("\n")[0]})`);
|
|
50
|
+
}
|
|
51
|
+
if (insideGitCheckout(cwd))
|
|
52
|
+
throw new Error(`cannot find the git common dir of ${cwd} (git rev-parse --git-common-dir printed nothing)`);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Everything a sandbox allowance must not reach (spec 25.4.3), canonical. `deep` adds the per-invocation items: the concrete
|
|
57
|
+
* write policy's deny list, bound workspaces and the git common dir. The agent's own checkout is left out: it is already writable.
|
|
58
|
+
*/
|
|
59
|
+
export function protectedPathsFor(project, agent, deep) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const add = (p, reason) => {
|
|
62
|
+
if (p)
|
|
63
|
+
out.push({ path: canonicalPath(p), reason });
|
|
64
|
+
};
|
|
65
|
+
add(project.home, "agent-lyceum home");
|
|
66
|
+
add(project.dir, "project directory");
|
|
67
|
+
add(path.join(os.homedir(), ".claude"), "Claude Code settings");
|
|
68
|
+
add(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "Codex settings");
|
|
69
|
+
for (const a of Object.values(project.agents)) {
|
|
70
|
+
add(a.agentMd, `AGENT.md of ${a.name}`);
|
|
71
|
+
for (const m of memoryDirs(a))
|
|
72
|
+
add(m, `memory of ${a.name}`);
|
|
73
|
+
add(outboxDir(project, a.name), `outbox of ${a.name}`);
|
|
74
|
+
add(inboxDir(project, a.name), `inbox of ${a.name}`);
|
|
75
|
+
}
|
|
76
|
+
for (const f of sharedContextFiles(project))
|
|
77
|
+
add(f, "shared context");
|
|
78
|
+
for (const f of repoInstructionFiles(project))
|
|
79
|
+
add(f, "repo instruction file");
|
|
80
|
+
add(recordsDir(project), "project records");
|
|
81
|
+
if (!deep)
|
|
82
|
+
return out;
|
|
83
|
+
for (const d of writePolicy(project, agent).deny)
|
|
84
|
+
add(d, "write-protected path");
|
|
85
|
+
if (project.run)
|
|
86
|
+
add(project.run.dir, "run directory");
|
|
87
|
+
add(path.join(project.paths.root, "worktrees"), "worktrees root");
|
|
88
|
+
const own = project.workspaces?.[agent.name];
|
|
89
|
+
for (const [name, ws] of Object.entries(project.workspaces ?? {})) {
|
|
90
|
+
if (name === agent.name)
|
|
91
|
+
continue;
|
|
92
|
+
add(ws.root, `workspace of ${name}`);
|
|
93
|
+
add(ws.gitDir, `git dir of ${name}`);
|
|
94
|
+
add(ws.commonDir, "git common dir");
|
|
95
|
+
}
|
|
96
|
+
// The agent's own checkout and git dir are already inside its write scope, so they are not listed; the shared common dir is.
|
|
97
|
+
add(own?.commonDir ?? gitCommonDir(repoDirFor(project, agent.name)), "git common dir");
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
export function describeConflict(agent, c, when) {
|
|
101
|
+
const suffix = when === "invocation" ? " (checked before invocation)" : "";
|
|
102
|
+
const rel = c.relation === "equal" ? "is" : c.relation === "inside" ? "is inside" : "contains";
|
|
103
|
+
return `agent ${agent}: sandbox.${c.key}[${c.index}] ${c.path} ${rel} protected path ${c.protectedPath} (${c.reason}); pick a different directory.${suffix}`;
|
|
104
|
+
}
|
|
105
|
+
/** Conflicts of one agent's `write`/`unix_sockets` with the protected set. */
|
|
106
|
+
export function sandboxConflictsOf(project, agent, deep) {
|
|
107
|
+
const s = agent.sandbox;
|
|
108
|
+
if (!s)
|
|
109
|
+
return [];
|
|
110
|
+
const entries = [
|
|
111
|
+
...s.write.map((p, index) => ({ key: "write", index, path: canonicalPath(p) })),
|
|
112
|
+
...s.unixSockets.map((p, index) => ({ key: "unix_sockets", index, path: canonicalPath(p) })),
|
|
113
|
+
];
|
|
114
|
+
return sandboxPathConflicts(entries, protectedPathsFor(project, agent, deep));
|
|
115
|
+
}
|
|
116
|
+
/** Capability excess of each backup of `agent` over its primary (spec 25.9): `[candidate label, excess...]`. */
|
|
117
|
+
export function fallbackCapExcess(agent) {
|
|
118
|
+
if (!agent.sandbox || !agent.runtime)
|
|
119
|
+
return [];
|
|
120
|
+
const primary = sandboxCaps(agent, agent.runtime);
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const f of agent.fallback) {
|
|
123
|
+
const excess = sandboxCapExcess(primary, sandboxCaps(agent, f.runtime));
|
|
124
|
+
if (excess.length)
|
|
125
|
+
out.push({ label: `${f.runtime}/${f.model || "(default model)"}`, excess });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** The refusal, if any, for this invocation. */
|
|
130
|
+
export function sandboxRefusal(input, base) {
|
|
131
|
+
const agent = input.agent;
|
|
132
|
+
if (!agent.sandbox)
|
|
133
|
+
return undefined;
|
|
134
|
+
for (const p of [...agent.sandbox.write, ...agent.sandbox.unixSockets])
|
|
135
|
+
strictCanonical(p);
|
|
136
|
+
const c = sandboxConflictsOf(input.project, agent, true)[0];
|
|
137
|
+
if (c)
|
|
138
|
+
return { reason: describeConflict(agent.name, c, "invocation"), path: c.path };
|
|
139
|
+
const f = fallbackCapExcess(base)[0];
|
|
140
|
+
if (f)
|
|
141
|
+
return { reason: `agent ${base.name}: fallback ${f.label} would apply more sandbox allowance than the primary (${f.excess.join("; ")}) (checked before invocation)` };
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
function applied(agent) {
|
|
145
|
+
const caps = sandboxCaps(agent);
|
|
146
|
+
const osOn = osSandboxAvailable();
|
|
147
|
+
if (!osOn)
|
|
148
|
+
return { write: agent.runtime === "claude-code" ? "tool-rules" : "none", network: "unrestricted", local_binding: "unrestricted", unix_sockets: "unrestricted" };
|
|
149
|
+
return { write: "os", network: caps.network, local_binding: caps.localBinding ? "on" : "off", unix_sockets: caps.unixSockets ? "on" : "off" };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The one entry every adapter invocation goes through (spec 25.4.3, 25.19.1): refuses (returning a `config` failure, never
|
|
153
|
+
* throwing, never spawning) when an allowance reaches a protected path or a backup would widen them; otherwise records
|
|
154
|
+
* the `sandbox` event and calls `invoke`. An agent without `sandbox`, and a policy-override wake, go straight through.
|
|
155
|
+
*/
|
|
156
|
+
export async function invokeWithSandboxChecks(invoke, input, base, sink, attempt) {
|
|
157
|
+
const agent = input.agent;
|
|
158
|
+
if (!agent.sandbox || input.policy)
|
|
159
|
+
return invoke(input);
|
|
160
|
+
const refuse = (reason) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: `sandbox refused: ${reason}`, kind: "config" });
|
|
161
|
+
try {
|
|
162
|
+
const bad = sandboxRefusal(input, base);
|
|
163
|
+
if (bad) {
|
|
164
|
+
sink.event("sandbox-refused", { agent: agent.name, runtime: agent.runtime, reason: bad.reason, ...(bad.path ? { path: bad.path } : {}) });
|
|
165
|
+
return refuse(bad.reason);
|
|
166
|
+
}
|
|
167
|
+
const s = agent.sandbox;
|
|
168
|
+
sink.event("sandbox", {
|
|
169
|
+
agent: agent.name,
|
|
170
|
+
runtime: agent.runtime,
|
|
171
|
+
model: agent.model,
|
|
172
|
+
attempt,
|
|
173
|
+
write: s.write.map(canonicalPath),
|
|
174
|
+
network: s.network,
|
|
175
|
+
local_binding: s.localBinding,
|
|
176
|
+
unix_sockets: s.unixSockets.map(canonicalPath),
|
|
177
|
+
applied: applied(agent),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
const reason = `${agent.name}: sandbox check failed: ${e.message}`;
|
|
182
|
+
sink.event("sandbox-refused", { agent: agent.name, runtime: agent.runtime, reason });
|
|
183
|
+
return refuse(reason);
|
|
184
|
+
}
|
|
185
|
+
return invoke(input);
|
|
186
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare function inferRuntime(model: string | undefined): Runtime | undef
|
|
|
12
12
|
export declare const WEB_LEVELS: readonly ["none", "search", "fetch"];
|
|
13
13
|
export type WebLevel = (typeof WEB_LEVELS)[number];
|
|
14
14
|
/** Why a wake failed, as far as the CLI's output reveals; decides whether to retry, fall back, or stop. */
|
|
15
|
-
export declare const ERROR_KINDS: readonly ["rate_limit", "quota", "timeout", "network", "crash", "auth", "unknown"];
|
|
15
|
+
export declare const ERROR_KINDS: readonly ["rate_limit", "quota", "timeout", "network", "crash", "auth", "config", "unknown"];
|
|
16
16
|
export type ErrorKind = (typeof ERROR_KINDS)[number];
|
|
17
17
|
/** One link of a fallback chain after resolution: runtime and model are filled in (model "" = the runtime's own default). */
|
|
18
18
|
export interface FallbackEntry {
|
|
@@ -37,6 +37,14 @@ export declare const FallbackEntryPartial: z.ZodObject<{
|
|
|
37
37
|
}>>;
|
|
38
38
|
}, z.core.$strict>;
|
|
39
39
|
export type FallbackEntryPartialT = z.infer<typeof FallbackEntryPartial>;
|
|
40
|
+
/** Per-agent sandbox allowances (spec 25); every key is optional and absent = none. */
|
|
41
|
+
export declare const SandboxPartial: z.ZodObject<{
|
|
42
|
+
write: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
43
|
+
network: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
44
|
+
local_binding: z.ZodOptional<z.ZodBoolean>;
|
|
45
|
+
unix_sockets: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
46
|
+
}, z.core.$strict>;
|
|
47
|
+
export type SandboxPartialT = z.infer<typeof SandboxPartial>;
|
|
40
48
|
export declare const AgentPartial: z.ZodObject<{
|
|
41
49
|
runtime: z.ZodOptional<z.ZodEnum<{
|
|
42
50
|
"claude-code": "claude-code";
|
|
@@ -80,6 +88,12 @@ export declare const AgentPartial: z.ZodObject<{
|
|
|
80
88
|
xhigh: "xhigh";
|
|
81
89
|
}>>;
|
|
82
90
|
}, z.core.$strict>>>;
|
|
91
|
+
sandbox: z.ZodOptional<z.ZodObject<{
|
|
92
|
+
write: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
93
|
+
network: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
94
|
+
local_binding: z.ZodOptional<z.ZodBoolean>;
|
|
95
|
+
unix_sockets: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
96
|
+
}, z.core.$strict>>;
|
|
83
97
|
owns: z.ZodOptional<z.ZodUnknown>;
|
|
84
98
|
}, z.core.$strict>;
|
|
85
99
|
export type AgentPartialT = z.infer<typeof AgentPartial>;
|
|
@@ -168,6 +182,12 @@ export declare const GlobalConfig: z.ZodObject<{
|
|
|
168
182
|
xhigh: "xhigh";
|
|
169
183
|
}>>;
|
|
170
184
|
}, z.core.$strict>>>;
|
|
185
|
+
sandbox: z.ZodOptional<z.ZodObject<{
|
|
186
|
+
write: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
187
|
+
network: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
188
|
+
local_binding: z.ZodOptional<z.ZodBoolean>;
|
|
189
|
+
unix_sockets: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
190
|
+
}, z.core.$strict>>;
|
|
171
191
|
owns: z.ZodOptional<z.ZodUnknown>;
|
|
172
192
|
}, z.core.$strict>>>;
|
|
173
193
|
}, z.core.$strict>;
|
|
@@ -250,6 +270,12 @@ export declare const ProjectConfig: z.ZodObject<{
|
|
|
250
270
|
xhigh: "xhigh";
|
|
251
271
|
}>>;
|
|
252
272
|
}, z.core.$strict>>>;
|
|
273
|
+
sandbox: z.ZodOptional<z.ZodObject<{
|
|
274
|
+
write: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
275
|
+
network: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
276
|
+
local_binding: z.ZodOptional<z.ZodBoolean>;
|
|
277
|
+
unix_sockets: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
278
|
+
}, z.core.$strict>>;
|
|
253
279
|
owns: z.ZodOptional<z.ZodUnknown>;
|
|
254
280
|
}, z.core.$strict>>>;
|
|
255
281
|
}, z.core.$strict>;
|
package/dist/schema.js
CHANGED
|
@@ -21,7 +21,7 @@ export function inferRuntime(model) {
|
|
|
21
21
|
/** How much of the web an agent may use: nothing, search only, or search plus fetching pages. Unset leaves each runtime's own default alone. */
|
|
22
22
|
export const WEB_LEVELS = ["none", "search", "fetch"];
|
|
23
23
|
/** Why a wake failed, as far as the CLI's output reveals; decides whether to retry, fall back, or stop. */
|
|
24
|
-
export const ERROR_KINDS = ["rate_limit", "quota", "timeout", "network", "crash", "auth", "unknown"];
|
|
24
|
+
export const ERROR_KINDS = ["rate_limit", "quota", "timeout", "network", "crash", "auth", "config", "unknown"];
|
|
25
25
|
const Memory = z
|
|
26
26
|
.object({
|
|
27
27
|
global: z.string().optional(),
|
|
@@ -36,6 +36,18 @@ export const FallbackEntryPartial = z
|
|
|
36
36
|
effort: z.enum(EFFORTS).optional(),
|
|
37
37
|
})
|
|
38
38
|
.strict();
|
|
39
|
+
const DOMAIN_RE = /^(\*\.)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
40
|
+
/** Per-agent sandbox allowances (spec 25); every key is optional and absent = none. */
|
|
41
|
+
export const SandboxPartial = z
|
|
42
|
+
.object({
|
|
43
|
+
write: z.array(z.string().trim().min(1)).optional(),
|
|
44
|
+
network: z
|
|
45
|
+
.array(z.string().trim().toLowerCase().regex(DOMAIN_RE, "expected a host name like registry.npmjs.org or *.example.com (no scheme, port, path or IP)"))
|
|
46
|
+
.optional(),
|
|
47
|
+
local_binding: z.boolean().optional(),
|
|
48
|
+
unix_sockets: z.array(z.string().trim().min(1)).optional(),
|
|
49
|
+
})
|
|
50
|
+
.strict();
|
|
39
51
|
export const AgentPartial = z
|
|
40
52
|
.object({
|
|
41
53
|
runtime: z.enum(RUNTIMES).optional(),
|
|
@@ -49,6 +61,7 @@ export const AgentPartial = z
|
|
|
49
61
|
can_ask_user: z.boolean().optional(),
|
|
50
62
|
allow_web: z.enum(WEB_LEVELS).optional(),
|
|
51
63
|
fallback: z.array(FallbackEntryPartial).optional(),
|
|
64
|
+
sandbox: SandboxPartial.optional(),
|
|
52
65
|
/** Removed setting: still accepted (any value) so old files load; ignored, with a warning from `validate`. */
|
|
53
66
|
owns: z.unknown().optional(),
|
|
54
67
|
})
|
package/dist/validate.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { enforcementFor, memoryDirs } from "./policy.js";
|
|
3
|
+
import { describeConflict, fallbackCapExcess, sandboxConflictsOf } from "./sandbox-invocation.js";
|
|
3
4
|
import { isInside } from "./paths.js";
|
|
4
5
|
import { inferRuntime, RUNTIME_EFFORTS, RUNTIMES } from "./schema.js";
|
|
5
6
|
import { ladderIndex } from "./tiers.js";
|
|
@@ -50,6 +51,10 @@ export function validateProject(project) {
|
|
|
50
51
|
err(`Agent "${a.name}": effort "${a.effort}" is not supported by ${a.runtime} (use ${RUNTIME_EFFORTS[a.runtime].join(", ")}).`);
|
|
51
52
|
}
|
|
52
53
|
validateFallback(project, a, err);
|
|
54
|
+
for (const c of sandboxConflictsOf(project, a, false))
|
|
55
|
+
err(describeConflict(a.name, c, "load"));
|
|
56
|
+
for (const f of fallbackCapExcess(a))
|
|
57
|
+
err(`agent ${a.name}: fallback ${f.label} would apply more sandbox allowance than the primary (${f.excess.join("; ")}).`);
|
|
53
58
|
if (!fs.existsSync(a.agentMd))
|
|
54
59
|
err(`Agent "${a.name}": AGENT.md not found at ${a.agentMd}`);
|
|
55
60
|
if (!a.memory.global && !a.memory.project)
|