Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ The shell is supported on Linux, Windows and macOS.

- **`df` bypasses `AllowedPaths` for mount-table enumeration.** `df` delegates filesystem listing to `builtins/internal/diskstats`, which on Linux reads `/proc/self/mountinfo` directly via `os.Open` and then calls `unix.Statfs(2)` on every mount point returned by the kernel. On macOS it calls `unix.Getfsstat(2)`. The mount-point paths are kernel-controlled — never derived from user input — so the same trade-off as `ss` / `ip route` applies: operators cannot use `AllowedPaths` to hide individual mounts from `df`. `Statfs` returns metadata only (block / inode counts, filesystem type, block size); no file content is read.

- **`free` bypasses `AllowedPaths` for its Linux-only memory read, and is intentionally not implemented on macOS/Windows.** `free` delegates to `builtins/internal/meminfo`, which on Linux reads `/proc/meminfo` directly via `os.Open` — the same documented exception as `ss`/`ip route`/`df`: the path is hardcoded, never derived from user input, and only aggregate host-wide memory/swap counters are exposed (no per-process or environment data). On macOS and Windows, `meminfo.Read` returns `ErrNotSupported` and `free` exits 1 rather than shipping a partial implementation: macOS has no sysctl(3)-level equivalent of the buffers/cache/shared breakdown (only the Mach `host_statistics64` RPC exposes it, which needs cgo or dynamic libSystem symbol resolution that no other builtin in this repo uses), and Windows' `GlobalMemoryStatusEx` has no concept of buffers/cache/shared at all — reporting those columns as a literal `0` would read as "this host has none" rather than "not available on this platform," which could mislead an agent's memory-pressure diagnosis. `free` is also scoped to a single-shot snapshot (`free`, `free -h`) per the accepted-candidate design brief; it does not implement `-s`/`-c` repeated sampling — that is out of scope for `free` and belongs to the planned `vmstat` builtin.
- **`vmstat` bypasses `AllowedPaths` for memory/CPU/IO pressure reads, and reports macOS-only-partial counters as `0`/`-` rather than fabricating them.** `vmstat` delegates counter collection to `builtins/internal/vmstat`, which on Linux reads `/proc/{stat,meminfo,vmstat,loadavg,uptime}` directly via `os.Open` (same documented exception as `ss`/`ip route`/`df`: the paths are hardcoded, never derived from user input) and on macOS calls `sysctl(3)` (`hw.memsize`, `vm.swapusage`, `vm.loadavg`) — the same darwin toolset `df` and `ss` already use, deliberately not Mach `host_statistics64` (no existing builtin uses Mach, and adding it would introduce a new syscall review surface). Consequently, on macOS: per-page memory breakdown (buffers/cache/active/inactive beyond the sysctl-reported total), CPU tick counters, and paging/interrupt/context-switch rates are unavailable. Column groups with no data source render as `-`; `MemFree`/`MemBuffers`/`MemCached`/`MemActive`/`MemInactive` render as `0` because macOS has no sysctl exposing them without Mach — this is a documented, intentional limitation (see `builtins/internal/vmstat/vmstat_darwin.go`), not a fabricated measurement.

- **`free` bypasses `AllowedPaths` for its Linux-only memory read, and is intentionally not implemented on macOS/Windows.** `free` delegates to `builtins/internal/meminfo`, which on Linux reads `/proc/meminfo` directly via `os.Open` — the same documented exception as `ss`/`ip route`/`df`: the path is hardcoded, never derived from user input, and only aggregate host-wide memory/swap counters are exposed (no per-process or environment data). On macOS and Windows, `meminfo.Read` returns `ErrNotSupported` and `free` exits 1 rather than shipping a partial implementation: macOS has no sysctl(3)-level equivalent of the buffers/cache/shared breakdown (only the Mach `host_statistics64` RPC exposes it, which needs cgo or dynamic libSystem symbol resolution that no other builtin in this repo uses), and Windows' `GlobalMemoryStatusEx` has no concept of buffers/cache/shared at all — reporting those columns as a literal `0` would read as "this host has none" rather than "not available on this platform," which could mislead an agent's memory-pressure diagnosis. `free` is also scoped to a single-shot snapshot (`free`, `free -h`) per the accepted-candidate design brief; it does not implement `-s`/`-c` repeated sampling — that is out of scope for `free` and belongs to the `vmstat` builtin.

