feat(cd): add cd builtin with -L/-P symlink modes#237
Conversation
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]>
|
@codex review |
There was a problem hiding this comment.
💡 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".
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]>
|
@codex review |
There was a problem hiding this comment.
💡 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".
…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]>
|
@codex review |
There was a problem hiding this comment.
💡 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".
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]>
|
@codex review |
There was a problem hiding this comment.
💡 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".
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]>
|
@codex review |
There was a problem hiding this comment.
💡 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".
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]>
|
@codex review |
There was a problem hiding this comment.
💡 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".
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]>
|
@codex review |
There was a problem hiding this comment.
💡 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".
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]>
|
@codex review |
There was a problem hiding this comment.
💡 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".
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]>
|
@codex review |
|
Codex Review: Didn't find any major issues. What shall we delve into next? ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
AlexandreYang
left a comment
There was a problem hiding this comment.
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
Positive observations
- All filesystem reads in
resolvePathgo throughcallCtx.StatFile/LstatFile/ReadlinkFile. No directos.*filesystem calls in builtin code. changeDirorders env writes before committingr.Dir, with explicitOLDPWDrollback onPWDwrite failure — verified by the dedicatedTestCdReportsVarStorageCapFailureGo test against theMaxTotalVarsBytescap.- Per-component
..validation (line 348) correctly rejectscd file/..andcd no-such/.., matching bash 5.2 — both directly tested. boolSeqFlaguses a private sentinel forNoOptDefValsocd --physical=trueis rejected, matching bash.RunCommandchild contexts (find -exec, xargs) setChangeDir: nilto isolatecdfrom leaking into the parent shell — verified bycd_in_find_exec_isolated.yaml.- The pipeline subshell case (in
runner_exec.go) usessubshell(true)so cd inside(...)or a pipeline stage mutates only the child'sr.Dir. - Fuzz seeds cover flag injection, control-character rejection (via
shellSafe), long inputs, CVE-class shapes; bothFuzzCdFlagsandFuzzCdPathassert no panics and only valid exit codes.
| if err := r.sandbox.Access(cleaned, r.Dir, 0x01); err != nil { | ||
| return err | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
[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.
| // 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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
[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.
…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]>
|
@codex make a comprehensive code and security review |
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]>
Summary
cdbuiltin so rshell scripts can change directory insideAllowedPaths. Targets outside the sandbox are rejected; the working directory and\$PWD/\$OLDPWDare mutated atomically only aftersandbox.Statconfirms the target is a directory.-L(default, lexical..),-P(physical, walks symlinks via the same sandbox-safe pattern aspwd -P),-h. Last-wins between-L/-Pmirrors bash.DIRECTORY,-(changes to\$OLDPWDand prints the new path), no-arg (uses\$HOME). Multi-operand and empty operands are rejected.-e,-@,CDPATH,~user, andcdable_varsare intentionally unsupported.The framework gains two
CallContextcallbacks —ChangeDir(absDir) errorandLookupEnvVar(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; newbuiltins/cd/package at 85% coverage)tests/scenarios/cmd/cd/{basic,flags,errors,hardening,help}/.., escape via cross-root symlinks, unknown flags, flag injection via expansion, dash-prefixed filenames, FD-leak under 1000-iteration loops, stdin-ignored, no-filesystem-writeFuzzCdFlags,FuzzCdPath) with corpora drawn from impl edge cases, CVE-class shapes, and existing test coverage; CI entry added to.github/workflows/fuzz.ymlRSHELL_BASH_TEST=1(Docker-backed bash compatibility) — could not run locally because the bind-mount undert.TempDir()is outside Docker Desktop's allow-list on this machine; please verify on CImake fmtcleango run ./cmd/rshell --allow-all-commands -c 'help cd'shows the new entry;SHELL_FEATURES.mdupdated alphabetically betweencatandcontinueNotes
tests/scenarios/shell/blocked_commands/builtins_and_features/cd.yaml— that scenario assertedcdwas an unknown command, which is no longer the case. A newtests/scenarios/cmd/cd/errors/no_allowed_paths.yamlcovers the "cd with no sandbox" failure path.cd -Pdeliberately does not applyCanonicalizeRootPrefix. Unlikepwd -P, which uses canonicalisation for display,cdneeds the working directory in the configured-root form so subsequent sandbox operations resolve under the same root. Documented inresolvePath.🤖 Generated with Claude Code