diff --git a/kiln-runtime/src/table.rs b/kiln-runtime/src/table.rs index ab1be975..5cf80d9e 100644 --- a/kiln-runtime/src/table.rs +++ b/kiln-runtime/src/table.rs @@ -104,6 +104,12 @@ pub struct Table { pub debug_name: Option, /// Verification level for table operations pub verification_level: VerificationLevel, + /// Runtime element cap (0 = unlimited). Enforced in `grow`/`grow_shared` in + /// ADDITION to the module-declared `ty.limits.max`, so a host can bound a + /// table below its declared max — the table analog of `Memory:: + /// runtime_max_pages` (SR-44; closes the uncapped-table.grow gap AD-WCMC-001 + /// found). 0 = unlimited; set before execution. + pub runtime_max_elements: core::sync::atomic::AtomicU32, } impl Debug for Table { @@ -128,6 +134,9 @@ impl Clone for Table { elements: Mutex::new(source_elements.clone()), debug_name: self.debug_name.clone(), verification_level: self.verification_level, + runtime_max_elements: core::sync::atomic::AtomicU32::new( + self.runtime_max_elements.load(core::sync::atomic::Ordering::Relaxed), + ), } } } @@ -269,9 +278,18 @@ impl Table { elements: Mutex::new(elements), verification_level: VerificationLevel::default(), debug_name: None, + runtime_max_elements: core::sync::atomic::AtomicU32::new(0), }) } + /// Set a runtime element cap (0 = unlimited). Enforced in `grow`/`grow_shared` + /// in addition to the module-declared `ty.limits.max` (SR-44). Set before + /// execution. + pub fn set_runtime_max_elements(&self, n: u32) { + self.runtime_max_elements + .store(n, core::sync::atomic::Ordering::Relaxed); + } + /// Creates a new table with the specified capacity and element type /// /// # Arguments @@ -448,6 +466,18 @@ impl Table { } } + // Runtime host cap (SR-44), in addition to the module-declared max; 0 = unlimited. + let rt_cap = self + .runtime_max_elements + .load(core::sync::atomic::Ordering::Relaxed); + if rt_cap != 0 && new_size > rt_cap { + return Err(Error::new( + ErrorCategory::Runtime, + kiln_error::codes::CAPACITY_EXCEEDED, + "Table size exceeds runtime cap", + )); + } + // Lock elements and push new values let mut elements = self.elements.lock() .map_err(|_| Error::runtime_error("Failed to lock table elements"))?; @@ -595,6 +625,19 @@ impl Table { } } + // Runtime host cap (SR-44), in addition to the module-declared max; 0 = unlimited. + // Kept in sync with grow_shared so neither path can bypass the cap. + let rt_cap = self + .runtime_max_elements + .load(core::sync::atomic::Ordering::Relaxed); + if rt_cap != 0 && new_size > rt_cap { + return Err(Error::new( + ErrorCategory::Runtime, + kiln_error::codes::CAPACITY_EXCEEDED, + "Table size exceeds runtime cap", + )); + } + // Lock elements and push new values let mut elements = self.elements.lock() .map_err(|_| Error::runtime_error("Failed to lock table elements"))?; @@ -911,3 +954,63 @@ impl Clone for TableManager { // type conversions This will be re-enabled once the Value types are properly // unified across crates +#[cfg(test)] +mod sr44_cap_tests { + use super::*; + + fn table(min: u32, max: Option) -> Table { + Table::new(KilnTableType { + element_type: KilnRefType::Funcref, + limits: KilnLimits { min, max }, + table64: false, + }) + .unwrap() + } + + /// SR-44 / AD-WCMC-001: a runtime element cap bounds table.grow BELOW the + /// module-declared max — the table analog of Memory::runtime_max_pages. + /// 0 = unlimited. + // rivet: verifies SR-44 + #[test] + fn runtime_cap_bounds_table_grow_below_declared_max() { + let t = table(0, Some(10)); // declared max 10 + t.set_runtime_max_elements(3); // host caps at 3 + let nullref = KilnValue::FuncRef(None); + assert!(t.grow_shared(3, nullref.clone()).is_ok(), "grow to the cap succeeds"); + assert_eq!(t.size(), 3); + assert!( + t.grow_shared(1, nullref).is_err(), + "grow past the runtime cap is rejected even though declared max (10) allows it" + ); + assert_eq!(t.size(), 3, "a rejected grow does not change the size"); + } + + /// SR-44: cap 0 = unlimited (only the declared max applies). + // rivet: verifies SR-44 + #[test] + fn runtime_cap_zero_is_unlimited_for_tables() { + let t = table(0, Some(5)); + let nullref = KilnValue::FuncRef(None); + assert!(t.grow_shared(5, nullref.clone()).is_ok(), "grow to declared max with no cap"); + assert_eq!(t.size(), 5); + assert!(t.grow_shared(1, nullref).is_err(), "declared max still enforced"); + } + + /// SR-44: the &mut self `grow` path must ALSO honour the runtime cap (a + /// clean-room review found it initially did not — neither path may bypass it). + // rivet: verifies SR-44 + #[test] + fn runtime_cap_bounds_mut_grow_path_too() { + let mut t = table(0, Some(10)); + t.set_runtime_max_elements(3); + let nullref = KilnValue::FuncRef(None); + assert!(t.grow(3, nullref.clone()).is_ok(), "&mut grow to the cap succeeds"); + assert_eq!(t.size(), 3); + assert!( + t.grow(1, nullref).is_err(), + "&mut grow past the runtime cap must be rejected (not just grow_shared)" + ); + assert_eq!(t.size(), 3); + } +} + diff --git a/kilnd/src/lib.rs b/kilnd/src/lib.rs index 81e9eafa..c471197c 100644 --- a/kilnd/src/lib.rs +++ b/kilnd/src/lib.rs @@ -872,6 +872,20 @@ impl KilndEngine { if cap_pages > 0 { if let Ok(inst) = engine.get_instance(instance) { if let Ok(mem) = inst.memory(0) { + // SR-43: reject-at-load when the module's DECLARED memory min + // exceeds the cap — refuse to run rather than trap mid-execution + // (moves a fixed-RAM overrun from mid-mission to load time). + // NOTE: this runs post-instantiate, so the declared `min` was + // already allocated by Memory::new; rejecting before eager `min` + // allocation needs a pre-instantiate engine API (follow-up). + let declared_min = mem.0.ty.limits.min; + if declared_min > cap_pages { + return Err(Error::new( + ErrorCategory::Resource, + codes::CAPACITY_EXCEEDED, + "Module declared memory min exceeds --memory cap (rejected at load)", + )); + } mem.0.set_runtime_max_pages(cap_pages); } } diff --git a/safety/requirements/functional-requirements.yaml b/safety/requirements/functional-requirements.yaml index 6a02beae..fa90646a 100644 --- a/safety/requirements/functional-requirements.yaml +++ b/safety/requirements/functional-requirements.yaml @@ -1085,3 +1085,45 @@ artifacts: model: claude-opus-4-8 timestamp: 2026-07-11T00:00:00Z release: v0.4.2 + + - id: SR-43 + type: requirement + title: kilnd rejects at load a module whose declared memory min exceeds the --memory cap + status: verified + description: "Closes the SR-41 ordering gap found by the WCMC analysis (AD-WCMC-001): the SR-41 runtime cap is set AFTER instantiation and only enforced in memory.grow, but Memory::new eagerly allocates the declared min pages BEFORE the cap exists. Fix: kilnd rejects-at-load when a module's declared memory min (pages*64KiB) exceeds config.max_memory (the --memory cap). Verified: (memory 2000)=128MB rejected at 64MB default, runs under --memory 256MB. KNOWN LIMITATION (in-code + clean-room confirmed): the check is post-instantiate, so the declared min was already allocated by Memory::new before rejection (Peak memory 131072000 observed in the allow case); a pre-allocation reject needs a pre-instantiate engine API (follow-up). Relates to SR-41. AD-WCMC-001. Issue #411." + tags: [kilnd, memory, resource-limit, reject-at-load, bug] + fields: + upstream-ref: https://github.com/pulseengine/kiln/issues/411 + provenance: + created-by: ai + model: claude-opus-4-8 + timestamp: 2026-07-11T10:00:00Z + release: v0.4.3 + + - id: SR-44 + type: requirement + title: Table growth honours a runtime element cap (mirror SR-41 for tables) + status: verified + description: "The WCMC analysis (AD-WCMC-001) found table.grow was UNCAPPED — Table::grow/grow_shared checked only the module-declared ty.limits.max; a table without a declared max could grow to multi-GiB, the class of bug SR-41 fixed for linear memory. Fix: added runtime_max_elements to Table (AtomicU32, 0=unlimited) enforced in BOTH grow paths (a clean-room review caught the first pass only covered grow_shared; the &mut self grow was fixed + regression-tested). 3 unit oracles. Relates to SR-41. AD-WCMC-001. Issue #411." + tags: [kiln-runtime, table, resource-limit, bug] + fields: + upstream-ref: https://github.com/pulseengine/kiln/issues/411 + provenance: + created-by: ai + model: claude-opus-4-8 + timestamp: 2026-07-11T10:00:00Z + release: v0.4.3 + + - id: SR-45 + type: requirement + title: The kiln.resource_limits manifest section is applied (wire the dead extraction) + status: proposed + description: "The WCMC analysis (AD-WCMC-001) found the kiln.resource_limits section is decoded but NEVER APPLIED (capability_engine.rs:561 TODO). DEEPER FINDING this pass: the extraction fn (kiln-foundation/src/execution.rs:144) is ALSO a stub — returns a default and does NOT read the section (the parser from_bytes_with_provider in kiln-decoder/src/resource_limits_section.rs is never called). So SR-45 is a bigger cross-crate wire: (1) make extract scan+parse the section; (2) thread limits to instantiate; (3) apply max_memory_usage -> set_runtime_max_pages (SR-41), max_call_depth -> engine cap. This is the on-target enforcement point the embedded trust chain (AD-WCMC-001) depends on. Own feature loop. AD-WCMC-001. Issue #415." + tags: [kiln-runtime, resource-limits, manifest, embedded, dead-code, bug] + fields: + upstream-ref: https://github.com/pulseengine/kiln/issues/415 + provenance: + created-by: ai + model: claude-opus-4-8 + timestamp: 2026-07-11T10:00:00Z + release: v0.4.3