a hidden file or socket: empty, and nothing to connect to
* --ro-bind
read-only inside something writable (what of a repository decides its config,
* hooks and git dir; the project settings agent CLIs run code from)
- * --bind a pinned directory: a mount point, so it can't be renamed, still writable inside
+ * --bind a pinned directory, or one held in place: a mount point, so it can't be renamed,
+ * still writable inside
* --ro-bind a container runtime socket the owner let in (spec.dockerSockets; off by default):
* connecting needs no write to the mount, and it can't be removed or replaced
* --setenv the toolchains' caches in the sandbox's cache dir (spec.env)
@@ -29,6 +30,7 @@
import { lstatSync, realpathSync } from "node:fs";
import { join, resolve } from "node:path";
import { LocalSandboxHandle } from "./local-handle.js";
+import { boundPath } from "./pinned.js";
import type { NetworkMode, SandboxCapabilities, SandboxHandle, SandboxProbe, SandboxProvider, SandboxSpawnOptions, SandboxSpec } from "./types.js";
export const BWRAP_PROVIDER_ID = "bwrap";
@@ -36,6 +38,7 @@ export const BWRAP_PROVIDER_ID = "bwrap";
/** What is at a path: a directory, anything else that exists, or nothing. */
export type PathKind = "dir" | "other" | null;
+// paths: a look at what is there, no write: a pinned path the handle checked first, and bwrap binds as pinned
export function pathKind(p: string): PathKind {
try {
const st = lstatSync(p);
@@ -51,7 +54,8 @@ export function pathKind(p: string): PathKind {
}
/** The real path when it exists, else the path. */
-function realOr(p: string): string {
+// paths: only for what isn't pinned (boundPath): a hidden path (a link there hides more), the home's allowlist (shown where it leads, by design)
+function realOrPath(p: string): string {
try {
return realpathSync(p);
} catch {
@@ -90,15 +94,22 @@ export function bwrapArgs(spec: SandboxSpec, cwd: string, kind: (p: string) => P
const mounts: Mount[] = [];
const seen = new Set();
+ // Everything bound by name is pinned, and bound by the path it was pinned at, never by where a link there leads
+ // (pinned.ts: the handle checked it is still that, and the launched argv checks again; #448 round 14: never
+ // resolved again here). Only what isn't pinned is resolved: a hidden path (a link there hides more), the home's
+ // allowlist (shown where a link leads, by design).
+ const realOr = boundPath(spec.pins, realOrPath);
const writable = [spec.workspace, ...spec.writable].map(realOr);
+ const shownInTmp = [...writable, ...spec.reexposed.flatMap((r) => [resolve(r), realOr(r)]).filter((r) => r !== "/tmp")];
const hide = (p: string) => {
const at = realOr(p);
const k = kind(at);
if (!k || seen.has(`h:${at}`)) return;
// The sandbox's own /dev and /proc are new, and so is its /tmp: the
- // host's is reachable only where a writable location is bound into it.
+ // host's is reachable only where a writable location, or one shown
+ // again (a worktree location of a repository in /tmp), is bound into it.
if (at === "/" || within(at, "/proc") || within(at, "/dev")) return;
- if (within(at, "/tmp") && !writable.some((w) => within(at, w))) return;
+ if (within(at, "/tmp") && !shownInTmp.some((w) => within(at, w))) return;
seen.add(`h:${at}`);
mounts.push(k === "dir" ? { kind: "hide-dir", path: at } : { kind: "hide-file", path: at });
};
@@ -122,15 +133,21 @@ export function bwrapArgs(spec: SandboxSpec, cwd: string, kind: (p: string) => P
// Read-only inside something writable (a repository's hooks and config), with everything in it.
for (const p of spec.readOnly ?? []) {
const at = realOr(p);
- if (!kind(at) || seen.has(`o:${at}`)) continue;
+ // Never one that is hidden (a repository's git config holding a credential, in a dir the agent added), or in a
+ // hidden dir its writable root shows: its bind would show it again over the hiding.
+ const holder = writable.filter((w) => within(at, w)).sort((x, y) => depth(y) - depth(x))[0];
+ if (!kind(at) || seen.has(`o:${at}`) || seen.has(`h:${at}`) || (holder && hiddenDirs.some((d) => within(at, d) && within(d, holder) && d !== holder))) continue;
seen.add(`o:${at}`);
mounts.push({ kind: "ro-bind", src: at, path: at });
}
- // Every dir between a writable root and a hidden path in it: a mount point nothing can rename, still writable inside.
- for (const p of spec.pinned ?? []) {
+ // Every dir between a writable root and a hidden path in it, and every dir held in place (the working directory
+ // and its way up to the workspace, a held path's parents): a mount point nothing can rename, still writable inside.
+ for (const p of [...(spec.pinned ?? []), ...(spec.heldInPlace ?? [])]) {
const at = realOr(p);
- // Never one a hidden dir covers (or is): its bind would show the real dir again.
- if (kind(at) !== "dir" || seen.has(`o:${at}`) || hiddenDirs.some((d) => within(at, d))) continue;
+ // Never one a hidden dir in its writable root covers (or is): its bind would show the real dir again. One in
+ // a hidden dir the root is shown again in (the hidden home) is shown already.
+ const holder = writable.filter((w) => within(at, w)).sort((x, y) => depth(y) - depth(x))[0];
+ if (kind(at) !== "dir" || seen.has(`o:${at}`) || hiddenDirs.some((d) => within(at, d) && (!holder || (within(d, holder) && d !== holder)))) continue;
seen.add(`o:${at}`);
mounts.push({ kind: "bind", src: at, path: at });
}
@@ -223,6 +240,7 @@ export class BwrapSandboxProvider implements SandboxProvider {
// (an AppArmor restriction, a container) fail here.
const argv = [bin, "--die-with-parent", "--unshare-pid", "--unshare-net", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--", "/bin/sh", "-c", "exit 0"];
try {
+ // paths: the probe: a fixed argv, in no session's directory
const proc = Bun.spawn(argv, { stdin: "ignore", stdout: "ignore", stderr: "pipe", timeout: 10_000 });
const [code, err] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
if (code === 0) return { available: true, detail: bin };
diff --git a/src/daemon/sandbox/claude-native.ts b/src/daemon/sandbox/claude-native.ts
index 1d9903ba..7f5ab5d1 100644
--- a/src/daemon/sandbox/claude-native.ts
+++ b/src/daemon/sandbox/claude-native.ts
@@ -31,11 +31,12 @@
* its commands.
*/
-import { readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
+import { lstatSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import type { SandboxSettings } from "@anthropic-ai/claude-agent-sdk";
import { absolutePathEntries, pathWithShim } from "./git-shim.js";
+import { pinRefusal, pinsProblem, SandboxPinError } from "./pinned.js";
import type { SandboxRuntime } from "./session-sandbox.js";
import type { SandboxSpec } from "./types.js";
@@ -106,6 +107,7 @@ export function claudeNativeEnv(
];
const file = join(temp.cli, "command-env.sh");
const part = `${file}.${process.pid}.part`;
+ // paths: the CLI's own temp dir: the daemon's, private, which no command can write (claudeTempEnv)
writeFileSync(part, lines.join("\n"), { mode: 0o600 });
renameSync(part, file);
// PATH's absolute entries only (#448 round 5): a relative one (`.`, `node_modules/.bin`) finds a program the CLI starts on the host in the workspace.
@@ -130,6 +132,9 @@ export function claudeSandboxSettings(
platform: NodeJS.Platform = process.platform,
): SandboxSettings {
const spec = rt.spec;
+ // Every directory the CLI is told to bind by name, checked first (pinned.ts, #448 round 13): never one a link leads from.
+ const problem = pinsProblem(spec.pins);
+ if (problem) throw new SandboxPinError(pinRefusal(problem));
const tempDir = temp?.commands ?? spec.tempDir;
const network: NonNullable =
spec.network.mode === "off"
@@ -146,6 +151,7 @@ export function claudeSandboxSettings(
const allSockets = sockets.length > 0 && platform === "linux";
if (sockets.length > 0) Object.assign(network, allSockets ? { allowAllUnixSockets: true } : { allowUnixSockets: sockets });
const envVars = credentialEnvNames(env, spec.unsetEnv).map((name) => ({ name, mode: "deny" as const }));
+ const roots = new Set(spec.projectRoots ?? []);
return {
enabled: true,
failIfUnavailable: true,
@@ -160,14 +166,27 @@ export function claudeSandboxSettings(
// would run outside the sandbox from the CLI's next start. A
// repository's hooks and config, for the same reason. And every dir
// between the workspace and a hidden path in it (nothing renames it away).
- denyWrite: physical([...claudeSettingsPaths(spec.workspace, env), ...denyAround(spec.readOnly ?? [], spec.writable), ...(spec.pinned ?? [])]),
+ // A pinned dir holding something writable (an added dir's dir on the way to the workspace) isn't denied:
+ // the CLI's denial takes the subtree, which would make that read-only; the gate checks the pins instead.
+ // Nor a dir held in place (spec.heldInPlace: the working directory and its way up, a held path's parents,
+ // #448 round 14): the CLI binds every dir from a writable root down to what it binds in it, so each is a
+ // mount point already, no rename (checked on the real CLI: sandbox-claude-real.test.ts "held in place").
+ denyWrite: physical([...claudeSettingsPaths(spec.workspace, env), ...denyAround(spec.readOnly ?? [], spec.writable), ...(spec.pinned ?? []).filter((p) => !holdsWritable(p, [spec.workspace, ...spec.writable]))]),
// Local services' sockets too while the network is off (spec.serviceSockets): those in the host's /tmp are
// behind its seccomp's unix socket block, or the denied /tmp with every socket allowed.
denyRead: physical([...spec.hidden, ...spec.containerSockets, ...(spec.serviceSockets ?? []).filter((p) => !HOST_SHARED_TEMP.some((t) => p === t || p.startsWith(`${t}/`)) && !p.startsWith("/private/tmp/")), ...(allSockets ? HOST_SHARED_TEMP : [])]),
// Inside the denied home: the allowlist, and what is written (a workspace in it). Not a worktree location
// holding the session's folder: the CLI lays what is readable over what is writable, which would make the
// folder read-only (#448 round 10); the location stays the denied home's scratch dir there.
- allowRead: physical([spec.workspace, tempDir, ...spec.writable, ...spec.reexposed.filter((r) => !holdsFolder(r, spec.worktreeFolders ?? [])), ...sockets]),
+ // The project roots the same way (they hold the workspace): each split around what is writable in it (readAround).
+ allowRead: physical([
+ spec.workspace,
+ tempDir,
+ ...spec.writable,
+ ...spec.reexposed.filter((r) => !roots.has(r) && !holdsFolder(r, spec.worktreeFolders ?? [])),
+ ...readAround([...roots], [spec.workspace, tempDir, ...spec.writable], undefined, [...spec.hidden, ...spec.containerSockets, ...(spec.serviceSockets ?? [])]),
+ ...sockets,
+ ]),
},
network,
...(envVars.length > 0 ? { credentials: { envVars } } : {}),
@@ -175,12 +194,81 @@ export function claudeSandboxSettings(
};
}
+/** Whether `dir` holds one of `writable` (strictly). */
+function holdsWritable(dir: string, writable: readonly string[]): boolean {
+ const d = physicalOf(dir);
+ return writable.some((w) => physicalOf(w).startsWith(`${d}/`));
+}
+
/** Whether `dir` holds one of the session's worktree folders (is a location of it). */
function holdsFolder(dir: string, folders: readonly string[]): boolean {
const d = resolve(dir);
return folders.some((f) => resolve(f) !== d && resolve(f).startsWith(`${d}/`));
}
+/**
+ * `readable` as the CLI's `allowRead` can show it without hiding what is
+ * writable: it lays every `allowRead` bind after every `allowWrite` one, so
+ * a readable dir holding a writable one (a project root holding the
+ * workspace) would make that one read-only. Such a dir is shown entry by
+ * entry instead, all but the way down to the writable ones (which
+ * `allowWrite` shows). An entry made in it after the query started isn't
+ * shown (docs/sandbox.md, Limits). Bounded.
+ *
+ * Never an entry that is a link (it would be shown by where it leads: the
+ * CLI binds physical paths; left out, it isn't there), nor one that is, or
+ * is in, a `hidden` path: an `allowRead` equal to a `denyRead` path wins in
+ * the CLI. A hidden path strictly inside a shown entry stays hidden: the CLI
+ * lays `denyRead` paths inside an `allowRead` one after it (checked on the
+ * real CLI, #448 round 12).
+ */
+export function readAround(readable: readonly string[], writable: readonly string[], limit = 4000, hidden: readonly string[] = []): string[] {
+ const out: string[] = [];
+ const inside = (child: string, parent: string) => child !== parent && child.startsWith(`${parent}/`);
+ const writes = writable.flatMap((w) => [resolve(w), physicalOf(w)]);
+ const hides = [...new Set(hidden.flatMap((h) => [resolve(h), physicalOf(h)]))];
+ // Only what is hidden in (or is) the readable dir: one holding it (the denied home) is what it is shown in.
+ let inBase: string[] = [];
+ const isHidden = (p: string) => [resolve(p), physicalOf(p)].some((at) => inBase.some((h) => at === h || inside(at, h)));
+ // paths: a look (is it a link: then left out), no write
+ const isLink = (p: string) => {
+ try {
+ return lstatSync(p).isSymbolicLink();
+ } catch {
+ return true;
+ }
+ };
+ let budget = limit;
+ const split = (dir: string, keep: string[]): void => {
+ let names: string[] = [];
+ // paths: a look (a root's entries, to show around what is writable), no write; links left out
+ try {
+ names = readdirSync(dir);
+ } catch {
+ return;
+ }
+ for (const name of names) {
+ if (--budget < 0) return;
+ const at = join(dir, name);
+ if (isLink(at) || isHidden(at)) continue;
+ const held = keep.filter((w) => w === at || inside(w, at));
+ if (held.length === 0) out.push(at);
+ else if (!held.includes(at)) split(at, held);
+ }
+ };
+ for (const r of readable) {
+ for (const base of new Set([resolve(r), physicalOf(r)])) {
+ inBase = hides.filter((h) => h === base || inside(h, base));
+ if (isHidden(base)) continue;
+ const keep = writes.filter((w) => w === base || inside(w, base));
+ if (keep.includes(base)) continue;
+ if (keep.length === 0) out.push(base);
+ else split(base, keep);
+ }
+ }
+ return [...new Set(out)];
+}
+
/**
* `readOnly` as the CLI's `denyWrite` can hold it. Its denials win over
* `allowWrite` wherever they overlap, so a read-only dir holding a writable
@@ -195,6 +283,7 @@ export function denyAround(readOnly: readonly string[], writable: readonly strin
const inside = (child: string, parent: string) => child !== parent && child.startsWith(`${parent}/`);
const split = (dir: string, keep: string[]): void => {
let names: string[] = [];
+ // paths: a look (a held dir's entries, to deny around what is writable), no write
try {
names = readdirSync(dir);
} catch {
@@ -223,6 +312,7 @@ export function physicalOf(p: string): string {
const missing: string[] = [];
for (let at = resolve(p); ; at = dirname(at)) {
try {
+ // paths: physical paths for the CLI, after claudeSandboxSettings checked every pin (no link in a pinned path: this changes none); what isn't pinned (a hidden path, a socket) is named where it is
return join(realpathSync(at), ...missing.reverse());
} catch {
if (dirname(at) === at) return resolve(p);
diff --git a/src/daemon/sandbox/dir-rules.ts b/src/daemon/sandbox/dir-rules.ts
new file mode 100644
index 00000000..46a39fca
--- /dev/null
+++ b/src/daemon/sandbox/dir-rules.ts
@@ -0,0 +1,95 @@
+/**
+ * The rules a project root or an added directory must meet (project-roots.ts),
+ * apart so config.ts can check them at load without the sandbox's policy.
+ */
+
+import { realpathSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { isAbsolute, join, resolve } from "node:path";
+import { worktreeRootProblem } from "./worktrees.js";
+
+/** A project root entry meaning "the parent of the session's repository" (the default). */
+export const PROJECT_ROOT_PARENT = "@parent";
+
+function expandHome(p: string, home: string): string {
+ if (p === "~") return home;
+ if (p.startsWith("~/")) return join(home, p.slice(2));
+ return p;
+}
+
+function within(child: string, parent: string): boolean {
+ return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`);
+}
+
+// paths: a refusal rule, checked by the path as given and where it leads; a directory it lets through is used by its real path, pinned (project-roots.ts, pinned.ts)
+function realOr(p: string): string {
+ try {
+ return realpathSync(p);
+ } catch {
+ return p;
+ }
+}
+
+export interface DirContext {
+ home?: string;
+ /** codeoid's config dir: no root is it, in it or holds it. */
+ configDir?: string;
+ platform?: NodeJS.Platform;
+ /** The host's shared temp dirs (default {@link hostTempRoots}): no project root is in one, no added dir is one. */
+ tempRoots?: readonly string[];
+}
+
+/**
+ * The host's shared temp dirs: `/tmp`, `/var/tmp`, `/dev/shm`, the
+ * daemon's `TMPDIR`, macOS's (`/private/tmp`, `/var/folders`), each also
+ * where it really is. Every program of the user's (and codeoid's private
+ * dir) keeps files there: never a project root, nor one inside them, nor an
+ * added directory itself.
+ */
+export function hostTempRoots(): string[] {
+ const roots = ["/tmp", "/var/tmp", "/dev/shm", "/private/tmp", "/private/var/tmp", "/var/folders", "/private/var/folders", tmpdir()];
+ return [...new Set(roots.flatMap((r) => [resolve(r), realOr(resolve(r))]))];
+}
+
+/**
+ * Why `dir` can't be a project root or an added directory, or null: it must
+ * be absolute (`~` expands), and never `/`, the home or a dir holding it, a
+ * hidden dir of the home (`~/.config`, `~/.local`, …: what tools load
+ * programs and settings from), `~/Library`, codeoid's config dir, or a
+ * system dir (worktrees.ts `worktreeRootProblem`, as written and where it
+ * really is), nor one of the host's temp dirs itself ({@link hostTempRoots}).
+ */
+export function sandboxDirProblem(dir: string, ctx: DirContext = {}): string | null {
+ const home = resolve(ctx.home ?? process.env.HOME ?? homedir());
+ if (!dir.trim()) return "an empty path";
+ const p = expandHome(dir, home);
+ if (!isAbsolute(p)) return `${dir} isn't an absolute path`;
+ const problem = worktreeRootProblem(p, { home, ...(ctx.configDir ? { configDir: ctx.configDir } : {}), ...(ctx.platform ? { platform: ctx.platform } : {}) });
+ if (problem) return problem;
+ const temps = ctx.tempRoots ?? hostTempRoots();
+ const at = [resolve(p), realOr(resolve(p))];
+ if (temps.some((t) => at.includes(t))) return `${dir} is the host's temp dir, shared by every program of yours (and codeoid's private dir)`;
+ return null;
+}
+
+/**
+ * Why `dir` can't be a project root, or null: {@link sandboxDirProblem},
+ * and never inside one of the host's temp dirs (what codeoid's private dir,
+ * other programs' sockets and sessions' scratch dirs sit beside; under
+ * bubblewrap the sandbox's `/tmp` is its own).
+ */
+export function projectRootProblem(dir: string, ctx: DirContext = {}): string | null {
+ const basic = sandboxDirProblem(dir, ctx);
+ if (basic) return basic;
+ const home = resolve(ctx.home ?? process.env.HOME ?? homedir());
+ const p = resolve(expandHome(dir, home));
+ const temps = ctx.tempRoots ?? hostTempRoots();
+ const temp = temps.find((t) => within(p, t) || within(realOr(p), t));
+ return temp ? `${dir} is in the host's temp dir (${temp}), shared by every program of yours (and codeoid's private dir)` : null;
+}
+
+/** `~` expanded and resolved: how an added directory or a root is kept. Null when it isn't absolute. */
+export function normalizeSandboxDir(dir: string, home: string = process.env.HOME ?? homedir()): string | null {
+ const p = expandHome(dir.trim(), resolve(home));
+ return isAbsolute(p) ? resolve(p) : null;
+}
diff --git a/src/daemon/sandbox/docker.ts b/src/daemon/sandbox/docker.ts
index 0e6d7c15..0a12b4b2 100644
--- a/src/daemon/sandbox/docker.ts
+++ b/src/daemon/sandbox/docker.ts
@@ -37,6 +37,7 @@ import { lstatSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { resolve as resolvePath } from "node:path";
import { LocalSandboxHandle } from "./local-handle.js";
+import { boundPath } from "./pinned.js";
import type { NetworkMode, SandboxCapabilities, SandboxHandle, SandboxProbe, SandboxProvider, SandboxSpawnOptions, SandboxSpec } from "./types.js";
export const DOCKER_PROVIDER_ID = "docker";
@@ -54,7 +55,8 @@ function within(child: string, parent: string): boolean {
return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`);
}
-function realOr(p: string): string {
+// paths: only for what isn't pinned (boundPath): a hidden path (a link there hides more), the home's allowlist, the owner's socket
+function realOrPath(p: string): string {
try {
return realpathSync(p);
} catch {
@@ -65,9 +67,10 @@ function realOr(p: string): string {
/** What exists at a path: a directory, something else, nothing. */
export type DockerPathKind = "dir" | "other" | null;
+// paths: a look at what is there, no write: a pinned path the handle checked first
function pathKind(p: string): DockerPathKind {
try {
- const st = lstatSync(realOr(p));
+ const st = lstatSync(realOrPath(p));
return st.isDirectory() ? "dir" : "other";
} catch {
return null;
@@ -98,6 +101,7 @@ export interface DockerRunOptions {
/** Where a container runtime socket the owner let in is mounted inside the container (`DOCKER_HOST` names it). */
export const CONTAINER_DOCKER_SOCKET = "/var/run/docker.sock";
+// paths: the owner's container socket (opted in), outside every sandbox's writes: a look at its group
function socketGroup(p: string): number | null {
try {
return statSync(p).gid;
@@ -109,6 +113,9 @@ function socketGroup(p: string): number | null {
/** `docker run`'s arguments for `argv` under `spec`. Pure but for `kind` (tests replace it). */
export function dockerRunArgs(spec: SandboxSpec, argv: readonly string[], o: DockerRunOptions): string[] {
const kind = o.kind ?? pathKind;
+ // Everything bound by name is pinned: mounted by the path it was pinned at (the handle checked it is still that,
+ // and the launched argv checks again; #448 round 14), never resolved again here. Only what isn't is resolved.
+ const realOr = boundPath(spec.pins, realOrPath);
const args = [
"run",
"--rm",
@@ -159,10 +166,14 @@ export function dockerRunArgs(spec: SandboxSpec, argv: readonly string[], o: Doc
}
// Every dir between a mounted writable root and a hidden path in it: a mount point (no rename), still writable (Docker mounts parents first).
const hiddenAt = spec.hidden.map(realOr);
- for (const p of spec.pinned ?? []) {
+ const writableAt = [workspace, ...spec.writable.map(realOr)];
+ for (const p of [...(spec.pinned ?? []), ...(spec.heldInPlace ?? [])]) {
const at = realOr(p);
- // Never one a hidden path covers (or is): its mount would show the real dir again.
- if (kind(at) === "dir" && inMounted(at) && !hiddenAt.some((h) => within(at, h))) bind(at, at, false);
+ // Never one a hidden path in its writable root covers (or is): its mount would show the real dir again. One in a
+ // hidden dir the root is mounted from (the hidden home holding the workspace) is the root's: mounted (#448
+ // round 14, as bubblewrap's since round 13).
+ const holder = writableAt.filter((w) => within(at, w)).sort((x, y) => y.length - x.length)[0];
+ if (kind(at) === "dir" && inMounted(at) && !hiddenAt.some((h) => within(at, h) && (!holder || (within(h, holder) && h !== holder)))) bind(at, at, false);
}
// A hidden path inside something mounted is covered again.
for (const h of [...spec.hidden, ...spec.containerSockets, ...(spec.serviceSockets ?? [])]) {
@@ -237,6 +248,7 @@ export class DockerSandboxProvider implements SandboxProvider {
const docker = this.#client();
if (!docker) return { available: false, reason: "the docker client isn't installed" };
try {
+ // paths: the probe: a fixed argv, in no session's directory
const proc = Bun.spawn([docker, "version", "--format", "{{.Server.Version}}"], { stdin: "ignore", stdout: "pipe", stderr: "pipe", timeout: 10_000 });
const [code, out, err] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
if (code === 0 && out.trim()) return { available: true, detail: `Docker ${out.trim()}` };
@@ -272,6 +284,7 @@ export class DockerSandboxProvider implements SandboxProvider {
// A launcher (no cwd: it runs from wherever it is started, maybe many at once) names none.
...(opts.cwd ? { name: `codeoid-${safe}-${randomBytes(4).toString("hex")}` } : {}),
handle: handleId,
+ // paths: the container's own working directory (-w), inside it
cwd: opts.cwd,
envNames: Object.keys(opts.env),
uid,
@@ -282,6 +295,7 @@ export class DockerSandboxProvider implements SandboxProvider {
env: { ...opts.env, ...clientEnv },
});
// Containers outlive a killed client: remove every one this handle started.
+ // paths: docker ps / rm by this handle's label: no path
const onClose = () =>
new Promise((resolve) => {
execFile(docker, ["ps", "-aq", "--filter", `label=codeoid.handle=${handleId}`], { env: { ...clientEnv } }, (err, out) => {
diff --git a/src/daemon/sandbox/git-shim.ts b/src/daemon/sandbox/git-shim.ts
index 3de051eb..19bcc090 100644
--- a/src/daemon/sandbox/git-shim.ts
+++ b/src/daemon/sandbox/git-shim.ts
@@ -42,6 +42,7 @@ import { chmodSync, lstatSync, mkdirSync, renameSync, writeFileSync } from "node
import { dirname, isAbsolute, join, resolve } from "node:path";
import { BASE_OVERRIDES, GIT_BIN, PROGRAM_ENV, REMOTE_PROTOCOLS } from "../git-env.js";
import { GIT_CONTROL, gitLayoutOf, programLocations } from "./policy.js";
+import { makeAt, pinOf } from "./pinned.js";
import type { SandboxSpec } from "./types.js";
/** The empty tree's id: `attr.tree` naming it reads no attributes from the working tree (git ≥ 2.46; an older git ignores the key). */
@@ -62,6 +63,7 @@ const ENV_BIN = "/usr/bin/env";
* can't be read apart). `PATH` (its absolute entries), `HOME` and the
* caller's `-c` list (`GIT_CONFIG_COUNT`) are handed over apart.
*/
+// paths: names only (the shim's environment allowlist), no path
export const SHIM_ENV_NAMES: readonly string[] = [
"USER", "LOGNAME", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "LC_COLLATE", "LC_MESSAGES", "LC_MONETARY",
"LC_NUMERIC", "LC_TIME", "LC_ADDRESS", "LC_IDENTIFICATION", "LC_MEASUREMENT", "LC_NAME", "LC_PAPER",
@@ -120,6 +122,7 @@ export interface GitShimWorktrees {
}
/** A directory itself, not a link to one. */
+// paths: a look (a directory, not a link), no write
function realDir(p: string): boolean {
try {
const st = lstatSync(p);
@@ -129,6 +132,7 @@ function realDir(p: string): boolean {
}
}
+// paths: a look (is it a link), no write
function isLink(p: string): boolean {
try {
return lstatSync(p).isSymbolicLink();
@@ -157,7 +161,8 @@ export function cliWorktrees(workspace: string, home?: string): GitShimWorktrees
if (dirname(resolve(layout.dotgit)) === ws && realDir(claude)) {
const checkouts = join(claude, "worktrees");
try {
- mkdirSync(checkouts, { mode: 0o755 });
+ // Never through a link (pinned.ts: `.claude` pinned for the make, checked again after; #448 round 14).
+ makeAt(checkouts, "dir", "making the CLI's worktree dir");
} catch {
/* there already (or can't be: then not opened) */
}
@@ -197,6 +202,8 @@ export function gitShimSpec(spec: SandboxSpec, opts: { tempParent: string; workt
privateTemp: true,
writable: [...spec.writable, ...open],
readOnly: (spec.readOnly ?? []).filter(held),
+ // Bound by name as the rest (pinned.ts, #448 round 14): never followed to where a link put there leads.
+ pins: [...(spec.pins ?? []), ...open.map((p) => pinOf(p, "a git worktree dir"))],
network: { mode: "off" },
};
}
@@ -212,6 +219,7 @@ export function gitProgramLocations(): string[] {
if (locations) return locations;
const out = programLocations(GIT_BIN, []);
try {
+ // paths: git's own program, asked where its helpers are: no session's directory
const r = Bun.spawnSync([GIT_BIN, "--exec-path"], { stdin: "ignore", stdout: "pipe", stderr: "ignore", env: { PATH: process.env.PATH ?? "/usr/bin:/bin" } });
const dir = r.exitCode === 0 ? r.stdout.toString().trim() : "";
if (dir.startsWith("/")) out.push(...programLocations(join(dir, "git"), []).filter((d) => d === dir));
@@ -391,6 +399,7 @@ export function gitShimScript(opts: GitShimOptions): string {
}
/** Write {@link gitShimScript} as `/git` (0700, atomically: a git started meanwhile gets the old one or the new one) and return `dir`. */
+// paths: the session's launcher dir: the daemon's private dir, which no sandbox sees
export function writeGitShim(dir: string, opts: GitShimOptions): string {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const path = join(dir, "git");
diff --git a/src/daemon/sandbox/git-watch.ts b/src/daemon/sandbox/git-watch.ts
index d4225641..a8c9b790 100644
--- a/src/daemon/sandbox/git-watch.ts
+++ b/src/daemon/sandbox/git-watch.ts
@@ -40,6 +40,7 @@ const DIR_LIMIT = 200;
/** Label → fingerprint of each watched file (absent: not in the map). */
export type GitControlSnapshot = ReadonlyMap;
+// paths: a look (a file's fingerprint), no write
function fingerprint(p: string): string | null {
try {
const st = lstatSync(p);
@@ -52,6 +53,7 @@ function fingerprint(p: string): string | null {
}
}
+// paths: a look, no write
function readlinkOr(p: string): string {
try {
return readlinkSync(p);
@@ -61,6 +63,7 @@ function readlinkOr(p: string): string {
}
/** codeoid's own placeholders read as absent: an empty `config.worktree`, a `commondir` of `.`. */
+// paths: a look, no write
function placeholder(p: string, name: string): boolean {
try {
const text = readFileSync(p, "utf8");
@@ -75,6 +78,7 @@ function placeholder(p: string, name: string): boolean {
* anywhere isn't, #448 round 11), `commondir` naming its repository
* (`common`, as `../..` from there), no `config.worktree` but codeoid's empty one.
*/
+// paths: a look, no write
function plainWorktree(dir: string, common: string): boolean {
try {
if (!lstatSync(dir).isDirectory() || lstatSync(join(dir, "commondir")).isSymbolicLink()) return false;
@@ -90,6 +94,7 @@ function plainWorktree(dir: string, common: string): boolean {
}
}
+// paths: a look, no write
function entries(dir: string): string[] {
try {
return readdirSync(dir).sort().slice(0, DIR_LIMIT);
@@ -99,8 +104,9 @@ function entries(dir: string): string[] {
}
/** `core.hooksPath` as the repository's own config files set it (the last one wins), or null. */
-function hooksPathOf(configs: readonly string[]): string | null {
+export function hooksPathOf(configs: readonly string[]): string | null {
let found: string | null = null;
+ // paths: a look (a config's text), no write
for (const file of configs) {
let text = "";
try {
@@ -150,6 +156,7 @@ function strayGitEntries(folders: readonly string[], common: string): string[] {
if (folders.length === 0) return [];
const commonReal = realOr(common);
const real = folders.map(realOr);
+ // paths: a look, no write
const check = (dotgit: string): boolean => {
let st: ReturnType;
try {
@@ -161,6 +168,7 @@ function strayGitEntries(folders: readonly string[], common: string): string[] {
return true;
};
// The worktrees git knows of there: each one's `.git`, as its admin dir's `gitdir` names it.
+ // paths: a look at git's records of worktrees, no write
for (const w of entries(join(common, "worktrees"))) {
try {
const dotgit = resolve(join(common, "worktrees", w), readFileSync(join(common, "worktrees", w, "gitdir"), "utf8").trim());
@@ -170,6 +178,7 @@ function strayGitEntries(folders: readonly string[], common: string): string[] {
/* no gitdir */
}
}
+ // paths: a look through the session's worktree folders, no write
let budget = FOLDER_DIR_LIMIT;
let level = [...real];
for (let depth = 0; depth <= 3 && level.length > 0; depth++) {
@@ -191,6 +200,7 @@ function strayGitEntries(folders: readonly string[], common: string): string[] {
return [...out];
}
+// paths: for comparing paths in a look, no write
function realOr(p: string): string {
try {
return realpathSync(p);
@@ -200,6 +210,7 @@ function realOr(p: string): string {
}
/** A worktree's `.git` file as git makes it: naming `/worktrees/`, whose `gitdir` names this file back. */
+// paths: a look, no write
function namesOwnAdmin(dotgit: string, commonReal: string): boolean {
try {
const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotgit, "utf8"));
@@ -247,6 +258,7 @@ export function gitControlSnapshot(workspace: string, home: string = process.env
// Every worktree's admin dir, as a group ({@link gitControlChanges}): what names its repository and its checkout,
// and its own config. One that appears as `git worktree add` makes one (its repository's `commondir`, no config
// of its own) isn't news, nor one that goes; one changed meanwhile is, or one that appears naming anything else.
+ // paths: a look at git's records of worktrees, no write
const own = (opts.ownFolders ?? []).map(realOr);
for (const w of entries(join(common, "worktrees"))) {
const dir = join(common, "worktrees", w);
@@ -262,6 +274,7 @@ export function gitControlSnapshot(workspace: string, home: string = process.env
for (const n of mine ? ["commondir", "config.worktree"] : ["commondir", "gitdir", "config.worktree"]) watch(join(dir, n));
}
// A checkout in the session's own worktree folders whose `.git` git would follow elsewhere (#448 round 10).
+ // paths: a look, no write
for (const g of strayGitEntries(opts.ownFolders ?? [], common)) {
let f: string | null = null;
try {
diff --git a/src/daemon/sandbox/host-file-tools.ts b/src/daemon/sandbox/host-file-tools.ts
index 9abc59ab..4e3a60cd 100644
--- a/src/daemon/sandbox/host-file-tools.ts
+++ b/src/daemon/sandbox/host-file-tools.ts
@@ -107,6 +107,7 @@ function bothForms(paths: readonly string[]): string[] {
return [...out];
}
+// paths: a look (does a hidden path exist), no write
function lexists(p: string): boolean {
try {
lstatSync(p);
diff --git a/src/daemon/sandbox/local-handle.ts b/src/daemon/sandbox/local-handle.ts
index 883ca801..09f667ee 100644
--- a/src/daemon/sandbox/local-handle.ts
+++ b/src/daemon/sandbox/local-handle.ts
@@ -2,10 +2,14 @@
* The {@link SandboxHandle} of a local provider that confines a program by
* wrapping its argv (bwrap, sandbox-exec, `docker run`): spawn and exec are
* the wrapped argv run as a child of the daemon, and close ends every child it
- * started that is still running.
+ * started that is still running. Every wrap first checks the spec's pinned
+ * directories (pinned.ts) and refuses when one isn't what it was, and the argv
+ * it hands back checks them again right before the program starts, so a
+ * launcher written from it once checks at each run (#448 round 13).
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
+import { pinGuardArgv, pinRefusal, pinsProblem, SandboxPinError } from "./pinned.js";
import type { SandboxExecOptions, SandboxExecResult, SandboxHandle, SandboxSpawnOptions, SandboxSpec } from "./types.js";
export type ArgvWrapper = (argv: readonly string[], opts: SandboxSpawnOptions) => { argv: string[]; env: Record };
@@ -30,13 +34,17 @@ export class LocalSandboxHandle implements SandboxHandle {
wrap(argv: readonly string[], opts: SandboxSpawnOptions): { argv: string[]; env: Record } {
if (this.#closed) throw new Error(`the ${this.providerId} sandbox for session ${this.spec.sessionId} is closed`);
- return this.#wrap(argv, opts);
+ const problem = pinsProblem(this.spec.pins);
+ if (problem) throw new SandboxPinError(pinRefusal(problem));
+ const wrapped = this.#wrap(argv, opts);
+ return { ...wrapped, argv: pinGuardArgv(this.spec.pins, wrapped.argv) };
}
spawn(command: string, args: readonly string[], opts: SandboxSpawnOptions): ChildProcessWithoutNullStreams {
const wrapped = this.wrap([command, ...args], opts);
const [bin, ...rest] = wrapped.argv;
if (!bin) throw new Error("empty sandbox argv");
+ // paths: the wrapped argv, its pins just checked by wrap (and again by the guard in it); its cwd is inside the sandbox it starts
const child = spawn(bin, rest, { cwd: opts.cwd, env: wrapped.env, stdio: ["pipe", "pipe", "pipe"] });
this.#children.add(child);
child.once("exit", () => this.#children.delete(child));
diff --git a/src/daemon/sandbox/pinned.ts b/src/daemon/sandbox/pinned.ts
new file mode 100644
index 00000000..3d6ad90e
--- /dev/null
+++ b/src/daemon/sandbox/pinned.ts
@@ -0,0 +1,514 @@
+/**
+ * The one way codeoid uses a path of a session's (#448 rounds 13 and 14,
+ * docs/sandbox.md "Pinned paths").
+ *
+ * A session's paths are where an agent's commands can write: the workspace
+ * (a monorepo's repository top), the working directory, every added
+ * directory, every project root (another session's workspace), the
+ * session's worktree folders, its repository's git dirs, the paths held
+ * read-only inside them, the directories held in place. Any component of
+ * one can be moved aside and replaced by a link between two uses of it, by
+ * this session's commands or another's. Resolving it again then (a
+ * `realpath`, a bind by name, a `mkdir`, a git run with it as its work
+ * tree) follows the link: the home bound writable, the daemon's private
+ * dir shown, a rewind writing into the home.
+ *
+ * So each is pinned once, with what it is: its real path and, for a
+ * directory, its identity (`dev:ino`, {@link pinRecord}), taken when the
+ * session is created, the directory granted, the root first resolved, the
+ * spec built. Every use checks first that the path is still that, reached
+ * through no symbolic link ({@link pinProblem}), and one that isn't is never
+ * followed: the use is refused with what changed, and the session says it.
+ *
+ * - a sandbox: every spec build, every launch of a program inside
+ * (`LocalSandboxHandle.wrap`, and again in the launched argv itself:
+ * {@link pinGuardArgv}, so a launcher script written once checks at each
+ * run), Claude's own sandbox settings, every tool call at the gate;
+ * - the daemon's own work in a session's directory ({@link PinnedDir}):
+ * checkpoints, going back a turn, a fork's files, the placeholders it
+ * makes, the worktree folders it makes and removes. Checked before and
+ * again after ({@link inPinnedDir}): git and the file system take paths,
+ * so what is checked and what is used can't be one open directory; a
+ * directory is created without following a link, a file opened with
+ * `O_NOFOLLOW | O_EXCL` ({@link makeInPinned}).
+ *
+ * Nothing else in the sandbox, session or checkpoint modules resolves such a
+ * path itself (src/tests/pinned-paths-static.test.ts).
+ */
+
+import { closeSync, constants, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeSync } from "node:fs";
+import { dirname, isAbsolute, join, resolve } from "node:path";
+
+/** One path of a spec bound by name (SandboxSpec.pins). */
+export interface PathPin {
+ /** Its real path: what is bound. */
+ path: string;
+ /** What it is, for what is said: "the workspace", "added directory", "project root", … */
+ what: string;
+ /** `dev:ino` it had when the spec was built; absent: only that it is reached through no link (one codeoid makes and removes, or a file). */
+ id?: string;
+ /** It may be missing (then nothing is bound there): a worktree folder, a held path not made. */
+ mayBeMissing?: boolean;
+ /** Not a directory (a held file: a repository's `config`, a project's `.mcp.json`): no link on its way nor itself; which file isn't compared (an ordinary write replaces one). */
+ file?: boolean;
+}
+
+/** Raised when a pinned path isn't what it was: the build, the launch or the daemon's own use is refused. */
+export class SandboxPinError extends Error {
+ override name = "SandboxPinError";
+}
+
+let bootId: string | null = null;
+/** This boot's id (Linux), else "": device numbers are reassigned at boot, so across one only the inode is compared. */
+export function currentBoot(): string {
+ if (bootId === null) {
+ try {
+ bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
+ } catch {
+ bootId = "";
+ }
+ }
+ return bootId;
+}
+
+/** The first component of `p` (absolute) that is a symbolic link, or null (none, or a missing one first). */
+function linkIn(p: string): string | null {
+ let at = "";
+ for (const part of resolve(p).split("/").filter(Boolean)) {
+ at += `/${part}`;
+ try {
+ if (lstatSync(at).isSymbolicLink()) return at;
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+
+/** `dev:ino` of the directory at `p`, reached through no link and at its real path, or null. */
+export function dirIdentity(p: string): string | null {
+ const at = resolve(p);
+ if (linkIn(at) !== null) return null;
+ try {
+ const st = lstatSync(at);
+ if (!st.isDirectory() || realpathSync(at) !== at) return null;
+ return `${st.dev}:${st.ino}`;
+ } catch {
+ return null;
+ }
+}
+
+/** What is kept for `p` when it is pinned: `dev:ino@boot`, or null when it isn't a directory at its real path. */
+export function pinRecord(p: string): string | null {
+ const id = dirIdentity(p);
+ return id === null ? null : `${id}@${currentBoot()}`;
+}
+
+/** Whether `current` (`dev:ino`, now) is the directory `stored` (`dev:ino@boot`) names: the same inode, and device within one boot. */
+export function sameDir(stored: string, current: string): boolean {
+ const [id = "", boot = ""] = stored.split("@");
+ const [dev, ino] = id.split(":");
+ const [nowDev, nowIno] = current.split(":");
+ if (!ino || ino !== nowIno) return false;
+ const now = currentBoot();
+ return !boot || !now || boot !== now || dev === nowDev;
+}
+
+/** Why `p` isn't what was pinned there, without naming it ("is gone", …), or null. */
+function changeOf(p: string, opts: { stored?: string; id?: string; mayBeMissing?: boolean; file?: boolean }): string | null {
+ const at = resolve(p);
+ const link = linkIn(at);
+ if (link) return `is reached through a symbolic link now (${link})`;
+ let st: ReturnType;
+ try {
+ st = lstatSync(at);
+ } catch {
+ return opts.mayBeMissing ? null : "is gone";
+ }
+ if (opts.file) {
+ // A held file: only that it is where it was, at its real path (its directory's checked above, component by component).
+ try {
+ return realpathSync(dirname(at)) === dirname(at) ? null : "isn't at its real path now";
+ } catch {
+ return "can't be resolved now";
+ }
+ }
+ if (!st.isDirectory()) return "isn't a directory now";
+ try {
+ if (realpathSync(at) !== at) return "isn't at its real path now";
+ } catch {
+ return "can't be resolved now";
+ }
+ const now = `${st.dev}:${st.ino}`;
+ if (opts.stored && !sameDir(opts.stored, now)) return "isn't the directory it was (another one is there now)";
+ if (opts.id && opts.id !== now) return "isn't the directory it was (another one is there now)";
+ return null;
+}
+
+/**
+ * Why `p` (`what`, for the words) isn't the directory pinned there, or null:
+ * a symbolic link in its path, gone (unless `mayBeMissing`), not a
+ * directory, or another directory than `stored` (`dev:ino@boot`) or `id`
+ * (`dev:ino`, this boot) names. `file`: a held file, only checked for links.
+ */
+export function pinProblem(p: string, what: string, opts: { stored?: string; id?: string; mayBeMissing?: boolean; file?: boolean } = {}): string | null {
+ const change = changeOf(p, opts);
+ return change === null ? null : `${what} ${resolve(p)} ${change}`;
+}
+
+/** The first of `pins` that isn't what it was, in words, or null. */
+export function pinsProblem(pins: readonly PathPin[] | undefined): string | null {
+ for (const pin of pins ?? []) {
+ const problem = pinProblem(pin.path, pin.what, {
+ ...(pin.id ? { id: pin.id } : {}),
+ ...(pin.mayBeMissing ? { mayBeMissing: true } : {}),
+ ...(pin.file ? { file: true } : {}),
+ });
+ if (problem) return problem;
+ }
+ return null;
+}
+
+/** The sentence a refused build, launch or call carries. */
+export function pinRefusal(problem: string): string {
+ return `${problem}: the sandbox binds it by name, so it isn't followed — this session's commands don't run until it is the directory it was (put it back), or it is removed from the session`;
+}
+
+/**
+ * What a spec binds at `path`, pinned as it is now (`what`, for the words):
+ * a directory by its identity, a file (or a directory `mayBeMissing` and not
+ * there) only against links. At the spec build, after the paths were found.
+ */
+export function pinOf(path: string, what: string, opts: { mayBeMissing?: boolean; noId?: boolean } = {}): PathPin {
+ const at = resolve(path);
+ let isDir: boolean | null = null;
+ try {
+ isDir = lstatSync(at).isDirectory();
+ } catch {
+ /* missing */
+ }
+ const id = isDir && !opts.noId ? dirIdentity(at) : null;
+ return {
+ path: at,
+ what,
+ ...(id ? { id } : {}),
+ ...(opts.mayBeMissing || isDir === null ? { mayBeMissing: true } : {}),
+ ...(isDir === false ? { file: true } : {}),
+ };
+}
+
+/**
+ * How a provider names a spec's path when it binds it: a pinned one as it
+ * was pinned (the handle checked it is still that, and the launched argv
+ * checks again), never resolved again; anything else (a hidden path: a link
+ * there hides more; the home's allowlist, shown by where a link leads) by
+ * its real path, `real` (tests replace it).
+ */
+export function boundPath(pins: readonly PathPin[] | undefined, real: (p: string) => string = realOrSelf): (p: string) => string {
+ const pinned = new Set((pins ?? []).map((p) => p.path));
+ return (p) => (pinned.has(resolve(p)) ? resolve(p) : real(p));
+}
+
+/**
+ * The real path when it exists, else the path: found once, for a path the
+ * caller vouches for (one a spec build checked, a git dir its layout did,
+ * codeoid's own), before it is pinned. Never to use a pinned path again.
+ */
+export function realPathOnce(p: string): string {
+ return realOrSelf(p);
+}
+
+/** The real path when it exists, else the path. */
+function realOrSelf(p: string): string {
+ try {
+ return realpathSync(p);
+ } catch {
+ return p;
+ }
+}
+
+// --- The daemon's own use of a session's directory -------------------------------------------------------------
+
+/** A directory of a session's as the daemon uses it on the host: its real path and what it was when pinned. */
+export interface PinnedDir {
+ /** Its real path. */
+ readonly path: string;
+ /** What it is, for what is said: "the working directory", … */
+ readonly what: string;
+ /** `dev:ino@boot` ({@link pinRecord}). */
+ readonly record: string;
+}
+
+/** A use of a pinned directory refused: it isn't what it was. Said on the session and audited by the caller. */
+export class PinnedPathError extends SandboxPinError {
+ override name = "PinnedPathError";
+ readonly path: string;
+ readonly op: string;
+ readonly reason: string;
+ constructor(path: string, op: string, reason: string) {
+ super(`${path} changed under the session: it ${reason}; ${op} refused (never followed)`);
+ this.path = path;
+ this.op = op;
+ this.reason = reason;
+ }
+}
+
+/**
+ * `path` pinned (`what`, for the words): checked against `stored` when there
+ * is a record, else pinned as it is now. Throws {@link PinnedPathError} when
+ * it isn't a directory at its real path reached through no link, or isn't
+ * the one `stored` names.
+ */
+export function pinDir(path: string, what: string, stored?: string | null, op = `using ${what}`): PinnedDir {
+ const at = resolve(path);
+ if (!isAbsolute(path)) throw new PinnedPathError(at, op, "isn't an absolute path");
+ const change = changeOf(at, stored ? { stored } : {});
+ if (change) throw new PinnedPathError(at, op, change);
+ const record = stored ?? pinRecord(at);
+ if (!record) throw new PinnedPathError(at, op, "can't be resolved now");
+ return { path: at, what, record };
+}
+
+/** Why `dir` isn't what was pinned there (in words, without its path), or null. */
+export function pinnedDirChange(dir: PinnedDir): string | null {
+ return changeOf(dir.path, { stored: dir.record });
+}
+
+/** Throws {@link PinnedPathError} (for `op`) when `dir` isn't what was pinned. */
+export function checkPinned(dir: PinnedDir, op: string): void {
+ const change = pinnedDirChange(dir);
+ if (change) throw new PinnedPathError(dir.path, op, change);
+}
+
+/**
+ * Run `act` on `dir`'s path (`op`, for what is said): checked before, and
+ * again after, so a rename racing the act is said, never taken as done. A
+ * git or a walk given the path resolves it once, at its start: run it with
+ * the path as its cwd, so what it does stays in the directory that was
+ * checked even when the path is changed meanwhile.
+ */
+export async function inPinnedDir(dir: PinnedDir, op: string, act: (path: string) => Promise): Promise {
+ checkPinned(dir, op);
+ const out = await act(dir.path);
+ checkPinned(dir, op);
+ return out;
+}
+
+/** {@link inPinnedDir}, synchronously. */
+export function inPinnedDirSync(dir: PinnedDir, op: string, act: (path: string) => T): T {
+ checkPinned(dir, op);
+ const out = act(dir.path);
+ checkPinned(dir, op);
+ return out;
+}
+
+/** Whether `p` is a symbolic link (false when missing). */
+function isLink(p: string): boolean {
+ try {
+ return lstatSync(p).isSymbolicLink();
+ } catch {
+ return false;
+ }
+}
+
+/** The relative path's parts, refusing what would leave the directory. */
+function partsOf(dir: PinnedDir, rel: string, op: string): string[] {
+ const parts = rel.split("/").filter((p) => p.length > 0 && p !== ".");
+ if (isAbsolute(rel) || parts.some((p) => p === "..")) throw new PinnedPathError(dir.path, op, `was asked for ${JSON.stringify(rel)}, which isn't inside it`);
+ return parts;
+}
+
+/**
+ * `rel` inside `dir`, as a path, once `dir` is checked and no existing part
+ * of `rel` is a link: for a read, a look, or a git run that never follows a
+ * link of its own work tree (git never writes through a symlinked parent).
+ */
+export function pinnedPath(dir: PinnedDir, rel: string, op: string): string {
+ checkPinned(dir, op);
+ let at = dir.path;
+ for (const part of partsOf(dir, rel, op)) {
+ at = join(at, part);
+ if (isLink(at)) throw new PinnedPathError(dir.path, op, `holds a symbolic link on the way to ${rel} (${at})`);
+ }
+ return at;
+}
+
+/**
+ * Make `rel` inside `dir` when it is missing (`"dir"`, or a file with
+ * `content`), never through a link: each directory on the way made (0755)
+ * or found a directory, not a link; the file opened `O_CREAT | O_EXCL |
+ * O_NOFOLLOW`. `dir` checked before and after. Whether it was made (false:
+ * there already). Throws {@link PinnedPathError} when `dir` or the way
+ * changed; other failures (a read-only repository) are the caller's.
+ */
+export function makeInPinned(dir: PinnedDir, rel: string, make: "dir" | { content: string; mode?: number }, op: string): boolean {
+ return inPinnedDirSync(dir, op, (base) => {
+ const parts = partsOf(dir, rel, op);
+ const leaf = parts.pop();
+ if (leaf === undefined) return false;
+ let at = base;
+ for (const part of parts) {
+ at = join(at, part);
+ try {
+ mkdirSync(at, { mode: 0o755 });
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
+ }
+ const st = lstatSync(at);
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new PinnedPathError(dir.path, op, `holds ${st.isSymbolicLink() ? "a symbolic link" : "a file"} on the way to ${rel} (${at})`);
+ }
+ const target = join(at, leaf);
+ if (make === "dir") {
+ try {
+ mkdirSync(target, { mode: 0o755 });
+ return true;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") return false;
+ throw err;
+ }
+ }
+ let fd: number;
+ try {
+ fd = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, make.mode ?? 0o644);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") return false;
+ throw err;
+ }
+ try {
+ writeSync(fd, make.content);
+ } finally {
+ closeSync(fd);
+ }
+ return true;
+ });
+}
+
+/**
+ * Write `content` as the file `rel` inside `dir`, whole and never through a
+ * link: made beside it ({@link makeInPinned}: `O_EXCL | O_NOFOLLOW`) and
+ * renamed over it (a rename replaces a link put there, never follows it),
+ * `dir` checked before and after.
+ */
+export function replaceInPinned(dir: PinnedDir, rel: string, content: string, op: string, mode = 0o644): void {
+ const part = `${rel}.codeoid-${process.pid}-${Date.now()}.part`;
+ if (!makeInPinned(dir, part, { content, mode }, op)) throw new PinnedPathError(dir.path, op, `already holds ${part}`);
+ inPinnedDirSync(dir, op, (base) => {
+ const from = pinnedPath(dir, part, op);
+ try {
+ renameSync(from, join(base, ...partsOf(dir, rel, op)));
+ } catch (err) {
+ try {
+ unlinkSync(from);
+ } catch {
+ /* gone already */
+ }
+ throw err;
+ }
+ });
+}
+
+/**
+ * Make `p` when it is missing (`"dir"`, or a file with `content`) in its
+ * directory as it is now: that directory found by its real path once
+ * (one the caller vouched for: a workspace the build just checked, a git
+ * dir its layout did, a worktree location never reached through a link)
+ * and pinned for the make ({@link makeInPinned}). Whether it was made.
+ */
+export function makeAt(p: string, make: "dir" | { content: string; mode?: number }, op: string): boolean {
+ const at = resolve(p);
+ const name = at.slice(at.lastIndexOf("/") + 1);
+ if (!name) return false;
+ return makeInPinned(pinDir(realOrSelf(dirname(at)), "a directory codeoid makes in", null, op), name, make, op);
+}
+
+/**
+ * Remove the directory `p` when it is empty, never through a link (no
+ * component of `p` may be one: codeoid's own dirs are kept by their real
+ * paths). Whether it was removed.
+ */
+export function removeEmptyDir(p: string): boolean {
+ const at = resolve(p);
+ if (at === "/" || linkIn(at) !== null) return false;
+ try {
+ if (!lstatSync(at).isDirectory()) return false;
+ rmdirSync(at);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// --- The launch guard ------------------------------------------------------------------------------------------
+
+/** The first word of a guarded argv's own: what {@link pinGuardArgv} adds before the program. */
+export const PIN_GUARD_NAME = "codeoid-pins";
+
+/**
+ * sh, run as `sh -c codeoid-pins ( )×n `:
+ * no path may be a link, nor have one on its way (its directory's physical
+ * path, `cd -P`, is itself); a path whose `` is `m` or `fm` may be
+ * missing; each with an `` (not `-`) must have that `dev:ino` (`stat`
+ * prints them, all at once: the absolute program found when the script is
+ * made; without one only the rest is checked). Then the program runs (exec),
+ * else it exits 126 saying which.
+ */
+export function pinGuardScript(stat: readonly string[] | null): string {
+ const quote = (w: string) => `'${w.replace(/'/g, `'\\''`)}'`;
+ const statCmd = stat ? stat.map(quote).join(" ") : "";
+ return [
+ "n=$1; shift",
+ "refs=; want=; i=0",
+ 'while [ "$i" -lt "$n" ]; do',
+ // Positional parameters by number: only digits reach eval.
+ " b=$((i * 3 + 1)); i=$((i + 1))",
+ ' eval "p=\\${$b} id=\\${$((b + 1))} m=\\${$((b + 2))}"',
+ ' if [ ! -e "$p" ] && [ ! -L "$p" ]; then',
+ ' case $m in *m) continue ;; esac',
+ ' printf "codeoid sandbox: %s is gone: not run\\n" "$p" >&2; exit 126',
+ " fi",
+ ' d=${p%/*}; [ -n "$d" ] || d=/',
+ ' r=$(cd -P -- "$d" 2>/dev/null && pwd -P) || r=',
+ ' if [ -L "$p" ] || [ "$r" != "$d" ]; then printf "codeoid sandbox: %s is not the directory it was (a symbolic link is in its path, or it is not a directory): not run\\n" "$p" >&2; exit 126; fi',
+ ' case $m in f*) ;; *) if [ ! -d "$p" ]; then printf "codeoid sandbox: %s is not the directory it was (a symbolic link is in its path, or it is not a directory): not run\\n" "$p" >&2; exit 126; fi ;; esac',
+ ` if [ "$id" != - ]; then refs="$refs \\"\\\${$b}\\""; want="$want$id "; fi`,
+ "done",
+ ...(stat
+ ? [
+ 'if [ -n "$refs" ]; then',
+ ` got=$(eval "${statCmd.replace(/"/g, '\\"')} -- $refs" 2>/dev/null | tr "\\n" " ") || got=`,
+ ' if [ "$got" != "$want" ]; then printf "codeoid sandbox: a directory the sandbox binds is not the directory it was (another one is there now): not run\\n" >&2; exit 126; fi',
+ "fi",
+ ]
+ : []),
+ "shift $((n * 3))",
+ 'exec "$@"',
+ ].join("\n");
+}
+
+let statArgv: string[] | null | undefined;
+/** How `dev:ino` is printed here: the system's `stat` with its format, or null (none found). */
+function statProgram(): string[] | null {
+ if (statArgv === undefined) {
+ const bin = Bun.which("stat", { PATH: "/usr/bin:/bin" });
+ statArgv = bin ? [bin, ...(process.platform === "darwin" ? ["-f", "%d:%i"] : ["-c", "%d:%i"])] : null;
+ }
+ return statArgv;
+}
+
+/**
+ * `argv` run only once every pin still holds, checked by the shell right
+ * before the program starts (a launcher written once checks at each run):
+ * `/bin/sh -c` {@link pinGuardScript}. Unchanged without pins.
+ */
+export function pinGuardArgv(pins: readonly PathPin[] | undefined, argv: readonly string[]): string[] {
+ if (!pins || pins.length === 0) return [...argv];
+ const mode = (p: PathPin) => `${p.file ? "f" : "d"}${p.mayBeMissing ? "m" : ""}`;
+ return ["/bin/sh", "-c", pinGuardScript(statProgram()), PIN_GUARD_NAME, String(pins.length), ...pins.flatMap((p) => [p.path, p.id ?? "-", mode(p)]), ...argv];
+}
+
+/** The program a guarded argv runs (what follows the guard's own words), for a caller that names it (the git shim). */
+export function unguardedArgv(argv: readonly string[]): string[] {
+ if (argv[0] !== "/bin/sh" || argv[3] !== PIN_GUARD_NAME) return [...argv];
+ const n = Number(argv[4]);
+ return argv.slice(5 + n * 3);
+}
diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts
index bc8134dc..5682ea5c 100644
--- a/src/daemon/sandbox/policy.ts
+++ b/src/daemon/sandbox/policy.ts
@@ -23,7 +23,7 @@
* - network per policy.
*/
-import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync, writeFileSync } from "node:fs";
+import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import type { CodeoidConfig } from "../../config.js";
@@ -34,6 +34,8 @@ import { cliCredentialFiles, registeredSecretLocations } from "../secret-paths.j
import { DEFAULT_HOME_READABLE } from "./home-readable.js";
import { validAllowedHost } from "./hosts.js";
import { sandboxRoot } from "./root.js";
+import { makeAt, makeInPinned, type PathPin, dirIdentity, pinDir, pinOf, pinProblem, pinRefusal, replaceInPinned, SandboxPinError } from "./pinned.js";
+import { controlDirProblem, nestedControl, type NestedControl, projectRootSecrets, type RootScan, rootScanKey, stillRealDir } from "./project-roots.js";
import { isOwnWorktree, worktreeAdminsOf, worktreeFoldersOf } from "./worktrees.js";
import type { NetworkMode, NetworkPolicy, SandboxSpec } from "./types.js";
@@ -62,6 +64,13 @@ export interface SandboxConfig {
* `//`, each session its own folder there (worktrees.ts).
*/
worktreeRoots: string[];
+ /**
+ * Where a session's commands may read other repositories, read-only
+ * (`~` expands; {@link PROJECT_ROOT_PARENT} is the parent of the session's
+ * repository, the default): everything under one, but what the hidden list
+ * hides (project-roots.ts `resolveProjectRoots`). Absent: the default.
+ */
+ projectRoots?: string[];
docker: {
/** Image a Docker sandbox runs; must carry what the backend runs (a shell, node for pi / the Gemini CLI). */
image: string;
@@ -84,6 +93,7 @@ export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = {
hiddenPaths: [],
homeReadable: [...DEFAULT_HOME_READABLE],
worktreeRoots: [],
+ projectRoots: ["@parent"],
docker: { image: "node:22-bookworm-slim", socket: false },
};
@@ -249,6 +259,7 @@ const TEMP_SERVICE_SOCKET = /^(?:\.s\.PGSQL\.\d+(?:\.lock)?|mysqlx?\.sock(?:\.lo
export function localServiceSocketPaths(tempDirs: readonly string[] = ["/tmp", "/private/tmp", "/var/tmp"]): string[] {
const out = [...LOCAL_SERVICE_SOCKET_DIRS];
for (const t of tempDirs) {
+ // paths: a look at the host's temp dirs (which local service sockets are there), no write
let names: string[] = [];
try {
names = readdirSync(t);
@@ -294,6 +305,7 @@ export function dockerSocketPaths(
}
/** A socket the daemon's user can connect to (writable: `/var/run/docker.sock` of a user not in the docker group isn't). */
+// paths: a look, no write
function socketAt(p: string): boolean {
try {
if (!statSync(p).isSocket()) return false;
@@ -306,6 +318,7 @@ function socketAt(p: string): boolean {
/** The runtime sockets {@link dockerSocketPaths} looks for that exist but the daemon's user can't connect to (said, not shown). */
export function unconnectableDockerSockets(home: string, env: Record = process.env, platform: NodeJS.Platform = process.platform): string[] {
+ // paths: a look, no write
const exists = (p: string) => {
try {
return statSync(p).isSocket();
@@ -373,6 +386,7 @@ export function hiddenPathsFor(config: CodeoidConfig | undefined, inputs: Hidden
if (isAbsolute(abs)) out.push(abs);
}
const all = new Set();
+ // paths: the hidden list, by the path as given and where it leads: hiding more, never less
for (const p of out) {
const abs = resolve(p);
all.add(abs);
@@ -442,6 +456,40 @@ export interface SpecInputs {
* process backend's handle has the network on to reach its model).
*/
hideLocalServices?: boolean;
+ /**
+ * The session's project roots, resolved (project-roots.ts
+ * `resolveProjectRoots`): readable whole, read-only, what the hidden list
+ * hides in them hidden again, and the credential files anywhere in them
+ * (`projectRootSecrets`); one too big to look through isn't shown.
+ */
+ projectRoots?: readonly string[];
+ /**
+ * Directories the session writes besides its workspace, validated
+ * (project-roots.ts `addedDirProblem`), each kept by its real path: each
+ * as a workspace is, its git structure and project settings held, and
+ * every repository and settings dir inside it (`nestedControl`), its
+ * repository's worktree folder writable; left out when it no longer is a
+ * directory at that path, or is too big to look through.
+ * `ownWorktreeEntries`: as {@link ownWorktreeEntries}, for its repository.
+ */
+ addedDirs?: readonly { dir: string; ownWorktreeEntries?: readonly string[]; agent?: boolean }[];
+ /** How many directories a root's or an added dir's look goes through (project-roots.ts; default theirs): tests. */
+ scanLimit?: number;
+ /**
+ * What each bound directory was when it was pinned (pinned.ts `pinRecord`,
+ * by real path: the workspace, added dirs, project roots; #448 round 13):
+ * one that isn't that directory now, or is reached through a link, refuses
+ * the build ({@link SandboxPinError}). One with no record is pinned as it
+ * is now (and a link-named one is never bound).
+ */
+ pins?: Readonly>;
+ /**
+ * The look through the roots (and the agent's own added dirs) for
+ * credential files the session's resolution already made
+ * (project-roots.ts `projectRootSecretsAsync`): used when it was of the
+ * same dirs, else looked again.
+ */
+ rootScan?: RootScan;
}
/** The home a spec hides: absolute, and not `/` (nothing is left to show then). */
@@ -473,6 +521,7 @@ export function homeCredentialFilesIn(readable: readonly string[]): string[] {
const out: string[] = [];
for (const r of readable) {
if (!r.endsWith(`${sep}.nvm`)) continue;
+ // paths: a look at a program's install (where node versions are), no write
let versions: string[] = [];
try {
versions = readdirSync(join(r, "versions", "node"));
@@ -541,6 +590,7 @@ const WORKTREE_CONTROL = ["commondir", "gitdir", "config.worktree"] as const;
/** Made when missing, so there is something to hold read-only: empty dirs, and an empty `config.worktree` (read only where the config turns it on). */
const GIT_PLACEHOLDER_DIRS = ["hooks", "info", "modules", "remotes", "branches", "worktrees"] as const;
+// paths: a look, no write
function readGitFile(dotgit: string): string | null {
try {
const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotgit, "utf8"));
@@ -589,6 +639,7 @@ export function gitLayoutOf(workspace: string, home: string = process.env.HOME ?
if (dirname(dir) === dir) break;
}
if (!dotgit) return null;
+ // paths: a look, no write
let st: ReturnType;
try {
st = lstatSync(dotgit);
@@ -697,6 +748,7 @@ export function gitWorkspacePaths(workspace: string, home: string = process.env.
const layout = gitLayoutOf(ws, home);
if (!layout) return out;
const { dotgit, own } = layout;
+ // paths: looks (what the repository's layout is), no write; what is made goes through makeAt
// A `.git` dir right at the top of the workspace: git's fallback when it is made invalid must find nothing.
if (dirname(dotgit) === ws && !isLink(dotgit) && lstatSync(dotgit).isDirectory()) {
const head = join(ws, "HEAD");
@@ -704,6 +756,7 @@ export function gitWorkspacePaths(workspace: string, home: string = process.env.
if (emptyDir(head)) out.readOnly.push(head);
}
const common = layout.common;
+ // paths: looks, no write
if (!common) {
// Not a layout codeoid can verify: nothing of it is writable, the `.git` file can't be rewritten.
if (lexists(dotgit) && !isLink(dotgit) && within(dotgit, ws) && !lstatSync(dotgit).isDirectory()) out.readOnly.push(dotgit);
@@ -775,21 +828,31 @@ export function gitWorkspacePaths(workspace: string, home: string = process.env.
* sandbox holds them read-only for commands; a file tool isn't one. No side
* effects (nothing is made).
*/
-export function protectedWriteTargets(workdir: string, home: string = process.env.HOME ?? homedir()): HeldPath[] {
+export function protectedWriteTargets(workdir: string, home: string = process.env.HOME ?? homedir(), addedDirs: readonly string[] = []): HeldPath[] {
const ws = sandboxWorkspaceOf(workdir, home);
const out: HeldPath[] = [];
- const layout = gitLayoutOf(ws, home);
- if (layout) {
- for (const p of [layout.dotgit, layout.common, layout.own, layout.named]) if (p) out.push({ at: p, whole: true, what: "git" });
- if (layout.common) {
- const others = listDirs(join(layout.common, "worktrees")).map((n) => gitdirTarget(join(layout.common as string, "worktrees", n)));
- for (const f of [...others, ...submoduleCheckouts(join(layout.common, "modules"))]) if (f && within(f, ws)) out.push({ at: f, whole: true, what: "git" });
- }
+ // The workspace, and each dir the session was given to write (SpecInputs.addedDirs), as the spec holds them:
+ // with every repository in it, and its settings dirs and bare repositories wherever they are.
+ const nested = addedDirs.map((d) => ({ dir: resolve(d), control: nestedControlCached(resolve(d)) }));
+ for (const n of nested) {
+ for (const at of n.control.settings) out.push({ at, whole: true, what: "settings" });
+ for (const at of n.control.gitDirs) out.push({ at, whole: true, what: "git" });
}
- // Each dir from the workdir up to the workspace's top: where the CLIs look for project settings.
- for (const dir of projectDirsOf(workdir, ws)) {
- for (const d of PROJECT_CONFIG_DIRS) out.push({ at: join(dir, d), whole: true, what: "settings" });
- for (const f of PROJECT_CONFIG_FILES) out.push({ at: join(dir, f), whole: false, what: "settings" });
+ const tops = [[ws, projectDirsOf(workdir, ws)] as const, ...nested.flatMap((n) => [n.dir, ...nestedRepos(n.dir, n.control)].map((d) => [d, [d]] as const))];
+ for (const [top, dirs] of tops) {
+ const layout = gitLayoutOf(top, home);
+ if (layout) {
+ for (const p of [layout.dotgit, layout.common, layout.own, layout.named]) if (p) out.push({ at: p, whole: true, what: "git" });
+ if (layout.common) {
+ const others = listDirs(join(layout.common, "worktrees")).map((n) => gitdirTarget(join(layout.common as string, "worktrees", n)));
+ for (const f of [...others, ...submoduleCheckouts(join(layout.common, "modules"))]) if (f && within(f, top)) out.push({ at: f, whole: true, what: "git" });
+ }
+ }
+ // Each dir from the workdir up to the workspace's top: where the CLIs look for project settings.
+ for (const dir of dirs) {
+ for (const d of PROJECT_CONFIG_DIRS) out.push({ at: join(dir, d), whole: true, what: "settings" });
+ for (const f of PROJECT_CONFIG_FILES) out.push({ at: join(dir, f), whole: false, what: "settings" });
+ }
}
return out;
}
@@ -802,6 +865,7 @@ export function gitLayoutProblem(workspace: string, home: string = process.env.H
}
/** An empty directory (not a link). */
+// paths: a look, no write
function emptyDir(p: string): boolean {
try {
return lstatSync(p).isDirectory() && readdirSync(p).length === 0;
@@ -811,6 +875,7 @@ function emptyDir(p: string): boolean {
}
/** git's own record that `gitDir` is the submodule git dir checked out at `dotgit`'s dir: its `core.worktree` names it back. */
+// paths: a look at git's records, no write
function validSubmodule(gitDir: string, dotgit: string): boolean {
try {
if (!lstatSync(gitDir).isDirectory() || !gitDirShape(gitDir)) return false;
@@ -825,7 +890,8 @@ function validSubmodule(gitDir: string, dotgit: string): boolean {
}
/** A git dir as git checks one: `objects/` and `HEAD`. */
-function gitDirShape(dir: string): boolean {
+// paths: a look, no write
+export function gitDirShape(dir: string): boolean {
try {
return lstatSync(join(dir, "objects")).isDirectory() && lstatSync(join(dir, "HEAD")).isFile();
} catch {
@@ -833,6 +899,7 @@ function gitDirShape(dir: string): boolean {
}
}
+// paths: a look, no write
function lexists(p: string): boolean {
try {
lstatSync(p);
@@ -842,6 +909,7 @@ function lexists(p: string): boolean {
}
}
+// paths: a look, no write
function isLink(p: string): boolean {
try {
return lstatSync(p).isSymbolicLink();
@@ -853,11 +921,12 @@ function isLink(p: string): boolean {
/** A dir (`"dir"`) or a file with `content`, made only where nothing (not even a link) is: never through one. Whether it was made. */
function makeIfMissing(p: string, content: string): boolean {
try {
- if (content === "dir") mkdirSync(p, { mode: 0o755 });
- else writeFileSync(p, content, { flag: "wx", mode: 0o644 });
- return true;
+ // In its directory as it is now (a workspace, working directory or added dir the build just checked against its
+ // pin, or a git dir its layout vouched for), pinned for the make: never through a link, the file `O_EXCL |
+ // O_NOFOLLOW`, the directory checked again after (pinned.ts, #448 round 14).
+ return makeAt(p, content === "dir" ? "dir" : { content, mode: 0o644 }, "making a placeholder");
} catch {
- /* there already, or a repository codeoid can't write either */
+ /* there already, or a repository codeoid can't write either, or its directory changed */
return false;
}
}
@@ -878,21 +947,25 @@ function excludePlaceholders(workspace: string, home: string, names: readonly st
const info = join(layout.common, "info");
const exclude = join(info, "exclude");
try {
- if (!lexists(info)) mkdirSync(info, { mode: 0o755 });
+ // The git dir pinned for the write: `info/` made and `exclude` replaced whole, never through a link (pinned.ts).
+ const common = pinDir(realOr(layout.common), "the repository's git dir", null, "listing placeholders in info/exclude");
+ makeInPinned(common, "info", "dir", "listing placeholders in info/exclude");
if (isLink(info) || isLink(exclude)) return;
const rel = relative(dirname(layout.dotgit), resolve(workspace));
if (rel.startsWith("..") || isAbsolute(rel)) return;
+ // paths: a look (the file's current text, before replaceInPinned writes it whole)
const have = lexists(exclude) ? readFileSync(exclude, "utf8") : "";
const lines = have.split("\n");
const add = names.map((n) => `/${rel ? `${rel.split(sep).join("/")}/` : ""}${n}`).filter((l) => !lines.includes(l));
if (add.length === 0) return;
const head = lines.includes(EXCLUDE_MARK) ? [] : [EXCLUDE_MARK];
- writeFileSync(exclude, `${have}${have && !have.endsWith("\n") ? "\n" : ""}${[...head, ...add].join("\n")}\n`, { flag: "w" });
+ replaceInPinned(common, "info/exclude", `${have}${have && !have.endsWith("\n") ? "\n" : ""}${[...head, ...add].join("\n")}\n`, "listing placeholders in info/exclude");
} catch {
/* a repository codeoid can't write: the placeholder shows in `git status` */
}
}
+// paths: a look, no write
function listDirs(dir: string): string[] {
try {
return readdirSync(dir, { withFileTypes: true })
@@ -904,6 +977,7 @@ function listDirs(dir: string): string[] {
}
/** The `.git` file a worktree's git dir names as its checkout's (its `gitdir` file), absolute. */
+// paths: a look at git's records, no write
function gitdirTarget(worktreeGitDir: string): string | null {
try {
const named = readFileSync(join(worktreeGitDir, "gitdir"), "utf8").trim();
@@ -924,6 +998,7 @@ function submoduleCheckouts(modules: string, depth = 0, budget = { left: 200 }):
for (const name of listDirs(modules)) {
if (--budget.left < 0) break;
const dir = join(modules, name);
+ // paths: a look at git's records, no write
if (gitDirShape(dir)) {
try {
const m = /^\s*worktree\s*=\s*(.+?)\s*$/im.exec(readFileSync(join(dir, "config"), "utf8"));
@@ -941,6 +1016,7 @@ function submoduleCheckouts(modules: string, depth = 0, budget = { left: 200 }):
}
/** git's own record that `gitDir` is the worktree whose `.git` file is `dotgit`, in a real repository `common`. */
+// paths: a look at git's records, no write
function validWorktree(gitDir: string, common: string, dotgit: string): boolean {
try {
if (!lstatSync(common).isDirectory() || !lstatSync(join(common, "objects")).isDirectory() || !lstatSync(join(common, "HEAD")).isFile()) return false;
@@ -1011,6 +1087,7 @@ export function projectConfigPaths(workspace: string, home: string = process.env
export function sandboxCacheDir(home: string, env: Record = process.env): string | null {
const xdg = env.XDG_CACHE_HOME && isAbsolute(env.XDG_CACHE_HOME) ? env.XDG_CACHE_HOME : join(home, ".cache");
const dir = join(xdg, "codeoid-sandbox");
+ // paths: the sandboxes' cache dir in the user's own cache dir: made by the daemon; in a sandbox it is a mount point (no rename) under a home that is read-only there
try {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const st = lstatSync(dir);
@@ -1042,6 +1119,7 @@ export function toolchainCacheEnv(cacheDir: string, home: string, env: Record projectConfigPaths(dir, home));
+ const configDir = inputs.hidden?.configDir ?? getConfigDir();
+ // Every directory bound by name, checked first (#448 round 13, pinned.ts): one reached through a link, or another
+ // directory than the one pinned, refuses the build; it is never followed to where the link leads.
+ const stored = inputs.pins ?? {};
+ const refuse = (problem: string | null): void => {
+ if (problem) throw new SandboxPinError(pinRefusal(problem));
+ };
+ refuse(pinProblem(workspace, "the workspace", { ...(stored[workspace] ? { stored: stored[workspace] } : { mayBeMissing: true }) }));
+ const workdir = inputs.workdir !== undefined ? resolve(inputs.workdir) : null;
+ // The working directory by its identity too (#448 round 14): one moved aside and replaced, even by a real
+ // directory, is another directory, never bound or snapshotted as the session's.
+ if (workdir && workdir !== workspace) refuse(pinProblem(workdir, "the working directory", stored[workdir] ? { stored: stored[workdir] } : { mayBeMissing: true }));
+ // Each added dir as a workspace of its own (never one inside the workspace: it is writable already), and only
+ // while it still is what was granted: a directory at its real path, no git or settings dir, every repository and
+ // settings dir in it found (project-roots.ts; Session says when one is dropped). One pinned that changed refuses.
+ const added = (inputs.addedDirs ?? [])
+ .map((a) => ({ ...a, dir: resolve(a.dir) }))
+ .filter((a, i, all) => !within(a.dir, workspace) && all.findIndex((b) => b.dir === a.dir) === i)
+ .filter((a) => {
+ const record = stored[a.dir];
+ if (record) refuse(pinProblem(a.dir, "added directory", { stored: record }));
+ return stillRealDir(a.dir) && controlDirProblem(a.dir, home) === null;
+ })
+ .map((a) => ({ ...a, nested: nestedControl(a.dir, inputs.scanLimit) }))
+ .filter((a) => a.nested.complete);
+ const gitOf = (dir: string, ownEntries: readonly string[] | undefined): GitPaths =>
+ gitWorkspacePaths(dir, home, {
+ worktreeRoots: sandboxCfg.worktreeRoots,
+ configDir,
+ ...(inputs.worktreeFolder ? { worktreeFolder: inputs.worktreeFolder } : {}),
+ ...(ownEntries ? { ownEntries } : {}),
+ });
+ const git: GitPaths = inputs.git === false ? { writable: [], readOnly: [], gitDirs: [], folders: [], locations: [], problems: [] } : gitOf(workspace, inputs.ownWorktreeEntries);
+ if (inputs.git !== false) {
+ // An added dir's own worktree git dir stays its own, even where the workspace's look holds it (another worktree of the same repository).
+ const addedOwn = new Set();
+ for (const a of added) {
+ const g = gitOf(a.dir, a.ownWorktreeEntries);
+ const own = gitLayoutOf(a.dir, home)?.own;
+ if (own) addedOwn.add(resolve(own));
+ for (const k of ["writable", "readOnly", "gitDirs", "folders", "locations", "problems"] as const) for (const p of g[k]) if (!git[k].includes(p)) git[k].push(p);
+ }
+ // The repositories inside an added dir (a folder of them): their git structure read-only, as the workspace's.
+ for (const top of added.flatMap((a) => nestedRepos(a.dir, a.nested))) {
+ const g = gitWorkspacePaths(top, home, { worktreeRoots: sandboxCfg.worktreeRoots, configDir });
+ for (const p of g.readOnly) if (!git.readOnly.includes(p)) git.readOnly.push(p);
+ for (const p of g.problems) if (!git.problems.includes(p)) git.problems.push(p);
+ }
+ // Never the workspace's own worktree git dir, wherever a look holds the rest of `worktrees/`.
+ const wsOwn = gitLayoutOf(workspace, home)?.own;
+ if (wsOwn && added.length > 0) addedOwn.add(resolve(wsOwn));
+ if (addedOwn.size > 0) git.readOnly = git.readOnly.filter((p) => !addedOwn.has(resolve(p)));
+ }
+ const projectConfig =
+ inputs.projectConfig === false
+ ? []
+ : [
+ ...[...projectDirsOf(inputs.workdir ?? workspace, workspace), ...added.map((a) => a.dir), ...added.flatMap((a) => nestedRepos(a.dir, a.nested))].flatMap((dir) => projectConfigPaths(dir, home)),
+ // Settings dirs and files anywhere else in an added dir, and bare repositories there: held as they are.
+ ...added.flatMap((a) => [...a.nested.settings, ...a.nested.gitDirs]).filter((p) => lexists(p) && !isLink(p)),
+ ].filter((p, i, all) => all.indexOf(p) === i);
const cache = inputs.cache === false ? null : sandboxCacheDir(home, env);
const own = [...(inputs.backendState ?? []).map((p) => resolve(p)), ...git.writable, ...(cache ? [cache] : [])];
const ownReal = own.map(realOr);
// Made now if it wasn't: what the daemon puts there later is hidden too.
const root = sandboxRootOrNull();
- const tempDir = resolve(inputs.tempDir);
+ const tempDir = realOr(resolve(inputs.tempDir));
// A workspace that is the home itself: the home is what the session works on (the hidden list still applies in it).
const homeHidden = hiddenHome(home) === workspace ? null : hiddenHome(home);
const userHidden = userHiddenPaths(inputs.config, home);
const allowed = homeHidden ? homeReadablePaths(inputs.config, homeHidden).filter((r) => !userHidden.some((u) => within(r, u))) : [];
+ const baseHidden = hiddenPathsFor(inputs.config, inputs.hidden);
+ // What the hidden list hides, the home aside: no root or added dir is inside it (validated; again here).
+ const inHidden = (p: string) => [...baseHidden, ...(root ? [root] : [])].some((h) => within(p, h) || within(realOr(p), realOr(h)));
+ const addedKept = added.filter((a) => !inHidden(a.dir) && !userHidden.some((u) => within(a.dir, u)));
+ // The session's own: its workspace and the dirs its owner added (their credentials are its to use, as the
+ // workspace's are). A dir the agent added itself (autonomous `request_directory`) keeps the hiding a root has.
+ // Its repository's git dir too (a fork's worktree uses its main checkout's): git there reads its config.
+ const ownCommon = inputs.git === false ? null : (gitLayoutOf(workspace, home)?.common ?? null);
+ const writableDirs = [workspace, ...(ownCommon ? [ownCommon] : []), ...addedKept.filter((a) => !a.agent).map((a) => a.dir)];
+ const agentDirs = addedKept.filter((a) => a.agent).map((a) => a.dir);
+ // Each root by its real path (a root named through a link is hidden in where it really is), pinned as the rest.
+ const rootCandidates = (inputs.projectRoots ?? [])
+ .map((r) => resolve(r))
+ .map((r) => {
+ const record = stored[r];
+ if (record) refuse(pinProblem(r, "project root", { stored: record }));
+ return dirIdentity(r) !== null ? r : realOr(r);
+ })
+ .filter((r, i, all) => dirIdentity(r) !== null && all.indexOf(r) === i && !userHidden.some((u) => within(r, u)) && !inHidden(r));
+ // The credential files anywhere in a root and in an agent's own added dir (not the session's own); a root or such
+ // a dir too big to look through whole isn't shown at all, nor written (fail closed: resolveSandbox says so).
+ const scanned = [...rootCandidates, ...agentDirs];
+ const rootSecrets =
+ inputs.rootScan && inputs.scanLimit === undefined && inputs.rootScan.key === rootScanKey(scanned, writableDirs) ? inputs.rootScan.result : projectRootSecrets(scanned, writableDirs, inputs.scanLimit);
+ const roots = rootCandidates.filter((r) => !rootSecrets.over.includes(r));
+ const addedDirs = addedKept.map((a) => a.dir).filter((d) => !rootSecrets.over.includes(d));
+ // The worktree locations shown (other sessions' checkouts of this repository, their copied `.env`s): the same.
+ const locationSecrets = projectRootSecrets(git.locations, [...writableDirs, ...git.folders]);
// A link in the allowlist leads only where the allowlist (or what is outside the home) shows: what it
// leads to elsewhere in the home stays hidden (a project of yours, with its `.env`), and the link dangles.
const reexposed = [
@@ -1080,27 +1237,74 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
...allowed,
// The worktree locations holding the session's folders, read-only: a worktree added elsewhere in one fails
// (it isn't hidden away into a scratch dir), and the repository's other worktrees there are readable.
- ...git.locations.filter((l) => !userHidden.some((u) => within(l, u))),
+ ...git.locations.filter((l) => !userHidden.some((u) => within(l, u)) && !locationSecrets.over.includes(l)),
+ // The project roots: other repositories, read-only.
+ ...roots,
];
const hiddenAll = [
...(homeHidden ? [homeHidden] : []),
- ...hiddenPathsFor(inputs.config, inputs.hidden),
+ ...baseHidden,
...homeCredentialFilesIn(reexposed),
+ // The credential files under a root and in the worktree locations shown.
+ ...rootSecrets.hidden,
+ ...locationSecrets.hidden,
...(root ? [root] : []),
];
// The session's worktree folders: writable, but never inside something hidden (a root configured in a hidden
// path), and, unlike the backend's own state, never taking a hidden path inside them out of the hidden list.
const locations = git.folders.filter((l) => !hiddenAll.some((h) => h !== homeHidden && (within(l, h) || within(l, realOr(h)))));
// What stays visible inside the hidden home: a hidden path there matters only inside one of these.
- const shown = [workspace, tempDir, ...own, ...ownReal, ...locations, ...reexposed, ...reexposed.map(realOr)];
+ const shown = [workspace, tempDir, ...own, ...ownReal, ...locations, ...addedDirs, ...addedDirs.map(realOr), ...reexposed, ...reexposed.map(realOr)];
+ // A credential the look found stays hidden even in a git dir commits write (an agent's own added dir's).
+ const credentials = new Set(rootSecrets.hidden);
const hidden = hiddenAll.filter(
(h, i, all) =>
all.indexOf(h) === i &&
// The backend's own state stays reachable by it; nothing it holds is taken out otherwise.
- ![...own, ...ownReal].some((o) => within(h, o)) &&
+ (credentials.has(h) || ![...own, ...ownReal].some((o) => within(h, o))) &&
// Inside the hidden home, only what is inside something shown needs hiding again.
(!homeHidden || h === homeHidden || !within(h, homeHidden) || shown.some((x) => within(h, x))),
);
+ // What stays writable: a held path or a dir held in place is bound only inside one (outside, nothing to hold,
+ // and a bind there could show what is hidden).
+ const writableRoots = [workspace, ...ownReal, ...addedDirs, ...locations];
+ const inWritable = (p: string) => writableRoots.some((w) => within(p, w));
+ // Held read-only, by real path (#448 round 14: resolved here, once, never again at a launch); only inside
+ // something writable.
+ const readOnly = [...new Set([...git.readOnly, ...projectConfig].map(realOr))].filter(inWritable);
+ // Held in place (no rename, still writable inside): every dir between a writable root and a hidden path in it,
+ // and between two nested writable roots (pinned); the working directory and every dir between it and the
+ // workspace (a monorepo's package dir moved aside takes its held settings with it, #448 round 14), and every
+ // dir between a writable root and a path held read-only in it (heldInPlace).
+ const pinned = [...new Set([...protectedAncestors(hidden, [workspace, tempDir, ...own, ...addedDirs, ...locations]), ...nestedRootAncestors([workspace, tempDir, ...addedDirs, ...locations], hidden)])];
+ // paths: a look (is the working directory there), no write
+ const heldInPlace = [
+ ...new Set([
+ ...(workdir && workdir !== workspace && within(workdir, workspace) && existsSync(workdir) ? [workdir, ...heldAncestors([workdir], [workspace], hidden)] : []),
+ ...heldAncestors(readOnly, writableRoots, hidden),
+ ]),
+ ].filter((p) => !pinned.includes(p));
+ // Everything bound by name, as it is now (pinned.ts): checked again at every launch, and bound as it is here.
+ // Not what is hidden (a link there hides more), nor the home's allowlist or a program's location (shown by
+ // where a link leads, by design), nor the owner's container sockets: none is where a sandbox writes.
+ const pins: PathPin[] = [];
+ const seenPin = new Set();
+ const pin = (p: PathPin) => {
+ if (seenPin.has(p.path)) return;
+ seenPin.add(p.path);
+ pins.push(p);
+ };
+ pin(pinOf(workspace, "the workspace"));
+ if (workdir && workdir !== workspace) pin(pinOf(workdir, "the working directory"));
+ for (const d of addedDirs) pin(pinOf(d, "added directory"));
+ for (const r of roots) pin(pinOf(r, "project root"));
+ for (const f of locations) pin(pinOf(f, "worktree folder", { mayBeMissing: true, noId: true }));
+ for (const w of ownReal) pin(pinOf(w, "a writable directory of the session's"));
+ pin(pinOf(tempDir, "the session's temp dir"));
+ for (const p of readOnly) pin(pinOf(p, "a path held read-only", { mayBeMissing: true }));
+ for (const p of [...pinned, ...heldInPlace]) pin(pinOf(p, "a directory held in place"));
+ // Made and removed by codeoid between turns, as the folders in them (SessionSandbox.tidyFolders): checked against links.
+ for (const l of git.locations) pin(pinOf(realOr(l), "a worktree location", { mayBeMissing: true, noId: true }));
// The container runtime's sockets, when the owner opted in: reachable at their own paths, `DOCKER_HOST` the first.
const sockets = inputs.dockerSocket ? dockerSocketPaths(home, env) : [];
const exposed = new Set(sockets.flatMap((s) => [s, realOr(s)]));
@@ -1108,13 +1312,17 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
sessionId: inputs.sessionId,
workspace,
tempDir,
- writable: [...own, ...locations],
+ writable: [...new Set([...ownReal, ...addedDirs, ...locations])],
hidden,
- readOnly: [...git.readOnly, ...projectConfig],
+ readOnly,
gitDirs: git.gitDirs,
...(locations.length > 0 ? { worktreeFolders: locations } : {}),
+ ...(roots.length > 0 ? { projectRoots: roots } : {}),
+ ...(addedDirs.length > 0 ? { addedDirs } : {}),
...(cache || sockets.length > 0 ? { env: { ...(cache ? toolchainCacheEnv(cache, home, env) : {}), ...(sockets[0] ? { DOCKER_HOST: `unix://${sockets[0]}` } : {}) } } : {}),
- pinned: protectedAncestors(hidden, [workspace, tempDir, ...own, ...locations]),
+ pinned,
+ ...(heldInPlace.length > 0 ? { heldInPlace } : {}),
+ pins,
reexposed,
containerSockets: containerSocketPaths(home, env).filter((p) => !exposed.has(p) && !exposed.has(realOr(p))),
...((inputs.hideLocalServices ?? inputs.network.mode !== "on") ? { serviceSockets: localServiceSocketPaths() } : {}),
@@ -1125,6 +1333,22 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
return spec;
}
+/** nestedControl, looked again at most every few seconds: the gate asks it of every file tool call. */
+const nestedCache = new Map();
+function nestedControlCached(dir: string): NestedControl {
+ const hit = nestedCache.get(dir);
+ if (hit && Date.now() - hit.at < 5_000) return hit.control;
+ const control = nestedControl(dir);
+ if (nestedCache.size > 64) nestedCache.clear();
+ nestedCache.set(dir, { at: Date.now(), control });
+ return control;
+}
+
+/** The repositories inside an added dir `dir` (nestedControl), but its own: each held as a workspace's is. */
+function nestedRepos(dir: string, nested: NestedControl): string[] {
+ return nested.repos.filter((r) => resolve(r) !== resolve(dir));
+}
+
function sandboxRootOrNull(): string | null {
try {
return sandboxRoot();
@@ -1133,6 +1357,7 @@ function sandboxRootOrNull(): string | null {
}
}
+// paths: at a spec build only: a path found where it really is, once (a workspace or added dir the build just checked against its pin, the repository's layout, the daemon's or the user's own dirs); whatever of it is bound is pinned there (pinOf) and never resolved again at a launch
function realOr(p: string): string {
try {
return realpathSync(p);
@@ -1155,10 +1380,60 @@ export function protectedAncestors(hidden: readonly string[], writableRoots: rea
for (const w of roots) {
if (at === w || !within(at, w)) continue;
for (let a = dirname(at); a !== w && within(a, w); a = dirname(a)) {
- // A dir that is hidden itself, or inside a hidden one, is never pinned: a pin binds
- // the real dir, which would show it again (the home in a workspace of /home). Hidden,
- // it is a mount point of its own (no rename) or not there at all.
- if (hiddenReal.some((h) => within(a, h))) continue;
+ // A dir that is hidden itself, or inside a hidden one in the root, is never pinned: a pin
+ // binds the real dir, which would show it again (the home in a workspace of /home). Hidden,
+ // it is a mount point of its own (no rename) or not there at all. One the root shows again
+ // (the root in the hidden home) is pinned.
+ if (hiddenReal.some((h) => within(a, h) && within(h, w) && h !== w)) continue;
+ out.add(a);
+ }
+ }
+ }
+ return [...out];
+}
+
+/**
+ * The directories strictly between a writable root and another one bound
+ * inside it (a workspace in an added dir, a worktree folder in one): pinned
+ * too (SandboxSpec.pinned), so no command can move a parent aside and put a
+ * link where the inner root was (#448 round 13). Never one hidden, or in a
+ * hidden dir inside the outer root (a pin would show it). Real paths.
+ */
+export function nestedRootAncestors(roots: readonly string[], hidden: readonly string[] = [], exists: (p: string) => boolean = existsSync): string[] {
+ const real = roots.map(realOr);
+ const hiddenReal = hidden.map(realOr);
+ const out = new Set();
+ for (const inner of real) {
+ if (!exists(inner)) continue;
+ for (const outer of real) {
+ if (inner === outer || !within(inner, outer)) continue;
+ for (let a = dirname(inner); a !== outer && within(a, outer); a = dirname(a)) {
+ if (hiddenReal.some((h) => within(a, h) && within(h, outer) && h !== outer)) continue;
+ out.add(a);
+ }
+ }
+ }
+ return [...out];
+}
+
+/**
+ * The directories between a writable root and a path held in it (`held`:
+ * a path held read-only, the working directory), from the path's parent up,
+ * strictly inside the root: pinned (SandboxSpec.pinned), so none can be
+ * moved aside with what is held in it and made again around a planted copy
+ * (#448 round 14: a monorepo package's `.claude/`, a submodule's hooks).
+ * Never one hidden, or in a hidden dir inside the root. Real paths.
+ */
+export function heldAncestors(held: readonly string[], roots: readonly string[], hidden: readonly string[] = [], exists: (p: string) => boolean = existsSync): string[] {
+ const real = roots.map(realOr);
+ const hiddenReal = hidden.map(realOr);
+ const out = new Set();
+ for (const p of held.map(realOr)) {
+ if (!exists(p)) continue;
+ for (const root of real) {
+ if (p === root || !within(p, root)) continue;
+ for (let a = dirname(p); a !== root && within(a, root); a = dirname(a)) {
+ if (hiddenReal.some((h) => within(a, h) && within(h, root) && h !== root)) continue;
out.add(a);
}
}
@@ -1219,6 +1494,7 @@ export function programLocations(
}
/** The nearest directory at or above `file`'s holding a `package.json`, below `/`. */
+// paths: a look at a program's install, no write
function packageRoot(file: string): string | null {
for (let dir = dirname(file); dir !== dirname(dir); dir = dirname(dir)) {
try {
@@ -1231,6 +1507,7 @@ function packageRoot(file: string): string | null {
}
/** The program a script's `#!` line runs (`env`'s argument for `#!/usr/bin/env x`), or null. */
+// paths: a look at a program's first line, no write
function shebangProgram(file: string): string | null {
let head = "";
try {
diff --git a/src/daemon/sandbox/project-roots.ts b/src/daemon/sandbox/project-roots.ts
new file mode 100644
index 00000000..552b0dbb
--- /dev/null
+++ b/src/daemon/sandbox/project-roots.ts
@@ -0,0 +1,763 @@
+/**
+ * Working across repositories (#448 follow-up, docs/sandbox.md "Working
+ * across repositories"): what a session may read besides its workspace, and
+ * what it may write.
+ *
+ * - project roots (`sandbox.projectRoots`, a session's own at create):
+ * readable whole, read-only. The default, {@link PROJECT_ROOT_PARENT}, is
+ * the parent of the session's repository (`~/Workspace` for a session in
+ * `~/Workspace/codeoid`), so its commands read the other repositories
+ * there, and see what other sessions did in their checkouts; a workspace
+ * in no repository has none. What the sandbox hides anywhere stays hidden
+ * in them (credential stores, codeoid's own dirs, the agents' logins,
+ * container sockets), and so do the credential files anywhere in them
+ * ({@link projectRootSecrets}: a root too big to look through isn't used);
+ * - added directories (`SessionSandboxSetting.addedDirs`): writable, each
+ * as a workspace is (its git structure and project settings held, its
+ * own worktree folder), and so is every repository and settings dir
+ * inside one ({@link nestedControl}). Kept by their real path, taken when
+ * granted ({@link addedDirReal}), and dropped when that no longer is a
+ * directory ({@link stillRealDir}). Added by the owner, or asked for by
+ * the agent (`request_directory`, request-directory.ts; in autonomous
+ * mode only {@link autonomousGrantProblem}'s).
+ *
+ * Neither may be a place whose contents run outside the sandbox, the home or
+ * `/` ({@link sandboxDirProblem}), nor a root the host's temp dir
+ * ({@link projectRootProblem}); an added one, nothing the sandbox hides, nor
+ * a git or project settings dir ({@link addedDirProblem}).
+ */
+
+import { type Dirent, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
+import { homedir } from "node:os";
+import { basename, dirname, isAbsolute, join, resolve } from "node:path";
+import { hooksPathOf } from "./git-watch.js";
+import { gitDirShape, gitLayoutOf, PROJECT_CONFIG_DIRS, PROJECT_CONFIG_FILES } from "./policy.js";
+import { type DirContext, hostTempRoots, normalizeSandboxDir, PROJECT_ROOT_PARENT, projectRootProblem, sandboxDirProblem } from "./dir-rules.js";
+import { mainCheckoutOf, worktreeLocationDirs } from "./worktrees.js";
+
+export { type DirContext, hostTempRoots, normalizeSandboxDir, PROJECT_ROOT_PARENT, projectRootProblem, sandboxDirProblem };
+
+function within(child: string, parent: string): boolean {
+ return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`);
+}
+
+// paths: a look, no write
+function lexists(p: string): boolean {
+ try {
+ lstatSync(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// paths: a root or an added dir found by its real path once, when first resolved or granted; then pinned (pinRecord) and checked at every use (pinned.ts)
+function realOr(p: string): string {
+ try {
+ return realpathSync(p);
+ } catch {
+ return p;
+ }
+}
+
+/** Dirs a scan doesn't go into: dependencies and build caches, too many to look through (docs/sandbox.md, Limits). */
+export const SCAN_SKIPPED_DIRS: ReadonlySet = new Set(["node_modules", ".venv", "__pycache__", ".tox", ".gradle", ".next", ".turbo"]);
+
+/** How many directories an added dir's look for repositories and settings goes through ({@link nestedControl}). */
+export const ADDED_DIR_SCAN_LIMIT = 20_000;
+/** How many directories of each project root the look for credential files goes through ({@link projectRootSecrets}). */
+export const PROJECT_ROOT_SCAN_LIMIT = 20_000;
+
+/** The first component of `p` as written (absolute) that is a symbolic link, or null (none, or a missing one first). */
+// paths: a look (which component is a link), no write
+export function linkInPath(p: string): string | null {
+ let at = "";
+ for (const part of resolve(p).split("/").filter(Boolean)) {
+ at += `/${part}`;
+ try {
+ if (lstatSync(at).isSymbolicLink()) return at;
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+
+/** Where `dir` (`~` expanded; absolute) really is, or null when it isn't there: what is stored and bound when it is added. */
+// paths: an added dir found by its real path once, when granted; then pinned (pinRecord) and checked at every use (pinned.ts)
+export function addedDirReal(dir: string, home: string = process.env.HOME ?? homedir()): string | null {
+ const abs = normalizeSandboxDir(dir, home);
+ if (abs === null) return null;
+ try {
+ return realpathSync(abs);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Whether the added dir kept at `stored` (its real path when granted) is
+ * still a directory there, reached through no link: one replaced by a link,
+ * or removed, is dropped (said on the session), never followed.
+ */
+// paths: a check (still a directory at its real path, no link), no write
+export function stillRealDir(stored: string): boolean {
+ const at = resolve(stored);
+ if (linkInPath(at) !== null) return false;
+ try {
+ return lstatSync(at).isDirectory() && realpathSync(at) === at;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Why `real` (a real path) is a dir whose contents git or an agent CLI runs
+ * outside the sandbox, or null: a project settings dir or one inside it
+ * (`.claude/`, `.codex/`, …: PROJECT_CONFIG_DIRS), a `.git` or `.hg`
+ * dir or one inside it, a git dir by its shape (a bare repository, a
+ * worktree's admin dir, a submodule's), or the repository's `core.hooksPath`
+ * dir or one inside it.
+ */
+export function controlDirProblem(real: string, home: string = process.env.HOME ?? homedir()): string | null {
+ const at = resolve(real);
+ const parts = at.split("/").filter(Boolean);
+ for (let i = 0; i < parts.length; i++) {
+ const name = parts[i] as string;
+ const path = `/${parts.slice(0, i + 1).join("/")}`;
+ if (PROJECT_CONFIG_DIRS.includes(name)) {
+ return `${path === at ? `${at} is` : `${at} is in`} a project settings dir (${path}), which agent CLIs run code from outside the sandbox`;
+ }
+ if (name === ".git" || name === ".hg") return `${path === at ? `${at} is` : `${at} is in`} a repository's ${name} dir (${path}), which git runs code from outside the sandbox`;
+ }
+ for (let dir = at; ; dir = dirname(dir)) {
+ if (gitDirShape(dir)) return `${dir === at ? `${at} is` : `${at} is in`} a git dir (${dir}), which git runs code from outside the sandbox`;
+ if (dirname(dir) === dir) break;
+ }
+ const layout = gitLayoutOf(at, home);
+ if (layout?.common) {
+ const top = mainCheckoutOf(layout) ?? dirname(layout.dotgit);
+ const hooks = hooksPathOf([join(layout.common, "config"), join(layout.common, "config.worktree"), ...(layout.own ? [join(layout.own, "config.worktree")] : [])]);
+ if (hooks) {
+ const dir = realOr(hooks.startsWith("~/") ? join(home, hooks.slice(2)) : resolve(top, hooks));
+ if (within(at, dir)) return `${at === dir ? `${at} is` : `${at} is in`} the repository's hooks dir (core.hooksPath ${hooks}), which git runs outside the sandbox`;
+ }
+ }
+ return null;
+}
+
+/** What an added dir holds that runs outside the sandbox: each held read-only as the workspace's own are (policy.ts). */
+export interface NestedControl {
+ /** The top of each repository inside it (a dir with a `.git`), the added dir's own included when it is one. */
+ repos: string[];
+ /** Bare repositories (git dirs by their shape) inside it: held read-only whole. */
+ gitDirs: string[];
+ /** Project settings dirs and files inside it (PROJECT_CONFIG_DIRS, PROJECT_CONFIG_FILES), at any depth. */
+ settings: string[];
+ /** False when the look stopped at its bound, or at a dir of yours it couldn't read: then the dir isn't added. */
+ complete: boolean;
+}
+
+/**
+ * What `dir` holds that git or an agent CLI runs outside the sandbox: every
+ * repository, bare git dir and project settings dir or file in it, looked
+ * for through its directories (not through links, not in a repository's
+ * git dir, not in {@link SCAN_SKIPPED_DIRS}), at most `limit` of them.
+ */
+export function nestedControl(dir: string, limit = ADDED_DIR_SCAN_LIMIT): NestedControl {
+ const out: NestedControl = { repos: [], gitDirs: [], settings: [], complete: true };
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
+ const queue = [resolve(dir)];
+ let seen = 0;
+ while (queue.length > 0) {
+ const d = queue.shift() as string;
+ if (gitDirShape(d)) {
+ out.gitDirs.push(d);
+ continue;
+ }
+ // paths: a look through an added dir (what repositories and settings dirs it holds), no write
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(d, { withFileTypes: true });
+ } catch {
+ // One of yours unreadable now could be made readable (and written) from inside: not looked through, not added.
+ try {
+ if (uid === null || lstatSync(d).uid === uid) out.complete = false;
+ } catch {
+ /* gone meanwhile */
+ }
+ if (!out.complete) return out;
+ continue;
+ }
+ for (const e of entries) {
+ if (e.isSymbolicLink()) continue;
+ const at = join(d, e.name);
+ if (e.name === ".git") {
+ out.repos.push(d);
+ continue;
+ }
+ if (PROJECT_CONFIG_DIRS.includes(e.name) && e.isDirectory()) {
+ out.settings.push(at);
+ continue;
+ }
+ if (PROJECT_CONFIG_FILES.includes(e.name) && e.isFile()) {
+ out.settings.push(at);
+ continue;
+ }
+ if (!e.isDirectory() || e.name === ".hg" || SCAN_SKIPPED_DIRS.has(e.name)) continue;
+ if (++seen > limit) {
+ out.complete = false;
+ return out;
+ }
+ queue.push(at);
+ }
+ }
+ return out;
+}
+
+export interface AddedDirContext extends DirContext {
+ /** What every sandbox hides (policy.ts `hiddenPathsFor`): an added dir is never in one. */
+ hidden?: readonly string[];
+ /** The daemon's `PATH`: a dir on it (or holding an entry of it) runs outside the sandbox. */
+ path?: string;
+ /** {@link nestedControl}'s bound (tests). */
+ scanLimit?: number;
+}
+
+/**
+ * Why `dir` can't be added to a session's writable directories, or null:
+ * {@link sandboxDirProblem}, then where it really is must be a directory of
+ * the daemon's user, outside everything the sandbox hides, holding no dir of
+ * the daemon's `PATH` (a program written there runs outside any sandbox at
+ * the user's next command), neither a git nor a project settings dir nor
+ * inside one ({@link controlDirProblem}), and small enough that every
+ * repository and settings dir in it is found ({@link nestedControl}).
+ */
+export function addedDirProblem(dir: string, ctx: AddedDirContext = {}): string | null {
+ const basic = sandboxDirProblem(dir, ctx);
+ if (basic) return basic;
+ const home = resolve(ctx.home ?? process.env.HOME ?? homedir());
+ const abs = normalizeSandboxDir(dir, home) as string;
+ const real = addedDirReal(abs, home);
+ if (real === null) return `${dir} doesn't exist`;
+ if (real !== abs) {
+ const again = sandboxDirProblem(real, ctx);
+ if (again) return `${dir} leads to ${real}, which ${again.startsWith(`${real} `) ? again.slice(real.length + 1) : `can't be added: ${again}`}`;
+ }
+ // paths: a look, no write
+ let st: ReturnType;
+ try {
+ st = lstatSync(real);
+ } catch {
+ return `${dir} doesn't exist`;
+ }
+ if (!st.isDirectory()) return `${dir} isn't a directory`;
+ if (typeof process.getuid === "function" && st.uid !== process.getuid()) return `${dir} isn't yours (another user owns it)`;
+ for (const h of ctx.hidden ?? []) {
+ const hr = realOr(h);
+ // In one: what is hidden isn't the session's to write. Holding one is fine: it stays hidden inside.
+ if (within(abs, h) || within(real, hr)) return `${dir} is in what the sandbox hides (${h})`;
+ }
+ for (const entry of (ctx.path ?? process.env.PATH ?? "").split(":")) {
+ if (!entry || !isAbsolute(entry)) continue;
+ if (within(resolve(entry), abs) || within(realOr(entry), real)) return `${dir} holds ${entry}, which is on your PATH: a program written there would run outside the sandbox`;
+ }
+ const control = controlDirProblem(real, home);
+ if (control) return control;
+ const limit = ctx.scanLimit ?? ADDED_DIR_SCAN_LIMIT;
+ if (!nestedControl(real, limit).complete) {
+ return `${dir} holds more than ${limit} directories to look through for repositories and project settings (${[...SCAN_SKIPPED_DIRS].slice(0, 2).join(", ")} and the like aside), or one of yours that can't be read: what is in it couldn't all be protected — add the repositories you need one by one`;
+ }
+ return null;
+}
+
+/**
+ * Why the agent's own `request_directory` can't be granted unasked in
+ * autonomous mode (an unattended run's too), or null. Beyond
+ * {@link addedDirProblem}: the path as written goes through no link
+ * (`written`), and where it really is (`real`) is strictly inside one of
+ * the session's project roots and inside a repository's checkout (its top,
+ * or a dir in it): a folder holding repositories, a root itself, or
+ * anything else is the owner's to add.
+ */
+export function autonomousGrantProblem(written: string, real: string, roots: readonly string[], home: string = process.env.HOME ?? homedir()): string | null {
+ const link = linkInPath(written);
+ if (link) return `${written} goes through a symbolic link (${link}); in autonomous mode a directory is added only by its real path`;
+ const inside = roots.some((r) => {
+ const rr = realOr(resolve(r));
+ return within(real, rr) && real !== rr;
+ });
+ if (!inside) return `${real} isn't under this session's project roots (${roots.length > 0 ? roots.join(", ") : "none"}); in autonomous mode nothing else is added`;
+ const layout = gitLayoutOf(real, home);
+ const top = layout?.common ? (layout.kind === "worktree" || layout.kind === "submodule" ? dirname(layout.dotgit) : (mainCheckoutOf(layout) ?? dirname(layout.dotgit))) : null;
+ if (!top || !within(real, realOr(top))) return `${real} isn't in a repository; in autonomous mode only a repository under a project root (or a directory in one) is added`;
+ return null;
+}
+
+/** A checkout another session works in (its workspace, a worktree folder of its): what an autonomous grant never touches. */
+export interface OtherCheckout {
+ dir: string;
+ /** In words: `session "x"'s workspace`. */
+ what: string;
+}
+
+/**
+ * Why `real` (a real path) touches a checkout that isn't the session's own,
+ * or null (#448 round 13): it is, is inside or holds one of `others` (other
+ * sessions' workspaces and worktree folders, which their commands write: a
+ * swap there would be bound here); it is a checkout in one of its
+ * repository's worktree locations (`-worktrees/`,
+ * `/.worktrees/`, `sandbox.worktreeRoots`'s: codeoid's fork,
+ * loop and `--worktree` checkouts, other sessions' folders) that isn't one
+ * of `ownFolders`, or holds such a location; or it is another checkout of
+ * the session's own repository (`workspace`'s: its main checkout, seen from
+ * a fork's worktree). An autonomous grant of one is refused; the owner's
+ * card says it.
+ */
+export function checkoutOverlap(
+ real: string,
+ others: readonly OtherCheckout[],
+ opts: { workspace: string; ownFolders?: readonly string[]; worktreeRoots?: readonly string[]; configDir?: string; home?: string },
+): string | null {
+ const at = resolve(real);
+ const home = resolve(opts.home ?? process.env.HOME ?? homedir());
+ for (const o of others) {
+ const dir = realOr(resolve(o.dir));
+ if (within(at, dir)) return `${at} ${at === dir ? "is" : "is in"} ${o.what} (${dir}), which that session's commands write`;
+ if (within(dir, at)) return `${at} holds ${o.what} (${dir}), which that session's commands write`;
+ }
+ const own = (opts.ownFolders ?? []).map((f) => realOr(resolve(f)));
+ const layout = gitLayoutOf(at, home);
+ if (layout?.common) {
+ const main = mainCheckoutOf(layout);
+ const locations = [
+ ...worktreeLocationDirs(layout, { roots: opts.worktreeRoots ?? [], home, ...(opts.configDir ? { configDir: opts.configDir } : {}) }).locations.map((l) => l.dir),
+ ...(main ? [join(main, ".worktrees")] : []),
+ ].map((l) => realOr(l));
+ for (const l of locations) {
+ if (within(at, l) && at !== l && !own.some((f) => within(at, f))) return `${at} is in ${l}, a worktree location: the checkouts there are other sessions' and codeoid's own (forks, loops, --worktree)`;
+ if (within(l, at) && lexists(l)) return `${at} holds ${l}, a worktree location: the checkouts there are other sessions' and codeoid's own (forks, loops, --worktree)`;
+ }
+ const mine = gitLayoutOf(resolve(opts.workspace), home);
+ if (mine?.common && realOr(mine.common) === realOr(layout.common)) {
+ const top = layout.kind === "worktree" ? dirname(layout.dotgit) : (main ?? dirname(layout.dotgit));
+ return `${at} ${at === realOr(top) ? "is" : "is in"} another checkout of this session's own repository (${realOr(top)}${top === main ? ", its main checkout" : ""})`;
+ }
+ }
+ return null;
+}
+
+/**
+ * The default project root for a session working in `workspace`: the
+ * parent of its repository's main checkout (a linked worktree's too, so a
+ * fork in `-worktrees/` reads ``'s siblings). Null when the
+ * workspace isn't in a repository: then there is no default root.
+ */
+export function defaultProjectRoot(workspace: string, home: string = process.env.HOME ?? homedir()): string | null {
+ const ws = resolve(workspace);
+ const layout = gitLayoutOf(ws, home);
+ if (!layout) return null;
+ const top = mainCheckoutOf(layout) ?? dirname(layout.dotgit);
+ return dirname(resolve(top));
+}
+
+export interface ResolvedRoots {
+ /** Absolute, existing dirs, deduplicated (by where they really are). */
+ roots: string[];
+ /** The one that is the default ({@link PROJECT_ROOT_PARENT}), when it is used. */
+ defaultRoot?: string;
+ /** What wasn't used, and why (the sandbox's limits say it). */
+ notes: string[];
+}
+
+/**
+ * `entries` (the session's own, else `sandbox.projectRoots`) for a session
+ * working in `workspace`: {@link PROJECT_ROOT_PARENT} as
+ * {@link defaultProjectRoot} (none outside a repository, said), a default
+ * that would be the home (a repository directly in it), the host's temp
+ * dir or another refused place falling back to no root, said; anything else
+ * refused by {@link projectRootProblem} or not there, said and not used.
+ * Each by its real path (one named through a link is read where it really
+ * is, said).
+ */
+export function resolveProjectRoots(entries: readonly string[], workspace: string, ctx: DirContext = {}): ResolvedRoots {
+ const home = resolve(ctx.home ?? process.env.HOME ?? homedir());
+ const out: ResolvedRoots = { roots: [], notes: [] };
+ const seen = new Set();
+ for (const entry of entries) {
+ const isDefault = entry === PROJECT_ROOT_PARENT;
+ const at = isDefault ? defaultProjectRoot(workspace, home) : normalizeSandboxDir(entry, home);
+ if (isDefault && at === null) {
+ out.notes.push("no project root: the workspace isn't in a repository, so there is no default (the repository's parent) — set sandbox.projectRoots (or the session's own) to read other directories");
+ continue;
+ }
+ const problem = at === null ? `${entry} isn't an absolute path` : projectRootProblem(at, { ...ctx, home });
+ if (problem || at === null) {
+ out.notes.push(
+ isDefault
+ ? `no project root: the default, the repository's parent (${at}), ${problem?.replace(/^\S+ is /, "is ") ?? "can't be one"} — set sandbox.projectRoots (or the session's own) to read other repositories`
+ : `project root ${entry}: ${problem}: not used`,
+ );
+ continue;
+ }
+ // paths: a look, no write; a root is used by its real path, pinned at its first resolution
+ let dir = false;
+ try {
+ dir = lstatSync(realOr(at)).isDirectory();
+ } catch {
+ /* not there */
+ }
+ if (!dir) {
+ if (!isDefault) out.notes.push(`project root ${entry} (${at}) isn't a directory here: not used`);
+ continue;
+ }
+ // By where it really is (#448 round 13): what is hidden in it, what is shown around the workspace and its pin
+ // are all of the real dirs, never of a link's path (bubblewrap binds a link's path whole, before them).
+ const real = realOr(at);
+ if (seen.has(real)) continue;
+ seen.add(real);
+ if (real !== at && !isDefault) out.notes.push(`project root ${entry} is ${real} (where it really is): read there`);
+ out.roots.push(real);
+ if (isDefault) out.defaultRoot = real;
+ }
+ return out;
+}
+
+/**
+ * Credential files, by name: dotenv files (not their examples), direnv's,
+ * registry and netrc tokens, private keys (not `.pub`: `id_*`, `*_rsa`,
+ * `*_ed25519`, `*_ecdsa`, `*_dsa`, deploy keys, `*.key`, `*.pem`, PuTTY's
+ * `*.ppk`, Apple's `*.p8`), certificates' keystores, kubeconfigs, Terraform
+ * state and variables, a vault token, service-account keys, `secrets.*`.
+ */
+const DATA_EXT = "(?:json|ya?ml|toml|ini|env|txt|conf|cfg|properties|xml|csv)";
+const KEY_EXT = "(?:\\.(?:key|pem|ppk|old|bak|orig|backup|[0-9]+))?";
+const SECRET_FILE = new RegExp(
+ `^(?:${[
+ "\\.env(?:\\.(?!(?:example|sample|template)$)[^/]+)?",
+ "\\.envrc",
+ "\\.npmrc",
+ "\\.pypirc",
+ "[._]netrc",
+ "\\.git-credentials",
+ "\\.vault-token",
+ "\\.pgpass",
+ "[^/]*\\.(?:pem|key|p12|pfx|jks|keystore|ppk|p8)",
+ // Key names (`id_ed25519_work`, `github_rsa`, `deploy_key`): no extension, or a key's or a backup's, never `.pub`
+ // (nor a source file: `id_generator.ts`, `crypto_rsa.go` aren't keys).
+ `id_[^/.]+${KEY_EXT}`,
+ `[^/.]*_(?:rsa|dsa|ecdsa|ed25519)(?:_[^/.]*)?${KEY_EXT}`,
+ `(?:[^/.]*[_-])?deploy[_-]?key(?:[_-][^/.]*)?${KEY_EXT}`,
+ "kubeconfig(?:\\.[^/]+)?",
+ "[^/]*\\.kubeconfig",
+ "[^/]*\\.tfstate(?:\\.[^/]+)?",
+ "[^/]*\\.tfvars(?:\\.json)?",
+ `credentials(?:\\.${DATA_EXT})?`,
+ "service[-_]account[^/]*\\.json",
+ `secrets?\\.${DATA_EXT}`,
+ ].join("|")})$`,
+ "i",
+);
+/** Dirs that hold only credentials (a project's own `.aws/`, `.ssh/`, …): hidden whole. */
+const SECRET_DIRS: ReadonlySet = new Set([".ssh", ".gnupg", ".aws", ".azure", ".gcloud", ".kube", ".password-store"]);
+/** Credential files by where they are: a project's own Claude settings (their `env`), Docker's login. */
+const SECRET_SUFFIXES: readonly string[] = ["/.claude/settings.local.json", "/.docker/config.json", "/.mcp.json"];
+/**
+ * Files that hold a credential only sometimes, judged by what is in them:
+ * Yarn's `.yarnrc.yml` (`npmAuthToken`, `npmAuthIdent`) and `.yarnrc`
+ * (`_authToken`), Composer's `auth.json`.
+ */
+const SECRET_BY_CONTENT: Readonly> = {
+ ".yarnrc.yml": /\bnpm(?:AuthToken|AuthIdent|Password)\s*:/,
+ ".yarnrc": /_auth(?:Token)?\b|_password\b/,
+ "auth.json": /"(?:http-basic|github-oauth|gitlab-oauth|gitlab-token|bitbucket-oauth|bearer|forgejo-token|custom-headers)"\s*:/,
+};
+
+/** A small regular file's text, or null (not one, too big, unreadable). */
+// paths: a look (a config's text, for credentials), no write
+function smallText(file: string, max = 1 << 20): string | null {
+ try {
+ const st = lstatSync(file);
+ if (!st.isFile() || st.size > max) return null;
+ return readFileSync(file, "utf8");
+ } catch {
+ return null;
+ }
+}
+
+/** Whether `file` (named `name`) is one of {@link SECRET_BY_CONTENT}'s holding a credential. */
+function secretByContent(name: string, file: string): boolean {
+ const re = SECRET_BY_CONTENT[name];
+ if (!re) return false;
+ const text = smallText(file);
+ return text !== null && re.test(text);
+}
+
+/**
+ * Whether a git config file carries a credential: a URL with a user or
+ * password in it (an https one, or any with a password), or a token in its
+ * query; an http `extraheader` in any syntax git reads (alone on its line,
+ * after its section's header on the same line, under `[http ""]`, any
+ * case); a credential helper that is a shell snippet printing a password or
+ * a token. A file too big to read is taken to carry one.
+ */
+// paths: a look (a git config's text, for credentials), no write
+export function gitConfigHasCredential(file: string): boolean {
+ let st: ReturnType;
+ try {
+ st = lstatSync(file);
+ } catch {
+ return false;
+ }
+ if (!st.isFile()) return false;
+ if (st.size > 1 << 20) return true;
+ const text = smallText(file);
+ if (text === null) return false;
+ return (
+ /\b(?:https?|ftps?):\/\/[^\s/"@]+@/i.test(text) ||
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:"@]+:[^\s/"@]*@/i.test(text) ||
+ /:\/\/[^\s"]*[?&](?:private[_-]?token|access[_-]?token|token|password|passwd|api[_-]?key|apikey|auth|key)=/i.test(text) ||
+ /(?:^|\])[ \t]*extraheader[ \t]*=/im.test(text) ||
+ /(?:^|\])[ \t]*helper[ \t]*=[ \t]*"?[ \t]*!.*(?:password|token|secret)/im.test(text)
+ );
+}
+
+/** The files a git config pulls in (`include.path`, `includeIf..path`), absolute: relative to it, `~/` to the home. */
+export function gitConfigIncludes(file: string, home: string = process.env.HOME ?? homedir()): string[] {
+ const text = smallText(file);
+ if (text === null) return [];
+ const out: string[] = [];
+ let section = "";
+ for (const raw of text.split("\n")) {
+ let line = raw.trim();
+ const head = /^\[\s*([A-Za-z0-9.-]+)(?:\s+"[^"]*")?\s*\]\s*(.*)$/.exec(line);
+ if (head) {
+ section = (head[1] ?? "").toLowerCase();
+ line = head[2] ?? "";
+ }
+ if (section !== "include" && section !== "includeif") continue;
+ const m = /^path\s*=\s*(.*?)\s*$/i.exec(line);
+ if (!m?.[1]) continue;
+ const value = m[1].replace(/^"(.*)"$/, "$1");
+ if (!value) continue;
+ out.push(value.startsWith("~/") ? join(home, value.slice(2)) : isAbsolute(value) ? value : resolve(dirname(file), value));
+ }
+ return out;
+}
+
+/** The credential stores a git config's `credential.helper = store --file ` names, absolute (relative to `base`). */
+function credentialStores(file: string, base: string, home: string): string[] {
+ const text = smallText(file);
+ if (text === null) return [];
+ const out: string[] = [];
+ for (const m of text.matchAll(/(?:^|\])[ \t]*helper[ \t]*=[ \t]*"?[ \t]*store\b[^\n]*?--file(?:[ \t]*=[ \t]*|[ \t]+)("?)([^"\s]+)\1/gim)) {
+ const f = m[2] ?? "";
+ if (!f) continue;
+ out.push(f.startsWith("~/") ? join(home, f.slice(2)) : isAbsolute(f) ? f : resolve(base, f));
+ }
+ return out;
+}
+
+/** Whether Mercurial's `hgrc` carries a credential: an `[auth]` section, or a URL with a user or password. */
+function hgrcHasCredential(file: string): boolean {
+ const text = smallText(file);
+ if (text === null) return false;
+ return /^\s*\[auth\]/im.test(text) || /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:"@]+:[^\s/"@]*@/i.test(text);
+}
+
+/**
+ * The credentials git may read in the git dir `gitDir` (a repository's
+ * `.git`, a bare one, a submodule's): every config git reads there
+ * (`config`, `config.worktree`, each worktree's `config.worktree`, each
+ * submodule's under `modules/`, bounded) carrying one, every file they
+ * include, a `store --file` they name, and a credential file by name at its
+ * top (`credentials`, `.git-credentials`). Configs in `configs`.
+ */
+function gitDirSecrets(gitDir: string, home: string, out: { found: string[]; configs: string[] }, budget = { left: 200 }, depth = 0): void {
+ if (depth > 4 || --budget.left < 0) return;
+ const top = basename(gitDir) === ".git" ? dirname(gitDir) : gitDir;
+ const configs = [join(gitDir, "config"), join(gitDir, "config.worktree")];
+ for (const w of listDirNames(join(gitDir, "worktrees"))) configs.push(join(gitDir, "worktrees", w, "config.worktree"), join(gitDir, "worktrees", w, "config"));
+ const seen = new Set();
+ const look = (config: string, level: number): void => {
+ if (seen.has(config) || level > 5) return;
+ seen.add(config);
+ if (gitConfigHasCredential(config)) {
+ out.found.push(config);
+ out.configs.push(config);
+ }
+ for (const store of credentialStores(config, top, home)) out.found.push(store);
+ for (const inc of gitConfigIncludes(config, home)) look(inc, level + 1);
+ };
+ for (const c of configs) look(c, 0);
+ for (const name of listFileNames(gitDir)) if (SECRET_FILE.test(name)) out.found.push(join(gitDir, name));
+ const walkModules = (dir: string, level: number): void => {
+ if (level > 4) return;
+ for (const name of listDirNames(dir)) {
+ if (--budget.left < 0) return;
+ const sub = join(dir, name);
+ // A submodule's git dir (one with a config is read by git as one, whatever else it has), and a submodule
+ // named with a slash (`libs/a`): its git dir a level down.
+ if (gitDirShape(sub) || smallText(join(sub, "config")) !== null) gitDirSecrets(sub, home, out, budget, depth + 1);
+ if (!gitDirShape(sub)) walkModules(sub, level + 1);
+ }
+ };
+ walkModules(join(gitDir, "modules"), 0);
+}
+
+// paths: a look, no write
+function listDirNames(dir: string): string[] {
+ try {
+ return readdirSync(dir, { withFileTypes: true })
+ .filter((e) => e.isDirectory())
+ .map((e) => e.name);
+ } catch {
+ return [];
+ }
+}
+
+// paths: a look, no write
+function listFileNames(dir: string): string[] {
+ try {
+ return readdirSync(dir, { withFileTypes: true })
+ .filter((e) => e.isFile())
+ .map((e) => e.name);
+ } catch {
+ return [];
+ }
+}
+
+/** What the look through project roots found ({@link projectRootSecrets}). */
+export interface RootSecrets {
+ /** The credential files and dirs, hidden. */
+ hidden: string[];
+ /** The git configs among them (a repository's whose remote carries a credential): git there can't read its config. */
+ gitConfigs: string[];
+ /** Roots too big to look through whole: not used (fail closed). */
+ over: string[];
+}
+
+/**
+ * The credential files anywhere under the `roots` (and the session's own
+ * worktree locations, `locations`), hidden like the credential stores are:
+ * {@link SECRET_FILE} by name, {@link SECRET_DIRS} whole,
+ * {@link SECRET_BY_CONTENT} by what is in them, every git config git may read
+ * in a repository's git dir or a bare one carrying a credential (and what
+ * it includes, a credential store it names: {@link gitDirSecrets}), a
+ * Mercurial `hgrc` with one; but in `writable` (the session's own workspace
+ * and the dirs its owner added: its own project's `.env` is its to use).
+ * Looked for through directories (not through links, not in
+ * {@link SCAN_SKIPPED_DIRS}) when the spec is built: one made later isn't
+ * hidden. A root with more than `limit` directories to look through is in
+ * `over`, and isn't to be used.
+ */
+export function projectRootSecrets(roots: readonly string[], writable: readonly string[], limit = PROJECT_ROOT_SCAN_LIMIT, home: string = process.env.HOME ?? homedir()): RootSecrets {
+ const scan = scanRootSecrets(roots, writable, limit, home);
+ for (let step = scan.next(); ; step = scan.next()) if (step.done) return step.value;
+}
+
+/**
+ * {@link projectRootSecrets} without holding the daemon's event loop: it
+ * lets other work run every few hundred directories (a root of tens of
+ * thousands takes a tenth of a second or more). What a session's
+ * resolution runs; its result is reused by every spec built from it.
+ */
+export async function projectRootSecretsAsync(roots: readonly string[], writable: readonly string[], limit = PROJECT_ROOT_SCAN_LIMIT, home: string = process.env.HOME ?? homedir()): Promise {
+ const scan = scanRootSecrets(roots, writable, limit, home);
+ for (let step = scan.next(); ; step = scan.next()) {
+ if (step.done) return step.value;
+ await new Promise((r) => setImmediate(r));
+ }
+}
+
+/** What a scan was of, to reuse its result only for the same roots and the same own dirs (buildSandboxSpec). */
+export function rootScanKey(roots: readonly string[], owned: readonly string[]): string {
+ return JSON.stringify([roots.map((r) => resolve(r)), owned.map((o) => resolve(o))]);
+}
+
+/** A scan's result with what it was of ({@link rootScanKey}). */
+export interface RootScan {
+ key: string;
+ result: RootSecrets;
+}
+
+/** How many directories {@link scanRootSecrets} looks through between yields. */
+const SCAN_SLICE = 500;
+
+function* scanRootSecrets(roots: readonly string[], writable: readonly string[], limit: number, home: string): Generator {
+ const out: RootSecrets = { hidden: [], gitConfigs: [], over: [] };
+ let slice = 0;
+ const mine = writable.flatMap((w) => [resolve(w), realOr(w)]);
+ const owned = (p: string) => mine.some((w) => within(p, w));
+ const seenRoots = new Set();
+ for (const root of roots) {
+ const top = resolve(root);
+ if (seenRoots.has(top)) continue;
+ seenRoots.add(top);
+ const git = { found: [] as string[], configs: [] as string[] };
+ const found: string[] = [];
+ const queue = [top];
+ let seen = 0;
+ let over = false;
+ while (queue.length > 0 && !over) {
+ const d = queue.shift() as string;
+ if (++slice % SCAN_SLICE === 0) yield;
+ if (owned(d) || owned(realOr(d))) continue;
+ // A bare repository (or any git dir by its shape): its configs, never walked as files.
+ if (gitDirShape(d)) {
+ gitDirSecrets(d, home, git);
+ continue;
+ }
+ // paths: a look through a root (credential files), no write
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(d, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+ for (const e of entries) {
+ if (e.isSymbolicLink()) continue;
+ const at = join(d, e.name);
+ if (e.isDirectory()) {
+ if (SECRET_DIRS.has(e.name)) found.push(at);
+ else if (e.name === ".git") gitDirSecrets(at, home, git);
+ else if (e.name === ".hg") {
+ if (hgrcHasCredential(join(at, "hgrc"))) found.push(join(at, "hgrc"));
+ } else if (!SCAN_SKIPPED_DIRS.has(e.name)) {
+ if (++seen > limit) {
+ over = true;
+ break;
+ }
+ queue.push(at);
+ }
+ continue;
+ }
+ if (!e.isFile()) continue;
+ if (SECRET_FILE.test(e.name) || SECRET_SUFFIXES.some((s) => at.endsWith(s)) || secretByContent(e.name, at)) found.push(at);
+ }
+ }
+ if (over) out.over.push(root);
+ else {
+ // What git reads that is in the root (an include or a store elsewhere is hidden where the sandbox hides it).
+ const inRoot = (p: string) => within(resolve(p), top) || within(realOr(p), realOr(top));
+ out.hidden.push(...[...found, ...git.found.filter(inRoot)].filter((p, i, all) => !owned(p) && all.indexOf(p) === i));
+ out.gitConfigs.push(...git.configs.filter((p, i, all) => inRoot(p) && !owned(p) && all.indexOf(p) === i));
+ }
+ }
+ return out;
+}
+
+/** The repository a hidden git config belongs to (`/.git/config`, a bare one's, a submodule's or a worktree's), for what is said about it. */
+export function repositoryOfConfig(config: string): string {
+ const parts = config.split("/");
+ const i = parts.lastIndexOf(".git");
+ if (i > 0) return parts.slice(0, i).join("/") || "/";
+ return dirname(config);
+}
+
+/** Whether `p` is in one of `dirs` (as written or where it really is). */
+export function inAnyDir(p: string, dirs: readonly string[]): boolean {
+ const abs = resolve(p);
+ const real = realOr(abs);
+ return dirs.some((d) => within(abs, resolve(d)) || within(real, realOr(d)));
+}
diff --git a/src/daemon/sandbox/request-directory.ts b/src/daemon/sandbox/request-directory.ts
new file mode 100644
index 00000000..fe642f7b
--- /dev/null
+++ b/src/daemon/sandbox/request-directory.ts
@@ -0,0 +1,189 @@
+/**
+ * `request_directory`: how an agent asks for a directory to write besides its
+ * workspace (docs/sandbox.md, "Working across repositories"). The tool only
+ * asks; the session decides (Session#requestDirectory): in guarded and
+ * interactive mode its owner answers a confirm card naming the directory and
+ * the reason (owner-only, audited); in autonomous mode (an unattended run's
+ * too) a repository, or a dir in one, under the session's project roots,
+ * named by a path with no link in it, is added unasked (audited) and
+ * anything else is refused at once, with a reason the agent reads. Never
+ * parks an unattended run. What is added is the directory the path really
+ * is then, and takes effect at the session's next turn.
+ *
+ * Delivered the way codeoid's other session tools are: in-process (the
+ * Claude Agent SDK's server, which Qwen's SDK takes as is), and over the
+ * daemon's HTTP MCP endpoint {@link SANDBOX_MCP_PATH} for the backends that
+ * mount a URL (Codex, the Gemini CLI, opencode). A token there is one
+ * session's, minted for it and revoked with it: there is no parameter naming
+ * a session.
+ */
+
+import { randomUUID } from "node:crypto";
+import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk";
+import { z } from "zod";
+import { DEFAULT_MCP_PROTOCOL_VERSION, ok, rpcErr, tokenFrom, type JsonRpcMessage, type JsonRpcResponse } from "../mcp/jsonrpc-http.js";
+
+export const SANDBOX_MCP_SERVER_NAME = "codeoid_sandbox";
+export const REQUEST_DIRECTORY_TOOL = "request_directory";
+/** The tool's name as the Claude SDK exposes it. */
+export const REQUEST_DIRECTORY_TOOL_FQ = `mcp__${SANDBOX_MCP_SERVER_NAME}__${REQUEST_DIRECTORY_TOOL}`;
+/** Where the endpoint is mounted on the daemon's HTTP server. */
+export const SANDBOX_MCP_PATH = "/mcp/sandbox";
+/** The env var a URL-mounting backend reads the token from (Codex's `bearer_token_env_var`; out of its commands' environment: `CODEOID_*_TOKEN`). */
+export const SANDBOX_MCP_TOKEN_ENV = "CODEOID_SANDBOX_TOKEN";
+/**
+ * How long a call waits for its owner's answer before it returns "no answer
+ * yet" (the question stays open; a later yes still adds the directory):
+ * under the Gemini CLI's and Qwen's MCP call timeout (10 min); Codex's is
+ * raised past it (`tool_timeout_sec`).
+ */
+export const REQUEST_DIRECTORY_WAIT_MS = 8 * 60_000;
+/** The same on opencode, whose MCP client gives a call about a minute (the MCP SDK's default request timeout). */
+export const REQUEST_DIRECTORY_SHORT_WAIT_MS = 45_000;
+
+export interface RequestDirectoryInput {
+ path: string;
+ reason: string;
+}
+
+/** Decides a request for `sessionId`; resolves with the text the agent reads. Never rejects. */
+export type RequestDirectoryHandler = (sessionId: string, input: RequestDirectoryInput) => Promise;
+
+export const REQUEST_DIRECTORY_DESCRIPTION = [
+ "Ask for write access to a directory outside your workspace (another repository, say), for this session's sandboxed commands.",
+ "Your commands can already read the session's project roots; this is for writing. Give the absolute path and why you need it.",
+ "Interactive and guarded mode: the owner is asked and decides. Autonomous mode: a repository (or a directory in one) under the project roots, named by its real path (no symbolic link in it), is added at once; anything else is refused.",
+ "An added directory is writable from your next turn: finish this turn after it is granted. Never your home, /, a git or settings dir (.git, .claude, …), or a dir of credentials.",
+].join(" ");
+
+const REASON_MAX = 1000;
+
+function clean(input: Record): RequestDirectoryInput | string {
+ const path = typeof input.path === "string" ? input.path.trim() : "";
+ const reason = typeof input.reason === "string" ? input.reason.trim().slice(0, REASON_MAX) : "";
+ if (!path) return "Refused: give the directory's absolute path (`path`).";
+ if (path.length > 4096) return "Refused: that path is too long.";
+ if (!reason) return "Refused: say why you need to write there (`reason`): the owner reads it.";
+ return { path, reason };
+}
+
+/** The tool as an in-process MCP server bound to one session (Claude; Qwen's SDK takes the same shape). */
+export function buildSandboxMcpServer(sessionId: string, handle: RequestDirectoryHandler): McpSdkServerConfigWithInstance {
+ const request = tool(
+ REQUEST_DIRECTORY_TOOL,
+ REQUEST_DIRECTORY_DESCRIPTION,
+ {
+ path: z.string().min(1).max(4096).describe("The directory, absolute (~ expands)."),
+ reason: z.string().min(1).max(REASON_MAX).describe("Why you need to write there: the owner reads it."),
+ },
+ async (args) => {
+ const input = clean(args as Record);
+ const text = typeof input === "string" ? input : await handle(sessionId, input).catch((e) => `Refused: ${e instanceof Error ? e.message : String(e)}`);
+ return { content: [{ type: "text" as const, text }] };
+ },
+ );
+ return createSdkMcpServer({ name: SANDBOX_MCP_SERVER_NAME, version: "0.1.0", tools: [request] });
+}
+
+/** What a session hands a URL-mounting backend. */
+export interface SandboxMcpMount {
+ url: string;
+ token: string;
+}
+
+/** The tool's input as JSON Schema (the HTTP endpoint's `tools/list`, pi's bridge). */
+export const REQUEST_DIRECTORY_SCHEMA: Record = {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "The directory, absolute (~ expands)." },
+ reason: { type: "string", description: "Why you need to write there: the owner reads it." },
+ },
+ required: ["path", "reason"],
+ additionalProperties: false,
+};
+
+/** The daemon's `/mcp/sandbox` endpoint: one token per session, `request_directory` its one tool. */
+export class SandboxMcpHttp {
+ readonly #tokens = new Map();
+ readonly #handle: RequestDirectoryHandler;
+
+ constructor(handle: RequestDirectoryHandler) {
+ this.#handle = handle;
+ }
+
+ /** A token scoped to `sessionId`: the scope of every call made with it. */
+ mint(sessionId: string): string {
+ const token = `sbt_${randomUUID().replace(/-/g, "")}`;
+ this.#tokens.set(token, sessionId);
+ return token;
+ }
+
+ revoke(token: string): void {
+ this.#tokens.delete(token);
+ }
+
+ /** Every token of `sessionId` (its destroy). */
+ revokeSession(sessionId: string): void {
+ for (const [t, s] of this.#tokens) if (s === sessionId) this.#tokens.delete(t);
+ }
+
+ get activeTokens(): number {
+ return this.#tokens.size;
+ }
+
+ async handle(req: Request): Promise {
+ if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
+ const token = tokenFrom(req);
+ const sessionId = token ? this.#tokens.get(token) : undefined;
+ if (!sessionId) {
+ return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { "Content-Type": "application/json", "WWW-Authenticate": "Bearer" } });
+ }
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json(rpcErr(null, -32700, "Parse error"), { status: 400 });
+ }
+ const batch = Array.isArray(body);
+ const messages = (batch ? body : [body]) as JsonRpcMessage[];
+ const responses: JsonRpcResponse[] = [];
+ let sawInitialize = false;
+ for (const m of messages) {
+ if (m && m.method === "initialize") sawInitialize = true;
+ const res = await this.#dispatch(m, sessionId);
+ if (res) responses.push(res);
+ }
+ if (responses.length === 0) return new Response(null, { status: 202 });
+ const headers: Record = { "Content-Type": "application/json" };
+ if (sawInitialize && token) headers["Mcp-Session-Id"] = token;
+ return new Response(JSON.stringify(batch ? responses : responses[0]), { status: 200, headers });
+ }
+
+ async #dispatch(msg: JsonRpcMessage | null, sessionId: string): Promise {
+ if (msg?.id === undefined) return null;
+ const id = msg.id ?? null;
+ switch (msg.method) {
+ case "initialize": {
+ const requested = msg.params?.protocolVersion;
+ return ok(id, {
+ protocolVersion: typeof requested === "string" ? requested : DEFAULT_MCP_PROTOCOL_VERSION,
+ capabilities: { tools: {} },
+ serverInfo: { name: SANDBOX_MCP_SERVER_NAME, version: "0.1.0" },
+ });
+ }
+ case "ping":
+ return ok(id, {});
+ case "tools/list":
+ return ok(id, { tools: [{ name: REQUEST_DIRECTORY_TOOL, description: REQUEST_DIRECTORY_DESCRIPTION, inputSchema: REQUEST_DIRECTORY_SCHEMA }] });
+ case "tools/call": {
+ const name = msg.params?.name;
+ if (name !== REQUEST_DIRECTORY_TOOL) return ok(id, { content: [{ type: "text", text: `Unknown tool: ${String(name)}` }], isError: true });
+ const input = clean((msg.params?.arguments ?? {}) as Record);
+ const text = typeof input === "string" ? input : await this.#handle(sessionId, input).catch((e) => `Refused: ${e instanceof Error ? e.message : String(e)}`);
+ return ok(id, { content: [{ type: "text", text }], isError: false });
+ }
+ default:
+ return rpcErr(id, -32601, `Method not found: ${String(msg.method)}`);
+ }
+ }
+}
diff --git a/src/daemon/sandbox/root.ts b/src/daemon/sandbox/root.ts
index de6f5997..75cb78d5 100644
--- a/src/daemon/sandbox/root.ts
+++ b/src/daemon/sandbox/root.ts
@@ -18,6 +18,7 @@ import { join } from "node:path";
let root: string | null = null;
/** The directory (made on first use; made again, at the same path, if a temp cleaner removed it). */
+// paths: the daemon's private dir: made 0700 by it, exclusive, never a link, which no sandbox sees
export function sandboxRoot(): string {
if (!root) {
const made = mkdtempSync(join(tmpdir(), "codeoid-sandbox-"));
@@ -53,6 +54,7 @@ export function sandboxRootIfMade(): string | null {
* A fresh private dir of the daemon's own, `/XXXXXX`: under the
* root every sandbox hides, whenever it is made (a sign-in started after a
* session's sandbox was built is hidden from it too).
+ // paths: the daemon's private dir (sandboxRoot)
*/
export function daemonTempDir(prefix: string): string {
return mkdtempSync(join(sandboxRoot(), prefix));
@@ -63,6 +65,7 @@ export function daemonTempPath(name: string): string {
return join(sandboxRoot(), name);
}
+// paths: a look at the host's temp dir for daemons' private dirs, no write
/** Every daemon's root in `tmp` (this one's, another's, a killed one's): `codeoid-sandbox-*`. */
export function sandboxRootsIn(tmp: string = tmpdir()): string[] {
try {
diff --git a/src/daemon/sandbox/seatbelt.ts b/src/daemon/sandbox/seatbelt.ts
index 5617fa80..a7db09ea 100644
--- a/src/daemon/sandbox/seatbelt.ts
+++ b/src/daemon/sandbox/seatbelt.ts
@@ -29,6 +29,7 @@
import { realpathSync, statSync } from "node:fs";
import { LocalSandboxHandle } from "./local-handle.js";
+import { boundPath } from "./pinned.js";
import type { NetworkMode, SandboxCapabilities, SandboxHandle, SandboxProbe, SandboxProvider, SandboxSpawnOptions, SandboxSpec } from "./types.js";
export const SEATBELT_PROVIDER_ID = "seatbelt";
@@ -63,13 +64,16 @@ export function seatbeltProfile(
spec: SandboxSpec,
fs: { real?: (p: string) => string; isDir?: (p: string) => boolean | null } = {},
): SeatbeltProfile {
- const real = fs.real ?? ((p: string) => {
+ // Everything a rule names by path is pinned: named as it was pinned (the handle checked it is still that, and
+ // the launched argv checks again; #448 round 14), never resolved again here. Only what isn't is resolved.
+ // paths: resolves only what isn't pinned (boundPath): a hidden path, the home's allowlist
+ const real = boundPath(spec.pins, fs.real ?? ((p: string) => {
try {
return realpathSync(p);
} catch {
return p;
}
- });
+ }));
const isDir = fs.isDir ?? (() => null);
const params: Record = {};
const names = new Map();
@@ -114,7 +118,7 @@ export function seatbeltProfile(
// A repository's hooks and config: not writable, with everything in them.
for (const p of (spec.readOnly ?? []).map(real)) rules.push({ path: p, kind: "read-only" });
// Every dir between a writable root and a hidden path in it: the directory itself not writable (so no rename), its contents still.
- for (const p of (spec.pinned ?? []).map(real)) rules.push({ path: p, kind: "pinned" });
+ for (const p of [...(spec.pinned ?? []), ...(spec.heldInPlace ?? [])].map(real)) rules.push({ path: p, kind: "pinned" });
const rank = (r: Rule) => (r.kind === "hide" ? 0 : 1);
rules.sort((a, b) => depth(a.path) - depth(b.path) || rank(a) - rank(b));
@@ -227,6 +231,7 @@ export class SeatbeltSandboxProvider implements SandboxProvider {
async probe(): Promise {
if (process.platform !== "darwin") return { available: false, reason: "Seatbelt runs on macOS only" };
try {
+ // paths: the probe: a fixed argv, in no session's directory
const proc = Bun.spawn([this.#bin, "-p", "(version 1)(allow default)", "/usr/bin/true"], { stdin: "ignore", stdout: "ignore", stderr: "pipe", timeout: 10_000 });
const [code, err] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
if (code === 0) return { available: true, detail: this.#bin };
@@ -242,6 +247,7 @@ export class SeatbeltSandboxProvider implements SandboxProvider {
openSync(spec: SandboxSpec): SandboxHandle {
const bin = this.#bin;
+ // paths: a look (directory or file) for a rule's form, once the handle checked the pins
const isDir = (p: string): boolean | null => {
try {
return statSync(p).isDirectory();
@@ -253,6 +259,7 @@ export class SeatbeltSandboxProvider implements SandboxProvider {
if (spec.privateTemp) {
// The temp dir is made per program, in the spec's (a real path: Seatbelt sees `/private/…`).
let parent = spec.tempDir;
+ // paths: the session's temp dir: made by the daemon in its private dir, which no sandbox sees
try {
parent = realpathSync(parent);
} catch {
diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts
index dac39c8e..c6e197f6 100644
--- a/src/daemon/sandbox/session-sandbox.ts
+++ b/src/daemon/sandbox/session-sandbox.ts
@@ -13,7 +13,7 @@
* starts on it.
*/
-import { existsSync, lstatSync, mkdirSync, mkdtempSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import type { SessionSandboxInfo, SessionSandboxSetting } from "../../protocol/types.js";
@@ -22,8 +22,25 @@ import type { ClaudeTempDirs } from "./claude-native.js";
import { type ConfinementPlan, confinementFor, runsNoCommands } from "./confinement.js";
import { GIT_BIN } from "../git-env.js";
import { CWD_FROM_CALLER } from "./docker.js";
-import { buildSandboxSpec, dockerSocketPaths, gitLayoutOf, gitLayoutProblem, networkPolicyOf, type SandboxConfig, sandboxConfigOf, sandboxWorkspaceOf, unconnectableDockerSockets, validAllowedHost } from "./policy.js";
+import { buildSandboxSpec, dockerSocketPaths, gitLayoutOf, gitLayoutProblem, hiddenPathsFor, networkPolicyOf, type SandboxConfig, sandboxConfigOf, sandboxWorkspaceOf, unconnectableDockerSockets, validAllowedHost } from "./policy.js";
+import {
+ addedDirProblem,
+ addedDirReal,
+ linkInPath,
+ nestedControl,
+ normalizeSandboxDir,
+ PROJECT_ROOT_PARENT,
+ PROJECT_ROOT_SCAN_LIMIT,
+ projectRootSecretsAsync,
+ repositoryOfConfig,
+ type RootScan,
+ rootScanKey,
+ resolveProjectRoots,
+ SCAN_SKIPPED_DIRS,
+ stillRealDir,
+} from "./project-roots.js";
import { ENTRY_GRACE_MS, WorktreeEntryGuard, type WorktreeEntryFinding } from "./worktree-entries.js";
+import { inPinnedDirSync, type PinnedDir, pinDir, realPathOnce, removeEmptyDir, pinProblem, pinRecord, pinRefusal, pinsProblem, SandboxPinError, unguardedArgv } from "./pinned.js";
import { existingFolderName, mainCheckoutOf, sessionFolderName, worktreeFoldersOf, worktreeLocationDirs, worktreeRootNotes } from "./worktrees.js";
import type { SandboxRegistry } from "./registry.js";
import { cliWorktrees, gitProgramLocations, gitShimSpec, writeGitShim } from "./git-shim.js";
@@ -142,6 +159,12 @@ export interface EffectiveSandbox {
network: NetworkPolicy;
/** The host's container runtime socket is reachable (`sandbox.docker.socket` / the session's `dockerSocket`): root-equivalent. */
dockerSocket: boolean;
+ /** Project roots as set (`@parent`, or absolute with `~` expanded): readable (project-roots.ts). */
+ projectRoots: string[];
+ /** Directories the session writes besides its workspace, absolute. */
+ addedDirs: string[];
+ /** Those the agent added itself (an autonomous `request_directory`): a project root's credential hiding stays in them. */
+ agentDirs: string[];
}
/** The setting a session ends up with: its own fields over the config's. */
@@ -151,13 +174,88 @@ export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxS
...(setting?.network ? { mode: setting.network } : {}),
...(setting?.allowedHosts ? { allow: setting.allowedHosts } : {}),
});
- return { choice, network, dockerSocket: setting?.dockerSocket ?? config.docker.socket ?? false };
+ const home = process.env.HOME ?? homedir();
+ const roots = (setting?.projectRoots ?? config.projectRoots ?? [PROJECT_ROOT_PARENT]).map((r) => (r === PROJECT_ROOT_PARENT ? r : (normalizeSandboxDir(r, home) ?? r)));
+ const added = (setting?.addedDirs ?? []).map((d) => normalizeSandboxDir(d, home)).filter((d): d is string => d !== null);
+ const agent = new Set((setting?.agentDirs ?? []).map((d) => normalizeSandboxDir(d, home)));
+ return {
+ choice,
+ network,
+ dockerSocket: setting?.dockerSocket ?? config.docker.socket ?? false,
+ projectRoots: [...new Set(roots)],
+ addedDirs: [...new Set(added)],
+ agentDirs: [...new Set(added)].filter((d) => agent.has(d)),
+ };
+}
+
+/**
+ * `setting` with each added directory `before` didn't have kept by where it
+ * really is now, when it is granted (project-roots.ts `addedDirReal`), and
+ * pinned there (pinned.ts: what it is, `dev:ino`): what is bound is that
+ * directory, never where a link there leads later, nor another one put in
+ * its place. One already kept stays as it was (a link put there since is
+ * dropped at the next build, never followed). An added dir inside another
+ * one isn't kept twice (the outer holds it). The agent's own (`agentDirs`)
+ * are those still added.
+ */
+export function canonicalAddedDirs(setting: SessionSandboxSetting, before?: SessionSandboxSetting): SessionSandboxSetting {
+ if (!setting.addedDirs) {
+ if (!setting.agentDirs) return setting;
+ const { agentDirs: _a, ...rest } = setting;
+ return rest;
+ }
+ const had = new Set(before?.addedDirs ?? []);
+ const unique = [...new Set(setting.addedDirs.map((d) => (had.has(d) ? d : (addedDirReal(d) ?? d))))];
+ const inside = (d: string, o: string) => d !== o && d.startsWith(o.endsWith("/") ? o : `${o}/`);
+ const dirs = unique.filter((d) => !unique.some((o) => inside(d, o)));
+ const ids: Record = { ...(setting.pins?.ids ?? before?.pins?.ids ?? {}) };
+ // A dir no longer added has no pin; one granted now is pinned as it is now (a grant again is a grant anew).
+ for (const d of before?.addedDirs ?? []) if (!dirs.includes(d)) delete ids[d];
+ for (const d of dirs) {
+ if (had.has(d)) continue;
+ const record = pinRecord(d);
+ if (record) ids[d] = record;
+ else delete ids[d];
+ }
+ const agent = new Set(setting.agentDirs ?? []);
+ const agentDirs = dirs.filter((d) => agent.has(d));
+ const { agentDirs: _a, pins: _p, ...rest } = setting;
+ const pins = setting.pins ?? before?.pins;
+ return {
+ ...rest,
+ addedDirs: dirs,
+ ...(agentDirs.length > 0 ? { agentDirs } : {}),
+ ...(Object.keys(ids).length > 0 || pins ? { pins: { ...(pins ?? {}), ids } } : {}),
+ };
+}
+
+/** The setting's fields that only say which directories are readable or writable: a change to them alone is applied between turns, never restarting work (SessionSandbox.set). */
+const DIR_FIELDS = ["projectRoots", "addedDirs", "agentDirs"] as const;
+
+function withoutDirs(setting: SessionSandboxSetting | undefined): string {
+ // `pins` is what the daemon keeps of the directories, not a choice: no change of it is one of the setting.
+ const { projectRoots: _r, addedDirs: _a, agentDirs: _g, pins: _p, ...rest } = setting ?? {};
+ return JSON.stringify(Object.entries(rest).sort(([a], [b]) => a.localeCompare(b)));
+}
+
+function dirsChanged(before: SessionSandboxSetting | undefined, after: SessionSandboxSetting | undefined): boolean {
+ return DIR_FIELDS.some((k) => JSON.stringify(before?.[k] ?? null) !== JSON.stringify(after?.[k] ?? null));
+}
+
+/** Whether `before` → `after` changes only the readable roots or the added directories. */
+export function onlyDirsChanged(before: SessionSandboxSetting | undefined, after: SessionSandboxSetting | undefined): boolean {
+ return withoutDirs(before) === withoutDirs(after) && dirsChanged(before, after);
+}
+
+/** Whether `before` → `after` changes nothing but what the daemon keeps of the directories (their pins). */
+function sameSetting(before: SessionSandboxSetting | undefined, after: SessionSandboxSetting | undefined): boolean {
+ return withoutDirs(before) === withoutDirs(after) && !dirsChanged(before, after);
}
/** A setting in words, for a change's line and audit: "bwrap, network on, Docker access". */
export function describeSandbox(e: EffectiveSandbox): string {
if (e.choice === SANDBOX_OFF) return "off";
- return `${e.choice}, network ${e.network.mode}${e.network.mode === "allowlist" ? ` (${(e.network.allow ?? []).join(", ")})` : ""}${e.dockerSocket ? ", Docker access" : ""}`;
+ return `${e.choice}, network ${e.network.mode}${e.network.mode === "allowlist" ? ` (${(e.network.allow ?? []).join(", ")})` : ""}${e.dockerSocket ? ", Docker access" : ""}${e.addedDirs.length > 0 ? `, also writes ${e.addedDirs.join(", ")}` : ""}`;
}
/** Why a setting is malformed (an unknown provider, a host that isn't one), or null. */
@@ -167,6 +265,8 @@ export function sandboxSettingProblem(setting: SessionSandboxSetting, registry:
}
const bad = (setting.allowedHosts ?? []).filter((h) => !validAllowedHost(h));
if (bad.length > 0) return `not a host an allowlist can name: ${bad.slice(0, 3).join(", ")}`;
+ const notAbsolute = [...(setting.addedDirs ?? []), ...(setting.projectRoots ?? []).filter((r) => r !== PROJECT_ROOT_PARENT)].filter((d) => normalizeSandboxDir(d) === null);
+ if (notAbsolute.length > 0) return `not an absolute path: ${notAbsolute.slice(0, 3).join(", ")}`;
return null;
}
@@ -178,17 +278,21 @@ function networkWidth(mode: NetworkMode): number {
/**
* Whether moving from `before` to `after` lets commands do more: the sandbox
* turned off, the network wider, an allowlist with a host it didn't have,
- * the container runtime's socket let in (root on the machine). Switching
- * provider counts as widening (a different boundary).
+ * the container runtime's socket let in (root on the machine), a directory
+ * added to what commands write or a project root to what they read.
+ * Switching provider counts as widening (a different boundary).
*/
export function widens(
- before: { choice: string; network: NetworkPolicy; dockerSocket?: boolean },
- after: { choice: string; network: NetworkPolicy; dockerSocket?: boolean },
+ before: { choice: string; network: NetworkPolicy; dockerSocket?: boolean; projectRoots?: readonly string[]; addedDirs?: readonly string[] },
+ after: { choice: string; network: NetworkPolicy; dockerSocket?: boolean; projectRoots?: readonly string[]; addedDirs?: readonly string[] },
): boolean {
if (after.choice === SANDBOX_OFF && before.choice !== SANDBOX_OFF) return true;
if (before.choice === SANDBOX_OFF) return false;
if (after.choice !== before.choice) return true;
if (after.dockerSocket === true && before.dockerSocket !== true) return true;
+ // A directory written, or a root read, that wasn't: the owner's call (removing one narrows).
+ if ((after.addedDirs ?? []).some((d) => !(before.addedDirs ?? []).includes(d))) return true;
+ if ((after.projectRoots ?? []).some((r) => !(before.projectRoots ?? []).includes(r))) return true;
const b = networkWidth(before.network.mode);
const a = networkWidth(after.network.mode);
if (a !== b) return a > b;
@@ -214,6 +318,131 @@ export function workspaceProblem(workspace: string): string | null {
return resolve(workspace) === "/" ? "a session working in / can't be sandboxed (the whole filesystem would be its writable workspace) — give it a project directory" : null;
}
+/** What the limits line says of a session whose workspace is the home. */
+export const HOME_WORKSPACE_NOTE =
+ "the workspace is your home: commands can read and write all of it but what the sandbox hides (credential stores, the agents' logins, codeoid's own dirs) — only credentials are protected";
+
+/** What {@link sandboxDirsOf} gives back. */
+export interface SandboxDirs {
+ roots: string[];
+ added: string[];
+ notes: string[];
+ homeWorkspace: boolean;
+ dropped: Array<{ dir: string; why: string }>;
+ /** Pins taken now (pinned.ts), by real path: a root or added dir seen for the first time. */
+ pinned: Record;
+ /** The look through the roots for credential files, reused by every spec built from this resolution. */
+ scan?: RootScan;
+}
+
+/**
+ * A session's project roots and added directories for its sandbox's
+ * workspace `top` (project-roots.ts): what is used, and what isn't and why.
+ * An added dir inside the workspace is dropped (writable already); one that
+ * is no longer a directory at the real path it was granted at (a link put
+ * there, removed), another directory than the one pinned there
+ * (`opts.pins`, pinned.ts), or can't be one now, is `dropped`, said. A root
+ * the same, or too big to look through for credential files, isn't used,
+ * said, and so is a repository there whose git config is hidden for the
+ * credential in it (never the session's own repository's). A root or added
+ * dir with no pin is pinned now (`pinned`). The agent's own added dirs
+ * (`opts.agentDirs`) keep a root's credential hiding.
+ */
+export async function sandboxDirsOf(
+ top: string,
+ rootEntries: readonly string[],
+ addedDirs: readonly string[],
+ config?: CodeoidConfig,
+ opts: { pins?: Readonly>; agentDirs?: readonly string[]; scanLimit?: number } = {},
+): Promise {
+ const home = resolve(process.env.HOME ?? homedir());
+ const configDir = getConfigDir();
+ const pins = opts.pins ?? {};
+ const pinned: Record = {};
+ const homeWorkspace = resolve(top) === home;
+ const r = resolveProjectRoots(rootEntries, top, { home, configDir });
+ const notes = [...(homeWorkspace ? [HOME_WORKSPACE_NOTE] : []), ...(homeWorkspace ? [] : r.notes)];
+ const hidden = addedDirs.length > 0 ? hiddenPathsFor(config) : [];
+ const added: string[] = [];
+ const dropped: Array<{ dir: string; why: string }> = [];
+ for (const d of addedDirs) {
+ if (resolve(d) === resolve(top) || resolve(d).startsWith(`${resolve(top)}/`)) continue;
+ const stored = pins[resolve(d)];
+ const changed = stored ? pinProblem(d, "it", { stored }) : null;
+ const problem = changed
+ ? `it is no longer the directory it was when added: ${changed.replace(/^it \S+ /, "it ")}`
+ : stillRealDir(d)
+ ? addedDirProblem(d, { home, configDir, hidden })
+ : `it is no longer the directory it was when added (${linkInPath(d) ? `${linkInPath(d)} is a symbolic link now` : "removed, or not a directory"})`;
+ if (problem) {
+ notes.push(`added directory ${d}: ${problem}: not writable`);
+ dropped.push({ dir: d, why: problem });
+ continue;
+ }
+ added.push(resolve(d));
+ if (!stored) {
+ const record = pinRecord(d);
+ if (record) pinned[resolve(d)] = record;
+ }
+ }
+ let roots: string[] = [];
+ for (const root of homeWorkspace ? [] : r.roots) {
+ const stored = pins[root];
+ const changed = stored ? pinProblem(root, "project root", { stored }) : null;
+ if (changed) {
+ notes.push(`${changed}: not used — set the session's project roots again to use it as it is now`);
+ continue;
+ }
+ roots.push(root);
+ if (!stored) {
+ const record = pinRecord(root);
+ if (record) pinned[root] = record;
+ }
+ }
+ const agent = new Set((opts.agentDirs ?? []).map((d) => resolve(d)));
+ const agentAdded = added.filter((d) => agent.has(d));
+ let scan: RootScan | undefined;
+ if (roots.length > 0 || agentAdded.length > 0) {
+ // The session's own: its workspace, its repository's git dir (a fork's worktree uses the main checkout's), and
+ // what its owner added; the agent's own added dirs are looked through as a root is. Without holding the
+ // daemon's event loop; every spec built from this resolution reuses it.
+ const layout = gitLayoutOf(top, home);
+ const own = [top, ...(layout?.common ? [layout.common] : []), ...added.filter((d) => !agent.has(d))];
+ const scanned = [...roots, ...agentAdded];
+ const limit = opts.scanLimit ?? PROJECT_ROOT_SCAN_LIMIT;
+ const secrets = await projectRootSecretsAsync(scanned, own, limit);
+ scan = { key: rootScanKey(scanned, own), result: secrets };
+ for (const over of secrets.over) {
+ if (agent.has(over)) {
+ const why = `more than ${limit} directories to look through for credential files (${[...SCAN_SKIPPED_DIRS].slice(0, 2).join(", ")} and the like aside), which stay hidden in a directory the agent added itself`;
+ notes.push(`added directory ${over}: ${why}: not writable — the owner can add it (\`/sandbox add\`), or a narrower one`);
+ dropped.push({ dir: over, why });
+ continue;
+ }
+ notes.push(
+ over === r.defaultRoot
+ ? `no project root: the default, the repository's parent (${over}), holds more than ${limit} directories to look through for credential files (build outputs such as target/, build/, dist/ and vendor/ count; ${[...SCAN_SKIPPED_DIRS].slice(0, 3).join(", ")} and the like don't): not used, so other repositories aren't readable — set sandbox.projectRoots (or the session's own) to the repositories you need`
+ : `project root ${over}: more than ${limit} directories to look through for credential files (${[...SCAN_SKIPPED_DIRS].slice(0, 2).join(", ")} and the like aside): not used — name narrower roots in sandbox.projectRoots (or the session's own), the repositories you need`,
+ );
+ }
+ roots = roots.filter((x) => !secrets.over.includes(x));
+ for (const c of secrets.gitConfigs.slice(0, 5)) {
+ notes.push(`${c} holds a credential (a remote URL with a user or password in it, or an http extraheader): hidden, so git in ${repositoryOfConfig(c)} can't read its config from this session`);
+ }
+ if (secrets.gitConfigs.length > 5) notes.push(`and ${secrets.gitConfigs.length - 5} more repositories' git configs holding a credential: hidden`);
+ for (const d of dropped) {
+ const i = added.indexOf(d.dir);
+ if (i >= 0) added.splice(i, 1);
+ }
+ }
+ return { roots, added, notes, homeWorkspace, dropped, pinned, ...(scan ? { scan } : {}) };
+}
+
+/** The repositories an added dir holds besides its own (a folder of them): the end-of-turn git look covers each. */
+export function addedRepositories(addedDirs: readonly string[]): string[] {
+ return [...new Set(addedDirs.flatMap((d) => nestedControl(d).repos.filter((r) => resolve(r) !== resolve(d))))];
+}
+
/** What a setting resolves to for a backend now (no side effects beyond a cached probe). */
export async function resolveSandbox(opts: {
registry: SandboxRegistry;
@@ -222,8 +451,22 @@ export async function resolveSandbox(opts: {
backendId: string;
/** The session's working directory: `/` can't be a sandbox's workspace. */
workspace?: string;
-}): Promise<{ info: SessionSandboxInfo; provider: SandboxProvider | null; plan: ConfinementPlan | null; network: NetworkPolicy }> {
- const { choice, network, dockerSocket } = effectiveSetting(opts.config, opts.setting);
+ /** The daemon's whole config: what an added directory may not be in (its data dirs: policy.ts `hiddenPathsFor`). */
+ codeoidConfig?: CodeoidConfig;
+ /** The sandbox's workspace as pinned for the session (default: the workspace's, policy.ts `sandboxWorkspaceOf`). */
+ top?: string;
+}): Promise<{
+ info: SessionSandboxInfo;
+ provider: SandboxProvider | null;
+ plan: ConfinementPlan | null;
+ network: NetworkPolicy;
+ droppedDirs?: Array<{ dir: string; why: string }>;
+ /** Roots and added dirs pinned by this resolution (sandboxDirsOf). */
+ pinned?: Record;
+ /** The look through the roots for credential files (sandboxDirsOf), for the specs built from this resolution. */
+ rootScan?: RootScan;
+}> {
+ const { choice, network, dockerSocket, projectRoots, addedDirs, agentDirs } = effectiveSetting(opts.config, opts.setting);
const base = {
choice,
network: network.mode,
@@ -252,7 +495,7 @@ export async function resolveSandbox(opts: {
// A repository whose `.git` names a git dir codeoid can't verify: held read-only, said (policy.ts gitWorkspacePaths).
const gitNote = opts.workspace !== undefined && c.plan.kind !== "no-shell" ? gitLayoutProblem(opts.workspace) : null;
// A monorepo's package: what its commands may write is the whole repository (#448 round 9).
- const top = opts.workspace !== undefined && c.plan.kind !== "no-shell" ? sandboxWorkspaceOf(opts.workspace) : null;
+ const top = opts.workspace !== undefined && c.plan.kind !== "no-shell" ? (opts.top ?? sandboxWorkspaceOf(opts.workspace)) : null;
const topNote = top && opts.workspace !== undefined && top !== resolve(opts.workspace) ? `the workspace is the repository's top (${top}), which the working directory is inside: all of it is readable and writable, its git structure and every project settings dir on the way held read-only` : null;
// The container runtime's socket, when the owner let it in: said, and said when there is none to reach.
const home = process.env.HOME ?? homedir();
@@ -270,7 +513,12 @@ export async function resolveSandbox(opts: {
// `sandbox.worktreeRoots` refused here, or missing and not beside the repository (never made): said (worktrees.ts).
const layout = top !== null && opts.config.worktreeRoots.length > 0 ? gitLayoutOf(top) : null;
const rootNotes = layout ? worktreeRootNotes(layout, { roots: opts.config.worktreeRoots, configDir: getConfigDir() }) : [];
- const limits = [...c.plan.limits, ...(net.note ? [net.note] : []), ...(gitNote ? [gitNote] : []), ...(topNote ? [topNote] : []), ...(dockerNote ? [dockerNote] : []), ...rootNotes];
+ // Working across repositories: the project roots it reads, the directories it writes besides its workspace.
+ const dirs: SandboxDirs =
+ top !== null
+ ? await sandboxDirsOf(top, projectRoots, addedDirs, opts.codeoidConfig, { ...(opts.setting?.pins ? { pins: opts.setting.pins.ids } : {}), agentDirs })
+ : { roots: [], added: [], notes: [], homeWorkspace: false, dropped: [], pinned: {} };
+ const limits = [...c.plan.limits, ...(net.note ? [net.note] : []), ...(gitNote ? [gitNote] : []), ...(topNote ? [topNote] : []), ...(dockerNote ? [dockerNote] : []), ...rootNotes, ...dirs.notes];
const applied: NetworkPolicy = net.mode === "allowlist" ? network : { mode: net.mode };
return {
info: {
@@ -284,11 +532,17 @@ export async function resolveSandbox(opts: {
via: c.plan.via,
...(limits.length > 0 ? { limits } : {}),
...(dockerSocket ? { dockerSocket: true } : {}),
+ ...(dirs.roots.length > 0 ? { projectRoots: dirs.roots } : {}),
+ ...(dirs.added.length > 0 ? { addedDirs: dirs.added } : {}),
+ ...(dirs.homeWorkspace ? { homeWorkspace: true } : {}),
enforced: !net.widened,
},
provider,
plan: c.plan,
network: applied,
+ ...(dirs.dropped.length > 0 ? { droppedDirs: dirs.dropped } : {}),
+ ...(Object.keys(dirs.pinned).length > 0 ? { pinned: dirs.pinned } : {}),
+ ...(dirs.scan ? { rootScan: dirs.scan } : {}),
};
}
@@ -322,8 +576,21 @@ export class SessionSandbox {
#folders = new Set();
/** The repository's worktree entries as git makes them (#448 round 11, worktree-entries.ts). */
readonly #entries = new WorktreeEntryGuard();
+ /** The same for each added directory's repository that isn't the workspace's, by its git dir. */
+ readonly #addedEntries = new Map();
+ /** The project roots and added directories the last resolution applied (the runtime's: a change waits for the next). */
+ #dirs: { roots: string[]; added: string[]; scan?: RootScan } = { roots: [], added: [] };
+ #droppedDirs: Array<{ dir: string; why: string }> = [];
+ /** The setting's directories changed since the last resolution, nothing else (applied between turns: Session). */
+ #dirsStale = false;
+ /** A pinned directory was found changed (pinProblem): the next resolution between turns looks at them again. */
+ #pinsBroken = false;
+ /** Pins were taken since the setting was last saved (takePinsChanged). */
+ #pinsChanged = false;
/** The spec last built for the session: what its commands can write, for the entries' look. */
#lastSpec: SandboxSpec | null = null;
+ /** The spec last built under the running resolution: what the gate checks the pins of (pinProblem). */
+ #gateSpec: SandboxSpec | null = null;
#entriesRecheck: ReturnType | null = null;
readonly #onWarning: ((text: string) => void) | undefined;
@@ -404,12 +671,176 @@ export class SessionSandbox {
* backend is torn down (Session restarts it, idle: a running turn is never
* cut off by a change of setting) or the session closes.
*/
- async set(setting: SessionSandboxSetting | undefined): Promise {
- this.#setting = setting && Object.keys(setting).length > 0 ? { ...setting } : undefined;
- this.#stale = true;
+ async set(setting: SessionSandboxSetting | undefined, opts: { repinRoots?: boolean } = {}): Promise {
+ let next = setting && Object.keys(setting).length > 0 ? canonicalAddedDirs({ ...setting }, this.#setting) : undefined;
+ // The project roots set again (the owner's own): each pinned anew at the next resolution, as it is then.
+ if (opts.repinRoots && next?.pins) {
+ const keep = new Set([...(next.addedDirs ?? []), ...(next.pins.workspace ? [next.pins.workspace] : [])]);
+ next = { ...next, pins: { ...next.pins, ids: Object.fromEntries(Object.entries(next.pins.ids).filter(([k]) => keep.has(k))) } };
+ }
+ if (sameSetting(this.#setting, next)) {
+ if (opts.repinRoots) this.#dirsStale = true;
+ } else if (onlyDirsChanged(this.#setting, next) && !this.#stale) {
+ // Only which directories are read or written: the runtime stands until Session applies it between turns.
+ this.#dirsStale = true;
+ } else this.#stale = true;
+ this.#setting = next;
this.#generation += 1;
}
+ /** Whether pins were taken (or let go) since the last call: the setting holding them is saved then (Session). */
+ takePinsChanged(): boolean {
+ const changed = this.#pinsChanged;
+ this.#pinsChanged = false;
+ return changed;
+ }
+
+ /**
+ * The sandbox's workspace: the one pinned for this working directory (the
+ * repository's top in a monorepo, found once), else as found now.
+ */
+ #workspace(): string {
+ const pins = this.#setting?.pins;
+ if (pins?.workspace && pins.workdir === resolve(this.#workdir)) return pins.workspace;
+ return sandboxWorkspaceOf(this.#workdir);
+ }
+
+ /** The sandbox's workspace (the repository's top in a monorepo), as pinned for the session. */
+ get workspace(): string {
+ return this.#workspace();
+ }
+
+ /**
+ * Pin the workspace and the working directory when they aren't (a new
+ * session, or one from before pins: the working directory's identity from
+ * #448 round 14): as they are now, at their real paths.
+ */
+ #pinWorkspace(): void {
+ const pins = this.#setting?.pins;
+ const workdir = resolve(this.#workdir);
+ if (pins?.workspace && pins.workdir === workdir) {
+ if (pins.ids[workdir] || pins.workspace === workdir) return;
+ // Pinned before the working directory was: its identity now.
+ const record = pinRecord(workdir);
+ if (!record) return;
+ this.#setting = { ...(this.#setting ?? {}), pins: { ...pins, ids: { ...pins.ids, [workdir]: record } } };
+ this.#pinsChanged = true;
+ return;
+ }
+ const workspace = sandboxWorkspaceOf(workdir);
+ const record = pinRecord(workspace);
+ if (!record) return;
+ const ids = { ...(pins?.ids ?? {}) };
+ if (pins?.workspace && pins.workspace !== workspace) delete ids[pins.workspace];
+ if (pins?.workdir && pins.workdir !== workdir && pins.workdir !== workspace) delete ids[pins.workdir];
+ ids[workspace] = record;
+ const own = workdir === workspace ? record : pinRecord(workdir);
+ if (own) ids[workdir] = own;
+ this.#setting = { ...(this.#setting ?? {}), pins: { workdir, workspace, ids } };
+ this.#pinsChanged = true;
+ }
+
+ /**
+ * The working directory as the daemon uses it on the host (pinned.ts
+ * {@link PinnedDir}: checkpoints, going back a turn, a fork's files, the
+ * end-of-turn git looks), pinned when it wasn't yet. Throws
+ * {@link PinnedPathError} (for `op`) when it isn't the directory pinned.
+ */
+ workdirPin(op: string): PinnedDir {
+ this.#pinWorkspace();
+ const workdir = resolve(this.#workdir);
+ return pinDir(workdir, "the working directory", this.#setting?.pins?.ids[workdir] ?? null, op);
+ }
+
+ /** Keep what a resolution pinned (roots, added dirs seen for the first time). */
+ #keepPins(pinned: Record | undefined): void {
+ if (!pinned || Object.keys(pinned).length === 0 || this.#closed) return;
+ const pins = this.#setting?.pins ?? { ids: {} };
+ this.#setting = { ...(this.#setting ?? {}), pins: { ...pins, ids: { ...pins.ids, ...pinned } } };
+ this.#pinsChanged = true;
+ }
+
+ /**
+ * Why the running sandbox's directories aren't what was pinned (the
+ * workspace, the working directory, an added dir, a project root, a
+ * worktree folder: pinned.ts), in the sentence a refused call carries, or
+ * null. Asked at the gate before every tool call: what has changed is
+ * never run against, and is looked at again at the next resolution
+ * between turns (an added dir or root changed is left out then, said; a
+ * workspace changed keeps every call refused until it is put back).
+ */
+ pinProblem(): string | null {
+ if (this.#closed || !this.#runtime) return null;
+ const ids = this.#setting?.pins?.ids ?? {};
+ const workspace = this.#workspace();
+ const workdir = resolve(this.#workdir);
+ const problem =
+ pinProblem(workspace, "the workspace", { ...(ids[workspace] ? { stored: ids[workspace] } : { mayBeMissing: true }) }) ??
+ (workdir !== workspace ? pinProblem(workdir, "the working directory", ids[workdir] ? { stored: ids[workdir] } : { mayBeMissing: true }) : null) ??
+ this.#dirs.added.map((d) => pinProblem(d, "added directory", ids[d] ? { stored: ids[d] } : {})).find((p) => p !== null) ??
+ this.#dirs.roots.map((r) => pinProblem(r, "project root", ids[r] ? { stored: ids[r] } : {})).find((p) => p !== null) ??
+ // Everything else the running sandbox binds by name (#448 round 14: a dir held in place, a path held
+ // read-only, the repository's git dirs, a worktree folder or location): changed by another session's
+ // command or on the host, it is said here, and the next resolution looks again.
+ pinsProblem(this.#gateSpec?.pins);
+ if (!problem) return null;
+ this.#pinsBroken = true;
+ return pinRefusal(problem);
+ }
+
+ /**
+ * Whether the setting's directories (project roots, added dirs) changed
+ * since the last resolution: applied by the next {@link refresh}, which
+ * Session runs only between turns with no background work (a Claude query
+ * rebuilt mid-work would end it; a process backend is restarted).
+ */
+ get dirsPending(): boolean {
+ return this.#dirsStale || this.#pinsBroken;
+ }
+
+ /** The added directories the running sandbox has (SandboxSpec.addedDirs as resolved): what host-side file tools are held to. */
+ appliedAddedDirs(): string[] {
+ return [...this.#dirs.added];
+ }
+
+ /** The project roots the running sandbox reads. */
+ appliedProjectRoots(): string[] {
+ return [...this.#dirs.roots];
+ }
+
+ /** The added directories the last resolution left out, and why (project-roots.ts `stillRealDir`, `addedDirProblem`): Session says it, and drops them. */
+ droppedDirs(): Array<{ dir: string; why: string }> {
+ return [...this.#droppedDirs];
+ }
+
+ /**
+ * Take `dirs` out of the setting's added directories, as the last
+ * resolution already left them out (droppedDirs): nothing to apply, so the
+ * runtime stands; a dir there again later isn't added again unasked.
+ */
+ forget(dirs: readonly string[]): void {
+ if (!this.#setting?.addedDirs) return;
+ const gone = new Set(dirs.map((d) => resolve(d)));
+ const kept = this.#setting.addedDirs.filter((d) => !gone.has(resolve(normalizeSandboxDir(d) ?? d)));
+ if (kept.length === this.#setting.addedDirs.length) return;
+ const { addedDirs: _old, agentDirs, pins, ...rest } = this.#setting;
+ const agent = (agentDirs ?? []).filter((d) => kept.includes(d));
+ const ids = Object.fromEntries(Object.entries(pins?.ids ?? {}).filter(([k]) => !gone.has(k)));
+ const base: SessionSandboxSetting = { ...rest, ...(agent.length > 0 ? { agentDirs: agent } : {}), ...(pins ? { pins: { ...pins, ids } } : {}) };
+ this.#setting = kept.length > 0 ? { ...base, addedDirs: kept } : Object.keys(base).length > 0 ? base : undefined;
+ this.#droppedDirs = this.#droppedDirs.filter((d) => !gone.has(resolve(d.dir)));
+ }
+
+ /** The session's temp dir, once made (its commands write it): null before. */
+ get tempDirPath(): string | null {
+ return this.#tempDir;
+ }
+
+ /** The repositories the session writes besides its workspace: its added dirs, and every repository inside one. */
+ appliedRepositories(): string[] {
+ return [...new Set([...this.#dirs.added, ...addedRepositories(this.#dirs.added)])];
+ }
+
/**
* Resolve for `backendId` (a provider switch, a setting change, the first
* turn). One at a time; one that started before a {@link set} resolves
@@ -425,9 +856,22 @@ export class SessionSandbox {
let r: Awaited>;
for (;;) {
const generation = this.#generation;
- r = await resolveSandbox({ registry: this.#registry, config: this.config, setting: this.#setting, backendId, workspace: this.#workdir });
+ this.#pinWorkspace();
+ r = await resolveSandbox({
+ registry: this.#registry,
+ config: this.config,
+ setting: this.#setting,
+ backendId,
+ workspace: this.#workdir,
+ top: this.#workspace(),
+ ...(this.#config ? { codeoidConfig: this.#config } : {}),
+ });
if (generation === this.#generation) break;
}
+ this.#keepPins(r.pinned);
+ this.#pinsBroken = false;
+ // The spec the gate checks is the new resolution's, once built: the last one's pins are its handles' to check.
+ this.#gateSpec = null;
if (this.#closed) {
this.#runtime = null;
return r.info;
@@ -435,6 +879,10 @@ export class SessionSandbox {
this.#info = r.info;
this.#resolvedFor = backendId;
this.#stale = false;
+ this.#dirsStale = false;
+ const dirs = { roots: [...(r.info.projectRoots ?? [])], added: [...(r.info.addedDirs ?? [])], ...(r.rootScan ? { scan: r.rootScan } : {}) };
+ this.#dirs = dirs;
+ this.#droppedDirs = r.droppedDirs ?? [];
if (r.info.state !== "on" || !r.provider || !r.plan) {
this.#runtime = null;
return r.info;
@@ -448,7 +896,7 @@ export class SessionSandbox {
const hideLocalServices = (r.info.networkRequested ?? r.info.network) !== "on";
const open = (backendState: readonly string[] = [], o: OpenOptions = {}) => {
if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`);
- return this.#spec(o.backendNetwork ? { mode: "on" } : r.network, backendState, o.readable ?? [], dockerSocket, hideLocalServices);
+ return this.#spec(o.backendNetwork ? { mode: "on" } : r.network, backendState, o.readable ?? [], dockerSocket, hideLocalServices, dirs);
};
// Built (and the temp dir made) when a backend first needs it, then kept: a worktree added or removed in the
// repository never rebuilds it (#448 round 11: that would rebuild a Claude query, its background tasks with it).
@@ -475,6 +923,7 @@ export class SessionSandbox {
claudeTemp: () => this.#ensureClaudeTemp(),
gitShim: (caller) => {
// Checked at each query: one removed meanwhile (a temp cleaner) is written again rather than leave the CLI the host's git.
+ // paths: a look at the shim in the session's launcher dir (the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees))
if (!gitShim || !existsSync(join(gitShim, "git"))) gitShim = this.#writeGitShim(provider, caller);
return gitShim;
},
@@ -493,6 +942,7 @@ export class SessionSandbox {
return this.#sessionId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32) || "session";
}
+ // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
#privateHome(key: string): string {
if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`);
if (!this.#homesDir) this.#homesDir = mkdtempSync(join(sandboxRoot(), `home-${this.#sessionSlug()}-`));
@@ -503,6 +953,7 @@ export class SessionSandbox {
}
/** The session's state root: under the transcripts dir when there is one, else (an embedder, tests) with its private homes. */
+ // paths: the session's state dir: the daemon's own, in its transcripts dir (which every sandbox hides); a backend writes inside it, never replaces it (each dir it is given is a mount point there)
#stateRoot(): string {
const dir = this.#config?.transcriptDir;
const safe = this.#sessionId.replace(/[^A-Za-z0-9_-]/g, "") || "session";
@@ -511,6 +962,7 @@ export class SessionSandbox {
return join(this.#homesDir, ".state");
}
+ // paths: the session's state dir: the daemon's own, in its transcripts dir (which every sandbox hides); a backend writes inside it, never replaces it (each dir it is given is a mount point there)
#stateDir(key: string): string {
if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`);
const dir = join(this.#stateRoot(), key.replace(/[^A-Za-z0-9_-]/g, "") || "backend");
@@ -519,10 +971,12 @@ export class SessionSandbox {
}
/** Remove what the session's backends kept across starts (with the session: its destroy). */
+ // paths: the session's state dir: the daemon's own, in its transcripts dir (which every sandbox hides); a backend writes inside it, never replaces it (each dir it is given is a mount point there)
removeState(): void {
rmSync(this.#stateRoot(), { recursive: true, force: true });
}
+ // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
#ensureClaudeTemp(): ClaudeTempDirs {
if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`);
if (!this.#claudeTemp) {
@@ -536,6 +990,7 @@ export class SessionSandbox {
return this.#claudeTemp;
}
+ // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
#ensureLauncherDir(): string {
if (!this.#launcherDir) this.#launcherDir = mkdtempSync(`${launcherRoot()}${this.#sessionSlug()}-`);
return this.#launcherDir;
@@ -551,6 +1006,7 @@ export class SessionSandbox {
if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`);
const launcherDir = this.#ensureLauncherDir();
// Gone (a temp cleaner, #448 round 5): made again, so a CLI started now finds the shim, not the host's git.
+ // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
mkdirSync(launcherDir, { recursive: true, mode: 0o700 });
const dir = join(launcherDir, "git-bin");
let launcher: string | null = null;
@@ -560,12 +1016,15 @@ export class SessionSandbox {
const handle = this.#track(provider.openSync(spec));
// In the caller's own directory, or not at all: one the sandbox hides isn't swapped for another (bwrap's `$HOME`).
launcher = this.#writeLauncher(handle, [GIT_BIN], [], "git", CWD_FROM_CALLER);
- program = handle.wrap?.([GIT_BIN], { cwd: "", env: {} }).argv[0] ?? null;
+ // The provider's own program (past the pin guard's shell): when it is gone, git runs defused.
+ // paths: no cwd: the wrapped argv's program only
+ program = handle.wrap ? (unguardedArgv(handle.wrap([GIT_BIN], { cwd: "", env: {} }).argv)[0] ?? null) : null;
}
return writeGitShim(dir, { launcher, provider: program, caller, home: homedir() });
}
/** Where the shim's git gets a fresh temp dir per run where the provider needs a real one (Seatbelt): its own, not the session's. */
+ // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
#ensureGitTempDir(): string {
if (!this.#gitTempDir) this.#gitTempDir = mkdtempSync(join(sandboxRoot(), `git-tmp-${this.#sessionSlug()}-`));
mkdirSync(this.#gitTempDir, { recursive: true, mode: 0o700 });
@@ -580,6 +1039,7 @@ export class SessionSandbox {
const wrapped = handle.wrap(argv, { cwd, env: Object.fromEntries(envNames.map((n) => [n, ""])) });
const path = join(launcherDir, `launch-${name ? name.replace(/[^A-Za-z0-9_-]/g, "") : ++this.#launchers}`);
// Written beside it and renamed over it: a caller running it meanwhile gets the old one or the new one, whole.
+ // paths: the session's launcher dir (the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)): written beside the launcher and renamed over it
const part = `${path}.${++this.#launchers}.part`;
writeFileSync(part, launcherScript(wrapped.argv), { mode: 0o700 });
renameSync(part, path);
@@ -598,13 +1058,24 @@ export class SessionSandbox {
return this.#handles.size;
}
- #spec(network: NetworkPolicy, backendState: readonly string[], readable: readonly string[] = [], dockerSocket = false, hideLocalServices = network.mode !== "on"): SandboxSpec {
- // A monorepo's package: the repository's top (#448 round 9); the session's cwd stays the workdir.
- const workspace = sandboxWorkspaceOf(this.#workdir);
- const folder = this.#worktreeFolderName(workspace);
- // The repository's worktree entries looked at first: the spec holds every one the session didn't make.
- if (folder) this.#checkEntries();
- const spec = buildSandboxSpec({
+ #spec(
+ network: NetworkPolicy,
+ backendState: readonly string[],
+ readable: readonly string[] = [],
+ dockerSocket = false,
+ hideLocalServices = network.mode !== "on",
+ dirs: { roots: readonly string[]; added: readonly string[]; scan?: RootScan } = this.#dirs,
+ ): SandboxSpec {
+ // A monorepo's package: the repository's top (#448 round 9), as pinned; the session's cwd stays the workdir.
+ const workspace = this.#workspace();
+ const folder = this.#worktreeFolderName(workspace, dirs.added);
+ // The repositories' worktree entries looked at first: the spec holds every one the session didn't make.
+ if (folder) {
+ this.#checkEntries();
+ for (const d of dirs.added) this.#checkEntriesOf(d);
+ }
+ const agent = new Set(this.effective().agentDirs);
+ const build = () => buildSandboxSpec({
backendReadable: readable,
sessionId: this.#sessionId,
workspace,
@@ -616,12 +1087,42 @@ export class SessionSandbox {
...(folder ? { worktreeFolder: folder, ownWorktreeEntries: this.#entries.ownEntries() } : {}),
dockerSocket,
hideLocalServices,
+ projectRoots: dirs.roots,
+ addedDirs: dirs.added.map((dir) => {
+ const guard = this.#guardFor(dir);
+ return { dir, ...(agent.has(dir) ? { agent: true } : {}), ...(folder && guard ? { ownWorktreeEntries: guard.ownEntries() } : {}) };
+ }),
+ ...(this.#setting?.pins ? { pins: this.#setting.pins.ids } : {}),
+ ...(dirs.scan ? { rootScan: dirs.scan } : {}),
});
+ let spec: SandboxSpec;
+ try {
+ spec = build();
+ } catch (err) {
+ // A pinned directory changed (pinned.ts): refused, and looked at again at the next resolution.
+ if (err instanceof SandboxPinError) this.#pinsBroken = true;
+ throw err;
+ }
for (const f of spec.worktreeFolders ?? []) this.#folders.add(f);
this.#lastSpec = spec;
+ this.#gateSpec = spec;
return spec;
}
+ /** The worktree-entry guard of an added dir's repository: the workspace's own when it is the same repository; null outside one. */
+ #guardFor(dir: string): WorktreeEntryGuard | null {
+ const common = gitLayoutOf(dir)?.common;
+ if (!common) return null;
+ const mine = gitLayoutOf(this.#workspace())?.common;
+ if (mine && resolve(mine) === resolve(common)) return this.#entries;
+ let g = this.#addedEntries.get(resolve(common));
+ if (!g) {
+ g = new WorktreeEntryGuard();
+ this.#addedEntries.set(resolve(common), g);
+ }
+ return g;
+ }
+
/**
* Look at the repository's worktree entries now (#448 round 11,
* worktree-entries.ts): what isn't as git makes it is restored where that
@@ -631,30 +1132,39 @@ export class SessionSandbox {
*/
checkWorktreeEntries(): void {
if (this.#closed || !this.#runtime) return;
- if (!this.#worktreeFolderName(sandboxWorkspaceOf(this.#workdir))) return;
+ if (!this.#worktreeFolderName(this.#workspace(), this.#dirs.added)) return;
this.#checkEntries();
+ for (const d of this.#dirs.added) this.#checkEntriesOf(d);
}
#checkEntries(): void {
+ this.#checkEntriesOf(this.#workspace(), this.#entries);
+ }
+
+ /** {@link checkWorktreeEntries} for the repository `workspace` is in (the workspace's, or an added dir's), with its guard. */
+ #checkEntriesOf(workspace: string, guard: WorktreeEntryGuard | null = this.#guardFor(workspace)): void {
try {
- const workspace = sandboxWorkspaceOf(this.#workdir);
const layout = gitLayoutOf(workspace);
const folder = this.#folderName;
- if (!layout?.common || !folder) return;
+ if (!layout?.common || !folder || !guard) return;
const opts = { roots: this.config.worktreeRoots, configDir: getConfigDir() };
const main = mainCheckoutOf(layout);
const last = this.#lastSpec;
// Before the first spec, the folders as they are (nothing is made for a look).
const folders = last ? [...(last.worktreeFolders ?? [])] : worktreeFoldersOf(layout, folder, { ...opts, workspace, make: false }).folders;
const dot = main ? join(main, ".worktrees") : null;
- const { findings, pending } = this.#entries.check({
- common: layout.common,
+ // In the repository's git dir as it is now, pinned for the look (pinned.ts, #448 round 14): what the guard
+ // restores is written there, never where a link put in its place leads; changed meanwhile, it is refused.
+ const commonDir = layout.common;
+ const common = pinDir(realPathOnce(commonDir), "the repository's git dir", null, "the look at its worktree entries");
+ const { findings, pending } = inPinnedDirSync(common, "the look at its worktree entries", () => guard.check({
+ common: commonDir,
main,
ownFolders: folders,
writable: [workspace, ...(this.#tempDir ? [this.#tempDir] : []), ...folders, ...(last?.writable ?? [])],
readOnly: last?.readOnly ?? [],
locations: [...worktreeLocationDirs(layout, opts).locations.map((l) => l.dir), ...(dot ? [dot] : [])],
- });
+ }));
if (pending && !this.#entriesRecheck) {
// An entry git may still be making: looked at once it is done.
this.#entriesRecheck = setTimeout(() => {
@@ -675,14 +1185,16 @@ export class SessionSandbox {
* need them there). Never through a link (worktrees.ts `worktreeFoldersOf`).
*/
prepareFolders(): void {
+ // paths: a look (are the folders there): they are made through makeAt (worktrees.ts)
if (this.#closed || this.#folders.size === 0 || [...this.#folders].every((f) => existsSync(f))) return;
- try {
- const workspace = sandboxWorkspaceOf(this.#workdir);
- const layout = gitLayoutOf(workspace);
- if (!layout || !this.#folderName) return;
- worktreeFoldersOf(layout, this.#folderName, { roots: this.config.worktreeRoots, configDir: getConfigDir(), workspace });
- } catch {
- /* a folder that can't be made: a worktree add there fails, as outside one */
+ for (const workspace of [this.#workspace(), ...this.#dirs.added]) {
+ try {
+ const layout = gitLayoutOf(workspace);
+ if (!layout || !this.#folderName) continue;
+ worktreeFoldersOf(layout, this.#folderName, { roots: this.config.worktreeRoots, configDir: getConfigDir(), workspace });
+ } catch {
+ /* a folder that can't be made: a worktree add there fails, as outside one */
+ }
}
}
@@ -695,14 +1207,8 @@ export class SessionSandbox {
*/
tidyFolders(): void {
for (const f of this.#folders) {
- for (const dir of [f, dirname(f)]) {
- try {
- if (!lstatSync(dir).isDirectory()) break;
- rmdirSync(dir);
- } catch {
- break;
- }
- }
+ // Empty ones only, never through a link (pinned.ts, #448 round 14): the folder, then its location.
+ for (const dir of [f, dirname(f)]) if (!removeEmptyDir(dir)) break;
}
}
@@ -712,11 +1218,12 @@ export class SessionSandbox {
* or a daemon restart keeps it), else its name and short id. Null outside
* a repository codeoid can verify.
*/
- #worktreeFolderName(workspace: string): string | null {
+ #worktreeFolderName(workspace: string, added: readonly string[] = []): string | null {
if (this.#folderName) return this.#folderName;
- const layout = gitLayoutOf(workspace);
- if (!layout?.common) return null;
- const { locations } = worktreeLocationDirs(layout, { roots: this.config.worktreeRoots, configDir: getConfigDir() });
+ // The workspace's repository first, then each added dir's: one name in every repository the session writes.
+ const layouts = [workspace, ...added].map((d) => gitLayoutOf(d)).filter((l) => l?.common);
+ if (layouts.length === 0) return null;
+ const locations = layouts.flatMap((l) => (l ? worktreeLocationDirs(l, { roots: this.config.worktreeRoots, configDir: getConfigDir() }).locations : []));
this.#folderName = existingFolderName(locations, this.#sessionId) ?? sessionFolderName(this.#name(), this.#sessionId);
return this.#folderName;
}
@@ -735,6 +1242,7 @@ export class SessionSandbox {
}
/** The session's private temp dir (made on first use, 0700). */
+ // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
#ensureTempDir(): string {
if (!this.#tempDir) this.#tempDir = mkdtempSync(join(sandboxRoot(), `tmp-${this.#sessionSlug()}-`));
return this.#tempDir;
@@ -752,10 +1260,12 @@ export class SessionSandbox {
this.#closed = true;
this.#runtime = null;
await this.#closeHandles();
+ // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
if (this.#tempDir) {
rmSync(this.#tempDir, { recursive: true, force: true });
this.#tempDir = null;
}
+ // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
for (const dir of [this.#launcherDir, this.#homesDir, this.#gitTempDir, this.#claudeTemp ? join(this.#claudeTemp.cli, "..") : null]) {
if (dir) rmSync(dir, { recursive: true, force: true });
}
@@ -858,6 +1368,7 @@ export async function openCheckSandbox(opts: {
if (rootProblem) return { kind: "refused", reason: rootProblem };
const mode: NetworkMode = provider.capabilities.network.includes(network.mode) ? network.mode : "off";
const safe = opts.label.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32) || "check";
+ // paths: a check's temp dir: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
const tempDir = mkdtempSync(join(sandboxRoot(), `check-${safe}-`));
let handle: SandboxHandle;
try {
@@ -873,11 +1384,13 @@ export async function openCheckSandbox(opts: {
});
handle = await provider.open(spec);
} catch (err) {
+ // paths: a check's temp dir: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
// Nothing opened: nothing of it is left behind.
rmSync(tempDir, { recursive: true, force: true });
throw err;
}
if (!handle.wrap) {
+ // paths: a check's temp dir: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
await handle.close();
rmSync(tempDir, { recursive: true, force: true });
return { kind: "refused", reason: `${provider.displayName} can't run a local command` };
@@ -885,6 +1398,7 @@ export async function openCheckSandbox(opts: {
return {
kind: "on",
wrap: handle.wrap.bind(handle),
+ // paths: a check's temp dir: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)
close: async () => {
await handle.close();
rmSync(tempDir, { recursive: true, force: true });
diff --git a/src/daemon/sandbox/types.ts b/src/daemon/sandbox/types.ts
index 01337e1c..ec71b082 100644
--- a/src/daemon/sandbox/types.ts
+++ b/src/daemon/sandbox/types.ts
@@ -19,6 +19,7 @@
*/
import type { ChildProcessWithoutNullStreams } from "node:child_process";
+import type { PathPin } from "./pinned.js";
/** How strong the boundary is: OS namespaces/profiles, a separate kernel (gVisor), a VM. */
export type IsolationTier = "os" | "kernel" | "hardware";
@@ -116,6 +117,17 @@ export interface SandboxSpec {
* `~/.config/gh` out from under the rule that hides it.
*/
pinned?: readonly string[];
+ /**
+ * Directories held in place as {@link pinned} ones are (a mount point, a
+ * denied rename), what is in them writable, but never read-only on
+ * Claude's own sandbox (whose denial takes a subtree; its CLI binds the
+ * working directory's ancestors itself): the working directory and each
+ * dir between it and the workspace, and each dir between a writable root
+ * and a path held read-only in it (#448 round 14). Moved aside, each
+ * would take what is held in it along and leave its place to a planted
+ * copy (a package's `.claude/settings.json`).
+ */
+ heldInPlace?: readonly string[];
/**
* The session's own folder in each of the repository's worktree locations
* (in {@link writable} too): where a command checks a new worktree out
@@ -125,6 +137,32 @@ export interface SandboxSpec {
* writable roots).
*/
worktreeFolders?: readonly string[];
+ /**
+ * The session's project roots (`sandbox.projectRoots`, policy.ts
+ * `resolveProjectRoots`): readable whole, read-only, with what the hidden
+ * list hides in them hidden again (in {@link reexposed} too). Other
+ * repositories, other sessions' checkouts.
+ */
+ projectRoots?: readonly string[];
+ /**
+ * Directories the session was given to write besides its workspace
+ * (`SessionSandboxSetting.addedDirs`): each treated as a workspace (in
+ * {@link writable}, its git structure and project settings in
+ * {@link readOnly}, its repository's worktree folder in
+ * {@link worktreeFolders}). What a backend's own sandbox must also let it
+ * write (Codex's writable roots).
+ */
+ addedDirs?: readonly string[];
+ /**
+ * Every path bound by name (the workspace, the working directory, added
+ * directories, project roots, worktree folders and locations, the
+ * session's writable and temp dirs, the paths held read-only, the dirs
+ * held in place), each with what it was when the spec was built
+ * (pinned.ts, #448 rounds 13 and 14): checked again at every launch, which
+ * is refused when one is reached through a link or is another directory
+ * now. A provider binds each by this path, never resolves it again.
+ */
+ pins?: readonly PathPin[];
/** Container runtime sockets (and their dirs): never reachable, they are root-equivalent. */
containerSockets: readonly string[];
/**
diff --git a/src/daemon/sandbox/worktree-entries.ts b/src/daemon/sandbox/worktree-entries.ts
index 884f84be..d50507ff 100644
--- a/src/daemon/sandbox/worktree-entries.ts
+++ b/src/daemon/sandbox/worktree-entries.ts
@@ -100,6 +100,7 @@ interface EntryRecord {
config: string;
}
+// paths: for comparing git's records with paths, in a look; no write
function realOr(p: string): string {
try {
return realpathSync(p);
@@ -112,6 +113,7 @@ function within(child: string, parent: string): boolean {
return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`);
}
+// paths: a look, no write
function lexists(p: string): boolean {
try {
lstatSync(p);
@@ -122,6 +124,7 @@ function lexists(p: string): boolean {
}
/** A small regular file's text (never through a link): `missing`, `file` with its text, or `other` (a link, a dir, too big). */
+// paths: a look, no write
function readSmall(p: string): { kind: "missing" } | { kind: "file"; text: string } | { kind: "other"; what: string } {
let st: ReturnType;
try {
@@ -174,6 +177,7 @@ function samePath(a: string, b: string): boolean {
}
/** `info/` holding only `sparse-checkout`: what git makes for a sparse worktree. */
+// paths: a look, no write
function onlySparse(info: string): boolean {
try {
if (!lstatSync(info).isDirectory()) return false;
@@ -224,6 +228,7 @@ function copiesMain(text: string, common: string): boolean {
return true;
}
+// paths: a look, no write
function readlinkOr(p: string): string {
try {
return readlinkSync(p);
@@ -235,6 +240,7 @@ function readlinkOr(p: string): string {
let replaceSeq = 0;
/** Replace `p` with a regular file holding `content` (atomically: never written through a link). Whether it was. */
+// paths: inside the repository's git dir, which the caller pins and checks before and after (SessionSandbox#checkEntriesOf); written beside the file (O_EXCL) and renamed over it, never through a link
function replaceFile(p: string, content: string): boolean {
const part = `${p}.codeoid-${process.pid}-${++replaceSeq}`;
try {
@@ -269,6 +275,7 @@ function writableBy(ctx: WorktreeEntryContext): (p: string) => boolean {
function scanClaims(worktreesReal: string, seeds: readonly string[], roots: ReadonlyArray<{ dir: string; depth: number }>): Map {
const out = new Map();
const seen = new Set();
+ // paths: a look, no write
const visit = (dotgit: string): boolean => {
let st: ReturnType;
try {
@@ -295,6 +302,7 @@ function scanClaims(worktreesReal: string, seeds: readonly string[], roots: Read
// A checkout: its own `.git` is what counts, nothing deeper.
if (visit(join(dir, ".git")) && depth > 0) continue;
if (depth === r.depth) continue;
+ // paths: a look, no write
let names: string[] = [];
try {
names = readdirSync(dir).sort();
@@ -362,6 +370,7 @@ export class WorktreeEntryGuard {
const firstLook = !this.#looked;
this.#looked = true;
const dir = join(common, "worktrees");
+ // paths: a look at the repository's worktrees dir, no write
let names: string[] = [];
try {
// A linked `worktrees/` makes the git dir read-only whole (policy.ts): nothing of it is the session's to make.
@@ -405,6 +414,7 @@ export class WorktreeEntryGuard {
const other = namedEntry(rec.dotgit, worktreesReal);
if (!other || other === name || this.#records.has(other) || !names.includes(other)) continue;
const otherNamed = entryGitdir(join(dir, other));
+ // paths: a look, no write
let otherKey = "";
try {
const o = lstatSync(join(dir, other));
@@ -422,6 +432,7 @@ export class WorktreeEntryGuard {
seen.add(name);
const at = join(dir, name);
const atReal = join(worktreesReal, name);
+ // paths: a look, no write
let st: ReturnType;
try {
st = lstatSync(at);
@@ -433,6 +444,7 @@ export class WorktreeEntryGuard {
this.#records.delete(name);
const target = readlinkOr(at);
let removed = false;
+ // paths: a link in the repository's worktrees dir (the git dir pinned by the caller): unlink removes the link itself, never what it leads to
try {
unlinkSync(at);
removed = true;
@@ -599,6 +611,7 @@ export class WorktreeEntryGuard {
}
/** The repository's dir for what is taken out of its worktree entries: made 0700, never through a link; null when it can't be. */
+// paths: in the repository's git dir (pinned by the caller): made 0700, used only when a directory and not a link
function quarantineDir(common: string): string | null {
const dir = join(common, QUARANTINE_DIR);
try {
@@ -615,6 +628,7 @@ function quarantineDir(common: string): string | null {
}
/** Move `src` (a file, a dir, a link) into the quarantine dir; where it went, or null. */
+// paths: an entry moved aside within the repository's git dir (pinned by the caller): a rename never follows a link
function quarantine(common: string, src: string, label: string): string | null {
const dir = quarantineDir(common);
if (!dir) return null;
@@ -633,6 +647,7 @@ function quarantine(common: string, src: string, label: string): string | null {
}
/** Keep `content` in the quarantine dir; where, or null. */
+// paths: in the quarantine dir (O_EXCL), in the git dir pinned by the caller
function keepAside(common: string, label: string, content: string): string | null {
const dir = quarantineDir(common);
if (!dir) return null;
diff --git a/src/daemon/sandbox/worktrees.ts b/src/daemon/sandbox/worktrees.ts
index 02c68896..46c27f35 100644
--- a/src/daemon/sandbox/worktrees.ts
+++ b/src/daemon/sandbox/worktrees.ts
@@ -30,15 +30,17 @@
* its own output, like the workspace.
*/
-import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync } from "node:fs";
+import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
+import { makeAt } from "./pinned.js";
import type { GitLayout } from "./policy.js";
function within(child: string, parent: string): boolean {
return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`);
}
+// paths: a repository's layout, found once at a spec build (a link from the main checkout's parent down is refused: `linked`); what it gives is pinned by the spec, and made only through makeAt
function realOr(p: string): string {
try {
return realpathSync(p);
@@ -47,6 +49,7 @@ function realOr(p: string): string {
}
}
+// paths: a look (is it a link: then refused), no write
function isLink(p: string): boolean {
try {
return lstatSync(p).isSymbolicLink();
@@ -56,6 +59,7 @@ function isLink(p: string): boolean {
}
/** A dir of the daemon user's own, not a link. */
+// paths: a look, no write
function ownDir(p: string): boolean {
try {
const st = lstatSync(p);
@@ -289,7 +293,8 @@ export function worktreeFoldersOf(
const mk = (p: string): void => {
if (!make) return;
try {
- mkdirSync(p, { mode: 0o755 });
+ // Never through a link (pinned.ts: its parent pinned for the make, checked again after; #448 round 14).
+ makeAt(p, "dir", "making a worktree folder");
} catch {
/* there already, or can't be made: then only an existing one is used */
}
@@ -343,6 +348,7 @@ export function existingFolderName(locations: readonly WorktreeLocation[], sessi
const suffix = `-${sessionShortId(sessionId)}`;
for (const loc of locations) {
let names: string[] = [];
+ // paths: a look (which folder names a location holds), no write
try {
names = readdirSync(loc.dir).filter((n) => n.endsWith(suffix) && n.length > suffix.length);
} catch {
@@ -375,6 +381,7 @@ export interface WorktreeAdmin {
export function worktreeAdminsOf(common: string, limit = 500): WorktreeAdmin[] {
const dir = join(common, "worktrees");
let names: string[] = [];
+ // paths: a look at the repository's worktree admin dirs, no write
try {
names = readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory())
diff --git a/src/daemon/server.ts b/src/daemon/server.ts
index a06ff9ff..fe05f58d 100644
--- a/src/daemon/server.ts
+++ b/src/daemon/server.ts
@@ -8,6 +8,7 @@
* - ZeroID JWT verification on every connection
*/
+import { SANDBOX_MCP_PATH } from "./sandbox/request-directory.js";
import type { SandboxRegistry } from "./sandbox/registry.js";
import { randomUUID } from "node:crypto";
import { homedir } from "node:os";
@@ -628,6 +629,8 @@ export class DaemonServer {
this.#manager.setBlackboardUrl(
`http://127.0.0.1:${this.#config.port}${BLACKBOARD_MCP_PATH}`,
);
+ // The sandbox's `request_directory` (sandbox/request-directory.ts): loopback too.
+ this.#manager.setSandboxMcpUrl(`http://127.0.0.1:${this.#config.port}${SANDBOX_MCP_PATH}`);
this.#manager.setMcp(this.#mcpRegistry, this.#mcpHub, mcpOAuth);
const mcpCount = this.#mcpRegistry.list().filter((s) => !s.builtin).length;
if (mcpCount > 0) console.log(`[codeoid] mcp: ${mcpCount} external server(s) registered`);
@@ -686,6 +689,13 @@ export class DaemonServer {
return self.#manager.blackboardMcp.handle(req);
}
+ // The sandbox's `request_directory` (sandbox/request-directory.ts): a session's token is its scope, and only
+ // the agents on this host (loopback peers, whatever the daemon binds) reach it.
+ if (url.pathname === SANDBOX_MCP_PATH) {
+ if (!isLoopbackAddress(server.requestIP(req)?.address)) return new Response("Forbidden", { status: 403 });
+ return self.#manager.sandboxMcp.handle(req);
+ }
+
// OAuth MCP relay (#396): Codex, the Gemini CLI and Qwen mount OAuth
// servers here with a per-session proxy token. Loopback peers only,
// whatever the daemon binds: the agents run on this host.
diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts
index b28660d3..7dedb143 100644
--- a/src/daemon/session-manager.ts
+++ b/src/daemon/session-manager.ts
@@ -8,6 +8,8 @@
* - Graceful drain on shutdown
*/
+import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js";
+import { type RequestDirectoryInput, type SandboxMcpMount, SandboxMcpHttp } from "./sandbox/request-directory.js";
import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve, sep } from "node:path";
@@ -137,13 +139,15 @@ import type { DispatchEventRow, DispatchTaskRow } from "./store.js";
import { type MemoryEngine, type MemoryMcpMount, workspaceIdFromPath } from "./memory/index.js";
import type { McpRegistry } from "./mcp/registry.js";
import type { SandboxRegistry } from "./sandbox/registry.js";
-import { effectiveSetting, openCheckSandbox, resolveSandbox, sandboxRefusalOf, sandboxSettingProblem, widens } from "./sandbox/session-sandbox.js";
-import { sandboxConfigOf } from "./sandbox/policy.js";
+import { canonicalAddedDirs, effectiveSetting, openCheckSandbox, resolveSandbox, sandboxRefusalOf, sandboxSettingProblem, widens } from "./sandbox/session-sandbox.js";
+import { checkPinned, type PinnedDir, PinnedPathError } from "./sandbox/pinned.js";
+import { sandboxRootIfMade } from "./sandbox/root.js";
+import { hiddenPathsFor, sandboxConfigOf } from "./sandbox/policy.js";
import { redactCommand } from "./skill-command.js";
import type { McpHub } from "./mcp/hub.js";
import { mcpOAuthBindingCookieName, type McpOAuth, type McpOAuthProof, type McpTenant } from "./mcp/oauth.js";
import { isOAuthServer, type McpServerSpec, OAUTH_UNSUPPORTED_BACKENDS } from "./mcp/types.js";
-import { atomicWrite, type CodeoidConfig, DEFAULT_PROVIDER_ENV, loadConfig, mutateConfigFile, validateConfigObject } from "../config.js";
+import { atomicWrite, type CodeoidConfig, DEFAULT_PROVIDER_ENV, getConfigDir, loadConfig, mutateConfigFile, validateConfigObject } from "../config.js";
import type { CompressionRegistry } from "./compress/index.js";
import { endpointNameOf, endpointProviderId, ORCHESTRATOR_ROLE, type ProviderInfo } from "../protocol/types.js";
import type {
@@ -427,6 +431,10 @@ export class SessionManager {
/** Goal-blackboard MCP endpoint + the loopback URL children mount it from.
* Always constructed: with no minted tokens every request fails closed. */
readonly #blackboardMcp = new BlackboardMcpHttp();
+ /** The sandbox's `request_directory` for the URL-mounting backends (sandbox/request-directory.ts): one token per session. */
+ readonly #sandboxMcp = new SandboxMcpHttp((sessionId, input) => this.#requestDirectoryFor(sessionId, input));
+ #sandboxMcpUrl?: string;
+ readonly #sandboxMcpTokens = new Map();
#blackboardUrl?: string;
/** Per-goal artifact store, lazily built on the shared DB connection. */
#blackboard?: Blackboard;
@@ -993,6 +1001,8 @@ export class SessionManager {
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
accounts: this.#accounts,
claudeAccount: meta.claudeAccount ?? null,
@@ -1373,6 +1383,8 @@ mcpHub: this.#mcpHub,
return this.#loopWorkerGuard(msg, "change the mode of") ?? this.#setMode(msg, auth);
case "session.set_sandbox":
return this.#loopWorkerGuard(msg, "change the sandbox of") ?? this.#setSandbox(msg, auth);
+ case "session.sandbox_dir":
+ return this.#loopWorkerGuard(msg, "change the sandbox of") ?? this.#sandboxDir(msg, auth);
case "sandbox.providers":
return this.#sandboxProviders(msg, auth);
case "session.pin":
@@ -1771,6 +1783,8 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
...(this.#identityManager
@@ -1846,7 +1860,7 @@ mcpHub: this.#mcpHub,
};
}
try {
- const snapshot = await readClaudeConfig(session.workdir);
+ const snapshot = await readClaudeConfig(session.pinnedWorkdir("reading the project's MCP config").path);
// Read for the drawer only — what a session applies changes on its first
// turn or a refresh. Freshly, before the rows: a row and its fingerprint
// are of the files as they are now.
@@ -3605,7 +3619,8 @@ mcpHub: this.#mcpHub,
}
if (session.filesPending) return this.#stillPreparing(msg.id);
try {
- return await handleFsRead(msg, session.workdir);
+ // In the working directory as pinned (#448 round 14): one swapped for a link is never read through.
+ return await handleFsRead(msg, session.pinnedWorkdir("reading a file for a client").path);
} catch (err) {
return this.#fsErr(msg.id, err);
}
@@ -3887,6 +3902,54 @@ mcpHub: this.#mcpHub,
this.#blackboardUrl = url;
}
+ /** The URL backends mount the sandbox's `request_directory` from (loopback, as the blackboard's). */
+ setSandboxMcpUrl(url: string): void {
+ this.#sandboxMcpUrl = url;
+ }
+
+ get sandboxMcp(): SandboxMcpHttp {
+ return this.#sandboxMcp;
+ }
+
+ /** A session's `/mcp/sandbox` mount: its own token, minted once. None without a sandbox or the endpoint. */
+ readonly #sandboxMcpFor = (sessionId: string): SandboxMcpMount | undefined => {
+ if (!this.#sandboxMcpUrl || !this.#sandboxRegistry) return undefined;
+ let token = this.#sandboxMcpTokens.get(sessionId);
+ if (!token) {
+ token = this.#sandboxMcp.mint(sessionId);
+ this.#sandboxMcpTokens.set(sessionId, token);
+ }
+ return { url: this.#sandboxMcpUrl, token };
+ };
+
+ /**
+ * The checkouts every other session's commands write (its workspace, its
+ * worktree folders, its added directories): an autonomous
+ * `request_directory` never touches one, the owner's card says it
+ * (#448 round 13, project-roots.ts `checkoutOverlap`).
+ */
+ readonly #sandboxCheckoutsFor = (sessionId: string): OtherCheckout[] => {
+ const out: OtherCheckout[] = [];
+ for (const s of this.#sessions.values()) {
+ if (s.id === sessionId) continue;
+ out.push({ dir: s.sandboxWorkspace, what: `session "${s.name}"'s workspace` });
+ for (const f of s.sandboxWorktreeFolders()) out.push({ dir: f, what: `session "${s.name}"'s worktree folder` });
+ for (const d of s.sandboxAddedDirs()) out.push({ dir: d, what: `a directory session "${s.name}" writes` });
+ }
+ return out;
+ };
+
+ /** A `request_directory` call over `/mcp/sandbox`: the session decides it. A gone session's token is let go. */
+ async #requestDirectoryFor(sessionId: string, input: RequestDirectoryInput): Promise {
+ const session = this.#sessions.get(sessionId);
+ if (!session) {
+ this.#sandboxMcp.revokeSession(sessionId);
+ this.#sandboxMcpTokens.delete(sessionId);
+ return "Refused: this session is gone.";
+ }
+ return session.requestDirectory(input);
+ }
+
/** Inject the cross-backend MCP registry + daemon-owned client pool. Sessions
* hand both to every provider so the registry's servers mount on all backends. */
setMcp(registry: McpRegistry, hub: McpHub, oauth?: McpOAuth): void {
@@ -4334,11 +4397,13 @@ mcpHub: this.#mcpHub,
if (!this.#sandboxRegistry) {
return { type: "response.error", requestId: msg.id, error: "This daemon has no OS sandbox", code: "invalid_request" };
}
- const problem = sandboxSettingProblem(msg.sandbox, this.#sandboxRegistry) ?? (await this.#sandboxUnavailable(msg.sandbox));
+ const problem = sandboxSettingProblem(msg.sandbox, this.#sandboxRegistry) ?? this.#sandboxDirsProblem(msg.sandbox) ?? (await this.#sandboxUnavailable(msg.sandbox));
if (problem) return { type: "response.error", requestId: msg.id, error: problem, code: "invalid_request" };
const refused = this.#widerSandboxRefusal(msg.sandbox, auth, "create a session");
if (refused) return { type: "response.error", requestId: msg.id, error: refused, code: "forbidden" };
}
+ // Each added dir kept by where it really is now: what is bound, never where a link there leads later.
+ const sandboxSetting = msg.sandbox ? canonicalAddedDirs(msg.sandbox) : undefined;
let goalSessionId = "";
const orchestratorFleet = collaboration
@@ -4363,7 +4428,9 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
- ...(msg.sandbox && Object.keys(msg.sandbox).length > 0 ? { sandbox: msg.sandbox } : {}),
+ sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
+ ...(sandboxSetting && Object.keys(sandboxSetting).length > 0 ? { sandbox: sandboxSetting } : {}),
claudeAccounts: this.#claudeAccounts,
// The Claude choice also when the session lands elsewhere: it applies if it is switched to Claude.
claudeAccount: msg.claudeAccount || null,
@@ -4758,6 +4825,8 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
// The collaboration runs on the accounts its lead was created on.
claudeAccount: parent.claudeAccount,
@@ -5072,21 +5141,30 @@ mcpHub: this.#mcpHub,
if (branch) worktree = { path: bound, branch, createdByCodeoid: false };
} else if (msg.isolate !== false || msg.baseBranch) {
// baseBranch implies isolation (a base needs its own worktree).
- if (await isGitRepo(parent.workdir)) {
+ // The parent's working directory as pinned (#448 round 14): its files are what the fork carries, never what a
+ // link put in its place leads to; checked again once the worktree is made.
+ let parentPin: PinnedDir;
+ try {
+ parentPin = parent.pinnedWorkdir("forking it");
+ } catch (err) {
+ if (!(err instanceof PinnedPathError)) throw err;
+ return { type: "response.error", requestId: msg.id, error: err.message, code: "invalid_request" };
+ }
+ if (await isGitRepo(parentPin.path)) {
try {
// From an earlier turn: start the worktree at the commit the repo
// was on then (recorded with the snapshot), so git history matches
// the files rolled in below; make sure the parent's subdirectory
// exists so the snapshot lands where it belongs.
let rollHead = point?.files && "checkpointId" in point.files ? point.files.head : undefined;
- recipe = await loadSetupRecipe(parent.workdir);
+ recipe = await loadSetupRecipe(parentPin.path);
// Whether the command can run at all is known only once the base
// commit is checked out (commandGate): create clean whenever a
// trusted recipe has one, then decide.
const mayRun = !!recipe && !("error" in recipe) && !!recipe.recipe.command && commandRefusal(policy, recipe.root, recipe.recipeHash, auth.sub) === null;
const make = (baseCommit?: string) =>
createForkWorktree({
- workdir: parent.workdir,
+ workdir: parentPin.path,
label: msg.name ?? parentInfo.name,
shortId: worktreeShortId,
...(msg.baseBranch ? { baseBranch: msg.baseBranch } : {}),
@@ -5103,6 +5181,7 @@ mcpHub: this.#mcpHub,
headGone = true;
return make();
});
+ checkPinned(parentPin, "forking it");
// Fork runs in the parent's equivalent subdir of the worktree; the
// worktree ROOT (wt.path) is what we remove on destroy.
// Same checks as any session's workdir (safe-root, protected dirs),
@@ -5177,6 +5256,8 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
// A fork continues the same conversation — on the same accounts,
// thinking as hard (#417).
@@ -5433,6 +5514,8 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -5721,6 +5804,8 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -6433,12 +6518,14 @@ mcpHub: this.#mcpHub,
// The worker runs unattended: refused here when its sandbox can't hold (#448).
if (msg.sandbox) {
if (!this.#sandboxRegistry) return err("This daemon has no OS sandbox", "invalid_request");
- const problem = sandboxSettingProblem(msg.sandbox, this.#sandboxRegistry);
+ const problem = sandboxSettingProblem(msg.sandbox, this.#sandboxRegistry) ?? this.#sandboxDirsProblem(msg.sandbox);
if (problem) return err(problem, "invalid_request");
const wider = this.#widerSandboxRefusal(msg.sandbox, auth, "start a loop");
if (wider) return err(wider, "forbidden");
}
- const sandboxRefused = await this.#unattendedSandboxRefusal(provider, msg.sandbox);
+ // Each added dir kept by where it really is now.
+ const loopSandbox = msg.sandbox ? canonicalAddedDirs(msg.sandbox) : undefined;
+ const sandboxRefused = await this.#unattendedSandboxRefusal(provider, loopSandbox);
if (sandboxRefused) return err(`The loop can't start: ${sandboxRefused}`, "invalid_request");
const run = svc.startRun(
{
@@ -6447,7 +6534,7 @@ mcpHub: this.#mcpHub,
checks: msg.checks,
provider,
...(msg.model ? { model: msg.model } : {}),
- ...(msg.sandbox ? { sandbox: msg.sandbox } : {}),
+ ...(loopSandbox ? { sandbox: loopSandbox } : {}),
...(msg.toolBudget !== undefined ? { toolBudget: msg.toolBudget } : {}),
...(msg.limits ? { limits: msg.limits } : {}),
},
@@ -6732,6 +6819,8 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -6832,6 +6921,8 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -6960,7 +7051,13 @@ mcpHub: this.#mcpHub,
providerIds: () => this.#providers.ids(),
...(this.#sandboxRegistry
? {
- sandboxProblem: (s: SessionSandboxSetting) => sandboxSettingProblem(s, this.#sandboxRegistry as SandboxRegistry),
+ // A routine's directories checked when it is defined, as a session's are when it is created (#448 round 13).
+ sandboxProblem: (s: SessionSandboxSetting) => sandboxSettingProblem(s, this.#sandboxRegistry as SandboxRegistry) ?? this.#sandboxDirsProblem(s),
+ sandboxCanonical: (s: SessionSandboxSetting) => {
+ // By real path; never a pin (each run's session pins what it binds when it starts).
+ const { pins: _p, agentDirs: _a, ...rest } = canonicalAddedDirs(s);
+ return rest;
+ },
}
: {}),
normalizeWorkdir: (p) => normalizeWorkdir(p),
@@ -7118,6 +7215,8 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -7993,7 +8092,14 @@ mcpHub: this.#mcpHub,
);
if (badModel) return refuse(`Model "${badModel.model}" isn't available on ${badModel.providerId}`);
// Agents sharing one folder would edit each other's files.
- if (!(await isGitRepo(parent.workdir))) {
+ let parentDir: string;
+ try {
+ parentDir = parent.pinnedWorkdir("comparing backends from it").path;
+ } catch (err) {
+ if (!(err instanceof PinnedPathError)) throw err;
+ return refuse(err.message);
+ }
+ if (!(await isGitRepo(parentDir))) {
return refuse("Compare needs a git repository: each branch runs in its own worktree, so they can't edit each other's files.");
}
const label = (t: { providerId: string; model?: string }) => `${t.providerId}${t.model ? `:${t.model}` : ""}`;
@@ -9040,6 +9146,55 @@ mcpHub: this.#mcpHub,
return { type: "response.ok", requestId: msg.id, data: { ...(result.pending ? { pending: true } : {}), sandbox: session.sandboxInfo } };
}
+ /**
+ * Why a setting's directories can't be used (docs/sandbox.md, "Working
+ * across repositories"), or null: a project root refused
+ * (project-roots.ts `projectRootProblem`: the home, `/`, the host's temp
+ * dir, …), an added dir that can't be one (`addedDirProblem`: the home,
+ * `/`, a hidden dir, a git or project settings dir or one in it, one the
+ * sandbox hides, one holding a PATH entry, one that isn't there, one too
+ * big to look through for the repositories in it).
+ */
+ #sandboxDirsProblem(setting: SessionSandboxSetting): string | null {
+ const configDir = getConfigDir();
+ for (const r of setting.projectRoots ?? []) {
+ if (r === PROJECT_ROOT_PARENT) continue;
+ const problem = projectRootProblem(r, { configDir });
+ if (problem) return `not a project root: ${problem}`;
+ }
+ const hidden = (setting.addedDirs ?? []).length > 0 ? [...hiddenPathsFor(this.#config), ...daemonSandboxRoot()] : [];
+ for (const d of setting.addedDirs ?? []) {
+ const problem = addedDirProblem(d, { configDir, hidden });
+ if (problem) return `can't add ${d}: ${problem}`;
+ }
+ return null;
+ }
+
+ /**
+ * `session.sandbox_dir`: add a directory to what a session's commands
+ * write, or remove one. Owner only, with session:approve; adding is a
+ * widening, so never a delegated token's, and asked of the owner on the
+ * session (owner-only confirm) before it lands.
+ */
+ async #sandboxDir(msg: Extract, auth: AuthContext): Promise {
+ if (!hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE)) {
+ return { type: "response.error", requestId: msg.id, error: "Missing scope: session:approve", code: "forbidden" };
+ }
+ const session = this.#getOwnedSession(msg.sessionId, auth);
+ if (!session) return { type: "response.error", requestId: msg.id, error: "Session not found", code: "not_found" };
+ if (session.createdBy !== auth.sub) {
+ return { type: "response.error", requestId: msg.id, error: "Only the session's owner can change the directories its sandbox writes", code: "forbidden" };
+ }
+ if (msg.action === "add" && auth.delegationDepth > 0) {
+ this.#store.audit(auth.sub, "session.sandbox_dir_refused", session.id, `${msg.path}: delegated token`);
+ return { type: "response.error", requestId: msg.id, error: "A delegated token cannot add a directory to a session's sandbox", code: "forbidden" };
+ }
+ if (!this.#sandboxRegistry) return { type: "response.error", requestId: msg.id, error: "This daemon has no OS sandbox", code: "invalid_request" };
+ const result = await session.changeSandboxDir(msg.action, msg.path, auth);
+ if (!result.ok) return { type: "response.error", requestId: msg.id, error: result.error, code: "invalid_request" };
+ return { type: "response.ok", requestId: msg.id, data: { ...(result.pending ? { pending: true } : {}), message: result.message, sandbox: session.sandboxInfo } };
+ }
+
/** `sandbox.providers` (#448): what the daemon has and whether each runs here. */
async #sandboxProviders(msg: Extract, auth: AuthContext): Promise {
if (!hasScope(auth.scopes as string[], SCOPES.SESSION_CREATE)) {
@@ -9066,7 +9221,7 @@ mcpHub: this.#mcpHub,
requestId: msg.id,
data: {
enabled: registry !== undefined,
- default: { provider: config.provider, network: config.network, allowedHosts: config.allowedHosts, dockerSocket: config.docker.socket },
+ default: { provider: config.provider, network: config.network, allowedHosts: config.allowedHosts, dockerSocket: config.docker.socket, projectRoots: config.projectRoots ?? ["@parent"] },
auto: registry?.autoId() ?? null,
providers,
},
@@ -9165,7 +9320,13 @@ mcpHub: this.#mcpHub,
}
const session = this.#getOwnedSession(msg.sessionId, auth);
if (!session) return fail("Session not found", "not_found");
- const read = await readWorkdirServers(session.workdir);
+ let pinned: string;
+ try {
+ pinned = session.pinnedWorkdir("reading the project's MCP config").path;
+ } catch (err) {
+ return fail(err instanceof Error ? err.message : String(err));
+ }
+ const read = await readWorkdirServers(pinned);
if (!read) return fail("Claude's config can't be read just now (it may be mid-write) — try again");
if (read.repoUnreadable) return fail("The project's .mcp.json can't be read just now (mid-edit, not JSON, or too big) — try again once it can");
const repo = read.servers.filter((w) => w.origin === "claude-repo" && w.name === msg.server && (msg.path === undefined || w.path === msg.path));
@@ -9234,7 +9395,13 @@ mcpHub: this.#mcpHub,
}
const session = this.#getOwnedSession(msg.sessionId, auth);
if (!session) return fail("Session not found", "not_found");
- const read = await readWorkdirServers(session.workdir);
+ let pinned: string;
+ try {
+ pinned = session.pinnedWorkdir("reading the project's MCP config").path;
+ } catch (err) {
+ return fail(err instanceof Error ? err.message : String(err));
+ }
+ const read = await readWorkdirServers(pinned);
if (!read) return fail("Claude's config can't be read just now (it may be mid-write) — try again");
if (read.repoUnreadable) return fail("The project's .mcp.json can't be read just now (mid-edit, not JSON, or too big) — try again once it can");
const repo = read.servers.filter(
@@ -9480,3 +9647,9 @@ async function resolveImportPath(
function tenantOf(auth: AuthContext): McpTenant {
return { accountId: auth.accountId, projectId: auth.projectId };
}
+
+/** codeoid's private dir (sandbox/root.ts), when made: no added dir is in it. */
+function daemonSandboxRoot(): string[] {
+ const root = sandboxRootIfMade();
+ return root ? [root] : [];
+}
diff --git a/src/daemon/session.ts b/src/daemon/session.ts
index 08de8b1c..17e1cf98 100644
--- a/src/daemon/session.ts
+++ b/src/daemon/session.ts
@@ -99,6 +99,7 @@ import {
numstatTrees,
restoreTree,
} from "./checkpoints.js";
+import { type PinnedDir, PinnedPathError, pinDir, pinRecord } from "./sandbox/pinned.js";
import { applyRewinds, REWIND_EVENT, type TurnIndexEntry } from "./transcript.js";
import type { EffortChangeResult, EffortLevel, RewindFiles, RewindIrreversible, TurnSummary } from "../protocol/types.js";
import { LIMITS } from "../protocol/types.js";
@@ -168,7 +169,7 @@ import {
type MemoryEngine,
type MemoryMcpMount,
} from "./memory/index.js";
-import { isElicitationTool, isSafeTool, isUnresolvedPathTool, roleDeniesTool } from "./providers/tool-safety.js";
+import { isElicitationTool, isRequestDirectoryTool, isSafeTool, isUnresolvedPathTool, roleDeniesTool } from "./providers/tool-safety.js";
import type { PackActivation } from "./pipeline/index.js";
import { McpRegistry } from "./mcp/registry.js";
import { mcpSpecKey, SessionMcpView, type WorkdirReach } from "./mcp/session-view.js";
@@ -183,6 +184,23 @@ const RESEED_AFTER_TEARDOWN = new Set(["gemini-cli"]);
* releases it when idle, so the next turn starts one on the new credential.
* The rest re-read their account every turn (accounts.ts).
*/
+/** What an approval card adds of the sandbox's directories (docs/sandbox.md, "Working across repositories"): "; reads R (read-only); also writes D". */
+function cardDirsNote(info: SessionSandboxInfo): string {
+ const parts = [
+ ...(info.homeWorkspace ? ["workspace is your home: only credentials are protected"] : []),
+ ...(info.projectRoots?.length ? [`reads ${info.projectRoots.join(", ")} (read-only)`] : []),
+ ...(info.addedDirs?.length ? [`also writes ${info.addedDirs.join(", ")}`] : []),
+ ];
+ return parts.length > 0 ? `; ${parts.join("; ")}` : "";
+}
+
+/** The directories a resolved sandbox reads and writes besides its workspace, in words. */
+function sandboxDirsLine(info: SessionSandboxInfo | null): string {
+ const added = info?.addedDirs ?? [];
+ const roots = info?.projectRoots ?? [];
+ return `writes ${added.length > 0 ? `the workspace and ${added.join(", ")}` : "the workspace"}; reads ${roots.length > 0 ? roots.join(", ") : "no project root"}`;
+}
+
const PROCESS_BACKENDS: ReadonlySet = new Set(["claude", "codex", "gemini-cli", "qwen", "pi"]);
/** Statuses the idle countdown (#451) runs in: nothing in flight until the next message. */
@@ -213,7 +231,10 @@ import { CLAUDE_ALL_SOCKETS_NOTE, describeSandbox, resolveSandbox, SandboxRequir
import { type WorktreeEntryFinding, worktreeEntryLine, worktreeEntryWarning } from "./sandbox/worktree-entries.js";
import { type GitControlSnapshot, gitControlChanges, gitControlSnapshot, gitControlWarning, refTips, settingsCommits, settingsCommitWarning } from "./sandbox/git-watch.js";
import { worktreeFolderNote } from "./sandbox/worktrees.js";
-import { protectedWriteTargets, userHiddenPaths } from "./sandbox/policy.js";
+import { hiddenPathsFor, protectedWriteTargets, userHiddenPaths } from "./sandbox/policy.js";
+import { addedDirProblem, addedDirReal, autonomousGrantProblem, checkoutOverlap, inAnyDir, nestedControl, normalizeSandboxDir, type OtherCheckout } from "./sandbox/project-roots.js";
+import { sandboxRootIfMade } from "./sandbox/root.js";
+import { REQUEST_DIRECTORY_SHORT_WAIT_MS, REQUEST_DIRECTORY_WAIT_MS, type RequestDirectoryInput, type SandboxMcpMount } from "./sandbox/request-directory.js";
import {
CLAUDE_PROVIDER_ID,
findModel,
@@ -450,6 +471,8 @@ export interface SessionCreateOptions {
backgroundWakeFallbackMs?: number;
/** Override for FOREIGN_STOP_GRACE_MS (tests). */
foreignStopGraceMs?: number;
+ /** Override for how long `request_directory` waits for its owner's answer before it returns (tests). */
+ requestDirectoryWaitMs?: number;
/** Optional memory engine — when provided, episodes are chunked and stored for recall. */
memory?: MemoryEngine;
/** Shared in-daemon memory MCP endpoint + URL, for URL-mounting backends. */
@@ -521,6 +544,14 @@ export interface SessionCreateOptions {
* rather than an in-process Claude-SDK server (#245).
*/
blackboardMcp?: { url: string; token: string };
+ /**
+ * The sandbox's `request_directory` over the daemon's `/mcp/sandbox`
+ * (sandbox/request-directory.ts), minted by the manager for this session:
+ * what the URL-mounting backends (Codex, the Gemini CLI, opencode) mount.
+ */
+ sandboxMcp?: (sessionId: string) => SandboxMcpMount | undefined;
+ /** The checkouts other sessions work in (SessionManager): what an autonomous `request_directory` never touches (#448 round 13). */
+ sandboxCheckouts?: (sessionId: string) => OtherCheckout[];
/**
* Pre-built codeoid_fleet MCP server (conductor sessions only). Built by
* the SessionManager because its tools close over the manager's tenant-
@@ -680,6 +711,7 @@ function planHash(parts: readonly string[]): string {
}
/** Whether `rel` exists in `workdir` as something other than a directory (links not followed). */
+// paths: a look (no write) in the working directory as pinned, just checked by #planRewind; the restore checks it again
async function isNonDirectory(workdir: string, rel: string): Promise {
try {
return !(await lstat(join(workdir, rel))).isDirectory();
@@ -693,6 +725,7 @@ async function isNonDirectory(workdir: string, rel: string): Promise {
* not followed). Exact: on a case-insensitive filesystem `lstat("readme.md")`
* also finds `README.md`, which would make a case-only rename look occupied.
*/
+// paths: a look (no write) in the working directory as pinned, just checked by #planRewind; the restore checks it again
async function existsOnDisk(workdir: string, rel: string): Promise {
try {
await lstat(join(workdir, rel));
@@ -710,6 +743,7 @@ async function existsOnDisk(workdir: string, rel: string): Promise {
}
/** Whether any parent directory of `rel` inside `workdir` is a symlink. */
+// paths: a look (no write) in the working directory as pinned, just checked by #planRewind; the restore checks it again
async function hasSymlinkedParent(workdir: string, rel: string): Promise {
const parts = rel.split("/");
for (let k = 1; k < parts.length; k++) {
@@ -777,6 +811,7 @@ export function irreversibleEffects(turns: readonly CanonicalTurn[], ctx: { work
const call = tc.input.call && typeof tc.input.call === "object" ? shellCallOf(inner, tc.input.call as Record) : null;
if (!call) continue;
const places = Array.isArray(tc.input.paths) ? tc.input.paths.filter((p): p is string => typeof p === "string" && p.length > 0) : [];
+ // paths: judging what a recorded command would reach (outsideEffects): no use of the path
if ((places.length > 0 ? places : [ctx.workdir]).some((cwd) => outsideEffects(call.command, { ...ctx, cwd: resolvePath(ctx.workdir, cwd) }))) {
out.push({ tool: inner, detail: shorten(call.command) });
}
@@ -930,6 +965,8 @@ export class Session {
* is scoped to a goal id that IS this session's id, so it can only be
* attached after construction (see attachBlackboard). */
#blackboardMcp?: { url: string; token: string };
+ #sandboxMcp?: (sessionId: string) => SandboxMcpMount | undefined;
+ #sandboxCheckouts?: (sessionId: string) => OtherCheckout[];
readonly createdBy: string;
readonly createdAt: string;
/**
@@ -1234,10 +1271,23 @@ export class Session {
#refBaseline: Promise | null = null;
/** The commit look in flight: one at a time, each from where the last one left the baseline. */
#settingsCheck: Promise = Promise.resolve();
+ /** The same two looks for each directory the session was given to write (its repository's), from when it applied. */
+ readonly #addedGitBaselines = new Map }>();
+ /** The agent's `request_directory` questions still open, by directory: a repeat request joins its answer. */
+ readonly #dirRequests = new Map>();
+ /** The pin change last said on the session (#sandboxPinRefusal): said once per change. */
+ #pinSaid: string | null = null;
+ /** The working directory's identity when there is no sandbox to keep it (an embedder): taken at its first use. */
+ #ownWorkdirRecord: string | null = null;
+ /** The change of the working directory last said (#workdirChanged): said once per change. */
+ #workdirChangeSaid: string | null = null;
+ #requestDirectoryWaitMs: number | undefined;
/** Started for unattended work: turns need an enforced sandbox (SessionCreateOptions.requireSandbox). */
#requireSandbox = false;
/** The sandbox changed while the backend process ran: it runs in the old one until it is restarted. */
#sandboxRestartPending = false;
+ /** The sandbox's directories changed and were applied while the backend ran: restarted before the next turn. */
+ #sandboxDirsRestart = false;
/** Why the last finished turn failed, when a usage or rate limit was the cause; null after a success. */
#lastTurnInfraError: TurnInfraError | null = null;
/**
@@ -1552,6 +1602,9 @@ export class Session {
this.collaboration = opts.collaboration;
this.collaborationRole = opts.collaborationRole;
this.#blackboardMcp = opts.blackboardMcp;
+ this.#sandboxMcp = opts.sandboxMcp;
+ this.#sandboxCheckouts = opts.sandboxCheckouts;
+ this.#requestDirectoryWaitMs = opts.requestDirectoryWaitMs;
this.#onStatusChange = opts.onStatusChange;
this.#workerShape = opts.workerShape;
this.#collaborationCost = opts.collaborationCost;
@@ -1856,6 +1909,8 @@ export class Session {
// A getter, so a mount attached after construction still reaches the
// provider when it next builds its server list.
blackboardMcp: () => this.#blackboardMcp,
+ // The sandbox's ask for a directory to write (sandbox/request-directory.ts): decided here, every backend.
+ ...(this.#sandbox ? { requestDirectory: (input: RequestDirectoryInput) => this.requestDirectory(input), sandboxMcp: () => this.#sandboxMcp?.(this.id) } : {}),
mcpRegistry: this.#mcpView ?? this.#mcpRegistry,
mcpHub: this.#mcpHub,
fleet: this.#fleet,
@@ -2031,38 +2086,49 @@ export class Session {
}
this.#releaseProviderWhenIdle = false;
this.#sandboxRestartPending = false;
- // Before the teardown, as terminateBackend does: codex and the Gemini
- // CLI are stopped with a bare SIGKILL, and what they started (MCP
- // servers, commands) would otherwise be left running, out of reach.
- let tree: ProcessEntry[] = [];
- try {
- const roots = (await withTimeout(this.#provider.backendProcessIds?.() ?? Promise.resolve([]), 3_000)) ?? [];
- tree = await withTimeout(snapshotTree(roots), 3_000);
- } catch (e) {
- console.error(`[codeoid/session ${this.id}] could not snapshot the ${backend} backend's processes: ${e instanceof Error ? e.message : String(e)}`);
- }
- await this.#teardownProvider();
- try {
- const killed = await killSurvivors(tree);
- if (killed.length > 0) {
- this.#store.audit(opts.idle ? "system:idle-release" : "system:account-release", "session.release_backend.reaped", this.id, `pids=${killed.join(",")}`);
- console.warn(`[codeoid/session ${this.id}] killed ${killed.length} process(es) the released ${backend} backend left running: ${killed.join(", ")}`);
- }
- } catch (e) {
- console.error(`[codeoid/session ${this.id}] killing the released backend's leftover processes failed: ${e instanceof Error ? e.message : String(e)}`);
- }
- // A backend that can't resume its own conversation (the Gemini CLI
- // starts a new ACP session) is handed the history, as after an MCP
- // rebuild — an account switch must never start the conversation over.
- if (RESEED_AFTER_TEARDOWN.has(this.#provider.id) && this.#accumulator.history.length > 0) {
- await this.#seedProviderFromHistory();
- }
+ this.#sandboxDirsRestart = false;
+ await this.#releaseBackendNow(opts.idle ? "system:idle-release" : "system:account-release");
})
.catch((e) => {
console.error(`[codeoid/session ${this.id}] releasing the idle ${backend} backend failed: ${e instanceof Error ? e.message : String(e)}`);
});
}
+ /**
+ * Stop the backend's process now (idle: in the send chain) so its next turn
+ * starts a fresh one that resumes the conversation: what it started is
+ * reaped, and a backend that can't resume is handed the history.
+ */
+ async #releaseBackendNow(who: string): Promise {
+ const backend = this.#provider.id;
+ // Before the teardown, as terminateBackend does: codex and the Gemini
+ // CLI are stopped with a bare SIGKILL, and what they started (MCP
+ // servers, commands) would otherwise be left running, out of reach.
+ let tree: ProcessEntry[] = [];
+ try {
+ const roots = (await withTimeout(this.#provider.backendProcessIds?.() ?? Promise.resolve([]), 3_000)) ?? [];
+ tree = await withTimeout(snapshotTree(roots), 3_000);
+ } catch (e) {
+ console.error(`[codeoid/session ${this.id}] could not snapshot the ${backend} backend's processes: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ await this.#teardownProvider();
+ try {
+ const killed = await killSurvivors(tree);
+ if (killed.length > 0) {
+ this.#store.audit(who, "session.release_backend.reaped", this.id, `pids=${killed.join(",")}`);
+ console.warn(`[codeoid/session ${this.id}] killed ${killed.length} process(es) the released ${backend} backend left running: ${killed.join(", ")}`);
+ }
+ } catch (e) {
+ console.error(`[codeoid/session ${this.id}] killing the released backend's leftover processes failed: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ // A backend that can't resume its own conversation (the Gemini CLI
+ // starts a new ACP session) is handed the history, as after an MCP
+ // rebuild — an account switch must never start the conversation over.
+ if (RESEED_AFTER_TEARDOWN.has(this.#provider.id) && this.#accumulator.history.length > 0) {
+ await this.#seedProviderFromHistory();
+ }
+ }
+
/**
* Arm the idle release (#451): a session left idle this long has its
* backend process, and the MCP servers it started, stopped. Re-armed each
@@ -2162,7 +2228,7 @@ export class Session {
#heldWrite(toolName: string, input: unknown): { path: string; what: HeldPath["what"] | "unresolved" } | null {
if (!this.#hostSideFileTools() || !input || typeof input !== "object") return null;
try {
- return heldWriteOf(toolName, input as Record, this.workdir, protectedWriteTargets(this.workdir));
+ return heldWriteOf(toolName, input as Record, this.workdir, protectedWriteTargets(this.workdir, undefined, this.#sandbox?.appliedAddedDirs() ?? []));
} catch {
return writeTargets(toolName, input as Record) ? { path: "(couldn't be judged)", what: "unresolved" } : null;
}
@@ -2197,6 +2263,7 @@ export class Session {
if (!rt) throw new Error("no runtime");
const own = this.#provider.hostFiles?.() ?? {};
const bounds = hostToolBoundsFor(rt, this.#provider.id, this.#provider.hostReadable?.() ?? [], own, userHiddenPaths(this.#config));
+ // paths: judging a tool call's input against the sandbox's bounds: no use of the path
return hostFileToolBreach(toolName, input, { bounds, workdir: this.workdir, ...(shellCwd !== undefined ? { cwd: shellCwd } : {}) });
} catch {
return { path: "(couldn't be judged)", access: "read", why: "unresolved" };
@@ -3110,13 +3177,84 @@ export class Session {
async ensureSandbox(): Promise {
const sbx = this.#sandbox;
if (!sbx) return null;
- if (sbx.resolvedFor(this.#provider.id) && sbx.info) return sbx.info;
+ // An agent's own grant another session works in now is let go first (applied as any change of directories).
+ await this.#dropSharedAgentDirs();
+ // Directories added or removed are applied only between turns, with no background work running: a
+ // Claude query rebuilt, or a process backend restarted, mid-work would end that work.
+ const applyDirs = sbx.dirsPending && this.#mayApplySandboxDirs();
+ if (sbx.resolvedFor(this.#provider.id) && sbx.info && !applyDirs) return sbx.info;
const before = JSON.stringify(sbx.info);
const info = await sbx.refresh(this.#provider.id);
- if (JSON.stringify(info) !== before) this.#broadcastInfoUpdate();
+ // Pins taken by this resolution (the workspace, a root seen first: #448 round 13) are kept with the setting.
+ if (sbx.takePinsChanged()) this.#saveMetaNow();
+ this.#sayDroppedDirs();
+ if (applyDirs) {
+ // A backend running now runs in the old sandbox: restarted before its next turn (#restartForSandboxDirs).
+ if (PROCESS_BACKENDS.has(this.#provider.id) && this.#provider.hasQueried) this.#sandboxDirsRestart = true;
+ const note = this.#makeMessage("info", `🛡 Sandbox directories applied: ${sandboxDirsLine(info)}`, SYSTEM_IDENTITY, undefined, undefined, { event: "sandbox.dirs_applied" });
+ this.#persistAndBuffer(note);
+ this.#broadcastRaw(note);
+ }
+ if (JSON.stringify(info) !== before || applyDirs) this.#broadcastInfoUpdate();
return info;
}
+ /**
+ * Why no tool call may run now because a directory the sandbox binds by
+ * name isn't what was pinned (SessionSandbox.pinProblem, #448 round 13),
+ * or null. Said on the session once per change (event
+ * `sandbox.pin_changed`, audit `session.sandbox_pin_changed`).
+ */
+ #sandboxPinRefusal(): string | null {
+ const sbx = this.#sandbox;
+ if (!sbx || sbx.info?.state !== "on") return null;
+ const problem = sbx.pinProblem();
+ if (!problem) {
+ this.#pinSaid = null;
+ return null;
+ }
+ if (this.#pinSaid !== problem) {
+ this.#pinSaid = problem;
+ this.#store.audit(SYSTEM_IDENTITY.sub, "session.sandbox_pin_changed", this.id, problem.slice(0, 2000));
+ const note = this.#makeMessage("info", `🛡 Sandbox: ${problem}.`, SYSTEM_IDENTITY, undefined, undefined, { event: "sandbox.pin_changed" });
+ this.#persistAndBuffer(note);
+ this.#broadcastRaw(note);
+ }
+ return problem;
+ }
+
+ /** Whether a change of the sandbox's directories may be applied now: no turn, approval or background work. */
+ #mayApplySandboxDirs(): boolean {
+ return !this.#turnActive() && !this.#hasAgentBackgroundWork() && this.#pendingApprovals.size === 0 && this.#pendingUiRequests.size === 0;
+ }
+
+ /**
+ * A directory change applied while the backend's process runs in the old
+ * sandbox: restarted (idle, in the send chain) before the turn that
+ * follows, which resumes its conversation.
+ */
+ async #restartForSandboxDirs(): Promise {
+ if (!this.#sandboxDirsRestart) return;
+ this.#sandboxDirsRestart = false;
+ if (!PROCESS_BACKENDS.has(this.#provider.id) || !this.#provider.hasQueried) return;
+ await this.#releaseBackendNow("system:sandbox-dirs");
+ }
+
+ /** The sandbox's workspace (a monorepo's repository top, as pinned), or the workdir without a sandbox. */
+ get sandboxWorkspace(): string {
+ return this.#sandbox?.workspace ?? this.workdir;
+ }
+
+ /** The session's own worktree folders (its commands write them): none without a sandbox. */
+ sandboxWorktreeFolders(): string[] {
+ return this.#sandbox?.worktreeFolders() ?? [];
+ }
+
+ /** The directories the session's commands write besides its workspace, as set. */
+ sandboxAddedDirs(): string[] {
+ return this.#sandbox?.effective().addedDirs ?? [];
+ }
+
/** The session's own sandbox setting (persisted; a fork and a collaboration's children inherit it). */
get sandboxSetting(): SessionSandboxSetting | undefined {
return this.#sandbox?.setting;
@@ -3124,7 +3262,19 @@ export class Session {
/** The resolved sandbox, if any (SessionInfo.sandbox). */
get sandboxInfo(): SessionSandboxInfo | null {
- return this.#sandbox?.info ?? null;
+ const info = this.#sandbox?.info;
+ return info ? this.#sandboxInfoWire(info) : null;
+ }
+
+ /** The resolution with the directories set but not applied yet (they take effect between turns). */
+ #sandboxInfoWire(info: SessionSandboxInfo): SessionSandboxInfo {
+ const sbx = this.#sandbox;
+ if (!sbx?.dirsPending) return info;
+ const applied = sbx.appliedAddedDirs();
+ const wanted = sbx.effective().addedDirs;
+ const add = wanted.filter((d) => !applied.includes(d));
+ const remove = applied.filter((d) => !wanted.includes(d));
+ return add.length > 0 || remove.length > 0 ? { ...info, pendingDirs: { add, remove } } : info;
}
/** Whether the session was started for unattended work (SessionCreateOptions.requireSandbox). */
@@ -3193,7 +3343,8 @@ export class Session {
this.#broadcastRaw(note);
return false;
}
- await sbx.set(next);
+ // Project roots set again: each pinned anew, as it is now (#448 round 13).
+ await sbx.set(next, { repinRoots: setting.projectRoots !== undefined });
this.#store.audit(sender.sub, "session.set_sandbox", this.id, answeredBy && answeredBy !== sender.sub ? `${change} (confirmed by ${answeredBy})` : change);
this.#saveMetaNow();
// A backend running now runs in the old sandbox: restarted before its next turn.
@@ -3244,6 +3395,337 @@ export class Session {
return { ok: true, pending: {} };
}
+ /**
+ * Why `raw` can't be added to what the session's commands write, or the
+ * directory it names: where it really is now (what is kept and bound:
+ * project-roots.ts `addedDirReal`), with `written` (absolute; a relative
+ * one is the workdir's) and `already` when it is writable now (the
+ * workspace, the session's worktree folders or temp dir, an added dir).
+ */
+ #sandboxDirCheck(raw: string): { dir: string; written: string; already?: string; agentHeld?: true } | { error: string } {
+ const sbx = this.#sandbox;
+ if (!sbx) return { error: "this daemon has no OS sandbox" };
+ const home = process.env.HOME ?? homedir();
+ const written = normalizeSandboxDir(raw, home) ?? resolvePath(this.workdir, raw.trim());
+ const dir = addedDirReal(written, home) ?? written;
+ const workspace = sbx.workspace;
+ if (inAnyDir(dir, [workspace])) return { dir, written, already: `${dir} is in the session's workspace (${workspace}), which its commands write already` };
+ const folder = sbx.worktreeFolders().find((f) => inAnyDir(dir, [f]));
+ if (folder) return { dir, written, already: `${dir} is in the session's own worktree folder (${folder}), which its commands write already` };
+ const temp = sbx.tempDirPath;
+ if (temp && inAnyDir(dir, [temp])) return { dir, written, already: `${dir} is in the session's temp dir, which its commands write already` };
+ const { addedDirs: added, agentDirs } = sbx.effective();
+ const holder = added.find((d) => inAnyDir(dir, [d]));
+ // The agent's own: added already, but its credentials stay hidden there — the owner may add it as theirs.
+ const agentHeld = holder !== undefined && agentDirs.includes(holder) ? ({ agentHeld: true } as const) : {};
+ if (holder) return { dir, written, already: holder === dir ? `${dir} is added already` : `${dir} is in ${holder}, which is added already`, ...agentHeld };
+ const root = sandboxRootIfMade();
+ const hidden = [...hiddenPathsFor(this.#config), ...(root ? [root] : [])];
+ const problem = addedDirProblem(written, { home, configDir: getConfigDir(), hidden });
+ return problem ? { error: problem } : { dir, written };
+ }
+
+ /**
+ * What stays read-only in `dir` once the owner adds it, in words, for the
+ * owner's confirm: true for what is there. Its credential files become
+ * readable to the session's commands, as the workspace's are (only a
+ * directory the agent added itself keeps a project root's hiding); and a
+ * checkout another session works in is said (#448 round 13).
+ */
+ #addedDirProtection(dir: string): string {
+ const nested = nestedControl(dir);
+ const repos = nested.repos.filter((r) => r !== dir).length;
+ const overlap = this.#checkoutOverlapOf(dir);
+ const held = `its git structure and project settings stay read-only, as in the workspace${repos > 0 ? `, and so do those of the ${repos} ${repos === 1 ? "repository" : "repositories"} inside it` : ""}`;
+ const warning = overlap ? `. Warning: ${overlap} — a change either session makes there (a directory swapped for a link included) the other one runs against` : "";
+ return `${held}; its credential files (.env files, keys, a token in a git config) become readable to its commands, as the workspace's are${warning}`;
+ }
+
+ /**
+ * The directories the agent added itself (an autonomous
+ * `request_directory`) that another session's checkout, or one of
+ * codeoid's, touches now (a session made there since, a directory
+ * another session added): taken out of the setting, said and audited
+ * (#448 round 14), so an agent's own grant never shares a directory with
+ * another writer, as at the grant (`checkoutOverlap`). Looked at every
+ * resolution; applied as any change of directories, between turns.
+ */
+ async #dropSharedAgentDirs(): Promise {
+ const sbx = this.#sandbox;
+ const setting = sbx?.setting;
+ if (!sbx || !setting?.agentDirs || setting.agentDirs.length === 0) return;
+ const shared = setting.agentDirs.map((dir) => ({ dir, why: this.#checkoutOverlapOf(dir) })).filter((d): d is { dir: string; why: string } => d.why !== null);
+ if (shared.length === 0) return;
+ const gone = new Set(shared.map((d) => d.dir));
+ const addedDirs = (setting.addedDirs ?? []).filter((d) => !gone.has(d) && !gone.has(normalizeSandboxDir(d) ?? d));
+ const agentDirs = setting.agentDirs.filter((d) => !gone.has(d));
+ const { addedDirs: _a, agentDirs: _g, ...rest } = setting;
+ await sbx.set({ ...rest, ...(addedDirs.length > 0 ? { addedDirs } : {}), ...(agentDirs.length > 0 ? { agentDirs } : {}) });
+ for (const d of shared) {
+ this.#store.audit(SYSTEM_IDENTITY.sub, "session.sandbox_dir_dropped", this.id, `${d.dir}: ${d.why}`.slice(0, 2000));
+ const note = this.#makeMessage("info", `🛡 Sandbox: ${d.dir}, which the agent added, is no longer writable: ${d.why}. The owner can add it (\`/sandbox add\`) if both should write it.`, SYSTEM_IDENTITY, undefined, undefined, {
+ event: "sandbox.dir_dropped",
+ path: d.dir,
+ });
+ this.#persistAndBuffer(note);
+ this.#broadcastRaw(note);
+ }
+ this.#saveMetaNow();
+ }
+
+ /** Why `dir` touches another session's or codeoid's checkout (project-roots.ts `checkoutOverlap`), or null. */
+ #checkoutOverlapOf(dir: string): string | null {
+ const sbx = this.#sandbox;
+ if (!sbx) return null;
+ try {
+ return checkoutOverlap(dir, this.#sandboxCheckouts?.(this.id) ?? [], {
+ workspace: sbx.workspace,
+ ownFolders: sbx.worktreeFolders(),
+ worktreeRoots: sbx.config.worktreeRoots,
+ configDir: getConfigDir(),
+ });
+ } catch {
+ return null;
+ }
+ }
+
+ /** The added dirs once `dir` is added: any of them inside it go (it holds them now). */
+ #withAddedDir(dir: string): string[] {
+ return [...this.#sandbox!.effective().addedDirs.filter((d) => !inAnyDir(d, [dir])), dir];
+ }
+
+ /**
+ * Say on the session (and audit) each added directory the last resolution
+ * left out because it no longer is what was granted, or can't be one now
+ * (SessionSandbox.droppedDirs), and take it out of the setting: never
+ * followed to where a link put there leads, never added back unasked.
+ */
+ #sayDroppedDirs(): void {
+ const sbx = this.#sandbox;
+ const dropped = sbx?.droppedDirs() ?? [];
+ if (!sbx || dropped.length === 0) return;
+ sbx.forget(dropped.map((d) => d.dir));
+ for (const d of dropped) {
+ this.#store.audit(SYSTEM_IDENTITY.sub, "session.sandbox_dir_dropped", this.id, `${d.dir}: ${d.why}`.slice(0, 2000));
+ const note = this.#makeMessage("info", `🛡 Sandbox: ${d.dir} is no longer writable: ${d.why}. Add it again (\`/sandbox add\`) if it should be.`, SYSTEM_IDENTITY, undefined, undefined, {
+ event: "sandbox.dir_dropped",
+ path: d.dir,
+ });
+ this.#persistAndBuffer(note);
+ this.#broadcastRaw(note);
+ }
+ this.#saveMetaNow();
+ }
+
+ /** When an added or removed directory takes effect, in words. */
+ #dirsWhen(): string {
+ if (this.#mayApplySandboxDirs()) return "from the session's next turn";
+ if (this.#hasAgentBackgroundWork()) return "from the session's next turn after the background work running now has ended";
+ return "from the session's next turn";
+ }
+
+ /**
+ * Record a change of the session's added directories (persisted, audited,
+ * said on the session) and apply it now when nothing runs, else at the next
+ * turn that starts with nothing running (#ensureSandbox). Never restarts
+ * work: the backend picks it up between turns.
+ */
+ async #changeSandboxDirs(next: readonly string[], audit: { who: string; action: string; detail: string }, line: string, grant?: { dir: string; by: "owner" | "agent" }): Promise {
+ const sbx = this.#sandbox;
+ if (!sbx) return;
+ // Who added each: the agent's own keep a project root's credential hiding (#448 round 13).
+ const agent = new Set(sbx.effective().agentDirs.filter((d) => next.includes(d)));
+ if (grant?.by === "agent") agent.add(grant.dir);
+ else if (grant) agent.delete(grant.dir);
+ const { agentDirs: _old, ...rest } = sbx.setting ?? {};
+ await sbx.set({ ...rest, addedDirs: [...next], ...(agent.size > 0 ? { agentDirs: [...agent] } : {}) });
+ this.#store.audit(audit.who, audit.action, this.id, audit.detail.slice(0, 2000));
+ this.#saveMetaNow();
+ const note = this.#makeMessage("info", `🛡 ${line}`, SYSTEM_IDENTITY, undefined, undefined, { event: "sandbox.dirs_changed", addedDirs: [...next] });
+ this.#persistAndBuffer(note);
+ this.#broadcastRaw(note);
+ if (this.#mayApplySandboxDirs() && sbx.resolvedFor(this.#provider.id)) {
+ await this.ensureSandbox();
+ // A process backend picks it up when it next starts: stopped now (idle), as a sandbox change is.
+ if (this.#sandboxDirsRestart) {
+ this.#sandboxDirsRestart = false;
+ this.#sandboxRestartPending = true;
+ this.releaseIdleProvider({ idle: true });
+ }
+ }
+ this.#broadcastInfoUpdate();
+ }
+
+ /**
+ * Add a directory to what the session's commands write, or remove one
+ * (`session.sandbox_dir`; owner only, undelegated: SessionManager checks).
+ * Removing applies (between turns); adding is a widening, asked of the
+ * owner on the session with an owner-only confirm, as `setSandbox`'s
+ * widening is, and lands on a yes. `confirmed`: the owner's own request
+ * where nothing else could have sent it (none today).
+ */
+ async changeSandboxDir(
+ action: "add" | "remove",
+ raw: string,
+ sender: AuthContext,
+ opts: { onDecided?: (applied: boolean, reason?: string) => void } = {},
+ ): Promise<{ ok: true; pending?: true; message: string } | { ok: false; error: string }> {
+ const sbx = this.#sandbox;
+ if (!sbx) return { ok: false, error: "this daemon has no OS sandbox" };
+ const current = sbx.effective().addedDirs;
+ if (action === "remove") {
+ const written = normalizeSandboxDir(raw) ?? resolvePath(this.workdir, raw.trim());
+ // Kept by where it really was when added: named either way.
+ const dir = current.includes(written) ? written : (addedDirReal(written) ?? written);
+ if (!current.includes(dir)) return { ok: false, error: `${dir} isn't one of the session's added directories${current.length > 0 ? ` (${current.join(", ")})` : ""}` };
+ const when = this.#dirsWhen();
+ await this.#changeSandboxDirs(current.filter((d) => d !== dir), { who: sender.sub, action: "session.sandbox_dir_removed", detail: dir }, `Sandbox: ${dir} is no longer writable (${when})`);
+ return { ok: true, message: `${dir} removed: not writable ${when}` };
+ }
+ const check = this.#sandboxDirCheck(raw);
+ if ("error" in check) return { ok: false, error: check.error };
+ // One the agent added itself is the owner's to add as theirs (its credentials readable then); anything else
+ // writable already has nothing to add.
+ const upgrade = check.already !== undefined && check.agentHeld === true && current.includes(check.dir);
+ if (check.already && !upgrade) return { ok: true, message: check.already };
+ const dir = check.dir;
+ const via = check.written !== dir ? ` (${check.written} leads there)` : "";
+ this.#store.audit(sender.sub, "session.sandbox_dir_ask", this.id, `${dir}${via}`);
+ let answeredBy: string | undefined;
+ void this.requestUserInput(
+ {
+ method: "confirm",
+ title: `Let "${this.name}" write ${dir}?`,
+ message: `Its sandboxed commands could change and commit files in ${dir}${via} (${this.#addedDirProtection(dir)}). It takes effect ${this.#dirsWhen()}.`,
+ },
+ { ownerOnly: true, onAnswer: (sub) => {
+ answeredBy = sub;
+ },
+ },
+ ).then(
+ async (answer) => {
+ if (answer.cancelled || answer.confirmed !== true) {
+ this.#store.audit(answeredBy ?? sender.sub, "session.sandbox_dir_denied", this.id, `${dir} ${answer.reason ?? "declined"}`);
+ opts.onDecided?.(false, answer.reason ?? "declined");
+ return;
+ }
+ const again = this.#sandboxDirCheck(dir);
+ const stillUpgrade = upgrade && !("error" in again) && again.agentHeld === true && again.dir === dir;
+ if ("error" in again || (again.already && !stillUpgrade) || again.dir !== dir) {
+ opts.onDecided?.(false, "error" in again ? again.error : (again.already ?? `${dir} isn't the directory it was when asked (it leads to ${again.dir} now)`));
+ return;
+ }
+ const when = this.#dirsWhen();
+ await this.#changeSandboxDirs(
+ this.#withAddedDir(dir),
+ { who: answeredBy ?? sender.sub, action: "session.sandbox_dir_added", detail: `${dir} by=owner` },
+ `Sandbox: ${dir} is writable (${when})`,
+ { dir, by: "owner" },
+ );
+ opts.onDecided?.(true);
+ },
+ () => opts.onDecided?.(false, "failed"),
+ );
+ return { ok: true, pending: true, message: `asked on the session: let it write ${dir}?` };
+ }
+
+ /**
+ * The agent's `request_directory` (sandbox/request-directory.ts): the text
+ * it reads. Interactive and guarded: its owner answers a confirm card
+ * naming the directory and the reason (owner-only; an unattended run
+ * isn't asked, so never parks). Autonomous: a directory under the
+ * session's project roots is added unasked, anything else refused at once.
+ * Audited either way; what is added is writable from the next turn.
+ */
+ async requestDirectory(input: RequestDirectoryInput): Promise {
+ const sbx = this.#sandbox;
+ const info = sbx?.info;
+ if (!sbx || !info || info.state !== "on") {
+ return `Nothing to add: this session's commands aren't sandboxed (${info?.reason ?? info?.state ?? "no sandbox"}), so they write wherever you can.`;
+ }
+ const check = this.#sandboxDirCheck(input.path);
+ const asked = "dir" in check ? check.dir : input.path;
+ this.#store.audit(SYSTEM_IDENTITY.sub, "session.sandbox_dir_request", this.id, `${asked} reason=${input.reason}`.slice(0, 2000));
+ const refuse = (why: string): string => {
+ this.#store.audit(SYSTEM_IDENTITY.sub, "session.sandbox_dir_refused", this.id, `${asked}: ${why}`.slice(0, 2000));
+ const note = this.#makeMessage("info", `🛡 The agent asked to write ${asked}: refused (${why})`, SYSTEM_IDENTITY, undefined, undefined, { event: "sandbox.dir_refused", path: asked });
+ this.#persistAndBuffer(note);
+ this.#broadcastRaw(note);
+ return `Refused: ${why}.`;
+ };
+ if ("error" in check) return refuse(check.error);
+ if (check.already) return `Nothing to add: ${check.already}.`;
+ const dir = check.dir;
+ const via = check.written !== dir ? ` (${check.written} leads there)` : "";
+ const grant = async (by: string, how: string, granted: "owner" | "agent"): Promise => {
+ const when = this.#dirsWhen();
+ await this.#changeSandboxDirs(
+ this.#withAddedDir(dir),
+ { who: by, action: "session.sandbox_dir_added", detail: `${dir} by=agent ${how} reason=${input.reason}` },
+ `Sandbox: ${dir} is writable, as the agent asked (${how}; ${when})`,
+ { dir, by: granted },
+ );
+ const hidden = granted === "agent" ? " Its credential files (.env files, keys, a token in a git config) stay hidden to you, as in a project root." : "";
+ return `Added: ${dir} is writable to your commands ${when.replace("the session's", "your")}.${hidden} Finish this turn; it isn't writable yet in this one.`;
+ };
+ // Autonomous (an unattended run's too: a routine's, a loop worker's, a dispatched worker's): only a repository
+ // (or a dir in one) strictly under a project root, named by its real path, never another session's or codeoid's
+ // checkout; its credential files stay hidden. Anything else is the owner's to add.
+ if (this.#mode === "autonomous") {
+ const why = autonomousGrantProblem(check.written, dir, sbx.appliedProjectRoots()) ?? this.#checkoutOverlapOf(dir);
+ if (!why) return grant(SYSTEM_IDENTITY.sub, "a repository under a project root, autonomous", "agent");
+ return refuse(`${why}. The owner can add it (\`/sandbox add ${dir}\`)`);
+ }
+ // One card per directory: a repeat request while it is open waits on the same answer.
+ let pending = this.#dirRequests.get(dir);
+ if (!pending) {
+ let answeredBy: string | undefined;
+ const asked = this.requestUserInput(
+ {
+ method: "confirm",
+ title: `The agent asks to write ${dir}`,
+ message: `"${this.name}" asks for its sandboxed commands to write ${dir}${via} (${this.#addedDirProtection(dir)}).\n\nIts reason: ${input.reason}`,
+ },
+ {
+ ownerOnly: true,
+ onAnswer: (sub) => {
+ answeredBy = sub;
+ },
+ },
+ );
+ pending = asked.then(async (answer): Promise => {
+ this.#dirRequests.delete(dir);
+ if (answer.cancelled || answer.confirmed !== true) {
+ const why =
+ answer.reason === "unattended"
+ ? "nobody is there to approve it in this unattended run; the owner can add it (`/sandbox add`)"
+ : answer.cancelled
+ ? `the question was withdrawn (${answer.reason ?? "cancelled"})`
+ : "the owner declined";
+ this.#store.audit(answeredBy ?? SYSTEM_IDENTITY.sub, "session.sandbox_dir_denied", this.id, `${dir} ${answer.reason ?? "declined"}`);
+ return refuse(why);
+ }
+ const again = this.#sandboxDirCheck(dir);
+ if ("error" in again) return refuse(again.error);
+ if (again.already) return `Nothing to add: ${again.already}.`;
+ if (again.dir !== dir) return refuse(`${dir} isn't the directory it was when asked (it leads to ${again.dir} now)`);
+ return grant(answeredBy ?? this.createdBy, "the owner approved", "owner");
+ });
+ this.#dirRequests.set(dir, pending);
+ }
+ // The call returns before its backend's MCP client gives up on it (opencode's: about a minute); the card stays
+ // open, and a later yes still adds the directory.
+ const waitMs = this.#requestDirectoryWaitMs ?? (this.#provider.id === "opencode" ? REQUEST_DIRECTORY_SHORT_WAIT_MS : REQUEST_DIRECTORY_WAIT_MS);
+ let timer: ReturnType | undefined;
+ const late = new Promise((resolve) => {
+ timer = setTimeout(() => resolve(null), waitMs);
+ });
+ const first = await Promise.race([pending, late]);
+ clearTimeout(timer);
+ if (first !== null) return first;
+ return `No answer yet: the owner has been asked to let you write ${dir}, and the question stays open. If they say yes, it is writable from a later turn (call request_directory again to see). Carry on without it, or end this turn.`;
+ }
+
/**
* A call that asks to run outside the backend's own sandbox or widen it
* (asksOutsideSandbox), on a session whose sandbox is the backend's own
@@ -3272,12 +3754,16 @@ export class Session {
*/
#checkGitControl(sub: string): Promise {
const commits = this.#checkSettingsCommits(sub);
+ const added = this.#checkAddedGitControl(sub);
// The repository's worktree entries first (#448 round 11): what is restored there isn't news below.
this.#sandbox?.checkWorktreeEntries();
const before = this.#gitBaseline;
- if (!before) return commits;
+ if (!before) return Promise.all([commits, added]).then(() => {});
+ // In the working directory as pinned (#448 round 14): one swapped for a link is said, never looked into.
+ const workdir = this.#pinnedWorkdirOrSay("the look at the repository's git settings");
+ if (!workdir) return Promise.all([commits, added]).then(() => {});
try {
- const now = gitControlSnapshot(this.workdir, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] });
+ const now = gitControlSnapshot(workdir.path, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] });
const changed = gitControlChanges(before, now);
this.#gitBaseline = now;
if (changed.length > 0) {
@@ -3287,7 +3773,46 @@ export class Session {
} catch (err) {
console.error(`[codeoid/session ${this.id}] checking the repository's git settings failed: ${err instanceof Error ? err.message : String(err)}`);
}
- return commits;
+ return Promise.all([commits, added]).then(() => {});
+ }
+
+ /**
+ * The end-of-turn look ({@link #checkGitControl}) in each added
+ * directory's repository: what of its git settings changed, and the
+ * commits that changed its project settings, each said naming the
+ * directory. Serialized with the workspace's; best effort.
+ */
+ #checkAddedGitControl(sub: string): Promise {
+ const dirs = [...this.#addedGitBaselines.keys()];
+ if (dirs.length === 0) return Promise.resolve();
+ const look = async (): Promise => {
+ for (const dir of dirs) {
+ const base = this.#addedGitBaselines.get(dir);
+ if (!base) continue;
+ try {
+ const now = gitControlSnapshot(dir, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] });
+ const changed = gitControlChanges(base.git, now);
+ base.git = now;
+ if (changed.length > 0) {
+ this.#store.audit(sub, "session.git_control_changed", this.id, `${dir}: ${changed.slice(0, 20).join(",")}`);
+ this.#sandboxWarning(`In ${dir} (added to this session's sandbox): ${gitControlWarning(changed)}`, "sandbox.git_changed");
+ }
+ const before = await base.refs;
+ const tips = await refTips(dir);
+ if (tips === null) continue;
+ const found = before === null ? { commits: [], more: false } : await settingsCommits(dir, before, tips);
+ if (found === null) continue;
+ base.refs = Promise.resolve(tips);
+ if (found.commits.length === 0) continue;
+ this.#store.audit(sub, "session.settings_committed", this.id, `${dir}: ${found.commits.slice(0, 20).map((c) => `${c.commit.slice(0, 12)}:${c.files.slice(0, 5).join("|")}`).join(",")}`.slice(0, 2000));
+ this.#sandboxWarning(`In ${dir} (added to this session's sandbox): ${settingsCommitWarning(found.commits, found.more)}`, "sandbox.settings_committed");
+ } catch (err) {
+ console.error(`[codeoid/session ${this.id}] checking ${dir}'s git settings failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+ };
+ this.#settingsCheck = this.#settingsCheck.then(look).catch(() => {});
+ return this.#settingsCheck;
}
/**
@@ -3305,9 +3830,12 @@ export class Session {
const baseline = this.#refBaseline;
if (!baseline) return;
const before = await baseline;
- const now = await refTips(this.workdir);
+ // In the working directory as pinned (#448 round 14): git never runs where a link put in its place leads.
+ const workdir = this.#pinnedWorkdirOrSay("the look at the session's commits");
+ if (!workdir) return;
+ const now = await refTips(workdir.path);
if (now === null) return;
- const found = before === null ? { commits: [], more: false } : await settingsCommits(this.workdir, before, now);
+ const found = before === null ? { commits: [], more: false } : await settingsCommits(workdir.path, before, now);
if (found === null) return;
this.#refBaseline = Promise.resolve(now);
if (found.commits.length === 0) return;
@@ -3366,7 +3894,7 @@ export class Session {
const inside = shell || runner?.fileTools === true;
// Commands can reach the container runtime (the owner's opt-in): every card says so, as a warning.
const docker = info.dockerSocket === true ? { sandboxDocker: true } : {};
- if (inside) return { sandboxed: true, sandboxNote: `sandboxed${info.dockerSocket ? ", Docker access" : ""}: ${info.providerName ?? info.provider}, network ${info.network}`, ...docker };
+ if (inside) return { sandboxed: true, sandboxNote: `sandboxed${info.dockerSocket ? ", Docker access" : ""}: ${info.providerName ?? info.provider}, network ${info.network}${cardDirsNote(info)}`, ...docker };
// Beyond what the sandbox lets its commands do: the owner decides, told so (#448 round 9).
const beyond = this.#hostBoundsNote(toolName, input, shellCwd);
return { sandboxed: false, sandboxNote: beyond ?? "runs in the backend's own process, outside the sandbox", ...docker };
@@ -4060,9 +4588,17 @@ export class Session {
// The OS sandbox (#448), resolved for this backend before it starts. An
// autonomous or unattended turn doesn't start without one that holds.
await this.ensureSandbox();
- // Its worktree folders, removed while empty at the last turn's end, are there again for this one's commands.
- this.#sandbox?.prepareFolders();
+ // Directories added or removed since the backend started: it starts again in the sandbox that has them.
+ await this.#restartForSandboxDirs();
+ // A pinned directory changed since the last resolution (#448 round 13): looked at again now, so an added dir or a
+ // root that changed is left out (said) before the turn; one that still is (the workspace) refuses it below.
+ if (this.#sandbox?.info?.state === "on" && this.#sandbox.pinProblem() && this.#mayApplySandboxDirs()) {
+ await this.ensureSandbox();
+ await this.#restartForSandboxDirs();
+ }
const sandboxRefused =
+ // A directory the sandbox binds by name isn't what was pinned: no command of this turn could run (#sandboxPinRefusal).
+ this.#sandboxPinRefusal() ??
this.sandboxRefusal() ??
// Its backend still runs in the sandbox it had before a change: not what the session reports.
(this.#sandboxRestartPending && this.#sandboxRequired() ? "its backend still runs in its previous sandbox (it restarts once the session is idle)" : null);
@@ -4071,11 +4607,24 @@ export class Session {
throw new SandboxRequiredError(`Not started: ${sandboxRefused}`);
}
this.#store.audit(sender.sub, "session.send", this.id);
- // What of the repository git runs at the user's next command, as it is before the agent works (#448).
- if (this.#sandbox && !this.#gitBaseline) this.#gitBaseline = gitControlSnapshot(this.workdir, undefined, { ownFolders: this.#sandbox.worktreeFolders() });
+ // Its worktree folders, removed while empty at the last turn's end, are there again for this one's commands: only
+ // once its directories are what was pinned (#448 round 14), and never made through a link (worktrees.ts).
+ this.#sandbox?.prepareFolders();
+ // What of the repository git runs at the user's next command, as it is before the agent works (#448), in the
+ // working directory as pinned (checked just above with the sandbox's pins).
+ const baselineDir = this.#sandbox && (!this.#gitBaseline || !this.#refBaseline) ? this.#pinnedWorkdirOrSay("the look at the repository's git settings") : null;
+ if (baselineDir && !this.#gitBaseline) this.#gitBaseline = gitControlSnapshot(baselineDir.path, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] });
// And the commits its refs name, so a commit made from here on can be told (#448 round 6).
- if (this.#sandbox && !this.#refBaseline) this.#refBaseline = refTips(this.workdir);
+ if (baselineDir && !this.#refBaseline) this.#refBaseline = refTips(baselineDir.path);
await this.#refBaseline;
+ // The same for each added directory's repository, from the first turn it is writable in.
+ // Every repository inside one too (a folder of them).
+ for (const dir of this.#sandbox?.appliedRepositories() ?? []) {
+ if (this.#addedGitBaselines.has(dir)) continue;
+ const base = { git: gitControlSnapshot(dir, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] }), refs: refTips(dir) };
+ this.#addedGitBaselines.set(dir, base);
+ await base.refs;
+ }
// A new turn is starting — clear any interrupt from the previous one.
this.#turnInterrupted = false;
this.#lastTurnError = null;
@@ -6006,7 +6555,9 @@ export class Session {
const t0 = Date.now();
try {
await execFileP("sh", ["-c", command], {
- cwd: this.workdir,
+ // In the fork's working directory as pinned (#448 round 14): changed meanwhile, the setup is refused (said).
+ // paths: the pinned workdir, checked here
+ cwd: this.pinnedWorkdir("running the fork's setup command").path,
timeout: FORK_SETUP_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024,
});
@@ -6583,7 +7134,7 @@ export class Session {
providerId: this.#provider.id,
...this.#accountInfo(),
mode: this.#mode,
- ...(this.#sandbox?.info ? { sandbox: this.#sandbox.info } : {}),
+ ...(this.#sandbox?.info ? { sandbox: this.#sandboxInfoWire(this.#sandbox.info) } : {}),
turnsRemaining: this.#turnsRemaining,
...(this.#budgetExhausted ? { budgetExhausted: true as const } : {}),
...(this.#autonomousBudgetSpent ? { autonomousBudgetSpent: true as const } : {}),
@@ -7656,9 +8207,43 @@ export class Session {
if (this.#memory) parts.push(this.#buildMemoryPromptAppend());
const worktrees = this.#worktreePromptAppend();
if (worktrees) parts.push(worktrees);
+ const dirs = this.#sandboxDirsPromptAppend();
+ if (dirs) parts.push(dirs);
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
+ /**
+ * What the session's sandboxed commands read and write besides the
+ * workspace (docs/sandbox.md, "Working across repositories"), as the
+ * running sandbox has it, and how to ask for more: a write outside fails
+ * with the OS's bare "Read-only file system". Changes only when a change
+ * of directories is applied (between turns), so the prefix stays stable.
+ */
+ #sandboxDirsPromptAppend(): string | null {
+ const sbx = this.#sandbox;
+ if (!sbx?.runtime || sbx.info?.state !== "on") return null;
+ const roots = sbx.appliedProjectRoots();
+ const added = sbx.appliedAddedDirs();
+ const lines = ["# Other directories in this sandbox"];
+ lines.push(
+ roots.length > 0
+ ? `Your commands can read (read-only) everything under ${roots.map((r) => `${r}/`).join(", ")}: other repositories, and other sessions' checkouts there. Credentials and secret files stay hidden.`
+ : "Your commands can read only the workspace and the toolchains the sandbox shows.",
+ );
+ if (added.length > 0) lines.push(`Besides the workspace, they can write ${added.map((d) => `${d}/`).join(", ")} (git structure and project settings there read-only, as in the workspace).`);
+ if (this.#requestDirectoryMounted()) lines.push("To write in another directory, call the `request_directory` tool (path, reason). What it grants is writable from your next turn.");
+ else lines.push("To write in another directory, ask the owner to add it (`/sandbox add `).");
+ return lines.join("\n");
+ }
+
+ /** Whether this session's backend has the sandbox's `request_directory` (sandbox/request-directory.ts). */
+ #requestDirectoryMounted(): boolean {
+ const id = this.#provider.id;
+ if (id === "claude" || id === "qwen" || id === "pi") return true;
+ if (id === "codex" || id === "gemini-cli" || id === "opencode") return this.#sandboxMcp?.(this.id) !== undefined;
+ return false;
+ }
+
/**
* Where the session's sandboxed commands may make git worktrees (#448
* round 10, sandbox/worktrees.ts): its own folder in each worktree
@@ -7770,6 +8355,9 @@ export class Session {
// resolve for certain is never run unasked (tool-safety.ts).
if (isUnresolvedPathTool(toolName)) return false;
+ // An ask itself (sandbox/request-directory.ts): the session decides it, so it never waits on a card first.
+ if (isRequestDirectoryTool(toolName)) return true;
+
if (this.#mode === "interactive") return false;
// Read-only / retrieval tools — safe in both guarded and autonomous.
@@ -7841,6 +8429,7 @@ export class Session {
if (isFleetSendTool(toolName)) return false;
if (isElicitationTool(toolName)) return false;
if (isUnresolvedPathTool(toolName)) return false;
+ if (isRequestDirectoryTool(toolName)) return true;
if (this.#mode === "interactive") return false;
if (isSafeTool(toolName)) return true;
if (this.#isReadonlyMcpTool(toolName)) return true;
@@ -8191,6 +8780,17 @@ export class Session {
// runs — not even a read — until a new budget re-arms the session.
if (this.#budgetExhausted) return this.#endAttemptOnBudget(approvalId, toolName, sender);
+ // A directory the sandbox binds by name isn't what was pinned (#448
+ // round 13: the workspace, an added dir, a root swapped for a link):
+ // no call runs against it, in any mode, approved or not.
+ const pinRefusal = this.#sandboxPinRefusal();
+ if (pinRefusal) {
+ this.#approvalPatchKeys.delete(approvalId);
+ this.#resolveToolCallMessage(approvalId, { phase: "cancelled", reason: "denied", message: pinRefusal });
+ this.#store.audit(sender.sub, "session.sandbox_pin_refuse", this.id, `tool=${toolName}`);
+ return { behavior: "deny" as const, message: pinRefusal };
+ }
+
// Hook gate — the policy layer, run BEFORE the approval gate. A hook
// block is a policy deny that never prompts the user (and never burns
// the autonomous budget — #shouldAutoApprove runs after this); an
@@ -8334,6 +8934,7 @@ export class Session {
// daemon's credential files or a process environment is refused; one
// searching a directory that holds them is asked about (refused in
// autonomous mode), never auto-run.
+ // paths: judging a tool call's input (secretPathVerdict): no use of the path
const secret = secretPathVerdict(toolName, effectiveInput, { workdir: this.workdir, configDir: getConfigDir(), cwd: shellCwd });
// A shell command is judged by what it runs (#431): one naming a
// credential file is refused like the file tools; a risky one asks.
@@ -9038,6 +9639,7 @@ export class Session {
// as canUseTool's synchronous section, always after it).
const risk = this.#shellRisk(event.name, event.input, event.shellCwd);
const held = this.#heldWrite(event.name, event.input);
+ // paths: judging a tool call's input (secretPathVerdict): no use of the path
const secret = secretPathVerdict(event.name, event.input, { workdir: this.workdir, configDir: getConfigDir(), cwd: event.shellCwd });
// Claude's sandbox asking for a host: decided by the session's network alone (#448).
const networkAsk = event.name === SANDBOX_NETWORK_TOOL && this.#sandbox?.info?.state === "on";
@@ -9054,9 +9656,11 @@ export class Session {
const budgetDenied = this.#budgetExhausted || this.#budgetSpentFor(event.name);
// Autonomous mode refuses it outright (#autonomousRefusal): no approval card either.
const deniedFirst = networkAsk || budgetDenied || secret?.action === "deny" || risk?.secret?.action === "deny";
+ // A pinned directory changed (#448 round 13): the gate refuses every call (#sandboxPinRefusal).
+ const pinned = budgetDenied ? null : this.#sandboxPinRefusal();
// Beyond the sandbox, a host-side file tool is refused in autonomous mode (#outsideSandbox).
- const outside = deniedFirst ? null : this.#outsideSandboxRefusal(event.name, event.input, event.shellCwd);
- const refused = deniedFirst ? null : outside ? { reason: hostFileBreachReason(outside) } : this.#autonomousRefusal(event.name, event.input, risk, secret, held);
+ const outside = deniedFirst || pinned ? null : this.#outsideSandboxRefusal(event.name, event.input, event.shellCwd);
+ const refused = pinned ? { reason: pinned } : deniedFirst ? null : outside ? { reason: hostFileBreachReason(outside) } : this.#autonomousRefusal(event.name, event.input, risk, secret, held);
const toolMsg = this.#makeMessage(
"tool_call",
`${event.name}(${Object.keys(event.input).join(", ")})`,
@@ -9782,6 +10386,68 @@ export class Session {
return join(this.#transcriptStore.dir, "checkpoints");
}
+ /**
+ * The working directory as the daemon uses it on the host (#448 round 14,
+ * sandbox/pinned.ts): its real path and identity, taken when the session
+ * first resolved its sandbox (kept with the setting) or, without one, at
+ * the first use. Checkpoints, going back a turn, a fork's files and the
+ * end-of-turn git looks take it from here, never by the path alone: one
+ * moved aside and replaced (by a link to the home, or another directory)
+ * is refused, said and audited ({@link #workdirChanged}), never followed.
+ */
+ #pinnedWorkdir(op: string): PinnedDir {
+ if (this.#sandbox) {
+ const pin = this.#sandbox.workdirPin(op);
+ if (this.#sandbox.takePinsChanged()) this.#saveMetaNow();
+ return pin;
+ }
+ this.#ownWorkdirRecord ??= pinRecord(this.workdir);
+ return pinDir(this.workdir, "the working directory", this.#ownWorkdirRecord, op);
+ }
+
+ /**
+ * The working directory as pinned, for a host-side use of it from outside
+ * the session (a client's file read, a fork's worktree and the files it
+ * carries, the project's MCP config): throws {@link PinnedPathError}, said
+ * on the session and audited, when it changed under the session.
+ */
+ pinnedWorkdir(op: string): PinnedDir {
+ try {
+ return this.#pinnedWorkdir(op);
+ } catch (err) {
+ if (err instanceof PinnedPathError) this.#workdirChanged(err);
+ throw err;
+ }
+ }
+
+ /**
+ * {@link #pinnedWorkdir}, or null when it changed: said on the session
+ * once per change (event `workdir.changed`) and audited
+ * (`session.workdir_changed`).
+ */
+ #pinnedWorkdirOrSay(op: string): PinnedDir | null {
+ try {
+ const pin = this.#pinnedWorkdir(op);
+ this.#workdirChangeSaid = null;
+ return pin;
+ } catch (err) {
+ if (!(err instanceof PinnedPathError)) throw err;
+ this.#workdirChanged(err);
+ return null;
+ }
+ }
+
+ /** A use of the working directory refused because it changed under the session: said once per change, audited every time. */
+ #workdirChanged(err: PinnedPathError): void {
+ this.#store.audit(SYSTEM_IDENTITY.sub, "session.workdir_changed", this.id, err.message.slice(0, 2000));
+ const said = `${err.path}: ${err.reason}`;
+ if (this.#workdirChangeSaid === said || this.#destroyed) return;
+ this.#workdirChangeSaid = said;
+ const note = this.#makeMessage("info", `⚠ ${err.message}.`, SYSTEM_IDENTITY, undefined, undefined, { event: "workdir.changed" });
+ this.#persistAndBuffer(note);
+ this.#broadcastRaw(note);
+ }
+
/**
* Snapshot the workspace for `turnId` (see checkpoints.ts). Waits at most
* `waitMs` (0 = don't wait) so a slow snapshot on a huge tree delays the
@@ -9795,13 +10461,16 @@ export class Session {
): Promise {
if (!this.#checkpointsEnabled() || this.#destroyed) return;
const cfg = this.#config?.session.checkpoints;
+ // Only of the working directory as pinned (#448 round 14): one an agent swapped for a link is never read.
+ const workdir = this.#pinnedWorkdirOrSay("a snapshot of the files");
+ if (!workdir) return;
let started = false;
// Serialized: a turn's start snapshot waits for the previous turn's end
// snapshot (within its wait budget) instead of being skipped.
const job = this.#checkpointChain.then(() =>
createCheckpoint({
root: this.#checkpointRoot,
- workdir: this.workdir,
+ workdir,
sessionId: this.id,
turnId,
// Late = the agent may have been editing while it was taken: for a
@@ -9815,6 +10484,8 @@ export class Session {
).then((r) => {
if (!r.ok) {
console.error(`[codeoid/session ${this.id.slice(0, 8)}] checkpoint skipped for turn ${turnId.slice(0, 8)}: ${r.reason}`);
+ // It changed while being read: said, as before it.
+ if (r.pinned) this.#pinnedWorkdirOrSay("a snapshot of the files");
}
return r;
});
@@ -10003,19 +10674,27 @@ export class Session {
checkpointId: string,
opts: { asEndOf?: string; within?: string } = {},
): Promise<{ restored: number; removed: number }> {
- // Only ever write inside the fork's own worktree, resolved — a symlinked
- // folder must not carry the roll somewhere else.
+ // Only ever into the working directory as pinned (#448 round 14), never a
+ // link or another directory put in its place.
+ let workdir: PinnedDir;
+ try {
+ workdir = this.#pinnedWorkdir("putting a fork's files in place");
+ } catch (err) {
+ if (err instanceof PinnedPathError) this.#workdirChanged(err);
+ throw err;
+ }
+ // And only inside the fork's own worktree: the pinned path is real; the
+ // worktree is codeoid's own, just made, resolved once to compare.
if (opts.within) {
- const real = (p: string) => {
- try {
- return realpathSync(p);
- } catch {
- return null;
- }
- };
- const root = real(opts.within);
- const here = real(this.workdir);
- if (!root || !here || (here !== root && !here.startsWith(`${root}/`))) {
+ let root: string | null = null;
+ try {
+ // paths: the fork's own worktree, just made by codeoid, resolved once to compare with the pinned workdir
+ root = realpathSync(opts.within);
+ } catch {
+ root = null;
+ }
+ const here = workdir.path;
+ if (!root || (here !== root && !here.startsWith(`${root}/`))) {
throw new Error("this fork's folder isn't inside its own worktree");
}
}
@@ -10024,13 +10703,16 @@ export class Session {
if (!rec) throw new Error("that snapshot isn't available to this session");
const now = await currentTree({
root: this.#checkpointRoot,
- workdir: this.workdir,
+ workdir,
sessionId: this.id,
excludeDirs: this.#protectedDirs(),
excludePaths: this.#setupPaths(),
limits: this.#checkpointLimits(),
});
- if (!now.ok) throw new Error(now.reason);
+ if (!now.ok) {
+ if (now.pinned) this.#pinnedWorkdirOrSay("putting a fork's files in place");
+ throw new Error(now.reason);
+ }
const changes = await diffTrees(this.#checkpointRoot, this.id, rec.sha, now.tree);
if (changes.length === 0) return { restored: 0, removed: 0 };
const protectedHit = changes.find((c) => this.#isProtectedPath(c.path));
@@ -10040,11 +10722,14 @@ export class Session {
const kept = new Set(keep);
await restoreTree({
root: this.#checkpointRoot,
- workdir: this.workdir,
+ workdir,
sessionId: this.id,
from: now.tree,
to: rec.sha,
...(keep.length > 0 ? { keep } : {}),
+ }).catch((err) => {
+ if (err instanceof PinnedPathError) this.#workdirChanged(err);
+ throw err;
});
return {
restored: changes.filter((c) => c.status !== "A" && !kept.has(c.path)).length,
@@ -10171,15 +10856,30 @@ export class Session {
if (!start) {
return { ...plan, planId: planHash([turnId, ...removedIds]), filesUnavailable: "there is no snapshot of the files from when that turn started" };
}
+ // The working directory as pinned (#448 round 14): one moved aside and replaced is never read or written.
+ let workdir: PinnedDir;
+ try {
+ workdir = this.#pinnedWorkdir("going back a turn");
+ } catch (err) {
+ if (!(err instanceof PinnedPathError)) throw err;
+ this.#workdirChanged(err);
+ throw new RewindError("restore_failed", `${err.message}. Nothing was changed.`);
+ }
const now = await currentTree({
root: this.#checkpointRoot,
- workdir: this.workdir,
+ workdir,
sessionId: this.id,
excludeDirs: this.#protectedDirs(),
excludePaths: this.#setupPaths(),
limits: this.#checkpointLimits(),
});
- if (!now.ok) return { ...plan, planId: planHash([turnId, ...removedIds]), filesUnavailable: `the current files couldn't be read: ${now.reason}` };
+ if (!now.ok) {
+ if (now.pinned) {
+ this.#pinnedWorkdirOrSay("going back a turn");
+ throw new RewindError("restore_failed", `${now.reason}. Nothing was changed.`);
+ }
+ return { ...plan, planId: planHash([turnId, ...removedIds]), filesUnavailable: `the current files couldn't be read: ${now.reason}` };
+ }
const changes = await diffTrees(this.#checkpointRoot, this.id, start.sha, now.tree);
// Never delete a file the snapshot didn't hold because it was IGNORED
@@ -10197,14 +10897,15 @@ export class Session {
// Paths left alone because something else occupies them now (reported).
const skipped: string[] = [];
for (const c of changes) {
- // Never through a symlinked parent (git wouldn't either; say so).
- if (await hasSymlinkedParent(this.workdir, c.path)) {
+ // Never through a symlinked parent (git wouldn't either; say so). Looks in the pinned directory just
+ // checked (read only); the restore checks it again.
+ if (await hasSymlinkedParent(workdir.path, c.path)) {
keep.add(c.path);
skipped.push(c.path);
continue;
}
if (c.status === "A") continue;
- if (c.status === "D" && (await existsOnDisk(this.workdir, c.path))) {
+ if (c.status === "D" && (await existsOnDisk(workdir.path, c.path))) {
keep.add(c.path);
skipped.push(c.path);
continue;
@@ -10213,7 +10914,7 @@ export class Session {
for (let k = 1; k < parts.length; k++) {
const parent = parts.slice(0, k).join("/");
if (createdSet.has(parent)) break;
- if (await isNonDirectory(this.workdir, parent)) {
+ if (await isNonDirectory(workdir.path, parent)) {
keep.add(c.path);
skipped.push(c.path);
break;
@@ -10346,10 +11047,12 @@ export class Session {
const protectedHit = plan_.fileChanges.find((c) => this.#isProtectedPath(c.path));
if (protectedHit) throw new RewindError("restore_failed", `refusing to restore a path in codeoid's own data: ${protectedHit.path}`);
try {
+ // Into the working directory as pinned (#448 round 14), checked again before and after the write.
+ const workdir = this.#pinnedWorkdir("going back a turn");
await this.#serializedCheckpointOp(() =>
restoreTree({
root: this.#checkpointRoot,
- workdir: this.workdir,
+ workdir,
sessionId: this.id,
from: plan_.currentTreeSha!,
to: plan_.checkpointSha!,
@@ -10357,6 +11060,7 @@ export class Session {
}),
);
} catch (err) {
+ if (err instanceof PinnedPathError) this.#workdirChanged(err);
this.#auditRewind(sender, turnId, plan_, `failed: ${err instanceof Error ? err.message : String(err)}`, opts.force);
throw new RewindError("restore_failed", `The files couldn't be restored: ${err instanceof Error ? err.message : String(err)}. ${wasBusy ? "The running turn was stopped; the conversation was not changed." : "Nothing was changed."}`);
}
diff --git a/src/daemon/settings/manifest.ts b/src/daemon/settings/manifest.ts
index 329b45e2..099309d9 100644
--- a/src/daemon/settings/manifest.ts
+++ b/src/daemon/settings/manifest.ts
@@ -238,6 +238,12 @@ const sandbox: SettingsTab = {
envVar: "CODEOID_SANDBOX_WORKTREE_ROOTS",
applies: "next-session",
}),
+ cfg("sandbox.projectRoots", "Project roots (readable)", "Where sandboxed commands may read other repositories, read-only — and see what other sessions did in their checkouts there. @parent is the parent of the session's repository (e.g. ~/Workspace for a session in ~/Workspace/codeoid), the default. ~ expands. Credentials and secret files (.env, .npmrc, …) stay hidden inside. Writing elsewhere is per session: /sandbox add , or the agent asks (request_directory). Refused: /, your home or a dir holding it, a hidden dir of your home, ~/Library, codeoid's config dir, a system dir; empty: none.", {
+ kind: "string[]",
+ envVar: "CODEOID_SANDBOX_PROJECT_ROOTS",
+ default: ["@parent"],
+ applies: "next-session",
+ }),
cfg("sandbox.docker.socket", "Docker access", "Let sandboxed commands reach the container runtime's socket (Docker, rootless Docker, Podman; Docker Desktop on macOS) — for tests that start containers (testcontainers). Docker access can reach the whole machine: a container can mount /. Sessions say \"sandboxed, Docker access\"; a session can turn it on for itself (asked of its owner).", {
kind: "boolean",
envVar: "CODEOID_SANDBOX_DOCKER_SOCKET",
diff --git a/src/terminal/client.ts b/src/terminal/client.ts
index 2773bcdb..050597e9 100644
--- a/src/terminal/client.ts
+++ b/src/terminal/client.ts
@@ -465,6 +465,22 @@ export class TerminalClient {
if (line) console.log(data.pending ? ` now: ${line}` : line);
}
+ /** Add a directory to what a session's commands write, or remove one (docs/sandbox.md, Working across repositories). */
+ async sandboxDir(sessionIdOrName: string, action: "add" | "remove", path: string): Promise {
+ const sessionId = await this.#resolveSession(sessionIdOrName);
+ if (!sessionId) return;
+ const resp = await this.#request({ type: "session.sandbox_dir", id: randomUUID(), sessionId, action, path });
+ if (resp.type !== "response.ok") {
+ this.#printError(resp);
+ return;
+ }
+ const data = resp.data as { pending?: boolean; message?: string; sandbox?: SessionSandboxInfo | null };
+ if (data.pending) console.log(`Asked on the session: ${path} is writable once its owner says yes (codeoid approve ${sessionIdOrName}, or the web UI), from its next turn.`);
+ else if (data.message) console.log(data.message);
+ const line = sandboxSummary(data.sandbox ?? undefined);
+ if (line) console.log(` now: ${line}`);
+ }
+
/** The daemon's sandbox providers and whether each runs here (#448). */
async sandboxProviders(): Promise {
const resp = await this.#request({ type: "sandbox.providers", id: randomUUID() });
diff --git a/src/tests/fixtures/claude-sandbox-probe.ts b/src/tests/fixtures/claude-sandbox-probe.ts
index ab15bda0..a1493a4f 100644
--- a/src/tests/fixtures/claude-sandbox-probe.ts
+++ b/src/tests/fixtures/claude-sandbox-probe.ts
@@ -23,8 +23,11 @@
* offline, with an environment of its own) or not, and a second query after the first (a new
* CLI start in the same workspace, the one that runs its `git status`);
* the second one's system prompt is told as `system`. A workspace of the
- * test's own: `CODEOID_PROBE_WORKSPACE`; the session's worktree folder
- * name: `CODEOID_PROBE_WORKTREE_FOLDER`. Run in its own
+ * test's own: `CODEOID_PROBE_WORKSPACE`; the session's cwd below it
+ * (a monorepo's package; the CLI then loads the project's settings):
+ * `CODEOID_PROBE_WORKDIR`; the session's worktree folder
+ * name: `CODEOID_PROBE_WORKTREE_FOLDER`; its project roots and added dirs
+ * (comma-separated): `CODEOID_PROBE_PROJECT_ROOTS`, `CODEOID_PROBE_ADDED_DIRS`. Run in its own
* process: a test file that mocks the SDK module must not see this one.
* canUseTool here allows a sandboxed call and DENIES one asking to run
* outside the sandbox, as codeoid's gate would until its owner says yes.
@@ -36,6 +39,7 @@ import { type ClaudeTempDirs, claudeNativeEnv, claudeSandboxSettings, claudeTemp
import { BwrapSandboxProvider } from "../../daemon/sandbox/bwrap.js";
import { CWD_FROM_CALLER } from "../../daemon/sandbox/docker.js";
import { cliWorktrees, gitProgramLocations, gitShimSpec, writeGitShim } from "../../daemon/sandbox/git-shim.js";
+import { unguardedArgv } from "../../daemon/sandbox/pinned.js";
import { buildSandboxSpec } from "../../daemon/sandbox/policy.js";
import { daemonTempDir } from "../../daemon/sandbox/root.js";
import { launcherScript } from "../../daemon/sandbox/session-sandbox.js";
@@ -115,6 +119,9 @@ const spec = policy
? buildSandboxSpec({
sessionId: "probe",
workspace,
+ // CODEOID_PROBE_WORKDIR: the session's cwd below the workspace (a monorepo's package), which the CLI starts in
+ // with the project's settings (a new Claude start there loads them).
+ ...(process.env.CODEOID_PROBE_WORKDIR ? { workdir: process.env.CODEOID_PROBE_WORKDIR } : {}),
tempDir: join(root, "tmp"),
network: network === "allowlist" ? { mode: network, allow: [allowHost] } : { mode: network },
config: { sandbox: config } as never,
@@ -124,6 +131,9 @@ const spec = policy
dockerSocket: process.env.CODEOID_PROBE_DOCKER_SOCKET === "1",
// CODEOID_PROBE_WORKTREE_FOLDER: the session's worktree folder name (sandbox/worktrees.ts).
...(process.env.CODEOID_PROBE_WORKTREE_FOLDER ? { worktreeFolder: process.env.CODEOID_PROBE_WORKTREE_FOLDER } : {}),
+ // CODEOID_PROBE_PROJECT_ROOTS / CODEOID_PROBE_ADDED_DIRS (comma-separated): working across repositories.
+ ...(process.env.CODEOID_PROBE_PROJECT_ROOTS ? { projectRoots: process.env.CODEOID_PROBE_PROJECT_ROOTS.split(",") } : {}),
+ ...(process.env.CODEOID_PROBE_ADDED_DIRS ? { addedDirs: process.env.CODEOID_PROBE_ADDED_DIRS.split(",").map((dir) => ({ dir })) } : {}),
})
: {
sessionId: "probe",
@@ -153,7 +163,7 @@ function gitShim(): string {
if (!wrapped) throw new Error("no wrap");
const launcher = join(dir, "launch-git");
writeFileSync(launcher, launcherScript(wrapped.argv), { mode: 0o700 });
- return writeGitShim(join(dir, "git-bin"), { launcher, provider: wrapped.argv[0] ?? null, caller: "the Claude CLI", home });
+ return writeGitShim(join(dir, "git-bin"), { launcher, provider: unguardedArgv(wrapped.argv)[0] ?? null, caller: "the Claude CLI", home });
}
const cliEnv = nativeEnv ? claudeNativeEnv(spec as never, temp, { ...env, SANDBOX_RUNTIME: "1" }, gitMode === "shim" ? gitShim() : undefined) : env;
// A sign-in started after the session's sandbox was built (#448 round 2): its home where the daemon makes it, the
@@ -169,12 +179,12 @@ async function run(): Promise {
for await (const m of query({
prompt: "run the probe",
options: {
- cwd: workspace,
+ cwd: process.env.CODEOID_PROBE_WORKDIR || workspace,
env: cliEnv,
sandbox,
...(nativeEnv ? { mcpServers: { probe: { type: "stdio" as const, command: process.execPath, args: [join(import.meta.dir, "env-dump.ts"), join(root, "mcp-env.json")] } } } : {}),
permissionMode: "default",
- settingSources: [],
+ settingSources: process.env.CODEOID_PROBE_WORKDIR ? ["project", "user"] : [],
// The preset's context carries the CLI's git status (what its own git found).
...(gitMode ? { systemPrompt: { type: "preset" as const, preset: "claude_code" as const } } : {}),
canUseTool: async (name, input) => {
diff --git a/src/tests/fixtures/sandbox-manager-harness.ts b/src/tests/fixtures/sandbox-manager-harness.ts
new file mode 100644
index 00000000..bb3a4a45
--- /dev/null
+++ b/src/tests/fixtures/sandbox-manager-harness.ts
@@ -0,0 +1,244 @@
+/**
+ * A real SessionManager with a scriptable mock Claude backend and a fake
+ * bubblewrap provider, over a scratch home with `projects/{work,sibling}`
+ * (each a repository with a commit; the sibling has a `.env`): what the
+ * #448 round-13 manager tests share. Call {@link sandboxManagerHarness} at a
+ * test file's top level; it registers its own beforeEach/afterEach.
+ */
+
+import { afterEach, beforeEach, expect } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import type { CodeoidConfig } from "../../config.js";
+import type { ProviderEvent } from "../../daemon/providers/interface.js";
+import { MockSessionProvider } from "../../daemon/providers/mock/session-provider.js";
+import { ProviderRegistry } from "../../daemon/providers/registry.js";
+import { LocalSandboxHandle } from "../../daemon/sandbox/local-handle.js";
+import { SandboxRegistry } from "../../daemon/sandbox/registry.js";
+import type { SandboxProvider, SandboxSpec } from "../../daemon/sandbox/types.js";
+import type { Session } from "../../daemon/session.js";
+import { SessionManager } from "../../daemon/session-manager.js";
+import { Store } from "../../daemon/store.js";
+import { TranscriptStore } from "../../daemon/transcript.js";
+import { ALL_SCOPES } from "../../protocol/scopes.js";
+import { type AuthContext, CAPABILITIES, type DaemonMessage, type SessionInfo } from "../../protocol/types.js";
+
+export const AUTH: AuthContext = { sub: "user:r13", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" };
+export const CLIENT = { id: "cli", auth: AUTH, send: () => {} };
+export const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "claude", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } };
+/** A Bash call (the gate is asked about it), with ids of its own. */
+export const BASH_CALL = (id: string): ProviderEvent => ({ type: "tool_start", toolId: `t-${id}`, sdkToolUseId: `sdk-${id}`, name: "Bash", input: { command: "ls" }, approvalId: `ap-${id}` });
+/** A turn: a Bash call, then done. */
+export const BASH = (n: number): ProviderEvent[] => [BASH_CALL(String(n)), DONE];
+
+export const infoOf = (r: DaemonMessage) => (r as { data?: SessionInfo }).data;
+
+export async function until(pred: () => boolean): Promise {
+ for (let i = 0; i < 300 && !pred(); i++) await Bun.sleep(10);
+}
+
+const GIT_ENV = (home: string) => ({ PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@e.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@e.invalid" });
+export function git(args: string[], cwd: string): void {
+ const r = Bun.spawnSync(["git", ...args], { cwd, env: GIT_ENV(cwd) });
+ if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`);
+}
+
+export interface SandboxManagerHarness {
+ /** The scratch dir (projects/, home/, data/ in it). */
+ readonly tmp: string;
+ /** `projects`: the default project root. */
+ readonly root: string;
+ /** `projects/work`: where sessions are created. */
+ readonly workdir: string;
+ /** `projects/sibling`: another repository under the root, with a `.env`. */
+ readonly sibling: string;
+ readonly transcript: TranscriptStore;
+ /** Every audit entry so far. */
+ readonly audits: Array<{ action: string; detail: string }>;
+ /** The turns the next backend made runs (else a Bash call per turn). */
+ script: ProviderEvent[][] | null;
+ newManager(): SessionManager;
+ create(m: SessionManager, extra?: Record): Promise;
+ /** A session in autonomous mode, its sandbox resolved. */
+ autonomous(m: SessionManager, extra?: Record): Promise<{ info: SessionInfo | undefined; session: Session }>;
+ /** Every confirm raised on the session, as a client with dialogs sees it. */
+ dialogs(m: SessionManager, name: string): Array<{ requestId: string; title: string; message: string }>;
+ /** Every info line said on the session from now on. */
+ infoLines(m: SessionManager, name: string): string[];
+ /** Every gate decision the backends made so far saw (a directory change restarts the backend: a new one). */
+ decisions(): Array<{ behavior: string; message?: string }>;
+ /** The spec the last backend made would run its commands under now (throws when it can't be built). */
+ runtimeSpec(): SandboxSpec | null;
+ /** Send once the backend's restart for a sandbox change is done (a send right after it is refused until then). */
+ sendWhenReady(session: Session, text: string): Promise;
+}
+
+/** Register the harness's setup and teardown, and hand back what the tests use. */
+export function sandboxManagerHarness(): SandboxManagerHarness {
+ let tmp = "";
+ let store: Store | null = null;
+ let transcript: TranscriptStore | null = null;
+ let audits: Array<{ action: string; detail: string }> = [];
+ let providers: MockSessionProvider[] = [];
+ let runtimes: Array<() => SandboxSpec | null> = [];
+ const realHome = process.env.HOME;
+
+ const config = (): CodeoidConfig =>
+ ({
+ daemonUrl: "ws://127.0.0.1:7400",
+ dbPath: join(tmp, "data", "codeoid.db"),
+ transcriptDir: join(tmp, "data", "transcripts"),
+ auth: { baseUrl: "http://localhost:8899" },
+ zeroidUrl: "http://localhost:8899",
+ workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 },
+ compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 },
+ labeling: {},
+ telemetry: { osc8: "auto" },
+ autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" },
+ session: {},
+ conductor: { enabled: false, name: "conductor", provider: "claude" },
+ }) as CodeoidConfig;
+
+ const fake = (): SandboxProvider => {
+ const open = (spec: SandboxSpec) => new LocalSandboxHandle("bwrap", spec, (argv, o) => ({ argv: [...argv], env: o.env }));
+ return {
+ id: "bwrap",
+ displayName: "fake-bwrap",
+ capabilities: { isolation: "os", placement: "local", workspace: "mount", stdioProcess: true, commands: true, wrapsArgv: true, network: ["on", "off"], platforms: ["linux"] },
+ probe: async () => ({ available: true }),
+ open: async (s) => open(s),
+ openSync: open,
+ };
+ };
+
+ const h: SandboxManagerHarness = {
+ get tmp() {
+ return tmp;
+ },
+ get root() {
+ return join(tmp, "projects");
+ },
+ get workdir() {
+ return join(tmp, "projects", "work");
+ },
+ get sibling() {
+ return join(tmp, "projects", "sibling");
+ },
+ get transcript() {
+ return transcript as TranscriptStore;
+ },
+ get audits() {
+ return audits;
+ },
+ script: null,
+ newManager() {
+ const registry = new ProviderRegistry("claude");
+ registry.register({
+ id: "claude",
+ displayName: "claude",
+ create: (init) => {
+ // Ids of their own per backend: one made after a restart isn't taken for a call already seen.
+ const n = providers.length * 10;
+ const p = new MockSessionProvider("claude", h.script ?? [BASH(n + 1), BASH(n + 2), BASH(n + 3), BASH(n + 4)]);
+ h.script = null;
+ providers.push(p);
+ // The spec the backend's commands run under (Claude's own sandbox is given it): what the tests read.
+ runtimes.push(() => init.sandbox?.()?.spec ?? null);
+ return p;
+ },
+ });
+ const sandboxRegistry = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`);
+ sandboxRegistry.register(fake());
+ return new SessionManager(store as Store, transcript as TranscriptStore, undefined, undefined, undefined, { config: config(), providers: registry, sandboxRegistry });
+ },
+ create(m, extra = {}) {
+ return m.handle({ type: "session.create", id: "c", name: `s-${Math.random().toString(36).slice(2, 8)}`, workdir: h.workdir, ...extra } as never, AUTH, CLIENT);
+ },
+ async autonomous(m, extra = {}) {
+ const info = infoOf(await h.create(m, extra));
+ expect(await m.handle({ type: "session.set_mode", id: "m", sessionId: info?.id as string, mode: "autonomous" }, AUTH, CLIENT)).toMatchObject({ type: "response.ok" });
+ const session = m.findByName(info?.name as string, AUTH);
+ if (!session) throw new Error("no session");
+ await session.ensureSandbox();
+ return { info, session };
+ },
+ dialogs(m, name) {
+ const asked: Array<{ requestId: string; title: string; message: string }> = [];
+ m.findByName(name, AUTH)?.attach({
+ id: `dialogs-${Math.random()}`,
+ auth: AUTH,
+ capabilities: [CAPABILITIES.UI_DIALOGS],
+ send: (msg: { type: string; requestId?: string; title?: string; message?: string }) => {
+ if (msg.type === "session.ui_request") asked.push({ requestId: msg.requestId ?? "", title: msg.title ?? "", message: msg.message ?? "" });
+ },
+ } as never);
+ return asked;
+ },
+ infoLines(m, name) {
+ const lines: string[] = [];
+ m.findByName(name, AUTH)?.attach({ id: `info-${Math.random()}`, auth: AUTH, send: (msg: { type: string; role?: string; content?: string }) => void (msg.type === "session.message" && msg.role === "info" && lines.push(String(msg.content))) } as never);
+ return lines;
+ },
+ decisions() {
+ return providers.flatMap((p) => p.canUseToolResults as Array<{ behavior: string; message?: string }>);
+ },
+ runtimeSpec() {
+ return runtimes.at(-1)?.() ?? null;
+ },
+ async sendWhenReady(session, text) {
+ for (let i = 0; ; i++) {
+ try {
+ await session.send(text, AUTH);
+ return;
+ } catch (err) {
+ if (i > 100 || !String(err).includes("previous sandbox")) throw err;
+ await Bun.sleep(20);
+ }
+ }
+ },
+ };
+
+ beforeEach(() => {
+ providers = [];
+ runtimes = [];
+ h.script = null;
+ const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache");
+ mkdirSync(parent, { recursive: true });
+ tmp = realpathSync(mkdtempSync(join(parent, "codeoid-sbx-r13-")));
+ process.env.HOME = join(tmp, "home");
+ mkdirSync(process.env.HOME);
+ for (const d of [h.workdir, h.sibling]) {
+ mkdirSync(d, { recursive: true });
+ git(["init", "-q", "-b", "main"], d);
+ writeFileSync(join(d, "README.md"), "x\n");
+ git(["add", "."], d);
+ git(["commit", "-qm", "i"], d);
+ }
+ // The sibling's credentials: hidden in the root.
+ writeFileSync(join(h.sibling, ".env"), "SIBLING_SECRET=1\n");
+ mkdirSync(join(tmp, "data"));
+ store = new Store(join(tmp, "data", "codeoid.db"));
+ transcript = new TranscriptStore(join(tmp, "data", "transcripts"));
+ audits = [];
+ const s = store;
+ const audit = s.audit.bind(s);
+ s.audit = (subject, action, sessionId, detail) => {
+ audits.push({ action, detail: detail ?? "" });
+ audit(subject, action, sessionId, detail);
+ };
+ });
+
+ afterEach(async () => {
+ try {
+ await transcript?.flush();
+ } catch {}
+ try {
+ store?.close();
+ } catch {}
+ process.env.HOME = realHome;
+ rmSync(tmp, { recursive: true, force: true });
+ });
+
+ return h;
+}
diff --git a/src/tests/pinned-paths-static.test.ts b/src/tests/pinned-paths-static.test.ts
new file mode 100644
index 00000000..ae249f5f
--- /dev/null
+++ b/src/tests/pinned-paths-static.test.ts
@@ -0,0 +1,123 @@
+/**
+ * A static check (#448 round 14, docs/sandbox.md "Pinned paths"): in the
+ * sandbox, session and checkpoint modules, nothing resolves or uses a path
+ * on the host directly (a `node:fs` call, a program spawned, a `cwd:`, a
+ * `GIT_WORK_TREE`) but the pinned-path API (sandbox/pinned.ts), unless a
+ * `// paths: