You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/19 12:18:27 UTC

(magpie) branch main updated: fix(agent-isolation): list gh write subcommands in ask instead of a catch-all that prompted on every read (#1290)

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git


The following commit(s) were added to refs/heads/main by this push:
     new db56c022 fix(agent-isolation): list gh write subcommands in ask instead of a catch-all that prompted on every read (#1290)
db56c022 is described below

commit db56c022ac90ab169da284f28d1c6be21975d41d
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sat Sep 19 14:18:22 2026 +0200

    fix(agent-isolation): list gh write subcommands in ask instead of a catch-all that prompted on every read (#1290)
    
    The reference `.claude/settings.json` kept `Bash(gh *)` in `permissions.ask`
    on the assumption that the more specific read-only `allow` rules
    (`gh pr view`, `gh issue list`, …) would exempt reads. They never did:
    Claude Code evaluates deny, then ask, then allow, and "a matching ask rule
    prompts even when a more specific allow rule also matches the same call".
    So every gh call prompted, reads included, in every permission mode — and
    because ask rules merge from every settings source, a user-scope file
    could not undo it.
    
    - .claude/settings.json + tools/sandbox-lint/expected.json: the catch-all
      becomes 94 explicit gh write subcommands (`gh api` included — GET and
      POST look the same to a prefix rule). Read-only subcommands now reach
      the allow list; a subcommand in neither list falls through to the
      mode's default.
    - sandbox-lint: new invariant rejects `Bash(gh *)` in ask, with a test;
      the old test that pinned the catch-all is replaced.
    - isolated-setup-verify: check 11b fails on a catch-all in any scope.
    - isolated-setup-doctor: the gh probe warns when a catch-all is present.
    - docs: the annotated reference config, the verification prompt, the
      catalog's gh entry and RFC-AI-0002 § Layer 3a now state the real
      precedence rule; the RFC's example `gh api * -X *` patterns (middle
      wildcards, which prefix rules do not support) become `gh api *`.
    - specs: agent-isolation-sandbox and sandbox-diagnostics updated; the
      validation script asserts no catch-all plus explicit writes.
    
    Generated-by: Claude Opus 5
---
 .claude/settings.json                              |  95 +++++++++++++++++-
 docs/mode-economics.md                             |   6 +-
 docs/rfcs/RFC-AI-0002.md                           |  22 +++--
 docs/setup/sandbox-troubleshooting.md              |   9 ++
 docs/setup/secure-agent-setup.md                   | 109 ++++++++++++++++++++-
 .../skills/isolated-setup-doctor/SKILL.md          |   8 ++
 .../skills/isolated-setup-verify/SKILL.md          |  20 +++-
 tools/sandbox-lint/expected.json                   |  95 +++++++++++++++++-
 tools/sandbox-lint/src/sandbox_lint/__init__.py    |  12 +++
 tools/sandbox-lint/tests/test_validator.py         |  26 ++++-
 tools/spec-loop/.last-sync                         |   2 +-
 tools/spec-loop/specs/agent-isolation-sandbox.md   |  27 +++--
 tools/spec-loop/specs/sandbox-diagnostics.md       |   7 ++
 13 files changed, 404 insertions(+), 34 deletions(-)

diff --git a/.claude/settings.json b/.claude/settings.json
index 89962227..6e621752 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -137,7 +137,100 @@
       "Bash(git push *)",
       "Bash(git push --force *)",
       "Bash(git push --force-with-lease *)",
-      "Bash(gh *)"
+      "Bash(gh api *)",
+      "Bash(gh pr create *)",
+      "Bash(gh pr comment *)",
+      "Bash(gh pr review *)",
+      "Bash(gh pr merge *)",
+      "Bash(gh pr close *)",
+      "Bash(gh pr reopen *)",
+      "Bash(gh pr edit *)",
+      "Bash(gh pr ready *)",
+      "Bash(gh pr lock *)",
+      "Bash(gh pr unlock *)",
+      "Bash(gh pr revert *)",
+      "Bash(gh pr update-branch *)",
+      "Bash(gh issue create *)",
+      "Bash(gh issue comment *)",
+      "Bash(gh issue close *)",
+      "Bash(gh issue reopen *)",
+      "Bash(gh issue edit *)",
+      "Bash(gh issue delete *)",
+      "Bash(gh issue lock *)",
+      "Bash(gh issue unlock *)",
+      "Bash(gh issue pin *)",
+      "Bash(gh issue unpin *)",
+      "Bash(gh issue transfer *)",
+      "Bash(gh issue develop *)",
+      "Bash(gh release create *)",
+      "Bash(gh release upload *)",
+      "Bash(gh release delete *)",
+      "Bash(gh release delete-asset *)",
+      "Bash(gh release edit *)",
+      "Bash(gh workflow run *)",
+      "Bash(gh workflow enable *)",
+      "Bash(gh workflow disable *)",
+      "Bash(gh run rerun *)",
+      "Bash(gh run cancel *)",
+      "Bash(gh run delete *)",
+      "Bash(gh repo create *)",
+      "Bash(gh repo delete *)",
+      "Bash(gh repo edit *)",
+      "Bash(gh repo fork *)",
+      "Bash(gh repo sync *)",
+      "Bash(gh repo archive *)",
+      "Bash(gh repo unarchive *)",
+      "Bash(gh repo rename *)",
+      "Bash(gh repo set-default *)",
+      "Bash(gh repo deploy-key add *)",
+      "Bash(gh repo deploy-key delete *)",
+      "Bash(gh repo autolink create *)",
+      "Bash(gh repo autolink delete *)",
+      "Bash(gh label create *)",
+      "Bash(gh label delete *)",
+      "Bash(gh label edit *)",
+      "Bash(gh label clone *)",
+      "Bash(gh cache delete *)",
+      "Bash(gh secret set *)",
+      "Bash(gh secret delete *)",
+      "Bash(gh variable set *)",
+      "Bash(gh variable delete *)",
+      "Bash(gh gist create *)",
+      "Bash(gh gist edit *)",
+      "Bash(gh gist delete *)",
+      "Bash(gh gist rename *)",
+      "Bash(gh auth login *)",
+      "Bash(gh auth logout *)",
+      "Bash(gh auth setup-git *)",
+      "Bash(gh auth switch *)",
+      "Bash(gh alias set *)",
+      "Bash(gh alias import *)",
+      "Bash(gh alias delete *)",
+      "Bash(gh extension install *)",
+      "Bash(gh extension remove *)",
+      "Bash(gh extension upgrade *)",
+      "Bash(gh extension exec *)",
+      "Bash(gh ssh-key add *)",
+      "Bash(gh ssh-key delete *)",
+      "Bash(gh gpg-key add *)",
+      "Bash(gh gpg-key delete *)",
+      "Bash(gh config set *)",
+      "Bash(gh project create *)",
+      "Bash(gh project edit *)",
+      "Bash(gh project delete *)",
+      "Bash(gh project close *)",
+      "Bash(gh project copy *)",
+      "Bash(gh project link *)",
+      "Bash(gh project unlink *)",
+      "Bash(gh project mark-template *)",
+      "Bash(gh project field-create *)",
+      "Bash(gh project field-delete *)",
+      "Bash(gh project item-add *)",
+      "Bash(gh project item-create *)",
+      "Bash(gh project item-delete *)",
+      "Bash(gh project item-edit *)",
+      "Bash(gh project item-archive *)",
+      "Bash(gh codespace *)"
     ]
   },
   "extraKnownMarketplaces": {
diff --git a/docs/mode-economics.md b/docs/mode-economics.md
index b869e075..884f4737 100644
--- a/docs/mode-economics.md
+++ b/docs/mode-economics.md
@@ -92,7 +92,7 @@ special-token spellings counted as ordinary text.
 Coverage: **75 of 75 local `skills/*/SKILL.md` files**.
 External `source.md` redirects and harness symlinks are excluded.
 
-Measurement manifest SHA-256: `6d676fa634fe74da4f7e62f763fdd3da747784b498460b0905f8531665762384`.
+Measurement manifest SHA-256: `e926a36aa5b1f577519a5872a99115a76cdb4faaf448acf48dd06d5b62ccdc20`.
 
 | Skill file | Measured tokens | Source SHA-256 (first 16 characters) |
 |---|---:|---|
@@ -159,10 +159,10 @@ Measurement manifest SHA-256: `6d676fa634fe74da4f7e62f763fdd3da747784b498460b090
 | [security-model-verify](../skills/security-model-verify/SKILL.md) | 6,625 | `cde155672857b33c` |
 | [security-tracker-stats-dashboard](../skills/security-tracker-stats-dashboard/SKILL.md) | 4,897 | `b52154deb8557ba4` |
 | [setup](../skills/setup/SKILL.md) | 8,724 | `82788542bb240309` |
-| [setup-isolated-setup-doctor](../skills/setup-isolated-setup-doctor/SKILL.md) | 5,582 | `127bbe476b669702` |
+| [setup-isolated-setup-doctor](../skills/setup-isolated-setup-doctor/SKILL.md) | 5,773 | `f678a595dbd9c16a` |
 | [setup-isolated-setup-install](../skills/setup-isolated-setup-install/SKILL.md) | 8,070 | `7b9029958dc7ac79` |
 | [setup-isolated-setup-update](../skills/setup-isolated-setup-update/SKILL.md) | 4,651 | `f82e55f10ff7aaae` |
-| [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 5,767 | `e6cc1e7facb71d63` |
+| [setup-isolated-setup-verify](../skills/setup-isolated-setup-verify/SKILL.md) | 5,948 | `fb737cbe73adcd0a` |
 | [setup-override-upstream](../skills/setup-override-upstream/SKILL.md) | 4,012 | `fb583feb56b7f77c` |
 | [setup-privacy-llm](../skills/setup-privacy-llm/SKILL.md) | 2,145 | `0e27b542a1656846` |
 | [setup-shared-config-sync](../skills/setup-shared-config-sync/SKILL.md) | 4,357 | `d1dfcd7cdeb5f5a6` |
diff --git a/docs/rfcs/RFC-AI-0002.md b/docs/rfcs/RFC-AI-0002.md
index 26957d1d..5be39aca 100644
--- a/docs/rfcs/RFC-AI-0002.md
+++ b/docs/rfcs/RFC-AI-0002.md
@@ -201,8 +201,7 @@ The reference implementation's project-scope `.claude/settings.json`, annotated
       "Bash(gh issue create *)", "Bash(gh issue edit *)",
       "Bash(gh issue close *)", "Bash(gh issue comment *)",
       "Bash(gh release create *)",
-      "Bash(gh api * -X *)",                     // any non-default-method API call
-      "Bash(gh api * -f *)", "Bash(gh api * -F *)"  // any payload-bearing API call
+      "Bash(gh api *)"                           // GET and POST look the same to a prefix rule, so the whole thing asks; vetted-ops carries the bounded reads
     ]
   }
 }
@@ -216,12 +215,19 @@ The `permissions.ask` block above intercepts every write-side action whose eff
 
 #### Layer 3a — Bounded operations (the `vetted-ops` dispatcher)
 
-Layer 3's weakness is not its rules, it is its **volume**. A wildcard like
-`Bash(gh *)` is doing real work — every unknown `gh` subcommand prompts — but it
-prompts for `gh issue view` as loudly as for `gh issue close`. On a sweep across
-thirty trackers that is a hundred prompts, and the hundredth gets the attention
-the first deserved. Prompt fatigue is not a usability complaint here; it is the
-mechanism by which Layer 3 stops working.
+Layer 3's weakness is not its rules, it is its **volume**. The reference
+config once carried a wildcard `Bash(gh *)` in `ask`, on the assumption that a
+more specific read-only `allow` rule would exempt `gh issue view`. It does not:
+Claude Code evaluates deny, then ask, then allow, and a matching ask rule
+prompts even when a more specific allow rule also matches, so the wildcard
+prompted for `gh issue view` as loudly as for `gh issue close`. The reference
+now lists the write subcommands one by one, but the shape of the problem
+survives wherever a prefix rule cannot tell a read from a write — `gh api`
+above all, where a GET and a POST look identical, so the whole command stays on
+`ask` and every read through it prompts. On a sweep across thirty trackers that
+is a hundred prompts, and the hundredth gets the attention the first deserved.
+Prompt fatigue is not a usability complaint here; it is the mechanism by which
+Layer 3 stops working.
 
 [`tools/vetted-ops`](https://github.com/apache/magpie/blob/main/tools/vetted-ops)
 narrows the surface so read traffic can leave the prompt stream without the
diff --git a/docs/setup/sandbox-troubleshooting.md b/docs/setup/sandbox-troubleshooting.md
index ece85b7c..43bf230f 100644
--- a/docs/setup/sandbox-troubleshooting.md
+++ b/docs/setup/sandbox-troubleshooting.md
@@ -822,6 +822,15 @@ Two parts:
 - Do not work around this by dumping the token (`gh auth token`) into
   `GH_TOKEN`; the framework reference keeps that command in
   `permissions.deny` on purpose.
+- A different symptom with a similar smell — the excluded `gh` *works*
+  but *prompts* on every call, `gh pr view` included — is a
+  permissions problem, not a sandbox one: a catch-all `Bash(gh *)` in
+  `permissions.ask` (any scope; ask rules merge from every settings
+  file). Claude Code evaluates deny, then ask, then allow, and a
+  matching ask rule prompts even when a more specific allow rule also
+  matches. Replace the catch-all with the explicit write-subcommand
+  list from the reference `.claude/settings.json`; the verify skill's
+  check 11b fails on it and the doctor's gh probe warns.
 - Linux / bubblewrap is not measured here. Go uses its own root store
   on Linux, so the TLS half does not apply; the keyring half depends
   on which credential helper `gh` is configured with.
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index a696a7d7..baf602b5 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -620,7 +620,108 @@ below, annotated.
     "ask": [
       "Bash(git push *)",                        // including --force / --force-with-lease variants
       "Bash(uv run --project ~/.claude/plugins/cache/apache-magpie/magpie-vetted-ops/*/tools/vetted-ops vetted-op *)",  // the vetted-ops WRITE dispatcher: bounded in shape, but still a remote mutation, so it keeps a confirmation
-      "Bash(gh *)"                               // safe-by-default: EVERY gh command prompts unless it matches a more-specific read-only allow rule above. Guarantees every destructive / unknown gh subcommand (gh pr close, gh run delete, gh label delete, gh repo archive, gh variable set, gh project item-delete, …) is confirmed. `gh auth token`/`refresh` are denied above (deny > ask).
+      // gh WRITE subcommands, listed one by one. Claude Code evaluates deny,
+      // then ask, then allow, and "a matching ask rule prompts even when a
+      // more specific allow rule also matches the same call" — so a catch-all
+      // `Bash(gh *)` here would force a prompt on `gh pr view` exactly as on
+      // `gh pr merge`, and the read-only allow rules above would never fire.
+      // A gh subcommand that appears in neither list falls through to the
+      // mode's default (a prompt in default mode, the classifier in auto).
+      // `gh auth token` / `refresh` are denied above (deny > ask).
+      "Bash(gh api *)",                          // GET and POST look the same to a pattern; keep the whole thing on ask (vetted-ops carries the bounded reads)
+      "Bash(gh pr create *)",
+      "Bash(gh pr comment *)",
+      "Bash(gh pr review *)",
+      "Bash(gh pr merge *)",
+      "Bash(gh pr close *)",
+      "Bash(gh pr reopen *)",
+      "Bash(gh pr edit *)",
+      "Bash(gh pr ready *)",
+      "Bash(gh pr lock *)",
+      "Bash(gh pr unlock *)",
+      "Bash(gh pr revert *)",
+      "Bash(gh pr update-branch *)",
+      "Bash(gh issue create *)",
+      "Bash(gh issue comment *)",
+      "Bash(gh issue close *)",
+      "Bash(gh issue reopen *)",
+      "Bash(gh issue edit *)",
+      "Bash(gh issue delete *)",
+      "Bash(gh issue lock *)",
+      "Bash(gh issue unlock *)",
+      "Bash(gh issue pin *)",
+      "Bash(gh issue unpin *)",
+      "Bash(gh issue transfer *)",
+      "Bash(gh issue develop *)",
+      "Bash(gh release create *)",
+      "Bash(gh release upload *)",
+      "Bash(gh release delete *)",
+      "Bash(gh release delete-asset *)",
+      "Bash(gh release edit *)",
+      "Bash(gh workflow run *)",
+      "Bash(gh workflow enable *)",
+      "Bash(gh workflow disable *)",
+      "Bash(gh run rerun *)",
+      "Bash(gh run cancel *)",
+      "Bash(gh run delete *)",
+      "Bash(gh repo create *)",
+      "Bash(gh repo delete *)",
+      "Bash(gh repo edit *)",
+      "Bash(gh repo fork *)",
+      "Bash(gh repo sync *)",
+      "Bash(gh repo archive *)",
+      "Bash(gh repo unarchive *)",
+      "Bash(gh repo rename *)",
+      "Bash(gh repo set-default *)",
+      "Bash(gh repo deploy-key add *)",
+      "Bash(gh repo deploy-key delete *)",
+      "Bash(gh repo autolink create *)",
+      "Bash(gh repo autolink delete *)",
+      "Bash(gh label create *)",
+      "Bash(gh label delete *)",
+      "Bash(gh label edit *)",
+      "Bash(gh label clone *)",
+      "Bash(gh cache delete *)",
+      "Bash(gh secret set *)",
+      "Bash(gh secret delete *)",
+      "Bash(gh variable set *)",
+      "Bash(gh variable delete *)",
+      "Bash(gh gist create *)",
+      "Bash(gh gist edit *)",
+      "Bash(gh gist delete *)",
+      "Bash(gh gist rename *)",
+      "Bash(gh auth login *)",
+      "Bash(gh auth logout *)",
+      "Bash(gh auth setup-git *)",
+      "Bash(gh auth switch *)",
+      "Bash(gh alias set *)",
+      "Bash(gh alias import *)",
+      "Bash(gh alias delete *)",
+      "Bash(gh extension install *)",
+      "Bash(gh extension remove *)",
+      "Bash(gh extension upgrade *)",
+      "Bash(gh extension exec *)",
+      "Bash(gh ssh-key add *)",
+      "Bash(gh ssh-key delete *)",
+      "Bash(gh gpg-key add *)",
+      "Bash(gh gpg-key delete *)",
+      "Bash(gh config set *)",
+      "Bash(gh project create *)",
+      "Bash(gh project edit *)",
+      "Bash(gh project delete *)",
+      "Bash(gh project close *)",
+      "Bash(gh project copy *)",
+      "Bash(gh project link *)",
+      "Bash(gh project unlink *)",
+      "Bash(gh project mark-template *)",
+      "Bash(gh project field-create *)",
+      "Bash(gh project field-delete *)",
+      "Bash(gh project item-add *)",
+      "Bash(gh project item-create *)",
+      "Bash(gh project item-delete *)",
+      "Bash(gh project item-edit *)",
+      "Bash(gh project item-archive *)",
+      "Bash(gh codespace *)"
     ]
   }
 }
@@ -2491,7 +2592,11 @@ below and report ✓ done / ✗ missing / ⚠ partial, with the evidence
    the command to run myself: it cannot see the display from
    inside the sandbox.
 10. `sandbox.excludedCommands` contains `"gh *"` (project or
-    user scope). Note, without failing, that the exclusion only
+    user scope), and `permissions.ask` lists the gh write
+    subcommands one by one — a catch-all `Bash(gh *)` in `ask`
+    (any scope) is ✗: ask beats allow regardless of specificity,
+    so it forces a prompt on every read-only gh call the allow
+    rules were meant to exempt. Note, without failing, that the exclusion only
     applies when every part of a Bash invocation is `cd …` or
     `gh …` — a pipe, `$(…)`, a loop, or any file redirection puts
     `gh` back in the sandbox (anthropics/claude-code#95532; see
diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md
index 9bac5c7d..7a78c700 100644
--- a/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md
+++ b/plugins/magpie-setup/skills/isolated-setup-doctor/SKILL.md
@@ -378,6 +378,13 @@ else
       *) echo "PROBE: gh-sandbox → ⚠ (gh failed for another reason, rc=$rc: $(echo "$out" | head -1))" ;;
     esac
   fi
+  # A catch-all ask rule prompts on every gh call, reads included:
+  # Claude Code evaluates deny, then ask, then allow, regardless of
+  # how specific the allow rules are.
+  if cat .claude/settings.json .claude/settings.local.json ~/.claude/settings.json 2>/dev/null \
+      | grep -q '"Bash(gh \*)"'; then
+    echo "PROBE: gh-sandbox → ⚠ (catch-all \"Bash(gh *)\" in permissions.ask — every gh call prompts, read-only allow rules never fire)"
+  fi
 fi
 ```
 
@@ -389,6 +396,7 @@ fi
 | `✓ … "gh *" is in excludedCommands` | Pass | The known macOS shape, and the framework's exclusion is present. Calls still fail if they are not `cd`/`gh`-only invocations — see the catalog entry. |
 | `✗ … NOT found in excludedCommands` | Fail | `gh` cannot work inside the sandbox on this machine and nothing runs it outside. |
 | `⚠ gh failed for another reason` | Warn | Not the catalogued shape (network down, not logged in, …); inspect the message. |
+| `⚠ catch-all "Bash(gh *)" in permissions.ask` | Warn | Ask beats allow regardless of specificity, so this rule prompts on every read-only `gh` call. Replace it with the explicit write-subcommand list from the reference `.claude/settings.json`. Extra line, printed after the main result. |
 | `⊘ gh not on PATH` | Skip | `gh` not installed; not a sandbox restriction. |
 
 `~/.claude/settings.json` is usually unreadable from inside the
diff --git a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
index 33ed5eea..708ec362 100644
--- a/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
+++ b/plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
@@ -380,6 +380,16 @@ Walk each in order:
     Missing on macOS is ✗; missing on Linux is ⚠ (a sandboxed `gh`
     may work there, but the reference config expects the exclusion).
 
+    **11b — no catch-all `gh` ask rule.** `permissions.ask` (project,
+    local and user scope alike — ask rules merge from every source)
+    must not contain `Bash(gh *)`. Claude Code evaluates deny, then
+    ask, then allow, and "a matching ask rule prompts even when a more
+    specific allow rule also matches", so the catch-all forces a prompt
+    on every read-only `gh` call that the `allow` rules were meant to
+    exempt — the reference config lists the write subcommands one by
+    one instead. A catch-all in any scope is ✗, and the report should
+    say which file carries it.
+
     Report as a **note**, not a failure: the exclusion applies only
     when every part of a Bash invocation is `cd …` or `gh …`. A pipe,
     a `$(…)` substitution, a loop, or any file redirection (even
@@ -424,10 +434,12 @@ without invoking it:
 - ✗ on check 10c → the one-file `allowRead` widening in the
   troubleshooting entry, applied by the user — never from this
   skill — then re-verify.
-- ✗ on check 11 (`"gh *"` missing from `sandbox.excludedCommands`)
-  → the operator adds it themselves (settings.json widenings are
-  never applied from a skill), following the catalog entry linked
-  in the check; then re-run `setup-isolated-setup-verify`.
+- ✗ on check 11 (`"gh *"` missing from `sandbox.excludedCommands`,
+  or a catch-all `Bash(gh *)` in `permissions.ask`) → the operator
+  edits settings themselves (settings.json changes are never applied
+  from a skill): add the exclusion, or replace the catch-all with the
+  explicit write-subcommand list from the reference
+  `.claude/settings.json`; then re-run `setup-isolated-setup-verify`.
 - The user-scope script copies live under `~/.claude-config/`
   for users who maintain that sync repo; uncommitted local edits
   there → `setup-shared-config-sync`.
diff --git a/tools/sandbox-lint/expected.json b/tools/sandbox-lint/expected.json
index 89962227..6e621752 100644
--- a/tools/sandbox-lint/expected.json
+++ b/tools/sandbox-lint/expected.json
@@ -137,7 +137,100 @@
       "Bash(git push *)",
       "Bash(git push --force *)",
       "Bash(git push --force-with-lease *)",
-      "Bash(gh *)"
+      "Bash(gh api *)",
+      "Bash(gh pr create *)",
+      "Bash(gh pr comment *)",
+      "Bash(gh pr review *)",
+      "Bash(gh pr merge *)",
+      "Bash(gh pr close *)",
+      "Bash(gh pr reopen *)",
+      "Bash(gh pr edit *)",
+      "Bash(gh pr ready *)",
+      "Bash(gh pr lock *)",
+      "Bash(gh pr unlock *)",
+      "Bash(gh pr revert *)",
+      "Bash(gh pr update-branch *)",
+      "Bash(gh issue create *)",
+      "Bash(gh issue comment *)",
+      "Bash(gh issue close *)",
+      "Bash(gh issue reopen *)",
+      "Bash(gh issue edit *)",
+      "Bash(gh issue delete *)",
+      "Bash(gh issue lock *)",
+      "Bash(gh issue unlock *)",
+      "Bash(gh issue pin *)",
+      "Bash(gh issue unpin *)",
+      "Bash(gh issue transfer *)",
+      "Bash(gh issue develop *)",
+      "Bash(gh release create *)",
+      "Bash(gh release upload *)",
+      "Bash(gh release delete *)",
+      "Bash(gh release delete-asset *)",
+      "Bash(gh release edit *)",
+      "Bash(gh workflow run *)",
+      "Bash(gh workflow enable *)",
+      "Bash(gh workflow disable *)",
+      "Bash(gh run rerun *)",
+      "Bash(gh run cancel *)",
+      "Bash(gh run delete *)",
+      "Bash(gh repo create *)",
+      "Bash(gh repo delete *)",
+      "Bash(gh repo edit *)",
+      "Bash(gh repo fork *)",
+      "Bash(gh repo sync *)",
+      "Bash(gh repo archive *)",
+      "Bash(gh repo unarchive *)",
+      "Bash(gh repo rename *)",
+      "Bash(gh repo set-default *)",
+      "Bash(gh repo deploy-key add *)",
+      "Bash(gh repo deploy-key delete *)",
+      "Bash(gh repo autolink create *)",
+      "Bash(gh repo autolink delete *)",
+      "Bash(gh label create *)",
+      "Bash(gh label delete *)",
+      "Bash(gh label edit *)",
+      "Bash(gh label clone *)",
+      "Bash(gh cache delete *)",
+      "Bash(gh secret set *)",
+      "Bash(gh secret delete *)",
+      "Bash(gh variable set *)",
+      "Bash(gh variable delete *)",
+      "Bash(gh gist create *)",
+      "Bash(gh gist edit *)",
+      "Bash(gh gist delete *)",
+      "Bash(gh gist rename *)",
+      "Bash(gh auth login *)",
+      "Bash(gh auth logout *)",
+      "Bash(gh auth setup-git *)",
+      "Bash(gh auth switch *)",
+      "Bash(gh alias set *)",
+      "Bash(gh alias import *)",
+      "Bash(gh alias delete *)",
+      "Bash(gh extension install *)",
+      "Bash(gh extension remove *)",
+      "Bash(gh extension upgrade *)",
+      "Bash(gh extension exec *)",
+      "Bash(gh ssh-key add *)",
+      "Bash(gh ssh-key delete *)",
+      "Bash(gh gpg-key add *)",
+      "Bash(gh gpg-key delete *)",
+      "Bash(gh config set *)",
+      "Bash(gh project create *)",
+      "Bash(gh project edit *)",
+      "Bash(gh project delete *)",
+      "Bash(gh project close *)",
+      "Bash(gh project copy *)",
+      "Bash(gh project link *)",
+      "Bash(gh project unlink *)",
+      "Bash(gh project mark-template *)",
+      "Bash(gh project field-create *)",
+      "Bash(gh project field-delete *)",
+      "Bash(gh project item-add *)",
+      "Bash(gh project item-create *)",
+      "Bash(gh project item-delete *)",
+      "Bash(gh project item-edit *)",
+      "Bash(gh project item-archive *)",
+      "Bash(gh codespace *)"
     ]
   },
   "extraKnownMarketplaces": {
diff --git a/tools/sandbox-lint/src/sandbox_lint/__init__.py b/tools/sandbox-lint/src/sandbox_lint/__init__.py
index a095a9b7..ba05b037 100644
--- a/tools/sandbox-lint/src/sandbox_lint/__init__.py
+++ b/tools/sandbox-lint/src/sandbox_lint/__init__.py
@@ -241,6 +241,18 @@ def check_invariants(settings: dict[str, Any]) -> list[str]:
         for required in REQUIRED_PERMISSIONS_DENY:
             if required not in deny:
                 errors.append(f"permissions.deny: must contain {required!r}")
+        # Claude Code evaluates deny, then ask, then allow, and a matching ask
+        # rule prompts even when a more specific allow rule also matches. A
+        # catch-all gh ask rule therefore prompts on every read-only gh call
+        # and silently defeats the read-only allow list; writes are listed
+        # subcommand by subcommand instead.
+        ask = set(perms.get("ask", []) or [])
+        if "Bash(gh *)" in ask:
+            errors.append(
+                "permissions.ask: must not contain 'Bash(gh *)' (ask beats allow "
+                "regardless of specificity, so it prompts on every read-only gh call; "
+                "list the gh write subcommands one by one)"
+            )
 
     return errors
 
diff --git a/tools/sandbox-lint/tests/test_validator.py b/tools/sandbox-lint/tests/test_validator.py
index 497d95bd..373f3e56 100644
--- a/tools/sandbox-lint/tests/test_validator.py
+++ b/tools/sandbox-lint/tests/test_validator.py
@@ -76,13 +76,31 @@ def test_baseline_excludes_gh_from_sandbox(baseline: dict[str, Any]) -> None:
     assert "gh *" in baseline["sandbox"].get("excludedCommands", [])
 
 
-def test_baseline_asks_on_all_gh_by_default(baseline: dict[str, Any]) -> None:
-    # Safe-by-default: every gh command prompts unless a more-specific
-    # read-only allow rule exempts it, so destructive/unknown gh always asks.
-    assert "Bash(gh *)" in baseline["permissions"]["ask"]
+def test_baseline_asks_on_gh_writes_not_on_reads(baseline: dict[str, Any]) -> None:
+    # Claude Code evaluates deny, then ask, then allow, and "a matching ask
+    # rule prompts even when a more specific allow rule also matches", so a
+    # catch-all `Bash(gh *)` in ask would silently defeat every read-only
+    # allow below it. The write subcommands are listed one by one instead.
+    ask = baseline["permissions"]["ask"]
+    assert "Bash(gh *)" not in ask
+    for rule in (
+        "Bash(gh api *)",
+        "Bash(gh pr merge *)",
+        "Bash(gh issue close *)",
+        "Bash(gh release delete *)",
+        "Bash(gh repo delete *)",
+    ):
+        assert rule in ask, rule
     assert "Bash(gh pr view *)" in baseline["permissions"]["allow"]
 
 
+def test_catch_all_gh_ask_rule_is_an_invariant_error(baseline: dict[str, Any]) -> None:
+    weakened = copy.deepcopy(baseline)
+    weakened["permissions"]["ask"].append("Bash(gh *)")
+    errors = check_invariants(weakened)
+    assert any("Bash(gh *)" in e for e in errors), errors
+
+
 def test_main_exits_zero_on_repo(monkeypatch: pytest.MonkeyPatch) -> None:
     monkeypatch.chdir(REPO_ROOT)
     assert main([]) == 0
diff --git a/tools/spec-loop/.last-sync b/tools/spec-loop/.last-sync
index 110ee8ab..062cee97 100644
--- a/tools/spec-loop/.last-sync
+++ b/tools/spec-loop/.last-sync
@@ -1 +1 @@
-f2c549c20fcf259e0e680a2269b2561d6da7243f
+b7b27cab0b1b62e01a2b33807a2845cea5b41bc7
diff --git a/tools/spec-loop/specs/agent-isolation-sandbox.md b/tools/spec-loop/specs/agent-isolation-sandbox.md
index 8ff28cbb..6f4baad9 100644
--- a/tools/spec-loop/specs/agent-isolation-sandbox.md
+++ b/tools/spec-loop/specs/agent-isolation-sandbox.md
@@ -17,9 +17,9 @@ acceptance:
     filesystem reads and network egress; runtime-specific exceptions
     are documented in the adapter and this spec.
   - Credential-shaped env vars are stripped before the agent execs.
-  - State-mutating shell calls (git push, and every gh command except
-    allow-listed read-only ones) require a confirmation prompt;
-    secrets/cred files are deny-read.
+  - State-mutating shell calls (git push and every gh write subcommand,
+    each listed explicitly) require a confirmation prompt; read-only gh
+    subcommands do not; secrets/cred files are deny-read.
 ---
 
 # Agent isolation / layered sandbox
@@ -105,9 +105,14 @@ The reference model is four layers, layered:
 3. **Tool permissions** — the host's `permissions.deny` blocks denied
    paths/binaries (`Read(~/.ssh/**)`, `Bash(curl *)`, …).
 4. **Forced confirmation** — `permissions.ask` on `git push` and,
-   safe-by-default, on `Bash(gh *)`: every `gh` command prompts unless a
-   more-specific read-only `allow` rule (`gh pr view`, `gh * list`, …)
-   exempts it, so every destructive or unknown `gh` subcommand confirms.
+   on every `gh` **write** subcommand, listed one by one (`gh pr merge`,
+   `gh issue close`, `gh release delete`, `gh api`, …). Not on a
+   catch-all `Bash(gh *)`: Claude Code evaluates deny, then ask, then
+   allow, and a matching ask rule prompts even when a more specific
+   allow rule also matches, so a catch-all would silently defeat the
+   read-only allows (`gh pr view`, `gh * list`, …) and prompt on every
+   read. A subcommand in neither list falls through to the mode's
+   default (prompt in default mode, classifier in auto).
 
 Pinned system tools (`bubblewrap`, `socat`, agent CLI) are aged through a
 cooldown window; bumps are PRs, not silent updates.
@@ -123,8 +128,9 @@ cooldown window; bumps are PRs, not silent updates.
 1. Filesystem and network default-deny with explicit allow-lists in the
    reference setup; Gemini's different boundaries are documented above.
 2. The clean-env wrapper strips credential-shaped vars before exec.
-3. `git push` and `Bash(gh *)` are in `permissions.ask` (read-only `gh`
-   exempted via `allow`); secret/cred files are in `permissions.deny`.
+3. `git push` and every `gh` write subcommand are in `permissions.ask`,
+   the read-only `gh` subcommands are in `allow`, and no catch-all
+   `Bash(gh *)` sits in `ask`; secret/cred files are in `permissions.deny`.
 
 ## Validation
 
@@ -134,8 +140,9 @@ uv run --directory tools/agent-guard --group dev pytest
 uv run --project tools/permission-audit --group dev pytest
 uv run --project tools/egress-gateway --group dev pytest
 python3 -c "import json,sys; s=json.load(open('.claude/settings.json')); \
-  asks=' '.join(s['permissions']['ask']); \
-  sys.exit(0 if 'git push' in asks and 'gh *' in asks else 1)"
+  ask=s['permissions']['ask']; \
+  sys.exit(0 if any(a.startswith('Bash(git push') for a in ask) \
+    and 'Bash(gh pr merge *)' in ask and 'Bash(gh *)' not in ask else 1)"
 ```
 
 ## Known gaps
diff --git a/tools/spec-loop/specs/sandbox-diagnostics.md b/tools/spec-loop/specs/sandbox-diagnostics.md
index 4f93db1e..c90b6950 100644
--- a/tools/spec-loop/specs/sandbox-diagnostics.md
+++ b/tools/spec-loop/specs/sandbox-diagnostics.md
@@ -109,6 +109,13 @@ error, and two skills that probe or verify the setup on demand.
   inside `gh` (guarded to `$PWD` and the Claude scratch tree, because
   the alias runs unsandboxed), and the upstream report
   anthropics/claude-code#95532 for the redirection regression.
+- **The `gh` ask-rule rule.** `permissions.ask` names gh write
+  subcommands one by one and never a catch-all `Bash(gh *)`: Claude
+  Code evaluates deny, then ask, then allow, and a matching ask rule
+  prompts even when a more specific allow rule also matches, so the
+  catch-all would turn every read-only gh call into a prompt. The verify
+  skill fails on a catch-all in any scope; the doctor's gh probe warns
+  on it.
 - **User-scope install.** The hook is meant for `~/.claude/settings.json`
   so it fires in every project on the host; the framework's own
   `.claude/settings.json` does not wire it.