Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3336dc7
add remediation commands and output redirects
matt-dz May 15, 2026
fef9cff
guard tee host writes with allowed paths
matt-dz May 15, 2026
f1afa56
preserve host command operand boundaries
matt-dz May 15, 2026
2ec31ec
pass host file targets as sandboxed fds
matt-dz May 15, 2026
ebb8741
avoid tee target mutation without host handler
matt-dz May 15, 2026
263fcfc
Reject symlink write targets
matt-dz May 15, 2026
5ebd2af
Reject special write targets
matt-dz May 15, 2026
2488f5d
Reject trailing slash write targets
matt-dz May 15, 2026
1555d3a
Harden fd redirects and host fd handoff
matt-dz May 15, 2026
74f84a3
Fix CI for Windows FIFO tests
matt-dz May 16, 2026
c07fe8a
Skip Unix fd handoff tests on Windows
matt-dz May 16, 2026
be5afcf
Add systemctl status query support
matt-dz May 20, 2026
f5520ae
Add rshell remediation parity receipts
matt-dz May 20, 2026
3328ece
Fix remediation review findings
matt-dz May 20, 2026
a27575b
Guard write redirects behind command policy
matt-dz May 20, 2026
2b50b44
Block compound command file redirects
matt-dz May 20, 2026
e40ff10
Reject commandless file redirects
matt-dz May 20, 2026
1599a2e
Fix redirect preflight and Windows write opens
matt-dz May 20, 2026
9bd0812
Cap stdout file redirection output
matt-dz May 20, 2026
decdf16
Avoid duplicate redirect target expansion
matt-dz May 20, 2026
640efa5
Preflight fd redirects before command expansion
matt-dz May 20, 2026
6ca2e2a
Handle dynamic devnull fd redirects
matt-dz May 20, 2026
915f47f
Check literal command policy before fd preflight
matt-dz May 20, 2026
4b8f735
Check dynamic command policy before fd preflight
matt-dz May 20, 2026
b767b33
Preflight static fd-dup redirects before command expansion
matt-dz May 20, 2026
4048913
Preflight safe dynamic fd-dup targets before command expansion
matt-dz May 20, 2026
2da2636
Defer command-substituted fd-dup redirect targets
matt-dz May 20, 2026
d8436fd
Preserve redirect expansion order in fd-dup preflight
matt-dz May 20, 2026
28593f3
Preserve redirect short-circuiting in fd-dup preflight
matt-dz May 20, 2026
62e1f53
Harden remediation commands and file writes
matt-dz May 20, 2026
65b8ed4
Fix kill PID range validation
matt-dz May 20, 2026
00f123e
Return failure when kill times out
matt-dz May 20, 2026
ec8487e
Detect exited processes during kill wait
matt-dz May 20, 2026
453f2a9
Preserve kill cancellation during liveness checks
matt-dz May 20, 2026
5ce4e4f
Reject Windows reparse points in write path
matt-dz May 20, 2026
fa2c719
fix redirect preflight edge cases
matt-dz May 20, 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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,25 @@ Every access path is default-deny:
| Resource | Default | Opt-in |
|----------------------|-------------------------------------|----------------------------------------------|
| Command execution | All commands blocked (exit code 127)| `AllowedCommands` with namespaced command list (e.g. `rshell:cat`) |
| External commands | Blocked (exit code 127) | Provide an `ExecHandler` |
| External commands | Blocked (exit code 127) | Provide an `ExecHandler`; the CLI also wires a guarded host-command path for remediation builtins |
| Filesystem access | Blocked | Configure `AllowedPaths` with directory list |
| Environment variables| Empty (no host env inherited) | Pass variables via the `Env` option |
| Output redirections | Only `/dev/null` allowed (exit code 2 for other targets) | `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` |
| Output redirections | File writes blocked unless `AllowedPaths` permits the target and file writes are enabled | Simple-command `COMMAND > FILE` / `COMMAND >> FILE`, `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` |

**AllowedCommands** restricts which commands (builtins or external) the interpreter may execute. Commands must be specified with the `rshell:` namespace prefix (e.g. `rshell:cat`, `rshell:echo`). If not set, no commands are allowed.

**AllowedPaths** restricts all file operations to specified directories using Go's `os.Root` API (`openat` syscalls), making it immune to symlink traversal, TOCTOU races, and `..` escape attacks. Configured directories that cannot be opened (missing, not a directory, no permission) are skipped with a diagnostic message; by default these messages are flushed once to the runner's stderr at construction time. Callers that need to keep stderr clean of sandbox diagnostics can route them to a dedicated sink with `WarningsWriter(io.Writer)` or retrieve them programmatically via `Runner.Warnings()`.

**DisableFileWrites** blocks filesystem creation and mutation even inside `AllowedPaths`. The API option is `DisableFileWrites()` and the CLI flag is `--disable-file-writes`; read-only file access remains governed by `AllowedPaths`, and redirects to `/dev/null` are still allowed.

> **Note:** The `ss`, `ip route`, and `df` builtins bypass `AllowedPaths` for their kernel-state reads. `ss` and `ip route` open `/proc/net/*` paths directly; `df` reads `/proc/self/mountinfo` (Linux) or calls `getfsstat(2)` (macOS), then issues `unix.Statfs(2)` against every kernel-reported mount point. These paths are hardcoded — never derived from user input — and `Statfs` returns metadata only (block / inode counts, filesystem type, block size). There is no sandbox-escape risk, but operators cannot use `AllowedPaths` to block `ss` from enumerating local sockets, `ip route` from reading the routing table, or `df` from reporting mount-table capacity — these reads succeed regardless of the configured path policy.

**ProcPath** (Linux-only) overrides the proc filesystem root used by the `ps` builtin (default `/proc`). This is a privileged option set at runner construction time by trusted caller code — scripts cannot influence it. Access to the proc path is intentionally not subject to `AllowedPaths` restrictions, since proc is a read-only virtual filesystem that does not expose host data under the normal file hierarchy.

**Guarded remediation commands** validate a narrow rshell contract before doing host-affecting work. `kill` signals the requested PID directly after validating its single-pid shape. `truncate`, `systemctl`, `logrotate`, and `tee` delegate to a guarded host command handler; file-mutating commands hand the handler sandbox-opened descriptors instead of raw writable paths. When file writes are disabled, those builtins receive no write-open capability and fail before host delegation. The CLI wires that controlled host-command path automatically, while API callers can provide `HostCommandHandler` to integrate their own execution environment. Their command shapes intentionally mirror the remediation primitives exposed by benchmark tooling; broader native command flags remain blocked by rshell argument validation or by the caller-provided handler.

**Explicit remediation writes** can use `write_file [--mode overwrite|append] [--json] FILE` when a benchmark wants a command-shaped `write_file` action instead of redirection syntax. The command reads stdin and writes only through `AllowedPaths`.

## Shell Features

