1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use super::env::parse_env_file_keys;
8
9mod permission_constraints;
10pub use permission_constraints::SandboxPermissionConstraints;
11
12pub const SANDBOX_SCOPE_CONTAINER_ROOT: &str = "/workspace";
13const SANDBOX_EXTRA_READONLY_ROOT: &str = "/workspace-extra/ro";
14const SANDBOX_EXTRA_READWRITE_ROOT: &str = "/workspace-extra/rw";
15const SANDBOX_LINKED_WORKTREE_ROOT: &str = "/workspace-worktrees";
16
17fn is_false(value: &bool) -> bool {
18 !*value
19}
20
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
22#[serde(rename_all = "camelCase")]
23pub enum SandboxNetworkMode {
24 #[default]
25 Bridge,
26 None,
27}
28
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
30#[serde(rename_all = "camelCase")]
31pub enum SandboxEnvMode {
32 #[default]
33 Sanitized,
34 Inherit,
35}
36
37#[derive(
38 Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Default,
39)]
40#[serde(rename_all = "camelCase")]
41pub enum SandboxCapability {
42 #[default]
43 WorkspaceRead,
44 WorkspaceWrite,
45 NetworkAccess,
46 LinkedWorktreeRead,
47}
48
49#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "camelCase")]
51pub enum SandboxCapabilityTier {
52 Observation,
53 Action,
54}
55
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
57#[serde(rename_all = "camelCase")]
58pub enum SandboxLinkedWorktreeMode {
59 #[default]
60 Disabled,
61 All,
62 Explicit,
63}
64
65#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "camelCase")]
67pub enum SandboxMountAccess {
68 ReadOnly,
69 ReadWrite,
70}
71
72impl SandboxMountAccess {
73 pub fn docker_suffix(self) -> &'static str {
74 match self {
75 Self::ReadOnly => "ro",
76 Self::ReadWrite => "rw",
77 }
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "camelCase")]
83pub struct SandboxMount {
84 pub host_path: String,
85 pub container_path: String,
86 pub access: SandboxMountAccess,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub reason: Option<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
92#[serde(rename_all = "camelCase")]
93pub struct SandboxPolicyInput {
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub workspace_id: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub codebase_id: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub workdir: Option<String>,
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub read_only_paths: Vec<String>,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub read_write_paths: Vec<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub network_mode: Option<SandboxNetworkMode>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub env_mode: Option<SandboxEnvMode>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub env_file: Option<String>,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub env_allowlist: Vec<String>,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub capabilities: Vec<SandboxCapability>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub linked_worktree_mode: Option<SandboxLinkedWorktreeMode>,
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub linked_worktree_ids: Vec<String>,
118 #[serde(default, skip_serializing_if = "is_false")]
119 pub trust_workspace_config: bool,
120}
121
122impl SandboxPolicyInput {
123 pub fn is_empty(&self) -> bool {
124 self.workspace_id.is_none()
125 && self.codebase_id.is_none()
126 && self.workdir.is_none()
127 && self.read_only_paths.is_empty()
128 && self.read_write_paths.is_empty()
129 && self.network_mode.is_none()
130 && self.env_mode.is_none()
131 && self.env_file.is_none()
132 && self.env_allowlist.is_empty()
133 && self.capabilities.is_empty()
134 && self.linked_worktree_mode.is_none()
135 && self.linked_worktree_ids.is_empty()
136 && !self.trust_workspace_config
137 }
138
139 pub fn resolve(
140 &self,
141 context: Option<SandboxPolicyContext>,
142 ) -> Result<ResolvedSandboxPolicy, String> {
143 let derived_root = context
144 .as_ref()
145 .and_then(|ctx| ctx.workspace_root.as_ref())
146 .map(|root| canonicalize_existing_path(root))
147 .transpose()?;
148 let workspace_config = resolve_workspace_config(self, derived_root.as_deref())?;
149 let effective_input = merge_workspace_config(self, workspace_config.as_ref());
150 let capability_set = effective_input
151 .capabilities
152 .iter()
153 .copied()
154 .collect::<BTreeSet<_>>();
155
156 let host_workdir = match effective_input.workdir.as_deref() {
157 Some(raw) => resolve_user_path(raw, derived_root.as_deref())?,
158 None => derived_root.clone().ok_or_else(|| {
159 "Sandbox policy requires either policy.workdir or a workspace/codebase root."
160 .to_string()
161 })?,
162 };
163
164 let scope_root = derived_root.clone().unwrap_or_else(|| host_workdir.clone());
165 if !is_within(&scope_root, &host_workdir) {
166 return Err(format!(
167 "Resolved workdir '{}' escapes scope root '{}'.",
168 host_workdir.display(),
169 scope_root.display()
170 ));
171 }
172
173 let mut notes = Vec::new();
174 record_workspace_config_note(workspace_config.as_ref(), &mut notes);
175
176 if derived_root.is_some() {
177 notes.push(format!(
178 "Resolved scope root from workspace/codebase context: {}",
179 scope_root.display()
180 ));
181 } else {
182 notes.push(format!(
183 "No workspace/codebase root provided; using workdir as scope root: {}",
184 scope_root.display()
185 ));
186 }
187
188 let mut read_only_paths =
189 resolve_grant_paths(&effective_input.read_only_paths, &scope_root)?;
190 let read_write_paths = resolve_grant_paths(&effective_input.read_write_paths, &scope_root)?;
191
192 if !read_write_paths.is_empty()
193 && !capability_set.contains(&SandboxCapability::WorkspaceWrite)
194 {
195 return Err(
196 "Sandbox policy readWritePaths require the workspaceWrite capability.".to_string(),
197 );
198 }
199
200 let read_write_set: BTreeSet<PathBuf> = read_write_paths.iter().cloned().collect();
201 read_only_paths.retain(|path| !read_write_set.contains(path));
202 if effective_input.read_only_paths.len() != read_only_paths.len() {
203 notes.push(
204 "Dropped duplicate read-only grants that were also present in read-write grants."
205 .to_string(),
206 );
207 }
208
209 let network_mode = resolve_network_mode(effective_input.network_mode, &capability_set)?;
210 if network_mode == SandboxNetworkMode::None && effective_input.network_mode.is_none() {
211 notes.push(
212 "Defaulted network mode to none because networkAccess is not allow-listed."
213 .to_string(),
214 );
215 }
216
217 let scope_access = if read_write_set.contains(&scope_root) {
218 SandboxMountAccess::ReadWrite
219 } else {
220 SandboxMountAccess::ReadOnly
221 };
222
223 let container_workdir = to_container_path(&scope_root, &host_workdir);
224 let mut mounts = vec![SandboxMount {
225 host_path: scope_root.to_string_lossy().to_string(),
226 container_path: SANDBOX_SCOPE_CONTAINER_ROOT.to_string(),
227 access: scope_access,
228 reason: Some("scopeRoot".to_string()),
229 }];
230
231 let overrides = collect_override_mounts(
232 &scope_root,
233 scope_access,
234 &read_only_paths,
235 &read_write_paths,
236 &mut notes,
237 );
238 mounts.extend(overrides);
239 mounts.extend(collect_external_mounts(
240 &scope_root,
241 &read_only_paths,
242 SandboxMountAccess::ReadOnly,
243 ));
244 mounts.extend(collect_external_mounts(
245 &scope_root,
246 &read_write_paths,
247 SandboxMountAccess::ReadWrite,
248 ));
249 let linked_worktrees = resolve_linked_worktrees(
250 &effective_input,
251 context.as_ref(),
252 &scope_root,
253 &capability_set,
254 &mut notes,
255 )?;
256 for linked_worktree in &linked_worktrees {
257 mounts.push(SandboxMount {
258 host_path: linked_worktree.host_path.clone(),
259 container_path: linked_worktree.container_path.clone(),
260 access: SandboxMountAccess::ReadOnly,
261 reason: Some("linkedWorktree".to_string()),
262 });
263 }
264
265 let env_files =
266 resolve_env_file_layers(self, workspace_config.as_ref(), &scope_root, &mut notes)?;
267 let env_allowlist = effective_input
268 .env_allowlist
269 .iter()
270 .map(|name| name.trim())
271 .filter(|name| !name.is_empty())
272 .map(ToOwned::to_owned)
273 .collect::<BTreeSet<_>>()
274 .into_iter()
275 .collect();
276
277 Ok(ResolvedSandboxPolicy {
278 workspace_id: context.as_ref().and_then(|ctx| ctx.workspace_id.clone()),
279 codebase_id: context.as_ref().and_then(|ctx| ctx.codebase_id.clone()),
280 scope_root: scope_root.to_string_lossy().to_string(),
281 host_workdir: host_workdir.to_string_lossy().to_string(),
282 container_workdir,
283 read_only_paths: read_only_paths
284 .into_iter()
285 .map(|path| path.to_string_lossy().to_string())
286 .collect(),
287 read_write_paths: read_write_paths
288 .into_iter()
289 .map(|path| path.to_string_lossy().to_string())
290 .collect(),
291 network_mode,
292 env_mode: effective_input.env_mode.unwrap_or_default(),
293 env_files,
294 env_allowlist,
295 mounts,
296 capabilities: resolve_capability_view(
297 &capability_set,
298 !read_write_set.is_empty(),
299 network_mode,
300 !linked_worktrees.is_empty(),
301 ),
302 linked_worktrees,
303 workspace_config: workspace_config.map(|entry| entry.descriptor),
304 notes,
305 })
306 }
307}
308
309#[derive(Debug, Clone, Default)]
310pub struct SandboxPolicyContext {
311 pub workspace_id: Option<String>,
312 pub codebase_id: Option<String>,
313 pub workspace_root: Option<PathBuf>,
314 pub available_worktrees: Vec<SandboxPolicyWorktree>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
318#[serde(rename_all = "camelCase")]
319pub struct ResolvedSandboxPolicy {
320 #[serde(skip_serializing_if = "Option::is_none")]
321 pub workspace_id: Option<String>,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub codebase_id: Option<String>,
324 pub scope_root: String,
325 pub host_workdir: String,
326 pub container_workdir: String,
327 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub read_only_paths: Vec<String>,
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub read_write_paths: Vec<String>,
331 pub network_mode: SandboxNetworkMode,
332 pub env_mode: SandboxEnvMode,
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 pub env_files: Vec<ResolvedSandboxEnvFile>,
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub env_allowlist: Vec<String>,
337 pub mounts: Vec<SandboxMount>,
338 #[serde(default, skip_serializing_if = "Vec::is_empty")]
339 pub capabilities: Vec<ResolvedSandboxCapability>,
340 #[serde(default, skip_serializing_if = "Vec::is_empty")]
341 pub linked_worktrees: Vec<ResolvedSandboxLinkedWorktree>,
342 #[serde(skip_serializing_if = "Option::is_none")]
343 pub workspace_config: Option<ResolvedSandboxWorkspaceConfig>,
344 #[serde(default, skip_serializing_if = "Vec::is_empty")]
345 pub notes: Vec<String>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
349#[serde(rename_all = "camelCase")]
350pub struct ResolvedSandboxCapability {
351 pub capability: SandboxCapability,
352 pub tier: SandboxCapabilityTier,
353 pub enabled: bool,
354 pub reason: String,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358#[serde(rename_all = "camelCase")]
359pub struct ResolvedSandboxLinkedWorktree {
360 pub id: String,
361 pub codebase_id: String,
362 pub branch: String,
363 pub host_path: String,
364 pub container_path: String,
365}
366
367#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
368#[serde(rename_all = "camelCase")]
369pub enum SandboxEnvFileSource {
370 WorkspaceConfig,
371 Request,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
375#[serde(rename_all = "camelCase")]
376pub struct ResolvedSandboxEnvFile {
377 pub path: String,
378 pub source: SandboxEnvFileSource,
379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
380 pub keys: Vec<String>,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
384#[serde(rename_all = "camelCase")]
385pub struct ResolvedSandboxWorkspaceConfig {
386 pub path: String,
387 pub trusted: bool,
388 pub loaded: bool,
389 pub reason: String,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
393#[serde(rename_all = "camelCase")]
394struct WorkspaceSandboxConfigFile {
395 #[serde(skip_serializing_if = "Option::is_none")]
396 workdir: Option<String>,
397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
398 read_only_paths: Vec<String>,
399 #[serde(default, skip_serializing_if = "Vec::is_empty")]
400 read_write_paths: Vec<String>,
401 #[serde(skip_serializing_if = "Option::is_none")]
402 network_mode: Option<SandboxNetworkMode>,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 env_mode: Option<SandboxEnvMode>,
405 #[serde(skip_serializing_if = "Option::is_none")]
406 env_file: Option<String>,
407 #[serde(default, skip_serializing_if = "Vec::is_empty")]
408 env_allowlist: Vec<String>,
409 #[serde(default, skip_serializing_if = "Vec::is_empty")]
410 capabilities: Vec<SandboxCapability>,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 linked_worktree_mode: Option<SandboxLinkedWorktreeMode>,
413 #[serde(default, skip_serializing_if = "Vec::is_empty")]
414 linked_worktree_ids: Vec<String>,
415}
416
417#[derive(Debug, Clone)]
418struct WorkspaceConfigResolution {
419 descriptor: ResolvedSandboxWorkspaceConfig,
420 config: Option<WorkspaceSandboxConfigFile>,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct SandboxPolicyWorktree {
425 pub id: String,
426 pub codebase_id: String,
427 pub worktree_path: String,
428 pub branch: String,
429}
430
431fn resolve_grant_paths(raw_paths: &[String], scope_root: &Path) -> Result<Vec<PathBuf>, String> {
432 raw_paths
433 .iter()
434 .map(|raw| resolve_user_path(raw, Some(scope_root)))
435 .collect::<Result<BTreeSet<_>, _>>()
436 .map(|set| set.into_iter().collect())
437}
438
439fn resolve_workspace_config(
440 policy: &SandboxPolicyInput,
441 derived_root: Option<&Path>,
442) -> Result<Option<WorkspaceConfigResolution>, String> {
443 let Some(root) = derived_root else {
444 return Ok(None);
445 };
446
447 let config_path = root.join(".routa").join("sandbox.json");
448 let config_path_string = config_path.to_string_lossy().to_string();
449 if !config_path.exists() {
450 return Ok(Some(WorkspaceConfigResolution {
451 descriptor: ResolvedSandboxWorkspaceConfig {
452 path: config_path_string,
453 trusted: policy.trust_workspace_config,
454 loaded: false,
455 reason: "notFound".to_string(),
456 },
457 config: None,
458 }));
459 }
460
461 if !policy.trust_workspace_config {
462 return Ok(Some(WorkspaceConfigResolution {
463 descriptor: ResolvedSandboxWorkspaceConfig {
464 path: config_path_string,
465 trusted: false,
466 loaded: false,
467 reason: "trustDisabled".to_string(),
468 },
469 config: None,
470 }));
471 }
472
473 let raw = fs::read_to_string(&config_path).map_err(|err| {
474 format!(
475 "Failed to read trusted workspace sandbox config '{}': {}",
476 config_path.display(),
477 err
478 )
479 })?;
480 let config = serde_json::from_str::<WorkspaceSandboxConfigFile>(&raw).map_err(|err| {
481 format!(
482 "Failed to parse trusted workspace sandbox config '{}': {}",
483 config_path.display(),
484 err
485 )
486 })?;
487
488 Ok(Some(WorkspaceConfigResolution {
489 descriptor: ResolvedSandboxWorkspaceConfig {
490 path: config_path_string,
491 trusted: true,
492 loaded: true,
493 reason: "loaded".to_string(),
494 },
495 config: Some(config),
496 }))
497}
498
499fn merge_workspace_config(
500 policy: &SandboxPolicyInput,
501 workspace_config: Option<&WorkspaceConfigResolution>,
502) -> SandboxPolicyInput {
503 let Some(config) = workspace_config.and_then(|entry| entry.config.as_ref()) else {
504 return policy.clone();
505 };
506
507 let mut merged = policy.clone();
508 if merged.workdir.is_none() {
509 merged.workdir = config.workdir.clone();
510 }
511 merged.read_only_paths = merge_string_lists(&config.read_only_paths, &policy.read_only_paths);
512 merged.read_write_paths =
513 merge_string_lists(&config.read_write_paths, &policy.read_write_paths);
514 if merged.network_mode.is_none() {
515 merged.network_mode = config.network_mode;
516 }
517 if merged.env_mode.is_none() {
518 merged.env_mode = config.env_mode;
519 }
520 merged.env_allowlist = merge_string_lists(&config.env_allowlist, &policy.env_allowlist);
521 merged.capabilities = merge_capabilities(&config.capabilities, &policy.capabilities);
522 if merged.linked_worktree_mode.is_none() {
523 merged.linked_worktree_mode = config.linked_worktree_mode;
524 }
525 merged.linked_worktree_ids =
526 merge_string_lists(&config.linked_worktree_ids, &policy.linked_worktree_ids);
527
528 merged
529}
530
531fn merge_string_lists(base: &[String], overlay: &[String]) -> Vec<String> {
532 base.iter()
533 .chain(overlay.iter())
534 .map(|value| value.trim())
535 .filter(|value| !value.is_empty())
536 .map(ToOwned::to_owned)
537 .collect::<BTreeSet<_>>()
538 .into_iter()
539 .collect()
540}
541
542fn merge_capabilities(
543 base: &[SandboxCapability],
544 overlay: &[SandboxCapability],
545) -> Vec<SandboxCapability> {
546 base.iter()
547 .chain(overlay.iter())
548 .copied()
549 .collect::<BTreeSet<_>>()
550 .into_iter()
551 .collect()
552}
553
554fn record_workspace_config_note(
555 workspace_config: Option<&WorkspaceConfigResolution>,
556 notes: &mut Vec<String>,
557) {
558 let Some(workspace_config) = workspace_config else {
559 return;
560 };
561
562 let descriptor = &workspace_config.descriptor;
563 let note = match descriptor.reason.as_str() {
564 "loaded" => format!(
565 "Loaded trusted workspace sandbox config: {}",
566 descriptor.path
567 ),
568 "trustDisabled" => format!(
569 "Ignored repo-local sandbox config because trustWorkspaceConfig is false: {}",
570 descriptor.path
571 ),
572 "notFound" => format!("No repo-local sandbox config found at {}", descriptor.path),
573 other => format!(
574 "Workspace sandbox config state '{}' for {}",
575 other, descriptor.path
576 ),
577 };
578 notes.push(note);
579}
580
581fn resolve_user_path(raw_path: &str, base_dir: Option<&Path>) -> Result<PathBuf, String> {
582 let raw_path = raw_path.trim();
583 if raw_path.is_empty() {
584 return Err("Sandbox policy path entries cannot be empty.".to_string());
585 }
586
587 let candidate = PathBuf::from(raw_path);
588 if candidate.is_absolute() {
589 canonicalize_existing_path(&candidate)
590 } else if let Some(base_dir) = base_dir {
591 canonicalize_existing_path(&base_dir.join(candidate))
592 } else {
593 Err(format!(
594 "Relative sandbox path '{}' requires a workspace/codebase root or explicit workdir base.",
595 raw_path
596 ))
597 }
598}
599
600fn resolve_env_file_layers(
601 policy: &SandboxPolicyInput,
602 workspace_config: Option<&WorkspaceConfigResolution>,
603 scope_root: &Path,
604 notes: &mut Vec<String>,
605) -> Result<Vec<ResolvedSandboxEnvFile>, String> {
606 let mut env_files = Vec::new();
607
608 if let Some(raw) = workspace_config
609 .and_then(|entry| entry.config.as_ref())
610 .and_then(|config| config.env_file.as_deref())
611 {
612 env_files.push(resolve_env_file(
613 raw,
614 scope_root,
615 SandboxEnvFileSource::WorkspaceConfig,
616 )?);
617 }
618 if let Some(raw) = policy.env_file.as_deref() {
619 env_files.push(resolve_env_file(
620 raw,
621 scope_root,
622 SandboxEnvFileSource::Request,
623 )?);
624 }
625
626 if !env_files.is_empty() {
627 notes.push(format!(
628 "Resolved {} env file layer(s) for sandbox environment injection.",
629 env_files.len()
630 ));
631 }
632
633 Ok(env_files)
634}
635
636fn resolve_env_file(
637 raw_path: &str,
638 scope_root: &Path,
639 source: SandboxEnvFileSource,
640) -> Result<ResolvedSandboxEnvFile, String> {
641 let path = resolve_user_path(raw_path, Some(scope_root))?;
642 let keys = parse_env_file_keys(&path)?;
643
644 Ok(ResolvedSandboxEnvFile {
645 path: path.to_string_lossy().to_string(),
646 source,
647 keys,
648 })
649}
650
651fn resolve_network_mode(
652 requested: Option<SandboxNetworkMode>,
653 capabilities: &BTreeSet<SandboxCapability>,
654) -> Result<SandboxNetworkMode, String> {
655 if capabilities.contains(&SandboxCapability::NetworkAccess) {
656 return Ok(requested.unwrap_or(SandboxNetworkMode::Bridge));
657 }
658
659 match requested {
660 Some(SandboxNetworkMode::Bridge) => Err(
661 "Sandbox policy networkMode=bridge requires the networkAccess capability.".to_string(),
662 ),
663 _ => Ok(SandboxNetworkMode::None),
664 }
665}
666
667fn resolve_linked_worktrees(
668 policy: &SandboxPolicyInput,
669 context: Option<&SandboxPolicyContext>,
670 scope_root: &Path,
671 capabilities: &BTreeSet<SandboxCapability>,
672 notes: &mut Vec<String>,
673) -> Result<Vec<ResolvedSandboxLinkedWorktree>, String> {
674 let mode = policy.linked_worktree_mode.unwrap_or_default();
675 if mode == SandboxLinkedWorktreeMode::Disabled {
676 return Ok(Vec::new());
677 }
678
679 if !capabilities.contains(&SandboxCapability::LinkedWorktreeRead) {
680 return Err(
681 "Sandbox policy linkedWorktreeMode requires the linkedWorktreeRead capability."
682 .to_string(),
683 );
684 }
685
686 let available = context
687 .map(|ctx| ctx.available_worktrees.as_slice())
688 .unwrap_or(&[]);
689 if available.is_empty() {
690 notes.push("No active linked worktrees available for this sandbox context.".to_string());
691 return Ok(Vec::new());
692 }
693
694 let requested_ids = policy
695 .linked_worktree_ids
696 .iter()
697 .cloned()
698 .collect::<BTreeSet<_>>();
699 if mode == SandboxLinkedWorktreeMode::Explicit && requested_ids.is_empty() {
700 return Err(
701 "Sandbox policy linkedWorktreeMode=explicit requires linkedWorktreeIds.".to_string(),
702 );
703 }
704
705 let mut selected = Vec::new();
706 for worktree in available {
707 let include = match mode {
708 SandboxLinkedWorktreeMode::Disabled => false,
709 SandboxLinkedWorktreeMode::All => true,
710 SandboxLinkedWorktreeMode::Explicit => requested_ids.contains(&worktree.id),
711 };
712 if !include {
713 continue;
714 }
715
716 let host_path = canonicalize_existing_path(Path::new(&worktree.worktree_path))?;
717 if host_path == scope_root {
718 notes.push(format!(
719 "Skipped linked worktree {} because it matches the sandbox scope root.",
720 worktree.id
721 ));
722 continue;
723 }
724
725 selected.push(ResolvedSandboxLinkedWorktree {
726 id: worktree.id.clone(),
727 codebase_id: worktree.codebase_id.clone(),
728 branch: worktree.branch.clone(),
729 host_path: host_path.to_string_lossy().to_string(),
730 container_path: format!(
731 "{}/{:02}-{}",
732 SANDBOX_LINKED_WORKTREE_ROOT,
733 selected.len(),
734 sanitize_mount_name(Path::new(&worktree.worktree_path))
735 ),
736 });
737 }
738
739 if mode == SandboxLinkedWorktreeMode::Explicit {
740 let selected_ids = selected
741 .iter()
742 .map(|worktree| worktree.id.clone())
743 .collect::<BTreeSet<_>>();
744 let missing = requested_ids
745 .difference(&selected_ids)
746 .cloned()
747 .collect::<Vec<_>>();
748 if !missing.is_empty() {
749 return Err(format!(
750 "Sandbox policy linkedWorktreeIds not found or inactive: {}",
751 missing.join(", ")
752 ));
753 }
754 }
755
756 if !selected.is_empty() {
757 notes.push(format!(
758 "Mounted {} linked worktree(s) as read-only comparison roots.",
759 selected.len()
760 ));
761 }
762
763 Ok(selected)
764}
765
766fn resolve_capability_view(
767 capabilities: &BTreeSet<SandboxCapability>,
768 uses_workspace_write: bool,
769 network_mode: SandboxNetworkMode,
770 has_linked_worktrees: bool,
771) -> Vec<ResolvedSandboxCapability> {
772 [
773 SandboxCapability::WorkspaceRead,
774 SandboxCapability::WorkspaceWrite,
775 SandboxCapability::NetworkAccess,
776 SandboxCapability::LinkedWorktreeRead,
777 ]
778 .into_iter()
779 .map(|capability| ResolvedSandboxCapability {
780 capability,
781 tier: capability_tier(capability),
782 enabled: capability == SandboxCapability::WorkspaceRead
783 || capabilities.contains(&capability),
784 reason: capability_reason(
785 capability,
786 capabilities,
787 uses_workspace_write,
788 network_mode,
789 has_linked_worktrees,
790 ),
791 })
792 .collect()
793}
794
795fn capability_tier(capability: SandboxCapability) -> SandboxCapabilityTier {
796 match capability {
797 SandboxCapability::WorkspaceRead | SandboxCapability::LinkedWorktreeRead => {
798 SandboxCapabilityTier::Observation
799 }
800 SandboxCapability::WorkspaceWrite | SandboxCapability::NetworkAccess => {
801 SandboxCapabilityTier::Action
802 }
803 }
804}
805
806fn capability_reason(
807 capability: SandboxCapability,
808 capabilities: &BTreeSet<SandboxCapability>,
809 uses_workspace_write: bool,
810 network_mode: SandboxNetworkMode,
811 has_linked_worktrees: bool,
812) -> String {
813 match capability {
814 SandboxCapability::WorkspaceRead => {
815 "Implicitly enabled for the primary workspace/codebase mount.".to_string()
816 }
817 SandboxCapability::WorkspaceWrite => {
818 if capabilities.contains(&SandboxCapability::WorkspaceWrite) {
819 if uses_workspace_write {
820 "Allow-listed and used to authorize read-write path grants.".to_string()
821 } else {
822 "Allow-listed, but no read-write path grants are currently in use.".to_string()
823 }
824 } else {
825 "Not allow-listed; workspace and extra mounts remain read-only.".to_string()
826 }
827 }
828 SandboxCapability::NetworkAccess => {
829 if capabilities.contains(&SandboxCapability::NetworkAccess) {
830 format!(
831 "Allow-listed with effective network mode {:?}.",
832 network_mode
833 )
834 } else {
835 "Not allow-listed; network defaults to none.".to_string()
836 }
837 }
838 SandboxCapability::LinkedWorktreeRead => {
839 if capabilities.contains(&SandboxCapability::LinkedWorktreeRead) {
840 if has_linked_worktrees {
841 "Allow-listed and used to mount linked worktrees read-only.".to_string()
842 } else {
843 "Allow-listed, but no linked worktrees were selected.".to_string()
844 }
845 } else {
846 "Not allow-listed; linked worktrees are unavailable.".to_string()
847 }
848 }
849 }
850}
851
852fn canonicalize_existing_path(path: &Path) -> Result<PathBuf, String> {
853 fs::canonicalize(path)
854 .map_err(|e| format!("Failed to resolve sandbox path '{}': {}", path.display(), e))
855}
856
857fn is_within(root: &Path, path: &Path) -> bool {
858 path == root || path.starts_with(root)
859}
860
861fn to_container_path(scope_root: &Path, host_path: &Path) -> String {
862 if host_path == scope_root {
863 return SANDBOX_SCOPE_CONTAINER_ROOT.to_string();
864 }
865
866 let suffix = host_path
867 .strip_prefix(scope_root)
868 .unwrap_or(host_path)
869 .components()
870 .map(|component| component.as_os_str().to_string_lossy().to_string())
871 .collect::<Vec<_>>()
872 .join("/");
873
874 format!("{}/{}", SANDBOX_SCOPE_CONTAINER_ROOT, suffix)
875}
876
877fn collect_override_mounts(
878 scope_root: &Path,
879 scope_access: SandboxMountAccess,
880 read_only_paths: &[PathBuf],
881 read_write_paths: &[PathBuf],
882 notes: &mut Vec<String>,
883) -> Vec<SandboxMount> {
884 let mut mounts = Vec::new();
885
886 let mut push_override_mounts =
887 |paths: &[PathBuf], access: SandboxMountAccess, redundant_when: SandboxMountAccess| {
888 let mut paths = paths
889 .iter()
890 .filter(|path| is_within(scope_root, path) && *path != scope_root)
891 .cloned()
892 .collect::<Vec<_>>();
893
894 paths.sort_by_key(|path| path.components().count());
895 for path in paths {
896 if scope_access == redundant_when {
897 notes.push(format!(
898 "Skipped redundant {:?} override inside scope root: {}",
899 access,
900 path.display()
901 ));
902 continue;
903 }
904
905 mounts.push(SandboxMount {
906 host_path: path.to_string_lossy().to_string(),
907 container_path: to_container_path(scope_root, &path),
908 access,
909 reason: Some("scopeOverride".to_string()),
910 });
911 }
912 };
913
914 push_override_mounts(
915 read_only_paths,
916 SandboxMountAccess::ReadOnly,
917 SandboxMountAccess::ReadOnly,
918 );
919 push_override_mounts(
920 read_write_paths,
921 SandboxMountAccess::ReadWrite,
922 SandboxMountAccess::ReadWrite,
923 );
924
925 mounts
926}
927
928fn collect_external_mounts(
929 scope_root: &Path,
930 paths: &[PathBuf],
931 access: SandboxMountAccess,
932) -> Vec<SandboxMount> {
933 paths
934 .iter()
935 .filter(|path| !is_within(scope_root, path))
936 .enumerate()
937 .map(|(index, path)| SandboxMount {
938 host_path: path.to_string_lossy().to_string(),
939 container_path: format!(
940 "{}/{:02}-{}",
941 match access {
942 SandboxMountAccess::ReadOnly => SANDBOX_EXTRA_READONLY_ROOT,
943 SandboxMountAccess::ReadWrite => SANDBOX_EXTRA_READWRITE_ROOT,
944 },
945 index,
946 sanitize_mount_name(path)
947 ),
948 access,
949 reason: Some("explicitGrant".to_string()),
950 })
951 .collect()
952}
953
954fn sanitize_mount_name(path: &Path) -> String {
955 let raw = path
956 .file_name()
957 .unwrap_or(path.as_os_str())
958 .to_string_lossy();
959 let name = raw
960 .chars()
961 .map(|ch| {
962 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
963 ch
964 } else {
965 '-'
966 }
967 })
968 .collect::<String>()
969 .trim_matches('-')
970 .chars()
971 .take(48)
972 .collect::<String>();
973
974 if name.is_empty() {
975 "path".to_string()
976 } else {
977 name
978 }
979}
980
981#[cfg(test)]
982mod policy_tests;