Skip to content

feat(cd): add cd builtin with -L/-P symlink modes#237

Merged
julesmcrt merged 19 commits into
mainfrom
cd-builtin
May 13, 2026
Merged

feat(cd): add cd builtin with -L/-P symlink modes#237
julesmcrt merged 19 commits into
mainfrom
cd-builtin

Conversation

@julesmcrt

Copy link
Copy Markdown
Collaborator

Summary

  • Adds the cd builtin so rshell scripts can change directory inside AllowedPaths. Targets outside the sandbox are rejected; the working directory and \$PWD/\$OLDPWD are mutated atomically only after sandbox.Stat confirms the target is a directory.
  • Supported flags: -L (default, lexical ..), -P (physical, walks symlinks via the same sandbox-safe pattern as pwd -P), -h. Last-wins between -L/-P mirrors bash.
  • Operands: bare DIRECTORY, - (changes to \$OLDPWD and prints the new path), no-arg (uses \$HOME). Multi-operand and empty operands are rejected. -e, -@, CDPATH, ~user, and cdable_vars are intentionally unsupported.

The framework gains two CallContext callbacks — ChangeDir(absDir) error and LookupEnvVar(name) (string, bool) — the minimum surface needed for a builtin that mutates runner state, without exposing a generic env-write hook.

Test plan

  • go test ./... (all green; new builtins/cd/ package at 85% coverage)
  • Scenario tests in tests/scenarios/cmd/cd/{basic,flags,errors,hardening,help}/
  • Pentest probes: path traversal via .., escape via cross-root symlinks, unknown flags, flag injection via expansion, dash-prefixed filenames, FD-leak under 1000-iteration loops, stdin-ignored, no-filesystem-write
  • Fuzz seeds (FuzzCdFlags, FuzzCdPath) with corpora drawn from impl edge cases, CVE-class shapes, and existing test coverage; CI entry added to .github/workflows/fuzz.yml
  • RSHELL_BASH_TEST=1 (Docker-backed bash compatibility) — could not run locally because the bind-mount under t.TempDir() is outside Docker Desktop's allow-list on this machine; please verify on CI
  • make fmt clean
  • go run ./cmd/rshell --allow-all-commands -c 'help cd' shows the new entry; SHELL_FEATURES.md updated alphabetically between cat and continue

Notes

  • Removed tests/scenarios/shell/blocked_commands/builtins_and_features/cd.yaml — that scenario asserted cd was an unknown command, which is no longer the case. A new tests/scenarios/cmd/cd/errors/no_allowed_paths.yaml covers the "cd with no sandbox" failure path.
  • cd -P deliberately does not apply CanonicalizeRootPrefix. Unlike pwd -P, which uses canonicalisation for display, cd needs the working directory in the configured-root form so subsequent sandbox operations resolve under the same root. Documented in resolvePath.

🤖 Generated with Claude Code

julesmcrt and others added 4 commits May 7, 2026 14:38
Adds cd as a sandboxed builtin so scripts can navigate inside their
AllowedPaths. Targets outside the sandbox are rejected; the working
directory and $PWD/$OLDPWD are updated atomically only after
sandbox.Stat confirms the target is a directory.

The framework gains two CallContext callbacks (ChangeDir,
LookupEnvVar) — the minimum surface for cd to mutate runner state
without exposing a generic env-write hook.

Supported flags: -L (default, lexical ..), -P (physical, walks
symlinks via the same sandbox-safe pattern as pwd -P), -h.
Operands: bare DIRECTORY, '-' (OLDPWD, prints new path), no-arg
(HOME). Multi-operand and empty operands are rejected. -e, -@,
CDPATH, ~user, and cdable_vars are intentionally unsupported.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Three CI fixes:

1. Windows: backslashes in absolute paths are consumed as escape
   characters when embedded in shell scripts (cd C:\Users\...
   becomes cd C:Users... after parsing). Add a shPath helper that
   converts paths to forward slashes for shell-script use; Windows
   file APIs accept both separators, and the assertion side keeps
   filepath.Join (native separator) since pwd output uses the host
   form.

2. cd --logical / cd --physical scenarios diverge from bash, whose
   builtin only accepts -L / -P. Mark logical_long.yaml and
   physical_long.yaml as skip_assert_against_bash; the long forms
   are an intentional rshell extension and the short-form
   scenarios still run against bash.

