Commit Graph
2 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 c5522700ff compare the binary as a string, and prove the uid by file ownership
host ran the live test against green. Two results.

SETPRIV WORKS. The privilege drop lands on the member and the SDK spawn survives
it, so the design does not change shape and everything layered on the hook
stays. That was the last question that could have moved the architecture.

THE BINARY CHECK REFUSED A BYTE-IDENTICAL PATH. sameFile used realpathSync,
which has to stat inside a 700 home the platform is `other` to, so it threw
EACCES and the catch turned that into "not their binary" — every member turn
refused forever, the moment the gates moved. Failing closed was the right
direction and it made the feature impossible rather than unsafe.

Now resolve(command) !== claudeBinIn(run.home). Both operands are computed by
the platform from the same function, so string equality establishes exactly what
the check is for and needs no access to their home. sameFile and its tests are
deleted: a helper kept for a case that cannot arise is a trap for the next
reader. The realpath version was defending against an upstream that normalises
paths, and there is no such upstream — the platform controls both ends.

The fact that decides this is that the check runs in the PLATFORM process, not
the member's, and neither of us stated it until EACCES did.

THE TEST WATCHED THE WRONG PROCESS. child.pid is sudo, whose real uid is
legitimately the service user's until it execs down through setpriv, so
asserting on it fails on a working drop. Split: --version through the hook
proves their binary ran, and a second spawn creates a file in their home whose
owner the test stats. Nothing self-reports, and a process cannot forge the uid
that owns a file it created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:03:17 +00:00
pastilhasandClaude Opus 5 6b7aad91db make both env guards able to fire, and follow the symlink
host was right twice, including about his own advice. Two dead guards had
shipped here, both for the same reason: written inside the spawn closure, where
the only way to reach them is to spawn — and the passing path spawns sudo. So
nothing ever demonstrated either one firing.

THE SUBSET CHECK WAS ALSO TAUTOLOGICAL. `permitted` came from the same constants
memberEnv builds childEnv from, so it was empty under every edit where that
holds — the exact criticism that retired NEVER_ENV. Worse, it lost the one live
trigger the denylist had: a credential added to ALLOWED_ENV used to throw, and
under the subset check widened the permitted set in the same motion and passed
silently. That is the realistic future edit and it was the one left unguarded.

Now both, and the denylist tests the LIST rather than the instance, so it fires
on exactly that edit. Extracted as `assertEnvSafe` so a test can pass a poisoned
allowlist — the guards being untestable in place is why they were decorative
twice.

THE BINARY CHECK WOULD HAVE THROWN ON EVERY TURN. Anthropic's installer puts a
symlink at ~/.local/bin/claude into a versioned directory; resolve() does not
follow symlinks, so the string compare matched only while `command` arrived as
the symlink spelling, and would have failed the moment anything upstream
normalised it — at exactly the point the hook gets wired. Compared through
realpathSync on both sides now, per turn and never cached, since `claude update`
moves the target.

Nine tests pin all of it: a poisoned allowlist, a stray key, each NEVER_ENV name,
and the symlink/target/missing-path cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:10:34 +00:00