mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: add support for allowed_web_search_modes in requirements.toml (#10964)
This PR makes it possible to disable live web search via an enterprise config even if the user is running in `--yolo` mode (though cached web search will still be available). To do this, create `/etc/codex/requirements.toml` as follows: ```toml # "live" is not allowed; "disabled" is allowed even though not listed explicitly. allowed_web_search_modes = ["cached"] ``` Or set `requirements_toml_base64` MDM as explained on https://developers.openai.com/codex/security/#locations. ### Why - Enforce admin/MDM/`requirements.toml` constraints on web-search behavior, independent of user config and per-turn sandbox defaults. - Ensure per-turn config resolution and review-mode overrides never crash when constraints are present. ### What - Add `allowed_web_search_modes` to requirements parsing and surface it in app-server v2 `ConfigRequirements` (`allowedWebSearchModes`), with fixtures updated. - Define a requirements allowlist type (`WebSearchModeRequirement`) and normalize semantics: - `disabled` is always implicitly allowed (even if not listed). - An empty list is treated as `["disabled"]`. - Make `Config.web_search_mode` a `Constrained<WebSearchMode>` and apply requirements via `ConstrainedWithSource<WebSearchMode>`. - Update per-turn resolution (`resolve_web_search_mode_for_turn`) to: - Prefer `Live → Cached → Disabled` when `SandboxPolicy::DangerFullAccess` is active (subject to requirements), unless the user preference is explicitly `Disabled`. - Otherwise, honor the user’s preferred mode, falling back to an allowed mode when necessary. - Update TUI `/debug-config` and app-server mapping to display normalized `allowed_web_search_modes` (including implicit `disabled`). - Fix web-search integration tests to assert cached behavior under `SandboxPolicy::ReadOnly` (since `DangerFullAccess` legitimately prefers `live` when allowed).
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -76,6 +77,7 @@ impl<T> std::ops::DerefMut for ConstrainedWithSource<T> {
|
||||
pub struct ConfigRequirements {
|
||||
pub approval_policy: ConstrainedWithSource<AskForApproval>,
|
||||
pub sandbox_policy: ConstrainedWithSource<SandboxPolicy>,
|
||||
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
|
||||
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
pub(crate) exec_policy: Option<Sourced<RequirementsExecPolicy>>,
|
||||
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
|
||||
@@ -92,6 +94,10 @@ impl Default for ConfigRequirements {
|
||||
Constrained::allow_any(SandboxPolicy::ReadOnly),
|
||||
None,
|
||||
),
|
||||
web_search_mode: ConstrainedWithSource::new(
|
||||
Constrained::allow_any(WebSearchMode::Cached),
|
||||
None,
|
||||
),
|
||||
mcp_servers: None,
|
||||
exec_policy: None,
|
||||
enforce_residency: ConstrainedWithSource::new(Constrained::allow_any(None), None),
|
||||
@@ -117,11 +123,50 @@ pub struct McpServerRequirement {
|
||||
pub identity: McpServerIdentity,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WebSearchModeRequirement {
|
||||
Disabled,
|
||||
Cached,
|
||||
Live,
|
||||
}
|
||||
|
||||
impl From<WebSearchMode> for WebSearchModeRequirement {
|
||||
fn from(mode: WebSearchMode) -> Self {
|
||||
match mode {
|
||||
WebSearchMode::Disabled => WebSearchModeRequirement::Disabled,
|
||||
WebSearchMode::Cached => WebSearchModeRequirement::Cached,
|
||||
WebSearchMode::Live => WebSearchModeRequirement::Live,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WebSearchModeRequirement> for WebSearchMode {
|
||||
fn from(mode: WebSearchModeRequirement) -> Self {
|
||||
match mode {
|
||||
WebSearchModeRequirement::Disabled => WebSearchMode::Disabled,
|
||||
WebSearchModeRequirement::Cached => WebSearchMode::Cached,
|
||||
WebSearchModeRequirement::Live => WebSearchMode::Live,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for WebSearchModeRequirement {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
WebSearchModeRequirement::Disabled => write!(f, "disabled"),
|
||||
WebSearchModeRequirement::Cached => write!(f, "cached"),
|
||||
WebSearchModeRequirement::Live => write!(f, "live"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Base config deserialized from /etc/codex/requirements.toml or MDM.
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
pub struct ConfigRequirementsToml {
|
||||
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
|
||||
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
|
||||
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
|
||||
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
|
||||
pub rules: Option<RequirementsExecPolicyToml>,
|
||||
pub enforce_residency: Option<ResidencyRequirement>,
|
||||
@@ -153,6 +198,7 @@ impl<T> std::ops::Deref for Sourced<T> {
|
||||
pub struct ConfigRequirementsWithSources {
|
||||
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
|
||||
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
|
||||
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
|
||||
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
|
||||
pub enforce_residency: Option<Sourced<ResidencyRequirement>>,
|
||||
@@ -186,6 +232,7 @@ impl ConfigRequirementsWithSources {
|
||||
{
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
allowed_web_search_modes,
|
||||
mcp_servers,
|
||||
rules,
|
||||
enforce_residency,
|
||||
@@ -197,6 +244,7 @@ impl ConfigRequirementsWithSources {
|
||||
let ConfigRequirementsWithSources {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
allowed_web_search_modes,
|
||||
mcp_servers,
|
||||
rules,
|
||||
enforce_residency,
|
||||
@@ -204,6 +252,7 @@ impl ConfigRequirementsWithSources {
|
||||
ConfigRequirementsToml {
|
||||
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
|
||||
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
|
||||
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
|
||||
mcp_servers: mcp_servers.map(|sourced| sourced.value),
|
||||
rules: rules.map(|sourced| sourced.value),
|
||||
enforce_residency: enforce_residency.map(|sourced| sourced.value),
|
||||
@@ -248,6 +297,7 @@ impl ConfigRequirementsToml {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.allowed_approval_policies.is_none()
|
||||
&& self.allowed_sandbox_modes.is_none()
|
||||
&& self.allowed_web_search_modes.is_none()
|
||||
&& self.mcp_servers.is_none()
|
||||
&& self.rules.is_none()
|
||||
&& self.enforce_residency.is_none()
|
||||
@@ -261,6 +311,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
let ConfigRequirementsWithSources {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
allowed_web_search_modes,
|
||||
mcp_servers,
|
||||
rules,
|
||||
enforce_residency,
|
||||
@@ -356,6 +407,46 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let web_search_mode = match allowed_web_search_modes {
|
||||
Some(Sourced {
|
||||
value: modes,
|
||||
source: requirement_source,
|
||||
}) => {
|
||||
let mut accepted = modes.into_iter().collect::<std::collections::BTreeSet<_>>();
|
||||
accepted.insert(WebSearchModeRequirement::Disabled);
|
||||
let allowed_for_error = format!(
|
||||
"{:?}",
|
||||
accepted
|
||||
.iter()
|
||||
.copied()
|
||||
.map(WebSearchMode::from)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let initial_value = if accepted.contains(&WebSearchModeRequirement::Cached) {
|
||||
WebSearchMode::Cached
|
||||
} else if accepted.contains(&WebSearchModeRequirement::Live) {
|
||||
WebSearchMode::Live
|
||||
} else {
|
||||
WebSearchMode::Disabled
|
||||
};
|
||||
let requirement_source_for_error = requirement_source.clone();
|
||||
let constrained = Constrained::new(initial_value, move |candidate| {
|
||||
if accepted.contains(&(*candidate).into()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "web_search_mode",
|
||||
candidate: format!("{candidate:?}"),
|
||||
allowed: allowed_for_error.clone(),
|
||||
requirement_source: requirement_source_for_error.clone(),
|
||||
})
|
||||
}
|
||||
})?;
|
||||
ConstrainedWithSource::new(constrained, Some(requirement_source))
|
||||
}
|
||||
None => ConstrainedWithSource::new(Constrained::allow_any(WebSearchMode::Cached), None),
|
||||
};
|
||||
|
||||
let enforce_residency = match enforce_residency {
|
||||
Some(Sourced {
|
||||
@@ -383,6 +474,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
Ok(ConfigRequirements {
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
web_search_mode,
|
||||
mcp_servers,
|
||||
exec_policy,
|
||||
enforce_residency,
|
||||
@@ -410,6 +502,7 @@ mod tests {
|
||||
let ConfigRequirementsToml {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
allowed_web_search_modes,
|
||||
mcp_servers,
|
||||
rules,
|
||||
enforce_residency,
|
||||
@@ -419,6 +512,8 @@ mod tests {
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
allowed_sandbox_modes: allowed_sandbox_modes
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
allowed_web_search_modes: allowed_web_search_modes
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
enforce_residency: enforce_residency
|
||||
@@ -436,6 +531,10 @@ mod tests {
|
||||
SandboxModeRequirement::WorkspaceWrite,
|
||||
SandboxModeRequirement::DangerFullAccess,
|
||||
];
|
||||
let allowed_web_search_modes = vec![
|
||||
WebSearchModeRequirement::Cached,
|
||||
WebSearchModeRequirement::Live,
|
||||
];
|
||||
let enforce_residency = ResidencyRequirement::Us;
|
||||
let enforce_source = source.clone();
|
||||
|
||||
@@ -444,6 +543,7 @@ mod tests {
|
||||
let other = ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(allowed_approval_policies.clone()),
|
||||
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
|
||||
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: Some(enforce_residency),
|
||||
@@ -459,6 +559,10 @@ mod tests {
|
||||
source.clone()
|
||||
)),
|
||||
allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source)),
|
||||
allowed_web_search_modes: Some(Sourced::new(
|
||||
allowed_web_search_modes,
|
||||
enforce_source.clone(),
|
||||
)),
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)),
|
||||
@@ -489,6 +593,7 @@ mod tests {
|
||||
source_location,
|
||||
)),
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -527,6 +632,7 @@ mod tests {
|
||||
existing_source,
|
||||
)),
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -615,6 +721,7 @@ mod tests {
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
allowed_web_search_modes = ["cached"]
|
||||
enforce_residency = "us"
|
||||
"#,
|
||||
)?;
|
||||
@@ -632,6 +739,10 @@ mod tests {
|
||||
requirements.sandbox_policy.source,
|
||||
Some(source_location.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
requirements.web_search_mode.source,
|
||||
Some(source_location.clone())
|
||||
);
|
||||
assert_eq!(requirements.enforce_residency.source, Some(source_location));
|
||||
|
||||
Ok(())
|
||||
@@ -746,6 +857,100 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_allowed_web_search_modes() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
allowed_web_search_modes = ["cached"]
|
||||
"#;
|
||||
let config: ConfigRequirementsToml = from_str(toml_str)?;
|
||||
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
|
||||
|
||||
assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Cached);
|
||||
assert!(
|
||||
requirements
|
||||
.web_search_mode
|
||||
.can_set(&WebSearchMode::Disabled)
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
requirements.web_search_mode.can_set(&WebSearchMode::Live),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "web_search_mode",
|
||||
candidate: "Live".into(),
|
||||
allowed: "[Disabled, Cached]".into(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
requirements
|
||||
.web_search_mode
|
||||
.can_set(&WebSearchMode::Cached)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_web_search_modes_allows_disabled() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
allowed_web_search_modes = ["disabled"]
|
||||
"#;
|
||||
let config: ConfigRequirementsToml = from_str(toml_str)?;
|
||||
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
|
||||
|
||||
assert_eq!(
|
||||
requirements.web_search_mode.value(),
|
||||
WebSearchMode::Disabled
|
||||
);
|
||||
assert!(
|
||||
requirements
|
||||
.web_search_mode
|
||||
.can_set(&WebSearchMode::Disabled)
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
requirements.web_search_mode.can_set(&WebSearchMode::Cached),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "web_search_mode",
|
||||
candidate: "Cached".into(),
|
||||
allowed: "[Disabled]".into(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_web_search_modes_empty_restricts_to_disabled() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
allowed_web_search_modes = []
|
||||
"#;
|
||||
let config: ConfigRequirementsToml = from_str(toml_str)?;
|
||||
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
|
||||
|
||||
assert_eq!(
|
||||
requirements.web_search_mode.value(),
|
||||
WebSearchMode::Disabled
|
||||
);
|
||||
assert!(
|
||||
requirements
|
||||
.web_search_mode
|
||||
.can_set(&WebSearchMode::Disabled)
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
requirements.web_search_mode.can_set(&WebSearchMode::Cached),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "web_search_mode",
|
||||
candidate: "Cached".into(),
|
||||
allowed: "[Disabled]".into(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_mcp_server_requirements() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
|
||||
@@ -41,6 +41,7 @@ pub use config_requirements::RequirementSource;
|
||||
pub use config_requirements::ResidencyRequirement;
|
||||
pub use config_requirements::SandboxModeRequirement;
|
||||
pub use config_requirements::Sourced;
|
||||
pub use config_requirements::WebSearchModeRequirement;
|
||||
pub use diagnostics::ConfigError;
|
||||
pub use diagnostics::ConfigLoadError;
|
||||
pub use diagnostics::TextPosition;
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::config_loader::config_requirements::RequirementSource;
|
||||
use crate::config_loader::fingerprint::version_for_toml;
|
||||
use crate::config_loader::load_requirements_toml;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
@@ -475,6 +476,7 @@ async fn load_requirements_toml_produces_expected_constraints() -> anyhow::Resul
|
||||
&requirements_file,
|
||||
r#"
|
||||
allowed_approval_policies = ["never", "on-request"]
|
||||
allowed_web_search_modes = ["cached"]
|
||||
enforce_residency = "us"
|
||||
"#,
|
||||
)
|
||||
@@ -490,6 +492,13 @@ enforce_residency = "us"
|
||||
.cloned(),
|
||||
Some(vec![AskForApproval::Never, AskForApproval::OnRequest])
|
||||
);
|
||||
assert_eq!(
|
||||
config_requirements_toml
|
||||
.allowed_web_search_modes
|
||||
.as_deref()
|
||||
.cloned(),
|
||||
Some(vec![crate::config_loader::WebSearchModeRequirement::Cached])
|
||||
);
|
||||
let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?;
|
||||
assert_eq!(
|
||||
config_requirements.approval_policy.value(),
|
||||
@@ -504,6 +513,25 @@ enforce_residency = "us"
|
||||
.can_set(&AskForApproval::OnFailure)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
config_requirements.web_search_mode.value(),
|
||||
WebSearchMode::Cached
|
||||
);
|
||||
config_requirements
|
||||
.web_search_mode
|
||||
.can_set(&WebSearchMode::Cached)?;
|
||||
config_requirements
|
||||
.web_search_mode
|
||||
.can_set(&WebSearchMode::Cached)?;
|
||||
config_requirements
|
||||
.web_search_mode
|
||||
.can_set(&WebSearchMode::Disabled)?;
|
||||
assert!(
|
||||
config_requirements
|
||||
.web_search_mode
|
||||
.can_set(&WebSearchMode::Live)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
config_requirements.enforce_residency.value(),
|
||||
Some(crate::config_loader::ResidencyRequirement::Us)
|
||||
@@ -536,6 +564,7 @@ allowed_approval_policies = ["on-request"]
|
||||
Some(ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -582,6 +611,7 @@ allowed_approval_policies = ["on-request"]
|
||||
ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -617,6 +647,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
|
||||
let requirements = ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
|
||||
Reference in New Issue
Block a user