3. TestCdPentestEscapeViaAbsoluteRoot does `cd /`, which on Windows
   is not absolute (no drive letter) so cd treats it as a relative
   no-op rather than an escape attempt. Skip the test on Windows;
   drive-root escape is exercised separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Windows escapes the bare backslash in the shell parser and '/' is
not an absolute path on Windows, so the test's premise (cd to the
absolute root being rejected by the sandbox) does not apply.
Drive-root semantics differ enough that a separate Windows test
covers the equivalent behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`cd /` is a no-op on Windows because `/` is not an absolute path
without a drive letter, so the scenario succeeded on Windows
runners and failed the test. Use `cd ../../../...` instead, which
escapes the AllowedPaths root with `..` segments on every platform
and is uniformly rejected by the sandbox with permission denied.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@julesmcrt julesmcrt added the verified/analysis Human-reviewed static analysis changes label May 7, 2026
@julesmcrt

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 715ee222ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread interp/runner_exec.go Outdated
julesmcrt and others added 2 commits May 7, 2026 16:10
Doubled scenario coverage and dropped skip_assert_against_bash on 5
error scenarios where the observable contract (exit code + cd:
stderr prefix + operand) matches bash even though the wording
diverges (bash prepends 'bash: line N:' and capitalises 'No such',
'Not a').

New basic scenarios cover form combinations that had no coverage:
chain_three_cds, trailing_slash, leading_dot_slash, dotdot_in_middle,
dotdot_chain, dash_toggles, dash_prints_destination,
no_stdout_on_success, oldpwd_chain, pwd_after_chain, in_for_loop,
in_command_substitution, in_or_chain, in_and_chain,
relative_then_absolute, cd_dir_with_space.

New flag scenarios cover combined-form parsing: LP_combined,
PL_combined, L_with_dotdot, dash_dash_then_dir, L_then_dash_dash,
P_then_dash_dash, L_with_dash, P_with_dash.

New hardening scenarios cover side-effect contracts:
no_filesystem_writes, in_pipeline_subshell,
oldpwd_isolated_in_subshell, cd_then_relative_file_resolves.

Skip rate: 53% → 18% (11 of 58 still skip; remaining skips are
sandbox-specific cases or rshell-only flag forms not understood by
bash).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Behaviour of `cd ""` is undefined by POSIX and varies between bash
versions: macOS bash 3.2 errors "cd: null directory" exit 1 while
debian-bookworm bash 5.2 silently treats it as cd-to-HOME exit 0.
Our shell always rejects it for predictable script behaviour, so we
cannot agree with both bash versions. Documented inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@julesmcrt

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6659ca9767

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread interp/runner_exec.go Outdated
Comment thread interp/cd_support.go
julesmcrt and others added 2 commits May 7, 2026 16:54
…n check

Two correctness fixes from review:

1. cd inside a sub-command spawned by RunCommand (find -exec,
   find -execdir, xargs) was wired to the parent runner's
   r.changeDir, so a sub-command could mutate the parent shell's
   working directory and $PWD/$OLDPWD. bash forks a process for
   each invocation, so cd in -exec is silently isolated. We model
   the same isolation by leaving ChangeDir nil in the child
   CallContext — cd in -exec now exits 1 with "cd: not supported
   in this runner" rather than leaking. Stricter than bash's silent
   fork but predictable.

2. changeDir committed the cd even when the target directory had no
   search/execute permission, so `cd nox` (mode 0644) would succeed
   while bash refuses with "Permission denied". Added a
   sandbox.Access(..., 0x01) check after the IsDir() guard; matches
   bash byte-for-byte for this case.

New tests:
- builtins/cd/cd_unix_test.go: TestCdNoSearchPermissionRejected
  locks down the permission-denied behaviour (Unix only; root is
  skipped because it bypasses permission bits).
- tests/scenarios/cmd/cd/hardening/cd_in_find_exec_isolated.yaml:
  verifies that find -execdir cd cannot mutate the parent's pwd.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The new sandbox.Access(cleaned, r.Dir, 0x01) check unconditionally
denies execute on Windows because allowedpaths.accessCheck has no
POSIX execute bits to inspect there — the result was that every cd
on Windows failed silently. Skip the check when runtime.GOOS ==
\"windows\". Directory traversal is governed by ACLs that os.Root
has already honoured, so the IsDir() guard is sufficient on Windows.

