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