Skip to main content

act_runtime/
fs_policy.rs

1//! Layer 1 phase C1, part 2/2: custom `wasi:filesystem` host impl that gates
2//! `open_at` (and path-taking siblings) on the `FsMatcher`.
3//!
4//! The host-facing surface:
5//! - `PolicyFilesystem` is a `HasData` marker used in place of the default
6//!   `wasmtime_wasi::WasiFilesystem` when adding the `wasi:filesystem/types`
7//!   and `wasi:filesystem/preopens` interfaces to the linker.
8//! - `PolicyFilesystemCtxView<'a>` bundles the default `WasiFilesystemCtx`,
9//!   the `ResourceTable`, the compiled `FsMatcher`, and a running map of
10//!   `fd → absolute host path`. It implements `preopens::Host`, `types::Host`,
11//!   `HostDescriptor`, and `HostDirectoryEntryStream`, mostly by delegating
12//!   to a temp `WasiFilesystemCtxView` constructed from the same fields.
13//! - Path-taking methods (`open_at`, `stat_at`, `readlink_at`,
14//!   `create_directory_at`, `remove_directory_at`, `unlink_file_at`,
15//!   `rename_at`, `link_at`, `symlink_at`, `metadata_hash_at`,
16//!   `set_times_at`) resolve the parent fd's host path, join the
17//!   guest-supplied relative path, canonicalise, and consult the matcher.
18//!   Deny → `ErrorCode::NotPermitted`; allow → delegate and (for `open_at`)
19//!   record the resulting fd's host path.
20//!
21//! fd→path tracking:
22//! - Preopens are recorded at construction (we know their host paths from
23//!   `derive_preopens` before calling `WasiCtxBuilder::preopened_dir`).
24//!   Their Resource reps aren't known at that point; we match reps to host
25//!   paths lazily the first time `get_directories()` is called.
26//! - New descriptors produced by `open_at` are recorded with the
27//!   canonicalised child path.
28
29use std::collections::HashMap;
30use std::path::PathBuf;
31use std::sync::Arc;
32
33use path_clean::PathClean;
34use wasmtime::component::{HasData, Resource, ResourceTable};
35use wasmtime_wasi::filesystem::{WasiFilesystemCtx, WasiFilesystemCtxView};
36use wasmtime_wasi::p2::bindings::filesystem::preopens;
37use wasmtime_wasi::p2::bindings::filesystem::types::{
38    self, ErrorCode, HostDescriptor, HostDirectoryEntryStream,
39};
40use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult};
41
42use act_types::{Capabilities, MountType};
43
44use act_policy::Decision;
45use act_policy::consent::{ConsentAsk, ConsentPrompter, DecisionCache};
46use act_policy::fs_matcher::FsAccess;
47use act_policy::grant::PolicyMode;
48use act_policy::provider::{CompiledCeiling, ResourceOp};
49
50use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
51
52// ── Mounts → preopens ─────────────────────────────────────────────────────
53
54/// A (guest path → host path) pair handed to wasmtime-wasi's `preopened_dir`.
55///
56/// Preopens are derived from the component's resolved mounts (see
57/// `resolve_mounts`): a `bind` mount preopens one host dir at its guest path,
58/// a `root` mount preopens the platform root(s). A component that declares
59/// only `bind` mounts sees ONLY those dirs (sandbox); the `FsMatcher` still
60/// gates per-op access on the host paths within them.
61#[derive(Debug, Clone, PartialEq)]
62pub struct Preopen {
63    pub guest: String,
64    pub host: PathBuf,
65}
66
67/// A resolved mount: concrete guest path + (for binds) an expanded host dir.
68#[derive(Debug, Clone, PartialEq)]
69pub struct ResolvedMount {
70    pub kind: MountType,
71    pub guest: String,
72    /// Expanded host directory for `bind`; `None` for `root`.
73    pub host: Option<PathBuf>,
74}
75
76/// Resolve a component's declared mounts into concrete topology.
77///
78/// Order: explicit `params.mounts`, then `mount-root` sugar (a `root` mount,
79/// skipped if an explicit `root` mount already exists), then a default `root`
80/// mount when nothing else is declared. Returns empty under `Deny` (the guest
81/// can name nothing).
82pub fn resolve_mounts(caps: &Capabilities, mode: PolicyMode) -> Vec<ResolvedMount> {
83    if mode == PolicyMode::Deny {
84        return Vec::new();
85    }
86    let declared = caps.fs_mounts().unwrap_or_else(|e| {
87        tracing::warn!(error = %e, "ignoring malformed wasi:filesystem mounts");
88        Vec::new()
89    });
90    let has_explicit_root = declared.iter().any(|m| m.kind == MountType::Root);
91
92    let mut out = Vec::new();
93    for m in &declared {
94        match m.kind {
95            MountType::Bind => {
96                if let (Some(g), Some(h)) = (m.guest.as_deref(), m.host.as_deref()) {
97                    out.push(ResolvedMount {
98                        kind: MountType::Bind,
99                        guest: g.to_string(),
100                        host: Some(expand_host_dir(h)),
101                    });
102                }
103            }
104            MountType::Root => out.push(ResolvedMount {
105                kind: MountType::Root,
106                guest: m.guest.as_deref().unwrap_or("/").to_string(),
107                host: None,
108            }),
109        }
110    }
111
112    // mount-root sugar → a root mount. "/" and "" are treated as no-ops (NOT a
113    // whole-fs root): a degenerate mount-root must never silently expose the
114    // entire host filesystem next to declared binds. Use an explicit
115    // `{type = "root"}` mount to combine whole-fs with binds.
116    if !has_explicit_root
117        && let Some(mr) = caps.fs_mount_root()
118        && mr != "/"
119        && !mr.is_empty()
120    {
121        out.push(ResolvedMount {
122            kind: MountType::Root,
123            guest: mr.to_string(),
124            host: None,
125        });
126    }
127
128    if out.is_empty() {
129        out.push(ResolvedMount {
130            kind: MountType::Root,
131            guest: "/".to_string(),
132            host: None,
133        });
134    }
135    out
136}
137
138/// Expand `~` and make a host directory path absolute (no glob handling — this
139/// is a directory, not a matcher pattern).
140fn expand_host_dir(s: &str) -> PathBuf {
141    let expanded = shellexpand::tilde(s).into_owned();
142    let p = PathBuf::from(&expanded);
143    if p.is_absolute() {
144        p
145    } else {
146        std::env::current_dir().map(|c| c.join(&p)).unwrap_or(p)
147    }
148}
149
150/// Build the preopen list from resolved mounts.
151pub fn derive_preopens(mounts: &[ResolvedMount]) -> Vec<Preopen> {
152    let mut out = Vec::new();
153    for m in mounts {
154        match m.kind {
155            MountType::Bind => {
156                if let Some(host) = &m.host {
157                    out.push(Preopen {
158                        guest: m.guest.clone(),
159                        host: host.clone(),
160                    });
161                }
162            }
163            MountType::Root => out.extend(root_preopens_under(&m.guest)),
164        }
165    }
166    out
167}
168
169/// Create missing `bind` host directories so they can be preopened. `root`
170/// mounts point at the platform root and create nothing.
171pub fn create_mount_dirs(mounts: &[ResolvedMount]) -> std::io::Result<()> {
172    for m in mounts {
173        if m.kind == MountType::Bind
174            && let Some(host) = &m.host
175        {
176            std::fs::create_dir_all(host)?;
177        }
178    }
179    Ok(())
180}
181
182#[cfg(unix)]
183fn root_preopens_under(guest: &str) -> Vec<Preopen> {
184    vec![Preopen {
185        guest: guest.to_string(),
186        host: PathBuf::from("/"),
187    }]
188}
189
190#[cfg(windows)]
191fn root_preopens_under(guest: &str) -> Vec<Preopen> {
192    let base = guest.trim_end_matches('/');
193    let mut out = Vec::new();
194    for letter in b'A'..=b'Z' {
195        let c = letter as char;
196        let host = PathBuf::from(format!("{}:\\", c));
197        // metadata trips DriveNotReady / access errors for absent drives;
198        // treat any failure as "skip this letter".
199        if std::fs::metadata(&host).is_ok() {
200            let g = if base.is_empty() {
201                format!("/{}", c.to_ascii_lowercase())
202            } else {
203                format!("{}/{}", base, c.to_ascii_lowercase())
204            };
205            out.push(Preopen { guest: g, host });
206        }
207    }
208    out
209}
210
211#[cfg(not(any(unix, windows)))]
212fn root_preopens_under(guest: &str) -> Vec<Preopen> {
213    vec![Preopen {
214        guest: guest.to_string(),
215        host: PathBuf::from("/"),
216    }]
217}
218
219// ── Wasmtime host impl ────────────────────────────────────────────────────
220
221/// `HasData` marker for our policy-aware filesystem view.
222pub struct PolicyFilesystem;
223
224impl HasData for PolicyFilesystem {
225    type Data<'a> = PolicyFilesystemCtxView<'a>;
226}
227
228/// Per-call view bundling all state the policy wrapper needs.
229pub struct PolicyFilesystemCtxView<'a> {
230    pub ctx: &'a mut WasiFilesystemCtx,
231    pub table: &'a mut ResourceTable,
232    pub ceiling: &'a Arc<dyn CompiledCeiling>,
233    pub fd_paths: &'a mut FdPathMap,
234    /// Configured mode; drives the p3 preopens kill-switch. p3 path-taking
235    /// ops can't be gated (upstream `Dir::open_at` is `pub(crate)`), so when
236    /// mode is anything but `Open` we return zero preopens from p3 and p3
237    /// guests can't acquire a `Descriptor::Dir` handle at all.
238    pub mode: PolicyMode,
239    /// Interactive-consent prompter, consulted when the ceiling returns
240    /// `Decision::Ask`. Shared across the store.
241    pub prompter: Arc<dyn ConsentPrompter>,
242    /// Per-session memory of ask decisions, keyed by `(cap-id, path)`.
243    pub cache: Arc<DecisionCache>,
244}
245
246/// Tracks the host path associated with each open filesystem descriptor,
247/// plus the configured preopen list (guest path → host path) used to fill
248/// in the map lazily the first time the guest calls `get-directories`.
249#[derive(Default, Debug)]
250pub struct FdPathMap {
251    pub preopens: Vec<(String, PathBuf)>,
252    pub by_rep: HashMap<u32, PathBuf>,
253}
254
255/// Sync matcher outcome for one path op. `Deny` is folded into the `Err` arm
256/// of `check_path_sync`; `Ask` carries owned `Arc` clones so the async prompt
257/// resolution never borrows the (`!Sync`) view.
258enum PathDecision {
259    Allow(PathBuf),
260    Ask {
261        canonical: PathBuf,
262        cache: Arc<DecisionCache>,
263        prompter: Arc<dyn ConsentPrompter>,
264    },
265}
266
267/// Resolve an `Ask`-mode filesystem decision via the interactive prompter
268/// (cached per canonical path). Free function over owned data so the returned
269/// future is `Send` (it captures only `Arc`s + a `PathBuf`, never the view).
270async fn resolve_ask(
271    cache: Arc<DecisionCache>,
272    prompter: Arc<dyn ConsentPrompter>,
273    canonical: PathBuf,
274) -> FsResult<PathBuf> {
275    let path = canonical.display().to_string();
276    let has_channel = prompter.has_channel();
277    let allowed = cache
278        .decide_cached(
279            &*prompter,
280            ConsentAsk {
281                cap_id: act_types::constants::CAP_FILESYSTEM.to_string(),
282                key: path.clone(),
283                summary: format!("filesystem access: {path}"),
284            },
285        )
286        .await;
287    emit_cap_decision(&CapDecisionRecord::answered(
288        act_types::constants::CAP_FILESYSTEM,
289        &path,
290        allowed,
291        has_channel,
292    ));
293    if allowed {
294        Ok(canonical)
295    } else {
296        Err(ErrorCode::NotPermitted.into())
297    }
298}
299
300impl PolicyFilesystemCtxView<'_> {
301    fn inner(&mut self) -> WasiFilesystemCtxView<'_> {
302        WasiFilesystemCtxView {
303            ctx: self.ctx,
304            table: self.table,
305        }
306    }
307
308    fn parent_path(&self, fd: &Resource<types::Descriptor>) -> Option<PathBuf> {
309        self.fd_paths.by_rep.get(&fd.rep()).cloned()
310    }
311
312    /// Resolve `(parent_fd, rel_path)` to an absolute canonical host path and
313    /// run it through the matcher. Returns `Ok(canonical)` on allow,
314    /// `Err(NotPermitted)` on deny. In `Ask` mode the matcher defers and we
315    /// resolve the verdict through the interactive consent prompter (cached
316    /// per path). Records the resolved path for the caller to associate with a
317    /// newly-opened fd if desired.
318    ///
319    /// This is the async entry point used by every path-taking method. It must
320    /// NOT hold a borrow of `self` across the `.await` (the host descriptor
321    /// futures must be `Send`, and `&PolicyFilesystemCtxView` is not `Sync`),
322    /// so it is a *sync* fn that performs the matcher decision (borrowing
323    /// `self`) and then returns a `Send` future that captures only owned data
324    /// (`Arc` clones + the path) — never `self`.
325    fn check_path(
326        &self,
327        parent_fd: &Resource<types::Descriptor>,
328        rel: &str,
329        access: FsAccess,
330    ) -> impl Future<Output = FsResult<PathBuf>> + Send + 'static {
331        let decision = self.check_path_sync(parent_fd, rel, access);
332        async move {
333            match decision? {
334                PathDecision::Allow(canonical) => Ok(canonical),
335                PathDecision::Ask {
336                    canonical,
337                    cache,
338                    prompter,
339                } => resolve_ask(cache, prompter, canonical).await,
340            }
341        }
342    }
343
344    /// Synchronous part of `check_path`: resolve + matcher decision. Borrows
345    /// `self` but never awaits, so the borrow ends before `check_path`'s await.
346    fn check_path_sync(
347        &self,
348        parent_fd: &Resource<types::Descriptor>,
349        rel: &str,
350        access: FsAccess,
351    ) -> FsResult<PathDecision> {
352        let Some(parent) = self.parent_path(parent_fd) else {
353            // Parent fd has no tracked path — belongs to an unknown preopen
354            // or was never witnessed. Deny conservatively.
355            tracing::warn!(fd = parent_fd.rep(), "fs policy: untracked parent fd");
356            return Err(ErrorCode::NotPermitted.into());
357        };
358        let canonical = parent.join(rel).clean();
359        let op = ResourceOp {
360            cap_id: act_types::constants::CAP_FILESYSTEM.to_string(),
361            key: canonical.display().to_string(),
362            action: if access == FsAccess::Write {
363                "write".to_string()
364            } else {
365                "read".to_string()
366            },
367            attrs: serde_json::Value::Null,
368        };
369        let explained = self.ceiling.classify_explained(&op);
370        let mode = self.ceiling.effective_mode().to_string();
371        match explained.decision {
372            Decision::Allow => {
373                emit_cap_decision(&CapDecisionRecord::statik(
374                    act_types::constants::CAP_FILESYSTEM,
375                    &op.key,
376                    &op.action,
377                    Decision4::Allow,
378                    &mode,
379                    explained.rule,
380                ));
381                Ok(PathDecision::Allow(canonical))
382            }
383            Decision::Deny => {
384                emit_cap_decision(&CapDecisionRecord::statik(
385                    act_types::constants::CAP_FILESYSTEM,
386                    &op.key,
387                    &op.action,
388                    Decision4::Deny,
389                    &mode,
390                    explained.rule,
391                ));
392                Err(ErrorCode::NotPermitted.into())
393            }
394            // Deliberately silent: `ask` has not resolved yet. The record is
395            // emitted in `resolve_ask` once the verdict exists.
396            Decision::Ask => Ok(PathDecision::Ask {
397                canonical,
398                cache: self.cache.clone(),
399                prompter: self.prompter.clone(),
400            }),
401        }
402    }
403
404    /// Called from `get_directories` on first use to align Resource reps with
405    /// the host paths we configured at preopen time.
406    fn populate_preopens(&mut self, entries: &[(Resource<types::Descriptor>, String)]) {
407        for (res, guest_path) in entries {
408            if self.fd_paths.by_rep.contains_key(&res.rep()) {
409                continue;
410            }
411            let Some(host) = self
412                .fd_paths
413                .preopens
414                .iter()
415                .find(|(g, _)| g == guest_path)
416                .map(|(_, h)| h.clone())
417            else {
418                continue;
419            };
420            self.fd_paths.by_rep.insert(res.rep(), host);
421        }
422    }
423}
424
425// ── preopens::Host ────────────────────────────────────────────────────────
426
427impl preopens::Host for PolicyFilesystemCtxView<'_> {
428    fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
429        let entries = self.inner().get_directories()?;
430        self.populate_preopens(&entries);
431        Ok(entries)
432    }
433}
434
435// ── types::Host ───────────────────────────────────────────────────────────
436
437impl types::Host for PolicyFilesystemCtxView<'_> {
438    fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
439        self.inner().convert_error_code(err)
440    }
441    fn filesystem_error_code(
442        &mut self,
443        err: Resource<wasmtime::Error>,
444    ) -> wasmtime::Result<Option<ErrorCode>> {
445        self.inner().filesystem_error_code(err)
446    }
447}
448
449// ── HostDescriptor ────────────────────────────────────────────────────────
450//
451// Every method delegates to `self.inner()` after a policy check on
452// path-taking methods. Non-path-taking methods operate on an already-opened
453// Resource<Descriptor>; access was granted at open_at time so no further
454// check is needed.
455
456impl HostDescriptor for PolicyFilesystemCtxView<'_> {
457    async fn advise(
458        &mut self,
459        fd: Resource<types::Descriptor>,
460        offset: types::Filesize,
461        len: types::Filesize,
462        advice: types::Advice,
463    ) -> FsResult<()> {
464        self.inner().advise(fd, offset, len, advice).await
465    }
466
467    async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
468        self.inner().sync_data(fd).await
469    }
470
471    async fn get_flags(
472        &mut self,
473        fd: Resource<types::Descriptor>,
474    ) -> FsResult<types::DescriptorFlags> {
475        self.inner().get_flags(fd).await
476    }
477
478    async fn get_type(
479        &mut self,
480        fd: Resource<types::Descriptor>,
481    ) -> FsResult<types::DescriptorType> {
482        self.inner().get_type(fd).await
483    }
484
485    async fn set_size(
486        &mut self,
487        fd: Resource<types::Descriptor>,
488        size: types::Filesize,
489    ) -> FsResult<()> {
490        self.inner().set_size(fd, size).await
491    }
492
493    async fn set_times(
494        &mut self,
495        fd: Resource<types::Descriptor>,
496        atim: types::NewTimestamp,
497        mtim: types::NewTimestamp,
498    ) -> FsResult<()> {
499        self.inner().set_times(fd, atim, mtim).await
500    }
501
502    async fn read(
503        &mut self,
504        fd: Resource<types::Descriptor>,
505        len: types::Filesize,
506        offset: types::Filesize,
507    ) -> FsResult<(Vec<u8>, bool)> {
508        self.inner().read(fd, len, offset).await
509    }
510
511    async fn write(
512        &mut self,
513        fd: Resource<types::Descriptor>,
514        buf: Vec<u8>,
515        offset: types::Filesize,
516    ) -> FsResult<types::Filesize> {
517        self.inner().write(fd, buf, offset).await
518    }
519
520    async fn read_directory(
521        &mut self,
522        fd: Resource<types::Descriptor>,
523    ) -> FsResult<Resource<types::DirectoryEntryStream>> {
524        self.inner().read_directory(fd).await
525    }
526
527    async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
528        self.inner().sync(fd).await
529    }
530
531    async fn create_directory_at(
532        &mut self,
533        fd: Resource<types::Descriptor>,
534        path: String,
535    ) -> FsResult<()> {
536        let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
537        self.inner().create_directory_at(fd, path).await
538    }
539
540    async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
541        self.inner().stat(fd).await
542    }
543
544    async fn stat_at(
545        &mut self,
546        fd: Resource<types::Descriptor>,
547        path_flags: types::PathFlags,
548        path: String,
549    ) -> FsResult<types::DescriptorStat> {
550        let _checked = self.check_path(&fd, &path, FsAccess::Read).await?;
551        self.inner().stat_at(fd, path_flags, path).await
552    }
553
554    async fn set_times_at(
555        &mut self,
556        fd: Resource<types::Descriptor>,
557        path_flags: types::PathFlags,
558        path: String,
559        atim: types::NewTimestamp,
560        mtim: types::NewTimestamp,
561    ) -> FsResult<()> {
562        let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
563        self.inner()
564            .set_times_at(fd, path_flags, path, atim, mtim)
565            .await
566    }
567
568    async fn link_at(
569        &mut self,
570        fd: Resource<types::Descriptor>,
571        old_path_flags: types::PathFlags,
572        old_path: String,
573        new_descriptor: Resource<types::Descriptor>,
574        new_path: String,
575    ) -> FsResult<()> {
576        let _old = self.check_path(&fd, &old_path, FsAccess::Read).await?;
577        let _new = self
578            .check_path(&new_descriptor, &new_path, FsAccess::Write)
579            .await?;
580        self.inner()
581            .link_at(fd, old_path_flags, old_path, new_descriptor, new_path)
582            .await
583    }
584
585    async fn open_at(
586        &mut self,
587        fd: Resource<types::Descriptor>,
588        path_flags: types::PathFlags,
589        path: String,
590        oflags: types::OpenFlags,
591        flags: types::DescriptorFlags,
592    ) -> FsResult<Resource<types::Descriptor>> {
593        let access = if flags.contains(types::DescriptorFlags::WRITE)
594            || flags.contains(types::DescriptorFlags::MUTATE_DIRECTORY)
595            || oflags.contains(types::OpenFlags::CREATE)
596            || oflags.contains(types::OpenFlags::TRUNCATE)
597            || oflags.contains(types::OpenFlags::EXCLUSIVE)
598        {
599            FsAccess::Write
600        } else {
601            FsAccess::Read
602        };
603        let canonical = self.check_path(&fd, &path, access).await?;
604        let new_fd = self
605            .inner()
606            .open_at(fd, path_flags, path, oflags, flags)
607            .await?;
608        self.fd_paths.by_rep.insert(new_fd.rep(), canonical);
609        Ok(new_fd)
610    }
611
612    fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
613        self.fd_paths.by_rep.remove(&fd.rep());
614        HostDescriptor::drop(&mut self.inner(), fd)
615    }
616
617    async fn readlink_at(
618        &mut self,
619        fd: Resource<types::Descriptor>,
620        path: String,
621    ) -> FsResult<String> {
622        let _checked = self.check_path(&fd, &path, FsAccess::Read).await?;
623        self.inner().readlink_at(fd, path).await
624    }
625
626    async fn remove_directory_at(
627        &mut self,
628        fd: Resource<types::Descriptor>,
629        path: String,
630    ) -> FsResult<()> {
631        let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
632        self.inner().remove_directory_at(fd, path).await
633    }
634
635    async fn rename_at(
636        &mut self,
637        fd: Resource<types::Descriptor>,
638        old_path: String,
639        new_fd: Resource<types::Descriptor>,
640        new_path: String,
641    ) -> FsResult<()> {
642        let _old = self.check_path(&fd, &old_path, FsAccess::Write).await?;
643        let _new = self.check_path(&new_fd, &new_path, FsAccess::Write).await?;
644        self.inner().rename_at(fd, old_path, new_fd, new_path).await
645    }
646
647    async fn symlink_at(
648        &mut self,
649        fd: Resource<types::Descriptor>,
650        src_path: String,
651        dest_path: String,
652    ) -> FsResult<()> {
653        let _checked = self.check_path(&fd, &dest_path, FsAccess::Write).await?;
654        self.inner().symlink_at(fd, src_path, dest_path).await
655    }
656
657    async fn unlink_file_at(
658        &mut self,
659        fd: Resource<types::Descriptor>,
660        path: String,
661    ) -> FsResult<()> {
662        let _checked = self.check_path(&fd, &path, FsAccess::Write).await?;
663        self.inner().unlink_file_at(fd, path).await
664    }
665
666    fn read_via_stream(
667        &mut self,
668        fd: Resource<types::Descriptor>,
669        offset: types::Filesize,
670    ) -> FsResult<Resource<DynInputStream>> {
671        self.inner().read_via_stream(fd, offset)
672    }
673
674    fn write_via_stream(
675        &mut self,
676        fd: Resource<types::Descriptor>,
677        offset: types::Filesize,
678    ) -> FsResult<Resource<DynOutputStream>> {
679        self.inner().write_via_stream(fd, offset)
680    }
681
682    fn append_via_stream(
683        &mut self,
684        fd: Resource<types::Descriptor>,
685    ) -> FsResult<Resource<DynOutputStream>> {
686        self.inner().append_via_stream(fd)
687    }
688
689    async fn is_same_object(
690        &mut self,
691        a: Resource<types::Descriptor>,
692        b: Resource<types::Descriptor>,
693    ) -> wasmtime::Result<bool> {
694        self.inner().is_same_object(a, b).await
695    }
696
697    async fn metadata_hash(
698        &mut self,
699        fd: Resource<types::Descriptor>,
700    ) -> FsResult<types::MetadataHashValue> {
701        self.inner().metadata_hash(fd).await
702    }
703
704    async fn metadata_hash_at(
705        &mut self,
706        fd: Resource<types::Descriptor>,
707        path_flags: types::PathFlags,
708        path: String,
709    ) -> FsResult<types::MetadataHashValue> {
710        let _checked = self.check_path(&fd, &path, FsAccess::Read).await?;
711        self.inner().metadata_hash_at(fd, path_flags, path).await
712    }
713}
714
715// ── p3 preopens kill-switch ───────────────────────────────────────────────
716//
717// We can't mirror the full p2 matcher on p3 because `Dir::open_at` and
718// friends are `pub(crate)` in wasmtime-wasi — shadowing `HostDescriptorWithStore`
719// would need to reproject the Accessor via a `U: WasiFilesystemView` bound
720// that the trait doesn't permit, and the sibling methods we'd need to call
721// directly (`dir.open_at`, `dir.as_dir`) are gated.
722//
723// Instead we gate at preopens: if fs mode is anything other than `Open`,
724// p3 `get_directories` returns an empty vec. A p3 guest with an empty
725// preopen list can't construct a `Descriptor::Dir` resource, so every
726// p3 path op fails before it reaches cap-std. Components that genuinely
727// need p3 filesystem access must run under `policy.filesystem = "open"`.
728
729impl wasmtime_wasi::p3::bindings::filesystem::preopens::Host for PolicyFilesystemCtxView<'_> {
730    fn get_directories(
731        &mut self,
732    ) -> wasmtime::Result<
733        Vec<(
734            Resource<wasmtime_wasi::p3::bindings::filesystem::types::Descriptor>,
735            String,
736        )>,
737    > {
738        if self.mode != PolicyMode::Open {
739            tracing::warn!(
740                mode = ?self.mode,
741                "p3 wasi:filesystem/preopens: returning empty; p3 path ops can't be matcher-gated",
742            );
743            return Ok(vec![]);
744        }
745        let mut inner = WasiFilesystemCtxView {
746            ctx: self.ctx,
747            table: self.table,
748        };
749        <WasiFilesystemCtxView as wasmtime_wasi::p3::bindings::filesystem::preopens::Host>::get_directories(&mut inner)
750    }
751}
752
753// ── HostDirectoryEntryStream ──────────────────────────────────────────────
754
755impl HostDirectoryEntryStream for PolicyFilesystemCtxView<'_> {
756    async fn read_directory_entry(
757        &mut self,
758        stream: Resource<types::DirectoryEntryStream>,
759    ) -> FsResult<Option<types::DirectoryEntry>> {
760        self.inner().read_directory_entry(stream).await
761    }
762
763    fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
764        HostDirectoryEntryStream::drop(&mut self.inner(), stream)
765    }
766}
767
768#[cfg(test)]
769mod mount_tests {
770    use super::*;
771    use act_policy::grant::PolicyMode;
772    use act_types::{Capabilities, CapabilityRequest, MountType};
773    use std::collections::BTreeMap;
774
775    fn caps_with_mounts(mounts: serde_json::Value) -> Capabilities {
776        let mut caps = Capabilities::default();
777        let mut params = BTreeMap::new();
778        params.insert("mounts".to_string(), mounts);
779        caps.0.insert(
780            "wasi:filesystem".into(),
781            CapabilityRequest {
782                params,
783                ..Default::default()
784            },
785        );
786        caps
787    }
788
789    #[test]
790    fn deny_mode_yields_no_mounts() {
791        let caps = caps_with_mounts(serde_json::json!([{ "guest": "/ows", "host": "/tmp/x" }]));
792        assert!(resolve_mounts(&caps, PolicyMode::Deny).is_empty());
793    }
794
795    #[test]
796    fn bind_only_component_gets_just_the_bind_preopen() {
797        let caps = caps_with_mounts(serde_json::json!([{ "guest": "/ows", "host": "/tmp/x" }]));
798        let mounts = resolve_mounts(&caps, PolicyMode::Ask);
799        let pre = derive_preopens(&mounts);
800        assert_eq!(pre.len(), 1);
801        assert_eq!(pre[0].guest, "/ows");
802        assert_eq!(pre[0].host, std::path::PathBuf::from("/tmp/x"));
803    }
804
805    #[test]
806    fn no_mounts_declared_defaults_to_root() {
807        let caps = Capabilities::default();
808        let mounts = resolve_mounts(&caps, PolicyMode::Allowlist);
809        assert_eq!(mounts.len(), 1);
810        assert_eq!(mounts[0].kind, MountType::Root);
811        assert_eq!(mounts[0].guest, "/");
812    }
813
814    #[cfg(unix)]
815    #[test]
816    fn root_mount_preopens_the_filesystem_root() {
817        let caps = caps_with_mounts(serde_json::json!([{ "type": "root", "guest": "/" }]));
818        let pre = derive_preopens(&resolve_mounts(&caps, PolicyMode::Open));
819        assert_eq!(pre.len(), 1);
820        assert_eq!(pre[0].guest, "/");
821        assert_eq!(pre[0].host, std::path::PathBuf::from("/"));
822    }
823
824    #[test]
825    fn mount_root_sugar_becomes_a_root_mount() {
826        let mut caps = Capabilities::default();
827        let mut params = BTreeMap::new();
828        params.insert("mount-root".to_string(), serde_json::json!("/data"));
829        caps.0.insert(
830            "wasi:filesystem".into(),
831            CapabilityRequest {
832                params,
833                ..Default::default()
834            },
835        );
836        let mounts = resolve_mounts(&caps, PolicyMode::Allowlist);
837        assert_eq!(mounts.len(), 1);
838        assert_eq!(mounts[0].kind, MountType::Root);
839        assert_eq!(mounts[0].guest, "/data");
840    }
841
842    #[test]
843    fn mount_root_slash_is_noop_with_binds() {
844        // A degenerate `mount-root = "/"` alongside binds must NOT silently add a
845        // whole-fs root mount — the guest gets only the declared bind.
846        let mut caps = Capabilities::default();
847        let mut params = BTreeMap::new();
848        params.insert(
849            "mounts".to_string(),
850            serde_json::json!([{ "guest": "/ows", "host": "/tmp/x" }]),
851        );
852        params.insert("mount-root".to_string(), serde_json::json!("/"));
853        caps.0.insert(
854            "wasi:filesystem".into(),
855            CapabilityRequest {
856                params,
857                ..Default::default()
858            },
859        );
860        let mounts = resolve_mounts(&caps, PolicyMode::Ask);
861        assert_eq!(mounts.len(), 1);
862        assert_eq!(mounts[0].kind, MountType::Bind);
863        assert_eq!(mounts[0].guest, "/ows");
864    }
865
866    #[test]
867    fn explicit_root_suppresses_mount_root_sugar() {
868        // An explicit `{type = "root"}` mount suppresses the mount-root sugar
869        // entirely — `/data` is NOT added.
870        let mut caps = Capabilities::default();
871        let mut params = BTreeMap::new();
872        params.insert(
873            "mounts".to_string(),
874            serde_json::json!([{ "type": "root", "guest": "/x" }]),
875        );
876        params.insert("mount-root".to_string(), serde_json::json!("/data"));
877        caps.0.insert(
878            "wasi:filesystem".into(),
879            CapabilityRequest {
880                params,
881                ..Default::default()
882            },
883        );
884        let mounts = resolve_mounts(&caps, PolicyMode::Allowlist);
885        assert_eq!(mounts.len(), 1);
886        assert_eq!(mounts[0].kind, MountType::Root);
887        assert_eq!(mounts[0].guest, "/x");
888    }
889
890    #[test]
891    fn tilde_in_bind_host_is_expanded() {
892        let caps = caps_with_mounts(serde_json::json!([{ "guest": "/ows", "host": "~/.ows" }]));
893        let mounts = resolve_mounts(&caps, PolicyMode::Ask);
894        let host = mounts[0].host.clone().unwrap();
895        assert!(host.is_absolute());
896        assert!(!host.to_string_lossy().starts_with('~'));
897    }
898
899    #[test]
900    fn create_mount_dirs_makes_bind_targets() {
901        let tmp = std::env::temp_dir().join(format!("act-mount-test-{}", std::process::id()));
902        let target = tmp.join("nested");
903        let mounts = vec![ResolvedMount {
904            kind: MountType::Bind,
905            guest: "/d".into(),
906            host: Some(target.clone()),
907        }];
908        create_mount_dirs(&mounts).unwrap();
909        assert!(target.is_dir());
910        std::fs::remove_dir_all(&tmp).ok();
911    }
912}
913
914#[cfg(test)]
915mod tests {
916    #[test]
917    fn fs_records_carry_the_matched_rule_and_a_reason_on_deny() {
918        // Pure record construction — no wasmtime store needed.
919        let allow = crate::audit::CapDecisionRecord::statik(
920            act_types::constants::CAP_FILESYSTEM,
921            "/data/app.db",
922            "read",
923            crate::audit::Decision4::Allow,
924            "allowlist",
925            Some("/data/**".into()),
926        );
927        assert_eq!(allow.cap_id, act_types::constants::CAP_FILESYSTEM);
928        assert_eq!(allow.rule.as_deref(), Some("/data/**"));
929        assert!(allow.reason.is_none());
930
931        let deny = crate::audit::CapDecisionRecord::statik(
932            act_types::constants::CAP_FILESYSTEM,
933            "/etc/passwd",
934            "read",
935            crate::audit::Decision4::Deny,
936            "allowlist",
937            None,
938        );
939        assert_eq!(deny.reason.as_deref(), Some("outside ceiling"));
940        assert_eq!(deny.actor, crate::audit::record::Actor::Static);
941    }
942
943    #[test]
944    fn ask_resolution_attributes_the_decision_to_the_user() {
945        let r = crate::audit::CapDecisionRecord::answered(
946            act_types::constants::CAP_FILESYSTEM,
947            "/home/u/.ssh/id_ed25519",
948            false,
949            true,
950        );
951        assert_eq!(r.decision, crate::audit::Decision4::AskDeny);
952        assert_eq!(r.actor, crate::audit::record::Actor::User);
953        assert_eq!(r.reason.as_deref(), Some("denied by user"));
954
955        let r = crate::audit::CapDecisionRecord::answered(
956            act_types::constants::CAP_FILESYSTEM,
957            "/home/u/notes.txt",
958            true,
959            true,
960        );
961        assert_eq!(r.decision, crate::audit::Decision4::AskAllow);
962        assert_eq!(r.actor, crate::audit::record::Actor::User);
963    }
964
965    #[test]
966    fn ask_resolution_with_no_channel_is_not_attributed_to_the_user() {
967        // M1: `resolve_ask` is called with a `DenyPrompter` in exactly this
968        // shape whenever a headless run has no interactive channel — see
969        // `fs_ask_resolution_reaches_the_audit_trail` in `audit_cli.rs` for
970        // the end-to-end version. Pinned here too, at the constructor, since
971        // this is the record `resolve_ask` actually builds.
972        let r = crate::audit::CapDecisionRecord::answered(
973            act_types::constants::CAP_FILESYSTEM,
974            "/home/u/.ssh/id_ed25519",
975            false,
976            false,
977        );
978        assert_eq!(r.decision, crate::audit::Decision4::AskDeny);
979        assert_ne!(r.actor, crate::audit::record::Actor::User);
980        assert_eq!(r.reason.as_deref(), Some("no prompt channel"));
981    }
982}
983
984#[cfg(test)]
985mod policy_tests {
986    use act_policy::Decision;
987    use act_policy::fs_matcher::{FsAccess, FsMatcher};
988    use act_policy::grant::{FsAllow, FsConfig, PolicyMode};
989    use act_types::FsMode;
990    use std::path::Path;
991
992    #[test]
993    fn ro_matcher_blocks_write_allows_read() {
994        let cfg = FsConfig {
995            mode: PolicyMode::Allowlist,
996            allow: vec![FsAllow {
997                glob: "/data/**".into(),
998                mode: FsMode::Ro,
999            }],
1000            deny: vec![],
1001        };
1002        let matcher = FsMatcher::compile(&cfg).unwrap();
1003        assert_eq!(
1004            matcher.decide(Path::new("/data/x.db"), FsAccess::Read),
1005            Decision::Allow
1006        );
1007        assert_eq!(
1008            matcher.decide(Path::new("/data/x.db"), FsAccess::Write),
1009            Decision::Deny
1010        );
1011    }
1012}