diff --git a/codex-rs/README.md b/codex-rs/README.md index 2ad7158f98..fcbd553821 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -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 diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index db2065f750..b8e4a7d94d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3039,6 +3039,10 @@ }, { "properties": { + "allowLimitedGitWrites": { + "default": false, + "type": "boolean" + }, "excludeSlashTmp": { "default": false, "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index ccce74bc0c..3ef180ff57 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 8560fb6c84..7d5bfc95a4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 4def45c049..6853c5cb4b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -424,6 +424,10 @@ }, { "properties": { + "allowLimitedGitWrites": { + "default": false, + "type": "boolean" + }, "excludeSlashTmp": { "default": false, "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index c348e7d955..bf069f2737 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -732,6 +732,10 @@ }, "SandboxWorkspaceWrite": { "properties": { + "allow_limited_git_writes": { + "default": false, + "type": "boolean" + }, "exclude_slash_tmp": { "default": false, "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 47677da41e..4a505e28a7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1090,6 +1090,10 @@ }, { "properties": { + "allowLimitedGitWrites": { + "default": false, + "type": "boolean" + }, "excludeSlashTmp": { "default": false, "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 38b0eb0d37..d429073c46 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1090,6 +1090,10 @@ }, { "properties": { + "allowLimitedGitWrites": { + "default": false, + "type": "boolean" + }, "excludeSlashTmp": { "default": false, "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 879fa5c687..153cfc9cfa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1090,6 +1090,10 @@ }, { "properties": { + "allowLimitedGitWrites": { + "default": false, + "type": "boolean" + }, "excludeSlashTmp": { "default": false, "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 062771029e..9b5b896aab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -544,6 +544,10 @@ }, { "properties": { + "allowLimitedGitWrites": { + "default": false, + "type": "boolean" + }, "excludeSlashTmp": { "default": false, "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts index c6780648cf..3e60921302 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts @@ -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, 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, readOnlyAccess: ReadOnlyAccess, networkAccess: boolean, allowLimitedGitWrites: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxWorkspaceWrite.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxWorkspaceWrite.ts index cd19d83f1f..0f1a6354ad 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxWorkspaceWrite.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxWorkspaceWrite.ts @@ -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, network_access: boolean, exclude_tmpdir_env_var: boolean, exclude_slash_tmp: boolean, }; +export type SandboxWorkspaceWrite = { writable_roots: Array, network_access: boolean, allow_limited_git_writes: boolean, exclude_tmpdir_env_var: boolean, exclude_slash_tmp: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index d642e7fab9..8b7de93ab2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -234,6 +234,7 @@ pub struct SandboxSettings { #[serde(default)] pub writable_roots: Vec, pub network_access: Option, + pub allow_limited_git_writes: Option, pub exclude_tmpdir_env_var: Option, pub exclude_slash_tmp: Option, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a30306b345..77ed2f8416 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -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 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, } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 4a9b2c6a16..c68ef707cf 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -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, }), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index eda24358ce..3bcb0c7954 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -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, }), diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 56ff26f907..ddcfd2972f 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -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, }) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 8d6321e412..5360717c3e 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -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, }, diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index cee68d7abb..eb4d1f0bb1 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -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, + #[serde(default)] + pub allow_limited_git_writes: bool, #[serde(flatten)] pub entries: BTreeMap, } diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 7413686a77..0998143fec 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -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 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), } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 2e311790d9..f9957d687d 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -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. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0673619971..b441ebb1bb 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 943c82c0e9..1072041202 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -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)) } diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 7f61952690..e8c4646ec3 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -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, }) diff --git a/codex-rs/core/src/safety_tests.rs b/codex-rs/core/src/safety_tests.rs index c0019ce21e..5dc8b25038 100644 --- a/codex-rs/core/src/safety_tests.rs +++ b/codex-rs/core/src/safety_tests.rs @@ -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, }; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1b18c8378d..a486621513 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -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, }); diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index f3d9bdd7de..7c7c849e89 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -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, }); diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index a2b6ca145e..5bea4b33fe 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -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, }; diff --git a/codex-rs/core/tests/common/zsh_fork.rs b/codex-rs/core/tests/common/zsh_fork.rs index e61d3ea950..5d5b715d19 100644 --- a/codex-rs/core/tests/common/zsh_fork.rs +++ b/codex-rs/core/tests/common/zsh_fork.rs @@ -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, } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 588dd98d50..42594b62f1 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -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, }; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f915ec45a3..173cc1c334 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -770,6 +770,7 @@ fn scenarios() -> Vec { 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, }; diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 1bf2ee121c..4461252771 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -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, }; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index ca0b3e281a..1bfd507d5c 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -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, }; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index bcc938734d..41a4466f02 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -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, } diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 4df6602cdc..f9d2c7bc4e 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -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, } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 4060d07da2..b501c76ae9 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -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()]; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index f24c3fd578..0cbd69eace 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -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, }, diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 691b590f4e..ceba963481 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -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, }; diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 64d1342bef..697ed09ba7 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -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, }; diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index 0ed10717f2..8246dd9a9c 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -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, }; diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 3795719f08..97709659fe 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -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, }; diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 2506dae747..deba7389f3 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -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; diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index ed56567454..89fb7d7cc4 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -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, + #[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, } @@ -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, writable_roots: Vec, unreadable_roots: Vec, @@ -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) -> 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 = - 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 { - let mut subpaths: Vec = 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, 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 { - 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 = 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, }; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6e0a62fc41..9b86c36cf9 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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 { - let mut subpaths: Vec = 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 { - 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 = 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, }, diff --git a/codex-rs/protocol/src/sandbox_paths.rs b/codex-rs/protocol/src/sandbox_paths.rs new file mode 100644 index 0000000000..1ff57f871b --- /dev/null +++ b/codex-rs/protocol/src/sandbox_paths.rs @@ -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 { + let mut subpaths: Vec = 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 { + ["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 { + 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 = 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) +} diff --git a/codex-rs/sandboxing/src/policy_transforms.rs b/codex-rs/sandboxing/src/policy_transforms.rs index 9b5e444d7c..ab4c8e4e9c 100644 --- a/codex-rs/sandboxing/src/policy_transforms.rs +++ b/codex-rs/sandboxing/src/policy_transforms.rs @@ -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, } diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index 9d958c9564..caddfc8f63 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -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 = 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, }; diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 29f9534521..2f44e0d957 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -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, }, diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 3fe3e5a8af..207ef04723 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -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, }) diff --git a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs index a65d6b2ce9..c00b4dac24 100644 --- a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs @@ -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, }); diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index b40532cda8..fb30df4e84 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -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 { + 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 = [dunce::canonicalize(&command_cwd).unwrap()] + .into_iter() + .collect(); + let expected_deny: HashSet = [ + 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 = [dunce::canonicalize(&command_cwd).unwrap()] + .into_iter() + .collect(); + let expected_deny: HashSet = [ + 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, }; diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 327425bd07..9b3b9a27f0 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -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, } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index b807ded589..9e197c6efc 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -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, } diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 8c334000ec..9e1487a6cc 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -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, };