diff --git a/CHANGELOG.md b/CHANGELOG.md index b22b5176..c52ebe3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -243,6 +243,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **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). + +- **A nested glob is no longer refused as a read of a process's environment.** + `cat src/*/*.ts`, or `grep x ~/.claude/projects/*/*.jsonl`, was refused in every mode with "/proc//environ holds credentials": a bare `*` component could spell "proc", and was taken for /proc at any depth. A pattern counts as /proc only at the root or after what can lead anywhere (`$d`, `..`); `$HOME/…` is the home on a line that doesn't set HOME. Real reads of a process's environment are still refused. + - **A refused tool call no longer shows as completed.** When codeoid refused a call (a credential, a denial) and the backend then reported it finished, the card turned "completed" with the refusal as its output. It stays cancelled now, with the reason; a refused Edit or Write says so under its diff. - **A session shows "waiting approval" only once there is an approval to answer.** diff --git a/docs/sandbox.md b/docs/sandbox.md index 4fbb1094..fc91b408 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -68,7 +68,7 @@ With the default policy (workspace-write): | | | |---|---| | **Your home: hidden whole** | the sandbox starts from an empty, read-only home. A credential that isn't on any list, one created after the session started (`aws configure`, `gh auth login`), or one a program replaces by writing a new file and renaming it over the old one, is simply not there. What is shown again, read-only: the **allowlist** `sandbox.homeReadable` (below), and what the backend itself needs (its program; its private home, below). The workspace, when it is in your home, is shown writable. | -| **Home allowlist** (`sandbox.homeReadable`, read-only) | by default toolchains and tool settings that hold no secret: `~/.bun`, `~/.cargo/bin`, `~/.cargo/registry`, `~/.cargo/git`, `~/.rustup`, `~/.nvm`, `~/.npm`, `~/.local/bin`, `~/.local/lib` (pip `--user` packages), `~/.local/share/pnpm`, `~/.local/share/uv`, `~/.local/share/pipx`, `~/.local/share/mise`, `~/.local/share/claude/versions` (the Claude installer's binaries), `~/.pyenv`, `~/go/pkg`, `~/go/bin`, `~/.gitconfig`, `~/.config/git/config`, `~/.config/git/ignore`, `~/.config/git/attributes`, `~/.claude/plugins` and `~/.claude/skills` (installed plugins and user skills, so a skill's script run as a command can read its own files; the CLI's settings, credentials and hooks stay hidden). Not listed, so hidden: `~/.npmrc`, `~/.bunfig.toml`, `~/.yarnrc.yml`, `~/.pypirc`, `~/.cargo/credentials.toml`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.gradle`, `~/.m2` and everything else. A global npmrc inside an nvm install (`~/.nvm/versions/node/*/etc/npmrc`) and uv's credential store (`~/.local/share/uv/credentials`) are hidden again. A link inside a listed dir (`~/.local/bin/x`) works only where it leads into the list itself (a pipx or `uv tool` venv, the Claude installer's versions) or outside the home: what it leads to elsewhere in the home (a project of yours, with its `.env`) stays hidden, and the link dangles. List the target's dir in `sandbox.homeReadable` to show it. A link in the list (`~/.gitconfig` into a dotfiles repository) is shown at its own path and where it leads. A token written into `~/.gitconfig` itself (a URL carrying one, an `http.extraHeader`) would show: keep it in a credential helper. | +| **Home allowlist** (`sandbox.homeReadable`, read-only) | by default toolchains and tool settings that hold no secret: `~/.bun`, `~/.cargo/bin`, `~/.cargo/registry`, `~/.cargo/git`, `~/.rustup`, `~/.nvm`, `~/.npm`, `~/.local/bin`, `~/.local/lib` (pip `--user` packages), `~/.local/share/pnpm`, `~/.local/share/uv`, `~/.local/share/pipx`, `~/.local/share/mise`, `~/.local/share/claude/versions` (the Claude installer's binaries), `~/.pyenv`, `~/go/pkg`, `~/go/bin`, `~/.gitconfig`, `~/.config/git/config`, `~/.config/git/ignore`, `~/.config/git/attributes`, `~/.claude/plugins` and `~/.claude/skills` (installed plugins and user skills, so a skill's script run as a command can read its own files; the CLI's settings, credentials and hooks stay hidden), and `~/.claude/projects` (every Claude session's transcript and memory, read-only, so an agent can look up what another session did; a secret once pasted into a session is in its transcript, so remove it from the list to hide them). Not listed, so hidden: `~/.npmrc`, `~/.bunfig.toml`, `~/.yarnrc.yml`, `~/.pypirc`, `~/.cargo/credentials.toml`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.gradle`, `~/.m2` and everything else. A global npmrc inside an nvm install (`~/.nvm/versions/node/*/etc/npmrc`) and uv's credential store (`~/.local/share/uv/credentials`) are hidden again. A link inside a listed dir (`~/.local/bin/x`) works only where it leads into the list itself (a pipx or `uv tool` venv, the Claude installer's versions) or outside the home: what it leads to elsewhere in the home (a project of yours, with its `.env`) stays hidden, and the link dangles. List the target's dir in `sandbox.homeReadable` to show it. A link in the list (`~/.gitconfig` into a dotfiles repository) is shown at its own path and where it leads. A token written into `~/.gitconfig` itself (a URL carrying one, an `http.extraHeader`) would show: keep it in a credential helper. | | **Writable** | the session's working directory (its worktree), a private temp dir made for the session (`$TMPDIR`, and the sandbox's `/tmp` under bubblewrap and Docker), and what a commit in the workspace's **git repository** writes: its git dir, a linked worktree's own git dir and its repository's (objects, refs, logs, the index, `HEAD`, `packed-refs` and the lock files beside them), wherever they are, a repository under the host's `/tmp` included. Nothing else. | | **Read-only** | everything else on the machine. `rm -rf ~` fails with "Read-only file system". Inside the writable, **a repository's structure**: everything that decides which config, hooks or git dir git uses, since a hook, `core.hooksPath` or `core.fsmonitor` planted there (or a config git is pointed at) would run outside the sandbox at your next `git status`. That is, in the repository's git dir: `config`, `config.worktree`, `commondir`, `hooks/`, `info/`, `modules/` (a submodule's git dir carries hooks and config of its own), `remotes/`, `branches/` and `worktrees/` whole (every worktree's git dir, one added after the session started included; the workspace's own worktree git dir is writable over it); in a linked worktree's own git dir: `commondir`, `gitdir`, `config.worktree`; the worktree's `.git` file, and the `.git` file of every other worktree or submodule checked out inside the workspace. A **submodule checkout** as the workspace (its `.git` a file naming `/.git/modules/`, whose `core.worktree` names it back) is held the same way, its git dir being the repository's. Any other `.git` file (a `--separate-git-dir` checkout, which git's records don't name back) is held read-only, with what it names when that is inside the workspace: nothing commits from inside, and the session's sandbox limits say so. The git dirs themselves are bound at their own paths, so none can be renamed away and rebuilt (`mv .git .git-old` fails), the workspace's `.git` dir included. A plain repository at the top of the workspace gets an empty `HEAD/` dir beside its `.git`, held read-only (git doesn't see an empty dir): a `.git` made invalid (its `HEAD` garbled, `refs/` removed) would otherwise have git take the workspace's top for a bare repository planted there, with its own config. What of these isn't there yet is made first, so there is something to hold: empty `hooks/`, `info/`, `modules/`, `remotes/`, `branches/`, `worktrees/`, an empty `config.worktree` in every git dir of the repository, and a `commondir` in the repository's git dir holding `.` (git, libgit2 and gitoxide read it as that git dir itself; see Limits). An entry that would have to be held but is a link (a bind can't hold one) makes the git dir read-only whole: commits from inside then fail. And **the project settings agent CLIs run code from** (below). | | **Project settings** (read-only) | `.claude/`, `.gemini/`, `.qwen/`, `.codex/`, `.pi/`, `.opencode/` in the workspace (and, for a session in a monorepo's package, in every dir from the package up to the repository's top: where the CLIs look), whole (made empty first where missing: git doesn't see an empty dir; not in a workspace that is your home or holds it, a temp root or `/`, where they would be the CLIs' own config dirs: there only those that exist are held), and `.mcp.json`, `opencode.json`, `opencode.jsonc` at its top, made first where missing (in the same places) holding an empty config (`{"mcpServers":{}}`, `{}`: what the CLIs read as none; your own `opencode` loads a project `opencode.json`'s MCP servers and plugins at its start) and, in a git repository, listed in its `.git/info/exclude` so `git status` and `git add -A` don't see them (an existing file is held as it is). A Claude hook, an MCP server, a pi or opencode plugin written there by one backend's command would run on the host in a later session of another, or in your own CLI. Changing them needs the sandbox lifted for that command: Claude's `dangerouslyDisableSandbox` and Codex's permission request are asked about in guarded and interactive mode and refused in autonomous mode (below); on other backends edit them yourself, or turn the session's sandbox off. A file tool that runs in the backend's own process, outside the sandbox (Claude's Write, Edit, MultiEdit and NotebookEdit, opencode's write, edit and patch) is held to the same rules at codeoid's gate in autonomous mode ([below](#file-tools-that-run-outside-the-sandbox)): writing what the sandbox holds read-only is refused there, and asked about in guarded and interactive mode. Writing the rest of **the repository's git dir** (its objects and refs, which a sandboxed commit writes, a linked worktree's, the `.git` file, another checkout's `.git` file in the workspace), or a file with more than one hard link, is asked about in guarded and interactive mode while the sandbox is on, the card saying why (audited `session.git_structure_write_ask` / `session.project_settings_write_ask`), and refused at once in autonomous mode (audited `session.autonomous_refuse`, kind `held_write`); on Claude the PreToolUse hook routes it to the gate past any allow rule. A backend whose file tools run inside the sandbox (Codex, pi, the Gemini CLI, Qwen; Claude under Docker) is held by the sandbox itself: nothing of this applies to it, a hard-linked file in `node_modules` included. | diff --git a/src/daemon/risk/shell-risk.test.ts b/src/daemon/risk/shell-risk.test.ts index 3d6fac7f..cdf256cd 100644 --- a/src/daemon/risk/shell-risk.test.ts +++ b/src/daemon/risk/shell-risk.test.ts @@ -716,4 +716,36 @@ describe("shell command risk (#431)", () => { expect((await assessShellCommand("rm -rf build", { ...ctx, cwd: "/elsewhere" })).level).toBe("high"); expect((await assessShellCommand("ls", { ...ctx, cwd: "/w/proj/src" })).level).toBe("low"); }); + + it("a nested glob isn't taken for /proc: a `*` component counts as proc only at the root or after what can lead anywhere", async () => { + // Ordinary globs read no process's environment: never refused as one. + for (const c of [ + "cat src/*/*.ts", + "cat ./a/*/*.md", + "grep -l gatd-aps ~/.claude/projects/*/*.jsonl", + 'grep -l "x" $HOME/.claude/projects/*/*.jsonl 2>/dev/null', + "for f in ~/.claude/projects/*/*.jsonl; do grep -l x \"$f\"; done", + "cat /home/u/x/*/y/*.jsonl", + "ls ~/x/*/*", + ]) { + const r = await assessShellCommand(c, ctx); + expect([c, r.secret?.action]).toEqual([c, undefined]); + } + // What can reach /proc still is. + for (const c of [ + "cat /proc/*/environ", + "cat /*/1/environ", + "cat /pr?c/1/environ", + "cat /proc/$PPID/environ", + "cat /proc/1/*", + 'cat "$d"/proc/1/*', + "cat ../../../../proc/1/*", + // A line that sets HOME can point $HOME anywhere. + "HOME=/; cat $HOME/proc/1/*", + "cat /home/u/r/proc/1/*", + ]) { + const r = await assessShellCommand(c, ctx); + expect([c, r.secret?.action]).toEqual([c, "deny"]); + } + }); }); diff --git a/src/daemon/risk/shell-risk.ts b/src/daemon/risk/shell-risk.ts index 3c01efc8..619ad6ee 100644 --- a/src/daemon/risk/shell-risk.ts +++ b/src/daemon/risk/shell-risk.ts @@ -85,13 +85,15 @@ export async function assessShellCommand(command: string, ctx: RiskContext): Pro */ export function assessShellCommandSync(command: string, ctx: RiskContext): ShellRisk { try { - return judge(parseShellSync(command), { ...ctx, home: ctx.home ?? homedir() }); + // `$HOME` is the home only on a line that can't change it: one naming HOME as a bare word (`HOME=`, `export HOME`, `unset HOME`, `read HOME`) may. + const homeFixed = !/(^|[^$\w{])HOME\b/.test(command); + return judge(parseShellSync(command), { ...ctx, home: ctx.home ?? homedir(), homeFixed }); } catch (err) { return { level: "high", reasons: [`the command couldn't be analysed: ${err instanceof Error ? err.message : String(err)}`] }; } } -type Ctx = Required; +type Ctx = Required & { /** `$HOME` can't have been changed by the line (see assessShellCommandSync). */ homeFixed?: boolean }; class Verdict { level: RiskLevel = "low"; @@ -1242,6 +1244,8 @@ function safeProcLeaf(leaf: string): boolean { */ function shapeComponents(w: ShellWord, cwd: string | null, ctx: Ctx): string[] | null { let shape = wordShape(w); + // `$HOME/…` on a line that can't change HOME is the home, as `~/…` is. + if (ctx.homeFixed && shape.startsWith(`${SHAPE_ANY}/`) && /^"?\$(?:HOME|\{HOME\})(?![\w}])"?\//.test(w.raw)) shape = ctx.home + shape.slice(SHAPE_ANY.length); if (shape === "~" || shape.startsWith("~/")) shape = ctx.home + shape.slice(1); else if (shape.startsWith("~")) return null; else if (!shape.startsWith("/") && !shape.startsWith(SHAPE_ANY)) shape = `${cwd ?? SHAPE_ANY}/${shape}`; @@ -1276,7 +1280,12 @@ function procSecretWord(w: ShellWord, cwd: string | null, ctx: Ctx): string | nu if (name) return `${w.raw}, which could be /proc//${name}`; } // Under a `proc` component (`/proc`, `/pr?c`, a cwd in /proc): only a fixed last component that is none of them is safe. - const at = comps.findIndex((c, i) => i < comps.length - 1 && !c.includes(SHAPE_ANY) && shapeRegex(c).test("proc")); + // /proc is a top-level directory: a component that only matches "proc" as a pattern (`*`, `pr?c`) is one at the + // root, or after what can lead anywhere (`$d`, `..`); a component that IS "proc" counts at any depth (a link to /). + const reachesRoot = (i: number) => i === 0 || comps.slice(0, i).some((c) => c.includes(SHAPE_ANY) || c === ".."); + const at = comps.findIndex( + (c, i) => i < comps.length - 1 && !c.includes(SHAPE_ANY) && shapeRegex(c).test("proc") && (c === "proc" || reachesRoot(i)), + ); if (at < 0) return null; // `/proc//root/…` is the process's `/`: what follows is judged from there. const root = comps.findIndex((c, i) => i > at && c === "root"); diff --git a/src/daemon/sandbox/home-readable.ts b/src/daemon/sandbox/home-readable.ts index 6f6565ea..7da1c7dc 100644 --- a/src/daemon/sandbox/home-readable.ts +++ b/src/daemon/sandbox/home-readable.ts @@ -43,4 +43,8 @@ export const DEFAULT_HOME_READABLE: readonly string[] = [ // credentials; the CLI's settings, credentials and hooks stay hidden. "~/.claude/plugins", "~/.claude/skills", + // Every Claude session's transcript and memory (read-only): an agent looking + // up what another session did. A secret once pasted into a session is in its + // transcript: remove this from sandbox.homeReadable to hide them. + "~/.claude/projects", ]; diff --git a/src/tests/sandbox-session.test.ts b/src/tests/sandbox-session.test.ts index fae2f9ed..d8efbd65 100644 --- a/src/tests/sandbox-session.test.ts +++ b/src/tests/sandbox-session.test.ts @@ -967,7 +967,9 @@ describe("round 8 of the #448 audit: host-side file tools are held to the sandbo }; expect((await run(read)).behavior).toBe("allow"); expect((await run({ ...read, content: "x" }, "Write")).message).toStartWith("Refused: outside what this session's sandbox allows"); - expect((await run({ file_path: join(homedir(), ".claude", "projects", "p", "other.jsonl") })).behavior).toBe("deny"); + // Other sessions' transcripts are readable (sandbox.homeReadable); the rest of the home isn't. + expect((await run({ file_path: join(homedir(), ".claude", "projects", "p", "other.jsonl") })).behavior).toBe("allow"); + expect((await run({ file_path: join(homedir(), "Documents", "notes.txt") })).behavior).toBe("deny"); }); test("not sandboxed (the owner's off): the gate is as before: guarded asks for a write outside, a read anywhere runs, autonomous runs a write outside", async () => {