Skip to content

Commit 48be570

Browse files
committed
fix(profile): reject special resolver modules
1 parent 8fcaf77 commit 48be570

6 files changed

Lines changed: 72 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/agent-spec/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ wasmtime = { version = "48.0.1", optional = true, default-features = false, feat
1919
"std",
2020
"wat",
2121
] }
22+
libc = { version = "0.2", optional = true }
2223

2324
[dev-dependencies]
2425
tempfile = "3"
2526

2627
[features]
27-
wasm-resolver = ["dep:wasmtime"]
28+
wasm-resolver = ["dep:libc", "dep:wasmtime"]
2829

2930
[[example]]
3031
name = "wasm_bench"

crates/agent-spec/src/profile_wasm.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,7 @@ fn read_module_bounded(
187187
) -> Result<Vec<u8>, WasmResolveError> {
188188
use std::io::Read as _;
189189

190-
let file = std::fs::File::open(path)
191-
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?;
190+
let file = open_module_file(path)?;
192191
let declared_len = file
193192
.metadata()
194193
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?
@@ -210,6 +209,45 @@ fn read_module_bounded(
210209
Ok(bytes)
211210
}
212211

212+
#[cfg(unix)]
213+
fn open_module_file(path: &std::path::Path) -> Result<std::fs::File, WasmResolveError> {
214+
use std::os::unix::fs::OpenOptionsExt as _;
215+
216+
let file = std::fs::OpenOptions::new()
217+
.read(true)
218+
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
219+
.open(path)
220+
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?;
221+
if !file
222+
.metadata()
223+
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?
224+
.file_type()
225+
.is_file()
226+
{
227+
return Err(WasmResolveError::Instantiation(
228+
"resolver module is not a regular file".to_owned(),
229+
));
230+
}
231+
Ok(file)
232+
}
233+
234+
#[cfg(not(unix))]
235+
fn open_module_file(path: &std::path::Path) -> Result<std::fs::File, WasmResolveError> {
236+
let file = std::fs::File::open(path)
237+
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?;
238+
if !file
239+
.metadata()
240+
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?
241+
.file_type()
242+
.is_file()
243+
{
244+
return Err(WasmResolveError::Instantiation(
245+
"resolver module is not a regular file".to_owned(),
246+
));
247+
}
248+
Ok(file)
249+
}
250+
213251
fn reject_symlink_components(
214252
agent_dir: &std::path::Path,
215253
path: &std::path::Path,

crates/agent-spec/tests/profile_wasm.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,25 @@ fn oversized_module_is_rejected_before_wasmtime_compilation() {
369369
}
370370
}
371371

372+
#[cfg(unix)]
373+
#[test]
374+
fn special_file_module_is_rejected_without_blocking() {
375+
use std::os::unix::ffi::OsStrExt as _;
376+
377+
let temp = tempfile::tempdir().expect("module directory");
378+
let fifo = temp.path().join("resolver.fifo");
379+
let fifo_c = std::ffi::CString::new(fifo.as_os_str().as_bytes()).expect("FIFO path");
380+
// SAFETY: the path is NUL-terminated and points into the live temporary directory.
381+
assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0);
382+
match WasmResolver::load(&fifo) {
383+
Err(WasmResolveError::Instantiation(error)) => {
384+
assert!(error.contains("regular file"), "got: {error}");
385+
}
386+
Err(other) => panic!("expected special-file refusal, got {other}"),
387+
Ok(_) => panic!("FIFO resolver unexpectedly compiled"),
388+
}
389+
}
390+
372391
#[test]
373392
fn registry_folds_every_guest_failure_into_unwatchable_and_keeps_resolving() {
374393
// A broken module registered under a scheme: every resolution fails contained, with the

docs/vrs/07-resource-profile/requirements.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,12 @@ accepted rationale is recorded in
6161
### Must contain resolver behavior
6262

6363
- **PROFILE-R04 Closed sandbox:** Resolver modules run with no WASI and no host
64-
imports, under a finite module-byte admission bound, per-call fuel budget,
64+
imports. Module files are opened nonblocking and accepted only as regular
65+
files, under a finite module-byte admission bound, per-call fuel budget,
6566
linear-memory cap, and table-element cap. Traps, infinite loops, missing
66-
exports, invalid memory ranges, malformed output, and oversized module,
67-
table, or allocation attempts cannot unwind into or terminate the supervisor.
67+
exports, invalid memory ranges, malformed output, and special or oversized
68+
module, table, or allocation attempts cannot unwind into or terminate the
69+
supervisor.
6870
- **PROFILE-R05 Host-enforced path boundary:** A module's non-empty returned
6971
path is decoded and normalized by the host and accepted only when it remains
7072
inside the agent directory. Every root, ancestor, and final component is

docs/vrs/07-resource-profile/spec.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,15 +164,16 @@ metadata alone is never treated as a durable proof.
164164

165165
## Runtime containment (PROFILE-R04..R07)
166166

167-
Each module file is read through a 16 MiB admission cap before validation or
168-
compilation, then compiled once per module path and shared by registry clones.
167+
Each module is opened nonblocking and no-follow, accepted only as a regular
168+
file, and read through a 16 MiB admission cap before validation or compilation.
169+
It is then compiled once per module path and shared by registry clones.
169170
Each resolution creates a fresh `Store` and `Instance`. One fuel allowance
170171
covers the module start function and the first resolution call; a reused
171172
instance receives one fresh allowance before each later call:
172173

173174
| Boundary | Contract |
174175
| --- | --- |
175-
| Module bytes | 16 MiB maximum before Wasmtime compilation |
176+
| Module file | regular, no-follow, nonblocking open; 16 MiB maximum before Wasmtime compilation |
176177
| Imports | none; import-requiring modules fail instantiation |
177178
| Fuel | 5,000,000 fuel units for start + first call; same budget per later call |
178179
| Linear memory | 64 MiB maximum |
@@ -185,7 +186,7 @@ Failure taxonomy:
185186

186187
| Failure | SDK result | Supervisor effect |
187188
| --- | --- | --- |
188-
| oversized module or module load/instantiation | `Instantiation` | binding unwatchable; reconcile warning; supervisor lives |
189+
| special/oversized module or module load/instantiation | `Instantiation` | binding unwatchable; reconcile warning; supervisor lives |
189190
| missing `memory`, `alloc`, or `resolve` | `MissingExport` | same |
190191
| unreachable/stack/memory trap | `Trap` | same |
191192
| infinite start function or call | `FuelExhausted` | same |

0 commit comments

Comments
 (0)