Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,32 +60,34 @@ Every access path is default-deny:
|----------------------|-------------------------------------|----------------------------------------------|
| 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` |
| Filesystem access | Blocked | Configure `AllowedPaths` with `PATH[:ro|:rw]` root specs |
| Filesystem access | Blocked | Configure `AllowedPaths` with `PATH[:ro|:rw]` root specs; optionally carve out subtrees with `DeniedPaths` `PATH[:r|:w]` specs |
| Environment variables| Empty (no host env inherited) | Pass variables via the `Env` option |
| Output redirections | Only `/dev/null` allowed in read-only mode (exit code 2 for other targets); file-target redirects enabled in remediation mode, gated by `AllowedPaths` | `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1`; in remediation mode: `>FILE`, `>>FILE`, `2>FILE`, `&>FILE`, `&>>FILE` |

**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 for reads and openat-based write handling for writes.
**AllowedPaths** restricts all file operations to specified directories using Go's `os.Root` API for reads and handle-relative write handling for writes. **DeniedPaths** explicitly denies subtrees inside those allowed roots.

- **Sandbox mechanism:** Reads go through `os.Root`; writes are checked against the most-specific path mode and, on Unix, opened with a no-symlink `openat` walk. Files outside the allowlist cannot be opened, created, truncated, or appended to.
- **Sandbox mechanism:** Reads go through `os.Root`, or through a deny-aware component resolver when `DeniedPaths` are configured; writes are checked against the most-specific path mode and opened with no-symlink handle-relative walks. Files outside the allowlist cannot be opened, created, truncated, or appended to.
- **Permission suffix:** Path entries may end with `:ro` or `:rw` representing read-only and read-write modes, respectively; entries without a suffix default to read-only, and the suffix is stripped before path validation. In remediation mode, write operations are accepted only inside the most-specific matching `:rw` root.
- **Symlink policy:** A symlink pointing outside its `os.Root` is followed for reads but never for writes; on Unix, symlink components in write targets are rejected with `symlinks are not supported as write targets` rather than followed, eliminating the TOCTOU window where a malicious link target could be swapped between resolution and open.
- **Deny suffix:** Denied path entries may end with `:r` or `:w`. `:r` denies reads and writes; `:w` denies writes only. Entries without a suffix default to `:r`. Direct `:r` denied children are filtered from directory listings; `:w` denied children remain visible and readable.
- **Symlink policy:** A symlink pointing outside its `os.Root` is followed for reads but never for writes; symlink components in write targets are rejected with `symlinks are not supported as write targets` rather than followed, eliminating the TOCTOU window where a malicious link target could be swapped between resolution and open.
- **Denied-path symlinks:** Reads with configured DeniedPaths use deny-aware component resolvers: Unix uses `openat`/`readlinkat`; Windows uses handle-relative `NtCreateFile` and explicit reparse-point parsing for symlinks and junctions/mount points. Both check pinned deny-root identities before returning the final descriptor/handle. Unsupported Windows reparse points are rejected when DeniedPaths are active, and Windows alternate data streams are rejected for sandboxed access.
- **Output redirections by mode:**
- _Read-only mode (default):_ file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) are rejected at parse time (exit 2).
- _Remediation mode:_ those redirections open through the same sandbox — writes inside `:rw` allowlist roots succeed; targets outside the allowlist or inside read-only roots fail with `permission denied` (exit 1).
- **Special targets:** The literal target `/dev/null` is always short-circuited to a discarded sink without going through the sandbox. `<>` (read-write open) is blocked in all modes.
- **Diagnostic messages:** Configured directories that cannot be opened (missing, not a directory, no permission) are skipped with a warning flushed once to the runner's stderr at construction time. Silence them or redirect them programmatically with `WarningsWriter(io.Writer)` or `Runner.Warnings()`.

> **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.
> **Note:** The `ss`, `ip route`, and `df` builtins bypass `AllowedPaths` and `DeniedPaths` 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 path policy 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. To avoid leaking secrets passed as CLI arguments, `ps` does not read `/proc/<pid>/cmdline`; the `CMD` column reports only the process comm/executable name.

**RemediationMode** opts the runner into host-remediation mode, enabling file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) within `:rw` entries in the configured `AllowedPaths` and enabling remediation-only builtins such as `truncate` and `logrotate`. Targets outside the allowlist or inside read-only roots are rejected with `permission denied` (exit 1); symlinked write targets are rejected with `symlinks are not supported as write targets`; `/dev/null` is always accepted. `<>` (read-write open) remains blocked in all modes. CLI flag: `--mode remediation` (default: `--mode read-only`). The `logrotate` builtin is an rshell-safe log truncation helper, not a full `logrotate(8)` replacement.

## 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.
Inside rshell, run `help` to list supported feature categories, a concise unsupported-feature summary, enabled commands, and the configured `AllowedPaths` sandbox roots plus any `DeniedPaths` roots. Use `help <feature|command>` for details about a specific rshell feature or command.

See [SHELL_FEATURES.md](SHELL_FEATURES.md) for the complete list of supported and blocked features.

Expand Down
6 changes: 4 additions & 2 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
- ✅ `find [-L] [-P] [PATH...] [EXPRESSION]` — search for files in a directory hierarchy; supports `--help`, `-name`, `-iname`, `-path`, `-ipath`, `-type` (b,c,d,f,l,p,s), `-size`, `-empty`, `-newer`, `-mtime`, `-mmin`, `-perm`, `-maxdepth`, `-mindepth`, `-print`, `-print0`, `-exec CMD {} \;`, `-execdir CMD {} \;`, `-prune`, `-quit`, logical operators (`!`, `-a`, `-o`, `()`); blocks `-delete`, `-regex` for sandbox safety
- ✅ `grep [-EFGivclLnHhoqsxw] [-e PATTERN] [-m NUM] [-A NUM] [-B NUM] [-C NUM] PATTERN [FILE]...` — print lines that match patterns; uses RE2 regex engine (linear-time, no backtracking)
- ✅ `head [-n N|-c N] [-q|-v] [FILE]...` — output the first part of files (default: first 10 lines); `-z`/`--zero-terminated` and `--follow` are rejected
- ✅ `help [--all] [feature|command]` — display rshell features, a concise unsupported-feature summary, available commands, and the configured `AllowedPaths` sandbox roots (or a notice that no paths are configured); with a topic, show detailed help for that feature or command
- ✅ `help [--all] [feature|command]` — display rshell features, a concise unsupported-feature summary, available commands, the configured `AllowedPaths` sandbox roots (or a notice that no paths are configured), and configured `DeniedPaths` roots; with a topic, show detailed help for that feature or command
- ✅ `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
Expand Down Expand Up @@ -121,7 +121,8 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
## Execution

