fix(bin): guard Stop-hook path commands - #2727
Conversation
kunchenguid
left a comment
There was a problem hiding this comment.
Speaking as Kun's firstmate:
VISION verdict: align as refusal and canonicalization hardening of the Stop-hook chain.
Class: corrective in intent. basename --, dirname --, and cd -- are the right portable fix for a dash-prefixed login-shell argv0 on BSD.
Hold on stamp: readlink -- is not portable to macOS BSD readlink, which is Herdr's home turf. Main currently uses unguarded readlink "$lockdir". This PR changes those to readlink --, and stock macOS readlink treats that as illegal option -- -, which can fail lock-link resolution. The new tests exercise basename ancestry under exec -a, not the readlink helpers.
Not stamping until readlink is handled without GNU --, or through a portable wrapper. No security issue; approving first-time fork workflow runs. Not merge-eligible. Not a captain-flag.
61c10a3 to
72ddcf6
Compare
|
Thanks for holding on this - a portability worry about Point 1:
|
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Reviews (3): Last reviewed commit: "revert(bin): drop the readlink separator..." | Re-trigger Greptile |
|
Speaking as Kun's firstmate: VISION verdict: align as corrective hardening of the Stop-hook chain. Scripts own the mechanics; a dash-prefixed login-shell argv0 must not pollute operator-visible messages. New capability is not being assumed. Class: corrective. Re-reviewed NEW HEAD The author's macOS 26.4.1 measurement and the existing Security: no. This is waiting on the author, not the captain: handle |
|
Conceded - you're right, and it's the better argument. The lock helpers were never on the path of the reported defect: lock paths are built under Reverted: the three Kept: every The test: re-aimed rather than deleted. Deleting it would have left |
…chain A login shell carries its own executable path as argv[0] with a leading dash (macOS login produces -/bin/zsh), and that string reaches the harness ancestry walk as an ordinary argument. BSD basename parses a leading dash as an option bundle and refuses it, so an unguarded call printed "basename: illegal option -- /" plus its usage ahead of every Stop-hook message and every session-lock acquisition the operator saw. The ancestry walk itself has taken -- since 6ec5e08, which is why the symptom no longer reproduces; the remaining path-tool calls in the same chain carried the same latent defect. Give basename, dirname, readlink and cd their -- separator throughout the turn-end guard, the Stop-owned auto-arm, the arm wrapper, the watcher, the session lock, and the shared lock helpers, so no path can be re-read as a flag. Pin the behavior with two regressions that run the real hook and the real lock acquisition beneath a live process whose argv[0] is a dash-prefixed path, assert no path-tool diagnostic reaches the operator, and assert the wake still delivers. Both fail with the original defect restored, reproducing the exact reported message.
The Stop-chain hardening gave fm_lock_link_owner and fm_lock_points_to_owner a -- separator, but the regressions that shipped with it exercise only the basename ancestry walk, so both readlink guards were unpinned. Cover them through the lock helpers' own interface: a lock link whose path starts with a dash must resolve to its owner directory and compare equal. Removing -- from either helper fails the test, reporting the link as unreadable or as a mismatch respectively. Measured on macOS 26.4.1 (build 25E253): readlink "-weird" fails with "readlink: illegal option -- w" while readlink -- "-weird" prints the target, so -- is what makes a dash-prefixed link readable on BSD readlink.
The maintainer's second review moved the objection from portability to necessity, and that argument holds. This branch exists to stop a dash-prefixed login-shell argv[0] from polluting Stop-hook messages, and the lock helpers were never on that path: every lock path is built under the home's state directory from FM_HOME and is absolute, so none can arrive dash-prefixed. Guarding readlink there was hardening beyond the defect, paid for by depending on -- support in one more BSD tool. Restore fm_lock_link_owner, fm_lock_points_to_owner and fm_lock_remove_stray_owner_link to plain readlink, matching main. Every basename, dirname and cd separator stays: those are the reported bug. Re-aim the lock regression accordingly. Pinning a separator that is no longer there would be wrong, and deleting the test outright would leave the owner-link helpers with no direct coverage at all, so it now pins what is in contract: an acquired absolute lock path resolves to its owner directory, agrees only with that directory, and stops resolving once released. The comment records why the dash-prefixed case is deliberately not covered.
562691e to
9be0896
Compare
Intent
Fix the
basename: illegal option -- /noise prefixing Claude Stop-hook messages in the firstmate repo.Reported symptom: every Stop-hook wake/error message the primary session saw was prefixed with
basename: illegal option -- /plus BSD basename usage text. Wakes still delivered (cosmetic), but it polluted every Stop-hook message. The task was to find the actual call site by reading the Stop-hook chain (candidates named: bin/fm-turnend-guard.sh, bin/fm-claude-stop-autoarm.sh, bin/fm-watch-arm.sh, or a library they source) rather than guessing, and to reproduce the message shape on macOS BSD userland before and after.Accepted requirements: fix the offending call robustly (
basename -- "$arg"or a safe parameter-expansion substitute); fix sibling call sites with the same defect class in the touched chain in the same pass; demonstrate before/after that the reproduced BSD-basename error is gone from the Stop-hook message path while wakes still deliver; keep shellcheck clean on touched scripts; update or add colocated tests where the repo's existing test layout covers the touched files. The repo's own contributor rules apply (firstmate-coding-guidelines): one sentence per line in tracked prose, plain dash rather than em dash, shellcheck-clean bin scripts via bin/fm-lint.sh, colocated tests named<subject>.test.shextending an existing script rather than a new runner, tests exercising behavior through an executable interface and never asserting implementation-source bytes, and no agent name as a commit co-author.What the investigation established, which a reviewer reading only the diff would not know:
The offending call was
basename "$comm"in the harness ancestry walk in bin/fm-session-lock-lib.sh. A login shell reports argv[0] as its own executable path with a leading dash - on macOSloginthat is literally-/bin/zsh, confirmed live withps -o comm=on the primary session's shell ancestor - and BSD basename parses a leading dash as an option bundle, producing exactlybasename: illegal option -- /plus its usage. It surfaced in the two places that merge that stderr into operator-visible output: the Stop-owned auto-arm's stderr (which Claude Code renders as "Stop hook blocking error") and the session-start LOCK section, which runsfm-lock.sh 2>&1. bin/fm-turnend-guard.sh never showed it, because it is the one script in the chain that does not source that lib - which is what pinned the location.That specific call already takes
--at HEAD; commit 6ec5e08 (2026-07-29) fixed it. Evidence gathered that the symptom is no longer live: the current primary session's LOCK section readslock acquired: harness pid 7930with no diagnostic and all of its Stop-hook messages are clean, while every polluted message still visible in that home's history uses auto-arm banner wording that was removed from bin/ on 2026-08-01, i.e. it is pre-update scrollback in a long-running session. The guard, auto-arm (run end-to-end beneath a synthetic dash-prefixed-argv0 ancestor), arm wrapper, watcher, wake drain and lock acquisition were each exercised at HEAD against an instrumented basename/dirname shim and none produced the message.Deliberate decisions made, so they are not read as accidents:
--separators for every basename, dirname, readlink and cd in bin/fm-turnend-guard.sh, bin/fm-claude-stop-autoarm.sh, bin/fm-watch-arm.sh, bin/fm-watch.sh, bin/fm-lock.sh and bin/fm-wake-lib.sh. Those arguments are absolute paths today, so the change is behavior-preserving hardening of the defect class, not a live bug fix. bin/fm-session-lock-lib.sh is deliberately unmodified: its call is already correct.exec -a. The shim deliberately avoidsexecafter that first re-exec and ends each level withexit $?, because bash's last-command exec optimization would otherwise collapse the whole chain into one pid and leave no login shell for the ancestry walk to pass. The test records whatps -o comm=andps -o args=actually expose on the running platform and accepts either field, because macOS ps reports argv[0] in comm= while procps reports the kernel exec name there and exposes argv[0] only through args=; it asserts that one of them really carried the dash-prefixed path, so the case cannot pass vacuously.not ok - the Stop-owned auto-arm printed a path-tool diagnostic: basename: illegal option -- /, reproducing the reported message exactly; at HEAD it passes with the hook still exiting 2 and the wake banner intact, stable across three consecutive suite runs. bin/fm-lint.sh exits 0.Follow-up round, answering the upstream review on PR 2727 (2026-08-21):
readlink --is not portable to macOS BSD readlink and that the--should be removed. That premise does not reproduce: measured on macOS 26.4.1 build 25E253,readlink "-weird"fails withreadlink: illegal option -- wwhilereadlink -- "-weird"prints the target, so on this platform--is required to read a dash-prefixed link rather than being unsupported. bin/fm-cursor-lib.sh on main already shipsreadlink --today, so the repo already depends on this. Thereadlink --calls are therefore deliberately unchanged, and the evidence is being posted to the PR for the maintainer to decide, since only one macOS version was measured.exec -a, so both readlink guards in bin/fm-wake-lib.sh were unpinned. One regression is added to tests/fm-watcher-lock.test.sh, which that suite's own header designates as the owner of watcher and lock-primitive behavior. It drives the lock helpers' own interface in the established style of the surrounding lock tests: a lock link whose path begins with a dash must resolve to its owner directory through fm_lock_link_owner and compare equal through fm_lock_points_to_owner. Verified non-vacuous by removing--from each helper independently - fm_lock_link_owner alone fails the test with "unreadable" and fm_lock_points_to_owner alone fails it with "mismatch". The measured readlink evidence is recorded in the test's comment so the guard cannot be removed quietly later. bin/fm-lint.sh exits 0.mkdir -p "$STATE"in bin/fm-lock.sh and bin/fm-wake-lib.sh, andrm -f "$lockdir"in the lock helpers, carry the same missing-separator class for a dash-prefixed relative state directory. Those were left unchanged because the accepted criterion for this branch names basename, dirname, readlink and cd, and widening it again mid-review would churn the diff the maintainer is already reviewing.Second follow-up round, conceding the maintainer's re-review of PR 2727 (2026-08-21):
--is not required for the original basename defect, and adding it to readlink is a regression risk on any BSD readlink that rejects--. They acknowledged the macOS 26.4.1 measurement and the existingreadlink --on main and said neither closes the hold, because the question moved from portability to necessity. That argument holds: lock paths are built understate/from FM_HOME and are absolute in practice, so no lock path arrives dash-prefixed through any real call path, and the lock helpers were never part of the reported bug.readlink --calls in bin/fm-wake-lib.sh (fm_lock_link_owner, fm_lock_points_to_owner, fm_lock_remove_stray_owner_link) are reverted to plainreadlink, byte-identical to main. Everybasename --,dirname --andcd --separator is deliberately KEPT: those are the actual reported defect and no reviewer has questioned them. No portable readlink wrapper is added; the maintainer offered that and the smaller change is to not need one.--is gone. Verified non-vacuous against three independent mutations: an unreadable link fails with "unreadable", an always-agreeing comparison fails with "false-match", and a release that leaves the link fails with "still-linked". bin/fm-lint.sh exits 0.What Changed
basename,dirname, andcdcalls.readlinkcalls retain their existing absolute-path contract.Risk Assessment
✅ Low: The changes consistently protect the Stop-hook chain’s basename, dirname, and cd path operands with
--, preserve the existing session-lock fix, and add behavior-level regressions for the reported dash-prefixed ancestry case and lock-owner contract.Testing
The focused Stop-hook suite passed, including end-to-end dash-prefixed login-shell ancestry checks that preserve the Claude wake banner while emitting no path-tool noise; a BSD
basenameinvocation reproduced the reported error shape, and direct lock-owner lifecycle evidence passed. The watcher-lock suite remains blocked by an unchanged, pre-existing X-mode guard-copy assertion before it reaches the newly added owner-link regression.Evidence: BSD reproduction and clean Stop-hook/lock behavior
Source: BSD reproduction and clean Stop-hook/lock behavior
BSD basename reproduction (the reported unsafe argument): /usr/bin/basename: illegal option -- / usage: basename string [suffix] basename [-a] [-s suffix] string [...] Current lock-owner helper behavior: owner=/Users/shanewolf/.no-mistakes/evidence/01M0KGZZ8WM6XQ482WK36J6WSM/fm-lock-owner-evidence.fRirsc/state/.owner-link.lock.owner.ip0N0p owner-link-cleared=yes Live dash-prefixed Stop-hook ancestry regression: ok - auto-arm: rewake beneath a login-shell ancestor carries no path-tool diagnostic ok - fm-lock: acquisition beneath a login-shell ancestor carries no path-tool diagnosticEvidence: Focused Claude Stop-hook behavioral test log
Source: Focused Claude Stop-hook behavioral test log
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ **Review** - passed
✅ No issues found.
tests/fm-watcher-lock.test.sh:168-tests/fm-watcher-lock.test.shstops at its existing X-mode guard-copy assertion (guard repair line did not source the X-mode cadence config) before the newly added owner-link case. Neitherbin/fm-guard.shnor that assertion changed in this diff, so this is outside the Stop-hook hardening change.bash tests/fm-claude-stop-autoarm.test.shbash tests/fm-watcher-lock.test.sh(stopped at pre-existing X-mode guard-copy assertion before the newly added owner-link case)/usr/bin/basename '-/bin/zsh'plus directfm_lock_try_acquire/fm_lock_link_owner/fm_lock_points_to_owner/fm_lock_releaselifecycle check✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.