From 8874fb8bd16db2cea911dda16cb4d7157ce4df6f Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Sun, 8 Nov 2026 11:41:21 +0800
Subject: [PATCH 01/23] feat: sandboxed sessions read other repositories under
project roots, and write directories added to them (#448)
A session in one directory couldn't read or write another repository: the
sandbox hid the home but a small allowlist, and only the workspace was
writable.
- Project roots (sandbox.projectRoots, CODEOID_SANDBOX_PROJECT_ROOTS, a
session's own at create): readable whole, read-only. The default, @parent,
is the parent of the session's repository (its main checkout's, for a
linked worktree); one that would be the home falls back to none, said on
the session. The hidden list still applies inside a root, and the secret
files at each project's top there (.env*, .envrc, .npmrc, ...) are hidden
too. Refused as worktree roots are: /, the home or a dir holding it, a
hidden dir of it, ~/Library, codeoid's config dir, a system dir.
- Added directories (SessionSandboxSetting.addedDirs): writable, each as a
workspace is (git structure and project settings held, its own worktree
folder, host-side file tools held to it, the end-of-turn git watch).
session.sandbox_dir adds (a widening: owner only, undelegated, an
owner-only confirm) or removes one. Persisted in the setting, so restart,
resume, forks and collaboration children keep them.
- request_directory, a codeoid tool on every backend that runs commands
(in-process on Claude and Qwen, a bridge tool on pi, /mcp/sandbox for
Codex, the Gemini CLI and opencode): guarded and interactive ask the
owner with the reason; autonomous adds one under a project root unasked
and refuses anything else at once; an unattended run is never parked.
- A change of directories is applied between turns with no background
work running (the backend restarted into it then), never mid-work.
- Claude's own sandbox lays its read binds after its write binds, so a
root holding the workspace is shown entry by entry around it.
- A workspace that is the home is allowed and says so.
Co-Authored-By: Claude Opus 5.5
---
packages/core/src/sandbox.ts | 34 +-
packages/protocol/src/schemas.test.ts | 1 +
packages/protocol/src/schemas.ts | 11 +
packages/protocol/src/types.ts | 44 ++
src/config.ts | 25 +-
src/daemon/providers/acp/index.ts | 15 +
src/daemon/providers/claude/index.ts | 12 +-
src/daemon/providers/codex/index.ts | 32 +-
src/daemon/providers/opencode/index.ts | 8 +
src/daemon/providers/pi/index.ts | 31 +-
src/daemon/providers/qwen/index.ts | 5 +
src/daemon/providers/registry.ts | 15 +
src/daemon/providers/tool-safety.ts | 18 +
src/daemon/sandbox/claude-native.ts | 51 ++-
src/daemon/sandbox/dir-rules.ts | 47 +++
src/daemon/sandbox/policy.ts | 107 +++--
src/daemon/sandbox/project-roots.ts | 193 +++++++++
src/daemon/sandbox/request-directory.ts | 184 ++++++++
src/daemon/sandbox/session-sandbox.ts | 197 +++++++--
src/daemon/sandbox/types.ts | 16 +
src/daemon/server.ts | 10 +
src/daemon/session-manager.ts | 102 ++++-
src/daemon/session.ts | 415 +++++++++++++++++--
src/tests/sandbox-dirs-manager.test.ts | 277 +++++++++++++
src/tests/sandbox-policy.test.ts | 2 +-
src/tests/sandbox-project-roots-real.test.ts | 225 ++++++++++
src/tests/sandbox-project-roots.test.ts | 361 ++++++++++++++++
src/tests/sandbox-session.test.ts | 23 +-
28 files changed, 2345 insertions(+), 116 deletions(-)
create mode 100644 src/daemon/sandbox/dir-rules.ts
create mode 100644 src/daemon/sandbox/project-roots.ts
create mode 100644 src/daemon/sandbox/request-directory.ts
create mode 100644 src/tests/sandbox-dirs-manager.test.ts
create mode 100644 src/tests/sandbox-project-roots-real.test.ts
create mode 100644 src/tests/sandbox-project-roots.test.ts
diff --git a/packages/core/src/sandbox.ts b/packages/core/src/sandbox.ts
index 3fb95abd..746c9d71 100644
--- a/packages/core/src/sandbox.ts
+++ b/packages/core/src/sandbox.ts
@@ -20,13 +20,42 @@ export interface SandboxBadge {
/** Why Docker access is a warning, wherever it is shown (a tooltip, a card). */
export const DOCKER_ACCESS_WHY = "Docker access can reach the whole machine (a container can mount /)";
+/** What a session working in its home is told (the chip and its tooltip). */
+export const HOME_WORKSPACE_LINE = "sandboxed: workspace is your home — only credentials are protected";
+
+/**
+ * What the sandbox reads and writes besides the workspace, in words (a
+ * tooltip, a card): "reads ~/Workspace (read-only); also writes ~/Workspace/b".
+ * Null when there is nothing beyond the workspace.
+ */
+export function sandboxDirsText(info: Pick | undefined): string | null {
+ if (!info) return null;
+ const parts: string[] = [];
+ if (info.projectRoots?.length) parts.push(`reads ${info.projectRoots.join(", ")} (read-only)`);
+ if (info.addedDirs?.length) parts.push(`also writes ${info.addedDirs.join(", ")}`);
+ const add = info.pendingDirs?.add ?? [];
+ const remove = info.pendingDirs?.remove ?? [];
+ if (add.length > 0 || remove.length > 0) {
+ parts.push(`from the next turn: ${[...add.map((d) => `+${d}`), ...remove.map((d) => `−${d}`)].join(", ")}`);
+ }
+ return parts.length > 0 ? parts.join("; ") : null;
+}
+
/** The session header's badge. Null on a daemon without sandboxing. */
export function sandboxBadge(info: SessionSandboxInfo | undefined): SandboxBadge | null {
if (!info) return null;
switch (info.state) {
case "on": {
const net = info.networkRequested ? `network ${info.network} (asked: ${info.networkRequested})` : `network ${info.network}`;
- const limits = info.limits?.length ? `\n${info.limits.map((l) => `• ${l}`).join("\n")}` : "";
+ const dirs = sandboxDirsText(info);
+ const limits = `${dirs ? `\nIt ${dirs}.` : ""}${info.limits?.length ? `\n${info.limits.map((l) => `• ${l}`).join("\n")}` : ""}`;
+ if (info.homeWorkspace && !info.dockerSocket) {
+ return {
+ text: HOME_WORKSPACE_LINE,
+ tone: "warn",
+ title: `Sandboxed, but the workspace is your home: commands can read and change all of it but the credentials the sandbox hides. ${info.via ?? info.providerName ?? info.provider ?? "on"}, ${net}.${limits}`,
+ };
+ }
if (info.dockerSocket) {
return {
text: info.enforced ? "sandboxed, Docker access" : "sandboxed partly, Docker access",
@@ -60,7 +89,8 @@ export function sandboxSummary(info: SessionSandboxInfo | undefined): string | n
case "on": {
const net = info.networkRequested ? `network ${info.network} (asked ${info.networkRequested})` : `network ${info.network}`;
const hosts = info.network === "allowlist" && info.allowedHosts?.length ? ` [${info.allowedHosts.join(", ")}]` : "";
- return `sandbox: ${info.providerName ?? info.provider}, ${net}${hosts}${info.dockerSocket ? ", Docker access (can reach the whole machine)" : ""}${info.enforced ? "" : " — not everything asked is enforced"}`;
+ const dirs = sandboxDirsText(info);
+ return `sandbox: ${info.providerName ?? info.provider}, ${net}${hosts}${info.dockerSocket ? ", Docker access (can reach the whole machine)" : ""}${info.homeWorkspace ? ", workspace is your home (only credentials are protected)" : ""}${dirs ? `; ${dirs}` : ""}${info.enforced ? "" : " — not everything asked is enforced"}`;
}
case "off":
return "sandbox: off";
diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts
index 682cc14a..2c586a89 100644
--- a/packages/protocol/src/schemas.test.ts
+++ b/packages/protocol/src/schemas.test.ts
@@ -95,6 +95,7 @@ const samples: { [T in ClientTypes]: Extract } = {
"session.destroy": { type: "session.destroy", id: "r9", sessionId: "s1" },
"session.set_mode": { type: "session.set_mode", id: "r10", sessionId: "s1", mode: "autonomous", maxTurns: 5 },
"session.set_sandbox": { type: "session.set_sandbox", id: "r10s", sessionId: "s1", sandbox: { provider: "bwrap", network: "allowlist", allowedHosts: ["github.com"] } },
+ "session.sandbox_dir": { type: "session.sandbox_dir", id: "r10d", sessionId: "s1", action: "add", path: "/home/u/Workspace/b" },
"sandbox.providers": { type: "sandbox.providers", id: "r10p" },
"session.pin": { type: "session.pin", id: "r11", sessionId: "s1", path: "SPEC.md" },
"session.unpin": { type: "session.unpin", id: "r12", sessionId: "s1", path: "SPEC.md" },
diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts
index 06cff85f..6a1b7ecb 100644
--- a/packages/protocol/src/schemas.ts
+++ b/packages/protocol/src/schemas.ts
@@ -93,6 +93,8 @@ export const sessionSandboxSettingSchema = z
network: z.enum(["on", "off", "allowlist"]).optional(),
allowedHosts: z.array(z.string().min(1).max(253)).max(256).optional(),
dockerSocket: z.boolean().optional(),
+ projectRoots: z.array(z.string().min(1).max(4096)).max(64).optional(),
+ addedDirs: z.array(z.string().min(1).max(4096)).max(64).optional(),
})
.strict();
@@ -277,6 +279,14 @@ export const sessionSetSandboxSchema = z.object({
sandbox: sessionSandboxSettingSchema,
});
+export const sessionSandboxDirSchema = z.object({
+ ...base,
+ type: z.literal("session.sandbox_dir"),
+ sessionId: sessionIdField,
+ action: z.enum(["add", "remove"]),
+ path: z.string().min(1).max(4096),
+});
+
export const sandboxProvidersSchema = z.object({ ...base, type: z.literal("sandbox.providers") });
export const sessionPinSchema = z.object({
@@ -1271,6 +1281,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
sessionDestroySchema,
sessionSetModeSchema,
sessionSetSandboxSchema,
+ sessionSandboxDirSchema,
sandboxProvidersSchema,
sessionPinSchema,
sessionUnpinSchema,
diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts
index c583c9dc..a2c36772 100644
--- a/packages/protocol/src/types.ts
+++ b/packages/protocol/src/types.ts
@@ -386,6 +386,22 @@ export interface SessionSandboxSetting {
* machine: turning it on is a widening, asked of the owner.
*/
dockerSocket?: boolean;
+ /**
+ * Where the session's commands read other repositories, read-only
+ * (docs/sandbox.md, "Working across repositories"): absolute paths (`~`
+ * expands) or `@parent`, the parent of the session's repository. Absent:
+ * `sandbox.projectRoots` (by default `@parent`); `[]`: none. One the
+ * daemon's default doesn't have is a widening.
+ */
+ projectRoots?: string[];
+ /**
+ * Directories the session's commands may write besides its workspace,
+ * absolute: each treated as a workspace (its git structure and project
+ * settings held, its own worktree folder). Adding one is a widening (the
+ * owner's own decision; `session.sandbox_dir`, or the agent's
+ * `request_directory`).
+ */
+ addedDirs?: string[];
}
export type SandboxNetworkMode = "on" | "off" | "allowlist";
@@ -423,6 +439,17 @@ export interface SessionSandboxInfo {
limits?: string[];
/** Commands can reach the container runtime's socket (the owner's opt-in): it can reach the whole machine. */
dockerSocket?: boolean;
+ /** The project roots commands read (read-only), resolved: other repositories, other sessions' checkouts. */
+ projectRoots?: string[];
+ /** The directories commands write besides the workspace, as the running sandbox has them. */
+ addedDirs?: string[];
+ /**
+ * Added (or removed) directories not applied yet: they take effect at the
+ * session's next turn (once no turn or background work is running).
+ */
+ pendingDirs?: { add: string[]; remove: string[] };
+ /** The workspace is the home itself: commands read and write all of it but the credentials the sandbox hides. */
+ homeWorkspace?: boolean;
/** Everything asked is enforced: what an autonomous session, a routine or a loop needs to start. */
enforced: boolean;
}
@@ -1265,6 +1292,7 @@ export type ClientMessage =
| SessionDestroyMsg
| SessionSetModeMsg
| SessionSetSandboxMsg
+ | SessionSandboxDirMsg
| SandboxProvidersMsg
| SessionPinMsg
| SessionUnpinMsg
@@ -1841,6 +1869,22 @@ export interface SessionSetSandboxMsg extends BaseClientMsg {
sandbox: SessionSandboxSetting;
}
+/**
+ * Add a directory to what a session's commands may write, or remove one
+ * (docs/sandbox.md, "Working across repositories"). Owner only, with
+ * session:approve and an undelegated token. Removing applies at once (from
+ * the next turn); adding is a widening, asked of the owner on the session
+ * (`data: { pending: true }`), and lands on a yes. It takes effect at the
+ * session's next turn with no background work running.
+ */
+export interface SessionSandboxDirMsg extends BaseClientMsg {
+ type: "session.sandbox_dir";
+ sessionId: string;
+ action: "add" | "remove";
+ /** Absolute (`~` expands). */
+ path: string;
+}
+
/** The sandbox providers this daemon has, and whether each runs here (#448). Needs session:create. */
export interface SandboxProvidersMsg extends BaseClientMsg {
type: "sandbox.providers";
diff --git a/src/config.ts b/src/config.ts
index a27f24c9..a63370ce 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -18,6 +18,7 @@ import type { ModelPrice } from "./daemon/pricing/index.js";
import type { SandboxConfig } from "./daemon/sandbox/policy.js";
import { DEFAULT_HOME_READABLE } from "./daemon/sandbox/home-readable.js";
import { validAllowedHost } from "./daemon/sandbox/hosts.js";
+import { PROJECT_ROOT_PARENT, sandboxDirProblem } from "./daemon/sandbox/dir-rules.js";
import { worktreeRootProblem } from "./daemon/sandbox/worktrees.js";
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync, rmSync } from "node:fs";
import { randomBytes } from "node:crypto";
@@ -294,6 +295,27 @@ const SandboxSchema = z
)
.max(64)
.default([]),
+ /**
+ * Where sandboxed commands read other repositories, read-only (docs/sandbox.md,
+ * "Working across repositories"): `@parent` (the parent of the session's
+ * repository, the default), or absolute paths (`~` expands). `[]`: none.
+ * Refused as worktree roots are (never `/`, the home, a hidden dir of it, …);
+ * a default that would be the home falls back to none, said on the session.
+ */
+ projectRoots: z
+ .array(
+ z
+ .string()
+ .min(1)
+ .max(4096)
+ .superRefine((root, ctx) => {
+ if (root === PROJECT_ROOT_PARENT) return;
+ const problem = sandboxDirProblem(root, { configDir: getConfigDir() });
+ if (problem) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `not a project root: ${problem}` });
+ }),
+ )
+ .max(64)
+ .default([PROJECT_ROOT_PARENT]),
/** The bwrap binary (default: on PATH). */
bwrapPath: z.string().refine(isAbsolute, "must be an absolute path").optional(),
docker: z
@@ -305,7 +327,7 @@ const SandboxSchema = z
})
.default({ image: "node:22-bookworm-slim", socket: false }),
})
- .default({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], docker: { image: "node:22-bookworm-slim", socket: false } });
+ .default({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], projectRoots: [PROJECT_ROOT_PARENT], docker: { image: "node:22-bookworm-slim", socket: false } });
const CompressSchema = z
.object({
@@ -1819,6 +1841,7 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [
{ env: "CODEOID_SANDBOX_DOCKER_IMAGE", path: "sandbox.docker.image", kind: "string" },
{ env: "CODEOID_SANDBOX_DOCKER_SOCKET", path: "sandbox.docker.socket", kind: "boolean" },
{ env: "CODEOID_SANDBOX_WORKTREE_ROOTS", path: "sandbox.worktreeRoots", kind: "csv" },
+ { env: "CODEOID_SANDBOX_PROJECT_ROOTS", path: "sandbox.projectRoots", kind: "csv" },
{ env: "CODEOID_ROUTINES_MAX_CONCURRENT", path: "routines.maxConcurrentRuns", kind: "int" },
{ env: "CODEOID_MCP_CONTROL_ACTIONS", path: "mcpControl.allowActions", kind: "boolean" },
{ env: "CODEOID_MAX_SESSIONS_PER_USER", path: "rateLimit.maxSessionsPerUser", kind: "int" },
diff --git a/src/daemon/providers/acp/index.ts b/src/daemon/providers/acp/index.ts
index 2bedf909..14ae698b 100644
--- a/src/daemon/providers/acp/index.ts
+++ b/src/daemon/providers/acp/index.ts
@@ -24,6 +24,7 @@
* agent can become a backend by reusing this shape with a different binary.
*/
+import { SANDBOX_MCP_SERVER_NAME, type SandboxMcpMount } from "../../sandbox/request-directory.js";
import type { McpTenant } from "../../mcp/oauth.js";
import { AsyncQueue } from "../../async-queue.js";
import type { Store } from "../../store.js";
@@ -86,6 +87,8 @@ export interface GeminiAcpProviderInit {
* server list (per turn for claude), by which time it is set.
*/
blackboardMcp?: () => { url: string; token: string } | undefined;
+ /** The sandbox's `request_directory` over the daemon's `/mcp/sandbox` (sandbox/request-directory.ts). */
+ sandboxMcp?: () => SandboxMcpMount | undefined;
/** Cross-backend MCP registry — external servers mount on session/new
* (gemini-cli owns its client); approval flows through canUseTool. */
@@ -126,6 +129,7 @@ export class GeminiAcpProvider implements SessionProvider {
#memoryMcp: MemoryMcpMount | null;
/** Role-scoped blackboard mount; token minted+revoked by the SessionManager. */
readonly #blackboardMcp: (() => { url: string; token: string } | undefined) | null;
+ readonly #sandboxMcp: (() => SandboxMcpMount | undefined) | null;
#mcpRegistry: McpServerView | null;
/** Live scoped token for the mounted memory endpoint; revoked on teardown. */
#memoryToken: string | null = null;
@@ -163,6 +167,7 @@ export class GeminiAcpProvider implements SessionProvider {
this.#workspaceId = init.workspaceId ?? init.sessionId;
this.#memoryMcp = init.memoryMcp ?? null;
this.#blackboardMcp = init.blackboardMcp ?? null;
+ this.#sandboxMcp = init.sandboxMcp ?? null;
this.#mcpRegistry = init.mcpRegistry ?? null;
this.#proxy = new McpProxyLease(
init.mcpProxy,
@@ -458,6 +463,16 @@ export class GeminiAcpProvider implements SessionProvider {
headers: [{ name: "Authorization", value: `Bearer ${bb.token}` }],
});
}
+ // The sandbox's ask for a directory to write (sandbox/request-directory.ts): the session's token.
+ const sbx = this.#sandboxMcp?.();
+ if (sbx) {
+ servers.push({
+ type: "http",
+ name: SANDBOX_MCP_SERVER_NAME,
+ url: sbx.url,
+ headers: [{ name: "Authorization", value: `Bearer ${sbx.token}` }],
+ });
+ }
// Registry servers — native mount (gemini-cli owns its MCP client). ACP's
// McpServer shape: http → {type,url,headers[]}; stdio → {command,args,env[]}
// (env/headers are {name,value} pairs, mirroring the memory http mount).
diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts
index 2cdc01bd..683ca7cd 100644
--- a/src/daemon/providers/claude/index.ts
+++ b/src/daemon/providers/claude/index.ts
@@ -48,6 +48,7 @@ import { mcpToolPrefix, resolveEnvMap } from "../../mcp/types.js";
import type { CompressionRegistry } from "../../compress/index.js";
import { FLEET_TOOL_NAMES } from "../../fleet.js";
import { LOOP_MCP_SERVER_NAME, LOOP_REPORT_TOOL_FQ } from "../../loops/tools.js";
+import { buildSandboxMcpServer, REQUEST_DIRECTORY_TOOL_FQ, type RequestDirectoryInput, SANDBOX_MCP_SERVER_NAME } from "../../sandbox/request-directory.js";
import { rewriteBashToolInput } from "../../compress/index.js";
import { type CodeoidConfig, getConfigDir } from "../../../config.js";
import { assessShellCommandSync } from "../../risk/shell-risk.js";
@@ -196,6 +197,8 @@ export interface ClaudeProviderInit {
* server list (per turn for claude), by which time it is set.
*/
blackboardMcp?: () => { url: string; token: string } | undefined;
+ /** The sandbox's `request_directory`, decided by the session (sandbox/request-directory.ts): mounted in-process. */
+ requestDirectory?: (input: RequestDirectoryInput) => Promise;
/** Cross-backend MCP registry — external servers are mounted natively on the
* SDK (claude owns its own MCP client); approval flows through canUseTool. */
mcpRegistry?: McpServerView;
@@ -1077,6 +1080,8 @@ export class ClaudeProvider implements SessionProvider {
// so the external-server timeout doesn't apply.
...(init.fleet ? { codeoid_fleet: init.fleet } : {}),
...(init.loop ? { [LOOP_MCP_SERVER_NAME]: init.loop } : {}),
+ // The sandbox's ask for a directory to write (sandbox/request-directory.ts): in-process, no token.
+ ...(init.requestDirectory ? { [SANDBOX_MCP_SERVER_NAME]: buildSandboxMcpServer(init.sessionId, (_id, input) => (init.requestDirectory as (i: RequestDirectoryInput) => Promise)(input)) } : {}),
// Collaboration children only — the role-scoped goal blackboard. Mounted
// over HTTP rather than in-process precisely so the SAME surface works on
// gemini/codex; claude deliberately gets no privileged path here (#245).
@@ -1119,6 +1124,8 @@ export class ClaudeProvider implements SessionProvider {
// deliberately absent so they still ride the owner's approval flow.
...(this.#init.fleet ? FLEET_TOOL_NAMES.map((t) => `mcp__codeoid_fleet__${t}`) : []),
...(this.#init.loop ? [LOOP_REPORT_TOOL_FQ] : []),
+ // An ask itself: the session decides it (owner's confirm, or the project roots in autonomous mode).
+ ...(this.#init.requestDirectory ? [REQUEST_DIRECTORY_TOOL_FQ] : []),
];
this.#autoApprovedTools = new Set(autoApprovedToolNames);
if (this.#consumerTask && this.#inputQueue && !this.#inputQueue.closed) {
@@ -1404,7 +1411,7 @@ export class ClaudeProvider implements SessionProvider {
// project settings (read-only to commands; this tool runs outside the sandbox) goes
// to codeoid's gate (asked, or refused in autonomous mode): never on an allow rule. Fail-closed.
if (sandbox?.plan.fileTools === false) {
- const held = heldWriteDecision(input, opts.workdir);
+ const held = heldWriteDecision(input, opts.workdir, sandbox.spec.addedDirs ?? []);
if (held) return held;
// And in autonomous mode any file tool beyond what the sandbox allows its commands
// (#448 rounds 8, 9): to the gate, which refuses it, never on an allow rule. Fail-closed.
@@ -2905,10 +2912,11 @@ export function claudeCatalogEntry(m: {
export function heldWriteDecision(
input: PreToolUseHookInput,
workdir: string,
+ addedDirs: readonly string[] = [],
): { hookSpecificOutput: { hookEventName: "PreToolUse"; permissionDecision: "ask"; permissionDecisionReason: string } } | null {
let reason: string;
try {
- const held = heldWriteOf(input.tool_name, (input.tool_input ?? {}) as Record, workdir, protectedWriteTargets(workdir));
+ const held = heldWriteOf(input.tool_name, (input.tool_input ?? {}) as Record, workdir, protectedWriteTargets(workdir, undefined, addedDirs));
if (!held) return null;
reason = heldWriteReason(held);
} catch {
diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts
index 92f6fc45..8f33a120 100644
--- a/src/daemon/providers/codex/index.ts
+++ b/src/daemon/providers/codex/index.ts
@@ -70,6 +70,7 @@ import { buildCodexEnv, withExtraEnv } from "../env.js";
import { CodexRpcProcess } from "./rpc.js";
import { EFFORT_LEVELS, type EffortLevel, type SessionMode } from "../../../protocol/types.js";
import { MEMORY_MCP_SERVER_NAME, MEMORY_MCP_TOKEN_ENV, type MemoryMcpMount } from "../../memory/mcp-http.js";
+import { REQUEST_DIRECTORY_WAIT_MS, SANDBOX_MCP_SERVER_NAME, SANDBOX_MCP_TOKEN_ENV, type SandboxMcpMount } from "../../sandbox/request-directory.js";
import {
BLACKBOARD_MCP_SERVER_NAME,
BLACKBOARD_MCP_TOKEN_ENV,
@@ -126,6 +127,8 @@ export interface CodexProviderInit {
* server list (per turn for claude), by which time it is set.
*/
blackboardMcp?: () => { url: string; token: string } | undefined;
+ /** The sandbox's `request_directory` over the daemon's `/mcp/sandbox` (sandbox/request-directory.ts). */
+ sandboxMcp?: () => SandboxMcpMount | undefined;
/** Cross-backend MCP registry — external servers mount natively via `-c
* mcp_servers.*` (codex owns its client); approval flows through canUseTool. */
@@ -242,7 +245,7 @@ export function codexSandboxPolicy(rt: SandboxRuntime | null, fallback: SandboxM
// What of them must stay read-only is held so by codeoid's bubblewrap around the app-server (gitWorkspacePaths).
// And the sandbox's cache dir, where the toolchains' caches are pointed (SandboxSpec.env), and the repository's
// worktree folders, where a command checks a new worktree out (sandbox/worktrees.ts).
- writableRoots: [...new Set([workdir, ...tempRoots(rt.spec), ...(rt.spec.gitDirs ?? []), ...cacheRoots(rt.spec), ...(rt.spec.worktreeFolders ?? [])])],
+ writableRoots: [...new Set([workdir, ...tempRoots(rt.spec), ...(rt.spec.gitDirs ?? []), ...cacheRoots(rt.spec), ...(rt.spec.worktreeFolders ?? []), ...(rt.spec.addedDirs ?? [])])],
networkAccess: rt.spec.network.mode === "on",
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
@@ -389,6 +392,7 @@ export class CodexProvider implements SessionProvider {
#memoryMcp: MemoryMcpMount | null;
/** Role-scoped blackboard mount; token minted+revoked by the SessionManager. */
readonly #blackboardMcp: (() => { url: string; token: string } | undefined) | null;
+ readonly #sandboxMcp: (() => SandboxMcpMount | undefined) | null;
#mcpRegistry: McpServerView | null;
/** Live scoped token for the mounted memory endpoint; revoked on teardown. */
#memoryToken: string | null = null;
@@ -493,6 +497,7 @@ export class CodexProvider implements SessionProvider {
this.#workspaceId = init.workspaceId ?? init.sessionId;
this.#memoryMcp = init.memoryMcp ?? null;
this.#blackboardMcp = init.blackboardMcp ?? null;
+ this.#sandboxMcp = init.sandboxMcp ?? null;
this.#mcpRegistry = init.mcpRegistry ?? null;
this.#proxy = new McpProxyLease(
init.mcpProxy,
@@ -663,6 +668,26 @@ export class CodexProvider implements SessionProvider {
};
}
+ /**
+ * `-c mcp_servers.codeoid_sandbox.*`: the sandbox's `request_directory`,
+ * same shape as the blackboard's (the session's token, owned by the
+ * SessionManager). Its call waits on the owner's answer, so its timeout
+ * is past the session's own wait (REQUEST_DIRECTORY_WAIT_MS).
+ */
+ #sandboxMcpSpawn(): { args: string[]; env: Record } {
+ const mount = this.#sandboxMcp?.();
+ if (!mount) return { args: [], env: {} };
+ const key = `mcp_servers.${SANDBOX_MCP_SERVER_NAME}`;
+ return {
+ args: [
+ "-c", `${key}.url=${JSON.stringify(mount.url)}`,
+ "-c", `${key}.bearer_token_env_var=${JSON.stringify(SANDBOX_MCP_TOKEN_ENV)}`,
+ "-c", `${key}.tool_timeout_sec=${Math.ceil(REQUEST_DIRECTORY_WAIT_MS / 1000) + 60}`,
+ ],
+ env: { [SANDBOX_MCP_TOKEN_ENV]: mount.token },
+ };
+ }
+
/**
* `-c mcp_servers.*` overrides mounting the registry's external servers on the
* codex app-server — a native mount, since codex owns its own MCP client.
@@ -930,6 +955,7 @@ export class CodexProvider implements SessionProvider {
// the user's auth + config; these just add the one server.
const mcp = this.#memoryMcpSpawn();
const bb = this.#blackboardMcpSpawn();
+ const sbx = this.#sandboxMcpSpawn();
const reg = this.#registryMcpArgs();
const shellEnv = codexShellEnvExcludeArgs();
// Without the exclude, the proxy token would reach the commands codex runs.
@@ -946,7 +972,7 @@ export class CodexProvider implements SessionProvider {
const env = withExtraEnv(
await applyCodexAccount(
this.#resolveAccount?.() ?? null,
- { ...buildCodexEnv(), ...mcp.env, ...bb.env, ...reg.env, ...oauth.env },
+ { ...buildCodexEnv(), ...mcp.env, ...bb.env, ...sbx.env, ...reg.env, ...oauth.env },
{ command: this.#command, argsPrefix: this.#argsPrefix },
),
this.#extraEnv,
@@ -970,7 +996,7 @@ export class CodexProvider implements SessionProvider {
this.#proc = new CodexRpcProcess({
command: this.#command,
argsPrefix: this.#argsPrefix,
- args: [...mcp.args, ...bb.args, ...reg.args, ...oauth.args, ...(shellEnv ?? [])],
+ args: [...mcp.args, ...bb.args, ...sbx.args, ...reg.args, ...oauth.args, ...(shellEnv ?? [])],
cwd: opts.workdir,
env: procEnv,
...(handle?.wrap ? { wrap: handle.wrap.bind(handle) } : {}),
diff --git a/src/daemon/providers/opencode/index.ts b/src/daemon/providers/opencode/index.ts
index 6c376b52..f6a1d37a 100644
--- a/src/daemon/providers/opencode/index.ts
+++ b/src/daemon/providers/opencode/index.ts
@@ -33,6 +33,7 @@
* authoritative for files.
*/
+import { REQUEST_DIRECTORY_WAIT_MS, SANDBOX_MCP_SERVER_NAME, type SandboxMcpMount } from "../../sandbox/request-directory.js";
import { join } from "node:path";
import type { BackendHostFiles } from "../../sandbox/host-file-tools.js";
import type { SandboxRuntime } from "../../sandbox/session-sandbox.js";
@@ -162,6 +163,8 @@ export interface OpencodeProviderInit {
memoryMcp?: MemoryMcpMount;
mcpProxy?: McpProxyMount;
blackboardMcp?: () => { url: string; token: string } | undefined;
+ /** The sandbox's `request_directory` over the daemon's `/mcp/sandbox` (sandbox/request-directory.ts). */
+ sandboxMcp?: () => SandboxMcpMount | undefined;
mcpRegistry?: McpServerView;
onModels?: (models: ReadonlyArray) => void;
/** opencode agent the session runs (`providers.opencode.agent`); absent = opencode's default. */
@@ -352,6 +355,7 @@ export class OpencodeProvider implements SessionProvider {
readonly #workspaceId: string;
readonly #memoryMcp: MemoryMcpMount | null;
readonly #blackboardMcp: (() => { url: string; token: string } | undefined) | null;
+ readonly #sandboxMcp: (() => SandboxMcpMount | undefined) | null;
readonly #mcpRegistry: McpServerView | null;
readonly #proxy: McpProxyLease;
readonly #onModels: OpencodeProviderInit["onModels"];
@@ -538,6 +542,7 @@ export class OpencodeProvider implements SessionProvider {
this.#workspaceId = init.workspaceId ?? init.sessionId;
this.#memoryMcp = init.memoryMcp ?? null;
this.#blackboardMcp = init.blackboardMcp ?? null;
+ this.#sandboxMcp = init.sandboxMcp ?? null;
this.#mcpRegistry = init.mcpRegistry ?? null;
this.#onModels = init.onModels;
this.#agent = init.agent;
@@ -1641,6 +1646,9 @@ export class OpencodeProvider implements SessionProvider {
}
const bb = this.#blackboardMcp?.();
if (bb) configs.push({ name: BLACKBOARD_MCP_SERVER_NAME, config: remote(bb.url, bb.token) });
+ // The sandbox's ask for a directory to write (sandbox/request-directory.ts); its call waits on the owner.
+ const sbx = this.#sandboxMcp?.();
+ if (sbx) configs.push({ name: SANDBOX_MCP_SERVER_NAME, config: { ...remote(sbx.url, sbx.token), timeout: REQUEST_DIRECTORY_WAIT_MS + 60_000 } });
for (const spec of this.#mcpRegistry?.forNativeMount(this.id) ?? []) {
const t = spec.transport;
if (t.kind === "stdio") {
diff --git a/src/daemon/providers/pi/index.ts b/src/daemon/providers/pi/index.ts
index d921823c..7be78236 100644
--- a/src/daemon/providers/pi/index.ts
+++ b/src/daemon/providers/pi/index.ts
@@ -19,6 +19,7 @@
* - `get_commands` → codeoid `session.commands` (slash passthrough)
*/
+import { REQUEST_DIRECTORY_DESCRIPTION, REQUEST_DIRECTORY_SCHEMA, REQUEST_DIRECTORY_TOOL_FQ, type RequestDirectoryInput } from "../../sandbox/request-directory.js";
import type { McpTenant } from "../../mcp/oauth.js";
import { randomUUID } from "node:crypto";
import { AsyncQueue } from "../../async-queue.js";
@@ -92,6 +93,11 @@ export interface PiProviderInit {
* pi (which has no MCP client) by registering bridge tools that proxy to the hub. */
mcpRegistry?: McpServerView;
mcpHub?: McpHub;
+ /**
+ * The sandbox's `request_directory`, decided by the session
+ * (sandbox/request-directory.ts): a bridge tool, as registry servers' are.
+ */
+ requestDirectory?: (input: RequestDirectoryInput) => Promise;
/**
* This session's account for the backend, resolved when the process is
* spawned (accounts.ts); null = the daemon's own login. Throws when the
@@ -139,6 +145,7 @@ export class PiProvider implements SessionProvider {
#memory: MemoryEngine | null;
#mcpRegistry: McpServerView | null;
#mcpHub: McpHub | null;
+ readonly #requestDirectory: ((input: RequestDirectoryInput) => Promise) | null;
#proc: PiRpcProcess | null = null;
#bridgeReady = false;
@@ -175,6 +182,7 @@ export class PiProvider implements SessionProvider {
this.#memory = init.memory ?? null;
this.#mcpRegistry = init.mcpRegistry ?? null;
this.#mcpHub = init.mcpHub ?? null;
+ this.#requestDirectory = init.requestDirectory ?? null;
}
/**
@@ -766,16 +774,35 @@ export class PiProvider implements SessionProvider {
/** Discover external MCP tools to register in the bridge at spawn time. */
async #discoverMcpTools(): Promise {
+ // The sandbox's ask for a directory to write: served here, not by the hub.
+ const own: BridgeMcpTool[] = this.#requestDirectory
+ ? [{ name: REQUEST_DIRECTORY_TOOL_FQ, description: REQUEST_DIRECTORY_DESCRIPTION, parameters: REQUEST_DIRECTORY_SCHEMA }]
+ : [];
const view = this.#mcpToolsView();
- if (!view || !view.hasServers()) return [];
+ if (!view || !view.hasServers()) return own;
const handles = await view.handles();
- return handles.map((h) => ({ name: h.canonicalName, description: h.description, parameters: h.inputSchema }));
+ return [...own, ...handles.map((h) => ({ name: h.canonicalName, description: h.description, parameters: h.inputSchema }))];
}
/** Bridge external-MCP call: the pi-registered tool proxies here (pi has no
* MCP). Already gated by the tool_call hook; run it through the daemon hub. */
async #onMcpToolRequest(id: string, frame: PiFrame): Promise {
const reply = (value: string) => this.#proc?.send({ type: "extension_ui_response", id, value });
+ const ask = this.#requestDirectory;
+ if (ask) {
+ let p: { name?: string; args?: Record } = {};
+ try {
+ p = JSON.parse(String(frame.placeholder ?? "{}")) as typeof p;
+ } catch {
+ /* judged below */
+ }
+ if (p.name === REQUEST_DIRECTORY_TOOL_FQ) {
+ const path = typeof p.args?.path === "string" ? p.args.path : "";
+ const reason = typeof p.args?.reason === "string" ? p.args.reason : "";
+ reply(!path || !reason ? "Refused: give the directory's absolute path and why you need it." : await ask({ path, reason }).catch((e) => `Refused: ${e instanceof Error ? e.message : String(e)}`));
+ return;
+ }
+ }
const view = this.#mcpToolsView();
if (!view) {
reply("codeoid MCP is not enabled for this session.");
diff --git a/src/daemon/providers/qwen/index.ts b/src/daemon/providers/qwen/index.ts
index 0475e080..f389ca87 100644
--- a/src/daemon/providers/qwen/index.ts
+++ b/src/daemon/providers/qwen/index.ts
@@ -61,6 +61,7 @@ import type { ResolvedAccount } from "../../accounts.js";
import { qwenAccountEnv } from "../../account-apply.js";
import { preparePrivateHome, QWEN_HOME_LAYOUT, withLoginsSynced } from "../../sandbox/backend-home.js";
import { programLocations } from "../../sandbox/policy.js";
+import { buildSandboxMcpServer, type RequestDirectoryInput, SANDBOX_MCP_SERVER_NAME } from "../../sandbox/request-directory.js";
import type { SandboxRuntime } from "../../sandbox/session-sandbox.js";
import type { SandboxHandle } from "../../sandbox/types.js";
@@ -91,6 +92,8 @@ export interface QwenProviderInit {
memory?: MemoryEngine;
/** codeoid_fleet MCP server — conductor sessions only. */
fleet?: { type: "sdk"; name: string; instance: unknown };
+ /** The sandbox's `request_directory`, decided by the session (sandbox/request-directory.ts): mounted in-process. */
+ requestDirectory?: (input: RequestDirectoryInput) => Promise;
/** The daemon's OAuth MCP relay (#396): how this backend reaches the
* registry's OAuth servers without holding their tokens. */
mcpProxy?: McpProxyMount;
@@ -438,6 +441,8 @@ export class QwenProvider implements SessionProvider {
}
: {}),
...(init.fleet ? { codeoid_fleet: init.fleet } : {}),
+ // The sandbox's ask for a directory to write (sandbox/request-directory.ts), in-process like memory.
+ ...(init.requestDirectory ? { [SANDBOX_MCP_SERVER_NAME]: buildSandboxMcpServer(init.sessionId, (_id, input) => (init.requestDirectory as (i: RequestDirectoryInput) => Promise)(input)) } : {}),
};
// An account is an API key with its endpoint; it replaces the configured auth.
diff --git a/src/daemon/providers/registry.ts b/src/daemon/providers/registry.ts
index 93beaa0e..caa22ffe 100644
--- a/src/daemon/providers/registry.ts
+++ b/src/daemon/providers/registry.ts
@@ -46,6 +46,7 @@ import type { EndpointStore } from "../openai-endpoints.js";
import { AccountError, type AccountStore, type ResolvedAccount } from "../accounts.js";
import { type EndpointCatalog, syncEndpointProviders } from "./endpoint/index.js";
import { registerSecretPaths } from "../secret-paths.js";
+import type { RequestDirectoryInput, SandboxMcpMount } from "../sandbox/request-directory.js";
import type { SandboxRuntime } from "../sandbox/session-sandbox.js";
/**
@@ -72,6 +73,14 @@ export interface ProviderSessionInit {
/** Role-scoped goal-blackboard mount, resolved lazily — see the provider
* inits for why it is a getter and not a value. */
blackboardMcp?: () => { url: string; token: string } | undefined;
+ /**
+ * The sandbox's `request_directory` (sandbox/request-directory.ts): decided
+ * by the session. Backends that run an in-process MCP server (Claude, Qwen)
+ * or a bridge (pi) serve it from this; present when the daemon sandboxes.
+ */
+ requestDirectory?: (input: RequestDirectoryInput) => Promise;
+ /** The same tool over the daemon's `/mcp/sandbox`, for the backends that mount a URL (Codex, the Gemini CLI, opencode). */
+ sandboxMcp?: () => SandboxMcpMount | undefined;
/** Cross-backend MCP registry — the servers to mount on this session's backend. */
mcpRegistry?: McpServerView;
/** Daemon-owned MCP client pool backing the registry (Model-B backends execute
@@ -450,6 +459,7 @@ export function createDefaultProviderRegistry(
fleet: init.fleet,
loop: init.loop,
blackboardMcp: init.blackboardMcp,
+ requestDirectory: init.requestDirectory,
mcpRegistry: init.mcpRegistry,
mcpHub: init.mcpHub,
config: init.config,
@@ -530,6 +540,7 @@ export function createDefaultProviderRegistry(
resolveAccount: init.resolveAccount,
extraEnv: init.extraEnv,
sandbox: init.sandbox,
+ requestDirectory: init.requestDirectory,
}),
});
} else {
@@ -560,6 +571,7 @@ export function createDefaultProviderRegistry(
memoryMcp: init.memoryMcp,
mcpProxy: init.mcpHub?.proxy ?? undefined,
blackboardMcp: init.blackboardMcp,
+ sandboxMcp: init.sandboxMcp,
mcpRegistry: init.mcpRegistry,
onModels: init.onModels,
resolveAccount: init.resolveAccount,
@@ -595,6 +607,7 @@ export function createDefaultProviderRegistry(
memoryMcp: init.memoryMcp,
mcpProxy: init.mcpHub?.proxy ?? undefined,
blackboardMcp: init.blackboardMcp,
+ sandboxMcp: init.sandboxMcp,
mcpRegistry: init.mcpRegistry,
onModels: init.onModels,
resolveAccount: init.resolveAccount,
@@ -636,6 +649,7 @@ export function createDefaultProviderRegistry(
resolveAccount: init.resolveAccount,
extraEnv: init.extraEnv,
sandbox: init.sandbox,
+ requestDirectory: init.requestDirectory,
}),
});
}
@@ -699,6 +713,7 @@ export function createDefaultProviderRegistry(
memoryMcp: init.memoryMcp,
mcpProxy: init.mcpHub?.proxy ?? undefined,
blackboardMcp: init.blackboardMcp,
+ sandboxMcp: init.sandboxMcp,
mcpRegistry: init.mcpRegistry,
onModels: init.onModels,
agent: config?.providers?.opencode?.agent,
diff --git a/src/daemon/providers/tool-safety.ts b/src/daemon/providers/tool-safety.ts
index 5ab78d5f..f5b404e1 100644
--- a/src/daemon/providers/tool-safety.ts
+++ b/src/daemon/providers/tool-safety.ts
@@ -8,6 +8,7 @@
import { BLACKBOARD_MCP_SERVER_NAME } from "../blackboard/mcp-http.js";
import { MEMORY_MCP_SERVER_NAME } from "../memory/mcp-http.js";
import { MEMORY_TOOL_NAMES } from "../memory/tools.js";
+import { REQUEST_DIRECTORY_TOOL, SANDBOX_MCP_SERVER_NAME } from "../sandbox/request-directory.js";
import { FLEET_READ_TOOLS, FLEET_TOOL_PREFIX } from "../../protocol/types.js";
/** Built-in read-only tools that never require confirmation. */
@@ -25,6 +26,15 @@ const BLACKBOARD_TOOL_PREFIXES = [
`${BLACKBOARD_MCP_SERVER_NAME}__`,
] as const;
+/**
+ * Same two namespacing conventions, for the sandbox's `request_directory`
+ * (sandbox/request-directory.ts). It runs unprompted because it is itself an
+ * ask: the session decides it (the owner's confirm in guarded and
+ * interactive mode, the project roots in autonomous mode), so a gate card in
+ * front of it would ask twice.
+ */
+const SANDBOX_TOOL_PREFIXES = [`mcp__${SANDBOX_MCP_SERVER_NAME}__`, `${SANDBOX_MCP_SERVER_NAME}__`] as const;
+
/**
* Same two namespacing conventions, for the conductor's fleet mount.
*
@@ -61,6 +71,11 @@ const BLACKBOARD_SAFE_TOOLS = [
* write-capable memory tool bypass confirmation. A different separator simply
* prompts (fail-safe), never auto-approves by accident.
*/
+/** The sandbox's `request_directory`, as any backend names it: the session decides it, so no gate card asks first (every mode). */
+export function isRequestDirectoryTool(name: string): boolean {
+ return SANDBOX_TOOL_PREFIXES.some((p) => name === `${p}${REQUEST_DIRECTORY_TOOL}`);
+}
+
export function isSafeTool(name: string): boolean {
if (SAFE_TOOLS.has(name)) return true;
for (const prefix of MEMORY_TOOL_PREFIXES) {
@@ -68,6 +83,9 @@ export function isSafeTool(name: string): boolean {
return (MEMORY_TOOL_NAMES as readonly string[]).includes(name.slice(prefix.length));
}
}
+ for (const prefix of SANDBOX_TOOL_PREFIXES) {
+ if (name.startsWith(prefix)) return name.slice(prefix.length) === REQUEST_DIRECTORY_TOOL;
+ }
for (const prefix of BLACKBOARD_TOOL_PREFIXES) {
if (name.startsWith(prefix)) {
return (BLACKBOARD_SAFE_TOOLS as readonly string[]).includes(name.slice(prefix.length));
diff --git a/src/daemon/sandbox/claude-native.ts b/src/daemon/sandbox/claude-native.ts
index 1d9903ba..cb007bc5 100644
--- a/src/daemon/sandbox/claude-native.ts
+++ b/src/daemon/sandbox/claude-native.ts
@@ -146,6 +146,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,
@@ -167,7 +168,15 @@ export function claudeSandboxSettings(
// 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]),
+ ...sockets,
+ ]),
},
network,
...(envVars.length > 0 ? { credentials: { envVars } } : {}),
@@ -181,6 +190,46 @@ function holdsFolder(dir: string, folders: readonly string[]): boolean {
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.
+ */
+export function readAround(readable: readonly string[], writable: readonly string[], limit = 4000): string[] {
+ const out: string[] = [];
+ const inside = (child: string, parent: string) => child !== parent && child.startsWith(`${parent}/`);
+ const writes = writable.flatMap((w) => [resolve(w), physicalOf(w)]);
+ let budget = limit;
+ const split = (dir: string, keep: string[]): void => {
+ let names: string[] = [];
+ try {
+ names = readdirSync(dir);
+ } catch {
+ return;
+ }
+ for (const name of names) {
+ if (--budget < 0) return;
+ const at = join(dir, name);
+ 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)])) {
+ 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
diff --git a/src/daemon/sandbox/dir-rules.ts b/src/daemon/sandbox/dir-rules.ts
new file mode 100644
index 00000000..8bff32dd
--- /dev/null
+++ b/src/daemon/sandbox/dir-rules.ts
@@ -0,0 +1,47 @@
+/**
+ * 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 { homedir } 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;
+}
+
+export interface DirContext {
+ home?: string;
+ /** codeoid's config dir: no root is it, in it or holds it. */
+ configDir?: string;
+ platform?: NodeJS.Platform;
+}
+
+/**
+ * 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).
+ */
+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`;
+ return worktreeRootProblem(p, { home, ...(ctx.configDir ? { configDir: ctx.configDir } : {}), ...(ctx.platform ? { platform: ctx.platform } : {}) });
+}
+
+/** `~` 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/policy.ts b/src/daemon/sandbox/policy.ts
index bc8134dc..90c026e6 100644
--- a/src/daemon/sandbox/policy.ts
+++ b/src/daemon/sandbox/policy.ts
@@ -34,6 +34,7 @@ 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 { projectRootSecrets } from "./project-roots.js";
import { isOwnWorktree, worktreeAdminsOf, worktreeFoldersOf } from "./worktrees.js";
import type { NetworkMode, NetworkPolicy, SandboxSpec } from "./types.js";
@@ -62,6 +63,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 +92,7 @@ export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = {
hiddenPaths: [],
homeReadable: [...DEFAULT_HOME_READABLE],
worktreeRoots: [],
+ projectRoots: ["@parent"],
docker: { image: "node:22-bookworm-slim", socket: false },
};
@@ -442,6 +451,21 @@ 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 secret files at each project's top
+ * there (`projectRootSecrets`).
+ */
+ projectRoots?: readonly string[];
+ /**
+ * Directories the session writes besides its workspace, validated
+ * (project-roots.ts `addedDirProblem`): each as a workspace is, its git
+ * structure and project settings held, its repository's worktree folder
+ * writable. `ownWorktreeEntries`: as {@link ownWorktreeEntries}, for its
+ * repository.
+ */
+ addedDirs?: readonly { dir: string; ownWorktreeEntries?: readonly string[] }[];
}
/** The home a spec hides: absolute, and not `/` (nothing is left to show then). */
@@ -775,21 +799,24 @@ 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.
+ for (const [top, dirs] of [[ws, projectDirsOf(workdir, ws)] as const, ...addedDirs.map((d) => [resolve(d), [resolve(d)]] as const)]) {
+ 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" });
}
- }
- // 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" });
}
return out;
}
@@ -1052,16 +1079,32 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
const home = inputs.hidden?.home ?? env.HOME ?? homedir();
const workspace = resolve(inputs.workspace);
const sandboxCfg = sandboxConfigOf(inputs.config);
- const git: GitPaths =
- inputs.git === false
- ? { writable: [], readOnly: [], gitDirs: [], folders: [], locations: [], problems: [] }
- : gitWorkspacePaths(workspace, home, {
- worktreeRoots: sandboxCfg.worktreeRoots,
- configDir: inputs.hidden?.configDir ?? getConfigDir(),
- ...(inputs.worktreeFolder ? { worktreeFolder: inputs.worktreeFolder } : {}),
- ...(inputs.ownWorktreeEntries ? { ownEntries: inputs.ownWorktreeEntries } : {}),
- });
- const projectConfig = inputs.projectConfig === false ? [] : projectDirsOf(inputs.workdir ?? workspace, workspace).flatMap((dir) => projectConfigPaths(dir, home));
+ const configDir = inputs.hidden?.configDir ?? getConfigDir();
+ // Each added dir as a workspace of its own (never one inside the workspace: it is writable already).
+ 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);
+ 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);
+ }
+ 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)].flatMap((dir) => projectConfigPaths(dir, home));
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);
@@ -1072,6 +1115,11 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
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 roots = (inputs.projectRoots ?? []).map((r) => resolve(r)).filter((r, i, all) => all.indexOf(r) === i && !userHidden.some((u) => within(r, u)) && !inHidden(r));
+ const addedDirs = added.map((a) => a.dir).filter((d) => !inHidden(d) && !userHidden.some((u) => within(d, u)));
// 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 = [
@@ -1081,18 +1129,23 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
// 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))),
+ // The project roots: other repositories, read-only.
+ ...roots,
];
+ const writableDirs = [workspace, ...addedDirs];
const hiddenAll = [
...(homeHidden ? [homeHidden] : []),
- ...hiddenPathsFor(inputs.config, inputs.hidden),
+ ...baseHidden,
...homeCredentialFilesIn(reexposed),
+ // The secret files at the top of each project under a root (not the session's own: its workspace, its added dirs).
+ ...projectRootSecrets(roots, writableDirs),
...(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)];
const hidden = hiddenAll.filter(
(h, i, all) =>
all.indexOf(h) === i &&
@@ -1108,13 +1161,15 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
sessionId: inputs.sessionId,
workspace,
tempDir,
- writable: [...own, ...locations],
+ writable: [...own, ...addedDirs, ...locations],
hidden,
readOnly: [...git.readOnly, ...projectConfig],
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: protectedAncestors(hidden, [workspace, tempDir, ...own, ...addedDirs, ...locations]),
reexposed,
containerSockets: containerSocketPaths(home, env).filter((p) => !exposed.has(p) && !exposed.has(realOr(p))),
...((inputs.hideLocalServices ?? inputs.network.mode !== "on") ? { serviceSockets: localServiceSocketPaths() } : {}),
diff --git a/src/daemon/sandbox/project-roots.ts b/src/daemon/sandbox/project-roots.ts
new file mode 100644
index 00000000..8e2d580d
--- /dev/null
+++ b/src/daemon/sandbox/project-roots.ts
@@ -0,0 +1,193 @@
+/**
+ * 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. What the
+ * sandbox hides anywhere stays hidden in them (credential stores,
+ * codeoid's own dirs, the agents' logins, container sockets), and so do
+ * the secret files at a project's top ({@link projectRootSecrets});
+ * - added directories (`SessionSandboxSetting.addedDirs`): writable, each
+ * as a workspace is (its git structure and project settings held, its
+ * own worktree folder). Added by the owner, or asked for by the agent
+ * (`request_directory`, request-directory.ts).
+ *
+ * Neither may be a place whose contents run outside the sandbox, the home or
+ * `/` ({@link sandboxDirProblem}); an added one, nothing the sandbox hides
+ * ({@link addedDirProblem}).
+ */
+
+import { lstatSync, readdirSync, realpathSync } from "node:fs";
+import { homedir } from "node:os";
+import { dirname, isAbsolute, join, resolve } from "node:path";
+import { gitLayoutOf } from "./policy.js";
+import { type DirContext, normalizeSandboxDir, PROJECT_ROOT_PARENT, sandboxDirProblem } from "./dir-rules.js";
+import { mainCheckoutOf } from "./worktrees.js";
+
+export { type DirContext, normalizeSandboxDir, PROJECT_ROOT_PARENT, sandboxDirProblem };
+
+function within(child: string, parent: string): boolean {
+ return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`);
+}
+
+function realOr(p: string): string {
+ try {
+ return realpathSync(p);
+ } catch {
+ return p;
+ }
+}
+
+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;
+}
+
+/**
+ * Why `dir` can't be added to a session's writable directories, or null:
+ * {@link sandboxDirProblem}, then it must be a directory of the daemon's
+ * user (not a link: where it leads is what is added), outside everything
+ * the sandbox hides, and hold no dir of the daemon's `PATH` (a program
+ * written there runs outside any sandbox at the user's next command).
+ */
+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;
+ let st: ReturnType;
+ try {
+ st = lstatSync(realOr(abs));
+ } 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)`;
+ const real = realOr(abs);
+ 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`;
+ }
+ 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), or the parent of
+ * the workspace when it isn't in a repository.
+ */
+export function defaultProjectRoot(workspace: string, home: string = process.env.HOME ?? homedir()): string {
+ const ws = resolve(workspace);
+ const layout = gitLayoutOf(ws, home);
+ const top = layout ? (mainCheckoutOf(layout) ?? dirname(layout.dotgit)) : ws;
+ return dirname(resolve(top));
+}
+
+export interface ResolvedRoots {
+ /** Absolute, existing dirs, deduplicated (by where they really are). */
+ roots: 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}, a default that would be the home (a
+ * repository directly in it) or another refused place falling back to no
+ * root, said; anything else refused by {@link sandboxDirProblem} or not
+ * there, said and not used.
+ */
+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);
+ const problem = at === null ? `${entry} isn't an absolute path` : sandboxDirProblem(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;
+ }
+ 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;
+ }
+ const real = realOr(at);
+ if (seen.has(real)) continue;
+ seen.add(real);
+ out.roots.push(at);
+ }
+ return out;
+}
+
+/** Secret files at a project's top: dotenv files (not their examples), direnv's, a project's registry tokens. */
+const PROJECT_SECRET_FILE = /^(?:\.env(?:\.(?!(?:example|sample|template|dist|defaults?|schema)$)[^/]+)?|\.envrc|\.npmrc|\.pypirc|\.netrc|\.git-credentials)$/i;
+
+/**
+ * The secret files at the top of each project under the `roots` (the root
+ * itself and each dir directly in it: `~/Workspace/b/.env`), hidden like
+ * the credential stores are, but in `writable` (the session's own
+ * workspace and added dirs: its own project's `.env` is its to use).
+ * Found when the spec is built: one made later isn't hidden. Bounded.
+ */
+export function projectRootSecrets(roots: readonly string[], writable: readonly string[], limit = 2000): string[] {
+ const out: string[] = [];
+ const mine = writable.flatMap((w) => [resolve(w), realOr(w)]);
+ const owned = (p: string) => mine.some((w) => within(p, w) || within(realOr(p), w));
+ let budget = limit;
+ const scan = (dir: string): void => {
+ if (owned(dir)) return;
+ let names: string[] = [];
+ try {
+ names = readdirSync(dir);
+ } catch {
+ return;
+ }
+ for (const n of names) if (PROJECT_SECRET_FILE.test(n)) out.push(join(dir, n));
+ };
+ for (const root of roots) {
+ scan(root);
+ let entries: import("node:fs").Dirent[] = [];
+ try {
+ entries = readdirSync(root, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+ for (const e of entries) {
+ if (--budget < 0) return out;
+ if (e.isDirectory()) scan(join(root, e.name));
+ }
+ }
+ return out;
+}
+
+/** 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..e7f32c29
--- /dev/null
+++ b/src/daemon/sandbox/request-directory.ts
@@ -0,0 +1,184 @@
+/**
+ * `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 one under the
+ * session's project roots 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 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 request waits for its owner's answer before it is withdrawn
+ * (said to the agent): under the Gemini CLI's MCP call timeout (10 min);
+ * Codex's is raised past it (`tool_timeout_sec`).
+ */
+export const REQUEST_DIRECTORY_WAIT_MS = 8 * 60_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 directory under the project roots 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, /, or a dir of credentials or settings.",
+].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/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts
index dac39c8e..6730833c 100644
--- a/src/daemon/sandbox/session-sandbox.ts
+++ b/src/daemon/sandbox/session-sandbox.ts
@@ -22,7 +22,8 @@ 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, normalizeSandboxDir, PROJECT_ROOT_PARENT, resolveProjectRoots } from "./project-roots.js";
import { ENTRY_GRACE_MS, WorktreeEntryGuard, type WorktreeEntryFinding } from "./worktree-entries.js";
import { existingFolderName, mainCheckoutOf, sessionFolderName, worktreeFoldersOf, worktreeLocationDirs, worktreeRootNotes } from "./worktrees.js";
import type { SandboxRegistry } from "./registry.js";
@@ -142,6 +143,10 @@ 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[];
}
/** The setting a session ends up with: its own fields over the config's. */
@@ -151,13 +156,29 @@ 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);
+ return { choice, network, dockerSocket: setting?.dockerSocket ?? config.docker.socket ?? false, projectRoots: [...new Set(roots)], addedDirs: [...new Set(added)] };
+}
+
+/** 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"] as const;
+
+function withoutDirs(setting: SessionSandboxSetting | undefined): string {
+ const { projectRoots: _r, addedDirs: _a, ...rest } = setting ?? {};
+ return JSON.stringify(Object.entries(rest).sort(([a], [b]) => a.localeCompare(b)));
+}
+
+/** 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) && DIR_FIELDS.some((k) => JSON.stringify(before?.[k] ?? null) !== JSON.stringify(after?.[k] ?? null));
}
/** 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 +188,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 +201,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 +241,37 @@ 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";
+
+/**
+ * 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).
+ */
+export function sandboxDirsOf(
+ top: string,
+ rootEntries: readonly string[],
+ addedDirs: readonly string[],
+ config?: CodeoidConfig,
+): { roots: string[]; added: string[]; notes: string[]; homeWorkspace: boolean } {
+ const home = resolve(process.env.HOME ?? homedir());
+ const configDir = getConfigDir();
+ 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[] = [];
+ for (const d of addedDirs) {
+ if (resolve(d) === resolve(top) || resolve(d).startsWith(`${resolve(top)}/`)) continue;
+ const problem = addedDirProblem(d, { home, configDir, hidden });
+ if (problem) notes.push(`added directory ${d}: ${problem}: not writable`);
+ else added.push(resolve(d));
+ }
+ return { roots: homeWorkspace ? [] : r.roots, added, notes, homeWorkspace };
+}
+
/** 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 +280,10 @@ export async function resolveSandbox(opts: {
backendId: string;
/** The session's working directory: `/` can't be a sandbox's workspace. */
workspace?: string;
+ /** The daemon's whole config: what an added directory may not be in (its data dirs: policy.ts `hiddenPathsFor`). */
+ codeoidConfig?: CodeoidConfig;
}): Promise<{ info: SessionSandboxInfo; provider: SandboxProvider | null; plan: ConfinementPlan | null; network: NetworkPolicy }> {
- const { choice, network, dockerSocket } = effectiveSetting(opts.config, opts.setting);
+ const { choice, network, dockerSocket, projectRoots, addedDirs } = effectiveSetting(opts.config, opts.setting);
const base = {
choice,
network: network.mode,
@@ -270,7 +330,9 @@ 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 = top !== null ? sandboxDirsOf(top, projectRoots, addedDirs, opts.codeoidConfig) : { roots: [], added: [], notes: [], homeWorkspace: false };
+ 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,6 +346,9 @@ 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,
@@ -322,6 +387,12 @@ 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[] } = { roots: [], added: [] };
+ /** The setting's directories changed since the last resolution, nothing else (applied between turns: Session). */
+ #dirsStale = false;
/** The spec last built for the session: what its commands can write, for the entries' look. */
#lastSpec: SandboxSpec | null = null;
#entriesRecheck: ReturnType | null = null;
@@ -405,11 +476,34 @@ export class SessionSandbox {
* 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;
+ const next = setting && Object.keys(setting).length > 0 ? { ...setting } : undefined;
+ // Only which directories are read or written: the runtime stands until Session applies it between turns.
+ if (onlyDirsChanged(this.#setting, next) && !this.#stale) this.#dirsStale = true;
+ else this.#stale = true;
+ this.#setting = next;
this.#generation += 1;
}
+ /**
+ * 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;
+ }
+
+ /** 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];
+ }
+
/**
* 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,7 +519,7 @@ 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 });
+ r = await resolveSandbox({ registry: this.#registry, config: this.config, setting: this.#setting, backendId, workspace: this.#workdir, ...(this.#config ? { codeoidConfig: this.#config } : {}) });
if (generation === this.#generation) break;
}
if (this.#closed) {
@@ -435,6 +529,9 @@ 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 ?? [])] };
+ this.#dirs = dirs;
if (r.info.state !== "on" || !r.provider || !r.plan) {
this.#runtime = null;
return r.info;
@@ -448,7 +545,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).
@@ -598,12 +695,22 @@ export class SessionSandbox {
return this.#handles.size;
}
- #spec(network: NetworkPolicy, backendState: readonly string[], readable: readonly string[] = [], dockerSocket = false, hideLocalServices = network.mode !== "on"): SandboxSpec {
+ #spec(
+ network: NetworkPolicy,
+ backendState: readonly string[],
+ readable: readonly string[] = [],
+ dockerSocket = false,
+ hideLocalServices = network.mode !== "on",
+ dirs: { roots: readonly string[]; added: readonly string[] } = this.#dirs,
+ ): 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 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 spec = buildSandboxSpec({
backendReadable: readable,
sessionId: this.#sessionId,
@@ -616,12 +723,31 @@ 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, ...(folder && guard ? { ownWorktreeEntries: guard.ownEntries() } : {}) };
+ }),
});
for (const f of spec.worktreeFolders ?? []) this.#folders.add(f);
this.#lastSpec = 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(sandboxWorkspaceOf(this.#workdir))?.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,23 +757,28 @@ export class SessionSandbox {
*/
checkWorktreeEntries(): void {
if (this.#closed || !this.#runtime) return;
- if (!this.#worktreeFolderName(sandboxWorkspaceOf(this.#workdir))) return;
+ if (!this.#worktreeFolderName(sandboxWorkspaceOf(this.#workdir), this.#dirs.added)) return;
this.#checkEntries();
+ for (const d of this.#dirs.added) this.#checkEntriesOf(d);
}
#checkEntries(): void {
+ this.#checkEntriesOf(sandboxWorkspaceOf(this.#workdir), 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({
+ const { findings, pending } = guard.check({
common: layout.common,
main,
ownFolders: folders,
@@ -676,13 +807,14 @@ export class SessionSandbox {
*/
prepareFolders(): void {
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 [sandboxWorkspaceOf(this.#workdir), ...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 */
+ }
}
}
@@ -712,11 +844,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;
}
diff --git a/src/daemon/sandbox/types.ts b/src/daemon/sandbox/types.ts
index 01337e1c..6ecc323a 100644
--- a/src/daemon/sandbox/types.ts
+++ b/src/daemon/sandbox/types.ts
@@ -125,6 +125,22 @@ 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[];
/** Container runtime sockets (and their dirs): never reachable, they are root-equivalent. */
containerSockets: readonly string[];
/**
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..ef3d16c0 100644
--- a/src/daemon/session-manager.ts
+++ b/src/daemon/session-manager.ts
@@ -8,6 +8,8 @@
* - Graceful drain on shutdown
*/
+import { addedDirProblem, PROJECT_ROOT_PARENT, sandboxDirProblem } 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";
@@ -138,12 +140,12 @@ import { type MemoryEngine, type MemoryMcpMount, workspaceIdFromPath } from "./m
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 { 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 +429,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 +999,7 @@ export class SessionManager {
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
claudeAccounts: this.#claudeAccounts,
accounts: this.#accounts,
claudeAccount: meta.claudeAccount ?? null,
@@ -1373,6 +1380,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 +1780,7 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
...(this.#identityManager
@@ -3887,6 +3897,37 @@ 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 };
+ };
+
+ /** 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,7 +4375,7 @@ 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" };
@@ -4363,6 +4404,7 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
...(msg.sandbox && Object.keys(msg.sandbox).length > 0 ? { sandbox: msg.sandbox } : {}),
claudeAccounts: this.#claudeAccounts,
// The Claude choice also when the session lands elsewhere: it applies if it is switched to Claude.
@@ -4758,6 +4800,7 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
claudeAccounts: this.#claudeAccounts,
// The collaboration runs on the accounts its lead was created on.
claudeAccount: parent.claudeAccount,
@@ -5177,6 +5220,7 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
claudeAccounts: this.#claudeAccounts,
// A fork continues the same conversation — on the same accounts,
// thinking as hard (#417).
@@ -5433,6 +5477,7 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -5721,6 +5766,7 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -6732,6 +6778,7 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -6832,6 +6879,7 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -7118,6 +7166,7 @@ mcpHub: this.#mcpHub,
transcriptStore: this.#transcriptStore,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
+ sandboxMcp: this.#sandboxMcpFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -9040,6 +9089,53 @@ 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 `sandboxDirProblem`), an added dir that can't be one
+ * (`addedDirProblem`: the home, `/`, a hidden or settings dir, one the
+ * sandbox hides, one holding a PATH entry, one that isn't there).
+ */
+ #sandboxDirsProblem(setting: SessionSandboxSetting): string | null {
+ const configDir = getConfigDir();
+ for (const r of setting.projectRoots ?? []) {
+ if (r === PROJECT_ROOT_PARENT) continue;
+ const problem = sandboxDirProblem(r, { configDir });
+ if (problem) return `not a project root: ${problem}`;
+ }
+ const hidden = (setting.addedDirs ?? []).length > 0 ? hiddenPathsFor(this.#config) : [];
+ 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)) {
diff --git a/src/daemon/session.ts b/src/daemon/session.ts
index 08de8b1c..b73b4fd7 100644
--- a/src/daemon/session.ts
+++ b/src/daemon/session.ts
@@ -168,7 +168,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 +183,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 +230,9 @@ 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, sandboxWorkspaceOf, userHiddenPaths } from "./sandbox/policy.js";
+import { addedDirProblem, inAnyDir, normalizeSandboxDir } from "./sandbox/project-roots.js";
+import { REQUEST_DIRECTORY_WAIT_MS, type RequestDirectoryInput, type SandboxMcpMount } from "./sandbox/request-directory.js";
import {
CLAUDE_PROVIDER_ID,
findModel,
@@ -521,6 +540,12 @@ 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;
/**
* Pre-built codeoid_fleet MCP server (conductor sessions only). Built by
* the SessionManager because its tools close over the manager's tenant-
@@ -930,6 +955,7 @@ 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;
readonly createdBy: string;
readonly createdAt: string;
/**
@@ -1234,10 +1260,14 @@ 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 }>();
/** 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 +1582,7 @@ export class Session {
this.collaboration = opts.collaboration;
this.collaborationRole = opts.collaborationRole;
this.#blackboardMcp = opts.blackboardMcp;
+ this.#sandboxMcp = opts.sandboxMcp;
this.#onStatusChange = opts.onStatusChange;
this.#workerShape = opts.workerShape;
this.#collaborationCost = opts.collaborationCost;
@@ -1856,6 +1887,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 +2064,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 +2206,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;
}
@@ -3110,13 +3154,40 @@ 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;
+ // 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();
+ 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;
}
+ /** 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 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 +3195,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). */
@@ -3244,6 +3327,188 @@ 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 (absolute; a relative one is the workdir's) with
+ * `already` when it is writable now (the workspace, an added dir).
+ */
+ #sandboxDirCheck(raw: string): { dir: string; already?: string } | { error: string } {
+ const sbx = this.#sandbox;
+ if (!sbx) return { error: "this daemon has no OS sandbox" };
+ const home = process.env.HOME ?? homedir();
+ const dir = normalizeSandboxDir(raw, home) ?? resolvePath(this.workdir, raw.trim());
+ const workspace = sandboxWorkspaceOf(this.workdir);
+ if (inAnyDir(dir, [workspace])) return { dir, already: `${dir} is in the session's workspace (${workspace}), which its commands write already` };
+ const added = sbx.effective().addedDirs;
+ const holder = added.find((d) => inAnyDir(dir, [d]));
+ if (holder) return { dir, already: holder === dir ? `${dir} is added already` : `${dir} is in ${holder}, which is added already` };
+ const problem = addedDirProblem(dir, { home, configDir: getConfigDir(), hidden: hiddenPathsFor(this.#config) });
+ return problem ? { error: problem } : { dir };
+ }
+
+ /** 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): Promise {
+ const sbx = this.#sandbox;
+ if (!sbx) return;
+ await sbx.set({ ...(sbx.setting ?? {}), addedDirs: [...next] });
+ 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 dir = normalizeSandboxDir(raw) ?? resolvePath(this.workdir, raw.trim());
+ 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 };
+ if (check.already) return { ok: true, message: check.already };
+ const dir = check.dir;
+ this.#store.audit(sender.sub, "session.sandbox_dir_ask", this.id, dir);
+ 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} (its git structure and project settings stay read-only, as in the workspace). 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);
+ if ("error" in again || again.already) {
+ opts.onDecided?.(false, "error" in again ? again.error : again.already);
+ return;
+ }
+ const when = this.#dirsWhen();
+ await this.#changeSandboxDirs([...sbx.effective().addedDirs, dir], { who: answeredBy ?? sender.sub, action: "session.sandbox_dir_added", detail: `${dir} by=owner` }, `Sandbox: ${dir} is writable (${when})`);
+ 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 grant = async (by: string, how: string): Promise => {
+ const when = this.#dirsWhen();
+ await this.#changeSandboxDirs([...sbx.effective().addedDirs, 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})`);
+ return `Added: ${dir} is writable to your commands ${when.replace("the session's", "your")}. Finish this turn; it isn't writable yet in this one.`;
+ };
+ if (this.#mode === "autonomous") {
+ const roots = sbx.appliedProjectRoots();
+ if (inAnyDir(dir, roots)) return grant(SYSTEM_IDENTITY.sub, "under a project root, autonomous");
+ return refuse(
+ `${dir} isn't under this session's project roots (${roots.length > 0 ? roots.join(", ") : "none"}); in autonomous mode nothing else is added. The owner can add it (\`/sandbox add ${dir}\`)`,
+ );
+ }
+ let answeredBy: string | undefined;
+ const answer = await this.requestUserInput(
+ {
+ method: "confirm",
+ title: `The agent asks to write ${dir}`,
+ message: `"${this.name}" asks for its sandboxed commands to write ${dir} (its git structure and project settings stay read-only).\n\nIts reason: ${input.reason}`,
+ timeoutMs: REQUEST_DIRECTORY_WAIT_MS,
+ },
+ { ownerOnly: true, onAnswer: (sub) => {
+ answeredBy = sub;
+ },
+ },
+ );
+ 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.reason === "timeout"
+ ? `the owner didn't answer within ${Math.round(REQUEST_DIRECTORY_WAIT_MS / 60_000)} minutes`
+ : "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}.`;
+ return grant(answeredBy ?? this.createdBy, "the owner approved");
+ }
+
/**
* 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,10 +3537,11 @@ 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(() => {});
try {
const now = gitControlSnapshot(this.workdir, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] });
const changed = gitControlChanges(before, now);
@@ -3287,7 +3553,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;
}
/**
@@ -3366,7 +3671,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,6 +4365,8 @@ 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();
+ // Directories added or removed since the backend started: it starts again in the sandbox that has them.
+ await this.#restartForSandboxDirs();
// Its worktree folders, removed while empty at the last turn's end, are there again for this one's commands.
this.#sandbox?.prepareFolders();
const sandboxRefused =
@@ -4076,6 +4383,13 @@ export class Session {
// 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);
await this.#refBaseline;
+ // The same for each added directory's repository, from the first turn it is writable in.
+ for (const dir of this.#sandbox?.appliedAddedDirs() ?? []) {
+ 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;
@@ -6583,7 +6897,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 +7970,34 @@ 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).`);
+ lines.push("To write in another directory, call the `request_directory` tool (path, reason). What it grants is writable from your next turn.");
+ return lines.join("\n");
+ }
+
/**
* Where the session's sandboxed commands may make git worktrees (#448
* round 10, sandbox/worktrees.ts): its own folder in each worktree
@@ -7770,6 +8109,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 +8183,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;
diff --git a/src/tests/sandbox-dirs-manager.test.ts b/src/tests/sandbox-dirs-manager.test.ts
new file mode 100644
index 00000000..5d4e7bcb
--- /dev/null
+++ b/src/tests/sandbox-dirs-manager.test.ts
@@ -0,0 +1,277 @@
+/**
+ * Working across repositories (docs/sandbox.md, "Working across
+ * repositories") through the real SessionManager with mock backends and a
+ * fake sandbox provider: a session's project roots and added directories in
+ * its info; adding one is the owner's own widening (asked on the session,
+ * never a delegated token's or another member's), removing one narrows;
+ * create, fork and resume keep them; the agent's `request_directory` asks the
+ * owner in guarded mode, is decided by the project roots in autonomous mode,
+ * and never parks an unattended run.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+import { sandboxBadge, sandboxSummary } from "@highflame/codeoid-core";
+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 { 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";
+
+const AUTH: AuthContext = { sub: "user:dirs", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" };
+const OTHER: AuthContext = { ...AUTH, sub: "user:other" };
+const NO_APPROVE: AuthContext = { ...AUTH, scopes: AUTH.scopes.filter((s) => s !== "session:approve") as AuthContext["scopes"] };
+const DELEGATED: AuthContext = { ...AUTH, delegationDepth: 1, delegatedBy: AUTH.sub } as AuthContext;
+const CLIENT = { id: "cli", auth: AUTH, send: () => {} };
+const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "claude", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } };
+
+let specs: SandboxSpec[] = [];
+function fake(): SandboxProvider {
+ const open = (spec: SandboxSpec) => {
+ specs.push(spec);
+ return 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,
+ };
+}
+
+let tmp: string;
+let root: string;
+let workdir: string;
+let sibling: string;
+let elsewhere: string;
+let store: Store;
+let transcript: TranscriptStore;
+let audits: Array<{ action: string; detail: string }>;
+
+function config(): CodeoidConfig {
+ return {
+ 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;
+}
+
+function newManager(): SessionManager {
+ const registry = new ProviderRegistry("claude");
+ registry.register({ id: "claude", displayName: "claude", create: () => new MockSessionProvider("claude", [[DONE], [DONE], [DONE]]) });
+ const sandboxRegistry = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`);
+ sandboxRegistry.register(fake());
+ return new SessionManager(store, transcript, undefined, undefined, undefined, { config: config(), providers: registry, sandboxRegistry });
+}
+
+beforeEach(() => {
+ specs = [];
+ tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-sbx-dirs-")));
+ // /projects/{work,sibling}: the default project root is /projects.
+ root = join(tmp, "projects");
+ workdir = join(root, "work");
+ sibling = join(root, "sibling");
+ elsewhere = join(tmp, "elsewhere");
+ for (const d of [workdir, sibling, elsewhere]) mkdirSync(d, { recursive: true });
+ // codeoid's own data beside the projects, never around them (an added dir is never in what the sandbox hides).
+ mkdirSync(join(tmp, "data"));
+ store = new Store(join(tmp, "data", "codeoid.db"));
+ transcript = new TranscriptStore(join(tmp, "data", "transcripts"));
+ audits = [];
+ const audit = store.audit.bind(store);
+ store.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 {}
+ rmSync(tmp, { recursive: true, force: true });
+});
+
+async function create(m: SessionManager, extra: Record = {}, auth: AuthContext = AUTH): Promise {
+ return m.handle({ type: "session.create", id: "c", name: `s-${Math.random().toString(36).slice(2, 8)}`, workdir, ...extra } as never, auth, { ...CLIENT, auth });
+}
+const infoOf = (r: DaemonMessage) => (r as { data?: SessionInfo }).data;
+
+/** Every confirm raised on the session, as a client with dialogs sees it. */
+function dialogs(m: SessionManager, name: string): Array<{ requestId: string; title: string; message: string }> {
+ 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;
+}
+
+async function until(pred: () => boolean): Promise {
+ for (let i = 0; i < 200 && !pred(); i++) await Bun.sleep(10);
+}
+
+describe("project roots and added directories through the SessionManager", () => {
+ it("a session's info carries its project root (the repository's parent by default), and the chip and ls say what it reads", async () => {
+ const m = newManager();
+ const info = infoOf(await create(m));
+ expect(info?.sandbox?.projectRoots).toEqual([root]);
+ expect(info?.sandbox?.addedDirs).toBeUndefined();
+ expect(sandboxBadge(info?.sandbox)?.title).toContain(`reads ${root}`);
+ expect(sandboxSummary(info?.sandbox)).toContain(`reads ${root}`);
+ // A session's own roots: none.
+ const none = infoOf(await create(m, { sandbox: { projectRoots: [] } }));
+ expect(none?.sandbox?.projectRoots).toBeUndefined();
+ });
+
+ it("session.sandbox_dir add: the owner's own, with session:approve, undelegated; asked on the session, applied on the owner's yes", async () => {
+ const m = newManager();
+ const info = infoOf(await create(m));
+ const id = info?.id as string;
+ for (const who of [OTHER, NO_APPROVE, DELEGATED]) {
+ expect(await m.handle({ type: "session.sandbox_dir", id: "d", sessionId: id, action: "add", path: sibling }, who, { ...CLIENT, auth: who })).toMatchObject({ type: "response.error", code: "forbidden" });
+ }
+ const asked = dialogs(m, info?.name as string);
+ expect(await m.handle({ type: "session.sandbox_dir", id: "d", sessionId: id, action: "add", path: sibling }, AUTH, CLIENT)).toMatchObject({ type: "response.ok", data: { pending: true } });
+ await until(() => asked.length > 0);
+ expect(asked[0]?.title).toBe(`Let "${info?.name}" write ${sibling}?`);
+ const requestId = asked[0]?.requestId as string;
+ for (const who of [DELEGATED, OTHER]) {
+ expect(await m.handle({ type: "session.ui_response", id: "u", sessionId: id, requestId, confirmed: true } as never, who, { ...CLIENT, auth: who })).toMatchObject({ type: "response.error", code: "forbidden" });
+ }
+ expect(await m.handle({ type: "session.ui_response", id: "u", sessionId: id, requestId, confirmed: true } as never, AUTH, CLIENT)).toMatchObject({ type: "response.ok" });
+ await until(() => audits.some((a) => a.action === "session.sandbox_dir_added"));
+ expect(audits.find((a) => a.action === "session.sandbox_dir_added")?.detail).toBe(`${sibling} by=owner`);
+ const after = m.findByName(info?.name as string, AUTH)?.toInfo();
+ expect(after?.sandbox?.addedDirs).toEqual([sibling]);
+ expect(sandboxSummary(after?.sandbox)).toContain(`also writes ${sibling}`);
+ // Removing narrows: applied, by a delegated token too.
+ expect(await m.handle({ type: "session.sandbox_dir", id: "d", sessionId: id, action: "remove", path: sibling }, DELEGATED, { ...CLIENT, auth: DELEGATED })).toMatchObject({ type: "response.ok" });
+ expect(m.findByName(info?.name as string, AUTH)?.toInfo().sandbox?.addedDirs).toBeUndefined();
+ });
+
+ it("refused: /, the home, a hidden dir of the home, what doesn't exist; already writable: the workspace", async () => {
+ const m = newManager();
+ const id = infoOf(await create(m))?.id as string;
+ const add = (path: string) => m.handle({ type: "session.sandbox_dir", id: "d", sessionId: id, action: "add", path }, AUTH, CLIENT);
+ for (const bad of ["/", homedir(), "~", join(homedir(), ".config"), join(tmp, "missing"), "/usr"]) {
+ expect(await add(bad)).toMatchObject({ type: "response.error", code: "invalid_request" });
+ }
+ const inside = await add(join(workdir));
+ expect(inside).toMatchObject({ type: "response.ok" });
+ expect((inside as { data: { message: string } }).data.message).toContain("in the session's workspace");
+ });
+
+ it("create with added dirs and roots of its own: wider than the daemon's, so session:approve and an undelegated token; a fork keeps them for its owner only; a restart keeps them", async () => {
+ const m = newManager();
+ for (const who of [DELEGATED, NO_APPROVE]) {
+ expect(await create(m, { sandbox: { addedDirs: [sibling] } }, who)).toMatchObject({ type: "response.error", code: "forbidden" });
+ expect(await create(m, { sandbox: { projectRoots: [elsewhere] } }, who)).toMatchObject({ type: "response.error", code: "forbidden" });
+ }
+ expect(await create(m, { sandbox: { addedDirs: ["/"] } })).toMatchObject({ type: "response.error", code: "invalid_request" });
+ expect(await create(m, { sandbox: { projectRoots: ["~"] } })).toMatchObject({ type: "response.error", code: "invalid_request" });
+ // Fewer roots than the daemon's is narrower: anyone who may create.
+ expect(infoOf(await create(m, { sandbox: { projectRoots: [] } }, DELEGATED))?.sandbox?.projectRoots).toBeUndefined();
+ const info = infoOf(await create(m, { sandbox: { addedDirs: [sibling], projectRoots: ["@parent", elsewhere] } }));
+ expect(info?.sandbox).toMatchObject({ addedDirs: [sibling], projectRoots: [root, elsewhere] });
+ for (const who of [OTHER, DELEGATED]) {
+ expect(await m.handle({ type: "session.fork", id: "f", sessionId: info?.id as string } as never, who, { ...CLIENT, auth: who })).toMatchObject({ type: "response.error", code: "forbidden" });
+ }
+ const fork = infoOf(await m.handle({ type: "session.fork", id: "f", sessionId: info?.id as string } as never, AUTH, CLIENT));
+ await Bun.sleep(20);
+ const listed = (await m.handle({ type: "session.list", id: "l" }, AUTH, CLIENT)) as { sessions: SessionInfo[] };
+ expect(listed.sessions.find((s) => s.id === fork?.id)?.sandbox?.addedDirs).toEqual([sibling]);
+
+ // The daemon restarts: the session comes back with them.
+ await transcript.flush();
+ const next = newManager();
+ await next.resumeSessions();
+ const back = next.findByName(info?.name as string, AUTH);
+ await back?.ensureSandbox();
+ expect(back?.toInfo().sandbox).toMatchObject({ addedDirs: [sibling], projectRoots: [root, elsewhere] });
+ });
+
+ it("request_directory, guarded: the owner is asked with the directory and the reason; yes adds it, no refuses it", async () => {
+ const m = newManager();
+ const info = infoOf(await create(m));
+ const session = m.findByName(info?.name as string, AUTH);
+ await session?.ensureSandbox();
+ const asked = dialogs(m, info?.name as string);
+ const pending = session?.requestDirectory({ path: sibling, reason: "update the shared types" }) as Promise;
+ await until(() => asked.length > 0);
+ expect(asked[0]?.title).toBe(`The agent asks to write ${sibling}`);
+ expect(asked[0]?.message).toContain("Its reason: update the shared types");
+ // Not the agent's own token, nor anyone else's: the owner answers.
+ expect(await m.handle({ type: "session.ui_response", id: "u", sessionId: info?.id as string, requestId: asked[0]?.requestId as string, confirmed: true } as never, DELEGATED, { ...CLIENT, auth: DELEGATED })).toMatchObject({ code: "forbidden" });
+ await m.handle({ type: "session.ui_response", id: "u", sessionId: info?.id as string, requestId: asked[0]?.requestId as string, confirmed: true } as never, AUTH, CLIENT);
+ const text = await pending;
+ expect(text).toContain(`Added: ${sibling} is writable to your commands from your next turn`);
+ expect(session?.toInfo().sandbox?.addedDirs).toEqual([sibling]);
+ expect(audits.find((a) => a.action === "session.sandbox_dir_added")?.detail).toContain("by=agent the owner approved reason=update the shared types");
+
+ const declined = session?.requestDirectory({ path: elsewhere, reason: "why not" }) as Promise;
+ await until(() => asked.length > 1);
+ await m.handle({ type: "session.ui_response", id: "u", sessionId: info?.id as string, requestId: asked[1]?.requestId as string, confirmed: false } as never, AUTH, CLIENT);
+ expect(await declined).toBe("Refused: the owner declined.");
+ expect(session?.toInfo().sandbox?.addedDirs).toEqual([sibling]);
+ });
+
+ it("request_directory, autonomous: under a project root it is added unasked (audited); elsewhere refused at once, with a reason; never parks", async () => {
+ const m = newManager();
+ const info = infoOf(await create(m));
+ const id = info?.id as string;
+ expect(await m.handle({ type: "session.set_mode", id: "m", sessionId: id, mode: "autonomous" }, AUTH, CLIENT)).toMatchObject({ type: "response.ok" });
+ const session = m.findByName(info?.name as string, AUTH);
+ await session?.ensureSandbox();
+ const asked = dialogs(m, info?.name as string);
+ expect(await session?.requestDirectory({ path: sibling, reason: "fix the client too" })).toContain(`Added: ${sibling}`);
+ expect(audits.find((a) => a.action === "session.sandbox_dir_added")?.detail).toContain("under a project root, autonomous");
+ const refused = await session?.requestDirectory({ path: elsewhere, reason: "needs it" });
+ expect(refused).toContain(`Refused: ${elsewhere} isn't under this session's project roots (${root})`);
+ expect(refused).toContain(`/sandbox add ${elsewhere}`);
+ expect(asked).toHaveLength(0);
+ expect(session?.toInfo().sandbox?.addedDirs).toEqual([sibling]);
+ expect(await session?.requestDirectory({ path: homedir(), reason: "all of it" })).toContain("Refused:");
+ });
+
+ it("request_directory on an unattended run (a routine, a loop) in guarded mode: refused, not parked", async () => {
+ const m = newManager();
+ const info = infoOf(await create(m));
+ const session = m.findByName(info?.name as string, AUTH);
+ await session?.ensureSandbox();
+ const release = session?.beginUnattended();
+ try {
+ expect(await session?.requestDirectory({ path: sibling, reason: "x" })).toContain("Refused: nobody is there to approve it in this unattended run");
+ } finally {
+ release?.();
+ }
+ expect(session?.toInfo().sandbox?.addedDirs).toBeUndefined();
+ });
+});
diff --git a/src/tests/sandbox-policy.test.ts b/src/tests/sandbox-policy.test.ts
index b7dfed74..04c36369 100644
--- a/src/tests/sandbox-policy.test.ts
+++ b/src/tests/sandbox-policy.test.ts
@@ -280,7 +280,7 @@ describe("hidden paths", () => {
describe("config", () => {
test("on by default: auto, network on", () => {
const c = loadConfig({ raw: {}, env: {}, quiet: true });
- expect(c.sandbox).toEqual({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], docker: { image: "node:22-bookworm-slim", socket: false } });
+ expect(c.sandbox).toEqual({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], projectRoots: ["@parent"], docker: { image: "node:22-bookworm-slim", socket: false } });
});
test("env vars override the file", () => {
diff --git a/src/tests/sandbox-project-roots-real.test.ts b/src/tests/sandbox-project-roots-real.test.ts
new file mode 100644
index 00000000..81330c4c
--- /dev/null
+++ b/src/tests/sandbox-project-roots-real.test.ts
@@ -0,0 +1,225 @@
+/**
+ * Real bubblewrap, skipped where it can't run: working across repositories
+ * (docs/sandbox.md, "Working across repositories"). A session in
+ * `/a` reads `/b` (cat, grep, git log, git status) and can't write
+ * it; once `/b` is added it writes and commits there, with b's git
+ * structure and project settings held read-only and its own worktree folder in
+ * `/b-worktrees//`. What the sandbox hides stays hidden inside a
+ * root, and so do the secret files at a project's top.
+ *
+ * Everything destructive runs INSIDE the sandbox under test, against
+ * repositories this test planted in its own temp dir, under a home of its own.
+ */
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js";
+import { buildSandboxSpec } from "../daemon/sandbox/policy.js";
+import { resolveProjectRoots } from "../daemon/sandbox/project-roots.js";
+import type { SandboxHandle } from "../daemon/sandbox/types.js";
+
+const provider = new BwrapSandboxProvider();
+const probe = await provider.probe();
+
+/** Not under the host's /tmp, which the sandbox replaces with its own. */
+function scratchParent(): string {
+ for (const dir of [process.env.CODEOID_TEST_SANDBOX_DIR, "/var/tmp", join(homedir(), ".cache")]) {
+ if (!dir) continue;
+ try {
+ mkdirSync(dir, { recursive: true });
+ const p = mkdtempSync(join(dir, "codeoid-roots-probe-"));
+ rmSync(p, { recursive: true, force: true });
+ return dir;
+ } catch {
+ /* next */
+ }
+ }
+ return tmpdir();
+}
+
+function gitEnv(home: string): Record {
+ return {
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
+ HOME: home,
+ GIT_CONFIG_NOSYSTEM: "1",
+ GIT_AUTHOR_NAME: "t",
+ GIT_AUTHOR_EMAIL: "t@example.invalid",
+ GIT_COMMITTER_NAME: "t",
+ GIT_COMMITTER_EMAIL: "t@example.invalid",
+ };
+}
+
+function git(args: string[], cwd: string, home: string): string {
+ const r = Bun.spawnSync(["git", ...args], { cwd, env: gitEnv(home) });
+ if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`);
+ return r.stdout.toString();
+}
+
+const FOLDER = "agent-5555eeee";
+
+describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: project roots and added directories", () => {
+ let base: string;
+ beforeAll(() => {
+ base = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-roots-")));
+ });
+ afterAll(() => rmSync(base, { recursive: true, force: true }));
+
+ const sh = (h: SandboxHandle, cwd: string, script: string, env: Record) => h.exec(["/bin/sh", "-c", script], { cwd, env, timeoutMs: 30_000 });
+
+ /** A home with `Workspace/{a,b}` (each a repository with a commit), b's secrets, and the user's credentials. */
+ function fixture(name: string) {
+ const dir = join(base, name);
+ const home = join(dir, "home");
+ const root = join(home, "Workspace");
+ const a = join(root, "a");
+ const b = join(root, "b");
+ for (const r of [a, b]) {
+ mkdirSync(r, { recursive: true });
+ git(["init", "-q", "-b", "main"], r, home);
+ writeFileSync(join(r, "README.md"), `${r === a ? "alpha" : "bravo"} readme\n`);
+ git(["add", "."], r, home);
+ git(["commit", "-qm", "init"], r, home);
+ }
+ // b's secret files at its top, and an example that isn't one.
+ writeFileSync(join(b, ".env"), "B_SECRET=hunter2\n");
+ writeFileSync(join(b, ".env.local"), "B_LOCAL=hunter3\n");
+ writeFileSync(join(b, ".env.example"), "B_SECRET=\n");
+ // a's own .env: the session's project, its to use.
+ writeFileSync(join(a, ".env"), "A_SECRET=mine\n");
+ // The user's credentials, beside the root.
+ mkdirSync(join(home, ".ssh"), { recursive: true });
+ writeFileSync(join(home, ".ssh", "id_ed25519"), "PRIVATE KEY\n");
+ // codeoid's config dir inside the root (an odd layout): hidden still.
+ const configDir = join(root, ".codeoid-config");
+ mkdirSync(configDir, { recursive: true });
+ writeFileSync(join(configDir, "config.json"), '{"apiKey":"zid_sk_secret"}\n');
+ // A dir the user hides explicitly, inside the root.
+ const private_ = join(root, "private");
+ mkdirSync(private_, { recursive: true });
+ writeFileSync(join(private_, "notes.txt"), "private notes\n");
+ const tmp = join(dir, "tmp");
+ mkdirSync(tmp);
+ const roots = resolveProjectRoots(["@parent"], a, { home }).roots;
+ const spec = (added: string[] = []) =>
+ buildSandboxSpec({
+ sessionId: name,
+ workspace: a,
+ tempDir: tmp,
+ network: { mode: "off" },
+ config: { sandbox: { hiddenPaths: [private_] } } as never,
+ hidden: { home, configDir, env: { HOME: home }, daemonTmp: "/tmp" },
+ worktreeFolder: FOLDER,
+ projectRoots: roots,
+ addedDirs: added.map((d) => ({ dir: d })),
+ });
+ return { dir, home, root, a, b, configDir, private: private_, roots, spec, env: gitEnv(home) };
+ }
+
+ test("the default root is the repository's parent: a session in a reads b (cat, grep, git log, git status) and can't write it", async () => {
+ const f = fixture("read");
+ expect(f.roots).toEqual([f.root]);
+ const h = await provider.open(f.spec());
+ try {
+ const r = await sh(
+ h,
+ f.a,
+ [
+ `cat '${join(f.b, "README.md")}'`,
+ `grep -rl bravo '${f.b}' --exclude-dir=.git`,
+ `git -C '${f.b}' log --oneline`,
+ `git -C '${f.b}' status --short && echo STATUS-OK`,
+ `echo x > '${join(f.b, "new.txt")}' 2>&1 || echo WRITE-REFUSED`,
+ `echo x >> '${join(f.b, "README.md")}' 2>&1 || echo APPEND-REFUSED`,
+ `git -C '${f.b}' commit --allow-empty -qm nope 2>&1 || echo COMMIT-REFUSED`,
+ // Its own workspace is writable, under the read-only root.
+ `echo a > '${join(f.a, "own.txt")}' && echo OWN-WRITTEN`,
+ ].join("; "),
+ f.env,
+ );
+ expect(r.stdout).toContain("bravo readme");
+ expect(r.stdout).toContain(join(f.b, "README.md"));
+ expect(r.stdout).toContain("init");
+ expect(r.stdout).toContain("STATUS-OK");
+ expect(r.stdout).toContain("WRITE-REFUSED");
+ expect(r.stdout).toContain("APPEND-REFUSED");
+ expect(r.stdout).toContain("COMMIT-REFUSED");
+ expect(r.stdout).toContain("Read-only file system");
+ expect(r.stdout).toContain("OWN-WRITTEN");
+ } finally {
+ await h.close();
+ }
+ expect(existsSync(join(f.b, "new.txt"))).toBe(false);
+ expect(readFileSync(join(f.b, "README.md"), "utf8")).toBe("bravo readme\n");
+ expect(git(["log", "--oneline"], f.b, f.home).trim().split("\n")).toHaveLength(1);
+ expect(existsSync(join(f.a, "own.txt"))).toBe(true);
+ });
+
+ test("what the sandbox hides stays hidden inside a root: b's .env files, codeoid's config dir, a hidden path; the home's credentials; its own .env readable", async () => {
+ const f = fixture("hidden");
+ const h = await provider.open(f.spec());
+ try {
+ const r = await sh(
+ h,
+ f.a,
+ [
+ `cat '${join(f.b, ".env")}' 2>/dev/null | grep -q hunter2 && echo LEAK-ENV || echo ENV-HIDDEN`,
+ `cat '${join(f.b, ".env.local")}' 2>/dev/null | grep -q hunter3 && echo LEAK-LOCAL || echo LOCAL-HIDDEN`,
+ `cat '${join(f.b, ".env.example")}' >/dev/null && echo EXAMPLE-READ`,
+ `grep -rq zid_sk_secret '${f.root}' 2>/dev/null && echo LEAK-CONFIG || echo CONFIG-HIDDEN`,
+ `cat '${join(f.private, "notes.txt")}' 2>/dev/null | grep -q private && echo LEAK-PRIVATE || echo PRIVATE-HIDDEN`,
+ `cat '${join(f.home, ".ssh", "id_ed25519")}' 2>/dev/null | grep -q PRIVATE && echo LEAK-SSH || echo SSH-HIDDEN`,
+ `grep -rq hunter '${f.root}' 2>/dev/null && echo LEAK-GREP || echo GREP-CLEAN`,
+ `grep -q mine '${join(f.a, ".env")}' && echo OWN-ENV`,
+ ].join("; "),
+ f.env,
+ );
+ for (const w of ["ENV-HIDDEN", "LOCAL-HIDDEN", "EXAMPLE-READ", "CONFIG-HIDDEN", "PRIVATE-HIDDEN", "SSH-HIDDEN", "GREP-CLEAN", "OWN-ENV"]) expect(r.stdout).toContain(w);
+ expect(r.stdout).not.toContain("LEAK");
+ } finally {
+ await h.close();
+ }
+ });
+
+ test("added, b is written and committed in, its git structure and project settings held, its own worktree folder in b-worktrees/", async () => {
+ const f = fixture("added");
+ const spec = f.spec([f.b]);
+ const own = join(f.root, "b-worktrees", FOLDER);
+ expect(spec.addedDirs).toEqual([f.b]);
+ expect(spec.worktreeFolders).toContain(own);
+ const marker = join(f.dir, "RAN-OUTSIDE");
+ const h = await provider.open(spec);
+ try {
+ const r = await sh(
+ h,
+ f.a,
+ [
+ `cd '${f.b}' && echo change >> README.md && git add README.md && git commit -qm from-a && echo COMMITTED`,
+ `printf '#!/bin/sh\\necho ran >> ${marker}\\n' > .git/hooks/pre-commit 2>&1 && echo HOOK-WRITTEN || echo HOOK-REFUSED`,
+ `git config core.fsmonitor 'touch ${marker}' 2>&1 && echo CONFIG-WRITTEN || echo CONFIG-REFUSED`,
+ `echo '{}' > .claude/settings.json 2>&1 && echo SETTINGS-WRITTEN || echo SETTINGS-REFUSED`,
+ `git worktree add -q '${join(own, "feat")}' -b feat && echo WT-ADDED`,
+ `git worktree add -q '${join(f.root, "b-worktrees", "elsewhere")}' -b nope 2>&1 && echo WT-ELSEWHERE || echo WT-ELSEWHERE-REFUSED`,
+ // The workspace stays writable beside it.
+ `echo a > '${join(f.a, "still.txt")}' && echo A-WRITTEN`,
+ ].join("; "),
+ f.env,
+ );
+ for (const w of ["COMMITTED", "HOOK-REFUSED", "CONFIG-REFUSED", "SETTINGS-REFUSED", "WT-ADDED", "WT-ELSEWHERE-REFUSED", "A-WRITTEN"]) expect(r.stdout).toContain(w);
+ } finally {
+ await h.close();
+ }
+ expect(git(["log", "--oneline"], f.b, f.home)).toContain("from-a");
+ expect(git(["worktree", "list"], f.b, f.home)).toContain(join(own, "feat"));
+ // The user's next git in b runs nothing planted.
+ git(["status", "--short"], f.b, f.home);
+ expect(existsSync(marker)).toBe(false);
+ // Its .env stays readable to it now that b is its own to write.
+ const h2 = await provider.open(spec);
+ try {
+ expect((await sh(h2, f.b, "cat .env", f.env)).stdout).toContain("hunter2");
+ } finally {
+ await h2.close();
+ }
+ });
+});
diff --git a/src/tests/sandbox-project-roots.test.ts b/src/tests/sandbox-project-roots.test.ts
new file mode 100644
index 00000000..df6e207d
--- /dev/null
+++ b/src/tests/sandbox-project-roots.test.ts
@@ -0,0 +1,361 @@
+/**
+ * Working across repositories (docs/sandbox.md, "Working across
+ * repositories"), piece by piece: the project roots a session reads (the
+ * repository's parent by default, never the home or `/`), the secret files at
+ * a project's top hidden, an added directory treated as a workspace (its git
+ * structure and settings held, its own worktree folder), Claude's own sandbox
+ * shown a root around what it writes, Codex's writable roots, widening, the
+ * `/mcp/sandbox` endpoint's scope, and a change of directories applied only
+ * between turns with no background work running.
+ */
+import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
+import { randomUUID } from "node:crypto";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+import type { CodeoidConfig } from "../config.js";
+import { codexSandboxPolicy } from "../daemon/providers/codex/index.js";
+import type { ProviderEvent } from "../daemon/providers/interface.js";
+import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js";
+import { isRequestDirectoryTool, isSafeTool } from "../daemon/providers/tool-safety.js";
+import { claudeSandboxSettings, readAround } from "../daemon/sandbox/claude-native.js";
+import { LocalSandboxHandle } from "../daemon/sandbox/local-handle.js";
+import { buildSandboxSpec, protectedWriteTargets } from "../daemon/sandbox/policy.js";
+import { addedDirProblem, defaultProjectRoot, projectRootSecrets, resolveProjectRoots, sandboxDirProblem } from "../daemon/sandbox/project-roots.js";
+import { SandboxRegistry } from "../daemon/sandbox/registry.js";
+import { SANDBOX_MCP_TOKEN_ENV, SandboxMcpHttp } from "../daemon/sandbox/request-directory.js";
+import { effectiveSetting, onlyDirsChanged, type SandboxRuntime, sandboxDirsOf, SessionSandbox, widens } from "../daemon/sandbox/session-sandbox.js";
+import { sandboxConfigOf } from "../daemon/sandbox/policy.js";
+import type { SandboxProvider, SandboxSpec } from "../daemon/sandbox/types.js";
+import { Session } from "../daemon/session.js";
+import { loadShellParser } from "../daemon/shell/parse.js";
+import { Store } from "../daemon/store.js";
+import { TranscriptStore } from "../daemon/transcript.js";
+import { type AuthContext, CAPABILITIES, type SessionUiRequestMsg } from "../protocol/types.js";
+
+const AUTH: AuthContext = { sub: "u", scopes: [], delegationDepth: 0, accountId: "a", projectId: "p" };
+
+function git(args: string[], cwd: string): void {
+ const r = Bun.spawnSync(["git", ...args], { cwd, env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: cwd, GIT_CONFIG_NOSYSTEM: "1" } });
+ if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`);
+}
+
+let base: string;
+beforeEach(() => {
+ base = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-roots-")));
+});
+afterEach(() => rmSync(base, { recursive: true, force: true }));
+
+describe("project roots", () => {
+ test("the default is the repository's parent; a linked worktree's is its main checkout's; outside a repository, the workspace's parent", () => {
+ const home = join(base, "home");
+ const repo = join(home, "Workspace", "app");
+ mkdirSync(join(repo, "packages", "ui"), { recursive: true });
+ git(["init", "-q", "-b", "main"], repo);
+ expect(defaultProjectRoot(join(repo, "packages", "ui"), home)).toBe(join(home, "Workspace"));
+ expect(defaultProjectRoot(repo, home)).toBe(join(home, "Workspace"));
+ const plain = join(home, "notes", "today");
+ mkdirSync(plain, { recursive: true });
+ expect(defaultProjectRoot(plain, home)).toBe(join(home, "notes"));
+ expect(resolveProjectRoots(["@parent"], repo, { home }).roots).toEqual([join(home, "Workspace")]);
+ });
+
+ test("a repository directly in the home: no default root, said; the home itself as a workspace: none either", () => {
+ const home = join(base, "home");
+ const repo = join(home, "proj");
+ mkdirSync(repo, { recursive: true });
+ git(["init", "-q"], repo);
+ const r = resolveProjectRoots(["@parent"], repo, { home });
+ expect(r.roots).toEqual([]);
+ expect(r.notes[0]).toContain("no project root: the default, the repository's parent");
+ expect(r.notes[0]).toContain("set sandbox.projectRoots");
+ const dirs = sandboxDirsOf(homedir(), ["@parent"], []);
+ expect(dirs).toMatchObject({ roots: [], homeWorkspace: true });
+ expect(dirs.notes[0]).toContain("only credentials are protected");
+ });
+
+ test("refused as roots and added dirs: ~, /, a dir holding the home, a hidden dir of it, ~/Library, a system dir, a relative path", () => {
+ const home = join(base, "home");
+ mkdirSync(home, { recursive: true });
+ for (const bad of ["~", home, "/", base, "~/.config", "~/.local/share/x", "~/Library/x", "/etc", "/usr/lib", "relative/dir"]) {
+ expect(sandboxDirProblem(bad, { home })).not.toBeNull();
+ expect(resolveProjectRoots([bad], join(home, "w"), { home }).roots).toEqual([]);
+ }
+ expect(sandboxDirProblem("~/Workspace", { home })).toBeNull();
+ });
+
+ test("an added dir: an existing dir of yours, outside what the sandbox hides, holding no PATH entry", () => {
+ const home = join(base, "home");
+ const ok = join(home, "Workspace", "b");
+ const bin = join(home, "tools", "bin");
+ mkdirSync(ok, { recursive: true });
+ mkdirSync(bin, { recursive: true });
+ const hidden = [join(home, "Workspace", "secrets")];
+ mkdirSync(hidden[0] as string, { recursive: true });
+ expect(addedDirProblem(ok, { home, hidden, path: "/usr/bin" })).toBeNull();
+ expect(addedDirProblem(join(home, "Workspace", "missing"), { home })).toContain("doesn't exist");
+ expect(addedDirProblem(join(home, "Workspace", "secrets"), { home, hidden })).toContain("in what the sandbox hides");
+ expect(addedDirProblem(join(home, "tools"), { home, path: `/usr/bin:${bin}` })).toContain("on your PATH");
+ writeFileSync(join(home, "file"), "");
+ expect(addedDirProblem(join(home, "file"), { home })).toContain("isn't a directory");
+ });
+
+ test("the secret files at a project's top are hidden, not their examples, and not the session's own", () => {
+ const root = join(base, "Workspace");
+ for (const p of ["a", "b", "c/deep"]) mkdirSync(join(root, p), { recursive: true });
+ for (const f of [".env", ".env.production", ".envrc", ".npmrc", ".env.example", ".env.sample", "README.md"]) writeFileSync(join(root, "b", f), "x");
+ writeFileSync(join(root, "a", ".env"), "mine");
+ writeFileSync(join(root, "c", "deep", ".env"), "deep");
+ const found = projectRootSecrets([root], [join(root, "a")]).sort();
+ expect(found).toEqual([".env", ".env.production", ".envrc", ".npmrc"].map((f) => join(root, "b", f)).sort());
+ });
+});
+
+describe("the spec", () => {
+ function layout() {
+ const home = join(base, "home");
+ const root = join(home, "Workspace");
+ const a = join(root, "a");
+ const b = join(root, "b");
+ for (const r of [a, b]) {
+ mkdirSync(r, { recursive: true });
+ git(["init", "-q", "-b", "main"], r);
+ }
+ writeFileSync(join(b, ".env"), "secret");
+ const tmp = join(base, "tmp");
+ mkdirSync(tmp);
+ const spec = buildSandboxSpec({
+ sessionId: "s",
+ workspace: a,
+ tempDir: tmp,
+ network: { mode: "off" },
+ hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" },
+ worktreeFolder: "s-1234abcd",
+ projectRoots: [root],
+ addedDirs: [{ dir: b }],
+ });
+ return { home, root, a, b, spec };
+ }
+
+ test("a root is shown read-only; an added dir is writable, its git structure and project settings held, its worktree folder writable", () => {
+ const { root, b, spec } = layout();
+ expect(spec.projectRoots).toEqual([root]);
+ expect(spec.reexposed).toContain(root);
+ expect(spec.addedDirs).toEqual([b]);
+ expect(spec.writable).toContain(b);
+ expect(spec.writable).toContain(join(b, ".git"));
+ for (const held of [join(b, ".git", "config"), join(b, ".git", "hooks"), join(b, ".claude"), join(b, ".mcp.json")]) expect(spec.readOnly).toContain(held);
+ expect(spec.worktreeFolders).toContain(join(root, "b-worktrees", "s-1234abcd"));
+ expect(spec.gitDirs).toContain(join(b, ".git"));
+ // b's .env: the session writes b, so it is its own now.
+ expect(spec.hidden).not.toContain(join(b, ".env"));
+ });
+
+ test("without the added dir, b's .env is hidden inside the root", () => {
+ const { b, spec, home, root, a } = layout();
+ const plain = buildSandboxSpec({
+ sessionId: "s",
+ workspace: a,
+ tempDir: spec.tempDir,
+ network: { mode: "off" },
+ hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" },
+ projectRoots: [root],
+ });
+ expect(plain.hidden).toContain(join(b, ".env"));
+ expect(plain.writable).not.toContain(b);
+ });
+
+ test("Claude's own sandbox: a root holding the workspace is shown entry by entry around it (its read binds land after its write binds)", () => {
+ const { root, a, b, spec } = layout();
+ mkdirSync(join(root, "c"));
+ const rt = { spec, config: sandboxConfigOf(undefined) } as unknown as Pick;
+ const fs = claudeSandboxSettings(rt, {}, undefined, "linux").filesystem;
+ expect(fs?.allowRead).not.toContain(root);
+ expect(fs?.allowRead).toContain(join(root, "c"));
+ expect(fs?.allowWrite).toContain(a);
+ expect(fs?.allowWrite).toContain(b);
+ expect(readAround([root], [a])).toEqual(expect.arrayContaining([b, join(root, "c")]));
+ expect(readAround([root], [a])).not.toContain(a);
+ expect(readAround([root], [])).toEqual([root]);
+ });
+
+ test("Codex's writable roots carry the added dirs", () => {
+ const { b, spec } = layout();
+ const policy = codexSandboxPolicy({ spec, plan: { kind: "native" } } as unknown as SandboxRuntime, "workspace-write", spec.workspace) as { writableRoots: string[] };
+ expect(policy.writableRoots).toContain(b);
+ });
+
+ test("a file tool outside the sandbox writing an added dir's git structure or settings is held, as the workspace's", () => {
+ const { a, b } = layout();
+ const held = protectedWriteTargets(a, join(base, "home"), [b]).map((h) => h.at);
+ expect(held).toContain(join(b, ".git"));
+ expect(held).toContain(join(b, ".claude"));
+ });
+});
+
+describe("the setting", () => {
+ test("adding a dir or a root widens; removing one narrows; only dirs changed is told apart", () => {
+ const cfg = sandboxConfigOf(undefined);
+ const before = effectiveSetting(cfg, undefined);
+ expect(before.projectRoots).toEqual(["@parent"]);
+ expect(widens(before, effectiveSetting(cfg, { addedDirs: ["/srv/b"] }))).toBe(true);
+ expect(widens(effectiveSetting(cfg, { addedDirs: ["/srv/b"] }), before)).toBe(false);
+ expect(widens(before, effectiveSetting(cfg, { projectRoots: ["@parent", "/srv"] }))).toBe(true);
+ expect(widens(before, effectiveSetting(cfg, { projectRoots: [] }))).toBe(false);
+ expect(effectiveSetting(cfg, { addedDirs: ["~/b"] }).addedDirs).toEqual([join(process.env.HOME ?? homedir(), "b")]);
+ expect(onlyDirsChanged({ network: "off" }, { network: "off", addedDirs: ["/x"] })).toBe(true);
+ expect(onlyDirsChanged({ network: "off" }, { network: "on", addedDirs: ["/x"] })).toBe(false);
+ expect(onlyDirsChanged(undefined, undefined)).toBe(false);
+ });
+});
+
+describe("request_directory", () => {
+ test("isn't asked about by the gate: the session decides it", () => {
+ for (const n of ["mcp__codeoid_sandbox__request_directory", "codeoid_sandbox__request_directory"]) {
+ expect(isRequestDirectoryTool(n)).toBe(true);
+ expect(isSafeTool(n)).toBe(true);
+ }
+ for (const n of ["mcp__codeoid_sandbox__other", "x_codeoid_sandbox__request_directory", "mcp__evil__request_directory"]) expect(isSafeTool(n)).toBe(false);
+ });
+
+ test("/mcp/sandbox: a session's token is its scope; no token, or a revoked one, is refused; the token variable stays out of Codex's commands", async () => {
+ const calls: Array<{ sessionId: string; path: string }> = [];
+ const ep = new SandboxMcpHttp(async (sessionId, input) => {
+ calls.push({ sessionId, path: input.path });
+ return "ok";
+ });
+ const token = ep.mint("s1");
+ const post = (body: unknown, t?: string) =>
+ ep.handle(new Request("http://127.0.0.1/mcp/sandbox", { method: "POST", headers: { "content-type": "application/json", ...(t ? { authorization: `Bearer ${t}` } : {}) }, body: JSON.stringify(body) }));
+ expect((await post({ jsonrpc: "2.0", id: 1, method: "tools/list" })).status).toBe(401);
+ const list = (await (await post({ jsonrpc: "2.0", id: 1, method: "tools/list" }, token)).json()) as { result: { tools: Array<{ name: string }> } };
+ expect(list.result.tools.map((t) => t.name)).toEqual(["request_directory"]);
+ const r = (await (await post({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "request_directory", arguments: { path: "/srv/b", reason: "why" } } }, token)).json()) as { result: { content: Array<{ text: string }> } };
+ expect(r.result.content[0]?.text).toBe("ok");
+ const noReason = (await (await post({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "request_directory", arguments: { path: "/srv/b" } } }, token)).json()) as { result: { content: Array<{ text: string }> } };
+ expect(noReason.result.content[0]?.text).toContain("say why");
+ expect(calls).toEqual([{ sessionId: "s1", path: "/srv/b" }]);
+ ep.revokeSession("s1");
+ expect((await post({ jsonrpc: "2.0", id: 4, method: "tools/list" }, token)).status).toBe(401);
+ expect(SANDBOX_MCP_TOKEN_ENV).toMatch(/^CODEOID_.*_TOKEN$/);
+ });
+});
+
+describe("applied between turns, never mid-work", () => {
+ const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "claude", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } };
+ let store: Store;
+ let transcripts: TranscriptStore;
+ beforeAll(async () => {
+ await loadShellParser();
+ });
+
+ function fakeRegistry(opened: SandboxSpec[]): SandboxRegistry {
+ const open = (spec: SandboxSpec) => {
+ opened.push(spec);
+ return new LocalSandboxHandle("bwrap", spec, (argv, o) => ({ argv: [...argv], env: o.env }));
+ };
+ const p: SandboxProvider = {
+ 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 r = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`);
+ r.register(p);
+ return r;
+ }
+
+ test("SessionSandbox: a change of directories only leaves the running sandbox as it is until the next refresh", async () => {
+ const work = join(base, "projects", "work");
+ const other = join(base, "projects", "other");
+ mkdirSync(work, { recursive: true });
+ mkdirSync(other, { recursive: true });
+ const sbx = new SessionSandbox({ sessionId: "s", workdir: work, registry: fakeRegistry([]) });
+ await sbx.refresh("claude");
+ expect(sbx.appliedAddedDirs()).toEqual([]);
+ await sbx.set({ addedDirs: [other] });
+ expect(sbx.dirsPending).toBe(true);
+ expect(sbx.resolvedFor("claude")).toBe(true);
+ expect(sbx.runtime?.spec.addedDirs).toBeUndefined();
+ await sbx.refresh("claude");
+ expect(sbx.dirsPending).toBe(false);
+ expect(sbx.appliedAddedDirs()).toEqual([other]);
+ expect(sbx.runtime?.spec.addedDirs).toEqual([other]);
+ // Anything else changed with it: a change of setting as before.
+ await sbx.set({ addedDirs: [other], network: "off" });
+ expect(sbx.dirsPending).toBe(false);
+ expect(sbx.resolvedFor("claude")).toBe(false);
+ await sbx.close();
+ });
+
+ test("a session with background work running keeps its sandbox; the dir lands at the next turn after it ended, and the backend starts again in it", async () => {
+ const dir = join(base, "s");
+ const work = join(dir, "projects", "work");
+ const other = join(dir, "projects", "other");
+ mkdirSync(work, { recursive: true });
+ mkdirSync(other, { recursive: true });
+ mkdirSync(join(dir, "data"));
+ store = new Store(join(dir, "data", "codeoid.db"));
+ transcripts = new TranscriptStore(join(dir, "data", "transcripts"));
+ const id = randomUUID();
+ store.createSession({ id, name: "dirs", workdir: work, status: "idle", createdBy: AUTH.sub, createdAt: new Date().toISOString(), attachedClients: 0, accountId: AUTH.accountId, projectId: AUTH.projectId });
+ const provider = new MockSessionProvider("claude", [[DONE], [DONE], [DONE]]);
+ const opened: SandboxSpec[] = [];
+ const session = new Session({
+ name: "dirs",
+ workdir: work,
+ auth: AUTH,
+ store,
+ transcriptStore: transcripts,
+ existingId: id,
+ _testProvider: provider,
+ initialMode: { mode: "guarded" },
+ config: { workspaceIndex: { enabled: false }, compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, labeling: {}, telemetry: { osc8: "auto" }, autoRotate: { enabled: false }, session: {}, dbPath: join(dir, "data", "codeoid.db"), transcriptDir: join(dir, "data", "transcripts") } as unknown as CodeoidConfig,
+ sandboxRegistry: fakeRegistry(opened),
+ });
+ const dialogs: SessionUiRequestMsg[] = [];
+ const infos: string[] = [];
+ session.attach({
+ id: "c",
+ auth: AUTH,
+ capabilities: [CAPABILITIES.UI_DIALOGS],
+ send: (m: { type: string; role?: string; content?: string }) => {
+ if (m.type === "session.ui_request") dialogs.push(m as SessionUiRequestMsg);
+ if (m.type === "session.message" && m.role === "info") infos.push(String(m.content ?? ""));
+ },
+ } as never);
+ try {
+ await session.send("one", AUTH);
+ for (let i = 0; i < 200 && session.toInfo().status !== "idle"; i++) await Bun.sleep(5);
+ provider.onSessionEvent?.({ type: "background_tasks", tasks: [{ id: "bg1", kind: "local_agent", description: "still working", status: "running" }] });
+ const r = await session.changeSandboxDir("add", other, AUTH);
+ expect(r).toMatchObject({ ok: true, pending: true });
+ for (let i = 0; i < 200 && dialogs.length === 0; i++) await Bun.sleep(5);
+ expect(dialogs[0]?.message).toContain("after the background work running now has ended");
+ session.resolveUiRequestFromClient(dialogs[0]?.requestId as string, { confirmed: true }, AUTH);
+ for (let i = 0; i < 200 && !session.toInfo().sandbox?.pendingDirs; i++) await Bun.sleep(5);
+ expect(session.toInfo().sandbox?.pendingDirs).toEqual({ add: [other], remove: [] });
+ expect(session.toInfo().sandbox?.addedDirs).toBeUndefined();
+ // A turn while it runs: still the old sandbox, nothing restarted.
+ const torn = provider.teardownCount;
+ await session.send("two", AUTH);
+ for (let i = 0; i < 200 && session.toInfo().status !== "idle"; i++) await Bun.sleep(5);
+ expect(session.toInfo().sandbox?.addedDirs).toBeUndefined();
+ expect(provider.teardownCount).toBe(torn);
+ // It ended: the next turn has the dir, its backend restarted into it.
+ provider.onSessionEvent?.({ type: "background_tasks", tasks: [] });
+ await session.send("three", AUTH);
+ for (let i = 0; i < 200 && session.toInfo().status !== "idle"; i++) await Bun.sleep(5);
+ expect(session.toInfo().sandbox?.addedDirs).toEqual([other]);
+ expect(session.toInfo().sandbox?.pendingDirs).toBeUndefined();
+ expect(infos.some((t) => t.includes("Sandbox directories applied"))).toBe(true);
+ expect(provider.teardownCount).toBeGreaterThan(torn);
+ } finally {
+ await session.destroy(AUTH).catch(() => {});
+ await transcripts.flush().catch(() => {});
+ store.close();
+ }
+ });
+});
diff --git a/src/tests/sandbox-session.test.ts b/src/tests/sandbox-session.test.ts
index b832dfc3..de2d9517 100644
--- a/src/tests/sandbox-session.test.ts
+++ b/src/tests/sandbox-session.test.ts
@@ -368,14 +368,14 @@ describe("fallback: unattended work refuses, interactive work says so", () => {
describe("the approval card and the gate", () => {
test("a shell call on a sandboxed session: sandboxed, with the provider and network", async () => {
const r = await gateCall("Bash", { command: "ls" });
- expect(r.card).toMatchObject({ sandboxed: true, sandboxNote: "sandboxed: fake-bwrap, network on" });
+ expect(r.card).toMatchObject({ sandboxed: true, sandboxNote: `sandboxed: fake-bwrap, network on; reads ${root} (read-only)` });
});
test("a sandbox with Docker access: every card says so, as a warning (#448 follow-up)", async () => {
const r = await gateCall("Bash", { command: "ls" }, { sandbox: { dockerSocket: true } });
- expect(r.card).toMatchObject({ sandboxed: true, sandboxDocker: true, sandboxNote: "sandboxed, Docker access: fake-bwrap, network on" });
- expect(r.pending[0]).toMatchObject({ sandboxDocker: true, sandboxNote: "sandboxed, Docker access: fake-bwrap, network on" });
- expect(approvalSandboxLine(r.card as Card)).toBe("⚠️ sandboxed, Docker access: fake-bwrap, network on");
+ expect(r.card).toMatchObject({ sandboxed: true, sandboxDocker: true, sandboxNote: `sandboxed, Docker access: fake-bwrap, network on; reads ${root} (read-only)` });
+ expect(r.pending[0]).toMatchObject({ sandboxDocker: true, sandboxNote: `sandboxed, Docker access: fake-bwrap, network on; reads ${root} (read-only)` });
+ expect(approvalSandboxLine(r.card as Card)).toBe(`⚠️ sandboxed, Docker access: fake-bwrap, network on; reads ${root} (read-only)`);
const file = await gateCall("Write", { file_path: join(workdir, "a.ts"), content: "x" }, { sandbox: { dockerSocket: true } });
expect(file.card).toMatchObject({ sandboxed: false, sandboxDocker: true });
expect(approvalSandboxLine(file.card as Card)).toBe("⚠️ runs in the backend's own process, outside the sandbox (the sandbox has Docker access)");
@@ -1012,9 +1012,18 @@ describe("round 8 of the #448 audit: host-side file tools are held to the sandbo
test("the sandbox decides what a read reaches: the home's allowlist is readable; the host's /tmp is to Claude's own sandbox, not to opencode's under bubblewrap (its /tmp is the session's)", async () => {
expect((await gateCall("Read", { file_path: join(homedir(), ".gitconfig") }, { mode: "autonomous" })).behavior).toBe("allow");
- const outside = join(root, "elsewhere", "notes.txt");
- expect((await gateCall("Read", { file_path: outside }, { mode: "autonomous" })).behavior).toBe("allow");
- expectOutside(await gateCall("Read", { file_path: outside }, { mode: "autonomous", backend: "opencode" }), "read", outside);
+ // Outside every project root (the workdir's parent is the default one): another dir of the host's /tmp.
+ const other = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-sbx-elsewhere-")));
+ try {
+ const outside = join(other, "notes.txt");
+ expect((await gateCall("Read", { file_path: outside }, { mode: "autonomous" })).behavior).toBe("allow");
+ expectOutside(await gateCall("Read", { file_path: outside }, { mode: "autonomous", backend: "opencode" }), "read", outside);
+ } finally {
+ rmSync(other, { recursive: true, force: true });
+ }
+ // Inside the project root, beside the workdir: readable to its commands, so to its file tools.
+ const sibling = join(root, "elsewhere", "notes.txt");
+ expect((await gateCall("Read", { file_path: sibling }, { mode: "autonomous", backend: "opencode" })).behavior).toBe("allow");
});
test("a write through a link out of the workspace is judged where it lands", async () => {
From 3ac7acf4acd09a8838d83b23ab48ae321712810b Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Sun, 8 Nov 2026 11:44:07 +0800
Subject: [PATCH 02/23] feat: project roots and added directories in the CLI,
the web UI and Settings (#448)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- /sandbox add , /sandbox remove (every frontend: core's slash
commands send session.sandbox_dir; adding is asked of the owner).
- codeoid new --add-dir (repeatable), --project-root
(repeatable), --no-project-roots; codeoid sandbox add|remove .
- The chip's tooltip, codeoid ls and the approval cards say what the
sandbox reads and writes besides the workspace, and what lands at the
next turn; a workspace that is the home says "sandboxed: workspace is
your home — only credentials are protected".
- The new-session dialog: readable project roots (the default shown) and
added directories. Settings > Sandbox: Project roots (readable).
Co-Authored-By: Claude Opus 5.5
---
packages/core/src/sandbox.test.ts | 21 ++++++++-
packages/core/src/slash.test.ts | 21 +++++++++
packages/core/src/slash.ts | 25 +++++++++-
src/cli.ts | 43 +++++++++++++++--
src/daemon/session-manager.ts | 2 +-
src/daemon/settings/manifest.ts | 6 +++
src/terminal/client.ts | 16 +++++++
web/src/components/NewSessionModal.test.tsx | 17 +++++++
web/src/components/NewSessionModal.tsx | 52 +++++++++++++++++++--
web/src/components/SandboxChip.tsx | 2 +-
10 files changed, 195 insertions(+), 10 deletions(-)
diff --git a/packages/core/src/sandbox.test.ts b/packages/core/src/sandbox.test.ts
index 34b9b846..bd7e5201 100644
--- a/packages/core/src/sandbox.test.ts
+++ b/packages/core/src/sandbox.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import type { SessionSandboxInfo } from "@highflame/codeoid-protocol";
-import { approvalSandboxLine, approvalSandboxWarns, DOCKER_ACCESS_WHY, modeSummary, sandboxBadge, sandboxSummary } from "./sandbox.js";
+import { approvalSandboxLine, approvalSandboxWarns, DOCKER_ACCESS_WHY, HOME_WORKSPACE_LINE, modeSummary, sandboxBadge, sandboxDirsText, sandboxSummary } from "./sandbox.js";
import { dispatchSlash, parseSlash } from "./slash.js";
const on: SessionSandboxInfo = { choice: "auto", state: "on", provider: "bwrap", providerName: "bubblewrap", network: "off", confinement: "native", via: "Claude Code's sandbox (bubblewrap) with codeoid's policy", enforced: true };
@@ -80,3 +80,22 @@ describe("the mode line in a list (#448)", () => {
expect(modeSummary({})).toBeNull();
});
});
+
+describe("project roots and added directories (working across repositories)", () => {
+ const on = { choice: "auto", state: "on" as const, provider: "bwrap", providerName: "bubblewrap", network: "on" as const, enforced: true };
+
+ it("the tooltip and the list line say what it reads and writes besides the workspace, and what lands next turn", () => {
+ const info = { ...on, projectRoots: ["/home/u/Workspace"], addedDirs: ["/home/u/Workspace/b"], pendingDirs: { add: ["/srv/c"], remove: [] } };
+ expect(sandboxDirsText(info)).toBe("reads /home/u/Workspace (read-only); also writes /home/u/Workspace/b; from the next turn: +/srv/c");
+ expect(sandboxBadge(info)?.title).toContain("It reads /home/u/Workspace (read-only); also writes /home/u/Workspace/b");
+ expect(sandboxSummary(info)).toBe("sandbox: bubblewrap, network on; reads /home/u/Workspace (read-only); also writes /home/u/Workspace/b; from the next turn: +/srv/c");
+ expect(sandboxDirsText(on)).toBeNull();
+ });
+
+ it("a workspace that is the home: the chip says so", () => {
+ const b = sandboxBadge({ ...on, homeWorkspace: true });
+ expect(b).toMatchObject({ text: HOME_WORKSPACE_LINE, tone: "warn" });
+ expect(HOME_WORKSPACE_LINE).toBe("sandboxed: workspace is your home — only credentials are protected");
+ expect(sandboxSummary({ ...on, homeWorkspace: true })).toContain("workspace is your home (only credentials are protected)");
+ });
+});
diff --git a/packages/core/src/slash.test.ts b/packages/core/src/slash.test.ts
index 060d9cff..86fd7122 100644
--- a/packages/core/src/slash.test.ts
+++ b/packages/core/src/slash.test.ts
@@ -406,3 +406,24 @@ describe("/account", () => {
expect(c.sent[0]).toMatchObject({ type: "session.set_claude_account", sessionId: c.sessionId, account: null });
});
});
+
+describe("/sandbox add|remove (working across repositories)", () => {
+ it("parses a directory to add or remove, spaces and all; the provider form is unchanged", () => {
+ expect(parseSlash("/sandbox add ~/Workspace/b")).toEqual({ kind: "sandbox-dir", action: "add", path: "~/Workspace/b" });
+ expect(parseSlash("/sandbox remove /srv/my repo")).toEqual({ kind: "sandbox-dir", action: "remove", path: "/srv/my repo" });
+ expect(parseSlash("/sandbox rm /srv/x")).toEqual({ kind: "sandbox-dir", action: "remove", path: "/srv/x" });
+ expect(() => parseSlash("/sandbox add")).toThrow("/sandbox add ");
+ expect(parseSlash("/sandbox bwrap off")).toEqual({ kind: "sandbox", sandbox: { provider: "bwrap", network: "off" } });
+ });
+
+ it("sends session.sandbox_dir, and says what was done unless it was asked of the owner", async () => {
+ const c = ctx();
+ dispatchSlash({ kind: "sandbox-dir", action: "add", path: "/srv/b" }, c);
+ expect(c.sent).toEqual([{ type: "session.sandbox_dir", id: "req-1", sessionId: "s1", action: "add", path: "/srv/b" }]);
+ const reports: string[] = [];
+ const withRequest = { ...ctx(), request: async () => ({ data: { message: "/srv/b removed: not writable from the session's next turn" } }), report: (t: string) => void reports.push(t) };
+ dispatchSlash({ kind: "sandbox-dir", action: "remove", path: "/srv/b" }, withRequest as never);
+ await Bun.sleep(1);
+ expect(reports).toEqual(["/srv/b removed: not writable from the session's next turn"]);
+ });
+});
diff --git a/packages/core/src/slash.ts b/packages/core/src/slash.ts
index 6264981e..188ef5d8 100644
--- a/packages/core/src/slash.ts
+++ b/packages/core/src/slash.ts
@@ -36,6 +36,7 @@ export type SlashCommand =
| { kind: "rotate" }
| { kind: "mode"; mode: SessionMode; maxTurns?: number }
| { kind: "sandbox"; sandbox: SessionSandboxSetting }
+ | { kind: "sandbox-dir"; action: "add" | "remove"; path: string }
| { kind: "model"; model: string; fallback?: string | null }
| { kind: "model-picker" }
| { kind: "effort"; effort: EffortLevel | null }
@@ -94,8 +95,15 @@ export function parseSlash(raw: string, opts?: ParseSlashOptions): SlashCommand
case "rotate":
return { kind: "rotate" };
case "sandbox": {
- const usage = "/sandbox [on|off|allow:host,host] [docker|nodocker]";
+ const usage = "/sandbox [on|off|allow:host,host] [docker|nodocker] — or /sandbox add|remove ";
if (rest.length === 0) throw new Error(usage);
+ // A directory the session's commands may write besides its workspace (adding asks its owner).
+ const sub = rest[0]?.toLowerCase();
+ if (sub === "add" || sub === "remove" || sub === "rm") {
+ const path = rest.slice(1).join(" ").trim();
+ if (!path) throw new Error(`/sandbox ${sub} `);
+ return { kind: "sandbox-dir", action: sub === "add" ? "add" : "remove", path };
+ }
const sandbox: SessionSandboxSetting = {};
for (const word of rest) {
const w = word.toLowerCase();
@@ -331,6 +339,21 @@ export function dispatchSlash(cmd: SlashCommand, ctx: SlashContext): void {
sandbox: cmd.sandbox,
});
return;
+ case "sandbox-dir": {
+ const msg: ClientMessage = { type: "session.sandbox_dir", id: ctx.newRequestId(), sessionId: ctx.sessionId, action: cmd.action, path: cmd.path };
+ if (!ctx.request) {
+ ctx.send(msg);
+ return;
+ }
+ ctx
+ .request(msg)
+ .then((resp) => {
+ const r = (resp as { data?: { message?: string; pending?: boolean } } | undefined)?.data;
+ if (r?.message && !r.pending) ctx.report?.(r.message);
+ })
+ .catch((e) => ctx.report?.(e instanceof Error ? e.message : String(e)));
+ return;
+ }
case "mode":
fire({
type: "session.set_mode",
diff --git a/src/cli.ts b/src/cli.ts
index 018f273b..265b1b6b 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -17,7 +17,7 @@
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
-import { join, resolve } from "node:path";
+import { isAbsolute, join, resolve } from "node:path";
import { homedir } from "node:os";
import { randomUUID } from "node:crypto";
import { type Command, program } from "commander";
@@ -1081,6 +1081,9 @@ program
.option("--network ", "What a sandboxed command may reach: on | off | allowlist (with --allow-host). Default: sandbox.network.")
.option("--allow-host ", "A host the allowlist lets through, repeatable (example.com, *.example.com).", (v: string, p: string[] = []) => [...p, v], [] as string[])
.option("--docker-socket", "Let the session's sandboxed commands reach the container runtime (testcontainers, docker run). Docker access can reach the whole machine; needs session:approve. Default: sandbox.docker.socket.")
+ .option("--add-dir ", "Another directory its sandboxed commands may write (a second repository), repeatable; treated as a workspace. Needs session:approve (docs/sandbox.md, Working across repositories).", (v: string, p: string[] = []) => [...p, v], [] as string[])
+ .option("--project-root ", "Where its sandboxed commands may read other repositories, read-only, repeatable; replaces sandbox.projectRoots (@parent = the repository's parent, the default).", (v: string, p: string[] = []) => [...p, v], [] as string[])
+ .option("--no-project-roots", "No project root: its commands read only the workspace (and the toolchains the sandbox shows)")
.option(
"--pack ",
"Activate an installed SDLC pack on the session (inject its constitution, expose its skills/subagents). With --collaborate, the collaboration ADOPTS the pack: role names bind strictly to the pack's declared roles.",
@@ -1119,6 +1122,9 @@ program
network?: string;
allowHost: string[];
dockerSocket?: boolean;
+ addDir: string[];
+ projectRoot: string[];
+ projectRoots?: boolean;
},
) => {
const config = loadConfig();
@@ -1196,9 +1202,24 @@ program
},
);
-/** A session's sandbox setting from `--sandbox` / `--network` / `--allow-host` / `--[no-]docker-socket` (#448); undefined when none is given. */
-function sandboxSettingFromFlags(o: { sandbox?: string; network?: string; allowHost?: string[]; dockerSocket?: boolean }): SessionSandboxSetting | undefined {
+/** A directory as the daemon keeps it: `~` left to it (its home), a relative one this shell's. */
+function dirFlag(d: string): string {
+ return d === "~" || d.startsWith("~/") || isAbsolute(d) ? d : resolve(process.cwd(), d);
+}
+
+/**
+ * A session's sandbox setting from `--sandbox` / `--network` / `--allow-host` / `--[no-]docker-socket`
+ * (#448) and `--add-dir` / `--project-root` / `--no-project-roots`; undefined when none is given.
+ */
+function sandboxSettingFromFlags(o: { sandbox?: string; network?: string; allowHost?: string[]; dockerSocket?: boolean; addDir?: string[]; projectRoot?: string[]; projectRoots?: boolean }): SessionSandboxSetting | undefined {
const out: SessionSandboxSetting = {};
+ if (o.addDir && o.addDir.length > 0) out.addedDirs = o.addDir.map(dirFlag);
+ if (o.projectRoots === false) {
+ if (o.projectRoot && o.projectRoot.length > 0) throw new Error("--no-project-roots and --project-root don't go together");
+ out.projectRoots = [];
+ } else if (o.projectRoot && o.projectRoot.length > 0) {
+ out.projectRoots = o.projectRoot.map((r) => (r === "@parent" ? r : dirFlag(r)));
+ }
if (o.sandbox) out.provider = o.sandbox;
if (o.network) {
if (o.network !== "on" && o.network !== "off" && o.network !== "allowlist") throw new Error("--network is on, off or allowlist");
@@ -1252,6 +1273,22 @@ sandboxCmd
client.disconnect();
});
+for (const action of ["add", "remove"] as const) {
+ sandboxCmd
+ .command(`${action} `)
+ .description(
+ action === "add"
+ ? "Let a session's sandboxed commands write another directory (a second repository), as its workspace: asked of its owner on the session; from its next turn"
+ : "Take a directory the session was given to write away again (from its next turn)",
+ )
+ .action(async (session: string, dir: string) => {
+ const client = new TerminalClient(loadConfig());
+ await client.connect();
+ await client.sandboxDir(session, action, dirFlag(dir));
+ client.disconnect();
+ });
+}
+
program
.command("attach ")
.description(
diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts
index ef3d16c0..148983d2 100644
--- a/src/daemon/session-manager.ts
+++ b/src/daemon/session-manager.ts
@@ -9162,7 +9162,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,
},
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/web/src/components/NewSessionModal.test.tsx b/web/src/components/NewSessionModal.test.tsx
index a76bc6c4..fe184b75 100644
--- a/web/src/components/NewSessionModal.test.tsx
+++ b/web/src/components/NewSessionModal.test.tsx
@@ -882,6 +882,23 @@ describe("NewSessionModal sandbox choice (#448)", () => {
await waitFor(() => expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ type: "session.create", sandbox: { dockerSocket: true } })));
});
+ it("project roots and added directories (working across repositories): the default shown, sent as projectRoots and addedDirs; none is []", async () => {
+ authMock.mockReturnValue(authOk(["claude"]));
+ requestMock.mockImplementation(async (m: { type: string }) => (m.type === "sandbox.providers" ? providersReply : { id: "s-new", name: "n", workdir: "/w" }));
+ const { getByText, getByLabelText, getByPlaceholderText } = render(() => );
+ openNewSessionModal();
+ fireEvent.click(getByText("change"));
+ await waitFor(() => expect(getByLabelText("Project roots")).toBeTruthy());
+ expect((getByLabelText("Project roots") as HTMLInputElement).placeholder).toContain("default: the repository's parent");
+ fireEvent.input(getByLabelText("Project roots"), { target: { value: "@parent, ~/src" } });
+ fireEvent.input(getByLabelText("Added directories"), { target: { value: "~/Workspace/b,\n/srv/c" } });
+ fireEvent.input(getByPlaceholderText("e.g. shield-refactor"), { target: { value: "demo" } });
+ fireEvent.click(getByText("create"));
+ await waitFor(() =>
+ expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ type: "session.create", sandbox: { projectRoots: ["@parent", "~/src"], addedDirs: ["~/Workspace/b", "/srv/c"] } })),
+ );
+ });
+
it("off says what it means", async () => {
authMock.mockReturnValue(authOk(["claude"]));
requestMock.mockResolvedValue(providersReply);
diff --git a/web/src/components/NewSessionModal.tsx b/web/src/components/NewSessionModal.tsx
index b693d2ad..561f9773 100644
--- a/web/src/components/NewSessionModal.tsx
+++ b/web/src/components/NewSessionModal.tsx
@@ -32,10 +32,25 @@ import type { PackWire, SandboxProviderWire, SessionInfo } from "../protocol/typ
interface SandboxProvidersData {
enabled: boolean;
auto: string | null;
- default: { provider: string; network: string; dockerSocket?: boolean };
+ default: { provider: string; network: string; dockerSocket?: boolean; projectRoots?: string[] };
providers: SandboxProviderWire[];
}
+/** A list typed in a field: comma- or newline-separated, trimmed. */
+function dirList(text: string): string[] {
+ return text
+ .split(/[,\n]/)
+ .map((d) => d.trim())
+ .filter(Boolean);
+}
+
+/** What the daemon's default project roots mean, in words. */
+function rootsDefaultLabel(d: SandboxProvidersData | null): string {
+ const roots = d?.default.projectRoots ?? ["@parent"];
+ if (roots.length === 0) return "none";
+ return roots.map((r) => (r === "@parent" ? "the repository's parent" : r)).join(", ");
+}
+
function sandboxDefaultLabel(d: SandboxProvidersData | null): string {
if (!d) return "auto";
const p = d.default.provider === "auto" && d.auto ? `auto: ${d.auto}` : d.default.provider;
@@ -153,6 +168,9 @@ const NewSessionModal: Component = () => {
const [sandboxNetwork, setSandboxNetwork] = createSignal("");
// Docker access (the container runtime's socket): off unless ticked, or the daemon's default has it.
const [sandboxDocker, setSandboxDocker] = createSignal(false);
+ // Working across repositories: "" = the daemon's project roots; "none" = none; else a list. Added dirs: writable too.
+ const [sandboxRoots, setSandboxRoots] = createSignal("");
+ const [sandboxAdded, setSandboxAdded] = createSignal("");
// The sandbox choice is opened on demand ("change"), and only then asks the
// daemon what it has (absent: an older daemon, or no sandboxing).
const [sandboxOpen, setSandboxOpen] = createSignal(false);
@@ -579,12 +597,14 @@ const NewSessionModal: Component = () => {
: {}),
...(packId() ? { pack: packId() } : {}),
...(packRole() ? { packRole: packRole() } : {}),
- ...(sandboxChoice() || sandboxNetwork() || sandboxDocker()
+ ...(sandboxChoice() || sandboxNetwork() || sandboxDocker() || sandboxRoots().trim() || sandboxAdded().trim()
? {
sandbox: {
...(sandboxChoice() ? { provider: sandboxChoice() } : {}),
...(sandboxNetwork() ? { network: sandboxNetwork() as "on" | "off" } : {}),
...(sandboxDocker() ? { dockerSocket: true } : {}),
+ ...(sandboxRoots().trim() ? { projectRoots: sandboxRoots().trim().toLowerCase() === "none" ? [] : dirList(sandboxRoots()) } : {}),
+ ...(dirList(sandboxAdded()).length > 0 ? { addedDirs: dirList(sandboxAdded()) } : {}),
},
}
: {}),
@@ -1053,8 +1073,34 @@ const NewSessionModal: Component = () => {
Off: the agent's commands can read your credentials and change any file you can, in every mode.
+
+
+
+
- The workspace and a private temp dir are writable; everything else is read-only and credential stores are hidden. Change it later with /sandbox.
+ The workspace and a private temp dir are writable, and the project roots readable; everything else is read-only and credential stores are hidden. Change it later with /sandbox, or /sandbox add <dir>; the agent can ask for a directory too.
diff --git a/web/src/components/SandboxChip.tsx b/web/src/components/SandboxChip.tsx
index da412bf1..c4f9d997 100644
--- a/web/src/components/SandboxChip.tsx
+++ b/web/src/components/SandboxChip.tsx
@@ -24,7 +24,7 @@ export const SandboxChip: Component<{ session: SessionInfo }> = (props) => {
{(b) => (
[on|off|allow:host,…] [docker|nodocker]`}
+ title={`${b().title}\nChange it with /sandbox [on|off|allow:host,…] [docker|nodocker]; write another directory with /sandbox add `}
data-sandbox={props.session.sandbox?.state}
>
🛡 {b().text}
From f86afe612fa50beda88bce56cc4a64f07b68a2a9 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Sun, 8 Nov 2026 11:47:01 +0800
Subject: [PATCH 03/23] test: Claude's own sandbox reads a project root around
the workspace, and writes an added dir, for real (#448)
The real CLI's bubblewrap shows the root read-only, the workspace still
writable, a sibling's .env hidden, and an added dir writable; handing it the
root whole makes the workspace read-only, which is why it is split. The
session's prompt names request_directory only where the backend mounts it.
Co-Authored-By: Claude Opus 5.5
---
src/daemon/session.ts | 11 +++++++-
src/tests/fixtures/claude-sandbox-probe.ts | 6 +++-
src/tests/sandbox-claude-real.test.ts | 33 ++++++++++++++++++++++
3 files changed, 48 insertions(+), 2 deletions(-)
diff --git a/src/daemon/session.ts b/src/daemon/session.ts
index b73b4fd7..4e151671 100644
--- a/src/daemon/session.ts
+++ b/src/daemon/session.ts
@@ -7994,10 +7994,19 @@ export class Session {
: "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).`);
- lines.push("To write in another directory, call the `request_directory` tool (path, reason). What it grants is writable from your next turn.");
+ 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
diff --git a/src/tests/fixtures/claude-sandbox-probe.ts b/src/tests/fixtures/claude-sandbox-probe.ts
index ab15bda0..37c55817 100644
--- a/src/tests/fixtures/claude-sandbox-probe.ts
+++ b/src/tests/fixtures/claude-sandbox-probe.ts
@@ -24,7 +24,8 @@
* 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
+ * 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.
@@ -124,6 +125,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",
diff --git a/src/tests/sandbox-claude-real.test.ts b/src/tests/sandbox-claude-real.test.ts
index e1968122..95741795 100644
--- a/src/tests/sandbox-claude-real.test.ts
+++ b/src/tests/sandbox-claude-real.test.ts
@@ -363,6 +363,39 @@ describe.skipIf(!ready)("Claude Code's sandbox with codeoid's policy, for real",
expect(r.toolResult ?? "").not.toContain("RAN");
expect(existsSync(join(outside, "keep.txt"))).toBe(true);
}, 120_000);
+
+ test("project roots and added dirs (working across repositories): the root shown around the workspace (its read binds land after its write binds), b read-only and its .env hidden; added, b writable", async () => {
+ const home = join(root, "home");
+ const ws = join(home, "Workspace", "a");
+ const b = join(home, "Workspace", "b");
+ for (const d of [ws, b, join(home, "Workspace", "c")]) mkdirSync(d, { recursive: true });
+ writeFileSync(join(b, "README.md"), "bravo readme\n");
+ writeFileSync(join(b, ".env"), "PLANTED-B-ENV\n");
+ const command = [
+ `cat ${join(b, "README.md")}`,
+ `cat ${join(b, ".env")} 2>/dev/null || echo ENV-HIDDEN`,
+ `echo a > ${join(ws, "own.txt")} && echo WS-WRITABLE`,
+ `echo b > ${join(b, "new.txt")} 2>/dev/null && echo B-WRITTEN || echo B-READ-ONLY`,
+ `echo c > ${join(home, "Workspace", "c", "new.txt")} 2>/dev/null && echo C-WRITTEN || echo C-READ-ONLY`,
+ ].join("; ");
+ const env = { CODEOID_PROBE_WORKSPACE: ws, CODEOID_PROBE_PROJECT_ROOTS: join(home, "Workspace") };
+ const r = await probe("off", command, ["policy"], env);
+ expect(r.result).toBe("success");
+ expect(r.toolResult).toContain("bravo readme");
+ expect(r.toolResult).toContain("ENV-HIDDEN");
+ expect(r.toolResult).not.toContain("PLANTED-B-ENV");
+ expect(r.toolResult).toContain("WS-WRITABLE");
+ expect(r.toolResult).toContain("B-READ-ONLY");
+ expect(r.toolResult).toContain("C-READ-ONLY");
+ const added = await probe("off", command, ["policy"], { ...env, CODEOID_PROBE_ADDED_DIRS: b });
+ expect(added.result).toBe("success");
+ expect(added.toolResult).toContain("WS-WRITABLE");
+ expect(added.toolResult).toContain("B-WRITTEN");
+ expect(added.toolResult).toContain("PLANTED-B-ENV");
+ expect(added.toolResult).toContain("C-READ-ONLY");
+ expect(existsSync(join(b, "new.txt"))).toBe(true);
+ expect(existsSync(join(home, "Workspace", "c", "new.txt"))).toBe(false);
+ }, 300_000);
});
/**
From f928c46c8e5b5cd76501b47a9c6a6c9f87ccbcb0 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Sun, 8 Nov 2026 11:47:01 +0800
Subject: [PATCH 04/23] =?UTF-8?q?docs:=20working=20across=20repositories?=
=?UTF-8?q?=20in=20the=20sandbox=20=E2=80=94=20project=20roots,=20added=20?=
=?UTF-8?q?directories,=20request=5Fdirectory=20(#448)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
docs/sandbox.md "Working across repositories": what a root shows and still
hides, how a directory is added (owner, agent, per backend), when a change
takes effect per backend, a workspace that is the home, and the limits;
CONFIGURATION.md's sandbox.projectRoots, CLAUDE.md and the CHANGELOG.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 5 ++
CLAUDE.md | 20 +++++
docs/CONFIGURATION.md | 4 +-
docs/sandbox.md | 189 +++++++++++++++++++++++++++++++++++++++++-
4 files changed, 214 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7931a48..83b54d44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Everything else in a location stays read-only to the session: your worktrees, other sessions' folders, codeoid's fork, loop and `--worktree` checkouts, and anything made there later; every existing worktree's admin dir is held. A worktree added or removed anywhere never rebuilds a session's sandbox (a Claude query, and its background tasks, keep running): instead every admin dir is checked against git's own layout when the sandbox is built, at every turn's end and at destroy, what a command changed there (its `gitdir`, `commondir`, a `config.worktree`, a planted `config` or hook, a duplicate a `git worktree repair` would adopt) is restored, and what can't be restored with certainty (an admin dir replaced by a link: the link is removed) is said and left; both on the session and in the audit log (`session.worktree_entry_restored`). A worktree is the session's own only when it made it, never because an admin dir says so. A `worktreeRoots` entry where tools load programs or settings (`~/.config`, `~/.local`, `~/Library`, …), the home or `/` fails the config's load, and a relative one is judged again where it leads (`../.config`); a root that doesn't exist is never made away from the repository.
The end-of-turn warning names a worktree in the session's folder whose `.git` points anywhere but its own admin dir, and an admin dir that changed. See [docs/sandbox.md](docs/sandbox.md#git-worktrees-inside-the-sandbox).
+- **Sandboxed sessions work across repositories: other repositories readable, more directories writable** (#448).
+ Everything under a session's **project roots** is readable to its commands, read-only: by default the parent of its repository (a session in `~/Workspace/codeoid` reads `~/Workspace/*`: the repositories beside it, and what other sessions did in their checkouts there). `sandbox.projectRoots` (`CODEOID_SANDBOX_PROJECT_ROOTS`, Settings > Sandbox) sets it, `@parent` being the default; per session at create `codeoid new --project-root ` / `--no-project-roots` or the new-session dialog. What the sandbox hides anywhere stays hidden inside a root, and so do the secret files at each project's top there (`.env`, `.env.*` but examples, `.envrc`, `.npmrc`, …). `/`, the home, a dir holding it, a hidden dir of it, `~/Library` and system dirs are refused; a default that would be the home (a repository directly in it) falls back to none, said on the session.
+ A session can be given **more directories to write**, each treated as its workspace (its git structure and project settings held, its own worktree folder there, the end-of-turn git look): `/sandbox add ` / `/sandbox remove `, `codeoid sandbox add|remove `, `codeoid new --add-dir `, the new-session dialog. Adding is a widening: its owner's own, undelegated, confirmed on the session, audited. The agent can ask with codeoid's `request_directory` tool (every backend that runs commands): in guarded and interactive mode its owner gets a card with the directory and the reason; in autonomous mode one under a project root is added unasked and anything else refused at once with a reason; an unattended run is never parked. Added directories survive a restart and are inherited by forks. A change takes effect at the session's next turn with no background work running (Claude's query rebuilt, a process backend restarted then), never mid-work; until then it shows as pending.
+ The chip, `codeoid ls` and approval cards say what the sandbox reads and writes besides the workspace. A session whose workspace is the home itself is allowed and says "sandboxed: workspace is your home — only credentials are protected". See [docs/sandbox.md](docs/sandbox.md#working-across-repositories).
+
- **Docker access, an opt-in for tests that start containers** (#448).
`sandbox.docker.socket` (`CODEOID_SANDBOX_DOCKER_SOCKET`) or a session's own `--docker-socket` lets sandboxed commands reach the container runtime's socket (Docker, rootless Docker, Podman; Docker Desktop on macOS) with `DOCKER_HOST` set; never the client's credentials. Off by default, a widening its owner confirms, and said on the chip and every approval card: it can reach the whole machine. Only that runtime's socket is shown: libvirt's, LXD's, Incus's and every other runtime's stay hidden in every sandbox, as do the system D-Bus and screen's sessions. On Claude's Linux sandbox, which can only allow every unix socket, the host's `/tmp` (tmux, X11, ssh-agent sockets) is hidden too, so are local databases' sockets with the network off, and what stays reachable is said. A runtime socket you can't connect to isn't opened up. See [docs/sandbox.md](docs/sandbox.md#docker-access).
diff --git a/CLAUDE.md b/CLAUDE.md
index a148acd2..8fd68a51 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -216,6 +216,7 @@ codeoid account ls|login |add-key |add-gateway|add-token|add-bedrock
codeoid destroy
codeoid new --sandbox auto|bwrap|seatbelt|docker|off [--network on|off|allowlist] [--allow-host h] [--docker-socket] # the session's OS sandbox (#448, docs/sandbox.md)
codeoid sandbox providers | set [provider] [--network …] [--[no-]docker-socket] # what runs here; change a session's (widening asks its owner)
+codeoid sandbox add|remove # another dir its commands write, as a workspace (adding asks its owner); codeoid new … --add-dir --project-root --no-project-roots
codeoid mcp config [--scopes observe|operate|full] # MCP client entry for /mcp/control
codeoid mcp headers [--scopes …] # fresh bearer header (an MCP headersHelper)
codeoid mcp reconnect # redial a backend MCP server that's down (#389)
@@ -438,6 +439,24 @@ containerd, …: `containerSocketPaths`) and the system bus and screen's dir
(Postgres, MySQL, Redis: `localServiceSocketPaths` → `SandboxSpec.serviceSockets`)
when the network the session asked for is off or an allowlist.
+Working across repositories (sandbox/project-roots.ts, dir-rules.ts, request-directory.ts; docs/sandbox.md
+"Working across repositories"): `sandbox.projectRoots` (default `@parent`: the parent of the repository's main
+checkout, `defaultProjectRoot`; the home falls back to none, said) are shown read-only (`SandboxSpec.projectRoots`,
+in `reexposed`), the hidden list hidden inside and `projectRootSecrets` (`.env*`, `.envrc`, `.npmrc`, … at each
+project's top, not the session's own). `SessionSandboxSetting.addedDirs` are writable, each a workspace to
+`buildSandboxSpec` (its `gitWorkspacePaths`, `projectConfigPaths`, worktree folder, a `WorktreeEntryGuard` per
+repository) and to `protectedWriteTargets` and the end-of-turn git look (`#checkAddedGitControl`); validated by
+`addedDirProblem` (`sandboxDirProblem`'s refusals, what the sandbox hides, a PATH entry). Adding a dir or a root
+`widens()`. `session.sandbox_dir` (owner, undelegated, owner-only confirm; remove narrows) and the agent's
+`request_directory` (`Session#requestDirectory`: guarded/interactive an owner-only confirm with the reason,
+autonomous added under a root else refused, unattended refused; in-process on Claude/Qwen, pi's bridge,
+`/mcp/sandbox` loopback with a per-session token for Codex/Gemini CLI/opencode; `isRequestDirectoryTool` skips the
+gate card). A dirs-only change (`onlyDirsChanged`) leaves the runtime until `Session.ensureSandbox` applies it at a
+turn start with no turn, approval or background work (`dirsPending`, `SessionSandboxInfo.pendingDirs`), then
+`#restartForSandboxDirs` restarts a process backend. Claude native shows a root around what is writable
+(`readAround`: its CLI binds allowRead after allowWrite). `homeWorkspace`: the chip says "sandboxed: workspace is
+your home — only credentials are protected".
+
Shell commands are approved by risk (#431, docs/approvals.md): `src/daemon/shell/`
parses a command line with tree-sitter-bash (WASM, vendored `tree-sitter-bash.wasm`;
the daemon loads it at start, so the gate judges synchronously and the approval
@@ -488,6 +507,7 @@ CODEOID_SANDBOX=auto # OS sandbox for agents' commands: auto|bwra
CODEOID_SANDBOX_NETWORK=on # on|off|allowlist (CODEOID_SANDBOX_ALLOWED_HOSTS); hiddenPaths: CODEOID_SANDBOX_HIDDEN_PATHS
CODEOID_SANDBOX_DOCKER_SOCKET=false # let sandboxed commands reach the container runtime (root-equivalent; docs/sandbox.md)
CODEOID_SANDBOX_WORKTREE_ROOTS= # more worktree locations (csv): //; never ~/.config, ~, /
+CODEOID_SANDBOX_PROJECT_ROOTS=@parent # readable roots (csv): @parent = the repository's parent; [] none; never ~, /
# Claude auth: uses your existing `claude login` session (Pro/Max subscription).
# No ANTHROPIC_API_KEY needed if you're logged in via Claude Code CLI.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index b79900e4..bfbeb96d 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -228,7 +228,7 @@ The opencode backend (opencode 2.2.5 or a later 2.2.x) is on by default when an
### OS sandbox
-Agents' commands run in an OS sandbox, on by default ([sandbox.md](sandbox.md)): the workspace and a private temp dir writable, everything else read-only, your home hidden whole but for a read-only allowlist, credential stores hidden, container sockets unreachable.
+Agents' commands run in an OS sandbox, on by default ([sandbox.md](sandbox.md)): the workspace and a private temp dir writable, the project roots (by default the parent of the session's repository) readable, everything else read-only, your home hidden whole but for a read-only allowlist, credential stores hidden, container sockets unreachable.
```json
{
@@ -238,6 +238,7 @@ Agents' commands run in an OS sandbox, on by default ([sandbox.md](sandbox.md)):
"allowedHosts": [],
"hiddenPaths": ["~/work/.env"],
"homeReadable": ["~/.bun", "~/.cargo/bin", "~/.gitconfig", "~/.volta"],
+ "projectRoots": ["@parent", "~/src/shared"],
"bwrapPath": "/usr/bin/bwrap",
"docker": { "image": "node:22-bookworm-slim", "runtime": "runsc" }
}
@@ -253,6 +254,7 @@ Agents' commands run in an OS sandbox, on by default ([sandbox.md](sandbox.md)):
- `docker.image` (`CODEOID_SANDBOX_DOCKER_IMAGE`) and `docker.runtime` — what a Docker sandbox runs, and `runsc` for gVisor.
- `docker.socket` (`CODEOID_SANDBOX_DOCKER_SOCKET`, default `false`) — Docker access: sandboxed commands can reach the host's container runtime socket (Docker, rootless Docker, Podman; Docker Desktop on macOS), with `DOCKER_HOST` set, for tests that start containers. Root-equivalent (a container can mount `/`), so every session with it says "sandboxed, Docker access"; a session turns it on for itself with `--docker-socket` / `/sandbox … docker`, asked of its owner ([sandbox.md](sandbox.md#docker-access)).
- `worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, comma separated) — more places a sandboxed session may check a git worktree of its repository out, on top of `-worktrees/` (`~` expands; a relative path is the repository's main checkout's, at most one level up, judged again where it leads: `../.config` of a repository in your home is refused; never inside the checkout). A root may be shared: each repository gets `//`, and a sandboxed session writes only its own folder there. A root that doesn't exist is used once you create it (codeoid makes one only beside the repository). The config fails to load on `/`, the home or a dir holding it, anything in a hidden dir of the home (`~/.config`, `~/.local`, …), `~/Library`, codeoid's config dir or a system dir ([sandbox.md](sandbox.md#git-worktrees-inside-the-sandbox)).
+- `projectRoots` (`CODEOID_SANDBOX_PROJECT_ROOTS`, comma separated; Settings > Sandbox > "Project roots (readable)") — where sandboxed commands may read other repositories, read-only: absolute dirs (`~` expands) and `@parent`, the parent of the session's repository (the default: a session in `~/Workspace/codeoid` reads `~/Workspace/*`). `[]` is none. What the sandbox hides stays hidden inside, and so do the secret files at each project's top there (`.env*`, `.envrc`, `.npmrc`, …). The config fails to load on the same places as `worktreeRoots`; a default that would be your home (a repository directly in it) falls back to none, said on the session. A session can have its own at create (`codeoid new --project-root `, `--no-project-roots`, the new-session dialog); more than the daemon's is a widening (`session:approve`, undelegated). Writing in another directory is per session: `codeoid new --add-dir `, `/sandbox add `, `codeoid sandbox add `, or the agent's `request_directory` ([sandbox.md](sandbox.md#working-across-repositories)).
- Where the sandbox can't run, interactive sessions run unsandboxed and say so on every approval; autonomous sessions, routines and loops refuse to start. `codeoid sandbox providers` shows what runs here.
### One daemon per data directory
diff --git a/docs/sandbox.md b/docs/sandbox.md
index 611e9e74..43b2f8c1 100644
--- a/docs/sandbox.md
+++ b/docs/sandbox.md
@@ -4,7 +4,10 @@ Every shell command an agent runs, on every backend that can be sandboxed,
runs inside an operating-system sandbox. The workspace and a private temp
directory are writable (and, so an agent can branch in a worktree of its own,
the session's own [worktree folder](#git-worktrees-inside-the-sandbox));
-everything else is read-only; credential stores are not there at all;
+the session's [project roots](#working-across-repositories) (by default the
+parent of its repository: the repositories beside it) are readable, and the
+directories it was [given](#added-directories-other-repositories-writable)
+writable; everything else is read-only; credential stores are not there at all;
container runtime sockets can't be reached unless the owner turns on
[Docker access](#docker-access); the network is a per-session setting.
@@ -778,6 +781,167 @@ Limits (defence in depth, as the rest of git's structure):
- On Seatbelt `worktrees/` and each session folder are kept from being renamed
by a `literal` write denial (not verified on a Mac: see below).
+## Working across repositories
+
+A session works in one directory, but work often spans several: a library
+and the app that uses it, a fix in a client and its server, a look at what
+another session did in its checkout. Two things open the sandbox up for
+that, each as narrow as it can be.
+
+### Project roots: other repositories, read-only
+
+Everything under a **project root** is readable to the session's commands,
+read-only, and to the backend's own file tools as the gate holds them
+([below](#file-tools-that-run-outside-the-sandbox)). By default the root is
+the **parent of the session's repository**: a session in `~/Workspace/codeoid`
+reads `~/Workspace/*`, so `cat`, `grep`, `git log` and `git status` work in
+the repositories beside it, and in the other sessions' checkouts there
+(`~/Workspace/codeoid-worktrees/…`). For a linked worktree (a fork's) it is
+the parent of the repository's main checkout; for a workspace that isn't in
+a repository, the workspace's parent.
+
+- **Set it** daemon-wide with `sandbox.projectRoots`
+ (`CODEOID_SANDBOX_PROJECT_ROOTS`, comma-separated; Settings > Sandbox >
+ "Project roots"): a list of absolute dirs (`~` expands) and `@parent` (the
+ default, the repository's parent). `[]` is none. Per session at create:
+ `codeoid new … --project-root ` (repeatable; replaces the daemon's
+ list, `@parent` included only if you name it), `--no-project-roots`, the
+ new-session dialog's "Readable project roots", or `session.create`'s
+ `sandbox.projectRoots`. A root the daemon's default doesn't have is a
+ widening (needs `session:approve` and an undelegated token, as below);
+ fewer is narrower.
+- **What stays hidden inside a root**: everything the sandbox hides anywhere
+ — codeoid's config and data dirs, every agent CLI's login and the other
+ [credential stores](#what-is-protected), `sandbox.hiddenPaths`, the
+ desktop's runtime dir, container and (with the network off) local service
+ sockets — and the **secret files at the top of each project** under the
+ root (the root itself and each dir directly in it): `.env` and `.env.*`
+ (not `.env.example`, `.sample`, `.template`, `.dist`, `.defaults`,
+ `.schema`), `.envrc`, `.npmrc`, `.pypirc`, `.netrc`, `.git-credentials`.
+ Not the session's own: its workspace's and its added directories' are its
+ to use. They are found when the sandbox is built: one made later, or deeper
+ (`~/Workspace/b/config/.env`), isn't hidden; add such paths to
+ `sandbox.hiddenPaths`.
+- **Refused as a root**: `/`, your home or a dir holding it, anything in a
+ hidden dir of your home (`~/.config`, `~/.local`, …: where tools load
+ programs and settings from), `~/Library`, codeoid's config dir, a system
+ dir (`/etc`, `/usr`, …), a relative path. The config fails to load on one;
+ a session's own is refused at create. **A default that would be your home**
+ (a repository directly in it, `~/proj`) falls back to no root, said on the
+ session ("no project root: the default, the repository's parent (…), is
+ your home…"): set `sandbox.projectRoots` to read your other repositories.
+- Writing there stays refused: "Read-only file system", as anywhere else
+ outside the workspace.
+
+### Added directories: other repositories, writable
+
+A session can be given **more directories to write**, each treated as its
+workspace is: writable, its repository's git structure (config, hooks,
+`info/`, the other worktrees' admin dirs, …) and project settings
+(`.claude/`, `.mcp.json`, …) held read-only and made first where missing,
+the session's own [worktree folder](#git-worktrees-inside-the-sandbox) in
+its repository's locations (`~/Workspace/b-worktrees//`), a
+host-side file tool writing its git structure or settings asked about
+([held writes](#file-tools-that-run-outside-the-sandbox)), and the
+end-of-turn look at its git settings and commits (`sandbox.git_changed`,
+`sandbox.settings_committed`, naming the directory). What the sandbox hides
+stays hidden inside one; its own `.env` is readable.
+
+**Adding one is a widening**, so it is the owner's own decision:
+
+- **By the owner**: `/sandbox add ` and `/sandbox remove ` (every
+ frontend), `codeoid sandbox add|remove `,
+ `codeoid new … --add-dir ` (repeatable), the new-session dialog's
+ "Also writable", `session.create`'s `sandbox.addedDirs`, the
+ `session.sandbox_dir` message. Adding needs `session:approve`, the
+ session's owner and an undelegated token; on a running session it is a
+ confirm on the session ("Let "" write ?") that only the owner's
+ own token answers (`codeoid approve `, or any frontend). Removing
+ is narrowing: applied, by a delegated token too. Audited:
+ `session.sandbox_dir_ask`, `_added` (`by=owner`), `_denied`, `_removed`.
+- **Asked for by the agent**: codeoid's `request_directory` tool
+ (`path`, `reason`). In **guarded and interactive mode** the owner gets a
+ confirm card naming the directory and the agent's reason ("The agent asks
+ to write "), answered by the owner's own token only; the agent reads
+ the answer. In **autonomous mode** a directory under one of the session's
+ project roots is added at once, unasked (audited: `by=agent under a
+ project root, autonomous`), and anything else is refused at once with a
+ reason the agent reads ("… isn't under this session's project roots …; the
+ owner can add it (`/sandbox add `)"). An unattended run (a routine, a
+ loop) is never parked on it: refused, said. An unanswered card is
+ withdrawn after 8 minutes (the agent is told). Audited:
+ `session.sandbox_dir_request`, `_added` (`by=agent …`), `_refused`.
+- **Refused as an added dir**: everything refused as a root (above), and a
+ dir that isn't there, isn't a directory, isn't yours, is inside what the
+ sandbox hides (codeoid's data dirs, a credential store, a
+ `sandbox.hiddenPaths` entry, the daemon's temp dir), or holds a dir on
+ the daemon's `PATH` (a program written there would run outside the sandbox
+ at your next command). One inside the workspace is writable already.
+- **Kept**: in the session's sandbox setting, so a daemon restart and a
+ resume keep them, and a fork, a compare and a collaboration's children
+ inherit them (a fork carries them for the parent's owner only, undelegated,
+ as any [wider sandbox](#choosing-a-sandbox)).
+
+`request_directory`, per backend:
+
+| Backend | How it's delivered |
+| --- | --- |
+| Claude | in-process MCP server (`mcp__codeoid_sandbox__request_directory`), pre-approved: the session decides it |
+| Qwen Code | in-process MCP server, the same |
+| pi | a tool in codeoid's bridge extension, as registry servers' are |
+| Codex, Gemini CLI, opencode | the daemon's `/mcp/sandbox` endpoint (loopback only), a token of the session's own (Codex reads it from `CODEOID_SANDBOX_TOKEN`, kept out of its commands' environment by `CODEOID_*_TOKEN`); its call timeout is raised past the card's 8 minutes on Codex and opencode |
+| OpenAI, Gemini, llama.cpp | none: these run no commands, so there is no sandbox to widen |
+
+The gate never asks about the call itself (it is the ask); its card is the
+session's own confirm.
+
+### When a change takes effect
+
+An added or removed directory takes effect **at the session's next turn that
+starts with no turn, approval or background work running** — never in the
+middle of work, which a rebuilt sandbox would end. The agent is told
+("writable … from your next turn"); until then the session's info lists it
+as pending (the chip: "from the next turn: +"), and an info line says
+when it applied.
+
+| Backend | What happens at that turn |
+| --- | --- |
+| Claude (its own sandbox) | its query is built again with the new directories (it resumes the same conversation) |
+| Claude under Docker, Codex, pi, the Gemini CLI, Qwen Code | the backend's process is restarted in the new sandbox before the turn (the conversation resumes; the Gemini CLI is handed its history) |
+| opencode | the session's shell lines run in the new sandbox (its launcher is written afresh each turn); its server keeps running |
+
+With background work running (a Claude background agent, a background shell),
+the change waits until it has ended; the confirm says so.
+
+### A workspace that is your home
+
+Starting a session in a parent directory makes that directory its workspace,
+writable: start it where the work is. A session whose workspace is **your
+home itself** is allowed, but then nothing of your home is hidden but what
+the [hidden list](#what-is-protected) names: every project, dotfile and
+setting in it is readable and writable to its commands. The chip says
+**"sandboxed: workspace is your home — only credentials are protected"**, as
+do `codeoid ls` and every approval card; project roots don't apply (all of the
+home is the workspace).
+
+### Limits
+
+- On **Claude's own sandbox**, a root that holds what the session writes (the
+ default does: it holds the workspace) is shown entry by entry around it:
+ its CLI lays every readable bind after every writable one, so showing the
+ root whole would make the workspace read-only (checked: every command then
+ fails). A dir made in the root after the query started isn't shown until
+ the next query.
+- The secret files hidden in a root are those at each project's top when the
+ sandbox is built (above).
+- The default root shows **every repository beside yours**, read-only: their
+ source, their git history, and any credential in a file not named above. Set
+ `sandbox.projectRoots: []` (or `--no-project-roots`) where that is too
+ much, and `sandbox.hiddenPaths` for what must stay out.
+- An added directory that is **another worktree of the session's own
+ repository** is writable, and its own admin dir with it; the rest of the
+ repository's structure stays held.
+
## Docker access
Tests that start containers (testcontainers' MySQL or Postgres, `docker run`
@@ -950,9 +1114,12 @@ to the workspace. In autonomous mode it is information only (the audit log's
script from the workdir, an earlier run's agent may have changed it — keep the
gate to commands you trust, or run with `--worktree`.
- Credentials in **project files** (`.env` in the workspace) are the workspace's:
- add them to `sandbox.hiddenPaths` or keep them out of the workdir. A
+ add them to `sandbox.hiddenPaths` or keep them out of the workdir. Those at
+ the top of the other projects under a project root are hidden
+ ([above](#project-roots-other-repositories-read-only)). A
workspace that is your home itself shows the home (the hidden list still
- applies in it); one that holds it (`/home`) hides it.
+ applies in it; [the chip says so](#a-workspace-that-is-your-home)); one
+ that holds it (`/home`) hides it.
- **The home is read-only and mostly empty inside.** The usual caches are
pointed at the sandbox's cache dir (above); a tool that writes other state in
the home fails there (`pip install --user`, `go install` into `~/go/bin`,
@@ -1073,6 +1240,22 @@ works, and skip where it doesn't):
the gate and, denied, never runs. (Manually also: network on reaches a host;
the command sees 5 processes and no environment carrying the key; a command
can't create `.claude/settings.json` in the workspace.)
+- Working across repositories, real bubblewrap
+ (`src/tests/sandbox-project-roots-real.test.ts`): a session in
+ `/a` reads `/b` (`cat`, `grep -r`, `git log`, `git status`)
+ and can't write or commit there ("Read-only file system") while its own
+ workspace stays writable; `b/.env` and `.env.local` are hidden, its
+ `.env.example` readable, codeoid's config dir and a `sandbox.hiddenPaths`
+ dir inside the root and the home's `~/.ssh` hidden, `grep -r` over the root
+ finds none of them; added, `b` is written and committed in, its hooks,
+ config and `.claude/` refused, a worktree added in its own folder of
+ `b-worktrees/` and refused elsewhere there, the user's next git in `b`
+ running nothing planted, its `.env` readable. The real Claude CLI
+ (`sandbox-claude-real.test.ts`, "project roots and added dirs"): the root
+ around the workspace is readable, the workspace writable, `b` read-only
+ and its `.env` hidden; added, `b` writable; handing the CLI the root whole
+ instead makes the workspace read-only (every command fails), which is why
+ it is split.
- `src/tests/sandbox-codex.test.ts`: the app-server started inside real
bubblewrap can't read a planted credential and can read its own home; codex's
own sandbox (0.166) works nested inside codeoid's bubblewrap; with the real
From a338a1177bcc3329c6bb91ffe99ee68d553996a1 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Sun, 8 Nov 2026 11:48:51 +0800
Subject: [PATCH 05/23] fix: an unanswered request_directory returns before the
backend's MCP client gives up, its question left open (#448)
opencode's MCP client gives a tool call about a minute, shorter than an
owner may take: the call now returns "no answer yet" after 45 seconds there
(8 minutes elsewhere), the card stays open and a later yes still adds the
directory; a repeat request for the same directory joins the open card.
Co-Authored-By: Claude Opus 5.5
---
docs/sandbox.md | 9 ++-
src/daemon/providers/opencode/index.ts | 7 ++-
src/daemon/sandbox/request-directory.ts | 9 ++-
src/daemon/session.ts | 80 ++++++++++++++++---------
src/tests/sandbox-project-roots.test.ts | 42 +++++++++++++
5 files changed, 111 insertions(+), 36 deletions(-)
diff --git a/docs/sandbox.md b/docs/sandbox.md
index 43b2f8c1..76f47f39 100644
--- a/docs/sandbox.md
+++ b/docs/sandbox.md
@@ -868,8 +868,11 @@ stays hidden inside one; its own `.env` is readable.
project root, autonomous`), and anything else is refused at once with a
reason the agent reads ("… isn't under this session's project roots …; the
owner can add it (`/sandbox add `)"). An unattended run (a routine, a
- loop) is never parked on it: refused, said. An unanswered card is
- withdrawn after 8 minutes (the agent is told). Audited:
+ loop) is never parked on it: refused, said. The call returns "no answer
+ yet" if the owner hasn't answered within 8 minutes (45 seconds on opencode,
+ whose MCP client gives a call about a minute); the card stays open, a later
+ yes still adds the directory, and asking again for the same one waits on
+ that card rather than raising another. Audited:
`session.sandbox_dir_request`, `_added` (`by=agent …`), `_refused`.
- **Refused as an added dir**: everything refused as a root (above), and a
dir that isn't there, isn't a directory, isn't yours, is inside what the
@@ -889,7 +892,7 @@ stays hidden inside one; its own `.env` is readable.
| Claude | in-process MCP server (`mcp__codeoid_sandbox__request_directory`), pre-approved: the session decides it |
| Qwen Code | in-process MCP server, the same |
| pi | a tool in codeoid's bridge extension, as registry servers' are |
-| Codex, Gemini CLI, opencode | the daemon's `/mcp/sandbox` endpoint (loopback only), a token of the session's own (Codex reads it from `CODEOID_SANDBOX_TOKEN`, kept out of its commands' environment by `CODEOID_*_TOKEN`); its call timeout is raised past the card's 8 minutes on Codex and opencode |
+| Codex, Gemini CLI, opencode | the daemon's `/mcp/sandbox` endpoint (loopback only), a token of the session's own (Codex reads it from `CODEOID_SANDBOX_TOKEN`, kept out of its commands' environment by `CODEOID_*_TOKEN`); Codex's call timeout is raised past the 8-minute wait |
| OpenAI, Gemini, llama.cpp | none: these run no commands, so there is no sandbox to widen |
The gate never asks about the call itself (it is the ask); its card is the
diff --git a/src/daemon/providers/opencode/index.ts b/src/daemon/providers/opencode/index.ts
index f6a1d37a..5484339d 100644
--- a/src/daemon/providers/opencode/index.ts
+++ b/src/daemon/providers/opencode/index.ts
@@ -33,7 +33,7 @@
* authoritative for files.
*/
-import { REQUEST_DIRECTORY_WAIT_MS, SANDBOX_MCP_SERVER_NAME, type SandboxMcpMount } from "../../sandbox/request-directory.js";
+import { SANDBOX_MCP_SERVER_NAME, type SandboxMcpMount } from "../../sandbox/request-directory.js";
import { join } from "node:path";
import type { BackendHostFiles } from "../../sandbox/host-file-tools.js";
import type { SandboxRuntime } from "../../sandbox/session-sandbox.js";
@@ -1646,9 +1646,10 @@ export class OpencodeProvider implements SessionProvider {
}
const bb = this.#blackboardMcp?.();
if (bb) configs.push({ name: BLACKBOARD_MCP_SERVER_NAME, config: remote(bb.url, bb.token) });
- // The sandbox's ask for a directory to write (sandbox/request-directory.ts); its call waits on the owner.
+ // The sandbox's ask for a directory to write (sandbox/request-directory.ts): its call returns within opencode's
+ // MCP call timeout, the owner's question left open (REQUEST_DIRECTORY_SHORT_WAIT_MS).
const sbx = this.#sandboxMcp?.();
- if (sbx) configs.push({ name: SANDBOX_MCP_SERVER_NAME, config: { ...remote(sbx.url, sbx.token), timeout: REQUEST_DIRECTORY_WAIT_MS + 60_000 } });
+ if (sbx) configs.push({ name: SANDBOX_MCP_SERVER_NAME, config: remote(sbx.url, sbx.token) });
for (const spec of this.#mcpRegistry?.forNativeMount(this.id) ?? []) {
const t = spec.transport;
if (t.kind === "stdio") {
diff --git a/src/daemon/sandbox/request-directory.ts b/src/daemon/sandbox/request-directory.ts
index e7f32c29..5401ef6c 100644
--- a/src/daemon/sandbox/request-directory.ts
+++ b/src/daemon/sandbox/request-directory.ts
@@ -30,11 +30,14 @@ 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 request waits for its owner's answer before it is withdrawn
- * (said to the agent): under the Gemini CLI's MCP call timeout (10 min);
- * Codex's is raised past it (`tool_timeout_sec`).
+ * 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;
diff --git a/src/daemon/session.ts b/src/daemon/session.ts
index 4e151671..eb11a192 100644
--- a/src/daemon/session.ts
+++ b/src/daemon/session.ts
@@ -232,7 +232,7 @@ import { type GitControlSnapshot, gitControlChanges, gitControlSnapshot, gitCont
import { worktreeFolderNote } from "./sandbox/worktrees.js";
import { hiddenPathsFor, protectedWriteTargets, sandboxWorkspaceOf, userHiddenPaths } from "./sandbox/policy.js";
import { addedDirProblem, inAnyDir, normalizeSandboxDir } from "./sandbox/project-roots.js";
-import { REQUEST_DIRECTORY_WAIT_MS, type RequestDirectoryInput, type SandboxMcpMount } from "./sandbox/request-directory.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,
@@ -469,6 +469,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. */
@@ -1262,6 +1264,9 @@ export class Session {
#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>();
+ #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. */
@@ -1583,6 +1588,7 @@ export class Session {
this.collaborationRole = opts.collaborationRole;
this.#blackboardMcp = opts.blackboardMcp;
this.#sandboxMcp = opts.sandboxMcp;
+ this.#requestDirectoryWaitMs = opts.requestDirectoryWaitMs;
this.#onStatusChange = opts.onStatusChange;
this.#workerShape = opts.workerShape;
this.#collaborationCost = opts.collaborationCost;
@@ -3480,33 +3486,53 @@ export class Session {
`${dir} isn't under this session's project roots (${roots.length > 0 ? roots.join(", ") : "none"}); in autonomous mode nothing else is added. The owner can add it (\`/sandbox add ${dir}\`)`,
);
}
- let answeredBy: string | undefined;
- const answer = await this.requestUserInput(
- {
- method: "confirm",
- title: `The agent asks to write ${dir}`,
- message: `"${this.name}" asks for its sandboxed commands to write ${dir} (its git structure and project settings stay read-only).\n\nIts reason: ${input.reason}`,
- timeoutMs: REQUEST_DIRECTORY_WAIT_MS,
- },
- { ownerOnly: true, onAnswer: (sub) => {
- answeredBy = sub;
+ // 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} (its git structure and project settings stay read-only).\n\nIts reason: ${input.reason}`,
},
- },
- );
- 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.reason === "timeout"
- ? `the owner didn't answer within ${Math.round(REQUEST_DIRECTORY_WAIT_MS / 60_000)} minutes`
- : "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}.`;
- return grant(answeredBy ?? this.createdBy, "the owner approved");
+ {
+ 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}.`;
+ return grant(answeredBy ?? this.createdBy, "the owner approved");
+ });
+ 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.`;
}
/**
diff --git a/src/tests/sandbox-project-roots.test.ts b/src/tests/sandbox-project-roots.test.ts
index df6e207d..1b128690 100644
--- a/src/tests/sandbox-project-roots.test.ts
+++ b/src/tests/sandbox-project-roots.test.ts
@@ -290,6 +290,48 @@ describe("applied between turns, never mid-work", () => {
await sbx.close();
});
+ test("request_directory unanswered: the call returns before its backend's MCP client would give up, the question stays open, a repeat joins it, a later yes adds it", async () => {
+ const dir = join(base, "late");
+ const work = join(dir, "projects", "work");
+ const other = join(dir, "projects", "other");
+ mkdirSync(work, { recursive: true });
+ mkdirSync(other, { recursive: true });
+ mkdirSync(join(dir, "data"));
+ const st = new Store(join(dir, "data", "codeoid.db"));
+ const tr = new TranscriptStore(join(dir, "data", "transcripts"));
+ const id = randomUUID();
+ st.createSession({ id, name: "late", workdir: work, status: "idle", createdBy: AUTH.sub, createdAt: new Date().toISOString(), attachedClients: 0, accountId: AUTH.accountId, projectId: AUTH.projectId });
+ const session = new Session({
+ name: "late",
+ workdir: work,
+ auth: AUTH,
+ store: st,
+ transcriptStore: tr,
+ existingId: id,
+ _testProvider: new MockSessionProvider("opencode", [[DONE]]),
+ initialMode: { mode: "guarded" },
+ config: { workspaceIndex: { enabled: false }, compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, labeling: {}, telemetry: { osc8: "auto" }, autoRotate: { enabled: false }, session: {}, dbPath: join(dir, "data", "codeoid.db"), transcriptDir: join(dir, "data", "transcripts") } as unknown as CodeoidConfig,
+ sandboxRegistry: fakeRegistry([]),
+ requestDirectoryWaitMs: 30,
+ });
+ const dialogs: SessionUiRequestMsg[] = [];
+ session.attach({ id: "c", auth: AUTH, capabilities: [CAPABILITIES.UI_DIALOGS], send: (m: { type: string }) => void (m.type === "session.ui_request" && dialogs.push(m as SessionUiRequestMsg)) } as never);
+ try {
+ await session.ensureSandbox();
+ expect(await session.requestDirectory({ path: other, reason: "shared types" })).toContain("No answer yet");
+ expect(await session.requestDirectory({ path: other, reason: "again" })).toContain("No answer yet");
+ expect(dialogs).toHaveLength(1);
+ session.resolveUiRequestFromClient(dialogs[0]?.requestId as string, { confirmed: true }, AUTH);
+ for (let i = 0; i < 200 && !session.toInfo().sandbox?.addedDirs; i++) await Bun.sleep(5);
+ expect(session.toInfo().sandbox?.addedDirs).toEqual([other]);
+ expect(await session.requestDirectory({ path: other, reason: "check" })).toContain("is added already");
+ } finally {
+ await session.destroy(AUTH).catch(() => {});
+ await tr.flush().catch(() => {});
+ st.close();
+ }
+ });
+
test("a session with background work running keeps its sandbox; the dir lands at the next turn after it ended, and the backend starts again in it", async () => {
const dir = join(base, "s");
const work = join(dir, "projects", "work");
From 5d7c8f0140485e2f891cd2c07d80bf4c58d302f6 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Sun, 8 Nov 2026 11:58:30 +0800
Subject: [PATCH 06/23] fix: in autonomous mode the agent is given only a
directory inside a project root, never the root itself (#448)
A root holds every repository in it: making all of it writable is the
owner's call (/sandbox add), not an unasked grant.
Co-Authored-By: Claude Opus 5.5
---
docs/sandbox.md | 2 +-
src/daemon/session.ts | 3 ++-
src/tests/sandbox-dirs-manager.test.ts | 2 ++
3 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/docs/sandbox.md b/docs/sandbox.md
index 76f47f39..0a1449aa 100644
--- a/docs/sandbox.md
+++ b/docs/sandbox.md
@@ -864,7 +864,7 @@ stays hidden inside one; its own `.env` is readable.
confirm card naming the directory and the agent's reason ("The agent asks
to write "), answered by the owner's own token only; the agent reads
the answer. In **autonomous mode** a directory under one of the session's
- project roots is added at once, unasked (audited: `by=agent under a
+ project roots (inside it, never the root itself) is added at once, unasked (audited: `by=agent under a
project root, autonomous`), and anything else is refused at once with a
reason the agent reads ("… isn't under this session's project roots …; the
owner can add it (`/sandbox add `)"). An unattended run (a routine, a
diff --git a/src/daemon/session.ts b/src/daemon/session.ts
index eb11a192..709c2f8f 100644
--- a/src/daemon/session.ts
+++ b/src/daemon/session.ts
@@ -3481,7 +3481,8 @@ export class Session {
};
if (this.#mode === "autonomous") {
const roots = sbx.appliedProjectRoots();
- if (inAnyDir(dir, roots)) return grant(SYSTEM_IDENTITY.sub, "under a project root, autonomous");
+ // Strictly under one: a root itself (every repository in it) is the owner's to add.
+ if (roots.some((r) => inAnyDir(dir, [r]) && !inAnyDir(r, [dir]))) return grant(SYSTEM_IDENTITY.sub, "under a project root, autonomous");
return refuse(
`${dir} isn't under this session's project roots (${roots.length > 0 ? roots.join(", ") : "none"}); in autonomous mode nothing else is added. The owner can add it (\`/sandbox add ${dir}\`)`,
);
diff --git a/src/tests/sandbox-dirs-manager.test.ts b/src/tests/sandbox-dirs-manager.test.ts
index 5d4e7bcb..bcda31fa 100644
--- a/src/tests/sandbox-dirs-manager.test.ts
+++ b/src/tests/sandbox-dirs-manager.test.ts
@@ -259,6 +259,8 @@ describe("project roots and added directories through the SessionManager", () =>
expect(asked).toHaveLength(0);
expect(session?.toInfo().sandbox?.addedDirs).toEqual([sibling]);
expect(await session?.requestDirectory({ path: homedir(), reason: "all of it" })).toContain("Refused:");
+ // The root itself (every repository in it) is the owner's to add, not the agent's unasked.
+ expect(await session?.requestDirectory({ path: root, reason: "everything" })).toContain(`Refused: ${root} isn't under this session's project roots`);
});
it("request_directory on an unattended run (a routine, a loop) in guarded mode: refused, not parked", async () => {
From 109458885b056e4fa4a2deeb5152931b58ab5e7d Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 15:02:19 +0800
Subject: [PATCH 07/23] test: packages/core's sandbox test typechecks again
(#448)
`sandboxDirsText(on)` passed an info with none of the fields the function
picks, which tsc's weak-type check refuses (TS2559), so `bun run typecheck`
failed at the round's base. The same case, with an empty `projectRoots`.
Co-Authored-By: Claude Opus 5.5
---
packages/core/src/sandbox.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/core/src/sandbox.test.ts b/packages/core/src/sandbox.test.ts
index bd7e5201..a3bc12b4 100644
--- a/packages/core/src/sandbox.test.ts
+++ b/packages/core/src/sandbox.test.ts
@@ -89,7 +89,7 @@ describe("project roots and added directories (working across repositories)", ()
expect(sandboxDirsText(info)).toBe("reads /home/u/Workspace (read-only); also writes /home/u/Workspace/b; from the next turn: +/srv/c");
expect(sandboxBadge(info)?.title).toContain("It reads /home/u/Workspace (read-only); also writes /home/u/Workspace/b");
expect(sandboxSummary(info)).toBe("sandbox: bubblewrap, network on; reads /home/u/Workspace (read-only); also writes /home/u/Workspace/b; from the next turn: +/srv/c");
- expect(sandboxDirsText(on)).toBeNull();
+ expect(sandboxDirsText({ ...on, projectRoots: [] })).toBeNull();
});
it("a workspace that is the home: the chip says so", () => {
From 49a61097766b37fb73bdc21618daf7ca4578b1e2 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 15:02:19 +0800
Subject: [PATCH 08/23] fix: #448 round-12 audit: Claude's own sandbox never
shows a project root's link targets or hidden entries; bubblewrap hides what
is hidden in a dir shown again under /tmp
On Claude's own sandbox a root holding the workspace is shown entry by
entry (readAround), each entry by its physical path: a link in the root
(`~/Workspace/keys -> ~/.ssh`, `-> ~`) was shown by its target, and an
entry that is itself hidden (a `.env` at the root's top, codeoid's private
dir, a `sandbox.hiddenPaths` entry) was shown again, since the real CLI
lets an allowRead equal to a denyRead win. Checked on the real CLI: a
denyRead strictly inside an allowRead stays denied, an allowRead nested in
a denied dir re-shows, a link inside a shown dir dangles. So readAround
now leaves out every link and every entry that is (or is in) a hidden path
or socket in the root; what is hidden deeper stays in denyRead.
bubblewrap skipped hiding anything under the host's /tmp outside a
writable dir; a dir shown again there (a worktree location of a repository
in /tmp) kept what is hidden in it. Now only what nothing shows is skipped.
Co-Authored-By: Claude Opus 5.5
---
src/daemon/sandbox/bwrap.ts | 6 +-
src/daemon/sandbox/claude-native.ts | 27 ++++++++-
src/tests/sandbox-root-mounts.test.ts | 87 +++++++++++++++++++++++++++
3 files changed, 115 insertions(+), 5 deletions(-)
create mode 100644 src/tests/sandbox-root-mounts.test.ts
diff --git a/src/daemon/sandbox/bwrap.ts b/src/daemon/sandbox/bwrap.ts
index 07c1279e..85a08d38 100644
--- a/src/daemon/sandbox/bwrap.ts
+++ b/src/daemon/sandbox/bwrap.ts
@@ -91,14 +91,16 @@ export function bwrapArgs(spec: SandboxSpec, cwd: string, kind: (p: string) => P
const mounts: Mount[] = [];
const seen = new Set();
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 });
};
diff --git a/src/daemon/sandbox/claude-native.ts b/src/daemon/sandbox/claude-native.ts
index cb007bc5..f4c65f26 100644
--- a/src/daemon/sandbox/claude-native.ts
+++ b/src/daemon/sandbox/claude-native.ts
@@ -31,7 +31,7 @@
* 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";
@@ -174,7 +174,7 @@ export function claudeSandboxSettings(
tempDir,
...spec.writable,
...spec.reexposed.filter((r) => !roots.has(r) && !holdsFolder(r, spec.worktreeFolders ?? [])),
- ...readAround([...roots], [spec.workspace, tempDir, ...spec.writable]),
+ ...readAround([...roots], [spec.workspace, tempDir, ...spec.writable], undefined, [...spec.hidden, ...spec.containerSockets, ...(spec.serviceSockets ?? [])]),
...sockets,
]),
},
@@ -198,11 +198,29 @@ function holdsFolder(dir: string, folders: readonly string[]): boolean {
* 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): string[] {
+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)));
+ 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[] = [];
@@ -214,6 +232,7 @@ export function readAround(readable: readonly string[], writable: readonly strin
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);
@@ -221,6 +240,8 @@ export function readAround(readable: readonly string[], writable: readonly strin
};
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);
diff --git a/src/tests/sandbox-root-mounts.test.ts b/src/tests/sandbox-root-mounts.test.ts
new file mode 100644
index 00000000..a2f1fa70
--- /dev/null
+++ b/src/tests/sandbox-root-mounts.test.ts
@@ -0,0 +1,87 @@
+/**
+ * How a project root is mounted (#448 round 12, docs/sandbox.md "Working
+ * across repositories", Limits): on Claude's own sandbox, never a link in
+ * the root shown by where it leads, nor an entry that is itself hidden (the
+ * CLI lets a readable path equal to a hidden one win; one strictly inside
+ * a readable one stays hidden); on bubblewrap, a hidden path in a dir shown
+ * again under the host's /tmp is hidden there too.
+ */
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import type { CodeoidConfig } from "../config.js";
+import { bwrapArgs, type PathKind } from "../daemon/sandbox/bwrap.js";
+import { claudeSandboxSettings, readAround } from "../daemon/sandbox/claude-native.js";
+import { buildSandboxSpec, sandboxConfigOf } from "../daemon/sandbox/policy.js";
+import type { SandboxRuntime } from "../daemon/sandbox/session-sandbox.js";
+
+let base: string;
+beforeEach(() => {
+ const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache");
+ mkdirSync(parent, { recursive: true });
+ base = realpathSync(mkdtempSync(join(parent, "codeoid-root-mounts-")));
+});
+afterEach(() => rmSync(base, { recursive: true, force: true }));
+
+describe("Claude's own sandbox shows nothing hidden of a root", () => {
+ test("no link in a root is shown by its target; no hidden entry is shown again; a hidden path deeper stays in denyRead", () => {
+ const home = join(base, "home");
+ const root = join(home, "Workspace");
+ const a = join(root, "a");
+ const b = join(root, "b");
+ for (const d of [a, b, join(home, ".ssh")]) mkdirSync(d, { recursive: true });
+ writeFileSync(join(home, ".ssh", "id_ed25519"), "k");
+ symlinkSync(join(home, ".ssh"), join(root, "keys"));
+ symlinkSync(home, join(root, "home-link"));
+ writeFileSync(join(root, ".env"), "x");
+ writeFileSync(join(b, ".env"), "x");
+ const priv = join(root, "private");
+ mkdirSync(priv);
+ const tmp = join(base, "tmp");
+ mkdirSync(tmp);
+ const spec = buildSandboxSpec({
+ sessionId: "s",
+ workspace: a,
+ tempDir: tmp,
+ network: { mode: "off" },
+ config: { sandbox: { ...sandboxConfigOf(undefined), hiddenPaths: [priv] } } as unknown as CodeoidConfig,
+ hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" },
+ projectRoots: [root],
+ });
+ const rt = { spec, config: sandboxConfigOf(undefined) } as unknown as Pick;
+ const fs = claudeSandboxSettings(rt, { HOME: home }, undefined, "linux").filesystem;
+ const read = fs?.allowRead ?? [];
+ for (const shown of [join(home, ".ssh"), home, join(root, ".env"), priv, join(root, "keys"), join(root, "home-link")]) expect([shown, read.includes(shown)]).toEqual([shown, false]);
+ expect(read).toContain(b);
+ for (const denied of [join(root, ".env"), join(b, ".env"), priv]) expect(fs?.denyRead).toContain(denied);
+ expect(readAround([root], [a], 4000, [join(root, ".env")])).not.toContain(join(root, ".env"));
+ expect(readAround([root], [a], 4000, [join(root, ".env")])).not.toContain(join(home, ".ssh"));
+ // A root holding nothing writable is shown whole (a link in it dangles in the CLI's sandbox, checked for real).
+ expect(readAround([b], [a])).toEqual([b]);
+ });
+});
+
+describe("bubblewrap: a hidden path in a dir shown again under the host's /tmp", () => {
+ test("is hidden there too (the private /tmp covers only what isn't shown)", () => {
+ const kinds: Record = { "/tmp/r/a": "dir", "/tmp/r/a-worktrees": "dir", "/tmp/r/a-worktrees/o/.env": "other", "/tmp/elsewhere/.env": "other", "/var/tmp/t": "dir" };
+ const args = bwrapArgs(
+ {
+ sessionId: "s",
+ workspace: "/tmp/r/a",
+ tempDir: "/var/tmp/t",
+ writable: [],
+ hidden: ["/tmp/r/a-worktrees/o/.env", "/tmp/elsewhere/.env"],
+ reexposed: ["/tmp/r/a-worktrees"],
+ containerSockets: [],
+ network: { mode: "off" },
+ unsetEnv: [],
+ },
+ "",
+ (p) => kinds[p] ?? null,
+ ).join(" ");
+ expect(args).toContain("--ro-bind /tmp/r/a-worktrees /tmp/r/a-worktrees");
+ expect(args).toContain("--ro-bind /dev/null /tmp/r/a-worktrees/o/.env");
+ expect(args).not.toContain("/tmp/elsewhere/.env");
+ });
+});
From beb728fae777119f295b4f256669431550363207 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 15:02:33 +0800
Subject: [PATCH 09/23] fix: #448 round-12 audit: an added directory is what it
was when granted, never a git or settings dir, every repository in it held;
credentials hidden anywhere in a project root; no root in a temp dir or
outside a repository
Added directories (request_directory, /sandbox add, create, loop start):
- kept by their real path, taken when granted (canonicalAddedDirs,
addedDirReal), and bound exactly there; the owner's card names it, and
the path asked for when a link led there;
- dropped at the next build when that path no longer is a directory
reached through no link (stillRealDir): said on the session
(sandbox.dir_dropped), audited (session.sandbox_dir_dropped), out of the
setting, never followed;
- refused when it is, or is inside, a project settings dir, a .git or .hg
dir, a git dir by its shape, or the repository's core.hooksPath dir
(controlDirProblem), and judged where it really is;
- every repository, bare git dir and settings dir inside one is held as the
workspace's are (nestedControl, in the spec, protectedWriteTargets and the
end-of-turn git look), found by a bounded look: past it, refused, and left
out of the spec (fail closed);
- writable already when in the session's worktree folders or temp dir;
one holding an added dir replaces it; loop start validates them too.
Autonomous grants (an unattended run's too, as docs now say): only a
repository, or a dir in one, strictly under a project root, by a path with
no symbolic link in it (autonomousGrantProblem).
Project roots: credential files by name anywhere in a root and in the
worktree locations shown (codeoid's worktrees of sibling repositories
included), credential dirs whole, a .git/config carrying a credential
(said: git there can't read its config); a root past the look's bound isn't
used, said. Never the host's temp dir or anything in it
(projectRootProblem, config load too); a workspace in no repository has no
default root, said.
Co-Authored-By: Claude Opus 5.5
---
src/config.ts | 9 +-
src/daemon/sandbox/dir-rules.ts | 55 ++-
src/daemon/sandbox/git-watch.ts | 2 +-
src/daemon/sandbox/policy.ts | 90 ++++-
src/daemon/sandbox/project-roots.ts | 405 ++++++++++++++++---
src/daemon/sandbox/request-directory.ts | 14 +-
src/daemon/sandbox/session-sandbox.ts | 108 ++++-
src/daemon/session-manager.ts | 35 +-
src/daemon/session.ts | 99 +++--
src/tests/sandbox-claude-real.test.ts | 40 +-
src/tests/sandbox-dirs-manager.test.ts | 132 +++++-
src/tests/sandbox-project-roots-real.test.ts | 181 ++++++++-
src/tests/sandbox-project-roots.test.ts | 288 ++++++++++++-
src/tests/sandbox-session.test.ts | 15 +-
14 files changed, 1307 insertions(+), 166 deletions(-)
diff --git a/src/config.ts b/src/config.ts
index a63370ce..6cf02c6f 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -18,7 +18,7 @@ import type { ModelPrice } from "./daemon/pricing/index.js";
import type { SandboxConfig } from "./daemon/sandbox/policy.js";
import { DEFAULT_HOME_READABLE } from "./daemon/sandbox/home-readable.js";
import { validAllowedHost } from "./daemon/sandbox/hosts.js";
-import { PROJECT_ROOT_PARENT, sandboxDirProblem } from "./daemon/sandbox/dir-rules.js";
+import { PROJECT_ROOT_PARENT, projectRootProblem } from "./daemon/sandbox/dir-rules.js";
import { worktreeRootProblem } from "./daemon/sandbox/worktrees.js";
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync, rmSync } from "node:fs";
import { randomBytes } from "node:crypto";
@@ -299,8 +299,9 @@ const SandboxSchema = z
* Where sandboxed commands read other repositories, read-only (docs/sandbox.md,
* "Working across repositories"): `@parent` (the parent of the session's
* repository, the default), or absolute paths (`~` expands). `[]`: none.
- * Refused as worktree roots are (never `/`, the home, a hidden dir of it, …);
- * a default that would be the home falls back to none, said on the session.
+ * Refused as worktree roots are (never `/`, the home, a hidden dir of it, …),
+ * and never the host's temp dir or anything in it; a default that would be
+ * the home or in a temp dir falls back to none, said on the session.
*/
projectRoots: z
.array(
@@ -310,7 +311,7 @@ const SandboxSchema = z
.max(4096)
.superRefine((root, ctx) => {
if (root === PROJECT_ROOT_PARENT) return;
- const problem = sandboxDirProblem(root, { configDir: getConfigDir() });
+ const problem = projectRootProblem(root, { configDir: getConfigDir() });
if (problem) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `not a project root: ${problem}` });
}),
)
diff --git a/src/daemon/sandbox/dir-rules.ts b/src/daemon/sandbox/dir-rules.ts
index 8bff32dd..26c8573e 100644
--- a/src/daemon/sandbox/dir-rules.ts
+++ b/src/daemon/sandbox/dir-rules.ts
@@ -3,7 +3,8 @@
* apart so config.ts can check them at load without the sandbox's policy.
*/
-import { homedir } from "node:os";
+import { realpathSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { worktreeRootProblem } from "./worktrees.js";
@@ -16,11 +17,37 @@ function expandHome(p: string, home: string): string {
return p;
}
+function within(child: string, parent: string): boolean {
+ return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`);
+}
+
+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))]))];
}
/**
@@ -29,14 +56,35 @@ export interface DirContext {
* 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).
+ * 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`;
- return worktreeRootProblem(p, { home, ...(ctx.configDir ? { configDir: ctx.configDir } : {}), ...(ctx.platform ? { platform: ctx.platform } : {}) });
+ 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. */
@@ -44,4 +92,3 @@ export function normalizeSandboxDir(dir: string, home: string = process.env.HOME
const p = expandHome(dir.trim(), resolve(home));
return isAbsolute(p) ? resolve(p) : null;
}
-
diff --git a/src/daemon/sandbox/git-watch.ts b/src/daemon/sandbox/git-watch.ts
index d4225641..65eda1db 100644
--- a/src/daemon/sandbox/git-watch.ts
+++ b/src/daemon/sandbox/git-watch.ts
@@ -99,7 +99,7 @@ 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;
for (const file of configs) {
let text = "";
diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts
index 90c026e6..860bba12 100644
--- a/src/daemon/sandbox/policy.ts
+++ b/src/daemon/sandbox/policy.ts
@@ -34,7 +34,7 @@ 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 { projectRootSecrets } from "./project-roots.js";
+import { controlDirProblem, nestedControl, type NestedControl, projectRootSecrets, stillRealDir } from "./project-roots.js";
import { isOwnWorktree, worktreeAdminsOf, worktreeFoldersOf } from "./worktrees.js";
import type { NetworkMode, NetworkPolicy, SandboxSpec } from "./types.js";
@@ -454,18 +454,22 @@ export interface SpecInputs {
/**
* 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 secret files at each project's top
- * there (`projectRootSecrets`).
+ * 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 as a workspace is, its git
- * structure and project settings held, its repository's worktree folder
- * writable. `ownWorktreeEntries`: as {@link ownWorktreeEntries}, for its
- * repository.
+ * (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[] }[];
+ /** How many directories a root's or an added dir's look goes through (project-roots.ts; default theirs): tests. */
+ scanLimit?: number;
}
/** The home a spec hides: absolute, and not `/` (nothing is left to show then). */
@@ -802,8 +806,15 @@ export function gitWorkspacePaths(workspace: string, home: string = process.env.
export function protectedWriteTargets(workdir: string, home: string = process.env.HOME ?? homedir(), addedDirs: readonly string[] = []): HeldPath[] {
const ws = sandboxWorkspaceOf(workdir, home);
const out: HeldPath[] = [];
- // The workspace, and each dir the session was given to write (SpecInputs.addedDirs), as the spec holds them.
- for (const [top, dirs] of [[ws, projectDirsOf(workdir, ws)] as const, ...addedDirs.map((d) => [resolve(d), [resolve(d)]] as const)]) {
+ // 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" });
+ }
+ 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" });
@@ -852,7 +863,7 @@ function validSubmodule(gitDir: string, dotgit: string): boolean {
}
/** A git dir as git checks one: `objects/` and `HEAD`. */
-function gitDirShape(dir: string): boolean {
+export function gitDirShape(dir: string): boolean {
try {
return lstatSync(join(dir, "objects")).isDirectory() && lstatSync(join(dir, "HEAD")).isFile();
} catch {
@@ -1080,8 +1091,15 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
const workspace = resolve(inputs.workspace);
const sandboxCfg = sandboxConfigOf(inputs.config);
const configDir = inputs.hidden?.configDir ?? getConfigDir();
- // Each added dir as a workspace of its own (never one inside the workspace: it is writable already).
- 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);
+ // 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).
+ 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) => 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,
@@ -1099,12 +1117,25 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
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)].flatMap((dir) => projectConfigPaths(dir, home));
+ : [
+ ...[...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);
@@ -1118,8 +1149,15 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
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 roots = (inputs.projectRoots ?? []).map((r) => resolve(r)).filter((r, i, all) => all.indexOf(r) === i && !userHidden.some((u) => within(r, u)) && !inHidden(r));
const addedDirs = added.map((a) => a.dir).filter((d) => !inHidden(d) && !userHidden.some((u) => within(d, u)));
+ const writableDirs = [workspace, ...addedDirs];
+ // The credential files anywhere in a root (not the session's own: its workspace, its added dirs); a root too big
+ // to look through whole isn't shown at all (fail closed: resolveSandbox says so).
+ const rootCandidates = (inputs.projectRoots ?? []).map((r) => resolve(r)).filter((r, i, all) => all.indexOf(r) === i && !userHidden.some((u) => within(r, u)) && !inHidden(r));
+ const rootSecrets = projectRootSecrets(rootCandidates, writableDirs, inputs.scanLimit);
+ const roots = rootCandidates.filter((r) => !rootSecrets.over.includes(r));
+ // 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 = [
@@ -1128,17 +1166,17 @@ 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 writableDirs = [workspace, ...addedDirs];
const hiddenAll = [
...(homeHidden ? [homeHidden] : []),
...baseHidden,
...homeCredentialFilesIn(reexposed),
- // The secret files at the top of each project under a root (not the session's own: its workspace, its added dirs).
- ...projectRootSecrets(roots, writableDirs),
+ // 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
@@ -1180,6 +1218,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();
diff --git a/src/daemon/sandbox/project-roots.ts b/src/daemon/sandbox/project-roots.ts
index 8e2d580d..1faeb9d2 100644
--- a/src/daemon/sandbox/project-roots.ts
+++ b/src/daemon/sandbox/project-roots.ts
@@ -7,28 +7,35 @@
* 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. What the
- * sandbox hides anywhere stays hidden in them (credential stores,
- * codeoid's own dirs, the agents' logins, container sockets), and so do
- * the secret files at a project's top ({@link projectRootSecrets});
+ * 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). Added by the owner, or asked for by the agent
- * (`request_directory`, request-directory.ts).
+ * 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}); an added one, nothing the sandbox hides
- * ({@link addedDirProblem}).
+ * `/` ({@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 { lstatSync, readdirSync, realpathSync } from "node:fs";
+import { type Dirent, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
-import { dirname, isAbsolute, join, resolve } from "node:path";
-import { gitLayoutOf } from "./policy.js";
-import { type DirContext, normalizeSandboxDir, PROJECT_ROOT_PARENT, sandboxDirProblem } from "./dir-rules.js";
+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 } from "./worktrees.js";
-export { type DirContext, normalizeSandboxDir, PROJECT_ROOT_PARENT, sandboxDirProblem };
+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}/`);
@@ -42,34 +49,194 @@ function realOr(p: string): string {
}
}
+/** 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). */
+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. */
+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.
+ */
+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;
+ }
+ 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 it must be a directory of the daemon's
- * user (not a link: where it leads is what is added), outside everything
- * the sandbox hides, and hold no dir of the daemon's `PATH` (a program
- * written there runs outside any sandbox at the user's next command).
+ * {@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}`}`;
+ }
let st: ReturnType;
try {
- st = lstatSync(realOr(abs));
+ 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)`;
- const real = realOr(abs);
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.
@@ -79,19 +246,49 @@ export function addedDirProblem(dir: string, ctx: AddedDirContext = {}): string
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;
}
/**
* 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), or the parent of
- * the workspace when it isn't in a repository.
+ * 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 {
+export function defaultProjectRoot(workspace: string, home: string = process.env.HOME ?? homedir()): string | null {
const ws = resolve(workspace);
const layout = gitLayoutOf(ws, home);
- const top = layout ? (mainCheckoutOf(layout) ?? dirname(layout.dotgit)) : ws;
+ if (!layout) return null;
+ const top = mainCheckoutOf(layout) ?? dirname(layout.dotgit);
return dirname(resolve(top));
}
@@ -105,10 +302,10 @@ export interface ResolvedRoots {
/**
* `entries` (the session's own, else `sandbox.projectRoots`) for a session
* working in `workspace`: {@link PROJECT_ROOT_PARENT} as
- * {@link defaultProjectRoot}, a default that would be the home (a
- * repository directly in it) or another refused place falling back to no
- * root, said; anything else refused by {@link sandboxDirProblem} or not
- * there, said and not used.
+ * {@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.
*/
export function resolveProjectRoots(entries: readonly string[], workspace: string, ctx: DirContext = {}): ResolvedRoots {
const home = resolve(ctx.home ?? process.env.HOME ?? homedir());
@@ -117,7 +314,11 @@ export function resolveProjectRoots(entries: readonly string[], workspace: strin
for (const entry of entries) {
const isDefault = entry === PROJECT_ROOT_PARENT;
const at = isDefault ? defaultProjectRoot(workspace, home) : normalizeSandboxDir(entry, home);
- const problem = at === null ? `${entry} isn't an absolute path` : sandboxDirProblem(at, { ...ctx, 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
@@ -144,47 +345,135 @@ export function resolveProjectRoots(entries: readonly string[], workspace: strin
return out;
}
-/** Secret files at a project's top: dotenv files (not their examples), direnv's, a project's registry tokens. */
-const PROJECT_SECRET_FILE = /^(?:\.env(?:\.(?!(?:example|sample|template|dist|defaults?|schema)$)[^/]+)?|\.envrc|\.npmrc|\.pypirc|\.netrc|\.git-credentials)$/i;
+/**
+ * Credential files, by name: dotenv files (not their examples), direnv's,
+ * registry and netrc tokens, private keys (not `.pub`), 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 SECRET_FILE = new RegExp(
+ `^(?:${[
+ "\\.env(?:\\.(?!(?:example|sample|template)$)[^/]+)?",
+ "\\.envrc",
+ "\\.npmrc",
+ "\\.pypirc",
+ "[._]netrc",
+ "\\.git-credentials",
+ "\\.vault-token",
+ "\\.pgpass",
+ "[^/]*\\.(?:pem|key|p12|pfx|jks|keystore)",
+ "id_(?:rsa|dsa|ecdsa|ed25519)[^/]*(? = 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"];
+
+/** 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 an http extraheader. */
+export function gitConfigHasCredential(file: string): boolean {
+ let text = "";
+ try {
+ const st = lstatSync(file);
+ if (!st.isFile() || st.size > 1 << 20) return st.isFile();
+ text = readFileSync(file, "utf8");
+ } catch {
+ return false;
+ }
+ return /\b(?:https?|ftps?):\/\/[^\s/"@]+@/i.test(text) || /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:"@]+:[^\s/"@]*@/i.test(text) || /^\s*extraheader\s*=/im.test(text);
+}
+
+/** 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 secret files at the top of each project under the `roots` (the root
- * itself and each dir directly in it: `~/Workspace/b/.env`), hidden like
- * the credential stores are, but in `writable` (the session's own
- * workspace and added dirs: its own project's `.env` is its to use).
- * Found when the spec is built: one made later isn't hidden. Bounded.
+ * 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, a repository's
+ * `.git/config` carrying a credential ({@link gitConfigHasCredential}); but
+ * in `writable` (the session's own workspace and added dirs: 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 = 2000): string[] {
- const out: string[] = [];
+export function projectRootSecrets(roots: readonly string[], writable: readonly string[], limit = PROJECT_ROOT_SCAN_LIMIT): RootSecrets {
+ const out: RootSecrets = { hidden: [], gitConfigs: [], over: [] };
const mine = writable.flatMap((w) => [resolve(w), realOr(w)]);
- const owned = (p: string) => mine.some((w) => within(p, w) || within(realOr(p), w));
- let budget = limit;
- const scan = (dir: string): void => {
- if (owned(dir)) return;
- let names: string[] = [];
- try {
- names = readdirSync(dir);
- } catch {
- return;
- }
- for (const n of names) if (PROJECT_SECRET_FILE.test(n)) out.push(join(dir, n));
- };
+ const owned = (p: string) => mine.some((w) => within(p, w));
+ const seenRoots = new Set();
for (const root of roots) {
- scan(root);
- let entries: import("node:fs").Dirent[] = [];
- try {
- entries = readdirSync(root, { withFileTypes: true });
- } catch {
- continue;
+ const top = resolve(root);
+ if (seenRoots.has(top)) continue;
+ seenRoots.add(top);
+ const found: string[] = [];
+ const configs: string[] = [];
+ const queue = [top];
+ let seen = 0;
+ let over = false;
+ while (queue.length > 0 && !over) {
+ const d = queue.shift() as string;
+ if (owned(d) || owned(realOr(d))) continue;
+ 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") {
+ const config = join(at, "config");
+ if (gitConfigHasCredential(config)) {
+ found.push(config);
+ configs.push(config);
+ }
+ } 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))) found.push(at);
+ }
}
- for (const e of entries) {
- if (--budget < 0) return out;
- if (e.isDirectory()) scan(join(root, e.name));
+ if (over) out.over.push(root);
+ else {
+ out.hidden.push(...found.filter((p) => !owned(p)));
+ out.gitConfigs.push(...configs.filter((p) => !owned(p)));
}
}
return out;
}
+/** The repository a hidden `.git/config` belongs to, for what is said about it. */
+export function repositoryOfConfig(config: string): string {
+ return basename(dirname(config)) === ".git" ? dirname(dirname(config)) : 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);
diff --git a/src/daemon/sandbox/request-directory.ts b/src/daemon/sandbox/request-directory.ts
index 5401ef6c..fe642f7b 100644
--- a/src/daemon/sandbox/request-directory.ts
+++ b/src/daemon/sandbox/request-directory.ts
@@ -3,10 +3,12 @@
* 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 one under the
- * session's project roots 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 takes effect at the session's next turn.
+ * 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
@@ -50,8 +52,8 @@ export type RequestDirectoryHandler = (sessionId: string, input: RequestDirector
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 directory under the project roots 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, /, or a dir of credentials or settings.",
+ "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;
diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts
index 6730833c..b18bcc3a 100644
--- a/src/daemon/sandbox/session-sandbox.ts
+++ b/src/daemon/sandbox/session-sandbox.ts
@@ -23,7 +23,20 @@ import { type ConfinementPlan, confinementFor, runsNoCommands } from "./confinem
import { GIT_BIN } from "../git-env.js";
import { CWD_FROM_CALLER } from "./docker.js";
import { buildSandboxSpec, dockerSocketPaths, gitLayoutOf, gitLayoutProblem, hiddenPathsFor, networkPolicyOf, type SandboxConfig, sandboxConfigOf, sandboxWorkspaceOf, unconnectableDockerSockets, validAllowedHost } from "./policy.js";
-import { addedDirProblem, normalizeSandboxDir, PROJECT_ROOT_PARENT, resolveProjectRoots } from "./project-roots.js";
+import {
+ addedDirProblem,
+ addedDirReal,
+ linkInPath,
+ nestedControl,
+ normalizeSandboxDir,
+ PROJECT_ROOT_PARENT,
+ PROJECT_ROOT_SCAN_LIMIT,
+ projectRootSecrets,
+ repositoryOfConfig,
+ resolveProjectRoots,
+ SCAN_SKIPPED_DIRS,
+ stillRealDir,
+} from "./project-roots.js";
import { ENTRY_GRACE_MS, WorktreeEntryGuard, type WorktreeEntryFinding } from "./worktree-entries.js";
import { existingFolderName, mainCheckoutOf, sessionFolderName, worktreeFoldersOf, worktreeLocationDirs, worktreeRootNotes } from "./worktrees.js";
import type { SandboxRegistry } from "./registry.js";
@@ -162,6 +175,20 @@ export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxS
return { choice, network, dockerSocket: setting?.dockerSocket ?? config.docker.socket ?? false, projectRoots: [...new Set(roots)], addedDirs: [...new Set(added)] };
}
+/**
+ * `setting` with each added directory `before` didn't have kept by where it
+ * really is now, when it is granted (project-roots.ts `addedDirReal`): what
+ * is bound is that directory, never where a link there leads later. One
+ * already kept stays as it was (a link put there since is dropped at the
+ * next build, never followed).
+ */
+export function canonicalAddedDirs(setting: SessionSandboxSetting, before?: SessionSandboxSetting): SessionSandboxSetting {
+ if (!setting.addedDirs) return setting;
+ const had = new Set(before?.addedDirs ?? []);
+ const dirs = setting.addedDirs.map((d) => (had.has(d) ? d : (addedDirReal(d) ?? d)));
+ return { ...setting, addedDirs: [...new Set(dirs)] };
+}
+
/** 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"] as const;
@@ -248,14 +275,18 @@ export const HOME_WORKSPACE_NOTE =
/**
* 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).
+ * 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), or can't be one now, is `dropped`, said. A root 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.
*/
export function sandboxDirsOf(
top: string,
rootEntries: readonly string[],
addedDirs: readonly string[],
config?: CodeoidConfig,
-): { roots: string[]; added: string[]; notes: string[]; homeWorkspace: boolean } {
+): { roots: string[]; added: string[]; notes: string[]; homeWorkspace: boolean; dropped: Array<{ dir: string; why: string }> } {
const home = resolve(process.env.HOME ?? homedir());
const configDir = getConfigDir();
const homeWorkspace = resolve(top) === home;
@@ -263,13 +294,37 @@ export function sandboxDirsOf(
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 problem = addedDirProblem(d, { home, configDir, hidden });
- if (problem) notes.push(`added directory ${d}: ${problem}: not writable`);
- else added.push(resolve(d));
+ const problem = 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 });
+ } else added.push(resolve(d));
}
- return { roots: homeWorkspace ? [] : r.roots, added, notes, homeWorkspace };
+ let roots = homeWorkspace ? [] : r.roots;
+ if (roots.length > 0) {
+ const secrets = projectRootSecrets(roots, [top, ...added]);
+ for (const over of secrets.over) {
+ notes.push(
+ `project root ${over}: more than ${PROJECT_ROOT_SCAN_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`);
+ }
+ return { roots, added, notes, homeWorkspace, dropped };
+}
+
+/** 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). */
@@ -282,7 +337,7 @@ export async function resolveSandbox(opts: {
workspace?: string;
/** The daemon's whole config: what an added directory may not be in (its data dirs: policy.ts `hiddenPathsFor`). */
codeoidConfig?: CodeoidConfig;
-}): Promise<{ info: SessionSandboxInfo; provider: SandboxProvider | null; plan: ConfinementPlan | null; network: NetworkPolicy }> {
+}): Promise<{ info: SessionSandboxInfo; provider: SandboxProvider | null; plan: ConfinementPlan | null; network: NetworkPolicy; droppedDirs?: Array<{ dir: string; why: string }> }> {
const { choice, network, dockerSocket, projectRoots, addedDirs } = effectiveSetting(opts.config, opts.setting);
const base = {
choice,
@@ -331,7 +386,7 @@ export async function resolveSandbox(opts: {
const layout = top !== null && opts.config.worktreeRoots.length > 0 ? gitLayoutOf(top) : null;
const rootNotes = layout ? worktreeRootNotes(layout, { roots: opts.config.worktreeRoots, configDir: getConfigDir() }) : [];
// Working across repositories: the project roots it reads, the directories it writes besides its workspace.
- const dirs = top !== null ? sandboxDirsOf(top, projectRoots, addedDirs, opts.codeoidConfig) : { roots: [], added: [], notes: [], homeWorkspace: false };
+ const dirs = top !== null ? sandboxDirsOf(top, projectRoots, addedDirs, opts.codeoidConfig) : { roots: [], added: [], notes: [], homeWorkspace: false, dropped: [] };
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 {
@@ -354,6 +409,7 @@ export async function resolveSandbox(opts: {
provider,
plan: c.plan,
network: applied,
+ ...(dirs.dropped.length > 0 ? { droppedDirs: dirs.dropped } : {}),
};
}
@@ -391,6 +447,7 @@ export class SessionSandbox {
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[] } = { 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;
/** The spec last built for the session: what its commands can write, for the entries' look. */
@@ -476,7 +533,7 @@ export class SessionSandbox {
* cut off by a change of setting) or the session closes.
*/
async set(setting: SessionSandboxSetting | undefined): Promise {
- const next = setting && Object.keys(setting).length > 0 ? { ...setting } : undefined;
+ const next = setting && Object.keys(setting).length > 0 ? canonicalAddedDirs({ ...setting }, this.#setting) : undefined;
// Only which directories are read or written: the runtime stands until Session applies it between turns.
if (onlyDirsChanged(this.#setting, next) && !this.#stale) this.#dirsStale = true;
else this.#stale = true;
@@ -504,6 +561,36 @@ export class SessionSandbox {
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, ...rest } = this.#setting;
+ this.#setting = kept.length > 0 ? { ...rest, addedDirs: kept } : Object.keys(rest).length > 0 ? rest : 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
@@ -532,6 +619,7 @@ export class SessionSandbox {
this.#dirsStale = false;
const dirs = { roots: [...(r.info.projectRoots ?? [])], added: [...(r.info.addedDirs ?? [])] };
this.#dirs = dirs;
+ this.#droppedDirs = r.droppedDirs ?? [];
if (r.info.state !== "on" || !r.provider || !r.plan) {
this.#runtime = null;
return r.info;
diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts
index 148983d2..0bc93d04 100644
--- a/src/daemon/session-manager.ts
+++ b/src/daemon/session-manager.ts
@@ -8,7 +8,7 @@
* - Graceful drain on shutdown
*/
-import { addedDirProblem, PROJECT_ROOT_PARENT, sandboxDirProblem } from "./sandbox/project-roots.js";
+import { addedDirProblem, 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";
@@ -139,7 +139,8 @@ 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 { canonicalAddedDirs, effectiveSetting, openCheckSandbox, resolveSandbox, sandboxRefusalOf, sandboxSettingProblem, widens } from "./sandbox/session-sandbox.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";
@@ -4380,6 +4381,8 @@ mcpHub: this.#mcpHub,
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
@@ -4405,7 +4408,7 @@ mcpHub: this.#mcpHub,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
sandboxMcp: this.#sandboxMcpFor,
- ...(msg.sandbox && Object.keys(msg.sandbox).length > 0 ? { sandbox: msg.sandbox } : {}),
+ ...(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,
@@ -6479,12 +6482,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(
{
@@ -6493,7 +6498,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 } : {}),
},
@@ -9092,18 +9097,20 @@ mcpHub: this.#mcpHub,
/**
* Why a setting's directories can't be used (docs/sandbox.md, "Working
* across repositories"), or null: a project root refused
- * (project-roots.ts `sandboxDirProblem`), an added dir that can't be one
- * (`addedDirProblem`: the home, `/`, a hidden or settings dir, one the
- * sandbox hides, one holding a PATH entry, one that isn't there).
+ * (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 = sandboxDirProblem(r, { configDir });
+ const problem = projectRootProblem(r, { configDir });
if (problem) return `not a project root: ${problem}`;
}
- const hidden = (setting.addedDirs ?? []).length > 0 ? hiddenPathsFor(this.#config) : [];
+ 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}`;
@@ -9576,3 +9583,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 709c2f8f..1282d92b 100644
--- a/src/daemon/session.ts
+++ b/src/daemon/session.ts
@@ -231,7 +231,8 @@ import { type WorktreeEntryFinding, worktreeEntryLine, worktreeEntryWarning } fr
import { type GitControlSnapshot, gitControlChanges, gitControlSnapshot, gitControlWarning, refTips, settingsCommits, settingsCommitWarning } from "./sandbox/git-watch.js";
import { worktreeFolderNote } from "./sandbox/worktrees.js";
import { hiddenPathsFor, protectedWriteTargets, sandboxWorkspaceOf, userHiddenPaths } from "./sandbox/policy.js";
-import { addedDirProblem, inAnyDir, normalizeSandboxDir } from "./sandbox/project-roots.js";
+import { addedDirProblem, addedDirReal, autonomousGrantProblem, inAnyDir, nestedControl, normalizeSandboxDir } 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,
@@ -3166,6 +3167,7 @@ export class Session {
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);
+ 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;
@@ -3335,21 +3337,65 @@ export class Session {
/**
* Why `raw` can't be added to what the session's commands write, or the
- * directory it names (absolute; a relative one is the workdir's) with
- * `already` when it is writable now (the workspace, an added dir).
+ * 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; already?: string } | { error: string } {
+ #sandboxDirCheck(raw: string): { dir: string; written: string; already?: string } | { error: string } {
const sbx = this.#sandbox;
if (!sbx) return { error: "this daemon has no OS sandbox" };
const home = process.env.HOME ?? homedir();
- const dir = normalizeSandboxDir(raw, home) ?? resolvePath(this.workdir, raw.trim());
+ const written = normalizeSandboxDir(raw, home) ?? resolvePath(this.workdir, raw.trim());
+ const dir = addedDirReal(written, home) ?? written;
const workspace = sandboxWorkspaceOf(this.workdir);
- if (inAnyDir(dir, [workspace])) return { dir, already: `${dir} is in the session's workspace (${workspace}), which its commands write already` };
+ 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 added = sbx.effective().addedDirs;
const holder = added.find((d) => inAnyDir(dir, [d]));
- if (holder) return { dir, already: holder === dir ? `${dir} is added already` : `${dir} is in ${holder}, which is added already` };
- const problem = addedDirProblem(dir, { home, configDir: getConfigDir(), hidden: hiddenPathsFor(this.#config) });
- return problem ? { error: problem } : { dir };
+ if (holder) return { dir, written, already: holder === dir ? `${dir} is added already` : `${dir} is in ${holder}, which is added already` };
+ 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 added, in words, for the owner's confirm: true for what is there. */
+ #addedDirProtection(dir: string): string {
+ const nested = nestedControl(dir);
+ const repos = nested.repos.filter((r) => r !== dir).length;
+ return `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` : ""}`;
+ }
+
+ /** 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. */
@@ -3404,7 +3450,9 @@ export class Session {
if (!sbx) return { ok: false, error: "this daemon has no OS sandbox" };
const current = sbx.effective().addedDirs;
if (action === "remove") {
- const dir = normalizeSandboxDir(raw) ?? resolvePath(this.workdir, raw.trim());
+ 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})`);
@@ -3414,13 +3462,14 @@ export class Session {
if ("error" in check) return { ok: false, error: check.error };
if (check.already) return { ok: true, message: check.already };
const dir = check.dir;
- this.#store.audit(sender.sub, "session.sandbox_dir_ask", this.id, 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} (its git structure and project settings stay read-only, as in the workspace). It takes effect ${this.#dirsWhen()}.`,
+ 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;
@@ -3434,12 +3483,12 @@ export class Session {
return;
}
const again = this.#sandboxDirCheck(dir);
- if ("error" in again || again.already) {
- opts.onDecided?.(false, "error" in again ? again.error : again.already);
+ if ("error" in again || again.already || 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([...sbx.effective().addedDirs, dir], { who: answeredBy ?? sender.sub, action: "session.sandbox_dir_added", detail: `${dir} by=owner` }, `Sandbox: ${dir} is writable (${when})`);
+ await this.#changeSandboxDirs(this.#withAddedDir(dir), { who: answeredBy ?? sender.sub, action: "session.sandbox_dir_added", detail: `${dir} by=owner` }, `Sandbox: ${dir} is writable (${when})`);
opts.onDecided?.(true);
},
() => opts.onDecided?.(false, "failed"),
@@ -3474,18 +3523,18 @@ export class Session {
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): Promise => {
const when = this.#dirsWhen();
- await this.#changeSandboxDirs([...sbx.effective().addedDirs, 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})`);
+ 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})`);
return `Added: ${dir} is writable to your commands ${when.replace("the session's", "your")}. 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; anything else is the owner's to add.
if (this.#mode === "autonomous") {
- const roots = sbx.appliedProjectRoots();
- // Strictly under one: a root itself (every repository in it) is the owner's to add.
- if (roots.some((r) => inAnyDir(dir, [r]) && !inAnyDir(r, [dir]))) return grant(SYSTEM_IDENTITY.sub, "under a project root, autonomous");
- return refuse(
- `${dir} isn't under this session's project roots (${roots.length > 0 ? roots.join(", ") : "none"}); in autonomous mode nothing else is added. The owner can add it (\`/sandbox add ${dir}\`)`,
- );
+ const why = autonomousGrantProblem(check.written, dir, sbx.appliedProjectRoots());
+ if (!why) return grant(SYSTEM_IDENTITY.sub, "a repository under a project root, autonomous");
+ 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);
@@ -3495,7 +3544,7 @@ export class Session {
{
method: "confirm",
title: `The agent asks to write ${dir}`,
- message: `"${this.name}" asks for its sandboxed commands to write ${dir} (its git structure and project settings stay read-only).\n\nIts reason: ${input.reason}`,
+ message: `"${this.name}" asks for its sandboxed commands to write ${dir}${via} (${this.#addedDirProtection(dir)}).\n\nIts reason: ${input.reason}`,
},
{
ownerOnly: true,
@@ -3519,6 +3568,7 @@ export class Session {
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");
});
this.#dirRequests.set(dir, pending);
@@ -4411,7 +4461,8 @@ export class Session {
if (this.#sandbox && !this.#refBaseline) this.#refBaseline = refTips(this.workdir);
await this.#refBaseline;
// The same for each added directory's repository, from the first turn it is writable in.
- for (const dir of this.#sandbox?.appliedAddedDirs() ?? []) {
+ // 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);
diff --git a/src/tests/sandbox-claude-real.test.ts b/src/tests/sandbox-claude-real.test.ts
index 95741795..06959497 100644
--- a/src/tests/sandbox-claude-real.test.ts
+++ b/src/tests/sandbox-claude-real.test.ts
@@ -9,7 +9,7 @@
*/
import { createServer } from "node:net";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js";
@@ -396,6 +396,44 @@ describe.skipIf(!ready)("Claude Code's sandbox with codeoid's policy, for real",
expect(existsSync(join(b, "new.txt"))).toBe(true);
expect(existsSync(join(home, "Workspace", "c", "new.txt"))).toBe(false);
}, 300_000);
+ test("a project root shows nothing hidden (#448 round 12): not a .env at its top, not where a link in it leads (~/.ssh, the home), not codeoid's private dir in it, not a credential file deeper", async () => {
+ const home = join(root, "home");
+ const W = join(home, "Workspace");
+ const ws = join(W, "a12");
+ const b = join(W, "b12");
+ for (const d of [ws, b, join(home, ".ssh"), join(home, "Documents")]) mkdirSync(d, { recursive: true });
+ writeFileSync(join(home, ".ssh", "id_ed25519"), "LEAK-SSH\n");
+ writeFileSync(join(home, "Documents", "doc.txt"), "LEAK-DOC\n");
+ writeFileSync(join(W, ".env"), "LEAK-ROOT-ENV\n");
+ writeFileSync(join(b, "README.md"), "B12-README\n");
+ writeFileSync(join(b, "id_rsa"), "LEAK-B-KEY\n");
+ mkdirSync(join(W, "b12-worktrees", "fork-1", "x"), { recursive: true });
+ writeFileSync(join(W, "b12-worktrees", "fork-1", ".env"), "LEAK-WT-ENV\n");
+ symlinkSync(join(home, ".ssh"), join(W, "keys"));
+ symlinkSync(join(home, "Documents"), join(W, "notes"));
+ symlinkSync(home, join(W, "home-link"));
+ // codeoid's private dir (its TMPDIR's codeoid-sandbox-*) right in the root, and a sign-in planted there after the spec was built.
+ const signinFile = join(root, "signin12.txt");
+ const command = [
+ `cat ${join(W, "keys", "id_ed25519")} 2>&1`,
+ `cat ${join(home, ".ssh", "id_ed25519")} 2>&1`,
+ `cat ${join(W, "notes", "doc.txt")} 2>&1`,
+ `cat ${join(home, "Documents", "doc.txt")} 2>&1`,
+ `cat ${join(W, ".env")} 2>&1`,
+ `cat ${join(b, "id_rsa")} 2>&1`,
+ `cat ${join(W, "b12-worktrees", "fork-1", ".env")} 2>&1`,
+ `cat "$(cat ${signinFile})/.credentials.json" 2>&1`,
+ `cat ${join(b, "README.md")}`,
+ `echo a > ${join(ws, "own.txt")} && echo WS-WRITABLE`,
+ ].join("; ");
+ const r = await probe("off", command, ["policy"], { CODEOID_PROBE_WORKSPACE: ws, CODEOID_PROBE_PROJECT_ROOTS: W, TMPDIR: W, CODEOID_PROBE_SIGNIN_PATH: signinFile });
+ expect(readFileSync(signinFile, "utf8").startsWith(W)).toBe(true);
+ expect(r.result).toBe("success");
+ expect(r.toolResult).toContain("B12-README");
+ expect(r.toolResult).toContain("WS-WRITABLE");
+ expect(r.toolResult).not.toContain("LEAK");
+ expect(r.toolResult).not.toContain("PLANTED-SIGNIN-TOKEN");
+ }, 300_000);
});
/**
diff --git a/src/tests/sandbox-dirs-manager.test.ts b/src/tests/sandbox-dirs-manager.test.ts
index bcda31fa..61a53d76 100644
--- a/src/tests/sandbox-dirs-manager.test.ts
+++ b/src/tests/sandbox-dirs-manager.test.ts
@@ -10,8 +10,8 @@
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
-import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
-import { homedir, tmpdir } from "node:os";
+import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync } from "node:fs";
+import { homedir } from "node:os";
import { join } from "node:path";
import { sandboxBadge, sandboxSummary } from "@highflame/codeoid-core";
import type { CodeoidConfig } from "../config.js";
@@ -84,15 +84,28 @@ function newManager(): SessionManager {
return new SessionManager(store, transcript, undefined, undefined, undefined, { config: config(), providers: registry, sandboxRegistry });
}
+const realHome = process.env.HOME;
+function git(args: string[], cwd: string): void {
+ const r = Bun.spawnSync(["git", ...args], { cwd, env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: cwd, GIT_CONFIG_NOSYSTEM: "1" } });
+ if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`);
+}
+
beforeEach(() => {
specs = [];
- tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-sbx-dirs-")));
- // /projects/{work,sibling}: the default project root is /projects.
+ // Not the host's temp dir (no project root is in one), with a home of the test's own beside it (the projects are
+ // in no hidden dir of it).
+ const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache");
+ mkdirSync(parent, { recursive: true });
+ tmp = realpathSync(mkdtempSync(join(parent, "codeoid-sbx-dirs-")));
+ process.env.HOME = join(tmp, "home");
+ mkdirSync(process.env.HOME);
+ // /projects/{work,sibling}, each a repository: the default project root is /projects.
root = join(tmp, "projects");
workdir = join(root, "work");
sibling = join(root, "sibling");
elsewhere = join(tmp, "elsewhere");
for (const d of [workdir, sibling, elsewhere]) mkdirSync(d, { recursive: true });
+ for (const d of [workdir, sibling]) git(["init", "-q"], d);
// codeoid's own data beside the projects, never around them (an added dir is never in what the sandbox hides).
mkdirSync(join(tmp, "data"));
store = new Store(join(tmp, "data", "codeoid.db"));
@@ -112,6 +125,7 @@ afterEach(async () => {
try {
store.close();
} catch {}
+ process.env.HOME = realHome;
rmSync(tmp, { recursive: true, force: true });
});
@@ -181,7 +195,8 @@ describe("project roots and added directories through the SessionManager", () =>
const m = newManager();
const id = infoOf(await create(m))?.id as string;
const add = (path: string) => m.handle({ type: "session.sandbox_dir", id: "d", sessionId: id, action: "add", path }, AUTH, CLIENT);
- for (const bad of ["/", homedir(), "~", join(homedir(), ".config"), join(tmp, "missing"), "/usr"]) {
+ const home = process.env.HOME as string;
+ for (const bad of ["/", home, "~", join(home, ".config"), join(tmp, "missing"), "/usr"]) {
expect(await add(bad)).toMatchObject({ type: "response.error", code: "invalid_request" });
}
const inside = await add(join(workdir));
@@ -277,3 +292,110 @@ describe("project roots and added directories through the SessionManager", () =>
expect(session?.toInfo().sandbox?.addedDirs).toBeUndefined();
});
});
+
+describe("an added directory is what it is when granted (#448 round 12)", () => {
+ async function autonomous(m: SessionManager) {
+ const info = infoOf(await create(m));
+ 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);
+ await session?.ensureSandbox();
+ return { info, session };
+ }
+
+ it("autonomous: a link (its own worktree folder's, leading anywhere of yours) is refused; so are a folder of repositories, a settings or git dir; a repository is added by its real path", async () => {
+ const m = newManager();
+ const { session } = await autonomous(m);
+ // The agent's own worktree folder is writable to it and under the root: a link there, to a dir of yours.
+ const folder = join(root, "work-worktrees", "agent-folder");
+ mkdirSync(folder, { recursive: true });
+ symlinkSync(elsewhere, join(folder, "x"));
+ const viaLink = await session?.requestDirectory({ path: join(folder, "x"), reason: "r" });
+ expect(viaLink).toContain("Refused:");
+ expect(viaLink).toContain("goes through a symbolic link");
+ // A plain folder holding repositories: the owner's to add (every repository in it at once).
+ const clients = join(root, "clients");
+ mkdirSync(join(clients, "beta"), { recursive: true });
+ git(["init", "-q"], join(clients, "beta"));
+ expect(await session?.requestDirectory({ path: clients, reason: "r" })).toContain(`Refused: ${clients} isn't in a repository`);
+ // What runs outside the sandbox: never writable.
+ for (const control of [join(sibling, ".claude"), join(sibling, ".git"), join(sibling, ".git", "hooks")]) {
+ mkdirSync(control, { recursive: true });
+ expect(await session?.requestDirectory({ path: control, reason: "r" })).toContain("Refused:");
+ }
+ expect(session?.toInfo().sandbox?.addedDirs).toBeUndefined();
+ // A repository under the root: added, by where it really is.
+ const alias = join(root, "sibling-alias");
+ symlinkSync(sibling, alias);
+ expect(await session?.requestDirectory({ path: alias, reason: "r" })).toContain("goes through a symbolic link");
+ expect(await session?.requestDirectory({ path: join(sibling, "src", "..", "."), reason: "r" })).toContain(`Added: ${sibling}`);
+ expect(session?.sandboxSetting?.addedDirs).toEqual([sibling]);
+ });
+
+ it("guarded: the owner's card names where a link leads, and what is kept is that directory", async () => {
+ const m = newManager();
+ const info = infoOf(await create(m));
+ const session = m.findByName(info?.name as string, AUTH);
+ await session?.ensureSandbox();
+ const asked = dialogs(m, info?.name as string);
+ const link = join(root, "work-worktrees", "agent-folder", "docs");
+ mkdirSync(join(root, "work-worktrees", "agent-folder"), { recursive: true });
+ symlinkSync(elsewhere, link);
+ const pending = session?.requestDirectory({ path: link, reason: "notes" }) as Promise;
+ await until(() => asked.length > 0);
+ expect(asked[0]?.title).toBe(`The agent asks to write ${elsewhere}`);
+ expect(asked[0]?.message).toContain(`${link} leads there`);
+ await m.handle({ type: "session.ui_response", id: "u", sessionId: info?.id as string, requestId: asked[0]?.requestId as string, confirmed: true } as never, AUTH, CLIENT);
+ expect(await pending).toContain(`Added: ${elsewhere}`);
+ expect(session?.sandboxSetting?.addedDirs).toEqual([elsewhere]);
+ });
+
+ it("a folder of repositories added by the owner: the card says their git structure and settings stay read-only", async () => {
+ const m = newManager();
+ const info = infoOf(await create(m));
+ const session = m.findByName(info?.name as string, AUTH);
+ await session?.ensureSandbox();
+ const clients = join(root, "clients");
+ for (const r of ["beta", "gamma"]) {
+ mkdirSync(join(clients, r), { recursive: true });
+ git(["init", "-q"], join(clients, r));
+ }
+ const asked = dialogs(m, info?.name as string);
+ await m.handle({ type: "session.sandbox_dir", id: "d", sessionId: info?.id as string, action: "add", path: clients }, AUTH, CLIENT);
+ await until(() => asked.length > 0);
+ expect(asked[0]?.message).toContain("and so do those of the 2 repositories inside it");
+ });
+
+ it("replaced by a link after it was granted: dropped at the next build, said and audited, out of the setting; never followed", async () => {
+ const m = newManager();
+ const { session } = await autonomous(m);
+ expect(await session?.requestDirectory({ path: sibling, reason: "r" })).toContain(`Added: ${sibling}`);
+ await session?.send("one", AUTH);
+ await until(() => session?.toInfo().status === "idle");
+ expect(session?.toInfo().sandbox?.addedDirs).toEqual([sibling]);
+ // Swapped (by whatever can write its parent) for a link to a dir of yours.
+ renameSync(sibling, join(tmp, "moved"));
+ symlinkSync(elsewhere, sibling);
+ const infos: string[] = [];
+ session?.attach({ id: "watch", auth: AUTH, send: (msg: { type: string; role?: string; content?: string }) => void (msg.type === "session.message" && msg.role === "info" && infos.push(String(msg.content))) } as never);
+ specs = [];
+ await session?.setSandbox({ ...(session?.sandboxSetting ?? {}), network: "off" }, AUTH);
+ await session?.ensureSandbox();
+ expect(session?.toInfo().sandbox?.addedDirs).toBeUndefined();
+ expect(session?.sandboxSetting?.addedDirs ?? []).toEqual([]);
+ expect(infos.some((t) => t.includes(`${sibling} is no longer writable`) && t.includes("symbolic link"))).toBe(true);
+ expect(audits.some((a) => a.action === "session.sandbox_dir_dropped" && a.detail.startsWith(sibling))).toBe(true);
+ for (const spec of specs) expect(spec.writable).not.toContain(elsewhere);
+ });
+
+ it("an unattended run in autonomous mode (a routine's, a loop worker's) follows the autonomous rule: a repository under a root, never anything else", async () => {
+ const m = newManager();
+ const { session } = await autonomous(m);
+ const release = session?.beginUnattended();
+ try {
+ expect(await session?.requestDirectory({ path: elsewhere, reason: "x" })).toContain("isn't under this session's project roots");
+ expect(await session?.requestDirectory({ path: sibling, reason: "x" })).toContain(`Added: ${sibling}`);
+ } finally {
+ release?.();
+ }
+ });
+});
diff --git a/src/tests/sandbox-project-roots-real.test.ts b/src/tests/sandbox-project-roots-real.test.ts
index 81330c4c..f1c61362 100644
--- a/src/tests/sandbox-project-roots-real.test.ts
+++ b/src/tests/sandbox-project-roots-real.test.ts
@@ -11,20 +11,20 @@
* repositories this test planted in its own temp dir, under a home of its own.
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js";
import { buildSandboxSpec } from "../daemon/sandbox/policy.js";
-import { resolveProjectRoots } from "../daemon/sandbox/project-roots.js";
+import { addedDirProblem, resolveProjectRoots } from "../daemon/sandbox/project-roots.js";
import type { SandboxHandle } from "../daemon/sandbox/types.js";
const provider = new BwrapSandboxProvider();
const probe = await provider.probe();
-/** Not under the host's /tmp, which the sandbox replaces with its own. */
+/** Not under the host's /tmp, which the sandbox replaces with its own, nor any temp dir (no project root is in one). */
function scratchParent(): string {
- for (const dir of [process.env.CODEOID_TEST_SANDBOX_DIR, "/var/tmp", join(homedir(), ".cache")]) {
+ for (const dir of [process.env.CODEOID_TEST_SANDBOX_DIR, join(homedir(), ".cache")]) {
if (!dir) continue;
try {
mkdirSync(dir, { recursive: true });
@@ -101,8 +101,9 @@ describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: pr
const tmp = join(dir, "tmp");
mkdirSync(tmp);
const roots = resolveProjectRoots(["@parent"], a, { home }).roots;
- const spec = (added: string[] = []) =>
+ const spec = (added: string[] = [], scanLimit?: number) =>
buildSandboxSpec({
+ ...(scanLimit !== undefined ? { scanLimit } : {}),
sessionId: name,
workspace: a,
tempDir: tmp,
@@ -222,4 +223,174 @@ describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: pr
await h2.close();
}
});
+ test("an added dir swapped for a link after it was granted, or a link kept as one: never written through (#448 round 12)", async () => {
+ const f = fixture("swap");
+ const docs = join(f.home, "Documents");
+ mkdirSync(docs, { recursive: true });
+ writeFileSync(join(docs, "taxes.txt"), "private\n");
+ const c = join(f.root, "c");
+ mkdirSync(c);
+ const before = await provider.open(f.spec([c]));
+ try {
+ expect((await sh(before, f.a, `echo x > '${join(c, "made.txt")}' && echo C-WRITTEN`, f.env)).stdout).toContain("C-WRITTEN");
+ } finally {
+ await before.close();
+ }
+ // Swapped (by whatever can write its parent) for a link to a dir of yours; and a link in the agent's own
+ // worktree folder kept as an added dir (as an older daemon kept what was asked for).
+ renameSync(c, join(f.root, "c-old"));
+ symlinkSync(docs, c);
+ const folder = join(f.root, "a-worktrees", FOLDER);
+ mkdirSync(folder, { recursive: true });
+ symlinkSync(docs, join(folder, "x"));
+ const spec = f.spec([c, join(folder, "x")]);
+ expect(spec.addedDirs).toBeUndefined();
+ const h = await provider.open(spec);
+ try {
+ const r = await sh(
+ h,
+ f.a,
+ [
+ `echo p > '${join(c, "planted.txt")}' 2>/dev/null && echo LEAK-SWAP || echo SWAP-REFUSED`,
+ `echo p > '${join(folder, "x", "planted2.txt")}' 2>/dev/null && echo LEAK-LINK || echo LINK-REFUSED`,
+ `cat '${join(docs, "taxes.txt")}' 2>/dev/null && echo LEAK-READ || echo DOCS-HIDDEN`,
+ ].join("; "),
+ f.env,
+ );
+ for (const w of ["SWAP-REFUSED", "LINK-REFUSED", "DOCS-HIDDEN"]) expect(r.stdout).toContain(w);
+ expect(r.stdout).not.toContain("LEAK");
+ } finally {
+ await h.close();
+ }
+ expect(existsSync(join(docs, "planted.txt"))).toBe(false);
+ expect(existsSync(join(docs, "planted2.txt"))).toBe(false);
+ });
+
+ test("a project settings or git dir is never an added dir; given one anyway, the spec leaves it out (#448 round 12)", async () => {
+ const f = fixture("control");
+ for (const d of [".claude", ".codex", join(".claude", "hooks")]) mkdirSync(join(f.b, d), { recursive: true });
+ for (const d of [".claude", ".codex", join(".claude", "hooks"), ".git", join(".git", "hooks")]) expect(addedDirProblem(join(f.b, d), { home: f.home, path: "/usr/bin" })).not.toBeNull();
+ const h = await provider.open(f.spec([join(f.b, ".claude"), join(f.b, ".codex"), join(f.b, ".git")]));
+ try {
+ const r = await sh(
+ h,
+ f.a,
+ [
+ `echo '{}' > '${join(f.b, ".claude", "settings.json")}' 2>/dev/null && echo LEAK-CLAUDE || echo CLAUDE-REFUSED`,
+ `echo x > '${join(f.b, ".codex", "config.toml")}' 2>/dev/null && echo LEAK-CODEX || echo CODEX-REFUSED`,
+ `echo x > '${join(f.b, ".git", "hooks", "pre-commit")}' 2>/dev/null && echo LEAK-HOOK || echo HOOK-REFUSED`,
+ ].join("; "),
+ f.env,
+ );
+ for (const w of ["CLAUDE-REFUSED", "CODEX-REFUSED", "HOOK-REFUSED"]) expect(r.stdout).toContain(w);
+ expect(r.stdout).not.toContain("LEAK");
+ } finally {
+ await h.close();
+ }
+ expect(existsSync(join(f.b, ".claude", "settings.json"))).toBe(false);
+ });
+
+ test("a folder of repositories added: their files written, each one's git structure and every settings dir in it held (#448 round 12)", async () => {
+ const f = fixture("folder");
+ const clients = join(f.root, "clients");
+ const beta = join(clients, "beta");
+ mkdirSync(beta, { recursive: true });
+ git(["init", "-q", "-b", "main"], beta, f.home);
+ mkdirSync(join(clients, "notes", ".codex"), { recursive: true });
+ const marker = join(f.dir, "RAN-OUTSIDE");
+ const h = await provider.open(f.spec([clients]));
+ try {
+ const r = await sh(
+ h,
+ f.a,
+ [
+ `echo x > '${join(beta, "file.txt")}' && echo FILE-WRITTEN`,
+ `printf '#!/bin/sh\necho ran >> ${marker}\n' > '${join(beta, ".git", "hooks", "post-checkout")}' 2>/dev/null && echo LEAK-HOOK || echo HOOK-REFUSED`,
+ `git -C '${beta}' config core.fsmonitor 'touch ${marker}' 2>/dev/null && echo LEAK-CONFIG || echo CONFIG-REFUSED`,
+ `echo '{}' > '${join(beta, ".claude", "settings.json")}' 2>/dev/null && echo LEAK-SETTINGS || echo SETTINGS-REFUSED`,
+ `echo x > '${join(clients, "notes", ".codex", "config.toml")}' 2>/dev/null && echo LEAK-CODEX || echo CODEX-REFUSED`,
+ ].join("; "),
+ f.env,
+ );
+ for (const w of ["FILE-WRITTEN", "HOOK-REFUSED", "CONFIG-REFUSED", "SETTINGS-REFUSED", "CODEX-REFUSED"]) expect(r.stdout).toContain(w);
+ expect(r.stdout).not.toContain("LEAK");
+ } finally {
+ await h.close();
+ }
+ // The user's next git in beta runs nothing planted.
+ git(["status", "--short"], beta, f.home);
+ expect(existsSync(marker)).toBe(false);
+ });
+
+ test("credential files anywhere in a root are hidden, a sibling worktree's .env and a git config with a token too; their examples readable (#448 round 12)", async () => {
+ const f = fixture("creds");
+ const files: Record = {
+ [join(f.b, "id_rsa")]: "LEAK-KEY",
+ [join(f.b, "deploy.pem")]: "LEAK-PEM",
+ [join(f.b, "config", ".env")]: "LEAK-DEEP",
+ [join(f.b, "terraform.tfstate")]: "LEAK-TFSTATE",
+ [join(f.b, ".aws", "credentials")]: "LEAK-AWS",
+ [join(f.b, ".claude", "settings.local.json")]: "LEAK-LOCAL-SETTINGS",
+ [join(f.root, "b-worktrees", "fork-1234", ".env")]: "LEAK-WT",
+ [join(f.b, "id_rsa.pub")]: "PUB-READ",
+ };
+ for (const [p, v] of Object.entries(files)) {
+ mkdirSync(join(p, ".."), { recursive: true });
+ writeFileSync(p, `${v}\n`);
+ }
+ git(["remote", "add", "origin", "https://x:LEAK-TOKEN@example.invalid/b.git"], f.b, f.home);
+ const h = await provider.open(f.spec());
+ try {
+ const r = await sh(h, f.a, `${Object.keys(files).map((p) => `cat '${p}' 2>/dev/null`).join("; ")}; cat '${join(f.b, ".git", "config")}' 2>/dev/null; git -C '${f.b}' log --oneline 2>&1`, f.env);
+ expect(r.stdout).not.toContain("LEAK");
+ expect(r.stdout).toContain("PUB-READ");
+ // git there can't read its config (the session's limits say so): its own repository is the workspace, unaffected.
+ expect(r.stdout).toContain("unable to access '.git/config'");
+ } finally {
+ await h.close();
+ }
+ });
+
+ test("the look for credentials is bounded and fails closed: a root past it isn't shown at all (#448 round 12)", async () => {
+ const f = fixture("budget");
+ const h = await provider.open(f.spec([], 1));
+ try {
+ const r = await sh(h, f.a, `cat '${join(f.b, "README.md")}' 2>/dev/null && echo LEAK-ROOT || echo ROOT-NOT-SHOWN; echo a > '${join(f.a, "own.txt")}' && echo OWN-WRITTEN`, f.env);
+ expect(r.stdout).toContain("ROOT-NOT-SHOWN");
+ expect(r.stdout).toContain("OWN-WRITTEN");
+ } finally {
+ await h.close();
+ }
+ });
+
+ test("a repository in the host's temp dir gets no default root: its siblings stay unreadable (#448 round 12)", async () => {
+ const t = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-roots-tmp-")));
+ try {
+ const a = join(t, "a");
+ const b = join(t, "b");
+ for (const r of [a, b]) {
+ mkdirSync(r);
+ git(["init", "-q"], r, t);
+ }
+ writeFileSync(join(b, "README.md"), "LEAK-SIBLING\n");
+ const home = join(base, "tmp-home");
+ mkdirSync(home, { recursive: true });
+ const roots = resolveProjectRoots(["@parent"], a, { home });
+ expect(roots.roots).toEqual([]);
+ expect(roots.notes[0]).toContain("temp dir");
+ const tmp = join(base, "tmp-root-temp");
+ mkdirSync(tmp, { recursive: true });
+ const spec = buildSandboxSpec({ sessionId: "t", workspace: a, tempDir: tmp, network: { mode: "off" }, hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, projectRoots: roots.roots });
+ const h = await provider.open(spec);
+ try {
+ const r = await sh(h, a, `cat '${join(b, "README.md")}' 2>/dev/null || echo SIBLING-HIDDEN`, gitEnv(home));
+ expect(r.stdout).toContain("SIBLING-HIDDEN");
+ expect(r.stdout).not.toContain("LEAK");
+ } finally {
+ await h.close();
+ }
+ } finally {
+ rmSync(t, { recursive: true, force: true });
+ }
+ });
});
diff --git a/src/tests/sandbox-project-roots.test.ts b/src/tests/sandbox-project-roots.test.ts
index 1b128690..e6d62e9e 100644
--- a/src/tests/sandbox-project-roots.test.ts
+++ b/src/tests/sandbox-project-roots.test.ts
@@ -10,10 +10,10 @@
*/
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
-import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
-import type { CodeoidConfig } from "../config.js";
+import { type CodeoidConfig, loadConfig } from "../config.js";
import { codexSandboxPolicy } from "../daemon/providers/codex/index.js";
import type { ProviderEvent } from "../daemon/providers/interface.js";
import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js";
@@ -21,10 +21,20 @@ import { isRequestDirectoryTool, isSafeTool } from "../daemon/providers/tool-saf
import { claudeSandboxSettings, readAround } from "../daemon/sandbox/claude-native.js";
import { LocalSandboxHandle } from "../daemon/sandbox/local-handle.js";
import { buildSandboxSpec, protectedWriteTargets } from "../daemon/sandbox/policy.js";
-import { addedDirProblem, defaultProjectRoot, projectRootSecrets, resolveProjectRoots, sandboxDirProblem } from "../daemon/sandbox/project-roots.js";
+import {
+ addedDirProblem,
+ autonomousGrantProblem,
+ defaultProjectRoot,
+ nestedControl,
+ projectRootProblem,
+ projectRootSecrets,
+ resolveProjectRoots,
+ sandboxDirProblem,
+ stillRealDir,
+} from "../daemon/sandbox/project-roots.js";
import { SandboxRegistry } from "../daemon/sandbox/registry.js";
import { SANDBOX_MCP_TOKEN_ENV, SandboxMcpHttp } from "../daemon/sandbox/request-directory.js";
-import { effectiveSetting, onlyDirsChanged, type SandboxRuntime, sandboxDirsOf, SessionSandbox, widens } from "../daemon/sandbox/session-sandbox.js";
+import { canonicalAddedDirs, effectiveSetting, onlyDirsChanged, type SandboxRuntime, sandboxDirsOf, SessionSandbox, widens } from "../daemon/sandbox/session-sandbox.js";
import { sandboxConfigOf } from "../daemon/sandbox/policy.js";
import type { SandboxProvider, SandboxSpec } from "../daemon/sandbox/types.js";
import { Session } from "../daemon/session.js";
@@ -41,13 +51,26 @@ function git(args: string[], cwd: string): void {
}
let base: string;
+/** Not the host's temp dir: no project root is in one (dir-rules.ts `projectRootProblem`). */
+function scratchParent(): string {
+ const dir = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache");
+ mkdirSync(dir, { recursive: true });
+ return dir;
+}
+const realHome = process.env.HOME;
beforeEach(() => {
- base = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-roots-")));
+ base = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-roots-")));
+ // A home of the test's own, beside the projects: they are in no hidden dir of it (~/.cache is one of yours).
+ process.env.HOME = join(base, "fakehome");
+ mkdirSync(process.env.HOME);
+});
+afterEach(() => {
+ process.env.HOME = realHome;
+ rmSync(base, { recursive: true, force: true });
});
-afterEach(() => rmSync(base, { recursive: true, force: true }));
describe("project roots", () => {
- test("the default is the repository's parent; a linked worktree's is its main checkout's; outside a repository, the workspace's parent", () => {
+ test("the default is the repository's parent; a linked worktree's is its main checkout's; outside a repository, none (said)", () => {
const home = join(base, "home");
const repo = join(home, "Workspace", "app");
mkdirSync(join(repo, "packages", "ui"), { recursive: true });
@@ -56,7 +79,10 @@ describe("project roots", () => {
expect(defaultProjectRoot(repo, home)).toBe(join(home, "Workspace"));
const plain = join(home, "notes", "today");
mkdirSync(plain, { recursive: true });
- expect(defaultProjectRoot(plain, home)).toBe(join(home, "notes"));
+ expect(defaultProjectRoot(plain, home)).toBeNull();
+ const none = resolveProjectRoots(["@parent"], plain, { home });
+ expect(none.roots).toEqual([]);
+ expect(none.notes[0]).toContain("the workspace isn't in a repository, so there is no default");
expect(resolveProjectRoots(["@parent"], repo, { home }).roots).toEqual([join(home, "Workspace")]);
});
@@ -69,7 +95,7 @@ describe("project roots", () => {
expect(r.roots).toEqual([]);
expect(r.notes[0]).toContain("no project root: the default, the repository's parent");
expect(r.notes[0]).toContain("set sandbox.projectRoots");
- const dirs = sandboxDirsOf(homedir(), ["@parent"], []);
+ const dirs = sandboxDirsOf(process.env.HOME as string, ["@parent"], []);
expect(dirs).toMatchObject({ roots: [], homeWorkspace: true });
expect(dirs.notes[0]).toContain("only credentials are protected");
});
@@ -100,14 +126,14 @@ describe("project roots", () => {
expect(addedDirProblem(join(home, "file"), { home })).toContain("isn't a directory");
});
- test("the secret files at a project's top are hidden, not their examples, and not the session's own", () => {
+ test("the secret files anywhere in a root are hidden, not their examples, and not the session's own", () => {
const root = join(base, "Workspace");
for (const p of ["a", "b", "c/deep"]) mkdirSync(join(root, p), { recursive: true });
for (const f of [".env", ".env.production", ".envrc", ".npmrc", ".env.example", ".env.sample", "README.md"]) writeFileSync(join(root, "b", f), "x");
writeFileSync(join(root, "a", ".env"), "mine");
writeFileSync(join(root, "c", "deep", ".env"), "deep");
- const found = projectRootSecrets([root], [join(root, "a")]).sort();
- expect(found).toEqual([".env", ".env.production", ".envrc", ".npmrc"].map((f) => join(root, "b", f)).sort());
+ const found = projectRootSecrets([root], [join(root, "a")]).hidden.sort();
+ expect(found).toEqual([...[".env", ".env.production", ".envrc", ".npmrc"].map((f) => join(root, "b", f)), join(root, "c", "deep", ".env")].sort());
});
});
@@ -401,3 +427,241 @@ describe("applied between turns, never mid-work", () => {
}
});
});
+
+/** `/Workspace/{a,b}`, each a repository, and a dir of yours outside every root. */
+function workspaceLayout() {
+ const home = join(base, "home");
+ const root = join(home, "Workspace");
+ const a = join(root, "a");
+ const b = join(root, "b");
+ const docs = join(home, "Documents");
+ for (const r of [a, b]) {
+ mkdirSync(r, { recursive: true });
+ git(["init", "-q", "-b", "main"], r);
+ }
+ mkdirSync(docs, { recursive: true });
+ const tmp = join(base, "tmp");
+ mkdirSync(tmp, { recursive: true });
+ const spec = (over: Partial[0]> = {}) =>
+ buildSandboxSpec({
+ sessionId: "s",
+ workspace: a,
+ tempDir: tmp,
+ network: { mode: "off" },
+ hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" },
+ projectRoots: [root],
+ ...over,
+ });
+ return { home, root, a, b, docs, tmp, spec };
+}
+
+describe("an added directory is what it was when granted (#448 round 12)", () => {
+ test("kept by its real path; replaced by a link later, it is dropped at the next build (said), never followed", () => {
+ const { home, root, a, docs, spec } = workspaceLayout();
+ const c = join(root, "c");
+ mkdirSync(c);
+ const link = join(root, "a-worktrees", "s-1234abcd", "x");
+ mkdirSync(join(root, "a-worktrees", "s-1234abcd"), { recursive: true });
+ symlinkSync(docs, link);
+ // What is kept is where it really is when granted.
+ expect(canonicalAddedDirs({ addedDirs: [link, c] }).addedDirs).toEqual([docs, c]);
+ expect(canonicalAddedDirs({ addedDirs: [link] }, { addedDirs: [link] }).addedDirs).toEqual([link]);
+ expect(stillRealDir(c)).toBe(true);
+ expect(spec({ addedDirs: [{ dir: c }] }).writable).toContain(c);
+ // c swapped for a link to a dir of yours.
+ renameSync(c, join(root, "c-old"));
+ symlinkSync(docs, c);
+ expect(stillRealDir(c)).toBe(false);
+ const after = spec({ addedDirs: [{ dir: c }] });
+ expect(after.addedDirs).toBeUndefined();
+ expect(after.writable).not.toContain(c);
+ expect(after.writable).not.toContain(docs);
+ // A stored link (as an older daemon kept one) isn't followed either.
+ expect(spec({ addedDirs: [{ dir: link }] }).writable).not.toContain(docs);
+ process.env.HOME = home;
+ const dirs = sandboxDirsOf(a, [], [c]);
+ expect(dirs.added).toEqual([]);
+ expect(dirs.dropped[0]?.dir).toBe(c);
+ expect(dirs.notes.join("\n")).toContain(`${c} is a symbolic link now`);
+ });
+
+ test("autonomous: only by a path with no link in it, strictly under a root, in a repository", () => {
+ const { home, root, b, docs } = workspaceLayout();
+ const clients = join(root, "clients");
+ mkdirSync(join(clients, "beta"), { recursive: true });
+ git(["init", "-q"], join(clients, "beta"));
+ mkdirSync(join(b, "src"));
+ symlinkSync(b, join(root, "b-alias"));
+ expect(autonomousGrantProblem(b, b, [root], home)).toBeNull();
+ expect(autonomousGrantProblem(join(b, "src"), join(b, "src"), [root], home)).toBeNull();
+ expect(autonomousGrantProblem(join(clients, "beta"), join(clients, "beta"), [root], home)).toBeNull();
+ expect(autonomousGrantProblem(join(root, "b-alias"), b, [root], home)).toContain("goes through a symbolic link");
+ expect(autonomousGrantProblem(clients, clients, [root], home)).toContain("isn't in a repository");
+ expect(autonomousGrantProblem(root, root, [root], home)).toContain("isn't under this session's project roots");
+ expect(autonomousGrantProblem(docs, docs, [root], home)).toContain("isn't under this session's project roots");
+ });
+
+ test("a git or project settings dir, or one inside it, is never added: what is in it runs outside the sandbox", () => {
+ const { home, root, b } = workspaceLayout();
+ const bare = join(root, "server.git");
+ mkdirSync(bare);
+ git(["init", "-q", "--bare"], bare);
+ git(["config", "core.hooksPath", ".husky"], b);
+ const refused = [
+ ...[".claude", ".codex", ".gemini", ".qwen", ".pi", ".opencode"].map((d) => join(b, d)),
+ join(b, ".claude", "hooks"),
+ join(b, ".git"),
+ join(b, ".git", "hooks"),
+ join(b, ".git", "info"),
+ bare,
+ join(bare, "hooks"),
+ join(b, ".husky"),
+ ];
+ for (const d of refused) {
+ mkdirSync(d, { recursive: true });
+ expect([d, addedDirProblem(d, { home, path: "/usr/bin" })]).toEqual([d, expect.stringMatching(/settings dir|\.git dir|git dir|hooks dir/)]);
+ }
+ expect(addedDirProblem(b, { home, path: "/usr/bin" })).toBeNull();
+ });
+
+ test("a folder of repositories: each repository's git structure and every settings dir in it held, as the workspace's", () => {
+ const { home, root, spec } = workspaceLayout();
+ const clients = join(root, "clients");
+ for (const r of ["beta", "gamma"]) {
+ mkdirSync(join(clients, r), { recursive: true });
+ git(["init", "-q"], join(clients, r));
+ }
+ mkdirSync(join(clients, "beta", ".claude"));
+ mkdirSync(join(clients, "notes", "deep", ".codex"), { recursive: true });
+ mkdirSync(join(clients, "web", "node_modules", "x", ".git"), { recursive: true });
+ const nested = nestedControl(clients);
+ expect(nested.complete).toBe(true);
+ expect(nested.repos.sort()).toEqual([join(clients, "beta"), join(clients, "gamma")]);
+ const s = spec({ addedDirs: [{ dir: clients }] });
+ expect(s.writable).toContain(clients);
+ for (const held of [
+ join(clients, "beta", ".git", "config"),
+ join(clients, "beta", ".git", "hooks"),
+ join(clients, "gamma", ".git", "hooks"),
+ join(clients, "beta", ".claude"),
+ join(clients, "gamma", ".mcp.json"),
+ join(clients, "notes", "deep", ".codex"),
+ ]) {
+ expect([held, s.readOnly?.includes(held)]).toEqual([held, true]);
+ }
+ const targets = protectedWriteTargets(join(root, "a"), home, [clients]).map((h) => h.at);
+ for (const t of [join(clients, "beta", ".git"), join(clients, "beta", ".claude"), join(clients, "notes", "deep", ".codex")]) expect(targets).toContain(t);
+ });
+
+ test("the look through an added dir is bounded, and fails closed: past it, refused, and left out of the spec", () => {
+ const { home, root, spec } = workspaceLayout();
+ const big = join(root, "big");
+ for (let i = 0; i < 5; i++) mkdirSync(join(big, `d${i}`), { recursive: true });
+ expect(nestedControl(big, 3).complete).toBe(false);
+ expect(addedDirProblem(big, { home, path: "/usr/bin", scanLimit: 3 })).toContain("holds more than 3 directories");
+ expect(addedDirProblem(big, { home, path: "/usr/bin" })).toBeNull();
+ expect(spec({ addedDirs: [{ dir: big }], scanLimit: 3 }).writable).not.toContain(big);
+ expect(spec({ addedDirs: [{ dir: big }] }).writable).toContain(big);
+ });
+});
+
+describe("credentials inside project roots (#448 round 12)", () => {
+ test("hidden by name anywhere in a root (codeoid's worktrees of sibling repositories too), not their examples, not the session's own", () => {
+ const { root, a, b } = workspaceLayout();
+ const secret = [
+ ".env",
+ ".env.production.local",
+ ".envrc",
+ ".npmrc",
+ ".pypirc",
+ ".netrc",
+ ".git-credentials",
+ ".vault-token",
+ "server.pem",
+ "tls.key",
+ "id_rsa",
+ "id_ed25519",
+ "id_ecdsa_old",
+ "cert.p12",
+ "cert.pfx",
+ "kubeconfig",
+ "dev.kubeconfig",
+ "terraform.tfstate",
+ "terraform.tfstate.backup",
+ "prod.tfvars",
+ "credentials.json",
+ "service-account-prod.json",
+ "secrets.yaml",
+ "config/.env",
+ "config/secrets.yml",
+ ".claude/settings.local.json",
+ ".mcp.json",
+ ".docker/config.json",
+ ].map((f) => join(b, f));
+ const dirs = [".aws", ".ssh", ".kube", ".gnupg"].map((d) => join(b, d));
+ const plain = [".env.example", ".env.sample", ".env.template", "id_rsa.pub", "id_ed25519.pub", "README.md", "credentials.ts", "secrets.py", "keys.txt", "node_modules/pkg/.env"].map((f) => join(b, f));
+ const worktrees = [join(root, "b-worktrees", "fork-1234", ".env"), join(root, "b-worktrees", "other-abcd", "feat", ".env")];
+ for (const f of [...secret, ...plain, ...worktrees, ...dirs.map((d) => join(d, "credentials")), join(a, ".env"), join(a, "id_rsa")]) {
+ mkdirSync(join(f, ".."), { recursive: true });
+ writeFileSync(f, "x");
+ }
+ const found = projectRootSecrets([root], [a]);
+ expect(found.over).toEqual([]);
+ expect(found.hidden.sort()).toEqual([...secret, ...dirs, ...worktrees].sort());
+ });
+
+ test("a repository's .git/config carrying a credential is hidden (said: git there can't read its config); a plain one isn't", () => {
+ const { home, root, a, b } = workspaceLayout();
+ const c = join(root, "c");
+ const d = join(root, "d");
+ for (const r of [c, d]) {
+ mkdirSync(r);
+ git(["init", "-q"], r);
+ }
+ git(["remote", "add", "origin", "https://x:ghp_PLANTED@example.invalid/b.git"], b);
+ git(["remote", "add", "origin", "ssh://git@example.invalid/c.git"], c);
+ git(["config", "http.https://example.invalid/.extraheader", "Authorization: Bearer PLANTED"], d);
+ const found = projectRootSecrets([root], [a]);
+ expect(found.gitConfigs.sort()).toEqual([join(b, ".git", "config"), join(d, ".git", "config")]);
+ expect(found.hidden).not.toContain(join(c, ".git", "config"));
+ process.env.HOME = home;
+ const notes = sandboxDirsOf(a, [root], []).notes.join("\n");
+ expect(notes).toContain(`${join(b, ".git", "config")} holds a credential`);
+ expect(notes).toContain(`git in ${b} can't read its config`);
+ });
+
+ test("the look through a root is bounded and fails closed: a root past it isn't shown, said", () => {
+ const { root, a, spec } = workspaceLayout();
+ for (let i = 0; i < 5; i++) mkdirSync(join(root, `p${i}`, "src"), { recursive: true });
+ expect(projectRootSecrets([root], [a], 4).over).toEqual([root]);
+ const s = spec({ scanLimit: 4 });
+ expect(s.projectRoots).toBeUndefined();
+ expect(s.reexposed).not.toContain(root);
+ expect(spec().projectRoots).toEqual([root]);
+ });
+});
+
+describe("project roots: never the host's temp dir, none outside a repository (#448 round 12)", () => {
+ test("/tmp, anything in it, /var/tmp's and TMPDIR's: refused as roots (the config too); a temp dir itself isn't an added dir", () => {
+ for (const bad of ["/tmp", "/tmp/work", "/var/tmp/x", join(tmpdir(), "x")]) expect([bad, projectRootProblem(bad)]).toEqual([bad, expect.stringContaining("temp dir")]);
+ expect(sandboxDirProblem("/tmp")).toContain("temp dir");
+ expect(sandboxDirProblem("/var/tmp")).toContain("temp dir");
+ expect(projectRootProblem(join(base, "projects"))).toBeNull();
+ expect(() => loadConfig({ raw: { sandbox: { projectRoots: ["/tmp/projects"] } }, env: {}, quiet: true })).toThrow(/temp dir/);
+ });
+
+ test("a repository in the host's temp dir: no default root, said", () => {
+ const t = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-roots-tmp-")));
+ try {
+ const repo = join(t, "a");
+ mkdirSync(repo);
+ git(["init", "-q"], repo);
+ const r = resolveProjectRoots(["@parent"], repo, { home: join(base, "home") });
+ expect(r.roots).toEqual([]);
+ expect(r.notes[0]).toContain("no project root: the default, the repository's parent");
+ expect(r.notes[0]).toContain("temp dir");
+ } finally {
+ rmSync(t, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/tests/sandbox-session.test.ts b/src/tests/sandbox-session.test.ts
index de2d9517..64b37f42 100644
--- a/src/tests/sandbox-session.test.ts
+++ b/src/tests/sandbox-session.test.ts
@@ -368,14 +368,15 @@ describe("fallback: unattended work refuses, interactive work says so", () => {
describe("the approval card and the gate", () => {
test("a shell call on a sandboxed session: sandboxed, with the provider and network", async () => {
const r = await gateCall("Bash", { command: "ls" });
- expect(r.card).toMatchObject({ sandboxed: true, sandboxNote: `sandboxed: fake-bwrap, network on; reads ${root} (read-only)` });
+ // No project root: the workdir is in no repository, and in the host's temp dir (#448 round 12).
+ expect(r.card).toMatchObject({ sandboxed: true, sandboxNote: "sandboxed: fake-bwrap, network on" });
});
test("a sandbox with Docker access: every card says so, as a warning (#448 follow-up)", async () => {
const r = await gateCall("Bash", { command: "ls" }, { sandbox: { dockerSocket: true } });
- expect(r.card).toMatchObject({ sandboxed: true, sandboxDocker: true, sandboxNote: `sandboxed, Docker access: fake-bwrap, network on; reads ${root} (read-only)` });
- expect(r.pending[0]).toMatchObject({ sandboxDocker: true, sandboxNote: `sandboxed, Docker access: fake-bwrap, network on; reads ${root} (read-only)` });
- expect(approvalSandboxLine(r.card as Card)).toBe(`⚠️ sandboxed, Docker access: fake-bwrap, network on; reads ${root} (read-only)`);
+ expect(r.card).toMatchObject({ sandboxed: true, sandboxDocker: true, sandboxNote: "sandboxed, Docker access: fake-bwrap, network on" });
+ expect(r.pending[0]).toMatchObject({ sandboxDocker: true, sandboxNote: "sandboxed, Docker access: fake-bwrap, network on" });
+ expect(approvalSandboxLine(r.card as Card)).toBe("⚠️ sandboxed, Docker access: fake-bwrap, network on");
const file = await gateCall("Write", { file_path: join(workdir, "a.ts"), content: "x" }, { sandbox: { dockerSocket: true } });
expect(file.card).toMatchObject({ sandboxed: false, sandboxDocker: true });
expect(approvalSandboxLine(file.card as Card)).toBe("⚠️ runs in the backend's own process, outside the sandbox (the sandbox has Docker access)");
@@ -1012,7 +1013,7 @@ describe("round 8 of the #448 audit: host-side file tools are held to the sandbo
test("the sandbox decides what a read reaches: the home's allowlist is readable; the host's /tmp is to Claude's own sandbox, not to opencode's under bubblewrap (its /tmp is the session's)", async () => {
expect((await gateCall("Read", { file_path: join(homedir(), ".gitconfig") }, { mode: "autonomous" })).behavior).toBe("allow");
- // Outside every project root (the workdir's parent is the default one): another dir of the host's /tmp.
+ // Outside every project root (there is none: the workdir is in no repository, in the host's temp dir): another dir of the host's /tmp.
const other = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-sbx-elsewhere-")));
try {
const outside = join(other, "notes.txt");
@@ -1021,9 +1022,9 @@ describe("round 8 of the #448 audit: host-side file tools are held to the sandbo
} finally {
rmSync(other, { recursive: true, force: true });
}
- // Inside the project root, beside the workdir: readable to its commands, so to its file tools.
+ // Beside the workdir: no root shows it (#448 round 12: none outside a repository, none in a temp dir), so neither do its file tools.
const sibling = join(root, "elsewhere", "notes.txt");
- expect((await gateCall("Read", { file_path: sibling }, { mode: "autonomous", backend: "opencode" })).behavior).toBe("allow");
+ expectOutside(await gateCall("Read", { file_path: sibling }, { mode: "autonomous", backend: "opencode" }), "read", sibling);
});
test("a write through a link out of the workspace is judged where it lands", async () => {
From 8552e5e856ef434ae4fea293f30968483ac91da7 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 15:02:33 +0800
Subject: [PATCH 10/23] docs: #448 round-12 audit: working across repositories
as it now behaves
docs/sandbox.md: an added directory is the one granted (real path, dropped
when replaced), never a git or settings dir, a folder of repositories held
whole; the autonomous rule and what unattended runs may be given; the
credential files hidden in a root, the look's bound and what fails closed;
no root in the host's temp dir or outside a repository; Claude's own
sandbox leaves a root's links and hidden entries out (checked on the real
CLI). CHANGELOG, CONFIGURATION.md and CLAUDE.md to match.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 4 +-
CLAUDE.md | 23 ++++---
docs/CONFIGURATION.md | 2 +-
docs/sandbox.md | 145 +++++++++++++++++++++++++++++++++---------
4 files changed, 134 insertions(+), 40 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83b54d44..542bf0fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,8 +23,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The end-of-turn warning names a worktree in the session's folder whose `.git` points anywhere but its own admin dir, and an admin dir that changed. See [docs/sandbox.md](docs/sandbox.md#git-worktrees-inside-the-sandbox).
- **Sandboxed sessions work across repositories: other repositories readable, more directories writable** (#448).
- Everything under a session's **project roots** is readable to its commands, read-only: by default the parent of its repository (a session in `~/Workspace/codeoid` reads `~/Workspace/*`: the repositories beside it, and what other sessions did in their checkouts there). `sandbox.projectRoots` (`CODEOID_SANDBOX_PROJECT_ROOTS`, Settings > Sandbox) sets it, `@parent` being the default; per session at create `codeoid new --project-root ` / `--no-project-roots` or the new-session dialog. What the sandbox hides anywhere stays hidden inside a root, and so do the secret files at each project's top there (`.env`, `.env.*` but examples, `.envrc`, `.npmrc`, …). `/`, the home, a dir holding it, a hidden dir of it, `~/Library` and system dirs are refused; a default that would be the home (a repository directly in it) falls back to none, said on the session.
- A session can be given **more directories to write**, each treated as its workspace (its git structure and project settings held, its own worktree folder there, the end-of-turn git look): `/sandbox add ` / `/sandbox remove `, `codeoid sandbox add|remove `, `codeoid new --add-dir `, the new-session dialog. Adding is a widening: its owner's own, undelegated, confirmed on the session, audited. The agent can ask with codeoid's `request_directory` tool (every backend that runs commands): in guarded and interactive mode its owner gets a card with the directory and the reason; in autonomous mode one under a project root is added unasked and anything else refused at once with a reason; an unattended run is never parked. Added directories survive a restart and are inherited by forks. A change takes effect at the session's next turn with no background work running (Claude's query rebuilt, a process backend restarted then), never mid-work; until then it shows as pending.
+ Everything under a session's **project roots** is readable to its commands, read-only: by default the parent of its repository (a session in `~/Workspace/codeoid` reads `~/Workspace/*`: the repositories beside it, and what other sessions did in their checkouts there). `sandbox.projectRoots` (`CODEOID_SANDBOX_PROJECT_ROOTS`, Settings > Sandbox) sets it, `@parent` being the default; per session at create `codeoid new --project-root ` / `--no-project-roots` or the new-session dialog. What the sandbox hides anywhere stays hidden inside a root, and so do the credential files anywhere in it, codeoid's worktrees of the repositories there included (`.env`, `.env.*` but examples, `.envrc`, `.npmrc`, `*.pem`, `*.key`, private keys, kubeconfigs, Terraform state, `.aws/`, …), and a repository's `.git/config` carrying a credential (git there then can't read its config, said); a root too big to look through (20000 directories) isn't used, said. On Claude's own sandbox a link in a root is left out, never shown by where it leads, and nothing hidden is shown again. `/`, the home, a dir holding it, a hidden dir of it, `~/Library`, system dirs and the host's temp dir or anything in it are refused; a default that would be the home (a repository directly in it) or in a temp dir falls back to none, and a workspace in no repository has no default root, said on the session.
+ A session can be given **more directories to write**, each treated as its workspace (its git structure and project settings held, its own worktree folder there, the end-of-turn git look): `/sandbox add ` / `/sandbox remove `, `codeoid sandbox add|remove `, `codeoid new --add-dir `, the new-session dialog. Adding is a widening: its owner's own, undelegated, confirmed on the session, audited. What is added is the directory the path really is when granted (the card names it); one replaced by a link or removed later is dropped, said, never followed. A git or project settings dir (`.git`, `.claude/`, …) or one inside it is never added; a folder of repositories is, with each one's git structure and settings held. The agent can ask with codeoid's `request_directory` tool (every backend that runs commands): in guarded and interactive mode its owner gets a card with the directory and the reason; in autonomous mode (an unattended run's too) a repository, or a dir in one, under a project root, named with no symbolic link, is added unasked and anything else refused at once with a reason; a guarded unattended run is never parked. Added directories survive a restart and are inherited by forks. A change takes effect at the session's next turn with no background work running (Claude's query rebuilt, a process backend restarted then), never mid-work; until then it shows as pending.
The chip, `codeoid ls` and approval cards say what the sandbox reads and writes besides the workspace. A session whose workspace is the home itself is allowed and says "sandboxed: workspace is your home — only credentials are protected". See [docs/sandbox.md](docs/sandbox.md#working-across-repositories).
- **Docker access, an opt-in for tests that start containers** (#448).
diff --git a/CLAUDE.md b/CLAUDE.md
index 8fd68a51..d3813723 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -441,20 +441,27 @@ when the network the session asked for is off or an allowlist.
Working across repositories (sandbox/project-roots.ts, dir-rules.ts, request-directory.ts; docs/sandbox.md
"Working across repositories"): `sandbox.projectRoots` (default `@parent`: the parent of the repository's main
-checkout, `defaultProjectRoot`; the home falls back to none, said) are shown read-only (`SandboxSpec.projectRoots`,
-in `reexposed`), the hidden list hidden inside and `projectRootSecrets` (`.env*`, `.envrc`, `.npmrc`, … at each
-project's top, not the session's own). `SessionSandboxSetting.addedDirs` are writable, each a workspace to
+checkout, `defaultProjectRoot`; none outside a repository, the home or a temp dir falls back to none, said; never
+the host's temp dir or in it, `projectRootProblem`) are shown read-only (`SandboxSpec.projectRoots`, in `reexposed`),
+the hidden list hidden inside and `projectRootSecrets` (credential files by name anywhere in a root and in the
+worktree locations shown, a `.git/config` with a credential, not the session's own; bounded per root, a root past it
+not used: fail closed, said). `SessionSandboxSetting.addedDirs` are kept by their real path when granted
+(`canonicalAddedDirs`, `addedDirReal`) and dropped at a build when that no longer is a directory reached through no
+link (`stillRealDir`; `Session#sayDroppedDirs`: said, audited, out of the setting), writable, each a workspace to
`buildSandboxSpec` (its `gitWorkspacePaths`, `projectConfigPaths`, worktree folder, a `WorktreeEntryGuard` per
-repository) and to `protectedWriteTargets` and the end-of-turn git look (`#checkAddedGitControl`); validated by
-`addedDirProblem` (`sandboxDirProblem`'s refusals, what the sandbox hides, a PATH entry). Adding a dir or a root
-`widens()`. `session.sandbox_dir` (owner, undelegated, owner-only confirm; remove narrows) and the agent's
+repository; every repository, bare git dir and settings dir inside it, `nestedControl`, bounded: past it refused /
+left out) and to `protectedWriteTargets` and the end-of-turn git look (`#checkAddedGitControl`); validated by
+`addedDirProblem` (`sandboxDirProblem`'s refusals, what the sandbox hides, a PATH entry, a git or settings dir or one
+in it, `controlDirProblem`). Adding a dir or a root `widens()`. `session.sandbox_dir` (owner, undelegated, owner-only confirm; remove narrows) and the agent's
`request_directory` (`Session#requestDirectory`: guarded/interactive an owner-only confirm with the reason,
-autonomous added under a root else refused, unattended refused; in-process on Claude/Qwen, pi's bridge,
+autonomous (an unattended run's too) only `autonomousGrantProblem`'s: a repository or a dir in one strictly under a
+root, by a path with no link, else refused; guarded unattended refused; in-process on Claude/Qwen, pi's bridge,
`/mcp/sandbox` loopback with a per-session token for Codex/Gemini CLI/opencode; `isRequestDirectoryTool` skips the
gate card). A dirs-only change (`onlyDirsChanged`) leaves the runtime until `Session.ensureSandbox` applies it at a
turn start with no turn, approval or background work (`dirsPending`, `SessionSandboxInfo.pendingDirs`), then
`#restartForSandboxDirs` restarts a process backend. Claude native shows a root around what is writable
-(`readAround`: its CLI binds allowRead after allowWrite). `homeWorkspace`: the chip says "sandboxed: workspace is
+(`readAround`: its CLI binds allowRead after allowWrite), never a link in it (it would show the target) nor an entry
+that is hidden (an `allowRead` equal to a `denyRead` wins; one strictly inside stays denied, checked on the real CLI). `homeWorkspace`: the chip says "sandboxed: workspace is
your home — only credentials are protected".
Shell commands are approved by risk (#431, docs/approvals.md): `src/daemon/shell/`
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index bfbeb96d..ba63a23d 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -254,7 +254,7 @@ Agents' commands run in an OS sandbox, on by default ([sandbox.md](sandbox.md)):
- `docker.image` (`CODEOID_SANDBOX_DOCKER_IMAGE`) and `docker.runtime` — what a Docker sandbox runs, and `runsc` for gVisor.
- `docker.socket` (`CODEOID_SANDBOX_DOCKER_SOCKET`, default `false`) — Docker access: sandboxed commands can reach the host's container runtime socket (Docker, rootless Docker, Podman; Docker Desktop on macOS), with `DOCKER_HOST` set, for tests that start containers. Root-equivalent (a container can mount `/`), so every session with it says "sandboxed, Docker access"; a session turns it on for itself with `--docker-socket` / `/sandbox … docker`, asked of its owner ([sandbox.md](sandbox.md#docker-access)).
- `worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, comma separated) — more places a sandboxed session may check a git worktree of its repository out, on top of `-worktrees/` (`~` expands; a relative path is the repository's main checkout's, at most one level up, judged again where it leads: `../.config` of a repository in your home is refused; never inside the checkout). A root may be shared: each repository gets `//`, and a sandboxed session writes only its own folder there. A root that doesn't exist is used once you create it (codeoid makes one only beside the repository). The config fails to load on `/`, the home or a dir holding it, anything in a hidden dir of the home (`~/.config`, `~/.local`, …), `~/Library`, codeoid's config dir or a system dir ([sandbox.md](sandbox.md#git-worktrees-inside-the-sandbox)).
-- `projectRoots` (`CODEOID_SANDBOX_PROJECT_ROOTS`, comma separated; Settings > Sandbox > "Project roots (readable)") — where sandboxed commands may read other repositories, read-only: absolute dirs (`~` expands) and `@parent`, the parent of the session's repository (the default: a session in `~/Workspace/codeoid` reads `~/Workspace/*`). `[]` is none. What the sandbox hides stays hidden inside, and so do the secret files at each project's top there (`.env*`, `.envrc`, `.npmrc`, …). The config fails to load on the same places as `worktreeRoots`; a default that would be your home (a repository directly in it) falls back to none, said on the session. A session can have its own at create (`codeoid new --project-root `, `--no-project-roots`, the new-session dialog); more than the daemon's is a widening (`session:approve`, undelegated). Writing in another directory is per session: `codeoid new --add-dir `, `/sandbox add `, `codeoid sandbox add `, or the agent's `request_directory` ([sandbox.md](sandbox.md#working-across-repositories)).
+- `projectRoots` (`CODEOID_SANDBOX_PROJECT_ROOTS`, comma separated; Settings > Sandbox > "Project roots (readable)") — where sandboxed commands may read other repositories, read-only: absolute dirs (`~` expands) and `@parent`, the parent of the session's repository (the default: a session in `~/Workspace/codeoid` reads `~/Workspace/*`). `[]` is none. What the sandbox hides stays hidden inside, and so do the credential files anywhere there (`.env*`, `*.pem`, `*.key`, private keys, `.npmrc`, kubeconfigs, Terraform state, a `.git/config` with a token in it, …; a root with more than 20000 directories to look through isn't used, said). The config fails to load on the same places as `worktreeRoots`, and on the host's temp dir or anything in it; a default that would be your home (a repository directly in it) or in a temp dir falls back to none, and a workspace in no repository has no default root, said on the session. A session can have its own at create (`codeoid new --project-root `, `--no-project-roots`, the new-session dialog); more than the daemon's is a widening (`session:approve`, undelegated). Writing in another directory is per session: `codeoid new --add-dir `, `/sandbox add `, `codeoid sandbox add `, or the agent's `request_directory` ([sandbox.md](sandbox.md#working-across-repositories)).
- Where the sandbox can't run, interactive sessions run unsandboxed and say so on every approval; autonomous sessions, routines and loops refuse to start. `codeoid sandbox providers` shows what runs here.
### One daemon per data directory
diff --git a/docs/sandbox.md b/docs/sandbox.md
index 0a1449aa..63df5338 100644
--- a/docs/sandbox.md
+++ b/docs/sandbox.md
@@ -797,8 +797,10 @@ the **parent of the session's repository**: a session in `~/Workspace/codeoid`
reads `~/Workspace/*`, so `cat`, `grep`, `git log` and `git status` work in
the repositories beside it, and in the other sessions' checkouts there
(`~/Workspace/codeoid-worktrees/…`). For a linked worktree (a fork's) it is
-the parent of the repository's main checkout; for a workspace that isn't in
-a repository, the workspace's parent.
+the parent of the repository's main checkout. **A workspace that isn't in a
+repository has no default root**, said on the session ("no project root: the
+workspace isn't in a repository…"): a session in `~/Documents/notes` doesn't
+read all of `~/Documents`. Name roots in `sandbox.projectRoots` for it.
- **Set it** daemon-wide with `sandbox.projectRoots`
(`CODEOID_SANDBOX_PROJECT_ROOTS`, comma-separated; Settings > Sandbox >
@@ -814,19 +816,42 @@ a repository, the workspace's parent.
— codeoid's config and data dirs, every agent CLI's login and the other
[credential stores](#what-is-protected), `sandbox.hiddenPaths`, the
desktop's runtime dir, container and (with the network off) local service
- sockets — and the **secret files at the top of each project** under the
- root (the root itself and each dir directly in it): `.env` and `.env.*`
- (not `.env.example`, `.sample`, `.template`, `.dist`, `.defaults`,
- `.schema`), `.envrc`, `.npmrc`, `.pypirc`, `.netrc`, `.git-credentials`.
- Not the session's own: its workspace's and its added directories' are its
- to use. They are found when the sandbox is built: one made later, or deeper
- (`~/Workspace/b/config/.env`), isn't hidden; add such paths to
- `sandbox.hiddenPaths`.
+ sockets — and the **credential files anywhere in the root**, codeoid's own
+ worktrees of the repositories there (`~/Workspace/b-worktrees//.env`)
+ included: `.env` and `.env.*` (not `.env.example`, `.env.sample`,
+ `.env.template`), `.envrc`, `.npmrc`, `.pypirc`, `.netrc`,
+ `.git-credentials`, `.vault-token`, `.pgpass`, `*.pem`, `*.key`, `*.p12`,
+ `*.pfx`, `*.jks`, `*.keystore`, private keys (`id_rsa*`, `id_ed25519*`,
+ `id_ecdsa*`, `id_dsa*`, not `.pub`), kubeconfigs (`kubeconfig*`,
+ `*.kubeconfig`), Terraform state and variables (`*.tfstate*`, `*.tfvars`),
+ `credentials` and `credentials.`,
+ `service-account*.json`, `secret(s).`, a project's
+ `.claude/settings.local.json`, `.mcp.json` and `.docker/config.json`, and
+ `.ssh/`, `.gnupg/`, `.aws/`, `.azure/`, `.gcloud/`, `.kube/`,
+ `.password-store/` dirs whole. A repository's **`.git/config` carrying a
+ credential** (a remote URL with a user or password in it, an
+ `http.*.extraheader`) is hidden too, and said on the session: git in that
+ repository then can't read its config from this session ("unable to access
+ '.git/config'"). The session's own repository is its workspace, never a
+ root's, so its git is unaffected; its added directories' are its to use too.
+ They are looked for through the root's directories (not through links, not
+ in `node_modules`, `.venv`, `__pycache__`, `.tox`, `.gradle`, `.next`,
+ `.turbo`) when the sandbox is built: one made later isn't hidden; add such
+ paths to `sandbox.hiddenPaths`. **The look is bounded** (20000 directories
+ per root): a root with more to look through isn't used at all (fail
+ closed), said on the session ("project root ~/src: more than 20000
+ directories to look through…: not used — name narrower roots"); name the
+ repositories you need instead of the folder holding them all.
- **Refused as a root**: `/`, your home or a dir holding it, anything in a
hidden dir of your home (`~/.config`, `~/.local`, …: where tools load
programs and settings from), `~/Library`, codeoid's config dir, a system
- dir (`/etc`, `/usr`, …), a relative path. The config fails to load on one;
- a session's own is refused at create. **A default that would be your home**
+ dir (`/etc`, `/usr`, …), **the host's temp dir or anything in it**
+ (`/tmp`, `/var/tmp`, the daemon's `TMPDIR`, macOS's `/private/tmp` and
+ `/var/folders`: codeoid's private dir and every program's sockets and
+ scratch files are there), a relative path. The config fails to load on
+ one; a session's own is refused at create. A default in a temp dir (a
+ repository at `/tmp/x/repo`) falls back to no root, said. **A default that
+ would be your home**
(a repository directly in it, `~/proj`) falls back to no root, said on the
session ("no project root: the default, the repository's parent (…), is
your home…"): set `sandbox.projectRoots` to read your other repositories.
@@ -839,13 +864,30 @@ A session can be given **more directories to write**, each treated as its
workspace is: writable, its repository's git structure (config, hooks,
`info/`, the other worktrees' admin dirs, …) and project settings
(`.claude/`, `.mcp.json`, …) held read-only and made first where missing,
+and so are those of **every repository inside it** (a folder of
+repositories: each one's git structure, its project settings at its top, and
+every project settings dir or file and bare repository anywhere in the
+folder, looked for when the sandbox is built, not in `node_modules` and the
+like),
the session's own [worktree folder](#git-worktrees-inside-the-sandbox) in
its repository's locations (`~/Workspace/b-worktrees//`), a
host-side file tool writing its git structure or settings asked about
([held writes](#file-tools-that-run-outside-the-sandbox)), and the
end-of-turn look at its git settings and commits (`sandbox.git_changed`,
-`sandbox.settings_committed`, naming the directory). What the sandbox hides
-stays hidden inside one; its own `.env` is readable.
+`sandbox.settings_committed`, naming the directory; each repository inside
+it too). What the sandbox hides stays hidden inside one; its own `.env` is
+readable.
+
+**An added directory is the directory it was when it was granted.** What is
+kept, and bound, is where the path really leads at that moment (its real
+path): the owner's card names it, and the path asked for when that differs
+("… (`` leads there)"). At every later build of the sandbox, a kept
+directory that no longer is a directory at that real path, or is reached
+through a link now (replaced by one, removed, moved), is **dropped**: not
+writable, said on the session (" is no longer writable: …", event
+`sandbox.dir_dropped`), audited (`session.sandbox_dir_dropped`) and taken
+out of the setting; it is never followed to where a link there leads. Add it
+again if it should be.
**Adding one is a widening**, so it is the owner's own decision:
@@ -863,23 +905,40 @@ stays hidden inside one; its own `.env` is readable.
(`path`, `reason`). In **guarded and interactive mode** the owner gets a
confirm card naming the directory and the agent's reason ("The agent asks
to write "), answered by the owner's own token only; the agent reads
- the answer. In **autonomous mode** a directory under one of the session's
- project roots (inside it, never the root itself) is added at once, unasked (audited: `by=agent under a
- project root, autonomous`), and anything else is refused at once with a
- reason the agent reads ("… isn't under this session's project roots …; the
- owner can add it (`/sandbox add `)"). An unattended run (a routine, a
- loop) is never parked on it: refused, said. The call returns "no answer
+ the answer. In **autonomous mode** only **a repository, or a directory in
+ one, strictly under one of the session's project roots, named by a path
+ with no symbolic link in it** is added at once, unasked (audited: `by=agent
+ a repository under a project root, autonomous`): judged where it really is,
+ and never a root itself, a folder holding repositories, a git or settings
+ dir. Anything else is refused at once with a reason the agent reads ("…
+ isn't in a repository …", "… goes through a symbolic link …", "… isn't
+ under this session's project roots …; the owner can add it (`/sandbox add
+ `)"). **An unattended run** (a routine's, a loop worker's, a
+ dispatched worker's) follows its mode: in autonomous mode (routines'
+ default, every loop worker) the autonomous rule above, so it can be given
+ a repository under a project root unasked; in guarded or interactive mode
+ it is never parked on the question: refused, said. The call returns "no answer
yet" if the owner hasn't answered within 8 minutes (45 seconds on opencode,
whose MCP client gives a call about a minute); the card stays open, a later
yes still adds the directory, and asking again for the same one waits on
that card rather than raising another. Audited:
`session.sandbox_dir_request`, `_added` (`by=agent …`), `_refused`.
-- **Refused as an added dir**: everything refused as a root (above), and a
- dir that isn't there, isn't a directory, isn't yours, is inside what the
- sandbox hides (codeoid's data dirs, a credential store, a
- `sandbox.hiddenPaths` entry, the daemon's temp dir), or holds a dir on
- the daemon's `PATH` (a program written there would run outside the sandbox
- at your next command). One inside the workspace is writable already.
+- **Refused as an added dir**: everything refused as a root (above; the
+ host's temp dir itself, but a dir in it may be added), and a dir that
+ isn't there, isn't a directory, isn't yours, is inside what the sandbox
+ hides (codeoid's data dirs, a credential store, a `sandbox.hiddenPaths`
+ entry, the daemon's private dir), holds a dir on the daemon's `PATH` (a
+ program written there would run outside the sandbox at your next
+ command), **is or is inside a project settings dir** (`.claude/`,
+ `.codex/`, `.gemini/`, `.qwen/`, `.pi/`, `.opencode/`), **a `.git` or
+ `.hg` dir, a git dir** (a bare repository, a worktree's admin dir) **or the
+ repository's `core.hooksPath` dir** (git and the agent CLIs run what is
+ there outside the sandbox), or holds more than 20000 directories to look
+ through for the repositories and settings in it ("… couldn't all be
+ protected — add the repositories you need one by one"). One inside the
+ workspace, the session's own worktree folders or its temp dir, or an added
+ dir, is writable already; adding a dir that holds an added one replaces
+ it.
- **Kept**: in the session's sandbox setting, so a daemon restart and a
resume keep them, and a fork, a compare and a collaboration's children
inherit them (a fork carries them for the parent's owner only, undelegated,
@@ -935,8 +994,18 @@ home is the workspace).
root whole would make the workspace read-only (checked: every command then
fails). A dir made in the root after the query started isn't shown until
the next query.
-- The secret files hidden in a root are those at each project's top when the
- sandbox is built (above).
+- On **Claude's own sandbox**, a link in such a root is left out, not shown
+ by where it leads (the CLI binds real paths: `~/Workspace/keys → ~/.ssh`
+ would show `~/.ssh`), and so is an entry that is itself hidden (a `.env`
+ at the root's top, codeoid's private dir, a `sandbox.hiddenPaths` entry):
+ the CLI lets a readable path equal to a hidden one win. A hidden path
+ deeper in a shown entry stays hidden (checked on the real CLI: it lays a
+ hidden path inside a readable one after it). On bubblewrap a link in a
+ root dangles where it leads into what is hidden.
+- The credential files hidden in a root are those found when the sandbox is
+ built, by name, outside dependency dirs (above); a file of credentials
+ named otherwise (`notes.txt`) is readable. A root too big to look through
+ isn't used.
- The default root shows **every repository beside yours**, read-only: their
source, their git history, and any credential in a file not named above. Set
`sandbox.projectRoots: []` (or `--no-project-roots`) where that is too
@@ -944,6 +1013,11 @@ home is the workspace).
- An added directory that is **another worktree of the session's own
repository** is writable, and its own admin dir with it; the rest of the
repository's structure stays held.
+- The repositories and settings dirs held inside an added directory are those
+ found when the sandbox is built, outside `node_modules` and the like: one
+ the agent makes there later (`git init`, a new `.claude/`) is its own work,
+ as in the workspace (what runs after the agent touched it isn't vouched
+ for).
## Docker access
@@ -1258,7 +1332,20 @@ works, and skip where it doesn't):
around the workspace is readable, the workspace writable, `b` read-only
and its `.env` hidden; added, `b` writable; handing the CLI the root whole
instead makes the workspace read-only (every command fails), which is why
- it is split.
+ it is split. Round 12: real bubblewrap, an added dir swapped for a link
+ after it was granted and a link kept as one aren't written through (the
+ target untouched); a settings or git dir given as one is left out; a
+ folder of repositories added writes their files but not a repository's
+ hooks or config, its `.claude/` or a `.codex/` deeper; credential files
+ anywhere in a root (a key, a `.pem`, a deep `.env`, a tfstate, `.aws/`,
+ `.claude/settings.local.json`, a sibling worktree's `.env`, a `.git/config`
+ with a token: git there then can't read its config) are hidden, `.pub`
+ readable; a root past the look's bound isn't shown; a repository in the
+ host's temp dir gets no default root. The real Claude CLI: a project root
+ shows no `.env` at its top, nothing a link in it leads to (`~/.ssh`,
+ `~/Documents`, the home), no sign-in in codeoid's private dir inside it, no
+ deeper key or sibling worktree's `.env`, while the rest of the root reads
+ and the workspace writes.
- `src/tests/sandbox-codex.test.ts`: the app-server started inside real
bubblewrap can't read a planted credential and can read its own home; codex's
own sandbox (0.166) works nested inside codeoid's bubblewrap; with the real
From 0b72e297eea591a5b2139d0c75ba4ba1d62cca6f Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 16:08:32 +0800
Subject: [PATCH 11/23] fix: #448 round-13 audit: every git config git may read
with a credential, private keys by their usual names, an hgrc, Yarn's and
Composer's credentials are hidden in a project root
The root look checked only /.git/config and named keys id_rsa*-style.
A bare mirror's config, a submodule's under .git/modules, a worktree's
config.worktree, a file a config includes, a credential store in the git
dir, deploy_key / github_ed25519 / *.ppk / *.p8 keys, an hgrc with [auth],
a .yarnrc.yml with npmAuthToken and Composer's auth.json were readable to
every sandboxed session (findings-c 3, findings-s 4 and 5, findings-r 4).
- gitDirSecrets: every config git reads in a git dir (config,
config.worktree, worktrees/*/config.worktree, modules/** at any depth, a
name with a slash too), what they include (include.path,
includeIf.*.path), a store they name, a credential file by name at its
top; a dir with a git dir's shape (a bare repo) is judged so, never
walked as files.
- gitConfigHasCredential: extraheader in every syntax git reads (on the
section's line, under [http ""], any case), an inline helper
printing a password or token, a token in a URL's query.
- Key names: id_*, *_rsa, *_ed25519, *_ecdsa, *_dsa, deploy keys, with no
extension or a key's or backup's, never .pub, never a source file
(id_generator.ts); *.ppk, *.p8.
- By content: .yarnrc.yml, .yarnrc, auth.json; .hg/hgrc.
Tests: sandbox-root-credentials.test.ts (unit), and
sandbox-root-credentials-real.test.ts (real bubblewrap: none of them is
readable through a root; a source file named like a key is).
Co-Authored-By: Claude Opus 5.5
---
src/daemon/sandbox/project-roots.ts | 225 +++++++++++++++---
.../sandbox-root-credentials-real.test.ts | 119 +++++++++
src/tests/sandbox-root-credentials.test.ts | 116 +++++++++
3 files changed, 429 insertions(+), 31 deletions(-)
create mode 100644 src/tests/sandbox-root-credentials-real.test.ts
create mode 100644 src/tests/sandbox-root-credentials.test.ts
diff --git a/src/daemon/sandbox/project-roots.ts b/src/daemon/sandbox/project-roots.ts
index 1faeb9d2..510e3b7d 100644
--- a/src/daemon/sandbox/project-roots.ts
+++ b/src/daemon/sandbox/project-roots.ts
@@ -347,11 +347,13 @@ export function resolveProjectRoots(entries: readonly string[], workspace: strin
/**
* Credential files, by name: dotenv files (not their examples), direnv's,
- * registry and netrc tokens, private keys (not `.pub`), certificates'
- * keystores, kubeconfigs, Terraform state and variables, a vault token,
- * service-account keys, `secrets.*`.
+ * 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)$)[^/]+)?",
@@ -362,8 +364,12 @@ const SECRET_FILE = new RegExp(
"\\.git-credentials",
"\\.vault-token",
"\\.pgpass",
- "[^/]*\\.(?:pem|key|p12|pfx|jks|keystore)",
- "id_(?:rsa|dsa|ecdsa|ed25519)[^/]*(? = 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*:/,
+};
-/** 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 an http extraheader. */
-export function gitConfigHasCredential(file: string): boolean {
- let text = "";
+/** A small regular file's text, or null (not one, too big, unreadable). */
+function smallText(file: string, max = 1 << 20): string | null {
try {
const st = lstatSync(file);
- if (!st.isFile() || st.size > 1 << 20) return st.isFile();
- text = readFileSync(file, "utf8");
+ 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.
+ */
+export function gitConfigHasCredential(file: string): boolean {
+ let st: ReturnType;
+ try {
+ st = lstatSync(file);
} catch {
return false;
}
- return /\b(?:https?|ftps?):\/\/[^\s/"@]+@/i.test(text) || /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:"@]+:[^\s/"@]*@/i.test(text) || /^\s*extraheader\s*=/im.test(text);
+ 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);
+}
+
+function listDirNames(dir: string): string[] {
+ try {
+ return readdirSync(dir, { withFileTypes: true })
+ .filter((e) => e.isDirectory())
+ .map((e) => e.name);
+ } catch {
+ return [];
+ }
+}
+
+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}). */
@@ -405,15 +558,18 @@ export interface RootSecrets {
/**
* 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, a repository's
- * `.git/config` carrying a credential ({@link gitConfigHasCredential}); but
- * in `writable` (the session's own workspace and added dirs: 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.
+ * {@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): RootSecrets {
+export function projectRootSecrets(roots: readonly string[], writable: readonly string[], limit = PROJECT_ROOT_SCAN_LIMIT, home: string = process.env.HOME ?? homedir()): RootSecrets {
const out: RootSecrets = { hidden: [], gitConfigs: [], over: [] };
const mine = writable.flatMap((w) => [resolve(w), realOr(w)]);
const owned = (p: string) => mine.some((w) => within(p, w));
@@ -422,14 +578,19 @@ export function projectRootSecrets(roots: readonly string[], writable: readonly
const top = resolve(root);
if (seenRoots.has(top)) continue;
seenRoots.add(top);
+ const git = { found: [] as string[], configs: [] as string[] };
const found: string[] = [];
- const configs: string[] = [];
const queue = [top];
let seen = 0;
let over = false;
while (queue.length > 0 && !over) {
const d = queue.shift() as string;
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;
+ }
let entries: Dirent[];
try {
entries = readdirSync(d, { withFileTypes: true });
@@ -441,12 +602,9 @@ export function projectRootSecrets(roots: readonly string[], writable: readonly
const at = join(d, e.name);
if (e.isDirectory()) {
if (SECRET_DIRS.has(e.name)) found.push(at);
- else if (e.name === ".git") {
- const config = join(at, "config");
- if (gitConfigHasCredential(config)) {
- found.push(config);
- configs.push(config);
- }
+ 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;
@@ -457,21 +615,26 @@ export function projectRootSecrets(roots: readonly string[], writable: readonly
continue;
}
if (!e.isFile()) continue;
- if (SECRET_FILE.test(e.name) || SECRET_SUFFIXES.some((s) => at.endsWith(s))) found.push(at);
+ 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 {
- out.hidden.push(...found.filter((p) => !owned(p)));
- out.gitConfigs.push(...configs.filter((p) => !owned(p)));
+ // 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, for what is said about it. */
+/** 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 {
- return basename(dirname(config)) === ".git" ? dirname(dirname(config)) : dirname(config);
+ 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). */
diff --git a/src/tests/sandbox-root-credentials-real.test.ts b/src/tests/sandbox-root-credentials-real.test.ts
new file mode 100644
index 00000000..790bfd81
--- /dev/null
+++ b/src/tests/sandbox-root-credentials-real.test.ts
@@ -0,0 +1,119 @@
+/**
+ * Real bubblewrap, skipped where it can't run (#448 round 13): through a
+ * project root, every git config git may read with a credential (a bare
+ * mirror's, a submodule's, a worktree's), an included file, a store in the
+ * git dir, private keys by their usual names, an `hgrc`, a `.yarnrc.yml` and
+ * an `auth.json` holding one are unreadable; a source file named like a key
+ * isn't hidden. What runs inside only reads files this test planted.
+ */
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js";
+import { buildSandboxSpec, type SpecInputs } from "../daemon/sandbox/policy.js";
+import { resolveProjectRoots } from "../daemon/sandbox/project-roots.js";
+import type { SandboxHandle } from "../daemon/sandbox/types.js";
+
+const provider = new BwrapSandboxProvider();
+const probe = await provider.probe();
+
+function scratchParent(): string {
+ for (const dir of [process.env.CODEOID_TEST_SANDBOX_DIR, join(homedir(), ".cache")]) {
+ if (!dir) continue;
+ try {
+ mkdirSync(dir, { recursive: true });
+ return dir;
+ } catch {
+ /* next */
+ }
+ }
+ return tmpdir();
+}
+
+function gitEnv(home: string): Record {
+ return { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.invalid" };
+}
+function git(args: string[], cwd: string, home: string): void {
+ const r = Bun.spawnSync(["git", ...args], { cwd, env: gitEnv(home) });
+ if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`);
+}
+
+describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: credentials git may read in a project root (#448 round 13)", () => {
+ let base: string;
+ beforeAll(() => {
+ base = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-pins-")));
+ });
+ afterAll(() => rmSync(base, { recursive: true, force: true }));
+
+ const sh = (h: SandboxHandle, cwd: string, script: string, env: Record) => h.exec(["/bin/sh", "-c", script], { cwd, env, timeoutMs: 30_000 });
+
+ /** `home/Workspace/{a,b}` (repositories), a planted fake home's secrets beside them, and a spec builder. */
+ function fixture(name: string) {
+ const dir = join(base, name);
+ const home = join(dir, "home");
+ const root = join(home, "Workspace");
+ const a = join(root, "a");
+ const b = join(root, "b");
+ for (const r of [a, b]) {
+ mkdirSync(r, { recursive: true });
+ git(["init", "-q", "-b", "main"], r, home);
+ writeFileSync(join(r, "README.md"), `${r === a ? "alpha" : "bravo"} readme\n`);
+ git(["add", "."], r, home);
+ git(["commit", "-qm", "init"], r, home);
+ }
+ // A planted "home" a link could lead to: its key and its shell startup file.
+ const fakeHome = join(dir, "fake-home");
+ mkdirSync(join(fakeHome, ".ssh"), { recursive: true });
+ writeFileSync(join(fakeHome, ".ssh", "id_ed25519"), "PLANTED-KEY\n");
+ writeFileSync(join(fakeHome, ".bashrc"), "# planted\n");
+ const tmp = join(dir, "tmp");
+ mkdirSync(tmp);
+ const configDir = join(home, ".codeoid");
+ const spec = (extra: Partial = {}) =>
+ buildSandboxSpec({ sessionId: name, workspace: a, tempDir: tmp, network: { mode: "off" }, hidden: { home, configDir, env: { HOME: home }, daemonTmp: "/tmp" }, worktreeFolder: "agent-r13", cache: false, ...extra });
+ return { dir, home, root, a, b, fakeHome, tmp, spec, env: gitEnv(home) };
+ }
+
+ test("every git config git may read with a credential, an included file, a store in the git dir, key names, hgrc, Yarn's, Composer's: unreadable through a root", async () => {
+ const f = fixture("configs");
+ const mirror = join(f.root, "mirror.git");
+ git(["init", "-q", "--bare", mirror], f.root, f.home);
+ git(["remote", "add", "origin", "https://x:LEAK-BARE@example.invalid/m.git"], mirror, f.home);
+ mkdirSync(join(f.b, ".git", "modules", "sub"), { recursive: true });
+ writeFileSync(join(f.b, ".git", "modules", "sub", "config"), '[remote "origin"]\n\turl = https://x:LEAK-SUB@example.invalid/s.git\n');
+ mkdirSync(join(f.b, ".git", "worktrees", "w"), { recursive: true });
+ writeFileSync(join(f.b, ".git", "worktrees", "w", "config.worktree"), "[http] extraheader = AUTHORIZATION: bearer LEAK-WT\n");
+ writeFileSync(join(f.b, "tokens.cfg"), "[http]\n\textraheader = AUTHORIZATION: bearer LEAK-INC\n");
+ git(["config", "include.path", "../tokens.cfg"], f.b, f.home);
+ writeFileSync(join(f.b, ".git", "credentials"), "https://x:LEAK-STORE@example.invalid\n");
+ for (const k of ["github_ed25519", "server.ppk", "AuthKey_X.p8", "id_work"]) writeFileSync(join(f.b, k), `LEAK-KEY-${k}\n`);
+ writeFileSync(join(f.b, "id_generator.ts"), "export const fine = 1;\n");
+ const c = join(f.root, "c");
+ mkdirSync(join(c, ".hg"), { recursive: true });
+ writeFileSync(join(c, ".hg", "hgrc"), "[auth]\nx.prefix = example.invalid\nx.password = LEAK-HG\n");
+ writeFileSync(join(c, ".yarnrc.yml"), 'npmAuthToken: "LEAK-YARN"\n');
+ writeFileSync(join(c, "auth.json"), '{"github-oauth":{"github.com":"LEAK-COMPOSER"}}\n');
+ const roots = resolveProjectRoots(["@parent"], f.a, { home: f.home }).roots;
+ const h = await provider.open(f.spec({ projectRoots: roots }));
+ try {
+ const files = [
+ join(mirror, "config"),
+ join(f.b, ".git", "modules", "sub", "config"),
+ join(f.b, ".git", "worktrees", "w", "config.worktree"),
+ join(f.b, "tokens.cfg"),
+ join(f.b, ".git", "credentials"),
+ ...["github_ed25519", "server.ppk", "AuthKey_X.p8", "id_work"].map((k) => join(f.b, k)),
+ join(c, ".hg", "hgrc"),
+ join(c, ".yarnrc.yml"),
+ join(c, "auth.json"),
+ ];
+ const r = await sh(h, f.a, `${files.map((p) => `cat '${p}' 2>/dev/null`).join("; ")}; cat ${f.b}/id_generator.ts`, f.env);
+ expect(r.stdout.match(/LEAK-[A-Z0-9_.-]+/g) ?? []).toEqual([]);
+ expect(r.stdout).toContain("export const fine");
+ } finally {
+ await h.close();
+ }
+ });
+
+});
diff --git a/src/tests/sandbox-root-credentials.test.ts b/src/tests/sandbox-root-credentials.test.ts
new file mode 100644
index 00000000..88d58506
--- /dev/null
+++ b/src/tests/sandbox-root-credentials.test.ts
@@ -0,0 +1,116 @@
+/**
+ * Credentials git may read in a project root, and credential files found by
+ * name or by what is in them (#448 round 13; the real-bwrap side is
+ * sandbox-root-credentials-real.test.ts): every `extraheader` syntax git
+ * reads, an inline helper printing a password, a token in a URL's query,
+ * what a config includes; a bare mirror's, a submodule's and a worktree's
+ * config, a credential store in the git dir, private keys by their usual
+ * names (never a source file), a Mercurial `hgrc`, Yarn's and Composer's.
+ */
+
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { gitConfigHasCredential, gitConfigIncludes, projectRootSecrets } from "../daemon/sandbox/project-roots.js";
+
+let base: string;
+beforeAll(() => {
+ const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache");
+ mkdirSync(parent, { recursive: true });
+ base = realpathSync(mkdtempSync(join(parent, "codeoid-pins-")));
+});
+afterAll(() => rmSync(base, { recursive: true, force: true }));
+
+let n = 0;
+function scratch(): string {
+ const d = join(base, `t${++n}`);
+ mkdirSync(d, { recursive: true });
+ return d;
+}
+
+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" });
+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()}`);
+}
+function repo(dir: string): string {
+ mkdirSync(dir, { recursive: true });
+ git(["init", "-q", "-b", "main"], dir);
+ writeFileSync(join(dir, "README.md"), "x\n");
+ git(["add", "."], dir);
+ git(["commit", "-qm", "i"], dir);
+ return dir;
+}
+
+describe("credentials git may read in a project root", () => {
+ test("every extraheader syntax, an inline helper with a password, a token in a URL's query; a plain config isn't", () => {
+ const d = scratch();
+ const cases: Record = {
+ headerLine: ["[http] extraheader = AUTHORIZATION: bearer T1\n", true],
+ headerUrl: ['[http "https://example.invalid/"]\n\textraHeader = Authorization: Basic T\n', true],
+ headerCase: ["[http]\n\tExtraHeader=x\n", true],
+ helperInline: ['[credential]\n\thelper = "!f() { echo password=T3; }; f"\n', true],
+ urlSameLine: ['[remote "o"] url = https://u:T2@h/x\n', true],
+ sshPassword: ['[remote "o"]\n\turl = ssh://u:pw@h/x\n', true],
+ queryToken: ['[remote "o"]\n\turl = https://h/x.git?private_token=T4\n', true],
+ plain: ['[remote "o"]\n\turl = https://example.invalid/x.git\n[credential]\n\thelper = osxkeychain\n', false],
+ sshUser: ['[remote "o"]\n\turl = ssh://git@example.invalid/x.git\n', false],
+ };
+ for (const [name, [text, expected]] of Object.entries(cases)) {
+ writeFileSync(join(d, name), text);
+ expect([name, gitConfigHasCredential(join(d, name))]).toEqual([name, expected]);
+ }
+ writeFileSync(join(d, "inc"), '[include]\n\tpath = ../tokens.cfg\n[includeIf "gitdir:~/w/"]\n\tpath = "/abs/x.cfg"\n[include] path = ~/y.cfg\n');
+ expect(gitConfigIncludes(join(d, "inc"), "/h")).toEqual([join(base, "tokens.cfg"), "/abs/x.cfg", "/h/y.cfg"]);
+ });
+
+ test("a bare mirror's, a submodule's, a worktree's config.worktree, an included file, a store in the git dir, key names, hgrc, Yarn's and Composer's: hidden; the rest isn't", () => {
+ const d = scratch();
+ const root = join(d, "W");
+ const a = repo(join(root, "a"));
+ const b = repo(join(root, "b"));
+ const mirror = join(root, "mirror.git");
+ git(["init", "-q", "--bare", mirror], root);
+ git(["remote", "add", "origin", "https://x:BARE@example.invalid/m.git"], mirror);
+ mkdirSync(join(b, ".git", "modules", "libs", "sub"), { recursive: true });
+ writeFileSync(join(b, ".git", "modules", "libs", "sub", "config"), '[remote "origin"]\n\turl = https://x:SUB@example.invalid/s.git\n');
+ mkdirSync(join(b, ".git", "worktrees", "w"), { recursive: true });
+ writeFileSync(join(b, ".git", "worktrees", "w", "config.worktree"), "[http] extraheader = AUTHORIZATION: bearer WT\n");
+ writeFileSync(join(b, "tokens.cfg"), "[http]\n\textraheader = AUTHORIZATION: bearer INC\n");
+ git(["config", "include.path", "../tokens.cfg"], b);
+ git(["config", "credential.helper", "store --file=.git/creds-store"], b);
+ writeFileSync(join(b, ".git", "creds-store"), "https://x:STORE@example.invalid\n");
+ writeFileSync(join(b, ".git", "credentials"), "https://x:STORE2@example.invalid\n");
+ const keys = ["deploy_key", "github_ed25519", "id_work", "id_rsa.old", "server.ppk", "AuthKey_ABC.p8", "x_deploy_key"];
+ const notKeys = ["id_generator.ts", "crypto_rsa.go", "id_rsa.pub", "deploy_key.pub", "README.md"];
+ for (const f of [...keys, ...notKeys]) writeFileSync(join(b, f), "k\n");
+ const c = join(root, "c");
+ mkdirSync(join(c, ".hg"), { recursive: true });
+ writeFileSync(join(c, ".hg", "hgrc"), "[auth]\nx.prefix = example.invalid\nx.password = HG\n");
+ writeFileSync(join(c, ".yarnrc.yml"), 'npmAuthToken: "YARN"\n');
+ writeFileSync(join(c, "auth.json"), '{"github-oauth":{"github.com":"COMPOSER"}}\n');
+ const plain = join(root, "plain");
+ mkdirSync(plain);
+ writeFileSync(join(plain, ".yarnrc.yml"), "nodeLinker: node-modules\n");
+ writeFileSync(join(plain, "auth.json"), '{"user":"x"}\n');
+ const found = projectRootSecrets([root], [a]);
+ const expected = [
+ join(mirror, "config"),
+ join(b, ".git", "modules", "libs", "sub", "config"),
+ join(b, ".git", "worktrees", "w", "config.worktree"),
+ join(b, "tokens.cfg"),
+ join(b, ".git", "creds-store"),
+ join(b, ".git", "credentials"),
+ ...keys.map((k) => join(b, k)),
+ join(c, ".hg", "hgrc"),
+ join(c, ".yarnrc.yml"),
+ join(c, "auth.json"),
+ ];
+ for (const p of expected) expect([p, found.hidden.includes(p)]).toEqual([p, true]);
+ for (const p of [...notKeys.map((k) => join(b, k)), join(plain, ".yarnrc.yml"), join(plain, "auth.json"), join(b, ".git", "config")]) expect([p, found.hidden.includes(p)]).toEqual([p, false]);
+ expect(found.gitConfigs).toContain(join(mirror, "config"));
+ expect(found.gitConfigs).toContain(join(b, "tokens.cfg"));
+ });
+
+});
From b413641c4d1e4c3a92c2a4ce5bea8261f3f5e0da Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 16:08:55 +0800
Subject: [PATCH 12/23] fix: #448 round-13 audit: a directory the sandbox binds
by name is pinned, and one swapped for a link is refused, never followed; a
link-named project root is used where it really is
bubblewrap (and Claude's own sandbox, and every launcher script) bound the
workspace, added dirs and roots by path at each command, so a directory
moved aside and replaced by a link between commands was bound where the
link led: the home, writable (findings-s 1 CRITICAL, findings-s 2 and
findings-r 1 HIGH). A root named through a link was hidden by the link's
path and shown by its real one (findings-c 2).
- sandbox/pins.ts: every bound directory (the workspace, a monorepo's top,
the working directory, added dirs, roots, worktree folders) is pinned by
real path and dev:ino: taken once (the session's first resolution, a
grant, a root's first resolution; SessionSandboxSetting.pins, kept by the
daemon, refused from clients by the schema), the inode alone across a
boot.
- Checked at every buildSandboxSpec, every LocalSandboxHandle.wrap
(bubblewrap, Seatbelt, Docker), again in the launched argv itself
(pinGuardArgv: a launcher written once, a process backend's, opencode's
shell launcher, the git shim, checks at each run), in
claudeSandboxSettings, and at the gate before every tool call
(Session#sandboxPinRefusal: refused, said once, audited). A changed
added dir or root is dropped at the next turn's resolution (said); a
changed workspace refuses every turn until it is put back. bubblewrap
binds a pinned path as stored, never realpath'd at spawn.
- The dirs between nested writable roots (a workspace inside an added dir)
are pinned (nestedRootAncestors), so no command moves them aside. The
same filter in protectedAncestors and bwrap's pinned loop skipped every
pin in the hidden home, so a hidden file's dirs were never pinned in a
workspace under ~: fixed. Claude's denyWrite leaves out a pinned dir
holding a writable one (it would take the workspace with it).
- An added dir inside another isn't kept twice (canonicalAddedDirs); a
grant again is pinned anew.
- resolveProjectRoots and buildSandboxSpec use a root by its real path
(said), pinned there.
Tests: sandbox-pins.test.ts (unit), sandbox-pins-real.test.ts (real
bubblewrap: the workspace, an added dir and a root swapped for a link to a
planted home between commands are refused by the handle and by a launcher
written before; a workspace in an added dir can't have its parent moved;
a link-named root's .env hidden), sandbox-pins-manager.test.ts (the gate
and the turn start), the real Claude CLI (a link-named root holding the
workspace: read, hidden, the workspace writable; every command used to
fail), opencode 2.2.5 I69 (the workspace swapped after the turn's launcher
was written: the line refused).
Co-Authored-By: Claude Opus 5.5
---
packages/protocol/src/types.ts | 8 +
src/daemon/sandbox/bwrap.ts | 12 +-
src/daemon/sandbox/claude-native.ts | 14 +-
src/daemon/sandbox/local-handle.ts | 11 +-
src/daemon/sandbox/pins.ts | 194 +++++++++++++
src/daemon/sandbox/policy.ts | 84 +++++-
src/daemon/sandbox/project-roots.ts | 7 +-
src/daemon/sandbox/session-sandbox.ts | 266 +++++++++++++++---
src/daemon/sandbox/types.ts | 10 +
src/daemon/session.ts | 60 +++-
src/tests/fixtures/claude-sandbox-probe.ts | 3 +-
src/tests/fixtures/sandbox-manager-harness.ts | 244 ++++++++++++++++
.../provider-opencode.integration.test.ts | 22 +-
src/tests/sandbox-claude-real.test.ts | 19 ++
src/tests/sandbox-pins-manager.test.ts | 76 +++++
src/tests/sandbox-pins-real.test.ts | 180 ++++++++++++
src/tests/sandbox-pins.test.ts | 249 ++++++++++++++++
src/tests/sandbox-session.test.ts | 14 +-
18 files changed, 1410 insertions(+), 63 deletions(-)
create mode 100644 src/daemon/sandbox/pins.ts
create mode 100644 src/tests/fixtures/sandbox-manager-harness.ts
create mode 100644 src/tests/sandbox-pins-manager.test.ts
create mode 100644 src/tests/sandbox-pins-real.test.ts
create mode 100644 src/tests/sandbox-pins.test.ts
diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts
index a2c36772..38d69cb1 100644
--- a/packages/protocol/src/types.ts
+++ b/packages/protocol/src/types.ts
@@ -402,6 +402,14 @@ export interface SessionSandboxSetting {
* `request_directory`).
*/
addedDirs?: string[];
+ /**
+ * Kept by the daemon, never sent by a client (the schema refuses it): what
+ * each directory the sandbox binds by name was when it was pinned (#448
+ * round 13): `workspace` (the sandbox's workspace, a monorepo's top) as
+ * found for `workdir` (another session's, a fork's, isn't used), and
+ * `ids`, by real path, `dev:ino@boot`.
+ */
+ pins?: { workdir?: string; workspace?: string; ids: Record };
}
export type SandboxNetworkMode = "on" | "off" | "allowlist";
diff --git a/src/daemon/sandbox/bwrap.ts b/src/daemon/sandbox/bwrap.ts
index 85a08d38..7293dd29 100644
--- a/src/daemon/sandbox/bwrap.ts
+++ b/src/daemon/sandbox/bwrap.ts
@@ -51,7 +51,7 @@ export function pathKind(p: string): PathKind {
}
/** The real path when it exists, else the path. */
-function realOr(p: string): string {
+function realOrPath(p: string): string {
try {
return realpathSync(p);
} catch {
@@ -90,6 +90,10 @@ export function bwrapArgs(spec: SandboxSpec, cwd: string, kind: (p: string) => P
const mounts: Mount[] = [];
const seen = new Set();
+ // A pinned directory is bound by the path it was pinned at, never by where a link there leads (pins.ts: the
+ // handle checked it is still that directory, and the launched argv checks again).
+ const pinned = new Set((spec.pins ?? []).map((p) => p.path));
+ const realOr = (p: string) => (pinned.has(p) ? p : realOrPath(p));
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) => {
@@ -131,8 +135,10 @@ export function bwrapArgs(spec: SandboxSpec, cwd: string, kind: (p: string) => P
// 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 ?? []) {
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 });
}
diff --git a/src/daemon/sandbox/claude-native.ts b/src/daemon/sandbox/claude-native.ts
index f4c65f26..ec66de37 100644
--- a/src/daemon/sandbox/claude-native.ts
+++ b/src/daemon/sandbox/claude-native.ts
@@ -36,6 +36,7 @@ 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 "./pins.js";
import type { SandboxRuntime } from "./session-sandbox.js";
import type { SandboxSpec } from "./types.js";
@@ -130,6 +131,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 (pins.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"
@@ -161,7 +165,9 @@ 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.
+ 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 : [])]),
@@ -184,6 +190,12 @@ 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);
diff --git a/src/daemon/sandbox/local-handle.ts b/src/daemon/sandbox/local-handle.ts
index 883ca801..bc3a5aff 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 (pins.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 "./pins.js";
import type { SandboxExecOptions, SandboxExecResult, SandboxHandle, SandboxSpawnOptions, SandboxSpec } from "./types.js";
export type ArgvWrapper = (argv: readonly string[], opts: SandboxSpawnOptions) => { argv: string[]; env: Record };
@@ -30,7 +34,10 @@ 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 {
diff --git a/src/daemon/sandbox/pins.ts b/src/daemon/sandbox/pins.ts
new file mode 100644
index 00000000..00677043
--- /dev/null
+++ b/src/daemon/sandbox/pins.ts
@@ -0,0 +1,194 @@
+/**
+ * Every directory a sandbox binds by name, pinned (#448 round 13,
+ * docs/sandbox.md "Pinned directories"): the workspace (a monorepo's
+ * repository top), the working directory, every added directory, every
+ * project root, the session's worktree folders.
+ *
+ * Each is kept by its real path, taken once (when the session is created,
+ * the directory granted, the root first resolved), with its identity
+ * (`dev:ino`, {@link pinRecord}). 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) and every tool call at the gate first checks that the path is
+ * still that directory, reached through no symbolic link
+ * ({@link pinProblem}). One that isn't is never followed: the build, the
+ * launch or the call is refused with what changed, and the session says it.
+ * A bind by name would otherwise lay what a link put there leads to (the
+ * home) writable inside the sandbox.
+ */
+
+import { lstatSync, readFileSync, realpathSync } from "node:fs";
+import { resolve } from "node:path";
+
+/** One pinned directory of a spec (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 a directory reached through no link (one codeoid makes and removes). */
+ id?: string;
+ /** It may be missing (then nothing is bound there): the working directory, a worktree folder. */
+ mayBeMissing?: boolean;
+}
+
+/** Raised when a pinned directory isn't what it was: the build or the launch 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` (`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.
+ */
+export function pinProblem(p: string, what: string, opts: { stored?: string; id?: string; mayBeMissing?: boolean } = {}): string | null {
+ const at = resolve(p);
+ const link = linkIn(at);
+ if (link) return `${what} ${at} is reached through a symbolic link now (${link})`;
+ let st: ReturnType;
+ try {
+ st = lstatSync(at);
+ } catch {
+ return opts.mayBeMissing ? null : `${what} ${at} is gone`;
+ }
+ if (!st.isDirectory()) return `${what} ${at} isn't a directory now`;
+ try {
+ if (realpathSync(at) !== at) return `${what} ${at} isn't at its real path now`;
+ } catch {
+ return `${what} ${at} can't be resolved now`;
+ }
+ const now = `${st.dev}:${st.ino}`;
+ if (opts.stored && !sameDir(opts.stored, now)) return `${what} ${at} isn't the directory it was (another one is there now)`;
+ if (opts.id && opts.id !== now) return `${what} ${at} isn't the directory it was (another one is there now)`;
+ return null;
+}
+
+/** 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 } : {}) });
+ 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`;
+}
+
+/** 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 `:
+ * each path must be a directory whose physical path is itself (no link on
+ * the way: `cd -P`), of `dev:ino` `` unless that is `-` (`stat` prints
+ * it: the absolute program found when the script is made; without one only
+ * the rest is checked), present unless `` is 1; 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, `'\\''`)}'`;
+ return [
+ "n=$1; shift",
+ 'while [ "$n" -gt 0 ]; do',
+ ' p=$1; id=$2; m=$3; shift 3; n=$((n - 1))',
+ ' if [ ! -e "$p" ] && [ ! -L "$p" ]; then',
+ ' [ "$m" = 1 ] && continue',
+ ' printf "codeoid sandbox: %s is gone: not run\\n" "$p" >&2; exit 126',
+ " fi",
+ ' r=$(cd -P -- "$p" 2>/dev/null && pwd -P) || r=',
+ ' if [ "$r" != "$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',
+ ...(stat
+ ? [` if [ "$id" != - ] && [ "$(${stat.map(quote).join(" ")} -- "$p" 2>/dev/null)" != "$id" ]; then printf "codeoid sandbox: %s is not the directory it was (another one is there now): not run\\n" "$p" >&2; exit 126; fi`]
+ : []),
+ "done",
+ '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];
+ return ["/bin/sh", "-c", pinGuardScript(statProgram()), PIN_GUARD_NAME, String(pins.length), ...pins.flatMap((p) => [p.path, p.id ?? "-", p.mayBeMissing ? "1" : "0"]), ...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 860bba12..355d8f2a 100644
--- a/src/daemon/sandbox/policy.ts
+++ b/src/daemon/sandbox/policy.ts
@@ -34,6 +34,7 @@ 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 { type PathPin, dirIdentity, pinProblem, pinRefusal, SandboxPinError } from "./pins.js";
import { controlDirProblem, nestedControl, type NestedControl, projectRootSecrets, stillRealDir } from "./project-roots.js";
import { isOwnWorktree, worktreeAdminsOf, worktreeFoldersOf } from "./worktrees.js";
import type { NetworkMode, NetworkPolicy, SandboxSpec } from "./types.js";
@@ -470,6 +471,14 @@ export interface SpecInputs {
addedDirs?: readonly { dir: string; ownWorktreeEntries?: readonly string[] }[];
/** 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 (pins.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 home a spec hides: absolute, and not `/` (nothing is left to show then). */
@@ -1091,13 +1100,26 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
const workspace = resolve(inputs.workspace);
const sandboxCfg = sandboxConfigOf(inputs.config);
const configDir = inputs.hidden?.configDir ?? getConfigDir();
+ // Every directory bound by name, checked first (#448 round 13, pins.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;
+ if (workdir && workdir !== workspace) refuse(pinProblem(workdir, "the working directory", { 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).
+ // 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) => stillRealDir(a.dir) && controlDirProblem(a.dir, home) === null)
+ .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 =>
@@ -1151,9 +1173,17 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
const inHidden = (p: string) => [...baseHidden, ...(root ? [root] : [])].some((h) => within(p, h) || within(realOr(p), realOr(h)));
const addedDirs = added.map((a) => a.dir).filter((d) => !inHidden(d) && !userHidden.some((u) => within(d, u)));
const writableDirs = [workspace, ...addedDirs];
+ // 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 (not the session's own: its workspace, its added dirs); a root too big
// to look through whole isn't shown at all (fail closed: resolveSandbox says so).
- const rootCandidates = (inputs.projectRoots ?? []).map((r) => resolve(r)).filter((r, i, all) => all.indexOf(r) === i && !userHidden.some((u) => within(r, u)) && !inHidden(r));
const rootSecrets = projectRootSecrets(rootCandidates, writableDirs, inputs.scanLimit);
const roots = rootCandidates.filter((r) => !rootSecrets.over.includes(r));
// The worktree locations shown (other sessions' checkouts of this repository, their copied `.env`s): the same.
@@ -1192,6 +1222,18 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
// 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 is bound by name, as it is now (pins.ts): checked again at every launch.
+ const pinOf = (path: string, what: string, mayBeMissing = false): PathPin => {
+ const id = mayBeMissing ? null : dirIdentity(path);
+ return { path, what, ...(id ? { id } : {}), ...(mayBeMissing || !id ? { mayBeMissing: true } : {}) };
+ };
+ const pins: PathPin[] = [
+ pinOf(workspace, "the workspace"),
+ ...(workdir && workdir !== workspace ? [pinOf(workdir, "the working directory", true)] : []),
+ ...addedDirs.map((d) => pinOf(d, "added directory")),
+ ...roots.map((r) => pinOf(r, "project root")),
+ ...locations.map((f) => pinOf(f, "worktree folder", 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)]));
@@ -1207,7 +1249,8 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
...(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, ...addedDirs, ...locations]),
+ pinned: [...new Set([...protectedAncestors(hidden, [workspace, tempDir, ...own, ...addedDirs, ...locations]), ...nestedRootAncestors([workspace, tempDir, ...addedDirs, ...locations], hidden)])],
+ pins,
reexposed,
containerSockets: containerSocketPaths(home, env).filter((p) => !exposed.has(p) && !exposed.has(realOr(p))),
...((inputs.hideLocalServices ?? inputs.network.mode !== "on") ? { serviceSockets: localServiceSocketPaths() } : {}),
@@ -1264,10 +1307,35 @@ 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);
}
}
diff --git a/src/daemon/sandbox/project-roots.ts b/src/daemon/sandbox/project-roots.ts
index 510e3b7d..c72fc4a7 100644
--- a/src/daemon/sandbox/project-roots.ts
+++ b/src/daemon/sandbox/project-roots.ts
@@ -306,6 +306,8 @@ export interface ResolvedRoots {
* 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());
@@ -337,10 +339,13 @@ export function resolveProjectRoots(entries: readonly string[], workspace: strin
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);
- out.roots.push(at);
+ if (real !== at && !isDefault) out.notes.push(`project root ${entry} is ${real} (where it really is): read there`);
+ out.roots.push(real);
}
return out;
}
diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts
index b18bcc3a..2b5509a1 100644
--- a/src/daemon/sandbox/session-sandbox.ts
+++ b/src/daemon/sandbox/session-sandbox.ts
@@ -38,6 +38,7 @@ import {
stillRealDir,
} from "./project-roots.js";
import { ENTRY_GRACE_MS, WorktreeEntryGuard, type WorktreeEntryFinding } from "./worktree-entries.js";
+import { pinProblem, pinRecord, pinRefusal, pinsProblem, SandboxPinError, unguardedArgv } from "./pins.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";
@@ -177,29 +178,58 @@ export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxS
/**
* `setting` with each added directory `before` didn't have kept by where it
- * really is now, when it is granted (project-roots.ts `addedDirReal`): what
- * is bound is that directory, never where a link there leads later. One
- * already kept stays as it was (a link put there since is dropped at the
- * next build, never followed).
+ * really is now, when it is granted (project-roots.ts `addedDirReal`), and
+ * pinned there (pins.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).
*/
export function canonicalAddedDirs(setting: SessionSandboxSetting, before?: SessionSandboxSetting): SessionSandboxSetting {
if (!setting.addedDirs) return setting;
const had = new Set(before?.addedDirs ?? []);
- const dirs = setting.addedDirs.map((d) => (had.has(d) ? d : (addedDirReal(d) ?? d)));
- return { ...setting, addedDirs: [...new Set(dirs)] };
+ 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 { pins: _p, ...rest } = setting;
+ const pins = setting.pins ?? before?.pins;
+ return {
+ ...rest,
+ addedDirs: dirs,
+ ...(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"] as const;
function withoutDirs(setting: SessionSandboxSetting | undefined): string {
- const { projectRoots: _r, addedDirs: _a, ...rest } = setting ?? {};
+ // `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, 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) && DIR_FIELDS.some((k) => JSON.stringify(before?.[k] ?? null) !== JSON.stringify(after?.[k] ?? null));
+ 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". */
@@ -272,23 +302,41 @@ export function workspaceProblem(workspace: string): string | null {
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 (pins.ts), by real path: a root or added dir seen for the first time. */
+ pinned: Record;
+}
+
/**
* 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), or can't be one now, is `dropped`, said. A root 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.
+ * there, removed), another directory than the one pinned there
+ * (`opts.pins`, pins.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 function sandboxDirsOf(
top: string,
rootEntries: readonly string[],
addedDirs: readonly string[],
config?: CodeoidConfig,
-): { roots: string[]; added: string[]; notes: string[]; homeWorkspace: boolean; dropped: Array<{ dir: string; why: string }> } {
+ opts: { pins?: Readonly> } = {},
+): SandboxDirs {
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)];
@@ -297,15 +345,38 @@ export function sandboxDirsOf(
const dropped: Array<{ dir: string; why: string }> = [];
for (const d of addedDirs) {
if (resolve(d) === resolve(top) || resolve(d).startsWith(`${resolve(top)}/`)) continue;
- const problem = 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"})`;
+ 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 });
- } else added.push(resolve(d));
+ 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;
+ }
}
- let roots = homeWorkspace ? [] : r.roots;
if (roots.length > 0) {
const secrets = projectRootSecrets(roots, [top, ...added]);
for (const over of secrets.over) {
@@ -319,7 +390,7 @@ export function sandboxDirsOf(
}
if (secrets.gitConfigs.length > 5) notes.push(`and ${secrets.gitConfigs.length - 5} more repositories' git configs holding a credential: hidden`);
}
- return { roots, added, notes, homeWorkspace, dropped };
+ return { roots, added, notes, homeWorkspace, dropped, pinned };
}
/** The repositories an added dir holds besides its own (a folder of them): the end-of-turn git look covers each. */
@@ -337,7 +408,17 @@ export async function resolveSandbox(opts: {
workspace?: string;
/** The daemon's whole config: what an added directory may not be in (its data dirs: policy.ts `hiddenPathsFor`). */
codeoidConfig?: CodeoidConfig;
-}): Promise<{ info: SessionSandboxInfo; provider: SandboxProvider | null; plan: ConfinementPlan | null; network: NetworkPolicy; droppedDirs?: Array<{ dir: string; why: string }> }> {
+ /** 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;
+}> {
const { choice, network, dockerSocket, projectRoots, addedDirs } = effectiveSetting(opts.config, opts.setting);
const base = {
choice,
@@ -367,7 +448,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();
@@ -386,7 +467,10 @@ export async function resolveSandbox(opts: {
const layout = top !== null && opts.config.worktreeRoots.length > 0 ? gitLayoutOf(top) : null;
const rootNotes = layout ? worktreeRootNotes(layout, { roots: opts.config.worktreeRoots, configDir: getConfigDir() }) : [];
// Working across repositories: the project roots it reads, the directories it writes besides its workspace.
- const dirs = top !== null ? sandboxDirsOf(top, projectRoots, addedDirs, opts.codeoidConfig) : { roots: [], added: [], notes: [], homeWorkspace: false, dropped: [] };
+ const dirs: SandboxDirs =
+ top !== null
+ ? sandboxDirsOf(top, projectRoots, addedDirs, opts.codeoidConfig, { ...(opts.setting?.pins ? { pins: opts.setting.pins.ids } : {}) })
+ : { 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 {
@@ -410,6 +494,7 @@ export async function resolveSandbox(opts: {
plan: c.plan,
network: applied,
...(dirs.dropped.length > 0 ? { droppedDirs: dirs.dropped } : {}),
+ ...(Object.keys(dirs.pinned).length > 0 ? { pinned: dirs.pinned } : {}),
};
}
@@ -450,6 +535,10 @@ export class SessionSandbox {
#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;
#entriesRecheck: ReturnType | null = null;
@@ -532,15 +621,93 @@ 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 {
- const next = setting && Object.keys(setting).length > 0 ? canonicalAddedDirs({ ...setting }, this.#setting) : undefined;
- // Only which directories are read or written: the runtime stands until Session applies it between turns.
- if (onlyDirsChanged(this.#setting, next) && !this.#stale) this.#dirsStale = true;
- else 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 when it isn't (a new session, or one from before pins): as it is now, at its real path. */
+ #pinWorkspace(): void {
+ const pins = this.#setting?.pins;
+ const workdir = resolve(this.#workdir);
+ if (pins?.workspace && pins.workdir === workdir) 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];
+ ids[workspace] = record;
+ this.#setting = { ...(this.#setting ?? {}), pins: { workdir, workspace, ids } };
+ this.#pinsChanged = true;
+ }
+
+ /** 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: pins.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", { 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) ??
+ pinsProblem((this.#lastSpec?.pins ?? []).filter((p) => p.what === "worktree folder"));
+ 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
@@ -548,7 +715,7 @@ export class SessionSandbox {
* rebuilt mid-work would end it; a process backend is restarted).
*/
get dirsPending(): boolean {
- return this.#dirsStale;
+ return this.#dirsStale || this.#pinsBroken;
}
/** The added directories the running sandbox has (SandboxSpec.addedDirs as resolved): what host-side file tools are held to. */
@@ -576,8 +743,10 @@ export class SessionSandbox {
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, ...rest } = this.#setting;
- this.#setting = kept.length > 0 ? { ...rest, addedDirs: kept } : Object.keys(rest).length > 0 ? rest : undefined;
+ const { addedDirs: _old, pins, ...rest } = this.#setting;
+ const ids = Object.fromEntries(Object.entries(pins?.ids ?? {}).filter(([k]) => !gone.has(k)));
+ const base: SessionSandboxSetting = { ...rest, ...(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)));
}
@@ -606,9 +775,20 @@ 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.#config ? { codeoidConfig: this.#config } : {}) });
+ 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;
if (this.#closed) {
this.#runtime = null;
return r.info;
@@ -745,7 +925,8 @@ 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.
+ program = handle.wrap ? (unguardedArgv(handle.wrap([GIT_BIN], { cwd: "", env: {} }).argv)[0] ?? null) : null;
}
return writeGitShim(dir, { launcher, provider: program, caller, home: homedir() });
}
@@ -791,15 +972,15 @@ export class SessionSandbox {
hideLocalServices = network.mode !== "on",
dirs: { roots: readonly string[]; added: readonly string[] } = this.#dirs,
): SandboxSpec {
- // A monorepo's package: the repository's top (#448 round 9); the session's cwd stays the workdir.
- const workspace = sandboxWorkspaceOf(this.#workdir);
+ // 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 spec = buildSandboxSpec({
+ const build = () => buildSandboxSpec({
backendReadable: readable,
sessionId: this.#sessionId,
workspace,
@@ -816,7 +997,16 @@ export class SessionSandbox {
const guard = this.#guardFor(dir);
return { dir, ...(folder && guard ? { ownWorktreeEntries: guard.ownEntries() } : {}) };
}),
+ ...(this.#setting?.pins ? { pins: this.#setting.pins.ids } : {}),
});
+ let spec: SandboxSpec;
+ try {
+ spec = build();
+ } catch (err) {
+ // A pinned directory changed (pins.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;
return spec;
@@ -826,7 +1016,7 @@ export class SessionSandbox {
#guardFor(dir: string): WorktreeEntryGuard | null {
const common = gitLayoutOf(dir)?.common;
if (!common) return null;
- const mine = gitLayoutOf(sandboxWorkspaceOf(this.#workdir))?.common;
+ const mine = gitLayoutOf(this.#workspace())?.common;
if (mine && resolve(mine) === resolve(common)) return this.#entries;
let g = this.#addedEntries.get(resolve(common));
if (!g) {
@@ -845,13 +1035,13 @@ export class SessionSandbox {
*/
checkWorktreeEntries(): void {
if (this.#closed || !this.#runtime) return;
- if (!this.#worktreeFolderName(sandboxWorkspaceOf(this.#workdir), this.#dirs.added)) 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(sandboxWorkspaceOf(this.#workdir), this.#entries);
+ 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. */
@@ -895,7 +1085,7 @@ export class SessionSandbox {
*/
prepareFolders(): void {
if (this.#closed || this.#folders.size === 0 || [...this.#folders].every((f) => existsSync(f))) return;
- for (const workspace of [sandboxWorkspaceOf(this.#workdir), ...this.#dirs.added]) {
+ for (const workspace of [this.#workspace(), ...this.#dirs.added]) {
try {
const layout = gitLayoutOf(workspace);
if (!layout || !this.#folderName) continue;
diff --git a/src/daemon/sandbox/types.ts b/src/daemon/sandbox/types.ts
index 6ecc323a..ff2894cc 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 "./pins.js";
/** How strong the boundary is: OS namespaces/profiles, a separate kernel (gVisor), a VM. */
export type IsolationTier = "os" | "kernel" | "hardware";
@@ -141,6 +142,15 @@ export interface SandboxSpec {
* write (Codex's writable roots).
*/
addedDirs?: readonly string[];
+ /**
+ * The directories bound by name (the workspace, the working directory,
+ * added directories, project roots, worktree folders), each with what it
+ * was when the spec was built (pins.ts, #448 round 13): 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 by
+ * where it leads.
+ */
+ pins?: readonly PathPin[];
/** Container runtime sockets (and their dirs): never reachable, they are root-equivalent. */
containerSockets: readonly string[];
/**
diff --git a/src/daemon/session.ts b/src/daemon/session.ts
index 1282d92b..a8f7ebec 100644
--- a/src/daemon/session.ts
+++ b/src/daemon/session.ts
@@ -230,7 +230,7 @@ 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 { hiddenPathsFor, protectedWriteTargets, sandboxWorkspaceOf, userHiddenPaths } from "./sandbox/policy.js";
+import { hiddenPathsFor, protectedWriteTargets, userHiddenPaths } from "./sandbox/policy.js";
import { addedDirProblem, addedDirReal, autonomousGrantProblem, inAnyDir, nestedControl, normalizeSandboxDir } 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";
@@ -1267,6 +1267,8 @@ export class Session {
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;
#requestDirectoryWaitMs: number | undefined;
/** Started for unattended work: turns need an enforced sandbox (SessionCreateOptions.requireSandbox). */
#requireSandbox = false;
@@ -3167,6 +3169,8 @@ export class Session {
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);
+ // 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).
@@ -3179,6 +3183,30 @@ export class Session {
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;
@@ -3284,7 +3312,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.
@@ -3348,7 +3377,7 @@ export class Session {
const home = process.env.HOME ?? homedir();
const written = normalizeSandboxDir(raw, home) ?? resolvePath(this.workdir, raw.trim());
const dir = addedDirReal(written, home) ?? written;
- const workspace = sandboxWorkspaceOf(this.workdir);
+ 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` };
@@ -4446,7 +4475,15 @@ export class Session {
await this.#restartForSandboxDirs();
// Its worktree folders, removed while empty at the last turn's end, are there again for this one's commands.
this.#sandbox?.prepareFolders();
+ // 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);
@@ -8621,6 +8658,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
@@ -9484,9 +9532,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(", ")})`,
diff --git a/src/tests/fixtures/claude-sandbox-probe.ts b/src/tests/fixtures/claude-sandbox-probe.ts
index 37c55817..e4462a43 100644
--- a/src/tests/fixtures/claude-sandbox-probe.ts
+++ b/src/tests/fixtures/claude-sandbox-probe.ts
@@ -37,6 +37,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/pins.js";
import { buildSandboxSpec } from "../../daemon/sandbox/policy.js";
import { daemonTempDir } from "../../daemon/sandbox/root.js";
import { launcherScript } from "../../daemon/sandbox/session-sandbox.js";
@@ -157,7 +158,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
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/provider-opencode.integration.test.ts b/src/tests/provider-opencode.integration.test.ts
index 75d3d12f..87f1771b 100644
--- a/src/tests/provider-opencode.integration.test.ts
+++ b/src/tests/provider-opencode.integration.test.ts
@@ -129,7 +129,7 @@
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
-import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { type CodeoidConfig, getConfigDir, loadConfig } from "../config.js";
@@ -1196,6 +1196,26 @@ describe("opencode (real binary)", () => {
expect(out).not.toContain("DAEMON-VISIBLE");
expect(readFileSync(join(outside, "keep.txt"), "utf8")).toBe("keep\n");
expect(readFileSync(join(ws, "made.txt"), "utf8")).toBe("hi\n");
+ // #448 round 13: the workspace swapped for a link to a planted home after the turn's launcher was written (from
+ // the gate, just before opencode runs the line): the launcher's own check refuses it, nothing read or written.
+ const fakeHome = join(root, "fake-home");
+ mkdirSync(join(fakeHome, ".ssh"), { recursive: true });
+ writeFileSync(join(fakeHome, ".ssh", "id_ed25519"), "PLANTED-KEY\n");
+ const swapThenAllow: ToolApprovalFn = async () => {
+ renameSync(ws, join(root, "ws-moved"));
+ symlinkSync(fakeHome, ws);
+ return { behavior: "allow" };
+ };
+ try {
+ const swapped = output(await collect(p.runTurn(opts(shell(`cat ${join(ws, ".ssh", "id_ed25519")}; echo PWNED > ${join(ws, "planted.txt")}; echo RAN`), { workdir: ws, canUseTool: swapThenAllow }))));
+ expect(swapped).not.toContain("PLANTED-KEY");
+ expect(swapped).not.toContain("RAN");
+ expect(swapped).toContain("not the directory it was");
+ expect(existsSync(join(fakeHome, "planted.txt"))).toBe(false);
+ } finally {
+ rmSync(ws);
+ renameSync(join(root, "ws-moved"), ws);
+ }
await p.teardown();
// The sandbox off: the same shell, unsandboxed (only a harmless look).
const off = provider({ sandbox: () => null });
diff --git a/src/tests/sandbox-claude-real.test.ts b/src/tests/sandbox-claude-real.test.ts
index 06959497..4d9ece32 100644
--- a/src/tests/sandbox-claude-real.test.ts
+++ b/src/tests/sandbox-claude-real.test.ts
@@ -434,6 +434,25 @@ describe.skipIf(!ready)("Claude Code's sandbox with codeoid's policy, for real",
expect(r.toolResult).not.toContain("LEAK");
expect(r.toolResult).not.toContain("PLANTED-SIGNIN-TOKEN");
}, 300_000);
+
+ test("a project root named through a link (#448 round 13): read and hidden where it really is, the workspace in it writable; every command used to fail", async () => {
+ const home = join(root, "home");
+ const W = join(home, "Workspace13");
+ const ws = join(W, "a");
+ const b = join(W, "b");
+ for (const d of [ws, b]) mkdirSync(d, { recursive: true });
+ writeFileSync(join(b, "README.md"), "B13-README\n");
+ writeFileSync(join(b, ".env"), "LINKROOT-B-ENV\n");
+ const code = join(home, "code13");
+ symlinkSync(W, code);
+ const command = [`cat ${join(b, "README.md")}`, `cat ${join(code, "b", ".env")} 2>&1`, `cat ${join(b, ".env")} 2>&1`, `echo a > ${join(ws, "own.txt")} && echo WS-WRITABLE`].join("; ");
+ const r = await probe("off", command, ["policy"], { CODEOID_PROBE_WORKSPACE: ws, CODEOID_PROBE_PROJECT_ROOTS: code });
+ expect(r.result).toBe("success");
+ expect(r.toolResult).toContain("B13-README");
+ expect(r.toolResult).toContain("WS-WRITABLE");
+ expect(r.toolResult).not.toContain("Read-only file system");
+ expect(r.toolResult).not.toContain("LINKROOT-B-ENV");
+ }, 300_000);
});
/**
diff --git a/src/tests/sandbox-pins-manager.test.ts b/src/tests/sandbox-pins-manager.test.ts
new file mode 100644
index 00000000..32d14d47
--- /dev/null
+++ b/src/tests/sandbox-pins-manager.test.ts
@@ -0,0 +1,76 @@
+/**
+ * Every directory the sandbox binds by name is pinned (#448 round 13,
+ * docs/sandbox.md "Directories bound by name are pinned"), through the real
+ * SessionManager with a scriptable mock backend and a fake provider (the
+ * real-bwrap side is sandbox-pins-real.test.ts): an added dir swapped for a
+ * link mid-turn refuses the turn's next tool call at the gate (said once,
+ * audited) and is dropped at the next turn; the workspace swapped starts no
+ * turn, turn after turn, and no spec is built over it.
+ */
+
+import { describe, expect, it } from "bun:test";
+import { mkdirSync, renameSync, symlinkSync } from "node:fs";
+import { join } from "node:path";
+import { AUTH, BASH_CALL, CLIENT, DONE, infoOf, sandboxManagerHarness, until } from "./fixtures/sandbox-manager-harness.js";
+
+const t = sandboxManagerHarness();
+
+describe("every directory the sandbox binds by name is pinned (#448 round 13)", () => {
+ it("an added dir swapped for a link mid-turn: the turn's next tool call refused at the gate (said once, audited); the next turn starts with it dropped (said)", async () => {
+ const m = t.newManager();
+ // The backend's first turn makes two Bash calls; the first waits on the owner (guarded).
+ t.script = [[BASH_CALL("a"), BASH_CALL("b"), DONE], [BASH_CALL("c"), DONE]];
+ const info = infoOf(await t.create(m, { sandbox: { addedDirs: [t.sibling] } }));
+ const session = m.findByName(info?.name as string, AUTH);
+ if (!session) throw new Error("no session");
+ await session.ensureSandbox();
+ expect(session.sandboxSetting?.pins?.ids[t.sibling]).toMatch(/^\d+:\d+@/);
+ const lines = t.infoLines(m, info?.name as string);
+ void session.send("go", AUTH);
+ await until(() => session.pendingToolApprovals().length > 0);
+ // Meanwhile, something that writes its parent swaps it for a link to a dir of yours.
+ const elsewhere = join(t.tmp, "elsewhere");
+ mkdirSync(elsewhere);
+ renameSync(t.sibling, join(t.tmp, "moved"));
+ symlinkSync(elsewhere, t.sibling);
+ await m.handle({ type: "session.approve", id: "a", sessionId: info?.id as string, approvalId: session.pendingToolApprovals()[0]?.approvalId as string, approve: true } as never, AUTH, CLIENT);
+ await until(() => t.decisions().length > 1);
+ const refused = t.decisions()[1] as { behavior: string; message?: string };
+ expect(refused.behavior).toBe("deny");
+ expect(refused.message).toContain(`added directory ${t.sibling} is reached through a symbolic link now`);
+ expect(refused.message).toContain("isn't followed");
+ await until(() => session.toInfo().status === "idle");
+ expect(lines.filter((l) => l.includes("reached through a symbolic link now")).length).toBe(1);
+ expect(t.audits.some((a) => a.action === "session.sandbox_pin_refuse")).toBe(true);
+ // The next turn starts with it dropped (said); its call is asked about as any is (guarded), not refused.
+ void t.sendWhenReady(session, "again");
+ await until(() => session.pendingToolApprovals().length > 0);
+ expect(session.pendingToolApprovals()[0]?.toolName).toBe("Bash");
+ expect(t.decisions()).toHaveLength(2);
+ expect(session.toInfo().sandbox?.addedDirs).toBeUndefined();
+ expect(lines.some((l) => l.includes(`${t.sibling} is no longer writable: it is no longer the directory it was when added: it is reached through a symbolic link now`))).toBe(true);
+ expect(t.runtimeSpec()?.writable).not.toContain(t.sibling);
+ expect(t.runtimeSpec()?.writable).not.toContain(elsewhere);
+ });
+
+ it("the workspace swapped for a link (to a planted home): no turn starts, turn after turn; no spec is built over it", async () => {
+ const m = t.newManager();
+ const info = infoOf(await t.create(m));
+ const session = m.findByName(info?.name as string, AUTH);
+ if (!session) throw new Error("no session");
+ await session.ensureSandbox();
+ expect(session.sandboxSetting?.pins?.workspace).toBe(t.workdir);
+ const lines = t.infoLines(m, info?.name as string);
+ const fakeHome = join(t.tmp, "fake-home");
+ mkdirSync(join(fakeHome, ".ssh"), { recursive: true });
+ renameSync(t.workdir, join(t.tmp, "work-moved"));
+ symlinkSync(fakeHome, t.workdir);
+ for (const prompt of ["one", "two"]) {
+ await expect(session.send(prompt, AUTH)).rejects.toThrow(`the workspace ${t.workdir} is reached through a symbolic link now`);
+ }
+ expect(t.decisions()).toHaveLength(0);
+ expect(lines.filter((l) => l.includes(`the workspace ${t.workdir} is reached through a symbolic link now`)).length).toBe(1);
+ expect(t.audits.some((a) => a.action === "session.sandbox_refused" && a.detail.includes("the workspace"))).toBe(true);
+ expect(() => t.runtimeSpec()).toThrow("the workspace");
+ });
+});
diff --git a/src/tests/sandbox-pins-real.test.ts b/src/tests/sandbox-pins-real.test.ts
new file mode 100644
index 00000000..d3330327
--- /dev/null
+++ b/src/tests/sandbox-pins-real.test.ts
@@ -0,0 +1,180 @@
+/**
+ * Real bubblewrap, skipped where it can't run (#448 round 13): every
+ * directory the sandbox binds by name is pinned, an agent's own added dir
+ * keeps a project root's credential hiding, every git config git may read
+ * with a credential in it is hidden, and a root named through a link is
+ * hidden where it really is.
+ *
+ * Every swap is made by the test on the host, between two commands, against
+ * directories it planted in its own scratch dir under a home of its own; what
+ * runs inside is a read or a write of planted files only.
+ */
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js";
+import { SandboxPinError } from "../daemon/sandbox/pins.js";
+import { buildSandboxSpec, type SpecInputs } from "../daemon/sandbox/policy.js";
+import { resolveProjectRoots } from "../daemon/sandbox/project-roots.js";
+import { launcherScript } from "../daemon/sandbox/session-sandbox.js";
+import type { SandboxHandle } from "../daemon/sandbox/types.js";
+
+const provider = new BwrapSandboxProvider();
+const probe = await provider.probe();
+
+function scratchParent(): string {
+ for (const dir of [process.env.CODEOID_TEST_SANDBOX_DIR, join(homedir(), ".cache")]) {
+ if (!dir) continue;
+ try {
+ mkdirSync(dir, { recursive: true });
+ return dir;
+ } catch {
+ /* next */
+ }
+ }
+ return tmpdir();
+}
+
+function gitEnv(home: string): Record {
+ return { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.invalid" };
+}
+function git(args: string[], cwd: string, home: string): void {
+ const r = Bun.spawnSync(["git", ...args], { cwd, env: gitEnv(home) });
+ if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`);
+}
+
+describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: pinned directories, an agent's own dir, credential configs (#448 round 13)", () => {
+ let base: string;
+ beforeAll(() => {
+ base = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-pins-")));
+ });
+ afterAll(() => rmSync(base, { recursive: true, force: true }));
+
+ const sh = (h: SandboxHandle, cwd: string, script: string, env: Record) => h.exec(["/bin/sh", "-c", script], { cwd, env, timeoutMs: 30_000 });
+
+ /** `home/Workspace/{a,b}` (repositories), a planted fake home's secrets beside them, and a spec builder. */
+ function fixture(name: string) {
+ const dir = join(base, name);
+ const home = join(dir, "home");
+ const root = join(home, "Workspace");
+ const a = join(root, "a");
+ const b = join(root, "b");
+ for (const r of [a, b]) {
+ mkdirSync(r, { recursive: true });
+ git(["init", "-q", "-b", "main"], r, home);
+ writeFileSync(join(r, "README.md"), `${r === a ? "alpha" : "bravo"} readme\n`);
+ git(["add", "."], r, home);
+ git(["commit", "-qm", "init"], r, home);
+ }
+ // A planted "home" a link could lead to: its key and its shell startup file.
+ const fakeHome = join(dir, "fake-home");
+ mkdirSync(join(fakeHome, ".ssh"), { recursive: true });
+ writeFileSync(join(fakeHome, ".ssh", "id_ed25519"), "PLANTED-KEY\n");
+ writeFileSync(join(fakeHome, ".bashrc"), "# planted\n");
+ const tmp = join(dir, "tmp");
+ mkdirSync(tmp);
+ const configDir = join(home, ".codeoid");
+ const spec = (extra: Partial = {}) =>
+ buildSandboxSpec({ sessionId: name, workspace: a, tempDir: tmp, network: { mode: "off" }, hidden: { home, configDir, env: { HOME: home }, daemonTmp: "/tmp" }, worktreeFolder: "agent-r13", cache: false, ...extra });
+ return { dir, home, root, a, b, fakeHome, tmp, spec, env: gitEnv(home) };
+ }
+
+ const bashrcIntact = (fakeHome: string) => readFileSync(join(fakeHome, ".bashrc"), "utf8") === "# planted\n";
+
+ test("the workspace swapped for a link to a planted home between commands: refused, by the handle and by a launcher written before; nothing read or written there", async () => {
+ const f = fixture("ws-swap");
+ const h = await provider.open(f.spec());
+ try {
+ expect((await sh(h, f.a, "echo ok > note.txt && cat README.md", f.env)).stdout).toBe("alpha readme\n");
+ // A launcher written once (as a process backend's, opencode's shell, the git shim are).
+ const launcher = join(f.tmp, "launch");
+ writeFileSync(launcher, launcherScript(h.wrap?.(["/bin/sh", "-c"], { cwd: "", env: {} }).argv ?? []), { mode: 0o700 });
+ renameSync(f.a, join(f.dir, "a-moved"));
+ symlinkSync(f.fakeHome, f.a);
+ await expect(sh(h, f.a, `cat ${f.a}/.ssh/id_ed25519; echo 'echo PWNED' >> ${f.a}/.bashrc`, f.env)).rejects.toThrow(SandboxPinError);
+ const r = Bun.spawnSync([launcher, `cat ${f.a}/.ssh/id_ed25519; echo 'echo PWNED' >> ${f.a}/.bashrc; echo RAN`], { cwd: f.dir, env: f.env, stdout: "pipe", stderr: "pipe" });
+ expect(r.exitCode).toBe(126);
+ expect(r.stdout.toString()).not.toContain("PLANTED-KEY");
+ expect(r.stdout.toString()).not.toContain("RAN");
+ expect(r.stderr.toString()).toContain("not the directory it was");
+ expect(bashrcIntact(f.fakeHome)).toBe(true);
+ // Nor is a spec built over it.
+ expect(() => f.spec()).toThrow(SandboxPinError);
+ } finally {
+ await h.close();
+ }
+ });
+
+ test("an added dir swapped for a link, or made anew: refused; a project root swapped: refused", async () => {
+ const f = fixture("added-swap");
+ const sub = join(f.b, "sub");
+ mkdirSync(sub);
+ const roots = resolveProjectRoots(["@parent"], f.a, { home: f.home }).roots;
+ let h = await provider.open(f.spec({ addedDirs: [{ dir: sub }], projectRoots: roots }));
+ try {
+ expect((await sh(h, f.a, `echo one > ${sub}/f && cat ${sub}/f`, f.env)).stdout).toBe("one\n");
+ renameSync(sub, join(f.b, "sub-old"));
+ symlinkSync(f.fakeHome, sub);
+ await expect(sh(h, f.a, `cat ${sub}/.ssh/id_ed25519; echo PWNED >> ${sub}/.bashrc`, f.env)).rejects.toThrow(`added directory ${sub} is reached through a symbolic link now`);
+ expect(bashrcIntact(f.fakeHome)).toBe(true);
+ // Another real dir in its place (a rename from elsewhere): not it either.
+ rmSync(sub);
+ mkdirSync(sub);
+ await expect(sh(h, f.a, "true", f.env)).rejects.toThrow("isn't the directory it was");
+ } finally {
+ await h.close();
+ }
+ // The root: swapped for a link to the planted home.
+ rmSync(sub, { recursive: true });
+ renameSync(join(f.b, "sub-old"), sub);
+ h = await provider.open(f.spec({ projectRoots: roots }));
+ try {
+ expect((await sh(h, f.a, `cat ${f.b}/README.md`, f.env)).stdout).toBe("bravo readme\n");
+ renameSync(f.root, join(f.home, "Workspace-moved"));
+ symlinkSync(f.fakeHome, f.root);
+ await expect(sh(h, "/", `cat ${f.root}/.ssh/id_ed25519`, f.env)).rejects.toThrow(SandboxPinError);
+ } finally {
+ await h.close();
+ }
+ });
+
+ test("a workspace inside an added dir: the dir between them can't be moved aside from inside (no link put where the workspace was)", async () => {
+ const f = fixture("nested");
+ const lib = join(f.b, "vendor", "lib");
+ mkdirSync(lib, { recursive: true });
+ git(["init", "-q", "-b", "main"], lib, f.home);
+ const spec = f.spec({ workspace: lib, addedDirs: [{ dir: f.b }] });
+ expect(spec.pinned).toContain(join(f.b, "vendor"));
+ const h = await provider.open(spec);
+ try {
+ const r = await sh(h, "/", `mv ${f.b}/vendor ${f.b}/vendor0 && mkdir ${f.b}/vendor && ln -s ${f.fakeHome} ${lib} && echo SWAPPED`, f.env);
+ expect(r.stdout).not.toContain("SWAPPED");
+ expect(r.stderr).toContain("busy");
+ expect(existsSync(join(f.b, "vendor0"))).toBe(false);
+ // The added dir itself stays writable.
+ expect((await sh(h, "/", `echo w > ${f.b}/w.txt && cat ${f.b}/w.txt`, f.env)).stdout).toBe("w\n");
+ } finally {
+ await h.close();
+ }
+ });
+
+ test("a project root named through a link: its credential files hidden where it really is (by the link's path too)", async () => {
+ const f = fixture("link-root");
+ writeFileSync(join(f.b, ".env"), "LINKROOT-B-ENV\n");
+ const code = join(f.home, "code");
+ symlinkSync(f.root, code);
+ const roots = resolveProjectRoots(["~/code"], f.a, { home: f.home }).roots;
+ expect(roots).toEqual([f.root]);
+ for (const given of [roots, [code]]) {
+ const h = await provider.open(f.spec({ projectRoots: given }));
+ try {
+ const r = await sh(h, f.a, `cat ${f.b}/README.md; cat ${code}/b/.env 2>&1; cat ${f.b}/.env 2>&1`, f.env);
+ expect(r.stdout).toContain("bravo readme");
+ expect(r.stdout).not.toContain("LINKROOT-B-ENV");
+ } finally {
+ await h.close();
+ }
+ }
+ });
+});
diff --git a/src/tests/sandbox-pins.test.ts b/src/tests/sandbox-pins.test.ts
new file mode 100644
index 00000000..cd856d96
--- /dev/null
+++ b/src/tests/sandbox-pins.test.ts
@@ -0,0 +1,249 @@
+/**
+ * Every directory a sandbox binds by name is pinned (#448 round 13,
+ * pins.ts; the real-bwrap side is sandbox-pins-real.test.ts): a directory
+ * is what was pinned only at its real path, through no link, with its
+ * `dev:ino` (the inode alone across a boot); the shell guard a launched argv
+ * carries refuses (126) when one isn't; a handle refuses to wrap (bwrap,
+ * Seatbelt, Docker); the spec build and Claude's settings refuse a stored
+ * pin that changed; a link-named root is used by its real path; the dirs
+ * between nested writable roots are pinned, in the hidden home too;
+ * canonicalAddedDirs pins a grant and keeps no dir inside another.
+ */
+
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { lstatSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { claudeSandboxSettings } from "../daemon/sandbox/claude-native.js";
+import { DockerSandboxProvider } from "../daemon/sandbox/docker.js";
+import { LocalSandboxHandle } from "../daemon/sandbox/local-handle.js";
+import { pinGuardArgv, pinProblem, pinRecord, SandboxPinError, sameDir, unguardedArgv } from "../daemon/sandbox/pins.js";
+import { buildSandboxSpec, nestedRootAncestors, protectedAncestors } from "../daemon/sandbox/policy.js";
+import { resolveProjectRoots } from "../daemon/sandbox/project-roots.js";
+import { SeatbeltSandboxProvider } from "../daemon/sandbox/seatbelt.js";
+import { canonicalAddedDirs } from "../daemon/sandbox/session-sandbox.js";
+import type { SandboxSpec } from "../daemon/sandbox/types.js";
+
+let base: string;
+beforeAll(() => {
+ const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache");
+ mkdirSync(parent, { recursive: true });
+ base = realpathSync(mkdtempSync(join(parent, "codeoid-pins-")));
+});
+afterAll(() => rmSync(base, { recursive: true, force: true }));
+
+let n = 0;
+function scratch(): string {
+ const d = join(base, `t${++n}`);
+ mkdirSync(d, { recursive: true });
+ return d;
+}
+
+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" });
+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()}`);
+}
+function repo(dir: string): string {
+ mkdirSync(dir, { recursive: true });
+ git(["init", "-q", "-b", "main"], dir);
+ writeFileSync(join(dir, "README.md"), "x\n");
+ git(["add", "."], dir);
+ git(["commit", "-qm", "i"], dir);
+ return dir;
+}
+
+describe("pins", () => {
+ test("a directory is what was pinned at its real path, through no link, with its dev:ino; the inode alone across a boot", () => {
+ const d = scratch();
+ const a = join(d, "a");
+ mkdirSync(a);
+ const record = pinRecord(a) as string;
+ expect(record).toMatch(/^\d+:\d+@/);
+ expect(pinProblem(a, "added directory", { stored: record })).toBeNull();
+ // Another directory put in its place (moved aside, made again): not it.
+ renameSync(a, join(d, "a-old"));
+ mkdirSync(a);
+ expect(pinProblem(a, "added directory", { stored: record })).toContain("isn't the directory it was");
+ // A link there, even to the very directory: never followed.
+ rmSync(a, { recursive: true });
+ symlinkSync(join(d, "a-old"), a);
+ expect(pinProblem(a, "added directory", { stored: record })).toContain(`added directory ${a} is reached through a symbolic link now (${a})`);
+ // A link above it.
+ const link = join(d, "via");
+ symlinkSync(d, link);
+ expect(pinProblem(join(link, "a-old"), "the workspace")).toContain(`(${link})`);
+ // Gone.
+ expect(pinProblem(join(d, "missing"), "project root")).toContain("is gone");
+ expect(pinProblem(join(d, "missing"), "worktree folder", { mayBeMissing: true })).toBeNull();
+ // Across a boot the device may be numbered anew: the inode decides.
+ const [id = ""] = record.split("@");
+ const [, ino] = id.split(":");
+ expect(sameDir(`99999:${ino}@another-boot`, `1:${ino}`)).toBe(true);
+ expect(sameDir(`99999:${ino}@another-boot`, `1:${Number(ino) + 1}`)).toBe(false);
+ });
+
+ test("the guard a launched argv carries: runs the program while every pin holds, refuses (126) once one is a link or another dir", () => {
+ const d = scratch();
+ const ws = join(d, "ws");
+ mkdirSync(ws);
+ const st = lstatSync(ws);
+ const pins = [{ path: ws, what: "the workspace", id: `${st.dev}:${st.ino}` }, { path: join(d, "folder"), what: "worktree folder", mayBeMissing: true }];
+ const argv = pinGuardArgv(pins, ["/bin/sh", "-c", "echo RAN"]);
+ expect(unguardedArgv(argv)).toEqual(["/bin/sh", "-c", "echo RAN"]);
+ const run = () => Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe" });
+ let r = run();
+ expect(r.exitCode).toBe(0);
+ expect(r.stdout.toString()).toBe("RAN\n");
+ // Another directory in its place.
+ renameSync(ws, join(d, "ws-old"));
+ mkdirSync(ws);
+ r = run();
+ expect(r.exitCode).toBe(126);
+ expect(r.stdout.toString()).toBe("");
+ expect(r.stderr.toString()).toContain("another one is there now");
+ // A link in its place, to the very directory.
+ rmSync(ws, { recursive: true });
+ symlinkSync(join(d, "ws-old"), ws);
+ r = run();
+ expect(r.exitCode).toBe(126);
+ expect(r.stderr.toString()).toContain("a symbolic link is in its path");
+ // No pins: the argv as it is.
+ expect(pinGuardArgv([], ["x"])).toEqual(["x"]);
+ });
+
+ test("a handle refuses to wrap once a pin changed, and its argv carries the guard", () => {
+ const d = scratch();
+ const ws = join(d, "ws");
+ mkdirSync(ws);
+ const st = lstatSync(ws);
+ const spec = { sessionId: "s", workspace: ws, pins: [{ path: ws, what: "the workspace", id: `${st.dev}:${st.ino}` }] } as unknown as SandboxSpec;
+ const h = new LocalSandboxHandle("bwrap", spec, (argv, o) => ({ argv: ["bwrap", ...argv], env: o.env }));
+ const w = h.wrap(["true"], { cwd: "", env: {} });
+ expect(w.argv[0]).toBe("/bin/sh");
+ expect(unguardedArgv(w.argv)).toEqual(["bwrap", "true"]);
+ renameSync(ws, join(d, "ws-old"));
+ symlinkSync(join(d, "ws-old"), ws);
+ expect(() => h.wrap(["true"], { cwd: "", env: {} })).toThrow(SandboxPinError);
+ expect(() => h.wrap(["true"], { cwd: "", env: {} })).toThrow("isn't followed");
+ });
+
+ test("the spec build refuses a stored pin that changed (workspace, added dir, root); one never pinned is pinned as it is; Claude's settings refuse too", () => {
+ const d = scratch();
+ const home = join(d, "home");
+ const ws = join(home, "W", "a");
+ const b = join(home, "W", "b");
+ for (const r of [ws, b]) mkdirSync(r, { recursive: true });
+ const tmp = join(d, "tmp");
+ mkdirSync(tmp);
+ const base = { sessionId: "s", workspace: ws, tempDir: tmp, network: { mode: "off" as const }, hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, git: false, cache: false };
+ const pins = { [ws]: pinRecord(ws) as string, [b]: pinRecord(b) as string, [join(home, "W")]: pinRecord(join(home, "W")) as string };
+ const spec = buildSandboxSpec({ ...base, addedDirs: [{ dir: b }], projectRoots: [join(home, "W")], pins });
+ expect(spec.pins?.map((p) => p.what)).toEqual(["the workspace", "added directory", "project root"]);
+ for (const p of spec.pins ?? []) expect(p.id).toMatch(/^\d+:\d+$/);
+ // b made anew: the build is refused, never laid over whatever is there now.
+ renameSync(b, join(d, "b-old"));
+ mkdirSync(b);
+ expect(() => buildSandboxSpec({ ...base, addedDirs: [{ dir: b }], pins })).toThrow("added directory");
+ // The workspace swapped for a link to a planted home.
+ renameSync(ws, join(d, "a-old"));
+ const fake = join(d, "fake-home");
+ mkdirSync(join(fake, ".ssh"), { recursive: true });
+ symlinkSync(fake, ws);
+ expect(() => buildSandboxSpec({ ...base, pins })).toThrow(`the workspace ${ws} is reached through a symbolic link now`);
+ // Not pinned before: a workspace through a link is refused all the same.
+ expect(() => buildSandboxSpec({ ...base })).toThrow(SandboxPinError);
+ // Claude's own sandbox is told nothing over a changed pin.
+ const st = lstatSync(join(d, "a-old"));
+ const rt = { spec: { ...spec, workspace: ws, pins: [{ path: ws, what: "the workspace", id: `${st.dev}:${st.ino}` }] }, config: { bwrapPath: undefined } } as never;
+ expect(() => claudeSandboxSettings(rt, { HOME: home })).toThrow(SandboxPinError);
+ });
+
+ test("a project root named through a link is used, hidden and pinned by where it really is", () => {
+ const d = scratch();
+ const home = join(d, "home");
+ const real = join(home, "Workspace");
+ const a = repo(join(real, "a"));
+ repo(join(real, "b"));
+ writeFileSync(join(real, "b", ".env"), "B=1\n");
+ symlinkSync(real, join(home, "code"));
+ const r = resolveProjectRoots(["~/code"], a, { home });
+ expect(r.roots).toEqual([real]);
+ expect(r.notes.join("\n")).toContain(`project root ~/code is ${real} (where it really is)`);
+ const tmp = join(d, "tmp");
+ mkdirSync(tmp);
+ // Given by the link anyway: the spec reads it at its real path, its .env hidden there.
+ const spec = buildSandboxSpec({ sessionId: "s", workspace: a, tempDir: tmp, network: { mode: "off" }, hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, projectRoots: [join(home, "code")], cache: false });
+ expect(spec.projectRoots).toEqual([real]);
+ expect(spec.hidden).toContain(join(real, "b", ".env"));
+ expect(spec.reexposed).not.toContain(join(home, "code"));
+ });
+
+ test("nested writable roots: every dir between them pinned; in the hidden home too (where nothing was pinned before)", () => {
+ const d = scratch();
+ const home = join(d, "home");
+ const b = join(home, "W", "b");
+ const lib = join(b, "vendor", "lib");
+ mkdirSync(lib, { recursive: true });
+ expect(nestedRootAncestors([lib, b], [home])).toEqual([join(b, "vendor")]);
+ // A hidden dir inside the outer root: never pinned (its bind would show it).
+ expect(nestedRootAncestors([lib, b], [join(b, "vendor")])).toEqual([]);
+ // Between a writable root in the hidden home and a hidden path in it.
+ mkdirSync(join(b, "sub", "deep"), { recursive: true });
+ writeFileSync(join(b, "sub", "deep", ".env"), "x\n");
+ expect(protectedAncestors([home, join(b, "sub", "deep", ".env")], [b]).sort()).toEqual([join(b, "sub"), join(b, "sub", "deep")]);
+ });
+});
+
+describe("canonicalAddedDirs", () => {
+ test("by real path, pinned; one inside another isn't kept twice; a grant again is pinned anew", () => {
+ const d = scratch();
+ const b = join(d, "b");
+ mkdirSync(join(b, "sub"), { recursive: true });
+ symlinkSync(b, join(d, "alias"));
+ const s = canonicalAddedDirs({ addedDirs: [join(d, "alias"), join(b, "sub")] });
+ expect(s.addedDirs).toEqual([b]);
+ expect(s.pins?.ids[b]).toMatch(/^\d+:\d+@/);
+ // Removed, then made anew and granted again: pinned as it is now.
+ const removed = canonicalAddedDirs({ addedDirs: [], pins: s.pins }, s);
+ expect(removed.pins?.ids[b]).toBeUndefined();
+ renameSync(b, join(d, "b-old"));
+ mkdirSync(b);
+ const again = canonicalAddedDirs({ addedDirs: [b], pins: removed.pins }, removed);
+ expect(again.pins?.ids[b]).toBe(pinRecord(b) as string);
+ expect(again.pins?.ids[b]).not.toBe(s.pins?.ids[b]);
+ });
+});
+
+describe("every provider's launch carries the pin guard (#448 round 13)", () => {
+ test("Seatbelt and Docker (not runnable here): their argv runs the guard first, and refuses to wrap over a changed pin", async () => {
+ const d = scratch();
+ const ws = join(d, "ws");
+ mkdirSync(ws);
+ const st = lstatSync(ws);
+ const spec: SandboxSpec = {
+ sessionId: "s",
+ workspace: ws,
+ tempDir: d,
+ writable: [],
+ hidden: [],
+ reexposed: [],
+ containerSockets: [],
+ network: { mode: "off" },
+ unsetEnv: [],
+ pins: [{ path: ws, what: "the workspace", id: `${st.dev}:${st.ino}` }],
+ };
+ const handles = [new SeatbeltSandboxProvider().openSync(spec), await new DockerSandboxProvider({ image: "img", docker: "/usr/bin/docker" }).open(spec)];
+ for (const h of handles) {
+ const argv = h.wrap?.(["true"], { cwd: ws, env: {} }).argv ?? [];
+ expect(argv.slice(0, 2)).toEqual(["/bin/sh", "-c"]);
+ expect(unguardedArgv(argv)[0]).not.toBe("/bin/sh");
+ }
+ renameSync(ws, join(d, "ws-old"));
+ symlinkSync(join(d, "ws-old"), ws);
+ for (const h of handles) {
+ expect(() => h.wrap?.(["true"], { cwd: ws, env: {} })).toThrow(SandboxPinError);
+ await h.close();
+ }
+ });
+});
diff --git a/src/tests/sandbox-session.test.ts b/src/tests/sandbox-session.test.ts
index 64b37f42..b0910267 100644
--- a/src/tests/sandbox-session.test.ts
+++ b/src/tests/sandbox-session.test.ts
@@ -32,6 +32,13 @@ import { TranscriptStore } from "../daemon/transcript.js";
import { type AuthContext, CAPABILITIES, type SessionMessage, type SessionMode, type SessionSandboxSetting, type SessionUiRequestMsg } from "../protocol/types.js";
const AUTH: AuthContext = { sub: "u", scopes: [], delegationDepth: 0, accountId: "a", projectId: "p" };
+
+/** A setting without what the daemon keeps of its directories (their pins, #448 round 13): undefined when that was all. */
+function withoutPins(setting: SessionSandboxSetting | undefined): SessionSandboxSetting | undefined {
+ if (!setting) return setting;
+ const { pins: _p, ...rest } = setting;
+ return Object.keys(rest).length > 0 ? rest : undefined;
+}
const BASE_CONFIG = {
workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 },
compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 },
@@ -425,7 +432,8 @@ describe("changing a session's sandbox", () => {
const r = await session.setSandbox({ network: "off" }, AUTH);
expect(r).toEqual({ ok: true });
expect(session.sandboxInfo).toMatchObject({ network: "off", state: "on" });
- expect(session.sandboxSetting).toEqual({ network: "off" });
+ // What the daemon keeps of the directories (their pins, #448 round 13) aside.
+ expect(withoutPins(session.sandboxSetting)).toEqual({ network: "off" });
});
test("widening (off) is asked on the session; a yes applies it, a no doesn't", async () => {
@@ -809,7 +817,7 @@ describe("round 1 of the #448 audit", () => {
const second = sbx.refresh("opencode");
release();
await Promise.all([first, second]);
- expect(sbx.setting).toEqual({ network: "off" });
+ expect(withoutPins(sbx.setting)).toEqual({ network: "off" });
expect(sbx.resolvedFor("opencode")).toBe(true);
expect(sbx.info?.network).toBe("off");
expect(sbx.runtime?.spec.network).toEqual({ mode: "off" });
@@ -924,7 +932,7 @@ describe("round 1 of the #448 audit", () => {
const busy = await session.setSandbox({ network: "off" }, AUTH);
expect(busy).toMatchObject({ ok: false });
expect((busy as { error: string }).error).toContain("busy");
- expect(session.sandboxSetting).toBeUndefined();
+ expect(withoutPins(session.sandboxSetting)).toBeUndefined();
await session.interrupt(AUTH);
await until(() => !session.pendingToolApprovals().length);
provider.onSessionEvent?.({ type: "background_tasks", tasks: [{ id: "bg1", kind: "local_bash", description: "dev server", status: "running" }] });
From 38b84e13e9ec1cf338cb03e2ea061942c154c728 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 16:08:55 +0800
Subject: [PATCH 13/23] fix: #448 round-13 audit: a directory the agent added
itself keeps a project root's credential hiding, and an autonomous grant
never touches another session's or codeoid's checkouts
In autonomous mode one request_directory of a sibling repository made its
.env, keys and a token in its git config readable (findings-c 1,
findings-s 3, findings-r 2), and could make another session's workspace or
worktree folder, a loop's or fork's checkout, or the parent's main checkout
writable (findings-r 3, findings-s 2).
- SessionSandboxSetting.agentDirs (kept by the daemon, refused from
clients): what the agent added itself. buildSandboxSpec and
sandboxDirsOf look through those as through a root (the same names, the
same git configs; one too big to look through isn't added, said); a
credential found stays hidden even inside the git dir commits write, and
bubblewrap never lays a read-only bind over a hidden file. What the owner
added (or approved) is the session's own; the owner's card says its
credentials become readable, and the owner can add an agent's dir as
theirs.
- checkoutOverlap: an autonomous grant is refused for a dir that is, is in
or holds another session's workspace, worktree folder or added dir
(SessionManager's sandboxCheckouts), a checkout in a worktree location
but the session's own folder, a dir holding one, or another checkout of
the session's own repository; the owner's card warns of the same.
Tests: sandbox-agent-grants.test.ts (unit), sandbox-agent-grants-real.test.ts
(real bubblewrap: the agent's dir is written, its credentials unreadable,
its dirs can't be moved aside to show one; the owner's are readable),
sandbox-agent-grants-manager.test.ts (through the SessionManager).
Co-Authored-By: Claude Opus 5.5
---
packages/protocol/src/types.ts | 7 ++
src/daemon/sandbox/bwrap.ts | 4 +-
src/daemon/sandbox/policy.ts | 20 +--
src/daemon/sandbox/project-roots.ts | 64 +++++++++-
src/daemon/sandbox/session-sandbox.ts | 61 +++++++---
src/daemon/session-manager.ts | 29 ++++-
src/daemon/session.ts | 103 +++++++++++++---
.../sandbox-agent-grants-manager.test.ts | 115 ++++++++++++++++++
src/tests/sandbox-agent-grants-real.test.ts | 109 +++++++++++++++++
src/tests/sandbox-agent-grants.test.ts | 112 +++++++++++++++++
10 files changed, 582 insertions(+), 42 deletions(-)
create mode 100644 src/tests/sandbox-agent-grants-manager.test.ts
create mode 100644 src/tests/sandbox-agent-grants-real.test.ts
create mode 100644 src/tests/sandbox-agent-grants.test.ts
diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts
index 38d69cb1..a59ba574 100644
--- a/packages/protocol/src/types.ts
+++ b/packages/protocol/src/types.ts
@@ -402,6 +402,13 @@ export interface SessionSandboxSetting {
* `request_directory`).
*/
addedDirs?: string[];
+ /**
+ * Kept by the daemon, never sent by a client (the schema refuses it): the
+ * added directories the agent added itself (an autonomous
+ * `request_directory`). Credential files in one stay hidden as in a
+ * project root; in one the owner added they are the session's to use.
+ */
+ agentDirs?: string[];
/**
* Kept by the daemon, never sent by a client (the schema refuses it): what
* each directory the sandbox binds by name was when it was pinned (#448
diff --git a/src/daemon/sandbox/bwrap.ts b/src/daemon/sandbox/bwrap.ts
index 7293dd29..d266cb26 100644
--- a/src/daemon/sandbox/bwrap.ts
+++ b/src/daemon/sandbox/bwrap.ts
@@ -128,7 +128,9 @@ 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): its
+ // bind would show it again over the hiding.
+ if (!kind(at) || seen.has(`o:${at}`) || seen.has(`h:${at}`)) continue;
seen.add(`o:${at}`);
mounts.push({ kind: "ro-bind", src: at, path: at });
}
diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts
index 355d8f2a..de5d0e41 100644
--- a/src/daemon/sandbox/policy.ts
+++ b/src/daemon/sandbox/policy.ts
@@ -468,7 +468,7 @@ export interface SpecInputs {
* 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[] }[];
+ 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;
/**
@@ -1171,8 +1171,11 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
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 addedDirs = added.map((a) => a.dir).filter((d) => !inHidden(d) && !userHidden.some((u) => within(d, u)));
- const writableDirs = [workspace, ...addedDirs];
+ 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.
+ const writableDirs = [workspace, ...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))
@@ -1182,10 +1185,11 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
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 (not the session's own: its workspace, its added dirs); a root too big
- // to look through whole isn't shown at all (fail closed: resolveSandbox says so).
- const rootSecrets = projectRootSecrets(rootCandidates, writableDirs, inputs.scanLimit);
+ // 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 rootSecrets = projectRootSecrets([...rootCandidates, ...agentDirs], 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
@@ -1214,11 +1218,13 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
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, ...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))),
);
diff --git a/src/daemon/sandbox/project-roots.ts b/src/daemon/sandbox/project-roots.ts
index c72fc4a7..3b6f2642 100644
--- a/src/daemon/sandbox/project-roots.ts
+++ b/src/daemon/sandbox/project-roots.ts
@@ -33,7 +33,7 @@ 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 } from "./worktrees.js";
+import { mainCheckoutOf, worktreeLocationDirs } from "./worktrees.js";
export { type DirContext, hostTempRoots, normalizeSandboxDir, PROJECT_ROOT_PARENT, projectRootProblem, sandboxDirProblem };
@@ -41,6 +41,15 @@ function within(child: string, parent: string): boolean {
return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`);
}
+function lexists(p: string): boolean {
+ try {
+ lstatSync(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
function realOr(p: string): string {
try {
return realpathSync(p);
@@ -278,6 +287,59 @@ export function autonomousGrantProblem(written: string, real: string, roots: rea
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} 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
diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts
index 2b5509a1..0c223cfa 100644
--- a/src/daemon/sandbox/session-sandbox.ts
+++ b/src/daemon/sandbox/session-sandbox.ts
@@ -161,6 +161,8 @@ export interface EffectiveSandbox {
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. */
@@ -173,7 +175,15 @@ export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxS
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);
- return { choice, network, dockerSocket: setting?.dockerSocket ?? config.docker.socket ?? false, projectRoots: [...new Set(roots)], addedDirs: [...new Set(added)] };
+ 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)),
+ };
}
/**
@@ -183,10 +193,15 @@ export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxS
* 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).
+ * 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) return setting;
+ 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}/`);
@@ -200,21 +215,24 @@ export function canonicalAddedDirs(setting: SessionSandboxSetting, before?: Sess
if (record) ids[d] = record;
else delete ids[d];
}
- const { pins: _p, ...rest } = setting;
+ 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"] as const;
+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, pins: _p, ...rest } = setting ?? {};
+ const { projectRoots: _r, addedDirs: _a, agentDirs: _g, pins: _p, ...rest } = setting ?? {};
return JSON.stringify(Object.entries(rest).sort(([a], [b]) => a.localeCompare(b)));
}
@@ -331,7 +349,7 @@ export function sandboxDirsOf(
rootEntries: readonly string[],
addedDirs: readonly string[],
config?: CodeoidConfig,
- opts: { pins?: Readonly> } = {},
+ opts: { pins?: Readonly>; agentDirs?: readonly string[] } = {},
): SandboxDirs {
const home = resolve(process.env.HOME ?? homedir());
const configDir = getConfigDir();
@@ -377,9 +395,18 @@ export function sandboxDirsOf(
if (record) pinned[root] = record;
}
}
- if (roots.length > 0) {
- const secrets = projectRootSecrets(roots, [top, ...added]);
+ const agent = new Set((opts.agentDirs ?? []).map((d) => resolve(d)));
+ const agentAdded = added.filter((d) => agent.has(d));
+ if (roots.length > 0 || agentAdded.length > 0) {
+ // The session's own: its workspace and what its owner added; the agent's own added dirs are looked through as a root is.
+ const secrets = projectRootSecrets([...roots, ...agentAdded], [top, ...added.filter((d) => !agent.has(d))]);
for (const over of secrets.over) {
+ if (agent.has(over)) {
+ const why = `more than ${PROJECT_ROOT_SCAN_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(
`project root ${over}: more than ${PROJECT_ROOT_SCAN_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`,
);
@@ -389,6 +416,10 @@ export function sandboxDirsOf(
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 };
}
@@ -419,7 +450,7 @@ export async function resolveSandbox(opts: {
/** Roots and added dirs pinned by this resolution (sandboxDirsOf). */
pinned?: Record;
}> {
- const { choice, network, dockerSocket, projectRoots, addedDirs } = effectiveSetting(opts.config, opts.setting);
+ const { choice, network, dockerSocket, projectRoots, addedDirs, agentDirs } = effectiveSetting(opts.config, opts.setting);
const base = {
choice,
network: network.mode,
@@ -469,7 +500,7 @@ export async function resolveSandbox(opts: {
// Working across repositories: the project roots it reads, the directories it writes besides its workspace.
const dirs: SandboxDirs =
top !== null
- ? sandboxDirsOf(top, projectRoots, addedDirs, opts.codeoidConfig, { ...(opts.setting?.pins ? { pins: opts.setting.pins.ids } : {}) })
+ ? 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 };
@@ -743,9 +774,10 @@ export class SessionSandbox {
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, pins, ...rest } = this.#setting;
+ 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, ...(pins ? { pins: { ...pins, ids } } : {}) };
+ 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)));
}
@@ -980,6 +1012,7 @@ export class SessionSandbox {
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,
@@ -995,7 +1028,7 @@ export class SessionSandbox {
projectRoots: dirs.roots,
addedDirs: dirs.added.map((dir) => {
const guard = this.#guardFor(dir);
- return { dir, ...(folder && guard ? { ownWorktreeEntries: guard.ownEntries() } : {}) };
+ return { dir, ...(agent.has(dir) ? { agent: true } : {}), ...(folder && guard ? { ownWorktreeEntries: guard.ownEntries() } : {}) };
}),
...(this.#setting?.pins ? { pins: this.#setting.pins.ids } : {}),
});
diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts
index 0bc93d04..5d17ee04 100644
--- a/src/daemon/session-manager.ts
+++ b/src/daemon/session-manager.ts
@@ -8,7 +8,7 @@
* - Graceful drain on shutdown
*/
-import { addedDirProblem, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js";
+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";
@@ -1001,6 +1001,7 @@ export class SessionManager {
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
accounts: this.#accounts,
claudeAccount: meta.claudeAccount ?? null,
@@ -1782,6 +1783,7 @@ mcpHub: this.#mcpHub,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
...(this.#identityManager
@@ -3918,6 +3920,23 @@ mcpHub: this.#mcpHub,
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);
@@ -4408,6 +4427,7 @@ mcpHub: this.#mcpHub,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
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.
@@ -4804,6 +4824,7 @@ mcpHub: this.#mcpHub,
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,
@@ -5224,6 +5245,7 @@ mcpHub: this.#mcpHub,
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).
@@ -5481,6 +5503,7 @@ mcpHub: this.#mcpHub,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -5770,6 +5793,7 @@ mcpHub: this.#mcpHub,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -6784,6 +6808,7 @@ mcpHub: this.#mcpHub,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -6885,6 +6910,7 @@ mcpHub: this.#mcpHub,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
@@ -7172,6 +7198,7 @@ mcpHub: this.#mcpHub,
providers: this.#providers,
sandboxRegistry: this.#sandboxRegistry,
sandboxMcp: this.#sandboxMcpFor,
+ sandboxCheckouts: this.#sandboxCheckoutsFor,
claudeAccounts: this.#claudeAccounts,
hooks: this.#hooks,
identityManager: this.#identityManager,
diff --git a/src/daemon/session.ts b/src/daemon/session.ts
index a8f7ebec..69908667 100644
--- a/src/daemon/session.ts
+++ b/src/daemon/session.ts
@@ -231,7 +231,7 @@ import { type WorktreeEntryFinding, worktreeEntryLine, worktreeEntryWarning } fr
import { type GitControlSnapshot, gitControlChanges, gitControlSnapshot, gitControlWarning, refTips, settingsCommits, settingsCommitWarning } from "./sandbox/git-watch.js";
import { worktreeFolderNote } from "./sandbox/worktrees.js";
import { hiddenPathsFor, protectedWriteTargets, userHiddenPaths } from "./sandbox/policy.js";
-import { addedDirProblem, addedDirReal, autonomousGrantProblem, inAnyDir, nestedControl, normalizeSandboxDir } from "./sandbox/project-roots.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 {
@@ -549,6 +549,8 @@ export interface SessionCreateOptions {
* 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-
@@ -959,6 +961,7 @@ export class Session {
* 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;
/**
@@ -1591,6 +1594,7 @@ export class Session {
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;
@@ -3224,6 +3228,21 @@ export class Session {
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;
@@ -3371,7 +3390,7 @@ export class Session {
* 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 } | { error: string } {
+ #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();
@@ -3383,20 +3402,47 @@ export class Session {
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 added = sbx.effective().addedDirs;
+ const { addedDirs: added, agentDirs } = sbx.effective();
const holder = added.find((d) => inAnyDir(dir, [d]));
- if (holder) return { dir, written, already: holder === dir ? `${dir} is added already` : `${dir} is in ${holder}, which is added already` };
+ // 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 added, in words, for the owner's confirm: true for what is there. */
+ /**
+ * 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;
- return `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 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}`;
+ }
+
+ /** 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). */
@@ -3440,10 +3486,15 @@ export class Session {
* 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): Promise {
+ 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;
- await sbx.set({ ...(sbx.setting ?? {}), addedDirs: [...next] });
+ // 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] });
@@ -3489,7 +3540,10 @@ export class Session {
}
const check = this.#sandboxDirCheck(raw);
if ("error" in check) return { ok: false, error: check.error };
- if (check.already) return { ok: true, message: check.already };
+ // 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}`);
@@ -3512,12 +3566,18 @@ export class Session {
return;
}
const again = this.#sandboxDirCheck(dir);
- if ("error" in again || again.already || again.dir !== 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})`);
+ 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"),
@@ -3553,16 +3613,23 @@ export class Session {
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): Promise => {
+ 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})`);
- return `Added: ${dir} is writable to your commands ${when.replace("the session's", "your")}. Finish this turn; it isn't writable yet in this one.`;
+ 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; anything else is the owner's to add.
+ // (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());
- if (!why) return grant(SYSTEM_IDENTITY.sub, "a repository under a project root, 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.
@@ -3598,7 +3665,7 @@ export class Session {
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");
+ return grant(answeredBy ?? this.createdBy, "the owner approved", "owner");
});
this.#dirRequests.set(dir, pending);
}
diff --git a/src/tests/sandbox-agent-grants-manager.test.ts b/src/tests/sandbox-agent-grants-manager.test.ts
new file mode 100644
index 00000000..3dc4008e
--- /dev/null
+++ b/src/tests/sandbox-agent-grants-manager.test.ts
@@ -0,0 +1,115 @@
+/**
+ * #448 round 13, through the real SessionManager with a mock backend and a
+ * fake provider (the real-bwrap side is sandbox-agent-grants-real.test.ts):
+ *
+ * - an added directory the agent added itself (autonomous
+ * `request_directory`) keeps a project root's credential hiding, kept
+ * with the setting (`agentDirs`) across a restart; one the owner added
+ * (or approved) is the session's own, and the owner's card says its
+ * credentials become readable; the owner may add an agent's as theirs;
+ * - an autonomous grant never touches another session's checkout (its
+ * workspace, inside it, holding it), a checkout in a worktree location
+ * (forks, loops, `--worktree`), or another checkout of the session's own
+ * repository; the owner's card warns of the same.
+ */
+
+import { describe, expect, it } from "bun:test";
+import { mkdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { AUTH, CLIENT, git, infoOf, sandboxManagerHarness, until } from "./fixtures/sandbox-manager-harness.js";
+
+const t = sandboxManagerHarness();
+
+describe("an autonomous grant never unhides credentials (#448 round 13)", () => {
+ it("the agent's own added dir keeps the root's credential hiding (kept across a restart); the owner's card says theirs become readable; the owner may add it as theirs", async () => {
+ const m = t.newManager();
+ const { info, session } = await t.autonomous(m);
+ const reply = await session.requestDirectory({ path: t.sibling, reason: "fix the client too" });
+ expect(reply).toContain(`Added: ${t.sibling}`);
+ expect(reply).toContain("Its credential files (.env files, keys, a token in a git config) stay hidden to you");
+ expect(session.sandboxSetting?.agentDirs).toEqual([t.sibling]);
+ expect(t.audits.find((a) => a.action === "session.sandbox_dir_added")?.detail).toContain(`${t.sibling} by=agent`);
+ // Applied at the next turn: its commands' spec writes the sibling, and its .env stays hidden.
+ await session.send("go", AUTH);
+ await until(() => session.toInfo().status === "idle");
+ expect(session.toInfo().sandbox?.addedDirs).toEqual([t.sibling]);
+ const spec = t.runtimeSpec();
+ expect(spec?.writable).toContain(t.sibling);
+ expect(spec?.hidden).toContain(join(t.sibling, ".env"));
+ // The restart keeps who added it.
+ await t.transcript.flush();
+ const meta = JSON.parse(readFileSync(join(t.tmp, "data", "transcripts", `${info?.id}.meta.json`), "utf8")) as { sandbox?: { agentDirs?: string[] } };
+ expect(meta.sandbox?.agentDirs).toEqual([t.sibling]);
+
+ // The owner adds it as theirs: asked, the card saying its credentials become readable.
+ const asked = t.dialogs(m, info?.name as string);
+ expect(await m.handle({ type: "session.sandbox_dir", id: "d", sessionId: info?.id as string, action: "add", path: t.sibling }, AUTH, CLIENT)).toMatchObject({ type: "response.ok", data: { pending: true } });
+ await until(() => asked.length > 0);
+ expect(asked[0]?.message).toContain("credential files (.env files, keys, a token in a git config) become readable");
+ await m.handle({ type: "session.ui_response", id: "u", sessionId: info?.id as string, requestId: asked[0]?.requestId as string, confirmed: true } as never, AUTH, CLIENT);
+ await until(() => session.sandboxSetting?.agentDirs === undefined);
+ expect(session.sandboxSetting?.addedDirs).toEqual([t.sibling]);
+ await t.sendWhenReady(session, "again");
+ await until(() => session.toInfo().status === "idle");
+ expect(t.runtimeSpec()?.writable).toContain(t.sibling);
+ expect(t.runtimeSpec()?.hidden).not.toContain(join(t.sibling, ".env"));
+ });
+
+ it("guarded: the owner approving the agent's request is the owner's grant (its card says the credentials become readable)", async () => {
+ const m = t.newManager();
+ const info = infoOf(await t.create(m));
+ const session = m.findByName(info?.name as string, AUTH);
+ if (!session) throw new Error("no session");
+ await session.ensureSandbox();
+ const asked = t.dialogs(m, info?.name as string);
+ const pending = session.requestDirectory({ path: t.sibling, reason: "r" });
+ await until(() => asked.length > 0);
+ expect(asked[0]?.message).toContain("become readable to its commands");
+ await m.handle({ type: "session.ui_response", id: "u", sessionId: info?.id as string, requestId: asked[0]?.requestId as string, confirmed: true } as never, AUTH, CLIENT);
+ expect(await pending).not.toContain("stay hidden");
+ expect(session.sandboxSetting?.addedDirs).toEqual([t.sibling]);
+ expect(session.sandboxSetting?.agentDirs).toBeUndefined();
+ });
+});
+
+describe("an autonomous grant never touches another session's or codeoid's checkouts (#448 round 13)", () => {
+ it("another session's workspace, a dir inside it, a dir holding it: refused; the owner's card warns", async () => {
+ const m = t.newManager();
+ // Another session works in the sibling.
+ const other = infoOf(await m.handle({ type: "session.create", id: "o", name: "other", workdir: t.sibling } as never, AUTH, CLIENT));
+ expect(other?.id).toBeTruthy();
+ const { info, session } = await t.autonomous(m);
+ mkdirSync(join(t.sibling, "src"), { recursive: true });
+ for (const path of [t.sibling, join(t.sibling, "src")]) {
+ const r = await session.requestDirectory({ path, reason: "r" });
+ expect(r).toContain("Refused:");
+ expect(r).toContain(`session "other"'s workspace`);
+ }
+ expect(session.sandboxSetting?.addedDirs).toBeUndefined();
+ // The owner may add it, warned.
+ const asked = t.dialogs(m, info?.name as string);
+ await m.handle({ type: "session.sandbox_dir", id: "d", sessionId: info?.id as string, action: "add", path: t.sibling }, AUTH, CLIENT);
+ await until(() => asked.length > 0);
+ expect(asked[0]?.message).toContain(`Warning: ${t.sibling} is session "other"'s workspace`);
+ });
+
+ it("a checkout in a worktree location (a fork's, a loop's), and the session's own main checkout from its worktree: refused", async () => {
+ const m = t.newManager();
+ // A loop's checkout of the sibling, beside it in codeoid's location.
+ const loopCheckout = join(t.root, "sibling-worktrees", "loop-run-abcd1234");
+ mkdirSync(join(t.root, "sibling-worktrees"), { recursive: true });
+ git(["worktree", "add", "-q", loopCheckout, "-b", "loop"], t.sibling);
+ const { session } = await t.autonomous(m);
+ const r = await session.requestDirectory({ path: loopCheckout, reason: "r" });
+ expect(r).toContain("Refused:");
+ expect(r).toContain("a worktree location");
+ // A session in a fork's worktree of `work` asks for `work` itself (its parent's main checkout).
+ const forkCheckout = join(t.root, "work-worktrees", "fork-1");
+ mkdirSync(join(t.root, "work-worktrees"), { recursive: true });
+ git(["worktree", "add", "-q", forkCheckout, "-b", "fork"], t.workdir);
+ const fork = await t.autonomous(m, { workdir: forkCheckout });
+ const main = await fork.session.requestDirectory({ path: t.workdir, reason: "r" });
+ expect(main).toContain("Refused:");
+ expect(main).toMatch(/another checkout of this session's own repository|session ".*"'s workspace/);
+ });
+});
diff --git a/src/tests/sandbox-agent-grants-real.test.ts b/src/tests/sandbox-agent-grants-real.test.ts
new file mode 100644
index 00000000..b22a7c63
--- /dev/null
+++ b/src/tests/sandbox-agent-grants-real.test.ts
@@ -0,0 +1,109 @@
+/**
+ * Real bubblewrap, skipped where it can't run (#448 round 13): a directory
+ * the agent added itself is writable, but a project root's credential
+ * hiding stays in it (a `.env`, keys, a token in its git config, a deep
+ * `.env` whose dirs can't be moved aside to show it); one the owner added
+ * is the session's own, its credentials readable. What runs inside reads or
+ * writes only files this test planted under a home of its own.
+ */
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js";
+import { buildSandboxSpec, type SpecInputs } from "../daemon/sandbox/policy.js";
+import { resolveProjectRoots } from "../daemon/sandbox/project-roots.js";
+import type { SandboxHandle } from "../daemon/sandbox/types.js";
+
+const provider = new BwrapSandboxProvider();
+const probe = await provider.probe();
+
+function scratchParent(): string {
+ for (const dir of [process.env.CODEOID_TEST_SANDBOX_DIR, join(homedir(), ".cache")]) {
+ if (!dir) continue;
+ try {
+ mkdirSync(dir, { recursive: true });
+ return dir;
+ } catch {
+ /* next */
+ }
+ }
+ return tmpdir();
+}
+
+function gitEnv(home: string): Record {
+ return { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.invalid" };
+}
+function git(args: string[], cwd: string, home: string): void {
+ const r = Bun.spawnSync(["git", ...args], { cwd, env: gitEnv(home) });
+ if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`);
+}
+
+describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: an agent's own added dir keeps a root's credential hiding (#448 round 13)", () => {
+ let base: string;
+ beforeAll(() => {
+ base = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-pins-")));
+ });
+ afterAll(() => rmSync(base, { recursive: true, force: true }));
+
+ const sh = (h: SandboxHandle, cwd: string, script: string, env: Record) => h.exec(["/bin/sh", "-c", script], { cwd, env, timeoutMs: 30_000 });
+
+ /** `home/Workspace/{a,b}` (repositories), a planted fake home's secrets beside them, and a spec builder. */
+ function fixture(name: string) {
+ const dir = join(base, name);
+ const home = join(dir, "home");
+ const root = join(home, "Workspace");
+ const a = join(root, "a");
+ const b = join(root, "b");
+ for (const r of [a, b]) {
+ mkdirSync(r, { recursive: true });
+ git(["init", "-q", "-b", "main"], r, home);
+ writeFileSync(join(r, "README.md"), `${r === a ? "alpha" : "bravo"} readme\n`);
+ git(["add", "."], r, home);
+ git(["commit", "-qm", "init"], r, home);
+ }
+ // A planted "home" a link could lead to: its key and its shell startup file.
+ const fakeHome = join(dir, "fake-home");
+ mkdirSync(join(fakeHome, ".ssh"), { recursive: true });
+ writeFileSync(join(fakeHome, ".ssh", "id_ed25519"), "PLANTED-KEY\n");
+ writeFileSync(join(fakeHome, ".bashrc"), "# planted\n");
+ const tmp = join(dir, "tmp");
+ mkdirSync(tmp);
+ const configDir = join(home, ".codeoid");
+ const spec = (extra: Partial = {}) =>
+ buildSandboxSpec({ sessionId: name, workspace: a, tempDir: tmp, network: { mode: "off" }, hidden: { home, configDir, env: { HOME: home }, daemonTmp: "/tmp" }, worktreeFolder: "agent-r13", cache: false, ...extra });
+ return { dir, home, root, a, b, fakeHome, tmp, spec, env: gitEnv(home) };
+ }
+
+ test("an agent's own added dir: writable, its credentials (a .env, keys, a token in its git config) still hidden; the owner's: readable", async () => {
+ const f = fixture("agent-dir");
+ writeFileSync(join(f.b, ".env"), "B-ENV-SECRET\n");
+ mkdirSync(join(f.b, ".aws"));
+ writeFileSync(join(f.b, ".aws", "credentials"), "B-AWS-SECRET\n");
+ writeFileSync(join(f.b, "deploy_key"), "B-DEPLOY-KEY\n");
+ mkdirSync(join(f.b, "sub", "deep"), { recursive: true });
+ writeFileSync(join(f.b, "sub", "deep", ".env"), "B-DEEP-SECRET\n");
+ git(["remote", "add", "origin", "https://x:B-GIT-TOKEN@example.invalid/b.git"], f.b, f.home);
+ const roots = resolveProjectRoots(["@parent"], f.a, { home: f.home }).roots;
+ const read = `for p in .env .aws/credentials deploy_key sub/deep/.env .git/config; do cat ${f.b}/$p 2>/dev/null; done; echo done`;
+ let h = await provider.open(f.spec({ projectRoots: roots, addedDirs: [{ dir: f.b, agent: true }] }));
+ try {
+ const r = await sh(h, f.a, read, f.env);
+ expect(r.stdout).not.toMatch(/B-ENV-SECRET|B-AWS-SECRET|B-DEPLOY-KEY|B-DEEP-SECRET|B-GIT-TOKEN/);
+ // Writable otherwise; the hidden file's dirs can't be moved aside to show it again.
+ expect((await sh(h, f.a, `echo w > ${f.b}/new.txt && cat ${f.b}/new.txt`, f.env)).stdout).toBe("w\n");
+ const moved = await sh(h, f.a, `mv ${f.b}/sub ${f.b}/sub2 && cat ${f.b}/sub2/deep/.env`, f.env);
+ expect(moved.stdout).not.toContain("B-DEEP-SECRET");
+ } finally {
+ await h.close();
+ }
+ h = await provider.open(f.spec({ projectRoots: roots, addedDirs: [{ dir: f.b }] }));
+ try {
+ const r = await sh(h, f.a, read, f.env);
+ for (const s of ["B-ENV-SECRET", "B-AWS-SECRET", "B-DEPLOY-KEY", "B-DEEP-SECRET", "B-GIT-TOKEN"]) expect(r.stdout).toContain(s);
+ } finally {
+ await h.close();
+ }
+ });
+
+});
diff --git a/src/tests/sandbox-agent-grants.test.ts b/src/tests/sandbox-agent-grants.test.ts
new file mode 100644
index 00000000..457c2082
--- /dev/null
+++ b/src/tests/sandbox-agent-grants.test.ts
@@ -0,0 +1,112 @@
+/**
+ * An added dir's provenance and the checkouts an autonomous grant never
+ * touches (#448 round 13; through the manager:
+ * sandbox-agent-grants-manager.test.ts, real bwrap:
+ * sandbox-agent-grants-real.test.ts): the agent's own added dir keeps a
+ * root's credential hiding, the owner's is the session's own; another
+ * session's workspace (in it, holding it), a checkout in a worktree
+ * location, another checkout of the session's own repository; the agent's
+ * own are kept only while added.
+ */
+
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { buildSandboxSpec } from "../daemon/sandbox/policy.js";
+import { checkoutOverlap } from "../daemon/sandbox/project-roots.js";
+import { canonicalAddedDirs } from "../daemon/sandbox/session-sandbox.js";
+
+let base: string;
+beforeAll(() => {
+ const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache");
+ mkdirSync(parent, { recursive: true });
+ base = realpathSync(mkdtempSync(join(parent, "codeoid-pins-")));
+});
+afterAll(() => rmSync(base, { recursive: true, force: true }));
+
+let n = 0;
+function scratch(): string {
+ const d = join(base, `t${++n}`);
+ mkdirSync(d, { recursive: true });
+ return d;
+}
+
+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" });
+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()}`);
+}
+function repo(dir: string): string {
+ mkdirSync(dir, { recursive: true });
+ git(["init", "-q", "-b", "main"], dir);
+ writeFileSync(join(dir, "README.md"), "x\n");
+ git(["add", "."], dir);
+ git(["commit", "-qm", "i"], dir);
+ return dir;
+}
+
+describe("an added dir's provenance", () => {
+ test("the agent's own added dir keeps a root's hiding; the owner's is the session's own", () => {
+ const d = scratch();
+ const home = join(d, "home");
+ const root = join(home, "W");
+ const a = repo(join(root, "a"));
+ const b = repo(join(root, "b"));
+ writeFileSync(join(b, ".env"), "B=1\n");
+ git(["remote", "add", "origin", "https://x:TOKEN@example.invalid/b.git"], b);
+ const tmp = join(d, "tmp");
+ mkdirSync(tmp);
+ const spec = (agent: boolean) =>
+ buildSandboxSpec({ sessionId: "s", workspace: a, tempDir: tmp, network: { mode: "off" }, hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, projectRoots: [root], addedDirs: [{ dir: b, agent }], cache: false });
+ const theirs = spec(true);
+ expect(theirs.writable).toContain(b);
+ expect(theirs.hidden).toContain(join(b, ".env"));
+ expect(theirs.hidden).toContain(join(b, ".git", "config"));
+ const owners = spec(false);
+ expect(owners.writable).toContain(b);
+ expect(owners.hidden).not.toContain(join(b, ".env"));
+ expect(owners.hidden).not.toContain(join(b, ".git", "config"));
+ });
+
+ test("the agent's own are kept only while added, and only once (an inner one goes with the outer)", () => {
+ const d = scratch();
+ const b = join(d, "b");
+ mkdirSync(join(b, "sub"), { recursive: true });
+ const s = canonicalAddedDirs({ addedDirs: [b, join(b, "sub")], agentDirs: [join(b, "sub"), join(d, "gone")] });
+ expect(s.addedDirs).toEqual([b]);
+ expect(s.agentDirs).toBeUndefined();
+ expect(canonicalAddedDirs({ addedDirs: [b], agentDirs: [b] }).agentDirs).toEqual([b]);
+ expect(canonicalAddedDirs({ agentDirs: [b] }).agentDirs).toBeUndefined();
+ });
+});
+
+describe("the checkouts an autonomous grant never touches", () => {
+ test("another session's workspace (in it, holding it), a checkout in a worktree location, another checkout of the session's own repository", () => {
+ const d = scratch();
+ const root = join(d, "W");
+ const a = repo(join(root, "a"));
+ const b = repo(join(root, "b"));
+ mkdirSync(join(b, "src"));
+ const others = [{ dir: b, what: 'session "other"\'s workspace' }];
+ const opts = { workspace: a };
+ expect(checkoutOverlap(b, others, opts)).toContain(`is session "other"'s workspace`);
+ expect(checkoutOverlap(join(b, "src"), others, opts)).toContain(`is in session "other"'s workspace`);
+ expect(checkoutOverlap(root, others, opts)).toContain(`holds session "other"'s workspace`);
+ expect(checkoutOverlap(b, [], opts)).toBeNull();
+ // A fork's (or loop's) checkout of b, in codeoid's location beside it.
+ const wt = join(root, "b-worktrees", "fork-1");
+ mkdirSync(join(root, "b-worktrees"));
+ git(["worktree", "add", "-q", wt, "-b", "f"], b);
+ expect(checkoutOverlap(wt, [], opts)).toContain("a worktree location");
+ expect(checkoutOverlap(wt, [], { ...opts, ownFolders: [wt] })).toBeNull();
+ // A session in a's worktree asks for a itself.
+ const awt = join(root, "a-worktrees", "s1");
+ mkdirSync(join(root, "a-worktrees"));
+ git(["worktree", "add", "-q", awt, "-b", "s"], a);
+ expect(checkoutOverlap(a, [], { workspace: awt })).toContain("another checkout of this session's own repository");
+ // A checkout holding codeoid's in-checkout location.
+ mkdirSync(join(b, ".worktrees"));
+ expect(checkoutOverlap(b, [], opts)).toContain("holds");
+ });
+});
From cfaace5323052a24de134f04d7eedc278d5233ae Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 16:08:55 +0800
Subject: [PATCH 14/23] fix: #448 round-13 audit: a routine's added directories
are checked and kept by their real path when it is defined; a fork isn't told
its own git config is hidden; the root look no longer holds up the daemon
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- A routine's added dirs went to each run's session as written, so a
`~/…` or link-named one was dropped at every run, and an invalid one was
accepted (findings-c 4). They are now checked as a session's are at
create (RoutineHost.sandboxProblem runs the dirs check) and kept by their
real path (sandboxCanonical), with no pins: each run pins them.
- A fork in a linked worktree was told its own repository's git config was
hidden when it wasn't (findings-c 5): the workspace's git dir counts as
the session's own in the look.
- The root look walked synchronously on the event loop two or three times
per resolution (findings-c 6): it yields every few hundred dirs
(projectRootSecretsAsync) and runs once per resolution, every spec built
from it reusing the result (SpecInputs.rootScan). A default root past the
bound says what counts (build outputs) and what to set.
Tests: sandbox-routine-dirs.test.ts, sandbox-roots-scan.test.ts.
Co-Authored-By: Claude Opus 5.5
---
src/daemon/routines/service.ts | 11 ++-
src/daemon/sandbox/policy.ts | 17 +++-
src/daemon/sandbox/project-roots.ts | 38 +++++++++
src/daemon/sandbox/session-sandbox.ts | 44 +++++++---
src/daemon/session-manager.ts | 8 +-
src/tests/sandbox-project-roots.test.ts | 12 +--
src/tests/sandbox-roots-scan.test.ts | 107 ++++++++++++++++++++++++
src/tests/sandbox-routine-dirs.test.ts | 32 +++++++
8 files changed, 244 insertions(+), 25 deletions(-)
create mode 100644 src/tests/sandbox-roots-scan.test.ts
create mode 100644 src/tests/sandbox-routine-dirs.test.ts
diff --git a/src/daemon/routines/service.ts b/src/daemon/routines/service.ts
index 65a496d2..574cb3e9 100644
--- a/src/daemon/routines/service.ts
+++ b/src/daemon/routines/service.ts
@@ -299,8 +299,14 @@ export interface RoutineHost {
audit(subject: string, action: string, sessionId: string | undefined, detail: string): void;
hasProvider(id: string): boolean;
providerIds(): string[];
- /** Why a sandbox setting can't be used (an unknown provider, a bad host), or null (#448). Absent: no sandboxing, any setting is refused. */
+ /**
+ * Why a sandbox setting can't be used (an unknown provider, a bad host, a
+ * project root or added directory that can't be one), or null (#448).
+ * Absent: no sandboxing, any setting is refused.
+ */
sandboxProblem?(setting: SessionSandboxSetting): string | null;
+ /** The setting as kept: each added directory by its real path, none inside another (#448 round 13). */
+ sandboxCanonical?(setting: SessionSandboxSetting): SessionSandboxSetting;
/** Expand `~`, resolve, and confirm the directory exists; null when unusable. */
normalizeWorkdir(path: string): string | null;
/** Loops, for routines that start one; absent when the daemon has none. */
@@ -1867,7 +1873,8 @@ export class RoutineService {
if (input.sandbox && Object.keys(input.sandbox).length > 0) {
const problem = this.#host.sandboxProblem ? this.#host.sandboxProblem(input.sandbox) : "this daemon has no OS sandbox";
if (problem) bad(`sandbox: ${problem}`);
- spec.sandbox = { ...input.sandbox };
+ // Kept by where each added directory really is now, as a session's are when granted: a run binds that.
+ spec.sandbox = this.#host.sandboxCanonical ? this.#host.sandboxCanonical(input.sandbox) : { ...input.sandbox };
}
if (spec.worktree && spec.session === "persistent") bad("worktree isolation is per run, so it needs session: fresh");
if (input.loop) spec.loop = this.#normalizeLoop(spec, input.loop, bad);
diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts
index de5d0e41..bff70ea3 100644
--- a/src/daemon/sandbox/policy.ts
+++ b/src/daemon/sandbox/policy.ts
@@ -35,7 +35,7 @@ import { DEFAULT_HOME_READABLE } from "./home-readable.js";
import { validAllowedHost } from "./hosts.js";
import { sandboxRoot } from "./root.js";
import { type PathPin, dirIdentity, pinProblem, pinRefusal, SandboxPinError } from "./pins.js";
-import { controlDirProblem, nestedControl, type NestedControl, projectRootSecrets, stillRealDir } from "./project-roots.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";
@@ -479,6 +479,13 @@ export interface SpecInputs {
* 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). */
@@ -1174,7 +1181,9 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
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.
- const writableDirs = [workspace, ...addedKept.filter((a) => !a.agent).map((a) => a.dir)];
+ // 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 ?? [])
@@ -1187,7 +1196,9 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
.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 rootSecrets = projectRootSecrets([...rootCandidates, ...agentDirs], writableDirs, inputs.scanLimit);
+ 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.
diff --git a/src/daemon/sandbox/project-roots.ts b/src/daemon/sandbox/project-roots.ts
index 3b6f2642..f279d6bd 100644
--- a/src/daemon/sandbox/project-roots.ts
+++ b/src/daemon/sandbox/project-roots.ts
@@ -357,6 +357,8 @@ export function defaultProjectRoot(workspace: string, home: string = process.env
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[];
}
@@ -408,6 +410,7 @@ export function resolveProjectRoots(entries: readonly string[], workspace: strin
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;
}
@@ -637,7 +640,41 @@ export interface RootSecrets {
* `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();
@@ -652,6 +689,7 @@ export function projectRootSecrets(roots: readonly string[], writable: readonly
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)) {
diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts
index 0c223cfa..11a8ad26 100644
--- a/src/daemon/sandbox/session-sandbox.ts
+++ b/src/daemon/sandbox/session-sandbox.ts
@@ -31,8 +31,10 @@ import {
normalizeSandboxDir,
PROJECT_ROOT_PARENT,
PROJECT_ROOT_SCAN_LIMIT,
- projectRootSecrets,
+ projectRootSecretsAsync,
repositoryOfConfig,
+ type RootScan,
+ rootScanKey,
resolveProjectRoots,
SCAN_SKIPPED_DIRS,
stillRealDir,
@@ -329,6 +331,8 @@ export interface SandboxDirs {
dropped: Array<{ dir: string; why: string }>;
/** Pins taken now (pins.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;
}
/**
@@ -344,13 +348,13 @@ export interface SandboxDirs {
* dir with no pin is pinned now (`pinned`). The agent's own added dirs
* (`opts.agentDirs`) keep a root's credential hiding.
*/
-export function sandboxDirsOf(
+export async function sandboxDirsOf(
top: string,
rootEntries: readonly string[],
addedDirs: readonly string[],
config?: CodeoidConfig,
- opts: { pins?: Readonly>; agentDirs?: readonly string[] } = {},
-): SandboxDirs {
+ opts: { pins?: Readonly>; agentDirs?: readonly string[]; scanLimit?: number } = {},
+): Promise {
const home = resolve(process.env.HOME ?? homedir());
const configDir = getConfigDir();
const pins = opts.pins ?? {};
@@ -397,18 +401,28 @@ export function sandboxDirsOf(
}
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 and what its owner added; the agent's own added dirs are looked through as a root is.
- const secrets = projectRootSecrets([...roots, ...agentAdded], [top, ...added.filter((d) => !agent.has(d))]);
+ // 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 ${PROJECT_ROOT_SCAN_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`;
+ 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(
- `project root ${over}: more than ${PROJECT_ROOT_SCAN_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`,
+ 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));
@@ -421,7 +435,7 @@ export function sandboxDirsOf(
if (i >= 0) added.splice(i, 1);
}
}
- return { roots, added, notes, homeWorkspace, dropped, pinned };
+ 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. */
@@ -449,6 +463,8 @@ export async function resolveSandbox(opts: {
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 = {
@@ -500,7 +516,7 @@ export async function resolveSandbox(opts: {
// Working across repositories: the project roots it reads, the directories it writes besides its workspace.
const dirs: SandboxDirs =
top !== null
- ? sandboxDirsOf(top, projectRoots, addedDirs, opts.codeoidConfig, { ...(opts.setting?.pins ? { pins: opts.setting.pins.ids } : {}), agentDirs })
+ ? 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 };
@@ -526,6 +542,7 @@ export async function resolveSandbox(opts: {
network: applied,
...(dirs.dropped.length > 0 ? { droppedDirs: dirs.dropped } : {}),
...(Object.keys(dirs.pinned).length > 0 ? { pinned: dirs.pinned } : {}),
+ ...(dirs.scan ? { rootScan: dirs.scan } : {}),
};
}
@@ -562,7 +579,7 @@ export class SessionSandbox {
/** 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[] } = { roots: [], added: [] };
+ #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;
@@ -829,7 +846,7 @@ export class SessionSandbox {
this.#resolvedFor = backendId;
this.#stale = false;
this.#dirsStale = false;
- const dirs = { roots: [...(r.info.projectRoots ?? [])], added: [...(r.info.addedDirs ?? [])] };
+ 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) {
@@ -1002,7 +1019,7 @@ export class SessionSandbox {
readable: readonly string[] = [],
dockerSocket = false,
hideLocalServices = network.mode !== "on",
- dirs: { roots: readonly string[]; added: readonly string[] } = this.#dirs,
+ 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();
@@ -1031,6 +1048,7 @@ export class SessionSandbox {
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 {
diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts
index 5d17ee04..4670f957 100644
--- a/src/daemon/session-manager.ts
+++ b/src/daemon/session-manager.ts
@@ -7039,7 +7039,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),
diff --git a/src/tests/sandbox-project-roots.test.ts b/src/tests/sandbox-project-roots.test.ts
index e6d62e9e..5001744d 100644
--- a/src/tests/sandbox-project-roots.test.ts
+++ b/src/tests/sandbox-project-roots.test.ts
@@ -86,7 +86,7 @@ describe("project roots", () => {
expect(resolveProjectRoots(["@parent"], repo, { home }).roots).toEqual([join(home, "Workspace")]);
});
- test("a repository directly in the home: no default root, said; the home itself as a workspace: none either", () => {
+ test("a repository directly in the home: no default root, said; the home itself as a workspace: none either", async () => {
const home = join(base, "home");
const repo = join(home, "proj");
mkdirSync(repo, { recursive: true });
@@ -95,7 +95,7 @@ describe("project roots", () => {
expect(r.roots).toEqual([]);
expect(r.notes[0]).toContain("no project root: the default, the repository's parent");
expect(r.notes[0]).toContain("set sandbox.projectRoots");
- const dirs = sandboxDirsOf(process.env.HOME as string, ["@parent"], []);
+ const dirs = await sandboxDirsOf(process.env.HOME as string, ["@parent"], []);
expect(dirs).toMatchObject({ roots: [], homeWorkspace: true });
expect(dirs.notes[0]).toContain("only credentials are protected");
});
@@ -456,7 +456,7 @@ function workspaceLayout() {
}
describe("an added directory is what it was when granted (#448 round 12)", () => {
- test("kept by its real path; replaced by a link later, it is dropped at the next build (said), never followed", () => {
+ test("kept by its real path; replaced by a link later, it is dropped at the next build (said), never followed", async () => {
const { home, root, a, docs, spec } = workspaceLayout();
const c = join(root, "c");
mkdirSync(c);
@@ -479,7 +479,7 @@ describe("an added directory is what it was when granted (#448 round 12)", () =>
// A stored link (as an older daemon kept one) isn't followed either.
expect(spec({ addedDirs: [{ dir: link }] }).writable).not.toContain(docs);
process.env.HOME = home;
- const dirs = sandboxDirsOf(a, [], [c]);
+ const dirs = await sandboxDirsOf(a, [], [c]);
expect(dirs.added).toEqual([]);
expect(dirs.dropped[0]?.dir).toBe(c);
expect(dirs.notes.join("\n")).toContain(`${c} is a symbolic link now`);
@@ -610,7 +610,7 @@ describe("credentials inside project roots (#448 round 12)", () => {
expect(found.hidden.sort()).toEqual([...secret, ...dirs, ...worktrees].sort());
});
- test("a repository's .git/config carrying a credential is hidden (said: git there can't read its config); a plain one isn't", () => {
+ test("a repository's .git/config carrying a credential is hidden (said: git there can't read its config); a plain one isn't", async () => {
const { home, root, a, b } = workspaceLayout();
const c = join(root, "c");
const d = join(root, "d");
@@ -625,7 +625,7 @@ describe("credentials inside project roots (#448 round 12)", () => {
expect(found.gitConfigs.sort()).toEqual([join(b, ".git", "config"), join(d, ".git", "config")]);
expect(found.hidden).not.toContain(join(c, ".git", "config"));
process.env.HOME = home;
- const notes = sandboxDirsOf(a, [root], []).notes.join("\n");
+ const notes = (await sandboxDirsOf(a, [root], [])).notes.join("\n");
expect(notes).toContain(`${join(b, ".git", "config")} holds a credential`);
expect(notes).toContain(`git in ${b} can't read its config`);
});
diff --git a/src/tests/sandbox-roots-scan.test.ts b/src/tests/sandbox-roots-scan.test.ts
new file mode 100644
index 00000000..0cd450fa
--- /dev/null
+++ b/src/tests/sandbox-roots-scan.test.ts
@@ -0,0 +1,107 @@
+/**
+ * The look through project roots for credentials (#448 round 13, lows): a
+ * fork in a linked worktree isn't told its own repository's git config is
+ * hidden; a default root too big to look through says what counts and what
+ * to set; the spec reuses the resolution's look; the look lets other work
+ * run while it goes.
+ */
+
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { buildSandboxSpec } from "../daemon/sandbox/policy.js";
+import { projectRootSecretsAsync, rootScanKey } from "../daemon/sandbox/project-roots.js";
+import { sandboxDirsOf } from "../daemon/sandbox/session-sandbox.js";
+
+let base: string;
+beforeAll(() => {
+ const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache");
+ mkdirSync(parent, { recursive: true });
+ base = realpathSync(mkdtempSync(join(parent, "codeoid-pins-")));
+});
+afterAll(() => rmSync(base, { recursive: true, force: true }));
+
+let n = 0;
+function scratch(): string {
+ const d = join(base, `t${++n}`);
+ mkdirSync(d, { recursive: true });
+ return d;
+}
+
+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" });
+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()}`);
+}
+function repo(dir: string): string {
+ mkdirSync(dir, { recursive: true });
+ git(["init", "-q", "-b", "main"], dir);
+ writeFileSync(join(dir, "README.md"), "x\n");
+ git(["add", "."], dir);
+ git(["commit", "-qm", "i"], dir);
+ return dir;
+}
+
+describe("the look through roots (#448 round 13 lows)", () => {
+ // A home of the test's own (its scratch dir isn't in a hidden dir of it).
+ const realHome = process.env.HOME;
+ beforeAll(() => {
+ process.env.HOME = join(base, "lows-home");
+ mkdirSync(process.env.HOME, { recursive: true });
+ });
+ afterAll(() => {
+ process.env.HOME = realHome;
+ });
+
+ test("a fork in a linked worktree isn't told its own repository's git config is hidden; a sibling's is", async () => {
+ const d = scratch();
+ const root = join(d, "W");
+ const a = repo(join(root, "a"));
+ const b = repo(join(root, "b"));
+ git(["remote", "add", "origin", "https://x:OWN@example.invalid/a.git"], a);
+ git(["remote", "add", "origin", "https://x:SIB@example.invalid/b.git"], b);
+ const fork = join(root, "a-worktrees", "fork-1");
+ mkdirSync(join(root, "a-worktrees"));
+ git(["worktree", "add", "-q", fork, "-b", "fork"], a);
+ const notes = (await sandboxDirsOf(fork, [root], [])).notes.join("\n");
+ expect(notes).not.toContain(join(a, ".git", "config"));
+ expect(notes).toContain(`${join(b, ".git", "config")} holds a credential`);
+ });
+
+ test("a default root too big to look through: said plainly (what counts, what to set); the spec reuses the resolution's look; the look lets other work run", async () => {
+ const d = scratch();
+ const root = join(d, "W");
+ const a = repo(join(root, "a"));
+ for (let i = 0; i < 30; i++) mkdirSync(join(root, "big", `target-${i}`), { recursive: true });
+ const dirs = await sandboxDirsOf(a, ["@parent"], [], undefined, { scanLimit: 10 });
+ expect(dirs.roots).toEqual([]);
+ expect(dirs.notes.join("\n")).toContain(`no project root: the default, the repository's parent (${root}), holds more than 10 directories`);
+ expect(dirs.notes.join("\n")).toContain("build outputs such as target/");
+ // The spec built from a resolution uses its look (a hidden path only that look found is hidden), not a new one.
+ const home = join(d, "home");
+ mkdirSync(home, { recursive: true });
+ const tmp = join(d, "tmp");
+ mkdirSync(tmp);
+ const own = [a, join(a, ".git")];
+ const sentinel = join(root, "only-the-resolution-saw-this");
+ const spec = buildSandboxSpec({
+ sessionId: "s",
+ workspace: a,
+ tempDir: tmp,
+ network: { mode: "off" },
+ hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" },
+ projectRoots: [root],
+ cache: false,
+ rootScan: { key: rootScanKey([root], own), result: { hidden: [sentinel], gitConfigs: [], over: [] } },
+ });
+ expect(spec.hidden).toContain(sentinel);
+ // The async look yields: a timer set before it fires while it runs.
+ for (let i = 0; i < 1500; i++) mkdirSync(join(root, "many", `d${i}`), { recursive: true });
+ let ticks = 0;
+ const timer = setInterval(() => ticks++, 0);
+ await projectRootSecretsAsync([root], [a]);
+ clearInterval(timer);
+ expect(ticks).toBeGreaterThan(0);
+ });
+});
diff --git a/src/tests/sandbox-routine-dirs.test.ts b/src/tests/sandbox-routine-dirs.test.ts
new file mode 100644
index 00000000..599abf44
--- /dev/null
+++ b/src/tests/sandbox-routine-dirs.test.ts
@@ -0,0 +1,32 @@
+/**
+ * A routine's added directories are checked and kept by their real path when
+ * it is defined (#448 round 13), as a session's are when it is created:
+ * `~/…` and a link-named dir work at every run instead of being dropped, and
+ * one that can't be added refuses the routine up front.
+ */
+
+import { describe, expect, it } from "bun:test";
+import { mkdirSync, symlinkSync } from "node:fs";
+import { join } from "node:path";
+import { AUTH, CLIENT, git, sandboxManagerHarness } from "./fixtures/sandbox-manager-harness.js";
+
+const t = sandboxManagerHarness();
+
+describe("a routine's added directories are checked and kept by their real path when it is defined (#448 round 13)", () => {
+ it("`~/…` and a link-named dir are kept by their real path; one that can't be added is refused at once", async () => {
+ const m = t.newManager();
+ const home = process.env.HOME as string;
+ const inHome = join(home, "Workspace", "b");
+ mkdirSync(inHome, { recursive: true });
+ git(["init", "-q"], inHome);
+ symlinkSync(join(home, "Workspace"), join(home, "ws"));
+ const spec = (addedDirs: string[]) => ({ name: `r-${Math.random().toString(36).slice(2, 7)}`, prompt: "p", workdir: t.workdir, every: "1h", sandbox: { addedDirs } });
+ const ok = await m.handle({ type: "routine.create", id: "r", spec: spec(["~/Workspace/b", join(home, "ws", "b")]) } as never, AUTH, CLIENT);
+ expect(ok.type).toBe("routine.snapshot");
+ const kept = (ok as unknown as { routine?: { spec?: { sandbox?: { addedDirs?: string[] } } } }).routine?.spec?.sandbox?.addedDirs;
+ expect(kept).toEqual([inHome]);
+ const bad = await m.handle({ type: "routine.create", id: "r", spec: spec([join(t.sibling, ".claude")]) } as never, AUTH, CLIENT);
+ expect(bad.type).toBe("response.error");
+ expect(JSON.stringify(bad)).toContain(".claude");
+ });
+});
From e69726a7bb5d5c73053374e18b1298dceaf06010 Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 16:09:01 +0800
Subject: [PATCH 15/23] docs: #448 round-13 audit: pinned directories, an
agent's own added dirs, the checkouts an autonomous grant never touches, and
the credentials hidden in a root
docs/sandbox.md gains "Directories bound by name are pinned" and says who
added a directory decides whether its credentials are readable, what an
autonomous grant refuses, which git configs and key names are hidden in a
root, that a link-named root is read where it really is, how routines keep
their added dirs, and the race that remains outside a session's own
sandboxes. CHANGELOG and CLAUDE.md follow.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 11 +++
CLAUDE.md | 18 +++--
docs/sandbox.md | 179 ++++++++++++++++++++++++++++++++++++++++++------
3 files changed, 183 insertions(+), 25 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 542bf0fb..c61168f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -256,6 +256,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixed
+- **A directory the sandbox binds by name is never followed to where a link put there leads** (#448).
+ The workspace (a monorepo's top), the working directory, every added directory, every project root and the session's worktree folders are pinned when the session is created, the directory granted or the root first resolved: by real path and identity. Every sandbox build, every launch inside one (bubblewrap, Seatbelt, Docker, a launcher script written once included), Claude's own sandbox settings and every tool call at the gate check it first; one swapped for a link, made anew or gone refuses the command, and the session says what changed. A changed added dir is dropped at the next turn; a changed workspace stops the session's commands until it is put back. The dirs between a writable directory and another shown inside it can't be moved aside from inside (this also restores the same protection for a hidden file's dirs in a workspace under the home). Before, a session granted the repository holding its own workspace, or a directory inside another session's checkout, could get the home bound writable.
+
+- **A directory the agent added itself keeps a project root's credential hiding** (#448).
+ An autonomous `request_directory` grant no longer makes the repository's `.env`, keys or a token in its git config readable; one the owner adds (or approves) still does, and the owner's card now says so. An autonomous grant is also refused for another session's workspace, worktree folder or added directory (or a dir in or holding one), a checkout in a worktree location (forks, loops, `--worktree`), and the session's own main checkout from a fork; the owner's card warns of the same.
+
+- **More credentials are hidden in a project root** (#448).
+ Every git config git may read with a credential (a bare mirror's, a submodule's under `.git/modules`, a worktree's `config.worktree`, a file one includes, a credential store in the git dir), `extraheader` in every syntax git accepts, inline credential helpers and tokens in a URL's query; private keys by their usual names (`*_rsa`, `*_ed25519`, `id_*`, deploy keys, `*.ppk`, `*.p8`); a Mercurial `hgrc`, Yarn's `.yarnrc.yml` and Composer's `auth.json` holding one. A root named through a link is read and hidden where it really is (on bubblewrap its credential files showed through the link's path; on Claude's sandbox every command failed).
+
+- **A routine's added directories are checked and kept by their real path when it is defined** (#448), so a `~/…` or link-named one works at every run instead of being dropped, and one that can't be added is refused up front. A fork in a linked worktree is no longer told its own repository's git config is hidden. The look through roots for credentials no longer holds up the daemon and is done once per resolution, and a default root too big to look through says what counts and what to set.
+
- **Agents in a sandboxed session can read other Claude sessions' transcripts again.**
`~/.claude/projects` is on the sandbox's read-only home allowlist by default: shell commands and Claude's file tools can look up what another session did, never change it. Remove it from `sandbox.homeReadable` to hide them (a secret once pasted into a session is in its transcript).
diff --git a/CLAUDE.md b/CLAUDE.md
index d3813723..7c9c4bf2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -443,9 +443,11 @@ Working across repositories (sandbox/project-roots.ts, dir-rules.ts, request-dir
"Working across repositories"): `sandbox.projectRoots` (default `@parent`: the parent of the repository's main
checkout, `defaultProjectRoot`; none outside a repository, the home or a temp dir falls back to none, said; never
the host's temp dir or in it, `projectRootProblem`) are shown read-only (`SandboxSpec.projectRoots`, in `reexposed`),
-the hidden list hidden inside and `projectRootSecrets` (credential files by name anywhere in a root and in the
-worktree locations shown, a `.git/config` with a credential, not the session's own; bounded per root, a root past it
-not used: fail closed, said). `SessionSandboxSetting.addedDirs` are kept by their real path when granted
+the hidden list hidden inside and `projectRootSecrets` (credential files by name or content anywhere in a root and in the
+worktree locations shown, every git config git reads with a credential: `gitDirSecrets`, bare, `modules/**`,
+`worktrees/*`, includes, a store; not the session's own; bounded per root, a root past it not used: fail closed, said;
+looked through once per resolution, `projectRootSecretsAsync`, reused by its specs: `rootScan`); a root named through
+a link is used by its real path. `SessionSandboxSetting.addedDirs` are kept by their real path when granted
(`canonicalAddedDirs`, `addedDirReal`) and dropped at a build when that no longer is a directory reached through no
link (`stillRealDir`; `Session#sayDroppedDirs`: said, audited, out of the setting), writable, each a workspace to
`buildSandboxSpec` (its `gitWorkspacePaths`, `projectConfigPaths`, worktree folder, a `WorktreeEntryGuard` per
@@ -455,7 +457,9 @@ left out) and to `protectedWriteTargets` and the end-of-turn git look (`#checkAd
in it, `controlDirProblem`). Adding a dir or a root `widens()`. `session.sandbox_dir` (owner, undelegated, owner-only confirm; remove narrows) and the agent's
`request_directory` (`Session#requestDirectory`: guarded/interactive an owner-only confirm with the reason,
autonomous (an unattended run's too) only `autonomousGrantProblem`'s: a repository or a dir in one strictly under a
-root, by a path with no link, else refused; guarded unattended refused; in-process on Claude/Qwen, pi's bridge,
+root, by a path with no link, never another session's or codeoid's checkout (`checkoutOverlap`, SessionManager's
+`sandboxCheckouts`; the owner's card warns), else refused; what the agent added itself (`agentDirs`) keeps a root's
+credential hiding, the owner's are the session's own (the card says so); guarded unattended refused; in-process on Claude/Qwen, pi's bridge,
`/mcp/sandbox` loopback with a per-session token for Codex/Gemini CLI/opencode; `isRequestDirectoryTool` skips the
gate card). A dirs-only change (`onlyDirsChanged`) leaves the runtime until `Session.ensureSandbox` applies it at a
turn start with no turn, approval or background work (`dirsPending`, `SessionSandboxInfo.pendingDirs`), then
@@ -463,6 +467,12 @@ turn start with no turn, approval or background work (`dirsPending`, `SessionSan
(`readAround`: its CLI binds allowRead after allowWrite), never a link in it (it would show the target) nor an entry
that is hidden (an `allowRead` equal to a `denyRead` wins; one strictly inside stays denied, checked on the real CLI). `homeWorkspace`: the chip says "sandboxed: workspace is
your home — only credentials are protected".
+Every directory a sandbox binds by name (workspace, workdir, added dirs, roots, worktree folders) is pinned
+(sandbox/pins.ts, #448 round 13, docs/sandbox.md "Directories bound by name are pinned"): real path and `dev:ino`,
+taken once (`SessionSandboxSetting.pins`, daemon-kept), checked at every `buildSandboxSpec`, every
+`LocalSandboxHandle.wrap` and again in the launched argv (`pinGuardArgv`: a launcher checks at each run), in
+`claudeSandboxSettings`, and at the gate (`Session#sandboxPinRefusal`); a changed one refuses, never followed. Dirs
+between nested writable roots are pinned too (`nestedRootAncestors`).
Shell commands are approved by risk (#431, docs/approvals.md): `src/daemon/shell/`
parses a command line with tree-sitter-bash (WASM, vendored `tree-sitter-bash.wasm`;
diff --git a/docs/sandbox.md b/docs/sandbox.md
index 63df5338..afd39d1d 100644
--- a/docs/sandbox.md
+++ b/docs/sandbox.md
@@ -781,6 +781,53 @@ Limits (defence in depth, as the rest of git's structure):
- On Seatbelt `worktrees/` and each session folder are kept from being renamed
by a `literal` write denial (not verified on a Mac: see below).
+## Directories bound by name are pinned
+
+The sandbox lays each directory it shows by its path: the workspace (a
+monorepo's repository top, when that is the workspace), the working
+directory, each added directory, each project root, the session's worktree
+folders. A path is only a name: a command (another session's, or this
+one's from a directory it may write) could move a directory aside and put a
+symbolic link where it was, and the next command's sandbox would lay where
+the link leads (your home, writable) at that path. So each one is **pinned**
+(#448 round 13):
+
+- **Taken once**: by its real path, with its identity (device and inode),
+ when the session is created (the workspace, the working directory's
+ repository top found then and kept), when a directory is granted, when a
+ root is first resolved. Kept with the session's setting (`pins`, never set
+ by a client), so a restart keeps it; across a reboot only the inode is
+ compared (device numbers are given out anew at boot).
+- **Checked every time**: at every build of a sandbox, at every launch of a
+ program inside one (bubblewrap, Seatbelt, Docker: the handle checks, and
+ the argv it hands back checks again in the shell right before the program
+ starts, so a launcher written once — a process backend's, opencode's shell
+ launcher, the git shim — checks at each run), when Claude's own sandbox is
+ given its settings, and at the gate before every tool call. A directory
+ that is **reached through a symbolic link now, is another directory than
+ the one pinned (moved aside and made again), or is gone** is never
+ followed: the build or launch is refused ("codeoid sandbox: … is not the
+ directory it was …: not run", exit 126), every tool call is refused with
+ the same reason (`session.sandbox_pin_refuse`), and the session says what
+ changed, once (event `sandbox.pin_changed`, audit
+ `session.sandbox_pin_changed`).
+- **What happens next**: an added directory that changed is dropped at the
+ session's next turn (said, as below); a root that changed isn't used, said
+ ("…: not used — set the session's project roots again to use it as it is
+ now"; setting them pins them anew). **A workspace that changed stops the
+ session's commands** until it is the directory it was again: every call is
+ refused, turn after turn; nothing is ever bound where the link leads.
+- **No rename from inside**: every directory between a writable directory
+ and another one shown inside it (a workspace in an added dir: `b/vendor`
+ between `b` and the workspace `b/vendor/lib`; a worktree folder in one) is
+ a mount point of its own on bubblewrap, so a command can't move it aside
+ ("Device or resource busy"). (This also made the same protection for a
+ hidden file's directories apply in a workspace in your home, where it
+ never did.) On Claude's own sandbox such a directory isn't denied (its
+ denial takes the subtree, which would make the workspace read-only): the
+ gate's check, and the CLI's own bind, which doesn't follow a link put
+ there (checked on the real CLI), hold it.
+
## Working across repositories
A session works in one directory, but work often spans several: a library
@@ -821,27 +868,59 @@ read all of `~/Documents`. Name roots in `sandbox.projectRoots` for it.
included: `.env` and `.env.*` (not `.env.example`, `.env.sample`,
`.env.template`), `.envrc`, `.npmrc`, `.pypirc`, `.netrc`,
`.git-credentials`, `.vault-token`, `.pgpass`, `*.pem`, `*.key`, `*.p12`,
- `*.pfx`, `*.jks`, `*.keystore`, private keys (`id_rsa*`, `id_ed25519*`,
- `id_ecdsa*`, `id_dsa*`, not `.pub`), kubeconfigs (`kubeconfig*`,
- `*.kubeconfig`), Terraform state and variables (`*.tfstate*`, `*.tfvars`),
- `credentials` and `credentials.`,
- `service-account*.json`, `secret(s).`, a project's
+ `*.pfx`, `*.jks`, `*.keystore`, PuTTY's `*.ppk`, Apple's `*.p8`, private
+ keys by their usual names (`id_*`, `*_rsa`, `*_ed25519`, `*_ecdsa`,
+ `*_dsa`, `deploy_key`, `*_deploy_key`: with no extension or a key's or a
+ backup's, never `.pub`; `id_generator.ts` or `crypto_rsa.go` is source,
+ not a key), kubeconfigs (`kubeconfig*`, `*.kubeconfig`), Terraform state
+ and variables (`*.tfstate*`, `*.tfvars`), `credentials` and
+ `credentials.`, `service-account*.json`,
+ `secret(s).`, a project's
`.claude/settings.local.json`, `.mcp.json` and `.docker/config.json`, and
`.ssh/`, `.gnupg/`, `.aws/`, `.azure/`, `.gcloud/`, `.kube/`,
- `.password-store/` dirs whole. A repository's **`.git/config` carrying a
- credential** (a remote URL with a user or password in it, an
- `http.*.extraheader`) is hidden too, and said on the session: git in that
+ `.password-store/` dirs whole; by what is in them, Yarn's `.yarnrc.yml`
+ (`npmAuthToken`, `npmAuthIdent`) and `.yarnrc`, Composer's `auth.json`,
+ and a Mercurial `.hg/hgrc` with an `[auth]` section or a password in a URL
+ (a plain one stays readable). **Every git config git may read carrying a
+ credential** is hidden too: a repository's `.git/config` and
+ `config.worktree`, each worktree's `config.worktree` in `.git/worktrees/`,
+ each submodule's config in `.git/modules/` (nested, a name with a slash
+ too), a bare repository's or a mirror's (`mirror.git/config`), every file
+ one of them includes inside the root (`include.path`, `includeIf.*.path`),
+ a credential store one names in the root (`credential.helper = store
+ --file=…`) and one by name in the git dir (`.git/credentials`). A config
+ carries a credential when it has a URL with a user and a password (any
+ scheme) or a user (https), a token in a URL's query (`?private_token=`,
+ `access_token`, …), an `extraheader` in any syntax git reads (on its own
+ line, after its section's header on the same line, under `[http
+ ""]`, any case), or a credential helper that is a shell snippet
+ printing a password or token. This is said on the session: git in that
repository then can't read its config from this session ("unable to access
'.git/config'"). The session's own repository is its workspace, never a
- root's, so its git is unaffected; its added directories' are its to use too.
+ root's, so its git is unaffected (a fork's worktree uses its main
+ checkout's git dir, which is its own too, and isn't said); the dirs its
+ **owner** added are its to use as well; a dir the **agent** added itself
+ keeps all this hiding ([below](#added-directories-other-repositories-writable)).
They are looked for through the root's directories (not through links, not
in `node_modules`, `.venv`, `__pycache__`, `.tox`, `.gradle`, `.next`,
- `.turbo`) when the sandbox is built: one made later isn't hidden; add such
- paths to `sandbox.hiddenPaths`. **The look is bounded** (20000 directories
- per root): a root with more to look through isn't used at all (fail
- closed), said on the session ("project root ~/src: more than 20000
+ `.turbo`) when the session's sandbox is resolved, without holding up the
+ daemon (the look lets other work run as it goes) and once per resolution:
+ every sandbox built from it reuses it. One made later isn't hidden; add
+ such paths to `sandbox.hiddenPaths`. **The look is bounded** (20000
+ directories per root): a root with more to look through isn't used at all
+ (fail closed), said on the session ("project root ~/src: more than 20000
directories to look through…: not used — name narrower roots"); name the
- repositories you need instead of the folder holding them all.
+ repositories you need instead of the folder holding them all. The default
+ root says so plainly ("no project root: the default, the repository's
+ parent (…), holds more than 20000 directories … (build outputs such as
+ target/, build/, dist/ and vendor/ count; node_modules, .venv … don't): not
+ used, so other repositories aren't readable — set sandbox.projectRoots …").
+- **A root named through a link** (`~/code` → `~/Workspace`, or to
+ `/data/code`) is used **where it really is**, said ("project root ~/code is
+ /home/you/Workspace (where it really is): read there"): what is hidden in
+ it is hidden there, and it is [pinned](#directories-bound-by-name-are-pinned)
+ there. (Named by the link, bubblewrap showed its credential files through
+ the link's path, and Claude's sandbox made every command fail.)
- **Refused as a root**: `/`, your home or a dir holding it, anything in a
hidden dir of your home (`~/.config`, `~/.local`, …: where tools load
programs and settings from), `~/Library`, codeoid's config dir, a system
@@ -875,8 +954,25 @@ host-side file tool writing its git structure or settings asked about
([held writes](#file-tools-that-run-outside-the-sandbox)), and the
end-of-turn look at its git settings and commits (`sandbox.git_changed`,
`sandbox.settings_committed`, naming the directory; each repository inside
-it too). What the sandbox hides stays hidden inside one; its own `.env` is
-readable.
+it too). What the sandbox hides stays hidden inside one, and how much of the
+rest is readable depends on who added it:
+
+- **Added by the owner** (`/sandbox add`, `--add-dir`, the new-session
+ dialog, `session.create`, or the owner saying yes to the agent's
+ request): it is treated as a workspace, so its **credential files are
+ readable** to the session's commands (its `.env`, its keys, a token in its
+ git config), as the workspace's are. The owner's card says so ("…; its
+ credential files (.env files, keys, a token in a git config) become
+ readable to its commands, as the workspace's are").
+- **Added by the agent itself** (an autonomous `request_directory`): it
+ stays writable, but **a project root's credential hiding stays in it**,
+ the same names, the same git configs (so git there can't read a config
+ carrying a credential, said); a dir too big to look through for them isn't
+ added (said). The agent is told ("Its credential files … stay hidden to
+ you"). Kept with the session's setting (`agentDirs`, never set by a
+ client), so a restart, a fork and a resume keep it. The owner can add it
+ as theirs (`/sandbox add ` again: asked on the session, with the card
+ above), which makes its credentials readable.
**An added directory is the directory it was when it was granted.** What is
kept, and bound, is where the path really leads at that moment (its real
@@ -910,7 +1006,18 @@ again if it should be.
with no symbolic link in it** is added at once, unasked (audited: `by=agent
a repository under a project root, autonomous`): judged where it really is,
and never a root itself, a folder holding repositories, a git or settings
- dir. Anything else is refused at once with a reason the agent reads ("…
+ dir. **Never another session's or codeoid's checkout either**: a dir that
+ is, is inside or holds another session's workspace, worktree folder or
+ added directory (their commands write it: what they put there, a link
+ included, this session's next command would run against), a checkout in
+ a worktree location (`-worktrees/`, `/.worktrees/`,
+ `sandbox.worktreeRoots`': forks, loops, `--worktree` sessions, other
+ sessions' folders) but its own folder, a dir holding such a location, or
+ another checkout of the session's own repository (its main checkout, seen
+ from a fork's worktree). The owner may still add one: the card warns
+ ("Warning: is session "x"'s workspace … — a change either session
+ makes there … the other one runs against"). The added dir is the agent's
+ own (above): its credentials stay hidden. Anything else is refused at once with a reason the agent reads ("…
isn't in a repository …", "… goes through a symbolic link …", "… isn't
under this session's project roots …; the owner can add it (`/sandbox add
`)"). **An unattended run** (a routine's, a loop worker's, a
@@ -942,7 +1049,12 @@ again if it should be.
- **Kept**: in the session's sandbox setting, so a daemon restart and a
resume keep them, and a fork, a compare and a collaboration's children
inherit them (a fork carries them for the parent's owner only, undelegated,
- as any [wider sandbox](#choosing-a-sandbox)).
+ as any [wider sandbox](#choosing-a-sandbox)). A routine's added
+ directories are checked when it is defined or edited, as a session's are
+ when it is created (one that can't be added refuses the routine), and kept
+ by their real path (`~/…` and a link-named one included); each run's
+ session [pins](#directories-bound-by-name-are-pinned) them when it starts.
+ An added dir inside another isn't kept twice: the outer one holds it.
`request_directory`, per backend:
@@ -1011,8 +1123,18 @@ home is the workspace).
`sandbox.projectRoots: []` (or `--no-project-roots`) where that is too
much, and `sandbox.hiddenPaths` for what must stay out.
- An added directory that is **another worktree of the session's own
- repository** is writable, and its own admin dir with it; the rest of the
- repository's structure stays held.
+ repository** (the owner's to add; never added autonomously) is writable,
+ and its own admin dir with it; the rest of the repository's structure
+ stays held.
+- [Pins](#directories-bound-by-name-are-pinned) are checked right before a
+ program starts, by path: a process **outside** this session's sandboxes
+ that writes the parent of a bound directory (another session the owner let
+ write the same tree, which its card warned of) and swaps it back and forth
+ could still land a swap between the check and bubblewrap's bind. Within
+ the session's own sandboxes the parents can't be moved (mount points), and
+ no autonomous grant overlaps another session's checkout.
+- The keys hidden in a root are found by name; a private key named
+ otherwise (`notes.txt`) is readable.
- The repositories and settings dirs held inside an added directory are those
found when the sandbox is built, outside `node_modules` and the like: one
the agent makes there later (`git init`, a new `.claude/`) is its own work,
@@ -1341,7 +1463,22 @@ works, and skip where it doesn't):
`.claude/settings.local.json`, a sibling worktree's `.env`, a `.git/config`
with a token: git there then can't read its config) are hidden, `.pub`
readable; a root past the look's bound isn't shown; a repository in the
- host's temp dir gets no default root. The real Claude CLI: a project root
+ host's temp dir gets no default root. Round 13
+ (`src/tests/sandbox-pins-real.test.ts`, real bubblewrap): the workspace, an
+ added dir or a root swapped for a link to a planted home between two
+ commands (or made anew) is refused, by the handle and by a launcher written
+ before the swap (exit 126), and the planted key isn't read nor its
+ `.bashrc` written; a workspace inside an added dir: the dir between them
+ can't be moved aside ("Device or resource busy"); an agent's own added dir
+ is written but its `.env`, `.aws/`, deploy key, a deep `.env` (its dirs
+ moved aside too) and token-bearing `.git/config` aren't readable, the
+ owner's are; a bare mirror's, a submodule's and a worktree's config, an
+ included file, a store in the git dir, `github_ed25519`, `*.ppk`, `*.p8`,
+ `id_work`, an `hgrc`, a `.yarnrc.yml` and an `auth.json` with credentials
+ are unreadable through a root, `id_generator.ts` readable; a link-named
+ root's `.env` is hidden by both paths. The real Claude CLI: a link-named
+ root holding the workspace is read and its `.env` hidden, the workspace
+ writable. The real Claude CLI: a project root
shows no `.env` at its top, nothing a link in it leads to (`~/.ssh`,
`~/Documents`, the home), no sign-in in codeoid's private dir inside it, no
deeper key or sibling worktree's `.env`, while the rest of the root reads
From 0d8ebb3188334fa9a8c283dd22897cca043cb3cb Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 16:22:57 +0800
Subject: [PATCH 16/23] fix: #448 round-13 audit: an autonomous grant refused
for the session's own main checkout names it as that checkout, not as a dir
in one
Co-Authored-By: Claude Opus 5.5
---
src/daemon/sandbox/project-roots.ts | 2 +-
src/tests/sandbox-agent-grants.test.ts | 4 +++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/daemon/sandbox/project-roots.ts b/src/daemon/sandbox/project-roots.ts
index f279d6bd..814e1df9 100644
--- a/src/daemon/sandbox/project-roots.ts
+++ b/src/daemon/sandbox/project-roots.ts
@@ -334,7 +334,7 @@ export function checkoutOverlap(
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} is in another checkout of this session's own repository (${realOr(top)}${top === main ? ", its main checkout" : ""})`;
+ 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;
diff --git a/src/tests/sandbox-agent-grants.test.ts b/src/tests/sandbox-agent-grants.test.ts
index 457c2082..3faec08d 100644
--- a/src/tests/sandbox-agent-grants.test.ts
+++ b/src/tests/sandbox-agent-grants.test.ts
@@ -104,7 +104,9 @@ describe("the checkouts an autonomous grant never touches", () => {
const awt = join(root, "a-worktrees", "s1");
mkdirSync(join(root, "a-worktrees"));
git(["worktree", "add", "-q", awt, "-b", "s"], a);
- expect(checkoutOverlap(a, [], { workspace: awt })).toContain("another checkout of this session's own repository");
+ expect(checkoutOverlap(a, [], { workspace: awt })).toContain(`${a} is another checkout of this session's own repository (${a}, its main checkout)`);
+ mkdirSync(join(a, "src"));
+ expect(checkoutOverlap(join(a, "src"), [], { workspace: awt })).toContain(`${join(a, "src")} is in another checkout`);
// A checkout holding codeoid's in-checkout location.
mkdirSync(join(b, ".worktrees"));
expect(checkoutOverlap(b, [], opts)).toContain("holds");
From 2ef6804bfbac187f8bb46f093bbf7ecb75212d2d Mon Sep 17 00:00:00 2001
From: Yash Datta
Date: Mon, 9 Nov 2026 17:42:44 +0800
Subject: [PATCH 17/23] fix: #448 round-14 audit: every path a sandbox binds is
pinned and bound as pinned, never resolved again at a launch; one another
session swaps for a link is refused, said and audited
bubblewrap, Docker and Seatbelt resolved everything but the round-13 pins
with realpath at each launch, so a dir another session's command swapped
for a link (a dir held in place in this session's spec, a path it keeps
read-only) was bound where the link led at this session's next command,
writable: the daemon's private dir with every session's launchers, another
repository (findings-r 1 HIGH). A swapped held dir was re-pinned silently
at the next build (findings-r low).
- sandbox/pins.ts becomes sandbox/pinned.ts, the one pinned-path API: the
identity primitives, the launch guard, pinOf, boundPath, and the daemon
side (PinnedDir, pinDir, inPinnedDir, makeInPinned, makeAt,
replaceInPinned, removeEmptyDir) the next commits route through.
- buildSandboxSpec pins every path it binds by name (SandboxSpec.pins):
the session's writable dirs (git dirs, backend state, cache) and temp dir
by real path, every path held read-only (a file: links only), every dir
held in place, worktree locations (links only); held paths only inside
something writable. The git shim's extra dirs too.
- bwrap/docker/seatbelt bind a pinned path as pinned (boundPath); only
hidden paths, the home's allowlist and sockets are resolved. bubblewrap
never shows a held path inside a hidden dir.
- The launch guard checks held files (no link on the way, not one itself)
and every identity with one stat.
- The gate checks every pin of the running resolution's spec
(SessionSandbox#gateSpec), so a swap is refused, said once and audited.
Tests: sandbox-bound-pinned-real.test.ts (real bubblewrap: the auditor's
scenario for the daemon's private dir and another repository, and a held
.claude/ swapped; refused by the handle and by a launcher written before),
sandbox-bound-pinned.test.ts (providers bind as pinned; the guard),
sandbox-pins-manager.test.ts (the gate, said, audited).
Co-Authored-By: Claude Opus 5.5
---
src/daemon/sandbox/bwrap.ts | 17 +-
src/daemon/sandbox/claude-native.ts | 4 +-
src/daemon/sandbox/docker.ts | 9 +-
src/daemon/sandbox/git-shim.ts | 3 +
src/daemon/sandbox/local-handle.ts | 4 +-
src/daemon/sandbox/pinned.ts | 511 ++++++++++++++++++++
src/daemon/sandbox/pins.ts | 194 --------
src/daemon/sandbox/policy.ts | 56 ++-
src/daemon/sandbox/seatbelt.ts | 8 +-
src/daemon/sandbox/session-sandbox.ts | 22 +-
src/daemon/sandbox/types.ts | 15 +-
src/tests/fixtures/claude-sandbox-probe.ts | 2 +-
src/tests/sandbox-bound-pinned-real.test.ts | 146 ++++++
src/tests/sandbox-bound-pinned.test.ts | 89 ++++
src/tests/sandbox-pins-manager.test.ts | 34 ++
src/tests/sandbox-pins-real.test.ts | 2 +-
src/tests/sandbox-pins.test.ts | 8 +-
17 files changed, 877 insertions(+), 247 deletions(-)
create mode 100644 src/daemon/sandbox/pinned.ts
delete mode 100644 src/daemon/sandbox/pins.ts
create mode 100644 src/tests/sandbox-bound-pinned-real.test.ts
create mode 100644 src/tests/sandbox-bound-pinned.test.ts
diff --git a/src/daemon/sandbox/bwrap.ts b/src/daemon/sandbox/bwrap.ts
index d266cb26..7e6a60a9 100644
--- a/src/daemon/sandbox/bwrap.ts
+++ b/src/daemon/sandbox/bwrap.ts
@@ -29,6 +29,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";
@@ -90,10 +91,11 @@ export function bwrapArgs(spec: SandboxSpec, cwd: string, kind: (p: string) => P
const mounts: Mount[] = [];
const seen = new Set();
- // A pinned directory is bound by the path it was pinned at, never by where a link there leads (pins.ts: the
- // handle checked it is still that directory, and the launched argv checks again).
- const pinned = new Set((spec.pins ?? []).map((p) => p.path));
- const realOr = (p: string) => (pinned.has(p) ? p : realOrPath(p));
+ // 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) => {
@@ -128,9 +130,10 @@ 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);
- // Never one that is hidden (a repository's git config holding a credential, in a dir the agent added): its
- // bind would show it again over the hiding.
- if (!kind(at) || seen.has(`o:${at}`) || seen.has(`h:${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 });
}
diff --git a/src/daemon/sandbox/claude-native.ts b/src/daemon/sandbox/claude-native.ts
index ec66de37..84083a80 100644
--- a/src/daemon/sandbox/claude-native.ts
+++ b/src/daemon/sandbox/claude-native.ts
@@ -36,7 +36,7 @@ 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 "./pins.js";
+import { pinRefusal, pinsProblem, SandboxPinError } from "./pinned.js";
import type { SandboxRuntime } from "./session-sandbox.js";
import type { SandboxSpec } from "./types.js";
@@ -131,7 +131,7 @@ 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 (pins.ts, #448 round 13): never one a link leads from.
+ // 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;
diff --git a/src/daemon/sandbox/docker.ts b/src/daemon/sandbox/docker.ts
index 0e6d7c15..11863df8 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 {
@@ -67,7 +69,7 @@ export type DockerPathKind = "dir" | "other" | null;
function pathKind(p: string): DockerPathKind {
try {
- const st = lstatSync(realOr(p));
+ const st = lstatSync(realOrPath(p));
return st.isDirectory() ? "dir" : "other";
} catch {
return null;
@@ -109,6 +111,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",
diff --git a/src/daemon/sandbox/git-shim.ts b/src/daemon/sandbox/git-shim.ts
index 3de051eb..f613bb01 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 { 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). */
@@ -197,6 +198,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" },
};
}
diff --git a/src/daemon/sandbox/local-handle.ts b/src/daemon/sandbox/local-handle.ts
index bc3a5aff..3673fc65 100644
--- a/src/daemon/sandbox/local-handle.ts
+++ b/src/daemon/sandbox/local-handle.ts
@@ -3,13 +3,13 @@
* 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. Every wrap first checks the spec's pinned
- * directories (pins.ts) and refuses when one isn't what it was, and the argv
+ * 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 "./pins.js";
+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 };
diff --git a/src/daemon/sandbox/pinned.ts b/src/daemon/sandbox/pinned.ts
new file mode 100644
index 00000000..62c4bbfb
--- /dev/null
+++ b/src/daemon/sandbox/pinned.ts
@@ -0,0 +1,511 @@
+/**
+ * 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}).
+ */
+
+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/pins.ts b/src/daemon/sandbox/pins.ts
deleted file mode 100644
index 00677043..00000000
--- a/src/daemon/sandbox/pins.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-/**
- * Every directory a sandbox binds by name, pinned (#448 round 13,
- * docs/sandbox.md "Pinned directories"): the workspace (a monorepo's
- * repository top), the working directory, every added directory, every
- * project root, the session's worktree folders.
- *
- * Each is kept by its real path, taken once (when the session is created,
- * the directory granted, the root first resolved), with its identity
- * (`dev:ino`, {@link pinRecord}). 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) and every tool call at the gate first checks that the path is
- * still that directory, reached through no symbolic link
- * ({@link pinProblem}). One that isn't is never followed: the build, the
- * launch or the call is refused with what changed, and the session says it.
- * A bind by name would otherwise lay what a link put there leads to (the
- * home) writable inside the sandbox.
- */
-
-import { lstatSync, readFileSync, realpathSync } from "node:fs";
-import { resolve } from "node:path";
-
-/** One pinned directory of a spec (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 a directory reached through no link (one codeoid makes and removes). */
- id?: string;
- /** It may be missing (then nothing is bound there): the working directory, a worktree folder. */
- mayBeMissing?: boolean;
-}
-
-/** Raised when a pinned directory isn't what it was: the build or the launch 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` (`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.
- */
-export function pinProblem(p: string, what: string, opts: { stored?: string; id?: string; mayBeMissing?: boolean } = {}): string | null {
- const at = resolve(p);
- const link = linkIn(at);
- if (link) return `${what} ${at} is reached through a symbolic link now (${link})`;
- let st: ReturnType;
- try {
- st = lstatSync(at);
- } catch {
- return opts.mayBeMissing ? null : `${what} ${at} is gone`;
- }
- if (!st.isDirectory()) return `${what} ${at} isn't a directory now`;
- try {
- if (realpathSync(at) !== at) return `${what} ${at} isn't at its real path now`;
- } catch {
- return `${what} ${at} can't be resolved now`;
- }
- const now = `${st.dev}:${st.ino}`;
- if (opts.stored && !sameDir(opts.stored, now)) return `${what} ${at} isn't the directory it was (another one is there now)`;
- if (opts.id && opts.id !== now) return `${what} ${at} isn't the directory it was (another one is there now)`;
- return null;
-}
-
-/** 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 } : {}) });
- 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`;
-}
-
-/** 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 `:
- * each path must be a directory whose physical path is itself (no link on
- * the way: `cd -P`), of `dev:ino` `` unless that is `-` (`stat` prints
- * it: the absolute program found when the script is made; without one only
- * the rest is checked), present unless `` is 1; 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, `'\\''`)}'`;
- return [
- "n=$1; shift",
- 'while [ "$n" -gt 0 ]; do',
- ' p=$1; id=$2; m=$3; shift 3; n=$((n - 1))',
- ' if [ ! -e "$p" ] && [ ! -L "$p" ]; then',
- ' [ "$m" = 1 ] && continue',
- ' printf "codeoid sandbox: %s is gone: not run\\n" "$p" >&2; exit 126',
- " fi",
- ' r=$(cd -P -- "$p" 2>/dev/null && pwd -P) || r=',
- ' if [ "$r" != "$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',
- ...(stat
- ? [` if [ "$id" != - ] && [ "$(${stat.map(quote).join(" ")} -- "$p" 2>/dev/null)" != "$id" ]; then printf "codeoid sandbox: %s is not the directory it was (another one is there now): not run\\n" "$p" >&2; exit 126; fi`]
- : []),
- "done",
- '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];
- return ["/bin/sh", "-c", pinGuardScript(statProgram()), PIN_GUARD_NAME, String(pins.length), ...pins.flatMap((p) => [p.path, p.id ?? "-", p.mayBeMissing ? "1" : "0"]), ...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 bff70ea3..f42f937c 100644
--- a/src/daemon/sandbox/policy.ts
+++ b/src/daemon/sandbox/policy.ts
@@ -34,7 +34,7 @@ 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 { type PathPin, dirIdentity, pinProblem, pinRefusal, SandboxPinError } from "./pins.js";
+import { type PathPin, dirIdentity, pinOf, pinProblem, pinRefusal, 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";
@@ -472,7 +472,7 @@ export interface SpecInputs {
/** 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 (pins.ts `pinRecord`,
+ * 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
@@ -1107,7 +1107,7 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
const workspace = resolve(inputs.workspace);
const sandboxCfg = sandboxConfigOf(inputs.config);
const configDir = inputs.hidden?.configDir ?? getConfigDir();
- // Every directory bound by name, checked first (#448 round 13, pins.ts): one reached through a link, or another
+ // 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 => {
@@ -1170,7 +1170,7 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
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);
@@ -1239,18 +1239,38 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
// 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 is bound by name, as it is now (pins.ts): checked again at every launch.
- const pinOf = (path: string, what: string, mayBeMissing = false): PathPin => {
- const id = mayBeMissing ? null : dirIdentity(path);
- return { path, what, ...(id ? { id } : {}), ...(mayBeMissing || !id ? { mayBeMissing: true } : {}) };
+ // 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.
+ 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
+ // 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);
};
- const pins: PathPin[] = [
- pinOf(workspace, "the workspace"),
- ...(workdir && workdir !== workspace ? [pinOf(workdir, "the working directory", true)] : []),
- ...addedDirs.map((d) => pinOf(d, "added directory")),
- ...roots.map((r) => pinOf(r, "project root")),
- ...locations.map((f) => pinOf(f, "worktree folder", true)),
- ];
+ 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) 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)]));
@@ -1258,15 +1278,15 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec {
sessionId: inputs.sessionId,
workspace,
tempDir,
- writable: [...own, ...addedDirs, ...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: [...new Set([...protectedAncestors(hidden, [workspace, tempDir, ...own, ...addedDirs, ...locations]), ...nestedRootAncestors([workspace, tempDir, ...addedDirs, ...locations], hidden)])],
+ pinned,
pins,
reexposed,
containerSockets: containerSocketPaths(home, env).filter((p) => !exposed.has(p) && !exposed.has(realOr(p))),
diff --git a/src/daemon/sandbox/seatbelt.ts b/src/daemon/sandbox/seatbelt.ts
index 5617fa80..9d93dc86 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