1use 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
26pub 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
41pub struct WriteBlocklistFileStore {
52 inner: Arc<dyn SessionFileSystem>,
53 blocklist: Vec<String>,
54}
55
56impl WriteBlocklistFileStore {
57 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 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#[async_trait]
186pub trait FileApprovalGate: Send + Sync {
187 async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool;
192
193 async fn approve_delete(&self, path: &str, recursive: bool) -> bool;
195}
196
197pub 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 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 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 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 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 store
411 .write_file(sid(), "/.git/config", "ok", "text")
412 .await
413 .expect("custom blocklist replaces default");
414 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}