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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,31 @@ rshell --allow-all-commands --timeout 5s -c 'echo "hello from rshell"'

Every access path is default-deny:

| Resource | Default | Opt-in |
| Resource | Default | Opt-in / Opt-out |
|----------------------|-------------------------------------|----------------------------------------------|
| Command execution | All commands blocked (exit code 127)| `AllowedCommands` with namespaced command list (e.g. `rshell:cat`) |
| Command execution | All commands blocked (exit code 127)| `AllowedCommands` (namespaced names), `AllowedCommandPatterns` (argv-prefix patterns), and `DeniedCommandPatterns` (deny-first carve-outs from the allow rules) |
| External commands | Blocked (exit code 127) | Provide an `ExecHandler` |
| 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` |

**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.

**AllowedCommandPatterns** restricts execution to argv sequences shaped like `(command [, subcommand_path...])`. Each pattern is a non-empty token list; an invocation is admitted when:

1. `argv[0]` equals `pattern[0]` exactly (the command name).
2. The leading **structural** tokens of `argv[1..]` equal `pattern[1..]`, where structural tokens are derived by skipping flag tokens according to the [`CommandSpec`](#commandspec) registered for the command.

For example, with the built-in `ip` spec, pattern `["ip", "route"]` admits all of `ip route show`, `ip -4 route show`, `ip --brief route show` — the spec recognises `-4` and `--brief` as boolean global flags, so they're skipped during structural extraction. The same pattern blocks `ip addr show` and `ip link list` because the leading structural token after `ip` is `addr` / `link`, not `route`.

Patterns are matched **after shell expansion**, so command-substitution-derived values (`$(...)`) cannot bypass the check — the matcher sees the resolved argv that would be handed to exec. `AllowedCommands` and `AllowedCommandPatterns` are independent permit axes joined by union: a command is allowed if its name appears in `AllowedCommands` OR its argv satisfies any pattern.

**CommandSpec** describes the flag conventions of a single command so the matcher can distinguish flag tokens from structural tokens. The `ip` builtin is shipped with a default spec; integrators register additional specs (kubectl, git, docker, etc.) via the `CommandSpecs(map[string]CommandSpec{...})` option. Multi-token patterns whose command lacks a spec are rejected at `New()` — single-token patterns (matching only `argv[0]`) require no spec.

> Without a spec, the matcher would have to guess which argv tokens are flag values vs. positional arguments, and that guess is the only way `kubectl delete pod get` could ever masquerade as a `kubectl get` invocation. The spec-driven matcher closes that bypass by inspecting only the leading structural position for the subcommand, so positional arguments at later positions cannot satisfy pattern slots.

**DeniedCommandPatterns** blocks invocations whose argv satisfies any of the given patterns, regardless of whether `AllowedCommands` or `AllowedCommandPatterns` would otherwise admit the call. Denies are evaluated **first**: a deny match short-circuits the gate to a refusal even if every other axis would permit the invocation. Same shape and matching semantics as `AllowedCommandPatterns` (pattern is a token list, multi-token patterns require a registered `CommandSpec`, etc.). Used to express "allow X but carve out Y" policies — for example, `AllowedCommands={rshell:ip}` plus `DeniedCommandPatterns=[["ip","route"]]` permits `ip addr` and `ip link` but forbids `ip route`. The architectural property that allow patterns survive shell substitution applies here equally: a substitution that resolves at runtime to a denied argv is blocked.

**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()`.

