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

@@ -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<()> {