From 4a8c5f40d84d7207ac355cd609797f174b386898 Mon Sep 17 00:00:00 2001 From: howlcipher Date: Tue, 6 Oct 2026 11:04:09 -0400 Subject: [PATCH] S8/P2.1: path-scoped filesystem grants (read/write roots) Close the filesystem half of S8: -allow-caps accepts filesystem:read= and filesystem:write= (repeatable), with coarse filesystem remaining unrestricted. Roots Abs/Clean at parse; VM and AST interpreter deny out-of-root and .. escapes with CAPABILITY_DENIED before I/O; EvalSymlinks on longest existing ancestor. file:// stores need read (and write for put/delete). Process/network/env scoping and SPAWN attenuation remain open; gogen/JS stay coarse; no prod -compile-bc/HFIR flip; #90 stays Partial. Journal: docs/journals/2026-10-06_path_scoped_filesystem.md Co-authored-by: howlcipher --- README.md | 12 ++ change_log.md | 1 + .../07-security-and-authority-findings.md | 2 +- .../08-roadmap-p0-p4.md | 2 +- docs/cli.md | 2 +- docs/index.html | 2 +- .../2026-10-06_path_scoped_filesystem.md | 72 ++++++++ docs/reference/lowered_hfir_abi_v1.md | 2 +- howlframe.go | 16 +- howlframe_cli_test.go | 43 +++++ internal/capability/grants.go | 115 +++++++++++++ internal/capability/grants_test.go | 62 +++++++ internal/vm/filesystem_scope_test.go | 161 ++++++++++++++++++ internal/vm/vm.go | 65 ++++++- 14 files changed, 537 insertions(+), 20 deletions(-) create mode 100644 docs/journals/2026-10-06_path_scoped_filesystem.md create mode 100644 internal/capability/grants.go create mode 100644 internal/capability/grants_test.go create mode 100644 internal/vm/filesystem_scope_test.go diff --git a/README.md b/README.md index 62ad8bb..07f1714 100644 --- a/README.md +++ b/README.md @@ -440,6 +440,18 @@ Pass an allow-list with `-allow-caps`, a comma-separated list of capability name go run howlframe.go -run-bc -allow-caps network,filesystem examples/cli_hello.howl.bc.bin ``` +For native bytecode and `-run`, use repeatable path grants such as +`-allow-caps filesystem:read=/data,filesystem:write=/out`. Read grants permit +reads only; write grants permit writes and `mkdir` only. `filesystem` remains +an unrestricted alias. Roots are cleaned and made absolute relative to the +runner's cwd when parsed. Targets must stay within a matching root, including +after symlink resolution. A `file://` store also needs `database`: open/load, +get, and keys need read coverage; put/delete need both read and write coverage. +Empty roots, unknown filesystem sub-keys, and scoped forms of other +capabilities are rejected. Checks precede I/O, but concurrent symlink changes +remain a TOCTOU risk. Generated Go and JavaScript still accept coarse grants +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). diff --git a/change_log.md b/change_log.md index c5c84d9..976293b 100644 --- a/change_log.md +++ b/change_log.md @@ -3,6 +3,7 @@ ## Unreleased ### Fixed +* 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/ai-native-language-review/07-security-and-authority-findings.md b/docs/ai-native-language-review/07-security-and-authority-findings.md index 6ea4776..86004e7 100644 --- a/docs/ai-native-language-review/07-security-and-authority-findings.md +++ b/docs/ai-native-language-review/07-security-and-authority-findings.md @@ -18,7 +18,7 @@ their own code. | S5 | **Unbounded recursion crashes the process.** At review time, `MaxCallDepth` (128) guarded only `SPAWN_AGENT`; ordinary bytecode `CALL` recursion could overflow the Go stack. C4a now bounds bytecode CALL with structured `LIMIT_EXCEEDED`, default 1000 shared with SPAWN_AGENT nesting, and a runner flag. AST interpreter recursion remains unbounded by this limit. | Medium | PARTIAL (C4a, 2026-10-06): bytecode CALL bounded; AST interpreter remains open. | `/tmp` probe with `--max-instructions 2000000000` | | S6 | **At review, wall-clock was unbounded.** `sleep`, `fetch`, `exec`, `read_line`, and model calls block outside the instruction count. | Medium | PARTIAL (C4b, 2026-10-06): optional deadline cancels bytecode sleep/fetch/exec/model requests and checks the instruction loop; blocking read_line and HTTP serving remain open, as does AST interpreter. | C4b deadline regression tests. Historical Codex B: a 5 s `sleep` under a 3-instruction ceiling ran until killed. Code: `vm.go:2122`, `:2170`. | | S7 | **At review, response and output sizes were unbounded.** `fetch` read the whole body and `exec` used `CombinedOutput`. `print` remains unbounded. | Medium | PARTIAL (C4b, 2026-10-06): bytecode fetch body and combined exec output capped at 10 MiB by default; print remains uncapped; interpreter unchanged. | C4b byte-cap regression tests; historical code reading (A, B) | -| S8 | **`process` grant = arbitrary host authority.** `(exec "/bin/sh" "-c" "...")` with only `process` writes files. Grants are five classes with no resource scoping. | High (policy design) | OPEN (P1/P2: scoped grants, action allow-list) | Codex B ran it in `/tmp` on both VM and interpreter | +| S8 | **`process` grant = arbitrary host authority.** `(exec "/bin/sh" "-c" "...")` with only `process` writes files. Native filesystem grants now support read/write roots; process still permits arbitrary host authority. | High (policy design) | PARTIAL-closed: filesystem roots enforced in VM/interpreter; process/network still OPEN (P2.1) | Codex B ran it in `/tmp` on both VM and interpreter; filesystem evidence: [2026-10-06 journal](../journals/2026-10-06_path_scoped_filesystem.md) | | S9 | **Child agents inherit the full grant.** `SPAWN_AGENT` and legacy `SPAWN` children get `AllowedCaps` unchanged (`vm.go:2233`, `:3030`). Legacy `SPAWN` also resets instruction accounting. | Medium | OPEN (P2: attenuation) | Code reading (B) | | S10 | **Semantic divergence changes effects.** `(and (= 1 2) (= (env "X") "x"))`: the VM and interpreter evaluate eagerly and hit `CAPABILITY_DENIED`. Generated Go short-circuits and prints `false`. | Medium | OPEN (P1: decide `and`/`or` semantics, add differential test) | Found by Codex A. Reproduced in this review. | | S11 | **Generated Go/JS gate only 6 effects.** Model calls, SQL, listeners, goroutines, and JS `spawn` run ungated. A generated-Go panic writes `crash.json` unconditionally. | Medium (documented) | OPEN. Keep Go/JS out of the governed profile. | README states it. Codex B confirmed. | diff --git a/docs/ai-native-language-review/08-roadmap-p0-p4.md b/docs/ai-native-language-review/08-roadmap-p0-p4.md index 59c0c94..61e6f89 100644 --- a/docs/ai-native-language-review/08-roadmap-p0-p4.md +++ b/docs/ai-native-language-review/08-roadmap-p0-p4.md @@ -34,7 +34,7 @@ the artifact format. | # | Item | Size | | --- | --- | --- | -| P2.1 | Resource-scoped grants: `filesystem:read=/data`, `filesystem:write=/out`, `network=host[:port]`, `process=`, `environment=VAR`. Keep the coarse names as aliases. | L | +| P2.1 | Resource-scoped grants: `filesystem:read=/data`, `filesystem:write=/out`, `network=host[:port]`, `process=`, `environment=VAR`. Keep the coarse names as aliases. Filesystem portion implemented in native VM/interpreter (read and write independent); remaining scopes OPEN. See [journal](../journals/2026-10-06_path_scoped_filesystem.md). | L | | P2.2 | Grant attenuation for `SPAWN_AGENT` children. Fold legacy `SPAWN` accounting into the parent budget. | M | | P2.3 | Full artifact validation (stack-height, operand types, embedded body lengths) for artifacts from outside | L | | P2.4 | Rooted / hash-pinned `include` and `use` for the governed profile | M | diff --git a/docs/cli.md b/docs/cli.md index 17ed11b..aee614e 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`). Instructions requiring an unlisted capability are denied and will cause the VM to panic. +* `--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. * `--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/index.html b/docs/index.html index b68c9db..b3762c5 100644 --- a/docs/index.html +++ b/docs/index.html @@ -409,7 +409,7 @@

