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