diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da0267..69795d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ce925..1c60c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `<`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a7b24d2..2620459 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b93aa9d..f2a76aa 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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 | @@ -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) @@ -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 | diff --git a/src/sandbox/allowlist.c b/src/sandbox/allowlist.c index 3f5ec65..17b1aba 100644 --- a/src/sandbox/allowlist.c +++ b/src/sandbox/allowlist.c @@ -7,10 +7,13 @@ #include "sandbox/allowlist.h" #include +#include #include #include #include #include +#include +#include /* ------------------------------------------------------------------ */ /* Built-in blocklist patterns */ @@ -76,11 +79,1340 @@ static void set_reason(char *buf, size_t cap, const char *prefix, const char *de buf[cap - 1] = '\0'; } -/** Return 1 if @p s begins with prefix after any leading whitespace. */ +/** Return 1 if @p tok begins with a path-like character. */ static int has_path_chars(const char *tok) { - if (!tok) return 0; - return tok[0] == '/' || tok[0] == '~' || tok[0] == '.'; + if (!tok || !tok[0]) return 0; + if (tok[0] == '/' || tok[0] == '~' || tok[0] == '.') return 1; + if (tok[0] == '$') return 1; + return 0; +} + +/** Return 1 if @p tok looks like a shell option flag (-x / --long), not a path. */ +static int is_option_token(const char *tok) +{ + if (!tok || tok[0] != '-') + return 0; + if (tok[1] == '\0') + return 0; + if (tok[1] >= '0' && tok[1] <= '9') + return 0; + return 1; +} + +/** + * Resolve a bare relative filename against the workspace and reject symlink + * (or hard-link) escapes. Names that do not exist yet are left to Landlock. + * @return 1 if blocked, 0 if allowed / not applicable. + */ +static int block_if_relative_token_escapes(const char *tok, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + char joined[PATH_MAX]; + int n; + + if (!tok || !tok[0] || !workspace_root || !workspace_root[0]) + return 0; + if (has_path_chars(tok) || is_option_token(tok)) + return 0; + n = snprintf(joined, sizeof(joined), "%s/%s", workspace_root, tok); + if (n < 0 || (size_t)n >= sizeof(joined)) { + set_reason(reason_buf, reason_cap, "command blocked: path too long: ", tok); + return 1; + } + if (access(joined, F_OK) != 0) + return 0; + if (!allowlist_path_is_under_workspace(joined, workspace_root)) { + set_reason(reason_buf, reason_cap, + "command blocked: path escapes workspace: ", joined); + fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", joined); + return 1; + } + return 0; +} + +static char *strip_surrounding_quotes(char *tok) +{ + size_t n; + + if (!tok || !tok[0]) return tok; + n = strlen(tok); + if (n >= 2 && ((tok[0] == '\'' && tok[n - 1] == '\'') || + (tok[0] == '"' && tok[n - 1] == '"'))) { + tok[n - 1] = '\0'; + return tok + 1; + } + return tok; +} + +static int expand_env_prefix(const char *tok, const char *prefix, size_t prefix_len, + int require_slash_or_end, const char *value, + char *expanded, size_t expanded_cap) +{ + const char *suffix; + int n; + + if (strncmp(tok, prefix, prefix_len) != 0) + return 1; + suffix = tok + prefix_len; + if (require_slash_or_end && suffix[0] != '\0' && suffix[0] != '/') + return 1; + if (!value) + return -1; + n = snprintf(expanded, expanded_cap, "%s%s", value, suffix); + if (n < 0 || (size_t)n >= expanded_cap) + return -1; + return 0; +} + +/** + * Expand `~`, `$HOME` / `${HOME}`, or `$PWD` / `${PWD}`. Other `$...` forms + * (ANSI-C, command substitution, unknown vars) fail closed. + */ +static int expand_shell_path_token(const char *tok, char *expanded, size_t expanded_cap) +{ + const char *home; + const char *cwd; + int rc; + + if (!tok || !expanded || expanded_cap == 0) return -1; + if (tok[0] == '~') { + int n; + + home = getenv("HOME"); + if (home) + n = snprintf(expanded, expanded_cap, "%s%s", home, tok + 1); + else + n = snprintf(expanded, expanded_cap, "%s", tok); + return (n < 0 || (size_t)n >= expanded_cap) ? -1 : 0; + } + if (tok[0] != '$') { + if (strlen(tok) >= expanded_cap) return -1; + memcpy(expanded, tok, strlen(tok) + 1); + return 0; + } + if (tok[1] == '\'' || tok[1] == '"' || tok[1] == '(') + return -1; + home = getenv("HOME"); + cwd = getenv("PWD"); + rc = expand_env_prefix(tok, "${HOME}", 7, 0, home, expanded, expanded_cap); + if (rc != 1) return rc; + rc = expand_env_prefix(tok, "$HOME", 5, 1, home, expanded, expanded_cap); + if (rc != 1) return rc; + rc = expand_env_prefix(tok, "${PWD}", 6, 0, cwd, expanded, expanded_cap); + if (rc != 1) return rc; + rc = expand_env_prefix(tok, "$PWD", 4, 1, cwd, expanded, expanded_cap); + if (rc != 1) return rc; + return -1; +} + +static int is_path_body_char(unsigned char c) +{ + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '/' || c == '.' || c == '_' || + c == '-' || c == '+' || c == '%' || c == '@'; +} + +static int is_ident_start(unsigned char c) +{ + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; +} + +static int is_ident_cont(unsigned char c) +{ + return is_ident_start(c) || (c >= '0' && c <= '9'); +} + +/** + * Drop `'` / `"` and trivial quote-concat `+` so `eval 'PWD=;'` and + * `f'+'ile://...` look like the shell/Python they become. + */ +static char *dup_unquoted(const char *src) +{ + size_t n; + char *dst; + size_t di; + const char *p; + char last; + + if (!src) + return NULL; + n = strlen(src); + dst = malloc(n + 1); + if (!dst) + return NULL; + di = 0; + last = 0; + for (p = src; *p; p++) { + unsigned char c = (unsigned char)*p; + + /* POSIX unquoted/double-quoted `\` + newline is deleted. */ + if (c == '\\' && p[1] == '\r' && p[2] == '\n') { + p += 2; + continue; + } + if (c == '\\' && (p[1] == '\n' || p[1] == '\r')) { + p++; + continue; + } + if (c == '\'' || c == '"') + continue; + if (c == '+' && last && is_ident_cont((unsigned char)last)) { + const char *nxt = p + 1; + + while (*nxt == '\'' || *nxt == '"') + nxt++; + if (*nxt && is_ident_start((unsigned char)*nxt)) + continue; + } + dst[di++] = *p; + last = *p; + } + dst[di] = '\0'; + return dst; +} + +static int is_cmd_word_start(const char *text, const char *p) +{ + unsigned char prev; + + if (!text || !p || p < text) + return 0; + if (p == text) + return 1; + prev = (unsigned char)p[-1]; + return prev == ' ' || prev == '\t' || prev == '\n' || prev == '\r' || + prev == ';' || prev == '|' || prev == '&' || prev == '(' || + prev == '{' || prev == ')' || prev == ',' || prev == '['; +} + +static int name_is_home_or_pwd(const char *p, size_t *nlen) +{ + if (strncmp(p, "HOME", 4) == 0 && !is_ident_cont((unsigned char)p[4])) { + if (nlen) + *nlen = 4; + return 1; + } + if (strncmp(p, "PWD", 3) == 0 && !is_ident_cont((unsigned char)p[3])) { + if (nlen) + *nlen = 3; + return 1; + } + return 0; +} + +/** + * Process getenv(HOME/PWD) is wrong after `PWD=; cat $PWD/etc/passwd`. + * Fail closed when the command text assigns, exports, unsets, namerefs, or + * clears those names (`declare -n`, `exec -c`). + */ +static int command_mutates_home_or_pwd(const char *text) +{ + const char *p; + + if (!text) + return 0; + for (p = text; *p; p++) { + size_t nlen = 0; + + if (!is_cmd_word_start(text, p)) + continue; + if (name_is_home_or_pwd(p, &nlen)) { + const char *eq = p + nlen; + + if (*eq == ']') + eq++; + if (*eq == '=' || *eq == ':') + return 1; + } + if (strncmp(p, "unset", 5) == 0 && !is_ident_cont((unsigned char)p[5])) { + const char *q = p + 5; + + while (*q == ' ' || *q == '\t') + q++; + while (*q && *q != ';' && *q != '|' && *q != '&' && *q != '\n') { + if (name_is_home_or_pwd(q, &nlen)) + return 1; + while (*q && *q != ' ' && *q != '\t' && *q != ';' && + *q != '|' && *q != '&' && *q != '\n') + q++; + while (*q == ' ' || *q == '\t') + q++; + } + } + if (strncmp(p, "export", 6) == 0 && !is_ident_cont((unsigned char)p[6])) { + const char *q = p + 6; + + while (*q == ' ' || *q == '\t') + q++; + while (*q && *q != ';' && *q != '|' && *q != '&' && *q != '\n') { + if (name_is_home_or_pwd(q, &nlen)) + return 1; + while (*q && *q != ' ' && *q != '\t' && *q != ';' && + *q != '|' && *q != '&' && *q != '\n') + q++; + while (*q == ' ' || *q == '\t') + q++; + } + } + if (strncmp(p, "printf", 6) == 0 && !is_ident_cont((unsigned char)p[6])) { + const char *q = p + 6; + + while (*q && *q != ';' && *q != '|' && *q != '&' && *q != '\n') { + if (q[0] == '-' && q[1] == 'v') { + const char *n = q + 2; + + while (*n == ' ' || *n == '\t') + n++; + if (name_is_home_or_pwd(n, &nlen)) + return 1; + } + q++; + } + } + if (strncmp(p, "read", 4) == 0 && !is_ident_cont((unsigned char)p[4])) { + const char *q = p + 4; + + while (*q && *q != ';' && *q != '|' && *q != '&' && *q != '\n') { + while (*q == ' ' || *q == '\t') + q++; + if (*q == '\0' || *q == ';' || *q == '|' || *q == '&' || *q == '\n') + break; + if (*q == '-') { + while (*q && *q != ' ' && *q != '\t' && *q != ';' && + *q != '|' && *q != '&' && *q != '\n') + q++; + continue; + } + if (name_is_home_or_pwd(q, &nlen)) + return 1; + while (*q && *q != ' ' && *q != '\t' && *q != ';' && + *q != '|' && *q != '&' && *q != '\n') + q++; + } + } + if (strncmp(p, "declare", 7) == 0 && !is_ident_cont((unsigned char)p[7])) { + const char *q = p + 7; + int saw_n = 0; + + while (*q == ' ' || *q == '\t') + q++; + while (*q == '-') { + const char *f = q + 1; + + if (*f == '-') { + while (*q && *q != ' ' && *q != '\t' && *q != ';' && + *q != '|' && *q != '&' && *q != '\n') + q++; + while (*q == ' ' || *q == '\t') + q++; + continue; + } + while (*f && *f != ' ' && *f != '\t' && *f != ';' && + *f != '|' && *f != '&' && *f != '\n') { + if (*f == 'n') + saw_n = 1; + f++; + } + q = f; + while (*q == ' ' || *q == '\t') + q++; + } + if (saw_n) { + const char *s = q; + + while (*s && *s != ';' && *s != '|' && *s != '&' && *s != '\n') { + if (*s == '=' && name_is_home_or_pwd(s + 1, &nlen)) + return 1; + if (is_cmd_word_start(text, s) && name_is_home_or_pwd(s, &nlen)) + return 1; + s++; + } + } + } + if (strncmp(p, "exec", 4) == 0 && !is_ident_cont((unsigned char)p[4])) { + const char *q = p + 4; + + while (*q == ' ' || *q == '\t') + q++; + while (*q == '-') { + const char *f = q + 1; + + if (*f == '-') { + while (*q && *q != ' ' && *q != '\t' && *q != ';' && + *q != '|' && *q != '&' && *q != '\n') + q++; + while (*q == ' ' || *q == '\t') + q++; + continue; + } + while (*f && *f != ' ' && *f != '\t' && *f != ';' && + *f != '|' && *f != '&' && *f != '\n') { + if (*f == 'c') + return 1; + f++; + } + q = f; + while (*q == ' ' || *q == '\t') + q++; + } + } + if (strncmp(p, "env", 3) == 0 && !is_ident_cont((unsigned char)p[3])) { + const char *q = p + 3; + + while (*q && *q != ';' && *q != '|' && *q != '&' && *q != '\n') { + if (strncmp(q, "--ignore-environment", 20) == 0 && + !is_ident_cont((unsigned char)q[20])) + return 1; + if (strncmp(q, "--unset", 7) == 0 && + (q[7] == '\0' || q[7] == ' ' || q[7] == '\t' || q[7] == '=')) { + const char *unset_arg = q + 7; + + if (*unset_arg == '=') + unset_arg++; + while (*unset_arg == ' ' || *unset_arg == '\t') + unset_arg++; + if (name_is_home_or_pwd(unset_arg, &nlen)) + return 1; + } + if (q[0] == '-' && q[1] != '-' && q[1] != '\0') { + const char *f = q + 1; + int saw_i = 0; + int saw_u = 0; + const char *after_u = NULL; + + while (*f >= 'a' && *f <= 'z') { + if (*f == 'i') + saw_i = 1; + if (*f == 'u') { + saw_u = 1; + after_u = f + 1; + } + f++; + } + if (saw_i) + return 1; + if (saw_u && after_u) { + const char *unset_arg = after_u; + + while (*unset_arg == ' ' || *unset_arg == '\t') + unset_arg++; + if (name_is_home_or_pwd(unset_arg, &nlen)) + return 1; + } + } + q++; + } + } + if (strncmp(p, "os.unsetenv", 11) == 0 && + !is_ident_cont((unsigned char)p[11])) { + const char *q = p + 11; + + while (*q && *q != ';' && *q != '\n') { + if (name_is_home_or_pwd(q, &nlen)) + return 1; + q++; + } + } + if (strncmp(p, "os.putenv", 9) == 0 && + !is_ident_cont((unsigned char)p[9])) { + const char *q = p + 9; + + while (*q && *q != ';' && *q != '\n') { + if (name_is_home_or_pwd(q, &nlen)) + return 1; + q++; + } + } + if (strncmp(p, "os.environ", 10) == 0) { + const char *q = p + 10; + + if (strncmp(q, ".clear", 6) == 0 && + !is_ident_cont((unsigned char)q[6])) + return 1; + if (strncmp(q, ".update", 7) == 0 && + !is_ident_cont((unsigned char)q[7])) { + q += 7; + while (*q && *q != ';' && *q != '\n') { + if (name_is_home_or_pwd(q, &nlen)) + return 1; + q++; + } + } + if (strncmp(q, ".pop", 4) == 0 || + strncmp(q, ".__delitem__", 12) == 0) { + q += (q[1] == 'p') ? 4 : 12; + while (*q && *q != ';' && *q != '\n') { + if (name_is_home_or_pwd(q, &nlen)) + return 1; + q++; + } + } + } + if (strncmp(p, "del", 3) == 0 && !is_ident_cont((unsigned char)p[3])) { + const char *q = p + 3; + + while (*q == ' ' || *q == '\t') + q++; + if (strncmp(q, "os.environ", 10) == 0) { + q += 10; + while (*q && *q != ';' && *q != '\n') { + if (name_is_home_or_pwd(q, &nlen)) + return 1; + q++; + } + } + } + } + return 0; +} + +/** + * Bytes of an escape that decodes to `/` or `.` (`\x2f` / `\x2e`, `\x{2f}`, + * `\u002f`, `\u{2f}`, `\U0000002f`, octal `\57` / `\56`, Perl `\o{57}` / `\o{056}`). + * Not a Python interpreter: `chr(47)` with no slash encoding in the text is + * still out of scope. + */ +static int hex_nibble(unsigned char c); +static size_t encoded_dot_or_slash_len(const char *p, char *decoded) +{ + int hi; + int val; + size_t n; + + if (!p || !decoded || p[0] != '\\' || p[1] == '\0') + return 0; + if ((p[1] == 'x' || p[1] == 'X') && p[2] == '{') { + val = 0; + n = 0; + while (n < 6 && hex_nibble((unsigned char)p[3 + n]) >= 0) { + val = (val << 4) | hex_nibble((unsigned char)p[3 + n]); + n++; + } + if (n > 0 && p[3 + n] == '}' && (val == 46 || val == 47)) { + *decoded = (char)val; + return 4 + n; + } + } + if ((p[1] == 'x' || p[1] == 'X')) { + int lo; + + hi = hex_nibble((unsigned char)p[2]); + lo = hex_nibble((unsigned char)p[3]); + if (hi >= 0 && lo >= 0) { + val = (hi << 4) | lo; + if (val == 46 || val == 47) { + *decoded = (char)val; + return 4; + } + } + } + if (p[1] == 'u' && p[2] == '{') { + val = 0; + n = 0; + while (n < 6 && hex_nibble((unsigned char)p[3 + n]) >= 0) { + val = (val << 4) | hex_nibble((unsigned char)p[3 + n]); + n++; + } + if (n > 0 && p[3 + n] == '}' && (val == 46 || val == 47)) { + *decoded = (char)val; + return 4 + n; + } + } + if (p[1] == 'u') { + val = 0; + for (n = 0; n < 4; n++) { + hi = hex_nibble((unsigned char)p[2 + n]); + if (hi < 0) { + val = -1; + break; + } + val = (val << 4) | hi; + } + if (val == 46 || val == 47) { + *decoded = (char)val; + return 6; + } + } + if (p[1] == 'U') { + val = 0; + for (n = 0; n < 8; n++) { + hi = hex_nibble((unsigned char)p[2 + n]); + if (hi < 0) { + val = -1; + break; + } + val = (val << 4) | hi; + } + if (val == 46 || val == 47) { + *decoded = (char)val; + return 10; + } + } + if ((p[1] == 'o' || p[1] == 'O') && p[2] == '{') { + val = 0; + n = 0; + while (n < 6 && p[3 + n] >= '0' && p[3 + n] <= '7') { + val = val * 8 + (p[3 + n] - '0'); + n++; + } + if (n > 0 && p[3 + n] == '}' && (val == 46 || val == 47)) { + *decoded = (char)val; + return 4 + n; + } + } + if (p[1] >= '0' && p[1] <= '7') { + val = 0; + n = 0; + while (n < 3 && p[1 + n] >= '0' && p[1 + n] <= '7') { + val = val * 8 + (p[1 + n] - '0'); + n++; + } + if (n > 0 && (val == 46 || val == 47)) { + *decoded = (char)val; + return 1 + n; + } + } + return 0; +} + +static int hex_nibble(unsigned char c) +{ + if (c >= '0' && c <= '9') + return (int)(c - '0'); + if (c >= 'a' && c <= 'f') + return (int)(c - 'a' + 10); + if (c >= 'A' && c <= 'F') + return (int)(c - 'A' + 10); + return -1; +} + +/** Percent-decode @p s in place. Invalid `%` and `%00` fail closed. */ +static int percent_decode_inplace(char *s) +{ + char *r; + char *w; + + if (!s) + return -1; + r = s; + w = s; + while (*r) { + if (r[0] == '%') { + int hi; + int lo; + unsigned char v; + + hi = hex_nibble((unsigned char)r[1]); + lo = hex_nibble((unsigned char)r[2]); + if (hi < 0 || lo < 0) + return -1; + v = (unsigned char)((hi << 4) | lo); + if (v == 0) + return -1; + *w++ = (char)v; + r += 3; + continue; + } + *w++ = *r++; + } + *w = '\0'; + return 0; +} + +/** `${HOME}/` and `${PWD}/` are one expansion; `${IFS}/` is a new FS root. */ +static int slash_follows_home_or_pwd_brace(const char *text, const char *slash) +{ + if (!text || !slash || slash <= text) + return 0; + if (slash >= text + 7 && strncmp(slash - 7, "${HOME}", 7) == 0) + return 1; + if (slash >= text + 6 && strncmp(slash - 6, "${PWD}", 6) == 0) + return 1; + return 0; +} + +/** True when @p p sits in a `://` URL span. `../` after that is still a path. */ +static int is_inside_url(const char *text, const char *p) +{ + const char *q; + + if (!text || !p || p < text) + return 0; + for (q = p; q > text; q--) { + unsigned char c = (unsigned char)q[-1]; + if (c == ' ' || c == '\t' || c == '\n' || c == ';' || c == '|' || + c == '&' || c == '<' || c == '>' || c == '"' || c == '\'') + return 0; + if (q >= text + 3 && q[-3] == ':' && q[-2] == '/' && q[-1] == '/') + return 1; + } + return 0; +} + +static int is_fs_absolute_path_start(const char *text, const char *p) +{ + unsigned char prev; + + if (!text || !p) + return 0; + if (*p == '.') { + if (!(p[1] == '/' || + (p[1] == '.' && (p[2] == '/' || p[2] == '\0' || + p[2] == '\'' || p[2] == '"' || + !is_path_body_char((unsigned char)p[2]))))) + return 0; + if (is_inside_url(text, p) && p[1] != '.') + return 0; + if (p == text) + return 1; + prev = (unsigned char)p[-1]; + if (is_path_body_char(prev) && prev != '/') + return 0; + return 1; + } + if (*p != '/' && *p != '~') + return 0; + if (p == text) + return 1; + prev = (unsigned char)p[-1]; + if (*p == '/' && prev == ':') + return 0; + if (*p == '/' && p >= text + 2 && p[-1] == '/' && p[-2] == ':') + return 0; + if (is_path_body_char(prev) && prev != '/') + return 0; + if (prev == '}') + return !slash_follows_home_or_pwd_brace(text, p); + return 1; +} + +static int expand_tilde_fragment(const char *fragment, char *dest, size_t dest_cap) +{ + const char *home; + int n; + + if (!fragment || !dest || dest_cap == 0) + return -1; + if (fragment[0] != '~') { + if (strlen(fragment) >= dest_cap) + return -1; + memcpy(dest, fragment, strlen(fragment) + 1); + return 0; + } + home = getenv("HOME"); + if (!home || home[0] == '\0') + return -1; + n = snprintf(dest, dest_cap, "%s%s", home, fragment + 1); + if (n < 0 || (size_t)n >= dest_cap) + return -1; + return 0; +} + +static int block_if_embedded_paths_escape(const char *text, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + const char *p; + + if (!text || !workspace_root) return 0; + for (p = text; *p; p++) { + char fragment[PATH_MAX]; + char expanded[PATH_MAX]; + size_t n = 0; + const char *start; + + if (!is_fs_absolute_path_start(text, p)) + continue; + start = p; + fragment[n++] = *p++; + while (*p && is_path_body_char((unsigned char)*p) && n + 1 < sizeof(fragment)) + fragment[n++] = *p++; + fragment[n] = '\0'; + if (expand_tilde_fragment(fragment, expanded, sizeof(expanded)) != 0) { + set_reason(reason_buf, reason_cap, + "command blocked: path escapes workspace: ", fragment); + return 1; + } + if (expanded[0] != '/') { + char joined[PATH_MAX]; + int jn; + + jn = snprintf(joined, sizeof(joined), "%s/%s", workspace_root, expanded); + if (jn < 0 || (size_t)jn >= sizeof(joined)) { + set_reason(reason_buf, reason_cap, + "command blocked: path escapes workspace: ", fragment); + return 1; + } + memcpy(expanded, joined, (size_t)jn + 1); + } + if (!allowlist_path_is_under_workspace(expanded, workspace_root)) { + set_reason(reason_buf, reason_cap, + "command blocked: path escapes workspace: ", expanded); + fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", expanded); + return 1; + } + if (p > start) + p--; + } + return 0; +} + +/** + * Reconstruct `/` or `../` from encoded `.` / `/` so a later literal slash + * (`etc/passwd`) cannot hide the root, and `\x2e\x2e/secret` cannot hide `..`. + * `\N{` fail-closes without parsing Unicode names. + */ +static int block_if_encoded_slash_escapes(const char *text, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + const char *p; + + if (!text || !workspace_root) return 0; + for (p = text; *p; ) { + size_t esc; + size_t n; + const char *q; + char reconstructed[PATH_MAX]; + char ch; + + if (p[0] == '\\' && p[1] == 'N' && p[2] == '{') { + set_reason(reason_buf, reason_cap, + "command blocked: unresolved unicode name escape", ""); + fprintf(stderr, "allowlist: blocked unicode name escape \\N{\n"); + return 1; + } + esc = encoded_dot_or_slash_len(p, &ch); + if (!esc) { + p++; + continue; + } + n = 0; + q = p; + while (n + 1 < sizeof(reconstructed)) { + size_t e2; + char ch2; + + e2 = encoded_dot_or_slash_len(q, &ch2); + if (e2) { + reconstructed[n++] = ch2; + q += e2; + continue; + } + if (*q && is_path_body_char((unsigned char)*q)) { + reconstructed[n++] = *q++; + continue; + } + break; + } + reconstructed[n] = '\0'; + if (reconstructed[0] != '/') { + char joined[PATH_MAX]; + int jn; + + jn = snprintf(joined, sizeof(joined), "%s/%s", workspace_root, reconstructed); + if (jn < 0 || (size_t)jn >= sizeof(joined)) { + set_reason(reason_buf, reason_cap, + "command blocked: path escapes workspace: ", reconstructed); + return 1; + } + memcpy(reconstructed, joined, (size_t)jn + 1); + } + if (!allowlist_path_is_under_workspace(reconstructed, workspace_root)) { + set_reason(reason_buf, reason_cap, + "command blocked: path escapes workspace: ", reconstructed); + fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", + reconstructed); + return 1; + } + p = q; + } + return 0; +} + +static size_t path_suffix_len(const char *p) +{ + size_t n = 0; + + if (!p || p[0] != '/') + return 0; + while (p[n] && is_path_body_char((unsigned char)p[n])) + n++; + return n; +} + +static int block_expanded_env_path(const char *value, const char *suffix, size_t suffix_len, + const char *workspace_root, const char *raw, + char *reason_buf, size_t reason_cap) +{ + char expanded[PATH_MAX]; + int n; + + if (!value) { + set_reason(reason_buf, reason_cap, + "command blocked: unresolved shell path expansion: ", raw); + fprintf(stderr, "allowlist: blocked unresolved shell path: %s\n", raw); + return 1; + } + n = snprintf(expanded, sizeof(expanded), "%s%.*s", value, (int)suffix_len, suffix); + if (n < 0 || (size_t)n >= sizeof(expanded)) { + set_reason(reason_buf, reason_cap, + "command blocked: unresolved shell path expansion: ", raw); + fprintf(stderr, "allowlist: blocked unresolved shell path: %s\n", raw); + return 1; + } + if (!allowlist_path_is_under_workspace(expanded, workspace_root)) { + set_reason(reason_buf, reason_cap, + "command blocked: path escapes workspace: ", expanded); + fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", expanded); + return 1; + } + return 0; +} + +/** + * Scan `$` on the full command. Shell glues expansions onto the previous word + * (`cat$IFS/etc/passwd`, `cat"$HOME/.bashrc"`), so strtok + tok[0]=='$' misses them. + * Expand `$HOME` / `${HOME}` / `$PWD` / `${PWD}` (plus a following `/...` suffix); + * fail closed on ANSI-C, command substitution, `$IFS`, and other `$...` forms. + */ +static int block_if_dollar_expansions_escape(const char *text, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + const char *p; + const char *home; + const char *cwd; + + if (!text || !workspace_root) return 0; + home = getenv("HOME"); + cwd = getenv("PWD"); + for (p = text; *p; ) { + size_t suffix_n; + + if (*p != '$') { + p++; + continue; + } + if (p[1] == '\'' || p[1] == '"' || p[1] == '(') { + set_reason(reason_buf, reason_cap, + "command blocked: unresolved shell path expansion: ", p); + fprintf(stderr, "allowlist: blocked unresolved shell path: %s\n", p); + return 1; + } + if (p[1] == '{') { + if (strncmp(p, "${HOME}", 7) == 0) { + suffix_n = path_suffix_len(p + 7); + if (block_expanded_env_path(home, p + 7, suffix_n, workspace_root, p, + reason_buf, reason_cap)) + return 1; + p += 7 + suffix_n; + continue; + } + if (strncmp(p, "${PWD}", 6) == 0) { + suffix_n = path_suffix_len(p + 6); + if (block_expanded_env_path(cwd, p + 6, suffix_n, workspace_root, p, + reason_buf, reason_cap)) + return 1; + p += 6 + suffix_n; + continue; + } + set_reason(reason_buf, reason_cap, + "command blocked: unresolved shell path expansion: ", p); + fprintf(stderr, "allowlist: blocked unresolved shell path: %s\n", p); + return 1; + } + if (strncmp(p, "$HOME", 5) == 0 && !is_ident_cont((unsigned char)p[5])) { + suffix_n = path_suffix_len(p + 5); + if (block_expanded_env_path(home, p + 5, suffix_n, workspace_root, p, + reason_buf, reason_cap)) + return 1; + p += 5 + suffix_n; + continue; + } + if (strncmp(p, "$PWD", 4) == 0 && !is_ident_cont((unsigned char)p[4])) { + suffix_n = path_suffix_len(p + 4); + if (block_expanded_env_path(cwd, p + 4, suffix_n, workspace_root, p, + reason_buf, reason_cap)) + return 1; + p += 4 + suffix_n; + continue; + } + set_reason(reason_buf, reason_cap, + "command blocked: unresolved shell path expansion: ", p); + fprintf(stderr, "allowlist: blocked unresolved shell path: %s\n", p); + return 1; + } + return 0; +} + +static int prefix_ci_eq(const char *p, const char *prefix) +{ + size_t i; + + for (i = 0; prefix[i]; i++) { + unsigned char a = (unsigned char)p[i]; + unsigned char b = (unsigned char)prefix[i]; + + if (a >= 'A' && a <= 'Z') a = (unsigned char)(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = (unsigned char)(b - 'A' + 'a'); + if (a != b) return 0; + } + return 1; +} + +/** + * Bytes of `\\xNN` / `\\x{NN}` / `\\u00NN` / `\\u{NN}` / `\\U000000NN` / + * octal `\\146` / `\\072` / `\\o{146}` that decode to a non-NUL byte. + * Used to recover a hidden `file:` scheme (`\\x66ile:`, `\\146ile:`). + */ +static size_t encoded_hex_unicode_byte_len(const char *p, unsigned char *decoded) +{ + int hi; + int val; + size_t n; + + if (!p || !decoded || p[0] != '\\' || p[1] == '\0') + return 0; + if ((p[1] == 'x' || p[1] == 'X') && p[2] == '{') { + val = 0; + n = 0; + while (n < 6 && hex_nibble((unsigned char)p[3 + n]) >= 0) { + val = (val << 4) | hex_nibble((unsigned char)p[3 + n]); + n++; + } + if (n > 0 && p[3 + n] == '}' && val >= 1 && val <= 255) { + *decoded = (unsigned char)val; + return 4 + n; + } + return 0; + } + if (p[1] == 'x' || p[1] == 'X') { + int lo; + + hi = hex_nibble((unsigned char)p[2]); + lo = hex_nibble((unsigned char)p[3]); + if (hi >= 0 && lo >= 0) { + val = (hi << 4) | lo; + if (val >= 1 && val <= 255) { + *decoded = (unsigned char)val; + return 4; + } + } + return 0; + } + if (p[1] == 'u' && p[2] == '{') { + val = 0; + n = 0; + while (n < 6 && hex_nibble((unsigned char)p[3 + n]) >= 0) { + val = (val << 4) | hex_nibble((unsigned char)p[3 + n]); + n++; + } + if (n > 0 && p[3 + n] == '}' && val >= 1 && val <= 255) { + *decoded = (unsigned char)val; + return 4 + n; + } + return 0; + } + if (p[1] == 'u') { + val = 0; + for (n = 0; n < 4; n++) { + hi = hex_nibble((unsigned char)p[2 + n]); + if (hi < 0) + return 0; + val = (val << 4) | hi; + } + if (val >= 1 && val <= 255) { + *decoded = (unsigned char)val; + return 6; + } + return 0; + } + if (p[1] == 'U') { + val = 0; + for (n = 0; n < 8; n++) { + hi = hex_nibble((unsigned char)p[2 + n]); + if (hi < 0) + return 0; + val = (val << 4) | hi; + } + if (val >= 1 && val <= 255) { + *decoded = (unsigned char)val; + return 10; + } + } + if ((p[1] == 'o' || p[1] == 'O') && p[2] == '{') { + val = 0; + n = 0; + while (n < 6 && p[3 + n] >= '0' && p[3 + n] <= '7') { + val = val * 8 + (p[3 + n] - '0'); + n++; + } + if (n > 0 && p[3 + n] == '}' && val >= 1 && val <= 255) { + *decoded = (unsigned char)val; + return 4 + n; + } + return 0; + } + if (p[1] >= '0' && p[1] <= '7') { + val = 0; + n = 0; + while (n < 3 && p[1 + n] >= '0' && p[1 + n] <= '7') { + val = val * 8 + (p[1 + n] - '0'); + n++; + } + if (n > 0 && val >= 1 && val <= 255) { + *decoded = (unsigned char)val; + return 1 + n; + } + } + return 0; +} + +static char *dup_decode_hex_unicode(const char *src) +{ + size_t n; + size_t di; + char *dst; + const char *p; + + if (!src) + return NULL; + n = strlen(src); + dst = malloc(n + 1); + if (!dst) + return NULL; + di = 0; + for (p = src; *p; ) { + unsigned char ch; + size_t esc = encoded_hex_unicode_byte_len(p, &ch); + + if (esc) { + dst[di++] = (char)ch; + p += esc; + } else if (p[0] == '\\' && p[1] != '\0' && p[1] != '\n' && p[1] != '\r') { + /* Shell identity escape: `f\ile:` -> `file:`. */ + dst[di++] = p[1]; + p += 2; + } else { + dst[di++] = *p++; + } + } + dst[di] = '\0'; + return dst; +} + +/** + * `is_fs_absolute_path_start` skips `/` after `:`, so `file:/etc/passwd` and + * `file://localhost/etc/passwd` never start a path fragment. Extract the local + * path from `file:` URLs, percent-decode, and run the workspace check. + */ +static int block_if_file_url_escapes(const char *text, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + const char *p; + + if (!text || !workspace_root) return 0; + for (p = text; *p; p++) { + const char *s; + char path[PATH_MAX]; + size_t n = 0; + + if (!prefix_ci_eq(p, "file:")) + continue; + if (p > text) { + unsigned char prev = (unsigned char)p[-1]; + + if (is_path_body_char(prev) && prev != '/') + continue; + } + s = p + 5; + while (*s == '/') + s++; + if (prefix_ci_eq(s, "localhost") && (s[9] == '/' || s[9] == '\0' || + s[9] == '\'' || s[9] == '"')) + s += 9; + else if (strncmp(s, "127.0.0.1", 9) == 0 && + (s[9] == '/' || s[9] == '\0' || s[9] == '\'' || s[9] == '"')) + s += 9; + else if (strncmp(s, "[::1]", 5) == 0 && + (s[5] == '/' || s[5] == '\0' || s[5] == '\'' || s[5] == '"')) + s += 5; + while (*s == '/') + s++; + if (*s == '\0' || *s == '\'' || *s == '"') + continue; + path[n++] = '/'; + while (*s && is_path_body_char((unsigned char)*s) && n + 1 < sizeof(path)) + path[n++] = *s++; + path[n] = '\0'; + if (percent_decode_inplace(path) != 0) { + set_reason(reason_buf, reason_cap, + "command blocked: path escapes workspace: ", path); + fprintf(stderr, "allowlist: blocked invalid percent-encoded file URL\n"); + return 1; + } + if (!allowlist_path_is_under_workspace(path, workspace_root)) { + set_reason(reason_buf, reason_cap, + "command blocked: path escapes workspace: ", path); + fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", path); + return 1; + } + } + return 0; +} + +static int resolved_is_under_workspace(const char *resolved, const char *actual_ws, size_t wlen) +{ + if (!resolved || !actual_ws || wlen == 0) return 0; + if (strncmp(resolved, actual_ws, wlen) != 0) return 0; + return resolved[wlen] == '\0' || resolved[wlen] == '/'; +} + +/* + * realpath(3) cannot canonicalize a path that does not exist. Walking to the + * first existing ancestor (same approach as tools/file.c) still collapses `..` + * through existing directories, so workspace/../../tmp/newfile is denied. + * A lexical prefix check would allow that destination. + */ +static int existing_ancestor_is_under_workspace(const char *path, const char *actual_ws, size_t wlen) +{ + char path_copy[PATH_MAX]; + char parent[PATH_MAX]; + char resolved[PATH_MAX]; + int hops; + + if (!path || path[0] == '\0' || strlen(path) >= PATH_MAX) return 0; + snprintf(path_copy, sizeof(path_copy), "%s", path); + for (hops = 0; hops < PATH_MAX; hops++) { + char *dir; + size_t n; + + dir = dirname(path_copy); + if (!dir || dir[0] == '\0') return 0; + n = strlen(dir); + if (n >= sizeof(parent)) return 0; + memcpy(parent, dir, n + 1); + if (realpath(parent, resolved) != NULL) + return resolved_is_under_workspace(resolved, actual_ws, wlen); + if (strcmp(parent, ".") == 0 || strcmp(parent, "/") == 0) return 0; + memcpy(path_copy, parent, n + 1); + } + return 0; +} + +/** + * Collapse `.` / `..` without requiring directories to exist, so + * `/ws/nope/../../../tmp/x` becomes `/tmp/x` instead of walking back to `/ws`. + * Do not cancel `..` across a symlink: the kernel walks the link first, so + * `workspace/out/../etc/passwd` with `out` -> `/` is `/etc/passwd`. + */ +static int lexical_collapse_path(const char *path, char *out, size_t out_cap) +{ + char tmp[PATH_MAX]; + const char *parts[PATH_MAX / 2] = { NULL }; + int nparts = 0; + int absolute; + size_t len; + char *cur; + int i; + size_t out_len; + + if (!path || !out || out_cap == 0) + return -1; + len = strlen(path); + if (len == 0 || len >= sizeof(tmp)) + return -1; + memcpy(tmp, path, len + 1); + absolute = (tmp[0] == '/'); + cur = absolute ? tmp + 1 : tmp; + while (*cur) { + char *seg = cur; + + while (*cur && *cur != '/') + cur++; + if (*cur == '/') { + *cur = '\0'; + cur++; + } + if (seg[0] == '\0' || strcmp(seg, ".") == 0) + continue; + if (strcmp(seg, "..") == 0) { + if (nparts > 0) { + char probe[PATH_MAX]; + struct stat st; + size_t probe_len; + int pi; + + memset(&st, 0, sizeof(st)); + + if (absolute) { + probe[0] = '/'; + probe_len = 1; + } else { + probe_len = 0; + } + for (pi = 0; pi < nparts; pi++) { + const char *ps = parts[pi]; + size_t sl; + + if (!ps) + return -1; + sl = strlen(ps); + if (pi > 0) { + if (probe_len + 1 >= sizeof(probe)) + return -1; + probe[probe_len++] = '/'; + } + if (probe_len + sl + 1 > sizeof(probe)) + return -1; + memcpy(probe + probe_len, ps, sl); + probe_len += sl; + } + probe[probe_len] = '\0'; + if (lstat(probe, &st) == 0 && S_ISLNK(st.st_mode)) + return -1; + nparts--; + } + continue; + } + if (nparts >= (int)(sizeof(parts) / sizeof(parts[0]))) + return -1; + parts[nparts++] = seg; + } + if (absolute) { + if (out_cap < 2) + return -1; + out[0] = '/'; + out_len = 1; + } else { + out_len = 0; + } + for (i = 0; i < nparts; i++) { + const char *seg = parts[i]; + size_t sl; + + if (!seg) + return -1; + sl = strlen(seg); + + if (i > 0) { + if (out_len + 1 >= out_cap) + return -1; + out[out_len++] = '/'; + } + if (out_len + sl + 1 > out_cap) + return -1; + memcpy(out + out_len, seg, sl); + out_len += sl; + } + if (!absolute && nparts == 0) { + if (out_cap < 2) + return -1; + out[0] = '.'; + out[1] = '\0'; + return 0; + } + out[out_len] = '\0'; + return 0; } /* ------------------------------------------------------------------ */ @@ -91,6 +1423,7 @@ int allowlist_path_is_under_workspace(const char *path, const char *workspace_ro { char resolved_path[PATH_MAX]; char resolved_ws[PATH_MAX]; + char collapsed[PATH_MAX]; const char *actual_ws; size_t wlen; if (!path || !workspace_root || !workspace_root[0]) return 0; @@ -100,23 +1433,14 @@ int allowlist_path_is_under_workspace(const char *path, const char *workspace_ro else actual_ws = workspace_root; wlen = strlen(actual_ws); - if (realpath(path, resolved_path)) { - /* Exact match or resolved path starts with resolved workspace + '/' */ - if (strncmp(resolved_path, actual_ws, wlen) == 0) { - if (resolved_path[wlen] == '\0' || resolved_path[wlen] == '/') return 1; - } + /* Kernel walk first so symlink/.. matches open(2), not lexical pop. */ + if (realpath(path, resolved_path)) + return resolved_is_under_workspace(resolved_path, actual_ws, wlen); + if (lexical_collapse_path(path, collapsed, sizeof(collapsed)) != 0) return 0; - } - /* Path does not exist on disk: check the lexical prefix against resolved workspace. */ - if (strncmp(path, actual_ws, wlen) == 0) { - if (path[wlen] == '\0' || path[wlen] == '/') return 1; - } - /* Also try against the original (unresolved) workspace root. */ - wlen = strlen(workspace_root); - if (strncmp(path, workspace_root, wlen) == 0) { - if (path[wlen] == '\0' || path[wlen] == '/') return 1; - } - return 0; + if (realpath(collapsed, resolved_path)) + return resolved_is_under_workspace(resolved_path, actual_ws, wlen); + return existing_ancestor_is_under_workspace(collapsed, actual_ws, wlen); } /* ------------------------------------------------------------------ */ @@ -129,7 +1453,6 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg const char *const *p; char ws_resolved[PATH_MAX]; const char *workspace_root = NULL; - int workspace_only = 0; char *cmd_copy = NULL; char *tok; char *saveptr; @@ -156,8 +1479,6 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg /* Phase 2: workspace path containment */ if (!cfg || !cfg->workspace_only || !cfg->workspace_path || !cfg->workspace_path[0]) return 0; - workspace_only = cfg->workspace_only; - (void)workspace_only; /* Resolve workspace root once */ if (!realpath(cfg->workspace_path, ws_resolved)) { /* Workspace path does not exist; use as-is. */ @@ -167,29 +1488,75 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg ws_resolved[n] = '\0'; } workspace_root = ws_resolved; - /* Tokenize the command and check each path-like token. */ + { + char *unquoted = dup_unquoted(cmd); + char *decoded; + int mutated; + int file_blocked = 0; + int dollar_blocked = 0; + + if (!unquoted) { + set_reason(reason_buf, reason_cap, "command blocked: out of memory", ""); + return 1; + } + decoded = dup_decode_hex_unicode(unquoted); + if (!decoded) { + free(unquoted); + set_reason(reason_buf, reason_cap, "command blocked: out of memory", ""); + return 1; + } + /* Identity/hex/octal/unicode fold before HOME/PWD keywords and `$`. */ + mutated = command_mutates_home_or_pwd(decoded); + if (!mutated) + file_blocked = block_if_file_url_escapes(decoded, workspace_root, + reason_buf, reason_cap); + if (!mutated && !file_blocked) + dollar_blocked = block_if_dollar_expansions_escape(decoded, workspace_root, + reason_buf, reason_cap); + free(decoded); + free(unquoted); + if (mutated) { + set_reason(reason_buf, reason_cap, + "command blocked: HOME/PWD assignment in command", ""); + fprintf(stderr, "allowlist: blocked HOME/PWD assignment in command\n"); + return 1; + } + if (file_blocked || dollar_blocked) + return 1; + } + if (block_if_encoded_slash_escapes(cmd, workspace_root, reason_buf, reason_cap)) + return 1; + if (block_if_embedded_paths_escape(cmd, workspace_root, reason_buf, reason_cap)) + return 1; cmd_copy = strdup(cmd); - if (!cmd_copy) return 0; /* fail-open on OOM */ + if (!cmd_copy) { + set_reason(reason_buf, reason_cap, "command blocked: out of memory", ""); + return 1; + } tok = strtok_r(cmd_copy, " \t\n;|&><", &saveptr); while (tok) { + tok = strip_surrounding_quotes(tok); if (has_path_chars(tok)) { - /* Expand a leading tilde naively */ char expanded[PATH_MAX]; - if (tok[0] == '~') { - const char *home = getenv("HOME"); - if (home) - snprintf(expanded, sizeof(expanded), "%s%s", home, tok + 1); - else - snprintf(expanded, sizeof(expanded), "%s", tok); - tok = expanded; - } - if (!allowlist_path_is_under_workspace(tok, workspace_root)) { + + if (expand_shell_path_token(tok, expanded, sizeof(expanded)) != 0) { + set_reason(reason_buf, reason_cap, + "command blocked: unresolved shell path expansion: ", tok); + fprintf(stderr, "allowlist: blocked unresolved shell path: %s\n", tok); + free(cmd_copy); + return 1; + } + if (!allowlist_path_is_under_workspace(expanded, workspace_root)) { set_reason(reason_buf, reason_cap, - "command blocked: path escapes workspace: ", tok); - fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", tok); + "command blocked: path escapes workspace: ", expanded); + fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", expanded); free(cmd_copy); return 1; } + } else if (block_if_relative_token_escapes(tok, workspace_root, reason_buf, + reason_cap)) { + free(cmd_copy); + return 1; } tok = strtok_r(NULL, " \t\n;|&><", &saveptr); } diff --git a/src/sandbox/allowlist.h b/src/sandbox/allowlist.h index 76a4e4f..4ca7311 100644 --- a/src/sandbox/allowlist.h +++ b/src/sandbox/allowlist.h @@ -9,11 +9,39 @@ * "mkfs", "dd of=/dev/", fork bombs, etc.). * 2. An optional workspace-containment check: if enabled via allowlist_config_t, * path-like tokens in the command are resolved with realpath(3) and rejected - * when they escape the declared workspace root. + * when they escape the declared workspace root. Quoted and embedded absolute + * paths (`cat '/etc/passwd'`, `python3 -c "open('/etc/passwd')"`) are scanned + * on the full command because whitespace tokenization misses them. `$HOME`, + * `${HOME}`, `$PWD`, `${PWD}`, and other `$...` forms are also scanned on the + * full command so glued expansions (`cat$IFS/etc/passwd`, `cat"$HOME/.bashrc"`, + * ANSI-C `$'\x20/...'`) cannot skip tok[0]. Known HOME/PWD forms are expanded + * (including a following `/...` suffix); other `$` forms fail closed. `file:` + * URLs are extracted even when `://` hides the path slash, then percent-decoded + * so `%2e%2e` / `%2f` cannot hide an escape. Missing directories before `..` + * are collapsed lexically so `/ws/nope/../../../tmp` cannot stop at `/ws`; + * `..` is not cancelled across a symlink. Embedded relative `../` is joined + * to the workspace before the same check. Encoded leading slashes (`\\x2f`, + * `\\57`, `\\u002f`, `\\u{2f}`, `\\x{2f}`, `\\o{57}`) are reconstructed as `/` or `../` plus the + * following path body. `\\N{` fail-closes without parsing Unicode names. + * In-command `HOME=` / `PWD=` / `export` / `unset` of those names fail closed + * even inside quotes (`eval 'PWD=;'`) or after a comma; `env -i`, `env -iu`, + * `env -u` / `--unset` HOME|PWD, POSIX `read HOME|PWD`, and `os.environ.pop`/`del`/ + * `clear` / `os.unsetenv` / `os.putenv` of those names fail closed. Quote-split `file:` schemes + * (`f'ile://...`, `'f'+'ile://...'`) and hex/unicode/octal-hidden schemes + * (`\\x66ile:`, `\\u0066ile:`, `\\146ile:`, `f\\ile:`) are joined before the URL check. + * POSIX `\\` + newline line continuation is collapsed before HOME/PWD and `file:` scans. + * `printf -v HOME|PWD` and `os.environ["HOME"]=` / `.update({"HOME":...})` fail closed. + * The same hex/unicode/octal/identity decode used for `file:` recovery runs before + * the HOME/PWD keyword gate and the `$` scan (`PW\\D=`, `\\unset`, `\\x24HOME`, + * `\\044`, `\\u0024`). `declare -n` targeting HOME|PWD and `exec -c` fail closed. + * `../` after `://` is still containment-checked so URL-disguised walks cannot skip the gate. * * Both checks are intentionally conservative and may produce false positives. - * They are a best-effort defence-in-depth layer; real isolation is provided by - * sandbox_exec() via kernel namespaces. + * They are a defence-in-depth layer. The kernel host-FS bound for the shell + * tool is Landlock in sandbox_exec() when a workspace path is set; namespaces + * fail closed if they cannot apply. workspace_only path scanning does not + * replace that bound (and is not a language interpreter: `chr(47)+` stays + * residual on this scanner). */ #ifndef SHELLCLAW_ALLOWLIST_H #define SHELLCLAW_ALLOWLIST_H @@ -57,8 +85,14 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg /** * Check whether @p path is contained inside @p workspace_root after resolving symlinks. * - * Uses realpath(3); if the path does not exist on disk, checks the string prefix - * against the canonicalised workspace root. + * Uses realpath(3) when the path exists (kernel symlink walk, including `..` + * after a symlink). If that fails, `..` / `.` are collapsed lexically without + * cancelling `..` across a symlink, so a missing directory before `..` cannot + * pin the walk at the workspace. If the collapsed path still does not exist, + * walks to the first existing ancestor and checks that resolved directory. + * + * Example: allowlist_path_is_under_workspace("/ws/../../tmp/x", "/ws") is 0 + * even when /tmp/x does not exist. * * @param path Absolute or relative path to test. * @param workspace_root Absolute path to the workspace root (already resolved). diff --git a/src/sandbox/sandbox.c b/src/sandbox/sandbox.c index 927ef3d..893b3ea 100644 --- a/src/sandbox/sandbox.c +++ b/src/sandbox/sandbox.c @@ -2,13 +2,11 @@ * @file sandbox.c * @brief Process sandbox: Linux namespace isolation, cgroups v2, timeout/kill. * - * Linux path: fork() + unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID) in the - * child, giving the shell and its children mount, network, and PID namespace - * isolation respectively. Does not mount(2), bind-mount, or pivot_root(2); Jetson - * GPU nodes (/dev/nvhost-*, /dev/nvgpu, /dev/nvmap) are never injected into the - * namespace — see docs/SECURITY.md. cgroups v2 memory.max and cpu.max limits are applied - * via the host cgroup hierarchy when available; the function degrades gracefully - * if the kernel does not expose writable cgroup controllers. + * Linux path: fork() then, in the child, unshare mount/network/PID namespaces + * (entering a user namespace first when unprivileged). Isolation failure is + * fail-closed (_exit SANDBOX_EXIT_NO_NS). When workspace_path is set, Landlock + * is the kernel FS bound (fail-closed SANDBOX_EXIT_NO_LL); the allowlist scanner + * is defense-in-depth only. cgroups v2 limits degrade if unavailable. * * Non-Linux path: plain fork() + execl(); a warning is emitted to stderr. */ @@ -32,6 +30,8 @@ #ifdef __linux__ #include #include +#include +#include #endif #define DEFAULT_TIMEOUT_MS 10000 @@ -39,6 +39,10 @@ #define DEFAULT_CGROUP_BASE "/sys/fs/cgroup" #define CGROUP_NAME_PREFIX "shellclaw_sb_" #define PIPE_POLL_SLICE_MS 500 +/* Child exits when isolation cannot be applied. Distinct from 124 (chdir), + * 125 (dup2), and 127 (exec). */ +#define SANDBOX_EXIT_NO_NS 123 +#define SANDBOX_EXIT_NO_LL 122 /* ------------------------------------------------------------------ */ /* cgroups v2 helpers (Linux only) */ @@ -140,6 +144,204 @@ static size_t drain_pipe(int fd, char *buf, size_t cap, int timeout_ms) /* Child setup before exec */ /* ------------------------------------------------------------------ */ +#ifdef __linux__ + +static int write_proc_str(const char *path, const char *s) +{ + int fd; + ssize_t n; + size_t len; + + fd = open(path, O_WRONLY | O_CLOEXEC); + if (fd < 0) return -1; + len = strlen(s); + n = write(fd, s, len); + close(fd); + return (n == (ssize_t)len) ? 0 : -1; +} + +static int enter_user_namespace(void) +{ + char map[64]; + uid_t uid = getuid(); + gid_t gid = getgid(); + + if (unshare(CLONE_NEWUSER) != 0) return -1; + if (write_proc_str("/proc/self/setgroups", "deny\n") != 0) return -1; + snprintf(map, sizeof map, "0 %u 1\n", (unsigned)uid); + if (write_proc_str("/proc/self/uid_map", map) != 0) return -1; + snprintf(map, sizeof map, "0 %u 1\n", (unsigned)gid); + if (write_proc_str("/proc/self/gid_map", map) != 0) return -1; + return 0; +} + +static int unshare_isolation_namespaces(void) +{ + return unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID); +} + +static void isolate_or_exit(void) +{ + if (unshare_isolation_namespaces() == 0) return; + if (enter_user_namespace() != 0) _exit(SANDBOX_EXIT_NO_NS); + if (unshare_isolation_namespaces() != 0) _exit(SANDBOX_EXIT_NO_NS); +} + +static __u64 landlock_abi1_fs_rights(void) +{ + return LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_CHAR | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_BLOCK | + LANDLOCK_ACCESS_FS_MAKE_SYM; +} + +/** + * Probe Landlock ABI and mask handled FS rights the running kernel understands. + * Passing REFER (ABI 2) or TRUNCATE (ABI 3) on ABI 1 makes create_ruleset fail. + */ +static int landlock_handled_fs(__u64 *handled_out) +{ + int abi; + __u64 handled; + + if (!handled_out) return -1; + abi = (int)syscall(__NR_landlock_create_ruleset, NULL, 0, + LANDLOCK_CREATE_RULESET_VERSION); + if (abi < 1) return -1; + handled = landlock_abi1_fs_rights(); +#ifdef LANDLOCK_ACCESS_FS_REFER + if (abi >= 2) + handled |= LANDLOCK_ACCESS_FS_REFER; +#endif +#ifdef LANDLOCK_ACCESS_FS_TRUNCATE + if (abi >= 3) + handled |= LANDLOCK_ACCESS_FS_TRUNCATE; +#endif + *handled_out = handled; + return 0; +} + +static int landlock_add_path(int ruleset_fd, const char *path, __u64 dir_access, + __u64 file_access) +{ + int pfd; + struct stat st; + struct landlock_path_beneath_attr pb; + long rc; + + pfd = open(path, O_PATH | O_CLOEXEC); + if (pfd < 0) + return 0; + memset(&pb, 0, sizeof(pb)); + pb.parent_fd = pfd; + if (fstat(pfd, &st) == 0 && S_ISDIR(st.st_mode)) + pb.allowed_access = dir_access; + else + pb.allowed_access = file_access; + rc = syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &pb, 0); + close(pfd); + (void)rc; + return 0; +} + +static int landlock_add_workspace(int ruleset_fd, const char *workspace, __u64 access) +{ + int ws_fd; + struct landlock_path_beneath_attr pb; + long rc; + + ws_fd = open(workspace, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (ws_fd < 0) + return -1; + memset(&pb, 0, sizeof(pb)); + pb.allowed_access = access; + pb.parent_fd = ws_fd; + rc = syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &pb, 0); + close(ws_fd); + return (rc == 0) ? 0 : -1; +} + +/** + * Landlock FS bound: full access under @p workspace, read/exec for paths + * needed by /bin/sh and interpreters. Fail closed — do not exec on the host + * tree if the ruleset cannot be applied. + */ +static int landlock_restrict_to_workspace(const char *workspace) +{ + static const char *const RO_PATHS[] = { + "/bin", "/usr", "/usr/local", "/lib", "/lib64", "/lib32", + "/etc/ld.so.cache", "/etc/ld.so.conf", "/etc/ld.so.conf.d", + "/etc/ssl", "/etc/nsswitch.conf", "/etc/hosts", "/etc/resolv.conf", + "/dev/null", "/dev/zero", "/dev/urandom", "/dev/tty", + "/proc", + NULL + }; + __u64 handled; + __u64 workspace_access; + __u64 ro_dir; + __u64 ro_file; + struct landlock_ruleset_attr attr; + int ruleset_fd; + size_t i; + long rc; + + if (!workspace || !workspace[0]) + return 0; + if (landlock_handled_fs(&handled) != 0) + return -1; + workspace_access = (LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SYM | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_SOCK) & handled; +#ifdef LANDLOCK_ACCESS_FS_REFER + workspace_access |= (LANDLOCK_ACCESS_FS_REFER & handled); +#endif +#ifdef LANDLOCK_ACCESS_FS_TRUNCATE + workspace_access |= (LANDLOCK_ACCESS_FS_TRUNCATE & handled); +#endif + ro_dir = (LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR) & handled; + ro_file = (LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE) & handled; + memset(&attr, 0, sizeof(attr)); + attr.handled_access_fs = handled; + /* ABI-1 field size so older kernels do not return E2BIG. */ + ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &attr, + sizeof(attr.handled_access_fs), 0); + if (ruleset_fd < 0) + return -1; + if (landlock_add_workspace(ruleset_fd, workspace, workspace_access) != 0) { + close(ruleset_fd); + return -1; + } + for (i = 0; RO_PATHS[i]; i++) + (void)landlock_add_path(ruleset_fd, RO_PATHS[i], ro_dir, ro_file); + rc = syscall(__NR_landlock_restrict_self, ruleset_fd, 0); + close(ruleset_fd); + return (rc == 0) ? 0 : -1; +} + +#endif /* __linux__ */ + static void setup_child_process(int pipe_wr, const char *workspace) { close(STDIN_FILENO); @@ -148,12 +350,16 @@ static void setup_child_process(int pipe_wr, const char *workspace) close(pipe_wr); #ifdef __linux__ setsid(); - /* Namespace isolation: mount + network + PID (children of this process). */ - unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID); + isolate_or_exit(); prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); #endif - if (workspace && workspace[0]) + if (workspace && workspace[0]) { +#ifdef __linux__ + if (landlock_restrict_to_workspace(workspace) != 0) + _exit(SANDBOX_EXIT_NO_LL); +#endif if (chdir(workspace) != 0) _exit(124); + } } /* ------------------------------------------------------------------ */ @@ -194,6 +400,7 @@ int sandbox_exec(const char *cmd, char *out, size_t out_cap, pid_t pid; size_t total; int timed_out = 0; + int child_st = 0; const char *workspace = cfg ? cfg->workspace_path : NULL; int used_cgroup = 0; #ifdef __linux__ @@ -249,15 +456,34 @@ int sandbox_exec(const char *cmd, char *out, size_t out_cap, #endif total = drain_pipe(pipefd[0], out, out_cap, timeout_ms); close(pipefd[0]); - timed_out = reap_child(pid, NULL); + timed_out = reap_child(pid, &child_st); if (timed_out && total < out_cap - 40) snprintf(out + total, out_cap - total, "\n[Sandbox: command timed out after %d ms]", timeout_ms); #ifdef __linux__ - if (used_cgroup) - cgroup_remove(cgroup_base, cgroup_name); + { + int isolation_failed = 0; + int exit_st = 0; + + if (!timed_out && WIFEXITED(child_st)) { + exit_st = WEXITSTATUS(child_st); + if (exit_st == SANDBOX_EXIT_NO_NS || exit_st == SANDBOX_EXIT_NO_LL) + isolation_failed = 1; + } + if (isolation_failed) { + if (exit_st == SANDBOX_EXIT_NO_LL) + snprintf(out, out_cap, "sandbox: Landlock filesystem bound failed"); + else + snprintf(out, out_cap, "sandbox: namespace isolation failed"); + } + if (used_cgroup) + cgroup_remove(cgroup_base, cgroup_name); + if (isolation_failed) + return -1; + } #else (void)used_cgroup; + (void)child_st; #endif return 0; } diff --git a/src/sandbox/sandbox.h b/src/sandbox/sandbox.h index 5d255d7..782c8e2 100644 --- a/src/sandbox/sandbox.h +++ b/src/sandbox/sandbox.h @@ -1,10 +1,13 @@ /** * @file sandbox.h - * @brief Process sandbox API: isolated execution with namespaces, timeout, and cgroups v2. + * @brief Process sandbox API: namespaces, Landlock FS bound, timeout, cgroups v2. * - * On Linux, sandbox_exec uses clone(2) with PID/mount/network namespace isolation, - * optional cgroups v2 resource limits, and a hard timeout with SIGKILL. - * On other platforms (macOS, BSDs) it falls back to a plain fork+exec and logs a warning. + * On Linux, sandbox_exec forks then unshares PID/mount/network namespaces + * (entering a user namespace when unprivileged). Isolation failure is + * fail-closed. When a workspace path is set, a Landlock ruleset is the kernel + * filesystem bound (fail-closed if it cannot apply). Optional cgroups v2 + * resource limits and a hard timeout with SIGKILL. On other platforms + * (macOS, BSDs) it falls back to a plain fork+exec and logs a warning. */ #ifndef SHELLCLAW_SANDBOX_H #define SHELLCLAW_SANDBOX_H @@ -42,9 +45,14 @@ typedef struct sandbox_config { /** * Execute @p cmd inside an isolated child process and capture output. * - * On Linux, clones with CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET. - * Applies cgroups v2 limits when available; degrades gracefully if not. - * Kills the child with SIGKILL if @p timeout_ms elapses before exit. + * On Linux, enters a user namespace when needed, then unshares + * CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET. If those namespaces cannot be + * applied, returns -1 (fail-closed). When @p cfg->workspace_path is set, + * applies a Landlock ruleset that denies host filesystem reads/writes outside + * the workspace (blocking symlink and interpreter path escapes such as + * `chr(47)+`); Landlock setup failure also returns -1. Applies cgroups v2 + * limits when available. Kills the child with SIGKILL if @p timeout_ms + * elapses before exit. * * On non-Linux platforms the function executes the command via fork()+exec() * without namespace isolation and emits a warning to stderr. @@ -55,7 +63,7 @@ typedef struct sandbox_config { * @param timeout_ms Maximum wall-clock milliseconds before SIGKILL. 0 = default (10 000 ms). * @param cfg Optional sandbox configuration. NULL = use built-in defaults. * @return 0 on success (command ran; check output for exit status text), - * -1 on system error (pipe/fork/clone failure). + * -1 on system error (pipe/fork/clone/isolation failure). */ int sandbox_exec(const char *cmd, char *out, size_t out_cap, int timeout_ms, const sandbox_config_t *cfg); diff --git a/src/tools/shell.c b/src/tools/shell.c index 89f125d..c2f7b92 100644 --- a/src/tools/shell.c +++ b/src/tools/shell.c @@ -3,8 +3,9 @@ * @brief Shell tool: execute commands, with optional sandbox isolation. * * When config_sandbox_enabled() is true, commands are checked via - * allowlist_check_shell_command() and executed inside sandbox_exec() (namespace - * isolation + cgroups v2 where available). + * allowlist_check_shell_command() and executed inside sandbox_exec() + * (namespaces + Landlock workspace bound + cgroups v2 where available). + * Isolation failure is fail-closed. * * When the sandbox is disabled (default), a best-effort substring blocklist * is applied and the command runs via fork()/execl() with the same pipe-and- diff --git a/tests/test_allowlist.c b/tests/test_allowlist.c index d87f768..d38cdc6 100644 --- a/tests/test_allowlist.c +++ b/tests/test_allowlist.c @@ -161,6 +161,206 @@ static int test_workspace_only_allows_inside_path(void) return 0; } +static int test_workspace_only_blocks_quoted_path(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat '/etc/passwd'", &cfg, reason, sizeof(reason)) == 1); + ASSERT(allowlist_check_shell_command("cat \"/etc/passwd\"", &cfg, reason, sizeof(reason)) == 1); + return 0; +} + +static int test_workspace_only_blocks_embedded_path_in_python(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('/etc/passwd').read()\"", &cfg, reason, sizeof(reason)) == 1); + ASSERT(strstr(reason, "passwd") != NULL || strstr(reason, "workspace") != NULL); + return 0; +} + +static int test_workspace_only_allows_relative_and_url_slashes(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo 3/4", &cfg, reason, sizeof(reason)) == 0); + ASSERT(allowlist_check_shell_command("ls src/foo", &cfg, reason, sizeof(reason)) == 0); + ASSERT(allowlist_check_shell_command( + "curl https://example.com/api", &cfg, reason, sizeof(reason)) == 0); + return 0; +} + +static int test_workspace_only_blocks_file_url(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat file:///etc/passwd", &cfg, reason, sizeof(reason)) == 1); + return 0; +} + +/** + * Shell expands `$HOME` / `${HOME}` / `$PWD` before open(2). Tokens never start + * with `/` `~` `.`, so the old has_path_chars gate skipped them. + */ +static int test_workspace_only_blocks_home_env_expansion(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_home_XXXXXX"; + char *dir; + const char *home = getenv("HOME"); + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_home_env_expansion: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat $HOME/.shellclaw/auth_tokens.json", + &cfg, reason, sizeof(reason)) == 1); + ASSERT(strstr(reason, "escapes workspace") != NULL || + strstr(reason, "unresolved") != NULL); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat ${HOME}/.shellclaw/auth_tokens.json", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat $PWD/../outside.txt", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat $'\\x2fetc\\x2fpasswd'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat \"$HOME/.shellclaw/auth_tokens.json\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat notes.txt", &cfg, reason, sizeof(reason)) == 0); + if (home && strcmp(home, dir) == 0) { + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("ls $HOME", &cfg, reason, sizeof(reason)) == 0); + } + rmdir(dir); + return 0; +} + +/** + * Glued expansions never start a strtok token with `$` or `/`, so the host-FS + * gate must scan `$` on the full command. `$PWD/../outside` must use the + * process PWD, not only a mkdtemp workspace that happens to differ from PWD. + */ +static int test_workspace_only_blocks_glued_shell_expansions(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_glue_XXXXXX"; + char *dir; + char *old_pwd; + char pwd_copy[256]; + char outside[512]; + int rc; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_glued_shell_expansions: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat$IFS/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat${IFS}/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat$'\\x20/etc/passwd'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('$HOME/.shellclaw/auth_tokens.json')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat\"$HOME/.bashrc\"", + &cfg, reason, sizeof(reason)) == 1); + + { + const char *old_home = getenv("HOME"); + char home_copy[256]; + + home_copy[0] = '\0'; + if (old_home) { + if (strlen(old_home) >= sizeof(home_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_glued_shell_expansions: HOME too long\n"); + return 1; + } + memcpy(home_copy, old_home, strlen(old_home) + 1); + } + if (setenv("HOME", dir, 1) != 0) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_glued_shell_expansions: setenv HOME failed\n"); + return 1; + } + reason[0] = '\0'; + rc = allowlist_check_shell_command("ls ${HOME}", &cfg, reason, sizeof(reason)); + if (rc == 0) + rc = allowlist_check_shell_command("ls $HOME", &cfg, reason, sizeof(reason)); + if (home_copy[0]) + (void)setenv("HOME", home_copy, 1); + else + (void)unsetenv("HOME"); + ASSERT(rc == 0); + } + + old_pwd = getenv("PWD"); + pwd_copy[0] = '\0'; + if (old_pwd) { + if (strlen(old_pwd) >= sizeof(pwd_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_glued_shell_expansions: PWD too long\n"); + return 1; + } + memcpy(pwd_copy, old_pwd, strlen(old_pwd) + 1); + } + if (setenv("PWD", dir, 1) != 0) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_glued_shell_expansions: setenv PWD failed\n"); + return 1; + } + snprintf(outside, sizeof(outside), "%s/../sc_al_pwd_stolen_%d.txt", dir, (int)getpid()); + reason[0] = '\0'; + rc = allowlist_check_shell_command("cat $PWD/../sc_al_pwd_stolen.txt", + &cfg, reason, sizeof(reason)); + if (pwd_copy[0]) + (void)setenv("PWD", pwd_copy, 1); + else + (void)unsetenv("PWD"); + ASSERT(rc == 1); + ASSERT(allowlist_path_is_under_workspace(outside, dir) == 0); + + rmdir(dir); + return 0; +} + /* ------------------------------------------------------------------ */ /* Symlink escape test (5.4) */ /* ------------------------------------------------------------------ */ @@ -201,6 +401,1095 @@ static int test_symlink_escape(void) #endif } +/* ------------------------------------------------------------------ */ +/* Non-existent path with .. must not escape via lexical prefix */ +/* ------------------------------------------------------------------ */ + +static int test_dotdot_escape_nonexistent_destination(void) +{ + char workspace[] = "/tmp/sc_al_ws_XXXXXX"; + char *ws; + char new_file[256]; + char escape_path[256]; + char cmd[640]; + allowlist_config_t cfg; + char reason[256]; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_dotdot_escape_nonexistent_destination: mkdtemp failed\n"); + return 1; + } + + /* realpath() fails for a new file; the workspace ancestor must still allow it. */ + snprintf(new_file, sizeof(new_file), "%s/brand_new.txt", ws); + ASSERT(allowlist_path_is_under_workspace(new_file, ws) == 1); + + /* + * Destination does not exist, so realpath() fails. A lexical prefix check + * treats workspace/../../tmp/... as inside the workspace. + */ + snprintf(escape_path, sizeof(escape_path), + "%s/../../tmp/sc_al_stolen_%d", ws, (int)getpid()); + ASSERT(allowlist_path_is_under_workspace(escape_path, ws) == 0); + + cfg.workspace_path = ws; + cfg.workspace_only = 1; + reason[0] = '\0'; + snprintf(cmd, sizeof(cmd), "cp %s/memory.db %s", ws, escape_path); + ASSERT(allowlist_check_shell_command(cmd, &cfg, reason, sizeof(reason)) == 1); + + rmdir(ws); + return 0; +} + +/** + * A missing directory *before* `..` must not stop the ancestor walk at the + * workspace. `/ws/nope/../../../tmp/stolen` lexically leaves `/ws`. + */ +static int test_dotdot_escape_missing_component_before_dotdot(void) +{ + char workspace[] = "/tmp/sc_al_ws_XXXXXX"; + char *ws; + char escape_path[256]; + char cmd[640]; + allowlist_config_t cfg; + char reason[256]; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_dotdot_escape_missing_component_before_dotdot: mkdtemp failed\n"); + return 1; + } + snprintf(escape_path, sizeof(escape_path), + "%s/nope/../../../tmp/sc_al_stolen2_%d", ws, (int)getpid()); + ASSERT(allowlist_path_is_under_workspace(escape_path, ws) == 0); + + cfg.workspace_path = ws; + cfg.workspace_only = 1; + reason[0] = '\0'; + snprintf(cmd, sizeof(cmd), "cp %s/memory.db %s", ws, escape_path); + ASSERT(allowlist_check_shell_command(cmd, &cfg, reason, sizeof(reason)) == 1); + + rmdir(ws); + return 0; +} + +static int test_workspace_only_blocks_file_url_variants(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file:/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file://localhost/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file://etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("python3 -c \"urllib.request.urlopen('file://localhost/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl https://example.com/api", + &cfg, reason, sizeof(reason)) == 0); + return 0; +} + +static int test_workspace_only_blocks_embedded_relative_dotdot(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_rel_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_embedded_relative_dotdot: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("python3 -c \"open('../secret')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("python3 -c \"open('foo/../../etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("python3 -c \"open('notes.txt')\"", + &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +/** + * Interpreter hex/octal/unicode slash escapes decode to `/` before a path + * body. A later literal `/` (`etc/passwd`) is not a path start, so the + * host-FS gate must reconstruct the encoded leading slash. This is not + * Python `chr(47)+` concatenation (no slash encoding in the command text). + */ +static int test_workspace_only_blocks_encoded_leading_slash(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('\\x2fetc/passwd')\"", &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "node -e \"require('fs').readFileSync('\\x2fetc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('\\57etc/passwd')\"", &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "node -e \"require('fs').readFileSync('\\u002fetc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('notes.txt')\"", &cfg, reason, sizeof(reason)) == 0); + return 0; +} + +/** + * `$PWD` / `$HOME` expansion must not trust process getenv when the command + * assigns, exports, or unsets those names. Process PWD/HOME are set to the + * workspace so a getenv-only check would incorrectly allow `$PWD/etc/passwd`. + */ +static int test_workspace_only_blocks_home_pwd_assignment(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_asgn_XXXXXX"; + char *dir; + const char *old_pwd; + const char *old_home; + char pwd_copy[256]; + char home_copy[256]; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_home_pwd_assignment: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + + old_pwd = getenv("PWD"); + pwd_copy[0] = '\0'; + if (old_pwd) { + if (strlen(old_pwd) >= sizeof(pwd_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_home_pwd_assignment: PWD too long\n"); + return 1; + } + memcpy(pwd_copy, old_pwd, strlen(old_pwd) + 1); + } + old_home = getenv("HOME"); + home_copy[0] = '\0'; + if (old_home) { + if (strlen(old_home) >= sizeof(home_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_home_pwd_assignment: HOME too long\n"); + return 1; + } + memcpy(home_copy, old_home, strlen(old_home) + 1); + } + if (setenv("PWD", dir, 1) != 0 || setenv("HOME", dir, 1) != 0) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_home_pwd_assignment: setenv failed\n"); + return 1; + } + + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("PWD=; cat $PWD/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("HOME=; cat $HOME/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("export PWD=; cat $PWD/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("unset HOME; cat $HOME/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + + if (pwd_copy[0]) + (void)setenv("PWD", pwd_copy, 1); + else + (void)unsetenv("PWD"); + if (home_copy[0]) + (void)setenv("HOME", home_copy, 1); + else + (void)unsetenv("HOME"); + rmdir(dir); + return 0; +} + +/** + * Kernel open(2) walks a symlink before `..`. Lexical collapse must not + * treat `workspace/out/../etc/passwd` as `workspace/etc/passwd` when `out` + * is a directory symlink to `/`. + */ +static int test_workspace_only_blocks_symlink_dotdot(void) +{ +#ifdef __linux__ + char workspace[] = "/tmp/sc_al_sydd_XXXXXX"; + char link_path[256]; + char escape_path[512]; + char cmd[640]; + char *ws; + allowlist_config_t cfg; + char reason[256]; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_only_blocks_symlink_dotdot: mkdtemp failed\n"); + return 1; + } + snprintf(link_path, sizeof(link_path), "%s/out", ws); + if (symlink("/", link_path) != 0) { + rmdir(ws); + fprintf(stderr, "test_workspace_only_blocks_symlink_dotdot: symlink failed\n"); + return 1; + } + snprintf(escape_path, sizeof(escape_path), "%s/../etc/passwd", link_path); + ASSERT(allowlist_path_is_under_workspace(escape_path, ws) == 0); + + cfg.workspace_path = ws; + cfg.workspace_only = 1; + reason[0] = '\0'; + snprintf(cmd, sizeof(cmd), "cat %s/../etc/passwd", link_path); + ASSERT(allowlist_check_shell_command(cmd, &cfg, reason, sizeof(reason)) == 1); + + unlink(link_path); + rmdir(ws); + return 0; +#else + fprintf(stderr, "test_workspace_only_blocks_symlink_dotdot: skipped (Linux-specific)\n"); + return 0; +#endif +} + +/** + * `file:` URLs percent-decode before the workspace check. Encoded `..` + * (`%2e%2e`) and `%2f` must not hide an escape. `https://` stays allowed. + */ +static int test_workspace_only_blocks_percent_encoded_file_url(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_pct_XXXXXX"; + char *dir; + char cmd[768]; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_percent_encoded_file_url: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + snprintf(cmd, sizeof(cmd), + "curl file://%s/%%2e%%2e/%%2e%%2e/%%2e%%2e/etc/passwd", dir); + ASSERT(allowlist_check_shell_command(cmd, &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file://localhost/%2fetc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl https://example.com/api", + &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +/** + * Quote immediately before `PWD=` / nested `eval` / `sh -c` must still + * fail closed. Process PWD/HOME are the workspace so getenv-only expansion + * would allow `$PWD/etc/passwd`. + */ +static int test_workspace_only_blocks_quoted_home_pwd_assignment(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_qasgn_XXXXXX"; + char *dir; + const char *old_pwd; + const char *old_home; + char pwd_copy[256]; + char home_copy[256]; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_quoted_home_pwd_assignment: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + + old_pwd = getenv("PWD"); + pwd_copy[0] = '\0'; + if (old_pwd) { + if (strlen(old_pwd) >= sizeof(pwd_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_quoted_home_pwd_assignment: PWD too long\n"); + return 1; + } + memcpy(pwd_copy, old_pwd, strlen(old_pwd) + 1); + } + old_home = getenv("HOME"); + home_copy[0] = '\0'; + if (old_home) { + if (strlen(old_home) >= sizeof(home_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_quoted_home_pwd_assignment: HOME too long\n"); + return 1; + } + memcpy(home_copy, old_home, strlen(old_home) + 1); + } + if (setenv("PWD", dir, 1) != 0 || setenv("HOME", dir, 1) != 0) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_quoted_home_pwd_assignment: setenv failed\n"); + return 1; + } + + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("eval 'PWD=; cat $PWD/etc/passwd'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("sh -c 'PWD=; cat $PWD/etc/passwd'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("eval 'HOME=; cat $HOME/etc/passwd'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("sh -c \"unset HOME; cat $HOME/etc/passwd\"", + &cfg, reason, sizeof(reason)) == 1); + + if (pwd_copy[0]) + (void)setenv("PWD", pwd_copy, 1); + else + (void)unsetenv("PWD"); + if (home_copy[0]) + (void)setenv("HOME", home_copy, 1); + else + (void)unsetenv("HOME"); + rmdir(dir); + return 0; +} + +/** + * Quotes (and trivial quote-concat) must not split the `file:` scheme. + * `https://` stays allowed. + */ +static int test_workspace_only_blocks_quote_split_file_url(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl f'ile://localhost/etc/passwd'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl f\"ile:/etc/passwd\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"urllib.request.urlopen('f'+'ile://localhost/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl https://example.com/api", + &cfg, reason, sizeof(reason)) == 0); + return 0; +} + +/** + * Encoded `.` (`\x2e` / `\56` / `\u002e`) forms `../`, and extra slash + * encodings (`\u{2f}`, `\N{SOLIDUS}`) decode to `/`. `\N{` fail-closes + * without parsing Unicode names. Not `chr(47)+` concatenation. + */ +static int test_workspace_only_blocks_encoded_dot_and_named_slash(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_edot_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_encoded_dot_and_named_slash: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('\\x2e\\x2e/secret')\"", &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('\\56\\56/secret')\"", &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "node -e \"require('fs').readFileSync('\\u{2f}etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('\\N{SOLIDUS}etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('notes.txt')\"", &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +/** + * `is_inside_url` must not hide `../` after `://`. Real https fetches without + * a `..` walk stay allowed. + */ +static int test_workspace_only_blocks_url_disguised_dotdot(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_url_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_url_disguised_dotdot: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "curl https://example.com/../../../../etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "cat https://example.com/../../../../etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl https://example.com/api", + &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +/** + * Perl braced hex `\x{2f}` / `\x{2e}` must reconstruct like `\u{2f}`. + * Not `chr(47)+` concatenation. + */ +static int test_workspace_only_blocks_perl_braced_hex(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_perl_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_perl_braced_hex: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "perl -e 'open F, \"\\x{2f}etc/passwd\"'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "perl -e 'open F, \"\\x{2e}\\x{2e}/secret\"'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "perl -e 'open F, \"notes.txt\"'", + &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +/** + * HOME/PWD mutation after comma (argv lists) and env replacement without + * `HOME=` (`env -i`, `env -u HOME`, `os.environ.pop`) must fail closed. + */ +static int test_workspace_only_blocks_env_replace_and_comma_assign(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_envr_XXXXXX"; + char *dir; + const char *old_pwd; + const char *old_home; + char pwd_copy[256]; + char home_copy[256]; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_env_replace_and_comma_assign: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + + old_pwd = getenv("PWD"); + pwd_copy[0] = '\0'; + if (old_pwd) { + if (strlen(old_pwd) >= sizeof(pwd_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_env_replace_and_comma_assign: PWD too long\n"); + return 1; + } + memcpy(pwd_copy, old_pwd, strlen(old_pwd) + 1); + } + old_home = getenv("HOME"); + home_copy[0] = '\0'; + if (old_home) { + if (strlen(old_home) >= sizeof(home_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_env_replace_and_comma_assign: HOME too long\n"); + return 1; + } + memcpy(home_copy, old_home, strlen(old_home) + 1); + } + if (setenv("PWD", dir, 1) != 0 || setenv("HOME", dir, 1) != 0) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_env_replace_and_comma_assign: setenv failed\n"); + return 1; + } + + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("env -i cat $PWD/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("env -u HOME cat $HOME/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"os.environ.pop('HOME'); open('$HOME/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"f(a,HOME=''); open('$HOME/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + + if (pwd_copy[0]) + (void)setenv("PWD", pwd_copy, 1); + else + (void)unsetenv("PWD"); + if (home_copy[0]) + (void)setenv("HOME", home_copy, 1); + else + (void)unsetenv("HOME"); + rmdir(dir); + return 0; +} + +/** + * POSIX `read HOME|PWD`, GNU `env --unset`, clustered `env -iu`, and + * Python `os.unsetenv` / `os.putenv` / `os.environ.clear` must fail closed + * so process getenv(PWD/HOME)=workspace cannot allow `$PWD/etc/passwd`. + */ +static int test_workspace_only_blocks_read_unset_and_clustered_env(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_rdun_XXXXXX"; + char *dir; + const char *old_pwd; + const char *old_home; + char pwd_copy[256]; + char home_copy[256]; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_read_unset_and_clustered_env: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + + old_pwd = getenv("PWD"); + pwd_copy[0] = '\0'; + if (old_pwd) { + if (strlen(old_pwd) >= sizeof(pwd_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_read_unset_and_clustered_env: PWD too long\n"); + return 1; + } + memcpy(pwd_copy, old_pwd, strlen(old_pwd) + 1); + } + old_home = getenv("HOME"); + home_copy[0] = '\0'; + if (old_home) { + if (strlen(old_home) >= sizeof(home_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_read_unset_and_clustered_env: HOME too long\n"); + return 1; + } + memcpy(home_copy, old_home, strlen(old_home) + 1); + } + if (setenv("PWD", dir, 1) != 0 || setenv("HOME", dir, 1) != 0) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_read_unset_and_clustered_env: setenv failed\n"); + return 1; + } + + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("read PWD; cat $PWD/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("read HOME; cat $HOME/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("env --unset=HOME cat $HOME/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("env --unset HOME cat $HOME/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("env -iu HOME cat $HOME/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"os.unsetenv('HOME'); open('$HOME/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"os.putenv('HOME',''); open('$HOME/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"os.environ.clear(); open('$PWD/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + + if (pwd_copy[0]) + (void)setenv("PWD", pwd_copy, 1); + else + (void)unsetenv("PWD"); + if (home_copy[0]) + (void)setenv("HOME", home_copy, 1); + else + (void)unsetenv("HOME"); + rmdir(dir); + return 0; +} + +/** + * Perl braced octal `\o{57}` / `\o{057}` must reconstruct like `\x{2f}`. + * Not `chr(47)+` concatenation. + */ +static int test_workspace_only_blocks_perl_braced_octal(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_poct_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_perl_braced_octal: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "perl -e 'open F, \"\\o{57}etc/passwd\"'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "perl -e 'open F, \"\\o{057}etc/passwd\"'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "perl -e 'open F, \"\\o{056}\\o{056}/secret\"'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "perl -e 'open F, \"notes.txt\"'", + &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +/** + * Encoded leading `f` (`\\x66` / `\\u0066`) must not hide a `file:` URL. + * Real `https://` fetches stay allowed. + */ +static int test_workspace_only_blocks_encoded_file_scheme(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_xfle_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_encoded_file_scheme: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "curl \\x66ile://localhost/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "curl \\u0066ile://localhost/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"urllib.request.urlopen('\\x66ile:/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl https://example.com/api", + &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +/** + * Octal scheme bytes (`\\146` / `\\072`) and shell identity `f\\ile:` must + * not hide a `file:` URL. Real `https://` fetches stay allowed. + */ +static int test_workspace_only_blocks_octal_and_identity_file_scheme(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_octf_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_octal_and_identity_file_scheme: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"urllib.request.urlopen('\\146ile:/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "curl f\\ile://localhost/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "curl file\\072/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl https://example.com/api", + &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +/** + * POSIX `\\` + newline (optional CR) line continuation must not split + * `file:` or `PWD=` so dash `/bin/sh -c` cannot skip the gates. + */ +static int test_workspace_only_blocks_backslash_newline_continuation(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_bscn_XXXXXX"; + char *dir; + const char *old_pwd; + char pwd_copy[256]; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_backslash_newline_continuation: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + + old_pwd = getenv("PWD"); + pwd_copy[0] = '\0'; + if (old_pwd) { + if (strlen(old_pwd) >= sizeof(pwd_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_backslash_newline_continuation: PWD too long\n"); + return 1; + } + memcpy(pwd_copy, old_pwd, strlen(old_pwd) + 1); + } + if (setenv("PWD", dir, 1) != 0) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_backslash_newline_continuation: setenv failed\n"); + return 1; + } + + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl f\\\nile:/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl f\\\r\nile:/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("PW\\\nD=; cat $PWD/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + + if (pwd_copy[0]) + (void)setenv("PWD", pwd_copy, 1); + else + (void)unsetenv("PWD"); + rmdir(dir); + return 0; +} + +/** + * `printf -v PWD` and `os.environ["HOME"]=` / `.update({"HOME":...})` must + * fail closed. `$PWD/etc/passwd` and `~/etc/passwd` after them must deny. + */ +static int test_workspace_only_blocks_printf_v_and_environ_index(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_pfv_XXXXXX"; + char *dir; + const char *old_pwd; + const char *old_home; + char pwd_copy[256]; + char home_copy[256]; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_printf_v_and_environ_index: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + + old_pwd = getenv("PWD"); + pwd_copy[0] = '\0'; + if (old_pwd) { + if (strlen(old_pwd) >= sizeof(pwd_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_printf_v_and_environ_index: PWD too long\n"); + return 1; + } + memcpy(pwd_copy, old_pwd, strlen(old_pwd) + 1); + } + old_home = getenv("HOME"); + home_copy[0] = '\0'; + if (old_home) { + if (strlen(old_home) >= sizeof(home_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_printf_v_and_environ_index: HOME too long\n"); + return 1; + } + memcpy(home_copy, old_home, strlen(old_home) + 1); + } + if (setenv("PWD", dir, 1) != 0 || setenv("HOME", dir, 1) != 0) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_printf_v_and_environ_index: setenv failed\n"); + return 1; + } + + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("printf -v PWD x; cat $PWD/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"os.environ['HOME']=''; open('$HOME/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"os.environ['HOME']=''; open('~/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"os.environ.update({'HOME': ''}); open('$HOME/etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + + if (pwd_copy[0]) + (void)setenv("PWD", pwd_copy, 1); + else + (void)unsetenv("PWD"); + if (home_copy[0]) + (void)setenv("HOME", home_copy, 1); + else + (void)unsetenv("HOME"); + rmdir(dir); + return 0; +} + +/** + * Identity-escaped HOME/PWD keywords (`PW\\D=`, `\\unset`, `\\env -i`), + * bash `declare -n` namerefs, and `exec -c` must fail closed before getenv. + */ +static int test_workspace_only_blocks_identity_and_nameref_env(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_idnr_XXXXXX"; + char *dir; + const char *old_pwd; + const char *old_home; + char pwd_copy[256]; + char home_copy[256]; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_identity_and_nameref_env: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + + old_pwd = getenv("PWD"); + pwd_copy[0] = '\0'; + if (old_pwd) { + if (strlen(old_pwd) >= sizeof(pwd_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_identity_and_nameref_env: PWD too long\n"); + return 1; + } + memcpy(pwd_copy, old_pwd, strlen(old_pwd) + 1); + } + old_home = getenv("HOME"); + home_copy[0] = '\0'; + if (old_home) { + if (strlen(old_home) >= sizeof(home_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_identity_and_nameref_env: HOME too long\n"); + return 1; + } + memcpy(home_copy, old_home, strlen(old_home) + 1); + } + if (setenv("PWD", dir, 1) != 0 || setenv("HOME", dir, 1) != 0) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_identity_and_nameref_env: setenv failed\n"); + return 1; + } + + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("export PW\\D=; cat $PWD/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("\\unset HOME; cat $HOME/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "\\env -i sh -c 'cat $HOME/etc/passwd'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "bash -c 'declare -n x=PWD; x=; cat $PWD/etc/passwd'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "bash -c 'exec -c sh -c \"cat $HOME/etc/passwd\"'", + &cfg, reason, sizeof(reason)) == 1); + + if (pwd_copy[0]) + (void)setenv("PWD", pwd_copy, 1); + else + (void)unsetenv("PWD"); + if (home_copy[0]) + (void)setenv("HOME", home_copy, 1); + else + (void)unsetenv("HOME"); + rmdir(dir); + return 0; +} + +/** + * Encoded `$` (`\\x24` / `\\044` / `\\u0024`) must be decoded before the + * `$` expansion scan. Not `chr(47)+`. + */ +static int test_workspace_only_blocks_encoded_dollar(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_xdol_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_encoded_dollar: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"import os; os.system('cat \\x24HOME/.bashrc')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"import os; os.system('cat \\044HOME/.bashrc')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"import os; os.system('cat \\u0024HOME/.bashrc')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl https://example.com/api", + &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +/** + * Bare relative names (`cat leak`) have no `/` `~` `.` `$`, so the scanner + * must still join them to the workspace and reject symlink escapes. + * Landlock is the kernel bound; this is defense-in-depth. Not chr(47)+. + */ +static int test_relative_symlink_indirection(void) +{ +#ifdef __linux__ + char workspace[] = "/tmp/sc_al_reltok_XXXXXX"; + char leak_path[256]; + char *ws; + allowlist_config_t cfg; + char reason[256]; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_relative_symlink_indirection: mkdtemp failed\n"); + return 1; + } + snprintf(leak_path, sizeof(leak_path), "%s/leak", ws); + if (symlink("/etc/passwd", leak_path) != 0) { + rmdir(ws); + fprintf(stderr, "test_relative_symlink_indirection: symlink failed\n"); + return 1; + } + cfg.workspace_path = ws; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat leak", &cfg, reason, sizeof(reason)) == 1); + ASSERT(strstr(reason, "escapes") != NULL || strstr(reason, "workspace") != NULL); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo hello", &cfg, reason, sizeof(reason)) == 0); + unlink(leak_path); + rmdir(ws); + return 0; +#else + fprintf(stderr, "test_relative_symlink_indirection: skipped (Linux-specific)\n"); + return 0; +#endif +} + /* ------------------------------------------------------------------ */ /* main */ /* ------------------------------------------------------------------ */ @@ -224,7 +1513,36 @@ int main(void) RUN(test_path_prefix_no_slash()); RUN(test_workspace_only_blocks_outside_path()); RUN(test_workspace_only_allows_inside_path()); + RUN(test_workspace_only_blocks_quoted_path()); + RUN(test_workspace_only_blocks_embedded_path_in_python()); + RUN(test_workspace_only_allows_relative_and_url_slashes()); + RUN(test_workspace_only_blocks_file_url()); + RUN(test_workspace_only_blocks_home_env_expansion()); + RUN(test_workspace_only_blocks_glued_shell_expansions()); RUN(test_symlink_escape()); + RUN(test_dotdot_escape_nonexistent_destination()); + RUN(test_dotdot_escape_missing_component_before_dotdot()); + RUN(test_workspace_only_blocks_file_url_variants()); + RUN(test_workspace_only_blocks_embedded_relative_dotdot()); + RUN(test_workspace_only_blocks_encoded_leading_slash()); + RUN(test_workspace_only_blocks_home_pwd_assignment()); + RUN(test_workspace_only_blocks_symlink_dotdot()); + RUN(test_workspace_only_blocks_percent_encoded_file_url()); + RUN(test_workspace_only_blocks_quoted_home_pwd_assignment()); + RUN(test_workspace_only_blocks_quote_split_file_url()); + RUN(test_workspace_only_blocks_encoded_dot_and_named_slash()); + RUN(test_workspace_only_blocks_url_disguised_dotdot()); + RUN(test_workspace_only_blocks_perl_braced_hex()); + RUN(test_workspace_only_blocks_env_replace_and_comma_assign()); + RUN(test_workspace_only_blocks_read_unset_and_clustered_env()); + RUN(test_workspace_only_blocks_perl_braced_octal()); + RUN(test_workspace_only_blocks_encoded_file_scheme()); + RUN(test_workspace_only_blocks_octal_and_identity_file_scheme()); + RUN(test_workspace_only_blocks_backslash_newline_continuation()); + RUN(test_workspace_only_blocks_printf_v_and_environ_index()); + RUN(test_workspace_only_blocks_identity_and_nameref_env()); + RUN(test_workspace_only_blocks_encoded_dollar()); + RUN(test_relative_symlink_indirection()); printf("test_allowlist: all tests passed\n"); return 0; } diff --git a/tests/test_sandbox.c b/tests/test_sandbox.c index 4c653c3..1bdbdbb 100644 --- a/tests/test_sandbox.c +++ b/tests/test_sandbox.c @@ -7,6 +7,11 @@ * On macOS and other platforms, the tests fall back to verifying basic * fork+exec behaviour: output capture, timeout, and null-safety. * + * GitHub-hosted runners often cannot apply user namespaces or Landlock. + * Production stays fail-closed (sandbox_exec returns -1). Success-path tests + * skip in that case; host-FS and netns tests treat the deny as the expected + * isolation failure. Do not weaken sandbox.c to make CI green. + * * 5.7 Benchmark: run sandbox_exec("true") 200 times and report median. * The benchmark is informational only — it does not gate the test suite. */ @@ -18,6 +23,11 @@ #include #include #include +#ifdef __linux__ +#include +#include +#include +#endif #define ASSERT(c) do { \ if (!(c)) { \ @@ -28,14 +38,67 @@ #define RUN(t) do { int r_ = (t); if (r_) return r_; } while (0) +/** + * True when sandbox_exec fail-closed because namespaces or Landlock could + * not apply. Distinct from argument errors (NULL cmd, zero cap). + */ +static int isolation_was_denied(int rc, const char *out) +{ + if (rc != -1) + return 0; + if (out == NULL) + return 0; + if (strstr(out, "sandbox: namespace isolation failed") != NULL) + return 1; + if (strstr(out, "sandbox: Landlock filesystem bound failed") != NULL) + return 1; + return 0; +} + +static int skip_if_isolation_denied(int rc, const char *out, const char *name) +{ + if (!isolation_was_denied(rc, out)) + return 0; + fprintf(stderr, "test_sandbox: skip %s (%s)\n", name, out); + return 1; +} + /* ------------------------------------------------------------------ */ /* Basic functionality */ /* ------------------------------------------------------------------ */ +static int test_isolation_denied_helper(void) +{ + ASSERT(isolation_was_denied(0, "sandbox: namespace isolation failed") == 0); + ASSERT(isolation_was_denied(-1, "hello") == 0); + ASSERT(isolation_was_denied(-1, NULL) == 0); + ASSERT(isolation_was_denied(-1, "sandbox: namespace isolation failed") == 1); + ASSERT(isolation_was_denied(-1, "sandbox: Landlock filesystem bound failed") == 1); + return 0; +} + +/** + * Either isolation applies (rc == 0) or fail-closed reports the isolation + * error. Other -1 reasons on "true" are a product bug. + */ +static int test_fail_closed_reports_isolation_error(void) +{ + char out[4096]; + int rc; + + rc = sandbox_exec("true", out, sizeof(out), 5000, NULL); + if (rc == 0) + return 0; + ASSERT(isolation_was_denied(rc, out)); + return 0; +} + static int test_output_capture(void) { char out[4096]; int rc = sandbox_exec("echo hello_sandbox", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_output_capture")) + return 0; ASSERT(rc == 0); ASSERT(strstr(out, "hello_sandbox") != NULL); return 0; @@ -45,6 +108,8 @@ static int test_stderr_captured(void) { char out[4096]; int rc = sandbox_exec("echo err >&2", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_stderr_captured")) + return 0; ASSERT(rc == 0); ASSERT(strstr(out, "err") != NULL); return 0; @@ -69,6 +134,8 @@ static int test_exit_nonzero_runs(void) char out[4096]; /* Command exits non-zero; sandbox_exec should still return 0 (ran ok). */ int rc = sandbox_exec("exit 1", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_exit_nonzero_runs")) + return 0; ASSERT(rc == 0); return 0; } @@ -77,9 +144,13 @@ static int test_workspace_chdir(void) { char out[4096]; sandbox_config_t cfg; + int rc; + memset(&cfg, 0, sizeof cfg); cfg.workspace_path = "/tmp"; - int rc = sandbox_exec("pwd", out, sizeof(out), 5000, &cfg); + rc = sandbox_exec("pwd", out, sizeof(out), 5000, &cfg); + if (skip_if_isolation_denied(rc, out, "test_workspace_chdir")) + return 0; ASSERT(rc == 0); ASSERT(strstr(out, "/tmp") != NULL); return 0; @@ -94,6 +165,8 @@ static int test_timeout_kills_process(void) char out[4096]; /* sleep 60 should be killed well before natural completion. */ int rc = sandbox_exec("sleep 60", out, sizeof(out), 300, NULL); + if (skip_if_isolation_denied(rc, out, "test_timeout_kills_process")) + return 0; ASSERT(rc == 0); ASSERT(strstr(out, "timed out") != NULL || strlen(out) == 0); return 0; @@ -108,14 +181,192 @@ static int test_shadow_not_accessible(void) { char out[4096]; int rc; - /* Even if /etc/shadow is not present in CI, the exec should succeed. - * The important check: no actual secret content leaks. */ + /* Fail-closed isolation is a deny. If the command ran, no secret leak. */ rc = sandbox_exec("cat /etc/shadow 2>&1 || echo BLOCKED", out, sizeof(out), 5000, NULL); + if (isolation_was_denied(rc, out)) + return 0; ASSERT(rc == 0); /* Either permission denied or the echo BLOCKED message appears. */ ASSERT(strlen(out) > 0); return 0; } + +/** + * With a workspace configured, Landlock must deny host reads even via a + * relative symlink (allowlist may miss bare names; sandbox is the FS gate). + * Hosts that cannot apply isolation fail closed: also a deny, no leak. + */ +static int test_workspace_landlock_blocks_symlink_escape(void) +{ + char workspace[] = "/tmp/sc_sb_ws_XXXXXX"; + char leak_path[256]; + char out[4096]; + sandbox_config_t cfg; + char *ws; + int rc; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_landlock_blocks_symlink_escape: mkdtemp failed\n"); + return 1; + } + snprintf(leak_path, sizeof(leak_path), "%s/leak", ws); + if (symlink("/etc/passwd", leak_path) != 0) { + rmdir(ws); + fprintf(stderr, "test_workspace_landlock_blocks_symlink_escape: symlink failed\n"); + return 1; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = ws; + rc = sandbox_exec("cat leak 2>&1; echo EXIT:$?", out, sizeof(out), 5000, &cfg); + unlink(leak_path); + rmdir(ws); + ASSERT(strstr(out, "root:x:") == NULL); + if (isolation_was_denied(rc, out)) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "Permission denied") != NULL || + strstr(out, "No such file") != NULL || + strstr(out, "EXIT:1") != NULL || + strstr(out, "EXIT:2") != NULL); + return 0; +} + +/** + * Kernel FS bound must stop interpreter path concat (`chr(47)+`) that the + * string scanner cannot see. Residual on allowlist only. + * Fail-closed isolation is also a deny (no host passwd leak). + */ +static int test_workspace_landlock_blocks_abs_etc(void) +{ + char workspace[] = "/tmp/sc_sb_ws2_XXXXXX"; + char out[4096]; + char outp[256]; + sandbox_config_t cfg; + char *ws; + int rc; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_landlock_blocks_abs_etc: mkdtemp failed\n"); + return 1; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = ws; + rc = sandbox_exec( + "python3 -c 'open(\"out\",\"w\").write(open(chr(47)+\"etc\"+chr(47)+\"passwd\").read())' 2>&1; " + "echo EXIT:$?", + out, sizeof(out), 8000, &cfg); + snprintf(outp, sizeof(outp), "%s/out", ws); + unlink(outp); + rmdir(ws); + ASSERT(strstr(out, "root:x:") == NULL); + if (isolation_was_denied(rc, out)) + return 0; + ASSERT(rc == 0); + return 0; +} + +static int test_workspace_landlock_allows_workspace_write(void) +{ + char workspace[] = "/tmp/sc_sb_wr_XXXXXX"; + char out[4096]; + char wrote[256]; + char buf[64]; + sandbox_config_t cfg; + char *ws; + FILE *f; + int rc; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_landlock_allows_workspace_write: mkdtemp failed\n"); + return 1; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = ws; + rc = sandbox_exec("echo landlock_ok > wrote.txt", out, sizeof(out), 5000, &cfg); + snprintf(wrote, sizeof(wrote), "%s/wrote.txt", ws); + if (isolation_was_denied(rc, out)) { + unlink(wrote); + rmdir(ws); + fprintf(stderr, "test_sandbox: skip test_workspace_landlock_allows_workspace_write (%s)\n", + out); + return 0; + } + ASSERT(rc == 0); + f = fopen(wrote, "r"); + ASSERT(f != NULL); + ASSERT(fgets(buf, sizeof(buf), f) != NULL); + fclose(f); + ASSERT(strstr(buf, "landlock_ok") != NULL); + unlink(wrote); + rmdir(ws); + return 0; +} + +static int listen_loopback_ephemeral(int *port_out) +{ + int fd; + int one = 1; + struct sockaddr_in addr; + socklen_t addr_len; + + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) != 0) { + close(fd); + return -1; + } + memset(&addr, 0, sizeof addr); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0) { + close(fd); + return -1; + } + if (listen(fd, 1) != 0) { + close(fd); + return -1; + } + addr_len = sizeof addr; + if (getsockname(fd, (struct sockaddr *)&addr, &addr_len) != 0) { + close(fd); + return -1; + } + *port_out = (int)ntohs(addr.sin_port); + return fd; +} + +static int test_network_namespace_blocks_host_loopback(void) +{ + int port = 0; + int srv; + int rc; + char cmd[256]; + char out[4096]; + + if (access("/bin/bash", X_OK) != 0) { + fprintf(stderr, "test_sandbox: skip netns loopback test (no bash)\n"); + return 0; + } + srv = listen_loopback_ephemeral(&port); + ASSERT(srv >= 0); + ASSERT(port > 0); + snprintf(cmd, sizeof cmd, + "bash -c 'echo >/dev/tcp/127.0.0.1/%d' >/dev/null 2>&1 " + "&& echo CONNECTED || echo ISOLATED", + port); + rc = sandbox_exec(cmd, out, sizeof out, 5000, NULL); + close(srv); + ASSERT(strstr(out, "CONNECTED") == NULL); + if (isolation_was_denied(rc, out)) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "ISOLATED") != NULL); + return 0; +} #endif /* ------------------------------------------------------------------ */ @@ -128,8 +379,14 @@ static int benchmark_sandbox_exec(void) long times_us[BENCH_N]; char out[256]; int i; + int rc; long sum = 0; long median_us; + + rc = sandbox_exec("true", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "benchmark_sandbox_exec")) + return 0; + for (i = 0; i < BENCH_N; i++) { struct timespec t0, t1; long diff_us; @@ -173,6 +430,8 @@ static int benchmark_sandbox_exec(void) int main(void) { + RUN(test_isolation_denied_helper()); + RUN(test_fail_closed_reports_isolation_error()); RUN(test_output_capture()); RUN(test_stderr_captured()); RUN(test_null_cmd_returns_error()); @@ -182,6 +441,10 @@ int main(void) RUN(test_timeout_kills_process()); #ifdef __linux__ RUN(test_shadow_not_accessible()); + RUN(test_workspace_landlock_blocks_symlink_escape()); + RUN(test_workspace_landlock_blocks_abs_etc()); + RUN(test_workspace_landlock_allows_workspace_write()); + RUN(test_network_namespace_blocks_host_loopback()); #else fprintf(stderr, "test_sandbox: Linux-only namespace tests skipped on this platform\n"); #endif