Move workspace roots onto thread/session state and stop using active permission profile modifications as an overlay for writable roots. Existing app-server threads now preserve their persisted PermissionProfile value across resume, fork, and turn updates; permissions requests on existing threads only update the active named profile after validating it exists. Workspace roots can be updated independently, and SandboxPolicy::WorkspaceWrite no longer stores its own writable_roots.

This commit is contained in:
Michael Bolin
2026-05-11 15:23:16 -07:00
parent 3e10e09e24
commit 6579ec2f9d
141 changed files with 2384 additions and 2210 deletions
+14 -24
View File
@@ -335,21 +335,6 @@ pub struct ActivePermissionProfile {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub extends: Option<String>,
/// Bounded user-requested modifications applied on top of the named
/// profile, if any.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub modifications: Vec<ActivePermissionProfileModification>,
}
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum ActivePermissionProfileModification {
/// Additional concrete directory that should be writable.
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
AdditionalWritableRoot { path: AbsolutePathBuf },
}
impl ActivePermissionProfile {
@@ -357,17 +342,8 @@ impl ActivePermissionProfile {
Self {
id: id.into(),
extends: None,
modifications: Vec::new(),
}
}
pub fn with_modifications(
mut self,
modifications: Vec<ActivePermissionProfileModification>,
) -> Self {
self.modifications = modifications;
self
}
}
impl Default for PermissionProfile {
@@ -539,6 +515,20 @@ impl PermissionProfile {
self.network_sandbox_policy(),
)
}
pub fn materialize_project_roots_with_workspace_roots(
&self,
workspace_roots: &[AbsolutePathBuf],
) -> Self {
let (file_system_sandbox_policy, network_sandbox_policy) = self.to_runtime_permissions();
let file_system_sandbox_policy = file_system_sandbox_policy
.materialize_project_roots_with_workspace_roots(workspace_roots);
Self::from_runtime_permissions_with_enforcement(
self.enforcement(),
&file_system_sandbox_policy,
network_sandbox_policy,
)
}
}
#[derive(Debug, Clone, Deserialize)]
+78 -62
View File
@@ -538,27 +538,16 @@ impl FileSystemSandboxPolicy {
/// into split filesystem policy.
pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
let mut file_system_policy = Self::from(sandbox_policy);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox_policy {
if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) {
for protected_path in default_read_only_subpaths_for_writable_root(
&cwd_root, /*protect_missing_dot_codex*/ true,
) {
append_default_read_only_path_if_no_explicit_rule(
&mut file_system_policy.entries,
protected_path,
);
}
}
for writable_root in writable_roots {
for protected_path in default_read_only_subpaths_for_writable_root(
writable_root,
/*protect_missing_dot_codex*/ false,
) {
append_default_read_only_path_if_no_explicit_rule(
&mut file_system_policy.entries,
protected_path,
);
}
if let SandboxPolicy::WorkspaceWrite { .. } = sandbox_policy
&& let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd)
{
for protected_path in default_read_only_subpaths_for_writable_root(
&cwd_root, /*protect_missing_dot_codex*/ true,
) {
append_default_read_only_path_if_no_explicit_rule(
&mut file_system_policy.entries,
protected_path,
);
}
}
@@ -679,6 +668,38 @@ impl FileSystemSandboxPolicy {
self
}
/// Replaces symbolic `:project_roots` entries with concrete entries for
/// each workspace root.
pub fn materialize_project_roots_with_workspace_roots(
mut self,
workspace_roots: &[AbsolutePathBuf],
) -> Self {
let mut entries = Vec::with_capacity(self.entries.len());
for entry in self.entries {
let FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { subpath },
} = &entry.path
else {
entries.push(entry);
continue;
};
entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: match subpath.as_ref() {
Some(subpath) => {
AbsolutePathBuf::resolve_path_against_base(subpath, root.as_path())
}
None => root.clone(),
},
},
access: entry.access,
}));
}
self.entries = entries;
self
}
pub fn with_additional_readable_roots(
mut self,
cwd: &Path,
@@ -994,10 +1015,10 @@ impl FileSystemSandboxPolicy {
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
let has_full_disk_write_access = self.has_full_disk_write_access();
let mut workspace_root_writable = false;
let mut writable_roots = Vec::new();
let mut tmpdir_writable = false;
let mut slash_tmp_writable = false;
let mut unbridgeable_root_write = false;
let mut unbridgeable_external_write = false;
for entry in &self.entries {
match &entry.path {
@@ -1007,7 +1028,7 @@ impl FileSystemSandboxPolicy {
if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) {
workspace_root_writable = true;
} else {
writable_roots.push(path.clone());
unbridgeable_external_write = true;
}
}
}
@@ -1023,11 +1044,8 @@ impl FileSystemSandboxPolicy {
FileSystemSpecialPath::ProjectRoots { subpath } => {
if subpath.is_none() && entry.access.can_write() {
workspace_root_writable = true;
} else if let Some(path) =
resolve_file_system_special_path(value, cwd_absolute.as_ref())
&& entry.access.can_write()
{
writable_roots.push(path);
} else if entry.access.can_write() {
unbridgeable_external_write = true;
}
}
FileSystemSpecialPath::Tmpdir => {
@@ -1055,21 +1073,20 @@ impl FileSystemSandboxPolicy {
});
}
if unbridgeable_root_write || unbridgeable_external_write {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly",
));
}
if workspace_root_writable {
SandboxPolicy::WorkspaceWrite {
writable_roots: dedup_absolute_paths(
writable_roots,
/*normalize_effective_paths*/ false,
),
network_access: network_policy.is_enabled(),
exclude_tmpdir_env_var: !tmpdir_writable,
exclude_slash_tmp: !slash_tmp_writable,
}
} else if unbridgeable_root_write
|| !writable_roots.is_empty()
|| tmpdir_writable
|| slash_tmp_writable
{
} else if tmpdir_writable || slash_tmp_writable {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly",
@@ -1135,12 +1152,11 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
}])
}
SandboxPolicy::WorkspaceWrite {
writable_roots,
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
} => FileSystemSandboxPolicy::workspace_write(
writable_roots,
&[],
*exclude_tmpdir_env_var,
*exclude_slash_tmp,
),
@@ -1447,7 +1463,6 @@ fn legacy_runtime_file_system_policy_for_cwd(
cwd: &Path,
) -> FileSystemSandboxPolicy {
let SandboxPolicy::WorkspaceWrite {
writable_roots,
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
@@ -1487,16 +1502,6 @@ fn legacy_runtime_file_system_policy_for_cwd(
access: FileSystemAccessMode::Write,
});
}
entries.extend(
writable_roots
.iter()
.cloned()
.map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Write,
}),
);
if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) {
for protected_path in default_read_only_subpaths_for_writable_root(
&cwd_root, /*protect_missing_dot_codex*/ true,
@@ -1504,14 +1509,6 @@ fn legacy_runtime_file_system_policy_for_cwd(
append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
}
}
for writable_root in writable_roots {
for protected_path in default_read_only_subpaths_for_writable_root(
writable_root,
/*protect_missing_dot_codex*/ false,
) {
append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path);
}
}
FileSystemSandboxPolicy::restricted(entries)
}
@@ -1770,6 +1767,29 @@ mod tests {
Ok(())
}
#[test]
fn legacy_bridge_rejects_extra_writable_root_when_cwd_is_writable() {
let cwd = TempDir::new().expect("tempdir");
let cwd_path = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd");
let extra_root = AbsolutePathBuf::resolve_path_against_base("extra-root", cwd.path());
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: cwd_path },
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: extra_root },
access: FileSystemAccessMode::Write,
},
]);
let err = policy
.to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd.path())
.expect_err("extra writable roots cannot be represented by WorkspaceWrite");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[cfg(unix)]
#[test]
fn writable_roots_proactively_protect_missing_dot_codex() {
@@ -1800,7 +1820,6 @@ mod tests {
#[test]
fn legacy_workspace_write_projection_preserves_symbolic_project_root() {
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -1956,7 +1975,6 @@ mod tests {
)
.expect("absolute root");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -2472,7 +2490,6 @@ mod tests {
fn legacy_projection_runtime_enforcement_ignores_entry_order() {
let cwd = TempDir::new().expect("tempdir");
let legacy_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
@@ -2499,7 +2516,6 @@ mod tests {
fn missing_symbolic_metadata_carveouts_need_direct_runtime_enforcement() {
let cwd = TempDir::new().expect("tempdir");
let legacy_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
+28 -13
View File
@@ -462,6 +462,10 @@ pub enum Op {
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<PathBuf>,
/// Updated workspace roots for sandbox/tool calls.
#[serde(skip_serializing_if = "Option::is_none")]
workspace_roots: Option<Vec<AbsolutePathBuf>>,
/// Updated command approval policy.
#[serde(skip_serializing_if = "Option::is_none")]
approval_policy: Option<AskForApproval>,
@@ -1004,14 +1008,9 @@ pub enum SandboxPolicy {
},
/// Same as `ReadOnly` but additionally grants write access to the current
/// working directory ("workspace").
/// thread workspace roots.
#[serde(rename = "workspace-write")]
WorkspaceWrite {
/// Additional folders (beyond cwd and possibly TMPDIR) that should be
/// writable from within the sandbox.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
writable_roots: Vec<AbsolutePathBuf>,
/// When set to `true`, outbound network access is allowed. `false` by
/// default.
#[serde(default)]
@@ -1121,7 +1120,6 @@ impl SandboxPolicy {
/// not allow network access.
pub fn new_workspace_write_policy() -> Self {
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
@@ -1159,13 +1157,11 @@ impl SandboxPolicy {
SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
SandboxPolicy::ReadOnly { .. } => Vec::new(),
SandboxPolicy::WorkspaceWrite {
writable_roots,
exclude_tmpdir_env_var,
exclude_slash_tmp,
network_access: _,
} => {
// Start from explicitly configured writable roots.
let mut roots: Vec<AbsolutePathBuf> = writable_roots.clone();
let mut roots: Vec<AbsolutePathBuf> = Vec::new();
// Always include defaults: cwd, /tmp (if present on Unix), and
// on macOS, the per-user TMPDIR unless explicitly excluded.
@@ -2704,6 +2700,8 @@ pub struct SessionMeta {
pub forked_from_id: Option<ThreadId>,
pub timestamp: String,
pub cwd: PathBuf,
#[serde(default)]
pub workspace_roots: Vec<AbsolutePathBuf>,
pub originator: String,
pub cli_version: String,
#[serde(default)]
@@ -2738,6 +2736,7 @@ impl Default for SessionMeta {
forked_from_id: None,
timestamp: String::new(),
cwd: PathBuf::new(),
workspace_roots: Vec::new(),
originator: String::new(),
cli_version: String::new(),
source: SessionSource::default(),
@@ -2808,6 +2807,8 @@ pub struct TurnContextItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
pub cwd: PathBuf,
#[serde(default)]
pub workspace_roots: Vec<AbsolutePathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -2816,6 +2817,8 @@ pub struct TurnContextItem {
pub sandbox_policy: SandboxPolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_profile: Option<PermissionProfile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_permission_profile: Option<ActivePermissionProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network: Option<TurnContextNetworkItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -3448,6 +3451,10 @@ pub struct SessionConfiguredEvent {
/// session.
pub cwd: AbsolutePathBuf,
/// Workspace roots used to realize symbolic `:project_roots` entries.
#[serde(default)]
pub workspace_roots: Vec<AbsolutePathBuf>,
/// The effort the model is putting into reasoning about the user's request.
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffortConfig>,
@@ -3496,6 +3503,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
#[serde(default)]
active_permission_profile: Option<ActivePermissionProfile>,
cwd: AbsolutePathBuf,
#[serde(default)]
workspace_roots: Option<Vec<AbsolutePathBuf>>,
reasoning_effort: Option<ReasoningEffortConfig>,
initial_messages: Option<Vec<EventMsg>>,
network_proxy: Option<SessionNetworkProxyRuntime>,
@@ -3513,6 +3522,9 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
return Err(serde::de::Error::missing_field("permission_profile"));
}
};
let workspace_roots = wire
.workspace_roots
.unwrap_or_else(|| vec![wire.cwd.clone()]);
Ok(Self {
session_id: wire.session_id,
@@ -3528,6 +3540,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
permission_profile,
active_permission_profile: wire.active_permission_profile,
cwd: wire.cwd,
workspace_roots,
reasoning_effort: wire.reasoning_effort,
initial_messages: wire.initial_messages,
network_proxy: wire.network_proxy,
@@ -4477,7 +4490,6 @@ mod tests {
#[test]
fn legacy_sandbox_policy_semantics_survive_split_bridge() {
let cwd = TempDir::new().expect("tempdir");
let writable_root = AbsolutePathBuf::resolve_path_against_base("writable", cwd.path());
let policies = [
SandboxPolicy::DangerFullAccess,
SandboxPolicy::ExternalSandbox {
@@ -4490,13 +4502,11 @@ mod tests {
network_access: false,
},
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root],
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: true,
@@ -5180,11 +5190,13 @@ mod tests {
turn_id: None,
trace_id: None,
cwd: test_path_buf("/tmp"),
workspace_roots: Vec::new(),
current_date: None,
timezone: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: None,
active_permission_profile: None,
network: Some(TurnContextNetworkItem {
allowed_domains: vec!["api.example.com".to_string()],
denied_domains: vec!["blocked.example.com".to_string()],
@@ -5257,6 +5269,7 @@ mod tests {
permission_profile: permission_profile.clone(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
workspace_roots: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
initial_messages: None,
network_proxy: None,
@@ -5276,6 +5289,7 @@ mod tests {
"approvals_reviewer": "user",
"permission_profile": permission_profile,
"cwd": test_path_buf("/home/user/project"),
"workspace_roots": [],
"reasoning_effort": "medium",
"rollout_path": format!("{}", rollout_file.path().display()),
}
@@ -5301,6 +5315,7 @@ mod tests {
let event: SessionConfiguredEvent = serde_json::from_value(value)?;
assert_eq!(event.permission_profile, PermissionProfile::read_only());
assert_eq!(event.workspace_roots, vec![cwd.abs()]);
Ok(())
}