1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
|
# 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;
# --- 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 <agentDir>/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/<owner> 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);
};
}
|