diff --git a/doc/plan/design.md b/doc/plan/design.md index 147251e..9871c1b 100644 --- a/doc/plan/design.md +++ b/doc/plan/design.md @@ -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/`, 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/` (own fork) | works | the work branch | +| open a cross-repo PR, head `tireless:` | works | delivery | +| comment on an issue | works | reporting, blocked explanations | +| create an issue | works | plan children, discovery proposals | +| push to `lair/` | **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 diff --git a/script/infra-setup.sh b/script/infra-setup.sh index 239afba..6f64a5e 100755 --- a/script/infra-setup.sh +++ b/script/infra-setup.sh @@ -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