diff --git a/bun.lock b/bun.lock index ee333d12..6f1b3d85 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ }, }, }, + "trustedDependencies": [], "packages": { "@better-auth/core": ["@better-auth/core@1.7.14", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.4", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-ByhdrGVwp/el+hoTYKSVOHIH23/lQj9TQ1txdGcWOg22tnfr3hJ6ukzySooeYj/R36XsQgBhkJqIp8mKsNeErA=="], diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 56e1a35c..9e22d582 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -141,6 +141,5 @@ fixes what it finds: - [Operator setup](/quest/m0/kix/operator-setup.md) - pilot installations and a verified test PR on moq-dev/moq and moq-dev/quest - [Private repositories](/quest/m0/kix/private.md) - moq-dev/moq.pro enrolls, visible only to signed-in users who can read it -- [Job sandbox](/quest/m0/kix/sandbox/README.md) - head approval for kix-pushed heads, and opencode sandboxed - [Runner account pool](/quest/m0/kix/cliproxy.md) - the runner routes every Claude and Codex job and review through its own CLIProxyAPI pool - [Web execution](/quest/m0/kix/execution.md) - spawn and iterate through the UI, ending in contribution PRs diff --git a/quest/m0/kix/execution.md b/quest/m0/kix/execution.md index 1efc02a8..c1b25595 100644 --- a/quest/m0/kix/execution.md +++ b/quest/m0/kix/execution.md @@ -65,7 +65,3 @@ Earlier guidance: - Cover the real prompt loop in integration tests, including simultaneous starts, a stale PR head, a blocked decision, an exhausted account, and a runner going offline mid-job. - -## Required - -- [Job sandbox](/quest/m0/kix/sandbox/README.md) - iterate and merge jobs on approved kix-pushed heads, with opencode sandboxed diff --git a/quest/m0/kix/sandbox/README.md b/quest/m0/kix/sandbox/README.md deleted file mode 100644 index 1f53f69c..00000000 --- a/quest/m0/kix/sandbox/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# Sandbox maintainers' jobs - -## Goal - -Every job's commands run in the OS sandbox, opencode's included, and a -maintainer can approve one exact PR head, such as one kix pushed, so its -iterate and merge jobs run on any runner. Runners keep refusing PR heads with -non-maintainer commits until -[contributor sandboxing](/quest/m1/kix/sandbox/README.md) in m1. - -## Plan - -Decided 2026-10-30 (user, /quest-plan): the untrusted-head VM, stock-Ubuntu -AppArmor, and macOS verification moved to -[m1](/quest/m1/kix/sandbox/README.md), since moq-dev's maintainers run their -own jobs. The decisions below still cover both epics. - -Decided 2026-10-15, from Codex's review of moq-dev/quest#121: - -- A Nix devShell is not an isolation boundary, and maintainers' machines - must not run contributor-controlled code unconfined. Sandbox all jobs, not - just untrusted ones: one execution path, which also contains prompt - injection from untrusted issue text. -- OS sandbox, not a VM per job: bubblewrap on Linux and Seatbelt on macOS. - Claude Code's docs say this does not replace a container or VM; the README - documents that residual risk. -- Untrusted heads build their devShell in a per-job VM on a Linux runner - before their commands run in the OS sandbox. macOS runners take trusted - revisions only, since a Linux VM cannot build a darwin devShell. An - untrusted-head job waits with "needs a Linux runner" while none is online. - Cloud runners are Linux-only. -- Decided 2026-10-22 on - [moq-dev/quest#188](https://github.com/moq-dev/quest/pull/188), from - Codex's security review: a job resumes with an answer from a maintainer - other than the runner's owner (the job's requester) only after the owner - approves it in the inbox, since the agent would act on that text with the - owner's machine. kix holds the job and the runner refuses an answer - without `approvedAt`. The sandbox lifts this gate once it is verified on - macOS. - -Shipped in [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212) -(`src/runner/sandbox.ts`), and verified live on Linux with Claude Code -2.1.291 and 2.1.305: - -- Claude Code's sandbox runs every command in `dontAsk` mode with - `--setting-sources user`, since a checkout's `.claude/settings.json` hooks - run outside the sandbox even in `-p` mode. The file tools edit and read - only the checkout, and WebFetch is off. -- The runner builds the devShell with `nix print-dev-env` from the job's - revision in the bare clone, never the edited checkout, and Claude Code - sources it through `CLAUDE_ENV_FILE` inside the sandbox, shellHook - included. -- Commands have no Nix daemon: on Linux the sandbox's seccomp filter - refuses Unix sockets, and allowing them all would expose every socket on - the filesystem, while a trusted Nix user could reach the daemon's - root-level options. `nix build` and `nix flake check` run in CI instead. -- Decided 2026-10-24 on - [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212): a - trusted revision's commands reach a fixed list of package registries - (npm, crates.io's index and downloads, PyPI), since most repositories - cannot run their checks without installing dependencies. An untrusted - revision's commands reach no network host. The answer-approval gate stays - until the sandbox is verified on macOS. -- opencode jobs' commands run under sandbox-runtime, set as opencode's - shell, with the same policy - ([moq-dev/quest#338](https://github.com/moq-dev/quest/pull/338)). - Its seccomp helper, which nixpkgs' sandbox-runtime leaves out, is built - from source (`nix/sandbox-runtime.nix`), and the runner refuses an opencode - account when srt lets commands make Unix sockets - ([moq-dev/quest#465](https://github.com/moq-dev/quest/pull/465)). - Besides disabling project config, the runner keeps the agent from creating - a checkout's own `opencode.json` or `.opencode/` and refuses a checkout - that holds them. Before each turn it refuses to run if opencode loaded any - config but the runner's and the account's, since a machine's managed - `/etc/opencode` loads above the runner's and could replace the sandboxed - `shell`. - `test/live/runner-sandbox.test.ts` checks that boundary with the pinned - opencode and srt and a scripted model, and - `test/live/runner-sandbox-claude.test.ts` checks Claude Code's the same way, - its scripted model set as the account's endpoint in its settings. It runs in `just runner-sandbox` - and a GitHub-hosted workflow (`.github/workflows/sandbox.yml`), nightly - and on PRs that touch the flake, `nix/`, the dependencies, the runner, or - these tests, not in `just test` or the merge queue, whose container can't - mount `/proc` for bwrap, so a bump that weakens the boundary fails on its - own PR. Decided 2026-11-03 (user, /quest-complete), after the merge - queue dropped [moq-dev/quest#404](https://github.com/moq-dev/quest/pull/404): - its own CI, nightly, so landing never blocks on it. -- Decided 2026-10-24 (user) in `/quest-plan`: split the remaining work into - the children below, ranked so the earliest unblock the most. Dropped - (user): read-only registry access, which Claude Code's sandbox cannot - filter by request today, and a cancellable `Workspace.prepare` fetch. -- Sources: - [Claude Code sandboxing](https://code.claude.com/docs/en/sandboxing) - and [sandbox-runtime](https://www.npmjs.com/package/@anthropic-ai/sandbox-runtime). - -## Required - -- [Per-job opencode saved output](/quest/m0/kix/sandbox/opencode-data.md) - each opencode job saves its tool output and shell captures beside its checkout, so no job reads another's, while sessions and the credential stay in the account's database diff --git a/quest/m0/kix/sandbox/opencode-data.md b/quest/m0/kix/sandbox/opencode-data.md deleted file mode 100644 index 4cbddef8..00000000 --- a/quest/m0/kix/sandbox/opencode-data.md +++ /dev/null @@ -1,80 +0,0 @@ -# [M] Per-job opencode saved output - -## Goal - -No opencode job can read the tool output or shell captures another job -saved, whether in another repository or the same one. Each job saves them -in its own directory, deleted with the job's checkout. Sessions and the -account's credential, including a refreshed OAuth token, stay in the -account's database that every job on the account shares. - -## Plan - -Decided 2026-11-04 (user, /quest-complete follow-up of -[moq-dev/quest#480](https://github.com/moq-dev/quest/pull/480)). On opencode -2.x, every job on an account shares the account's `data/opencode`, and the -job plugin lets the read-only file tools read all of its `tool-output` and -`shell` captures (`savedOutput` in `src/runner/opencode.ts`). A job can -therefore read output an earlier job saved, such as private moq-dev/moq.pro -output from a job on a public repository. - -- What should the quest's goal be? - - ✅ A per-job data directory - - Rejected: narrowing the plugin's read root to the current project's - subdirectory, which still lets jobs on the same repository read each - other's output -- Which milestone and epic? - - ✅ m0's job sandbox, since the leak already crosses m0's private and - public repositories - - Rejected: m1's contributor sandboxing - -Decided 2026-11-04 (user, /quest-complete on -[moq-dev/quest#484](https://github.com/moq-dev/quest/pull/484)): - -- Where should a job's sessions live? - - ✅ The shared account database: every job keeps using the account's - `opencode.db` through `OPENCODE_DB`, so the credential and its token - refreshes stay shared. Only saved output (`tool-output`, `shell`) moves - to the per-job directory. - - Rejected: a per-job database with a copied credential, because - concurrent jobs would each refresh their own copy, and where the - provider rotates refresh tokens, one job's refresh invalidates the - others'. - -What opencode 2.1.1 keeps under `$XDG_DATA_HOME/opencode`, from its source -at tag `v2.1.1`: - -- `opencode.db` holds both the sessions and the credentials (its - `credential` table). `auth.json` is only a legacy file a migration imports - once, so the credential cannot be linked in as a file. `OPENCODE_DB` - overrides the database's path, resolved against the data directory unless - it is absolute (`packages/cli/src/database-path.ts`), and the server - honours it (`packages/cli/src/server-process.ts`). -- `tool-output/` is flat, with no directory per project - (`packages/core/src/tool-output.ts`). Only `shell/` has one - (`packages/core/src/shell.ts`). Both keep files for 7 days. `log/` and - `repos/` live beside them. -- A shared database's other sessions are out of a job's reach. The subagent - tool resumes only a child of the current session - (`packages/core/src/tool/plugin/subagent.ts`). Renaming or moving another - session needs `tools.opencode.*` through `execute`, which `jobConfig` - denies. - -Implementation guidance: - -- Put the job's data directory beside its checkout, as `.review` - is, and remove it in `Workspace.remove`. It must not go inside the - checkout, which commands read and kix publishes. The runner's state - directory, which commands cannot read, already holds it. -- The launch sets `XDG_DATA_HOME` to that directory. `savedOutput`, the - `external_directory` rules, and the plugin's `reads` then name only this - job's `tool-output` and `shell`. -- The launch sets `OPENCODE_DB` to the absolute path of the account's - `opencode.db`. A relative name would resolve inside the job's directory - and give the job an empty database without the credential. -- Account sign-in and status keep the account's own data directory. -- Nothing is in production, so existing accounts' shared data needs no - migration. -- Extend `test/live/runner-sandbox.test.ts` with a second job, in the same - repository, that tries to read the first job's saved tool output and shell - capture. diff --git a/quest/m1/kix/sandbox/README.md b/quest/m1/kix/sandbox/README.md index f66fc35d..164baf8b 100644 --- a/quest/m1/kix/sandbox/README.md +++ b/quest/m1/kix/sandbox/README.md @@ -9,10 +9,89 @@ which lifts the owner's approval of another maintainer's answer. ## Plan -Split from [the m0 sandbox epic](/quest/m0/kix/sandbox/README.md) on -2026-10-30 (user, /quest-plan): moq-dev's maintainers run their jobs on -trusted or approved heads, so untrusted heads stay refused until m1. The -design decisions for the whole sandbox stay in the m0 epic's Plan. +Split from the m0 sandbox epic on 2026-10-30 (user, /quest-plan): +moq-dev's maintainers run their jobs on trusted or approved heads, so +untrusted heads stay refused until m1. The m0 epic finished with +[moq-dev/quest#536](https://github.com/moq-dev/quest/pull/536); its design +decisions for the whole sandbox moved here. + +Decided 2026-10-15, from Codex's review of moq-dev/quest#121: + +- A Nix devShell is not an isolation boundary, and maintainers' machines + must not run contributor-controlled code unconfined. Sandbox all jobs, not + just untrusted ones: one execution path, which also contains prompt + injection from untrusted issue text. +- OS sandbox, not a VM per job: bubblewrap on Linux and Seatbelt on macOS. + Claude Code's docs say this does not replace a container or VM; the README + documents that residual risk. +- Untrusted heads build their devShell in a per-job VM on a Linux runner + before their commands run in the OS sandbox. macOS runners take trusted + revisions only, since a Linux VM cannot build a darwin devShell. An + untrusted-head job waits with "needs a Linux runner" while none is online. + Cloud runners are Linux-only. +- Decided 2026-10-22 on + [moq-dev/quest#188](https://github.com/moq-dev/quest/pull/188), from + Codex's security review: a job resumes with an answer from a maintainer + other than the runner's owner (the job's requester) only after the owner + approves it in the inbox, since the agent would act on that text with the + owner's machine. kix holds the job and the runner refuses an answer + without `approvedAt`. The sandbox lifts this gate once it is verified on + macOS. + +Shipped in [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212) +(`src/runner/sandbox.ts`), and verified live on Linux with Claude Code +2.1.291 and 2.1.305: + +- Claude Code's sandbox runs every command in `dontAsk` mode with + `--setting-sources user`, since a checkout's `.claude/settings.json` hooks + run outside the sandbox even in `-p` mode. The file tools edit and read + only the checkout, and WebFetch is off. +- The runner builds the devShell with `nix print-dev-env` from the job's + revision in the bare clone, never the edited checkout, and Claude Code + sources it through `CLAUDE_ENV_FILE` inside the sandbox, shellHook + included. +- Commands have no Nix daemon: on Linux the sandbox's seccomp filter + refuses Unix sockets, and allowing them all would expose every socket on + the filesystem, while a trusted Nix user could reach the daemon's + root-level options. `nix build` and `nix flake check` run in CI instead. +- Decided 2026-10-24 on + [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212): a + trusted revision's commands reach a fixed list of package registries + (npm, crates.io's index and downloads, PyPI), since most repositories + cannot run their checks without installing dependencies. An untrusted + revision's commands reach no network host. The answer-approval gate stays + until the sandbox is verified on macOS. +- opencode jobs' commands run under sandbox-runtime, set as opencode's + shell, with the same policy + ([moq-dev/quest#338](https://github.com/moq-dev/quest/pull/338)). + Its seccomp helper, which nixpkgs' sandbox-runtime leaves out, is built + from source (`nix/sandbox-runtime.nix`), and the runner refuses an opencode + account when srt lets commands make Unix sockets + ([moq-dev/quest#465](https://github.com/moq-dev/quest/pull/465)). + Besides disabling project config, the runner keeps the agent from creating + a checkout's own `opencode.json` or `.opencode/` and refuses a checkout + that holds them. Before each turn it refuses to run if opencode loaded any + config but the runner's and the account's, since a machine's managed + `/etc/opencode` loads above the runner's and could replace the sandboxed + `shell`. + `test/live/runner-sandbox.test.ts` checks that boundary with the pinned + opencode and srt and a scripted model, and + `test/live/runner-sandbox-claude.test.ts` checks Claude Code's the same way, + its scripted model set as the account's endpoint in its settings. It runs in `just runner-sandbox` + and a GitHub-hosted workflow (`.github/workflows/sandbox.yml`), nightly + and on PRs that touch the flake, `nix/`, the dependencies, the runner, or + these tests, not in `just test` or the merge queue, whose container can't + mount `/proc` for bwrap, so a bump that weakens the boundary fails on its + own PR. Decided 2026-11-03 (user, /quest-complete), after the merge + queue dropped [moq-dev/quest#404](https://github.com/moq-dev/quest/pull/404): + its own CI, nightly, so landing never blocks on it. +- Decided 2026-10-24 (user) in `/quest-plan`: split the remaining work into + the m0 epic's children, since landed, ranked so the earliest unblock the most. Dropped + (user): read-only registry access, which Claude Code's sandbox cannot + filter by request today, and a cancellable `Workspace.prepare` fetch. +- Sources: + [Claude Code sandboxing](https://code.claude.com/docs/en/sandboxing) + and [sandbox-runtime](https://www.npmjs.com/package/@anthropic-ai/sandbox-runtime). ## Required diff --git a/quest/m2/cloud/funding.md b/quest/m2/cloud/funding.md index 761bf60a..0fa8380d 100644 --- a/quest/m2/cloud/funding.md +++ b/quest/m2/cloud/funding.md @@ -51,10 +51,6 @@ cloud runners; donor allocations follow working cloud runners separately. [SDK cost tracking](https://code.claude.com/docs/en/agent-sdk/cost-tracking), and [Console usage APIs](https://platform.claude.com/docs/en/manage-claude/usage-cost-api). -## Required - -- [Job sandbox](/quest/m0/kix/sandbox/README.md) - the boundary keeping provider credentials outside job commands - ## Related - [Donor allocations](/quest/m2/cloud/donors.md) - add shared budgets after cloud runners work diff --git a/quest/m2/donor-isolation.md b/quest/m2/donor-isolation.md index 703ddd6e..0877b1d2 100644 --- a/quest/m2/donor-isolation.md +++ b/quest/m2/donor-isolation.md @@ -7,8 +7,8 @@ with constrained network and filesystem access, without exposing host secrets. ## Plan -- m0 [sandboxes](/quest/m0/kix/sandbox/README.md) job commands at the OS level and - builds untrusted devShells in a VM. This quest adds a stronger whole-job +- m0 sandboxes job commands at the OS level and + [m1](/quest/m1/kix/sandbox/README.md) builds untrusted devShells in a VM. This quest adds a stronger whole-job boundary for shared runners running other people's projects. Nix devShells give reproducibility, not an isolation boundary for arbitrary commands. - Evaluate current microVM tooling and supported Linux/macOS hosts before diff --git a/src/runner/adapter.ts b/src/runner/adapter.ts index bc75f609..cd437337 100644 --- a/src/runner/adapter.ts +++ b/src/runner/adapter.ts @@ -40,6 +40,8 @@ export interface Launch { cwd: string; /** The account's isolated home, which holds its CLI login and sessions. */ home: string; + /** A directory for the CLI's files only this job may see, which commands cannot reach and which goes with the checkout. */ + data: string; session: string; /** Resume `session` rather than starting it. */ resume: boolean; diff --git a/src/runner/home.ts b/src/runner/home.ts index 1add0c1b..09b7e578 100644 --- a/src/runner/home.ts +++ b/src/runner/home.ts @@ -4,7 +4,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import { readFileSync, readdirSync, rmSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { privateDirectory } from "../cli/agent"; import { type Owner, alive, currentOwner } from "../cli/supervise"; @@ -55,7 +55,8 @@ export class RunnerHome { readonly dir: string; constructor(dir: string) { - this.dir = privateDirectory(dir); + // Absolute, since the sandbox and opencode's OPENCODE_DB take the paths under it as they are. + this.dir = privateDirectory(resolve(dir)); } login(): Login | undefined { diff --git a/src/runner/job.ts b/src/runner/job.ts index 4634bcb1..47b60559 100644 --- a/src/runner/job.ts +++ b/src/runner/job.ts @@ -279,6 +279,7 @@ export class Attempt { this.#agent = adapter.launch({ cwd: this.#workspace.dir, home: ctx.home(account), + data: this.#workspace.data, session: this.#session, resume, prompt: resume ? resumePrompt(job) : startPrompt(job, this.instructions, this.#workspace.dir), diff --git a/src/runner/opencode.ts b/src/runner/opencode.ts index 8398ede3..4616575e 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -82,9 +82,19 @@ export function environment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { ); } -/** opencode's environment in an account's home, as `HOME` too, since opencode loads plugins and skills from it. */ +/** The database holding an account's opencode sessions and credentials, which every job on it shares. */ +export function accountDatabase(home: string): string { + return join(accountDirectories(home).XDG_DATA_HOME, "opencode", "opencode.db"); +} + +/** + * opencode's environment in an account's home, as `HOME` too, since opencode + * loads plugins and skills from it. The database is named absolutely, since a + * job's data directory is elsewhere, and opencode would otherwise pick one per + * release channel. + */ function accountEnvironment(env: NodeJS.ProcessEnv, home: string): NodeJS.ProcessEnv { - return { ...environment(env), ...accountDirectories(home), HOME: home }; + return { ...environment(env), ...accountDirectories(home), HOME: home, OPENCODE_DB: accountDatabase(home) }; } /** The account's own opencode config: which providers it may use, and the model it picked. */ @@ -239,6 +249,9 @@ export default { const writes = options.writes.map(real); const reads = [...writes, ...options.reads.map(real)]; const config = options.config.map(real); + await ctx.tool.transform((editor) => { + for (const tool of editor.list()) if (tool.options?.namespace === "opencode") editor.remove(tool.id); + }); await ctx.tool.hook("execute.before", ({ tool, input }) => { const files = Object.hasOwn(TOOLS, tool); if (!files && !FOREGROUND.includes(tool)) return; @@ -287,22 +300,24 @@ exec /usr/bin/env -i ${kept.join(" ")} "QUEST_COMMAND=$2" ${quote(srt)} --settin } /** - * Where opencode saves the full output it truncates: a tool's, and a shell - * command's, under a directory per project. The read-only file tools may read them. + * Where opencode saves the full output it truncates, a tool's and a shell + * command's, in the job's own data directory so no other job can read it. The + * read-only file tools may read them. */ -function savedOutput(home: string): string[] { - const data = join(accountDirectories(home).XDG_DATA_HOME, "opencode"); - return [join(data, "tool-output"), join(data, "shell")]; +function savedOutput(data: string): string[] { + return [join(data, "opencode", "tool-output"), join(data, "opencode", "shell")]; } /** * opencode's config for a job. Every tool is allowed, as Claude Code's * permission mode allows its own, except: questions, which go through kix; * WebFetch, web search, and the desktop browser, which would run in the - * unconfined server; Code Mode's `execute`, which carries opencode's own - * tools, such as moving the session to another directory; and the file tools' - * paths outside the checkout, the attempt's directory, and opencode's saved - * output, which the plugin keeps read-only. A tool whose last matching rule denies it + * unconfined server; Code Mode's `execute`; and the file tools' paths outside + * the checkout, the attempt's directory, and the job's saved output, which the + * plugin keeps read-only. The plugin also removes opencode's own tools, which + * opencode offers directly once `execute` is denied, such as moving or renaming + * any session in the account's shared database, or making or removing a + * worktree anywhere; one checks its permission under another tool's name. A tool whose last matching rule denies it * everywhere is not offered at all. The kix tools stay direct tools rather than * Code Mode's. `instructions` is the file holding the launch's system prompt. */ @@ -326,7 +341,7 @@ export function jobConfig(options: { deny("execute"), deny("browser"), deny("external_directory"), - ...[launch.sandbox.tmp, ...savedOutput(launch.home)].map((dir) => ({ + ...[launch.sandbox.tmp, ...savedOutput(launch.data)].map((dir) => ({ action: "external_directory", resource: join(dir, "*"), effect: "allow", @@ -353,7 +368,7 @@ export function jobConfig(options: { options: { cwd: launch.cwd, writes: [launch.cwd, launch.sandbox.tmp], - reads: savedOutput(launch.home), + reads: savedOutput(launch.data), config: PROJECT_CONFIG.map((name) => join(launch.cwd, name)), }, }, @@ -695,6 +710,7 @@ export class Opencode implements Adapter { const shell = join(dir, "sandboxed-bash"); writeFileSync(shell, shellScript(this.srt, settings, launch.sandbox, launch.env), { mode: 0o700 }); const config = jobConfig({ launch, shell, plugin, instructions }); + mkdirSync(launch.data, { recursive: true, mode: 0o700 }); // The server runs outside the devShell, whose shellHook the job's revision controls, and with a // HOME commands cannot write, since opencode loads plugins and instructions from it. const server = serve( @@ -702,6 +718,9 @@ export class Opencode implements Adapter { launch.cwd, { ...accountEnvironment(launch.env, launch.home), + // The job's saved output stays its own, while its sessions and the credential, whose refreshed + // tokens every job must see, stay in the account's database. + XDG_DATA_HOME: launch.data, TMPDIR: join(dir, "tmp"), OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_SERVER_PASSWORD: password, diff --git a/src/runner/workspace.ts b/src/runner/workspace.ts index e0ec5ae2..fd99eb23 100644 --- a/src/runner/workspace.ts +++ b/src/runner/workspace.ts @@ -393,12 +393,17 @@ export class Workspace { return { root: dir, base }; } + /** A directory beside the checkout for the agent CLI's own per-job files, outside what the agent's sandbox can reach. */ + get data(): string { + return `${this.dir}.data`; + } + /** The branch `reviewCheckout` checks out, one per job. */ get reviewBranch(): string { return `kix-review/${basename(this.dir)}`; } - /** Delete the checkout, its review checkout and session, and its record once the job is done with this runner. */ + /** Delete the checkout, its review checkout and session, its data, and its record once the job is done with this runner. */ async remove(): Promise { await locked(this.#git, async () => { await remove(this.#git, this.dir); @@ -406,6 +411,7 @@ export class Workspace { await run("git", ["-C", this.#git, "branch", "--quiet", "-D", this.reviewBranch]).catch(() => {}); rmSync(sessionFile(this.#git, this.reviewBranch), { force: true }); }); + rmSync(this.data, { recursive: true, force: true }); rmSync(this.#file, { force: true }); } } diff --git a/test/live/runner-sandbox-claude.test.ts b/test/live/runner-sandbox-claude.test.ts index 7244f10b..17e9ae1e 100644 --- a/test/live/runner-sandbox-claude.test.ts +++ b/test/live/runner-sandbox-claude.test.ts @@ -390,6 +390,7 @@ const RUN_BOUND = 4 * 60_000; async function run(adapter: Claude, sandbox: Sandbox, output: (line: string) => void) { const running = adapter.launch({ cwd: paths.checkout, + data: `${paths.checkout}.data`, home: paths.account, session: crypto.randomUUID(), resume: false, diff --git a/test/live/runner-sandbox.test.ts b/test/live/runner-sandbox.test.ts index d46b0b24..6607685c 100644 --- a/test/live/runner-sandbox.test.ts +++ b/test/live/runner-sandbox.test.ts @@ -1,6 +1,7 @@ // The opencode sandbox's boundary, with the real opencode and sandbox-runtime // (`srt`): a scripted model drives one runner job through `Opencode.launch` in -// a checkout that tries to escape, and each attempt is refused. +// a checkout that tries to escape, and each attempt is refused. A second job on +// the same account and repository then tries to read the output the first saved. // // `test/runner.test.ts` checks only the settings the runner hands opencode and // srt; whether they confine anything is those programs' behavior, which can @@ -18,6 +19,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, rmSync, @@ -27,11 +29,17 @@ import { import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { createServer as createNetServer, type Server as NetServer } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { requireProgram } from "../../src/cli/program"; -import { accountDirectories, Opencode, PROJECT_CONFIG, projectConfig } from "../../src/runner/opencode"; +import { + accountDatabase, + accountDirectories, + Opencode, + PROJECT_CONFIG, + projectConfig, +} from "../../src/runner/opencode"; import { binaryPath } from "../../src/runner/cli"; import { closeSandbox, openSandbox, quote, type Sandbox, unixSocketRefusal } from "../../src/runner/sandbox"; @@ -45,6 +53,8 @@ const TOOLS_TOKEN = "quest-boundary-tools-token"; interface Paths { /** The job's checkout: a worktree of `repository`. */ checkout: string; + /** Another job's checkout of the same repository, on the same account. */ + second: string; /** The bare clone, which commands read but must not write, such as a hook git would run on the host. */ repository: string; /** The runner's state, which commands can neither read nor write. */ @@ -70,8 +80,10 @@ interface Attempt { } /** The database opencode keeps the account's credentials in. */ -const credentialStore = (paths: Paths) => - join(accountDirectories(paths.account).XDG_DATA_HOME, "opencode", "opencode.db"); +const credentialStore = (paths: Paths) => accountDatabase(paths.account); + +/** The runner's data directory for the job in `checkout`. */ +const dataDirectory = (checkout: string) => `${checkout}.data`; /** Commands avoid `cd` and the like, whose directory opencode's own path check refuses before srt sees it. */ const ATTEMPTS: Attempt[] = [ @@ -196,6 +208,14 @@ const bash = (command: string): Call => ({ tool: "shell", args: { command } }); /** A command inside the sandbox that must work, so the refusals above are not every command failing. */ const ALLOWED = bash(`printf allowed > allowed.txt && printf ok > "$TMPDIR/allowed" && echo ALLOWED-$((6 * 7))`); +/** + * A command whose output opencode truncates, saving it whole as a shell + * capture: what the second job tries to read. opencode saves other tools' + * output whole only for tools that bound none of their own, such as the kix + * tools, so the test puts that file where opencode would. + */ +const SAVED = "first-job-saved-output"; +const TRUNCATED = bash(`printf '%s-%s\\n' first-job saved-output; seq 3000`); /** The command's environment, which must hold none of the server's secrets. */ const ENVIRONMENT = bash("env"); /** @@ -220,8 +240,9 @@ const missing = new Set(); /** * Every tool the job offers the model, so a release adding one fails here - * until it is reviewed: the questions, WebFetch, web search, and Code Mode it - * denies are absent, and so are the kix tools, at a port nothing listens on. + * until it is reviewed: the questions, WebFetch, web search, Code Mode, and + * opencode's own tools it denies are absent, and so are the kix tools, at a + * port nothing listens on. */ const OFFERED = ["edit", "glob", "grep", "read", "shell", "skill", "subagent", "write"]; /** Standing instructions for the job, which must reach the model. */ @@ -242,13 +263,15 @@ describe.skipIf(!linux)("opencode's sandbox", () => { const repository = join(runner, "repos", "fixture.git"); const seed = join(scratch, "seed"); git(scratch, "init", "-q", "-b", "main", seed); - symlinkSync(join(accountDirectories(account).XDG_DATA_HOME, "opencode", "opencode.db"), join(seed, "leak")); + symlinkSync(accountDatabase(account), join(seed, "leak")); writeFileSync(join(seed, "README.md"), "fixture\n"); git(seed, "add", "."); git(seed, "commit", "-qm", "base"); git(scratch, "clone", "-q", "--bare", seed, repository); const checkout = join(runner, "checkouts", "job"); git(repository, "worktree", "add", "-q", "--detach", checkout, "main"); + const second = join(runner, "checkouts", "second"); + git(repository, "worktree", "add", "-q", "--detach", second, "main"); server = await listen(model); const socket = join(repository, "host.sock"); @@ -257,6 +280,7 @@ describe.skipIf(!linux)("opencode's sandbox", () => { if (!address || typeof address === "string") throw new Error("the scripted model has no port"); paths = { checkout, + second, repository, runner, account, @@ -286,7 +310,6 @@ describe.skipIf(!linux)("opencode's sandbox", () => { test("an opencode job's commands cannot escape", { timeout: 5 * 60_000 }, async () => { const srt = binaryPath("srt", process.env); - const opencode = binaryPath("opencode", process.env); const sandbox = openSandbox({ cwd: paths.checkout, repository: paths.repository, @@ -301,11 +324,17 @@ describe.skipIf(!linux)("opencode's sandbox", () => { DESCRIPTORS, ...ATTEMPTS.map((attempt) => bash(attempt.command(paths))), ...FILE_ATTEMPTS.map((attempt) => attempt.call), + TRUNCATED, ]; let output = ""; let exit: Awaited>; try { - exit = await run(new Opencode(opencode, srt, process.env), sandbox, (line) => (output += line)); + exit = await run({ + checkout: paths.checkout, + sandbox, + session: "boundary", + output: (line) => (output += line), + }); } finally { closeSandbox(sandbox); } @@ -324,7 +353,7 @@ describe.skipIf(!linux)("opencode's sandbox", () => { const [allowed, environment, descriptors, ...rest] = results; const attempts = rest.slice(0, ATTEMPTS.length); - const files = rest.slice(ATTEMPTS.length); + const files = rest.slice(ATTEMPTS.length, ATTEMPTS.length + FILE_ATTEMPTS.length); expect(allowed, "a permitted command failed, so every refusal would pass vacuously").toContain("ALLOWED-42"); expect(readFileSync(join(paths.checkout, "allowed.txt"), "utf8")).toBe("allowed"); for (const secret of [KEY, TOOLS_TOKEN, "OPENCODE_", "XDG_DATA_HOME"]) @@ -359,8 +388,59 @@ describe.skipIf(!linux)("opencode's sandbox", () => { for (const name of PROJECT_CONFIG) expect(existsSync(join(paths.checkout, name)), name).toBe(false); expect(git(paths.checkout, "status", "--porcelain", "--ignored")).toBe("?? allowed.txt\n"); }); + + test("another job on the repository cannot read the output the first saved", { timeout: 5 * 60_000 }, async () => { + // The first job's saved output, in its own data directory. + const data = join(dataDirectory(paths.checkout), "opencode"); + const capture = readdirSync(join(data, "shell"), { recursive: true, encoding: "utf8" }) + .filter((name) => name.endsWith(".out")) + .map((name) => join(data, "shell", name)) + .find((file) => readFileSync(file, "utf8").includes(SAVED)); + expect(capture, "the first job saved no shell capture to read").toBeDefined(); + const output = join(data, "tool-output", "tool_000000000000first"); + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, `${SAVED}\n`); + + const sandbox = openSandbox({ + cwd: paths.second, + repository: paths.repository, + runnerHome: paths.runner, + devShell: null, + trusted: false, + }); + const reads = [output, capture!].flatMap((path) => [ + { tool: "read", args: { path } }, + bash(`cat ${quote(path)}`), + ]); + script = reads; + requests.length = 0; + let log = ""; + let exit: Awaited>; + try { + exit = await run({ checkout: paths.second, sandbox, session: "second", output: (line) => (log += line) }); + } finally { + closeSandbox(sandbox); + } + const results = toolResults(); + const context = `exit ${JSON.stringify(exit)}\n${log.slice(-4000)}`; + expect(exit.failed, context).toBe(false); + expect(results, `the model saw ${results.length} of ${script.length} results\n${context}`).toHaveLength( + script.length, + ); + results.forEach((result, index) => + expect(result, `${JSON.stringify(reads[index])} read the first job's output`).not.toContain(SAVED), + ); + }); }); +/** One job on the account, in its own checkout and sandbox. */ +interface Job { + checkout: string; + sandbox: Sandbox; + session: string; + output(line: string): void; +} + /** How long the job may run before it is stopped, within the test's timeout and the stop's grace. */ const RUN_BOUND = 4 * 60_000; @@ -369,11 +449,13 @@ const RUN_BOUND = 4 * 60_000; * listens on. A turn that runs past `RUN_BOUND` is stopped and awaited, so a * hung opencode or srt never outlives the test. */ -async function run(adapter: Opencode, sandbox: Sandbox, output: (line: string) => void) { +async function run({ checkout, sandbox, session, output }: Job) { + const adapter = new Opencode(binaryPath("opencode", process.env), binaryPath("srt", process.env), process.env); const running = adapter.launch({ - cwd: paths.checkout, + cwd: checkout, home: paths.account, - session: "boundary", + data: dataDirectory(checkout), + session, resume: false, prompt: "Run the commands you are given.", system: SYSTEM, @@ -434,7 +516,7 @@ function signIn(account: string, baseURL: string) { ); const imported = spawnSync(binaryPath("opencode", process.env), ["auth", "import", "--standalone", exported], { cwd: account, - env: { PATH: process.env.PATH, HOME: account, ...dirs }, + env: { PATH: process.env.PATH, HOME: account, ...dirs, OPENCODE_DB: accountDatabase(account) }, encoding: "utf8", }); rmSync(exported); diff --git a/test/runner.test.ts b/test/runner.test.ts index 4388ea50..f07426d5 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -870,15 +870,29 @@ describe("opencode", () => { const { default: plugin } = await import(pathToFileURL(file).href); const config = ["opencode.json", ".opencode"].map((name) => join(checkout, name)); let hook: ((event: { tool: string; input: unknown }) => unknown) | undefined; + let tools = [ + { id: "read", options: { codemode: false } }, + { id: "opencode_session_move", options: { namespace: "opencode" } }, + { id: "opencode_worktree_remove", options: { namespace: "opencode", permission: "edit" } }, + { id: "kix_ask", options: { namespace: "kix" } }, + ]; await plugin.setup({ options: { cwd: checkout, writes: [checkout, tmp], reads: [output], config }, tool: { + transform: async (callback: (editor: unknown) => void) => { + callback({ + list: () => tools, + remove: (id: string) => (tools = tools.filter((tool) => tool.id !== id)), + }); + }, hook: async (name: string, callback: typeof hook) => { expect(name).toBe("execute.before"); hook = callback; }, }, }); + // opencode's own tools run in the server, one checking its permission under another tool's name. + expect(tools.map((tool) => tool.id)).toEqual(["read", "kix_ask"]); const call = async (tool: string, input: unknown) => hook!({ tool, input }); const patch = (...headers: string[]) => ({ patchText: ["*** Begin Patch", ...headers, "+x", "*** End Patch"].join("\n"), @@ -1534,7 +1548,7 @@ const plugin = config && (async () => { const { package: dir, options } = config.plugins[0]; const { default: definition } = await import(pathToFileURL(join(dir, "server.js")).href); let hooked = false; - await definition.setup({ options, tool: { hook: async () => (hooked = true) } }); + await definition.setup({ options, tool: { transform: async (edit) => edit({ list: () => [], remove() {} }), hook: async () => (hooked = true) } }); return { id: definition.id, source: { type: "local", path: dir }, features: {}, state: hooked ? { status: "active" } : { status: "failed", error: "no hook" } }; })(); const body = (request) => new Promise((resolve) => { @@ -1548,7 +1562,7 @@ async function turn(session, text) { const resume = fs.existsSync(logFile) && fs.readFileSync(logFile, "utf8").includes('"prompted":"' + session + '"'); log({ argv, cwd: process.cwd(), stdin: text, system: fs.readFileSync(config.instructions[0], "utf8"), session, prompted: session, - xdg: process.env.XDG_DATA_HOME, home: process.env.HOME, devShell: process.env.IN_DEVSHELL ?? null, + xdg: process.env.XDG_DATA_HOME, db: process.env.OPENCODE_DB ?? null, home: process.env.HOME, devShell: process.env.IN_DEVSHELL ?? null, gh: process.env.GH_TOKEN ?? null, projectConfig: process.env.OPENCODE_DISABLE_PROJECT_CONFIG ?? null, config, }); await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "fake", version: "0" } }); @@ -2142,15 +2156,18 @@ describe("quest runner", () => { const [nix, first, srt, shell] = r.calls(); expect(nix.nix.slice(0, 3)).toEqual(["print-dev-env", "--accept-flake-config", "--no-write-lock-file"]); const home = r.home.accountHome("acct1"); + const checkout = r.home.path("jobs", "job1"); + const data = `${checkout}.data`; // The server runs outside the devShell, in the account's own homes, and loads nothing from the checkout. expect(first.argv).toEqual(["serve", "--stdio", "--hostname=127.0.0.1", "--port=0"]); expect(first.devShell).toBeNull(); - expect(first.xdg).toBe(join(home, "data")); + // It saves output in the job's own data directory, but keeps sessions and the credential in the account's database. + expect(first.xdg).toBe(data); + expect(first.db).toBe(join(home, "data", "opencode", "opencode.db")); expect(first.home).toBe(home); expect(first.projectConfig).toBe("1"); expect(first.gh).toBeNull(); // Its commands run through sandbox-runtime with the policy Claude Code's sandbox gets. - const checkout = r.home.path("jobs", "job1"); const repository = srt.settings.filesystem.allowRead[1]; const [tmp] = srt.settings.filesystem.allowWrite.slice(1); expect(srt.srt[0]).toBe("--settings"); @@ -2188,9 +2205,11 @@ describe("quest runner", () => { resource: "*", effect: "deny", })), - ...[tmp, join(home, "data", "opencode", "tool-output"), join(home, "data", "opencode", "shell")].map( - (dir) => ({ action: "external_directory", resource: `${dir}/*`, effect: "allow" }), - ), + ...[tmp, join(data, "opencode", "tool-output"), join(data, "opencode", "shell")].map((dir) => ({ + action: "external_directory", + resource: `${dir}/*`, + effect: "allow", + })), ], lsp: false, formatter: false, @@ -2207,11 +2226,12 @@ describe("quest runner", () => { expect(first.config.plugins[0].options).toEqual({ cwd: checkout, writes: [checkout, tmp], - reads: [join(home, "data", "opencode", "tool-output"), join(home, "data", "opencode", "shell")], + reads: [join(data, "opencode", "tool-output"), join(data, "opencode", "shell")], config: [".opencode", "opencode.json", "opencode.jsonc"].map((name) => join(checkout, name)), }); - // The launch's shell, plugin, and settings are gone once it ends. + // The launch's shell, plugin, and settings are gone once it ends, but the job's data stays for its next attempt. expect(existsSync(dirname(first.config.shell))).toBe(false); + expect(existsSync(data)).toBe(true); // The server's own output and its events stream as raw output. const output = r.hub .of("output") @@ -2249,6 +2269,9 @@ describe("quest runner", () => { // The kix session maps to the opencode session that asked. expect(resumed[1].session).toBe(first.session); expect(resumed[1].stdin).toContain("Q: Which?\nA: A"); + // The job's data goes with its checkout. + expect(existsSync(checkout)).toBe(false); + expect(existsSync(data)).toBe(false); await r.runner.shutdown(); });