Skip to main content

everruns_runtime/
file_store_decorators.rs

1// Composable `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`. Two concerns are layered here:
5//
6//   * `WriteBlocklistFileStore` — reject writes/deletes inside vendored or
7//     build directories (`.git/`, `node_modules/`, `target/`, …) at any depth.
8//     Reads pass through.
9//   * `ApprovalGatingFileStore` — gate writes/deletes through an
10//     embedder-supplied async `FileApprovalGate`. Reads pass through. The
11//     embedder owns the UI / oneshot wiring; this crate only cares about the
12//     yes/no answer.
13//
14// Compose as `ApprovalGatingFileStore::new(WriteBlocklistFileStore::new(real_disk), gate)`.
15// Reads short-circuit through both layers; only the destructive paths take
16// the policy decisions.
17
18use async_trait::async_trait;
19use everruns_core::error::{AgentLoopError, Result};
20use everruns_core::session_file::{
21    FileInfo, FileStat, GrepMatch, GrepOptions, GrepSearchResult, InitialFile, SessionFile,
22};
23use everruns_core::traits::SessionFileSystem;
24use everruns_core::typed_id::SessionId;
25use std::path::Component;
26use std::sync::Arc;
27
28/// Default vendored / build directory names that `WriteBlocklistFileStore`
29/// rejects writes into. Embedders can override via `with_blocklist`.
30pub const DEFAULT_WRITE_BLOCKLIST: &[&str] = &[
31    ".git",
32    "node_modules",
33    "target",
34    "dist",
35    "build",
36    ".next",
37    ".venv",
38    "venv",
39    ".tox",
40    ".gradle",
41];
42
43/// Reject writes into vendored / build directories at any depth.
44///
45/// Reads, listings, stats, and greps pass through; only mutating operations
46/// (`write_file`, `delete_file`, `create_directory`, `seed_initial_file`,
47/// `write_file_if_content_matches`) check the blocklist.
48//
49// Non-generic over the wrapped store: we hold `Arc<dyn SessionFileSystem>`
50// rather than a generic `Arc<S>` so decorator stacks compose without
51// coherence gymnastics. The runtime only ever wraps one concrete store
52// (`RealDiskFileStore` today), so monomorphization wasn't earning anything.
53pub struct WriteBlocklistFileStore {
54    inner: Arc<dyn SessionFileSystem>,
55    blocklist: Vec<String>,
56}
57
58impl WriteBlocklistFileStore {
59    /// Wrap `inner` with the [`DEFAULT_WRITE_BLOCKLIST`].
60    pub fn new(inner: Arc<dyn SessionFileSystem>) -> Self {
61        Self {
62            inner,
63            blocklist: DEFAULT_WRITE_BLOCKLIST
64                .iter()
65                .map(|s| s.to_string())
66                .collect(),
67        }
68    }
69
70    /// Wrap `inner` with a custom blocklist (replaces the default entirely).
71    pub fn with_blocklist(
72        inner: Arc<dyn SessionFileSystem>,
73        blocklist: impl IntoIterator<Item = impl Into<String>>,
74    ) -> Self {
75        Self {
76            inner,
77            blocklist: blocklist.into_iter().map(Into::into).collect(),
78        }
79    }
80
81    fn check(&self, path: &str) -> Result<()> {
82        let p = std::path::Path::new(path);
83        for comp in p.components() {
84            if let Component::Normal(name) = comp {
85                let s = name.to_string_lossy();
86                if self.blocklist.iter().any(|b| b == s.as_ref()) {
87                    return Err(AgentLoopError::tool(format!(
88                        "writes into `{s}/` are blocked; write blocklist rejected `{path}`"
89                    )));
90                }
91            }
92        }
93        Ok(())
94    }
95}
96
97#[async_trait]
98impl SessionFileSystem for WriteBlocklistFileStore {
99    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
100        self.inner.read_file(session_id, path).await
101    }
102
103    async fn write_file(
104        &self,
105        session_id: SessionId,
106        path: &str,
107        content: &str,
108        encoding: &str,
109    ) -> Result<SessionFile> {
110        self.check(path)?;
111        self.inner
112            .write_file(session_id, path, content, encoding)
113            .await
114    }
115
116    async fn write_file_if_content_matches(
117        &self,
118        session_id: SessionId,
119        path: &str,
120        expected_content: &str,
121        expected_encoding: &str,
122        content: &str,
123        encoding: &str,
124    ) -> Result<Option<SessionFile>> {
125        self.check(path)?;
126        self.inner
127            .write_file_if_content_matches(
128                session_id,
129                path,
130                expected_content,
131                expected_encoding,
132                content,
133                encoding,
134            )
135            .await
136    }
137
138    async fn delete_file(
139        &self,
140        session_id: SessionId,
141        path: &str,
142        recursive: bool,
143    ) -> Result<bool> {
144        self.check(path)?;
145        self.inner.delete_file(session_id, path, recursive).await
146    }
147
148    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
149        self.inner.list_directory(session_id, path).await
150    }
151
152    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
153        self.inner.stat_file(session_id, path).await
154    }
155
156    async fn grep_files(
157        &self,
158        session_id: SessionId,
159        pattern: &str,
160        path_pattern: Option<&str>,
161    ) -> Result<Vec<GrepMatch>> {
162        self.inner
163            .grep_files(session_id, pattern, path_pattern)
164            .await
165    }
166
167    async fn grep_files_with_options(
168        &self,
169        session_id: SessionId,
170        pattern: &str,
171        options: &GrepOptions,
172    ) -> Result<GrepSearchResult> {
173        self.inner
174            .grep_files_with_options(session_id, pattern, options)
175            .await
176    }
177
178    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
179        self.check(path)?;
180        self.inner.create_directory(session_id, path).await
181    }
182
183    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
184        self.check(&file.path)?;
185        self.inner.seed_initial_file(session_id, file).await
186    }
187
188    fn is_mount_resolver(&self) -> bool {
189        self.inner.is_mount_resolver()
190    }
191}
192
193/// Embedder-supplied approval callback used by [`ApprovalGatingFileStore`].
194///
195/// Implementations decide how to ask the user (TUI prompt, web confirmation,
196/// auto-approve, OS notification, etc.). The store passes raw paths and the
197/// before/after content for writes so the implementation can render diffs.
198#[async_trait]
199pub trait FileApprovalGate: Send + Sync {
200    /// Decide whether the proposed write may proceed.
201    ///
202    /// `before` is the inner store's current content, if any — `None` if the
203    /// file does not yet exist. `after` is the proposed new content.
204    async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool;
205
206    /// Decide whether the proposed delete may proceed.
207    async fn approve_delete(&self, path: &str, recursive: bool) -> bool;
208}
209
210/// Gate destructive operations through an embedder-supplied
211/// [`FileApprovalGate`]. Reads pass through.
212///
213/// For writes we always fetch the inner store's current content first so the
214/// approval prompt can show a diff. That's one extra read per write —
215/// acceptable for a coding agent where writes are rare and small. The
216/// `create_directory` and `seed_initial_file` paths are not gated: the
217/// subsequent `write_file` inside the created directory triggers the prompt,
218/// and seed files are embedder-supplied (not LLM-driven).
219pub struct ApprovalGatingFileStore {
220    inner: Arc<dyn SessionFileSystem>,
221    gate: Arc<dyn FileApprovalGate>,
222}
223
224impl ApprovalGatingFileStore {
225    pub fn new(inner: Arc<dyn SessionFileSystem>, gate: Arc<dyn FileApprovalGate>) -> Self {
226        Self { inner, gate }
227    }
228
229    /// Internal helper: gate a write given an already-known `before` content,
230    /// then write through the inner store.
231    ///
232    /// Used by [`Self::write_file`] for the unconditional-write path. The CAS
233    /// path ([`Self::write_file_if_content_matches`]) re-checks via the inner
234    /// store's CAS write after approval so it cannot use this helper.
235    async fn gated_write_with_before(
236        &self,
237        session_id: SessionId,
238        path: &str,
239        before: Option<String>,
240        content: &str,
241        encoding: &str,
242    ) -> Result<SessionFile> {
243        let approved = self.gate.approve_write(path, before, content).await;
244        if !approved {
245            return Err(AgentLoopError::tool(format!(
246                "user denied write to `{path}`"
247            )));
248        }
249        self.inner
250            .write_file(session_id, path, content, encoding)
251            .await
252    }
253}
254
255#[async_trait]
256impl SessionFileSystem for ApprovalGatingFileStore {
257    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
258        self.inner.read_file(session_id, path).await
259    }
260
261    async fn write_file(
262        &self,
263        session_id: SessionId,
264        path: &str,
265        content: &str,
266        encoding: &str,
267    ) -> Result<SessionFile> {
268        // Propagate inner read errors instead of silently treating them as
269        // "no prior content" — a permission error or transient I/O fault
270        // should surface, not be hidden behind the approval prompt.
271        let before = self
272            .inner
273            .read_file(session_id, path)
274            .await?
275            .and_then(|f| f.content);
276        self.gated_write_with_before(session_id, path, before, content, encoding)
277            .await
278    }
279
280    async fn write_file_if_content_matches(
281        &self,
282        session_id: SessionId,
283        path: &str,
284        expected_content: &str,
285        expected_encoding: &str,
286        content: &str,
287        encoding: &str,
288    ) -> Result<Option<SessionFile>> {
289        // Read existing, compare, then gate using the already-fetched content
290        // for the approval `before`. Avoids a second `read_file` on the
291        // successful-write path.
292        let Some(existing) = self.inner.read_file(session_id, path).await? else {
293            return Ok(None);
294        };
295        if existing.is_directory {
296            return Ok(None);
297        }
298        let current = existing.content.unwrap_or_default();
299        if current != expected_content || existing.encoding != expected_encoding {
300            return Ok(None);
301        }
302        let approved = self.gate.approve_write(path, Some(current), content).await;
303        if !approved {
304            return Err(AgentLoopError::tool(format!(
305                "user denied write to `{path}`"
306            )));
307        }
308
309        self.inner
310            .write_file_if_content_matches(
311                session_id,
312                path,
313                expected_content,
314                expected_encoding,
315                content,
316                encoding,
317            )
318            .await
319    }
320
321    async fn delete_file(
322        &self,
323        session_id: SessionId,
324        path: &str,
325        recursive: bool,
326    ) -> Result<bool> {
327        let approved = self.gate.approve_delete(path, recursive).await;
328        if !approved {
329            return Err(AgentLoopError::tool(format!(
330                "user denied delete of `{path}`"
331            )));
332        }
333        self.inner.delete_file(session_id, path, recursive).await
334    }
335
336    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
337        self.inner.list_directory(session_id, path).await
338    }
339
340    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
341        self.inner.stat_file(session_id, path).await
342    }
343
344    async fn grep_files(
345        &self,
346        session_id: SessionId,
347        pattern: &str,
348        path_pattern: Option<&str>,
349    ) -> Result<Vec<GrepMatch>> {
350        self.inner
351            .grep_files(session_id, pattern, path_pattern)
352            .await
353    }
354
355    async fn grep_files_with_options(
356        &self,
357        session_id: SessionId,
358        pattern: &str,
359        options: &GrepOptions,
360    ) -> Result<GrepSearchResult> {
361        self.inner
362            .grep_files_with_options(session_id, pattern, options)
363            .await
364    }
365
366    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
367        self.inner.create_directory(session_id, path).await
368    }
369
370    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
371        self.inner.seed_initial_file(session_id, file).await
372    }
373
374    fn is_mount_resolver(&self) -> bool {
375        self.inner.is_mount_resolver()
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::in_memory::InMemorySessionFileStore;
383    use std::sync::Mutex;
384
385    fn sid() -> SessionId {
386        "session_00000000000000000000000000000001".parse().unwrap()
387    }
388
389    fn inner() -> Arc<dyn SessionFileSystem> {
390        Arc::new(InMemorySessionFileStore::new())
391    }
392
393    #[tokio::test]
394    async fn write_blocklist_rejects_blocked_paths() {
395        let store = WriteBlocklistFileStore::new(inner());
396        let err = store
397            .write_file(sid(), "/.git/config", "bad", "text")
398            .await
399            .expect_err("write into .git must be rejected");
400        assert!(format!("{err}").contains(".git"));
401    }
402
403    #[tokio::test]
404    async fn write_blocklist_allows_unblocked_paths() {
405        let store = WriteBlocklistFileStore::new(inner());
406        store
407            .write_file(sid(), "/src/main.rs", "fn main() {}", "text")
408            .await
409            .expect("write outside blocklist must succeed");
410    }
411
412    #[tokio::test]
413    async fn write_blocklist_reads_pass_through_blocked() {
414        // Seed via the inner store directly, then verify read works through
415        // the decorator even though the path is in the blocklist.
416        let inner_store: Arc<dyn SessionFileSystem> = inner();
417        inner_store
418            .write_file(sid(), "/.git/config", "settings", "text")
419            .await
420            .unwrap();
421        let store = WriteBlocklistFileStore::new(inner_store);
422        let file = store
423            .read_file(sid(), "/.git/config")
424            .await
425            .unwrap()
426            .expect("read through blocklist must succeed");
427        assert_eq!(file.content.as_deref(), Some("settings"));
428    }
429
430    #[tokio::test]
431    async fn write_blocklist_contextual_grep_passes_through() {
432        let inner_store: Arc<dyn SessionFileSystem> = inner();
433        inner_store
434            .write_file(
435                sid(),
436                "/output.log",
437                "before\nError: failed\nROOT_CAUSE=duplicate_sentinel\nafter\n",
438                "text",
439            )
440            .await
441            .unwrap();
442        let store = WriteBlocklistFileStore::new(inner_store);
443
444        let result = store
445            .grep_files_with_options(
446                sid(),
447                "Error|failed",
448                &GrepOptions {
449                    before_context: 1,
450                    after_context: 1,
451                    ..GrepOptions::default()
452                },
453            )
454            .await
455            .expect("contextual grep must pass through the decorator");
456
457        assert_eq!(result.returned_matches, 1);
458        assert_eq!(result.blocks.len(), 1);
459        assert_eq!(result.blocks[0].start_line, 1);
460        assert_eq!(result.blocks[0].end_line, 3);
461    }
462
463    #[tokio::test]
464    async fn write_blocklist_custom_overrides_default() {
465        let store = WriteBlocklistFileStore::with_blocklist(inner(), ["forbidden"]);
466        // Default-blocked path is now allowed.
467        store
468            .write_file(sid(), "/.git/config", "ok", "text")
469            .await
470            .expect("custom blocklist replaces default");
471        // Custom-blocked path is rejected.
472        let err = store
473            .write_file(sid(), "/forbidden/x", "no", "text")
474            .await
475            .expect_err("custom blocklist entry must be enforced");
476        assert!(format!("{err}").contains("forbidden"));
477    }
478
479    struct RecordingGate {
480        approve: bool,
481        writes: Mutex<Vec<(String, Option<String>, String)>>,
482        deletes: Mutex<Vec<(String, bool)>>,
483    }
484
485    impl RecordingGate {
486        fn new(approve: bool) -> Self {
487            Self {
488                approve,
489                writes: Mutex::new(Vec::new()),
490                deletes: Mutex::new(Vec::new()),
491            }
492        }
493    }
494
495    #[async_trait]
496    impl FileApprovalGate for RecordingGate {
497        async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool {
498            self.writes
499                .lock()
500                .unwrap()
501                .push((path.to_string(), before, after.to_string()));
502            self.approve
503        }
504
505        async fn approve_delete(&self, path: &str, recursive: bool) -> bool {
506            self.deletes
507                .lock()
508                .unwrap()
509                .push((path.to_string(), recursive));
510            self.approve
511        }
512    }
513
514    #[tokio::test]
515    async fn approval_gating_denies_write_when_user_rejects() {
516        let gate = Arc::new(RecordingGate::new(false));
517        let store = ApprovalGatingFileStore::new(inner(), gate.clone());
518        let err = store
519            .write_file(sid(), "/notes.txt", "new", "text")
520            .await
521            .expect_err("rejected write must surface as tool error");
522        assert!(format!("{err}").contains("denied"));
523        assert_eq!(gate.writes.lock().unwrap().len(), 1);
524    }
525
526    #[tokio::test]
527    async fn approval_gating_approves_write_and_passes_before_after() {
528        let inner_store: Arc<dyn SessionFileSystem> = inner();
529        inner_store
530            .write_file(sid(), "/notes.txt", "original", "text")
531            .await
532            .unwrap();
533        let gate = Arc::new(RecordingGate::new(true));
534        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
535        let file = store
536            .write_file(sid(), "/notes.txt", "updated", "text")
537            .await
538            .expect("approved write must succeed");
539        assert_eq!(file.content.as_deref(), Some("updated"));
540        let writes = gate.writes.lock().unwrap();
541        assert_eq!(writes.len(), 1);
542        assert_eq!(writes[0].0, "/notes.txt");
543        assert_eq!(writes[0].1.as_deref(), Some("original"));
544        assert_eq!(writes[0].2, "updated");
545    }
546
547    #[tokio::test]
548    async fn approval_gating_denies_delete_when_user_rejects() {
549        let inner_store: Arc<dyn SessionFileSystem> = inner();
550        inner_store
551            .write_file(sid(), "/scratch.txt", "x", "text")
552            .await
553            .unwrap();
554        let gate = Arc::new(RecordingGate::new(false));
555        let store = ApprovalGatingFileStore::new(inner_store, gate);
556        let err = store
557            .delete_file(sid(), "/scratch.txt", false)
558            .await
559            .expect_err("rejected delete must surface as tool error");
560        assert!(format!("{err}").contains("denied"));
561    }
562
563    #[tokio::test]
564    async fn approval_gating_reads_pass_through_without_prompt() {
565        let inner_store: Arc<dyn SessionFileSystem> = inner();
566        inner_store
567            .write_file(sid(), "/notes.txt", "hi", "text")
568            .await
569            .unwrap();
570        let gate = Arc::new(RecordingGate::new(false));
571        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
572        let file = store.read_file(sid(), "/notes.txt").await.unwrap();
573        assert_eq!(file.unwrap().content.as_deref(), Some("hi"));
574        assert!(gate.writes.lock().unwrap().is_empty());
575    }
576
577    #[tokio::test]
578    async fn approval_gating_contextual_grep_passes_through_without_prompt() {
579        let inner_store: Arc<dyn SessionFileSystem> = inner();
580        inner_store
581            .write_file(
582                sid(),
583                "/output.log",
584                "before\nError: failed\nROOT_CAUSE=duplicate_sentinel\nafter\n",
585                "text",
586            )
587            .await
588            .unwrap();
589        let gate = Arc::new(RecordingGate::new(false));
590        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
591
592        let result = store
593            .grep_files_with_options(
594                sid(),
595                "Error|failed",
596                &GrepOptions {
597                    before_context: 1,
598                    after_context: 1,
599                    ..GrepOptions::default()
600                },
601            )
602            .await
603            .expect("contextual grep must pass through the decorator");
604
605        assert_eq!(result.returned_matches, 1);
606        assert_eq!(result.blocks.len(), 1);
607        assert!(gate.writes.lock().unwrap().is_empty());
608        assert!(gate.deletes.lock().unwrap().is_empty());
609    }
610
611    #[tokio::test]
612    async fn write_if_content_matches_takes_one_approval_per_write() {
613        let inner_store: Arc<dyn SessionFileSystem> = inner();
614        inner_store
615            .write_file(sid(), "/notes.txt", "original", "text")
616            .await
617            .unwrap();
618        let gate = Arc::new(RecordingGate::new(true));
619        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
620
621        let result = store
622            .write_file_if_content_matches(
623                sid(),
624                "/notes.txt",
625                "original",
626                "text",
627                "updated",
628                "text",
629            )
630            .await
631            .unwrap();
632        assert!(result.is_some());
633        assert_eq!(gate.writes.lock().unwrap().len(), 1);
634    }
635
636    #[tokio::test]
637    async fn write_if_content_matches_with_stale_expected_returns_none_without_prompt() {
638        let inner_store: Arc<dyn SessionFileSystem> = inner();
639        inner_store
640            .write_file(sid(), "/notes.txt", "actual", "text")
641            .await
642            .unwrap();
643        let gate = Arc::new(RecordingGate::new(true));
644        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
645
646        let result = store
647            .write_file_if_content_matches(
648                sid(),
649                "/notes.txt",
650                "stale-expected",
651                "text",
652                "new",
653                "text",
654            )
655            .await
656            .unwrap();
657        assert!(result.is_none());
658        assert!(gate.writes.lock().unwrap().is_empty());
659    }
660
661    struct MutatingGate {
662        inner: Arc<dyn SessionFileSystem>,
663    }
664
665    #[async_trait]
666    impl FileApprovalGate for MutatingGate {
667        async fn approve_write(&self, _path: &str, _before: Option<String>, _after: &str) -> bool {
668            self.inner
669                .write_file(sid(), "/notes.txt", "intruder", "text")
670                .await
671                .unwrap();
672            true
673        }
674
675        async fn approve_delete(&self, _path: &str, _recursive: bool) -> bool {
676            true
677        }
678    }
679
680    #[tokio::test]
681    async fn write_if_content_matches_rechecks_after_approval() {
682        let inner_store: Arc<dyn SessionFileSystem> = inner();
683        inner_store
684            .write_file(sid(), "/notes.txt", "original", "text")
685            .await
686            .unwrap();
687        let gate = Arc::new(MutatingGate {
688            inner: inner_store.clone(),
689        });
690        let store = ApprovalGatingFileStore::new(inner_store.clone(), gate);
691
692        let result = store
693            .write_file_if_content_matches(
694                sid(),
695                "/notes.txt",
696                "original",
697                "text",
698                "updated",
699                "text",
700            )
701            .await
702            .unwrap();
703
704        assert!(result.is_none());
705        let final_file = inner_store
706            .read_file(sid(), "/notes.txt")
707            .await
708            .unwrap()
709            .unwrap();
710        assert_eq!(final_file.content.as_deref(), Some("intruder"));
711    }
712}