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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ Every access path is default-deny:

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

> ### ⚠️ Demo only — `host:` namespace (not for production)
>
> A `host:<name>=<absolute-path>` entry (e.g. `host:logrotate=/usr/sbin/logrotate`) allowlists a single host binary at the given absolute path. When dispatch sees a non-builtin command name matching `<name>`, the binary at `<absolute-path>` is exec'd directly (no PATH lookup, no shell, no argv filtering). Stdin/stdout/stderr are plumbed through, the binary's exit code is propagated, context cancellation kills the process via `SIGKILL`, and the env passed to the binary is filtered to `PATH`, `HOME`, `LANG`. Linux-only; on darwin/windows the dispatch returns 127 with "host execution not supported on this platform".
>
> **This entry-point fundamentally changes rshell's threat model — the entire reason rshell exists is to *not* execute host binaries.** It exists for the host-remediation demo (step 6: `logrotate -f /etc/logrotate.d/app`) and would need a real design pass before becoming a product feature. The current implementation has no audit logging, no argv filtering, no multi-binary scaffolding, and no operator-friendly configuration story.

**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. Both reads and writes are sandboxed by the same mechanism — files outside the allowlist cannot be opened, created, truncated, or appended to. The cross-root symlink fallback is read-only: a symlink that points outside its `os.Root` is followed for reads but never for writes (avoids a TOCTOU window where a malicious link target could be swapped between resolution and open). File-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) open through the same sandbox: writes inside `AllowedPaths` succeed, anything else fails with `permission denied` and exit 1. The literal target `/dev/null` is short-circuited to a discarded sink without going through the sandbox. 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`, `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.
Expand Down
1 change: 1 addition & 0 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ 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
- ⚠️ **Demo only:** `host:<name>=<absolute-path>` AllowedCommands entries (e.g. `host:logrotate=/usr/sbin/logrotate`) allowlist a single host binary that rshell will exec directly when invoked. Linux-only. Plumbs stdin/stdout/stderr, propagates exit code, kills via `SIGKILL` on context cancel, forwards only `PATH`/`HOME`/`LANG`. This fundamentally changes rshell's threat model and is intended for the host-remediation demo, not production — see README "demo only" section.
- ✅ AllowedPaths filesystem sandboxing — restricts all file access (read and write) to specified directories; cross-root symlink fallback is read-only to avoid TOCTOU on writes
- ✅ 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: 1 addition & 1 deletion cmd/rshell/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
cmd.Flags().StringVarP(&command, "command", "c", "", "shell command string to execute")
cmd.Flags().MarkHidden("command") //nolint:errcheck // flag is guaranteed to exist
cmd.Flags().StringVarP(&allowedPaths, "allowed-paths", "p", "", "comma-separated list of directories the shell is allowed to access")
cmd.Flags().StringVar(&allowedCommands, "allowed-commands", "", "comma-separated list of namespaced commands (e.g. rshell:cat,rshell:find)")
cmd.Flags().StringVar(&allowedCommands, "allowed-commands", "", "comma-separated list of namespaced commands (e.g. rshell:cat,rshell:find,host:logrotate=/usr/sbin/logrotate)")
cmd.Flags().BoolVar(&allowAllCmds, "allow-all-commands", false, "allow execution of all commands (builtins and external)")
cmd.Flags().DurationVar(&timeout, "timeout", 0, "maximum execution time for the entire shell run (e.g. 100ms, 5s, 1m)")
cmd.Flags().StringVar(&procPath, "proc-path", "", "path to the proc filesystem used by ps (default \"/proc\")")
Expand Down
2 changes: 1 addition & 1 deletion cmd/rshell/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ func TestAllowedCommandsMissingNamespace(t *testing.T) {
}

func TestAllowedCommandsUnknownNamespace(t *testing.T) {
code, _, stderr := runCLI(t, "--allowed-commands", "host:echo", "-c", `echo hello`)
code, _, stderr := runCLI(t, "--allowed-commands", "bogus:echo", "-c", `echo hello`)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "unknown namespace")
}
Expand Down
69 changes: 68 additions & 1 deletion interp/allowed_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@
package interp_test

import (
"bytes"
"context"
"errors"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"mvdan.cc/sh/v3/syntax"

"github.com/DataDog/rshell/interp"
)
Expand All @@ -21,7 +27,7 @@ func TestAllowedCommandsNamespaceRequired(t *testing.T) {
}

