Compare commits

...

1 Commits

Author SHA1 Message Date
Dai Ha 5524449cca audit: spawn-boundary asymmetry in member/ launchers (AUDIT.md) 2026-09-04 10:32:45 +07:00
+90
View File
@@ -0,0 +1,90 @@
# Spawn-boundary audit — `member/` launchers
Scope: `fleetd/src/main/java/dev/ltms/fleet/member/` (`ClaudeCodeLauncher`, `OpenCodeLauncher`,
`HerdrPeerLauncher`, `CompositePeerLauncher`, `EnvAllowListScrub`, `MemberEnvAllowList`,
`MemberCredentialPolicyView`, `OpenCodeSessionDiscovery`). No `worker/` package exists — the
launcher classes live entirely under `member/`, so I read that whole directory instead.
## Main finding
**1. `fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java:548-632` (`seedTrustDialog`)**
**Issue.** `seedTrustDialog` writes the workspace-trust seed to `configDir/.claude.json`, or to
`~/.claude.json` (fleetd's own `user.home`) when `configDir` is unset. It gates only on
`isProvisionedWorktree(cwd)` (line 549). It never checks `memberHerdrSocketConfigured()` and,
being a `static` method, cannot reach that instance state even if it wanted to.
Compare this to its sibling in the very same class, `writeCharterFile` (line 788, an *instance*
method): that one explicitly checks `memberHerdrSocketConfigured()` (line 790) and, when true,
routes the file under `memberScrubParentDir()` (`worktreeRoot`) and calls
`EnvAllowListScrub.shareWithGroup(dir, group)` (line 813) — or refuses the spawn outright when
`worktreeRoot`/`worktreeGroup` isn't configured (lines 798-807), rather than hand the member a
path its OS user cannot read. `OpenCodeLauncher.writeConfig`/`configParentDir` (lines 422-508,
542-559) do the identical thing for `opencode.json`. `seedTrustDialog` is the one cross-process,
member-facing file write in this class that skips that treatment entirely.
It also makes the miss worse on its own: `writeAtomically` (line 696) calls
`copyPosixPermissionsIfPresent` (line 710), which deliberately preserves the *existing* file's
POSIX permissions — and Claude Code ships `.claude.json` at `0600` (see the javadoc at line 681).
So even where `configDir` happens to point at an already-shared directory, the seeded file itself
is written owner-only, unreadable by a different OS user.
**Spawn path that reaches it.** Any `claude` profile that (a) sets `memberHerdrSocket:` (real,
validated config — see `memberHerdrSocketConfigured()` at `HerdrPeerLauncher.java:1667`) and
(b) does **not** set `configDir:`, spawned with `worktree:true` (so `isProvisionedWorktree(cwd)`
is true and the method proceeds). `buildLaunch` calls `seedTrustDialog(cfg.configDir(), spec.cwd())`
unconditionally at line 290, before the pane is ever created.
**What goes wrong.** Under that config, `seedTrustDialog` writes the trust entry into fleetd's
*own* `~/.claude.json` — the daemon's OS user's home. But the actual Claude Code process starts
under the *member's* different OS user (that's the whole point of `memberHerdrSocket`), with its
own `$HOME`, and (with `configDir` unset) reads `~/.claude.json` relative to *that* home — a file
this method never touches. The seed lands nowhere the spawned process will ever look. The result
is exactly the fleetd #149 incident this method exists to prevent: the member sits on Claude
Code's interactive, un-timed "Is this a project you trust?" dialog forever, never mounts the
bridge MCP, and never calls `fleet_reply`; the CB-306 spawn-readiness gate eventually times it
out as an unexplained "did not reach injectable state."
**Confidence.** High that the code path is exactly as described — I traced `buildLaunch` →
`seedTrustDialog` → `writeAtomically`/`copyPosixPermissionsIfPresent` directly, and confirmed
`writeCharterFile`'s contrasting `memberHerdrSocketConfigured()` gate at the same class's
line 790. Medium on operational reachability *today*: several javadocs elsewhere in
`HerdrPeerLauncher` describe "`memberHerdrSocket` ABSENT" as "today's only live mode," so this
gap may not be hit by the currently-deployed fleet — but it is a real, config-reachable hole in a
feature the codebase otherwise treats as first-class (four other defects fixed for exactly this
seam: fleetd #213, #219, #222 in this same file/its sibling). Direction of harm: an operator who
turns `memberHerdrSocket` on gets a silently-hung `claude` member instead of a working one — no
data loss, no credential leak, but a resource stuck occupying a pane with no diagnosis pointing
at the real cause (this is the "silent" failure mode the class's other methods were deliberately
rewritten to avoid).
## Secondary findings
**2. `fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java:788-818` (`writeCharterFile`) and `OpenCodeLauncher.java:422-508` (`writeConfig`)**
Both create a per-spawn temp file/directory (role+reply charter file; `opencode.json` +
member-charter + IDE-rules files) and rely only on `deleteOnExit` for cleanup. Unlike the CB-633
ZDOTDIR directory, which `spawnInternal` (`HerdrPeerLauncher.java:519-528`) explicitly deletes
with `EnvAllowListScrub.deleteRecursively(zdotdir)` when `spawnInTab`/`spawnAsPane` throws *after*
`buildLaunch` succeeded, nothing tears down the charter file or the opencode config directory on
that same failure path — they leak until JVM exit, which for a long-running daemon can be
effectively never. Symmetric between the two launchers (not an asymmetry), so it is secondary, but
it is the same "created at spawn, not cleaned up if spawn then fails" shape the brief called out.
Confidence: high that the code omits it (read both methods and the `spawnInternal` catch block);
low-medium severity — disk clutter in a temp/worktree-scrub directory, not a security or
correctness issue on the member itself.
**3. Shape noted, not investigated.** `ClaudeCodeLauncher.writeIdeOverlay` (line 438) also skips
`memberHerdrSocketConfigured()`, but unlike `seedTrustDialog` it writes into `cwd` itself (the
worktree root), which — if worktree provisioning already shares the worktree with the member's OS
user under `worktreeGroup` (plausible, but I did not verify the worktree-provisioning code, which
is outside this scope) — would already be readable/writable by the member without any extra
routing. Flagging the shape rather than a finding since I have not confirmed worktree provisioning
outside `member/`.
## Out of scope, noted only
- The `worker/` directory named in the brief does not exist; all launcher classes live in
`member/`, which I reviewed in full instead.
- Worktree-provisioning permissions (whether `worktreeGroup` sharing actually covers `cwd`) live
outside this package and were not verified.