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 '{raw_path}' requires a workspace/codebase root or explicit workdir base."
595 ))
596 }
597}
598
599fn resolve_env_file_layers(
600 policy: &SandboxPolicyInput,
601 workspace_config: Option<&WorkspaceConfigResolution>,
602 scope_root: &Path,
603 notes: &mut Vec<String>,
604) -> Result<Vec<ResolvedSandboxEnvFile>, String> {
605 let mut env_files = Vec::new();
606
607 if let Some(raw) = workspace_config
608 .and_then(|entry| entry.config.as_ref())
609 .and_then(|config| config.env_file.as_deref())
610 {
611 env_files.push(resolve_env_file(
612 raw,
613 scope_root,
614 SandboxEnvFileSource::WorkspaceConfig,
615 )?);
616 }
617 if let Some(raw) = policy.env_file.as_deref() {
618 env_files.push(resolve_env_file(
619 raw,
620 scope_root,
621 SandboxEnvFileSource::Request,
622 )?);
623 }
624
625 if !env_files.is_empty() {
626 notes.push(format!(
627 "Resolved {} env file layer(s) for sandbox environment injection.",
628 env_files.len()
629 ));
630 }
631
632 Ok(env_files)
633}
634
635fn resolve_env_file(
636 raw_path: &str,
637 scope_root: &Path,
638 source: SandboxEnvFileSource,
639) -> Result<ResolvedSandboxEnvFile, String> {
640 let path = resolve_user_path(raw_path, Some(scope_root))?;
641 let keys = parse_env_file_keys(&path)?;
642
643 Ok(ResolvedSandboxEnvFile {
644 path: path.to_string_lossy().to_string(),
645 source,
646 keys,
647 })
648}
649
650fn resolve_network_mode(
651 requested: Option<SandboxNetworkMode>,
652 capabilities: &BTreeSet<SandboxCapability>,
653) -> Result<SandboxNetworkMode, String> {
654 if capabilities.contains(&SandboxCapability::NetworkAccess) {
655 return Ok(requested.unwrap_or(SandboxNetworkMode::Bridge));
656 }
657
658 match requested {
659 Some(SandboxNetworkMode::Bridge) => Err(
660 "Sandbox policy networkMode=bridge requires the networkAccess capability.".to_string(),
661 ),
662 _ => Ok(SandboxNetworkMode::None),
663 }
664}
665
666fn resolve_linked_worktrees(
667 policy: &SandboxPolicyInput,
668 context: Option<&SandboxPolicyContext>,
669 scope_root: &Path,
670 capabilities: &BTreeSet<SandboxCapability>,
671 notes: &mut Vec<String>,
672) -> Result<Vec<ResolvedSandboxLinkedWorktree>, String> {
673 let mode = policy.linked_worktree_mode.unwrap_or_default();
674 if mode == SandboxLinkedWorktreeMode::Disabled {
675 return Ok(Vec::new());
676 }
677
678 if !capabilities.contains(&SandboxCapability::LinkedWorktreeRead) {
679 return Err(
680 "Sandbox policy linkedWorktreeMode requires the linkedWorktreeRead capability."
681 .to_string(),
682 );
683 }
684
685 let available = context
686 .map(|ctx| ctx.available_worktrees.as_slice())
687 .unwrap_or(&[]);
688 if available.is_empty() {
689 notes.push("No active linked worktrees available for this sandbox context.".to_string());
690 return Ok(Vec::new());
691 }
692
693 let requested_ids = policy
694 .linked_worktree_ids
695 .iter()
696 .cloned()
697 .collect::<BTreeSet<_>>();
698 if mode == SandboxLinkedWorktreeMode::Explicit && requested_ids.is_empty() {
699 return Err(
700 "Sandbox policy linkedWorktreeMode=explicit requires linkedWorktreeIds.".to_string(),
701 );
702 }
703
704 let mut selected = Vec::new();
705 for worktree in available {
706 let include = match mode {
707 SandboxLinkedWorktreeMode::Disabled => false,
708 SandboxLinkedWorktreeMode::All => true,
709 SandboxLinkedWorktreeMode::Explicit => requested_ids.contains(&worktree.id),
710 };
711 if !include {
712 continue;
713 }
714
715 let host_path = canonicalize_existing_path(Path::new(&worktree.worktree_path))?;
716 if host_path == scope_root {
717 notes.push(format!(
718 "Skipped linked worktree {} because it matches the sandbox scope root.",
719 worktree.id
720 ));
721 continue;
722 }
723
724 selected.push(ResolvedSandboxLinkedWorktree {
725 id: worktree.id.clone(),
726 codebase_id: worktree.codebase_id.clone(),
727 branch: worktree.branch.clone(),
728 host_path: host_path.to_string_lossy().to_string(),
729 container_path: format!(
730 "{}/{:02}-{}",
731 SANDBOX_LINKED_WORKTREE_ROOT,
732 selected.len(),
733 sanitize_mount_name(Path::new(&worktree.worktree_path))
734 ),
735 });
736 }
737
738 if mode == SandboxLinkedWorktreeMode::Explicit {
739 let selected_ids = selected
740 .iter()
741 .map(|worktree| worktree.id.clone())
742 .collect::<BTreeSet<_>>();
743 let missing = requested_ids
744 .difference(&selected_ids)
745 .cloned()
746 .collect::<Vec<_>>();
747 if !missing.is_empty() {
748 return Err(format!(
749 "Sandbox policy linkedWorktreeIds not found or inactive: {}",
750 missing.join(", ")
751 ));
752 }
753 }
754
755 if !selected.is_empty() {
756 notes.push(format!(
757 "Mounted {} linked worktree(s) as read-only comparison roots.",
758 selected.len()
759 ));
760 }
761
762 Ok(selected)
763}
764
765fn resolve_capability_view(
766 capabilities: &BTreeSet<SandboxCapability>,
767 uses_workspace_write: bool,
768 network_mode: SandboxNetworkMode,
769 has_linked_worktrees: bool,
770) -> Vec<ResolvedSandboxCapability> {
771 [
772 SandboxCapability::WorkspaceRead,
773 SandboxCapability::WorkspaceWrite,
774 SandboxCapability::NetworkAccess,
775 SandboxCapability::LinkedWorktreeRead,
776 ]
777 .into_iter()
778 .map(|capability| ResolvedSandboxCapability {
779 capability,
780 tier: capability_tier(capability),
781 enabled: capability == SandboxCapability::WorkspaceRead
782 || capabilities.contains(&capability),
783 reason: capability_reason(
784 capability,
785 capabilities,
786 uses_workspace_write,
787 network_mode,
788 has_linked_worktrees,
789 ),
790 })
791 .collect()
792}
793
794fn capability_tier(capability: SandboxCapability) -> SandboxCapabilityTier {
795 match capability {
796 SandboxCapability::WorkspaceRead | SandboxCapability::LinkedWorktreeRead => {
797 SandboxCapabilityTier::Observation
798 }
799 SandboxCapability::WorkspaceWrite | SandboxCapability::NetworkAccess => {
800 SandboxCapabilityTier::Action
801 }
802 }
803}
804
805fn capability_reason(
806 capability: SandboxCapability,
807 capabilities: &BTreeSet<SandboxCapability>,
808 uses_workspace_write: bool,
809 network_mode: SandboxNetworkMode,
810 has_linked_worktrees: bool,
811) -> String {
812 match capability {
813 SandboxCapability::WorkspaceRead => {
814 "Implicitly enabled for the primary workspace/codebase mount.".to_string()
815 }
816 SandboxCapability::WorkspaceWrite => {
817 if capabilities.contains(&SandboxCapability::WorkspaceWrite) {
818 if uses_workspace_write {
819 "Allow-listed and used to authorize read-write path grants.".to_string()
820 } else {
821 "Allow-listed, but no read-write path grants are currently in use.".to_string()
822 }
823 } else {
824 "Not allow-listed; workspace and extra mounts remain read-only.".to_string()
825 }
826 }
827 SandboxCapability::NetworkAccess => {
828 if capabilities.contains(&SandboxCapability::NetworkAccess) {
829 format!("Allow-listed with effective network mode {network_mode:?}.")
830 } else {
831 "Not allow-listed; network defaults to none.".to_string()
832 }
833 }
834 SandboxCapability::LinkedWorktreeRead => {
835 if capabilities.contains(&SandboxCapability::LinkedWorktreeRead) {
836 if has_linked_worktrees {
837 "Allow-listed and used to mount linked worktrees read-only.".to_string()
838 } else {
839 "Allow-listed, but no linked worktrees were selected.".to_string()
840 }
841 } else {
842 "Not allow-listed; linked worktrees are unavailable.".to_string()
843 }
844 }
845 }
846}
847
848fn canonicalize_existing_path(path: &Path) -> Result<PathBuf, String> {
849 fs::canonicalize(path)
850 .map_err(|e| format!("Failed to resolve sandbox path '{}': {}", path.display(), e))
851}
852
853fn is_within(root: &Path, path: &Path) -> bool {
854 path == root || path.starts_with(root)
855}
856
857fn to_container_path(scope_root: &Path, host_path: &Path) -> String {
858 if host_path == scope_root {
859 return SANDBOX_SCOPE_CONTAINER_ROOT.to_string();
860 }
861
862 let suffix = host_path
863 .strip_prefix(scope_root)
864 .unwrap_or(host_path)
865 .components()
866 .map(|component| component.as_os_str().to_string_lossy().to_string())
867 .collect::<Vec<_>>()
868 .join("/");
869
870 format!("{SANDBOX_SCOPE_CONTAINER_ROOT}/{suffix}")
871}
872
873fn collect_override_mounts(
874 scope_root: &Path,
875 scope_access: SandboxMountAccess,
876 read_only_paths: &[PathBuf],
877 read_write_paths: &[PathBuf],
878 notes: &mut Vec<String>,
879) -> Vec<SandboxMount> {
880 let mut mounts = Vec::new();
881
882 let mut push_override_mounts =
883 |paths: &[PathBuf], access: SandboxMountAccess, redundant_when: SandboxMountAccess| {
884 let mut paths = paths
885 .iter()
886 .filter(|path| is_within(scope_root, path) && *path != scope_root)
887 .cloned()
888 .collect::<Vec<_>>();
889
890 paths.sort_by_key(|path| path.components().count());
891 for path in paths {
892 if scope_access == redundant_when {
893 notes.push(format!(
894 "Skipped redundant {:?} override inside scope root: {}",
895 access,
896 path.display()
897 ));
898 continue;
899 }
900
901 mounts.push(SandboxMount {
902 host_path: path.to_string_lossy().to_string(),
903 container_path: to_container_path(scope_root, &path),
904 access,
905 reason: Some("scopeOverride".to_string()),
906 });
907 }
908 };
909
910 push_override_mounts(
911 read_only_paths,
912 SandboxMountAccess::ReadOnly,
913 SandboxMountAccess::ReadOnly,
914 );
915 push_override_mounts(
916 read_write_paths,
917 SandboxMountAccess::ReadWrite,
918 SandboxMountAccess::ReadWrite,
919 );
920
921 mounts
922}
923
924fn collect_external_mounts(
925 scope_root: &Path,
926 paths: &[PathBuf],
927 access: SandboxMountAccess,
928) -> Vec<SandboxMount> {
929 paths
930 .iter()
931 .filter(|path| !is_within(scope_root, path))
932 .enumerate()
933 .map(|(index, path)| SandboxMount {
934 host_path: path.to_string_lossy().to_string(),
935 container_path: format!(
936 "{}/{:02}-{}",
937 match access {
938 SandboxMountAccess::ReadOnly => SANDBOX_EXTRA_READONLY_ROOT,
939 SandboxMountAccess::ReadWrite => SANDBOX_EXTRA_READWRITE_ROOT,
940 },
941 index,
942 sanitize_mount_name(path)
943 ),
944 access,
945 reason: Some("explicitGrant".to_string()),
946 })
947 .collect()
948}
949
950fn sanitize_mount_name(path: &Path) -> String {
951 let raw = path
952 .file_name()
953 .unwrap_or(path.as_os_str())
954 .to_string_lossy();
955 let name = raw
956 .chars()
957 .map(|ch| {
958 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
959 ch
960 } else {
961 '-'
962 }
963 })
964 .collect::<String>()
965 .trim_matches('-')
966 .chars()
967 .take(48)
968 .collect::<String>();
969
970 if name.is_empty() {
971 "path".to_string()
972 } else {
973 name
974 }
975}
976
977#[cfg(test)]
978mod policy_tests;