diff --git a/Makefile b/Makefile index f40f391..7ac876b 100644 --- a/Makefile +++ b/Makefile @@ -127,7 +127,8 @@ ASAP_INVOKE_TEST_O := $(BINDIR)/asap_invoke_test.o # Sandbox (Phase 3 ยง5) SANDBOX_CORE_O := src/sandbox/sandbox.o SANDBOX_LANDLOCK_O := src/sandbox/sandbox_landlock.o -SANDBOX_O := $(SANDBOX_CORE_O) $(SANDBOX_LANDLOCK_O) +SANDBOX_ENV_O := src/sandbox/sandbox_env.o +SANDBOX_O := $(SANDBOX_CORE_O) $(SANDBOX_LANDLOCK_O) $(SANDBOX_ENV_O) ALLOWLIST_SCAN_O := src/sandbox/allowlist.o ALLOWLIST_PATH_O := src/sandbox/allowlist_path.o ALLOWLIST_O := $(ALLOWLIST_SCAN_O) $(ALLOWLIST_PATH_O) @@ -385,9 +386,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_CORE_O): src/sandbox/sandbox.c src/sandbox/sandbox.h src/sandbox/sandbox_landlock.h +$(SANDBOX_CORE_O): src/sandbox/sandbox.c src/sandbox/sandbox.h src/sandbox/sandbox_landlock.h src/sandbox/sandbox_env.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/sandbox.c +$(SANDBOX_ENV_O): src/sandbox/sandbox_env.c src/sandbox/sandbox_env.h + $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/sandbox_env.c + $(SANDBOX_LANDLOCK_O): src/sandbox/sandbox_landlock.c src/sandbox/sandbox_landlock.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/sandbox_landlock.c @@ -909,7 +913,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_CORE_O) $(SANDBOX_LANDLOCK_O) $(ALLOWLIST_SCAN_O) $(ALLOWLIST_PATH_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) $(SANDBOX_ENV_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/docs/SECURITY.md b/docs/SECURITY.md index 4a2b3fd..f3ff695 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -39,7 +39,7 @@ The primary goals are: prevent sandboxed shell commands from escaping to host de | Surface | Mechanism | Notes | |---------|-----------|-------| -| Shell (sandbox on) | `fork()` + user ns when needed + `unshare(CLONE_NEWNS \| CLONE_NEWNET \| CLONE_NEWPID)` + fork for PID 1 + Landlock workspace bound + `prctl(PR_SET_NO_NEW_PRIVS)` | 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 on) | `fork()` + user ns when needed + `unshare(CLONE_NEWNS \| CLONE_NEWNET \| CLONE_NEWPID)` + fork for PID 1 + Landlock workspace bound + `prctl(PR_SET_NO_NEW_PRIVS)` + secret-free environment | Fail-closed if namespaces or Landlock cannot apply. The command child keeps `PATH`, locale, `TERM`, `TZ`, and `HOME` set to the workspace. Host API keys and bot tokens are not inherited. Isolation uses a control pipe, not `sh` exit 122/123. See [Linux sandbox (Jetson)](#linux-sandbox-jetson) | | Shell (sandbox off) | Plain `fork()` + substring fallback blocklist | **Not** a security boundary; stderr warning | | Allowlist | Substring blocklist + workspace containment (ancestor walk, quoted/embedded `/` `~`, `$HOME`/`$PWD`, `file:` URLs, relative names) | Defense-in-depth string scan; Landlock is the kernel host-FS bound | | Landlock | Ruleset on configured `workspace_path` (RW workspace + traverse-only `/` + RO `/bin` `/usr` `/lib` and `/etc/ssl/certs` only; RW `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/tty` without `IOCTL_DEV`). Child `fchdir`s the workspace before `restrict_self`. A failed `landlock_add_rule` fails the ruleset closed. | Primary host-FS gate; `/` is `READ_DIR` only so `/etc/passwd` stays closed. `/etc/ssl/private` is not granted. Blocks symlink and `chr(47)+` host reads | diff --git a/src/sandbox/sandbox.c b/src/sandbox/sandbox.c index 4aac1a7..76a69c4 100644 --- a/src/sandbox/sandbox.c +++ b/src/sandbox/sandbox.c @@ -12,7 +12,9 @@ * 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. + * memory.max and cpu.max apply to sh -c. The command process then + * replaces the inherited environment so provider keys and bot tokens + * are not readable via printenv. Limits degrade if unavailable. * * Non-Linux path: plain fork() + execl(); a warning is emitted to stderr. */ @@ -20,6 +22,7 @@ #define _POSIX_C_SOURCE 200809L #include "sandbox/sandbox.h" +#include "sandbox/sandbox_env.h" #include "sandbox/sandbox_landlock.h" #include #include @@ -446,6 +449,7 @@ int sandbox_exec(const char *cmd, char *out, size_t out_cap, #endif close(errpipe[1]); close_inherited_fds(); + sandbox_scrub_command_environment(workspace); execl("/bin/sh", "sh", "-c", cmd, (char *)NULL); _exit(127); } diff --git a/src/sandbox/sandbox_env.c b/src/sandbox/sandbox_env.c new file mode 100644 index 0000000..158cedf --- /dev/null +++ b/src/sandbox/sandbox_env.c @@ -0,0 +1,77 @@ +/** + * @file sandbox_env.c + * @brief Secret-free environment for the sandboxed command process. + * + * Provider keys and channel tokens live in the agent environment. The + * shell tool's stdout is returned to the model, so sh -c must not inherit + * them. A prompt-injected printenv would otherwise leak the values. + */ +#define _GNU_SOURCE +#define _POSIX_C_SOURCE 200809L + +#include "sandbox/sandbox_env.h" + +#include +#include +#if !defined(__linux__) +#include +#endif + +static const char FALLBACK_PATH[] = + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +static char *dup_env(const char *name) +{ + const char *v; + + v = getenv(name); + if (!v || !v[0]) + return NULL; + return strdup(v); +} + +static void put_env(const char *name, const char *value) +{ + if (value && value[0]) + setenv(name, value, 1); +} + +static void free_kept(char *path, char *lang, char *lc_all, char *lc_ctype, + char *term, char *tz) +{ + free(path); + free(lang); + free(lc_all); + free(lc_ctype); + free(term); + free(tz); +} + +void sandbox_scrub_command_environment(const char *workspace) +{ + char *path = dup_env("PATH"); + char *lang = dup_env("LANG"); + char *lc_all = dup_env("LC_ALL"); + char *lc_ctype = dup_env("LC_CTYPE"); + char *term = dup_env("TERM"); + char *tz = dup_env("TZ"); +#if !defined(__linux__) + static char *empty_env[] = { NULL }; + extern char **environ; +#endif + +#if defined(__linux__) + clearenv(); +#else + environ = empty_env; +#endif + put_env("PATH", path ? path : FALLBACK_PATH); + put_env("LANG", lang); + put_env("LC_ALL", lc_all); + put_env("LC_CTYPE", lc_ctype); + put_env("TERM", term); + put_env("TZ", tz); + if (workspace && workspace[0]) + setenv("HOME", workspace, 1); + free_kept(path, lang, lc_all, lc_ctype, term, tz); +} diff --git a/src/sandbox/sandbox_env.h b/src/sandbox/sandbox_env.h new file mode 100644 index 0000000..2d4e951 --- /dev/null +++ b/src/sandbox/sandbox_env.h @@ -0,0 +1,18 @@ +/** + * @file sandbox_env.h + * @brief Drop host secrets before exec of a sandboxed shell command. + */ +#ifndef SHELLCLAW_SANDBOX_ENV_H +#define SHELLCLAW_SANDBOX_ENV_H + +/** + * Replace the process environment with PATH, locale, and an optional HOME. + * + * Call only in the child that is about to execl sh -c. API keys, bot + * tokens, and LD_* variables from the agent are not passed through. + * + * Example: sandbox_scrub_command_environment("/home/me/.shellclaw/workspace"); + */ +void sandbox_scrub_command_environment(const char *workspace); + +#endif diff --git a/tests/test_sandbox.c b/tests/test_sandbox.c index 45fa2d0..41c24c1 100644 --- a/tests/test_sandbox.c +++ b/tests/test_sandbox.c @@ -147,6 +147,29 @@ static int test_stderr_captured(void) return 0; } +/** Host API keys must not be readable from sh -c. printenv is not blocklisted. */ +static int test_command_does_not_inherit_host_secrets(void) +{ + char out[4096]; + int rc; + const char *secret = "leaked_via_env"; + + ASSERT(setenv("SHELLCLAW_AUDIT_SECRET", secret, 1) == 0); + ASSERT(setenv("ANTHROPIC_API_KEY", secret, 1) == 0); + rc = sandbox_exec( + "printenv SHELLCLAW_AUDIT_SECRET ANTHROPIC_API_KEY; " + "tr '\\0' '\\n' < /proc/1/environ 2>/dev/null; echo PATH_OK", + out, sizeof out, 5000, NULL); + unsetenv("SHELLCLAW_AUDIT_SECRET"); + unsetenv("ANTHROPIC_API_KEY"); + if (skip_if_isolation_denied(rc, out, "test_command_does_not_inherit_host_secrets")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, secret) == NULL); + ASSERT(strstr(out, "PATH_OK") != NULL); + return 0; +} + static int test_null_cmd_returns_error(void) { char out[256]; @@ -823,6 +846,7 @@ int main(void) RUN(test_fail_closed_reports_isolation_error()); RUN(test_output_capture()); RUN(test_stderr_captured()); + RUN(test_command_does_not_inherit_host_secrets()); RUN(test_null_cmd_returns_error()); RUN(test_zero_cap_returns_error()); RUN(test_exit_nonzero_runs());