SECTION // 04 Capability Boundary Matrix

filesystem file_read, file_write DENY (Blocked) - Runner flag: -allow-caps filesystem + Runner flag: -allow-caps filesystem:read=/data,filesystem:write=/out (native VM; filesystem remains unrestricted) system diff --git a/docs/journals/2026-10-06_path_scoped_filesystem.md b/docs/journals/2026-10-06_path_scoped_filesystem.md new file mode 100644 index 0000000..a777666 --- /dev/null +++ b/docs/journals/2026-10-06_path_scoped_filesystem.md @@ -0,0 +1,72 @@ +# 2026-10-06: Path-scoped filesystem grants (S8 / P2.1) + +## Findings and change + +- S8 is PARTIAL-closed: the native filesystem portion is implemented. The + original process authority finding remains open. P2.1 retains its other scopes. +- `-allow-caps filesystem:read=/data,filesystem:write=/out` accepts repeatable + roots. Read grants authorize reads only. Write grants authorize write_file + and mkdir only. The existing `filesystem` alias remains unrestricted. +- `internal/capability/grants.go` exposes ParseGrant and Grants. ParseGrant + returns Capability values compatible with existing []capability.Capability + APIs, including RunBytecode and Interpreter.AllowedCaps. Roots are Abs/Clean + at parse time, relative to runner cwd. Direct callers should use ParseGrant. + Scoped grants count as filesystem for coarse required-capability gates; + path checks remain mandatory before native I/O. RequiredCapabilities still + reports the coarse effect class, not evidence of path authorization. +- Invalid/empty roots, unknown filesystem sub-keys, and colon scopes on other + capabilities produce the existing CLI unknown-capability error. +- `howlframe.go` parses CLI grants; `internal/vm/vm.go` enforces read_file, + write_file, mkdir in both execution engines. Native file stores check the + path before open/load and at storeHandle for every get/keys/put/delete. + Database is still required. Open/get/keys need read; put/delete need both + read and write. Memory stores are unchanged. No interpreter file-store + implementation exists to extend. +- Containment uses filepath.Rel, rejecting parent escapes and prefix siblings. + EvalSymlinks resolves each root and the target's longest existing ancestor; + resolution errors and dangling symlinks fail closed. Scoped I/O uses the + checked absolute cleaned path, preventing raw symlink/.. traversal from + differing from the permission check. Roots themselves may be symlinks. +- Scoped receipt authorization is recorded only after all required path checks + pass; denied paths record denied rather than a provisional coarse allowed. +- Denial precedes file I/O with the existing CAPABILITY_DENIED format in the + VM and existing capability denial in the interpreter. Denials omit paths. + +## Verification + +- TestParseGrant: valid forms, invalid forms, cwd anchoring. +- TestFilesystemGrantContainment: independent read/write scopes, containment, + prefix siblings, parent escape, unrestricted alias, symlink and dangling-link + escape denial with nonexistent write targets. +- TestFilesystemScopesVMAndInterpreter: reads/writes/mkdir inside/outside, + parent escape, independent scopes, coarse alias, symlink escapes, and + unchanged files/directories after denied mutations. +- TestFileStoreFilesystemScopes: allowed put/delete, denied put/delete outside + write coverage, read-only get/keys, denied open outside read coverage, and + write-only open denial; denied mutations leave persisted bytes unchanged. +- TestFilesystemScopeReceiptDecision: scoped denial and success decisions, + including read-only store mutation denial after successful open. +- TestCLIPathScopedFilesystemGrants: relative/repeated roots, coarse alias, + write-only read denial, invalid roots/sub-keys/other-capability scopes. +- `gofmt -l .`: empty output; `go vet ./...`: passed; `go build -v ./...`: + passed. Targeted capability/VM/CLI tests passed. +- `python3 -m unittest test_harness.py` in benchmarks/v2/harness: 8 tests + passed. Its deliberate failing fixture attempts are expected harness probes; + the command exits zero. `python3 scripts/test_seo.py`: all checks passed. +- Initial `go test ./...` collided with the concurrently running Python + harness HTTP fixture on port 8080 (TestHTTPServerServeHFBC). The full Go + suite passed after the harness finished. A subsequent `go test ./...` on + the final code (including receipt decisions) also passed, all packages. + +## Remaining work + +- Process allow-list, network host scoping, and environment=VAR remain open. +- S9/P2.2 SPAWN_AGENT attenuation is unchanged; children inherit runner grants. +- Generated Go and JavaScript still use coarse howlFrameGrantHas("filesystem") + checks through HOWLFRAME_ALLOW_CAPS. Scoped native execution does not establish + backend parity. No production -compile-bc/HFIR default flip or #90 status change. +- Symlink checks reduce static escapes but are not atomic with I/O: concurrent + replacement of symlinks/ancestors remains a TOCTOU risk. Hard links and host + mount changes are not isolated by path scoping. Strong isolation needs + descriptor-relative OS containment or a host sandbox in a later change. +- No live LLM calls, process/network scoping, commit, or push in this change. diff --git a/docs/reference/lowered_hfir_abi_v1.md b/docs/reference/lowered_hfir_abi_v1.md index fcc4612..32adef1 100644 --- a/docs/reference/lowered_hfir_abi_v1.md +++ b/docs/reference/lowered_hfir_abi_v1.md @@ -153,7 +153,7 @@ The conformance cases `nested_fetch_denied` and `nested_fetch_granted` are `test The conformance cases `nested_multi_effect_denied`, `nested_multi_effect_partial`, and `nested_multi_effect_granted` are `tests/conformance/abi_v1/21_nested_multi_effect.howl`. Their hosts are the same five. One `defun` nests `env`, `read_file`, `write_file`, `mkdir`, `exec`, and `fetch` inside `if`, `while`, and `for`, including a `for` inside a `for`. With no grant, the first reached effect is `env`. The denial is `CAPABILITY_DENIED` before any later effect, stdout is empty, and no path is created. With `environment,filesystem,process` and without `network`, the first site prints `phase1-token`, `multi-read-marker`, and `phase2b-exec-marker`, writes one file, creates one directory, and then `fetch` is `CAPABILITY_DENIED`. No request is sent. With `environment,filesystem,process,network`, the taken lines continue through `phase2d-fetch-marker`, `L a 0`, `L b 0`, `kept 2`, the kept site, `result 2`, `miss`, the miss site, and `empty 0`. The untaken branches must not print, must not create their paths, and must not send their requests. One untaken `fetch` passes a body string. `OpFetch` has no body operand, and neither bytecode compiler loads that string. This case is evidence that the experimental lowerer and the AST bytecode compiler execute that combination the same way. It does not switch `-compile-bc` to HFIR. -The interpreter and the bytecode VM consult `-allow-caps`. Generated Go and JavaScript do the same check in `howlFrameEnv`, `howlFrameExec`, `howlFrameReadFile`, `howlFrameFetch`, `howlFrameWriteFile`, and `howlFrameMkdir`. 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. `howlFrameFetch` does not call `http.NewRequest`, `http.DefaultClient.Do`, or `fetch` until `network` is present, and the denial text does not contain the URL. `howlFrameWriteFile` does not call `os.WriteFile` or `writeFileSync` until `filesystem` is present, and the denial text does not contain the path. `howlFrameMkdir` does not call `os.MkdirAll` or `mkdirSync` until `filesystem` is present, and the denial text does not contain the path. Other generated host effects are still not mediated. +The interpreter and the bytecode VM consult `-allow-caps`. Generated Go and JavaScript do the same check in `howlFrameEnv`, `howlFrameExec`, `howlFrameReadFile`, `howlFrameFetch`, `howlFrameWriteFile`, and `howlFrameMkdir`. Their runner grant is `HOWLFRAME_ALLOW_CAPS`, a comma-separated list of coarse capability names. Native VM/interpreter `-allow-caps` additionally accepts `filesystem:read=` and `filesystem:write=`; generated Go/JavaScript do not enforce these scopes. 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. `howlFrameFetch` does not call `http.NewRequest`, `http.DefaultClient.Do`, or `fetch` until `network` is present, and the denial text does not contain the URL. `howlFrameWriteFile` does not call `os.WriteFile` or `writeFileSync` until `filesystem` is present, and the denial text does not contain the path. `howlFrameMkdir` does not call `os.MkdirAll` or `mkdirSync` until `filesystem` is present, and the denial text does not contain the path. Other generated host effects are still not mediated. ### Feasibility diff --git a/howlframe.go b/howlframe.go index 42b9eb7..baee74d 100644 --- a/howlframe.go +++ b/howlframe.go @@ -59,7 +59,7 @@ func main() { requiredCaps := flag.Bool("required-caps", false, "report required capabilities as compact JSON without execution") receiptPath := flag.String("receipt", "", "write runner-owned bytecode execution receipt JSON to this path") runBc := flag.Bool("run-bc", false, "run bytecode from JSON file") - allowCaps := flag.String("allow-caps", "", "comma-separated capabilities to allow when running bytecode with -run-bc (network,filesystem,process,environment,database); instructions requiring an unlisted capability are denied") + allowCaps := flag.String("allow-caps", "", "comma-separated capabilities to allow when running bytecode with -run-bc (network,filesystem,filesystem:read=,filesystem:write=,process,environment,database); instructions requiring an unlisted capability are denied") maxInstructions := flag.Int("max-instructions", vm.DefaultLimits.MaxInstructions, "positive finite instruction ceiling for -run-bc (default 100000; zero and negative values are invalid)") maxCallDepth := flag.Int("max-call-depth", vm.DefaultLimits.MaxCallDepth, "positive ceiling for CALL recursion and SPAWN_AGENT nesting for -run-bc (default 1000)") maxMemory := flag.Int("max-memory-bytes", vm.DefaultLimits.MaxMemoryBytes, "cumulative allocation ceiling in bytes") @@ -566,14 +566,6 @@ func reportHFIRDiagnostics(diags []hfir.Diagnostic) { os.Exit(1) } -var knownCapabilities = map[capability.Capability]bool{ - capability.Network: true, - capability.Filesystem: true, - capability.Process: true, - capability.Environment: true, - capability.Database: true, -} - // parseAllowedCaps turns -allow-caps into a capability allow-list. An empty // or unset flag denies every capability-gated instruction (fail-closed // default); RunBytecode always permits CapNone regardless of this list. @@ -587,8 +579,8 @@ func parseAllowedCaps(raw string) []capability.Capability { if part == "" { continue } - cap := capability.Capability(part) - if !knownCapabilities[cap] { + cap, err := capability.ParseGrant(part) + if err != nil { ast.ReportError(fmt.Sprintf("unknown capability in -allow-caps: %q", part), 0, 0) } caps = append(caps, cap) @@ -909,7 +901,7 @@ func runArtifact() { runFlags := flag.NewFlagSet("run", flag.ExitOnError) receiptPath := runFlags.String("receipt", "", "write runner-owned bytecode execution receipt JSON to this path") target := runFlags.String("target", "bytecode", "execution target: bytecode (or bc), interpreter (or run)") - allowCaps := runFlags.String("allow-caps", "", "comma-separated capabilities to allow (network,filesystem,process,environment,database)") + allowCaps := runFlags.String("allow-caps", "", "comma-separated capabilities to allow (network,filesystem,filesystem:read=,filesystem:write=,process,environment,database)") maxInst := runFlags.Int("max-instructions", vm.DefaultLimits.MaxInstructions, "finite instruction limit") maxCallDepth := runFlags.Int("max-call-depth", vm.DefaultLimits.MaxCallDepth, "positive ceiling for CALL recursion and SPAWN_AGENT nesting (default 1000)") diff --git a/howlframe_cli_test.go b/howlframe_cli_test.go index a96729e..fe2b429 100644 --- a/howlframe_cli_test.go +++ b/howlframe_cli_test.go @@ -809,3 +809,46 @@ func TestLegacyCLIAppExitStatusAndFlaggedWriteOnly(t *testing.T) { }) } } + +func TestCLIPathScopedFilesystemGrants(t *testing.T) { + root := t.TempDir() + binary := filepath.Join(root, "howlframe") + if out, err := exec.Command("go", "build", "-o", binary, "howlframe.go").CombinedOutput(); err != nil { + t.Fatalf("build: %v %s", err, out) + } + input := filepath.Join(root, "input") + if err := os.WriteFile(input, []byte("scope fixture"), 0600); err != nil { + t.Fatal(err) + } + source := filepath.Join(root, "app.howl") + if err := os.WriteFile(source, []byte(`(cli_app (print (read_file "input")))`), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + grant string + allowed, invalid bool + }{ + {"filesystem:read=.", true, false}, + {"filesystem:read=.,filesystem:write=.", true, false}, + {"filesystem", true, false}, + {"filesystem:write=.", false, false}, + {"filesystem:read=", false, true}, + {"filesystem:unknown=.", false, true}, + {"network:read=.", false, true}, + } { + t.Run(tc.grant, func(t *testing.T) { + cmd := exec.Command(binary, "-run", "-allow-caps", tc.grant, source) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if (err == nil) != tc.allowed { + t.Fatalf("err=%v output=%s", err, out) + } + if tc.invalid && !strings.Contains(string(out), "unknown capability in -allow-caps") { + t.Fatalf("invalid grant: %s", out) + } + if !tc.allowed && !tc.invalid && !strings.Contains(string(out), "capability denied: filesystem") { + t.Fatalf("expected denial: %s", out) + } + }) + } +} diff --git a/internal/capability/grants.go b/internal/capability/grants.go new file mode 100644 index 0000000..6b2af1e --- /dev/null +++ b/internal/capability/grants.go @@ -0,0 +1,115 @@ +package capability + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ParseGrant validates a grant and anchors filesystem roots to the current cwd. +// The returned value can be passed through the existing []Capability APIs. +func ParseGrant(raw string) (Capability, error) { + for _, c := range All() { + if raw == string(c) { + return c, nil + } + } + for _, mode := range []string{"read", "write"} { + prefix := "filesystem:" + mode + "=" + if strings.HasPrefix(raw, prefix) && len(raw) > len(prefix) { + root, err := filepath.Abs(strings.TrimPrefix(raw, prefix)) + if err == nil { + return Capability(prefix + filepath.Clean(root)), nil + } + } + } + return None, fmt.Errorf("unknown capability: %q", raw) +} + +// Grants retains coarse capability compatibility while enforcing path scopes. +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:")) { + return true + } + } + return false +} + +// HasUnrestrictedFilesystem reports whether the legacy coarse alias is present. +func (g Grants) HasUnrestrictedFilesystem() bool { + for _, grant := range g { + if grant == Filesystem { + return true + } + } + return false +} + +func (g Grants) AllowsRead(path string) bool { return g.allows(path, "read") } +func (g Grants) AllowsWrite(path string) bool { return g.allows(path, "write") } + +func contained(root, target string) bool { + rel, err := filepath.Rel(root, target) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +// resolveAncestor follows symlinks even for a target that has not been created. +// Errors other than a missing component fail closed (including dangling links). +func resolveAncestor(path string) (string, error) { + resolved, err := filepath.EvalSymlinks(path) + if err == nil { + return resolved, nil + } + if !os.IsNotExist(err) { + return "", err + } + if _, e := os.Lstat(path); e == nil { + return "", err + } + parent := filepath.Dir(path) + if parent == path { + return "", err + } + resolved, err = resolveAncestor(parent) + if err != nil { + return "", err + } + return filepath.Join(resolved, filepath.Base(path)), nil +} + +func (g Grants) allows(path, mode string) bool { + if g.HasUnrestrictedFilesystem() { + return true + } + target, err := filepath.Abs(path) + if err != nil { + return false + } + target = filepath.Clean(target) + resolvedTarget, err := resolveAncestor(target) + if err != nil { + return false + } + prefix := "filesystem:" + mode + "=" + for _, grant := range g { + // Direct API callers must use ParseGrant to anchor relative roots. + raw := string(grant) + if !strings.HasPrefix(raw, prefix) { + continue + } + root := strings.TrimPrefix(raw, prefix) + if root == "" || !filepath.IsAbs(root) || !contained(filepath.Clean(root), target) { + continue + } + resolvedRoot, err := resolveAncestor(filepath.Clean(root)) + if err == nil && contained(resolvedRoot, resolvedTarget) { + return true + } + } + return false +} diff --git a/internal/capability/grants_test.go b/internal/capability/grants_test.go new file mode 100644 index 0000000..f1389af --- /dev/null +++ b/internal/capability/grants_test.go @@ -0,0 +1,62 @@ +package capability + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseGrant(t *testing.T) { + for _, raw := range []string{"filesystem", "network", "filesystem:read=.", "filesystem:write=../out"} { + grant, err := ParseGrant(raw) + if err != nil || !(Grants{grant}).Has(Capability(raw)) && !(Grants{grant}).Has(Filesystem) { + t.Fatalf("parse %q: %q %v", raw, grant, err) + } + } + for _, raw := range []string{"filesystem:read=", "filesystem:write=", "filesystem:execute=/tmp", "network:read=/tmp", "unknown", "filesystem:read"} { + if _, err := ParseGrant(raw); err == nil { + t.Fatalf("accepted %q", raw) + } + } + grant, _ := ParseGrant("filesystem:read=.") + cwd, _ := os.Getwd() + if grant != Capability("filesystem:read="+cwd) { + t.Fatalf("root not anchored: %q", grant) + } +} + +func TestFilesystemGrantContainment(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "data") + if err := os.Mkdir(root, 0755); err != nil { + t.Fatal(err) + } + read, _ := ParseGrant("filesystem:read=" + root) + write, _ := ParseGrant("filesystem:write=" + root) + for _, path := range []string{root, filepath.Join(root, "new", "file")} { + if !(Grants{read}).AllowsRead(path) || (Grants{read}).AllowsWrite(path) || !(Grants{write}).AllowsWrite(path) || (Grants{write}).AllowsRead(path) { + t.Fatalf("wrong scope for %s", path) + } + } + for _, path := range []string{filepath.Join(base, "data2", "file"), root + "/../escape"} { + if (Grants{read, write}).AllowsRead(path) || (Grants{read, write}).AllowsWrite(path) { + t.Fatalf("escape allowed: %s", path) + } + } + if !(Grants{Filesystem}).AllowsWrite(filepath.Join(base, "outside")) { + t.Fatal("coarse alias denied") + } + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + if (Grants{read, write}).AllowsWrite(filepath.Join(root, "link", "new")) { + t.Fatal("symlink escape allowed") + } + if err := os.Symlink(filepath.Join(outside, "missing"), filepath.Join(root, "dangling")); err != nil { + t.Fatal(err) + } + if (Grants{write}).AllowsWrite(filepath.Join(root, "dangling")) { + t.Fatal("dangling symlink allowed") + } +} diff --git a/internal/vm/filesystem_scope_test.go b/internal/vm/filesystem_scope_test.go new file mode 100644 index 0000000..99de53b --- /dev/null +++ b/internal/vm/filesystem_scope_test.go @@ -0,0 +1,161 @@ +package vm + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/howlcipher/howlframe/internal/bytecode" + "github.com/howlcipher/howlframe/internal/capability" + "github.com/howlcipher/howlframe/internal/lexer" + "github.com/howlcipher/howlframe/internal/parser" +) + +func TestFilesystemScopesVMAndInterpreter(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + insideFile := filepath.Join(root, "input") + outsideFile := filepath.Join(outside, "input") + for _, path := range []string{insideFile, outsideFile} { + if err := os.WriteFile(path, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + read, _ := capability.ParseGrant("filesystem:read=" + root) + write, _ := capability.ParseGrant("filesystem:write=" + root) + cases := []struct { + name, body string + grants []capability.Capability + denied bool + }{ + {"read inside", fmt.Sprintf("(read_file %q)", insideFile), []capability.Capability{read}, false}, + {"read outside", fmt.Sprintf("(read_file %q)", outsideFile), []capability.Capability{read}, true}, + {"write outside", fmt.Sprintf("(write_file %q \"changed\")", outsideFile), []capability.Capability{write}, true}, + {"mkdir outside", fmt.Sprintf("(mkdir %q)", filepath.Join(outside, "new")), []capability.Capability{write}, true}, + {"parent escape", fmt.Sprintf("(read_file %q)", root+"/../"+filepath.Base(outside)+"/input"), []capability.Capability{read}, true}, + {"read only", fmt.Sprintf("(write_file %q \"changed\")", insideFile), []capability.Capability{read}, true}, + {"write only", fmt.Sprintf("(read_file %q)", insideFile), []capability.Capability{write}, true}, + {"write inside", fmt.Sprintf("(write_file %q \"ok\")", filepath.Join(root, "output")), []capability.Capability{write}, false}, + {"mkdir inside", fmt.Sprintf("(mkdir %q)", filepath.Join(root, "new", "nested")), []capability.Capability{write}, false}, + {"coarse alias", fmt.Sprintf("(read_file %q)", outsideFile), []capability.Capability{capability.Filesystem}, false}, + {"symlink read", fmt.Sprintf("(read_file %q)", filepath.Join(root, "link", "input")), []capability.Capability{read}, true}, + {"symlink write", fmt.Sprintf("(write_file %q \"changed\")", filepath.Join(root, "link", "input")), []capability.Capability{write}, true}, + {"symlink mkdir", fmt.Sprintf("(mkdir %q)", filepath.Join(root, "link", "new")), []capability.Capability{write}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + node := parser.NewParser(lexer.NewLexer("(cli_app "+tc.body+")"), "scope.howl").ParseExpression() + var out, stderr bytes.Buffer + ev := RunBytecodeWithEvidence(bytecode.CompileToBytecode(node), nil, DefaultExecutionPolicy(), tc.grants, strings.NewReader(""), &out, &stderr, 0) + if tc.denied { + if ev.RuntimeFailure == nil || ev.RuntimeFailure.Code != "CAPABILITY_DENIED" { + t.Fatalf("expected denial: %+v", ev.RuntimeFailure) + } + } else if ev.RuntimeFailure != nil { + t.Fatalf("unexpected failure: %+v", ev.RuntimeFailure) + } + stderr.Reset() + code := Interpret(node, nil, tc.grants, strings.NewReader(""), &out, &stderr) + if (code != 0) != tc.denied || tc.denied && !strings.Contains(stderr.String(), "capability denied: filesystem") { + t.Fatalf("interpreter code %d: %s", code, &stderr) + } + }) + } + for _, path := range []string{insideFile, outsideFile} { + data, err := os.ReadFile(path) + if err != nil || string(data) != "original" { + t.Fatalf("denied write changed %s: %q %v", path, data, err) + } + } + if _, err := os.Stat(filepath.Join(outside, "new")); !os.IsNotExist(err) { + t.Fatalf("denied mkdir created directory: %v", err) + } +} + +func TestFileStoreFilesystemScopes(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + read, _ := capability.ParseGrant("filesystem:read=" + root) + write, _ := capability.ParseGrant("filesystem:write=" + root) + otherWrite, _ := capability.ParseGrant("filesystem:write=" + outside) + otherRead, _ := capability.ParseGrant("filesystem:read=" + outside) + for _, tc := range []struct { + name string + grants []capability.Capability + action string + denied bool + }{ + {"put inside", []capability.Capability{read, write}, `(store_put db "k" (dict ("v" 1)))`, false}, + {"put outside write root", []capability.Capability{read, otherWrite}, `(store_put db "k" (dict ("v" 2)))`, true}, + {"delete outside write root", []capability.Capability{read, otherWrite}, `(store_delete db "k")`, true}, + {"get read only", []capability.Capability{read}, `(store_get db "k")`, false}, + {"keys read only", []capability.Capability{read}, `(store_keys db)`, false}, + {"open outside read root", []capability.Capability{otherRead, write}, `(store_keys db)`, true}, + {"open write only", []capability.Capability{write}, `(store_keys db)`, true}, + {"delete inside", []capability.Capability{read, write}, `(store_delete db "k")`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(root, "store.json") + before, _ := os.ReadFile(path) + source := fmt.Sprintf("(cli_app (store_open db %q) %s)", "file://"+path, tc.action) + node := parser.NewParser(lexer.NewLexer(source), "store-scope.howl").ParseExpression() + caps := append([]capability.Capability{capability.Database}, tc.grants...) + var out, stderr bytes.Buffer + ev := RunBytecodeWithEvidence(bytecode.CompileToBytecode(node), nil, DefaultExecutionPolicy(), caps, nil, &out, &stderr, 0) + if tc.denied { + if ev.RuntimeFailure == nil || ev.RuntimeFailure.Code != "CAPABILITY_DENIED" { + t.Fatalf("expected denial: %+v", ev.RuntimeFailure) + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("denial changed store") + } + } else if ev.RuntimeFailure != nil { + t.Fatalf("unexpected failure: %+v", ev.RuntimeFailure) + } + }) + } +} + +func TestFilesystemScopeReceiptDecision(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + read, _ := capability.ParseGrant("filesystem:read=" + root) + write, _ := capability.ParseGrant("filesystem:write=" + root) + for _, tc := range []struct { + source, op string + denied bool + }{ + {fmt.Sprintf("(cli_app (read_file %q))", filepath.Join(outside, "missing")), "READ_FILE", true}, + {fmt.Sprintf("(cli_app (mkdir %q))", filepath.Join(root, "new")), "MKDIR", false}, + {fmt.Sprintf(`(cli_app (store_open db %q) (store_put db "k" (dict ("v" 1))))`, "file://"+filepath.Join(root, "store.json")), "STORE_PUT", true}, + } { + caps := []capability.Capability{capability.Database, read} + if tc.op == "MKDIR" { + caps = append(caps, write) + } + receipt, _, _ := receiptRun(t, tc.source, DefaultExecutionPolicy(), caps...) + found := false + for _, effect := range receipt.Effects { + if effect.Op == tc.op && effect.Capability == "filesystem" { + found = true + want := "allowed" + if tc.denied { + want = "denied" + } + if effect.Decision != want { + t.Fatalf("%s recorded %s, want %s", tc.op, effect.Decision, want) + } + } + } + if !found { + t.Fatalf("missing %s filesystem decision: %+v", tc.op, receipt) + } + } +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index ed6d1d5..e40c21d 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -23,6 +23,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "reflect" "regexp" "sort" @@ -94,7 +95,7 @@ type Interpreter struct { func (interp *Interpreter) requireCapability(cap capability.Capability, node *ast.Node) { for _, allowed := range interp.AllowedCaps { - if allowed == cap { + if capability.Grants([]capability.Capability{allowed}).Has(cap) { return } } @@ -759,6 +760,7 @@ func (interp *Interpreter) evalList(node *ast.Node, env *InterpEnv) any { InterpErr("read_file expects (read_file path)", node) } path := fmt.Sprint(interp.eval(node.Children[1], env)) + path = interp.requireFilesystem(path, false, node) b, err := os.ReadFile(path) if err != nil { InterpErr(fmt.Sprintf("IO_ERROR: read_file failed: %v", err), node) @@ -780,6 +782,7 @@ func (interp *Interpreter) evalList(node *ast.Node, env *InterpEnv) any { default: InterpErr(fmt.Sprintf("TYPE_ERROR: write_file expected string data, got %T", v), node) } + writePath = interp.requireFilesystem(writePath, true, node) if err := os.WriteFile(writePath, data, 0644); err != nil { InterpErr(fmt.Sprintf("IO_ERROR: write_file failed: %v", err), node) } @@ -792,6 +795,7 @@ func (interp *Interpreter) evalList(node *ast.Node, env *InterpEnv) any { InterpErr("mkdir expects (mkdir path)", node) } dirPath := fmt.Sprint(interp.eval(node.Children[1], env)) + dirPath = interp.requireFilesystem(dirPath, true, node) if err := os.MkdirAll(dirPath, 0755); err != nil { InterpErr(fmt.Sprintf("IO_ERROR: mkdir failed: %v", err), node) } @@ -1782,13 +1786,22 @@ func (vm *BCVM) storeHandle(env *BcEnv, name string, op bytecode.Opcode) *bcMemo name, )) } + if store.file != "" { + vm.requireFilesystem(store.file, false, op) + if op == bytecode.OpStorePut || op == bytecode.OpStoreDelete { + vm.requireFilesystem(store.file, true, op) + } + } return store } func (vm *BCVM) requireCapability(cap capability.Capability, op bytecode.Opcode) { for _, allowed := range vm.AllowedCaps { - if allowed == cap { - vm.recordEffect(cap, op, "allowed") + if capability.Grants([]capability.Capability{allowed}).Has(cap) { + // A scoped filesystem class check is provisional until its path check. + if cap != capability.Filesystem || capability.Grants(vm.AllowedCaps).HasUnrestrictedFilesystem() { + vm.recordEffect(cap, op, "allowed") + } return } } @@ -2084,6 +2097,9 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { case bytecode.OpStoreOpen: uri := inst.StringOperand2 vm.requireStoreCapabilities(uri, inst.Op) + if strings.HasPrefix(uri, "file://") { + uri = "file://" + vm.requireFilesystem(strings.TrimPrefix(uri, "file://"), false, inst.Op) + } store, err := vm.stores.open(uri) if err != nil { code := "STORE_PERSISTENCE_READ_FAILED" @@ -2198,6 +2214,7 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { vm.push(bytesToAnySlice(b)) case bytecode.OpReadFile: path := vm.popCheckedString(inst, ip, "read_file expected string") + path = vm.requireFilesystem(path, false, inst.Op) b, err := os.ReadFile(path) if err != nil { panic(NewRuntimeError("IO_ERROR", "main", ip, inst.Op, "read_file failed: %v", err)) @@ -2207,6 +2224,7 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { case bytecode.OpWriteFile: dataAny := vm.pop(inst.Op) path := vm.popCheckedString(inst, ip, "write_file expected string path") + path = vm.requireFilesystem(path, true, inst.Op) var data []byte switch v := dataAny.(type) { case string: @@ -2224,6 +2242,7 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { } case bytecode.OpMkdir: path := vm.popCheckedString(inst, ip, "mkdir expected string") + path = vm.requireFilesystem(path, true, inst.Op) err := os.MkdirAll(path, 0755) if err != nil { panic(NewRuntimeError("IO_ERROR", "main", ip, inst.Op, "mkdir failed: %v", err)) @@ -3579,3 +3598,43 @@ func (b *limitedOutput) Write(p []byte) (int, error) { } return b.buffer.Write(p) } + +func (vm *BCVM) requireFilesystem(path string, write bool, op bytecode.Opcode) string { + grants := capability.Grants(vm.AllowedCaps) + allowed := grants.AllowsRead(path) + if write { + allowed = grants.AllowsWrite(path) + } + if !allowed { + vm.recordEffect(capability.Filesystem, op, "denied") + panic(NewRuntimeError("CAPABILITY_DENIED", "main", vm.ip, op, "capability denied: %s", capability.Filesystem)) + } + // Mutating stores must pass both scopes before recording authorization. + if write || op != bytecode.OpStorePut && op != bytecode.OpStoreDelete { + vm.recordEffect(capability.Filesystem, op, "allowed") + } + return filesystemIOPath(path, grants) +} +func (interp *Interpreter) requireFilesystem(path string, write bool, node *ast.Node) string { + grants := capability.Grants(interp.AllowedCaps) + allowed := grants.AllowsRead(path) + if write { + allowed = grants.AllowsWrite(path) + } + if !allowed { + InterpErr("capability denied: filesystem", node) + } + return filesystemIOPath(path, grants) +} + +// Use exactly the normalized path checked by scoped grants, so symlink/.. OS +// traversal cannot disagree with lexical containment. Preserve coarse behavior. +func filesystemIOPath(path string, grants capability.Grants) string { + for _, grant := range grants { + if grant == capability.Filesystem { + return path + } + } + normalized, _ := filepath.Abs(path) // authorization already checked this error + return normalized +}