Skip to content

fix: let implement-issue run end to end without permission prompts - #657

Merged
johanzander merged 2 commits into
mainfrom
worktree-fix+agent-permission-profile
Aug 19, 2026
Merged

fix: let implement-issue run end to end without permission prompts#657
johanzander merged 2 commits into
mainfrom
worktree-fix+agent-permission-profile

Conversation

@johanzander

@johanzander johanzander commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • implement-issue stalled 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.
  • Every command the skill runs from fetch to a ready PR now resolves to allow; the maintainer's calls still ask.
  • Adds a test pinning the command inventory extracted from the skills, so a skill gaining a new command fails CI instead of stalling a run at 2am.

Root cause

Bash(gh api *) sat in ask. Reading inline review comments (pulls/<n>/comments) has no gh pr view equivalent — 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, while gh auth token and gh issue close were 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

Category Change
allow the four scripts; gh pr checks/ready/comment, gh run list/view/watch, gh api reads; git * (Step 4's prune uses git -C <path> …, which a narrow git branch * would miss); build/test commands
ask gh issue close; gh api write-forms in both flag orderings — gh api * -X * alone cannot match gh api -X POST <url>, since the leading * cannot span the empty prefix
deny git reset*, git push --force*/-f* (SKILL.md:176), gh auth token*
removed blanket Bash(gh api) / Bash(gh api *) from ask

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 feature-branch case.

gh pr merge, gh issue close and the release verbs stay in ask for the opposite reason — the server cannot distinguish the agent calling gh with 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.

46 passed in 0.03s
2138 passed, 50 skipped, 562 deselected   # -m "not slow"

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 api read, and gh auth token resolving to allow.

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 > allow with fnmatch, 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 --help is caught by Bash(git -* stash --*)), which the gh api write-form rules depend on.

Review round 1

CHANGES_REQUESTED correctly caught Bash(git reset*) in deny as a blocker rather than a follow-up. deny never prompts, and rules.md:36-40 prescribes git reset --soft HEAD~1 as the WIP-commit recovery pattern now that git stash is denied repo-wide — so the rule removed a documented hard-constraint workflow repo-wide, for humans too. Narrowed to Bash(git reset --hard*), the only form that discards committed work.

Also added a MUST_NOT_BE_DENIED table: the FORBIDDEN table only exercised --hard, so the suite could not have caught the regression it introduced. A deny pattern broader than the danger it targets now fails CI.

🤖 Generated with Claude Code

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
@bess-agent

Copy link
Copy Markdown
Collaborator

@claude-bot review

@bess-manager-claude-bot bess-manager-claude-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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~1 to 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 .venv available here) — relying on the PR's reported 46 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
@bess-agent

Copy link
Copy Markdown
Collaborator

@claude-bot review

@bess-manager-claude-bot bess-manager-claude-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 three FORBIDDEN cases (git push --force, git push -f, gh auth token all resolved to unmatched instead of deny) and the gh api write-form case. This is real RED→GREEN evidence per docs/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.

@johanzander
johanzander marked this pull request as ready for review August 19, 2026 04:34
@johanzander
johanzander merged commit eca47e9 into main Aug 19, 2026
8 checks passed
@johanzander
johanzander deleted the worktree-fix+agent-permission-profile branch August 19, 2026 04:42
johanzander added a commit that referenced this pull request Aug 21, 2026
* 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>
johanzander added a commit that referenced this pull request Aug 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants