Skip to content

Commit b15faa0

Browse files
committed
refactor(cli): policies belong in runtime — collapse into runtime/ module
Moves the policy-related modules into a runtime/ directory module, next to the wasmtime linker setup and the generated WIT bindings they depend on. Config stays declarative; runtime owns all the host setup. Layout: src/ config.rs — declarative types: FsConfig, HttpConfig, CliPolicyOverrides, TOML + CLI resolution. runtime/ mod.rs — HostState, linker, create_store, actor loop. bindings/ — WIT-generated bindings (was src/bindings/). fs_matcher.rs — globset-backed FsMatcher. fs_policy.rs — Preopen + derive_preopens + platform_root_preopens + apply_mount_root + PolicyFilesystem HostDescriptor. http_policy.rs — PolicyHttpHooks. Also renames DirMount → Preopen. With the virtual-root model we're no longer doing traditional mount-style remapping — a Preopen is just a (guest, host) pair handed to wasmtime-wasi's preopened_dir. Tests that exercised preopen derivation and apply_mount_root moved to the preopen_tests submodule in runtime/fs_policy.rs.
1 parent 52d5cc1 commit b15faa0

7 files changed

Lines changed: 163 additions & 137 deletions

File tree

act-cli/src/config.rs

Lines changed: 0 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -54,66 +54,6 @@ impl FsConfig {
5454
..Default::default()
5555
}
5656
}
57-
58-
/// Preopens for this policy.
59-
///
60-
/// For every non-`Deny` mode, the `FsMatcher` is the only enforcement
61-
/// point — the guest sees the whole (platform-native) host filesystem in
62-
/// its namespace but can't actually open anything the matcher denies.
63-
///
64-
/// - Unix: a single virtual-root preopen `{guest: "/", host: "/"}`.
65-
/// - Windows: one preopen per accessible drive, guest `/{lowercase letter}`
66-
/// → host `{uppercase letter}:\`. Drives are enumerated by attempting
67-
/// `std::fs::metadata` on each letter A–Z; inaccessible drives are
68-
/// silently skipped.
69-
///
70-
/// Deny mode: no preopens at all (guest can't name anything).
71-
pub fn preopens(&self) -> Result<Vec<DirMount>> {
72-
match self.mode {
73-
PolicyMode::Deny => Ok(vec![]),
74-
PolicyMode::Open | PolicyMode::Allowlist => Ok(platform_root_preopens()),
75-
}
76-
}
77-
}
78-
79-
#[cfg(unix)]
80-
fn platform_root_preopens() -> Vec<DirMount> {
81-
vec![DirMount {
82-
guest: "/".to_string(),
83-
host: PathBuf::from("/"),
84-
}]
85-
}
86-
87-
#[cfg(windows)]
88-
fn platform_root_preopens() -> Vec<DirMount> {
89-
let mut mounts = Vec::new();
90-
for letter in b'A'..=b'Z' {
91-
let c = letter as char;
92-
let host = PathBuf::from(format!("{}:\\", c));
93-
// `metadata` trips DriveNotReady / access errors for absent drives;
94-
// treat any failure as "skip this letter".
95-
if std::fs::metadata(&host).is_ok() {
96-
let guest = format!("/{}", c.to_ascii_lowercase());
97-
mounts.push(DirMount { guest, host });
98-
}
99-
}
100-
mounts
101-
}
102-
103-
#[cfg(not(any(unix, windows)))]
104-
fn platform_root_preopens() -> Vec<DirMount> {
105-
// Fall back to a POSIX-ish root on esoteric platforms.
106-
vec![DirMount {
107-
guest: "/".to_string(),
108-
host: PathBuf::from("/"),
109-
}]
110-
}
111-
112-
/// Derived directory mount for wasmtime's `preopened_dir`.
113-
#[derive(Debug, Clone, PartialEq)]
114-
pub struct DirMount {
115-
pub guest: String,
116-
pub host: PathBuf,
11757
}
11858

11959
/// Resolved HTTP policy for a component invocation.
@@ -410,22 +350,6 @@ pub fn resolve_metadata(
410350
}
411351
}
412352

413-
/// Adjust guest paths in preopen mounts based on the component's `std:fs:mount-root`.
414-
pub fn apply_mount_root(mounts: &mut [DirMount], mount_root: &str) {
415-
if mount_root == "/" || mount_root.is_empty() {
416-
return;
417-
}
418-
let root = mount_root.trim_end_matches('/');
419-
for mount in mounts {
420-
if mount.guest == "/" {
421-
mount.guest = root.to_string();
422-
} else {
423-
let guest = mount.guest.trim_start_matches('/');
424-
mount.guest = format!("{}/{}", root, guest);
425-
}
426-
}
427-
}
428-
429353
#[cfg(test)]
430354
mod tests {
431355
use super::*;
@@ -441,38 +365,6 @@ mod tests {
441365
assert!(PolicyMode::parse("bogus").is_err());
442366
}
443367

444-
#[test]
445-
fn fs_deny_has_no_preopens() {
446-
let cfg = FsConfig::deny();
447-
assert!(cfg.preopens().unwrap().is_empty());
448-
}
449-
450-
#[test]
451-
fn fs_open_maps_root() {
452-
let cfg = FsConfig {
453-
mode: PolicyMode::Open,
454-
..Default::default()
455-
};
456-
let mounts = cfg.preopens().unwrap();
457-
assert_eq!(mounts.len(), 1);
458-
assert_eq!(mounts[0].host, PathBuf::from("/"));
459-
assert_eq!(mounts[0].guest, "/");
460-
}
461-
462-
#[test]
463-
fn fs_allowlist_uses_virtual_root() {
464-
let cfg = FsConfig {
465-
mode: PolicyMode::Allowlist,
466-
allow: vec!["/tmp/data/**".into(), "~/projects/{foo,bar}/**".into()],
467-
..Default::default()
468-
};
469-
let mounts = cfg.preopens().unwrap();
470-
// Always a single virtual-root preopen; matcher handles the patterns.
471-
assert_eq!(mounts.len(), 1);
472-
assert_eq!(mounts[0].host, PathBuf::from("/"));
473-
assert_eq!(mounts[0].guest, "/");
474-
}
475-
476368
#[test]
477369
fn cli_http_allow_host() {
478370
let cli = CliPolicyOverrides {
@@ -532,16 +424,6 @@ allow = [{ host = "api.openai.com", scheme = "https" }]
532424
assert_eq!(http.allow[0].scheme.as_deref(), Some("https"));
533425
}
534426

535-
#[test]
536-
fn apply_mount_root_rewrites() {
537-
let mut mounts = vec![DirMount {
538-
guest: "/".to_string(),
539-
host: PathBuf::from("/host"),
540-
}];
541-
apply_mount_root(&mut mounts, "/data");
542-
assert_eq!(mounts[0].guest, "/data");
543-
}
544-
545427
#[test]
546428
fn cli_overrides_config_file() {
547429
let toml = r#"

act-cli/src/main.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
mod config;
22
mod format;
3-
mod fs_matcher;
4-
mod fs_policy;
53
mod http;
6-
mod http_policy;
74
mod mcp;
85
mod resolve;
96
mod runtime;
@@ -299,9 +296,9 @@ async fn prepare_component(
299296

300297
runtime::warn_missing_capabilities(&info, &fs, &http);
301298

302-
let mut preopens = fs.preopens()?;
299+
let mut preopens = runtime::fs_policy::derive_preopens(&fs);
303300
let mount_root = info.std.capabilities.fs_mount_root().unwrap_or("/");
304-
config::apply_mount_root(&mut preopens, mount_root);
301+
runtime::fs_policy::apply_mount_root(&mut preopens, mount_root);
305302

306303
let metadata: runtime::Metadata = resolved
307304
.metadata
Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,87 @@ use wasmtime_wasi::p2::bindings::filesystem::types::{
3838
};
3939
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult};
4040

41-
use crate::fs_matcher::{FsDecision, FsMatcher};
41+
use crate::config::{FsConfig, PolicyMode};
42+
use crate::runtime::fs_matcher::{FsDecision, FsMatcher};
43+
44+
// ── Preopen derivation ────────────────────────────────────────────────────
45+
46+
/// A (guest path → host path) pair handed to wasmtime-wasi's `preopened_dir`.
47+
///
48+
/// With the virtual-root policy model the guest always sees the whole host
49+
/// filesystem at `/` (Unix) or at `/c`, `/d`, ... (Windows drives); the
50+
/// `FsMatcher` gates per-op access. These records are the bridge between
51+
/// config and the wasmtime setup code.
52+
#[derive(Debug, Clone, PartialEq)]
53+
pub struct Preopen {
54+
pub guest: String,
55+
pub host: PathBuf,
56+
}
57+
58+
/// Derive the preopen list for an `FsConfig`.
59+
///
60+
/// - `Deny` → no preopens (guest can't name anything).
61+
/// - `Open` / `Allowlist` → platform root preopens. Unix: one `/` preopen.
62+
/// Windows: one per accessible drive letter (`/c` → `C:\`, `/d` → `D:\`, …;
63+
/// absent drives are skipped).
64+
pub fn derive_preopens(cfg: &FsConfig) -> Vec<Preopen> {
65+
match cfg.mode {
66+
PolicyMode::Deny => Vec::new(),
67+
PolicyMode::Open | PolicyMode::Allowlist => platform_root_preopens(),
68+
}
69+
}
70+
71+
#[cfg(unix)]
72+
fn platform_root_preopens() -> Vec<Preopen> {
73+
vec![Preopen {
74+
guest: "/".to_string(),
75+
host: PathBuf::from("/"),
76+
}]
77+
}
78+
79+
#[cfg(windows)]
80+
fn platform_root_preopens() -> Vec<Preopen> {
81+
let mut mounts = Vec::new();
82+
for letter in b'A'..=b'Z' {
83+
let c = letter as char;
84+
let host = PathBuf::from(format!("{}:\\", c));
85+
// `metadata` trips DriveNotReady / access errors for absent drives;
86+
// treat any failure as "skip this letter".
87+
if std::fs::metadata(&host).is_ok() {
88+
let guest = format!("/{}", c.to_ascii_lowercase());
89+
mounts.push(Preopen { guest, host });
90+
}
91+
}
92+
mounts
93+
}
94+
95+
#[cfg(not(any(unix, windows)))]
96+
fn platform_root_preopens() -> Vec<Preopen> {
97+
vec![Preopen {
98+
guest: "/".to_string(),
99+
host: PathBuf::from("/"),
100+
}]
101+
}
102+
103+
/// Adjust guest paths based on the component's `std:fs:mount-root` —
104+
/// cosmetic renaming of the preopen entries. Host paths are untouched and
105+
/// the policy matcher always operates on host paths.
106+
pub fn apply_mount_root(preopens: &mut [Preopen], mount_root: &str) {
107+
if mount_root == "/" || mount_root.is_empty() {
108+
return;
109+
}
110+
let root = mount_root.trim_end_matches('/');
111+
for p in preopens {
112+
if p.guest == "/" {
113+
p.guest = root.to_string();
114+
} else {
115+
let guest = p.guest.trim_start_matches('/');
116+
p.guest = format!("{}/{}", root, guest);
117+
}
118+
}
119+
}
120+
121+
// ── Wasmtime host impl ────────────────────────────────────────────────────
42122

43123
/// `HasData` marker for our policy-aware filesystem view.
44124
pub struct PolicyFilesystem;
@@ -410,3 +490,67 @@ impl HostDirectoryEntryStream for PolicyFilesystemCtxView<'_> {
410490
HostDirectoryEntryStream::drop(&mut self.inner(), stream)
411491
}
412492
}
493+
494+
#[cfg(test)]
495+
mod preopen_tests {
496+
use super::*;
497+
498+
#[test]
499+
fn deny_mode_yields_no_preopens() {
500+
let cfg = FsConfig::deny();
501+
assert!(derive_preopens(&cfg).is_empty());
502+
}
503+
504+
#[test]
505+
#[cfg(unix)]
506+
fn unix_open_and_allowlist_yield_root() {
507+
for mode in [PolicyMode::Open, PolicyMode::Allowlist] {
508+
let cfg = FsConfig {
509+
mode,
510+
..Default::default()
511+
};
512+
let preopens = derive_preopens(&cfg);
513+
assert_eq!(preopens.len(), 1);
514+
assert_eq!(preopens[0].guest, "/");
515+
assert_eq!(preopens[0].host, PathBuf::from("/"));
516+
}
517+
}
518+
519+
#[test]
520+
fn apply_mount_root_is_a_noop_for_root() {
521+
let mut preopens = vec![Preopen {
522+
guest: "/".to_string(),
523+
host: PathBuf::from("/host"),
524+
}];
525+
apply_mount_root(&mut preopens, "/");
526+
assert_eq!(preopens[0].guest, "/");
527+
}
528+
529+
#[test]
530+
fn apply_mount_root_rewrites_virtual_root() {
531+
let mut preopens = vec![Preopen {
532+
guest: "/".to_string(),
533+
host: PathBuf::from("/host"),
534+
}];
535+
apply_mount_root(&mut preopens, "/data");
536+
assert_eq!(preopens[0].guest, "/data");
537+
assert_eq!(preopens[0].host, PathBuf::from("/host"));
538+
}
539+
540+
#[test]
541+
fn apply_mount_root_prefixes_drive_letters() {
542+
let mut preopens = vec![
543+
Preopen {
544+
guest: "/c".to_string(),
545+
host: PathBuf::from("C:\\"),
546+
},
547+
Preopen {
548+
guest: "/d".to_string(),
549+
host: PathBuf::from("D:\\"),
550+
},
551+
];
552+
apply_mount_root(&mut preopens, "/host");
553+
assert_eq!(preopens[0].guest, "/host/c");
554+
assert_eq!(preopens[1].guest, "/host/d");
555+
}
556+
}

0 commit comments

Comments
 (0)