From 57a3969086609e78c75938dd4ec8e24b9221df5a Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 22 Jul 2026 15:59:51 +0200 Subject: [PATCH 1/2] feat(runtime): apply kiln.resource_limits manifest on-target at load (SR-45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the AD-WCMC-001 on-target enforcement: a module's signed `kiln.resource_limits` custom section is now APPLIED at load time, not extracted-then-discarded. An embedded/gale deployment with NO `--memory` CLI flag is still bounded by the module's own manifest. - kiln-decoder: real `extract_resource_limits_from_binary` — scans the binary's custom sections and decodes `kiln.resource_limits`. Strictly distinguishes absent (Ok(None)) from present-but-malformed (Err): a broken manifest must fail loud, never be treated as absent. - kiln-runtime load_module: the manifest's max_memory_usage is converted via EngineResourceLimits::from_max_memory_bytes and fed into the SAME SR-46/47/48 pre-instantiate gate (check_declared_minimums) and runtime grow caps (set_runtime_max_pages / set_runtime_max_elements) that the CLI path uses. The banned `.unwrap_or(None)` masking fallback and the "TODO: Apply resource limits" are gone; a malformed section is now a load error. - Precedence (SR-45): when both a CLI `--memory` bound and a manifest bound are present, the MOST-RESTRICTIVE (minimum) wins — an operator cannot loosen a module's signed self-declared bound, and a module cannot loosen the operator's cap (EngineResourceLimits::most_restrictive). - EngineBuilder::from_binary: no longer swallows extraction errors (the old loop always yielded ASIL-D from a stub). It now selects the ASIL mode from the manifest's qualified level, fails loud on a malformed manifest or unknown level, and defaults to QM only when the manifest (or its level) is genuinely absent. Dead `with_resource_config` storage (never read by build()) removed. - kiln-foundation: deleted the vacuous extract_resource_limits_from_binary stub that ignored the binary and returned a defaulted config. Manifest fields not expressible by EngineResourceLimits (max_call_depth, fuel) are not yet enforced by this gate; the memory bound — the WCMC attack surface — is. Tests (RED->GREEN): manifest_memory_bound_enforced_without_cli_limits, manifest_bound_admits_fitting_module_and_caps_growth, manifest_tighter_than_cli_wins, cli_tighter_than_manifest_wins (pins the converse), malformed_manifest_section_fails_loud, builder_from_binary_fails_loud_on_malformed_manifest, builder_from_binary_selects_manifest_asil_level; decoder unit tests test_extract_{absent_section_is_none,present_section_roundtrips, malformed_section_is_error,non_wasm_binary_is_error}. Closes #415 Closes #421 Implements: SR-45 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FcTUZgts331Z1TK3q8YBQj --- kiln-decoder/src/resource_limits_section.rs | 142 +++++++++++ kiln-foundation/src/execution.rs | 24 -- kiln-runtime/src/engine/builder.rs | 91 ++++---- kiln-runtime/src/engine/capability_engine.rs | 76 +++--- .../tests/resource_limits_gate_tests.rs | 221 ++++++++++++++++++ 5 files changed, 458 insertions(+), 96 deletions(-) diff --git a/kiln-decoder/src/resource_limits_section.rs b/kiln-decoder/src/resource_limits_section.rs index 1ba5de1c..23d84064 100644 --- a/kiln-decoder/src/resource_limits_section.rs +++ b/kiln-decoder/src/resource_limits_section.rs @@ -27,6 +27,79 @@ use kiln_foundation::{ /// Standard custom section name for resource limits pub const RESOURCE_LIMITS_SECTION_NAME: &str = "kiln.resource_limits"; +/// Extract the `kiln.resource_limits` custom section from a raw WebAssembly +/// binary (SR-45). +/// +/// This is the on-target read side of the AD-WCMC-001 trust chain: the +/// section is authored and signed off-target (sigil), and the runtime applies +/// it at load time. The three outcomes are strictly distinguished: +/// +/// - `Ok(None)` — the binary carries NO `kiln.resource_limits` section. This +/// is the only "no manifest" case; host CLI limits (if any) still apply. +/// - `Ok(Some(section))` — the section is present, decodes, and validates. +/// - `Err(_)` — the binary's section framing is malformed, or the section is +/// PRESENT but undecodable/invalid. A broken manifest must fail loud and +/// must never be treated as absent (that would silently unbound a module +/// that declared itself bounded). +pub fn extract_resource_limits_from_binary( + binary: &[u8], +) -> Result, Error> { + use kiln_format::binary::read_leb128_u32; + + // WebAssembly header: 4-byte magic + 4-byte version. + if binary.len() < 8 || &binary[0..4] != b"\0asm" { + return Err(Error::parse_error( + "Not a WebAssembly binary (bad magic) while scanning for resource limits", + )); + } + + let mut offset = 8; + while offset < binary.len() { + // Section framing: id byte + LEB128 payload size + payload. + let section_id = binary[offset]; + offset += 1; + let (payload_len, len_size) = read_leb128_u32(binary, offset)?; + offset += len_size; + let payload_end = offset + .checked_add(payload_len as usize) + .ok_or_else(|| Error::parse_error("Section size overflows while scanning"))?; + if payload_end > binary.len() { + return Err(Error::parse_error( + "Section extends past end of binary while scanning for resource limits", + )); + } + let payload = &binary[offset..payload_end]; + offset = payload_end; + + if section_id != 0 { + continue; // non-custom section + } + + // Custom section payload: name (LEB128 length + bytes) + contents. + let (name_len, name_len_size) = read_leb128_u32(payload, 0)?; + let name_end = name_len_size + .checked_add(name_len as usize) + .ok_or_else(|| Error::parse_error("Custom section name overflows"))?; + if name_end > payload.len() { + return Err(Error::parse_error( + "Custom section name extends past section payload", + )); + } + let name = core::str::from_utf8(&payload[name_len_size..name_end]) + .map_err(|_| Error::parse_error("Custom section name is not valid UTF-8"))?; + if name != RESOURCE_LIMITS_SECTION_NAME { + continue; + } + + // Section is PRESENT: decode + validate errors propagate (fail loud). + let section = ResourceLimitsSection::decode(&payload[name_end..])?; + section.validate()?; + return Ok(Some(section)); + } + + Ok(None) +} + /// Version of the resource limits format (for future compatibility) pub const RESOURCE_LIMITS_VERSION: u32 = 1; @@ -1282,6 +1355,75 @@ mod tests { Ok(()) } + /// Minimal valid WebAssembly binary: magic + version, no sections. + const EMPTY_MODULE: [u8; 8] = [0, b'a', b's', b'm', 1, 0, 0, 0]; + + fn module_with_section(name: &str, payload: &[u8]) -> alloc::vec::Vec { + let mut wasm = EMPTY_MODULE.to_vec(); + let mut content = alloc::vec![name.len() as u8]; // single-byte LEB128 + content.extend_from_slice(name.as_bytes()); + content.extend_from_slice(payload); + wasm.push(0); // custom section id + wasm.push(content.len() as u8); // single-byte LEB128 + wasm.extend(content); + wasm + } + + #[test] + fn test_extract_absent_section_is_none() -> kiln_error::Result<()> { + // No custom section at all. + assert_eq!(extract_resource_limits_from_binary(&EMPTY_MODULE)?, None); + // An unrelated custom section does not count as a manifest. + let wasm = module_with_section("some.other.section", &[1, 2, 3]); + assert_eq!(extract_resource_limits_from_binary(&wasm)?, None); + Ok(()) + } + + #[test] + fn test_extract_present_section_roundtrips() -> kiln_error::Result<()> { + let provider = kiln_foundation::safe_managed_alloc!( + 4096, + kiln_foundation::budget_aware_provider::CrateId::Decoder + )?; + let section = ResourceLimitsSection::with_execution_limits( + provider, + Some(1000), + Some(64 * 1024), + Some(32), + None, + None, + )?; + let payload = section.encode()?; + let wasm = module_with_section(RESOURCE_LIMITS_SECTION_NAME, &payload); + + let extracted = + extract_resource_limits_from_binary(&wasm)?.expect("present section must be extracted"); + assert_eq!(extracted.max_fuel_per_step, Some(1000)); + assert_eq!(extracted.max_memory_usage, Some(64 * 1024)); + assert_eq!(extracted.max_call_depth, Some(32)); + Ok(()) + } + + #[test] + fn test_extract_malformed_section_is_error() { + // Present but truncated mid-field: version + "present" flag with no value. + let wasm = module_with_section(RESOURCE_LIMITS_SECTION_NAME, &[1, 0, 0, 0, 1]); + assert!( + extract_resource_limits_from_binary(&wasm).is_err(), + "a present-but-malformed section must be an error, not treated as absent" + ); + + // Present but empty payload: not decodable either. + let wasm = module_with_section(RESOURCE_LIMITS_SECTION_NAME, &[]); + assert!(extract_resource_limits_from_binary(&wasm).is_err()); + } + + #[test] + fn test_extract_non_wasm_binary_is_error() { + assert!(extract_resource_limits_from_binary(b"not wasm at all").is_err()); + assert!(extract_resource_limits_from_binary(&[]).is_err()); + } + #[test] fn test_lower_asil_levels() -> kiln_error::Result<()> { let provider = kiln_foundation::safe_managed_alloc!( diff --git a/kiln-foundation/src/execution.rs b/kiln-foundation/src/execution.rs index 4f225272..159792e4 100644 --- a/kiln-foundation/src/execution.rs +++ b/kiln-foundation/src/execution.rs @@ -125,27 +125,3 @@ impl ExecutionStats { self.execution_time_us = time_us; } } - -/// Extract resource limits configuration from WebAssembly binary -/// -/// This function attempts to extract ASIL-compliant resource limits -/// from a WebAssembly binary's custom sections. -/// -/// # Arguments -/// -/// * `binary` - The WebAssembly binary data -/// * `asil_mode` - The target ASIL execution mode -/// -/// # Returns -/// -/// Returns `Ok(Some(config))` if resource limits are found and valid, -/// `Ok(None)` if no resource limits are found, or `Err` if the binary is -/// invalid. -pub fn extract_resource_limits_from_binary( - _binary: &[u8], - asil_mode: ASILExecutionMode, -) -> kiln_error::Result> { - // TODO: Implement actual resource limits extraction from custom sections - // For now, return a default configuration based on ASIL mode - Ok(Some(ASILExecutionConfig::new(asil_mode))) -} diff --git a/kiln-runtime/src/engine/builder.rs b/kiln-runtime/src/engine/builder.rs index b054eb95..f0c99941 100644 --- a/kiln-runtime/src/engine/builder.rs +++ b/kiln-runtime/src/engine/builder.rs @@ -4,14 +4,11 @@ //! This module provides a fluent builder interface for creating WebAssembly //! engines with proper ASIL-level configuration and resource limits. -use kiln_error::Result; +use kiln_decoder::resource_limits_section::extract_resource_limits_from_binary; +use kiln_error::{Error, Result}; use kiln_foundation::{ capabilities::MemoryCapabilityContext, - execution::{ - extract_resource_limits_from_binary, - ASILExecutionConfig, - ASILExecutionMode, - }, + execution::ASILExecutionMode, }; use crate::engine::{ @@ -23,23 +20,20 @@ use crate::engine::{ #[derive(Debug)] pub struct EngineBuilder { /// Target ASIL level for the engine - asil_level: Option, + asil_level: Option, /// Engine preset (overrides ASIL level if set) - preset: Option, + preset: Option, /// Custom capability context (overrides both ASIL level and preset) - custom_context: Option, - /// Resource limits configuration from binary - resource_config: Option, + custom_context: Option, } impl EngineBuilder { /// Create a new engine builder pub fn new() -> Self { Self { - asil_level: None, - preset: None, - custom_context: None, - resource_config: None, + asil_level: None, + preset: None, + custom_context: None, } } @@ -61,12 +55,6 @@ impl EngineBuilder { self } - /// Set resource limits configuration from a WebAssembly binary - pub fn with_resource_config(mut self, config: ASILExecutionConfig) -> Self { - self.resource_config = Some(config); - self - } - /// Create an engine for QM (Quality Management) level pub fn qm() -> Self { Self::new().with_preset(EnginePreset::QM) @@ -92,26 +80,24 @@ impl EngineBuilder { Self::new().with_preset(EnginePreset::AsilD) } - /// Create an engine from a WebAssembly binary with embedded resource limits + /// Create an engine builder from a WebAssembly binary, selecting the ASIL + /// level from the binary's `kiln.resource_limits` manifest (SR-45). + /// + /// - Manifest present with a qualified ASIL level: that level is selected. + /// - Manifest present without a qualified level: QM (the manifest's + /// numeric limits are enforced separately by + /// `CapabilityAwareEngine::load_module`, regardless of level). + /// - Manifest absent: QM. + /// - Manifest present but malformed, or an unknown qualified level: + /// `Err` — never silently downgraded (fail loud). pub fn from_binary(binary: &[u8]) -> Result { - // Function is now imported at the top - - // Try to extract resource limits from the binary - // Start with ASIL-D for maximum compatibility, then work down - for asil_mode in &[ - ASILExecutionMode::AsilD, - ASILExecutionMode::AsilC, - ASILExecutionMode::AsilB, - ASILExecutionMode::AsilA, - ASILExecutionMode::QM, - ] { - if let Ok(Some(config)) = extract_resource_limits_from_binary(binary, *asil_mode) { - return Ok(Self::new().with_asil_level(config.mode).with_resource_config(config)); - } - } - - // No resource limits found, default to QM - Ok(Self::qm()) + let Some(section) = extract_resource_limits_from_binary(binary)? else { + return Ok(Self::qm()); + }; + let Some(level) = section.qualified_asil_level() else { + return Ok(Self::qm()); + }; + Ok(Self::new().with_asil_level(parse_qualified_asil_level(level)?)) } /// Build the engine with the configured settings @@ -143,9 +129,32 @@ impl EngineBuilder { } } +/// Map a manifest's qualified ASIL level string to an execution mode. +/// +/// Whitespace is trimmed and matching is ASCII-case-insensitive (section +/// authors have historically emitted levels like `"ASIL-D "`). An unknown +/// level is an error — a signed manifest claiming a qualification the runtime +/// does not recognize must not be silently reinterpreted (SR-45, fail loud). +fn parse_qualified_asil_level(level: &str) -> Result { + let normalized = level.trim(); + for (name, mode) in [ + ("QM", ASILExecutionMode::QM), + ("ASIL-A", ASILExecutionMode::AsilA), + ("ASIL-B", ASILExecutionMode::AsilB), + ("ASIL-C", ASILExecutionMode::AsilC), + ("ASIL-D", ASILExecutionMode::AsilD), + ] { + if normalized.eq_ignore_ascii_case(name) { + return Ok(mode); + } + } + Err(Error::parse_error( + "Unknown qualified ASIL level in kiln.resource_limits manifest", + )) +} + impl Default for EngineBuilder { fn default() -> Self { Self::new() } } - diff --git a/kiln-runtime/src/engine/capability_engine.rs b/kiln-runtime/src/engine/capability_engine.rs index 4c0e298b..b4bdb141 100644 --- a/kiln-runtime/src/engine/capability_engine.rs +++ b/kiln-runtime/src/engine/capability_engine.rs @@ -16,12 +16,8 @@ use core::sync::atomic::{ // Import decoder function use kiln_decoder::decoder::decode_module; -// Import execution configuration from kiln-foundation where it belongs -use kiln_foundation::execution::{ - extract_resource_limits_from_binary, - ASILExecutionConfig, - ASILExecutionMode, -}; +// Import the resource-limits manifest extractor (SR-45) +use kiln_decoder::resource_limits_section::extract_resource_limits_from_binary; use kiln_foundation::{ bounded_collections::BoundedMap, budget_aware_provider::CrateId, @@ -210,6 +206,22 @@ impl EngineResourceLimits { self.max_memory_bytes } + /// Combine two limits into the MOST-RESTRICTIVE (minimum) bound (SR-45). + /// + /// Precedence rule for CLI (`--memory`) vs. module manifest + /// (`kiln.resource_limits`): neither side may LOOSEN the other. An + /// operator's `--memory` must not override a module's signed + /// self-declared bound upward (that would break the AD-WCMC-001 trust + /// chain: the qualified bound travels with the binary), and a module's + /// manifest must not grant itself more than the operator allows. Taking + /// the minimum satisfies both directions. + #[must_use] + pub fn most_restrictive(self, other: Self) -> Self { + Self { + max_memory_bytes: self.max_memory_bytes.min(other.max_memory_bytes), + } + } + /// Maximum page count for a memory with the given page size in bytes. #[must_use] pub fn max_pages_for_page_size(&self, page_size_bytes: u64) -> u32 { @@ -386,7 +398,9 @@ pub struct CapabilityAwareEngine { handle_to_idx: std::collections::HashMap, /// Host-imposed resource limits (kilnd `--memory`): pre-allocation gate on /// declared memory/table minimums + runtime grow caps (SR-46/47/48). - /// `None` = no host limits configured. + /// `None` = no host limits configured. Combined per-module with the + /// module's own `kiln.resource_limits` manifest at load time; the + /// most-restrictive bound wins (SR-45). resource_limits: Option, } @@ -474,17 +488,6 @@ impl CapabilityAwareEngine { self.inner.set_host_handler(handler); } - /// Convert engine preset to ASIL execution mode - fn preset_to_asil_mode(&self) -> ASILExecutionMode { - match self.preset { - EnginePreset::QM => ASILExecutionMode::QM, - EnginePreset::AsilA => ASILExecutionMode::AsilA, - EnginePreset::AsilB => ASILExecutionMode::AsilB, - EnginePreset::AsilC => ASILExecutionMode::AsilC, - EnginePreset::AsilD => ASILExecutionMode::AsilD, - } - } - /// Create host integration components based on engine preset fn create_host_integration( preset: &EnginePreset, @@ -690,14 +693,6 @@ impl CapabilityEngine for CapabilityAwareEngine { let operation = MemoryOperation::Allocate { size: binary.len() }; self.context.verify_operation(CrateId::Runtime, &operation)?; - // Extract resource limits from binary if available - let asil_mode = self.preset_to_asil_mode(); - let _resource_config = - extract_resource_limits_from_binary(binary, asil_mode).unwrap_or(None); // Ignore errors, use defaults if extraction fails - - // TODO: Apply resource limits to execution context - // This would integrate with the fuel async executor to enforce limits - // Decode the module using kiln-decoder (Box to avoid stack overflow) #[cfg(feature = "tracing")] trace!(binary_size = binary.len(), "Decoding module"); @@ -705,23 +700,42 @@ impl CapabilityEngine for CapabilityAwareEngine { #[cfg(feature = "tracing")] trace!(types = decoded.types.len(), functions = decoded.functions.len(), "Decode successful, converting to runtime module"); - // SR-46/47/48 pre-allocation gate: validate the DECLARED minimums of - // ALL memories and ALL tables against the host budget BEFORE + // SR-45: extract the module's own `kiln.resource_limits` manifest + // (authored and signed off-target — the on-target end of the + // AD-WCMC-001 trust chain). Absent section => None (host CLI limits, + // if any, still apply). Present-but-malformed section => load error + // (fail loud) — a module that declared itself bounded must never + // silently run unbounded. + let manifest_limits = extract_resource_limits_from_binary(binary)? + .and_then(|section| section.max_memory_usage) + .map(EngineResourceLimits::from_max_memory_bytes); + + // SR-45 precedence: when BOTH a host CLI bound (`--memory`) and a + // manifest bound exist, the MOST-RESTRICTIVE (minimum) wins — the + // operator cannot loosen the module's signed bound and the module + // cannot loosen the operator's cap (see `most_restrictive`). + let effective_limits = match (self.resource_limits, manifest_limits) { + (Some(host), Some(manifest)) => Some(host.most_restrictive(manifest)), + (host, manifest) => host.or(manifest), + }; + + // SR-45/46/47/48 pre-allocation gate: validate the DECLARED minimums + // of ALL memories and ALL tables against the effective budget BEFORE // from_kiln_module eagerly allocates them (Memory::new zero-fills the // declared min pages; Table::new allocates the declared min slots). - if let Some(limits) = self.resource_limits { + if let Some(limits) = effective_limits { limits.check_declared_minimums(&decoded)?; } // Convert to runtime module (pass by reference, returns Box) let runtime_module = Module::from_kiln_module(&*decoded)?; - // SR-41/44/47/48: apply the runtime grow caps to EVERY memory and + // SR-41/44/45/47/48: apply the runtime grow caps to EVERY memory and // EVERY table the module defines (instances share these objects via // Arc, so instantiated memories/tables carry the caps). Imported // memories/tables were capped when their providing module was loaded // by this engine. - if let Some(limits) = self.resource_limits { + if let Some(limits) = effective_limits { for memory in &runtime_module.memories { let page_size = memory.0.page_size_bytes() as u64; memory diff --git a/kiln-runtime/tests/resource_limits_gate_tests.rs b/kiln-runtime/tests/resource_limits_gate_tests.rs index 19babad6..83e184c9 100644 --- a/kiln-runtime/tests/resource_limits_gate_tests.rs +++ b/kiln-runtime/tests/resource_limits_gate_tests.rs @@ -169,3 +169,224 @@ fn no_limits_configured_means_no_caps() { let table = inst.table(0).expect("table must exist"); assert_eq!(table.0.runtime_max_elements.load(Ordering::Relaxed), 0); } + +// --------------------------------------------------------------------------- +// SR-45 (issues #415 / #421): the module's own signed `kiln.resource_limits` +// manifest must be APPLIED on-target at load — not extracted-then-discarded. +// An embedded/gale deployment with NO `--memory` CLI flag must still be +// bounded by the manifest, and a present-but-malformed manifest must fail +// the load (never silently proceed unbounded). +// --------------------------------------------------------------------------- + +use kiln_decoder::resource_limits_section::{RESOURCE_LIMITS_SECTION_NAME, ResourceLimitsSection}; +use kiln_foundation::budget_aware_provider::CrateId; +use kiln_runtime::engine::EngineBuilder; + +/// Minimal unsigned LEB128 encoder for section sizes in test fixtures. +fn leb128(mut value: u32) -> Vec { + let mut out = Vec::new(); + loop { + let byte = (value & 0x7F) as u8; + value >>= 7; + if value == 0 { + out.push(byte); + return out; + } + out.push(byte | 0x80); + } +} + +/// Append a custom section (id 0) with the given name and payload to a +/// WebAssembly binary — how a `kiln.resource_limits` manifest is embedded. +fn append_custom_section(mut wasm: Vec, name: &str, payload: &[u8]) -> Vec { + let mut content = leb128(name.len() as u32); + content.extend_from_slice(name.as_bytes()); + content.extend_from_slice(payload); + wasm.push(0); // custom section id + wasm.extend(leb128(content.len() as u32)); + wasm.extend(content); + wasm +} + +/// Encode a well-formed `kiln.resource_limits` payload declaring only a +/// memory bound (what sigil signs off-target for AD-WCMC-001). +fn manifest_payload(max_memory_usage: u64) -> Vec { + let provider = kiln_foundation::safe_managed_alloc!(4096, CrateId::Decoder) + .expect("test provider allocation"); + let section = ResourceLimitsSection::with_execution_limits( + provider, + None, + Some(max_memory_usage), + None, + None, + None, + ) + .expect("manifest section construction"); + section.encode().expect("manifest section encoding") +} + +/// SR-45 core: a module carrying a manifest memory bound, loaded WITHOUT any +/// CLI `--memory` limits, must have that bound enforced by the same +/// pre-allocation gate the CLI path uses. Declared min (2 pages) exceeds the +/// manifest bound (1 page) → reject at load, before allocation. +#[test] +#[serial_test::serial] +fn manifest_memory_bound_enforced_without_cli_limits() { + let mut engine = + CapabilityAwareEngine::with_preset(EnginePreset::QM).expect("engine construction"); + let binary = append_custom_section( + wasm("(module (memory 2))"), + RESOURCE_LIMITS_SECTION_NAME, + &manifest_payload(WASM_PAGE), // manifest allows 1 page + ); + let err = engine + .load_module(&binary) + .expect_err("declared min of 2 pages must not load under a 1-page manifest bound"); + assert!( + err.message.contains("rejected before allocation"), + "reject must come from the pre-allocation gate, got: {}", + err.message + ); +} + +/// SR-45 no-over-reject: a module whose declared min fits under its manifest +/// bound loads, instantiates, and carries the manifest-derived runtime grow +/// cap (2 pages) on its memory. +#[test] +#[serial_test::serial] +fn manifest_bound_admits_fitting_module_and_caps_growth() { + let mut engine = + CapabilityAwareEngine::with_preset(EnginePreset::QM).expect("engine construction"); + let binary = append_custom_section( + wasm("(module (memory 1))"), + RESOURCE_LIMITS_SECTION_NAME, + &manifest_payload(2 * WASM_PAGE), // manifest allows 2 pages + ); + let handle = engine + .load_module(&binary) + .expect("declared min of 1 page must load under a 2-page manifest bound"); + let instance = engine.instantiate(handle).expect("must instantiate"); + let inst = engine.get_instance(instance).expect("instance must exist"); + let mem = inst.memory(0).expect("memory must exist"); + assert_eq!( + mem.0.runtime_max_pages.load(Ordering::Relaxed), + 2, + "memory must carry the manifest-derived runtime page cap" + ); +} + +/// SR-45 precedence: manifest bound tighter than the CLI bound → the manifest +/// wins (an operator's `--memory` must not LOOSEN a module's signed +/// self-declared bound). CLI allows 10 pages, manifest allows 1, module +/// declares 2 → reject. +#[test] +#[serial_test::serial] +fn manifest_tighter_than_cli_wins() { + let mut engine = engine_with_cap(10 * WASM_PAGE); + let binary = append_custom_section( + wasm("(module (memory 2))"), + RESOURCE_LIMITS_SECTION_NAME, + &manifest_payload(WASM_PAGE), // manifest allows 1 page + ); + let err = engine + .load_module(&binary) + .expect_err("the tighter manifest bound must win over a looser CLI bound"); + assert!( + err.message.contains("rejected before allocation"), + "reject must come from the pre-allocation gate, got: {}", + err.message + ); +} + +/// SR-45 precedence (converse): CLI bound tighter than the manifest bound → +/// the CLI wins (a module's manifest must not loosen an operator cap). CLI +/// allows 1 page, manifest allows 10, module declares 2 → reject. +#[test] +#[serial_test::serial] +fn cli_tighter_than_manifest_wins() { + let mut engine = engine_with_cap(WASM_PAGE); + let binary = append_custom_section( + wasm("(module (memory 2))"), + RESOURCE_LIMITS_SECTION_NAME, + &manifest_payload(10 * WASM_PAGE), // manifest allows 10 pages + ); + let err = engine + .load_module(&binary) + .expect_err("the tighter CLI bound must win over a looser manifest bound"); + assert!( + err.message.contains("rejected before allocation"), + "reject must come from the pre-allocation gate, got: {}", + err.message + ); +} + +/// SR-45 fail-loud: a PRESENT but MALFORMED `kiln.resource_limits` section is +/// a load error — never a silent unbounded pass-through. The payload below is +/// truncated mid-field (version + "present" flag for max_fuel, no value). +#[test] +#[serial_test::serial] +fn malformed_manifest_section_fails_loud() { + let mut engine = + CapabilityAwareEngine::with_preset(EnginePreset::QM).expect("engine construction"); + let truncated_payload = [1u8, 0, 0, 0, 1]; // version=1, max_fuel "present" but value missing + let binary = append_custom_section( + wasm("(module (memory 1))"), + RESOURCE_LIMITS_SECTION_NAME, + &truncated_payload, + ); + engine + .load_module(&binary) + .expect_err("a present-but-malformed manifest must fail the load, not proceed unbounded"); +} + +/// SR-45 builder consistency: `EngineBuilder::from_binary` must not swallow a +/// malformed manifest either — the second silent-fallback path from #421. +#[test] +#[serial_test::serial] +fn builder_from_binary_fails_loud_on_malformed_manifest() { + let truncated_payload = [1u8, 0, 0, 0, 1]; + let binary = append_custom_section( + wasm("(module (memory 1))"), + RESOURCE_LIMITS_SECTION_NAME, + &truncated_payload, + ); + assert!( + EngineBuilder::from_binary(&binary).is_err(), + "EngineBuilder::from_binary must fail loud on a malformed manifest" + ); +} + +/// SR-45 builder: the qualified ASIL level in a well-formed manifest selects +/// the builder's ASIL mode (today's stub unconditionally reports ASIL-D for +/// every binary). +#[test] +#[serial_test::serial] +fn builder_from_binary_selects_manifest_asil_level() { + let provider = kiln_foundation::safe_managed_alloc!(4096, CrateId::Decoder) + .expect("test provider allocation"); + let section = ResourceLimitsSection::with_execution_limits( + provider, + None, + Some(WASM_PAGE), + None, + None, + None, + ) + .expect("manifest section construction") + .with_qualification([0u8; 32], "ASIL-B") + .expect("manifest qualification"); + let payload = section.encode().expect("manifest section encoding"); + let binary = append_custom_section( + wasm("(module (memory 1))"), + RESOURCE_LIMITS_SECTION_NAME, + &payload, + ); + let builder = + EngineBuilder::from_binary(&binary).expect("well-formed manifest must be accepted"); + let debug = format!("{:?}", builder); + assert!( + debug.contains("AsilB"), + "builder must select the manifest's qualified ASIL level, got: {}", + debug + ); +} From 3f83ee4078b0146a44e69613b18f976f61c12491 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 22 Jul 2026 16:01:37 +0200 Subject: [PATCH 2/2] chore(rivet): SR-45 implemented in v0.4.4 (PR #464) Bump SR-45 to implemented, retarget release v0.5.0 -> v0.4.4 (the enforcement-capstone release), and record the IMPLEMENTED (PR #464) note with the verifying test names, matching the SR-46..51 convention. Implements: SR-45 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FcTUZgts331Z1TK3q8YBQj --- safety/requirements/functional-requirements/SR-45.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/safety/requirements/functional-requirements/SR-45.yaml b/safety/requirements/functional-requirements/SR-45.yaml index 2561d144..0369d78c 100644 --- a/safety/requirements/functional-requirements/SR-45.yaml +++ b/safety/requirements/functional-requirements/SR-45.yaml @@ -2,8 +2,8 @@ artifacts: - 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.' + status: implemented + description: 'IMPLEMENTED (PR #464): real extract_resource_limits_from_binary in kiln-decoder scans the binary custom sections and decodes kiln.resource_limits — strictly distinguishing absent (Ok(None)) from present-but-malformed (Err, fail loud, never treated as absent). load_module converts the manifest max_memory_usage via EngineResourceLimits::from_max_memory_bytes and feeds it into the SAME SR-46/47/48 pre-instantiate gate (check_declared_minimums) + runtime grow caps (set_runtime_max_pages/set_runtime_max_elements) the CLI --memory path uses, so a module with a manifest and NO CLI flag is bounded. Precedence: CLI + manifest combine via most_restrictive (min) — the operator cannot loosen the signed bound, the module cannot loosen the operator cap. The .unwrap_or(None) masking fallback and the foundation stub extractor are deleted; EngineBuilder::from_binary now selects ASIL mode from the manifest qualified level and fails loud on malformed manifests (it previously always yielded ASIL-D from the stub). NOT yet enforced: max_call_depth/fuel (EngineResourceLimits cannot express them; memory — the WCMC attack surface — is). Verified: manifest_memory_bound_enforced_without_cli_limits, manifest_bound_admits_fitting_module_and_caps_growth, manifest_tighter_than_cli_wins, cli_tighter_than_manifest_wins, malformed_manifest_section_fails_loud, builder_from_binary_fails_loud_on_malformed_manifest, builder_from_binary_selects_manifest_asil_level (kiln-runtime/tests/resource_limits_gate_tests.rs); test_extract_absent_section_is_none, test_extract_present_section_roundtrips, test_extract_malformed_section_is_error, test_extract_non_wasm_binary_is_error (kiln-decoder). --- 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 @@ -17,4 +17,4 @@ artifacts: created-by: ai model: claude-opus-4-8 timestamp: 2026-07-11T10:00:00Z - release: v0.5.0 + release: v0.4.4