2
16 Security and Trust Boundary
Dai Ha edited this page 2026-09-10 07:10:49 +07:00

16. Security and Trust Boundary

Scope. This page pulls the security model into one place. The model is real, but the code that carries it is spread across three areas — the guard package, the auth package, and the member package — so no single page showed it whole before this one. Every claim below was checked against the source at main a814d1e (2026-08-31). Where this page and the code ever disagree again, trust the code, not this page.

fleetd draws four separate lines of defense: a subscription boundary that stops a worker from billing the operator's paid Claude plan, an identity and authorization model that stops one member from acting as another, an environment control on what a spawned member inherits from the operator's own shell, and a token scope limit on what a member can do to the git forge. The sections below cover each in turn, using the class that enforces it.

flowchart TB
    op["Operator's shell<br/>own secret store"]

    subgraph primary_side["Primary — on subscription"]
        primary["Primary (lead)<br/>ANTHROPIC_BASE_URL must be absent"]
    end

    subgraph member_side["Member — off subscription"]
        member["Worker / architect pane<br/>login shell"]
        claude["claude / opencode process"]
    end

    guard["guard.SubscriptionGuard<br/>assertWorker / assertPrimaryClean"]
    authz["auth.Authz + mcp.ConnectionIdentity<br/>role table, identity from the connection"]
    scrub["member.MemberEnvAllowList<br/>+ EnvAllowListScrub (ZDOTDIR)"]
    token["Repo-scoped forge token<br/>write:repository only"]

    op -- "re-sources on login" --> member
    guard -- "checked before spawn" --> member
    guard -- "checked at daemon start" --> primary
    authz -- "gates every fleet_* call" --> primary
    authz -- "gates every fleet_* call" --> member
    scrub -- "blanks names after the shell chain runs" --> member
    member --> claude
    token -- "injected into the worker env" --> claude

    classDef boundary fill:#6b46c1,stroke:#3d2a75,color:#ffffff;
    class guard,authz,scrub,token boundary

Figure: the four controls (purple) that separate the operator's subscription and secrets from a spawned member. The environment controls stop at the process environment — the argv gap in the last section of this page is not one of the boxes above, because nothing here defends it.

1. The subscription boundary

SubscriptionGuard (fleetd/src/main/java/dev/ltms/fleet/guard/SubscriptionGuard.java) enforces one rule in two directions: a worker must run off the operator's paid subscription, and the primary must never run on a base URL at all.

  • assertWorker(baseUrl) (SubscriptionGuard.java:32-46) refuses to spawn a worker whose ANTHROPIC_BASE_URL is blank, not a valid URL, or resolves to a host outside a configured allowlist. The allowlist is FleetConfig.Guard.offSubscriptionHosts (FleetConfig.java:1156-1159) and it defaults to an empty list. Because the list is a config value that differs by host, this page names no hostname.
  • assertPrimaryClean(env) (SubscriptionGuard.java:49-55) refuses to let the daemon start if its own environment carries ANTHROPIC_BASE_URL. Fleetd.java:131-132 calls this against System.getenv() at startup, before the daemon does anything else. If the primary's shell were ever pointed at a base URL, the daemon would not come up.
  • ClaudeCodeLauncher.buildLaunch (fleetd/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java:191-213) calls assertWorker right before every non-subscription spawn.

The opt-out: subscription: true. A profile can deliberately choose to run a worker on the operator's own subscription instead of an off-subscription endpoint (CB-539). This is meant for a profile with no off-subscription endpoint to point at. It is enforced at two separate points, so a worker cannot slip past the guard on the subscription path:

  • At spawn time, ClaudeCodeLauncher.buildLaunch (ClaudeCodeLauncher.java:196-213, 227-233) refuses a profile that sets both subscription: true and a baseUrl — the two say opposite things, so the launcher throws rather than picking one. On the subscription path it also strips ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN from the worker's environment as a belt-and-braces step, even though subscription: true should never carry them.
  • At config-load time, FleetConfig.validateSubscriptionProfiles() (FleetConfig.java:2058-2075) refuses to start the daemon at all if any subscription: true profile's env: block names ANTHROPIC_BASE_URL or ANTHROPIC_AUTH_TOKEN. The reason this must be a fatal, loud refusal and not a silent strip: on the subscription path, SubscriptionGuard is skipped entirely for that profile, so nothing else would catch a repoint (FleetConfig.java:2044-2052).