The Unix permission contract (and the new
TestCdNoSearchPermissionRejected covering it) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@julesmcrt

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b62640fdd4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread builtins/cd/cd.go
bash 5.2 treats `cd /tmp -P` as two operands and exits 1 with "too
many arguments". pflag's default interspersed parsing was silently
consuming the trailing -P as a flag and committing the cd, hiding
malformed scripts. Disable interspersed parsing on the cd FlagSet
so the first positional terminates flag parsing — any subsequent
token (flag-shaped or not) flows through the >1-positional check.

Also drops skip_assert_against_bash on too_many_args.yaml: bash 5.2
exits 1 here (not 2 as bash 3.2 on macOS does), so the scenario can
be compared against the CI bash now. Added a new scenario
flag_after_directory_rejected.yaml covering the regression directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@julesmcrt

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f75e89b692

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread interp/cd_support.go Outdated
Two related corrections so `PWD=/bogus cd b` matches bash byte-for-byte:

1. cd's OLDPWD source is the env var $PWD, not the runner's
   tracked r.Dir. They normally agree, but a temporary assignment
   like `PWD=/bogus` in front of cd makes them diverge — bash
   captures /bogus into OLDPWD, while we were capturing the real
   pre-cd dir. Read $PWD from r.writeEnv first, fall back to r.Dir
   only if unset.

2. cd intentionally writes $PWD and $OLDPWD as part of its
   semantics, but runner_exec.go's deferred temp-assignment revert
   was clobbering those updates after the call returned. Skip the
   revert for PWD/OLDPWD when the dispatched command was a
   *successful* cd. A failed cd still reverts normally so the temp
   assignment doesn't leak.

After both fixes:
  PWD=/bogus cd b → PWD=/parent/b OLDPWD=/bogus     (matches bash)
  PWD=/bogus cd nope → PWD reverts to original       (matches bash)

Two new scenarios in tests/scenarios/cmd/cd/basic/ lock in both
behaviours and run against the bash compat job.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@julesmcrt

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d0c288ec19

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread builtins/cd/cd.go Outdated
Comment thread builtins/cd/cd.go Outdated
bash distinguishes \"unset\" from \"set to empty string\" for HOME and
OLDPWD. We were rejecting both, which broke scripts that
temporarily clear those vars (HOME= cd, OLDPWD= cd -). Drop the
empty check from resolveOperand and short-circuit on an empty
target in the handler:

  HOME= cd     → no-op success, pwd unchanged       (matches bash 5.2)
  unset HOME; cd → \"cd: HOME not set\" exit 1        (matches bash)
  OLDPWD= cd - → blank line + no-op success         (matches bash 5.2)
  unset OLDPWD; cd - → \"cd: OLDPWD not set\" exit 1  (matches bash)
  cd \"\"        → no-op success, pwd unchanged       (matches bash 5.2)

Scenarios added (all run against bash compat):
  basic/empty_operand_is_noop.yaml
  basic/home_empty_is_noop.yaml
  basic/oldpwd_empty_dash_prints_blank.yaml

Removed errors/empty_operand.yaml (the contract is no longer an
error). Replaced TestCdEmptyOperandRejected / TestCdPentestEmptyExpansion
with no-op assertions to lock the new contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@julesmcrt

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f714a964b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread builtins/cd/cd.go Outdated
Comment thread builtins/cd/cd.go Outdated
julesmcrt and others added 2 commits May 11, 2026 14:17
Two bash-compatibility fixes flagged in review:

1. Per-component validation before ".." collapse. Previously cd
   relied on filepath.Clean (and filepath.Join for relative
   targets), which lexically erases ".." segments before any
   filesystem check. As a result `cd no-such/..` and
   `cd file/..` silently succeeded, while bash refuses both
   (bash POSIX: prefix must be an existing directory before .. is
   processed).

   Rewrote resolvePath to walk path components left-to-right and
   StatFile the accumulated prefix on every "..", erroring out on
   ENOENT / ENOTDIR. The same walker handles -L and -P; -P also
   expands symlinks. Above-sandbox paths still pass through
   opaquely (the final ChangeDir catches anything that lands
   outside).

   Switched the relative-path join in the handler to a raw
   concatenation that preserves "..", since filepath.Join calls
   Clean.

