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.
This commit is contained in:
Generated
+1
@@ -7475,6 +7475,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"wasmparser 0.244.0",
|
||||
"wasmprinter 0.244.0",
|
||||
"wasmtime",
|
||||
"wat",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -950,3 +950,81 @@ fn operational_limits_cannot_raise_the_supported_ceiling() {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn private_resource_component(nested_depth: usize, allocate: bool) -> Vec<u8> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user