From 5ebb5a97c29ba02c98681766c0d77bcacf3c164b Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 17:51:29 -0300 Subject: [PATCH 1/4] fix(allowlist): fail closed on workspace escapes and reserved names workspace_only tokenized on whitespace, so a quoted path, a file: URL, or $HOME/$PWD could leave the workspace, and strdup failure fell open. Record the unresolved-path reason before freeing the command copy. Substring-block auth_tokens.json, shellclaw.pid, and shellclaw.log on the whole command so python -c open() cannot skip the basename check. --- CHANGELOG.md | 1 + Makefile | 9 +- src/sandbox/allowlist.c | 782 ++++++++++++++++++++++++++++------- src/sandbox/allowlist.h | 46 ++- src/sandbox/allowlist_path.c | 208 ++++++++++ tests/test_allowlist.c | 541 ++++++++++++++++++++++++ 6 files changed, 1408 insertions(+), 179 deletions(-) create mode 100644 src/sandbox/allowlist_path.c diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb4f95..710582b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed +- Shell `workspace_only` walks the first existing ancestor, collapses `..` lexically (without cancelling across a symlink), and scans quoted/embedded paths, `file:` URLs, `$HOME`/`$PWD` (including glued `$IFS`), and bare relative names such as `cat leak`. `strdup` OOM is fail-closed. Encoded-slash cat-and-mouse is frozen; Landlock is the kernel host-FS bound. - Default `workspace_path` is `~/.shellclaw/workspace`, and file/shell tools still refuse `auth_tokens.json`, `shellclaw.pid`, `shellclaw.log`, and `.shellclaw` `config.toml` / `memory.db` (including `memory.db-*` sidecars) when a custom workspace contains them. A bare `cat config.toml` after the sandbox `chdir` is blocked, and a symlink at the workspace path is not accepted. - Dashboard `PUT /api/config` merges JSON fields into `config.toml` and reloads live settings. Indented keys and `[section] # comment` headers are updated in place; a present field with the wrong JSON type returns 400; a saved file whose live reload fails returns 500. Gateway host and port still need a process restart to rebind. - Unsandboxed `shell` no longer blocks forever in `waitpid` after the output cap fills; leftover children (including background grandchildren) are SIGKILL'd via the command process group, and truncated capture is NUL-terminated (#69). diff --git a/Makefile b/Makefile index 05f4519..ae2a06d 100644 --- a/Makefile +++ b/Makefile @@ -126,7 +126,9 @@ ASAP_INVOKE_O := src/tools/asap_invoke.o ASAP_INVOKE_TEST_O := $(BINDIR)/asap_invoke_test.o # Sandbox (Phase 3 §5) SANDBOX_O := src/sandbox/sandbox.o -ALLOWLIST_O := src/sandbox/allowlist.o +ALLOWLIST_SCAN_O := src/sandbox/allowlist.o +ALLOWLIST_PATH_O := src/sandbox/allowlist_path.o +ALLOWLIST_O := $(ALLOWLIST_SCAN_O) $(ALLOWLIST_PATH_O) MANIFEST_O := src/asap/manifest.o MANIFEST_PROFILES_O := src/asap/manifest_profiles.o MANIFEST_BUILD_O := src/asap/manifest_build.o @@ -384,9 +386,12 @@ $(SHELL_O): src/tools/shell.c src/tools/tool.h src/tools/shell.h src/core/config $(SANDBOX_O): src/sandbox/sandbox.c src/sandbox/sandbox.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/sandbox.c -$(ALLOWLIST_O): src/sandbox/allowlist.c src/sandbox/allowlist.h +$(ALLOWLIST_SCAN_O): src/sandbox/allowlist.c src/sandbox/allowlist.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/allowlist.c +$(ALLOWLIST_PATH_O): src/sandbox/allowlist_path.c src/sandbox/allowlist.h + $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/allowlist_path.c + $(WEBSEARCH_O): src/tools/web_search.c src/tools/tool.h src/tools/web_search.h src/core/config.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/tools/web_search.c diff --git a/src/sandbox/allowlist.c b/src/sandbox/allowlist.c index f3a67f2..7d06dd9 100644 --- a/src/sandbox/allowlist.c +++ b/src/sandbox/allowlist.c @@ -1,23 +1,22 @@ /** * @file allowlist.c - * @brief Shell-command allowlist: built-in blocklist + workspace realpath checks. + * @brief Shell-command allowlist: blocklist plus workspace path scans. + * + * sandbox_exec() does not pivot_root. Landlock is the kernel host-FS bound. + * This scanner is defense-in-depth for quoted/embedded paths, $HOME/$PWD, + * file: URLs, and relative symlink tokens the old strtok gate missed. */ #define _DEFAULT_SOURCE #define _POSIX_C_SOURCE 200809L #include "sandbox/allowlist.h" -#include #include #include #include #include - -/* ------------------------------------------------------------------ */ -/* Built-in blocklist patterns */ -/* ------------------------------------------------------------------ */ +#include static const char *const BLOCK_SUBSTRINGS[] = { - /* Filesystem destroyers */ "rm -rf /", "rm -rf /*", "rm -rf / ", @@ -28,7 +27,6 @@ static const char *const BLOCK_SUBSTRINGS[] = { "> /dev/sd", "dd if=", "dd of=/dev", - /* System lifecycle */ "shutdown", "reboot", "halt", @@ -38,40 +36,35 @@ static const char *const BLOCK_SUBSTRINGS[] = { "systemctl poweroff", "systemctl reboot", "systemctl halt", - /* Fork bombs */ ":(){ :|:& };:", "fork()", ":(){:|:&};:", - /* Privilege escalation */ "chmod 777 /", "chmod -R 777 /", "chown root", "sudo rm -rf", - /* Credential / secret file access */ "/etc/shadow", "/etc/gshadow", "~/.ssh/id_", "id_rsa", "id_ed25519", - "auth_tokens.json", - "shellclaw.pid", - "shellclaw.log", /* Jetson Tegra GPU device nodes (audit 7.1 — not bind-mounted, block direct open) */ "/dev/nvhost", "/dev/nvgpu", "/dev/nvmap", - /* Jetson Argus camera daemon socket (audit 7.3 — agent-only, not shell sandbox) */ "/tmp/argus_socket", + /* Not only token basenames: python -c open('auth_tokens.json') is one + * argument. Sandbox-off already substring-matches these three. */ + "auth_tokens.json", + "shellclaw.pid", + "shellclaw.log", NULL }; -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - static void set_reason(char *buf, size_t cap, const char *prefix, const char *detail) { - if (!buf || cap == 0) return; + if (!buf || cap == 0) + return; if (detail && detail[0]) snprintf(buf, cap, "%s%s", prefix, detail); else @@ -79,45 +72,554 @@ 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. */ 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; + return tok[0] == '/' || tok[0] == '~' || tok[0] == '.' || tok[0] == '$'; +} + +static int is_option_token(const char *tok) +{ + if (!tok || tok[0] != '-' || tok[1] == '\0') + return 0; + if (tok[1] >= '0' && tok[1] <= '9') + return 0; + return 1; } -/* ------------------------------------------------------------------ */ -/* Public: path-under-workspace check (5.4) */ -/* ------------------------------------------------------------------ */ +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 == '@'; +} -int allowlist_path_is_under_workspace(const char *path, const char *workspace_root) +static int is_ident_cont(unsigned char c) { - char resolved_path[PATH_MAX]; - char resolved_ws[PATH_MAX]; - const char *actual_ws; - size_t wlen; - if (!path || !workspace_root || !workspace_root[0]) return 0; - /* Resolve the workspace root (handles symlinks like macOS /tmp -> /private/tmp). */ - if (realpath(workspace_root, resolved_ws)) - actual_ws = resolved_ws; - 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; + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_'; +} + +static int join_under_workspace(const char *workspace_root, const char *rel, + char *out, size_t cap) +{ + if (!workspace_root || !rel || !out || cap == 0) + return -1; + { + int n = snprintf(out, cap, "%s/%s", workspace_root, rel); + if (n < 0 || (size_t)n >= cap) + return -1; + } + return 0; +} + +static int deny_escaped(char *reason_buf, size_t reason_cap, const char *shown) +{ + set_reason(reason_buf, reason_cap, "command blocked: path escapes workspace: ", shown); + fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", shown); + return 1; +} + +static int deny_unresolved(char *reason_buf, size_t reason_cap, const char *shown) +{ + set_reason(reason_buf, reason_cap, + "command blocked: unresolved shell path expansion: ", shown); + fprintf(stderr, "allowlist: blocked unresolved shell path: %s\n", shown); + return 1; +} + +static int check_joined_or_abs(const char *candidate, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + char joined[PATH_MAX]; + const char *check = candidate; + + if (!candidate || candidate[0] == '\0') + return 0; + if (candidate[0] != '/') { + if (join_under_workspace(workspace_root, candidate, joined, sizeof(joined)) != 0) + return deny_escaped(reason_buf, reason_cap, candidate); + check = joined; + } + if (!allowlist_path_is_under_workspace(check, workspace_root)) + return deny_escaped(reason_buf, reason_cap, check); + return 0; +} + +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]; + + if (!tok || !tok[0] || !workspace_root || !workspace_root[0]) + return 0; + if (has_path_chars(tok) || is_option_token(tok)) + return 0; + if (join_under_workspace(workspace_root, tok, joined, sizeof(joined)) != 0) + return deny_escaped(reason_buf, reason_cap, tok); + if (access(joined, F_OK) != 0) + return 0; + if (!allowlist_path_is_under_workspace(joined, workspace_root)) + return deny_escaped(reason_buf, reason_cap, joined); + 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; + + 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; + { + int n = snprintf(expanded, expanded_cap, "%s%s", value, suffix); + if (n < 0 || (size_t)n >= expanded_cap) + return -1; + } + return 0; +} + +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 || home[0] == '\0') + return -1; + n = snprintf(expanded, expanded_cap, "%s%s", home, tok + 1); + 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 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; +} + +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; + + 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; + { + int 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) + return deny_escaped(reason_buf, reason_cap, fragment); + if (check_joined_or_abs(expanded, workspace_root, reason_buf, reason_cap)) + return 1; + if (p > start) + p--; + } + 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]; + + if (!value) + return deny_unresolved(reason_buf, reason_cap, raw); + { + int n = snprintf(expanded, sizeof(expanded), "%s%.*s", value, (int)suffix_len, suffix); + if (n < 0 || (size_t)n >= sizeof(expanded)) + return deny_unresolved(reason_buf, reason_cap, raw); + } + if (!allowlist_path_is_under_workspace(expanded, workspace_root)) + return deny_escaped(reason_buf, reason_cap, expanded); + return 0; +} + +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] == '(') + return deny_unresolved(reason_buf, reason_cap, p); + 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; + } + return deny_unresolved(reason_buf, reason_cap, p); + } + 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; + } + return deny_unresolved(reason_buf, reason_cap, p); + } + return 0; +} + +static int prefix_ci_eq(const char *p, const char *prefix) +{ + size_t i; + + if (!p || !prefix) return 0; + 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; } - /* 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; + return 1; +} + +static int hex_nibble(unsigned char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +static int percent_decode_inplace(char *s) +{ + char *r = s; + char *w = s; + + while (*r) { + if (*r == '%') { + int hi; + int lo; + + if (!r[1] || !r[2]) + return -1; + hi = hex_nibble((unsigned char)r[1]); + lo = hex_nibble((unsigned char)r[2]); + if (hi < 0 || lo < 0) + return -1; + *w++ = (char)((hi << 4) | lo); + r += 3; + continue; + } + *w++ = *r++; } - /* 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; + *w = '\0'; + return 0; +} + +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; + 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) + return deny_escaped(reason_buf, reason_cap, path); + if (!allowlist_path_is_under_workspace(path, workspace_root)) + return deny_escaped(reason_buf, reason_cap, path); + } + return 0; +} + +static int at_word_start(const char *text, const char *p) +{ + unsigned char prev; + + if (!text || !p) + return 0; + if (p == text) + return 1; + prev = (unsigned char)p[-1]; + return !is_ident_cont(prev); +} + +static int name_is_home_or_pwd(const char *q) +{ + if (strncmp(q, "HOME", 4) == 0 && !is_ident_cont((unsigned char)q[4])) + return 1; + if (strncmp(q, "PWD", 3) == 0 && !is_ident_cont((unsigned char)q[3])) + return 1; + return 0; +} + +static int command_mutates_home_or_pwd(const char *text) +{ + const char *p; + + if (!text) + return 0; + for (p = text; *p; p++) { + const char *q; + + if (!at_word_start(text, p)) + continue; + if (strncmp(p, "HOME=", 5) == 0 || strncmp(p, "PWD=", 4) == 0) + return 1; + if (strncmp(p, "unset", 5) == 0 && !is_ident_cont((unsigned char)p[5])) { + q = p + 5; + while (*q == ' ' || *q == '\t') + q++; + if (name_is_home_or_pwd(q)) + return 1; + } + if (strncmp(p, "export", 6) == 0 && !is_ident_cont((unsigned char)p[6])) { + q = p + 6; + while (*q == ' ' || *q == '\t') + q++; + if (name_is_home_or_pwd(q)) + return 1; + } + } + return 0; +} + +static int blocklist_hit(const char *cmd, char *reason_buf, size_t reason_cap) +{ + const char *const *p; + + for (p = BLOCK_SUBSTRINGS; *p; p++) { + if (strstr(cmd, *p) != NULL) { + set_reason(reason_buf, reason_cap, + "command blocked: contains forbidden pattern '", *p); + if (reason_buf && reason_cap > 0) { + size_t used = strlen(reason_buf); + if (used + 2 < reason_cap) { + reason_buf[used] = '\''; + reason_buf[used + 1] = '\0'; + } + } + fprintf(stderr, "allowlist: blocked command containing '%s'\n", *p); + return 1; + } } return 0; } @@ -157,156 +659,116 @@ int allowlist_path_is_runtime_state_file(const char *path) return strcmp(slash, ".shellclaw") == 0; } -/** Copy @p rel under @p root, collapsing "." and "..". Absolute @p rel is copied as-is. */ -static int join_under_root(const char *root, const char *rel, char *out, size_t cap) +static int deny_runtime_state(char *reason_buf, size_t reason_cap, const char *shown) { - char tmp[PATH_MAX]; - char *dup; - char *save = NULL; - char *tok; - char *stack[48] = {0}; - int nstack = 0; - int i; - size_t used; + set_reason(reason_buf, reason_cap, "command blocked: runtime state file: ", shown); + fprintf(stderr, "allowlist: blocked runtime state file: %s\n", shown); + return 1; +} - if (!rel || !rel[0] || !out || cap == 0) - return -1; - if (rel[0] == '/') { - if (strlen(rel) + 1 > cap) - return -1; - memcpy(out, rel, strlen(rel) + 1); +/* State files are rejected even when workspace_only is off. Bare names are + * joined only when a workspace path is configured, so a sandbox-off fallback + * does not treat the process cwd as ~/.shellclaw. */ +static int block_if_runtime_state_token(const char *tok, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + char expanded[PATH_MAX]; + char joined[PATH_MAX]; + const char *check = tok; + + if (!tok || !tok[0]) return 0; - } - if (!root || !root[0]) - return -1; - if (snprintf(tmp, sizeof(tmp), "%s/%s", root, rel) >= (int)sizeof(tmp)) - return -1; - dup = strdup(tmp); - if (!dup) - return -1; - for (tok = strtok_r(dup, "/", &save); tok; tok = strtok_r(NULL, "/", &save)) { - if (strcmp(tok, ".") == 0) - continue; - if (strcmp(tok, "..") == 0) { - if (nstack > 0) - nstack--; - continue; - } - if (nstack >= (int)(sizeof(stack) / sizeof(stack[0]))) { - free(dup); - return -1; - } - stack[nstack++] = tok; - } - used = 0; - out[0] = '\0'; - for (i = 0; i < nstack; i++) { - size_t part = strlen(stack[i]); - if (used + 1 + part + 1 > cap) { - free(dup); - return -1; - } - out[used++] = '/'; - memcpy(out + used, stack[i], part); - used += part; - out[used] = '\0'; - } - free(dup); - if (used == 0) - return -1; + if ((tok[0] == '~' || tok[0] == '$') && + expand_shell_path_token(tok, expanded, sizeof(expanded)) == 0) + check = expanded; + if (allowlist_path_is_runtime_state_file(check)) + return deny_runtime_state(reason_buf, reason_cap, check); + if (!workspace_root || !workspace_root[0] || check[0] == '/') + return 0; + if (join_under_workspace(workspace_root, check, joined, sizeof(joined)) != 0) + return 0; + if (allowlist_path_is_runtime_state_file(joined)) + return deny_runtime_state(reason_buf, reason_cap, joined); return 0; } -/* ------------------------------------------------------------------ */ -/* Public: combined check */ -/* ------------------------------------------------------------------ */ +static int block_workspace_scanners(const char *cmd, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + if (command_mutates_home_or_pwd(cmd)) { + 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 (block_if_file_url_escapes(cmd, workspace_root, reason_buf, reason_cap)) + return 1; + if (block_if_dollar_expansions_escape(cmd, workspace_root, reason_buf, reason_cap)) + return 1; + return block_if_embedded_paths_escape(cmd, workspace_root, reason_buf, reason_cap); +} int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg, char *reason_buf, size_t reason_cap) { - const char *const *p; char ws_resolved[PATH_MAX]; const char *workspace_root = NULL; - int workspace_only = 0; - char *cmd_copy = NULL; + int workspace_only; + char *cmd_copy; char *tok; char *saveptr; + if (!cmd) { set_reason(reason_buf, reason_cap, "null command", ""); return 1; } - /* Phase 1: built-in substring blocklist */ - for (p = BLOCK_SUBSTRINGS; *p; p++) { - if (strstr(cmd, *p) != NULL) { - set_reason(reason_buf, reason_cap, - "command blocked: contains forbidden pattern '", *p); - if (reason_buf && reason_cap > 0) { - size_t used = strlen(reason_buf); - if (used + 2 < reason_cap) { - reason_buf[used] = '\''; - reason_buf[used + 1] = '\0'; - } - } - fprintf(stderr, "allowlist: blocked command containing '%s'\n", *p); - return 1; - } - } - /* Phase 2: runtime-state paths, then optional workspace containment. - * State files are rejected even when workspace_only is off, so an - * unsandboxed `cat ~/.shellclaw/config.toml` cannot skip the check. */ + if (blocklist_hit(cmd, reason_buf, reason_cap)) + return 1; workspace_only = cfg && cfg->workspace_only && cfg->workspace_path && cfg->workspace_path[0]; if (cfg && cfg->workspace_path && cfg->workspace_path[0]) { if (!realpath(cfg->workspace_path, ws_resolved)) { size_t n = strlen(cfg->workspace_path); - if (n >= PATH_MAX) n = PATH_MAX - 1; + if (n >= PATH_MAX) + n = PATH_MAX - 1; memcpy(ws_resolved, cfg->workspace_path, n); ws_resolved[n] = '\0'; } workspace_root = ws_resolved; } - /* Tokenize the command and check each path-like token. */ + if (workspace_only && + block_workspace_scanners(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) { - char expanded[PATH_MAX]; - const char *check = tok; - 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); - check = expanded; - } - if (allowlist_path_is_runtime_state_file(check)) { - set_reason(reason_buf, reason_cap, - "command blocked: runtime state file: ", check); - fprintf(stderr, "allowlist: blocked runtime state file: %s\n", check); + tok = strip_surrounding_quotes(tok); + if (block_if_runtime_state_token(tok, workspace_root, reason_buf, reason_cap)) { free(cmd_copy); return 1; } - /* sandbox_exec chdirs into the workspace, so a bare name is that file. */ - if (workspace_root && check[0] != '/') { - char joined[PATH_MAX]; - if (join_under_root(workspace_root, check, joined, sizeof(joined)) == 0 && - allowlist_path_is_runtime_state_file(joined)) { - set_reason(reason_buf, reason_cap, - "command blocked: runtime state file: ", joined); - fprintf(stderr, "allowlist: blocked runtime state file: %s\n", joined); + if (workspace_only && has_path_chars(tok)) { + char expanded[PATH_MAX]; + + if (expand_shell_path_token(tok, expanded, sizeof(expanded)) != 0) { + /* tok points into cmd_copy; copy the reason before free. */ + int blocked = deny_unresolved(reason_buf, reason_cap, tok); free(cmd_copy); - return 1; + return blocked; } - } - if (workspace_only && has_path_chars(tok)) { - if (!allowlist_path_is_under_workspace(check, workspace_root)) { - set_reason(reason_buf, reason_cap, - "command blocked: path escapes workspace: ", check); - fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", check); + if (check_joined_or_abs(expanded, workspace_root, reason_buf, reason_cap)) { free(cmd_copy); return 1; } + } else if (workspace_only && + 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 b2d8ea1..9c89988 100644 --- a/src/sandbox/allowlist.h +++ b/src/sandbox/allowlist.h @@ -1,19 +1,22 @@ /** * @file allowlist.h - * @brief Shell-command allowlist: conservative block rules and workspace path checks. + * @brief Shell-command allowlist: blocklist plus workspace path defense-in-depth. * - * allowlist_check_shell_command() should be called before executing any shell command - * when the sandbox is enabled. It combines two independent layers of defence: + * allowlist_check_shell_command() runs before sandbox_exec() when the sandbox + * is enabled. It is not the host-FS bound: Linux Landlock in sandbox_exec() + * is, when a workspace path is set. This scanner still closes the holes that + * whitespace tokenization missed: * - * 1. A built-in substring blocklist (dangerous patterns like "rm -rf /", - * "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. + * 1. Built-in substring blocklist (rm -rf /, mkfs, Jetson GPU /dev, Argus). + * 2. workspace_only containment: existing-ancestor walk, lexical `..` + * collapse (no cancel across a symlink), quoted/embedded `/` `~` `../`, + * `file:` URLs (including percent-encoding), `$HOME`/`$PWD` on the full + * command (including glued `$IFS`), fail-closed other `$` forms, in-command + * HOME/PWD assignment, and bare relative names (`cat leak`). * - * 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. + * Conservative false positives (`awk '/foo/'`, `echo HOME=foo`) are accepted. + * Interpreter concat with no path character (`chr(47)+`) is residual here; + * Landlock denies the host inode. Encoded-slash cat-and-mouse is frozen. */ #ifndef SHELLCLAW_ALLOWLIST_H #define SHELLCLAW_ALLOWLIST_H @@ -28,13 +31,13 @@ extern "C" { typedef struct allowlist_config { /** * Absolute (or ~-prefixed) path to the workspace root. - * When non-NULL and workspace_only is non-zero, any path argument that - * resolves outside this prefix is rejected. + * When non-NULL and workspace_only is non-zero, path arguments that + * resolve outside this prefix are rejected. */ const char *workspace_path; /** - * When non-zero, any token that looks like a path is subjected to - * realpath()-based containment checks against workspace_path. + * When non-zero, path-like tokens and embedded path fragments are + * subjected to containment checks against workspace_path. */ int workspace_only; } allowlist_config_t; @@ -50,6 +53,9 @@ typedef struct allowlist_config { * @param reason_buf Optional buffer for a blocking reason message. * @param reason_cap Capacity of @p reason_buf. * @return 0 if the command is allowed, 1 if blocked. + * + * Example: allowlist_check_shell_command("cat '/etc/passwd'", &cfg, reason, sizeof reason) + * returns 1 when cfg.workspace_only is set to a workspace other than `/etc`. */ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg, char *reason_buf, size_t reason_cap); @@ -57,8 +63,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/allowlist_path.c b/src/sandbox/allowlist_path.c new file mode 100644 index 0000000..d85c3d8 --- /dev/null +++ b/src/sandbox/allowlist_path.c @@ -0,0 +1,208 @@ +/** + * @file allowlist_path.c + * @brief Workspace containment for shell-command paths (defense-in-depth). + * + * realpath(3) cannot canonicalize a missing destination. Walking the first + * existing ancestor (same idea as tools/file.c) still collapses `..` through + * directories that exist. Lexical collapse runs first so a missing component + * before `..` cannot pin the walk at the workspace. `..` is not cancelled + * across a symlink: the kernel walks the link first. + */ +#define _DEFAULT_SOURCE +#define _POSIX_C_SOURCE 200809L + +#include "sandbox/allowlist.h" +#include +#include +#include +#include +#include +#include +#include + +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] == '/'; +} + +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; +} + +static int append_path_seg(char *buf, size_t cap, size_t *len, const char *seg, + int add_slash) +{ + size_t sl; + + if (!buf || !len || !seg) + return -1; + sl = strlen(seg); + if (add_slash) { + if (*len + 1 >= cap) + return -1; + buf[(*len)++] = '/'; + } + if (*len + sl + 1 > cap) + return -1; + memcpy(buf + *len, seg, sl); + *len += sl; + buf[*len] = '\0'; + return 0; +} + +/** True when the stacked path is a symlink, so `..` must not cancel it. */ +static int stacked_path_is_symlink(const char **parts, int nparts, int absolute) +{ + 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; + probe[1] = '\0'; + } else { + probe_len = 0; + probe[0] = '\0'; + } + for (pi = 0; pi < nparts; pi++) { + if (!parts[pi]) + return 1; + if (append_path_seg(probe, sizeof(probe), &probe_len, parts[pi], + (absolute && pi == 0) ? 0 : (probe_len > 0)) != 0) + return 1; + } + return lstat(probe, &st) == 0 && S_ISLNK(st.st_mode); +} + +/** + * Collapse `.` / `..` without requiring directories to exist, so + * `/ws/nope/../../../tmp/x` becomes `/tmp/x` instead of walking back to `/ws`. + */ +static int lexical_collapse_path(const char *path, char *out, size_t out_cap) +{ + char tmp[PATH_MAX]; + const char *parts[PATH_MAX / 2]; + int nparts = 0; + int absolute; + size_t len; + char *cur; + int i; + size_t out_len; + + memset(parts, 0, sizeof(parts)); + 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) { + if (stacked_path_is_symlink(parts, nparts, absolute)) + 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; + out[1] = '\0'; + } else { + out_len = 0; + out[0] = '\0'; + } + if (!absolute && nparts == 0) { + if (out_cap < 2) + return -1; + out[0] = '.'; + out[1] = '\0'; + return 0; + } + for (i = 0; i < nparts; i++) { + if (!parts[i]) + return -1; + if (append_path_seg(out, out_cap, &out_len, parts[i], + (!absolute && i == 0) ? 0 : (out_len > (absolute ? 1 : 0))) != 0) + return -1; + } + return 0; +} + +int allowlist_path_is_under_workspace(const char *path, const char *workspace_root) +{ + 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; + if (realpath(workspace_root, resolved_ws)) + actual_ws = resolved_ws; + else + actual_ws = workspace_root; + wlen = strlen(actual_ws); + 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; + 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); +} diff --git a/tests/test_allowlist.c b/tests/test_allowlist.c index 223b3d9..6ff4dc8 100644 --- a/tests/test_allowlist.c +++ b/tests/test_allowlist.c @@ -293,6 +293,530 @@ static int test_symlink_escape(void) #endif } +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; +} + +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; + + 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 || + strstr(reason, "forbidden") != 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); + rmdir(dir); + return 0; +} + +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); + 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; +} + +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; + } + snprintf(new_file, sizeof(new_file), "%s/brand_new.txt", ws); + ASSERT(allowlist_path_is_under_workspace(new_file, ws) == 1); + 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; +} + +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("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; +} + +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; + + 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; + 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); + rmdir(dir); + return 0; +} + +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 +} + +static int test_workspace_only_blocks_percent_encoded_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 file:///etc/%2e%2e/%2e%2e/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_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 +} + +static int test_runtime_state_edges_and_url_hosts(void) +{ + allowlist_config_t cfg; + char reason[256]; + + ASSERT(allowlist_path_is_runtime_state_file(NULL) == 0); + ASSERT(allowlist_path_is_runtime_state_file("") == 0); + ASSERT(allowlist_path_is_runtime_state_file("shellclaw.pid") == 1); + ASSERT(allowlist_path_is_runtime_state_file("shellclaw.log") == 1); + ASSERT(allowlist_path_is_runtime_state_file("config.toml") == 0); + ASSERT(allowlist_path_is_runtime_state_file("/config.toml") == 0); + ASSERT(allowlist_path_is_runtime_state_file("/.shellclaw/config.toml") == 1); + ASSERT(allowlist_path_is_runtime_state_file("/.shellclaw/memory.db") == 1); + ASSERT(allowlist_check_shell_command("cat shellclaw.pid", NULL, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command("cat shellclaw.log", NULL, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command("cat config.toml", NULL, NULL, 0) == 0); + ASSERT(allowlist_path_is_under_workspace(NULL, "/tmp") == 0); + ASSERT(allowlist_path_is_under_workspace("/tmp/a", NULL) == 0); + ASSERT(allowlist_path_is_under_workspace("/tmp/a", "") == 0); + ASSERT(allowlist_path_is_under_workspace("foo/../../etc/passwd", "/tmp") == 0); + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file://127.0.0.1/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file:///etc/%2", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file:///etc/%GG", + &cfg, reason, sizeof(reason)) == 1); + return 0; +} + +/* Sandbox-off substring-matches these names. Sandbox-on must too: a + * python -c open() is not a shell token whose basename is the file. */ +static int test_runtime_state_names_block_embedded(void) +{ + allowlist_config_t cfg; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + ASSERT(allowlist_check_shell_command( + "python3 -c \"print(open('auth_tokens.json').read())\"", + &cfg, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('shellclaw.pid')\"", + &cfg, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('shellclaw.log')\"", + &cfg, NULL, 0) == 1); + return 0; +} + +/* $HOME/$PWD that stay inside the workspace take the scanner's allow path. + * A temp workspace makes those expansions fail closed, so these lines were + * never hit. Also cover token-walker and file-URL edges around that path. */ +static int test_in_workspace_env_and_scanner_edges(void) +{ + allowlist_config_t cfg; + char reason[256]; + char cwd[PATH_MAX]; + char ws[] = "/tmp/sc_al_cov_XXXXXX"; + const char *home; + const char *pwd; + char *dir; + + home = getenv("HOME"); + pwd = getenv("PWD"); + if (!home || !home[0] || !getcwd(cwd, sizeof(cwd))) { + fprintf(stderr, "test_in_workspace_env_and_scanner_edges: HOME or cwd unavailable\n"); + return 1; + } + if (!pwd || !pwd[0]) + pwd = cwd; + cfg.workspace_only = 1; + cfg.workspace_path = home; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo $HOME/notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo ${HOME}/notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo ~/notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo $HOME.extra", + &cfg, reason, sizeof(reason)) == 1); + ASSERT(strstr(reason, "unresolved") != NULL); + cfg.workspace_path = pwd; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo $PWD/notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo ${PWD}/notes.txt", + &cfg, reason, sizeof(reason)) == 0); + cfg.workspace_path = "/tmp"; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("/bin/echo hi", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file:///%2E%2E/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo xfile:///tmp/inside", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo file://localhost", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("unset FOO; echo notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("export BAR; echo notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo -1", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("python3 -c \"open('..')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo ..;", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo https://example.com/.git", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat ${HOME}/auth_tokens.json", + NULL, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat $PWD/shellclaw.pid", + NULL, reason, sizeof(reason)) == 1); + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_in_workspace_env_and_scanner_edges: mkdtemp failed\n"); + return 1; + } + ASSERT(allowlist_path_is_under_workspace("foo/..", dir) == 0); + ASSERT(allowlist_path_is_under_workspace("foo/./bar", dir) == 0); + rmdir(dir); + return 0; +} + /* ------------------------------------------------------------------ */ /* main */ /* ------------------------------------------------------------------ */ @@ -315,12 +839,29 @@ int main(void) RUN(test_block_state_dir_config_and_memory()); RUN(test_block_memory_sidecars_and_bare_names()); RUN(test_allow_project_config_toml()); + RUN(test_runtime_state_names_block_embedded()); + RUN(test_runtime_state_edges_and_url_hosts()); + RUN(test_in_workspace_env_and_scanner_edges()); RUN(test_path_inside_workspace()); RUN(test_path_outside_workspace()); 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_home_pwd_assignment()); + RUN(test_workspace_only_blocks_symlink_dotdot()); + RUN(test_workspace_only_blocks_percent_encoded_file_url()); + RUN(test_relative_symlink_indirection()); printf("test_allowlist: all tests passed\n"); return 0; } From 554fcf55921f7517ae2394cf10f95524da12f95e Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 17:52:26 -0300 Subject: [PATCH 2/4] fix(sandbox): fail closed when namespace setup cannot be applied unshare does not move the caller into the new PID namespace, and a shared mount tree would let a later umount of /proc hit the host. Fork so the command is PID 1, require MS_PRIVATE, remount proc, and report setup failure on a control pipe: the child writes the byte, and EOF is success. Join the cgroup before that fork, reap on timeout, and close inherited fds. --- src/sandbox/sandbox.c | 339 +++++++++++++++++++++++++++------ src/sandbox/sandbox.h | 29 ++- tests/test_sandbox.c | 432 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 696 insertions(+), 104 deletions(-) diff --git a/src/sandbox/sandbox.c b/src/sandbox/sandbox.c index 927ef3d..192a5b0 100644 --- a/src/sandbox/sandbox.c +++ b/src/sandbox/sandbox.c @@ -1,14 +1,17 @@ /** * @file sandbox.c - * @brief Process sandbox: Linux namespace isolation, cgroups v2, timeout/kill. + * @brief Process sandbox: Linux namespaces, cgroups v2, timeout. * - * 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 the isolator, then unshare mount/network/PID (user ns + * first when unprivileged). Isolation failure is reported on a control pipe, + * not via sh-compatible exit codes. dup2, chdir, PID-1 fork, and workspace + * setup failures write that byte before exiting; a 0-byte read is success. + * After CLONE_NEWPID, fork so the command + * is PID 1 (unshare does not move the caller). That child fchdir's the + * workspace, remounts procfs, sets PR_SET_PDEATHSIG, and closes fds >= 3 + * so a timeout SIGKILL of the isolator cannot leave the command under + * host init. The isolator joins its cgroup before the command fork so + * memory.max and cpu.max apply to sh -c. Limits degrade if unavailable. * * Non-Linux path: plain fork() + execl(); a warning is emitted to stderr. */ @@ -31,18 +34,18 @@ #ifdef __linux__ #include +#include #include +#include #endif #define DEFAULT_TIMEOUT_MS 10000 -#define DEFAULT_MEMORY_MAX (64UL * 1024UL * 1024UL) /* 64 MiB */ +#define DEFAULT_MEMORY_MAX (64UL * 1024UL * 1024UL) #define DEFAULT_CGROUP_BASE "/sys/fs/cgroup" #define CGROUP_NAME_PREFIX "shellclaw_sb_" #define PIPE_POLL_SLICE_MS 500 - -/* ------------------------------------------------------------------ */ -/* cgroups v2 helpers (Linux only) */ -/* ------------------------------------------------------------------ */ +#define SANDBOX_ISO_NS 1 +#define SANDBOX_ISO_LL 2 #ifdef __linux__ @@ -66,23 +69,31 @@ static int cgroup_controllers_available(const char *base) return access(path, F_OK) == 0; } -/** - * Create a cgroup at /, write resource limits, assign @p pid. - * Returns 0 on success; the caller must call cgroup_remove() when done. - */ -static int cgroup_create(const char *base, const char *name, pid_t pid, - size_t memory_max, const char *cpu_max_str) +static int cgroup_prepare(const char *base, const char *name, + size_t memory_max, const char *cpu_max_str) { char cpath[1024]; char val[64]; - snprintf(cpath, sizeof(cpath), "%s/%s", base, name); - if (mkdir(cpath, 0755) != 0 && errno != EEXIST) return -1; + int n; + + n = snprintf(cpath, sizeof(cpath), "%s/%s", base, name); + if (n < 0 || (size_t)n >= sizeof(cpath)) + return -1; + if (mkdir(cpath, 0755) != 0 && errno != EEXIST) + return -1; snprintf(val, sizeof(val), "%zu", memory_max > 0 ? memory_max : DEFAULT_MEMORY_MAX); cgroup_write_file(cpath, "memory.max", val); if (cpu_max_str && cpu_max_str[0]) cgroup_write_file(cpath, "cpu.max", cpu_max_str); - snprintf(val, sizeof(val), "%d", (int)pid); - return cgroup_write_file(cpath, "cgroup.procs", val); + return 0; +} + +/* cgroup v2 does not move descendants that already exist. Write 0 before fork. */ +static int cgroup_join_self(const char *cpath) +{ + if (!cpath || !cpath[0]) + return -1; + return cgroup_write_file(cpath, "cgroup.procs", "0"); } static void cgroup_remove(const char *base, const char *name) @@ -94,10 +105,6 @@ static void cgroup_remove(const char *base, const char *name) #endif /* __linux__ */ -/* ------------------------------------------------------------------ */ -/* Pipe drain with timeout */ -/* ------------------------------------------------------------------ */ - static size_t drain_pipe(int fd, char *buf, size_t cap, int timeout_ms) { size_t total = 0; @@ -136,29 +143,158 @@ static size_t drain_pipe(int fd, char *buf, size_t cap, int timeout_ms) return total; } -/* ------------------------------------------------------------------ */ -/* 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) +{ + if (unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID) != 0) + return -1; + /* Shared mounts would let the later umount2("/proc") hit the host. */ + if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0) + return -1; + return 0; +} + +static int isolate_namespaces(void) +{ + if (unshare_isolation_namespaces() == 0) + return 0; + if (enter_user_namespace() != 0) + return -1; + return unshare_isolation_namespaces(); +} + +static int remount_procfs(void) +{ + (void)umount2("/proc", MNT_DETACH); + if (mount("proc", "/proc", "proc", MS_NOSUID | MS_NOEXEC | MS_NODEV, NULL) != 0) + return -1; + return 0; +} + +static int enter_workspace_cwd(const char *workspace) +{ + int ws_fd; + ws_fd = open(workspace, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (ws_fd < 0) + return -1; + if (fchdir(ws_fd) != 0) { + close(ws_fd); + return -1; + } + close(ws_fd); + return 0; +} + +static unsigned char setup_command_process(const char *workspace) +{ + if (prctl(PR_SET_PDEATHSIG, SIGKILL, 0, 0, 0) != 0) + return SANDBOX_ISO_NS; + if (workspace && workspace[0] && enter_workspace_cwd(workspace) != 0) + return SANDBOX_ISO_NS; + if (remount_procfs() != 0) + return SANDBOX_ISO_NS; + return 0; +} + +#endif /* __linux__ */ + +static void close_inherited_fds(void) +{ +#if defined(__linux__) && defined(__NR_close_range) + if (syscall(__NR_close_range, 3, ~0U, 0) == 0) + return; +#endif + { + int fd; + for (fd = 3; fd < 1024; fd++) + (void)close(fd); + } +} + +static ssize_t read_isolation_byte(int fd, unsigned char *iso, int timeout_ms) +{ + struct pollfd pfd; + int r; + pfd.fd = fd; + pfd.events = POLLIN; + pfd.revents = 0; + r = poll(&pfd, 1, timeout_ms); + if (r <= 0) + return -1; + { + ssize_t n = read(fd, iso, 1); + return n; + } +} -static void setup_child_process(int pipe_wr, const char *workspace) +static unsigned char setup_child_process(int pipe_wr, const char *workspace) { close(STDIN_FILENO); - if (dup2(pipe_wr, STDOUT_FILENO) < 0) _exit(125); - if (dup2(pipe_wr, STDERR_FILENO) < 0) _exit(125); + if (dup2(pipe_wr, STDOUT_FILENO) < 0) + return SANDBOX_ISO_NS; + if (dup2(pipe_wr, STDERR_FILENO) < 0) + return SANDBOX_ISO_NS; close(pipe_wr); #ifdef __linux__ - setsid(); - /* Namespace isolation: mount + network + PID (children of this process). */ - unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID); - prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)workspace; + (void)setsid(); + if (isolate_namespaces() != 0) + return SANDBOX_ISO_NS; + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) + return SANDBOX_ISO_NS; + if (prctl(PR_SET_PDEATHSIG, SIGKILL, 0, 0, 0) != 0) + return SANDBOX_ISO_NS; +#else + if (workspace && workspace[0] && chdir(workspace) != 0) + return SANDBOX_ISO_NS; #endif - if (workspace && workspace[0]) - if (chdir(workspace) != 0) _exit(124); + return 0; } -/* ------------------------------------------------------------------ */ -/* Post-drain wait: kill child if still alive */ -/* ------------------------------------------------------------------ */ +#ifdef __linux__ +static void wait_and_exit_with_child(pid_t cmd_pid) +{ + int st = 0; + + if (waitpid(cmd_pid, &st, 0) < 0) + _exit(125); + if (WIFEXITED(st)) + _exit(WEXITSTATUS(st)); + if (WIFSIGNALED(st)) + _exit(128 + WTERMSIG(st)); + _exit(1); +} +#endif static int reap_child(pid_t pid, int *status_out) { @@ -168,29 +304,43 @@ static int reap_child(pid_t pid, int *status_out) struct timespec ts; int retries; kill(pid, SIGKILL); + (void)kill(-pid, SIGKILL); for (retries = 0; retries < 40; retries++) { wr = waitpid(pid, &st, WNOHANG); if (wr != 0) break; ts.tv_sec = 0; - ts.tv_nsec = 50 * 1000 * 1000; /* 50 ms */ + ts.tv_nsec = 50 * 1000 * 1000; nanosleep(&ts, NULL); } if (wr == 0) waitpid(pid, &st, 0); if (status_out) *status_out = st; - return 1; /* did time out */ + return 1; } if (status_out) *status_out = st; return 0; } -/* ------------------------------------------------------------------ */ -/* Public API */ -/* ------------------------------------------------------------------ */ +static int report_isolation_failure(char *out, size_t out_cap, unsigned char iso) +{ + if (iso == SANDBOX_ISO_LL) + snprintf(out, out_cap, "sandbox: Landlock filesystem bound failed"); + else + snprintf(out, out_cap, "sandbox: namespace isolation failed"); + return -1; +} + +static int close_pipes_pair(int a, int b) +{ + close(a); + close(b); + return -1; +} int sandbox_exec(const char *cmd, char *out, size_t out_cap, int timeout_ms, const sandbox_config_t *cfg) { int pipefd[2]; + int errpipe[2]; pid_t pid; size_t total; int timed_out = 0; @@ -198,10 +348,13 @@ int sandbox_exec(const char *cmd, char *out, size_t out_cap, int used_cgroup = 0; #ifdef __linux__ char cgroup_name[80]; + char cgroup_dir[1024]; const char *cgroup_base = (cfg && cfg->cgroup_base && cfg->cgroup_base[0]) ? cfg->cgroup_base : DEFAULT_CGROUP_BASE; size_t memory_max = cfg ? cfg->memory_max_bytes : 0; const char *cpu_max_str = cfg ? cfg->cpu_max : NULL; + static unsigned cgroup_seq; + cgroup_dir[0] = '\0'; #endif if (!cmd || !out || out_cap == 0) return -1; out[0] = '\0'; @@ -217,36 +370,98 @@ int sandbox_exec(const char *cmd, char *out, size_t out_cap, } #endif if (pipe(pipefd) != 0) return -1; - if (fcntl(pipefd[0], F_SETFD, FD_CLOEXEC) != 0) { + if (pipe(errpipe) != 0) { close(pipefd[0]); close(pipefd[1]); return -1; } - /* Write end must NOT have CLOEXEC so child inherits it. */ + if (fcntl(pipefd[0], F_SETFD, FD_CLOEXEC) != 0 || + fcntl(errpipe[0], F_SETFD, FD_CLOEXEC) != 0 || + fcntl(errpipe[1], F_SETFD, FD_CLOEXEC) != 0) { + close(errpipe[0]); + close(errpipe[1]); + return close_pipes_pair(pipefd[0], pipefd[1]); + } +#ifdef __linux__ + if (cgroup_controllers_available(cgroup_base)) { + cgroup_seq++; + snprintf(cgroup_name, sizeof(cgroup_name), "%s%d_%u", + CGROUP_NAME_PREFIX, (int)getpid(), cgroup_seq); + if (cgroup_prepare(cgroup_base, cgroup_name, memory_max, cpu_max_str) == 0) { + int n = snprintf(cgroup_dir, sizeof(cgroup_dir), "%s/%s", + cgroup_base, cgroup_name); + if (n > 0 && (size_t)n < sizeof(cgroup_dir)) + used_cgroup = 1; + } else { + fprintf(stderr, "sandbox: cgroup setup failed (non-fatal)\n"); + } + } +#endif pid = fork(); if (pid < 0) { - close(pipefd[0]); - close(pipefd[1]); - return -1; + close(errpipe[0]); + close(errpipe[1]); +#ifdef __linux__ + if (used_cgroup) + cgroup_remove(cgroup_base, cgroup_name); +#endif + return close_pipes_pair(pipefd[0], pipefd[1]); } if (pid == 0) { - /* Child */ + unsigned char iso; close(pipefd[0]); - setup_child_process(pipefd[1], workspace); + close(errpipe[0]); +#ifdef __linux__ + if (used_cgroup && cgroup_join_self(cgroup_dir) != 0) + fprintf(stderr, "sandbox: cgroup join failed (non-fatal)\n"); +#endif + iso = setup_child_process(pipefd[1], workspace); + if (iso != 0) { + if (write(errpipe[1], &iso, 1) < 0) { /* parent fail-closes on EOF */ } + _exit(1); + } +#ifdef __linux__ + { + pid_t cmd_pid = fork(); + if (cmd_pid < 0) { + unsigned char fork_iso = SANDBOX_ISO_NS; + if (write(errpipe[1], &fork_iso, 1) < 0) { /* parent fail-closes on EOF */ } + _exit(1); + } + if (cmd_pid > 0) { + close(errpipe[1]); + wait_and_exit_with_child(cmd_pid); + } + iso = setup_command_process(workspace); + if (iso != 0) { + if (write(errpipe[1], &iso, 1) < 0) { /* parent fail-closes on EOF */ } + _exit(1); + } + } +#endif + close(errpipe[1]); + close_inherited_fds(); execl("/bin/sh", "sh", "-c", cmd, (char *)NULL); _exit(127); } - /* Parent */ close(pipefd[1]); + close(errpipe[1]); + { + unsigned char iso = 0; + ssize_t n = read_isolation_byte(errpipe[0], &iso, timeout_ms); + close(errpipe[0]); + if (n != 0) { + (void)reap_child(pid, NULL); #ifdef __linux__ - if (cgroup_controllers_available(cgroup_base)) { - snprintf(cgroup_name, sizeof(cgroup_name), "%s%d", CGROUP_NAME_PREFIX, (int)pid); - if (cgroup_create(cgroup_base, cgroup_name, pid, memory_max, cpu_max_str) == 0) - used_cgroup = 1; - else - fprintf(stderr, "sandbox: cgroup setup failed for pid %d (non-fatal)\n", (int)pid); - } + if (used_cgroup) + cgroup_remove(cgroup_base, cgroup_name); #endif + close(pipefd[0]); + if (n != 1) + iso = SANDBOX_ISO_NS; + return report_isolation_failure(out, out_cap, iso); + } + } total = drain_pipe(pipefd[0], out, out_cap, timeout_ms); close(pipefd[0]); timed_out = reap_child(pid, NULL); diff --git a/src/sandbox/sandbox.h b/src/sandbox/sandbox.h index 5d255d7..280a7dc 100644 --- a/src/sandbox/sandbox.h +++ b/src/sandbox/sandbox.h @@ -1,10 +1,17 @@ /** * @file sandbox.h - * @brief Process sandbox API: isolated execution with namespaces, timeout, and cgroups v2. + * @brief Process sandbox API: namespaces, 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 an isolator, unshares PID/mount/network + * namespaces (entering a user namespace when unprivileged), then forks again + * so the command is PID 1. Isolation failure is fail-closed via a control + * pipe (not sh exit 122/123). The copied mount tree must become + * MS_REC|MS_PRIVATE, and the command child remounts proc. Optional cgroups + * v2 resource limits are joined before the command fork. A hard timeout + * SIGKILLs the isolator; the command child sets PR_SET_PDEATHSIG so the + * PID-1 process cannot outlive it. + * 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 +49,13 @@ 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 and forks so the command is + * PID 1. If those namespaces or MS_PRIVATE cannot be applied, returns -1 + * (fail-closed). Setup failure is a byte on the control pipe; EOF is + * success. Applies cgroups v2 limits when available, joining before the + * command fork. On timeout the parent SIGKILLs the isolator; the command + * is PID 1 and dies via PR_SET_PDEATHSIG and process-group kill. * * On non-Linux platforms the function executes the command via fork()+exec() * without namespace isolation and emits a warning to stderr. @@ -55,7 +66,9 @@ 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). + * + * Example: sandbox_exec("echo hi", out, sizeof out, 5000, &cfg); */ int sandbox_exec(const char *cmd, char *out, size_t out_cap, int timeout_ms, const sandbox_config_t *cfg); diff --git a/tests/test_sandbox.c b/tests/test_sandbox.c index 4c653c3..6febd6f 100644 --- a/tests/test_sandbox.c +++ b/tests/test_sandbox.c @@ -2,22 +2,35 @@ * @file test_sandbox.c * @brief Unit tests for sandbox_exec. * - * Linux-specific namespace and cgroup tests are guarded by #ifdef __linux__ - * and runtime checks for userns / cgroup availability. - * On macOS and other platforms, the tests fall back to verifying basic - * fork+exec behaviour: output capture, timeout, and null-safety. + * Linux-specific namespace and cgroup tests are guarded by + * #ifdef __linux__. GitHub-hosted runners often cannot apply user namespaces + * (sandbox_exec fail-closes; success-path tests skip). Do not weaken + * sandbox.c for CI. * * 5.7 Benchmark: run sandbox_exec("true") 200 times and report median. * The benchmark is informational only — it does not gate the test suite. */ +#define _DEFAULT_SOURCE +#define _DARWIN_C_SOURCE #define _POSIX_C_SOURCE 200809L #include "sandbox/sandbox.h" +#include +#include #include #include #include #include #include +#ifdef __linux__ +#include +#include +#include +#include +#include +#include +#include +#endif #define ASSERT(c) do { \ if (!(c)) { \ @@ -28,14 +41,94 @@ #define RUN(t) do { int r_ = (t); if (r_) return r_; } while (0) -/* ------------------------------------------------------------------ */ -/* Basic functionality */ -/* ------------------------------------------------------------------ */ +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; +} + +#ifdef __linux__ +static int proc_cmdline_has(const char *needle) +{ + DIR *d; + struct dirent *e; + d = opendir("/proc"); + if (!d) + return 0; + while ((e = readdir(d)) != NULL) { + char path[288]; + char buf[256]; + FILE *f; + size_t n; + size_t i; + if (e->d_name[0] < '1' || e->d_name[0] > '9') + continue; + if (strlen(e->d_name) > 16) + continue; + snprintf(path, sizeof path, "/proc/%s/cmdline", e->d_name); + f = fopen(path, "r"); + if (!f) + continue; + n = fread(buf, 1, sizeof buf - 1, f); + fclose(f); + for (i = 0; i < n; i++) { + if (buf[i] == '\0') + buf[i] = ' '; + } + buf[n] = '\0'; + if (strstr(buf, needle) != NULL) { + closedir(d); + return 1; + } + } + closedir(d); + return 0; +} +#endif + +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; +} + +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 +138,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; @@ -67,9 +162,84 @@ static int test_zero_cap_returns_error(void) 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; +} + +static int test_exit_122_is_not_isolation_failure(void) +{ + char out[4096]; + int rc = sandbox_exec("exit 122", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_exit_122_is_not_isolation_failure")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "namespace isolation failed") == NULL); + ASSERT(strstr(out, "Landlock filesystem bound failed") == NULL); + return 0; +} + +static int test_exit_123_with_workspace_is_not_isolation_failure(void) +{ + char out[4096]; + sandbox_config_t cfg; + char ws[] = "/tmp/sc_sb_ex_XXXXXX"; + char *dir; + int rc; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_exit_123_with_workspace_is_not_isolation_failure: mkdtemp failed\n"); + return 1; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = dir; + rc = sandbox_exec("exit 123", out, sizeof(out), 5000, &cfg); + rmdir(dir); + if (skip_if_isolation_denied(rc, out, "test_exit_123_with_workspace_is_not_isolation_failure")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "namespace isolation failed") == NULL); + ASSERT(strstr(out, "Landlock filesystem bound failed") == NULL); + return 0; +} + +static int test_pidns_fork_allows_second_command(void) +{ + char out[4096]; + int rc = sandbox_exec("/bin/echo A; /bin/echo B", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_pidns_fork_allows_second_command")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "Cannot fork") == NULL); + ASSERT(strstr(out, "A") != NULL); + ASSERT(strstr(out, "B") != NULL); + return 0; +} + +static int test_dev_null_is_writable(void) +{ + char out[4096]; + sandbox_config_t cfg; + char ws[] = "/tmp/sc_sb_null_XXXXXX"; + char *dir; + int rc; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_dev_null_is_writable: mkdtemp failed\n"); + return 1; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = dir; + rc = sandbox_exec("echo hi >/dev/null && echo OK", out, sizeof(out), 5000, &cfg); + rmdir(dir); + if (skip_if_isolation_denied(rc, out, "test_dev_null_is_writable")) + return 0; ASSERT(rc == 0); + ASSERT(strstr(out, "OK") != NULL); return 0; } @@ -77,50 +247,200 @@ 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; } -/* ------------------------------------------------------------------ */ -/* Timeout test */ -/* ------------------------------------------------------------------ */ +static int test_missing_workspace_fail_closed(void) +{ + char out[4096]; + sandbox_config_t cfg; + int rc; + + memset(&cfg, 0, sizeof cfg); + memset(out, 0, sizeof out); + cfg.workspace_path = "/no/such/sc_ws_missing_dir"; + rc = sandbox_exec("echo should_not_run", out, sizeof out, 3000, &cfg); + ASSERT(rc == -1); + ASSERT(strstr(out, "should_not_run") == NULL); + ASSERT(strstr(out, "sandbox:") != NULL); + return 0; +} 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); + int rc = sandbox_exec("sleep 86401", 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); +#ifdef __linux__ + { + int tries; + for (tries = 0; tries < 20; tries++) { + if (!proc_cmdline_has("sleep 86401")) + break; + { + struct timespec ts; + ts.tv_sec = 0; + ts.tv_nsec = 50 * 1000 * 1000; + nanosleep(&ts, NULL); + } + } + ASSERT(proc_cmdline_has("sleep 86401") == 0); + } +#endif + return 0; +} + +#ifdef __linux__ +static int test_inherited_fd_is_closed(void) +{ + char dir[] = "/tmp/sc_sb_fd_XXXXXX"; + char path[256]; + char cmd[128]; + char out[4096]; + char *ws; + int fd; + int rc; + ws = mkdtemp(dir); + if (!ws) { + fprintf(stderr, "test_inherited_fd_is_closed: mkdtemp failed\n"); + return 1; + } + snprintf(path, sizeof path, "%s/secret", ws); + fd = open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0600); + if (fd < 0) { + rmdir(ws); + return 1; + } + if (write(fd, "SECRET_FD_LEAK\n", 15) != 15) { + close(fd); + unlink(path); + rmdir(ws); + return 1; + } + close(fd); + fd = open(path, O_RDONLY); + if (fd < 0) { + unlink(path); + rmdir(ws); + return 1; + } + snprintf(cmd, sizeof cmd, "cat /dev/fd/%d 2>&1; echo EXIT:$?", fd); + rc = sandbox_exec(cmd, out, sizeof out, 5000, NULL); + close(fd); + unlink(path); + rmdir(ws); + if (skip_if_isolation_denied(rc, out, "test_inherited_fd_is_closed")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "SECRET_FD_LEAK") == NULL); return 0; } -/* ------------------------------------------------------------------ */ -/* Linux-only: namespace isolation (shadow should not be readable) */ -/* ------------------------------------------------------------------ */ +static int test_proc_is_namespaced(void) +{ + char out[4096]; + int rc; + rc = sandbox_exec("tr '\\0' ' ' < /proc/1/cmdline; echo", out, sizeof out, 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_proc_is_namespaced")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "systemd") == NULL); + ASSERT(strstr(out, "sh") != NULL); + return 0; +} + +#endif #ifdef __linux__ 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. */ + 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); + ASSERT(strstr(out, "root:") == NULL); return 0; } -#endif -/* ------------------------------------------------------------------ */ -/* 5.7 Benchmark: sandbox_exec("true") N times */ -/* ------------------------------------------------------------------ */ +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 static int benchmark_sandbox_exec(void) { @@ -128,8 +448,13 @@ 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; @@ -141,7 +466,6 @@ static int benchmark_sandbox_exec(void) times_us[i] = diff_us; sum += diff_us; } - /* Simple selection sort for median (small N). */ for (i = 0; i < BENCH_N - 1; i++) { int j, min_idx = i; for (j = i + 1; j < BENCH_N; j++) @@ -155,33 +479,73 @@ static int benchmark_sandbox_exec(void) median_us = times_us[BENCH_N / 2]; printf("sandbox_exec('true') N=%d: median=%ld µs avg=%ld µs\n", BENCH_N, median_us, sum / BENCH_N); - /* - * PRD §4.7.35 target: clone + setup < 1 ms. - * This assertion is informational; we mark slow CI as a skip rather than - * a hard failure. Uncomment the hard assert for local development: - * ASSERT(median_us < 1000); - */ if (median_us >= 2000) fprintf(stderr, "sandbox bench: median %ld µs exceeds 2 ms target (CI may be slow)\n", median_us); return 0; } -/* ------------------------------------------------------------------ */ -/* main */ -/* ------------------------------------------------------------------ */ +#ifdef __linux__ +static int test_command_inherits_cgroup(void) +{ + char probe[160]; + char out[4096]; + char *cg; + char *dir; + char *nl; + int rc; + + snprintf(probe, sizeof probe, "/sys/fs/cgroup/shellclaw_probe_%d", (int)getpid()); + if (mkdir(probe, 0755) != 0) { + fprintf(stderr, + "test_sandbox: skip test_command_inherits_cgroup (mkdir errno %d)\n", + errno); + return 0; + } + rmdir(probe); + rc = sandbox_exec( + "echo CG:$(cat /proc/self/cgroup); echo DIR:$(ls /sys/fs/cgroup 2>/dev/null | grep shellclaw_sb_ || true)", + out, sizeof out, 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_command_inherits_cgroup")) + return 0; + ASSERT(rc == 0); + dir = strstr(out, "DIR:"); + if (!dir || dir[4] == '\0' || dir[4] == '\n') { + fprintf(stderr, "test_sandbox: skip test_command_inherits_cgroup (no cgroup dir)\n"); + return 0; + } + cg = strstr(out, "CG:"); + ASSERT(cg != NULL); + nl = strchr(cg, '\n'); + if (nl) + *nl = '\0'; + ASSERT(strstr(cg, "shellclaw_sb_") != NULL); + return 0; +} +#endif 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()); RUN(test_zero_cap_returns_error()); RUN(test_exit_nonzero_runs()); + RUN(test_exit_122_is_not_isolation_failure()); + RUN(test_exit_123_with_workspace_is_not_isolation_failure()); + RUN(test_pidns_fork_allows_second_command()); + RUN(test_dev_null_is_writable()); RUN(test_workspace_chdir()); + RUN(test_missing_workspace_fail_closed()); RUN(test_timeout_kills_process()); #ifdef __linux__ + RUN(test_command_inherits_cgroup()); + RUN(test_inherited_fd_is_closed()); + RUN(test_proc_is_namespaced()); RUN(test_shadow_not_accessible()); + RUN(test_network_namespace_blocks_host_loopback()); #else fprintf(stderr, "test_sandbox: Linux-only namespace tests skipped on this platform\n"); #endif From 541195a30a952a0cb2ba15458977052b28c9c901 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 17:52:39 -0300 Subject: [PATCH 3/4] fix(sandbox): bound host files with Landlock on the workspace The string allowlist cannot see interpreter concatenation such as chr(47)+. Enter the workspace before restrict_self, grant read on /etc/ssl/certs only, and split prepare() from restrict_self so gcov can still write. Landlock setup failure is fail-closed on the isolation pipe. --- CHANGELOG.md | 1 + Makefile | 11 +- src/sandbox/sandbox.c | 9 +- src/sandbox/sandbox.h | 23 +-- src/sandbox/sandbox_landlock.c | 243 ++++++++++++++++++++++++++ src/sandbox/sandbox_landlock.h | 39 +++++ src/tools/shell.c | 5 +- tests/test_sandbox.c | 307 ++++++++++++++++++++++++++++++++- 8 files changed, 617 insertions(+), 21 deletions(-) create mode 100644 src/sandbox/sandbox_landlock.c create mode 100644 src/sandbox/sandbox_landlock.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 710582b..f91e866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - Shell `workspace_only` walks the first existing ancestor, collapses `..` lexically (without cancelling across a symlink), and scans quoted/embedded paths, `file:` URLs, `$HOME`/`$PWD` (including glued `$IFS`), and bare relative names such as `cat leak`. `strdup` OOM is fail-closed. Encoded-slash cat-and-mouse is frozen; Landlock is the kernel host-FS bound. +- 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). After `CLONE_NEWPID` the isolator forks so the command is PID 1. Isolation failure uses a control pipe, not `sh` exit 122/123. `/dev/null` stays writable under Landlock. - Default `workspace_path` is `~/.shellclaw/workspace`, and file/shell tools still refuse `auth_tokens.json`, `shellclaw.pid`, `shellclaw.log`, and `.shellclaw` `config.toml` / `memory.db` (including `memory.db-*` sidecars) when a custom workspace contains them. A bare `cat config.toml` after the sandbox `chdir` is blocked, and a symlink at the workspace path is not accepted. - Dashboard `PUT /api/config` merges JSON fields into `config.toml` and reloads live settings. Indented keys and `[section] # comment` headers are updated in place; a present field with the wrong JSON type returns 400; a saved file whose live reload fails returns 500. Gateway host and port still need a process restart to rebind. - Unsandboxed `shell` no longer blocks forever in `waitpid` after the output cap fills; leftover children (including background grandchildren) are SIGKILL'd via the command process group, and truncated capture is NUL-terminated (#69). diff --git a/Makefile b/Makefile index ae2a06d..f40f391 100644 --- a/Makefile +++ b/Makefile @@ -125,7 +125,9 @@ CRON_O := src/tools/cron.o ASAP_INVOKE_O := src/tools/asap_invoke.o ASAP_INVOKE_TEST_O := $(BINDIR)/asap_invoke_test.o # Sandbox (Phase 3 §5) -SANDBOX_O := src/sandbox/sandbox.o +SANDBOX_CORE_O := src/sandbox/sandbox.o +SANDBOX_LANDLOCK_O := src/sandbox/sandbox_landlock.o +SANDBOX_O := $(SANDBOX_CORE_O) $(SANDBOX_LANDLOCK_O) ALLOWLIST_SCAN_O := src/sandbox/allowlist.o ALLOWLIST_PATH_O := src/sandbox/allowlist_path.o ALLOWLIST_O := $(ALLOWLIST_SCAN_O) $(ALLOWLIST_PATH_O) @@ -383,9 +385,12 @@ $(SHELL_O): src/tools/shell.c src/tools/tool.h src/tools/shell.h src/core/config src/sandbox/sandbox.h src/sandbox/allowlist.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/tools/shell.c -$(SANDBOX_O): src/sandbox/sandbox.c src/sandbox/sandbox.h +$(SANDBOX_CORE_O): src/sandbox/sandbox.c src/sandbox/sandbox.h src/sandbox/sandbox_landlock.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/sandbox.c +$(SANDBOX_LANDLOCK_O): src/sandbox/sandbox_landlock.c src/sandbox/sandbox_landlock.h + $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/sandbox_landlock.c + $(ALLOWLIST_SCAN_O): src/sandbox/allowlist.c src/sandbox/allowlist.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/allowlist.c @@ -904,7 +909,7 @@ clean-root-dsym: @rm -f shellclaw test_agent test_anthropic test_channel test_cli test_config test_file test_memory test_local_provider test_openai test_provider test_router test_shell test_skill test_telegram test_web_search test_ws clean: clean-root-dsym - rm -f $(OBJS) $(PROVIDER_COMMON_O) $(STUB_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) $(ROUTER_O) $(CJSON_O) $(TWEETNACL_O) $(ANTHROPIC_TEST_O) $(OPENAI_TEST_O) $(LOCAL_TEST_O) $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(CHANNEL_TG_TEST_O) $(CHANNEL_COMMON_O) $(CHANNEL_STUB_O) $(CHANNEL_CLI_O) $(CHANNEL_TG_O) $(CHANNEL_DISCORD_O) $(DISCORD_HELPERS_O) $(CHANNEL_HEARTBEAT_O) $(CHANNEL_WEBCHAT_O) $(AUTH_O) $(STATIC_O) $(HTTP_O) $(HTTP_LWS_O) $(ASAP_HTTP_BODY_O) $(ROUTES_O) $(ROUTES_HARDWARE_O) $(WS_O) $(MANIFEST_O) $(MANIFEST_PROFILES_O) $(MANIFEST_BUILD_O) $(MANIFEST_SIGN_O) $(MANIFEST_KEYS_O) $(ENVELOPE_O) $(ULID_O) $(CLIENT_O) $(ASAP_REGISTRY_O) $(SERVER_O) $(ASAP_LOG_O) $(RATE_LIMIT_O) $(SHELL_O) $(WEBSEARCH_O) $(FILE_O) $(REGISTRY_O) $(CONTEXT_O) $(CONTEXT_CACHE_O) $(CONTEXT_HTTP_O) $(CONTEXT_GEO_O) $(CRYPTO_O) $(JCS_O) $(HARDWARE_STUB_O) $(HARDWARE_INIT_O) $(HARDWARE_GPIO_SNAPSHOT_O) $(HARDWARE_TEGRASTATS_O) $(HARDWARE_TOOLS_O) $(BOARD_DETECT_O) src/hardware/hardware_libgpiod.o $(HARDWARE_I2C_O) $(HARDWARE_CAMERA_O) $(CRON_O) $(ASAP_INVOKE_O) $(SANDBOX_O) $(ALLOWLIST_O) + rm -f $(OBJS) $(PROVIDER_COMMON_O) $(STUB_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) $(ROUTER_O) $(CJSON_O) $(TWEETNACL_O) $(ANTHROPIC_TEST_O) $(OPENAI_TEST_O) $(LOCAL_TEST_O) $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(CHANNEL_TG_TEST_O) $(CHANNEL_COMMON_O) $(CHANNEL_STUB_O) $(CHANNEL_CLI_O) $(CHANNEL_TG_O) $(CHANNEL_DISCORD_O) $(DISCORD_HELPERS_O) $(CHANNEL_HEARTBEAT_O) $(CHANNEL_WEBCHAT_O) $(AUTH_O) $(STATIC_O) $(HTTP_O) $(HTTP_LWS_O) $(ASAP_HTTP_BODY_O) $(ROUTES_O) $(ROUTES_HARDWARE_O) $(WS_O) $(MANIFEST_O) $(MANIFEST_PROFILES_O) $(MANIFEST_BUILD_O) $(MANIFEST_SIGN_O) $(MANIFEST_KEYS_O) $(ENVELOPE_O) $(ULID_O) $(CLIENT_O) $(ASAP_REGISTRY_O) $(SERVER_O) $(ASAP_LOG_O) $(RATE_LIMIT_O) $(SHELL_O) $(WEBSEARCH_O) $(FILE_O) $(REGISTRY_O) $(CONTEXT_O) $(CONTEXT_CACHE_O) $(CONTEXT_HTTP_O) $(CONTEXT_GEO_O) $(CRYPTO_O) $(JCS_O) $(HARDWARE_STUB_O) $(HARDWARE_INIT_O) $(HARDWARE_GPIO_SNAPSHOT_O) $(HARDWARE_TEGRASTATS_O) $(HARDWARE_TOOLS_O) $(BOARD_DETECT_O) src/hardware/hardware_libgpiod.o $(HARDWARE_I2C_O) $(HARDWARE_CAMERA_O) $(CRON_O) $(ASAP_INVOKE_O) $(SANDBOX_CORE_O) $(SANDBOX_LANDLOCK_O) $(ALLOWLIST_SCAN_O) $(ALLOWLIST_PATH_O) rm -f src/gateway/ui_assets.h find . -name '*.gcno' -o -name '*.gcda' -o -name '*.gcov' | xargs rm -f 2>/dev/null || true rm -f $(WS_TEST_O) $(BINDIR)/asap_registry_test.o $(BINDIR)/asap_invoke_test.o $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(BINDIR)/shellclaw $(BINDIR)/test_tweetnacl_smoke $(BINDIR)/test_config $(BINDIR)/test_config_patch $(BINDIR)/test_memory $(BINDIR)/test_skill $(BINDIR)/test_provider $(BINDIR)/test_anthropic $(BINDIR)/test_openai $(BINDIR)/test_local_provider $(BINDIR)/test_router $(BINDIR)/test_heartbeat $(BINDIR)/test_crypto $(BINDIR)/test_hardware_stub $(BINDIR)/test_board_detect $(BINDIR)/test_hardware_libgpiod $(BINDIR)/test_hardware_i2c $(BINDIR)/test_hardware_camera $(BINDIR)/test_pin_tables $(BINDIR)/test_hardware_init $(BINDIR)/test_hardware_tools $(BINDIR)/test_registry $(BINDIR)/test_ws $(BINDIR)/test_agent $(BINDIR)/test_channel $(BINDIR)/test_cli $(BINDIR)/test_shell $(BINDIR)/test_file $(BINDIR)/test_telegram $(BINDIR)/test_discord_helpers $(BINDIR)/test_web_search $(BINDIR)/test_cron $(BINDIR)/test_context $(BINDIR)/test_manifest_build $(BINDIR)/test_manifest_keys $(BINDIR)/test_jcs $(BINDIR)/test_asap_envelope $(BINDIR)/test_asap_ulid $(BINDIR)/test_asap_client $(BINDIR)/test_asap_registry $(BINDIR)/test_asap_server $(BINDIR)/test_asap_invoke $(BINDIR)/test_asap_log $(BINDIR)/test_auth $(BINDIR)/test_gateway_http $(BINDIR)/test_static $(BINDIR)/test_sandbox $(BINDIR)/test_allowlist $(BINDIR)/test_rate_limit diff --git a/src/sandbox/sandbox.c b/src/sandbox/sandbox.c index 192a5b0..4aac1a7 100644 --- a/src/sandbox/sandbox.c +++ b/src/sandbox/sandbox.c @@ -1,6 +1,6 @@ /** * @file sandbox.c - * @brief Process sandbox: Linux namespaces, cgroups v2, timeout. + * @brief Process sandbox: Linux namespaces, Landlock FS bound, cgroups v2. * * Linux path: fork the isolator, then unshare mount/network/PID (user ns * first when unprivileged). Isolation failure is reported on a control pipe, @@ -8,7 +8,8 @@ * setup failures write that byte before exiting; a 0-byte read is success. * After CLONE_NEWPID, fork so the command * is PID 1 (unshare does not move the caller). That child fchdir's the - * workspace, remounts procfs, sets PR_SET_PDEATHSIG, and closes fds >= 3 + * workspace (Landlock cannot walk `/tmp` after restrict_self), remounts + * procfs, applies Landlock, sets PR_SET_PDEATHSIG, and closes fds >= 3 * so a timeout SIGKILL of the isolator cannot leave the command under * host init. The isolator joins its cgroup before the command fork so * memory.max and cpu.max apply to sh -c. Limits degrade if unavailable. @@ -19,6 +20,7 @@ #define _POSIX_C_SOURCE 200809L #include "sandbox/sandbox.h" +#include "sandbox/sandbox_landlock.h" #include #include #include @@ -223,6 +225,9 @@ static unsigned char setup_command_process(const char *workspace) return SANDBOX_ISO_NS; if (remount_procfs() != 0) return SANDBOX_ISO_NS; + if (workspace && workspace[0] && + sandbox_landlock_restrict_to_workspace(workspace) != 0) + return SANDBOX_ISO_LL; return 0; } diff --git a/src/sandbox/sandbox.h b/src/sandbox/sandbox.h index 280a7dc..277d513 100644 --- a/src/sandbox/sandbox.h +++ b/src/sandbox/sandbox.h @@ -1,15 +1,14 @@ /** * @file sandbox.h - * @brief Process sandbox API: namespaces, timeout, cgroups v2. + * @brief Process sandbox API: namespaces, Landlock FS bound, timeout, cgroups v2. * * On Linux, sandbox_exec forks an isolator, unshares PID/mount/network * namespaces (entering a user namespace when unprivileged), then forks again * so the command is PID 1. Isolation failure is fail-closed via a control - * pipe (not sh exit 122/123). The copied mount tree must become - * MS_REC|MS_PRIVATE, and the command child remounts proc. Optional cgroups - * v2 resource limits are joined before the command fork. A hard timeout - * SIGKILLs the isolator; the command child sets PR_SET_PDEATHSIG so the - * PID-1 process cannot outlive it. + * pipe (not sh exit 122/123). When a workspace path is set, a Landlock + * ruleset is the kernel filesystem bound. Optional cgroups v2 resource + * limits and a hard timeout with SIGKILL (the command child sets + * PR_SET_PDEATHSIG so the PID-1 process cannot outlive the isolator). * On other platforms (macOS, BSDs) it falls back to a plain fork+exec * and logs a warning. */ @@ -51,11 +50,13 @@ typedef struct sandbox_config { * * On Linux, enters a user namespace when needed, then unshares * CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET and forks so the command is - * PID 1. If those namespaces or MS_PRIVATE cannot be applied, returns -1 - * (fail-closed). Setup failure is a byte on the control pipe; EOF is - * success. Applies cgroups v2 limits when available, joining before the - * command fork. On timeout the parent SIGKILLs the isolator; the command - * is PID 1 and dies via PR_SET_PDEATHSIG and process-group kill. + * PID 1. 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. On timeout the + * parent SIGKILLs the isolator; the command is PID 1 and dies via + * PR_SET_PDEATHSIG and process-group kill. * * On non-Linux platforms the function executes the command via fork()+exec() * without namespace isolation and emits a warning to stderr. diff --git a/src/sandbox/sandbox_landlock.c b/src/sandbox/sandbox_landlock.c new file mode 100644 index 0000000..fd2d040 --- /dev/null +++ b/src/sandbox/sandbox_landlock.c @@ -0,0 +1,243 @@ +/** + * @file sandbox_landlock.c + * @brief Linux Landlock ruleset: RW workspace, RO system paths, RW /dev/null. + * + * Probe the kernel ABI and pass only bits it understands. create_ruleset uses + * the ABI-1 attr size so older kernels are not E2BIG. Missing optional RO + * paths are skipped; the workspace rule and restrict_self fail closed. + */ +#define _GNU_SOURCE +#define _POSIX_C_SOURCE 200809L + +#include "sandbox/sandbox_landlock.h" + +#include +#include +#include +#include + +#ifdef __linux__ +#include +#include +#endif + +#ifndef __linux__ +int sandbox_landlock_restrict_to_workspace(const char *workspace) +{ + (void)workspace; + return 0; +} + +int sandbox_landlock_prepare(const char *workspace) +{ + (void)workspace; + return 0; +} +#else + +#ifndef LANDLOCK_ACCESS_FS_IOCTL_DEV +#define LANDLOCK_ACCESS_FS_IOCTL_DEV (1ULL << 15) +#endif + +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; +} + +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 + if (abi >= 5) + handled |= LANDLOCK_ACCESS_FS_IOCTL_DEV; + *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; +} + +static __u64 mask_workspace_access(__u64 handled) +{ + __u64 access; + + 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 + access |= (LANDLOCK_ACCESS_FS_REFER & handled); +#endif +#ifdef LANDLOCK_ACCESS_FS_TRUNCATE + access |= (LANDLOCK_ACCESS_FS_TRUNCATE & handled); +#endif + return access; +} + +#define LANDLOCK_FD_SKIP (-2) + +static int landlock_make_ruleset(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", + /* Public CA bundle only. /etc/ssl/private stays outside the ruleset. */ + "/etc/ssl/certs", "/etc/nsswitch.conf", "/etc/hosts", "/etc/resolv.conf", + "/proc", + NULL + }; + static const char *const RW_DEV[] = { + "/dev/null", "/dev/zero", "/dev/urandom", "/dev/tty", + NULL + }; + __u64 handled; + __u64 workspace_access; + __u64 ro_dir; + __u64 ro_file; + __u64 rw_file; + struct landlock_ruleset_attr attr; + int ruleset_fd; + size_t i; + + if (!workspace || !workspace[0]) + return LANDLOCK_FD_SKIP; + if (landlock_handled_fs(&handled) != 0) + return -1; + workspace_access = mask_workspace_access(handled); + 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; + rw_file = (LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_WRITE_FILE) & handled; +#ifdef LANDLOCK_ACCESS_FS_TRUNCATE + rw_file |= (LANDLOCK_ACCESS_FS_TRUNCATE & handled); +#endif + rw_file |= (LANDLOCK_ACCESS_FS_IOCTL_DEV & handled); + memset(&attr, 0, sizeof(attr)); + attr.handled_access_fs = handled; + 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; + } + /* Traverse-only `/` so execl("/bin/sh") can walk to RO trees. READ_DIR + * does not grant READ_FILE, so /etc/passwd stays closed. */ + if (landlock_add_workspace(ruleset_fd, "/", + LANDLOCK_ACCESS_FS_READ_DIR & handled) != 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); + for (i = 0; RW_DEV[i]; i++) + (void)landlock_add_path(ruleset_fd, RW_DEV[i], ro_dir, rw_file); + return ruleset_fd; +} + +int sandbox_landlock_prepare(const char *workspace) +{ + int fd; + fd = landlock_make_ruleset(workspace); + if (fd == LANDLOCK_FD_SKIP) + return 0; + if (fd < 0) + return -1; + close(fd); + return 0; +} + +int sandbox_landlock_restrict_to_workspace(const char *workspace) +{ + int fd; + long rc; + fd = landlock_make_ruleset(workspace); + if (fd == LANDLOCK_FD_SKIP) + return 0; + if (fd < 0) + return -1; + rc = syscall(__NR_landlock_restrict_self, fd, 0); + close(fd); + return (rc == 0) ? 0 : -1; +} + +#endif /* __linux__ */ diff --git a/src/sandbox/sandbox_landlock.h b/src/sandbox/sandbox_landlock.h new file mode 100644 index 0000000..ff48819 --- /dev/null +++ b/src/sandbox/sandbox_landlock.h @@ -0,0 +1,39 @@ +/** + * @file sandbox_landlock.h + * @brief Landlock workspace filesystem bound for sandbox_exec (Linux). + * + * Non-Linux builds compile a stub that returns 0. On Linux, failure is + * fail-closed: the caller must not exec on the host tree. + */ +#ifndef SHELLCLAW_SANDBOX_LANDLOCK_H +#define SHELLCLAW_SANDBOX_LANDLOCK_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Restrict the calling thread to @p workspace (RW) plus a small RO/exec set + * needed by /bin/sh. Empty @p workspace skips Landlock (returns 0). + * + * @param workspace Absolute workspace directory, or NULL/empty to skip. + * @return 0 on success or skip; -1 if the ruleset cannot apply. + * + * Example: sandbox_landlock_restrict_to_workspace("/home/user/.shellclaw"); + */ +int sandbox_landlock_restrict_to_workspace(const char *workspace); + +/** + * Build and discard the workspace ruleset without restrict_self. + * Same skip/error contract as restrict. Used so tests can cover the + * builder without locking the process (gcov cannot write after restrict). + * + * Example: sandbox_landlock_prepare("/tmp/ws"); + */ +int sandbox_landlock_prepare(const char *workspace); + +#ifdef __cplusplus +} +#endif + +#endif /* SHELLCLAW_SANDBOX_LANDLOCK_H */ diff --git a/src/tools/shell.c b/src/tools/shell.c index d2c02fa..65a2d97 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_sandbox.c b/tests/test_sandbox.c index 6febd6f..45fa2d0 100644 --- a/tests/test_sandbox.c +++ b/tests/test_sandbox.c @@ -2,10 +2,11 @@ * @file test_sandbox.c * @brief Unit tests for sandbox_exec. * - * Linux-specific namespace and cgroup tests are guarded by + * Linux-specific namespace, Landlock, and cgroup tests are guarded by * #ifdef __linux__. GitHub-hosted runners often cannot apply user namespaces - * (sandbox_exec fail-closes; success-path tests skip). Do not weaken - * sandbox.c for CI. + * (sandbox_exec fail-closes; success-path tests skip). The Landlock + * builder is exercised in-process via prepare(); restrict_self runs in a + * child so gcov can still write. Do not weaken sandbox.c for CI. * * 5.7 Benchmark: run sandbox_exec("true") 200 times and report median. * The benchmark is informational only — it does not gate the test suite. @@ -15,6 +16,7 @@ #define _POSIX_C_SOURCE 200809L #include "sandbox/sandbox.h" +#include "sandbox/sandbox_landlock.h" #include #include #include @@ -361,6 +363,166 @@ static int test_proc_is_namespaced(void) return 0; } +static int test_landlock_probe_without_restrict(void) +{ + char workspace[] = "/tmp/sc_ll_prep_XXXXXX"; + char *ws; + ASSERT(sandbox_landlock_restrict_to_workspace(NULL) == 0); + ASSERT(sandbox_landlock_restrict_to_workspace("") == 0); + ASSERT(sandbox_landlock_restrict_to_workspace("/no/such/sc_ll_ws") == -1); + ASSERT(sandbox_landlock_prepare(NULL) == 0); + ASSERT(sandbox_landlock_prepare("") == 0); + ASSERT(sandbox_landlock_prepare("/no/such/sc_ll_ws") == -1); + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_landlock_probe_without_restrict: mkdtemp failed\n"); + return 1; + } + ASSERT(sandbox_landlock_prepare(ws) == 0); + rmdir(ws); + return 0; +} + +static int test_landlock_restrict_denies_etc_passwd(void) +{ + char workspace[] = "/tmp/sc_ll_XXXXXX"; + char *ws; + pid_t pid; + int st; + int status; + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_landlock_restrict_denies_etc_passwd: mkdtemp failed\n"); + return 1; + } + pid = fork(); + if (pid < 0) { + rmdir(ws); + return 1; + } + if (pid == 0) { + FILE *f; + int pfd; + if (chdir(ws) != 0) + _exit(5); + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) + _exit(3); + if (sandbox_landlock_restrict_to_workspace(ws) != 0) + _exit(2); + f = fopen("ok", "w"); + if (!f) + _exit(4); + fclose(f); + unlink("ok"); + pfd = open("/etc/passwd", O_RDONLY | O_CLOEXEC); + if (pfd >= 0) { + close(pfd); + _exit(1); + } + _exit(0); + } + if (waitpid(pid, &st, 0) < 0) { + rmdir(ws); + return 1; + } + rmdir(ws); + if (!WIFEXITED(st)) { + fprintf(stderr, "FAIL: tests/test_sandbox.c: landlock child did not exit (st=%d)\n", + st); + return 1; + } + status = WEXITSTATUS(st); + if (status == 2 || status == 3) { + fprintf(stderr, "test_sandbox: skip test_landlock_restrict_denies_etc_passwd (WEXITSTATUS=%d)\n", + status); + return 0; + } + if (status != 0) { + fprintf(stderr, "FAIL: tests/test_sandbox.c: test_landlock_restrict_denies_etc_passwd WEXITSTATUS=%d (0=denied 1=passwd_open 4=ws_write 5=chdir)\n", + status); + return 1; + } + return 0; +} + +static int test_landlock_denies_etc_ssl_outside_certs(void) +{ + char workspace[] = "/tmp/sc_ll_ssl_XXXXXX"; + char *ws; + pid_t pid; + int st; + int status; + int host_cnf; + int host_private; + + host_cnf = access("/etc/ssl/openssl.cnf", R_OK) == 0; + host_private = access("/etc/ssl/private", R_OK) == 0; + if (!host_cnf && !host_private) { + fprintf(stderr, + "test_sandbox: skip test_landlock_denies_etc_ssl_outside_certs (no readable /etc/ssl targets)\n"); + return 0; + } + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_landlock_denies_etc_ssl_outside_certs: mkdtemp failed\n"); + return 1; + } + pid = fork(); + if (pid < 0) { + rmdir(ws); + return 1; + } + if (pid == 0) { + int pfd; + int saw_cnf = access("/etc/ssl/openssl.cnf", R_OK) == 0; + int saw_private = access("/etc/ssl/private", R_OK) == 0; + if (chdir(ws) != 0) + _exit(5); + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) + _exit(3); + if (sandbox_landlock_restrict_to_workspace(ws) != 0) + _exit(2); + if (saw_cnf) { + pfd = open("/etc/ssl/openssl.cnf", O_RDONLY | O_CLOEXEC); + if (pfd >= 0) { + close(pfd); + _exit(1); + } + } + if (saw_private) { + pfd = open("/etc/ssl/private", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (pfd >= 0) { + close(pfd); + _exit(7); + } + } + _exit(0); + } + if (waitpid(pid, &st, 0) < 0) { + rmdir(ws); + return 1; + } + rmdir(ws); + if (!WIFEXITED(st)) { + fprintf(stderr, + "FAIL: tests/test_sandbox.c: ssl landlock child did not exit (st=%d)\n", st); + return 1; + } + status = WEXITSTATUS(st); + if (status == 2 || status == 3) { + fprintf(stderr, + "test_sandbox: skip test_landlock_denies_etc_ssl_outside_certs (WEXITSTATUS=%d)\n", + status); + return 0; + } + if (status != 0) { + fprintf(stderr, + "FAIL: tests/test_sandbox.c: test_landlock_denies_etc_ssl_outside_certs WEXITSTATUS=%d (0=denied 1=cnf_open 7=private_open)\n", + status); + return 1; + } + return 0; +} #endif #ifdef __linux__ @@ -378,6 +540,137 @@ static int test_shadow_not_accessible(void) return 0; } +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; +} + +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("cat /etc/passwd 2>&1; echo EXIT:$?", out, sizeof(out), 5000, &cfg); + ASSERT(strstr(out, "root:x:") == NULL); + if (isolation_was_denied(rc, out)) { + rmdir(ws); + return 0; + } + ASSERT(rc == 0); + ASSERT(strstr(out, "EXIT:0") == NULL); + if (access("/usr/bin/python3", X_OK) == 0 || access("/bin/python3", X_OK) == 0) { + FILE *wrote; + char buf[64]; + size_t nread; + 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); + wrote = fopen(outp, "r"); + if (wrote) { + nread = fread(buf, 1, sizeof buf - 1, wrote); + fclose(wrote); + buf[nread] = '\0'; + ASSERT(strstr(buf, "root:") == NULL); + } + unlink(outp); + ASSERT(strstr(out, "root:x:") == NULL); + if (!isolation_was_denied(rc, out)) { + ASSERT(rc == 0); + ASSERT(strstr(out, "EXIT:0") == NULL); + } + } + rmdir(ws); + 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); + if (fgets(buf, sizeof(buf), f) == NULL) { + fclose(f); + unlink(wrote); + rmdir(ws); + fprintf(stderr, "FAIL: %s:%d fgets wrote.txt\n", __FILE__, __LINE__); + return 1; + } + fclose(f); + ASSERT(strstr(buf, "landlock_ok") != NULL); + unlink(wrote); + rmdir(ws); + return 0; +} + static int listen_loopback_ephemeral(int *port_out) { int fd; @@ -541,15 +834,23 @@ int main(void) RUN(test_missing_workspace_fail_closed()); RUN(test_timeout_kills_process()); #ifdef __linux__ + RUN(test_landlock_probe_without_restrict()); RUN(test_command_inherits_cgroup()); RUN(test_inherited_fd_is_closed()); RUN(test_proc_is_namespaced()); 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 RUN(benchmark_sandbox_exec()); +#ifdef __linux__ + RUN(test_landlock_restrict_denies_etc_passwd()); + RUN(test_landlock_denies_etc_ssl_outside_certs()); +#endif printf("test_sandbox: all tests passed\n"); return 0; } From ec9ef6ba548731159f2df282e160d9e794be428f Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 17:52:50 -0300 Subject: [PATCH 4/4] docs(security): record Landlock, MS_PRIVATE, and proc remount Namespace setup is fail-closed, the mount tree is made private, and proc is remounted for PID 1. Landlock, not the string allowlist, is the kernel host-FS bound when a workspace path is set. --- docs/ARCHITECTURE.md | 10 ++++++---- docs/SECURITY.md | 33 +++++++++++++++++---------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a7b24d2..bd52b26 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)` then fork for PID 1, 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` | @@ -137,9 +137,11 @@ v1.0 ships static manifest discovery; live cross-agent HTTP and full compliance Linux path (`src/sandbox/sandbox.c`): -- Child: `unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID)` then `exec` shell command. -- **No** `mount()`, bind-mount, or `pivot_root()` — GPU device nodes and Argus socket are not injected. -- Allowlist in `src/sandbox/allowlist.c` blocks dangerous paths and Jetson GPU `/dev` literals. +- Isolator: user namespace when unprivileged, then `unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID)`, then **fork** so the command is PID 1. +- Isolation failure is fail-closed via a control pipe (not `sh` exit 122/123). +- Landlock workspace bound when `workspace_path` is set (RW workspace, RO `/bin` `/usr` `/lib*`, RW `/dev/null`). +- **No** `pivot_root()` — GPU device nodes are not granted by Landlock; the allowlist still blocks `/dev/nv*` literals. +- Allowlist in `src/sandbox/allowlist.c` is defense-in-depth (quoted paths, `$HOME`/`$PWD`, `file:` URLs). - cgroups v2: `memory.max`, `cpu.max` when writable under `/sys/fs/cgroup`. Non-Linux: plain `fork`/`exec` with a stderr warning. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b93aa9d..073bdcc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -23,7 +23,7 @@ The primary goals are: prevent sandboxed shell commands from escaping to host de | ID | Finding | Severity | Mitigation | Residual risk | Tests / verification | |----|---------|----------|------------|---------------|----------------------| -| 7.1 | Jetson GPU `/dev` nodes visible in shell mount namespace (no `pivot_root`, no bind-mount isolation) | Medium | Substring blocklist for `/dev/nvhost`, `/dev/nvgpu`, `/dev/nvmap` in `allowlist.c`; `sandbox_exec()` uses `unshare` only | Indirect paths or globs may bypass literal blocklist | `tests/test_allowlist.c` (`test_block_jetson_gpu_devices`) | +| 7.1 | Jetson GPU `/dev` nodes visible in shell mount namespace (no `pivot_root`, no bind-mount isolation) | Medium | Landlock workspace bound does not grant `/dev/nv*`; substring blocklist for `/dev/nvhost`, `/dev/nvgpu`, `/dev/nvmap` in `allowlist.c` | Without Landlock (non-Linux / sandbox off), globs may bypass the literal blocklist | `tests/test_allowlist.c` (`test_block_jetson_gpu_devices`), `tests/test_sandbox.c` | | 7.1 | `pivot_root` hardening not implemented | Low (documented) | Allowlist + namespace network/PID isolation | Full mount-slave `/dev` tmpfs deferred post-v1.0 | Source audit of `sandbox.c` | | 7.2 | Camera capture could invoke shell with user-controlled pipeline strings | High (if present) | **Not present:** `hardware_camera_capture()` uses `execvp` + fixed `argv[]`; strict input validation | N/A when validation holds | `tests/test_hardware_camera.c` (injection + `test_no_shell_invocation`) | | 7.3 | Sandboxed shell could reach Argus IPC socket | Medium | Blocklist `/tmp/argus_socket` and `argus_socket`; camera spawn runs outside shell sandbox by design | Compromised agent process can still spawn GStreamer | `tests/test_allowlist.c` (`test_block_argus_socket`) | @@ -39,10 +39,11 @@ 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)` + fork for PID 1 + Landlock workspace bound + `prctl(PR_SET_NO_NEW_PRIVS)` | Fail-closed if namespaces or Landlock cannot apply. Isolation uses a control pipe, not `sh` exit 122/123. 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` | -| cgroups v2 | `memory.max`, `cpu.max` on child PID | Best-effort; non-fatal if cgroup write fails | +| Allowlist | Substring blocklist + workspace containment (ancestor walk, quoted/embedded `/` `~`, `$HOME`/`$PWD`, `file:` URLs, relative names) | Defense-in-depth string scan; Landlock is the kernel host-FS bound | +| Landlock | Ruleset on configured `workspace_path` (RW workspace + traverse-only `/` + RO `/bin` `/usr` `/lib` and `/etc/ssl/certs` only; RW `/dev/null`). Child `fchdir`s the workspace before `restrict_self`. | Primary host-FS gate; `/` is `READ_DIR` only so `/etc/passwd` stays closed. `/etc/ssl/private` is not granted. Blocks symlink and `chr(47)+` host reads | +| cgroups v2 | `memory.max`, `cpu.max`; isolator writes `cgroup.procs` before the command fork | Best-effort; non-fatal if cgroup write fails | | Hardware GPIO/I2C | libgpiod / `i2c-dev` in agent process | Not exposed inside shell namespace | Implementation: [`src/sandbox/sandbox.c`](../src/sandbox/sandbox.c), [`src/sandbox/allowlist.c`](../src/sandbox/allowlist.c). @@ -55,13 +56,13 @@ Implementation: [`src/sandbox/sandbox.c`](../src/sandbox/sandbox.c), [`src/sandb ### GPU device nodes — not bind-mounted -`sandbox_exec()` does **not** call `mount()`, `bind()`, or `pivot_root()`. The child namespace is created only with: +`sandbox_exec()` does **not** call `bind()` or `pivot_root()`. After `unshare(CLONE_NEWNS)` the child makes the copied mount tree `MS_REC|MS_PRIVATE` and, once the command is PID 1, remounts `proc` on `/proc`. It does not bind-mount Tegra GPU devices. ```c unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID); ``` -Therefore ShellClaw never bind-mounts Tegra GPU devices into the sandbox. In particular, these paths are **not** explicitly mounted into the shell namespace: +In particular, these paths are **not** explicitly mounted into the shell namespace: - `/dev/nvhost-*` - `/dev/nvgpu` @@ -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 (made `MS_PRIVATE`). Jetson `/dev` nodes remain in that mount namespace; Landlock (when a workspace path is set) is the kernel FS bound and does not grant `/dev/nvhost`, `/dev/nvgpu`, or `/dev/nvmap`. -**Mitigation in v1.0:** 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`). +**Mitigation in v1.0:** Landlock plus the shell allowlist, which rejects commands whose text references `/dev/nvhost`, `/dev/nvgpu`, or `/dev/nvmap` (substring blocklist). Regression tests live in `tests/test_allowlist.c` (`test_block_jetson_gpu_devices`) and `tests/test_sandbox.c`. -**Residual risk:** a crafted command that opens GPU nodes without those literal substrings (e.g. shell globs 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 grant list). Without Landlock (non-Linux, or sandbox off) the substring blocklist remains best-effort. `workspace_only` walks a missing destination’s existing ancestor, collapses `..` lexically (without cancelling `..` across a symlink), scans quoted/embedded `/` `~` and relative `../`, extracts and percent-decodes `file:` URLs, fail-closes in-command `HOME`/`PWD` assignment, and expands or fail-closes `$` on the full command (including glued `$IFS` and mid-token `$HOME`). The string scanner is not a language interpreter: `open(chr(47)+'etc/passwd')` has no path character in the command text and stays residual **on the scanner**; Landlock is the kernel bound for that class. Encoded-slash / identity-escape cat-and-mouse is frozen. Conservative regex false positives such as `awk '/foo/'` remain. ### Board-agnostic blocklist entries (Jetson literals) @@ -82,7 +83,7 @@ The task checklist references `unshare(CLONE_NEWNS) + pivot_root` as a hardened ### Network and PID isolation - `CLONE_NEWNET` — sandboxed shell has no routable network (no interface setup in child). -- `CLONE_NEWPID` — PID namespace; child is PID 1 in its namespace for the `sh -c` session. +- `CLONE_NEWPID` — PID namespace; the isolator forks so the command process is PID 1 (unshare does not move the caller). ### JetPack 6 / kernel 5.15 note @@ -120,7 +121,7 @@ On Jetson CSI cameras, frame capture uses GStreamer `nvarguscamerasrc`, which is | `/tmp/argus_socket` | Unix domain socket used by Argus clients (JetPack 6.x default path) | | `gst-launch-1.0` + `nvarguscamerasrc` | Child process spawned by ShellClaw **outside** the shell sandbox | -ShellClaw does **not** bind-mount `/tmp/argus_socket` into the sandboxed shell namespace (`sandbox_exec` uses only `unshare`, per § [Linux sandbox (Jetson)](#linux-sandbox-jetson)). +ShellClaw does **not** bind-mount `/tmp/argus_socket` into the sandboxed shell namespace (`sandbox_exec` uses `unshare` without `pivot_root`, per § [Linux sandbox (Jetson)](#linux-sandbox-jetson)). Landlock does not grant that socket path. ### Who may talk to Argus @@ -267,8 +268,8 @@ 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 | Tegra GPU character devices remain in inherited mount namespace | Landlock does not grant `/dev/nv*`; literal blocklist on `/dev/nvhost*`, `/dev/nvgpu`, `/dev/nvmap` | `src/sandbox/allowlist.c`, `sandbox_landlock.c` | +| Shell sandbox | No `pivot_root` / minimal `/dev` in v1.0 | Landlock workspace bound (fail-closed); allowlist defense in depth | `src/sandbox/sandbox.c` | | CSI camera | Argus daemon (`root`) + `/tmp/argus_socket` | Shell blocklist; camera only via agent `execvp` path | `src/hardware/hardware_camera.c`, `allowlist.c` | | Gateway | GPU telemetry via `tegrastats` parsing | Bearer auth on `/api/hardware/gpu`; read-only GET | `src/gateway/routes_hardware.c` | | GPIO / I2C | `tegra234-gpio` chips via libgpiod | Hardware tools run in agent process, not shell namespace | `src/hardware/` backends | @@ -284,8 +285,8 @@ This section summarizes Jetson Orin Nano Super / JetPack 6.2.x concerns that do | Area | Directory / file | Security-relevant behavior | |------|------------------|----------------------------| -| Sandbox isolation | [`src/sandbox/sandbox.c`](../src/sandbox/sandbox.c) | `unshare` namespaces, cgroups v2 limits, no mount/bind | -| Command policy | [`src/sandbox/allowlist.c`](../src/sandbox/allowlist.c) | Blocklist (incl. Jetson GPU + Argus), workspace path containment | +| Sandbox isolation | [`src/sandbox/sandbox.c`](../src/sandbox/sandbox.c), [`src/sandbox/sandbox_landlock.c`](../src/sandbox/sandbox_landlock.c) | user ns + `unshare` + PID-1 fork, Landlock workspace bound, cgroups v2, fail-closed isolation | +| Command policy | [`src/sandbox/allowlist.c`](../src/sandbox/allowlist.c) | Blocklist (incl. Jetson GPU + Argus), workspace path containment (DiD) | | Gateway auth | [`src/gateway/http_lws.c`](../src/gateway/http_lws.c) | `requires_auth()` Bearer gate for `/api/*` | | Hardware HTTP API | [`src/gateway/routes_hardware.c`](../src/gateway/routes_hardware.c) | Read-only GET handlers, camera POST deferred stub | | Rate limits | [`src/gateway/rate_limit.c`](../src/gateway/rate_limit.c) | Per-IP `/asap` RPM (64-slot table); reuses expired windows; **fail-closed** (429) when table is full and no slot expired | @@ -293,7 +294,7 @@ This section summarizes Jetson Orin Nano Super / JetPack 6.2.x concerns that do | Signing keys | [`src/asap/manifest_keys.c`](../src/asap/manifest_keys.c) | `0600` create, loose-perm rejection, load/rotate guards | | Signed manifest gate | [`src/gateway/routes.c`](../src/gateway/routes.c) | `manifest_keys_ensure_loaded()` before `manifest_build_signed_json()` | -Automated regression coverage: `tests/test_allowlist.c`, `tests/test_hardware_camera.c`, `tests/test_gateway_http.c`, `tests/test_rate_limit.c`, `tests/test_manifest_build`, `tests/test_manifest_keys`. +Automated regression coverage: `tests/test_allowlist.c`, `tests/test_sandbox.c`, `tests/test_hardware_camera.c`, `tests/test_gateway_http.c`, `tests/test_rate_limit.c`, `tests/test_manifest_build`, `tests/test_manifest_keys`. ### Board-agnostic blocklist entries (Jetson literals)