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
176#[async_trait]
182pub trait FileApprovalGate: Send + Sync {
183 async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool;
188
189 async fn approve_delete(&self, path: &str, recursive: bool) -> bool;
191}
192
193pub 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 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 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 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 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 store
403 .write_file(sid(), "/.git/config", "ok", "text")
404 .await
405 .expect("custom blocklist replaces default");
406 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}