diff --git a/doc/plan/design.md b/doc/plan/design.md index f989d52..147251e 100644 --- a/doc/plan/design.md +++ b/doc/plan/design.md @@ -564,19 +564,63 @@ not be able to reach each other's state. Cloning from a local path hardlinks objects rather than copying them, so a job clone is fast and near-free on disk regardless of repo size — git never mutates -an existing object, so the hardlinks are safe. `origin` is then repointed at the -real remote, because the mirror is a cache, not the truth. +an existing object, so the hardlinks are safe. -Branches are namespaced `tireless/-` so forge branch protection can -permit the bot there and nowhere else. +**Work happens in a fork, and the two remotes are not interchangeable.** After +cloning from the mirror, a job carries: -### 6.4 Identity +| Remote | Points at | Used for | +| --- | --- | --- | +| `upstream` | `/` | fetching the base branch. **Never pushed to.** | +| `origin` | `tireless/` | pushing the work branch | -A dedicated `tireless` Gitea account, not the operator's. Its token is scoped to -issue and PR write; branch protection on each repo's default branch denies it -push. Three benefits: 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. +Branch from `upstream/`, push to `origin`, open the pull request across +repositories with a head of `tireless:`. Branches stay namespaced +`tireless/-`, now for legibility in a PR list rather than to give +branch protection something to match. + +### 6.4 Identity and access + +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. + +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`: + +| Unit | Grant | Why | +| --- | --- | --- | +| 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 | + +The team is scoped to named repositories rather than the whole org, so adding a +repo to tireless's remit is an explicit act. + +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. + +Two 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. + +`known_hosts` is pre-seeded for the forge, because an unattended git must not +prompt and `StrictHostKeyChecking=accept-new` would trust whatever answered +first. ### 6.5 systemd hardening @@ -695,9 +739,19 @@ thread; a bad implementation is a branch. Start where mistakes are cheapest. Mirror cache, per-job clone, branch, commit, push, open PR. Wire the Claude Code implementation path. Idempotent re-runs against existing branches and PRs. -*Done when:* an issue labelled `tireless/implement` yields a reviewable PR from a -protected-branch-respecting bot account, and re-running the job updates rather -than duplicates. +Two remotes, not one (§6.3): fetch the base from `upstream`, push the work branch +to `origin` — the bot's fork — and open the pull request across repositories with +a head of `tireless:`. Ensure the fork exists and is not stale before +branching; a fork whose default branch has drifted produces a PR full of +unrelated commits. + +*Done when:* an issue labelled `tireless/implement` yields a reviewable PR opened +by the bot account from its own fork, and re-running the job updates rather than +duplicates. + +The credentials and access model this depends on are already provisioned and +verified (§6.4) — the bot cannot push to a `lair` repository at all, so this +stage cannot accidentally write to one. ### Stage 5 — OpenCode executor diff --git a/script/infra-setup.sh b/script/infra-setup.sh index 4fc99d2..239afba 100755 --- a/script/infra-setup.sh +++ b/script/infra-setup.sh @@ -321,18 +321,41 @@ Remaining one-time steps (operator, on the target host): ANTHROPIC_API_KEY in /etc/tireless/tireless.env instead. Do not do both: the API key silently wins, and the subscription goes unused. -2. Gitea bot account. - Create a dedicated `tireless` user on git.lair.cafe (not your own account), - generate a token scoped to issue + PR write, and put it in - /etc/tireless/tireless.env as GITEA_TOKEN (0640 root:tireless). +2. Gitea bot account. (Done for git.lair.cafe on 2026-08-07.) + A dedicated `tireless` user, not your own account, holding two credentials: - Then, for each repo tireless should work on: - - add `tireless` as a collaborator with write access; - - enable branch protection on the default branch, denying `tireless` push; - - confirm it can still push refs matching `tireless/*`. + - ssh key at /var/lib/tireless/.ssh/id_ed25519 (0600 tireless:tireless), + registered on the account, for git transport. The private half is kept at + ~/.ssh/id_tireless on the operator workstation. + - API token scoped `write:issue`, `write:repository`, `read:user`, in + /etc/tireless/tireless.env as GITEA_TOKEN (0640 root:tireless), for + issues and pull requests. - The protection rule is what keeps an unattended agent from writing to main. - Verify it rather than assuming it. + 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. + + 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: + - 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 + + 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`. 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