diff --git a/README.md b/README.md index 5dc2eb73..950b07b9 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,7 @@ through `HOWLFRAME_ALLOW_CAPS`; they do not enforce these path scopes. An unrecognized capability name in `-allow-caps` is rejected outright rather than silently granting nothing. See `docs/reference/bytecode_reference.md` for the full opcode-to-capability mapping. -Generated Go and JavaScript mediate `(env "KEY")`, `(exec cmd args...)`, `(read_file path)`, `(fetch url method)`, `(write_file path data)`, and `(mkdir path)` the same way. The runner grant is the `HOWLFRAME_ALLOW_CAPS` environment variable, a comma-separated list of the same names. An empty or unset value denies the effect with `CAPABILITY_DENIED` before the variable is fetched, a process is spawned, a file is read or written, a directory is created, or an HTTP request is sent. `environment` returns the value. `process` runs the command. `filesystem` reads a file, writes a file, or creates a directory. `network` performs the request. Other generated host effects are not on this gate yet. An optional `(fetch)` body is still sent by the interpreter, Go, and JavaScript when that call runs. Both bytecode compilers leave it off `OpFetch`. The design for a later change on those two compilers together is [fetch-body bytecode design](docs/reference/fetch_body_bytecode_design.md). The flip checklist is [production flip criteria](docs/reference/lowered_hfir_prod_flip_criteria.md). +Generated Go and JavaScript mediate `(env "KEY")`, `(exec cmd args...)`, `(read_file path)`, `(fetch url method)`, `(write_file path data)`, and `(mkdir path)` the same way. The runner grant is the `HOWLFRAME_ALLOW_CAPS` environment variable, a comma-separated list of the same names. An empty or unset value denies the effect with `CAPABILITY_DENIED` before the variable is fetched, a process is spawned, a file is read or written, a directory is created, or an HTTP request is sent. `environment` returns the value. `process:exec=[ ...]` runs exactly that program with matching argv; a bare `process` grant never runs a command (see [process authority](docs/reference/process_authority.md)). `filesystem` reads a file, writes a file, or creates a directory. `network` performs the request. Other generated host effects are not on this gate yet. An optional `(fetch)` body is still sent by the interpreter, Go, and JavaScript when that call runs. Both bytecode compilers leave it off `OpFetch`. The design for a later change on those two compilers together is [fetch-body bytecode design](docs/reference/fetch_body_bytecode_design.md). The flip checklist is [production flip criteria](docs/reference/lowered_hfir_prod_flip_criteria.md). Dictionary operations such as `map_get` and `map_keys` grant nothing. `map_keys` is pure: `MAP_KEYS` has an empty capability field and runs under an empty grant. `store_keys` stays `database`. A `memory://` store needs that grant alone. A `file://` store additionally requires `filesystem`; `database` alone denies `file://` `store_keys` with `CAPABILITY_DENIED`. The generated opcode table records the opcode field (empty for `MAP_KEYS`, `database` for `STORE_KEYS`). The URI-dependent grant is written in [bytecode capability notes](docs/reference/bytecode_capability_notes.md). diff --git a/SECURITY.md b/SECURITY.md index 25723d57..d9680348 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,7 @@ It does **NOT** provide or guarantee: - Formal verification of all programs - Production-grade protection against novel VM escapes -The instruction limits and capability gating are logic-level restrictions evaluated within the host process, not a strict isolation boundary provided by the operating system. Do not use HowlFrame to execute untrusted code in a sensitive production environment without proper external containerization. +HowlFrame is a capability-governed runtime, not a sandbox. The instruction limits and capability gating (including `process:exec=` allow-lists and `spawn_agent` grant attenuation, see `docs/reference/process_authority.md`) are logic-level restrictions evaluated within the host process, not a strict isolation boundary provided by the operating system. Do not use HowlFrame to execute untrusted code in a sensitive production environment without proper external containerization. ## Reporting a Vulnerability diff --git a/apps/counter_api/counter_api.howl b/apps/counter_api/counter_api.howl index eefce772..e15ad27e 100644 --- a/apps/counter_api/counter_api.howl +++ b/apps/counter_api/counter_api.howl @@ -1,13 +1,14 @@ (http_server 8103 + (grants "database") (store_open kv "memory://session") (sput kv "n" "0") (route "/count" (lambda (req) - (res_json 200 (dict ("n" (nz (sget kv "n") "0")))))) + (res_json 200 (dict ("n" (sget kv "n")))))) (route "/inc" (lambda (req) (switch (req_method req) ("POST" - (let (n (+ (to_int (nz (sget kv "n") "0")) 1)) - (do (sput kv "n" (to_string n)) (res_json 200 (dict ("n" (to_string n))))))) + (let (n (to_string (+ (to_int (sget kv "n")) 1))) + (do (sput kv "n" n) (res_json 200 (dict ("n" n)))))) (else (res_json 405 (dict ("error" "method_not_allowed"))))))) (route "/reset" (lambda (req) (switch (req_method req) diff --git a/apps/rest_tasks/rest_tasks.howl b/apps/rest_tasks/rest_tasks.howl index 47ae4e0b..81561bba 100644 --- a/apps/rest_tasks/rest_tasks.howl +++ b/apps/rest_tasks/rest_tasks.howl @@ -10,7 +10,7 @@ (store_put kv "next_id" (dict ("val" (to_string (+ n 1))))) (return (to_string n)))))) - (route "/tasks" (lambda (req) + (route "/tasks" (grants "database") (lambda (req) (cond ((= (req_method req) "GET") (do @@ -35,7 +35,7 @@ (res_json 201 task))))))) (else (res_json 405 (dict ("error" "method_not_allowed"))))))) - (route "/tasks/{id}" (lambda (req) + (route "/tasks/{id}" (grants "database") (lambda (req) (let (id (req_path req "id") m (req_method req)) (cond ((= m "GET") diff --git a/apps/status_api/status_api.howl b/apps/status_api/status_api.howl index 344cbbb7..7b133bed 100644 --- a/apps/status_api/status_api.howl +++ b/apps/status_api/status_api.howl @@ -1,10 +1,10 @@ (http_server 8090 (route "/health" (lambda (req) (res_json 200 (dict ("status" "ok"))))) - (route "/ready" (lambda (req) + (route "/ready" (grants "environment") (lambda (req) (if (!= (env "REQUIRED_ENV") "") (res_json 200 (dict ("status" "ready"))) (res_json 503 (dict ("status" "not ready") ("reason" "missing REQUIRED_ENV")))))) (route "/version" (lambda (req) (res_json 200 (dict ("version" "1.0.0") ("app" "status_api"))))) - (route "/config" (lambda (req) + (route "/config" (grants "environment") (lambda (req) (res_json 200 (dict ("public_config" (env "PUBLIC_CONFIG"))))))) diff --git a/apps/task_api/task_api.howl b/apps/task_api/task_api.howl index 08d33d2c..0859a1ea 100644 --- a/apps/task_api/task_api.howl +++ b/apps/task_api/task_api.howl @@ -20,7 +20,7 @@ ) ) - (route "/tasks/create" (lambda (req) + (route "/tasks/create" (grants "database") (lambda (req) (try_let (body (parse_json TaskInput req.body)) (catch parse_err (res_json 400 (dict ("error" "invalid_json"))) @@ -42,7 +42,7 @@ ) )) - (route "/tasks/list" (lambda (req) + (route "/tasks/list" (grants "database") (lambda (req) (do (store_open kv "memory://session") (let (next_rec (store_get kv "next_id")) @@ -78,7 +78,7 @@ ) )) - (route "/tasks/get" (lambda (req) + (route "/tasks/get" (grants "database") (lambda (req) (try_let (body (parse_json TaskInput req.body)) (catch parse_err (res_json 400 (dict ("error" "invalid_json"))) @@ -100,7 +100,7 @@ ) )) - (route "/tasks/complete" (lambda (req) + (route "/tasks/complete" (grants "database") (lambda (req) (try_let (body (parse_json TaskInput req.body)) (catch parse_err (res_json 400 (dict ("error" "invalid_json"))) @@ -129,7 +129,7 @@ ) )) - (route "/tasks/delete" (lambda (req) + (route "/tasks/delete" (grants "database") (lambda (req) (try_let (body (parse_json TaskInput req.body)) (catch parse_err (res_json 400 (dict ("error" "invalid_json"))) diff --git a/apps/task_api/task_api_test.go b/apps/task_api/task_api_test.go index 94fd5268..2c2f2dd9 100644 --- a/apps/task_api/task_api_test.go +++ b/apps/task_api/task_api_test.go @@ -397,21 +397,17 @@ func TestTaskAPICapabilities(t *testing.T) { } }) - t.Run("network only starts but store access is denied", func(t *testing.T) { - startServer(t, srcDir, bcPath, "network") - // This subtest previously asserted the opposite: that a - // capability-denied panic inside a route handler was swallowed and the - // client saw a 200 with an empty body. That made the platform's core - // safety mechanism indistinguishable from success at the client, and - // the VM now fails closed instead, surfacing the structured VMError - // with its code intact. See TestHTTPHandlerFailuresFailClosed in - // internal/vm for the unit-level regression test. - status, data := doRaw(t, "POST", "/tasks/create", `{"title":"x"}`) - if status != 500 { - t.Errorf("expected 500 on capability-denied store access, got %d body=%q", status, data) - } - if !strings.Contains(string(data), "CAPABILITY_DENIED") { - t.Errorf("denial response does not carry its code: %q", data) + t.Run("network only refuses the database route grant at startup", func(t *testing.T) { + // Each route handler receives only network plus its (grants ...) + // list, which must be covered by the runner grant. A route that + // asks for database under a network-only grant fails closed when it + // is registered, before the server listens (previously the denial + // surfaced per request as a 500). + cmd := exec.Command("go", "run", "../../howlframe.go", "-run-bc", "-allow-caps", "network", bcPath) + cmd.Dir = srcDir + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), "CAPABILITY_DENIED") || !strings.Contains(string(out), "HTTP_ROUTE") { + t.Errorf("expected HTTP_ROUTE CAPABILITY_DENIED at startup, got err=%v out=%s", err, out) } }) diff --git a/apps/webhook_relay/webhook_relay.howl b/apps/webhook_relay/webhook_relay.howl index 12562388..3b4fbf56 100644 --- a/apps/webhook_relay/webhook_relay.howl +++ b/apps/webhook_relay/webhook_relay.howl @@ -1,5 +1,5 @@ (http_server 8098 - (route "/relay" (lambda (req) + (route "/relay" (grants "environment") (lambda (req) (let (upstream (env "UPSTREAM_URL")) (if (= upstream "") (res_json 500 (dict ("error" "missing_UPSTREAM_URL"))) diff --git a/authority_cli_test.go b/authority_cli_test.go new file mode 100644 index 00000000..710d8a80 --- /dev/null +++ b/authority_cli_test.go @@ -0,0 +1,541 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/howlcipher/howlframe/internal/vm" +) + +// Authority hardening: exact-argv process allow-lists and S9 attenuation for +// spawn_agent children. Every case runs on the interpreter (-run), the +// production bytecode path (-compile-bc + -run-bc) and the experimental HFIR +// lowering (-compile-hfir-bc + -run-bc). + +type authorityRun struct { + code int + stdout, stderr string + receipt *vm.ExecutionReceipt +} + +func authorityPaths() []string { return []string{"interpreter", "bytecode", "hfir"} } + +func runAuthority(t *testing.T, binary, path, source, grant string) authorityRun { + t.Helper() + dir := t.TempDir() + src := filepath.Join(dir, "prog.howl") + if err := os.WriteFile(src, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + var args []string + receiptPath := "" + switch path { + case "interpreter": + args = []string{"-allow-caps", grant, "-run", src} + case "bytecode", "hfir": + flag := "-compile-bc" + if path == "hfir" { + flag = "-compile-hfir-bc" + } + artifact := filepath.Join(dir, "prog.hfbc") + if out, err := exec.Command(binary, flag, src, "-o", artifact).CombinedOutput(); err != nil { + return authorityRun{code: -1, stdout: string(out)} + } + receiptPath = filepath.Join(dir, "receipt.json") + args = []string{"-allow-caps", grant, "-receipt", receiptPath, "-run-bc", artifact} + } + if grant == "" { + args = args[2:] + } + cmd := exec.Command(binary, args...) + cmd.Dir = dir + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + r := authorityRun{stdout: stdout.String(), stderr: stderr.String()} + if err != nil { + e, ok := err.(*exec.ExitError) + if !ok { + t.Fatal(err) + } + r.code = e.ExitCode() + } + if receiptPath != "" { + if data, err := os.ReadFile(receiptPath); err == nil { + var rec vm.ExecutionReceipt + if err := json.Unmarshal(data, &rec); err != nil { + t.Fatalf("receipt: %v %s", err, data) + } + r.receipt = &rec + } + } + return r +} + +// requireDenied asserts a typed CAPABILITY_DENIED failure. On the bytecode +// paths the receipt must record the denied effect for the given opcode. +func requireDenied(t *testing.T, path string, r authorityRun, opcode string, forbid ...string) { + t.Helper() + all := r.stdout + r.stderr + if r.code == 0 || !strings.Contains(all, "CAPABILITY_DENIED") { + t.Fatalf("%s: want CAPABILITY_DENIED, got code %d stdout %q stderr %q", path, r.code, r.stdout, r.stderr) + } + for _, f := range forbid { + if strings.Contains(r.stdout, f) { + t.Fatalf("%s: denied run printed %q: %q", path, f, r.stdout) + } + } + if path == "interpreter" { + return // interpreter receipts are unsupported (--receipt requires -run-bc) + } + if r.receipt == nil { + t.Fatalf("%s: no receipt written", path) + } + if r.receipt.Exit.Error == nil || r.receipt.Exit.Error.Code != "CAPABILITY_DENIED" || r.receipt.Exit.Error.Opcode != opcode { + t.Fatalf("%s: receipt exit = %+v, want CAPABILITY_DENIED at %s", path, r.receipt.Exit, opcode) + } + found := false + for _, e := range r.receipt.Effects { + if e.Op == opcode && (e.Capability == "process" || opcode == "HTTP_ROUTE") && e.Decision == "denied" { + found = true + } + } + if !found { + t.Fatalf("%s: receipt effects %+v lack a denied %s process effect", path, r.receipt.Effects, opcode) + } +} + +func canonicalProgram(t *testing.T, name string) string { + t.Helper() + for _, dir := range []string{"/usr/bin", "/bin"} { + p := filepath.Join(dir, name) + if resolved, err := filepath.EvalSymlinks(p); err == nil && resolved == p { + return p + } + } + t.Skipf("no canonical %s", name) + return "" +} + +func TestAuthorityExecAllowList(t *testing.T) { + binary := buildHowlFrameBinaryForTest(t) + printf := canonicalProgram(t, "printf") + marker := "authority-exec-marker" + src := fmt.Sprintf(`(cli_app (print (bytes_to_string (exec %q "%%s" %q))))`, printf, marker) + for _, path := range authorityPaths() { + t.Run(path, func(t *testing.T) { + // Exact allow-list entry runs. + if r := runAuthority(t, binary, path, src, "process:exec="+printf+" %s "+marker); r.code != 0 || strings.TrimSpace(r.stdout) != marker { + t.Fatalf("granted: code %d stdout %q stderr %q", r.code, r.stdout, r.stderr) + } + // A bare process grant has no allow-list and fails closed. + requireDenied(t, path, runAuthority(t, binary, path, src, "process"), "EXEC", marker) + // The wrong program, a different argument, and no grant all deny. + requireDenied(t, path, runAuthority(t, binary, path, src, "process:exec=/usr/bin/true **"), "EXEC", marker) + requireDenied(t, path, runAuthority(t, binary, path, src, "process:exec="+printf+" %s other"), "EXEC", marker) + r := runAuthority(t, binary, path, src, "") + if r.code == 0 || !strings.Contains(r.stdout+r.stderr, "CAPABILITY_DENIED") && !strings.Contains(r.stdout+r.stderr, "capability denied") { + t.Fatalf("no grant: code %d %q %q", r.code, r.stdout, r.stderr) + } + }) + } +} + +func TestAuthorityExecArgvInjection(t *testing.T) { + binary := buildHowlFrameBinaryForTest(t) + printf := canonicalProgram(t, "printf") + grant := "process:exec=" + printf + " %s safe" + for _, path := range authorityPaths() { + t.Run(path, func(t *testing.T) { + pwned := filepath.Join(t.TempDir(), "pwned") + for name, src := range map[string]string{ + // Shell metacharacters are never interpreted and do not match the literal. + "metachar": fmt.Sprintf(`(cli_app (print (bytes_to_string (exec %q "%%s" "safe; touch %s"))))`, printf, pwned), + // An extra trailing argument is not covered by an exact pattern. + "extra": fmt.Sprintf(`(cli_app (print (bytes_to_string (exec %q "%%s" "safe" "extra"))))`, printf), + // Argument smuggling inside the program string does not split. + "program-args": fmt.Sprintf(`(cli_app (print (bytes_to_string (exec "%s %%s safe"))))`, printf), + // A missing argument does not match either. + "short": fmt.Sprintf(`(cli_app (print (bytes_to_string (exec %q "%%s"))))`, printf), + } { + t.Run(name, func(t *testing.T) { + requireDenied(t, path, runAuthority(t, binary, path, src, grant), "EXEC", "safe") + if _, err := os.Stat(pwned); !os.IsNotExist(err) { + t.Fatalf("injection created %s", pwned) + } + }) + } + }) + } +} + +func TestAuthorityExecProgramPath(t *testing.T) { + binary := buildHowlFrameBinaryForTest(t) + printf := canonicalProgram(t, "printf") + link := filepath.Join(t.TempDir(), "printf-link") + if err := os.Symlink(printf, link); err != nil { + t.Skip(err) + } + marker := "authority-path-marker" + call := func(program string) string { + return fmt.Sprintf(`(cli_app (print (bytes_to_string (exec %q %q))))`, program, marker) + } + for _, path := range authorityPaths() { + t.Run(path, func(t *testing.T) { + for name, tc := range map[string]struct{ program, grant string }{ + // Bare names never go through a PATH lookup. + "bare name": {"printf", "process:exec=" + printf + " **"}, + // A relative path is refused even when it resolves to the program. + "relative": {"./printf", "process:exec=" + printf + " **"}, + // A non-clean path that names the program is refused. + "dotdot": {filepath.Join(filepath.Dir(printf), "..", filepath.Base(filepath.Dir(printf))) + "/./printf", "process:exec=" + printf + " **"}, + // Calling through a symlink to the granted program is refused. + "symlink to granted": {link, "process:exec=" + printf + " **"}, + // Granting the symlink itself is refused: links can be swapped. + "granted symlink": {link, "process:exec=" + link + " **"}, + } { + t.Run(name, func(t *testing.T) { + requireDenied(t, path, runAuthority(t, binary, path, call(tc.program), tc.grant), "EXEC", marker) + }) + } + }) + } +} + +// spawn_agent runs only on the bytecode VM. The interpreter and the HFIR +// lowering refuse it outright, which is fail-closed; the cases still run +// there to prove no child body executes. +func TestAuthoritySpawnAgentAttenuation(t *testing.T) { + binary := buildHowlFrameBinaryForTest(t) + printf := canonicalProgram(t, "printf") + execGrant := "process:exec=" + printf + " **" + cases := []struct { + name, grant, source, want string + denied, receiptDeny bool + failedChild string + }{ + { + name: "default no grants", + grant: "process,environment", + source: `(cli_app (spawn_agent "Child" (task "t" (print "child-ran") (env "HOME"))) (print "parent-after"))`, + // The child body starts but its env is denied: it inherited nothing. + want: "child-ran\nparent-after", + failedChild: "CAPABILITY_DENIED: capability denied: environment", + }, + { + name: "explicit subset", + grant: "process,environment", + source: `(cli_app (spawn_agent "Child" (grants "environment") (task "t" (print (is_nil (env "HOWL_AUTHORITY_UNSET"))))))`, + want: "false", + }, + { + name: "child requests more than parent", + grant: "process", + source: `(cli_app (spawn_agent "Child" (grants "environment") (task "t" (print "child-ran"))))`, + denied: true, + }, + { + name: "child requests coarse filesystem from scoped parent", + grant: "process,filesystem:read=/tmp", + source: `(cli_app (spawn_agent "Child" (grants "filesystem") (task "t" (print "child-ran"))))`, + denied: true, + }, + { + name: "child requests a wider exec pattern", + grant: "process," + "process:exec=" + printf + " x", + source: fmt.Sprintf(`(cli_app (spawn_agent "Child" (grants %q) (task "t" (print "child-ran"))))`, execGrant), + denied: true, + }, + { + name: "nested escalation", + grant: "process,environment", + // Outer holds only process; Inner asks for environment, which the + // root has but Outer was never handed. + // The refusal is a typed error inside Outer, isolated like any child failure. + source: `(cli_app (spawn_agent "Outer" (grants "process") (task "o" (spawn_agent "Inner" (grants "environment") (task "i" (print "child-ran"))))) (print "parent-after"))`, + want: "parent-after", + failedChild: "CAPABILITY_DENIED: capability denied: spawn_agent: child requested a grant the parent does not hold", + receiptDeny: true, + }, + { + name: "nested spawn without process", + grant: "process,environment", + // Outer received nothing, so it cannot spawn at all. + source: `(cli_app (spawn_agent "Outer" (task "o" (spawn_agent "Inner" (task "i" (print "child-ran"))))) (print "parent-after"))`, + want: "parent-after", + failedChild: "CAPABILITY_DENIED: capability denied: process", + }, + { + name: "exec delegated exactly", + grant: "process," + execGrant, + source: fmt.Sprintf(`(cli_app (spawn_agent "Child" (grants "process:exec=%s **") (task "t" (print (bytes_to_string (exec %q "delegated"))))))`, printf, printf), + want: "delegated", + }, + { + name: "exec not delegated", + grant: "process," + execGrant, + source: fmt.Sprintf(`(cli_app (spawn_agent "Child" (grants "process") (task "t" (print (bytes_to_string (exec %q "child-ran"))))))`, printf), + failedChild: "CAPABILITY_DENIED: capability denied: process: exec argv is not in the allow-list", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for _, path := range authorityPaths() { + r := runAuthority(t, binary, path, tc.source, tc.grant) + if strings.Contains(r.stdout, "child-ran") && (path != "bytecode" || tc.denied) { + t.Fatalf("%s: child body ran: %q", path, r.stdout) + } + if path != "bytecode" { + if r.code == 0 { + t.Fatalf("%s: spawn_agent must be refused, got code 0 stdout %q", path, r.stdout) + } + continue + } + if tc.denied { + requireDenied(t, path, r, "SPAWN_AGENT", "child-ran") + continue + } + var lines []string + for _, l := range strings.Split(strings.TrimSpace(r.stdout), "\n") { + if !strings.HasPrefix(l, "[Swarm VM]") { + lines = append(lines, l) + } + } + if r.code != 0 || strings.Join(lines, "\n") != tc.want { + t.Fatalf("code %d stdout %q stderr %q, want %q", r.code, r.stdout, r.stderr, tc.want) + } + if tc.failedChild != "" && !strings.Contains(r.stderr, tc.failedChild) { + t.Fatalf("stderr %q, want %q", r.stderr, tc.failedChild) + } + if tc.receiptDeny { + found := false + for _, e := range r.receipt.Effects { + found = found || e.Op == "SPAWN_AGENT" && e.Decision == "denied" + } + if !found { + t.Fatalf("receipt effects %+v lack a denied SPAWN_AGENT", r.receipt.Effects) + } + } + if tc.failedChild == "" && r.stderr != "" { + t.Fatalf("unexpected stderr %q", r.stderr) + } + } + }) + } +} + +func TestAuthorityGrantsMustBeLiteral(t *testing.T) { + binary := buildHowlFrameBinaryForTest(t) + for name, src := range map[string]string{ + "expression": `(cli_app (let (g "process") (spawn_agent "C" (grants g) (task "t" (print 1)))))`, + "unknown": `(cli_app (spawn_agent "C" (grants "root") (task "t" (print 1))))`, + "relative": `(cli_app (spawn_agent "C" (grants "process:exec=printf") (task "t" (print 1))))`, + "not grants": `(cli_app (spawn_agent "C" (list "process") (task "t" (print 1))))`, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "p.howl") + if err := os.WriteFile(path, []byte(src), 0o600); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(binary, "-compile-bc", path, "-o", filepath.Join(dir, "p.hfbc")).CombinedOutput(); err == nil { + t.Fatalf("compiled: %s", out) + } + }) + } +} + +func runAuthorityEnv(t *testing.T, binary, path, source, grant string, env ...string) authorityRun { + t.Helper() + for _, kv := range env { + k, v, _ := strings.Cut(kv, "=") + t.Setenv(k, v) + } + return runAuthority(t, binary, path, source, grant) +} + +// A "*" slot never matches an empty argument or one that starts with "-", so +// a wildcard cannot smuggle in a flag. Literal flags and an escaped literal +// "*" still match. +func TestAuthorityExecFlagInjection(t *testing.T) { + binary := buildHowlFrameBinaryForTest(t) + printf := canonicalProgram(t, "printf") + call := func(args ...string) string { + quoted := make([]string, len(args)) + for i, a := range args { + quoted[i] = fmt.Sprintf("%q", a) + } + return fmt.Sprintf(`(cli_app (print (bytes_to_string (exec %q %s))))`, printf, strings.Join(quoted, " ")) + } + grant := "process:exec=" + printf + " %s *" + for _, path := range authorityPaths() { + t.Run(path, func(t *testing.T) { + if r := runAuthority(t, binary, path, call("%s", "plain"), grant); r.code != 0 || strings.TrimSpace(r.stdout) != "plain" { + t.Fatalf("plain slot: %d %q %q", r.code, r.stdout, r.stderr) + } + requireDenied(t, path, runAuthority(t, binary, path, call("%s", "-v"), grant), "EXEC", "-v") + requireDenied(t, path, runAuthority(t, binary, path, call("%s", "--help"), grant), "EXEC", "help") + requireDenied(t, path, runAuthority(t, binary, path, call("%s", ""), grant), "EXEC") + // A literal flag in the grant still matches. + if r := runAuthority(t, binary, path, call("-flag-literal-%s"), "process:exec="+printf+" -flag-literal-%s"); r.code != 0 || strings.TrimSpace(r.stdout) != "-flag-literal-" { + t.Fatalf("literal flag: %d %q %q", r.code, r.stdout, r.stderr) + } + // `\*` matches a literal "*" and nothing else. + if r := runAuthority(t, binary, path, call("%s", "*"), "process:exec="+printf+` %s \*`); r.code != 0 || strings.TrimSpace(r.stdout) != "*" { + t.Fatalf("escaped star: %d %q %q", r.code, r.stdout, r.stderr) + } + requireDenied(t, path, runAuthority(t, binary, path, call("%s", "x"), "process:exec="+printf+` %s \*`), "EXEC") + }) + } +} + +// exec children start with an empty environment; only process:env=NAME +// variables pass through. +func TestAuthorityExecEnvironment(t *testing.T) { + binary := buildHowlFrameBinaryForTest(t) + envProg := canonicalProgram(t, "env") + src := fmt.Sprintf(`(cli_app (print (bytes_to_string (exec %q))))`, envProg) + for _, path := range authorityPaths() { + t.Run(path, func(t *testing.T) { + r := runAuthorityEnv(t, binary, path, src, "process:exec="+envProg, "FOO=leakme") + if r.code != 0 || strings.Contains(r.stdout, "leakme") || strings.Contains(r.stdout, "PATH=") || strings.TrimSpace(r.stdout) != "" { + t.Fatalf("default env: code %d stdout %q stderr %q", r.code, r.stdout, r.stderr) + } + r = runAuthorityEnv(t, binary, path, src, "process:exec="+envProg+",process:env=FOO", "FOO=leakme", "BAR=other") + if r.code != 0 || strings.TrimSpace(r.stdout) != "FOO=leakme" { + t.Fatalf("granted FOO: code %d stdout %q stderr %q", r.code, r.stdout, r.stderr) + } + }) + } +} + +// Receipts name the matched allow-list entry for an allowed exec. +func TestAuthorityReceiptMatchedGrant(t *testing.T) { + binary := buildHowlFrameBinaryForTest(t) + printf := canonicalProgram(t, "printf") + grant := "process:exec=" + printf + " **" + for _, path := range []string{"bytecode", "hfir"} { + r := runAuthority(t, binary, path, fmt.Sprintf(`(cli_app (print (bytes_to_string (exec %q "x"))))`, printf), grant) + if r.code != 0 || r.receipt == nil || len(r.receipt.Effects) != 1 || r.receipt.Effects[0].Grant != grant || r.receipt.Effects[0].Decision != "allowed" { + t.Fatalf("%s: receipt %+v", path, r.receipt) + } + } +} + +// spawn (in-agent background task) bodies get no grants by default and only +// an explicit subset of the parent's. +func TestAuthoritySpawnAttenuation(t *testing.T) { + binary := buildHowlFrameBinaryForTest(t) + t.Setenv("HOWL_SPAWN_SECRET", "spawn-secret-value") + inherit := `(cli_app (spawn (lambda () (print (env "HOWL_SPAWN_SECRET")))) (sleep 200) (print "parent-done"))` + explicit := `(cli_app (spawn (grants "environment") (lambda () (print (env "HOWL_SPAWN_SECRET")))) (sleep 200) (print "parent-done"))` + over := `(cli_app (spawn (grants "environment") (lambda () (print "child-ran"))) (sleep 200) (print "parent-done"))` + for _, path := range authorityPaths() { + t.Run(path, func(t *testing.T) { + r := runAuthority(t, binary, path, inherit, "process,environment") + if strings.Contains(r.stdout, "spawn-secret-value") { + t.Fatalf("default spawn inherited environment: %q", r.stdout) + } + r = runAuthority(t, binary, path, over, "process") + if strings.Contains(r.stdout, "child-ran") { + t.Fatalf("over-request ran: %q", r.stdout) + } + if path == "bytecode" { + requireDenied(t, path, r, "SPAWN", "child-ran") + if r := runAuthority(t, binary, path, explicit, "process,environment"); r.code != 0 || !strings.Contains(r.stdout, "spawn-secret-value") { + t.Fatalf("explicit spawn grant: %d %q %q", r.code, r.stdout, r.stderr) + } + } + }) + } +} + +// HTTP route handlers keep network (needed to respond) and otherwise only +// their (grants ...) list, which must be covered by the runner grant. +func TestAuthorityRouteAttenuation(t *testing.T) { + binary := buildHowlFrameBinaryForTest(t) + t.Setenv("HOWL_ROUTE_SECRET", "route-secret-value") + for i, path := range []string{"bytecode", "hfir"} { + t.Run(path, func(t *testing.T) { + port := 18620 + i*10 + // Over-request refuses registration before the server listens. + over := fmt.Sprintf(`(http_server %d (route "/x" (grants "environment") (lambda (req) (res 200 "text/plain" "ok"))))`, port) + r := runAuthority(t, binary, path, over, "network") + if r.code == -1 { + t.Skipf("%s does not compile routes: %s", path, r.stdout) + } + requireDenied(t, path, r, "HTTP_ROUTE") + + for _, tc := range []struct { + route, want string + }{ + {`(route "/x" (lambda (req) (res 200 "text/plain" (env "HOWL_ROUTE_SECRET"))))`, "CAPABILITY_DENIED"}, + {`(route "/x" (grants "environment") (lambda (req) (res 200 "text/plain" (env "HOWL_ROUTE_SECRET"))))`, "route-secret-value"}, + // A server-wide (grants ...) is the default for every route. + {`(grants "environment") (route "/x" (lambda (req) (res 200 "text/plain" (env "HOWL_ROUTE_SECRET"))))`, "route-secret-value"}, + // A route's own list replaces the server default. + {`(grants "environment") (route "/x" (grants) (lambda (req) (res 200 "text/plain" (env "HOWL_ROUTE_SECRET"))))`, "CAPABILITY_DENIED"}, + } { + port++ + src := fmt.Sprintf(`(http_server %d %s)`, port, tc.route) + body := serveOnce(t, binary, path, src, "network,environment", port) + if !strings.Contains(body, tc.want) || tc.want == "CAPABILITY_DENIED" && strings.Contains(body, "route-secret-value") { + t.Fatalf("route %s: body %q, want %q", tc.route, body, tc.want) + } + } + }) + } +} + +func serveOnce(t *testing.T, binary, path, source, grant string, port int) string { + t.Helper() + dir := t.TempDir() + src := filepath.Join(dir, "srv.howl") + if err := os.WriteFile(src, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + flag := "-compile-bc" + if path == "hfir" { + flag = "-compile-hfir-bc" + } + artifact := filepath.Join(dir, "srv.hfbc") + if out, err := exec.Command(binary, flag, src, "-o", artifact).CombinedOutput(); err != nil { + t.Fatalf("compile: %v %s", err, out) + } + cmd := exec.Command(binary, "-allow-caps", grant, "-run-bc", artifact) + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { cmd.Process.Kill(); cmd.Wait() }() + url := fmt.Sprintf("http://127.0.0.1:%d/x", port) + for i := 0; i < 100; i++ { + resp, err := httpGet(url) + if err == nil { + return resp + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("server never answered: %s", out.String()) + return "" +} + +func httpGet(url string) (string, error) { + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + return string(b), err +} diff --git a/change_log.md b/change_log.md index 35ad5c75..9e1f14f2 100644 --- a/change_log.md +++ b/change_log.md @@ -2,6 +2,9 @@ ## Unreleased +### Breaking +* Authority hardening (process allow-lists and S9 attenuation). HowlFrame is a capability-governed runtime, not a sandbox. (1) A bare `process` grant no longer authorizes `exec` on any path (interpreter, `-run-bc` for AST and HFIR artifacts, generated Go/JS): each program needs `process:exec=[ ...]` with exact-argv matching (`literal`, `*`, trailing `**`). Bare names (no PATH lookup), relative or non-clean paths, and symlinked program paths are refused. Migrate `-allow-caps process` + `(exec "find" ...)` to `-allow-caps 'process:exec=/usr/bin/find **'` + `(exec "/usr/bin/find" ...)`. (2) `spawn_agent` children now start with **no grants** instead of inheriting the parent's; `(spawn_agent name (grants "cap" ...) task)` hands an explicit subset, which must be covered by the parent, so nested spawns cannot escalate. A nested spawner needs `(grants "process")`. Spawning still requires coarse `process`. (3) Denials are typed `CAPABILITY_DENIED` with a reason and are recorded in the `--receipt` (EXEC / SPAWN_AGENT, decision `denied`). New `grants` construct (opaque, arity 0+). No new opcode; the child grant list rides in `SPAWN_AGENT` `string_operand_2`. Pinned benchmark results unchanged; #90 stays Partial; no production flip. Reference: `docs/reference/process_authority.md`. Review follow-ups: (4) a `*` slot no longer matches an empty argument or one starting with `-` (grant flags as literals; `\*` matches a literal `*`; `**` still accepts anything). (5) exec children start with an empty environment on every path; `process:env=NAME` passes one variable through. (6) `spawn` bodies and HTTP route handlers are attenuated like `spawn_agent`: `(spawn (grants ...) (lambda ...))` (default none) and `(route path (grants ...) (lambda ...))` (default `network` only, which a handler needs to respond; one `(grants ...)` directly under `http_server` sets the default for its routes); over-requests fail closed at `SPAWN`/`HTTP_ROUTE`. Apps updated: counter_api (server-wide `(grants "database")`, plus two equivalent simplifications to stay under the Python token count: `n` is initialized at startup, so `nz` fallbacks were dropped, and `/inc` binds the string once), rest_tasks, task_api (`database`), status_api, webhook_relay (`environment`); task_api's network-only run now fails at startup instead of per request. Receipts carry the matched exec grant; spawn denials no longer echo the requested grant; generated Go/JS refuse a non-final `**`. Interpreter denials are typed but still write no receipt (documented). + ### Added * Records and JSON sugar (heldout-v3 records slice): `(rec k v …)` record literals with name keys, `(get m k …)` / `(put m k … v)` nested path read/write, `(read_json p)` / `(write_json p v)` one-call file JSON under the existing filesystem grant, `(pluck lst k)`, `(select lst k …)`, `(group_by x lst key)`, `(merge a b …)`. Desugar-only: no opcode or capability; arity entries; Go/JS reject with `UNSUPPORTED_ON_TARGET`; governed scan fails closed through every form. `parse_json` now runs under `-run`. `encode_json` already writes sorted keys, so output is stable. * Dogfood round 4: `apps/fetch_cli`, `apps/template_cli`, `apps/stamp_api`; `(map_has)`, `(when)`/`(unless)`, n-ary `and`/`or` desugar. Suite total HowlFrame 2331 vs Python 3025 / Go 3295 / Node 2927. Journal: `docs/journals/2026-10-10_dogfood_apps_round4.md`. diff --git a/docs/cli.md b/docs/cli.md index aee614ee..a8576e8c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -22,7 +22,7 @@ Executes a compiled HowlFrame bytecode artifact. **Important**: Capabilities are denied by default. You must explicitly grant capabilities to the runtime. ### Options -* `--allow-caps` : Comma-separated capabilities to allow (e.g., `network,filesystem,process,environment,database`). Repeatable `filesystem:read=` grants permit reads; `filesystem:write=` grants permit writes and `mkdir`, without granting reads. `filesystem` remains unrestricted. Roots are cleaned and anchored to the runner cwd at parse time. Targets must remain within a matching root after lexical normalization and symlink resolution. `file://` stores require `database` plus read coverage for open/get/keys and both read/write coverage for put/delete. Empty roots, unknown sub-keys, and scoped forms of other capabilities are unknown-capability errors. Denials occur before I/O with `CAPABILITY_DENIED`. Concurrent symlink changes remain a TOCTOU caveat. Generated Go/JavaScript remain coarse. +* `--allow-caps` : Comma-separated capabilities to allow (e.g., `network,filesystem,process,environment,database`). Repeatable `filesystem:read=` grants permit reads; `filesystem:write=` grants permit writes and `mkdir`, without granting reads. `filesystem` remains unrestricted. Roots are cleaned and anchored to the runner cwd at parse time. Targets must remain within a matching root after lexical normalization and symlink resolution. `file://` stores require `database` plus read coverage for open/get/keys and both read/write coverage for put/delete. Empty roots, unknown sub-keys, and scoped forms of other capabilities are unknown-capability errors. Denials occur before I/O with `CAPABILITY_DENIED`. Concurrent symlink changes remain a TOCTOU caveat. Generated Go/JavaScript remain coarse for filesystem. `process` alone never authorizes `exec`: each program needs `process:exec=[ ...]` (exact argv; `*` one non-empty argument not starting with `-`, `\*` a literal `*`, trailing `**` the rest; no PATH lookup; symlinked programs refused), on every path including generated Go/JavaScript. exec children get an empty environment except `process:env=NAME` grants. `spawn_agent`, `spawn` and route handlers receive only the `(grants ...)` they are handed (route handlers also keep `network`), never more than the parent. See `docs/reference/process_authority.md`. * `--max-instructions` : A finite instruction ceiling (default `100000`). Once the ceiling is reached, execution halts to prevent infinite loops and runaway resource consumption. * `--max-call-depth` : Positive CALL recursion and SPAWN_AGENT nesting ceiling (default `1000`). * `--max-memory-bytes` : Positive cumulative allocation charge ceiling (default `67108864`, 64 MiB). Charges approximate storage; they are not a measured live heap limit and are never reclaimed. diff --git a/docs/journals/2026-10-12_process_authority_hardening.md b/docs/journals/2026-10-12_process_authority_hardening.md new file mode 100644 index 00000000..813b6362 --- /dev/null +++ b/docs/journals/2026-10-12_process_authority_hardening.md @@ -0,0 +1,37 @@ +# Process authority hardening: exec allow-lists and spawn_agent attenuation + +Date: 2026-10-12. Base: main `54009fb2`. + +## Why +Seat vote (Lain): before any live-model run, `process` allowed any command and +`spawn_agent` children inherited the parent's full grants. + +## What changed +* `process:exec=[ ...]` exact-argv allow-list; a bare + `process` grant fails closed for `exec` on the interpreter, `-run-bc` (AST and + HFIR artifacts) and generated Go/JS. No shell, no PATH lookup, symlinked or + non-clean program paths refused. +* `spawn_agent` children get only `(grants ...)`, each covered by the parent; + default none. Spawning still needs coarse `process`. +* Denials: `CAPABILITY_DENIED` with reason; `--receipt` records denied + `EXEC`/`SPAWN_AGENT` process effects. + +## Evidence +* `authority_cli_test.go`: allow-list, argv injection (shell metachar, extra, + short, args smuggled into the program string), bare/relative/dotdot/symlink + program paths, default-no-grant children, child over-request, nested + escalation, exec delegation — on interpreter, `-run-bc` and HFIR. Interpreter + and HFIR refuse `spawn_agent` (fail closed) and the test proves no child body + runs there. +* ABI conformance gained `exec_bare_process_denied` and + `exec_argv_mismatch_denied` across all five targets. +* Unit tests: `internal/capability/process_test.go`. + +## Migration found while updating consumers +* `examples/repo_analyst` (find), the parity/conformance exec fixtures + (printf, rm) and the swarm dogfoods (nested spawner needs + `(grants "process")`) were updated. `/bin/sh` and `/bin/echo` are symlinks on + usr-merged hosts and must be called by canonical path. + +## Not changed +No opcode; #90 stays Partial; no production flip; no live LLM. diff --git a/docs/reference/construct_coverage.md b/docs/reference/construct_coverage.md index 7c97184b..9c49b3c7 100644 --- a/docs/reference/construct_coverage.md +++ b/docs/reference/construct_coverage.md @@ -52,6 +52,7 @@ | `fuzzy_cast` | No | None | No | Yes | - | AUTHORITATIVE | | `get` | No | None | Yes | No | - | AUTHORITATIVE | | `go_import` | No | None | No | Yes | - | AUTHORITATIVE | +| `grants` | Yes | None | No | No | - | AUTHORITATIVE | | `group_by` | No | None | Yes | No | - | AUTHORITATIVE | | `html_escape` | Yes | None | No | No | - | AUTHORITATIVE | | `http_body` | No | None | Yes | No | - | AUTHORITATIVE | diff --git a/docs/reference/lowered_hfir_prod_flip_criteria.md b/docs/reference/lowered_hfir_prod_flip_criteria.md index 528b193d..75b8d00f 100644 --- a/docs/reference/lowered_hfir_prod_flip_criteria.md +++ b/docs/reference/lowered_hfir_prod_flip_criteria.md @@ -98,6 +98,7 @@ achieve confidence db_connect ephemeral_circuit +grants http_request http_server lazy_synthesize diff --git a/docs/reference/process_authority.md b/docs/reference/process_authority.md new file mode 100644 index 00000000..640ad507 --- /dev/null +++ b/docs/reference/process_authority.md @@ -0,0 +1,117 @@ +# Process authority: exec allow-lists and spawn_agent attenuation + +HowlFrame is a **capability-governed runtime**, not a sandbox. Grants are +logic-level checks inside the host process (see `SECURITY.md`); run untrusted +programs inside external OS isolation. + +## `exec` needs an exact-argv allow-list + +A grant of `process` with no allow-list **never** authorizes `exec`. Each +permitted program is a separate grant: + +``` +process:exec=[ ...] +``` + +| pattern | matches | +| --- | --- | +| `literal` | exactly one argument equal to `literal` (literal flags such as `-n` work) | +| `\literal` | a backslash escapes a token: `\*` matches a literal `*`, `\**` a literal `**` | +| `*` | exactly one argument that is **non-empty and does not start with `-`** | +| `**` | (last token only) zero or more remaining arguments, **including flags** | + +`*` refuses flags so a wildcard slot cannot inject `-o`, `--exec`, etc.; grant +any flag as a literal. `**` is an explicit "anything" and accepts flags; use it +only for programs where any argv is acceptable. A non-final `**` is an invalid +grant on every path (`-allow-caps` parse error; generated Go/JS refuse the +exec). + +With no pattern, the program may run only with zero arguments. Examples: + +``` +-allow-caps 'process:exec=/usr/bin/find **,filesystem' +-allow-caps 'process:exec=/usr/bin/printf %s *' +``` + +Rules, enforced identically on the interpreter (`-run`), `-run-bc` (both +`-compile-bc` and `-compile-hfir-bc` artifacts), and generated Go/JavaScript +(`HOWLFRAME_ALLOW_CAPS`): + +* No shell is ever involved; argv is passed verbatim. +* exec children start with an **empty environment** (no `PATH`, `HOME`, ...). + Each `process:env=NAME` grant passes one host variable through to every exec + child; nothing else is inherited. `process:env=` is independent of the + `environment` capability (which governs `(env ...)` reads by the program). +* No PATH lookup: the requested program must be an absolute, clean path. + `printf`, `./printf` and `/usr/bin/../bin/printf` are refused. +* The program path must be symlink-free (it must equal its own + `EvalSymlinks` result). Calling a symlink to a granted program, or granting + the symlink itself, is refused. On usr-merged systems `/bin/sh` is a + symlink; grant and call the canonical target (for example `/usr/bin/dash`). +* Literal arguments cannot contain spaces or commas (grants are + comma-separated and patterns space-separated); use `*` for such arguments. +* Denial is `CAPABILITY_DENIED` with a reason (`exec argv is not in the + allow-list`, `not an absolute path`, `is or traverses a symlink`, ...). The + reason never echoes the program or its arguments. On `-run-bc` the receipt + records `{"op":"EXEC","capability":"process","decision":"denied"}` and the + exit error; an allowed exec records the matched entry in the effect's + `grant` field. +* Interpreter (`-run`) denials are typed `CAPABILITY_DENIED` errors but write + no receipt: `--receipt` requires `-run-bc`, and the interpreter has no + receipt recorder. Use `-run-bc` when an audit trail is required. +* Concurrent replacement of a program file after the check is a TOCTOU + caveat, as for filesystem grants. + +## `spawn_agent` children receive only explicit grants (S9 attenuation) + +``` +(spawn_agent "Name" (grants "environment" "process:exec=/usr/bin/printf **") + (task "description" body...)) +``` + +* The default is **no grants**: `(spawn_agent "Name" (task ...))` runs its + body with an empty grant set, whatever the parent holds. +* Every `(grants ...)` entry must be a string literal naming a valid grant; + anything else is a compile error. +* At spawn time each entry must be covered by the *spawning agent's* grants: + coarse names and `process:exec=` entries verbatim; `filesystem:read=` / + `filesystem:write=` roots contained in a parent root of the same mode (or a + coarse parent `filesystem`). A child can never hold more than its parent, so + escalation through nested spawns is impossible. +* Spawning requires the coarse `process` grant; an exec allow-list alone does + not let an agent spawn. A nested spawner must therefore be handed + `(grants "process")`. +* A request for more than the parent holds raises `CAPABILITY_DENIED` at the + `SPAWN_AGENT` instruction (`spawn_agent: child requested a grant the parent + does not hold`; the requested grant text is never echoed) before any child + instruction runs, and the receipt + records a denied `SPAWN_AGENT` effect. Inside an agent body this is isolated + like any other child failure. +* The grant list is carried in `SPAWN_AGENT`'s `string_operand_2` + (newline-separated). No opcode was added; older artifacts without it run + their children with no grants. +* `spawn_agent` executes its body only on the bytecode VM. The interpreter and + the experimental HFIR lowering refuse it. Generated Go/JavaScript accept the + two-argument form but **do not run the task body**: they only print the + spawning/completed lines. They reject the `(grants ...)` form with + `UNSUPPORTED_ON_TARGET`. + +## `spawn` and HTTP route handlers are attenuated too + +* `(spawn (lambda () body))` runs its background body with **no grants**; + `(spawn (grants "cap" ...) (lambda () body))` hands an explicit subset, + checked against the spawner like `spawn_agent` (`SPAWN` `string_operand` + carries the list). An over-request raises `CAPABILITY_DENIED` at `SPAWN`. + Failures inside a spawned body stay silent, as before. +* `(route "/path" (grants "cap" ...) (lambda (req) body))`: a handler runs with + `network` (required to answer: `res`, `res_json` and `res_header` are + network-classified, and `HTTP_ROUTE` already required it) **plus only** its + `(grants ...)` list (`HTTP_ROUTE` `string_operand_3`). A route without a list + gets `network` alone, so store, environment, filesystem and process access + in a handler must be listed. An over-request fails closed when the route is + registered, before the server listens. +* `(http_server PORT (grants "cap" ...) routes...)`: one `(grants ...)` form + directly under `http_server` is the default list for every route that does + not declare its own; a route's own `(grants ...)` (even empty) replaces it. +* Both forms are bytecode-only; the Go checker/backends reject them with + `UNSUPPORTED_ON_TARGET`. diff --git a/examples/repo_analyst/README.md b/examples/repo_analyst/README.md index 00a82a2d..27e8e5f6 100644 --- a/examples/repo_analyst/README.md +++ b/examples/repo_analyst/README.md @@ -14,24 +14,24 @@ From the repository root: go build -o /tmp/howlframe-repo-analyst howlframe.go /tmp/howlframe-repo-analyst -validate examples/repo_analyst/repo_analyst.howl /tmp/howlframe-repo-analyst -compile-bc examples/repo_analyst/repo_analyst.howl -o /tmp/repo_analyst.hfbc -/tmp/howlframe-repo-analyst -run-bc -allow-caps process,filesystem /tmp/repo_analyst.hfbc +/tmp/howlframe-repo-analyst -run-bc -allow-caps "process:exec=/usr/bin/find **,filesystem" /tmp/repo_analyst.hfbc ``` The default runner policy permits 100,000 instructions. For a larger legitimate workload, the trusted runner can authorize a higher finite ceiling without changing the application or artifact: ```bash -/tmp/howlframe-repo-analyst -run-bc -allow-caps process,filesystem --max-instructions 1000000 /tmp/repo_analyst.hfbc +/tmp/howlframe-repo-analyst -run-bc -allow-caps "process:exec=/usr/bin/find **,filesystem" --max-instructions 1000000 /tmp/repo_analyst.hfbc ``` Pass a second application argument to write the report instead of printing it: ```bash -/tmp/howlframe-repo-analyst -run-bc -allow-caps process,filesystem /tmp/repo_analyst.hfbc /tmp/repo-report.txt +/tmp/howlframe-repo-analyst -run-bc -allow-caps "process:exec=/usr/bin/find **,filesystem" /tmp/repo_analyst.hfbc /tmp/repo-report.txt ``` The runtime policy grants exactly the effects used by the application: -- `process` runs the host `find` command for recursive discovery. +- `process:exec=/usr/bin/find **` lets the program run exactly the host `find` binary (absolute, symlink-free path; any arguments) for recursive discovery. A bare `process` grant no longer authorizes `exec`. - `filesystem` reads candidate text files and, when requested, writes the report. No network, environment, database, model, or agent capability is used. Host `find` is an intentional composition of today's primitives, not a Repo Analyst-specific runtime operation. diff --git a/examples/repo_analyst/discovery.howl b/examples/repo_analyst/discovery.howl index 24ca8c2e..f0c3723b 100644 --- a/examples/repo_analyst/discovery.howl +++ b/examples/repo_analyst/discovery.howl @@ -3,7 +3,7 @@ (type_hint repository_path "string") (type_hint return "any") (try_let (command_output - (exec "find" repository_path "-type" "f" "-not" "-path" "*/.git/*" "-print")) + (exec "/usr/bin/find" repository_path "-type" "f" "-not" "-path" "*/.git/*" "-print")) (catch err (do (stderr (str_join diff --git a/examples/repo_analyst/repo_analyst_test.go b/examples/repo_analyst/repo_analyst_test.go index 3769135c..86cd93da 100644 --- a/examples/repo_analyst/repo_analyst_test.go +++ b/examples/repo_analyst/repo_analyst_test.go @@ -28,7 +28,7 @@ func runRepoAnalystWithBudget(t *testing.T, compiler, artifact string, maxInstru t.Helper() commandArgs := []string{ "-run-bc", - "-allow-caps", "process,filesystem", + "-allow-caps", "process:exec=/usr/bin/find **,filesystem", } if maxInstructions > 0 { commandArgs = append(commandArgs, "--max-instructions", fmt.Sprint(maxInstructions)) diff --git a/examples/swarm_argv_dogfood/swarm_argv_dogfood.howl b/examples/swarm_argv_dogfood/swarm_argv_dogfood.howl index 746bbd37..a78d3d99 100644 --- a/examples/swarm_argv_dogfood/swarm_argv_dogfood.howl +++ b/examples/swarm_argv_dogfood/swarm_argv_dogfood.howl @@ -1,6 +1,6 @@ (cli_app (print "swarm argv start") - (spawn_agent "Outer" (task "argv and stdin" + (spawn_agent "Outer" (grants "process") (task "argv and stdin" (print (cli_args 0)) (print (cli_args)) (print (read_line)) diff --git a/examples/swarm_nested_dogfood/swarm_nested_dogfood.howl b/examples/swarm_nested_dogfood/swarm_nested_dogfood.howl index db894e27..41e73229 100644 --- a/examples/swarm_nested_dogfood/swarm_nested_dogfood.howl +++ b/examples/swarm_nested_dogfood/swarm_nested_dogfood.howl @@ -1,6 +1,6 @@ (cli_app (print "swarm start") - (spawn_agent "Outer" (task "outer work" + (spawn_agent "Outer" (grants "process") (task "outer work" (print "outer before") (spawn_agent "Inner" (task "inner work" (print "inner body"))) diff --git a/internal/backend/gogen/exec_capability_test.go b/internal/backend/gogen/exec_capability_test.go index 9f429d62..c3662fce 100644 --- a/internal/backend/gogen/exec_capability_test.go +++ b/internal/backend/gogen/exec_capability_test.go @@ -13,14 +13,14 @@ import ( func TestGoExecRequiresGrantBeforeSpawn(t *testing.T) { marker := filepath.Join(t.TempDir(), "phase2b-secret-out") - source := `(cli_app (exec "touch" "` + marker + `"))` + source := `(cli_app (exec "/usr/bin/touch" "` + marker + `"))` root := parser.NewParser(lexer.NewLexer(source), "exec.howl").ParseExpression() checker.Check(root) code, _ := GenerateCode(root) - if strings.Contains(code, `exec.Command("touch"`) { + if strings.Contains(code, `exec.Command("/usr/bin/touch"`) { t.Fatalf("exec spawns at the call site:\n%s", code) } - if !strings.Contains(code, `howlFrameExec("touch", "`+marker+`")`) { + if !strings.Contains(code, `howlFrameExec("/usr/bin/touch", "`+marker+`")`) { t.Fatalf("exec is not mediated:\n%s", code) } helper := code[strings.Index(code, "func howlFrameExec"):] @@ -67,7 +67,10 @@ func TestGoExecRequiresGrantBeforeSpawn(t *testing.T) { otherOut, otherErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=environment") assertDenied("environment grant", otherOut, otherErr) - granted, grantErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process") + bareOut, bareErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process") + assertDenied("bare process grant (no allow-list)", bareOut, bareErr) + + granted, grantErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/touch **,process:exec=/usr/bin/printf **,process:exec=/usr/bin/true") if grantErr != "" { t.Fatalf("process grant failed: %s\n%s", grantErr, granted) } @@ -79,16 +82,16 @@ func TestGoExecRequiresGrantBeforeSpawn(t *testing.T) { func TestGoExecGrantedPrintsCapturedOutput(t *testing.T) { const source = `(cli_app - (let (value (exec "printf" "phase2b-exec-marker")) + (let (value (exec "/usr/bin/printf" "phase2b-exec-marker")) (print (bytes_to_string value))))` root := parser.NewParser(lexer.NewLexer(source), "exec.howl").ParseExpression() checker.Check(root) code, _ := GenerateCode(root) - if !strings.Contains(code, `howlFrameExec("printf", "phase2b-exec-marker")`) { + if !strings.Contains(code, `howlFrameExec("/usr/bin/printf", "phase2b-exec-marker")`) { t.Fatalf("let-bound exec is not mediated:\n%s", code) } rootDir := moduleRoot(t) - granted, grantErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process") + granted, grantErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/touch **,process:exec=/usr/bin/printf **,process:exec=/usr/bin/true") if grantErr != "" { t.Fatalf("process grant failed: %s\n%s", grantErr, granted) } @@ -98,31 +101,31 @@ func TestGoExecGrantedPrintsCapturedOutput(t *testing.T) { } func TestGoExecExpressionPositionIsMediated(t *testing.T) { - const source = `(cli_app (print (bytes_to_string (exec "printf" "phase2b-exec-marker"))))` + const source = `(cli_app (print (bytes_to_string (exec "/usr/bin/printf" "phase2b-exec-marker"))))` root := parser.NewParser(lexer.NewLexer(source), "exec.howl").ParseExpression() checker.Check(root) code, _ := GenerateCode(root) - if !strings.Contains(code, `howlFrameExec("printf", "phase2b-exec-marker")`) { + if !strings.Contains(code, `howlFrameExec("/usr/bin/printf", "phase2b-exec-marker")`) { t.Fatalf("expression-position exec is not mediated:\n%s", code) } - if strings.Contains(code, `exec.Command("printf"`) { + if strings.Contains(code, `exec.Command("/usr/bin/printf"`) { t.Fatalf("expression-position exec bypasses the grant:\n%s", code) } } func TestGoExecZeroArgsIsMediated(t *testing.T) { - const source = `(cli_app (exec "true") (print "zero-arg-ok"))` + const source = `(cli_app (exec "/usr/bin/true") (print "zero-arg-ok"))` root := parser.NewParser(lexer.NewLexer(source), "exec.howl").ParseExpression() checker.Check(root) code, _ := GenerateCode(root) - if !strings.Contains(code, `howlFrameExec("true")`) { + if !strings.Contains(code, `howlFrameExec("/usr/bin/true")`) { t.Fatalf("zero-arg exec is not mediated:\n%s", code) } - if strings.Contains(code, `exec.Command("true"`) { + if strings.Contains(code, `exec.Command("/usr/bin/true"`) { t.Fatalf("zero-arg exec spawns at the call site:\n%s", code) } rootDir := moduleRoot(t) - granted, grantErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process") + granted, grantErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/touch **,process:exec=/usr/bin/printf **,process:exec=/usr/bin/true") if grantErr != "" { t.Fatalf("process grant failed: %s\n%s", grantErr, granted) } @@ -130,3 +133,32 @@ func TestGoExecZeroArgsIsMediated(t *testing.T) { t.Fatalf("granted stdout = %q, want zero-arg-ok", granted) } } + +// Generated Go exec children start with an empty environment, and a "*" slot +// refuses flags, matching the bytecode runtime. +func TestGoExecEnvironmentAndFlagSlots(t *testing.T) { + const source = `(cli_app (print (bytes_to_string (exec "/usr/bin/env"))))` + root := parser.NewParser(lexer.NewLexer(source), "exec.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateCode(root) + rootDir := moduleRoot(t) + out, errOut := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/env", "FOO=leakme") + if errOut != "" || strings.Contains(out, "leakme") || strings.TrimSpace(out) != "" { + t.Fatalf("default env leaked: out %q err %q", out, errOut) + } + out, errOut = runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/env,process:env=FOO", "FOO=leakme") + if errOut != "" || strings.TrimSpace(out) != "FOO=leakme" { + t.Fatalf("granted env: out %q err %q", out, errOut) + } + + const flagSource = `(cli_app (print (bytes_to_string (exec "/usr/bin/printf" "%s" "-v"))))` + root = parser.NewParser(lexer.NewLexer(flagSource), "exec.howl").ParseExpression() + checker.Check(root) + code, _ = GenerateCode(root) + if _, errOut := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/printf %s *"); errOut == "" { + t.Fatal("a * slot matched a flag") + } + if _, errOut := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/printf ** %s"); errOut == "" { + t.Fatal("a non-final ** grant was accepted") + } +} diff --git a/internal/backend/gogen/gogen.go b/internal/backend/gogen/gogen.go index f764e85d..826e1e3c 100644 --- a/internal/backend/gogen/gogen.go +++ b/internal/backend/gogen/gogen.go @@ -505,11 +505,92 @@ func envHelperSource() string { // is spawned only after that grant is present, so a denial cannot run it // or include the command in the error. func execHelperSource() string { - return `func howlFrameExec(cmd string, args ...string) []byte { - if !howlFrameGrantHas("process") { + return `// howlFrameExecAllowed mirrors capability.Grants.AuthorizeExec: the program +// must be an absolute, clean, symlink-free path named by a process:exec= +// grant whose argument pattern matches argv exactly. A bare process grant +// never authorizes exec. +func howlFrameExecAllowed(cmd string, args []string) bool { + if !strings.HasPrefix(cmd, "/") { + return false + } + prefix := "" + for _, part := range strings.Split(cmd[1:], "/") { + if part == "" || part == "." || part == ".." { + return false + } + prefix += "/" + part + info, err := os.Lstat(prefix) + if err != nil || info.Mode()&os.ModeSymlink != 0 { + return false + } + } + for _, grant := range strings.Split(os.Getenv("HOWLFRAME_ALLOW_CAPS"), ",") { + spec, ok := strings.CutPrefix(strings.TrimSpace(grant), "process:exec=") + if !ok { + continue + } + fields := strings.Fields(spec) + if len(fields) == 0 || fields[0] != cmd { + continue + } + pats := fields[1:] + for i, p := range pats { + if p == "**" && i != len(pats)-1 { + panic("CAPABILITY_DENIED: capability denied: process: invalid process:exec grant (** must be last)") + } + } + matched := func() bool { + for i, p := range pats { + if p == "**" { + return true + } + if i >= len(args) || !howlFrameExecSlot(p, args[i]) { + return false + } + } + return len(args) == len(pats) + }() + if matched { + return true + } + } + return false +} + +// howlFrameExecSlot: "*" refuses empty and "-"-prefixed arguments (no flag +// injection); a leading backslash escapes a literal token. +func howlFrameExecSlot(p, arg string) bool { + if p == "*" { + return arg != "" && !strings.HasPrefix(arg, "-") + } + if strings.HasPrefix(p, "\\") { + return arg == p[1:] + } + return p == arg +} + +// howlFrameExecEnv passes only process:env=NAME variables; never the host env. +func howlFrameExecEnv() []string { + env := []string{} + for _, grant := range strings.Split(os.Getenv("HOWLFRAME_ALLOW_CAPS"), ",") { + name, ok := strings.CutPrefix(strings.TrimSpace(grant), "process:env=") + if !ok || name == "" || strings.ContainsAny(name, "= ") { + continue + } + if v, ok := os.LookupEnv(name); ok { + env = append(env, name+"="+v) + } + } + return env +} + +func howlFrameExec(cmd string, args ...string) []byte { + if !howlFrameExecAllowed(cmd, args) { panic("CAPABILITY_DENIED: capability denied: process") } - out, err := exec.Command(cmd, args...).CombinedOutput() + child := exec.Command(cmd, args...) + child.Env = howlFrameExecEnv() + out, err := child.CombinedOutput() if err != nil { panic(fmt.Sprintf("IO_ERROR: exec failed: %v", err)) } @@ -866,6 +947,11 @@ func GenerateCode(node *ast.Node) (string, string) { continue } + if head == "grants" { + // Handler grant attenuation is enforced only by the bytecode runtime. + ast.ReportError("UNSUPPORTED_ON_TARGET: http_server (grants ...) is enforced only by the bytecode runtime", handlerNode.Line, handlerNode.Column) + } + if head == "test" { if len(handlerNode.Children) < 3 { // ast.ReportError("test expects (test \"description\" body...)", handlerNode.Line, handlerNode.Column) @@ -1130,7 +1216,8 @@ func GenerateCode(node *ast.Node) (string, string) { if head == "route" { if len(handlerNode.Children) != 3 { - // ast.ReportError("route expects (route path handler)", handlerNode.Line, handlerNode.Column) + // Per-route grant attenuation is enforced only by the bytecode runtime. + ast.ReportError("UNSUPPORTED_ON_TARGET: route with (grants ...) is enforced only by the bytecode runtime", handlerNode.Line, handlerNode.Column) } pathNode := handlerNode.Children[1] if pathNode.Type != "STRING" { @@ -1169,7 +1256,8 @@ func GenerateCode(node *ast.Node) (string, string) { // ast.ReportError("middleware block can only contain routes", routeNode.Line, routeNode.Column) } if len(routeNode.Children) != 3 { - // ast.ReportError("route expects (route path handler)", routeNode.Line, routeNode.Column) + // Per-route grant attenuation is enforced only by the bytecode runtime. + ast.ReportError("UNSUPPORTED_ON_TARGET: route with (grants ...) is enforced only by the bytecode runtime", routeNode.Line, routeNode.Column) } pathNode := routeNode.Children[1] if pathNode.Type != "STRING" { @@ -1894,7 +1982,8 @@ func generateStatementRaw(node *ast.Node, reqVar string, depth int) string { } } else if head == "spawn_agent" { if len(node.Children) != 3 { - // ast.ReportError("spawn_agent expects (spawn_agent name task)", node.Line, node.Column) + // Child grant attenuation is enforced only by the bytecode runtime. + ast.ReportError("UNSUPPORTED_ON_TARGET: spawn_agent with (grants ...) is enforced only by the bytecode runtime", node.Line, node.Column) } agentNameStr := generateExpression(node.Children[1], reqVar, depth+1) taskDescStr := generateExpression(node.Children[2], reqVar, depth+1) diff --git a/internal/backend/javascript/exec_capability_test.go b/internal/backend/javascript/exec_capability_test.go index c17b7a17..0a893862 100644 --- a/internal/backend/javascript/exec_capability_test.go +++ b/internal/backend/javascript/exec_capability_test.go @@ -17,14 +17,14 @@ func TestJSExecRequiresGrantBeforeSpawn(t *testing.T) { t.Skip("node runtime not available") } marker := filepath.Join(t.TempDir(), "phase2b-secret-out") - source := `(web_app (exec "touch" "` + marker + `"))` + source := `(web_app (exec "/usr/bin/touch" "` + marker + `"))` root := parser.NewParser(lexer.NewLexer(source), "exec.howl").ParseExpression() checker.Check(root) code, _ := GenerateJSCode(root) if strings.Contains(code, `spawnSync("touch"`) || strings.Contains(code, "execFileSync") { t.Fatalf("exec spawns at the call site:\n%s", code) } - if !strings.Contains(code, `howlFrameExec("touch", ["`+marker+`"])`) { + if !strings.Contains(code, `howlFrameExec("/usr/bin/touch", ["`+marker+`"])`) { t.Fatalf("exec is not mediated:\n%s", code) } helper := code[strings.Index(code, "function howlFrameExec"):] @@ -63,7 +63,10 @@ func TestJSExecRequiresGrantBeforeSpawn(t *testing.T) { otherOut, otherErr, otherCode := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=environment") assertDenied("environment grant", otherOut, otherErr, otherCode) - grantedOut, grantedErr, grantedCode := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=process") + bareOut, bareErr, bareCode := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=process") + assertDenied("bare process grant (no allow-list)", bareOut, bareErr, bareCode) + + grantedOut, grantedErr, grantedCode := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/touch **,process:exec=/usr/bin/printf **,process:exec=/usr/bin/true") if grantedCode != 0 { t.Fatalf("process grant failed (%d): %s", grantedCode, grantedErr) } @@ -78,15 +81,15 @@ func TestJSExecGrantedPrintsCapturedOutput(t *testing.T) { t.Skip("node runtime not available") } const source = `(web_app - (let (value (exec "printf" "phase2b-exec-marker")) + (let (value (exec "/usr/bin/printf" "phase2b-exec-marker")) (print (bytes_to_string value))))` root := parser.NewParser(lexer.NewLexer(source), "exec.howl").ParseExpression() checker.Check(root) code, _ := GenerateJSCode(root) - if !strings.Contains(code, `howlFrameExec("printf", ["phase2b-exec-marker"])`) { + if !strings.Contains(code, `howlFrameExec("/usr/bin/printf", ["phase2b-exec-marker"])`) { t.Fatalf("let-bound exec is not mediated:\n%s", code) } - grantedOut, grantedErr, grantedCode := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=process") + grantedOut, grantedErr, grantedCode := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/touch **,process:exec=/usr/bin/printf **,process:exec=/usr/bin/true") if grantedCode != 0 { t.Fatalf("process grant failed (%d): %s", grantedCode, grantedErr) } @@ -96,14 +99,41 @@ func TestJSExecGrantedPrintsCapturedOutput(t *testing.T) { } func TestJSExecExpressionPositionIsMediated(t *testing.T) { - const source = `(web_app (print (bytes_to_string (exec "printf" "phase2b-exec-marker"))))` + const source = `(web_app (print (bytes_to_string (exec "/usr/bin/printf" "phase2b-exec-marker"))))` root := parser.NewParser(lexer.NewLexer(source), "exec.howl").ParseExpression() checker.Check(root) code, _ := GenerateJSCode(root) - if !strings.Contains(code, `howlFrameExec("printf", ["phase2b-exec-marker"])`) { + if !strings.Contains(code, `howlFrameExec("/usr/bin/printf", ["phase2b-exec-marker"])`) { t.Fatalf("expression-position exec is not mediated:\n%s", code) } if strings.Contains(code, `spawnSync("printf"`) { t.Fatalf("expression-position exec bypasses the grant:\n%s", code) } } + +// Generated JS exec children start with an empty environment, and a "*" slot +// refuses flags, matching the bytecode runtime. +func TestJSExecEnvironmentAndFlagSlots(t *testing.T) { + gen := func(source string) string { + root := parser.NewParser(lexer.NewLexer(source), "exec.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateJSCode(root) + return code + } + code := gen(`(web_app (print (bytes_to_string (exec "/usr/bin/env"))))`) + out, errOut, exit := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/env", "FOO=leakme") + if exit != 0 || strings.Contains(out, "leakme") || strings.TrimSpace(out) != "" { + t.Fatalf("default env leaked: %d out %q err %q", exit, out, errOut) + } + out, errOut, exit = runJS(t, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/env,process:env=FOO", "FOO=leakme") + if exit != 0 || strings.TrimSpace(out) != "FOO=leakme" { + t.Fatalf("granted env: %d out %q err %q", exit, out, errOut) + } + code = gen(`(web_app (print (bytes_to_string (exec "/usr/bin/printf" "%s" "-v"))))`) + if _, _, exit := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/printf %s *"); exit == 0 { + t.Fatal("a * slot matched a flag") + } + if _, _, exit := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=process:exec=/usr/bin/printf ** %s"); exit == 0 { + t.Fatal("a non-final ** grant was accepted") + } +} diff --git a/internal/backend/javascript/javascript.go b/internal/backend/javascript/javascript.go index e1faad98..443ff829 100644 --- a/internal/backend/javascript/javascript.go +++ b/internal/backend/javascript/javascript.go @@ -319,11 +319,69 @@ func envJSHelper() string { // put the command in the error. There is no shell. Stdout is returned // ahead of stderr, which matches CombinedOutput when only one stream writes. func execJSHelper() string { - return `function howlFrameExec(cmd, args) { - if (!howlFrameGrantHas("process")) { + return `function howlFrameExecAllowed(cmd, args) { + var fs = require("fs"); + if (typeof cmd !== "string" || cmd.charAt(0) !== "/") return false; + var prefix = ""; + var parts = cmd.slice(1).split("/"); + for (var i = 0; i < parts.length; i++) { + var part = parts[i]; + if (part === "" || part === "." || part === "..") return false; + prefix += "/" + part; + try { + if (fs.lstatSync(prefix).isSymbolicLink()) return false; + } catch (e) { + return false; + } + } + var grants = String(process.env.HOWLFRAME_ALLOW_CAPS || "").split(","); + for (var g = 0; g < grants.length; g++) { + var grant = grants[g].trim(); + if (grant.indexOf("process:exec=") !== 0) continue; + var fields = grant.slice("process:exec=".length).split(/\s+/).filter(function (f) { return f !== ""; }); + if (fields.length === 0 || fields[0] !== cmd) continue; + var pats = fields.slice(1); + for (var q = 0; q < pats.length - 1; q++) { + if (pats[q] === "**") throw new Error("CAPABILITY_DENIED: capability denied: process: invalid process:exec grant (** must be last)"); + } + var ok = true; + var rest = false; + for (var k = 0; k < pats.length; k++) { + if (pats[k] === "**") { rest = true; break; } + if (k >= args.length || !howlFrameExecSlot(pats[k], String(args[k]))) { ok = false; break; } + } + if (ok && !rest && args.length !== pats.length) ok = false; + if (ok) return true; + } + return false; +} + +// "*" refuses empty and "-"-prefixed arguments; a leading backslash escapes a literal. +function howlFrameExecSlot(p, arg) { + if (p === "*") return arg !== "" && arg.charAt(0) !== "-"; + if (p.charAt(0) === "\\") return arg === p.slice(1); + return p === arg; +} + +// Only process:env=NAME variables reach the child; never the host environment. +function howlFrameExecEnv() { + var env = {}; + var grants = String(process.env.HOWLFRAME_ALLOW_CAPS || "").split(","); + for (var i = 0; i < grants.length; i++) { + var grant = grants[i].trim(); + if (grant.indexOf("process:env=") !== 0) continue; + var name = grant.slice("process:env=".length); + if (name === "" || /[= ]/.test(name)) continue; + if (Object.prototype.hasOwnProperty.call(process.env, name)) env[name] = process.env[name]; + } + return env; +} + +function howlFrameExec(cmd, args) { + if (!howlFrameExecAllowed(cmd, args)) { throw new Error("CAPABILITY_DENIED: capability denied: process"); } - var result = require("child_process").spawnSync(cmd, args, { encoding: "utf8" }); + var result = require("child_process").spawnSync(cmd, args, { encoding: "utf8", env: howlFrameExecEnv() }); if (result.error) { throw new Error("IO_ERROR: exec failed: " + result.error.message); } @@ -1172,7 +1230,8 @@ func generateJSStatementRaw(node *ast.Node, reqVar string, depth int) string { return jsFetchCall(node, reqVar, depth) } else if head == "spawn_agent" { if len(node.Children) != 3 { - // ast.ReportError("spawn_agent expects (spawn_agent name task)", node.Line, node.Column) + // Child grant attenuation is enforced only by the bytecode runtime. + ast.ReportError("UNSUPPORTED_ON_TARGET: spawn_agent with (grants ...) is enforced only by the bytecode runtime", node.Line, node.Column) } agentNameStr := generateJSExpression(node.Children[1], reqVar, depth+1) taskDescStr := generateJSExpression(node.Children[2], reqVar, depth+1) diff --git a/internal/bytecode/bytecode.go b/internal/bytecode/bytecode.go index 77b57381..b0e71472 100644 --- a/internal/bytecode/bytecode.go +++ b/internal/bytecode/bytecode.go @@ -6,11 +6,13 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "github.com/howlcipher/howlframe/internal/capability" "github.com/howlcipher/howlframe/internal/construct" "github.com/howlcipher/howlframe/internal/ast" "github.com/howlcipher/howlframe/internal/httpreq" "strconv" + "strings" ) type BCInstruction struct { @@ -543,13 +545,21 @@ func (c *BCCompiler) compileNode(node *ast.Node) []BCInstruction { bodyVar := node.Children[2].Value insts = append(insts, BCInstruction{OpString: "PARSE_JSON", Op: OpParseJson, StringOperand: bodyVar}) case "spawn": - lambdaNode := node.Children[1] + lambdaNode := node.Children[len(node.Children)-1] + spawnGrants := "" + if len(node.Children) == 3 { + spawnGrants = compileSpawnGrants(node.Children[1]) + } bodyInsts := c.compileNode(lambdaNode.Children[2]) - insts = append(insts, BCInstruction{OpString: "SPAWN", Op: OpSpawn, IntOperand: int64(float64(len(bodyInsts)))}) + insts = append(insts, BCInstruction{OpString: "SPAWN", Op: OpSpawn, StringOperand: spawnGrants, IntOperand: int64(float64(len(bodyInsts)))}) insts = append(insts, bodyInsts...) case "spawn_agent": agentName := node.Children[1].Value - taskNode := node.Children[2] + taskNode := node.Children[len(node.Children)-1] + childGrants := "" + if len(node.Children) == 4 { + childGrants = compileSpawnGrants(node.Children[2]) + } var bodyInsts []BCInstruction if taskNode.Type == "List" && len(taskNode.Children) >= 2 && taskNode.Children[0].Value == "task" { insts = append(insts, BCInstruction{OpString: "TASK", Op: OpTask, StringOperand: taskNode.Children[1].Value}) @@ -561,8 +571,12 @@ func (c *BCCompiler) compileNode(node *ast.Node) []BCInstruction { // onto the stack; SPAWN_AGENT still type-checks a string task. insts = append(insts, c.compileNode(taskNode)...) } - insts = append(insts, BCInstruction{OpString: "SPAWN_AGENT", Op: OpSpawnAgent, StringOperand: agentName, IntOperand: int64(len(bodyInsts))}) + insts = append(insts, BCInstruction{OpString: "SPAWN_AGENT", Op: OpSpawnAgent, StringOperand: agentName, StringOperand2: childGrants, IntOperand: int64(len(bodyInsts))}) insts = append(insts, bodyInsts...) + case "grants": + // Only spawn_agent's (grants ...) argument is legal; it is read by + // compileSpawnGrants and never evaluated as an expression. + ast.ReportError("grants is only valid as the child grant list of spawn_agent, spawn or route", node.Line, node.Column) case "task": insts = append(insts, BCInstruction{OpString: "TASK", Op: OpTask, StringOperand: node.Children[1].Value}) case "res": @@ -597,15 +611,37 @@ func (c *BCCompiler) compileNode(node *ast.Node) []BCInstruction { case "http_server": portNode := node.Children[1] insts = append(insts, BCInstruction{OpString: "HTTP_SERVER_START", Op: OpHttpServerStart, StringOperand: portNode.Value}) + // (grants ...) directly under http_server is the default handler + // grant list for routes that do not declare their own. + serverGrants := "" + for _, child := range node.Children[2:] { + if child.Type == "List" && len(child.Children) > 0 && child.Children[0].Value == "grants" { + if serverGrants != "" { + ast.ReportError("http_server accepts at most one (grants ...) form", child.Line, child.Column) + } + serverGrants = compileSpawnGrants(child) + if serverGrants == "" { + serverGrants = "\n" + } + } + } for _, child := range node.Children[2:] { + if child.Type == "List" && len(child.Children) > 0 && child.Children[0].Value == "grants" { + continue + } if child.Type == "List" && len(child.Children) > 0 && child.Children[0].Value == "route" { path := child.Children[1].Value if _, err := httpreq.ParseRoute(path); err != nil { ast.ReportError(err.Error(), child.Children[1].Line, child.Children[1].Column) } - reqVar := child.Children[2].Children[1].Children[0].Value - bodyInsts := c.compileNode(child.Children[2].Children[2]) - insts = append(insts, BCInstruction{OpString: "HTTP_ROUTE", Op: OpHttpRoute, StringOperand: path, StringOperand2: reqVar, IntOperand: int64(float64(len(bodyInsts)))}) + handler := child.Children[len(child.Children)-1] + routeGrants := strings.TrimSpace(serverGrants) + if len(child.Children) == 4 { + routeGrants = compileSpawnGrants(child.Children[2]) + } + reqVar := handler.Children[1].Children[0].Value + bodyInsts := c.compileNode(handler.Children[2]) + insts = append(insts, BCInstruction{OpString: "HTTP_ROUTE", Op: OpHttpRoute, StringOperand: path, StringOperand2: reqVar, StringOperand3: routeGrants, IntOperand: int64(float64(len(bodyInsts)))}) insts = append(insts, bodyInsts...) } else { insts = append(insts, c.compileNode(child)...) @@ -939,3 +975,25 @@ func ShortCircuit(op string, left, right []BCInstruction) []BCInstruction { BCInstruction{OpString: "BINOP", Op: OpBinop, StringOperand: "or"}) return out } + +// compileSpawnGrants validates (grants "cap" ...) for spawn_agent, spawn and route. Every entry +// must be a string literal naming a valid grant; the runtime attenuates the +// list against the parent's grants. Entries are newline-joined, which no +// valid grant contains. +func compileSpawnGrants(node *ast.Node) string { + if node.Type != "List" || len(node.Children) == 0 || node.Children[0].Value != "grants" { + ast.ReportError("expected (grants \"cap\" ...)", node.Line, node.Column) + } + var out []string + for _, g := range node.Children[1:] { + if g.Type != "STRING" { + ast.ReportError("grants entries must be string literals", g.Line, g.Column) + } + parsed, err := capability.ParseGrant(g.Value) + if err != nil { + ast.ReportError(fmt.Sprintf("grants: %v", err), g.Line, g.Column) + } + out = append(out, string(parsed)) + } + return strings.Join(out, "\n") +} diff --git a/internal/capability/grants.go b/internal/capability/grants.go index 6b2af1e0..4e77a20b 100644 --- a/internal/capability/grants.go +++ b/internal/capability/grants.go @@ -15,6 +15,18 @@ func ParseGrant(raw string) (Capability, error) { return c, nil } } + if name, ok := strings.CutPrefix(raw, EnvGrantPrefix); ok { + if !validEnvName(name) { + return None, fmt.Errorf("unknown capability: %q", raw) + } + return Capability(raw), nil + } + if c, ok, err := parseExecGrant(raw); ok { + if err != nil { + return None, err + } + return c, nil + } for _, mode := range []string{"read", "write"} { prefix := "filesystem:" + mode + "=" if strings.HasPrefix(raw, prefix) && len(raw) > len(prefix) { @@ -33,7 +45,7 @@ type Grants []Capability func (g Grants) Has(c Capability) bool { for _, grant := range g { parsed, err := ParseGrant(string(grant)) - if err == nil && (parsed == c || c == Filesystem && strings.HasPrefix(string(parsed), "filesystem:")) { + if err == nil && (parsed == c || c == Filesystem && strings.HasPrefix(string(parsed), "filesystem:") || c == Process && strings.HasPrefix(string(parsed), ExecGrantPrefix)) { return true } } diff --git a/internal/capability/process.go b/internal/capability/process.go new file mode 100644 index 00000000..018a1a51 --- /dev/null +++ b/internal/capability/process.go @@ -0,0 +1,214 @@ +package capability + +import ( + "fmt" + "path/filepath" + "strings" +) + +// ExecGrantPrefix scopes the process capability to one exact program and an +// argument pattern. A coarse "process" grant authorizes spawn and spawn_agent +// only: it never authorizes exec. exec runs only when an exec grant matches the +// requested argv exactly, so a grant of "process" with no allow-list fails closed. +// +// Syntax: process:exec=[ ...] +// +// literal matches exactly one argument equal to the literal +// * matches exactly one argument with any value +// ** (last token only) matches zero or more remaining arguments +// +// No shell is involved and no PATH lookup happens: the requested program must +// be the same absolute, canonical (symlink-free) path that the grant names. +const ExecGrantPrefix = "process:exec=" + +// ExecDenied is the typed detail of a refused exec or attenuation request. +type ExecDenied struct { + Reason string +} + +func (e *ExecDenied) Error() string { return e.Reason } + +// parseExecSpec validates and normalizes an exec grant body. +func parseExecSpec(spec string) (string, []string, error) { + fields := strings.Fields(spec) + if len(fields) == 0 { + return "", nil, fmt.Errorf("process:exec grant needs a program path") + } + program := fields[0] + if !filepath.IsAbs(program) || filepath.Clean(program) != program { + return "", nil, fmt.Errorf("process:exec program must be an absolute, clean path: %q", program) + } + pats := fields[1:] + for i, p := range pats { + if p == "**" && i != len(pats)-1 { + return "", nil, fmt.Errorf("process:exec pattern ** must be the last token") + } + } + return program, pats, nil +} + +func parseExecGrant(raw string) (Capability, bool, error) { + if !strings.HasPrefix(raw, ExecGrantPrefix) { + return None, false, nil + } + program, pats, err := parseExecSpec(strings.TrimPrefix(raw, ExecGrantPrefix)) + if err != nil { + return None, true, err + } + return Capability(ExecGrantPrefix + strings.Join(append([]string{program}, pats...), " ")), true, nil +} + +func argvMatches(pats, args []string) bool { + for i, p := range pats { + if p == "**" { + return true + } + if i >= len(args) { + return false + } + if !slotMatches(p, args[i]) { + return false + } + } + return len(args) == len(pats) +} + +// slotMatches matches one argument. "*" refuses empty arguments and anything +// that starts with "-", so a wildcard slot cannot smuggle in a flag; flags +// must be granted as literals. A leading backslash escapes a token, so `\*` +// matches a literal "*" and `\-x` a literal "-x". +func slotMatches(p, arg string) bool { + if p == "*" { + return arg != "" && !strings.HasPrefix(arg, "-") + } + if strings.HasPrefix(p, "\\") { + return arg == p[1:] + } + return p == arg +} + +// EnvGrantPrefix names one host environment variable passed through to every +// exec child. exec children otherwise start with an empty environment. +const EnvGrantPrefix = "process:env=" + +func validEnvName(name string) bool { + if name == "" { + return false + } + for i, r := range name { + if !(r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9') { + return false + } + } + return true +} + +// ExecEnv returns the environment for an exec child: only variables named by +// process:env= grants and present in the host environment. It is never nil, +// so os/exec does not fall back to inheriting the host environment. +func (g Grants) ExecEnv(lookup func(string) (string, bool)) []string { + env := []string{} + seen := map[string]bool{} + for _, grant := range g { + name, ok := strings.CutPrefix(string(grant), EnvGrantPrefix) + if !ok || !validEnvName(name) || seen[name] { + continue + } + seen[name] = true + if v, ok := lookup(name); ok { + env = append(env, name+"="+v) + } + } + return env +} + +// HasCoarse reports whether the exact coarse grant c is present. +func (g Grants) HasCoarse(c Capability) bool { + for _, grant := range g { + if grant == c { + return true + } + } + return false +} + +// AuthorizeExec checks a requested argv against the exec allow-list. It +// returns the program path to execute (identical to the requested one) or a +// typed denial. Denial reasons never echo the program or its arguments, +// which can carry secrets; the receipt records only the program base name. +// Relative or bare names, non-canonical paths and paths that are +// or traverse a symlink are refused, so PATH lookup and link swaps cannot +// change which binary runs. +// The second result is the matched allow-list entry, for receipts. +func (g Grants) AuthorizeExec(program string, args []string) (string, string, error) { + if !filepath.IsAbs(program) { + return "", "", &ExecDenied{"process: exec program is not an absolute path (no PATH lookup)"} + } + if filepath.Clean(program) != program { + return "", "", &ExecDenied{"process: exec program is not a clean path"} + } + resolved, err := filepath.EvalSymlinks(program) + if err != nil { + return "", "", &ExecDenied{"process: exec program cannot be resolved"} + } + if resolved != program { + return "", "", &ExecDenied{"process: exec program is or traverses a symlink; grant and call its canonical path"} + } + for _, grant := range g { + parsed, ok, err := parseExecGrant(string(grant)) + if !ok || err != nil { + continue + } + gp, pats, _ := parseExecSpec(strings.TrimPrefix(string(parsed), ExecGrantPrefix)) + if gp == program && argvMatches(pats, args) { + return program, string(parsed), nil + } + } + return "", "", &ExecDenied{"process: exec argv is not in the allow-list"} +} + +// Attenuate returns the child grant set for an explicitly requested subset of +// the parent's grants. Nothing is inherited implicitly: an empty request yields +// no grants. Every requested grant must be covered by the parent, otherwise +// the whole request is refused. +func Attenuate(parent Grants, requested []string) (Grants, error) { + child := Grants{} + for _, raw := range requested { + g, err := ParseGrant(raw) + if err != nil { + return nil, &ExecDenied{"child grant is invalid"} + } + if !parent.covers(g) { + // Never echo the requested grant: it can name paths or programs. + return nil, &ExecDenied{"child requested a grant the parent does not hold"} + } + child = append(child, g) + } + return child, nil +} + +func (g Grants) covers(c Capability) bool { + raw := string(c) + for _, mode := range []string{"read", "write"} { + prefix := "filesystem:" + mode + "=" + if strings.HasPrefix(raw, prefix) { + if g.HasCoarse(Filesystem) { + return true + } + root := strings.TrimPrefix(raw, prefix) + for _, pg := range g { + if strings.HasPrefix(string(pg), prefix) && contained(strings.TrimPrefix(string(pg), prefix), root) { + return true + } + } + return false + } + } + // Coarse names and exec grants must be held verbatim (normalized). + for _, pg := range g { + if parsed, err := ParseGrant(string(pg)); err == nil && parsed == c { + return true + } + } + return false +} diff --git a/internal/capability/process_test.go b/internal/capability/process_test.go new file mode 100644 index 00000000..45d01319 --- /dev/null +++ b/internal/capability/process_test.go @@ -0,0 +1,120 @@ +package capability + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseExecGrant(t *testing.T) { + for raw, want := range map[string]string{ + "process:exec=/usr/bin/printf": "process:exec=/usr/bin/printf", + "process:exec=/usr/bin/printf %s **": "process:exec=/usr/bin/printf %s **", + "process:exec=/usr/bin/printf * literal": "process:exec=/usr/bin/printf * literal", + } { + got, err := ParseGrant(raw) + if err != nil || string(got) != want { + t.Fatalf("ParseGrant(%q) = %q, %v; want %q", raw, got, err, want) + } + } + for _, raw := range []string{"process:exec=", "process:exec=printf", "process:exec=./printf", "process:exec=/usr/bin/../bin/printf", "process:exec=/usr/bin/printf ** x"} { + if _, err := ParseGrant(raw); err == nil { + t.Fatalf("ParseGrant(%q) accepted", raw) + } + } +} + +func TestAuthorizeExec(t *testing.T) { + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + prog := filepath.Join(dir, "tool") + if err := os.WriteFile(prog, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(prog, link); err != nil { + t.Skip(err) + } + g := Grants{Process, Capability("process:exec=" + prog + " a * **"), Capability("process:exec=" + link)} + allow := []struct { + program string + args []string + }{ + {prog, []string{"a", "b"}}, {prog, []string{"a", "b", "c", "d"}}, + } + for _, c := range allow { + if _, _, err := g.AuthorizeExec(c.program, c.args); err != nil { + t.Fatalf("AuthorizeExec(%q, %q) = %v", c.program, c.args, err) + } + } + deny := []struct { + program string + args []string + }{ + {prog, nil}, {prog, []string{"a"}}, {prog, []string{"x", "b"}}, + {"tool", []string{"a", "b"}}, {link, nil}, {dir + "/./tool", []string{"a", "b"}}, + } + for _, c := range deny { + if _, _, err := g.AuthorizeExec(c.program, c.args); err == nil { + t.Fatalf("AuthorizeExec(%q, %q) allowed", c.program, c.args) + } + } + if _, _, err := (Grants{Process}).AuthorizeExec(prog, nil); err == nil { + t.Fatal("bare process authorized exec") + } +} + +func TestAttenuate(t *testing.T) { + parent := Grants{Process, Environment, "filesystem:read=/srv/data", "process:exec=/usr/bin/printf **"} + ok := [][]string{nil, {}, {"process"}, {"environment"}, {"filesystem:read=/srv/data/sub"}, {"process:exec=/usr/bin/printf **"}} + for _, req := range ok { + if _, err := Attenuate(parent, req); err != nil { + t.Fatalf("Attenuate(%q) = %v", req, err) + } + } + if child, _ := Attenuate(parent, nil); len(child) != 0 { + t.Fatalf("default child grants = %v, want none", child) + } + bad := [][]string{{"network"}, {"filesystem"}, {"filesystem:read=/srv"}, {"filesystem:write=/srv/data"}, {"process:exec=/usr/bin/printf"}, {"process:exec=/usr/bin/true **"}, {"bogus"}, {"environment", "database"}} + for _, req := range bad { + if _, err := Attenuate(parent, req); err == nil { + t.Fatalf("Attenuate(%q) allowed escalation", req) + } + } +} + +func TestExecSlotsAndEnv(t *testing.T) { + for _, c := range []struct { + pat, arg string + ok bool + }{ + {"*", "x", true}, {"*", "-x", false}, {"*", "--", false}, {"*", "", false}, + {"-x", "-x", true}, {`\*`, "*", true}, {`\*`, "x", false}, {`\-v`, "-v", true}, + } { + if got := slotMatches(c.pat, c.arg); got != c.ok { + t.Fatalf("slotMatches(%q, %q) = %v", c.pat, c.arg, got) + } + } + if _, err := ParseGrant("process:env=FOO_1"); err != nil { + t.Fatal(err) + } + for _, bad := range []string{"process:env=", "process:env=1A", "process:env=A=B"} { + if _, err := ParseGrant(bad); err == nil { + t.Fatalf("ParseGrant(%q) accepted", bad) + } + } + env := Grants{"process:env=FOO", "process:env=MISSING"}.ExecEnv(func(k string) (string, bool) { + if k == "FOO" { + return "v", true + } + return "", false + }) + if len(env) != 1 || env[0] != "FOO=v" { + t.Fatalf("ExecEnv = %q", env) + } + if env := (Grants{Process}).ExecEnv(func(string) (string, bool) { return "x", true }); env == nil || len(env) != 0 { + t.Fatalf("default ExecEnv = %#v, want empty non-nil", env) + } +} diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 54609db0..058c6319 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -314,7 +314,7 @@ func checkJSStatement(node *ast.Node, depth int) { } } else if head == "spawn_agent" { if len(node.Children) != 3 { - ast.ReportError("spawn_agent expects (spawn_agent name task)", node.Line, node.Column) + ast.ReportError("UNSUPPORTED_ON_TARGET: spawn_agent with (grants ...) is enforced only by the bytecode runtime; expects (spawn_agent name task)", node.Line, node.Column) } checkJSStatement(node.Children[1], depth+1) checkJSStatement(node.Children[2], depth+1) @@ -453,14 +453,17 @@ func checkGoAppHandler(handlerNode *ast.Node, isCliApp bool) { bodyNode := handlerNode.Children[len(handlerNode.Children)-1] checkGoStatement(bodyNode, 0) case "route": - if len(handlerNode.Children) != 3 { + // (route path handler) or (route path (grants "cap" ...) handler); the + // grant list is enforced by the bytecode runtime only. + grantsForm := len(handlerNode.Children) == 4 && handlerNode.Children[2].Type == "List" && len(handlerNode.Children[2].Children) > 0 && handlerNode.Children[2].Children[0].Value == "grants" + if len(handlerNode.Children) != 3 && !grantsForm { ast.ReportError("route expects (route path handler)", handlerNode.Line, handlerNode.Column) } pathNode := handlerNode.Children[1] if pathNode.Type != "STRING" { ast.ReportError("route path must be a string", pathNode.Line, pathNode.Column) } - lambdaNode := handlerNode.Children[2] + lambdaNode := handlerNode.Children[len(handlerNode.Children)-1] if lambdaNode.Type != "List" || len(lambdaNode.Children) != 3 || lambdaNode.Children[0].Value != "lambda" { ast.ReportError("route expects a lambda handler (lambda (req) body)", lambdaNode.Line, lambdaNode.Column) } @@ -494,6 +497,9 @@ func checkGoAppHandler(handlerNode *ast.Node, isCliApp bool) { } checkGoAppHandler(routeNode, isCliApp) } + case "grants": + // Server-wide default handler grants; compiled by the bytecode + // compiler, rejected by the Go/JS backends. case "store_open", "store_put", "store_delete", "do", "let": // Startup forms compiled before HTTP_SERVER_SERVE (shared process store init). checkGoStatement(handlerNode, 0) @@ -588,6 +594,9 @@ func checkGoStatement(node *ast.Node, depth int) { successBodyCode := node.Children[3] checkGoStatement(successBodyCode, depth+1) case "spawn": + if len(node.Children) != 2 { + ast.ReportError("UNSUPPORTED_ON_TARGET: spawn with (grants ...) is enforced only by the bytecode runtime", node.Line, node.Column) + } lambdaNode := node.Children[1] checkGoStatement(lambdaNode.Children[2], depth+1) case "call": diff --git a/internal/checker/types.go b/internal/checker/types.go index a7d883e7..522d153c 100644 --- a/internal/checker/types.go +++ b/internal/checker/types.go @@ -538,8 +538,8 @@ func (a *Analysis) inferList(node *ast.Node, env typeEnv) ast.TypeInfo { a.infer(node.Children[3], loopEnv) return ast.Layout(ast.Void) case "spawn": - if len(node.Children) >= 2 && node.Children[1].Type == "List" && len(node.Children[1].Children) >= 3 { - a.infer(node.Children[1].Children[2], cloneEnv(env)) + if lambda := node.Children[len(node.Children)-1]; len(node.Children) >= 2 && lambda.Type == "List" && len(lambda.Children) >= 3 { + a.infer(lambda.Children[2], cloneEnv(env)) } return ast.Layout(ast.Void) case "match": diff --git a/internal/construct/arity.go b/internal/construct/arity.go index dabed866..3a1272cd 100644 --- a/internal/construct/arity.go +++ b/internal/construct/arity.go @@ -48,7 +48,7 @@ var arities = map[string]Arity{ "req_path": {2, 2}, "req_query": {2, 2}, "res": {3, 3}, "res_header": {2, 2}, "res_json": {2, 2}, "return": {1, 1}, "reverse": {1, 1}, "schema_bridge": {2, 2}, "set": {2, 2}, "sget": {2, 2}, "sinc": {2, 2}, "sleep": {1, 1}, "sort": {1, 1}, - "spawn": {1, 1}, "spawn_agent": {2, 2}, "sput": {3, 3}, "sql_query": {2, Variadic}, + "spawn": {1, 2}, "spawn_agent": {2, 3}, "sput": {3, 3}, "sql_query": {2, Variadic}, "starts_with": {2, 2}, "stderr": {1, 1}, "store_delete": {2, 2}, "store_get": {2, 2}, "store_keys": {1, 1}, "store_open": {2, 2}, "store_put": {3, 3}, "str": {1, Variadic}, "str_contains": {2, 2}, "str_join": {2, 2}, "str_len": {1, 1}, "str_replace": {3, 3}, @@ -64,7 +64,7 @@ var arities = map[string]Arity{ "ltrim": {1, 1}, "to_base": {2, 2}, // Records and JSON sugar (heldout-v3 records slice). "rec": {0, Variadic}, "get": {2, Variadic}, "put": {3, Variadic}, "read_json": {1, 1}, "write_json": {2, 2}, - "pluck": {2, 2}, "select": {2, Variadic}, "group_by": {3, 3}, "merge": {1, Variadic}, + "pluck": {2, 2}, "select": {2, Variadic}, "grants": {0, Variadic}, "group_by": {3, 3}, "merge": {1, Variadic}, } // arityExempt lists registry constructs with no arity entry, each with the diff --git a/internal/construct/construct.go b/internal/construct/construct.go index 9ea90456..9e43fcc2 100644 --- a/internal/construct/construct.go +++ b/internal/construct/construct.go @@ -127,6 +127,7 @@ var table = []Entry{ {Name: "fuzzy_cast", Support: Unsupported, Note: "LLM struct coercion; no opcode, Go backend only"}, {Name: "get", Support: CompileTimeOnly, Note: "records sugar; (get m k ...) nested path read via map_get/list_get by ast.Desugar (pure)"}, {Name: "go_import", Support: Unsupported, Note: "Go-backend-only directive; the standalone VM cannot honour a Go package dependency"}, + {Name: "grants", Support: Supported, Opaque: true, Note: "child grant list (S9 attenuation) for spawn_agent, spawn and route; string literals compiled into SPAWN_AGENT string_operand_2, SPAWN string_operand, HTTP_ROUTE string_operand_3; never evaluated"}, {Name: "group_by", Support: CompileTimeOnly, Note: "records sugar; (group_by x lst key) via for/nz/append/map_set by ast.Desugar (pure)"}, {Name: "html_escape", Support: Supported, Note: "HTML_ESCAPE; encodes &, <, >, \", and ' with no capability"}, {Name: "http_body", Support: CompileTimeOnly, Note: "density sugar; (http_body r) → bytes_to_string of map_get body by ast.Desugar"}, diff --git a/internal/ir/ir.go b/internal/ir/ir.go index 4e998838..8df82510 100644 --- a/internal/ir/ir.go +++ b/internal/ir/ir.go @@ -108,10 +108,15 @@ func ValidateShared(node *ast.Node) (string, bool) { } return "", true case "spawn": - if reason := requireArguments(node, 1); reason != "" { + // (spawn (lambda () body)) or (spawn (grants "cap" ...) (lambda () body)). + if len(node.Children) == 3 { + if g := node.Children[1]; g.Type != "List" || len(g.Children) == 0 || g.Children[0].Value != "grants" { + return "spawn expects (spawn (grants ...) lambda)", true + } + } else if reason := requireArguments(node, 1); reason != "" { return reason, true } - lambda := node.Children[1] + lambda := node.Children[len(node.Children)-1] if lambda.Type != "List" || len(lambda.Children) != 3 || lambda.Children[0].Value != "lambda" { return "spawn expects a lambda", true } diff --git a/internal/vm/ast_interp_cap_test.go b/internal/vm/ast_interp_cap_test.go index e4d49fa4..2e8f67a3 100644 --- a/internal/vm/ast_interp_cap_test.go +++ b/internal/vm/ast_interp_cap_test.go @@ -90,11 +90,11 @@ func TestInterpretExecDeniedBeforeSpawn(t *testing.T) { } func TestInterpretExecGrantedRuns(t *testing.T) { - source := `(cli_app (print (bytes_to_string (exec "printf" "phase2b-exec-marker"))))` + source := `(cli_app (print (bytes_to_string (exec "/usr/bin/printf" "phase2b-exec-marker"))))` node, _ := parseAndCompile(t, source) var stdout, stderr bytes.Buffer - allowed := []capability.Capability{capability.Process} + allowed := []capability.Capability{capability.Process, "process:exec=/usr/bin/printf phase2b-exec-marker"} exitCode := Interpret(node, nil, allowed, strings.NewReader(""), &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected Interpret to run exec with process, got exit %d; stderr=%s", exitCode, stderr.String()) diff --git a/internal/vm/hfir_equivalence_test.go b/internal/vm/hfir_equivalence_test.go index 569874c4..2a77dd62 100644 --- a/internal/vm/hfir_equivalence_test.go +++ b/internal/vm/hfir_equivalence_test.go @@ -1316,8 +1316,8 @@ func TestHFIRBytecodeExecFixture(t *testing.T) { if len(node.ControlEdges) != 0 || len(node.DataInputs) != 2 || node.DataInputs[0].Name != "cmd" || node.DataInputs[1].Name != "arg" { t.Fatalf("exec %s edges %#v control %v", node.ID, node.DataInputs, node.ControlEdges) } - if got := fsPathValue(t, graph, node); got != "printf" { - t.Fatalf("exec command = %q, want printf", got) + if got := fsPathValue(t, graph, node); got != "/usr/bin/printf" { + t.Fatalf("exec command = %q, want /usr/bin/printf", got) } arg := graph.NodeByID(node.DataInputs[1].SourceNode) if arg == nil || arg.Kind != "const" || arg.Value != "phase2b-exec-marker" { @@ -1346,16 +1346,16 @@ func TestHFIRBytecodeExecFixture(t *testing.T) { if got, want := execOperandShapes(legacy), execOperandShapes(direct); !reflect.DeepEqual(got, want) { t.Fatalf("EXEC operands\nAST %#v\nHFIR %#v", got, want) } - if !reflect.DeepEqual(execOperandShapes(direct), []string{"main\x00printf\x00phase2b-exec-marker"}) { + if !reflect.DeepEqual(execOperandShapes(direct), []string{"main\x00/usr/bin/printf\x00phase2b-exec-marker"}) { t.Fatalf("EXEC operands = %#v", execOperandShapes(direct)) } legacyDenied := runBytecodeOutcome(legacy, "", nil) directDenied := runBytecodeOutcome(direct, "", nil) requireSameBytecodeOutcome(t, legacyDenied, directDenied) - requireProcessDenial(t, legacyDenied, "printf", "phase2b-exec-marker") + requireProcessDenial(t, legacyDenied, "/usr/bin/printf", "phase2b-exec-marker") - caps := []capability.Capability{capability.Process} + caps := []capability.Capability{printfExecGrant} legacyGranted := runBytecodeOutcome(legacy, "", caps) directGranted := runBytecodeOutcome(direct, "", caps) requireSameBytecodeOutcome(t, legacyGranted, directGranted) @@ -1370,9 +1370,9 @@ func TestHFIRBytecodeExecFixture(t *testing.T) { // printf format plus two values, so a swapped operand changes the text. func TestHFIRBytecodeExecOperandOrder(t *testing.T) { source := `(cli_app - (print (bytes_to_string (exec "true"))) - (print (bytes_to_string (exec "printf" "only"))) - (print (bytes_to_string (exec "printf" "%s:%s" "left" "right"))))` + (print (bytes_to_string (exec "/usr/bin/true"))) + (print (bytes_to_string (exec "/usr/bin/printf" "only"))) + (print (bytes_to_string (exec "/usr/bin/printf" "%s:%s" "left" "right"))))` root, graph := checkedHFIRGraph(t, source) var counts []int for _, node := range graph.Nodes { @@ -1400,14 +1400,14 @@ func TestHFIRBytecodeExecOperandOrder(t *testing.T) { } direct = roundTripArtifact(t, direct) wantShapes := []string{ - "main\x00true", - "main\x00printf\x00only", - "main\x00printf\x00%s:%s\x00left\x00right", + "main\x00/usr/bin/true", + "main\x00/usr/bin/printf\x00only", + "main\x00/usr/bin/printf\x00%s:%s\x00left\x00right", } if got, want := execOperandShapes(legacy), execOperandShapes(direct); !reflect.DeepEqual(got, want) || !reflect.DeepEqual(got, wantShapes) { t.Fatalf("EXEC operands\nAST %#v\nHFIR %#v\nwant %#v", got, want, wantShapes) } - caps := []capability.Capability{capability.Process} + caps := []capability.Capability{printfExecGrant, "process:exec=/usr/bin/true"} legacyOutcome := runBytecodeOutcome(legacy, "", caps) directOutcome := runBytecodeOutcome(direct, "", caps) requireSameBytecodeOutcome(t, legacyOutcome, directOutcome) @@ -1449,9 +1449,9 @@ func TestHFIRBytecodeNestedExecFixture(t *testing.T) { legacyDenied := runBytecodeOutcome(legacy, "", nil) directDenied := runBytecodeOutcome(direct, "", nil) requireSameBytecodeOutcome(t, legacyDenied, directDenied) - requireProcessDenial(t, legacyDenied, "printf", "phase2b-exec-marker") + requireProcessDenial(t, legacyDenied, "/usr/bin/printf", "phase2b-exec-marker") - caps := []capability.Capability{capability.Process} + caps := []capability.Capability{printfExecGrant} legacyGranted := runBytecodeOutcome(legacy, "", caps) directGranted := runBytecodeOutcome(direct, "", caps) requireSameBytecodeOutcome(t, legacyGranted, directGranted) @@ -1783,7 +1783,7 @@ func TestHFIRBytecodeNestedMultiEffectFixture(t *testing.T) { } const partialStdout = "phase1-token\nmulti-read-marker\nphase2b-exec-marker\n" - partialCaps := []capability.Capability{capability.Environment, capability.Filesystem, capability.Process} + partialCaps := []capability.Capability{capability.Environment, capability.Filesystem, printfExecGrant} legacyPartial, legacyPartialFS := runOne(legacy, partialCaps) directPartial, directPartialFS := runOne(direct, partialCaps) requireSameBytecodeOutcome(t, legacyPartial, directPartial) @@ -1804,8 +1804,9 @@ func TestHFIRBytecodeNestedMultiEffectFixture(t *testing.T) { } const wantStdout = "phase1-token\nmulti-read-marker\nphase2b-exec-marker\n" + abiFetchMarker + "\nL a 0\nL b 0\nkept 2\nphase1-token\nmulti-kept-read\nphase2b-exec-kept\n" + abiFetchMarker + "\nresult 2\nmiss\nphase1-token\nmulti-miss-read\nphase2b-exec-miss\n" + abiFetchMarker + "\nempty 0\n" - legacyGranted, legacyFS := runOne(legacy, wantCaps) - directGranted, directFS := runOne(direct, wantCaps) + grantedCaps := []capability.Capability{capability.Environment, capability.Filesystem, capability.Network, printfExecGrant} + legacyGranted, legacyFS := runOne(legacy, grantedCaps) + directGranted, directFS := runOne(direct, grantedCaps) requireSameBytecodeOutcome(t, legacyGranted, directGranted) if legacyGranted.stdout != wantStdout || legacyGranted.stderr != "" || legacyGranted.exitCode != 0 || legacyGranted.vmError != nil || legacyGranted.panicVal != nil { t.Fatalf("granted outcome %#v", legacyGranted) @@ -2136,8 +2137,8 @@ func assertNestedExecShape(t *testing.T, graph *hfir.Graph) { if len(node.ControlEdges) != 0 || len(node.DataInputs) != 2 || node.DataInputs[0].Name != "cmd" || node.DataInputs[1].Name != "arg" { t.Fatalf("exec %s edges %#v control %v", node.ID, node.DataInputs, node.ControlEdges) } - if got := fsPathValue(t, graph, node); got != "printf" { - t.Fatalf("exec command = %q, want printf", got) + if got := fsPathValue(t, graph, node); got != "/usr/bin/printf" { + t.Fatalf("exec command = %q, want /usr/bin/printf", got) } arg := graph.NodeByID(node.DataInputs[1].SourceNode) if arg == nil || arg.Kind != "const" || arg.LiteralKind != "STRING" || arg.Value == "" { @@ -2384,8 +2385,8 @@ func assertNestedMultiEffectShape(t *testing.T, graph *hfir.Graph, prefix string if len(node.ControlEdges) != 0 || len(node.DataInputs) != 2 || node.DataInputs[0].Name != "cmd" || node.DataInputs[1].Name != "arg" { t.Fatalf("exec %s edges %#v control %v", node.ID, node.DataInputs, node.ControlEdges) } - if got := fsPathValue(t, graph, node); got != "printf" { - t.Fatalf("exec command = %q, want printf", got) + if got := fsPathValue(t, graph, node); got != "/usr/bin/printf" { + t.Fatalf("exec command = %q, want /usr/bin/printf", got) } arg := graph.NodeByID(node.DataInputs[1].SourceNode) if arg == nil || arg.Kind != "const" || arg.Value == "" { @@ -2677,6 +2678,10 @@ func serveABIFetchFixture(t *testing.T) (hits, bodies *atomic.Int64) { return hits, bodies } +// printfExecGrant is the exact-argv allow-list entry the exec fixtures need; +// a bare process grant never authorizes exec. +const printfExecGrant = capability.Capability("process:exec=/usr/bin/printf **") + func requireProcessDenial(t *testing.T, outcome bytecodeOutcome, command, marker string) { t.Helper() if outcome.vmError == nil || outcome.vmError.Code != "CAPABILITY_DENIED" || outcome.vmError.Opcode != "EXEC" || outcome.vmError.Message != "capability denied: process" { diff --git a/internal/vm/receipt.go b/internal/vm/receipt.go index 36ba6670..dc710755 100644 --- a/internal/vm/receipt.go +++ b/internal/vm/receipt.go @@ -40,6 +40,8 @@ type ReceiptEffect struct { Capability string `json:"capability"` TargetSummary string `json:"target_summary"` Decision string `json:"decision"` + // Grant is the matched allow-list entry for an allowed exec. + Grant string `json:"grant,omitempty"` } type ReceiptExit struct { Status string `json:"status"` @@ -82,6 +84,10 @@ func (r *receiptRecorder) finish(instructions int) { r.finalized = true } func (vm *BCVM) recordEffect(cap capability.Capability, op bytecode.Opcode, decision string) { + vm.recordEffectGrant(cap, op, decision, "") +} + +func (vm *BCVM) recordEffectGrant(cap capability.Capability, op bytecode.Opcode, decision, grant string) { if vm.receipt == nil { return } @@ -105,7 +111,7 @@ func (vm *BCVM) recordEffect(cap capability.Capability, op bytecode.Opcode, deci r.truncated = true return } - r.effects = append(r.effects, ReceiptEffect{bytecode.Registry[op].Name, string(cap), d.summary, decision}) + r.effects = append(r.effects, ReceiptEffect{bytecode.Registry[op].Name, string(cap), d.summary, decision, grant}) } // RunBytecodeWithReceipt returns normally on every VM outcome. Diagnostics match diff --git a/internal/vm/receipt_test.go b/internal/vm/receipt_test.go index 0d29c6fa..8aa93c12 100644 --- a/internal/vm/receipt_test.go +++ b/internal/vm/receipt_test.go @@ -55,7 +55,7 @@ func testReceiptFetch(t *testing.T, origin string) { t.Helper() address := strings.Replace(origin, "http://", "http://user:pw@", 1) + "/path?token=secret#frag" r, raw, _ := receiptRun(t, fmt.Sprintf(`(cli_app (fetch %q "GET"))`, address), DefaultExecutionPolicy(), capability.Network) - if r.Exit.Code != 0 || len(r.Effects) != 1 || r.Effects[0] != (ReceiptEffect{"FETCH", "network", origin, "allowed"}) { + if r.Exit.Code != 0 || len(r.Effects) != 1 || r.Effects[0] != (ReceiptEffect{"FETCH", "network", origin, "allowed", ""}) { t.Fatalf("receipt: %s", raw) } for _, secret := range []string{"secret", "token", "pw", "/path", "user:"} { @@ -66,7 +66,7 @@ func testReceiptFetch(t *testing.T, origin string) { } func TestReceiptEnvironmentAndExec(t *testing.T) { t.Setenv("HF_RECEIPT_TEST", "secret-value-123") - r, raw, _ := receiptRun(t, `(cli_app (env "HF_RECEIPT_TEST") (exec "/bin/echo" "secret-argument-456"))`, DefaultExecutionPolicy(), capability.Environment, capability.Process, capability.Environment) + r, raw, _ := receiptRun(t, `(cli_app (env "HF_RECEIPT_TEST") (exec "/usr/bin/echo" "secret-argument-456"))`, DefaultExecutionPolicy(), capability.Environment, capability.Capability("process:exec=/usr/bin/echo *"), capability.Environment) if r.Exit.Code != 0 || len(r.Effects) != 2 || r.Effects[0].TargetSummary != "HF_RECEIPT_TEST" || r.Effects[1].TargetSummary != "echo" || len(r.Grant) != 2 { t.Fatalf("receipt: %s", raw) } @@ -90,7 +90,7 @@ func TestReceiptDeniedAndLimit(t *testing.T) { } func TestReceiptSpawnAgent(t *testing.T) { t.Setenv("HF_RECEIPT_CHILD", "child-secret") - r, raw, _ := receiptRun(t, `(cli_app (spawn_agent "child" (task "work" (env "HF_RECEIPT_CHILD"))))`, DefaultExecutionPolicy(), capability.Process, capability.Environment) + r, raw, _ := receiptRun(t, `(cli_app (spawn_agent "child" (grants "environment") (task "work" (env "HF_RECEIPT_CHILD"))))`, DefaultExecutionPolicy(), capability.Process, capability.Environment) if len(r.Effects) != 2 || r.Effects[0].Op != "SPAWN_AGENT" || r.Effects[1].Op != "ENV" || r.Effects[1].TargetSummary != "HF_RECEIPT_CHILD" || r.InstructionsUsed < 4 { t.Fatal(raw) } diff --git a/internal/vm/resource_limits_test.go b/internal/vm/resource_limits_test.go index 9c2358db..cce9d9bb 100644 --- a/internal/vm/resource_limits_test.go +++ b/internal/vm/resource_limits_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" "time" @@ -18,9 +19,16 @@ import ( func runResourceTest(t *testing.T, source string, policy ExecutionPolicy) *bytecode.RuntimeFailure { t.Helper() + // exec needs an exact allow-list entry for a canonical (symlink-free) + // program path; /bin/sh is commonly a symlink, so run its target. + caps := []capability.Capability{capability.Network, capability.Filesystem, capability.Process} + if sh, err := filepath.EvalSymlinks("/bin/sh"); err == nil && strings.Contains(source, `"/bin/sh"`) { + source = strings.ReplaceAll(source, `"/bin/sh"`, fmt.Sprintf("%q", sh)) + caps = append(caps, capability.Capability("process:exec="+sh+" -c *")) + } _, prog := parseAndCompile(t, source) var out, stderr bytes.Buffer - ev := RunBytecodeWithEvidence(prog, nil, policy, []capability.Capability{capability.Network, capability.Filesystem, capability.Process}, nil, &out, &stderr, 0) + ev := RunBytecodeWithEvidence(prog, nil, policy, caps, nil, &out, &stderr, 0) if ev.ExitCode != 0 { t.Fatalf("unexpected exit %d: %s", ev.ExitCode, &stderr) } diff --git a/internal/vm/spawn_agent_test.go b/internal/vm/spawn_agent_test.go index 95327e16..cfed0dc9 100644 --- a/internal/vm/spawn_agent_test.go +++ b/internal/vm/spawn_agent_test.go @@ -10,8 +10,19 @@ import ( "github.com/howlcipher/howlframe/internal/capability" ) +// spawnTestBody delegates "process" to a child that itself spawns, because a +// child receives only the grants its spawn_agent lists (S9 attenuation). func spawnTestBody(name string, body ...bytecode.BCInstruction) []bytecode.BCInstruction { - return append([]bytecode.BCInstruction{{Op: bytecode.OpTask, StringOperand: "work"}, {Op: bytecode.OpSpawnAgent, StringOperand: name, IntOperand: int64(len(body))}}, body...) + grants := "" + for _, inst := range body { + if inst.Op == bytecode.OpSpawnAgent { + grants = "process" + } + } + return spawnTestBodyGrants(name, grants, body...) +} +func spawnTestBodyGrants(name, grants string, body ...bytecode.BCInstruction) []bytecode.BCInstruction { + return append([]bytecode.BCInstruction{{Op: bytecode.OpTask, StringOperand: "work"}, {Op: bytecode.OpSpawnAgent, StringOperand: name, StringOperand2: grants, IntOperand: int64(len(body))}}, body...) } func spawnTestPrint(s string) []bytecode.BCInstruction { return []bytecode.BCInstruction{{Op: bytecode.OpLoadConst, ValueOperand: s}, {Op: bytecode.OpPrint, IntOperand: 1}} @@ -52,7 +63,7 @@ func TestVMSpawnAgentNesting(t *testing.T) { {"three levels", three, spawnTestStart("Outer") + spawnTestStart("Middle") + spawnTestStart("Inner") + "leaf\n" + spawnTestDone("Inner") + spawnTestDone("Middle") + spawnTestDone("Outer"), "", "", 0, 0}, {"nested failure", nestedFailure, spawnTestStart("Outer") + spawnTestStart("Inner") + "outer after\n" + spawnTestDone("Outer"), "[Swarm VM] Agent \"Inner\" failed task: \"work\": CAPABILITY_DENIED: capability denied: environment\n", "", 0, 0}, {"failed child then sibling", append(append(spawnTestBody("Bad", failure...), spawnTestBody("Good", spawnTestPrint("sibling")...)...), spawnTestPrint("parent after")...), spawnTestStart("Bad") + spawnTestStart("Good") + "sibling\n" + spawnTestDone("Good") + "parent after\n", "[Swarm VM] Agent \"Bad\" failed task: \"work\": CAPABILITY_DENIED: capability denied: environment\n", "", 0, 0}, - {"type error isolated", append(spawnTestBody("Bad", bytecode.BCInstruction{Op: bytecode.OpLoadConst, ValueOperand: int64(1)}, bytecode.BCInstruction{Op: bytecode.OpEnv}), spawnTestPrint("after")...), spawnTestStart("Bad") + "after\n", "[Swarm VM] Agent \"Bad\" failed task: \"work\": TYPE_ERROR: env expected string name, got int64\n", "", 0, 0}, + {"type error isolated", append(spawnTestBodyGrants("Bad", "environment", bytecode.BCInstruction{Op: bytecode.OpLoadConst, ValueOperand: int64(1)}, bytecode.BCInstruction{Op: bytecode.OpEnv}), spawnTestPrint("after")...), spawnTestStart("Bad") + "after\n", "[Swarm VM] Agent \"Bad\" failed task: \"work\": TYPE_ERROR: env expected string name, got int64\n", "", 0, 0}, {"map writes before failure stay isolated", maps, spawnTestStart("Writer") + "parent\n", "[Swarm VM] Agent \"Writer\" failed task: \"work\": CAPABILITY_DENIED: capability denied: environment\n", "", 0, 0}, {"bindings and stack", vars, spawnTestStart("Writer") + spawnTestDone("Writer") + "sentinel\nparent\n", "", "", 0, 0}, {"exact shared budget", three, spawnTestStart("Outer") + spawnTestStart("Middle") + spawnTestStart("Inner") + "leaf\n" + spawnTestDone("Inner") + spawnTestDone("Middle") + spawnTestDone("Outer"), "", "", 8, 0}, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index f5d75158..c3d8a4e5 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -793,7 +793,14 @@ func (interp *Interpreter) evalList(node *ast.Node, env *InterpEnv) any { for _, argNode := range node.Children[2:] { args = append(args, fmt.Sprint(interp.eval(argNode, env))) } - out, err := exec.Command(cmd, args...).CombinedOutput() + cmd, _, denied := capability.Grants(interp.AllowedCaps).AuthorizeExec(cmd, args) + if denied != nil { + InterpErr(fmt.Sprintf("CAPABILITY_DENIED: capability denied: %s", denied.Error()), node) + } + child := exec.Command(cmd, args...) + // Children never inherit the host environment; only process:env= names pass. + child.Env = capability.Grants(interp.AllowedCaps).ExecEnv(os.LookupEnv) + out, err := child.CombinedOutput() if err != nil { InterpErr(fmt.Sprintf("IO_ERROR: exec failed: %v", err), node) } @@ -1943,9 +1950,18 @@ func (vm *BCVM) storeHandle(env *BcEnv, name string, op bytecode.Opcode) *bcMemo } func (vm *BCVM) requireCapability(cap capability.Capability, op bytecode.Opcode) { + if cap == capability.Process && (op == bytecode.OpSpawn || op == bytecode.OpSpawnAgent) && !capability.Grants(vm.AllowedCaps).HasCoarse(capability.Process) { + // Spawning needs the coarse process grant; an exec allow-list alone does not spawn. + vm.recordEffect(cap, op, "denied") + panic(NewRuntimeError("CAPABILITY_DENIED", "main", vm.ip, op, "capability denied: %s", cap)) + } for _, allowed := range vm.AllowedCaps { if capability.Grants([]capability.Capability{allowed}).Has(cap) { - // A scoped filesystem class check is provisional until its path check. + // A scoped filesystem class check is provisional until its path + // check; exec and spawn_agent are provisional until argv/attenuation checks. + if op == bytecode.OpExec || op == bytecode.OpSpawnAgent || op == bytecode.OpSpawn || op == bytecode.OpHttpRoute { + return + } if cap != capability.Filesystem || capability.Grants(vm.AllowedCaps).HasUnrestrictedFilesystem() { vm.recordEffect(cap, op, "allowed") } @@ -2455,12 +2471,15 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { args = append([]string{fmt.Sprint(vm.pop(inst.Op))}, args...) } cmdStr := fmt.Sprint(vm.pop(inst.Op)) + cmdStr = vm.requireExec(cmdStr, args, inst.Op) max := vm.Limits.MaxExecOutputBytes if max <= 0 { panic(NewRuntimeError("LIMIT_EXCEEDED", "main", ip, inst.Op, "exec output exceeds maximum limit of %d bytes", max)) } cmdCtx, cancel := context.WithCancel(vm.ctx) cmd := exec.CommandContext(cmdCtx, cmdStr, args...) + // Children never inherit the host environment; only process:env= names pass. + cmd.Env = capability.Grants(vm.AllowedCaps).ExecEnv(os.LookupEnv) output := &limitedOutput{max: max, cancel: cancel} cmd.Stdout, cmd.Stderr = output, output // Bound inherited pipe waits when the runner has a deadline. @@ -2532,6 +2551,7 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { } bodyLen := int(inst.IntOperand) bodyInsts := insts[ip+1 : ip+1+bodyLen] + spawnCaps := vm.attenuateChild(inst.StringOperand, capability.Process, inst.Op, ip, "spawn") capturedEnv := NewBcEnv(nil) for e := env; e != nil; e = e.parent { for k, v := range e.vars { @@ -2542,7 +2562,7 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { } go func(cEnv *BcEnv) { defer func() { recover() }() - childVM := &BCVM{ctx: vm.ctx, allocations: vm.allocations, receipt: vm.receipt, prog: vm.prog, env: cEnv, stores: vm.stores, Limits: vm.Limits, AllowedCaps: vm.AllowedCaps, Out: vm.Out, ErrOut: vm.ErrOut} + childVM := &BCVM{ctx: vm.ctx, allocations: vm.allocations, receipt: vm.receipt, prog: vm.prog, env: cEnv, stores: vm.stores, Limits: vm.Limits, AllowedCaps: spawnCaps, Out: vm.Out, ErrOut: vm.ErrOut} childVM.run(bodyInsts, cEnv) }(capturedEnv) ip += bodyLen @@ -2602,6 +2622,14 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { bodyInsts := insts[ip+1 : ip+1+bodyLen] dispatch := requireHTTPDispatch(env, ip, inst, "http_route") + // A handler always keeps network: HTTP_ROUTE already required it and + // res/res_json/res_header are network-classified, so a handler could + // not answer without it. Everything else must be listed explicitly. + routeGrants := "network" + if inst.StringOperand3 != "" { + routeGrants += "\n" + inst.StringOperand3 + } + routeCaps := vm.attenuateChild(routeGrants, bytecode.Registry[inst.Op].Capability, inst.Op, ip, "route") capturedEnv := NewBcEnv(nil) for e := env; e != nil; e = e.parent { @@ -2619,7 +2647,7 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { reqEnv.vars["w"] = http.ResponseWriter(recorder) reqEnv.vars[reqVar] = r reqEnv.vars["req"] = r - childVM := &BCVM{ctx: vm.ctx, allocations: vm.allocations, receipt: vm.receipt, prog: prog, env: reqEnv, stores: vm.stores, Limits: vm.Limits, AllowedCaps: vm.AllowedCaps, Out: vm.Out, ErrOut: vm.ErrOut} + childVM := &BCVM{ctx: vm.ctx, allocations: vm.allocations, receipt: vm.receipt, prog: prog, env: reqEnv, stores: vm.stores, Limits: vm.Limits, AllowedCaps: routeCaps, Out: vm.Out, ErrOut: vm.ErrOut} func() { defer func() { recovered := recover() @@ -3390,6 +3418,7 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { panic(NewRuntimeError("TYPE_ERROR", "main", ip, inst.Op, "spawn_agent expected string task, got %T", v)) } name := inst.StringOperand + childCaps := vm.attenuateChild(inst.StringOperand2, capability.Process, inst.Op, ip, "spawn_agent") if vm.spawnDepth >= vm.Limits.MaxCallDepth { panic(NewRuntimeError("LIMIT_EXCEEDED", "main", ip, inst.Op, "spawn nesting depth limit exceeded")) } @@ -3412,7 +3441,7 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { } } // Synchronous children share the buffered reader to preserve stdin order. - childVM := &BCVM{ctx: vm.ctx, allocations: vm.allocations, receipt: vm.receipt, prog: vm.prog, env: capturedEnv, args: vm.args, In: vm.In, lineReader: vm.lineReader, stores: vm.stores, Limits: vm.Limits, AllowedCaps: vm.AllowedCaps, Out: vm.Out, ErrOut: vm.ErrOut, executed: vm.executed, spawnDepth: vm.spawnDepth + 1, callDepth: vm.callDepth} + childVM := &BCVM{ctx: vm.ctx, allocations: vm.allocations, receipt: vm.receipt, prog: vm.prog, env: capturedEnv, args: vm.args, In: vm.In, lineReader: vm.lineReader, stores: vm.stores, Limits: vm.Limits, AllowedCaps: childCaps, Out: vm.Out, ErrOut: vm.ErrOut, executed: vm.executed, spawnDepth: vm.spawnDepth + 1, callDepth: vm.callDepth} func() { defer func() { // Account for all child work even when it exits through a panic. @@ -3810,6 +3839,36 @@ func (b *limitedOutput) Write(p []byte) (int, error) { return b.buffer.Write(p) } +// requireExec enforces the exact-argv process allow-list. A denial is a typed +// CAPABILITY_DENIED error and is recorded in the receipt before anything runs; +// an allowed run records the matched allow-list entry. +func (vm *BCVM) requireExec(program string, args []string, op bytecode.Opcode) string { + path, grant, err := capability.Grants(vm.AllowedCaps).AuthorizeExec(program, args) + if err != nil { + vm.recordEffect(capability.Process, op, "denied") + panic(NewRuntimeError("CAPABILITY_DENIED", "main", vm.ip, op, "capability denied: %s", err.Error())) + } + vm.recordEffectGrant(capability.Process, op, "allowed", grant) + return path +} + +// attenuateChild computes a child's grants (S9 attenuation) for spawn_agent, +// spawn and http_route bodies: only the explicit (grants ...) subset, never +// more than the parent holds; none by default. +func (vm *BCVM) attenuateChild(list string, cap capability.Capability, op bytecode.Opcode, ip int, what string) []capability.Capability { + var requested []string + if list != "" { + requested = strings.Split(list, "\n") + } + child, err := capability.Attenuate(capability.Grants(vm.AllowedCaps), requested) + if err != nil { + vm.recordEffect(cap, op, "denied") + panic(NewRuntimeError("CAPABILITY_DENIED", "main", ip, op, "capability denied: %s: %s", what, err.Error())) + } + vm.recordEffect(cap, op, "allowed") + return child +} + func (vm *BCVM) requireFilesystem(path string, write bool, op bytecode.Opcode) string { grants := capability.Grants(vm.AllowedCaps) allowed := grants.AllowsRead(path) diff --git a/internal/vm/vm_negative_test.go b/internal/vm/vm_negative_test.go index 65655417..8ea7fcee 100644 --- a/internal/vm/vm_negative_test.go +++ b/internal/vm/vm_negative_test.go @@ -690,7 +690,7 @@ func TestVMSpawnAndHTTPRouteBodyLengthBounds(t *testing.T) { }() prog := &bytecode.BCProgram{Main: []bytecode.BCInstruction{ {Op: bytecode.OpLoadConst, ValueOperand: "prefix"}, - {Op: op.op, StringOperand: "/test", StringOperand2: "req", IntOperand: tc.length}, + {Op: op.op, StringOperand: routePathOperand(op.op), StringOperand2: "req", IntOperand: tc.length}, {Op: bytecode.OpLoadConst, ValueOperand: "unexpected body"}, {Op: bytecode.OpPrint, IntOperand: 1}, }} @@ -718,7 +718,7 @@ func TestVMSpawnAndHTTPRouteBodyLengthBounds(t *testing.T) { insts = append(insts, bytecode.BCInstruction{Op: bytecode.OpHttpServerStart, StringOperand: "0"}) } // A const-only spawn body avoids asynchronous writes to test buffers. - insts = append(insts, bytecode.BCInstruction{Op: op.op, StringOperand: "/test", StringOperand2: "req", IntOperand: 1}, bytecode.BCInstruction{Op: bytecode.OpLoadConst, ValueOperand: "body"}) + insts = append(insts, bytecode.BCInstruction{Op: op.op, StringOperand: routePathOperand(op.op), StringOperand2: "req", IntOperand: 1}, bytecode.BCInstruction{Op: bytecode.OpLoadConst, ValueOperand: "body"}) var out, stderr bytes.Buffer ev := RunBytecodeWithEvidence(&bytecode.BCProgram{Main: insts}, nil, DefaultExecutionPolicy(), []capability.Capability{op.cap}, nil, &out, &stderr, 0) if ev.RuntimeFailure != nil || ev.ExitCode != 0 || out.Len() != 0 || stderr.Len() != 0 { @@ -1372,3 +1372,12 @@ func TestInterpExitToIntSafety(t *testing.T) { }) } } + +// routePathOperand is HTTP_ROUTE's path; SPAWN's string operand is its child +// grant list, so it stays empty (no grants). +func routePathOperand(op bytecode.Opcode) string { + if op == bytecode.OpHttpRoute { + return "/test" + } + return "" +} diff --git a/issue141_arity_cli_test.go b/issue141_arity_cli_test.go index 76f28368..e8c4c64f 100644 --- a/issue141_arity_cli_test.go +++ b/issue141_arity_cli_test.go @@ -101,8 +101,8 @@ func TestIssue141MotokoArityRepros(t *testing.T) { {"http_server0", `(http_server)`, "ARITY_ERROR: http_server expects at least 1 arg, got 0"}, {"str_trim_in_print", `(print (str_trim))`, "ARITY_ERROR: str_trim expects 1 arg, got 0"}, {"exec0", `(print (exec))`, "ARITY_ERROR: exec expects at least 1 arg, got 0"}, - {"spawn_agent0", `(spawn_agent)`, "ARITY_ERROR: spawn_agent expects 2 args, got 0"}, - {"spawn_agent1", `(spawn_agent "x")`, "ARITY_ERROR: spawn_agent expects 2 args, got 1"}, + {"spawn_agent0", `(spawn_agent)`, "ARITY_ERROR: spawn_agent expects 2 to 3 args, got 0"}, + {"spawn_agent1", `(spawn_agent "x")`, "ARITY_ERROR: spawn_agent expects 2 to 3 args, got 1"}, {"llm_generate0", `(print (llm_generate))`, "ARITY_ERROR: llm_generate expects"}, {"sql_query0", `(print (sql_query))`, "ARITY_ERROR: sql_query expects"}, {"sql_query1", `(print (sql_query db))`, "ARITY_ERROR: sql_query expects"}, diff --git a/tests/conformance/abi_v1/06_exec_capability.howl b/tests/conformance/abi_v1/06_exec_capability.howl index 60697597..9a9de3e1 100644 --- a/tests/conformance/abi_v1/06_exec_capability.howl +++ b/tests/conformance/abi_v1/06_exec_capability.howl @@ -1,3 +1,3 @@ (cli_app - (let (value (exec "printf" "phase2b-exec-marker")) + (let (value (exec "/usr/bin/printf" "phase2b-exec-marker")) (print (bytes_to_string value)))) diff --git a/tests/conformance/abi_v1/19_nested_exec.howl b/tests/conformance/abi_v1/19_nested_exec.howl index 2af64c48..aea94b04 100644 --- a/tests/conformance/abi_v1/19_nested_exec.howl +++ b/tests/conformance/abi_v1/19_nested_exec.howl @@ -10,34 +10,34 @@ (for label (list "L") (if (= label "skip") (do - (print (bytes_to_string (exec "printf" "no-skip"))) + (print (bytes_to_string (exec "/usr/bin/printf" "no-skip"))) (print "no")) (do - (print (bytes_to_string (exec "printf" "phase2b-exec-marker"))) + (print (bytes_to_string (exec "/usr/bin/printf" "phase2b-exec-marker"))) (for name names (if (= name "z") (do - (print (bytes_to_string (exec "printf" "no-else"))) + (print (bytes_to_string (exec "/usr/bin/printf" "no-else"))) (print "no")) (do (set total (+ total 1)) (print label name n)))) (for absent (list) (do - (print (bytes_to_string (exec "printf" "no-empty"))) + (print (bytes_to_string (exec "/usr/bin/printf" "no-empty"))) (print "no"))) (while false (do - (print (bytes_to_string (exec "printf" "no-dead"))) + (print (bytes_to_string (exec "/usr/bin/printf" "no-dead"))) (print "no")))))) (set n (+ n 1)))) (if (> total 0) (do (print "kept" total) - (print (bytes_to_string (exec "printf" "phase2b-exec-kept")))) + (print (bytes_to_string (exec "/usr/bin/printf" "phase2b-exec-kept")))) (do (print "miss") - (print (bytes_to_string (exec "printf" "phase2b-exec-miss"))))) + (print (bytes_to_string (exec "/usr/bin/printf" "phase2b-exec-miss"))))) (return total)))))) (print "result" (call probe 1)) (print "empty" (call probe 0))) diff --git a/tests/conformance/abi_v1/21_nested_multi_effect.howl b/tests/conformance/abi_v1/21_nested_multi_effect.howl index 910bdf6d..9c7e9d24 100644 --- a/tests/conformance/abi_v1/21_nested_multi_effect.howl +++ b/tests/conformance/abi_v1/21_nested_multi_effect.howl @@ -14,7 +14,7 @@ (print (bytes_to_string (read_file "/tmp/howlframe-abi-v1-multi-skip-read.txt"))) (write_file "/tmp/howlframe-abi-v1-multi-skip-write.txt" "no") (mkdir "/tmp/howlframe-abi-v1-multi-skip-dir") - (print (bytes_to_string (exec "printf" "no-skip"))) + (print (bytes_to_string (exec "/usr/bin/printf" "no-skip"))) (print (bytes_to_string (fetch "http://127.0.0.1:47653/howlframe-abi-v1-phase2d" "GET"))) (print "no")) (do @@ -22,7 +22,7 @@ (print (bytes_to_string (read_file "/tmp/howlframe-abi-v1-multi-read.txt"))) (write_file "/tmp/howlframe-abi-v1-multi-write.txt" "multi-write-marker") (mkdir "/tmp/howlframe-abi-v1-multi-dir") - (print (bytes_to_string (exec "printf" "phase2b-exec-marker"))) + (print (bytes_to_string (exec "/usr/bin/printf" "phase2b-exec-marker"))) (print (bytes_to_string (fetch "http://127.0.0.1:47653/howlframe-abi-v1-phase2d" "GET"))) (for name names (if (= name "z") @@ -31,7 +31,7 @@ (print (bytes_to_string (read_file "/tmp/howlframe-abi-v1-multi-else-read.txt"))) (write_file "/tmp/howlframe-abi-v1-multi-else-write.txt" "no") (mkdir "/tmp/howlframe-abi-v1-multi-else-dir") - (print (bytes_to_string (exec "printf" "no-else"))) + (print (bytes_to_string (exec "/usr/bin/printf" "no-else"))) (print (bytes_to_string (fetch "http://127.0.0.1:47653/howlframe-abi-v1-phase2d" "GET"))) (print "no")) (do @@ -43,7 +43,7 @@ (print (bytes_to_string (read_file "/tmp/howlframe-abi-v1-multi-empty-read.txt"))) (write_file "/tmp/howlframe-abi-v1-multi-empty-write.txt" "no") (mkdir "/tmp/howlframe-abi-v1-multi-empty-dir") - (print (bytes_to_string (exec "printf" "no-empty"))) + (print (bytes_to_string (exec "/usr/bin/printf" "no-empty"))) (print (bytes_to_string (fetch "http://127.0.0.1:47653/howlframe-abi-v1-phase2d" "GET"))) (print "no"))) (while false @@ -52,7 +52,7 @@ (print (bytes_to_string (read_file "/tmp/howlframe-abi-v1-multi-dead-read.txt"))) (write_file "/tmp/howlframe-abi-v1-multi-dead-write.txt" "no") (mkdir "/tmp/howlframe-abi-v1-multi-dead-dir") - (print (bytes_to_string (exec "printf" "no-dead"))) + (print (bytes_to_string (exec "/usr/bin/printf" "no-dead"))) (print (bytes_to_string (fetch "http://127.0.0.1:47653/howlframe-abi-v1-phase2d" "PUT" "phase2d-dead-branch-body"))) (print "no")))))) (set n (+ n 1)))) @@ -63,7 +63,7 @@ (print (bytes_to_string (read_file "/tmp/howlframe-abi-v1-multi-kept-read.txt"))) (write_file "/tmp/howlframe-abi-v1-multi-kept-write.txt" "multi-kept-write") (mkdir "/tmp/howlframe-abi-v1-multi-kept-dir") - (print (bytes_to_string (exec "printf" "phase2b-exec-kept"))) + (print (bytes_to_string (exec "/usr/bin/printf" "phase2b-exec-kept"))) (print (bytes_to_string (fetch "http://127.0.0.1:47653/howlframe-abi-v1-phase2d" "GET")))) (do (print "miss") @@ -71,7 +71,7 @@ (print (bytes_to_string (read_file "/tmp/howlframe-abi-v1-multi-miss-read.txt"))) (write_file "/tmp/howlframe-abi-v1-multi-miss-write.txt" "multi-miss-write") (mkdir "/tmp/howlframe-abi-v1-multi-miss-dir") - (print (bytes_to_string (exec "printf" "phase2b-exec-miss"))) + (print (bytes_to_string (exec "/usr/bin/printf" "phase2b-exec-miss"))) (print (bytes_to_string (fetch "http://127.0.0.1:47653/howlframe-abi-v1-phase2d" "GET"))))) (return total)))))) (print "result" (call probe 1)) diff --git a/tests/conformance/lowered_hfir_abi_v1.json b/tests/conformance/lowered_hfir_abi_v1.json index 3a4796e4..a15b102d 100644 --- a/tests/conformance/lowered_hfir_abi_v1.json +++ b/tests/conformance/lowered_hfir_abi_v1.json @@ -84,12 +84,30 @@ { "name": "exec_granted", "fixture": "tests/conformance/abi_v1/06_exec_capability.howl", - "allow_caps": "process", + "allow_caps": "process:exec=/usr/bin/printf *", "targets": ["hfir_bytecode", "bytecode", "interpreter", "go", "javascript"], "javascript_root": "web_app", "expect": "PASS", "stdout": "phase2b-exec-marker" }, + { + "name": "exec_bare_process_denied", + "fixture": "tests/conformance/abi_v1/06_exec_capability.howl", + "allow_caps": "process", + "targets": ["hfir_bytecode", "bytecode", "interpreter", "go", "javascript"], + "javascript_root": "web_app", + "expect": "CAPABILITY_DENIED", + "forbid": "phase2b-exec-marker" + }, + { + "name": "exec_argv_mismatch_denied", + "fixture": "tests/conformance/abi_v1/06_exec_capability.howl", + "allow_caps": "process:exec=/usr/bin/printf other-marker", + "targets": ["hfir_bytecode", "bytecode", "interpreter", "go", "javascript"], + "javascript_root": "web_app", + "expect": "CAPABILITY_DENIED", + "forbid": "phase2b-exec-marker" + }, { "name": "read_file_denied", "fixture": "tests/conformance/abi_v1/07_read_file_capability.howl", @@ -271,7 +289,7 @@ { "name": "nested_exec_granted", "fixture": "tests/conformance/abi_v1/19_nested_exec.howl", - "allow_caps": "process", + "allow_caps": "process:exec=/usr/bin/printf *", "targets": ["hfir_bytecode", "bytecode", "interpreter", "go", "javascript"], "javascript_root": "web_app", "expect": "PASS", @@ -309,7 +327,7 @@ { "name": "nested_multi_effect_partial", "fixture": "tests/conformance/abi_v1/21_nested_multi_effect.howl", - "allow_caps": "environment,filesystem,process", + "allow_caps": "environment,filesystem,process:exec=/usr/bin/printf *", "targets": ["hfir_bytecode", "bytecode", "interpreter", "go", "javascript"], "javascript_root": "web_app", "expect": "CAPABILITY_DENIED", @@ -319,7 +337,7 @@ { "name": "nested_multi_effect_granted", "fixture": "tests/conformance/abi_v1/21_nested_multi_effect.howl", - "allow_caps": "environment,filesystem,process,network", + "allow_caps": "environment,filesystem,process:exec=/usr/bin/printf *,network", "targets": ["hfir_bytecode", "bytecode", "interpreter", "go", "javascript"], "javascript_root": "web_app", "expect": "PASS", diff --git a/tests/parity/08_io_cli.howl b/tests/parity/08_io_cli.howl index 6611bf93..cd2c3cfe 100644 --- a/tests/parity/08_io_cli.howl +++ b/tests/parity/08_io_cli.howl @@ -5,6 +5,6 @@ (try_let (content (read_file filename)) (catch err (stderr "read failed\n")) (print "read back:" (bytes_to_string content))) - (exec "rm" "-f" filename) + (exec "/usr/bin/rm" "-f" filename) (stderr "parity stderr notice\n") (print "completed io test")))) diff --git a/tests/test_primitives.howl b/tests/test_primitives.howl index f7bf1a99..c97037dd 100644 --- a/tests/test_primitives.howl +++ b/tests/test_primitives.howl @@ -21,6 +21,6 @@ ) ) ) - (exec "rm" "test_prims.txt") + (exec "/usr/bin/rm" "test_prims.txt") ) ) diff --git a/tests/test_v2_features.howl b/tests/test_v2_features.howl index ea66c6f3..027a12d2 100644 --- a/tests/test_v2_features.howl +++ b/tests/test_v2_features.howl @@ -5,6 +5,6 @@ (catch err (print err)) (print file_content) ) - (exec "rm" "test_v2.txt") + (exec "/usr/bin/rm" "test_v2.txt") (print "done") ) diff --git a/tools/difftest/difftest.go b/tools/difftest/difftest.go index 00f46656..c421159c 100644 --- a/tools/difftest/difftest.go +++ b/tools/difftest/difftest.go @@ -149,7 +149,10 @@ func NormalizeError(errStr string) string { } } -const allKnownCaps = "network,filesystem,process,environment,database" +// allKnownCaps grants every capability class. process is scoped to the exact +// programs the parity corpus runs, because a bare process grant never +// authorizes exec. +const allKnownCaps = "network,filesystem,process,process:exec=/usr/bin/rm **,environment,database" // RunOptions controls one differential execution. // A nil AllowCaps keeps the historical grant of every known capability. diff --git a/tools/difftest/main.go b/tools/difftest/main.go index c4d16118..67cad559 100644 --- a/tools/difftest/main.go +++ b/tools/difftest/main.go @@ -112,7 +112,7 @@ func testFixture(file string) (passed bool, skipReason string, failMsg string) { return false, "", fmt.Sprintf("(compile-bc): %v\n%s", err, out5) } - out6, err := runCommand("go", "run", "howlframe.go", "-run-bc", "-allow-caps", "network,filesystem,process,environment,database", bcPath) + out6, err := runCommand("go", "run", "howlframe.go", "-run-bc", "-allow-caps", "network,filesystem,process,process:exec=/usr/bin/rm **,environment,database", bcPath) if err != nil { return false, "", fmt.Sprintf("(run-bc): %v\n%s", err, out6) } @@ -133,7 +133,7 @@ func testFixture(file string) (passed bool, skipReason string, failMsg string) { } } if hfirFenced == "" { - out8, err = runCommand("go", "run", "howlframe.go", "-run-bc", "-allow-caps", "network,filesystem,process,environment,database", hfirPath) + out8, err = runCommand("go", "run", "howlframe.go", "-run-bc", "-allow-caps", "network,filesystem,process,process:exec=/usr/bin/rm **,environment,database", hfirPath) if err != nil { return false, "", fmt.Sprintf("(run-bc hfir): %v\n%s", err, out8) }