Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
ef5b154
empty
AlexandreYang May 4, 2026
985d893
feat(cd): implement cd builtin with -L/-P, cd -, and $HOME support
AlexandreYang May 4, 2026
96bacb0
[iter 1] fix(cd): resolve intermediate symlinks under -P; preserve cd…
AlexandreYang May 4, 2026
39e0321
[iter 1] test(cd): align scenario error assertions with bash's capita…
AlexandreYang May 4, 2026
6820286
[iter 1] fix(cd): filter U+0080 in cd fuzz inputs
AlexandreYang May 4, 2026
0cefdc2
[iter 2] fix(cd): cross-platform symlink-loop test, empty HOME/OLDPWD…
AlexandreYang May 4, 2026
812279a
[iter 2] test(cd): skip Windows tests that interpolate paths into she…
AlexandreYang May 4, 2026
99829d5
[iter 3] fix: address review comments — comment accuracy, weak assert…
AlexandreYang May 4, 2026
ee1d11c
[iter 3] test(cd): add stdout_contains_windows for tests asserting pa…
AlexandreYang May 4, 2026
8843453
[iter 3] fix(cd): cd "" is a no-op success matching bash
AlexandreYang May 4, 2026
67d8983
[iter 3] fix(cd): update TestCdEmptyArg and relocate empty_arg scenario
AlexandreYang May 4, 2026
e8a5b39
[iter 4] fix(cd): no-op cd cases now update OLDPWD to current directory
AlexandreYang May 4, 2026
133ad6f
[iter 5] fix(cd): cd - with empty OLDPWD prints bare newline; doc and…
AlexandreYang May 4, 2026
e108125
[iter 1] doc(cd): expand cd - description to cover empty-OLDPWD and p…
AlexandreYang May 4, 2026
250e1c3
[iter 2] fix(cd): update one-liner doc and add test for cd -P - raw-p…
AlexandreYang May 4, 2026
beae9eb
[iter 3] fix(cd): use display="-" for cd - errors; add cd -P - bash s…
AlexandreYang May 4, 2026
9f7c703
[iter 4] test(cd): add sandbox and intermediate-symlink scenario tests
AlexandreYang May 4, 2026
a616ae5
[iter 5] doc(cd): fix sandbox error description; document CDPATH omis…
AlexandreYang May 4, 2026
4ceee81
[iter 6] fix(cd): remove dead fuzz seeds with embedded newlines/NULs
AlexandreYang May 4, 2026
77c7021
[iter 1] docs(cd): address P2/P3 review findings
AlexandreYang May 4, 2026
80188d1
[iter 1] merge: sync with main (pwd builtin, help features overhaul)
AlexandreYang May 4, 2026
b1be81f
[iter 2] test(cd): add scenario tests for OLDPWD inline assignment an…
AlexandreYang May 4, 2026
30d4443
[iter 4] fix(cd): cross-platform inline_home scenario test
AlexandreYang May 4, 2026
4320d4b
fix(review-fix-loop): only increment clean streak on no-findings iter…
AlexandreYang May 4, 2026
028f4f6
fix(review-fix-loop): only increment clean streak on no-findings iter…
AlexandreYang May 4, 2026
d5d9c6e
[iter 1] test(cd): strengthen cd -P - scenario assertion
AlexandreYang May 4, 2026
e3c1f51
[iter 2] test(cd): add scenario for cd -P -L last-wins (logical mode)
AlexandreYang May 4, 2026
eaa88d1
[iter 1] docs(review-fix-loop): clarify inner vs outer loop signal sc…
AlexandreYang May 4, 2026
0792dfd
fix(skills): match chatgpt-codex-connector without [bot] suffix in Gr…
AlexandreYang May 4, 2026
ffc2b60
Merge remote-tracking branch 'origin/alex/review-fix-loop' into alex/…
AlexandreYang May 4, 2026
f7d9561
[iter 1] fix(cd): resolve P1/P2 review findings
AlexandreYang May 4, 2026
a05a6a6
undo skills
AlexandreYang May 4, 2026
6985c1b
[iter 1] Address review comments: not_a_directory stderr_contains, Ne…
AlexandreYang May 4, 2026
bd3819b
[iter 1] Fix Windows CI failures in cd builtin: volume-name handling …
AlexandreYang May 4, 2026
e843aa7
[iter 2] fix(cd): applyNewWorkDir distinguishes unset vs empty $PWD f…
AlexandreYang May 4, 2026
b63000e
[iter 3] fix(cd): assert OLDPWD updated on empty HOME; clarify ErrPer…
AlexandreYang May 4, 2026
d3535f3
[iter 4] fix(cd): show OLDPWD path in cd - error messages; add StatFi…
AlexandreYang May 4, 2026
e201ea0
[iter 6] fix(cd): SetInterspersed(false), fix displayOverride for emp…
AlexandreYang May 4, 2026
7f24c9c
[iter 6] Fix CI: add strings.HasPrefix to cd per-command symbol allow…
AlexandreYang May 4, 2026
20c3758
[iter 7] Address review comments: cd -e compat, ErrPermission comment…
AlexandreYang May 5, 2026
b177774
[iter 8] Address review comments: subshell lastCallChangedWorkDir, fa…
AlexandreYang May 5, 2026
c02b1d9
[iter 9] Address review comments: NewWorkDir runtime validation, empt…
AlexandreYang May 5, 2026
85c1183
[iter 10] Address review comments: cd -P - with empty OLDPWD, //path …
AlexandreYang May 5, 2026
e13ae38
[iter 11] Address review comments: newBase length guard, capitalize c…
AlexandreYang May 5, 2026
f0ce599
[iter 11] fix(cd/fuzz): update stale seed comment for empty-string input
AlexandreYang May 5, 2026
648c822
[iter 11] Address review comments: YAML block scalars, AccessFile exe…
AlexandreYang May 5, 2026
0866892
[iter 12] Address review comments: test coverage, comment accuracy
AlexandreYang May 5, 2026
ab3b7e6
[iter 12] Fix cd on Windows: skip X_OK access check (Windows has no P…
AlexandreYang May 5, 2026
7a8be15
[iter 13] Address review comments: atomic applyNewWorkDir rollback; d…
AlexandreYang May 5, 2026
d57d33d
[iter 14] Address review comments: capitalize error messages to match…
AlexandreYang May 5, 2026
94d62ac
[iter 15] Address review comments: correct ErrPermission comment accu…
AlexandreYang May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ jobs:
- pkg: ./builtins/tests/cat/
name: cat
corpus_path: builtins/tests/cat
- pkg: ./builtins/tests/cd/
name: cd
corpus_path: builtins/tests/cd
- pkg: ./builtins/tests/wc/
name: wc
corpus_path: builtins/tests/wc
Expand Down
1 change: 1 addition & 0 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c

- ✅ `break` — exit the innermost `for` loop
- ✅ `cat [-AbeEnstTuv] [FILE]...` — concatenate files to stdout; supports line numbering, blank squeezing, and non-printing character display
Comment thread
AlexandreYang marked this conversation as resolved.
- ✅ `cd [-L|-P] [DIR]` — change the working directory; bare `cd` uses `$HOME`, `cd -` switches to `$OLDPWD` and prints the raw OLDPWD value; `-P` resolves symlinks physically (capped at 40 hops); failure leaves `$PWD`/`$OLDPWD` untouched; subshell scope mirrors bash (`(cd …)` does not leak); `$CDPATH` is intentionally not implemented (prevents environment-variable path injection); paths beginning with `//` are normalised to `/` by `filepath.Clean` (POSIX allows this; bash preserves `//` but rshell intentionally diverges since the cleaned path is required for sandbox validation)
- ✅ `continue` — skip to the next iteration of the innermost `for` loop
- ✅ `cut [-b LIST|-c LIST|-f LIST] [-d DELIM] [-s] [-n] [--complement] [--output-delimiter=STRING] [FILE]...` — remove sections from each line of files
- ✅ `echo [-neE] [ARG]...` — write arguments to stdout; `-n` suppresses trailing newline, `-e` enables backslash escapes, `-E` disables them (default)
Expand Down
25 changes: 25 additions & 0 deletions analysis/symbols_builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,28 @@ var builtinPerCommandSymbols = map[string][]string{
"io.ReadCloser", // 🟢 interface type; no side effects.
"os.O_RDONLY", // 🟢 read-only file flag constant; cannot open files by itself.
},
"cd": {
"context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects.
"errors.As", // 🟢 error type assertion; pure function, no I/O.
"errors.Is", // 🟢 error comparison; pure function, no I/O.
"errors.New", // 🟢 creates a simple error value; pure function, no I/O.
"io/fs.ErrNotExist", // 🟢 sentinel error value; pure constant.
"io/fs.ErrPermission", // 🟢 sentinel error value; pure constant.
"io/fs.ModeSymlink", // 🟢 file mode bit constant for symlinks; pure constant.
"os.PathError", // 🟢 error type for filesystem path errors; pure type, no I/O.
"path/filepath.Clean", // 🟢 normalises a path string; pure function, no I/O.
"path/filepath.Dir", // 🟢 returns the directory component of a path; pure function, no I/O.
"path/filepath.IsAbs", // 🟢 reports whether a path is absolute; pure function, no I/O.
"path/filepath.Join", // 🟢 joins path components; pure function, no I/O.
"path/filepath.Separator", // 🟢 OS path separator constant ('/' or '\\'); pure constant, no I/O.
"path/filepath.ToSlash", // 🟢 converts OS path separators to forward slashes; pure function, no I/O.
"path/filepath.VolumeName", // 🟢 returns the volume prefix of a path (e.g. "C:" on Windows, "" on Unix); pure function, no I/O.
"runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O.
"strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O.
"strings.IndexByte", // 🟢 finds byte in string; pure function, no I/O.
"strings.Split", // 🟢 splits a string by separator into a slice; pure function, no I/O.
"strings.ToUpper", // 🟢 converts string to uppercase; pure function, no I/O.
},
"continue": {
"context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects.
},
Expand Down Expand Up @@ -448,6 +470,8 @@ var builtinAllowedSymbols = []string{
"io.WriteString", // 🟠 writes a string to a writer; no filesystem access, delegates to Write.
"io.Writer", // 🟢 interface type for writing; no side effects.
"io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects.
"io/fs.ErrNotExist", // 🟢 sentinel error value for "not exists"; pure constant.
"io/fs.ErrPermission", // 🟢 sentinel error value for "permission denied"; pure constant.
"io/fs.FileInfo", // 🟢 interface type for file information; no side effects.
"io/fs.FileMode", // 🟢 file permission bits type; pure type.
"io/fs.ModeCharDevice", // 🟢 file mode bit constant for character devices; pure constant.
Expand Down Expand Up @@ -523,6 +547,7 @@ var builtinAllowedSymbols = []string{
"strings.ReplaceAll", // 🟢 replaces all occurrences of a substring; pure function, no I/O.
"strings.Split", // 🟢 splits a string by separator into a slice; pure function, no I/O.
"strings.ToLower", // 🟢 converts string to lowercase; pure function, no I/O.
"strings.ToUpper", // 🟢 converts string to uppercase; pure function, no I/O.
"strings.TrimPrefix", // 🟢 removes a leading prefix from a string; pure function, no I/O.
"strings.TrimSpace", // 🟢 removes leading/trailing whitespace; pure function.
"syscall.ByHandleFileInformation", // 🟢 Windows file info struct for extracting nlink; read-only type, no I/O.
Expand Down
25 changes: 25 additions & 0 deletions builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ type CallContext struct {
// Used by builtins that need to compute absolute paths for sub-operations.
WorkDir func() string

// LookupVar returns the value of a shell variable and whether it is set.
// Used by cd to read $HOME and $OLDPWD; the value is the empty string and
// ok is false when the variable has never been assigned.
LookupVar func(name string) (value string, ok bool)

// HostPrefix returns the configured host-mount prefix used by
// container-style sandboxes to translate host-absolute paths
// (e.g. /var/log/pods/...) into the prefixed paths the sandbox can
Expand Down Expand Up @@ -251,6 +256,26 @@ type Result struct {

// ContinueN > 0 means continue from N enclosing loops.
ContinueN int

// NewWorkDir, when non-empty, is an absolute, validated path that the
Comment thread
AlexandreYang marked this conversation as resolved.
// runner adopts as the shell's new working directory before continuing.
// Set by the "cd" builtin; the runner is responsible for updating r.Dir,
// $PWD, and $OLDPWD. The builtin must validate accessibility (e.g. via
// callCtx.StatFile) before signalling a change.
//
Comment thread
AlexandreYang marked this conversation as resolved.
// SECURITY: The runner performs a defense-in-depth sandbox re-validation
// of NewWorkDir before applying it (see runner_exec.go). Builtins MUST
// also call callCtx.StatFile on the target path before returning this
// field so the sandbox validates the path at the builtin level; the
// runner re-check is a safety net for future builtins that may forget
// the prior-StatFile step.
//
// Side effect: when NewWorkDir is non-empty and Code is 0, the runner
// suppresses the inline-assignment restore of $PWD and $OLDPWD after
// the command returns (matching bash semantics for `OLDPWD=X cd -`).
// Any future builtin returning a non-empty NewWorkDir inherits this
// behaviour.
NewWorkDir string
Comment thread
AlexandreYang marked this conversation as resolved.
Comment thread
AlexandreYang marked this conversation as resolved.
Comment thread
AlexandreYang marked this conversation as resolved.
Comment thread
AlexandreYang marked this conversation as resolved.
}

var registry = map[string]HandlerFunc{}
Expand Down
224 changes: 224 additions & 0 deletions builtins/cd/builtin_cd_pentest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2026-present Datadog, Inc.

// This file holds adversarial tests for the cd builtin. Each test
// targets a specific abuse pattern (oversized inputs, sandbox escape,
// flag injection, infinite-source-style loops) and asserts that cd
// fails fast — a non-zero exit and a finite return time.

package cd_test

import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/DataDog/rshell/interp"
)

// runWithDeadline runs script under a 5-second timeout and asserts the
// runner returned in time, regardless of the exit code.
func runWithDeadline(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
type result struct {
stdout, stderr string
code int
}
done := make(chan result, 1)
go func() {
so, se, c := runScriptCtx(ctx, t, script, dir, opts...)
done <- result{so, se, c}
}()
select {
case r := <-done:
return r.stdout, r.stderr, r.code
case <-time.After(10 * time.Second):
t.Fatal("cd did not return within deadline")
return "", "", 0
}
}

// --- Path length pentest ---

func TestPentestCdAbsoluteOversizePath(t *testing.T) {
dir := t.TempDir()
huge := "/" + strings.Repeat("a", 70000)
_, stderr, code := runWithDeadline(t, "cd "+huge, dir, interp.AllowedPaths([]string{dir}))
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "path too long")
}

func TestPentestCdHomeOversize(t *testing.T) {
dir := t.TempDir()
huge := "/" + strings.Repeat("h", 70000)
_, stderr, code := runWithDeadline(t, "cd", dir,
interp.AllowedPaths([]string{dir}),
interp.Env("HOME="+huge))
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "path too long")
}

func TestPentestCdOldpwdOversize(t *testing.T) {
dir := t.TempDir()
huge := "/" + strings.Repeat("o", 70000)
_, stderr, code := runWithDeadline(t, "cd -", dir,
interp.AllowedPaths([]string{dir}),
interp.Env("OLDPWD="+huge))
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "path too long")
}

// --- Sandbox escape attempts ---

func TestPentestCdToHostRoot(t *testing.T) {
if runtime.GOOS == "windows" {
// On Windows "/" is the root of the current drive, which on the
// CI runner is the same drive as the temp dir — filepath.Clean
// can normalise it inside the allowed sandbox prefix and the
// cd succeeds. The Unix notion of a single host root that is
// always outside a sandboxed temp dir does not apply.
t.Skip("Unix-only: \"/\" semantics differ on Windows (per-drive root)")
}
allowed := t.TempDir()
// "/" exists outside the allowed path; sandbox must reject.
_, stderr, code := runWithDeadline(t, "cd /", allowed, interp.AllowedPaths([]string{allowed}))
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "cd:")
}

func TestPentestCdParentTraversalEscape(t *testing.T) {
allowed := t.TempDir()
// ../.. shouldn't be able to escape the sandbox even with logical
// path interpretation. filepath.Clean reduces the literal path; the
// sandbox then rejects it.
_, stderr, code := runWithDeadline(t, "cd ../../../../../../etc", allowed,
interp.AllowedPaths([]string{allowed}))
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "cd:")
}

func TestPentestCdSymlinkEscape(t *testing.T) {
allowed := t.TempDir()
other := t.TempDir()
link := filepath.Join(allowed, "escape")
require.NoError(t, os.Symlink(other, link))
// -P would resolve through the link to a directory outside the
// sandbox; the sandbox-aware ReadlinkFile/StatFile must refuse the
// off-sandbox final component.
_, stderr, code := runWithDeadline(t, "cd -P escape", allowed,
interp.AllowedPaths([]string{allowed}))
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "cd:")
}

