Skip to content

fix: allow provable directory changes within the firstmate home - #303

Merged
dnth merged 8 commits into
mainfrom
fm/fm-cd-guard-home
Nov 13, 2026
Merged

dnth merged 8 commits into
mainfrom
fm/fm-cd-guard-home

Conversation

@dnth

@dnth dnth commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

The cd-guard denied every persistent top-level cd in the primary firstmate shell: 38 denials in
7 days, of which one was the actual hazard (a cd into projects/infoconnect-search-engine) and the
rest were the home itself, /tmp, scratch worktrees, data/, state/, and $FM_HOME.
It now exempts a cd it can prove stays inside the home, and keeps denying everything else.

cd <target>                       (top-level, persists in the primary shell)
  proof gate
    ├ first node, or an exempt cd chain after `;` / newline / `&&`
    ├ no redirection, heredoc, or leading assignment on the node
    └ no shell keyword anywhere in the command
  operand
    ├ literal path (absolute or relative)
    ├ unquoted, unescaped leading `~`
    └ quoted `$FM_HOME` / `${FM_HOME}`
  destination
    ├ both path rules inside the home (logical `..`, physical symlinks)
    ├ outside `projects/` and outside every foreign git checkout
    └ an existing, searchable directory
  → allow, and open that directory for the next node
  otherwise → deny [persistent-cd]

Design source: upstream kunchenguid/firstmate commit d42a6997 (kunchenguid#8021) added the home exemption
(cdTargetsHome plus --home), which allows a cd to the home itself. This PR ports the behaviour and
its both-path-rules resolution, then extends it to a provable subset of the home.

Deliberately kept denied:

  • anything under projects/, a home symlink that resolves into projects/ or out of the home, a
    treehouse or scratch worktree, any other repo checkout, and /tmp (a firstmate command run from there
    resolves the wrong home);
  • any target that cannot be resolved, and any destination that is not an existing, searchable directory;
  • any cd the policy cannot prove: after a non-cd command, through ||, inside a pipeline, background
    job, 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 more
    than one operand, or with an unquoted $FM_HOME that Bash could split or pathname-expand.

Evidence

Before: every one of these was denied.

cd /home/<user>/Desktop/firstmate      → denied
cd data                                 → denied
cd ~/data                               → denied
cd "$FM_HOME/data"                      → denied

After: those run, and cd projects/<clone>, /tmp, foreign worktrees, and every unprovable shape stay
denied. Test evidence: tests/fm-cd-pretool-check.test.sh reports the acceptance matrix across all five
harness entry forms plus the dedicated refusal, CDPATH, tilde-quoting, and Bash-oracle regressions.

Contract amendment

The original AC1 listed the unquoted $FM_HOME form as allowed. A quoted expansion is neither word-split
nor pathname-expanded, so only cd "$FM_HOME..." is provably safe; the unquoted form is now a documented
refusal and the quoted form replaces it. docs/cd-guard.md is the single owner of the rule and lists
every 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 acceptance
suite 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 rejects cd &#34;&#34;. With safe home/state and a checkout at home/data/state, cd data &amp;&amp; cd &#34;&#34; &amp;&amp; cd ..; cd state passes: the policy checks home/state, while Bash skips cd .. 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 &#34;data\dir&#34; 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 &amp;&amp; CD_BUILTINS.has(word.value) check misses if &#34;cd&#34; /tmp; then :; fi, allowing Bash to leave home. Conversely, it denies if command -v cd; then :; fi, where cd is query data. The local check also permits cd 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 as cd data*, cd &#34;$(printf data)&#34;, cd &#34;$OTHER&#34;, and cd 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.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Use absolute, relative, tilde, quoted FM_HOME, and symlink paths into a disposable home; guard allows and Bash reaches the expected directory ✅ pass live Live transcript: spellings
Chain safe directory changes with semicolons, newlines, or &&; subsequent paths resolve correctly ✅ pass live Live transcript: chains
Attempt projects, nested repositories, linked scratch worktrees, outside paths, missing directories, and divergent symlink paths; guard denies ✅ pass live Live transcript: hazards, including physical_rule_stdout
Attempt unprovable command shapes, expansion operands, empty operands, quoted backslashes, and CDPATH redirection; guard denies ✅ pass live Live transcript: refusals
Submit CLI and harness-shaped JSON payloads; receive correct decisions while scoped commands and linked-worktree scope remain allowed ✅ pass live Live transcript: transport
  • python3 ~/.no-mistakes/evidence/01M796S134JFXVZXT5JVHEMWAY/drive-cd-guard.py — 77 real CLI probes
  • bash --noprofile --norc directory checks using default logical resolution and set -P
  • bash tests/fm-cd-pretool-check.test.sh and bash tests/fm-arm-pretool-check.test.sh — both exited 0
  • Behavior-only copies of both focused suites, excluding embedded lint calls — both exited 0
  • Removed disposable fixtures and temporary runners; confirmed clean working tree
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

dnth added 8 commits November 13, 2026 09:01
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.
@dnth
dnth merged commit a17fad0 into main Nov 13, 2026
21 checks passed
@dnth
dnth deleted the fm/fm-cd-guard-home branch November 13, 2026 02:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant