Skip to main content

everruns_host/
file_store_decorators.rs

1// Composable host `SessionFileSystem` decorators for policy enforcement.
2//
3// EVE-478: promoted from `examples/coding-cli` so any non-server embedder can
4// compose them on top of `RealDiskFileStore`. Three concerns are layered here:
5//
6//   * `PolicyFileStore` — apply portable workspace read/write policy to any
7//     provider selected by the runtime.
8//   * `WriteBlocklistFileStore` — reject writes/deletes inside vendored or
9//     build directories (`.git/`, `node_modules/`, `target/`, …) at any depth.
10//     Reads pass through.
11//   * `ApprovalGatingFileStore` — gate writes/deletes through an
12//     embedder-supplied async `FileApprovalGate`. Reads pass through. The
13//     embedder owns the UI / oneshot wiring; this crate only cares about the
14//     yes/no answer.
15
16use async_trait::async_trait;
17use everruns_core::WorkspacePolicy;
18use everruns_core::session_file::{
19    FileInfo, FileStat, GrepMatch, GrepOptions, GrepSearchResult, InitialFile, SessionFile,
20    build_grep_search_result,
21};
22use everruns_core::session_files::SessionFileSystem;
23use everruns_provider::error::{AgentLoopError, Result};
24use everruns_provider::typed_id::SessionId;
25use std::collections::{HashSet, VecDeque};
26use std::path::Component;
27use std::sync::Arc;
28
29const MAX_POLICY_WALK_ENTRIES: usize = 100_000;
30
31/// Default vendored / build directory names that `WriteBlocklistFileStore`
32/// rejects writes into. Embedders can override via `with_blocklist`.
33const DEFAULT_WRITE_BLOCKLIST: &[&str] = &[
34    ".git",
35    "node_modules",
36    "target",
37    "dist",
38    "build",
39    ".next",
40    ".venv",
41    "venv",
42    ".tox",
43    ".gradle",
44];
45
46/// Apply a backend-independent [`WorkspacePolicy`] to a session filesystem.
47///
48/// Every model-driven read, listing, search, write, create, and delete flows
49/// through the same policy regardless of the concrete provider. Starter-file
50/// seeding bypasses the policy because it is trusted application configuration;
51/// later model access to a seeded path is still checked normally. Custom
52/// providers must honor the canonical-path contract of
53/// [`SessionFileSystem::resolve_path`].
54pub struct PolicyFileStore {
55    inner: Arc<dyn SessionFileSystem>,
56    policy: WorkspacePolicy,
57}
58
59impl PolicyFileStore {
60    /// Wrap `inner` with `policy`.
61    pub fn new(inner: Arc<dyn SessionFileSystem>, policy: WorkspacePolicy) -> Self {
62        Self { inner, policy }
63    }
64
65    fn checked_path(&self, path: &str) -> Result<String> {
66        WorkspacePolicy::validate_path(path)
67            .map(|()| self.inner.resolve_path(path))
68            .map_err(|error| AgentLoopError::tool(error.to_string()))
69    }
70
71    fn check_read(&self, path: &str) -> Result<()> {
72        // THREAT[TM-FS-015]: every read spelling is normalized and checked at
73        // the shared filesystem seam before a provider sees it.
74        self.checked_path(path).and_then(|path| {
75            self.policy
76                .check_read(&path)
77                .map_err(|error| AgentLoopError::tool(error.to_string()))
78        })
79    }
80
81    fn check_write(&self, path: &str) -> Result<()> {
82        // THREAT[TM-FS-015]: deny precedence and protected-path defaults apply
83        // uniformly to every provider and every mutating capability.
84        self.checked_path(path).and_then(|path| {
85            self.policy
86                .check_write(&path)
87                .map_err(|error| AgentLoopError::tool(error.to_string()))
88        })
89    }
90
91    async fn check_recursive_delete(&self, session_id: SessionId, path: &str) -> Result<()> {
92        if !self.policy.permits_recursive_delete() {
93            return Err(AgentLoopError::tool(format!(
94                "workspace policy denied recursive delete of `{path}`; recursive deletion requires an explicit opt-in"
95            )));
96        }
97
98        let Some(target) = self.inner.stat_file(session_id, path).await? else {
99            return Ok(());
100        };
101        if !target.is_directory {
102            return Ok(());
103        }
104
105        // THREAT[TM-FS-015]: an opt-in to recursive deletion does not override
106        // denied descendants. Inspect through the inner provider so protected
107        // entries hidden from model-facing listings still block the delete.
108        // A same-user filesystem mutation can still race this preflight; host
109        // embedders that need a stronger boundary must add OS isolation.
110        let mut pending = VecDeque::from([target.path]);
111        let mut visited = HashSet::new();
112        let mut entries_seen = 0usize;
113        while let Some(directory) = pending.pop_front() {
114            if !visited.insert(directory.clone()) {
115                continue;
116            }
117            for entry in self.inner.list_directory(session_id, &directory).await? {
118                entries_seen += 1;
119                if entries_seen > MAX_POLICY_WALK_ENTRIES {
120                    return Err(AgentLoopError::tool(format!(
121                        "workspace policy stopped recursive delete after {MAX_POLICY_WALK_ENTRIES} entries"
122                    )));
123                }
124                self.check_write(&entry.path)?;
125                if entry.is_directory {
126                    pending.push_back(entry.path);
127                }
128            }
129        }
130        Ok(())
131    }
132}
133
134#[async_trait]
135impl SessionFileSystem for PolicyFileStore {
136    fn display_root(&self) -> String {
137        self.inner.display_root()
138    }
139
140    fn display_path(&self, path: &str) -> String {
141        self.inner.display_path(path)
142    }
143
144    fn resolve_path(&self, input: &str) -> String {
145        self.inner.resolve_path(input)
146    }
147
148    fn is_mount_resolver(&self) -> bool {
149        self.inner.is_mount_resolver()
150    }
151
152    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
153        self.check_read(path)?;
154        self.inner.read_file(session_id, path).await
155    }
156
157    async fn write_file(
158        &self,
159        session_id: SessionId,
160        path: &str,
161        content: &str,
162        encoding: &str,
163    ) -> Result<SessionFile> {
164        self.check_write(path)?;
165        self.inner
166            .write_file(session_id, path, content, encoding)
167            .await
168    }
169
170    async fn write_file_if_content_matches(
171        &self,
172        session_id: SessionId,
173        path: &str,
174        expected_content: &str,
175        expected_encoding: &str,
176        content: &str,
177        encoding: &str,
178    ) -> Result<Option<SessionFile>> {
179        self.check_write(path)?;
180        self.inner
181            .write_file_if_content_matches(
182                session_id,
183                path,
184                expected_content,
185                expected_encoding,
186                content,
187                encoding,
188            )
189            .await
190    }
191
192    async fn delete_file(
193        &self,
194        session_id: SessionId,
195        path: &str,
196        recursive: bool,
197    ) -> Result<bool> {
198        self.check_write(path)?;
199        if recursive {
200            self.check_recursive_delete(session_id, path).await?;
201        }
202        self.inner.delete_file(session_id, path, recursive).await
203    }
204
205    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
206        let canonical = self.checked_path(path)?;
207        if !self.policy.permits_read_traversal(&canonical) {
208            self.policy
209                .check_read(&canonical)
210                .map_err(|error| AgentLoopError::tool(error.to_string()))?;
211        }
212        let mut entries = self.inner.list_directory(session_id, path).await?;
213        entries.retain(|entry| {
214            self.checked_path(&entry.path).is_ok_and(|canonical| {
215                self.policy.permits_read(&canonical)
216                    || self.policy.permits_read_traversal(&canonical)
217            })
218        });
219        Ok(entries)
220    }
221
222    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
223        let canonical = self.checked_path(path)?;
224        if !self.policy.permits_read_traversal(&canonical) {
225            self.policy
226                .check_read(&canonical)
227                .map_err(|error| AgentLoopError::tool(error.to_string()))?;
228        }
229        self.inner.stat_file(session_id, path).await
230    }
231
232    async fn grep_files(
233        &self,
234        session_id: SessionId,
235        pattern: &str,
236        path_pattern: Option<&str>,
237    ) -> Result<Vec<GrepMatch>> {
238        let result = self
239            .grep_files_with_options(
240                session_id,
241                pattern,
242                &GrepOptions {
243                    path_pattern: path_pattern.map(ToString::to_string),
244                    ..GrepOptions::default()
245                },
246            )
247            .await?;
248        Ok(result.matches)
249    }
250
251    async fn grep_files_with_options(
252        &self,
253        session_id: SessionId,
254        pattern: &str,
255        options: &GrepOptions,
256    ) -> Result<GrepSearchResult> {
257        let regex = crate::grep_limits::build_regex(pattern)?;
258        crate::grep_limits::validate_path_pattern(options.path_pattern.as_deref())?;
259        let path_pattern = options
260            .path_pattern
261            .as_deref()
262            .map(everruns_core::session_path::GrepPathPattern::new)
263            .transpose()?;
264
265        // Walk through the policy-filtered listing surface and read only files
266        // the policy permits. Calling the provider's grep directly and
267        // filtering its output would still make it open denied files.
268        let mut pending = VecDeque::from(["/".to_string()]);
269        let mut visited = HashSet::new();
270        let mut text_files = Vec::new();
271        let mut total_scanned = 0usize;
272        let mut entries_seen = 0usize;
273        while let Some(directory) = pending.pop_front() {
274            if !visited.insert(directory.clone()) {
275                continue;
276            }
277            for entry in self.list_directory(session_id, &directory).await? {
278                entries_seen += 1;
279                if entries_seen > MAX_POLICY_WALK_ENTRIES {
280                    return Err(AgentLoopError::tool(format!(
281                        "workspace policy stopped grep after {MAX_POLICY_WALK_ENTRIES} entries"
282                    )));
283                }
284                if entry.is_directory {
285                    pending.push_back(entry.path);
286                    continue;
287                }
288                if path_pattern
289                    .as_ref()
290                    .is_some_and(|matcher| !matcher.is_match(&entry.path))
291                {
292                    continue;
293                }
294                let Some(file) = self.read_file(session_id, &entry.path).await? else {
295                    continue;
296                };
297                if file.encoding != "text" {
298                    continue;
299                }
300                let Some(content) = file.content else {
301                    continue;
302                };
303                if crate::grep_limits::account_scan(&mut total_scanned, content.len())? {
304                    text_files.push((entry.path, content));
305                }
306            }
307        }
308        Ok(build_grep_search_result(text_files, &regex, options))
309    }
310
311    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
312        self.check_write(path)?;
313        self.inner.create_directory(session_id, path).await
314    }
315
316    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
317        self.inner.seed_initial_file(session_id, file).await
318    }
319}
320
321/// Reject writes into vendored / build directories at any depth.
322///
323/// Reads, listings, stats, and greps pass through; only mutating operations
324/// (`write_file`, `delete_file`, `create_directory`, `seed_initial_file`,
325/// `write_file_if_content_matches`) check the blocklist.
326//
327// Non-generic over the wrapped store: we hold `Arc<dyn SessionFileSystem>`
328// rather than a generic `Arc<S>` so decorator stacks compose without
329// coherence gymnastics. The runtime only ever wraps one concrete store
330// (`RealDiskFileStore` today), so monomorphization wasn't earning anything.
331pub struct WriteBlocklistFileStore {
332    inner: Arc<dyn SessionFileSystem>,
333    blocklist: Vec<String>,
334}
335
336impl WriteBlocklistFileStore {
337    /// Wrap `inner` with the default defense-in-depth blocklist.
338    pub fn new(inner: Arc<dyn SessionFileSystem>) -> Self {
339        Self {
340            inner,
341            blocklist: DEFAULT_WRITE_BLOCKLIST
342                .iter()
343                .map(|s| s.to_string())
344                .collect(),
345        }
346    }
347
348    /// Wrap `inner` with a custom blocklist (replaces the default entirely).
349    pub fn with_blocklist(
350        inner: Arc<dyn SessionFileSystem>,
351        blocklist: impl IntoIterator<Item = impl Into<String>>,
352    ) -> Self {
353        Self {
354            inner,
355            blocklist: blocklist.into_iter().map(Into::into).collect(),
356        }
357    }
358
359    fn check(&self, path: &str) -> Result<()> {
360        let p = std::path::Path::new(path);
361        for comp in p.components() {
362            if let Component::Normal(name) = comp {
363                let s = name.to_string_lossy();
364                if self.blocklist.iter().any(|b| b == s.as_ref()) {
365                    return Err(AgentLoopError::tool(format!(
366                        "writes into `{s}/` are blocked; write blocklist rejected `{path}`"
367                    )));
368                }
369            }
370        }
371        Ok(())
372    }
373}
374
375#[async_trait]
376impl SessionFileSystem for WriteBlocklistFileStore {
377    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
378        self.inner.read_file(session_id, path).await
379    }
380
381    async fn write_file(
382        &self,
383        session_id: SessionId,
384        path: &str,
385        content: &str,
386        encoding: &str,
387    ) -> Result<SessionFile> {
388        self.check(path)?;
389        self.inner
390            .write_file(session_id, path, content, encoding)
391            .await
392    }
393
394    async fn write_file_if_content_matches(
395        &self,
396        session_id: SessionId,
397        path: &str,
398        expected_content: &str,
399        expected_encoding: &str,
400        content: &str,
401        encoding: &str,
402    ) -> Result<Option<SessionFile>> {
403        self.check(path)?;
404        self.inner
405            .write_file_if_content_matches(
406                session_id,
407                path,
408                expected_content,
409                expected_encoding,
410                content,
411                encoding,
412            )
413            .await
414    }
415
416    async fn delete_file(
417        &self,
418        session_id: SessionId,
419        path: &str,
420        recursive: bool,
421    ) -> Result<bool> {
422        self.check(path)?;
423        self.inner.delete_file(session_id, path, recursive).await
424    }
425
426    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
427        self.inner.list_directory(session_id, path).await
428    }
429
430    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
431        self.inner.stat_file(session_id, path).await
432    }
433
434    async fn grep_files(
435        &self,
436        session_id: SessionId,
437        pattern: &str,
438        path_pattern: Option<&str>,
439    ) -> Result<Vec<GrepMatch>> {
440        self.inner
441            .grep_files(session_id, pattern, path_pattern)
442            .await
443    }
444
445    async fn grep_files_with_options(
446        &self,
447        session_id: SessionId,
448        pattern: &str,
449        options: &GrepOptions,
450    ) -> Result<GrepSearchResult> {
451        self.inner
452            .grep_files_with_options(session_id, pattern, options)
453            .await
454    }
455
456    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
457        self.check(path)?;
458        self.inner.create_directory(session_id, path).await
459    }
460
461    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
462        self.check(&file.path)?;
463        self.inner.seed_initial_file(session_id, file).await
464    }
465
466    fn is_mount_resolver(&self) -> bool {
467        self.inner.is_mount_resolver()
468    }
469}
470
471/// Embedder-supplied approval callback used by [`ApprovalGatingFileStore`].
472///
473/// Implementations decide how to ask the user (TUI prompt, web confirmation,
474/// auto-approve, OS notification, etc.). The store passes raw paths and the
475/// before/after content for writes so the implementation can render diffs.
476#[async_trait]
477pub trait FileApprovalGate: Send + Sync {
478    /// Decide whether the proposed write may proceed.
479    ///
480    /// `before` is the inner store's current content, if any — `None` if the
481    /// file does not yet exist. `after` is the proposed new content.
482    async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool;
483
484    /// Decide whether the proposed delete may proceed.
485    async fn approve_delete(&self, path: &str, recursive: bool) -> bool;
486}
487
488/// Gate destructive operations through an embedder-supplied
489/// [`FileApprovalGate`]. Reads pass through.
490///
491/// For writes we always fetch the inner store's current content first so the
492/// approval prompt can show a diff. That's one extra read per write —
493/// acceptable for a coding agent where writes are rare and small. The
494/// `create_directory` and `seed_initial_file` paths are not gated: the
495/// subsequent `write_file` inside the created directory triggers the prompt,
496/// and seed files are embedder-supplied (not LLM-driven).
497pub struct ApprovalGatingFileStore {
498    inner: Arc<dyn SessionFileSystem>,
499    gate: Arc<dyn FileApprovalGate>,
500}
501
502impl ApprovalGatingFileStore {
503    pub fn new(inner: Arc<dyn SessionFileSystem>, gate: Arc<dyn FileApprovalGate>) -> Self {
504        Self { inner, gate }
505    }
506
507    /// Internal helper: gate a write given an already-known `before` content,
508    /// then write through the inner store.
509    ///
510    /// Used by [`Self::write_file`] for the unconditional-write path. The CAS
511    /// path ([`Self::write_file_if_content_matches`]) re-checks via the inner
512    /// store's CAS write after approval so it cannot use this helper.
513    async fn gated_write_with_before(
514        &self,
515        session_id: SessionId,
516        path: &str,
517        before: Option<String>,
518        content: &str,
519        encoding: &str,
520    ) -> Result<SessionFile> {
521        let approved = self.gate.approve_write(path, before, content).await;
522        if !approved {
523            return Err(AgentLoopError::tool(format!(
524                "user denied write to `{path}`"
525            )));
526        }
527        self.inner
528            .write_file(session_id, path, content, encoding)
529            .await
530    }
531}
532
533#[async_trait]
534impl SessionFileSystem for ApprovalGatingFileStore {
535    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
536        self.inner.read_file(session_id, path).await
537    }
538
539    async fn write_file(
540        &self,
541        session_id: SessionId,
542        path: &str,
543        content: &str,
544        encoding: &str,
545    ) -> Result<SessionFile> {
546        // Propagate inner read errors instead of silently treating them as
547        // "no prior content" — a permission error or transient I/O fault
548        // should surface, not be hidden behind the approval prompt.
549        let before = self
550            .inner
551            .read_file(session_id, path)
552            .await?
553            .and_then(|f| f.content);
554        self.gated_write_with_before(session_id, path, before, content, encoding)
555            .await
556    }
557
558    async fn write_file_if_content_matches(
559        &self,
560        session_id: SessionId,
561        path: &str,
562        expected_content: &str,
563        expected_encoding: &str,
564        content: &str,
565        encoding: &str,
566    ) -> Result<Option<SessionFile>> {
567        // Read existing, compare, then gate using the already-fetched content
568        // for the approval `before`. Avoids a second `read_file` on the
569        // successful-write path.
570        let Some(existing) = self.inner.read_file(session_id, path).await? else {
571            return Ok(None);
572        };
573        if existing.is_directory {
574            return Ok(None);
575        }
576        let current = existing.content.unwrap_or_default();
577        if current != expected_content || existing.encoding != expected_encoding {
578            return Ok(None);
579        }
580        let approved = self.gate.approve_write(path, Some(current), content).await;
581        if !approved {
582            return Err(AgentLoopError::tool(format!(
583                "user denied write to `{path}`"
584            )));
585        }
586
587        self.inner
588            .write_file_if_content_matches(
589                session_id,
590                path,
591                expected_content,
592                expected_encoding,
593                content,
594                encoding,
595            )
596            .await
597    }
598
599    async fn delete_file(
600        &self,
601        session_id: SessionId,
602        path: &str,
603        recursive: bool,
604    ) -> Result<bool> {
605        let approved = self.gate.approve_delete(path, recursive).await;
606        if !approved {
607            return Err(AgentLoopError::tool(format!(
608                "user denied delete of `{path}`"
609            )));
610        }
611        self.inner.delete_file(session_id, path, recursive).await
612    }
613
614    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
615        self.inner.list_directory(session_id, path).await
616    }
617
618    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
619        self.inner.stat_file(session_id, path).await
620    }
621
622    async fn grep_files(
623        &self,
624        session_id: SessionId,
625        pattern: &str,
626        path_pattern: Option<&str>,
627    ) -> Result<Vec<GrepMatch>> {
628        self.inner
629            .grep_files(session_id, pattern, path_pattern)
630            .await
631    }
632
633    async fn grep_files_with_options(
634        &self,
635        session_id: SessionId,
636        pattern: &str,
637        options: &GrepOptions,
638    ) -> Result<GrepSearchResult> {
639        self.inner
640            .grep_files_with_options(session_id, pattern, options)
641            .await
642    }
643
644    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
645        self.inner.create_directory(session_id, path).await
646    }
647
648    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
649        self.inner.seed_initial_file(session_id, file).await
650    }
651
652    fn is_mount_resolver(&self) -> bool {
653        self.inner.is_mount_resolver()
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use crate::in_memory::InMemorySessionFileStore;
661    use std::sync::Mutex;
662
663    fn sid() -> SessionId {
664        "session_00000000000000000000000000000001".parse().unwrap()
665    }
666
667    fn inner() -> Arc<dyn SessionFileSystem> {
668        Arc::new(InMemorySessionFileStore::new())
669    }
670
671    #[tokio::test]
672    async fn write_blocklist_rejects_blocked_paths() {
673        let store = WriteBlocklistFileStore::new(inner());
674        let err = store
675            .write_file(sid(), "/.git/config", "bad", "text")
676            .await
677            .expect_err("write into .git must be rejected");
678        assert!(format!("{err}").contains(".git"));
679    }
680
681    #[tokio::test]
682    async fn write_blocklist_allows_unblocked_paths() {
683        let store = WriteBlocklistFileStore::new(inner());
684        store
685            .write_file(sid(), "/src/main.rs", "fn main() {}", "text")
686            .await
687            .expect("write outside blocklist must succeed");
688    }
689
690    #[tokio::test]
691    async fn write_blocklist_reads_pass_through_blocked() {
692        // Seed via the inner store directly, then verify read works through
693        // the decorator even though the path is in the blocklist.
694        let inner_store: Arc<dyn SessionFileSystem> = inner();
695        inner_store
696            .write_file(sid(), "/.git/config", "settings", "text")
697            .await
698            .unwrap();
699        let store = WriteBlocklistFileStore::new(inner_store);
700        let file = store
701            .read_file(sid(), "/.git/config")
702            .await
703            .unwrap()
704            .expect("read through blocklist must succeed");
705        assert_eq!(file.content.as_deref(), Some("settings"));
706    }
707
708    #[tokio::test]
709    async fn write_blocklist_contextual_grep_passes_through() {
710        let inner_store: Arc<dyn SessionFileSystem> = inner();
711        inner_store
712            .write_file(
713                sid(),
714                "/output.log",
715                "before\nError: failed\nROOT_CAUSE=duplicate_sentinel\nafter\n",
716                "text",
717            )
718            .await
719            .unwrap();
720        let store = WriteBlocklistFileStore::new(inner_store);
721
722        let result = store
723            .grep_files_with_options(
724                sid(),
725                "Error|failed",
726                &GrepOptions {
727                    before_context: 1,
728                    after_context: 1,
729                    ..GrepOptions::default()
730                },
731            )
732            .await
733            .expect("contextual grep must pass through the decorator");
734
735        assert_eq!(result.returned_matches, 1);
736        assert_eq!(result.blocks.len(), 1);
737        assert_eq!(result.blocks[0].start_line, 1);
738        assert_eq!(result.blocks[0].end_line, 3);
739    }
740
741    #[tokio::test]
742    async fn write_blocklist_custom_overrides_default() {
743        let store = WriteBlocklistFileStore::with_blocklist(inner(), ["forbidden"]);
744        // Default-blocked path is now allowed.
745        store
746            .write_file(sid(), "/.git/config", "ok", "text")
747            .await
748            .expect("custom blocklist replaces default");
749        // Custom-blocked path is rejected.
750        let err = store
751            .write_file(sid(), "/forbidden/x", "no", "text")
752            .await
753            .expect_err("custom blocklist entry must be enforced");
754        assert!(format!("{err}").contains("forbidden"));
755    }
756
757    #[tokio::test]
758    async fn workspace_policy_filters_reads_listings_and_grep_summaries() {
759        let inner_store: Arc<dyn SessionFileSystem> = inner();
760        for (path, content) in [
761            ("/src/lib.rs", "visible sentinel"),
762            ("/.env", "hidden sentinel"),
763            ("/private/secret.txt", "private sentinel"),
764        ] {
765            inner_store
766                .write_file(sid(), path, content, "text")
767                .await
768                .unwrap();
769        }
770        let policy = WorkspacePolicy::builder()
771            .allow_read("/")
772            .deny_read("private")
773            .build()
774            .unwrap();
775        let store = PolicyFileStore::new(inner_store, policy);
776
777        assert!(store.read_file(sid(), "/src/lib.rs").await.is_ok());
778        assert!(store.read_file(sid(), "/.env").await.is_err());
779        assert!(store.read_file(sid(), "/private/secret.txt").await.is_err());
780
781        let listed = store.list_directory(sid(), "/").await.unwrap();
782        assert_eq!(
783            listed
784                .iter()
785                .map(|entry| entry.path.as_str())
786                .collect::<Vec<_>>(),
787            vec!["/src"]
788        );
789
790        let result = store
791            .grep_files_with_options(sid(), "sentinel", &GrepOptions::default())
792            .await
793            .unwrap();
794        assert_eq!(result.returned_matches, 1);
795        assert_eq!(result.total_matches, 1);
796        assert_eq!(result.matches[0].path, "/src/lib.rs");
797    }
798
799    #[tokio::test]
800    async fn workspace_policy_enforces_write_scope_deny_precedence_and_recursive_opt_in() {
801        let policy = WorkspacePolicy::builder()
802            .allow_read("/")
803            .allow_write("output")
804            .deny_write("output/locked")
805            .allow_recursive_delete(false)
806            .build()
807            .unwrap();
808        let store = PolicyFileStore::new(inner(), policy);
809
810        store
811            .write_file(sid(), "/output/report.txt", "ok", "text")
812            .await
813            .unwrap();
814        assert!(
815            store
816                .write_file(sid(), "/output/locked/report.txt", "no", "text")
817                .await
818                .is_err()
819        );
820        assert!(
821            store
822                .write_file(sid(), "/src/lib.rs", "no", "text")
823                .await
824                .is_err()
825        );
826        assert!(store.delete_file(sid(), "/output", true).await.is_err());
827    }
828
829    #[tokio::test]
830    async fn recursive_delete_opt_in_does_not_override_a_denied_descendant() {
831        let inner_store: Arc<dyn SessionFileSystem> = inner();
832        inner_store
833            .write_file(sid(), "/output/report.txt", "ok", "text")
834            .await
835            .unwrap();
836        inner_store
837            .write_file(sid(), "/output/locked/secret.txt", "no", "text")
838            .await
839            .unwrap();
840        let policy = WorkspacePolicy::builder()
841            .allow_write("output")
842            .deny_write("output/locked")
843            .allow_recursive_delete(true)
844            .build()
845            .unwrap();
846        let store = PolicyFileStore::new(inner_store.clone(), policy);
847
848        let error = store.delete_file(sid(), "/output", true).await.unwrap_err();
849        assert!(error.to_string().contains("/workspace/output/locked"));
850        assert!(
851            inner_store
852                .read_file(sid(), "/output/report.txt")
853                .await
854                .unwrap()
855                .is_some()
856        );
857    }
858
859    #[tokio::test]
860    async fn trusted_seed_bypasses_policy_but_later_access_does_not() {
861        let store = PolicyFileStore::new(inner(), WorkspacePolicy::default());
862        store
863            .seed_initial_file(
864                sid(),
865                &InitialFile {
866                    path: "/.env".to_string(),
867                    content: "TOKEN=secret".to_string(),
868                    encoding: "text".to_string(),
869                    is_readonly: true,
870                },
871            )
872            .await
873            .unwrap();
874        assert!(store.read_file(sid(), "/.env").await.is_err());
875    }
876
877    #[tokio::test]
878    async fn workspace_policy_rejects_traversal_before_backend_access() {
879        let store = PolicyFileStore::new(inner(), WorkspacePolicy::read_write());
880        let error = store
881            .write_file(sid(), "/workspace/src/../../outside", "no", "text")
882            .await
883            .unwrap_err();
884        assert!(error.to_string().contains("traversal"));
885    }
886
887    #[cfg(unix)]
888    #[tokio::test]
889    async fn host_symlink_swap_between_operations_is_rejected() {
890        use std::os::unix::fs::symlink;
891
892        let workspace = tempfile::tempdir().unwrap();
893        let outside = tempfile::tempdir().unwrap();
894        std::fs::create_dir(workspace.path().join("current")).unwrap();
895        std::fs::write(outside.path().join("secret.txt"), "outside").unwrap();
896        let inner: Arc<dyn SessionFileSystem> =
897            Arc::new(crate::real_disk::RealDiskFileStore::new(workspace.path()).unwrap());
898        let store = PolicyFileStore::new(inner, WorkspacePolicy::read_write());
899
900        store
901            .write_file(sid(), "/current/safe.txt", "inside", "text")
902            .await
903            .unwrap();
904        std::fs::remove_dir_all(workspace.path().join("current")).unwrap();
905        symlink(outside.path(), workspace.path().join("current")).unwrap();
906
907        let error = store
908            .read_file(sid(), "/current/secret.txt")
909            .await
910            .unwrap_err();
911        assert!(error.to_string().contains("symlink"));
912    }
913
914    #[tokio::test]
915    async fn host_absolute_path_outside_root_cannot_expose_host_file() {
916        let workspace = tempfile::tempdir().unwrap();
917        let outside = tempfile::NamedTempFile::new().unwrap();
918        std::fs::write(outside.path(), "outside").unwrap();
919        let inner: Arc<dyn SessionFileSystem> =
920            Arc::new(crate::real_disk::RealDiskFileStore::new(workspace.path()).unwrap());
921        let store = PolicyFileStore::new(inner, WorkspacePolicy::read_write());
922
923        match store
924            .read_file(sid(), outside.path().to_str().unwrap())
925            .await
926        {
927            Ok(None) | Err(_) => {}
928            Ok(Some(file)) => assert_ne!(
929                file.content.as_deref(),
930                Some("outside"),
931                "an absolute path outside the root must not expose that host file"
932            ),
933        }
934    }
935
936    #[tokio::test]
937    async fn host_absolute_alias_cannot_bypass_canonical_deny_scope() {
938        let workspace = tempfile::tempdir().unwrap();
939        std::fs::create_dir(workspace.path().join("private")).unwrap();
940        let secret = workspace.path().join("private/secret.txt");
941        std::fs::write(&secret, "secret").unwrap();
942        let inner: Arc<dyn SessionFileSystem> =
943            Arc::new(crate::real_disk::RealDiskFileStore::new(workspace.path()).unwrap());
944        let policy = WorkspacePolicy::builder()
945            .allow_read("/")
946            .deny_read("private")
947            .build()
948            .unwrap();
949        let store = PolicyFileStore::new(inner, policy);
950
951        // `RealDiskFileStore::display_root` exposes the canonical host root, so
952        // use the same spelling a model could echo back from tool output.
953        let canonical_secret = secret.canonicalize().unwrap();
954        let error = store
955            .read_file(sid(), canonical_secret.to_str().unwrap())
956            .await
957            .unwrap_err();
958        let message = error.to_string();
959        assert!(
960            message.contains("/workspace/private/secret.txt"),
961            "unexpected policy diagnostic: {message}"
962        );
963    }
964
965    struct RecordingGate {
966        approve: bool,
967        writes: Mutex<Vec<(String, Option<String>, String)>>,
968        deletes: Mutex<Vec<(String, bool)>>,
969    }
970
971    impl RecordingGate {
972        fn new(approve: bool) -> Self {
973            Self {
974                approve,
975                writes: Mutex::new(Vec::new()),
976                deletes: Mutex::new(Vec::new()),
977            }
978        }
979    }
980
981    #[async_trait]
982    impl FileApprovalGate for RecordingGate {
983        async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool {
984            self.writes
985                .lock()
986                .unwrap()
987                .push((path.to_string(), before, after.to_string()));
988            self.approve
989        }
990
991        async fn approve_delete(&self, path: &str, recursive: bool) -> bool {
992            self.deletes
993                .lock()
994                .unwrap()
995                .push((path.to_string(), recursive));
996            self.approve
997        }
998    }
999
1000    #[tokio::test]
1001    async fn approval_gating_denies_write_when_user_rejects() {
1002        let gate = Arc::new(RecordingGate::new(false));
1003        let store = ApprovalGatingFileStore::new(inner(), gate.clone());
1004        let err = store
1005            .write_file(sid(), "/notes.txt", "new", "text")
1006            .await
1007            .expect_err("rejected write must surface as tool error");
1008        assert!(format!("{err}").contains("denied"));
1009        assert_eq!(gate.writes.lock().unwrap().len(), 1);
1010    }
1011
1012    #[tokio::test]
1013    async fn approval_gating_approves_write_and_passes_before_after() {
1014        let inner_store: Arc<dyn SessionFileSystem> = inner();
1015        inner_store
1016            .write_file(sid(), "/notes.txt", "original", "text")
1017            .await
1018            .unwrap();
1019        let gate = Arc::new(RecordingGate::new(true));
1020        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
1021        let file = store
1022            .write_file(sid(), "/notes.txt", "updated", "text")
1023            .await
1024            .expect("approved write must succeed");
1025        assert_eq!(file.content.as_deref(), Some("updated"));
1026        let writes = gate.writes.lock().unwrap();
1027        assert_eq!(writes.len(), 1);
1028        assert_eq!(writes[0].0, "/notes.txt");
1029        assert_eq!(writes[0].1.as_deref(), Some("original"));
1030        assert_eq!(writes[0].2, "updated");
1031    }
1032
1033    #[tokio::test]
1034    async fn approval_gating_denies_delete_when_user_rejects() {
1035        let inner_store: Arc<dyn SessionFileSystem> = inner();
1036        inner_store
1037            .write_file(sid(), "/scratch.txt", "x", "text")
1038            .await
1039            .unwrap();
1040        let gate = Arc::new(RecordingGate::new(false));
1041        let store = ApprovalGatingFileStore::new(inner_store, gate);
1042        let err = store
1043            .delete_file(sid(), "/scratch.txt", false)
1044            .await
1045            .expect_err("rejected delete must surface as tool error");
1046        assert!(format!("{err}").contains("denied"));
1047    }
1048
1049    #[tokio::test]
1050    async fn approval_gating_reads_pass_through_without_prompt() {
1051        let inner_store: Arc<dyn SessionFileSystem> = inner();
1052        inner_store
1053            .write_file(sid(), "/notes.txt", "hi", "text")
1054            .await
1055            .unwrap();
1056        let gate = Arc::new(RecordingGate::new(false));
1057        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
1058        let file = store.read_file(sid(), "/notes.txt").await.unwrap();
1059        assert_eq!(file.unwrap().content.as_deref(), Some("hi"));
1060        assert!(gate.writes.lock().unwrap().is_empty());
1061    }
1062
1063    #[tokio::test]
1064    async fn approval_gating_contextual_grep_passes_through_without_prompt() {
1065        let inner_store: Arc<dyn SessionFileSystem> = inner();
1066        inner_store
1067            .write_file(
1068                sid(),
1069                "/output.log",
1070                "before\nError: failed\nROOT_CAUSE=duplicate_sentinel\nafter\n",
1071                "text",
1072            )
1073            .await
1074            .unwrap();
1075        let gate = Arc::new(RecordingGate::new(false));
1076        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
1077
1078        let result = store
1079            .grep_files_with_options(
1080                sid(),
1081                "Error|failed",
1082                &GrepOptions {
1083                    before_context: 1,
1084                    after_context: 1,
1085                    ..GrepOptions::default()
1086                },
1087            )
1088            .await
1089            .expect("contextual grep must pass through the decorator");
1090
1091        assert_eq!(result.returned_matches, 1);
1092        assert_eq!(result.blocks.len(), 1);
1093        assert!(gate.writes.lock().unwrap().is_empty());
1094        assert!(gate.deletes.lock().unwrap().is_empty());
1095    }
1096
1097    #[tokio::test]
1098    async fn write_if_content_matches_takes_one_approval_per_write() {
1099        let inner_store: Arc<dyn SessionFileSystem> = inner();
1100        inner_store
1101            .write_file(sid(), "/notes.txt", "original", "text")
1102            .await
1103            .unwrap();
1104        let gate = Arc::new(RecordingGate::new(true));
1105        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
1106
1107        let result = store
1108            .write_file_if_content_matches(
1109                sid(),
1110                "/notes.txt",
1111                "original",
1112                "text",
1113                "updated",
1114                "text",
1115            )
1116            .await
1117            .unwrap();
1118        assert!(result.is_some());
1119        assert_eq!(gate.writes.lock().unwrap().len(), 1);
1120    }
1121
1122    #[tokio::test]
1123    async fn write_if_content_matches_with_stale_expected_returns_none_without_prompt() {
1124        let inner_store: Arc<dyn SessionFileSystem> = inner();
1125        inner_store
1126            .write_file(sid(), "/notes.txt", "actual", "text")
1127            .await
1128            .unwrap();
1129        let gate = Arc::new(RecordingGate::new(true));
1130        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
1131
1132        let result = store
1133            .write_file_if_content_matches(
1134                sid(),
1135                "/notes.txt",
1136                "stale-expected",
1137                "text",
1138                "new",
1139                "text",
1140            )
1141            .await
1142            .unwrap();
1143        assert!(result.is_none());
1144        assert!(gate.writes.lock().unwrap().is_empty());
1145    }
1146
1147    struct MutatingGate {
1148        inner: Arc<dyn SessionFileSystem>,
1149    }
1150
1151    #[async_trait]
1152    impl FileApprovalGate for MutatingGate {
1153        async fn approve_write(&self, _path: &str, _before: Option<String>, _after: &str) -> bool {
1154            self.inner
1155                .write_file(sid(), "/notes.txt", "intruder", "text")
1156                .await
1157                .unwrap();
1158            true
1159        }
1160
1161        async fn approve_delete(&self, _path: &str, _recursive: bool) -> bool {
1162            true
1163        }
1164    }
1165
1166    #[tokio::test]
1167    async fn write_if_content_matches_rechecks_after_approval() {
1168        let inner_store: Arc<dyn SessionFileSystem> = inner();
1169        inner_store
1170            .write_file(sid(), "/notes.txt", "original", "text")
1171            .await
1172            .unwrap();
1173        let gate = Arc::new(MutatingGate {
1174            inner: inner_store.clone(),
1175        });
1176        let store = ApprovalGatingFileStore::new(inner_store.clone(), gate);
1177
1178        let result = store
1179            .write_file_if_content_matches(
1180                sid(),
1181                "/notes.txt",
1182                "original",
1183                "text",
1184                "updated",
1185                "text",
1186            )
1187            .await
1188            .unwrap();
1189
1190        assert!(result.is_none());
1191        let final_file = inner_store
1192            .read_file(sid(), "/notes.txt")
1193            .await
1194            .unwrap()
1195            .unwrap();
1196        assert_eq!(final_file.content.as_deref(), Some("intruder"));
1197    }
1198}