Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 5 additions & 1 deletion src/sandbox/sandbox.c
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@
* 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.
*/
#define _GNU_SOURCE
#define _POSIX_C_SOURCE 200809L

#include "sandbox/sandbox.h"
#include "sandbox/sandbox_env.h"
#include "sandbox/sandbox_landlock.h"
#include <errno.h>
#include <fcntl.h>
Expand Down Expand Up @@ -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);
}
Expand Down
77 changes: 77 additions & 0 deletions src/sandbox/sandbox_env.c
Original file line number Diff line number Diff line change
@@ -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 <stdlib.h>
#include <string.h>
#if !defined(__linux__)
#include <unistd.h>
#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);
}
18 changes: 18 additions & 0 deletions src/sandbox/sandbox_env.h
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions tests/test_sandbox.c
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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());
Expand Down
Loading