From 45e24375d5ab4f04e5bfcc0e51de2a2528e158d0 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 28 Oct 2026 17:14:25 +0900 Subject: [PATCH] fix: reject unmetered private Component resource allocators Address formal review R1 by failing closed on guest resource definitions and canonical resource.new in outer and nested Components before Store creation. Preserve shared artifact/ABI verification and imported Host connections; regress resource ceilings zero/default, 64 live handles in core start, nested definitions and fresh execution after rejection. --- Cargo.lock | 1 + crates/plugin-runtime/Cargo.toml | 1 + crates/plugin-runtime/README.md | 2 + crates/plugin-runtime/src/executor.rs | 7 ++- crates/plugin-runtime/src/sandbox.rs | 33 +++++++++++ crates/plugin-runtime/tests/executor.rs | 78 +++++++++++++++++++++++++ package.nix | 2 +- 7 files changed, 122 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c02cc76e6..63fbf2a18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7475,6 +7475,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "wasmparser 0.244.0", "wasmprinter 0.244.0", "wasmtime", "wat", diff --git a/crates/plugin-runtime/Cargo.toml b/crates/plugin-runtime/Cargo.toml index 6eeed5817..a24b8ff89 100644 --- a/crates/plugin-runtime/Cargo.toml +++ b/crates/plugin-runtime/Cargo.toml @@ -14,6 +14,7 @@ thiserror.workspace = true tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] } tokio-util = { version = "0.7", features = ["rt"] } jsonschema = { version = "0.33.0", default-features = false } +wasmparser = "0.244.0" wasmtime = { version = "=38.0.4", default-features = false, features = ["async", "component-model", "cranelift", "runtime", "std", "threads"] } [dev-dependencies] diff --git a/crates/plugin-runtime/README.md b/crates/plugin-runtime/README.md index 440bff337..c70471a70 100644 --- a/crates/plugin-runtime/README.md +++ b/crates/plugin-runtime/README.md @@ -8,4 +8,6 @@ Component instantiation is not a Tool export: all Host imports, including loggin A single deadline covers admission, instantiation, guest execution, asynchronous Host waits, post-return and publication. Fuel bounds computation and cooperatively yields; epoch interruption cooperatively yields as well. Store memory/table/instance limits, call-local resource and operation counts, serialized payload and diagnostic budgets complement those bounds. Text/JSON overflows fail rather than returning truncated success; excess diagnostic entries are dropped and counted. Guest diagnostics are untrusted, must not contain secrets, and are separate from model output. Host adapters must not log raw request/response or credentials. +The Tool sandbox accepts only imported Host resources. Wasmtime's Store limiter does not meter guest-defined Component resource tables, so compile rejects private resource definitions and canonical `resource.new` throughout concrete nested Components before any Store exists. This is an additional execution-policy restriction after shared package/ABI verification, not a second package validator. It preserves the WIT connection imports, whose creation is metered by the adapter, without claiming a quota for unmetered internal resource allocators. Unsupported execution policy is distinct from corrupt artifact identity. + Compile results may be shared, but invocation memory, resource tables and imported state are never shared. The caller owns finite aggregate admission/concurrency quotas; per-invocation limits alone do not bound an unbounded number of callers. A validated digest is content identity, never admission authority. This standalone core is not connected to normal Worker launch/restore; product integration must supply the live Server provider. diff --git a/crates/plugin-runtime/src/executor.rs b/crates/plugin-runtime/src/executor.rs index 127eabf17..9023e4d41 100644 --- a/crates/plugin-runtime/src/executor.rs +++ b/crates/plugin-runtime/src/executor.rs @@ -54,6 +54,8 @@ impl Default for Limits { pub enum ExecutionError { #[error("invalid artifact: {0}")] Artifact(String), + #[error("unsupported Component execution policy: {0}")] + UnsupportedComponent(String), #[error("invalid invocation: {0}")] InvalidInput(String), #[error("invalid output: {0}")] @@ -165,7 +167,10 @@ impl Executor { ) .map_err(|e| ExecutionError::Artifact(e.message))?; let manifest = verified.package.manifest; - let component = Component::new(&self.engine, &verified.files[&manifest.runtime.component]) + let component_bytes = &verified.files[&manifest.runtime.component]; + crate::sandbox::check_component_resources(component_bytes) + .map_err(|e| ExecutionError::UnsupportedComponent(e.to_string()))?; + let component = Component::new(&self.engine, component_bytes) .map_err(|e| ExecutionError::Artifact(e.to_string()))?; Ok(CompiledTool { component, diff --git a/crates/plugin-runtime/src/sandbox.rs b/crates/plugin-runtime/src/sandbox.rs index f10daffac..9b8be9eb8 100644 --- a/crates/plugin-runtime/src/sandbox.rs +++ b/crates/plugin-runtime/src/sandbox.rs @@ -4,6 +4,39 @@ use crate::Limits; use wasmtime::{ResourceLimiter, StoreLimits, StoreLimitsBuilder}; +/// Additional execution-policy check, not a replacement ABI/package validator. +/// Wasmtime's Store limiter does not meter Component-internal resource tables. +/// The Tool ABI needs only imported Host resources, whose creation is metered +/// by our adapter; guest-defined resources and allocators therefore fail closed. +/// `parse_all` visits concrete nested Components as well as the outer one. +pub(crate) fn check_component_resources(bytes: &[u8]) -> wasmtime::Result<()> { + use wasmparser::{CanonicalFunction, ComponentType, Parser, Payload}; + for payload in Parser::new(0).parse_all(bytes) { + match payload? { + Payload::ComponentTypeSection(types) => { + for ty in types { + if matches!(ty?, ComponentType::Resource { .. }) { + return Err(wasmtime::Error::msg( + "guest-defined Component resources are unsupported by the Tool sandbox", + )); + } + } + } + Payload::ComponentCanonicalSection(functions) => { + for function in functions { + if matches!(function?, CanonicalFunction::ResourceNew { .. }) { + return Err(wasmtime::Error::msg( + "guest Component resource.new is unsupported by the Tool sandbox", + )); + } + } + } + _ => (), + } + } + Ok(()) +} + pub(crate) struct SandboxLimits { inner: StoreLimits, memory: Budget, diff --git a/crates/plugin-runtime/tests/executor.rs b/crates/plugin-runtime/tests/executor.rs index cc0241cb0..33d2d59b3 100644 --- a/crates/plugin-runtime/tests/executor.rs +++ b/crates/plugin-runtime/tests/executor.rs @@ -950,3 +950,81 @@ fn operational_limits_cannot_raise_the_supported_ceiling() { .is_err() ); } + +fn private_resource_component(nested_depth: usize, allocate: bool) -> Vec { + let mut payload = "(type $private (resource (rep i32)))".to_string(); + if allocate { + // The i32 drop discards a representation, not the live resource handle. + // A core start creates handles during Component instantiation without + // changing the T-791 fixture's public imports or exports. + payload.push_str(&format!( + r#" + (core func $new (canon resource.new $private)) + (core module $allocate + (import "r" "new" (func $new (param i32) (result i32))) + (func $start {}) + (start $start) + ) + (core instance $resource-funcs (export "new" (func $new))) + (core instance $allocated (instantiate $allocate (with "r" (instance $resource-funcs)))) + "#, + "i32.const 7 call $new drop ".repeat(64) + )); + } + for _ in 0..nested_depth { + payload = format!("(component $nested {payload}) (instance (instantiate $nested))"); + } + let wat = wasmprinter::print_bytes(PURE_COMPONENT).unwrap(); + let end = wat.rfind(')').unwrap(); + wat::parse_str(format!("{}{}{}", &wat[..end], payload, &wat[end..])).unwrap() +} + +#[tokio::test] +async fn private_and_nested_component_resources_fail_closed_before_instantiation() { + for resources in [0, Limits::default().resources] { + let ex = executor(Limits { + resources, + ..Limits::default() + }); + let fresh = compile(&ex, &artifact(PURE, PURE_COMPONENT).unwrap()); + let host = TestHost::new(Behavior::Respond); + for nested_depth in 0..=2 { + for allocate in [false, true] { + let bytes = private_resource_component(nested_depth, allocate); + // The canonical package/ABI is valid. Reject only the unsupported + // execution policy, after verification and before a Store exists. + let package = artifact(PURE, &bytes).unwrap(); + let error = ex + .compile( + &package.bytes, + &package.package_digest, + &package.artifact_digest, + package.artifact_size, + ) + .err() + .expect("private resources must be rejected"); + assert!( + matches!(&error, ExecutionError::UnsupportedComponent(message) if message.contains("guest-defined Component resources")), + "depth={nested_depth}, resources={resources}, allocate={allocate}: {error:?}" + ); + host.released(); + } + } + assert_eq!(host.admissions.load(Ordering::SeqCst), 0); + let result = ex + .invoke( + &fresh, + echo_context(&fresh, "fresh-after-rejection"), + host.clone(), + "example_echo", + r#"{"text":"fresh"}"#, + "{}", + ) + .await + .unwrap(); + let structured: serde_json::Value = + serde_json::from_str(result.output.structured_json.as_deref().unwrap()).unwrap(); + assert_eq!(structured["text"], "fresh"); + host.released(); + } +} diff --git a/package.nix b/package.nix index 78c2416fb..fc3bee854 100644 --- a/package.nix +++ b/package.nix @@ -44,7 +44,7 @@ rustPlatform.buildRustPackage rec { }; # All four WIP SDK crates resolve from crates.io 0.2.0 via Cargo.lock checksums. - cargoHash = "sha256-bT0HZacyAThuZPFw7T7etyy80K0C8Y1tlJkhkjAjA34="; + cargoHash = "sha256-ikCD2DBORUchmnPk6bLZjgPVPbnNh4rVsSz43S164Eo="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint,