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

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

Library callers can install passive file-access instrumentation with `WithFileAccessHooks`. When configured, rshell calls before/after hooks around sandboxed file opens, directory reads, stat/lstat/readlink/access checks, input redirections, command substitution file shortcuts, and glob expansion. The hooks receive command/source/operation/path context plus lightweight pre/post metadata, but cannot authorize, deny, rewrite, or otherwise alter file access.

Library callers can also install passive command-dispatch instrumentation with `WithCommandHooks`. When configured, rshell reports command name, arguments, allowed/known status, and exit code after each observed command dispatch. Command hooks are observability only and cannot authorize, deny, rewrite, or otherwise alter command execution.

> **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.
Expand Down
2 changes: 2 additions & 0 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ 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-access hooks — library callers can opt in to passive before/after metadata events for sandboxed file opens, directory reads, stat/lstat/readlink/access checks, input redirects, command substitution file shortcuts, and glob expansion
- ✅ Command hooks — library callers can opt in to passive command-dispatch events with command name, arguments, allowed/known status, and exit code
- ✅ 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
Expand Down
16 changes: 16 additions & 0 deletions allowedpaths/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,22 @@ func (s *Sandbox) Readlink(path string, cwd string) (string, error) {
return target, nil
}

// ResolvePath returns a best-effort absolute path after applying the same
// sandbox root and cross-root symlink resolution used by filesystem
// operations. When preserveLast is true, the final path component is not
// resolved if it is a symlink, matching lstat/readlink semantics.
func (s *Sandbox) ResolvePath(path string, cwd string, preserveLast bool) (string, error) {
absPath := filepath.Clean(toAbs(path, cwd))
ar, relPath, ok := s.resolveRootFollowingSymlinks(absPath, preserveLast)
if !ok {
return "", &os.PathError{Op: "resolve", Path: path, Err: os.ErrPermission}
}
if relPath == "." {
return ar.absPath, nil
}
return filepath.Join(ar.absPath, relPath), nil
}

