sandbox: excludedCommands is skipped when the excluded command is inside a shell loop (gh fails TLS under Seatbelt)

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 1 comment · opened Jul 28, 2026

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 as gh, gcloud, and terraform may fail TLS verification under Seatbelt. List these tools in excludedCommands to 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:

  1. excludedCommands is 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
  2. the matching semantics and this limitation are documented, in sandbox.excludedCommands and 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
  • gh 2.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 — excludedCommands doesn't bypass sandbox (closed as not planned, stale)
  • #33429 — same for uv on 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.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