Files
platform/COMMS/sidecar-app-store/36-live-test-results.md
T
pastilhasandClaude Opus 5 85249a2756 36: the privilege drop works, and the binary check refuses every member turn
Ran the live test against green. Two results pointing opposite ways.

setpriv survives. Verified independently by spawning the same argv and reading what the command
printed: id -u = 1001. Nothing layered on the hook has to move. But the test asserts against
/proc/<child.pid>/status where child.pid is sudo, whose real uid is legitimately 1000 until it
execs down to setpriv, so it fails on a working drop. The intent — don't let the child
self-report — is right; the fix is to observe the final process by having the child create a
file and stat its owner, which a process cannot forge.

The binary check refuses a byte-identical path. sameFile calls realpathSync, which throws
EACCES for the service user because .local is 700 and the platform is "other", and the catch
turns that into false. Every member turn would be refused the moment the gates move.

That one is mine. In 16 I argued for leaving .local closed to the platform and reasoned about
the file browser, without considering that spawn-as-member runs IN the platform process and
must stat a path inside it. Recommended comparing resolve(command) to claudeBinIn(run.home)
instead: both operands are platform-computed by the same function, so string equality
establishes exactly what the check is for with no filesystem access. Granting traverse instead
would need x on four directories, not one, and reverses 16 across the tree.

All state restored and verified: .local back to 700, no residual ACL entries on any directory
I touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:01:13 +00:00

3.5 KiB

36 — the privilege drop works; the binary check refuses every member turn

Ran spawn-as-member.live.test.ts against green on the production host. Two results, and they point opposite ways.

1. setpriv survives — the design does not change shape

Verified independently of the test, by spawning the same argv and reading what the command itself printed:

sudo -n setpriv --reuid green --regid green --init-groups --reset-env -- env … sh -c 'id -u'
  → 1001

The privilege drop works. Nothing layered on the hook has to move.

But the test's assertion is aimed at the wrong process. It reads /proc/<child.pid>/status, and child.pid is sudo — whose real uid is legitimately 1000 until it execs down the chain to setprivenvclaude. So it asserts against the wrapper and fails on a working drop. In my probe that pid was already unreadable by the time I looked, which is the same problem from the other side.

Your intent was right — don't let the child self-report. The fix is to keep that property while observing the final process: have the child create a file, then stat its owner. A process cannot forge the uid that owns a file it created, and the answer is the kernel's, about the process that actually ran.

2. The binary check refuses a byte-identical path

error: refusing to run …/home/.local/bin/claude as green;
       expected their own …/home/.local/bin/claude

Identical strings. sameFile uses realpathSync, which throws EACCES for the service user, and the catch turns that into false:

drwx------ green:green  …/home/.local          ← platform is "other"
realpathSync as pastilhas → EACCES

Every member turn would be refused, forever, the moment the gates move. It fails closed, which is the right direction, and it makes the feature impossible rather than unsafe.

This is my fault and I want it on the record. In 30/16 I argued for leaving .local closed to the platform, and you agreed. I reasoned about the file browser and never considered that spawn-as-member.ts runs in the platform process and must stat a path inside that directory. The decision was sound for the reason given and wrong for a reason nobody checked.

Recommended fix — compare without touching the filesystem:

if (resolve(command) !== claudeBinIn(run.home)) throw 

Both operands are computed by the platform from the same function — claude-manager.ts sets pathToClaudeCodeExecutable: claudeBinIn(params.member.home) and the guard recomputes it — so string equality establishes exactly what the check is for, and needs no access to the member's home at all. If the SDK ever normalises the path, it fails closed and loudly rather than silently.

The alternative — granting the service user traverse — is wider than it looks: realpath follows the symlink, so it needs x on .local, .local/share, .local/share/claude and versions/, which reverses 16 across the whole tree rather than one directory. I would not.

My 06 note that pushed you to realpathSync was defending against an upstream that normalises. There is no such upstream — the platform controls both ends — and the defence costs the feature.

State

Everything I changed to run this is restored and verified: .local back to 700, the four others to 770, and no user:pastilhas entries on any of the five. The 240 inherited rwx entries deeper in the tree are pre-existing and unreachable through a 700 parent.

Green untouched otherwise. Both gates up.