Inside rshell, run `help` to list supported feature categories, a concise unsupported-feature summary, enabled commands, and the configured `AllowedPaths` sandbox roots (or a notice when none are configured). Use `help <feature|command>` for details about a specific rshell feature or command.
Expand Down
19 changes: 16 additions & 3 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
- ✅ `ip [-o|-4|-6|--brief] addr|link [show] [dev IFNAME]` — show network interface addresses and link-layer info (read-only); write ops (`add`, `del`, `flush`, `set`), namespace ops (`netns`, `-n`), and batch mode (`-b`/`-B`/`--force`) are blocked
- ✅ `ip route [show|list]` — show IPv4 routing table (Linux only; reads `/proc/net/route` directly via `os.Open`, bypassing `AllowedPaths`); at most 10 000 entries loaded; lines longer than 1 MiB abort parsing with an error (exit 1)
- ✅ `ip route get ADDRESS` — show the route selected by longest-prefix-match for ADDRESS (Linux only); write ops (`add`, `del`, `flush`, `replace`, `change`, `save`, `restore`) are blocked; `-6` (IPv6 routing) is not supported
- ✅ `kill [-9] [--timeout DURATION] [--json] PID` — guarded remediation command; sends SIGTERM or SIGKILL directly after validating a single positive PID, then polls until the PID exits or the timeout elapses
- ✅ `logrotate [--json] PATH` — guarded remediation command; delegates one existing allowed file descriptor to the host command handler, usually a scenario-provided wrapper; `--json` reports before/after size and best-effort rotated path discovery
- ✅ `sort [-rnhubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-h`/`--human-numeric-sort` orders by SI suffix (none < K/k < M < G < T < P < E < Z < Y < R < Q) then by numeric value (single-letter suffixes only — `Ki`, `Mi`, etc. are not recognised); `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec)
- ✅ `ss [-tuaxlans4689Hoehs] [OPTION]...` — display network socket statistics; reads kernel socket state directly via `os.Open` (bypassing `AllowedPaths`) from: Linux: `/proc/net/`; macOS: sysctl; Windows: iphlpapi.dll; `-F`/`--filter` (GTFOBins file-read), `-p`/`--processes` (PID disclosure), `-K`/`--kill`, `-E`/`--events`, and `-N`/`--net` are rejected
- ✅ `ls [-1aAdFhlpRrSt] [--offset N] [--limit N] [FILE]...` — list directory contents; `--offset`/`--limit` are non-standard pagination flags (single-directory only, silently ignored with `-R` or multiple arguments, capped at 1,000 entries per call); offset operates on filesystem order (not sorted order) for O(n) memory
Expand All @@ -34,13 +36,17 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
- ✅ `read [-r] [-p PROMPT] [-d DELIM] [-n N] [-N N] [-t SECS] [NAME...]` — read one delimited chunk from stdin and assign each IFS-split field to a shell variable (defaulting to `REPLY` when no NAME is given); `-n`/`-N` are capped at 1 MiB; non-raw mode treats `\<newline>` as a line continuation (both characters are dropped) and `\<X>` for any other `X` (including the active custom delimiter under `-d`) as a literal `X` with the backslash removed — e.g. `printf 'a\,b,c' | read -d , x` assigns `x="a,b"`; `-p` is suppressed unless stdin is a terminal (matches bash); `-a` (array), `-s` (silent), `-u` (read from FD), `-e` (readline), and `-i` (initial text) are not implemented
- ✅ `sed [-n] [-e SCRIPT] [-E|-r] [SCRIPT] [FILE]...` — stream editor for filtering and transforming text; uses RE2 regex engine; `-i`/`-f` rejected; `e`/`w`/`W`/`r`/`R` commands blocked
- ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s`
- ✅ `systemctl [--json] start|stop|restart|reload|status UNIT`; `systemctl show --property=ActiveState --value UNIT` — guarded remediation command; delegates one lifecycle or status action and unit to the host command handler; `--json` includes `active_state`
- ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected
- ✅ `tee [-a] FILE` — guarded remediation command; copies stdin to stdout and one sandbox-opened file descriptor through the host command handler; only overwrite and append forms are supported
- ✅ `test EXPRESSION` / `[ EXPRESSION ]` — evaluate conditional expression (file tests, string/integer comparison, logical operators)
- ✅ `truncate -s SIZE [--json] FILE` — guarded remediation command; shrinks one existing regular file to a non-negative byte size no larger than its current size by delegating a sandbox-opened file descriptor to the host command handler; `--json` reports before/after size
- ✅ `tr [-cdsCt] SET1 [SET2]` — translate, squeeze, and/or delete characters from stdin
- ✅ `true` — return exit code 0
- ✅ `uname [-asnrvm]` — print system information (Linux only; reads from `/proc/sys/kernel/`, respects `--proc-path`)
- ✅ `uniq [OPTION]... [INPUT]` — report or omit repeated lines
- ✅ `wc [-l] [-w] [-c] [-m] [-L] [FILE]...` — count lines, words, bytes, characters, or max line length
- ✅ `write_file [--mode overwrite|append] [--json] FILE` — explicit remediation write action; writes stdin to one AllowedPaths-constrained file, overwriting by default or appending with `--mode append`
- ✅ `xargs [-0] [-a FILE] [-d DELIM] [-E EOF-STR] [-I REPLSTR] [-L N] [-n N] [-r] [-s N] [-t] [-x] [COMMAND [INITIAL-ARGS]...]` — build and execute commands from standard input; only invokes other registered builtins (subject to `CommandAllowed`), so the GTFOBins shell-escape `xargs … /bin/sh` is rejected; flags outside the supported set above (e.g. `-p` interactive, `-P` parallel, `-o`/`--open-tty`, `--show-limits`) are rejected as unknown
- ❌ All other commands — return exit code 127 with `<cmd>: not found` unless an ExecHandler is configured

Expand Down Expand Up @@ -81,16 +87,20 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
- ✅ `<` — input redirection (read-only, within AllowedPaths)
- ✅ `<<DELIM` — heredoc
- ✅ `<<-DELIM` — heredoc with tab stripping
- ✅ `COMMAND > FILE` — redirect simple-command stdout to FILE, creating/truncating within AllowedPaths unless file writes are disabled
- ✅ `COMMAND >> FILE` — append simple-command stdout to FILE, creating within AllowedPaths unless file writes are disabled
- ✅ `>/dev/null`, `2>/dev/null` — redirect stdout or stderr to /dev/null (output is discarded; only `/dev/null` is allowed as target)
- ✅ `&>/dev/null` — redirect both stdout and stderr to /dev/null
- ✅ `>>/dev/null`, `&>>/dev/null` — append redirect to /dev/null (same effect as truncate)
- ✅ `2>&1`, `>&2` — file descriptor duplication between stdout (1) and stderr (2)
- ❌ `> FILE`, `VAR=x > FILE` — commandless stdout redirects to real files are blocked; use `write_file` or a simple command redirect
- ❌ `{ ...; } > FILE`, `( ... ) > FILE`, `while ...; done > FILE` — compound-command stdout redirects to real files are blocked; use a simple command redirect or `/dev/null`
- ❌ `|&` — pipe stdout and stderr (bash extension)
- ❌ `<<<` — herestring (bash extension)
- ❌ `> FILE` — write/truncate to any file other than /dev/null
- ❌ `>> FILE` — append to any file other than /dev/null
- ❌ `2> FILE` — redirect stderr to a real file
- ❌ `&> FILE` — redirect all to any file other than /dev/null
- ❌ `&>> FILE` — append all to any file other than /dev/null
- ❌ `>| FILE` — clobber redirection
- ❌ `<>` — read-write
- ❌ `<&N` — input file descriptor duplication

