feat(infra): provision the poller identity
All checks were successful
deploy / deploy (push) Successful in 5m35s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user