2. Empty-target cd now refreshes $PWD / $OLDPWD. Previously the
   short-circuit on empty target returned before ChangeDir, so
   `cd child; cd ""; cd -` jumped back to the *parent* instead of
   staying in `child` (the stale OLDPWD from the first cd). The
   handler now redirects an empty target to cwd and lets ChangeDir
   update the env vars normally; the `cd -` form still prints a
   blank line.

Allowlist additions: io/fs.ErrPermission (used for above-sandbox
passthrough), strings.HasSuffix (used by the manual path-join).

Three new bash-compared scenarios:
  errors/dotdot_after_missing.yaml
  errors/dotdot_after_file.yaml
  basic/empty_cd_refreshes_oldpwd.yaml

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The new per-component walker splits on filepath.Separator. On
Windows that's '\\', so `cd no-such/..` (forward-slash form) was
seen as a single component "no-such/.." and the intermediate-
component check never fired — the two new bash-compared scenarios
(dotdot_after_missing, dotdot_after_file) failed on the Windows
runner with exit 0 instead of 1.

Call filepath.FromSlash on absPath at the top of resolvePath: it
converts '/' to the OS separator without invoking filepath.Clean,
so '..' survives and the walker splits cleanly. On Unix it's a
no-op so nothing changes there.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@julesmcrt

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e816239bbd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread interp/cd_support.go Outdated
julesmcrt and others added 2 commits May 11, 2026 18:01
setVarString stashes failures on r.exit.code as a side-effect, but
the cd builtin's Result{} immediately clobbers r.exit.code in
runner_exec.go. As a result, hitting MaxTotalVarsBytes during cd's
own PWD/OLDPWD updates would print the storage-cap error but still
report cd as exit 0 — scripts continued past a failed cd with a
stale $PWD while r.Dir had already moved.

Switched the two setVarString calls in changeDir to setVarErr and
return the error. cd's handler already maps a ChangeDir error to
Result{Code: 1}, so the failure now surfaces correctly.

TestCdReportsVarStorageCapFailure pre-fills the env to
MaxTotalVarsBytes and verifies cd exits 1 with the storage-cap
message on stderr.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two cleanups so the new TestCdReportsVarStorageCapFailure passes
on every CI runner:

1. gofmt removed a trailing blank line that slipped in when the
   test file was written.
2. Windows builds the temp path with backslashes, and embedding
   that path verbatim into the shell script body lets the parser
   eat the backslashes as escape chars ("C:Users..." — sandbox
   then reports "path escapes from parent"). Pipe `child`
   through filepath.ToSlash before printing it into the script;
   Windows file APIs accept '/' so the cd succeeds-then-fails on
   the var-storage cap as intended.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@julesmcrt

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7380084c9b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread interp/cd_support.go Outdated
julesmcrt and others added 2 commits May 11, 2026 18:59
Previously changeDir set r.Dir = cleaned *before* writing $PWD and
$OLDPWD via setVarErr. If the var-storage cap rejected either
write, changeDir returned an error and the cd builtin reported
exit 1 — but r.Dir had already moved to the new directory, so
subsequent relative file operations resolved against the new dir
while $PWD/$OLDPWD still pointed at the old one. bash never
mutates pwd on a failed cd.

Reorder so the env writes are attempted first:

  1. Capture the previous OLDPWD value for rollback.
  2. setVarErr("OLDPWD", oldDir). On error → return; nothing mutated.
  3. setVarErr("PWD", cleaned). On error → restore the old OLDPWD
     via setVarRestore (cap-bypassing) and return; r.Dir unchanged.
  4. Commit r.Dir = cleaned only after both writes succeed.

Extended TestCdReportsVarStorageCapFailure to assert
`POST_PWD=$dir` after the failed cd, so a regression would surface
immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@julesmcrt

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@julesmcrt
julesmcrt marked this pull request as ready for review May 12, 2026 09:57

