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
176/// Embedder-supplied approval callback used by [`ApprovalGatingFileStore`].
177///
178/// Implementations decide how to ask the user (TUI prompt, web confirmation,
179/// auto-approve, OS notification, etc.). The store passes raw paths and the
180/// before/after content for writes so the implementation can render diffs.
181#[async_trait]
182pub trait FileApprovalGate: Send + Sync {
183    /// Decide whether the proposed write may proceed.
184    ///
185    /// `before` is the inner store's current content, if any — `None` if the
186    /// file does not yet exist. `after` is the proposed new content.
187    async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool;
188
189    /// Decide whether the proposed delete may proceed.
190    async fn approve_delete(&self, path: &str, recursive: bool) -> bool;
191}
192
193/// Gate destructive operations through an embedder-supplied
194/// [`FileApprovalGate`]. Reads pass through.
195///
196/// For writes we always fetch the inner store's current content first so the
197/// approval prompt can show a diff. That's one extra read per write —
198/// acceptable for a coding agent where writes are rare and small. The
199/// `create_directory` and `seed_initial_file` paths are not gated: the
200/// subsequent `write_file` inside the created directory triggers the prompt,
201/// and seed files are embedder-supplied (not LLM-driven).
202pub struct ApprovalGatingFileStore {
203    inner: Arc<dyn SessionFileSystem>,
204    gate: Arc<dyn FileApprovalGate>,
205}
206
207impl ApprovalGatingFileStore {
208    pub fn new(inner: Arc<dyn SessionFileSystem>, gate: Arc<dyn FileApprovalGate>) -> Self {
209        Self { inner, gate }
210    }
211
212    /// Internal helper: gate a write given an already-known `before` content,
213    /// then write through the inner store. Used by `write_file_if_content_matches`
214    /// to avoid re-reading the file when the caller already has the content
215    /// in hand from the CAS check.
216    async fn gated_write_with_before(
217        &self,
218        session_id: SessionId,
219        path: &str,
220        before: Option<String>,
221        content: &str,
222        encoding: &str,
223    ) -> Result<SessionFile> {
224        let approved = self.gate.approve_write(path, before, content).await;
225        if !approved {
226            return Err(AgentLoopError::tool(format!(
227                "user denied write to `{path}`"
228            )));
229        }
230        self.inner
231            .write_file(session_id, path, content, encoding)
232            .await
233    }
234}
235
236#[async_trait]
237impl SessionFileSystem for ApprovalGatingFileStore {
238    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
239        self.inner.read_file(session_id, path).await
240    }
241
242    async fn write_file(
243        &self,
244        session_id: SessionId,
245        path: &str,
246        content: &str,
247        encoding: &str,
248    ) -> Result<SessionFile> {
249        // Propagate inner read errors instead of silently treating them as
250        // "no prior content" — a permission error or transient I/O fault
251        // should surface, not be hidden behind the approval prompt.
252        let before = self
253            .inner
254            .read_file(session_id, path)
255            .await?
256            .and_then(|f| f.content);
257        self.gated_write_with_before(session_id, path, before, content, encoding)
258            .await
259    }
260
261    async fn write_file_if_content_matches(
262        &self,
263        session_id: SessionId,
264        path: &str,
265        expected_content: &str,
266        expected_encoding: &str,
267        content: &str,
268        encoding: &str,
269    ) -> Result<Option<SessionFile>> {
270        // Read existing, compare, then gate using the already-fetched content
271        // for the approval `before`. Avoids a second `read_file` on the
272        // successful-write path.
273        let Some(existing) = self.inner.read_file(session_id, path).await? else {
274            return Ok(None);
275        };
276        if existing.is_directory {
277            return Ok(None);
278        }
279        let current = existing.content.unwrap_or_default();
280        if current != expected_content || existing.encoding != expected_encoding {
281            return Ok(None);
282        }
283        self.gated_write_with_before(session_id, path, Some(current), content, encoding)
284            .await
285            .map(Some)
286    }
287
288    async fn delete_file(
289        &self,
290        session_id: SessionId,
291        path: &str,
292        recursive: bool,
293    ) -> Result<bool> {
294        let approved = self.gate.approve_delete(path, recursive).await;
295        if !approved {
296            return Err(AgentLoopError::tool(format!(
297                "user denied delete of `{path}`"
298            )));
299        }
300        self.inner.delete_file(session_id, path, recursive).await
301    }
302
303    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
304        self.inner.list_directory(session_id, path).await
305    }
306
307    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
308        self.inner.stat_file(session_id, path).await
309    }
310
311    async fn grep_files(
312        &self,
313        session_id: SessionId,
314        pattern: &str,
315        path_pattern: Option<&str>,
316    ) -> Result<Vec<GrepMatch>> {
317        self.inner
318            .grep_files(session_id, pattern, path_pattern)
319            .await
320    }
321
322    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
323        self.inner.create_directory(session_id, path).await
324    }
325
326    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
327        self.inner.seed_initial_file(session_id, file).await
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::in_memory::InMemorySessionFileStore;
335    use std::sync::Mutex;
336
337    fn sid() -> SessionId {
338        "session_00000000000000000000000000000001".parse().unwrap()
339    }
340
341    fn inner() -> Arc<dyn SessionFileSystem> {
342        Arc::new(InMemorySessionFileStore::new())
343    }
344
345    #[tokio::test]
346    async fn write_blocklist_rejects_blocked_paths() {
347        let store = WriteBlocklistFileStore::new(inner());
348        let err = store
349            .write_file(sid(), "/.git/config", "bad", "text")
350            .await
351            .expect_err("write into .git must be rejected");
352        assert!(format!("{err}").contains(".git"));
353    }
354
355    #[tokio::test]
356    async fn write_blocklist_allows_unblocked_paths() {
357        let store = WriteBlocklistFileStore::new(inner());
358        store
359            .write_file(sid(), "/src/main.rs", "fn main() {}", "text")
360            .await
361            .expect("write outside blocklist must succeed");
362    }
363
364    #[tokio::test]
365    async fn write_blocklist_reads_pass_through_blocked() {
366        // Seed via the inner store directly, then verify read works through
367        // the decorator even though the path is in the blocklist.
368        let inner_store: Arc<dyn SessionFileSystem> = inner();
369        inner_store
370            .write_file(sid(), "/.git/config", "settings", "text")
371            .await
372            .unwrap();
373        let store = WriteBlocklistFileStore::new(inner_store);
374        let file = store
375            .read_file(sid(), "/.git/config")
376            .await
377            .unwrap()
378            .expect("read through blocklist must succeed");
379        assert_eq!(file.content.as_deref(), Some("settings"));
380    }
381
382    #[tokio::test]
383    async fn write_blocklist_custom_overrides_default() {
384        let store = WriteBlocklistFileStore::with_blocklist(inner(), ["forbidden"]);
385        // Default-blocked path is now allowed.
386        store
387            .write_file(sid(), "/.git/config", "ok", "text")
388            .await
389            .expect("custom blocklist replaces default");
390        // Custom-blocked path is rejected.
391        let err = store
392            .write_file(sid(), "/forbidden/x", "no", "text")
393            .await
394            .expect_err("custom blocklist entry must be enforced");
395        assert!(format!("{err}").contains("forbidden"));
396    }
397
398    struct RecordingGate {
399        approve: bool,
400        writes: Mutex<Vec<(String, Option<String>, String)>>,
401        deletes: Mutex<Vec<(String, bool)>>,
402    }
403
404    impl RecordingGate {
405        fn new(approve: bool) -> Self {
406            Self {
407                approve,
408                writes: Mutex::new(Vec::new()),
409                deletes: Mutex::new(Vec::new()),
410            }
411        }
412    }
413
414    #[async_trait]
415    impl FileApprovalGate for RecordingGate {
416        async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool {
417            self.writes
418                .lock()
419                .unwrap()
420                .push((path.to_string(), before, after.to_string()));
421            self.approve
422        }
423
424        async fn approve_delete(&self, path: &str, recursive: bool) -> bool {
425            self.deletes
426                .lock()
427                .unwrap()
428                .push((path.to_string(), recursive));
429            self.approve
430        }
431    }
432
433    #[tokio::test]
434    async fn approval_gating_denies_write_when_user_rejects() {
435        let gate = Arc::new(RecordingGate::new(false));
436        let store = ApprovalGatingFileStore::new(inner(), gate.clone());
437        let err = store
438            .write_file(sid(), "/notes.txt", "new", "text")
439            .await
440            .expect_err("rejected write must surface as tool error");
441        assert!(format!("{err}").contains("denied"));
442        assert_eq!(gate.writes.lock().unwrap().len(), 1);
443    }
444
445    #[tokio::test]
446    async fn approval_gating_approves_write_and_passes_before_after() {
447        let inner_store: Arc<dyn SessionFileSystem> = inner();
448        inner_store
449            .write_file(sid(), "/notes.txt", "original", "text")
450            .await
451            .unwrap();
452        let gate = Arc::new(RecordingGate::new(true));
453        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
454        let file = store
455            .write_file(sid(), "/notes.txt", "updated", "text")
456            .await
457            .expect("approved write must succeed");
458        assert_eq!(file.content.as_deref(), Some("updated"));
459        let writes = gate.writes.lock().unwrap();
460        assert_eq!(writes.len(), 1);
461        assert_eq!(writes[0].0, "/notes.txt");
462        assert_eq!(writes[0].1.as_deref(), Some("original"));
463        assert_eq!(writes[0].2, "updated");
464    }
465
466    #[tokio::test]
467    async fn approval_gating_denies_delete_when_user_rejects() {
468        let inner_store: Arc<dyn SessionFileSystem> = inner();
469        inner_store
470            .write_file(sid(), "/scratch.txt", "x", "text")
471            .await
472            .unwrap();
473        let gate = Arc::new(RecordingGate::new(false));
474        let store = ApprovalGatingFileStore::new(inner_store, gate);
475        let err = store
476            .delete_file(sid(), "/scratch.txt", false)
477            .await
478            .expect_err("rejected delete must surface as tool error");
479        assert!(format!("{err}").contains("denied"));
480    }
481
482    #[tokio::test]
483    async fn approval_gating_reads_pass_through_without_prompt() {
484        let inner_store: Arc<dyn SessionFileSystem> = inner();
485        inner_store
486            .write_file(sid(), "/notes.txt", "hi", "text")
487            .await
488            .unwrap();
489        let gate = Arc::new(RecordingGate::new(false));
490        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
491        let file = store.read_file(sid(), "/notes.txt").await.unwrap();
492        assert_eq!(file.unwrap().content.as_deref(), Some("hi"));
493        assert!(gate.writes.lock().unwrap().is_empty());
494    }
495
496    #[tokio::test]
497    async fn write_if_content_matches_takes_one_approval_per_write() {
498        let inner_store: Arc<dyn SessionFileSystem> = inner();
499        inner_store
500            .write_file(sid(), "/notes.txt", "original", "text")
501            .await
502            .unwrap();
503        let gate = Arc::new(RecordingGate::new(true));
504        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
505
506        let result = store
507            .write_file_if_content_matches(
508                sid(),
509                "/notes.txt",
510                "original",
511                "text",
512                "updated",
513                "text",
514            )
515            .await
516            .unwrap();
517        assert!(result.is_some());
518        assert_eq!(gate.writes.lock().unwrap().len(), 1);
519    }
520
521    #[tokio::test]
522    async fn write_if_content_matches_with_stale_expected_returns_none_without_prompt() {
523        let inner_store: Arc<dyn SessionFileSystem> = inner();
524        inner_store
525            .write_file(sid(), "/notes.txt", "actual", "text")
526            .await
527            .unwrap();
528        let gate = Arc::new(RecordingGate::new(true));
529        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
530
531        let result = store
532            .write_file_if_content_matches(
533                sid(),
534                "/notes.txt",
535                "stale-expected",
536                "text",
537                "new",
538                "text",
539            )
540            .await
541            .unwrap();
542        assert!(result.is_none());
543        assert!(gate.writes.lock().unwrap().is_empty());
544    }
545}