@agentswf/sandbox 0.0.0-stage → 0.1.0-beta.1
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/docker/Dockerfile +45 -0
- package/docker/proxy.js +45 -0
- package/docker/relay.js +36 -0
- package/package.json +34 -4
- package/src/docker/args.ts +65 -0
- package/src/docker/index.ts +865 -0
- package/src/git.ts +152 -0
- package/src/groups.ts +81 -0
- package/src/index.ts +3 -0
- package/src/once.ts +16 -0
- package/src/pane.ts +39 -0
- package/src/resolve.ts +352 -0
- package/src/seam.ts +214 -0
- package/src/secrets.ts +67 -0
- package/src/srt/index.ts +427 -0
- package/src/srt/profile.ts +199 -0
- package/src/testing/conformance.ts +437 -0
- package/src/testing/fake.ts +134 -0
- package/src/testing/index.ts +2 -0
- package/README.md +0 -3
package/src/git.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { contains } from "./resolve";
|
|
4
|
+
import type { ResolvedSandbox } from "./seam";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What in a writable gitdir the host's git runs, or follows to config it would run: hooks and
|
|
8
|
+
* config; `commondir`, `gitdir` and `config.worktree`, which point it elsewhere; submodules'
|
|
9
|
+
* gitdirs; and `info/`. The rest (objects, refs, the index, `HEAD`) is what a commit writes.
|
|
10
|
+
*/
|
|
11
|
+
const GIT_PROTECTED = [
|
|
12
|
+
"hooks",
|
|
13
|
+
"config",
|
|
14
|
+
"config.worktree",
|
|
15
|
+
"commondir",
|
|
16
|
+
"gitdir",
|
|
17
|
+
"modules",
|
|
18
|
+
"info",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
/** A worktree's own state in its gitdir, which another worktree's commit leaves. */
|
|
22
|
+
const WORKTREE_STATE = ["index", "HEAD"] as const;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What the operator's tools run from a directory they work in, unasked: srt's own list, which it
|
|
26
|
+
* denies anywhere, and claude's project settings, whose hooks run on the host's next claude there.
|
|
27
|
+
*/
|
|
28
|
+
const HOST_RUN = [
|
|
29
|
+
".gitconfig",
|
|
30
|
+
".gitmodules",
|
|
31
|
+
".bashrc",
|
|
32
|
+
".bash_profile",
|
|
33
|
+
".zshrc",
|
|
34
|
+
".zprofile",
|
|
35
|
+
".profile",
|
|
36
|
+
".ripgreprc",
|
|
37
|
+
".mcp.json",
|
|
38
|
+
".vscode",
|
|
39
|
+
".idea",
|
|
40
|
+
".claude/commands",
|
|
41
|
+
".claude/agents",
|
|
42
|
+
".claude/settings.json",
|
|
43
|
+
".claude/settings.local.json",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every path an agent must not write, whether it exists yet or not, so the host runs nothing an
|
|
48
|
+
* agent planted: each writable gitdir's protected parts, and those of every worktree kept inside
|
|
49
|
+
* it; the state of every worktree out of reach, the main one's included, which the operator
|
|
50
|
+
* commits from; the `.git` pointers of worktrees and submodules in writable paths, which
|
|
51
|
+
* rewritten would point the host's git at a gitdir an agent made; hooks `core.hooksPath` puts in a
|
|
52
|
+
* writable path; host-run config at the root of each writable path, worktree and clone; and what
|
|
53
|
+
* any of these, being a link, names in a writable path. Every provider holds the same list.
|
|
54
|
+
*/
|
|
55
|
+
export async function protectedPaths(spec: ResolvedSandbox<unknown>): Promise<string[]> {
|
|
56
|
+
const writable = spec.gitdirs.filter((gitdir) => gitdir.writable);
|
|
57
|
+
const inWrite = (path: string) => spec.write.some((root) => contains(root, path));
|
|
58
|
+
const gitdirs = new Set(writable.map(({ path }) => path));
|
|
59
|
+
// Whose worktree is in reach: the others' state is theirs to stage and check out.
|
|
60
|
+
const own = new Set(writable.filter((gitdir) => !gitdir.linked).map(({ path }) => path));
|
|
61
|
+
const others: string[] = [];
|
|
62
|
+
const pointers: string[] = [];
|
|
63
|
+
for (const { path } of writable) {
|
|
64
|
+
for (const name of await readdir(join(path, "worktrees")).catch(() => [])) {
|
|
65
|
+
const worktree = join(path, "worktrees", name);
|
|
66
|
+
gitdirs.add(worktree);
|
|
67
|
+
if (!own.has(worktree)) others.push(worktree);
|
|
68
|
+
// Git keeps where each worktree's pointer is.
|
|
69
|
+
const pointer = await readFile(join(worktree, "gitdir"), "utf8").catch(() => undefined);
|
|
70
|
+
if (pointer?.trim()) pointers.push(resolve(worktree, pointer.trim()));
|
|
71
|
+
}
|
|
72
|
+
pointers.push(...(await submodulePointers(join(path, "modules"))));
|
|
73
|
+
}
|
|
74
|
+
const guarded = pointers.filter(inWrite);
|
|
75
|
+
// Where the host's tools work: each writable path, and each worktree and clone inside one.
|
|
76
|
+
const roots = [
|
|
77
|
+
...spec.write,
|
|
78
|
+
...guarded.map(dirname),
|
|
79
|
+
...writable
|
|
80
|
+
.filter(({ path }) => basename(path) === ".git" && inWrite(path))
|
|
81
|
+
.map(({ path }) => dirname(path)),
|
|
82
|
+
];
|
|
83
|
+
// `core.hooksPath` puts hooks outside the gitdir, relative to a worktree's root, often in it.
|
|
84
|
+
const hooks: string[] = [];
|
|
85
|
+
for (const { path } of writable) {
|
|
86
|
+
for (const name of ["config", "config.worktree"]) {
|
|
87
|
+
const config = await readFile(join(path, name), "utf8").catch(() => "");
|
|
88
|
+
for (const [, value = ""] of config.matchAll(/^\s*hookspath\s*=\s*(.+?)\s*$/gim)) {
|
|
89
|
+
const named = value.replace(/^"(.*)"$/, "$1");
|
|
90
|
+
// Every worktree's root: a repository's config holds for each of them.
|
|
91
|
+
const worktrees = [
|
|
92
|
+
...(basename(path) === ".git" ? [dirname(path)] : []),
|
|
93
|
+
...guarded.map(dirname),
|
|
94
|
+
];
|
|
95
|
+
hooks.push(
|
|
96
|
+
...(isAbsolute(named) ? [named] : worktrees.map((root) => resolve(root, named))),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const listed = [
|
|
102
|
+
...new Set([
|
|
103
|
+
...[...gitdirs].flatMap((gitdir) => GIT_PROTECTED.map((name) => join(gitdir, name))),
|
|
104
|
+
...[...writable.filter((gitdir) => gitdir.linked).map(({ path }) => path), ...others].flatMap(
|
|
105
|
+
(gitdir) => WORKTREE_STATE.map((name) => join(gitdir, name)),
|
|
106
|
+
),
|
|
107
|
+
...guarded,
|
|
108
|
+
...hooks.filter(inWrite),
|
|
109
|
+
...roots.flatMap((root) => HOST_RUN.map((name) => join(root, name))),
|
|
110
|
+
]),
|
|
111
|
+
];
|
|
112
|
+
// One that is, or is under, a link into a writable path is written through there: that path is
|
|
113
|
+
// guarded too, whether it exists yet or not.
|
|
114
|
+
const targets = await Promise.all(
|
|
115
|
+
listed.map(async (path) => {
|
|
116
|
+
const real = await realpathOf(path);
|
|
117
|
+
return real !== path && inWrite(real) ? [real] : [];
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
return [...new Set([...listed, ...targets.flat()])];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The `.git` pointer of each submodule whose gitdir is under `modules`, nested ones included: a
|
|
125
|
+
* module's gitdir names its worktree in its config's `core.worktree`.
|
|
126
|
+
*/
|
|
127
|
+
async function submodulePointers(modules: string): Promise<string[]> {
|
|
128
|
+
const found: string[] = [];
|
|
129
|
+
const visit = async (directory: string): Promise<void> => {
|
|
130
|
+
const config = await readFile(join(directory, "config"), "utf8").catch(() => undefined);
|
|
131
|
+
if (config !== undefined) {
|
|
132
|
+
const worktree = /^\s*worktree\s*=\s*(.+?)\s*$/m.exec(config)?.[1];
|
|
133
|
+
if (worktree) found.push(join(resolve(directory, worktree), ".git"));
|
|
134
|
+
await visit(join(directory, "modules"));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// A module's name may hold `/`: its gitdir is nested that deep.
|
|
138
|
+
for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
|
|
139
|
+
if (entry.isDirectory()) await visit(join(directory, entry.name));
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
if (await stat(modules).catch(() => undefined)) await visit(modules);
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `path` with every link in it resolved, the part below the deepest one that exists kept as is. */
|
|
147
|
+
async function realpathOf(path: string): Promise<string> {
|
|
148
|
+
const real = await realpath(path).catch(() => undefined);
|
|
149
|
+
if (real !== undefined) return real;
|
|
150
|
+
const parent = dirname(path);
|
|
151
|
+
return parent === path ? path : join(await realpathOf(parent), basename(path));
|
|
152
|
+
}
|
package/src/groups.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** A shell script that records its pid in `$0`, then becomes the command in `$@`. */
|
|
6
|
+
export const RECORD_LEADER = 'mkdir -p "$(dirname "$0")" && echo $$ > "$0" && exec "$@"';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A shell script that kills, where the host's `process.kill` cannot reach, the group each pid file
|
|
10
|
+
* among its arguments names, or each one in a directory among them, and forgets it. A pid that is
|
|
11
|
+
* not a number above 1 is skipped: `kill -9 -1` would take every process of the uid.
|
|
12
|
+
*/
|
|
13
|
+
export const KILL_GROUPS = `for a in "$@"; do for f in "$a" "$a"/*; do [ -f "$f" ] || continue; p=$(cat "$f"); case "$p" in ''|*[!0-9]*|0|1) ;; *) kill -9 -"$p" 2>/dev/null;; esac; rm -f "$f"; done; done; true`;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The process groups one agent's launches lead, each named by a pid file its leader writes, so a
|
|
17
|
+
* provider can end what an agent still runs at `release` and `close`: a turn left finishing, or
|
|
18
|
+
* one a caller never killed. `runProcess` makes each launch a group leader (`setsid`).
|
|
19
|
+
*/
|
|
20
|
+
export class LaunchedGroups {
|
|
21
|
+
constructor(private readonly directory: string) {}
|
|
22
|
+
|
|
23
|
+
/** A fresh pid file, in a directory that exists, for a leader that records itself: a pane's. */
|
|
24
|
+
async pidFile(): Promise<string> {
|
|
25
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
26
|
+
return join(this.directory, randomUUID());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `argv`, run by a shell that records its pid and then becomes it. */
|
|
30
|
+
wrap(argv: readonly string[]): { argv: string[]; pidFile: string } {
|
|
31
|
+
const pidFile = join(this.directory, randomUUID());
|
|
32
|
+
return {
|
|
33
|
+
argv: ["/bin/sh", "-c", RECORD_LEADER, pidFile, ...argv],
|
|
34
|
+
pidFile,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Kills the group whose leader wrote `pidFile`. A group id stays reserved while any member
|
|
40
|
+
* lives, and no process takes a live group's id, so the one risk is a leader whose group is
|
|
41
|
+
* gone and whose pid a new process has taken: a pane's file stays until release. A process
|
|
42
|
+
* holding the pid that started after the file was written is that one, and is left alone.
|
|
43
|
+
*/
|
|
44
|
+
async kill(pidFile: string): Promise<void> {
|
|
45
|
+
const [text, written] = await Promise.all([
|
|
46
|
+
readFile(pidFile, "utf8").catch(() => ""),
|
|
47
|
+
stat(pidFile).then(
|
|
48
|
+
(found) => found.mtimeMs,
|
|
49
|
+
() => undefined,
|
|
50
|
+
),
|
|
51
|
+
]);
|
|
52
|
+
const pid = Number(text.trim());
|
|
53
|
+
const started = Number.isSafeInteger(pid) && pid > 1 ? startedAt(pid) : undefined;
|
|
54
|
+
// `ps` gives whole seconds. With the leader gone, what is left of its group is killed.
|
|
55
|
+
const reused = started !== undefined && written !== undefined && started > written + 1_000;
|
|
56
|
+
if (Number.isSafeInteger(pid) && pid > 1 && !reused) {
|
|
57
|
+
try {
|
|
58
|
+
process.kill(-pid, "SIGKILL");
|
|
59
|
+
} catch {
|
|
60
|
+
// Already gone.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await rm(pidFile, { force: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Kills every group still recorded, and forgets them. */
|
|
67
|
+
async killAll(): Promise<void> {
|
|
68
|
+
const files = await readdir(this.directory).catch(() => []);
|
|
69
|
+
await Promise.all(files.map((file) => this.kill(join(this.directory, file))));
|
|
70
|
+
await rm(this.directory, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** When the process `pid` started, in milliseconds, or undefined when there is none. */
|
|
75
|
+
function startedAt(pid: number): number | undefined {
|
|
76
|
+
const listed = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
|
|
77
|
+
env: { PATH: "/bin:/usr/bin", LC_ALL: "C", TZ: "UTC" },
|
|
78
|
+
});
|
|
79
|
+
const started = Date.parse(`${listed.stdout.toString().trim()} UTC`);
|
|
80
|
+
return listed.exitCode === 0 && Number.isFinite(started) ? started : undefined;
|
|
81
|
+
}
|
package/src/index.ts
ADDED
package/src/once.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** `start`, run once and shared by every caller until it fails: the call after a failure tries again. */
|
|
2
|
+
export function onceUnlessFailed<A extends unknown[], T>(
|
|
3
|
+
start: (...args: A) => Promise<T>,
|
|
4
|
+
): (...args: A) => Promise<T> {
|
|
5
|
+
let running: Promise<T> | undefined;
|
|
6
|
+
return (...args) => {
|
|
7
|
+
if (!running) {
|
|
8
|
+
const attempt = start(...args);
|
|
9
|
+
running = attempt;
|
|
10
|
+
attempt.catch(() => {
|
|
11
|
+
if (running === attempt) running = undefined;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return running;
|
|
15
|
+
};
|
|
16
|
+
}
|
package/src/pane.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { PaneTerminal } from "./seam";
|
|
3
|
+
import { shellQuote } from "./secrets";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a pane's login shell is typed, to become the agent's shell: a zsh run through `through`,
|
|
7
|
+
* with exactly `environment`, and nothing of the operator's shell; when it exits the pane closes
|
|
8
|
+
* (H6). Its pid is recorded in `pidFile` and, with job control off (`+m`), every job stays in the
|
|
9
|
+
* group it leads, so a release ends them all. Its secrets are sourced from `secretsFile`, which is
|
|
10
|
+
* then removed. zsh takes `PROMPT` from its environment, typed as `%%` and shown as `%`, so the
|
|
11
|
+
* screen shows `ready` only once zsh draws it.
|
|
12
|
+
*/
|
|
13
|
+
export function panePrelude(options: {
|
|
14
|
+
environment: Readonly<Record<string, string>>;
|
|
15
|
+
pidFile: string;
|
|
16
|
+
secretsFile: string;
|
|
17
|
+
through: readonly string[];
|
|
18
|
+
}): Pick<PaneTerminal, "prelude" | "ready"> {
|
|
19
|
+
const nonce = randomUUID().replaceAll("-", "");
|
|
20
|
+
const environment = {
|
|
21
|
+
...options.environment,
|
|
22
|
+
TERM: "xterm-256color",
|
|
23
|
+
PROMPT: `awf-%%-${nonce}%# `,
|
|
24
|
+
};
|
|
25
|
+
const pidFile = shellQuote(options.pidFile);
|
|
26
|
+
const secretsFile = shellQuote(options.secretsFile);
|
|
27
|
+
const confined = [
|
|
28
|
+
`mkdir -p "$(dirname ${pidFile})" && echo $$ > ${pidFile}`,
|
|
29
|
+
`set -a; . ${secretsFile}; set +a; rm -f ${secretsFile}`,
|
|
30
|
+
`exec ${[...options.through, "/bin/zsh", "-f", "+m"].map(shellQuote).join(" ")}`,
|
|
31
|
+
].join("; ");
|
|
32
|
+
const assignments = Object.entries(environment)
|
|
33
|
+
.map(([name, value]) => `${name}=${shellQuote(value)}`)
|
|
34
|
+
.join(" ");
|
|
35
|
+
return {
|
|
36
|
+
prelude: `exec /usr/bin/env -i ${assignments} /bin/sh -c ${shellQuote(confined)}`,
|
|
37
|
+
ready: `awf-%-${nonce}% `,
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { isRecord } from "@agentswf/contract";
|
|
5
|
+
import { SANDBOX_ENVIRONMENTS, type SandboxEnvironmentKey } from "@agentswf/contract/workflow";
|
|
6
|
+
import type { Gitdir, ResolvedSandbox, SandboxProviders } from "./seam";
|
|
7
|
+
|
|
8
|
+
const REACH_KEYS = ["read", "write", "network", "sockets", "webSearch"] as const;
|
|
9
|
+
|
|
10
|
+
export type SandboxResolution = {
|
|
11
|
+
/** The environment key the sandbox runs under: the one the spec named, else the default. */
|
|
12
|
+
provider: SandboxEnvironmentKey;
|
|
13
|
+
sandbox: ResolvedSandbox<unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ResolveOptions = {
|
|
17
|
+
key: string;
|
|
18
|
+
/** What a relative `cwd` resolves against, and the sandbox's `cwd` when the spec names none. */
|
|
19
|
+
cwd: string;
|
|
20
|
+
/** Every run's directory: no sandbox path may be or lie inside it, and one holding it hides it. */
|
|
21
|
+
runRoot: string;
|
|
22
|
+
/**
|
|
23
|
+
* What awf keeps per machine, `~/.awf`: every sandbox's homes, with their copied credentials, and
|
|
24
|
+
* the sessions runs drive. No sandbox path may lie inside it.
|
|
25
|
+
*/
|
|
26
|
+
machineRoot: string;
|
|
27
|
+
providers: SandboxProviders;
|
|
28
|
+
/** An agent's private spec, which may not carry `key` or `cwd`. */
|
|
29
|
+
inline?: boolean;
|
|
30
|
+
/** The operator's home, for `~`. */
|
|
31
|
+
home?: string;
|
|
32
|
+
/** The operator's harness state, where its environment put it: no sandbox path may overlap it. */
|
|
33
|
+
harnessState: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Where the engine keeps every run's doors: `/tmp`, holding an `awf-*` directory per run. No
|
|
36
|
+
* sandbox path may contain it, or lie in one of those directories: an agent could rewrite
|
|
37
|
+
* another agent's launcher, which runs on the host.
|
|
38
|
+
*/
|
|
39
|
+
controlRoot?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Checks an author's spec the way the types would, since a workflow is untyped at run time, and
|
|
44
|
+
* resolves its paths once, at open: a sandbox holds what they named then, whatever moves later.
|
|
45
|
+
*/
|
|
46
|
+
export async function resolveSandbox(
|
|
47
|
+
spec: unknown,
|
|
48
|
+
options: ResolveOptions,
|
|
49
|
+
): Promise<SandboxResolution> {
|
|
50
|
+
if (!isRecord(spec)) throw new Error("a sandbox spec must be an object");
|
|
51
|
+
const allowed = new Set<string>([
|
|
52
|
+
...REACH_KEYS,
|
|
53
|
+
...SANDBOX_ENVIRONMENTS,
|
|
54
|
+
...(options.inline ? [] : ["key", "cwd"]),
|
|
55
|
+
]);
|
|
56
|
+
for (const key of Object.keys(spec)) {
|
|
57
|
+
if (allowed.has(key)) continue;
|
|
58
|
+
throw new Error(
|
|
59
|
+
options.inline && (key === "key" || key === "cwd" || key === "provider")
|
|
60
|
+
? `an agent's inline sandbox cannot name ${key}; open a sandbox and pass its ref to share one`
|
|
61
|
+
: `unknown sandbox field ${JSON.stringify(key)}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const named = SANDBOX_ENVIRONMENTS.filter((key) => spec[key] !== undefined);
|
|
65
|
+
if (named.length > 1) {
|
|
66
|
+
throw new Error(`a sandbox has one environment, not ${named.join(" and ")}`);
|
|
67
|
+
}
|
|
68
|
+
const provider = named[0] ?? options.providers.default;
|
|
69
|
+
if (provider === undefined) throw new Error("no sandbox provider is installed");
|
|
70
|
+
const installed = options.providers.installed[provider];
|
|
71
|
+
if (!installed) {
|
|
72
|
+
const why = options.providers.unavailable?.[provider];
|
|
73
|
+
throw new Error(
|
|
74
|
+
`the ${provider} sandbox provider is not ${why ? `usable: ${why}` : "installed"}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const environment = installed.environment(named[0] === undefined ? {} : spec[named[0]]);
|
|
78
|
+
|
|
79
|
+
const home = await realpath(options.home ?? homedir());
|
|
80
|
+
const runRoot = await realpath(options.runRoot);
|
|
81
|
+
const machine = await realpath(options.machineRoot).catch(() => options.machineRoot);
|
|
82
|
+
const controlRoot =
|
|
83
|
+
options.controlRoot === undefined
|
|
84
|
+
? undefined
|
|
85
|
+
: await realpath(options.controlRoot).catch(() => options.controlRoot);
|
|
86
|
+
const harnessState = await Promise.all(
|
|
87
|
+
options.harnessState.map((path) => realpath(path).catch(() => path)),
|
|
88
|
+
);
|
|
89
|
+
const forbidden = (path: string, field: string) => {
|
|
90
|
+
if (contains(path, home)) throw new Error(`sandbox ${field} ${path} would expose ~`);
|
|
91
|
+
// A path within the run root would be another run's. One holding it is `hidden`.
|
|
92
|
+
if (contains(runRoot, path)) {
|
|
93
|
+
throw new Error(`sandbox ${field} ${path} would expose the run root`);
|
|
94
|
+
}
|
|
95
|
+
// Code the host runs and every past transcript (story 004, "A fresh harness home").
|
|
96
|
+
const state = harnessState.find((root) => contains(path, root) || contains(root, path));
|
|
97
|
+
if (state) throw new Error(`sandbox ${field} ${path} would expose harness state ${state}`);
|
|
98
|
+
const keys = KEY_DIRECTORIES.map((name) => join(home, name)).find(
|
|
99
|
+
(root) => contains(path, root) || contains(root, path),
|
|
100
|
+
);
|
|
101
|
+
if (keys) throw new Error(`sandbox ${field} ${path} would expose keys in ${keys}`);
|
|
102
|
+
if (contains(machine, path))
|
|
103
|
+
throw new Error(`sandbox ${field} ${path} would expose ${machine}`);
|
|
104
|
+
if (controlRoot && (contains(path, controlRoot) || insideDoors(controlRoot, path))) {
|
|
105
|
+
throw new Error(`sandbox ${field} ${path} would expose the engine's doors`);
|
|
106
|
+
}
|
|
107
|
+
return path;
|
|
108
|
+
};
|
|
109
|
+
const cwd = forbidden(
|
|
110
|
+
await resolvePath(spec.cwd === undefined ? "." : spec.cwd, options.cwd, home, "cwd"),
|
|
111
|
+
"cwd",
|
|
112
|
+
);
|
|
113
|
+
const paths = (field: "read" | "write" | "sockets") =>
|
|
114
|
+
Promise.all(
|
|
115
|
+
list(spec[field], field).map(async (path) =>
|
|
116
|
+
forbidden(await resolvePath(path, cwd, home, field), field),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
const write = unique(await paths("write"));
|
|
120
|
+
const read = unique(await paths("read")).filter((path) => !write.includes(path));
|
|
121
|
+
const sockets = unique(await paths("sockets"));
|
|
122
|
+
if (spec.webSearch !== undefined && typeof spec.webSearch !== "boolean") {
|
|
123
|
+
throw new Error("sandbox webSearch must be true or false");
|
|
124
|
+
}
|
|
125
|
+
const network = unique(
|
|
126
|
+
list(spec.network, "network").map((domain) => {
|
|
127
|
+
if (!isDomain(domain)) throw new Error(`not a sandbox domain: ${JSON.stringify(domain)}`);
|
|
128
|
+
return domain.toLowerCase();
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
// The most specific path naming a directory decides whether it is writable.
|
|
132
|
+
const writableAt = (path: string) => writableIn({ read, write }, path);
|
|
133
|
+
/** Each gitdir, and whether a writable worktree reaches it as its own or as a linked one's. */
|
|
134
|
+
const gitdirs = new Map<string, { own: boolean; linked: boolean }>();
|
|
135
|
+
const add = (repository: Repository, path: string) => {
|
|
136
|
+
// Writable only when the whole worktree is: `write: ["out"]` must not let an agent move
|
|
137
|
+
// the repository's branches or stage what the operator commits next.
|
|
138
|
+
const writable = writableAt(repository.root);
|
|
139
|
+
repository.gitdirs.forEach((gitdir, index) => {
|
|
140
|
+
forbidden(gitdir, `gitdir of ${path}`);
|
|
141
|
+
const via = gitdirs.get(gitdir) ?? { own: false, linked: false };
|
|
142
|
+
// A linked worktree's common gitdir is its second.
|
|
143
|
+
if (writable && index === 1) via.linked = true;
|
|
144
|
+
else if (writable) via.own = true;
|
|
145
|
+
gitdirs.set(gitdir, via);
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
for (const path of [cwd, ...read, ...write]) {
|
|
149
|
+
const repository = await repositoryOf(path, home);
|
|
150
|
+
if (repository) add(repository, path);
|
|
151
|
+
}
|
|
152
|
+
// A clone inside a writable path is as writable as its hooks: its gitdir is guarded too. One
|
|
153
|
+
// whose gitdir lies outside is refused, never added: a pointer an earlier agent wrote would
|
|
154
|
+
// widen the reach to wherever it names. One whose gitdir is gone points nowhere to guard.
|
|
155
|
+
for (const root of write) {
|
|
156
|
+
for (const nested of await nestedRepositories(root)) {
|
|
157
|
+
const repository = await repositoryOf(nested, home).catch(() => undefined);
|
|
158
|
+
if (!repository) continue;
|
|
159
|
+
const outside = repository.gitdirs.find((gitdir) => !write.some((w) => contains(w, gitdir)));
|
|
160
|
+
if (outside) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`sandbox write ${root} holds ${nested}, whose gitdir ${outside} is outside it: write that too, or remove its .git`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
add(repository, nested);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// A project keeps its runs in `.awf/runs`: a sandbox holding the project is not refused, and
|
|
169
|
+
// every provider hides the run root inside it.
|
|
170
|
+
const holdsRunRoot = [cwd, ...read, ...write, ...gitdirs.keys()].some((path) =>
|
|
171
|
+
contains(path, runRoot),
|
|
172
|
+
);
|
|
173
|
+
return {
|
|
174
|
+
provider,
|
|
175
|
+
sandbox: {
|
|
176
|
+
key: options.key,
|
|
177
|
+
cwd,
|
|
178
|
+
read,
|
|
179
|
+
write,
|
|
180
|
+
network,
|
|
181
|
+
sockets,
|
|
182
|
+
webSearch: spec.webSearch === true,
|
|
183
|
+
hidden: holdsRunRoot ? [runRoot] : [],
|
|
184
|
+
gitdirs: [...gitdirs].map(([path, { own, linked }]): Gitdir => {
|
|
185
|
+
// A submodule's gitdir is in its superproject's `modules`, which is guarded whole: it is
|
|
186
|
+
// committed in under no provider, whether that superproject is in reach or not.
|
|
187
|
+
const module =
|
|
188
|
+
path.includes("/.git/modules/") ||
|
|
189
|
+
[...gitdirs.keys()].some((other) => contains(join(other, "modules"), path));
|
|
190
|
+
return {
|
|
191
|
+
path,
|
|
192
|
+
writable: (own || linked) && !module,
|
|
193
|
+
...(linked && !own && !module ? { linked: true as const } : {}),
|
|
194
|
+
};
|
|
195
|
+
}),
|
|
196
|
+
environment,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Whether `path` is writable in this reach: the most specific of `read` and `write` holding it
|
|
203
|
+
* decides. The working directory is readable, but never more specific than a `write` holding it.
|
|
204
|
+
*/
|
|
205
|
+
export function writableIn(
|
|
206
|
+
reach: { read: readonly string[]; write: readonly string[] },
|
|
207
|
+
path: string,
|
|
208
|
+
): boolean {
|
|
209
|
+
const deepest = [...reach.read, ...reach.write]
|
|
210
|
+
.filter((root) => contains(root, path))
|
|
211
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
212
|
+
return deepest !== undefined && reach.write.includes(deepest);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Whether `path` lies in one of the engine's run directories under its control root. */
|
|
216
|
+
function insideDoors(controlRoot: string, path: string): boolean {
|
|
217
|
+
if (path === controlRoot || !contains(controlRoot, path)) return false;
|
|
218
|
+
const [first = ""] = path.slice(controlRoot.length + 1).split("/");
|
|
219
|
+
return first.startsWith("awf-");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Whether `path` is `root` or lies under it. Both are absolute and resolved. */
|
|
223
|
+
export function contains(root: string, path: string): boolean {
|
|
224
|
+
return path === root || path.startsWith(root.endsWith("/") ? root : `${root}/`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Whether an agent at `path` sees it inside: under the sandbox's working directory or its reach. */
|
|
228
|
+
export function withinReach(sandbox: ResolvedSandbox<unknown>, path: string): boolean {
|
|
229
|
+
return [sandbox.cwd, ...sandbox.read, ...sandbox.write].some((root) => contains(root, path));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* `registry.npmjs.org`, or `*.npmjs.org` for its subdomains. The last label is not a number in any
|
|
234
|
+
* base a resolver reads (`10`, `0x7f000001`), so an address is not a name, and a wildcard needs a
|
|
235
|
+
* name under a top-level domain, not the domain itself. Anything with a scheme, port or path has
|
|
236
|
+
* characters no label has.
|
|
237
|
+
*/
|
|
238
|
+
export function isDomain(value: unknown): value is string {
|
|
239
|
+
if (typeof value !== "string" || value.length > 253) return false;
|
|
240
|
+
const wildcard = value.startsWith("*.");
|
|
241
|
+
const labels = (wildcard ? value.slice(2) : value).split(".");
|
|
242
|
+
return (
|
|
243
|
+
labels.every((label) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)) &&
|
|
244
|
+
!/^(\d+|0x[0-9a-f]*)$/i.test(labels.at(-1)!) &&
|
|
245
|
+
(!wildcard || labels.length >= 2)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Directories under `~` that hold keys, which no sandbox path may include or lie in, under any
|
|
251
|
+
* provider, nor a toolchain: OrbStack keeps its SSH key apart from its `bin/`.
|
|
252
|
+
*/
|
|
253
|
+
export const KEY_DIRECTORIES = [
|
|
254
|
+
".ssh",
|
|
255
|
+
".gnupg",
|
|
256
|
+
".aws",
|
|
257
|
+
".docker",
|
|
258
|
+
".kube",
|
|
259
|
+
".orbstack/ssh",
|
|
260
|
+
] as const;
|
|
261
|
+
|
|
262
|
+
/** Directories a writable path is scanned through for repositories, beyond which it is refused. */
|
|
263
|
+
const SCAN_LIMIT = 100_000;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The worktree roots below `root` that hold a `.git`, without entering a `.git`, `node_modules` or
|
|
267
|
+
* a link. Refuses a tree too large to scan, rather than leave a repository in it unguarded.
|
|
268
|
+
*/
|
|
269
|
+
async function nestedRepositories(root: string): Promise<string[]> {
|
|
270
|
+
const found: string[] = [];
|
|
271
|
+
const queue = [root];
|
|
272
|
+
for (let scanned = 0; queue.length > 0; scanned++) {
|
|
273
|
+
if (scanned === SCAN_LIMIT) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`sandbox write ${root} holds over ${SCAN_LIMIT} directories to check for repositories; name a narrower one`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const directory = queue.pop()!;
|
|
279
|
+
for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
|
|
280
|
+
if (entry.name === ".git") {
|
|
281
|
+
if (directory !== root) found.push(directory);
|
|
282
|
+
} else if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
283
|
+
queue.push(join(directory, entry.name));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return found;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
type Repository = { root: string; gitdirs: string[] };
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The repository a path belongs to: its worktree's root, and the git directories git reads there,
|
|
294
|
+
* which may lie outside it: a `.git` directory, or a worktree's own gitdir and its common one.
|
|
295
|
+
* Read from the files git reads, without running git. The walk up stops below `home`, so a
|
|
296
|
+
* dotfiles repository at `~` is never the one a path belongs to.
|
|
297
|
+
*/
|
|
298
|
+
export async function repositoryOf(path: string, home: string): Promise<Repository | undefined> {
|
|
299
|
+
for (let at = path; !contains(at, home); at = dirname(at)) {
|
|
300
|
+
const dotGit = join(at, ".git");
|
|
301
|
+
const found = await stat(dotGit).catch(() => undefined);
|
|
302
|
+
if (found?.isDirectory()) return { root: at, gitdirs: [await realpath(dotGit)] };
|
|
303
|
+
if (found?.isFile()) {
|
|
304
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(await readFile(dotGit, "utf8"))?.[1]?.trim();
|
|
305
|
+
if (!pointer) throw new Error(`${dotGit} names no gitdir`);
|
|
306
|
+
const gitdir = await realpath(resolve(at, pointer)).catch(() => {
|
|
307
|
+
throw new Error(`${dotGit} names ${pointer}, which is gone: git worktree prune`);
|
|
308
|
+
});
|
|
309
|
+
const common = await readFile(join(gitdir, "commondir"), "utf8").catch(() => undefined);
|
|
310
|
+
return {
|
|
311
|
+
root: at,
|
|
312
|
+
gitdirs:
|
|
313
|
+
common === undefined
|
|
314
|
+
? [gitdir]
|
|
315
|
+
: [gitdir, await realpath(resolve(gitdir, common.trim()))],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
if (dirname(at) === at) break;
|
|
319
|
+
}
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function resolvePath(
|
|
324
|
+
path: unknown,
|
|
325
|
+
base: string,
|
|
326
|
+
home: string,
|
|
327
|
+
field: string,
|
|
328
|
+
): Promise<string> {
|
|
329
|
+
if (typeof path !== "string" || path.trim() === "") {
|
|
330
|
+
throw new Error(`sandbox ${field} needs a non-empty path, not ${JSON.stringify(path)}`);
|
|
331
|
+
}
|
|
332
|
+
if (path.startsWith("~") && path !== "~" && !path.startsWith("~/")) {
|
|
333
|
+
throw new Error(`sandbox ${field} ${path}: only ~ and ~/ expand, to the operator's home`);
|
|
334
|
+
}
|
|
335
|
+
const expanded = path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path;
|
|
336
|
+
const absolute = isAbsolute(expanded) ? expanded : resolve(base, expanded);
|
|
337
|
+
try {
|
|
338
|
+
return await realpath(absolute);
|
|
339
|
+
} catch {
|
|
340
|
+
throw new Error(`sandbox ${field} ${path} does not exist`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function list(value: unknown, field: string): unknown[] {
|
|
345
|
+
if (value === undefined) return [];
|
|
346
|
+
if (!Array.isArray(value)) throw new Error(`sandbox ${field} must be a list`);
|
|
347
|
+
return value;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function unique<T>(values: readonly T[]): T[] {
|
|
351
|
+
return [...new Set(values)];
|
|
352
|
+
}
|