From b90b117581444b5afd30ed6d2d15f6279164bac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 11:08:37 +0300 Subject: [PATCH 1/9] compiler: gate capability-bearing module families MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- compiler/aetherc.c | 113 +++++++++++------- docs/aether-embedded-in-host-applications.md | 17 +-- docs/design/aether-dsl-as-a-rules-engine.md | 5 +- docs/emit-lib.md | 25 ++-- ...5535Z-security-emit-lib-module-families.md | 4 + .../emit_lib_banned/test_emit_lib_banned.sh | 12 +- .../test_emit_lib_with_capability.sh | 16 ++- 7 files changed, 118 insertions(+), 74 deletions(-) create mode 100644 new_changelogs/20261029T075535Z-security-emit-lib-module-families.md diff --git a/compiler/aetherc.c b/compiler/aetherc.c index ad566bec1..e08d01b09 100644 --- a/compiler/aetherc.c +++ b/compiler/aetherc.c @@ -1086,17 +1086,53 @@ static const char* inspect_import_origin(const char* mod) { return "local"; } +typedef struct { + const char* module; + const char* capability; +} ModuleCapability; + +// Keep aliases and nested module families behind the same gate as their +// underlying authority. A match ends at the module name or a '.' boundary, +// so std.filex does not inherit std.file's classification. +static const ModuleCapability module_capabilities[] = { + { "std.fs", "fs" }, + { "std.file", "fs" }, + { "std.dir", "fs" }, + { "std.net", "net" }, + { "std.http", "net" }, + { "std.tcp", "net" }, + { "std.udp", "net" }, + { "std.os", "os" }, + { "std.dl", "os" }, + { "contrib.host", "extern" }, +}; + +static int module_is_or_descends_from(const char* mod, const char* root) { + size_t n; + if (!mod || !root) return 0; + n = strlen(root); + return strncmp(mod, root, n) == 0 && (mod[n] == '\0' || mod[n] == '.'); +} + // The --with capability a gated module needs, or NULL if ungated. -static const char* inspect_module_capability(const char* mod) { - if (!mod) return NULL; - if (strcmp(mod, "std.fs") == 0) return "fs"; - if (strcmp(mod, "std.net") == 0) return "net"; - if (strcmp(mod, "std.http") == 0) return "net"; - if (strcmp(mod, "std.tcp") == 0) return "net"; - if (strcmp(mod, "std.os") == 0) return "os"; +static const char* module_capability(const char* mod) { + size_t count = sizeof(module_capabilities) / sizeof(module_capabilities[0]); + for (size_t i = 0; i < count; i++) { + if (module_is_or_descends_from(mod, module_capabilities[i].module)) + return module_capabilities[i].capability; + } return NULL; } +static int module_capability_is_granted(const char* capability) { + if (!capability) return 1; + if (strcmp(capability, "fs") == 0) return with_fs; + if (strcmp(capability, "net") == 0) return with_net; + if (strcmp(capability, "os") == 0) return with_os; + if (strcmp(capability, "extern") == 0) return with_extern; + return 0; +} + // True iff `c` is a declaration from THIS file (not merged in from an // imported module). `is_imported` covers most merged nodes, but some — // e.g. an imported module's top-level constants — aren't flagged, so we @@ -1115,7 +1151,7 @@ static void emit_inspect_report(FILE* out, ASTNode* program, const char* path) { // First pass: tallies + artifact shape. int n_import = 0, n_fn = 0, n_actor = 0, n_struct = 0, n_const = 0, n_extern = 0, n_builder = 0, n_export = 0, has_main = 0; - int need_fs = 0, need_net = 0, need_os = 0; + int need_fs = 0, need_net = 0, need_os = 0, need_extern = 0; for (int i = 0; i < program->child_count; i++) { ASTNode* c = program->children[i]; // Skip declarations merged in from imported modules — inspect @@ -1124,11 +1160,12 @@ static void emit_inspect_report(FILE* out, ASTNode* program, const char* path) { switch (c->type) { case AST_IMPORT_STATEMENT: { n_import++; - const char* cap = inspect_module_capability(c->value); + const char* cap = module_capability(c->value); if (cap) { if (strcmp(cap, "fs") == 0) need_fs = 1; else if (strcmp(cap, "net") == 0) need_net = 1; else if (strcmp(cap, "os") == 0) need_os = 1; + else if (strcmp(cap, "extern") == 0) need_extern = 1; } break; } @@ -1143,7 +1180,7 @@ static void emit_inspect_report(FILE* out, ASTNode* program, const char* path) { case AST_ACTOR_DEFINITION: n_actor++; break; case AST_STRUCT_DEFINITION: n_struct++; break; case AST_CONST_DECLARATION: n_const++; break; - case AST_EXTERN_FUNCTION: n_extern++; break; + case AST_EXTERN_FUNCTION: n_extern++; need_extern = 1; break; case AST_EXPORTS_LIST: n_export += c->child_count; break; default: break; } @@ -1163,7 +1200,6 @@ static void emit_inspect_report(FILE* out, ASTNode* program, const char* path) { // Capability posture: what the gated imports require, and whether the // current --with flags would grant it. - int need_extern = n_extern > 0; if (need_fs || need_net || need_os || need_extern) { fprintf(out, " capabilities required (gated imports):"); if (need_fs) fprintf(out, " fs%s", with_fs ? "(granted)" : ""); @@ -1184,7 +1220,7 @@ static void emit_inspect_report(FILE* out, ASTNode* program, const char* path) { for (int i = 0; i < program->child_count; i++) { ASTNode* c = program->children[i]; if (!inspect_is_local(c, path) || c->type != AST_IMPORT_STATEMENT || !c->value) continue; - const char* cap = inspect_module_capability(c->value); + const char* cap = module_capability(c->value); fprintf(out, " %-28s [%s%s%s]\n", c->value, inspect_import_origin(c->value), cap ? ", capability: " : "", cap ? cap : ""); @@ -1630,43 +1666,30 @@ int compile_source(const char* input_path, const char* output_path) { // / --with=os. The gate stays default-deny; --with is the explicit // acknowledgement that the host owns and audits the surface it's // enabling. Keeping the capability categories coarse (three buckets) - // matches the three banned-import groupings below. + // matches the capability registry shared with `inspect`. if (emit_lib) { - // Each entry is (module_name, granted_flag, capability_name). - // When granted_flag is non-zero, that module is allowed; otherwise - // it's rejected with a message that names the --with flag needed. - struct { const char* module; bool granted; const char* cap; } gated[] = { - { "std.fs", with_fs, "fs" }, - { "std.net", with_net, "net" }, - { "std.http", with_net, "net" }, - { "std.tcp", with_net, "net" }, - { "std.os", with_os, "os" }, - }; - int num_gated = (int)(sizeof(gated) / sizeof(gated[0])); for (int i = 0; i < program->child_count; i++) { ASTNode* child = program->children[i]; if (!child || child->type != AST_IMPORT_STATEMENT || !child->value) continue; - for (int g = 0; g < num_gated; g++) { - if (strcmp(child->value, gated[g].module) != 0) continue; - if (gated[g].granted) break; // opted in; allow. - fprintf(stderr, - "Error: --emit=lib rejects 'import %s' without --with=%s.\n" - "\n" - " The library ABI is capability-empty by default so an\n" - " embedded Aether script can't escalate beyond what its\n" - " host grants. Pass --with=%s if the binary linking this\n" - " library is itself the capability owner (i.e. you're\n" - " writing systems code that compiles .ae and handwritten\n" - " C into one executable). Multiple capabilities can be\n" - " comma-separated: --with=%s,os\n", - gated[g].module, gated[g].cap, gated[g].cap, gated[g].cap); - module_registry_shutdown(); - free_ast_node(program); - free_tokens(tokens, token_count); - free_parser(parser); - free(source); - return 0; - } + const char* cap = module_capability(child->value); + if (!cap || module_capability_is_granted(cap)) continue; + fprintf(stderr, + "Error: --emit=lib rejects 'import %s' without --with=%s.\n" + "\n" + " The library ABI is capability-empty by default so an\n" + " embedded Aether script can't escalate beyond what its\n" + " host grants. Pass --with=%s if the binary linking this\n" + " library is itself the capability owner (i.e. you're\n" + " writing systems code that compiles .ae and handwritten\n" + " C into one executable). Multiple capabilities can be\n" + " comma-separated: --with=%s,os\n", + child->value, cap, cap, cap); + module_registry_shutdown(); + free_ast_node(program); + free_tokens(tokens, token_count); + free_parser(parser); + free(source); + return 0; } } diff --git a/docs/aether-embedded-in-host-applications.md b/docs/aether-embedded-in-host-applications.md index aec73c4dc..0bc946b56 100644 --- a/docs/aether-embedded-in-host-applications.md +++ b/docs/aether-embedded-in-host-applications.md @@ -20,7 +20,8 @@ applications (Java, Go, etc.). > for Python, Java (Panama, JDK 22+), Ruby (Fiddle). Java SWIG is no > longer the recommended path, Panama is cleaner once you have a > namespace manifest to template against -> - Part 3, capability-empty default (rejects `std.net|http|tcp|fs|os`) +> - Part 3, capability-empty default (rejects network, filesystem, OS, and +> foreign-runtime module families) > - Part 4, LD_PRELOAD containment composes naturally with `--emit=lib` > > What's still speculative (📋 Future): @@ -479,18 +480,18 @@ Any config language that executes as real code introduces risks that static config (JSON, TOML) doesn't have: **1. Network access.** If the embedded Aether runtime has access to -socket APIs, a config file could open a listener, exfiltrate data, or -act as a backdoor. The embedding mode should **not link `std.tcp`, -`std.http`, or `std.net`** by default. The facade should be the *only* +socket APIs, a config file could open a listener or send data outside the +host. The embedding mode should **not link `std.tcp`, `std.udp`, +`std.http` (including its children), or `std.net`** by default. The facade should be the *only* way the script talks to the outside world. **2. File system access.** A config script that can read `/etc/passwd` or write to `/tmp` is a liability. The embedding mode should **not link -`std.fs` or `std.file`** unless the host explicitly opts in. +`std.fs`, `std.file`, or `std.dir`** unless the host explicitly opts in. -**3. Process spawning.** `os_system()` and `os_run()` should be -unavailable in embedded mode. A config file that can `exec` arbitrary -commands is not a config file, it's a remote code execution vector. +**3. OS integration.** `os_system()`, `os_run()`, dynamic loading through +`std.dl`, and foreign runtimes through `contrib.host.*` should be unavailable +in embedded mode unless the host explicitly grants the corresponding category. **4. Unbounded computation.** A config script with an infinite loop hangs the host. The embedding runtime should support a **wall-clock diff --git a/docs/design/aether-dsl-as-a-rules-engine.md b/docs/design/aether-dsl-as-a-rules-engine.md index 11b2cd615..e5e0b61ae 100644 --- a/docs/design/aether-dsl-as-a-rules-engine.md +++ b/docs/design/aether-dsl-as-a-rules-engine.md @@ -384,8 +384,9 @@ A `rules` module providing: ### In the compiler -- ✅ **Done**: shared library output (`aetherc --emit=lib`), with - capability-empty link profile rejecting `std.net|http|tcp|fs|os`. +- ✅ **Done**: shared library output (`aetherc --emit=lib`), with a + capability-empty link profile covering network, filesystem, OS, and + foreign-runtime module families. Renamed from the original `--embed` proposal to fit alongside `--emit=exe` and `--emit=both`. - 📋 **Future**: `ae eval` subcommand for the subprocess model diff --git a/docs/emit-lib.md b/docs/emit-lib.md index db97e529d..e9f12594e 100644 --- a/docs/emit-lib.md +++ b/docs/emit-lib.md @@ -440,9 +440,10 @@ way you would treat any other cross-language value exchange. `--emit=lib` rejects `.ae` files that import: -- `std.net`, `std.http`, `std.tcp` networking -- `std.fs` filesystem -- `std.os` process / environment / shell +- `std.net`, `std.http` and its children, `std.tcp`, `std.udp` networking +- `std.fs`, `std.file`, `std.dir` filesystem +- `std.os`, `std.dl` process, environment, shell, and dynamic loading +- `contrib.host.*` foreign-language runtimes The rationale: a library embedded in a host process shouldn't have ambient network, filesystem, or process-spawning access. Those are @@ -456,9 +457,9 @@ other capability-free standard modules are allowed. It also rejects an `extern` declared in the program's own code (the entry file, an `@extern("…")` binding, or a local module it imports). An extern names a C function directly, so `extern system(cmd: string) -> int` would -reach what `import std.os` is gated to stop. Externs declared by `std.*` and -`contrib.*` modules are not the program's and are unaffected: those modules -sit behind the gates above. Opt in with `--with=extern`. +reach what `import std.os` is gated to stop. Externs declared by other `std.*` +modules are not the program's and are unaffected. Foreign-language host modules +sit behind the `contrib.host.*` gate above. Opt in with `--with=extern`. ### Opting in: `--with=` @@ -467,13 +468,13 @@ handwritten C into one binary, rather than embedding Aether as an untrusted user script, opt into specific capability categories: ```sh -ae build --emit=lib --with=fs file.ae # std.fs -ae build --emit=lib --with=net file.ae # std.net, std.http, std.tcp -ae build --emit=lib --with=os file.ae # std.os +ae build --emit=lib --with=fs file.ae # std.fs, std.file, std.dir +ae build --emit=lib --with=net file.ae # std.net/http/tcp/udp families +ae build --emit=lib --with=os file.ae # std.os, std.dl ae build --emit=lib --with=fs,os file.ae # multiple, comma-separated -ae build --emit=lib --with=extern file.ae # the program's own C externs -ae build --emit=lib --with=first-party file.ae # alias for fs,net,os -ae build --emit=lib --with=all file.ae # alias for fs,net,os +ae build --emit=lib --with=extern file.ae # own C externs, contrib.host.* +ae build --emit=lib --with=first-party file.ae # alias for fs,net,os,extern +ae build --emit=lib --with=all file.ae # alias for fs,net,os,extern ``` The gate stays default-deny: a build without `--with=fs` still rejects diff --git a/new_changelogs/20261029T075535Z-security-emit-lib-module-families.md b/new_changelogs/20261029T075535Z-security-emit-lib-module-families.md new file mode 100644 index 000000000..f78c44e7f --- /dev/null +++ b/new_changelogs/20261029T075535Z-security-emit-lib-module-families.md @@ -0,0 +1,4 @@ +- **Shared-library capability checks now cover aliases and module families.** + `--emit=lib` now applies the filesystem gate to `std.file` and `std.dir`, + the network gate to `std.udp` and nested `std.http` modules, the OS gate to + `std.dl`, and the extern gate to `contrib.host.*`. diff --git a/tests/integration/emit_lib_banned/test_emit_lib_banned.sh b/tests/integration/emit_lib_banned/test_emit_lib_banned.sh index 142ab35b5..c4e858109 100755 --- a/tests/integration/emit_lib_banned/test_emit_lib_banned.sh +++ b/tests/integration/emit_lib_banned/test_emit_lib_banned.sh @@ -1,7 +1,7 @@ #!/bin/sh -# Verifies --emit=lib rejects every capability-heavy stdlib import. -# All five should produce the capability-empty error and a non-zero -# exit status. +# Verifies --emit=lib rejects every capability-heavy module family. +# Each case should produce the capability-empty error and a non-zero +# exit status, including aliases and nested modules. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" @@ -25,7 +25,11 @@ check_banned() { fi } -for mod in std.net std.http std.tcp std.fs std.os; do +for mod in \ + std.net std.http std.http.client std.tcp std.udp \ + std.fs std.file std.dir \ + std.os std.dl \ + contrib.host.python; do check_banned "$mod" done diff --git a/tests/integration/emit_lib_with_capability/test_emit_lib_with_capability.sh b/tests/integration/emit_lib_with_capability/test_emit_lib_with_capability.sh index fbefb5aac..cece6d0d2 100755 --- a/tests/integration/emit_lib_with_capability/test_emit_lib_with_capability.sh +++ b/tests/integration/emit_lib_with_capability/test_emit_lib_with_capability.sh @@ -5,9 +5,9 @@ # — that's pinned by the existing emit_lib_banned test. This test pins # the opt-in direction: # -# 1. --with=fs unlocks std.fs but still rejects std.net / std.os. -# 2. --with=net unlocks std.net / std.http / std.tcp but not std.fs. -# 3. --with=os unlocks std.os. +# 1. --with=fs unlocks the filesystem module family but still rejects net / os. +# 2. --with=net unlocks the network module family but not fs. +# 3. --with=os unlocks the operating-system module family. # 4. --with=fs,os unlocks both in one invocation. # 5. --with= is a hard error, not a silent no-op. # 6. --with=fs WITHOUT --emit=lib is a no-op (no error, no effect). @@ -53,6 +53,8 @@ run_case() { # ---- 1. --with=fs unlocks std.fs, still rejects std.net / std.os ---- run_case "--with=fs accepts std.fs" std.fs "--emit=lib --with=fs" accept +run_case "--with=fs accepts std.file" std.file "--emit=lib --with=fs" accept +run_case "--with=fs accepts std.dir" std.dir "--emit=lib --with=fs" accept run_case "--with=fs still rejects std.net" std.net "--emit=lib --with=fs" reject run_case "--with=fs still rejects std.os" std.os "--emit=lib --with=fs" reject @@ -60,12 +62,20 @@ run_case "--with=fs still rejects std.os" std.os "--emit=lib --with=fs" rejec run_case "--with=net accepts std.net" std.net "--emit=lib --with=net" accept run_case "--with=net accepts std.http" std.http "--emit=lib --with=net" accept +run_case "--with=net accepts std.http.client" std.http.client "--emit=lib --with=net" accept run_case "--with=net accepts std.tcp" std.tcp "--emit=lib --with=net" accept +run_case "--with=net accepts std.udp" std.udp "--emit=lib --with=net" accept run_case "--with=net still rejects std.fs" std.fs "--emit=lib --with=net" reject # ---- 3. --with=os unlocks std.os ---- run_case "--with=os accepts std.os" std.os "--emit=lib --with=os" accept +run_case "--with=os accepts std.dl" std.dl "--emit=lib --with=os" accept + +# ---- 3b. contrib.host.* requires the explicit extern capability ---- + +run_case "--with=extern accepts contrib.host.python" contrib.host.python "--emit=lib --with=extern" accept +run_case "--with=os still rejects contrib.host.python" contrib.host.python "--emit=lib --with=os" reject # ---- 4. --with=fs,os unlocks both in one invocation ---- From ad37fbda89a41a3dc86e22382360bfb7290d4801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 11:41:37 +0300 Subject: [PATCH 2/9] fix(compiler): check every clause under effect tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- compiler/analysis/typechecker.c | 40 ++++++++++++++----- ...9T120000Z-fixed-effect-tags-multiclause.md | 4 ++ .../effect_tags/reject_multiclause.ae | 7 ++++ .../reject_multiclause_transitive.ae | 9 +++++ .../effect_tags/test_effect_tags.sh | 4 +- 5 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 new_changelogs/20261029T120000Z-fixed-effect-tags-multiclause.md create mode 100644 tests/integration/effect_tags/reject_multiclause.ae create mode 100644 tests/integration/effect_tags/reject_multiclause_transitive.ae diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 4ced9fbe8..6e0da962a 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -11781,17 +11781,27 @@ static const char* effect_scan(ASTNode* program, ASTNode* node, const char* forb } } { + ASTNode* def = effect_resolve_callee(program, node->value); + const char* callee = def && def->value ? def->value : node->value; int seen = 0; for (int i = 0; i < *nvisited; i++) - if (strcmp(visited[i], node->value) == 0) { seen = 1; break; } + if (strcmp(visited[i], callee) == 0) { seen = 1; break; } if (!seen && *nvisited < 512) { - ASTNode* def = effect_resolve_callee(program, node->value); if (def && def->child_count > 0) { - visited[(*nvisited)++] = node->value; - ASTNode* body = def->children[def->child_count - 1]; - const char* c = effect_scan(program, body, forbid, visited, - nvisited, outcall, outsz, depth + 1); - if (c) return c; + visited[(*nvisited)++] = callee; + for (int i = 0; i < program->child_count; i++) { + ASTNode* clause = program->children[i]; + if (!clause || + (clause->type != AST_FUNCTION_DEFINITION && + clause->type != AST_BUILDER_FUNCTION) || + !clause->value || strcmp(clause->value, callee) != 0 || + clause->child_count == 0) + continue; + ASTNode* body = clause->children[clause->child_count - 1]; + const char* c = effect_scan(program, body, forbid, visited, + nvisited, outcall, outsz, depth + 1); + if (c) return c; + } } } } @@ -11817,14 +11827,24 @@ static void check_effect_tags(ASTNode* program) { if (!eff) continue; const char* forbid = eff + 7; /* "fs,net,os" or a subset */ if (fn->child_count == 0) continue; - ASTNode* body = fn->children[fn->child_count - 1]; const char* visited[512]; int nv = 0; if (fn->value) visited[nv++] = fn->value; /* no self-recursion loop */ char offending[160]; offending[0] = '\0'; - const char* cap = effect_scan(program, body, forbid, visited, &nv, - offending, sizeof(offending), 0); + const char* cap = NULL; + for (int j = 0; j < program->child_count && !cap; j++) { + ASTNode* clause = program->children[j]; + if (!clause || + (clause->type != AST_FUNCTION_DEFINITION && + clause->type != AST_BUILDER_FUNCTION) || + !clause->value || !fn->value || strcmp(clause->value, fn->value) != 0 || + clause->child_count == 0) + continue; + ASTNode* body = clause->children[clause->child_count - 1]; + cap = effect_scan(program, body, forbid, visited, &nv, + offending, sizeof(offending), 0); + } if (cap) { /* #2729: named as the source spells it (`checks.shell`), not by * the merged C name (`checks_shell`). */ diff --git a/new_changelogs/20261029T120000Z-fixed-effect-tags-multiclause.md b/new_changelogs/20261029T120000Z-fixed-effect-tags-multiclause.md new file mode 100644 index 000000000..b8e047cc1 --- /dev/null +++ b/new_changelogs/20261029T120000Z-fixed-effect-tags-multiclause.md @@ -0,0 +1,4 @@ +- **Effect tags now cover every clause of a function.** `@pure`, `@no_fs`, + `@no_net`, and `@no_os` no longer accept a function when a later pattern + clause, or a later clause of a transitive callee, reaches a forbidden + capability. diff --git a/tests/integration/effect_tags/reject_multiclause.ae b/tests/integration/effect_tags/reject_multiclause.ae new file mode 100644 index 000000000..8da61264b --- /dev/null +++ b/tests/integration/effect_tags/reject_multiclause.ae @@ -0,0 +1,7 @@ +import std.os + +@no_os +status(0) -> 0 +status(n: int) -> os.system("echo hi") + n + +main() { status(1) } diff --git a/tests/integration/effect_tags/reject_multiclause_transitive.ae b/tests/integration/effect_tags/reject_multiclause_transitive.ae new file mode 100644 index 000000000..7b8f49e66 --- /dev/null +++ b/tests/integration/effect_tags/reject_multiclause_transitive.ae @@ -0,0 +1,9 @@ +import std.os + +helper(0) -> 0 +helper(n: int) -> os.system("echo hi") + n + +@pure +run() -> helper(1) + +main() { run() } diff --git a/tests/integration/effect_tags/test_effect_tags.sh b/tests/integration/effect_tags/test_effect_tags.sh index 3d4f489a1..0ed0b867d 100755 --- a/tests/integration/effect_tags/test_effect_tags.sh +++ b/tests/integration/effect_tags/test_effect_tags.sh @@ -25,6 +25,8 @@ check_rejected() { check_rejected reject_fs fs check_rejected reject_transitive os check_rejected reject_net net +check_rejected reject_multiclause os +check_rejected reject_multiclause_transitive os -echo " [PASS] effect_tags: @no_fs / @pure-transitive / @no_net violations all rejected" +echo " [PASS] effect_tags: direct, transitive, and multi-clause violations all rejected" exit 0 From ee93641e94de3db13d692f795895840de4db42ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 11:08:39 +0300 Subject: [PATCH 3/9] I enforced hide and seal across DSL receiver lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- compiler/analysis/typechecker.c | 68 +++++++++++-------- ...075358Z-security-hide-seal-dsl-receiver.md | 3 + .../hide_seal_directives/test_hide_reject.sh | 62 +++++++++++++++++ 3 files changed, 106 insertions(+), 27 deletions(-) create mode 100644 new_changelogs/20261029T075358Z-security-hide-seal-dsl-receiver.md diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 6e0da962a..5bdcb4d63 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -154,6 +154,26 @@ int scope_name_in_whitelist(SymbolTable* table, const char* name) { return name_list_contains(table->seal_whitelist, name); } +static void scope_apply_visibility_directives(SymbolTable* table, ASTNode* block) { + if (!table || !block) return; + for (int i = 0; i < block->child_count; i++) { + ASTNode* child = block->children[i]; + if (!child) continue; + if (child->type == AST_HIDE_DIRECTIVE) { + for (int j = 0; j < child->child_count; j++) { + ASTNode* id = child->children[j]; + if (id && id->value) scope_hide_name(table, id->value); + } + } else if (child->type == AST_SEAL_DIRECTIVE) { + table->is_sealed = 1; + for (int j = 0; j < child->child_count; j++) { + ASTNode* id = child->children[j]; + if (id && id->value) scope_seal_except(table, id->value); + } + } + } +} + // Returns 1 if `name` was blocked by a `hide` or `seal except` directive // somewhere in the scope chain AND a real binding for it exists farther // up (above the blocking scope). Used to give a better error message than @@ -533,6 +553,15 @@ static Symbol* lookup_symbol_in(SymbolTable* table, const char* name, const char if (symbol && !table->parent) return own_extern_symbol(symbol, merged_from, name); if (symbol) return symbol; + // Leaving this scope, whether through a DSL receiver or the parent chain: + // enforce hide / seal directives before resolving an outward name. + if (scope_name_is_hidden(table, name)) { + return NULL; + } + if (table->is_sealed && !scope_name_in_whitelist(table, name)) { + return NULL; + } + // Issue #333 DSL block receiver fallback: when this scope is the // body of a `receiver.method(args) { ... }` trailing closure, // `dsl_receiver` names the receiver namespace or struct type. @@ -558,22 +587,16 @@ static Symbol* lookup_symbol_in(SymbolTable* table, const char* name, const char snprintf(rewritten, sizeof(rewritten), "%.250s_%.250s", table->dsl_receiver, name); for (SymbolTable* t = table; t; t = t->parent) { + if (t != table && + (scope_name_is_hidden(t, name) || + (t->is_sealed && !scope_name_in_whitelist(t, name)))) { + return NULL; + } Symbol* s = lookup_symbol_local(t, rewritten); if (s) return s; } } - // Crossing the scope boundary upward: enforce hide / seal directives. - // - `hide foo` blocks any name in the hidden_names list. - // - `seal except a, b` blocks anything that isn't in the whitelist. - // Either way, return NULL so the caller sees an undefined identifier. - if (scope_name_is_hidden(table, name)) { - return NULL; - } - if (table->is_sealed && !scope_name_in_whitelist(table, name)) { - return NULL; - } - if (table->parent) { return lookup_symbol_in(table->parent, name, merged_from); } @@ -15434,22 +15457,7 @@ int typecheck_statement(ASTNode* stmt, SymbolTable* table) { // PRE-PASS: collect hide / seal directives BEFORE processing any // other statements, so they're scope-level (position within the // block doesn't matter) and apply to every other statement here. - for (int i = 0; i < stmt->child_count; i++) { - ASTNode* child = stmt->children[i]; - if (!child) continue; - if (child->type == AST_HIDE_DIRECTIVE) { - for (int j = 0; j < child->child_count; j++) { - ASTNode* id = child->children[j]; - if (id && id->value) scope_hide_name(block_table, id->value); - } - } else if (child->type == AST_SEAL_DIRECTIVE) { - block_table->is_sealed = 1; - for (int j = 0; j < child->child_count; j++) { - ASTNode* id = child->children[j]; - if (id && id->value) scope_seal_except(block_table, id->value); - } - } - } + scope_apply_visibility_directives(block_table, stmt); for (int i = 0; i < stmt->child_count; i++) { ASTNode* child = stmt->children[i]; @@ -17456,6 +17464,12 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { } } + for (int i = 0; i < expr->child_count; i++) { + ASTNode* child = expr->children[i]; + if (child && child->type == AST_BLOCK) + scope_apply_visibility_directives(closure_scope, child); + } + // Register closure parameters in the child scope for (int i = 0; i < expr->child_count; i++) { ASTNode* child = expr->children[i]; diff --git a/new_changelogs/20261029T075358Z-security-hide-seal-dsl-receiver.md b/new_changelogs/20261029T075358Z-security-hide-seal-dsl-receiver.md new file mode 100644 index 000000000..a4f7b11e9 --- /dev/null +++ b/new_changelogs/20261029T075358Z-security-hide-seal-dsl-receiver.md @@ -0,0 +1,3 @@ +- **`hide` and `seal except` now cover DSL receiver fallback.** A trailing + builder block could previously resolve a blocked bare name through its + receiver-prefixed method before lexical visibility was enforced. diff --git a/tests/integration/hide_seal_directives/test_hide_reject.sh b/tests/integration/hide_seal_directives/test_hide_reject.sh index b0eed4a38..fb4a8c07b 100755 --- a/tests/integration/hide_seal_directives/test_hide_reject.sh +++ b/tests/integration/hide_seal_directives/test_hide_reject.sh @@ -270,6 +270,68 @@ main() { EOF run_accept "seal except lets a whitelisted prefix through nested blocks" $AE_TMP/ae_seal_qualified_whitelisted.ae +# Cases 16-19: DSL receiver fallback is still an outward lookup. A hidden or +# sealed-out bare name must not reach the receiver-prefixed function, while a +# name admitted by the seal remains available through that fallback. +mkdir -p $AE_TMP/dsl_helper +cat > $AE_TMP/dsl_helper/module.ae << 'EOF' +exports(run, script) + +builder run(label: string) {} + +script(_ctx: ptr, name: string) {} +EOF + +cat > $AE_TMP/ae_hide_dsl_receiver.ae << 'EOF' +import dsl_helper +main() { + dsl_helper.run("direct") { + hide script + script("blocked") + } +} +EOF +run_reject "hide blocks DSL receiver fallback" $AE_TMP/ae_hide_dsl_receiver.ae + +cat > $AE_TMP/ae_seal_dsl_receiver_nested.ae << 'EOF' +import dsl_helper +main() { + dsl_helper.run("nested") { + seal except println + { + script("blocked") + } + } +} +EOF +run_reject "seal except blocks nested DSL receiver fallback" $AE_TMP/ae_seal_dsl_receiver_nested.ae + +cat > $AE_TMP/ae_seal_dsl_receiver_allowed.ae << 'EOF' +import dsl_helper +main() { + dsl_helper.run("allowed") { + seal except script + script("ok") + } +} +EOF +run_accept "seal except admits whitelisted DSL receiver fallback" $AE_TMP/ae_seal_dsl_receiver_allowed.ae + +cat > $AE_TMP/ae_hide_dsl_receiver_outer.ae << 'EOF' +import dsl_helper +main() { + { + hide script + { + dsl_helper.run("outer") { + script("blocked") + } + } + } +} +EOF +run_reject "hide blocks DSL receiver fallback across nested scopes" $AE_TMP/ae_hide_dsl_receiver_outer.ae + # Cleanup rm -f $AE_TMP/ae_hide_*.ae $AE_TMP/ae_seal_*.ae $AE_TMP/ae_hide_out From 40c50edbcd41e7b540f114002fa5f5a415bdb666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 10:57:18 +0300 Subject: [PATCH 4/9] ci: run FreeBSD containment tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- .github/workflows/ci.yml | 2 ++ std/capsicum/test_capsicum_portability.ae | 8 +++--- tests/freebsd/README.md | 32 +++++++++++------------ tests/freebsd/capsicum_enforce.ae | 7 +++-- tests/freebsd/casper_delegation.ae | 7 ++--- tests/freebsd/nightly.sh | 19 +++++++------- tests/freebsd/rights_limit_stdlib_fd.ae | 4 +-- tests/freebsd/run.sh | 5 ++-- 8 files changed, 41 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4c687a76..a0d98c24f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -596,6 +596,8 @@ jobs: set -e gmake compiler ae stdlib gmake test + ./build/ae run std/capsicum/test_capsicum_portability.ae + sh tests/freebsd/run.sh # ============================================================ # contrib/host bridge check — syntax check of every host bridge diff --git a/std/capsicum/test_capsicum_portability.ae b/std/capsicum/test_capsicum_portability.ae index 04bb95236..7420bcbcd 100644 --- a/std/capsicum/test_capsicum_portability.ae +++ b/std/capsicum/test_capsicum_portability.ae @@ -1,5 +1,4 @@ -// std.capsicum / std.casper portability (#1584) — co-located, replacing -// tests/regression/test_capsicum_portability.ae. +// std.capsicum / std.casper portability (#1584), co-located with the module. // // Capsicum is FreeBSD-only, and that is exactly why this suite runs // EVERYWHERE rather than skipping. Two contracts, and both matter: @@ -16,9 +15,8 @@ // half everywhere and asserts something real on every leg. // // The kernel-enforcement behaviour itself (entering capability mode, -// rights narrowing, Casper delegation) needs a live FreeBSD box: that -// lives in tests/freebsd/ and the nightly harness, which the CI matrix -// has no runner for. +// rights narrowing, Casper delegation) needs a live FreeBSD box. Those +// tests live in tests/freebsd/ and run in the native FreeBSD CI job. import std.spec import std.capsicum diff --git a/tests/freebsd/README.md b/tests/freebsd/README.md index ab5dcc67d..d34faf6c3 100644 --- a/tests/freebsd/README.md +++ b/tests/freebsd/README.md @@ -1,14 +1,14 @@ # FreeBSD-only sandbox tests These exercise **real Capsicum / Casper enforcement** and can only pass on a -FreeBSD host with a Capsicum-enabled kernel. GitHub's CI matrix has **no -FreeBSD runner**, so they live here (outside `tests/regression/`) and are run -by `run.sh` / the nightly harness rather than the normal test suite. +FreeBSD host with a Capsicum-enabled kernel. They live here, outside the +portable regression suite, and are run by `run.sh` in the native FreeBSD CI +VM. The nightly harness can provide an independent scheduled run. -The portable side of the contract — graceful degrade off FreeBSD, the gate -`aeo`'s `require_capsicum()` / `prefer_capsicum()` depends on — **is** covered -by `tests/regression/test_capsicum_portability.ae`, which runs on every CI -platform. +The portable side of the contract - graceful degrade off FreeBSD, the gate +`aeo`'s `require_capsicum()` / `prefer_capsicum()` depends on - **is** covered +by `std/capsicum/test_capsicum_portability.ae`, which runs in the portable +suite and explicitly in the native FreeBSD CI job. ## Tests @@ -39,22 +39,22 @@ sh tests/freebsd/run.sh # uses ./build/ae; all-skip + exit 0 off FreeB AE=/path/to/ae sh tests/freebsd/run.sh ``` -## Nightly self-test (the CI stand-in) +## Optional nightly self-test -`nightly.sh` syncs the target branch, builds from clean, runs these tests + -the portable contract test + the `#668` seccomp clone-fence test, and **files -a GitHub issue on failure** (deduped by an issue-title marker; comments on the -existing issue rather than spamming new ones). Intended for a cron on the -GhostBSD build box. +`nightly.sh` provides an independent scheduled run. It syncs the target branch, +builds from clean, runs these tests plus the portable contract test, and +**files a GitHub issue on failure** (deduped by an issue-title marker; comments +on the existing issue rather than spamming new ones). It is intended for a cron +on the GhostBSD build box. ```sh # GhostBSD cron (03:17 nightly): -17 3 * * * cd $HOME/aether && BRANCH=feat/freebsd-sandbox-parity-v2 \ +17 3 * * * cd $HOME/aether && BRANCH=main \ sh tests/freebsd/nightly.sh >> $HOME/aether-nightly.log 2>&1 ``` `gh` must be authenticated on the host for issue filing; without it the script still runs the tests and just prints the report. `NO_ISSUE=1` forces a dry run. -When a real FreeBSD CI runner exists, fold `run.sh` into the matrix and retire -the nightly (or keep it as defence in depth). +The native FreeBSD CI job is the pull-request gate. Keep the nightly only when +an independent scheduled run is useful. diff --git a/tests/freebsd/capsicum_enforce.ae b/tests/freebsd/capsicum_enforce.ae index bf7fbc002..383303265 100644 --- a/tests/freebsd/capsicum_enforce.ae +++ b/tests/freebsd/capsicum_enforce.ae @@ -1,7 +1,6 @@ -// FreeBSD-only Capsicum enforcement test (run as a subprocess by the -// nightly harness / tests/freebsd/run.sh). NOT in the CI regression set — -// the CI matrix has no FreeBSD runner; the portable contract is covered by -// tests/regression/test_capsicum_portability.ae. +// FreeBSD-only Capsicum enforcement test (run as a subprocess by +// tests/freebsd/run.sh in the native FreeBSD CI job). The portable contract +// is covered by std/capsicum/test_capsicum_portability.ae. // // capsicum.enter() is IRREVERSIBLE and process-wide, so this whole program // is the unit: it asserts pre-state, enters capability mode, then asserts diff --git a/tests/freebsd/casper_delegation.ae b/tests/freebsd/casper_delegation.ae index df962c09c..2ff4bfd1e 100644 --- a/tests/freebsd/casper_delegation.ae +++ b/tests/freebsd/casper_delegation.ae @@ -1,7 +1,8 @@ -// FreeBSD-only Casper delegation test (run as a subprocess by the nightly -// harness). Proves the two-phase pattern that std.casper's docstring (and +// FreeBSD-only Casper delegation test (run as a subprocess by +// tests/freebsd/run.sh in the native FreeBSD CI job). Proves the two-phase +// pattern that std.casper's docstring (and // aeo's design) depend on: open the service channel BEFORE entering -// capability mode, then use it AFTER — a passwd lookup that would +// capability mode, then use it AFTER - a passwd lookup that would // otherwise be impossible in capability mode (no /etc/passwd access) // succeeds because Casper performs it on the other side of the boundary. // diff --git a/tests/freebsd/nightly.sh b/tests/freebsd/nightly.sh index 1c3e1c7d6..1cc002cb3 100755 --- a/tests/freebsd/nightly.sh +++ b/tests/freebsd/nightly.sh @@ -1,15 +1,14 @@ #!/bin/sh # tests/freebsd/nightly.sh — self-run nightly FreeBSD sandbox validation. # -# There is no FreeBSD runner in the GitHub CI matrix, so the Capsicum / -# Casper / audit enforcement paths can't be gated automatically. This -# script is the stand-in: run it on a cron on the GhostBSD build box -# (paul@192.168.0.57), it builds the target branch from a clean tree and -# runs the FreeBSD enforcement tests + the portable contract test. On -# failure it opens (or updates) a GitHub issue so the result feeds back. +# The native FreeBSD CI job gates the Capsicum / Casper enforcement paths. +# This script provides an optional independent scheduled run on the GhostBSD +# build box: it builds the target branch from a clean tree and runs the +# FreeBSD enforcement tests plus the portable contract test. On failure it +# opens (or updates) a GitHub issue so the result feeds back. # # Cron example (GhostBSD), 03:17 nightly: -# 17 3 * * * cd $HOME/aether && BRANCH=feat/freebsd-sandbox-parity-v2 \ +# 17 3 * * * cd $HOME/aether && BRANCH=main \ # sh tests/freebsd/nightly.sh >> $HOME/aether-nightly.log 2>&1 # # Requirements on the host: git, gmake, a C toolchain, and `gh` (GitHub @@ -17,12 +16,12 @@ # runs the tests and just prints the report (no issue filed). # # Env: -# BRANCH branch to test (default: feat/freebsd-sandbox-parity-v2) +# BRANCH branch to test (default: main) # REPO owner/name for issues (default: aether-lang-dev/aether) # NO_ISSUE=1 run tests, never file an issue (dry run) set -u -BRANCH="${BRANCH:-feat/freebsd-sandbox-parity-v2}" +BRANCH="${BRANCH:-main}" REPO="${REPO:-aether-lang-dev/aether}" ROOT=$(cd "$(dirname "$0")/../.." && pwd) cd "$ROOT" || exit 3 @@ -47,7 +46,7 @@ fail_report() { return fi title="$MARKER $BRANCH failed: $stage" - body=$(printf 'Nightly FreeBSD sandbox validation failed.\n\n- host: %s\n- branch: `%s`\n- commit: `%s`\n- stage: **%s**\n- when: %s\n\n```\n%s\n```\n\n_Filed automatically by tests/freebsd/nightly.sh (no FreeBSD CI runner yet)._' \ + body=$(printf 'Nightly FreeBSD sandbox validation failed.\n\n- host: %s\n- branch: `%s`\n- commit: `%s`\n- stage: **%s**\n- when: %s\n\n```\n%s\n```\n\n_Filed automatically by the optional tests/freebsd/nightly.sh run._' \ "$(uname -srm)" "$BRANCH" "$(git rev-parse --short HEAD 2>/dev/null)" "$stage" "$TS" "$(tail -120 "$LOG")") # Dedup: if an open issue with the same title exists, comment instead. existing=$(gh issue list --repo "$REPO" --state open --search "$title in:title" --json number --jq '.[0].number' 2>/dev/null) diff --git a/tests/freebsd/rights_limit_stdlib_fd.ae b/tests/freebsd/rights_limit_stdlib_fd.ae index 4c5feb081..7e8469acc 100644 --- a/tests/freebsd/rights_limit_stdlib_fd.ae +++ b/tests/freebsd/rights_limit_stdlib_fd.ae @@ -1,6 +1,6 @@ // FreeBSD-only: capsicum.rights_limit() on a descriptor obtained from a -// std.file handle via file.fd() (issue #1003). Run by tests/freebsd/run.sh; -// NOT in the CI regression set (no FreeBSD runner). +// std.file handle via file.fd() (issue #1003). Run by tests/freebsd/run.sh +// in the native FreeBSD CI job. // // Proves the fd accessor returns the real kernel descriptor: narrowing it // succeeds; after capsicum.enter() the narrowed, already-open handle still diff --git a/tests/freebsd/run.sh b/tests/freebsd/run.sh index 4d502e4a7..1709c6376 100755 --- a/tests/freebsd/run.sh +++ b/tests/freebsd/run.sh @@ -2,9 +2,8 @@ # tests/freebsd/run.sh — run the FreeBSD-only sandbox enforcement tests. # # These exercise REAL Capsicum/Casper behaviour and can only pass on a -# FreeBSD host with a Capsicum-enabled kernel; there is no FreeBSD runner -# in the CI matrix yet, so they live outside tests/regression/ and are -# driven by this script (locally / by the nightly harness). +# FreeBSD host with a Capsicum-enabled kernel. The native FreeBSD CI job +# and the optional nightly harness both drive this script. # # Each test .ae is a self-contained program: exit 0 = pass, 1 = fail, # 2 = skip (not FreeBSD). On a non-FreeBSD host every test skips and the From 9ff1749f74142431f4fac7c505e83802b760c4f0 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 5/9] fix: isolate sandbox shared-memory grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- ...261029T075739Z-fixed-sandbox-unique-shm.md | 4 + runtime/sandbox/aether_sandbox_shm.h | 77 +++++++++ runtime/sandbox/spawn_sandboxed_bsd.c | 28 +-- runtime/sandbox/spawn_sandboxed_linux.c | 28 +-- .../integration/sandbox_unique_shm/harness.c | 160 ++++++++++++++++++ .../test_sandbox_unique_shm.sh | 53 ++++++ 6 files changed, 306 insertions(+), 44 deletions(-) create mode 100644 new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md create mode 100644 runtime/sandbox/aether_sandbox_shm.h create mode 100644 tests/integration/sandbox_unique_shm/harness.c create mode 100644 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..ba2654506 --- /dev/null +++ b/new_changelogs/20261029T075739Z-fixed-sandbox-unique-shm.md @@ -0,0 +1,4 @@ +- **Concurrent sandboxed child launches no longer share one grant transport.** + The Linux and FreeBSD `spawn_sandboxed` backends now allocate each call an + exclusively created shared-memory name, so overlapping launches cannot + overwrite or unlink each other's serialized grants. diff --git a/runtime/sandbox/aether_sandbox_shm.h b/runtime/sandbox/aether_sandbox_shm.h new file mode 100644 index 000000000..9e2615891 --- /dev/null +++ b/runtime/sandbox/aether_sandbox_shm.h @@ -0,0 +1,77 @@ +#ifndef AETHER_SANDBOX_SHM_H +#define AETHER_SANDBOX_SHM_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define AETHER_SANDBOX_SHM_NAME_SIZE 64 +#define AETHER_SANDBOX_SHM_CREATE_ATTEMPTS 128 + +static atomic_uint aether_sandbox_shm_sequence = ATOMIC_VAR_INIT(0); + +static int aether_sandbox_shm_create(char* name, size_t name_size) { + for (int attempt = 0; attempt < AETHER_SANDBOX_SHM_CREATE_ATTEMPTS; attempt++) { + unsigned sequence = atomic_fetch_add_explicit( + &aether_sandbox_shm_sequence, 1, memory_order_relaxed); + int written = snprintf(name, name_size, "/aether_sandbox_%ld_%u", + (long)getpid(), sequence); + if (written < 0 || (size_t)written >= name_size) { + errno = ENAMETOOLONG; + return -1; + } + + int fd = shm_open(name, O_CREAT | O_EXCL | O_RDWR, 0600); + if (fd >= 0) return fd; + if (errno != EEXIST) return -1; + } + errno = EEXIST; + return -1; +} + +static int aether_sandbox_shm_write(char* name, size_t name_size, + const void* data, size_t data_size) { + int fd = aether_sandbox_shm_create(name, name_size); + if (fd < 0) return -1; + + if (ftruncate(fd, (off_t)data_size) != 0) { + int saved = errno; + close(fd); + shm_unlink(name); + errno = saved; + return -1; + } + + void* mem = mmap(NULL, data_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (mem == MAP_FAILED) { + int saved = errno; + close(fd); + shm_unlink(name); + errno = saved; + return -1; + } + + memcpy(mem, data, data_size); + if (munmap(mem, data_size) != 0) { + int saved = errno; + close(fd); + shm_unlink(name); + errno = saved; + return -1; + } + if (close(fd) != 0) { + int saved = errno; + shm_unlink(name); + errno = saved; + return -1; + } + return 0; +} + +#endif diff --git a/runtime/sandbox/spawn_sandboxed_bsd.c b/runtime/sandbox/spawn_sandboxed_bsd.c index 797e6dbe7..925767c68 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_shm.h" // The close-on-exec pipe, the fork, and what a child execs with. #include "../utils/aether_spawn.h" @@ -84,9 +85,7 @@ static int sandbox_report(int errfd) { // Serialize grants from a list to shared memory // Format: "category:pattern\n" lines, null-terminated -static char shm_name[64]; - -static int serialize_grants(void* grant_list) { +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; @@ -100,24 +99,8 @@ static int serialize_grants(void* grant_list) { pos += snprintf(buf + pos, sizeof(buf) - pos, "%s:%s\n", cat, pat); } - // Write to shared memory - snprintf(shm_name, sizeof(shm_name), "/aether_sandbox_%d", getpid()); - int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0600); - if (fd < 0) return -1; - - if (ftruncate(fd, pos + 1) != 0) { - close(fd); - shm_unlink(shm_name); - return -1; - } - void* mem = mmap(NULL, pos + 1, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); - if (mem == MAP_FAILED) { close(fd); return -1; } - - memcpy(mem, buf, pos + 1); - munmap(mem, pos + 1); - close(fd); - - return 0; + return aether_sandbox_shm_write( + shm_name, shm_name_size, buf, (size_t)pos + 1); } // Spawn a sandboxed child process @@ -132,7 +115,8 @@ int aether_spawn_sandboxed(void* grant_list, const char* program, const char* ar } // Serialize grants to shared memory - if (serialize_grants(grant_list) < 0) { + 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"); return -1; } diff --git a/runtime/sandbox/spawn_sandboxed_linux.c b/runtime/sandbox/spawn_sandboxed_linux.c index fd07b2c00..7e525de80 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_shm.h" // The close-on-exec pipe, the fork, and what a child execs with. #include "../utils/aether_spawn.h" @@ -89,9 +90,7 @@ static int sandbox_report(int errfd) { // Serialize grants from a list to shared memory // Format: "category:pattern\n" lines, null-terminated -static char shm_name[64]; - -static int serialize_grants(void* grant_list) { +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; @@ -105,24 +104,8 @@ static int serialize_grants(void* grant_list) { pos += snprintf(buf + pos, sizeof(buf) - pos, "%s:%s\n", cat, pat); } - // Write to shared memory - snprintf(shm_name, sizeof(shm_name), "/aether_sandbox_%d", getpid()); - int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0600); - if (fd < 0) return -1; - - if (ftruncate(fd, pos + 1) != 0) { - close(fd); - shm_unlink(shm_name); - return -1; - } - void* mem = mmap(NULL, pos + 1, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); - if (mem == MAP_FAILED) { close(fd); return -1; } - - memcpy(mem, buf, pos + 1); - munmap(mem, pos + 1); - close(fd); - - return 0; + return aether_sandbox_shm_write( + shm_name, shm_name_size, buf, (size_t)pos + 1); } // Is the `fork` category granted in the grant list? The LD_PRELOAD layer @@ -338,7 +321,8 @@ int aether_spawn_sandboxed(void* grant_list, const char* program, const char* ar } // Serialize grants to shared memory - if (serialize_grants(grant_list) < 0) { + 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"); return -1; } diff --git a/tests/integration/sandbox_unique_shm/harness.c b/tests/integration/sandbox_unique_shm/harness.c new file mode 100644 index 000000000..daea80292 --- /dev/null +++ b/tests/integration/sandbox_unique_shm/harness.c @@ -0,0 +1,160 @@ +#define _XOPEN_SOURCE 700 + +#include +#include +#include +#include +#include +#include +#include +#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); + +static pthread_barrier_t fork_barrier; +static char collision_name[128]; + +static void remove_collision(void) { + if (collision_name[0]) shm_unlink(collision_name); +} + +pid_t __wrap_aether_fork(void) { + int rc = pthread_barrier_wait(&fork_barrier); + if (rc != 0 && rc != PTHREAD_BARRIER_SERIAL_THREAD) { + errno = rc; + return -1; + } + return fork(); +} + +struct sandbox_call { + void* grants; + const char* script; + const char* output; + int rc; +}; + +static void* run_sandbox(void* raw) { + struct sandbox_call* call = (struct sandbox_call*)raw; + call->rc = aether_spawn_sandboxed(call->grants, call->script, call->output); + return NULL; +} + +static int write_script(const char* path) { + static const char text[] = + "#!/bin/sh\n" + "printf '%s\\n' \"$AETHER_SANDBOX_SHM\" > \"$1\"\n"; + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0755); + if (fd < 0) return -1; + ssize_t written = write(fd, text, sizeof text - 1); + int saved = errno; + close(fd); + errno = saved; + if (written != (ssize_t)(sizeof text - 1)) return -1; + return chmod(path, 0755); +} + +static int read_name(const char* path, char* name, size_t size) { + FILE* f = fopen(path, "r"); + if (!f) return -1; + if (!fgets(name, (int)size, f)) { + fclose(f); + return -1; + } + fclose(f); + name[strcspn(name, "\r\n")] = '\0'; + return name[0] ? 0 : -1; +} + +static int expect_removed(const char* name) { + int fd = shm_open(name, O_RDONLY, 0); + if (fd < 0 && errno == ENOENT) return 0; + if (fd >= 0) close(fd); + fprintf(stderr, "FAIL shared memory remains after spawn: %s\n", name); + return 1; +} + +int main(int argc, char** argv) { + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + char script[4096]; + char output_a[4096]; + char output_b[4096]; + snprintf(script, sizeof script, "%s/record-shm.sh", argv[1]); + snprintf(output_a, sizeof output_a, "%s/a.txt", argv[1]); + snprintf(output_b, sizeof output_b, "%s/b.txt", argv[1]); + if (write_script(script) != 0) { + perror("write script"); + return 1; + } + + void* grants = list_new(); + if (!grants || !list_add_raw(grants, (void*)"*") || + !list_add_raw(grants, (void*)"*")) { + fprintf(stderr, "FAIL cannot create grant list\n"); + return 1; + } + if (pthread_barrier_init(&fork_barrier, NULL, 2) != 0) { + fprintf(stderr, "FAIL cannot create fork barrier\n"); + return 1; + } + + snprintf(collision_name, sizeof collision_name, + "/aether_sandbox_%ld_0", (long)getpid()); + int collision_fd = shm_open(collision_name, O_CREAT | O_EXCL | O_RDWR, 0600); + if (collision_fd < 0) { + perror("create collision object"); + return 1; + } + close(collision_fd); + atexit(remove_collision); + + struct sandbox_call a = { grants, script, output_a, -1 }; + struct sandbox_call b = { grants, script, output_b, -1 }; + pthread_t thread_a; + pthread_t thread_b; + if (pthread_create(&thread_a, NULL, run_sandbox, &a) != 0 || + pthread_create(&thread_b, NULL, run_sandbox, &b) != 0) { + fprintf(stderr, "FAIL cannot create caller threads\n"); + return 1; + } + pthread_join(thread_a, NULL); + pthread_join(thread_b, NULL); + pthread_barrier_destroy(&fork_barrier); + + if (a.rc != 0 || b.rc != 0) { + fprintf(stderr, "FAIL concurrent spawns returned %d and %d\n", a.rc, b.rc); + return 1; + } + + char name_a[128]; + char name_b[128]; + if (read_name(output_a, name_a, sizeof name_a) != 0 || + read_name(output_b, name_b, sizeof name_b) != 0) { + fprintf(stderr, "FAIL children did not record shared-memory names\n"); + return 1; + } + if (strcmp(name_a, name_b) == 0) { + fprintf(stderr, "FAIL concurrent spawns shared one name: %s\n", name_a); + return 1; + } + if (expect_removed(name_a) || expect_removed(name_b)) return 1; + + collision_fd = shm_open(collision_name, O_RDONLY, 0); + if (collision_fd < 0) { + fprintf(stderr, "FAIL spawn did not preserve an existing name: %s\n", + collision_name); + return 1; + } + close(collision_fd); + + printf("PASS: concurrent spawn_sandboxed calls use separate shared memory\n"); + return 0; +} diff --git a/tests/integration/sandbox_unique_shm/test_sandbox_unique_shm.sh b/tests/integration/sandbox_unique_shm/test_sandbox_unique_shm.sh new file mode 100644 index 000000000..76e5528be --- /dev/null +++ b/tests/integration/sandbox_unique_shm/test_sandbox_unique_shm.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Two concurrent spawn_sandboxed calls must not publish the same grant +# transport. The harness holds both callers at aether_fork until both shared +# memory objects have been created, then compares the names seen by the children. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_unique_shm: spawn_sandboxed is Linux and FreeBSD only" + exit 0 ;; +esac +if [ ! -f "$ROOT/build/libaether.a" ]; then + echo " [SKIP] sandbox_unique_shm: build/libaether.a not built" + exit 0 +fi +if [ ! -f "$ROOT/build/libaether_sandbox.so" ]; then + echo " [SKIP] sandbox_unique_shm: build/libaether_sandbox.so not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT + +if ! ${CC:-cc} -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" -Wl,--wrap=aether_fork \ + -lpthread -lm 2>"$TMP/cc.err"; then + if ! ${CC:-cc} -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" -Wl,--wrap=aether_fork \ + -lpthread -ldl -lm -lrt -lz -lssl -lcrypto 2>"$TMP/cc.err"; then + echo " [FAIL] could not link the concurrent spawn harness" + sed 's/^/ /' "$TMP/cc.err" | head -8 + exit 1 + fi +fi + +mkdir -p "$TMP/work" +out="$(AETHER_SANDBOX_PRELOAD="$ROOT/build/libaether_sandbox.so" \ + env -u AETHER_SANDBOX_VERBOSE "$TMP/harness" "$TMP/work" 2>&1 Date: Thu, 29 Oct 2026 11:26:15 +0300 Subject: [PATCH 6/9] runtime: fail closed on unreadable sandbox grant sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- ...20Z-security-sandbox-source-fail-closed.md | 4 + runtime/libaether_sandbox_preload.c | 63 +++++++++----- .../test_sandbox_source_fail_closed.sh | 83 +++++++++++++++++++ 3 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 new_changelogs/20261029T082520Z-security-sandbox-source-fail-closed.md create mode 100644 tests/integration/sandbox_source_fail_closed/test_sandbox_source_fail_closed.sh diff --git a/new_changelogs/20261029T082520Z-security-sandbox-source-fail-closed.md b/new_changelogs/20261029T082520Z-security-sandbox-source-fail-closed.md new file mode 100644 index 000000000..6d18550ca --- /dev/null +++ b/new_changelogs/20261029T082520Z-security-sandbox-source-fail-closed.md @@ -0,0 +1,4 @@ +- **Configured sandbox grant sources now fail closed when unreadable.** The + preload layer keeps interception active with zero grants when an explicit + shared-memory or file source is missing, unreadable, or oversized instead + of silently allowing every operation. diff --git a/runtime/libaether_sandbox_preload.c b/runtime/libaether_sandbox_preload.c index bbb4aaede..bfabe7796 100644 --- a/runtime/libaether_sandbox_preload.c +++ b/runtime/libaether_sandbox_preload.c @@ -27,6 +27,7 @@ // Grant storage #define MAX_GRANTS 256 +#define MAX_GRANT_SOURCE_SIZE 8192 static struct { char cat[64]; char pat[256]; } grants[MAX_GRANTS]; static int grant_count = 0; static int sandbox_active = 0; @@ -121,7 +122,8 @@ static void parse_grants(const char* data, int len) { char line[320]; int lpos = 0; for (int i = 0; i <= len && grant_count < MAX_GRANTS; i++) { - if (i == len || data[i] == '\n' || data[i] == '\0') { + int at_end = i == len; + if (at_end || data[i] == '\n' || data[i] == '\0') { line[lpos] = '\0'; if (lpos > 0 && line[0] != '#') { char* colon = strchr(line, ':'); @@ -135,7 +137,7 @@ static void parse_grants(const char* data, int len) { } } lpos = 0; - if (data[i] == '\0') break; + if (at_end || data[i] == '\0') break; } else if (lpos < 319) { line[lpos++] = data[i]; } @@ -164,37 +166,58 @@ static void __attribute__((constructor)) sandbox_init(void) { // Legacy: AETHER_SANDBOX_VERBOSE=1 sets stderr mode if (real_getenv("AETHER_SANDBOX_VERBOSE")) log_mode = LOG_STDERR; - // Try shared memory first (set by aether_spawn_sandboxed) + // An explicitly configured source is a containment request. If every + // configured source fails to load, leave the sandbox active with zero + // grants so intercepted operations are denied rather than running + // unrestricted. With no source configured at all, preload stays inert. + int source_requested = 0; + int source_loaded = 0; + + // Try shared memory first (set by aether_spawn_sandboxed). const char* shm_name = real_getenv("AETHER_SANDBOX_SHM"); if (shm_name) { + source_requested = 1; int fd = shm_open(shm_name, O_RDONLY, 0); if (fd >= 0) { struct stat st; - fstat(fd, &st); - char* data = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED, fd, 0); - if (data != MAP_FAILED) { - parse_grants(data, st.st_size); - munmap(data, st.st_size); - sandbox_active = 1; + if (fstat(fd, &st) == 0 && st.st_size >= 0 && + st.st_size <= MAX_GRANT_SOURCE_SIZE) { + if (st.st_size == 0) { + source_loaded = 1; + } else { + char* data = mmap(NULL, (size_t)st.st_size, PROT_READ, + MAP_SHARED, fd, 0); + if (data != MAP_FAILED) { + parse_grants(data, (int)st.st_size); + munmap(data, (size_t)st.st_size); + source_loaded = 1; + } + } } close(fd); } } // Fall back to grant file (manual LD_PRELOAD usage) - if (!sandbox_active) { + if (!source_loaded) { const char* grant_file = real_getenv("AETHER_SANDBOX_GRANTS"); - if (!grant_file) return; - FILE* f = real_fopen(grant_file, "r"); - if (!f) return; - // Read entire file - char buf[8192]; - int n = fread(buf, 1, sizeof(buf) - 1, f); - buf[n] = '\0'; - fclose(f); - parse_grants(buf, n); - sandbox_active = 1; + if (grant_file) { + source_requested = 1; + FILE* f = real_fopen(grant_file, "r"); + if (f) { + char buf[MAX_GRANT_SOURCE_SIZE]; + size_t n = fread(buf, 1, sizeof(buf), f); + int extra = fgetc(f); + int read_failed = ferror(f) || extra != EOF; + fclose(f); + if (!read_failed) { + parse_grants(buf, (int)n); + source_loaded = 1; + } + } + } } + sandbox_active = source_requested; if (log_mode == LOG_STDERR && sandbox_active) { fprintf(stderr, "AETHER_SANDBOX: active with %d grants\n", grant_count); diff --git a/tests/integration/sandbox_source_fail_closed/test_sandbox_source_fail_closed.sh b/tests/integration/sandbox_source_fail_closed/test_sandbox_source_fail_closed.sh new file mode 100644 index 000000000..b8ea4c97f --- /dev/null +++ b/tests/integration/sandbox_source_fail_closed/test_sandbox_source_fail_closed.sh @@ -0,0 +1,83 @@ +#!/bin/sh +# An explicitly configured grant source is a containment request. If that +# source cannot be read, the preload layer must deny rather than silently run +# without a sandbox. With no source configured at all, preload remains inert. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +LIB="$ROOT/build/libaether_sandbox.so" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_source_fail_closed: preload is Linux and FreeBSD only" + exit 0 ;; +esac +if [ ! -f "$LIB" ]; then + echo " [SKIP] sandbox_source_fail_closed: preload library not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT +printf 'source sentinel\n' > "$TMP/data.txt" +printf '*:*\n' > "$TMP/allow-all.grants" +printf '*:*\n' > "$TMP/oversized.grants" +dd if=/dev/zero bs=8193 count=1 2>/dev/null | tr '\000' '#' >> "$TMP/oversized.grants" +missing_shm="/aether_sandbox_missing_$$" +missing_file="$TMP/missing.grants" +fail=0 + +expect_denied() { + label="$1" + shift + if "$@" >"$TMP/out" 2>"$TMP/err"; then + echo " [FAIL] $label: cat exited zero" + fail=1 + elif grep -qF 'source sentinel' "$TMP/out"; then + echo " [FAIL] $label: protected data was read" + fail=1 + else + echo " [PASS] $label" + fi +} + +expect_allowed() { + label="$1" + shift + if "$@" >"$TMP/out" 2>"$TMP/err" && grep -qF 'source sentinel' "$TMP/out"; then + echo " [PASS] $label" + else + echo " [FAIL] $label: expected cat to read the file" + sed 's/^/ /' "$TMP/err" | head -8 + fail=1 + fi +} + +expect_denied "missing shared-memory source fails closed" \ + env -u AETHER_SANDBOX_GRANTS LD_PRELOAD="$LIB" \ + AETHER_SANDBOX_SHM="$missing_shm" AETHER_SANDBOX_LOG=none \ + /bin/cat "$TMP/data.txt" + +expect_denied "missing grant-file source fails closed" \ + env -u AETHER_SANDBOX_SHM LD_PRELOAD="$LIB" \ + AETHER_SANDBOX_GRANTS="$missing_file" AETHER_SANDBOX_LOG=none \ + /bin/cat "$TMP/data.txt" + +expect_denied "oversized grant-file source fails closed before parsing" \ + env -u AETHER_SANDBOX_SHM LD_PRELOAD="$LIB" \ + AETHER_SANDBOX_GRANTS="$TMP/oversized.grants" AETHER_SANDBOX_LOG=none \ + /bin/cat "$TMP/data.txt" + +expect_allowed "valid grant file remains a fallback for missing shared memory" \ + env LD_PRELOAD="$LIB" AETHER_SANDBOX_SHM="$missing_shm" \ + AETHER_SANDBOX_GRANTS="$TMP/allow-all.grants" AETHER_SANDBOX_LOG=none \ + /bin/cat "$TMP/data.txt" + +expect_allowed "preload without a configured source remains inert" \ + env -u AETHER_SANDBOX_SHM -u AETHER_SANDBOX_GRANTS LD_PRELOAD="$LIB" \ + AETHER_SANDBOX_LOG=none /bin/cat "$TMP/data.txt" + +echo "" +echo "sandbox_source_fail_closed: $([ "$fail" -eq 0 ] && echo PASS || echo FAIL)" +[ "$fail" -eq 0 ] From 0f58cf8e9ab9c917396b9f91f8d62e6041a5c3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 11:31:19 +0300 Subject: [PATCH 7/9] runtime: preserve sandbox controls across explicit exec envs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- ...61029T083032Z-security-sandbox-exec-env.md | 4 + runtime/libaether_sandbox_preload.c | 96 ++++++++++++++++++- .../sandbox_exec_env_inherit/launcher.c | 29 ++++++ .../test_sandbox_exec_env_inherit.sh | 78 +++++++++++++++ 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 new_changelogs/20261029T083032Z-security-sandbox-exec-env.md create mode 100644 tests/integration/sandbox_exec_env_inherit/launcher.c create mode 100644 tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh diff --git a/new_changelogs/20261029T083032Z-security-sandbox-exec-env.md b/new_changelogs/20261029T083032Z-security-sandbox-exec-env.md new file mode 100644 index 000000000..12dd10721 --- /dev/null +++ b/new_changelogs/20261029T083032Z-security-sandbox-exec-env.md @@ -0,0 +1,4 @@ +- **Sandbox controls now survive `execve` with an explicit environment.** The + preload layer rebuilds child environments with its current `LD_PRELOAD` and + grant-source entries, preventing an explicit `envp` from dropping or + replacing cross-process containment. diff --git a/runtime/libaether_sandbox_preload.c b/runtime/libaether_sandbox_preload.c index bfabe7796..0fc4e3281 100644 --- a/runtime/libaether_sandbox_preload.c +++ b/runtime/libaether_sandbox_preload.c @@ -415,6 +415,51 @@ FILE* fopen64(const char* path, const char* mode) { #endif // __linux__ (open64 / openat64 / fopen64) +#define MAX_EXEC_ENV_ENTRIES 4096 +extern char** environ; + +static int env_key_is(const char* entry, const char* key) { + size_t n; + if (!entry || !key) return 0; + n = strlen(key); + return strncmp(entry, key, n) == 0 && entry[n] == '='; +} + +static int same_env_key(const char* a, const char* b) { + const char* ae; + const char* be; + if (!a || !b) return 0; + ae = strchr(a, '='); + be = strchr(b, '='); + if (!ae || !be) return 0; + return (ae - a) == (be - b) && strncmp(a, b, (size_t)(ae - a)) == 0; +} + +static int is_sandbox_control_env(const char* entry) { + static const char prefix[] = "AETHER_SANDBOX_"; + if (!entry) return 0; + return env_key_is(entry, "LD_PRELOAD") || + env_key_is(entry, "AETHER_CAPSICUM") || + strncmp(entry, prefix, sizeof(prefix) - 1) == 0; +} + +static int env_has_same_key(char* const env[], size_t count, const char* entry) { + for (size_t i = 0; i < count; i++) { + if (same_env_key(env[i], entry)) return 1; + } + return 0; +} + +static int env_has_nonempty(char* const env[], size_t count, const char* key) { + for (size_t i = 0; i < count; i++) { + if (env_key_is(env[i], key)) { + const char* value = strchr(env[i], '='); + return value && value[1] != '\0'; + } + } + return 0; +} + // execve int execve(const char* pathname, char* const argv[], char* const envp[]) { if (!real_execve) real_execve = dlsym(RTLD_NEXT, "execve"); @@ -422,7 +467,56 @@ int execve(const char* pathname, char* const argv[], char* const envp[]) { errno = EPERM; return -1; } - return real_execve(pathname, argv, envp); + if (!sandbox_active) return real_execve(pathname, argv, envp); + + // An explicit envp is the child's whole environment. Rebuild it on the + // stack so application entries survive while sandbox controls come only + // from this contained process. This avoids allocation in the common + // fork-then-exec path and prevents an explicit environment from dropping + // or replacing the preload and grant source. + char* merged_env[MAX_EXEC_ENV_ENTRIES + 1]; + size_t count = 0; + size_t scanned = 0; + while (envp && envp[scanned]) { + if (scanned >= MAX_EXEC_ENV_ENTRIES) { + errno = E2BIG; + return -1; + } + if (!is_sandbox_control_env(envp[scanned])) { + if (count >= MAX_EXEC_ENV_ENTRIES) { + errno = E2BIG; + return -1; + } + merged_env[count++] = envp[scanned]; + } + scanned++; + } + + scanned = 0; + while (environ && environ[scanned]) { + if (scanned >= MAX_EXEC_ENV_ENTRIES) { + errno = E2BIG; + return -1; + } + if (is_sandbox_control_env(environ[scanned]) && + !env_has_same_key(merged_env, count, environ[scanned])) { + if (count >= MAX_EXEC_ENV_ENTRIES) { + errno = E2BIG; + return -1; + } + merged_env[count++] = environ[scanned]; + } + scanned++; + } + merged_env[count] = NULL; + + if (!env_has_nonempty(merged_env, count, "LD_PRELOAD") || + (!env_has_nonempty(merged_env, count, "AETHER_SANDBOX_SHM") && + !env_has_nonempty(merged_env, count, "AETHER_SANDBOX_GRANTS"))) { + errno = EPERM; + return -1; + } + return real_execve(pathname, argv, merged_env); } // stat/access/fstatat — NOT intercepted. Modern glibc inlines these as diff --git a/tests/integration/sandbox_exec_env_inherit/launcher.c b/tests/integration/sandbox_exec_env_inherit/launcher.c new file mode 100644 index 000000000..c41ab810c --- /dev/null +++ b/tests/integration/sandbox_exec_env_inherit/launcher.c @@ -0,0 +1,29 @@ +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + char* cat_argv[] = { (char*)"/bin/cat", argv[1], NULL }; + char* env_argv[] = { (char*)"/usr/bin/env", NULL }; + char* omitted[] = { (char*)"ONLY=explicit", NULL }; + char* overridden[] = { + (char*)"ONLY=explicit", + (char*)"LD_PRELOAD=", + (char*)"AETHER_SANDBOX_GRANTS=/definitely/missing", + NULL + }; + char** child_env = strcmp(argv[2], "override") == 0 ? overridden : omitted; + const char* child_path = strcmp(argv[2], "env") == 0 + ? "/usr/bin/env" : "/bin/cat"; + char** child_argv = strcmp(argv[2], "env") == 0 ? env_argv : cat_argv; + + execve(child_path, child_argv, child_env); + fprintf(stderr, "execve: %s\n", strerror(errno)); + return 111; +} diff --git a/tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh b/tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh new file mode 100644 index 000000000..71e2fd08a --- /dev/null +++ b/tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# An allowed exec with an explicit environment must retain the preload layer +# and its grant source. Omitting or replacing control entries must not turn the +# child into an unrestricted process. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +LIB="$ROOT/build/libaether_sandbox.so" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_exec_env_inherit: preload is Linux and FreeBSD only" + exit 0 ;; +esac +if [ ! -f "$LIB" ]; then + echo " [SKIP] sandbox_exec_env_inherit: preload library not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT +if ! ${CC:-cc} -std=c11 -Wall -Wextra -Werror \ + "$SCRIPT_DIR/launcher.c" -o "$TMP/launcher"; then + echo " [FAIL] sandbox_exec_env_inherit: launcher did not compile" + exit 1 +fi + +printf 'exec sentinel\n' > "$TMP/data.txt" +printf 'exec:/bin/cat\n' > "$TMP/deny-read.grants" +printf 'exec:/bin/cat\nfs_read:%s\n' "$TMP/data.txt" > "$TMP/allow-read.grants" +printf 'exec:/usr/bin/env\n' > "$TMP/env.grants" +fail=0 + +expect_denied() { + mode="$1" + if LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$TMP/deny-read.grants" \ + AETHER_SANDBOX_LOG=none "$TMP/launcher" "$TMP/data.txt" "$mode" \ + >"$TMP/out" 2>"$TMP/err"; then + echo " [FAIL] explicit env ($mode) dropped containment: child exited zero" + fail=1 + elif grep -qF 'exec sentinel' "$TMP/out"; then + echo " [FAIL] explicit env ($mode) dropped containment: child read the file" + fail=1 + else + echo " [PASS] explicit env ($mode) retains containment" + fi +} + +expect_denied omit +expect_denied override + +if LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$TMP/allow-read.grants" \ + AETHER_SANDBOX_LOG=none "$TMP/launcher" "$TMP/data.txt" omit \ + >"$TMP/out" 2>"$TMP/err" && grep -qF 'exec sentinel' "$TMP/out"; then + echo " [PASS] inherited controls preserve an allowed exec" +else + echo " [FAIL] inherited controls broke an allowed exec" + sed 's/^/ /' "$TMP/err" | head -8 + fail=1 +fi + +if LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$TMP/env.grants" \ + AETHER_SANDBOX_LOG=none "$TMP/launcher" ignored env \ + >"$TMP/out" 2>"$TMP/err" && + grep -qxF 'ONLY=explicit' "$TMP/out" && + grep -qxF "LD_PRELOAD=$LIB" "$TMP/out" && + grep -qxF "AETHER_SANDBOX_GRANTS=$TMP/env.grants" "$TMP/out"; then + echo " [PASS] explicit entries and sandbox controls reach the child" +else + echo " [FAIL] merged child environment lost explicit or sandbox entries" + sed 's/^/ /' "$TMP/out" | head -12 + fail=1 +fi + +echo "" +echo "sandbox_exec_env_inherit: $([ "$fail" -eq 0 ] && echo PASS || echo FAIL)" +[ "$fail" -eq 0 ] From f92a672cbbcaa092fec83ef6f8fec4732e7e326a Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 29 Oct 2026 08:51:50 +0000 Subject: [PATCH 8/9] runtime: snapshot sandbox controls at init for explicit-env execve The execve wrapper rebuilt the child's sandbox controls from live environ, which the contained process can edit (setenv a grant file of its own, unsetenv the shm, then execve). Snapshot LD_PRELOAD, AETHER_SANDBOX_* and AETHER_CAPSICUM once in sandbox_init into static bounded buffers and merge from the snapshot; if they do not fit, execve fails closed with EPERM. Adds a tamper case to sandbox_exec_env_inherit. Co-Authored-By: Claude Opus 5.5 --- runtime/libaether_sandbox_preload.c | 58 +++++++++++++------ .../sandbox_exec_env_inherit/launcher.c | 12 +++- .../test_sandbox_exec_env_inherit.sh | 14 +++++ 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/runtime/libaether_sandbox_preload.c b/runtime/libaether_sandbox_preload.c index 0fc4e3281..9b3e6d6a5 100644 --- a/runtime/libaether_sandbox_preload.c +++ b/runtime/libaether_sandbox_preload.c @@ -144,6 +144,9 @@ static void parse_grants(const char* data, int len) { } } +extern char** environ; +static void snapshot_sandbox_env(void); + // Load grants from shared memory or file static void __attribute__((constructor)) sandbox_init(void) { // Resolve real libc functions FIRST, before we intercept getenv @@ -156,6 +159,9 @@ static void __attribute__((constructor)) sandbox_init(void) { real_mmap = dlsym(RTLD_NEXT, "mmap"); real_mprotect = dlsym(RTLD_NEXT, "mprotect"); + // Capture the sandbox controls before any user code can edit environ. + snapshot_sandbox_env(); + // Log mode: file (default), stderr, or none const char* log_env = real_getenv("AETHER_SANDBOX_LOG"); if (log_env) { @@ -443,11 +449,34 @@ static int is_sandbox_control_env(const char* entry) { strncmp(entry, prefix, sizeof(prefix) - 1) == 0; } -static int env_has_same_key(char* const env[], size_t count, const char* entry) { - for (size_t i = 0; i < count; i++) { - if (same_env_key(env[i], entry)) return 1; +// Snapshot of the sandbox controls taken once in sandbox_init, from the +// environment as it was before user code ran. execve rebuilds the child's +// controls from this, never from live environ, which the contained process can +// edit (setenv/unsetenv). A key absent from the snapshot was absent at init; +// an empty value ("KEY=") is recorded as present. Fixed buffers, no malloc, +// so execve stays async-signal-safe. +#define SNAP_MAX_ENTRIES 32 +#define SNAP_BUF_SIZE 16384 +static char snap_buf[SNAP_BUF_SIZE]; +static char* snap_entries[SNAP_MAX_ENTRIES]; +static size_t snap_count = 0; +static int snap_overflow = 0; + +static void snapshot_sandbox_env(void) { + size_t used = 0; + for (size_t i = 0; environ && environ[i]; i++) { + const char* e = environ[i]; + if (!is_sandbox_control_env(e)) continue; + size_t len = strlen(e); + if (snap_count >= SNAP_MAX_ENTRIES || len + 1 > SNAP_BUF_SIZE - used) { + snap_overflow = 1; // cannot preserve controls: execve fails closed + return; + } + memcpy(snap_buf + used, e, len); + snap_buf[used + len] = '\0'; + snap_entries[snap_count++] = snap_buf + used; + used += len + 1; } - return 0; } static int env_has_nonempty(char* const env[], size_t count, const char* key) { @@ -471,7 +500,7 @@ int execve(const char* pathname, char* const argv[], char* const envp[]) { // An explicit envp is the child's whole environment. Rebuild it on the // stack so application entries survive while sandbox controls come only - // from this contained process. This avoids allocation in the common + // from the init-time snapshot of this contained process. This avoids allocation in the common // fork-then-exec path and prevents an explicit environment from dropping // or replacing the preload and grant source. char* merged_env[MAX_EXEC_ENV_ENTRIES + 1]; @@ -492,21 +521,16 @@ int execve(const char* pathname, char* const argv[], char* const envp[]) { scanned++; } - scanned = 0; - while (environ && environ[scanned]) { - if (scanned >= MAX_EXEC_ENV_ENTRIES) { + if (snap_overflow) { + errno = EPERM; + return -1; + } + for (size_t i = 0; i < snap_count; i++) { + if (count >= MAX_EXEC_ENV_ENTRIES) { errno = E2BIG; return -1; } - if (is_sandbox_control_env(environ[scanned]) && - !env_has_same_key(merged_env, count, environ[scanned])) { - if (count >= MAX_EXEC_ENV_ENTRIES) { - errno = E2BIG; - return -1; - } - merged_env[count++] = environ[scanned]; - } - scanned++; + merged_env[count++] = snap_entries[i]; } merged_env[count] = NULL; diff --git a/tests/integration/sandbox_exec_env_inherit/launcher.c b/tests/integration/sandbox_exec_env_inherit/launcher.c index c41ab810c..20437c15e 100644 --- a/tests/integration/sandbox_exec_env_inherit/launcher.c +++ b/tests/integration/sandbox_exec_env_inherit/launcher.c @@ -1,11 +1,13 @@ +#define _DEFAULT_SOURCE 1 #include #include +#include #include #include int main(int argc, char** argv) { - if (argc != 3) { - fprintf(stderr, "usage: %s \n", argv[0]); + if (argc != 3 && argc != 4) { + fprintf(stderr, "usage: %s [grants]\n", argv[0]); return 2; } @@ -23,6 +25,12 @@ int main(int argc, char** argv) { ? "/usr/bin/env" : "/bin/cat"; char** child_argv = strcmp(argv[2], "env") == 0 ? env_argv : cat_argv; + if (strcmp(argv[2], "tamper") == 0 && argc == 4) { + // A contained process editing its own environment before exec. + setenv("AETHER_SANDBOX_GRANTS", argv[3], 1); + unsetenv("AETHER_SANDBOX_SHM"); + } + execve(child_path, child_argv, child_env); fprintf(stderr, "execve: %s\n", strerror(errno)); return 111; diff --git a/tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh b/tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh index 71e2fd08a..f1c2c392f 100644 --- a/tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh +++ b/tests/integration/sandbox_exec_env_inherit/test_sandbox_exec_env_inherit.sh @@ -50,6 +50,20 @@ expect_denied() { expect_denied omit expect_denied override +# The contained launcher points GRANTS at a permissive file and unsets SHM +# before exec; the child must still run under the ORIGINAL policy. +if LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$TMP/deny-read.grants" \ + AETHER_SANDBOX_LOG=none "$TMP/launcher" "$TMP/data.txt" tamper \ + "$TMP/allow-read.grants" >"$TMP/out" 2>"$TMP/err"; then + echo " [FAIL] env tampering before execve widened the child policy: exited zero" + fail=1 +elif grep -qF 'exec sentinel' "$TMP/out"; then + echo " [FAIL] env tampering before execve widened the child policy: child read the file" + fail=1 +else + echo " [PASS] env tampering before execve does not change the child policy" +fi + if LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$TMP/allow-read.grants" \ AETHER_SANDBOX_LOG=none "$TMP/launcher" "$TMP/data.txt" omit \ >"$TMP/out" 2>"$TMP/err" && grep -qF 'exec sentinel' "$TMP/out"; then From e18798f7920db26883f72a31f465f3cce7160d88 Mon Sep 17 00:00:00 2001 From: paul Date: Thu, 29 Oct 2026 08:51:50 +0000 Subject: [PATCH 9/9] runtime: bound sandbox grant serialisation; drop ATOMIC_VAR_INIT pos += snprintf(...) could pass buf[8192] (snprintf returns the would-be length), so the following (pos+1)-byte write read past the stack buffer. Fail the spawn (-1) when an entry does not fit, on Linux and BSD. Adds sandbox_grant_overflow. ATOMIC_VAR_INIT is deprecated since C17. docs: note #3436 and #3440 behaviour in containment-sandbox.md. Co-Authored-By: Claude Opus 5.5 --- docs/containment-sandbox.md | 13 +++++ runtime/sandbox/aether_sandbox_shm.h | 2 +- runtime/sandbox/spawn_sandboxed_bsd.c | 6 ++- runtime/sandbox/spawn_sandboxed_linux.c | 6 ++- .../sandbox_grant_overflow/harness.c | 33 +++++++++++++ .../test_sandbox_grant_overflow.sh | 49 +++++++++++++++++++ 6 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/integration/sandbox_grant_overflow/harness.c create mode 100755 tests/integration/sandbox_grant_overflow/test_sandbox_grant_overflow.sh diff --git a/docs/containment-sandbox.md b/docs/containment-sandbox.md index 7e6fb0694..dafbe38c0 100644 --- a/docs/containment-sandbox.md +++ b/docs/containment-sandbox.md @@ -433,6 +433,19 @@ should have both. An IPv4-mapped peer (`::ffff:127.0.0.1`, how a dual-stack socket reaches an IPv4 host) is the IPv4 host, and matches its grant. +- **Unreadable grant sources deny everything.** If the preload layer is + configured with a grant source (`AETHER_SANDBOX_SHM` or + `AETHER_SANDBOX_GRANTS`) that cannot be read, or is larger than the + size limit, the sandbox stays active with zero grants rather than + running unrestricted. + +- **`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 + contained and edits to the live environment do not change them. Only + `execve` is interposed; `execvp` and `posix_spawn` paths that reach the + kernel through glibc internals are not (a known gap). + - **Application-level only.** See "Cross-process containment" below for extending enforcement to child processes. diff --git a/runtime/sandbox/aether_sandbox_shm.h b/runtime/sandbox/aether_sandbox_shm.h index 9e2615891..6fc79b60a 100644 --- a/runtime/sandbox/aether_sandbox_shm.h +++ b/runtime/sandbox/aether_sandbox_shm.h @@ -14,7 +14,7 @@ #define AETHER_SANDBOX_SHM_NAME_SIZE 64 #define AETHER_SANDBOX_SHM_CREATE_ATTEMPTS 128 -static atomic_uint aether_sandbox_shm_sequence = ATOMIC_VAR_INIT(0); +static atomic_uint aether_sandbox_shm_sequence = 0; static int aether_sandbox_shm_create(char* name, size_t name_size) { for (int attempt = 0; attempt < AETHER_SANDBOX_SHM_CREATE_ATTEMPTS; attempt++) { diff --git a/runtime/sandbox/spawn_sandboxed_bsd.c b/runtime/sandbox/spawn_sandboxed_bsd.c index 925767c68..70c1c9f5d 100644 --- a/runtime/sandbox/spawn_sandboxed_bsd.c +++ b/runtime/sandbox/spawn_sandboxed_bsd.c @@ -96,7 +96,11 @@ static int serialize_grants(void* grant_list, char* shm_name, size_t shm_name_si 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; - pos += snprintf(buf + pos, sizeof(buf) - pos, "%s:%s\n", cat, pat); + 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; } return aether_sandbox_shm_write( diff --git a/runtime/sandbox/spawn_sandboxed_linux.c b/runtime/sandbox/spawn_sandboxed_linux.c index 7e525de80..9f6607fba 100644 --- a/runtime/sandbox/spawn_sandboxed_linux.c +++ b/runtime/sandbox/spawn_sandboxed_linux.c @@ -101,7 +101,11 @@ static int serialize_grants(void* grant_list, char* shm_name, size_t shm_name_si 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; - pos += snprintf(buf + pos, sizeof(buf) - pos, "%s:%s\n", cat, pat); + 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; } return aether_sandbox_shm_write( diff --git a/tests/integration/sandbox_grant_overflow/harness.c b/tests/integration/sandbox_grant_overflow/harness.c new file mode 100644 index 000000000..597b87940 --- /dev/null +++ b/tests/integration/sandbox_grant_overflow/harness.c @@ -0,0 +1,33 @@ +#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); + +// A grant entry that starts under the serialiser's 8000-byte cut-off but +// ends past its 8192-byte buffer must make the spawn fail (-1) without +// overrunning the stack buffer. Each "fs_read:<990 bytes>\n" entry is 999 +// bytes, so eight end at 7992 and the ninth would end at 8991. (Entries that +// simply stop at the cut-off are dropped, which narrows the policy; that is +// not this case.) +int main(void) { + static char pat[991]; + memset(pat, 'a', sizeof pat - 1); + pat[0] = '/'; + void* grants = list_new(); + for (int i = 0; i < 20; i++) { + if (!list_add_raw(grants, (void*)"fs_read") || + !list_add_raw(grants, (void*)pat)) { + fprintf(stderr, "FAIL cannot build grant list\n"); + return 1; + } + } + int rc = aether_spawn_sandboxed(grants, "/bin/true", ""); + if (rc == -1) { + printf("PASS: oversized grant list denied\n"); + return 0; + } + printf("FAIL: oversized grant list returned %d\n", rc); + return 1; +} diff --git a/tests/integration/sandbox_grant_overflow/test_sandbox_grant_overflow.sh b/tests/integration/sandbox_grant_overflow/test_sandbox_grant_overflow.sh new file mode 100755 index 000000000..d46361744 --- /dev/null +++ b/tests/integration/sandbox_grant_overflow/test_sandbox_grant_overflow.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# An oversized grant list must deny the spawn instead of overrunning the +# serialisation buffer. Built with ASan when the compiler supports it. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_grant_overflow: 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_overflow: runtime or preload library not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT + +built=0 +for san in "-fsanitize=address" ""; do + for libs in "-lpthread -lm" "-lpthread -ldl -lm -lrt -lz -lssl -lcrypto"; do + # shellcheck disable=SC2086 + if ${CC:-cc} $san -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" $libs 2>"$TMP/cc.err"; then + built=1; break 2 + fi + done +done +if [ "$built" -ne 1 ]; then + echo " [FAIL] sandbox_grant_overflow: could not link harness" + sed 's/^/ /' "$TMP/cc.err" | head -8 + exit 1 +fi + +# The harness deliberately leaks its grant list; only the overrun matters. +out="$(ASAN_OPTIONS=detect_leaks=0 AETHER_SANDBOX_PRELOAD="$ROOT/build/libaether_sandbox.so" \ + "$TMP/harness" 2>&1