mirror of
https://github.com/openai/codex.git
synced 2026-05-01 01:47:18 +00: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:
@@ -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<()> {
|
||||
|
||||
Reference in New Issue
Block a user