func TestAllowedCommandsUnknownNamespace(t *testing.T) {
_, err := interp.New(interp.AllowedCommands([]string{"host:echo"}))
_, err := interp.New(interp.AllowedCommands([]string{"bogus:echo"}))
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown namespace")
}
Expand Down Expand Up @@ -62,3 +68,64 @@ func TestAllowedCommandsEmpty(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "empty command name")
}

// TestHostEntryDoesNotAuthorizeBuiltin is a regression test: a host: entry
// whose name collides with a builtin must NOT silently authorize the
// builtin. Without the !isKnown gate in call(), an entry like
// "host:cat=<path>" would flip isAllowed=true and the builtin cat would
// run with stdin/AllowedPaths access, never executing the host path.
// Cross-platform: this exercises the dispatch gate, not the actual host
// exec, so it runs on darwin/windows too. The host path uses
// t.TempDir() rather than a hardcoded /bin/... so AllowedCommands'
// filepath.IsAbs check passes on every OS (Windows requires drive
// letter or UNC).
func TestHostEntryDoesNotAuthorizeBuiltin(t *testing.T) {
hostPath := filepath.Join(t.TempDir(), "fake-binary")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
r, err := interp.New(
interp.AllowedCommands([]string{"host:cat=" + hostPath}),
interp.StdIO(strings.NewReader(""), stdout, stderr),
)
require.NoError(t, err)
t.Cleanup(func() { _ = r.Close() })

prog, err := syntax.NewParser().Parse(strings.NewReader("cat"), "")
require.NoError(t, err)

runErr := r.Run(context.Background(), prog)
var status interp.ExitStatus
require.True(t, errors.As(runErr, &status), "expected ExitStatus error, got %v", runErr)
assert.Equal(t, interp.ExitStatus(127), status)
assert.Contains(t, stderr.String(), "command not allowed")
assert.Empty(t, stdout.String(), "cat builtin must not have run")
}

// TestHostEntryAuthorizesNonBuiltin verifies the positive case for the
// dispatch gate — a host: entry whose name does NOT collide with a
// builtin still passes the allowlist check (it would fail with
// "command not allowed" if the gate were too strict). The actual exec
// path is platform-specific and tested in host_exec_test.go (linux);
// here we only assert that dispatch reaches it (on darwin/windows the
// host-exec stub returns 127 with a different message). t.TempDir()
// produces an absolute path on every OS so AllowedCommands accepts it
// on Windows too.
func TestHostEntryAuthorizesNonBuiltin(t *testing.T) {
hostPath := filepath.Join(t.TempDir(), "fake-binary")
stderr := &bytes.Buffer{}
r, err := interp.New(
interp.AllowedCommands([]string{"host:somenonsensename=" + hostPath}),
interp.StdIO(strings.NewReader(""), &bytes.Buffer{}, stderr),
Comment thread
julesmcrt marked this conversation as resolved.
)
require.NoError(t, err)
t.Cleanup(func() { _ = r.Close() })

prog, err := syntax.NewParser().Parse(strings.NewReader("somenonsensename"), "")
require.NoError(t, err)

_ = r.Run(context.Background(), prog)
// Whatever exit code we got, the rejection path ("command not
// allowed") must NOT have been taken — that's the only thing the
// dispatch gate is responsible for here.
assert.NotContains(t, stderr.String(), "command not allowed")
}
72 changes: 57 additions & 15 deletions interp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ type runnerConfig struct {
// command. Intended for testing convenience.
allowAllCommands bool

// hostCommands maps allowlisted host-binary names (e.g. "logrotate") to
// the absolute path of the binary on disk (e.g. "/usr/sbin/logrotate").
// Populated by AllowedCommands entries of the form "host:<name>=<path>".
//
// DEMO ONLY: running host binaries fundamentally changes rshell's threat
// model. This entry-point exists for the host-remediation demo and is not
// intended to ship as a product feature.
hostCommands map[string]string

// maxExecutionTime bounds the duration of each Run call. Zero disables
// the limit. When non-zero, Run derives a child context with this timeout.
maxExecutionTime time.Duration
Expand Down Expand Up @@ -734,10 +743,19 @@ func HostPrefix(prefix string) RunnerOption {
}

// AllowedCommands restricts command execution to the specified command names.
// Names must use the "rshell:" namespace prefix (e.g. "rshell:cat",
// "rshell:find"). Names without a colon separator or with an unknown namespace
// are rejected. The bare command name (after the prefix) is stored internally
// and matched exactly against the command name (args[0]) at execution time.
// Names must use a namespace prefix.
//
// Two namespaces are accepted:
// - "rshell:<name>" — allowlist a builtin (e.g. "rshell:cat", "rshell:find").
// The bare command name (after the prefix) is matched exactly against
// args[0] at execution time.
// - "host:<name>=<absolute-path>" — DEMO ONLY: allowlist a host binary at
// the given absolute path (e.g. "host:logrotate=/usr/sbin/logrotate").
// When dispatch sees a non-builtin command name matching <name>, the
// binary at <absolute-path> is exec'd. See [hostCommands] for the threat-
// model caveat. Linux-only.
//
// Names without a colon separator or with an unknown namespace are rejected.
//
// Only commands whose name appears in the list may be executed; all others are
// rejected with "<cmd>: command not allowed".
Expand All @@ -750,28 +768,52 @@ func HostPrefix(prefix string) RunnerOption {
func AllowedCommands(names []string) RunnerOption {
return func(r *Runner) error {
m := make(map[string]bool, len(names))
var hostMap map[string]string
for _, n := range names {
if n == "" {
return fmt.Errorf("AllowedCommands: empty command name")
}
idx := strings.Index(n, ":")
if idx < 0 {
return fmt.Errorf("AllowedCommands: %q missing namespace prefix (expected \"rshell:<command>\")", n)
return fmt.Errorf("AllowedCommands: %q missing namespace prefix (expected \"rshell:<command>\" or \"host:<name>=<path>\")", n)
}
ns := n[:idx]
cmd := n[idx+1:]
if strings.Index(cmd, ":") >= 0 {
return fmt.Errorf("AllowedCommands: %q contains multiple colons; expected format \"rshell:<command>\"", n)
}
if ns != "rshell" {
return fmt.Errorf("AllowedCommands: %q has unknown namespace %q (only \"rshell\" is supported)", n, ns)
}
if cmd == "" {
return fmt.Errorf("AllowedCommands: %q has empty command name", n)
rest := n[idx+1:]
switch ns {
case "rshell":
if strings.Index(rest, ":") >= 0 { //nolint:gosimple // strings.Contains is not on the interp allowlist
return fmt.Errorf("AllowedCommands: %q contains multiple colons; expected format \"rshell:<command>\"", n)
}
if rest == "" {
return fmt.Errorf("AllowedCommands: %q has empty command name", n)
}
m[rest] = true
case "host":
eq := strings.Index(rest, "=")
if eq < 0 {
return fmt.Errorf("AllowedCommands: %q missing \"=<path>\" (expected format \"host:<name>=<absolute-path>\")", n)
}
name := rest[:eq]
path := rest[eq+1:]
if name == "" {
return fmt.Errorf("AllowedCommands: %q has empty host command name", n)
}
if path == "" {
return fmt.Errorf("AllowedCommands: %q has empty host binary path", n)
}
if !filepath.IsAbs(path) {
return fmt.Errorf("AllowedCommands: %q host binary path must be absolute, got %q", n, path)
}
if hostMap == nil {
hostMap = make(map[string]string)
}
hostMap[name] = path
default:
return fmt.Errorf("AllowedCommands: %q has unknown namespace %q (only \"rshell\" and \"host\" are supported)", n, ns)
}
m[cmd] = true
}
r.allowedCommands = m
r.hostCommands = hostMap
return nil
}
}
Expand Down
111 changes: 111 additions & 0 deletions interp/host_exec_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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.

//go:build linux

// DEMO ONLY: this file enables rshell to execute a small allowlist of host
// binaries. Running host binaries fundamentally changes rshell's threat
// model — the entire reason rshell exists is to *not* execute host binaries.
// This entry-point exists for the host-remediation demo (see
// docs/RULES.md / SHELL_FEATURES.md "demo only" section) and would need a
// real design pass before becoming a product feature.

package interp

import (
"context"
"errors"
"os/exec"
)

// hostEnvAllowlist is the set of environment variable names forwarded to
// host binaries. Anything else in the runner environment is stripped so
// that host invocations do not leak ambient configuration that the rest
// of the shell deliberately keeps out.
var hostEnvAllowlist = []string{"PATH", "HOME", "LANG"}

// runHostCommand executes the host binary at path with args[1:] as its argv,
// plumbing the runner's stdin/stdout/stderr through. It returns the binary's
// exit code as a uint8 (so $? works) and propagates context cancellation by
// killing the process with SIGKILL — exec.CommandContext's default Cancel
// uses os.Kill (SIGKILL on Unix), which matches the timeout behaviour the
// rest of the runner applies to builtins.
//
// args[0] is the user-visible command name; args[1:] is the binary's argv.
// The binary path itself comes from the hostCommands allowlist entry, not
// from args, so PATH lookup is intentionally not performed.
func (r *Runner) runHostCommand(ctx context.Context, path string, args []string) uint8 {
cmd := exec.CommandContext(ctx, path, args[1:]...)
cmd.Dir = r.Dir
cmd.Env = r.filterHostEnv()
if r.stdin != nil {
cmd.Stdin = r.stdin
}
cmd.Stdout = r.stdout
cmd.Stderr = r.stderr

err := cmd.Run()
// If the runner's context expired (MaxExecutionTime, parent cancel,
// or builtin-style cooperative cancel), exec.CommandContext kills
// the child with SIGKILL and cmd.Run returns *exec.ExitError with
// ExitCode() == -1. We must surface ctx.Err() back through Run()
// rather than mapping the signal to a numeric exit code, otherwise
// Run() returns ExitStatus(130) and the CLI's timeout path
// (context.DeadlineExceeded → "execution timed out", exit 124)
// never fires and run-span telemetry is misclassified as success.
// Use exit.fatal so the err is recorded; the returned uint8 is only
// observed when r.exit.err is nil, so the value is symbolic here.
if ctxErr := ctx.Err(); ctxErr != nil {
r.exit.fatal(ctxErr)
return 130
}
if err == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// ExitCode returns -1 if the process was terminated by a signal
// without a context cancel having fired (e.g. an external
// SIGKILL from outside this process). Map that to 130 — the
// shell-conventional "terminated" code — so the caller can still
// observe a non-zero exit.
code := exitErr.ExitCode()
if code < 0 {
return 130
}
return uint8(code)
Comment thread
julesmcrt marked this conversation as resolved.
}
// Failure to start or some other I/O error: surface to stderr and
// return 127 (the shell convention for "command not found / not
// executable").
r.errf("rshell: %s: %v\n", args[0], err)
return 127
}

// filterHostEnv builds a minimal env slice for host binaries from the
// runner's environment overlay (r.writeEnv) — NOT the ambient Go
// process env — forwarding only the names in hostEnvAllowlist that
// are also marked Exported. Matches bash semantics: a script-level
// assignment like `PATH=/tmp; hostcmd` does not propagate to the
// child unless PATH was previously exported (via interp.Env or an
// `export` statement). Inline command assignments
// (`PATH=/safe hostcmd`) propagate because call() forces
// vr.Exported = true before dispatch.
//
// Reading from r.writeEnv is what makes the runner's documented
// "empty by default, no host env inherited" guarantee hold for host
// binaries: an unset PATH/HOME/LANG in the runner is simply omitted,
// regardless of what the surrounding Go process exports.
func (r *Runner) filterHostEnv() []string {
out := make([]string, 0, len(hostEnvAllowlist))
for _, name := range hostEnvAllowlist {
vr := r.writeEnv.Get(name)
if !vr.Declared() || !vr.Exported {
continue
}
out = append(out, name+"="+vr.String())
}
return out
}
21 changes: 21 additions & 0 deletions interp/host_exec_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// 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.

//go:build !linux

// DEMO ONLY: see host_exec_linux.go. The host-binary entry-point is a
// Linux-only demo; on darwin/windows we simply refuse to dispatch.

package interp

import "context"

// runHostCommand is a stub for non-Linux platforms. It writes an explanatory
// error to the runner's stderr and returns 127 (shell-conventional "command
// not found / not executable") so callers can rely on a non-zero exit.
func (r *Runner) runHostCommand(_ context.Context, _ string, args []string) uint8 {
r.errf("rshell: %s: host execution not supported on this platform\n", args[0])
return 127
}
Loading