Skip to content

Secure mode never gives apply-seccomp CAP_SYS_ADMIN: --cap-drop ALL empties the bounding set, so the nested-userns fallback cannot work #498

Description

@timkuijsten

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions