summaryrefslogtreecommitdiff
path: root/profiles/sandbox.nix
blob: 40b962d7bfb1ac3fe6014d4af9f70ccac2ce9cb4 (plain)
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
# 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 -"
    ];
  };
}