diff --git a/.claude/.claude-plugin/CHANGELOG.md b/.claude/.claude-plugin/CHANGELOG.md index 4362c5e..179b4e1 100644 --- a/.claude/.claude-plugin/CHANGELOG.md +++ b/.claude/.claude-plugin/CHANGELOG.md @@ -9,6 +9,25 @@ Entries land here as work merges; `.claude/scripts/release.sh` (issue #176) turn `## [X.Y.Z] - YYYY-MM-DD` section — ahead of the prior release, below this scaffold — at cut time. See `docs/USAGE.md` → "Release cycle". +### Added +- **`/orchestrator:provision`** (issue #204): guided, resumable walkthrough of `docs/HARDENING.md`'s + dedicated-Linux-server worked example — interview once, then nine checkpointed phases (agent user, + fresh credentials, clone, managed settings, harden + arm, optional nftables egress / auditd + detection / remote-SSH per `docs/REMOTE_SSH_RUNBOOK.md`) with per-phase verification and progress + persisted in `.claude/state/provision-progress.json`. +- **Explicit GitHub-token mint walkthroughs** in the credential steps: `docs/HARDENING.md` worked + example step 2 now carries the fine-grained-PAT click-path and exact permission table (Contents/ + Issues/Pull-requests read-write, Metadata read, Workflows only if the loop pushes CI files) plus the + classic `repo`-scope bot-token recipe; `/orchestrator:provision` Phase 3 prints it, and + `/orchestrator:setup` step 7's "action needed" path spells out the same bot-account walkthrough. + +### Changed +- **`BOT_LOGIN` default is now derived from the bot token** instead of a hardcoded personal login: + `pr-ci-fix.sh` / `pr-comment-fix.sh` / `pr-rebase.sh` / `pr-feedback.sh` fall back to + `bot-gh.sh api user --jq .login` when `BOT_LOGIN` is unset, so consumer repos no longer silently + filter for PRs authored by the plugin author's bot. Set `BOT_LOGIN` in `.env` to skip the extra + API call; behavior is unchanged when it's set. + ## [0.2.2] - 2026-07-17 ### Changed diff --git a/.claude/.claude-plugin/plugin.json b/.claude/.claude-plugin/plugin.json index 8f250d6..81db33b 100644 --- a/.claude/.claude-plugin/plugin.json +++ b/.claude/.claude-plugin/plugin.json @@ -6,6 +6,7 @@ "commands": [ "./commands/harden.md", "./commands/pr-loop.md", + "./commands/provision.md", "./commands/test-pr.md" ] } diff --git a/.claude/commands/provision.md b/.claude/commands/provision.md new file mode 100644 index 0000000..3515684 --- /dev/null +++ b/.claude/commands/provision.md @@ -0,0 +1,148 @@ +--- +description: Provision a dedicated Linux server for the autonomous loop — guided, resumable walkthrough of HARDENING.md's dedicated-server worked example +--- + +You are guiding the human through provisioning a **fresh dedicated Linux box** (server / VM / cloud +instance) as the maximum-containment home for the autonomous PR loop. +`docs/HARDENING.md` → *"Worked example: dedicated Linux server (maximum containment)"* is the source of +truth — this command turns it into an interactive, checkpointed, **resumable** flow. Never restate its +rationale from memory: read the section and use its blocks verbatim, substituting the interview answers. + +Where to run: **on the new box**, as the human's own **admin** account, in a session with interactive +permission prompts (i.e. BEFORE any hardening is active — sudo and prompts must still work), inside a +clone of the target repo with the `orchestrator` plugin enabled (or this repo itself, self-hosted). +The repo must already be onboarded — `.claude/gates.json` and `.claude/scripts/arm-loop.sh` exist +(scaffolded by `/orchestrator:setup`; tracked in-repo when self-hosting). If they don't, run +`/orchestrator:setup` first and come back. + +> Division of labor, stated up front and repeated per phase: you run what a sandboxed/promptable session +> can run; anything touching **another user's `$HOME`, `/etc`, systemd, or requiring a login shell** is +> printed as a copy-paste block for the human's real terminal, then **verified** by you afterwards. +> Never skip a verification because the human says it's done — check. + +## State & resume + +Progress lives in `.claude/state/provision-progress.json`: + +```json +{ "answers": { "agent_user": "...", "repos": ["..."], "notifier": "...", "layers": ["nftables", "auditd", "remote-ssh"] }, + "phases": { "1-prereqs": "done", "2-agent-user": "pending", "...": "..." } } +``` + +On start: if the file exists, summarize where the run left off and continue from the first +non-`done` phase (re-running a phase's verification before trusting its `done`). If not, start at the +interview. Update the file after every phase. Phases the human declines are recorded `skipped`, never +silently dropped from the final report. + +## 0. Interview (once — stored in `answers`) + +Ask, with defaults: +1. **Agent username** (default `recode-agent`). +2. **Repo(s) the loop will host** (owner/name; first one is cloned in Phase 4). +3. **Notifier** — ntfy topic (recommended; also receives the egress-drop alarm), other command, or none. +4. **Optional layers** — kernel egress allowlist (nftables), detection (auditd + divergence timer), + remote SSH access for humans (`docs/REMOTE_SSH_RUNBOOK.md` — on native Linux its WSL2 gotchas drop out). + +Substitute the answers everywhere below (the docs' `recode-agent` placeholder = answer 1). + +## Phase 1 — prerequisites (`1-prereqs`) + +- Toolchain the box needs: `git`, `tmux`, `bubblewrap`, `socat` (sandbox backend), plus `nftables` / + `auditd` if those layers were chosen. Install what's missing (`sudo apt-get install -y ...` or distro + equivalent). +- On Debian-family kernels, verify unprivileged user namespaces are enabled + (`sysctl kernel.unprivileged_userns_clone` where present, else confirm `bwrap --ro-bind / / true` runs). +- Verify: every command above exists on PATH; `bwrap` smoke test passes. + +## Phase 2 — the agent user (`2-agent-user`, HARDENING step 1) + +- Create it exactly per the worked example: `useradd`, **not** in `sudo`/`wheel`/`docker`/`adm`, + `loginctl enable-linger`, no `~/.ssh/authorized_keys` — reachable only via the admin account + `sudo -iu`. +- Verify: `id -nG ` shows no privileged groups; `sudo -l -U ` shows no sudo; + linger is on (`loginctl show-user -p Linger`). + +## Phase 3 — fresh credentials (`3-credentials`, HARDENING step 2) + +You cannot mint these; guide the human through each and verify the result. The worked example's +**step 2** now carries the full mint walkthrough (click-path + exact permission table) — print it +verbatim, substituting the target repo(s): +- **Fine-grained PAT** for the loop's push identity: *Only select repositories* = the target repo(s); + repository permissions exactly Contents/Issues/Pull-requests read-write + Metadata read (Workflows + read-write only if the loop may push `.github/workflows/` changes); expiry set. +- **Bot token** for `bot-gh.sh` (`GH_BOT_TOKEN`): **classic** PAT with the single `repo` scope, minted + as the machine account (classic on purpose — fine-grained PATs cannot reliably target repos owned by + another personal account; one-time bot setup notes at the top of `.claude/scripts/bot-gh.sh`). +- **Dedicated Anthropic API key** with a spend cap set in the console (skip if the box will use + subscription auth via `claude` login in Phase 4). +- **Rotate every token that lived on the machine being replaced** — this is part of the migration, not + optional hygiene. +- Verify (after Phase 4's clone exists): `.env` present in the agent's clone, mode `600`, owned by the + agent user; old tokens confirmed revoked by the human. + +## Phase 4 — the agent's clone (`4-clone`) + +Print for the human's terminal (needs a login shell as the agent user — `sudo -iu `): +- Install node (nvm), the `claude` CLI, and `gh` in the agent's own home; authenticate `claude` once. +- `git clone` the target repo; write `.env` (Phase 3) and machine-local state — e.g. + `.claude/state/ntfy-topic` if ntfy was chosen. +- Verify from your side where possible (`sudo -u test -f ...` style checks via the human if + your session can't): clone exists, `.env` mode `600`, `claude --version` and `gh --version` run as the + agent user. + +## Phase 5 — managed settings (`5-managed`, HARDENING steps 3–4) + +- Materialize `/etc/claude-code/managed-settings.json` from HARDENING.md **Step 4**, root-owned `644`, + with the sandbox **network allowlist here** (not in `settings.local.json`): Anthropic API, GitHub, the + notifier host, package registries the gates need. +- Verify: file parses as JSON; `ls -l` shows `root root 644`; a write attempt as the agent user fails. + +## Phase 6 — harden profile + arm the loop (`6-arm`) + +- In a Claude Code session **as the agent user** inside the clone: run `/orchestrator:harden`, restart, + `/sandbox`, and its backstop checks (`sudo true` blocked under bypass; a write outside the repo fails). +- Then, in the agent's real terminal: `bash .claude/scripts/arm-loop.sh` (self-hosting this repo: + add `--gates-file self/gates.json`). +- Add the systemd hardening drop-in from the worked example (step 4's `[Service]` block) via + `systemctl --user edit pr-loop-.service`, then `daemon-reload` + restart. +- Verify: both units active as the agent user; `systemd-analyze security pr-loop-.service` reflects + the drop-in (score improves; `NoNewPrivileges=yes` shown). + +## Phase 7 — kernel egress + detection (`7-egress`, optional layers; HARDENING steps 5–6) + +If chosen in the interview: +- nftables: materialize the worked example's UID-matched output chain for the agent user, allowed set = + exactly the hosts from Phase 5's allowlist; **log the drops** and wire the drop log into the notifier — + blocked egress from this box is the intrusion alarm. +- auditd watches on `.claude/scripts/`, `self/` (if self-hosting), `.env`, and the unit files; + journald `Storage=persistent`; the root-owned origin/main divergence timer if the human wants it. +- Verify: `curl https://example.com` **as the agent user** is dropped AND the drop is logged/notified; + the loop's own hosts still work (`curl -sI https://api.github.com` as the agent user succeeds). + +## Phase 8 — remote SSH for humans (`8-remote-ssh`, optional layer; `docs/REMOTE_SSH_RUNBOOK.md`) + +If chosen in the interview — this gives the human's **admin** account zero-inbound-exposure SSH +(Cloudflare Tunnel + Access + short-lived certs); it is **never** a path into the agent user, whose +no-`authorized_keys` invariant from Phase 2 stands: +- Walk the runbook top to bottom; on native Linux its WSL2-specific gotchas (the `loopback0` ufw rule, + `localhostForwarding`) drop out. Almost everything is human-terminal or Cloudflare-dashboard work + (sshd config, `cloudflared` install + tunnel, the Access app and SSH CA) — print the blocks, then verify. +- Interplay with Phase 7's egress allowlist: `cloudflared` runs as its own system user, not the agent + user, so the UID-matched nftables chain does not (and must not) allowlist anything for it. +- Verify: `sshd -T` shows loopback-only `ListenAddress`, `PasswordAuthentication no`, + `PermitRootLogin no`, and `AllowUsers` limited to the admin (+ email-local-part alias); the + `cloudflared` tunnel unit is active; the human confirms a real login from another device lands as + the admin account; `~/.ssh/authorized_keys` still does not exist. + +## Phase 9 — final verification (`9-verify`) + +Walk HARDENING.md's **"Checklist deltas"** for the worked example plus: +- One loop tick landed in `.claude/state/loop-ticks.jsonl` (or the census explains why not). +- A test notification arrived through the `notify` seam. +- The old machine: its loop/daemons decommissioned and its tokens rotated (Phase 3). +- If remote SSH was set up (Phase 8): the runbook's §6 "Verify the security posture" block passes. + +## Report + +End with a compact table: phase → done/skipped + the one-line evidence used to verify it, the collected +answers, and anything the human still owes (with the exact command). If everything is `done`, say so +plainly: the box is provisioned and the loop is armed. diff --git a/.claude/scripts/pr-ci-fix.sh b/.claude/scripts/pr-ci-fix.sh index 1df68e4..11ae1ab 100755 --- a/.claude/scripts/pr-ci-fix.sh +++ b/.claude/scripts/pr-ci-fix.sh @@ -8,7 +8,7 @@ # \t\t\t # # A PR is listed when ALL hold: -# - authored by the bot ($BOT_LOGIN, default robercano-ghbot), open, base is +# - authored by the bot ($BOT_LOGIN, default: the bot token's own login), open, base is # the adapter's merge.baseBranch ($GATES_FILE, default .claude/gates.json — # same node-read as loop-census.sh/merge-ready.sh); # - at least one CI check on the CURRENT head is FAILING: CheckRun @@ -60,7 +60,7 @@ set -euo pipefail # Route EVERY gh call through the bot identity (see bot-gh.sh). gh() { bash "$script_dir/bot-gh.sh" "$@"; } repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" -bot="${BOT_LOGIN:-robercano-ghbot}" +bot="${BOT_LOGIN:-$(gh api user --jq .login)}" # default: the bot token's own login gates_rel="${GATES_FILE:-.claude/gates.json}" case "$gates_rel" in /*) gates="$gates_rel" ;; *) gates="$root/$gates_rel" ;; esac diff --git a/.claude/scripts/pr-comment-fix.sh b/.claude/scripts/pr-comment-fix.sh index ec57f41..0f1a9c7 100644 --- a/.claude/scripts/pr-comment-fix.sh +++ b/.claude/scripts/pr-comment-fix.sh @@ -9,7 +9,7 @@ # \t\t:[,:...]\t # # A PR is listed when ALL hold: -# - authored by the bot ($BOT_LOGIN, default robercano-ghbot), open, base is +# - authored by the bot ($BOT_LOGIN, default: the bot token's own login), open, base is # the adapter's merge.baseBranch ($GATES_FILE, default .claude/gates.json # — same node-read as loop-census.sh/pr-ci-fix.sh); # - it is NOT ALSO a pr-feedback.sh candidate. VERDICT PRECEDENCE (issue @@ -79,7 +79,7 @@ set -euo pipefail # Route EVERY gh call through the bot identity (see bot-gh.sh). gh() { bash "$script_dir/bot-gh.sh" "$@"; } repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" -bot="${BOT_LOGIN:-robercano-ghbot}" +bot="${BOT_LOGIN:-$(gh api user --jq .login)}" # default: the bot token's own login owner_login="${OWNER_LOGIN:-${repo%%/*}}" repo_name="${repo#*/}" repo_owner="${repo%%/*}" diff --git a/.claude/scripts/pr-feedback.sh b/.claude/scripts/pr-feedback.sh index 38b00b3..5d1b245 100644 --- a/.claude/scripts/pr-feedback.sh +++ b/.claude/scripts/pr-feedback.sh @@ -30,7 +30,7 @@ set -euo pipefail # Route EVERY gh call through the bot identity (see bot-gh.sh). gh() { bash "$script_dir/bot-gh.sh" "$@"; } repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" -bot="${BOT_LOGIN:-robercano-ghbot}" +bot="${BOT_LOGIN:-$(gh api user --jq .login)}" # default: the bot token's own login marker="" # needs_human_flag/needs_human_clear (issue #99): the ONE shared label+notify diff --git a/.claude/scripts/pr-rebase.sh b/.claude/scripts/pr-rebase.sh index 0d49918..600e9bd 100644 --- a/.claude/scripts/pr-rebase.sh +++ b/.claude/scripts/pr-rebase.sh @@ -9,7 +9,7 @@ # \t\t\t\t # # A PR is listed when ALL hold: -# - authored by the bot ($BOT_LOGIN, default robercano-ghbot), open, base is +# - authored by the bot ($BOT_LOGIN, default: the bot token's own login), open, base is # the adapter's merge.baseBranch ($GATES_FILE, default .claude/gates.json # — same node-read as loop-census.sh/pr-ci-fix.sh/pr-comment-fix.sh); # - GitHub's own `mergeable` field on the PR is EXACTLY `CONFLICTING`. This @@ -89,7 +89,7 @@ set -euo pipefail # Route EVERY gh call through the bot identity (see bot-gh.sh). gh() { bash "$script_dir/bot-gh.sh" "$@"; } repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" -bot="${BOT_LOGIN:-robercano-ghbot}" +bot="${BOT_LOGIN:-$(gh api user --jq .login)}" # default: the bot token's own login # needs_human_flag (issue #99 seam): sourced AFTER `gh` is defined above, so # its calls run through the bot identity too. Guarded (not a bare source diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 3249c25..ccafc96 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -148,9 +148,16 @@ Report created vs already-existing. Remind: **an issue is only loop-eligible onc ## 7. Verify the bot account - Confirm `.env` has `GH_BOT_TOKEN` and the bot can see the repo: `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh api user --jq .login` and a `repo view` on the resolved repo. -- If missing/no access, DON'T fail the whole setup — point at the one-time setup notes in - `.claude/scripts/bot-gh.sh` (create machine account → add as **write** collaborator → classic `repo`-scope - token → `.env`) and mark this step "action needed". +- If missing/no access, DON'T fail the whole setup — print the one-time walkthrough (from the setup + notes in `.claude/scripts/bot-gh.sh`) and mark this step "action needed": + 1. Create a free GitHub machine account (one is allowed alongside a personal account), e.g. `-assistant-bot`. + 2. As the OWNER: add it as a **write** collaborator on this repo; accept the invite as the bot. + 3. As the BOT: github.com → *Settings → Developer settings → Personal access tokens → Tokens (classic) + → Generate new token (classic)* with the single **`repo`** scope, expiry set. Classic on purpose: + fine-grained PATs cannot reliably target repos owned by another personal account. (This bot token is + distinct from the loop's own push credential — for a hardened/dedicated box see + `docs/HARDENING.md` → worked example step 2 for the fine-grained-PAT permission table.) + 4. Put it in the project's gitignored `.env` as `GH_BOT_TOKEN=...` and re-run the verification above. ## 8. Server-side gates (CI) - Confirm `.github/workflows/gates.yml` exists (scaffolded in step 4 if it wasn't already) and its jobs match diff --git a/.env.example b/.env.example index bbc557c..88908fb 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,9 @@ GH_BOT_TOKEN= # OPTIONAL — Login of the bot machine account. # Used to identify bot-authored PRs in the feedback step. -# Default: robercano-ghbot -# BOT_LOGIN=robercano-ghbot +# Default: resolved from GH_BOT_TOKEN (the bot's own login). +# BOT_LOGIN= # OPTIONAL — GitHub login whose APPROVED review authorizes a merge. # Default: the repo owner (the org/user part of the git remote). -# MERGE_APPROVER=robercano +# MERGE_APPROVER= diff --git a/docs/HARDENING.md b/docs/HARDENING.md index 04dc14f..81265b8 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -317,6 +317,10 @@ sudo gpasswd -d "$USER" docker # if you don't need Docker in this distro ### Worked example: dedicated Linux server (maximum containment) +> **Guided:** run **`/orchestrator:provision`** on the new box to be walked through this section +> interactively — an interview, phase-by-phase checkpoints with verification, and resumable progress in +> `.claude/state/provision-progress.json`. This section stays the source of truth; the command executes it. + A dedicated server (or VM/cloud box) is the strongest home for the loop, because it fixes the two things a shared machine can't: a **real privilege boundary** between the agent and you (on WSL you and the agent are the same user, so Step 4's root-owned policy is decoration), and **kernel-level egress @@ -337,8 +341,27 @@ sudo -iu recode-agent # how YOU inspect/operate it No `~/.ssh/authorized_keys` for `recode-agent` — it is reachable only via your account + `sudo -u`. **2. Fresh credentials, minted for the box (never copied from your workstation):** -- A **fine-grained GitHub PAT** scoped to only the target repo(s) — Contents/Issues/Pull-requests - read-write, nothing administrative, expiry set — into `~recode-agent//.env` (mode `600`). +- A **fine-grained GitHub PAT** scoped to only the target repo(s) — into `~recode-agent//.env` + (mode `600`). Mint it at github.com → *Settings → Developer settings → Personal access tokens → + Fine-grained tokens → Generate new token*: + - **Resource owner**: the account/org that owns the target repo(s). + - **Repository access**: *Only select repositories* → the target repo(s), nothing else. + - **Repository permissions** — exactly these, everything else stays *No access*: + | Permission | Level | Why the loop needs it | + |---|---|---| + | Contents | Read and write | push branches / read the repo | + | Issues | Read and write | file + label loop issues | + | Pull requests | Read and write | open, update, comment on PRs | + | Metadata | Read-only | mandatory (auto-selected) | + | Workflows | Read and write — **only if** the loop may push changes under `.github/workflows/` | without it such pushes are refused (workflow-scope push restriction); leave at *No access* and keep CI files human-edited otherwise | + - **No account permissions, nothing administrative.** Set an **expiration** (≤90 days) and put the + rotation date somewhere you'll see it. +- The **bot token** (`GH_BOT_TOKEN` for `bot-gh.sh`) is a separate credential and deliberately + **classic**, not fine-grained — fine-grained PATs cannot reliably target repos owned by another + personal account, and the bot is its own machine account. Mint it *as the bot*: *Settings → + Developer settings → Tokens (classic) → Generate new token (classic)* with the single `repo` scope, + expiry set. Full one-time bot setup (machine account, write-collaborator invite) is in the notes at + the top of `.claude/scripts/bot-gh.sh`. - A **dedicated Anthropic API key** with a spend cap set in the console. - Rotate whatever token previously lived on the old machine as part of the migration.