From 1bc28bcc09363cd0fbbc8293abee20af54a06d7f Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 24 Jun 2026 16:38:01 -0400 Subject: [PATCH 1/2] add denied paths sandbox policy --- README.md | 10 +- SHELL_FEATURES.md | 4 +- allowedpaths/path_mode.go | 20 ++ allowedpaths/path_mode_nonwindows.go | 14 + allowedpaths/path_mode_windows.go | 9 + allowedpaths/portable_unix.go | 12 + allowedpaths/portable_windows.go | 22 ++ allowedpaths/read_open_unix.go | 206 ++++++++++++ allowedpaths/read_open_windows.go | 49 +++ allowedpaths/sandbox.go | 304 +++++++++++++++++- allowedpaths/sandbox_test.go | 26 ++ allowedpaths/write_open.go | 8 + analysis/symbols_allowedpaths.go | 12 + builtins/builtins.go | 5 + builtins/help/help.go | 15 + cmd/rshell/main.go | 11 + cmd/rshell/main_test.go | 2 + interp/allowed_paths_internal_test.go | 13 + interp/allowed_paths_test.go | 28 ++ interp/api.go | 42 ++- interp/runner_exec.go | 12 + .../denied_paths/help_lists_denied_paths.yaml | 18 ++ .../list_hides_read_denied_child.yaml | 19 ++ .../list_shows_write_denied_child.yaml | 20 ++ .../denied_paths/read_denied_blocks_cat.yaml | 20 ++ .../read_denied_blocks_write.yaml | 17 + .../symlink_into_read_denied_blocked.yaml | 18 ++ .../write_denied_allows_read.yaml | 19 ++ tests/scenarios_test.go | 29 ++ 29 files changed, 964 insertions(+), 20 deletions(-) create mode 100644 allowedpaths/read_open_unix.go create mode 100644 allowedpaths/read_open_windows.go create mode 100644 tests/scenarios/shell/denied_paths/help_lists_denied_paths.yaml create mode 100644 tests/scenarios/shell/denied_paths/list_hides_read_denied_child.yaml create mode 100644 tests/scenarios/shell/denied_paths/list_shows_write_denied_child.yaml create mode 100644 tests/scenarios/shell/denied_paths/read_denied_blocks_cat.yaml create mode 100644 tests/scenarios/shell/denied_paths/read_denied_blocks_write.yaml create mode 100644 tests/scenarios/shell/denied_paths/symlink_into_read_denied_blocked.yaml create mode 100644 tests/scenarios/shell/denied_paths/write_denied_allows_read.yaml diff --git a/README.md b/README.md index 35571d77a..1f553a0c1 100644 --- a/README.md +++ b/README.md @@ -60,24 +60,26 @@ Every access path is default-deny: |----------------------|-------------------------------------|----------------------------------------------| | Command execution | All commands blocked (exit code 127)| `AllowedCommands` with namespaced command list (e.g. `rshell:cat`) | | External commands | Blocked (exit code 127) | Provide an `ExecHandler` | -| Filesystem access | Blocked | Configure `AllowedPaths` with `PATH[:ro|:rw]` root specs | +| Filesystem access | Blocked | Configure `AllowedPaths` with `PATH[:ro|:rw]` root specs; optionally carve out subtrees with `DeniedPaths` `PATH[:r|:w]` 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. -**AllowedPaths** restricts all file operations to specified directories using Go's `os.Root` API for reads and openat-based write handling for writes. +**AllowedPaths** restricts all file operations to specified directories using Go's `os.Root` API for reads and openat-based write handling for writes. **DeniedPaths** explicitly denies subtrees inside those allowed roots. - **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. - **Permission suffix:** Path entries may end with `:ro` or `:rw` representing read-only and read-write modes, respectively; entries without a suffix default to read-only, and the suffix is stripped before path validation. In remediation mode, write operations are accepted only inside the most-specific matching `:rw` root. +- **Deny suffix:** Denied path entries may end with `:r` or `:w`. `:r` denies reads and writes; `:w` denies writes only. Entries without a suffix default to `:r`. Direct `:r` denied children are filtered from directory listings; `:w` denied children remain visible and readable. - **Symlink policy:** A symlink pointing outside its `os.Root` is followed for reads but never for writes; on Unix, symlink components in write targets are rejected with `symlinks are not supported as write targets` rather than followed, eliminating the TOCTOU window where a malicious link target could be swapped between resolution and open. +- **Denied-path symlinks:** On Unix, reads with configured DeniedPaths use a deny-aware `openat` resolver that follows symlinks explicitly and checks pinned deny-root identities before returning the final file descriptor. Windows applies the same deny policy on a best-effort handle/root basis. - **Output redirections by mode:** - _Read-only mode (default):_ file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) are rejected at parse time (exit 2). - _Remediation mode:_ those redirections open through the same sandbox — writes inside `:rw` allowlist roots succeed; targets outside the allowlist or inside read-only roots fail with `permission denied` (exit 1). - **Special targets:** The literal target `/dev/null` is always short-circuited to a discarded sink without going through the sandbox. `<>` (read-write open) is blocked in all modes. - **Diagnostic messages:** Configured directories that cannot be opened (missing, not a directory, no permission) are skipped with a warning flushed once to the runner's stderr at construction time. Silence them or redirect them programmatically with `WarningsWriter(io.Writer)` or `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. +> **Note:** The `ss`, `ip route`, and `df` builtins bypass `AllowedPaths` and `DeniedPaths` 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 path policy 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. To avoid leaking secrets passed as CLI arguments, `ps` does not read `/proc//cmdline`; the `CMD` column reports only the process comm/executable name. @@ -85,7 +87,7 @@ Every access path is default-deny: ## Shell Features -Inside rshell, run `help` to list supported feature categories, a concise unsupported-feature summary, enabled commands, and the configured `AllowedPaths` sandbox roots (or a notice when none are configured). Use `help ` for details about a specific rshell feature or command. +Inside rshell, run `help` to list supported feature categories, a concise unsupported-feature summary, enabled commands, and the configured `AllowedPaths` sandbox roots plus any `DeniedPaths` roots. Use `help ` for details about a specific rshell feature or command. See [SHELL_FEATURES.md](SHELL_FEATURES.md) for the complete list of supported and blocked features. diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 38140a403..00589a679 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -20,7 +20,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `find [-L] [-P] [PATH...] [EXPRESSION]` — search for files in a directory hierarchy; supports `--help`, `-name`, `-iname`, `-path`, `-ipath`, `-type` (b,c,d,f,l,p,s), `-size`, `-empty`, `-newer`, `-mtime`, `-mmin`, `-perm`, `-maxdepth`, `-mindepth`, `-print`, `-print0`, `-exec CMD {} \;`, `-execdir CMD {} \;`, `-prune`, `-quit`, logical operators (`!`, `-a`, `-o`, `()`); blocks `-delete`, `-regex` for sandbox safety - ✅ `grep [-EFGivclLnHhoqsxw] [-e PATTERN] [-m NUM] [-A NUM] [-B NUM] [-C NUM] PATTERN [FILE]...` — print lines that match patterns; uses RE2 regex engine (linear-time, no backtracking) - ✅ `head [-n N|-c N] [-q|-v] [FILE]...` — output the first part of files (default: first 10 lines); `-z`/`--zero-terminated` and `--follow` are rejected -- ✅ `help [--all] [feature|command]` — display rshell features, a concise unsupported-feature summary, available commands, and the configured `AllowedPaths` sandbox roots (or a notice that no paths are configured); with a topic, show detailed help for that feature or command +- ✅ `help [--all] [feature|command]` — display rshell features, a concise unsupported-feature summary, available commands, the configured `AllowedPaths` sandbox roots (or a notice that no paths are configured), and configured `DeniedPaths` roots; with a topic, show detailed help for that feature or command - ✅ `ip [-o|-4|-6|--brief] addr|link [show] [dev IFNAME]` — show network interface addresses and link-layer info (read-only); write ops (`add`, `del`, `flush`, `set`), namespace ops (`netns`, `-n`), and batch mode (`-b`/`-B`/`--force`) are blocked - ✅ `ip route [show|list]` — show IPv4 routing table (Linux only; reads `/proc/net/route` directly via `os.Open`, bypassing `AllowedPaths`); at most 10 000 entries loaded; lines longer than 1 MiB abort parsing with an error (exit 1) - ✅ `ip route get ADDRESS` — show the route selected by longest-prefix-match for ADDRESS (Linux only); write ops (`add`, `del`, `flush`, `replace`, `change`, `save`, `restore`) are blocked; `-6` (IPv6 routing) is not supported @@ -122,6 +122,7 @@ 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 (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 +- ✅ DeniedPaths filesystem deny rules — explicitly deny subtrees inside AllowedPaths. Entries may end with `:r` (deny reads and writes) or `:w` (deny writes only); entries without a suffix default to `:r`. Direct `:r` denied children are filtered from directory listings; `:w` denied children remain visible and readable. Kernel-state builtins that intentionally bypass AllowedPaths (`ss`, `ip route`, `df`, and `ps` proc reads) also bypass DeniedPaths - ✅ 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//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 @@ -139,6 +140,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ Caller-provided variables via the `Env` option - ✅ `IFS` is set to space/tab/newline by default - ✅ `ALLOWED_PATHS` — when `AllowedPaths` is configured, set to a `filepath.ListSeparator`-delimited list of resolved allowed directories (`:` on Unix, `;` on Windows) +- ✅ `DENIED_PATHS` — when path sandboxing is configured, set to a `filepath.ListSeparator`-delimited list of resolved denied directories (`:` on Unix, `;` on Windows) - ❌ No automatic inheritance from the host process - ❌ `export`, `readonly` are blocked diff --git a/allowedpaths/path_mode.go b/allowedpaths/path_mode.go index 872b43d2d..be64c7193 100644 --- a/allowedpaths/path_mode.go +++ b/allowedpaths/path_mode.go @@ -33,3 +33,23 @@ func splitAllowedPathMode(path string) (string, pathMode, bool) { } return path, pathModeReadOnly, false } + +func parseDeniedPathMode(path string) (string, denyMode) { + path, mode, _ := splitDeniedPathMode(path) + return path, mode +} + +func splitDeniedPathMode(path string) (string, denyMode, bool) { + for _, suffix := range []struct { + text string + mode denyMode + }{ + {text: ":r", mode: denyModeRead | denyModeWrite}, + {text: ":w", mode: denyModeWrite}, + } { + if strings.HasSuffix(path, suffix.text) && len(path) > len(suffix.text) { + return path[:len(path)-len(suffix.text)], suffix.mode, true + } + } + return path, denyModeRead | denyModeWrite, false +} diff --git a/allowedpaths/path_mode_nonwindows.go b/allowedpaths/path_mode_nonwindows.go index 51d0eaa7d..90886f464 100644 --- a/allowedpaths/path_mode_nonwindows.go +++ b/allowedpaths/path_mode_nonwindows.go @@ -25,3 +25,17 @@ func resolveAllowedPathMode(path string) (string, pathMode) { } return stripped, mode } + +func resolveDeniedPathMode(path string) (string, denyMode) { + stripped, mode, ok := splitDeniedPathMode(path) + if !ok { + return path, denyModeRead | denyModeWrite + } + // On POSIX filesystems, paths may literally end in ":r" or ":w". + // Preserve an existing literal path, or any path we cannot prove is absent, + // so a config suffix never widens access by stripping real filename text. + if _, err := os.Lstat(path); err == nil || !errors.Is(err, os.ErrNotExist) { + return path, denyModeRead | denyModeWrite + } + return stripped, mode +} diff --git a/allowedpaths/path_mode_windows.go b/allowedpaths/path_mode_windows.go index d40fa7d49..cac2eac51 100644 --- a/allowedpaths/path_mode_windows.go +++ b/allowedpaths/path_mode_windows.go @@ -20,3 +20,12 @@ func resolveAllowedPathMode(path string) (string, pathMode) { // rshell mode suffix interpretation for this unsupported ambiguity. return stripped, mode } + +func resolveDeniedPathMode(path string) (string, denyMode) { + stripped, mode, ok := splitDeniedPathMode(path) + if !ok { + return path, denyModeRead | denyModeWrite + } + // Windows treats terminal ":r" and ":w" as rshell deny-mode metadata. + return stripped, mode +} diff --git a/allowedpaths/portable_unix.go b/allowedpaths/portable_unix.go index 9d2306aee..e7de8a56c 100644 --- a/allowedpaths/portable_unix.go +++ b/allowedpaths/portable_unix.go @@ -46,6 +46,18 @@ func sameOpenedRootAndPath(root *os.Root, path string) (bool, error) { return rootDev == pathDev && rootIno == pathIno, nil } +func identityForOpenedRoot(root *os.Root) (fileIdentity, error) { + info, err := root.Stat(".") + if err != nil { + return fileIdentity{}, err + } + dev, ino, ok := FileIdentity("", info, nil) + if !ok { + return fileIdentity{}, errors.New("file identity is unavailable") + } + return fileIdentity{dev: dev, ino: ino}, nil +} + func (r *root) accessCheck(rel string, checkRead, checkWrite, checkExec bool) (fs.FileInfo, error) { // Write-only or exec-only checks (no read): single Stat + mode-bit // inspection. No TOCTOU because there is only one resolution. diff --git a/allowedpaths/portable_windows.go b/allowedpaths/portable_windows.go index cd3327eb7..63b325cc3 100644 --- a/allowedpaths/portable_windows.go +++ b/allowedpaths/portable_windows.go @@ -73,6 +73,20 @@ func sameOpenedRootAndPath(root *os.Root, path string) (bool, error) { return rootVolume == pathVolume && rootIndex == pathIndex, nil } +func identityForOpenedRoot(root *os.Root) (fileIdentity, error) { + rootFile, err := root.Open(".") + if err != nil { + return fileIdentity{}, err + } + defer rootFile.Close() + + volume, index, err := fileIdentityFromHandle(rootFile) + if err != nil { + return fileIdentity{}, err + } + return fileIdentity{dev: volume, ino: index}, nil +} + func fileIdentityFromHandle(f *os.File) (uint64, uint64, error) { h := syscall.Handle(f.Fd()) var d syscall.ByHandleFileInformation @@ -82,6 +96,14 @@ func fileIdentityFromHandle(f *os.File) (uint64, uint64, error) { return uint64(d.VolumeSerialNumber), uint64(d.FileIndexHigh)<<32 | uint64(d.FileIndexLow), nil } +func fileIdentityFromOpenFile(f *os.File) (fileIdentity, bool) { + volume, index, err := fileIdentityFromHandle(f) + if err != nil { + return fileIdentity{}, false + } + return fileIdentity{dev: volume, ino: index}, true +} + // accessCheck verifies the path is inside the sandbox via os.Root.Stat, // then checks read permission by attempting to open the file through // os.Root. This respects NTFS ACLs — the kernel denies the open if diff --git a/allowedpaths/read_open_unix.go b/allowedpaths/read_open_unix.go new file mode 100644 index 000000000..7fbefac94 --- /dev/null +++ b/allowedpaths/read_open_unix.go @@ -0,0 +1,206 @@ +// 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 || darwin + +package allowedpaths + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +type readRestart struct { + absPath string +} + +func (s *Sandbox) openReadDenyAware(path string, cwd string, flag int, perm os.FileMode) (*os.File, error) { + absPath := filepath.Clean(toAbs(path, cwd)) + for hops := 0; hops <= maxSymlinkHops; hops++ { + f, restart, err := s.openReadDenyAwareAbs(path, absPath, flag, perm) + if err != nil { + return nil, err + } + if restart == nil { + return f, nil + } + absPath = filepath.Clean(restart.absPath) + } + return nil, &os.PathError{Op: "openat", Path: path, Err: unix.ELOOP} +} + +func (s *Sandbox) openReadDenyAwareAbs(displayPath, absPath string, flag int, perm os.FileMode) (*os.File, *readRestart, error) { + if s.deniedFor(absPath, denyModeRead) { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + ar, relPath, ok := s.resolve(absPath) + if !ok { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + if ar.readRoot == nil { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + + clean := filepath.Clean(relPath) + components := []string{"."} + if clean != "." { + components = strings.Split(clean, string(filepath.Separator)) + } + + dirFD := int(ar.readRoot.Fd()) + closeDir := false + currentAbs := ar.absPath + activeDeny := s.denyModeForPath(currentAbs) + if activeDeny&denyModeRead != 0 { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + for i, component := range components { + final := i == len(components)-1 + if component == "" || component == "." { + if final { + fd, err := unix.Openat(dirFD, ".", flag|unix.O_CLOEXEC|unix.O_NOFOLLOW, uint32(perm.Perm())) + if closeDir { + _ = unix.Close(dirFD) + } + if err != nil { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: err} + } + if err := s.checkOpenedDeny(fd, displayPath, activeDeny, denyModeRead); err != nil { + _ = unix.Close(fd) + return nil, nil, err + } + f := os.NewFile(uintptr(fd), displayPath) + if f == nil { + _ = unix.Close(fd) + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: errors.New("invalid file descriptor")} + } + return f, nil, nil + } + continue + } + if component == ".." { + if closeDir { + _ = unix.Close(dirFD) + } + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + + nextAbs := filepath.Join(currentAbs, component) + activeDeny |= s.denyModeForPath(nextAbs) + if activeDeny&denyModeRead != 0 { + if closeDir { + _ = unix.Close(dirFD) + } + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + + if final { + fd, err := unix.Openat(dirFD, component, flag|unix.O_CLOEXEC|unix.O_NOFOLLOW, uint32(perm.Perm())) + if errors.Is(err, unix.ELOOP) { + target, linkErr := readlinkat(dirFD, component) + if closeDir { + _ = unix.Close(dirFD) + } + if linkErr != nil { + return nil, nil, &os.PathError{Op: "readlinkat", Path: displayPath, Err: linkErr} + } + return nil, &readRestart{absPath: s.resolveSymlinkTarget(currentAbs, target, nil)}, nil + } + if closeDir { + _ = unix.Close(dirFD) + } + if err != nil { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: err} + } + if err := s.checkOpenedDeny(fd, displayPath, activeDeny, denyModeRead); err != nil { + _ = unix.Close(fd) + return nil, nil, err + } + f := os.NewFile(uintptr(fd), displayPath) + if f == nil { + _ = unix.Close(fd) + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: errors.New("invalid file descriptor")} + } + return f, nil, nil + } + + nextFD, err := unix.Openat(dirFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if errors.Is(err, unix.ELOOP) { + target, linkErr := readlinkat(dirFD, component) + if closeDir { + _ = unix.Close(dirFD) + } + if linkErr != nil { + return nil, nil, &os.PathError{Op: "readlinkat", Path: displayPath, Err: linkErr} + } + return nil, &readRestart{absPath: s.resolveSymlinkTarget(currentAbs, target, components[i+1:])}, nil + } + if closeDir { + _ = unix.Close(dirFD) + } + if err != nil { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: err} + } + if err := s.checkOpenedDeny(nextFD, displayPath, activeDeny, denyModeRead); err != nil { + _ = unix.Close(nextFD) + return nil, nil, err + } + dirFD = nextFD + closeDir = true + currentAbs = nextAbs + } + + if closeDir { + _ = unix.Close(dirFD) + } + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrInvalid} +} + +func (s *Sandbox) resolveSymlinkTarget(parentAbs string, target string, remaining []string) string { + var absPath string + if filepath.IsAbs(target) { + absPath = target + if s.hostPrefix != "" && !strings.HasPrefix(absPath, s.hostPrefix+string(filepath.Separator)) { + absPath = filepath.Join(s.hostPrefix, absPath) + } + } else { + absPath = filepath.Join(parentAbs, target) + } + if len(remaining) > 0 { + absPath = filepath.Join(absPath, filepath.Join(remaining...)) + } + return absPath +} + +func (s *Sandbox) checkOpenedDeny(fd int, displayPath string, active denyMode, requested denyMode) error { + var st unix.Stat_t + if err := unix.Fstat(fd, &st); err != nil { + return &os.PathError{Op: "fstat", Path: displayPath, Err: err} + } + mode := active | s.denyModeForIdentity(fileIdentity{dev: uint64(st.Dev), ino: uint64(st.Ino)}) + if mode&requested != 0 { + return &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + return nil +} + +func readlinkat(dirFD int, name string) (string, error) { + size := 256 + for { + buf := make([]byte, size) + n, err := unix.Readlinkat(dirFD, name, buf) + if err != nil { + return "", err + } + if n < len(buf) { + return string(buf[:n]), nil + } + size *= 2 + } +} diff --git a/allowedpaths/read_open_windows.go b/allowedpaths/read_open_windows.go new file mode 100644 index 000000000..bac347dbf --- /dev/null +++ b/allowedpaths/read_open_windows.go @@ -0,0 +1,49 @@ +// 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 windows + +package allowedpaths + +import ( + "os" + "path/filepath" +) + +func (s *Sandbox) openReadDenyAware(path string, cwd string, flag int, perm os.FileMode) (*os.File, error) { + absPath := filepath.Clean(toAbs(path, cwd)) + if s.deniedFor(absPath, denyModeRead) { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + ar, relPath, ok := s.resolve(absPath) + if !ok { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + + resolved, resolvedRel, ok := s.resolveRootFollowingSymlinks(absPath, false) + if ok { + resolvedAbs := filepath.Join(resolved.absPath, resolvedRel) + resolvedCanonicalAbs := filepath.Join(resolved.canonicalAbsPath, resolvedRel) + if s.deniedFor(resolvedAbs, denyModeRead) || s.deniedFor(resolvedCanonicalAbs, denyModeRead) { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + } + + f, err := ar.root.OpenFile(relPath, flag, perm) + if err != nil && isPathEscapeError(err) { + if r, rel, ok := s.resolveFollowingSymlinks(absPath, false); ok { + f, err = r.OpenFile(rel, flag, perm) + } + } + if err != nil { + return nil, err + } + identity, ok := fileIdentityFromOpenFile(f) + if ok && s.denyModeForIdentity(identity)&denyModeRead != 0 { + f.Close() + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + return f, nil +} diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 24d2ea979..0fb6af3f6 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -45,14 +45,35 @@ type root struct { canonicalAbsPath string mode pathMode root *os.Root + readRoot *os.File writeRoot *os.File } +type denyMode uint8 + +const ( + denyModeRead denyMode = 1 << iota + denyModeWrite +) + +type fileIdentity struct { + dev uint64 + ino uint64 +} + +type denyRoot struct { + absPath string + canonicalAbsPath string + mode denyMode + identity fileIdentity +} + // Sandbox restricts filesystem access to a set of allowed directories. // The restriction is enforced using os.Root (Go 1.24+), which uses openat // syscalls for atomic path validation — immune to symlink and ".." traversal attacks. type Sandbox struct { roots []root + denyRoots []denyRoot hostPrefix string // when non-empty, enables container symlink resolution readOnly bool // when true (default), Open and Truncate reject writes } @@ -64,6 +85,14 @@ type Sandbox struct { // Diagnostic messages about skipped paths are collected into warnings. The // caller is responsible for writing them to the appropriate output stream. func New(paths []string) (sb *Sandbox, warnings []byte, err error) { + return NewWithDeniedPaths(paths, nil) +} + +// NewWithDeniedPaths creates a sandbox from an allowlist of directory paths +// and a denylist of subtrees. Denied paths may end with :r or :w. :r denies +// reads and writes; :w denies writes only. Entries without a suffix default to +// :r. Paths that do not exist or cannot be opened are skipped with warnings. +func NewWithDeniedPaths(paths []string, deniedPaths []string) (sb *Sandbox, warnings []byte, err error) { var buf bytes.Buffer roots := make([]root, 0, len(paths)) for _, p := range paths { @@ -87,18 +116,57 @@ func New(paths []string) (sb *Sandbox, warnings []byte, err error) { fmt.Fprintf(&buf, "AllowedPaths: skipping %q: %v\n", abs, err) continue } + readRoot, err := openRootFile(r) + if err != nil { + r.Close() + fmt.Fprintf(&buf, "AllowedPaths: skipping %q: %v\n", abs, err) + continue + } var writeRoot *os.File if mode == pathModeReadWrite { writeRoot, err = openWriteRoot(r) if err != nil { + closeRootFile(readRoot) r.Close() fmt.Fprintf(&buf, "AllowedPaths: skipping %q: %v\n", abs, err) continue } } - roots = append(roots, root{absPath: abs, canonicalAbsPath: canonical, mode: mode, root: r, writeRoot: writeRoot}) + roots = append(roots, root{absPath: abs, canonicalAbsPath: canonical, mode: mode, root: r, readRoot: readRoot, writeRoot: writeRoot}) } - return &Sandbox{roots: roots, readOnly: true}, buf.Bytes(), nil + denyRoots := make([]denyRoot, 0, len(deniedPaths)) + for _, p := range deniedPaths { + p, mode := resolveDeniedPathMode(p) + abs, err := filepath.Abs(p) + if err != nil { + fmt.Fprintf(&buf, "DeniedPaths: skipping %q: %v\n", p, err) + continue + } + r, err := os.OpenRoot(abs) + if err != nil { + fmt.Fprintf(&buf, "DeniedPaths: skipping %q: %v\n", abs, err) + continue + } + canonical, err := canonicalForOpenedRoot(abs, r) + if err != nil { + r.Close() + fmt.Fprintf(&buf, "DeniedPaths: skipping %q: %v\n", abs, err) + continue + } + identity, err := identityForOpenedRoot(r) + r.Close() + if err != nil { + fmt.Fprintf(&buf, "DeniedPaths: skipping %q: %v\n", abs, err) + continue + } + denyRoots = append(denyRoots, denyRoot{ + absPath: abs, + canonicalAbsPath: canonical, + mode: mode, + identity: identity, + }) + } + return &Sandbox{roots: roots, denyRoots: denyRoots, readOnly: true}, buf.Bytes(), nil } func canonicalForOpenedRoot(abs string, r *os.Root) (string, error) { @@ -315,11 +383,66 @@ func isWithinRoot(rootPath, path string) bool { return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } +func pathWithin(rootPath, path string) bool { + rel, err := filepath.Rel(filepath.Clean(rootPath), filepath.Clean(path)) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func (s *Sandbox) denyModeForPath(absPath string) denyMode { + if s == nil { + return 0 + } + absPath = filepath.Clean(absPath) + var mode denyMode + for i := range s.denyRoots { + deny := &s.denyRoots[i] + if pathWithin(deny.absPath, absPath) || pathWithin(deny.canonicalAbsPath, absPath) { + mode |= deny.mode + } + } + return mode +} + +func (s *Sandbox) denyModeForIdentity(identity fileIdentity) denyMode { + if s == nil { + return 0 + } + var mode denyMode + for i := range s.denyRoots { + deny := &s.denyRoots[i] + if deny.identity == identity { + mode |= deny.mode + } + } + return mode +} + +func (s *Sandbox) deniedFor(absPath string, mode denyMode) bool { + return s.denyModeForPath(absPath)&mode != 0 +} + +func (s *Sandbox) denyModeForResolvedPath(absPath string, preserveLast bool) denyMode { + mode := s.denyModeForPath(absPath) + if len(s.denyRoots) == 0 { + return mode + } + resolved, resolvedRel, ok := s.resolveRootFollowingSymlinks(absPath, preserveLast) + if !ok { + return mode + } + mode |= s.denyModeForPath(filepath.Join(resolved.absPath, resolvedRel)) + mode |= s.denyModeForPath(filepath.Join(resolved.canonicalAbsPath, resolvedRel)) + return mode +} + // resolveWriteTarget follows in-root symlinks before writes so path modes are // enforced against the final most-specific root, not just the lexical path. func (s *Sandbox) resolveWriteTarget(absPath string) (*root, string, bool) { ar, relPath, ok := s.resolve(absPath) - if !ok || ar.mode != pathModeReadWrite { + if !ok || ar.mode != pathModeReadWrite || s.deniedFor(absPath, denyModeWrite) { return nil, "", false } @@ -329,6 +452,9 @@ func (s *Sandbox) resolveWriteTarget(absPath string) (*root, string, bool) { } resolvedAbs := filepath.Join(resolved.absPath, resolvedRel) resolvedCanonicalAbs := filepath.Join(resolved.canonicalAbsPath, resolvedRel) + if s.deniedFor(resolvedAbs, denyModeWrite) || s.deniedFor(resolvedCanonicalAbs, denyModeWrite) { + return nil, "", false + } if !isWithinRoot(ar.absPath, resolvedAbs) && !isWithinRoot(ar.canonicalAbsPath, resolvedCanonicalAbs) { return nil, "", false } @@ -379,6 +505,19 @@ func (s *Sandbox) Access(path string, cwd string, mode uint32) error { if s == nil { return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} } + requestedDeny := denyMode(0) + if mode&modeRead != 0 { + requestedDeny |= denyModeRead + } + if mode&modeWrite != 0 { + requestedDeny |= denyModeWrite + } + if mode&modeExecute != 0 { + requestedDeny |= denyModeRead + } + if requestedDeny != 0 && s.denyModeForResolvedPath(absPath, false)&requestedDeny != 0 { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } ar, rel, ok := s.resolve(absPath) if !ok { return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} @@ -474,6 +613,13 @@ func (s *Sandbox) Open(path string, cwd string, flag int, perm os.FileMode) (io. } absPath := toAbs(path, cwd) + if flag&writeOpenFlags == 0 && len(s.denyRoots) > 0 { + f, err := s.openReadDenyAware(path, cwd, flag, perm) + if err != nil { + return nil, PortablePathError(err) + } + return f, nil + } var ar *root var relPath string @@ -695,6 +841,12 @@ func (s *Sandbox) ReadDirForGlob(path string, cwd string) ([]fs.DirEntry, error) // entries than the limit an error is returned. func (s *Sandbox) readDirN(path string, cwd string, maxEntries int) ([]fs.DirEntry, error) { absPath := toAbs(path, cwd) + if s == nil { + return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } + if s.deniedFor(absPath, denyModeRead) { + return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } ar, relPath, ok := s.resolve(absPath) if !ok { @@ -708,7 +860,13 @@ func (s *Sandbox) readDirN(path string, cwd string, maxEntries int) ([]fs.DirEnt return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} } - f, err := s.openWithSymlinkFallback(ar.root, relPath, absPath) + var f *os.File + var err error + if len(s.denyRoots) > 0 { + f, err = s.openReadDenyAware(path, cwd, os.O_RDONLY, 0) + } else { + f, err = s.openWithSymlinkFallback(ar.root, relPath, absPath) + } if err != nil { return nil, PortablePathError(err) } @@ -730,6 +888,10 @@ func (s *Sandbox) readDirN(path string, cwd string, maxEntries int) ([]fs.DirEnt Err: fmt.Errorf("directory has too many entries (cap: %d)", maxEntries), } } + entries = s.filterDeniedReadEntries(absPath, entries) + if len(s.denyRoots) > 0 { + entries = s.materializeDirEntries(absPath, entries) + } // 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 { @@ -743,13 +905,25 @@ func (s *Sandbox) readDirN(path string, cwd string, maxEntries int) ([]fs.DirEnt // Returns fs.ReadDirFile to expose only read-only directory methods. func (s *Sandbox) OpenDir(path string, cwd string) (fs.ReadDirFile, error) { absPath := toAbs(path, cwd) + if s == nil { + return nil, &os.PathError{Op: "opendir", Path: path, Err: os.ErrPermission} + } + if s.deniedFor(absPath, denyModeRead) { + return nil, &os.PathError{Op: "opendir", Path: path, Err: os.ErrPermission} + } ar, relPath, ok := s.resolve(absPath) if !ok { return nil, &os.PathError{Op: "opendir", Path: path, Err: os.ErrPermission} } - f, err := s.openWithSymlinkFallback(ar.root, relPath, absPath) + var f *os.File + var err error + if len(s.denyRoots) > 0 { + f, err = s.openReadDenyAware(path, cwd, os.O_RDONLY, 0) + } else { + f, err = s.openWithSymlinkFallback(ar.root, relPath, absPath) + } if err != nil { return nil, PortablePathError(err) } @@ -761,21 +935,34 @@ func (s *Sandbox) OpenDir(path string, cwd string) (fs.ReadDirFile, error) { // needs to be determined. func (s *Sandbox) IsDirEmpty(path string, cwd string) (bool, error) { absPath := toAbs(path, cwd) + if s == nil { + return false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } + if s.deniedFor(absPath, denyModeRead) { + return false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } ar, relPath, ok := s.resolve(absPath) if !ok { return false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} } - f, err := s.openWithSymlinkFallback(ar.root, relPath, absPath) + var f *os.File + var err error + if len(s.denyRoots) > 0 { + f, err = s.openReadDenyAware(path, cwd, os.O_RDONLY, 0) + } else { + f, err = s.openWithSymlinkFallback(ar.root, relPath, absPath) + } if err != nil { return false, PortablePathError(err) } defer f.Close() - entries, err := f.ReadDir(1) + entries, err := f.ReadDir(-1) if err != nil && err != io.EOF { return false, PortablePathError(err) } + entries = s.filterDeniedReadEntries(absPath, entries) return len(entries) == 0, nil } @@ -790,11 +977,23 @@ func (s *Sandbox) IsDirEmpty(path string, cwd string) (bool, error) { // O(n) memory regardless of offset value, where n = min(maxRead, entries). func (s *Sandbox) ReadDirLimited(path string, cwd string, offset, maxRead int) ([]fs.DirEntry, bool, error) { absPath := toAbs(path, cwd) + if s == nil { + return nil, false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } + if s.deniedFor(absPath, denyModeRead) { + return nil, false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } ar, relPath, ok := s.resolve(absPath) if !ok { return nil, false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} } - f, err := s.openWithSymlinkFallback(ar.root, relPath, absPath) + var f *os.File + var err error + if len(s.denyRoots) > 0 { + f, err = s.openReadDenyAware(path, cwd, os.O_RDONLY, 0) + } else { + f, err = s.openWithSymlinkFallback(ar.root, relPath, absPath) + } if err != nil { return nil, false, PortablePathError(err) } @@ -816,9 +1015,66 @@ func (s *Sandbox) ReadDirLimited(path string, cwd string, offset, maxRead int) ( if lastErr != nil { return entries, truncated, PortablePathError(lastErr) } + entries = s.filterDeniedReadEntries(absPath, entries) + if len(s.denyRoots) > 0 { + entries = s.materializeDirEntries(absPath, entries) + } return entries, truncated, nil } +func (s *Sandbox) filterDeniedReadEntries(absPath string, entries []fs.DirEntry) []fs.DirEntry { + if s == nil || len(s.denyRoots) == 0 { + return entries + } + filtered := entries[:0] + for _, entry := range entries { + childAbs := filepath.Join(absPath, entry.Name()) + if s.deniedFor(childAbs, denyModeRead) { + continue + } + filtered = append(filtered, entry) + } + return filtered +} + +type sandboxDirEntry struct { + name string + info fs.FileInfo + err error +} + +func (e sandboxDirEntry) Name() string { + return e.name +} + +func (e sandboxDirEntry) IsDir() bool { + return e.info != nil && e.info.IsDir() +} + +func (e sandboxDirEntry) Type() fs.FileMode { + if e.info == nil { + return 0 + } + return e.info.Mode().Type() +} + +func (e sandboxDirEntry) Info() (fs.FileInfo, error) { + if e.err != nil { + return nil, e.err + } + return e.info, nil +} + +func (s *Sandbox) materializeDirEntries(absPath string, entries []fs.DirEntry) []fs.DirEntry { + materialized := make([]fs.DirEntry, 0, len(entries)) + for _, entry := range entries { + childAbs := filepath.Join(absPath, entry.Name()) + info, err := s.Lstat(childAbs, "") + materialized = append(materialized, sandboxDirEntry{name: entry.Name(), info: info, err: err}) + } + return materialized +} + // CollectDirEntries reads directory entries in batches using readBatch, // skipping the first offset entries and collecting up to maxRead entries. // Returns (entries, truncated, lastErr). Entries are sorted by name. @@ -883,6 +1139,12 @@ func (s *Sandbox) Stat(path string, cwd string) (fs.FileInfo, error) { } absPath := toAbs(path, cwd) + if s == nil { + return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrPermission} + } + if s.denyModeForResolvedPath(absPath, false)&denyModeRead != 0 { + return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrPermission} + } ar, relPath, ok := s.resolve(absPath) if !ok { @@ -917,6 +1179,12 @@ func (s *Sandbox) Lstat(path string, cwd string) (fs.FileInfo, error) { } absPath := toAbs(path, cwd) + if s == nil { + return nil, &os.PathError{Op: "lstat", Path: path, Err: os.ErrPermission} + } + if s.denyModeForResolvedPath(absPath, true)&denyModeRead != 0 { + return nil, &os.PathError{Op: "lstat", Path: path, Err: os.ErrPermission} + } ar, relPath, ok := s.resolve(absPath) if !ok { @@ -944,6 +1212,12 @@ func (s *Sandbox) Lstat(path string, cwd string) (fs.FileInfo, error) { // Readlink returns the destination of a symbolic link within the sandbox. func (s *Sandbox) Readlink(path string, cwd string) (string, error) { absPath := toAbs(path, cwd) + if s == nil { + return "", &os.PathError{Op: "readlink", Path: path, Err: os.ErrPermission} + } + if s.denyModeForResolvedPath(absPath, true)&denyModeRead != 0 { + return "", &os.PathError{Op: "readlink", Path: path, Err: os.ErrPermission} + } ar, relPath, ok := s.resolve(absPath) if !ok { @@ -1040,6 +1314,18 @@ func (s *Sandbox) Paths() []string { return paths } +// DeniedPaths returns the resolved absolute paths of all denied directories. +func (s *Sandbox) DeniedPaths() []string { + if s == nil { + return nil + } + paths := make([]string, len(s.denyRoots)) + for i, r := range s.denyRoots { + paths[i] = r.absPath + } + return paths +} + // Close releases all root file descriptors. It is safe to call multiple times. func (s *Sandbox) Close() error { if s == nil { @@ -1050,6 +1336,8 @@ func (s *Sandbox) Close() error { s.roots[i].root.Close() s.roots[i].root = nil } + closeRootFile(s.roots[i].readRoot) + s.roots[i].readRoot = nil closeWriteRoot(s.roots[i].writeRoot) s.roots[i].writeRoot = nil } diff --git a/allowedpaths/sandbox_test.go b/allowedpaths/sandbox_test.go index 3d7073455..c03fd7390 100644 --- a/allowedpaths/sandbox_test.go +++ b/allowedpaths/sandbox_test.go @@ -231,6 +231,32 @@ func TestParseAllowedPathMode(t *testing.T) { } } +func TestParseDeniedPathMode(t *testing.T) { + tests := []struct { + name string + in string + path string + mode denyMode + }{ + {name: "default deny read and write", in: "/var/log", path: "/var/log", mode: denyModeRead | denyModeWrite}, + {name: "explicit read deny", in: "/var/log:r", path: "/var/log", mode: denyModeRead | denyModeWrite}, + {name: "explicit write deny", in: "/var/log:w", path: "/var/log", mode: denyModeWrite}, + {name: "last terminal suffix wins", in: "/var/log:w:r", path: "/var/log:w", mode: denyModeRead | denyModeWrite}, + {name: "middle suffix is path text", in: "/var/log:w/datadog", path: "/var/log:w/datadog", mode: denyModeRead | denyModeWrite}, + {name: "unknown suffix is path text", in: "/var/log:x", path: "/var/log:x", mode: denyModeRead | denyModeWrite}, + {name: "bare r suffix is path text", in: ":r", path: ":r", mode: denyModeRead | denyModeWrite}, + {name: "bare w suffix is path text", in: ":w", path: ":w", mode: denyModeRead | denyModeWrite}, + {name: "empty path", in: "", path: "", mode: denyModeRead | denyModeWrite}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, mode := parseDeniedPathMode(tt.in) + assert.Equal(t, tt.path, path) + assert.Equal(t, tt.mode, mode) + }) + } +} + func TestResolveAllowedPathModePreservesExistingLiteralPath(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("literal paths ending in :rw/:ro are POSIX-only") diff --git a/allowedpaths/write_open.go b/allowedpaths/write_open.go index 0bdb853c3..24b0506a6 100644 --- a/allowedpaths/write_open.go +++ b/allowedpaths/write_open.go @@ -22,6 +22,14 @@ func closeWriteRoot(file *os.File) { writeopen.CloseRoot(file) } +func openRootFile(root *os.Root) (*os.File, error) { + return writeopen.OpenRoot(root) +} + +func closeRootFile(file *os.File) { + writeopen.CloseRoot(file) +} + func (r *root) openWriteFile(relPath string, flag int, perm os.FileMode) (*os.File, error) { if err := r.rejectSymlinkWriteTarget(relPath); err != nil { return nil, err diff --git a/analysis/symbols_allowedpaths.go b/analysis/symbols_allowedpaths.go index 74ebe0a8f..0b96d9e3b 100644 --- a/analysis/symbols_allowedpaths.go +++ b/analysis/symbols_allowedpaths.go @@ -24,6 +24,16 @@ var allowedpathsAllowedSymbols = []string{ "errors.New", // 🟢 creates a simple error value; pure function, no I/O. "fmt.Errorf", // 🟢 formatted error creation; pure function, no I/O. "fmt.Fprintf", // 🟠 writes warning messages to in-memory buffer during sandbox construction. + "golang.org/x/sys/unix.Close", // 🟠 closes fd-relative directory descriptors opened during deny-aware read resolution; no path lookup. + "golang.org/x/sys/unix.ELOOP", // 🟢 symlink-loop errno constant; pure constant. + "golang.org/x/sys/unix.Fstat", // 🟠 reads metadata for an already-open fd so deny checks apply to the object actually opened. + "golang.org/x/sys/unix.O_CLOEXEC", // 🟢 close-on-exec open flag constant; pure constant. + "golang.org/x/sys/unix.O_DIRECTORY", // 🟢 directory-only open flag constant used for component walks. + "golang.org/x/sys/unix.O_NOFOLLOW", // 🟢 no-follow open flag constant; prevents silent symlink traversal during deny-aware reads. + "golang.org/x/sys/unix.O_RDONLY", // 🟢 read-only open flag constant; pure constant. + "golang.org/x/sys/unix.Openat", // 🟠 fd-relative open used to keep deny checks and final opens in one race-resistant resolution chain. + "golang.org/x/sys/unix.Readlinkat", // 🟠 reads symlink targets relative to an already-open directory fd during deny-aware resolution. + "golang.org/x/sys/unix.Stat_t", // 🟢 file stat structure type returned by Fstat; pure data type. "io.EOF", // 🟢 sentinel error value; pure constant. "io.ReadWriteCloser", // 🟢 combined interface type; no side effects. "io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects. @@ -35,6 +45,7 @@ var allowedpathsAllowedSymbols = []string{ "io/fs.FileMode", // 🟢 file permission bits type; pure type. "io/fs.ReadDirFile", // 🟢 read-only directory handle interface; no write capability. "os.DevNull", // 🟢 platform null device path constant; pure constant. + "os.ErrInvalid", // 🟢 sentinel error for invalid operations; pure error value. "os.ErrNotExist", // 🟢 sentinel error for missing literal paths; pure constant. "os.ErrPermission", // 🟢 sentinel error for permission denied; pure constant. "os.File", // 🟠 file handle returned by os.Root.Open; needed for cross-root symlink fallback. @@ -48,6 +59,7 @@ var allowedpathsAllowedSymbols = []string{ "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. "os.O_TRUNC", // 🟢 truncate-on-open file flag constant; pure constant. Part of the sandbox open-flag allowlist. "os.O_WRONLY", // 🟢 write-only file flag constant; pure constant. Part of the sandbox open-flag allowlist. + "os.NewFile", // 🟠 wraps a final fd opened by the deny-aware resolver; the fd was already policy-checked. "os.OpenRoot", // 🟠 opens a directory as a root for sandboxed file access; needed for sandbox. "os.PathError", // 🟢 error type wrapping path and operation; pure type. "os.Root", // 🟠 sandboxed directory root type; core of the filesystem sandbox. diff --git a/builtins/builtins.go b/builtins/builtins.go index 5f57d3593..66e4c6c7d 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -236,6 +236,11 @@ type CallContext struct { // Used by the help builtin to surface the active sandbox roots. AllowedPathsList func() []string + // DeniedPathsList returns the resolved absolute paths of the configured + // DeniedPaths sandbox roots. Used by the help builtin to surface explicit + // deny subtrees. + DeniedPathsList func() []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 diff --git a/builtins/help/help.go b/builtins/help/help.go index 30a62dd87..7ac378162 100644 --- a/builtins/help/help.go +++ b/builtins/help/help.go @@ -152,6 +152,7 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { } printAllowedPaths(callCtx) + printDeniedPaths(callCtx) callCtx.Out("\nRun 'help ' for more information on a specific topic.\n") return builtins.Result{} @@ -205,6 +206,20 @@ func printAllowedPaths(callCtx *builtins.CallContext) { } } +func printDeniedPaths(callCtx *builtins.CallContext) { + if callCtx.DeniedPathsList == nil { + return + } + paths := callCtx.DeniedPathsList() + if len(paths) == 0 { + return + } + callCtx.Out("\nDenied paths:\n") + for _, p := range paths { + callCtx.Outf(" %s\n", p) + } +} + func printUnsupportedSummary(callCtx *builtins.CallContext, items []string) { if len(items) == 0 { return diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index 95b9c9021..d24328bed 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -36,6 +36,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. var ( command string allowedPaths string + deniedPaths string allowedCommands string allowAllCmds bool timeout time.Duration @@ -75,6 +76,10 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. if allowedPaths != "" { paths = strings.Split(allowedPaths, ",") } + var denied []string + if deniedPaths != "" { + denied = strings.Split(deniedPaths, ",") + } var cmds []string if allowedCommands != "" { @@ -88,6 +93,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. execOpts := executeOpts{ allowedPaths: paths, + deniedPaths: denied, allowedCommands: cmds, allowAllCommands: allowAllCmds, procPath: procPath, @@ -140,6 +146,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 PATH[:ro|:rw] directories the shell is allowed to access; entries without a suffix are read-only") + cmd.Flags().StringVar(&deniedPaths, "denied-paths", "", "comma-separated list of PATH[:r|:w] directories the shell is explicitly denied from reading/writing; entries without a suffix deny reads and writes") cmd.Flags().StringVar(&allowedCommands, "allowed-commands", "", "comma-separated list of namespaced commands (e.g. rshell:cat,rshell:find)") 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)") @@ -211,6 +218,7 @@ func rejectLongCommand(rawArgs []string) error { // executeOpts holds options for the execute function. type executeOpts struct { allowedPaths []string + deniedPaths []string allowedCommands []string allowAllCommands bool procPath string @@ -233,6 +241,9 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i if len(opts.allowedPaths) > 0 { runOpts = append(runOpts, interp.AllowedPaths(opts.allowedPaths)) } + if len(opts.deniedPaths) > 0 { + runOpts = append(runOpts, interp.DeniedPaths(opts.deniedPaths)) + } if opts.allowAllCommands { runOpts = append(runOpts, interpoption.AllowAllCommands().(interp.RunnerOption)) } else if len(opts.allowedCommands) > 0 { diff --git a/cmd/rshell/main_test.go b/cmd/rshell/main_test.go index df8315dde..f1f7147c1 100644 --- a/cmd/rshell/main_test.go +++ b/cmd/rshell/main_test.go @@ -178,6 +178,8 @@ func TestHelp(t *testing.T) { assert.Contains(t, stdout, "--allowed-paths") assert.Contains(t, stdout, "PATH[:ro|:rw]") assert.Contains(t, stdout, "entries without a suffix are read-only") + assert.Contains(t, stdout, "--denied-paths") + assert.Contains(t, stdout, "PATH[:r|:w]") assert.Contains(t, stdout, "--allowed-commands") assert.Contains(t, stdout, "--allow-all-commands") assert.Contains(t, stdout, "file-target output redirections within :rw AllowedPaths roots") diff --git a/interp/allowed_paths_internal_test.go b/interp/allowed_paths_internal_test.go index 86fd8bdbd..a36df019b 100644 --- a/interp/allowed_paths_internal_test.go +++ b/interp/allowed_paths_internal_test.go @@ -344,6 +344,19 @@ func TestAllowedPathsEnvVarSkipsNonexistent(t *testing.T) { assert.Equal(t, dir+"\n", stdout) } +func TestDeniedPathsEnvVar(t *testing.T) { + root := t.TempDir() + denied := filepath.Join(root, "secret") + require.NoError(t, os.Mkdir(denied, 0755)) + + stdout, _, _ := runScriptInternal(t, `echo $DENIED_PATHS`, root, + AllowedPaths([]string{root}), + DeniedPaths([]string{denied}), + ) + + assert.Equal(t, denied+"\n", stdout) +} + // TestAllowedPathsEnvVarNotSetWithoutSandbox verifies that ALLOWED_PATHS // is not set when AllowedPaths is not configured. func TestAllowedPathsEnvVarNotSetWithoutSandbox(t *testing.T) { diff --git a/interp/allowed_paths_test.go b/interp/allowed_paths_test.go index 8ce6f6d41..cf5d96bd2 100644 --- a/interp/allowed_paths_test.go +++ b/interp/allowed_paths_test.go @@ -87,6 +87,34 @@ func TestAllowedPathsOption(t *testing.T) { }) } +func TestDeniedPathsOption(t *testing.T) { + t.Run("nonexistent path skipped with warning", func(t *testing.T) { + var warnings bytes.Buffer + runner, err := interp.New( + interp.WarningsWriter(&warnings), + interp.DeniedPaths([]string{"/nonexistent/path/that/does/not/exist"}), + ) + require.NoError(t, err, "nonexistent denied paths should be skipped, not rejected") + defer runner.Close() + assert.Contains(t, warnings.String(), "DeniedPaths: skipping") + }) + + t.Run("option order independent", func(t *testing.T) { + dir := t.TempDir() + secret := filepath.Join(dir, "secret") + require.NoError(t, os.Mkdir(secret, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(secret, "data.txt"), []byte("secret\n"), 0644)) + + stdout, stderr, exitCode := runScript(t, "cat secret/data.txt", dir, + interp.DeniedPaths([]string{secret}), + interp.AllowedPaths([]string{dir}), + ) + assert.Equal(t, 1, exitCode) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "permission denied") + }) +} + func TestDefaultDirFromAllowedPaths(t *testing.T) { t.Run("defaults to first allowed path when Dir is unset", func(t *testing.T) { first := t.TempDir() diff --git a/interp/api.go b/interp/api.go index 724b78025..5c952fd96 100644 --- a/interp/api.go +++ b/interp/api.go @@ -50,11 +50,20 @@ type runnerConfig struct { // glob expansion. It must be non-nil. readDirHandler ReadDirHandlerFunc + // allowedPathSpecs and deniedPathSpecs hold the raw path policy specs + // supplied through AllowedPaths and DeniedPaths. They are materialized into + // sandbox after all RunnerOptions are applied so option ordering is stable. + allowedPathSpecs []string + deniedPathSpecs []string + allowedPathsSet bool + deniedPathsSet bool + // sandbox restricts file/directory access to allowed directories. // nil (default) blocks all file access; populate via AllowedPaths option. sandbox *allowedpaths.Sandbox - // sandboxWarnings holds diagnostic messages about skipped AllowedPaths + // sandboxWarnings holds diagnostic messages about skipped AllowedPaths or + // DeniedPaths // entries. Flushed to warningsWriter after all options are applied and // defaults are set, so the output target is independent of option // ordering. Retained on the runner after flush so callers can also @@ -278,6 +287,15 @@ func New(opts ...RunnerOption) (*Runner, error) { return nil, err } } + if r.allowedPathsSet || r.deniedPathsSet { + sb, warnings, err := allowedpaths.NewWithDeniedPaths(r.allowedPathSpecs, r.deniedPathSpecs) + if err != nil { + _ = r.Close() + return nil, err + } + r.sandbox = sb + r.sandboxWarnings = warnings + } // Default to an empty environment to avoid propagating parent env vars. if r.Env == nil { @@ -496,6 +514,7 @@ func (r *Runner) Reset() { r.setVarString("OPTIND", "1") if r.sandbox != nil { r.setVarString("ALLOWED_PATHS", strings.Join(r.sandbox.Paths(), string(filepath.ListSeparator))) + r.setVarString("DENIED_PATHS", strings.Join(r.sandbox.DeniedPaths(), string(filepath.ListSeparator))) } // Reset the total-bytes counter so that the interpreter's own initial @@ -764,12 +783,21 @@ func (r *Runner) Warnings() []string { // An empty slice also blocks all file access. func AllowedPaths(paths []string) RunnerOption { return func(r *Runner) error { - sb, warnings, err := allowedpaths.New(paths) - if err != nil { - return err - } - r.sandbox = sb - r.sandboxWarnings = warnings + r.allowedPathSpecs = append([]string(nil), paths...) + r.allowedPathsSet = true + return nil + } +} + +// DeniedPaths denies file and directory access inside specific subtrees of the +// configured AllowedPaths sandbox. A path may end with :r or :w. :r denies +// reads and writes; :w denies writes only. Paths without a suffix default to +// :r. Denied paths that cannot be opened at runner construction time are +// skipped with a warning, matching AllowedPaths skip behavior. +func DeniedPaths(paths []string) RunnerOption { + return func(r *Runner) error { + r.deniedPathSpecs = append([]string(nil), paths...) + r.deniedPathsSet = true return nil } } diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 6162e08e8..4771ff5ea 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -649,6 +649,12 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } return r.sandbox.Paths() }, + DeniedPathsList: func() []string { + if r.sandbox == nil { + return nil + } + return r.sandbox.DeniedPaths() + }, // ChangeDir is intentionally nil for RunCommand children // (find -exec, find -execdir, xargs). bash forks a child // process for each invocation, so cd inside such a child @@ -779,6 +785,12 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } return r.sandbox.Paths() }, + DeniedPathsList: func() []string { + if r.sandbox == nil { + return nil + } + return r.sandbox.DeniedPaths() + }, ChangeDir: r.changeDir, LookupEnvVar: r.lookupEnvVar, RunCommand: runCmd, diff --git a/tests/scenarios/shell/denied_paths/help_lists_denied_paths.yaml b/tests/scenarios/shell/denied_paths/help_lists_denied_paths.yaml new file mode 100644 index 000000000..c3092379f --- /dev/null +++ b/tests/scenarios/shell/denied_paths/help_lists_denied_paths.yaml @@ -0,0 +1,18 @@ +# skip: denied_paths help output is an rshell-specific feature not present in bash +skip_assert_against_bash: true +description: Help lists configured denied paths. +setup: + files: + - path: allowed/secret/data.txt + content: "secret\n" +input: + allowed_paths: ["allowed"] + denied_paths: ["allowed/secret:r"] + script: |+ + help +expect: + stdout_contains: + - "Denied paths:" + - "allowed/secret" + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/shell/denied_paths/list_hides_read_denied_child.yaml b/tests/scenarios/shell/denied_paths/list_hides_read_denied_child.yaml new file mode 100644 index 000000000..0e70f4e3e --- /dev/null +++ b/tests/scenarios/shell/denied_paths/list_hides_read_denied_child.yaml @@ -0,0 +1,19 @@ +# skip: denied_paths sandbox restriction is an rshell-specific feature not present in bash +skip_assert_against_bash: true +description: Directory listings hide direct read-denied children. +setup: + files: + - path: allowed/public.txt + content: "public\n" + - path: allowed/secret/data.txt + content: "secret\n" +input: + allowed_paths: ["allowed"] + denied_paths: ["allowed/secret:r"] + script: |+ + ls allowed +expect: + stdout: |+ + public.txt + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/shell/denied_paths/list_shows_write_denied_child.yaml b/tests/scenarios/shell/denied_paths/list_shows_write_denied_child.yaml new file mode 100644 index 000000000..97cb9f4f0 --- /dev/null +++ b/tests/scenarios/shell/denied_paths/list_shows_write_denied_child.yaml @@ -0,0 +1,20 @@ +# skip: denied_paths sandbox restriction is an rshell-specific feature not present in bash +skip_assert_against_bash: true +description: Directory listings keep write-denied children visible. +setup: + files: + - path: allowed/logs/data.txt + content: "log\n" + - path: allowed/public.txt + content: "public\n" +input: + allowed_paths: ["allowed"] + denied_paths: ["allowed/logs:w"] + script: |+ + ls allowed +expect: + stdout: |+ + logs + public.txt + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/shell/denied_paths/read_denied_blocks_cat.yaml b/tests/scenarios/shell/denied_paths/read_denied_blocks_cat.yaml new file mode 100644 index 000000000..95d14a8f0 --- /dev/null +++ b/tests/scenarios/shell/denied_paths/read_denied_blocks_cat.yaml @@ -0,0 +1,20 @@ +# skip: denied_paths sandbox restriction is an rshell-specific feature not present in bash +skip_assert_against_bash: true +description: DeniedPaths read-deny blocks reading a child of an allowed path. +setup: + files: + - path: allowed/public.txt + content: "public\n" + - path: allowed/secret/data.txt + content: "secret\n" +input: + allowed_paths: ["allowed"] + denied_paths: ["allowed/secret:r"] + script: |+ + cat allowed/public.txt + cat allowed/secret/data.txt +expect: + stdout: |+ + public + stderr_contains: ["permission denied"] + exit_code: 1 diff --git a/tests/scenarios/shell/denied_paths/read_denied_blocks_write.yaml b/tests/scenarios/shell/denied_paths/read_denied_blocks_write.yaml new file mode 100644 index 000000000..a6fdefe21 --- /dev/null +++ b/tests/scenarios/shell/denied_paths/read_denied_blocks_write.yaml @@ -0,0 +1,17 @@ +# skip: denied_paths sandbox restriction is an rshell-specific feature not present in bash +skip_assert_against_bash: true +description: DeniedPaths read-deny also blocks writes in remediation mode. +setup: + files: + - path: allowed/secret/data.txt + content: "secret\n" +input: + allowed_paths: ["allowed:rw"] + denied_paths: ["allowed/secret:r"] + mode: remediation + script: |+ + echo changed > allowed/secret/data.txt +expect: + stdout: |+ + stderr_contains: ["permission denied"] + exit_code: 1 diff --git a/tests/scenarios/shell/denied_paths/symlink_into_read_denied_blocked.yaml b/tests/scenarios/shell/denied_paths/symlink_into_read_denied_blocked.yaml new file mode 100644 index 000000000..e773fa611 --- /dev/null +++ b/tests/scenarios/shell/denied_paths/symlink_into_read_denied_blocked.yaml @@ -0,0 +1,18 @@ +# skip: denied_paths sandbox restriction is an rshell-specific feature not present in bash +skip_assert_against_bash: true +description: Symlinks cannot bypass a read-denied subtree. +setup: + files: + - path: allowed/secret/data.txt + content: "secret\n" + - path: allowed/link.txt + symlink: secret/data.txt +input: + allowed_paths: ["allowed"] + denied_paths: ["allowed/secret:r"] + script: |+ + cat allowed/link.txt +expect: + stdout: |+ + stderr_contains: ["permission denied"] + exit_code: 1 diff --git a/tests/scenarios/shell/denied_paths/write_denied_allows_read.yaml b/tests/scenarios/shell/denied_paths/write_denied_allows_read.yaml new file mode 100644 index 000000000..5c3038ef1 --- /dev/null +++ b/tests/scenarios/shell/denied_paths/write_denied_allows_read.yaml @@ -0,0 +1,19 @@ +# skip: denied_paths sandbox restriction is an rshell-specific feature not present in bash +skip_assert_against_bash: true +description: DeniedPaths write-deny allows reads but blocks writes. +setup: + files: + - path: allowed/logs/data.txt + content: "readable\n" +input: + allowed_paths: ["allowed:rw"] + denied_paths: ["allowed/logs:w"] + mode: remediation + script: |+ + cat allowed/logs/data.txt + echo changed > allowed/logs/data.txt +expect: + stdout: |+ + readable + stderr_contains: ["permission denied"] + exit_code: 1 diff --git a/tests/scenarios_test.go b/tests/scenarios_test.go index fd915cfc5..09300ed3b 100644 --- a/tests/scenarios_test.go +++ b/tests/scenarios_test.go @@ -70,6 +70,7 @@ type input struct { InterpreterEnv map[string]string `yaml:"interpreter_env"` Script string `yaml:"script"` AllowedPaths []string `yaml:"allowed_paths"` // relative to test temp dir; "$DIR" resolves to temp dir itself + DeniedPaths []string `yaml:"denied_paths"` // relative to test temp dir; "$DIR" resolves to temp dir itself // AllowedCommands lists the command names (builtin or external) that the // interpreter is permitted to execute. If nil and AllowAllCommands is not // explicitly set to true, the test defaults to allowing all commands for @@ -147,8 +148,26 @@ func splitScenarioPathMode(path string) (base string, suffix string) { return path, "" } +func splitScenarioDenyPathMode(path string) (base string, suffix string) { + for _, candidate := range []string{":r", ":w"} { + if strings.HasSuffix(path, candidate) && len(path) > len(candidate) { + return path[:len(path)-len(candidate)], candidate + } + } + return path, "" +} + func resolveScenarioAllowedPath(dir string, configuredPath string) (string, bool) { path, suffix := splitScenarioPathMode(configuredPath) + return resolveScenarioPathWithSuffix(dir, path, suffix) +} + +func resolveScenarioDeniedPath(dir string, configuredPath string) (string, bool) { + path, suffix := splitScenarioDenyPathMode(configuredPath) + return resolveScenarioPathWithSuffix(dir, path, suffix) +} + +func resolveScenarioPathWithSuffix(dir string, path string, suffix string) (string, bool) { var resolved string switch { case path == "$DIR": @@ -234,6 +253,16 @@ func runScenario(t *testing.T, sc scenario) { // runner unrestricted. opts = append(opts, interp.AllowedPaths(resolved)) } + if sc.Input.DeniedPaths != nil { + var resolved []string + for _, p := range sc.Input.DeniedPaths { + path, ok := resolveScenarioDeniedPath(dir, p) + if ok { + resolved = append(resolved, path) + } + } + opts = append(opts, interp.DeniedPaths(resolved)) + } if sc.Input.AllowAllCommands != nil && *sc.Input.AllowAllCommands { opts = append(opts, interpoption.AllowAllCommands().(interp.RunnerOption)) } else if len(sc.Input.AllowedCommands) > 0 { From 0211974487d68a06dfad68acd58e538d01e54c44 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 25 Jun 2026 14:51:09 -0400 Subject: [PATCH 2/2] harden windows denied path resolution --- README.md | 8 +- SHELL_FEATURES.md | 4 +- .../internal/writeopen/writeopen_windows.go | 10 +- allowedpaths/path_mode_nonwindows.go | 17 + allowedpaths/path_mode_windows.go | 25 ++ allowedpaths/path_mode_windows_test.go | 48 +++ allowedpaths/read_open_windows.go | 356 +++++++++++++++++- allowedpaths/sandbox.go | 80 ++-- allowedpaths/sandbox_windows_test.go | 16 + analysis/symbols_allowedpaths.go | 179 +++++---- 10 files changed, 613 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 1f553a0c1..a11e2f401 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,13 @@ 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. -**AllowedPaths** restricts all file operations to specified directories using Go's `os.Root` API for reads and openat-based write handling for writes. **DeniedPaths** explicitly denies subtrees inside those allowed roots. +**AllowedPaths** restricts all file operations to specified directories using Go's `os.Root` API for reads and handle-relative write handling for writes. **DeniedPaths** explicitly denies subtrees inside those allowed roots. -- **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. +- **Sandbox mechanism:** Reads go through `os.Root`, or through a deny-aware component resolver when `DeniedPaths` are configured; writes are checked against the most-specific path mode and opened with no-symlink handle-relative walks. Files outside the allowlist cannot be opened, created, truncated, or appended to. - **Permission suffix:** Path entries may end with `:ro` or `:rw` representing read-only and read-write modes, respectively; entries without a suffix default to read-only, and the suffix is stripped before path validation. In remediation mode, write operations are accepted only inside the most-specific matching `:rw` root. - **Deny suffix:** Denied path entries may end with `:r` or `:w`. `:r` denies reads and writes; `:w` denies writes only. Entries without a suffix default to `:r`. Direct `:r` denied children are filtered from directory listings; `:w` denied children remain visible and readable. -- **Symlink policy:** A symlink pointing outside its `os.Root` is followed for reads but never for writes; on Unix, symlink components in write targets are rejected with `symlinks are not supported as write targets` rather than followed, eliminating the TOCTOU window where a malicious link target could be swapped between resolution and open. -- **Denied-path symlinks:** On Unix, reads with configured DeniedPaths use a deny-aware `openat` resolver that follows symlinks explicitly and checks pinned deny-root identities before returning the final file descriptor. Windows applies the same deny policy on a best-effort handle/root basis. +- **Symlink policy:** A symlink pointing outside its `os.Root` is followed for reads but never for writes; symlink components in write targets are rejected with `symlinks are not supported as write targets` rather than followed, eliminating the TOCTOU window where a malicious link target could be swapped between resolution and open. +- **Denied-path symlinks:** Reads with configured DeniedPaths use deny-aware component resolvers: Unix uses `openat`/`readlinkat`; Windows uses handle-relative `NtCreateFile` and explicit reparse-point parsing for symlinks and junctions/mount points. Both check pinned deny-root identities before returning the final descriptor/handle. Unsupported Windows reparse points are rejected when DeniedPaths are active, and Windows alternate data streams are rejected for sandboxed access. - **Output redirections by mode:** - _Read-only mode (default):_ file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) are rejected at parse time (exit 2). - _Remediation mode:_ those redirections open through the same sandbox — writes inside `:rw` allowlist roots succeed; targets outside the allowlist or inside read-only roots fail with `permission denied` (exit 1). diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 00589a679..000af7c2e 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -121,8 +121,8 @@ 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 -- ✅ 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 -- ✅ DeniedPaths filesystem deny rules — explicitly deny subtrees inside AllowedPaths. Entries may end with `:r` (deny reads and writes) or `:w` (deny writes only); entries without a suffix default to `:r`. Direct `:r` denied children are filtered from directory listings; `:w` denied children remain visible and readable. Kernel-state builtins that intentionally bypass AllowedPaths (`ss`, `ip route`, `df`, and `ps` proc reads) also bypass DeniedPaths +- ✅ 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; symlink components in write targets are rejected with `symlinks are not supported as write targets` via no-follow handle-relative walks +- ✅ DeniedPaths filesystem deny rules — explicitly deny subtrees inside AllowedPaths. Entries may end with `:r` (deny reads and writes) or `:w` (deny writes only); entries without a suffix default to `:r`. Direct `:r` denied children are filtered from directory listings; `:w` denied children remain visible and readable. Deny-aware reads resolve components and check pinned deny-root identities before returning final descriptors/handles; Windows supports symlink and junction/mount-point reparse targets, rejects unsupported reparse points when DeniedPaths are active, and rejects alternate data streams for sandboxed access. Kernel-state builtins that intentionally bypass AllowedPaths (`ss`, `ip route`, `df`, and `ps` proc reads) also bypass DeniedPaths - ✅ 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//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 diff --git a/allowedpaths/internal/writeopen/writeopen_windows.go b/allowedpaths/internal/writeopen/writeopen_windows.go index 0b9926fd4..3b061ce82 100644 --- a/allowedpaths/internal/writeopen/writeopen_windows.go +++ b/allowedpaths/internal/writeopen/writeopen_windows.go @@ -9,11 +9,15 @@ package writeopen import "os" -func OpenRoot(*os.Root) (*os.File, error) { - return nil, nil +func OpenRoot(root *os.Root) (*os.File, error) { + return root.Open(".") } -func CloseRoot(*os.File) {} +func CloseRoot(file *os.File) { + if file != nil { + _ = file.Close() + } +} func OpenFile(_ *os.File, root *os.Root, relPath string, flag int, perm os.FileMode) (*os.File, error) { // On Windows, os.Root.OpenFile uses the runtime's handle-relative diff --git a/allowedpaths/path_mode_nonwindows.go b/allowedpaths/path_mode_nonwindows.go index 90886f464..865fbb847 100644 --- a/allowedpaths/path_mode_nonwindows.go +++ b/allowedpaths/path_mode_nonwindows.go @@ -10,6 +10,8 @@ package allowedpaths import ( "errors" "os" + "path/filepath" + "strings" ) func resolveAllowedPathMode(path string) (string, pathMode) { @@ -39,3 +41,18 @@ func resolveDeniedPathMode(path string) (string, denyMode) { } return stripped, mode } + +func relWithin(rootPath, path string) (string, bool) { + rel, err := filepath.Rel(filepath.Clean(rootPath), filepath.Clean(path)) + if err != nil { + return "", false + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + return rel, true +} + +func hasUnsupportedPathSyntax(string) bool { + return false +} diff --git a/allowedpaths/path_mode_windows.go b/allowedpaths/path_mode_windows.go index cac2eac51..629b99fb2 100644 --- a/allowedpaths/path_mode_windows.go +++ b/allowedpaths/path_mode_windows.go @@ -7,6 +7,11 @@ package allowedpaths +import ( + "path/filepath" + "strings" +) + func resolveAllowedPathMode(path string) (string, pathMode) { stripped, mode, ok := splitAllowedPathMode(path) if !ok { @@ -29,3 +34,23 @@ func resolveDeniedPathMode(path string) (string, denyMode) { // Windows treats terminal ":r" and ":w" as rshell deny-mode metadata. return stripped, mode } + +func relWithin(rootPath, path string) (string, bool) { + rootPath = filepath.Clean(rootPath) + path = filepath.Clean(path) + if strings.EqualFold(rootPath, path) { + return ".", true + } + prefix := rootPath + if !strings.HasSuffix(prefix, string(filepath.Separator)) { + prefix += string(filepath.Separator) + } + if len(path) < len(prefix) || !strings.EqualFold(path[:len(prefix)], prefix) { + return "", false + } + return path[len(prefix):], true +} + +func hasUnsupportedPathSyntax(path string) bool { + return hasWindowsAlternateDataStream(path) +} diff --git a/allowedpaths/path_mode_windows_test.go b/allowedpaths/path_mode_windows_test.go index cd8a519d5..1de4d90c3 100644 --- a/allowedpaths/path_mode_windows_test.go +++ b/allowedpaths/path_mode_windows_test.go @@ -25,3 +25,51 @@ func TestResolveAllowedPathModeStripsWindowsSuffix(t *testing.T) { assert.Equal(t, base, path) assert.Equal(t, pathModeReadOnly, mode) } + +func TestRelWithinWindowsIsCaseInsensitive(t *testing.T) { + rel, ok := relWithin(`C:\Allowed\Root`, `c:\allowed\root\Child\File.txt`) + assert.True(t, ok) + assert.Equal(t, `Child\File.txt`, rel) + + rel, ok = relWithin(`C:\Allowed\Root`, `C:\Allowed\Root`) + assert.True(t, ok) + assert.Equal(t, `.`, rel) + + _, ok = relWithin(`C:\Allowed\Root`, `C:\Allowed\Rooted\File.txt`) + assert.False(t, ok) +} + +func TestWindowsAlternateDataStreamDetection(t *testing.T) { + assert.False(t, hasWindowsAlternateDataStream(`C:\tmp\file.txt`)) + assert.False(t, hasWindowsAlternateDataStream(`\\server\share\file.txt`)) + assert.False(t, hasWindowsAlternateDataStream(`\\?\C:\tmp\file.txt`)) + + assert.True(t, hasWindowsAlternateDataStream(`C:\tmp\file.txt:stream`)) + assert.True(t, hasWindowsAlternateDataStream(`\\?\C:\tmp\file.txt:stream`)) + assert.True(t, hasWindowsAlternateDataStream(`relative:name`)) + assert.True(t, hasWindowsAlternateDataStream(`C:relative`)) +} + +func TestNormalizeWindowsAbsoluteReparseTarget(t *testing.T) { + tests := []struct { + name string + in string + want string + ok bool + }{ + {name: "nt drive", in: `\??\C:\allowed\target`, want: `C:\allowed\target`, ok: true}, + {name: "nt unc", in: `\??\UNC\server\share\target`, want: `\\server\share\target`, ok: true}, + {name: "win32 drive", in: `\\?\C:\allowed\target`, want: `C:\allowed\target`, ok: true}, + {name: "volume guid", in: `\??\Volume{11111111-2222-3333-4444-555555555555}\target`, want: `\\?\Volume{11111111-2222-3333-4444-555555555555}\target`, ok: true}, + {name: "relative", in: `target\child`, ok: false}, + {name: "drive relative", in: `C:target\child`, ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := normalizeWindowsAbsoluteReparseTarget(tt.in) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/allowedpaths/read_open_windows.go b/allowedpaths/read_open_windows.go index bac347dbf..fec128e56 100644 --- a/allowedpaths/read_open_windows.go +++ b/allowedpaths/read_open_windows.go @@ -8,42 +8,362 @@ package allowedpaths import ( + "errors" "os" "path/filepath" + "strconv" + "strings" + "syscall" + + "golang.org/x/sys/windows" ) +const windowsSymlinkFlagRelative = 1 + +type readRestart struct { + absPath string +} + func (s *Sandbox) openReadDenyAware(path string, cwd string, flag int, perm os.FileMode) (*os.File, error) { + if flag != os.O_RDONLY { + return nil, &os.PathError{Op: "openat", Path: path, Err: os.ErrPermission} + } absPath := filepath.Clean(toAbs(path, cwd)) - if s.deniedFor(absPath, denyModeRead) { - return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + for hops := 0; hops <= maxSymlinkHops; hops++ { + f, restart, err := s.openReadDenyAwareAbs(path, absPath, flag, perm) + if err != nil { + return nil, err + } + if restart == nil { + return f, nil + } + absPath = filepath.Clean(restart.absPath) + } + return nil, &os.PathError{Op: "openat", Path: path, Err: syscall.ELOOP} +} + +func (s *Sandbox) openReadDenyAwareAbs(displayPath, absPath string, _ int, _ os.FileMode) (*os.File, *readRestart, error) { + if hasWindowsAlternateDataStream(absPath) || s.deniedFor(absPath, denyModeRead) { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} } ar, relPath, ok := s.resolve(absPath) if !ok { - return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + if ar.readRoot == nil { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} } - resolved, resolvedRel, ok := s.resolveRootFollowingSymlinks(absPath, false) - if ok { - resolvedAbs := filepath.Join(resolved.absPath, resolvedRel) - resolvedCanonicalAbs := filepath.Join(resolved.canonicalAbsPath, resolvedRel) - if s.deniedFor(resolvedAbs, denyModeRead) || s.deniedFor(resolvedCanonicalAbs, denyModeRead) { - return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} - } + clean := filepath.Clean(relPath) + components := []string{"."} + if clean != "." { + components = strings.Split(clean, string(filepath.Separator)) + } + + dirHandle := windows.Handle(ar.readRoot.Fd()) + closeDir := false + currentAbs := ar.absPath + activeDeny := s.denyModeForPath(currentAbs) + if activeDeny&denyModeRead != 0 { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} } + for i, component := range components { + final := i == len(components)-1 + if component == "" || component == "." { + if final { + f, err := s.openWindowsFinalRead(dirHandle, closeDir, ".", displayPath, activeDeny) + return f, nil, err + } + continue + } + if component == ".." { + closeWindowsHandle(dirHandle, closeDir) + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } - f, err := ar.root.OpenFile(relPath, flag, perm) - if err != nil && isPathEscapeError(err) { - if r, rel, ok := s.resolveFollowingSymlinks(absPath, false); ok { - f, err = r.OpenFile(rel, flag, perm) + nextAbs := filepath.Join(currentAbs, component) + activeDeny |= s.denyModeForPath(nextAbs) + if activeDeny&denyModeRead != 0 { + closeWindowsHandle(dirHandle, closeDir) + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} } + + nextHandle, err := openWindowsReadHandleAt(dirHandle, component) + closeWindowsHandle(dirHandle, closeDir) + if err != nil { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: err} + } + + info, err := windowsHandleInfo(nextHandle) + if err != nil { + _ = syscall.CloseHandle(syscall.Handle(nextHandle)) + return nil, nil, &os.PathError{Op: "fstat", Path: displayPath, Err: err} + } + if info.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + target, relative, err := readWindowsSupportedReparseTarget(nextHandle) + _ = syscall.CloseHandle(syscall.Handle(nextHandle)) + if err != nil { + if errors.Is(err, os.ErrPermission) { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + return nil, nil, &os.PathError{Op: "readlinkat", Path: displayPath, Err: err} + } + remaining := components[i+1:] + restart, ok := s.resolveWindowsReparseTarget(currentAbs, target, relative, remaining) + if !ok { + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + return nil, &readRestart{absPath: restart}, nil + } + + if final { + if err := s.checkOpenedDenyWindows(nextHandle, displayPath, activeDeny, denyModeRead); err != nil { + _ = syscall.CloseHandle(syscall.Handle(nextHandle)) + return nil, nil, err + } + f := os.NewFile(uintptr(nextHandle), displayPath) + if f == nil { + _ = syscall.CloseHandle(syscall.Handle(nextHandle)) + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrInvalid} + } + return f, nil, nil + } + + if info.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY == 0 { + _ = syscall.CloseHandle(syscall.Handle(nextHandle)) + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: syscall.ENOTDIR} + } + if err := s.checkOpenedDenyWindows(nextHandle, displayPath, activeDeny, denyModeRead); err != nil { + _ = syscall.CloseHandle(syscall.Handle(nextHandle)) + return nil, nil, err + } + dirHandle = nextHandle + closeDir = true + currentAbs = nextAbs } + + closeWindowsHandle(dirHandle, closeDir) + return nil, nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrInvalid} +} + +func (s *Sandbox) openWindowsFinalRead(parent windows.Handle, closeParent bool, name, displayPath string, active denyMode) (*os.File, error) { + h, err := openWindowsReadHandleAt(parent, name) + closeWindowsHandle(parent, closeParent) if err != nil { + return nil, &os.PathError{Op: "openat", Path: displayPath, Err: err} + } + if err := s.checkOpenedDenyWindows(h, displayPath, active, denyModeRead); err != nil { + _ = syscall.CloseHandle(syscall.Handle(h)) return nil, err } - identity, ok := fileIdentityFromOpenFile(f) - if ok && s.denyModeForIdentity(identity)&denyModeRead != 0 { - f.Close() - return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + f := os.NewFile(uintptr(h), displayPath) + if f == nil { + _ = syscall.CloseHandle(syscall.Handle(h)) + return nil, &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrInvalid} } return f, nil } + +func openWindowsReadHandleAt(parent windows.Handle, name string) (windows.Handle, error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return windows.InvalidHandle, err + } + attrs := &windows.OBJECT_ATTRIBUTES{ + Length: windowsObjectAttributesLength(), + RootDirectory: parent, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + + var h windows.Handle + err = windows.NtCreateFile( + &h, + windows.FILE_GENERIC_READ, + attrs, + &windows.IO_STATUS_BLOCK{}, + nil, + uint32(syscall.FILE_ATTRIBUTE_NORMAL), + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN, + windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_FOR_BACKUP_INTENT|windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return windows.InvalidHandle, windowsOpenError(err) + } + return h, nil +} + +func windowsObjectAttributesLength() uint32 { + if strconv.IntSize == 64 { + return 48 + } + return 24 +} + +func windowsOpenError(err error) error { + if status, ok := err.(windows.NTStatus); ok { + switch status { + case windows.STATUS_REPARSE_POINT_ENCOUNTERED: + return syscall.ELOOP + case windows.STATUS_NOT_A_DIRECTORY: + return syscall.ENOTDIR + case windows.STATUS_FILE_IS_A_DIRECTORY: + return syscall.EISDIR + case windows.STATUS_OBJECT_NAME_COLLISION: + return syscall.EEXIST + default: + return status.Errno() + } + } + return err +} + +func windowsHandleInfo(h windows.Handle) (syscall.ByHandleFileInformation, error) { + var info syscall.ByHandleFileInformation + err := syscall.GetFileInformationByHandle(syscall.Handle(h), &info) + return info, err +} + +func (s *Sandbox) checkOpenedDenyWindows(h windows.Handle, displayPath string, active denyMode, requested denyMode) error { + info, err := windowsHandleInfo(h) + if err != nil { + return &os.PathError{Op: "fstat", Path: displayPath, Err: err} + } + mode := active | s.denyModeForIdentity(fileIdentity{ + dev: uint64(info.VolumeSerialNumber), + ino: uint64(info.FileIndexHigh)<<32 | uint64(info.FileIndexLow), + }) + if mode&requested != 0 { + return &os.PathError{Op: "openat", Path: displayPath, Err: os.ErrPermission} + } + return nil +} + +func closeWindowsHandle(h windows.Handle, close bool) { + if close { + _ = syscall.CloseHandle(syscall.Handle(h)) + } +} + +func readWindowsSupportedReparseTarget(h windows.Handle) (string, bool, error) { + buf := make([]byte, windows.MAXIMUM_REPARSE_DATA_BUFFER_SIZE) + var returned uint32 + err := windows.DeviceIoControl( + h, + windows.FSCTL_GET_REPARSE_POINT, + nil, + 0, + &buf[0], + uint32(len(buf)), + &returned, + nil, + ) + if err != nil { + return "", false, err + } + if returned < 8 { + return "", false, os.ErrInvalid + } + buf = buf[:returned] + tag := windowsReparseUint32(buf, 0) + switch tag { + case windows.IO_REPARSE_TAG_SYMLINK: + if len(buf) < 20 { + return "", false, os.ErrInvalid + } + target, err := windowsReparseString(buf, 20, windowsReparseUint16(buf, 8), windowsReparseUint16(buf, 10)) + return target, windowsReparseUint32(buf, 16)&windowsSymlinkFlagRelative != 0, err + case windows.IO_REPARSE_TAG_MOUNT_POINT: + if len(buf) < 16 { + return "", false, os.ErrInvalid + } + target, err := windowsReparseString(buf, 16, windowsReparseUint16(buf, 8), windowsReparseUint16(buf, 10)) + return target, false, err + default: + return "", false, os.ErrPermission + } +} + +func windowsReparseString(buf []byte, pathStart int, offset uint16, length uint16) (string, error) { + start := pathStart + int(offset) + end := start + int(length) + if start < pathStart || end < start || end > len(buf) || length%2 != 0 { + return "", os.ErrInvalid + } + words := make([]uint16, int(length)/2) + for i := range words { + j := start + i*2 + words[i] = uint16(buf[j]) | uint16(buf[j+1])<<8 + } + return syscall.UTF16ToString(words), nil +} + +func windowsReparseUint16(buf []byte, offset int) uint16 { + return uint16(buf[offset]) | uint16(buf[offset+1])<<8 +} + +func windowsReparseUint32(buf []byte, offset int) uint32 { + return uint32(windowsReparseUint16(buf, offset)) | uint32(windowsReparseUint16(buf, offset+2))<<16 +} + +func (s *Sandbox) resolveWindowsReparseTarget(parentAbs string, target string, relative bool, remaining []string) (string, bool) { + var absPath string + if relative { + absPath = filepath.Join(parentAbs, target) + } else { + var ok bool + absPath, ok = normalizeWindowsAbsoluteReparseTarget(target) + if !ok { + return "", false + } + } + if len(remaining) > 0 { + absPath = filepath.Join(absPath, filepath.Join(remaining...)) + } + if hasWindowsAlternateDataStream(absPath) { + return "", false + } + return absPath, true +} + +func normalizeWindowsAbsoluteReparseTarget(target string) (string, bool) { + target = filepath.Clean(target) + const ( + ntPrefix = `\??\` + ntUNCPrefix = `\??\UNC\` + win32Prefix = `\\?\` + win32UNCPrefix = `\\?\UNC\` + uncPrefix = `\\` + volumePrefix = `Volume{` + ) + switch { + case strings.HasPrefix(target, ntUNCPrefix): + target = uncPrefix + strings.TrimPrefix(target, ntUNCPrefix) + case strings.HasPrefix(target, ntPrefix): + target = strings.TrimPrefix(target, ntPrefix) + if strings.HasPrefix(target, volumePrefix) { + target = win32Prefix + target + } + case strings.HasPrefix(target, win32UNCPrefix): + target = uncPrefix + strings.TrimPrefix(target, win32UNCPrefix) + case strings.HasPrefix(target, win32Prefix): + target = strings.TrimPrefix(target, win32Prefix) + if strings.HasPrefix(target, volumePrefix) { + target = win32Prefix + target + } + } + target = filepath.Clean(target) + if !filepath.IsAbs(target) { + return "", false + } + return target, true +} + +func hasWindowsAlternateDataStream(path string) bool { + volume := filepath.VolumeName(path) + rest := strings.TrimPrefix(path, volume) + return strings.Contains(rest, ":") +} diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 0fb6af3f6..4a043b887 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -230,11 +230,8 @@ func (s *Sandbox) resolveBy( for i := range s.roots { candidate := &s.roots[i] candidatePath := rootPath(candidate) - rel, err := filepath.Rel(candidatePath, absPath) - if err != nil { - continue - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + rel, ok := relWithin(candidatePath, absPath) + if !ok { continue } candidateLen := len(candidatePath) @@ -267,14 +264,8 @@ func (s *Sandbox) isAncestorOfRoot(absPath string) bool { absPath = filepath.Clean(absPath) for i := range s.roots { rootPath := filepath.Clean(s.roots[i].absPath) - if absPath == rootPath { - continue - } - rel, err := filepath.Rel(absPath, rootPath) - if err != nil { - continue - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + rel, ok := relWithin(absPath, rootPath) + if !ok || rel == "." { continue } return true @@ -376,19 +367,13 @@ func (s *Sandbox) resolveFollowingSymlinks(absPath string, preserveLast bool) (* } func isWithinRoot(rootPath, path string) bool { - rel, err := filepath.Rel(filepath.Clean(rootPath), filepath.Clean(path)) - if err != nil { - return false - } - return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) + _, ok := relWithin(rootPath, path) + return ok } func pathWithin(rootPath, path string) bool { - rel, err := filepath.Rel(filepath.Clean(rootPath), filepath.Clean(path)) - if err != nil { - return false - } - return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) + _, ok := relWithin(rootPath, path) + return ok } func (s *Sandbox) denyModeForPath(absPath string) denyMode { @@ -441,6 +426,9 @@ func (s *Sandbox) denyModeForResolvedPath(absPath string, preserveLast bool) den // resolveWriteTarget follows in-root symlinks before writes so path modes are // enforced against the final most-specific root, not just the lexical path. func (s *Sandbox) resolveWriteTarget(absPath string) (*root, string, bool) { + if hasUnsupportedPathSyntax(absPath) { + return nil, "", false + } ar, relPath, ok := s.resolve(absPath) if !ok || ar.mode != pathModeReadWrite || s.deniedFor(absPath, denyModeWrite) { return nil, "", false @@ -505,6 +493,9 @@ func (s *Sandbox) Access(path string, cwd string, mode uint32) error { if s == nil { return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} } + if hasUnsupportedPathSyntax(absPath) { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } requestedDeny := denyMode(0) if mode&modeRead != 0 { requestedDeny |= denyModeRead @@ -582,10 +573,11 @@ const allowedOpenFlags = os.O_RDONLY | os.O_WRONLY | const writeOpenFlags = os.O_WRONLY | os.O_APPEND | os.O_CREATE | os.O_TRUNC // Open implements the restricted file-open policy. Read opens go through -// os.Root for atomic path validation. Write opens use resolveWriteTarget for -// mode checks, then use the platform write opener; on Unix that opener walks -// with openat(O_NOFOLLOW) so mutable symlink components cannot redirect a -// checked write into a narrower read-only root. +// os.Root for atomic path validation, or through a deny-aware component +// resolver when DeniedPaths are configured. Write opens use resolveWriteTarget +// for mode checks, then use the platform write opener; that opener rejects +// symlink/reparse components so mutable links cannot redirect a checked write +// into a narrower read-only or denied root. // // In the default read-only mode only O_RDONLY opens are accepted; any write // flag returns ErrPermission. Call SetWritable to enable write opens for roots @@ -613,6 +605,9 @@ func (s *Sandbox) Open(path string, cwd string, flag int, perm os.FileMode) (io. } absPath := toAbs(path, cwd) + if hasUnsupportedPathSyntax(absPath) { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } if flag&writeOpenFlags == 0 && len(s.denyRoots) > 0 { f, err := s.openReadDenyAware(path, cwd, flag, perm) if err != nil { @@ -706,6 +701,9 @@ func (s *Sandbox) Truncate(path string, cwd string, size int64, create bool) err } absPath := toAbs(path, cwd) + if hasUnsupportedPathSyntax(absPath) { + return &os.PathError{Op: "truncate", Path: path, Err: os.ErrPermission} + } ar, relPath, ok := s.resolveWriteTarget(absPath) if !ok { @@ -844,6 +842,9 @@ func (s *Sandbox) readDirN(path string, cwd string, maxEntries int) ([]fs.DirEnt if s == nil { return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} } + if hasUnsupportedPathSyntax(absPath) { + return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } if s.deniedFor(absPath, denyModeRead) { return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} } @@ -908,6 +909,9 @@ func (s *Sandbox) OpenDir(path string, cwd string) (fs.ReadDirFile, error) { if s == nil { return nil, &os.PathError{Op: "opendir", Path: path, Err: os.ErrPermission} } + if hasUnsupportedPathSyntax(absPath) { + return nil, &os.PathError{Op: "opendir", Path: path, Err: os.ErrPermission} + } if s.deniedFor(absPath, denyModeRead) { return nil, &os.PathError{Op: "opendir", Path: path, Err: os.ErrPermission} } @@ -938,6 +942,9 @@ func (s *Sandbox) IsDirEmpty(path string, cwd string) (bool, error) { if s == nil { return false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} } + if hasUnsupportedPathSyntax(absPath) { + return false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } if s.deniedFor(absPath, denyModeRead) { return false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} } @@ -980,6 +987,9 @@ func (s *Sandbox) ReadDirLimited(path string, cwd string, offset, maxRead int) ( if s == nil { return nil, false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} } + if hasUnsupportedPathSyntax(absPath) { + return nil, false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} + } if s.deniedFor(absPath, denyModeRead) { return nil, false, &os.PathError{Op: "readdir", Path: path, Err: os.ErrPermission} } @@ -1142,6 +1152,9 @@ func (s *Sandbox) Stat(path string, cwd string) (fs.FileInfo, error) { if s == nil { return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrPermission} } + if hasUnsupportedPathSyntax(absPath) { + return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrPermission} + } if s.denyModeForResolvedPath(absPath, false)&denyModeRead != 0 { return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrPermission} } @@ -1182,6 +1195,9 @@ func (s *Sandbox) Lstat(path string, cwd string) (fs.FileInfo, error) { if s == nil { return nil, &os.PathError{Op: "lstat", Path: path, Err: os.ErrPermission} } + if hasUnsupportedPathSyntax(absPath) { + return nil, &os.PathError{Op: "lstat", Path: path, Err: os.ErrPermission} + } if s.denyModeForResolvedPath(absPath, true)&denyModeRead != 0 { return nil, &os.PathError{Op: "lstat", Path: path, Err: os.ErrPermission} } @@ -1215,6 +1231,9 @@ func (s *Sandbox) Readlink(path string, cwd string) (string, error) { if s == nil { return "", &os.PathError{Op: "readlink", Path: path, Err: os.ErrPermission} } + if hasUnsupportedPathSyntax(absPath) { + return "", &os.PathError{Op: "readlink", Path: path, Err: os.ErrPermission} + } if s.denyModeForResolvedPath(absPath, true)&denyModeRead != 0 { return "", &os.PathError{Op: "readlink", Path: path, Err: os.ErrPermission} } @@ -1287,11 +1306,8 @@ func (s *Sandbox) CanonicalizeRootPrefix(absPath string) string { if r.canonicalAbsPath == "" || r.canonicalAbsPath == r.absPath { continue } - rel, err := filepath.Rel(r.absPath, absPath) - if err != nil { - continue - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + rel, ok := relWithin(r.absPath, absPath) + if !ok { continue } if rel == "." { diff --git a/allowedpaths/sandbox_windows_test.go b/allowedpaths/sandbox_windows_test.go index 317281b93..e7190811e 100644 --- a/allowedpaths/sandbox_windows_test.go +++ b/allowedpaths/sandbox_windows_test.go @@ -126,3 +126,19 @@ func TestAccessExecAlwaysDeniedWindows(t *testing.T) { // Windows has no POSIX execute bits — always denied. assert.ErrorIs(t, sb.Access("data.txt", dir, 0x01), os.ErrPermission) } + +func TestAlternateDataStreamSyntaxRejectedWindows(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("data"), 0644)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + _, err = sb.Open("data.txt:stream", dir, os.O_RDONLY, 0) + assert.ErrorIs(t, err, os.ErrPermission) + assert.ErrorIs(t, sb.Access("data.txt:stream", dir, 0x04), os.ErrPermission) + + _, err = sb.Stat("data.txt:stream", dir) + assert.ErrorIs(t, err, os.ErrPermission) +} diff --git a/analysis/symbols_allowedpaths.go b/analysis/symbols_allowedpaths.go index 0b96d9e3b..2c9ce9e1d 100644 --- a/analysis/symbols_allowedpaths.go +++ b/analysis/symbols_allowedpaths.go @@ -17,75 +17,112 @@ package analysis // // The permanently banned packages (reflect, unsafe) apply here too. var allowedpathsAllowedSymbols = []string{ - "bytes.Buffer", // 🟢 in-memory byte buffer; collects sandbox warnings for deferred output. - "context.Context", // 🟢 context type used to signal cancellation; no I/O or side effects. - "errors.As", // 🟢 error type assertion; pure function, no I/O. - "errors.Is", // 🟢 error comparison; pure function, no I/O. - "errors.New", // 🟢 creates a simple error value; pure function, no I/O. - "fmt.Errorf", // 🟢 formatted error creation; pure function, no I/O. - "fmt.Fprintf", // 🟠 writes warning messages to in-memory buffer during sandbox construction. - "golang.org/x/sys/unix.Close", // 🟠 closes fd-relative directory descriptors opened during deny-aware read resolution; no path lookup. - "golang.org/x/sys/unix.ELOOP", // 🟢 symlink-loop errno constant; pure constant. - "golang.org/x/sys/unix.Fstat", // 🟠 reads metadata for an already-open fd so deny checks apply to the object actually opened. - "golang.org/x/sys/unix.O_CLOEXEC", // 🟢 close-on-exec open flag constant; pure constant. - "golang.org/x/sys/unix.O_DIRECTORY", // 🟢 directory-only open flag constant used for component walks. - "golang.org/x/sys/unix.O_NOFOLLOW", // 🟢 no-follow open flag constant; prevents silent symlink traversal during deny-aware reads. - "golang.org/x/sys/unix.O_RDONLY", // 🟢 read-only open flag constant; pure constant. - "golang.org/x/sys/unix.Openat", // 🟠 fd-relative open used to keep deny checks and final opens in one race-resistant resolution chain. - "golang.org/x/sys/unix.Readlinkat", // 🟠 reads symlink targets relative to an already-open directory fd during deny-aware resolution. - "golang.org/x/sys/unix.Stat_t", // 🟢 file stat structure type returned by Fstat; pure data type. - "io.EOF", // 🟢 sentinel error value; pure constant. - "io.ReadWriteCloser", // 🟢 combined interface type; no side effects. - "io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects. - "io/fs.ModeSymlink", // 🟢 file mode bit for symlinks; pure constant. - "io/fs.ErrExist", // 🟢 sentinel error for "already exists"; pure constant. - "io/fs.ErrNotExist", // 🟢 sentinel error for "does not exist"; pure constant. - "io/fs.ErrPermission", // 🟢 sentinel error for permission denied; pure constant. - "io/fs.FileInfo", // 🟢 interface type for file metadata; no side effects. - "io/fs.FileMode", // 🟢 file permission bits type; pure type. - "io/fs.ReadDirFile", // 🟢 read-only directory handle interface; no write capability. - "os.DevNull", // 🟢 platform null device path constant; pure constant. - "os.ErrInvalid", // 🟢 sentinel error for invalid operations; pure error value. - "os.ErrNotExist", // 🟢 sentinel error for missing literal paths; pure constant. - "os.ErrPermission", // 🟢 sentinel error for permission denied; pure constant. - "os.File", // 🟠 file handle returned by os.Root.Open; needed for cross-root symlink fallback. - "os.FileMode", // 🟢 file permission bits type; pure type. - "os.Getgid", // 🟠 returns the numeric group id of the caller; read-only syscall. - "os.Getgroups", // 🟠 returns supplementary group ids; read-only syscall. - "os.Getuid", // 🟠 returns the numeric user id of the caller; read-only syscall. - "os.Lstat", // 🟠 checks whether a literal AllowedPaths entry exists without following symlinks, preserving legacy paths that end in :ro/:rw. - "os.O_APPEND", // 🟢 append-on-write file flag constant; pure constant. Part of the sandbox open-flag allowlist. - "os.O_CREATE", // 🟢 create-if-missing file flag constant; pure constant. Part of the sandbox open-flag allowlist. - "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. - "os.O_TRUNC", // 🟢 truncate-on-open file flag constant; pure constant. Part of the sandbox open-flag allowlist. - "os.O_WRONLY", // 🟢 write-only file flag constant; pure constant. Part of the sandbox open-flag allowlist. - "os.NewFile", // 🟠 wraps a final fd opened by the deny-aware resolver; the fd was already policy-checked. - "os.OpenRoot", // 🟠 opens a directory as a root for sandboxed file access; needed for sandbox. - "os.PathError", // 🟢 error type wrapping path and operation; pure type. - "os.Root", // 🟠 sandboxed directory root type; core of the filesystem sandbox. - "os.Stat", // 🟠 returns file info for a path; needed for sandbox path validation. - "path/filepath.Abs", // 🟢 returns absolute path; pure path computation. - "path/filepath.Clean", // 🟢 normalizes a path; pure function, no I/O. - "path/filepath.Dir", // 🟢 returns directory portion of a path; pure function, no I/O. - "path/filepath.EvalSymlinks", // 🟠 resolves symlinks via os.Lstat; the sandbox uses this at setup time to record canonical root paths so builtins like `pwd -P` can reflect the symlink resolution that os.Root has implicitly followed. - "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.Rel", // 🟢 returns relative path; pure path computation. - "path/filepath.Separator", // 🟢 OS path separator constant; pure constant. - "slices.SortFunc", // 🟢 sorts a slice with a comparison function; pure function, no I/O. - "sync.Once", // 🟢 ensures one-time execution; used to close file descriptors at most once. - "strings.Compare", // 🟢 compares two strings lexicographically; pure function, no I/O. - "strings.EqualFold", // 🟢 case-insensitive string comparison; pure function, no I/O. - "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. - "strings.HasSuffix", // 🟢 pure function for suffix matching; no I/O. - "strings.Join", // 🟢 joins string slices; pure function, no I/O. - "strings.Split", // 🟢 splits a string by separator; pure function, no I/O. - "syscall.ByHandleFileInformation", // 🟢 Windows file identity structure; pure type for file metadata. - "syscall.EINVAL", // 🟢 "invalid argument" errno constant; pure constant. Used by Sandbox.Truncate to reject negative sizes. - "syscall.EISDIR", // 🟢 "is a directory" errno constant; pure constant. - "syscall.Errno", // 🟢 system call error number type; pure type. - "syscall.GetFileInformationByHandle", // 🟠 Windows API for file identity (vol serial + file index); read-only syscall. - "syscall.Handle", // 🟢 Windows file handle type; pure type alias. - "syscall.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking on FIFOs during access checks. Pure constant. - "syscall.Stat_t", // 🟢 file stat structure type; pure type for Unix file metadata. + "bytes.Buffer", // 🟢 in-memory byte buffer; collects sandbox warnings for deferred output. + "context.Context", // 🟢 context type used to signal cancellation; no I/O or side effects. + "errors.As", // 🟢 error type assertion; pure function, no I/O. + "errors.Is", // 🟢 error comparison; pure function, no I/O. + "errors.New", // 🟢 creates a simple error value; pure function, no I/O. + "fmt.Errorf", // 🟢 formatted error creation; pure function, no I/O. + "fmt.Fprintf", // 🟠 writes warning messages to in-memory buffer during sandbox construction. + "golang.org/x/sys/unix.Close", // 🟠 closes fd-relative directory descriptors opened during deny-aware read resolution; no path lookup. + "golang.org/x/sys/unix.ELOOP", // 🟢 symlink-loop errno constant; pure constant. + "golang.org/x/sys/unix.Fstat", // 🟠 reads metadata for an already-open fd so deny checks apply to the object actually opened. + "golang.org/x/sys/unix.O_CLOEXEC", // 🟢 close-on-exec open flag constant; pure constant. + "golang.org/x/sys/unix.O_DIRECTORY", // 🟢 directory-only open flag constant used for component walks. + "golang.org/x/sys/unix.O_NOFOLLOW", // 🟢 no-follow open flag constant; prevents silent symlink traversal during deny-aware reads. + "golang.org/x/sys/unix.O_RDONLY", // 🟢 read-only open flag constant; pure constant. + "golang.org/x/sys/unix.Openat", // 🟠 fd-relative open used to keep deny checks and final opens in one race-resistant resolution chain. + "golang.org/x/sys/unix.Readlinkat", // 🟠 reads symlink targets relative to an already-open directory fd during deny-aware resolution. + "golang.org/x/sys/unix.Stat_t", // 🟢 file stat structure type returned by Fstat; pure data type. + "golang.org/x/sys/windows.DeviceIoControl", // 🟠 reads reparse metadata from an already-open Windows handle; used only to resolve supported symlink/junction targets during deny-aware reads. + "golang.org/x/sys/windows.FILE_GENERIC_READ", // 🟢 read-only access mask for handle-relative Windows opens. + "golang.org/x/sys/windows.FILE_OPEN", // 🟢 NtCreateFile open-existing disposition constant. + "golang.org/x/sys/windows.FILE_OPEN_FOR_BACKUP_INTENT", // 🟢 permits opening directories for read/list operations through NtCreateFile. + "golang.org/x/sys/windows.FILE_OPEN_REPARSE_POINT", // 🟢 prevents final reparse-point traversal so rshell can inspect symlinks/junctions explicitly. + "golang.org/x/sys/windows.FILE_SHARE_DELETE", // 🟢 Windows share-mode constant matching os.Root open compatibility. + "golang.org/x/sys/windows.FILE_SHARE_READ", // 🟢 Windows share-mode constant matching os.Root open compatibility. + "golang.org/x/sys/windows.FILE_SHARE_WRITE", // 🟢 Windows share-mode constant matching os.Root open compatibility. + "golang.org/x/sys/windows.FILE_SYNCHRONOUS_IO_NONALERT", // 🟢 synchronous handle option constant for NtCreateFile. + "golang.org/x/sys/windows.FSCTL_GET_REPARSE_POINT", // 🟢 DeviceIoControl selector for read-only reparse metadata. + "golang.org/x/sys/windows.Handle", // 🟢 opaque Windows handle type; pure type. + "golang.org/x/sys/windows.IO_REPARSE_TAG_MOUNT_POINT", // 🟢 reparse tag constant for junctions/mount points. + "golang.org/x/sys/windows.IO_REPARSE_TAG_SYMLINK", // 🟢 reparse tag constant for symbolic links. + "golang.org/x/sys/windows.IO_STATUS_BLOCK", // 🟢 NtCreateFile status structure; pure data type. + "golang.org/x/sys/windows.InvalidHandle", // 🟢 sentinel invalid Windows handle value. + "golang.org/x/sys/windows.MAXIMUM_REPARSE_DATA_BUFFER_SIZE", // 🟢 documented maximum reparse metadata buffer size. + "golang.org/x/sys/windows.NTStatus", // 🟢 Windows NTSTATUS error type; pure type. + "golang.org/x/sys/windows.NewNTUnicodeString", // 🟢 prepares an in-memory NT path string for handle-relative NtCreateFile calls. + "golang.org/x/sys/windows.NtCreateFile", // 🟠 handle-relative Windows open primitive used to keep deny checks and final opens in one race-resistant resolution chain. + "golang.org/x/sys/windows.OBJECT_ATTRIBUTES", // 🟢 NtCreateFile object-attributes structure; pure data type. + "golang.org/x/sys/windows.OBJ_CASE_INSENSITIVE", // 🟢 Windows path matching flag for normal case-insensitive filesystems. + "golang.org/x/sys/windows.STATUS_FILE_IS_A_DIRECTORY", // 🟢 NTSTATUS constant mapped to EISDIR. + "golang.org/x/sys/windows.STATUS_NOT_A_DIRECTORY", // 🟢 NTSTATUS constant mapped to ENOTDIR. + "golang.org/x/sys/windows.STATUS_OBJECT_NAME_COLLISION", // 🟢 NTSTATUS constant mapped to EEXIST. + "golang.org/x/sys/windows.STATUS_REPARSE_POINT_ENCOUNTERED", // 🟢 NTSTATUS constant mapped to ELOOP if a reparse point is unexpectedly encountered. + "io.EOF", // 🟢 sentinel error value; pure constant. + "io.ReadWriteCloser", // 🟢 combined interface type; no side effects. + "io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects. + "io/fs.ModeSymlink", // 🟢 file mode bit for symlinks; pure constant. + "io/fs.ErrExist", // 🟢 sentinel error for "already exists"; pure constant. + "io/fs.ErrNotExist", // 🟢 sentinel error for "does not exist"; pure constant. + "io/fs.ErrPermission", // 🟢 sentinel error for permission denied; pure constant. + "io/fs.FileInfo", // 🟢 interface type for file metadata; no side effects. + "io/fs.FileMode", // 🟢 file permission bits type; pure type. + "io/fs.ReadDirFile", // 🟢 read-only directory handle interface; no write capability. + "os.DevNull", // 🟢 platform null device path constant; pure constant. + "os.ErrInvalid", // 🟢 sentinel error for invalid operations; pure error value. + "os.ErrNotExist", // 🟢 sentinel error for missing literal paths; pure constant. + "os.ErrPermission", // 🟢 sentinel error for permission denied; pure constant. + "os.File", // 🟠 file handle returned by os.Root.Open; needed for cross-root symlink fallback. + "os.FileMode", // 🟢 file permission bits type; pure type. + "os.Getgid", // 🟠 returns the numeric group id of the caller; read-only syscall. + "os.Getgroups", // 🟠 returns supplementary group ids; read-only syscall. + "os.Getuid", // 🟠 returns the numeric user id of the caller; read-only syscall. + "os.Lstat", // 🟠 checks whether a literal AllowedPaths entry exists without following symlinks, preserving legacy paths that end in :ro/:rw. + "os.O_APPEND", // 🟢 append-on-write file flag constant; pure constant. Part of the sandbox open-flag allowlist. + "os.O_CREATE", // 🟢 create-if-missing file flag constant; pure constant. Part of the sandbox open-flag allowlist. + "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. + "os.O_TRUNC", // 🟢 truncate-on-open file flag constant; pure constant. Part of the sandbox open-flag allowlist. + "os.O_WRONLY", // 🟢 write-only file flag constant; pure constant. Part of the sandbox open-flag allowlist. + "os.NewFile", // 🟠 wraps a final fd opened by the deny-aware resolver; the fd was already policy-checked. + "os.OpenRoot", // 🟠 opens a directory as a root for sandboxed file access; needed for sandbox. + "os.PathError", // 🟢 error type wrapping path and operation; pure type. + "os.Root", // 🟠 sandboxed directory root type; core of the filesystem sandbox. + "os.Stat", // 🟠 returns file info for a path; needed for sandbox path validation. + "path/filepath.Abs", // 🟢 returns absolute path; pure path computation. + "path/filepath.Clean", // 🟢 normalizes a path; pure function, no I/O. + "path/filepath.Dir", // 🟢 returns directory portion of a path; pure function, no I/O. + "path/filepath.EvalSymlinks", // 🟠 resolves symlinks via os.Lstat; the sandbox uses this at setup time to record canonical root paths so builtins like `pwd -P` can reflect the symlink resolution that os.Root has implicitly followed. + "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.Rel", // 🟢 returns relative path; pure path computation. + "path/filepath.Separator", // 🟢 OS path separator constant; pure constant. + "path/filepath.VolumeName", // 🟢 parses Windows volume prefixes so alternate data stream syntax can be rejected without mistaking drive letters for ADS. + "slices.SortFunc", // 🟢 sorts a slice with a comparison function; pure function, no I/O. + "strconv.IntSize", // 🟢 compile-time pointer-size hint used to populate Windows OBJECT_ATTRIBUTES.Length without importing unsafe. + "sync.Once", // 🟢 ensures one-time execution; used to close file descriptors at most once. + "strings.Compare", // 🟢 compares two strings lexicographically; pure function, no I/O. + "strings.Contains", // 🟢 pure string search used to reject Windows alternate data stream syntax. + "strings.EqualFold", // 🟢 case-insensitive string comparison; pure function, no I/O. + "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. + "strings.HasSuffix", // 🟢 pure function for suffix matching; no I/O. + "strings.Join", // 🟢 joins string slices; pure function, no I/O. + "strings.Split", // 🟢 splits a string by separator; pure function, no I/O. + "strings.TrimPrefix", // 🟢 pure string normalization for Windows NT/Win32 reparse-target prefixes. + "syscall.ByHandleFileInformation", // 🟢 Windows file identity structure; pure type for file metadata. + "syscall.CloseHandle", // 🟠 closes Windows handles opened during deny-aware read resolution; no path lookup. + "syscall.EEXIST", // 🟢 "already exists" errno constant; pure constant. + "syscall.EINVAL", // 🟢 "invalid argument" errno constant; pure constant. Used by Sandbox.Truncate to reject negative sizes. + "syscall.EISDIR", // 🟢 "is a directory" errno constant; pure constant. + "syscall.ELOOP", // 🟢 symlink-loop errno constant; pure constant. + "syscall.ENOTDIR", // 🟢 "not a directory" errno constant; pure constant. + "syscall.Errno", // 🟢 system call error number type; pure type. + "syscall.FILE_ATTRIBUTE_DIRECTORY", // 🟢 Windows directory attribute bit; pure constant. + "syscall.FILE_ATTRIBUTE_NORMAL", // 🟢 Windows default file attribute for NtCreateFile opens; pure constant. + "syscall.FILE_ATTRIBUTE_REPARSE_POINT", // 🟢 Windows reparse attribute bit; pure constant. + "syscall.GetFileInformationByHandle", // 🟠 Windows API for file identity (vol serial + file index); read-only syscall. + "syscall.Handle", // 🟢 Windows file handle type; pure type alias. + "syscall.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking on FIFOs during access checks. Pure constant. + "syscall.Stat_t", // 🟢 file stat structure type; pure type for Unix file metadata. + "syscall.UTF16ToString", // 🟢 decodes UTF-16 reparse target text from an in-memory metadata buffer. }