Skip to content

Commit 648b4df

Browse files
committed
fix(profile): address post-recovery review gaps
1 parent 44af62c commit 648b4df

9 files changed

Lines changed: 270 additions & 60 deletions

File tree

crates/agent-spec/src/profile.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,11 @@ pub enum ProfileSource {
8181
/// A wasm module implementing the `resolve` protocol (see [`crate::profile_wasm`] for the
8282
/// ABI), plus the notification class every carrier it resolves carries. Compilation results
8383
/// (success or failure) are cached by path and file identity; instances remain per-resolution.
84-
Wasm { module: PathBuf, class: ProfileClass },
84+
Wasm {
85+
module: PathBuf,
86+
class: ProfileClass,
87+
containment_root: Option<PathBuf>,
88+
},
8589
}
8690

8791
impl ResourceProfile {
@@ -96,6 +100,26 @@ impl ResourceProfile {
96100
source: ProfileSource::Wasm {
97101
module: module.into(),
98102
class,
103+
containment_root: None,
104+
},
105+
}
106+
}
107+
108+
/// Wasm-module profile whose module must be opened beneath one trusted directory without
109+
/// following symlinks in any relative path component.
110+
pub fn wasm_contained(
111+
scheme: impl Into<String>,
112+
containment_root: impl Into<PathBuf>,
113+
relative_module: impl AsRef<Path>,
114+
class: ProfileClass,
115+
) -> Self {
116+
let containment_root = containment_root.into();
117+
Self {
118+
scheme: scheme.into(),
119+
source: ProfileSource::Wasm {
120+
module: containment_root.join(relative_module),
121+
class,
122+
containment_root: Some(containment_root),
99123
},
100124
}
101125
}
@@ -228,11 +252,15 @@ impl ResourceProfileRefresh<'_> {
228252
let Some(profile) = self.registry.profiles.get(scheme) else {
229253
return Ok(None);
230254
};
231-
let ProfileSource::Wasm { module, class } = &profile.source;
255+
let ProfileSource::Wasm {
256+
module,
257+
class,
258+
containment_root,
259+
} = &profile.source;
232260

