Skip to content

fix: make cd, ls, rg and /dev/null behave like the real thing - #1

Open
yibn2008 wants to merge 1 commit into
mark3labs:masterfrom
yibn2008:fix/cd-ls-rg
Open

fix: make cd, ls, rg and /dev/null behave like the real thing#1
yibn2008 wants to merge 1 commit into
mark3labs:masterfrom
yibn2008:fix/cd-ls-rg

Conversation

@yibn2008

@yibn2008 yibn2008 commented Aug 28, 2026

Copy link
Copy Markdown

Four things that behave differently inside the sandbox than they do in a real shell. Each section is the symptom, the cause, and the fix.

Full test suite passes.


1. cd always fails with "permission denied"

Symptom. Every cd fails, including cd to the directory you are already in:

$ cd sub
cd: permission denied: "sub"
$ cd /home/user/project      # the current directory
cd: permission denied: "/home/user/project"

It is not a permission problem: chmod 777 changes nothing, and the same directory lists and writes fine.

Cause. After the stat succeeds, mvdan/sh's changeDir calls r.access(path, X_OK), which on Unix is a plain syscall against the host filesystem:

// mvdan.cc/sh/v3/interp/os_unix.go
func (r *Runner) access(ctx context.Context, path string, mode uint32) error {
	// TODO(v4): "access" may need to become part of a handler, like "open" or "stat".
	return unix.Access(path, mode)
}

Virtual paths do not exist on the host, so the check always fails. The reverse also holds — cd /etc succeeds because /etc happens to exist on the host — so cd's exit status leaks whether a given path exists there.

