1use 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
28pub 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
43pub struct WriteBlocklistFileStore {
54 inner: Arc<dyn SessionFileSystem>,
55 blocklist: Vec<String>,
56}
57
58impl WriteBlocklistFileStore {
59 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 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#[async_trait]
199pub trait FileApprovalGate: Send + Sync {
200 async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool;
205
206 async fn approve_delete(&self, path: &str, recursive: bool) -> bool;
208}
209
210pub 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 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 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 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 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 store
468 .write_file(sid(), "/.git/config", "ok", "text")
469 .await
470 .expect("custom blocklist replaces default");
471 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}