> **Note:** The `ss` and `ip route` builtins bypass `AllowedPaths` for their `/proc/net/*` reads. Both builtins open kernel pseudo-filesystem paths (e.g. `/proc/net/tcp`, `/proc/net/route`) directly with `os.Open` rather than going through the sandboxed opener. These paths are hardcoded in the implementation and are never derived from user input, so there is no sandbox-escape risk. However, operators cannot use `AllowedPaths` to block `ss` from enumerating local sockets or `ip route` from reading the routing table — these reads succeed regardless of the configured path policy.
Expand Down
3 changes: 3 additions & 0 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ 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
- ✅ AllowedCommandPatterns — restricts execution to `(command [, subcommand_path...])` shaped invocations (e.g. `["ip","route"]` permits `ip route show` AND `ip -4 route show` but not `ip addr show`); matched after shell expansion so command-substitution cannot bypass; argv[0] must equal pattern[0] exactly; remaining pattern tokens must equal the LEADING STRUCTURAL tokens of argv[1..] (where flags are skipped per the registered CommandSpec); combined with AllowedCommands by union (allow if name OR pattern admits); multi-token patterns require a CommandSpec for their command (single-token patterns do not)
- ✅ DeniedCommandPatterns — blocks invocations whose argv satisfies any of the given patterns, evaluated FIRST so a deny match overrides every allow rule (AllowAllCommands, AllowedCommands, AllowedCommandPatterns); same pattern shape and matching semantics as AllowedCommandPatterns; useful for "allow X but carve out Y" policies (e.g. allow `ip` wholesale by name, deny `ip route` specifically)
- ✅ CommandSpecs — registers per-command flag conventions (BooleanFlags, ValueFlags) used by AllowedCommandPatterns to distinguish flag tokens from structural tokens; `ip` ships with a built-in spec; integrators add their own via `interp.CommandSpecs(map[string]interp.CommandSpec{...})`
- ✅ AllowedPaths filesystem sandboxing — restricts all file access to specified directories
- ✅ 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)
Expand Down
2 changes: 2 additions & 0 deletions analysis/symbols_interp.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ var interpAllowedSymbols = []string{
"runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O.
"strconv.Itoa", // 🟢 int-to-string conversion; pure function, no I/O.
"strings.Builder", // 🟢 efficient string concatenation; pure in-memory buffer, no I/O.
"strings.Contains", // 🟢 checks if a substring is in a string; pure function, no I/O.
"strings.ContainsRune", // 🟢 checks if a rune is in a string; pure function, no I/O.
"strings.NewReader", // 🟢 wraps a string as an io.Reader; pure function, no I/O; used by ParseScript.
"strings.Index", // 🟢 finds substring index; pure function, no I/O.
Expand All @@ -65,6 +66,7 @@ var interpAllowedSymbols = []string{
"strings.Split", // 🟢 splits a string by separator; pure function, no I/O.
"strings.ToUpper", // 🟢 converts string to uppercase; pure function, no I/O.
"strings.TrimLeft", // 🟢 trims leading characters; pure function, no I/O.
"strings.TrimRight", // 🟢 trims trailing characters; pure function, no I/O.
"sync.Mutex", // 🟢 mutual exclusion lock; concurrency primitive, no I/O.
"sync.Once", // 🟢 ensures a function runs exactly once; concurrency primitive, no I/O.
"sync.WaitGroup", // 🟢 waits for goroutines to finish; concurrency primitive, no I/O.
Expand Down
13 changes: 9 additions & 4 deletions builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,15 @@ type CallContext struct {
// where FileInfo.Sys() lacks identity fields; Unix ignores it.
FileIdentity func(path string, info fs.FileInfo) (FileID, bool)

// CommandAllowed reports whether a command name is permitted under the
// current shell policy. Used by the help builtin to list only executable
// commands.
CommandAllowed func(name string) bool
// CommandAllowed reports whether the given invocation is permitted under
// the current shell policy. name is the command name (args[0]) and args
// is the full argv. Both are provided so callers may consult either a
// name-based allowlist or an argv-prefix pattern allowlist; pass a
// single-element argv (e.g. []string{name}) when only the name is known.
//
// Used by the help builtin to filter the list of allowed commands and
// by find -exec/-execdir to validate child commands before invocation.
CommandAllowed func(name string, args []string) bool

// WorkDir returns the shell's current working directory (absolute path).
// Used by builtins that need to compute absolute paths for sub-operations.
Expand Down
26 changes: 13 additions & 13 deletions builtins/find/builtin_find_pentest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func TestFindExecDirCommandNotAllowed(t *testing.T) {
runCalled = true
return 0, nil
}
callCtx.CommandAllowed = func(name string) bool {
callCtx.CommandAllowed = func(name string, _ []string) bool {
return false // block everything
}

Expand Down Expand Up @@ -120,7 +120,7 @@ func testExecDirFilename(t *testing.T, filename string) {
copy(capturedArgs, args)
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down Expand Up @@ -202,7 +202,7 @@ func TestFindExecDirRootPath(t *testing.T) {
copy(capturedArgs, args)
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down Expand Up @@ -296,7 +296,7 @@ func TestFindExecDirParentDir(t *testing.T) {
capturedDir = dir
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down Expand Up @@ -332,7 +332,7 @@ func TestFindExecDirEmbeddedBracesReplacement(t *testing.T) {
copy(capturedArgs, args)
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down Expand Up @@ -366,7 +366,7 @@ func TestFindExecDirTrailingSlashPreserved(t *testing.T) {
copy(capturedArgs, args)
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down Expand Up @@ -400,7 +400,7 @@ func TestFindExecDirWindowsDriveRoot(t *testing.T) {
capturedDir = dir
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down Expand Up @@ -435,7 +435,7 @@ func TestFindExecDirCommandTokenSubstitution(t *testing.T) {
capturedCmd = cmd
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down Expand Up @@ -467,7 +467,7 @@ func TestFindExecCommandTokenSubstitution(t *testing.T) {
capturedCmd = cmd
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down Expand Up @@ -514,7 +514,7 @@ func TestFindExecCommandNotAllowed(t *testing.T) {
runCalled = true
return 0, nil
}
callCtx.CommandAllowed = func(name string) bool {
callCtx.CommandAllowed = func(name string, _ []string) bool {
return false // block everything
}

Expand Down Expand Up @@ -587,7 +587,7 @@ func testExecFilename(t *testing.T, relPath, printPath string) {
copy(capturedArgs, args)
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down Expand Up @@ -698,7 +698,7 @@ func TestFindExecWorkDir(t *testing.T) {
capturedDir = dir
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down Expand Up @@ -734,7 +734,7 @@ func TestFindExecEmbeddedBracesReplacement(t *testing.T) {
copy(capturedArgs, args)
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }
callCtx.CommandAllowed = func(_ string, _ []string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
Expand Down
17 changes: 12 additions & 5 deletions builtins/find/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,15 +304,22 @@ func evalExecLike(ec *evalContext, e *expr, name, replacement, dir string) evalR
return evalResult{}
}
cmd := strings.ReplaceAll(e.execCmd, "{}", replacement)
if ec.callCtx.CommandAllowed != nil && !ec.callCtx.CommandAllowed(cmd) {
ec.callCtx.Errf("find: %s: '%s': command not allowed\n", name, cmd)
ec.failed = true
return evalResult{}
}
args := make([]string, len(e.execArgs))
for i, a := range e.execArgs {
args[i] = strings.ReplaceAll(a, "{}", replacement)
}
// Construct the full argv (cmd + args) so the policy callback can
// consult both a name allowlist and an argv-prefix pattern allowlist.
// This is the authoritative check; the parse-time check at find.go is
// a name-only fast-fail.
fullArgv := make([]string, 0, len(args)+1)
fullArgv = append(fullArgv, cmd)
fullArgv = append(fullArgv, args...)
if ec.callCtx.CommandAllowed != nil && !ec.callCtx.CommandAllowed(cmd, fullArgv) {
ec.callCtx.Errf("find: %s: '%s': command not allowed\n", name, cmd)
ec.failed = true
return evalResult{}
}
exitCode, err := ec.callCtx.RunCommand(ec.ctx, dir, cmd, args)
if err != nil {
ec.callCtx.Errf("find: '%s': %s\n", cmd, err)
Expand Down
54 changes: 48 additions & 6 deletions builtins/find/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,14 +182,25 @@ optLoop:
}

// Post-parse validation: check -exec/-execdir commands are allowed.
// Commands containing {} are skipped here — the substituted name is
// validated at eval-time when the replacement is known.
for _, cmd := range collectExecCmds(expression) {
if strings.Contains(cmd, "{}") {
// Commands whose name contains {} are skipped here — the substituted
// name is validated at eval-time when the replacement is known.
//
// We pass the unsubstituted argv (cmd + args, possibly including {}
// placeholders in trailing positions) to CommandAllowed so that
// argv-prefix patterns like ["echo","hello"] can match this fast-fail
// gate. The leading tokens of an unsubstituted argv are stable across
// {} substitution, so no false-positives slip through here that would
// be rejected at eval-time. evalExecLike still re-checks with the
// fully substituted argv before invocation.
for _, inv := range collectExecInvocations(expression) {
if strings.Contains(inv.cmd, "{}") {
continue
}
if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(cmd) {
callCtx.Errf("find: '%s': command not allowed\n", cmd)
fullArgv := make([]string, 0, len(inv.args)+1)
fullArgv = append(fullArgv, inv.cmd)
fullArgv = append(fullArgv, inv.args...)
if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(inv.cmd, fullArgv) {
callCtx.Errf("find: '%s': command not allowed\n", inv.cmd)
return builtins.Result{Code: 1}
}
}
Expand Down Expand Up @@ -612,6 +623,37 @@ func collectExecCmds(e *expr) []string {
return cmds
}

// execInvocation captures one -exec/-execdir clause as its command name plus
// the (still unsubstituted) argument template. Both fields are taken from the
// expression node verbatim, so {} placeholders are preserved in args.
type execInvocation struct {
cmd string
args []string
}

// collectExecInvocations walks the expression tree and returns one entry per
// -exec/-execdir clause. Used by the parse-time policy check to construct
// the unsubstituted argv (cmd + args) for pattern matching, so that a
// multi-token pattern such as ["echo","hello"] can match an invocation
// whose argv is ["echo","hello","{}"].
func collectExecInvocations(e *expr) []execInvocation {
var inv []execInvocation
collectExecInvocationsInto(e, &inv)
return inv
}

func collectExecInvocationsInto(e *expr, inv *[]execInvocation) {
if e == nil {
return
}
if e.kind == exprExecDir || e.kind == exprExec {
*inv = append(*inv, execInvocation{cmd: e.execCmd, args: e.execArgs})
}
collectExecInvocationsInto(e.left, inv)
collectExecInvocationsInto(e.right, inv)
collectExecInvocationsInto(e.operand, inv)
}

func collectExecCmdsInto(e *expr, cmds *[]string) {
if e == nil {
return
Expand Down
15 changes: 13 additions & 2 deletions builtins/help/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc {
printFeatureDetails(callCtx, feature)
return builtins.Result{}
}
if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(name) {
// help shows information about a single command name; we don't
// have a full argv to consult, so we pass []string{name}. As a
// consequence, commands that are only authorised by a
// multi-token argv-prefix pattern (e.g. "kubectl get") will
// not appear here even if their name does match a pattern's
// first token. That's a documented cosmetic limitation, not a
// security one.
if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(name, []string{name}) {
callCtx.Errf("help: no help topics match '%s'\n", name)
return builtins.Result{Code: 1}
}
Expand Down Expand Up @@ -105,7 +112,11 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc {
allNames := builtins.Names()
var allowed, notAllowed []string
for _, name := range allNames {
if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(name) {
// Same caveat as the single-name lookup above: argv-prefix
// pattern authorisations whose patterns have more than one
// token are not surfaced here, because the bare name is the
// only argv we have.
if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(name, []string{name}) {
notAllowed = append(notAllowed, name)
continue
}
Expand Down
Loading
Loading