Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Binary file added .DS_Store
Binary file not shown.
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# pkg/shell — Safe Shell Interpreter

## Overview

This is a minimal bash/POSIX like shell interpreter.
Safety is the primary goal.

This shell is intended to be used by AI Agents.

## Platform Support

The shell is supported on Linux, Windows and macOS.

## Documentation

- `README.md` and `SHELL_FEATURES.md` must be kept up to date with the implementation.

## Testing

- In test scenarios, use `expect.stderr` when possible instead of `stderr_contains`.
- `test_against_local_shell` should be enabled (the default) when the tested feature is bash/POSIX compliant. Only set `test_against_local_shell: false` for features that intentionally diverge from standard bash behavior (e.g. blocked commands, restricted redirects, readonly enforcement).
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
13 changes: 13 additions & 0 deletions COMMANDS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Builtin Commands

Short reference for builtin commands available in `pkg/shell`.

| Command | Options | Short description |
| --- | --- | --- |
| `true` | none | Exit with status `0`. |
| `false` | none | Exit with status `1`. |
| `echo [ARG ...]` | none | Print arguments separated by spaces, then newline. |
| `cat [FILE ...]` | `-` (read stdin) | Print files; with no args, read stdin. |
| `exit [N]` | `N` (status code) | Exit the shell with `N` (default: last status). |
| `break [N]` | `N` (loop levels) | Break current loop, or `N` enclosing loops. |
| `continue [N]` | `N` (loop levels) | Continue current loop, or `N` enclosing loops. |
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# pkg/shell — Restricted Shell Interpreter

A restricted shell interpreter designed for AI agents performing SRE investigation tasks.
Safety is the primary design goal: the shell defaults to denying all external command execution
and all filesystem access, requiring explicit opt-in via configuration.

For the complete list of supported and blocked shell features, see [SHELL_FEATURES.md](SHELL_FEATURES.md).

## Execution Model

Scripts are processed in two phases:

1. **Parse & Validate** — The script is parsed into an AST, then validated against an allowlist of
supported syntax nodes.

2. **Execute** — The validated AST is interpreted. Commands are dispatched to builtins or to the
configured ExecHandler. File access goes through the configured OpenHandler, which enforces
AllowedPaths restrictions.

## Shell Features

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

## Security Model

Every access path is default-deny:

| Resource | Default | Opt-in |
|----------------------|----------------------------------|----------------------------------------------|
| 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 | Blocked at validation (exit code 2) | Not configurable — always blocked |

**AllowedPaths** restricts all file operations (open, read, readdir, exec) to a set of specified
directories. It is built on Go's `os.Root` API, which uses kernel-level `openat` syscalls
for atomic path validation, making it immune to symlink traversal, TOCTOU races, and `..` escape attacks.

## Testing

Tests use a YAML scenario-driven framework located in `tests/scenarios/`.

Scenarios are organized by feature area:

```
tests/scenarios/
├── cmd/ # builtin command tests (echo, cat, exit, true, ...)
└── shell/ # shell feature tests (pipes, variables, control flow, globbing, ...)
```

