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.
214    ///
215    /// Used by [`Self::write_file`] for the unconditional-write path. The CAS
216    /// path ([`Self::write_file_if_content_matches`]) re-checks via the inner
217    /// store's CAS write after approval so it cannot use this helper.
218    async fn gated_write_with_before(
219        &self,
220        session_id: SessionId,
221        path: &str,
222        before: Option<String>,
223        content: &str,
224        encoding: &str,
225    ) -> Result<SessionFile> {
226        let approved = self.gate.approve_write(path, before, content).await;
227        if !approved {
228            return Err(AgentLoopError::tool(format!(
229                "user denied write to `{path}`"
230            )));
231        }
232        self.inner
233            .write_file(session_id, path, content, encoding)
234            .await
235    }
236}
237
238#[async_trait]
239impl SessionFileSystem for ApprovalGatingFileStore {
240    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
241        self.inner.read_file(session_id, path).await
242    }
243
244    async fn write_file(
245        &self,
246        session_id: SessionId,
247        path: &str,
248        content: &str,
249        encoding: &str,
250    ) -> Result<SessionFile> {
251        // Propagate inner read errors instead of silently treating them as
252        // "no prior content" — a permission error or transient I/O fault
253        // should surface, not be hidden behind the approval prompt.
254        let before = self
255            .inner
256            .read_file(session_id, path)
257            .await?
258            .and_then(|f| f.content);
259        self.gated_write_with_before(session_id, path, before, content, encoding)
260            .await
261    }
262
263    async fn write_file_if_content_matches(
264        &self,
265        session_id: SessionId,
266        path: &str,
267        expected_content: &str,
268        expected_encoding: &str,
269        content: &str,
270        encoding: &str,
271    ) -> Result<Option<SessionFile>> {
272        // Read existing, compare, then gate using the already-fetched content
273        // for the approval `before`. Avoids a second `read_file` on the
274        // successful-write path.
275        let Some(existing) = self.inner.read_file(session_id, path).await? else {
276            return Ok(None);
277        };
278        if existing.is_directory {
279            return Ok(None);
280        }
281        let current = existing.content.unwrap_or_default();
282        if current != expected_content || existing.encoding != expected_encoding {
283            return Ok(None);
284        }
285        let approved = self.gate.approve_write(path, Some(current), content).await;
286        if !approved {
287            return Err(AgentLoopError::tool(format!(
288                "user denied write to `{path}`"
289            )));
290        }
291
292        self.inner
293            .write_file_if_content_matches(
294                session_id,
295                path,
296                expected_content,
297                expected_encoding,
298                content,
299                encoding,
300            )
301            .await
302    }
303
304    async fn delete_file(
305        &self,
306        session_id: SessionId,
307        path: &str,
308        recursive: bool,
309    ) -> Result<bool> {
310        let approved = self.gate.approve_delete(path, recursive).await;
311        if !approved {
312            return Err(AgentLoopError::tool(format!(
313                "user denied delete of `{path}`"
314            )));
315        }
316        self.inner.delete_file(session_id, path, recursive).await
317    }
318
319    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
320        self.inner.list_directory(session_id, path).await
321    }
322
323    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
324        self.inner.stat_file(session_id, path).await
325    }
326
327    async fn grep_files(
328        &self,
329        session_id: SessionId,
330        pattern: &str,
331        path_pattern: Option<&str>,
332    ) -> Result<Vec<GrepMatch>> {
333        self.inner
334            .grep_files(session_id, pattern, path_pattern)
335            .await
336    }
337
338    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
339        self.inner.create_directory(session_id, path).await
340    }
341
342    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
343        self.inner.seed_initial_file(session_id, file).await
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::in_memory::InMemorySessionFileStore;
351    use std::sync::Mutex;
352
353    fn sid() -> SessionId {
354        "session_00000000000000000000000000000001".parse().unwrap()
355    }
356
357    fn inner() -> Arc<dyn SessionFileSystem> {
358        Arc::new(InMemorySessionFileStore::new())
359    }
360
361    #[tokio::test]
362    async fn write_blocklist_rejects_blocked_paths() {
363        let store = WriteBlocklistFileStore::new(inner());
364        let err = store
365            .write_file(sid(), "/.git/config", "bad", "text")
366            .await
367            .expect_err("write into .git must be rejected");
368        assert!(format!("{err}").contains(".git"));
369    }
370
371    #[tokio::test]
372    async fn write_blocklist_allows_unblocked_paths() {
373        let store = WriteBlocklistFileStore::new(inner());
374        store
375            .write_file(sid(), "/src/main.rs", "fn main() {}", "text")
376            .await
377            .expect("write outside blocklist must succeed");
378    }
379
380    #[tokio::test]
381    async fn write_blocklist_reads_pass_through_blocked() {
382        // Seed via the inner store directly, then verify read works through
383        // the decorator even though the path is in the blocklist.
384        let inner_store: Arc<dyn SessionFileSystem> = inner();
385        inner_store
386            .write_file(sid(), "/.git/config", "settings", "text")
387            .await
388            .unwrap();
389        let store = WriteBlocklistFileStore::new(inner_store);
390        let file = store
391            .read_file(sid(), "/.git/config")
392            .await
393            .unwrap()
394            .expect("read through blocklist must succeed");
395        assert_eq!(file.content.as_deref(), Some("settings"));
396    }
397
398    #[tokio::test]
399    async fn write_blocklist_custom_overrides_default() {
400        let store = WriteBlocklistFileStore::with_blocklist(inner(), ["forbidden"]);
401        // Default-blocked path is now allowed.
402        store
403            .write_file(sid(), "/.git/config", "ok", "text")
404            .await
405            .expect("custom blocklist replaces default");
406        // Custom-blocked path is rejected.
407        let err = store
408            .write_file(sid(), "/forbidden/x", "no", "text")
409            .await
410            .expect_err("custom blocklist entry must be enforced");
411        assert!(format!("{err}").contains("forbidden"));
412    }
413
414    struct RecordingGate {
415        approve: bool,
416        writes: Mutex<Vec<(String, Option<String>, String)>>,
417        deletes: Mutex<Vec<(String, bool)>>,
418    }
419
420    impl RecordingGate {
421        fn new(approve: bool) -> Self {
422            Self {
423                approve,
424                writes: Mutex::new(Vec::new()),
425                deletes: Mutex::new(Vec::new()),
426            }
427        }
428    }
429
430    #[async_trait]
431    impl FileApprovalGate for RecordingGate {
432        async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool {
433            self.writes
434                .lock()
435                .unwrap()
436                .push((path.to_string(), before, after.to_string()));
437            self.approve
438        }
439
440        async fn approve_delete(&self, path: &str, recursive: bool) -> bool {
441            self.deletes
442                .lock()
443                .unwrap()
444                .push((path.to_string(), recursive));
445            self.approve
446        }
447    }
448
449    #[tokio::test]
450    async fn approval_gating_denies_write_when_user_rejects() {
451        let gate = Arc::new(RecordingGate::new(false));
452        let store = ApprovalGatingFileStore::new(inner(), gate.clone());
453        let err = store
454            .write_file(sid(), "/notes.txt", "new", "text")
455            .await
456            .expect_err("rejected write must surface as tool error");
457        assert!(format!("{err}").contains("denied"));
458        assert_eq!(gate.writes.lock().unwrap().len(), 1);
459    }
460
461    #[tokio::test]
462    async fn approval_gating_approves_write_and_passes_before_after() {
463        let inner_store: Arc<dyn SessionFileSystem> = inner();
464        inner_store
465            .write_file(sid(), "/notes.txt", "original", "text")
466            .await
467            .unwrap();
468        let gate = Arc::new(RecordingGate::new(true));
469        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
470        let file = store
471            .write_file(sid(), "/notes.txt", "updated", "text")
472            .await
473            .expect("approved write must succeed");
474        assert_eq!(file.content.as_deref(), Some("updated"));
475        let writes = gate.writes.lock().unwrap();
476        assert_eq!(writes.len(), 1);
477        assert_eq!(writes[0].0, "/notes.txt");
478        assert_eq!(writes[0].1.as_deref(), Some("original"));
479        assert_eq!(writes[0].2, "updated");
480    }
481
482    #[tokio::test]
483    async fn approval_gating_denies_delete_when_user_rejects() {
484        let inner_store: Arc<dyn SessionFileSystem> = inner();
485        inner_store
486            .write_file(sid(), "/scratch.txt", "x", "text")
487            .await
488            .unwrap();
489        let gate = Arc::new(RecordingGate::new(false));
490        let store = ApprovalGatingFileStore::new(inner_store, gate);
491        let err = store
492            .delete_file(sid(), "/scratch.txt", false)
493            .await
494            .expect_err("rejected delete must surface as tool error");
495        assert!(format!("{err}").contains("denied"));
496    }
497
498    #[tokio::test]
499    async fn approval_gating_reads_pass_through_without_prompt() {
500        let inner_store: Arc<dyn SessionFileSystem> = inner();
501        inner_store
502            .write_file(sid(), "/notes.txt", "hi", "text")
503            .await
504            .unwrap();
505        let gate = Arc::new(RecordingGate::new(false));
506        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
507        let file = store.read_file(sid(), "/notes.txt").await.unwrap();
508        assert_eq!(file.unwrap().content.as_deref(), Some("hi"));
509        assert!(gate.writes.lock().unwrap().is_empty());
510    }
511
512    #[tokio::test]
513    async fn write_if_content_matches_takes_one_approval_per_write() {
514        let inner_store: Arc<dyn SessionFileSystem> = inner();
515        inner_store
516            .write_file(sid(), "/notes.txt", "original", "text")
517            .await
518            .unwrap();
519        let gate = Arc::new(RecordingGate::new(true));
520        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
521
522        let result = store
523            .write_file_if_content_matches(
524                sid(),
525                "/notes.txt",
526                "original",
527                "text",
528                "updated",
529                "text",
530            )
531            .await
532            .unwrap();
533        assert!(result.is_some());
534        assert_eq!(gate.writes.lock().unwrap().len(), 1);
535    }
536
537    #[tokio::test]
538    async fn write_if_content_matches_with_stale_expected_returns_none_without_prompt() {
539        let inner_store: Arc<dyn SessionFileSystem> = inner();
540        inner_store
541            .write_file(sid(), "/notes.txt", "actual", "text")
542            .await
543            .unwrap();
544        let gate = Arc::new(RecordingGate::new(true));
545        let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
546
547        let result = store
548            .write_file_if_content_matches(
549                sid(),
550                "/notes.txt",
551                "stale-expected",
552                "text",
553                "new",
554                "text",
555            )
556            .await
557            .unwrap();
558        assert!(result.is_none());
559        assert!(gate.writes.lock().unwrap().is_empty());
560    }
561
562    struct MutatingGate {
563        inner: Arc<dyn SessionFileSystem>,
564    }
565
566    #[async_trait]
567    impl FileApprovalGate for MutatingGate {
568        async fn approve_write(&self, _path: &str, _before: Option<String>, _after: &str) -> bool {
569            self.inner
570                .write_file(sid(), "/notes.txt", "intruder", "text")
571                .await
572                .unwrap();
573            true
574        }
575
576        async fn approve_delete(&self, _path: &str, _recursive: bool) -> bool {
577            true
578        }
579    }
580
581    #[tokio::test]
582    async fn write_if_content_matches_rechecks_after_approval() {
583        let inner_store: Arc<dyn SessionFileSystem> = inner();
584        inner_store
585            .write_file(sid(), "/notes.txt", "original", "text")
586            .await
587            .unwrap();
588        let gate = Arc::new(MutatingGate {
589            inner: inner_store.clone(),
590        });
591        let store = ApprovalGatingFileStore::new(inner_store.clone(), gate);
592
593        let result = store
594            .write_file_if_content_matches(
595                sid(),
596                "/notes.txt",
597                "original",
598                "text",
599                "updated",
600                "text",
601            )
602            .await
603            .unwrap();
604
605        assert!(result.is_none());
606        let final_file = inner_store
607            .read_file(sid(), "/notes.txt")
608            .await
609            .unwrap()
610            .unwrap();
611        assert_eq!(final_file.content.as_deref(), Some("intruder"));
612    }
613}