fix(bin): prove the primary harness owns the pane before away-mode injection - #13
Merged
Merged
Conversation
…jection The away-mode daemon typed digests into whatever its rendered guards called an empty composer. A zsh prompt themed with a bare `❯` reads exactly like an idle Claude composer, so a primary that exited or crashed to its login shell received the digest, zsh ran any command substitution a worker had quoted, and the delivery was recorded as confirmed. inject_msg now requires fm_backend_pane_harness_state to prove, from the pane's foreground process group (plus Herdr's native agent name where verified), that the detected primary harness owns the terminal before any guard runs, and again before a submit counts as delivered. A refusal keeps the escalation buffered for the existing wedge path. The composer classifier also takes the caller's harness (FM_COMPOSER_HARNESS): for Claude, whose composer is always framed by solid rules, an unframed agent glyph row now reads unknown instead of empty.
dardant
added a commit
that referenced
this pull request
Sep 25, 2026
Keep both sides where this branch's harness-pid helpers and main's pane ownership helpers (#13) were added next to each other in the tmux and Herdr adapters.
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.
Intent
Security fix: prove the harness owns the pane before the away-mode daemon types into it, so a digest can never be typed into a bare shell.
Context: the away-mode investigation found that on 2026-09-23 the away-mode supervise daemon deferred digest injection 1,558 times for six hours because an idle primary Claude pane still displayed a worker's echoed spinner line, which Herdr's native Claude detector and Firstmate's unanchored rendered regex both read as busy; the wedge alarm had no channel on Linux. Separately it proved a security defect: the composer classifier treats a bare
❯row as an empty agent composer, so if the primary Claude exits during away mode the daemon types a digest into a plain zsh prompt, zsh executes any$(...)in worker-quoted status text, and the delivery is recorded as confirmed. The captain answered the report's calls: "both fixes, yes to Herdr".The report's security fix, which this change is: in inject_msg, refuse unless the supervisor pane's foreground process is the detected primary harness - on Herdr,
agent getmust report that harness (agent == claude for a Claude primary) and the pane's process state must not be a shell; on tmux, compare pane_current_command. Also stop treating a bare, unbordered❯row as an empty composer for harnesses whose composer is always bordered (Claude draws─rules above and below it), and never accept a shell prompt reappearing as submit confirmation. The report marks this security-sensitive and gives it its own ship; its busy-guard stall fixes are a separate change.What Changed
inject_msginbin/fm-supervise-daemon.shnow refuses to type unless the newfm_backend_pane_harness_stateinbin/fm-backend.shreports that the detected primary harnessownedthe supervisor pane. It checks this before typing and again before counting a confirmed submit as delivered, so a shell prompt that reappears after Enter no longer counts as a submitted turn. On Herdr,agent getmust name the harness and a foreground process frompane process-infomust be that harness. On tmux,pane_current_commandor a process in the pane tty's foreground group must match, read through an exact pane id. Aforeignorunreadableresult logs the refusal and keeps the escalation buffered.bin/fm-agent-process-lib.shadds helpers that work out which harness family a process belongs to.bin/fm-composer-lib.shnow takes the pane's harness throughFM_COMPOSER_HARNESS. For harnesses listed inFM_COMPOSER_FRAMED_HARNESSES(currently only Claude), a bare❯row readsunknowninstead ofemptyunless it has a─rule directly above it and a closing rule below it. The daemon passes its primary harness for both the composer check and the submit confirmation.bin/fm-afk-launch.shdetects the primary harness in the captain's context and passes it to the detached daemon terminal asFM_DAEMON_PRIMARY_HARNESS, on both tmux and Herdr. The/afkskill, the backend docs and the runtime verification notes describe the new ownership proof. Unit and E2E tests cover it for tmux, Herdr, the composer classifier and the launcher.🤖 Generated with Claude Code
Risk Assessment
Testing
I ran the targeted tmux and Herdr away-mode inject E2E suites and the afk-launch start-path E2E, and all passed. The new shell-refusal scenario fails against the pre-fix tree, so it reproduces the bug. I then drove the real daemon flush in an isolated Herdr lab with real zsh, Claude Code 2.1.280 and codex-cli 0.154.0. The pre-fix daemon ran a digest-quoted
$(touch)in zsh and marked the digest delivered. The fixed daemon refused the bare-❯ zsh, suspended Claude, exited Claude and a codex pane, and kept each escalation buffered. It delivered to idle Claude, which answered. I tore the lab down and the default Herdr session was untouched. This is a terminal/daemon change, so the evidence is text captures of the Herdr pane rather than screenshots.Evidence: Live Herdr: pre-fix daemon typed the digest into zsh, ran $(touch) and cleared the buffer
Source: Live Herdr: pre-fix daemon typed the digest into zsh, ran $(touch) and cleared the buffer
Evidence: Live Herdr: fixed daemon refuses the bare-❯ zsh pane, nothing typed, buffer kept
Source: Live Herdr: fixed daemon refuses the bare-❯ zsh pane, nothing typed, buffer kept
Evidence: Live Herdr: fixed daemon delivers to idle real Claude 2.1.280, which replies ACK
Source: Live Herdr: fixed daemon delivers to idle real Claude 2.1.280, which replies ACK
Evidence: Live Herdr: Claude suspended with ^Z is refused (zsh in the foreground)
Source: Live Herdr: Claude suspended with ^Z is refused (zsh in the foreground)
Evidence: Live Herdr: pane refused after Claude /exit
Source: Live Herdr: pane refused after Claude /exit
Evidence: Live Herdr: codex in the pane while primary=claude is refused (foreign)
Source: Live Herdr: codex in the pane while primary=claude is refused (foreign)
Evidence: Live Herdr: Claude trust dialog reads owned but composer pending
Source: Live Herdr: Claude trust dialog reads owned but composer pending
Evidence: tmux inject E2E (A-D) on the fix
Source: tmux inject E2E (A-D) on the fix
Evidence: tmux inject E2E Scenario D failing against pre-fix base (regression reproduced)
Source: tmux inject E2E Scenario D failing against pre-fix base (regression reproduced)
Evidence: Real-Herdr inject E2E
Source: Real-Herdr inject E2E
Evidence: afk-launch start path passes the detected harness to the daemon (herdr and tmux)
Source: afk-launch start path passes the detected harness to the daemon (herdr and tmux)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-supervise-daemon.sh:673- The new ownership proof silently disables away-mode delivery for the verified primaries whose daemonbin/fm-afk-launch.sh startlaunches (codex, opencode, omp, kimi, cursor, per .agents/skills/afk/SKILL.md step 2).supervisor_pane_ownedcompares the pane againstfm_daemon_primary_harness, which runsfm-harness.shinside the daemon.fm_afk_launch_create_tmuxandfm_afk_launch_create_herdr(bin/fm-afk-launch.sh:500 and :527) start the daemon asexec env FM_HOME=.. FM_SUPERVISOR_TARGET=.. FM_SUPERVISOR_BACKEND=.. fm-afk-start.shin a detached tmux session or Herdr workspace. No harness is among the daemon's parent processes, and codex, opencode, omp and kimi publish no environment marker, so detection returnsunknown. This was checked directly: fm-harness.sh run orphaned from any harness with a clean environment printsunknown.fm_agent_harness_family unknownreturns 1, sofm_backend_*_pane_harness_stateprintsforeign. Every digest is then refused withharness=unknown owner=foreignand stays buffered until the wedge alarm fires. Before this change those digests were delivered. tests/fm-afk-inject-herdr-e2e.test.sh masks the problem by pinning FM_DAEMON_PRIMARY_HARNESS=omp. Fix: in both launch paths, resolve the harness in the captain's context with the existingfm_afk_launch_primary_harnessand pass it as FM_DAEMON_PRIMARY_HARNESS in theexec envcommand, the same way FM_SUPERVISOR_TARGET is passed. Add a test that drives thestartpath without pinning the harness.🔧 Fix applied.
1 info still open:
bin/backends/herdr.sh:118- The intent says that on Herdr,agent getmust report the primary harness (for example agent == claude for a Claude primary) and the pane's process state must not be a shell.fm_backend_herdr_native_agent_namechecks the native name only for claude, codex and pi. For omp, opencode, kimi, cursor and grok,fm_backend_herdr_pane_harness_stateskipsagent getand relies on the foreground-process-group proof alone. That proof is stronger than "not a shell", so digests still cannot be typed into a shell for those harnesses; the only missing piece is the extra registration check. The change documents this as deliberate, because an unverified Herdr name would refuse every delivery. Please confirm that droppingagent getfor harnesses without a verified Herdr name is acceptable, or extend the verified list once those names are measured.✅ **Test** - passed
✅ No issues found.
bash tests/fm-afk-inject-e2e.test.sh(private tmux server, real zsh bare-❯ shell, Scenarios A-D)tests/fm-afk-inject-e2e.test.shrun against agit archive da71dc8copy of the pre-fix tree: Scenario D fails with 'the shell executed a command substitution from the digest', so the regression reproducesbash tests/fm-afk-inject-herdr-e2e.test.sh(real Herdr 0.9.0, isolated fm-lab session)bash tests/fm-afk-launch.test.sh(real herdr and tmuxstartpath under a codex-named captain, no pinned FM_DAEMON_PRIMARY_HARNESS)Live Herdr lab viabin/fm-herdr-lab.sh provision/run/teardown(session fm-lab-afkown-*): the real daemonescalate_flush(sourced from bin/fm-supervise-daemon.sh, FM_SUPERVISOR_BACKEND=herdr) was run against the operator's zsh with a bare ❯ prompt, fixed vs pre-fix codeLive lab: real Claude Code 2.1.280 trust dialog probe (owned, composer pending)Live lab: daemon flush into idle real Claude 2.1.280, which delivered the digest and got the reply ACKLive lab: daemon flush with Claude suspended via ctrl+z (zsh in the foreground)Live lab: daemon flush after Claude /exitLive lab: daemon flush with real codex-cli 0.154.0 in the pane while primary=claudePer-state probes offm_backend_pane_harness_stateandfm_backend_composer_statewith and without FM_COMPOSER_HARNESS=claude✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.