diff --git a/asset/systemd/tireless-poller.service b/asset/systemd/tireless-poller.service index 85609d0..6d18dfd 100644 --- a/asset/systemd/tireless-poller.service +++ b/asset/systemd/tireless-poller.service @@ -9,7 +9,16 @@ Type=exec User=tireless Group=tireless Environment=HOME=/var/lib/tireless -EnvironmentFile=-/etc/tireless/tireless.env +# Its OWN environment file, not the runner's. This carries the one credential in +# the system that can write a label, and it must not reach the runner: the runner +# spawns coding agents as subprocesses, which inherit its environment, so a token +# there is a token every agent run can read. +# +# The separation is between processes, not between users — both units run as +# `tireless`, so this file is readable by the runner's uid even though its +# process never loads it. Closing that gap properly means either separate service +# accounts or systemd LoadCredential=; see doc/plan/design.md §6.4. +EnvironmentFile=-/etc/tireless/poller.env ExecStart=/usr/local/bin/tireless-worker --config /etc/tireless/config.toml poll Restart=on-failure RestartSec=30s diff --git a/doc/plan/design.md b/doc/plan/design.md index 9871c1b..74c6d77 100644 --- a/doc/plan/design.md +++ b/doc/plan/design.md @@ -611,15 +611,44 @@ writes a label. It records state in Postgres, which §2.2 already makes the authority, and a separate identity reconciles labels onto the forge. That maps onto the split the two units already have (§2.1): -| Unit | Identity | Forge access | +| Unit | Identity | Forge access | Holds | +| --- | --- | --- | --- | +| `tireless-runner` | `tireless` | no grant at all; pushes only to its own fork | agent login, ssh key | +| `tireless-poller` | `tireless-poller` | issue write via `lair/tireless-poller` team, code `none` | nothing else | + +Verified as built, each identity able to do its own job and not the other's: + +| | `tireless` | `tireless-poller` | | --- | --- | --- | -| `tireless-runner` | `tireless` | none beyond public read; pushes only to its own fork | -| `tireless-poller` | separate | issue write, and no code access at all | +| apply / remove a label | **403** | 200 / 204 | +| fork a repository | works | **403** | +| read issues | 200 | 200 | The token that can touch issues is held by the process that never runs an agent; the token held by the process running unattended agents can only push to a repository nobody depends on. Neither half can do the other's damage. +**The two units load different environment files** — `/etc/tireless/poller.env` +and `/etc/tireless/tireless.env` — and that is not tidiness. The runner spawns +coding agents as subprocesses, and subprocesses inherit the environment, so a +label-capable token in the runner's environment is a token every agent run can +read. Keeping it out of that environment is the point. + +Being precise about what that does and does not achieve: the separation is +between *processes*, not between *users*. Both units run as `tireless`, so the +runner's uid can read `poller.env` even though its process never loads it. An +agent would have to go looking for it rather than find it handed over in `env`, +which is a meaningful difference but not a boundary. Closing it properly means +either separate service accounts or systemd `LoadCredential=`, which puts the +credential in a per-unit tmpfs no other unit can read. Deferred to stage 8; noted +here so it is a known gap rather than an assumed guarantee. + +One measurement caveat worth recording, since it will mislead whoever checks: +the poller's team grants code `none`, yet it can still read repository contents. +That is because `lair/tireless` is public — the unit permission bites on private +repositories. Do not read a successful content fetch as evidence the grant is +wrong. + It also removes a latent silent failure. Creating an issue *with* labels as an unprivileged user returns **`201` and drops the labels** — no error, no warning, an issue that simply never gets picked up. A Plan job stamping inherited @@ -629,17 +658,21 @@ labels recorded in Postgres, and the poller applies them on the next reconcile. The cost is that a child is not admitted until the next poll, which is a scheduling delay of minutes and visible in the dashboard. -The second identity is provisioned when stage 2 first writes a label; nothing -before then needs it. - Credentials, with different jobs and different blast radii: -- an **ssh key** (`/var/lib/tireless/.ssh/id_ed25519`, 0600, on the account) for - git transport; -- an **API token** scoped `write:issue`, `write:repository`, `read:user`, in - `/etc/tireless/tireless.env` (0640 root:tireless). The `write:issue` scope is a - ceiling on what the token may attempt, not a grant — the account still has no - issue write, so labelling stays denied. Scope narrows; it never widens. +| Credential | Held by | Path | +| --- | --- | --- | +| ssh key, on the `tireless` account | runner | `/var/lib/tireless/.ssh/id_ed25519`, 0600 | +| API token, `write:repository` + `read:user` | runner | `/etc/tireless/tireless.env`, 0640 root:tireless | +| API token, `write:issue` + `read:repository` + `read:user` | poller | `/etc/tireless/poller.env`, 0640 root:tireless | +| Claude Code subscription login | runner | `/var/lib/tireless/.claude.json`, 0600 — stat'd, never read | + +The poller has no ssh key and no git access of any kind; it talks only to the +forge API and to Postgres. + +Token scope is a ceiling on what a token may attempt, never a grant. The runner's +token could carry `write:issue` and labelling would still be denied, because the +account behind it has no issue write. Scope narrows; permission is what widens. `known_hosts` is pre-seeded for the forge, because an unattended git must not prompt and `StrictHostKeyChecking=accept-new` would trust whatever answered diff --git a/script/infra-setup.sh b/script/infra-setup.sh index 6f64a5e..8a2200b 100755 --- a/script/infra-setup.sh +++ b/script/infra-setup.sh @@ -353,15 +353,32 @@ Remaining one-time steps (operator, on the target host): protection for your own workflow if you want it; it is no longer what stands between an unattended agent and `main`. - LABELLING IS A SEPARATE IDENTITY, and does not exist yet -- provision it with - stage 2, which is when the first label is written. Applying a label is the one - §2.2 capability an unprivileged account cannot do, and it must not be bought - by granting the runner code write. The poller reconciles labels from Postgres - under its own account; the runner never writes one. + LABELLING IS A SEPARATE IDENTITY: `tireless-poller`. (Done 2026-08-07.) + Applying a label is the one §2.2 capability an unprivileged account cannot do, + and it must not be bought by granting the runner code write. - Beware when you build it: creating an issue WITH labels as an unprivileged - user returns 201 and silently drops the labels. No error, no warning, an issue - that never gets picked up. + - account `tireless-poller` (tireless-poller@lair.cafe), no ssh key -- it + never clones anything; + - org team `lair/tireless-poller`: Issues=write, PullRequests=read, + Code=none, everything else none, scoped to named repos; + - API token scoped `write:issue`, `read:repository`, `read:user`, in + /etc/tireless/poller.env (0640 root:tireless). + + The env file is SEPARATE from the runner's on purpose. The runner spawns + coding agents as subprocesses, and subprocesses inherit the environment, so a + label-capable token in the runner's environment is one every agent run can + read. tireless-poller.service loads poller.env; tireless-runner.service loads + tireless.env. Do not merge them. + + Confirm the split, rather than assume it: + runner token, add label 403 + poller token, add label 200 + poller token, fork a repo 403 + + Beware when you build stage 2: creating an issue WITH labels as an + unprivileged user returns 201 and silently drops the labels. No error, no + warning, an issue that never gets picked up. The runner creates children bare + and records their intended labels in Postgres; the poller applies them. 3. Confirm the proxy is serving the cert that is on disk. The vhost, its cert and the split-horizon DNS were installed above, but