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 '{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;