From 2c952e81073e817efe2e54552244af6680f55dd2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 10:55:31 -0800 Subject: [PATCH 1/6] chore(quest): claim quest/m0/kix/sandbox/opencode-data.md Quest-Claim: 2f8a892aaa2b4e75 From d10d2d92dfb7210ad174f115e43a39e5ada29a2e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 11:08:54 -0800 Subject: [PATCH 2/6] feat(runner): keep each opencode job's saved output its own Each opencode job now saves its tool output and shell captures in a data directory beside its checkout, removed with it, while sessions and the credential stay in the account's database through an absolute OPENCODE_DB. The runner's plugin also removes opencode's own tools, which opencode 2.2 offers directly once Code Mode's execute is denied. Co-Authored-By: Claude Opus 5.5 --- quest/m0/kix/sandbox/README.md | 1 - quest/m0/kix/sandbox/opencode-data.md | 80 ------------------ src/runner/adapter.ts | 2 + src/runner/home.ts | 5 +- src/runner/job.ts | 1 + src/runner/opencode.ts | 45 ++++++++--- src/runner/workspace.ts | 8 +- test/live/runner-sandbox.test.ts | 112 ++++++++++++++++++++++---- test/runner.test.ts | 41 +++++++--- 9 files changed, 174 insertions(+), 121 deletions(-) delete mode 100644 quest/m0/kix/sandbox/opencode-data.md diff --git a/quest/m0/kix/sandbox/README.md b/quest/m0/kix/sandbox/README.md index 44dea2e1..dc77597f 100644 --- a/quest/m0/kix/sandbox/README.md +++ b/quest/m0/kix/sandbox/README.md @@ -92,5 +92,4 @@ Shipped in [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212) ## 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 - [Claude Code boundary test](/quest/m0/kix/sandbox/claude-boundary.md) - the same hostile-repository test for Claude Code jobs, with a fake model set in the account's settings 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/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 8aea4d19..8408cff0 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.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 25d7c640..8e0bc937 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" } }); @@ -2135,15 +2149,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"); @@ -2181,9 +2198,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, @@ -2200,11 +2219,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") @@ -2242,6 +2262,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(); }); From 24037351ec9e3ab0f956239abe4296152c69b82d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 14:21:58 -0800 Subject: [PATCH 3/6] chore(quest): claim quest/m0/kix/sandbox/pr-trigger.md Quest-Claim: e6a6c03dae5b528a From fb27360c7980069f2bf05ae78c88b65d0bbafc34 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 14:22:20 -0800 Subject: [PATCH 4/6] ci(sandbox): run the boundary tests on PRs that move the agents Co-Authored-By: Claude Opus 5.5 --- .github/workflows/sandbox.yml | 17 +++++++++++++++-- README.md | 3 ++- quest/m0/kix/sandbox/README.md | 10 +++++----- quest/m0/kix/sandbox/pr-trigger.md | 28 ---------------------------- 4 files changed, 22 insertions(+), 36 deletions(-) delete mode 100644 quest/m0/kix/sandbox/pr-trigger.md diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 47fe9dff..0f35dbdb 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -1,8 +1,11 @@ name: Sandbox # The runner sandbox's boundary tests (`just runner-sandbox`) run nightly on a -# GitHub-hosted runner, outside pull requests and the merge queue, so landing -# never waits on them. GitHub reports a failed scheduled run only to the account +# GitHub-hosted runner, and on pull requests that change the agents they check: +# the flake's pins, the runner's sources, or the tests themselves. They stay +# off the merge queue's required checks, so landing never waits on them; +# /quest-merge names a failing optional check before it queues. The job runs PR +# code, so it stays read-only and uses no secrets. GitHub reports a failed scheduled run only to the account # that last changed the cron schedule (with Actions notifications on), not to # the repository's watchers; that is the maintainer, whose account lands the # workflow, so they see a boundary failure. @@ -10,6 +13,16 @@ on: schedule: - cron: "23 7 * * *" workflow_dispatch: + pull_request: + paths: + - flake.lock + - flake.nix + - nix/** + - package.json + - bun.lock + - src/runner/** + - test/live/runner-sandbox*.ts + - .github/workflows/sandbox.yml permissions: contents: read diff --git a/README.md b/README.md index 1a75bf64..eae784ce 100644 --- a/README.md +++ b/README.md @@ -1414,7 +1414,8 @@ repository runs a scripted model through the pinned Claude Code, and through the pinned opencode and sandbox-runtime, in a checkout that tries to read credentials, write outside its sandbox, plant its own agent config and hooks, reach the network, and connect to the Nix daemon's or another Unix socket, and -fails if any attempt gets through. CI runs it nightly, outside the merge queue. +fails if any attempt gets through. CI runs it nightly and on pull requests that change the pins or the runner, +outside the merge queue. On Linux the sandbox needs `bwrap` and `socat` (the flake's `runner` package pins both) and unprivileged user namespaces, including the nested one the diff --git a/quest/m0/kix/sandbox/README.md b/quest/m0/kix/sandbox/README.md index fb5c0114..1f53f69c 100644 --- a/quest/m0/kix/sandbox/README.md +++ b/quest/m0/kix/sandbox/README.md @@ -78,10 +78,11 @@ Shipped in [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212) 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 nightly GitHub-hosted workflow (`.github/workflows/sandbox.yml`), - not in `just test` or the merge queue, whose container can't mount - `/proc` for bwrap, so a bump that weakens the boundary fails the next - nightly run. Decided 2026-11-03 (user, /quest-complete), after the merge + 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 @@ -95,4 +96,3 @@ Shipped in [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212) ## 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 -- [Boundary tests on agent-moving PRs](/quest/m0/kix/sandbox/pr-trigger.md) - PRs that bump flake.lock or change the runner run the live sandbox tests before landing diff --git a/quest/m0/kix/sandbox/pr-trigger.md b/quest/m0/kix/sandbox/pr-trigger.md deleted file mode 100644 index e2aef7bd..00000000 --- a/quest/m0/kix/sandbox/pr-trigger.md +++ /dev/null @@ -1,28 +0,0 @@ -# [S] The sandbox boundary tests run on PRs that move the agents - -## Goal - -A PR that changes what the runner's agents are, such as a `flake.lock` -bump or a change to the opencode client or the runner's sandbox code, runs -the live boundary tests (`just runner-sandbox`) before it lands, so a -failure shows on the PR instead of the next nightly run. The merge gate -still does not wait on them. - -## Plan - -Decided 2026-11-07 (coordinator, follow-up of -[moq-dev/quest#536](https://github.com/moq-dev/quest/pull/536)): the -nixpkgs bump in [moq-dev/quest#502](https://github.com/moq-dev/quest/pull/502) -moved the pinned opencode from 2.1.1 to 2.2.1, which offered jobs its own -session and worktree tools outside the sandbox. Nothing ran the boundary -test until the next nightly run, which failed after the bump had merged. - -- Add a `pull_request` trigger with a paths filter to - `.github/workflows/sandbox.yml`: `flake.lock`, `flake.nix`, the runner's - sources and its live tests. Keep the job off the merge queue's required - checks; `/quest-merge` already names a failing optional check before it - queues. -- Keep the workflow read-only (`contents: read`, no secrets), since it runs - PR code. -- Update the workflow's header comment and the sandbox epic's README, - which say the test runs only nightly. From ca885e021a560c76d75c50d8082962b2bcfc6f9e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 14:24:53 -0800 Subject: [PATCH 5/6] test: pass the job's data directory to the Claude boundary test --- bun.lock | 1 + test/live/runner-sandbox-claude.test.ts | 1 + 2 files changed, 2 insertions(+) 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/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, From 3d5a9935407055d3a1f37c9b315c1629a967b95f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 14:25:38 -0800 Subject: [PATCH 6/6] quest: finish the m0 sandbox epic, moving its decisions to m1's Merges #560's branch so the epic's deletion holds whichever lands first. Co-Authored-By: Claude Opus 5.5 --- quest/m0/kix/README.md | 1 - quest/m0/kix/execution.md | 4 -- quest/m0/kix/sandbox/README.md | 94 ---------------------------------- quest/m1/kix/sandbox/README.md | 87 +++++++++++++++++++++++++++++-- quest/m2/cloud/funding.md | 4 -- quest/m2/donor-isolation.md | 4 +- 6 files changed, 85 insertions(+), 109 deletions(-) delete mode 100644 quest/m0/kix/sandbox/README.md 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 06a4b126..00000000 --- a/quest/m0/kix/sandbox/README.md +++ /dev/null @@ -1,94 +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). 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