2. Identity and authorization

A member's identity comes from the connection it calls on, never from an argument in the request. ConnectionIdentity.resolve (fleetd/src/main/java/dev/ltms/fleet/mcp/ConnectionIdentity.java:42-48) takes only the remote address and port of the MCP connection. It maps the connecting process's PID to a herdr pane through PaneLocator, and that pane maps to a worker's terminal_id. Both steps use sources the caller cannot influence: the OS reports the real PID for a loopback connection, and herdr owns the PID-to-pane mapping. A caller that maps to no worker pane — an off-host client, or the primary itself — resolves to null (ConnectionIdentity.java:9-14).

The role table lives in Authz.permits (fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java:44-72):

Action Who may perform it Why (from the code's own comments)
SPAWN, STOP, DRAIN primary only fleet lifecycle is the primary's alone; an architect deliberately does not get these, even though it coordinates workers, so it cannot tear a fleet down
SEND primary or architect an architect delegates to workers, which is the point of the role, but still has no lifecycle rights; a worker sending would be it escalating into the orchestrator role
REPLY, ASK the caller must own the target session the load-bearing rule: a caller acts only as the pane it occupies
READ, METRICS primary, worker, or architect observation carries no secrets, so every authenticated role may read

The REPLY/ASK row is what stops one member from forging a reply for another member's rendezvous. Because identity is read from the connection and not from a session id typed into the request, a member cannot claim a session id that is not its own pane's. An unauthenticated caller is refused for every action (Authz.java:44-47).

3. What a member inherits, and what is blocked

This is the part of the model that most needs care, because two different controls exist and only one of them actually works once a login shell has run.

Why a spawn-time overlay is not enough. A herdr pane runs a login shell, and that shell re-sources the operator's own secret store. A control applied at pane-creation time — an environment overlay handed to herdr before the shell starts — is therefore overwritten the moment the operator's own ~/.zshrc and secret files run (FleetConfig.java:1182-1183, EnvAllowListScrub.java:17-25). This is the deny-by-default policy (FleetConfig.MemberCredentials.POLICY_DENY_BY_DEFAULT, FleetConfig.java:1223): it shadows each known-but-not-allowed name in the pane-creation overlay, but a login shell that re-exports the same name defeats it.

What actually holds is the allow-list policy (FleetConfig.MemberCredentials.POLICY_ALLOW_LIST, FleetConfig.java:1235), implemented in EnvAllowListScrub (fleetd/src/main/java/dev/ltms/fleet/member/EnvAllowListScrub.java). It generates a per-spawn ZDOTDIR whose startup files run the scrub after the whole operator chain has sourced, blanking every exported variable that is not on a derived allow-list. Because the scrub runs last, nothing sourced later can undo it (EnvAllowListScrub.java:20-25). The scrub is sourced from both the generated .zshrc and .zlogin, because herdr opens a login zsh on macOS (where .zlogin runs) and a plain interactive zsh on Linux (where it does not) — a scrub placed in only one of those files would silently do nothing on the other platform (EnvAllowListScrub.java:27-34). This control is zsh-only: it depends on $ZDOTDIR being a zsh mechanism, so a member whose pane runs a different shell is not covered by it.

The effective allow-list is wider than what an operator writes. MemberEnvAllowList.derive (fleetd/src/main/java/dev/ltms/fleet/member/MemberEnvAllowList.java:108-128) builds the kept-name set as a union of:

  • INFRASTRUCTURE_PASSTHROUGH — locations and shell settings such as PATH, HOME, ZDOTDIR, and the XDG_* roots (MemberEnvAllowList.java:78-84), never credentials;
  • every configured profile's gitTokenEnv, gitHostEnv, tokenEnv, and every key of that profile's env: map (MemberEnvAllowList.java:110-118) — for every profile, not only the one being spawned, so adding a profile can only widen the list, never narrow another spawn's scrub;
  • the operator's own memberCredentials.allow: list (MemberEnvAllowList.java:120-126).

So the list a spawn actually keeps is a strict superset of what an operator types under allow:. An operator who reads only their own allow: entry will underestimate what a member can keep.

SSH_AUTH_SOCK is excluded from that union (MemberEnvAllowList.java:122). It is the operator's ssh-agent socket, and a member holding it can sign with the operator's own keys, so it is governed only by the separate memberCredentials.sshAuthSock setting (FleetConfig.java:1208-1216), never by appearing in allow:.

The limit of an environment control. Blocking the agent socket in the environment does not stop a member reaching a passphrase-free private key file sitting on disk outside ~/.ssh. An environment control covers what a process can read from its environment variables; it says nothing about the filesystem. A control at one layer does not close a gap at another.

4. Tokens

A member is given a repo-scoped forge token with write:repository scope only — enough to create a branch and open a pull request — never the operator's own admin-scoped GITEA_ACCESS_TOKEN (fleetd/docs/Worker-Git-Workflow.md:139-144). GitWorktrees's credential helper reads this scoped token from WORKER_GITEA_TOKEN (fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java:70-72) and warns, in its own comment, that a silent fallback to the primary's admin token would turn a loud crash into a quiet privilege leak (GitWorktrees.java:52-58). ClaudeCodeLauncher.buildLaunch injects the scoped token into the worker's environment as part of every spawn (ClaudeCodeLauncher.java:240). A lead gets no git token at all — it reviews and merges through the operator's own credentials, and LeadLauncher.leadEnv never grants it the scoped token a member gets (fleetd/src/main/java/dev/ltms/fleet/lead/LeadLauncher.java:287-288).

The reason for the narrow scope is direct: a member opens its own pull request and must never be able to merge it. A leaked or misused member token can open pull requests. It cannot merge, delete a branch it does not own, or touch the org. Merge authority stays with the operator or the primary.

The one honest limitation: argv is world-readable

A process's command-line arguments (argv) are visible to any other process on the same host — ps -axww prints them for every user session. If a program passes a credential as NAME=value on its command line, that value is exposed the same way a value printed to a shared log would be. Nothing in fleetd checks for this, and nothing in this page's other three sections defends against it.

fleetd, herdr, and claude are clean on this specific point: fleetd passes a member's environment to herdr as a JSON field in the spawn request, not as command-line arguments — WorkspaceControl.createTab and WorkspaceControl.splitPane both carry env as a separate params entry alongside cwd, never appended to argv (fleetd/src/main/java/dev/ltms/fleet/herdr/WorkspaceControl.java:84-93, 100-109).

But this guarantee is narrow. It covers fleetd, herdr, and claude — nothing else. A different program started on the same host can still leak a credential through its own argv, and every control in this page — the subscription guard, the identity check, the environment scrub, the token scope — is bypassed the moment that happens, because none of them touch argv. This is a gap in the host, not in fleetd's own code, and it is recorded here rather than left undocumented, because a security page that hides its own limits is worse than one with none.

Re-measured on the Mac, 2026-09-10. The check itself has a trap, and it produces false positives that look exactly like real findings.

pgrep -fl <pattern> prints the full command line of every matching process — including the process running your search. A hunt for credential-shaped names matches the grep pattern in your own argv, so the names you are looking for appear in the output whether or not any process is leaking them. Read against a ticket that lists the names, it reads as confirmation.

The two commands disagreed by a factor of two here: pgrep -fl claude reported 12 environment names in argv, ps -axwwo args= across every process on the host reported 5, and the number of credential-shaped names was 0 by ps. The ps number is the right one. Every extra name came from the search command and from a shell command whose text quoted the ticket.

Check with ps, one process at a time, and extract names only:

# every process: how many credential-shaped names are in argv?
ps -axwwo pid=,args= | grep -oE '[A-Z][A-Z0-9_]{2,}=' | sort -u | grep -E 'TOKEN|KEY|SECRET|PASSWORD'

# one process, names only
ps -wwo args= -p <pid> | grep -oE '[A-Z][A-Z0-9_]{2,}=' | sed 's/=$//' | sort -u

grep -o on a pattern that ends at = cannot emit the value, so both forms are safe by construction. Never use pgrep -fl for this, and never print a value.

When two counts disagree, suspect the two commands before the machine. ps and pgrep do not search the same set: one omits the searcher, the other includes it.


This page does not cover herdr's own process isolation, the LavinMQ broker's per-vhost isolation between two fleets, or session lifecycle and reaping. See 1. Architecture and 9. Implementation Architecture for those.