feat(config): support managed deny-read requirements (#17740)

## Summary
- adds managed requirements support for deny-read filesystem entries
- constrains config layers so managed deny-read requirements cannot be
widened by user-controlled config
- surfaces managed deny-read requirements through debug/config plumbing

This PR lets managed requirements inject deny-read filesystem
constraints into the effective filesystem sandbox policy.
User-controlled config can still choose the surrounding permission
profile, but it cannot remove or weaken the managed deny-read entries.

## Managed deny-read shape
A managed requirements file can declare exact paths and glob patterns
under `[permissions.filesystem]`:

```toml
# /etc/codex/requirements.toml
[permissions.filesystem]
deny_read = [
  "/Users/alice/.gitconfig",
  "/Users/alice/.ssh",
  "./managed-private/**/*.env",
]
```

Those entries are compiled into the effective filesystem policy as
`access = none` rules, equivalent in shape to filesystem permission
entries like:

```toml
[permissions.workspace.filesystem]
"/Users/alice/.gitconfig" = "none"
"/Users/alice/.ssh" = "none"
"/absolute/path/to/managed-private/**/*.env" = "none"
```

The important difference is that the managed entries come from
requirements, so lower-precedence user config cannot remove them or make
those paths readable again.

Relative managed `deny_read` entries are resolved relative to the
directory containing the managed requirements file. Glob entries keep
their glob suffix after the non-glob prefix is normalized.

## Runtime behavior
- Managed `deny_read` entries are appended to the effective
`FileSystemSandboxPolicy` after the selected permission profile is
resolved.
- Exact paths become `FileSystemPath::Path { access: None }`; glob
patterns become `FileSystemPath::GlobPattern { access: None }`.
- When managed deny-read entries are present, `sandbox_mode` is
constrained to `read-only` or `workspace-write`; `danger-full-access`
and `external-sandbox` cannot silently bypass the managed read-deny
policy.
- On Windows, the managed deny-read policy is enforced for direct file
tools, but shell subprocess reads are not sandboxed yet, so startup
emits a warning for that platform.
- `/debug-config` shows the effective managed requirement as
`permissions.filesystem.deny_read` with its source.

## Stack
1. #15979 - glob deny-read policy/config/direct-tool support
2. #18096 - macOS and Linux sandbox enforcement
3. This PR - managed deny-read requirements

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
viyatb-oai
2026-04-17 08:40:09 -07:00
committed by GitHub
parent 2dd6734dd3
commit dae0608c06
10 changed files with 509 additions and 1 deletions

View File

@@ -7,6 +7,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use serde::de::Error as _;
use serde::de::value::Error as ValueDeserializerError;
use serde::de::value::StrDeserializer;
use std::collections::BTreeMap;
use std::fmt;
@@ -88,6 +90,8 @@ pub struct ConfigRequirements {
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
/// Managed network constraints derived from requirements.
pub network: Option<Sourced<NetworkConstraints>>,
/// Managed filesystem constraints derived from requirements.
pub filesystem: Option<Sourced<FilesystemConstraints>>,
}
impl Default for ConfigRequirements {
@@ -117,6 +121,7 @@ impl Default for ConfigRequirements {
/*source*/ None,
),
network: None,
filesystem: None,
}
}
}
@@ -402,6 +407,126 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct FilesystemRequirementsToml {
pub deny_read: Option<Vec<FilesystemDenyReadPattern>>,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct PermissionsRequirementsToml {
pub filesystem: Option<FilesystemRequirementsToml>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FilesystemConstraints {
pub deny_read: Vec<FilesystemDenyReadPattern>,
}
impl From<PermissionsRequirementsToml> 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(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct FilesystemDenyReadPattern(String);
impl FilesystemDenyReadPattern {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn contains_glob(&self) -> bool {
self.0.chars().any(is_glob_metacharacter)
}
pub fn from_input(input: &str) -> Result<Self, String> {
if !input.chars().any(is_glob_metacharacter) {
let path = deserialize_absolute_path(input)?;
return Ok(Self(path.to_string_lossy().into_owned()));
}
let (directory_prefix, suffix) = split_glob_pattern(input);
let normalized_prefix = if directory_prefix.is_empty() {
deserialize_absolute_path(".")?
} else {
deserialize_absolute_path(directory_prefix)?
};
let normalized_prefix = normalized_prefix.to_string_lossy();
let normalized = if suffix.is_empty() {
normalized_prefix.into_owned()
} else if normalized_prefix == "/" {
format!("/{suffix}")
} else {
format!("{normalized_prefix}/{suffix}")
};
Ok(Self(normalized))
}
}
impl From<AbsolutePathBuf> for FilesystemDenyReadPattern {
fn from(value: AbsolutePathBuf) -> Self {
Self(value.to_string_lossy().into_owned())
}
}
impl<'de> Deserialize<'de> for FilesystemDenyReadPattern {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let input = String::deserialize(deserializer)?;
Self::from_input(&input).map_err(D::Error::custom)
}
}
fn deserialize_absolute_path(input: &str) -> Result<AbsolutePathBuf, String> {
AbsolutePathBuf::deserialize(StrDeserializer::<ValueDeserializerError>::new(input))
.map_err(|err| err.to_string())
}
fn split_glob_pattern(input: &str) -> (&str, &str) {
let Some(first_glob) = input.find(is_glob_metacharacter) else {
return ("", input);
};
let separator_index = input[..first_glob]
.char_indices()
.rev()
.find(|(_, ch)| is_path_separator(*ch))
.map(|(index, _)| index);
match separator_index {
Some(0) => ("/", &input[1..]),
Some(index)
if cfg!(windows)
&& index == 2
&& input.as_bytes().get(1) == Some(&b':')
&& input.as_bytes().get(2).is_some() =>
{
(&input[..=index], &input[index + 1..])
}
Some(index) => (&input[..index], &input[index + 1..]),
None => ("", input),
}
}
fn is_path_separator(ch: char) -> bool {
if cfg!(windows) {
ch == '/' || ch == '\\'
} else {
ch == '/'
}
}
fn is_glob_metacharacter(ch: char) -> bool {
matches!(ch, '*' | '?' | '[')
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "lowercase")]
pub enum WebSearchModeRequirement {
@@ -504,6 +629,7 @@ pub struct ConfigRequirementsToml {
pub enforce_residency: Option<ResidencyRequirement>,
#[serde(rename = "experimental_network")]
pub network: Option<NetworkRequirementsToml>,
pub permissions: Option<PermissionsRequirementsToml>,
pub guardian_policy_config: Option<String>,
}
@@ -541,6 +667,7 @@ pub struct ConfigRequirementsWithSources {
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
pub enforce_residency: Option<Sourced<ResidencyRequirement>>,
pub network: Option<Sourced<NetworkRequirementsToml>>,
pub permissions: Option<Sourced<PermissionsRequirementsToml>>,
pub guardian_policy_config: Option<Sourced<String>>,
}
@@ -573,6 +700,7 @@ impl ConfigRequirementsWithSources {
rules: _,
enforce_residency: _,
network: _,
permissions: _,
guardian_policy_config: _,
} = &other;
@@ -598,6 +726,7 @@ impl ConfigRequirementsWithSources {
rules,
enforce_residency,
network,
permissions,
guardian_policy_config,
}
);
@@ -623,6 +752,7 @@ impl ConfigRequirementsWithSources {
rules,
enforce_residency,
network,
permissions,
guardian_policy_config,
} = self;
ConfigRequirementsToml {
@@ -636,6 +766,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_policy_config: guardian_policy_config.map(|sourced| sourced.value),
}
}
@@ -692,6 +823,7 @@ impl ConfigRequirementsToml {
&& self.rules.is_none()
&& self.enforce_residency.is_none()
&& self.network.is_none()
&& self.permissions.is_none()
&& self
.guardian_policy_config
.as_deref()
@@ -714,6 +846,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
rules,
enforce_residency,
network,
permissions,
guardian_policy_config: _guardian_policy_config,
} = toml;
@@ -919,6 +1052,10 @@ impl TryFrom<ConfigRequirementsWithSources> 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,
approvals_reviewer,
@@ -929,6 +1066,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
exec_policy,
enforce_residency,
network,
filesystem,
})
}
}
@@ -942,6 +1080,7 @@ mod tests {
use codex_execpolicy::RuleMatch;
use codex_protocol::protocol::NetworkAccess;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use pretty_assertions::assert_eq;
use toml::from_str;
@@ -967,6 +1106,7 @@ mod tests {
rules,
enforce_residency,
network,
permissions,
guardian_policy_config,
} = toml;
ConfigRequirementsWithSources {
@@ -986,6 +1126,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_policy_config: guardian_policy_config
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
}
@@ -1027,6 +1168,7 @@ mod tests {
rules: None,
enforce_residency: Some(enforce_residency),
network: None,
permissions: None,
guardian_policy_config: Some(guardian_policy_config.clone()),
};
@@ -1057,6 +1199,7 @@ mod tests {
rules: None,
enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)),
network: None,
permissions: None,
guardian_policy_config: Some(Sourced::new(guardian_policy_config, source)),
}
);
@@ -1093,6 +1236,7 @@ mod tests {
rules: None,
enforce_residency: None,
network: None,
permissions: None,
guardian_policy_config: None,
}
);
@@ -1137,6 +1281,7 @@ mod tests {
rules: None,
enforce_residency: None,
network: None,
permissions: None,
guardian_policy_config: None,
}
);
@@ -1219,6 +1364,71 @@ allowed_approvals_reviewers = ["user"]
Ok(())
}
#[test]
fn deserialize_filesystem_deny_read_requirements() -> Result<()> {
let deny_read_0 = if cfg!(windows) {
r"C:\Users\alice\.gitconfig"
} else {
"/home/alice/.gitconfig"
};
let deny_read_1 = if cfg!(windows) {
r"C:\Users\alice\.ssh"
} else {
"/home/alice/.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)?.into(),
AbsolutePathBuf::from_absolute_path(deny_read_1)?.into(),
],
},
RequirementSource::Unknown,
))
);
Ok(())
}
#[test]
fn deserialize_filesystem_deny_read_glob_requirements() -> Result<()> {
let temp_dir = std::env::temp_dir();
let _guard = AbsolutePathBufGuard::new(&temp_dir);
let config: ConfigRequirementsToml = from_str(
r#"
[permissions.filesystem]
deny_read = ["./private/**/*.txt"]
"#,
)?;
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
assert_eq!(
requirements.filesystem,
Some(Sourced::new(
FilesystemConstraints {
deny_read: vec![
FilesystemDenyReadPattern::from_input("./private/**/*.txt")
.expect("normalize glob pattern"),
],
},
RequirementSource::Unknown,
))
);
Ok(())
}
#[test]
fn deserialize_apps_requirements() -> Result<()> {
let toml_str = r#"