Skip to content

Commit 13648c5

Browse files
committed
feat(cli): L1 phase C1 — per-op filesystem policy with virtual-root preopen
Custom HostDescriptor impl gates every path-taking wasi:filesystem operation on the compiled FsMatcher. Delivers the deny-overlay case that preopen-only enforcement couldn't — user can write --fs-allow '~/projects/**' --fs-deny '~/.ssh/**' and the guest sees the former but opens in the latter get NotPermitted at the WASI layer. Design: - Single virtual-root preopen at host / for every non-Deny mode. The guest sees the whole host fs in its namespace but can't open anything the matcher denies. FsConfig::preopens() returns an empty list (Deny) or [{guest: /, host: /}] (Allowlist or Open). - FsMatcher (new fs_matcher.rs, globset-based) compiles allow/deny patterns. Full glob syntax: *, ?, [...], {a,b}, **, trailing / or /** for directory subtrees. Literal directory patterns implicitly also match descendants. - PolicyFilesystem (new fs_policy.rs) is a HasData marker used to shadow the default wasi:filesystem/types and /preopens bindings. PolicyFilesystemCtxView holds ctx, table, matcher, and a fd -> host path map populated at get-directories time and updated on every open-at. - Path-taking methods (open_at, stat_at, create_directory_at, remove_directory_at, unlink_file_at, rename_at, link_at, symlink_at, readlink_at, metadata_hash_at, set_times_at) look up the parent fd's host path, join the rel path, normalise .. and consult the matcher. Non-path methods (read, write, stat on a handle, stream setup, etc.) delegate unchanged. Known limitations: - Unix-first. Preopening host / assumes a POSIX root; Windows needs per-drive preopens (future work). - p3 wasi:filesystem bindings are not yet shadowed; wasip3 filesystem-using components bypass the matcher. Components target wasm32-wasip2 today. - Symlink resolution and TOCTOU: the matcher operates on the guest-supplied path before cap-std opens the target. cap-std's *at syscalls prevent .. escape within the preopen, but a symlink that points outside an allowed subtree would resolve through cap-std's default policy (still scoped to /). Removes the earlier 'fs.deny is parsed but not enforced' warning - deny entries are now enforced via the matcher. Removes path_to_mount / literal_prefix logic - no longer needed with the single-virtual-root preopen. Verified end-to-end with the filesystem component: - --fs-allow /tmp/x + read /tmp/x/foo: works - --fs-allow /tmp/x + read /etc/passwd: Permission denied - --fs-allow '/tmp/x/**' --fs-deny /tmp/x/secret.txt: reads /tmp/x/allowed.txt, denies /tmp/x/secret.txt std:fs:mount-root is respected via apply_mount_root - rewrites the guest-facing path of the virtual root preopen (guest sees {mount-root} instead of /). Policy matching always on host paths.
1 parent ed0411c commit 13648c5

7 files changed

Lines changed: 728 additions & 69 deletions

File tree

Cargo.lock

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

act-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ http-body-util = "0.1"
5050
hyper = "1"
5151
bytes = "1"
5252
cidr = "0.3"
53+
globset = "0.4"
5354

5455
[dev-dependencies]
5556
tempfile = "3"

act-cli/src/config.rs

Lines changed: 16 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,22 @@ impl FsConfig {
5555
}
5656
}
5757

58-
/// Preopens derived from `allow` entries. Layer 1 supports literal path
59-
/// patterns and a trailing `/**` (meaning the directory and everything
60-
/// under it). Other glob syntax is rejected until the Layer 1 custom WASI
61-
/// impl lands and replaces preopens with per-op matching.
58+
/// Preopens for this policy. Layer 1 uses a single virtual-root preopen
59+
/// at host `/` for every non-`Deny` mode — the `FsMatcher` is the only
60+
/// enforcement point. The guest sees the whole host filesystem in its
61+
/// namespace but can't actually open anything the matcher denies.
62+
///
63+
/// Deny mode: no preopens at all (guest can't name anything).
64+
///
65+
/// This is Unix-first: preopening `/` assumes a POSIX root. Windows
66+
/// support requires preopening each drive separately and is deferred.
6267
pub fn preopens(&self) -> Result<Vec<DirMount>> {
6368
match self.mode {
6469
PolicyMode::Deny => Ok(vec![]),
65-
PolicyMode::Open => Ok(vec![DirMount {
70+
PolicyMode::Open | PolicyMode::Allowlist => Ok(vec![DirMount {
6671
guest: "/".to_string(),
6772
host: PathBuf::from("/"),
6873
}]),
69-
PolicyMode::Allowlist => self.allow.iter().map(|p| path_to_mount(p)).collect(),
7074
}
7175
}
7276
}
@@ -206,32 +210,6 @@ pub fn get_profile<'a>(config: &'a ConfigFile, name: &str) -> Result<&'a Profile
206210
.with_context(|| format!("profile '{}' not found in config", name))
207211
}
208212

209-
/// Expand `~` in a path string to the user's home directory.
210-
fn expand_path(s: &str) -> PathBuf {
211-
let expanded = shellexpand::tilde(s);
212-
PathBuf::from(expanded.as_ref())
213-
}
214-
215-
/// Convert an `allow` entry (literal path or `dir/**`) to a preopen mount.
216-
fn path_to_mount(pattern: &str) -> Result<DirMount> {
217-
let trimmed = pattern
218-
.strip_suffix("/**")
219-
.or_else(|| pattern.strip_suffix("/*"))
220-
.unwrap_or(pattern);
221-
if trimmed.contains('*') || trimmed.contains('?') || trimmed.contains('[') {
222-
anyhow::bail!(
223-
"unsupported glob in '{}'; Layer 1 allows literal paths and a trailing '/**' only",
224-
pattern
225-
);
226-
}
227-
let host = expand_path(trimmed);
228-
let guest = format!(
229-
"/{}",
230-
host.file_name().and_then(|n| n.to_str()).unwrap_or("")
231-
);
232-
Ok(DirMount { guest, host })
233-
}
234-
235213
// ── Resolution ──
236214

237215
/// CLI-provided policy overrides, collected in one place.
@@ -444,39 +422,21 @@ mod tests {
444422
let mounts = cfg.preopens().unwrap();
445423
assert_eq!(mounts.len(), 1);
446424
assert_eq!(mounts[0].host, PathBuf::from("/"));
425+
assert_eq!(mounts[0].guest, "/");
447426
}
448427

449428
#[test]
450-
fn fs_allowlist_literal_path() {
429+
fn fs_allowlist_uses_virtual_root() {
451430
let cfg = FsConfig {
452431
mode: PolicyMode::Allowlist,
453-
allow: vec!["/tmp/data".into()],
432+
allow: vec!["/tmp/data/**".into(), "~/projects/{foo,bar}/**".into()],
454433
..Default::default()
455434
};
456435
let mounts = cfg.preopens().unwrap();
436+
// Always a single virtual-root preopen; matcher handles the patterns.
457437
assert_eq!(mounts.len(), 1);
458-
assert_eq!(mounts[0].host, PathBuf::from("/tmp/data"));
459-
}
460-
461-
#[test]
462-
fn fs_allowlist_trailing_double_star() {
463-
let cfg = FsConfig {
464-
mode: PolicyMode::Allowlist,
465-
allow: vec!["/tmp/data/**".into()],
466-
..Default::default()
467-
};
468-
let mounts = cfg.preopens().unwrap();
469-
assert_eq!(mounts[0].host, PathBuf::from("/tmp/data"));
470-
}
471-
472-
#[test]
473-
fn fs_allowlist_rejects_mid_glob() {
474-
let cfg = FsConfig {
475-
mode: PolicyMode::Allowlist,
476-
allow: vec!["/tmp/*/x".into()],
477-
..Default::default()
478-
};
479-
assert!(cfg.preopens().is_err());
438+
assert_eq!(mounts[0].host, PathBuf::from("/"));
439+
assert_eq!(mounts[0].guest, "/");
480440
}
481441

