sandbox: excludedCommands is skipped when the excluded command is inside a shell loop (gh fails TLS under Seatbelt)
Summary
sandbox.excludedCommands is matched against the whole Bash command string, so a command that is excluded when invoked bare becomes sandboxed as soon as it is wrapped in a shell compound construct such as a for loop. With "gh *" excluded, gh … runs outside the sandbox as intended, but for i in …; do gh …; done runs inside it.
This matters because the sandboxing docs recommend excludedCommands as the remedy for Go-based CLIs:
Go-based CLIs fail TLS verification on macOS: tools such asgh,gcloud, andterraformmay fail TLS verification under Seatbelt. List these tools inexcludedCommandsto run them outside the sandbox.
So the documented mitigation quietly stops applying in exactly the pattern the model generates most often for gh — a CI-status polling loop.
Reproduction
macOS, sandbox enabled, with gh * in sandbox.excludedCommands (delivered via managed settings in our case, but the source shouldn't matter).
Probe with a write to $HOME, which is denied inside the sandbox and allowed outside it:
# 1. bare invocation
gh --version > ~/sbprobe # → writes. Unsandboxed, exclusion applied.
# 2. preceded by a read-only command
echo pre; gh --version > ~/sbprobe # → writes. Still unsandboxed.
# 3. same command inside a for loop
for i in 1; do gh --version > ~/sbprobe; done # → (eval):1: operation not permitted: ~/sbprobe
# Sandboxed, despite `gh *` being excluded.
Case 3 is the bug: the only difference from case 1 is the surrounding for.
Why it shows up as a confusing TLS error
Once gh is sandboxed it hits the documented Go/Seatbelt TLS verification failure, but only on some calls, so it reads as flaky rather than as a sandbox issue. In the same loop:
gh pr view N --json number -q .number # succeeds (REST)
gh pr view N --json statusCheckRollup -q '…' # fails (GraphQL)
# Post "https://api.github.com/graphql": tls: failed to verify certificate: x509: OSStatus -26276
The real-world case that led me here was an ordinary CI poll, which failed on all 12 iterations before being retried unsandboxed:
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
s=$(gh pr view 293 --json statusCheckRollup \
-q '.statusCheckRollup[] | select(.status != "COMPLETED" or .conclusion != "CANCELLED") | "\(.name) \(.status) \(.conclusion)"' | tail -3)
echo "== $s"; echo "$s" | grep -q "COMPLETED" && break; sleep 30
done
Expected
One of:
excludedCommandsis evaluated per command within a compound invocation, so a loop or conditional wrapping only excluded (and read-only) commands stays unsandboxed — consistent with case 2, where a read-only prefix already doesn't void the exclusion; or- the matching semantics and this limitation are documented, in
sandbox.excludedCommandsand in the Go-CLI troubleshooting entry, so the recommended mitigation isn't silently incomplete.
Actual
The exclusion applies only when the whole command string matches the pattern. Any loop or conditional around the excluded command sandboxes the entire invocation, with no indication that the exclusion was skipped.
Environment
- Claude Code 2.1.220 (native install), CLI entrypoint
- macOS 15.7.7, Darwin 24.6.0, arm64
gh2.x, authenticated via keyring- Sandbox settings in effect:
enabled: true,autoAllowBashIfSandboxed: true,allowUnsandboxedCommands: true,excludedCommands: ["bk *", "docker *", "git *", "gh *", "linear *", "ps *"](managed settings)
Related
- #17821 —
excludedCommandsdoesn't bypass sandbox (closed as not planned, stale) - #33429 — same for
uvon macOS (closed as duplicate)
Both concern bare invocations; neither covers the compound-command case, and the probe in case 1 above shows bare exclusion does work on current versions.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