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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<absolute path>[ <pattern>...]` 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).

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions apps/counter_api/counter_api.howl
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
4 changes: 2 additions & 2 deletions apps/rest_tasks/rest_tasks.howl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions apps/status_api/status_api.howl
Original file line number Diff line number Diff line change
@@ -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")))))))
10 changes: 5 additions & 5 deletions apps/task_api/task_api.howl
Original file line number Diff line number Diff line change
Expand Up @@ -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")))
Expand All @@ -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"))
Expand Down Expand Up @@ -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")))
Expand All @@ -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")))
Expand Down Expand Up @@ -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")))
Expand Down
26 changes: 11 additions & 15 deletions apps/task_api/task_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})

Expand Down
2 changes: 1 addition & 1 deletion apps/webhook_relay/webhook_relay.howl
Original file line number Diff line number Diff line change
@@ -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")))
Expand Down
Loading
Loading