diff options
| author | Henry J. Webster <hwebs@hwebs.info> | 2026-08-03 22:00:49 -0500 |
|---|---|---|
| committer | Henry J. Webster <hwebs@hwebs.info> | 2026-08-03 22:12:32 -0500 |
| commit | 3df910d75fa98a7d0291e6b8c6889905c6088f64 (patch) | |
| tree | 8b3234001e4d128e4390fe9966a74df5dd7c0129 | |
| parent | 71e36370770acc7498748a890cd0365b3d351027 (diff) | |
profiles: extract shared sandbox profile for enzo + kusanagi
Move the yokai agent-sandbox account (user, ikai group, /srv/git drop dir,
scoped sudo de-escalation, and the local-clones workflow docs) out of
machines/enzo into a shared profiles/sandbox.nix that both machines import.
The only per-machine value is the human owner allowed to drop into the
sandbox, exposed as a `sandbox.owner` string option so the profile stays
username-agnostic (enzo: hwebs, kusanagi: henz). kusanagi gains the sandbox
it didn't have before; enzo's inline block collapses to one setting.
Assisted-by: claude-code:claude-opus-4-8
| -rw-r--r-- | machines/enzo/default.nix | 107 | ||||
| -rw-r--r-- | machines/kusanagi/default.nix | 5 | ||||
| -rw-r--r-- | profiles/sandbox.nix | 135 |
3 files changed, 146 insertions, 101 deletions
diff --git a/machines/enzo/default.nix b/machines/enzo/default.nix index d14dacd..3246ca3 100644 --- a/machines/enzo/default.nix +++ b/machines/enzo/default.nix @@ -7,6 +7,7 @@ # apps.nix here, or one-off packages below, as needed. ../../profiles/desktop.nix ../../profiles/dev.nix + ../../profiles/sandbox.nix ./desktop.nix inputs.disko.nixosModules.disko @@ -25,10 +26,10 @@ users.users.hwebs = { isNormalUser = true; description = "Henry J. Webster"; + # (ikai — the sandbox group — is added by profiles/sandbox.nix.) extraGroups = [ "networkmanager" "wheel" - "ikai" ]; packages = with pkgs; [ git @@ -47,106 +48,10 @@ }; # --- Sandbox account (yokai) --- - # Model: LOCAL CLONES, not a shared working tree. yokai owns its own clone of - # each repo under /srv/git; hwebs 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 hwebs runs `git` *inside* — a planted - # hook/config would run as hwebs 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: hwebs never runs git with CWD inside /srv/git/<repo>. 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 - # hwebs and yokai (both in ikai) can each drop clones without sudo and new - # files inherit the ikai group. It sits outside ~ so /home/hwebs 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/<repo> /srv/git/<repo> - # sudo chown -R yokai:ikai /srv/git/<repo> # 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/<repo> remote add sandbox /srv/git/<repo> - # git -C ~/git/<repo> fetch sandbox - # git -C ~/git/<repo> log --oneline sandbox/main # review, then merge - # - # Land + SIGN on main (signatures attest that hwebs reviewed+vouches, so hwebs - # 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/<repo> checkout main - # git -C ~/git/<repo> merge --squash sandbox/main # stage, don't commit - # git -C ~/git/<repo> commit -S -m "...\n\nAssisted-by: claude-code:<model>" - # git -C ~/git/<repo> push origin main - # Alt — keep granular commits instead of squashing: rebase re-commits each - # one signed by hwebs (also all-signed, but review commit-by-commit): - # git -C ~/git/<repo> checkout -b land sandbox/main # local branch at tip - # git -C ~/git/<repo> rebase -S main # replay onto main, sign each - # git -C ~/git/<repo> checkout main - # git -C ~/git/<repo> merge --ff-only land && git -C ~/git/<repo> branch -d land - # git -C ~/git/<repo> 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/<repo> checkout -b land sandbox/main - # git -C ~/git/<repo> rebase -i -S main # -S signs each resulting commit - # # ...then merge --ff-only land into main + push, as above. - # (Signing config lives in hwebs's dotfiles, not here: commit.gpgsign + - # user.signingkey.) - # - # Airtight variant — exchange via bundles (inert data, no hooks/config run): - # (yokai) git -C /srv/git/<repo> bundle create /srv/git/x.bundle main - # (hwebs) git -C ~/git/<repo> fetch /srv/git/x.bundle main:sandbox/main - # - # Run the agent as yokai: sudo -u yokai claude - # - # Let hwebs 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 = [ "hwebs" ]; - 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 - ]; - }; - - # Drop dir for the agent's local clones. Created at activation as 2770 - # root:ikai — setgid (new entries inherit ikai) + group-writable so hwebs and - # yokai can each place clones here without sudo; world sees nothing. Sits - # outside ~ so /home/hwebs stays a sealed 700. Trailing "-" = no age-cleaning. - # Each clone's own ownership is set when seeded (see the notes above). - systemd.tmpfiles.rules = [ - "d /srv/git 2770 root ikai -" - ]; + # The yokai user, ikai group, /srv/git drop dir, and the sudo de-escalation + # rule all live in profiles/sandbox.nix (imported above); that file also + # documents the local-clones workflow. Only the owner name is per-machine: + sandbox.owner = "hwebs"; nix.settings.trusted-users = [ "root" diff --git a/machines/kusanagi/default.nix b/machines/kusanagi/default.nix index a22843d..bb719d1 100644 --- a/machines/kusanagi/default.nix +++ b/machines/kusanagi/default.nix @@ -9,9 +9,14 @@ ./desktop.nix ../../profiles/dev.nix ../../profiles/apps.nix + ../../profiles/sandbox.nix ./hardware-configuration.nix ]; + # Run coding agents under the isolated `yokai` sandbox user; henz owns the + # canonical repos in ~. See profiles/sandbox.nix for the full workflow. + sandbox.owner = "henz"; + networking.hostName = "kusanagi"; # This value determines the NixOS release from which the default diff --git a/profiles/sandbox.nix b/profiles/sandbox.nix new file mode 100644 index 0000000..40b962d --- /dev/null +++ b/profiles/sandbox.nix @@ -0,0 +1,135 @@ +# 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/<repo>. +# 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/<owner> 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/<repo> /srv/git/<repo> +# sudo chown -R yokai:ikai /srv/git/<repo> # 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/<repo> remote add sandbox /srv/git/<repo> +# git -C ~/git/<repo> fetch sandbox +# git -C ~/git/<repo> 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/<repo> checkout main +# git -C ~/git/<repo> merge --squash sandbox/main # stage, don't commit +# git -C ~/git/<repo> commit -S -m "...\n\nAssisted-by: claude-code:<model>" +# git -C ~/git/<repo> 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/<repo> checkout -b land sandbox/main # local branch at tip +# git -C ~/git/<repo> rebase -S main # replay onto main, sign each +# git -C ~/git/<repo> checkout main +# git -C ~/git/<repo> merge --ff-only land && git -C ~/git/<repo> branch -d land +# git -C ~/git/<repo> 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/<repo> checkout -b land sandbox/main +# git -C ~/git/<repo> 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/<repo> bundle create /srv/git/x.bundle main +# (owner) git -C ~/git/<repo> 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; +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 + ]; + }; + + # 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/<owner> stays a sealed 700. Trailing "-" + # = no age-cleaning. Each clone's own ownership is set when seeded (above). + systemd.tmpfiles.rules = [ + "d /srv/git 2770 root ikai -" + ]; + }; +} |
