fix: keep worker launch briefs off command lines - #111
Merged
Conversation
added 4 commits
August 16, 2026 18:38
Every launch template composed the whole encoded brief into the command sent to the pane, so the host process table carried, continuously, the full instructions every agent on the host was working from: task, repository, acceptance criteria, internal paths, operating rules. Measured on this host, not inferred. A sanitised scan of /proc/*/cmdline from one account returned another account's entire worker brief (uid 1000, 9820-byte command line, read from uid 1002) and a sibling worker's brief under the reader's own account (11511 bytes). Both home directories are mode 750 owned by their own account, so the process table was the only channel through which a brief crossed the account boundary, and process-listing protection would not have closed the same-uid half at all. Each installed CLI was checked for a native prompt-file flag on its supervised interactive path; neither claude 2.1.233 nor codex-cli 0.145.0 has one, and piping stdin to claude leaves the supervised path entirely. So the launch now carries the brief's PATH and the worker opens the file itself: the brief's bytes travel filesystem to worker, and only its address travels argv to worker. bin/fm-operational-input.sh gains `launch-pointer <brief-path>`, which builds the launch input from the path and never reads the brief, keeps the launch-brief wire kind so every consumer that classifies a launch input is unaffected, and refuses an unreadable path rather than launching a worker at a brief that is not there. All eight templates in bin/fm-spawn.sh's launch_template() use it, crewmate and secondmate shapes alike. Pi already set FM_FIRSTMATE_PI_LAUNCH_BRIEF to the brief's path while the full encoded brief still rode the same line. That variable feeds calm-mode visibility and was never a delivery mechanism, so .pi/extensions/fm-calm.ts now derives the value it expects through the same shell owner instead of reading the brief and encoding its body. tests/fm-spawn-brief-off-argv.test.sh is the regression gate. For every harness, and for the separate secondmate shapes, it spawns through fm-spawn, then EXECUTES the captured launch command with the harness replaced by an argv recorder and fails if a sentinel that exists only in the brief body appears there. Executing it is the point: a $(cat brief) and a $(... launch-pointer path) are equally innocent as strings. It also asserts the launch still delivers, so a launch that leaks nothing because it delivers nothing cannot pass, and a coverage gate fails if an adapter is added with no case. With the templates reverted, every case fails on the argv measurement. Verified by sentinel spawn on this host: claude and codex, each receiving and following its brief with the marker then found in no host command line. UNVERIFIED individually: opencode, pi, and grok, none of which is installed here. docs/brief-off-argv.md records the measurements, versions, and that boundary.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Harness verification: what was measured, and what was not
The acceptance bar for this change is per harness, not per fleet, so it is stated per harness here.
A pass requires both halves: the sentinel worker received and followed its brief, and a host-wide scan of process command lines found its unique marker nowhere. A worker that never got its brief is not a pass.
opencodebinary on this host, so no worker could be launched to measurepibinary on this host; the Pi calm-mode E2E also skips here for the same reasongrokbinary on this hostThe three UNVERIFIED harnesses have their launch templates converted, and
tests/fm-spawn-brief-off-argv.test.shexecutes their launch composition and reads the resulting argv. That is real execution rather than code inspection, but it is not the same evidence as a sentinel spawn and is deliberately not reported as such. There is no blanket "all five clean" claim in this PR: three of the five rest on composition evidence alone, anddocs/brief-off-argv.mdrecords that boundary with the same wording.Every sentinel ran in an isolated firstmate home on a private tmux server against a real pooled worktree, so nothing touched the live fleet, and no brief body, credential, or environment was printed at any point.
Intent
Deliver a worker's launch brief without ever placing it on a command line, for every harness this fleet can launch.
The defect, established by a model panel rather than assumed: every launch template in bin/fm-spawn.sh composed the whole encoded brief into the command sent to the pane, so the host process table carried, continuously, the full instructions every agent on the host was working from - task, repository, acceptance criteria, internal paths, operating rules. This was measured on this host on 2026-08-16, not inferred: an ordinary process listing from one account returned another account's entire worker brief (uid 1000, 9820-byte command line, read from uid 1002), plus a sibling worker's brief under the reader's own account.
There are TWO leaks and this change is the only one that closes both. Cross-account: any account on this host reads any other's command lines; persistent process-listing protection would close that half, needs root, and is tracked elsewhere. Same-account: two workers under ONE account read each other's briefs, and process-listing protection does not protect same-uid processes from the same uid, so only taking the brief off the command line closes that half. Containerising the vessels was considered by a panel and explicitly REJECTED for this purpose, because a container relocates the exposure rather than closing it - the launch lands on the host's process table one layer up. Do not propose containers as a substitute.
Constraints the user set: no harness may lose any launch capability it has today; the supervised interactive launch path is the one that matters and a working non-interactive prompt-mode variant does not demonstrate the fix; the mechanism must be chosen per harness against what that harness's INSTALLED CLI actually supports on this machine, verified against the installed version rather than documentation or memory; and secrets discipline applies throughout, so no brief body, credential, or full environment is ever printed while testing.
Decisions made while doing the work. Sanctioned candidates were a file pointer the harness reads, standard input, or terminal input sent after launch. claude 2.1.233 and codex-cli 0.145.0 were both checked with --help: neither has a prompt-file flag on its supervised interactive path, and piping stdin to claude switches it to non-interactive print mode, which leaves the supervised path entirely. So the file pointer - the first sanctioned candidate - is the one mechanism all five harnesses share: the launch carries the brief's PATH and the worker opens the file itself, so the brief's bytes travel filesystem-to-worker and only its address travels argv-to-worker. This is deliberately not weaker than terminal input, because data//brief.md is the durable record and exists on disk under every candidate, so the process-table channel is the only thing any of them could close.
New
fm-operational-input.sh launch-pointer <brief-path>builds that launch input; it deliberately never reads the brief, keeps the existing launch-brief wire kind so every consumer that classifies a launch input is unaffected, and fails closed on an unreadable path rather than launching a worker at a brief that is not there. All eight templates in launch_template() use it, crewmate and secondmate shapes alike.A trap the panel had already found: at least one adapter ALREADY set a brief-path variable while still passing the full encoded brief on the same line. That was pi's FM_FIRSTMATE_PI_LAUNCH_BRIEF, which feeds calm-mode visibility and was never a delivery mechanism. .pi/extensions/fm-calm.ts now derives the value it expects through the same shell owner instead of reading the brief and encoding its body, so the two producers cannot drift; a half-converted path reads as done and is not, so the test asserts the variable and the body independently.
The raw-launch escape hatch for unverified adapters is caller-authored, so it warns rather than refusing - refusing would remove a launch shape that works today, which the "no harness loses any capability" constraint forbids.
Acceptance criteria the user set, deliberately harness by harness. For each harness: a real sentinel worker spawned through the ordinary supervised path carrying a unique marker; it receives and follows its brief; and a host-wide scan of process command lines finds that marker nowhere - both halves required, since a worker that never got its brief is not a pass. Code inspection alone satisfies no criterion. A harness that cannot be exercised on this host must be named UNVERIFIED individually with the reason, and never folded into a general pass - a blanket "all five clean" resting on three measurements is the exact failure this task exists to prevent. A colocated test must cover the launch-path composition so a later edit cannot silently put a brief back on the command line, and must be proven to fail without the change. The PR must state plainly which harnesses were verified by sentinel spawn, which were not, and why.
What was measured. Before the change, sentinel spawns on claude and codex each received and followed their brief AND had their marker readable in the process table (claude pid 3832468, 827 bytes; codex pid 3881761, 960 bytes). After the change, both again received and followed their brief, and a host-wide scan found the marker in 0 of 474 and 0 of 480 processes respectively. Every sentinel ran in an isolated firstmate home on a private tmux server so nothing touched the live fleet. opencode, pi, and grok are UNVERIFIED individually because none of those binaries is installed on this host, so no worker could be launched to measure; their templates are converted and exercised by the executing composition test, which is deliberately not reported as equivalent evidence. docs/brief-off-argv.md records all of this with versions and exact output.
The regression gate is tests/fm-spawn-brief-off-argv.test.sh. It does not inspect source: for every harness, and for the separate secondmate template shapes, it spawns through fm-spawn and then EXECUTES the captured launch command with the harness replaced by an argv recorder, failing if a sentinel that exists only in the brief body appears on any argv. Executing it is the point, because a$(cat brief) and a $ (... launch-pointer path) are equally innocent as strings and only the shell can say which one puts a brief on a command line. It also asserts the launch still DELIVERS - the argv names the brief and carries the launch-brief kind - so a launch that leaks nothing because it delivers nothing cannot pass, and a coverage gate fails if an adapter is added to launch_template() with no case. With the templates reverted to their pre-change shape, every case fails on the argv measurement; that was verified per test function.
This repository is firstmate's own shared tracked material and the pin source, so bin/ changes land here and reach the fleet by pin bump. Knowledge placement follows the repo's firstmate-coding-guidelines: mechanics in the script header and --help, empirical evidence in docs/, one line of reinforcement at the adapter-verification risk point in the harness-adapters skill, and deliberately nothing added to AGENTS.md, whose token cost every session pays.
Note for the reviewer: tests/fm-arm-pretool-check.test.sh fails in this worktree, and that failure was confirmed identical on a pristine tree with none of these changes applied. It is pre-existing and unrelated to this work.
What Changed
launch-brieffile pointers, while preserving crewmate and secondmate launch capabilities and warning on unsafe caller-authored raw commands.launch-pointeroperational input and align Pi calm-mode visibility with the same pointer construction.Risk Assessment
✅ Low: The change is well-bounded, preserves all eight supervised launch shapes, keeps brief bodies off argv through the shared launch-pointer boundary, and the follow-up replaces the flagged source assertions with behavioral coverage without weakening executed harness cases.
Testing
Focused executable checks validated pointer construction, fail-closed operational-input behavior, supervised launch composition for every supported crewmate and secondmate template, Pi’s independent path binding, and dispatch-profile compatibility; all applicable checks passed, while Pi’s installed-package and interactive E2E checks skipped because Pi is unavailable on this host. The sanitized transcript provides reviewer-visible CLI evidence without exposing any brief body, credential, or environment.
Evidence: Sanitized targeted test transcript
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 3 issues found → auto-fixed ✅
tests/fm-calm-pi-extension.test.sh:83- This newly added static contract block only reads TypeScript source and checks for implementation strings. Remove it or replace it with behavior through the Pi extension interface; the later pointer-classification fixture already exercises the relevant observable behavior.tests/fm-spawn-brief-off-argv.test.sh:263- These tests parselaunch_template()source and search for case labels and forbidden strings, which violates the test-quality rule. Retain the executed argv cases, and expose/consume a semantic supported-harness registry if exhaustive coverage must be enforced.tests/fm-captain-translation-contract.test.sh:255- The changed assertion readsfm-spawn.shand checks for a literal constructor string rather than exercising emitted operational input. Remove it or assert the kind through a captured and executed launch command.🔧 Fix: Replace source assertions with behavioral harness registry
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
bash tests/fm-operational-input.test.shbash tests/fm-spawn-brief-off-argv.test.shbash tests/fm-calm-pi-extension.test.shbash tests/fm-spawn-dispatch-profile.test.shInstalled-version check viacommand -vand<harness> --version: Claude 2.1.233 and codex-cli 0.145.0 available; opencode, pi, and grok unavailableRepeatedtests/fm-operational-input.test.shandtests/fm-spawn-brief-off-argv.test.shwhile capturing the sanitized evidence transcriptgit status --shortconfirmed testing left no transient worktree changes✅ **Document** - passed
✅ No issues found.
🔧 **Lint** - 1 issue found → auto-fixed ✅
🔧 Fix: Clarify empty local declaration
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.