feat(wasi): --preopen flag unblocks dynamic Fs paths (operator grant, layer b1)#21
Merged
Conversation
… layer b1) Add a `--preopen <dir>[:ro|:rw]` flag to the experimental `--wasi` mode that lets the operator declare filesystem authority over a single directory, unblocking DYNAMIC (non-literal) Fs paths the compiler cannot derive a preopen ceiling for. The dynamic path resolves at RUNTIME relative to the operator preopen (the WASI `--dir` model). Framed honestly as a Level-2 operator-declared grant, distinct from the compiler-derived surface, and recorded as such in the SBOM. Emitter: - A new dynamic-path branch in the Fs call-site emitters (read / write / exists / is_dir / mkdir / list_dir): pushes the runtime path (ptr,len) as both the full path (for the fine-attenuation gate) and the relative path, plus the operator preopen index. Reuses the existing guest wrappers verbatim; the only new WAT is a runtime recursive mkdir sequencer ($Fs_mkdir_recursive) that walks a dynamic path's segments and calls the single-segment $Fs_mkdir per cumulative prefix, preserving os.makedirs(exist_ok=True) parity that the literal path gets via compile-time prefix unrolling. - `_validate_wasi_caps` suppresses the dynamic-path rejection only when an operator preopen is declared; without it the rejection stands unchanged. Index rule (emitter <-> host agreement): the host registers the operator preopen AFTER every derived ceiling preopen, so its index is the number of derived preopens. A dynamic path forces a not-closed ceiling (no derived preopens), so the operator preopen is index 0, the constant the dynamic call site addresses. Host: register the operator preopen (host_dir, perms) after the derived ceiling preopens, exposed via `_wasi_fs_applied`. CLI: `--preopen` (gated to --wasi or an SBOM command; repeatable but b1 rejects more than one for dynamic-path resolution). Threads the grant to the emitter (suppress rejection) and the host (register), and into the manifest / SBOM. SBOM: a new top-level `operator_declared_grants` block (manifest, CycloneDX, SPDX), labelled operator-declared (Level 2), distinct from the derived surface so a regulator never reads it as program-proven. Parity: read / write / exists / is_dir / mkdir (single + multi-segment) / list_dir over a dynamic path are byte-for-byte identical across the Python, capa:host and WASI backends; the guest-side fine attenuation (restrict_to / allows) still gates the dynamic path lexically. Without --preopen the dynamic path still rejects at compile time; literal paths still resolve via the derived ceiling. Full suite: 3459 tests green.
…oracle parity)
The guest-side Fs fine-attenuation gate ($Fs_path_allowed via
$Fs_path_contained) did a purely lexical prefix comparison. Once
--preopen began admitting dynamic Fs paths, a path like
"sub/../secret.txt" started lexically with the restrict_to("sub")
prefix and PASSED the gate, reading a sibling outside the subtree, while
the Python oracle (os.path.realpath) correctly denied it.
The gate now lexically normalizes . and .. in both the runtime path and
the stored prefixes first (new $__fs_normalize, an os.path.normpath-style
collapse that preserves a leading .. so an escape stays an escape),
restoring byte-for-byte three-backend parity (Python == capa:host ==
WASI): sub/ok.txt admitted; sub/../secret.txt and sub/../sub2/x.txt
denied; sub/../sub/ok.txt (normalizes back inside) admitted. Symlinks are
still not resolved, the only remaining Level-2 loss.
The Level-1 preopen ceiling (wasmtime) is unchanged and still confines an
unrestricted Fs to the granted dir regardless of ..
Also: a program mixing a literal and a dynamic Fs path under --preopen
still fails closed (b1 limitation), now with a clear message naming the
limitation and the flag instead of an internal "no closed preopen
ceiling" wording.
Docs (wasi_mode.md) and CHANGELOG updated; tests added for the .. parity
table, Level-1 confinement of an unrestricted dynamic .. path, and the
mixed-path message.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
--preopen <dir>[:ro|:rw]flag to the experimental--wasimode (layer b1 of the--wasiplan) that lets the operator declare filesystem authority over a single directory, unblocking DYNAMIC (non-literal)Fspaths the compiler cannot derive a preopen ceiling for. The dynamic path resolves at runtime relative to the operator preopen (the WASI--dirmodel, as in wasmtime). It is framed honestly as a Level-2 operator-declared grant, distinct from the compiler-derived capability surface, and recorded as such in the SBOM.Scope is strictly b1: a single
--preopenfor dynamicFspaths. Multiple-preopen dynamic resolution andNet --allow-hostare out of scope.What changed
_caps.py): a new dynamic-path branch for read / write / exists / is_dir / mkdir / list_dir. It pushes the runtime path(ptr,len)as both the full path (for the fine-attenuation gate$Fs_path_allowed) and the relative path, plus the operator preopen index const. The existing guest wrappers are reused verbatim. The only new WAT is a runtime recursive mkdir sequencer ($Fs_mkdir_recursive) that walks a dynamic path's/boundaries and calls the single-segment$Fs_mkdirper cumulative prefix, preservingos.makedirs(exist_ok=True)parity (the literal path gets this via compile-time prefix unrolling; a runtime path cannot pre-enumerate its segments)._wasi.py):_validate_wasi_capssuppresses the dynamic-path rejection only when an operator preopen is declared; without it the rejection stands unchanged (no regression).len(derived preopens). A dynamic path forces a not-closed ceiling (no derived preopens), so the operator preopen is index 0 - the constant the dynamic call site addresses. For an all-literal program the operator preopen sits at index N, registered + recorded but unused by the guest._wasm_component_host.py): registers the operator preopen(host_dir, perms)after the derived ceiling, exposed via_wasi_fs_applied.cli.py):--preopen(gated to--wasior an SBOM/--manifestcommand; repeatable but b1 rejects more than one). Threads the grant to the emitter (suppress rejection,wasi_dynamic_fs), the host (register), and the SBOM.capa/manifest/): a new top-leveloperator_declared_grantsblock in the manifest, CycloneDX and SPDX, labelledoperator-declared(Level 2), kept distinct from the derived surface so a regulator never reads it as program-proven.Validation
capa:host== WASI) for read / write / exists / is_dir / mkdir (single and multi-segment) / list_dir over a genuine dynamic path (sourced fromenv.args()), plus the filesystem effect.restrict_to'd Fs denies a runtime path outside the prefix and admits one inside, parity with the oracle.--preopen, a dynamic path still rejects at compile time; literal paths still resolve via the derived ceiling; multiple--preopenis rejected with a clear message.Notes
Do not merge directly - via PR + CI.