233261
#[cfg(not(feature = "wasm-resolver"))]
234262
{
235-
let _ = (module, class, agent_dir);
263+
let _ = (module, class, containment_root, agent_dir);
236264
Err("profile resolver unavailable: st2 was built without the `wasm-resolver` feature"
237265
.to_owned())
238266
}
@@ -243,7 +271,7 @@ impl ResourceProfileRefresh<'_> {
243271
if let Some(result) = modules.get(module) {
244272
result.clone()
245273
} else {
246-
let result = self.registry.compiled(module);
274+
let result = self.registry.compiled(module, containment_root.as_deref());
247275
modules.insert(module.clone(), result.clone());
248276
result
249277
}
@@ -420,13 +448,15 @@ impl ResourceProfileRegistry {
420448
fn compiled(
421449
&self,
422450
module_path: &Path,
451+
containment_root: Option<&Path>,
423452
) -> Result<Arc<crate::profile_wasm::WasmResolver>, String> {
424453
#[cfg(test)]
425454
{
426455
self.wasm_cache.lock().snapshot_attempts += 1;
427456
}
428457
let snapshot = crate::profile_wasm::read_module_snapshot(
429458
module_path,
459+
containment_root,
430460
DEFAULT_MODULE_LIMIT_BYTES,
431461
)
432462
.map_err(|error| error.to_string())?;

crates/agent-spec/src/profile_wasm.rs

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub const DEFAULT_MEMORY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
2929
pub const DEFAULT_TABLE_ELEMENT_LIMIT: usize = 10_000;
3030
/// Maximum resolver module bytes admitted before Wasmtime validation and compilation.
3131
pub use crate::profile::DEFAULT_MODULE_LIMIT_BYTES;
32+
/// Maximum JSON payload accepted from one resolver call before UTF-8 or Serde decoding.
33+
pub const DEFAULT_OUTPUT_LIMIT_BYTES: usize = 64 * 1024;
3234

3335
/// One resolution result as produced by a wasm resolver module.
3436
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
@@ -101,7 +103,7 @@ impl Clone for WasmResolver {
101103
impl WasmResolver {
102104
/// Compile a `.wasm` module from disk with default budgets.
103105
pub fn load(path: &std::path::Path) -> Result<Self, WasmResolveError> {
104-
let snapshot = read_module_snapshot(path, DEFAULT_MODULE_LIMIT_BYTES)?;
106+
let snapshot = read_module_snapshot(path, None, DEFAULT_MODULE_LIMIT_BYTES)?;
105107
Self::from_bytes(&snapshot.bytes)
106108
}
107109

@@ -204,11 +206,12 @@ pub(crate) struct ModuleSnapshot {
204206

205207
pub(crate) fn read_module_snapshot(
206208
path: &std::path::Path,
209+
containment_root: Option<&std::path::Path>,
207210
limit: usize,
208211
) -> Result<ModuleSnapshot, WasmResolveError> {
209212
use std::io::Read as _;
210213

211-
let file = open_module_file(path)?;
214+
let file = open_module_file(path, containment_root)?;
212215
let declared_len = file
213216
.metadata()
214217
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?
@@ -235,31 +238,82 @@ pub(crate) fn read_module_snapshot(
235238
}
236239

237240
#[cfg(unix)]
238-
fn open_module_file(path: &std::path::Path) -> Result<std::fs::File, WasmResolveError> {
241+
fn open_module_file(
242+
path: &std::path::Path,
243+
containment_root: Option<&std::path::Path>,
244+
) -> Result<std::fs::File, WasmResolveError> {
245+
use std::ffi::CString;
246+
use std::os::fd::{AsRawFd as _, FromRawFd as _};
247+
use std::os::unix::ffi::OsStrExt as _;
239248
use std::os::unix::fs::OpenOptionsExt as _;
240-
241-
let file = std::fs::OpenOptions::new()
242-
.read(true)
243-
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
244-
.open(path)
245-
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?;
246-
if !file
247-
.metadata()
248-
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?
249-
.file_type()
250-
.is_file()
251-
{
249+
use std::path::Component;
250+
251+
let Some(root) = containment_root else {
252+
return validate_module_file(
253+
std::fs::OpenOptions::new()
254+
.read(true)
255+
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
256+
.open(path)
257+
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?,
258+
);
259+
};
260+
let relative = path.strip_prefix(root).map_err(|_| {
261+
WasmResolveError::Instantiation(format!(
262+
"resolver module {} escapes containment root {}",
263+
path.display(),
264+
root.display()
265+
))
266+
})?;
267+
let mut components = relative.components().peekable();
268+
if components.peek().is_none() {
252269
return Err(WasmResolveError::Instantiation(
253-
"resolver module is not a regular file".to_owned(),
270+
"resolver module path is empty".to_owned(),
254271
));
255272
}
256-
Ok(file)
273+
let mut directory = std::fs::OpenOptions::new()
274+
.read(true)
275+
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
276+
.open(root)
277+
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?;
278+
while let Some(component) = components.next() {
279+
let Component::Normal(name) = component else {
280+
return Err(WasmResolveError::Instantiation(
281+
"resolver module path contains an unsafe component".to_owned(),
282+
));
283+
};
284+
let name = CString::new(name.as_bytes()).map_err(|_| {
285+
WasmResolveError::Instantiation(
286+
"resolver module path contains an interior NUL".to_owned(),
287+
)
288+
})?;
289+
let is_last = components.peek().is_none();
290+
let flags = libc::O_RDONLY
291+
| libc::O_CLOEXEC
292+
| libc::O_NOFOLLOW
293+
| if is_last {
294+
libc::O_NONBLOCK
295+
} else {
296+
libc::O_DIRECTORY
297+
};
298+
// SAFETY: `directory` is a live descriptor and `name` is a NUL-terminated single path
299+
// component. The returned descriptor is immediately owned by `File`.
300+
let fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
301+
if fd < 0 {
302+
return Err(WasmResolveError::Instantiation(
303+
std::io::Error::last_os_error().to_string(),
304+
));
305+
}
306+
// SAFETY: `openat` returned a fresh owned descriptor.
307+
let opened = unsafe { std::fs::File::from_raw_fd(fd) };
308+
if is_last {
309+
return validate_module_file(opened);
310+
}
311+
directory = opened;
312+
}
313+
unreachable!("a non-empty component iterator returns its final file")
257314
}
258315

259-
#[cfg(not(unix))]
260-
fn open_module_file(path: &std::path::Path) -> Result<std::fs::File, WasmResolveError> {
261-
let file = std::fs::File::open(path)
262-
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?;
316+
fn validate_module_file(file: std::fs::File) -> Result<std::fs::File, WasmResolveError> {
263317
if !file
264318
.metadata()
265319
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?
@@ -273,6 +327,17 @@ fn open_module_file(path: &std::path::Path) -> Result<std::fs::File, WasmResolve
273327
Ok(file)
274328
}
275329

330+
#[cfg(not(unix))]
331+
fn open_module_file(
332+
path: &std::path::Path,
333+
_containment_root: Option<&std::path::Path>,
334+
) -> Result<std::fs::File, WasmResolveError> {
335+
validate_module_file(
336+
std::fs::File::open(path)
337+
.map_err(|error| WasmResolveError::Instantiation(error.to_string()))?,
338+
)
339+
}
340+
276341
fn reject_symlink_components(
277342
agent_dir: &std::path::Path,
278343
path: &std::path::Path,
@@ -420,6 +485,11 @@ impl WasmInstance {
420485

421486
let ret_ptr = (packed >> 32) as u32 as usize;
422487
let ret_len = (packed as u32) as usize;
488+
if ret_len > DEFAULT_OUTPUT_LIMIT_BYTES {
489+
return Err(WasmResolveError::BadReturn(format!(
490+
"return payload is {ret_len} bytes; limit is {DEFAULT_OUTPUT_LIMIT_BYTES} bytes"
491+
)));
492+
}
423493
let bytes = self.read_guest_bytes(ret_ptr, ret_len)?;
424494
let text = std::str::from_utf8(bytes)
425495
.map_err(|e| WasmResolveError::BadReturn(format!("return payload is not UTF-8: {e}")))?;

crates/agent-spec/tests/profile_wasm.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
use agent_spec::profile::{ProfileClass, ResourceProfile, ResourceProfileRegistry};
1212
use agent_spec::profile_wasm::{
13-
DEFAULT_MODULE_LIMIT_BYTES, DEFAULT_TABLE_ELEMENT_LIMIT, WasmResolveError, WasmResolver,
13+
DEFAULT_MODULE_LIMIT_BYTES, DEFAULT_OUTPUT_LIMIT_BYTES, DEFAULT_TABLE_ELEMENT_LIMIT,
14+
WasmResolveError, WasmResolver,
1415
};
1516
use std::path::{Path, PathBuf};
1617
use wasmtime::Trap;
@@ -293,6 +294,26 @@ fn garbage_return_payload_is_reported_not_crashed_on() {
293294
}
294295
}
295296

297+
#[test]
298+
fn oversized_valid_return_range_is_rejected_before_decoding() {
299+
let oversized = WasmResolver::from_wat(
300+
r#"(module
301+
(memory (export "memory") 2)
302+
(func (export "alloc") (param i32) (result i32) (i32.const 1024))
303+
(func (export "resolve") (param i32 i32 i32 i32) (result i64)
304+
(i64.extend_i32_u (i32.const 65537)))
305+
)"#,
306+
)
307+
.expect("oversized-return module compiles");
308+
match oversized.resolve_once("dev.schickling.agent-goal://x", "/a") {
309+
Err(WasmResolveError::BadReturn(error)) => {
310+
assert!(error.contains("limit"), "got: {error}");
311+
assert!(error.contains(&(DEFAULT_OUTPUT_LIMIT_BYTES + 1).to_string()));
312+
}
313+
other => panic!("expected output-limit rejection, got {other:?}"),
314+
}
315+
}
316+
296317
#[test]
297318
fn wild_return_pointer_is_caught_before_memory_access() {
298319
let wild = WasmResolver::from_wat(&format!(
@@ -389,6 +410,34 @@ fn oversized_module_is_rejected_before_wasmtime_compilation() {
389410
}
390411
}
391412

413+
#[cfg(unix)]
414+
#[test]
415+
fn catalog_relative_module_rejects_symlinked_path_ancestors() {
416+
let catalog = tempfile::tempdir().expect("catalog directory");
417+
let outside = tempfile::tempdir().expect("outside directory");
418+
std::fs::copy(DEMO_WASM_PATH, outside.path().join("demo.wasm"))
419+
.expect("outside module is copied");
420+
std::os::unix::fs::symlink(outside.path(), catalog.path().join("resolvers"))
421+
.expect("resolver ancestor symlink is created");
422+
let registry = ResourceProfileRegistry::empty().with_profile(
423+
ResourceProfile::wasm_contained(
424+
"dev.schickling.agent-goal",
425+
catalog.path(),
426+
"resolvers/demo.wasm",
427+
ProfileClass::Immediate,
428+
),
429+
);
430+
431+
let error = registry
432+
.try_resolve(
433+
catalog.path(),
434+
"dev.schickling.agent-goal://host/worker",
435+
)
436+
.expect_err("a symlinked module ancestor must be rejected");
437+
assert!(!error.is_empty());
438+
}
439+
440+
392441
#[cfg(unix)]
393442
#[test]
394443
fn special_file_module_is_rejected_without_blocking() {

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,13 @@ There is one source variant:
6666
ProfileSource::Wasm {
6767
module: PathBuf,
6868
class: ProfileClass,
69+
containment_root: Option<PathBuf>,
6970
}
7071
```
7172

73+
`containment_root` is the trusted descriptor-traversal root for catalog-relative
74+
modules and is absent for explicitly external absolute modules.
75+
7276
There is no template or exec variant. `ResourceProfileRegistry::builtin()` is
7377
empty. `with_profile` and `with_profiles` inject catalog-owned registrations;
7478
a later programmatic insertion for the same exact scheme replaces the prior
@@ -219,6 +223,8 @@ metadata alone is never treated as a durable proof.
219223

220224
Each module is opened nonblocking and no-follow, accepted only as a regular
221225
file, and read through a 16 MiB admission cap before validation or compilation.
226+
Catalog-relative modules are traversed descriptor-relative from the catalog
227+
root with `O_NOFOLLOW` on every ancestor and the final component.
222228
The bounded 32-entry LRU cache stores both successful modules and compilation
223229
failures by module path plus byte digest and stable file metadata. Registry
224230
clones and concurrent subscribers therefore coalesce one compilation attempt
@@ -231,10 +237,11 @@ instance receives one fresh allowance before each later call:
231237

232238
| Boundary | Contract |
233239
| --- | --- |
234-
| Module file | regular, no-follow, nonblocking open; 16 MiB maximum before Wasmtime compilation |
240+
| Module file | regular, nonblocking; catalog-relative paths use descriptor-relative no-follow traversal for every component; 16 MiB maximum before Wasmtime compilation |
235241
| Imports | none; import-requiring modules fail instantiation |
236242
| Fuel | 5,000,000 fuel units for start + first call; same budget per later call |
237243
| Linear memory | 64 MiB maximum |
244+
| Resolver return | memory range must be valid and at most 64 KiB before UTF-8/JSON decoding |
238245
| Memories | at most 1 |
239246
| Tables | at most 4, with at most 10,000 elements each |
240247
| Instance state | fresh per registry resolution |
@@ -248,7 +255,7 @@ Failure taxonomy:
248255
| missing `memory`, `alloc`, or `resolve` | `MissingExport` | same |
249256
| unreachable/stack/memory trap | `Trap` | same |
250257
| infinite start function or call | `FuelExhausted` | same |
251-
| invalid pointer, UTF-8, JSON, empty/escaped path, symlink, or special-file read | `BadReturn` or unreadable carrier | same |
258+
| invalid pointer, oversized return, UTF-8, JSON, empty/escaped path, symlink, or special-file read | `BadReturn` or unreadable carrier | same |
252259
| feature disabled | registered-profile error | same; no alternate resolver |
253260

254261
All wasmtime code and dependencies are gated by `wasm-resolver`, forwarded from

src/agents.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ pub fn roster_from_discovered(
5959
this_host: &str,
6060
) -> Vec<AgentRow> {
6161
let pty_root = probe_pty_root(catalog_root);
62+
let profiles = crate::catalog::declared_profiles(catalog_root).unwrap_or_default();
63+
let profile_refresh = profiles.begin_refresh();
6264
let mut rows: Vec<AgentRow> = found
6365
.specs
6466
.iter()
@@ -76,7 +78,13 @@ pub fn roster_from_discovered(
7678
resource_resync: s
7779
.resources
7880
.iter()
79-
.map(|resource| crate::resync::resource_coverage(agent_dir, resource))
81+
.map(|resource| {
82+
crate::resync::resource_coverage_with_profiles(
83+
agent_dir,
84+
resource,
85+
&profile_refresh,
86+
)
87+
})
8088
.collect(),
8189
last_activity_ms: newest_activity_ms(agent_dir),
8290
inbox: inbox_count(agent_dir),

0 commit comments

Comments
 (0)