Repository navigation
fix: allow provable directory changes within the firstmate home - #303
Merged
Merged
Conversation
The cd-guard denied every persistent top-level cd in the primary shell - 38 in 7 days - although only one of those moved the shell out of the home. A cd whose single target resolves inside the home now runs, while everything that leaves the home stays denied. The target is resolved under both path rules: logical, collapsing `..` lexically as Bash's default cd does, and physical, following symlinks component by component as under cd -P. Both must land inside the home, outside the projects tree and outside any nested git checkout, and the target must exist. Absolute, relative, `~`, and `$FM_HOME`/`$HOME` spellings resolve the same way as the absolute equivalent. Kept denied on purpose: anything under projects/, a home symlink that resolves into projects/ or out of the home, a treehouse or scratch worktree, /tmp (a firstmate command there resolves the wrong home), and any unresolvable target. Behaviour ported from kunchenguid/firstmate d42a699 (kunchenguid#8021), which exempts the home itself. docs/cd-guard.md owns the rule; tests/fm-cd-pretool-check.test.sh proves the allow and deny matrix across all five harness entry forms.
Round-one review findings, applied: - Resolve a relative target from the tracked directory, starting at the home and updated by every earlier cd this guard already allowed in the same command list. `cd data && cd ..` reaches the home; `cd data && cd state` is judged against data/state. - Pass the shell's real FM_HOME value instead of substituting the repo root. Bash expands an unset variable to the empty string, so the substitution turned a leave-the-home command into an allowed one; an empty value now denies every $FM_HOME form. - Deny a relative target when CDPATH is non-empty, either inherited or assigned on the command, because Bash consults it before the current directory and could send the target out of the home. - Drop the unrequested $HOME alias and the unused --home=/--fm-home= spellings; the required ~ and --fm-home forms are unchanged. docs/cd-guard.md records the tracked-directory rule, the real-value requirement, the CDPATH deny, and the remaining known limit: the tracked directory starts at the home for every tool call, so a shell already sitting deeper is not observable without a per-harness cwd fact this guard does not yet have.
Review rounds 2 and 3 kept reopening the same frontier: Bash semantics the exemption could not enumerate. Each fix produced siblings - conditional execution, logical versus physical PWD, CDPATH's first-component rule, variable mutation by arbitrary builtins, quoted and escaped tildes - and the mutation class cannot be closed by a lexical guard at all. An exemption is now granted only when nothing before the cd can change what it means: the first node of a command, or a chain of exempt cds joined by `;`, a newline, or `&&`. An exempt cd always succeeds (its destination must be an existing, searchable directory) and mutates nothing, so an exempt chain proves HOME, FM_HOME, and CDPATH were not reassigned and no conditional skipped a node. Every other earlier node withdraws the exemption, and a cd reached through `||` stays denied. Kept precise where the rule is cheap: both logical and physical destinations must be inside the home, outside projects/, and outside a foreign checkout; the reachable directory set advances through allowed cds and keeps previous entries when a redirection could fail; CDPATH is consulted for every relative operand except first components `.` and `..`; a leading tilde expands only when its prefix is unquoted and unescaped, decided per prefix rather than per word via a new additive lexer flag; FM_HOME must be absolute and is used verbatim; options and the special `-` are refused. Deliberate fail-closed refusals, documented in docs/cd-guard.md and the PR body: `cmd; cd data`, `cmd && cd data`, `cd a || cd b`, any option or `-`, and glob or substitution operands.
…ss shell expansions, prior nodes, and keyword compounds; remove unused escape metadata. Focused regressions pass
Review round 5 kept finding ways a word can become something other than its lexical text: word splitting, pathname expansion, assignment expansion inside redirections and heredocs, keywords hiding a command position. Patching them one at a time does not converge, so the exemption now depends on nothing the policy cannot prove. An exempt cd now requires all of: - nothing before it but an exempt cd chain joined by `;`, a newline, or `&&`, which an exempt cd never breaks; - no redirection, heredoc, or leading assignment on the node, so no expansion runs before the builtin; - no shell keyword compound anywhere in the command, which also catches a cd hidden behind `if`, `for`, `while`, `until`, or `time`; - an operand that needs no unprovable expansion: a literal path, an unquoted and unescaped leading `~`, or a quoted `$FM_HOME` prefix. The unquoted `cd $FM_HOME` form is now a documented refusal, because Bash may split or pathname-expand it; the quoted spelling replaces it and is covered by the suite. Every previously required allow form still passes: absolute, relative, `~`, quoted `$FM_HOME`, a home symlink, and the provable `cd data && cd ..` chains. docs/cd-guard.md states the operand forms, the proof requirement, and the full refusal list once; tests cover each refusal across all five harness entry forms.
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.
Summary
The cd-guard denied every persistent top-level
cdin the primary firstmate shell: 38 denials in7 days, of which one was the actual hazard (a
cdintoprojects/infoconnect-search-engine) and therest were the home itself,
/tmp, scratch worktrees,data/,state/, and$FM_HOME.It now exempts a
cdit can prove stays inside the home, and keeps denying everything else.Design source: upstream
kunchenguid/firstmatecommitd42a6997(kunchenguid#8021) added the home exemption(
cdTargetsHomeplus--home), which allows acdto the home itself. This PR ports the behaviour andits both-path-rules resolution, then extends it to a provable subset of the home.
Deliberately kept denied:
projects/, a home symlink that resolves intoprojects/or out of the home, atreehouse or scratch worktree, any other repo checkout, and
/tmp(a firstmate command run from thereresolves the wrong home);
cdthe policy cannot prove: after a non-cdcommand, through||, inside a pipeline, backgroundjob, subshell, or brace group, inside a shell-keyword compound, with a redirection, heredoc, or leading
assignment, with any option or the special
-, with a glob, substitution, or other variable, with morethan one operand, or with an unquoted
$FM_HOMEthat Bash could split or pathname-expand.Evidence
Before: every one of these was denied.
After: those run, and
cd projects/<clone>,/tmp, foreign worktrees, and every unprovable shape staydenied. Test evidence:
tests/fm-cd-pretool-check.test.shreports the acceptance matrix across all fiveharness entry forms plus the dedicated refusal, CDPATH, tilde-quoting, and Bash-oracle regressions.
Contract amendment
The original AC1 listed the unquoted
$FM_HOMEform as allowed. A quoted expansion is neither word-splitnor pathname-expanded, so only
cd "$FM_HOME..."is provably safe; the unquoted form is now a documentedrefusal and the quoted form replaces it.
docs/cd-guard.mdis the single owner of the rule and listsevery refusal once.
Merge Danger
Door: two-way. One hook script, one policy module, one document, and the test suite; nothing migrates
state or touches
projects/.Blast radius: one harness seatbelt. A regression can only refuse a shell command, and the refusal
message names the allowed
git -C/ absolute-path / subshell alternatives. Verified by the acceptancesuite across Codex, Claude, Grok, OpenCode, and Pi entry forms.
Pipeline
Updates from git push no-mistakes
✅ **Intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 5 issues found → auto-fixed ✅
bin/fm-cd-command-policy.mjs:192- An empty operand is resolved as the current directory, although Bash rejectscd "". With safe home/state and a checkout at home/data/state,cd data && cd "" && cd ..; cd statepasses: the policy checks home/state, while Bash skipscd ..and enters the checkout. The earlier R6/R16 success-proof fixes left this sibling unresolved. Reject empty operands before resolving them. Related sites: bin/fm-cd-command-policy.mjs:85 treats empty text as cwd; :217 records success; :294 advances the tracked cwd.bin/fm-cd-command-policy.mjs:138- The new exemption trusts cooked paths from a lexer that removes every backslash inside double quotes. Bash preserves backslashes before ordinary characters. With safe home/datadir and a checkout named home/data\dir,cd "data\dir"passes against datadir but enters the checkout. Preserve Bash's double-quote escape semantics in the shared lexer before comparing destinations. Related sites: bin/fm-arm-command-policy.mjs:408-415 removes the backslash; bin/fm-cd-command-policy.mjs:154 also consumes incorrectly decoded quoted FM_HOME suffixes.bin/fm-cd-command-policy.mjs:266- The latest keyword handling contradicts the accepted criterion “no shell keyword compound in the command.” The added!word.quoted && CD_BUILTINS.has(word.value)check missesif "cd" /tmp; then :; fi, allowing Bash to leave home. Conversely, it deniesif command -v cd; then :; fi, where cd is query data. The local check also permitscd data; if true; then :; fi, granting an exemption despite a later compound. Enforce the command-wide exemption restriction while distinguishing executed builtins from arguments and queries. Related sites: bin/fm-cd-command-policy.mjs:262 checks only the current node; :269 continues without detecting quoted builtins; :288 grants exemptions before inspecting later nodes. This is new behavior in 658ee8b, beyond the previously declined keyword-classification finding.bin/fm-cd-command-policy.mjs:157- Simplification: the latest narrowing rejects leading assignments and redirections at :206-207, leaving two unnecessary components: assignment-specific CDPATH evaluation at :159-164 and redirection-failure destination branching at :290-294. Neither can be reached by an exempt cd. Remove those branches, read the inherited CDPATH directly, and retain one logical/physical cwd pair instead of the unreachable alternative-state machinery.tests/fm-cd-pretool-check.test.sh:151- The accepted requirement says “Every refused shape has a deny test.” The expanded acceptance suite has no deny cases for glob operands, command-substitution operands, other variable operands, or multiple ordinary operands. Add executable guard cases such ascd data*,cd "$(printf data)",cd "$OTHER", andcd data state, using the accepted refusal contract as the oracle. These would reject accidental acceptance of each expressly forbidden shape.🔧 Fix applied.
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
python3 ~/.no-mistakes/evidence/01M796S134JFXVZXT5JVHEMWAY/drive-cd-guard.py— 77 real CLI probesbash --noprofile --norcdirectory checks using default logical resolution andset -Pbash tests/fm-cd-pretool-check.test.shandbash tests/fm-arm-pretool-check.test.sh— both exited 0Behavior-only copies of both focused suites, excluding embedded lint calls — both exited 0Removed disposable fixtures and temporary runners; confirmed clean working tree✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.