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
3 changes: 3 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ jobs:
- pkg: ./builtins/tests/truncate/
name: truncate
corpus_path: builtins/tests/truncate
- pkg: ./builtins/tests/rm/
name: rm
corpus_path: builtins/tests/rm
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,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`, `logrotate`, and `rm`. 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. The `rm` builtin never deletes directories, recursively or otherwise, but may delete any other non-directory entry (regular files, symlinks, FIFOs, sockets, device nodes), and accepts at most 10 file operands per invocation.

## 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 @@ -33,6 +33,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
- ✅ `printf FORMAT [ARGUMENT]...` — format and print data to stdout; supports `%s`, `%b`, `%c`, `%d`, `%i`, `%o`, `%u`, `%x`, `%X`, `%e`, `%E`, `%f`, `%F`, `%g`, `%G`, `%%`; format reuse for excess arguments; `%n` rejected (security risk); `-v` rejected
- ✅ `pwd [-LP]` — print the absolute pathname of the current working directory; `-L` (default) prints the shell's tracked logical path, `-P` resolves all symlinks; `-P` is best-effort within the sandbox (path components above `AllowedPaths` pass through unresolved); `--version` rejected
- ✅ `read [-r] [-p PROMPT] [-d DELIM] [-n N] [-N N] [-t SECS] [NAME...]` — read one delimited chunk from stdin and assign each IFS-split field to a shell variable (defaulting to `REPLY` when no NAME is given); `-n`/`-N` are capped at 1 MiB; non-raw mode treats `\<newline>` as a line continuation (both characters are dropped) and `\<X>` for any other `X` (including the active custom delimiter under `-d`) as a literal `X` with the backslash removed — e.g. `printf 'a\,b,c' | read -d , x` assigns `x="a,b"`; `-p` is suppressed unless stdin is a terminal (matches bash); `-a` (array), `-s` (silent), `-u` (read from FD), `-e` (readline), and `-i` (initial text) are not implemented
- ✅ `rm [-v] FILE...` — remove files; **remediation mode only**, targets must be within a `:rw` `AllowedPaths` root; directories are always rejected, even empty ones (there is no recursive or `-d`/`--dir` mode) — any other non-directory entry (regular file, symlink, FIFO, socket, device node) may be removed; a symlink argument removes the link itself, never its referent; at most 10 files per invocation, checked before any file is removed; `-v`/`--verbose` prints `removed 'FILE'` per file; `-r`/`-R`/`--recursive`, `-f`/`--force`, `-i`/`-I`/`--interactive`, `-d`/`--dir`, `--preserve-root`, `--no-preserve-root`, and `--one-file-system` are rejected as unknown flags
- ✅ `sed [-n] [-e SCRIPT] [-E|-r] [SCRIPT] [FILE]...` — stream editor for filtering and transforming text; uses RE2 regex engine; `-i`/`-f` rejected; `e`/`w`/`W`/`r`/`R` commands blocked
- ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s`
- ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected
Expand Down Expand Up @@ -124,7 +125,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
- ✅ 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`, `logrotate`, and `rm`; 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
33 changes: 33 additions & 0 deletions allowedpaths/internal/writeopen/dirsyntax.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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 writeopen

import "path/filepath"

// HasTrailingDirSyntax reports whether relPath, as literally passed to
// Unlink, syntactically requires its target to resolve as a directory: it
// ends in a path separator, or its final component is "." or "..". POSIX/GNU
// tools reject such operands (e.g. "file/", "file/.") with ENOTDIR when the
// target — after following any symlink, since a trailing separator forces
// dereference — is not a directory, rather than silently operating on
// whatever remains once path cleaning drops the trailing syntax. "/" is
// always checked since it is the shell's own path-separator syntax; "\" is
// only a separator on Windows — on Unix it is a valid filename character.
//
// Exported so Sandbox.Remove can detect this syntax on the caller's raw path
// before toAbs's filepath.Join cleans the trailing separator away, and
// re-encode the requirement onto relPath before calling Unlink.
func HasTrailingDirSyntax(relPath string) bool {
if relPath == "" {
return false
}
last := relPath[len(relPath)-1]
if last == '/' || (filepath.Separator == '\\' && last == '\\') {
return true
}
base := filepath.Base(relPath)
return base == "." || base == ".."
}
8 changes: 8 additions & 0 deletions allowedpaths/internal/writeopen/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ import "errors"
// symlinks rather than following them so the target cannot change between
// resolution and open.
var ErrSymlinkWriteTarget = errors.New("symlinks are not supported as write targets")

// ErrIsDirectory reports that Unlink's target is a directory.
var ErrIsDirectory = errors.New("is a directory")

// ErrNotDirectory reports that a path passed to Unlink syntactically
// required its target to be a directory (a trailing separator, or a final
// "." or ".." component) but the resolved target is not one.
var ErrNotDirectory = errors.New("not a directory")
74 changes: 74 additions & 0 deletions allowedpaths/internal/writeopen/writeopen_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,77 @@ func writePathError(relPath string, err error) error {
}
return &os.PathError{Op: "openat", Path: relPath, Err: err}
}

// Unlink removes relPath via an atomic, no-follow openat walk to the parent
// directory followed by unlinkat on the held directory fd. Intermediate
// components are rejected if they are symlinks (same as OpenFile); the final
// component may be a symlink, since unlink(2) removes the link itself
// without following it — unless relPath itself syntactically demands
// directory semantics (a trailing separator, or a final "." / ".."
// component, e.g. "file/" or "symlink-to-file/"), in which case POSIX forces
// the target to be dereferenced before the directory check. Directories are
// rejected via fstatat on the same held fd, closing the TOCTOU window
// between a directory check and the actual removal.
func Unlink(rootFile *os.File, _ *os.Root, relPath string) error {
if rootFile == nil {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: os.ErrPermission}
}

requiresDir := HasTrailingDirSyntax(relPath)

clean := filepath.Clean(relPath)
Comment thread
julesmcrt marked this conversation as resolved.
Comment thread
julesmcrt marked this conversation as resolved.
if clean == "." {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ErrIsDirectory}
}
components := strings.Split(clean, string(filepath.Separator))
dirFD := int(rootFile.Fd())
closeDir := false
for _, component := range components[:len(components)-1] {
if component == "" || component == "." {
continue
}
if component == ".." {
if closeDir {
_ = unix.Close(dirFD)
}
return &os.PathError{Op: "unlinkat", Path: relPath, Err: os.ErrPermission}
}
nextFD, err := unix.Openat(dirFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
if closeDir {
_ = unix.Close(dirFD)
}
if err != nil {
return writePathError(relPath, err)
}
dirFD = nextFD
closeDir = true
}
if closeDir {
defer func() { _ = unix.Close(dirFD) }()
}

base := components[len(components)-1]
if base == "" || base == "." || base == ".." {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ErrIsDirectory}
}

statFlags := unix.AT_SYMLINK_NOFOLLOW
if requiresDir {
statFlags = 0
}
var stat unix.Stat_t
if err := unix.Fstatat(dirFD, base, &stat, statFlags); err != nil {
return writePathError(relPath, err)
}
if stat.Mode&unix.S_IFMT == unix.S_IFDIR {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ErrIsDirectory}
}
if requiresDir {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ErrNotDirectory}
}

if err := unix.Unlinkat(dirFD, base, 0); err != nil {
return writePathError(relPath, err)
}
return nil
}
173 changes: 172 additions & 1 deletion allowedpaths/internal/writeopen/writeopen_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@

package writeopen

import "os"
import (
"os"
"path/filepath"
"unsafe"

"golang.org/x/sys/windows"
)

func OpenRoot(*os.Root) (*os.File, error) {
return nil, nil
Expand All @@ -22,3 +28,168 @@ func OpenFile(_ *os.File, root *os.Root, relPath string, flag int, perm os.FileM
// openat walker.
return root.OpenFile(relPath, flag, perm)
}

// fileDispositionInformationEx mirrors the NT FILE_DISPOSITION_INFORMATION_EX
// struct (a single ULONG Flags field), which golang.org/x/sys/windows does
// not expose as a type even though it exposes the FILE_DISPOSITION_* flag
// constants and the NtSetInformationFile syscall that consumes it.
type fileDispositionInformationEx struct {
Flags uint32
}

// Unlink removes relPath. The parent directory is opened through
// os.Root.OpenFile, reusing its handle-relative, no-follow-anywhere walk to
// validate every intermediate path component; only the final component is
// then resolved and deleted directly via NtCreateFile+NtSetInformationFile,
// mirroring what Go's os.Root.Remove does internally on Windows (which is
// unfortunately unexported and unreachable from outside the standard
// library). Doing the directory check and the delete-on-close disposition
// on the same held handle closes the TOCTOU window between a separate Lstat
// and Remove: nothing can swap the object out from under an already-open
// handle. FILE_OPEN_REPARSE_POINT ensures a symlink leaf is deleted as
// itself, never followed, matching unlink(2) semantics on Unix.
//
// The leaf is deliberately opened without FILE_NON_DIRECTORY_FILE: NTFS sets
// FILE_ATTRIBUTE_DIRECTORY on a symlink/junction whose target is a
// directory, on the reparse point itself, not just its target. With
// FILE_NON_DIRECTORY_FILE, NtCreateFile would reject opening that reparse
// point with STATUS_FILE_IS_A_DIRECTORY even though FILE_OPEN_REPARSE_POINT
// means it's the link, not a real directory, being opened — incorrectly
// refusing to remove a directory symlink. Instead, the directory check is
// done after open by inspecting the actual file attributes: an
// FILE_ATTRIBUTE_DIRECTORY without FILE_ATTRIBUTE_REPARSE_POINT is a real
// directory; a reparse point is always removable regardless of what it
// resolves to, matching Lstat-based directory detection on Unix — unless
// relPath itself syntactically demands directory semantics (a trailing
// separator, or a final "." / ".." component), in which case POSIX forces
// the target to be dereferenced (via isReparseTargetDir) before deciding
// between ErrIsDirectory and ErrNotDirectory.
func Unlink(_ *os.File, root *os.Root, relPath string) error {
requiresDir := HasTrailingDirSyntax(relPath)

clean := filepath.Clean(relPath)
if clean == "." {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ErrIsDirectory}
}
base := filepath.Base(clean)
if base == "" || base == "." || base == ".." {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ErrIsDirectory}
}

parent, err := root.OpenFile(filepath.Dir(clean), os.O_RDONLY, 0)
if err != nil {
return err
}
defer parent.Close()

nameU, err := windows.NewNTUnicodeString(base)
if err != nil {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: err}
}
objAttrs := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: windows.Handle(parent.Fd()),
ObjectName: nameU,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}

var h windows.Handle
iosb := &windows.IO_STATUS_BLOCK{}
ntErr := windows.NtCreateFile(
&h,
windows.DELETE|windows.FILE_READ_ATTRIBUTES,
objAttrs,
iosb,
nil,
0,
windows.FILE_SHARE_DELETE|windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
windows.FILE_OPEN,
windows.FILE_OPEN_REPARSE_POINT|windows.FILE_OPEN_FOR_BACKUP_INTENT,
0, 0,
)
if ntErr != nil {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ntStatusErr(ntErr)}
}
defer windows.CloseHandle(h)

var byHandleInfo windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(h, &byHandleInfo); err != nil {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: err}
}
isReparsePoint := byHandleInfo.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0
isRealDir := byHandleInfo.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 && !isReparsePoint
if isRealDir {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ErrIsDirectory}
}

if requiresDir {
if isReparsePoint {
// Trailing separator syntax forces dereferencing the link to
// determine the real target type, matching POSIX semantics.
targetIsDir, err := isReparseTargetDir(objAttrs)
if err != nil {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: err}
}
if targetIsDir {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ErrIsDirectory}
}
}
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ErrNotDirectory}
}

disposition := fileDispositionInformationEx{
Flags: windows.FILE_DISPOSITION_DELETE | windows.FILE_DISPOSITION_POSIX_SEMANTICS,
}
if ntErr := windows.NtSetInformationFile(
h, iosb,
(*byte)(unsafe.Pointer(&disposition)), uint32(unsafe.Sizeof(disposition)),
windows.FileDispositionInformationEx,
); ntErr != nil {
return &os.PathError{Op: "unlinkat", Path: relPath, Err: ntStatusErr(ntErr)}
}
return nil
}

// isReparseTargetDir opens objAttrs' object following the reparse point
// (omitting FILE_OPEN_REPARSE_POINT) to determine whether the dereferenced
// target is a directory. Used only when the caller's path syntactically
// demanded directory semantics (a trailing separator), which per POSIX
// forces dereferencing a symlink leaf rather than inspecting the link
// itself.
func isReparseTargetDir(objAttrs *windows.OBJECT_ATTRIBUTES) (bool, error) {
var h windows.Handle
iosb := &windows.IO_STATUS_BLOCK{}
ntErr := windows.NtCreateFile(
&h,
windows.FILE_READ_ATTRIBUTES,
objAttrs,
iosb,
nil,
0,
windows.FILE_SHARE_DELETE|windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
windows.FILE_OPEN,
windows.FILE_OPEN_FOR_BACKUP_INTENT,
0, 0,
)
if ntErr != nil {
return false, ntStatusErr(ntErr)
}
defer windows.CloseHandle(h)

var byHandleInfo windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(h, &byHandleInfo); err != nil {
return false, err
}
return byHandleInfo.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0, nil
}

// ntStatusErr converts an NTStatus into the equivalent syscall.Errno (e.g.
// ERROR_FILE_NOT_FOUND) so callers can use errors.Is with the usual
// os.ErrNotExist / os.ErrPermission sentinels instead of matching raw
// NTStatus values.
func ntStatusErr(err error) error {
if status, ok := err.(windows.NTStatus); ok {
return status.Errno()
}
return err
}
14 changes: 14 additions & 0 deletions allowedpaths/portable.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ func PortableErrMsg(err error) string {
return err.Error()
}

// rewrapPathError rebuilds err as a *os.PathError using the caller-facing op
// and path rather than whatever os.Root's internal error carried (e.g. its
// own "statat" op name and a path relative to the sandbox root, not the
// path the caller passed in). The inner error is preserved as-is (not
// stringified) so errors.Is checks against fs.ErrNotExist/fs.ErrPermission
// etc. still work when the result is passed through PortableErrMsg again.
func rewrapPathError(op, path string, err error) error {
var pe *os.PathError
if errors.As(err, &pe) {
return &os.PathError{Op: op, Path: path, Err: pe.Err}
}
return &os.PathError{Op: op, Path: path, Err: err}
}

// PortablePathError returns a *os.PathError with a normalized error message.
// If the error is not a *os.PathError, it is returned as-is.
// Only the Err field is normalized; the Path and Op fields are preserved as-is.
Expand Down
Loading
Loading