Skip to content

feat(wasi): --preopen flag unblocks dynamic Fs paths (operator grant, layer b1)#21

Merged
nelsonduarte merged 2 commits into
mainfrom
feat/wasi-preopen-flag
Jun 30, 2026
Merged

feat(wasi): --preopen flag unblocks dynamic Fs paths (operator grant, layer b1)#21
nelsonduarte merged 2 commits into
mainfrom
feat/wasi-preopen-flag

Conversation

@nelsonduarte

Copy link
Copy Markdown
Owner

Summary

Adds a --preopen <dir>[:ro|:rw] flag to the experimental --wasi mode (layer b1 of the --wasi plan) 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, 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 --preopen for dynamic Fs paths. Multiple-preopen dynamic resolution and Net --allow-host are out of scope.

What changed

  • Emitter call-site (_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_mkdir per cumulative prefix, preserving os.makedirs(exist_ok=True) parity (the literal path gets this via compile-time prefix unrolling; a runtime path cannot pre-enumerate its segments).
  • Rejection suppression (_wasi.py): _validate_wasi_caps suppresses the dynamic-path rejection only when an operator preopen is declared; without it the rejection stands unchanged (no regression).
  • Index rule (emitter <-> host): the host registers the operator preopen after every derived ceiling preopen, so its index is 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.
  • Host (_wasm_component_host.py): registers the operator preopen (host_dir, perms) after the derived ceiling, exposed via _wasi_fs_applied.
  • CLI (cli.py): --preopen (gated to --wasi or an SBOM/--manifest command; repeatable but b1 rejects more than one). Threads the grant to the emitter (suppress rejection, wasi_dynamic_fs), the host (register), and the SBOM.
  • SBOM (capa/manifest/): a new top-level operator_declared_grants block in the manifest, CycloneDX and SPDX, labelled operator-declared (Level 2), kept distinct from the derived surface so a regulator never reads it as program-proven.

Validation

  • 3-backend byte parity (Python oracle == capa:host == WASI) for read / write / exists / is_dir / mkdir (single and multi-segment) / list_dir over a genuine dynamic path (sourced from env.args()), plus the filesystem effect.
  • Fine-attenuation mitigation works with a dynamic path: a restrict_to'd Fs denies a runtime path outside the prefix and admits one inside, parity with the oracle.
  • No regression: without --preopen, a dynamic path still rejects at compile time; literal paths still resolve via the derived ceiling; multiple --preopen is rejected with a clear message.
  • Full suite green: 3459 tests, 0 failures, 18 skipped.

Notes

Do not merge directly - via PR + CI.

… 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.
@nelsonduarte nelsonduarte merged commit bc62bbc into main Jun 30, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant