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(
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 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 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 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 store
387 .write_file(sid(), "/.git/config", "ok", "text")
388 .await
389 .expect("custom blocklist replaces default");
390 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}