Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,23 +522,45 @@ default. Use Claude Code for Claude subscriptions, since opencode cannot sign
in to them. kix.quest/runners shows each runner's presence and sets the order
jobs use your accounts in.

Every job runs in its own checkout inside the project's `nix develop` shell,
with the flake's `nixConfig` honored; a project without a flake gets a bootstrap
job instead. The agent reports through kix tools: `checkpoint` summaries publish
Every job runs in its own checkout with the project's devShell, which the runner
builds with `nix print-dev-env` from the job's revision, honoring the flake's
`nixConfig`; a project without a flake gets a bootstrap job instead. On Claude
Code accounts, Claude Code's sandbox (bubblewrap on Linux, Seatbelt on macOS)
confines every command the agent runs, the devShell's shellHook included:
commands write only to the checkout and a private temporary directory that is
also their `HOME`, cannot read account homes, the runner's state, or shared
temporary directories, and have no Nix daemon. A trusted revision's commands
reach only the npm, crates.io, and PyPI package registries, so its checks can
install dependencies; an untrusted one's reach no network host. npm's registry
also accepts publishes, so a prompt-injected agent holding someone's npm token
could publish the checkout there. The agent's file tools edit only the checkout,
WebFetch is off, and the checkout's own `.claude` settings, hooks, and MCP
servers are never loaded. Claude Code itself runs outside the sandbox with a
minimal environment, so its login stays out of the commands' reach. This is an
OS sandbox, not a VM, and it narrows rather than removes the risk of running
agent commands on your machine. opencode has no sandbox yet: on opencode
accounts the agent's commands run unconfined on your machine, in the devShell
with a private `HOME`.

