From b7a3e67e7d2d0a1bd54fb21e3414fc3c71f150b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 11:01:59 +0300 Subject: [PATCH 1/6] runtime: isolate and validate sandbox grant transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci Rebased onto main after #3456; conflicts with e18798f7 resolved in favour of aether_sandbox_grants.h. Co-Authored-By: Claude Opus 5.5 --- ...261029T075739Z-fixed-sandbox-unique-shm.md | 5 ++ runtime/sandbox/aether_sandbox_grants.h | 58 ++++++++++++++ runtime/sandbox/spawn_sandboxed_bsd.c | 23 ++---- runtime/sandbox/spawn_sandboxed_linux.c | 23 ++---- .../sandbox_grant_transport/harness.c | 78 +++++++++++++++++++ .../test_sandbox_grant_transport.sh | 49 ++++++++++++ .../test_sandbox_unique_shm.sh | 0 7 files changed, 202 insertions(+), 34 deletions(-) create mode 100644 new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md create mode 100644 runtime/sandbox/aether_sandbox_grants.h create mode 100644 tests/integration/sandbox_grant_transport/harness.c create mode 100755 tests/integration/sandbox_grant_transport/test_sandbox_grant_transport.sh mode change 100644 => 100755 tests/integration/sandbox_unique_shm/test_sandbox_unique_shm.sh diff --git a/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md b/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md new file mode 100644 index 000000000..969b6b8c5 --- /dev/null +++ b/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md @@ -0,0 +1,5 @@ +- **Sandbox grant transport is isolated and validated.** + Linux and FreeBSD now allocate an exclusive shared-memory object per + `spawn_sandboxed` call. Empty grant lists run as deny-all policies, while + malformed or oversized lists are rejected instead of being skipped or + truncated during serialization. diff --git a/runtime/sandbox/aether_sandbox_grants.h b/runtime/sandbox/aether_sandbox_grants.h new file mode 100644 index 000000000..541fc5fb0 --- /dev/null +++ b/runtime/sandbox/aether_sandbox_grants.h @@ -0,0 +1,58 @@ +#ifndef AETHER_SANDBOX_GRANTS_H +#define AETHER_SANDBOX_GRANTS_H + +#include +#include +#include +#include + +#define AETHER_SANDBOX_GRANTS_SIZE 8192 + +extern int list_size(void*); +extern void* list_get_raw(void*, int); +extern const char* aether_string_data(const void*); + +static int aether_sandbox_serialize_grants(void* grant_list, char* buf, + size_t buf_size, size_t* data_size) { + if (!grant_list || !buf || !data_size || buf_size == 0) { + errno = EINVAL; + return -1; + } + + int n = list_size(grant_list); + if (n < 0 || n % 2 != 0) { + errno = EINVAL; + return -1; + } + + size_t pos = 0; + buf[0] = '\0'; + for (int i = 0; i < n; i += 2) { + void* category_item = list_get_raw(grant_list, i); + void* pattern_item = list_get_raw(grant_list, i + 1); + if (!category_item || !pattern_item) { + errno = EINVAL; + return -1; + } + const char* category = aether_string_data(category_item); + const char* pattern = aether_string_data(pattern_item); + if (strchr(category, ':') || + strpbrk(category, "\r\n") || strpbrk(pattern, "\r\n")) { + errno = EINVAL; + return -1; + } + + int written = snprintf(buf + pos, buf_size - pos, "%s:%s\n", + category, pattern); + if (written < 0 || (size_t)written >= buf_size - pos) { + errno = E2BIG; + return -1; + } + pos += (size_t)written; + } + + *data_size = pos + 1; + return 0; +} + +#endif diff --git a/runtime/sandbox/spawn_sandboxed_bsd.c b/runtime/sandbox/spawn_sandboxed_bsd.c index 70c1c9f5d..aae1f5173 100644 --- a/runtime/sandbox/spawn_sandboxed_bsd.c +++ b/runtime/sandbox/spawn_sandboxed_bsd.c @@ -40,6 +40,7 @@ extern const char* aether_string_data(const void*); // The preload library's lookup, shared with the contrib process hosts. #include "aether_sandbox_preload_path.h" +#include "aether_sandbox_grants.h" #include "aether_sandbox_shm.h" // The close-on-exec pipe, the fork, and what a child execs with. #include "../utils/aether_spawn.h" @@ -86,25 +87,13 @@ static int sandbox_report(int errfd) { // Serialize grants from a list to shared memory // Format: "category:pattern\n" lines, null-terminated static int serialize_grants(void* grant_list, char* shm_name, size_t shm_name_size) { - int n = list_size(grant_list); - if (n <= 0 || n % 2 != 0) return -1; - - // Build the grant string - char buf[8192]; - int pos = 0; - for (int i = 0; i < n && pos < 8000; i += 2) { - const char* cat = aether_string_data(list_get_raw(grant_list, i)); - const char* pat = aether_string_data(list_get_raw(grant_list, i + 1)); - if (!cat || !pat) continue; - int w = snprintf(buf + pos, sizeof(buf) - pos, "%s:%s\n", cat, pat); - // snprintf returns the length it WOULD have written; an entry that - // does not fit must deny the spawn, not advance pos past buf. - if (w < 0 || (size_t)w >= sizeof(buf) - (size_t)pos) return -1; - pos += w; - } + char buf[AETHER_SANDBOX_GRANTS_SIZE]; + size_t data_size = 0; + if (aether_sandbox_serialize_grants( + grant_list, buf, sizeof buf, &data_size) != 0) return -1; return aether_sandbox_shm_write( - shm_name, shm_name_size, buf, (size_t)pos + 1); + shm_name, shm_name_size, buf, data_size); } // Spawn a sandboxed child process diff --git a/runtime/sandbox/spawn_sandboxed_linux.c b/runtime/sandbox/spawn_sandboxed_linux.c index 9f6607fba..2b4cbbf7a 100644 --- a/runtime/sandbox/spawn_sandboxed_linux.c +++ b/runtime/sandbox/spawn_sandboxed_linux.c @@ -36,6 +36,7 @@ extern const char* aether_string_data(const void*); // The preload library's lookup, shared with the contrib process hosts. #include "aether_sandbox_preload_path.h" +#include "aether_sandbox_grants.h" #include "aether_sandbox_shm.h" // The close-on-exec pipe, the fork, and what a child execs with. #include "../utils/aether_spawn.h" @@ -91,25 +92,13 @@ static int sandbox_report(int errfd) { // Serialize grants from a list to shared memory // Format: "category:pattern\n" lines, null-terminated static int serialize_grants(void* grant_list, char* shm_name, size_t shm_name_size) { - int n = list_size(grant_list); - if (n <= 0 || n % 2 != 0) return -1; - - // Build the grant string - char buf[8192]; - int pos = 0; - for (int i = 0; i < n && pos < 8000; i += 2) { - const char* cat = aether_string_data(list_get_raw(grant_list, i)); - const char* pat = aether_string_data(list_get_raw(grant_list, i + 1)); - if (!cat || !pat) continue; - int w = snprintf(buf + pos, sizeof(buf) - pos, "%s:%s\n", cat, pat); - // snprintf returns the length it WOULD have written; an entry that - // does not fit must deny the spawn, not advance pos past buf. - if (w < 0 || (size_t)w >= sizeof(buf) - (size_t)pos) return -1; - pos += w; - } + char buf[AETHER_SANDBOX_GRANTS_SIZE]; + size_t data_size = 0; + if (aether_sandbox_serialize_grants( + grant_list, buf, sizeof buf, &data_size) != 0) return -1; return aether_sandbox_shm_write( - shm_name, shm_name_size, buf, (size_t)pos + 1); + shm_name, shm_name_size, buf, data_size); } // Is the `fork` category granted in the grant list? The LD_PRELOAD layer diff --git a/tests/integration/sandbox_grant_transport/harness.c b/tests/integration/sandbox_grant_transport/harness.c new file mode 100644 index 000000000..6175cad98 --- /dev/null +++ b/tests/integration/sandbox_grant_transport/harness.c @@ -0,0 +1,78 @@ +#include +#include +#include + +extern int aether_spawn_sandboxed(void* grant_list, const char* program, const char* arg); +extern void* list_new(void); +extern int list_add_raw(void* list, void* item); +extern void list_free(void* list); + +static int expect_result(const char* name, int got, int want) { + if (got == want) return 0; + fprintf(stderr, "FAIL %s: got %d, want %d\n", name, got, want); + return 1; +} + +static void* pair(const char* category, const char* pattern) { + void* grants = list_new(); + if (!grants || !list_add_raw(grants, (void*)category) || + !list_add_raw(grants, (void*)pattern)) { + fprintf(stderr, "FAIL cannot create grant pair\n"); + exit(2); + } + return grants; +} + +int main(void) { + int failures = 0; + + void* empty = list_new(); + if (!empty) return 2; + failures += expect_result("empty deny-all list", + aether_spawn_sandboxed(empty, "/bin/true", NULL), 0); + list_free(empty); + + failures += expect_result("null list", + aether_spawn_sandboxed(NULL, "/bin/true", NULL), -1); + + void* odd = list_new(); + if (!odd || !list_add_raw(odd, (void*)"exec")) return 2; + failures += expect_result("odd list", + aether_spawn_sandboxed(odd, "/bin/true", NULL), -1); + list_free(odd); + + void* null_item = pair(NULL, "*"); + failures += expect_result("null grant item", + aether_spawn_sandboxed(null_item, "/bin/true", NULL), -1); + list_free(null_item); + + void* multiline = pair("fs_read", "/tmp/allowed\n*:*"); + failures += expect_result("multiline pattern", + aether_spawn_sandboxed(multiline, "/bin/true", NULL), -1); + list_free(multiline); + + void* delimited_category = pair("fs_read:*", "*"); + failures += expect_result("category containing delimiter", + aether_spawn_sandboxed(delimited_category, + "/bin/true", NULL), -1); + list_free(delimited_category); + + char* oversized_pattern = malloc(9001); + if (!oversized_pattern) return 2; + memset(oversized_pattern, 'a', 9000); + oversized_pattern[9000] = '\0'; + void* oversized = pair("fs_read", oversized_pattern); + failures += expect_result("oversized transport", + aether_spawn_sandboxed(oversized, "/bin/true", NULL), -1); + list_free(oversized); + free(oversized_pattern); + + void* allow = pair("*", "*"); + failures += expect_result("ordinary grant pair", + aether_spawn_sandboxed(allow, "/bin/true", NULL), 0); + list_free(allow); + + if (failures) return 1; + printf("PASS: sandbox grant transport validates complete input\n"); + return 0; +} diff --git a/tests/integration/sandbox_grant_transport/test_sandbox_grant_transport.sh b/tests/integration/sandbox_grant_transport/test_sandbox_grant_transport.sh new file mode 100755 index 000000000..2320d0313 --- /dev/null +++ b/tests/integration/sandbox_grant_transport/test_sandbox_grant_transport.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Grant transport accepts an empty deny-all policy, while rejecting malformed +# or unrepresentable lists before starting a child. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_grant_transport: spawn_sandboxed is Linux and FreeBSD only" + exit 0 ;; +esac +if [ ! -f "$ROOT/build/libaether.a" ] || + [ ! -f "$ROOT/build/libaether_sandbox.so" ]; then + echo " [SKIP] sandbox_grant_transport: sandbox runtime not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT + +if ! ${CC:-cc} -std=c11 -Wall -Wextra -Werror \ + -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" -lpthread -lm 2>"$TMP/cc.err"; then + if ! ${CC:-cc} -std=c11 -Wall -Wextra -Werror \ + -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" \ + -lpthread -ldl -lm -lrt -lz -lssl -lcrypto 2>"$TMP/cc.err"; then + echo " [FAIL] sandbox_grant_transport: harness did not link" + sed 's/^/ /' "$TMP/cc.err" | head -8 + exit 1 + fi +fi + +out="$(AETHER_SANDBOX_PRELOAD="$ROOT/build/libaether_sandbox.so" \ + AETHER_SANDBOX_LOG=none "$TMP/harness" 2>&1 Date: Thu, 29 Oct 2026 15:06:08 +0000 Subject: [PATCH 2/6] tests: cover newline/CR injection in both grant fields Co-Authored-By: Claude Opus 5.5 --- .../sandbox_grant_transport/harness.c | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/integration/sandbox_grant_transport/harness.c b/tests/integration/sandbox_grant_transport/harness.c index 6175cad98..d6647229b 100644 --- a/tests/integration/sandbox_grant_transport/harness.c +++ b/tests/integration/sandbox_grant_transport/harness.c @@ -57,6 +57,27 @@ int main(void) { "/bin/true", NULL), -1); list_free(delimited_category); + void* multiline_category = pair("fs_read\n*", "*"); + failures += expect_result("newline in category", + aether_spawn_sandboxed(multiline_category, + "/bin/true", NULL), -1); + list_free(multiline_category); + + void* cr_pattern = pair("fs_read", "/tmp/allowed\r*:*"); + failures += expect_result("carriage return in pattern", + aether_spawn_sandboxed(cr_pattern, "/bin/true", NULL), -1); + list_free(cr_pattern); + + void* cr_category = pair("fs_read\r", "*"); + failures += expect_result("carriage return in category", + aether_spawn_sandboxed(cr_category, "/bin/true", NULL), -1); + list_free(cr_category); + + void* trailing_nl = pair("fs_read", "/tmp/allowed\n"); + failures += expect_result("trailing newline in pattern", + aether_spawn_sandboxed(trailing_nl, "/bin/true", NULL), -1); + list_free(trailing_nl); + char* oversized_pattern = malloc(9001); if (!oversized_pattern) return 2; memset(oversized_pattern, 'a', 9000); From 923d88c81f7e0de8e4152407a541a688e38130a0 Mon Sep 17 00:00:00 2001 From: paul Date: Fri, 30 Oct 2026 07:24:08 +0000 Subject: [PATCH 3/6] runtime: say which grant step failed in spawn_sandboxed An invalid grant list (EINVAL) and an oversized one (E2BIG) were both reported as a shared-memory failure. The caller now reports 'invalid grant list', 'grant list too large' or the shm failure. Return value stays -1. Co-Authored-By: Claude Opus 5.5 --- runtime/sandbox/aether_sandbox_grants.h | 18 ++++++++++++++++++ runtime/sandbox/spawn_sandboxed_bsd.c | 8 +++++--- runtime/sandbox/spawn_sandboxed_linux.c | 8 +++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/runtime/sandbox/aether_sandbox_grants.h b/runtime/sandbox/aether_sandbox_grants.h index 541fc5fb0..a6d32a45b 100644 --- a/runtime/sandbox/aether_sandbox_grants.h +++ b/runtime/sandbox/aether_sandbox_grants.h @@ -55,4 +55,22 @@ static int aether_sandbox_serialize_grants(void* grant_list, char* buf, return 0; } +// Wrapper result codes for serialize_grants() in the spawn files, so the +// caller can say which step failed. All map to spawn_sandboxed's -1. +#define AETHER_SANDBOX_GRANTS_ERR_SHM (-1) // shared memory could not be created +#define AETHER_SANDBOX_GRANTS_ERR_INVALID (-2) // EINVAL from serialize_grants +#define AETHER_SANDBOX_GRANTS_ERR_TOOBIG (-3) // E2BIG from serialize_grants + +static inline int aether_sandbox_grants_err_code(int err) { + return err == EINVAL ? AETHER_SANDBOX_GRANTS_ERR_INVALID + : err == E2BIG ? AETHER_SANDBOX_GRANTS_ERR_TOOBIG + : AETHER_SANDBOX_GRANTS_ERR_SHM; +} + +static inline const char* aether_sandbox_grants_err_msg(int code) { + return code == AETHER_SANDBOX_GRANTS_ERR_INVALID ? "invalid grant list" + : code == AETHER_SANDBOX_GRANTS_ERR_TOOBIG ? "grant list too large" + : "cannot create shared memory for grants"; +} + #endif diff --git a/runtime/sandbox/spawn_sandboxed_bsd.c b/runtime/sandbox/spawn_sandboxed_bsd.c index aae1f5173..1dac1035c 100644 --- a/runtime/sandbox/spawn_sandboxed_bsd.c +++ b/runtime/sandbox/spawn_sandboxed_bsd.c @@ -90,7 +90,8 @@ static int serialize_grants(void* grant_list, char* shm_name, size_t shm_name_si char buf[AETHER_SANDBOX_GRANTS_SIZE]; size_t data_size = 0; if (aether_sandbox_serialize_grants( - grant_list, buf, sizeof buf, &data_size) != 0) return -1; + grant_list, buf, sizeof buf, &data_size) != 0) + return aether_sandbox_grants_err_code(errno); return aether_sandbox_shm_write( shm_name, shm_name_size, buf, data_size); @@ -109,8 +110,9 @@ int aether_spawn_sandboxed(void* grant_list, const char* program, const char* ar // Serialize grants to shared memory char shm_name[AETHER_SANDBOX_SHM_NAME_SIZE]; - if (serialize_grants(grant_list, shm_name, sizeof shm_name) < 0) { - fprintf(stderr, "[aether] cannot create shared memory for grants\n"); + int grants_rc = serialize_grants(grant_list, shm_name, sizeof shm_name); + if (grants_rc < 0) { + fprintf(stderr, "[aether] %s\n", aether_sandbox_grants_err_msg(grants_rc)); return -1; } diff --git a/runtime/sandbox/spawn_sandboxed_linux.c b/runtime/sandbox/spawn_sandboxed_linux.c index 2b4cbbf7a..2a228139e 100644 --- a/runtime/sandbox/spawn_sandboxed_linux.c +++ b/runtime/sandbox/spawn_sandboxed_linux.c @@ -95,7 +95,8 @@ static int serialize_grants(void* grant_list, char* shm_name, size_t shm_name_si char buf[AETHER_SANDBOX_GRANTS_SIZE]; size_t data_size = 0; if (aether_sandbox_serialize_grants( - grant_list, buf, sizeof buf, &data_size) != 0) return -1; + grant_list, buf, sizeof buf, &data_size) != 0) + return aether_sandbox_grants_err_code(errno); return aether_sandbox_shm_write( shm_name, shm_name_size, buf, data_size); @@ -315,8 +316,9 @@ int aether_spawn_sandboxed(void* grant_list, const char* program, const char* ar // Serialize grants to shared memory char shm_name[AETHER_SANDBOX_SHM_NAME_SIZE]; - if (serialize_grants(grant_list, shm_name, sizeof shm_name) < 0) { - fprintf(stderr, "[aether] cannot create shared memory for grants\n"); + int grants_rc = serialize_grants(grant_list, shm_name, sizeof shm_name); + if (grants_rc < 0) { + fprintf(stderr, "[aether] %s\n", aether_sandbox_grants_err_msg(grants_rc)); return -1; } From 63ce0efb3881ea57914530b877ab5fd9996c17df Mon Sep 17 00:00:00 2001 From: paul Date: Fri, 30 Oct 2026 07:24:08 +0000 Subject: [PATCH 4/6] tests: an empty grant list confines the spawned child The child tries a file read, a TCP connect and an exec and reports which were not refused; an empty list must refuse all three, matching grants must allow all three. With an inert preload the same test fails (child status 67). sandbox_grant_transport also checks the two new diagnostics. Co-Authored-By: Claude Opus 5.5 --- .../integration/sandbox_empty_grants/child.c | 44 +++++++++++++ .../sandbox_empty_grants/harness.c | 46 ++++++++++++++ .../test_sandbox_empty_grants.sh | 62 +++++++++++++++++++ .../test_sandbox_grant_transport.sh | 4 +- 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/integration/sandbox_empty_grants/child.c create mode 100644 tests/integration/sandbox_empty_grants/harness.c create mode 100755 tests/integration/sandbox_empty_grants/test_sandbox_empty_grants.sh diff --git a/tests/integration/sandbox_empty_grants/child.c b/tests/integration/sandbox_empty_grants/child.c new file mode 100644 index 000000000..a163dee34 --- /dev/null +++ b/tests/integration/sandbox_empty_grants/child.c @@ -0,0 +1,44 @@ +/* Child for sandbox_empty_grants: try a file read, a TCP connect and an exec, + * and report which of them were NOT refused through the exit status. + * bit 0 (1) open(argv[1], O_RDONLY) succeeded + * bit 1 (2) connect() to 127.0.0.1 got past the sandbox (any errno other + * than EACCES/EPERM; nothing listens, so ECONNREFUSED is "allowed") + * bit 6 (64) execve of /bin/sh happened (the exec'd shell exits with the + * status, so a refused exec leaves the bit clear) + * Exit 0 therefore means every attempt was refused. */ +#include +#include +#include +#include +#include +#include +#include + +extern char** environ; + +int main(int argc, char** argv) { + int bits = 0; + if (argc < 2) return 99; + + int fd = open(argv[1], O_RDONLY); + if (fd >= 0) { bits |= 1; close(fd); } + + int s = socket(AF_INET, SOCK_STREAM, 0); + if (s >= 0) { + struct sockaddr_in a = { 0 }; + a.sin_family = AF_INET; + a.sin_port = htons(1); + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(s, (struct sockaddr*)&a, sizeof a) == 0 || + (errno != EACCES && errno != EPERM)) bits |= 2; + close(s); + } + + char cmd[32]; + snprintf(cmd, sizeof cmd, "exit %d", bits | 64); + char* sh_argv[] = { (char*)"sh", (char*)"-c", cmd, NULL }; + /* execve, not execv: glibc's execv calls its internal __execve, which an + * LD_PRELOAD override of execve does not see. */ + execve("/bin/sh", sh_argv, environ); /* returns only if refused */ + return bits; +} diff --git a/tests/integration/sandbox_empty_grants/harness.c b/tests/integration/sandbox_empty_grants/harness.c new file mode 100644 index 000000000..f06b7a850 --- /dev/null +++ b/tests/integration/sandbox_empty_grants/harness.c @@ -0,0 +1,46 @@ +#include +#include + +extern int aether_spawn_sandboxed(void* grant_list, const char* program, const char* arg); +extern void* list_new(void); +extern int list_add_raw(void* list, void* item); +extern void list_free(void* list); + +static void add(void* l, const char* s) { + if (!list_add_raw(l, (void*)s)) { fprintf(stderr, "cannot build grants\n"); exit(2); } +} + +int main(int argc, char** argv) { + if (argc != 3) return 2; + const char* child = argv[1]; + const char* file = argv[2]; + int failures = 0; + + /* Empty list: the child starts, confined, with nothing allowed. */ + void* none = list_new(); + if (!none) return 2; + int rc = aether_spawn_sandboxed(none, child, file); + list_free(none); + if (rc != 0) { + fprintf(stderr, "FAIL empty grants: child status %d, want 0 " + "(bit0 read, bit1 connect, 64 exec were not refused; -1 = not started)\n", rc); + failures++; + } + + /* Control: the same child with matching grants is allowed all three. */ + void* all = list_new(); + if (!all) return 2; + add(all, "fs_read"); add(all, file); + add(all, "tcp"); add(all, "127.0.0.1"); + add(all, "exec"); add(all, "/bin/sh"); + rc = aether_spawn_sandboxed(all, child, file); + list_free(all); + if (rc != (1 | 2 | 64)) { + fprintf(stderr, "FAIL granted control: child status %d, want %d\n", rc, 1 | 2 | 64); + failures++; + } + + if (failures) return 1; + printf("PASS: empty grant list confines the child; matching grants allow it\n"); + return 0; +} diff --git a/tests/integration/sandbox_empty_grants/test_sandbox_empty_grants.sh b/tests/integration/sandbox_empty_grants/test_sandbox_empty_grants.sh new file mode 100755 index 000000000..c0dd2e07b --- /dev/null +++ b/tests/integration/sandbox_empty_grants/test_sandbox_empty_grants.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# An empty grant list starts the child under an active sandbox with nothing +# allowed: its file read, TCP connect and exec are all refused. The same child +# with matching grants is allowed all three, so the test shows confinement +# rather than a broken child. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_empty_grants: spawn_sandboxed is Linux and FreeBSD only" + exit 0 ;; +esac +if [ ! -f "$ROOT/build/libaether.a" ] || + [ ! -f "$ROOT/build/libaether_sandbox.so" ]; then + echo " [SKIP] sandbox_empty_grants: sandbox runtime not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT + +if ! ${CC:-cc} -std=c11 -D_DEFAULT_SOURCE -Wall -Wextra -Werror \ + -o "$TMP/child" "$SCRIPT_DIR/child.c" 2>"$TMP/cc.err"; then + echo " [FAIL] sandbox_empty_grants: child did not compile" + sed 's/^/ /' "$TMP/cc.err" | head -8 + exit 1 +fi +if ! ${CC:-cc} -std=c11 -Wall -Wextra -Werror \ + -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" -lpthread -lm 2>"$TMP/cc.err"; then + if ! ${CC:-cc} -std=c11 -Wall -Wextra -Werror \ + -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" \ + -lpthread -ldl -lm -lrt -lz -lssl -lcrypto 2>"$TMP/cc.err"; then + echo " [FAIL] sandbox_empty_grants: harness did not link" + sed 's/^/ /' "$TMP/cc.err" | head -8 + exit 1 + fi +fi + +printf 'secret\n' > "$TMP/data.txt" + +# SANDBOX_PRELOAD_OVERRIDE is for proving the test can fail: point it at an +# inert library and the child runs unconfined (see the commit message). +PRELOAD="${SANDBOX_PRELOAD_OVERRIDE:-$ROOT/build/libaether_sandbox.so}" +out="$(AETHER_SANDBOX_PRELOAD="$PRELOAD" AETHER_SANDBOX_LOG=none \ + "$TMP/harness" "$TMP/child" "$TMP/data.txt" 2>&1 &1 Date: Fri, 30 Oct 2026 07:24:08 +0000 Subject: [PATCH 5/6] docs: empty grant list starts a deny-all child [skip actions] Co-Authored-By: Claude Opus 5.5 --- docs/containment-sandbox.md | 7 +++++++ .../20261029T075739Z-fixed-sandbox-unique-shm.md | 10 +++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/containment-sandbox.md b/docs/containment-sandbox.md index dafbe38c0..cc5d3ce9c 100644 --- a/docs/containment-sandbox.md +++ b/docs/containment-sandbox.md @@ -439,6 +439,13 @@ should have both. size limit, the sandbox stays active with zero grants rather than running unrestricted. +- **An empty grant list starts the child with nothing allowed.** + `spawn_sandboxed` with no grants runs a compute-only worker: it can use + the pipes it was given, but no files, exec or network. Malformed lists + (odd length, a `:` in a category, CR or LF in a category or pattern) and + lists over the transport size limit are refused with `-1` and the + message "invalid grant list" or "grant list too large". + - **`execve` with an explicit environment keeps the sandbox controls.** The preload layer re-adds `LD_PRELOAD` and the `AETHER_SANDBOX_*` / `AETHER_CAPSICUM` entries it captured at startup, so the child stays diff --git a/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md b/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md index 969b6b8c5..4034add88 100644 --- a/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md +++ b/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md @@ -1,5 +1,9 @@ - **Sandbox grant transport is isolated and validated.** Linux and FreeBSD now allocate an exclusive shared-memory object per - `spawn_sandboxed` call. Empty grant lists run as deny-all policies, while - malformed or oversized lists are rejected instead of being skipped or - truncated during serialization. + `spawn_sandboxed` call. An empty grant list starts the child with an active + sandbox and nothing allowed (a compute-only worker: the pipes it was given, + but no files, exec or network). Malformed lists, including CR/LF or `:` + injection into a category or pattern, and lists that exceed the transport + size (E2BIG) are refused with `-1` instead of being skipped or truncated, + and the diagnostic now says "invalid grant list" or "grant list too large" + rather than a shared-memory failure. From 4d0184075d0bdb91663d684bb36e747f3c4b5993 Mon Sep 17 00:00:00 2001 From: paul Date: Fri, 30 Oct 2026 07:41:25 +0000 Subject: [PATCH 6/6] Run CI on #3466's diagnostics, empty-grant test and docs The previous head (f49c4753) carried [skip actions], which skipped CI for the code commits under it. Co-Authored-By: Claude Opus 5.5