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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,31 @@ 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`) |
| System services | All services and actions blocked | `AllowedSystemServices` with exact service/action grants |
| External commands | Blocked (exit code 127) | Provide an `ExecHandler` |
| Filesystem access | Blocked | Configure `AllowedPaths` with `PATH[:ro|:rw]` root 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.

**AllowedSystemServices** configures exact service/action grants for future system-service builtins. Service names are matched exactly without adding suffixes, resolving aliases, changing case, or otherwise normalizing them: `mysql` and `mysql.service` are different grants. The supported actions are `read`, `reload`, and `restart`. Empty names, whitespace, path-like names, and glob patterns are rejected. The policy defaults to denying every service, remains enforced when all commands are allowed, and requires remediation mode before any grant can be authorized.

```go
interp.AllowedSystemServices([]interp.SystemServiceControlGrant{
{
Service: "mysql.service",
Actions: []interp.SystemServiceAction{
interp.SystemServiceRestart,
interp.SystemServiceReload,
interp.SystemServiceRead,
},
},
})
```

The development CLI accepts the equivalent syntax through `--allowed-services mysql.service:restart+reload+read,nginx.service:read`. The policy and authorization capability are implemented, but `systemctl` and `journalctl` builtins are not yet available.

**AllowedPaths** restricts all file operations to specified directories using Go's `os.Root` API for reads and openat-based write handling for writes.

- **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.
Expand All @@ -83,7 +101,7 @@ Every access path is default-deny:

**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.
**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`. Remediation-only commands are rejected by builtin dispatch before argument parsing when this mode is disabled. 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