On Linux the sandbox needs `bwrap` and `socat` (the flake's `runner` package
pins both) and unprivileged user namespaces; on Ubuntu 23.10 and later,
AppArmor allows them only for programs with a profile granting `userns`.
The agent reports through kix tools: `checkpoint` summaries publish
work in progress to the job's branch, `ask` pauses the job on a decision for you,
and `deliver` publishes the result and its pull request, listing any suggested
follow-ups, which the inbox offers you to plan. A job on an issue or
pull request reads it through `context`, since the runner holds no GitHub
token; a new plan reads your idea and pasted context there. kix publishes as its GitHub App, so the runner never pushes. A job that hits an account's limit
pauses for you to pick another account; the runner never switches on its own.
On SIGINT or SIGTERM, running work is published and its jobs move to another
runner. Until the job sandbox ships, runners take only trusted revisions,
agents run with every tool allowed (Claude Code's `bypassPermissions`), and a
job resumes with another maintainer's answer only once you approve it in the
inbox. opencode runs as `opencode serve` on localhost behind a password made for each turn.

The flake's `runner` package is a `quest` whose Claude Code and opencode are
pinned by this repository's lock (`nix run github:kixelated/quest#runner -- runner`);
runner. Runners take only trusted revisions until they can build untrusted
heads' devShells in a VM, and a job resumes with another maintainer's answer
only once you approve it in the inbox. opencode runs as `opencode serve` on
localhost behind a password made for each turn.

The flake's `runner` package is a `quest` whose Claude Code and opencode (and on
Linux, bubblewrap and socat) are pinned by this repository's lock
(`nix run github:kixelated/quest#runner -- runner`);
otherwise `claude` and `opencode` come from `QUEST_RUNNER_CLAUDE` and
`QUEST_RUNNER_OPENCODE`, or `PATH`. State lives in `$QUEST_RUNNER_HOME`, by default
`$XDG_DATA_HOME/quest/runner`; one `quest runner` runs in a home at a time. To keep a runner up, run `quest runner` as a user
Expand Down
10 changes: 8 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,21 @@
config.allowUnfreePredicate = pkg: nixpkgs.lib.getName pkg == "claude-code";
};
quest = pkgs.callPackage ./nix/package.nix { };
# `quest runner` with the agent CLIs it drives pinned by this flake's lock.
# `quest runner` with the agent CLIs it drives pinned by this flake's lock, and on
# Linux the bubblewrap and socat Claude Code's sandbox runs its jobs' commands in.
runner = pkgs.symlinkJoin {
name = "quest-runner-${quest.version}";
paths = [ quest ];
nativeBuildInputs = [ pkgs.makeWrapper ];
postBuild = ''
wrapProgram $out/bin/quest \
--set-default QUEST_RUNNER_CLAUDE ${pkgs.lib.getExe pkgs.claude-code} \
--set-default QUEST_RUNNER_OPENCODE ${pkgs.lib.getExe pkgs.opencode}
--set-default QUEST_RUNNER_OPENCODE ${pkgs.lib.getExe pkgs.opencode} ${pkgs.lib.optionalString pkgs.stdenv.isLinux "--prefix PATH : ${
pkgs.lib.makeBinPath [
pkgs.bubblewrap
pkgs.socat
]
}"}
'';
meta = quest.meta;
};
Expand Down
2 changes: 1 addition & 1 deletion quest/m0/kix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ fixes what it finds:
- [Deploy kix.quest](/quest/m0/kix/deploy.md) - the operator registers the App and deploys the Worker with GitHub sign-in at kix.quest
- [Operator setup](/quest/m0/kix/operator-setup.md) - fork namespace, pilot installations, and a verified test PR
- [Per-project forks](/quest/m0/kix/forks.md) - each project publishes through its own fork, recorded by repository ID
- [Job sandbox](/quest/m0/kix/sandbox.md) - OS-sandboxed commands, with untrusted PR devShells built in a VM on Linux runners
- [Job sandbox](/quest/m0/kix/sandbox.md) - untrusted PR devShells built in a VM on Linux runners, maintainer PR heads trusted, opencode sandboxed, and macOS verified
- [Merge jobs](/quest/m0/kix/merge-job.md) - a merge pick fixes conflicts, CI, and reviews on a runner, then lands the PR
- [Web execution](/quest/m0/kix/execution.md) - spawn and iterate through the UI, ending in contribution PRs
- [Onboarding guide](/quest/m0/kix/guide.md) - a complete maintainer path, from runner setup through a reviewed PR
102 changes: 66 additions & 36 deletions quest/m0/kix/sandbox.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# [L] Sandbox every runner job
# [L] Run untrusted PR heads on sandboxed runners

## Goal

Every job's commands run in an OS sandbox on the runner: writes only to the
job's worktree, network only to the provider, kix, and Nix substituters, and
no access to other account homes or the machine's secrets. Jobs on PR heads
containing non-maintainer commits route to Linux runners, which build the
PR's devShell in a per-job VM before its commands run in the sandbox.
Jobs on PR heads containing non-maintainer commits route to Linux runners,
which build the PR's devShell in a per-job VM before its commands run in the
OS sandbox every Claude Code job already uses. opencode jobs' commands run in
that sandbox too. kix classifies PR heads whose commits all
come from maintainers or kix jobs as trusted, so those run on any runner. The
sandbox is verified on macOS, and the owner's approval of another
maintainer's answer is lifted once it is.

## Plan

