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 ----