// --- Flag injection / pflag rejection ---

func TestPentestCdRandomFlags(t *testing.T) {
dir := t.TempDir()
for _, flag := range []string{"-Z", "--no-such-flag", "-PL=oops", "--logical=maybe"} {
_, stderr, code := runWithDeadline(t, "cd "+flag, dir, interp.AllowedPaths([]string{dir}))
assert.Equal(t, 1, code, "flag=%q", flag)
assert.Contains(t, stderr, "cd:", "flag=%q", flag)
}
}

func TestPentestCdFlagDashAfterDoubleDash(t *testing.T) {
dir := t.TempDir()
makeDir(t, dir, "real")
link := filepath.Join(dir, "-funny")
require.NoError(t, os.Symlink("real", link))
// `cd -- -funny` must accept the dash-prefixed dir name as a path
// rather than trying to parse it as a flag.
stdout, _, code := runWithDeadline(t, "cd -- -funny\necho \"$PWD\"", dir,
interp.AllowedPaths([]string{dir}))
assert.Equal(t, 0, code)
assert.Contains(t, stdout, "-funny")
}

// --- Stat target hardening ---

func TestPentestCdToDevNull(t *testing.T) {
dir := t.TempDir()
// /dev/null is allowed by sandbox but is a character device, not a
// directory — cd must reject with "not a directory".
_, stderr, code := runWithDeadline(t, "cd "+os.DevNull, dir,
interp.AllowedPaths([]string{dir}))
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "cd:")
}

