fix: let implement-issue run end to end without permission prompts - #657
Conversation
The profile blocked the one command Step 11 prescribes and allowed the ones it never runs. `gh api *` sat in `ask`, but reading inline review comments (`pulls/<n>/comments`) has no `gh pr view` equivalent -- it is the only way to get that feed, and it is a plain GET. Meanwhile `gh auth token` and `gh issue close` were permitted outright. The four scripts the skills actually drive -- request-pr-review.sh, gh-agent.sh, worktree-setup.sh, quality-check.sh -- matched no rule at all, so each invocation fell through to the auto-mode classifier and stalled unpredictably. Now: every command implement-issue runs from fetch to a ready PR resolves to allow; `gh pr merge`, `gh issue close` and the release verbs still ask, since no server-side ruleset covers them; `gh api` writes ask in either flag ordering, because `gh api * -X *` alone cannot match `gh api -X POST <url>`. `git push` is deliberately not gated -- main, beta and tags are protected by rulesets with empty bypass lists, so a client-side prompt could only ever fire on the routine case. The test pins the command inventory extracted from the skills. It models the matcher as deny > ask > allow with fnmatch, so it asserts the rule set expresses the intent, not that the harness enforces it that way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQkTrgs9uDZCbD8Gp9TBzp
|
@claude-bot review |
There was a problem hiding this comment.
Summary
Scope is clean: config + one test file, no production code, well-evidenced root cause (the gh api ask/allow inversion, backed by a 29/46-failing-before-fix count). The permission-model reasoning for git push staying ungated (server-side rulesets cover the dangerous case) is sound and clearly stated. No linked issue — this reads as self-directed tooling work discovered from a stalled run, which is fine, but nothing to cross-check a "root cause" claim against.
Blocking: Bash(git reset*) in deny blocks a documented, required workflow with no escape hatch
.claude/settings.json (deny, new): "Bash(git reset*)",
docs/agents/rules.md:36-40 (required reading, hard constraints) documents the WIP-commit recovery pattern:
To set work aside on the branch you are already on ... use a WIP commit ... then
git reset --soft HEAD~1to pick it back up.
I ran the PR's own decide() model (from backend/tests/test_agent_permissions.py) against the PR's actual .claude/settings.json:
git reset --soft HEAD~1 -> ('deny', 'git reset*')
git reset HEAD backend/app.py -> ('deny', 'git reset*')
deny has no override — per rules.md:21-24 itself, deny "blocks every mutating form outright ... with no override." So this PR silently removes the only documented way to un-stage a WIP commit, for every agent and every human-supervised session in this repo, not just implement-issue. That's a harder failure mode than the prompt-stalls this PR sets out to fix — no prompt, no path forward, just a rejected command.
The PR description's own "Follow-up worth considering" section identifies this exact problem and even names the fix:
Narrowing to
git reset --hard*would keep the real protection.
Given the fix is a one-line pattern narrowing, and this is a documented hard-constraint workflow the PR itself would break, I'd ask for that narrowing in this PR rather than deferring it — "fix it in the same cycle" is the project's own debugging-protocol convention (rules.md:147) for a bug found while working the same area.
Secondary: the FORBIDDEN test table only asserts git reset --hard origin/main -> deny; it never exercises git reset --soft HEAD~1 or a bare git reset HEAD <file>, so the test suite the PR adds doesn't catch this regression either. Worth a case in RUNS_UNATTENDED (or a new "does not deny" table) once the pattern is narrowed, so it can't drift back.
Test coverage
Otherwise strong — three tables (unattended/needs-approval/forbidden) plus the flag-ordering case are a reasonable pin of the command inventory, and the "evidence the test discriminates" section (29/46 failing pre-fix) is exactly the kind of proof this checklist looks for.
Everything else
- No Optional[x], hasattr/getattr, new classes, or exception-string-matching — N/A, no production code touched.
- Comments in the new test file are WHY-only (incident context, caveats about the fnmatch model) — compliant with the no-what-comments rule.
- Could not run black/ruff/mypy locally in this review environment (no
.venvavailable here) — relying on the PR's reported46 passed/ quality-check output; worth a CI-green confirmation before merge if not already checked.
`Bash(git reset*)` denied every form, and deny never prompts. That removed `git reset --soft HEAD~1` -- which rules.md:36-40 prescribes as the WIP-commit recovery pattern now that `git stash` is denied repo-wide -- along with plain unstaging, repo-wide and for humans too. Only `--hard` discards committed work. Adds a MUST_NOT_BE_DENIED table so a deny pattern broader than the danger it targets fails CI. The FORBIDDEN table only exercised `--hard`, so it could not have caught this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQkTrgs9uDZCbD8Gp9TBzp
|
@claude-bot review |
There was a problem hiding this comment.
Summary
No linked issue — this is a self-diagnosed tooling fix, not an issue-driven change, so the "matches linked issue root cause" checklist item doesn't apply. Root cause is verified directly against .claude/settings.json.
Root cause matches the diff. Confirmed by reading the pre-PR file: "ask": ["Bash(gh api)", "Bash(gh api *)", ...] sat above the blanket gh * isn't present — so gh api reads genuinely had no matching allow rule, while gh auth token/gh issue close had no rule at all and fell to the unpredictable auto-classifier. The fix's characterization is accurate.
The test discriminates — verified by hand, not just trusted. I checked out the PR branch in an isolated worktree and ran backend/tests/test_agent_permissions.py against both settings.json versions:
- Post-fix: 48/48 pass.
- Pre-fix (
git show main:.claude/settings.json): 29/48 fail, including all threeFORBIDDENcases (git push --force,git push -f,gh auth tokenall resolved tounmatchedinstead ofdeny) and thegh apiwrite-form case. This is real RED→GREEN evidence perdocs/agents/testing.md, not just an assertion in the PR body.
The self-correction in commit 2 (Bash(git reset*) → Bash(git reset --hard*)) is exactly right — git reset --soft HEAD~1 is the WIP-recovery pattern rules.md:36-40 prescribes now that stash is denied repo-wide, and the new MUST_NOT_BE_DENIED table (test_agent_permissions.py:150-158) pins that so a future overly-broad deny fails CI instead of silently removing the escape hatch again.
Scope is minimal. Only .claude/settings.json and the new test file — no production code, matches the PR's own scope assessment.
Observation (non-blocking). The new Bash(git *) allow rule (.claude/settings.json:57) is a blanket allow needed because git -C <path> ... puts the worktree flag between the binary and subcommand, which a narrower git branch *-style pattern would miss — the PR explains this tradeoff and deliberately covers git reset --hard/git push --force/-f in deny. It does not add equivalent protection for git clean -f/-fd (deletes untracked files, just as irreversible as reset --hard) or git checkout -- . (discards uncommitted changes) — both called out alongside git reset --hard in the root CLAUDE.md's "Executing actions with care" section as needing confirmation. I grepped .claude/skills/*/SKILL.md and docs/ and found no current use of git clean in any skill, so there's no live path that exercises this today — before this PR the same commands were merely "unmatched" (unpredictable), not safely gated either, so this isn't a regression the PR introduces. Worth a follow-up deny rule for git clean -f*/-fd* given the stated design principle (irreversible-and-uncovered-by-server-rulesets → deny) applies equally here.
No rule violations found against docs/agents/rules.md / .github/claude-bot.md checklist. CI (Docker build, E2E, Fast tests, Frontend checks, Code quality, Merge gate) all pass.
* fix: assert the gh api guard by behaviour, not by spelling `quality-check.sh` failed on a clean `origin/main`. Its permission gate demanded the literal strings `Bash(gh api)` and `Bash(gh api *)` in `permissions.ask`; #657 replaced that blanket with one rule per write flag so `gh api` reads would stop prompting. The property the gate cared about still held — a `gh api` write still could not reach `allow` — but the spelling it pinned was gone, so it reported main as broken. Pinning a spelling was the wrong assertion. Whether a write can reach `allow` is already asserted by command string in MUST_BE_GUARDED, and those pins survive a respelling. Drop the two literal entries from REQUIRED["ask"] and keep the behavioural ones. Relaxing it uncovered a live hole. `gh api --help` documents `-F, --field` and `-f, --raw-field`; only the short forms were enumerated, so gh api repos/o/r/releases --field tag_name=v1 resolved to `allow` — a GitHub write that never prompted. Four command strings now pin both long forms in both argument positions, and `settings.json` gains the matching ask rules. Also annotates the six test functions in test_agent_permissions.py, which #614's mypy-on-changed-files gate requires once the file is touched, and corrects two claims in local-agent-environment.md that this makes false: that every `gh api` asks, and that the gate requires the blanket spelling. No CHANGELOG entry: agent tooling, no user-visible effect on the add-on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Y74iknEwCAGYGMwDM4NRc * fix: pin attached-value gh api write spellings so they prompt (#665) cobra/pflag accepts `--flag=value`, `-fvalue` and `-f=value` as readily as `--flag value`, but the space-separated globs in settings.json end in ` --field ` (with a space) and could not reach them -- so a write like `gh api repos/o/r/releases --field=body=hi` resolved to `allow` and never prompted. Add the attached-value ask patterns in both argument positions, pin the same spellings in MUST_BE_GUARDED, and extend the permission test so a respelling can't reopen the leak. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…670) * feat: run dispatched agents in containers, with a fleet manifest instead of git worktrees Phase 1 of docs/superpowers/specs/2026-08-20-agent-fleet-sandbox-router-design.md: dispatched work moves off the maintainer's filesystem and credentials, so permissions come from a boundary rather than from another allowlist pattern. - scripts/run-agent.sh <n> gives one agent one private clone (not a worktree: worktrees share a mutable ref namespace that only survives because a human paces dispatch by hand) and one container, alive for the whole issue lifecycle so Step 10's conflict self-heal and Step 11's review loop keep running unattended. - scripts/run-po.sh runs product-owner as a single long-lived looping container -- the one role with project memory, so a per-pass clone would trap every memory edit in a throwaway checkout. - scripts/fleet-manifest.sh replaces `git worktree list` as the registry, in SQLite because N containers write status concurrently. It enforces the product-owner singleton at register time. - scripts/wait-for-reply.sh lets a headless run block at a judgment gate instead of exiting, resuming in the same process on the maintainer's reply. - Containerfile.agent BAKES the Linux dependency trees rather than mounting the host's, which the design assumed: the host is macOS/arm64 (Mach-O venv, @esbuild/darwin-arm64) and the container is Linux. They are built at the host's own absolute path, so one unchanged symlink resolves to macOS deps on the host and Linux deps in the container, and the clone stays a normal checkout you can cd into and test. Per-container copy-on-write replaces the design's read-only mount, verified by `run-agent.sh --verify-isolation`. - The container's egress is an nftables allowlist re-resolved on an interval, and the entrypoint refuses to start rather than run unrestricted. - implement-issue gains a Headless local mode table, parallel to CI mode's, with a row per numbered step. Step 8 applies here unlike in CI: the container drives the compose stack as siblings through the outer podman. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDRGrAguemfDrx1phG2Bpe * fix: make a dispatched container actually able to run an agent Four defects, all found by running the thing rather than reading it. - The container ran as root, and Claude Code refuses --dangerously-skip- permissions as root outright -- so the one flag this whole phase exists to enable could never be used. The image now has an `agent` user; the entrypoint still starts as root (it needs NET_ADMIN for the egress ruleset) and drops with setpriv immediately after. Podman's virtiofs presents the bind-mounted clone as owned by whatever uid the process has, so this costs no chown of the host's files. - Claude Code ignored the project's own .claude/settings.json ("this workspace has not been trusted") with no dialog to accept. The entrypoint writes the trust entry for the clone's path, which is per-dispatch and so cannot be baked into the image. - The podman socket pre-flight tested the path on the HOST. On macOS that socket lives inside the VM, so the check was a guaranteed false negative and refused every dispatch. Ask podman whether its own socket exists instead. - register refused a re-dispatch of a finished issue, because the container id is derived from the issue number and the old row still held it -- blocking exactly the resume implement-issue Step 0 is built around. It now reclaims a row whose status is `done`, and still refuses one that is live. It also accepts an empty issue number, which is what run-po.sh legitimately has. Verified live: the agent starts non-root, reports permissionMode bypassPermissions, loads the repo's skills, works in the clone, is egress- restricted, and gets as far as authenticating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDRGrAguemfDrx1phG2Bpe * docs: note that the in-container bwrap sandbox warning is expected Every dispatch prints "Sandbox disabled: bubblewrap not installed". That is Claude Code's own in-process sandbox, which this container deliberately does without -- the container is the boundary. Say so where someone reading the Containerfile will look, so nobody installs bwrap to quiet a warning and ends up debugging a second, narrower sandbox nested inside the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDRGrAguemfDrx1phG2Bpe * fix: restore the blanket gh api ask that #657's enumeration replaced #657 turned the blanket `Bash(gh api *)` ask into an allow plus an enumerated deny, to stop `gh api --jq` reads from prompting. The write forms put their marker at an arbitrary argument position, so the deny globs could not reach them, and the enumeration left real holes twice (quality-check.sh documents both). A blanket `Bash(gh *)` allow would invert the whole thing by letting `gh auth token` through, so there is none: `gh api` is a blanket ask again and every interactive call prompts once. Dispatched agents bypass permissions entirely, so headless runs are unaffected. Pin the blanket-ask profile in test_agent_permissions.py and annotate the test functions to pass the mypy gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: make the dispatched container's baked deps usable by the agent (PR #670 review) Two confirmed blockers from Stage 4, both in Containerfile.agent: - The dependency trees (.venv, frontend/node_modules) were baked in as root, but the entrypoint drops to uid 1000, so an in-container pip/npm install hit EACCES -- which also made run-agent.sh --verify-isolation unrunnable, since its measurement is exactly that pip install. Hand the trees to the agent with a chown after useradd. - The Playwright browsers were baked into /root/.cache/ms-playwright (build-time HOME=/root) but the runtime HOME is /home/agent and /root is drwx------, so the baked cache was unreachable and dispatches re-downloaded. Set PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright and hand that over too. Also the two heads-up notes: the Headless Step 8 row said `docker compose` (the image only has podman-compose) and implied localhost reachability (the siblings publish on the podman host; observe via the allowlisted host.containers.internal). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: gate the reply-wait on the repo owner, not a denylist (PR #670 review) wait-for-reply.sh accepted any comment newer than `since` that was not from one of the listed automation identities. On a public repo that is every account: a drive-by comment was returned as the maintainer's answer to an agent running --dangerously-skip-permissions with a write-scoped token. The rest of the repo gates on the owner explicitly (CLAUDE.md's rule); this was the one place that inverting it. Make --from an allowlist (repeatable) defaulting to the repo owner via `gh repo view --json owner`, with --ignore still subtracting the automation identities on top. An unrecognised author is skipped, never accepted, and a gate that cannot resolve its owner fails explicitly. Tests now cover the stranger-rejection cases that were RED under the old logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: mount the podman socket only for --with-compose dispatches (PR #670 review) The socket is authority over the whole podman host: whoever holds it can start sibling containers with arbitrary bind mounts that reach the host paths mounted in them -- including the main checkout's .env and the tokens in it (BESS_PO_TOKEN, BESS_AGENT_TOKEN, the agent's Claude auth) -- and those siblings get no nftables egress allowlist, since that lives per network namespace. So the socket is now opt-in: only run-agent.sh --with-compose mounts it (that is what makes Step 8's local run & observe possible from inside a container), run-po.sh never mounts it, and --dry-run no longer echoes the credentials baked into the run args. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
implement-issuestalled mid-run waiting for approvals nobody was awake to give. The profile blocked the one command Step 11 prescribes and permitted several it never runs.allow; the maintainer's calls stillask.Root cause
Bash(gh api *)sat inask. Reading inline review comments (pulls/<n>/comments) has nogh pr viewequivalent — that endpoint is the only way to get the feed, and it is a plain GET. So the review loop's own prescribed command (SKILL.md:698) was the one thing gated, whilegh auth tokenandgh issue closewere permitted outright.Separately, the four scripts the skills actually drive —
request-pr-review.sh,gh-agent.sh,worktree-setup.sh,quality-check.sh— matched no rule in any category. Permission rules match only the top-level command string, so each fell through to the auto-mode classifier and behaved unpredictably run to run.Fix
allowgh pr checks/ready/comment,gh run list/view/watch,gh apireads;git *(Step 4's prune usesgit -C <path> …, which a narrowgit branch *would miss); build/test commandsaskgh issue close;gh apiwrite-forms in both flag orderings —gh api * -X *alone cannot matchgh api -X POST <url>, since the leading*cannot span the empty prefixdenygit reset*,git push --force*/-f*(SKILL.md:176),gh auth token*Bash(gh api)/Bash(gh api *)fromaskgit pushis deliberately not gated:main,betaand tags are protected by rulesets with empty bypass lists, so a client-side prompt could only ever fire on the routine feature-branch case.gh pr merge,gh issue closeand the release verbs stay inaskfor the opposite reason — the server cannot distinguish the agent callingghwith the maintainer's credentials from the maintainer clicking the button.Test plan
backend/tests/test_agent_permissions.py— 46 assertions over three tables (runs-unattended / needs-approval / forbidden), plus the flag-ordering case.Evidence the test discriminates
Run against the pre-fix settings it fails 29 of 46 — including every one of the four scripts, the Step 11
gh apiread, andgh auth tokenresolving toallow.Scope assessment
Config plus one test file; no production code. No CHANGELOG entry — agent tooling has no user-visible effect.
Known limitation
The test models the matcher as
deny > ask > allowwithfnmatch, so it asserts the rule set expresses the intent, not that the harness enforces it that way. The one behaviour verified against the real matcher by hand: mid-string wildcards do match (git -c core.pager=cat stash --helpis caught byBash(git -* stash --*)), which thegh apiwrite-form rules depend on.Review round 1
CHANGES_REQUESTEDcorrectly caughtBash(git reset*)indenyas a blocker rather than a follow-up.denynever prompts, andrules.md:36-40prescribesgit reset --soft HEAD~1as the WIP-commit recovery pattern now thatgit stashis denied repo-wide — so the rule removed a documented hard-constraint workflow repo-wide, for humans too. Narrowed toBash(git reset --hard*), the only form that discards committed work.Also added a
MUST_NOT_BE_DENIEDtable: theFORBIDDENtable only exercised--hard, so the suite could not have caught the regression it introduced. Adenypattern broader than the danger it targets now fails CI.🤖 Generated with Claude Code