summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHenry J. Webster <hwebs@hwebs.info>2026-08-03 22:00:49 -0500
committerHenry J. Webster <hwebs@hwebs.info>2026-08-03 22:12:32 -0500
commit3df910d75fa98a7d0291e6b8c6889905c6088f64 (patch)
tree8b3234001e4d128e4390fe9966a74df5dd7c0129
parent71e36370770acc7498748a890cd0365b3d351027 (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.nix107
-rw-r--r--machines/kusanagi/default.nix5
-rw-r--r--profiles/sandbox.nix135
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 -"
+ ];
+ };
+}