diff --git a/CHANGELOG.md b/CHANGELOG.md index e7931a48..cec0da61 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 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). `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). @@ -251,6 +256,21 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **A monorepo package session can't plant its package's Claude settings, and going back a turn never follows a working directory swapped for a link** (#448). + The working directory and every directory between it and the repository's top can't be moved aside from inside the sandbox any more, nor can a repository inside an added directory or any directory holding a path the sandbox keeps read-only. Before, a sandboxed command could move `packages/app` aside and make it again around a planted `.claude/settings.json`, whose hook a later Claude start there would run on the host. The working directory is pinned by its identity too, and the daemon's own work in it (the snapshots at each turn, going back a turn, a fork from a turn and its setup, a client's file read, the project's MCP config, the end-of-turn git looks) checks it before and after and refuses, says and audits a change; before, a working directory swapped for a link to the home had the home snapshotted, and going back a turn wrote and deleted files there. + Every path a sandbox binds is pinned and bound as it was, never resolved again at a launch: before, a directory another session swapped for a link (one this session holds in place, or 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 included. An agent's own added directory is let go once another session works in it. See [docs/sandbox.md](docs/sandbox.md#pinned-paths). + +- **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 a148acd2..26edf03b 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,54 @@ 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`; 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 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 +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 (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, 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 +`#restartForSandboxDirs` restarts a process backend. Claude native shows a root around what is writable +(`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 path a session uses goes through one module, sandbox/pinned.ts (#448 rounds 13, 14, docs/sandbox.md +"Pinned paths"). Every path a sandbox binds by name (workspace, workdir, added dirs, roots, worktree folders and +locations, writable git/state/cache dirs, temp dir, `readOnly`, `pinned`, `heldInPlace`) is pinned (`SandboxSpec.pins`, +`pinOf`): real path and `dev:ino` (a held file, a codeoid-made folder: links only), the workspace's and the workdir's +taken once (`SessionSandboxSetting.pins`, daemon-kept), the rest at each build; bound as pinned, never `realpath`'d at a +launch (`boundPath`: only hidden paths, the allowlist, sockets are resolved); checked at every `buildSandboxSpec`, +every `LocalSandboxHandle.wrap` and again in the launched argv (`pinGuardArgv`, one `stat`: a launcher checks at each +run), in `claudeSandboxSettings`, and at the gate (`Session#sandboxPinRefusal`, all of the gate spec's pins); a changed +one refuses, never followed. Held in place (mount points; Seatbelt `literal`): dirs between nested writable roots +(`nestedRootAncestors`), and (`SandboxSpec.heldInPlace`, `heldAncestors`) the workdir and its way up to the +workspace and every dir between a writable root and a held path (a package's `.claude/`); Claude native leaves those +to the CLI, which binds them itself (real-CLI test). The daemon's own work in the workdir (checkpoints, rewind, +`rollFilesTo`, fork creation and setup, `fs.read`, MCP config reads, end-of-turn git looks) takes +`Session#pinnedWorkdir` (`PinnedDir`: `inPinnedDir` checks before and after, git with it as cwd and +`GIT_WORK_TREE=.`); a change refuses, said (`workdir.changed`) and audited (`session.workdir_changed`). Daemon-side +makes go through `makeAt`/`makeInPinned`/`replaceInPinned` (O_EXCL|O_NOFOLLOW), removes through `removeEmptyDir`. +`src/tests/pinned-paths-static.test.ts` fails on a direct fs/spawn/cwd/`GIT_WORK_TREE` use in sandbox/, session.ts +or checkpoints.ts without a `// paths: ` comment. An agent's own grant that another session comes to share is +let go at the next turn (`Session#dropSharedAgentDirs`). + 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 +537,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..ba63a23d 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 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 611e9e74..eeeb6a58 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,458 @@ 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). + + +## Pinned paths + +The sandbox lays each path it binds by name: 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 and +locations, the repository's git dirs and the session's other writable dirs, +its temp dir, every path it holds read-only, every directory it holds in +place. And the daemon works in a session's directories itself: its +snapshots, going back a turn, a fork's files. 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 (or another directory) +where it was, and the next use would lay, read or write where the link leads +(your home, writable; the daemon's private dir; a rewind writing into the +home). So every such path is **pinned** (#448 rounds 13 and 14), and goes +through one module, `src/daemon/sandbox/pinned.ts`: + +- **Taken once**: by its real path, with its identity (device and inode) for + a directory, when the session is created (the workspace, its repository top + found then and kept, and the working directory), when a directory is + granted, when a root is first resolved, and at each spec build for the rest. + 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). A held file (a repository's `config`, + a project's `.mcp.json`) and what codeoid makes and removes itself (a + worktree folder, a worktree location) are checked against links only. +- **Bound as pinned, never resolved again**: bubblewrap, Docker and Seatbelt + name a pinned path as it was pinned, never by where it leads at a launch. + Not pinned, by design: what is hidden (a link there hides more), the home's + allowlist and a program's location (shown where a link leads), the owner's + container sockets; none of them is where a sandbox writes. +- **Checked every time**: at every build of a sandbox, at every launch of a + program inside one (the handle checks, and the argv it hands back checks + again in the shell right before the program starts, with one `stat`, 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 path 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`) — a dir + another session's command swapped included ("a directory held in place … + is reached through a symbolic link now"), which is never re-pinned + silently. +- **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); anything else bound is looked at again + by the next turn's resolution. **A workspace or working directory 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**: a directory that holds something the sandbox + shows or holds can't be moved aside by a command. On bubblewrap and Docker + each is a mount point of its own (`mv`: "Device or resource busy"), on + Seatbelt the directory itself isn't writable: + - 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); + - every directory between a writable directory and a hidden path in it; + - **the working directory and every directory between it and the + workspace**, and every directory between a writable directory and a + path held read-only in it (#448 round 14). A monorepo package session's + package dir (`packages/app`) and `packages/` can't be moved aside to + leave a planted `packages/app/.claude/settings.json` in their place, which + a later Claude start there (a provider switch, a fork onto Claude, a new + session) would load and run its hooks from on the host; neither can a + repository inside an added directory (`b/lib`, its `.claude/` held), nor + a submodule's git dir. + + On Claude's own sandbox such a directory isn't denied (its denial takes the + subtree, which would make the workspace read-only): the CLI binds every + directory from a writable root down to what it binds inside it, so each is + a mount point there too. Checked on the real CLI + (`sandbox-claude-real.test.ts`, "held in place"): moving a package dir, the + dir above it, a repository inside an added dir, and the dir between a + workspace and the added dir holding it all fail ("Device or resource + busy"). The gate's check holds what the CLI doesn't. + +### The daemon's own work in a session's directories + +The daemon reads and writes the working directory itself, outside every +sandbox: the snapshot at each turn's start and end, going back a turn, a fork +from a turn (the worktree, the files it carries, the files it rolls back), a +fork's setup command, a client's file read, the project's MCP config, the +end-of-turn git looks. Each takes the working directory as pinned +(`PinnedDir`), checks it before and again after, and runs git with it as the +directory git starts in (`GIT_WORK_TREE=.`), so even a swap racing it can't +send the work elsewhere unnoticed. One that changed is refused, never +followed: "`` changed under the session: it is reached through a +symbolic link now (…); going back a turn refused (never followed)", said on +the session once per change (event `workdir.changed`) and audited +(`session.workdir_changed`). Nothing of where the link leads is copied into +the session's snapshots, written or deleted. This holds for a session +without a sandbox too (its working directory is pinned at its first use). + +What codeoid makes in a session's directories (the project settings' +placeholders, its entries in `info/exclude`, worktree folders, the CLI's +worktree dir) is made through the same module: in a directory checked before +and after, each directory on the way never a link, a file opened +`O_CREAT | O_EXCL | O_NOFOLLOW`, a file rewritten whole beside it and renamed +over it; an empty worktree folder is removed only where no part of its path +is a link. The look at the repository's worktree entries runs with the +repository's git dir pinned. + +A static check (`src/tests/pinned-paths-static.test.ts`) fails when a direct +file-system use, a spawned program, a `cwd` or a `GIT_WORK_TREE` appears in +the sandbox, session or checkpoint modules without a `// paths:` comment +saying why it is safe (the daemon's own dir, the user's own config no sandbox +writes, a look, a path already checked through `pinned.ts`). + +### An agent's own grant, shared later + +A directory the agent added itself (an autonomous `request_directory`) is +never one another session writes (above). When another session comes to +work there afterwards (made in it, or given it), the next turn's resolution +lets the agent's grant go: said ("…, which the agent added, is no longer +writable: … is session "infra"'s workspace"), audited +(`session.sandbox_dir_dropped`). The owner can add it as theirs if both +should write it. + +## 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. **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 > + "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 **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`, 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; 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 (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 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. 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](#pinned-paths) + 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 + 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. +- 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, +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; each repository inside +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 +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: + +- **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** 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. **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 + 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; 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, + 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](#pinned-paths) them when it starts. + An added dir inside another isn't kept twice: the outer one holds it. + +`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`); 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 +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. +- 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 + much, and `sandbox.hiddenPaths` for what must stay out. +- An added directory that is **another worktree of the session's own + 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](#pinned-paths) 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; the same + between the daemon's own check of the working directory and the start of + the git it runs there (the check after it says so, but can't take back + what that git did). Within the session's own sandboxes the parents, the + working directory and the dirs on its way up can't be moved (mount + points), and no autonomous grant overlaps another session's checkout (one + that comes to, is let go at the next turn). +- A path held but **missing when the sandbox was built** (a worktree folder + removed while empty, a location) is checked against links only, and only + when it is there: one made between the launch's check and its bind isn't + checked. Its location is read-only to every sandbox, so only a process + outside them could make it. +- The credential files hidden in a root or an agent's own added directory + are those **found when the sandbox was resolved** (each turn's start at + most): one your own tools write there meanwhile (an `.env` an env pull + makes, a token written into that repository's `.git/config`), or one + another session renames within a directory it writes, is readable to the + session's commands until its next resolution. +- 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, + as in the workspace (what runs after the agent touched it isn't vouched + for). + ## Docker access Tests that start containers (testcontainers' MySQL or Postgres, `docker run` @@ -950,9 +1405,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 +1531,67 @@ 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. 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. 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 + and the workspace writes. Round 14, real bubblewrap: + `src/tests/sandbox-held-real.test.ts`: a monorepo package session can't + move its package dir or `packages/` aside, nor put a link where its working + directory was, nor write the package's `.claude/settings.json`, while it + writes the package and runs git; a repository inside an added dir can't be + moved aside; a working directory made anew refuses the build by its + identity. `src/tests/sandbox-bound-pinned-real.test.ts`: a dir held in + place in one session's spec (an agent dir's hidden `.env`'s dir) swapped by + another session's command for a link to a planted daemon dir or another + repository refuses the first session's next command, by the handle and by + a launcher written before (exit 126), and nothing lands there; a held + `.claude/` swapped for a link refuses too. The real Claude CLI: the same + moves fail there ("Device or resource busy"), the dir between a workspace + and the added dir holding it included. Through the real SessionManager and + real git (`src/tests/workdir-pinned.test.ts`): a package dir swapped for a + link to a planted home (or made anew) between turns isn't snapshotted; + going back a turn, a fork from an earlier turn and a client's file read + are refused, said and audited; the planted home is untouched. - `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 diff --git a/packages/core/src/sandbox.test.ts b/packages/core/src/sandbox.test.ts index 34b9b846..a3bc12b4 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, projectRoots: [] })).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/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/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/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..a59ba574 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -386,6 +386,37 @@ 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[]; + /** + * 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 + * 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"; @@ -423,6 +454,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 +1307,7 @@ export type ClientMessage = | SessionDestroyMsg | SessionSetModeMsg | SessionSetSandboxMsg + | SessionSandboxDirMsg | SandboxProvidersMsg | SessionPinMsg | SessionUnpinMsg @@ -1841,6 +1884,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/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/config.ts b/src/config.ts index a27f24c9..6cf02c6f 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, 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"; @@ -294,6 +295,28 @@ 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, …), + * 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( + z + .string() + .min(1) + .max(4096) + .superRefine((root, ctx) => { + if (root === PROJECT_ROOT_PARENT) return; + const problem = projectRootProblem(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 +328,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 +1842,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/checkpoints.ts b/src/daemon/checkpoints.ts index 67dda45c..e6d1620f 100644 --- a/src/daemon/checkpoints.ts +++ b/src/daemon/checkpoints.ts @@ -30,6 +30,12 @@ * - It works in non-git directories too, identically. * - The child gets a minimal environment, never the daemon's (which holds * credentials — see providers/env.ts). + * - The work tree is a pinned directory (sandbox/pinned.ts, #448 round + * 14): the session's working directory as it was when the session was + * made, checked before every snapshot, look and restore and again after, + * and given to git as the cwd it starts in. One an agent moved aside and + * replaced (by a link to the home, or another directory) is refused, + * never snapshotted, written into or deleted from. * * The cost of self-containment is one compressed copy of the work tree on a * session's first snapshot; later snapshots store only what changed (git @@ -47,6 +53,7 @@ import { existsSync } from "node:fs"; import { appendFile, copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import { inPinnedDir, PinnedPathError, type PinnedDir, pinDir } from "./sandbox/pinned.js"; import { daemonTempDir } from "./sandbox/root.js"; const execFileP = promisify(execFile); @@ -78,7 +85,24 @@ export const DEFAULT_CHECKPOINT_LIMITS: CheckpointLimits = { timeoutMs: 30_000, }; -export type CheckpointResult = { ok: true; sha: string; late: boolean } | { ok: false; reason: string }; +/** `pinned`: refused because the work tree isn't the directory pinned (PinnedPathError), nothing taken. */ +export type CheckpointResult = { ok: true; sha: string; late: boolean } | { ok: false; reason: string; pinned?: true }; + +/** + * A snapshot's work tree: the caller's pinned directory (Session: the + * working directory as pinned when the session was made), or a path pinned + * now (it must be a directory at its real path, reached through no link). + */ +export type WorkTree = string | PinnedDir; + +function workTreeOf(w: WorkTree, op: string): PinnedDir { + return typeof w === "string" ? pinDir(w, "the working directory", null, op) : w; +} + +/** A failure as a result: one of the work tree's pin marked so. */ +function failure(err: unknown): { ok: false; reason: string; pinned?: true } { + return { ok: false, reason: err instanceof Error ? err.message : String(err), ...(err instanceof PinnedPathError ? { pinned: true as const } : {}) }; +} /** A recorded checkpoint. `late`: the turn had already started while the files were being read. */ export interface CheckpointRecord { @@ -164,6 +188,7 @@ function gitEnv(extra: Record): Record { /** Belt and braces on top of the shadow repo's own config. */ const SAFE_FLAGS = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "core.untrackedCache=false"]; +// paths: a git, with the cwd each caller gives: the shadow repository, or a work tree's path inside inPinnedDir (checked before and after) async function run( args: string[], opts: { cwd: string; env: Record; timeoutMs: number; input?: string }, @@ -184,6 +209,7 @@ async function run( * directory and renamed into place, so a crash can't leave a repo that looks * ready but lacks its configuration. */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) async function ensureShadow(shadow: string, timeoutMs: number): Promise { if (existsSync(path.join(shadow, "HEAD"))) return; const parent = path.dirname(shadow); @@ -215,6 +241,7 @@ async function ensureShadow(shadow: string, timeoutMs: number): Promise { } } +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) async function shadowSizeBytes(shadow: string, timeoutMs: number): Promise { const out = await run(["count-objects", "-v"], { cwd: shadow, env: gitEnv({ GIT_DIR: shadow }), timeoutMs }); let kib = 0; @@ -234,6 +261,7 @@ type OrderEntry = { turnId: string; sha: string; late: boolean; head?: string }; const orderLine = (e: OrderEntry): string => `${e.turnId} ${e.sha}${e.late ? " late" : ""}${e.head ? ` head=${e.head}` : ""}\n`; +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) async function readOrder(shadow: string): Promise { try { const raw = await readFile(path.join(shadow, "turns.log"), "utf8"); @@ -263,6 +291,7 @@ async function readOrder(shadow: string): Promise { * names and retry. (Nested repositories are recorded as a pointer to their * checked-out commit, never their files — git's model for embedded repos.) */ +// paths: in the work tree's path stageWorkTree was given inside inPinnedDir (checked before and after), git given it as `.` async function addAll(workdir: string, env: Record, timeoutMs: number, extra: string[]): Promise { const excludes = [...extra]; for (let attempt = 0; attempt < 20; attempt++) { @@ -288,7 +317,7 @@ const inFlight = new Set(); */ export async function createCheckpoint(opts: { root: string; - workdir: string; + workdir: WorkTree; sessionId: string; turnId: string; limits?: Partial; @@ -336,21 +365,29 @@ export async function createCheckpoint(opts: { await prune(shadow, keep, t, { reclaimNow: true }).catch(() => {}); if (keep === 0) break; } - // The user repo's HEAD, so a fork from this point can start from the same - // commit. Read BEFORE the files: a commit landing mid-snapshot then shows - // as the files' uncommitted edits, never as reverts of a newer commit. - // rev-parse only reads (no hooks, fsmonitor off). - const head = await run(["rev-parse", "--verify", "-q", "HEAD^{commit}"], { cwd: opts.workdir, env: gitEnv({}), timeoutMs: t }) - .then((o) => o.trim()) - .catch(() => ""); - const staged = await stageWorkTree(shadow, opts.workdir, limits, opts.excludeDirs, opts.excludePaths); - if (!staged.ok) return staged; - const late = opts.isLate?.() === true; - const { env, first } = staged; - const tree = (await run(["write-tree"], { cwd: opts.workdir, env, timeoutMs: t })).trim(); + // The files, read in the pinned work tree (checked before and after: a snapshot of a directory put in its + // paths: the work tree pinned (workTreeOf) and checked before and after (inPinnedDir) for the git runs in it; the rest is the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) + // place is never recorded, #448 round 14). + const work = workTreeOf(opts.workdir, "a snapshot of the files"); + const read = await inPinnedDir(work, "a snapshot of the files", async (wd) => { + // The user repo's HEAD, so a fork from this point can start from the same + // commit. Read BEFORE the files: a commit landing mid-snapshot then shows + // as the files' uncommitted edits, never as reverts of a newer commit. + // rev-parse only reads (no hooks, fsmonitor off). + const head = await run(["rev-parse", "--verify", "-q", "HEAD^{commit}"], { cwd: wd, env: gitEnv({}), timeoutMs: t }) + .then((o) => o.trim()) + .catch(() => ""); + const staged = await stageWorkTree(shadow, wd, limits, opts.excludeDirs, opts.excludePaths); + if (!staged.ok) return staged; + const late = opts.isLate?.() === true; + const tree = (await run(["write-tree"], { cwd: wd, env: staged.env, timeoutMs: t })).trim(); + return { ok: true as const, head, late, tree, env: staged.env, first: staged.first }; + }); + if (!read.ok) return read; + const { head, late, tree, first } = read; const message = `codeoid checkpoint\n\nsession: ${opts.sessionId}\nturn: ${opts.turnId}\n`; - const sha = (await run(["commit-tree", tree, "-F", "-"], { cwd: opts.workdir, env, timeoutMs: t, input: message })).trim(); const shadowEnv = gitEnv({ GIT_DIR: shadow }); + const sha = (await run(["commit-tree", tree, "-F", "-"], { cwd: shadow, env: shadowEnv, timeoutMs: t, input: message })).trim(); await run(["update-ref", `refs/turns/${opts.turnId}`, sha], { cwd: shadow, env: shadowEnv, timeoutMs: t }); await appendFile( path.join(shadow, "turns.log"), @@ -360,10 +397,11 @@ export async function createCheckpoint(opts: { await prune(shadow, limits.maxPerSession, t).catch(() => {}); // A first snapshot of a big tree leaves one loose object per file; pack // them so the store stays compact and cheap to copy for forks. + // paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) if (first) await run(["repack", "-a", "-d", "-q"], { cwd: shadow, env: shadowEnv, timeoutMs: t * 4 }).catch(() => {}); return { ok: true, sha, late }; } catch (err) { - return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + return failure(err); } finally { inFlight.delete(shadow); } @@ -371,8 +409,11 @@ export async function createCheckpoint(opts: { /** * Stage the work tree into the shadow index — bounded by the limits — so - * `write-tree` yields its snapshot. Caller holds the shadow's in-flight slot. + * `write-tree` yields its snapshot. Caller holds the shadow's in-flight slot, + * and has checked `workdir` (a pinned dir's path): every git here starts in + * it and takes it as `.`, never resolving the path again. */ +// paths: in the work tree's path its caller checked (inPinnedDir), git given it as `.`; the index and objects are the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) async function stageWorkTree( shadow: string, workdir: string, @@ -385,7 +426,7 @@ async function stageWorkTree( // A lock left by a snapshot killed mid-write (crash, forced shutdown). // Ours are serialized by `inFlight`, so any lock here now is stale. await rm(path.join(shadow, "index.lock"), { force: true }); - const env = gitEnv({ GIT_DIR: shadow, GIT_WORK_TREE: workdir }); + const env = gitEnv({ GIT_DIR: shadow, GIT_WORK_TREE: "." }); const inside = (p: string) => { const rel = path.relative(workdir, p); return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("/") : null; @@ -447,26 +488,30 @@ async function stageWorkTree( * (same scope and exclusions as a snapshot), without recording a checkpoint * — what "go back a turn" compares a snapshot against. */ +// paths: the work tree pinned and checked before and after (inPinnedDir); the index is the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) export async function currentTree(opts: { root: string; - workdir: string; + workdir: WorkTree; sessionId: string; excludeDirs?: readonly string[]; excludePaths?: readonly string[]; limits?: Partial; -}): Promise<{ ok: true; tree: string } | { ok: false; reason: string }> { +}): Promise<{ ok: true; tree: string } | { ok: false; reason: string; pinned?: true }> { const limits = { ...DEFAULT_CHECKPOINT_LIMITS, ...opts.limits }; const shadow = shadowRepoPath(opts.root, opts.sessionId); if (inFlight.has(shadow)) return { ok: false, reason: "a snapshot is still running" }; inFlight.add(shadow); try { await ensureShadow(shadow, limits.timeoutMs); - const staged = await stageWorkTree(shadow, opts.workdir, limits, opts.excludeDirs, opts.excludePaths); - if (!staged.ok) return staged; - const tree = (await run(["write-tree"], { cwd: opts.workdir, env: staged.env, timeoutMs: limits.timeoutMs })).trim(); - return { ok: true, tree }; + const work = workTreeOf(opts.workdir, "reading the current files"); + return await inPinnedDir(work, "reading the current files", async (wd) => { + const staged = await stageWorkTree(shadow, wd, limits, opts.excludeDirs, opts.excludePaths); + if (!staged.ok) return staged; + const tree = (await run(["write-tree"], { cwd: wd, env: staged.env, timeoutMs: limits.timeoutMs })).trim(); + return { ok: true as const, tree }; + }); } catch (err) { - return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + return failure(err); } finally { inFlight.delete(shadow); } @@ -527,9 +572,10 @@ export async function diffTrees(root: string, sessionId: string, from: string, t * directories, a deleted file whose path is now taken by an ignored one): * the target is `to` with those entries taken from `from`. */ +// paths: the work tree pinned and checked before and after (inPinnedDir); the index is the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) export async function restoreTree(opts: { root: string; - workdir: string; + workdir: WorkTree; sessionId: string; from: string; to: string; @@ -541,30 +587,12 @@ export async function restoreTree(opts: { const tmpIndex = path.join(shadow, `index.restore-${process.pid}-${Date.now()}`); const t = DEFAULT_CHECKPOINT_LIMITS.timeoutMs * 2; try { - const env = gitEnv({ GIT_DIR: shadow, GIT_WORK_TREE: opts.workdir, GIT_INDEX_FILE: tmpIndex }); + // Only into the pinned work tree: checked before, and after (a rename racing the restore is said, never taken + // as done); every git starts in it and takes it as `.`, never resolving its path again (#448 round 14). + const work = workTreeOf(opts.workdir, "putting the files back"); let target = opts.to; if (opts.keep && opts.keep.length > 0) target = await treeKeeping(shadow, opts.to, opts.from, opts.keep, t); - // Start from the shadow index — {@link currentTree} just staged `from` - // into it, so its stat cache spares a full re-hash on a big tree — then - // make it exactly `from` (a one-tree merge keeps the cached stats). - try { - await copyFile(path.join(shadow, "index"), tmpIndex); - await run(["read-tree", "-m", opts.from], { cwd: opts.workdir, env, timeoutMs: t }); - } catch { - await rm(tmpIndex, { force: true }).catch(() => {}); - await run(["read-tree", opts.from], { cwd: opts.workdir, env, timeoutMs: t }); - } - // Stat-refresh so read-tree can verify the work tree still matches `from`. - await run(["update-index", "--refresh"], { cwd: opts.workdir, env, timeoutMs: t }).catch(() => {}); - try { - await run(["read-tree", "-m", "-u", opts.from, target], { cwd: opts.workdir, env, timeoutMs: t }); - } catch (err) { - const stderr = String((err as { stderr?: unknown }).stderr ?? "").trim(); - if (/not uptodate|would be overwritten/.test(stderr)) { - throw new RestoreConflictError(`files changed since the preview, nothing was restored (${stderr.split("\n")[0]})`); - } - throw err; - } + await inPinnedDir(work, "putting the files back", (wd) => restoreInto(shadow, wd, tmpIndex, opts.from, target, t)); } finally { inFlight.delete(shadow); await rm(tmpIndex, { force: true }).catch(() => {}); @@ -572,6 +600,33 @@ export async function restoreTree(opts: { } } +/** {@link restoreTree}'s git work, in the checked work tree `wd` (each git starts there and takes it as `.`). */ +// paths: in the work tree's path restoreTree checked (inPinnedDir), git given it as `.` (it never writes through a symlinked parent); the index is the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) +async function restoreInto(shadow: string, wd: string, tmpIndex: string, from: string, target: string, t: number): Promise { + const env = gitEnv({ GIT_DIR: shadow, GIT_WORK_TREE: ".", GIT_INDEX_FILE: tmpIndex }); + // Start from the shadow index — {@link currentTree} just staged `from` + // into it, so its stat cache spares a full re-hash on a big tree — then + // make it exactly `from` (a one-tree merge keeps the cached stats). + try { + await copyFile(path.join(shadow, "index"), tmpIndex); + await run(["read-tree", "-m", from], { cwd: wd, env, timeoutMs: t }); + } catch { + await rm(tmpIndex, { force: true }).catch(() => {}); + await run(["read-tree", from], { cwd: wd, env, timeoutMs: t }); + } + // Stat-refresh so read-tree can verify the work tree still matches `from`. + await run(["update-index", "--refresh"], { cwd: wd, env, timeoutMs: t }).catch(() => {}); + try { + await run(["read-tree", "-m", "-u", from, target], { cwd: wd, env, timeoutMs: t }); + } catch (err) { + const stderr = String((err as { stderr?: unknown }).stderr ?? "").trim(); + if (/not uptodate|would be overwritten/.test(stderr)) { + throw new RestoreConflictError(`files changed since the preview, nothing was restored (${stderr.split("\n")[0]})`); + } + throw err; + } +} + /** The files changed under the restore between preview and apply. Nothing was written. */ export class RestoreConflictError extends Error { constructor(message: string) { @@ -581,6 +636,7 @@ export class RestoreConflictError extends Error { } /** Tree `to`, with each path in `keep` as it is in `from` (or absent when `from` lacks it). */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) async function treeKeeping(shadow: string, to: string, from: string, keep: readonly string[], timeoutMs: number): Promise { const tmp = path.join(shadow, `index.keep-${process.pid}-${Date.now()}`); const env = gitEnv({ GIT_DIR: shadow, GIT_INDEX_FILE: tmp, GIT_LITERAL_PATHSPECS: "1" }); @@ -602,6 +658,7 @@ async function treeKeeping(shadow: string, to: string, from: string, keep: reado } /** Drop all but the newest `keep` checkpoints, and the objects only they held. */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) async function prune(shadow: string, keep: number, timeoutMs: number, opts: { reclaimNow?: boolean } = {}): Promise { const order = await readOrder(shadow); const excess = order.length - Math.max(0, keep); @@ -632,6 +689,7 @@ const RECLAIM_EVERY = 20; * Every checkpoint of a session, oldest first: `turnId → sha`. Trusts only * entries whose ref still points at the commit the daemon recorded. */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) export async function listCheckpoints( root: string, sessionId: string, @@ -671,6 +729,7 @@ export async function listCheckpoints( * session is destroyed. `turnIds` limits the copy; default all. Objects are * fetched, so the copy is self-contained and survives the source repo. */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) export async function copyCheckpoints(opts: { root: string; fromSessionId: string; @@ -703,6 +762,7 @@ export async function copyCheckpoints(opts: { * Remove shadow repositories that belong to no known session — left behind * by a crash between destroy's steps, or by state lost outside the daemon. */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) export async function sweepCheckpoints(root: string, knownSessionIds: ReadonlySet): Promise { let removed = 0; let names: string[]; @@ -722,6 +782,7 @@ export async function sweepCheckpoints(root: string, knownSessionIds: ReadonlySe } /** Delete one checkpoint (a snapshot for a turn that never started). */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) export async function deleteCheckpoint(root: string, sessionId: string, turnId: string): Promise { if (!SAFE_ID.test(turnId)) return; const shadow = shadowRepoPath(root, sessionId); @@ -733,6 +794,7 @@ export async function deleteCheckpoint(root: string, sessionId: string, turnId: } /** Rewrite turns.log atomically — a torn rewrite would hide every snapshot. */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) async function writeOrder(shadow: string, entries: readonly OrderEntry[]): Promise { const file = path.join(shadow, "turns.log"); const tmp = `${file}.tmp`; @@ -741,6 +803,7 @@ async function writeOrder(shadow: string, entries: readonly OrderEntry[]): Promi } /** Delete a session's checkpoints (session destroy). Never throws. */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) export async function deleteCheckpoints(root: string, sessionId: string): Promise { try { await rm(shadowRepoPath(root, sessionId), { recursive: true, force: true }); @@ -750,6 +813,7 @@ export async function deleteCheckpoints(root: string, sessionId: string): Promis } /** {@link shadowGit} with literal pathspecs (a file named `*` is that file). */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) export function shadowGitLiteral(root: string, sessionId: string, args: string[]): Promise { const shadow = shadowRepoPath(root, sessionId); return run(args, { @@ -766,6 +830,7 @@ export function shadowGitLiteral(root: string, sessionId: string, args: string[] * since. The snapshot's ignore files are materialized in a scratch directory * and asked with `check-ignore --no-index`; nothing in the workdir is read. */ +// paths: a scratch repository in the daemon's private dir, and the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides): no work tree export async function ignoredUnderTree(root: string, sessionId: string, sha: string, paths: readonly string[]): Promise { if (paths.length === 0) return []; const shadow = shadowRepoPath(root, sessionId); @@ -826,16 +891,18 @@ export async function ignoredUnderTree(root: string, sessionId: string, sha: str * Run git against a session's shadow repository (for the features that read * or restore checkpoints). Same hardened environment as snapshots. */ +// paths: the shadow repository: the daemon's own, in its transcripts dir (which every sandbox hides) export async function shadowGit( root: string, sessionId: string, args: string[], - opts: { workdir?: string; timeoutMs?: number; input?: string } = {}, + opts: { timeoutMs?: number; input?: string } = {}, ): Promise { const shadow = shadowRepoPath(root, sessionId); + // The shadow repository only: a work tree is reached through the pinned functions above, never here. return run(args, { - cwd: opts.workdir ?? shadow, - env: gitEnv({ GIT_DIR: shadow, ...(opts.workdir ? { GIT_WORK_TREE: opts.workdir } : {}) }), + cwd: shadow, + env: gitEnv({ GIT_DIR: shadow }), timeoutMs: opts.timeoutMs ?? DEFAULT_CHECKPOINT_LIMITS.timeoutMs, ...(opts.input !== undefined ? { input: opts.input } : {}), }); 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..5484339d 100644 --- a/src/daemon/providers/opencode/index.ts +++ b/src/daemon/providers/opencode/index.ts @@ -33,6 +33,7 @@ * authoritative for files. */ +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"; @@ -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,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 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) }); 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/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/backend-home.ts b/src/daemon/sandbox/backend-home.ts index 6c988017..4eb269b4 100644 --- a/src/daemon/sandbox/backend-home.ts +++ b/src/daemon/sandbox/backend-home.ts @@ -297,6 +297,7 @@ interface CopiedLogin { /** Larger isn't a login. */ const LOGIN_LIMIT = 256 * 1024; +// paths: a look (a login's text), no write function readSmallFile(p: string): string | null { try { const st = lstatSync(p); @@ -322,6 +323,7 @@ function syncLogins(copied: CopiedLogin[]): LoginSync { if (merged.text === c.held) continue; // The real one must still be what was copied: a refresh someone else saved meanwhile isn't overwritten. if (readSmallFile(c.real) !== c.held) continue; + // paths: the user's own login file, in the backend's config dir (every sandbox hides it): written beside it (O_EXCL) and renamed over it, never through a link const part = join(dirname(c.real), `.${basename(c.real)}.codeoid-${process.pid}-${randomBytes(4).toString("hex")}`); try { writeFileSync(part, merged.text, { mode: 0o600, flag: "wx" }); @@ -373,6 +375,7 @@ export function withLoginsSynced(handle: SandboxHandle, home: Pick syncLogins(copied) }; + // paths: into the private home: fresh, made by the daemon in its private dir for this start const link = (name: string, to: string) => { try { symlinkSync(to, join(target, name)); @@ -415,6 +419,7 @@ export function preparePrivateHome( copyEntryDir(join(target, name)); continue; } + // paths: the session's state dir: the daemon's own (its transcripts dir, which every sandbox hides); bound writable only at its own path (a mount point in the backend's sandbox, and pinned by the spec) const own = join(opts.state, name); try { mkdirSync(own, { recursive: true, mode: 0o700 }); @@ -425,6 +430,7 @@ export function preparePrivateHome( link(name, at); out.writable.push(at); } + // paths: a look at the user's own backend config dir (every sandbox hides it), no write let names: string[] = []; try { names = readdirSync(source); @@ -437,6 +443,7 @@ export function preparePrivateHome( const refresh = layout.refreshable?.[name]; if (refresh) { // A private copy it may save a refresh to; written back when its process ends. + // paths: copied from the user's own backend config into the private home made for this start (O_EXCL) const real = realOr(from); const held = readSmallFile(real); if (held !== null) { @@ -461,6 +468,7 @@ export function preparePrivateHome( return out; } +// paths: the private home made for this start: the daemon's own function copyEntryDir(to: string): void { try { mkdirSync(to, { mode: 0o700 }); @@ -470,6 +478,7 @@ function copyEntryDir(to: string): void { } /** A small file copied (0600), a directory made empty, anything else left out. */ +// paths: from the user's own backend config (every sandbox hides it) into the private home made for this start function copyEntry(from: string, to: string, o: { fileOnly?: boolean; anySize?: boolean } = {}): void { let st: ReturnType; try { diff --git a/src/daemon/sandbox/bwrap.ts b/src/daemon/sandbox/bwrap.ts index 07c1279e..f44fe176 100644 --- a/src/daemon/sandbox/bwrap.ts +++ b/src/daemon/sandbox/bwrap.ts @@ -13,7 +13,8 @@ * --ro-bind /dev/null a hidden file or socket: empty, and nothing to connect to * --ro-bind

read-only inside something writable (what of a repository decides its config, * hooks and git dir; the project settings agent CLIs run code from) - * --bind a pinned directory: a mount point, so it can't be renamed, still writable inside + * --bind a pinned directory, or one held in place: a mount point, so it can't be renamed, + * still writable inside * --ro-bind a container runtime socket the owner let in (spec.dockerSockets; off by default): * connecting needs no write to the mount, and it can't be removed or replaced * --setenv the toolchains' caches in the sandbox's cache dir (spec.env) @@ -29,6 +30,7 @@ import { lstatSync, realpathSync } from "node:fs"; import { join, resolve } from "node:path"; import { LocalSandboxHandle } from "./local-handle.js"; +import { boundPath } from "./pinned.js"; import type { NetworkMode, SandboxCapabilities, SandboxHandle, SandboxProbe, SandboxProvider, SandboxSpawnOptions, SandboxSpec } from "./types.js"; export const BWRAP_PROVIDER_ID = "bwrap"; @@ -36,6 +38,7 @@ export const BWRAP_PROVIDER_ID = "bwrap"; /** What is at a path: a directory, anything else that exists, or nothing. */ export type PathKind = "dir" | "other" | null; +// paths: a look at what is there, no write: a pinned path the handle checked first, and bwrap binds as pinned export function pathKind(p: string): PathKind { try { const st = lstatSync(p); @@ -51,7 +54,8 @@ export function pathKind(p: string): PathKind { } /** The real path when it exists, else the path. */ -function realOr(p: string): string { +// paths: only for what isn't pinned (boundPath): a hidden path (a link there hides more), the home's allowlist (shown where it leads, by design) +function realOrPath(p: string): string { try { return realpathSync(p); } catch { @@ -90,15 +94,22 @@ export function bwrapArgs(spec: SandboxSpec, cwd: string, kind: (p: string) => P const mounts: Mount[] = []; const seen = new Set(); + // Everything bound by name is pinned, and bound by the path it was pinned at, never by where a link there leads + // (pinned.ts: the handle checked it is still that, and the launched argv checks again; #448 round 14: never + // resolved again here). Only what isn't pinned is resolved: a hidden path (a link there hides more), the home's + // allowlist (shown where a link leads, by design). + const realOr = boundPath(spec.pins, realOrPath); const writable = [spec.workspace, ...spec.writable].map(realOr); + const shownInTmp = [...writable, ...spec.reexposed.flatMap((r) => [resolve(r), realOr(r)]).filter((r) => r !== "/tmp")]; const hide = (p: string) => { const at = realOr(p); const k = kind(at); if (!k || seen.has(`h:${at}`)) return; // The sandbox's own /dev and /proc are new, and so is its /tmp: the - // host's is reachable only where a writable location is bound into it. + // host's is reachable only where a writable location, or one shown + // again (a worktree location of a repository in /tmp), is bound into it. if (at === "/" || within(at, "/proc") || within(at, "/dev")) return; - if (within(at, "/tmp") && !writable.some((w) => within(at, w))) return; + if (within(at, "/tmp") && !shownInTmp.some((w) => within(at, w))) return; seen.add(`h:${at}`); mounts.push(k === "dir" ? { kind: "hide-dir", path: at } : { kind: "hide-file", path: at }); }; @@ -122,15 +133,21 @@ export function bwrapArgs(spec: SandboxSpec, cwd: string, kind: (p: string) => P // Read-only inside something writable (a repository's hooks and config), with everything in it. for (const p of spec.readOnly ?? []) { const at = realOr(p); - if (!kind(at) || seen.has(`o:${at}`)) continue; + // Never one that is hidden (a repository's git config holding a credential, in a dir the agent added), or in a + // hidden dir its writable root shows: its bind would show it again over the hiding. + const holder = writable.filter((w) => within(at, w)).sort((x, y) => depth(y) - depth(x))[0]; + if (!kind(at) || seen.has(`o:${at}`) || seen.has(`h:${at}`) || (holder && hiddenDirs.some((d) => within(at, d) && within(d, holder) && d !== holder))) continue; seen.add(`o:${at}`); mounts.push({ kind: "ro-bind", src: at, path: at }); } - // Every dir between a writable root and a hidden path in it: a mount point nothing can rename, still writable inside. - for (const p of spec.pinned ?? []) { + // Every dir between a writable root and a hidden path in it, and every dir held in place (the working directory + // and its way up to the workspace, a held path's parents): a mount point nothing can rename, still writable inside. + for (const p of [...(spec.pinned ?? []), ...(spec.heldInPlace ?? [])]) { const at = realOr(p); - // Never one a hidden dir covers (or is): its bind would show the real dir again. - if (kind(at) !== "dir" || seen.has(`o:${at}`) || hiddenDirs.some((d) => within(at, d))) continue; + // Never one a hidden dir in its writable root covers (or is): its bind would show the real dir again. One in + // a hidden dir the root is shown again in (the hidden home) is shown already. + const holder = writable.filter((w) => within(at, w)).sort((x, y) => depth(y) - depth(x))[0]; + if (kind(at) !== "dir" || seen.has(`o:${at}`) || hiddenDirs.some((d) => within(at, d) && (!holder || (within(d, holder) && d !== holder)))) continue; seen.add(`o:${at}`); mounts.push({ kind: "bind", src: at, path: at }); } @@ -223,6 +240,7 @@ export class BwrapSandboxProvider implements SandboxProvider { // (an AppArmor restriction, a container) fail here. const argv = [bin, "--die-with-parent", "--unshare-pid", "--unshare-net", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--", "/bin/sh", "-c", "exit 0"]; try { + // paths: the probe: a fixed argv, in no session's directory const proc = Bun.spawn(argv, { stdin: "ignore", stdout: "ignore", stderr: "pipe", timeout: 10_000 }); const [code, err] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); if (code === 0) return { available: true, detail: bin }; diff --git a/src/daemon/sandbox/claude-native.ts b/src/daemon/sandbox/claude-native.ts index 1d9903ba..7f5ab5d1 100644 --- a/src/daemon/sandbox/claude-native.ts +++ b/src/daemon/sandbox/claude-native.ts @@ -31,11 +31,12 @@ * its commands. */ -import { readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs"; +import { lstatSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import type { SandboxSettings } from "@anthropic-ai/claude-agent-sdk"; import { absolutePathEntries, pathWithShim } from "./git-shim.js"; +import { pinRefusal, pinsProblem, SandboxPinError } from "./pinned.js"; import type { SandboxRuntime } from "./session-sandbox.js"; import type { SandboxSpec } from "./types.js"; @@ -106,6 +107,7 @@ export function claudeNativeEnv( ]; const file = join(temp.cli, "command-env.sh"); const part = `${file}.${process.pid}.part`; + // paths: the CLI's own temp dir: the daemon's, private, which no command can write (claudeTempEnv) writeFileSync(part, lines.join("\n"), { mode: 0o600 }); renameSync(part, file); // PATH's absolute entries only (#448 round 5): a relative one (`.`, `node_modules/.bin`) finds a program the CLI starts on the host in the workspace. @@ -130,6 +132,9 @@ export function claudeSandboxSettings( platform: NodeJS.Platform = process.platform, ): SandboxSettings { const spec = rt.spec; + // Every directory the CLI is told to bind by name, checked first (pinned.ts, #448 round 13): never one a link leads from. + const problem = pinsProblem(spec.pins); + if (problem) throw new SandboxPinError(pinRefusal(problem)); const tempDir = temp?.commands ?? spec.tempDir; const network: NonNullable = spec.network.mode === "off" @@ -146,6 +151,7 @@ export function claudeSandboxSettings( const allSockets = sockets.length > 0 && platform === "linux"; if (sockets.length > 0) Object.assign(network, allSockets ? { allowAllUnixSockets: true } : { allowUnixSockets: sockets }); const envVars = credentialEnvNames(env, spec.unsetEnv).map((name) => ({ name, mode: "deny" as const })); + const roots = new Set(spec.projectRoots ?? []); return { enabled: true, failIfUnavailable: true, @@ -160,14 +166,27 @@ export function claudeSandboxSettings( // would run outside the sandbox from the CLI's next start. A // repository's hooks and config, for the same reason. And every dir // between the workspace and a hidden path in it (nothing renames it away). - denyWrite: physical([...claudeSettingsPaths(spec.workspace, env), ...denyAround(spec.readOnly ?? [], spec.writable), ...(spec.pinned ?? [])]), + // A pinned dir holding something writable (an added dir's dir on the way to the workspace) isn't denied: + // the CLI's denial takes the subtree, which would make that read-only; the gate checks the pins instead. + // Nor a dir held in place (spec.heldInPlace: the working directory and its way up, a held path's parents, + // #448 round 14): the CLI binds every dir from a writable root down to what it binds in it, so each is a + // mount point already, no rename (checked on the real CLI: sandbox-claude-real.test.ts "held in place"). + denyWrite: physical([...claudeSettingsPaths(spec.workspace, env), ...denyAround(spec.readOnly ?? [], spec.writable), ...(spec.pinned ?? []).filter((p) => !holdsWritable(p, [spec.workspace, ...spec.writable]))]), // Local services' sockets too while the network is off (spec.serviceSockets): those in the host's /tmp are // behind its seccomp's unix socket block, or the denied /tmp with every socket allowed. denyRead: physical([...spec.hidden, ...spec.containerSockets, ...(spec.serviceSockets ?? []).filter((p) => !HOST_SHARED_TEMP.some((t) => p === t || p.startsWith(`${t}/`)) && !p.startsWith("/private/tmp/")), ...(allSockets ? HOST_SHARED_TEMP : [])]), // Inside the denied home: the allowlist, and what is written (a workspace in it). Not a worktree location // holding the session's folder: the CLI lays what is readable over what is writable, which would make the // folder read-only (#448 round 10); the location stays the denied home's scratch dir there. - allowRead: physical([spec.workspace, tempDir, ...spec.writable, ...spec.reexposed.filter((r) => !holdsFolder(r, spec.worktreeFolders ?? [])), ...sockets]), + // The project roots the same way (they hold the workspace): each split around what is writable in it (readAround). + allowRead: physical([ + spec.workspace, + tempDir, + ...spec.writable, + ...spec.reexposed.filter((r) => !roots.has(r) && !holdsFolder(r, spec.worktreeFolders ?? [])), + ...readAround([...roots], [spec.workspace, tempDir, ...spec.writable], undefined, [...spec.hidden, ...spec.containerSockets, ...(spec.serviceSockets ?? [])]), + ...sockets, + ]), }, network, ...(envVars.length > 0 ? { credentials: { envVars } } : {}), @@ -175,12 +194,81 @@ export function claudeSandboxSettings( }; } +/** Whether `dir` holds one of `writable` (strictly). */ +function holdsWritable(dir: string, writable: readonly string[]): boolean { + const d = physicalOf(dir); + return writable.some((w) => physicalOf(w).startsWith(`${d}/`)); +} + /** Whether `dir` holds one of the session's worktree folders (is a location of it). */ function holdsFolder(dir: string, folders: readonly string[]): boolean { const d = resolve(dir); return folders.some((f) => resolve(f) !== d && resolve(f).startsWith(`${d}/`)); } +/** + * `readable` as the CLI's `allowRead` can show it without hiding what is + * writable: it lays every `allowRead` bind after every `allowWrite` one, so + * a readable dir holding a writable one (a project root holding the + * workspace) would make that one read-only. Such a dir is shown entry by + * entry instead, all but the way down to the writable ones (which + * `allowWrite` shows). An entry made in it after the query started isn't + * shown (docs/sandbox.md, Limits). Bounded. + * + * Never an entry that is a link (it would be shown by where it leads: the + * CLI binds physical paths; left out, it isn't there), nor one that is, or + * is in, a `hidden` path: an `allowRead` equal to a `denyRead` path wins in + * the CLI. A hidden path strictly inside a shown entry stays hidden: the CLI + * lays `denyRead` paths inside an `allowRead` one after it (checked on the + * real CLI, #448 round 12). + */ +export function readAround(readable: readonly string[], writable: readonly string[], limit = 4000, hidden: readonly string[] = []): string[] { + const out: string[] = []; + const inside = (child: string, parent: string) => child !== parent && child.startsWith(`${parent}/`); + const writes = writable.flatMap((w) => [resolve(w), physicalOf(w)]); + const hides = [...new Set(hidden.flatMap((h) => [resolve(h), physicalOf(h)]))]; + // Only what is hidden in (or is) the readable dir: one holding it (the denied home) is what it is shown in. + let inBase: string[] = []; + const isHidden = (p: string) => [resolve(p), physicalOf(p)].some((at) => inBase.some((h) => at === h || inside(at, h))); + // paths: a look (is it a link: then left out), no write + const isLink = (p: string) => { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return true; + } + }; + let budget = limit; + const split = (dir: string, keep: string[]): void => { + let names: string[] = []; + // paths: a look (a root's entries, to show around what is writable), no write; links left out + try { + names = readdirSync(dir); + } catch { + return; + } + for (const name of names) { + if (--budget < 0) return; + const at = join(dir, name); + if (isLink(at) || isHidden(at)) continue; + const held = keep.filter((w) => w === at || inside(w, at)); + if (held.length === 0) out.push(at); + else if (!held.includes(at)) split(at, held); + } + }; + for (const r of readable) { + for (const base of new Set([resolve(r), physicalOf(r)])) { + inBase = hides.filter((h) => h === base || inside(h, base)); + if (isHidden(base)) continue; + const keep = writes.filter((w) => w === base || inside(w, base)); + if (keep.includes(base)) continue; + if (keep.length === 0) out.push(base); + else split(base, keep); + } + } + return [...new Set(out)]; +} + /** * `readOnly` as the CLI's `denyWrite` can hold it. Its denials win over * `allowWrite` wherever they overlap, so a read-only dir holding a writable @@ -195,6 +283,7 @@ export function denyAround(readOnly: readonly string[], writable: readonly strin const inside = (child: string, parent: string) => child !== parent && child.startsWith(`${parent}/`); const split = (dir: string, keep: string[]): void => { let names: string[] = []; + // paths: a look (a held dir's entries, to deny around what is writable), no write try { names = readdirSync(dir); } catch { @@ -223,6 +312,7 @@ export function physicalOf(p: string): string { const missing: string[] = []; for (let at = resolve(p); ; at = dirname(at)) { try { + // paths: physical paths for the CLI, after claudeSandboxSettings checked every pin (no link in a pinned path: this changes none); what isn't pinned (a hidden path, a socket) is named where it is return join(realpathSync(at), ...missing.reverse()); } catch { if (dirname(at) === at) return resolve(p); diff --git a/src/daemon/sandbox/dir-rules.ts b/src/daemon/sandbox/dir-rules.ts new file mode 100644 index 00000000..46a39fca --- /dev/null +++ b/src/daemon/sandbox/dir-rules.ts @@ -0,0 +1,95 @@ +/** + * The rules a project root or an added directory must meet (project-roots.ts), + * apart so config.ts can check them at load without the sandbox's policy. + */ + +import { realpathSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { worktreeRootProblem } from "./worktrees.js"; + +/** A project root entry meaning "the parent of the session's repository" (the default). */ +export const PROJECT_ROOT_PARENT = "@parent"; + +function expandHome(p: string, home: string): string { + if (p === "~") return home; + if (p.startsWith("~/")) return join(home, p.slice(2)); + return p; +} + +function within(child: string, parent: string): boolean { + return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`); +} + +// paths: a refusal rule, checked by the path as given and where it leads; a directory it lets through is used by its real path, pinned (project-roots.ts, pinned.ts) +function realOr(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +export interface DirContext { + home?: string; + /** codeoid's config dir: no root is it, in it or holds it. */ + configDir?: string; + platform?: NodeJS.Platform; + /** The host's shared temp dirs (default {@link hostTempRoots}): no project root is in one, no added dir is one. */ + tempRoots?: readonly string[]; +} + +/** + * The host's shared temp dirs: `/tmp`, `/var/tmp`, `/dev/shm`, the + * daemon's `TMPDIR`, macOS's (`/private/tmp`, `/var/folders`), each also + * where it really is. Every program of the user's (and codeoid's private + * dir) keeps files there: never a project root, nor one inside them, nor an + * added directory itself. + */ +export function hostTempRoots(): string[] { + const roots = ["/tmp", "/var/tmp", "/dev/shm", "/private/tmp", "/private/var/tmp", "/var/folders", "/private/var/folders", tmpdir()]; + return [...new Set(roots.flatMap((r) => [resolve(r), realOr(resolve(r))]))]; +} + +/** + * Why `dir` can't be a project root or an added directory, or null: it must + * be absolute (`~` expands), and never `/`, the home or a dir holding it, a + * hidden dir of the home (`~/.config`, `~/.local`, …: what tools load + * programs and settings from), `~/Library`, codeoid's config dir, or a + * system dir (worktrees.ts `worktreeRootProblem`, as written and where it + * really is), nor one of the host's temp dirs itself ({@link hostTempRoots}). + */ +export function sandboxDirProblem(dir: string, ctx: DirContext = {}): string | null { + const home = resolve(ctx.home ?? process.env.HOME ?? homedir()); + if (!dir.trim()) return "an empty path"; + const p = expandHome(dir, home); + if (!isAbsolute(p)) return `${dir} isn't an absolute path`; + const problem = worktreeRootProblem(p, { home, ...(ctx.configDir ? { configDir: ctx.configDir } : {}), ...(ctx.platform ? { platform: ctx.platform } : {}) }); + if (problem) return problem; + const temps = ctx.tempRoots ?? hostTempRoots(); + const at = [resolve(p), realOr(resolve(p))]; + if (temps.some((t) => at.includes(t))) return `${dir} is the host's temp dir, shared by every program of yours (and codeoid's private dir)`; + return null; +} + +/** + * Why `dir` can't be a project root, or null: {@link sandboxDirProblem}, + * and never inside one of the host's temp dirs (what codeoid's private dir, + * other programs' sockets and sessions' scratch dirs sit beside; under + * bubblewrap the sandbox's `/tmp` is its own). + */ +export function projectRootProblem(dir: string, ctx: DirContext = {}): string | null { + const basic = sandboxDirProblem(dir, ctx); + if (basic) return basic; + const home = resolve(ctx.home ?? process.env.HOME ?? homedir()); + const p = resolve(expandHome(dir, home)); + const temps = ctx.tempRoots ?? hostTempRoots(); + const temp = temps.find((t) => within(p, t) || within(realOr(p), t)); + return temp ? `${dir} is in the host's temp dir (${temp}), shared by every program of yours (and codeoid's private dir)` : null; +} + +/** `~` expanded and resolved: how an added directory or a root is kept. Null when it isn't absolute. */ +export function normalizeSandboxDir(dir: string, home: string = process.env.HOME ?? homedir()): string | null { + const p = expandHome(dir.trim(), resolve(home)); + return isAbsolute(p) ? resolve(p) : null; +} diff --git a/src/daemon/sandbox/docker.ts b/src/daemon/sandbox/docker.ts index 0e6d7c15..0a12b4b2 100644 --- a/src/daemon/sandbox/docker.ts +++ b/src/daemon/sandbox/docker.ts @@ -37,6 +37,7 @@ import { lstatSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { resolve as resolvePath } from "node:path"; import { LocalSandboxHandle } from "./local-handle.js"; +import { boundPath } from "./pinned.js"; import type { NetworkMode, SandboxCapabilities, SandboxHandle, SandboxProbe, SandboxProvider, SandboxSpawnOptions, SandboxSpec } from "./types.js"; export const DOCKER_PROVIDER_ID = "docker"; @@ -54,7 +55,8 @@ function within(child: string, parent: string): boolean { return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`); } -function realOr(p: string): string { +// paths: only for what isn't pinned (boundPath): a hidden path (a link there hides more), the home's allowlist, the owner's socket +function realOrPath(p: string): string { try { return realpathSync(p); } catch { @@ -65,9 +67,10 @@ function realOr(p: string): string { /** What exists at a path: a directory, something else, nothing. */ export type DockerPathKind = "dir" | "other" | null; +// paths: a look at what is there, no write: a pinned path the handle checked first function pathKind(p: string): DockerPathKind { try { - const st = lstatSync(realOr(p)); + const st = lstatSync(realOrPath(p)); return st.isDirectory() ? "dir" : "other"; } catch { return null; @@ -98,6 +101,7 @@ export interface DockerRunOptions { /** Where a container runtime socket the owner let in is mounted inside the container (`DOCKER_HOST` names it). */ export const CONTAINER_DOCKER_SOCKET = "/var/run/docker.sock"; +// paths: the owner's container socket (opted in), outside every sandbox's writes: a look at its group function socketGroup(p: string): number | null { try { return statSync(p).gid; @@ -109,6 +113,9 @@ function socketGroup(p: string): number | null { /** `docker run`'s arguments for `argv` under `spec`. Pure but for `kind` (tests replace it). */ export function dockerRunArgs(spec: SandboxSpec, argv: readonly string[], o: DockerRunOptions): string[] { const kind = o.kind ?? pathKind; + // Everything bound by name is pinned: mounted by the path it was pinned at (the handle checked it is still that, + // and the launched argv checks again; #448 round 14), never resolved again here. Only what isn't is resolved. + const realOr = boundPath(spec.pins, realOrPath); const args = [ "run", "--rm", @@ -159,10 +166,14 @@ export function dockerRunArgs(spec: SandboxSpec, argv: readonly string[], o: Doc } // Every dir between a mounted writable root and a hidden path in it: a mount point (no rename), still writable (Docker mounts parents first). const hiddenAt = spec.hidden.map(realOr); - for (const p of spec.pinned ?? []) { + const writableAt = [workspace, ...spec.writable.map(realOr)]; + for (const p of [...(spec.pinned ?? []), ...(spec.heldInPlace ?? [])]) { const at = realOr(p); - // Never one a hidden path covers (or is): its mount would show the real dir again. - if (kind(at) === "dir" && inMounted(at) && !hiddenAt.some((h) => within(at, h))) bind(at, at, false); + // Never one a hidden path in its writable root covers (or is): its mount would show the real dir again. One in a + // hidden dir the root is mounted from (the hidden home holding the workspace) is the root's: mounted (#448 + // round 14, as bubblewrap's since round 13). + const holder = writableAt.filter((w) => within(at, w)).sort((x, y) => y.length - x.length)[0]; + if (kind(at) === "dir" && inMounted(at) && !hiddenAt.some((h) => within(at, h) && (!holder || (within(h, holder) && h !== holder)))) bind(at, at, false); } // A hidden path inside something mounted is covered again. for (const h of [...spec.hidden, ...spec.containerSockets, ...(spec.serviceSockets ?? [])]) { @@ -237,6 +248,7 @@ export class DockerSandboxProvider implements SandboxProvider { const docker = this.#client(); if (!docker) return { available: false, reason: "the docker client isn't installed" }; try { + // paths: the probe: a fixed argv, in no session's directory const proc = Bun.spawn([docker, "version", "--format", "{{.Server.Version}}"], { stdin: "ignore", stdout: "pipe", stderr: "pipe", timeout: 10_000 }); const [code, out, err] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]); if (code === 0 && out.trim()) return { available: true, detail: `Docker ${out.trim()}` }; @@ -272,6 +284,7 @@ export class DockerSandboxProvider implements SandboxProvider { // A launcher (no cwd: it runs from wherever it is started, maybe many at once) names none. ...(opts.cwd ? { name: `codeoid-${safe}-${randomBytes(4).toString("hex")}` } : {}), handle: handleId, + // paths: the container's own working directory (-w), inside it cwd: opts.cwd, envNames: Object.keys(opts.env), uid, @@ -282,6 +295,7 @@ export class DockerSandboxProvider implements SandboxProvider { env: { ...opts.env, ...clientEnv }, }); // Containers outlive a killed client: remove every one this handle started. + // paths: docker ps / rm by this handle's label: no path const onClose = () => new Promise((resolve) => { execFile(docker, ["ps", "-aq", "--filter", `label=codeoid.handle=${handleId}`], { env: { ...clientEnv } }, (err, out) => { diff --git a/src/daemon/sandbox/git-shim.ts b/src/daemon/sandbox/git-shim.ts index 3de051eb..19bcc090 100644 --- a/src/daemon/sandbox/git-shim.ts +++ b/src/daemon/sandbox/git-shim.ts @@ -42,6 +42,7 @@ import { chmodSync, lstatSync, mkdirSync, renameSync, writeFileSync } from "node import { dirname, isAbsolute, join, resolve } from "node:path"; import { BASE_OVERRIDES, GIT_BIN, PROGRAM_ENV, REMOTE_PROTOCOLS } from "../git-env.js"; import { GIT_CONTROL, gitLayoutOf, programLocations } from "./policy.js"; +import { makeAt, pinOf } from "./pinned.js"; import type { SandboxSpec } from "./types.js"; /** The empty tree's id: `attr.tree` naming it reads no attributes from the working tree (git ≥ 2.46; an older git ignores the key). */ @@ -62,6 +63,7 @@ const ENV_BIN = "/usr/bin/env"; * can't be read apart). `PATH` (its absolute entries), `HOME` and the * caller's `-c` list (`GIT_CONFIG_COUNT`) are handed over apart. */ +// paths: names only (the shim's environment allowlist), no path export const SHIM_ENV_NAMES: readonly string[] = [ "USER", "LOGNAME", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "LC_COLLATE", "LC_MESSAGES", "LC_MONETARY", "LC_NUMERIC", "LC_TIME", "LC_ADDRESS", "LC_IDENTIFICATION", "LC_MEASUREMENT", "LC_NAME", "LC_PAPER", @@ -120,6 +122,7 @@ export interface GitShimWorktrees { } /** A directory itself, not a link to one. */ +// paths: a look (a directory, not a link), no write function realDir(p: string): boolean { try { const st = lstatSync(p); @@ -129,6 +132,7 @@ function realDir(p: string): boolean { } } +// paths: a look (is it a link), no write function isLink(p: string): boolean { try { return lstatSync(p).isSymbolicLink(); @@ -157,7 +161,8 @@ export function cliWorktrees(workspace: string, home?: string): GitShimWorktrees if (dirname(resolve(layout.dotgit)) === ws && realDir(claude)) { const checkouts = join(claude, "worktrees"); try { - mkdirSync(checkouts, { mode: 0o755 }); + // Never through a link (pinned.ts: `.claude` pinned for the make, checked again after; #448 round 14). + makeAt(checkouts, "dir", "making the CLI's worktree dir"); } catch { /* there already (or can't be: then not opened) */ } @@ -197,6 +202,8 @@ export function gitShimSpec(spec: SandboxSpec, opts: { tempParent: string; workt privateTemp: true, writable: [...spec.writable, ...open], readOnly: (spec.readOnly ?? []).filter(held), + // Bound by name as the rest (pinned.ts, #448 round 14): never followed to where a link put there leads. + pins: [...(spec.pins ?? []), ...open.map((p) => pinOf(p, "a git worktree dir"))], network: { mode: "off" }, }; } @@ -212,6 +219,7 @@ export function gitProgramLocations(): string[] { if (locations) return locations; const out = programLocations(GIT_BIN, []); try { + // paths: git's own program, asked where its helpers are: no session's directory const r = Bun.spawnSync([GIT_BIN, "--exec-path"], { stdin: "ignore", stdout: "pipe", stderr: "ignore", env: { PATH: process.env.PATH ?? "/usr/bin:/bin" } }); const dir = r.exitCode === 0 ? r.stdout.toString().trim() : ""; if (dir.startsWith("/")) out.push(...programLocations(join(dir, "git"), []).filter((d) => d === dir)); @@ -391,6 +399,7 @@ export function gitShimScript(opts: GitShimOptions): string { } /** Write {@link gitShimScript} as `

/git` (0700, atomically: a git started meanwhile gets the old one or the new one) and return `dir`. */ +// paths: the session's launcher dir: the daemon's private dir, which no sandbox sees export function writeGitShim(dir: string, opts: GitShimOptions): string { mkdirSync(dir, { recursive: true, mode: 0o700 }); const path = join(dir, "git"); diff --git a/src/daemon/sandbox/git-watch.ts b/src/daemon/sandbox/git-watch.ts index d4225641..a8c9b790 100644 --- a/src/daemon/sandbox/git-watch.ts +++ b/src/daemon/sandbox/git-watch.ts @@ -40,6 +40,7 @@ const DIR_LIMIT = 200; /** Label → fingerprint of each watched file (absent: not in the map). */ export type GitControlSnapshot = ReadonlyMap; +// paths: a look (a file's fingerprint), no write function fingerprint(p: string): string | null { try { const st = lstatSync(p); @@ -52,6 +53,7 @@ function fingerprint(p: string): string | null { } } +// paths: a look, no write function readlinkOr(p: string): string { try { return readlinkSync(p); @@ -61,6 +63,7 @@ function readlinkOr(p: string): string { } /** codeoid's own placeholders read as absent: an empty `config.worktree`, a `commondir` of `.`. */ +// paths: a look, no write function placeholder(p: string, name: string): boolean { try { const text = readFileSync(p, "utf8"); @@ -75,6 +78,7 @@ function placeholder(p: string, name: string): boolean { * anywhere isn't, #448 round 11), `commondir` naming its repository * (`common`, as `../..` from there), no `config.worktree` but codeoid's empty one. */ +// paths: a look, no write function plainWorktree(dir: string, common: string): boolean { try { if (!lstatSync(dir).isDirectory() || lstatSync(join(dir, "commondir")).isSymbolicLink()) return false; @@ -90,6 +94,7 @@ function plainWorktree(dir: string, common: string): boolean { } } +// paths: a look, no write function entries(dir: string): string[] { try { return readdirSync(dir).sort().slice(0, DIR_LIMIT); @@ -99,8 +104,9 @@ function entries(dir: string): string[] { } /** `core.hooksPath` as the repository's own config files set it (the last one wins), or null. */ -function hooksPathOf(configs: readonly string[]): string | null { +export function hooksPathOf(configs: readonly string[]): string | null { let found: string | null = null; + // paths: a look (a config's text), no write for (const file of configs) { let text = ""; try { @@ -150,6 +156,7 @@ function strayGitEntries(folders: readonly string[], common: string): string[] { if (folders.length === 0) return []; const commonReal = realOr(common); const real = folders.map(realOr); + // paths: a look, no write const check = (dotgit: string): boolean => { let st: ReturnType; try { @@ -161,6 +168,7 @@ function strayGitEntries(folders: readonly string[], common: string): string[] { return true; }; // The worktrees git knows of there: each one's `.git`, as its admin dir's `gitdir` names it. + // paths: a look at git's records of worktrees, no write for (const w of entries(join(common, "worktrees"))) { try { const dotgit = resolve(join(common, "worktrees", w), readFileSync(join(common, "worktrees", w, "gitdir"), "utf8").trim()); @@ -170,6 +178,7 @@ function strayGitEntries(folders: readonly string[], common: string): string[] { /* no gitdir */ } } + // paths: a look through the session's worktree folders, no write let budget = FOLDER_DIR_LIMIT; let level = [...real]; for (let depth = 0; depth <= 3 && level.length > 0; depth++) { @@ -191,6 +200,7 @@ function strayGitEntries(folders: readonly string[], common: string): string[] { return [...out]; } +// paths: for comparing paths in a look, no write function realOr(p: string): string { try { return realpathSync(p); @@ -200,6 +210,7 @@ function realOr(p: string): string { } /** A worktree's `.git` file as git makes it: naming `/worktrees/`, whose `gitdir` names this file back. */ +// paths: a look, no write function namesOwnAdmin(dotgit: string, commonReal: string): boolean { try { const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotgit, "utf8")); @@ -247,6 +258,7 @@ export function gitControlSnapshot(workspace: string, home: string = process.env // Every worktree's admin dir, as a group ({@link gitControlChanges}): what names its repository and its checkout, // and its own config. One that appears as `git worktree add` makes one (its repository's `commondir`, no config // of its own) isn't news, nor one that goes; one changed meanwhile is, or one that appears naming anything else. + // paths: a look at git's records of worktrees, no write const own = (opts.ownFolders ?? []).map(realOr); for (const w of entries(join(common, "worktrees"))) { const dir = join(common, "worktrees", w); @@ -262,6 +274,7 @@ export function gitControlSnapshot(workspace: string, home: string = process.env for (const n of mine ? ["commondir", "config.worktree"] : ["commondir", "gitdir", "config.worktree"]) watch(join(dir, n)); } // A checkout in the session's own worktree folders whose `.git` git would follow elsewhere (#448 round 10). + // paths: a look, no write for (const g of strayGitEntries(opts.ownFolders ?? [], common)) { let f: string | null = null; try { diff --git a/src/daemon/sandbox/host-file-tools.ts b/src/daemon/sandbox/host-file-tools.ts index 9abc59ab..4e3a60cd 100644 --- a/src/daemon/sandbox/host-file-tools.ts +++ b/src/daemon/sandbox/host-file-tools.ts @@ -107,6 +107,7 @@ function bothForms(paths: readonly string[]): string[] { return [...out]; } +// paths: a look (does a hidden path exist), no write function lexists(p: string): boolean { try { lstatSync(p); diff --git a/src/daemon/sandbox/local-handle.ts b/src/daemon/sandbox/local-handle.ts index 883ca801..09f667ee 100644 --- a/src/daemon/sandbox/local-handle.ts +++ b/src/daemon/sandbox/local-handle.ts @@ -2,10 +2,14 @@ * The {@link SandboxHandle} of a local provider that confines a program by * wrapping its argv (bwrap, sandbox-exec, `docker run`): spawn and exec are * the wrapped argv run as a child of the daemon, and close ends every child it - * started that is still running. + * started that is still running. Every wrap first checks the spec's pinned + * directories (pinned.ts) and refuses when one isn't what it was, and the argv + * it hands back checks them again right before the program starts, so a + * launcher written from it once checks at each run (#448 round 13). */ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { pinGuardArgv, pinRefusal, pinsProblem, SandboxPinError } from "./pinned.js"; import type { SandboxExecOptions, SandboxExecResult, SandboxHandle, SandboxSpawnOptions, SandboxSpec } from "./types.js"; export type ArgvWrapper = (argv: readonly string[], opts: SandboxSpawnOptions) => { argv: string[]; env: Record }; @@ -30,13 +34,17 @@ export class LocalSandboxHandle implements SandboxHandle { wrap(argv: readonly string[], opts: SandboxSpawnOptions): { argv: string[]; env: Record } { if (this.#closed) throw new Error(`the ${this.providerId} sandbox for session ${this.spec.sessionId} is closed`); - return this.#wrap(argv, opts); + const problem = pinsProblem(this.spec.pins); + if (problem) throw new SandboxPinError(pinRefusal(problem)); + const wrapped = this.#wrap(argv, opts); + return { ...wrapped, argv: pinGuardArgv(this.spec.pins, wrapped.argv) }; } spawn(command: string, args: readonly string[], opts: SandboxSpawnOptions): ChildProcessWithoutNullStreams { const wrapped = this.wrap([command, ...args], opts); const [bin, ...rest] = wrapped.argv; if (!bin) throw new Error("empty sandbox argv"); + // paths: the wrapped argv, its pins just checked by wrap (and again by the guard in it); its cwd is inside the sandbox it starts const child = spawn(bin, rest, { cwd: opts.cwd, env: wrapped.env, stdio: ["pipe", "pipe", "pipe"] }); this.#children.add(child); child.once("exit", () => this.#children.delete(child)); diff --git a/src/daemon/sandbox/pinned.ts b/src/daemon/sandbox/pinned.ts new file mode 100644 index 00000000..3d6ad90e --- /dev/null +++ b/src/daemon/sandbox/pinned.ts @@ -0,0 +1,514 @@ +/** + * The one way codeoid uses a path of a session's (#448 rounds 13 and 14, + * docs/sandbox.md "Pinned paths"). + * + * A session's paths are where an agent's commands can write: the workspace + * (a monorepo's repository top), the working directory, every added + * directory, every project root (another session's workspace), the + * session's worktree folders, its repository's git dirs, the paths held + * read-only inside them, the directories held in place. Any component of + * one can be moved aside and replaced by a link between two uses of it, by + * this session's commands or another's. Resolving it again then (a + * `realpath`, a bind by name, a `mkdir`, a git run with it as its work + * tree) follows the link: the home bound writable, the daemon's private + * dir shown, a rewind writing into the home. + * + * So each is pinned once, with what it is: its real path and, for a + * directory, its identity (`dev:ino`, {@link pinRecord}), taken when the + * session is created, the directory granted, the root first resolved, the + * spec built. Every use checks first that the path is still that, reached + * through no symbolic link ({@link pinProblem}), and one that isn't is never + * followed: the use is refused with what changed, and the session says it. + * + * - a sandbox: every spec build, every launch of a program inside + * (`LocalSandboxHandle.wrap`, and again in the launched argv itself: + * {@link pinGuardArgv}, so a launcher script written once checks at each + * run), Claude's own sandbox settings, every tool call at the gate; + * - the daemon's own work in a session's directory ({@link PinnedDir}): + * checkpoints, going back a turn, a fork's files, the placeholders it + * makes, the worktree folders it makes and removes. Checked before and + * again after ({@link inPinnedDir}): git and the file system take paths, + * so what is checked and what is used can't be one open directory; a + * directory is created without following a link, a file opened with + * `O_NOFOLLOW | O_EXCL` ({@link makeInPinned}). + * + * Nothing else in the sandbox, session or checkpoint modules resolves such a + * path itself (src/tests/pinned-paths-static.test.ts). + */ + +import { closeSync, constants, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +/** One path of a spec bound by name (SandboxSpec.pins). */ +export interface PathPin { + /** Its real path: what is bound. */ + path: string; + /** What it is, for what is said: "the workspace", "added directory", "project root", … */ + what: string; + /** `dev:ino` it had when the spec was built; absent: only that it is reached through no link (one codeoid makes and removes, or a file). */ + id?: string; + /** It may be missing (then nothing is bound there): a worktree folder, a held path not made. */ + mayBeMissing?: boolean; + /** Not a directory (a held file: a repository's `config`, a project's `.mcp.json`): no link on its way nor itself; which file isn't compared (an ordinary write replaces one). */ + file?: boolean; +} + +/** Raised when a pinned path isn't what it was: the build, the launch or the daemon's own use is refused. */ +export class SandboxPinError extends Error { + override name = "SandboxPinError"; +} + +let bootId: string | null = null; +/** This boot's id (Linux), else "": device numbers are reassigned at boot, so across one only the inode is compared. */ +export function currentBoot(): string { + if (bootId === null) { + try { + bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(); + } catch { + bootId = ""; + } + } + return bootId; +} + +/** The first component of `p` (absolute) that is a symbolic link, or null (none, or a missing one first). */ +function linkIn(p: string): string | null { + let at = ""; + for (const part of resolve(p).split("/").filter(Boolean)) { + at += `/${part}`; + try { + if (lstatSync(at).isSymbolicLink()) return at; + } catch { + return null; + } + } + return null; +} + +/** `dev:ino` of the directory at `p`, reached through no link and at its real path, or null. */ +export function dirIdentity(p: string): string | null { + const at = resolve(p); + if (linkIn(at) !== null) return null; + try { + const st = lstatSync(at); + if (!st.isDirectory() || realpathSync(at) !== at) return null; + return `${st.dev}:${st.ino}`; + } catch { + return null; + } +} + +/** What is kept for `p` when it is pinned: `dev:ino@boot`, or null when it isn't a directory at its real path. */ +export function pinRecord(p: string): string | null { + const id = dirIdentity(p); + return id === null ? null : `${id}@${currentBoot()}`; +} + +/** Whether `current` (`dev:ino`, now) is the directory `stored` (`dev:ino@boot`) names: the same inode, and device within one boot. */ +export function sameDir(stored: string, current: string): boolean { + const [id = "", boot = ""] = stored.split("@"); + const [dev, ino] = id.split(":"); + const [nowDev, nowIno] = current.split(":"); + if (!ino || ino !== nowIno) return false; + const now = currentBoot(); + return !boot || !now || boot !== now || dev === nowDev; +} + +/** Why `p` isn't what was pinned there, without naming it ("is gone", …), or null. */ +function changeOf(p: string, opts: { stored?: string; id?: string; mayBeMissing?: boolean; file?: boolean }): string | null { + const at = resolve(p); + const link = linkIn(at); + if (link) return `is reached through a symbolic link now (${link})`; + let st: ReturnType; + try { + st = lstatSync(at); + } catch { + return opts.mayBeMissing ? null : "is gone"; + } + if (opts.file) { + // A held file: only that it is where it was, at its real path (its directory's checked above, component by component). + try { + return realpathSync(dirname(at)) === dirname(at) ? null : "isn't at its real path now"; + } catch { + return "can't be resolved now"; + } + } + if (!st.isDirectory()) return "isn't a directory now"; + try { + if (realpathSync(at) !== at) return "isn't at its real path now"; + } catch { + return "can't be resolved now"; + } + const now = `${st.dev}:${st.ino}`; + if (opts.stored && !sameDir(opts.stored, now)) return "isn't the directory it was (another one is there now)"; + if (opts.id && opts.id !== now) return "isn't the directory it was (another one is there now)"; + return null; +} + +/** + * Why `p` (`what`, for the words) isn't the directory pinned there, or null: + * a symbolic link in its path, gone (unless `mayBeMissing`), not a + * directory, or another directory than `stored` (`dev:ino@boot`) or `id` + * (`dev:ino`, this boot) names. `file`: a held file, only checked for links. + */ +export function pinProblem(p: string, what: string, opts: { stored?: string; id?: string; mayBeMissing?: boolean; file?: boolean } = {}): string | null { + const change = changeOf(p, opts); + return change === null ? null : `${what} ${resolve(p)} ${change}`; +} + +/** The first of `pins` that isn't what it was, in words, or null. */ +export function pinsProblem(pins: readonly PathPin[] | undefined): string | null { + for (const pin of pins ?? []) { + const problem = pinProblem(pin.path, pin.what, { + ...(pin.id ? { id: pin.id } : {}), + ...(pin.mayBeMissing ? { mayBeMissing: true } : {}), + ...(pin.file ? { file: true } : {}), + }); + if (problem) return problem; + } + return null; +} + +/** The sentence a refused build, launch or call carries. */ +export function pinRefusal(problem: string): string { + return `${problem}: the sandbox binds it by name, so it isn't followed — this session's commands don't run until it is the directory it was (put it back), or it is removed from the session`; +} + +/** + * What a spec binds at `path`, pinned as it is now (`what`, for the words): + * a directory by its identity, a file (or a directory `mayBeMissing` and not + * there) only against links. At the spec build, after the paths were found. + */ +export function pinOf(path: string, what: string, opts: { mayBeMissing?: boolean; noId?: boolean } = {}): PathPin { + const at = resolve(path); + let isDir: boolean | null = null; + try { + isDir = lstatSync(at).isDirectory(); + } catch { + /* missing */ + } + const id = isDir && !opts.noId ? dirIdentity(at) : null; + return { + path: at, + what, + ...(id ? { id } : {}), + ...(opts.mayBeMissing || isDir === null ? { mayBeMissing: true } : {}), + ...(isDir === false ? { file: true } : {}), + }; +} + +/** + * How a provider names a spec's path when it binds it: a pinned one as it + * was pinned (the handle checked it is still that, and the launched argv + * checks again), never resolved again; anything else (a hidden path: a link + * there hides more; the home's allowlist, shown by where a link leads) by + * its real path, `real` (tests replace it). + */ +export function boundPath(pins: readonly PathPin[] | undefined, real: (p: string) => string = realOrSelf): (p: string) => string { + const pinned = new Set((pins ?? []).map((p) => p.path)); + return (p) => (pinned.has(resolve(p)) ? resolve(p) : real(p)); +} + +/** + * The real path when it exists, else the path: found once, for a path the + * caller vouches for (one a spec build checked, a git dir its layout did, + * codeoid's own), before it is pinned. Never to use a pinned path again. + */ +export function realPathOnce(p: string): string { + return realOrSelf(p); +} + +/** The real path when it exists, else the path. */ +function realOrSelf(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +// --- The daemon's own use of a session's directory ------------------------------------------------------------- + +/** A directory of a session's as the daemon uses it on the host: its real path and what it was when pinned. */ +export interface PinnedDir { + /** Its real path. */ + readonly path: string; + /** What it is, for what is said: "the working directory", … */ + readonly what: string; + /** `dev:ino@boot` ({@link pinRecord}). */ + readonly record: string; +} + +/** A use of a pinned directory refused: it isn't what it was. Said on the session and audited by the caller. */ +export class PinnedPathError extends SandboxPinError { + override name = "PinnedPathError"; + readonly path: string; + readonly op: string; + readonly reason: string; + constructor(path: string, op: string, reason: string) { + super(`${path} changed under the session: it ${reason}; ${op} refused (never followed)`); + this.path = path; + this.op = op; + this.reason = reason; + } +} + +/** + * `path` pinned (`what`, for the words): checked against `stored` when there + * is a record, else pinned as it is now. Throws {@link PinnedPathError} when + * it isn't a directory at its real path reached through no link, or isn't + * the one `stored` names. + */ +export function pinDir(path: string, what: string, stored?: string | null, op = `using ${what}`): PinnedDir { + const at = resolve(path); + if (!isAbsolute(path)) throw new PinnedPathError(at, op, "isn't an absolute path"); + const change = changeOf(at, stored ? { stored } : {}); + if (change) throw new PinnedPathError(at, op, change); + const record = stored ?? pinRecord(at); + if (!record) throw new PinnedPathError(at, op, "can't be resolved now"); + return { path: at, what, record }; +} + +/** Why `dir` isn't what was pinned there (in words, without its path), or null. */ +export function pinnedDirChange(dir: PinnedDir): string | null { + return changeOf(dir.path, { stored: dir.record }); +} + +/** Throws {@link PinnedPathError} (for `op`) when `dir` isn't what was pinned. */ +export function checkPinned(dir: PinnedDir, op: string): void { + const change = pinnedDirChange(dir); + if (change) throw new PinnedPathError(dir.path, op, change); +} + +/** + * Run `act` on `dir`'s path (`op`, for what is said): checked before, and + * again after, so a rename racing the act is said, never taken as done. A + * git or a walk given the path resolves it once, at its start: run it with + * the path as its cwd, so what it does stays in the directory that was + * checked even when the path is changed meanwhile. + */ +export async function inPinnedDir(dir: PinnedDir, op: string, act: (path: string) => Promise): Promise { + checkPinned(dir, op); + const out = await act(dir.path); + checkPinned(dir, op); + return out; +} + +/** {@link inPinnedDir}, synchronously. */ +export function inPinnedDirSync(dir: PinnedDir, op: string, act: (path: string) => T): T { + checkPinned(dir, op); + const out = act(dir.path); + checkPinned(dir, op); + return out; +} + +/** Whether `p` is a symbolic link (false when missing). */ +function isLink(p: string): boolean { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +} + +/** The relative path's parts, refusing what would leave the directory. */ +function partsOf(dir: PinnedDir, rel: string, op: string): string[] { + const parts = rel.split("/").filter((p) => p.length > 0 && p !== "."); + if (isAbsolute(rel) || parts.some((p) => p === "..")) throw new PinnedPathError(dir.path, op, `was asked for ${JSON.stringify(rel)}, which isn't inside it`); + return parts; +} + +/** + * `rel` inside `dir`, as a path, once `dir` is checked and no existing part + * of `rel` is a link: for a read, a look, or a git run that never follows a + * link of its own work tree (git never writes through a symlinked parent). + */ +export function pinnedPath(dir: PinnedDir, rel: string, op: string): string { + checkPinned(dir, op); + let at = dir.path; + for (const part of partsOf(dir, rel, op)) { + at = join(at, part); + if (isLink(at)) throw new PinnedPathError(dir.path, op, `holds a symbolic link on the way to ${rel} (${at})`); + } + return at; +} + +/** + * Make `rel` inside `dir` when it is missing (`"dir"`, or a file with + * `content`), never through a link: each directory on the way made (0755) + * or found a directory, not a link; the file opened `O_CREAT | O_EXCL | + * O_NOFOLLOW`. `dir` checked before and after. Whether it was made (false: + * there already). Throws {@link PinnedPathError} when `dir` or the way + * changed; other failures (a read-only repository) are the caller's. + */ +export function makeInPinned(dir: PinnedDir, rel: string, make: "dir" | { content: string; mode?: number }, op: string): boolean { + return inPinnedDirSync(dir, op, (base) => { + const parts = partsOf(dir, rel, op); + const leaf = parts.pop(); + if (leaf === undefined) return false; + let at = base; + for (const part of parts) { + at = join(at, part); + try { + mkdirSync(at, { mode: 0o755 }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + const st = lstatSync(at); + if (st.isSymbolicLink() || !st.isDirectory()) throw new PinnedPathError(dir.path, op, `holds ${st.isSymbolicLink() ? "a symbolic link" : "a file"} on the way to ${rel} (${at})`); + } + const target = join(at, leaf); + if (make === "dir") { + try { + mkdirSync(target, { mode: 0o755 }); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") return false; + throw err; + } + } + let fd: number; + try { + fd = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, make.mode ?? 0o644); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") return false; + throw err; + } + try { + writeSync(fd, make.content); + } finally { + closeSync(fd); + } + return true; + }); +} + +/** + * Write `content` as the file `rel` inside `dir`, whole and never through a + * link: made beside it ({@link makeInPinned}: `O_EXCL | O_NOFOLLOW`) and + * renamed over it (a rename replaces a link put there, never follows it), + * `dir` checked before and after. + */ +export function replaceInPinned(dir: PinnedDir, rel: string, content: string, op: string, mode = 0o644): void { + const part = `${rel}.codeoid-${process.pid}-${Date.now()}.part`; + if (!makeInPinned(dir, part, { content, mode }, op)) throw new PinnedPathError(dir.path, op, `already holds ${part}`); + inPinnedDirSync(dir, op, (base) => { + const from = pinnedPath(dir, part, op); + try { + renameSync(from, join(base, ...partsOf(dir, rel, op))); + } catch (err) { + try { + unlinkSync(from); + } catch { + /* gone already */ + } + throw err; + } + }); +} + +/** + * Make `p` when it is missing (`"dir"`, or a file with `content`) in its + * directory as it is now: that directory found by its real path once + * (one the caller vouched for: a workspace the build just checked, a git + * dir its layout did, a worktree location never reached through a link) + * and pinned for the make ({@link makeInPinned}). Whether it was made. + */ +export function makeAt(p: string, make: "dir" | { content: string; mode?: number }, op: string): boolean { + const at = resolve(p); + const name = at.slice(at.lastIndexOf("/") + 1); + if (!name) return false; + return makeInPinned(pinDir(realOrSelf(dirname(at)), "a directory codeoid makes in", null, op), name, make, op); +} + +/** + * Remove the directory `p` when it is empty, never through a link (no + * component of `p` may be one: codeoid's own dirs are kept by their real + * paths). Whether it was removed. + */ +export function removeEmptyDir(p: string): boolean { + const at = resolve(p); + if (at === "/" || linkIn(at) !== null) return false; + try { + if (!lstatSync(at).isDirectory()) return false; + rmdirSync(at); + return true; + } catch { + return false; + } +} + +// --- The launch guard ------------------------------------------------------------------------------------------ + +/** The first word of a guarded argv's own: what {@link pinGuardArgv} adds before the program. */ +export const PIN_GUARD_NAME = "codeoid-pins"; + +/** + * sh, run as `sh -c codeoid-pins ( )×n `: + * no path may be a link, nor have one on its way (its directory's physical + * path, `cd -P`, is itself); a path whose `` is `m` or `fm` may be + * missing; each with an `` (not `-`) must have that `dev:ino` (`stat` + * prints them, all at once: the absolute program found when the script is + * made; without one only the rest is checked). Then the program runs (exec), + * else it exits 126 saying which. + */ +export function pinGuardScript(stat: readonly string[] | null): string { + const quote = (w: string) => `'${w.replace(/'/g, `'\\''`)}'`; + const statCmd = stat ? stat.map(quote).join(" ") : ""; + return [ + "n=$1; shift", + "refs=; want=; i=0", + 'while [ "$i" -lt "$n" ]; do', + // Positional parameters by number: only digits reach eval. + " b=$((i * 3 + 1)); i=$((i + 1))", + ' eval "p=\\${$b} id=\\${$((b + 1))} m=\\${$((b + 2))}"', + ' if [ ! -e "$p" ] && [ ! -L "$p" ]; then', + ' case $m in *m) continue ;; esac', + ' printf "codeoid sandbox: %s is gone: not run\\n" "$p" >&2; exit 126', + " fi", + ' d=${p%/*}; [ -n "$d" ] || d=/', + ' r=$(cd -P -- "$d" 2>/dev/null && pwd -P) || r=', + ' if [ -L "$p" ] || [ "$r" != "$d" ]; then printf "codeoid sandbox: %s is not the directory it was (a symbolic link is in its path, or it is not a directory): not run\\n" "$p" >&2; exit 126; fi', + ' case $m in f*) ;; *) if [ ! -d "$p" ]; then printf "codeoid sandbox: %s is not the directory it was (a symbolic link is in its path, or it is not a directory): not run\\n" "$p" >&2; exit 126; fi ;; esac', + ` if [ "$id" != - ]; then refs="$refs \\"\\\${$b}\\""; want="$want$id "; fi`, + "done", + ...(stat + ? [ + 'if [ -n "$refs" ]; then', + ` got=$(eval "${statCmd.replace(/"/g, '\\"')} -- $refs" 2>/dev/null | tr "\\n" " ") || got=`, + ' if [ "$got" != "$want" ]; then printf "codeoid sandbox: a directory the sandbox binds is not the directory it was (another one is there now): not run\\n" >&2; exit 126; fi', + "fi", + ] + : []), + "shift $((n * 3))", + 'exec "$@"', + ].join("\n"); +} + +let statArgv: string[] | null | undefined; +/** How `dev:ino` is printed here: the system's `stat` with its format, or null (none found). */ +function statProgram(): string[] | null { + if (statArgv === undefined) { + const bin = Bun.which("stat", { PATH: "/usr/bin:/bin" }); + statArgv = bin ? [bin, ...(process.platform === "darwin" ? ["-f", "%d:%i"] : ["-c", "%d:%i"])] : null; + } + return statArgv; +} + +/** + * `argv` run only once every pin still holds, checked by the shell right + * before the program starts (a launcher written once checks at each run): + * `/bin/sh -c` {@link pinGuardScript}. Unchanged without pins. + */ +export function pinGuardArgv(pins: readonly PathPin[] | undefined, argv: readonly string[]): string[] { + if (!pins || pins.length === 0) return [...argv]; + const mode = (p: PathPin) => `${p.file ? "f" : "d"}${p.mayBeMissing ? "m" : ""}`; + return ["/bin/sh", "-c", pinGuardScript(statProgram()), PIN_GUARD_NAME, String(pins.length), ...pins.flatMap((p) => [p.path, p.id ?? "-", mode(p)]), ...argv]; +} + +/** The program a guarded argv runs (what follows the guard's own words), for a caller that names it (the git shim). */ +export function unguardedArgv(argv: readonly string[]): string[] { + if (argv[0] !== "/bin/sh" || argv[3] !== PIN_GUARD_NAME) return [...argv]; + const n = Number(argv[4]); + return argv.slice(5 + n * 3); +} diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts index bc8134dc..5682ea5c 100644 --- a/src/daemon/sandbox/policy.ts +++ b/src/daemon/sandbox/policy.ts @@ -23,7 +23,7 @@ * - network per policy. */ -import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { CodeoidConfig } from "../../config.js"; @@ -34,6 +34,8 @@ import { cliCredentialFiles, registeredSecretLocations } from "../secret-paths.j import { DEFAULT_HOME_READABLE } from "./home-readable.js"; import { validAllowedHost } from "./hosts.js"; import { sandboxRoot } from "./root.js"; +import { makeAt, makeInPinned, type PathPin, dirIdentity, pinDir, pinOf, pinProblem, pinRefusal, replaceInPinned, SandboxPinError } from "./pinned.js"; +import { controlDirProblem, nestedControl, type NestedControl, projectRootSecrets, type RootScan, rootScanKey, stillRealDir } from "./project-roots.js"; import { isOwnWorktree, worktreeAdminsOf, worktreeFoldersOf } from "./worktrees.js"; import type { NetworkMode, NetworkPolicy, SandboxSpec } from "./types.js"; @@ -62,6 +64,13 @@ export interface SandboxConfig { * `//`, each session its own folder there (worktrees.ts). */ worktreeRoots: string[]; + /** + * Where a session's commands may read other repositories, read-only + * (`~` expands; {@link PROJECT_ROOT_PARENT} is the parent of the session's + * repository, the default): everything under one, but what the hidden list + * hides (project-roots.ts `resolveProjectRoots`). Absent: the default. + */ + projectRoots?: string[]; docker: { /** Image a Docker sandbox runs; must carry what the backend runs (a shell, node for pi / the Gemini CLI). */ image: string; @@ -84,6 +93,7 @@ export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = { hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], + projectRoots: ["@parent"], docker: { image: "node:22-bookworm-slim", socket: false }, }; @@ -249,6 +259,7 @@ const TEMP_SERVICE_SOCKET = /^(?:\.s\.PGSQL\.\d+(?:\.lock)?|mysqlx?\.sock(?:\.lo export function localServiceSocketPaths(tempDirs: readonly string[] = ["/tmp", "/private/tmp", "/var/tmp"]): string[] { const out = [...LOCAL_SERVICE_SOCKET_DIRS]; for (const t of tempDirs) { + // paths: a look at the host's temp dirs (which local service sockets are there), no write let names: string[] = []; try { names = readdirSync(t); @@ -294,6 +305,7 @@ export function dockerSocketPaths( } /** A socket the daemon's user can connect to (writable: `/var/run/docker.sock` of a user not in the docker group isn't). */ +// paths: a look, no write function socketAt(p: string): boolean { try { if (!statSync(p).isSocket()) return false; @@ -306,6 +318,7 @@ function socketAt(p: string): boolean { /** The runtime sockets {@link dockerSocketPaths} looks for that exist but the daemon's user can't connect to (said, not shown). */ export function unconnectableDockerSockets(home: string, env: Record = process.env, platform: NodeJS.Platform = process.platform): string[] { + // paths: a look, no write const exists = (p: string) => { try { return statSync(p).isSocket(); @@ -373,6 +386,7 @@ export function hiddenPathsFor(config: CodeoidConfig | undefined, inputs: Hidden if (isAbsolute(abs)) out.push(abs); } const all = new Set(); + // paths: the hidden list, by the path as given and where it leads: hiding more, never less for (const p of out) { const abs = resolve(p); all.add(abs); @@ -442,6 +456,40 @@ export interface SpecInputs { * process backend's handle has the network on to reach its model). */ hideLocalServices?: boolean; + /** + * The session's project roots, resolved (project-roots.ts + * `resolveProjectRoots`): readable whole, read-only, what the hidden list + * hides in them hidden again, and the credential files anywhere in them + * (`projectRootSecrets`); one too big to look through isn't shown. + */ + projectRoots?: readonly string[]; + /** + * Directories the session writes besides its workspace, validated + * (project-roots.ts `addedDirProblem`), each kept by its real path: each + * as a workspace is, its git structure and project settings held, and + * every repository and settings dir inside it (`nestedControl`), its + * repository's worktree folder writable; left out when it no longer is a + * directory at that path, or is too big to look through. + * `ownWorktreeEntries`: as {@link ownWorktreeEntries}, for its repository. + */ + addedDirs?: readonly { dir: string; ownWorktreeEntries?: readonly string[]; agent?: boolean }[]; + /** How many directories a root's or an added dir's look goes through (project-roots.ts; default theirs): tests. */ + scanLimit?: number; + /** + * What each bound directory was when it was pinned (pinned.ts `pinRecord`, + * by real path: the workspace, added dirs, project roots; #448 round 13): + * one that isn't that directory now, or is reached through a link, refuses + * the build ({@link SandboxPinError}). One with no record is pinned as it + * is now (and a link-named one is never bound). + */ + pins?: Readonly>; + /** + * The look through the roots (and the agent's own added dirs) for + * credential files the session's resolution already made + * (project-roots.ts `projectRootSecretsAsync`): used when it was of the + * same dirs, else looked again. + */ + rootScan?: RootScan; } /** The home a spec hides: absolute, and not `/` (nothing is left to show then). */ @@ -473,6 +521,7 @@ export function homeCredentialFilesIn(readable: readonly string[]): string[] { const out: string[] = []; for (const r of readable) { if (!r.endsWith(`${sep}.nvm`)) continue; + // paths: a look at a program's install (where node versions are), no write let versions: string[] = []; try { versions = readdirSync(join(r, "versions", "node")); @@ -541,6 +590,7 @@ const WORKTREE_CONTROL = ["commondir", "gitdir", "config.worktree"] as const; /** Made when missing, so there is something to hold read-only: empty dirs, and an empty `config.worktree` (read only where the config turns it on). */ const GIT_PLACEHOLDER_DIRS = ["hooks", "info", "modules", "remotes", "branches", "worktrees"] as const; +// paths: a look, no write function readGitFile(dotgit: string): string | null { try { const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotgit, "utf8")); @@ -589,6 +639,7 @@ export function gitLayoutOf(workspace: string, home: string = process.env.HOME ? if (dirname(dir) === dir) break; } if (!dotgit) return null; + // paths: a look, no write let st: ReturnType; try { st = lstatSync(dotgit); @@ -697,6 +748,7 @@ export function gitWorkspacePaths(workspace: string, home: string = process.env. const layout = gitLayoutOf(ws, home); if (!layout) return out; const { dotgit, own } = layout; + // paths: looks (what the repository's layout is), no write; what is made goes through makeAt // A `.git` dir right at the top of the workspace: git's fallback when it is made invalid must find nothing. if (dirname(dotgit) === ws && !isLink(dotgit) && lstatSync(dotgit).isDirectory()) { const head = join(ws, "HEAD"); @@ -704,6 +756,7 @@ export function gitWorkspacePaths(workspace: string, home: string = process.env. if (emptyDir(head)) out.readOnly.push(head); } const common = layout.common; + // paths: looks, no write if (!common) { // Not a layout codeoid can verify: nothing of it is writable, the `.git` file can't be rewritten. if (lexists(dotgit) && !isLink(dotgit) && within(dotgit, ws) && !lstatSync(dotgit).isDirectory()) out.readOnly.push(dotgit); @@ -775,21 +828,31 @@ export function gitWorkspacePaths(workspace: string, home: string = process.env. * sandbox holds them read-only for commands; a file tool isn't one. No side * effects (nothing is made). */ -export function protectedWriteTargets(workdir: string, home: string = process.env.HOME ?? homedir()): HeldPath[] { +export function protectedWriteTargets(workdir: string, home: string = process.env.HOME ?? homedir(), addedDirs: readonly string[] = []): HeldPath[] { const ws = sandboxWorkspaceOf(workdir, home); const out: HeldPath[] = []; - const layout = gitLayoutOf(ws, home); - if (layout) { - for (const p of [layout.dotgit, layout.common, layout.own, layout.named]) if (p) out.push({ at: p, whole: true, what: "git" }); - if (layout.common) { - const others = listDirs(join(layout.common, "worktrees")).map((n) => gitdirTarget(join(layout.common as string, "worktrees", n))); - for (const f of [...others, ...submoduleCheckouts(join(layout.common, "modules"))]) if (f && within(f, ws)) out.push({ at: f, whole: true, what: "git" }); - } + // The workspace, and each dir the session was given to write (SpecInputs.addedDirs), as the spec holds them: + // with every repository in it, and its settings dirs and bare repositories wherever they are. + const nested = addedDirs.map((d) => ({ dir: resolve(d), control: nestedControlCached(resolve(d)) })); + for (const n of nested) { + for (const at of n.control.settings) out.push({ at, whole: true, what: "settings" }); + for (const at of n.control.gitDirs) out.push({ at, whole: true, what: "git" }); } - // Each dir from the workdir up to the workspace's top: where the CLIs look for project settings. - for (const dir of projectDirsOf(workdir, ws)) { - for (const d of PROJECT_CONFIG_DIRS) out.push({ at: join(dir, d), whole: true, what: "settings" }); - for (const f of PROJECT_CONFIG_FILES) out.push({ at: join(dir, f), whole: false, what: "settings" }); + const tops = [[ws, projectDirsOf(workdir, ws)] as const, ...nested.flatMap((n) => [n.dir, ...nestedRepos(n.dir, n.control)].map((d) => [d, [d]] as const))]; + for (const [top, dirs] of tops) { + const layout = gitLayoutOf(top, home); + if (layout) { + for (const p of [layout.dotgit, layout.common, layout.own, layout.named]) if (p) out.push({ at: p, whole: true, what: "git" }); + if (layout.common) { + const others = listDirs(join(layout.common, "worktrees")).map((n) => gitdirTarget(join(layout.common as string, "worktrees", n))); + for (const f of [...others, ...submoduleCheckouts(join(layout.common, "modules"))]) if (f && within(f, top)) out.push({ at: f, whole: true, what: "git" }); + } + } + // Each dir from the workdir up to the workspace's top: where the CLIs look for project settings. + for (const dir of dirs) { + for (const d of PROJECT_CONFIG_DIRS) out.push({ at: join(dir, d), whole: true, what: "settings" }); + for (const f of PROJECT_CONFIG_FILES) out.push({ at: join(dir, f), whole: false, what: "settings" }); + } } return out; } @@ -802,6 +865,7 @@ export function gitLayoutProblem(workspace: string, home: string = process.env.H } /** An empty directory (not a link). */ +// paths: a look, no write function emptyDir(p: string): boolean { try { return lstatSync(p).isDirectory() && readdirSync(p).length === 0; @@ -811,6 +875,7 @@ function emptyDir(p: string): boolean { } /** git's own record that `gitDir` is the submodule git dir checked out at `dotgit`'s dir: its `core.worktree` names it back. */ +// paths: a look at git's records, no write function validSubmodule(gitDir: string, dotgit: string): boolean { try { if (!lstatSync(gitDir).isDirectory() || !gitDirShape(gitDir)) return false; @@ -825,7 +890,8 @@ function validSubmodule(gitDir: string, dotgit: string): boolean { } /** A git dir as git checks one: `objects/` and `HEAD`. */ -function gitDirShape(dir: string): boolean { +// paths: a look, no write +export function gitDirShape(dir: string): boolean { try { return lstatSync(join(dir, "objects")).isDirectory() && lstatSync(join(dir, "HEAD")).isFile(); } catch { @@ -833,6 +899,7 @@ function gitDirShape(dir: string): boolean { } } +// paths: a look, no write function lexists(p: string): boolean { try { lstatSync(p); @@ -842,6 +909,7 @@ function lexists(p: string): boolean { } } +// paths: a look, no write function isLink(p: string): boolean { try { return lstatSync(p).isSymbolicLink(); @@ -853,11 +921,12 @@ function isLink(p: string): boolean { /** A dir (`"dir"`) or a file with `content`, made only where nothing (not even a link) is: never through one. Whether it was made. */ function makeIfMissing(p: string, content: string): boolean { try { - if (content === "dir") mkdirSync(p, { mode: 0o755 }); - else writeFileSync(p, content, { flag: "wx", mode: 0o644 }); - return true; + // In its directory as it is now (a workspace, working directory or added dir the build just checked against its + // pin, or a git dir its layout vouched for), pinned for the make: never through a link, the file `O_EXCL | + // O_NOFOLLOW`, the directory checked again after (pinned.ts, #448 round 14). + return makeAt(p, content === "dir" ? "dir" : { content, mode: 0o644 }, "making a placeholder"); } catch { - /* there already, or a repository codeoid can't write either */ + /* there already, or a repository codeoid can't write either, or its directory changed */ return false; } } @@ -878,21 +947,25 @@ function excludePlaceholders(workspace: string, home: string, names: readonly st const info = join(layout.common, "info"); const exclude = join(info, "exclude"); try { - if (!lexists(info)) mkdirSync(info, { mode: 0o755 }); + // The git dir pinned for the write: `info/` made and `exclude` replaced whole, never through a link (pinned.ts). + const common = pinDir(realOr(layout.common), "the repository's git dir", null, "listing placeholders in info/exclude"); + makeInPinned(common, "info", "dir", "listing placeholders in info/exclude"); if (isLink(info) || isLink(exclude)) return; const rel = relative(dirname(layout.dotgit), resolve(workspace)); if (rel.startsWith("..") || isAbsolute(rel)) return; + // paths: a look (the file's current text, before replaceInPinned writes it whole) const have = lexists(exclude) ? readFileSync(exclude, "utf8") : ""; const lines = have.split("\n"); const add = names.map((n) => `/${rel ? `${rel.split(sep).join("/")}/` : ""}${n}`).filter((l) => !lines.includes(l)); if (add.length === 0) return; const head = lines.includes(EXCLUDE_MARK) ? [] : [EXCLUDE_MARK]; - writeFileSync(exclude, `${have}${have && !have.endsWith("\n") ? "\n" : ""}${[...head, ...add].join("\n")}\n`, { flag: "w" }); + replaceInPinned(common, "info/exclude", `${have}${have && !have.endsWith("\n") ? "\n" : ""}${[...head, ...add].join("\n")}\n`, "listing placeholders in info/exclude"); } catch { /* a repository codeoid can't write: the placeholder shows in `git status` */ } } +// paths: a look, no write function listDirs(dir: string): string[] { try { return readdirSync(dir, { withFileTypes: true }) @@ -904,6 +977,7 @@ function listDirs(dir: string): string[] { } /** The `.git` file a worktree's git dir names as its checkout's (its `gitdir` file), absolute. */ +// paths: a look at git's records, no write function gitdirTarget(worktreeGitDir: string): string | null { try { const named = readFileSync(join(worktreeGitDir, "gitdir"), "utf8").trim(); @@ -924,6 +998,7 @@ function submoduleCheckouts(modules: string, depth = 0, budget = { left: 200 }): for (const name of listDirs(modules)) { if (--budget.left < 0) break; const dir = join(modules, name); + // paths: a look at git's records, no write if (gitDirShape(dir)) { try { const m = /^\s*worktree\s*=\s*(.+?)\s*$/im.exec(readFileSync(join(dir, "config"), "utf8")); @@ -941,6 +1016,7 @@ function submoduleCheckouts(modules: string, depth = 0, budget = { left: 200 }): } /** git's own record that `gitDir` is the worktree whose `.git` file is `dotgit`, in a real repository `common`. */ +// paths: a look at git's records, no write function validWorktree(gitDir: string, common: string, dotgit: string): boolean { try { if (!lstatSync(common).isDirectory() || !lstatSync(join(common, "objects")).isDirectory() || !lstatSync(join(common, "HEAD")).isFile()) return false; @@ -1011,6 +1087,7 @@ export function projectConfigPaths(workspace: string, home: string = process.env export function sandboxCacheDir(home: string, env: Record = process.env): string | null { const xdg = env.XDG_CACHE_HOME && isAbsolute(env.XDG_CACHE_HOME) ? env.XDG_CACHE_HOME : join(home, ".cache"); const dir = join(xdg, "codeoid-sandbox"); + // paths: the sandboxes' cache dir in the user's own cache dir: made by the daemon; in a sandbox it is a mount point (no rename) under a home that is read-only there try { mkdirSync(dir, { recursive: true, mode: 0o700 }); const st = lstatSync(dir); @@ -1042,6 +1119,7 @@ export function toolchainCacheEnv(cacheDir: string, home: string, env: Record projectConfigPaths(dir, home)); + const configDir = inputs.hidden?.configDir ?? getConfigDir(); + // Every directory bound by name, checked first (#448 round 13, pinned.ts): one reached through a link, or another + // directory than the one pinned, refuses the build; it is never followed to where the link leads. + const stored = inputs.pins ?? {}; + const refuse = (problem: string | null): void => { + if (problem) throw new SandboxPinError(pinRefusal(problem)); + }; + refuse(pinProblem(workspace, "the workspace", { ...(stored[workspace] ? { stored: stored[workspace] } : { mayBeMissing: true }) })); + const workdir = inputs.workdir !== undefined ? resolve(inputs.workdir) : null; + // The working directory by its identity too (#448 round 14): one moved aside and replaced, even by a real + // directory, is another directory, never bound or snapshotted as the session's. + if (workdir && workdir !== workspace) refuse(pinProblem(workdir, "the working directory", stored[workdir] ? { stored: stored[workdir] } : { mayBeMissing: true })); + // Each added dir as a workspace of its own (never one inside the workspace: it is writable already), and only + // while it still is what was granted: a directory at its real path, no git or settings dir, every repository and + // settings dir in it found (project-roots.ts; Session says when one is dropped). One pinned that changed refuses. + const added = (inputs.addedDirs ?? []) + .map((a) => ({ ...a, dir: resolve(a.dir) })) + .filter((a, i, all) => !within(a.dir, workspace) && all.findIndex((b) => b.dir === a.dir) === i) + .filter((a) => { + const record = stored[a.dir]; + if (record) refuse(pinProblem(a.dir, "added directory", { stored: record })); + return stillRealDir(a.dir) && controlDirProblem(a.dir, home) === null; + }) + .map((a) => ({ ...a, nested: nestedControl(a.dir, inputs.scanLimit) })) + .filter((a) => a.nested.complete); + const gitOf = (dir: string, ownEntries: readonly string[] | undefined): GitPaths => + gitWorkspacePaths(dir, home, { + worktreeRoots: sandboxCfg.worktreeRoots, + configDir, + ...(inputs.worktreeFolder ? { worktreeFolder: inputs.worktreeFolder } : {}), + ...(ownEntries ? { ownEntries } : {}), + }); + const git: GitPaths = inputs.git === false ? { writable: [], readOnly: [], gitDirs: [], folders: [], locations: [], problems: [] } : gitOf(workspace, inputs.ownWorktreeEntries); + if (inputs.git !== false) { + // An added dir's own worktree git dir stays its own, even where the workspace's look holds it (another worktree of the same repository). + const addedOwn = new Set(); + for (const a of added) { + const g = gitOf(a.dir, a.ownWorktreeEntries); + const own = gitLayoutOf(a.dir, home)?.own; + if (own) addedOwn.add(resolve(own)); + for (const k of ["writable", "readOnly", "gitDirs", "folders", "locations", "problems"] as const) for (const p of g[k]) if (!git[k].includes(p)) git[k].push(p); + } + // The repositories inside an added dir (a folder of them): their git structure read-only, as the workspace's. + for (const top of added.flatMap((a) => nestedRepos(a.dir, a.nested))) { + const g = gitWorkspacePaths(top, home, { worktreeRoots: sandboxCfg.worktreeRoots, configDir }); + for (const p of g.readOnly) if (!git.readOnly.includes(p)) git.readOnly.push(p); + for (const p of g.problems) if (!git.problems.includes(p)) git.problems.push(p); + } + // Never the workspace's own worktree git dir, wherever a look holds the rest of `worktrees/`. + const wsOwn = gitLayoutOf(workspace, home)?.own; + if (wsOwn && added.length > 0) addedOwn.add(resolve(wsOwn)); + if (addedOwn.size > 0) git.readOnly = git.readOnly.filter((p) => !addedOwn.has(resolve(p))); + } + const projectConfig = + inputs.projectConfig === false + ? [] + : [ + ...[...projectDirsOf(inputs.workdir ?? workspace, workspace), ...added.map((a) => a.dir), ...added.flatMap((a) => nestedRepos(a.dir, a.nested))].flatMap((dir) => projectConfigPaths(dir, home)), + // Settings dirs and files anywhere else in an added dir, and bare repositories there: held as they are. + ...added.flatMap((a) => [...a.nested.settings, ...a.nested.gitDirs]).filter((p) => lexists(p) && !isLink(p)), + ].filter((p, i, all) => all.indexOf(p) === i); const cache = inputs.cache === false ? null : sandboxCacheDir(home, env); const own = [...(inputs.backendState ?? []).map((p) => resolve(p)), ...git.writable, ...(cache ? [cache] : [])]; const ownReal = own.map(realOr); // Made now if it wasn't: what the daemon puts there later is hidden too. const root = sandboxRootOrNull(); - const tempDir = resolve(inputs.tempDir); + const tempDir = realOr(resolve(inputs.tempDir)); // A workspace that is the home itself: the home is what the session works on (the hidden list still applies in it). const homeHidden = hiddenHome(home) === workspace ? null : hiddenHome(home); const userHidden = userHiddenPaths(inputs.config, home); const allowed = homeHidden ? homeReadablePaths(inputs.config, homeHidden).filter((r) => !userHidden.some((u) => within(r, u))) : []; + const baseHidden = hiddenPathsFor(inputs.config, inputs.hidden); + // What the hidden list hides, the home aside: no root or added dir is inside it (validated; again here). + const inHidden = (p: string) => [...baseHidden, ...(root ? [root] : [])].some((h) => within(p, h) || within(realOr(p), realOr(h))); + const addedKept = added.filter((a) => !inHidden(a.dir) && !userHidden.some((u) => within(a.dir, u))); + // The session's own: its workspace and the dirs its owner added (their credentials are its to use, as the + // workspace's are). A dir the agent added itself (autonomous `request_directory`) keeps the hiding a root has. + // Its repository's git dir too (a fork's worktree uses its main checkout's): git there reads its config. + const ownCommon = inputs.git === false ? null : (gitLayoutOf(workspace, home)?.common ?? null); + const writableDirs = [workspace, ...(ownCommon ? [ownCommon] : []), ...addedKept.filter((a) => !a.agent).map((a) => a.dir)]; + const agentDirs = addedKept.filter((a) => a.agent).map((a) => a.dir); + // Each root by its real path (a root named through a link is hidden in where it really is), pinned as the rest. + const rootCandidates = (inputs.projectRoots ?? []) + .map((r) => resolve(r)) + .map((r) => { + const record = stored[r]; + if (record) refuse(pinProblem(r, "project root", { stored: record })); + return dirIdentity(r) !== null ? r : realOr(r); + }) + .filter((r, i, all) => dirIdentity(r) !== null && all.indexOf(r) === i && !userHidden.some((u) => within(r, u)) && !inHidden(r)); + // The credential files anywhere in a root and in an agent's own added dir (not the session's own); a root or such + // a dir too big to look through whole isn't shown at all, nor written (fail closed: resolveSandbox says so). + const scanned = [...rootCandidates, ...agentDirs]; + const rootSecrets = + inputs.rootScan && inputs.scanLimit === undefined && inputs.rootScan.key === rootScanKey(scanned, writableDirs) ? inputs.rootScan.result : projectRootSecrets(scanned, writableDirs, inputs.scanLimit); + const roots = rootCandidates.filter((r) => !rootSecrets.over.includes(r)); + const addedDirs = addedKept.map((a) => a.dir).filter((d) => !rootSecrets.over.includes(d)); + // The worktree locations shown (other sessions' checkouts of this repository, their copied `.env`s): the same. + const locationSecrets = projectRootSecrets(git.locations, [...writableDirs, ...git.folders]); // A link in the allowlist leads only where the allowlist (or what is outside the home) shows: what it // leads to elsewhere in the home stays hidden (a project of yours, with its `.env`), and the link dangles. const reexposed = [ @@ -1080,27 +1237,74 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { ...allowed, // The worktree locations holding the session's folders, read-only: a worktree added elsewhere in one fails // (it isn't hidden away into a scratch dir), and the repository's other worktrees there are readable. - ...git.locations.filter((l) => !userHidden.some((u) => within(l, u))), + ...git.locations.filter((l) => !userHidden.some((u) => within(l, u)) && !locationSecrets.over.includes(l)), + // The project roots: other repositories, read-only. + ...roots, ]; const hiddenAll = [ ...(homeHidden ? [homeHidden] : []), - ...hiddenPathsFor(inputs.config, inputs.hidden), + ...baseHidden, ...homeCredentialFilesIn(reexposed), + // The credential files under a root and in the worktree locations shown. + ...rootSecrets.hidden, + ...locationSecrets.hidden, ...(root ? [root] : []), ]; // The session's worktree folders: writable, but never inside something hidden (a root configured in a hidden // path), and, unlike the backend's own state, never taking a hidden path inside them out of the hidden list. const locations = git.folders.filter((l) => !hiddenAll.some((h) => h !== homeHidden && (within(l, h) || within(l, realOr(h))))); // What stays visible inside the hidden home: a hidden path there matters only inside one of these. - const shown = [workspace, tempDir, ...own, ...ownReal, ...locations, ...reexposed, ...reexposed.map(realOr)]; + const shown = [workspace, tempDir, ...own, ...ownReal, ...locations, ...addedDirs, ...addedDirs.map(realOr), ...reexposed, ...reexposed.map(realOr)]; + // A credential the look found stays hidden even in a git dir commits write (an agent's own added dir's). + const credentials = new Set(rootSecrets.hidden); const hidden = hiddenAll.filter( (h, i, all) => all.indexOf(h) === i && // The backend's own state stays reachable by it; nothing it holds is taken out otherwise. - ![...own, ...ownReal].some((o) => within(h, o)) && + (credentials.has(h) || ![...own, ...ownReal].some((o) => within(h, o))) && // Inside the hidden home, only what is inside something shown needs hiding again. (!homeHidden || h === homeHidden || !within(h, homeHidden) || shown.some((x) => within(h, x))), ); + // What stays writable: a held path or a dir held in place is bound only inside one (outside, nothing to hold, + // and a bind there could show what is hidden). + const writableRoots = [workspace, ...ownReal, ...addedDirs, ...locations]; + const inWritable = (p: string) => writableRoots.some((w) => within(p, w)); + // Held read-only, by real path (#448 round 14: resolved here, once, never again at a launch); only inside + // something writable. + const readOnly = [...new Set([...git.readOnly, ...projectConfig].map(realOr))].filter(inWritable); + // Held in place (no rename, still writable inside): every dir between a writable root and a hidden path in it, + // and between two nested writable roots (pinned); the working directory and every dir between it and the + // workspace (a monorepo's package dir moved aside takes its held settings with it, #448 round 14), and every + // dir between a writable root and a path held read-only in it (heldInPlace). + const pinned = [...new Set([...protectedAncestors(hidden, [workspace, tempDir, ...own, ...addedDirs, ...locations]), ...nestedRootAncestors([workspace, tempDir, ...addedDirs, ...locations], hidden)])]; + // paths: a look (is the working directory there), no write + const heldInPlace = [ + ...new Set([ + ...(workdir && workdir !== workspace && within(workdir, workspace) && existsSync(workdir) ? [workdir, ...heldAncestors([workdir], [workspace], hidden)] : []), + ...heldAncestors(readOnly, writableRoots, hidden), + ]), + ].filter((p) => !pinned.includes(p)); + // Everything bound by name, as it is now (pinned.ts): checked again at every launch, and bound as it is here. + // Not what is hidden (a link there hides more), nor the home's allowlist or a program's location (shown by + // where a link leads, by design), nor the owner's container sockets: none is where a sandbox writes. + const pins: PathPin[] = []; + const seenPin = new Set(); + const pin = (p: PathPin) => { + if (seenPin.has(p.path)) return; + seenPin.add(p.path); + pins.push(p); + }; + pin(pinOf(workspace, "the workspace")); + if (workdir && workdir !== workspace) pin(pinOf(workdir, "the working directory")); + for (const d of addedDirs) pin(pinOf(d, "added directory")); + for (const r of roots) pin(pinOf(r, "project root")); + for (const f of locations) pin(pinOf(f, "worktree folder", { mayBeMissing: true, noId: true })); + for (const w of ownReal) pin(pinOf(w, "a writable directory of the session's")); + pin(pinOf(tempDir, "the session's temp dir")); + for (const p of readOnly) pin(pinOf(p, "a path held read-only", { mayBeMissing: true })); + for (const p of [...pinned, ...heldInPlace]) pin(pinOf(p, "a directory held in place")); + // Made and removed by codeoid between turns, as the folders in them (SessionSandbox.tidyFolders): checked against links. + for (const l of git.locations) pin(pinOf(realOr(l), "a worktree location", { mayBeMissing: true, noId: true })); // The container runtime's sockets, when the owner opted in: reachable at their own paths, `DOCKER_HOST` the first. const sockets = inputs.dockerSocket ? dockerSocketPaths(home, env) : []; const exposed = new Set(sockets.flatMap((s) => [s, realOr(s)])); @@ -1108,13 +1312,17 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { sessionId: inputs.sessionId, workspace, tempDir, - writable: [...own, ...locations], + writable: [...new Set([...ownReal, ...addedDirs, ...locations])], hidden, - readOnly: [...git.readOnly, ...projectConfig], + readOnly, gitDirs: git.gitDirs, ...(locations.length > 0 ? { worktreeFolders: locations } : {}), + ...(roots.length > 0 ? { projectRoots: roots } : {}), + ...(addedDirs.length > 0 ? { addedDirs } : {}), ...(cache || sockets.length > 0 ? { env: { ...(cache ? toolchainCacheEnv(cache, home, env) : {}), ...(sockets[0] ? { DOCKER_HOST: `unix://${sockets[0]}` } : {}) } } : {}), - pinned: protectedAncestors(hidden, [workspace, tempDir, ...own, ...locations]), + pinned, + ...(heldInPlace.length > 0 ? { heldInPlace } : {}), + pins, reexposed, containerSockets: containerSocketPaths(home, env).filter((p) => !exposed.has(p) && !exposed.has(realOr(p))), ...((inputs.hideLocalServices ?? inputs.network.mode !== "on") ? { serviceSockets: localServiceSocketPaths() } : {}), @@ -1125,6 +1333,22 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { return spec; } +/** nestedControl, looked again at most every few seconds: the gate asks it of every file tool call. */ +const nestedCache = new Map(); +function nestedControlCached(dir: string): NestedControl { + const hit = nestedCache.get(dir); + if (hit && Date.now() - hit.at < 5_000) return hit.control; + const control = nestedControl(dir); + if (nestedCache.size > 64) nestedCache.clear(); + nestedCache.set(dir, { at: Date.now(), control }); + return control; +} + +/** The repositories inside an added dir `dir` (nestedControl), but its own: each held as a workspace's is. */ +function nestedRepos(dir: string, nested: NestedControl): string[] { + return nested.repos.filter((r) => resolve(r) !== resolve(dir)); +} + function sandboxRootOrNull(): string | null { try { return sandboxRoot(); @@ -1133,6 +1357,7 @@ function sandboxRootOrNull(): string | null { } } +// paths: at a spec build only: a path found where it really is, once (a workspace or added dir the build just checked against its pin, the repository's layout, the daemon's or the user's own dirs); whatever of it is bound is pinned there (pinOf) and never resolved again at a launch function realOr(p: string): string { try { return realpathSync(p); @@ -1155,10 +1380,60 @@ export function protectedAncestors(hidden: readonly string[], writableRoots: rea for (const w of roots) { if (at === w || !within(at, w)) continue; for (let a = dirname(at); a !== w && within(a, w); a = dirname(a)) { - // A dir that is hidden itself, or inside a hidden one, is never pinned: a pin binds - // the real dir, which would show it again (the home in a workspace of /home). Hidden, - // it is a mount point of its own (no rename) or not there at all. - if (hiddenReal.some((h) => within(a, h))) continue; + // A dir that is hidden itself, or inside a hidden one in the root, is never pinned: a pin + // binds the real dir, which would show it again (the home in a workspace of /home). Hidden, + // it is a mount point of its own (no rename) or not there at all. One the root shows again + // (the root in the hidden home) is pinned. + if (hiddenReal.some((h) => within(a, h) && within(h, w) && h !== w)) continue; + out.add(a); + } + } + } + return [...out]; +} + +/** + * The directories strictly between a writable root and another one bound + * inside it (a workspace in an added dir, a worktree folder in one): pinned + * too (SandboxSpec.pinned), so no command can move a parent aside and put a + * link where the inner root was (#448 round 13). Never one hidden, or in a + * hidden dir inside the outer root (a pin would show it). Real paths. + */ +export function nestedRootAncestors(roots: readonly string[], hidden: readonly string[] = [], exists: (p: string) => boolean = existsSync): string[] { + const real = roots.map(realOr); + const hiddenReal = hidden.map(realOr); + const out = new Set(); + for (const inner of real) { + if (!exists(inner)) continue; + for (const outer of real) { + if (inner === outer || !within(inner, outer)) continue; + for (let a = dirname(inner); a !== outer && within(a, outer); a = dirname(a)) { + if (hiddenReal.some((h) => within(a, h) && within(h, outer) && h !== outer)) continue; + out.add(a); + } + } + } + return [...out]; +} + +/** + * The directories between a writable root and a path held in it (`held`: + * a path held read-only, the working directory), from the path's parent up, + * strictly inside the root: pinned (SandboxSpec.pinned), so none can be + * moved aside with what is held in it and made again around a planted copy + * (#448 round 14: a monorepo package's `.claude/`, a submodule's hooks). + * Never one hidden, or in a hidden dir inside the root. Real paths. + */ +export function heldAncestors(held: readonly string[], roots: readonly string[], hidden: readonly string[] = [], exists: (p: string) => boolean = existsSync): string[] { + const real = roots.map(realOr); + const hiddenReal = hidden.map(realOr); + const out = new Set(); + for (const p of held.map(realOr)) { + if (!exists(p)) continue; + for (const root of real) { + if (p === root || !within(p, root)) continue; + for (let a = dirname(p); a !== root && within(a, root); a = dirname(a)) { + if (hiddenReal.some((h) => within(a, h) && within(h, root) && h !== root)) continue; out.add(a); } } @@ -1219,6 +1494,7 @@ export function programLocations( } /** The nearest directory at or above `file`'s holding a `package.json`, below `/`. */ +// paths: a look at a program's install, no write function packageRoot(file: string): string | null { for (let dir = dirname(file); dir !== dirname(dir); dir = dirname(dir)) { try { @@ -1231,6 +1507,7 @@ function packageRoot(file: string): string | null { } /** The program a script's `#!` line runs (`env`'s argument for `#!/usr/bin/env x`), or null. */ +// paths: a look at a program's first line, no write function shebangProgram(file: string): string | null { let head = ""; try { diff --git a/src/daemon/sandbox/project-roots.ts b/src/daemon/sandbox/project-roots.ts new file mode 100644 index 00000000..552b0dbb --- /dev/null +++ b/src/daemon/sandbox/project-roots.ts @@ -0,0 +1,763 @@ +/** + * Working across repositories (#448 follow-up, docs/sandbox.md "Working + * across repositories"): what a session may read besides its workspace, and + * what it may write. + * + * - project roots (`sandbox.projectRoots`, a session's own at create): + * readable whole, read-only. The default, {@link PROJECT_ROOT_PARENT}, is + * the parent of the session's repository (`~/Workspace` for a session in + * `~/Workspace/codeoid`), so its commands read the other repositories + * there, and see what other sessions did in their checkouts; a workspace + * in no repository has none. What the sandbox hides anywhere stays hidden + * in them (credential stores, codeoid's own dirs, the agents' logins, + * container sockets), and so do the credential files anywhere in them + * ({@link projectRootSecrets}: a root too big to look through isn't used); + * - added directories (`SessionSandboxSetting.addedDirs`): writable, each + * as a workspace is (its git structure and project settings held, its + * own worktree folder), and so is every repository and settings dir + * inside one ({@link nestedControl}). Kept by their real path, taken when + * granted ({@link addedDirReal}), and dropped when that no longer is a + * directory ({@link stillRealDir}). Added by the owner, or asked for by + * the agent (`request_directory`, request-directory.ts; in autonomous + * mode only {@link autonomousGrantProblem}'s). + * + * Neither may be a place whose contents run outside the sandbox, the home or + * `/` ({@link sandboxDirProblem}), nor a root the host's temp dir + * ({@link projectRootProblem}); an added one, nothing the sandbox hides, nor + * a git or project settings dir ({@link addedDirProblem}). + */ + +import { type Dirent, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { hooksPathOf } from "./git-watch.js"; +import { gitDirShape, gitLayoutOf, PROJECT_CONFIG_DIRS, PROJECT_CONFIG_FILES } from "./policy.js"; +import { type DirContext, hostTempRoots, normalizeSandboxDir, PROJECT_ROOT_PARENT, projectRootProblem, sandboxDirProblem } from "./dir-rules.js"; +import { mainCheckoutOf, worktreeLocationDirs } from "./worktrees.js"; + +export { type DirContext, hostTempRoots, normalizeSandboxDir, PROJECT_ROOT_PARENT, projectRootProblem, sandboxDirProblem }; + +function within(child: string, parent: string): boolean { + return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`); +} + +// paths: a look, no write +function lexists(p: string): boolean { + try { + lstatSync(p); + return true; + } catch { + return false; + } +} + +// paths: a root or an added dir found by its real path once, when first resolved or granted; then pinned (pinRecord) and checked at every use (pinned.ts) +function realOr(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +/** Dirs a scan doesn't go into: dependencies and build caches, too many to look through (docs/sandbox.md, Limits). */ +export const SCAN_SKIPPED_DIRS: ReadonlySet = new Set(["node_modules", ".venv", "__pycache__", ".tox", ".gradle", ".next", ".turbo"]); + +/** How many directories an added dir's look for repositories and settings goes through ({@link nestedControl}). */ +export const ADDED_DIR_SCAN_LIMIT = 20_000; +/** How many directories of each project root the look for credential files goes through ({@link projectRootSecrets}). */ +export const PROJECT_ROOT_SCAN_LIMIT = 20_000; + +/** The first component of `p` as written (absolute) that is a symbolic link, or null (none, or a missing one first). */ +// paths: a look (which component is a link), no write +export function linkInPath(p: string): string | null { + let at = ""; + for (const part of resolve(p).split("/").filter(Boolean)) { + at += `/${part}`; + try { + if (lstatSync(at).isSymbolicLink()) return at; + } catch { + return null; + } + } + return null; +} + +/** Where `dir` (`~` expanded; absolute) really is, or null when it isn't there: what is stored and bound when it is added. */ +// paths: an added dir found by its real path once, when granted; then pinned (pinRecord) and checked at every use (pinned.ts) +export function addedDirReal(dir: string, home: string = process.env.HOME ?? homedir()): string | null { + const abs = normalizeSandboxDir(dir, home); + if (abs === null) return null; + try { + return realpathSync(abs); + } catch { + return null; + } +} + +/** + * Whether the added dir kept at `stored` (its real path when granted) is + * still a directory there, reached through no link: one replaced by a link, + * or removed, is dropped (said on the session), never followed. + */ +// paths: a check (still a directory at its real path, no link), no write +export function stillRealDir(stored: string): boolean { + const at = resolve(stored); + if (linkInPath(at) !== null) return false; + try { + return lstatSync(at).isDirectory() && realpathSync(at) === at; + } catch { + return false; + } +} + +/** + * Why `real` (a real path) is a dir whose contents git or an agent CLI runs + * outside the sandbox, or null: a project settings dir or one inside it + * (`.claude/`, `.codex/`, …: PROJECT_CONFIG_DIRS), a `.git` or `.hg` + * dir or one inside it, a git dir by its shape (a bare repository, a + * worktree's admin dir, a submodule's), or the repository's `core.hooksPath` + * dir or one inside it. + */ +export function controlDirProblem(real: string, home: string = process.env.HOME ?? homedir()): string | null { + const at = resolve(real); + const parts = at.split("/").filter(Boolean); + for (let i = 0; i < parts.length; i++) { + const name = parts[i] as string; + const path = `/${parts.slice(0, i + 1).join("/")}`; + if (PROJECT_CONFIG_DIRS.includes(name)) { + return `${path === at ? `${at} is` : `${at} is in`} a project settings dir (${path}), which agent CLIs run code from outside the sandbox`; + } + if (name === ".git" || name === ".hg") return `${path === at ? `${at} is` : `${at} is in`} a repository's ${name} dir (${path}), which git runs code from outside the sandbox`; + } + for (let dir = at; ; dir = dirname(dir)) { + if (gitDirShape(dir)) return `${dir === at ? `${at} is` : `${at} is in`} a git dir (${dir}), which git runs code from outside the sandbox`; + if (dirname(dir) === dir) break; + } + const layout = gitLayoutOf(at, home); + if (layout?.common) { + const top = mainCheckoutOf(layout) ?? dirname(layout.dotgit); + const hooks = hooksPathOf([join(layout.common, "config"), join(layout.common, "config.worktree"), ...(layout.own ? [join(layout.own, "config.worktree")] : [])]); + if (hooks) { + const dir = realOr(hooks.startsWith("~/") ? join(home, hooks.slice(2)) : resolve(top, hooks)); + if (within(at, dir)) return `${at === dir ? `${at} is` : `${at} is in`} the repository's hooks dir (core.hooksPath ${hooks}), which git runs outside the sandbox`; + } + } + return null; +} + +/** What an added dir holds that runs outside the sandbox: each held read-only as the workspace's own are (policy.ts). */ +export interface NestedControl { + /** The top of each repository inside it (a dir with a `.git`), the added dir's own included when it is one. */ + repos: string[]; + /** Bare repositories (git dirs by their shape) inside it: held read-only whole. */ + gitDirs: string[]; + /** Project settings dirs and files inside it (PROJECT_CONFIG_DIRS, PROJECT_CONFIG_FILES), at any depth. */ + settings: string[]; + /** False when the look stopped at its bound, or at a dir of yours it couldn't read: then the dir isn't added. */ + complete: boolean; +} + +/** + * What `dir` holds that git or an agent CLI runs outside the sandbox: every + * repository, bare git dir and project settings dir or file in it, looked + * for through its directories (not through links, not in a repository's + * git dir, not in {@link SCAN_SKIPPED_DIRS}), at most `limit` of them. + */ +export function nestedControl(dir: string, limit = ADDED_DIR_SCAN_LIMIT): NestedControl { + const out: NestedControl = { repos: [], gitDirs: [], settings: [], complete: true }; + const uid = typeof process.getuid === "function" ? process.getuid() : null; + const queue = [resolve(dir)]; + let seen = 0; + while (queue.length > 0) { + const d = queue.shift() as string; + if (gitDirShape(d)) { + out.gitDirs.push(d); + continue; + } + // paths: a look through an added dir (what repositories and settings dirs it holds), no write + let entries: Dirent[]; + try { + entries = readdirSync(d, { withFileTypes: true }); + } catch { + // One of yours unreadable now could be made readable (and written) from inside: not looked through, not added. + try { + if (uid === null || lstatSync(d).uid === uid) out.complete = false; + } catch { + /* gone meanwhile */ + } + if (!out.complete) return out; + continue; + } + for (const e of entries) { + if (e.isSymbolicLink()) continue; + const at = join(d, e.name); + if (e.name === ".git") { + out.repos.push(d); + continue; + } + if (PROJECT_CONFIG_DIRS.includes(e.name) && e.isDirectory()) { + out.settings.push(at); + continue; + } + if (PROJECT_CONFIG_FILES.includes(e.name) && e.isFile()) { + out.settings.push(at); + continue; + } + if (!e.isDirectory() || e.name === ".hg" || SCAN_SKIPPED_DIRS.has(e.name)) continue; + if (++seen > limit) { + out.complete = false; + return out; + } + queue.push(at); + } + } + return out; +} + +export interface AddedDirContext extends DirContext { + /** What every sandbox hides (policy.ts `hiddenPathsFor`): an added dir is never in one. */ + hidden?: readonly string[]; + /** The daemon's `PATH`: a dir on it (or holding an entry of it) runs outside the sandbox. */ + path?: string; + /** {@link nestedControl}'s bound (tests). */ + scanLimit?: number; +} + +/** + * Why `dir` can't be added to a session's writable directories, or null: + * {@link sandboxDirProblem}, then where it really is must be a directory of + * the daemon's user, outside everything the sandbox hides, holding no dir of + * the daemon's `PATH` (a program written there runs outside any sandbox at + * the user's next command), neither a git nor a project settings dir nor + * inside one ({@link controlDirProblem}), and small enough that every + * repository and settings dir in it is found ({@link nestedControl}). + */ +export function addedDirProblem(dir: string, ctx: AddedDirContext = {}): string | null { + const basic = sandboxDirProblem(dir, ctx); + if (basic) return basic; + const home = resolve(ctx.home ?? process.env.HOME ?? homedir()); + const abs = normalizeSandboxDir(dir, home) as string; + const real = addedDirReal(abs, home); + if (real === null) return `${dir} doesn't exist`; + if (real !== abs) { + const again = sandboxDirProblem(real, ctx); + if (again) return `${dir} leads to ${real}, which ${again.startsWith(`${real} `) ? again.slice(real.length + 1) : `can't be added: ${again}`}`; + } + // paths: a look, no write + let st: ReturnType; + try { + st = lstatSync(real); + } catch { + return `${dir} doesn't exist`; + } + if (!st.isDirectory()) return `${dir} isn't a directory`; + if (typeof process.getuid === "function" && st.uid !== process.getuid()) return `${dir} isn't yours (another user owns it)`; + for (const h of ctx.hidden ?? []) { + const hr = realOr(h); + // In one: what is hidden isn't the session's to write. Holding one is fine: it stays hidden inside. + if (within(abs, h) || within(real, hr)) return `${dir} is in what the sandbox hides (${h})`; + } + for (const entry of (ctx.path ?? process.env.PATH ?? "").split(":")) { + if (!entry || !isAbsolute(entry)) continue; + if (within(resolve(entry), abs) || within(realOr(entry), real)) return `${dir} holds ${entry}, which is on your PATH: a program written there would run outside the sandbox`; + } + const control = controlDirProblem(real, home); + if (control) return control; + const limit = ctx.scanLimit ?? ADDED_DIR_SCAN_LIMIT; + if (!nestedControl(real, limit).complete) { + return `${dir} holds more than ${limit} directories to look through for repositories and project settings (${[...SCAN_SKIPPED_DIRS].slice(0, 2).join(", ")} and the like aside), or one of yours that can't be read: what is in it couldn't all be protected — add the repositories you need one by one`; + } + return null; +} + +/** + * Why the agent's own `request_directory` can't be granted unasked in + * autonomous mode (an unattended run's too), or null. Beyond + * {@link addedDirProblem}: the path as written goes through no link + * (`written`), and where it really is (`real`) is strictly inside one of + * the session's project roots and inside a repository's checkout (its top, + * or a dir in it): a folder holding repositories, a root itself, or + * anything else is the owner's to add. + */ +export function autonomousGrantProblem(written: string, real: string, roots: readonly string[], home: string = process.env.HOME ?? homedir()): string | null { + const link = linkInPath(written); + if (link) return `${written} goes through a symbolic link (${link}); in autonomous mode a directory is added only by its real path`; + const inside = roots.some((r) => { + const rr = realOr(resolve(r)); + return within(real, rr) && real !== rr; + }); + if (!inside) return `${real} isn't under this session's project roots (${roots.length > 0 ? roots.join(", ") : "none"}); in autonomous mode nothing else is added`; + const layout = gitLayoutOf(real, home); + const top = layout?.common ? (layout.kind === "worktree" || layout.kind === "submodule" ? dirname(layout.dotgit) : (mainCheckoutOf(layout) ?? dirname(layout.dotgit))) : null; + if (!top || !within(real, realOr(top))) return `${real} isn't in a repository; in autonomous mode only a repository under a project root (or a directory in one) is added`; + return null; +} + +/** A checkout another session works in (its workspace, a worktree folder of its): what an autonomous grant never touches. */ +export interface OtherCheckout { + dir: string; + /** In words: `session "x"'s workspace`. */ + what: string; +} + +/** + * Why `real` (a real path) touches a checkout that isn't the session's own, + * or null (#448 round 13): it is, is inside or holds one of `others` (other + * sessions' workspaces and worktree folders, which their commands write: a + * swap there would be bound here); it is a checkout in one of its + * repository's worktree locations (`-worktrees/`, + * `
/.worktrees/`, `sandbox.worktreeRoots`'s: codeoid's fork, + * loop and `--worktree` checkouts, other sessions' folders) that isn't one + * of `ownFolders`, or holds such a location; or it is another checkout of + * the session's own repository (`workspace`'s: its main checkout, seen from + * a fork's worktree). An autonomous grant of one is refused; the owner's + * card says it. + */ +export function checkoutOverlap( + real: string, + others: readonly OtherCheckout[], + opts: { workspace: string; ownFolders?: readonly string[]; worktreeRoots?: readonly string[]; configDir?: string; home?: string }, +): string | null { + const at = resolve(real); + const home = resolve(opts.home ?? process.env.HOME ?? homedir()); + for (const o of others) { + const dir = realOr(resolve(o.dir)); + if (within(at, dir)) return `${at} ${at === dir ? "is" : "is in"} ${o.what} (${dir}), which that session's commands write`; + if (within(dir, at)) return `${at} holds ${o.what} (${dir}), which that session's commands write`; + } + const own = (opts.ownFolders ?? []).map((f) => realOr(resolve(f))); + const layout = gitLayoutOf(at, home); + if (layout?.common) { + const main = mainCheckoutOf(layout); + const locations = [ + ...worktreeLocationDirs(layout, { roots: opts.worktreeRoots ?? [], home, ...(opts.configDir ? { configDir: opts.configDir } : {}) }).locations.map((l) => l.dir), + ...(main ? [join(main, ".worktrees")] : []), + ].map((l) => realOr(l)); + for (const l of locations) { + if (within(at, l) && at !== l && !own.some((f) => within(at, f))) return `${at} is in ${l}, a worktree location: the checkouts there are other sessions' and codeoid's own (forks, loops, --worktree)`; + if (within(l, at) && lexists(l)) return `${at} holds ${l}, a worktree location: the checkouts there are other sessions' and codeoid's own (forks, loops, --worktree)`; + } + const mine = gitLayoutOf(resolve(opts.workspace), home); + if (mine?.common && realOr(mine.common) === realOr(layout.common)) { + const top = layout.kind === "worktree" ? dirname(layout.dotgit) : (main ?? dirname(layout.dotgit)); + return `${at} ${at === realOr(top) ? "is" : "is in"} another checkout of this session's own repository (${realOr(top)}${top === main ? ", its main checkout" : ""})`; + } + } + return null; +} + +/** + * The default project root for a session working in `workspace`: the + * parent of its repository's main checkout (a linked worktree's too, so a + * fork in `-worktrees/` reads ``'s siblings). Null when the + * workspace isn't in a repository: then there is no default root. + */ +export function defaultProjectRoot(workspace: string, home: string = process.env.HOME ?? homedir()): string | null { + const ws = resolve(workspace); + const layout = gitLayoutOf(ws, home); + if (!layout) return null; + const top = mainCheckoutOf(layout) ?? dirname(layout.dotgit); + return dirname(resolve(top)); +} + +export interface ResolvedRoots { + /** Absolute, existing dirs, deduplicated (by where they really are). */ + roots: string[]; + /** The one that is the default ({@link PROJECT_ROOT_PARENT}), when it is used. */ + defaultRoot?: string; + /** What wasn't used, and why (the sandbox's limits say it). */ + notes: string[]; +} + +/** + * `entries` (the session's own, else `sandbox.projectRoots`) for a session + * working in `workspace`: {@link PROJECT_ROOT_PARENT} as + * {@link defaultProjectRoot} (none outside a repository, said), a default + * that would be the home (a repository directly in it), the host's temp + * dir or another refused place falling back to no root, said; anything else + * refused by {@link projectRootProblem} or not there, said and not used. + * Each by its real path (one named through a link is read where it really + * is, said). + */ +export function resolveProjectRoots(entries: readonly string[], workspace: string, ctx: DirContext = {}): ResolvedRoots { + const home = resolve(ctx.home ?? process.env.HOME ?? homedir()); + const out: ResolvedRoots = { roots: [], notes: [] }; + const seen = new Set(); + for (const entry of entries) { + const isDefault = entry === PROJECT_ROOT_PARENT; + const at = isDefault ? defaultProjectRoot(workspace, home) : normalizeSandboxDir(entry, home); + if (isDefault && at === null) { + out.notes.push("no project root: the workspace isn't in a repository, so there is no default (the repository's parent) — set sandbox.projectRoots (or the session's own) to read other directories"); + continue; + } + const problem = at === null ? `${entry} isn't an absolute path` : projectRootProblem(at, { ...ctx, home }); + if (problem || at === null) { + out.notes.push( + isDefault + ? `no project root: the default, the repository's parent (${at}), ${problem?.replace(/^\S+ is /, "is ") ?? "can't be one"} — set sandbox.projectRoots (or the session's own) to read other repositories` + : `project root ${entry}: ${problem}: not used`, + ); + continue; + } + // paths: a look, no write; a root is used by its real path, pinned at its first resolution + let dir = false; + try { + dir = lstatSync(realOr(at)).isDirectory(); + } catch { + /* not there */ + } + if (!dir) { + if (!isDefault) out.notes.push(`project root ${entry} (${at}) isn't a directory here: not used`); + continue; + } + // By where it really is (#448 round 13): what is hidden in it, what is shown around the workspace and its pin + // are all of the real dirs, never of a link's path (bubblewrap binds a link's path whole, before them). + const real = realOr(at); + if (seen.has(real)) continue; + seen.add(real); + if (real !== at && !isDefault) out.notes.push(`project root ${entry} is ${real} (where it really is): read there`); + out.roots.push(real); + if (isDefault) out.defaultRoot = real; + } + return out; +} + +/** + * Credential files, by name: dotenv files (not their examples), direnv's, + * registry and netrc tokens, private keys (not `.pub`: `id_*`, `*_rsa`, + * `*_ed25519`, `*_ecdsa`, `*_dsa`, deploy keys, `*.key`, `*.pem`, PuTTY's + * `*.ppk`, Apple's `*.p8`), certificates' keystores, kubeconfigs, Terraform + * state and variables, a vault token, service-account keys, `secrets.*`. + */ +const DATA_EXT = "(?:json|ya?ml|toml|ini|env|txt|conf|cfg|properties|xml|csv)"; +const KEY_EXT = "(?:\\.(?:key|pem|ppk|old|bak|orig|backup|[0-9]+))?"; +const SECRET_FILE = new RegExp( + `^(?:${[ + "\\.env(?:\\.(?!(?:example|sample|template)$)[^/]+)?", + "\\.envrc", + "\\.npmrc", + "\\.pypirc", + "[._]netrc", + "\\.git-credentials", + "\\.vault-token", + "\\.pgpass", + "[^/]*\\.(?:pem|key|p12|pfx|jks|keystore|ppk|p8)", + // Key names (`id_ed25519_work`, `github_rsa`, `deploy_key`): no extension, or a key's or a backup's, never `.pub` + // (nor a source file: `id_generator.ts`, `crypto_rsa.go` aren't keys). + `id_[^/.]+${KEY_EXT}`, + `[^/.]*_(?:rsa|dsa|ecdsa|ed25519)(?:_[^/.]*)?${KEY_EXT}`, + `(?:[^/.]*[_-])?deploy[_-]?key(?:[_-][^/.]*)?${KEY_EXT}`, + "kubeconfig(?:\\.[^/]+)?", + "[^/]*\\.kubeconfig", + "[^/]*\\.tfstate(?:\\.[^/]+)?", + "[^/]*\\.tfvars(?:\\.json)?", + `credentials(?:\\.${DATA_EXT})?`, + "service[-_]account[^/]*\\.json", + `secrets?\\.${DATA_EXT}`, + ].join("|")})$`, + "i", +); +/** Dirs that hold only credentials (a project's own `.aws/`, `.ssh/`, …): hidden whole. */ +const SECRET_DIRS: ReadonlySet = new Set([".ssh", ".gnupg", ".aws", ".azure", ".gcloud", ".kube", ".password-store"]); +/** Credential files by where they are: a project's own Claude settings (their `env`), Docker's login. */ +const SECRET_SUFFIXES: readonly string[] = ["/.claude/settings.local.json", "/.docker/config.json", "/.mcp.json"]; +/** + * Files that hold a credential only sometimes, judged by what is in them: + * Yarn's `.yarnrc.yml` (`npmAuthToken`, `npmAuthIdent`) and `.yarnrc` + * (`_authToken`), Composer's `auth.json`. + */ +const SECRET_BY_CONTENT: Readonly> = { + ".yarnrc.yml": /\bnpm(?:AuthToken|AuthIdent|Password)\s*:/, + ".yarnrc": /_auth(?:Token)?\b|_password\b/, + "auth.json": /"(?:http-basic|github-oauth|gitlab-oauth|gitlab-token|bitbucket-oauth|bearer|forgejo-token|custom-headers)"\s*:/, +}; + +/** A small regular file's text, or null (not one, too big, unreadable). */ +// paths: a look (a config's text, for credentials), no write +function smallText(file: string, max = 1 << 20): string | null { + try { + const st = lstatSync(file); + if (!st.isFile() || st.size > max) return null; + return readFileSync(file, "utf8"); + } catch { + return null; + } +} + +/** Whether `file` (named `name`) is one of {@link SECRET_BY_CONTENT}'s holding a credential. */ +function secretByContent(name: string, file: string): boolean { + const re = SECRET_BY_CONTENT[name]; + if (!re) return false; + const text = smallText(file); + return text !== null && re.test(text); +} + +/** + * Whether a git config file carries a credential: a URL with a user or + * password in it (an https one, or any with a password), or a token in its + * query; an http `extraheader` in any syntax git reads (alone on its line, + * after its section's header on the same line, under `[http ""]`, any + * case); a credential helper that is a shell snippet printing a password or + * a token. A file too big to read is taken to carry one. + */ +// paths: a look (a git config's text, for credentials), no write +export function gitConfigHasCredential(file: string): boolean { + let st: ReturnType; + try { + st = lstatSync(file); + } catch { + return false; + } + if (!st.isFile()) return false; + if (st.size > 1 << 20) return true; + const text = smallText(file); + if (text === null) return false; + return ( + /\b(?:https?|ftps?):\/\/[^\s/"@]+@/i.test(text) || + /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:"@]+:[^\s/"@]*@/i.test(text) || + /:\/\/[^\s"]*[?&](?:private[_-]?token|access[_-]?token|token|password|passwd|api[_-]?key|apikey|auth|key)=/i.test(text) || + /(?:^|\])[ \t]*extraheader[ \t]*=/im.test(text) || + /(?:^|\])[ \t]*helper[ \t]*=[ \t]*"?[ \t]*!.*(?:password|token|secret)/im.test(text) + ); +} + +/** The files a git config pulls in (`include.path`, `includeIf..path`), absolute: relative to it, `~/` to the home. */ +export function gitConfigIncludes(file: string, home: string = process.env.HOME ?? homedir()): string[] { + const text = smallText(file); + if (text === null) return []; + const out: string[] = []; + let section = ""; + for (const raw of text.split("\n")) { + let line = raw.trim(); + const head = /^\[\s*([A-Za-z0-9.-]+)(?:\s+"[^"]*")?\s*\]\s*(.*)$/.exec(line); + if (head) { + section = (head[1] ?? "").toLowerCase(); + line = head[2] ?? ""; + } + if (section !== "include" && section !== "includeif") continue; + const m = /^path\s*=\s*(.*?)\s*$/i.exec(line); + if (!m?.[1]) continue; + const value = m[1].replace(/^"(.*)"$/, "$1"); + if (!value) continue; + out.push(value.startsWith("~/") ? join(home, value.slice(2)) : isAbsolute(value) ? value : resolve(dirname(file), value)); + } + return out; +} + +/** The credential stores a git config's `credential.helper = store --file ` names, absolute (relative to `base`). */ +function credentialStores(file: string, base: string, home: string): string[] { + const text = smallText(file); + if (text === null) return []; + const out: string[] = []; + for (const m of text.matchAll(/(?:^|\])[ \t]*helper[ \t]*=[ \t]*"?[ \t]*store\b[^\n]*?--file(?:[ \t]*=[ \t]*|[ \t]+)("?)([^"\s]+)\1/gim)) { + const f = m[2] ?? ""; + if (!f) continue; + out.push(f.startsWith("~/") ? join(home, f.slice(2)) : isAbsolute(f) ? f : resolve(base, f)); + } + return out; +} + +/** Whether Mercurial's `hgrc` carries a credential: an `[auth]` section, or a URL with a user or password. */ +function hgrcHasCredential(file: string): boolean { + const text = smallText(file); + if (text === null) return false; + return /^\s*\[auth\]/im.test(text) || /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:"@]+:[^\s/"@]*@/i.test(text); +} + +/** + * The credentials git may read in the git dir `gitDir` (a repository's + * `.git`, a bare one, a submodule's): every config git reads there + * (`config`, `config.worktree`, each worktree's `config.worktree`, each + * submodule's under `modules/`, bounded) carrying one, every file they + * include, a `store --file` they name, and a credential file by name at its + * top (`credentials`, `.git-credentials`). Configs in `configs`. + */ +function gitDirSecrets(gitDir: string, home: string, out: { found: string[]; configs: string[] }, budget = { left: 200 }, depth = 0): void { + if (depth > 4 || --budget.left < 0) return; + const top = basename(gitDir) === ".git" ? dirname(gitDir) : gitDir; + const configs = [join(gitDir, "config"), join(gitDir, "config.worktree")]; + for (const w of listDirNames(join(gitDir, "worktrees"))) configs.push(join(gitDir, "worktrees", w, "config.worktree"), join(gitDir, "worktrees", w, "config")); + const seen = new Set(); + const look = (config: string, level: number): void => { + if (seen.has(config) || level > 5) return; + seen.add(config); + if (gitConfigHasCredential(config)) { + out.found.push(config); + out.configs.push(config); + } + for (const store of credentialStores(config, top, home)) out.found.push(store); + for (const inc of gitConfigIncludes(config, home)) look(inc, level + 1); + }; + for (const c of configs) look(c, 0); + for (const name of listFileNames(gitDir)) if (SECRET_FILE.test(name)) out.found.push(join(gitDir, name)); + const walkModules = (dir: string, level: number): void => { + if (level > 4) return; + for (const name of listDirNames(dir)) { + if (--budget.left < 0) return; + const sub = join(dir, name); + // A submodule's git dir (one with a config is read by git as one, whatever else it has), and a submodule + // named with a slash (`libs/a`): its git dir a level down. + if (gitDirShape(sub) || smallText(join(sub, "config")) !== null) gitDirSecrets(sub, home, out, budget, depth + 1); + if (!gitDirShape(sub)) walkModules(sub, level + 1); + } + }; + walkModules(join(gitDir, "modules"), 0); +} + +// paths: a look, no write +function listDirNames(dir: string): string[] { + try { + return readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + return []; + } +} + +// paths: a look, no write +function listFileNames(dir: string): string[] { + try { + return readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => e.name); + } catch { + return []; + } +} + +/** What the look through project roots found ({@link projectRootSecrets}). */ +export interface RootSecrets { + /** The credential files and dirs, hidden. */ + hidden: string[]; + /** The git configs among them (a repository's whose remote carries a credential): git there can't read its config. */ + gitConfigs: string[]; + /** Roots too big to look through whole: not used (fail closed). */ + over: string[]; +} + +/** + * The credential files anywhere under the `roots` (and the session's own + * worktree locations, `locations`), hidden like the credential stores are: + * {@link SECRET_FILE} by name, {@link SECRET_DIRS} whole, + * {@link SECRET_BY_CONTENT} by what is in them, every git config git may read + * in a repository's git dir or a bare one carrying a credential (and what + * it includes, a credential store it names: {@link gitDirSecrets}), a + * Mercurial `hgrc` with one; but in `writable` (the session's own workspace + * and the dirs its owner added: its own project's `.env` is its to use). + * Looked for through directories (not through links, not in + * {@link SCAN_SKIPPED_DIRS}) when the spec is built: one made later isn't + * hidden. A root with more than `limit` directories to look through is in + * `over`, and isn't to be used. + */ +export function projectRootSecrets(roots: readonly string[], writable: readonly string[], limit = PROJECT_ROOT_SCAN_LIMIT, home: string = process.env.HOME ?? homedir()): RootSecrets { + const scan = scanRootSecrets(roots, writable, limit, home); + for (let step = scan.next(); ; step = scan.next()) if (step.done) return step.value; +} + +/** + * {@link projectRootSecrets} without holding the daemon's event loop: it + * lets other work run every few hundred directories (a root of tens of + * thousands takes a tenth of a second or more). What a session's + * resolution runs; its result is reused by every spec built from it. + */ +export async function projectRootSecretsAsync(roots: readonly string[], writable: readonly string[], limit = PROJECT_ROOT_SCAN_LIMIT, home: string = process.env.HOME ?? homedir()): Promise { + const scan = scanRootSecrets(roots, writable, limit, home); + for (let step = scan.next(); ; step = scan.next()) { + if (step.done) return step.value; + await new Promise((r) => setImmediate(r)); + } +} + +/** What a scan was of, to reuse its result only for the same roots and the same own dirs (buildSandboxSpec). */ +export function rootScanKey(roots: readonly string[], owned: readonly string[]): string { + return JSON.stringify([roots.map((r) => resolve(r)), owned.map((o) => resolve(o))]); +} + +/** A scan's result with what it was of ({@link rootScanKey}). */ +export interface RootScan { + key: string; + result: RootSecrets; +} + +/** How many directories {@link scanRootSecrets} looks through between yields. */ +const SCAN_SLICE = 500; + +function* scanRootSecrets(roots: readonly string[], writable: readonly string[], limit: number, home: string): Generator { + const out: RootSecrets = { hidden: [], gitConfigs: [], over: [] }; + let slice = 0; + const mine = writable.flatMap((w) => [resolve(w), realOr(w)]); + const owned = (p: string) => mine.some((w) => within(p, w)); + const seenRoots = new Set(); + for (const root of roots) { + const top = resolve(root); + if (seenRoots.has(top)) continue; + seenRoots.add(top); + const git = { found: [] as string[], configs: [] as string[] }; + const found: string[] = []; + const queue = [top]; + let seen = 0; + let over = false; + while (queue.length > 0 && !over) { + const d = queue.shift() as string; + if (++slice % SCAN_SLICE === 0) yield; + if (owned(d) || owned(realOr(d))) continue; + // A bare repository (or any git dir by its shape): its configs, never walked as files. + if (gitDirShape(d)) { + gitDirSecrets(d, home, git); + continue; + } + // paths: a look through a root (credential files), no write + let entries: Dirent[]; + try { + entries = readdirSync(d, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + if (e.isSymbolicLink()) continue; + const at = join(d, e.name); + if (e.isDirectory()) { + if (SECRET_DIRS.has(e.name)) found.push(at); + else if (e.name === ".git") gitDirSecrets(at, home, git); + else if (e.name === ".hg") { + if (hgrcHasCredential(join(at, "hgrc"))) found.push(join(at, "hgrc")); + } else if (!SCAN_SKIPPED_DIRS.has(e.name)) { + if (++seen > limit) { + over = true; + break; + } + queue.push(at); + } + continue; + } + if (!e.isFile()) continue; + if (SECRET_FILE.test(e.name) || SECRET_SUFFIXES.some((s) => at.endsWith(s)) || secretByContent(e.name, at)) found.push(at); + } + } + if (over) out.over.push(root); + else { + // What git reads that is in the root (an include or a store elsewhere is hidden where the sandbox hides it). + const inRoot = (p: string) => within(resolve(p), top) || within(realOr(p), realOr(top)); + out.hidden.push(...[...found, ...git.found.filter(inRoot)].filter((p, i, all) => !owned(p) && all.indexOf(p) === i)); + out.gitConfigs.push(...git.configs.filter((p, i, all) => inRoot(p) && !owned(p) && all.indexOf(p) === i)); + } + } + return out; +} + +/** The repository a hidden git config belongs to (`/.git/config`, a bare one's, a submodule's or a worktree's), for what is said about it. */ +export function repositoryOfConfig(config: string): string { + const parts = config.split("/"); + const i = parts.lastIndexOf(".git"); + if (i > 0) return parts.slice(0, i).join("/") || "/"; + return dirname(config); +} + +/** Whether `p` is in one of `dirs` (as written or where it really is). */ +export function inAnyDir(p: string, dirs: readonly string[]): boolean { + const abs = resolve(p); + const real = realOr(abs); + return dirs.some((d) => within(abs, resolve(d)) || within(real, realOr(d))); +} diff --git a/src/daemon/sandbox/request-directory.ts b/src/daemon/sandbox/request-directory.ts new file mode 100644 index 00000000..fe642f7b --- /dev/null +++ b/src/daemon/sandbox/request-directory.ts @@ -0,0 +1,189 @@ +/** + * `request_directory`: how an agent asks for a directory to write besides its + * workspace (docs/sandbox.md, "Working across repositories"). The tool only + * asks; the session decides (Session#requestDirectory): in guarded and + * interactive mode its owner answers a confirm card naming the directory and + * the reason (owner-only, audited); in autonomous mode (an unattended run's + * too) a repository, or a dir in one, under the session's project roots, + * named by a path with no link in it, is added unasked (audited) and + * anything else is refused at once, with a reason the agent reads. Never + * parks an unattended run. What is added is the directory the path really + * is then, and takes effect at the session's next turn. + * + * Delivered the way codeoid's other session tools are: in-process (the + * Claude Agent SDK's server, which Qwen's SDK takes as is), and over the + * daemon's HTTP MCP endpoint {@link SANDBOX_MCP_PATH} for the backends that + * mount a URL (Codex, the Gemini CLI, opencode). A token there is one + * session's, minted for it and revoked with it: there is no parameter naming + * a session. + */ + +import { randomUUID } from "node:crypto"; +import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; +import { DEFAULT_MCP_PROTOCOL_VERSION, ok, rpcErr, tokenFrom, type JsonRpcMessage, type JsonRpcResponse } from "../mcp/jsonrpc-http.js"; + +export const SANDBOX_MCP_SERVER_NAME = "codeoid_sandbox"; +export const REQUEST_DIRECTORY_TOOL = "request_directory"; +/** The tool's name as the Claude SDK exposes it. */ +export const REQUEST_DIRECTORY_TOOL_FQ = `mcp__${SANDBOX_MCP_SERVER_NAME}__${REQUEST_DIRECTORY_TOOL}`; +/** Where the endpoint is mounted on the daemon's HTTP server. */ +export const SANDBOX_MCP_PATH = "/mcp/sandbox"; +/** The env var a URL-mounting backend reads the token from (Codex's `bearer_token_env_var`; out of its commands' environment: `CODEOID_*_TOKEN`). */ +export const SANDBOX_MCP_TOKEN_ENV = "CODEOID_SANDBOX_TOKEN"; +/** + * How long a call waits for its owner's answer before it returns "no answer + * yet" (the question stays open; a later yes still adds the directory): + * under the Gemini CLI's and Qwen's MCP call timeout (10 min); Codex's is + * raised past it (`tool_timeout_sec`). + */ +export const REQUEST_DIRECTORY_WAIT_MS = 8 * 60_000; +/** The same on opencode, whose MCP client gives a call about a minute (the MCP SDK's default request timeout). */ +export const REQUEST_DIRECTORY_SHORT_WAIT_MS = 45_000; + +export interface RequestDirectoryInput { + path: string; + reason: string; +} + +/** Decides a request for `sessionId`; resolves with the text the agent reads. Never rejects. */ +export type RequestDirectoryHandler = (sessionId: string, input: RequestDirectoryInput) => Promise; + +export const REQUEST_DIRECTORY_DESCRIPTION = [ + "Ask for write access to a directory outside your workspace (another repository, say), for this session's sandboxed commands.", + "Your commands can already read the session's project roots; this is for writing. Give the absolute path and why you need it.", + "Interactive and guarded mode: the owner is asked and decides. Autonomous mode: a repository (or a directory in one) under the project roots, named by its real path (no symbolic link in it), is added at once; anything else is refused.", + "An added directory is writable from your next turn: finish this turn after it is granted. Never your home, /, a git or settings dir (.git, .claude, …), or a dir of credentials.", +].join(" "); + +const REASON_MAX = 1000; + +function clean(input: Record): RequestDirectoryInput | string { + const path = typeof input.path === "string" ? input.path.trim() : ""; + const reason = typeof input.reason === "string" ? input.reason.trim().slice(0, REASON_MAX) : ""; + if (!path) return "Refused: give the directory's absolute path (`path`)."; + if (path.length > 4096) return "Refused: that path is too long."; + if (!reason) return "Refused: say why you need to write there (`reason`): the owner reads it."; + return { path, reason }; +} + +/** The tool as an in-process MCP server bound to one session (Claude; Qwen's SDK takes the same shape). */ +export function buildSandboxMcpServer(sessionId: string, handle: RequestDirectoryHandler): McpSdkServerConfigWithInstance { + const request = tool( + REQUEST_DIRECTORY_TOOL, + REQUEST_DIRECTORY_DESCRIPTION, + { + path: z.string().min(1).max(4096).describe("The directory, absolute (~ expands)."), + reason: z.string().min(1).max(REASON_MAX).describe("Why you need to write there: the owner reads it."), + }, + async (args) => { + const input = clean(args as Record); + const text = typeof input === "string" ? input : await handle(sessionId, input).catch((e) => `Refused: ${e instanceof Error ? e.message : String(e)}`); + return { content: [{ type: "text" as const, text }] }; + }, + ); + return createSdkMcpServer({ name: SANDBOX_MCP_SERVER_NAME, version: "0.1.0", tools: [request] }); +} + +/** What a session hands a URL-mounting backend. */ +export interface SandboxMcpMount { + url: string; + token: string; +} + +/** The tool's input as JSON Schema (the HTTP endpoint's `tools/list`, pi's bridge). */ +export const REQUEST_DIRECTORY_SCHEMA: Record = { + type: "object", + properties: { + path: { type: "string", description: "The directory, absolute (~ expands)." }, + reason: { type: "string", description: "Why you need to write there: the owner reads it." }, + }, + required: ["path", "reason"], + additionalProperties: false, +}; + +/** The daemon's `/mcp/sandbox` endpoint: one token per session, `request_directory` its one tool. */ +export class SandboxMcpHttp { + readonly #tokens = new Map(); + readonly #handle: RequestDirectoryHandler; + + constructor(handle: RequestDirectoryHandler) { + this.#handle = handle; + } + + /** A token scoped to `sessionId`: the scope of every call made with it. */ + mint(sessionId: string): string { + const token = `sbt_${randomUUID().replace(/-/g, "")}`; + this.#tokens.set(token, sessionId); + return token; + } + + revoke(token: string): void { + this.#tokens.delete(token); + } + + /** Every token of `sessionId` (its destroy). */ + revokeSession(sessionId: string): void { + for (const [t, s] of this.#tokens) if (s === sessionId) this.#tokens.delete(t); + } + + get activeTokens(): number { + return this.#tokens.size; + } + + async handle(req: Request): Promise { + if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } }); + const token = tokenFrom(req); + const sessionId = token ? this.#tokens.get(token) : undefined; + if (!sessionId) { + return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { "Content-Type": "application/json", "WWW-Authenticate": "Bearer" } }); + } + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json(rpcErr(null, -32700, "Parse error"), { status: 400 }); + } + const batch = Array.isArray(body); + const messages = (batch ? body : [body]) as JsonRpcMessage[]; + const responses: JsonRpcResponse[] = []; + let sawInitialize = false; + for (const m of messages) { + if (m && m.method === "initialize") sawInitialize = true; + const res = await this.#dispatch(m, sessionId); + if (res) responses.push(res); + } + if (responses.length === 0) return new Response(null, { status: 202 }); + const headers: Record = { "Content-Type": "application/json" }; + if (sawInitialize && token) headers["Mcp-Session-Id"] = token; + return new Response(JSON.stringify(batch ? responses : responses[0]), { status: 200, headers }); + } + + async #dispatch(msg: JsonRpcMessage | null, sessionId: string): Promise { + if (msg?.id === undefined) return null; + const id = msg.id ?? null; + switch (msg.method) { + case "initialize": { + const requested = msg.params?.protocolVersion; + return ok(id, { + protocolVersion: typeof requested === "string" ? requested : DEFAULT_MCP_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: SANDBOX_MCP_SERVER_NAME, version: "0.1.0" }, + }); + } + case "ping": + return ok(id, {}); + case "tools/list": + return ok(id, { tools: [{ name: REQUEST_DIRECTORY_TOOL, description: REQUEST_DIRECTORY_DESCRIPTION, inputSchema: REQUEST_DIRECTORY_SCHEMA }] }); + case "tools/call": { + const name = msg.params?.name; + if (name !== REQUEST_DIRECTORY_TOOL) return ok(id, { content: [{ type: "text", text: `Unknown tool: ${String(name)}` }], isError: true }); + const input = clean((msg.params?.arguments ?? {}) as Record); + const text = typeof input === "string" ? input : await this.#handle(sessionId, input).catch((e) => `Refused: ${e instanceof Error ? e.message : String(e)}`); + return ok(id, { content: [{ type: "text", text }], isError: false }); + } + default: + return rpcErr(id, -32601, `Method not found: ${String(msg.method)}`); + } + } +} diff --git a/src/daemon/sandbox/root.ts b/src/daemon/sandbox/root.ts index de6f5997..75cb78d5 100644 --- a/src/daemon/sandbox/root.ts +++ b/src/daemon/sandbox/root.ts @@ -18,6 +18,7 @@ import { join } from "node:path"; let root: string | null = null; /** The directory (made on first use; made again, at the same path, if a temp cleaner removed it). */ +// paths: the daemon's private dir: made 0700 by it, exclusive, never a link, which no sandbox sees export function sandboxRoot(): string { if (!root) { const made = mkdtempSync(join(tmpdir(), "codeoid-sandbox-")); @@ -53,6 +54,7 @@ export function sandboxRootIfMade(): string | null { * A fresh private dir of the daemon's own, `/XXXXXX`: under the * root every sandbox hides, whenever it is made (a sign-in started after a * session's sandbox was built is hidden from it too). + // paths: the daemon's private dir (sandboxRoot) */ export function daemonTempDir(prefix: string): string { return mkdtempSync(join(sandboxRoot(), prefix)); @@ -63,6 +65,7 @@ export function daemonTempPath(name: string): string { return join(sandboxRoot(), name); } +// paths: a look at the host's temp dir for daemons' private dirs, no write /** Every daemon's root in `tmp` (this one's, another's, a killed one's): `codeoid-sandbox-*`. */ export function sandboxRootsIn(tmp: string = tmpdir()): string[] { try { diff --git a/src/daemon/sandbox/seatbelt.ts b/src/daemon/sandbox/seatbelt.ts index 5617fa80..a7db09ea 100644 --- a/src/daemon/sandbox/seatbelt.ts +++ b/src/daemon/sandbox/seatbelt.ts @@ -29,6 +29,7 @@ import { realpathSync, statSync } from "node:fs"; import { LocalSandboxHandle } from "./local-handle.js"; +import { boundPath } from "./pinned.js"; import type { NetworkMode, SandboxCapabilities, SandboxHandle, SandboxProbe, SandboxProvider, SandboxSpawnOptions, SandboxSpec } from "./types.js"; export const SEATBELT_PROVIDER_ID = "seatbelt"; @@ -63,13 +64,16 @@ export function seatbeltProfile( spec: SandboxSpec, fs: { real?: (p: string) => string; isDir?: (p: string) => boolean | null } = {}, ): SeatbeltProfile { - const real = fs.real ?? ((p: string) => { + // Everything a rule names by path is pinned: named as it was pinned (the handle checked it is still that, and + // the launched argv checks again; #448 round 14), never resolved again here. Only what isn't is resolved. + // paths: resolves only what isn't pinned (boundPath): a hidden path, the home's allowlist + const real = boundPath(spec.pins, fs.real ?? ((p: string) => { try { return realpathSync(p); } catch { return p; } - }); + })); const isDir = fs.isDir ?? (() => null); const params: Record = {}; const names = new Map(); @@ -114,7 +118,7 @@ export function seatbeltProfile( // A repository's hooks and config: not writable, with everything in them. for (const p of (spec.readOnly ?? []).map(real)) rules.push({ path: p, kind: "read-only" }); // Every dir between a writable root and a hidden path in it: the directory itself not writable (so no rename), its contents still. - for (const p of (spec.pinned ?? []).map(real)) rules.push({ path: p, kind: "pinned" }); + for (const p of [...(spec.pinned ?? []), ...(spec.heldInPlace ?? [])].map(real)) rules.push({ path: p, kind: "pinned" }); const rank = (r: Rule) => (r.kind === "hide" ? 0 : 1); rules.sort((a, b) => depth(a.path) - depth(b.path) || rank(a) - rank(b)); @@ -227,6 +231,7 @@ export class SeatbeltSandboxProvider implements SandboxProvider { async probe(): Promise { if (process.platform !== "darwin") return { available: false, reason: "Seatbelt runs on macOS only" }; try { + // paths: the probe: a fixed argv, in no session's directory const proc = Bun.spawn([this.#bin, "-p", "(version 1)(allow default)", "/usr/bin/true"], { stdin: "ignore", stdout: "ignore", stderr: "pipe", timeout: 10_000 }); const [code, err] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); if (code === 0) return { available: true, detail: this.#bin }; @@ -242,6 +247,7 @@ export class SeatbeltSandboxProvider implements SandboxProvider { openSync(spec: SandboxSpec): SandboxHandle { const bin = this.#bin; + // paths: a look (directory or file) for a rule's form, once the handle checked the pins const isDir = (p: string): boolean | null => { try { return statSync(p).isDirectory(); @@ -253,6 +259,7 @@ export class SeatbeltSandboxProvider implements SandboxProvider { if (spec.privateTemp) { // The temp dir is made per program, in the spec's (a real path: Seatbelt sees `/private/…`). let parent = spec.tempDir; + // paths: the session's temp dir: made by the daemon in its private dir, which no sandbox sees try { parent = realpathSync(parent); } catch { diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts index dac39c8e..c6e197f6 100644 --- a/src/daemon/sandbox/session-sandbox.ts +++ b/src/daemon/sandbox/session-sandbox.ts @@ -13,7 +13,7 @@ * starts on it. */ -import { existsSync, lstatSync, mkdirSync, mkdtempSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import type { SessionSandboxInfo, SessionSandboxSetting } from "../../protocol/types.js"; @@ -22,8 +22,25 @@ import type { ClaudeTempDirs } from "./claude-native.js"; import { type ConfinementPlan, confinementFor, runsNoCommands } from "./confinement.js"; import { GIT_BIN } from "../git-env.js"; import { CWD_FROM_CALLER } from "./docker.js"; -import { buildSandboxSpec, dockerSocketPaths, gitLayoutOf, gitLayoutProblem, networkPolicyOf, type SandboxConfig, sandboxConfigOf, sandboxWorkspaceOf, unconnectableDockerSockets, validAllowedHost } from "./policy.js"; +import { buildSandboxSpec, dockerSocketPaths, gitLayoutOf, gitLayoutProblem, hiddenPathsFor, networkPolicyOf, type SandboxConfig, sandboxConfigOf, sandboxWorkspaceOf, unconnectableDockerSockets, validAllowedHost } from "./policy.js"; +import { + addedDirProblem, + addedDirReal, + linkInPath, + nestedControl, + normalizeSandboxDir, + PROJECT_ROOT_PARENT, + PROJECT_ROOT_SCAN_LIMIT, + projectRootSecretsAsync, + repositoryOfConfig, + type RootScan, + rootScanKey, + resolveProjectRoots, + SCAN_SKIPPED_DIRS, + stillRealDir, +} from "./project-roots.js"; import { ENTRY_GRACE_MS, WorktreeEntryGuard, type WorktreeEntryFinding } from "./worktree-entries.js"; +import { inPinnedDirSync, type PinnedDir, pinDir, realPathOnce, removeEmptyDir, pinProblem, pinRecord, pinRefusal, pinsProblem, SandboxPinError, unguardedArgv } from "./pinned.js"; import { existingFolderName, mainCheckoutOf, sessionFolderName, worktreeFoldersOf, worktreeLocationDirs, worktreeRootNotes } from "./worktrees.js"; import type { SandboxRegistry } from "./registry.js"; import { cliWorktrees, gitProgramLocations, gitShimSpec, writeGitShim } from "./git-shim.js"; @@ -142,6 +159,12 @@ export interface EffectiveSandbox { network: NetworkPolicy; /** The host's container runtime socket is reachable (`sandbox.docker.socket` / the session's `dockerSocket`): root-equivalent. */ dockerSocket: boolean; + /** Project roots as set (`@parent`, or absolute with `~` expanded): readable (project-roots.ts). */ + projectRoots: string[]; + /** Directories the session writes besides its workspace, absolute. */ + addedDirs: string[]; + /** Those the agent added itself (an autonomous `request_directory`): a project root's credential hiding stays in them. */ + agentDirs: string[]; } /** The setting a session ends up with: its own fields over the config's. */ @@ -151,13 +174,88 @@ export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxS ...(setting?.network ? { mode: setting.network } : {}), ...(setting?.allowedHosts ? { allow: setting.allowedHosts } : {}), }); - return { choice, network, dockerSocket: setting?.dockerSocket ?? config.docker.socket ?? false }; + const home = process.env.HOME ?? homedir(); + const roots = (setting?.projectRoots ?? config.projectRoots ?? [PROJECT_ROOT_PARENT]).map((r) => (r === PROJECT_ROOT_PARENT ? r : (normalizeSandboxDir(r, home) ?? r))); + const added = (setting?.addedDirs ?? []).map((d) => normalizeSandboxDir(d, home)).filter((d): d is string => d !== null); + const agent = new Set((setting?.agentDirs ?? []).map((d) => normalizeSandboxDir(d, home))); + return { + choice, + network, + dockerSocket: setting?.dockerSocket ?? config.docker.socket ?? false, + projectRoots: [...new Set(roots)], + addedDirs: [...new Set(added)], + agentDirs: [...new Set(added)].filter((d) => agent.has(d)), + }; +} + +/** + * `setting` with each added directory `before` didn't have kept by where it + * really is now, when it is granted (project-roots.ts `addedDirReal`), and + * pinned there (pinned.ts: what it is, `dev:ino`): what is bound is that + * directory, never where a link there leads later, nor another one put in + * its place. One already kept stays as it was (a link put there since is + * dropped at the next build, never followed). An added dir inside another + * one isn't kept twice (the outer holds it). The agent's own (`agentDirs`) + * are those still added. + */ +export function canonicalAddedDirs(setting: SessionSandboxSetting, before?: SessionSandboxSetting): SessionSandboxSetting { + if (!setting.addedDirs) { + if (!setting.agentDirs) return setting; + const { agentDirs: _a, ...rest } = setting; + return rest; + } + const had = new Set(before?.addedDirs ?? []); + const unique = [...new Set(setting.addedDirs.map((d) => (had.has(d) ? d : (addedDirReal(d) ?? d))))]; + const inside = (d: string, o: string) => d !== o && d.startsWith(o.endsWith("/") ? o : `${o}/`); + const dirs = unique.filter((d) => !unique.some((o) => inside(d, o))); + const ids: Record = { ...(setting.pins?.ids ?? before?.pins?.ids ?? {}) }; + // A dir no longer added has no pin; one granted now is pinned as it is now (a grant again is a grant anew). + for (const d of before?.addedDirs ?? []) if (!dirs.includes(d)) delete ids[d]; + for (const d of dirs) { + if (had.has(d)) continue; + const record = pinRecord(d); + if (record) ids[d] = record; + else delete ids[d]; + } + const agent = new Set(setting.agentDirs ?? []); + const agentDirs = dirs.filter((d) => agent.has(d)); + const { agentDirs: _a, pins: _p, ...rest } = setting; + const pins = setting.pins ?? before?.pins; + return { + ...rest, + addedDirs: dirs, + ...(agentDirs.length > 0 ? { agentDirs } : {}), + ...(Object.keys(ids).length > 0 || pins ? { pins: { ...(pins ?? {}), ids } } : {}), + }; +} + +/** The setting's fields that only say which directories are readable or writable: a change to them alone is applied between turns, never restarting work (SessionSandbox.set). */ +const DIR_FIELDS = ["projectRoots", "addedDirs", "agentDirs"] as const; + +function withoutDirs(setting: SessionSandboxSetting | undefined): string { + // `pins` is what the daemon keeps of the directories, not a choice: no change of it is one of the setting. + const { projectRoots: _r, addedDirs: _a, agentDirs: _g, pins: _p, ...rest } = setting ?? {}; + return JSON.stringify(Object.entries(rest).sort(([a], [b]) => a.localeCompare(b))); +} + +function dirsChanged(before: SessionSandboxSetting | undefined, after: SessionSandboxSetting | undefined): boolean { + return DIR_FIELDS.some((k) => JSON.stringify(before?.[k] ?? null) !== JSON.stringify(after?.[k] ?? null)); +} + +/** Whether `before` → `after` changes only the readable roots or the added directories. */ +export function onlyDirsChanged(before: SessionSandboxSetting | undefined, after: SessionSandboxSetting | undefined): boolean { + return withoutDirs(before) === withoutDirs(after) && dirsChanged(before, after); +} + +/** Whether `before` → `after` changes nothing but what the daemon keeps of the directories (their pins). */ +function sameSetting(before: SessionSandboxSetting | undefined, after: SessionSandboxSetting | undefined): boolean { + return withoutDirs(before) === withoutDirs(after) && !dirsChanged(before, after); } /** A setting in words, for a change's line and audit: "bwrap, network on, Docker access". */ export function describeSandbox(e: EffectiveSandbox): string { if (e.choice === SANDBOX_OFF) return "off"; - return `${e.choice}, network ${e.network.mode}${e.network.mode === "allowlist" ? ` (${(e.network.allow ?? []).join(", ")})` : ""}${e.dockerSocket ? ", Docker access" : ""}`; + return `${e.choice}, network ${e.network.mode}${e.network.mode === "allowlist" ? ` (${(e.network.allow ?? []).join(", ")})` : ""}${e.dockerSocket ? ", Docker access" : ""}${e.addedDirs.length > 0 ? `, also writes ${e.addedDirs.join(", ")}` : ""}`; } /** Why a setting is malformed (an unknown provider, a host that isn't one), or null. */ @@ -167,6 +265,8 @@ export function sandboxSettingProblem(setting: SessionSandboxSetting, registry: } const bad = (setting.allowedHosts ?? []).filter((h) => !validAllowedHost(h)); if (bad.length > 0) return `not a host an allowlist can name: ${bad.slice(0, 3).join(", ")}`; + const notAbsolute = [...(setting.addedDirs ?? []), ...(setting.projectRoots ?? []).filter((r) => r !== PROJECT_ROOT_PARENT)].filter((d) => normalizeSandboxDir(d) === null); + if (notAbsolute.length > 0) return `not an absolute path: ${notAbsolute.slice(0, 3).join(", ")}`; return null; } @@ -178,17 +278,21 @@ function networkWidth(mode: NetworkMode): number { /** * Whether moving from `before` to `after` lets commands do more: the sandbox * turned off, the network wider, an allowlist with a host it didn't have, - * the container runtime's socket let in (root on the machine). Switching - * provider counts as widening (a different boundary). + * the container runtime's socket let in (root on the machine), a directory + * added to what commands write or a project root to what they read. + * Switching provider counts as widening (a different boundary). */ export function widens( - before: { choice: string; network: NetworkPolicy; dockerSocket?: boolean }, - after: { choice: string; network: NetworkPolicy; dockerSocket?: boolean }, + before: { choice: string; network: NetworkPolicy; dockerSocket?: boolean; projectRoots?: readonly string[]; addedDirs?: readonly string[] }, + after: { choice: string; network: NetworkPolicy; dockerSocket?: boolean; projectRoots?: readonly string[]; addedDirs?: readonly string[] }, ): boolean { if (after.choice === SANDBOX_OFF && before.choice !== SANDBOX_OFF) return true; if (before.choice === SANDBOX_OFF) return false; if (after.choice !== before.choice) return true; if (after.dockerSocket === true && before.dockerSocket !== true) return true; + // A directory written, or a root read, that wasn't: the owner's call (removing one narrows). + if ((after.addedDirs ?? []).some((d) => !(before.addedDirs ?? []).includes(d))) return true; + if ((after.projectRoots ?? []).some((r) => !(before.projectRoots ?? []).includes(r))) return true; const b = networkWidth(before.network.mode); const a = networkWidth(after.network.mode); if (a !== b) return a > b; @@ -214,6 +318,131 @@ export function workspaceProblem(workspace: string): string | null { return resolve(workspace) === "/" ? "a session working in / can't be sandboxed (the whole filesystem would be its writable workspace) — give it a project directory" : null; } +/** What the limits line says of a session whose workspace is the home. */ +export const HOME_WORKSPACE_NOTE = + "the workspace is your home: commands can read and write all of it but what the sandbox hides (credential stores, the agents' logins, codeoid's own dirs) — only credentials are protected"; + +/** What {@link sandboxDirsOf} gives back. */ +export interface SandboxDirs { + roots: string[]; + added: string[]; + notes: string[]; + homeWorkspace: boolean; + dropped: Array<{ dir: string; why: string }>; + /** Pins taken now (pinned.ts), by real path: a root or added dir seen for the first time. */ + pinned: Record; + /** The look through the roots for credential files, reused by every spec built from this resolution. */ + scan?: RootScan; +} + +/** + * A session's project roots and added directories for its sandbox's + * workspace `top` (project-roots.ts): what is used, and what isn't and why. + * An added dir inside the workspace is dropped (writable already); one that + * is no longer a directory at the real path it was granted at (a link put + * there, removed), another directory than the one pinned there + * (`opts.pins`, pinned.ts), or can't be one now, is `dropped`, said. A root + * the same, or too big to look through for credential files, isn't used, + * said, and so is a repository there whose git config is hidden for the + * credential in it (never the session's own repository's). A root or added + * dir with no pin is pinned now (`pinned`). The agent's own added dirs + * (`opts.agentDirs`) keep a root's credential hiding. + */ +export async function sandboxDirsOf( + top: string, + rootEntries: readonly string[], + addedDirs: readonly string[], + config?: CodeoidConfig, + opts: { pins?: Readonly>; agentDirs?: readonly string[]; scanLimit?: number } = {}, +): Promise { + const home = resolve(process.env.HOME ?? homedir()); + const configDir = getConfigDir(); + const pins = opts.pins ?? {}; + const pinned: Record = {}; + const homeWorkspace = resolve(top) === home; + const r = resolveProjectRoots(rootEntries, top, { home, configDir }); + const notes = [...(homeWorkspace ? [HOME_WORKSPACE_NOTE] : []), ...(homeWorkspace ? [] : r.notes)]; + const hidden = addedDirs.length > 0 ? hiddenPathsFor(config) : []; + const added: string[] = []; + const dropped: Array<{ dir: string; why: string }> = []; + for (const d of addedDirs) { + if (resolve(d) === resolve(top) || resolve(d).startsWith(`${resolve(top)}/`)) continue; + const stored = pins[resolve(d)]; + const changed = stored ? pinProblem(d, "it", { stored }) : null; + const problem = changed + ? `it is no longer the directory it was when added: ${changed.replace(/^it \S+ /, "it ")}` + : stillRealDir(d) + ? addedDirProblem(d, { home, configDir, hidden }) + : `it is no longer the directory it was when added (${linkInPath(d) ? `${linkInPath(d)} is a symbolic link now` : "removed, or not a directory"})`; + if (problem) { + notes.push(`added directory ${d}: ${problem}: not writable`); + dropped.push({ dir: d, why: problem }); + continue; + } + added.push(resolve(d)); + if (!stored) { + const record = pinRecord(d); + if (record) pinned[resolve(d)] = record; + } + } + let roots: string[] = []; + for (const root of homeWorkspace ? [] : r.roots) { + const stored = pins[root]; + const changed = stored ? pinProblem(root, "project root", { stored }) : null; + if (changed) { + notes.push(`${changed}: not used — set the session's project roots again to use it as it is now`); + continue; + } + roots.push(root); + if (!stored) { + const record = pinRecord(root); + if (record) pinned[root] = record; + } + } + const agent = new Set((opts.agentDirs ?? []).map((d) => resolve(d))); + const agentAdded = added.filter((d) => agent.has(d)); + let scan: RootScan | undefined; + if (roots.length > 0 || agentAdded.length > 0) { + // The session's own: its workspace, its repository's git dir (a fork's worktree uses the main checkout's), and + // what its owner added; the agent's own added dirs are looked through as a root is. Without holding the + // daemon's event loop; every spec built from this resolution reuses it. + const layout = gitLayoutOf(top, home); + const own = [top, ...(layout?.common ? [layout.common] : []), ...added.filter((d) => !agent.has(d))]; + const scanned = [...roots, ...agentAdded]; + const limit = opts.scanLimit ?? PROJECT_ROOT_SCAN_LIMIT; + const secrets = await projectRootSecretsAsync(scanned, own, limit); + scan = { key: rootScanKey(scanned, own), result: secrets }; + for (const over of secrets.over) { + if (agent.has(over)) { + const why = `more than ${limit} directories to look through for credential files (${[...SCAN_SKIPPED_DIRS].slice(0, 2).join(", ")} and the like aside), which stay hidden in a directory the agent added itself`; + notes.push(`added directory ${over}: ${why}: not writable — the owner can add it (\`/sandbox add\`), or a narrower one`); + dropped.push({ dir: over, why }); + continue; + } + notes.push( + over === r.defaultRoot + ? `no project root: the default, the repository's parent (${over}), holds more than ${limit} directories to look through for credential files (build outputs such as target/, build/, dist/ and vendor/ count; ${[...SCAN_SKIPPED_DIRS].slice(0, 3).join(", ")} and the like don't): not used, so other repositories aren't readable — set sandbox.projectRoots (or the session's own) to the repositories you need` + : `project root ${over}: more than ${limit} directories to look through for credential files (${[...SCAN_SKIPPED_DIRS].slice(0, 2).join(", ")} and the like aside): not used — name narrower roots in sandbox.projectRoots (or the session's own), the repositories you need`, + ); + } + roots = roots.filter((x) => !secrets.over.includes(x)); + for (const c of secrets.gitConfigs.slice(0, 5)) { + notes.push(`${c} holds a credential (a remote URL with a user or password in it, or an http extraheader): hidden, so git in ${repositoryOfConfig(c)} can't read its config from this session`); + } + if (secrets.gitConfigs.length > 5) notes.push(`and ${secrets.gitConfigs.length - 5} more repositories' git configs holding a credential: hidden`); + for (const d of dropped) { + const i = added.indexOf(d.dir); + if (i >= 0) added.splice(i, 1); + } + } + return { roots, added, notes, homeWorkspace, dropped, pinned, ...(scan ? { scan } : {}) }; +} + +/** The repositories an added dir holds besides its own (a folder of them): the end-of-turn git look covers each. */ +export function addedRepositories(addedDirs: readonly string[]): string[] { + return [...new Set(addedDirs.flatMap((d) => nestedControl(d).repos.filter((r) => resolve(r) !== resolve(d))))]; +} + /** What a setting resolves to for a backend now (no side effects beyond a cached probe). */ export async function resolveSandbox(opts: { registry: SandboxRegistry; @@ -222,8 +451,22 @@ export async function resolveSandbox(opts: { backendId: string; /** The session's working directory: `/` can't be a sandbox's workspace. */ workspace?: string; -}): Promise<{ info: SessionSandboxInfo; provider: SandboxProvider | null; plan: ConfinementPlan | null; network: NetworkPolicy }> { - const { choice, network, dockerSocket } = effectiveSetting(opts.config, opts.setting); + /** The daemon's whole config: what an added directory may not be in (its data dirs: policy.ts `hiddenPathsFor`). */ + codeoidConfig?: CodeoidConfig; + /** The sandbox's workspace as pinned for the session (default: the workspace's, policy.ts `sandboxWorkspaceOf`). */ + top?: string; +}): Promise<{ + info: SessionSandboxInfo; + provider: SandboxProvider | null; + plan: ConfinementPlan | null; + network: NetworkPolicy; + droppedDirs?: Array<{ dir: string; why: string }>; + /** Roots and added dirs pinned by this resolution (sandboxDirsOf). */ + pinned?: Record; + /** The look through the roots for credential files (sandboxDirsOf), for the specs built from this resolution. */ + rootScan?: RootScan; +}> { + const { choice, network, dockerSocket, projectRoots, addedDirs, agentDirs } = effectiveSetting(opts.config, opts.setting); const base = { choice, network: network.mode, @@ -252,7 +495,7 @@ export async function resolveSandbox(opts: { // A repository whose `.git` names a git dir codeoid can't verify: held read-only, said (policy.ts gitWorkspacePaths). const gitNote = opts.workspace !== undefined && c.plan.kind !== "no-shell" ? gitLayoutProblem(opts.workspace) : null; // A monorepo's package: what its commands may write is the whole repository (#448 round 9). - const top = opts.workspace !== undefined && c.plan.kind !== "no-shell" ? sandboxWorkspaceOf(opts.workspace) : null; + const top = opts.workspace !== undefined && c.plan.kind !== "no-shell" ? (opts.top ?? sandboxWorkspaceOf(opts.workspace)) : null; const topNote = top && opts.workspace !== undefined && top !== resolve(opts.workspace) ? `the workspace is the repository's top (${top}), which the working directory is inside: all of it is readable and writable, its git structure and every project settings dir on the way held read-only` : null; // The container runtime's socket, when the owner let it in: said, and said when there is none to reach. const home = process.env.HOME ?? homedir(); @@ -270,7 +513,12 @@ export async function resolveSandbox(opts: { // `sandbox.worktreeRoots` refused here, or missing and not beside the repository (never made): said (worktrees.ts). const layout = top !== null && opts.config.worktreeRoots.length > 0 ? gitLayoutOf(top) : null; const rootNotes = layout ? worktreeRootNotes(layout, { roots: opts.config.worktreeRoots, configDir: getConfigDir() }) : []; - const limits = [...c.plan.limits, ...(net.note ? [net.note] : []), ...(gitNote ? [gitNote] : []), ...(topNote ? [topNote] : []), ...(dockerNote ? [dockerNote] : []), ...rootNotes]; + // Working across repositories: the project roots it reads, the directories it writes besides its workspace. + const dirs: SandboxDirs = + top !== null + ? await sandboxDirsOf(top, projectRoots, addedDirs, opts.codeoidConfig, { ...(opts.setting?.pins ? { pins: opts.setting.pins.ids } : {}), agentDirs }) + : { roots: [], added: [], notes: [], homeWorkspace: false, dropped: [], pinned: {} }; + const limits = [...c.plan.limits, ...(net.note ? [net.note] : []), ...(gitNote ? [gitNote] : []), ...(topNote ? [topNote] : []), ...(dockerNote ? [dockerNote] : []), ...rootNotes, ...dirs.notes]; const applied: NetworkPolicy = net.mode === "allowlist" ? network : { mode: net.mode }; return { info: { @@ -284,11 +532,17 @@ export async function resolveSandbox(opts: { via: c.plan.via, ...(limits.length > 0 ? { limits } : {}), ...(dockerSocket ? { dockerSocket: true } : {}), + ...(dirs.roots.length > 0 ? { projectRoots: dirs.roots } : {}), + ...(dirs.added.length > 0 ? { addedDirs: dirs.added } : {}), + ...(dirs.homeWorkspace ? { homeWorkspace: true } : {}), enforced: !net.widened, }, provider, plan: c.plan, network: applied, + ...(dirs.dropped.length > 0 ? { droppedDirs: dirs.dropped } : {}), + ...(Object.keys(dirs.pinned).length > 0 ? { pinned: dirs.pinned } : {}), + ...(dirs.scan ? { rootScan: dirs.scan } : {}), }; } @@ -322,8 +576,21 @@ export class SessionSandbox { #folders = new Set(); /** The repository's worktree entries as git makes them (#448 round 11, worktree-entries.ts). */ readonly #entries = new WorktreeEntryGuard(); + /** The same for each added directory's repository that isn't the workspace's, by its git dir. */ + readonly #addedEntries = new Map(); + /** The project roots and added directories the last resolution applied (the runtime's: a change waits for the next). */ + #dirs: { roots: string[]; added: string[]; scan?: RootScan } = { roots: [], added: [] }; + #droppedDirs: Array<{ dir: string; why: string }> = []; + /** The setting's directories changed since the last resolution, nothing else (applied between turns: Session). */ + #dirsStale = false; + /** A pinned directory was found changed (pinProblem): the next resolution between turns looks at them again. */ + #pinsBroken = false; + /** Pins were taken since the setting was last saved (takePinsChanged). */ + #pinsChanged = false; /** The spec last built for the session: what its commands can write, for the entries' look. */ #lastSpec: SandboxSpec | null = null; + /** The spec last built under the running resolution: what the gate checks the pins of (pinProblem). */ + #gateSpec: SandboxSpec | null = null; #entriesRecheck: ReturnType | null = null; readonly #onWarning: ((text: string) => void) | undefined; @@ -404,12 +671,176 @@ export class SessionSandbox { * backend is torn down (Session restarts it, idle: a running turn is never * cut off by a change of setting) or the session closes. */ - async set(setting: SessionSandboxSetting | undefined): Promise { - this.#setting = setting && Object.keys(setting).length > 0 ? { ...setting } : undefined; - this.#stale = true; + async set(setting: SessionSandboxSetting | undefined, opts: { repinRoots?: boolean } = {}): Promise { + let next = setting && Object.keys(setting).length > 0 ? canonicalAddedDirs({ ...setting }, this.#setting) : undefined; + // The project roots set again (the owner's own): each pinned anew at the next resolution, as it is then. + if (opts.repinRoots && next?.pins) { + const keep = new Set([...(next.addedDirs ?? []), ...(next.pins.workspace ? [next.pins.workspace] : [])]); + next = { ...next, pins: { ...next.pins, ids: Object.fromEntries(Object.entries(next.pins.ids).filter(([k]) => keep.has(k))) } }; + } + if (sameSetting(this.#setting, next)) { + if (opts.repinRoots) this.#dirsStale = true; + } else if (onlyDirsChanged(this.#setting, next) && !this.#stale) { + // Only which directories are read or written: the runtime stands until Session applies it between turns. + this.#dirsStale = true; + } else this.#stale = true; + this.#setting = next; this.#generation += 1; } + /** Whether pins were taken (or let go) since the last call: the setting holding them is saved then (Session). */ + takePinsChanged(): boolean { + const changed = this.#pinsChanged; + this.#pinsChanged = false; + return changed; + } + + /** + * The sandbox's workspace: the one pinned for this working directory (the + * repository's top in a monorepo, found once), else as found now. + */ + #workspace(): string { + const pins = this.#setting?.pins; + if (pins?.workspace && pins.workdir === resolve(this.#workdir)) return pins.workspace; + return sandboxWorkspaceOf(this.#workdir); + } + + /** The sandbox's workspace (the repository's top in a monorepo), as pinned for the session. */ + get workspace(): string { + return this.#workspace(); + } + + /** + * Pin the workspace and the working directory when they aren't (a new + * session, or one from before pins: the working directory's identity from + * #448 round 14): as they are now, at their real paths. + */ + #pinWorkspace(): void { + const pins = this.#setting?.pins; + const workdir = resolve(this.#workdir); + if (pins?.workspace && pins.workdir === workdir) { + if (pins.ids[workdir] || pins.workspace === workdir) return; + // Pinned before the working directory was: its identity now. + const record = pinRecord(workdir); + if (!record) return; + this.#setting = { ...(this.#setting ?? {}), pins: { ...pins, ids: { ...pins.ids, [workdir]: record } } }; + this.#pinsChanged = true; + return; + } + const workspace = sandboxWorkspaceOf(workdir); + const record = pinRecord(workspace); + if (!record) return; + const ids = { ...(pins?.ids ?? {}) }; + if (pins?.workspace && pins.workspace !== workspace) delete ids[pins.workspace]; + if (pins?.workdir && pins.workdir !== workdir && pins.workdir !== workspace) delete ids[pins.workdir]; + ids[workspace] = record; + const own = workdir === workspace ? record : pinRecord(workdir); + if (own) ids[workdir] = own; + this.#setting = { ...(this.#setting ?? {}), pins: { workdir, workspace, ids } }; + this.#pinsChanged = true; + } + + /** + * The working directory as the daemon uses it on the host (pinned.ts + * {@link PinnedDir}: checkpoints, going back a turn, a fork's files, the + * end-of-turn git looks), pinned when it wasn't yet. Throws + * {@link PinnedPathError} (for `op`) when it isn't the directory pinned. + */ + workdirPin(op: string): PinnedDir { + this.#pinWorkspace(); + const workdir = resolve(this.#workdir); + return pinDir(workdir, "the working directory", this.#setting?.pins?.ids[workdir] ?? null, op); + } + + /** Keep what a resolution pinned (roots, added dirs seen for the first time). */ + #keepPins(pinned: Record | undefined): void { + if (!pinned || Object.keys(pinned).length === 0 || this.#closed) return; + const pins = this.#setting?.pins ?? { ids: {} }; + this.#setting = { ...(this.#setting ?? {}), pins: { ...pins, ids: { ...pins.ids, ...pinned } } }; + this.#pinsChanged = true; + } + + /** + * Why the running sandbox's directories aren't what was pinned (the + * workspace, the working directory, an added dir, a project root, a + * worktree folder: pinned.ts), in the sentence a refused call carries, or + * null. Asked at the gate before every tool call: what has changed is + * never run against, and is looked at again at the next resolution + * between turns (an added dir or root changed is left out then, said; a + * workspace changed keeps every call refused until it is put back). + */ + pinProblem(): string | null { + if (this.#closed || !this.#runtime) return null; + const ids = this.#setting?.pins?.ids ?? {}; + const workspace = this.#workspace(); + const workdir = resolve(this.#workdir); + const problem = + pinProblem(workspace, "the workspace", { ...(ids[workspace] ? { stored: ids[workspace] } : { mayBeMissing: true }) }) ?? + (workdir !== workspace ? pinProblem(workdir, "the working directory", ids[workdir] ? { stored: ids[workdir] } : { mayBeMissing: true }) : null) ?? + this.#dirs.added.map((d) => pinProblem(d, "added directory", ids[d] ? { stored: ids[d] } : {})).find((p) => p !== null) ?? + this.#dirs.roots.map((r) => pinProblem(r, "project root", ids[r] ? { stored: ids[r] } : {})).find((p) => p !== null) ?? + // Everything else the running sandbox binds by name (#448 round 14: a dir held in place, a path held + // read-only, the repository's git dirs, a worktree folder or location): changed by another session's + // command or on the host, it is said here, and the next resolution looks again. + pinsProblem(this.#gateSpec?.pins); + if (!problem) return null; + this.#pinsBroken = true; + return pinRefusal(problem); + } + + /** + * Whether the setting's directories (project roots, added dirs) changed + * since the last resolution: applied by the next {@link refresh}, which + * Session runs only between turns with no background work (a Claude query + * rebuilt mid-work would end it; a process backend is restarted). + */ + get dirsPending(): boolean { + return this.#dirsStale || this.#pinsBroken; + } + + /** The added directories the running sandbox has (SandboxSpec.addedDirs as resolved): what host-side file tools are held to. */ + appliedAddedDirs(): string[] { + return [...this.#dirs.added]; + } + + /** The project roots the running sandbox reads. */ + appliedProjectRoots(): string[] { + return [...this.#dirs.roots]; + } + + /** The added directories the last resolution left out, and why (project-roots.ts `stillRealDir`, `addedDirProblem`): Session says it, and drops them. */ + droppedDirs(): Array<{ dir: string; why: string }> { + return [...this.#droppedDirs]; + } + + /** + * Take `dirs` out of the setting's added directories, as the last + * resolution already left them out (droppedDirs): nothing to apply, so the + * runtime stands; a dir there again later isn't added again unasked. + */ + forget(dirs: readonly string[]): void { + if (!this.#setting?.addedDirs) return; + const gone = new Set(dirs.map((d) => resolve(d))); + const kept = this.#setting.addedDirs.filter((d) => !gone.has(resolve(normalizeSandboxDir(d) ?? d))); + if (kept.length === this.#setting.addedDirs.length) return; + const { addedDirs: _old, agentDirs, pins, ...rest } = this.#setting; + const agent = (agentDirs ?? []).filter((d) => kept.includes(d)); + const ids = Object.fromEntries(Object.entries(pins?.ids ?? {}).filter(([k]) => !gone.has(k))); + const base: SessionSandboxSetting = { ...rest, ...(agent.length > 0 ? { agentDirs: agent } : {}), ...(pins ? { pins: { ...pins, ids } } : {}) }; + this.#setting = kept.length > 0 ? { ...base, addedDirs: kept } : Object.keys(base).length > 0 ? base : undefined; + this.#droppedDirs = this.#droppedDirs.filter((d) => !gone.has(resolve(d.dir))); + } + + /** The session's temp dir, once made (its commands write it): null before. */ + get tempDirPath(): string | null { + return this.#tempDir; + } + + /** The repositories the session writes besides its workspace: its added dirs, and every repository inside one. */ + appliedRepositories(): string[] { + return [...new Set([...this.#dirs.added, ...addedRepositories(this.#dirs.added)])]; + } + /** * Resolve for `backendId` (a provider switch, a setting change, the first * turn). One at a time; one that started before a {@link set} resolves @@ -425,9 +856,22 @@ export class SessionSandbox { let r: Awaited>; for (;;) { const generation = this.#generation; - r = await resolveSandbox({ registry: this.#registry, config: this.config, setting: this.#setting, backendId, workspace: this.#workdir }); + this.#pinWorkspace(); + r = await resolveSandbox({ + registry: this.#registry, + config: this.config, + setting: this.#setting, + backendId, + workspace: this.#workdir, + top: this.#workspace(), + ...(this.#config ? { codeoidConfig: this.#config } : {}), + }); if (generation === this.#generation) break; } + this.#keepPins(r.pinned); + this.#pinsBroken = false; + // The spec the gate checks is the new resolution's, once built: the last one's pins are its handles' to check. + this.#gateSpec = null; if (this.#closed) { this.#runtime = null; return r.info; @@ -435,6 +879,10 @@ export class SessionSandbox { this.#info = r.info; this.#resolvedFor = backendId; this.#stale = false; + this.#dirsStale = false; + const dirs = { roots: [...(r.info.projectRoots ?? [])], added: [...(r.info.addedDirs ?? [])], ...(r.rootScan ? { scan: r.rootScan } : {}) }; + this.#dirs = dirs; + this.#droppedDirs = r.droppedDirs ?? []; if (r.info.state !== "on" || !r.provider || !r.plan) { this.#runtime = null; return r.info; @@ -448,7 +896,7 @@ export class SessionSandbox { const hideLocalServices = (r.info.networkRequested ?? r.info.network) !== "on"; const open = (backendState: readonly string[] = [], o: OpenOptions = {}) => { if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`); - return this.#spec(o.backendNetwork ? { mode: "on" } : r.network, backendState, o.readable ?? [], dockerSocket, hideLocalServices); + return this.#spec(o.backendNetwork ? { mode: "on" } : r.network, backendState, o.readable ?? [], dockerSocket, hideLocalServices, dirs); }; // Built (and the temp dir made) when a backend first needs it, then kept: a worktree added or removed in the // repository never rebuilds it (#448 round 11: that would rebuild a Claude query, its background tasks with it). @@ -475,6 +923,7 @@ export class SessionSandbox { claudeTemp: () => this.#ensureClaudeTemp(), gitShim: (caller) => { // Checked at each query: one removed meanwhile (a temp cleaner) is written again rather than leave the CLI the host's git. + // paths: a look at the shim in the session's launcher dir (the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)) if (!gitShim || !existsSync(join(gitShim, "git"))) gitShim = this.#writeGitShim(provider, caller); return gitShim; }, @@ -493,6 +942,7 @@ export class SessionSandbox { return this.#sessionId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32) || "session"; } + // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) #privateHome(key: string): string { if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`); if (!this.#homesDir) this.#homesDir = mkdtempSync(join(sandboxRoot(), `home-${this.#sessionSlug()}-`)); @@ -503,6 +953,7 @@ export class SessionSandbox { } /** The session's state root: under the transcripts dir when there is one, else (an embedder, tests) with its private homes. */ + // paths: the session's state dir: the daemon's own, in its transcripts dir (which every sandbox hides); a backend writes inside it, never replaces it (each dir it is given is a mount point there) #stateRoot(): string { const dir = this.#config?.transcriptDir; const safe = this.#sessionId.replace(/[^A-Za-z0-9_-]/g, "") || "session"; @@ -511,6 +962,7 @@ export class SessionSandbox { return join(this.#homesDir, ".state"); } + // paths: the session's state dir: the daemon's own, in its transcripts dir (which every sandbox hides); a backend writes inside it, never replaces it (each dir it is given is a mount point there) #stateDir(key: string): string { if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`); const dir = join(this.#stateRoot(), key.replace(/[^A-Za-z0-9_-]/g, "") || "backend"); @@ -519,10 +971,12 @@ export class SessionSandbox { } /** Remove what the session's backends kept across starts (with the session: its destroy). */ + // paths: the session's state dir: the daemon's own, in its transcripts dir (which every sandbox hides); a backend writes inside it, never replaces it (each dir it is given is a mount point there) removeState(): void { rmSync(this.#stateRoot(), { recursive: true, force: true }); } + // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) #ensureClaudeTemp(): ClaudeTempDirs { if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`); if (!this.#claudeTemp) { @@ -536,6 +990,7 @@ export class SessionSandbox { return this.#claudeTemp; } + // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) #ensureLauncherDir(): string { if (!this.#launcherDir) this.#launcherDir = mkdtempSync(`${launcherRoot()}${this.#sessionSlug()}-`); return this.#launcherDir; @@ -551,6 +1006,7 @@ export class SessionSandbox { if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`); const launcherDir = this.#ensureLauncherDir(); // Gone (a temp cleaner, #448 round 5): made again, so a CLI started now finds the shim, not the host's git. + // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); const dir = join(launcherDir, "git-bin"); let launcher: string | null = null; @@ -560,12 +1016,15 @@ export class SessionSandbox { const handle = this.#track(provider.openSync(spec)); // In the caller's own directory, or not at all: one the sandbox hides isn't swapped for another (bwrap's `$HOME`). launcher = this.#writeLauncher(handle, [GIT_BIN], [], "git", CWD_FROM_CALLER); - program = handle.wrap?.([GIT_BIN], { cwd: "", env: {} }).argv[0] ?? null; + // The provider's own program (past the pin guard's shell): when it is gone, git runs defused. + // paths: no cwd: the wrapped argv's program only + program = handle.wrap ? (unguardedArgv(handle.wrap([GIT_BIN], { cwd: "", env: {} }).argv)[0] ?? null) : null; } return writeGitShim(dir, { launcher, provider: program, caller, home: homedir() }); } /** Where the shim's git gets a fresh temp dir per run where the provider needs a real one (Seatbelt): its own, not the session's. */ + // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) #ensureGitTempDir(): string { if (!this.#gitTempDir) this.#gitTempDir = mkdtempSync(join(sandboxRoot(), `git-tmp-${this.#sessionSlug()}-`)); mkdirSync(this.#gitTempDir, { recursive: true, mode: 0o700 }); @@ -580,6 +1039,7 @@ export class SessionSandbox { const wrapped = handle.wrap(argv, { cwd, env: Object.fromEntries(envNames.map((n) => [n, ""])) }); const path = join(launcherDir, `launch-${name ? name.replace(/[^A-Za-z0-9_-]/g, "") : ++this.#launchers}`); // Written beside it and renamed over it: a caller running it meanwhile gets the old one or the new one, whole. + // paths: the session's launcher dir (the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees)): written beside the launcher and renamed over it const part = `${path}.${++this.#launchers}.part`; writeFileSync(part, launcherScript(wrapped.argv), { mode: 0o700 }); renameSync(part, path); @@ -598,13 +1058,24 @@ export class SessionSandbox { return this.#handles.size; } - #spec(network: NetworkPolicy, backendState: readonly string[], readable: readonly string[] = [], dockerSocket = false, hideLocalServices = network.mode !== "on"): SandboxSpec { - // A monorepo's package: the repository's top (#448 round 9); the session's cwd stays the workdir. - const workspace = sandboxWorkspaceOf(this.#workdir); - const folder = this.#worktreeFolderName(workspace); - // The repository's worktree entries looked at first: the spec holds every one the session didn't make. - if (folder) this.#checkEntries(); - const spec = buildSandboxSpec({ + #spec( + network: NetworkPolicy, + backendState: readonly string[], + readable: readonly string[] = [], + dockerSocket = false, + hideLocalServices = network.mode !== "on", + dirs: { roots: readonly string[]; added: readonly string[]; scan?: RootScan } = this.#dirs, + ): SandboxSpec { + // A monorepo's package: the repository's top (#448 round 9), as pinned; the session's cwd stays the workdir. + const workspace = this.#workspace(); + const folder = this.#worktreeFolderName(workspace, dirs.added); + // The repositories' worktree entries looked at first: the spec holds every one the session didn't make. + if (folder) { + this.#checkEntries(); + for (const d of dirs.added) this.#checkEntriesOf(d); + } + const agent = new Set(this.effective().agentDirs); + const build = () => buildSandboxSpec({ backendReadable: readable, sessionId: this.#sessionId, workspace, @@ -616,12 +1087,42 @@ export class SessionSandbox { ...(folder ? { worktreeFolder: folder, ownWorktreeEntries: this.#entries.ownEntries() } : {}), dockerSocket, hideLocalServices, + projectRoots: dirs.roots, + addedDirs: dirs.added.map((dir) => { + const guard = this.#guardFor(dir); + return { dir, ...(agent.has(dir) ? { agent: true } : {}), ...(folder && guard ? { ownWorktreeEntries: guard.ownEntries() } : {}) }; + }), + ...(this.#setting?.pins ? { pins: this.#setting.pins.ids } : {}), + ...(dirs.scan ? { rootScan: dirs.scan } : {}), }); + let spec: SandboxSpec; + try { + spec = build(); + } catch (err) { + // A pinned directory changed (pinned.ts): refused, and looked at again at the next resolution. + if (err instanceof SandboxPinError) this.#pinsBroken = true; + throw err; + } for (const f of spec.worktreeFolders ?? []) this.#folders.add(f); this.#lastSpec = spec; + this.#gateSpec = spec; return spec; } + /** The worktree-entry guard of an added dir's repository: the workspace's own when it is the same repository; null outside one. */ + #guardFor(dir: string): WorktreeEntryGuard | null { + const common = gitLayoutOf(dir)?.common; + if (!common) return null; + const mine = gitLayoutOf(this.#workspace())?.common; + if (mine && resolve(mine) === resolve(common)) return this.#entries; + let g = this.#addedEntries.get(resolve(common)); + if (!g) { + g = new WorktreeEntryGuard(); + this.#addedEntries.set(resolve(common), g); + } + return g; + } + /** * Look at the repository's worktree entries now (#448 round 11, * worktree-entries.ts): what isn't as git makes it is restored where that @@ -631,30 +1132,39 @@ export class SessionSandbox { */ checkWorktreeEntries(): void { if (this.#closed || !this.#runtime) return; - if (!this.#worktreeFolderName(sandboxWorkspaceOf(this.#workdir))) return; + if (!this.#worktreeFolderName(this.#workspace(), this.#dirs.added)) return; this.#checkEntries(); + for (const d of this.#dirs.added) this.#checkEntriesOf(d); } #checkEntries(): void { + this.#checkEntriesOf(this.#workspace(), this.#entries); + } + + /** {@link checkWorktreeEntries} for the repository `workspace` is in (the workspace's, or an added dir's), with its guard. */ + #checkEntriesOf(workspace: string, guard: WorktreeEntryGuard | null = this.#guardFor(workspace)): void { try { - const workspace = sandboxWorkspaceOf(this.#workdir); const layout = gitLayoutOf(workspace); const folder = this.#folderName; - if (!layout?.common || !folder) return; + if (!layout?.common || !folder || !guard) return; const opts = { roots: this.config.worktreeRoots, configDir: getConfigDir() }; const main = mainCheckoutOf(layout); const last = this.#lastSpec; // Before the first spec, the folders as they are (nothing is made for a look). const folders = last ? [...(last.worktreeFolders ?? [])] : worktreeFoldersOf(layout, folder, { ...opts, workspace, make: false }).folders; const dot = main ? join(main, ".worktrees") : null; - const { findings, pending } = this.#entries.check({ - common: layout.common, + // In the repository's git dir as it is now, pinned for the look (pinned.ts, #448 round 14): what the guard + // restores is written there, never where a link put in its place leads; changed meanwhile, it is refused. + const commonDir = layout.common; + const common = pinDir(realPathOnce(commonDir), "the repository's git dir", null, "the look at its worktree entries"); + const { findings, pending } = inPinnedDirSync(common, "the look at its worktree entries", () => guard.check({ + common: commonDir, main, ownFolders: folders, writable: [workspace, ...(this.#tempDir ? [this.#tempDir] : []), ...folders, ...(last?.writable ?? [])], readOnly: last?.readOnly ?? [], locations: [...worktreeLocationDirs(layout, opts).locations.map((l) => l.dir), ...(dot ? [dot] : [])], - }); + })); if (pending && !this.#entriesRecheck) { // An entry git may still be making: looked at once it is done. this.#entriesRecheck = setTimeout(() => { @@ -675,14 +1185,16 @@ export class SessionSandbox { * need them there). Never through a link (worktrees.ts `worktreeFoldersOf`). */ prepareFolders(): void { + // paths: a look (are the folders there): they are made through makeAt (worktrees.ts) if (this.#closed || this.#folders.size === 0 || [...this.#folders].every((f) => existsSync(f))) return; - try { - const workspace = sandboxWorkspaceOf(this.#workdir); - const layout = gitLayoutOf(workspace); - if (!layout || !this.#folderName) return; - worktreeFoldersOf(layout, this.#folderName, { roots: this.config.worktreeRoots, configDir: getConfigDir(), workspace }); - } catch { - /* a folder that can't be made: a worktree add there fails, as outside one */ + for (const workspace of [this.#workspace(), ...this.#dirs.added]) { + try { + const layout = gitLayoutOf(workspace); + if (!layout || !this.#folderName) continue; + worktreeFoldersOf(layout, this.#folderName, { roots: this.config.worktreeRoots, configDir: getConfigDir(), workspace }); + } catch { + /* a folder that can't be made: a worktree add there fails, as outside one */ + } } } @@ -695,14 +1207,8 @@ export class SessionSandbox { */ tidyFolders(): void { for (const f of this.#folders) { - for (const dir of [f, dirname(f)]) { - try { - if (!lstatSync(dir).isDirectory()) break; - rmdirSync(dir); - } catch { - break; - } - } + // Empty ones only, never through a link (pinned.ts, #448 round 14): the folder, then its location. + for (const dir of [f, dirname(f)]) if (!removeEmptyDir(dir)) break; } } @@ -712,11 +1218,12 @@ export class SessionSandbox { * or a daemon restart keeps it), else its name and short id. Null outside * a repository codeoid can verify. */ - #worktreeFolderName(workspace: string): string | null { + #worktreeFolderName(workspace: string, added: readonly string[] = []): string | null { if (this.#folderName) return this.#folderName; - const layout = gitLayoutOf(workspace); - if (!layout?.common) return null; - const { locations } = worktreeLocationDirs(layout, { roots: this.config.worktreeRoots, configDir: getConfigDir() }); + // The workspace's repository first, then each added dir's: one name in every repository the session writes. + const layouts = [workspace, ...added].map((d) => gitLayoutOf(d)).filter((l) => l?.common); + if (layouts.length === 0) return null; + const locations = layouts.flatMap((l) => (l ? worktreeLocationDirs(l, { roots: this.config.worktreeRoots, configDir: getConfigDir() }).locations : [])); this.#folderName = existingFolderName(locations, this.#sessionId) ?? sessionFolderName(this.#name(), this.#sessionId); return this.#folderName; } @@ -735,6 +1242,7 @@ export class SessionSandbox { } /** The session's private temp dir (made on first use, 0700). */ + // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) #ensureTempDir(): string { if (!this.#tempDir) this.#tempDir = mkdtempSync(join(sandboxRoot(), `tmp-${this.#sessionSlug()}-`)); return this.#tempDir; @@ -752,10 +1260,12 @@ export class SessionSandbox { this.#closed = true; this.#runtime = null; await this.#closeHandles(); + // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) if (this.#tempDir) { rmSync(this.#tempDir, { recursive: true, force: true }); this.#tempDir = null; } + // paths: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) for (const dir of [this.#launcherDir, this.#homesDir, this.#gitTempDir, this.#claudeTemp ? join(this.#claudeTemp.cli, "..") : null]) { if (dir) rmSync(dir, { recursive: true, force: true }); } @@ -858,6 +1368,7 @@ export async function openCheckSandbox(opts: { if (rootProblem) return { kind: "refused", reason: rootProblem }; const mode: NetworkMode = provider.capabilities.network.includes(network.mode) ? network.mode : "off"; const safe = opts.label.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32) || "check"; + // paths: a check's temp dir: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) const tempDir = mkdtempSync(join(sandboxRoot(), `check-${safe}-`)); let handle: SandboxHandle; try { @@ -873,11 +1384,13 @@ export async function openCheckSandbox(opts: { }); handle = await provider.open(spec); } catch (err) { + // paths: a check's temp dir: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) // Nothing opened: nothing of it is left behind. rmSync(tempDir, { recursive: true, force: true }); throw err; } if (!handle.wrap) { + // paths: a check's temp dir: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) await handle.close(); rmSync(tempDir, { recursive: true, force: true }); return { kind: "refused", reason: `${provider.displayName} can't run a local command` }; @@ -885,6 +1398,7 @@ export async function openCheckSandbox(opts: { return { kind: "on", wrap: handle.wrap.bind(handle), + // paths: a check's temp dir: the daemon's private dir (sandboxRoot: 0700, its own, which no sandbox sees) close: async () => { await handle.close(); rmSync(tempDir, { recursive: true, force: true }); diff --git a/src/daemon/sandbox/types.ts b/src/daemon/sandbox/types.ts index 01337e1c..ec71b082 100644 --- a/src/daemon/sandbox/types.ts +++ b/src/daemon/sandbox/types.ts @@ -19,6 +19,7 @@ */ import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import type { PathPin } from "./pinned.js"; /** How strong the boundary is: OS namespaces/profiles, a separate kernel (gVisor), a VM. */ export type IsolationTier = "os" | "kernel" | "hardware"; @@ -116,6 +117,17 @@ export interface SandboxSpec { * `~/.config/gh` out from under the rule that hides it. */ pinned?: readonly string[]; + /** + * Directories held in place as {@link pinned} ones are (a mount point, a + * denied rename), what is in them writable, but never read-only on + * Claude's own sandbox (whose denial takes a subtree; its CLI binds the + * working directory's ancestors itself): the working directory and each + * dir between it and the workspace, and each dir between a writable root + * and a path held read-only in it (#448 round 14). Moved aside, each + * would take what is held in it along and leave its place to a planted + * copy (a package's `.claude/settings.json`). + */ + heldInPlace?: readonly string[]; /** * The session's own folder in each of the repository's worktree locations * (in {@link writable} too): where a command checks a new worktree out @@ -125,6 +137,32 @@ export interface SandboxSpec { * writable roots). */ worktreeFolders?: readonly string[]; + /** + * The session's project roots (`sandbox.projectRoots`, policy.ts + * `resolveProjectRoots`): readable whole, read-only, with what the hidden + * list hides in them hidden again (in {@link reexposed} too). Other + * repositories, other sessions' checkouts. + */ + projectRoots?: readonly string[]; + /** + * Directories the session was given to write besides its workspace + * (`SessionSandboxSetting.addedDirs`): each treated as a workspace (in + * {@link writable}, its git structure and project settings in + * {@link readOnly}, its repository's worktree folder in + * {@link worktreeFolders}). What a backend's own sandbox must also let it + * write (Codex's writable roots). + */ + addedDirs?: readonly string[]; + /** + * Every path bound by name (the workspace, the working directory, added + * directories, project roots, worktree folders and locations, the + * session's writable and temp dirs, the paths held read-only, the dirs + * held in place), each with what it was when the spec was built + * (pinned.ts, #448 rounds 13 and 14): checked again at every launch, which + * is refused when one is reached through a link or is another directory + * now. A provider binds each by this path, never resolves it again. + */ + pins?: readonly PathPin[]; /** Container runtime sockets (and their dirs): never reachable, they are root-equivalent. */ containerSockets: readonly string[]; /** diff --git a/src/daemon/sandbox/worktree-entries.ts b/src/daemon/sandbox/worktree-entries.ts index 884f84be..d50507ff 100644 --- a/src/daemon/sandbox/worktree-entries.ts +++ b/src/daemon/sandbox/worktree-entries.ts @@ -100,6 +100,7 @@ interface EntryRecord { config: string; } +// paths: for comparing git's records with paths, in a look; no write function realOr(p: string): string { try { return realpathSync(p); @@ -112,6 +113,7 @@ function within(child: string, parent: string): boolean { return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`); } +// paths: a look, no write function lexists(p: string): boolean { try { lstatSync(p); @@ -122,6 +124,7 @@ function lexists(p: string): boolean { } /** A small regular file's text (never through a link): `missing`, `file` with its text, or `other` (a link, a dir, too big). */ +// paths: a look, no write function readSmall(p: string): { kind: "missing" } | { kind: "file"; text: string } | { kind: "other"; what: string } { let st: ReturnType; try { @@ -174,6 +177,7 @@ function samePath(a: string, b: string): boolean { } /** `info/` holding only `sparse-checkout`: what git makes for a sparse worktree. */ +// paths: a look, no write function onlySparse(info: string): boolean { try { if (!lstatSync(info).isDirectory()) return false; @@ -224,6 +228,7 @@ function copiesMain(text: string, common: string): boolean { return true; } +// paths: a look, no write function readlinkOr(p: string): string { try { return readlinkSync(p); @@ -235,6 +240,7 @@ function readlinkOr(p: string): string { let replaceSeq = 0; /** Replace `p` with a regular file holding `content` (atomically: never written through a link). Whether it was. */ +// paths: inside the repository's git dir, which the caller pins and checks before and after (SessionSandbox#checkEntriesOf); written beside the file (O_EXCL) and renamed over it, never through a link function replaceFile(p: string, content: string): boolean { const part = `${p}.codeoid-${process.pid}-${++replaceSeq}`; try { @@ -269,6 +275,7 @@ function writableBy(ctx: WorktreeEntryContext): (p: string) => boolean { function scanClaims(worktreesReal: string, seeds: readonly string[], roots: ReadonlyArray<{ dir: string; depth: number }>): Map { const out = new Map(); const seen = new Set(); + // paths: a look, no write const visit = (dotgit: string): boolean => { let st: ReturnType; try { @@ -295,6 +302,7 @@ function scanClaims(worktreesReal: string, seeds: readonly string[], roots: Read // A checkout: its own `.git` is what counts, nothing deeper. if (visit(join(dir, ".git")) && depth > 0) continue; if (depth === r.depth) continue; + // paths: a look, no write let names: string[] = []; try { names = readdirSync(dir).sort(); @@ -362,6 +370,7 @@ export class WorktreeEntryGuard { const firstLook = !this.#looked; this.#looked = true; const dir = join(common, "worktrees"); + // paths: a look at the repository's worktrees dir, no write let names: string[] = []; try { // A linked `worktrees/` makes the git dir read-only whole (policy.ts): nothing of it is the session's to make. @@ -405,6 +414,7 @@ export class WorktreeEntryGuard { const other = namedEntry(rec.dotgit, worktreesReal); if (!other || other === name || this.#records.has(other) || !names.includes(other)) continue; const otherNamed = entryGitdir(join(dir, other)); + // paths: a look, no write let otherKey = ""; try { const o = lstatSync(join(dir, other)); @@ -422,6 +432,7 @@ export class WorktreeEntryGuard { seen.add(name); const at = join(dir, name); const atReal = join(worktreesReal, name); + // paths: a look, no write let st: ReturnType; try { st = lstatSync(at); @@ -433,6 +444,7 @@ export class WorktreeEntryGuard { this.#records.delete(name); const target = readlinkOr(at); let removed = false; + // paths: a link in the repository's worktrees dir (the git dir pinned by the caller): unlink removes the link itself, never what it leads to try { unlinkSync(at); removed = true; @@ -599,6 +611,7 @@ export class WorktreeEntryGuard { } /** The repository's dir for what is taken out of its worktree entries: made 0700, never through a link; null when it can't be. */ +// paths: in the repository's git dir (pinned by the caller): made 0700, used only when a directory and not a link function quarantineDir(common: string): string | null { const dir = join(common, QUARANTINE_DIR); try { @@ -615,6 +628,7 @@ function quarantineDir(common: string): string | null { } /** Move `src` (a file, a dir, a link) into the quarantine dir; where it went, or null. */ +// paths: an entry moved aside within the repository's git dir (pinned by the caller): a rename never follows a link function quarantine(common: string, src: string, label: string): string | null { const dir = quarantineDir(common); if (!dir) return null; @@ -633,6 +647,7 @@ function quarantine(common: string, src: string, label: string): string | null { } /** Keep `content` in the quarantine dir; where, or null. */ +// paths: in the quarantine dir (O_EXCL), in the git dir pinned by the caller function keepAside(common: string, label: string, content: string): string | null { const dir = quarantineDir(common); if (!dir) return null; diff --git a/src/daemon/sandbox/worktrees.ts b/src/daemon/sandbox/worktrees.ts index 02c68896..46c27f35 100644 --- a/src/daemon/sandbox/worktrees.ts +++ b/src/daemon/sandbox/worktrees.ts @@ -30,15 +30,17 @@ * its own output, like the workspace. */ -import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, normalize, resolve, sep } from "node:path"; +import { makeAt } from "./pinned.js"; import type { GitLayout } from "./policy.js"; function within(child: string, parent: string): boolean { return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`); } +// paths: a repository's layout, found once at a spec build (a link from the main checkout's parent down is refused: `linked`); what it gives is pinned by the spec, and made only through makeAt function realOr(p: string): string { try { return realpathSync(p); @@ -47,6 +49,7 @@ function realOr(p: string): string { } } +// paths: a look (is it a link: then refused), no write function isLink(p: string): boolean { try { return lstatSync(p).isSymbolicLink(); @@ -56,6 +59,7 @@ function isLink(p: string): boolean { } /** A dir of the daemon user's own, not a link. */ +// paths: a look, no write function ownDir(p: string): boolean { try { const st = lstatSync(p); @@ -289,7 +293,8 @@ export function worktreeFoldersOf( const mk = (p: string): void => { if (!make) return; try { - mkdirSync(p, { mode: 0o755 }); + // Never through a link (pinned.ts: its parent pinned for the make, checked again after; #448 round 14). + makeAt(p, "dir", "making a worktree folder"); } catch { /* there already, or can't be made: then only an existing one is used */ } @@ -343,6 +348,7 @@ export function existingFolderName(locations: readonly WorktreeLocation[], sessi const suffix = `-${sessionShortId(sessionId)}`; for (const loc of locations) { let names: string[] = []; + // paths: a look (which folder names a location holds), no write try { names = readdirSync(loc.dir).filter((n) => n.endsWith(suffix) && n.length > suffix.length); } catch { @@ -375,6 +381,7 @@ export interface WorktreeAdmin { export function worktreeAdminsOf(common: string, limit = 500): WorktreeAdmin[] { const dir = join(common, "worktrees"); let names: string[] = []; + // paths: a look at the repository's worktree admin dirs, no write try { names = readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory()) diff --git a/src/daemon/server.ts b/src/daemon/server.ts index a06ff9ff..fe05f58d 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -8,6 +8,7 @@ * - ZeroID JWT verification on every connection */ +import { SANDBOX_MCP_PATH } from "./sandbox/request-directory.js"; import type { SandboxRegistry } from "./sandbox/registry.js"; import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; @@ -628,6 +629,8 @@ export class DaemonServer { this.#manager.setBlackboardUrl( `http://127.0.0.1:${this.#config.port}${BLACKBOARD_MCP_PATH}`, ); + // The sandbox's `request_directory` (sandbox/request-directory.ts): loopback too. + this.#manager.setSandboxMcpUrl(`http://127.0.0.1:${this.#config.port}${SANDBOX_MCP_PATH}`); this.#manager.setMcp(this.#mcpRegistry, this.#mcpHub, mcpOAuth); const mcpCount = this.#mcpRegistry.list().filter((s) => !s.builtin).length; if (mcpCount > 0) console.log(`[codeoid] mcp: ${mcpCount} external server(s) registered`); @@ -686,6 +689,13 @@ export class DaemonServer { return self.#manager.blackboardMcp.handle(req); } + // The sandbox's `request_directory` (sandbox/request-directory.ts): a session's token is its scope, and only + // the agents on this host (loopback peers, whatever the daemon binds) reach it. + if (url.pathname === SANDBOX_MCP_PATH) { + if (!isLoopbackAddress(server.requestIP(req)?.address)) return new Response("Forbidden", { status: 403 }); + return self.#manager.sandboxMcp.handle(req); + } + // OAuth MCP relay (#396): Codex, the Gemini CLI and Qwen mount OAuth // servers here with a per-session proxy token. Loopback peers only, // whatever the daemon binds: the agents run on this host. diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index b28660d3..7dedb143 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -8,6 +8,8 @@ * - Graceful drain on shutdown */ +import { addedDirProblem, type OtherCheckout, PROJECT_ROOT_PARENT, projectRootProblem } from "./sandbox/project-roots.js"; +import { type RequestDirectoryInput, type SandboxMcpMount, SandboxMcpHttp } from "./sandbox/request-directory.js"; import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; @@ -137,13 +139,15 @@ import type { DispatchEventRow, DispatchTaskRow } from "./store.js"; import { type MemoryEngine, type MemoryMcpMount, workspaceIdFromPath } from "./memory/index.js"; import type { McpRegistry } from "./mcp/registry.js"; import type { SandboxRegistry } from "./sandbox/registry.js"; -import { effectiveSetting, openCheckSandbox, resolveSandbox, sandboxRefusalOf, sandboxSettingProblem, widens } from "./sandbox/session-sandbox.js"; -import { sandboxConfigOf } from "./sandbox/policy.js"; +import { canonicalAddedDirs, effectiveSetting, openCheckSandbox, resolveSandbox, sandboxRefusalOf, sandboxSettingProblem, widens } from "./sandbox/session-sandbox.js"; +import { checkPinned, type PinnedDir, PinnedPathError } from "./sandbox/pinned.js"; +import { sandboxRootIfMade } from "./sandbox/root.js"; +import { hiddenPathsFor, sandboxConfigOf } from "./sandbox/policy.js"; import { redactCommand } from "./skill-command.js"; import type { McpHub } from "./mcp/hub.js"; import { mcpOAuthBindingCookieName, type McpOAuth, type McpOAuthProof, type McpTenant } from "./mcp/oauth.js"; import { isOAuthServer, type McpServerSpec, OAUTH_UNSUPPORTED_BACKENDS } from "./mcp/types.js"; -import { atomicWrite, type CodeoidConfig, DEFAULT_PROVIDER_ENV, loadConfig, mutateConfigFile, validateConfigObject } from "../config.js"; +import { atomicWrite, type CodeoidConfig, DEFAULT_PROVIDER_ENV, getConfigDir, loadConfig, mutateConfigFile, validateConfigObject } from "../config.js"; import type { CompressionRegistry } from "./compress/index.js"; import { endpointNameOf, endpointProviderId, ORCHESTRATOR_ROLE, type ProviderInfo } from "../protocol/types.js"; import type { @@ -427,6 +431,10 @@ export class SessionManager { /** Goal-blackboard MCP endpoint + the loopback URL children mount it from. * Always constructed: with no minted tokens every request fails closed. */ readonly #blackboardMcp = new BlackboardMcpHttp(); + /** The sandbox's `request_directory` for the URL-mounting backends (sandbox/request-directory.ts): one token per session. */ + readonly #sandboxMcp = new SandboxMcpHttp((sessionId, input) => this.#requestDirectoryFor(sessionId, input)); + #sandboxMcpUrl?: string; + readonly #sandboxMcpTokens = new Map(); #blackboardUrl?: string; /** Per-goal artifact store, lazily built on the shared DB connection. */ #blackboard?: Blackboard; @@ -993,6 +1001,8 @@ export class SessionManager { transcriptStore: this.#transcriptStore, providers: this.#providers, sandboxRegistry: this.#sandboxRegistry, + sandboxMcp: this.#sandboxMcpFor, + sandboxCheckouts: this.#sandboxCheckoutsFor, claudeAccounts: this.#claudeAccounts, accounts: this.#accounts, claudeAccount: meta.claudeAccount ?? null, @@ -1373,6 +1383,8 @@ mcpHub: this.#mcpHub, return this.#loopWorkerGuard(msg, "change the mode of") ?? this.#setMode(msg, auth); case "session.set_sandbox": return this.#loopWorkerGuard(msg, "change the sandbox of") ?? this.#setSandbox(msg, auth); + case "session.sandbox_dir": + return this.#loopWorkerGuard(msg, "change the sandbox of") ?? this.#sandboxDir(msg, auth); case "sandbox.providers": return this.#sandboxProviders(msg, auth); case "session.pin": @@ -1771,6 +1783,8 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, sandboxRegistry: this.#sandboxRegistry, + sandboxMcp: this.#sandboxMcpFor, + sandboxCheckouts: this.#sandboxCheckoutsFor, claudeAccounts: this.#claudeAccounts, hooks: this.#hooks, ...(this.#identityManager @@ -1846,7 +1860,7 @@ mcpHub: this.#mcpHub, }; } try { - const snapshot = await readClaudeConfig(session.workdir); + const snapshot = await readClaudeConfig(session.pinnedWorkdir("reading the project's MCP config").path); // Read for the drawer only — what a session applies changes on its first // turn or a refresh. Freshly, before the rows: a row and its fingerprint // are of the files as they are now. @@ -3605,7 +3619,8 @@ mcpHub: this.#mcpHub, } if (session.filesPending) return this.#stillPreparing(msg.id); try { - return await handleFsRead(msg, session.workdir); + // In the working directory as pinned (#448 round 14): one swapped for a link is never read through. + return await handleFsRead(msg, session.pinnedWorkdir("reading a file for a client").path); } catch (err) { return this.#fsErr(msg.id, err); } @@ -3887,6 +3902,54 @@ mcpHub: this.#mcpHub, this.#blackboardUrl = url; } + /** The URL backends mount the sandbox's `request_directory` from (loopback, as the blackboard's). */ + setSandboxMcpUrl(url: string): void { + this.#sandboxMcpUrl = url; + } + + get sandboxMcp(): SandboxMcpHttp { + return this.#sandboxMcp; + } + + /** A session's `/mcp/sandbox` mount: its own token, minted once. None without a sandbox or the endpoint. */ + readonly #sandboxMcpFor = (sessionId: string): SandboxMcpMount | undefined => { + if (!this.#sandboxMcpUrl || !this.#sandboxRegistry) return undefined; + let token = this.#sandboxMcpTokens.get(sessionId); + if (!token) { + token = this.#sandboxMcp.mint(sessionId); + this.#sandboxMcpTokens.set(sessionId, token); + } + return { url: this.#sandboxMcpUrl, token }; + }; + + /** + * The checkouts every other session's commands write (its workspace, its + * worktree folders, its added directories): an autonomous + * `request_directory` never touches one, the owner's card says it + * (#448 round 13, project-roots.ts `checkoutOverlap`). + */ + readonly #sandboxCheckoutsFor = (sessionId: string): OtherCheckout[] => { + const out: OtherCheckout[] = []; + for (const s of this.#sessions.values()) { + if (s.id === sessionId) continue; + out.push({ dir: s.sandboxWorkspace, what: `session "${s.name}"'s workspace` }); + for (const f of s.sandboxWorktreeFolders()) out.push({ dir: f, what: `session "${s.name}"'s worktree folder` }); + for (const d of s.sandboxAddedDirs()) out.push({ dir: d, what: `a directory session "${s.name}" writes` }); + } + return out; + }; + + /** A `request_directory` call over `/mcp/sandbox`: the session decides it. A gone session's token is let go. */ + async #requestDirectoryFor(sessionId: string, input: RequestDirectoryInput): Promise { + const session = this.#sessions.get(sessionId); + if (!session) { + this.#sandboxMcp.revokeSession(sessionId); + this.#sandboxMcpTokens.delete(sessionId); + return "Refused: this session is gone."; + } + return session.requestDirectory(input); + } + /** Inject the cross-backend MCP registry + daemon-owned client pool. Sessions * hand both to every provider so the registry's servers mount on all backends. */ setMcp(registry: McpRegistry, hub: McpHub, oauth?: McpOAuth): void { @@ -4334,11 +4397,13 @@ mcpHub: this.#mcpHub, if (!this.#sandboxRegistry) { return { type: "response.error", requestId: msg.id, error: "This daemon has no OS sandbox", code: "invalid_request" }; } - const problem = sandboxSettingProblem(msg.sandbox, this.#sandboxRegistry) ?? (await this.#sandboxUnavailable(msg.sandbox)); + const problem = sandboxSettingProblem(msg.sandbox, this.#sandboxRegistry) ?? this.#sandboxDirsProblem(msg.sandbox) ?? (await this.#sandboxUnavailable(msg.sandbox)); if (problem) return { type: "response.error", requestId: msg.id, error: problem, code: "invalid_request" }; const refused = this.#widerSandboxRefusal(msg.sandbox, auth, "create a session"); if (refused) return { type: "response.error", requestId: msg.id, error: refused, code: "forbidden" }; } + // Each added dir kept by where it really is now: what is bound, never where a link there leads later. + const sandboxSetting = msg.sandbox ? canonicalAddedDirs(msg.sandbox) : undefined; let goalSessionId = ""; const orchestratorFleet = collaboration @@ -4363,7 +4428,9 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, sandboxRegistry: this.#sandboxRegistry, - ...(msg.sandbox && Object.keys(msg.sandbox).length > 0 ? { sandbox: msg.sandbox } : {}), + sandboxMcp: this.#sandboxMcpFor, + sandboxCheckouts: this.#sandboxCheckoutsFor, + ...(sandboxSetting && Object.keys(sandboxSetting).length > 0 ? { sandbox: sandboxSetting } : {}), claudeAccounts: this.#claudeAccounts, // The Claude choice also when the session lands elsewhere: it applies if it is switched to Claude. claudeAccount: msg.claudeAccount || null, @@ -4758,6 +4825,8 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, sandboxRegistry: this.#sandboxRegistry, + sandboxMcp: this.#sandboxMcpFor, + sandboxCheckouts: this.#sandboxCheckoutsFor, claudeAccounts: this.#claudeAccounts, // The collaboration runs on the accounts its lead was created on. claudeAccount: parent.claudeAccount, @@ -5072,21 +5141,30 @@ mcpHub: this.#mcpHub, if (branch) worktree = { path: bound, branch, createdByCodeoid: false }; } else if (msg.isolate !== false || msg.baseBranch) { // baseBranch implies isolation (a base needs its own worktree). - if (await isGitRepo(parent.workdir)) { + // The parent's working directory as pinned (#448 round 14): its files are what the fork carries, never what a + // link put in its place leads to; checked again once the worktree is made. + let parentPin: PinnedDir; + try { + parentPin = parent.pinnedWorkdir("forking it"); + } catch (err) { + if (!(err instanceof PinnedPathError)) throw err; + return { type: "response.error", requestId: msg.id, error: err.message, code: "invalid_request" }; + } + if (await isGitRepo(parentPin.path)) { try { // From an earlier turn: start the worktree at the commit the repo // was on then (recorded with the snapshot), so git history matches // the files rolled in below; make sure the parent's subdirectory // exists so the snapshot lands where it belongs. let rollHead = point?.files && "checkpointId" in point.files ? point.files.head : undefined; - recipe = await loadSetupRecipe(parent.workdir); + recipe = await loadSetupRecipe(parentPin.path); // Whether the command can run at all is known only once the base // commit is checked out (commandGate): create clean whenever a // trusted recipe has one, then decide. const mayRun = !!recipe && !("error" in recipe) && !!recipe.recipe.command && commandRefusal(policy, recipe.root, recipe.recipeHash, auth.sub) === null; const make = (baseCommit?: string) => createForkWorktree({ - workdir: parent.workdir, + workdir: parentPin.path, label: msg.name ?? parentInfo.name, shortId: worktreeShortId, ...(msg.baseBranch ? { baseBranch: msg.baseBranch } : {}), @@ -5103,6 +5181,7 @@ mcpHub: this.#mcpHub, headGone = true; return make(); }); + checkPinned(parentPin, "forking it"); // Fork runs in the parent's equivalent subdir of the worktree; the // worktree ROOT (wt.path) is what we remove on destroy. // Same checks as any session's workdir (safe-root, protected dirs), @@ -5177,6 +5256,8 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, sandboxRegistry: this.#sandboxRegistry, + sandboxMcp: this.#sandboxMcpFor, + sandboxCheckouts: this.#sandboxCheckoutsFor, claudeAccounts: this.#claudeAccounts, // A fork continues the same conversation — on the same accounts, // thinking as hard (#417). @@ -5433,6 +5514,8 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, sandboxRegistry: this.#sandboxRegistry, + sandboxMcp: this.#sandboxMcpFor, + sandboxCheckouts: this.#sandboxCheckoutsFor, claudeAccounts: this.#claudeAccounts, hooks: this.#hooks, identityManager: this.#identityManager, @@ -5721,6 +5804,8 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, sandboxRegistry: this.#sandboxRegistry, + sandboxMcp: this.#sandboxMcpFor, + sandboxCheckouts: this.#sandboxCheckoutsFor, claudeAccounts: this.#claudeAccounts, hooks: this.#hooks, identityManager: this.#identityManager, @@ -6433,12 +6518,14 @@ mcpHub: this.#mcpHub, // The worker runs unattended: refused here when its sandbox can't hold (#448). if (msg.sandbox) { if (!this.#sandboxRegistry) return err("This daemon has no OS sandbox", "invalid_request"); - const problem = sandboxSettingProblem(msg.sandbox, this.#sandboxRegistry); + const problem = sandboxSettingProblem(msg.sandbox, this.#sandboxRegistry) ?? this.#sandboxDirsProblem(msg.sandbox); if (problem) return err(problem, "invalid_request"); const wider = this.#widerSandboxRefusal(msg.sandbox, auth, "start a loop"); if (wider) return err(wider, "forbidden"); } - const sandboxRefused = await this.#unattendedSandboxRefusal(provider, msg.sandbox); + // Each added dir kept by where it really is now. + const loopSandbox = msg.sandbox ? canonicalAddedDirs(msg.sandbox) : undefined; + const sandboxRefused = await this.#unattendedSandboxRefusal(provider, loopSandbox); if (sandboxRefused) return err(`The loop can't start: ${sandboxRefused}`, "invalid_request"); const run = svc.startRun( { @@ -6447,7 +6534,7 @@ mcpHub: this.#mcpHub, checks: msg.checks, provider, ...(msg.model ? { model: msg.model } : {}), - ...(msg.sandbox ? { sandbox: msg.sandbox } : {}), + ...(loopSandbox ? { sandbox: loopSandbox } : {}), ...(msg.toolBudget !== undefined ? { toolBudget: msg.toolBudget } : {}), ...(msg.limits ? { limits: msg.limits } : {}), }, @@ -6732,6 +6819,8 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, sandboxRegistry: this.#sandboxRegistry, + sandboxMcp: this.#sandboxMcpFor, + sandboxCheckouts: this.#sandboxCheckoutsFor, claudeAccounts: this.#claudeAccounts, hooks: this.#hooks, identityManager: this.#identityManager, @@ -6832,6 +6921,8 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, sandboxRegistry: this.#sandboxRegistry, + sandboxMcp: this.#sandboxMcpFor, + sandboxCheckouts: this.#sandboxCheckoutsFor, claudeAccounts: this.#claudeAccounts, hooks: this.#hooks, identityManager: this.#identityManager, @@ -6960,7 +7051,13 @@ mcpHub: this.#mcpHub, providerIds: () => this.#providers.ids(), ...(this.#sandboxRegistry ? { - sandboxProblem: (s: SessionSandboxSetting) => sandboxSettingProblem(s, this.#sandboxRegistry as SandboxRegistry), + // A routine's directories checked when it is defined, as a session's are when it is created (#448 round 13). + sandboxProblem: (s: SessionSandboxSetting) => sandboxSettingProblem(s, this.#sandboxRegistry as SandboxRegistry) ?? this.#sandboxDirsProblem(s), + sandboxCanonical: (s: SessionSandboxSetting) => { + // By real path; never a pin (each run's session pins what it binds when it starts). + const { pins: _p, agentDirs: _a, ...rest } = canonicalAddedDirs(s); + return rest; + }, } : {}), normalizeWorkdir: (p) => normalizeWorkdir(p), @@ -7118,6 +7215,8 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, sandboxRegistry: this.#sandboxRegistry, + sandboxMcp: this.#sandboxMcpFor, + sandboxCheckouts: this.#sandboxCheckoutsFor, claudeAccounts: this.#claudeAccounts, hooks: this.#hooks, identityManager: this.#identityManager, @@ -7993,7 +8092,14 @@ mcpHub: this.#mcpHub, ); if (badModel) return refuse(`Model "${badModel.model}" isn't available on ${badModel.providerId}`); // Agents sharing one folder would edit each other's files. - if (!(await isGitRepo(parent.workdir))) { + let parentDir: string; + try { + parentDir = parent.pinnedWorkdir("comparing backends from it").path; + } catch (err) { + if (!(err instanceof PinnedPathError)) throw err; + return refuse(err.message); + } + if (!(await isGitRepo(parentDir))) { return refuse("Compare needs a git repository: each branch runs in its own worktree, so they can't edit each other's files."); } const label = (t: { providerId: string; model?: string }) => `${t.providerId}${t.model ? `:${t.model}` : ""}`; @@ -9040,6 +9146,55 @@ mcpHub: this.#mcpHub, return { type: "response.ok", requestId: msg.id, data: { ...(result.pending ? { pending: true } : {}), sandbox: session.sandboxInfo } }; } + /** + * Why a setting's directories can't be used (docs/sandbox.md, "Working + * across repositories"), or null: a project root refused + * (project-roots.ts `projectRootProblem`: the home, `/`, the host's temp + * dir, …), an added dir that can't be one (`addedDirProblem`: the home, + * `/`, a hidden dir, a git or project settings dir or one in it, one the + * sandbox hides, one holding a PATH entry, one that isn't there, one too + * big to look through for the repositories in it). + */ + #sandboxDirsProblem(setting: SessionSandboxSetting): string | null { + const configDir = getConfigDir(); + for (const r of setting.projectRoots ?? []) { + if (r === PROJECT_ROOT_PARENT) continue; + const problem = projectRootProblem(r, { configDir }); + if (problem) return `not a project root: ${problem}`; + } + const hidden = (setting.addedDirs ?? []).length > 0 ? [...hiddenPathsFor(this.#config), ...daemonSandboxRoot()] : []; + for (const d of setting.addedDirs ?? []) { + const problem = addedDirProblem(d, { configDir, hidden }); + if (problem) return `can't add ${d}: ${problem}`; + } + return null; + } + + /** + * `session.sandbox_dir`: add a directory to what a session's commands + * write, or remove one. Owner only, with session:approve; adding is a + * widening, so never a delegated token's, and asked of the owner on the + * session (owner-only confirm) before it lands. + */ + async #sandboxDir(msg: Extract, auth: AuthContext): Promise { + if (!hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE)) { + return { type: "response.error", requestId: msg.id, error: "Missing scope: session:approve", code: "forbidden" }; + } + const session = this.#getOwnedSession(msg.sessionId, auth); + if (!session) return { type: "response.error", requestId: msg.id, error: "Session not found", code: "not_found" }; + if (session.createdBy !== auth.sub) { + return { type: "response.error", requestId: msg.id, error: "Only the session's owner can change the directories its sandbox writes", code: "forbidden" }; + } + if (msg.action === "add" && auth.delegationDepth > 0) { + this.#store.audit(auth.sub, "session.sandbox_dir_refused", session.id, `${msg.path}: delegated token`); + return { type: "response.error", requestId: msg.id, error: "A delegated token cannot add a directory to a session's sandbox", code: "forbidden" }; + } + if (!this.#sandboxRegistry) return { type: "response.error", requestId: msg.id, error: "This daemon has no OS sandbox", code: "invalid_request" }; + const result = await session.changeSandboxDir(msg.action, msg.path, auth); + if (!result.ok) return { type: "response.error", requestId: msg.id, error: result.error, code: "invalid_request" }; + return { type: "response.ok", requestId: msg.id, data: { ...(result.pending ? { pending: true } : {}), message: result.message, sandbox: session.sandboxInfo } }; + } + /** `sandbox.providers` (#448): what the daemon has and whether each runs here. */ async #sandboxProviders(msg: Extract, auth: AuthContext): Promise { if (!hasScope(auth.scopes as string[], SCOPES.SESSION_CREATE)) { @@ -9066,7 +9221,7 @@ mcpHub: this.#mcpHub, requestId: msg.id, data: { enabled: registry !== undefined, - default: { provider: config.provider, network: config.network, allowedHosts: config.allowedHosts, dockerSocket: config.docker.socket }, + default: { provider: config.provider, network: config.network, allowedHosts: config.allowedHosts, dockerSocket: config.docker.socket, projectRoots: config.projectRoots ?? ["@parent"] }, auto: registry?.autoId() ?? null, providers, }, @@ -9165,7 +9320,13 @@ mcpHub: this.#mcpHub, } const session = this.#getOwnedSession(msg.sessionId, auth); if (!session) return fail("Session not found", "not_found"); - const read = await readWorkdirServers(session.workdir); + let pinned: string; + try { + pinned = session.pinnedWorkdir("reading the project's MCP config").path; + } catch (err) { + return fail(err instanceof Error ? err.message : String(err)); + } + const read = await readWorkdirServers(pinned); if (!read) return fail("Claude's config can't be read just now (it may be mid-write) — try again"); if (read.repoUnreadable) return fail("The project's .mcp.json can't be read just now (mid-edit, not JSON, or too big) — try again once it can"); const repo = read.servers.filter((w) => w.origin === "claude-repo" && w.name === msg.server && (msg.path === undefined || w.path === msg.path)); @@ -9234,7 +9395,13 @@ mcpHub: this.#mcpHub, } const session = this.#getOwnedSession(msg.sessionId, auth); if (!session) return fail("Session not found", "not_found"); - const read = await readWorkdirServers(session.workdir); + let pinned: string; + try { + pinned = session.pinnedWorkdir("reading the project's MCP config").path; + } catch (err) { + return fail(err instanceof Error ? err.message : String(err)); + } + const read = await readWorkdirServers(pinned); if (!read) return fail("Claude's config can't be read just now (it may be mid-write) — try again"); if (read.repoUnreadable) return fail("The project's .mcp.json can't be read just now (mid-edit, not JSON, or too big) — try again once it can"); const repo = read.servers.filter( @@ -9480,3 +9647,9 @@ async function resolveImportPath( function tenantOf(auth: AuthContext): McpTenant { return { accountId: auth.accountId, projectId: auth.projectId }; } + +/** codeoid's private dir (sandbox/root.ts), when made: no added dir is in it. */ +function daemonSandboxRoot(): string[] { + const root = sandboxRootIfMade(); + return root ? [root] : []; +} diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 08de8b1c..17e1cf98 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -99,6 +99,7 @@ import { numstatTrees, restoreTree, } from "./checkpoints.js"; +import { type PinnedDir, PinnedPathError, pinDir, pinRecord } from "./sandbox/pinned.js"; import { applyRewinds, REWIND_EVENT, type TurnIndexEntry } from "./transcript.js"; import type { EffortChangeResult, EffortLevel, RewindFiles, RewindIrreversible, TurnSummary } from "../protocol/types.js"; import { LIMITS } from "../protocol/types.js"; @@ -168,7 +169,7 @@ import { type MemoryEngine, type MemoryMcpMount, } from "./memory/index.js"; -import { isElicitationTool, isSafeTool, isUnresolvedPathTool, roleDeniesTool } from "./providers/tool-safety.js"; +import { isElicitationTool, isRequestDirectoryTool, isSafeTool, isUnresolvedPathTool, roleDeniesTool } from "./providers/tool-safety.js"; import type { PackActivation } from "./pipeline/index.js"; import { McpRegistry } from "./mcp/registry.js"; import { mcpSpecKey, SessionMcpView, type WorkdirReach } from "./mcp/session-view.js"; @@ -183,6 +184,23 @@ const RESEED_AFTER_TEARDOWN = new Set(["gemini-cli"]); * releases it when idle, so the next turn starts one on the new credential. * The rest re-read their account every turn (accounts.ts). */ +/** What an approval card adds of the sandbox's directories (docs/sandbox.md, "Working across repositories"): "; reads R (read-only); also writes D". */ +function cardDirsNote(info: SessionSandboxInfo): string { + const parts = [ + ...(info.homeWorkspace ? ["workspace is your home: only credentials are protected"] : []), + ...(info.projectRoots?.length ? [`reads ${info.projectRoots.join(", ")} (read-only)`] : []), + ...(info.addedDirs?.length ? [`also writes ${info.addedDirs.join(", ")}`] : []), + ]; + return parts.length > 0 ? `; ${parts.join("; ")}` : ""; +} + +/** The directories a resolved sandbox reads and writes besides its workspace, in words. */ +function sandboxDirsLine(info: SessionSandboxInfo | null): string { + const added = info?.addedDirs ?? []; + const roots = info?.projectRoots ?? []; + return `writes ${added.length > 0 ? `the workspace and ${added.join(", ")}` : "the workspace"}; reads ${roots.length > 0 ? roots.join(", ") : "no project root"}`; +} + const PROCESS_BACKENDS: ReadonlySet = new Set(["claude", "codex", "gemini-cli", "qwen", "pi"]); /** Statuses the idle countdown (#451) runs in: nothing in flight until the next message. */ @@ -213,7 +231,10 @@ import { CLAUDE_ALL_SOCKETS_NOTE, describeSandbox, resolveSandbox, SandboxRequir import { type WorktreeEntryFinding, worktreeEntryLine, worktreeEntryWarning } from "./sandbox/worktree-entries.js"; import { type GitControlSnapshot, gitControlChanges, gitControlSnapshot, gitControlWarning, refTips, settingsCommits, settingsCommitWarning } from "./sandbox/git-watch.js"; import { worktreeFolderNote } from "./sandbox/worktrees.js"; -import { protectedWriteTargets, userHiddenPaths } from "./sandbox/policy.js"; +import { hiddenPathsFor, protectedWriteTargets, userHiddenPaths } from "./sandbox/policy.js"; +import { addedDirProblem, addedDirReal, autonomousGrantProblem, checkoutOverlap, inAnyDir, nestedControl, normalizeSandboxDir, type OtherCheckout } from "./sandbox/project-roots.js"; +import { sandboxRootIfMade } from "./sandbox/root.js"; +import { REQUEST_DIRECTORY_SHORT_WAIT_MS, REQUEST_DIRECTORY_WAIT_MS, type RequestDirectoryInput, type SandboxMcpMount } from "./sandbox/request-directory.js"; import { CLAUDE_PROVIDER_ID, findModel, @@ -450,6 +471,8 @@ export interface SessionCreateOptions { backgroundWakeFallbackMs?: number; /** Override for FOREIGN_STOP_GRACE_MS (tests). */ foreignStopGraceMs?: number; + /** Override for how long `request_directory` waits for its owner's answer before it returns (tests). */ + requestDirectoryWaitMs?: number; /** Optional memory engine — when provided, episodes are chunked and stored for recall. */ memory?: MemoryEngine; /** Shared in-daemon memory MCP endpoint + URL, for URL-mounting backends. */ @@ -521,6 +544,14 @@ export interface SessionCreateOptions { * rather than an in-process Claude-SDK server (#245). */ blackboardMcp?: { url: string; token: string }; + /** + * The sandbox's `request_directory` over the daemon's `/mcp/sandbox` + * (sandbox/request-directory.ts), minted by the manager for this session: + * what the URL-mounting backends (Codex, the Gemini CLI, opencode) mount. + */ + sandboxMcp?: (sessionId: string) => SandboxMcpMount | undefined; + /** The checkouts other sessions work in (SessionManager): what an autonomous `request_directory` never touches (#448 round 13). */ + sandboxCheckouts?: (sessionId: string) => OtherCheckout[]; /** * Pre-built codeoid_fleet MCP server (conductor sessions only). Built by * the SessionManager because its tools close over the manager's tenant- @@ -680,6 +711,7 @@ function planHash(parts: readonly string[]): string { } /** Whether `rel` exists in `workdir` as something other than a directory (links not followed). */ +// paths: a look (no write) in the working directory as pinned, just checked by #planRewind; the restore checks it again async function isNonDirectory(workdir: string, rel: string): Promise { try { return !(await lstat(join(workdir, rel))).isDirectory(); @@ -693,6 +725,7 @@ async function isNonDirectory(workdir: string, rel: string): Promise { * not followed). Exact: on a case-insensitive filesystem `lstat("readme.md")` * also finds `README.md`, which would make a case-only rename look occupied. */ +// paths: a look (no write) in the working directory as pinned, just checked by #planRewind; the restore checks it again async function existsOnDisk(workdir: string, rel: string): Promise { try { await lstat(join(workdir, rel)); @@ -710,6 +743,7 @@ async function existsOnDisk(workdir: string, rel: string): Promise { } /** Whether any parent directory of `rel` inside `workdir` is a symlink. */ +// paths: a look (no write) in the working directory as pinned, just checked by #planRewind; the restore checks it again async function hasSymlinkedParent(workdir: string, rel: string): Promise { const parts = rel.split("/"); for (let k = 1; k < parts.length; k++) { @@ -777,6 +811,7 @@ export function irreversibleEffects(turns: readonly CanonicalTurn[], ctx: { work const call = tc.input.call && typeof tc.input.call === "object" ? shellCallOf(inner, tc.input.call as Record) : null; if (!call) continue; const places = Array.isArray(tc.input.paths) ? tc.input.paths.filter((p): p is string => typeof p === "string" && p.length > 0) : []; + // paths: judging what a recorded command would reach (outsideEffects): no use of the path if ((places.length > 0 ? places : [ctx.workdir]).some((cwd) => outsideEffects(call.command, { ...ctx, cwd: resolvePath(ctx.workdir, cwd) }))) { out.push({ tool: inner, detail: shorten(call.command) }); } @@ -930,6 +965,8 @@ export class Session { * is scoped to a goal id that IS this session's id, so it can only be * attached after construction (see attachBlackboard). */ #blackboardMcp?: { url: string; token: string }; + #sandboxMcp?: (sessionId: string) => SandboxMcpMount | undefined; + #sandboxCheckouts?: (sessionId: string) => OtherCheckout[]; readonly createdBy: string; readonly createdAt: string; /** @@ -1234,10 +1271,23 @@ export class Session { #refBaseline: Promise | null = null; /** The commit look in flight: one at a time, each from where the last one left the baseline. */ #settingsCheck: Promise = Promise.resolve(); + /** The same two looks for each directory the session was given to write (its repository's), from when it applied. */ + readonly #addedGitBaselines = new Map }>(); + /** The agent's `request_directory` questions still open, by directory: a repeat request joins its answer. */ + readonly #dirRequests = new Map>(); + /** The pin change last said on the session (#sandboxPinRefusal): said once per change. */ + #pinSaid: string | null = null; + /** The working directory's identity when there is no sandbox to keep it (an embedder): taken at its first use. */ + #ownWorkdirRecord: string | null = null; + /** The change of the working directory last said (#workdirChanged): said once per change. */ + #workdirChangeSaid: string | null = null; + #requestDirectoryWaitMs: number | undefined; /** Started for unattended work: turns need an enforced sandbox (SessionCreateOptions.requireSandbox). */ #requireSandbox = false; /** The sandbox changed while the backend process ran: it runs in the old one until it is restarted. */ #sandboxRestartPending = false; + /** The sandbox's directories changed and were applied while the backend ran: restarted before the next turn. */ + #sandboxDirsRestart = false; /** Why the last finished turn failed, when a usage or rate limit was the cause; null after a success. */ #lastTurnInfraError: TurnInfraError | null = null; /** @@ -1552,6 +1602,9 @@ export class Session { this.collaboration = opts.collaboration; this.collaborationRole = opts.collaborationRole; this.#blackboardMcp = opts.blackboardMcp; + this.#sandboxMcp = opts.sandboxMcp; + this.#sandboxCheckouts = opts.sandboxCheckouts; + this.#requestDirectoryWaitMs = opts.requestDirectoryWaitMs; this.#onStatusChange = opts.onStatusChange; this.#workerShape = opts.workerShape; this.#collaborationCost = opts.collaborationCost; @@ -1856,6 +1909,8 @@ export class Session { // A getter, so a mount attached after construction still reaches the // provider when it next builds its server list. blackboardMcp: () => this.#blackboardMcp, + // The sandbox's ask for a directory to write (sandbox/request-directory.ts): decided here, every backend. + ...(this.#sandbox ? { requestDirectory: (input: RequestDirectoryInput) => this.requestDirectory(input), sandboxMcp: () => this.#sandboxMcp?.(this.id) } : {}), mcpRegistry: this.#mcpView ?? this.#mcpRegistry, mcpHub: this.#mcpHub, fleet: this.#fleet, @@ -2031,38 +2086,49 @@ export class Session { } this.#releaseProviderWhenIdle = false; this.#sandboxRestartPending = false; - // Before the teardown, as terminateBackend does: codex and the Gemini - // CLI are stopped with a bare SIGKILL, and what they started (MCP - // servers, commands) would otherwise be left running, out of reach. - let tree: ProcessEntry[] = []; - try { - const roots = (await withTimeout(this.#provider.backendProcessIds?.() ?? Promise.resolve([]), 3_000)) ?? []; - tree = await withTimeout(snapshotTree(roots), 3_000); - } catch (e) { - console.error(`[codeoid/session ${this.id}] could not snapshot the ${backend} backend's processes: ${e instanceof Error ? e.message : String(e)}`); - } - await this.#teardownProvider(); - try { - const killed = await killSurvivors(tree); - if (killed.length > 0) { - this.#store.audit(opts.idle ? "system:idle-release" : "system:account-release", "session.release_backend.reaped", this.id, `pids=${killed.join(",")}`); - console.warn(`[codeoid/session ${this.id}] killed ${killed.length} process(es) the released ${backend} backend left running: ${killed.join(", ")}`); - } - } catch (e) { - console.error(`[codeoid/session ${this.id}] killing the released backend's leftover processes failed: ${e instanceof Error ? e.message : String(e)}`); - } - // A backend that can't resume its own conversation (the Gemini CLI - // starts a new ACP session) is handed the history, as after an MCP - // rebuild — an account switch must never start the conversation over. - if (RESEED_AFTER_TEARDOWN.has(this.#provider.id) && this.#accumulator.history.length > 0) { - await this.#seedProviderFromHistory(); - } + this.#sandboxDirsRestart = false; + await this.#releaseBackendNow(opts.idle ? "system:idle-release" : "system:account-release"); }) .catch((e) => { console.error(`[codeoid/session ${this.id}] releasing the idle ${backend} backend failed: ${e instanceof Error ? e.message : String(e)}`); }); } + /** + * Stop the backend's process now (idle: in the send chain) so its next turn + * starts a fresh one that resumes the conversation: what it started is + * reaped, and a backend that can't resume is handed the history. + */ + async #releaseBackendNow(who: string): Promise { + const backend = this.#provider.id; + // Before the teardown, as terminateBackend does: codex and the Gemini + // CLI are stopped with a bare SIGKILL, and what they started (MCP + // servers, commands) would otherwise be left running, out of reach. + let tree: ProcessEntry[] = []; + try { + const roots = (await withTimeout(this.#provider.backendProcessIds?.() ?? Promise.resolve([]), 3_000)) ?? []; + tree = await withTimeout(snapshotTree(roots), 3_000); + } catch (e) { + console.error(`[codeoid/session ${this.id}] could not snapshot the ${backend} backend's processes: ${e instanceof Error ? e.message : String(e)}`); + } + await this.#teardownProvider(); + try { + const killed = await killSurvivors(tree); + if (killed.length > 0) { + this.#store.audit(who, "session.release_backend.reaped", this.id, `pids=${killed.join(",")}`); + console.warn(`[codeoid/session ${this.id}] killed ${killed.length} process(es) the released ${backend} backend left running: ${killed.join(", ")}`); + } + } catch (e) { + console.error(`[codeoid/session ${this.id}] killing the released backend's leftover processes failed: ${e instanceof Error ? e.message : String(e)}`); + } + // A backend that can't resume its own conversation (the Gemini CLI + // starts a new ACP session) is handed the history, as after an MCP + // rebuild — an account switch must never start the conversation over. + if (RESEED_AFTER_TEARDOWN.has(this.#provider.id) && this.#accumulator.history.length > 0) { + await this.#seedProviderFromHistory(); + } + } + /** * Arm the idle release (#451): a session left idle this long has its * backend process, and the MCP servers it started, stopped. Re-armed each @@ -2162,7 +2228,7 @@ export class Session { #heldWrite(toolName: string, input: unknown): { path: string; what: HeldPath["what"] | "unresolved" } | null { if (!this.#hostSideFileTools() || !input || typeof input !== "object") return null; try { - return heldWriteOf(toolName, input as Record, this.workdir, protectedWriteTargets(this.workdir)); + return heldWriteOf(toolName, input as Record, this.workdir, protectedWriteTargets(this.workdir, undefined, this.#sandbox?.appliedAddedDirs() ?? [])); } catch { return writeTargets(toolName, input as Record) ? { path: "(couldn't be judged)", what: "unresolved" } : null; } @@ -2197,6 +2263,7 @@ export class Session { if (!rt) throw new Error("no runtime"); const own = this.#provider.hostFiles?.() ?? {}; const bounds = hostToolBoundsFor(rt, this.#provider.id, this.#provider.hostReadable?.() ?? [], own, userHiddenPaths(this.#config)); + // paths: judging a tool call's input against the sandbox's bounds: no use of the path return hostFileToolBreach(toolName, input, { bounds, workdir: this.workdir, ...(shellCwd !== undefined ? { cwd: shellCwd } : {}) }); } catch { return { path: "(couldn't be judged)", access: "read", why: "unresolved" }; @@ -3110,13 +3177,84 @@ export class Session { async ensureSandbox(): Promise { const sbx = this.#sandbox; if (!sbx) return null; - if (sbx.resolvedFor(this.#provider.id) && sbx.info) return sbx.info; + // An agent's own grant another session works in now is let go first (applied as any change of directories). + await this.#dropSharedAgentDirs(); + // Directories added or removed are applied only between turns, with no background work running: a + // Claude query rebuilt, or a process backend restarted, mid-work would end that work. + const applyDirs = sbx.dirsPending && this.#mayApplySandboxDirs(); + if (sbx.resolvedFor(this.#provider.id) && sbx.info && !applyDirs) return sbx.info; const before = JSON.stringify(sbx.info); const info = await sbx.refresh(this.#provider.id); - if (JSON.stringify(info) !== before) this.#broadcastInfoUpdate(); + // Pins taken by this resolution (the workspace, a root seen first: #448 round 13) are kept with the setting. + if (sbx.takePinsChanged()) this.#saveMetaNow(); + this.#sayDroppedDirs(); + if (applyDirs) { + // A backend running now runs in the old sandbox: restarted before its next turn (#restartForSandboxDirs). + if (PROCESS_BACKENDS.has(this.#provider.id) && this.#provider.hasQueried) this.#sandboxDirsRestart = true; + const note = this.#makeMessage("info", `🛡 Sandbox directories applied: ${sandboxDirsLine(info)}`, SYSTEM_IDENTITY, undefined, undefined, { event: "sandbox.dirs_applied" }); + this.#persistAndBuffer(note); + this.#broadcastRaw(note); + } + if (JSON.stringify(info) !== before || applyDirs) this.#broadcastInfoUpdate(); return info; } + /** + * Why no tool call may run now because a directory the sandbox binds by + * name isn't what was pinned (SessionSandbox.pinProblem, #448 round 13), + * or null. Said on the session once per change (event + * `sandbox.pin_changed`, audit `session.sandbox_pin_changed`). + */ + #sandboxPinRefusal(): string | null { + const sbx = this.#sandbox; + if (!sbx || sbx.info?.state !== "on") return null; + const problem = sbx.pinProblem(); + if (!problem) { + this.#pinSaid = null; + return null; + } + if (this.#pinSaid !== problem) { + this.#pinSaid = problem; + this.#store.audit(SYSTEM_IDENTITY.sub, "session.sandbox_pin_changed", this.id, problem.slice(0, 2000)); + const note = this.#makeMessage("info", `🛡 Sandbox: ${problem}.`, SYSTEM_IDENTITY, undefined, undefined, { event: "sandbox.pin_changed" }); + this.#persistAndBuffer(note); + this.#broadcastRaw(note); + } + return problem; + } + + /** Whether a change of the sandbox's directories may be applied now: no turn, approval or background work. */ + #mayApplySandboxDirs(): boolean { + return !this.#turnActive() && !this.#hasAgentBackgroundWork() && this.#pendingApprovals.size === 0 && this.#pendingUiRequests.size === 0; + } + + /** + * A directory change applied while the backend's process runs in the old + * sandbox: restarted (idle, in the send chain) before the turn that + * follows, which resumes its conversation. + */ + async #restartForSandboxDirs(): Promise { + if (!this.#sandboxDirsRestart) return; + this.#sandboxDirsRestart = false; + if (!PROCESS_BACKENDS.has(this.#provider.id) || !this.#provider.hasQueried) return; + await this.#releaseBackendNow("system:sandbox-dirs"); + } + + /** The sandbox's workspace (a monorepo's repository top, as pinned), or the workdir without a sandbox. */ + get sandboxWorkspace(): string { + return this.#sandbox?.workspace ?? this.workdir; + } + + /** The session's own worktree folders (its commands write them): none without a sandbox. */ + sandboxWorktreeFolders(): string[] { + return this.#sandbox?.worktreeFolders() ?? []; + } + + /** The directories the session's commands write besides its workspace, as set. */ + sandboxAddedDirs(): string[] { + return this.#sandbox?.effective().addedDirs ?? []; + } + /** The session's own sandbox setting (persisted; a fork and a collaboration's children inherit it). */ get sandboxSetting(): SessionSandboxSetting | undefined { return this.#sandbox?.setting; @@ -3124,7 +3262,19 @@ export class Session { /** The resolved sandbox, if any (SessionInfo.sandbox). */ get sandboxInfo(): SessionSandboxInfo | null { - return this.#sandbox?.info ?? null; + const info = this.#sandbox?.info; + return info ? this.#sandboxInfoWire(info) : null; + } + + /** The resolution with the directories set but not applied yet (they take effect between turns). */ + #sandboxInfoWire(info: SessionSandboxInfo): SessionSandboxInfo { + const sbx = this.#sandbox; + if (!sbx?.dirsPending) return info; + const applied = sbx.appliedAddedDirs(); + const wanted = sbx.effective().addedDirs; + const add = wanted.filter((d) => !applied.includes(d)); + const remove = applied.filter((d) => !wanted.includes(d)); + return add.length > 0 || remove.length > 0 ? { ...info, pendingDirs: { add, remove } } : info; } /** Whether the session was started for unattended work (SessionCreateOptions.requireSandbox). */ @@ -3193,7 +3343,8 @@ export class Session { this.#broadcastRaw(note); return false; } - await sbx.set(next); + // Project roots set again: each pinned anew, as it is now (#448 round 13). + await sbx.set(next, { repinRoots: setting.projectRoots !== undefined }); this.#store.audit(sender.sub, "session.set_sandbox", this.id, answeredBy && answeredBy !== sender.sub ? `${change} (confirmed by ${answeredBy})` : change); this.#saveMetaNow(); // A backend running now runs in the old sandbox: restarted before its next turn. @@ -3244,6 +3395,337 @@ export class Session { return { ok: true, pending: {} }; } + /** + * Why `raw` can't be added to what the session's commands write, or the + * directory it names: where it really is now (what is kept and bound: + * project-roots.ts `addedDirReal`), with `written` (absolute; a relative + * one is the workdir's) and `already` when it is writable now (the + * workspace, the session's worktree folders or temp dir, an added dir). + */ + #sandboxDirCheck(raw: string): { dir: string; written: string; already?: string; agentHeld?: true } | { error: string } { + const sbx = this.#sandbox; + if (!sbx) return { error: "this daemon has no OS sandbox" }; + const home = process.env.HOME ?? homedir(); + const written = normalizeSandboxDir(raw, home) ?? resolvePath(this.workdir, raw.trim()); + const dir = addedDirReal(written, home) ?? written; + const workspace = sbx.workspace; + if (inAnyDir(dir, [workspace])) return { dir, written, already: `${dir} is in the session's workspace (${workspace}), which its commands write already` }; + const folder = sbx.worktreeFolders().find((f) => inAnyDir(dir, [f])); + if (folder) return { dir, written, already: `${dir} is in the session's own worktree folder (${folder}), which its commands write already` }; + const temp = sbx.tempDirPath; + if (temp && inAnyDir(dir, [temp])) return { dir, written, already: `${dir} is in the session's temp dir, which its commands write already` }; + const { addedDirs: added, agentDirs } = sbx.effective(); + const holder = added.find((d) => inAnyDir(dir, [d])); + // The agent's own: added already, but its credentials stay hidden there — the owner may add it as theirs. + const agentHeld = holder !== undefined && agentDirs.includes(holder) ? ({ agentHeld: true } as const) : {}; + if (holder) return { dir, written, already: holder === dir ? `${dir} is added already` : `${dir} is in ${holder}, which is added already`, ...agentHeld }; + const root = sandboxRootIfMade(); + const hidden = [...hiddenPathsFor(this.#config), ...(root ? [root] : [])]; + const problem = addedDirProblem(written, { home, configDir: getConfigDir(), hidden }); + return problem ? { error: problem } : { dir, written }; + } + + /** + * What stays read-only in `dir` once the owner adds it, in words, for the + * owner's confirm: true for what is there. Its credential files become + * readable to the session's commands, as the workspace's are (only a + * directory the agent added itself keeps a project root's hiding); and a + * checkout another session works in is said (#448 round 13). + */ + #addedDirProtection(dir: string): string { + const nested = nestedControl(dir); + const repos = nested.repos.filter((r) => r !== dir).length; + const overlap = this.#checkoutOverlapOf(dir); + const held = `its git structure and project settings stay read-only, as in the workspace${repos > 0 ? `, and so do those of the ${repos} ${repos === 1 ? "repository" : "repositories"} inside it` : ""}`; + const warning = overlap ? `. Warning: ${overlap} — a change either session makes there (a directory swapped for a link included) the other one runs against` : ""; + return `${held}; its credential files (.env files, keys, a token in a git config) become readable to its commands, as the workspace's are${warning}`; + } + + /** + * The directories the agent added itself (an autonomous + * `request_directory`) that another session's checkout, or one of + * codeoid's, touches now (a session made there since, a directory + * another session added): taken out of the setting, said and audited + * (#448 round 14), so an agent's own grant never shares a directory with + * another writer, as at the grant (`checkoutOverlap`). Looked at every + * resolution; applied as any change of directories, between turns. + */ + async #dropSharedAgentDirs(): Promise { + const sbx = this.#sandbox; + const setting = sbx?.setting; + if (!sbx || !setting?.agentDirs || setting.agentDirs.length === 0) return; + const shared = setting.agentDirs.map((dir) => ({ dir, why: this.#checkoutOverlapOf(dir) })).filter((d): d is { dir: string; why: string } => d.why !== null); + if (shared.length === 0) return; + const gone = new Set(shared.map((d) => d.dir)); + const addedDirs = (setting.addedDirs ?? []).filter((d) => !gone.has(d) && !gone.has(normalizeSandboxDir(d) ?? d)); + const agentDirs = setting.agentDirs.filter((d) => !gone.has(d)); + const { addedDirs: _a, agentDirs: _g, ...rest } = setting; + await sbx.set({ ...rest, ...(addedDirs.length > 0 ? { addedDirs } : {}), ...(agentDirs.length > 0 ? { agentDirs } : {}) }); + for (const d of shared) { + this.#store.audit(SYSTEM_IDENTITY.sub, "session.sandbox_dir_dropped", this.id, `${d.dir}: ${d.why}`.slice(0, 2000)); + const note = this.#makeMessage("info", `🛡 Sandbox: ${d.dir}, which the agent added, is no longer writable: ${d.why}. The owner can add it (\`/sandbox add\`) if both should write it.`, SYSTEM_IDENTITY, undefined, undefined, { + event: "sandbox.dir_dropped", + path: d.dir, + }); + this.#persistAndBuffer(note); + this.#broadcastRaw(note); + } + this.#saveMetaNow(); + } + + /** Why `dir` touches another session's or codeoid's checkout (project-roots.ts `checkoutOverlap`), or null. */ + #checkoutOverlapOf(dir: string): string | null { + const sbx = this.#sandbox; + if (!sbx) return null; + try { + return checkoutOverlap(dir, this.#sandboxCheckouts?.(this.id) ?? [], { + workspace: sbx.workspace, + ownFolders: sbx.worktreeFolders(), + worktreeRoots: sbx.config.worktreeRoots, + configDir: getConfigDir(), + }); + } catch { + return null; + } + } + + /** The added dirs once `dir` is added: any of them inside it go (it holds them now). */ + #withAddedDir(dir: string): string[] { + return [...this.#sandbox!.effective().addedDirs.filter((d) => !inAnyDir(d, [dir])), dir]; + } + + /** + * Say on the session (and audit) each added directory the last resolution + * left out because it no longer is what was granted, or can't be one now + * (SessionSandbox.droppedDirs), and take it out of the setting: never + * followed to where a link put there leads, never added back unasked. + */ + #sayDroppedDirs(): void { + const sbx = this.#sandbox; + const dropped = sbx?.droppedDirs() ?? []; + if (!sbx || dropped.length === 0) return; + sbx.forget(dropped.map((d) => d.dir)); + for (const d of dropped) { + this.#store.audit(SYSTEM_IDENTITY.sub, "session.sandbox_dir_dropped", this.id, `${d.dir}: ${d.why}`.slice(0, 2000)); + const note = this.#makeMessage("info", `🛡 Sandbox: ${d.dir} is no longer writable: ${d.why}. Add it again (\`/sandbox add\`) if it should be.`, SYSTEM_IDENTITY, undefined, undefined, { + event: "sandbox.dir_dropped", + path: d.dir, + }); + this.#persistAndBuffer(note); + this.#broadcastRaw(note); + } + this.#saveMetaNow(); + } + + /** When an added or removed directory takes effect, in words. */ + #dirsWhen(): string { + if (this.#mayApplySandboxDirs()) return "from the session's next turn"; + if (this.#hasAgentBackgroundWork()) return "from the session's next turn after the background work running now has ended"; + return "from the session's next turn"; + } + + /** + * Record a change of the session's added directories (persisted, audited, + * said on the session) and apply it now when nothing runs, else at the next + * turn that starts with nothing running (#ensureSandbox). Never restarts + * work: the backend picks it up between turns. + */ + async #changeSandboxDirs(next: readonly string[], audit: { who: string; action: string; detail: string }, line: string, grant?: { dir: string; by: "owner" | "agent" }): Promise { + const sbx = this.#sandbox; + if (!sbx) return; + // Who added each: the agent's own keep a project root's credential hiding (#448 round 13). + const agent = new Set(sbx.effective().agentDirs.filter((d) => next.includes(d))); + if (grant?.by === "agent") agent.add(grant.dir); + else if (grant) agent.delete(grant.dir); + const { agentDirs: _old, ...rest } = sbx.setting ?? {}; + await sbx.set({ ...rest, addedDirs: [...next], ...(agent.size > 0 ? { agentDirs: [...agent] } : {}) }); + this.#store.audit(audit.who, audit.action, this.id, audit.detail.slice(0, 2000)); + this.#saveMetaNow(); + const note = this.#makeMessage("info", `🛡 ${line}`, SYSTEM_IDENTITY, undefined, undefined, { event: "sandbox.dirs_changed", addedDirs: [...next] }); + this.#persistAndBuffer(note); + this.#broadcastRaw(note); + if (this.#mayApplySandboxDirs() && sbx.resolvedFor(this.#provider.id)) { + await this.ensureSandbox(); + // A process backend picks it up when it next starts: stopped now (idle), as a sandbox change is. + if (this.#sandboxDirsRestart) { + this.#sandboxDirsRestart = false; + this.#sandboxRestartPending = true; + this.releaseIdleProvider({ idle: true }); + } + } + this.#broadcastInfoUpdate(); + } + + /** + * Add a directory to what the session's commands write, or remove one + * (`session.sandbox_dir`; owner only, undelegated: SessionManager checks). + * Removing applies (between turns); adding is a widening, asked of the + * owner on the session with an owner-only confirm, as `setSandbox`'s + * widening is, and lands on a yes. `confirmed`: the owner's own request + * where nothing else could have sent it (none today). + */ + async changeSandboxDir( + action: "add" | "remove", + raw: string, + sender: AuthContext, + opts: { onDecided?: (applied: boolean, reason?: string) => void } = {}, + ): Promise<{ ok: true; pending?: true; message: string } | { ok: false; error: string }> { + const sbx = this.#sandbox; + if (!sbx) return { ok: false, error: "this daemon has no OS sandbox" }; + const current = sbx.effective().addedDirs; + if (action === "remove") { + const written = normalizeSandboxDir(raw) ?? resolvePath(this.workdir, raw.trim()); + // Kept by where it really was when added: named either way. + const dir = current.includes(written) ? written : (addedDirReal(written) ?? written); + if (!current.includes(dir)) return { ok: false, error: `${dir} isn't one of the session's added directories${current.length > 0 ? ` (${current.join(", ")})` : ""}` }; + const when = this.#dirsWhen(); + await this.#changeSandboxDirs(current.filter((d) => d !== dir), { who: sender.sub, action: "session.sandbox_dir_removed", detail: dir }, `Sandbox: ${dir} is no longer writable (${when})`); + return { ok: true, message: `${dir} removed: not writable ${when}` }; + } + const check = this.#sandboxDirCheck(raw); + if ("error" in check) return { ok: false, error: check.error }; + // One the agent added itself is the owner's to add as theirs (its credentials readable then); anything else + // writable already has nothing to add. + const upgrade = check.already !== undefined && check.agentHeld === true && current.includes(check.dir); + if (check.already && !upgrade) return { ok: true, message: check.already }; + const dir = check.dir; + const via = check.written !== dir ? ` (${check.written} leads there)` : ""; + this.#store.audit(sender.sub, "session.sandbox_dir_ask", this.id, `${dir}${via}`); + let answeredBy: string | undefined; + void this.requestUserInput( + { + method: "confirm", + title: `Let "${this.name}" write ${dir}?`, + message: `Its sandboxed commands could change and commit files in ${dir}${via} (${this.#addedDirProtection(dir)}). It takes effect ${this.#dirsWhen()}.`, + }, + { ownerOnly: true, onAnswer: (sub) => { + answeredBy = sub; + }, + }, + ).then( + async (answer) => { + if (answer.cancelled || answer.confirmed !== true) { + this.#store.audit(answeredBy ?? sender.sub, "session.sandbox_dir_denied", this.id, `${dir} ${answer.reason ?? "declined"}`); + opts.onDecided?.(false, answer.reason ?? "declined"); + return; + } + const again = this.#sandboxDirCheck(dir); + const stillUpgrade = upgrade && !("error" in again) && again.agentHeld === true && again.dir === dir; + if ("error" in again || (again.already && !stillUpgrade) || again.dir !== dir) { + opts.onDecided?.(false, "error" in again ? again.error : (again.already ?? `${dir} isn't the directory it was when asked (it leads to ${again.dir} now)`)); + return; + } + const when = this.#dirsWhen(); + await this.#changeSandboxDirs( + this.#withAddedDir(dir), + { who: answeredBy ?? sender.sub, action: "session.sandbox_dir_added", detail: `${dir} by=owner` }, + `Sandbox: ${dir} is writable (${when})`, + { dir, by: "owner" }, + ); + opts.onDecided?.(true); + }, + () => opts.onDecided?.(false, "failed"), + ); + return { ok: true, pending: true, message: `asked on the session: let it write ${dir}?` }; + } + + /** + * The agent's `request_directory` (sandbox/request-directory.ts): the text + * it reads. Interactive and guarded: its owner answers a confirm card + * naming the directory and the reason (owner-only; an unattended run + * isn't asked, so never parks). Autonomous: a directory under the + * session's project roots is added unasked, anything else refused at once. + * Audited either way; what is added is writable from the next turn. + */ + async requestDirectory(input: RequestDirectoryInput): Promise { + const sbx = this.#sandbox; + const info = sbx?.info; + if (!sbx || !info || info.state !== "on") { + return `Nothing to add: this session's commands aren't sandboxed (${info?.reason ?? info?.state ?? "no sandbox"}), so they write wherever you can.`; + } + const check = this.#sandboxDirCheck(input.path); + const asked = "dir" in check ? check.dir : input.path; + this.#store.audit(SYSTEM_IDENTITY.sub, "session.sandbox_dir_request", this.id, `${asked} reason=${input.reason}`.slice(0, 2000)); + const refuse = (why: string): string => { + this.#store.audit(SYSTEM_IDENTITY.sub, "session.sandbox_dir_refused", this.id, `${asked}: ${why}`.slice(0, 2000)); + const note = this.#makeMessage("info", `🛡 The agent asked to write ${asked}: refused (${why})`, SYSTEM_IDENTITY, undefined, undefined, { event: "sandbox.dir_refused", path: asked }); + this.#persistAndBuffer(note); + this.#broadcastRaw(note); + return `Refused: ${why}.`; + }; + if ("error" in check) return refuse(check.error); + if (check.already) return `Nothing to add: ${check.already}.`; + const dir = check.dir; + const via = check.written !== dir ? ` (${check.written} leads there)` : ""; + const grant = async (by: string, how: string, granted: "owner" | "agent"): Promise => { + const when = this.#dirsWhen(); + await this.#changeSandboxDirs( + this.#withAddedDir(dir), + { who: by, action: "session.sandbox_dir_added", detail: `${dir} by=agent ${how} reason=${input.reason}` }, + `Sandbox: ${dir} is writable, as the agent asked (${how}; ${when})`, + { dir, by: granted }, + ); + const hidden = granted === "agent" ? " Its credential files (.env files, keys, a token in a git config) stay hidden to you, as in a project root." : ""; + return `Added: ${dir} is writable to your commands ${when.replace("the session's", "your")}.${hidden} Finish this turn; it isn't writable yet in this one.`; + }; + // Autonomous (an unattended run's too: a routine's, a loop worker's, a dispatched worker's): only a repository + // (or a dir in one) strictly under a project root, named by its real path, never another session's or codeoid's + // checkout; its credential files stay hidden. Anything else is the owner's to add. + if (this.#mode === "autonomous") { + const why = autonomousGrantProblem(check.written, dir, sbx.appliedProjectRoots()) ?? this.#checkoutOverlapOf(dir); + if (!why) return grant(SYSTEM_IDENTITY.sub, "a repository under a project root, autonomous", "agent"); + return refuse(`${why}. The owner can add it (\`/sandbox add ${dir}\`)`); + } + // One card per directory: a repeat request while it is open waits on the same answer. + let pending = this.#dirRequests.get(dir); + if (!pending) { + let answeredBy: string | undefined; + const asked = this.requestUserInput( + { + method: "confirm", + title: `The agent asks to write ${dir}`, + message: `"${this.name}" asks for its sandboxed commands to write ${dir}${via} (${this.#addedDirProtection(dir)}).\n\nIts reason: ${input.reason}`, + }, + { + ownerOnly: true, + onAnswer: (sub) => { + answeredBy = sub; + }, + }, + ); + pending = asked.then(async (answer): Promise => { + this.#dirRequests.delete(dir); + if (answer.cancelled || answer.confirmed !== true) { + const why = + answer.reason === "unattended" + ? "nobody is there to approve it in this unattended run; the owner can add it (`/sandbox add`)" + : answer.cancelled + ? `the question was withdrawn (${answer.reason ?? "cancelled"})` + : "the owner declined"; + this.#store.audit(answeredBy ?? SYSTEM_IDENTITY.sub, "session.sandbox_dir_denied", this.id, `${dir} ${answer.reason ?? "declined"}`); + return refuse(why); + } + const again = this.#sandboxDirCheck(dir); + if ("error" in again) return refuse(again.error); + if (again.already) return `Nothing to add: ${again.already}.`; + if (again.dir !== dir) return refuse(`${dir} isn't the directory it was when asked (it leads to ${again.dir} now)`); + return grant(answeredBy ?? this.createdBy, "the owner approved", "owner"); + }); + this.#dirRequests.set(dir, pending); + } + // The call returns before its backend's MCP client gives up on it (opencode's: about a minute); the card stays + // open, and a later yes still adds the directory. + const waitMs = this.#requestDirectoryWaitMs ?? (this.#provider.id === "opencode" ? REQUEST_DIRECTORY_SHORT_WAIT_MS : REQUEST_DIRECTORY_WAIT_MS); + let timer: ReturnType | undefined; + const late = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), waitMs); + }); + const first = await Promise.race([pending, late]); + clearTimeout(timer); + if (first !== null) return first; + return `No answer yet: the owner has been asked to let you write ${dir}, and the question stays open. If they say yes, it is writable from a later turn (call request_directory again to see). Carry on without it, or end this turn.`; + } + /** * A call that asks to run outside the backend's own sandbox or widen it * (asksOutsideSandbox), on a session whose sandbox is the backend's own @@ -3272,12 +3754,16 @@ export class Session { */ #checkGitControl(sub: string): Promise { const commits = this.#checkSettingsCommits(sub); + const added = this.#checkAddedGitControl(sub); // The repository's worktree entries first (#448 round 11): what is restored there isn't news below. this.#sandbox?.checkWorktreeEntries(); const before = this.#gitBaseline; - if (!before) return commits; + if (!before) return Promise.all([commits, added]).then(() => {}); + // In the working directory as pinned (#448 round 14): one swapped for a link is said, never looked into. + const workdir = this.#pinnedWorkdirOrSay("the look at the repository's git settings"); + if (!workdir) return Promise.all([commits, added]).then(() => {}); try { - const now = gitControlSnapshot(this.workdir, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] }); + const now = gitControlSnapshot(workdir.path, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] }); const changed = gitControlChanges(before, now); this.#gitBaseline = now; if (changed.length > 0) { @@ -3287,7 +3773,46 @@ export class Session { } catch (err) { console.error(`[codeoid/session ${this.id}] checking the repository's git settings failed: ${err instanceof Error ? err.message : String(err)}`); } - return commits; + return Promise.all([commits, added]).then(() => {}); + } + + /** + * The end-of-turn look ({@link #checkGitControl}) in each added + * directory's repository: what of its git settings changed, and the + * commits that changed its project settings, each said naming the + * directory. Serialized with the workspace's; best effort. + */ + #checkAddedGitControl(sub: string): Promise { + const dirs = [...this.#addedGitBaselines.keys()]; + if (dirs.length === 0) return Promise.resolve(); + const look = async (): Promise => { + for (const dir of dirs) { + const base = this.#addedGitBaselines.get(dir); + if (!base) continue; + try { + const now = gitControlSnapshot(dir, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] }); + const changed = gitControlChanges(base.git, now); + base.git = now; + if (changed.length > 0) { + this.#store.audit(sub, "session.git_control_changed", this.id, `${dir}: ${changed.slice(0, 20).join(",")}`); + this.#sandboxWarning(`In ${dir} (added to this session's sandbox): ${gitControlWarning(changed)}`, "sandbox.git_changed"); + } + const before = await base.refs; + const tips = await refTips(dir); + if (tips === null) continue; + const found = before === null ? { commits: [], more: false } : await settingsCommits(dir, before, tips); + if (found === null) continue; + base.refs = Promise.resolve(tips); + if (found.commits.length === 0) continue; + this.#store.audit(sub, "session.settings_committed", this.id, `${dir}: ${found.commits.slice(0, 20).map((c) => `${c.commit.slice(0, 12)}:${c.files.slice(0, 5).join("|")}`).join(",")}`.slice(0, 2000)); + this.#sandboxWarning(`In ${dir} (added to this session's sandbox): ${settingsCommitWarning(found.commits, found.more)}`, "sandbox.settings_committed"); + } catch (err) { + console.error(`[codeoid/session ${this.id}] checking ${dir}'s git settings failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + }; + this.#settingsCheck = this.#settingsCheck.then(look).catch(() => {}); + return this.#settingsCheck; } /** @@ -3305,9 +3830,12 @@ export class Session { const baseline = this.#refBaseline; if (!baseline) return; const before = await baseline; - const now = await refTips(this.workdir); + // In the working directory as pinned (#448 round 14): git never runs where a link put in its place leads. + const workdir = this.#pinnedWorkdirOrSay("the look at the session's commits"); + if (!workdir) return; + const now = await refTips(workdir.path); if (now === null) return; - const found = before === null ? { commits: [], more: false } : await settingsCommits(this.workdir, before, now); + const found = before === null ? { commits: [], more: false } : await settingsCommits(workdir.path, before, now); if (found === null) return; this.#refBaseline = Promise.resolve(now); if (found.commits.length === 0) return; @@ -3366,7 +3894,7 @@ export class Session { const inside = shell || runner?.fileTools === true; // Commands can reach the container runtime (the owner's opt-in): every card says so, as a warning. const docker = info.dockerSocket === true ? { sandboxDocker: true } : {}; - if (inside) return { sandboxed: true, sandboxNote: `sandboxed${info.dockerSocket ? ", Docker access" : ""}: ${info.providerName ?? info.provider}, network ${info.network}`, ...docker }; + if (inside) return { sandboxed: true, sandboxNote: `sandboxed${info.dockerSocket ? ", Docker access" : ""}: ${info.providerName ?? info.provider}, network ${info.network}${cardDirsNote(info)}`, ...docker }; // Beyond what the sandbox lets its commands do: the owner decides, told so (#448 round 9). const beyond = this.#hostBoundsNote(toolName, input, shellCwd); return { sandboxed: false, sandboxNote: beyond ?? "runs in the backend's own process, outside the sandbox", ...docker }; @@ -4060,9 +4588,17 @@ export class Session { // The OS sandbox (#448), resolved for this backend before it starts. An // autonomous or unattended turn doesn't start without one that holds. await this.ensureSandbox(); - // Its worktree folders, removed while empty at the last turn's end, are there again for this one's commands. - this.#sandbox?.prepareFolders(); + // Directories added or removed since the backend started: it starts again in the sandbox that has them. + await this.#restartForSandboxDirs(); + // A pinned directory changed since the last resolution (#448 round 13): looked at again now, so an added dir or a + // root that changed is left out (said) before the turn; one that still is (the workspace) refuses it below. + if (this.#sandbox?.info?.state === "on" && this.#sandbox.pinProblem() && this.#mayApplySandboxDirs()) { + await this.ensureSandbox(); + await this.#restartForSandboxDirs(); + } const sandboxRefused = + // A directory the sandbox binds by name isn't what was pinned: no command of this turn could run (#sandboxPinRefusal). + this.#sandboxPinRefusal() ?? this.sandboxRefusal() ?? // Its backend still runs in the sandbox it had before a change: not what the session reports. (this.#sandboxRestartPending && this.#sandboxRequired() ? "its backend still runs in its previous sandbox (it restarts once the session is idle)" : null); @@ -4071,11 +4607,24 @@ export class Session { throw new SandboxRequiredError(`Not started: ${sandboxRefused}`); } this.#store.audit(sender.sub, "session.send", this.id); - // What of the repository git runs at the user's next command, as it is before the agent works (#448). - if (this.#sandbox && !this.#gitBaseline) this.#gitBaseline = gitControlSnapshot(this.workdir, undefined, { ownFolders: this.#sandbox.worktreeFolders() }); + // Its worktree folders, removed while empty at the last turn's end, are there again for this one's commands: only + // once its directories are what was pinned (#448 round 14), and never made through a link (worktrees.ts). + this.#sandbox?.prepareFolders(); + // What of the repository git runs at the user's next command, as it is before the agent works (#448), in the + // working directory as pinned (checked just above with the sandbox's pins). + const baselineDir = this.#sandbox && (!this.#gitBaseline || !this.#refBaseline) ? this.#pinnedWorkdirOrSay("the look at the repository's git settings") : null; + if (baselineDir && !this.#gitBaseline) this.#gitBaseline = gitControlSnapshot(baselineDir.path, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] }); // And the commits its refs name, so a commit made from here on can be told (#448 round 6). - if (this.#sandbox && !this.#refBaseline) this.#refBaseline = refTips(this.workdir); + if (baselineDir && !this.#refBaseline) this.#refBaseline = refTips(baselineDir.path); await this.#refBaseline; + // The same for each added directory's repository, from the first turn it is writable in. + // Every repository inside one too (a folder of them). + for (const dir of this.#sandbox?.appliedRepositories() ?? []) { + if (this.#addedGitBaselines.has(dir)) continue; + const base = { git: gitControlSnapshot(dir, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] }), refs: refTips(dir) }; + this.#addedGitBaselines.set(dir, base); + await base.refs; + } // A new turn is starting — clear any interrupt from the previous one. this.#turnInterrupted = false; this.#lastTurnError = null; @@ -6006,7 +6555,9 @@ export class Session { const t0 = Date.now(); try { await execFileP("sh", ["-c", command], { - cwd: this.workdir, + // In the fork's working directory as pinned (#448 round 14): changed meanwhile, the setup is refused (said). + // paths: the pinned workdir, checked here + cwd: this.pinnedWorkdir("running the fork's setup command").path, timeout: FORK_SETUP_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, }); @@ -6583,7 +7134,7 @@ export class Session { providerId: this.#provider.id, ...this.#accountInfo(), mode: this.#mode, - ...(this.#sandbox?.info ? { sandbox: this.#sandbox.info } : {}), + ...(this.#sandbox?.info ? { sandbox: this.#sandboxInfoWire(this.#sandbox.info) } : {}), turnsRemaining: this.#turnsRemaining, ...(this.#budgetExhausted ? { budgetExhausted: true as const } : {}), ...(this.#autonomousBudgetSpent ? { autonomousBudgetSpent: true as const } : {}), @@ -7656,9 +8207,43 @@ export class Session { if (this.#memory) parts.push(this.#buildMemoryPromptAppend()); const worktrees = this.#worktreePromptAppend(); if (worktrees) parts.push(worktrees); + const dirs = this.#sandboxDirsPromptAppend(); + if (dirs) parts.push(dirs); return parts.length > 0 ? parts.join("\n\n") : undefined; } + /** + * What the session's sandboxed commands read and write besides the + * workspace (docs/sandbox.md, "Working across repositories"), as the + * running sandbox has it, and how to ask for more: a write outside fails + * with the OS's bare "Read-only file system". Changes only when a change + * of directories is applied (between turns), so the prefix stays stable. + */ + #sandboxDirsPromptAppend(): string | null { + const sbx = this.#sandbox; + if (!sbx?.runtime || sbx.info?.state !== "on") return null; + const roots = sbx.appliedProjectRoots(); + const added = sbx.appliedAddedDirs(); + const lines = ["# Other directories in this sandbox"]; + lines.push( + roots.length > 0 + ? `Your commands can read (read-only) everything under ${roots.map((r) => `${r}/`).join(", ")}: other repositories, and other sessions' checkouts there. Credentials and secret files stay hidden.` + : "Your commands can read only the workspace and the toolchains the sandbox shows.", + ); + if (added.length > 0) lines.push(`Besides the workspace, they can write ${added.map((d) => `${d}/`).join(", ")} (git structure and project settings there read-only, as in the workspace).`); + if (this.#requestDirectoryMounted()) lines.push("To write in another directory, call the `request_directory` tool (path, reason). What it grants is writable from your next turn."); + else lines.push("To write in another directory, ask the owner to add it (`/sandbox add `)."); + return lines.join("\n"); + } + + /** Whether this session's backend has the sandbox's `request_directory` (sandbox/request-directory.ts). */ + #requestDirectoryMounted(): boolean { + const id = this.#provider.id; + if (id === "claude" || id === "qwen" || id === "pi") return true; + if (id === "codex" || id === "gemini-cli" || id === "opencode") return this.#sandboxMcp?.(this.id) !== undefined; + return false; + } + /** * Where the session's sandboxed commands may make git worktrees (#448 * round 10, sandbox/worktrees.ts): its own folder in each worktree @@ -7770,6 +8355,9 @@ export class Session { // resolve for certain is never run unasked (tool-safety.ts). if (isUnresolvedPathTool(toolName)) return false; + // An ask itself (sandbox/request-directory.ts): the session decides it, so it never waits on a card first. + if (isRequestDirectoryTool(toolName)) return true; + if (this.#mode === "interactive") return false; // Read-only / retrieval tools — safe in both guarded and autonomous. @@ -7841,6 +8429,7 @@ export class Session { if (isFleetSendTool(toolName)) return false; if (isElicitationTool(toolName)) return false; if (isUnresolvedPathTool(toolName)) return false; + if (isRequestDirectoryTool(toolName)) return true; if (this.#mode === "interactive") return false; if (isSafeTool(toolName)) return true; if (this.#isReadonlyMcpTool(toolName)) return true; @@ -8191,6 +8780,17 @@ export class Session { // runs — not even a read — until a new budget re-arms the session. if (this.#budgetExhausted) return this.#endAttemptOnBudget(approvalId, toolName, sender); + // A directory the sandbox binds by name isn't what was pinned (#448 + // round 13: the workspace, an added dir, a root swapped for a link): + // no call runs against it, in any mode, approved or not. + const pinRefusal = this.#sandboxPinRefusal(); + if (pinRefusal) { + this.#approvalPatchKeys.delete(approvalId); + this.#resolveToolCallMessage(approvalId, { phase: "cancelled", reason: "denied", message: pinRefusal }); + this.#store.audit(sender.sub, "session.sandbox_pin_refuse", this.id, `tool=${toolName}`); + return { behavior: "deny" as const, message: pinRefusal }; + } + // Hook gate — the policy layer, run BEFORE the approval gate. A hook // block is a policy deny that never prompts the user (and never burns // the autonomous budget — #shouldAutoApprove runs after this); an @@ -8334,6 +8934,7 @@ export class Session { // daemon's credential files or a process environment is refused; one // searching a directory that holds them is asked about (refused in // autonomous mode), never auto-run. + // paths: judging a tool call's input (secretPathVerdict): no use of the path const secret = secretPathVerdict(toolName, effectiveInput, { workdir: this.workdir, configDir: getConfigDir(), cwd: shellCwd }); // A shell command is judged by what it runs (#431): one naming a // credential file is refused like the file tools; a risky one asks. @@ -9038,6 +9639,7 @@ export class Session { // as canUseTool's synchronous section, always after it). const risk = this.#shellRisk(event.name, event.input, event.shellCwd); const held = this.#heldWrite(event.name, event.input); + // paths: judging a tool call's input (secretPathVerdict): no use of the path const secret = secretPathVerdict(event.name, event.input, { workdir: this.workdir, configDir: getConfigDir(), cwd: event.shellCwd }); // Claude's sandbox asking for a host: decided by the session's network alone (#448). const networkAsk = event.name === SANDBOX_NETWORK_TOOL && this.#sandbox?.info?.state === "on"; @@ -9054,9 +9656,11 @@ export class Session { const budgetDenied = this.#budgetExhausted || this.#budgetSpentFor(event.name); // Autonomous mode refuses it outright (#autonomousRefusal): no approval card either. const deniedFirst = networkAsk || budgetDenied || secret?.action === "deny" || risk?.secret?.action === "deny"; + // A pinned directory changed (#448 round 13): the gate refuses every call (#sandboxPinRefusal). + const pinned = budgetDenied ? null : this.#sandboxPinRefusal(); // Beyond the sandbox, a host-side file tool is refused in autonomous mode (#outsideSandbox). - const outside = deniedFirst ? null : this.#outsideSandboxRefusal(event.name, event.input, event.shellCwd); - const refused = deniedFirst ? null : outside ? { reason: hostFileBreachReason(outside) } : this.#autonomousRefusal(event.name, event.input, risk, secret, held); + const outside = deniedFirst || pinned ? null : this.#outsideSandboxRefusal(event.name, event.input, event.shellCwd); + const refused = pinned ? { reason: pinned } : deniedFirst ? null : outside ? { reason: hostFileBreachReason(outside) } : this.#autonomousRefusal(event.name, event.input, risk, secret, held); const toolMsg = this.#makeMessage( "tool_call", `${event.name}(${Object.keys(event.input).join(", ")})`, @@ -9782,6 +10386,68 @@ export class Session { return join(this.#transcriptStore.dir, "checkpoints"); } + /** + * The working directory as the daemon uses it on the host (#448 round 14, + * sandbox/pinned.ts): its real path and identity, taken when the session + * first resolved its sandbox (kept with the setting) or, without one, at + * the first use. Checkpoints, going back a turn, a fork's files and the + * end-of-turn git looks take it from here, never by the path alone: one + * moved aside and replaced (by a link to the home, or another directory) + * is refused, said and audited ({@link #workdirChanged}), never followed. + */ + #pinnedWorkdir(op: string): PinnedDir { + if (this.#sandbox) { + const pin = this.#sandbox.workdirPin(op); + if (this.#sandbox.takePinsChanged()) this.#saveMetaNow(); + return pin; + } + this.#ownWorkdirRecord ??= pinRecord(this.workdir); + return pinDir(this.workdir, "the working directory", this.#ownWorkdirRecord, op); + } + + /** + * The working directory as pinned, for a host-side use of it from outside + * the session (a client's file read, a fork's worktree and the files it + * carries, the project's MCP config): throws {@link PinnedPathError}, said + * on the session and audited, when it changed under the session. + */ + pinnedWorkdir(op: string): PinnedDir { + try { + return this.#pinnedWorkdir(op); + } catch (err) { + if (err instanceof PinnedPathError) this.#workdirChanged(err); + throw err; + } + } + + /** + * {@link #pinnedWorkdir}, or null when it changed: said on the session + * once per change (event `workdir.changed`) and audited + * (`session.workdir_changed`). + */ + #pinnedWorkdirOrSay(op: string): PinnedDir | null { + try { + const pin = this.#pinnedWorkdir(op); + this.#workdirChangeSaid = null; + return pin; + } catch (err) { + if (!(err instanceof PinnedPathError)) throw err; + this.#workdirChanged(err); + return null; + } + } + + /** A use of the working directory refused because it changed under the session: said once per change, audited every time. */ + #workdirChanged(err: PinnedPathError): void { + this.#store.audit(SYSTEM_IDENTITY.sub, "session.workdir_changed", this.id, err.message.slice(0, 2000)); + const said = `${err.path}: ${err.reason}`; + if (this.#workdirChangeSaid === said || this.#destroyed) return; + this.#workdirChangeSaid = said; + const note = this.#makeMessage("info", `⚠ ${err.message}.`, SYSTEM_IDENTITY, undefined, undefined, { event: "workdir.changed" }); + this.#persistAndBuffer(note); + this.#broadcastRaw(note); + } + /** * Snapshot the workspace for `turnId` (see checkpoints.ts). Waits at most * `waitMs` (0 = don't wait) so a slow snapshot on a huge tree delays the @@ -9795,13 +10461,16 @@ export class Session { ): Promise { if (!this.#checkpointsEnabled() || this.#destroyed) return; const cfg = this.#config?.session.checkpoints; + // Only of the working directory as pinned (#448 round 14): one an agent swapped for a link is never read. + const workdir = this.#pinnedWorkdirOrSay("a snapshot of the files"); + if (!workdir) return; let started = false; // Serialized: a turn's start snapshot waits for the previous turn's end // snapshot (within its wait budget) instead of being skipped. const job = this.#checkpointChain.then(() => createCheckpoint({ root: this.#checkpointRoot, - workdir: this.workdir, + workdir, sessionId: this.id, turnId, // Late = the agent may have been editing while it was taken: for a @@ -9815,6 +10484,8 @@ export class Session { ).then((r) => { if (!r.ok) { console.error(`[codeoid/session ${this.id.slice(0, 8)}] checkpoint skipped for turn ${turnId.slice(0, 8)}: ${r.reason}`); + // It changed while being read: said, as before it. + if (r.pinned) this.#pinnedWorkdirOrSay("a snapshot of the files"); } return r; }); @@ -10003,19 +10674,27 @@ export class Session { checkpointId: string, opts: { asEndOf?: string; within?: string } = {}, ): Promise<{ restored: number; removed: number }> { - // Only ever write inside the fork's own worktree, resolved — a symlinked - // folder must not carry the roll somewhere else. + // Only ever into the working directory as pinned (#448 round 14), never a + // link or another directory put in its place. + let workdir: PinnedDir; + try { + workdir = this.#pinnedWorkdir("putting a fork's files in place"); + } catch (err) { + if (err instanceof PinnedPathError) this.#workdirChanged(err); + throw err; + } + // And only inside the fork's own worktree: the pinned path is real; the + // worktree is codeoid's own, just made, resolved once to compare. if (opts.within) { - const real = (p: string) => { - try { - return realpathSync(p); - } catch { - return null; - } - }; - const root = real(opts.within); - const here = real(this.workdir); - if (!root || !here || (here !== root && !here.startsWith(`${root}/`))) { + let root: string | null = null; + try { + // paths: the fork's own worktree, just made by codeoid, resolved once to compare with the pinned workdir + root = realpathSync(opts.within); + } catch { + root = null; + } + const here = workdir.path; + if (!root || (here !== root && !here.startsWith(`${root}/`))) { throw new Error("this fork's folder isn't inside its own worktree"); } } @@ -10024,13 +10703,16 @@ export class Session { if (!rec) throw new Error("that snapshot isn't available to this session"); const now = await currentTree({ root: this.#checkpointRoot, - workdir: this.workdir, + workdir, sessionId: this.id, excludeDirs: this.#protectedDirs(), excludePaths: this.#setupPaths(), limits: this.#checkpointLimits(), }); - if (!now.ok) throw new Error(now.reason); + if (!now.ok) { + if (now.pinned) this.#pinnedWorkdirOrSay("putting a fork's files in place"); + throw new Error(now.reason); + } const changes = await diffTrees(this.#checkpointRoot, this.id, rec.sha, now.tree); if (changes.length === 0) return { restored: 0, removed: 0 }; const protectedHit = changes.find((c) => this.#isProtectedPath(c.path)); @@ -10040,11 +10722,14 @@ export class Session { const kept = new Set(keep); await restoreTree({ root: this.#checkpointRoot, - workdir: this.workdir, + workdir, sessionId: this.id, from: now.tree, to: rec.sha, ...(keep.length > 0 ? { keep } : {}), + }).catch((err) => { + if (err instanceof PinnedPathError) this.#workdirChanged(err); + throw err; }); return { restored: changes.filter((c) => c.status !== "A" && !kept.has(c.path)).length, @@ -10171,15 +10856,30 @@ export class Session { if (!start) { return { ...plan, planId: planHash([turnId, ...removedIds]), filesUnavailable: "there is no snapshot of the files from when that turn started" }; } + // The working directory as pinned (#448 round 14): one moved aside and replaced is never read or written. + let workdir: PinnedDir; + try { + workdir = this.#pinnedWorkdir("going back a turn"); + } catch (err) { + if (!(err instanceof PinnedPathError)) throw err; + this.#workdirChanged(err); + throw new RewindError("restore_failed", `${err.message}. Nothing was changed.`); + } const now = await currentTree({ root: this.#checkpointRoot, - workdir: this.workdir, + workdir, sessionId: this.id, excludeDirs: this.#protectedDirs(), excludePaths: this.#setupPaths(), limits: this.#checkpointLimits(), }); - if (!now.ok) return { ...plan, planId: planHash([turnId, ...removedIds]), filesUnavailable: `the current files couldn't be read: ${now.reason}` }; + if (!now.ok) { + if (now.pinned) { + this.#pinnedWorkdirOrSay("going back a turn"); + throw new RewindError("restore_failed", `${now.reason}. Nothing was changed.`); + } + return { ...plan, planId: planHash([turnId, ...removedIds]), filesUnavailable: `the current files couldn't be read: ${now.reason}` }; + } const changes = await diffTrees(this.#checkpointRoot, this.id, start.sha, now.tree); // Never delete a file the snapshot didn't hold because it was IGNORED @@ -10197,14 +10897,15 @@ export class Session { // Paths left alone because something else occupies them now (reported). const skipped: string[] = []; for (const c of changes) { - // Never through a symlinked parent (git wouldn't either; say so). - if (await hasSymlinkedParent(this.workdir, c.path)) { + // Never through a symlinked parent (git wouldn't either; say so). Looks in the pinned directory just + // checked (read only); the restore checks it again. + if (await hasSymlinkedParent(workdir.path, c.path)) { keep.add(c.path); skipped.push(c.path); continue; } if (c.status === "A") continue; - if (c.status === "D" && (await existsOnDisk(this.workdir, c.path))) { + if (c.status === "D" && (await existsOnDisk(workdir.path, c.path))) { keep.add(c.path); skipped.push(c.path); continue; @@ -10213,7 +10914,7 @@ export class Session { for (let k = 1; k < parts.length; k++) { const parent = parts.slice(0, k).join("/"); if (createdSet.has(parent)) break; - if (await isNonDirectory(this.workdir, parent)) { + if (await isNonDirectory(workdir.path, parent)) { keep.add(c.path); skipped.push(c.path); break; @@ -10346,10 +11047,12 @@ export class Session { const protectedHit = plan_.fileChanges.find((c) => this.#isProtectedPath(c.path)); if (protectedHit) throw new RewindError("restore_failed", `refusing to restore a path in codeoid's own data: ${protectedHit.path}`); try { + // Into the working directory as pinned (#448 round 14), checked again before and after the write. + const workdir = this.#pinnedWorkdir("going back a turn"); await this.#serializedCheckpointOp(() => restoreTree({ root: this.#checkpointRoot, - workdir: this.workdir, + workdir, sessionId: this.id, from: plan_.currentTreeSha!, to: plan_.checkpointSha!, @@ -10357,6 +11060,7 @@ export class Session { }), ); } catch (err) { + if (err instanceof PinnedPathError) this.#workdirChanged(err); this.#auditRewind(sender, turnId, plan_, `failed: ${err instanceof Error ? err.message : String(err)}`, opts.force); throw new RewindError("restore_failed", `The files couldn't be restored: ${err instanceof Error ? err.message : String(err)}. ${wasBusy ? "The running turn was stopped; the conversation was not changed." : "Nothing was changed."}`); } diff --git a/src/daemon/settings/manifest.ts b/src/daemon/settings/manifest.ts index 329b45e2..099309d9 100644 --- a/src/daemon/settings/manifest.ts +++ b/src/daemon/settings/manifest.ts @@ -238,6 +238,12 @@ const sandbox: SettingsTab = { envVar: "CODEOID_SANDBOX_WORKTREE_ROOTS", applies: "next-session", }), + cfg("sandbox.projectRoots", "Project roots (readable)", "Where sandboxed commands may read other repositories, read-only — and see what other sessions did in their checkouts there. @parent is the parent of the session's repository (e.g. ~/Workspace for a session in ~/Workspace/codeoid), the default. ~ expands. Credentials and secret files (.env, .npmrc, …) stay hidden inside. Writing elsewhere is per session: /sandbox add , or the agent asks (request_directory). Refused: /, your home or a dir holding it, a hidden dir of your home, ~/Library, codeoid's config dir, a system dir; empty: none.", { + kind: "string[]", + envVar: "CODEOID_SANDBOX_PROJECT_ROOTS", + default: ["@parent"], + applies: "next-session", + }), cfg("sandbox.docker.socket", "Docker access", "Let sandboxed commands reach the container runtime's socket (Docker, rootless Docker, Podman; Docker Desktop on macOS) — for tests that start containers (testcontainers). Docker access can reach the whole machine: a container can mount /. Sessions say \"sandboxed, Docker access\"; a session can turn it on for itself (asked of its owner).", { kind: "boolean", envVar: "CODEOID_SANDBOX_DOCKER_SOCKET", diff --git a/src/terminal/client.ts b/src/terminal/client.ts index 2773bcdb..050597e9 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -465,6 +465,22 @@ export class TerminalClient { if (line) console.log(data.pending ? ` now: ${line}` : line); } + /** Add a directory to what a session's commands write, or remove one (docs/sandbox.md, Working across repositories). */ + async sandboxDir(sessionIdOrName: string, action: "add" | "remove", path: string): Promise { + const sessionId = await this.#resolveSession(sessionIdOrName); + if (!sessionId) return; + const resp = await this.#request({ type: "session.sandbox_dir", id: randomUUID(), sessionId, action, path }); + if (resp.type !== "response.ok") { + this.#printError(resp); + return; + } + const data = resp.data as { pending?: boolean; message?: string; sandbox?: SessionSandboxInfo | null }; + if (data.pending) console.log(`Asked on the session: ${path} is writable once its owner says yes (codeoid approve ${sessionIdOrName}, or the web UI), from its next turn.`); + else if (data.message) console.log(data.message); + const line = sandboxSummary(data.sandbox ?? undefined); + if (line) console.log(` now: ${line}`); + } + /** The daemon's sandbox providers and whether each runs here (#448). */ async sandboxProviders(): Promise { const resp = await this.#request({ type: "sandbox.providers", id: randomUUID() }); diff --git a/src/tests/fixtures/claude-sandbox-probe.ts b/src/tests/fixtures/claude-sandbox-probe.ts index ab15bda0..a1493a4f 100644 --- a/src/tests/fixtures/claude-sandbox-probe.ts +++ b/src/tests/fixtures/claude-sandbox-probe.ts @@ -23,8 +23,11 @@ * offline, with an environment of its own) or not, and a second query after the first (a new * CLI start in the same workspace, the one that runs its `git status`); * the second one's system prompt is told as `system`. A workspace of the - * test's own: `CODEOID_PROBE_WORKSPACE`; the session's worktree folder - * name: `CODEOID_PROBE_WORKTREE_FOLDER`. Run in its own + * test's own: `CODEOID_PROBE_WORKSPACE`; the session's cwd below it + * (a monorepo's package; the CLI then loads the project's settings): + * `CODEOID_PROBE_WORKDIR`; the session's worktree folder + * name: `CODEOID_PROBE_WORKTREE_FOLDER`; its project roots and added dirs + * (comma-separated): `CODEOID_PROBE_PROJECT_ROOTS`, `CODEOID_PROBE_ADDED_DIRS`. Run in its own * process: a test file that mocks the SDK module must not see this one. * canUseTool here allows a sandboxed call and DENIES one asking to run * outside the sandbox, as codeoid's gate would until its owner says yes. @@ -36,6 +39,7 @@ import { type ClaudeTempDirs, claudeNativeEnv, claudeSandboxSettings, claudeTemp import { BwrapSandboxProvider } from "../../daemon/sandbox/bwrap.js"; import { CWD_FROM_CALLER } from "../../daemon/sandbox/docker.js"; import { cliWorktrees, gitProgramLocations, gitShimSpec, writeGitShim } from "../../daemon/sandbox/git-shim.js"; +import { unguardedArgv } from "../../daemon/sandbox/pinned.js"; import { buildSandboxSpec } from "../../daemon/sandbox/policy.js"; import { daemonTempDir } from "../../daemon/sandbox/root.js"; import { launcherScript } from "../../daemon/sandbox/session-sandbox.js"; @@ -115,6 +119,9 @@ const spec = policy ? buildSandboxSpec({ sessionId: "probe", workspace, + // CODEOID_PROBE_WORKDIR: the session's cwd below the workspace (a monorepo's package), which the CLI starts in + // with the project's settings (a new Claude start there loads them). + ...(process.env.CODEOID_PROBE_WORKDIR ? { workdir: process.env.CODEOID_PROBE_WORKDIR } : {}), tempDir: join(root, "tmp"), network: network === "allowlist" ? { mode: network, allow: [allowHost] } : { mode: network }, config: { sandbox: config } as never, @@ -124,6 +131,9 @@ const spec = policy dockerSocket: process.env.CODEOID_PROBE_DOCKER_SOCKET === "1", // CODEOID_PROBE_WORKTREE_FOLDER: the session's worktree folder name (sandbox/worktrees.ts). ...(process.env.CODEOID_PROBE_WORKTREE_FOLDER ? { worktreeFolder: process.env.CODEOID_PROBE_WORKTREE_FOLDER } : {}), + // CODEOID_PROBE_PROJECT_ROOTS / CODEOID_PROBE_ADDED_DIRS (comma-separated): working across repositories. + ...(process.env.CODEOID_PROBE_PROJECT_ROOTS ? { projectRoots: process.env.CODEOID_PROBE_PROJECT_ROOTS.split(",") } : {}), + ...(process.env.CODEOID_PROBE_ADDED_DIRS ? { addedDirs: process.env.CODEOID_PROBE_ADDED_DIRS.split(",").map((dir) => ({ dir })) } : {}), }) : { sessionId: "probe", @@ -153,7 +163,7 @@ function gitShim(): string { if (!wrapped) throw new Error("no wrap"); const launcher = join(dir, "launch-git"); writeFileSync(launcher, launcherScript(wrapped.argv), { mode: 0o700 }); - return writeGitShim(join(dir, "git-bin"), { launcher, provider: wrapped.argv[0] ?? null, caller: "the Claude CLI", home }); + return writeGitShim(join(dir, "git-bin"), { launcher, provider: unguardedArgv(wrapped.argv)[0] ?? null, caller: "the Claude CLI", home }); } const cliEnv = nativeEnv ? claudeNativeEnv(spec as never, temp, { ...env, SANDBOX_RUNTIME: "1" }, gitMode === "shim" ? gitShim() : undefined) : env; // A sign-in started after the session's sandbox was built (#448 round 2): its home where the daemon makes it, the @@ -169,12 +179,12 @@ async function run(): Promise { for await (const m of query({ prompt: "run the probe", options: { - cwd: workspace, + cwd: process.env.CODEOID_PROBE_WORKDIR || workspace, env: cliEnv, sandbox, ...(nativeEnv ? { mcpServers: { probe: { type: "stdio" as const, command: process.execPath, args: [join(import.meta.dir, "env-dump.ts"), join(root, "mcp-env.json")] } } } : {}), permissionMode: "default", - settingSources: [], + settingSources: process.env.CODEOID_PROBE_WORKDIR ? ["project", "user"] : [], // The preset's context carries the CLI's git status (what its own git found). ...(gitMode ? { systemPrompt: { type: "preset" as const, preset: "claude_code" as const } } : {}), canUseTool: async (name, input) => { diff --git a/src/tests/fixtures/sandbox-manager-harness.ts b/src/tests/fixtures/sandbox-manager-harness.ts new file mode 100644 index 00000000..bb3a4a45 --- /dev/null +++ b/src/tests/fixtures/sandbox-manager-harness.ts @@ -0,0 +1,244 @@ +/** + * A real SessionManager with a scriptable mock Claude backend and a fake + * bubblewrap provider, over a scratch home with `projects/{work,sibling}` + * (each a repository with a commit; the sibling has a `.env`): what the + * #448 round-13 manager tests share. Call {@link sandboxManagerHarness} at a + * test file's top level; it registers its own beforeEach/afterEach. + */ + +import { afterEach, beforeEach, expect } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { CodeoidConfig } from "../../config.js"; +import type { ProviderEvent } from "../../daemon/providers/interface.js"; +import { MockSessionProvider } from "../../daemon/providers/mock/session-provider.js"; +import { ProviderRegistry } from "../../daemon/providers/registry.js"; +import { LocalSandboxHandle } from "../../daemon/sandbox/local-handle.js"; +import { SandboxRegistry } from "../../daemon/sandbox/registry.js"; +import type { SandboxProvider, SandboxSpec } from "../../daemon/sandbox/types.js"; +import type { Session } from "../../daemon/session.js"; +import { SessionManager } from "../../daemon/session-manager.js"; +import { Store } from "../../daemon/store.js"; +import { TranscriptStore } from "../../daemon/transcript.js"; +import { ALL_SCOPES } from "../../protocol/scopes.js"; +import { type AuthContext, CAPABILITIES, type DaemonMessage, type SessionInfo } from "../../protocol/types.js"; + +export const AUTH: AuthContext = { sub: "user:r13", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc", projectId: "proj" }; +export const CLIENT = { id: "cli", auth: AUTH, send: () => {} }; +export const DONE: ProviderEvent = { type: "turn_done", result: { providerId: "claude", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } }; +/** A Bash call (the gate is asked about it), with ids of its own. */ +export const BASH_CALL = (id: string): ProviderEvent => ({ type: "tool_start", toolId: `t-${id}`, sdkToolUseId: `sdk-${id}`, name: "Bash", input: { command: "ls" }, approvalId: `ap-${id}` }); +/** A turn: a Bash call, then done. */ +export const BASH = (n: number): ProviderEvent[] => [BASH_CALL(String(n)), DONE]; + +export const infoOf = (r: DaemonMessage) => (r as { data?: SessionInfo }).data; + +export async function until(pred: () => boolean): Promise { + for (let i = 0; i < 300 && !pred(); i++) await Bun.sleep(10); +} + +const GIT_ENV = (home: string) => ({ PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@e.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@e.invalid" }); +export function git(args: string[], cwd: string): void { + const r = Bun.spawnSync(["git", ...args], { cwd, env: GIT_ENV(cwd) }); + if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`); +} + +export interface SandboxManagerHarness { + /** The scratch dir (projects/, home/, data/ in it). */ + readonly tmp: string; + /** `projects`: the default project root. */ + readonly root: string; + /** `projects/work`: where sessions are created. */ + readonly workdir: string; + /** `projects/sibling`: another repository under the root, with a `.env`. */ + readonly sibling: string; + readonly transcript: TranscriptStore; + /** Every audit entry so far. */ + readonly audits: Array<{ action: string; detail: string }>; + /** The turns the next backend made runs (else a Bash call per turn). */ + script: ProviderEvent[][] | null; + newManager(): SessionManager; + create(m: SessionManager, extra?: Record): Promise; + /** A session in autonomous mode, its sandbox resolved. */ + autonomous(m: SessionManager, extra?: Record): Promise<{ info: SessionInfo | undefined; session: Session }>; + /** Every confirm raised on the session, as a client with dialogs sees it. */ + dialogs(m: SessionManager, name: string): Array<{ requestId: string; title: string; message: string }>; + /** Every info line said on the session from now on. */ + infoLines(m: SessionManager, name: string): string[]; + /** Every gate decision the backends made so far saw (a directory change restarts the backend: a new one). */ + decisions(): Array<{ behavior: string; message?: string }>; + /** The spec the last backend made would run its commands under now (throws when it can't be built). */ + runtimeSpec(): SandboxSpec | null; + /** Send once the backend's restart for a sandbox change is done (a send right after it is refused until then). */ + sendWhenReady(session: Session, text: string): Promise; +} + +/** Register the harness's setup and teardown, and hand back what the tests use. */ +export function sandboxManagerHarness(): SandboxManagerHarness { + let tmp = ""; + let store: Store | null = null; + let transcript: TranscriptStore | null = null; + let audits: Array<{ action: string; detail: string }> = []; + let providers: MockSessionProvider[] = []; + let runtimes: Array<() => SandboxSpec | null> = []; + const realHome = process.env.HOME; + + const config = (): CodeoidConfig => + ({ + daemonUrl: "ws://127.0.0.1:7400", + dbPath: join(tmp, "data", "codeoid.db"), + transcriptDir: join(tmp, "data", "transcripts"), + auth: { baseUrl: "http://localhost:8899" }, + zeroidUrl: "http://localhost:8899", + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session: {}, + conductor: { enabled: false, name: "conductor", provider: "claude" }, + }) as CodeoidConfig; + + const fake = (): SandboxProvider => { + const open = (spec: SandboxSpec) => new LocalSandboxHandle("bwrap", spec, (argv, o) => ({ argv: [...argv], env: o.env })); + return { + id: "bwrap", + displayName: "fake-bwrap", + capabilities: { isolation: "os", placement: "local", workspace: "mount", stdioProcess: true, commands: true, wrapsArgv: true, network: ["on", "off"], platforms: ["linux"] }, + probe: async () => ({ available: true }), + open: async (s) => open(s), + openSync: open, + }; + }; + + const h: SandboxManagerHarness = { + get tmp() { + return tmp; + }, + get root() { + return join(tmp, "projects"); + }, + get workdir() { + return join(tmp, "projects", "work"); + }, + get sibling() { + return join(tmp, "projects", "sibling"); + }, + get transcript() { + return transcript as TranscriptStore; + }, + get audits() { + return audits; + }, + script: null, + newManager() { + const registry = new ProviderRegistry("claude"); + registry.register({ + id: "claude", + displayName: "claude", + create: (init) => { + // Ids of their own per backend: one made after a restart isn't taken for a call already seen. + const n = providers.length * 10; + const p = new MockSessionProvider("claude", h.script ?? [BASH(n + 1), BASH(n + 2), BASH(n + 3), BASH(n + 4)]); + h.script = null; + providers.push(p); + // The spec the backend's commands run under (Claude's own sandbox is given it): what the tests read. + runtimes.push(() => init.sandbox?.()?.spec ?? null); + return p; + }, + }); + const sandboxRegistry = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`); + sandboxRegistry.register(fake()); + return new SessionManager(store as Store, transcript as TranscriptStore, undefined, undefined, undefined, { config: config(), providers: registry, sandboxRegistry }); + }, + create(m, extra = {}) { + return m.handle({ type: "session.create", id: "c", name: `s-${Math.random().toString(36).slice(2, 8)}`, workdir: h.workdir, ...extra } as never, AUTH, CLIENT); + }, + async autonomous(m, extra = {}) { + const info = infoOf(await h.create(m, extra)); + expect(await m.handle({ type: "session.set_mode", id: "m", sessionId: info?.id as string, mode: "autonomous" }, AUTH, CLIENT)).toMatchObject({ type: "response.ok" }); + const session = m.findByName(info?.name as string, AUTH); + if (!session) throw new Error("no session"); + await session.ensureSandbox(); + return { info, session }; + }, + dialogs(m, name) { + const asked: Array<{ requestId: string; title: string; message: string }> = []; + m.findByName(name, AUTH)?.attach({ + id: `dialogs-${Math.random()}`, + auth: AUTH, + capabilities: [CAPABILITIES.UI_DIALOGS], + send: (msg: { type: string; requestId?: string; title?: string; message?: string }) => { + if (msg.type === "session.ui_request") asked.push({ requestId: msg.requestId ?? "", title: msg.title ?? "", message: msg.message ?? "" }); + }, + } as never); + return asked; + }, + infoLines(m, name) { + const lines: string[] = []; + m.findByName(name, AUTH)?.attach({ id: `info-${Math.random()}`, auth: AUTH, send: (msg: { type: string; role?: string; content?: string }) => void (msg.type === "session.message" && msg.role === "info" && lines.push(String(msg.content))) } as never); + return lines; + }, + decisions() { + return providers.flatMap((p) => p.canUseToolResults as Array<{ behavior: string; message?: string }>); + }, + runtimeSpec() { + return runtimes.at(-1)?.() ?? null; + }, + async sendWhenReady(session, text) { + for (let i = 0; ; i++) { + try { + await session.send(text, AUTH); + return; + } catch (err) { + if (i > 100 || !String(err).includes("previous sandbox")) throw err; + await Bun.sleep(20); + } + } + }, + }; + + beforeEach(() => { + providers = []; + runtimes = []; + h.script = null; + const parent = process.env.CODEOID_TEST_SANDBOX_DIR ?? join(homedir(), ".cache"); + mkdirSync(parent, { recursive: true }); + tmp = realpathSync(mkdtempSync(join(parent, "codeoid-sbx-r13-"))); + process.env.HOME = join(tmp, "home"); + mkdirSync(process.env.HOME); + for (const d of [h.workdir, h.sibling]) { + mkdirSync(d, { recursive: true }); + git(["init", "-q", "-b", "main"], d); + writeFileSync(join(d, "README.md"), "x\n"); + git(["add", "."], d); + git(["commit", "-qm", "i"], d); + } + // The sibling's credentials: hidden in the root. + writeFileSync(join(h.sibling, ".env"), "SIBLING_SECRET=1\n"); + mkdirSync(join(tmp, "data")); + store = new Store(join(tmp, "data", "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "data", "transcripts")); + audits = []; + const s = store; + const audit = s.audit.bind(s); + s.audit = (subject, action, sessionId, detail) => { + audits.push({ action, detail: detail ?? "" }); + audit(subject, action, sessionId, detail); + }; + }); + + afterEach(async () => { + try { + await transcript?.flush(); + } catch {} + try { + store?.close(); + } catch {} + process.env.HOME = realHome; + rmSync(tmp, { recursive: true, force: true }); + }); + + return h; +} diff --git a/src/tests/pinned-paths-static.test.ts b/src/tests/pinned-paths-static.test.ts new file mode 100644 index 00000000..ae249f5f --- /dev/null +++ b/src/tests/pinned-paths-static.test.ts @@ -0,0 +1,123 @@ +/** + * A static check (#448 round 14, docs/sandbox.md "Pinned paths"): in the + * sandbox, session and checkpoint modules, nothing resolves or uses a path + * on the host directly (a `node:fs` call, a program spawned, a `cwd:`, a + * `GIT_WORK_TREE`) but the pinned-path API (sandbox/pinned.ts), unless a + * `// paths: ` comment says why that use is safe: the path is the + * daemon's own (its private dir, the shadow repository), the user's that no + * sandbox writes, already checked through the API, or only looked at. + * + * A comment covers its own line and the statement or block that follows it + * (to the line its braces close on). A new direct use without one fails + * here: route it through pinned.ts, or say why it is safe. + */ + +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const DAEMON = join(import.meta.dir, "..", "daemon"); +const API = join(DAEMON, "sandbox", "pinned.ts"); +const FILES = [...readdirSync(join(DAEMON, "sandbox")).filter((f) => f.endsWith(".ts")).map((f) => join(DAEMON, "sandbox", f)), join(DAEMON, "session.ts"), join(DAEMON, "checkpoints.ts")].filter((f) => f !== API); +const MARK = /\/\/\s*paths:\s*\S/; + +/** The names a file imports from node:fs and node:fs/promises (calls to them are direct uses). */ +function fsNames(src: string): string[] { + const out = new Set(); + for (const m of src.matchAll(/import\s*\{([^}]*)\}\s*from\s*"node:fs(?:\/promises)?"/g)) { + for (const part of (m[1] ?? "").split(",")) { + const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop()?.trim(); + if (name && name !== "constants" && !part.trim().startsWith("type ")) out.add(name); + } + } + for (const m of src.matchAll(/import\s+(?:\*\s+as\s+)?(\w+)\s+from\s*"node:fs(?:\/promises)?"/g)) out.add(`${m[1]}\\.\\w+`); + return [...out]; +} + +/** + * Lines (0-based) a `// paths:` comment covers: its own, and the paragraph + * after it (the code lines that follow, to a blank line, a new comment or a + * line less indented than it), with whatever its brackets hold open. + */ +function covered(lines: readonly string[]): Set { + const out = new Set(); + const indent = (l: string) => l.length - l.trimStart().length; + const isComment = (l: string) => /^\s*(\/\/|\*|\/\*)/.test(l); + lines.forEach((line, i) => { + if (!MARK.test(line)) return; + out.add(i); + // A trailing comment covers its line only. + if (!isComment(line)) return; + const at = indent(line); + let depth = 0; + let j = i + 1; + // The comment's own following lines. + while (j < lines.length && isComment(lines[j] ?? "")) out.add(j++); + for (; j < lines.length; j++) { + const text = lines[j] ?? ""; + out.add(j); + const code = text.replace(/\/\/.*$/, "").replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""'); + for (const ch of code) { + if (ch === "{" || ch === "(" || ch === "[") depth++; + else if (ch === "}" || ch === ")" || ch === "]") depth--; + } + if (depth > 0) continue; + const next = lines[j + 1]; + if (next === undefined || next.trim() === "" || isComment(next) || indent(next) < at) break; + } + }); + return out; +} + +/** Each direct use in `file` no comment covers: `file:line: text`. */ +function uncovered(file: string): string[] { + const src = readFileSync(file, "utf8"); + const names = fsNames(src); + const uses = new RegExp( + [ + ...(names.length > 0 ? [`\\b(?:${names.join("|")})\\(`] : []), + // A cwd given to a program (not a parameter's type). + "\\bcwd:\\s(?!string\\b)", + "\\bGIT_WORK_TREE\\b", + // node:child_process's, called (a handle's own `spawn` wraps its argv in the sandbox; a declaration names its parameters' types). + "(? { + const t = line.trim(); + if (t.startsWith("/*")) inBlockComment = !t.includes("*/"); + else if (inBlockComment) { + if (t.includes("*/")) inBlockComment = false; + return; + } + if (t.startsWith("//") || t.startsWith("*") || t.startsWith("import ")) return; + if (uses.test(line.replace(/\/\/.*$/, "")) && !ok.has(i)) out.push(`${relative(join(DAEMON, ".."), file)}:${i + 1}: ${t.slice(0, 140)}`); + }); + return out; +} + +describe("no session path is used but through the pinned-path API (#448 round 14)", () => { + test("every direct file-system use, spawn, cwd and GIT_WORK_TREE in the sandbox, session and checkpoint modules is pinned.ts's or says why it is safe", () => { + const found = FILES.flatMap(uncovered); + expect(found).toEqual([]); + }); + + test("the check itself: an unmarked use is found, a marked one isn't", () => { + const lines = ["function f(p: string) {", " // paths: the daemon's own dir", " mkdirSync(p);", "", " rmSync(p);", "}", "rmSync(q); // paths: a look", "rmSync(r);"]; + const ok = covered(lines); + expect(ok.has(2)).toBe(true); + expect(ok.has(4)).toBe(false); + expect(ok.has(6)).toBe(true); + expect(ok.has(7)).toBe(false); + // A comment above a block covers the block. + const block = ["// paths: the shadow repository", "async function g() {", " await rm(a);", "", " await rm(b);", "}", "", "await rm(c);"]; + const okBlock = covered(block); + expect([2, 4].every((i) => okBlock.has(i))).toBe(true); + expect(okBlock.has(7)).toBe(false); + }); +}); 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-agent-grants-manager.test.ts b/src/tests/sandbox-agent-grants-manager.test.ts new file mode 100644 index 00000000..b1b6219d --- /dev/null +++ b/src/tests/sandbox-agent-grants-manager.test.ts @@ -0,0 +1,138 @@ +/** + * #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/); + }); +}); + +describe("an agent's own grant never shares a directory with another session made there later (#448 round 14)", () => { + it("a session created in the agent's dir afterwards: the dir is let go at the next turn (said, audited), its spec no longer writes it", async () => { + const m = t.newManager(); + const { info, session } = await t.autonomous(m); + expect(await session.requestDirectory({ path: t.sibling, reason: "fix the client too" })).toContain(`Added: ${t.sibling}`); + await session.send("go", AUTH); + await until(() => session.toInfo().status === "idle"); + expect(t.runtimeSpec()?.writable).toContain(t.sibling); + const lines = t.infoLines(m, info?.name as string); + // The owner starts another session there: its commands write the sibling too. + const other = infoOf(await m.handle({ type: "session.create", id: "o", name: "infra", workdir: t.sibling } as never, AUTH, CLIENT)); + expect(other?.id).toBeTruthy(); + await t.sendWhenReady(session, "again"); + await until(() => session.toInfo().status === "idle"); + expect(session.sandboxSetting?.addedDirs).toBeUndefined(); + expect(session.sandboxSetting?.agentDirs).toBeUndefined(); + expect(session.toInfo().sandbox?.addedDirs).toBeUndefined(); + expect(t.runtimeSpec()?.writable).not.toContain(t.sibling); + expect(lines.some((l) => l.includes(`${t.sibling}, which the agent added, is no longer writable: ${t.sibling} is session "infra"'s workspace`))).toBe(true); + expect(t.audits.some((a) => a.action === "session.sandbox_dir_dropped" && a.detail.includes(`session "infra"'s workspace`))).toBe(true); + }); +}); 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..3faec08d --- /dev/null +++ b/src/tests/sandbox-agent-grants.test.ts @@ -0,0 +1,114 @@ +/** + * 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(`${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"); + }); +}); diff --git a/src/tests/sandbox-bound-pinned-real.test.ts b/src/tests/sandbox-bound-pinned-real.test.ts new file mode 100644 index 00000000..6dee648c --- /dev/null +++ b/src/tests/sandbox-bound-pinned-real.test.ts @@ -0,0 +1,146 @@ +/** + * Real bubblewrap, skipped where it can't run (#448 round 14): every path a + * sandbox binds by name is pinned and bound as pinned, never resolved again + * at a launch. A dir another session's command swaps for a link (a dir held + * in place in this session's spec, a path held read-only there) is refused at + * this session's next command, by the handle and by a launcher written + * before, never bound where the link leads: the daemon's private dir with + * every session's launchers, another repository (findings-r 1). + * + * Every swap from inside runs inside the sandbox under test (the other + * session's own); every swap from outside is the test's own, on the host, + * against directories it planted in its own scratch dir. Nothing else is read + * or written. + */ +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/pinned.js"; +import { buildSandboxSpec, type SpecInputs } from "../daemon/sandbox/policy.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 repo(dir: string, home: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "README.md"), "readme\n"); + for (const args of [["init", "-q", "-b", "main"], ["add", "."], ["commit", "-qm", "init"]]) { + const r = Bun.spawnSync(["git", "-c", "core.hooksPath=/dev/null", ...args], { cwd: dir, env: gitEnv(home) }); + if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`); + } +} + +const PLANTED_SETTINGS = JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: "command", command: "true" }] }] } }); + +describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: what a sandbox binds by name is never followed (#448 round 14)", () => { + let base: string; + beforeAll(() => { + base = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-held-"))); + }); + 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 }); + + function fixture(name: string) { + const dir = join(base, name); + const home = join(dir, "home"); + const W = join(home, "Workspace"); + const tmp = join(dir, "tmp"); + mkdirSync(tmp, { recursive: true }); + // What a link could lead to: a planted "home" (its key, its shell startup file) and a planted daemon dir. + 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 daemonDir = join(dir, "daemon-private"); + mkdirSync(join(daemonDir, "launchers"), { recursive: true }); + writeFileSync(join(daemonDir, "launchers", "launch-x"), "#!/bin/sh\necho original\n"); + const planted = join(dir, "planted-settings.json"); + writeFileSync(planted, PLANTED_SETTINGS); + const hidden = { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: daemonDir }; + const spec = (inputs: Partial & { workspace: string }) => buildSandboxSpec({ sessionId: name, tempDir: tmp, network: { mode: "off" }, hidden, cache: false, ...inputs }); + return { dir, home, W, tmp, fakeHome, daemonDir, planted, spec, env: gitEnv(home) }; + } + const intact = (f: { fakeHome: string; daemonDir: string }) => + readFileSync(join(f.fakeHome, ".bashrc"), "utf8") === "# planted\n" && readFileSync(join(f.daemonDir, "launchers", "launch-x"), "utf8") === "#!/bin/sh\necho original\n"; + + test("another session swaps a dir held in place in this one's spec for a link (to the daemon's private dir, to elsewhere): this one's next command is refused, by the handle and by a launcher written before; nothing bound there", async () => { + for (const where of ["daemon", "other"] as const) { + const f = fixture(`held-${where}`); + const a = join(f.W, "a"); + const infra = join(f.W, "infra"); + repo(a, f.home); + repo(infra, f.home); + mkdirSync(join(infra, "sub")); + writeFileSync(join(infra, "sub", ".env"), "SECRET=PLANTED-INFRA-ENV\n"); + const other = join(f.dir, "other-repo"); + mkdirSync(other); + const target = where === "daemon" ? f.daemonDir : other; + // A: the agent added infra itself (its .env hidden, so infra/sub held in place). + const specA = f.spec({ workspace: a, projectRoots: [f.W], addedDirs: [{ dir: infra, agent: true }] }); + expect(specA.pinned).toContain(join(infra, "sub")); + const hA = await provider.open(specA); + // B: the owner's session in infra (nothing of it hidden there). + const hB = await provider.open(f.spec({ workspace: infra, projectRoots: [] })); + try { + expect((await sh(hA, a, `cat ${infra}/sub/.env 2>&1; echo read-done`, f.env)).stdout).not.toContain("PLANTED-INFRA-ENV"); + const launcher = join(f.tmp, "launch-a"); + writeFileSync(launcher, launcherScript(hA.wrap?.(["/bin/sh", "-c"], { cwd: "", env: {} }).argv ?? []), { mode: 0o700 }); + // B's own command, in B's own sandbox: allowed there. + expect((await sh(hB, infra, `mv sub sub0 && ln -s ${target} sub && echo SWAPPED`, f.env)).stdout).toContain("SWAPPED"); + const attempt = `echo PWNED > ${infra}/sub/launchers/launch-x 2>&1; echo t > ${infra}/sub/probe && echo WROTE; cat ${infra}/sub0/.env 2>&1; echo RAN`; + await expect(sh(hA, a, attempt, f.env)).rejects.toThrow(`a directory held in place ${join(infra, "sub")} is reached through a symbolic link now`); + const r = Bun.spawnSync([launcher, attempt], { cwd: a, env: f.env, stdout: "pipe", stderr: "pipe" }); + expect(r.exitCode).toBe(126); + expect(r.stdout.toString()).not.toContain("RAN"); + expect(existsSync(join(target, "probe"))).toBe(false); + expect(intact(f)).toBe(true); + } finally { + await hA.close(); + await hB.close(); + } + } + }); + + test("another session swaps a path held read-only in this one's spec for a link: refused, never bound where it leads", async () => { + const f = fixture("held-ro"); + const a = join(f.W, "a"); + const infra = join(f.W, "infra"); + repo(a, f.home); + repo(infra, f.home); + const specA = f.spec({ workspace: a, addedDirs: [{ dir: infra, agent: true }] }); + expect(specA.readOnly).toContain(join(infra, ".claude")); + const hA = await provider.open(specA); + try { + expect((await sh(hA, a, "echo ok", f.env)).stdout).toBe("ok\n"); + // Outside A's sandbox (another session's, or the host): its held .claude swapped for a link to the daemon's dir. + renameSync(join(infra, ".claude"), join(infra, ".claude-old")); + symlinkSync(f.daemonDir, join(infra, ".claude")); + await expect(sh(hA, a, `ls ${f.daemonDir} 2>&1; echo RAN`, f.env)).rejects.toThrow(SandboxPinError); + await expect(sh(hA, a, "true", f.env)).rejects.toThrow(`a path held read-only ${join(infra, ".claude")} is reached through a symbolic link now`); + expect(intact(f)).toBe(true); + } finally { + await hA.close(); + } + }); +}); diff --git a/src/tests/sandbox-bound-pinned.test.ts b/src/tests/sandbox-bound-pinned.test.ts new file mode 100644 index 00000000..3b45359f --- /dev/null +++ b/src/tests/sandbox-bound-pinned.test.ts @@ -0,0 +1,89 @@ +/** + * Every path a spec binds is bound as pinned (#448 round 14, + * sandbox/pinned.ts): bubblewrap, Docker and Seatbelt name it as it was + * pinned, never where a link put there since leads; the guard a launched argv + * carries checks held files and directories, with one `stat`. + */ + +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bwrapArgs } from "../daemon/sandbox/bwrap.js"; +import { dockerRunArgs } from "../daemon/sandbox/docker.js"; +import { pinGuardArgv, pinOf } from "../daemon/sandbox/pinned.js"; +import { seatbeltProfile } from "../daemon/sandbox/seatbelt.js"; +import type { SandboxSpec } from "../daemon/sandbox/types.js"; + +const made: string[] = []; +function scratch(): string { + const d = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-pinned-"))); + made.push(d); + return d; +} +afterAll(() => { + for (const d of made) rmSync(d, { recursive: true, force: true }); +}); + +describe("every path a spec binds is bound as pinned (#448 round 14)", () => { + test("bubblewrap, Docker and Seatbelt bind a pinned path as it was pinned, never where a link put there since leads", () => { + const d = scratch(); + const ws = join(d, "ws"); + const held = join(ws, "sub"); + const target = join(d, "daemon-private"); + mkdirSync(ws); + mkdirSync(target); + // Pinned while real, swapped for a link afterwards (the handle refuses that; the argv still never resolves it). + symlinkSync(target, held); + const spec = { + sessionId: "s", + workspace: ws, + tempDir: join(d, "tmp"), + writable: [], + hidden: [], + readOnly: [], + pinned: [held], + reexposed: [], + containerSockets: [], + network: { mode: "off" }, + unsetEnv: [], + pins: [pinOf(ws, "the workspace"), { path: held, what: "a directory held in place", id: "1:2" }], + } as unknown as SandboxSpec; + const args = bwrapArgs(spec, ws, (p) => (p === held || p === target || p === ws ? "dir" : null)); + expect(args.join(" ")).toContain(`--bind ${held} ${held}`); + expect(args).not.toContain(target); + const docker = dockerRunArgs(spec, ["true"], { image: "x", cwd: ws, envNames: [], uid: 1, gid: 1, kind: (p) => (p === held || p === ws ? "dir" : null) }); + expect(docker.join(" ")).toContain(`source=${held}`); + expect(docker.join(" ")).not.toContain(target); + const sb = seatbeltProfile(spec, { real: (p) => (p === held ? target : p), isDir: () => true }); + expect(Object.values(sb.params)).toContain(held); + expect(Object.values(sb.params)).not.toContain(target); + }); + + test("the launch guard: a held file swapped for a link, a dir replaced: refused (126) with one stat; a missing optional one passes", () => { + const d = scratch(); + const ws = join(d, "ws"); + mkdirSync(join(ws, ".claude"), { recursive: true }); + writeFileSync(join(ws, ".mcp.json"), "{}\n"); + const pins = [pinOf(ws, "the workspace"), pinOf(join(ws, ".claude"), "a path held read-only"), pinOf(join(ws, ".mcp.json"), "a path held read-only"), pinOf(join(ws, "folder"), "worktree folder", { mayBeMissing: true, noId: true })]; + expect(pins[2]).toMatchObject({ file: true }); + const argv = pinGuardArgv(pins, ["/bin/sh", "-c", "echo RAN"]); + const run = () => Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe" }); + expect(run().stdout.toString()).toBe("RAN\n"); + // The held file swapped for a link. + renameSync(join(ws, ".mcp.json"), join(d, "mcp-old.json")); + symlinkSync(join(d, "mcp-old.json"), join(ws, ".mcp.json")); + let r = run(); + expect(r.exitCode).toBe(126); + expect(r.stderr.toString()).toContain("a symbolic link is in its path"); + rmSync(join(ws, ".mcp.json")); + writeFileSync(join(ws, ".mcp.json"), "{}\n"); + expect(run().exitCode).toBe(0); + // The held dir replaced by another. + renameSync(join(ws, ".claude"), join(d, "claude-old")); + mkdirSync(join(ws, ".claude")); + r = run(); + expect(r.exitCode).toBe(126); + expect(r.stderr.toString()).toContain("another one is there now"); + }); +}); diff --git a/src/tests/sandbox-claude-real.test.ts b/src/tests/sandbox-claude-real.test.ts index e1968122..ddf9ea0e 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"; @@ -363,6 +363,158 @@ 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); + 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); + + 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); + /** A repository at `dir`, one commit, made on the host with no hook and no global config. */ + function repo(dir: string, home: string): void { + mkdirSync(dir, { recursive: true }); + const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home, GIT_CONFIG_NOSYSTEM: "1" }; + const g = (args: string[]) => Bun.spawnSync(["git", "-c", "core.hooksPath=/dev/null", "-c", "user.name=t", "-c", "user.email=t@example.invalid", ...args], { cwd: dir, env }); + writeFileSync(join(dir, "README.md"), "readme\n"); + g(["init", "-q", "-b", "main"]); + g(["add", "-A"]); + g(["commit", "-qm", "init"]); + } + + test("held in place on Claude's own sandbox (#448 round 14): a monorepo package session can't move its package dir, nor the dir above it, to plant the package's project settings", async () => { + const home = join(root, "home"); + const top = join(home, "Workspace", "mono14"); + const pkg = join(top, "packages", "app"); + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(pkg, "index.js"), "1\n"); + repo(top, home); + const command = [ + `cd ${join(top, "packages")} && mv app app-old 2>&1; echo mv-pkg=$?`, + `cd ${top} && mv packages packages-old 2>&1; echo mv-parent=$?`, + `echo '{"hooks":{}}' > ${join(pkg, ".claude", "settings.json")} 2>&1 || echo SETTINGS-HELD`, + `echo w > ${join(pkg, "own.txt")} && echo PKG-WRITABLE`, + ].join("; "); + const r = await probe("off", command, ["policy"], { CODEOID_PROBE_WORKSPACE: top, CODEOID_PROBE_WORKDIR: pkg }); + expect(r.result).toBe("success"); + expect(r.toolResult).toContain("mv-pkg=1"); + expect(r.toolResult).toContain("mv-parent=1"); + expect(r.toolResult).toContain("Device or resource busy"); + expect(r.toolResult).toContain("SETTINGS-HELD"); + expect(r.toolResult).toContain("PKG-WRITABLE"); + expect(existsSync(join(top, "packages", "app-old"))).toBe(false); + expect(existsSync(join(top, "packages-old"))).toBe(false); + expect(existsSync(join(pkg, ".claude", "settings.json"))).toBe(false); + }, 300_000); + + test("held in place on Claude's own sandbox (#448 round 14): a repository inside an added dir can't be moved aside to plant its project settings; a workspace inside an added dir can't have the dir between them moved", async () => { + const home = join(root, "home"); + const W = join(home, "Workspace"); + const ws = join(W, "a14"); + const b = join(W, "b14"); + repo(ws, home); + repo(join(b, "lib"), home); + const nested = await probe( + "off", + [`cd ${b} && mv lib lib-old 2>&1; echo mv-lib=$?`, `echo x > ${join(b, "lib", "new.txt")} && echo LIB-WRITABLE`].join("; "), + ["policy"], + { CODEOID_PROBE_WORKSPACE: ws, CODEOID_PROBE_ADDED_DIRS: b }, + ); + expect(nested.result).toBe("success"); + expect(nested.toolResult).toContain("mv-lib=1"); + expect(nested.toolResult).toContain("LIB-WRITABLE"); + expect(existsSync(join(b, "lib-old"))).toBe(false); + // A workspace inside an owner-added dir, two levels down: the dir between them stays (findings-s L1). + const A = join(W, "outer14"); + const inner = join(A, "mid", "ws"); + repo(inner, home); + const between = await probe("off", `cd ${A} && mv mid mid2 2>&1; echo mv-mid=$?`, ["policy"], { CODEOID_PROBE_WORKSPACE: inner, CODEOID_PROBE_ADDED_DIRS: A }); + expect(between.result).toBe("success"); + expect(between.toolResult).toContain("mv-mid=1"); + expect(existsSync(join(A, "mid2"))).toBe(false); + }, 300_000); }); /** diff --git a/src/tests/sandbox-dirs-manager.test.ts b/src/tests/sandbox-dirs-manager.test.ts new file mode 100644 index 00000000..61a53d76 --- /dev/null +++ b/src/tests/sandbox-dirs-manager.test.ts @@ -0,0 +1,401 @@ +/** + * 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, 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"; +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 }); +} + +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 = []; + // 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")); + 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 {} + process.env.HOME = realHome; + 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); + 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)); + 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:"); + // 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 () => { + 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(); + }); +}); + +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-held-real.test.ts b/src/tests/sandbox-held-real.test.ts new file mode 100644 index 00000000..b85b15a3 --- /dev/null +++ b/src/tests/sandbox-held-real.test.ts @@ -0,0 +1,160 @@ +/** + * Real bubblewrap, skipped where it can't run (#448 round 14): every + * directory a held path is in stays in place, and the working directory is + * pinned by its identity. + * + * - a monorepo package session can't move its package dir (nor the dir + * above it) aside to plant the package's `.claude/settings.json`, which a + * later Claude start there would run on the host, nor put a link where + * its working directory was (findings-c H1, findings-s 1); + * - a repository inside an added dir can't be moved aside either + * (findings-c H1, same class); + * - the working directory replaced by another real directory is refused by + * its identity. + * + * Every swap from inside runs inside the sandbox under test; every swap from + * outside is the test's own, on the host, against directories it planted in + * its own scratch dir. Nothing else is read or written. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js"; +import { pinRecord } from "../daemon/sandbox/pinned.js"; +import { buildSandboxSpec, type SpecInputs } from "../daemon/sandbox/policy.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 repo(dir: string, home: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "README.md"), "readme\n"); + for (const args of [["init", "-q", "-b", "main"], ["add", "."], ["commit", "-qm", "init"]]) { + const r = Bun.spawnSync(["git", "-c", "core.hooksPath=/dev/null", ...args], { cwd: dir, env: gitEnv(home) }); + if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`); + } +} + +const PLANTED_SETTINGS = JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: "command", command: "true" }] }] } }); + +describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: what a sandbox holds stays in place (#448 round 14)", () => { + let base: string; + beforeAll(() => { + base = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-held-"))); + }); + 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 }); + + function fixture(name: string) { + const dir = join(base, name); + const home = join(dir, "home"); + const W = join(home, "Workspace"); + const tmp = join(dir, "tmp"); + mkdirSync(tmp, { recursive: true }); + // What a link could lead to: a planted "home" (its key, its shell startup file) and a planted daemon dir. + 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 daemonDir = join(dir, "daemon-private"); + mkdirSync(join(daemonDir, "launchers"), { recursive: true }); + writeFileSync(join(daemonDir, "launchers", "launch-x"), "#!/bin/sh\necho original\n"); + const planted = join(dir, "planted-settings.json"); + writeFileSync(planted, PLANTED_SETTINGS); + const hidden = { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: daemonDir }; + const spec = (inputs: Partial & { workspace: string }) => buildSandboxSpec({ sessionId: name, tempDir: tmp, network: { mode: "off" }, hidden, cache: false, ...inputs }); + return { dir, home, W, tmp, fakeHome, daemonDir, planted, spec, env: gitEnv(home) }; + } + const intact = (f: { fakeHome: string; daemonDir: string }) => + readFileSync(join(f.fakeHome, ".bashrc"), "utf8") === "# planted\n" && readFileSync(join(f.daemonDir, "launchers", "launch-x"), "utf8") === "#!/bin/sh\necho original\n"; + + test("a monorepo package session: its package dir, and the dir above it, can't be moved aside; its settings can't be planted; no link goes where it was", async () => { + const f = fixture("mono"); + const top = join(f.W, "mono"); + const pkg = join(top, "packages", "app"); + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(pkg, "index.js"), "1\n"); + repo(top, f.home); + const spec = f.spec({ workspace: top, workdir: pkg, pins: { [top]: pinRecord(top) as string, [pkg]: pinRecord(pkg) as string } }); + expect(spec.heldInPlace).toEqual(expect.arrayContaining([pkg, join(top, "packages")])); + expect(spec.readOnly).toContain(join(pkg, ".claude")); + const h = await provider.open(spec); + try { + const r = await sh( + h, + top, + [ + `cd ${join(top, "packages")} && mv app app-old 2>&1; echo mv-pkg=$?`, + `cd ${top} && mv packages packages-old 2>&1; echo mv-parent=$?`, + `cd ${top} && mv packages/app packages/app.old 2>&1 && ln -s ${f.fakeHome} packages/app && echo SWAPPED`, + `mkdir -p ${pkg}/.claude && cp ${f.planted} ${pkg}/.claude/settings.json 2>&1 && echo PLANTED`, + `echo w > ${pkg}/own.txt && echo PKG-WRITABLE`, + `cd ${pkg} && git status --short >/dev/null && echo GIT-OK`, + ].join("; "), + f.env, + ); + expect(r.stdout).toContain("mv-pkg=1"); + expect(r.stdout).toContain("mv-parent=1"); + expect(r.stdout).toContain("busy"); + expect(r.stdout).not.toContain("SWAPPED"); + expect(r.stdout).not.toContain("PLANTED"); + expect(r.stdout).toContain("PKG-WRITABLE"); + expect(r.stdout).toContain("GIT-OK"); + expect(existsSync(join(top, "packages", "app-old"))).toBe(false); + expect(existsSync(join(top, "packages-old"))).toBe(false); + expect(existsSync(join(pkg, ".claude", "settings.json"))).toBe(false); + expect(intact(f)).toBe(true); + } finally { + await h.close(); + } + // Replaced on the host by another real directory (moved aside, made again): refused by its identity. + renameSync(pkg, join(top, "packages", "app-moved")); + mkdirSync(pkg); + expect(() => f.spec({ workspace: top, workdir: pkg, pins: { [top]: pinRecord(top) as string, [pkg]: pinRecord(join(top, "packages", "app-moved")) as string } })).toThrow( + `the working directory ${pkg} isn't the directory it was`, + ); + }); + + test("a repository inside an added dir: it can't be moved aside to plant its settings", async () => { + const f = fixture("nested"); + const a = join(f.W, "a"); + const b = join(f.W, "b-folder"); + repo(a, f.home); + repo(join(b, "lib"), f.home); + const spec = f.spec({ workspace: a, addedDirs: [{ dir: b }] }); + expect(spec.readOnly).toContain(join(b, "lib", ".claude")); + expect(spec.heldInPlace).toContain(join(b, "lib")); + const h = await provider.open(spec); + try { + const r = await sh(h, a, `cd ${b} && mv lib lib-old 2>&1; echo mv=$?; mkdir -p lib/.claude; cp ${f.planted} lib/.claude/settings.json 2>&1 && echo PLANTED; echo w > ${b}/lib/own.txt && echo LIB-WRITABLE`, f.env); + expect(r.stdout).toContain("mv=1"); + expect(r.stdout).toContain("busy"); + expect(r.stdout).not.toContain("PLANTED"); + expect(r.stdout).toContain("LIB-WRITABLE"); + expect(existsSync(join(b, "lib-old"))).toBe(false); + expect(existsSync(join(b, "lib", ".claude", "settings.json"))).toBe(false); + } finally { + await h.close(); + } + }); + +}); diff --git a/src/tests/sandbox-held.test.ts b/src/tests/sandbox-held.test.ts new file mode 100644 index 00000000..ddd40990 --- /dev/null +++ b/src/tests/sandbox-held.test.ts @@ -0,0 +1,77 @@ +/** + * What a spec holds in place and pins (#448 round 14): every path it binds by + * name (its writable dirs, its temp dir, the paths it holds read-only, the + * dirs it holds in place) is pinned, the working directory by its identity; + * the working directory and every dir up to the workspace are held in place. + */ + +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bwrapArgs } from "../daemon/sandbox/bwrap.js"; +import { claudeSandboxSettings } from "../daemon/sandbox/claude-native.js"; +import { dockerRunArgs } from "../daemon/sandbox/docker.js"; +import { buildSandboxSpec } from "../daemon/sandbox/policy.js"; +import { seatbeltProfile } from "../daemon/sandbox/seatbelt.js"; + +const made: string[] = []; +function scratch(): string { + const d = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-pinned-"))); + made.push(d); + return d; +} +afterAll(() => { + for (const d of made) rmSync(d, { recursive: true, force: true }); +}); + +describe("every path a spec binds is pinned; the working directory and its way up held in place (#448 round 14)", () => { + test("the spec pins its writable dirs, its temp dir, the paths it holds read-only and the dirs it holds in place; the working directory by identity", () => { + const d = scratch(); + const home = join(d, "home"); + const top = join(home, "W", "mono"); + const pkg = join(top, "packages", "app"); + mkdirSync(pkg, { recursive: true }); + Bun.spawnSync(["git", "init", "-q"], { cwd: top }); + const tmp = join(d, "tmp"); + mkdirSync(tmp); + const spec = buildSandboxSpec({ sessionId: "s", workspace: top, workdir: pkg, tempDir: tmp, network: { mode: "off" }, hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, cache: false }); + const byPath = new Map((spec.pins ?? []).map((p) => [p.path, p])); + expect(byPath.get(pkg)).toMatchObject({ what: "the working directory", id: expect.stringMatching(/^\d+:\d+$/) }); + expect(byPath.get(tmp)?.what).toBe("the session's temp dir"); + expect(byPath.get(join(top, ".git"))?.what).toBe("a writable directory of the session's"); + expect(byPath.get(join(top, ".git", "config"))).toMatchObject({ what: "a path held read-only", file: true }); + expect(byPath.get(join(pkg, ".claude"))).toMatchObject({ what: "a path held read-only", id: expect.any(String) }); + expect(byPath.get(join(top, "packages"))?.what).toBe("a directory held in place"); + expect(spec.heldInPlace).toEqual(expect.arrayContaining([pkg, join(top, "packages")])); + // Every path a provider binds by name (but the hidden list, the home's allowlist, sockets) is one of them. + for (const p of [spec.workspace, spec.tempDir, ...spec.writable, ...(spec.readOnly ?? []), ...(spec.pinned ?? []), ...(spec.heldInPlace ?? [])]) expect(byPath.has(p)).toBe(true); + }); + + test("every provider holds them in place: bubblewrap and Docker bind each at its own path (writable), Seatbelt denies writing the dir itself; Claude's sandbox doesn't deny them (its CLI binds them)", () => { + const d = scratch(); + const home = join(d, "home"); + const top = join(home, "W", "mono"); + const pkg = join(top, "packages", "app"); + mkdirSync(pkg, { recursive: true }); + Bun.spawnSync(["git", "init", "-q"], { cwd: top }); + const tmp = join(d, "tmp"); + mkdirSync(tmp); + const spec = buildSandboxSpec({ sessionId: "s", workspace: top, workdir: pkg, tempDir: tmp, network: { mode: "off" }, hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, cache: false }); + const held = [pkg, join(top, "packages")]; + expect(spec.heldInPlace).toEqual(expect.arrayContaining(held)); + const bw = bwrapArgs(spec, pkg).join(" "); + for (const h of held) expect(bw).toContain(`--bind ${h} ${h}`); + const docker = dockerRunArgs(spec, ["true"], { image: "x", cwd: pkg, envNames: [], uid: 1, gid: 1 }).join(" "); + for (const h of held) expect(docker).toContain(`source=${h},target=${h}`); + const sb = seatbeltProfile(spec, { isDir: () => true }); + const names = Object.entries(sb.params); + for (const h of held) { + const name = names.find(([, v]) => v === h)?.[0]; + expect(name).toBeDefined(); + expect(sb.profile).toContain(`(deny file-write* (literal (param "${name}")))`); + } + const native = claudeSandboxSettings({ spec, config: { bwrapPath: undefined } } as never, { HOME: home }); + for (const h of held) expect(native.filesystem?.denyWrite).not.toContain(h); + }); +}); diff --git a/src/tests/sandbox-pinned.test.ts b/src/tests/sandbox-pinned.test.ts new file mode 100644 index 00000000..d6e28949 --- /dev/null +++ b/src/tests/sandbox-pinned.test.ts @@ -0,0 +1,146 @@ +/** + * The pinned-path API's daemon side (#448 round 14, sandbox/pinned.ts): the + * daemon's own work in a session's directory checks it before and after, + * never follows a link (a placeholder, a file rewritten, an empty dir + * removed), and a snapshot whose work tree is swapped while it is read isn't + * recorded. + */ + +import { afterAll, describe, expect, test } from "bun:test"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createCheckpoint, listCheckpoints, restoreTree } from "../daemon/checkpoints.js"; +import { checkPinned, inPinnedDir, makeAt, makeInPinned, PinnedPathError, pinDir, pinRecord, removeEmptyDir, replaceInPinned } from "../daemon/sandbox/pinned.js"; + +const made: string[] = []; +function scratch(): string { + const d = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-pinned-"))); + made.push(d); + return d; +} +afterAll(() => { + for (const d of made) rmSync(d, { recursive: true, force: true }); +}); + +describe("the daemon's own use of a pinned directory (#448 round 14)", () => { + test("pinDir: a directory at its real path, by its identity; a link in its place, or another directory, refused", () => { + const d = scratch(); + const ws = join(d, "ws"); + mkdirSync(ws); + const pin = pinDir(ws, "the working directory"); + expect(pin.record).toBe(pinRecord(ws) as string); + expect(() => checkPinned(pin, "x")).not.toThrow(); + renameSync(ws, join(d, "ws-old")); + mkdirSync(ws); + expect(() => checkPinned(pin, "a snapshot")).toThrow("isn't the directory it was (another one is there now); a snapshot refused"); + rmSync(ws, { recursive: true }); + symlinkSync(join(d, "ws-old"), ws); + expect(() => checkPinned(pin, "a snapshot")).toThrow(PinnedPathError); + expect(() => pinDir(ws, "the working directory")).toThrow("is reached through a symbolic link now"); + expect(() => pinDir(ws, "the working directory", pin.record)).toThrow(`${ws} changed under the session`); + }); + + test("inPinnedDir: checked before and after; a swap racing the act is said, not taken as done", async () => { + const d = scratch(); + const ws = join(d, "ws"); + mkdirSync(ws); + const pin = pinDir(ws, "the working directory"); + expect(await inPinnedDir(pin, "a look", async (p) => p)).toBe(ws); + await expect( + inPinnedDir(pin, "putting the files back", async () => { + renameSync(ws, join(d, "elsewhere")); + symlinkSync(join(d, "elsewhere"), ws); + }), + ).rejects.toThrow("putting the files back refused"); + }); + + test("makeInPinned / makeAt: made only in the directory as pinned, never through a link on the way, never over one", () => { + const d = scratch(); + const ws = join(d, "ws"); + const outside = join(d, "outside"); + mkdirSync(ws); + mkdirSync(outside); + const pin = pinDir(ws, "the working directory"); + expect(makeInPinned(pin, ".claude", "dir", "making a placeholder")).toBe(true); + expect(makeInPinned(pin, ".claude", "dir", "making a placeholder")).toBe(false); + expect(makeInPinned(pin, ".mcp.json", { content: "{}\n" }, "making a placeholder")).toBe(true); + expect(readFileSync(join(ws, ".mcp.json"), "utf8")).toBe("{}\n"); + // A link where the file would go: not written through (O_EXCL | O_NOFOLLOW), nothing lands outside. + symlinkSync(join(outside, "target.json"), join(ws, "opencode.json")); + expect(makeInPinned(pin, "opencode.json", { content: "{}\n" }, "making a placeholder")).toBe(false); + expect(existsSync(join(outside, "target.json"))).toBe(false); + // A link on the way: refused. + symlinkSync(outside, join(ws, "sub")); + expect(() => makeInPinned(pin, "sub/.claude", "dir", "making a placeholder")).toThrow("holds a symbolic link on the way"); + expect(existsSync(join(outside, ".claude"))).toBe(false); + // Out of the directory: refused. + expect(() => makeInPinned(pin, "../escape", "dir", "making a placeholder")).toThrow("isn't inside it"); + // makeAt: in its parent as it is now. + expect(makeAt(join(ws, "HEAD"), "dir", "making a placeholder")).toBe(true); + expect(lstatSync(join(ws, "HEAD")).isDirectory()).toBe(true); + }); + + test("replaceInPinned replaces a link put at the file, never writes where it leads; removeEmptyDir never through a link", () => { + const d = scratch(); + const common = join(d, "git"); + mkdirSync(join(common, "info"), { recursive: true }); + const outside = join(d, "outside.txt"); + writeFileSync(outside, "keep\n"); + symlinkSync(outside, join(common, "info", "exclude")); + const pin = pinDir(common, "the repository's git dir"); + replaceInPinned(pin, "info/exclude", "/HEAD\n", "listing placeholders"); + expect(lstatSync(join(common, "info", "exclude")).isSymbolicLink()).toBe(false); + expect(readFileSync(join(common, "info", "exclude"), "utf8")).toBe("/HEAD\n"); + expect(readFileSync(outside, "utf8")).toBe("keep\n"); + // removeEmptyDir: an empty dir goes; one reached through a link stays. + const loc = join(d, "loc"); + mkdirSync(join(loc, "folder"), { recursive: true }); + const elsewhere = join(d, "elsewhere"); + mkdirSync(join(elsewhere, "folder"), { recursive: true }); + symlinkSync(elsewhere, join(d, "link")); + expect(removeEmptyDir(join(d, "link", "folder"))).toBe(false); + expect(existsSync(join(elsewhere, "folder"))).toBe(true); + expect(removeEmptyDir(join(loc, "folder"))).toBe(true); + expect(existsSync(join(loc, "folder"))).toBe(false); + }); +}); + +describe("checkpoints take the work tree pinned (#448 round 14)", () => { + const git = (cwd: string, ...args: string[]) => Bun.spawnSync(["git", ...args], { cwd, env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: cwd, GIT_CONFIG_NOSYSTEM: "1" } }); + + test("a snapshot whose work tree is swapped while it is read isn't recorded (the check after it); a path given with a link in it is refused", async () => { + const d = scratch(); + const wd = join(d, "wd"); + mkdirSync(wd); + writeFileSync(join(wd, "a.txt"), "a\n"); + git(wd, "init", "-q"); + const root = join(d, "checkpoints"); + const pin = pinDir(wd, "the working directory"); + const elsewhere = join(d, "elsewhere"); + mkdirSync(elsewhere); + writeFileSync(join(elsewhere, "token.txt"), "PLANTED\n"); + // The swap lands while the files are read (isLate runs inside the read). + const r = await createCheckpoint({ + root, + workdir: pin, + sessionId: "s", + turnId: "t1", + isLate: () => { + renameSync(wd, join(d, "wd-moved")); + symlinkSync(elsewhere, wd); + return false; + }, + }); + expect(r.ok).toBe(false); + expect(r.ok ? "" : r.reason).toContain("changed under the session"); + expect(r.ok ? false : r.pinned).toBe(true); + expect((await listCheckpoints(root, "s")).size).toBe(0); + // A string path is pinned at the call: one reached through a link is refused. + const viaLink = await createCheckpoint({ root, workdir: wd, sessionId: "s", turnId: "t2" }); + expect(viaLink.ok).toBe(false); + expect(viaLink.ok ? "" : viaLink.reason).toContain("is reached through a symbolic link now"); + await expect(restoreTree({ root, workdir: wd, sessionId: "s", from: "HEAD", to: "HEAD" })).rejects.toThrow(PinnedPathError); + expect(readFileSync(join(elsewhere, "token.txt"), "utf8")).toBe("PLANTED\n"); + }); +}); diff --git a/src/tests/sandbox-pins-manager.test.ts b/src/tests/sandbox-pins-manager.test.ts new file mode 100644 index 00000000..d460e613 --- /dev/null +++ b/src/tests/sandbox-pins-manager.test.ts @@ -0,0 +1,110 @@ +/** + * 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"); + }); +}); + +describe("everything the sandbox binds by name is checked at the gate (#448 round 14)", () => { + it("a path held read-only in an added dir swapped for a link mid-turn (by another session's command, or on the host): the next tool call refused at the gate (said once, audited), never re-pinned silently", async () => { + const m = t.newManager(); + 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(); + const held = join(t.sibling, ".claude"); + expect(t.runtimeSpec()?.pins?.find((p) => p.path === held)?.what).toBe("a path held read-only"); + const lines = t.infoLines(m, info?.name as string); + void session.send("go", AUTH); + await until(() => session.pendingToolApprovals().length > 0); + // Meanwhile, something outside this session's sandbox swaps the held settings dir for a link. + const elsewhere = join(t.tmp, "planted-claude"); + mkdirSync(elsewhere); + renameSync(held, join(t.tmp, "claude-moved")); + symlinkSync(elsewhere, held); + 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(`a path held read-only ${held} is reached through a symbolic link now`); + await until(() => session.toInfo().status === "idle"); + expect(lines.filter((l) => l.includes(`a path held read-only ${held} is reached through a symbolic link now`)).length).toBe(1); + expect(t.audits.some((a) => a.action === "session.sandbox_pin_changed" && a.detail.includes(held))).toBe(true); + // The next turn's resolution looks again: the link isn't held (nor bound) there, the turn runs. + void t.sendWhenReady(session, "again"); + await until(() => session.pendingToolApprovals().length > 0); + expect(t.runtimeSpec()?.readOnly).not.toContain(held); + expect(t.runtimeSpec()?.readOnly).not.toContain(elsewhere); + }); +}); diff --git a/src/tests/sandbox-pins-real.test.ts b/src/tests/sandbox-pins-real.test.ts new file mode 100644 index 00000000..7bbcc1f9 --- /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/pinned.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..b15dc8bc --- /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, + * pinned.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/pinned.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).slice(0, 3)).toEqual(["the workspace", "added directory", "project root"]); + for (const p of (spec.pins ?? []).filter((p) => !p.file && !p.mayBeMissing)) 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-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..f1c61362 --- /dev/null +++ b/src/tests/sandbox-project-roots-real.test.ts @@ -0,0 +1,396 @@ +/** + * 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, 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 { 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, nor any temp dir (no project root is in one). */ +function scratchParent(): string { + for (const dir of [process.env.CODEOID_TEST_SANDBOX_DIR, 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[] = [], scanLimit?: number) => + buildSandboxSpec({ + ...(scanLimit !== undefined ? { scanLimit } : {}), + 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(); + } + }); + 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 new file mode 100644 index 00000000..5001744d --- /dev/null +++ b/src/tests/sandbox-project-roots.test.ts @@ -0,0 +1,667 @@ +/** + * 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, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +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"; +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, + 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 { 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"; +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; +/** 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(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 }); +}); + +describe("project roots", () => { + 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 }); + 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)).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")]); + }); + + 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 }); + 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 = await sandboxDirsOf(process.env.HOME as string, ["@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 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")]).hidden.sort(); + expect(found).toEqual([...[".env", ".env.production", ".envrc", ".npmrc"].map((f) => join(root, "b", f)), join(root, "c", "deep", ".env")].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("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"); + 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(); + } + }); +}); + +/** `/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", async () => { + 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 = 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`); + }); + + 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", async () => { + 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 = (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`); + }); + + 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-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")); + }); + +}); 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"); + }); +}); 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"); + }); +}); diff --git a/src/tests/sandbox-session.test.ts b/src/tests/sandbox-session.test.ts index b832dfc3..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 }, @@ -368,6 +375,7 @@ 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" }); + // 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" }); }); @@ -424,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 () => { @@ -808,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" }); @@ -923,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" }] }); @@ -1012,9 +1021,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 (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"); + 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 }); + } + // 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"); + 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 () => { diff --git a/src/tests/workdir-pinned.test.ts b/src/tests/workdir-pinned.test.ts new file mode 100644 index 00000000..83760951 --- /dev/null +++ b/src/tests/workdir-pinned.test.ts @@ -0,0 +1,243 @@ +/** + * The working directory is pinned for the daemon's own work in it (#448 + * round 14, sandbox/pinned.ts): through the real SessionManager with a mock + * backend and real git. A monorepo package session's working directory sits + * inside what its commands write; one moved aside and replaced (by a link to + * a planted "home", or by another real directory) is never snapshotted, + * restored into, deleted from, forked from or read through: each is refused, + * said on the session and audited, and what the link leads to is untouched. + * + * The swap is the test's own, on the host, between turns, against + * directories it planted in its own scratch dir. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } 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 { 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, DaemonMessage, SessionMessage, SessionTurnsResultMsg } from "../protocol/types.js"; + +const AUTH: AuthContext = { sub: "user:pinned", scopes: [...ALL_SCOPES] as AuthContext["scopes"], delegationDepth: 0, accountId: "acc-p", projectId: "proj-p" }; +const CLIENT = { id: "cli", auth: AUTH, send: () => {} }; + +function say(text: string): ProviderEvent[] { + return [ + { type: "text_done", content: text } as ProviderEvent, + { type: "turn_done", result: { providerId: "claude", model: "mock", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } } as ProviderEvent, + ]; +} + +function mkConfig(tmp: string): CodeoidConfig { + return { + daemonUrl: "ws://127.0.0.1:7400", + dbPath: join(tmp, "codeoid.db"), + transcriptDir: join(tmp, "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; +} + +let tmp: string; +let repo: string; +let app: string; +let victim: string; +let store: Store; +let transcript: TranscriptStore; +let managers: SessionManager[]; +const audits: Array<{ action: string; detail: string }> = []; + +function newManager(agentEdits: Array<(() => void) | undefined>): SessionManager { + let turn = 0; + const m = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: mkConfig(tmp), + _testProviderFactory: () => { + const p = new MockSessionProvider("claude", [say("a1"), say("a2"), say("a3")]); + const run = p.runTurn.bind(p); + p.runTurn = (opts) => { + agentEdits[turn++]?.(); + return run(opts); + }; + return p; + }, + }); + managers.push(m); + return m; +} + +const git = (...args: string[]): string => execFileSync("git", args, { cwd: repo, encoding: "utf8" }); + +beforeEach(() => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-pinned-workdir-"))); + repo = join(tmp, "mono"); + app = join(repo, "packages", "app"); + mkdirSync(app, { recursive: true }); + git("init", "-q"); + git("config", "user.email", "t@example.com"); + git("config", "user.name", "t"); + writeFileSync(join(app, "index.ts"), "export {};\n"); + git("add", "."); + git("commit", "-qm", "init"); + // What a link could lead to: a planted "home" with a token and no shell startup file. + victim = join(tmp, "victim-home"); + mkdirSync(join(victim, ".config", "gh"), { recursive: true }); + writeFileSync(join(victim, ".config", "gh", "hosts.yml"), "oauth_token: PLANTED-GH-TOKEN\n"); + store = new Store(join(tmp, "codeoid.db")); + const audit = store.audit.bind(store); + audits.length = 0; + store.audit = ((sub: string, action: string, sessionId?: string, detail?: string) => { + audits.push({ action, detail: detail ?? "" }); + return audit(sub, action, sessionId, detail); + }) as typeof store.audit; + transcript = new TranscriptStore(join(tmp, "transcripts")); + managers = []; +}); + +afterEach(async () => { + for (const m of managers) await m.drain(2_000).catch(() => {}); + await transcript.flush(); + rmSync(tmp, { recursive: true, force: true }); +}); + +async function create(m: SessionManager): Promise { + const r = await m.handle({ type: "session.create", id: "c", name: `pw-${Math.random()}`, workdir: app }, AUTH, CLIENT); + if (r.type !== "response.ok") throw new Error(JSON.stringify(r)); + return (r.data as { id: string }).id; +} + +async function sendAndSettle(m: SessionManager, id: string, text: string): Promise { + const s = m._sessionForTest(id)!; + await s.send(text, AUTH); + for (let i = 0; i < 300 && s.status !== "idle"; i++) await Bun.sleep(10); + expect(s.status).toBe("idle"); + // The end-of-turn snapshot, and its git, have run. + await Bun.sleep(300); +} + +async function turns(m: SessionManager, id: string): Promise { + const r = await m.handle({ type: "session.turns", id: "t", sessionId: id }, AUTH, CLIENT); + if (r.type !== "session.turns.result") throw new Error(JSON.stringify(r)); + return r.turns; +} + +function lines(m: SessionManager, id: string): string[] { + const received: DaemonMessage[] = []; + m._sessionForTest(id)!.attach({ id: `w-${Math.random()}`, auth: AUTH, send: (msg) => received.push(msg) }); + const replay = received.find((x) => x.type === "scrollback.replay") as { messages: SessionMessage[] } | undefined; + return (replay?.messages ?? []).filter((x) => x.metadata?.event === "workdir.changed").map((x) => x.content); +} + +/** Every file name any snapshot of the session holds. */ +function snapshotted(id: string): string { + const shadow = join(tmp, "transcripts", "checkpoints", `${id}.git`); + if (!existsSync(shadow)) return ""; + const refs = execFileSync("git", ["--git-dir", shadow, "for-each-ref", "--format=%(objectname)", "refs/turns/"], { encoding: "utf8" }).trim().split("\n").filter(Boolean); + return refs.map((r) => execFileSync("git", ["--git-dir", shadow, "ls-tree", "-r", "--name-only", r], { encoding: "utf8" })).join("\n"); +} + +function victimUntouched(): void { + expect(existsSync(join(victim, ".bashrc"))).toBe(false); + expect(readFileSync(join(victim, ".config", "gh", "hosts.yml"), "utf8")).toBe("oauth_token: PLANTED-GH-TOKEN\n"); + expect(readdirSync(victim).sort()).toEqual([".config"]); +} + +describe("the working directory, swapped under the session, is never followed (#448 round 14)", () => { + it("swapped for a link: the end snapshot isn't taken of where it leads, going back a turn is refused (said, audited), what it leads to untouched", async () => { + const m = newManager([ + // Turn 1: the agent leaves a file named like a shell startup file in its package dir. + () => writeFileSync(join(app, ".bashrc"), "echo PLANTED-BY-REWIND\n"), + // Turn 2: one command moves the package dir aside and puts a link to the "home" in its place. + () => { + renameSync(app, join(repo, "packages", "app.old")); + symlinkSync(victim, app); + }, + ]); + const id = await create(m); + await sendAndSettle(m, id, "one"); + await sendAndSettle(m, id, "two"); + // Nothing of where the link leads was copied into the session's snapshots. + expect(snapshotted(id)).not.toContain("hosts.yml"); + expect(lines(m, id).some((l) => l.includes(`${app} changed under the session: it is reached through a symbolic link now`) && l.includes("a snapshot of the files refused"))).toBe(true); + expect(audits.some((a) => a.action === "session.workdir_changed")).toBe(true); + // Going back to before turn 2, files included: refused, nothing written or deleted there. + const list = await turns(m, id); + for (const dryRun of [true, false]) { + const r = await m.handle({ type: "session.rewind", id: "rw", sessionId: id, turnId: list[1]!.turnId, restoreFiles: true, dryRun }, AUTH, CLIENT); + expect(r.type).toBe("response.error"); + expect((r as { error: string }).error).toContain(`${app} changed under the session`); + expect((r as { error: string }).error).toContain("going back a turn refused"); + } + victimUntouched(); + // Its conversation stays as it was: nothing was taken back. + expect((await turns(m, id)).length).toBe(2); + }); + + it("replaced by another real directory (moved aside, made again): refused the same, by its identity", async () => { + const m = newManager([ + () => writeFileSync(join(app, "notes.md"), "turn one\n"), + () => { + renameSync(app, join(repo, "packages", "app.old")); + mkdirSync(app); + writeFileSync(join(app, "planted.md"), "planted\n"); + }, + ]); + const id = await create(m); + await sendAndSettle(m, id, "one"); + await sendAndSettle(m, id, "two"); + expect(snapshotted(id)).not.toContain("planted.md"); + const r = await m.handle({ type: "session.rewind", id: "rw", sessionId: id, turnId: (await turns(m, id))[1]!.turnId, restoreFiles: true }, AUTH, CLIENT); + expect(r.type).toBe("response.error"); + expect((r as { error: string }).error).toContain("isn't the directory it was (another one is there now)"); + // The planted dir's file stays, the moved-aside one is untouched. + expect(readFileSync(join(app, "planted.md"), "utf8")).toBe("planted\n"); + expect(readFileSync(join(repo, "packages", "app.old", "notes.md"), "utf8")).toBe("turn one\n"); + }); + + it("swapped for a link: a fork from an earlier turn, and a client's file read, are refused; nothing of where it leads is copied", async () => { + const m = newManager([ + () => writeFileSync(join(app, "notes.md"), "turn one\n"), + () => { + renameSync(app, join(repo, "packages", "app.old")); + symlinkSync(victim, app); + }, + ]); + const id = await create(m); + await sendAndSettle(m, id, "one"); + await sendAndSettle(m, id, "two"); + const before = readdirSync(tmp).sort(); + const f = await m.handle({ type: "session.fork", id: "f", sessionId: id, afterTurnId: (await turns(m, id))[0]!.turnId } as never, AUTH, CLIENT); + expect(f.type).toBe("response.error"); + expect(JSON.stringify(f)).toContain("changed under the session"); + // No fork worktree was made beside the repository. + expect(readdirSync(tmp).sort()).toEqual(before); + const read = await m.handle({ type: "fs.read", id: "r", sessionId: id, path: ".config/gh/hosts.yml" } as never, AUTH, CLIENT); + expect(read.type).toBe("response.error"); + expect(JSON.stringify(read)).not.toContain("PLANTED-GH-TOKEN"); + victimUntouched(); + }); + + it("left as it was: snapshots, going back a turn and a fork's files work as before", async () => { + const m = newManager([() => writeFileSync(join(app, "index.ts"), "export const x = 1;\n"), () => writeFileSync(join(app, "index.ts"), "export const x = 2;\n")]); + const id = await create(m); + await sendAndSettle(m, id, "one"); + await sendAndSettle(m, id, "two"); + const r = await m.handle({ type: "session.rewind", id: "rw", sessionId: id, turnId: (await turns(m, id))[1]!.turnId, restoreFiles: true }, AUTH, CLIENT); + expect(r.type).toBe("session.rewind.result"); + expect(readFileSync(join(app, "index.ts"), "utf8")).toBe("export const x = 1;\n"); + expect(audits.some((a) => a.action === "session.workdir_changed")).toBe(false); + }); +}); 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}