fix: make cd, ls, rg and /dev/null behave like the real thing - #1
Open
yibn2008 wants to merge 1 commit into
Open
fix: make cd, ls, rg and /dev/null behave like the real thing#1yibn2008 wants to merge 1 commit into
yibn2008 wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
cdalways fails with "permission denied"Symptom. Every
cdfails, includingcdto the directory you are already in:It is not a permission problem:
chmod 777changes nothing, and the same directory lists and writes fine.Cause. After the stat succeeds, mvdan/sh's
changeDircallsr.access(path, X_OK), which on Unix is a plain syscall against the host filesystem:Virtual paths do not exist on the host, so the check always fails. The reverse also holds —
cd /etcsucceeds because/etchappens to exist on the host — socd's exit status leaks whether a given path exists there.Fix. Unlike
open/stat/readdir,accesshas no handler to point at the VFS, so this intercepts the command word instead.CallHandlerruns before mvdan/sh dispatches its builtins, so rewritingcdandpwdto/bin/cdand/bin/pwdsends them to the gobash registry (vialookupCommand's basename fallback) rather than to mvdan/sh. Thecdbuilt-in then moves the interpreter through a newContext.SetCwdback-channel.Two decisions worth flagging:
Runnerwith its ownDir. Mutating the root Runner would fail to take effect inside(cd x; ...)and leak the change out of it, soSetCwdresolves the currently executing Runner from theHandlerContext. If that field ever disappears upstream, it fails closed —cderrors instead of writing to the wrong Runner.$PWDgets updated. Those variables live in mvdan/sh's variable table, which nothing outside the package can write.exportthere is assignment syntax, not a builtin, so the update goes throughHandlerContext.Builtin(ctx, ["eval", "export PWD=... OLDPWD=..."])—evalis in the builtin table and runs on the same Runner.A user-defined
cd()shell function still takes precedence, sincer.Funcsis checked afterCallHandler.If
accessever becomes handler-routed upstream, mvdan/sh's owncdworks against the VFS andinterp/activerunner.gocan be deleted. Happy to swap it for areplaceon a patched fork if you would rather not carry that file.2.
lsnever switches to one-entry-per-lineSymptom.
lsprints entries space-separated on a single line even when piped, sols | wc -lalways reports 1:Cause. Real
lsuses 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/-xask for columns explicitly,-mfor the comma-separated form.This also clears up two things that looked like separate bugs:
ls -t dir | wc -lreturning 1 looked like broken sorting, andcp a b dir/ && ls dir | wc -lreturning 1 looked likecpdropping a file. Both were fine — only the layout was wrong.3.
rgoutput does not match ripgrepSymptom / cause, all three checked against the real binary:
echo APPLE | rg -i applesearches the working directory instead of the piped input. ripgrep reads stdin whenever stdin is not a terminal.line:text, then a blank separator. Piped ripgrep emits a flatfile:textper line. The blank separator in particular breaksrg ... | wc -l.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
--headingto opt back into the grouped form and-nto 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/-Ioverride.4.
/dev/nullis an ordinary fileSymptom. Writes to
/dev/nullaccumulate and can be read back:And when the caller passes
CwdorFiles, the default layout is suppressed, so/devdoes not exist at all —2>/dev/nullthen adds a line of stderr (open /dev: file does not exist) instead of silencing one.Fix. The FileSystem now recognises
/dev/nullby path: reads return EOF, writes are discarded,Statreports 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.FSbehave the same as shell redirections. It also works whether or not/devexists 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,splitandrgpinned the previous output and now reflect what bash and ripgrep print when piped.builtins/cdtests supply theSetCwdback-channel and add a case covering the fail-closed path when it is absent;builtins/rggains a-ncase.🤖 Generated with Claude Code