Good sources of POSIX shell test scenarios:
- [yash POSIX shell test suite](https://github.com/magicant/yash/tree/trunk/tests)

## Platform Support

Linux, macOS, and Windows.

## Tips

Prompt for generate test scenarios:
```
Improve pkg/shell/tests scenarios coverage by taking inspiration from pkg/shell/yash_posix_tests

Notes:
- avoid duplicate test coverage, if you encounter duplicate scenarios, remove or merge them
- create as many new scenarios as possible (no limit), of course they must be valuable
- if some tests fail, keep in mind it's possible that pkg/shell implementation is wrong, and it's fine to fix the implementation
```

Prompt for avoid disabling test_against_local_shell when possible
```
In pkg/shell/tests, for each scenarios with test_against_local_shell is disabled,
examine why test_against_local_shell is disabled.

test_against_local_shell is usually disabled because the restricted shell doesn't behave as bash

BUT if it's disabled because of a bug in restricted shell (making it behave differently than bash),
then the restricted shell implementation must be fixed
```
93 changes: 93 additions & 0 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Shell Features Reference

This document lists every shell feature and whether it is supported (✅) or blocked (❌).
Blocked features are rejected before execution with exit code 2.

## Builtins

- ✅ `echo` — prints arguments separated by spaces, followed by a newline
- ✅ `cat` — reads files or stdin (`-`); respects AllowedPaths
- ✅ `true` — exits with code 0
- ✅ `false` — exits with code 1
- ✅ `exit [N]` — exits with code N (default: last exit code)
- ✅ `break [N]` / `continue [N]` — loop control
- ❌ All other commands — return exit code 127 with `<cmd>: not found` unless an ExecHandler is configured

## Variables

- ✅ Assignment: `VAR=value`
- ✅ Expansion: `$VAR`, `${VAR}`
- ✅ `$?` — last exit code (the only supported special variable)
- ✅ Inline assignment: `VAR=value command` (scoped to that command)
- ❌ Command substitution: `$(cmd)`, `` `cmd` ``
- ❌ Arithmetic expansion: `$(( expr ))`
- ❌ Array assignment: `arr=(a b c)`, `arr[0]=x`
- ❌ Append assignment: `VAR+=value`
- ❌ Parameter expansion operations: `${#var}`, `${var:-default}`, `${var:=default}`, `${var:?msg}`, `${var:+alt}`, `${var:offset}`, `${var/pattern/repl}`, `${var#prefix}`, `${var%suffix}`, `${!var}`, `${!prefix*}`, case conversion
- ❌ Positional parameters: `$1`–`$9`, `$@`, `$*`, `$#`, `$0`
- ❌ Special variables: `$!`, `$LINENO`

## Control Flow

- ✅ `for VAR in WORDS; do CMDS; done`
- ✅ `&&` — AND list (short-circuit)
- ✅ `||` — OR list (short-circuit)
- ✅ `!` — negation (inverts exit code)
- ✅ `{ CMDS; }` — brace group
- ✅ `;` and newline as command separators
- ❌ `if` / `elif` / `else`
- ❌ `while` / `until`
- ❌ `case`
- ❌ `select`
- ❌ C-style for loop: `for (( i=0; i<N; i++ ))`
- ❌ Functions: `fname() { ... }`
- ❌ Subshells: `( CMDS )`

## Pipes and Redirections

- ✅ `|` — pipe stdout
- ✅ `<` — input redirection (read-only, within AllowedPaths)
- ✅ `<<DELIM` — heredoc
- ✅ `<<-DELIM` — heredoc with tab stripping
- ❌ `|&` — pipe stdout and stderr (bash extension)
- ❌ `<<<` — herestring (bash extension)
- ❌ `>` — write/truncate
- ❌ `>>` — append
- ❌ `&>` — redirect all
- ❌ `&>>` — append all
- ❌ `<>` — read-write
- ❌ `>&N` / `<&N` — file descriptor duplication

## Quoting and Expansion

- ✅ Single quotes: `'literal'`
- ✅ Double quotes: `"with $expansion"`
- ✅ Globbing: `*`, `?`, `[abc]`, `[a-z]`, `[!a]`
- ✅ Line continuation: `\` at end of line
- ✅ Comments: `# text`
- ❌ Extended globbing: `@(pat)`, `*(pat)`, etc.
- ❌ Tilde expansion: `~`, `~/path`
- ❌ Process substitution: `<(cmd)`, `>(cmd)`

## Execution

- ✅ AllowedPaths filesystem sandboxing — restricts all file access to specified directories
- ❌ External commands — blocked by default; requires an ExecHandler to be configured and the binary to be within AllowedPaths
- ❌ Background execution: `cmd &`
- ❌ Coprocesses: `coproc`
- ❌ `time`
- ❌ `[[ ... ]]` test expressions
- ❌ `(( ... ))` arithmetic commands
- ❌ `declare`, `export`, `local`, `readonly`, `let`

## Environment

- ✅ Empty by default — no parent environment variables are inherited
- ✅ Caller-provided variables via the `Env` option
- ✅ `IFS` is set to space/tab/newline by default
- ❌ No automatic inheritance from the host process
- ❌ `export`, `readonly` are blocked

## Appendix

Formating: In each category, supported features should be listed first, and the most useful ones first.
163 changes: 163 additions & 0 deletions interp/allowed_paths.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// 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 2016-present Datadog, Inc.

package interp

import (
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
)

// AllowedPaths restricts file and directory access to the specified directories.
// Paths must be absolute directories that exist. When set, only files within
// these directories can be opened, read, or executed.
//
// When not set (default), all file access is blocked.
// An empty slice also blocks all file access.
//
// The restriction is enforced using os.Root (Go 1.24+), which uses openat
// syscalls for atomic path validation — immune to symlink and ".." traversal attacks.
func AllowedPaths(paths []string) RunnerOption {
return func(r *Runner) error {
cleaned := make([]string, len(paths))
for i, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("AllowedPaths: cannot resolve %q: %w", p, err)
}
info, err := os.Stat(abs)
if err != nil {
return fmt.Errorf("AllowedPaths: cannot stat %q: %w", abs, err)
}
if !info.IsDir() {
return fmt.Errorf("AllowedPaths: %q is not a directory", abs)
}
cleaned[i] = abs
}
r.allowedPaths = cleaned
return nil
}
}

