From 572673ba19c8e8c15c8cc888bdb2aae4642d0c78 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 9 Mar 2026 21:58:43 -0700 Subject: [PATCH 1/3] feat(config): add managed deny-read requirements --- codex-rs/app-server/src/config_api.rs | 2 + codex-rs/cloud-requirements/src/lib.rs | 15 ++++ codex-rs/config/src/config_requirements.rs | 85 +++++++++++++++++++ codex-rs/config/src/constraint.rs | 51 +++++++++++ codex-rs/config/src/lib.rs | 1 + codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/config/mod.rs | 65 +++++++++++++- codex-rs/core/src/config_loader/mod.rs | 11 +++ codex-rs/core/src/config_loader/tests.rs | 48 +++++++++++ codex-rs/core/src/environment_context.rs | 37 ++++++++ .../core/src/environment_context_tests.rs | 69 +++++++++++++++ codex-rs/tui/src/debug_config.rs | 41 +++++++++ 12 files changed, 425 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index d138d2f5eb9..e586b871e21 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -591,6 +591,7 @@ mod tests { }), allow_local_binding: Some(true), }), + permissions: None, }; let mapped = map_requirements_toml_to_api(requirements); @@ -716,6 +717,7 @@ mod tests { rules: None, enforce_residency: None, network: None, + permissions: None, }; let mapped = map_requirements_toml_to_api(requirements); diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 12e62d880f9..b85280744b0 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1149,6 +1149,7 @@ mod tests { rules: None, enforce_residency: None, network: None, + permissions: None, })) ); } @@ -1177,6 +1178,7 @@ mod tests { rules: None, enforce_residency: None, network: None, + permissions: None, })) ); } @@ -1222,6 +1224,7 @@ mod tests { rules: None, enforce_residency: None, network: None, + permissions: None, }) ); } @@ -1303,6 +1306,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, })) ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); @@ -1374,6 +1378,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, })) ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); @@ -1443,6 +1448,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, })) ); @@ -1606,6 +1612,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, })) ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); @@ -1636,6 +1643,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, })) ); @@ -1686,6 +1694,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, })) ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); @@ -1735,6 +1744,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, })) ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); @@ -1788,6 +1798,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, })) ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); @@ -1842,6 +1853,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, })) ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); @@ -1896,6 +1908,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, }) ); let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload bytes"); @@ -1983,6 +1996,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, })) ); @@ -2009,6 +2023,7 @@ enabled = false rules: None, enforce_residency: None, network: None, + permissions: None, }) ); } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index d63fa1e8b24..c4908d1d046 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -86,6 +86,8 @@ pub struct ConfigRequirements { pub enforce_residency: ConstrainedWithSource>, /// Managed network constraints derived from requirements. pub network: Option>, + /// Managed filesystem constraints derived from requirements. + pub filesystem: Option>, } impl Default for ConfigRequirements { @@ -111,6 +113,7 @@ impl Default for ConfigRequirements { /*source*/ None, ), network: None, + filesystem: None, } } } @@ -396,6 +399,31 @@ impl From for NetworkConstraints { } } +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct FilesystemRequirementsToml { + pub deny_read: Option>, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct PermissionsRequirementsToml { + pub filesystem: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FilesystemConstraints { + pub deny_read: Vec, +} + +impl From for FilesystemConstraints { + fn from(value: PermissionsRequirementsToml) -> Self { + let deny_read = value + .filesystem + .and_then(|filesystem| filesystem.deny_read) + .unwrap_or_default(); + Self { deny_read } + } +} + #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[serde(rename_all = "lowercase")] pub enum WebSearchModeRequirement { @@ -497,6 +525,7 @@ pub struct ConfigRequirementsToml { pub enforce_residency: Option, #[serde(rename = "experimental_network")] pub network: Option, + pub permissions: Option, pub guardian_developer_instructions: Option, } @@ -533,6 +562,7 @@ pub struct ConfigRequirementsWithSources { pub rules: Option>, pub enforce_residency: Option>, pub network: Option>, + pub permissions: Option>, pub guardian_developer_instructions: Option>, } @@ -564,6 +594,7 @@ impl ConfigRequirementsWithSources { rules: _, enforce_residency: _, network: _, + permissions: _, guardian_developer_instructions: _, } = &other; @@ -588,6 +619,7 @@ impl ConfigRequirementsWithSources { rules, enforce_residency, network, + permissions, guardian_developer_instructions, } ); @@ -612,6 +644,7 @@ impl ConfigRequirementsWithSources { rules, enforce_residency, network, + permissions, guardian_developer_instructions, } = self; ConfigRequirementsToml { @@ -624,6 +657,7 @@ impl ConfigRequirementsWithSources { rules: rules.map(|sourced| sourced.value), enforce_residency: enforce_residency.map(|sourced| sourced.value), network: network.map(|sourced| sourced.value), + permissions: permissions.map(|sourced| sourced.value), guardian_developer_instructions: guardian_developer_instructions .map(|sourced| sourced.value), } @@ -680,6 +714,7 @@ impl ConfigRequirementsToml { && self.rules.is_none() && self.enforce_residency.is_none() && self.network.is_none() + && self.permissions.is_none() && self .guardian_developer_instructions .as_deref() @@ -701,6 +736,7 @@ impl TryFrom for ConfigRequirements { rules, enforce_residency, network, + permissions, guardian_developer_instructions: _guardian_developer_instructions, } = toml; @@ -876,6 +912,10 @@ impl TryFrom for ConfigRequirements { let Sourced { value, source } = sourced_network; Sourced::new(NetworkConstraints::from(value), source) }); + let filesystem = permissions.map(|sourced_permissions| { + let Sourced { value, source } = sourced_permissions; + Sourced::new(FilesystemConstraints::from(value), source) + }); Ok(ConfigRequirements { approval_policy, sandbox_policy, @@ -885,6 +925,7 @@ impl TryFrom for ConfigRequirements { exec_policy, enforce_residency, network, + filesystem, }) } } @@ -922,6 +963,7 @@ mod tests { rules, enforce_residency, network, + permissions, guardian_developer_instructions, } = toml; ConfigRequirementsWithSources { @@ -939,6 +981,7 @@ mod tests { enforce_residency: enforce_residency .map(|value| Sourced::new(value, RequirementSource::Unknown)), network: network.map(|value| Sourced::new(value, RequirementSource::Unknown)), + permissions: permissions.map(|value| Sourced::new(value, RequirementSource::Unknown)), guardian_developer_instructions: guardian_developer_instructions .map(|value| Sourced::new(value, RequirementSource::Unknown)), } @@ -978,6 +1021,7 @@ mod tests { rules: None, enforce_residency: Some(enforce_residency), network: None, + permissions: None, guardian_developer_instructions: Some(guardian_developer_instructions.clone()), }; @@ -1004,6 +1048,7 @@ mod tests { rules: None, enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), network: None, + permissions: None, guardian_developer_instructions: Some(Sourced::new( guardian_developer_instructions, source, @@ -1042,6 +1087,7 @@ mod tests { rules: None, enforce_residency: None, network: None, + permissions: None, guardian_developer_instructions: None, } ); @@ -1085,6 +1131,7 @@ mod tests { rules: None, enforce_residency: None, network: None, + permissions: None, guardian_developer_instructions: None, } ); @@ -1157,6 +1204,44 @@ guardian_developer_instructions = """ Ok(()) } + #[test] + fn deserialize_filesystem_deny_read_requirements() -> Result<()> { + let deny_read_0 = if cfg!(windows) { + r"C:\Users\viyatb\.gitconfig" + } else { + "/home/viyatb/.gitconfig" + }; + let deny_read_1 = if cfg!(windows) { + r"C:\Users\viyatb\.ssh" + } else { + "/home/viyatb/.ssh" + }; + let toml_str = format!( + r#" + [permissions.filesystem] + deny_read = [{deny_read_0:?}, {deny_read_1:?}] + "# + ); + + let config: ConfigRequirementsToml = from_str(&toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.filesystem, + Some(Sourced::new( + FilesystemConstraints { + deny_read: vec![ + AbsolutePathBuf::from_absolute_path(deny_read_0)?, + AbsolutePathBuf::from_absolute_path(deny_read_1)?, + ], + }, + RequirementSource::Unknown, + )) + ); + + Ok(()) + } + #[test] fn deserialize_apps_requirements() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/constraint.rs b/codex-rs/config/src/constraint.rs index d6c8e09f5fb..3d68e2eec2d 100644 --- a/codex-rs/config/src/constraint.rs +++ b/codex-rs/config/src/constraint.rs @@ -138,6 +138,27 @@ impl Constrained { (self.validator)(candidate) } + /// Composes an additional validator onto the current constraint. + /// + /// The existing value must satisfy the combined validator before it is installed. + pub fn add_validator( + &mut self, + validator: impl Fn(&T) -> ConstraintResult<()> + Send + Sync + 'static, + ) -> ConstraintResult<()> + where + T: 'static, + { + let existing_validator = self.validator.clone(); + let combined_validator: Arc> = Arc::new(move |candidate| { + existing_validator(candidate)?; + validator(candidate) + }); + + combined_validator(&self.value)?; + self.validator = combined_validator; + Ok(()) + } + pub fn set(&mut self, value: T) -> ConstraintResult<()> { let value = if let Some(normalizer) = &self.normalizer { normalizer(value) @@ -224,6 +245,36 @@ mod tests { Ok(()) } + #[test] + fn constrained_add_validator_composes_with_existing_validator() -> anyhow::Result<()> { + let mut constrained = Constrained::new(5, |value: &i32| { + if *value >= 0 { + Ok(()) + } else { + Err(ConstraintError::empty_field("value")) + } + })?; + constrained.add_validator(|value| { + if *value <= 10 { + Ok(()) + } else { + Err(ConstraintError::empty_field("value")) + } + })?; + + assert_eq!(constrained.can_set(&7), Ok(())); + assert_eq!( + constrained.can_set(&11), + Err(ConstraintError::empty_field("value")) + ); + assert_eq!( + constrained.can_set(&-1), + Err(ConstraintError::empty_field("value")) + ); + + Ok(()) + } + #[test] fn constrained_new_rejects_invalid_initial_value() { let result = Constrained::new(0, |value| { diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 9492d56b4d2..fa812f52da3 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -22,6 +22,7 @@ pub use config_requirements::ConfigRequirementsToml; pub use config_requirements::ConfigRequirementsWithSources; pub use config_requirements::ConstrainedWithSource; pub use config_requirements::FeatureRequirementsToml; +pub use config_requirements::FilesystemConstraints; pub use config_requirements::McpServerIdentity; pub use config_requirements::McpServerRequirement; pub use config_requirements::NetworkConstraints; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 60f0e06f7d9..b71ba5e47cf 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4906,6 +4906,7 @@ fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> any rules: None, enforce_residency: None, network: None, + permissions: None, guardian_developer_instructions: None, }; let requirement_source = crate::config_loader::RequirementSource::Unknown; @@ -5506,6 +5507,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s rules: None, enforce_residency: None, network: None, + permissions: None, guardian_developer_instructions: None, }; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1a0722119b5..42b620b7c80 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1787,6 +1787,27 @@ fn resolve_permission_config_syntax( }) } +fn apply_managed_filesystem_constraints( + file_system_sandbox_policy: &mut FileSystemSandboxPolicy, + filesystem_constraints: &crate::config_loader::FilesystemConstraints, +) { + for deny_read in &filesystem_constraints.deny_read { + let deny_entry = codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: deny_read.clone(), + }, + access: codex_protocol::permissions::FileSystemAccessMode::None, + }; + if !file_system_sandbox_policy + .entries + .iter() + .any(|existing| existing == &deny_entry) + { + file_system_sandbox_policy.entries.push(deny_entry); + } + } +} + /// Optional overrides for user configuration (e.g., from CLI flags). #[derive(Default, Debug, Clone)] pub struct ConfigOverrides { @@ -1982,6 +2003,7 @@ impl Config { exec_policy: _, enforce_residency, network: network_requirements, + filesystem: filesystem_requirements, } = config_layer_stack.requirements().clone(); let user_instructions = Self::load_instructions(Some(&codex_home)); @@ -2473,6 +2495,34 @@ impl Config { &mut constrained_approval_policy, &mut startup_warnings, )?; + if let Some(Sourced { + value: filesystem_requirements, + source: filesystem_requirements_source, + }) = filesystem_requirements.as_ref() + && !filesystem_requirements.deny_read.is_empty() + { + let requirement_source = filesystem_requirements_source.clone(); + constrained_sandbox_policy + .value + .add_validator(move |policy| match policy { + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => Ok(()), + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: policy.to_string(), + allowed: "[read-only, workspace-write]".to_string(), + requirement_source: requirement_source.clone(), + }) + } + }) + .map_err(std::io::Error::from)?; + + if cfg!(target_os = "windows") { + startup_warnings.push(format!( + "managed filesystem deny_read from {filesystem_requirements_source} is only enforced for direct file tools on Windows; shell subprocess reads are not sandboxed" + )); + } + } apply_requirement_constrained_value( "sandbox_mode", sandbox_policy, @@ -2520,15 +2570,26 @@ impl Config { main_execve_wrapper_exe.as_ref(), ); let effective_sandbox_policy = constrained_sandbox_policy.value.get().clone(); - let effective_file_system_sandbox_policy = + let mut effective_file_system_sandbox_policy = if effective_sandbox_policy == original_sandbox_policy { file_system_sandbox_policy } else { - FileSystemSandboxPolicy::from_legacy_sandbox_policy( + FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_read_denies( &effective_sandbox_policy, resolved_cwd.as_path(), + &file_system_sandbox_policy, ) }; + if let Some(Sourced { + value: filesystem_requirements, + .. + }) = filesystem_requirements.as_ref() + { + apply_managed_filesystem_constraints( + &mut effective_file_system_sandbox_policy, + filesystem_requirements, + ); + } let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy .with_additional_readable_roots(resolved_cwd.as_path(), &helper_readable_roots); let effective_network_sandbox_policy = diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index df33665956e..4a3a836696c 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -38,6 +38,7 @@ pub use codex_config::ConfigRequirements; pub use codex_config::ConfigRequirementsToml; pub use codex_config::ConstrainedWithSource; pub use codex_config::FeatureRequirementsToml; +pub use codex_config::FilesystemConstraints; pub use codex_config::LoaderOverrides; pub use codex_config::McpServerIdentity; pub use codex_config::McpServerRequirement; @@ -365,6 +366,16 @@ async fn load_requirements_toml( AbsolutePathBuf::from_absolute_path(requirements_toml_file.as_ref())?; match tokio::fs::read_to_string(&requirements_toml_file).await { Ok(contents) => { + let requirements_parent = requirements_toml_file.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Requirements file {} has no parent directory", + requirements_toml_file.as_ref().display() + ), + ) + })?; + let _guard = AbsolutePathBufGuard::new(requirements_parent.as_path()); let requirements_config: ConfigRequirementsToml = toml::from_str(&contents).map_err(|e| { io::Error::new( diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 6a2bc0b6b99..61de832f24e 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -657,6 +657,7 @@ allowed_approval_policies = ["on-request"] rules: None, enforce_residency: None, network: None, + permissions: None, guardian_developer_instructions: None, })) }), @@ -708,6 +709,7 @@ allowed_approval_policies = ["on-request"] rules: None, enforce_residency: None, network: None, + permissions: None, guardian_developer_instructions: None, }, ); @@ -731,6 +733,51 @@ allowed_approval_policies = ["on-request"] Ok(()) } +#[tokio::test(flavor = "current_thread")] +async fn load_requirements_toml_resolves_filesystem_deny_read_against_requirements_parent() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let requirements_dir = tmp.path().join("managed"); + tokio::fs::create_dir_all(&requirements_dir).await?; + let requirements_file = requirements_dir.join("requirements.toml"); + tokio::fs::write( + &requirements_file, + r#" +[permissions.filesystem] +deny_read = ["./sensitive", "../shared/secret.txt"] +"#, + ) + .await?; + + let mut config_requirements_toml = ConfigRequirementsWithSources::default(); + load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?; + + let permissions = config_requirements_toml + .permissions + .expect("permissions requirements should load"); + let filesystem = permissions + .value + .filesystem + .expect("filesystem requirements should load"); + let deny_read = filesystem.deny_read.expect("deny_read paths should load"); + + assert_eq!( + deny_read, + vec![ + AbsolutePathBuf::try_from(requirements_dir.join("sensitive"))?, + AbsolutePathBuf::try_from(tmp.path().join("shared").join("secret.txt"))?, + ] + ); + assert_eq!( + permissions.source, + RequirementSource::SystemRequirementsToml { + file: AbsolutePathBuf::try_from(requirements_file)?, + } + ); + + Ok(()) +} + #[tokio::test] async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -748,6 +795,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> rules: None, enforce_residency: None, network: None, + permissions: None, guardian_developer_instructions: None, }; let expected = requirements.clone(); diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index df4e49cf4e1..78c27b671c0 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -2,6 +2,7 @@ use crate::codex::TurnContext; use crate::contextual_user_message::ENVIRONMENT_CONTEXT_FRAGMENT; use crate::shell::Shell; use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use serde::Deserialize; @@ -16,6 +17,7 @@ pub(crate) struct EnvironmentContext { pub current_date: Option, pub timezone: Option, pub network: Option, + pub deny_read_paths: Vec, pub subagents: Option, } @@ -32,6 +34,7 @@ impl EnvironmentContext { current_date: Option, timezone: Option, network: Option, + deny_read_paths: Vec, subagents: Option, ) -> Self { Self { @@ -40,6 +43,7 @@ impl EnvironmentContext { current_date, timezone, network, + deny_read_paths, subagents, } } @@ -53,6 +57,7 @@ impl EnvironmentContext { current_date, timezone, network, + deny_read_paths, subagents, shell: _, } = other; @@ -60,6 +65,7 @@ impl EnvironmentContext { && self.current_date == *current_date && self.timezone == *timezone && self.network == *network + && self.deny_read_paths == *deny_read_paths && self.subagents == *subagents } @@ -70,6 +76,8 @@ impl EnvironmentContext { ) -> Self { let before_network = Self::network_from_turn_context_item(before); let after_network = Self::network_from_turn_context(after); + let before_deny_read_paths = Vec::new(); + let after_deny_read_paths = deny_read_paths(&after.file_system_sandbox_policy, &after.cwd); let cwd = if before.cwd.as_path() != after.cwd.as_path() { Some(after.cwd.to_path_buf()) } else { @@ -82,12 +90,18 @@ impl EnvironmentContext { } else { before_network }; + let deny_read_paths = if before_deny_read_paths != after_deny_read_paths { + after_deny_read_paths + } else { + before_deny_read_paths + }; EnvironmentContext::new( cwd, shell.clone(), current_date, timezone, network, + deny_read_paths, /*subagents*/ None, ) } @@ -99,6 +113,7 @@ impl EnvironmentContext { turn_context.current_date.clone(), turn_context.timezone.clone(), Self::network_from_turn_context(turn_context), + deny_read_paths(&turn_context.file_system_sandbox_policy, &turn_context.cwd), /*subagents*/ None, ) } @@ -110,6 +125,7 @@ impl EnvironmentContext { turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), + Vec::new(), /*subagents*/ None, ) } @@ -166,6 +182,9 @@ impl EnvironmentContext { /// /// ... /// ... + /// + /// ... + /// /// /// ``` pub fn serialize_to_xml(self) -> String { @@ -198,6 +217,13 @@ impl EnvironmentContext { // lines.push(" ".to_string()); } } + if !self.deny_read_paths.is_empty() { + lines.push(" ".to_string()); + for path in &self.deny_read_paths { + lines.push(format!(" {path}")); + } + lines.push(" ".to_string()); + } if let Some(subagents) = self.subagents { lines.push(" ".to_string()); lines.extend(subagents.lines().map(|line| format!(" {line}"))); @@ -213,6 +239,17 @@ impl From for ResponseItem { } } +fn deny_read_paths( + file_system_sandbox_policy: &FileSystemSandboxPolicy, + cwd: &std::path::Path, +) -> Vec { + file_system_sandbox_policy + .get_unreadable_roots_with_cwd(cwd) + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect() +} + #[cfg(test)] #[path = "environment_context_tests.rs"] mod tests; diff --git a/codex-rs/core/src/environment_context_tests.rs b/codex-rs/core/src/environment_context_tests.rs index 5718c09de43..001adcc8922 100644 --- a/codex-rs/core/src/environment_context_tests.rs +++ b/codex-rs/core/src/environment_context_tests.rs @@ -21,6 +21,7 @@ fn serialize_workspace_write_environment_context() { Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), None, + Vec::new(), None, ); @@ -49,6 +50,7 @@ fn serialize_environment_context_with_network() { Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), Some(network), + Vec::new(), None, ); @@ -78,6 +80,7 @@ fn serialize_read_only_environment_context() { Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), None, + Vec::new(), None, ); @@ -98,6 +101,7 @@ fn serialize_external_sandbox_environment_context() { Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), None, + Vec::new(), None, ); @@ -118,6 +122,7 @@ fn serialize_external_sandbox_with_restricted_network_environment_context() { Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), None, + Vec::new(), None, ); @@ -138,6 +143,7 @@ fn serialize_full_access_environment_context() { Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), None, + Vec::new(), None, ); @@ -158,6 +164,7 @@ fn equals_except_shell_compares_cwd() { None, None, None, + Vec::new(), None, ); let context2 = EnvironmentContext::new( @@ -166,6 +173,7 @@ fn equals_except_shell_compares_cwd() { None, None, None, + Vec::new(), None, ); assert!(context1.equals_except_shell(&context2)); @@ -179,6 +187,7 @@ fn equals_except_shell_ignores_sandbox_policy() { None, None, None, + Vec::new(), None, ); let context2 = EnvironmentContext::new( @@ -187,6 +196,7 @@ fn equals_except_shell_ignores_sandbox_policy() { None, None, None, + Vec::new(), None, ); @@ -201,6 +211,7 @@ fn equals_except_shell_compares_cwd_differences() { None, None, None, + Vec::new(), None, ); let context2 = EnvironmentContext::new( @@ -209,6 +220,31 @@ fn equals_except_shell_compares_cwd_differences() { None, None, None, + Vec::new(), + None, + ); + + assert!(!context1.equals_except_shell(&context2)); +} + +#[test] +fn equals_except_shell_compares_deny_read_paths() { + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + vec!["/repo/.gitconfig".to_string()], + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + vec!["/repo/.ssh".to_string()], None, ); @@ -227,6 +263,7 @@ fn equals_except_shell_ignores_shell() { None, None, None, + Vec::new(), None, ); let context2 = EnvironmentContext::new( @@ -239,12 +276,43 @@ fn equals_except_shell_ignores_shell() { None, None, None, + Vec::new(), None, ); assert!(context1.equals_except_shell(&context2)); } +#[test] +fn serialize_environment_context_with_deny_read_paths() { + let denied = vec!["/repo/.gitconfig".to_string(), "/repo/.ssh".to_string()]; + let context = EnvironmentContext::new( + Some(test_path_buf("/repo")), + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + denied, + None, + ); + + let expected = format!( + r#" + {} + bash + 2026-02-26 + America/Los_Angeles + + /repo/.gitconfig + /repo/.ssh + +"#, + test_path_buf("/repo").display() + ); + + assert_eq!(context.serialize_to_xml(), expected); +} + #[test] fn serialize_environment_context_with_subagents() { let context = EnvironmentContext::new( @@ -253,6 +321,7 @@ fn serialize_environment_context_with_subagents() { Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), None, + Vec::new(), Some("- agent-1: atlas\n- agent-2".to_string()), ); diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 42cfa195863..408925b932d 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -167,6 +167,22 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { )); } + if let Some(filesystem) = requirements.filesystem.as_ref() { + let deny_read = join_or_empty( + filesystem + .value + .deny_read + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect::>(), + ); + requirement_lines.push(requirement_line( + "permissions.filesystem.deny_read", + deny_read, + Some(&filesystem.source), + )); + } + if requirement_lines.is_empty() { lines.push(" ".dim().into()); } else { @@ -428,6 +444,7 @@ mod tests { use codex_core::config_loader::ConfigRequirements; use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::ConstrainedWithSource; + use codex_core::config_loader::FilesystemConstraints; use codex_core::config_loader::McpServerIdentity; use codex_core::config_loader::McpServerRequirement; use codex_core::config_loader::NetworkConstraints; @@ -515,6 +532,11 @@ mod tests { } else { absolute_path("/etc/codex/requirements.toml") }; + let denied_path = if cfg!(windows) { + absolute_path("C:\\Users\\alice\\.gitconfig") + } else { + absolute_path("/home/alice/.gitconfig") + }; let requirements = ConfigRequirements { approval_policy: ConstrainedWithSource::new( @@ -559,6 +581,14 @@ mod tests { }, RequirementSource::CloudRequirements, )), + filesystem: Some(Sourced::new( + FilesystemConstraints { + deny_read: vec![denied_path.clone()], + }, + RequirementSource::SystemRequirementsToml { + file: requirements_file.clone(), + }, + )), ..ConfigRequirements::default() }; @@ -580,6 +610,7 @@ mod tests { rules: None, enforce_residency: Some(ResidencyRequirement::Us), network: None, + permissions: None, }; let user_file = if cfg!(windows) { @@ -620,6 +651,15 @@ mod tests { assert!(rendered.contains( "experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)" )); + assert!( + rendered.contains( + format!( + "permissions.filesystem.deny_read: {}", + denied_path.as_path().display() + ) + .as_str() + ) + ); assert!(!rendered.contains(" - rules:")); } #[test] @@ -701,6 +741,7 @@ approval_policy = "never" rules: None, enforce_residency: None, network: None, + permissions: None, }; let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) From edc46969ab787ac2699bf5f70498f7cb2de548c8 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 27 Mar 2026 07:20:28 -0700 Subject: [PATCH 2/3] fix(core): persist deny-read paths in turn context Co-authored-by: Codex --- codex-rs/core/src/codex.rs | 6 ++++++ codex-rs/core/src/codex/rollout_reconstruction_tests.rs | 8 ++++++++ codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/environment_context.rs | 4 ++-- codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 5 +++++ 7 files changed, 24 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73ee4889f1a..6ce888d9939 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1008,6 +1008,12 @@ impl TurnContext { approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), network: self.turn_context_network_item(), + deny_read_paths: self + .file_system_sandbox_policy + .get_unreadable_roots_with_cwd(&self.cwd) + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), model: self.model_info.slug.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index e468bfceb55..e73b7f5c4db 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -68,6 +68,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, + deny_read_paths: Vec::new(), model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -107,6 +108,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, + deny_read_paths: Vec::new(), model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -857,6 +859,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, + deny_read_paths: Vec::new(), model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -929,6 +932,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, + deny_read_paths: Vec::new(), model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -958,6 +962,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, + deny_read_paths: Vec::new(), model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -1064,6 +1069,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, + deny_read_paths: Vec::new(), model: current_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -1166,6 +1172,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, + deny_read_paths: Vec::new(), model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), @@ -1310,6 +1317,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, + deny_read_paths: Vec::new(), model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ff0e08e2113..2d17428a31f 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1255,6 +1255,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, + deny_read_paths: Vec::new(), model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 3c508e05ff7..8c6d5d95971 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -133,6 +133,7 @@ fn reference_context_item() -> TurnContextItem { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), network: None, + deny_read_paths: Vec::new(), model: "gpt-test".to_string(), personality: None, collaboration_mode: None, diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 78c27b671c0..82860742421 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -76,7 +76,7 @@ impl EnvironmentContext { ) -> Self { let before_network = Self::network_from_turn_context_item(before); let after_network = Self::network_from_turn_context(after); - let before_deny_read_paths = Vec::new(); + let before_deny_read_paths = before.deny_read_paths.clone(); let after_deny_read_paths = deny_read_paths(&after.file_system_sandbox_policy, &after.cwd); let cwd = if before.cwd.as_path() != after.cwd.as_path() { Some(after.cwd.to_path_buf()) @@ -125,7 +125,7 @@ impl EnvironmentContext { turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), - Vec::new(), + turn_context_item.deny_read_paths.clone(), /*subagents*/ None, ) } diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index e6090054363..ac9d3181e8c 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -34,6 +34,7 @@ fn resume_history( approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), network: None, + deny_read_paths: Vec::new(), model: previous_model.to_string(), personality: None, collaboration_mode: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 9a4f2b90b2b..358c57a447b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2617,6 +2617,8 @@ pub struct TurnContextItem { pub sandbox_policy: SandboxPolicy, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny_read_paths: Vec, pub model: String, #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, @@ -4564,6 +4566,7 @@ mod tests { assert_eq!(item.trace_id, None); assert_eq!(item.network, None); + assert_eq!(item.deny_read_paths, Vec::::new()); Ok(()) } @@ -4581,6 +4584,7 @@ mod tests { allowed_domains: vec!["api.example.com".to_string()], denied_domains: vec!["blocked.example.com".to_string()], }), + deny_read_paths: vec!["/tmp/private".to_string()], model: "gpt-5".to_string(), personality: None, collaboration_mode: None, @@ -4601,6 +4605,7 @@ mod tests { "denied_domains": ["blocked.example.com"], }) ); + assert_eq!(value["deny_read_paths"], json!(["/tmp/private"])); Ok(()) } From c97d7f6abc9599c88d38012644f338a872e847ab Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 27 Mar 2026 09:11:27 -0700 Subject: [PATCH 3/3] fix(state): add deny_read_paths to rollout fixtures Co-authored-by: Codex noreply@openai.com --- codex-rs/state/src/extract.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 8d35d393a89..51046b71bae 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -275,6 +275,7 @@ mod tests { approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, network: None, + deny_read_paths: Vec::new(), model: "gpt-5".to_string(), personality: None, collaboration_mode: None, @@ -313,6 +314,7 @@ mod tests { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), network: None, + deny_read_paths: Vec::new(), model: "gpt-5".to_string(), personality: None, collaboration_mode: None, @@ -345,6 +347,7 @@ mod tests { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), network: None, + deny_read_paths: Vec::new(), model: "gpt-5".to_string(), personality: None, collaboration_mode: None,