Skip to main content

routa_core/sandbox/
policy.rs

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;