482442
#[test]

act-cli/src/fs_matcher.rs

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
//! Layer 1 phase C1, part 1/2: glob matcher for filesystem policy.
2+
//!
3+
//! Compiles the resolved `FsConfig.allow` / `FsConfig.deny` lists into
4+
//! `GlobSet`s and answers "is this absolute host path allowed?" The matcher
5+
//! itself is hook-agnostic — it's consumed by the custom `HostDescriptor`
6+
//! wrapper (part 2/2) at `open_at` time.
7+
//!
8+
//! Path normalisation rules:
9+
//! - All patterns are canonicalised to absolute host paths at construction
10+
//! (`~` expansion, relative paths resolved against the current directory).
11+
//! - All paths passed to `decide` must be absolute host paths, already
12+
//! canonicalised (symlinks resolved, `..` collapsed). The wrapper handles
13+
//! canonicalisation before calling into the matcher.
14+
//! - Patterns accept `globset` syntax: `*`, `?`, `[...]`, `{a,b}`, `**`. A
15+
//! pattern ending in `/` or `/**` applies to the directory and everything
16+
//! below it.
17+
//!
18+
//! Decision rule:
19+
//! - Mode = Deny → always `Deny`.
20+
//! - Mode = Open → always `Allow`.
21+
//! - Mode = Allowlist → `Deny` if any deny pattern matches; else `Allow`
22+
//! if any allow pattern matches; else `Deny`.
23+
24+
use std::path::Path;
25+
26+
use anyhow::{Context, Result};
27+
use globset::{Glob, GlobSet, GlobSetBuilder};
28+
29+
use crate::config::{FsConfig, PolicyMode};
30+
31+
/// Result of a policy decision for one filesystem operation.
32+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33+
#[allow(dead_code)] // wired in phase C1 part 2 (HostDescriptor wrapper)
34+
pub enum FsDecision {
35+
Allow,
36+
Deny,
37+
}
38+
39+
/// Compiled glob sets ready to decide access for a given host path.
40+
#[derive(Debug, Clone)]
41+
#[allow(dead_code)] // wired in phase C1 part 2 (HostDescriptor wrapper)
42+
pub struct FsMatcher {
43+
mode: PolicyMode,
44+
allow: GlobSet,
45+
deny: GlobSet,
46+
}
47+
48+
impl FsMatcher {
49+
/// Compile a matcher from a resolved `FsConfig`.
50+
pub fn compile(cfg: &FsConfig) -> Result<Self> {
51+
Ok(Self {
52+
mode: cfg.mode,
53+
allow: compile_set("allow", &cfg.allow)?,
54+
deny: compile_set("deny", &cfg.deny)?,
55+
})
56+
}
57+
58+
/// Decide whether an absolute, canonical host path may be opened/touched.
59+
pub fn decide(&self, path: &Path) -> FsDecision {
60+
match self.mode {
61+
PolicyMode::Deny => FsDecision::Deny,
62+
PolicyMode::Open => FsDecision::Allow,
63+
PolicyMode::Allowlist => {
64+
if self.deny.is_match(path) {
65+
return FsDecision::Deny;
66+
}
67+
if self.allow.is_match(path) {
68+
FsDecision::Allow
69+
} else {
70+
FsDecision::Deny
71+
}
72+
}
73+
}
74+
}
75+
}
76+
77+
fn compile_set(label: &str, patterns: &[String]) -> Result<GlobSet> {
78+
let mut b = GlobSetBuilder::new();
79+
for p in patterns {
80+
let expanded = expand_pattern(p);
81+
let glob = Glob::new(&expanded)
82+
.with_context(|| format!("invalid {label} glob '{p}' (expanded: '{expanded}')"))?;
83+
b.add(glob);
84+
// A directory pattern like `/foo/bar` (no trailing `/**`) should also
85+
// match descendants, so add `/foo/bar/**` alongside.
86+
if !expanded.ends_with("/**") && !expanded.contains('*') && !expanded.contains('?') {
87+
let descendants = format!("{expanded}/**");
88+
let glob = Glob::new(&descendants).with_context(|| {
89+
format!("invalid derived {label} glob '{descendants}' from '{p}'")
90+
})?;
91+
b.add(glob);
92+
}
93+
}
94+
b.build()
95+
.with_context(|| format!("failed to build {label} glob set"))
96+
}
97+
98+
/// Expand `~` and make patterns absolute. Relative patterns are resolved
99+
/// against the current directory; patterns beginning with `~` expand against
100+
/// the home directory. `**` and other globset metacharacters are left intact.
101+
fn expand_pattern(pattern: &str) -> String {
102+
let expanded = shellexpand::tilde(pattern).into_owned();
103+
if Path::new(&expanded).is_absolute() {
104+
return expanded;
105+
}
106+
match std::env::current_dir() {
107+
Ok(cwd) => cwd.join(&expanded).to_string_lossy().into_owned(),
108+
Err(_) => expanded,
109+
}
110+
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
use super::*;
115+
use std::path::PathBuf;
116+
117+
fn cfg(mode: PolicyMode, allow: &[&str], deny: &[&str]) -> FsConfig {
118+
FsConfig {
119+
mode,
120+
allow: allow.iter().map(|s| s.to_string()).collect(),
121+
deny: deny.iter().map(|s| s.to_string()).collect(),
122+
}
123+
}
124+
125+
#[test]
126+
fn deny_mode_blocks_everything() {
127+
let m = FsMatcher::compile(&cfg(PolicyMode::Deny, &[], &[])).unwrap();
128+
assert_eq!(m.decide(&PathBuf::from("/tmp/anything")), FsDecision::Deny);
129+
}
130+
131+
#[test]
132+
fn open_mode_allows_everything() {
133+
let m = FsMatcher::compile(&cfg(PolicyMode::Open, &[], &[])).unwrap();
134+
assert_eq!(m.decide(&PathBuf::from("/etc/passwd")), FsDecision::Allow);
135+
}
136+
137+
#[test]
138+
fn allow_literal_path_matches_descendants() {
139+
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/work"], &[])).unwrap();
140+
assert_eq!(m.decide(&PathBuf::from("/tmp/work")), FsDecision::Allow);
141+
assert_eq!(
142+
m.decide(&PathBuf::from("/tmp/work/sub/file.txt")),
143+
FsDecision::Allow
144+
);
145+
assert_eq!(m.decide(&PathBuf::from("/tmp/other")), FsDecision::Deny);
146+
}
147+
148+
#[test]
149+
fn allow_trailing_double_star_matches_descendants() {
150+
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/work/**"], &[])).unwrap();
151+
assert_eq!(
152+
m.decide(&PathBuf::from("/tmp/work/sub/file.txt")),
153+
FsDecision::Allow
154+
);
155+
assert_eq!(m.decide(&PathBuf::from("/tmp/other")), FsDecision::Deny);
156+
}
157+
158+
#[test]
159+
fn deny_rules_beat_allow() {
160+
let m = FsMatcher::compile(&cfg(
161+
PolicyMode::Allowlist,
162+
&["/home/alex/**"],
163+
&["/home/alex/.ssh/**", "/home/alex/.aws/**"],
164+
))
165+
.unwrap();
166+
assert_eq!(
167+
m.decide(&PathBuf::from("/home/alex/project/main.rs")),
168+
FsDecision::Allow
169+
);
170+
assert_eq!(
171+
m.decide(&PathBuf::from("/home/alex/.ssh/id_rsa")),
172+
FsDecision::Deny
173+
);
174+
assert_eq!(
175+
m.decide(&PathBuf::from("/home/alex/.aws/credentials")),
176+
FsDecision::Deny
177+
);
178+
}
179+
180+
#[test]
181+
fn ripgrep_style_brace_expansion() {
182+
let m = FsMatcher::compile(&cfg(
183+
PolicyMode::Allowlist,
184+
&["/home/alex/{projects,work}/**"],
185+
&[],
186+
))
187+
.unwrap();
188+
assert_eq!(
189+
m.decide(&PathBuf::from("/home/alex/projects/foo/lib.rs")),
190+
FsDecision::Allow
191+
);
192+
assert_eq!(
193+
m.decide(&PathBuf::from("/home/alex/work/docs/README.md")),
194+
FsDecision::Allow
195+
);
196+
assert_eq!(
197+
m.decide(&PathBuf::from("/home/alex/Downloads/x")),
198+
FsDecision::Deny
199+
);
200+
}
201+
202+
#[test]
203+
fn extension_glob() {
204+
let m = FsMatcher::compile(&cfg(PolicyMode::Allowlist, &["/tmp/**/*.md"], &[])).unwrap();
205+
assert_eq!(
206+
m.decide(&PathBuf::from("/tmp/notes/today.md")),
207+
FsDecision::Allow
208+
);
209+
assert_eq!(
210+
m.decide(&PathBuf::from("/tmp/notes/secret.txt")),
211+
FsDecision::Deny
212+
);
213+
}
214+
}

0 commit comments

Comments
 (0)