fix(infra): grant the runner's account nothing; split labelling off
All checks were successful
deploy / deploy (push) Successful in 5m34s

Reverses the org team added an hour ago. It was solving the wrong problem.

Measured on the bare account, with no team and no collaborator role: pushing to
its own fork works, opening a cross-repo pull request works, commenting works,
creating an issue works. Pushing to a lair repo is denied and so is deleting it.
Exactly one §2.2 capability is out of reach — applying a label — and that is the
one that must not be bought by granting code write.

So the runner gets nothing at all, which is a better property than a carefully
scoped grant: there is no permission to review, no team to audit, and no rule to
forget on the next repo added. Adding a repo to its remit is now one step, a
fork, rather than three.

Labelling moves to a separate identity, which lines up with the split the two
units already have. The runner records state in Postgres — already the authority
per §2.2 — and the poller reconciles labels onto the forge under its own account
with issue write and no code access. 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.

That also removes a silent failure I found while testing this. Creating an issue
*with* labels as an unprivileged user returns 201 and drops the labels — no
error, an issue that never gets picked up. A Plan job stamping inherited
admission on its children would have failed precisely that way and looked fine.
Children are now created bare, with their intended labels recorded in Postgres
for the poller to apply.

The second identity is provisioned with stage 2, when the first label is
written. Nothing before then needs it.

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:26:01 +03:00
parent b1e01777ff
commit 8586da2cb9
2 changed files with 70 additions and 41 deletions

View File

@@ -585,38 +585,61 @@ A dedicated `tireless` Gitea account, not the operator's — so the audit trail
distinguishes agent work from human work, you can meaningfully review a PR you
did not author, and revoking the agent does not touch your own credentials.
**The bot has no write access to any repository it works on.** That is the point
of the fork: it pushes only to `tireless/<repo>`, which is its own, and reaches
the real repository through a pull request like any outside contributor.
**The runner's account is granted nothing.** It is an ordinary authenticated
user with no collaborator role, no team membership and no permission on any
`lair` repository beyond public read. That is not a limitation to work around —
it is the security property, and the fork is what makes it sufficient.
What it does need is the ability to run the label protocol (§2.2), and labels are
a *write* on the issues unit. Granting that as blanket collaborator write would
hand it code push as well, which the fork exists to avoid. So access is a Gitea
**org team with unit-level permissions**, `lair/tireless-agent`:
Measured on the bare account, because the boundary is only worth what it can be
shown to do:
| Unit | Grant | Why |
| Operation | | Needed for |
| --- | --- | --- |
| Issues | **write** | labels, comments, and creating plan children — the protocol does not work without it |
| Pull requests | write | opening the PR from the fork |
| Code | read | fetching the base branch |
| everything else | none | wiki, releases, packages, projects, actions |
| push to `tireless/<repo>` (own fork) | works | the work branch |
| open a cross-repo PR, head `tireless:<branch>` | works | delivery |
| comment on an issue | works | reporting, blocked explanations |
| create an issue | works | plan children, discovery proposals |
| push to `lair/<repo>` | **denied** | — |
| delete the repository | **denied** | — |
| **apply a label** | **denied** | the label protocol |
The team is scoped to named repositories rather than the whole org, so adding a
repo to tireless's remit is an explicit act.
So exactly one capability in §2.2 is out of reach, and it is the one that must
not be bought with code write.
This is stronger than the branch-protection scheme this section described
before. Branch protection is a rule that can be edited, per repo, and is easy to
forget on the next repo added; a bot that simply has no push permission cannot
write to `main` whatever anyone forgets. Verified as built: pushing to the fork
succeeds, pushing to `lair/tireless` returns `User permission denied for
writing.`, labelling an issue succeeds, and deleting the repository is refused.
**Labelling is a different process with a different identity.** The runner never
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):
Two credentials, with different jobs and different blast radii:
| Unit | Identity | Forge access |
| --- | --- | --- |
| `tireless-runner` | `tireless` | none beyond public read; pushes only to its own fork |
| `tireless-poller` | separate | issue write, and no code access at all |
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.
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
admission (§2.5) on its children would have failed exactly that way. Under this
split the runner does not attempt it: children are created bare, their intended
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), for issues and PRs.
`/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.
`known_hosts` is pre-seeded for the forge, because an unattended git must not
prompt and `StrictHostKeyChecking=accept-new` would trust whatever answered

View File

@@ -334,28 +334,34 @@ Remaining one-time steps (operator, on the target host):
Seed known_hosts for the forge at the same time. An unattended git must not
prompt, and StrictHostKeyChecking=accept-new would trust whatever answered.
Access is a Gitea ORG TEAM with unit-level permissions -- `lair/tireless-agent`:
Issues=write, PullRequests=write, Code=read, everything else none, scoped to
named repositories rather than the whole org.
GRANT THIS ACCOUNT NOTHING. No collaborator role, no team membership, no
permission on any repo beyond public read. An ordinary authenticated user can
already fork, push to its own fork, open a cross-repo pull request, comment
on an issue and create an issue -- which is the whole of what the runner does.
Do NOT add `tireless` as a collaborator with write access. Collaborator
permissions in Gitea are repo-wide, so "write" to run the label protocol also
grants code push, which is exactly what the fork model exists to avoid. The
unit-level team grants issue write WITHOUT code write.
For each repo tireless should work on, the only step is:
- fork it to the `tireless` user.
For each repo tireless should work on:
- add the repo to the `tireless-agent` team;
- fork it to the `tireless` user (the runner pushes only to its fork);
- confirm, rather than assume:
push to the fork succeeds
push to the upstream repo "User permission denied for writing."
labelling an issue succeeds
deleting the repo refused
Confirm, rather than assume:
push to the fork succeeds
cross-repo PR from the fork succeeds
push to the upstream repo "User permission denied for writing."
deleting the repo refused
Note what is NOT needed: branch protection for the bot. It has no push
permission at all, so there is no rule to forget on the next repo you add.
Keep branch protection for your own workflow if you want it; it is no longer
what stands between an unattended agent and `main`.
What is NOT needed: branch protection for the bot. It has no push permission
at all, so there is no rule to forget on the next repo you add. Keep branch
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.
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.
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