This document is the v1.0 security assurance record (Wave 7, slice 04). It consolidates self-audit findings from tasks 7.1–7.7, mitigations implemented in tree, and honest scope limits. Read Limitations before treating this as third-party assurance.
Related code: src/sandbox/ · src/gateway/ · src/hardware/ · src/asap/manifest_keys.c
ShellClaw runs as a user-level agent on edge boards (Jetson Orin Nano Super, Raspberry Pi). Untrusted input arrives via chat channels (Telegram, Discord, Web UI) and may invoke:
- Shell tool — subprocess with optional Linux namespaces + allowlist.
- File tool — workspace-scoped paths when configured.
- Hardware tools — GPIO, I2C, camera (gateway
/api/hardware/*is Bearer-authenticated in v1.0). - ASAP / gateway — Bearer-authenticated
/api/*HTTP and WebSocket.POST /asapis protocol-public (rate-limited, not Bearer); authenticity is[asap].trusted_senders.
The primary goals are: prevent sandboxed shell commands from escaping to host destruction, block direct GPU/camera daemon access from the shell sandbox, and keep signing keys and cloud credentials off disk with unsafe permissions.
| ID | Finding | Severity | Mitigation | Residual risk | Tests / verification |
|---|---|---|---|---|---|
| 7.1 | Jetson GPU /dev nodes visible in shell mount namespace (no pivot_root, no bind-mount isolation) |
Medium | Landlock workspace bound does not grant /dev/nv*; substring blocklist for /dev/nvhost, /dev/nvgpu, /dev/nvmap in allowlist.c |
Without Landlock (non-Linux / sandbox off), globs may bypass the literal blocklist | tests/test_allowlist.c (test_block_jetson_gpu_devices), tests/test_sandbox.c |
| 7.1 | pivot_root hardening not implemented |
Low (documented) | Allowlist + namespace network/PID isolation | Full mount-slave /dev tmpfs deferred post-v1.0 |
Source audit of sandbox.c |
| 7.2 | Camera capture could invoke shell with user-controlled pipeline strings | High (if present) | Not present: hardware_camera_capture() uses execvp + fixed argv[]; strict input validation |
N/A when validation holds | tests/test_hardware_camera.c (injection + test_no_shell_invocation) |
| 7.3 | Sandboxed shell could reach Argus IPC socket | Medium | Blocklist /tmp/argus_socket and argus_socket; camera spawn runs outside shell sandbox by design |
Compromised agent process can still spawn GStreamer | tests/test_allowlist.c (test_block_argus_socket) |
| 7.4 | New /api/hardware/* routes expose board/GPIO/I2C/GPU state |
Medium | Central Bearer gate in http_lws.c; read-only GET handlers; camera POST deferred stub (no rate limit until v1.2 HTTP capture) |
Stolen bearer token grants read access until revoked | tests/test_gateway_http.c, tests/test_routes_hardware |
| 7.5 | Loose permissions on Ed25519 private key | High | 0600 on create; reject load/rotate if (st_mode & 0077); fail on first signed manifest GET |
0400-only owner key passes group/other check |
tests/test_manifest_keys, tests/test_bootstrap_keys |
| 7.6 | Memory safety / undefined behavior regressions | High | make static (cppcheck) + make test-sanitize (ASan/UBSan) in CI |
Sanitizer builds ≠ release binaries | CI .github/workflows/ci.yml, scripts/ci-local.sh |
Release gate: zero Critical findings open; Medium items above have documented mitigations and regression tests.
| Surface | Mechanism | Notes |
|---|---|---|
| Shell (sandbox on) | fork() + user ns when needed + unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID) + fork for PID 1 + Landlock workspace bound + prctl(PR_SET_NO_NEW_PRIVS) + secret-free environment |
Fail-closed if namespaces or Landlock cannot apply. The command child keeps PATH, locale, TERM, TZ, and HOME set to the workspace. Host API keys and bot tokens are not inherited. Isolation uses a control pipe, not sh exit 122/123. See Linux sandbox (Jetson) |
| Shell (sandbox off) | Plain fork() + substring fallback blocklist |
Not a security boundary; the full host environment is inherited — printenv works there (see Command environment model); stderr warning |
| Allowlist | Substring blocklist + workspace containment (ancestor walk, quoted/embedded / ~, $HOME/$PWD, file: URLs, relative names) |
Defense-in-depth string scan; Landlock is the kernel host-FS bound |
| Landlock | Ruleset on configured workspace_path (RW workspace + traverse-only / + RO /bin /usr /lib and /etc/ssl/certs only; RW /dev/null, /dev/zero, /dev/urandom, /dev/tty without IOCTL_DEV). Child fchdirs the workspace before restrict_self. A failed landlock_add_rule fails the ruleset closed. |
Primary host-FS gate; / is READ_DIR only so /etc/passwd stays closed. /etc/ssl/private is not granted. Blocks symlink and chr(47)+ host reads |
| cgroups v2 | memory.max, cpu.max; isolator writes cgroup.procs before the command fork |
Best-effort; non-fatal if cgroup write fails |
| Hardware GPIO/I2C | libgpiod / i2c-dev in agent process |
Not exposed inside shell namespace |
Implementation: src/sandbox/sandbox.c, src/sandbox/allowlist.c.
The two shell paths differ in environment exposure as much as in isolation, so the model is stated explicitly:
| Path | Environment the command sees | printenv inside the command |
|---|---|---|
Sandboxed (sandbox.enabled = true) |
Scrubbed whitelist only: PATH, LANG, LC_ALL, LC_CTYPE, TERM, and TZ keep their host values when set, and HOME is set to the configured workspace (when one is set). Everything else is dropped (clearenv before exec). |
Only the whitelist: provider keys, bot tokens, and loader variables are gone. |
Unsandboxed fallback (sandbox.enabled = false, the default when the key is omitted) |
The full agent (host) environment is inherited. | Works — it prints the agent's environment, secrets included. Intentional: this path is reduced isolation, not a security boundary; a stderr warning is printed per command. |
Jetson-relevant variables that deliberately do not pass into the sandbox: LD_LIBRARY_PATH and all other LD_* (including LD_PRELOAD), CUDA_HOME and other CUDA_*, TMPDIR, SSL_CERT_FILE, and proxy variables (http_proxy, https_proxy, no_proxy, …). Sandboxed workloads that rely on them must set them per command (for example LD_LIBRARY_PATH=… cmd); there is no [sandbox] passthrough list in v1.0. Tool paths that genuinely need host setup (camera capture) run argv-only execvp in the agent process, outside the shell sandbox.
Implementation: src/sandbox/sandbox_env.c.
Audit task 7.1 (2026-05-25). Reviewed src/sandbox/sandbox.c and src/sandbox/allowlist.c against JetPack 6.2.x (kernel 5.15) on Jetson Orin Nano Super.
sandbox_exec() does not call bind() or pivot_root(). After unshare(CLONE_NEWNS) the child makes the copied mount tree MS_REC|MS_PRIVATE and, once the command is PID 1, remounts proc on /proc. It does not bind-mount Tegra GPU devices.
unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID);In particular, these paths are not explicitly mounted into the shell namespace:
/dev/nvhost-*/dev/nvgpu/dev/nvmap
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 (made MS_PRIVATE). 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: Landlock plus the shell allowlist, which 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) and tests/test_sandbox.c.
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 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 (without cancelling .. across a symlink), scans quoted/embedded / ~ and relative ../, extracts and percent-decodes file: URLs, fail-closes in-command HOME/PWD assignment, and expands or fail-closes $ on the full command (including glued $IFS and mid-token $HOME). 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. Encoded-slash / identity-escape cat-and-mouse is frozen. Conservative regex false positives such as awk '/foo/' remain.
src/sandbox/allowlist.c lists Jetson GPU paths (/dev/nvhost, /dev/nvgpu, /dev/nvmap) and Argus socket strings for all boards. These are substring deny rules on the shell command line, not board-specific mounts or runtime board_id checks. On Raspberry Pi or stub builds the entries are inert (no matching devices) but keep one shared allowlist binary. They do not hide /dev nodes from the inherited mount namespace — see GPU device nodes above.
CLONE_NEWNET— sandboxed shell has no routable network (no interface setup in child).CLONE_NEWPID— PID namespace; the isolator forks so the command process is PID 1 (unshare does not move the caller).
Namespace behavior above was verified by source audit. On-device validation on JetPack 6.2.x is a known pending item (not a CI or merge-to-main gate).
hardware_camera_capture() in src/hardware/hardware_camera.c never invokes /bin/sh -c. The backend builds a fixed argv[] and runs fork() + execvp(argv[0], argv) (or a test hook).
| Input | Validation | Shell / pipeline injection |
|---|---|---|
sensor_id |
int, range 0–3 → snprintf(..., "sensor-id=%d", ...) |
Metacharacters cannot appear in argv |
resolution |
Strict WxH via sscanf + trailing-byte check |
Suffixes like 640x480;rm rejected |
camera_type |
Whitelist: auto, csi, usb only |
Values like csi|sh rejected before spawn |
output_path |
path_chars_safe() + reject .. |
Shell metacharacters and traversal blocked |
video_index |
0–99 → /dev/video%d in argv |
Numeric only |
GStreamer pipeline elements (nvarguscamerasrc, caps strings, location=) are separate argv entries, not a single interpolated shell string.
Regression tests: tests/test_hardware_camera.c (test_*_injection_rejected, test_no_shell_invocation).
On Jetson CSI cameras, frame capture uses GStreamer nvarguscamerasrc, which is a client of NVIDIA Argus (nvargus-daemon).
| Component | Role |
|---|---|
nvargus-daemon |
System service, typically root; owns the Argus IPC endpoint |
/tmp/argus_socket |
Unix domain socket used by Argus clients (JetPack 6.x default path) |
gst-launch-1.0 + nvarguscamerasrc |
Child process spawned by ShellClaw outside the shell sandbox |
ShellClaw does not bind-mount /tmp/argus_socket into the sandboxed shell namespace (sandbox_exec uses unshare without pivot_root, per § Linux sandbox (Jetson)). Landlock does not grant that socket path.
flowchart LR
LLM[LLM / channels] --> Agent[ShellClaw agent process]
Agent -->|camera_capture tool| Cam[hardware_camera_capture]
Cam -->|fork execvp| GST[gst-launch-1.0]
GST -->|nvarguscamerasrc plugin| Socket["/tmp/argus_socket"]
Socket --> Daemon[nvargus-daemon root]
Agent -->|shell tool sandbox on| SB[sh -c in namespaces]
SB -.->|blocked allowlist| Socket
- Allowed (by design): the main agent process invokes
hardware_camera_capture()→execvp("gst-launch-1.0", …)withnvarguscamerasrcin argv. The GStreamer child connects to Argus as any normal camera client would. - Blocked: the shell tool path runs inside
sandbox_exec()+ allowlist. Commands that reference/tmp/argus_socketorargus_socketare rejected (src/sandbox/allowlist.c). Regression:tests/test_allowlist.c(test_block_argus_socket).
- Gateway:
POST /api/hardware/camera/snapshotreturns a v1.2 placeholder (no HTTP-driven capture in v1.0). - LLM tool:
camera_captureremains registered when the camera backend is active; it uses the same Argus path above and is subject to channel allowlists / operator trust, not the shell sandbox.
Residual risk: a compromised agent process (not merely a sandboxed shell command) could still spawn gst-launch or other Argus clients. Mitigation is process-level trust and minimizing agent attack surface; socket blocking applies to the shell sandbox boundary only.
Audit (2026-05-25): src/gateway/http_lws.c requires_auth() treats every /api/ path as Bearer-protected (except /health, /pair, /.well-known/, /). Unauthenticated requests receive 401 before dispatch_route() runs.
| Method | Path | Auth | Mutating | Notes |
|---|---|---|---|---|
| GET | /api/hardware/board |
Bearer | No | Board id + backends |
| GET | /api/hardware/gpio |
Bearer | No | 40-pin snapshot |
| GET | /api/hardware/i2c-scan |
Bearer | No | Bus scan (read-only) |
| GET | /api/hardware/gpu |
Bearer | No | Jetson tegrastats JSON |
| GET | /api/hardware/sensors |
Bearer | No | v1.2 deferred stub |
| POST | /api/hardware/camera/snapshot |
Bearer | Yes | v1.2 deferred stub; no per-token rate limit until Phase 7 HTTP capture |
Handlers: src/gateway/routes_hardware.c. Non-GET methods on read-only paths return 405. Example: POST /api/hardware/board → 405 (tested in test_routes_hardware).
POST /api/hardware/camera/snapshot returns the v1.2 deferred JSON stub when authenticated. Per-token throttling was removed from src/gateway/rate_limit.c until the real HTTP image path ships in Phase 7; Bearer auth still applies via requires_auth().
Slice 02 introduced these routes; this audit confirms Bearer gating remains centralized in http_lws.c. Rate limiting for camera POST is deferred with the capture implementation.
POST /asap is excluded from Bearer pairing so ASAP peers can call without a ShellClaw token. Authenticity is [asap].trusted_senders. An empty list (local/dev default) allows every claimed URN.
After provider/tool wiring in handle_asap (#53), a sender that passes that check can:
task.request—agent_run()with the same tool table as chat (shell,file, hardware,asap_invoke, …)mcp.tool_call—execute()with attacker-chosen name and arguments (no LLM)
Default gateway bind is 127.0.0.1 (http_start sets libwebsockets info.iface from gateway.host), which contains this for stock installs. Bind-all hosts (0.0.0.0, *, ::, [::], empty) require allow_bind_all. Production MUST set trusted_senders before exposing the gateway (allow_bind_all, tunnel, marketplace URL). Restricting the inbound MCP tool table (or failing closed when the allowlist is empty and the host is not loopback) is a follow-up.
Audit (2026-05-26): Re-verified slice 03 key handling in src/asap/manifest_keys.c. Keys load lazily on the first GET /.well-known/asap/manifest.json via manifest_keys_ensure_loaded() in src/gateway/routes.c; CLI/agent without gateway does not require ~/.shellclaw/keys/ at startup.
| File | Path | Mode on create |
|---|---|---|
| Private key | $SHELLCLAW_HOME/keys/ed25519.priv (default ~/.shellclaw/keys/ed25519.priv) |
0600 |
| Public key | …/keys/ed25519.pub |
0600 |
| Keys directory | …/keys/ |
0700 (mkdir on first use) |
SHELLCLAW_HOME overrides the base directory (same as config and auth tokens).
manifest_priv_permissions_ok() rejects ed25519.priv when any group or other permission bit is set (st_mode & 0077). This covers 0644, 0660, and world-readable modes. New and rotated keys are written with open(…, 0600) plus fchmod(…, 0600) after write.
| Operation | Loose ed25519.priv behavior |
|---|---|
manifest_keys_load() |
Fails with ed25519.priv permissions too open (expected 0600) |
manifest_keys_rotate |
Same error (explicit message) |
Agent startup (init_subsystems) |
Does not load signing keys (gateway/CLI without manifest GET may start without keys) |
/.well-known/asap/manifest.json |
Returns 500 if signing keys cannot be loaded (permissions or I/O) |
The keypair is loaded (or created) on the first signed manifest request, not at init_subsystems(). A world-readable ed25519.priv blocks manifest discovery but does not block one-shot -m runs that never hit the gateway manifest route.
tests/test_manifest_keys:test_manifest_keys_first_run_creates(0600 on create),test_manifest_keys_rejects_loose_priv_perms,test_manifest_keys_rotate_rejects_loose_privtests/test_bootstrap_keys: CLI starts without keys;manifest_keys_ensure_loadedrejects loose privtests/test_daemon_smoke.sh: post---rotate-keysstat expects mode600on both key files
Residual note: Only ed25519.priv permissions are checked on load; ed25519.pub is not re-stat'd for mode (public material). Owner-only 0400 on the private key passes the group/other check but is not exactly 0600; created keys always use 0600.
Audit (2026-05-26): Re-ran make static and make test-sanitize on the development branch; both exit 0. CI runs the same steps on ubuntu-24.04 (.github/workflows/ci.yml).
| Item | Value |
|---|---|
| Tool | cppcheck (CI installs via apt) |
| Scope | src/ only (vendored trees excluded from the walk) |
| Enables | warning, style, performance, portability |
| Failure mode | --error-exitcode=1 — any reported issue fails the build |
Curated suppressions in the Makefile cover known false positives (e.g. knownConditionTrueFalse in config reload paths, TweetNaCl variableScope). This is stricter than a one-off --enable=all run, which floods style noise on cJSON and ASAP helpers without improving security signal.
Local: make static
| Item | Value |
|---|---|
| Command | make test-sanitize |
| Flags | -fsanitize=address,undefined, -fno-omit-frame-pointer, -Werror (via CI=true) |
| Suite | Full make test with GATEWAY=1 (includes test_gateway_http, hardware, sandbox, manifest) |
Local (matches CI):
make test-sanitizeFull pre-release mirror on Linux: ./scripts/ci-local.sh (static → test → LIBGPIOD=0 test → sanitizer → release size → coverage).
Note: Sanitizer builds are slower and require a toolchain with ASan/UBSan support (GCC/Clang on Linux or macOS). They are not used for release binaries (make release).
This section summarizes Jetson Orin Nano Super / JetPack 6.2.x concerns that do not apply to Raspberry Pi or generic x86 dev machines.
| Surface | Jetson-specific behavior | Primary mitigation | Source |
|---|---|---|---|
| Shell sandbox | Tegra GPU character devices remain in inherited mount namespace | Landlock does not grant /dev/nv*; literal blocklist on /dev/nvhost*, /dev/nvgpu, /dev/nvmap |
src/sandbox/allowlist.c, sandbox_landlock.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 |
| Power / thermals | MAXN_SUPER vs 15W modes affect load | Operational guidance in hardware docs (not a sandbox control) | — |
v1.0 scope on Jetson: HTTP camera snapshot and sensor decoders are deferred stubs; LLM camera_capture and GPIO/I2C tools remain operator-trusted paths outside the shell sandbox. See NVIDIA Argus and Gateway hardware API.
Validation outside CI: gpio-mockup local ritual plus the on-device checklist in JETSON_SIGNOFF.md. Jetson sign-off is known pending (no Jetson runner in GitHub Actions; not a merge-to-main gate).
| Area | Directory / file | Security-relevant behavior |
|---|---|---|
| Sandbox isolation | src/sandbox/sandbox.c, src/sandbox/sandbox_landlock.c |
user ns + unshare + PID-1 fork, Landlock workspace bound, cgroups v2, fail-closed isolation |
| Command policy | src/sandbox/allowlist.c |
Blocklist (incl. Jetson GPU + Argus), workspace path containment (DiD) |
| Gateway auth | src/gateway/http_lws.c |
requires_auth() Bearer gate for /api/* |
| Hardware HTTP API | src/gateway/routes_hardware.c |
Read-only GET handlers, camera POST deferred stub |
| Rate limits | src/gateway/rate_limit.c |
Per-IP /asap RPM (64-slot table); reuses expired windows; fail-closed (429) when table is full and no slot expired |
| Camera spawn | src/hardware/hardware_camera.c |
argv-only execvp, validated resolution/type/path |
| Signing keys | src/asap/manifest_keys.c |
0600 create, loose-perm rejection, load/rotate guards |
| Signed manifest gate | src/gateway/routes.c |
manifest_keys_ensure_loaded() before manifest_build_signed_json() |
Automated regression coverage: tests/test_allowlist.c, tests/test_sandbox.c, tests/test_hardware_camera.c, tests/test_gateway_http.c, tests/test_rate_limit.c, tests/test_manifest_build, tests/test_manifest_keys.
src/sandbox/allowlist.c lists Jetson GPU device path substrings (/dev/nvhost, /dev/nvgpu, /dev/nvmap) and Argus socket paths for all boards. These are deny-by-path rules in the generic shell blocklist, not runtime board_id_t guards: on non-Jetson hosts the entries are harmless no-ops; on Jetson they block sandboxed shell access to GPU nodes and the Argus IPC socket. Camera capture via hardware_camera_capture() runs outside the shell sandbox by design.
Per project decision Q-SECREVIEW (DR-020):
- Self-audit only for v1.0. This document, source review notes in tasks 7.1–7.6, and CI static/sanitizer runs constitute the full assurance package for the v1.0 release. There was no external penetration test, no third-party code review, and no paid security auditor for this version.
- Bug bounty: not offered for v1.0; considered post-v1.0 (likely v1.2+ alongside external review — see roadmap).
- Hardware CI gap: GitHub Actions does not execute on a physical Jetson. Namespace and libgpiod behavior on Tegra are validated via source audit, unit tests, and the gpio-mockup release ritual. The on-device sign-off checklist is known pending and is not a merge-to-
maingate. - Sandbox depth: substring blocklists and namespace isolation are defense in depth, not a formal proof against a determined attacker with shell access when sandbox mode is disabled or when the agent process itself is compromised.
Do not describe ShellClaw v1.0 as "externally audited" or "pen-tested."
Last updated: 2026-05-26 — Wave 7.7 published (tasks 7.1–7.7); satisfies doc task 9.4.