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:
Michael Bolin
2026-02-06 21:55:15 -08:00
committed by GitHub
parent 82c981cafc
commit a118494323
17 changed files with 618 additions and 40 deletions

View File

@@ -733,10 +733,22 @@ impl Session {
session_configuration.collaboration_mode.reasoning_effort();
per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary;
per_turn_config.personality = session_configuration.personality;
per_turn_config.web_search_mode = Some(resolve_web_search_mode_for_turn(
per_turn_config.web_search_mode,
let resolved_web_search_mode = resolve_web_search_mode_for_turn(
&per_turn_config.web_search_mode,
session_configuration.sandbox_policy.get(),
));
);
if let Err(err) = per_turn_config
.web_search_mode
.set(resolved_web_search_mode)
{
let fallback_value = per_turn_config.web_search_mode.value();
tracing::warn!(
error = %err,
?resolved_web_search_mode,
?fallback_value,
"resolved web_search_mode is disallowed by requirements; keeping constrained value"
);
}
per_turn_config.features = config.features.clone();
per_turn_config
}
@@ -794,7 +806,7 @@ impl Session {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &per_turn_config.features,
web_search_mode: per_turn_config.web_search_mode,
web_search_mode: Some(per_turn_config.web_search_mode.value()),
});
let cwd = session_configuration.cwd.clone();
@@ -3524,7 +3536,15 @@ async fn spawn_review_thread(
let mut per_turn_config = (*config).clone();
per_turn_config.model = Some(model.clone());
per_turn_config.features = review_features.clone();
per_turn_config.web_search_mode = Some(review_web_search_mode);
if let Err(err) = per_turn_config.web_search_mode.set(review_web_search_mode) {
let fallback_value = per_turn_config.web_search_mode.value();
tracing::warn!(
error = %err,
?review_web_search_mode,
?fallback_value,
"review web_search_mode is disallowed by requirements; keeping constrained value"
);
}
let otel_manager = parent_turn_context
.otel_manager

View File

@@ -329,7 +329,7 @@ pub struct Config {
pub include_apply_patch_tool: bool,
/// Explicit or feature-derived web search mode.
pub web_search_mode: Option<WebSearchMode>,
pub web_search_mode: Constrained<WebSearchMode>,
/// If set to `true`, used only the experimental unified exec tool.
pub use_experimental_unified_exec_tool: bool,
@@ -1331,17 +1331,39 @@ fn resolve_web_search_mode(
}
pub(crate) fn resolve_web_search_mode_for_turn(
explicit_mode: Option<WebSearchMode>,
web_search_mode: &Constrained<WebSearchMode>,
sandbox_policy: &SandboxPolicy,
) -> WebSearchMode {
if let Some(mode) = explicit_mode {
return mode;
}
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
WebSearchMode::Live
let preferred = web_search_mode.value();
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess)
&& preferred != WebSearchMode::Disabled
{
for mode in [
WebSearchMode::Live,
WebSearchMode::Cached,
WebSearchMode::Disabled,
] {
if web_search_mode.can_set(&mode).is_ok() {
return mode;
}
}
} else {
WebSearchMode::Cached
if web_search_mode.can_set(&preferred).is_ok() {
return preferred;
}
for mode in [
WebSearchMode::Cached,
WebSearchMode::Live,
WebSearchMode::Disabled,
] {
if web_search_mode.can_set(&mode).is_ok() {
return mode;
}
}
}
WebSearchMode::Disabled
}
impl Config {
@@ -1482,7 +1504,8 @@ impl Config {
);
approval_policy = requirements.approval_policy.value();
}
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features);
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features)
.unwrap_or(WebSearchMode::Cached);
// TODO(dylan): We should be able to leverage ConfigLayerStack so that
// we can reliably check this at every config level.
let did_user_set_custom_approval_policy_or_sandbox_mode =
@@ -1626,6 +1649,7 @@ impl Config {
let ConfigRequirements {
approval_policy: mut constrained_approval_policy,
sandbox_policy: mut constrained_sandbox_policy,
web_search_mode: mut constrained_web_search_mode,
mcp_servers,
exec_policy: _,
enforce_residency,
@@ -1643,6 +1667,12 @@ impl Config {
&mut constrained_sandbox_policy,
&mut startup_warnings,
)?;
apply_requirement_constrained_value(
"web_search_mode",
web_search_mode,
&mut constrained_web_search_mode,
&mut startup_warnings,
)?;
let mcp_servers = constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_servers.as_ref())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?;
@@ -1722,7 +1752,7 @@ impl Config {
forced_chatgpt_workspace_id,
forced_login_method,
include_apply_patch_tool: include_apply_patch_tool_flag,
web_search_mode,
web_search_mode: constrained_web_search_mode.value,
use_experimental_unified_exec_tool,
ghost_snapshot,
features,
@@ -2462,27 +2492,51 @@ trust_level = "trusted"
}
#[test]
fn web_search_mode_for_turn_defaults_to_cached_when_unset() {
let mode = resolve_web_search_mode_for_turn(None, &SandboxPolicy::ReadOnly);
fn web_search_mode_for_turn_uses_preference_for_read_only() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::ReadOnly);
assert_eq!(mode, WebSearchMode::Cached);
}
#[test]
fn web_search_mode_for_turn_defaults_to_live_for_danger_full_access() {
let mode = resolve_web_search_mode_for_turn(None, &SandboxPolicy::DangerFullAccess);
fn web_search_mode_for_turn_prefers_live_for_danger_full_access() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Cached);
let mode =
resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess);
assert_eq!(mode, WebSearchMode::Live);
}
#[test]
fn web_search_mode_for_turn_prefers_explicit_value() {
let mode = resolve_web_search_mode_for_turn(
Some(WebSearchMode::Cached),
&SandboxPolicy::DangerFullAccess,
);
fn web_search_mode_for_turn_respects_disabled_for_danger_full_access() {
let web_search_mode = Constrained::allow_any(WebSearchMode::Disabled);
let mode =
resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess);
assert_eq!(mode, WebSearchMode::Disabled);
}
#[test]
fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Result<()> {
let allowed = [WebSearchMode::Disabled, WebSearchMode::Cached];
let web_search_mode = Constrained::new(WebSearchMode::Cached, move |candidate| {
if allowed.contains(candidate) {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "web_search_mode",
candidate: format!("{candidate:?}"),
allowed: format!("{allowed:?}"),
requirement_source: RequirementSource::Unknown,
})
}
})?;
let mode =
resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess);
assert_eq!(mode, WebSearchMode::Cached);
Ok(())
}
#[test]
@@ -3985,7 +4039,7 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: None,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
use_experimental_unified_exec_tool: !cfg!(windows),
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
@@ -4073,7 +4127,7 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: None,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
use_experimental_unified_exec_tool: !cfg!(windows),
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
@@ -4176,7 +4230,7 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: None,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
use_experimental_unified_exec_tool: !cfg!(windows),
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
@@ -4265,7 +4319,7 @@ model_verbosity = "high"
forced_chatgpt_workspace_id: None,
forced_login_method: None,
include_apply_patch_tool: false,
web_search_mode: None,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
use_experimental_unified_exec_tool: !cfg!(windows),
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
@@ -4311,6 +4365,72 @@ model_verbosity = "high"
Ok(())
}
#[test]
fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> anyhow::Result<()>
{
let fixture = create_test_fixture()?;
let requirements_toml = crate::config_loader::ConfigRequirementsToml {
allowed_approval_policies: None,
allowed_sandbox_modes: None,
allowed_web_search_modes: Some(vec![
crate::config_loader::WebSearchModeRequirement::Cached,
]),
mcp_servers: None,
rules: None,
enforce_residency: None,
};
let requirement_source = crate::config_loader::RequirementSource::Unknown;
let requirement_source_for_error = requirement_source.clone();
let allowed = vec![WebSearchMode::Disabled, WebSearchMode::Cached];
let constrained = Constrained::new(WebSearchMode::Cached, move |candidate| {
if matches!(candidate, WebSearchMode::Cached | WebSearchMode::Disabled) {
Ok(())
} else {
Err(ConstraintError::InvalidValue {
field_name: "web_search_mode",
candidate: format!("{candidate:?}"),
allowed: format!("{allowed:?}"),
requirement_source: requirement_source_for_error.clone(),
})
}
})?;
let requirements = crate::config_loader::ConfigRequirements {
web_search_mode: crate::config_loader::ConstrainedWithSource::new(
constrained,
Some(requirement_source),
),
..Default::default()
};
let config_layer_stack = crate::config_loader::ConfigLayerStack::new(
Vec::new(),
requirements,
requirements_toml,
)
.expect("config layer stack");
let config = Config::load_config_with_layer_stack(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd()),
..Default::default()
},
fixture.codex_home(),
config_layer_stack,
)?;
assert!(
!config
.startup_warnings
.iter()
.any(|warning| warning.contains("Configured value for `web_search_mode`")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[test]
fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> {
let project_dir = Path::new("/some/path");
@@ -4812,6 +4932,7 @@ mcp_oauth_callback_port = 5678
allowed_sandbox_modes: Some(vec![
crate::config_loader::SandboxModeRequirement::ReadOnly,
]),
allowed_web_search_modes: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -4829,6 +4950,38 @@ mcp_oauth_callback_port = 5678
Ok(())
}
#[tokio::test]
async fn requirements_web_search_mode_overrides_danger_full_access_default()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"sandbox_mode = "danger-full-access"
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Some(crate::config_loader::ConfigRequirementsToml {
allowed_web_search_modes: Some(vec![
crate::config_loader::WebSearchModeRequirement::Cached,
]),
..Default::default()
})
}))
.build()
.await?;
assert_eq!(config.web_search_mode.value(), WebSearchMode::Cached);
assert_eq!(
resolve_web_search_mode_for_turn(&config.web_search_mode, config.sandbox_policy.get()),
WebSearchMode::Cached,
);
Ok(())
}
#[tokio::test]
async fn requirements_disallowing_default_approval_falls_back_to_required_default()
-> std::io::Result<()> {

View File

@@ -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#"

View File

@@ -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;

View File

@@ -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,

View File

@@ -17,6 +17,7 @@ use tokio_util::sync::CancellationToken;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::codex_delegate::run_codex_thread_one_shot;
use crate::config::Constrained;
use crate::review_format::format_review_findings_block;
use crate::review_format::render_review_output_text;
use crate::state::TaskKind;
@@ -86,7 +87,20 @@ async fn start_review_conversation(
let mut sub_agent_config = config.as_ref().clone();
// Carry over review-only feature restrictions so the delegate cannot
// re-enable blocked tools (web search, view image).
sub_agent_config.web_search_mode = Some(WebSearchMode::Disabled);
if let Err(err) = sub_agent_config
.web_search_mode
.set(WebSearchMode::Disabled)
{
tracing::warn!(
"failed to force review web_search_mode=disabled; falling back to a normalizer: {err}"
);
sub_agent_config.web_search_mode =
Constrained::normalized(WebSearchMode::Disabled, |_| WebSearchMode::Disabled)
.unwrap_or_else(|err| {
tracing::warn!("failed to build normalizer for review web_search_mode: {err}");
Constrained::allow_any(WebSearchMode::Disabled)
});
}
// Set explicit review rubric for the sub-agent
sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string());