Expand Down
3 changes: 2 additions & 1 deletion SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,11 @@ 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
- ✅ AllowedSystemServices policy — configures exact, case-sensitive `read`, `reload`, and `restart` grants for system service names through `interp.AllowedSystemServices` or CLI `--allowed-services SERVICE:ACTION[+ACTION...]`; names are not normalized (`mysql` does not match `mysql.service`), glob and path-like names are rejected, all services are denied by default, and allowing all commands does not bypass this policy; the authorization capability is implemented for future consumers, but `systemctl` and `journalctl` builtins are not yet available
- ✅ 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
- ✅ 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
- ✅ 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`; builtin dispatch rejects remediation-only commands before argument parsing when the mode is disabled; 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
- ❌ External commands — blocked by default; requires an ExecHandler to be configured and the binary to be within AllowedPaths
- ❌ Background execution: `cmd &`
- ❌ Coprocesses: `coproc`
Expand Down
26 changes: 24 additions & 2 deletions builtins/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ type AllowedPath struct {
Access AllowedPathAccess
}

// SystemServiceAction identifies an operation that a builtin may perform on
// an explicitly configured system service.
type SystemServiceAction string

const (
SystemServiceRead SystemServiceAction = "read"
SystemServiceReload SystemServiceAction = "reload"
SystemServiceRestart SystemServiceAction = "restart"
)

// Command pairs a builtin name with its flag-declaring factory. MakeFlags
// registers any flags on the provided FlagSet and returns the bound handler.
// Commands that accept no flags may ignore fs via NoFlags.
Expand All @@ -72,8 +82,8 @@ type Command struct {
NormalizeArgs func(args []string) []string

// RemediationOnly marks a builtin as only available in remediation mode.
// The help builtin uses this to move the command to the disabled list
// when the shell is in read-only mode.
// Register rejects the command before argument parsing in read-only mode;
// the help builtin also uses this to move the command to the disabled list.
RemediationOnly bool
}

Expand All @@ -95,6 +105,7 @@ func (c Command) Register() {
name := c.Name
factory := c.MakeFlags
normalize := c.NormalizeArgs
remediationOnly := c.RemediationOnly
if _, exists := featureByName[name]; exists {
panic("builtin name conflicts with rshell feature: " + name)
}
Expand All @@ -108,6 +119,11 @@ func (c Command) Register() {

metaRegistry[name] = CommandMeta{Name: name, Description: c.Description, Help: c.Help, HasFlags: hasFlags, RemediationOnly: c.RemediationOnly}
addToRegistry(name, func(ctx context.Context, callCtx *CallContext, args []string) Result {
if remediationOnly && !callCtx.RemediationMode {
callCtx.Errf("%s: command requires remediation mode\n", name)
return Result{Code: 1}
}

fs := pflag.NewFlagSet(name, pflag.ContinueOnError)
fs.SetOutput(io.Discard) // handler formats errors itself
handler := factory(fs)
Expand Down Expand Up @@ -245,6 +261,12 @@ type CallContext struct {
// commands.
CommandAllowed func(name string) bool

// AuthorizeSystemServices reports whether all named services may be used
// for action under the current shell policy. Implementations must authorize
// the complete list before a builtin performs any operation so multi-service
// requests cannot partially execute. Service names are matched exactly.
AuthorizeSystemServices func(action SystemServiceAction, services ...string) error

// AllowedPathsList returns the resolved absolute paths and configured
// access modes of the AllowedPaths sandbox roots. An empty/nil slice means
// no allowed paths are configured, which blocks all filesystem access.
Expand Down
54 changes: 54 additions & 0 deletions builtins/remediation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// 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 builtins

import (
"bytes"
"context"
"testing"
)

func TestCommandRegisterEnforcesRemediationOnly(t *testing.T) {
const name = "test-remediation-only"
defer delete(registry, name)
defer delete(metaRegistry, name)

called := false
Command{
Name: name,
Description: "test remediation enforcement",
RemediationOnly: true,
MakeFlags: NoFlags(func(context.Context, *CallContext, []string) Result {
called = true
return Result{}
}),
}.Register()

handler, ok := Lookup(name)
if !ok {
t.Fatal("registered command was not found")
}

var stderr bytes.Buffer
result := handler(context.Background(), &CallContext{Stderr: &stderr}, nil)
if result.Code != 1 {
t.Fatalf("read-only result code = %d, want 1", result.Code)
}
if called {
t.Fatal("remediation-only handler ran in read-only mode")
}
if got, want := stderr.String(), name+": command requires remediation mode\n"; got != want {
t.Fatalf("stderr = %q, want %q", got, want)
}

result = handler(context.Background(), &CallContext{Stderr: &stderr, RemediationMode: true}, nil)
if result.Code != 0 {
t.Fatalf("remediation result code = %d, want 0", result.Code)
}
if !called {
t.Fatal("remediation-only handler did not run in remediation mode")
}
}
38 changes: 38 additions & 0 deletions cmd/rshell/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
command string
allowedPaths string
allowedCommands string
allowedServices string
allowAllCmds bool
timeout time.Duration
procPath string
Expand Down Expand Up @@ -84,6 +85,11 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
cmds = strings.Split(allowedCommands, ",")
}

serviceGrants, err := parseAllowedServices(allowedServices)
if err != nil {
return err
}

parsedMode := interp.Mode(mode)
if parsedMode != interp.ModeReadOnly && parsedMode != interp.ModeRemediation {
return fmt.Errorf("--mode must be one of: read-only, remediation")
Expand All @@ -92,6 +98,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
execOpts := executeOpts{
allowedPaths: paths,
allowedCommands: cmds,
allowedServices: serviceGrants,
allowAllCommands: allowAllCmds,
procPath: procPath,
mode: parsedMode,
Expand Down Expand Up @@ -144,6 +151,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
cmd.Flags().MarkHidden("command") //nolint:errcheck // flag is guaranteed to exist
cmd.Flags().StringVarP(&allowedPaths, "allowed-paths", "p", "", "comma-separated list of PATH[:ro|:rw] directories the shell is allowed to access; entries without a suffix are read-only")
cmd.Flags().StringVar(&allowedCommands, "allowed-commands", "", "comma-separated list of namespaced commands (e.g. rshell:cat,rshell:find)")
cmd.Flags().StringVar(&allowedServices, "allowed-services", "", "comma-separated exact service grants in SERVICE:ACTION[+ACTION...] form; actions: read, reload, restart")
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 Expand Up @@ -215,6 +223,7 @@ func rejectLongCommand(rawArgs []string) error {
type executeOpts struct {
allowedPaths []string
allowedCommands []string
allowedServices []interp.SystemServiceControlGrant
allowAllCommands bool
procPath string
mode interp.Mode
Expand All @@ -241,6 +250,9 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i
} else if len(opts.allowedCommands) > 0 {
runOpts = append(runOpts, interp.AllowedCommands(opts.allowedCommands))
}
if len(opts.allowedServices) > 0 {
runOpts = append(runOpts, interp.AllowedSystemServices(opts.allowedServices))
}
if opts.procPath != "" {
runOpts = append(runOpts, interp.ProcPath(opts.procPath))
}
Expand All @@ -256,3 +268,29 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i

return runner.Run(ctx, prog)
}

func parseAllowedServices(value string) ([]interp.SystemServiceControlGrant, error) {
if value == "" {
return nil, nil
}

entries := strings.Split(value, ",")
grants := make([]interp.SystemServiceControlGrant, 0, len(entries))
for _, entry := range entries {
separator := strings.LastIndexByte(entry, ':')
if separator <= 0 || separator == len(entry)-1 {
return nil, fmt.Errorf("--allowed-services: invalid grant %q (expected SERVICE:ACTION[+ACTION...])", entry)
}

actionNames := strings.Split(entry[separator+1:], "+")
actions := make([]interp.SystemServiceAction, len(actionNames))
for i, action := range actionNames {
actions[i] = interp.SystemServiceAction(action)
}
grants = append(grants, interp.SystemServiceControlGrant{
Service: entry[:separator],
Actions: actions,
})
}
return grants, nil
}
52 changes: 52 additions & 0 deletions cmd/rshell/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ func TestHelp(t *testing.T) {
assert.Contains(t, stdout, "PATH[:ro|:rw]")
assert.Contains(t, stdout, "entries without a suffix are read-only")
assert.Contains(t, stdout, "--allowed-commands")
assert.Contains(t, stdout, "--allowed-services")
assert.Contains(t, stdout, "SERVICE:ACTION[+ACTION...]")
assert.Contains(t, stdout, "--allow-all-commands")
assert.Contains(t, stdout, "file-target output redirections within :rw AllowedPaths roots")
assert.Contains(t, stdout, "--timeout")
Expand Down Expand Up @@ -284,6 +286,56 @@ func TestAllowedCommandsUnknownNamespace(t *testing.T) {
assert.Contains(t, stderr, "unknown namespace")
}

func TestAllowedServicesFlag(t *testing.T) {
code, stdout, stderr := runCLI(t,
"--allow-all-commands",
"--allowed-services", "mysql.service:restart+reload+read,nginx.service:read",
"--mode", "remediation",
"-c", `echo hello`,
)
assert.Equal(t, 0, code)
assert.Equal(t, "hello\n", stdout)
assert.Empty(t, stderr)
}

func TestAllowedServicesFlagRejectsInvalidGrant(t *testing.T) {
code, _, stderr := runCLI(t,
"--allow-all-commands",
"--allowed-services", "mysql.service",
"-c", `echo hello`,
)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "invalid grant")
}

func TestAllowedServicesFlagRejectsUnknownAction(t *testing.T) {
code, _, stderr := runCLI(t,
"--allow-all-commands",
"--allowed-services", "mysql.service:stop",
"-c", `echo hello`,
)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, `unsupported action "stop"`)
}

func TestAllowedServicesFlagRejectsGlob(t *testing.T) {
code, _, stderr := runCLI(t,
"--allow-all-commands",
"--allowed-services", "mysql*.service:read",
"-c", `echo hello`,
)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "glob pattern")
}

func TestParseAllowedServicesUsesLastColon(t *testing.T) {
grants, err := parseAllowedServices("tenant:mysql.service:read+reload")
require.NoError(t, err)
require.Len(t, grants, 1)
assert.Equal(t, "tenant:mysql.service", grants[0].Service)
assert.Equal(t, []interp.SystemServiceAction{interp.SystemServiceRead, interp.SystemServiceReload}, grants[0].Actions)
}

func TestAllowAllCommandsFlag(t *testing.T) {
code, stdout, _ := runCLI(t, "--allow-all-commands", "-c", `echo hello`)
assert.Equal(t, 0, code)
Expand Down
5 changes: 5 additions & 0 deletions interp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ type runnerConfig struct {
// command. Intended for testing convenience.
allowAllCommands bool

// allowedSystemServices maps exact service names to their permitted
// actions. It is independent of allowAllCommands and defaults to denying
// every service.
allowedSystemServices systemServiceGrants

// 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
3 changes: 2 additions & 1 deletion interp/builtin_truncate_pentest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ func TestTruncatePentestSandboxReadOnly(t *testing.T) {
_, stderr, code := runScript(t, "truncate -s 0 f.txt", dir,
interp.AllowedPaths([]string{dir}),
// Deliberately omit interp.WithMode(interp.ModeRemediation) —
// this means callCtx.Truncate is nil, which is the mode-guard path.
// central remediation-only dispatch must reject the command before its
// handler can reach the nil Truncate capability.
)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "truncate")
Expand Down
Loading
Loading