Fix. Unlike open/stat/readdir, access has no handler to point at the VFS, so this intercepts the command word instead. CallHandler runs before mvdan/sh dispatches its builtins, so rewriting cd and pwd to /bin/cd and /bin/pwd sends them to the gobash registry (via lookupCommand's basename fallback) rather than to mvdan/sh. The cd built-in then moves the interpreter through a new Context.SetCwd back-channel.

Two decisions worth flagging:

  • Which runner moves. A subshell is a separate Runner with its own Dir. Mutating the root Runner would fail to take effect inside (cd x; ...) and leak the change out of it, so SetCwd resolves the currently executing Runner from the HandlerContext. If that field ever disappears upstream, it fails closed — cd errors instead of writing to the wrong Runner.
  • How $PWD gets updated. Those variables live in mvdan/sh's variable table, which nothing outside the package can write. export there is assignment syntax, not a builtin, so the update goes through HandlerContext.Builtin(ctx, ["eval", "export PWD=... OLDPWD=..."])eval is in the builtin table and runs on the same Runner.

A user-defined cd() shell function still takes precedence, since r.Funcs is checked after CallHandler.

If access ever becomes handler-routed upstream, mvdan/sh's own cd works against the VFS and interp/activerunner.go can be deleted. Happy to swap it for a replace on a patched fork if you would rather not carry that file.


2. ls never switches to one-entry-per-line

Symptom. ls prints entries space-separated on a single line even when piped, so ls | wc -l always reports 1:

$ ls z | cat -A
f1  f2  f3$

Cause. Real ls uses a column layout only when stdout is a terminal, and falls back to one entry per line otherwise. gobash always used the column form.

Fix. One entry per line is now the default. -C/-x ask for columns explicitly, -m for the comma-separated form.

This also clears up two things that looked like separate bugs: ls -t dir | wc -l returning 1 looked like broken sorting, and cp a b dir/ && ls dir | wc -l returning 1 looked like cp dropping a file. Both were fine — only the layout was wrong.


3. rg output does not match ripgrep

Symptom / cause, all three checked against the real binary:

  • stdin is ignored. echo APPLE | rg -i apple searches the working directory instead of the piped input. ripgrep reads stdin whenever stdin is not a terminal.
  • The terminal layout is always used — filename on its own line, then line:text, then a blank separator. Piped ripgrep emits a flat file:text per line. The blank separator in particular breaks rg ... | wc -l.
  • Line numbers are always on. Piped ripgrep omits them in all three cases (rg pat file, rg pat dir/, ... | rg pat).

Fix. Peek stdin when no path argument is given and search it if something was piped in, otherwise keep recursing from the cwd. The flat layout becomes the default, with --heading to opt back into the grouped form and -n to opt into line numbers.

The filename prefix now depends on whether a directory was searched or several paths were given, rather than on how many files the walk produced — ripgrep labels lines when asked to search a tree, even a tree holding one file. -H/-I override.


4. /dev/null is an ordinary file

Symptom. Writes to /dev/null accumulate and can be read back:

$ echo hi >/dev/null
$ cat /dev/null | wc -c
3

And when the caller passes Cwd or Files, the default layout is suppressed, so /dev does not exist at all — 2>/dev/null then adds a line of stderr (open /dev: file does not exist) instead of silencing one.

Fix. The FileSystem now recognises /dev/null by path: reads return EOF, writes are discarded, Stat reports a zero-length character device.

The wrapper sits at the FileSystem layer rather than in the interpreter's open handler, so built-ins going through Context.FS behave the same as shell redirections. It also works whether or not /dev exists as a directory, which means the layout-suppression behaviour is deliberately left untouched. Bash.FS() unwraps, so callers still get back the FileSystem they passed in.


Tests

Fixtures for cp, ls, mkdir, split and rg pinned the previous output and now reflect what bash and ripgrep print when piped. builtins/cd tests supply the SetCwd back-channel and add a case covering the fail-closed path when it is absent; builtins/rg gains a -n case.

🤖 Generated with Claude Code

Four things that behave differently inside the sandbox than they do in
a real shell.

cd always fails with "permission denied"
----------------------------------------
Every cd fails, including cd to the directory you are already in. It
is not a permission problem: chmod 777 changes nothing, and the same
directory lists and writes fine. After the stat succeeds, mvdan/sh's
changeDir calls r.access(path, X_OK), which on Unix is a plain syscall
against the HOST filesystem (interp/os_unix.go, carrying the upstream
note `TODO(v4): "access" may need to become part of a handler, like
"open" or "stat"`). Virtual paths do not exist on the host, so the
check always fails. The reverse also holds -- cd /etc succeeds because
/etc exists on the host -- so cd's exit status leaks whether a given
path exists there.

Unlike open/stat/readdir, access has no handler to point at the VFS,
so this intercepts the command word instead. CallHandler runs before
mvdan/sh dispatches its builtins, so rewriting cd and pwd to /bin/cd
and /bin/pwd sends them to the gobash registry via lookupCommand's
basename fallback. The cd built-in then moves the interpreter through
a new Context.SetCwd back-channel. A user-defined cd() shell function
still takes precedence, since r.Funcs is checked after CallHandler.

Two decisions worth flagging:

  - Which runner moves. A subshell is a separate Runner with its own
    Dir, so mutating the root Runner would fail to take effect inside
    `(cd x; ...)` and leak the change out of it. SetCwd resolves the
    currently executing Runner from the HandlerContext, and fails
    closed if that field ever disappears upstream.

  - How $PWD gets updated. Those variables live in mvdan/sh's variable
    table, which nothing outside the package can write. `export` there
    is assignment syntax, not a builtin, so the update goes through
    HandlerContext.Builtin(ctx, ["eval", "export PWD=... OLDPWD=..."])
    -- eval is in the builtin table and runs on the same Runner.

If access ever becomes handler-routed upstream, mvdan/sh's own cd
works against the VFS and interp/activerunner.go can be deleted.

ls never switches to one-entry-per-line
---------------------------------------
ls printed entries space-separated on a single line even when piped,
so `ls | wc -l` always reported 1. Real ls uses a column layout only
when stdout is a terminal. One entry per line is now the default;
-C/-x ask for columns explicitly, -m for the comma-separated form.

This also clears up two things that looked like separate bugs:
`ls -t dir | wc -l` returning 1 looked like broken sorting, and
`cp a b dir/ && ls dir | wc -l` returning 1 looked like cp dropping a
file. Both were fine -- only the layout was wrong.

rg output does not match ripgrep
--------------------------------
All three checked against the real binary:

  - stdin is ignored: `echo APPLE | rg -i apple` searches the working
    directory instead of the piped input.
  - The terminal layout is always used (filename on its own line, then
    line:text, then a blank separator). Piped ripgrep emits a flat
    file:text per line; the blank separator breaks `rg ... | wc -l`.
  - Line numbers are always on. Piped ripgrep omits them for
    `rg pat file`, `rg pat dir/` and `... | rg pat` alike.

Peek stdin when no path argument is given and search it if something
was piped in, otherwise keep recursing from the cwd. The flat layout
becomes the default, with --heading to opt back into the grouped form
and -n to opt into line numbers. The filename prefix now depends on
whether a directory was searched or several paths were given, rather
than on how many files the walk produced -- ripgrep labels lines when
asked to search a tree, even a tree holding one file. -H/-I override.

/dev/null is an ordinary file
-----------------------------
Writes to /dev/null accumulated and could be read back. And when the
caller passes Cwd or Files the default layout is suppressed, so /dev
does not exist at all -- `2>/dev/null` then added a line of stderr
("open /dev: file does not exist") instead of silencing one.

The FileSystem now recognises /dev/null by path: reads return EOF,
writes are discarded, Stat reports a zero-length character device. The
wrapper sits at the FileSystem layer rather than in the interpreter's
open handler, so built-ins going through Context.FS behave the same as
shell redirections. It also works whether or not /dev exists as a
directory, so the layout-suppression behaviour is deliberately left
untouched. Bash.FS() unwraps, so callers still get back the FileSystem
they passed in.

Tests
-----
Fixtures for cp, ls, mkdir, split and rg pinned the previous output and
now reflect what bash and ripgrep print when piped. builtins/cd tests
supply the SetCwd back-channel and add a case covering the fail-closed
path when it is absent; builtins/rg gains a -n case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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