Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/.claude-plugin/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"commands": [
"./commands/harden.md",
"./commands/pr-loop.md",
"./commands/provision.md",
"./commands/test-pr.md"
]
}
148 changes: 148 additions & 0 deletions .claude/commands/provision.md
Original file line number Diff line number Diff line change
@@ -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 <agent_user>` shows no privileged groups; `sudo -l -U <agent_user>` shows no sudo;
linger is on (`loginctl show-user <agent_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 <agent_user>`):
- 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 <agent_user> 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-<repo>.service`, then `daemon-reload` + restart.
- Verify: both units active as the agent user; `systemd-analyze security pr-loop-<repo>.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; `~<agent_user>/.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.
4 changes: 2 additions & 2 deletions .claude/scripts/pr-ci-fix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# <number>\t<branch>\t<failing_check_names_csv>\t<head_sha>
#
# 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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .claude/scripts/pr-comment-fix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# <number>\t<branch>\t<thread_id>:<next_attempt>[,<thread_id>:<next_attempt>...]\t<head_sha>
#
# 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
Expand Down Expand Up @@ -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%%/*}"
Expand Down
2 changes: 1 addition & 1 deletion .claude/scripts/pr-feedback.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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="<!-- claude-addressed -->"

# needs_human_flag/needs_human_clear (issue #99): the ONE shared label+notify
Expand Down
4 changes: 2 additions & 2 deletions .claude/scripts/pr-rebase.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# <number>\t<branch>\t<head_sha>\t<base_sha>\t<attempt>
#
# 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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions .claude/skills/setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<you>-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
Expand Down
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=<your-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=<your-github-login>
Loading
Loading