@AlexandreYang AlexandreYang left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — cd builtin (PR #237)

Scope: New cd builtin (builtins/cd/), framework callbacks (ChangeDir, LookupEnvVar), runner integration in interp/cd_support.go and interp/runner_exec.go, plus 58 scenario tests, 11 hardening scenarios, fuzz seeds, and Go unit / pentest / bash-compat tests.

Overall: Carefully engineered. The implementation walks paths component-by-component through the sandbox's Stat/Lstat/Readlink callbacks (no direct os.* filesystem calls), respects bash semantics for -L/-P last-wins flag ordering and cd "" no-op refresh, applies env writes before committing r.Dir, isolates cd in RunCommand children (find -exec, xargs), skips the execute-bit check on Windows where os.Root ACL checks cover it, and bounds symlink expansion at 40 hops. Test coverage is extensive — 58 scenarios with most asserting against bash, plus pentest + fuzz + bash-compat Go tests.

No P0/P1 findings. A small number of P2/P3 observations follow as inline comments — primarily around documentation/test surface area for subtle sandbox-spanning interactions.

Summary of findings

# Priority File Finding
1 P2 Badge builtins/cd/cd.go:384-388 -P mode does not resolve above-sandbox symlinks (Lstat ErrPermission → opaque pass-through). Documented locally; worth promoting to the package docstring as a sandbox-spanning invariant.
2 P2 Badge builtins/cd/cd.go:412-416 HostPrefix re-prefix check matches hp+sep — a target whose canonical path collides with a sibling of the host prefix can get re-prefixed. Worth a hardening test if HostPrefix is in scope.
3 P2 Badge tests/scenarios/cmd/cd/errors/ Coverage gap — no scenario asserts that HOME=/outside-sandbox cd is rejected with permission denied. Current tests only cover HOME unset and HOME empty.
4 P3 Badge builtins/cd/cd.go:144-147 The not supported in this runner message is correct but technical — under find -execdir cd ... users will see this when the cause is intentional isolation. Consider phrasing that hints at the cause.
5 P3 Badge interp/cd_support.go:65-69 Windows access-check skip is correctly documented (os.Root handles ACLs). Optional: a Windows-specific test that exercises a sandbox-rooted denied directory would close the loop.

Positive observations

  • All filesystem reads in resolvePath go through callCtx.StatFile / LstatFile / ReadlinkFile. No direct os.* filesystem calls in builtin code.
  • changeDir orders env writes before committing r.Dir, with explicit OLDPWD rollback on PWD write failure — verified by the dedicated TestCdReportsVarStorageCapFailure Go test against the MaxTotalVarsBytes cap.
  • Per-component .. validation (line 348) correctly rejects cd file/.. and cd no-such/.., matching bash 5.2 — both directly tested.
  • boolSeqFlag uses a private sentinel for NoOptDefVal so cd --physical=true is rejected, matching bash.
  • RunCommand child contexts (find -exec, xargs) set ChangeDir: nil to isolate cd from leaking into the parent shell — verified by cd_in_find_exec_isolated.yaml.
  • The pipeline subshell case (in runner_exec.go) uses subshell(true) so cd inside (...) or a pipeline stage mutates only the child's r.Dir.
  • Fuzz seeds cover flag injection, control-character rejection (via shellSafe), long inputs, CVE-class shapes; both FuzzCdFlags and FuzzCdPath assert no panics and only valid exit codes.

Comment thread builtins/cd/cd.go
Comment thread builtins/cd/cd.go
Comment thread builtins/cd/cd.go
Comment thread tests/scenarios/cmd/cd/errors/home_unset.yaml
Comment thread interp/cd_support.go
if err := r.sandbox.Access(cleaned, r.Dir, 0x01); err != nil {
return err
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Windows access-check skip — consider an explicit Windows test

The rationale (os.Root honours ACLs at open time, accessCheck always denies execute on Windows) is sound. The Windows test file (cd_windows_test.go) covers reserved-name rejection, NTFS case-insensitivity, and forward-slash paths, but I didn't see a test that exercises a sandbox-rooted directory the current user lacks traversal rights on.

Not blocking — Windows ACL test setup is fiddly. Just flagging that the documented invariant (os.Root ACL enforcement at open) isn't directly asserted here. The Unix counterpart is TestCdNoSearchPermissionRejected.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Opus 4.7 (1M context)] Acknowledged — leaving this open. As you noted, the invariant is enforced by os.Root's ACL handling at open time so the on-the-wire contract is correct, but a Windows-specific regression test would close the loop. Holding off because (a) reliable Windows ACL test setup needs DENY ACEs that GH-actions runners may not allow under the default user, and (b) the macOS/Linux TestCdNoSearchPermissionRejected covers the Unix counterpart of the same contract. Happy to add it if you have a pattern that worked elsewhere in the repo.

Comment thread interp/runner_exec.go
// the new dir afterwards). The skip is scoped to a
// successful cd so a cd that errored still gets its
// temp PWD assignment reverted normally.
isCd := len(fields) > 0 && fields[0] == "cd" && r.exit.ok()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge isCd defer skip is keyed on the literal command name

The check fields[0] == "cd" is fine today because rshell has no aliases, no command cd ... indirection, and no function-keyword name override. If any of those are added later, the defer-skip would silently regress (a successful cd reached via the new path would revert the env writes it just made).

Not actionable today — just flagging it as a low-priority invariant to test if the feature surface ever grows.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Opus 4.7 (1M context)] Agreed — this is forward-looking and not actionable today, leaving the thread open. The fields[0] == "cd" literal check would silently regress if rshell ever grew aliases, command cd, or shell-function name override. Worth a // TODO comment near that defer if/when one of those surfaces; for now flagged so future contributors know the invariant.

julesmcrt and others added 2 commits May 12, 2026 17:11
…cs; close coverage gaps

Four review items from @AlexandreYang on PR #237 addressed in one
commit:

1. Promote the -P-above-sandbox passthrough invariant from a local
   comment into the package docstring so callers can reason about
   when cd -P diverges from bash on container-style sandboxes.

2. Improve the diagnostic emitted when ChangeDir is nil (the
   RunCommand-child case used by find -exec / -execdir / xargs).
   Was "cd: not supported in this runner" — generic and opaque to
   users. Now reads "cd: cannot change directory from inside
   find -exec/-execdir or xargs (child invocations are isolated)"
   so the user knows the cause is intentional isolation.

3. Add a scenario for HOME pointing outside the sandbox
   (errors/home_outside_sandbox.yaml). Existing scenarios only
   covered HOME unset and HOME empty; the rejection path where
   resolveOperand succeeds but changeDir rejects was untested.

4. Add four hardening unit tests for the HostPrefix re-prefix
   logic in resolveSymlinks: unrelated absolute target gets
   re-prefixed, already-prefixed target is left alone, target
   exactly equal to hp is left alone, and the
   /mnt/host vs /mnt/hostile prefix-collision boundary is
   correctly distinguished by the hp+sep check.

P3 review items (#5 Windows ACL test, #6 isCd literal-name
defer-skip) are intentionally not addressed — both are flagged
as not blocking and are forward-looking notes; replies posted
on the threads.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
/etc does not exist on Windows, so the sandbox returns "no such
file or directory" first instead of the "permission denied" we
hit on Unix where /etc exists and is rejected for being outside
the sandbox. Both are valid rejections of an out-of-sandbox
target; relax the scenario to assert only the operational
contract (exit 1, "cd:" + the operand in stderr).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@AlexandreYang

Copy link
Copy Markdown
Member

@codex make a comprehensive code and security review

@julesmcrt
julesmcrt added this pull request to the merge queue May 13, 2026
Merged via the queue into main with commit 3c208c1 May 13, 2026
40 checks passed
@julesmcrt
julesmcrt deleted the cd-builtin branch May 13, 2026 11:26
julesmcrt added a commit that referenced this pull request May 13, 2026
PR #237 (cd builtin) merged to main after this PR branched and
brought tests that assert pflag's pre-rewrite wording: `unknown flag`
/ `unknown shorthand flag`. After merging main into this branch the
shared rewriter in builtins/internal/flagparser/ translates those to
GNU's `unrecognized option` / `invalid option -- 'X'`, plus the
`Try 'cmd --help' for more information.` footer.

Updated:
- builtins/cd/cd_test.go: TestCdUnknownLongFlag substring assertion
  switched to "cd: unrecognized option".
- tests/scenarios/cmd/cd/errors/unknown_flag_short.yaml: stderr now
  matches "cd: invalid option -- 'x'\nTry 'cd --help' for more
  information.".
- tests/scenarios/cmd/cd/errors/unknown_flag_long.yaml: stderr now
  matches "cd: unrecognized option '--no-such-flag'\nTry 'cd --help'
  for more information.".

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

verified/analysis Human-reviewed static analysis changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants