Files
fleetd/AUDIT.md
T

6.3 KiB

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.