- **`journalctl` bypasses `AllowedPaths` for trusted systemd target access.** The builtin delegates journal discovery and reads, disk-usage scans, vacuuming, machine-ID reads, and journald control-socket access to `internal/systemd`, which opens the configured paths directly with `os` APIs. `SystemdTargetConfig.JournalDirs`, `SystemdTargetConfig.MachineIDPath`, and `SystemdTargetConfig.JournalControlSocket` are fixed by the embedding application when constructing the runner and cannot be supplied or changed by shell scripts. Operators therefore cannot use `AllowedPaths` to block these accesses; authorization is enforced separately by `AllowedCommands`, exact `AllowedSystemServices` grants, and remediation mode for mutations. All configured target paths are trusted and must refer to the same host. See the trusted systemd target exception in `docs/RULES.md` for the backend requirements.

Expand All @@ -54,12 +56,13 @@ The shell is supported on Linux, Windows and macOS.
The test suite runs all scenarios against `debian:bookworm-slim` (GNU bash + GNU coreutils) and compares output byte-for-byte. Only set `skip_assert_against_bash: true` in a scenario when the behavior intentionally diverges from bash (e.g. sandbox restrictions, blocked commands).

- **Prefer scenario tests (`tests/scenarios/`) over Go tests.** Scenario tests are declarative YAML files that are automatically validated against both the shell and bash, making them easier to write, review, and maintain. Only use Go tests when scenario tests cannot express the required behaviour (e.g. testing Go APIs directly, complex programmatic assertions).
- **`ip route show`/`ip route get` happy-path scenario tests cannot be added.** The scenario test framework has no platform-skip mechanism, and `ip route` reads `/proc/net/route` which is Linux-only — the command exits 1 with "not supported" on macOS and Windows. Happy-path coverage lives in `builtins/tests/ip/ip_linux_test.go` instead.
- **`free` happy-path scenario tests cannot be added, for the same reason as `ip route`.** `free` is Linux-only; the same script exits 0 with real memory data on Linux but exits 1 with "not supported" elsewhere, and the scenario framework cannot express a platform-conditional expectation. Happy-path coverage lives in `builtins/internal/meminfo`'s `parseMeminfo` tests (fixed fixtures, run on Linux CI) plus `TestFreeLinuxHappyPath`/`TestFreeNotSupportedOffLinux` in `builtins/free/free_test.go` (both `runtime.GOOS`-gated). Only `free`'s help/error paths, which behave identically on every platform, have scenario coverage.
- **`ip route show`/`ip route get` happy-path scenario tests cannot be added.** The scenario test framework's `skip_windows` field only skips Windows, and `ip route` reads `/proc/net/route` which is Linux-only — the command exits 1 with "not supported" on macOS too, so a single scenario still can't express the happy path on every platform. Happy-path coverage lives in `builtins/tests/ip/ip_linux_test.go` instead.
- **`free` happy-path scenario tests cannot be added, for the same reason as `ip route`.** `free` is Linux-only; the same script exits 0 with real memory data on Linux but exits 1 with "not supported" elsewhere, and `skip_windows` only skips Windows, not macOS, so the scenario framework still cannot express a platform-conditional expectation. Happy-path coverage lives in `builtins/internal/meminfo`'s `parseMeminfo` tests (fixed fixtures, run on Linux CI) plus `TestFreeLinuxHappyPath`/`TestFreeNotSupportedOffLinux` in `builtins/free/free_test.go` (both `runtime.GOOS`-gated). Only `free`'s help/error paths, which behave identically on every platform, have scenario coverage.
- In test scenarios, use `expect.stderr` when possible instead of `stderr_contains`.
- Always use the YAML `|+` block scalar for `input.script`, `expect.stdout`, and `expect.stderr` values, even single-line ones.
- Test scenarios are asserted against bash by default. Only set `skip_assert_against_bash: true` for features that intentionally diverge from standard bash behavior (e.g. blocked commands, restricted redirects, readonly enforcement).
- When expected output differs on Windows (e.g. path separators `\` vs `/`), use Windows-specific assertion fields:
- `stdout_windows` / `stderr_windows` — override `stdout` / `stderr` on Windows.
- `stdout_contains_windows` / `stderr_contains_windows` — override `stdout_contains` / `stderr_contains` on Windows.
- If the Windows field is not set, the non-Windows field is used as fallback.
- Set `skip_windows: true` to skip a scenario entirely on Windows, for commands that are intentionally unsupported there rather than merely producing different output (e.g. `vmstat`, which only reads `/proc` on Linux and `sysctl` on macOS).
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Vacuum thresholds are provided by the `journalctl` command itself. The backend r
- **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 and invalid system service names 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 inspect them with `Runner.Warnings()`.

> **Note:** The `ss`, `ip route`, `df`, and `free` 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; `free` reads `/proc/meminfo` (Linux only) directly via `os.Open`. These paths are hardcoded — never derived from user input — and the reads return metadata only (block / inode counts, filesystem type, block size, or aggregate memory/swap counters). 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, `df` from reporting mount-table capacity, or `free` from reporting host memory usage — these reads succeed regardless of the configured path policy.
> **Note:** The `ss`, `ip route`, `df`, `vmstat`, and `free` 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; `vmstat` reads `/proc/{stat,meminfo,vmstat,loadavg,uptime}` (Linux) or calls `sysctl(3)` (macOS: `hw.memsize`, `vm.swapusage`, `vm.loadavg`); `free` reads `/proc/meminfo` (Linux only) directly via `os.Open`. These paths are hardcoded — never derived from user input — and all of these calls return metadata/counters only (no directory listings, no file contents). 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, `df` from reporting mount-table capacity, `vmstat` from reporting memory/CPU/IO pressure counters, or `free` from reporting host memory usage — 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/<pid>/cmdline`; the `CMD` column reports only the process comm/executable name.

Expand Down
1 change: 1 addition & 0 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c
- ✅ `true` — return exit code 0
- ✅ `uname [-asnrvm]` — print system information (Linux only; reads from `/proc/sys/kernel/`, respects `--proc-path`)
- ✅ `uniq [OPTION]... [INPUT]` — report or omit repeated lines
- ✅ `vmstat [-a] [-w] [-S k|K|m|M] [-s] [delay [count]]` — report virtual memory, swap, IO, and CPU pressure statistics; no arguments prints a since-boot snapshot, `delay [count]` samples repeatedly, `-s`/`--stats` prints a full counter summary; reads `/proc/*` (Linux) or `sysctl(3)` (macOS) directly, bypassing `AllowedPaths` (same exception as `df`/`ss`/`ip route`); macOS lacks several counters (Mach-only), shown as `-`/`0` rather than fabricated; `-d`, `-p`, `-f`, `-m`, `-t`, `-n`, `-V` are not implemented; not supported on Windows
- ✅ `wc [-l] [-w] [-c] [-m] [-L] [FILE]...` — count lines, words, bytes, characters, or max line length
- ✅ `xargs [-0] [-a FILE] [-d DELIM] [-E EOF-STR] [-I REPLSTR] [-L N] [-n N] [-r] [-s N] [-t] [-x] [COMMAND [INITIAL-ARGS]...]` — build and execute commands from standard input; only invokes other registered builtins (subject to `CommandAllowed`), so the GTFOBins shell-escape `xargs … /bin/sh` is rejected; flags outside the supported set above (e.g. `-p` interactive, `-P` parallel, `-o`/`--open-tty`, `--show-limits`) are rejected as unknown
- ❌ All other commands — return exit code 127 with `<cmd>: not found` unless an ExecHandler is configured
Expand Down
21 changes: 21 additions & 0 deletions analysis/symbols_builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,23 @@ var builtinPerCommandSymbols = map[string][]string{
"strconv.ParseInt", // 🟢 string-to-int conversion with base/bit-size; pure function, no I/O.
"strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O.
},
"vmstat": {
"context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects.
"fmt.Errorf", // 🟢 error formatting; pure function, no I/O.
"fmt.Sprintf", // 🟢 string formatting; pure function, no I/O.
"math.MaxInt", // 🟢 integer constant; bounds the count operand before conversion to int; no side effects.
"math.MaxUint64", // 🟢 integer constant; saturates float-to-uint64 rate conversions instead of relying on out-of-range cast behavior; no side effects.
"math.Round", // 🟢 rounds a float64 to the nearest integer; used for CPU-tick percentage rounding; pure function, no I/O.
"strconv.ParseUint", // 🟢 string-to-unsigned-int conversion; used to validate delay/count operands; pure function, no I/O.
"strings.Join", // 🟢 concatenates a slice of strings with a separator; pure function, no I/O.
"strings.Repeat", // 🟢 builds dash separators for the group header; pure function, no I/O.
"time.Duration", // 🟢 duration type; pure integer alias, no I/O.
"time.NewTimer", // 🟢 creates a timer that fires once after a duration; used for the sampling-interval wait; pure in-process scheduling, no I/O.
"time.Second", // 🟢 constant representing one second; no side effects.
// Note: builtins/internal/vmstat symbols are exempt from this
// allowlist (internal packages are not checked by the
// builtinAllowedSymbols test).
},
"ss": {
"context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects.
"errors.Is", // 🟢 error comparison; used to distinguish syscall.ENOENT from unexpected errors.
Expand Down Expand Up @@ -616,6 +633,7 @@ var builtinPerCommandCallContextFields = map[string][]string{
"ss": {},
"true": {},
"uname": {},
"vmstat": {},

"cat": {
"OpenFile",
Expand Down Expand Up @@ -802,11 +820,13 @@ var builtinAllowedSymbols = []string{
"math.Inf", // 🟢 returns positive or negative infinity; pure function, no I/O.
"math.IsInf", // 🟢 IEEE 754 infinity check; pure function, no I/O.
"math.IsNaN", // 🟢 IEEE 754 NaN check; pure function, no I/O.
"math.MaxInt", // 🟢 integer constant; no side effects.
"math.MaxInt32", // 🟢 integer constant; no side effects.
"math.MaxInt64", // 🟢 integer constant; no side effects.
"math.MaxUint64", // 🟢 integer constant; no side effects.
"math.MinInt64", // 🟢 integer constant; no side effects.
"math.NaN", // 🟢 returns IEEE 754 NaN value; pure function, no I/O.
"math.Round", // 🟢 rounds a float64 to the nearest integer; pure function, no I/O.
"net.DefaultResolver", // 🔴 default system DNS resolver; used for context-aware address lookup; network I/O is the explicit purpose of the ping builtin.
"net.FlagBroadcast", // 🟢 interface flag constant: broadcast capability; pure constant, no network connections.
"net.IPAddr", // 🟢 resolved IP address struct (IP + Zone); pure data type, no I/O.
Expand Down Expand Up @@ -883,6 +903,7 @@ var builtinAllowedSymbols = []string{
"time.Hour", // 🟢 constant representing one hour; no side effects.
"time.Millisecond", // 🟢 constant representing one millisecond; no side effects.
"time.Minute", // 🟢 constant representing one minute; no side effects.
"time.NewTimer", // 🟢 creates a timer that fires once after a duration; pure in-process scheduling, no I/O.
"time.Parse", // 🟢 parses timestamps according to a caller-supplied layout; pure function, no I/O.
"time.ParseDuration", // 🟢 parses Go duration strings (e.g. "1s"); pure function, no I/O.
"time.ParseInLocation", // 🟢 parses timestamps in a caller-supplied location; pure function, no I/O.
Expand Down
Loading
Loading