This is a minimal bash/POSIX like shell interpreter. Safety is the primary goal.
This shell is intended to be used by AI Agents.
The shell is supported on Linux, Windows and macOS.
README.mdandSHELL_FEATURES.mdmust be kept up to date with the implementation.- When adding or modifying a builtin, set or update the
Descriptionfield on itsbuiltins.Commandstruct and verify the command appears correctly inhelpoutput.
- IMPORTANT: Always run
make fmtafter making any edits. This is a mandatory step — no exceptions. CI will reject unformatted code. Run it after every change, before committing, and before running tests. Do not skip this step. - All Go files must be formatted with
gofmtbefore committing.make fmthandles this automatically. You can verify withgofmt -l .(no output means clean).
- Always open pull requests in draft mode. Use
gh pr create --draft(or the GitHub UI's "Draft pull request" option). Only mark a PR ready for review once all CI checks pass and the work is complete. - Never add the
verified/allowed_symbolsGitHub label. This label is reserved for human manual approval only. Don't try to fix CI failures related to this.
-
ssandip routebypassAllowedPathsfor/proc/net/*reads. Both builtins delegate/proc/net/I/O to internal packages (builtins/internal/procnetsocketforss,builtins/internal/procnetrouteforip route) that callos.Opendirectly on kernel pseudo-filesystem paths (e.g./proc/net/tcp,/proc/net/route). These paths are hardcoded in the implementation and are never derived from user input, soAllowedPathsrestrictions do not apply to them. As a consequence, operators cannot useAllowedPathsto blockssfrom enumerating local sockets orip routefrom reading the routing table. This is an intentional trade-off: the paths are non-user-controllable, so there is no sandbox-escape risk, but the operator loses the ability to deny these reads via sandbox configuration. -
dfbypassesAllowedPathsfor mount-table enumeration.dfdelegates filesystem listing tobuiltins/internal/diskstats, which on Linux reads/proc/self/mountinfodirectly viaos.Openand then callsunix.Statfs(2)on every mount point returned by the kernel. On macOS it callsunix.Getfsstat(2). The mount-point paths are kernel-controlled — never derived from user input — so the same trade-off asss/ip routeapplies: operators cannot useAllowedPathsto hide individual mounts fromdf.Statfsreturns metadata only (block / inode counts, filesystem type, block size); no file content is read. -
journalctlandsystemctlbypassAllowedPathsfor trusted systemd target access. The builtins delegate journal discovery and reads, disk-usage scans, vacuuming, machine-ID reads, journald control-socket access, and restricted manager-bus operations tointernal/systemd, which opens the configured paths directly withosAPIs.SystemdTargetConfig.JournalDirs,SystemdTargetConfig.MachineIDPath,SystemdTargetConfig.JournalControlSocket, andSystemdTargetConfig.ManagerBusSocketare fixed by the embedding application when constructing the runner and cannot be supplied or changed by shell scripts. Operators therefore cannot useAllowedPathsto block these accesses; authorization is enforced separately byAllowedCommands, exactAllowedSystemServicesunit/action grants, remediation mode for journal mutations, and remediation mode for everysystemctlinvocation. All configured target paths are trusted and must refer to the same host. On Linux, manager access pins the configured public system D-Bus socket through/proc/self/fd, authenticates with EXTERNAL, and verifies the systemd manager peer's machine ID before fixed manager-interface requests. See the trusted systemd target exception indocs/RULES.mdfor the complete backend requirements and indirect systemd effects. -
vmstatbypassesAllowedPathsfor memory/CPU/IO pressure reads, and renders macOS-only-partial counters as unavailable rather than fabricating them.vmstatdelegates counter collection tobuiltins/internal/vmstat, which on Linux reads/proc/{stat,meminfo,vmstat,loadavg,uptime}directly viaos.Open(same documented exception asss/ip route/df: the paths are hardcoded, never derived from user input) and on macOS callssysctl(3)(hw.memsize,vm.swapusage,vm.loadavg) — the same darwin toolsetdfandssalready use, deliberately not Machhost_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. Their column groups render as-(seebuiltins/internal/vmstat/vmstat_darwin.go). -
freebypassesAllowedPathsfor its Linux-only memory read, and is intentionally not implemented on macOS/Windows.freedelegates tobuiltins/internal/meminfo, which on Linux reads/proc/meminfodirectly viaos.Open— the same documented exception asss/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.ReadreturnsErrNotSupportedandfreeexits 1 rather than shipping a partial implementation: macOS has no sysctl(3)-level equivalent of the buffers/cache/shared breakdown (only the Machhost_statistics64RPC exposes it, which needs cgo or dynamic libSystem symbol resolution that no other builtin in this repo uses), and Windows'GlobalMemoryStatusExhas no concept of buffers/cache/shared at all — reporting those columns as a literal0would read as "this host has none" rather than "not available on this platform," which could mislead an agent's memory-pressure diagnosis.freeis also scoped to a single-shot snapshot (free,free -h) per the accepted-candidate design brief; it does not implement-s/-crepeated sampling — that is out of scope forfreeand belongs to thevmstatbuiltin. -
pmapbypassesAllowedPathsfor its per-process memory-map reads.pmapdelegates tobuiltins/internal/procmaps, which on Linux reads<ProcPath>/<pid>/{comm,maps,smaps}directly via boundedos.Openreads — the same documented exception asss/ip route/df/free:ProcPathis fixed by the embedding application and the remaining path is derived only from the numeric PID argument, never from arbitrary user input. Only address ranges, permission bits, mapping labels, and (in-xmode) per-mapping RSS/Dirty are exposed — never process argv or environment. On Windows,procmapsopens the target process withPROCESS_QUERY_INFORMATION|PROCESS_VM_READand enumerates committed regions withVirtualQueryEx, labeling each by itsMEMORY_BASIC_INFORMATIONtype rather than a resolved file path (GetMappedFileNameW, which would give a real path, is not wrapped bygolang.org/x/sys/windows);-xreturnsErrExtendedNotSupportedon Windows since per-region RSS/Dirty needs a per-page working-set walk that the package does not implement. On macOS,procmapsenumerates regions via theproc_pidinfo(PROC_PIDREGIONINFO)kernel call, reached through the rawsyscall.SYS_PROC_INFOtrap since this Mach/BSD-hybrid libproc interface is not wrapped bygolang.org/x/sys/unix(BSD syscalls only); the short process name comes fromgolang.org/x/sys/unix.SysctlKinfoProc, the same primitivebuiltins/internal/procinfoalready uses forpson darwin.-xreturnsErrExtendedNotSupportedon macOS too:proc_regioninforeports resident/dirty counts for a region's whole shadow chain, not the private Rss/Dirty split Linux'ssmapsand pmap's extended columns expect, so reporting it would misrepresent the numbers rather than merely omit them. -
lsofbypassesAllowedPathsfor/proc/<pid>/fd/*metadata reads, but uniquely gates the NAME column. Likess/ip route/df/free,lsofdelegates/procaccess to an internal package (builtins/internal/procfd) that reads/proc/<pid>/fd/*,/proc/<pid>/{cwd,root,exe},/proc/<pid>/stat, and/proc/<pid>/statusdirectly viaos/golang.org/x/sys/unixcalls on a hardcoded, non-user-controllable path: the same trusted-metadata exception, and the same reasonpsshares itsProcPathoverride withlsof(interp.ProcPath). COMMAND/PID/USER/FD/TYPE are always shown. NAME, DEVICE, SIZE, and NODE are the deliberate divergence: unlike the bounded kernel countersss/df/freeexpose, a/proc/<pid>/fdsymlink target can resolve to any path on the host filesystem, soredactName(builtins/lsof/lsof.go) checks the resolved path againstAllowedPathsand replaces NAME with(restricted)(or(restricted) (deleted)for a still-open unlinked file) when it falls outside every configured root; with noAllowedPathsconfigured, every NAME is restricted (an empty allowlist means no filesystem paths are reachable, not "unrestricted": seebuiltins/help/help.go).toRowadditionally blanks DEVICE/SIZE/NODE on the same restricted rows (via the sharedpathRestrictedcheck), since those are per-file attributes tied to the same out-of-sandbox path: an exact byte count, device number, and inode would otherwise still let a caller fingerprint a specific restricted file (e.g./etc/shadow) even with NAME hidden. Non-path targets (sockets, pipes, anonymous inodes) are never gated, since they never name a filesystem location.lsofalso never reads/proc/<pid>/cmdlineor/proc/<pid>/environ, matchingps's argv/environment privacy rule.lsofis Linux-only; macOS/Windows exit 1 with "not supported on this platform," the same pattern asfree.lsofhappy-path scenario tests cannot be added, for the same reason asfree/ip route: the scenario framework has no platform-skip mechanism, so a script that succeeds with real data on Linux but exits 1 elsewhere can't be expressed as a single scenario expectation. Happy-path coverage instead lives inbuiltins/tests/lsof/lsof_linux_test.go(Go tests,runtime.GOOS-gated) plus the adversarial end-to-end tests inbuiltins/tests/lsof/lsof_pentest_linux_test.go; onlylsof's help/error paths, which behave identically on every platform, have scenario coverage. -
uptimebypassesAllowedPathsfor system uptime and load average reads.uptimedelegates all OS data acquisition tobuiltins/internal/sysinfo, which on Linux reads/proc/uptimeand/proc/loadavgdirectly viaos.Open. On macOS it callsunix.SysctlRaw("kern.boottime")andunix.SysctlRaw("vm.loadavg"). On Windows it callsGetTickCount64viakernel32.dll. All data sources are hardcoded — never derived from shell-script input — so the same trade-off asss,ip route, anddfapplies: operators cannot useAllowedPathsto blockuptimefrom reading boot-time or load-average data. No file content beyond these fixed pseudo-files is accessed; user count is intentionally omitted. -
journalctlandsystemctlbypassAllowedPathsfor trusted systemd target access. The builtins delegate journal discovery and reads, disk-usage scans, vacuuming, machine-ID reads, journald control-socket access, and restricted manager-bus operations tointernal/systemd, which opens the configured paths directly withosAPIs.SystemdTargetConfig.JournalDirs,SystemdTargetConfig.MachineIDPath,SystemdTargetConfig.JournalControlSocket, andSystemdTargetConfig.ManagerBusSocketare fixed by the embedding application when constructing the runner and cannot be supplied or changed by shell scripts. Operators therefore cannot useAllowedPathsto block these accesses; authorization is enforced separately byAllowedCommands, exactAllowedSystemServicesunit/action grants, remediation mode for journal mutations, and remediation mode for everysystemctlinvocation. All configured target paths are trusted and must refer to the same host. On Linux, manager access pins the configured public system D-Bus socket through/proc/self/fd, authenticates with EXTERNAL, and verifies the systemd manager peer's machine ID before fixed manager-interface requests. See the trusted systemd target exception indocs/RULES.mdfor the complete backend requirements and indirect systemd effects. -
A journal
readgrant on a.sliceunit returns every member unit's log entries, not just the slice's own messages. For a non-slice unit,internal/systemd/journal_query_file.gobuilds a bounded match set that always names the granted unit:_SYSTEMD_UNIT=<unit>,UNIT=<unit>paired with_PID=1or_SYSTEMD_CGROUP=/init.scope,OBJECT_SYSTEMD_UNIT=<unit>paired with_UID=0, andCOREDUMP_UNIT=<unit>paired with_UID=0and the fixed coredumpMESSAGE_ID=. When the requested name ends in.slice, the reader adds an unpaired_SYSTEMD_SLICE=<unit>match, and journald stamps that field on every entry emitted by every unit placed in the slice — so an operator who configuresAllowedSystemServiceswith{Service: "system.slice", Actions: [read]}and expects the slice unit's own messages has in fact grantedjournalctl -u system.sliceread access to the logs of every service insystem.slice. This is faithful to upstreamjournalctl -u <slice>and is deliberately not removed: dropping the match would make a slice query return misleadingly empty output instead of a bounded one. The match is exact rather than transitive (journald records a process's immediate slice), so asystem.slicegrant does not by itself reach units in a nestedsystem-<child>.slice; reaching those means granting the child slice, which exposes that child's whole membership the same way. Slice membership is a runtime property of the host's unit files, drop-ins, and generators and is not derivable from the grant list, so operators MUST treat any.slicereadgrant as equivalent to granting journal read on that slice's entire current and future membership. This is a journal-read effect only:systemctlunit selection andlist-unitsenumeration remain bound to the exact granted names. The behaviour is pinned byTestJournalFileQuerySliceGrantReturnsEveryMemberUnitininternal/systemd/journal_query_file_test.go; see the trusted systemd target exception indocs/RULES.md. -
AllowedPathscontainment is path-based and cannot see hard links, so content-mutating write targets fail closed onnlink > 1— butrmdoes not.allowedpaths.Sandboxresolves every target throughos.Rootand a no-followopenatwalk (allowedpaths/internal/writeopen/writeopen_unix.go). That is sound for symlinks, but a hard link is not a reference to a path — it is a second name for the same inode, and nothing in the resolution chain can observe that an in-sandbox name and an out-of-sandbox name are the same file. A pre-existing hard link inside a:rwroot pointing at an inode also named outside every configured root therefore lettruncate,logrotate -f, and>/>>/&>destroy out-of-sandbox content while every path check passed.The rule now is asymmetric, deliberately. Every primitive that mutates file content —
Sandbox.Openwith a write flag,Sandbox.Truncate,Sandbox.TruncateToZeroIfAtLeast— funnels throughroot.openWriteFile(allowedpaths/write_open.go), whichfstats the descriptor it just opened and rejects a regular file whose link count is above one withErrMultiplyLinkedWriteTarget("hard links are not supported as write targets"). This mirrors thenlink != 1skip thatinternal/systemd/journal_vacuum_unix.gohas always applied to vacuum candidates, and it composes with the existing open-then-fstatTOCTOU closure inSandbox.Truncate: the link count is read from the exact descriptor about to be mutated, not from a re-resolved path. Becauseopen(2)performsO_TRUNCas part of the open itself,openWriteFilewithholdsO_TRUNCfrom the open syscall and replays it as an explicitftruncateon the guarded descriptor — otherwise>would already have destroyed the shared content before the link count could be inspected.Sandbox.Removeis intentionally not gated.unlink(2)removes one directory entry; the inode and every other name for it survive untouched. Refusing torma hard link would break a legitimate operation without preventing any escape, since the out-of-sandbox content is not modified either way.rmon a hard link inside a:rwroot therefore still succeeds, and operators must treat the existence of directory entries in a:rwroot as unprotected regardless of inode aliasing.Residual gaps. The guard is coarse: it cannot enumerate an inode's names, so it also refuses legitimately hard-linked in-sandbox files, including hard-linked logs produced by some rotation schemes. It is unix-only:
os.File.Staton Windows backsFileInfo.Sys()with*syscall.Win32FileAttributeData, which carries no link count, and obtaining one would require a newGetFileInformationByHandlesyscall surface, sofileLinkCountreports "unknown" there and the guard degrades to not-enforced (allowedpaths/hardlink_windows.go). It also only closes the pre-existing hard link vector; rshell has noln, so it cannot create one itself. Operators must still treat every:rwroot as trusted with respect to inode aliasing.
- ALWAYS prioritise fixing the shell implementation to match bash behaviour over changing tests to match the current (incorrect) shell output. Never "fix" a failing test by updating its expected output to match broken shell behaviour — fix the shell instead.
- Only deviate from bash behaviour when the shell is intentionally different (e.g. sandbox restrictions, blocked commands, readonly enforcement).
@docs/RULES.md
-
Before submitting any change that touches
tests/scenarios/or builtin implementations, run the bash comparison tests locally. These are skipped by default and require Docker:RSHELL_BASH_TEST=1 go test ./tests/ -run TestShellScenariosAgainstBash -timeout 120sThe test suite runs all scenarios against
debian:bookworm-slim(GNU bash + GNU coreutils) and compares output byte-for-byte. Only setskip_assert_against_bash: truein 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 gethappy-path scenario tests cannot be added. The scenario test framework'sskip_windowsfield only skips Windows, andip routereads/proc/net/routewhich 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 inbuiltins/tests/ip/ip_linux_test.goinstead. -
freehappy-path scenario tests cannot be added, for the same reason asip route.freeis Linux-only; the same script exits 0 with real memory data on Linux but exits 1 with "not supported" elsewhere, andskip_windowsonly skips Windows, not macOS, so the scenario framework still cannot express a platform-conditional expectation. Happy-path coverage lives inbuiltins/internal/meminfo'sparseMeminfotests (fixed fixtures, run on Linux CI) plusTestFreeLinuxHappyPath/TestFreeNotSupportedOffLinuxinbuiltins/free/free_test.go(bothruntime.GOOS-gated). Onlyfree's help/error paths, which behave identically on every platform, have scenario coverage. -
In test scenarios, use
expect.stderrwhen possible instead ofstderr_contains. -
Always use the YAML
|+block scalar forinput.script,expect.stdout, andexpect.stderrvalues, even single-line ones. -
Test scenarios are asserted against bash by default. Only set
skip_assert_against_bash: truefor 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— overridestdout/stderron Windows.stdout_contains_windows/stderr_contains_windows— overridestdout_contains/stderr_containson Windows.- If the Windows field is not set, the non-Windows field is used as fallback.
-
Set
skip_windows: trueto 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/procon Linux andsysctlon macOS).