// SetHostPrefix overrides the mount prefix used to translate host-absolute
// symlink targets inside containers.
func (s *Sandbox) SetHostPrefix(prefix string) {
Expand Down
3 changes: 3 additions & 0 deletions analysis/symbols_interp.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ var interpAllowedSymbols = []string{
"io.Writer", // 🟢 interface type for writing; no side effects.
"io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects.
"io/fs.FileInfo", // 🟢 interface type for file metadata; no side effects.
"io/fs.FileMode", // 🟢 file mode bits type for metadata; pure type.
"io/fs.ModeSymlink", // 🟢 symlink mode bit constant for metadata classification; pure constant.
"io/fs.ReadDirFile", // 🟢 read-only directory handle interface; no write capability.
"maps.Insert", // 🟢 inserts all key-value pairs from one map into another; pure function.
"os.DirEntry", // 🟢 type alias for fs.DirEntry; no side effects.
Expand All @@ -50,6 +52,7 @@ var interpAllowedSymbols = []string{
"os.O_RDONLY", // 🟢 read-only file flag constant; pure constant.
"os.PathError", // 🟢 error type wrapping path and operation; pure type.
"os.Pipe", // 🟠 creates an OS pipe pair; needed for shell pipelines.
"path/filepath.Clean", // 🟢 normalizes path strings for reporting; pure function, no I/O.
"path/filepath.IsAbs", // 🟢 checks if path is absolute; pure function, no I/O.
"path/filepath.Join", // 🟢 joins path elements; pure function, no I/O.
"path/filepath.ListSeparator", // 🟢 OS-specific path list separator; pure constant.
Expand Down
4 changes: 4 additions & 0 deletions builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ type CallContext struct {
// commands.
CommandAllowed func(name string) bool

// CommandDenied reports an attempted child-command dispatch that was
// rejected by CommandAllowed before RunCommand was invoked.
CommandDenied func(ctx context.Context, name string, args []string)

// WorkDir returns the shell's current working directory (absolute path).
// Used by builtins that need to compute absolute paths for sub-operations.
WorkDir func() string
Expand Down
11 changes: 7 additions & 4 deletions builtins/find/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,15 +304,18 @@ func evalExecLike(ec *evalContext, e *expr, name, replacement, dir string) evalR
return evalResult{}
}
cmd := strings.ReplaceAll(e.execCmd, "{}", replacement)
args := make([]string, len(e.execArgs))
for i, a := range e.execArgs {
args[i] = strings.ReplaceAll(a, "{}", replacement)
}
if ec.callCtx.CommandAllowed != nil && !ec.callCtx.CommandAllowed(cmd) {
if ec.callCtx.CommandDenied != nil {
ec.callCtx.CommandDenied(ec.ctx, cmd, args)
}
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)
}
exitCode, err := ec.callCtx.RunCommand(ec.ctx, dir, cmd, args)
if err != nil {
ec.callCtx.Errf("find: '%s': %s\n", cmd, err)
Expand Down
27 changes: 16 additions & 11 deletions builtins/find/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,16 @@ 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) {
for _, ex := range collectExecExprs(expression) {
cmd := ex.execCmd
if strings.Contains(cmd, "{}") {
continue
}
if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(cmd) {
args := append([]string(nil), ex.execArgs...)
if callCtx.CommandDenied != nil {
callCtx.CommandDenied(ctx, cmd, args)
}
callCtx.Errf("find: '%s': command not allowed\n", cmd)
return builtins.Result{Code: 1}
}
Expand Down Expand Up @@ -605,23 +610,23 @@ func walkPath(
return walkResult{failed: failed, quit: quit}
}

// collectExecCmds walks the expression tree and returns all -exec/-execdir command names.
func collectExecCmds(e *expr) []string {
var cmds []string
collectExecCmdsInto(e, &cmds)
return cmds
// collectExecExprs walks the expression tree and returns all -exec/-execdir expressions.
func collectExecExprs(e *expr) []*expr {
var execs []*expr
collectExecExprsInto(e, &execs)
return execs
}

func collectExecCmdsInto(e *expr, cmds *[]string) {
func collectExecExprsInto(e *expr, execs *[]*expr) {
if e == nil {
return
}
if e.kind == exprExecDir || e.kind == exprExec {
*cmds = append(*cmds, e.execCmd)
*execs = append(*execs, e)
}
collectExecCmdsInto(e.left, cmds)
collectExecCmdsInto(e.right, cmds)
collectExecCmdsInto(e.operand, cmds)
collectExecExprsInto(e.left, execs)
collectExecExprsInto(e.right, execs)
collectExecExprsInto(e.operand, execs)
}

// collectNewerRefs walks the expression tree and returns all -newer reference paths.
Expand Down
3 changes: 3 additions & 0 deletions builtins/xargs/xargs.go
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,9 @@ func invokeCommand(ctx context.Context, callCtx *builtins.CallContext, o options
return exitSubCmdNotStart, true
}
if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(finalCmd) {
if callCtx.CommandDenied != nil {
callCtx.CommandDenied(ctx, finalCmd, finalArgs)
}
callCtx.Errf("xargs: %s: command not allowed\n", finalCmd)
return exitSubCmdNotStart, true
}
Expand Down
49 changes: 36 additions & 13 deletions interp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ type runnerConfig struct {
// New() and shared across subshells via runnerConfig value copy.
proc *builtins.ProcProvider

// fileAccessHooks, when configured, receives passive before/after events
// for filesystem operations performed by rshell. Hooks are observability
// only: they cannot authorize, deny, or alter file access.
fileAccessHooks FileAccessHooks

// commandHooks, when configured, receives passive events for command
// dispatch decisions. Hooks are observability only: they cannot authorize,
// deny, or alter command execution.
commandHooks CommandHooks

// usedNew is set by New() and checked in Reset() to ensure a Runner
// was properly constructed rather than zero-initialized.
usedNew bool
Expand Down Expand Up @@ -193,6 +203,16 @@ type runnerState struct {
// (including concurrent pipe subshells) via pointer, and must be
// accessed atomically.
globReadDirCount *atomic.Int64

// fileAccessSeq assigns stable before/after event ids for the current
// Run(). It is shared with subshells so concurrent pipeline stages do not
// produce colliding ids.
fileAccessSeq *atomic.Int64

// fileAccessCommand carries a best-effort command name while expanding a
// simple command, so glob and command-substitution file accesses can be
// attributed before the final expanded argv is known.
fileAccessCommand string
}

// A Runner interprets shell programs. It can be reused, but it is not safe for
Expand Down Expand Up @@ -586,6 +606,7 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) {
r.runStdout = r.stdout
r.startTime = time.Now()
r.globReadDirCount = &atomic.Int64{}
r.fileAccessSeq = &atomic.Int64{}
r.fillExpandConfig(ctx)
if err := validateNode(node); err != nil {
fmt.Fprintln(r.stderr, err)
Expand Down Expand Up @@ -802,19 +823,21 @@ func (r *Runner) subshell(background bool) *Runner {
r2 := &Runner{
runnerConfig: r.runnerConfig,
runnerState: runnerState{
Dir: r.Dir,
Params: r.Params,
stdin: r.stdin,
stdout: r.stdout,
stderr: r.stderr,
runStdin: r.runStdin,
runStdout: r.runStdout,
inPipeline: r.inPipeline,
filename: r.filename,
exit: r.exit,
lastExit: r.lastExit,
startTime: r.startTime,
globReadDirCount: r.globReadDirCount,
Dir: r.Dir,
Params: r.Params,
stdin: r.stdin,
stdout: r.stdout,
stderr: r.stderr,
runStdin: r.runStdin,
runStdout: r.runStdout,
inPipeline: r.inPipeline,
filename: r.filename,
exit: r.exit,
lastExit: r.lastExit,
startTime: r.startTime,
globReadDirCount: r.globReadDirCount,
fileAccessSeq: r.fileAccessSeq,
fileAccessCommand: r.fileAccessCommand,
},
}
r2.writeEnv = newOverlayEnviron(r.writeEnv, background)
Expand Down
70 changes: 70 additions & 0 deletions interp/command_hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// 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.

package interp

import (
"context"

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

// CommandHooks are passive callbacks invoked around rshell command dispatch
// when configured by [WithCommandHooks]. Hooks are observability only: they
// cannot authorize, deny, rewrite, or otherwise alter command execution.
type CommandHooks struct {
After func(context.Context, CommandEvent)
}

// CommandEvent describes one command dispatch observed by rshell.
type CommandEvent struct {
Name string
Args []string

IsAllowed bool
IsKnown bool
ExitCode uint8
}

// WithCommandHooks installs passive command-dispatch hooks. Nil callbacks are
// ignored.
func WithCommandHooks(hooks CommandHooks) RunnerOption {
return func(r *Runner) error {
r.commandHooks = hooks
return nil
}
}

func (r *Runner) commandHooksEnabled() bool {
return r.commandHooks.After != nil
}

func (r *Runner) callCommandHook(ctx context.Context, hook func(context.Context, CommandEvent), event CommandEvent) {
if hook == nil {
return
}
defer func() {
_ = recover()
}()
hook(ctx, event)
}

func (r *Runner) notifyCommandDenied(ctx context.Context, name string, args []string) {
if !r.commandHooksEnabled() {
return
}
r.callCommandHook(ctx, r.commandHooks.After, CommandEvent{
Name: name,
Args: append([]string(nil), args...),
IsAllowed: false,
IsKnown: commandIsKnown(name),
ExitCode: 127,
})
}

func commandIsKnown(name string) bool {
_, ok := builtins.Lookup(name)
return ok
}
Loading
Loading