// findMatchingRoot returns the matching os.Root and relative path for an absolute path.
// It returns false if no root matches.
func findMatchingRoot(absPath string, roots []*os.Root, allowedPaths []string) (*os.Root, string, bool) {
for i, ap := range allowedPaths {
rel, err := filepath.Rel(ap, absPath)
if err != nil {
continue
}
// Check for exact ".." or "..<sep>..." to detect escapes, but not
// filenames that happen to start with two dots (e.g. "..hidden").
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
continue
}
return roots[i], rel, true
}
return nil, "", false
}

// wrapOpenHandler wraps an OpenHandlerFunc to restrict file opens to allowed paths.
// The file is opened through os.Root for atomic path validation.
func wrapOpenHandler(roots []*os.Root, allowedPaths []string) OpenHandlerFunc {
return func(ctx context.Context, path string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) {
absPath := path
if !filepath.IsAbs(absPath) {
hc := HandlerCtx(ctx)
absPath = filepath.Join(hc.Dir, absPath)
}

root, relPath, ok := findMatchingRoot(absPath, roots, allowedPaths)
if !ok {
return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission}
}

return root.OpenFile(relPath, flag, perm)
}
}

// wrapReadDirHandler returns a ReadDirHandlerFunc that restricts directory reads to allowed paths.
func wrapReadDirHandler(roots []*os.Root, allowedPaths []string) ReadDirHandlerFunc {
return func(ctx context.Context, path string) ([]fs.DirEntry, error) {
absPath := path
if !filepath.IsAbs(absPath) {
hc := HandlerCtx(ctx)
absPath = filepath.Join(hc.Dir, absPath)
}

root, relPath, ok := findMatchingRoot(absPath, roots, allowedPaths)
if !ok {
return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission}
}

f, err := root.Open(relPath)
if err != nil {
return nil, err
}
defer f.Close()
entries, err := f.ReadDir(-1)
if err != nil {
return nil, err
}
// os.Root's ReadDir does not guarantee sorted order like os.ReadDir.
// Sort to match POSIX glob expansion expectations.
slices.SortFunc(entries, func(a, b fs.DirEntry) int {
if a.Name() < b.Name() {
return -1
}
if a.Name() > b.Name() {
return 1
}
return 0
})
return entries, nil
}
}

// wrapExecHandler wraps an ExecHandlerFunc to restrict command execution to allowed paths.
// It resolves the command to an absolute path, validates it against allowed roots
// using os.Root.Stat for atomic symlink-safe verification, then delegates to next
// for actual execution. Returns exit code 127 if not found.
func wrapExecHandler(roots []*os.Root, allowedPaths []string, next ExecHandlerFunc) ExecHandlerFunc {
return func(ctx context.Context, args []string) error {
hc := HandlerCtx(ctx)
path, err := ExecLookPathDir(hc.Dir, hc.Env, args[0])
if err != nil {
fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0])
return ExitStatus(127)
}

root, relPath, ok := findMatchingRoot(path, roots, allowedPaths)
if !ok {
fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0])
return ExitStatus(127)
}

// Validate via os.Root.Stat which uses openat-based resolution,
// atomically rejecting symlinks that escape the root directory.
info, err := root.Stat(relPath)
if err != nil {
fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0])
return ExitStatus(127)
}
if info.IsDir() {
fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0])
return ExitStatus(127)
}
if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
fmt.Fprintf(hc.Stderr, "%s: command not found\n", args[0])
return ExitStatus(127)
}

return next(ctx, args)
}
}
Loading