Summary
In secure mode the bwrap invocation passes --cap-drop ALL, which empties the capability bounding set. That makes both of apply-seccomp's routes to CAP_SYS_ADMIN unavailable at once:
- path (a) — "the caller (bwrap) kept
CAP_SYS_ADMIN in this user namespace via --cap-add" — is never enabled, because no --cap-add is ever passed;
- path (b) — create a nested user namespace to obtain the capability — cannot grant any capability either, because a user namespace cannot grant what the bounding set no longer contains.
So apply-seccomp falls into path (b) and dies writing the new namespace's map:
apply-seccomp: write /proc/self/uid_map: Operation not permitted
This is the failure mode the code already anticipates at apply-seccomp.c:694-699 — "the unshare succeeds but the new namespace grants no capabilities … the caller must supply CAP_SYS_ADMIN" — but attributed there to an LSM gate. Here there is no LSM gate; the caller's own --cap-drop ALL produces it.
It surfaces when the runtime is already inside an unprivileged user namespace (rootless container, bwrap --unshare-user wrapper), where path (b)'s map write has no privileged fallback. Downstream report, where this removes the Bash tool entirely: anthropics/claude-code#89478. Claude Code 2.1.212 works; 2.1.220 through 2.1.236 fail.
The two paths, and why both are closed
All references pinned to e5fb1b9 (current main, v0.0.74).
vendor/seccomp-src/apply-seccomp.c:685-699:
/* ---- New PID + mount namespaces. Children (not us) enter the PID ns. ----
*
* Two paths to get CAP_SYS_ADMIN for the unshare:
* (a) The caller (bwrap) kept CAP_SYS_ADMIN in this user namespace via
* --cap-add. Just unshare directly.
* (b) We don't have the cap. Create a nested user namespace to get it,
* map uid/gid, then unshare. ...
src/sandbox/linux-sandbox-utils.ts:1946-1966:
if (!enableWeakerNestedSandbox) {
// ...
// apply-seccomp does not need caps here — it creates its own nested
// userns to obtain CAP_SYS_ADMIN for its PID+mount unshare (see below).
bwrapArgs.push('--unshare-user', '--cap-drop', 'ALL', '--proc', '/proc')
} else {
// ...
bwrapArgs.push('--unshare-user', '--bind', '/proc', '/proc')
}
--cap-add appears nowhere in the TypeScript — it occurs only in those two apply-seccomp comments (688-689, 834-836). Path (a) is therefore dead code as shipped, and the comment at 1953-1954 ("apply-seccomp does not need caps here") holds only where path (b) can still bootstrap a capability-bearing user namespace.
The measured consequence, inside an unprivileged user namespace on an unrestricted host:
--cap-drop ALL -> CapBnd: 0000000000000000 CapEff: 0000000000000000
and inside a freshly created nested userns: CapEff: 0000000000000000
With an empty bounding set the nested namespace confers nothing, so the /proc/self/{setgroups,uid_map,gid_map} writes at 737-746 fail and the process dies at 743.
Reproduction
Host: Pop!_OS 24.04 LTS, kernel 7.0.11-76070011-generic x86_64, bubblewrap 0.11.0. Unprivileged user namespaces unrestricted (user.max_user_namespaces=2147483647, no apparmor_restrict_unprivileged_userns), so the LSM caveat in the README does not apply.
Run all of this inside an unprivileged user namespace, e.g. under bwrap --unshare-user …, to model a rootless container:
BW="bwrap --ro-bind /usr /usr --ro-bind /etc /etc --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib64 /lib64 --dev /dev --unshare-pid --unshare-user"
# 1. Secure mode as shipped (line 1955). Bounding set empty; nested userns grants nothing.
$BW --proc /proc --cap-drop ALL -- sh -c '
grep -E "CapBnd|CapEff" /proc/self/status
unshare -U sh -c "grep CapEff /proc/self/status" # caps inside the path-(b) namespace
unshare --pid --mount --fork true' # path (a)
# CapBnd: 0000000000000000
# CapEff: 0000000000000000
# CapEff: 0000000000000000 <- nested userns confers no capability
# unshare: unshare() failed: Operation not permitted
# 2. Weaker mode (line 1965). Caps retained, path (a) succeeds outright.
$BW --bind /proc /proc -- sh -c 'grep CapBnd /proc/self/status; unshare --pid --mount --fork true && echo "path (a): OK"'
# CapBnd: 000001ffffffffff
# path (a): OK
# 3. Proposed fix. Everything dropped except the one capability apply-seccomp asks for.
$BW --proc /proc --cap-drop ALL --cap-add CAP_SYS_ADMIN -- sh -c 'grep CapEff /proc/self/status; unshare --pid --mount --fork true && echo "path (a): OK"'
# CapEff: 0000000000200000 <- CAP_SYS_ADMIN only
# path (a): OK
A note for anyone reproducing the exact die message with a shell rather than the real binary: a plain shell fails one step earlier, on the setgroups write at 737 with EACCES, because it lacks the PR_SET_DUMPABLE flip that apply-seccomp performs at 731-732. The real binary clears that hurdle and then dies on the uid_map write at 743, which is the message users report.
Version evidence
String search over the shipped Claude Code executables, which bundle this runtime:
| Claude Code |
--cap-drop |
--cap-add |
Bash inside a nested userns |
| 2.1.212 |
absent |
absent |
works |
| 2.1.231 |
present |
absent |
fails |
| 2.1.236 |
present |
absent |
fails |
The regression tracks the introduction of --cap-drop ALL, and no build has ever paired it with --cap-add.
Suggested fix
Pass the capability apply-seccomp documents that it needs, at line 1955:
bwrapArgs.push('--unshare-user', '--cap-drop', 'ALL', '--cap-add', 'CAP_SYS_ADMIN', '--proc', '/proc')
This restores path (a), so the nested-userns bootstrap is never attempted and the environment-dependent map write disappears from the common path. It also makes the runtime match its own documentation.
The workload's confinement is unchanged. apply-seccomp clears the ambient set at 837 once the mount is done, sets PR_SET_NO_NEW_PRIVS at 859, and execvps the command at 871 — exactly the sequence the comment at 834-836 describes — so the command begins with an empty effective set plus the seccomp filter.
Two things worth deciding explicitly rather than inheriting:
- Bounding set.
--cap-add CAP_SYS_ADMIN leaves CAP_SYS_ADMIN in the worker's bounding set. PR_SET_NO_NEW_PRIVS plus the absence of file capabilities on anything reachable means the workload cannot regain it, so this is defensible; adding PR_CAPBSET_DROP for the worker after the mount would close it outright and cost nothing.
- The remount concern the current code is guarding. The comment at 1948-1953 motivates
--cap-drop ALL by the risk of a root parent leaving the sandboxed command able to remount rw over --ro-bind / /. Granting one capability to apply-seccomp, which strips it before exec, does not reopen that: the shell/command past execvp still has nothing. The current arrangement pays for that guarantee by removing the capability apply-seccomp itself needs.
If granting the capability is unacceptable, the fallback options are strictly worse but still better than today:
- Detect the empty bounding set (or a failed path (b)) and emit the actionable message the
setgroups branch already carries at 738-740 — "nested userns is capability-restricted; caller must provide CAP_SYS_ADMIN" — for the uid_map/gid_map writes at 743 and 746 too, instead of a bare EPERM. Right now the one branch that explains the problem is the one branch users do not hit.
- Have the caller detect this case and fall back to the
enableWeakerNestedSandbox argument shape automatically, rather than requiring users to discover an undocumented setting after their sandbox stops working.
Cross-reference
Downstream: anthropics/claude-code#89478 (Bash tool refused since 2.1.220; 2.1.212 works). Filed here because the mechanism and the fix both live in this repo.
Summary
In secure mode the bwrap invocation passes
--cap-drop ALL, which empties the capability bounding set. That makes both ofapply-seccomp's routes toCAP_SYS_ADMINunavailable at once:CAP_SYS_ADMINin this user namespace via--cap-add" — is never enabled, because no--cap-addis ever passed;So
apply-seccompfalls into path (b) and dies writing the new namespace's map:This is the failure mode the code already anticipates at
apply-seccomp.c:694-699— "the unshare succeeds but the new namespace grants no capabilities … the caller must supply CAP_SYS_ADMIN" — but attributed there to an LSM gate. Here there is no LSM gate; the caller's own--cap-drop ALLproduces it.It surfaces when the runtime is already inside an unprivileged user namespace (rootless container,
bwrap --unshare-userwrapper), where path (b)'s map write has no privileged fallback. Downstream report, where this removes the Bash tool entirely: anthropics/claude-code#89478. Claude Code 2.1.212 works; 2.1.220 through 2.1.236 fail.The two paths, and why both are closed
All references pinned to
e5fb1b9(currentmain, v0.0.74).vendor/seccomp-src/apply-seccomp.c:685-699:src/sandbox/linux-sandbox-utils.ts:1946-1966:--cap-addappears nowhere in the TypeScript — it occurs only in those two apply-seccomp comments (688-689, 834-836). Path (a) is therefore dead code as shipped, and the comment at 1953-1954 ("apply-seccomp does not need caps here") holds only where path (b) can still bootstrap a capability-bearing user namespace.The measured consequence, inside an unprivileged user namespace on an unrestricted host:
With an empty bounding set the nested namespace confers nothing, so the
/proc/self/{setgroups,uid_map,gid_map}writes at 737-746 fail and the process dies at 743.Reproduction
Host: Pop!_OS 24.04 LTS, kernel
7.0.11-76070011-genericx86_64, bubblewrap 0.11.0. Unprivileged user namespaces unrestricted (user.max_user_namespaces=2147483647, noapparmor_restrict_unprivileged_userns), so the LSM caveat in the README does not apply.Run all of this inside an unprivileged user namespace, e.g. under
bwrap --unshare-user …, to model a rootless container:A note for anyone reproducing the exact die message with a shell rather than the real binary: a plain shell fails one step earlier, on the
setgroupswrite at 737 withEACCES, because it lacks thePR_SET_DUMPABLEflip thatapply-seccompperforms at 731-732. The real binary clears that hurdle and then dies on theuid_mapwrite at 743, which is the message users report.Version evidence
String search over the shipped Claude Code executables, which bundle this runtime:
--cap-drop--cap-addThe regression tracks the introduction of
--cap-drop ALL, and no build has ever paired it with--cap-add.Suggested fix
Pass the capability apply-seccomp documents that it needs, at line 1955:
This restores path (a), so the nested-userns bootstrap is never attempted and the environment-dependent map write disappears from the common path. It also makes the runtime match its own documentation.
The workload's confinement is unchanged.
apply-seccompclears the ambient set at 837 once the mount is done, setsPR_SET_NO_NEW_PRIVSat 859, andexecvps the command at 871 — exactly the sequence the comment at 834-836 describes — so the command begins with an empty effective set plus the seccomp filter.Two things worth deciding explicitly rather than inheriting:
--cap-add CAP_SYS_ADMINleavesCAP_SYS_ADMINin the worker's bounding set.PR_SET_NO_NEW_PRIVSplus the absence of file capabilities on anything reachable means the workload cannot regain it, so this is defensible; addingPR_CAPBSET_DROPfor the worker after the mount would close it outright and cost nothing.--cap-drop ALLby the risk of a root parent leaving the sandboxed command able to remountrwover--ro-bind / /. Granting one capability toapply-seccomp, which strips it before exec, does not reopen that: the shell/command pastexecvpstill has nothing. The current arrangement pays for that guarantee by removing the capability apply-seccomp itself needs.If granting the capability is unacceptable, the fallback options are strictly worse but still better than today:
setgroupsbranch already carries at 738-740 — "nested userns is capability-restricted; caller must provide CAP_SYS_ADMIN" — for theuid_map/gid_mapwrites at 743 and 746 too, instead of a bareEPERM. Right now the one branch that explains the problem is the one branch users do not hit.enableWeakerNestedSandboxargument shape automatically, rather than requiring users to discover an undocumented setting after their sandbox stops working.Cross-reference
Downstream: anthropics/claude-code#89478 (Bash tool refused since 2.1.220; 2.1.212 works). Filed here because the mechanism and the fix both live in this repo.