[BUG] macOS sandbox: System V shared-memory and sysctl calls are refused with EPERM whenever two sandboxed Bash commands run at the same time — and a refused shmget/shmctl still leaves the segment allocated
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
A single sandboxed Bash command can call shmget, shmctl(IPC_RMID) and run ipcs -mo (which uses sysctlbyname) as often as it likes: 40 calls, 0 refusals.
The moment a second sandboxed Bash command is running at the same time — a run_in_background Bash call, a subagent's Bash call, or another Claude Code session on the same machine — the same calls are refused with EPERM ("Operation not permitted") in both commands, for the whole time they overlap. Reproduced three times: 40/40 allowed
alone, 0/40 during overlap.
Two consequences make this worse than a refusal:
- A refused create still allocates the segment. PostgreSQL's
initdbfailed withFATAL: could not create shared memory segment: Operation not permitted/DETAIL: Failed system call was shmget(key=12353002, size=56, 03600)— and afterwardsipcs -moshowed a segment with key0x00bc7dea(= 12353002), NATTCH 0, that nothing had created except that refused call. Two refused starts left two orphans with exactly the keys named in the two FATAL lines. - A refused remove leaves the segment behind. A loop that created a private segment and removed it 40 times, run in two shells at once, left 29 orphan segments (every
shmctl(IPC_RMID)was refused), after which everyshmgeton the machine returnedENOSPCand no PostgreSQL could start — the user's own live server included, until a person removed the orphans from a normal terminal.
So on a machine that runs PostgreSQL-backed tests from more than one agent, the sandbox silently exhausts a scarce kernel resource (32 segments for the whole machine) within minutes.
What Should Happen?
The sandbox's decision for a system call should not depend on whether another sandboxed command happens to be running.
Either the calls are allowed (the profile does not mention System V IPC) or they are denied consistently — and a deniedshmget should not leave an allocated segment.
Error Messages/Logs
Steps to Reproduce
Minimal reproduction
shm_loop.py — creates and removes a private segment 40 times, 0.3 s apart, and reports refusals:
import ctypes, ctypes.util, os, errno, time, subprocess
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
ok = refused = 0
for i in range(40):
shmid = libc.shmget(0, 4096, 0o1000 | 0o600) # IPC_PRIVATE, IPC_CREAT | 0600
if shmid < 0:
e = ctypes.get_errno(); refused += 1
ipcs = subprocess.run(["/usr/bin/ipcs", "-mo"], capture_output=True, text=True).returncode
print(f"{time.strftime('%H:%M:%S')} shmget REFUSED {errno.errorcode.get(e)} ipcs_exit={ipcs}")
else:
if libc.shmctl(shmid, 0, None) < 0: # IPC_RMID
e = ctypes.get_errno(); print(f"shmctl RMID REFUSED {errno.errorcode.get(e)} -- segment {shmid} ORPHANED")
ok += 1
time.sleep(0.3)
print(f"ok={ok} refused={refused}")
ipcs_loop.sh — the listing only, no segment is ever created, so this one cannot leak:
#!/bin/zsh
ok=0; refused=0
for i in {1..40}; do
if /usr/bin/ipcs -mo >/dev/null 2>&1; then ok=$((ok+1)); else refused=$((refused+1)); echo "$(date +%H:%M:%S) ipcs REFUSED"; fi
sleep 0.3
done
echo "ok=$ok refused=$refused"
Steps, inside Claude Code with the sandbox enabled:
- Run
python3 shm_loop.pyas one Bash tool call. Result:ok=40 refused=0. - Run
./ipcs_loop.shas a Bash tool call withrun_in_background: true, and in the same turn run./ipcs_loop.shas a foreground Bash tool call. Result in both:ok=0 refused=40, every lineipcs REFUSED. - (Leaks — clean up afterwards with
ipcrm -m <id>on the NATTCH-0 segments from a normal terminal.) Runpython3 shm_loop.pyin the background and again in the foreground at the same time. Result:shmctl RMID REFUSED EPERMon every created segment, thenshmget REFUSED ENOSPConce the 32 slots are full;ipcs -moafterwards lists ~29 segments with key0x00000000and NATTCH 0. - For the PostgreSQL shape: run
initdb -D /tmp/xinside a sandboxed Bash call while another sandboxed Bash call is running.initdbfails with the FATAL line above, andipcs -moafterwards shows a NATTCH-0 segment whose key is the one the FATAL line named.
Timestamps from the measured runs: refusals at 08:56:17–08:56:19 and 08:58:46–08:58:54 (concurrent), allowed at 08:55:57, 08:56:08, 08:56:35–08:56:41 (alone) and a further 09:10 solo run at 40/40 followed by a concurrent run at 0/40.
Claude Model
Other
Is this a regression?
No, this never worked
Last Working Version
_No response_
Claude Code Version
2.1.258
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
Workaround in use
sandbox.excludedCommands naming the test command ("*venv/bin/python -m pytest *"), which runs PostgreSQL-backed
tests outside the sandbox; plus a test fixture that records every segment it creates or is refused on, retries a refused
start, and removes its own segments by identifier with the removal's return value checked — because ipcs is refused in
the same window, a cleanup that depends on the listing cannot help.
Notes for the maintainers
ipcsfailing looks exactly like an empty machine: it prints its header row and an empty table and exits 1 with
ipcs: sysctlbyname: Operation not permitted on stderr. Tooling that reads a failed listing as "no segments" will make
wrong decisions.
- The refusal windows were not always short. In one session, running one command at a time,
ipcsand single-segment
scripts were refused for about 17 minutes with brief gaps while a second Claude Code window was open on the same machine.
Environment
| | |
|---|---|
| Claude Model | Fable 5.1 |
| Claude Code | 2.1.258 |
| macOS | 26.5.2 (build 25F84), Darwin 25.5.0, arm64 |
| Sandbox settings (user) | "sandbox": {"allowUnsandboxedCommands": false, "strictAllowlist": true, "failIfUnavailable": true, "filesystem": {"disabled": true}} |
| Sandbox settings (project) | "sandbox": {"enabled": true, "network": {"deniedDomains": [...], "allowLocalBinding": true}, ...} |
| Affected program | PostgreSQL 18.4 (Homebrew) — initdb and postmaster need one System V segment each for the data-directory interlock |
| sysctl kern.sysv.shmmni | 32 (the machine-wide limit on segments) |
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