diff --git a/README.md b/README.md index 7620064..c1136f3 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ go run howlframe.go -run-bc -allow-caps network,filesystem examples/cli_hello.ho 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")` and `(exec cmd args...)` 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 or a process is spawned. `environment` returns the value. `process` runs the command. Other generated host effects are not on this gate yet. +Generated Go and JavaScript mediate `(env "KEY")`, `(exec cmd args...)`, and `(read_file 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, or a file is read. `environment` returns the value. `process` runs the command. `filesystem` reads the file. Other generated host effects are not on this gate yet. 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/change_log.md b/change_log.md index 0e8b5c3..4b0aaad 100644 --- a/change_log.md +++ b/change_log.md @@ -3,6 +3,7 @@ ## Unreleased ### Changed +* Generated Go and JavaScript mediate `(read_file)`. An empty, missing, or non-`filesystem` `HOWLFRAME_ALLOW_CAPS` grant fails with `CAPABILITY_DENIED` before any filesystem read, and the denial does not include the path. The `filesystem` grant reads the file. The production `-compile-bc` path is unchanged. Journal: `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`. * Generated Go and JavaScript mediate `(exec)`. An empty, missing, or non-`process` `HOWLFRAME_ALLOW_CAPS` grant fails with `CAPABILITY_DENIED` before a subprocess starts, and the denial does not include the command. The `process` grant runs it. The production `-compile-bc` path is unchanged. Journal: `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`. * Generated Go and JavaScript mediate `(env)`. An empty or non-`environment` `HOWLFRAME_ALLOW_CAPS` grant fails with `CAPABILITY_DENIED` before the variable is read. The `environment` grant returns the value. The production `-compile-bc` path is unchanged. Journal: `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`. * `map_keys` order is UTF-8 byte order on the interpreter, the bytecode VM, the Go backend, and JavaScript. JavaScript no longer uses UTF-16 code-unit sort, so a supplementary-plane key sorts with the same list as Go `sort.Strings`. No new opcode and no capability change. Journal: `docs/journals/2026-09-30_dict_key_sort.md`. diff --git a/docs/hfir_execution_status.md b/docs/hfir_execution_status.md index c301956..7964f7f 100644 --- a/docs/hfir_execution_status.md +++ b/docs/hfir_execution_status.md @@ -81,9 +81,9 @@ Improvement #88 has a real but deliberately bounded execution destination: model `docs/reference/lowered_hfir_abi_v1.md` versions the contract the hosts must share (`lowered-hfir-abi/v1`). The conformance suite compares the interpreter, the bytecode VM, Go, and JavaScript on a pure core and on `env` denial. That comparison runs the production AST paths through `tools/difftest`. It does not switch `-compile-bc` over to `LowerToBytecode`. -Phase 2a mediates `env` in generated Go and JavaScript. An empty `HOWLFRAME_ALLOW_CAPS` grant is `CAPABILITY_DENIED` and does not read the variable. Phase 2b mediates `exec` the same way: an empty grant, or a grant that omits `process`, is `CAPABILITY_DENIED` before any subprocess starts. The production compiler is unchanged. +Phase 2a mediates `env` in generated Go and JavaScript. An empty `HOWLFRAME_ALLOW_CAPS` grant is `CAPABILITY_DENIED` and does not read the variable. Phase 2b mediates `exec` the same way: an empty grant, or a grant that omits `process`, is `CAPABILITY_DENIED` before any subprocess starts. Phase 2c mediates `read_file` the same way: an empty grant, or a grant that omits `filesystem`, is `CAPABILITY_DENIED` before any filesystem read. The production compiler is unchanged. -Wasm feasibility in this revision is the closed set `exec`, `spawn_agent`, and `http_server_start` (`HFIR_TARGET_INFEASIBLE`). Control edges are still unpopulated. Calls are still not executable HFIR. The rest of Phase 2 is the execution-path move. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`. +Wasm feasibility in this revision is the closed set `exec`, `spawn_agent`, and `http_server_start` (`HFIR_TARGET_INFEASIBLE`). Control edges are still unpopulated. Calls are still not executable HFIR. The rest of Phase 2 is the execution-path move. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`. ## Provenance limitation diff --git a/docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md b/docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md new file mode 100644 index 0000000..1f32071 --- /dev/null +++ b/docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md @@ -0,0 +1,30 @@ +# Lowered HFIR ABI, phase 2c: read_file grants on Go and JavaScript + +## Why this slice + +Phase 2a put `(env)` behind `HOWLFRAME_ALLOW_CAPS`. Phase 2b did the same for `(exec)`. `(read_file path)` on generated Go was still a direct `os.ReadFile`, and JavaScript had no `read_file` form. The bytecode VM already denies `OpReadFile` before `os.ReadFile`. The interpreter already denied a missing `filesystem` grant, then rejected the form as unsupported, so a granted read there was not an honest file read. This slice makes the generated hosts check the grant, and makes the interpreter read only after that same check. + +Phase 2 as a whole is still one lowered graph for every host. This change does not take that step. #90 stays Partial. + +## What landed + +`(read_file path)` in generated Go and JavaScript goes through `howlFrameReadFile`. The helper reads the runner grant `HOWLFRAME_ALLOW_CAPS` (comma-separated, the same names as `-allow-caps`). The grant name is `filesystem`, matching `capability.ForConstruct("read_file")` and `OpReadFile`. It panics or throws `CAPABILITY_DENIED: capability denied: filesystem` when `filesystem` is absent, and only then would it read. An empty grant, an unset grant, and a grant of some other capability all deny. The denial does not include the path, and the file is not opened. + +A `filesystem` grant reads the file. Go's `try_let` still binds `(bytes, error)` from `howlFrameReadFile`, so a granted miss stays an IO error instead of a capability denial. `let` and `bytes_to_string` use `howlFrameReadFileBytes`, which returns one `[]byte` after that same check. JavaScript returns the UTF-8 text from `readFileSync` after the check. The interpreter now reads with `os.ReadFile` after its existing `filesystem` check and returns those bytes, so the conformance case can include it. The bytecode VM is unchanged: `OpReadFile` is already `filesystem`, and the gate runs before the read. + +`tools/difftest` already passes `HOWLFRAME_ALLOW_CAPS` to generated Go and `node`. `tests/conformance/lowered_hfir_abi_v1.json` runs `read_file_denied` and `read_file_granted` on the interpreter, the bytecode VM, Go, and JavaScript. The fixture reads `/tmp/howlframe-abi-v1-phase2c.txt`. The conformance test writes `phase2c-read-marker` there first, because generated Go does not share the caller's working directory. The marker is absent on denial and printed when `filesystem` is granted. + +The JavaScript checker accepts `read_file` so a `web_app` body can host the same fixture. Production `-compile-bc` is still `runHFIRGate` and then `bytecode.CompileToBytecode` on the AST. + +## What is still Phase 2 + +* `-compile-bc` still compiles the AST. `-compile-hfir-bc` is still the experimental lowerer. +* `defun`, `call`, and `while` are still not executable HFIR. +* `ControlEdges` are still empty, so the graph is not SSA. +* Generated `fetch`, `write_file`, `mkdir`, and the other host effects still do not consult a grant. `env`, `exec`, and `read_file` do. `write_file` and `mkdir` stayed out: JavaScript and the interpreter do not implement them, and folding them into this helper would be a second host effect. +* Feasibility is still a Wasm-only set. This slice adds no Wasm opcode and does not execute Wasm. +* No module linker, no HFIR module graph, and no VM module opcode. + +## What this does not do + +No new capability kind. No new opcode. #102–#105 and #108 stay Done and are not reopened. #90 stays Partial. diff --git a/docs/reference/lowered_hfir_abi_v1.md b/docs/reference/lowered_hfir_abi_v1.md index 08798c8..9d4e477 100644 --- a/docs/reference/lowered_hfir_abi_v1.md +++ b/docs/reference/lowered_hfir_abi_v1.md @@ -69,7 +69,7 @@ v1 defines no linear memory and no Wasm import table. Observability imports `print`, `stderr`, and `exit` grant nothing. -Host effects are named by `capability.ForConstruct`. The v1 suite uses two of them. `(env "KEY")` requires `environment`. An empty grant denies it before the variable is read. The grant `environment` returns the value. `(exec cmd args...)` requires `process`, the same name as `OpExec`. An empty grant denies it before any subprocess starts. The grant `process` runs the command and returns its output. File, network, and database imports stay on that same table and are not given new opcodes here. +Host effects are named by `capability.ForConstruct`. The v1 suite uses three of them. `(env "KEY")` requires `environment`. An empty grant denies it before the variable is read. The grant `environment` returns the value. `(exec cmd args...)` requires `process`, the same name as `OpExec`. An empty grant denies it before any subprocess starts. The grant `process` runs the command and returns its output. `(read_file path)` requires `filesystem`, the same name as `OpReadFile`. An empty grant denies it before any filesystem read. The grant `filesystem` returns the file bytes. Network and database imports stay on that same table and are not given new opcodes here. ### Errors @@ -93,7 +93,9 @@ Pure operations declare no capability. `map_keys` and `map_get` stay pure (#107) `exec` is the process case. The suite binds `(exec "printf" "phase2b-exec-marker")` and prints that output as text. With no grant, the same four hosts reject with `CAPABILITY_DENIED`, exit nonzero, write no stdout, and do not include the marker. With the `process` grant, those hosts print the marker. The command is not a shell pipeline. -The interpreter and the bytecode VM consult `-allow-caps`. Generated Go and JavaScript do the same check in `howlFrameEnv` and `howlFrameExec`. Their runner grant is `HOWLFRAME_ALLOW_CAPS`, a comma-separated list of the same names as `-allow-caps`. An empty or unset value denies. A grant that omits the required name denies. `howlFrameEnv` may read that grant variable. It does not read the requested key until `environment` is present. `howlFrameExec` does not spawn until `process` is present, and the denial text does not contain the command. Other generated host effects, including `read_file` and `fetch`, are still not mediated. +`read_file` is the filesystem case. The suite binds `(read_file "/tmp/howlframe-abi-v1-phase2c.txt")` and prints those bytes as text. The conformance test writes `phase2c-read-marker` to that absolute path before the case, because generated Go runs in its own directory. With no grant, the same four hosts reject with `CAPABILITY_DENIED`, exit nonzero, write no stdout, and do not include the marker. With the `filesystem` grant, those hosts print the marker. + +The interpreter and the bytecode VM consult `-allow-caps`. Generated Go and JavaScript do the same check in `howlFrameEnv`, `howlFrameExec`, and `howlFrameReadFile`. Their runner grant is `HOWLFRAME_ALLOW_CAPS`, a comma-separated list of the same names as `-allow-caps`. An empty or unset value denies. A grant that omits the required name denies. `howlFrameEnv` may read that grant variable. It does not read the requested key until `environment` is present. `howlFrameExec` does not spawn until `process` is present, and the denial text does not contain the command. `howlFrameReadFile` does not call `os.ReadFile` or `readFileSync` until `filesystem` is present, and the denial text does not contain the path. Other generated host effects, including `fetch`, `write_file`, and `mkdir`, are still not mediated. ### Feasibility @@ -128,7 +130,7 @@ Phase 2 is one lowered graph consumed by every host, with identical outcomes or * Production `-compile-bc` still compiles the AST. Flipping that path is Phase 2. * `defun`, `call`, and `while` become executable HFIR, or every host rejects them with one code. Today the hosts run them and the experimental lowerer rejects them. * `ControlEdges` are populated and the graph is SSA. -* Go and JavaScript mediate `env` (Phase 2a) and `exec` (Phase 2b). Other generated host effects, including `read_file` and `fetch`, still do not. One lowered graph for every host is still the rest of Phase 2. +* Go and JavaScript mediate `env` (Phase 2a), `exec` (Phase 2b), and `read_file` (Phase 2c). Other generated host effects, including `fetch`, `write_file`, and `mkdir`, still do not. One lowered graph for every host is still the rest of Phase 2. * One feasibility table covers every target, not only the three Wasm host effects. * Non-exact integer division picks one rule. * Wasm collections (#73) and `for` / `match` / `try_let` / `spawn` SSA lowering (#84) target this ABI. They are not part of v1, and this revision does not grow them. diff --git a/improvements.md b/improvements.md index a8041b0..11569bd 100644 --- a/improvements.md +++ b/improvements.md @@ -45,7 +45,7 @@ Pending rows are ranked by a diminishing-returns score: | 106 | [Bytecode modules: design spike only](#106-bytecode-modules-design-spike-only) | Done (2026-09-30) | 1.50 (6×1÷4) | Opus 5 | Gemini 3.1 Pro (High) | gpt-5.6-sol | Do not build a bytecode-tier linker yet. Flat multi-file programs already run on `-compile-bc` / `-run-bc` through #95. A half linker and an HFIR module patch are refused. Journal: `docs/journals/2026-09-30_bytecode_modules_spike.md`. | | 108 | [Fail-closed TYPE_ERROR on remaining dict and list ops](#108-fail-closed-type_error-on-remaining-dict-and-list-ops) | Done (2026-09-29) | 1.50 (6×1÷4) | Sonnet 5 | Gemini 3.1 Pro (High) | gpt-5.6-sol | `append`, `map_set`, `map_delete`, `map_get`, `list_get`, and `list_len` reject a wrong receiver with `TYPE_ERROR` on the VM, Go, and JS. A missing `map_get` key is still `""`. No new capability. Journal: `docs/journals/2026-09-29_type_error_collections.md`. | | 88 | [Define a provider-neutral HFIR model-adapter protocol](#88-define-a-provider-neutral-hfir-model-adapter-protocol) | Pending | 1.5 (6×1÷4) | Opus 5 | — | gpt-5.6-terra | Mask plans are provider-neutral but no adapter produces or repairs a verified semantic program artifact. A schema, constrained-decoding boundary, and delta protocol prevents lock-in and reduces regeneration cost. | -| 90 | [Define the lowered-HFIR backend ABI and conformance suite](#90-define-the-lowered-hfir-backend-abi-and-conformance-suite) | Partial — Phase 1 Done, Phase 2a Done, Phase 2b (exec) Done (2026-09-30); Phase 2 remaining | 1.5 (6×1÷4) | Opus 5 | — | gpt-5.6-sol | Phase 1 is the versioned ABI and the difftest suite. Phase 2a mediates `env`. Phase 2b mediates `exec` on generated Go and JavaScript. Production execution is still AST → bytecode. One lowered graph, calls, and Wasm stay in Phase 2. #90 is not Done. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`. | +| 90 | [Define the lowered-HFIR backend ABI and conformance suite](#90-define-the-lowered-hfir-backend-abi-and-conformance-suite) | Partial — Phase 1 Done, Phase 2a Done, Phase 2b (exec) Done, Phase 2c (read_file) Done (2026-09-30); Phase 2 remaining | 1.5 (6×1÷4) | Opus 5 | — | gpt-5.6-sol | Phase 1 is the versioned ABI and the difftest suite. Phase 2a mediates `env`. Phase 2b mediates `exec`. Phase 2c mediates `read_file` on generated Go and JavaScript. Production execution is still AST → bytecode. One lowered graph, calls, and Wasm stay in Phase 2. #90 is not Done. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`. | | 89 | [Add content-addressed HFIR storage and incremental compilation](#89-add-content-addressed-hfir-storage-and-incremental-compilation) | Pending | 1.4 (7×1÷5) | Opus 5 | — | gpt-5.6-sol | Stable graph identities permit small repairs, dependency-closure recompilation, reproducible artifacts, and bounded model context instead of whole-program regeneration. | | 95 | [Standalone Runtime Module Use/Export](#95-standalone-runtime-module-use-export) | Done (2026-08-08) | 1.33 (8×0.5÷3) | Sonnet 5 | Gemini 3.1 Pro (High) | — | Multi-file `cli_app` compilation now works through `-compile-bc`/`-run-bc`. `ast.ResolveModules`'s existing compile-time AST linker (improvement #93) already handled the standalone path; closed the nested-import gap with a fail-closed boundary and reclassified `use`/`export`/`module` as `CompileTimeOnly`. Journal: `docs/journals/2026-08-08_improvement_95_standalone_modules.md`. | | 109 | [Dict key sort parity across VM, Go, and JS, including non-BMP](#109-dict-key-sort-parity-across-vm-go-and-js-including-non-bmp) | Done (2026-09-30) | 1.33 (4×1÷3) | Sonnet 5 | Gemini 3.1 Pro (High) | gpt-5.6-sol | `map_keys` order is UTF-8 byte order on the VM, Go, and JS, including non-BMP keys. JavaScript no longer uses UTF-16 code-unit sort. #73 must reuse this order. Journal: `docs/journals/2026-09-30_dict_key_sort.md`. | @@ -788,14 +788,14 @@ As HowlFrame matures past transpilation into Go and JS, the ultimate objective i * **Required tests:** invalidation, hash determinism, corruption recovery, and reproducible-build integration. ### 90. Define the lowered-HFIR backend ABI and conformance suite -* **Status:** Partial — Phase 1 Done, Phase 2a Done, Phase 2b (exec) Done (2026-09-30); Phase 2 remaining. The contract is `docs/reference/lowered_hfir_abi_v1.md` (`lowered-hfir-abi/v1`). The suite is `tests/conformance/lowered_hfir_abi_v1.json`, executed by `tools/difftest` on the interpreter, the bytecode VM, Go, and JavaScript where that host applies. Wasm's v1 rule is `HFIR_TARGET_INFEASIBLE` for `exec`, `spawn_agent`, and `http_server_start` only. Production `-compile-bc` is still `runHFIRGate` and then `bytecode.CompileToBytecode` on the AST. `-compile-hfir-bc` stays the experimental lowerer. `ControlEdges` are still empty. `defun`, `call`, and `while` are not executable HFIR. Phase 2a: generated Go and JavaScript mediate `env` the same way the interpreter and the bytecode VM do. An empty grant is `CAPABILITY_DENIED` and does not read the variable. Phase 2b: those hosts mediate `exec` the same way. The grant name is `process`. An empty or missing grant, or a grant that omits `process`, is `CAPABILITY_DENIED` before any subprocess starts, and the denial does not include the command. The `process` grant runs the command. Other generated host effects, including `read_file` and `fetch`, are still unmediated. Phase 2 is one lowered graph for every host. #90 is not Done. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`. +* **Status:** Partial — Phase 1 Done, Phase 2a Done, Phase 2b (exec) Done, Phase 2c (read_file) Done (2026-09-30); Phase 2 remaining. The contract is `docs/reference/lowered_hfir_abi_v1.md` (`lowered-hfir-abi/v1`). The suite is `tests/conformance/lowered_hfir_abi_v1.json`, executed by `tools/difftest` on the interpreter, the bytecode VM, Go, and JavaScript where that host applies. Wasm's v1 rule is `HFIR_TARGET_INFEASIBLE` for `exec`, `spawn_agent`, and `http_server_start` only. Production `-compile-bc` is still `runHFIRGate` and then `bytecode.CompileToBytecode` on the AST. `-compile-hfir-bc` stays the experimental lowerer. `ControlEdges` are still empty. `defun`, `call`, and `while` are not executable HFIR. Phase 2a: generated Go and JavaScript mediate `env` the same way the interpreter and the bytecode VM do. An empty grant is `CAPABILITY_DENIED` and does not read the variable. Phase 2b: those hosts mediate `exec` the same way. The grant name is `process`. An empty or missing grant, or a grant that omits `process`, is `CAPABILITY_DENIED` before any subprocess starts, and the denial does not include the command. The `process` grant runs the command. Phase 2c: those hosts mediate `read_file` the same way. The grant name is `filesystem`. An empty or missing grant, or a grant that omits `filesystem`, is `CAPABILITY_DENIED` before any filesystem read, and the denial does not include the path. The `filesystem` grant reads the file. Other generated host effects, including `fetch`, `write_file`, and `mkdir`, are still unmediated. Phase 2 is one lowered graph for every host. #90 is not Done. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`. * **Description:** Specify a target-independent lowered HFIR ABI for typed CFG/SSA, calls, memory/runtime imports, errors, effects, and capability boundaries. Migrate a deterministic core subset and compare interpreter, bytecode, Go, JS where applicable, and Wasm outcomes or explicit rejections. -* **Current reality (recorded 2026-09-30):** HFIR is still not the production execution source. `-compile-bc` verifies a lowered graph and then compiles the AST. The experimental `-compile-hfir-bc` path can emit bytecode for a semantic subset (`let`, `if`, `map_keys`, `env`, and the forms in `internal/hfir/bytecode.go`) and fails closed with `HFIR_BYTECODE_UNSUPPORTED` outside it, including `defun`, `call`, and `while`. `LowerAST` still does not populate `ControlEdges`. On 2026-08-07 that direct path did not exist; it exists now as research, not as the compiler. Go, JavaScript, and the interpreter still consume the AST. The v1 suite compares those hosts on a pure core and on capability denial. Phase 2a puts generated Go and JavaScript on the `env` denial and granted cases. Phase 2b puts those hosts on the `exec` denial and granted cases. It does not flip the execution path. +* **Current reality (recorded 2026-09-30):** HFIR is still not the production execution source. `-compile-bc` verifies a lowered graph and then compiles the AST. The experimental `-compile-hfir-bc` path can emit bytecode for a semantic subset (`let`, `if`, `map_keys`, `env`, and the forms in `internal/hfir/bytecode.go`) and fails closed with `HFIR_BYTECODE_UNSUPPORTED` outside it, including `defun`, `call`, and `while`. `LowerAST` still does not populate `ControlEdges`. On 2026-08-07 that direct path did not exist; it exists now as research, not as the compiler. Go, JavaScript, and the interpreter still consume the AST. The v1 suite compares those hosts on a pure core and on capability denial. Phase 2a puts generated Go and JavaScript on the `env` denial and granted cases. Phase 2b puts those hosts on the `exec` denial and granted cases. Phase 2c puts those hosts on the `read_file` denial and granted cases. It does not flip the execution path. * **Why:** Each current backend owns overlapping semantics. More backend expansion without a contract will amplify drift. * **Dependencies:** #86 and #87; incorporates #78. #73 and #84 should target this ABI. * **Acceptance criteria:** one lowering runs equivalently on each supported target; unsupported effects use the same feasibility contract. Phase 1 meets the second clause for the closed Wasm host-effect set and records identical outcomes for the core subset on the hosts that already implement it. The first clause, one lowering as the source for every host, is Phase 2. * **Required tests:** differential and property-based deterministic fixtures plus negative capability tests. Phase 1 adds those on the existing difftest harness. `tests/parity` remains the broader AST corpus, including calls. -* **Team backlog (2026-09-29, updated 2026-09-30):** Phase 1 is in. Phase 2a mediates `env`. Phase 2b mediates `exec` on Go and JavaScript only. `read_file`, `fetch`, and the other host effects stay open. Wasm expansion stays gated on the rest of Phase 2; do not grow a thin Wasm backend ahead of it. See `docs/journals/2026-09-29_team_howlframe_backlog.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, and `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`. +* **Team backlog (2026-09-29, updated 2026-09-30):** Phase 1 is in. Phase 2a mediates `env`. Phase 2b mediates `exec` on Go and JavaScript. Phase 2c mediates `read_file` on Go and JavaScript. `fetch`, `write_file`, `mkdir`, and the other host effects stay open. Wasm expansion stays gated on the rest of Phase 2; do not grow a thin Wasm backend ahead of it. See `docs/journals/2026-09-29_team_howlframe_backlog.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, and `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`. ### 91. Add semantic patch deltas and bounded repair context * **Description:** Make autonomous repair operate on HFIR deltas addressed to stable IDs. Require preconditions, touched dependencies, expected invariants, and regression evidence, with compact context extracted from graph neighborhoods and diagnostics. diff --git a/internal/backend/gogen/gogen.go b/internal/backend/gogen/gogen.go index 95ed649..b85ab28 100644 --- a/internal/backend/gogen/gogen.go +++ b/internal/backend/gogen/gogen.go @@ -38,6 +38,10 @@ var gogenNeedsEnv bool // runner grant before spawning a process. It is reset on every call. var gogenNeedsExec bool +// gogenNeedsReadFile is set when GenerateCode emits read_file. The helper +// checks the runner grant before any filesystem read. It is reset on every call. +var gogenNeedsReadFile bool + // gogenVarTypes records the Go type of names emitted in the current // GenerateCode call. An untracked name stays on the historical direct // operation. A dynamic any value goes through the fail-closed helpers. @@ -482,9 +486,35 @@ func execHelperSource() string { ` } +// readFileHelperSource mediates (read_file path). The grant name is +// filesystem, the same name as capability.ForConstruct("read_file") and +// OpReadFile. os.ReadFile runs only after that grant is present, so a +// denial cannot read the file or include the path in the error. +// howlFrameReadFile keeps the (bytes, error) pair that try_let already +// binds. howlFrameReadFileBytes is the single value let and bytes_to_string +// expect; an IO failure there panics after the grant check. +func readFileHelperSource() string { + return `func howlFrameReadFile(path string) ([]byte, error) { + if !howlFrameGrantHas("filesystem") { + panic("CAPABILITY_DENIED: capability denied: filesystem") + } + return os.ReadFile(path) +} + +func howlFrameReadFileBytes(path string) []byte { + b, err := howlFrameReadFile(path) + if err != nil { + panic(fmt.Sprintf("IO_ERROR: read_file failed: %v", err)) + } + return b +} + +` +} + func goHostHelpers() string { var code string - if gogenNeedsEnv || gogenNeedsExec { + if gogenNeedsEnv || gogenNeedsExec || gogenNeedsReadFile { code += grantHelperSource() } if gogenNeedsEnv { @@ -493,6 +523,9 @@ func goHostHelpers() string { if gogenNeedsExec { code += execHelperSource() } + if gogenNeedsReadFile { + code += readFileHelperSource() + } return code } @@ -520,6 +553,20 @@ func goExecCall(node *ast.Node, reqVar string, depth int) string { return fmt.Sprintf("howlFrameExec(%s, %s)", cmd, strings.Join(args, ", ")) } +// goReadFileCall emits the mediated read. tuple is the try_let form, which +// still binds (bytes, error). Every other use gets one []byte. +func goReadFileCall(node *ast.Node, reqVar string, depth int, tuple bool) string { + gogenNeedsReadFile = true + if node == nil || len(node.Children) != 2 { + return "" + } + pathStr := generateStatement(node.Children[1], reqVar, depth+1) + if tuple { + return fmt.Sprintf("howlFrameReadFile(%s)", pathStr) + } + return fmt.Sprintf("howlFrameReadFileBytes(%s)", pathStr) +} + func GenerateCode(node *ast.Node) (string, string) { CurrentSchemaDDLs = nil gogenHTTPReq = false @@ -528,6 +575,7 @@ func GenerateCode(node *ast.Node) (string, string) { gogenCollection = false gogenNeedsEnv = false gogenNeedsExec = false + gogenNeedsReadFile = false resetGoVarTypes() if node.Type != "List" || len(node.Children) == 0 { // ast.ReportError("Expected list at root", node.Line, node.Column) @@ -1249,7 +1297,12 @@ func EmitGoIR(ir *ir.IRNode, reqVar string, depth int) string { } } - valStr := generateStatement(valNode, reqVar, depth+1) + var valStr string + if valNode.Type == "List" && len(valNode.Children) > 0 && valNode.Children[0].Value == "read_file" { + valStr = goReadFileCall(valNode, reqVar, depth, true) + } else { + valStr = generateStatement(valNode, reqVar, depth+1) + } return fmt.Sprintf(` { %s, %s := %s if %s != nil { @@ -1604,11 +1657,7 @@ func generateStatementRaw(node *ast.Node, reqVar string, depth int) string { queryStr := generateExpression(queryNode, reqVar, depth+1) return fmt.Sprintf(" %s.Query(%s)", dbVar, queryStr) } else if head == "read_file" { - if len(node.Children) != 2 { - // ast.ReportError("read_file expects (read_file path)", node.Line, node.Column) - } - pathStr := generateStatement(node.Children[1], reqVar, depth+1) - return fmt.Sprintf("os.ReadFile(%s)", pathStr) + return goReadFileCall(node, reqVar, depth, false) } else if head == "write_file" { if len(node.Children) != 3 { // ast.ReportError("write_file expects (write_file path data)", node.Line, node.Column) diff --git a/internal/backend/gogen/read_file_capability_test.go b/internal/backend/gogen/read_file_capability_test.go new file mode 100644 index 0000000..509a11d --- /dev/null +++ b/internal/backend/gogen/read_file_capability_test.go @@ -0,0 +1,149 @@ +package gogen + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/howlcipher/howlframe/internal/checker" + "github.com/howlcipher/howlframe/internal/lexer" + "github.com/howlcipher/howlframe/internal/parser" +) + +func TestGoReadFileRequiresGrantBeforeRead(t *testing.T) { + dir := t.TempDir() + secretPath := filepath.Join(dir, "phase2c-secret-path.txt") + const secretBody = "phase2c-secret-bytes" + if err := os.WriteFile(secretPath, []byte(secretBody), 0o644); err != nil { + t.Fatal(err) + } + source := `(cli_app + (let (value (read_file "` + secretPath + `")) + (print (bytes_to_string value))))` + root := parser.NewParser(lexer.NewLexer(source), "read_file.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateCode(root) + if strings.Contains(code, `os.ReadFile("`) { + t.Fatalf("read_file reads at the call site:\n%s", code) + } + if !strings.Contains(code, `howlFrameReadFileBytes("`) { + t.Fatalf("read_file is not mediated:\n%s", code) + } + helperAt := strings.Index(code, "func howlFrameReadFile(") + if helperAt < 0 { + t.Fatalf("missing howlFrameReadFile helper:\n%s", code) + } + helper := code[helperAt:] + denyAt := strings.Index(helper, "CAPABILITY_DENIED") + readAt := strings.Index(helper, "os.ReadFile") + if denyAt < 0 || readAt < 0 || denyAt > readAt { + t.Fatalf("grant check must precede the filesystem read:\n%s", helper) + } + + rootDir := moduleRoot(t) + crashPath := filepath.Join(rootDir, "crash.json") + t.Cleanup(func() { os.Remove(crashPath) }) + + assertDenied := func(label, output, errText string) { + t.Helper() + if errText == "" { + t.Fatalf("%s: read_file succeeded: %s", label, output) + } + if strings.Contains(output, secretBody) || strings.Contains(output, "phase2c-secret-path") { + t.Fatalf("%s: process output leaked the file: %s", label, output) + } + crash, err := os.ReadFile(crashPath) + if err != nil { + t.Fatalf("%s: denial did not write crash.json: %v\n%s", label, err, output) + } + if strings.Contains(string(crash), secretBody) || strings.Contains(string(crash), "phase2c-secret-path") { + t.Fatalf("%s: crash.json leaked the file: %s", label, crash) + } + if !strings.Contains(string(crash), "CAPABILITY_DENIED") || !strings.Contains(string(crash), "capability denied: filesystem") { + t.Fatalf("%s: crash.json = %s, want CAPABILITY_DENIED", label, crash) + } + os.Remove(crashPath) + } + + unsetOut, unsetErr := runGenerated(t, rootDir, code) + assertDenied("unset grant", unsetOut, unsetErr) + + emptyOut, emptyErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=") + assertDenied("empty grant", emptyOut, emptyErr) + + otherOut, otherErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=process") + assertDenied("process grant", otherOut, otherErr) + + granted, grantErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=filesystem") + if grantErr != "" { + t.Fatalf("filesystem grant failed: %s\n%s", grantErr, granted) + } + if strings.TrimSpace(granted) != secretBody { + t.Fatalf("granted stdout = %q, want %s", granted, secretBody) + } +} + +func TestGoReadFileTryLetDeniesBeforeRead(t *testing.T) { + missing := filepath.Join(t.TempDir(), "phase2c-secret-path.txt") + source := `(cli_app + (try_let (value (read_file "` + missing + `")) + (catch err (print "caught-io")) + (print "read-ok")))` + root := parser.NewParser(lexer.NewLexer(source), "read_file.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateCode(root) + if strings.Contains(code, ":= howlFrameReadFileBytes(") { + t.Fatalf("try_let read_file dropped the error return:\n%s", code) + } + if !strings.Contains(code, "value, err := howlFrameReadFile(") { + t.Fatalf("try_let read_file is not mediated:\n%s", code) + } + if strings.Contains(code, `os.ReadFile("`) { + t.Fatalf("try_let read_file reads at the call site:\n%s", code) + } + + rootDir := moduleRoot(t) + crashPath := filepath.Join(rootDir, "crash.json") + t.Cleanup(func() { os.Remove(crashPath) }) + + denied, errText := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=") + if errText == "" { + t.Fatalf("empty grant succeeded: %s", denied) + } + if strings.Contains(denied, "caught-io") || strings.Contains(denied, "read-ok") { + t.Fatalf("empty grant treated the miss as an IO error: %s", denied) + } + crash, err := os.ReadFile(crashPath) + if err != nil { + t.Fatalf("denial did not write crash.json: %v\n%s", err, denied) + } + if strings.Contains(string(crash), "phase2c-secret-path") { + t.Fatalf("crash.json leaked the path: %s", crash) + } + if !strings.Contains(string(crash), "CAPABILITY_DENIED") || !strings.Contains(string(crash), "capability denied: filesystem") { + t.Fatalf("crash.json = %s, want CAPABILITY_DENIED", crash) + } + os.Remove(crashPath) + + granted, grantErr := runGenerated(t, rootDir, code, "HOWLFRAME_ALLOW_CAPS=filesystem") + if grantErr != "" { + t.Fatalf("filesystem grant failed: %s\n%s", grantErr, granted) + } + if strings.TrimSpace(granted) != "caught-io" { + t.Fatalf("granted miss stdout = %q, want caught-io", granted) + } +} + +func TestGoReadFileExpressionPositionIsMediated(t *testing.T) { + const source = `(cli_app (print (bytes_to_string (read_file "phase2c-expr.txt"))))` + root := parser.NewParser(lexer.NewLexer(source), "read_file.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateCode(root) + if !strings.Contains(code, `howlFrameReadFileBytes("phase2c-expr.txt")`) { + t.Fatalf("expression-position read_file is not mediated:\n%s", code) + } + if strings.Contains(code, `os.ReadFile("`) { + t.Fatalf("expression-position read_file bypasses the grant:\n%s", code) + } +} diff --git a/internal/backend/javascript/javascript.go b/internal/backend/javascript/javascript.go index 204b4e1..603efbf 100644 --- a/internal/backend/javascript/javascript.go +++ b/internal/backend/javascript/javascript.go @@ -66,6 +66,10 @@ var jsNeedsEnv bool // runner grant before spawning a process. It is reset on every call. var jsNeedsExec bool +// jsNeedsReadFile is set when GenerateJSCode emits read_file. The helper +// checks the runner grant before any filesystem read. It is reset on every call. +var jsNeedsReadFile bool + func collectionJSHelper() string { // Dicts are plain objects. Lists are arrays. A missing map_get key is // still "". list_get of an in-range element returns that element. An @@ -309,8 +313,24 @@ func execJSHelper() string { ` } +// readFileJSHelper mediates (read_file path). The grant name is filesystem, +// the same name as capability.ForConstruct("read_file") and OpReadFile. +// readFileSync runs only after that grant is present, so a denial cannot +// read the file or put the path in the error. The return is UTF-8 text, +// which bytes_to_string prints unchanged. +func readFileJSHelper() string { + return `function howlFrameReadFile(path) { + if (!howlFrameGrantHas("filesystem")) { + throw new Error("CAPABILITY_DENIED: capability denied: filesystem"); + } + return require("fs").readFileSync(path, "utf8"); +} + +` +} + func jsHostHelpers() string { - if !jsNeedsEnv && !jsNeedsExec { + if !jsNeedsEnv && !jsNeedsExec && !jsNeedsReadFile { return "" } helper := grantJSHelper() @@ -320,6 +340,9 @@ func jsHostHelpers() string { if jsNeedsExec { helper += execJSHelper() } + if jsNeedsReadFile { + helper += readFileJSHelper() + } return helper } @@ -344,6 +367,15 @@ func jsExecCall(node *ast.Node, reqVar string, depth int) string { return fmt.Sprintf("howlFrameExec(%s, [%s])", cmd, strings.Join(args, ", ")) } +func jsReadFileCall(node *ast.Node, reqVar string, depth int) string { + jsNeedsReadFile = true + if node == nil || len(node.Children) != 2 { + return "" + } + pathStr := generateJSExpression(node.Children[1], reqVar, depth+1) + return fmt.Sprintf("howlFrameReadFile(%s)", pathStr) +} + func sanitizeJSName(name string) string { if !strings.Contains(name, "/") { return name @@ -643,6 +675,7 @@ func GenerateJSCode(node *ast.Node) (string, string) { jsNeedsMapKeyOrder = false jsNeedsEnv = false jsNeedsExec = false + jsNeedsReadFile = false if node.Type != "List" || len(node.Children) == 0 { // ast.ReportError("Expected list at root", node.Line, node.Column) } @@ -714,10 +747,10 @@ func GenerateJSCode(node *ast.Node) (string, string) { // keeps them reachable as globals for inline event handlers. code := funcsCode if strings.TrimSpace(appCode) != "" { - if jsNeedsEnv || jsNeedsExec { - // A denied env read or exec throws. 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. + if jsNeedsEnv || jsNeedsExec || jsNeedsReadFile { + // A denied env read, exec, or read_file throws. 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) } else { code += fmt.Sprintf(";(async () => {\n%s\n})();\n", appCode) @@ -901,6 +934,8 @@ func generateJSStatementRaw(node *ast.Node, reqVar string, depth int) string { return jsEnvCall(node.Children[1], reqVar, depth) } else if head == "exec" { return jsExecCall(node, reqVar, depth) + } else if head == "read_file" { + return jsReadFileCall(node, reqVar, depth) } else if head == "req_query" || head == "req_header" || head == "req_path" { jsNeedsRequestRead = true if len(node.Children) != 3 { diff --git a/internal/backend/javascript/read_file_capability_test.go b/internal/backend/javascript/read_file_capability_test.go new file mode 100644 index 0000000..56cf03f --- /dev/null +++ b/internal/backend/javascript/read_file_capability_test.go @@ -0,0 +1,117 @@ +package javascript + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/howlcipher/howlframe/internal/checker" + "github.com/howlcipher/howlframe/internal/lexer" + "github.com/howlcipher/howlframe/internal/parser" +) + +func TestJSReadFileRequiresGrantBeforeRead(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node runtime not available") + } + dir := t.TempDir() + secretPath := filepath.Join(dir, "phase2c-secret-path.txt") + const secretBody = "phase2c-secret-bytes" + if err := os.WriteFile(secretPath, []byte(secretBody), 0o644); err != nil { + t.Fatal(err) + } + source := `(web_app + (let (value (read_file "` + secretPath + `")) + (print (bytes_to_string value))))` + root := parser.NewParser(lexer.NewLexer(source), "read_file.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateJSCode(root) + if strings.Contains(code, `readFileSync("`) { + t.Fatalf("read_file reads at the call site:\n%s", code) + } + if !strings.Contains(code, `howlFrameReadFile("`) { + t.Fatalf("read_file is not mediated:\n%s", code) + } + helperAt := strings.Index(code, "function howlFrameReadFile") + if helperAt < 0 { + t.Fatalf("missing howlFrameReadFile helper:\n%s", code) + } + helper := code[helperAt:] + denyAt := strings.Index(helper, "CAPABILITY_DENIED") + readAt := strings.Index(helper, "readFileSync") + if denyAt < 0 || readAt < 0 || denyAt > readAt { + t.Fatalf("grant check must precede the filesystem read:\n%s", helper) + } + + assertDenied := func(label, stdout, stderr string, code int) { + t.Helper() + if code == 0 { + t.Fatalf("%s: read_file succeeded: %s", label, stdout+stderr) + } + combined := stdout + stderr + if strings.Contains(combined, secretBody) || strings.Contains(combined, "phase2c-secret-path") { + t.Fatalf("%s: output leaked the file: stdout=%q stderr=%q", label, stdout, stderr) + } + if !strings.Contains(stderr, "CAPABILITY_DENIED") || !strings.Contains(stderr, "capability denied: filesystem") { + t.Fatalf("%s: stderr = %q, want CAPABILITY_DENIED", label, stderr) + } + if strings.TrimSpace(stdout) != "" { + t.Fatalf("%s: denied stdout = %q, want empty", label, stdout) + } + } + + unsetOut, unsetErr, unsetCode := runJS(t, code) + assertDenied("unset grant", unsetOut, unsetErr, unsetCode) + + emptyOut, emptyErr, emptyCode := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=") + assertDenied("empty grant", emptyOut, emptyErr, emptyCode) + + otherOut, otherErr, otherCode := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=process") + assertDenied("process grant", otherOut, otherErr, otherCode) + + grantedOut, grantedErr, grantedCode := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=filesystem") + if grantedCode != 0 { + t.Fatalf("filesystem grant failed (%d): %s", grantedCode, grantedErr) + } + if strings.TrimSpace(grantedOut) != secretBody { + t.Fatalf("granted stdout = %q, want %s", grantedOut, secretBody) + } +} + +func TestJSReadFileMissingPathDeniesBeforeRead(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node runtime not available") + } + missing := filepath.Join(t.TempDir(), "phase2c-secret-path.txt") + source := `(web_app (print (bytes_to_string (read_file "` + missing + `"))))` + root := parser.NewParser(lexer.NewLexer(source), "read_file.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateJSCode(root) + + stdout, stderr, exitCode := runJS(t, code, "HOWLFRAME_ALLOW_CAPS=") + if exitCode == 0 { + t.Fatalf("empty grant succeeded: %s", stdout+stderr) + } + combined := stdout + stderr + if strings.Contains(combined, "phase2c-secret-path") || strings.Contains(combined, "ENOENT") { + t.Fatalf("denial read the path: stdout=%q stderr=%q", stdout, stderr) + } + if !strings.Contains(stderr, "CAPABILITY_DENIED") || !strings.Contains(stderr, "capability denied: filesystem") { + t.Fatalf("stderr = %q, want CAPABILITY_DENIED", stderr) + } +} + +func TestJSReadFileExpressionPositionIsMediated(t *testing.T) { + const source = `(web_app (print (bytes_to_string (read_file "phase2c-expr.txt"))))` + root := parser.NewParser(lexer.NewLexer(source), "read_file.howl").ParseExpression() + checker.Check(root) + code, _ := GenerateJSCode(root) + if !strings.Contains(code, `howlFrameReadFile("phase2c-expr.txt")`) { + t.Fatalf("expression-position read_file is not mediated:\n%s", code) + } + if strings.Contains(code, `readFileSync("`) { + t.Fatalf("expression-position read_file bypasses the grant:\n%s", code) + } +} diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 773f6d6..9dd0a5f 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -341,6 +341,11 @@ func checkJSStatement(node *ast.Node, depth int) { for _, child := range node.Children[1:] { checkJSStatement(child, depth+1) } + } else if head == "read_file" { + if len(node.Children) != 2 { + ast.ReportError("read_file expects (read_file path)", node.Line, node.Column) + } + checkJSStatement(node.Children[1], depth+1) } else { ast.ReportError(fmt.Sprintf("Unknown statement for JS: %s", head), node.Line, node.Column) } diff --git a/internal/vm/ast_interp_cap_test.go b/internal/vm/ast_interp_cap_test.go index 601cf53..5b3ccef 100644 --- a/internal/vm/ast_interp_cap_test.go +++ b/internal/vm/ast_interp_cap_test.go @@ -100,6 +100,73 @@ func TestInterpretExecGrantedRuns(t *testing.T) { } } +func TestInterpretReadFileDeniedBeforeRead(t *testing.T) { + dir := t.TempDir() + secretPath := filepath.Join(dir, "phase2c-secret-path.txt") + const secretBody = "phase2c-secret-bytes" + if err := os.WriteFile(secretPath, []byte(secretBody), 0o644); err != nil { + t.Fatal(err) + } + source := `(cli_app (print (bytes_to_string (read_file "` + secretPath + `"))))` + node, _ := parseAndCompile(t, source) + + var stdout, stderr bytes.Buffer + exitCode := Interpret(node, nil, nil, strings.NewReader(""), &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("expected Interpret to deny read_file without filesystem, got exit 0; stdout=%q", stdout.String()) + } + errStr := stderr.String() + if !strings.Contains(errStr, "capability denied: filesystem") { + t.Fatalf("expected 'capability denied: filesystem' in stderr, got: %s", errStr) + } + if strings.Contains(errStr+stdout.String(), secretBody) || strings.Contains(errStr, "phase2c-secret-path") { + t.Fatalf("denial leaked the file: stdout=%q stderr=%q", stdout.String(), errStr) + } + + stdout.Reset() + stderr.Reset() + other := []capability.Capability{capability.Process} + exitCode = Interpret(node, nil, other, strings.NewReader(""), &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("process grant read the file; stdout=%q", stdout.String()) + } + if strings.Contains(stdout.String()+stderr.String(), secretBody) { + t.Fatalf("process grant leaked the file: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + + missing := filepath.Join(dir, "phase2c-missing-secret.txt") + missingSource := `(cli_app (print (bytes_to_string (read_file "` + missing + `"))))` + missingNode, _ := parseAndCompile(t, missingSource) + stdout.Reset() + stderr.Reset() + exitCode = Interpret(missingNode, nil, nil, strings.NewReader(""), &stdout, &stderr) + if exitCode == 0 { + t.Fatal("missing path was read without filesystem") + } + if strings.Contains(stderr.String(), "phase2c-missing-secret") || strings.Contains(stderr.String(), "no such file") { + t.Fatalf("denial reached the filesystem: %s", stderr.String()) + } +} + +func TestInterpretReadFileGrantedReads(t *testing.T) { + path := filepath.Join(t.TempDir(), "phase2c-read.txt") + if err := os.WriteFile(path, []byte("phase2c-read-marker"), 0o644); err != nil { + t.Fatal(err) + } + source := `(cli_app (print (bytes_to_string (read_file "` + path + `"))))` + node, _ := parseAndCompile(t, source) + + var stdout, stderr bytes.Buffer + allowed := []capability.Capability{capability.Filesystem} + exitCode := Interpret(node, nil, allowed, strings.NewReader(""), &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected Interpret to read with filesystem, got exit %d; stderr=%s", exitCode, stderr.String()) + } + if strings.TrimSpace(stdout.String()) != "phase2c-read-marker" { + t.Fatalf("stdout = %q, want phase2c-read-marker", stdout.String()) + } +} + func TestInterpretNetworkCapabilityDeniedByDefault(t *testing.T) { source := `(cli_app (neural_circuit () "test prompt"))` node, _ := parseAndCompile(t, source) diff --git a/internal/vm/vm.go b/internal/vm/vm.go index bcb9490..ad4eb7a 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -721,6 +721,17 @@ func (interp *Interpreter) evalList(node *ast.Node, env *InterpEnv) any { InterpErr(fmt.Sprintf("IO_ERROR: exec failed: %v", err), node) } return out + case "read_file": + // requireCapability already ran for filesystem. Read only after that grant. + if len(node.Children) != 2 { + InterpErr("read_file expects (read_file path)", node) + } + path := fmt.Sprint(interp.eval(node.Children[1], env)) + b, err := os.ReadFile(path) + if err != nil { + InterpErr(fmt.Sprintf("IO_ERROR: read_file failed: %v", err), node) + } + return b } InterpErr(fmt.Sprintf("%q is not supported under -run in Phase 1 (see docs/direct_execution_design.md)", head), node.Children[0]) diff --git a/tests/conformance/abi_v1/07_read_file_capability.howl b/tests/conformance/abi_v1/07_read_file_capability.howl new file mode 100644 index 0000000..374103c --- /dev/null +++ b/tests/conformance/abi_v1/07_read_file_capability.howl @@ -0,0 +1,3 @@ +(cli_app + (let (value (read_file "/tmp/howlframe-abi-v1-phase2c.txt")) + (print (bytes_to_string value)))) diff --git a/tests/conformance/lowered_hfir_abi_v1.json b/tests/conformance/lowered_hfir_abi_v1.json index a3e20c5..e374457 100644 --- a/tests/conformance/lowered_hfir_abi_v1.json +++ b/tests/conformance/lowered_hfir_abi_v1.json @@ -89,6 +89,24 @@ "javascript_root": "web_app", "expect": "PASS", "stdout": "phase2b-exec-marker" + }, + { + "name": "read_file_denied", + "fixture": "tests/conformance/abi_v1/07_read_file_capability.howl", + "deny_all": true, + "targets": ["bytecode", "interpreter", "go", "javascript"], + "javascript_root": "web_app", + "expect": "CAPABILITY_DENIED", + "forbid": "phase2c-read-marker" + }, + { + "name": "read_file_granted", + "fixture": "tests/conformance/abi_v1/07_read_file_capability.howl", + "allow_caps": "filesystem", + "targets": ["bytecode", "interpreter", "go", "javascript"], + "javascript_root": "web_app", + "expect": "PASS", + "stdout": "phase2c-read-marker" } ] } diff --git a/tools/difftest/conformance_test.go b/tools/difftest/conformance_test.go index f1b6e49..ce47178 100644 --- a/tools/difftest/conformance_test.go +++ b/tools/difftest/conformance_test.go @@ -71,6 +71,13 @@ func (c conformanceCase) wantStdout() string { // share one error class and must not print a forbidden value. func TestLoweredHFIRABIConformance(t *testing.T) { t.Setenv("HOWLFRAME_ABI_SECRET", "phase1-token") + // 07_read_file_capability.howl reads this absolute path. Generated Go runs + // in its own temp directory, so a relative fixture path would miss. + const readMarkerPath = "/tmp/howlframe-abi-v1-phase2c.txt" + if err := os.WriteFile(readMarkerPath, []byte("phase2c-read-marker"), 0o644); err != nil { + t.Fatalf("write read_file marker: %v", err) + } + t.Cleanup(func() { os.Remove(readMarkerPath) }) root, file := loadConformance(t) if file.ABI != hfir.LoweredABIV1 { t.Fatalf("manifest abi = %q, want %q", file.ABI, hfir.LoweredABIV1) diff --git a/tools/difftest/difftest.go b/tools/difftest/difftest.go index 1043cf8..80e736f 100644 --- a/tools/difftest/difftest.go +++ b/tools/difftest/difftest.go @@ -178,8 +178,8 @@ func (o RunOptions) capFlags() []string { } // hostGrantValue is the runner grant for generated Go and JavaScript. -// Those hosts read HOWLFRAME_ALLOW_CAPS at env and exec. An empty value -// denies. Nil AllowCaps keeps the historical grant of every known capability. +// Those hosts read HOWLFRAME_ALLOW_CAPS at env, exec, and read_file. An empty +// value denies. Nil AllowCaps keeps the historical grant of every known capability. func (o RunOptions) hostGrantValue() string { if o.DenyAll { return ""