1use std::fmt;
8
9const SENSITIVE_COMPONENTS: &[&str] = &[
10 ".aws",
11 ".azure",
12 ".docker",
13 ".git",
14 ".git-credentials",
15 ".gnupg",
16 ".kube",
17 ".netrc",
18 ".npmrc",
19 ".pypirc",
20 ".ssh",
21 "credentials",
22 "credentials.json",
23 "gcloud",
24];
25
26const DEFAULT_WRITE_DENY_COMPONENTS: &[&str] = &[
27 "node_modules",
28 "target",
29 "dist",
30 "build",
31 ".next",
32 ".venv",
33 "venv",
34 ".tox",
35 ".gradle",
36];
37
38#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct WorkspacePolicy {
56 layers: Vec<PolicyLayer>,
57}
58
59#[derive(Clone, Debug, Default)]
64pub struct WorkspacePolicyBuilder {
65 read: Vec<String>,
66 write: Vec<String>,
67 deny_read: Vec<String>,
68 deny_write: Vec<String>,
69 deny_write_components: Vec<String>,
70 hidden: Vec<String>,
71 sensitive: Vec<String>,
72 recursive_delete: bool,
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct WorkspacePolicyError {
78 message: String,
79}
80
81impl WorkspacePolicyError {
82 fn new(message: impl Into<String>) -> Self {
83 Self {
84 message: message.into(),
85 }
86 }
87}
88
89impl fmt::Display for WorkspacePolicyError {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str(&self.message)
92 }
93}
94
95impl std::error::Error for WorkspacePolicyError {}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98struct PolicyLayer {
99 read: Vec<PolicyPath>,
100 write: Vec<PolicyPath>,
101 deny_read: Vec<PolicyPath>,
102 deny_write: Vec<PolicyPath>,
103 deny_write_components: Vec<String>,
104 hidden: Vec<PolicyPath>,
105 sensitive: Vec<PolicyPath>,
106 recursive_delete: bool,
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
110struct PolicyPath(Vec<String>);
111
112impl WorkspacePolicy {
113 pub fn read_only() -> Self {
119 Self {
120 layers: vec![PolicyLayer {
121 read: vec![PolicyPath::root()],
122 write: Vec::new(),
123 deny_read: Vec::new(),
124 deny_write: Vec::new(),
125 deny_write_components: Vec::new(),
126 hidden: vec![PolicyPath(vec![".agents".to_string()])],
127 sensitive: Vec::new(),
128 recursive_delete: false,
129 }],
130 }
131 }
132
133 pub fn read_write() -> Self {
140 Self {
141 layers: vec![PolicyLayer {
142 read: vec![PolicyPath::root()],
143 write: vec![PolicyPath::root()],
144 deny_read: Vec::new(),
145 deny_write: vec![PolicyPath(vec![".agents".to_string()])],
146 deny_write_components: DEFAULT_WRITE_DENY_COMPONENTS
147 .iter()
148 .map(|component| (*component).to_string())
149 .collect(),
150 hidden: vec![PolicyPath(vec![".agents".to_string()])],
151 sensitive: Vec::new(),
152 recursive_delete: false,
153 }],
154 }
155 }
156
157 pub fn builder() -> WorkspacePolicyBuilder {
159 WorkspacePolicyBuilder::default()
160 }
161
162 pub fn compose(mut self, additional: Self) -> Self {
167 self.layers.extend(additional.layers);
168 self
169 }
170
171 pub fn permits_read(&self, path: &str) -> bool {
175 PolicyPath::parse(path)
176 .is_ok_and(|path| self.layers.iter().all(|layer| layer.permits_read(&path)))
177 }
178
179 pub fn permits_write(&self, path: &str) -> bool {
183 PolicyPath::parse(path)
184 .is_ok_and(|path| self.layers.iter().all(|layer| layer.permits_write(&path)))
185 }
186
187 pub fn permits_read_traversal(&self, path: &str) -> bool {
192 PolicyPath::parse(path).is_ok_and(|path| {
193 self.layers
194 .iter()
195 .all(|layer| layer.permits_read_traversal(&path))
196 })
197 }
198
199 pub fn validate_path(path: &str) -> Result<(), WorkspacePolicyError> {
205 PolicyPath::parse(path).map(|_| ())
206 }
207
208 pub fn check_read(&self, path: &str) -> Result<(), WorkspacePolicyError> {
210 let normalized = PolicyPath::parse(path)?;
211 if self
212 .layers
213 .iter()
214 .all(|layer| layer.permits_read(&normalized))
215 {
216 Ok(())
217 } else {
218 Err(WorkspacePolicyError::new(format!(
219 "workspace policy denied read of `{}`",
220 normalized.display()
221 )))
222 }
223 }
224
225 pub fn check_write(&self, path: &str) -> Result<(), WorkspacePolicyError> {
227 let normalized = PolicyPath::parse(path)?;
228 if self
229 .layers
230 .iter()
231 .all(|layer| layer.permits_write(&normalized))
232 {
233 Ok(())
234 } else {
235 Err(WorkspacePolicyError::new(format!(
236 "workspace policy denied write to `{}`",
237 normalized.display()
238 )))
239 }
240 }
241
242 pub fn permits_recursive_delete(&self) -> bool {
245 self.layers.iter().all(|layer| layer.recursive_delete)
246 }
247}
248
249impl Default for WorkspacePolicy {
250 fn default() -> Self {
251 Self::read_only()
252 }
253}
254
255impl WorkspacePolicyBuilder {
256 pub fn allow_read(mut self, path: impl Into<String>) -> Self {
261 self.read.push(path.into());
262 self
263 }
264
265 pub fn allow_write(mut self, path: impl Into<String>) -> Self {
267 self.write.push(path.into());
268 self
269 }
270
271 pub fn deny_read(mut self, path: impl Into<String>) -> Self {
273 self.deny_read.push(path.into());
274 self
275 }
276
277 pub fn deny_write(mut self, path: impl Into<String>) -> Self {
279 self.deny_write.push(path.into());
280 self
281 }
282
283 pub fn deny_write_component(mut self, component: impl Into<String>) -> Self {
289 self.deny_write_components.push(component.into());
290 self
291 }
292
293 pub fn allow_hidden(mut self, path: impl Into<String>) -> Self {
298 self.hidden.push(path.into());
299 self
300 }
301
302 pub fn allow_sensitive(mut self, path: impl Into<String>) -> Self {
307 self.sensitive.push(path.into());
308 self
309 }
310
311 pub fn allow_recursive_delete(mut self, allow: bool) -> Self {
317 self.recursive_delete = allow;
318 self
319 }
320
321 pub fn build(self) -> Result<WorkspacePolicy, WorkspacePolicyError> {
323 Ok(WorkspacePolicy {
324 layers: vec![PolicyLayer {
325 read: parse_paths("read allow", self.read)?,
326 write: parse_paths("write allow", self.write)?,
327 deny_read: parse_paths("read deny", self.deny_read)?,
328 deny_write: parse_paths("write deny", self.deny_write)?,
329 deny_write_components: parse_components(
330 "write deny component",
331 self.deny_write_components,
332 )?,
333 hidden: parse_paths("hidden allow", self.hidden)?,
334 sensitive: parse_paths("sensitive allow", self.sensitive)?,
335 recursive_delete: self.recursive_delete,
336 }],
337 })
338 }
339}
340
341impl PolicyLayer {
342 fn permits_read(&self, path: &PolicyPath) -> bool {
343 self.read.iter().any(|scope| scope.contains(path))
344 && !self
345 .deny_read
346 .iter()
347 .any(|scope| scope.contains_ignoring_ascii_case(path))
348 && self.permits_protected_components(path)
349 }
350
351 fn permits_read_traversal(&self, path: &PolicyPath) -> bool {
352 (self
353 .read
354 .iter()
355 .any(|scope| scope.contains(path) || path.contains(scope)))
356 && !self
357 .deny_read
358 .iter()
359 .any(|scope| scope.contains_ignoring_ascii_case(path))
360 && self.permits_protected_traversal(path)
361 }
362
363 fn permits_write(&self, path: &PolicyPath) -> bool {
364 self.write.iter().any(|scope| scope.contains(path))
365 && !self
366 .deny_write
367 .iter()
368 .any(|scope| scope.contains_ignoring_ascii_case(path))
369 && !path.has_any_component(&self.deny_write_components)
370 && self.permits_protected_components(path)
371 }
372
373 fn permits_protected_components(&self, path: &PolicyPath) -> bool {
374 if self.sensitive.iter().any(|scope| scope.contains(path)) {
375 return true;
376 }
377 if path.has_sensitive_component() {
378 return false;
379 }
380 !path.has_hidden_component() || self.hidden.iter().any(|scope| scope.contains(path))
381 }
382
383 fn permits_protected_traversal(&self, path: &PolicyPath) -> bool {
384 if path.has_sensitive_component() {
385 return self
386 .sensitive
387 .iter()
388 .any(|scope| scope.contains(path) || path.contains(scope));
389 }
390 !path.has_hidden_component()
391 || self
392 .hidden
393 .iter()
394 .any(|scope| scope.contains(path) || path.contains(scope))
395 }
396}
397
398impl PolicyPath {
399 fn root() -> Self {
400 Self(Vec::new())
401 }
402
403 fn parse(input: &str) -> Result<Self, WorkspacePolicyError> {
404 let trimmed = input.trim();
405 if trimmed.contains('\0') {
406 return Err(WorkspacePolicyError::new(format!(
407 "workspace path contains a NUL byte: {input:?}"
408 )));
409 }
410 if trimmed.contains('\\') {
411 return Err(WorkspacePolicyError::new(format!(
412 "workspace paths must use forward slashes: {input:?}"
413 )));
414 }
415
416 let canonical = crate::session_path::to_session_path(trimmed);
417 let without_alias = canonical.trim_start_matches('/');
418
419 let mut components = Vec::new();
420 for component in without_alias.split('/') {
421 match component {
422 "" | "." => {}
423 ".." => {
424 return Err(WorkspacePolicyError::new(format!(
425 "workspace path traversal is not allowed: {input:?}"
426 )));
427 }
428 value => components.push(value.to_string()),
429 }
430 }
431 Ok(Self(components))
432 }
433
434 fn contains(&self, candidate: &Self) -> bool {
435 candidate.0.starts_with(&self.0)
437 }
438
439 fn contains_ignoring_ascii_case(&self, candidate: &Self) -> bool {
440 self.0.len() <= candidate.0.len()
442 && self
443 .0
444 .iter()
445 .zip(&candidate.0)
446 .all(|(scope, component)| scope.eq_ignore_ascii_case(component))
447 }
448
449 fn has_hidden_component(&self) -> bool {
450 self.0.iter().any(|component| component.starts_with('.'))
451 }
452
453 fn has_any_component(&self, denied: &[String]) -> bool {
454 self.0.iter().any(|component| {
455 denied
456 .iter()
457 .any(|denied| denied.eq_ignore_ascii_case(component))
458 })
459 }
460
461 fn has_sensitive_component(&self) -> bool {
462 self.0.iter().any(|component| {
463 let component = component.to_ascii_lowercase();
464 component == ".env"
465 || component.starts_with(".env.")
466 || SENSITIVE_COMPONENTS.contains(&component.as_str())
467 })
468 }
469
470 fn display(&self) -> String {
471 if self.0.is_empty() {
472 "/workspace".to_string()
473 } else {
474 format!("/workspace/{}", self.0.join("/"))
475 }
476 }
477}
478
479fn parse_paths(kind: &str, paths: Vec<String>) -> Result<Vec<PolicyPath>, WorkspacePolicyError> {
480 paths
481 .into_iter()
482 .map(|path| {
483 if path.trim().is_empty() {
484 return Err(WorkspacePolicyError::new(format!(
485 "invalid {kind} scope {path:?}: path must not be empty"
486 )));
487 }
488 PolicyPath::parse(&path).map_err(|error| {
489 WorkspacePolicyError::new(format!("invalid {kind} scope {path:?}: {error}"))
490 })
491 })
492 .collect()
493}
494
495fn parse_components(
496 kind: &str,
497 components: Vec<String>,
498) -> Result<Vec<String>, WorkspacePolicyError> {
499 components
500 .into_iter()
501 .map(|component| {
502 let trimmed = component.trim();
503 if trimmed.is_empty()
504 || matches!(trimmed, "." | "..")
505 || trimmed.contains(['/', '\\', '\0'])
506 {
507 return Err(WorkspacePolicyError::new(format!(
508 "invalid {kind} {component:?}: expected one path component"
509 )));
510 }
511 Ok(trimmed.to_ascii_lowercase())
512 })
513 .collect()
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn default_is_read_only_and_hides_sensitive_paths() {
522 let policy = WorkspacePolicy::default();
523 assert!(policy.permits_read("/workspace/src/lib.rs"));
524 assert!(!policy.permits_write("/workspace/src/lib.rs"));
525 assert!(!policy.permits_read("/workspace/.env"));
526 assert!(!policy.permits_read("/workspace/.git/config"));
527 assert!(policy.permits_read("/workspace/.agents/skills/example/SKILL.md"));
528 assert!(!policy.permits_recursive_delete());
529 }
530
531 #[test]
532 fn read_write_keeps_framework_managed_content_read_only() {
533 let policy = WorkspacePolicy::read_write();
534
535 assert!(policy.permits_read("/.agents/skills/example/SKILL.md"));
536 assert!(!policy.permits_write("/.agents/skills/example/SKILL.md"));
537 assert!(policy.permits_write("/generated/report.md"));
538 assert!(!policy.permits_write("/crates/example/target/output"));
539 assert!(!policy.permits_write("/web/node_modules/package/index.js"));
540 }
541
542 #[test]
543 fn custom_component_write_deny_applies_at_every_depth() {
544 let policy = WorkspacePolicy::builder()
545 .allow_write("/")
546 .deny_write_component("vendor")
547 .build()
548 .unwrap();
549
550 assert!(policy.permits_write("/src/generated.rs"));
551 assert!(policy.permits_write("/src/vendorish/generated.rs"));
552 assert!(!policy.permits_write("/src/vendor/generated.rs"));
553 assert!(!policy.permits_write("/VENDOR/generated.rs"));
554 }
555
556 #[test]
557 fn deny_wins_over_allow() {
558 let policy = WorkspacePolicy::builder()
559 .allow_read("/")
560 .allow_write("/workspace/output")
561 .deny_read("private")
562 .deny_write("output/locked")
563 .build()
564 .unwrap();
565
566 assert!(policy.permits_read("notes.txt"));
567 assert!(!policy.permits_read("private/notes.txt"));
568 assert!(policy.permits_write("output/result.txt"));
569 assert!(!policy.permits_write("output/locked/result.txt"));
570 }
571
572 #[test]
573 fn scoped_policy_allows_parent_traversal_but_not_parent_reads() {
574 let policy = WorkspacePolicy::builder()
575 .allow_read("src/generated")
576 .build()
577 .unwrap();
578
579 assert!(policy.permits_read_traversal("/workspace"));
580 assert!(policy.permits_read_traversal("/workspace/src"));
581 assert!(!policy.permits_read("/workspace/src"));
582 assert!(policy.permits_read("/workspace/src/generated/file.rs"));
583 assert!(!policy.permits_read("/workspace/tests/test.rs"));
584 assert!(!policy.permits_read_traversal("/workspace/tests"));
585 }
586
587 #[test]
588 fn hidden_and_sensitive_paths_require_separate_explicit_opt_ins() {
589 let hidden = WorkspacePolicy::builder()
590 .allow_read("/")
591 .allow_hidden(".github")
592 .allow_hidden(".git")
593 .build()
594 .unwrap();
595 assert!(hidden.permits_read(".github/workflows/ci.yml"));
596 assert!(!hidden.permits_read(".git/config"));
597
598 let sensitive = WorkspacePolicy::builder()
599 .allow_read("/")
600 .allow_sensitive(".env.example")
601 .build()
602 .unwrap();
603 assert!(sensitive.permits_read(".env.example"));
604 assert!(!sensitive.permits_read(".env"));
605 }
606
607 #[test]
608 fn narrow_protected_scope_allows_only_ancestor_traversal() {
609 let policy = WorkspacePolicy::builder()
610 .allow_read(".ssh/id_ed25519")
611 .allow_sensitive(".ssh/id_ed25519")
612 .build()
613 .unwrap();
614
615 assert!(policy.permits_read_traversal("/.ssh"));
616 assert!(!policy.permits_read("/.ssh"));
617 assert!(policy.permits_read("/.ssh/id_ed25519"));
618 assert!(!policy.permits_read("/.ssh/config"));
619 assert!(!policy.permits_read_traversal("/.ssh/config"));
620 }
621
622 #[test]
623 fn composition_can_only_restrict() {
624 let application = WorkspacePolicy::builder()
625 .allow_read("/")
626 .allow_write("/")
627 .deny_read("src/private")
628 .deny_write("src/generated/locked")
629 .allow_recursive_delete(true)
630 .build()
631 .unwrap();
632 let library = WorkspacePolicy::builder()
633 .allow_read("src")
634 .allow_write("src/generated")
635 .build()
636 .unwrap();
637 for policy in [
638 application.clone().compose(library.clone()),
639 library.compose(application),
640 ] {
641 assert!(policy.permits_read("src/lib.rs"));
642 assert!(!policy.permits_read("Cargo.toml"));
643 assert!(!policy.permits_read("src/private/secret.rs"));
644 assert!(policy.permits_write("src/generated/mod.rs"));
645 assert!(!policy.permits_write("src/lib.rs"));
646 assert!(!policy.permits_write("src/generated/locked/mod.rs"));
647 assert!(!policy.permits_recursive_delete());
648 }
649 let recursive = WorkspacePolicy::builder()
650 .allow_recursive_delete(true)
651 .build()
652 .unwrap();
653 assert!(
654 recursive
655 .clone()
656 .compose(recursive)
657 .permits_recursive_delete()
658 );
659 }
660
661 #[test]
662 fn traversal_nul_and_platform_separators_fail_closed() {
663 let policy = WorkspacePolicy::read_write();
664 assert!(policy.permits_read("src/ordinary.txt"));
665 assert!(policy.permits_write("src/ordinary.txt"));
666 assert!(policy.check_read("src/ordinary.txt").is_ok());
667 assert!(policy.check_write("src/ordinary.txt").is_ok());
668 for path in [
669 "src/../ordinary.txt",
670 "../outside",
671 "src\\..\\ordinary.txt",
672 "bad\0path",
673 ] {
674 assert!(!policy.permits_read(path), "read: {path:?}");
675 assert!(!policy.permits_write(path), "write: {path:?}");
676 assert!(!policy.permits_read_traversal(path), "traverse: {path:?}");
677 assert!(policy.check_read(path).is_err(), "check_read: {path:?}");
678 assert!(policy.check_write(path).is_err(), "check_write: {path:?}");
679 }
680 }
681
682 #[test]
683 fn workspace_and_session_absolute_paths_share_one_namespace() {
684 let policy = WorkspacePolicy::builder()
685 .allow_read("/workspace/src")
686 .allow_write("/output")
687 .build()
688 .unwrap();
689 assert!(policy.permits_read("src/lib.rs"));
690 assert!(policy.permits_read("/src/lib.rs"));
691 assert!(policy.permits_read("/workspace/src/lib.rs"));
692 assert!(policy.permits_write("/workspace/output/report.md"));
693 }
694
695 #[test]
696 fn repeated_slashes_cannot_bypass_a_deny_scope() {
697 let policy = WorkspacePolicy::builder()
698 .allow_read("/")
699 .deny_read("private")
700 .build()
701 .unwrap();
702 assert!(policy.permits_read("//workspace//public//readme.txt"));
703 assert!(!policy.permits_read("//workspace//private//secret.txt"));
704 }
705
706 #[test]
707 fn alternate_ascii_case_cannot_bypass_a_deny_or_sensitive_path() {
708 let policy = WorkspacePolicy::builder()
709 .allow_read("/")
710 .allow_write("/")
711 .allow_hidden("/")
712 .deny_read("Private")
713 .deny_write("Private")
714 .build()
715 .unwrap();
716
717 assert!(policy.permits_read("/.ordinary"));
718 assert!(policy.permits_write("/.ordinary"));
719 for path in ["/private/secret.txt", "/.ENV", "/.Git/config"] {
720 assert!(!policy.permits_read(path), "read: {path}");
721 assert!(!policy.permits_write(path), "write: {path}");
722 }
723 }
724
725 #[test]
726 fn alternate_ascii_case_cannot_broaden_an_allow_scope() {
727 let policy = WorkspacePolicy::builder()
728 .allow_read("public")
729 .allow_write("generated")
730 .build()
731 .unwrap();
732
733 assert!(policy.permits_read("public/index.html"));
734 assert!(!policy.permits_read("Public/secret.txt"));
735 assert!(!policy.permits_read_traversal("Public"));
736 assert!(policy.permits_write("generated/report.md"));
737 assert!(!policy.permits_write("Generated/secret.txt"));
738 }
739
740 #[test]
741 fn invalid_custom_scope_is_reported_at_build_time() {
742 let error = WorkspacePolicy::builder()
743 .allow_read("../outside")
744 .build()
745 .unwrap_err();
746 assert!(error.to_string().contains("traversal"));
747
748 let error = WorkspacePolicy::builder()
749 .deny_write_component("nested/vendor")
750 .build()
751 .unwrap_err();
752 assert!(error.to_string().contains("one path component"));
753
754 let error = WorkspacePolicy::builder()
755 .allow_write(" ")
756 .build()
757 .unwrap_err();
758 assert!(error.to_string().contains("must not be empty"));
759 }
760}