use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::env::parse_env_file_keys;
mod permission_constraints;
pub use permission_constraints::SandboxPermissionConstraints;
pub const SANDBOX_SCOPE_CONTAINER_ROOT: &str = "/workspace";
const SANDBOX_EXTRA_READONLY_ROOT: &str = "/workspace-extra/ro";
const SANDBOX_EXTRA_READWRITE_ROOT: &str = "/workspace-extra/rw";
const SANDBOX_LINKED_WORKTREE_ROOT: &str = "/workspace-worktrees";
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum SandboxNetworkMode {
#[default]
Bridge,
None,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum SandboxEnvMode {
#[default]
Sanitized,
Inherit,
}
#[derive(
Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Default,
)]
#[serde(rename_all = "camelCase")]
pub enum SandboxCapability {
#[default]
WorkspaceRead,
WorkspaceWrite,
NetworkAccess,
LinkedWorktreeRead,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum SandboxCapabilityTier {
Observation,
Action,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum SandboxLinkedWorktreeMode {
#[default]
Disabled,
All,
Explicit,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum SandboxMountAccess {
ReadOnly,
ReadWrite,
}
impl SandboxMountAccess {
pub fn docker_suffix(self) -> &'static str {
match self {
Self::ReadOnly => "ro",
Self::ReadWrite => "rw",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SandboxMount {
pub host_path: String,
pub container_path: String,
pub access: SandboxMountAccess,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SandboxPolicyInput {
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codebase_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workdir: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub read_only_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub read_write_paths: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_mode: Option<SandboxNetworkMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_mode: Option<SandboxEnvMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_file: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_allowlist: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<SandboxCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linked_worktree_mode: Option<SandboxLinkedWorktreeMode>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub linked_worktree_ids: Vec<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub trust_workspace_config: bool,
}
impl SandboxPolicyInput {
pub fn is_empty(&self) -> bool {
self.workspace_id.is_none()
&& self.codebase_id.is_none()
&& self.workdir.is_none()
&& self.read_only_paths.is_empty()
&& self.read_write_paths.is_empty()
&& self.network_mode.is_none()
&& self.env_mode.is_none()
&& self.env_file.is_none()
&& self.env_allowlist.is_empty()
&& self.capabilities.is_empty()
&& self.linked_worktree_mode.is_none()
&& self.linked_worktree_ids.is_empty()
&& !self.trust_workspace_config
}
pub fn resolve(
&self,
context: Option<SandboxPolicyContext>,
) -> Result<ResolvedSandboxPolicy, String> {
let derived_root = context
.as_ref()
.and_then(|ctx| ctx.workspace_root.as_ref())
.map(|root| canonicalize_existing_path(root))
.transpose()?;
let workspace_config = resolve_workspace_config(self, derived_root.as_deref())?;
let effective_input = merge_workspace_config(self, workspace_config.as_ref());
let capability_set = effective_input
.capabilities
.iter()
.copied()
.collect::<BTreeSet<_>>();
let host_workdir = match effective_input.workdir.as_deref() {
Some(raw) => resolve_user_path(raw, derived_root.as_deref())?,
None => derived_root.clone().ok_or_else(|| {
"Sandbox policy requires either policy.workdir or a workspace/codebase root."
.to_string()
})?,
};
let scope_root = derived_root.clone().unwrap_or_else(|| host_workdir.clone());
if !is_within(&scope_root, &host_workdir) {
return Err(format!(
"Resolved workdir '{}' escapes scope root '{}'.",
host_workdir.display(),
scope_root.display()
));
}
let mut notes = Vec::new();
record_workspace_config_note(workspace_config.as_ref(), &mut notes);
if derived_root.is_some() {
notes.push(format!(
"Resolved scope root from workspace/codebase context: {}",
scope_root.display()
));
} else {
notes.push(format!(
"No workspace/codebase root provided; using workdir as scope root: {}",
scope_root.display()
));
}
let mut read_only_paths =
resolve_grant_paths(&effective_input.read_only_paths, &scope_root)?;
let read_write_paths = resolve_grant_paths(&effective_input.read_write_paths, &scope_root)?;
if !read_write_paths.is_empty()
&& !capability_set.contains(&SandboxCapability::WorkspaceWrite)
{
return Err(
"Sandbox policy readWritePaths require the workspaceWrite capability.".to_string(),
);
}
let read_write_set: BTreeSet<PathBuf> = read_write_paths.iter().cloned().collect();
read_only_paths.retain(|path| !read_write_set.contains(path));
if effective_input.read_only_paths.len() != read_only_paths.len() {
notes.push(
"Dropped duplicate read-only grants that were also present in read-write grants."
.to_string(),
);
}
let network_mode = resolve_network_mode(effective_input.network_mode, &capability_set)?;
if network_mode == SandboxNetworkMode::None && effective_input.network_mode.is_none() {
notes.push(
"Defaulted network mode to none because networkAccess is not allow-listed."
.to_string(),
);
}
let scope_access = if read_write_set.contains(&scope_root) {
SandboxMountAccess::ReadWrite
} else {
SandboxMountAccess::ReadOnly
};
let container_workdir = to_container_path(&scope_root, &host_workdir);
let mut mounts = vec![SandboxMount {
host_path: scope_root.to_string_lossy().to_string(),
container_path: SANDBOX_SCOPE_CONTAINER_ROOT.to_string(),
access: scope_access,
reason: Some("scopeRoot".to_string()),
}];
let overrides = collect_override_mounts(
&scope_root,
scope_access,
&read_only_paths,
&read_write_paths,
&mut notes,
);
mounts.extend(overrides);
mounts.extend(collect_external_mounts(
&scope_root,
&read_only_paths,
SandboxMountAccess::ReadOnly,
));
mounts.extend(collect_external_mounts(
&scope_root,
&read_write_paths,
SandboxMountAccess::ReadWrite,
));
let linked_worktrees = resolve_linked_worktrees(
&effective_input,
context.as_ref(),
&scope_root,
&capability_set,
&mut notes,
)?;
for linked_worktree in &linked_worktrees {
mounts.push(SandboxMount {
host_path: linked_worktree.host_path.clone(),
container_path: linked_worktree.container_path.clone(),
access: SandboxMountAccess::ReadOnly,
reason: Some("linkedWorktree".to_string()),
});
}
let env_files =
resolve_env_file_layers(self, workspace_config.as_ref(), &scope_root, &mut notes)?;
let env_allowlist = effective_input
.env_allowlist
.iter()
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
Ok(ResolvedSandboxPolicy {
workspace_id: context.as_ref().and_then(|ctx| ctx.workspace_id.clone()),
codebase_id: context.as_ref().and_then(|ctx| ctx.codebase_id.clone()),
scope_root: scope_root.to_string_lossy().to_string(),
host_workdir: host_workdir.to_string_lossy().to_string(),
container_workdir,
read_only_paths: read_only_paths
.into_iter()
.map(|path| path.to_string_lossy().to_string())
.collect(),
read_write_paths: read_write_paths
.into_iter()
.map(|path| path.to_string_lossy().to_string())
.collect(),
network_mode,
env_mode: effective_input.env_mode.unwrap_or_default(),
env_files,
env_allowlist,
mounts,
capabilities: resolve_capability_view(
&capability_set,
!read_write_set.is_empty(),
network_mode,
!linked_worktrees.is_empty(),
),
linked_worktrees,
workspace_config: workspace_config.map(|entry| entry.descriptor),
notes,
})
}
}
#[derive(Debug, Clone, Default)]
pub struct SandboxPolicyContext {
pub workspace_id: Option<String>,
pub codebase_id: Option<String>,
pub workspace_root: Option<PathBuf>,
pub available_worktrees: Vec<SandboxPolicyWorktree>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedSandboxPolicy {
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codebase_id: Option<String>,
pub scope_root: String,
pub host_workdir: String,
pub container_workdir: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub read_only_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub read_write_paths: Vec<String>,
pub network_mode: SandboxNetworkMode,
pub env_mode: SandboxEnvMode,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_files: Vec<ResolvedSandboxEnvFile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_allowlist: Vec<String>,
pub mounts: Vec<SandboxMount>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<ResolvedSandboxCapability>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub linked_worktrees: Vec<ResolvedSandboxLinkedWorktree>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_config: Option<ResolvedSandboxWorkspaceConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedSandboxCapability {
pub capability: SandboxCapability,
pub tier: SandboxCapabilityTier,
pub enabled: bool,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedSandboxLinkedWorktree {
pub id: String,
pub codebase_id: String,
pub branch: String,
pub host_path: String,
pub container_path: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum SandboxEnvFileSource {
WorkspaceConfig,
Request,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedSandboxEnvFile {
pub path: String,
pub source: SandboxEnvFileSource,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keys: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedSandboxWorkspaceConfig {
pub path: String,
pub trusted: bool,
pub loaded: bool,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct WorkspaceSandboxConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
workdir: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
read_only_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
read_write_paths: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
network_mode: Option<SandboxNetworkMode>,
#[serde(skip_serializing_if = "Option::is_none")]
env_mode: Option<SandboxEnvMode>,
#[serde(skip_serializing_if = "Option::is_none")]
env_file: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
env_allowlist: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
capabilities: Vec<SandboxCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
linked_worktree_mode: Option<SandboxLinkedWorktreeMode>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
linked_worktree_ids: Vec<String>,
}
#[derive(Debug, Clone)]
struct WorkspaceConfigResolution {
descriptor: ResolvedSandboxWorkspaceConfig,
config: Option<WorkspaceSandboxConfigFile>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxPolicyWorktree {
pub id: String,
pub codebase_id: String,
pub worktree_path: String,
pub branch: String,
}
fn resolve_grant_paths(raw_paths: &[String], scope_root: &Path) -> Result<Vec<PathBuf>, String> {
raw_paths
.iter()
.map(|raw| resolve_user_path(raw, Some(scope_root)))
.collect::<Result<BTreeSet<_>, _>>()
.map(|set| set.into_iter().collect())
}
fn resolve_workspace_config(
policy: &SandboxPolicyInput,
derived_root: Option<&Path>,
) -> Result<Option<WorkspaceConfigResolution>, String> {
let Some(root) = derived_root else {
return Ok(None);
};
let config_path = root.join(".routa").join("sandbox.json");
let config_path_string = config_path.to_string_lossy().to_string();
if !config_path.exists() {
return Ok(Some(WorkspaceConfigResolution {
descriptor: ResolvedSandboxWorkspaceConfig {
path: config_path_string,
trusted: policy.trust_workspace_config,
loaded: false,
reason: "notFound".to_string(),
},
config: None,
}));
}
if !policy.trust_workspace_config {
return Ok(Some(WorkspaceConfigResolution {
descriptor: ResolvedSandboxWorkspaceConfig {
path: config_path_string,
trusted: false,
loaded: false,
reason: "trustDisabled".to_string(),
},
config: None,
}));
}
let raw = fs::read_to_string(&config_path).map_err(|err| {
format!(
"Failed to read trusted workspace sandbox config '{}': {}",
config_path.display(),
err
)
})?;
let config = serde_json::from_str::<WorkspaceSandboxConfigFile>(&raw).map_err(|err| {
format!(
"Failed to parse trusted workspace sandbox config '{}': {}",
config_path.display(),
err
)
})?;
Ok(Some(WorkspaceConfigResolution {
descriptor: ResolvedSandboxWorkspaceConfig {
path: config_path_string,
trusted: true,
loaded: true,
reason: "loaded".to_string(),
},
config: Some(config),
}))
}
fn merge_workspace_config(
policy: &SandboxPolicyInput,
workspace_config: Option<&WorkspaceConfigResolution>,
) -> SandboxPolicyInput {
let Some(config) = workspace_config.and_then(|entry| entry.config.as_ref()) else {
return policy.clone();
};
let mut merged = policy.clone();
if merged.workdir.is_none() {
merged.workdir = config.workdir.clone();
}
merged.read_only_paths = merge_string_lists(&config.read_only_paths, &policy.read_only_paths);
merged.read_write_paths =
merge_string_lists(&config.read_write_paths, &policy.read_write_paths);
if merged.network_mode.is_none() {
merged.network_mode = config.network_mode;
}
if merged.env_mode.is_none() {
merged.env_mode = config.env_mode;
}
merged.env_allowlist = merge_string_lists(&config.env_allowlist, &policy.env_allowlist);
merged.capabilities = merge_capabilities(&config.capabilities, &policy.capabilities);
if merged.linked_worktree_mode.is_none() {
merged.linked_worktree_mode = config.linked_worktree_mode;
}
merged.linked_worktree_ids =
merge_string_lists(&config.linked_worktree_ids, &policy.linked_worktree_ids);
merged
}
fn merge_string_lists(base: &[String], overlay: &[String]) -> Vec<String> {
base.iter()
.chain(overlay.iter())
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn merge_capabilities(
base: &[SandboxCapability],
overlay: &[SandboxCapability],
) -> Vec<SandboxCapability> {
base.iter()
.chain(overlay.iter())
.copied()
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn record_workspace_config_note(
workspace_config: Option<&WorkspaceConfigResolution>,
notes: &mut Vec<String>,
) {
let Some(workspace_config) = workspace_config else {
return;
};
let descriptor = &workspace_config.descriptor;
let note = match descriptor.reason.as_str() {
"loaded" => format!(
"Loaded trusted workspace sandbox config: {}",
descriptor.path
),
"trustDisabled" => format!(
"Ignored repo-local sandbox config because trustWorkspaceConfig is false: {}",
descriptor.path
),
"notFound" => format!("No repo-local sandbox config found at {}", descriptor.path),
other => format!(
"Workspace sandbox config state '{}' for {}",
other, descriptor.path
),
};
notes.push(note);
}
fn resolve_user_path(raw_path: &str, base_dir: Option<&Path>) -> Result<PathBuf, String> {
let raw_path = raw_path.trim();
if raw_path.is_empty() {
return Err("Sandbox policy path entries cannot be empty.".to_string());
}
let candidate = PathBuf::from(raw_path);
if candidate.is_absolute() {
canonicalize_existing_path(&candidate)
} else if let Some(base_dir) = base_dir {
canonicalize_existing_path(&base_dir.join(candidate))
} else {
Err(format!(
"Relative sandbox path '{}' requires a workspace/codebase root or explicit workdir base.",
raw_path
))
}
}
fn resolve_env_file_layers(
policy: &SandboxPolicyInput,
workspace_config: Option<&WorkspaceConfigResolution>,
scope_root: &Path,
notes: &mut Vec<String>,
) -> Result<Vec<ResolvedSandboxEnvFile>, String> {
let mut env_files = Vec::new();
if let Some(raw) = workspace_config
.and_then(|entry| entry.config.as_ref())
.and_then(|config| config.env_file.as_deref())
{
env_files.push(resolve_env_file(
raw,
scope_root,
SandboxEnvFileSource::WorkspaceConfig,
)?);
}
if let Some(raw) = policy.env_file.as_deref() {
env_files.push(resolve_env_file(
raw,
scope_root,
SandboxEnvFileSource::Request,
)?);
}
if !env_files.is_empty() {
notes.push(format!(
"Resolved {} env file layer(s) for sandbox environment injection.",
env_files.len()
));
}
Ok(env_files)
}
fn resolve_env_file(
raw_path: &str,
scope_root: &Path,
source: SandboxEnvFileSource,
) -> Result<ResolvedSandboxEnvFile, String> {
let path = resolve_user_path(raw_path, Some(scope_root))?;
let keys = parse_env_file_keys(&path)?;
Ok(ResolvedSandboxEnvFile {
path: path.to_string_lossy().to_string(),
source,
keys,
})
}
fn resolve_network_mode(
requested: Option<SandboxNetworkMode>,
capabilities: &BTreeSet<SandboxCapability>,
) -> Result<SandboxNetworkMode, String> {
if capabilities.contains(&SandboxCapability::NetworkAccess) {
return Ok(requested.unwrap_or(SandboxNetworkMode::Bridge));
}
match requested {
Some(SandboxNetworkMode::Bridge) => Err(
"Sandbox policy networkMode=bridge requires the networkAccess capability.".to_string(),
),
_ => Ok(SandboxNetworkMode::None),
}
}
fn resolve_linked_worktrees(
policy: &SandboxPolicyInput,
context: Option<&SandboxPolicyContext>,
scope_root: &Path,
capabilities: &BTreeSet<SandboxCapability>,
notes: &mut Vec<String>,
) -> Result<Vec<ResolvedSandboxLinkedWorktree>, String> {
let mode = policy.linked_worktree_mode.unwrap_or_default();
if mode == SandboxLinkedWorktreeMode::Disabled {
return Ok(Vec::new());
}
if !capabilities.contains(&SandboxCapability::LinkedWorktreeRead) {
return Err(
"Sandbox policy linkedWorktreeMode requires the linkedWorktreeRead capability."
.to_string(),
);
}
let available = context
.map(|ctx| ctx.available_worktrees.as_slice())
.unwrap_or(&[]);
if available.is_empty() {
notes.push("No active linked worktrees available for this sandbox context.".to_string());
return Ok(Vec::new());
}
let requested_ids = policy
.linked_worktree_ids
.iter()
.cloned()
.collect::<BTreeSet<_>>();
if mode == SandboxLinkedWorktreeMode::Explicit && requested_ids.is_empty() {
return Err(
"Sandbox policy linkedWorktreeMode=explicit requires linkedWorktreeIds.".to_string(),
);
}
let mut selected = Vec::new();
for worktree in available {
let include = match mode {
SandboxLinkedWorktreeMode::Disabled => false,
SandboxLinkedWorktreeMode::All => true,
SandboxLinkedWorktreeMode::Explicit => requested_ids.contains(&worktree.id),
};
if !include {
continue;
}
let host_path = canonicalize_existing_path(Path::new(&worktree.worktree_path))?;
if host_path == scope_root {
notes.push(format!(
"Skipped linked worktree {} because it matches the sandbox scope root.",
worktree.id
));
continue;
}
selected.push(ResolvedSandboxLinkedWorktree {
id: worktree.id.clone(),
codebase_id: worktree.codebase_id.clone(),
branch: worktree.branch.clone(),
host_path: host_path.to_string_lossy().to_string(),
container_path: format!(
"{}/{:02}-{}",
SANDBOX_LINKED_WORKTREE_ROOT,
selected.len(),
sanitize_mount_name(Path::new(&worktree.worktree_path))
),
});
}
if mode == SandboxLinkedWorktreeMode::Explicit {
let selected_ids = selected
.iter()
.map(|worktree| worktree.id.clone())
.collect::<BTreeSet<_>>();
let missing = requested_ids
.difference(&selected_ids)
.cloned()
.collect::<Vec<_>>();
if !missing.is_empty() {
return Err(format!(
"Sandbox policy linkedWorktreeIds not found or inactive: {}",
missing.join(", ")
));
}
}
if !selected.is_empty() {
notes.push(format!(
"Mounted {} linked worktree(s) as read-only comparison roots.",
selected.len()
));
}
Ok(selected)
}
fn resolve_capability_view(
capabilities: &BTreeSet<SandboxCapability>,
uses_workspace_write: bool,
network_mode: SandboxNetworkMode,
has_linked_worktrees: bool,
) -> Vec<ResolvedSandboxCapability> {
[
SandboxCapability::WorkspaceRead,
SandboxCapability::WorkspaceWrite,
SandboxCapability::NetworkAccess,
SandboxCapability::LinkedWorktreeRead,
]
.into_iter()
.map(|capability| ResolvedSandboxCapability {
capability,
tier: capability_tier(capability),
enabled: capability == SandboxCapability::WorkspaceRead
|| capabilities.contains(&capability),
reason: capability_reason(
capability,
capabilities,
uses_workspace_write,
network_mode,
has_linked_worktrees,
),
})
.collect()
}
fn capability_tier(capability: SandboxCapability) -> SandboxCapabilityTier {
match capability {
SandboxCapability::WorkspaceRead | SandboxCapability::LinkedWorktreeRead => {
SandboxCapabilityTier::Observation
}
SandboxCapability::WorkspaceWrite | SandboxCapability::NetworkAccess => {
SandboxCapabilityTier::Action
}
}
}
fn capability_reason(
capability: SandboxCapability,
capabilities: &BTreeSet<SandboxCapability>,
uses_workspace_write: bool,
network_mode: SandboxNetworkMode,
has_linked_worktrees: bool,
) -> String {
match capability {
SandboxCapability::WorkspaceRead => {
"Implicitly enabled for the primary workspace/codebase mount.".to_string()
}
SandboxCapability::WorkspaceWrite => {
if capabilities.contains(&SandboxCapability::WorkspaceWrite) {
if uses_workspace_write {
"Allow-listed and used to authorize read-write path grants.".to_string()
} else {
"Allow-listed, but no read-write path grants are currently in use.".to_string()
}
} else {
"Not allow-listed; workspace and extra mounts remain read-only.".to_string()
}
}
SandboxCapability::NetworkAccess => {
if capabilities.contains(&SandboxCapability::NetworkAccess) {
format!(
"Allow-listed with effective network mode {:?}.",
network_mode
)
} else {
"Not allow-listed; network defaults to none.".to_string()
}
}
SandboxCapability::LinkedWorktreeRead => {
if capabilities.contains(&SandboxCapability::LinkedWorktreeRead) {
if has_linked_worktrees {
"Allow-listed and used to mount linked worktrees read-only.".to_string()
} else {
"Allow-listed, but no linked worktrees were selected.".to_string()
}
} else {
"Not allow-listed; linked worktrees are unavailable.".to_string()
}
}
}
}
fn canonicalize_existing_path(path: &Path) -> Result<PathBuf, String> {
fs::canonicalize(path)
.map_err(|e| format!("Failed to resolve sandbox path '{}': {}", path.display(), e))
}
fn is_within(root: &Path, path: &Path) -> bool {
path == root || path.starts_with(root)
}
fn to_container_path(scope_root: &Path, host_path: &Path) -> String {
if host_path == scope_root {
return SANDBOX_SCOPE_CONTAINER_ROOT.to_string();
}
let suffix = host_path
.strip_prefix(scope_root)
.unwrap_or(host_path)
.components()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/");
format!("{}/{}", SANDBOX_SCOPE_CONTAINER_ROOT, suffix)
}
fn collect_override_mounts(
scope_root: &Path,
scope_access: SandboxMountAccess,
read_only_paths: &[PathBuf],
read_write_paths: &[PathBuf],
notes: &mut Vec<String>,
) -> Vec<SandboxMount> {
let mut mounts = Vec::new();
let mut push_override_mounts =
|paths: &[PathBuf], access: SandboxMountAccess, redundant_when: SandboxMountAccess| {
let mut paths = paths
.iter()
.filter(|path| is_within(scope_root, path) && *path != scope_root)
.cloned()
.collect::<Vec<_>>();
paths.sort_by_key(|path| path.components().count());
for path in paths {
if scope_access == redundant_when {
notes.push(format!(
"Skipped redundant {:?} override inside scope root: {}",
access,
path.display()
));
continue;
}
mounts.push(SandboxMount {
host_path: path.to_string_lossy().to_string(),
container_path: to_container_path(scope_root, &path),
access,
reason: Some("scopeOverride".to_string()),
});
}
};
push_override_mounts(
read_only_paths,
SandboxMountAccess::ReadOnly,
SandboxMountAccess::ReadOnly,
);
push_override_mounts(
read_write_paths,
SandboxMountAccess::ReadWrite,
SandboxMountAccess::ReadWrite,
);
mounts
}
fn collect_external_mounts(
scope_root: &Path,
paths: &[PathBuf],
access: SandboxMountAccess,
) -> Vec<SandboxMount> {
paths
.iter()
.filter(|path| !is_within(scope_root, path))
.enumerate()
.map(|(index, path)| SandboxMount {
host_path: path.to_string_lossy().to_string(),
container_path: format!(
"{}/{:02}-{}",
match access {
SandboxMountAccess::ReadOnly => SANDBOX_EXTRA_READONLY_ROOT,
SandboxMountAccess::ReadWrite => SANDBOX_EXTRA_READWRITE_ROOT,
},
index,
sanitize_mount_name(path)
),
access,
reason: Some("explicitGrant".to_string()),
})
.collect()
}
fn sanitize_mount_name(path: &Path) -> String {
let raw = path
.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy();
let name = raw
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.chars()
.take(48)
.collect::<String>();
if name.is_empty() {
"path".to_string()
} else {
name
}
}
#[cfg(test)]
mod policy_tests;