Skip to content

Track cd/pushd working directories in the Bash path guard (#568) - #579

Merged
Kewton merged 2 commits into
developfrom
feature/issue-568-security-tools-bash-cd-pushd-cwd-symlink-509
Oct 3, 2026
Merged

Kewton merged 2 commits into
developfrom
feature/issue-568-security-tools-bash-cd-pushd-cwd-symlink-509

Conversation

@Kewton

@Kewton Kewton commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Closes #568

What changed

  • src/tools/bash_write_guard/working_directory.rs (new) tracks the working directory a command can reach after cd, pushd and popd. It keeps a set of candidates.
    • The set starts at the workspace root. Each cd or pushd with a resolvable target adds that target to every candidate; nothing is ever replaced. Subshells, pipelines and || may or may not restore the directory, and keeping the root covers both cases without emulating them.
    • A target is resolvable when it is literal: no $, backticks, ~, glob, - or ...
    • popd, pushd ±N and a bare pushd add no candidates. pushd -n DIR adds DIR.
    • The candidate set is unresolvable in these cases:
      • the command contains CDPATH or DIRSTACK
      • cd, pushd or popd is combined with a loop or a function definition
      • the set grows past 64 candidates. Each chained cd doubles the set, so this cap prevents exponential work before sh starts.
  • src/tools/bash_write_guard.rs:
    • Relative write targets and each cd/pushd target are checked against every candidate.
    • When the candidate set is unresolvable, any relative write target is rejected (honest failure).
    • pushd is recorded as a working directory target.
    • protected_path_mutation also matches relative targets against each candidate. cd tests && cp a.txt spec.rs is now detected.
  • src/tools/bash.rs:
    • The stage-2 path check uses the same candidates for relative read candidates. When the candidate set is unresolvable, it rejects every relative read word. This closes cd sub && cat link/secret and CDPATH=sub cd link && cat secret.
    • CDPATH is removed from the environment of the sh -c process, so the runtime cannot resolve cd differently from the static check.
  • src/tools/bash_write_guard/command_prefix.rs: pushd and popd are added to the write programs that the unresolvable-prefix scan looks for.
  • tests/issue568_bash_cwd_write_targets.rs (new): integration tests that call path_confinement_rejection.

What did not change

  • shell_tokens, ShellToken and the command_prefix resolution.
  • The ANSI-C quoting scan at the start of write_targets.
  • The candidates are not added as WriteTargets. So write detection and the bash:verify auto-allow do not change: cd frontend && npm test and cd crates/x && cargo test are still auto-allowed.
  • Event names and schemas. Rejections reuse existing operation values.

Verification

  • Runner verify: 15/15 pass on 175b61da.
  • Before/after: on 3581d777, 4 of 5 new integration tests fail. After the review fix, the DIRSTACK/CDPATH test fails on b5bea876, and the candidate-cap test does not return within 15 minutes there. All 7 pass on 175b61da.
  • cargo mutants (-- working_directory, no --lib filter, --jobs 2):
    • First run: 94 mutants, 76 caught.
    • The 9 survivors were stricter-side or unused-value mutants. Tests were added, and all of them are caught now.
    • Fix diff: 27 mutants, 21 caught. The remaining 4 shift the cap comparison by one step (64 vs 128 candidates). That is stricter and still bounded.
  • Merge-gate review (Claude_Sub):
    • The first review found 2 blockers. Rewriting DIRSTACK sent popd outside the workspace. A relative read was allowed after CDPATH. It also found the exponential candidate growth.
    • All three are fixed. The recheck reports 0 blockers.
    • 1760 earlier table rows were re-run: 0 changed from reject to allow. Write detection, protected-path detection and auto-allow did not change, except the intended protected-path detection after cd.
  • Unchanged and still passing: issue575_*, issue566_*, issue567_*, issue428_*, bash_workspace_confinement.

Notes

Developed with the CommandMate parallel-dev harness (PM: Claude Code, leader: Claude_Dev, workers: Command Code).

🤖 Generated with Claude Code

Kewton and others added 2 commits October 3, 2026 11:21
The Bash write-target guard judged relative paths against the workspace
root only, so `cd sub && tee link/f` (and readers such as
`cd sub && cat link/secret`) traversed a `sub/link` symlink out of the
workspace, `pushd`/`popd` were not recognized, and CDPATH could redirect a
literal `cd`. Add a working-directory leaf module that builds the union of
the directories the command could run in (root plus each determinable
cd/pushd destination, appended not replaced), refuses a relative write that
escapes from any candidate, refuses relative paths after an undecidable cd
(CDPATH, loop, function), matches protected paths against the candidates,
judges stage-2 relative reads the same way, and drops CDPATH from the
`sh -c` environment.

Two-point measurement: on the base tree the four new reject tests fail
(e.g. "expected rejection: cd sub && tee link/f"); with the change all five
pass. Mutation run (working_directory + wiring, 30-minute cap) was
truncated after 10 of 94 mutants; the 10 observed survivors are equivalent
under the test set and are explained in the worker report.

読み替え: the issue's problem table lists `cd sub && grep -r x link` as a
read that must be rejected, but the referenced H-01 report defers bare
words without `/` to #571. To satisfy the issue's 拒否する形 list, stage 2
also joins literal relative words onto every candidate; this only rejects
paths that actually escape.
判断: `pushd` with no operand and `pushd ±N` record no working-directory
target (they only rotate the stack); `cd` with no operand keeps its existing
`~` rejection.
本文に無い指摘: `env cd sub && tee elink/f` is refused by the existing
root-relative per-target check (root/elink escapes), not by the new
candidate check.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
B1: `DIRSTACK[1]=sub/link` rewrites bash's directory stack, so a later
`popd`, `pushd ±N`, or bare `pushd` can leave the workspace. A word that
contains `DIRSTACK` now marks the working directory undecidable, like
`CDPATH`.

B2: after an undecidable `cd`/`pushd` (CDPATH, DIRSTACK, loop, or function),
a command such as `CDPATH=sub cd link && cat secret` had its relative read
words joined onto the candidates without the undecidable check. Stage two
now refuses when the change is undecidable and at least one relative read
word remains. Read words are taken only from segments that are not a
working-directory change, so a pure `cd d0; cd d1; …` chain stays allowed
while an absolute/home write under an undecidable cd keeps its old reason.

Candidate cap: consecutive distinct `cd` destinations can double the set at
each step. Growth now stops past 64 candidates and the destination becomes
undecidable, and duplicate checks go through a `HashSet` so the check stays
cheap at the cap.

Tests: new reject integration cases for the DIRSTACK forms and for
`CDPATH=sub cd link && {cat,grep} …`, a timed reject for
`cd d0; …; cd d39; tee f` (returns under a second) with its no-write allow,
plus unit tests for the absolute/home undecidable skip, the protected-path
absolute/home skip, `pushd` write targets, and a popd loop.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
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