1use async_trait::async_trait;
17use everruns_core::WorkspacePolicy;
18use everruns_core::session_file::{
19 FileInfo, FileStat, GrepMatch, GrepOptions, GrepSearchResult, InitialFile, SessionFile,
20 build_grep_search_result,
21};
22use everruns_core::session_files::SessionFileSystem;
23use everruns_provider::error::{AgentLoopError, Result};
24use everruns_provider::typed_id::SessionId;
25use std::collections::{HashSet, VecDeque};
26use std::path::Component;
27use std::sync::Arc;
28
29const MAX_POLICY_WALK_ENTRIES: usize = 100_000;
30
31const DEFAULT_WRITE_BLOCKLIST: &[&str] = &[
34 ".git",
35 "node_modules",
36 "target",
37 "dist",
38 "build",
39 ".next",
40 ".venv",
41 "venv",
42 ".tox",
43 ".gradle",
44];
45
46pub struct PolicyFileStore {
55 inner: Arc<dyn SessionFileSystem>,
56 policy: WorkspacePolicy,
57}
58
59impl PolicyFileStore {
60 pub fn new(inner: Arc<dyn SessionFileSystem>, policy: WorkspacePolicy) -> Self {
62 Self { inner, policy }
63 }
64
65 fn checked_path(&self, path: &str) -> Result<String> {
66 WorkspacePolicy::validate_path(path)
67 .map(|()| self.inner.resolve_path(path))
68 .map_err(|error| AgentLoopError::tool(error.to_string()))
69 }
70
71 fn check_read(&self, path: &str) -> Result<()> {
72 self.checked_path(path).and_then(|path| {
75 self.policy
76 .check_read(&path)
77 .map_err(|error| AgentLoopError::tool(error.to_string()))
78 })
79 }
80
81 fn check_write(&self, path: &str) -> Result<()> {
82 self.checked_path(path).and_then(|path| {
85 self.policy
86 .check_write(&path)
87 .map_err(|error| AgentLoopError::tool(error.to_string()))
88 })
89 }
90
91 async fn check_recursive_delete(&self, session_id: SessionId, path: &str) -> Result<()> {
92 if !self.policy.permits_recursive_delete() {
93 return Err(AgentLoopError::tool(format!(
94 "workspace policy denied recursive delete of `{path}`; recursive deletion requires an explicit opt-in"
95 )));
96 }
97
98 let Some(target) = self.inner.stat_file(session_id, path).await? else {
99 return Ok(());
100 };
101 if !target.is_directory {
102 return Ok(());
103 }
104
105 let mut pending = VecDeque::from([target.path]);
111 let mut visited = HashSet::new();
112 let mut entries_seen = 0usize;
113 while let Some(directory) = pending.pop_front() {
114 if !visited.insert(directory.clone()) {
115 continue;
116 }
117 for entry in self.inner.list_directory(session_id, &directory).await? {
118 entries_seen += 1;
119 if entries_seen > MAX_POLICY_WALK_ENTRIES {
120 return Err(AgentLoopError::tool(format!(
121 "workspace policy stopped recursive delete after {MAX_POLICY_WALK_ENTRIES} entries"
122 )));
123 }
124 self.check_write(&entry.path)?;
125 if entry.is_directory {
126 pending.push_back(entry.path);
127 }
128 }
129 }
130 Ok(())
131 }
132}
133
134#[async_trait]
135impl SessionFileSystem for PolicyFileStore {
136 fn display_root(&self) -> String {
137 self.inner.display_root()
138 }
139
140 fn display_path(&self, path: &str) -> String {
141 self.inner.display_path(path)
142 }
143
144 fn resolve_path(&self, input: &str) -> String {
145 self.inner.resolve_path(input)
146 }
147
148 fn is_mount_resolver(&self) -> bool {
149 self.inner.is_mount_resolver()
150 }
151
152 async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
153 self.check_read(path)?;
154 self.inner.read_file(session_id, path).await
155 }
156
157 async fn write_file(
158 &self,
159 session_id: SessionId,
160 path: &str,
161 content: &str,
162 encoding: &str,
163 ) -> Result<SessionFile> {
164 self.check_write(path)?;
165 self.inner
166 .write_file(session_id, path, content, encoding)
167 .await
168 }
169
170 async fn write_file_if_content_matches(
171 &self,
172 session_id: SessionId,
173 path: &str,
174 expected_content: &str,
175 expected_encoding: &str,
176 content: &str,
177 encoding: &str,
178 ) -> Result<Option<SessionFile>> {
179 self.check_write(path)?;
180 self.inner
181 .write_file_if_content_matches(
182 session_id,
183 path,
184 expected_content,
185 expected_encoding,
186 content,
187 encoding,
188 )
189 .await
190 }
191
192 async fn delete_file(
193 &self,
194 session_id: SessionId,
195 path: &str,
196 recursive: bool,
197 ) -> Result<bool> {
198 self.check_write(path)?;
199 if recursive {
200 self.check_recursive_delete(session_id, path).await?;
201 }
202 self.inner.delete_file(session_id, path, recursive).await
203 }
204
205 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
206 let canonical = self.checked_path(path)?;
207 if !self.policy.permits_read_traversal(&canonical) {
208 self.policy
209 .check_read(&canonical)
210 .map_err(|error| AgentLoopError::tool(error.to_string()))?;
211 }
212 let mut entries = self.inner.list_directory(session_id, path).await?;
213 entries.retain(|entry| {
214 self.checked_path(&entry.path).is_ok_and(|canonical| {
215 self.policy.permits_read(&canonical)
216 || self.policy.permits_read_traversal(&canonical)
217 })
218 });
219 Ok(entries)
220 }
221
222 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
223 let canonical = self.checked_path(path)?;
224 if !self.policy.permits_read_traversal(&canonical) {
225 self.policy
226 .check_read(&canonical)
227 .map_err(|error| AgentLoopError::tool(error.to_string()))?;
228 }
229 self.inner.stat_file(session_id, path).await
230 }
231
232 async fn grep_files(
233 &self,
234 session_id: SessionId,
235 pattern: &str,
236 path_pattern: Option<&str>,
237 ) -> Result<Vec<GrepMatch>> {
238 let result = self
239 .grep_files_with_options(
240 session_id,
241 pattern,
242 &GrepOptions {
243 path_pattern: path_pattern.map(ToString::to_string),
244 ..GrepOptions::default()
245 },
246 )
247 .await?;
248 Ok(result.matches)
249 }
250
251 async fn grep_files_with_options(
252 &self,
253 session_id: SessionId,
254 pattern: &str,
255 options: &GrepOptions,
256 ) -> Result<GrepSearchResult> {
257 let regex = crate::grep_limits::build_regex(pattern)?;
258 crate::grep_limits::validate_path_pattern(options.path_pattern.as_deref())?;
259 let path_pattern = options
260 .path_pattern
261 .as_deref()
262 .map(everruns_core::session_path::GrepPathPattern::new)
263 .transpose()?;
264
265 let mut pending = VecDeque::from(["/".to_string()]);
269 let mut visited = HashSet::new();
270 let mut text_files = Vec::new();
271 let mut total_scanned = 0usize;
272 let mut entries_seen = 0usize;
273 while let Some(directory) = pending.pop_front() {
274 if !visited.insert(directory.clone()) {
275 continue;
276 }
277 for entry in self.list_directory(session_id, &directory).await? {
278 entries_seen += 1;
279 if entries_seen > MAX_POLICY_WALK_ENTRIES {
280 return Err(AgentLoopError::tool(format!(
281 "workspace policy stopped grep after {MAX_POLICY_WALK_ENTRIES} entries"
282 )));
283 }
284 if entry.is_directory {
285 pending.push_back(entry.path);
286 continue;
287 }
288 if path_pattern
289 .as_ref()
290 .is_some_and(|matcher| !matcher.is_match(&entry.path))
291 {
292 continue;
293 }
294 let Some(file) = self.read_file(session_id, &entry.path).await? else {
295 continue;
296 };
297 if file.encoding != "text" {
298 continue;
299 }
300 let Some(content) = file.content else {
301 continue;
302 };
303 if crate::grep_limits::account_scan(&mut total_scanned, content.len())? {
304 text_files.push((entry.path, content));
305 }
306 }
307 }
308 Ok(build_grep_search_result(text_files, ®ex, options))
309 }
310
311 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
312 self.check_write(path)?;
313 self.inner.create_directory(session_id, path).await
314 }
315
316 async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
317 self.inner.seed_initial_file(session_id, file).await
318 }
319}
320
321pub struct WriteBlocklistFileStore {
332 inner: Arc<dyn SessionFileSystem>,
333 blocklist: Vec<String>,
334}
335
336impl WriteBlocklistFileStore {
337 pub fn new(inner: Arc<dyn SessionFileSystem>) -> Self {
339 Self {
340 inner,
341 blocklist: DEFAULT_WRITE_BLOCKLIST
342 .iter()
343 .map(|s| s.to_string())
344 .collect(),
345 }
346 }
347
348 pub fn with_blocklist(
350 inner: Arc<dyn SessionFileSystem>,
351 blocklist: impl IntoIterator<Item = impl Into<String>>,
352 ) -> Self {
353 Self {
354 inner,
355 blocklist: blocklist.into_iter().map(Into::into).collect(),
356 }
357 }
358
359 fn check(&self, path: &str) -> Result<()> {
360 let p = std::path::Path::new(path);
361 for comp in p.components() {
362 if let Component::Normal(name) = comp {
363 let s = name.to_string_lossy();
364 if self.blocklist.iter().any(|b| b == s.as_ref()) {
365 return Err(AgentLoopError::tool(format!(
366 "writes into `{s}/` are blocked; write blocklist rejected `{path}`"
367 )));
368 }
369 }
370 }
371 Ok(())
372 }
373}
374
375#[async_trait]
376impl SessionFileSystem for WriteBlocklistFileStore {
377 async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
378 self.inner.read_file(session_id, path).await
379 }
380
381 async fn write_file(
382 &self,
383 session_id: SessionId,
384 path: &str,
385 content: &str,
386 encoding: &str,
387 ) -> Result<SessionFile> {
388 self.check(path)?;
389 self.inner
390 .write_file(session_id, path, content, encoding)
391 .await
392 }
393
394 async fn write_file_if_content_matches(
395 &self,
396 session_id: SessionId,
397 path: &str,
398 expected_content: &str,
399 expected_encoding: &str,
400 content: &str,
401 encoding: &str,
402 ) -> Result<Option<SessionFile>> {
403 self.check(path)?;
404 self.inner
405 .write_file_if_content_matches(
406 session_id,
407 path,
408 expected_content,
409 expected_encoding,
410 content,
411 encoding,
412 )
413 .await
414 }
415
416 async fn delete_file(
417 &self,
418 session_id: SessionId,
419 path: &str,
420 recursive: bool,
421 ) -> Result<bool> {
422 self.check(path)?;
423 self.inner.delete_file(session_id, path, recursive).await
424 }
425
426 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
427 self.inner.list_directory(session_id, path).await
428 }
429
430 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
431 self.inner.stat_file(session_id, path).await
432 }
433
434 async fn grep_files(
435 &self,
436 session_id: SessionId,
437 pattern: &str,
438 path_pattern: Option<&str>,
439 ) -> Result<Vec<GrepMatch>> {
440 self.inner
441 .grep_files(session_id, pattern, path_pattern)
442 .await
443 }
444
445 async fn grep_files_with_options(
446 &self,
447 session_id: SessionId,
448 pattern: &str,
449 options: &GrepOptions,
450 ) -> Result<GrepSearchResult> {
451 self.inner
452 .grep_files_with_options(session_id, pattern, options)
453 .await
454 }
455
456 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
457 self.check(path)?;
458 self.inner.create_directory(session_id, path).await
459 }
460
461 async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
462 self.check(&file.path)?;
463 self.inner.seed_initial_file(session_id, file).await
464 }
465
466 fn is_mount_resolver(&self) -> bool {
467 self.inner.is_mount_resolver()
468 }
469}
470
471#[async_trait]
477pub trait FileApprovalGate: Send + Sync {
478 async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool;
483
484 async fn approve_delete(&self, path: &str, recursive: bool) -> bool;
486}
487
488pub struct ApprovalGatingFileStore {
498 inner: Arc<dyn SessionFileSystem>,
499 gate: Arc<dyn FileApprovalGate>,
500}
501
502impl ApprovalGatingFileStore {
503 pub fn new(inner: Arc<dyn SessionFileSystem>, gate: Arc<dyn FileApprovalGate>) -> Self {
504 Self { inner, gate }
505 }
506
507 async fn gated_write_with_before(
514 &self,
515 session_id: SessionId,
516 path: &str,
517 before: Option<String>,
518 content: &str,
519 encoding: &str,
520 ) -> Result<SessionFile> {
521 let approved = self.gate.approve_write(path, before, content).await;
522 if !approved {
523 return Err(AgentLoopError::tool(format!(
524 "user denied write to `{path}`"
525 )));
526 }
527 self.inner
528 .write_file(session_id, path, content, encoding)
529 .await
530 }
531}
532
533#[async_trait]
534impl SessionFileSystem for ApprovalGatingFileStore {
535 async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
536 self.inner.read_file(session_id, path).await
537 }
538
539 async fn write_file(
540 &self,
541 session_id: SessionId,
542 path: &str,
543 content: &str,
544 encoding: &str,
545 ) -> Result<SessionFile> {
546 let before = self
550 .inner
551 .read_file(session_id, path)
552 .await?
553 .and_then(|f| f.content);
554 self.gated_write_with_before(session_id, path, before, content, encoding)
555 .await
556 }
557
558 async fn write_file_if_content_matches(
559 &self,
560 session_id: SessionId,
561 path: &str,
562 expected_content: &str,
563 expected_encoding: &str,
564 content: &str,
565 encoding: &str,
566 ) -> Result<Option<SessionFile>> {
567 let Some(existing) = self.inner.read_file(session_id, path).await? else {
571 return Ok(None);
572 };
573 if existing.is_directory {
574 return Ok(None);
575 }
576 let current = existing.content.unwrap_or_default();
577 if current != expected_content || existing.encoding != expected_encoding {
578 return Ok(None);
579 }
580 let approved = self.gate.approve_write(path, Some(current), content).await;
581 if !approved {
582 return Err(AgentLoopError::tool(format!(
583 "user denied write to `{path}`"
584 )));
585 }
586
587 self.inner
588 .write_file_if_content_matches(
589 session_id,
590 path,
591 expected_content,
592 expected_encoding,
593 content,
594 encoding,
595 )
596 .await
597 }
598
599 async fn delete_file(
600 &self,
601 session_id: SessionId,
602 path: &str,
603 recursive: bool,
604 ) -> Result<bool> {
605 let approved = self.gate.approve_delete(path, recursive).await;
606 if !approved {
607 return Err(AgentLoopError::tool(format!(
608 "user denied delete of `{path}`"
609 )));
610 }
611 self.inner.delete_file(session_id, path, recursive).await
612 }
613
614 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
615 self.inner.list_directory(session_id, path).await
616 }
617
618 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
619 self.inner.stat_file(session_id, path).await
620 }
621
622 async fn grep_files(
623 &self,
624 session_id: SessionId,
625 pattern: &str,
626 path_pattern: Option<&str>,
627 ) -> Result<Vec<GrepMatch>> {
628 self.inner
629 .grep_files(session_id, pattern, path_pattern)
630 .await
631 }
632
633 async fn grep_files_with_options(
634 &self,
635 session_id: SessionId,
636 pattern: &str,
637 options: &GrepOptions,
638 ) -> Result<GrepSearchResult> {
639 self.inner
640 .grep_files_with_options(session_id, pattern, options)
641 .await
642 }
643
644 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
645 self.inner.create_directory(session_id, path).await
646 }
647
648 async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
649 self.inner.seed_initial_file(session_id, file).await
650 }
651
652 fn is_mount_resolver(&self) -> bool {
653 self.inner.is_mount_resolver()
654 }
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use crate::in_memory::InMemorySessionFileStore;
661 use std::sync::Mutex;
662
663 fn sid() -> SessionId {
664 "session_00000000000000000000000000000001".parse().unwrap()
665 }
666
667 fn inner() -> Arc<dyn SessionFileSystem> {
668 Arc::new(InMemorySessionFileStore::new())
669 }
670
671 #[tokio::test]
672 async fn write_blocklist_rejects_blocked_paths() {
673 let store = WriteBlocklistFileStore::new(inner());
674 let err = store
675 .write_file(sid(), "/.git/config", "bad", "text")
676 .await
677 .expect_err("write into .git must be rejected");
678 assert!(format!("{err}").contains(".git"));
679 }
680
681 #[tokio::test]
682 async fn write_blocklist_allows_unblocked_paths() {
683 let store = WriteBlocklistFileStore::new(inner());
684 store
685 .write_file(sid(), "/src/main.rs", "fn main() {}", "text")
686 .await
687 .expect("write outside blocklist must succeed");
688 }
689
690 #[tokio::test]
691 async fn write_blocklist_reads_pass_through_blocked() {
692 let inner_store: Arc<dyn SessionFileSystem> = inner();
695 inner_store
696 .write_file(sid(), "/.git/config", "settings", "text")
697 .await
698 .unwrap();
699 let store = WriteBlocklistFileStore::new(inner_store);
700 let file = store
701 .read_file(sid(), "/.git/config")
702 .await
703 .unwrap()
704 .expect("read through blocklist must succeed");
705 assert_eq!(file.content.as_deref(), Some("settings"));
706 }
707
708 #[tokio::test]
709 async fn write_blocklist_contextual_grep_passes_through() {
710 let inner_store: Arc<dyn SessionFileSystem> = inner();
711 inner_store
712 .write_file(
713 sid(),
714 "/output.log",
715 "before\nError: failed\nROOT_CAUSE=duplicate_sentinel\nafter\n",
716 "text",
717 )
718 .await
719 .unwrap();
720 let store = WriteBlocklistFileStore::new(inner_store);
721
722 let result = store
723 .grep_files_with_options(
724 sid(),
725 "Error|failed",
726 &GrepOptions {
727 before_context: 1,
728 after_context: 1,
729 ..GrepOptions::default()
730 },
731 )
732 .await
733 .expect("contextual grep must pass through the decorator");
734
735 assert_eq!(result.returned_matches, 1);
736 assert_eq!(result.blocks.len(), 1);
737 assert_eq!(result.blocks[0].start_line, 1);
738 assert_eq!(result.blocks[0].end_line, 3);
739 }
740
741 #[tokio::test]
742 async fn write_blocklist_custom_overrides_default() {
743 let store = WriteBlocklistFileStore::with_blocklist(inner(), ["forbidden"]);
744 store
746 .write_file(sid(), "/.git/config", "ok", "text")
747 .await
748 .expect("custom blocklist replaces default");
749 let err = store
751 .write_file(sid(), "/forbidden/x", "no", "text")
752 .await
753 .expect_err("custom blocklist entry must be enforced");
754 assert!(format!("{err}").contains("forbidden"));
755 }
756
757 #[tokio::test]
758 async fn workspace_policy_filters_reads_listings_and_grep_summaries() {
759 let inner_store: Arc<dyn SessionFileSystem> = inner();
760 for (path, content) in [
761 ("/src/lib.rs", "visible sentinel"),
762 ("/.env", "hidden sentinel"),
763 ("/private/secret.txt", "private sentinel"),
764 ] {
765 inner_store
766 .write_file(sid(), path, content, "text")
767 .await
768 .unwrap();
769 }
770 let policy = WorkspacePolicy::builder()
771 .allow_read("/")
772 .deny_read("private")
773 .build()
774 .unwrap();
775 let store = PolicyFileStore::new(inner_store, policy);
776
777 assert!(store.read_file(sid(), "/src/lib.rs").await.is_ok());
778 assert!(store.read_file(sid(), "/.env").await.is_err());
779 assert!(store.read_file(sid(), "/private/secret.txt").await.is_err());
780
781 let listed = store.list_directory(sid(), "/").await.unwrap();
782 assert_eq!(
783 listed
784 .iter()
785 .map(|entry| entry.path.as_str())
786 .collect::<Vec<_>>(),
787 vec!["/src"]
788 );
789
790 let result = store
791 .grep_files_with_options(sid(), "sentinel", &GrepOptions::default())
792 .await
793 .unwrap();
794 assert_eq!(result.returned_matches, 1);
795 assert_eq!(result.total_matches, 1);
796 assert_eq!(result.matches[0].path, "/src/lib.rs");
797 }
798
799 #[tokio::test]
800 async fn workspace_policy_enforces_write_scope_deny_precedence_and_recursive_opt_in() {
801 let policy = WorkspacePolicy::builder()
802 .allow_read("/")
803 .allow_write("output")
804 .deny_write("output/locked")
805 .allow_recursive_delete(false)
806 .build()
807 .unwrap();
808 let store = PolicyFileStore::new(inner(), policy);
809
810 store
811 .write_file(sid(), "/output/report.txt", "ok", "text")
812 .await
813 .unwrap();
814 assert!(
815 store
816 .write_file(sid(), "/output/locked/report.txt", "no", "text")
817 .await
818 .is_err()
819 );
820 assert!(
821 store
822 .write_file(sid(), "/src/lib.rs", "no", "text")
823 .await
824 .is_err()
825 );
826 assert!(store.delete_file(sid(), "/output", true).await.is_err());
827 }
828
829 #[tokio::test]
830 async fn recursive_delete_opt_in_does_not_override_a_denied_descendant() {
831 let inner_store: Arc<dyn SessionFileSystem> = inner();
832 inner_store
833 .write_file(sid(), "/output/report.txt", "ok", "text")
834 .await
835 .unwrap();
836 inner_store
837 .write_file(sid(), "/output/locked/secret.txt", "no", "text")
838 .await
839 .unwrap();
840 let policy = WorkspacePolicy::builder()
841 .allow_write("output")
842 .deny_write("output/locked")
843 .allow_recursive_delete(true)
844 .build()
845 .unwrap();
846 let store = PolicyFileStore::new(inner_store.clone(), policy);
847
848 let error = store.delete_file(sid(), "/output", true).await.unwrap_err();
849 assert!(error.to_string().contains("/workspace/output/locked"));
850 assert!(
851 inner_store
852 .read_file(sid(), "/output/report.txt")
853 .await
854 .unwrap()
855 .is_some()
856 );
857 }
858
859 #[tokio::test]
860 async fn trusted_seed_bypasses_policy_but_later_access_does_not() {
861 let store = PolicyFileStore::new(inner(), WorkspacePolicy::default());
862 store
863 .seed_initial_file(
864 sid(),
865 &InitialFile {
866 path: "/.env".to_string(),
867 content: "TOKEN=secret".to_string(),
868 encoding: "text".to_string(),
869 is_readonly: true,
870 },
871 )
872 .await
873 .unwrap();
874 assert!(store.read_file(sid(), "/.env").await.is_err());
875 }
876
877 #[tokio::test]
878 async fn workspace_policy_rejects_traversal_before_backend_access() {
879 let store = PolicyFileStore::new(inner(), WorkspacePolicy::read_write());
880 let error = store
881 .write_file(sid(), "/workspace/src/../../outside", "no", "text")
882 .await
883 .unwrap_err();
884 assert!(error.to_string().contains("traversal"));
885 }
886
887 #[cfg(unix)]
888 #[tokio::test]
889 async fn host_symlink_swap_between_operations_is_rejected() {
890 use std::os::unix::fs::symlink;
891
892 let workspace = tempfile::tempdir().unwrap();
893 let outside = tempfile::tempdir().unwrap();
894 std::fs::create_dir(workspace.path().join("current")).unwrap();
895 std::fs::write(outside.path().join("secret.txt"), "outside").unwrap();
896 let inner: Arc<dyn SessionFileSystem> =
897 Arc::new(crate::real_disk::RealDiskFileStore::new(workspace.path()).unwrap());
898 let store = PolicyFileStore::new(inner, WorkspacePolicy::read_write());
899
900 store
901 .write_file(sid(), "/current/safe.txt", "inside", "text")
902 .await
903 .unwrap();
904 std::fs::remove_dir_all(workspace.path().join("current")).unwrap();
905 symlink(outside.path(), workspace.path().join("current")).unwrap();
906
907 let error = store
908 .read_file(sid(), "/current/secret.txt")
909 .await
910 .unwrap_err();
911 assert!(error.to_string().contains("symlink"));
912 }
913
914 #[tokio::test]
915 async fn host_absolute_path_outside_root_cannot_expose_host_file() {
916 let workspace = tempfile::tempdir().unwrap();
917 let outside = tempfile::NamedTempFile::new().unwrap();
918 std::fs::write(outside.path(), "outside").unwrap();
919 let inner: Arc<dyn SessionFileSystem> =
920 Arc::new(crate::real_disk::RealDiskFileStore::new(workspace.path()).unwrap());
921 let store = PolicyFileStore::new(inner, WorkspacePolicy::read_write());
922
923 match store
924 .read_file(sid(), outside.path().to_str().unwrap())
925 .await
926 {
927 Ok(None) | Err(_) => {}
928 Ok(Some(file)) => assert_ne!(
929 file.content.as_deref(),
930 Some("outside"),
931 "an absolute path outside the root must not expose that host file"
932 ),
933 }
934 }
935
936 #[tokio::test]
937 async fn host_absolute_alias_cannot_bypass_canonical_deny_scope() {
938 let workspace = tempfile::tempdir().unwrap();
939 std::fs::create_dir(workspace.path().join("private")).unwrap();
940 let secret = workspace.path().join("private/secret.txt");
941 std::fs::write(&secret, "secret").unwrap();
942 let inner: Arc<dyn SessionFileSystem> =
943 Arc::new(crate::real_disk::RealDiskFileStore::new(workspace.path()).unwrap());
944 let policy = WorkspacePolicy::builder()
945 .allow_read("/")
946 .deny_read("private")
947 .build()
948 .unwrap();
949 let store = PolicyFileStore::new(inner, policy);
950
951 let canonical_secret = secret.canonicalize().unwrap();
954 let error = store
955 .read_file(sid(), canonical_secret.to_str().unwrap())
956 .await
957 .unwrap_err();
958 let message = error.to_string();
959 assert!(
960 message.contains("/workspace/private/secret.txt"),
961 "unexpected policy diagnostic: {message}"
962 );
963 }
964
965 struct RecordingGate {
966 approve: bool,
967 writes: Mutex<Vec<(String, Option<String>, String)>>,
968 deletes: Mutex<Vec<(String, bool)>>,
969 }
970
971 impl RecordingGate {
972 fn new(approve: bool) -> Self {
973 Self {
974 approve,
975 writes: Mutex::new(Vec::new()),
976 deletes: Mutex::new(Vec::new()),
977 }
978 }
979 }
980
981 #[async_trait]
982 impl FileApprovalGate for RecordingGate {
983 async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool {
984 self.writes
985 .lock()
986 .unwrap()
987 .push((path.to_string(), before, after.to_string()));
988 self.approve
989 }
990
991 async fn approve_delete(&self, path: &str, recursive: bool) -> bool {
992 self.deletes
993 .lock()
994 .unwrap()
995 .push((path.to_string(), recursive));
996 self.approve
997 }
998 }
999
1000 #[tokio::test]
1001 async fn approval_gating_denies_write_when_user_rejects() {
1002 let gate = Arc::new(RecordingGate::new(false));
1003 let store = ApprovalGatingFileStore::new(inner(), gate.clone());
1004 let err = store
1005 .write_file(sid(), "/notes.txt", "new", "text")
1006 .await
1007 .expect_err("rejected write must surface as tool error");
1008 assert!(format!("{err}").contains("denied"));
1009 assert_eq!(gate.writes.lock().unwrap().len(), 1);
1010 }
1011
1012 #[tokio::test]
1013 async fn approval_gating_approves_write_and_passes_before_after() {
1014 let inner_store: Arc<dyn SessionFileSystem> = inner();
1015 inner_store
1016 .write_file(sid(), "/notes.txt", "original", "text")
1017 .await
1018 .unwrap();
1019 let gate = Arc::new(RecordingGate::new(true));
1020 let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
1021 let file = store
1022 .write_file(sid(), "/notes.txt", "updated", "text")
1023 .await
1024 .expect("approved write must succeed");
1025 assert_eq!(file.content.as_deref(), Some("updated"));
1026 let writes = gate.writes.lock().unwrap();
1027 assert_eq!(writes.len(), 1);
1028 assert_eq!(writes[0].0, "/notes.txt");
1029 assert_eq!(writes[0].1.as_deref(), Some("original"));
1030 assert_eq!(writes[0].2, "updated");
1031 }
1032
1033 #[tokio::test]
1034 async fn approval_gating_denies_delete_when_user_rejects() {
1035 let inner_store: Arc<dyn SessionFileSystem> = inner();
1036 inner_store
1037 .write_file(sid(), "/scratch.txt", "x", "text")
1038 .await
1039 .unwrap();
1040 let gate = Arc::new(RecordingGate::new(false));
1041 let store = ApprovalGatingFileStore::new(inner_store, gate);
1042 let err = store
1043 .delete_file(sid(), "/scratch.txt", false)
1044 .await
1045 .expect_err("rejected delete must surface as tool error");
1046 assert!(format!("{err}").contains("denied"));
1047 }
1048
1049 #[tokio::test]
1050 async fn approval_gating_reads_pass_through_without_prompt() {
1051 let inner_store: Arc<dyn SessionFileSystem> = inner();
1052 inner_store
1053 .write_file(sid(), "/notes.txt", "hi", "text")
1054 .await
1055 .unwrap();
1056 let gate = Arc::new(RecordingGate::new(false));
1057 let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
1058 let file = store.read_file(sid(), "/notes.txt").await.unwrap();
1059 assert_eq!(file.unwrap().content.as_deref(), Some("hi"));
1060 assert!(gate.writes.lock().unwrap().is_empty());
1061 }
1062
1063 #[tokio::test]
1064 async fn approval_gating_contextual_grep_passes_through_without_prompt() {
1065 let inner_store: Arc<dyn SessionFileSystem> = inner();
1066 inner_store
1067 .write_file(
1068 sid(),
1069 "/output.log",
1070 "before\nError: failed\nROOT_CAUSE=duplicate_sentinel\nafter\n",
1071 "text",
1072 )
1073 .await
1074 .unwrap();
1075 let gate = Arc::new(RecordingGate::new(false));
1076 let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
1077
1078 let result = store
1079 .grep_files_with_options(
1080 sid(),
1081 "Error|failed",
1082 &GrepOptions {
1083 before_context: 1,
1084 after_context: 1,
1085 ..GrepOptions::default()
1086 },
1087 )
1088 .await
1089 .expect("contextual grep must pass through the decorator");
1090
1091 assert_eq!(result.returned_matches, 1);
1092 assert_eq!(result.blocks.len(), 1);
1093 assert!(gate.writes.lock().unwrap().is_empty());
1094 assert!(gate.deletes.lock().unwrap().is_empty());
1095 }
1096
1097 #[tokio::test]
1098 async fn write_if_content_matches_takes_one_approval_per_write() {
1099 let inner_store: Arc<dyn SessionFileSystem> = inner();
1100 inner_store
1101 .write_file(sid(), "/notes.txt", "original", "text")
1102 .await
1103 .unwrap();
1104 let gate = Arc::new(RecordingGate::new(true));
1105 let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
1106
1107 let result = store
1108 .write_file_if_content_matches(
1109 sid(),
1110 "/notes.txt",
1111 "original",
1112 "text",
1113 "updated",
1114 "text",
1115 )
1116 .await
1117 .unwrap();
1118 assert!(result.is_some());
1119 assert_eq!(gate.writes.lock().unwrap().len(), 1);
1120 }
1121
1122 #[tokio::test]
1123 async fn write_if_content_matches_with_stale_expected_returns_none_without_prompt() {
1124 let inner_store: Arc<dyn SessionFileSystem> = inner();
1125 inner_store
1126 .write_file(sid(), "/notes.txt", "actual", "text")
1127 .await
1128 .unwrap();
1129 let gate = Arc::new(RecordingGate::new(true));
1130 let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
1131
1132 let result = store
1133 .write_file_if_content_matches(
1134 sid(),
1135 "/notes.txt",
1136 "stale-expected",
1137 "text",
1138 "new",
1139 "text",
1140 )
1141 .await
1142 .unwrap();
1143 assert!(result.is_none());
1144 assert!(gate.writes.lock().unwrap().is_empty());
1145 }
1146
1147 struct MutatingGate {
1148 inner: Arc<dyn SessionFileSystem>,
1149 }
1150
1151 #[async_trait]
1152 impl FileApprovalGate for MutatingGate {
1153 async fn approve_write(&self, _path: &str, _before: Option<String>, _after: &str) -> bool {
1154 self.inner
1155 .write_file(sid(), "/notes.txt", "intruder", "text")
1156 .await
1157 .unwrap();
1158 true
1159 }
1160
1161 async fn approve_delete(&self, _path: &str, _recursive: bool) -> bool {
1162 true
1163 }
1164 }
1165
1166 #[tokio::test]
1167 async fn write_if_content_matches_rechecks_after_approval() {
1168 let inner_store: Arc<dyn SessionFileSystem> = inner();
1169 inner_store
1170 .write_file(sid(), "/notes.txt", "original", "text")
1171 .await
1172 .unwrap();
1173 let gate = Arc::new(MutatingGate {
1174 inner: inner_store.clone(),
1175 });
1176 let store = ApprovalGatingFileStore::new(inner_store.clone(), gate);
1177
1178 let result = store
1179 .write_file_if_content_matches(
1180 sid(),
1181 "/notes.txt",
1182 "original",
1183 "text",
1184 "updated",
1185 "text",
1186 )
1187 .await
1188 .unwrap();
1189
1190 assert!(result.is_none());
1191 let final_file = inner_store
1192 .read_file(sid(), "/notes.txt")
1193 .await
1194 .unwrap()
1195 .unwrap();
1196 assert_eq!(final_file.content.as_deref(), Some("intruder"));
1197 }
1198}