From fc252a267abbfe26f5d7307093e513050533fa4a Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 9 Jul 2026 09:18:07 +0200 Subject: [PATCH] fix(wasi): back get-directories return with cabi_realloc'd memory (SR-37, #405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WASI Preview2 component run via `kilnd --wasi --component --wasi-fs ` could not use any preopen: `wasi:filesystem/preopens::get-directories` wrote its `list>` result into memory adjacent to the 8-byte canonical-ABI return area (retptr+8 / retptr+20) — memory the guest never allocated. preview2 libc then registered no usable preopen and every file op returned ENOENT, without ever calling open-at. Measured (single wasm32-wasip2 component): nested_component_instances=0 (the InterComponentHandler override does not fire) and the queried dispatcher already held the preopen (preopens.len()=1) — so the defect was the return encoding, not the wiring. The working get-arguments path proves the fix: back the list return with cabi_realloc'd guest memory. This mirrors the args machinery across five sites: - kiln-foundation: HostImportHandler gains get_preopens + set_preopens_allocation. - kiln-runtime: pre_allocate_wasi_preopens + allocate_wasi_preopens_memory (N*12 entry buffer + per-path string buffers via cabi_realloc); CapabilityAware wrapper. - kiln-wasi: WasiDispatcher.preopens_alloc; get-directories writes entries into the allocation and FAILS LOUD if it was not set (no more unowned scribble). - kiln-component: ComponentInstance::pre_allocate_wasi_preopens. - kilnd: call it before the entry point, next to pre_allocate_wasi_args. With this, the guest registers the "." preopen and successfully open-at/stat's a file under it (verified by trace). Two unit oracles assert the encoding uses the allocation (not retptr+8) and fails loud without it. Note: full end-to-end file READ for #405 remains blocked on SR-38 (input-stream blocking-read writes list to a fixed unowned address — a distinct, harder defect needing on-demand cabi_realloc). #405 stays open; this lands its prerequisite. Trace: SR-37 --- kiln-component/src/types.rs | 16 ++ kiln-foundation/src/traits.rs | 22 +++ kiln-runtime/src/engine/capability_engine.rs | 11 ++ kiln-runtime/src/stackless/engine.rs | 93 ++++++++++ kiln-wasi/src/dispatcher.rs | 172 +++++++++++++++--- kilnd/src/lib.rs | 5 + .../requirements/functional-requirements.yaml | 34 ++++ 7 files changed, 330 insertions(+), 23 deletions(-) diff --git a/kiln-component/src/types.rs b/kiln-component/src/types.rs index b903cecb..358d8fcd 100644 --- a/kiln-component/src/types.rs +++ b/kiln-component/src/types.rs @@ -322,6 +322,22 @@ impl ComponentInstance { Ok(()) } } + + /// Pre-allocate memory for WASI filesystem preopens using cabi_realloc. + /// + /// Must be called after setting the host handler (so the preopens are registered) + /// but before calling entry points that use `get-directories`. Mirrors + /// [`pre_allocate_wasi_args`]; the canonical ABI requires guest-owned memory for + /// returning the `list>` from get-directories. + #[cfg(all(feature = "std", feature = "kiln-execution", feature = "wasi"))] + pub fn pre_allocate_wasi_preopens(&mut self) -> kiln_error::Result<()> { + if let (Some(engine), Some(handle)) = (&mut self.runtime_engine, self.main_instance_handle) + { + engine.pre_allocate_wasi_preopens(handle) + } else { + Ok(()) + } + } } /// State of a component instance diff --git a/kiln-foundation/src/traits.rs b/kiln-foundation/src/traits.rs index 730d6af3..0f3ab2d0 100644 --- a/kiln-foundation/src/traits.rs +++ b/kiln-foundation/src/traits.rs @@ -1648,4 +1648,26 @@ pub trait HostImportHandler: Send + Sync { fn set_args_allocation(&mut self, _list_ptr: u32, _string_ptrs: Vec<(u32, u32)>) { // Default no-op for handlers that don't need this } + + /// Report the registered filesystem preopens as (descriptor-handle, path) pairs. + /// + /// The engine reads this before entry to size the cabi_realloc'd return buffer + /// for `wasi:filesystem/preopens::get-directories`. Default empty for handlers + /// with no filesystem preopens. + fn get_preopens(&self) -> Vec<(u32, alloc::string::String)> { + Vec::new() + } + + /// Record the cabi_realloc'd memory backing the `get-directories` list return. + /// + /// Mirrors [`set_args_allocation`]: the canonical-ABI return area for + /// `func() -> list>` is only 8 bytes (ptr,len), so the + /// list entries and path strings must live in guest-owned memory allocated via + /// `cabi_realloc`. `list_ptr` points to the N*12-byte entry array; `string_ptrs` + /// holds the (ptr, len) of each separately-allocated path string, in preopen order. + /// + /// Default no-op for handlers that don't serve filesystem preopens. + fn set_preopens_allocation(&mut self, _list_ptr: u32, _string_ptrs: Vec<(u32, u32)>) { + // Default no-op for handlers that don't need this + } } diff --git a/kiln-runtime/src/engine/capability_engine.rs b/kiln-runtime/src/engine/capability_engine.rs index a39cde04..1452b74e 100644 --- a/kiln-runtime/src/engine/capability_engine.rs +++ b/kiln-runtime/src/engine/capability_engine.rs @@ -498,6 +498,17 @@ impl CapabilityAwareEngine { self.inner.pre_allocate_wasi_args(*instance_idx) } + /// Pre-allocate cabi_realloc'd memory backing `get-directories` (filesystem preopens). + /// + /// Mirrors [`pre_allocate_wasi_args`] for `wasi:filesystem/preopens::get-directories`. + #[cfg(feature = "wasi")] + pub fn pre_allocate_wasi_preopens(&mut self, instance_handle: InstanceHandle) -> Result<()> { + let instance_idx = self.handle_to_idx.get(&instance_handle) + .ok_or_else(|| Error::resource_not_found("Instance not found"))?; + + self.inner.pre_allocate_wasi_preopens(*instance_idx) + } + /// Register a lowered function from a canon.lower operation /// /// When a module calls a function at this (instance_id, func_idx), the engine diff --git a/kiln-runtime/src/stackless/engine.rs b/kiln-runtime/src/stackless/engine.rs index a657e9bf..721bf87f 100644 --- a/kiln-runtime/src/stackless/engine.rs +++ b/kiln-runtime/src/stackless/engine.rs @@ -11981,6 +11981,99 @@ impl StacklessEngine { } } + /// Pre-allocate cabi_realloc'd memory backing `wasi:filesystem/preopens::get-directories`. + /// + /// The canonical-ABI return area for `func() -> list>` is + /// only 8 bytes (ptr+len), so the entry array (N*12 bytes) and each path string must + /// live in guest-owned memory. Mirrors [`pre_allocate_wasi_args`]. The preopens are + /// read back from the host handler (they were registered via `add_preopen`). + #[cfg(feature = "wasi")] + pub fn pre_allocate_wasi_preopens(&mut self, instance_id: usize) -> Result<()> { + // Read the registered preopens from the host handler. + let preopens: Vec<(u32, String)> = match self.host_handler { + Some(ref handler) => handler.get_preopens(), + None => return Ok(()), + }; + if preopens.is_empty() { + #[cfg(feature = "tracing")] + trace!("[WASI-PREALLOC] No preopens to pre-allocate"); + return Ok(()); + } + + let paths: Vec = preopens.into_iter().map(|(_fd, path)| path).collect(); + match self.allocate_wasi_preopens_memory(instance_id, &paths)? { + Some((list_ptr, string_ptrs)) => { + if let Some(ref mut handler) = self.host_handler { + handler.set_preopens_allocation(list_ptr, string_ptrs); + } + Ok(()) + } + None => { + #[cfg(feature = "tracing")] + trace!("[WASI-PREALLOC] preopens: cabi_realloc not available"); + Ok(()) + } + } + } + + /// Allocate guest memory for the get-directories list return via cabi_realloc. + /// + /// Returns (list_ptr, string_ptrs) where list_ptr is the N*12-byte entry array + /// (descriptor, path_ptr, path_len per entry) and string_ptrs holds each path + /// string's (ptr, len), allocated separately for proper allocator metadata. + #[cfg(feature = "wasi")] + fn allocate_wasi_preopens_memory( + &mut self, + instance_id: usize, + paths: &[String], + ) -> Result)>> { + let instance = self.instances.get(&instance_id) + .ok_or_else(|| kiln_error::Error::runtime_error("Instance not found"))? + .clone(); + let module = instance.module(); + + let cabi_realloc_idx = match self.find_export_index(&module, "cabi_realloc") { + Ok(idx) => idx, + Err(_) => { + #[cfg(feature = "tracing")] + trace!("[WASI-ALLOC] cabi_realloc not found for preopens"); + return Ok(None); + } + }; + + if paths.is_empty() { + return Ok(None); + } + + // Entry array: 12 bytes per preopen (descriptor u32, path_ptr u32, path_len u32). + let list_size = (paths.len() * 12) as u32; + let list_ptr = self.call_cabi_realloc( + instance_id, + cabi_realloc_idx, + 0, // old_ptr + 0, // old_size + 4, // align (4-byte for the u32 fields) + list_size, + )?; + + // Allocate each path string separately (align 1 for byte data). + let mut string_ptrs: Vec<(u32, u32)> = Vec::with_capacity(paths.len()); + for path in paths { + let path_len = path.len() as u32; + let string_ptr = self.call_cabi_realloc( + instance_id, + cabi_realloc_idx, + 0, + 0, + 1, + path_len.max(1), // never request 0 bytes + )?; + string_ptrs.push((string_ptr, path_len)); + } + + Ok(Some((list_ptr, string_ptrs))) + } + /// Write data to WASM instance memory fn write_to_instance(&self, instance_id: usize, addr: u32, data: &[u8]) -> Result<()> { let instance = self.instances.get(&instance_id) diff --git a/kiln-wasi/src/dispatcher.rs b/kiln-wasi/src/dispatcher.rs index 45136b62..53129e3e 100644 --- a/kiln-wasi/src/dispatcher.rs +++ b/kiln-wasi/src/dispatcher.rs @@ -219,6 +219,12 @@ pub struct WasiDispatcher { /// Pre-opened directories (list of (handle, path) pairs) #[cfg(feature = "std")] preopens: Vec<(u32, PathBuf)>, + /// Pre-allocated memory for the get-directories list return (`list_ptr`, `string_ptrs`). + /// Set by the engine after calling `cabi_realloc`; mirrors `args_alloc`. The + /// canonical-ABI return area for `list>` is only 8 bytes, + /// so the entry array and path strings must live in this guest-owned memory. + #[cfg(feature = "std")] + preopens_alloc: Option<(u32, Vec<(u32, u32)>)>, } impl WasiDispatcher { @@ -261,6 +267,8 @@ impl WasiDispatcher { fd_table, #[cfg(feature = "std")] preopens: Vec::new(), + #[cfg(feature = "std")] + preopens_alloc: None, }) } @@ -2324,7 +2332,15 @@ impl WasiDispatcher { // wasi:filesystem/preopens - core dispatch #[cfg(feature = "wasi-filesystem")] ("wasi:filesystem/preopens", "get-directories") => { - // Return list of (descriptor, path) tuples at retptr + // Return list> at retptr. + // + // The canonical-ABI return area at retptr is only 8 bytes (the list + // header: ptr + len). The list entries and path strings MUST live in + // guest-owned memory allocated via cabi_realloc — writing them into + // memory adjacent to retptr (as an earlier version did) hands the guest + // a list backed by memory it never allocated, so preview2 libc registers + // no usable preopen and every file op returns noent. This mirrors the + // get-arguments path exactly (see preopens_alloc / pre_allocate_wasi_preopens). if let Some(mem) = memory { let retptr = match args.first() { Some(CoreValue::I32(v)) => *v as u32, @@ -2334,29 +2350,44 @@ impl WasiDispatcher { // No preopens: empty list (ptr=0, len=0) mem.write_bytes(retptr, &0u32.to_le_bytes())?; mem.write_bytes(retptr + 4, &0u32.to_le_bytes())?; - } else { - // Each entry is (descriptor: u32, path_ptr: u32, path_len: u32) = 12 bytes - let entry_size = 12u32; - let list_size = self.preopens.len() as u32 * entry_size; - // Write entries starting after retptr+8 (the list header) - let data_start = retptr + 8; - for (i, (fd, path)) in self.preopens.iter().enumerate() { - let entry_offset = data_start + (i as u32 * entry_size); - let path_bytes = path.to_string_lossy(); - let path_bytes = path_bytes.as_bytes(); - // Write path string after all entries - let path_offset = data_start + list_size + (i as u32 * 256); - let path_len = core::cmp::min(path_bytes.len(), 255) as u32; - mem.write_bytes(path_offset, &path_bytes[..path_len as usize])?; - // Write entry: (fd, path_ptr, path_len) - mem.write_bytes(entry_offset, &(*fd).to_le_bytes())?; - mem.write_bytes(entry_offset + 4, &path_offset.to_le_bytes())?; - mem.write_bytes(entry_offset + 8, &path_len.to_le_bytes())?; - } - // Write list header: (ptr, len) - mem.write_bytes(retptr, &data_start.to_le_bytes())?; - mem.write_bytes(retptr + 4, &(self.preopens.len() as u32).to_le_bytes())?; + return Ok(vec![]); } + + // Use pre-allocated cabi_realloc memory. NO FALLBACK — per FAIL-LOUD, + // if the engine did not pre-allocate (no cabi_realloc export) we error + // rather than scribble into unowned guest memory. + let (list_ptr, string_ptrs) = match &self.preopens_alloc { + Some((lp, sp)) => (*lp, sp.clone()), + None => { + return Err(Error::runtime_error( + "get-directories requires cabi_realloc pre-allocation (preopens_alloc not set)", + )); + }, + }; + if string_ptrs.len() != self.preopens.len() { + return Err(Error::runtime_error( + "get-directories: preopen string allocation count mismatch", + )); + } + + // Each entry is (descriptor: u32, path_ptr: u32, path_len: u32) = 12 bytes. + let entry_size = 12u32; + for (i, (fd, path)) in self.preopens.iter().enumerate() { + let (string_ptr, alloc_len) = string_ptrs[i]; + let path_str = path.to_string_lossy(); + let path_bytes = path_str.as_bytes(); + let path_len = core::cmp::min(path_bytes.len() as u32, alloc_len); + // Write the path string into its own cabi_realloc'd buffer. + mem.write_bytes(string_ptr, &path_bytes[..path_len as usize])?; + // Write the entry (descriptor, path_ptr, path_len) into the list buffer. + let entry_offset = list_ptr + (i as u32 * entry_size); + mem.write_bytes(entry_offset, &(*fd).to_le_bytes())?; + mem.write_bytes(entry_offset + 4, &string_ptr.to_le_bytes())?; + mem.write_bytes(entry_offset + 8, &path_len.to_le_bytes())?; + } + // Write list header (ptr, len) into the return area. + mem.write_bytes(retptr, &list_ptr.to_le_bytes())?; + mem.write_bytes(retptr + 4, &(self.preopens.len() as u32).to_le_bytes())?; } Ok(vec![]) } @@ -2646,6 +2677,17 @@ impl kiln_foundation::HostImportHandler for WasiDispatcher { fn set_args_allocation(&mut self, list_ptr: u32, string_ptrs: Vec<(u32, u32)>) { self.set_args_alloc(list_ptr, string_ptrs); } + + fn get_preopens(&self) -> Vec<(u32, String)> { + self.preopens + .iter() + .map(|(fd, path)| (*fd, path.to_string_lossy().into_owned())) + .collect() + } + + fn set_preopens_allocation(&mut self, list_ptr: u32, string_ptrs: Vec<(u32, u32)>) { + self.preopens_alloc = Some((list_ptr, string_ptrs)); + } } /// True iff `full` (a guest path already joined onto the preopen `base`) stays @@ -2685,6 +2727,90 @@ mod tests { assert!(!is_within_sandbox(base, &base.join("/etc/passwd"))); } + /// SR-37 / #405: get-directories must back its `list>` + /// return with cabi_realloc'd guest memory, NOT memory adjacent to the 8-byte + /// return area. The list header at retptr must point at the pre-allocated + /// `list_ptr` (not retptr+8), and each entry (descriptor, path_ptr, path_len) + /// must reference the pre-allocated string buffer — so a preview2 guest gets a + /// usable preopen instead of a list backed by memory it never allocated. + // rivet: verifies SR-37 + #[cfg(all(feature = "wasi-filesystem", feature = "std"))] + #[test] + fn get_directories_uses_cabi_realloc_allocation_not_retptr_scribble() -> Result<()> { + use kiln_foundation::traits::SliceMemory; + use kiln_foundation::HostImportHandler; + MemoryInitializer::ensure_initialized()?; + let mut dispatcher = WasiDispatcher::with_defaults()?; + + // Register a preopen "." (as `--wasi-fs .` would). + let fd = dispatcher.add_preopen(".")?; + + // Simulate the engine's cabi_realloc pre-allocation: a 12-byte entry buffer + // at 0x2000 and a 1-byte path-string buffer at 0x3000 — both well away from + // the 8-byte return area at retptr. + let retptr = 0x1000u32; + let list_ptr = 0x2000u32; + let str_ptr = 0x3000u32; + dispatcher.set_preopens_allocation(list_ptr, vec![(str_ptr, 1)]); + + let mem = SliceMemory::with_size(0x10000); + let res = dispatcher.dispatch_core( + "wasi:filesystem/preopens", + "get-directories", + &[kiln_foundation::values::Value::I32(retptr as i32)], + Some(&mem), + )?; + assert!(res.is_empty(), "get-directories returns void (writes via retptr)"); + + // Return area: (list_ptr, len) — MUST point at the allocation, not retptr+8. + let mut hdr = [0u8; 8]; + mem.read_bytes(retptr, &mut hdr)?; + let got_ptr = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]); + let got_len = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]); + assert_eq!(got_ptr, list_ptr, "list header must point at cabi_realloc'd list_ptr"); + assert_ne!(got_ptr, retptr + 8, "must NOT scribble into memory adjacent to the return area"); + assert_eq!(got_len, 1, "exactly one preopen"); + + // Entry at list_ptr: (descriptor, path_ptr, path_len). + let mut entry = [0u8; 12]; + mem.read_bytes(list_ptr, &mut entry)?; + let e_fd = u32::from_le_bytes([entry[0], entry[1], entry[2], entry[3]]); + let e_pptr = u32::from_le_bytes([entry[4], entry[5], entry[6], entry[7]]); + let e_plen = u32::from_le_bytes([entry[8], entry[9], entry[10], entry[11]]); + assert_eq!(e_fd, fd, "entry descriptor is the preopen handle"); + assert_eq!(e_pptr, str_ptr, "entry path_ptr references the allocated string buffer"); + assert_eq!(e_plen, 1, "path \".\" is 1 byte"); + + // The path string "." lives in the allocated buffer. + let mut pbuf = [0u8; 1]; + mem.read_bytes(str_ptr, &mut pbuf)?; + assert_eq!(&pbuf, b".", "path bytes written into the cabi_realloc'd buffer"); + Ok(()) + } + + /// SR-37 / #405: FAIL-LOUD — with preopens present but no cabi_realloc + /// allocation set, get-directories must error rather than scribble into + /// unowned guest memory (the pre-fix behavior that produced ENOENT). + // rivet: verifies SR-37 + #[cfg(all(feature = "wasi-filesystem", feature = "std"))] + #[test] + fn get_directories_fails_loud_without_allocation() -> Result<()> { + use kiln_foundation::traits::SliceMemory; + MemoryInitializer::ensure_initialized()?; + let mut dispatcher = WasiDispatcher::with_defaults()?; + dispatcher.add_preopen(".")?; + // NOTE: no set_preopens_allocation call. + let mem = SliceMemory::with_size(0x10000); + let res = dispatcher.dispatch_core( + "wasi:filesystem/preopens", + "get-directories", + &[kiln_foundation::values::Value::I32(0x1000)], + Some(&mem), + ); + assert!(res.is_err(), "must fail loud when cabi_realloc pre-allocation is missing"); + Ok(()) + } + #[test] fn test_dispatcher_creation() -> Result<()> { MemoryInitializer::ensure_initialized()?; diff --git a/kilnd/src/lib.rs b/kilnd/src/lib.rs index e08fa967..910a5262 100644 --- a/kilnd/src/lib.rs +++ b/kilnd/src/lib.rs @@ -654,6 +654,11 @@ impl KilndEngine { if let Err(e) = instance.pre_allocate_wasi_args() { eprintln!("[WASI-PREALLOC] Failed: {}", e); } + // Pre-allocate cabi_realloc'd memory for get-directories so a component + // can actually use --wasi-fs preopens (SR-37 / #405). + if let Err(e) = instance.pre_allocate_wasi_preopens() { + eprintln!("[WASI-PREALLOC] preopens failed: {}", e); + } } // Direct Component-Model hosting (#344, AD-COMPONENT-HOST-001): diff --git a/safety/requirements/functional-requirements.yaml b/safety/requirements/functional-requirements.yaml index 7717b991..ab4fb2b1 100644 --- a/safety/requirements/functional-requirements.yaml +++ b/safety/requirements/functional-requirements.yaml @@ -994,3 +994,37 @@ artifacts: model: claude-opus-4-8 timestamp: 2026-07-09T02:17:21Z release: v0.3.6 + + - id: SR-37 + type: requirement + title: Component wasi:filesystem/preopens::get-directories returns a guest-usable preopen list (cabi_realloc-backed) + status: verified + description: "A WASI Preview2 component run via kilnd --wasi --component --wasi-fs must be able to read/list/open files under the preopen, matching wasmtime --dir. Measured (v0.4.0, single wasm32-wasip2 component, nested_component_instances.len()=0 so the InterComponentHandler override does NOT fire; the queried dispatcher's self.preopens.len()=1 — the preopen IS present): the guest receives get-directories but rejects the preopen BEFORE ever calling open-at (trace: get-directories → straight to stderr write, no open-at/stat-at) → every op returns noent (os error 44). Root cause: dispatch_core get-directories (dispatcher.rs:2337-2358) writes the list> elements into retptr+8 and path strings into retptr+20 — but the canonical-ABI return area for `func() -> list<..>` is only 8 bytes (ptr,len). It scribbles into guest memory the guest never allocated and hands back a list backed by unowned memory, so preview2 libc registers no usable preopen. The working get-arguments path (dispatcher.rs:1209+) proves the correct pattern: back the list return with cabi_realloc'd guest memory (args_alloc, pre-allocated via pre_allocate_wasi_args). Fix: mirror it — pre_allocate_wasi_preopens allocates a N*12 list buffer (align 4) + N separate path-string buffers via cabi_realloc, stored as preopens_alloc; get-directories writes (fd, str_ptr, str_len) entries there. Differential oracle: wasmtime run --dir .::. reads the same dir (READ:CANARY, DIR:[inside.txt]). Still-open remainder of SR-33/#392. Issue #405." + tags: [kilnd, wasi, filesystem, component, canonical-abi, bug] + links: + - type: derives-from + target: REQ_FUNC_035 + fields: + upstream-ref: https://github.com/pulseengine/kiln/issues/405 + provenance: + created-by: ai + model: claude-opus-4-8 + timestamp: 2026-07-09T07:00:00Z + release: v0.4.1 + + - id: SR-38 + type: requirement + title: Component file-read data return (blocking-read / read-via-stream) uses cabi_realloc'd guest memory + status: proposed + description: "The remaining end-to-end blocker for #405, uncovered by measuring the SR-37 fix: with get-directories fixed, a preview2 component now registers the '.' preopen, calls open-at(inside.txt)→descriptor, stat, read-via-stream, then wasi:io/streams input-stream.blocking-read — which FAILS. Root cause (dispatcher.rs ~2298-2313): blocking-read writes the returned list to a FIXED raw guest address (0x100000 + stream_handle*0x10000) that the guest never allocated, corrupting guest memory; the guest then makes a bad indirect call and the runtime tries to resolve a non-existent export → [Runtime][E07DA] Function not found in exports. Same unowned-memory class as SR-37, but HARDER: the read size is dynamic (not pre-allocatable like args/preopens), so the fix needs on-demand cabi_realloc during dispatch — dispatch_core currently only has a MemoryAccessor, no engine hook to call cabi_realloc mid-call. Requires an allocation callback into the engine (or routing stream reads through a return-lowering path that reallocs). Measured trace: get-directories → open-at → metadata-hash → stat → read-via-stream → blocking-read → E07DA. Differential oracle: wasmtime run --dir .::. prints READ:CANARY. Blocks #405 end-to-end; SR-37 (get-directories) is the prerequisite and is done." + tags: [kilnd, wasi, filesystem, component, canonical-abi, io-streams, bug] + links: + - type: derives-from + target: REQ_FUNC_035 + fields: + upstream-ref: https://github.com/pulseengine/kiln/issues/405 + provenance: + created-by: ai + model: claude-opus-4-8 + timestamp: 2026-07-09T07:10:00Z + release: v0.4.1