func TestPentestCdNonexistentParentNoCrash(t *testing.T) {
dir := t.TempDir()
_, _, code := runWithDeadline(t, "cd /nonexistent/deeply/nested/path", dir,
interp.AllowedPaths([]string{dir}))
assert.Equal(t, 1, code)
}

// --- Repeated mass invocation (FD / leak check) ---

func TestPentestCdRepeated(t *testing.T) {
dir := t.TempDir()
makeDir(t, dir, "sub")
var sb strings.Builder
for i := 0; i < 200; i++ {
sb.WriteString("cd sub\ncd ..\n")
}
stdout, stderr, code := runWithDeadline(t, sb.String(), dir,
interp.AllowedPaths([]string{dir}))
assert.Equal(t, 0, code, "stderr=%q", stderr)
assert.Equal(t, "", stdout)
}

// --- Cancellation under loop ---

func TestPentestCdInLoopCancellation(t *testing.T) {
dir := t.TempDir()
makeDir(t, dir, "sub")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
script := "for i in 1 2 3 4 5 6 7 8 9 10; do cd sub; cd ..; done"
done := make(chan struct{})
go func() {
defer close(done)
_, _, _ = runScriptCtx(ctx, t, script, dir, interp.AllowedPaths([]string{dir}))
}()
select {
case <-done:
// returned; that's all we need to assert.
case <-time.After(5 * time.Second):
t.Fatal("cd loop hung past cancellation")
}
}

// --- Execute-permission check ---

// TestPentestCdNoExecutePermission verifies that AccessFile is consulted and
// that cd fails with "permission denied" when the target directory exists
// (StatFile succeeds) but lacks execute/search permission for the current
// user. This exercises the callCtx.AccessFile(ctx, absPath, 0x01) path that
// would otherwise be invisible to StatFile alone.
func TestPentestCdNoExecutePermission(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not use POSIX execute bits — chmod(000) has no effect")
}
dir := t.TempDir()
locked := makeDir(t, dir, "locked")
require.NoError(t, os.Chmod(locked, 0o000))
// Restore permissions so t.TempDir cleanup can remove the directory.
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
_, stderr, code := runWithDeadline(t, "cd locked", dir, interp.AllowedPaths([]string{dir}))
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "cd:")
assert.Contains(t, stderr, "Permission denied")
}
Loading
Loading