# Agent sandbox profile — a bare `yokai` user for running coding agents under a # separate UID, isolated from the machine owner's files and secrets. yokai has # no privileged group memberships and its own $HOME. # # Opt-in per machine: import this profile and set `sandbox.owner` to the human # user allowed to drop into the sandbox (enzo: hwebs, kusanagi: henz). The # profile stays username-agnostic; only that one option differs per machine. # # Model: LOCAL CLONES, not a shared working tree. yokai owns its own clone of # each repo under /srv/git; the owner keeps the canonical repo in ~. Work moves # between the two over git remotes — never by sharing one tree. # # Why not one shared tree: git executes hooks and config out of .git, so a tree # yokai can write must never be one the owner runs `git` *inside* — a planted # hook/config would run as the owner and escape the sandbox. Git's "dubious # ownership" warning is exactly that guard; don't defeat it with safe.directory # on a yokai-writable repo. # # CARDINAL RULE: the owner never runs git with CWD inside /srv/git/. # Review the agent's work by FETCHING its commits into your own repo and reading # them there. # # /srv/git is created below as 2770 root:ikai — group-writable + setgid, so the # owner and yokai (both in ikai) can each drop clones without sudo and new files # inherit the ikai group. It sits outside ~ so /home/ stays 700 and yokai # never needs to traverse into home. (Tradeoff: yokai has write on the /srv/git # dir itself, i.e. its own play area — fine for a sandbox.) # # Seed the agent's clone (hand a fresh clone to yokai): # git clone ~/git/ /srv/git/ # sudo chown -R yokai:ikai /srv/git/ # yokai now owns its tree # TODO: wrap seed (clone + chown) in a one-shot helper (shell fn / just). # # Pull the agent's work back into YOUR repo (stay in your trusted tree): # git -C ~/git/ remote add sandbox /srv/git/ # git -C ~/git/ fetch sandbox # git -C ~/git/ log --oneline sandbox/main # review, then merge # # Land + SIGN on main (signatures attest that the owner reviewed+vouches, so the # owner signs — never put a signing key in yokai's home, and never give yokai # yours). Squash the agent's WIP into one commit you author + sign; satisfies # "require signed commits" branch protection trivially and keeps history clean: # git -C ~/git/ checkout main # git -C ~/git/ merge --squash sandbox/main # stage, don't commit # git -C ~/git/ commit -S -m "...\n\nAssisted-by: claude-code:" # git -C ~/git/ push origin main # Alt — keep granular commits instead of squashing: rebase re-commits each # one signed by the owner (also all-signed, but review commit-by-commit): # git -C ~/git/ checkout -b land sandbox/main # local branch at tip # git -C ~/git/ rebase -S main # replay onto main, sign each # git -C ~/git/ checkout main # git -C ~/git/ merge --ff-only land && git -C ~/git/ branch -d land # git -C ~/git/ push origin main # Alt — INTERACTIVE, for exploring/curating the agent's history by hand: opens # a todo list (pick/squash/fixup/reword/drop/reorder per commit), then the # message editor. Good when you want to hand-pick what lands. Run it in a real # terminal — interactive git can't be driven through the agent harness: # git -C ~/git/ checkout -b land sandbox/main # git -C ~/git/ rebase -i -S main # -S signs each resulting commit # # ...then merge --ff-only land into main + push, as above. # (Signing config lives in the owner's dotfiles, not here: commit.gpgsign + # user.signingkey.) # # Airtight variant — exchange via bundles (inert data, no hooks/config run): # (yokai) git -C /srv/git/ bundle create /srv/git/x.bundle main # (owner) git -C ~/git/ fetch /srv/git/x.bundle main:sandbox/main # # Run the agent as yokai: sudo -u yokai claude { config, lib, pkgs, ... }: let owner = config.sandbox.owner; # --- One instructions file for every coding-agent harness --- # There is no cross-vendor standard for "the markdown the agent reads first": # claude-code wants CLAUDE.md, opencode and pi want AGENTS.md, each with its # own global path. Rather than maintaining N diverging copies, # common/AGENTS.md is the single source of truth: it ships to /etc/AGENTS.md # (below), and every harness's global context path becomes a symlink to it. # Edit the file in this repo, rebuild, and every harness for every user sees # the same text. # # Global paths, verified against the packaged harnesses (2026-08): # claude-code 2.1.187: reads ~/.claude/CLAUDE.md ("user memory") + CLAUDE.md # up the project tree. Does NOT read AGENTS.md (the only mentions in the # binary are in the /init prompt), hence the CLAUDE.md-named symlink. # opencode 1.15.10: reads ~/.config/opencode/AGENTS.md + project AGENTS.md. # pi 0.75.4: reads /AGENTS.md or CLAUDE.md (agentDir defaults to # ~/.pi/agent — dist/config.js), plus AGENTS.md|CLAUDE.md walking up from # cwd (dist/core/resource-loader.js). AGENTS.md wins over CLAUDE.md. # # Per-repo instructions are a separate layer and stay in each repo: commit an # AGENTS.md at the repo root plus a `CLAUDE.md -> AGENTS.md` symlink (git # stores symlinks fine) and all three harnesses pick it up as project context. # # Semantics to be aware of: # - L+ replaces whatever already sits at the path — that is the point # (convergence), but salvage any hand-written ~/.claude/CLAUDE.md into # common/AGENTS.md before the first rebuild on a machine. # - The link resolves into the store, so in-band memory editing (claude's # `#` shortcut / /memory) fails read-only: edits belong in this repo. # - `d` lines apply mode+ownership to existing dirs too; the harness # dot-dirs are normalized to 0700 (they hold credentials). agentsTarget = "/etc/AGENTS.md"; agentUsers = lib.filterAttrs (_: u: u.isNormalUser) config.users.users; agentContextRulesFor = u: [ "d ${u.home}/.claude 0700 ${u.name} ${u.group} -" "L+ ${u.home}/.claude/CLAUDE.md - - - - ${agentsTarget}" "d ${u.home}/.config 0700 ${u.name} ${u.group} -" "d ${u.home}/.config/opencode 0700 ${u.name} ${u.group} -" "L+ ${u.home}/.config/opencode/AGENTS.md - - - - ${agentsTarget}" "d ${u.home}/.pi 0700 ${u.name} ${u.group} -" "d ${u.home}/.pi/agent 0700 ${u.name} ${u.group} -" "L+ ${u.home}/.pi/agent/AGENTS.md - - - - ${agentsTarget}" ]; in { options.sandbox.owner = lib.mkOption { type = lib.types.str; example = "hwebs"; description = '' Human user permitted to `sudo -u yokai` into the agent sandbox, and joined to the `ikai` group so they share the /srv/git drop dir with yokai. Set per machine (enzo: hwebs, kusanagi: henz). ''; }; config = { # The owner joins ikai so they can drop clones into /srv/git without sudo. users.users.${owner}.extraGroups = [ "ikai" ]; # Let the owner drop to yokai without a password. This grants NO new # privilege — it's a de-escalation to a weaker account — so NOPASSWD here is # low-risk, unlike a run-as-root rule. Scoped to runAs=yokai only; root # stays gated. security.sudo.extraRules = [ { users = [ owner ]; runAs = "yokai"; commands = [ { command = "ALL"; options = [ "NOPASSWD" "SETENV" ]; } ]; } ]; users.groups.ikai = { }; users.users.yokai = { isNormalUser = true; description = "Sandbox account for coding agents"; group = "ikai"; packages = with pkgs; [ git claude-code pi-coding-agent ]; }; # Canonical agent instructions, shared by all harnesses (see the big # comment in the let-block above). environment.etc."AGENTS.md".source = ../common/AGENTS.md; # Drop dir for the agent's local clones. Created at activation as 2770 # root:ikai — setgid (new entries inherit ikai) + group-writable so the # owner and yokai can each place clones here without sudo; world sees # nothing. Sits outside ~ so /home/ stays a sealed 700. Trailing "-" # = no age-cleaning. Each clone's own ownership is set when seeded (above). # Plus the per-user agent-context symlinks from above. systemd.tmpfiles.rules = [ "d /srv/git 2770 root ikai -" ] ++ lib.concatMap agentContextRulesFor (lib.attrValues agentUsers); }; }