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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script>globalThis.HOWLFRAME_ALLOW_CAPS = "network";</script>
<script src="app.js"></script>
```

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.
1 change: 1 addition & 0 deletions change_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<root>` and `filesystem:write=<root>` 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/`.
Expand Down
8 changes: 8 additions & 0 deletions docs/journals/2026-10-12_browser_capability_dogfood.md
Original file line number Diff line number Diff line change
@@ -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.
105 changes: 105 additions & 0 deletions internal/backend/javascript/browser_fetch_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 11 additions & 4 deletions internal/backend/javascript/javascript.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}
Expand Down
Loading