- ✅ 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 (read and write) to specified directories. Entries may end with `:ro` or `:rw` to indicate read-only and read-write permissions, respectively; entries without a suffix default to read-only. In remediation mode, write operations are accepted only inside the most-specific matching `:rw` root. Cross-root symlink fallback is read-only to avoid TOCTOU on writes; on Unix, symlink components in write targets are rejected with `symlinks are not supported as write targets` via a no-follow `openat` walk
- ✅ AllowedPaths filesystem sandboxing — restricts all file access (read and write) to specified directories. Entries may end with `:ro` or `:rw` to indicate read-only and read-write permissions, respectively; entries without a suffix default to read-only. In remediation mode, write operations are accepted only inside the most-specific matching `:rw` root. Cross-root symlink fallback is read-only to avoid TOCTOU on writes; symlink components in write targets are rejected with `symlinks are not supported as write targets` via no-follow handle-relative walks
- ✅ DeniedPaths filesystem deny rules — explicitly deny subtrees inside AllowedPaths. Entries may end with `:r` (deny reads and writes) or `:w` (deny writes only); entries without a suffix default to `:r`. Direct `:r` denied children are filtered from directory listings; `:w` denied children remain visible and readable. Deny-aware reads resolve components and check pinned deny-root identities before returning final descriptors/handles; Windows supports symlink and junction/mount-point reparse targets, rejects unsupported reparse points when DeniedPaths are active, and rejects alternate data streams for sandboxed access. Kernel-state builtins that intentionally bypass AllowedPaths (`ss`, `ip route`, `df`, and `ps` proc reads) also bypass DeniedPaths
- ✅ 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); `ps` does not read `/proc/<pid>/cmdline`
- ✅ RemediationMode — opt-in mode (`interp.WithMode(interp.ModeRemediation)` / `--mode remediation`) that enables file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) within `:rw` `AllowedPaths` and remediation-only builtins such as `truncate` and `logrotate`; targets outside the allowlist or inside read-only roots fail with `permission denied` (exit 1); symlinked write targets fail with `symlinks are not supported as write targets`; `/dev/null` always accepted; `<>` remains blocked
Expand All @@ -139,6 +140,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
- ✅ Caller-provided variables via the `Env` option
- ✅ `IFS` is set to space/tab/newline by default
- ✅ `ALLOWED_PATHS` — when `AllowedPaths` is configured, set to a `filepath.ListSeparator`-delimited list of resolved allowed directories (`:` on Unix, `;` on Windows)
- ✅ `DENIED_PATHS` — when path sandboxing is configured, set to a `filepath.ListSeparator`-delimited list of resolved denied directories (`:` on Unix, `;` on Windows)
- ❌ No automatic inheritance from the host process
- ❌ `export`, `readonly` are blocked

