feat: allow limited git writes in workspace sandbox

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-04-24 09:29:42 -07:00
committed by David Wiesen
co-authored by Codex noreply@openai.com
parent a9c111da54
commit 5efa14eaa3
54 changed files with 686 additions and 228 deletions
+6
View File
@@ -89,6 +89,12 @@ codex --sandbox danger-full-access
The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`.
In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval.
Set the following to allow Git metadata writes under writable roots while keeping Git config and hooks read-only:
```toml
[sandbox_workspace_write]
allow_limited_git_writes = true
```
## Code Organization
@@ -3039,6 +3039,10 @@
},
{
"properties": {
"allowLimitedGitWrites": {
"default": false,
"type": "boolean"
},
"excludeSlashTmp": {
"default": false,
"type": "boolean"
@@ -13465,6 +13465,10 @@
},
{
"properties": {
"allowLimitedGitWrites": {
"default": false,
"type": "boolean"
},
"excludeSlashTmp": {
"default": false,
"type": "boolean"
@@ -13512,6 +13516,10 @@
},
"SandboxWorkspaceWrite": {
"properties": {
"allow_limited_git_writes": {
"default": false,
"type": "boolean"
},
"exclude_slash_tmp": {
"default": false,
"type": "boolean"
@@ -10180,6 +10180,10 @@
},
{
"properties": {
"allowLimitedGitWrites": {
"default": false,
"type": "boolean"
},
"excludeSlashTmp": {
"default": false,
"type": "boolean"
@@ -10227,6 +10231,10 @@
},
"SandboxWorkspaceWrite": {
"properties": {
"allow_limited_git_writes": {
"default": false,
"type": "boolean"
},
"exclude_slash_tmp": {
"default": false,
"type": "boolean"
@@ -424,6 +424,10 @@
},
{
"properties": {
"allowLimitedGitWrites": {
"default": false,
"type": "boolean"
},
"excludeSlashTmp": {
"default": false,
"type": "boolean"
@@ -732,6 +732,10 @@
},
"SandboxWorkspaceWrite": {
"properties": {
"allow_limited_git_writes": {
"default": false,
"type": "boolean"
},
"exclude_slash_tmp": {
"default": false,
"type": "boolean"
@@ -1090,6 +1090,10 @@
},
{
"properties": {
"allowLimitedGitWrites": {
"default": false,
"type": "boolean"
},
"excludeSlashTmp": {
"default": false,
"type": "boolean"
@@ -1090,6 +1090,10 @@
},
{
"properties": {
"allowLimitedGitWrites": {
"default": false,
"type": "boolean"
},
"excludeSlashTmp": {
"default": false,
"type": "boolean"
@@ -1090,6 +1090,10 @@
},
{
"properties": {
"allowLimitedGitWrites": {
"default": false,
"type": "boolean"
},
"excludeSlashTmp": {
"default": false,
"type": "boolean"
@@ -544,6 +544,10 @@
},
{
"properties": {
"allowLimitedGitWrites": {
"default": false,
"type": "boolean"
},
"excludeSlashTmp": {
"default": false,
"type": "boolean"
@@ -5,4 +5,4 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { NetworkAccess } from "./NetworkAccess";
import type { ReadOnlyAccess } from "./ReadOnlyAccess";
export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", access: ReadOnlyAccess, networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array<AbsolutePathBuf>, readOnlyAccess: ReadOnlyAccess, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, };
export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", access: ReadOnlyAccess, networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array<AbsolutePathBuf>, readOnlyAccess: ReadOnlyAccess, networkAccess: boolean, allowLimitedGitWrites: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, };
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SandboxWorkspaceWrite = { writable_roots: Array<string>, network_access: boolean, exclude_tmpdir_env_var: boolean, exclude_slash_tmp: boolean, };
export type SandboxWorkspaceWrite = { writable_roots: Array<string>, network_access: boolean, allow_limited_git_writes: boolean, exclude_tmpdir_env_var: boolean, exclude_slash_tmp: boolean, };
@@ -234,6 +234,7 @@ pub struct SandboxSettings {
#[serde(default)]
pub writable_roots: Vec<AbsolutePathBuf>,
pub network_access: Option<bool>,
pub allow_limited_git_writes: Option<bool>,
pub exclude_tmpdir_env_var: Option<bool>,
pub exclude_slash_tmp: Option<bool>,
}
@@ -646,6 +646,8 @@ pub struct SandboxWorkspaceWrite {
#[serde(default)]
pub network_access: bool,
#[serde(default)]
pub allow_limited_git_writes: bool,
#[serde(default)]
pub exclude_tmpdir_env_var: bool,
#[serde(default)]
pub exclude_slash_tmp: bool,
@@ -1744,6 +1746,8 @@ pub enum SandboxPolicy {
#[serde(default)]
network_access: bool,
#[serde(default)]
allow_limited_git_writes: bool,
#[serde(default)]
exclude_tmpdir_env_var: bool,
#[serde(default)]
exclude_slash_tmp: bool,
@@ -1775,12 +1779,14 @@ impl SandboxPolicy {
writable_roots,
read_only_access,
network_access,
allow_limited_git_writes,
exclude_tmpdir_env_var,
exclude_slash_tmp,
} => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.clone(),
read_only_access: read_only_access.to_core(),
network_access: *network_access,
allow_limited_git_writes: *allow_limited_git_writes,
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
},
@@ -1813,12 +1819,14 @@ impl From<codex_protocol::protocol::SandboxPolicy> for SandboxPolicy {
writable_roots,
read_only_access,
network_access,
allow_limited_git_writes,
exclude_tmpdir_env_var,
exclude_slash_tmp,
} => SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access: ReadOnlyAccess::from(read_only_access),
network_access,
allow_limited_git_writes,
exclude_tmpdir_env_var,
exclude_slash_tmp,
},
@@ -9387,6 +9395,7 @@ mod tests {
readable_roots: vec![readable_root.clone()],
},
network_access: true,
allow_limited_git_writes: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
@@ -9401,6 +9410,7 @@ mod tests {
readable_roots: vec![readable_root],
},
network_access: true,
allow_limited_git_writes: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}
@@ -9441,6 +9451,7 @@ mod tests {
writable_roots: vec![],
read_only_access: ReadOnlyAccess::FullAccess,
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}
@@ -1896,6 +1896,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
writable_roots: vec![first_cwd.try_into()?],
read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}),
@@ -538,6 +538,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
writable_roots: vec![workspace.clone().try_into()?],
read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}),
@@ -1963,6 +1963,7 @@ allowed_approvals_reviewers = ["user"]
writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
})
+2
View File
@@ -661,12 +661,14 @@ impl ConfigToml {
Some(SandboxWorkspaceWrite {
writable_roots,
network_access,
allow_limited_git_writes,
exclude_tmpdir_env_var,
exclude_slash_tmp,
}) => SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.clone(),
read_only_access: ReadOnlyAccess::FullAccess,
network_access: *network_access,
allow_limited_git_writes: *allow_limited_git_writes,
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
},
+2
View File
@@ -35,6 +35,8 @@ pub struct FilesystemPermissionsToml {
/// platforms that snapshot glob matches before sandbox startup.
#[schemars(range(min = 1))]
pub glob_scan_max_depth: Option<usize>,
#[serde(default)]
pub allow_limited_git_writes: bool,
#[serde(flatten)]
pub entries: BTreeMap<String, FilesystemPermissionToml>,
}
+3
View File
@@ -681,6 +681,8 @@ pub struct SandboxWorkspaceWrite {
#[serde(default)]
pub network_access: bool,
#[serde(default)]
pub allow_limited_git_writes: bool,
#[serde(default)]
pub exclude_tmpdir_env_var: bool,
#[serde(default)]
pub exclude_slash_tmp: bool,
@@ -691,6 +693,7 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
Self {
writable_roots: sandbox_workspace_write.writable_roots,
network_access: Some(sandbox_workspace_write.network_access),
allow_limited_git_writes: Some(sandbox_workspace_write.allow_limited_git_writes),
exclude_tmpdir_env_var: Some(sandbox_workspace_write.exclude_tmpdir_env_var),
exclude_slash_tmp: Some(sandbox_workspace_write.exclude_slash_tmp),
}
+4 -1
View File
@@ -12,7 +12,10 @@ Expects `/usr/bin/sandbox-exec` to be present.
When using the workspace-write sandbox policy, the Seatbelt profile allows
writes under the configured writable roots while keeping `.git` (directory or
pointer file), the resolved `gitdir:` target, and `.codex` read-only.
pointer file), the resolved `gitdir:` target, and `.codex` read-only by
default. When `allow_limited_git_writes` is enabled, Git metadata writes are allowed,
but `.git/config`, `.git/hooks`, and resolved worktree gitdir config/hooks
remain read-only.
Network access and filesystem read/write roots are controlled by
`SandboxPolicy`. Seatbelt consumes the resolved policy and enforces it.
+9 -1
View File
@@ -746,6 +746,10 @@
},
"FilesystemPermissionsToml": {
"properties": {
"allow_limited_git_writes": {
"default": false,
"type": "boolean"
},
"glob_scan_max_depth": {
"description": "Optional maximum depth for expanding unreadable glob patterns on platforms that snapshot glob matches before sandbox startup.",
"format": "uint",
@@ -1959,6 +1963,10 @@
"SandboxWorkspaceWrite": {
"additionalProperties": false,
"properties": {
"allow_limited_git_writes": {
"default": false,
"type": "boolean"
},
"exclude_slash_tmp": {
"default": false,
"type": "boolean"
@@ -3167,4 +3175,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}
+5
View File
@@ -98,8 +98,13 @@ pub(crate) fn compile_permission_profile(
)?;
let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
let allow_limited_git_writes = profile
.filesystem
.as_ref()
.is_some_and(|filesystem| filesystem.allow_limited_git_writes);
let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries);
file_system_sandbox_policy.glob_scan_max_depth = glob_scan_max_depth;
file_system_sandbox_policy.allow_limited_git_writes = allow_limited_git_writes;
Ok((file_system_sandbox_policy, network_sandbox_policy))
}
+1
View File
@@ -593,6 +593,7 @@ allowed_sandbox_modes = ["read-only"]
writable_roots: Vec::new(),
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
})
+5
View File
@@ -30,6 +30,7 @@ fn test_writable_roots_constraint() {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -52,6 +53,7 @@ fn test_writable_roots_constraint() {
writable_roots: vec![parent],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -100,6 +102,7 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -145,6 +148,7 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -296,6 +300,7 @@ fn missing_project_dot_codex_config_requires_approval() {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
+2
View File
@@ -2839,6 +2839,7 @@ async fn session_configuration_apply_preserves_split_file_system_policy_on_cwd_o
readable_roots: vec![docs_dir.clone()],
},
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
});
@@ -3020,6 +3021,7 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_
readable_roots: vec![docs_dir],
},
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
});
@@ -241,6 +241,7 @@ fn write_permissions_for_paths_skip_dirs_already_writable_under_workspace_root()
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
});
@@ -264,6 +265,7 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
});
@@ -270,6 +270,7 @@ fn shell_request_escalation_execution_is_explicit() {
writable_roots: vec![AbsolutePathBuf::from_absolute_path("/tmp/original/output").unwrap()],
read_only_access: ReadOnlyAccess::FullAccess,
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
+1
View File
@@ -45,6 +45,7 @@ pub fn restrictive_workspace_write_policy() -> SandboxPolicy {
writable_roots: Vec::new(),
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
@@ -645,6 +645,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace(
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -701,6 +702,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace(
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
+3
View File
@@ -770,6 +770,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
writable_roots: vec![],
read_only_access: Default::default(),
network_access,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
@@ -1801,6 +1802,7 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file()
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
@@ -2531,6 +2533,7 @@ allow_local_binding = true
writable_roots: vec![],
read_only_access: Default::default(),
network_access: true,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
@@ -544,6 +544,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> {
writable_roots: vec![writable_root],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
@@ -435,6 +435,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
writable_roots: vec![writable.path().try_into().unwrap()],
read_only_access: Default::default(),
network_access: true,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -714,6 +715,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
writable_roots: vec![writable.abs()],
read_only_access: Default::default(),
network_access: true,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -288,6 +288,7 @@ fn workspace_write_excluding_tmp() -> SandboxPolicy {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
@@ -75,6 +75,7 @@ fn workspace_write_excluding_tmp() -> SandboxPolicy {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
@@ -485,6 +485,7 @@ fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
exclude_tmpdir_env_var,
exclude_slash_tmp,
read_only_access: _,
allow_limited_git_writes: _,
} => {
let mut summary = "workspace-write".to_string();
let mut writable_entries = vec!["workdir".to_string()];
+1
View File
@@ -424,6 +424,7 @@ fn session_configured_from_thread_response_uses_review_policy_from_response() {
writable_roots: vec![],
read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
+3
View File
@@ -184,6 +184,7 @@ async fn python_multiprocessing_lock_works_under_sandbox() {
writable_roots,
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
@@ -299,6 +300,7 @@ async fn sandbox_distinguishes_command_and_policy_cwds() {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -381,6 +383,7 @@ async fn sandbox_blocks_first_time_dot_codex_creation() {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
+2
View File
@@ -1373,6 +1373,7 @@ mod tests {
],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -1404,6 +1405,7 @@ mod tests {
writable_roots: vec![AbsolutePathBuf::try_from(Path::new("/dev")).expect("/dev path")],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -458,6 +458,7 @@ fn resolve_sandbox_policies_accepts_semantically_equivalent_workspace_write_inpu
writable_roots: vec![workspace],
read_only_access: ReadOnlyAccess::FullAccess,
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
@@ -89,6 +89,7 @@ async fn run_cmd_result_with_writable_roots(
.collect(),
read_only_access: Default::default(),
network_access,
allow_limited_git_writes: false,
// Exclude tmp-related folders from writable roots because we need a
// folder that is writable by tests but that we intentionally disallow
// writing to in the sandbox.
@@ -563,6 +564,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() {
writable_roots: vec![AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir")],
read_only_access: Default::default(),
network_access: true,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -636,6 +638,7 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() {
writable_roots: vec![AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir")],
read_only_access: Default::default(),
network_access: true,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
+2
View File
@@ -25,4 +25,6 @@ pub mod plan_tool;
pub mod protocol;
pub mod request_permissions;
pub mod request_user_input;
mod sandbox_paths;
mod serde_helpers;
pub mod user_input;
+190 -103
View File
@@ -1,5 +1,4 @@
use std::collections::HashSet;
use std::ffi::OsStr;
use std::io;
use std::path::Path;
use std::path::PathBuf;
@@ -12,13 +11,13 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display;
use tracing::error;
use ts_rs::TS;
use crate::protocol::NetworkAccess;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
use crate::protocol::WritableRoot;
use crate::sandbox_paths::default_read_only_subpaths_for_writable_root;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
@@ -142,6 +141,8 @@ pub struct FileSystemSandboxPolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub glob_scan_max_depth: Option<usize>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub allow_limited_git_writes: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<FileSystemSandboxEntry>,
}
@@ -157,6 +158,7 @@ struct FileSystemSemanticSignature {
has_full_disk_read_access: bool,
has_full_disk_write_access: bool,
include_platform_defaults: bool,
allow_limited_git_writes: bool,
readable_roots: Vec<AbsolutePathBuf>,
writable_roots: Vec<WritableRoot>,
unreadable_roots: Vec<AbsolutePathBuf>,
@@ -262,6 +264,7 @@ impl Default for FileSystemSandboxPolicy {
Self {
kind: FileSystemSandboxKind::Restricted,
glob_scan_max_depth: None,
allow_limited_git_writes: false,
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
@@ -378,6 +381,32 @@ impl FileSystemSandboxPolicy {
})
}
pub fn unrestricted() -> Self {
Self {
kind: FileSystemSandboxKind::Unrestricted,
glob_scan_max_depth: None,
allow_limited_git_writes: false,
entries: Vec::new(),
}
}
pub fn external_sandbox() -> Self {
Self {
kind: FileSystemSandboxKind::ExternalSandbox,
glob_scan_max_depth: None,
allow_limited_git_writes: false,
entries: Vec::new(),
}
}
pub fn restricted(entries: Vec<FileSystemSandboxEntry>) -> Self {
Self {
kind: FileSystemSandboxKind::Restricted,
glob_scan_max_depth: None,
allow_limited_git_writes: false,
entries,
}
}
/// Converts a legacy sandbox policy into an equivalent filesystem policy
/// for the provided cwd.
///
@@ -387,7 +416,12 @@ impl FileSystemSandboxPolicy {
/// into split filesystem policy.
pub fn from_legacy_sandbox_policy(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 SandboxPolicy::WorkspaceWrite {
writable_roots,
allow_limited_git_writes,
..
} = sandbox_policy
{
let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
file_system_policy.entries.retain(|entry| {
if entry.access != FileSystemAccessMode::Read {
@@ -405,7 +439,9 @@ impl FileSystemSandboxPolicy {
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,
&cwd_root,
/*protect_missing_dot_codex*/ true,
*allow_limited_git_writes,
) {
append_default_read_only_path_if_no_explicit_rule(
&mut file_system_policy.entries,
@@ -417,6 +453,7 @@ impl FileSystemSandboxPolicy {
for protected_path in default_read_only_subpaths_for_writable_root(
writable_root,
/*protect_missing_dot_codex*/ false,
*allow_limited_git_writes,
) {
append_default_read_only_path_if_no_explicit_rule(
&mut file_system_policy.entries,
@@ -607,10 +644,14 @@ impl FileSystemSandboxPolicy {
.ok()
.is_some_and(|cwd| normalize_effective_absolute_path(cwd) == root);
let mut read_only_subpaths: Vec<AbsolutePathBuf> =
default_read_only_subpaths_for_writable_root(&root, protect_missing_dot_codex)
.into_iter()
.filter(|path| !has_explicit_resolved_path_entry(&resolved_entries, path))
.collect();
default_read_only_subpaths_for_writable_root(
&root,
protect_missing_dot_codex,
self.allow_limited_git_writes,
)
.into_iter()
.filter(|path| !has_explicit_resolved_path_entry(&resolved_entries, path))
.collect();
// Narrower explicit non-write entries carve out broader writable roots.
// More specific write entries still remain writable because they appear
// as separate WritableRoot values and are checked independently.
@@ -882,6 +923,7 @@ impl FileSystemSandboxPolicy {
),
read_only_access,
network_access: network_policy.is_enabled(),
allow_limited_git_writes: self.allow_limited_git_writes,
exclude_tmpdir_env_var: !tmpdir_writable,
exclude_slash_tmp: !slash_tmp_writable,
}
@@ -924,6 +966,7 @@ impl FileSystemSandboxPolicy {
has_full_disk_read_access: self.has_full_disk_read_access(),
has_full_disk_write_access: self.has_full_disk_write_access(),
include_platform_defaults: self.include_platform_defaults(),
allow_limited_git_writes: self.allow_limited_git_writes,
readable_roots: self.get_readable_roots_with_cwd(cwd),
writable_roots: self.get_writable_roots_with_cwd(cwd),
unreadable_roots: self.get_unreadable_roots_with_cwd(cwd),
@@ -987,6 +1030,7 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access,
allow_limited_git_writes,
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
@@ -1051,7 +1095,10 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
access: FileSystemAccessMode::Write,
}),
);
FileSystemSandboxPolicy::restricted(entries)
FileSystemSandboxPolicy {
allow_limited_git_writes: *allow_limited_git_writes,
..FileSystemSandboxPolicy::restricted(entries)
}
}
}
}
@@ -1305,44 +1352,6 @@ fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf {
path
}
fn default_read_only_subpaths_for_writable_root(
writable_root: &AbsolutePathBuf,
protect_missing_dot_codex: bool,
) -> Vec<AbsolutePathBuf> {
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
let top_level_git = writable_root.join(".git");
// This applies to typical repos (directory .git), worktrees/submodules
// (file .git with gitdir pointer), and bare repos when the gitdir is the
// writable root itself.
let top_level_git_is_file = top_level_git.as_path().is_file();
let top_level_git_is_dir = top_level_git.as_path().is_dir();
if top_level_git_is_dir || top_level_git_is_file {
if top_level_git_is_file
&& is_git_pointer_file(&top_level_git)
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
{
subpaths.push(gitdir);
}
subpaths.push(top_level_git);
}
let top_level_agents = writable_root.join(".agents");
if top_level_agents.as_path().is_dir() {
subpaths.push(top_level_agents);
}
// Keep top-level project metadata under .codex read-only to the agent by
// default. For the workspace root itself, protect it even before the
// directory exists so first-time creation still goes through the
// protected-path approval flow.
let top_level_codex = writable_root.join(".codex");
if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
}
dedup_absolute_paths(subpaths, /*normalize_effective_paths*/ false)
}
fn append_path_entry_if_missing(
entries: &mut Vec<FileSystemSandboxEntry>,
path: AbsolutePathBuf,
@@ -1387,62 +1396,6 @@ fn has_explicit_resolved_path_entry(
entries.iter().any(|entry| &entry.path == path)
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
let contents = match std::fs::read_to_string(dot_git.as_path()) {
Ok(contents) => contents,
Err(err) => {
error!(
"Failed to read {path} for gitdir pointer: {err}",
path = dot_git.as_path().display()
);
return None;
}
};
let trimmed = contents.trim();
let (_, gitdir_raw) = match trimmed.split_once(':') {
Some(parts) => parts,
None => {
error!(
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_raw = gitdir_raw.trim();
if gitdir_raw.is_empty() {
error!(
"Expected {path} to contain a gitdir pointer, but it was empty.",
path = dot_git.as_path().display()
);
return None;
}
let base = match dot_git.as_path().parent() {
Some(base) => base,
None => {
error!(
"Unable to resolve parent directory for {path}.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base);
if !gitdir_path.as_path().exists() {
error!(
"Resolved gitdir path {path} does not exist.",
path = gitdir_path.as_path().display()
);
return None;
}
Some(gitdir_path)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1561,6 +1514,138 @@ mod tests {
);
}
#[test]
fn writable_roots_protect_git_metadata_by_default() {
let cwd = TempDir::new().expect("tempdir");
let git_dir = cwd.path().join(".git");
std::fs::create_dir_all(&git_dir).expect("create .git dir");
let expected_root = AbsolutePathBuf::from_absolute_path(
cwd.path().canonicalize().expect("canonicalize cwd"),
)
.expect("absolute canonical root");
let expected_git = expected_root.join(".git");
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
}]);
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
assert_eq!(writable_roots.len(), 1);
assert_eq!(writable_roots[0].root, expected_root);
assert!(writable_roots[0].read_only_subpaths.contains(&expected_git));
}
#[test]
fn writable_roots_with_allow_limited_git_writes_protects_only_config_and_hooks() {
let cwd = TempDir::new().expect("tempdir");
std::fs::create_dir_all(cwd.path().join(".git").join("hooks")).expect("create .git dir");
let expected_root = AbsolutePathBuf::from_absolute_path(
cwd.path().canonicalize().expect("canonicalize cwd"),
)
.expect("absolute canonical root");
let expected_git = expected_root.join(".git");
let expected_git_config = expected_git.join("config");
let expected_git_hooks = expected_git.join("hooks");
let policy = FileSystemSandboxPolicy {
allow_limited_git_writes: true,
..FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Write,
}])
};
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
assert_eq!(writable_roots.len(), 1);
assert_eq!(writable_roots[0].root, expected_root);
assert!(
!writable_roots[0].read_only_subpaths.contains(&expected_git),
".git should remain writable when Git writes are allowed"
);
assert!(
writable_roots[0]
.read_only_subpaths
.contains(&expected_git_config)
);
assert!(
writable_roots[0]
.read_only_subpaths
.contains(&expected_git_hooks)
);
}
#[test]
fn legacy_workspace_write_projection_with_allow_limited_git_writes_protects_worktree_config_and_hooks()
{
let cwd = TempDir::new().expect("tempdir");
let canonical_cwd = cwd.path().canonicalize().expect("canonicalize cwd");
let gitdir = cwd
.path()
.join("repo.git")
.join("worktrees")
.join("example");
let hooks = gitdir.join("hooks");
std::fs::create_dir_all(&hooks).expect("create worktree gitdir");
std::fs::write(
cwd.path().join(".git"),
"gitdir: repo.git/worktrees/example",
)
.expect("write .git pointer");
let expected_gitdir = AbsolutePathBuf::from_absolute_path(
gitdir.canonicalize().expect("canonicalize gitdir"),
)
.expect("expected gitdir");
let expected_gitdir_config = expected_gitdir.join("config");
let expected_gitdir_hooks = expected_gitdir.join("hooks");
let expected_top_level_git =
AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".git"))
.expect("expected .git pointer");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: vec![],
},
network_access: false,
allow_limited_git_writes: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_policy =
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, cwd.path());
let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd.path());
assert_eq!(writable_roots.len(), 1);
assert_eq!(writable_roots[0].root.as_path(), canonical_cwd.as_path());
assert!(
!writable_roots[0]
.read_only_subpaths
.contains(&expected_top_level_git)
);
assert!(
!writable_roots[0]
.read_only_subpaths
.contains(&expected_gitdir)
);
assert!(
writable_roots[0]
.read_only_subpaths
.contains(&expected_gitdir_config)
);
assert!(
writable_roots[0]
.read_only_subpaths
.contains(&expected_gitdir_hooks)
);
}
#[test]
fn legacy_workspace_write_projection_blocks_missing_dot_codex_writes() {
let cwd = TempDir::new().expect("tempdir");
@@ -1572,6 +1657,7 @@ mod tests {
readable_roots: vec![],
},
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -1599,6 +1685,7 @@ mod tests {
readable_roots: vec![],
},
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
+16 -102
View File
@@ -5,7 +5,6 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fmt;
use std::ops::Mul;
use std::path::Path;
@@ -49,6 +48,9 @@ use crate::plan_tool::UpdatePlanArgs;
use crate::request_permissions::RequestPermissionsEvent;
use crate::request_permissions::RequestPermissionsResponse;
use crate::request_user_input::RequestUserInputResponse;
use crate::sandbox_paths::default_read_only_subpaths_for_writable_root;
use crate::serde_helpers::deserialize_double_option;
use crate::serde_helpers::serialize_double_option;
use crate::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
@@ -1150,6 +1152,11 @@ pub enum SandboxPolicy {
#[serde(default)]
network_access: bool,
/// When set to `true`, `.git` metadata may be written under writable
/// roots, while `.git/config` and `.git/hooks` remain read-only.
#[serde(default)]
allow_limited_git_writes: bool,
/// When set to `true`, will NOT include the per-user `TMPDIR`
/// environment variable among the default writable roots. Defaults to
/// `false`.
@@ -1235,6 +1242,7 @@ impl SandboxPolicy {
writable_roots: vec![],
read_only_access: ReadOnlyAccess::FullAccess,
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}
@@ -1320,6 +1328,7 @@ impl SandboxPolicy {
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access: _,
allow_limited_git_writes,
exclude_tmpdir_env_var,
exclude_slash_tmp,
network_access: _,
@@ -1393,6 +1402,7 @@ impl SandboxPolicy {
read_only_subpaths: default_read_only_subpaths_for_writable_root(
&writable_root,
protect_missing_dot_codex,
*allow_limited_git_writes,
),
root: writable_root,
}
@@ -1403,107 +1413,6 @@ impl SandboxPolicy {
}
}
fn default_read_only_subpaths_for_writable_root(
writable_root: &AbsolutePathBuf,
protect_missing_dot_codex: bool,
) -> Vec<AbsolutePathBuf> {
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
let top_level_git = writable_root.join(".git");
// This applies to typical repos (directory .git), worktrees/submodules
// (file .git with gitdir pointer), and bare repos when the gitdir is the
// writable root itself.
let top_level_git_is_file = top_level_git.as_path().is_file();
let top_level_git_is_dir = top_level_git.as_path().is_dir();
if top_level_git_is_dir || top_level_git_is_file {
if top_level_git_is_file
&& is_git_pointer_file(&top_level_git)
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
{
subpaths.push(gitdir);
}
subpaths.push(top_level_git);
}
let top_level_agents = writable_root.join(".agents");
if top_level_agents.as_path().is_dir() {
subpaths.push(top_level_agents);
}
// Keep top-level project metadata under .codex read-only to the agent by
// default. For the workspace root itself, protect it even before the
// directory exists so first-time creation still goes through the
// protected-path approval flow.
let top_level_codex = writable_root.join(".codex");
if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
}
let mut deduped = Vec::with_capacity(subpaths.len());
let mut seen = HashSet::new();
for path in subpaths {
if seen.insert(path.to_path_buf()) {
deduped.push(path);
}
}
deduped
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
let contents = match std::fs::read_to_string(dot_git.as_path()) {
Ok(contents) => contents,
Err(err) => {
error!(
"Failed to read {path} for gitdir pointer: {err}",
path = dot_git.as_path().display()
);
return None;
}
};
let trimmed = contents.trim();
let (_, gitdir_raw) = match trimmed.split_once(':') {
Some(parts) => parts,
None => {
error!(
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_raw = gitdir_raw.trim();
if gitdir_raw.is_empty() {
error!(
"Expected {path} to contain a gitdir pointer, but it was empty.",
path = dot_git.as_path().display()
);
return None;
}
let base = match dot_git.as_path().parent() {
Some(base) => base,
None => {
error!(
"Unable to resolve parent directory for {path}.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base);
if !gitdir_path.as_path().exists() {
error!(
"Resolved gitdir path {path} does not exist.",
path = gitdir_path.as_path().display()
);
return None;
}
Some(gitdir_path)
}
/// Event Queue Entry - events from agent
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Event {
@@ -4410,6 +4319,7 @@ mod tests {
readable_roots: vec![],
},
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
};
@@ -4636,6 +4546,7 @@ mod tests {
readable_roots: vec![docs],
},
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -4708,6 +4619,7 @@ mod tests {
writable_roots: vec![],
read_only_access: ReadOnlyAccess::FullAccess,
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
@@ -4718,6 +4630,7 @@ mod tests {
readable_roots: vec![readable_root],
},
network_access: true,
allow_limited_git_writes: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: true,
},
@@ -4728,6 +4641,7 @@ mod tests {
readable_roots: vec![nested_readable_root],
},
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
+124
View File
@@ -0,0 +1,124 @@
use std::collections::HashSet;
use std::ffi::OsStr;
use codex_utils_absolute_path::AbsolutePathBuf;
use tracing::error;
pub(crate) fn default_read_only_subpaths_for_writable_root(
writable_root: &AbsolutePathBuf,
protect_missing_dot_codex: bool,
allow_limited_git_writes: bool,
) -> Vec<AbsolutePathBuf> {
let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
let top_level_git = writable_root.join(".git");
// This applies to typical repos (directory .git), worktrees/submodules
// (file .git with gitdir pointer), and bare repos when the gitdir is the
// writable root itself.
let top_level_git_is_file = top_level_git.as_path().is_file();
let top_level_git_is_dir = top_level_git.as_path().is_dir();
if top_level_git_is_dir || top_level_git_is_file {
if top_level_git_is_file
&& is_git_pointer_file(&top_level_git)
&& let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
{
if allow_limited_git_writes {
subpaths.extend(protected_git_subpaths_when_writable(&gitdir));
} else {
subpaths.push(gitdir);
}
}
if allow_limited_git_writes {
if top_level_git_is_dir {
subpaths.extend(protected_git_subpaths_when_writable(&top_level_git));
}
} else {
subpaths.push(top_level_git);
}
}
let top_level_agents = writable_root.join(".agents");
if top_level_agents.as_path().is_dir() {
subpaths.push(top_level_agents);
}
// Keep top-level project metadata under .codex read-only to the agent by
// default. For the workspace root itself, protect it even before the
// directory exists so first-time creation still goes through the
// protected-path approval flow.
let top_level_codex = writable_root.join(".codex");
if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
}
let mut deduped = Vec::with_capacity(subpaths.len());
let mut seen = HashSet::new();
for path in subpaths {
if seen.insert(path.to_path_buf()) {
deduped.push(path);
}
}
deduped
}
fn protected_git_subpaths_when_writable(gitdir: &AbsolutePathBuf) -> Vec<AbsolutePathBuf> {
["config", "hooks"]
.into_iter()
.map(|subpath| gitdir.join(subpath))
.collect()
}
fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
}
fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
let contents = match std::fs::read_to_string(dot_git.as_path()) {
Ok(contents) => contents,
Err(err) => {
error!(
"Failed to read {path} for gitdir pointer: {err}",
path = dot_git.as_path().display()
);
return None;
}
};
let trimmed = contents.trim();
let (_, gitdir_raw) = match trimmed.split_once(':') {
Some(parts) => parts,
None => {
error!(
"Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_raw = gitdir_raw.trim();
if gitdir_raw.is_empty() {
error!(
"Expected {path} to contain a gitdir pointer, but it was empty.",
path = dot_git.as_path().display()
);
return None;
}
let base = match dot_git.as_path().parent() {
Some(base) => base,
None => {
error!(
"Unable to resolve parent directory for {path}.",
path = dot_git.as_path().display()
);
return None;
}
};
let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base);
if !gitdir_path.as_path().exists() {
error!(
"Resolved gitdir path {path} does not exist.",
path = gitdir_path.as_path().display()
);
return None;
}
Some(gitdir_path)
}
@@ -608,6 +608,7 @@ fn sandbox_policy_with_additional_permissions(
writable_roots,
read_only_access,
network_access,
allow_limited_git_writes,
exclude_tmpdir_env_var,
exclude_slash_tmp,
} => {
@@ -620,6 +621,7 @@ fn sandbox_policy_with_additional_permissions(
extra_reads,
),
network_access: merge_network_access(*network_access, additional_permissions),
allow_limited_git_writes: *allow_limited_git_writes,
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
}
@@ -644,6 +646,7 @@ fn sandbox_policy_with_additional_permissions(
extra_reads,
),
network_access: merge_network_access(*network_access, additional_permissions),
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}
+67
View File
@@ -365,6 +365,7 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() {
readable_roots: vec![docs.clone()],
},
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
@@ -429,6 +430,7 @@ fn dynamic_network_policy_preserves_restricted_policy_when_proxy_config_without_
writable_roots: vec![],
read_only_access: Default::default(),
network_access: true,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
@@ -495,6 +497,7 @@ fn dynamic_network_policy_preserves_restricted_policy_for_managed_network_withou
writable_roots: vec![],
read_only_access: Default::default(),
network_access: true,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
@@ -786,6 +789,7 @@ fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: true,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
@@ -837,6 +841,7 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
.collect(),
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -1037,6 +1042,65 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
);
}
#[test]
fn create_seatbelt_args_with_allow_limited_git_writes_carves_out_config_and_hooks() {
let tmp = TempDir::new().expect("tempdir");
let PopulatedTmp {
vulnerable_root,
dot_git_canonical,
empty_root,
..
} = populate_tmpdir(tmp.path());
let cwd = tmp.path().join("cwd");
fs::create_dir_all(&cwd).expect("create cwd");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![vulnerable_root, empty_root]
.into_iter()
.map(|p| p.try_into().unwrap())
.collect(),
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let args = create_seatbelt_command_args(
vec!["true".to_string()],
&policy,
&cwd,
/*enforce_managed_network*/ false,
/*network*/ None,
);
let writable_definitions: Vec<String> = args
.iter()
.filter(|arg| arg.starts_with("-DWRITABLE_ROOT_"))
.cloned()
.collect();
let dot_git_definition = format!("={}", dot_git_canonical.to_string_lossy());
assert!(
!writable_definitions
.iter()
.any(|arg| arg.ends_with(&dot_git_definition)),
"allow_limited_git_writes should not carve out the entire .git directory in {writable_definitions:#?}"
);
for protected_path in [
dot_git_canonical.join("config"),
dot_git_canonical.join("hooks"),
] {
let protected_definition = format!("={}", protected_path.to_string_lossy());
assert!(
writable_definitions
.iter()
.any(|arg| arg.ends_with(&protected_definition)),
"expected protected Git path {protected_path:?} in {writable_definitions:#?}"
);
}
}
#[test]
fn create_seatbelt_args_block_first_time_dot_codex_creation_with_exact_and_descendant_carveouts() {
let tmp = TempDir::new().expect("tempdir");
@@ -1056,6 +1120,7 @@ fn create_seatbelt_args_block_first_time_dot_codex_creation_with_exact_and_desce
writable_roots: vec![repo_root.as_path().try_into().expect("absolute repo root")],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -1112,6 +1177,7 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() {
writable_roots: vec![worktree_root.try_into().expect("worktree_root is absolute")],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
@@ -1208,6 +1274,7 @@ fn create_seatbelt_args_for_cwd_as_git_repo() {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
@@ -56,6 +56,7 @@ async fn preset_matching_accepts_workspace_write_with_extra_roots() {
writable_roots: vec![extra_root],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
@@ -539,6 +540,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w
writable_roots: vec![extra_root],
read_only_access: ReadOnlyAccess::FullAccess,
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
+2
View File
@@ -104,6 +104,7 @@ async fn status_snapshot_includes_reasoning_details() {
writable_roots: Vec::new(),
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
})
@@ -190,6 +191,7 @@ async fn status_permissions_non_default_workspace_write_is_custom() {
writable_roots: Vec::new(),
read_only_access: Default::default(),
network_access: true,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
})
@@ -24,6 +24,7 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
exclude_tmpdir_env_var,
exclude_slash_tmp,
read_only_access: _,
allow_limited_git_writes: _,
} => {
let mut summary = "workspace-write".to_string();
@@ -89,6 +90,7 @@ mod tests {
writable_roots: vec![writable_root.clone()],
read_only_access: Default::default(),
network_access: true,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
});
+150 -19
View File
@@ -2,6 +2,7 @@ use crate::policy::SandboxPolicy;
use dunce::canonicalize;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
@@ -37,28 +38,36 @@ pub fn compute_allow_paths(
..
}
);
let allow_limited_git_writes = matches!(
policy,
SandboxPolicy::WorkspaceWrite {
allow_limited_git_writes: true,
..
}
);
if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) {
let add_writable_root =
|root: PathBuf,
policy_cwd: &Path,
add_allow: &mut dyn FnMut(PathBuf),
add_deny: &mut dyn FnMut(PathBuf)| {
let candidate = if root.is_absolute() {
root
} else {
policy_cwd.join(root)
};
let canonical = canonicalize(&candidate).unwrap_or(candidate);
add_allow(canonical.clone());
for protected_subdir in [".git", ".codex", ".agents"] {
let protected_entry = canonical.join(protected_subdir);
if protected_entry.exists() {
add_deny(protected_entry);
}
}
let add_writable_root = |root: PathBuf,
policy_cwd: &Path,
add_allow: &mut dyn FnMut(PathBuf),
add_deny: &mut dyn FnMut(PathBuf)| {
let candidate = if root.is_absolute() {
root
} else {
policy_cwd.join(root)
};
let canonical = canonicalize(&candidate).unwrap_or(candidate);
add_allow(canonical.clone());
add_git_deny_paths(&canonical, allow_limited_git_writes, add_deny);
for protected_subdir in [".codex", ".agents"] {
let protected_entry = canonical.join(protected_subdir);
if protected_entry.exists() {
add_deny(protected_entry);
}
}
};
add_writable_root(
command_cwd.to_path_buf(),
@@ -92,6 +101,52 @@ pub fn compute_allow_paths(
AllowDenyPaths { allow, deny }
}
fn add_git_deny_paths(
canonical_writable_root: &Path,
allow_limited_git_writes: bool,
add_deny: &mut dyn FnMut(PathBuf),
) {
let dot_git = canonical_writable_root.join(".git");
if allow_limited_git_writes {
if dot_git.is_dir() {
add_deny(dot_git.join("config"));
add_deny(dot_git.join("hooks"));
} else if dot_git.is_file()
&& let Some(gitdir) = resolve_gitdir_from_file(&dot_git)
{
add_deny(gitdir.join("config"));
add_deny(gitdir.join("hooks"));
}
} else if dot_git.exists() {
add_deny(dot_git.clone());
if dot_git.is_file()
&& let Some(gitdir) = resolve_gitdir_from_file(&dot_git)
{
add_deny(gitdir);
}
}
}
fn resolve_gitdir_from_file(dot_git: &Path) -> Option<PathBuf> {
let contents = fs::read_to_string(dot_git).ok()?;
let (_, gitdir_raw) = contents.trim().split_once(':')?;
let gitdir_raw = gitdir_raw.trim();
if gitdir_raw.is_empty() {
return None;
}
let gitdir = PathBuf::from(gitdir_raw);
let gitdir = if gitdir.is_absolute() {
gitdir
} else {
dot_git.parent()?.join(gitdir)
};
if gitdir.exists() {
Some(canonicalize(&gitdir).unwrap_or(gitdir))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -112,6 +167,7 @@ mod tests {
writable_roots: vec![AbsolutePathBuf::try_from(extra_root.as_path()).unwrap()],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
@@ -139,6 +195,7 @@ mod tests {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
};
@@ -167,6 +224,7 @@ mod tests {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
};
@@ -195,6 +253,7 @@ mod tests {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
};
@@ -211,6 +270,76 @@ mod tests {
assert_eq!(expected_deny, paths.deny);
}
#[test]
fn allows_git_metadata_except_config_and_hooks_when_requested() {
let tmp = TempDir::new().expect("tempdir");
let command_cwd = tmp.path().join("workspace");
let git_dir = command_cwd.join(".git");
let git_config = git_dir.join("config");
let git_hooks = git_dir.join("hooks");
fs::create_dir_all(&git_hooks).expect("create git hooks");
fs::write(&git_config, "[core]\n").expect("write git config");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
};
let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new());
let expected_allow: HashSet<PathBuf> = [dunce::canonicalize(&command_cwd).unwrap()]
.into_iter()
.collect();
let expected_deny: HashSet<PathBuf> = [
dunce::canonicalize(&git_config).unwrap(),
dunce::canonicalize(&git_hooks).unwrap(),
]
.into_iter()
.collect();
assert_eq!(expected_allow, paths.allow);
assert_eq!(expected_deny, paths.deny);
}
#[test]
fn allow_limited_git_writes_resolves_gitdir_pointer_file() {
let tmp = TempDir::new().expect("tempdir");
let command_cwd = tmp.path().join("workspace");
let dot_git = command_cwd.join(".git");
let gitdir = command_cwd.join("repo.git").join("worktrees").join("example");
let git_config = gitdir.join("config");
let git_hooks = gitdir.join("hooks");
fs::create_dir_all(&git_hooks).expect("create git hooks");
fs::write(&git_config, "[core]\n").expect("write git config");
fs::write(&dot_git, "gitdir: repo.git/worktrees/example").expect("write git pointer");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
};
let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new());
let expected_allow: HashSet<PathBuf> = [dunce::canonicalize(&command_cwd).unwrap()]
.into_iter()
.collect();
let expected_deny: HashSet<PathBuf> = [
dunce::canonicalize(&git_config).unwrap(),
dunce::canonicalize(&git_hooks).unwrap(),
]
.into_iter()
.collect();
assert_eq!(expected_allow, paths.allow);
assert_eq!(expected_deny, paths.deny);
}
#[test]
fn denies_codex_and_agents_inside_writable_root() {
let tmp = TempDir::new().expect("tempdir");
@@ -224,6 +353,7 @@ mod tests {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
};
@@ -253,6 +383,7 @@ mod tests {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: false,
};
@@ -492,6 +492,7 @@ mod windows_impl {
writable_roots: Vec::new(),
read_only_access: Default::default(),
network_access,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}
+1
View File
@@ -644,6 +644,7 @@ mod windows_impl {
writable_roots: Vec::new(),
read_only_access: Default::default(),
network_access,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
}
@@ -1374,6 +1374,7 @@ mod tests {
readable_roots: Vec::new(),
},
network_access: false,
allow_limited_git_writes: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};