diff --git a/README.md b/README.md index c8d34351..5dc2eb73 100644 --- a/README.md +++ b/README.md @@ -498,3 +498,25 @@ See [docs/language_write_cost_benchmark.md](docs/language_write_cost_benchmark.m ## License HowlFrame is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +## Browser host capability grants + +Compiled JavaScript network access requires an explicit host grant. In Node, +set `HOWLFRAME_ALLOW_CAPS=network` in the environment. In a browser, the hosting +page sets `globalThis.HOWLFRAME_ALLOW_CAPS = "network"` before loading the +compiled script. Unset, empty, non-string, and unrelated grants deny a request +before calling fetch. The Node environment remains authoritative in Node. + +```html + + +``` + +The string uses the same capability names as `-allow-caps`; only the network +helper has a browser host API. Node-specific filesystem, process, and environment +APIs do not become browser APIs. This live value is read at call time, so any +script on the page can change it. It is host configuration, not an OS sandbox +or deployment authorization. A process.env bundler shim takes precedence and +fails closed unless that shim supplies the environment grant. +Browser bootstrap failures keep their original error rather than trying to +call Node process.exit. diff --git a/change_log.md b/change_log.md index ab0aeb7c..88be185e 100644 --- a/change_log.md +++ b/change_log.md @@ -8,6 +8,7 @@ * Dogfood round 2: `apps/notes_cli`, `apps/auth_api`, `apps/grep_cli` built on standalone bytecode; `store_open` accepts a runtime URI expression; AST density sugar `(switch …)` and `(inc x)`. Real-app token harness extended with Python/Go/Node ports and parity. Journal: `docs/journals/2026-10-10_dogfood_apps_round2.md`. ### Fixed +* HOWL-E2E-UX-001 UX-003: browser JavaScript fetch accepts explicit host grants without reading Node process.env. Missing grants still deny before requests; Node environment authority and exit behavior are preserved. Browser bootstrap errors preserve the original failure. * S8/P2.1 filesystem portion: native VM and interpreter enforce repeatable `filesystem:read=` and `filesystem:write=` grants, with symlink containment checks. File stores require read coverage and write coverage for mutations. Coarse `filesystem` remains unrestricted; process/network scoping and generated backends remain open. Journal: `docs/journals/2026-10-06_path_scoped_filesystem.md`. * Review C3/C7: executable VM effect-gate conformance coverage; demo decisions use escaped JSON; executor capability checks precede writes. Effects remain ordered, not atomic. Journal: `docs/journals/2026-10-05_effect_gate_conformance_demo_honesty.md`. * Checker enforces `docs/reference/NUMERIC_CONTRACT.md`: mixed int/float `+`, `-`, `*` are valid and typed float, `/` always types float, and int/float comparisons are valid (HFREC-063). Void expressions in value position (`let`, `set`, call/print/list arguments, `append`/`map_set` values) and statically provable builtin argument mismatches (`str_split`, `str_join`, `regex_match`, `list_len`) are rejected before any backend runs (HFREC-009, HFREC-010). `parse_json` bodies must be variable names outside `web_app` (HFREC-004). Generated JavaScript keeps typed parameter names (HFREC-049). Go and JavaScript `to_int` truncate toward zero like the VM; `web_app` rejects integer literals and JSON integers outside +/-(2^53-1) instead of rounding. Wasm integer `/` fails closed. Artifact format version 2 (sorted function table, deterministic bytes) still reads version 1 artifacts; version 1 readers cannot read version 2 artifacts (see `docs/reference/artifact_compatibility.md`). New corpus: `tests/parity_stabilization/`. diff --git a/docs/journals/2026-10-12_browser_capability_dogfood.md b/docs/journals/2026-10-12_browser_capability_dogfood.md new file mode 100644 index 00000000..baa57922 --- /dev/null +++ b/docs/journals/2026-10-12_browser_capability_dogfood.md @@ -0,0 +1,8 @@ +# Browser capability dogfood + +HowlBoard backend tests passed, but the browser emitted no API requests and displayed a connection error. The generated fetch grant helper referenced process.env in a browser. + +Before: five browser-like Node VM regressions failed with process undefined, including the explicit network-grant case. +After: host browser grants are explicit strings; missing, invalid and unrelated grants deny before fetch. Node still reads its environment. The bootstrap catcher reports the original error in browser contexts. + +Test impact: existing Node fetch and other capability tests retain coverage. New generated-code tests execute with no process/require browser globals, covering allowed fetch and four denials. Full repository tests/build/vet and real HowlBoard browser acceptance are recorded in the mission evidence before publication. diff --git a/internal/backend/javascript/browser_fetch_test.go b/internal/backend/javascript/browser_fetch_test.go new file mode 100644 index 00000000..845c1019 --- /dev/null +++ b/internal/backend/javascript/browser_fetch_test.go @@ -0,0 +1,105 @@ +package javascript + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/howlcipher/howlframe/internal/checker" + "github.com/howlcipher/howlframe/internal/lexer" + "github.com/howlcipher/howlframe/internal/parser" +) + +func TestBrowserFetchRequiresExplicitHostGrant(t *testing.T) { + root := parser.NewParser(lexer.NewLexer(`(web_app (print (bytes_to_string (fetch "https://example.invalid" "GET"))))`), "browser.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateJSCode(root) + encoded, err := json.Marshal(code) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, grant string + allowed bool + }{ + {"unset", "undefined", false}, + {"empty", `""`, false}, + {"wrong", `"filesystem"`, false}, + {"invalid", "42", false}, + {"network", `"network"`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + // vm supplies browser globals without Node's process or require. + wrapper := `const vm = require("node:vm"); +let calls = 0; +const logs = []; +const browser = { + console: {log: value => logs.push(String(value)), error: () => {}}, + fetch: async () => { calls++; return {text: async () => "browser response"}; } +}; +const grant = ` + tc.grant + `; +if (grant !== undefined) browser.HOWLFRAME_ALLOW_CAPS = grant; +Promise.resolve(vm.runInNewContext(` + string(encoded) + `, browser)).then( + () => console.log(JSON.stringify({calls, logs, error: ""})), + err => console.log(JSON.stringify({calls, logs, error: String(err.message)})) +);` + stdout, stderr, exit := runJS(t, wrapper) + if exit != 0 { + t.Fatalf("browser harness failed: %s %s", stdout, stderr) + } + var result struct { + Calls int `json:"calls"` + Logs []string `json:"logs"` + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid browser result %q: %v", stdout, err) + } + if tc.allowed { + if result.Calls != 1 || result.Error != "" || len(result.Logs) != 1 || result.Logs[0] != "browser response" { + t.Fatalf("granted fetch failed: %+v", result) + } + } else if result.Calls != 0 || len(result.Logs) != 0 || !strings.Contains(result.Error, "CAPABILITY_DENIED") { + t.Fatalf("fetch must fail closed before dialing: %+v", result) + } + }) + } +} + +func TestNodeEnvironmentCannotBeWidenedByBrowserGrant(t *testing.T) { + root := parser.NewParser(lexer.NewLexer(`(web_app (print (bytes_to_string (fetch "https://example.invalid" "GET"))))`), "node.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateJSCode(root) + wrapper := `globalThis.HOWLFRAME_ALLOW_CAPS = "network"; +globalThis.fetch = () => { throw new Error("UNEXPECTED_REQUEST"); }; +` + code + stdout, stderr, exit := runJS(t, wrapper, "HOWLFRAME_ALLOW_CAPS=") + if exit == 0 || !strings.Contains(stderr, "CAPABILITY_DENIED") || strings.Contains(stdout+stderr, "UNEXPECTED_REQUEST") { + t.Fatalf("browser global widened Node authority: exit=%d stdout=%q stderr=%q", exit, stdout, stderr) + } +} + +func TestBrowserBootstrapPreservesOriginalError(t *testing.T) { + root := parser.NewParser(lexer.NewLexer(`(web_app (print (bytes_to_string (fetch "https://example.invalid" "GET"))))`), "browser-error.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateJSCode(root) + encoded, err := json.Marshal(code) + if err != nil { + t.Fatal(err) + } + wrapper := `const vm = require("node:vm"); +const original = new Error("NETWORK_FAILED"); +const browser = { + HOWLFRAME_ALLOW_CAPS: "network", + console: {log: () => {}, error: () => {}}, + fetch: async () => { throw original; } +}; +Promise.resolve(vm.runInNewContext(` + string(encoded) + `, browser)).then( + () => { throw new Error("UNEXPECTED_SUCCESS"); }, + err => { if (err !== original) throw new Error("ERROR_REPLACED"); console.log("original error preserved"); } +);` + stdout, stderr, exit := runJS(t, wrapper) + if exit != 0 || strings.TrimSpace(stdout) != "original error preserved" { + t.Fatalf("bootstrap replaced the browser error: exit=%d stdout=%q stderr=%q", exit, stdout, stderr) + } +} diff --git a/internal/backend/javascript/javascript.go b/internal/backend/javascript/javascript.go index 20aa2cf0..3fe08cc2 100644 --- a/internal/backend/javascript/javascript.go +++ b/internal/backend/javascript/javascript.go @@ -274,11 +274,18 @@ function howlRequestString(kind, value) { ` } -// grantJSHelper reads HOWLFRAME_ALLOW_CAPS, comma-separated, the same names -// as -allow-caps. An empty or unset grant denies every name. +// grantJSHelper reads the host's explicit grant. Node uses its environment; +// browsers use a host-supplied global before loading the compiled script. +// An empty, unset, or non-string grant denies every name. func grantJSHelper() string { return `function howlFrameGrantHas(name) { - var raw = process.env.HOWLFRAME_ALLOW_CAPS || ""; + var raw = ""; + if (typeof process !== "undefined" && process.env) { + raw = process.env.HOWLFRAME_ALLOW_CAPS || ""; + } else if (typeof globalThis !== "undefined") { + raw = globalThis.HOWLFRAME_ALLOW_CAPS; + } + if (typeof raw !== "string") return false; var parts = raw.split(","); for (var i = 0; i < parts.length; i++) { if (parts[i].trim() === name) return true; @@ -994,7 +1001,7 @@ func GenerateJSCode(node *ast.Node) (string, string) { // The async IIFE would otherwise turn that into an unhandled rejection // and a zero exit. try_let still catches the throw before it reaches // this handler. - code += fmt.Sprintf(";(async () => {\n%s\n})().catch((err) => {\n console.error(err && err.message ? err.message : err);\n process.exit(1);\n});\n", appCode) + code += fmt.Sprintf(";(async () => {\n%s\n})().catch((err) => {\n console.error(err && err.message ? err.message : err);\n if (typeof process !== \"undefined\" && typeof process.exit === \"function\") {\n process.exit(1);\n } else {\n throw err;\n }\n});\n", appCode) } else { code += fmt.Sprintf(";(async () => {\n%s\n})();\n", appCode) }