diff --git a/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/approvals.json b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/approvals.json new file mode 100644 index 0000000..87b9baf --- /dev/null +++ b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/approvals.json @@ -0,0 +1,4 @@ +{ + "approvals": [], + "reopenings": [] +} diff --git a/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/change.md b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/change.md new file mode 100644 index 0000000..91ce0dd --- /dev/null +++ b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/change.md @@ -0,0 +1,24 @@ +--- +id: upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314 +state: draft +type: bug_fix +base_commit: 85d7c4efbca79947afa359504b095c95c486b391 +--- + +# Upgrade wasmtime past RUSTSEC-2026-0316 and RUSTSEC-2026-0314 + +## Intent + +Upgrade wasmtime past RUSTSEC-2026-0316 and RUSTSEC-2026-0314 + +## Affected Canonical Specs + +- `plugin-wasm` + +## Acceptance Criteria + +- cargo audit reports neither RUSTSEC-2026-0316 nor RUSTSEC-2026-0314 against the committed Cargo.lock or a fresh cargo generate-lockfile resolution (the form CI's audit job checks), with wasmtime, wasmtime-wasi, wiggle and every wasmtime-internal crate at 49.0.1; the plugin sandbox behaves as it did on wasmtime 46.0.3, so /project is read-only under both the project and plugin scopes (create, write-open, truncate and mkdir refused with EPERM), /plugin is read-write, .. cannot leave /plugin, no filesystem grant means no preopens (EBADF), memory.grow succeeds up to 256 MiB and is refused past it, fuel exhaustion and the epoch timeout still map to their own errors, a network grant keeps TCP and UDP on with IP name lookup off, and the engine refuses GC, exception-handling, typed function references and wide-arithmetic modules as 46.0.3 did; the new src/plugin/wasm.rs tests pass on 49.0.1 and give the same results on main's 46.0.3; CI is green with --locked on this PR (test and integration on ubuntu, macos and windows, lint, audit, spec-check, intent-check) + +## No-spec Rationale + +Not applicable diff --git a/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/context.md b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/context.md new file mode 100644 index 0000000..a6bac3b --- /dev/null +++ b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/context.md @@ -0,0 +1,176 @@ +--- +change: upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314 +artifact: context +--- + +# Context + +## What led here + +CI on `main` went red on exactly one job, `audit`, at `85d7c4e` (the v1.8.1 release +commit, which is neither tagged nor published; crates.io still has 1.8.0). Run +36787734566, `rustsec/audit-check@v2.0.0`, advisory DB `9b3a3b73` (2026-09-30): + +| Advisory | Crate | Title | +|---|---|---| +| RUSTSEC-2026-0316 (GHSA-jqpg-j7w6-42pr) | wasmtime 46.0.3 | Dynamic record lifting can allocate beyond the hostcall fuel limit | +| RUSTSEC-2026-0314 (GHSA-j2g9-4prp-pf6h) | wasmtime-wasi 46.0.3 | Guest can panic host through filesystem datetime overflow | + +Both were published 2026-09-24. Patched: `>=36.0.16,<37`, `>=48.0.3,<49`, `>=49.0.1`. +The two `unmaintained` warnings in the same run (`instant`, `number_prefix`) are +informational and do not fail the job. + +The audit job runs `cargo generate-lockfile` before auditing, so it checks a fresh +resolution of `Cargo.toml`, not the committed `Cargo.lock`. The `46.0.1` requirement +resolves to 46.0.3 there whatever the lock says. The fix has to move the requirement +itself, and the lock with it so `--locked` builds agree. + +The 1.8.1 tag waits on this change. + +## Exposure + +fledge loads plugins as core modules (`Module::new`) and links only WASI preview 1 +(`wasmtime_wasi::p1::add_to_linker_sync`). On that footing neither advisory looks +reachable: + +- **RUSTSEC-2026-0316** is in the component-model `Val` API ("This vulnerability only + affects the `Val` API of hosts"). fledge never instantiates a component. +- **RUSTSEC-2026-0314** is `Duration::new` panicking when nanoseconds overflow into a + seconds field already at `u64::MAX`. Preview 1 passes a timestamp as one `u64` of + nanoseconds and `systimespec` splits it into `ts / 1e9` seconds plus `ts % 1e9` + nanoseconds, so the nanoseconds are always below one second and the overflow cannot + form. The GHSA lists wasip1 as affected; a preview 1 guest calling + `path_filestat_set_times` with `u64::MAX`, `i64::MAX`, `i64::MIN` and + `i64::MIN + 999_999_999` against 46.0.3 returned normally in every case (errno 0 on + `/plugin`, 63 on the read-only `/project`). Checked on macOS only. + +The upgrade ships anyway. The plugin runtime is fledge's security boundary, `cargo +audit` is a required check, and "not reachable today" depends on fledge staying on +core modules and preview 1. + +## What changed between 46.0.3 and 49.0.1 that fledge touches + +| Area | 46.0.3 | 49.0.1 | fledge change | +|---|---|---|---| +| Preopen permissions | `preopened_dir(host, guest, DirPerms, FilePerms)` | `preopened_dir(host, guest, FsPerms)`, `ReadOnly` or `ReadWrite` | `/project`: `READ` + `READ` becomes `ReadOnly`; `/plugin`: `all()` + `all()` becomes `ReadWrite` | +| Socket defaults | TCP on, UDP on, IP name lookup off | all three off | a `network` grant now calls `allow_tcp(true)` and `allow_udp(true)` after `inherit_network()` | +| Filesystem backend | `cap-std` | cap-primitives' sandboxed resolver vendored as `filesystem::primitives` | none; `..` confinement is now covered by a test | +| Fuel instrumentation | | `call_ref` reloads fuel (the RUSTSEC-2026-0315 fix; 46 was unaffected), bulk memory ops are charged by size | none; same `FUEL_LIMIT` | +| WebAssembly proposals on by default | WASM 2.0 plus multi-memory, relaxed SIMD, tail calls, extended const, memory64 and threads | adds GC, exception handling and typed function references (since 47) and wide arithmetic (since 49) | `create_engine` turns those four off, back to 46.0.3's set | +| `StoreLimits`, `Trap::OutOfFuel`, `Trap::Interrupt`, epochs | | unchanged (`limits.rs` is byte-identical) | none | +| CLI context defaults (env, args, stdin, stderr), clocks, random | | unchanged | none | +| `windows-sys` 0.59 features | `cap-primitives` 3.4.6 (under `cap-std`) enabled `Win32_Security` | `cap-primitives` 4.0.3 no longer enables it on 0.59 | fledge declares `Win32_Security` itself | + +## The Windows build break the upgrade exposed + +The first CI run on #538 failed `test (windows-latest)` at compile time, in fledge's +own code, not wasmtime's: + +```text +error[E0432]: unresolved import `windows_sys::Win32::System::JobObjects::CreateJobObjectW` + --> src\lanes\execute.rs:659:76 +``` + +In windows-sys 0.59, `CreateJobObjectW` is behind `#[cfg(feature = "Win32_Security")]`, +because its first parameter is a `SECURITY_ATTRIBUTES` pointer. fledge declares only +`Win32_Foundation` and `Win32_System_JobObjects`. On main the missing feature arrived +through feature unification: `cap-primitives` 3.4.6, pulled in by wasmtime-wasi 46's +`cap-std`, enables `Win32_Security` on windows-sys 0.59. wasmtime-wasi 49 dropped +`cap-std`, so nothing else turns the feature on. `cargo tree --target +x86_64-pc-windows-msvc -e features -i windows-sys@0.59.0` shows the change: on main the +only `Win32_Security` edge comes from `cap-primitives`; with `--no-default-features`, +main has none at all, so a Windows build without the `wasm` feature was already broken +there. fledge now declares `Win32_Security` itself. That is a feature flag only: no new +crate, no `Cargo.lock` change. + +## Why this change names plugin-wasm + +The change was first drafted as `no_spec_change`, on the reasoning that the sandbox +behaves as it did on 46.0.3. `specsync change ship` refused it: "acceptance input +`src/plugin/wasm.rs` is production source without deterministic canonical ownership". +A no-spec change declares no module, and SpecSync 6.0.0 assigns production source +under `source_dirs` only to a declared module whose spec lists it in `files:`. +`specs/plugin/plugin-wasm.spec.md` lists `src/plugin/wasm.rs`, so the change now +declares `plugin-wasm` and carries a delta for it. The two workflow-2 no-spec changes +fledge archived before this one touched only `tests/isolation.rs`, a workflow and +`AGENTS.md`, all outside `source_dirs`. CHG-0007 changed `src/` as a no-spec change, but +under workflow 1, before this ownership check. + +The delta writes down the sandbox contract the PR enforces and its new tests pin, as +REQ-plugin-wasm-001 to 006: read-only `/project`, read-write `/plugin` confined to +`/data/`, no preopens without a filesystem grant, TCP and UDP only under a +network grant with IP name lookup always off, the 256 MiB memory cap, and the +WebAssembly feature set held to wasmtime 46.0.3's baseline. None of it changes +behaviour. It also corrects the five spec statements those requirements contradict: + +| Section | Was | Now | +|---|---|---| +| WASM Host Interface, `### Network` | outbound TCP/UDP, no listening sockets, DNS from the host | TCP and UDP for every address under the grant, off without it, IP name lookup off either way; nothing usable under preview 1 | +| Security Model, guarantee 2 | no socket imports without the grant | TCP and UDP disabled and every address refused; the preview 1 socket imports still link | +| Invariants, 7 | no socket imports without the grant | the same, plus what the grant enables | +| Behavioral Examples, zero-capability scenario | instantiation fails because the filesystem imports are not linked | the plugin instantiates; with no preopen it has no descriptor to open a path under (`EBADF`) | +| Error Cases, memory limit | trap with "plugin exceeded memory limit" | `memory.grow` returns -1 and the plugin keeps running | + +The spec never enforced "no listening sockets": wasmtime-wasi's `inherit_network()` +lets a guest bind as well as connect, so under preview 2 a granted plugin could listen. +The corrected text drops the claim instead of keeping a promise nothing backs. + +SpecSync resolves a module's requirements file to `requirements.md` beside its spec. +`plugin-wasm` shares `specs/plugin/` with `plugin`, so the six requirements will land in +`specs/plugin/requirements.md`, after the plugin module's own entries, not in +`requirements-wasm.md`. + +## Open items in plugin-wasm (noticed, not fixed here) + +Spec statements outside what the delta states. Each is a separate fix. + +- The spec frames every capability as link-time: the Capability Mapping intro under + Manifest Changes, invariant 2 and Security Model guarantee 6 say an ungranted + capability's import is not linked. That holds for `exec`, `store` and `metadata`. + `filesystem` and `network` are enforced by the WASI context instead; the preview 1 + imports are always linked. +- The Capability Mapping row for `network` ("WASI socket API for outbound + connections") and the manifest field's "Outbound network access" overstate what a + preview 1 plugin gets. +- Error Cases: fuel exhaustion is reported as "Plugin '' exceeded its compute + budget", not "plugin exceeded compute limit". +- Resource Limits: "Stack size 1 MB". fledge never calls `max_wasm_stack`, so + wasmtime's default applies (512 KiB in 49.0.1). "Memory 256 MB" means 256 MiB. +- Caching: the stamp's second line is the `wasmtime` requirement from `Cargo.toml` + (`49.0.1`), not "the Wasmtime major version". +- The `plugin-wasm` companions are not reachable through a delta and still say the + old thing: `requirements-wasm.md` item 7 ("Enable WASI socket imports only when + `network = true`") and `testing-wasm.md` ("Memory limit exceeded produces a trap with + descriptive error"). +- REQ-plugin-wasm-004 has code evidence only. A test would need either a preview 2 + guest or a way to read the socket settings back from `WasiP1Ctx`, which wasmtime-wasi + 49 does not offer. + +## Constraints + +- The crate version stays 1.8.1. No tag, no publish, no crates.io. +- CI runners are not touched. +- wasmtime 49 needs rustc 1.96 (48 needs 1.95; 46 needed 1.94). CI builds on `stable` + (1.98.0 today), so CI is unaffected. People building from source need 1.96 or newer. + +## Out of scope (noticed, left alone) + +- `Cargo.toml` declares `rust-version = "1.89"`, which was already below the 1.94 that + wasmtime 46 needed. Raising it is a separate decision: clippy reads `rust-version` + for its MSRV-aware lints, so changing it could surface new `-D warnings` failures + unrelated to this fix. +- In preview 1 a guest has no call that creates a socket, and IP name lookup is off, so + a network grant reaches nothing usable today. That was true on 46.0.3 too. This + change keeps the grant's configuration identical rather than giving it a use; the + spec now says what the grant does (see "Why this change names plugin-wasm"). +- `exit_code_42_returns_error_with_code` passes because the plugin is named + `test-exit42`. `run_wasm_plugin` formats the wasmtime error with `{}`, which drops + the cause chain, so the exit code never reaches the message. +- `StoreLimits` caps each memory at 256 MiB but sets no `table_elements` limit, so a + guest can `table.grow` a `funcref` table far past that: 50 million elements (about + 400 MB of host memory) succeeded on 46.0.3 and 49.0.1 alike. Multi-memory and shared + memories are accepted on both too. Fuel and the wall clock still bound the run. That + predates this change and is left for a separate fix. +- The committed `Cargo.lock` carries `yoke-derive 0.8.3`, which is yanked + (`cargo audit` warns). It predates this change, and CI's fresh resolution does not + pick it. diff --git a/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/deltas/plugin-wasm.md b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/deltas/plugin-wasm.md new file mode 100644 index 0000000..b874959 --- /dev/null +++ b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/deltas/plugin-wasm.md @@ -0,0 +1,215 @@ +--- +change: upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314 +module: plugin-wasm +--- + +## ADDED + +### REQUIREMENT REQ-plugin-wasm-001 + +Under `filesystem = "project"` and `filesystem = "plugin"`, the host SHALL mount the project root as `/project` read-only (`FsPerms::ReadOnly`): a plugin can open files there for reading, and creating a file, opening an existing file for writing (with or without `O_TRUNC`) and creating a directory are refused with `EPERM`, leaving the host filesystem unchanged. + +Acceptance Criteria +- Under `filesystem = "project"`, `path_open` for reading an existing file under `/project` returns 0. +- Under `filesystem = "project"`, `path_open` with `O_CREAT` under `/project` returns `EPERM` (63) and no file appears in the project root. +- Under `filesystem = "project"`, `path_open` for writing an existing file, with and without `O_TRUNC`, returns `EPERM` and the file's contents are unchanged. +- Under `filesystem = "project"`, `path_create_directory` under `/project` returns `EPERM` and no directory appears. +- Under `filesystem = "plugin"`, `path_open` with `O_CREAT` under `/project` returns `EPERM` and no file appears. + +### REQUIREMENT REQ-plugin-wasm-002 + +Under `filesystem = "plugin"`, the host SHALL create `/data/` and mount it as `/plugin` read-write (`FsPerms::ReadWrite`), so a plugin can create files and directories there. A path that uses `..` SHALL NOT reach outside `/data/`, so the rest of the plugin directory (`plugin.toml`, the `.wasm` and `.cwasm` files) is never writable. + +Acceptance Criteria +- Running a plugin with `filesystem = "plugin"` creates `/data/`. +- `path_open` with `O_CREAT` under `/plugin` returns 0 and the file appears in `/data/`. +- `path_create_directory` under `/plugin` returns 0 and the directory appears in `/data/`. +- `path_open` of `../escape.txt` with `O_CREAT` from `/plugin` fails, and no `escape.txt` appears in ``. + +### REQUIREMENT REQ-plugin-wasm-003 + +When `filesystem` is absent or `"none"`, the host SHALL preopen no directory. The WASI preview 1 imports are still linked, so the plugin instantiates, but it holds no directory descriptor: descriptor 3, where the first preopen would sit, is not open. + +Acceptance Criteria +- With default capabilities, a module importing `wasi_snapshot_preview1` `path_open` instantiates, and `path_open` on descriptor 3 returns `EBADF` (8). + +### REQUIREMENT REQ-plugin-wasm-004 + +The host SHALL enable WASI TCP and UDP, with every socket address allowed, only for a plugin granted `network = true`; without the grant, TCP and UDP stay disabled and every socket address is refused. IP name lookup SHALL stay disabled with or without the grant. WASI preview 1, which fledge links, has no call that creates a socket and the host preopens none, so under preview 1 no plugin can open a connection either way; this configuration is what the grant means to a future preview 2 host. + +Acceptance Criteria +- `build_wasi_p1` calls `inherit_network()`, `allow_tcp(true)` and `allow_udp(true)` only when `capabilities.network` is true. +- Apart from those three calls, `build_wasi_p1` configures no socket setting (no `allow_ip_name_lookup`, no `socket_addr_check` of its own), so wasmtime-wasi 49.0.1's builder defaults (TCP, UDP and IP name lookup off, every socket address refused) hold for an ungranted plugin, and IP name lookup stays off for a granted one. + +### REQUIREMENT REQ-plugin-wasm-005 + +Every plugin store SHALL cap each linear memory at `MAX_MEMORY_BYTES`, 256 MiB (4096 pages of 64 KiB), through wasmtime's `StoreLimits`. `memory.grow` up to exactly the cap succeeds, and a grow past it returns -1 without trapping, so the plugin keeps running. + +Acceptance Criteria +- Through `run_wasm_plugin`, a guest that starts with one page and grows by 4095 pages, to exactly 256 MiB, gets a result other than -1. +- Through `run_wasm_plugin`, a guest that starts with one page and grows by 4096 pages, one page past the cap, gets -1 and the run ends without a trap. + +### REQUIREMENT REQ-plugin-wasm-006 + +The engine fledge compiles and runs plugins with (`create_engine`, used by both `compile_and_cache` and `run_wasm_plugin`) SHALL hold the WebAssembly feature set to the wasmtime 46.0.3 baseline: it SHALL refuse modules that use the GC, exception-handling, typed function references or wide-arithmetic proposals, which wasmtime 46.0.3 refused and wasmtime 47 and 49 turned on by default, and SHALL keep accepting what `wasm32-wasip1` toolchains emit by default (bulk memory, sign extension, saturating float-to-int conversion, multi-value, reference types) plus tail calls. + +Acceptance Criteria +- A module using `memory.copy`, `i32.extend8_s`, `i32.trunc_sat_f32_s`, a multi-value function, `ref.null extern` with `ref.is_null`, and `return_call` compiles with `create_engine()`. +- A module using `array.new_default` (GC), one using `try_table` and `throw` (exception handling), one using `call_ref` (typed function references) and one using `i64.add128` (wide arithmetic) each fail to compile with `create_engine()`. + +## MODIFIED + +### SPEC SECTION WASM Host Interface + +The host exposes functions in the `fledge` namespace that WASM plugins import. These are the **only** way for a WASM plugin to interact with the system. + +### Core (always available) + +```wit +// Plugin receives messages from fledge (init, response, cancel) +fledge::recv() -> Message + +// Plugin sends messages to fledge (prompt, confirm, output, log, progress, etc.) +fledge::send(message: Message) + +// Exit with status code +fledge::exit(code: u32) +``` + +These three functions are the WASM equivalent of stdin/stdout in the native protocol. The fledge-v1 JSON-lines protocol is preserved — `send` and `recv` serialize/deserialize the same message types. This means a plugin's protocol logic is identical whether it's native or WASM; only the I/O transport changes. + +### Exec (requires `exec = true`) + +```wit +fledge::exec(command: string, cwd: option, timeout: option) -> ExecResult + +record ExecResult { + code: u32, + stdout: string, + stderr: string, +} +``` + +Identical semantics to the native `exec` protocol message. The host validates `cwd` and runs the command as a subprocess. + +### Store (requires `store = true`) + +```wit +fledge::store_set(key: string, value: string) +fledge::store_get(key: string) -> option +``` + +Same limits as native: 256-byte keys, 64KB values, 1MB total, 256 keys max. + +### Metadata (requires `metadata = true`) + +```wit +fledge::metadata(keys: list) -> string // JSON-encoded object +``` + +Returns the same metadata as the native `metadata` protocol message. + +### Filesystem (requires `filesystem != "none"`) + +No custom imports needed — uses standard WASI filesystem preopens: + +| `filesystem` value | Preopened directories | +|-------------------|----------------------| +| `"none"` | (no preopens) | +| `"project"` | Project root → `/project` (read-only) | +| `"plugin"` | Project root → `/project` (read-only), Plugin `data/` subdir → `/plugin` (read-write) | + +Plugins see a virtual filesystem rooted at `/project` and `/plugin`. The `/plugin` mount points to `/data/`, not the full plugin directory — this prevents plugins from modifying their own `plugin.toml`, `.wasm`, or `.cwasm` files. All preopened paths are canonicalized before mounting to prevent symlink escapes. No access to home directories, system files, or other plugins' storage. + +### Network (requires `network = true`) + +The grant enables WASI TCP and UDP with every socket address allowed. IP name lookup (DNS) stays off with or without the grant, and without the grant TCP and UDP are disabled and every socket address is refused. fledge links WASI preview 1, which has no call that creates a socket, and the host preopens none, so under preview 1 a plugin cannot open a connection with or without the grant. The grant's configuration is what a future preview 2 host would inherit. + +### SPEC SECTION Security Model + +### What WASM sandboxing guarantees + +1. **No ambient filesystem access.** A WASM plugin cannot read `~/.ssh/`, `~/.aws/credentials`, shell history, or any file outside its preopened directories. +2. **No ambient network access.** Without `network = true`, WASI TCP and UDP are disabled and every socket address is refused — the plugin cannot phone home or exfiltrate data over the network. The preview 1 socket imports are still linked; the WASI context, not the linker, refuses them. +3. **No process spawning.** Without `exec = true`, the plugin cannot run shell commands. Even with exec, commands are proxied through the host with the same cwd validation as native. +4. **No environment variable access.** The plugin sees only what the `init` message provides. No `$HOME`, `$PATH`, `$GITHUB_TOKEN`, etc. +5. **Resource-bounded.** Memory, CPU, and wall-clock time are all capped. A buggy or malicious plugin cannot OOM the host or spin forever. +6. **Capability enforcement is structural.** Capabilities are enforced at WASM link time — if the import isn't linked, the code can't call it. This is not a runtime check that could be bypassed. + +### What WASM sandboxing does NOT guarantee + +1. **Exec is still powerful.** A plugin with `exec = true` can run arbitrary commands as the user, same as native. The sandbox only helps when exec is denied. +2. **Network + exec = exfiltration.** A plugin with both capabilities can read files via exec and send them over the network. The sandbox limits the combination surface. +3. **Timing side channels.** WASM plugins can measure execution time and potentially infer information. This is a theoretical concern, not a practical one for CLI plugins. +4. **Host bugs.** If Wasmtime has a sandbox escape vulnerability, the isolation breaks. We depend on Wasmtime's security posture (which is excellent — it's used in Cloudflare Workers, Fastly, Fermyon, etc.). + +### SPEC SECTION Invariants + +1. WASM plugins run inside a Wasmtime sandbox with WASI preview 1 +2. Capabilities map to WASM imports — ungranted capabilities are not linked, causing instantiation failure if the plugin tries to import them +3. The fledge-v1 protocol is preserved — same message types, same semantics, different transport (WASM imports vs stdio pipes) +4. `filesystem = "none"` means zero preopened directories — the plugin cannot read or write any file +5. `filesystem = "project"` preopens only the project root, read-only +6. `filesystem = "plugin"` preopens project root (read-only) and plugin `data/` subdir (read-write) — the full plugin dir is never writable +7. `network = false` means WASI TCP and UDP are disabled and every socket address is refused — the plugin cannot make any network connections, although the preview 1 socket imports are still linked. `network = true` enables TCP and UDP for every address. IP name lookup stays off either way +8. Resource limits (memory, fuel, wall-clock) are enforced by Wasmtime and cannot be disabled by plugins +9. Compiled WASM modules are cached as `.cwasm` with a 3-line stamp file — cache is invalidated by source `.wasm` hash change, wasmtime version mismatch, or `.cwasm` tamper (hash mismatch) +10. WASM plugins do not inherit host stderr — diagnostic output must use `fledge::send` with `Log` messages +11. Interactive UI messages (prompt/confirm/select) are rejected in WASM mode with a warning +12. Native plugins are completely unaffected by the WASM runtime addition (backward-compatible) +13. The `fledge-plugin-sdk` crate abstracts WASM imports into the same ergonomic API as the native protocol +14. In 2.0.0, installing a native plugin displays a warning and requires explicit user confirmation +15. All preopened paths are canonicalized before mounting to prevent symlink escapes +16. Host function JSON parse errors include the function name as context prefix (e.g., `"exec: malformed JSON: ..."`) +17. The timeout thread is joined after `_start` completes to prevent use-after-drop +18. Atomic ordering uses `Acquire` on reads and `Release` on stores for the finished flag + +### SPEC SECTION Behavioral Examples + +### Scenario: Install a WASM plugin + +- **Given** a plugin repo with `runtime = "wasm"` in plugin.toml +- **When** user runs `fledge plugins install owner/fledge-plugin-deploy` +- **Then** fledge clones, runs build hook, validates `.wasm` binary exists, pre-compiles to `.cwasm`, prompts for capabilities, installs + +### Scenario: Zero-capability WASM plugin + +- **Given** a WASM plugin with all capabilities `false` and `filesystem = "none"` +- **When** the plugin tries to read a file +- **Then** the read fails: the WASI imports are linked, so the plugin instantiates, but no directory is preopened, so it holds no descriptor to resolve a path against (a raw `path_open` on descriptor 3 returns `EBADF`) + +### Scenario: WASM plugin with filesystem = "project" + +- **Given** a WASM plugin with `filesystem = "project"` +- **When** the plugin opens `/project/src/main.rs` +- **Then** read succeeds (project root is preopened read-only) +- **When** the plugin tries to open `/project/../.ssh/id_ed25519` +- **Then** open fails — WASI path resolution prevents directory traversal above the preopen + +### Scenario: Native plugin unchanged + +- **Given** an existing native plugin with no `runtime` field +- **When** user updates to fledge 1.1.0 +- **Then** plugin continues to work exactly as before — `runtime` defaults to `"native"` + +### Scenario: Canary plugin as WASM + +- **Given** the fledge-plugin-canary ported to WASM with zero capabilities +- **When** `fledge canary` runs the baseline tests +- **Then** every file access, credential probe, and persistence vector check fails — the WASM sandbox prevents all of them +- **Then** output shows 0 warnings (vs 12+ warnings in native mode), proving the sandbox works + +### SPEC SECTION Error Cases + +| Error | When | Behavior | +|-------|------|----------| +| WASM binary not found | `.wasm` file missing after build | Error with build hint | +| Instantiation failed | Plugin imports a function not linked (capability denied) | Error listing which imports are missing and which capabilities would provide them | +| Fuel exhausted | Plugin exceeds instruction limit | Trap with "plugin exceeded compute limit" message | +| Memory limit | Plugin grows a linear memory past 256 MiB | `memory.grow` returns -1 instead of trapping and the plugin keeps running; a plugin that cannot handle the failed grow (a Rust allocator aborting, say) then traps, reported as `Plugin '' trapped: …` | +| Wall-clock timeout | Plugin exceeds 60 seconds | Kill with timeout error | +| Invalid WASM | Binary is not valid WebAssembly | Error with validation details | +| WASI incompatible | Module is not a valid WASI P1 module | Error suggesting recompile with `wasm32-wasip1` target | +| Path traversal | Plugin attempts `..` escape from preopened dir | WASI denies the open — no host-side check needed | +| Cache corrupt | `.cwasm` fails to deserialize | Re-compile from `.wasm`, warn user | +| Cache tampered | `.cwasm` SHA-256 doesn't match stamp file | Invalidate cache, re-compile from `.wasm` | diff --git a/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/design.md b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/design.md new file mode 100644 index 0000000..77e3ceb --- /dev/null +++ b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/design.md @@ -0,0 +1,115 @@ +--- +change: upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314 +artifact: design +--- + +# Design + +## Move the requirement, not only the lock + +`wasmtime` and `wasmtime-wasi` go from `"46.0.1"` to `"49.0.1"` in `Cargo.toml`. CI's +audit job resolves from scratch (`cargo generate-lockfile`), and so does +`cargo install fledge` without `--locked`. Both must land on a patched release. The +caret requirement `49.0.1` admits nothing older than 49.0.1. + +The committed lock moves with a targeted `cargo update -p wasmtime -p wasmtime-wasi`, +not a blanket update, so nothing outside the wasmtime tree changes. + +## Map the preopen grants one to one + +| Grant | Before | After | Meaning in both | +|---|---|---|---| +| `/project` | `DirPerms::READ`, `FilePerms::READ` | `FsPerms::ReadOnly` | read and list; no create, remove, rename, set-times or write-open | +| `/plugin` | `DirPerms::all()`, `FilePerms::all()` | `FsPerms::ReadWrite` | everything | + +wasmtime-wasi 49 collapsed the two bitflags into one enum. fledge only ever used the +two corners of the old grid (read/read and all/all), and those are exactly the two +variants that remain. A comment in `build_wasi_p1` records the mapping. + +## Keep the network grant's configuration identical + +On 46.0.3 a `network` grant meant: TCP on, UDP on (both by default), every address +allowed (`inherit_network()`), IP name lookup off (by default). 49.0.1 turned TCP and +UDP off by default, so `inherit_network()` alone would leave a granted plugin with no +sockets. The grant now reads `inherit_network().allow_tcp(true).allow_udp(true)`, which +is the 46.0.3 configuration exactly. IP name lookup stays off, as before. + +Without the grant, 49.0.1 now also has TCP and UDP off. On 46.0.3 they were on but +every address was refused, so no socket could connect or bind either way. This +change does not switch them back on for ungranted plugins. That would only loosen a +default for no gain. + +None of this is observable from a preview 1 guest today, because preview 1 has no call +that creates a socket. The point is that the grant keeps meaning what it meant, so a +future move to preview 2 does not inherit a silent behaviour change. + +## Pin the grants with tests that run real WASI calls + +The sandbox had no test of the read-only and read-write split, which is the code this +change rewrites. The new tests in `src/plugin/wasm.rs` build the WASI context with +`build_wasi_p1` and the linker with `setup_linker`, the same calls `run_wasm_plugin` +makes, and drive `path_open` / `path_create_directory` from a WAT guest. They assert +both the WASI errno and the host filesystem (the file must not appear, the contents +must not change). + +They call `build_wasi_p1` directly with a temporary project root instead of going +through `run_wasm_plugin`. `run_wasm_plugin` mounts the process's current directory +as `/project`, which in `cargo test` is the repository itself. A test that tries to +write there would, if the sandbox were broken, write into the checkout. + +The memory cap test goes through `run_wasm_plugin`. The guest exits 0 only on the +expected `memory.grow` outcome and hits `unreachable` on the other. The plain error +text cannot carry the guest's exit code, because the top-level message drops the cause +chain. + +## Declare the Windows feature fledge actually uses + +`src/lanes/execute.rs` calls `CreateJobObjectW`, which windows-sys 0.59 compiles only +with `Win32_Security`. fledge had been getting that feature from wasmtime's dependency +tree. Declaring it in fledge's own `[target."cfg(windows)".dependencies]` makes the +Windows build independent of what wasmtime happens to pull in, and fixes +`--no-default-features` on Windows as a side effect. The alternative, pinning +`cap-primitives` 3 back into the graph, would be a dependency kept only for a feature +side effect. + +## Keep the WebAssembly feature set at 46.0.3's + +`create_engine` used to take wasmtime's default feature set. wasmtime 47 added GC, +exception handling and typed function references to it, and 49 added wide arithmetic, +so the upgrade alone would let a plugin use four proposals that 46.0.3 refused. +`create_engine` now turns all four off with `wasm_gc(false)`, `wasm_exceptions(false)`, +`wasm_function_references(false)` and `wasm_wide_arithmetic(false)`. + +No plugin that ran on 1.8.0 can depend on them, since 46.0.3 rejected such modules at +compile time, and `wasm32-wasip1` toolchains do not emit them by default. They are +extra guest-reachable runtime, not just syntax: RUSTSEC-2026-0315, fixed in the same +49.0.1 release, let `call_ref` and exception `catch` drop fuel accounting. Turning +them on is a separate decision with its own review, not a side effect of a security +patch. `engine_keeps_wasmtime_46_feature_set` pins the set: a baseline module compiles, +and one module per proposal is refused. + +## Let the cache invalidate itself + +The `.cwasm` stamp's second line is `WASMTIME_DEP_VERSION`, which `build.rs` reads +from the `wasmtime` requirement in `Cargo.toml`. It changes from `46.0.1` to +`49.0.1`, so every installed plugin's cached module fails the stamp check and is +recompiled once, silently, on its next run. A 46.x `.cwasm` could not be deserialized +by 49.x anyway. No migration code is needed. + +## State the sandbox contract as plugin-wasm requirements + +The change declares `plugin-wasm`, the spec that owns `src/plugin/wasm.rs`, because +SpecSync 6.0.0 will not ship production source without a declared owner (see context). +The contract goes into the delta as requirements, not spec prose, because each one maps +to tests: REQ-plugin-wasm-001 (read-only `/project`), 002 (read-write `/plugin`, `..` +confined), 003 (no preopens without a filesystem grant), 004 (TCP and UDP only under a +network grant, IP name lookup off), 005 (the 256 MiB memory cap, grow past it returns +-1) and 006 (the wasmtime 46.0.3 feature set). `testing.md` maps every acceptance +criterion to its test. 004 is the exception, backed by code only. + +The spec itself changes only where it contradicts those requirements: one sentence in +each of five sections. A delta replaces a whole `##` section, so each `## MODIFIED` +body is the living section copied verbatim with that one sentence swapped. Reviewing +the delta means checking those five sentences. Materialized, the spec's diff is those +five lines, the `version` bump from 2 to 3 and the Change Log row. Other inaccuracies +in the spec are listed in context as open items and left for their own change. diff --git a/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/plan.md b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/plan.md new file mode 100644 index 0000000..7e2e7fc --- /dev/null +++ b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/plan.md @@ -0,0 +1,29 @@ +--- +change: upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314 +artifact: plan +--- + +# Plan + +1. Branch `0xleif/fix/wasmtime-rustsec-2026-0316` from `origin/main` (`85d7c4e`). +2. Raise `wasmtime` and `wasmtime-wasi` to `49.0.1` in `Cargo.toml`, then run + `cargo update -p wasmtime -p wasmtime-wasi`. Fall back to 48.0.3 only if 49 needs + more than a mechanical source change. +3. Follow the wasmtime-wasi 49 API in `build_wasi_p1`: `FsPerms::ReadOnly` for + `/project`, `FsPerms::ReadWrite` for `/plugin`, and `allow_tcp(true)` / + `allow_udp(true)` under the network grant. +4. Diff wasmtime-wasi 46.0.3 against 49.0.1 (`ctx.rs`, `filesystem.rs`, + `sockets/mod.rs`, CLI/clock/random defaults) and wasmtime's limits, traps and fuel + instrumentation for anything else that changes the sandbox. +5. Add regression tests for the preopen grants, `..` confinement, the no-grant case and + the memory cap. Run them against main's 46.0.3 as well, unchanged, and mutate the + 46.0.3 sandbox to show they fail when it is weakened. +6. Run fmt, clippy (CI's form and the `lint` task's `--all-targets`), clippy with + `--no-default-features`, the full `cargo test --locked`, and `cargo audit` against + the committed lock, a fresh `generate-lockfile` resolution, and main's lock as a + negative control. +7. Declare `windows-sys`'s `Win32_Security` feature, which the first Windows CI run + showed fledge had been getting from wasmtime-wasi 46's `cap-std`. +8. Record the security fix under 1.8.1 in `CHANGELOG.md`, and correct the 1.8.1 note + that said dependencies were unchanged from 1.8.0. +9. Open the PR and watch every CI job. diff --git a/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/research.md b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/research.md new file mode 100644 index 0000000..ce56b8e --- /dev/null +++ b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/research.md @@ -0,0 +1,68 @@ +--- +change: upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314 +artifact: research +--- + +# Research + +## Which release line + +| Line | Patched? | MSRV | Notes | +|---|---|---|---| +| 49.0.1 | yes | 1.96 | newest; chosen | +| 48.0.3 | yes | 1.95 | fallback if 49 had needed more source changes | +| 47.x | no | 1.94 | also inside RUSTSEC-2026-0315's affected range (`unaffected < 47.0.0`) | +| 36.0.16 (LTS) | yes | | ten majors back from 46; rejected | + +49.0.1 built after a four-line change in `build_wasi_p1`, so 48.0.3 was never built. +crates.io's `rust_version` field was read for each line. + +## Lockfile movement + +`cargo update -p wasmtime -p wasmtime-wasi` after the requirement bump locked 47 +packages, all inside the wasmtime tree: + +- wasmtime, wasmtime-wasi, wasmtime-wasi-io, wasmtime-environ, every + `wasmtime-internal-*`, wiggle, wiggle-generate, wiggle-macro, pulley-interpreter, + pulley-macros: 46.0.3 to 49.0.1 +- cranelift-*: 0.133.3 to 0.136.1 +- wasm-encoder, wasmparser, wasmprinter, wasm-compose, wit-parser: 0.251.0 to 0.258.0 +- cap-primitives 3.4.6 to 4.0.3, io-extras 0.18.4 to 0.19.0, object 0.39.1 to 0.40.0, + cpp_demangle 0.4.5 to 0.5.1 +- toml 0.9.12 to 1.1.6, used only by `wasmtime-internal-cache`. fledge's own + `toml = "0.8"` stays 0.8.23. +- removed: cap-std, cap-fs-ext, cap-net-ext, cap-time-ext, unicode-xid +- added: io-lifetimes 3.0.1, wasm-metadata 0.258.0, wit-component 0.258.0 + +Nothing outside the wasmtime family moved in the lockfile. Feature unification did +change: `cap-primitives` 3.4.6 had been enabling `Win32_Security` on `windows-sys` +0.59, which fledge's Windows job-object code needs without declaring it (see context). + +## Source read to confirm equivalence + +- `wasmtime-wasi` `ctx.rs`, 46.0.3 against 49.0.1: the only differences are + `preopened_dir`'s signature and the socket-default doc comments. +- `filesystem.rs`: every 46.0.3 `DirPerms::MUTATE` / `FilePerms::WRITE` check has a + 49.0.1 `write_not_permitted()` check at the same site. 46.0.3's `DirPerms::READ` + checks have no 49.0.1 counterpart, because both `FsPerms` variants allow reads, and + both fledge preopens always granted `READ`. `open_at` refuses create or write-open + under a read-only preopen in both versions. `open_mode` is `READ` for `/project` + and `READ | WRITE` for `/plugin` in both. +- `sockets/mod.rs`: `AllowedNetworkUses::default()` was `{ip_name_lookup: false, udp: + true, tcp: true}` in 46.0.3 and is all `false` in 49.0.1. `inherit_network()` is + unchanged: it only installs an allow-all `socket_addr_check`. +- `cli.rs`, `clocks.rs`, `random.rs` defaults: unchanged. +- `wasmtime` `runtime/limits.rs`: byte-identical. `Trap::OutOfFuel` and + `Trap::Interrupt` still exist and still downcast from the call error. +- `wasmtime-internal-cranelift` fuel instrumentation: `Loop`, `If`, branches and `End` + are still fuel points; `CallRef` now reloads fuel; bulk memory ops are charged by + size. + +## Advisory text + +- GHSA-jqpg-j7w6-42pr: "This vulnerability only affects the `Val` API of hosts. Hosts + that use `bindgen!` and otherwise statically-typed APIs are unaffected." +- GHSA-j2g9-4prp-pf6h: wasip1 `path_filestat_set_times` / `fd_filestat_set_times`, + wasip2/wasip3 `set-times` / `set-times-at`. The fix in 49.0.1 is in + `p2/host/filesystem.rs::systemtime_from`, which preview 1 reaches only with + nanoseconds already reduced below one second. diff --git a/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/state.json b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/state.json new file mode 100644 index 0000000..7abe64d --- /dev/null +++ b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/state.json @@ -0,0 +1,41 @@ +{ + "schema_version": 1, + "workflow_version": 2, + "workflow_origin_version": 2, + "id": "upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314", + "slug": "upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314", + "title": "Upgrade wasmtime past RUSTSEC-2026-0316 and RUSTSEC-2026-0314", + "description": "Upgrade wasmtime past RUSTSEC-2026-0316 and RUSTSEC-2026-0314", + "kind": "bug_fix", + "state": "draft", + "base_commit": "85d7c4efbca79947afa359504b095c95c486b391", + "created_at": 1790819749, + "updated_at": 1790826623, + "affected_specs": [ + "plugin-wasm" + ], + "affected_paths": [ + "Cargo.toml", + "Cargo.lock", + "src/plugin/wasm.rs", + "CHANGELOG.md" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "cargo audit reports neither RUSTSEC-2026-0316 nor RUSTSEC-2026-0314 against the committed Cargo.lock or a fresh cargo generate-lockfile resolution (the form CI's audit job checks), with wasmtime, wasmtime-wasi, wiggle and every wasmtime-internal crate at 49.0.1; the plugin sandbox behaves as it did on wasmtime 46.0.3, so /project is read-only under both the project and plugin scopes (create, write-open, truncate and mkdir refused with EPERM), /plugin is read-write, .. cannot leave /plugin, no filesystem grant means no preopens (EBADF), memory.grow succeeds up to 256 MiB and is refused past it, fuel exhaustion and the epoch timeout still map to their own errors, a network grant keeps TCP and UDP on with IP name lookup off, and the engine refuses GC, exception-handling, typed function references and wide-arithmetic modules as 46.0.3 did; the new src/plugin/wasm.rs tests pass on 49.0.1 and give the same results on main's 46.0.3; CI is green with --locked on this PR (test and integration on ubuntu, macos and windows, lint, audit, spec-check, intent-check)" + ], + "selected_artifacts": [ + "context", + "testing", + "tasks", + "research", + "design", + "plan" + ], + "dependencies": [], + "answers": { + "architecture_risk": "yes", + "public_contract": "no" + } +} diff --git a/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/tasks.md b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/tasks.md new file mode 100644 index 0000000..3d38d87 --- /dev/null +++ b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/tasks.md @@ -0,0 +1,34 @@ +--- +change: upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314 +artifact: tasks +--- + +# Tasks + +- [x] Read the failing `audit` job on `main` (run 36787734566) and record the two + advisories and their patched ranges +- [x] Confirm the audit job checks a fresh `generate-lockfile` resolution, so the + `Cargo.toml` requirement has to move +- [x] Raise `wasmtime` and `wasmtime-wasi` to `49.0.1`; targeted + `cargo update -p wasmtime -p wasmtime-wasi` +- [x] Port `build_wasi_p1` to `FsPerms`; re-enable TCP and UDP under the network grant +- [x] Diff wasmtime-wasi and wasmtime 46.0.3 against 49.0.1 for other sandbox changes +- [x] Add tests for the `/project` and `/plugin` grants, `..` confinement, no + preopens without a grant, and the memory cap +- [x] Run the new tests unchanged against main's 46.0.3, and against a deliberately + weakened 46.0.3 sandbox +- [x] fmt, clippy (CI form, `--all-targets`, `--no-default-features`), + `cargo test --locked` and `cargo audit` locally (macOS, rustc 1.98.0) +- [x] CHANGELOG entry under 1.8.1 +- [x] Declare `windows-sys` `Win32_Security` after `test (windows-latest)` failed to + compile `CreateJobObjectW` on #538's first run (run 36804407887) +- [x] Turn off the proposals wasmtime 47 and 49 enabled by default (GC, exceptions, + typed function references, wide arithmetic) and pin the feature set with a test +- [x] CI green on #538: test and integration on ubuntu-latest, macos-latest, + windows-latest; lint; audit; spec-check; intent-check (run 36809320239 on 8964ca0) +- [x] Declare `plugin-wasm`, the owner of `src/plugin/wasm.rs`, after `change ship` + refused the no-spec draft; write its delta (REQ-plugin-wasm-001 to 006 and the + five spec sentences they contradict) and map each requirement to its evidence + +Lifecycle (not implementation tasks): definition approval by Leif, `change check`, +review and ship before merge; tag and publish 1.8.1 after merge. diff --git a/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/testing.md b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/testing.md new file mode 100644 index 0000000..d02c120 --- /dev/null +++ b/.specsync/changes/upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314/testing.md @@ -0,0 +1,123 @@ +--- +change: upgrade-wasmtime-past-rustsec-2026-0316-and-rustsec-2026-0314 +artifact: testing +--- + +# Testing + +## Requirement evidence + +Each acceptance criterion of the requirements in `deltas/plugin-wasm.md`, and what proves it. +Test names are in `src/plugin/wasm.rs::tests`. + +| Requirement | Acceptance criterion | Evidence | +|---|---|---| +| REQ-plugin-wasm-001 | reading under `/project` returns 0 | `project_scope_can_read_project_files` | +| REQ-plugin-wasm-001 | `O_CREAT` under `/project` is `EPERM`; no file on the host | `project_scope_cannot_create_files_in_project` | +| REQ-plugin-wasm-001 | write-open, with and without `O_TRUNC`, is `EPERM`; contents unchanged | `project_scope_cannot_write_or_truncate_project_files` | +| REQ-plugin-wasm-001 | `path_create_directory` under `/project` is `EPERM`; no directory | `project_scope_cannot_create_directories_in_project` | +| REQ-plugin-wasm-001 | `/project` stays read-only under the `plugin` scope | `plugin_scope_keeps_project_read_only` | +| REQ-plugin-wasm-002 | `filesystem = "plugin"` creates `/data/` | `filesystem_plugin_creates_data_dir` | +| REQ-plugin-wasm-002 | create and mkdir under `/plugin` return 0 and land in `/data/` | `plugin_scope_can_write_plugin_data_dir` | +| REQ-plugin-wasm-002 | `../escape.txt` from `/plugin` fails; nothing written beside `data/` | `plugin_scope_cannot_escape_data_dir` | +| REQ-plugin-wasm-003 | default capabilities: the module instantiates and descriptor 3 is `EBADF` | `no_filesystem_grant_has_no_preopens` (`run_fs_probe` unwraps `instantiate`, so instantiating is part of the assertion) | +| REQ-plugin-wasm-004 | TCP and UDP are switched on only inside `if capabilities.network` | Code, not a test: `build_wasi_p1` in `src/plugin/wasm.rs` (the `if capabilities.network` block). A preview 1 guest cannot observe it (in wasmtime-wasi 49.0.1, `sock_accept`, `sock_recv`, `sock_send` and `sock_shutdown` only look up an existing descriptor and return `ENOTSOCK`), and `WasiP1Ctx` does not expose the socket settings. See the open items in `context.md` | +| REQ-plugin-wasm-004 | no other socket setting, so the builder defaults hold | Code, not a test: `grep -n "allow_ip_name_lookup\|socket_addr_check" src/plugin/wasm.rs` finds nothing. In wasmtime-wasi 49.0.1, `src/ctx.rs` documents TCP, UDP and IP name lookup as "By default this is disabled", and `SocketAddrCheck::default()` in `src/sockets/mod.rs` refuses every address | +| REQ-plugin-wasm-005 | growing to exactly 256 MiB succeeds | `memory_is_capped_at_max_memory_bytes` (`run_grow(pages_at_cap - 1, false)`) | +| REQ-plugin-wasm-005 | one page past the cap returns -1 and the run does not trap | `memory_is_capped_at_max_memory_bytes` (`run_grow(pages_at_cap, true)`: the guest exits 0 only when `memory.grow` returned -1, so `Ok` also rules out a trap) | +| REQ-plugin-wasm-006 | the baseline module compiles | `engine_keeps_wasmtime_46_feature_set` | +| REQ-plugin-wasm-006 | GC, exception-handling, typed function references and wide-arithmetic modules are each refused | `engine_keeps_wasmtime_46_feature_set` | + +The mutation runs under "What was verified" show the filesystem, memory and feature-set +tests fail when the sandbox they pin is weakened. + +## Spec corrections + +The five `## MODIFIED` sections in `deltas/plugin-wasm.md` are verbatim copies of the +living `plugin-wasm.spec.md` sections with one sentence replaced in each. What makes each +replacement true: + +| Section | Replaced statement | Evidence | +|---|---|---| +| WASM Host Interface (`### Network`) | grant gives outbound TCP/UDP, no listening sockets, DNS from the host | REQ-plugin-wasm-004's evidence; `setup_linker` links all of preview 1 (`wasmtime_wasi::p1::add_to_linker_sync`), which has no call that creates a socket; wasmtime-wasi 49.0.1 documents `inherit_network()` as letting the guest bind or connect to any address | +| Security Model (guarantee 2) | no socket imports without the grant | the same `add_to_linker_sync` call links the preview 1 socket imports for every plugin; REQ-plugin-wasm-004's evidence | +| Invariants (7) | no socket imports without the grant | as above | +| Behavioral Examples (zero-capability scenario) | instantiation fails because the filesystem imports are not linked | REQ-plugin-wasm-003's evidence: the module instantiates and descriptor 3 is `EBADF` | +| Error Cases (memory limit) | the plugin traps with "plugin exceeded memory limit" | REQ-plugin-wasm-005's evidence; any other trap reaches the `_ => bail!("Plugin '{}' trapped: {}", …)` arm of `run_wasm_plugin`, and no "exceeded memory limit" string exists in `src/` | + +## Automated + +New tests in `src/plugin/wasm.rs` (run by CI's `cargo test --verbose --locked` on all +three OSes): + +| Test | Asserts | +|---|---| +| `project_scope_can_read_project_files` | `path_open` for read under `/project` returns 0 | +| `project_scope_cannot_create_files_in_project` | create under `/project` returns EPERM (63); no file on the host | +| `project_scope_cannot_write_or_truncate_project_files` | write-open, and write-open with `O_TRUNC`, return EPERM; contents unchanged | +| `project_scope_cannot_create_directories_in_project` | `path_create_directory` under `/project` returns EPERM; no directory | +| `plugin_scope_can_write_plugin_data_dir` | create and mkdir under `/plugin` return 0 and land in `/data/` | +| `plugin_scope_keeps_project_read_only` | `/project` is still read-only when the scope is `plugin` | +| `plugin_scope_cannot_escape_data_dir` | `../escape.txt` from `/plugin` fails; nothing written beside `data/` | +| `no_filesystem_grant_has_no_preopens` | without a filesystem grant fd 3 is EBADF | +| `memory_is_capped_at_max_memory_bytes` | `memory.grow` to exactly 256 MiB succeeds; one page more returns -1 | +| `engine_keeps_wasmtime_46_feature_set` | a baseline module (bulk memory, sign extension, saturating truncation, multi-value, reference types, tail calls) compiles; a GC, an exception-handling, a typed function references and a wide-arithmetic module are each refused | + +Existing tests still cover fuel exhaustion, capability denial at instantiation, the +`.cwasm` cache and its version stamp (`wasmtime_version_derived_from_cargo_toml`, +`cache_invalid_on_version_mismatch`). + +## What was verified + +All local runs on macOS (aarch64), rustc/clippy 1.98.0 (`stable`, the toolchain CI +uses). + +| Check | Result | +|---|---| +| `cargo fmt --check` | clean | +| `cargo clippy --locked -- -D warnings` (CI's lint form) | clean | +| `fledge run lint` (`cargo clippy --all-targets -- -D warnings`) | clean | +| `cargo clippy --locked --no-default-features -- -D warnings` | clean | +| `cargo test --verbose --locked` | all pass: 1096 unit tests (1087 on main plus the 9 new ones, 47 of them in `plugin::wasm`) and every integration test binary | +| New tests, unchanged, on main's 46.0.3 code | same results, same errno values | +| Same tests on 46.0.3 with `/project` widened to `all()` / `all()` and the memory limiter removed | 5 fail: the four read-only tests and the memory cap | +| Fuel exhaustion (infinite loop through `run_wasm_plugin`) | "exceeded its compute budget" after about 6.7 s, on 49.0.1 and 46.0.3 alike | +| Epoch deadline (deadline 1, epoch bumped after 300 ms) | `Trap::Interrupt` after about 306 ms, on 49.0.1 and 46.0.3 alike | +| `path_filestat_set_times` with extreme timestamps, preview 1 | no panic on either version; 0 on `/plugin`, 63 on `/project` | +| `cargo audit` on the branch `Cargo.lock`, advisory DB `9b3a3b7` (the commit CI used) | exit 0, 0 vulnerabilities | +| `cargo audit` on a fresh `cargo generate-lockfile` resolution, same DB | exit 0, 0 vulnerabilities; wasmtime, wasmtime-wasi and wiggle at 49.0.1 | +| `cargo audit` on main's `Cargo.lock`, same DB (negative control) | exit 1: RUSTSEC-2026-0316 (wasmtime 46.0.3), RUSTSEC-2026-0314 (wasmtime-wasi 46.0.3) | +| `cargo deny` | not run; the repository has no `deny.toml` | +| `test (windows-latest)` on #538's first run (36804407887) | failed to compile: `CreateJobObjectW` missing without `Win32_Security` | +| `cargo tree --target x86_64-pc-windows-msvc -e features -i windows-sys@0.59.0` | main: `Win32_Security` only via `cap-primitives` 3.4.6, none with `--no-default-features`; branch: enabled by fledge, with and without default features | +| Windows compile, locally | not run; no Windows target installed here, so CI's `windows-latest` cells are the check | +| Review: probe modules for each proposal on main's 46.0.3 and on 49.0.1 before the feature-set commit | 46.0.3 refuses GC, exceptions, typed function references and wide arithmetic; 49.0.1 accepted all four | +| `engine_keeps_wasmtime_46_feature_set`, unchanged, on main's 46.0.3 code | passes | +| Same test on 49.0.1 without the four `wasm_*(false)` calls | fails (GC accepted) | +| `cargo test --locked` after the feature-set commit (macOS, 1.98.0) | all pass: 1097 unit tests and every integration test binary | +| fmt and clippy (CI form, `--all-targets`, `--no-default-features`) after the feature-set commit | clean | +| `specsync change show` after naming `plugin-wasm` (SpecSync 6.0.0) | draft, `affected_specs: [plugin-wasm]`, `no_spec_change: false`, no open questions, artifacts complete; next step is definition approval | +| SpecSync 6.0.0's approve, check and ship gates on a scratch copy of the branch, called read-only from a scratch build of the v6.0.0 tag (no approval recorded, nothing saved) | `validate_definition`, `validate_delta_files`, `validate_declared_path_ownership` and the effective-contract replay pass; `src/plugin/wasm.rs` resolves to `plugin-wasm`, and `Cargo.toml`, `Cargo.lock` and `CHANGELOG.md` to `@exact:delivery`; every REQ-plugin-wasm ID has evidence; the scoped `specsync check --spec plugin-wasm --strict` passes | +| The canonical files that materialization would write, on the scratch copy | spec diff is the five sentences, `version` 2 to 3 and one Change Log row; `specsync check --force --strict --require-coverage 100` 33 passed, 0 warnings; `fledge spec lint` 0 errors, 0 warnings | +| `specsync check --force --strict --require-coverage 100` on the branch | 33 passed, 0 warnings, file and LOC coverage 100% | +| `specsync change check --strict --require-coverage 100` (CI's form) | "Nothing to check": the change is still a draft | +| `specsync change audit` | fails only on "meaningful changed paths are not covered by an active change", because a draft covers no paths until it is approved; the no-spec draft got the same result | +| `cargo +stable test --locked --bin fledge plugin::wasm` (rustc 1.98.0) | 48 passed | +| `fledge run spec-check` | 33 specs, 0 errors, 0 warnings | + +## Acceptance signals + +- The `audit` job is green on the PR. +- `test` and `integration` are green on ubuntu-latest, macos-latest and windows-latest, + with the new sandbox tests among them. + +## Rejection signals + +- Any of the four `/project` read-only tests passing with `/project` writable. The + mutation run shows they fail. +- `memory_is_capped_at_max_memory_bytes` passing without the limiter. The mutation run + shows it fails. +- `engine_keeps_wasmtime_46_feature_set` passing with wasmtime's defaults left on. The + mutation run shows it fails. +- `cargo audit` on a fresh resolution still finding either advisory, which would mean + the requirement did not move. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f42168..8f05a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [v1.8.1] - 2026-09-26 -> **About 1.8.0.** fledge 1.8.0 was published to crates.io on 2026-09-19 from `435b7c5`, a release-branch commit that never reached `main`. 1.8.1 is that release cut from `main`. Everything below shipped in 1.8.0 except #531, #533, #534 and #535. The one difference in the binary is the `corvid-stack` template's Trust pin: 1.2.0 in 1.8.0, 1.2.1 here (#533). Dependencies are unchanged from 1.8.0. +> **About 1.8.0.** fledge 1.8.0 was published to crates.io on 2026-09-19 from `435b7c5`, a release-branch commit that never reached `main`. 1.8.1 is that release cut from `main`. Everything below shipped in 1.8.0 except #531, #533, #534, #535 and #538. The binary differs in two ways: the `corvid-stack` template's Trust pin is 1.2.1 here, not 1.2.0 (#533), and the plugin sandbox runs on wasmtime 49.0.1, not 46.0.3 (#538). Apart from the wasmtime family, dependencies are unchanged from 1.8.0. ### CI @@ -57,6 +57,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - name the two remaining --json schema literals (#444) (#503) (6eb7a3e) +### Security + +- upgrade wasmtime and wasmtime-wasi 46.0.3 to 49.0.1, clearing RUSTSEC-2026-0316 and RUSTSEC-2026-0314 (#538) + - RUSTSEC-2026-0316 ([GHSA-jqpg-j7w6-42pr](https://github.com/bytecodealliance/wasmtime/security/advisories/GHSA-jqpg-j7w6-42pr)): dynamic record lifting can allocate beyond the hostcall fuel limit. RUSTSEC-2026-0314 ([GHSA-j2g9-4prp-pf6h](https://github.com/bytecodealliance/wasmtime/security/advisories/GHSA-j2g9-4prp-pf6h)): a guest can panic the host through a filesystem datetime overflow. Both are in the wasmtime that sandboxes WASM plugins. Neither looks reachable from fledge's host, which runs core modules over WASI preview 1 only: the first is in the component-model `Val` API, which fledge never uses, and preview 1 splits every timestamp into whole seconds plus under a second of nanoseconds, so the overflow cannot form (a preview 1 guest passing extreme timestamps to 46.0.3 returned normally rather than panicking). The upgrade ships anyway: the plugin runtime is a security boundary, and `cargo audit` gates CI. + - The sandbox grants are unchanged: `/project` read-only, `/plugin` read-write, 256 MB memory, 10 billion fuel, 60 seconds of wall clock. wasmtime-wasi 49 replaced `DirPerms`/`FilePerms` with `FsPerms` and turned TCP and UDP off by default, so a plugin granted `network` now has both switched back on explicitly. New tests pin the read-only and read-write grants, `..` confinement and the memory cap. + - The WebAssembly feature set plugins see is unchanged too. wasmtime 47 turned the GC, exception-handling and typed function references proposals on by default, and wasmtime 49 added wide arithmetic. fledge now turns all four off, as wasmtime 46.0.3 had them; no plugin that ran on 1.8.0 can use them. RUSTSEC-2026-0315 (fuel dropped across `call_ref` and exception `catch`, fixed in 49.0.1) shows the surface they add. A test pins the feature set. + - An installed WASM plugin's cached `.cwasm` is recompiled once, on its first run after the upgrade. + - fledge now declares the `windows-sys` `Win32_Security` feature that its Windows job-object code (`CreateJobObjectW`) needs. Until now that feature arrived only through wasmtime-wasi 46's `cap-std`. The upgrade removed `cap-std`, which would have broken the Windows build, and `--no-default-features` builds on Windows already lacked the feature. + - Building fledge from source now needs Rust 1.96 or newer, wasmtime 49's minimum (wasmtime 46 needed 1.94). + ### Tests - mocking harness + $HOME isolation + publish coverage (#447) (#504) (43a2138) diff --git a/Cargo.lock b/Cargo.lock index ad71a89..48387e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,71 +202,21 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "cap-fs-ext" -version = "3.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "476f0d0003a760918ed4b1e039a59e11769030416f79c8222551d22785f7f70d" -dependencies = [ - "cap-primitives", - "cap-std", - "io-lifetimes", - "windows-sys 0.59.0", -] - -[[package]] -name = "cap-net-ext" -version = "3.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "150941cefd3df4de2fea24604ba4949371576f62e527410298333f7d431a1bc6" -dependencies = [ - "cap-primitives", - "cap-std", - "rustix", - "smallvec", -] - [[package]] name = "cap-primitives" -version = "3.4.6" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e0bf07d379916947be6c4a07f43684153d710a2896c31f9e97781362895596c" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" dependencies = [ "ambient-authority", "fs-set-times", "io-extras", - "io-lifetimes", + "io-lifetimes 3.0.1", "ipnet", "maybe-owned", "rustix", "rustix-linux-procfs", - "windows-sys 0.59.0", - "winx", -] - -[[package]] -name = "cap-std" -version = "3.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a59e59fa26472d29680ece6a9f8ee8b0551a719a33df2f5240bde065ecbddfd7" -dependencies = [ - "cap-primitives", - "io-extras", - "io-lifetimes", - "rustix", -] - -[[package]] -name = "cap-time-ext" -version = "3.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b54c289326c70f1c697ebf0a31842a480932e5942b5fac92fcc46e87286b48e2" -dependencies = [ - "ambient-authority", - "cap-primitives", - "iana-time-zone", - "once_cell", - "rustix", + "windows-sys 0.61.2", "winx", ] @@ -460,9 +410,9 @@ dependencies = [ [[package]] name = "cpp_demangle" -version = "0.4.5" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" dependencies = [ "cfg-if", ] @@ -487,27 +437,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aadfd45214f09461ba0406f7dacecce5cac39bf0bdb4890eeb468f4ef090e07" +checksum = "f6d504c8c7ba3f3accaf8e98b7096beb98e1a271404357744c7e9e730c6865ff" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57069faede9bd1f926364b4cf69ccc9bebc6f4af5ab897083581b7bc991bf20a" +checksum = "c065757905596bc06e82969ebd7b92d691b7fd710a7e9e8181ae0c30dab588d0" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1221450f2f9efaa996d916bf53a0a4d13346a4a75468afb828390209a4fdd4" +checksum = "5ca7162cd4d4fc5f9f2f88d1459d939882203d93a8c5a19ef2c405b2e82ef13f" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -515,9 +465,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97706f2b466d67bdf0f69d1cf5c5c4a5cd31087cf1fa2e8e5fe0450dddc8661d" +checksum = "0ec2ba2d4f3d959ec4023a10266c3dcfb28381d1938d507a9a5e1abb78bbcc88" dependencies = [ "serde", "serde_derive", @@ -526,9 +476,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "432246ee44b2fc0140b412dca55801d200d6fb25d2d7d18c44bb29e8cd71268b" +checksum = "51a64cb821d5fed56bdd6053acd36fa928cd82b70bf6b19b8b14b8b27d047b9a" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -557,9 +507,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aa51eff0949f06a5c8f3d5adefef94a12ea99f71cd8985047b91d45f367150" +checksum = "142be1e267a7497ba3c2b5a829eaf5387b92b9bcdba6e0a6020f1ffccae7f2b8" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -570,24 +520,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6095e7095930b6169490138b4262fe7ec24290d3f604d7fe62e1e3138e740daa" +checksum = "0e41f75410da6cce8601330137d73718f679db81cb790a33de3871b5c90a0de3" [[package]] name = "cranelift-control" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e61dfa0766c0eadf42b3febd145a35f81f31086e207f534e5fb406fc3d08fc47" +checksum = "0c2616c6d01c14921ac9289ef65eb7702d9c760dfdc4537a8ee5bf38ea2832c6" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93867a79a06dd5d9ffeb96f7563dc7b6b142b5d89e42ab77e458a408a8aa9d5" +checksum = "a03e8b44c4877cf2fb21e5788d514af6fd291a066ed9ae37adf114a25fecb4aa" dependencies = [ "cranelift-bitset", "serde", @@ -597,9 +547,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1991dc32fefa54f14d2a63570d1e963826257605e478558733f83450b7e7661" +checksum = "366ff7cb014bd08752210eba55a650969f8db9b52670d69d2ea0b23e1e9e3edf" dependencies = [ "cranelift-codegen", "hashbrown 0.17.1", @@ -610,15 +560,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784546de9988ff6e1df0fd4d2450760469a7607344156c661e69ec28082c5da4" +checksum = "9ea6526ebf9064fff8ce3b1e26afad7027f9e6bb0e45d2b9200e4cbab8343345" [[package]] name = "cranelift-native" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7107a4ae4a2ba48b5df8648365e638b08322fa1da11e86206bf5eddb862b21dd" +checksum = "b44d323f3e5108a8a5bc61f3acd227285ece066e19afe9aece211d66b34a64ea" dependencies = [ "cranelift-codegen", "libc", @@ -627,9 +577,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.133.3" +version = "0.136.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b3f161aa81ee5dd8d7b0eb8828988f57baf2f78d288e62b6b3714b37aa6d6a1" +checksum = "fc173168f291ff3b95b5d0769d6a05b1eaef5e6d74c2600affab8396b4e7b420" [[package]] name = "crc32fast" @@ -965,7 +915,7 @@ version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ - "io-lifetimes", + "io-lifetimes 2.0.4", "rustix", "windows-sys 0.59.0", ] @@ -1430,11 +1380,11 @@ dependencies = [ [[package]] name = "io-extras" -version = "0.18.4" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ - "io-lifetimes", + "io-lifetimes 3.0.1", "windows-sys 0.59.0", ] @@ -1444,6 +1394,12 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "ipnet" version = "2.12.2" @@ -1737,9 +1693,9 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "object" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" dependencies = [ "crc32fast", "hashbrown 0.17.1", @@ -1929,9 +1885,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f647f8aefd39efd5e38b484a8b7a926dd035ae366127df75c1d94d196f0a4ba" +checksum = "def59f28ccaf2cb4f8dfaac83197fd7d56a4470a70e4f2589454c731370101c5" dependencies = [ "cranelift-bitset", "log", @@ -1941,9 +1897,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e902032e329008b189b67498b1be7784d5849276f32402eca17441df08a6bcb" +checksum = "3a1834d3b3ef994f266a0c9ed29a8214f2c2faaa9b1c44babfa57566bb05af83" dependencies = [ "proc-macro2", "quote", @@ -2154,9 +2110,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -2560,17 +2516,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.15", + "winnow 1.0.4", ] [[package]] @@ -2584,9 +2540,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -2687,12 +2643,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "untrusted" version = "0.9.0" @@ -2837,9 +2787,9 @@ dependencies = [ [[package]] name = "wasm-compose" -version = "0.251.0" +version = "0.258.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b089037d7eb453ed57b560fe7833de0707411c8b9fdc429745ced77e2a1bacb9" +checksum = "27dc71f96d21a8b9153da9005c603f4f20d5781b7e6d2675ea0ca5def8713b75" dependencies = [ "anyhow", "heck", @@ -2847,19 +2797,19 @@ dependencies = [ "log", "petgraph", "smallvec", - "wasm-encoder 0.251.0", - "wasmparser 0.251.0", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", "wat", ] [[package]] name = "wasm-encoder" -version = "0.251.0" +version = "0.258.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" +checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f" dependencies = [ "leb128fmt", - "wasmparser 0.251.0", + "wasmparser 0.258.3", ] [[package]] @@ -2872,11 +2822,23 @@ dependencies = [ "wasmparser 0.259.0", ] +[[package]] +name = "wasm-metadata" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e10808eb27efde692f1b1706089fe19296728bf872291f5b4e0d50fba88f2f" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", +] + [[package]] name = "wasmparser" -version = "0.251.0" +version = "0.258.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" +checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" dependencies = [ "bitflags 2.13.2", "hashbrown 0.17.1", @@ -2898,27 +2860,27 @@ dependencies = [ [[package]] name = "wasmprinter" -version = "0.251.0" +version = "0.258.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8798c1a699bd25648b6708eefe94d97c6f9891febb94b42cca1f7a4b086ea64e" +checksum = "14a457ba8670a3db3d357c1aeac3ed4fe378a76054a221789f0df0ea507e292b" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.251.0", + "wasmparser 0.258.3", ] [[package]] name = "wasmtime" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39226663bd765601279a79b88645a7c4a4d3c2fe73040afdc27bead02ee6659" +checksum = "4fbdd39f5c66f63ba6b5b31391b324b7ade7237d29c866cdb561330118ef436e" dependencies = [ "addr2line", "async-trait", "bitflags 2.13.2", "bumpalo", + "bytes", "cc", - "cfg-if", "encoding_rs", "futures", "fxprof-processed-profile", @@ -2942,8 +2904,8 @@ dependencies = [ "target-lexicon", "tempfile", "wasm-compose", - "wasm-encoder 0.251.0", - "wasmparser 0.251.0", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", "wasmtime-environ", "wasmtime-internal-cache", "wasmtime-internal-component-macro", @@ -2962,9 +2924,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2595a168541e8a7faeacf21e3d59da93628ee71468cd69fee9fc2a92c9b1c7d8" +checksum = "e915c27826de4f7b4411b61fd6db32ca448657a5136b8c8e8874a15e90cecbf4" dependencies = [ "anyhow", "cpp_demangle", @@ -2984,8 +2946,8 @@ dependencies = [ "sha2 0.10.9", "smallvec", "target-lexicon", - "wasm-encoder 0.251.0", - "wasmparser 0.251.0", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", "wasmprinter", "wasmtime-internal-component-util", "wasmtime-internal-core", @@ -2993,9 +2955,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cache" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a37d629957320219db11336663e0947e19afe77150403509cac21c47ead60c69" +checksum = "952086551d2041430a159fe4bda3471cfea3d52846b93c825829cea9dd01954d" dependencies = [ "base64 0.22.1", "directories-next", @@ -3005,7 +2967,7 @@ dependencies = [ "serde", "serde_derive", "sha2 0.10.9", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "wasmtime-environ", "windows-sys 0.61.2", "zstd", @@ -3013,9 +2975,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9abf0fb4ce458eb6edbfaf6cbf13fcbf4216eac5a85febfe90224a1f6064c990" +checksum = "d95f531a0b809fd9fd158be6133c97bd2fc628ac47f5812aff3e3b5624eb02c2" dependencies = [ "anyhow", "proc-macro2", @@ -3028,15 +2990,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e50df18c9a8e0deec353ad1e6364ef50538231719a12d80fa5dcb300be8d36b" +checksum = "e754a3ff5690c34eb4713bd7c65f60dd607ead4e39042cbf52ac8011de14455b" [[package]] name = "wasmtime-internal-core" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93b097389e5fad89c8ff8ee0aeafd16447b5a288d52aa1131f17ab4c19fd5be3" +checksum = "ba00c5db77adb0749ee059068e5a417bfac3d8969f5a3479d9bd1ca8c1ba248d" dependencies = [ "anyhow", "hashbrown 0.17.1", @@ -3046,11 +3008,10 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08b3e909115836655b83c8bdef7c2e20a34293e785218d9f71a4e5382a09c6b0" +checksum = "97ceeb4325712a253f463e50a69b5a547fbbecd52e45612360c543eaa25a03ff" dependencies = [ - "cfg-if", "cranelift-codegen", "cranelift-control", "cranelift-entity", @@ -3064,7 +3025,7 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.20", - "wasmparser 0.251.0", + "wasmparser 0.258.3", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-unwinder", @@ -3073,12 +3034,11 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a000d1124dd40417ce8433d07de1063ecc9be7cac21745c8de7f9cf7684c3ead" +checksum = "3eb3f21bc32b5382ea95f83ce9a5f2f62b68f1b25a37d0802a41a65236eae5ae" dependencies = [ "cc", - "cfg-if", "libc", "rustix", "wasmtime-environ", @@ -3088,9 +3048,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7439956e78ba68e2cd766f34c3f056d625fad9a055eafb0c4569ac91c09e6b4" +checksum = "e786f9925126e0d2e466c5308e572e8b6226f4047596228a3d63d99528a78949" dependencies = [ "cc", "object", @@ -3100,11 +3060,10 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3a381a9dd1ffe3893507733300b420605f465655d78c7a75a965a09d7b43d5" +checksum = "92cb1dd5da78f5b21d65e91063bdd8c2eb343c1f077a1dcbc29003d4a794abb0" dependencies = [ - "cfg-if", "libc", "wasmtime-internal-core", "windows-sys 0.61.2", @@ -3112,11 +3071,10 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f951f78a1a09001547838fa557522d56655385db97e47307d0f582ca866974" +checksum = "d0243d244f6dd07ccd114e6bb099e8dd1197b47de5c054576adc9483f80f65d8" dependencies = [ - "cfg-if", "cranelift-codegen", "log", "object", @@ -3125,9 +3083,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e06f9fabbe647e5c2ed186e6920ab1fb48174688d7d3fdadbda537b8499b51" +checksum = "20fa5616cd875ec78f5b92020fa47ced7c8bb923079185c8475751098b8c4a69" dependencies = [ "proc-macro2", "quote", @@ -3136,36 +3094,32 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49ac5121036c2255bdc203b1b75a1c04544744cd5f51c93240b58bddca9f0f1" +checksum = "cad5e8cef56790d4741b62f7b6c971af53b2ad4c2d1603f8ffe86872763147aa" dependencies = [ "anyhow", "bitflags 2.13.2", "heck", "indexmap", + "wit-component", "wit-parser", ] [[package]] name = "wasmtime-wasi" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d23668f18d6fd0a932c1fc58600bbb8a2e4f9b217f08bb61841e2c275d4ce3" +checksum = "284555904d42017e049fd57c05810b7975075c322481c045f99b07385fb7a5ba" dependencies = [ "async-trait", "bitflags 2.13.2", "bytes", - "cap-fs-ext", - "cap-net-ext", - "cap-std", - "cap-time-ext", - "cfg-if", + "cap-primitives", "futures", - "io-extras", - "io-lifetimes", "rand 0.10.2", "rustix", + "rustix-linux-procfs", "thiserror 2.0.20", "tokio", "tracing", @@ -3174,13 +3128,14 @@ dependencies = [ "wasmtime-wasi-io", "wiggle", "windows-sys 0.61.2", + "winx", ] [[package]] name = "wasmtime-wasi-io" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0df5ffcd5aca96285be7745d4677ca5e2851245ca7c066a98c0fbee5bc6375f1" +checksum = "ebaf573aae85636595bdb976b6c76e9277e1f81db067aff6764cb256e0c17dff" dependencies = [ "async-trait", "bytes", @@ -3241,9 +3196,9 @@ dependencies = [ [[package]] name = "wiggle" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77deefc2b0fe8c258316372ec322da627ce8c5b5ea9eec3b001190e9f8f2f69" +checksum = "ad3dcdc2980b91d0e7db6975caee4f553c6e37fd8e1464a38b09baceed3fe568" dependencies = [ "bitflags 2.13.2", "thiserror 2.0.20", @@ -3255,9 +3210,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a52145d29793eb1e0c5be9b6be5a74552b9236f01866c769ec1c3270584085f" +checksum = "4765433ad668e225812b956e0f598011e5cdab4d1a57b2d295ab6863e36ff231" dependencies = [ "heck", "proc-macro2", @@ -3269,9 +3224,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "46.0.3" +version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26242c5c54d0f2837e1fecc933e1581e45d39cde1b1f6b3ad72955e0851ec20" +checksum = "d94c6623099d11a3fee39986492e803dd0db6676c9d40ab9553a7edd29f317e5" dependencies = [ "proc-macro2", "quote", @@ -3485,11 +3440,30 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "wit-component" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fca88950af0c1f42bc77e17315e48798b8d68340171b6d204f62bc19a878a6" +dependencies = [ + "anyhow", + "bitflags 2.13.2", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.258.3", + "wasm-metadata", + "wasmparser 0.258.3", + "wit-parser", +] + [[package]] name = "wit-parser" -version = "0.251.0" +version = "0.258.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e960732e824fab95099971a09e638979347c94ca48568d3c854c945729196947" +checksum = "72a2127663dcf3ee16614fb0e3c33d5c6c66fec9dad317a8b0fdd3869487a533" dependencies = [ "anyhow", "hashbrown 0.17.1", @@ -3500,8 +3474,8 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "unicode-xid", - "wasmparser 0.251.0", + "unicode-ident", + "wasmparser 0.258.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 81926e6..7854a27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT" homepage = "https://github.com/CorvidLabs/fledge" documentation = "https://corvidlabs.github.io/fledge/docs" repository = "https://github.com/CorvidLabs/fledge" -rust-version = "1.89" +rust-version = "1.96" keywords = ["cli", "scaffold", "template", "task-runner", "dev-tools"] categories = ["command-line-utilities", "development-tools"] @@ -36,8 +36,8 @@ sha2 = "0.11.0" base64 = "0.22" notify = "7" shell-words = "1.1.1" -wasmtime = { version = "46.0.1", optional = true } -wasmtime-wasi = { version = "46.0.1", optional = true } +wasmtime = { version = "49.0.2", optional = true } +wasmtime-wasi = { version = "49.0.2", optional = true } [features] default = ["wasm"] @@ -56,7 +56,11 @@ tempfile = "3" libc = "0.2.185" [target."cfg(windows)".dependencies] +# Win32_Security gates CreateJobObjectW (its first parameter is a +# SECURITY_ATTRIBUTES pointer). It used to arrive only through wasmtime-wasi +# 46's cap-std, so declare it here rather than rely on feature unification. windows-sys = { version = "0.59", features = [ "Win32_Foundation", + "Win32_Security", "Win32_System_JobObjects", ] } diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index a53c155..049770b 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use wasmtime::*; use wasmtime_wasi::p1::WasiP1Ctx; -use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder}; +use wasmtime_wasi::{FsPerms, WasiCtxBuilder}; use crate::plugin::PluginCapabilities; @@ -39,6 +39,15 @@ fn create_engine() -> Result { let mut config = Config::new(); config.consume_fuel(true); config.epoch_interruption(true); + // Keep the guest-visible feature set at what wasmtime 46 accepted. wasmtime + // 47 turned GC, exception handling and typed function references on by + // default, and 49 added wide arithmetic. No plugin that ran on wasmtime 46 + // can use them, and RUSTSEC-2026-0315 (fuel dropped across `call_ref` and + // exception `catch`) shows the extra surface is not free. + config.wasm_gc(false); + config.wasm_exceptions(false); + config.wasm_function_references(false); + config.wasm_wide_arithmetic(false); Ok(Engine::new(&config)?) } @@ -153,21 +162,25 @@ fn build_wasi_p1( .canonicalize() .unwrap_or_else(|_| plugin_dir.to_path_buf()); + // `/project` is read-only (no create, remove, rename or write-open); + // `/plugin` is read-write. wasmtime-wasi 49 replaced the separate + // DirPerms/FilePerms bitflags with `FsPerms`: `ReadOnly` is the old + // `DirPerms::READ` + `FilePerms::READ`, `ReadWrite` the old `all()` + `all()`. match capabilities.filesystem.as_deref() { Some("project") => { if let Some(root) = project_root { let resolved = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); - builder.preopened_dir(&resolved, "/project", DirPerms::READ, FilePerms::READ)?; + builder.preopened_dir(&resolved, "/project", FsPerms::ReadOnly)?; } } Some("plugin") => { if let Some(root) = project_root { let resolved = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); - builder.preopened_dir(&resolved, "/project", DirPerms::READ, FilePerms::READ)?; + builder.preopened_dir(&resolved, "/project", FsPerms::ReadOnly)?; } let data_dir = resolved_plugin_dir.join("data"); std::fs::create_dir_all(&data_dir)?; - builder.preopened_dir(&data_dir, "/plugin", DirPerms::all(), FilePerms::all())?; + builder.preopened_dir(&data_dir, "/plugin", FsPerms::ReadWrite)?; } _ => {} } @@ -175,7 +188,12 @@ fn build_wasi_p1( builder.inherit_stdout(); if capabilities.network { - builder.inherit_network(); + // wasmtime-wasi 49 turned TCP and UDP off by default (46 had them on), + // so `inherit_network()`, which only opens the socket address check, + // no longer grants sockets by itself. Re-enable both so a plugin + // granted `network` keeps exactly what it had. IP name lookup stays + // off, as before. Without the grant, sockets stay disabled. + builder.inherit_network().allow_tcp(true).allow_udp(true); } Ok(builder.build_p1()) @@ -623,6 +641,58 @@ mod tests { create_engine().unwrap(); } + #[test] + fn engine_keeps_wasmtime_46_feature_set() { + let engine = create_engine().unwrap(); + + // What wasm32-wasip1 toolchains emit by default (bulk memory, sign + // extension, saturating truncation, multi-value, reference types), + // plus tail calls, all of which wasmtime 46 accepted. + let accepted = r#"(module + (memory 1) + (func $pair (result i32 i32) (i32.const 1) (i32.const 2)) + (func $tail (result i32 i32) (return_call $pair)) + (func (export "_start") + (memory.copy (i32.const 0) (i32.const 8) (i32.const 4)) + (drop (i32.extend8_s (i32.const 255))) + (drop (i32.trunc_sat_f32_s (f32.const 1.5))) + (drop (ref.is_null (ref.null extern))) + (call $tail) drop drop))"#; + Module::new(&engine, accepted).expect("baseline module must compile"); + + let refused = [ + ( + "gc", + r#"(module (type $a (array (mut i8))) + (func (export "f") (result i32) + (array.len (array.new_default $a (i32.const 1)))))"#, + ), + ( + "exceptions", + r#"(module (tag $e) + (func (export "f") + (block $h (try_table (catch_all $h) (throw $e)))))"#, + ), + ( + "function references", + r#"(module (type $t (func (result i32))) + (func $g (type $t) (i32.const 1)) (elem declare func $g) + (func (export "f") (result i32) (call_ref $t (ref.func $g))))"#, + ), + ( + "wide arithmetic", + r#"(module (func (export "f") (result i64 i64) + (i64.add128 (i64.const 1) (i64.const 0) (i64.const 2) (i64.const 0))))"#, + ), + ]; + for (proposal, wat) in refused { + assert!( + Module::new(&engine, wat).is_err(), + "the {proposal} proposal must stay disabled, as it was on wasmtime 46" + ); + } + } + #[test] fn compute_hash_deterministic() { let h1 = compute_hash(b"hello world"); @@ -787,6 +857,47 @@ mod tests { ); } + #[test] + fn memory_is_capped_at_max_memory_bytes() { + // The guest starts with one page and grows by `grow_pages`. It exits 0 + // only on the outcome the test expects and hits `unreachable` on the + // other, so `Ok` means memory.grow returned what the cap requires. + let run_grow = |grow_pages: usize, expect_refused: bool| { + let (on_refused, on_granted) = if expect_refused { + ("(call $exit (i32.const 0))", "(unreachable)") + } else { + ("(unreachable)", "(call $exit (i32.const 0))") + }; + let wat = format!( + r#"(module + (import "fledge" "exit" (func $exit (param i32))) + (memory (export "memory") 1) + (func (export "_start") + (if (i32.eq (memory.grow (i32.const {grow_pages})) (i32.const -1)) + (then {on_refused}) + (else {on_granted}))))"# + ); + let dir = tempfile::tempdir().unwrap(); + let wasm_path = dir.path().join("grow.wasm"); + std::fs::write(&wasm_path, wat).unwrap(); + let caps = PluginCapabilities::default(); + run_wasm_plugin(&wasm_path, &[], "test-grow", "0.0.1", dir.path(), &caps) + }; + let pages_at_cap = MAX_MEMORY_BYTES / (64 * 1024); + + let at_cap = run_grow(pages_at_cap - 1, false); + assert!( + at_cap.is_ok(), + "growing to exactly the cap must succeed: {at_cap:?}" + ); + + let past_cap = run_grow(pages_at_cap, true); + assert!( + past_cap.is_ok(), + "growing past the cap must be refused: {past_cap:?}" + ); + } + #[test] fn capability_denial_exec_traps() { // Module that tries to call exec — but caps.exec is false @@ -1298,4 +1409,207 @@ mod tests { "plugin filesystem mode should create data/ directory" ); } + + // --- Filesystem grants: /project is read-only, /plugin is read-write --- + // + // These drive real WASI preview1 calls against the preopens build_wasi_p1 + // hands the guest, so a change in how the grants map onto wasmtime-wasi's + // permissions (DirPerms/FilePerms before 49, FsPerms since) shows up as a + // different errno or a file appearing on the host. + + const ERRNO_SUCCESS: i32 = 0; + const ERRNO_BADF: i32 = 8; + const ERRNO_PERM: i32 = 63; + const OFLAGS_CREAT: i32 = 1; + const OFLAGS_TRUNC: i32 = 8; + const RIGHTS_FD_READ: i64 = 1 << 1; + const RIGHTS_FD_WRITE: i64 = 1 << 6; + // Preopens follow stdin, stdout and stderr, in the order build_wasi_p1 + // adds them: /project first, then /plugin. + const FD_PROJECT: i32 = 3; + const FD_PLUGIN: i32 = 4; + + fn path_open_probe(fd: i32, path: &str, oflags: i32, rights: i64) -> String { + format!( + r#"(module + (import "wasi_snapshot_preview1" "path_open" + (func $path_open (param i32 i32 i32 i32 i32 i64 i64 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 16) "{path}") + (func (export "probe") (result i32) + (call $path_open + (i32.const {fd}) (i32.const 0) + (i32.const 16) (i32.const {len}) + (i32.const {oflags}) (i64.const {rights}) (i64.const 0) + (i32.const 0) (i32.const 0))))"#, + len = path.len() + ) + } + + fn create_dir_probe(fd: i32, path: &str) -> String { + format!( + r#"(module + (import "wasi_snapshot_preview1" "path_create_directory" + (func $mkdir (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 16) "{path}") + (func (export "probe") (result i32) + (call $mkdir (i32.const {fd}) (i32.const 16) (i32.const {len}))))"#, + len = path.len() + ) + } + + /// Runs the module's `probe` export in the sandbox `run_wasm_plugin` builds + /// for `caps`, with `project` as the project root, and returns the errno. + fn run_fs_probe( + wat: &str, + caps: &PluginCapabilities, + plugin_dir: &Path, + project: &Path, + ) -> i32 { + let engine = create_engine().unwrap(); + let module = Module::new(&engine, wat).unwrap(); + let wasi = build_wasi_p1(caps, plugin_dir, Some(project)).unwrap(); + let host_state = HostState { + wasi, + plugin_name: "test-fs-probe".to_string(), + plugin_dir: plugin_dir.to_path_buf(), + capabilities: caps.clone(), + pending_response: None, + limits: StoreLimitsBuilder::new() + .memory_size(MAX_MEMORY_BYTES) + .build(), + }; + let mut store = Store::new(&engine, host_state); + store.limiter(|s| &mut s.limits); + store.set_fuel(FUEL_LIMIT).unwrap(); + store.set_epoch_deadline(1); + let linker = setup_linker(&engine, caps).unwrap(); + let instance = linker.instantiate(&mut store, &module).unwrap(); + let probe = instance + .get_typed_func::<(), i32>(&mut store, "probe") + .unwrap(); + probe.call(&mut store, ()).unwrap() + } + + fn fs_caps(scope: &str) -> PluginCapabilities { + PluginCapabilities { + filesystem: Some(scope.to_string()), + ..PluginCapabilities::default() + } + } + + #[test] + fn project_scope_can_read_project_files() { + let plugin = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("readme.txt"), "hello").unwrap(); + + let wat = path_open_probe(FD_PROJECT, "readme.txt", 0, RIGHTS_FD_READ); + let errno = run_fs_probe(&wat, &fs_caps("project"), plugin.path(), project.path()); + assert_eq!( + errno, ERRNO_SUCCESS, + "reading under /project must be allowed" + ); + } + + #[test] + fn project_scope_cannot_create_files_in_project() { + let plugin = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + + let wat = path_open_probe(FD_PROJECT, "new.txt", OFLAGS_CREAT, RIGHTS_FD_WRITE); + let errno = run_fs_probe(&wat, &fs_caps("project"), plugin.path(), project.path()); + assert_eq!(errno, ERRNO_PERM, "creating under /project must be refused"); + assert!(!project.path().join("new.txt").exists()); + } + + #[test] + fn project_scope_cannot_write_or_truncate_project_files() { + let plugin = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let readme = project.path().join("readme.txt"); + std::fs::write(&readme, "hello").unwrap(); + + for oflags in [0, OFLAGS_TRUNC] { + let wat = path_open_probe(FD_PROJECT, "readme.txt", oflags, RIGHTS_FD_WRITE); + let errno = run_fs_probe(&wat, &fs_caps("project"), plugin.path(), project.path()); + assert_eq!( + errno, ERRNO_PERM, + "write-open (oflags {oflags}) must be refused" + ); + } + assert_eq!(std::fs::read_to_string(&readme).unwrap(), "hello"); + } + + #[test] + fn project_scope_cannot_create_directories_in_project() { + let plugin = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + + let wat = create_dir_probe(FD_PROJECT, "sub"); + let errno = run_fs_probe(&wat, &fs_caps("project"), plugin.path(), project.path()); + assert_eq!(errno, ERRNO_PERM, "mkdir under /project must be refused"); + assert!(!project.path().join("sub").exists()); + } + + #[test] + fn plugin_scope_can_write_plugin_data_dir() { + let plugin = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let caps = fs_caps("plugin"); + + let wat = path_open_probe(FD_PLUGIN, "new.txt", OFLAGS_CREAT, RIGHTS_FD_WRITE); + let errno = run_fs_probe(&wat, &caps, plugin.path(), project.path()); + assert_eq!( + errno, ERRNO_SUCCESS, + "creating under /plugin must be allowed" + ); + assert!(plugin.path().join("data").join("new.txt").is_file()); + + let wat = create_dir_probe(FD_PLUGIN, "sub"); + let errno = run_fs_probe(&wat, &caps, plugin.path(), project.path()); + assert_eq!(errno, ERRNO_SUCCESS, "mkdir under /plugin must be allowed"); + assert!(plugin.path().join("data").join("sub").is_dir()); + } + + #[test] + fn plugin_scope_keeps_project_read_only() { + let plugin = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + + let wat = path_open_probe(FD_PROJECT, "new.txt", OFLAGS_CREAT, RIGHTS_FD_WRITE); + let errno = run_fs_probe(&wat, &fs_caps("plugin"), plugin.path(), project.path()); + assert_eq!( + errno, ERRNO_PERM, + "/project stays read-only under the plugin scope" + ); + assert!(!project.path().join("new.txt").exists()); + } + + #[test] + fn plugin_scope_cannot_escape_data_dir() { + let plugin = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + + let wat = path_open_probe(FD_PLUGIN, "../escape.txt", OFLAGS_CREAT, RIGHTS_FD_WRITE); + let errno = run_fs_probe(&wat, &fs_caps("plugin"), plugin.path(), project.path()); + assert_ne!(errno, ERRNO_SUCCESS, "`..` must not leave /plugin"); + assert!(!plugin.path().join("escape.txt").exists()); + } + + #[test] + fn no_filesystem_grant_has_no_preopens() { + let plugin = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("readme.txt"), "hello").unwrap(); + + let wat = path_open_probe(FD_PROJECT, "readme.txt", 0, RIGHTS_FD_READ); + let caps = PluginCapabilities::default(); + let errno = run_fs_probe(&wat, &caps, plugin.path(), project.path()); + assert_eq!( + errno, ERRNO_BADF, + "without a filesystem grant there is no fd 3" + ); + } }