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