fix(infra): grant the runner's account nothing; split labelling off
All checks were successful
deploy / deploy (push) Successful in 5m34s
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user