Expand All @@ -109,9 +119,12 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c

- ✅ AllowedCommands — restricts which commands (builtins or external) may be executed; commands require the `rshell:` namespace prefix (e.g. `rshell:cat`); if not set, no commands are allowed
- ✅ AllowedPaths filesystem sandboxing — restricts all file access to specified directories
- ✅ File write disable option — `DisableFileWrites()` / `--disable-file-writes` blocks file creation and mutation through redirects and write-style builtins while preserving read-only AllowedPaths access and `/dev/null` redirects
- ✅ Guarded remediation commands — remediation builtins validate their restricted contract before host-affecting work; `kill` signals the target PID directly, while `truncate`, `systemctl`, `logrotate`, and `tee` delegate to a guarded host command handler. File-mutating commands pass sandbox-opened descriptors via handler context extra files. The CLI wires the controlled host-command path automatically; API callers can provide `HostCommandHandler`.
- ✅ Structured remediation receipts — guarded remediation commands accept `--json` where command-specific receipts are useful, while preserving normal shell stdout/stderr behavior by default; captured host stdout/stderr in receipts is capped and reports `stdout_truncated` / `stderr_truncated` when the cap is hit
- ✅ Whole-run execution timeout — callers can bound a `Run()` call via `context.Context`, `interp.MaxExecutionTime`, or the CLI `--timeout` flag; the deadline applies to the entire script, not each individual command
- ✅ ProcPath — overrides the proc filesystem path used by `ps` (default `/proc`; Linux-only; useful for testing/container environments)
- ❌ External commands — blocked by default; requires an ExecHandler to be configured and the binary to be within AllowedPaths
- ❌ External commands — blocked by default; require an ExecHandler and the command name must pass AllowedCommands
- ❌ Background execution: `cmd &`
- ❌ Coprocesses: `coproc`
- ❌ `time`
Expand Down
80 changes: 80 additions & 0 deletions allowedpaths/portable_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"syscall"

"golang.org/x/sys/unix"
)

// IsErrIsDirectory reports whether err is an "is a directory" error.
Expand Down Expand Up @@ -104,6 +108,82 @@ func (r *root) accessCheck(rel string, checkRead, checkWrite, checkExec bool) (f
return info, nil
}

func (r *root) openFileNoFollow(rel string, flag int, perm os.FileMode) (*os.File, error) {
f, err := r.root.OpenFile(rel, flag|syscall.O_NOFOLLOW, perm)
if errors.Is(err, syscall.ELOOP) {
return nil, &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission}
}
return f, err
}

func (r *root) openFileValidatedNoFollow(rel string, flag int, perm os.FileMode, _ bool) (*os.File, error) {
rel = filepath.Clean(rel)
if rel == "." {
return nil, &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")}
}

rootDir, err := r.root.Open(".")
if err != nil {
return nil, err
}
defer rootDir.Close()

rootFD := int(rootDir.Fd())
dirFD := rootFD
closeDirFD := func() {
if dirFD != rootFD {
_ = unix.Close(dirFD)
dirFD = rootFD
}
}
defer closeDirFD()

components := strings.Split(rel, string(filepath.Separator))
for _, component := range components[:len(components)-1] {
if component == "" || component == "." {
continue
}
fd, err := unix.Openat(dirFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return nil, noFollowOpenPathError(rel, err)
}
closeDirFD()
dirFD = fd
}

leaf := components[len(components)-1]
if leaf == "" || leaf == "." {
return nil, &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")}
}

fd, err := unix.Openat(dirFD, leaf, flag|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, uint32(perm))
if err != nil {
return nil, noFollowOpenPathError(rel, err)
}
f := os.NewFile(uintptr(fd), rel)
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, err
}
if info.IsDir() {
_ = f.Close()
return nil, &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")}
}
if !info.Mode().IsRegular() {
_ = f.Close()
return nil, &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission}
}
return f, nil
}

func noFollowOpenPathError(rel string, err error) error {
if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) || errors.Is(err, unix.ENXIO) {
return &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission}
}
return &os.PathError{Op: "open", Path: rel, Err: err}
}

// effectiveHasPerm checks whether the current process has the requested
// permission by inspecting the file's owner/group/other permission class
// that applies to the effective UID and GID of the running process.
Expand Down
Loading
Loading