feat(infra): provision the poller identity
All checks were successful
deploy / deploy (push) Successful in 5m35s

Stage 2 writes the first label, and nothing about the label protocol can be
dogfooded until an identity exists that is allowed to. Provisioned now rather
than deferred.

  account   tireless-poller (tireless-poller@lair.cafe), no ssh key — it never
            clones anything
  team      lair/tireless-poller: Issues=write, PullRequests=read, Code=none,
            everything else none, scoped to named repos
  token     write:issue, read:repository, read:user

This is the one place an org team earns its keep. Collaborator permissions in
Gitea are repo-wide, so granting issue write that way would hand this account
code push too — the unit-level team grants labelling without it.

The units now load different environment files, 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. poller.env is loaded only by tireless-poller.service,
which spawns nothing.

Be precise about what that buys: the separation is between processes, not users.
Both units run as `tireless`, so the runner's uid can still read poller.env even
though its process never loads it. An agent would have to go looking rather than
find it handed over in `env` — a real difference, not a boundary. Closing it
means separate service accounts or LoadCredential=; recorded as a stage 8 item so
it is a known gap rather than an assumed guarantee.

Verified each identity can do its own job and not the other's:

              tireless   tireless-poller
  add label      403          200
  fork repo      works        403
  read issues    200          200

One measurement caveat recorded in §6.4: the poller can still read repository
contents despite Code=none, because lair/tireless is public. The unit permission
bites on private repos — do not read that as the grant being wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
This commit is contained in:
rob thijssen
2026-08-07 18:31:59 +03:00
parent 8586da2cb9
commit 9deeaaa9c2
3 changed files with 80 additions and 21 deletions

View File

@@ -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