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>
This commit is contained in:
2026-08-12 01:03:17 +00:00
co-authored by Claude Opus 5
parent 85249a2756
commit c5522700ff
4 changed files with 94 additions and 90 deletions
+18 -28
View File
@@ -1,7 +1,6 @@
import type { SpawnOptions, SpawnedProcess } from '@anthropic-ai/claude-agent-sdk';
import { spawn } from 'node:child_process';
import { realpathSync } from 'node:fs';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import { runAsArgv } from '@@/os-user';
import { claudeBinIn } from '@@/os-user-claude';
@@ -119,20 +118,6 @@ export function assertEnvSafe(permitted: ReadonlySet<string>, childEnv: Record<s
if (unexpected.length) throw new Error(`refusing to run ${who}'s agent with unvetted env: ${unexpected.join(', ')}`);
}
/**
* Whether two paths are the same file, following symlinks on both sides.
*
* False rather than throwing when either path does not exist, so a missing install reads as "not their
* binary" and refuses, instead of surfacing an ENOENT from inside a spawn hook.
*/
export function sameFile(a: string, b: string): boolean {
try {
return realpathSync(a) === realpathSync(b);
} catch {
return false;
}
}
/** Build the child environment for a member's turn: allowlist in, everything else absent. */
function memberEnv(run: MemberRun, inherited: Record<string, string | undefined>): Record<string, string> {
const env: Record<string, string> = {};
@@ -164,25 +149,30 @@ export function spawnClaudeAsMember(run: MemberRun): (options: SpawnOptions) =>
assertEnvSafe(PERMITTED_ENV, childEnv, run.osUser);
// The binary must be theirs. Without this the claim is only a comment: `command` arrives from the SDK, and
// `claude-manager.ts` resolves it to the OWNER'S `CLAUDE_BIN` at module load — so the wired version would
// run the owner's install as the member, the exact confusion this file exists to prevent.
// The binary must be theirs — established without touching their filesystem.
//
// Compared through `realpathSync` because Anthropic's installer puts a SYMLINK at `~/.local/bin/claude`
// pointing into a versioned directory. `resolve()` does not follow symlinks, so a plain string compare
// matches only while `command` happens to arrive as the symlink path — and throws on every turn the moment
// anything upstream normalises it. Accepting either form is what makes the check mean "their install"
// rather than "this spelling of their install".
// Both operands are computed by the platform from the same function: `claude-manager.ts` sets
// `pathToClaudeCodeExecutable: claudeBinIn(member.home)`, and this recomputes it. So string equality
// establishes exactly what the check is for, and if the SDK ever normalises the path it fails closed and
// loudly rather than silently.
//
// Resolved per turn, never cached: `claude update` moves the symlink's target.
// This was `realpathSync` on both sides for one commit, to survive an upstream that resolves symlinks —
// and there is no such upstream, because the platform controls both ends. The cost was total: a member's
// home is 700, the platform is `other`, so `realpathSync` threw EACCES, the catch turned that into
// "not their binary", and EVERY member turn would have been refused forever the moment the gates moved.
// Verified on the production host against a byte-identical path. Failing closed was the right direction
// and it made the feature impossible rather than unsafe.
//
// The alternative — granting the service user traverse — needs `x` on `.local`, `.local/share`,
// `.local/share/claude` and `versions/`, which reopens a decision made deliberately in `16` across a whole
// subtree rather than one directory.
const expectedBin = claudeBinIn(run.home);
if (!sameFile(command, expectedBin)) {
if (resolve(command) !== expectedBin) {
throw new Error(`refusing to run ${command} as ${run.osUser}; expected their own ${expectedBin}`);
}
// `env K=V …` inside the argv, because `--reset-env` clears anything handed to `setpriv` itself. This is
// the only channel through which a variable can reach the member's process, which is what makes the
// allowlist above the complete answer to "what can this turn see".
// the only channel through which a variable can reach the member's process.
const assignments = Object.entries(childEnv).map(([name, value]) => `${name}=${value}`);
const [launcher, ...launcherArgs] = runAsArgv(run.osUser, ['env', ...assignments, command, ...args]);
// Unreachable — `runAsArgv` always returns `sudo` first and throws on an empty command. Written rather