Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
39c73af
test(sandbox): reject .. escapes for nonexistent shell destinations
adriannoes Sep 14, 2026
cd27ada
fix(sandbox): walk existing ancestor before workspace prefix check
adriannoes Sep 14, 2026
6597943
test(sandbox): reject quoted and embedded absolute shell paths
adriannoes Sep 14, 2026
68ed422
fix(sandbox): scan quoted and embedded absolute paths in allowlist
adriannoes Sep 14, 2026
6fd886a
test(sandbox): reject $HOME and $PWD expansions in workspace allowlist
adriannoes Sep 14, 2026
152e862
fix(sandbox): expand $HOME and $PWD before workspace allowlist checks
adriannoes Sep 14, 2026
9c346db
test(sandbox): reject glued $IFS and mid-token $HOME expansions
adriannoes Sep 14, 2026
b80912d
fix(sandbox): scan $ expansions on the full shell command
adriannoes Sep 14, 2026
ccbda3c
docs(sandbox): record workspace_only glued-$ gate and residuals
adriannoes Sep 14, 2026
f7abfc3
test(sandbox): reject missing-dir .., file: URLs, and embedded ../
adriannoes Sep 14, 2026
496a7dd
fix(sandbox): collapse .. lexically and scan file: plus embedded ../
adriannoes Sep 14, 2026
2ee46b6
fix(sandbox): zero-init lexical collapse segment pointers
adriannoes Sep 14, 2026
6631ec7
test(sandbox): reject encoded slash, HOME/PWD assign, symlink .., file %
adriannoes Sep 14, 2026
8ea72e9
fix(sandbox): block encoded slash, HOME/PWD assign, symlink .., file %
adriannoes Sep 14, 2026
231b3f0
fix(sandbox): narrow encoded-slash octal locals for cppcheck
adriannoes Sep 14, 2026
283206f
test(sandbox): reject quoted HOME/PWD assign, split file:, encoded dot
adriannoes Sep 14, 2026
8b3d5c1
fix(sandbox): catch quoted HOME/PWD, split file:, encoded dot/N
adriannoes Sep 14, 2026
efda63f
fix(sandbox): narrow encoded-dot hex local for cppcheck
adriannoes Sep 14, 2026
1206218
test(sandbox): reject URL ../, Perl \\x{}, env -i/pop, comma HOME=
adriannoes Sep 14, 2026
2b10dfe
fix(sandbox): catch URL ../, Perl \x{}, env -i/pop, comma HOME=
adriannoes Sep 14, 2026
88cf550
test(sandbox): reject read/unset env, Perl \o{}, encoded file:
adriannoes Sep 14, 2026
4eb3ae5
fix(sandbox): catch read/--unset/-iu, Perl \o{}, encoded file:
adriannoes Sep 14, 2026
3ecd673
test(sandbox): reject octal/identity file:, \\+nl, printf -v
adriannoes Sep 14, 2026
ffafa0b
fix(sandbox): catch octal/identity file:, \\+nl, printf -v
adriannoes Sep 14, 2026
5d53b60
test(sandbox): reject identity HOME/PWD, nameref, encoded $
adriannoes Sep 14, 2026
83780a5
fix(sandbox): fold identity before HOME/PWD; decode encoded $
adriannoes Sep 14, 2026
7c34828
test(sandbox): reject relative leak tokens, Landlock host FS, netns
adriannoes Sep 14, 2026
87123a5
fix(sandbox): Landlock workspace bound; fail closed without ns
adriannoes Sep 14, 2026
c8826a3
ci: allow unprivileged userns on Ubuntu 24.04 runners
adriannoes Sep 14, 2026
04f7492
test(sandbox): skip or expect deny when isolation cannot apply
adriannoes Sep 14, 2026
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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ jobs:
- name: Static analysis (cppcheck)
run: make static

# Ubuntu 24.04 AppArmor blocks unshare(CLONE_NEWUSER) in unsigned
# binaries. sandbox_exec fail-closes without namespaces; tests need the
# user ns so Landlock/netns assertions can run. Not a product bypass.
- name: Allow unprivileged user namespaces
run: |
if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_unconfined_userns ]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_unconfined_userns=0
fi

- name: Build and test (libgpiod present)
run: make clean && make test
env:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha
## [Unreleased]

### Fixed
- Shell `workspace_only` walks to the first existing ancestor instead of a lexical prefix, so a missing `workspace/../../tmp/stolen` destination cannot escape the sandbox.
- Shell `workspace_only` scans quoted and embedded absolute paths (`cat '/etc/passwd'`, `python3 -c "open('/etc/passwd')"`) and fail-closes on `strdup` OOM. Bare relative names that exist under the workspace are resolved so `cat leak` cannot follow a symlink out; URL slashes stay allowed; `file:///...` is still blocked.
- Shell `workspace_only` expands `$HOME` / `${HOME}` / `$PWD` / `${PWD}` (including one quote layer) before the workspace check and fail-closes other `$...` forms such as ANSI-C `$'\x2f...'`. Glued expansions (`cat$IFS/etc/passwd`, `cat${IFS}/...`, `cat"$HOME/..."`, `python3 -c "open('$HOME/...')"`) are scanned on the full command, not only strtok tokens that start with `$`. `file:` URL variants (`file:/`, `file://localhost/`, `file://etc/passwd`) are extracted (including quote-split `f'ile:` / `'f'+'ile:`, hex/unicode-hidden `\x66ile:` / `\u0066ile:`, octal `\146ile:` / `\072`, and identity `f\ile:`) and percent-decoded (`%2e%2e`, `%2f`) before the workspace check. POSIX `\` + newline (optional CR) is collapsed before those scans. Embedded relative `../` is checked against the workspace, including `../` after `://` (`https://example.com/../../../../etc/passwd`); real `https://` fetches without a `..` walk stay allowed. `..` is collapsed lexically so a missing directory before `..` cannot pin the ancestor walk at the workspace, and is not cancelled across a symlink. Encoded `/` and `.` (`\x2f`, `\x2e`, `\x{2f}`, `\57`, `\56`, `\u002f`, `\u{2f}`, `\o{57}`) reconstruct a path body; `\N{` fail-closes. In-command `HOME=` / `PWD=` assignment, `export`, and `unset` fail closed even inside `eval` / `sh -c` quotes or after a comma or `[`. `env -i`, clustered `env -iu`, `env -u` / `--unset` HOME|PWD, POSIX `read HOME|PWD`, `printf -v HOME|PWD`, `declare -n` targeting HOME|PWD, `exec -c`, and `os.environ.pop`/`del`/`clear`/`update` / `os.unsetenv` / `os.putenv` / `os.environ["HOME"]=` of those names fail closed instead of trusting process getenv. Identity-escape fold (same decode as `file:` recovery) runs before the HOME/PWD keyword gate so `export PW\D=` / `\unset HOME` / `\env -i` cannot skip getenv. Encoded `$` (`\x24` / `\044` / `\u0024`) is decoded before the `$` expansion scan.
- Shell `sandbox_exec` applies a Landlock ruleset to the configured workspace (fail-closed) as the kernel host-FS bound, so symlink and `chr(47)+` host reads cannot skip the string scanner. Mount/network/PID namespaces fail closed (user namespace first when unprivileged) instead of running on the host netns. The `workspace_only` scanner stays defense-in-depth.
- Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads).
- WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped.
- WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`.
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Shell commands run in a **Linux sandbox** (namespaces + cgroups v2). Hardware to
| `channels/` | Inbound/outbound I/O | CLI, Telegram, Discord, WebChat, heartbeat |
| `gateway/` | Embedded HTTP/WebSocket server, pairing auth, rate limits, static Web UI | `http_lws`, `routes.c`, `routes_hardware.c` |
| `asap/` | Protocol client/server, envelope, ULID, registry cache, signed manifest | `manifest_build_signed_json()`, `POST /asap` |
| `sandbox/` | Process isolation for shell tool | `sandbox_run()` — `unshare(CLONE_NEWNS\|NEWNET\|NEWPID)`, no `pivot_root` |
| `sandbox/` | Process isolation for shell tool | `sandbox_exec()` — user ns + `unshare(CLONE_NEWNS\|NEWNET\|NEWPID)`, Landlock workspace bound, no `pivot_root` |
| `hardware/` | Board abstraction: GPIO (libgpiod), I2C (`/dev/i2c-N`), camera (fixed-argv CLI spawn) | `hardware_init()`, `board_detect()` |
| `crypto/` | Ed25519 signing + JCS canonicalization for manifests | `manifest_keys_ensure_loaded()` (lazy on manifest GET), `jcs.c` |

Expand Down
11 changes: 6 additions & 5 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ The primary goals are: prevent sandboxed shell commands from escaping to host de

| Surface | Mechanism | Notes |
|---------|-----------|-------|
| Shell (sandbox on) | `fork()` + `unshare(CLONE_NEWNS \| CLONE_NEWNET \| CLONE_NEWPID)` + `prctl(PR_SET_NO_NEW_PRIVS)` | See [Linux sandbox (Jetson)](#linux-sandbox-jetson) |
| Shell (sandbox on) | `fork()` + user ns when needed + `unshare(CLONE_NEWNS \| CLONE_NEWNET \| CLONE_NEWPID)` + Landlock workspace bound + `prctl(PR_SET_NO_NEW_PRIVS)` | Fail-closed if namespaces or Landlock cannot apply. See [Linux sandbox (Jetson)](#linux-sandbox-jetson) |
| Shell (sandbox off) | Plain `fork()` + substring fallback blocklist | **Not** a security boundary; stderr warning |
| Allowlist | Substring blocklist + optional workspace `realpath` containment | Defense in depth before `sandbox_exec` |
| Allowlist | Substring blocklist + workspace containment (existing ancestor, quoted/embedded `/` `~`, bare relative names, full-command `$HOME`/`$PWD` / fail-closed `$`) | Defense-in-depth string scan; Landlock is the kernel host-FS bound |
| Landlock | Ruleset on configured `workspace_path` (RW workspace + RO `/bin` `/usr` `/lib` …) | Primary host-FS gate; blocks symlink and `chr(47)+` host reads |
| cgroups v2 | `memory.max`, `cpu.max` on child PID | Best-effort; non-fatal if cgroup write fails |
| Hardware GPIO/I2C | libgpiod / `i2c-dev` in agent process | Not exposed inside shell namespace |

Expand Down Expand Up @@ -69,11 +70,11 @@ Therefore ShellClaw never bind-mounts Tegra GPU devices into the sandbox. In par

### `pivot_root` — not used (v1.0)

The task checklist references `unshare(CLONE_NEWNS) + pivot_root` as a hardened pattern. **v1.0 does not implement `pivot_root`.** After `unshare(CLONE_NEWNS)`, the child inherits a **copy** of the host mount tree (default propagation). Jetson `/dev` nodes remain visible inside the new mount namespace unless blocked elsewhere.
The task checklist references `unshare(CLONE_NEWNS) + pivot_root` as a hardened pattern. **v1.0 does not implement `pivot_root`.** After `unshare(CLONE_NEWNS)`, the child inherits a **copy** of the host mount tree (default propagation). Jetson `/dev` nodes remain in that mount namespace; Landlock (when a workspace path is set) is the kernel FS bound and does not grant `/dev/nvhost`, `/dev/nvgpu`, or `/dev/nvmap`.

**Mitigation in v1.0:** the shell allowlist rejects commands whose text references `/dev/nvhost`, `/dev/nvgpu`, or `/dev/nvmap` (substring blocklist). Regression tests live in `tests/test_allowlist.c` (`test_block_jetson_gpu_devices`).

**Residual risk:** a crafted command that opens GPU nodes without those literal substrings (e.g. shell globs or indirect paths) may still reach devices until a future release adds mount-slave propagation, a minimal `/dev` tmpfs, or seccomp. Track as post-v1.0 hardening.
**Residual risk:** a crafted command that opens GPU nodes without those literal substrings (e.g. shell globs) is still denied by Landlock when a workspace path is set (`/dev/nv*` is not in the RO grant list). Without Landlock (non-Linux, or sandbox off) the substring blocklist remains best-effort. `workspace_only` walks a missing destination’s existing ancestor, collapses `..` lexically (including a missing directory before `..`, without cancelling `..` across a symlink), scans quoted/embedded `/` `~`, bare relative names (`cat leak`), and relative `../` (including `../` after `://`), extracts and percent-decodes `file:` URLs (including quote-split schemes, `\x66`/`\u0066`/`\146` hidden schemes, identity `f\ile:`, and POSIX `\`+newline continuation), reconstructs encoded `/` and `.` (`\x2f` / `\x{2f}` / `\x2e` / `\u{2f}` / `\o{57}`), fail-closes `\N{` and in-command `HOME`/`PWD` assignment (including quoted `eval` / `sh -c`, comma-separated argv, POSIX `read`, `printf -v`, `declare -n` targeting HOME|PWD, `exec -c`, `env -i` / `-iu` / `-u` / `--unset`, and `os.environ.pop`/`clear`/`update` / `os.unsetenv` / `os.putenv` / subscript assign; identity-escape fold before the keyword gate so `PW\D=` / `\unset` / `\env -i` cannot skip getenv), and expands or fail-closes `$` on the full command (including glued `$IFS`, mid-token `$HOME`, and encoded `\x24` / `\044` / `\u0024`). The string scanner is not a language interpreter: `open(chr(47)+'etc/passwd')` has no path character in the command text and stays residual **on the scanner**; Landlock is the kernel bound for that class. Conservative regex false positives such as `awk '/foo/'` remain.

### Board-agnostic blocklist entries (Jetson literals)

Expand Down Expand Up @@ -268,7 +269,7 @@ This section summarizes Jetson Orin Nano Super / JetPack 6.2.x concerns that do
| Surface | Jetson-specific behavior | Primary mitigation | Source |
|---------|-------------------------|-------------------|--------|
| Shell sandbox | Tegra GPU character devices remain in inherited mount namespace | Literal blocklist on `/dev/nvhost*`, `/dev/nvgpu`, `/dev/nvmap` | `src/sandbox/allowlist.c` |
| Shell sandbox | No `pivot_root` / minimal `/dev` in v1.0 | Documented residual risk; allowlist defense in depth | `src/sandbox/sandbox.c` |
| Shell sandbox | No `pivot_root` / minimal `/dev` in v1.0 | Landlock workspace bound (fail-closed); allowlist defense in depth | `src/sandbox/sandbox.c` |
| CSI camera | Argus daemon (`root`) + `/tmp/argus_socket` | Shell blocklist; camera only via agent `execvp` path | `src/hardware/hardware_camera.c`, `allowlist.c` |
| Gateway | GPU telemetry via `tegrastats` parsing | Bearer auth on `/api/hardware/gpu`; read-only GET | `src/gateway/routes_hardware.c` |
| GPIO / I2C | `tegra234-gpio` chips via libgpiod | Hardware tools run in agent process, not shell namespace | `src/hardware/` backends |
Expand Down
Loading
Loading