Expand Down
10 changes: 7 additions & 3 deletions allowedpaths/internal/writeopen/writeopen_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ package writeopen

import "os"

func OpenRoot(*os.Root) (*os.File, error) {
return nil, nil
func OpenRoot(root *os.Root) (*os.File, error) {
return root.Open(".")
}

func CloseRoot(*os.File) {}
func CloseRoot(file *os.File) {
if file != nil {
_ = file.Close()
}
}

func OpenFile(_ *os.File, root *os.Root, relPath string, flag int, perm os.FileMode) (*os.File, error) {
// On Windows, os.Root.OpenFile uses the runtime's handle-relative
Expand Down
20 changes: 20 additions & 0 deletions allowedpaths/path_mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,23 @@ func splitAllowedPathMode(path string) (string, pathMode, bool) {
}
return path, pathModeReadOnly, false
}

func parseDeniedPathMode(path string) (string, denyMode) {
path, mode, _ := splitDeniedPathMode(path)
return path, mode
}

func splitDeniedPathMode(path string) (string, denyMode, bool) {
for _, suffix := range []struct {
text string
mode denyMode
}{
{text: ":r", mode: denyModeRead | denyModeWrite},
{text: ":w", mode: denyModeWrite},
} {
if strings.HasSuffix(path, suffix.text) && len(path) > len(suffix.text) {
return path[:len(path)-len(suffix.text)], suffix.mode, true
}
}
return path, denyModeRead | denyModeWrite, false
}
31 changes: 31 additions & 0 deletions allowedpaths/path_mode_nonwindows.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ package allowedpaths
import (
"errors"
"os"
"path/filepath"
"strings"
)

func resolveAllowedPathMode(path string) (string, pathMode) {
Expand All @@ -25,3 +27,32 @@ func resolveAllowedPathMode(path string) (string, pathMode) {
}
return stripped, mode
}

func resolveDeniedPathMode(path string) (string, denyMode) {
stripped, mode, ok := splitDeniedPathMode(path)
if !ok {
return path, denyModeRead | denyModeWrite
}
// On POSIX filesystems, paths may literally end in ":r" or ":w".
// Preserve an existing literal path, or any path we cannot prove is absent,
// so a config suffix never widens access by stripping real filename text.
if _, err := os.Lstat(path); err == nil || !errors.Is(err, os.ErrNotExist) {
return path, denyModeRead | denyModeWrite
}
return stripped, mode
}

func relWithin(rootPath, path string) (string, bool) {
rel, err := filepath.Rel(filepath.Clean(rootPath), filepath.Clean(path))
if err != nil {
return "", false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", false
}
return rel, true
}

func hasUnsupportedPathSyntax(string) bool {
return false
}
34 changes: 34 additions & 0 deletions allowedpaths/path_mode_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@

package allowedpaths

import (
"path/filepath"
"strings"
)

func resolveAllowedPathMode(path string) (string, pathMode) {
stripped, mode, ok := splitAllowedPathMode(path)
if !ok {
Expand All @@ -20,3 +25,32 @@ func resolveAllowedPathMode(path string) (string, pathMode) {
// rshell mode suffix interpretation for this unsupported ambiguity.
return stripped, mode
}

func resolveDeniedPathMode(path string) (string, denyMode) {
stripped, mode, ok := splitDeniedPathMode(path)
if !ok {
return path, denyModeRead | denyModeWrite
}
// Windows treats terminal ":r" and ":w" as rshell deny-mode metadata.
return stripped, mode
}

func relWithin(rootPath, path string) (string, bool) {
rootPath = filepath.Clean(rootPath)
path = filepath.Clean(path)
if strings.EqualFold(rootPath, path) {
return ".", true
}
prefix := rootPath
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
prefix += string(filepath.Separator)
}
if len(path) < len(prefix) || !strings.EqualFold(path[:len(prefix)], prefix) {
return "", false
}
return path[len(prefix):], true
}

func hasUnsupportedPathSyntax(path string) bool {
return hasWindowsAlternateDataStream(path)
}
48 changes: 48 additions & 0 deletions allowedpaths/path_mode_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,51 @@ func TestResolveAllowedPathModeStripsWindowsSuffix(t *testing.T) {
assert.Equal(t, base, path)
assert.Equal(t, pathModeReadOnly, mode)
}

func TestRelWithinWindowsIsCaseInsensitive(t *testing.T) {
rel, ok := relWithin(`C:\Allowed\Root`, `c:\allowed\root\Child\File.txt`)
assert.True(t, ok)
assert.Equal(t, `Child\File.txt`, rel)

rel, ok = relWithin(`C:\Allowed\Root`, `C:\Allowed\Root`)
assert.True(t, ok)
assert.Equal(t, `.`, rel)

_, ok = relWithin(`C:\Allowed\Root`, `C:\Allowed\Rooted\File.txt`)
assert.False(t, ok)
}

func TestWindowsAlternateDataStreamDetection(t *testing.T) {
assert.False(t, hasWindowsAlternateDataStream(`C:\tmp\file.txt`))
assert.False(t, hasWindowsAlternateDataStream(`\\server\share\file.txt`))
assert.False(t, hasWindowsAlternateDataStream(`\\?\C:\tmp\file.txt`))

assert.True(t, hasWindowsAlternateDataStream(`C:\tmp\file.txt:stream`))
assert.True(t, hasWindowsAlternateDataStream(`\\?\C:\tmp\file.txt:stream`))
assert.True(t, hasWindowsAlternateDataStream(`relative:name`))
assert.True(t, hasWindowsAlternateDataStream(`C:relative`))
}

func TestNormalizeWindowsAbsoluteReparseTarget(t *testing.T) {
tests := []struct {
name string
in string
want string
ok bool
}{
{name: "nt drive", in: `\??\C:\allowed\target`, want: `C:\allowed\target`, ok: true},
{name: "nt unc", in: `\??\UNC\server\share\target`, want: `\\server\share\target`, ok: true},
{name: "win32 drive", in: `\\?\C:\allowed\target`, want: `C:\allowed\target`, ok: true},
{name: "volume guid", in: `\??\Volume{11111111-2222-3333-4444-555555555555}\target`, want: `\\?\Volume{11111111-2222-3333-4444-555555555555}\target`, ok: true},
{name: "relative", in: `target\child`, ok: false},
{name: "drive relative", in: `C:target\child`, ok: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := normalizeWindowsAbsoluteReparseTarget(tt.in)
assert.Equal(t, tt.ok, ok)
assert.Equal(t, tt.want, got)
})
}
}
Loading
Loading