Expand All @@ -17,44 +19,72 @@ Decided 2026-10-15, from Codex's review of kixelated/quest#121:
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.
Use Claude Code's built-in sandbox, and Anthropic's sandbox-runtime
around opencode, which has no native sandbox. Claude Code's docs say this
does not replace a container or VM; document that residual risk.
- Keep the agent CLI's login outside what sandboxed commands can read. For
opencode under sandbox-runtime, verify how much of its account home the
wrapped process exposes, and deny reads of every other account home.
Claude Code's docs say this does not replace a container or VM; the README
documents that residual risk.
- Job coordination marks every `iterate` job untrusted and routes it only to
Linux runners that register with `vm: true`. Classify PR heads whose commits
all come from maintainers or kix jobs as trusted here.
- Trusted revisions (the default branch, kix job branches, and maintainer
commits) build their devShell through the host Nix daemon before entering
the sandbox. Verify whether sandboxed commands such as `nix flake check`
need daemon access, and allow it only for trusted revisions.
- Untrusted heads: evaluate and build the PR's own devShell in a per-job
KVM microVM on a Linux runner, then bring the closure into the host store
and run the job's commands in the OS sandbox without daemon access. Verify
how to import unsigned paths without making the runner trust arbitrary
builders, and never publish them to the shared cache.
and run the job's commands in the OS sandbox. Verify how to import unsigned
paths without making the runner trust arbitrary builders, and never
publish them to the shared cache. The runner refuses untrusted jobs until
then.
- 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.
- `quest runner` (`src/runner/`) runs Claude Code with
`--permission-mode bypassPermissions` and the user's environment, minus
GitHub tokens, and refuses untrusted jobs. Decided 2026-10-22 on
[kixelated/quest#188](https://github.com/kixelated/quest/pull/188): keep
that until this sandbox ships and replaces it as the job's boundary.
- Decided 2026-10-22 on the same PR, from Codex's security review: until this
sandbox ships, 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 and credentials. kix holds the job and the runner refuses an
answer without `approvedAt`. Shipping the sandbox lifts this gate: remove
the approval step from kix, the inbox, and the runner.
- The devShell bootstrap job runs sandboxed in the runner's own pinned Nix
environment.
- Test that the boundary stops writes outside the worktree, reading
another account home or `~/.ssh`, network to unlisted hosts, and a PR
whose shellHook or flake tries to run on the host.
- Decided 2026-10-22 on
[kixelated/quest#188](https://github.com/kixelated/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: remove the approval step
from kix, the inbox, and the runner.

Shipped in [kixelated/quest#212](https://github.com/kixelated/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
[kixelated/quest#212](https://github.com/kixelated/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.

Remaining work:

- opencode has no native sandbox, and its jobs' commands still run unconfined
on the host (in the revision's devShell, with the attempt's private HOME).
Decided 2026-10-15: run `opencode serve` under Anthropic's sandbox-runtime
with the runner's `Sandbox` policy (`src/runner/sandbox.ts`). The runner
reaches the server on localhost, and opencode needs its providers' hosts,
so check both under sandbox-runtime's network isolation. Keep the account's
login out of what the commands can read: verify how much of its XDG home the
wrapped process exposes, and deny reads of every other account home.
Commands also inherit the per-turn `OPENCODE_SERVER_PASSWORD`, and the kix
tools' bearer token in `OPENCODE_CONFIG_CONTENT`; keep both out of reach.

- On macOS, sandboxed commands cannot reach the account's Keychain item
(`security find-generic-password`), `/private/var/folders`, or another
home, and the realpath'd `/private/tmp` directory works as `TMPDIR`.
- On Ubuntu 23.10 and later, unprivileged user namespaces need an AppArmor
profile for the runner; document one and fail fast when the sandbox cannot
start, instead of failing each job.
- Sources:
[Claude Code sandboxing](https://code.claude.com/docs/en/sandboxing)
and [sandbox-runtime](https://www.npmjs.com/package/@anthropic-ai/sandbox-runtime).
5 changes: 3 additions & 2 deletions src/runner/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// through anything CLI-specific.
import { strippedEnvironment } from "../cli/agent";
import type { AccountKind } from "./home";
import type { Sandbox } from "./sandbox";

/** The agent CLIs a runner drives. */
export type AdapterName = "claude" | "opencode";
Expand Down Expand Up @@ -48,8 +49,8 @@ export interface Launch {
system: string;
/** The kix tools' MCP endpoint and bearer token. */
tools: { url: string; token: string };
/** A command prefix the CLI runs under, such as entering the project's Nix devShell. */
shell: string[];
/** The OS sandbox every command the agent runs is confined to. */
sandbox: Sandbox;
env: NodeJS.ProcessEnv;
/** Receives each line of the CLI's raw output. */
output(line: string): void;
Expand Down
Loading
Loading