Ignore reusable command approvals for cyber models (#37516)

## What changed

- Filter saved `allow` prefix rules from the execution policy for cyber-specialized models and models listed in `auto_review.ignore_rules`, while preserving prompt, forbidden, network, and host-executable policy entries.
- Apply the filtered policy consistently to permission instructions, shell commands, unified exec, and the zsh-fork backend.
- Limit cyber-model command approvals to one-time decisions without proposing reusable policy amendments. General models continue to honor saved prefixes, including after switching models within a thread.

## Testing

- Add policy-level coverage for filtering allow-prefix rules while retaining restrictive and network rules.
- Add end-to-end coverage across shell and unified exec paths, zsh-fork execution, user and automatic review, and model switching.

GitOrigin-RevId: f3bd3872424291b12354ec415f33986ec369a368
This commit is contained in:
andrewgu-oai
2026-08-08 00:59:39 +00:00
committed by copyberry
parent c2bcb9a26b
commit e734a1a5c1
16 changed files with 717 additions and 14 deletions
@@ -728,6 +728,7 @@ mod tests {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
auto_review: Some(AutoReviewRequirementsToml {
required_on_models: Some(vec!["gpt-protected".to_string()]),
ignore_rules: None,
}),
models: Some(ModelsRequirementsToml {
new_thread: Some(NewThreadModelDefaultsToml {
+13 -4
View File
@@ -964,6 +964,7 @@ pub struct ConfigRequirementsToml {
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct AutoReviewRequirementsToml {
pub required_on_models: Option<Vec<String>>,
pub ignore_rules: Option<Vec<String>>,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
@@ -1174,6 +1175,12 @@ impl ConfigRequirementsWithSources {
}
}
}
if existing_auto_review.value.ignore_rules.is_none()
&& let Some(ignore_rules) = incoming_auto_review.ignore_rules
{
existing_auto_review.value.ignore_rules = Some(ignore_rules);
source_contributed = true;
}
if source_contributed && existing_auto_review.source != source {
existing_auto_review.source = RequirementSource::composite([
existing_auto_review.source.clone(),
@@ -1392,10 +1399,11 @@ impl ConfigRequirementsToml {
&& self.network.is_none()
&& self.permissions.is_none()
&& self.auto_review.as_ref().is_none_or(|auto_review| {
auto_review
.required_on_models
.as_ref()
.is_none_or(Vec::is_empty)
auto_review.ignore_rules.as_ref().is_none_or(Vec::is_empty)
&& auto_review
.required_on_models
.as_ref()
.is_none_or(Vec::is_empty)
})
&& self
.models
@@ -2350,6 +2358,7 @@ mod tests {
};
let auto_review = AutoReviewRequirementsToml {
required_on_models: Some(vec!["managed-model".to_string()]),
ignore_rules: None,
};
let models = ModelsRequirementsToml {
new_thread: Some(NewThreadModelDefaultsToml {
@@ -47,6 +47,7 @@ impl AutoReviewModelsMergeState {
*target = Some(Sourced::new(
AutoReviewRequirementsToml {
required_on_models: Some(self.slugs),
ignore_rules: None,
},
source,
));
@@ -242,7 +242,7 @@ fn populate_merged_regular_fields_with_sources(
enforce_residency,
network,
permissions,
auto_review: _,
auto_review,
models,
guardian_policy_config,
} = requirements;
@@ -273,6 +273,7 @@ fn populate_merged_regular_fields_with_sources(
set_sourced!(allow_managed_hooks_only, &["allow_managed_hooks_only"]);
set_sourced!(allow_appshots, &["allow_appshots"]);
set_sourced!(allow_remote_control, &["allow_remote_control"]);
set_sourced!(auto_review, &["auto_review"]);
set_sourced!(computer_use, &["computer_use"]);
set_sourced!(browser_use, &["browser_use"]);
set_sourced!(windows, &["windows"]);
+12 -5
View File
@@ -40,6 +40,10 @@ use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
use codex_utils_absolute_path::AbsolutePathBuf;
use shlex::try_join as shlex_try_join;
mod model_policy;
pub(crate) use model_policy::AllowPrefixRules;
const PROMPT_CONFLICT_REASON: &str =
"approval required by policy, but AskForApproval is set to Never";
const REJECT_SANDBOX_APPROVAL_REASON: &str =
@@ -281,6 +285,7 @@ pub(crate) struct ExecApprovalRequest<'a> {
pub(crate) windows_sandbox_level: WindowsSandboxLevel,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) prefix_rule: Option<Vec<String>>,
pub(crate) allow_prefix_rules: AllowPrefixRules,
}
impl ExecPolicyManager {
@@ -315,17 +320,19 @@ impl ExecPolicyManager {
windows_sandbox_level,
sandbox_permissions,
prefix_rule,
allow_prefix_rules,
} = req;
let exec_policy = self.current();
let exec_policy = self.current_for_prefix_rules(allow_prefix_rules);
let ExecPolicyCommands {
commands,
used_complex_parsing,
command_origin,
} = commands_for_exec_policy(command);
// Keep heredoc prefix parsing for rule evaluation so existing
// allow/prompt/forbidden rules still apply, but avoid auto-derived
// amendments when only the heredoc fallback parser matched.
let auto_amendment_allowed = !used_complex_parsing;
// Keep heredoc prefix parsing for the rules that apply to this model,
// but avoid reusable approvals for cyber models or when only the
// heredoc fallback parser matched.
let auto_amendment_allowed =
!used_complex_parsing && allow_prefix_rules == AllowPrefixRules::Honor;
let exec_policy_fallback = |cmd: &[String]| {
render_decision_for_unmatched_command(
cmd,
@@ -0,0 +1,47 @@
use super::ExecPolicyManager;
use codex_execpolicy::Decision;
use codex_execpolicy::Policy;
use codex_execpolicy::PrefixRule;
use std::sync::Arc;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum AllowPrefixRules {
Honor,
IgnoreForCyberModel,
}
impl ExecPolicyManager {
pub(crate) fn current_for_prefix_rules(
&self,
allow_prefix_rules: AllowPrefixRules,
) -> Arc<Policy> {
let policy = self.current();
if allow_prefix_rules == AllowPrefixRules::Honor {
return policy;
}
let rules = policy
.rules()
.iter_all()
.flat_map(|(program, rules)| {
rules.iter().filter_map(move |rule| {
let is_allow_prefix = rule
.as_any()
.downcast_ref::<PrefixRule>()
.is_some_and(|prefix| prefix.decision == Decision::Allow);
(!is_allow_prefix).then(|| (program.clone(), Arc::clone(rule)))
})
})
.collect();
Arc::new(Policy::from_parts(
rules,
policy.network_rules().to_vec(),
policy.host_executables().clone(),
))
}
}
#[cfg(test)]
#[path = "model_policy_tests.rs"]
mod tests;
@@ -0,0 +1,177 @@
use super::AllowPrefixRules;
use super::ExecPolicyManager;
use crate::exec_policy::ExecApprovalRequest;
use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ExecApprovalRequirement;
use codex_execpolicy::Decision;
use codex_execpolicy::MatchOptions;
use codex_execpolicy::Policy;
use codex_execpolicy::PolicyParser;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use pretty_assertions::assert_eq;
use std::sync::Arc;
fn policy_with_broad_allow_prefix() -> (Arc<Policy>, String) {
let program_name = if cfg!(windows) { "cargo.exe" } else { "cargo" };
let program_path = std::env::temp_dir()
.join(program_name)
.to_string_lossy()
.into_owned();
let escaped_program_path = program_path.replace('\\', "\\\\");
let source = format!(
r#"
host_executable(name="cargo", paths=["{escaped_program_path}"])
prefix_rule(pattern=["cargo"], decision="allow")
prefix_rule(pattern=["cargo", "publish"], decision="prompt")
prefix_rule(pattern=["rm"], decision="forbidden")
network_rule(host="example.com", protocol="https", decision="allow")
"#
);
let mut parser = PolicyParser::new();
parser.parse("test.rules", &source).expect("parse policy");
(Arc::new(parser.build()), program_path)
}
#[test]
fn cyber_policy_filters_allow_prefixes_but_preserves_restrictive_and_network_rules() {
let (policy, program_path) = policy_with_broad_allow_prefix();
let manager = ExecPolicyManager::new(Arc::clone(&policy));
let standard_policy = manager.current_for_prefix_rules(AllowPrefixRules::Honor);
assert!(Arc::ptr_eq(&standard_policy, &policy));
let cyber_policy = manager.current_for_prefix_rules(AllowPrefixRules::IgnoreForCyberModel);
assert!(cyber_policy.get_allowed_prefixes().is_empty());
assert_eq!(cyber_policy.network_rules(), policy.network_rules());
assert_eq!(cyber_policy.host_executables(), policy.host_executables());
let cargo_install = vec!["cargo".to_string(), "install".to_string()];
assert_eq!(
cyber_policy
.check(&cargo_install, &|_| Decision::Prompt)
.decision,
Decision::Prompt,
);
let cargo_publish = vec!["cargo".to_string(), "publish".to_string()];
assert_eq!(
cyber_policy
.check(&cargo_publish, &|_| Decision::Allow)
.decision,
Decision::Prompt,
);
let resolved_cargo_publish = vec![program_path, "publish".to_string()];
assert_eq!(
cyber_policy
.check_with_options(
&resolved_cargo_publish,
&|_| Decision::Allow,
&MatchOptions {
resolve_host_executables: true,
},
)
.decision,
Decision::Prompt,
);
let forbidden_command = vec!["rm".to_string(), "target".to_string()];
assert_eq!(
cyber_policy
.check(&forbidden_command, &|_| Decision::Allow)
.decision,
Decision::Forbidden,
);
}
#[tokio::test]
async fn cyber_policy_requires_approval_for_broad_wrapped_and_resolved_prefixes() {
let (policy, program_path) = policy_with_broad_allow_prefix();
let manager = ExecPolicyManager::new(policy);
let commands = [
vec![
"cargo".to_string(),
"install".to_string(),
"example".to_string(),
],
vec![
"bash".to_string(),
"-lc".to_string(),
"cargo install example".to_string(),
],
vec![program_path, "install".to_string(), "example".to_string()],
];
for command in commands {
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
allow_prefix_rules: AllowPrefixRules::IgnoreForCyberModel,
})
.await;
assert_eq!(
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
},
"command {command:?} must not inherit a saved prefix approval",
);
}
}
#[tokio::test]
async fn cyber_policy_keeps_heuristically_safe_commands_inside_the_sandbox() {
let mut policy = Policy::empty();
policy
.add_prefix_rule(&["echo".to_string()], Decision::Allow)
.expect("add broad allow prefix");
let manager = ExecPolicyManager::new(Arc::new(policy));
let command = vec!["echo".to_string(), "hello".to_string()];
let cyber_requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::IgnoreForCyberModel,
})
.await;
assert_eq!(
cyber_requirement,
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
);
let standard_requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await;
assert_eq!(
standard_requirement,
ExecApprovalRequirement::Skip {
bypass_sandbox: true,
proposed_execpolicy_amendment: None,
},
);
}
+12
View File
@@ -1392,6 +1392,7 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision()
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await;
@@ -1429,6 +1430,7 @@ async fn forced_rm_preserves_rule_rejection_when_granular_rules_are_disabled() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await;
@@ -1453,6 +1455,7 @@ async fn exec_approval_requirement_falls_back_to_heuristics() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await;
@@ -1478,6 +1481,7 @@ async fn empty_bash_lc_script_falls_back_to_original_command() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await;
@@ -1507,6 +1511,7 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await;
@@ -1536,6 +1541,7 @@ async fn request_rule_uses_prefix_rule() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await;
@@ -1568,6 +1574,7 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands(
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await;
@@ -1607,6 +1614,7 @@ async fn heuristics_apply_when_other_commands_match_policy() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await,
ExecApprovalRequirement::NeedsApproval {
@@ -2085,6 +2093,7 @@ async fn forced_rm_requires_approval_or_specific_rejection_on_all_platforms() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: permissions,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await,
r#"On all platforms, a forbidden command should require approval
@@ -2106,6 +2115,7 @@ async fn forced_rm_requires_approval_or_specific_rejection_on_all_platforms() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: permissions,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await,
r#"On all platforms, a forbidden command should require approval
@@ -2164,6 +2174,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: permissions,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await,
"{pwsh_approval_reason}"
@@ -2256,6 +2267,7 @@ async fn exec_approval_requirement_for_command(
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
sandbox_permissions,
prefix_rule,
allow_prefix_rules: AllowPrefixRules::Honor,
})
.await
}
+1
View File
@@ -11599,6 +11599,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
windows_sandbox_level: turn_context.windows_sandbox_level,
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
allow_prefix_rules: turn_context.allow_prefix_rules(),
})
.await;
assert!(matches!(
+19
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::exec_policy::AllowPrefixRules;
use crate::shell_snapshot::ShellSnapshotFile;
use codex_core_plugins::PluginCommandAttribution;
use codex_core_plugins::TrustedPluginRoots;
@@ -8,6 +9,7 @@ use codex_model_provider::SharedModelProvider;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::MultiAgentVersion;
@@ -206,6 +208,23 @@ impl TurnContext {
self.config.permissions.approval_policy.value()
}
pub(crate) fn allow_prefix_rules(&self) -> AllowPrefixRules {
let ignore_rules = self
.config
.config_layer_stack
.requirements_toml()
.auto_review
.as_ref()
.and_then(|auto_review| auto_review.ignore_rules.as_ref())
.is_some_and(|models| models.contains(&self.model_info.slug));
if self.model_info.model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER) || ignore_rules
{
AllowPrefixRules::IgnoreForCyberModel
} else {
AllowPrefixRules::Honor
}
}
pub(crate) fn permission_profile(&self) -> PermissionProfile {
self.config.permissions.effective_permission_profile()
}
+4 -2
View File
@@ -142,6 +142,10 @@ impl Session {
.and_then(|instructions| instructions.end.as_deref()),
));
world_state.add_section(AgentsMdState::new(step_context.loaded_agents_md.as_deref()));
let exec_policy = self
.services
.exec_policy
.current_for_prefix_rules(turn_context.allow_prefix_rules());
if turn_context.config.include_permissions_instructions {
let environment = step_context.environments.primary();
let permission_profile = environment
@@ -162,7 +166,6 @@ impl Session {
.and_then(|environment| environment.cwd().to_abs_path().ok())
.unwrap_or_else(|| turn_context.cwd.clone());
let model_messages = turn_context.model_info.model_messages.as_ref();
let exec_policy = self.services.exec_policy.current();
world_state.add_section(PermissionsState::new(
&permission_profile,
turn_context.approval_policy(),
@@ -183,7 +186,6 @@ impl Session {
.enabled(Feature::RequestPermissionsTool),
));
} else {
let exec_policy = self.services.exec_policy.current();
world_state.add_section(CompactPermissionsState::new(exec_policy.as_ref()));
}
if turn_context.config.include_collaboration_mode_instructions {
@@ -186,6 +186,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
effective_additional_permissions.sandbox_permissions
},
prefix_rule,
allow_prefix_rules: turn.allow_prefix_rules(),
})
.await;
@@ -172,7 +172,12 @@ pub(super) async fn try_run_zsh_fork(
.unwrap_or(crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
);
let exec_policy = Arc::new(RwLock::new(
ctx.session.services.exec_policy.current().as_ref().clone(),
ctx.session
.services
.exec_policy
.current_for_prefix_rules(ctx.turn.allow_prefix_rules())
.as_ref()
.clone(),
));
// TODO(anp): Keep PathUri through the shell escalation boundary.
let sandbox_cwd = sandbox_cwd
@@ -280,7 +285,12 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
}
let exec_policy = Arc::new(RwLock::new(
ctx.session.services.exec_policy.current().as_ref().clone(),
ctx.session
.services
.exec_policy
.current_for_prefix_rules(ctx.turn.allow_prefix_rules())
.as_ref()
.clone(),
));
// TODO(anp): Keep PathUri through the zsh-fork executor boundary.
let cwd = exec_request
@@ -1162,6 +1162,7 @@ impl UnifiedExecProcessManager {
request.sandbox_permissions
},
prefix_rule: request.prefix_rule.clone(),
allow_prefix_rules: context.turn.allow_prefix_rules(),
})
.await;
let req = UnifiedExecToolRequest {
@@ -0,0 +1,413 @@
use anyhow::Result;
use codex_core::config::Config;
use codex_core::config::Constrained;
use codex_core::sandboxing::SandboxPermissions;
use codex_features::Feature;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::sse_completed;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_host_windows;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::skip_if_wine_exec;
use core_test_support::submit_thread_settings;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::zsh_fork::zsh_fork_runtime;
use core_test_support::zsh_fork::zsh_fork_test_builder;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::fs;
use test_case::test_case;
const TEST_COMMAND: &str = "git version";
const SAVED_PREFIX: &str = r#"["git", "version"]"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CommandTool {
ShellCommand,
UnifiedExec,
}
impl CommandTool {
fn configure(self, config: &mut Config) {
if self == Self::UnifiedExec {
config.use_experimental_unified_exec_tool = true;
config
.features
.enable(Feature::UnifiedExec)
.expect("enable unified exec");
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ModelSpecialty {
Cyber,
General,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ShellBackend {
Standard,
ZshFork,
}
fn configure_saved_prefix_and_guardian(config: &mut Config) {
let policy_path = config.codex_home.join("rules/default.rules");
fs::create_dir_all(policy_path.parent().expect("rules directory"))
.expect("create rules directory");
fs::write(
policy_path,
r#"prefix_rule(pattern=["git", "version"], decision="allow")"#,
)
.expect("write saved command prefix rule");
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
config
.set_legacy_sandbox_policy(SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
})
.expect("set workspace sandbox policy");
}
fn command_response(response_id: &str, call_id: &str, command_tool: CommandTool) -> Result<String> {
let (tool_name, command_key, timeout_key) = match command_tool {
CommandTool::ShellCommand => ("shell_command", "command", "timeout_ms"),
CommandTool::UnifiedExec => ("exec_command", "cmd", "yield_time_ms"),
};
let mut args = json!({
"sandbox_permissions": SandboxPermissions::RequireEscalated,
"justification": "Check whether a saved prefix bypasses Guardian.",
"prefix_rule": ["git", "version"],
});
args[command_key] = json!(TEST_COMMAND);
args[timeout_key] = json!(1_000);
Ok(sse(vec![
ev_response_created(response_id),
ev_function_call(call_id, tool_name, &serde_json::to_string(&args)?),
ev_completed(response_id),
]))
}
fn guardian_allow_response(response_id: &str) -> String {
sse(vec![
ev_response_created(response_id),
ev_assistant_message(&format!("{response_id}-message"), r#"{"outcome":"allow"}"#),
ev_completed(response_id),
])
}
async fn submit_model_turn(test: &TestCodex, model: &str, prompt: &str) -> Result<()> {
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
model: Some(model.to_string()),
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
..Default::default()
},
)
.await?;
test.submit_text_turn(prompt).await
}
#[test_case(CommandTool::ShellCommand, ModelSpecialty::Cyber, ShellBackend::Standard; "cyber shell command is reviewed")]
#[test_case(CommandTool::UnifiedExec, ModelSpecialty::Cyber, ShellBackend::Standard; "cyber unified exec is reviewed")]
#[test_case(CommandTool::ShellCommand, ModelSpecialty::Cyber, ShellBackend::ZshFork; "cyber zsh shell command is reviewed")]
#[test_case(CommandTool::UnifiedExec, ModelSpecialty::Cyber, ShellBackend::ZshFork; "cyber zsh unified exec is reviewed")]
#[test_case(CommandTool::ShellCommand, ModelSpecialty::General, ShellBackend::Standard; "general shell command keeps saved approval")]
#[test_case(CommandTool::UnifiedExec, ModelSpecialty::General, ShellBackend::Standard; "general unified exec keeps saved approval")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn saved_prefix_only_bypasses_guardian_for_general_models(
command_tool: CommandTool,
model_specialty: ModelSpecialty,
shell_backend: ShellBackend,
) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_wine_exec!(Ok(()), "Guardian command reviews require host-native paths");
let server = start_mock_server().await;
let builder = match shell_backend {
ShellBackend::Standard => test_codex(),
ShellBackend::ZshFork => {
skip_if_host_windows!(Ok(()));
let Some(runtime) = zsh_fork_runtime("cyber model zsh-fork saved prefix")? else {
return Ok(());
};
zsh_fork_test_builder(runtime, AskForApproval::OnRequest)
}
};
let mut builder = builder
.with_model_info_override("gpt-5.4", move |model| {
if model_specialty == ModelSpecialty::Cyber {
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
}
})
.with_config(move |config| {
configure_saved_prefix_and_guardian(config);
command_tool.configure(config);
if shell_backend == ShellBackend::ZshFork && command_tool == CommandTool::UnifiedExec {
config
.features
.enable(Feature::UnifiedExecZshFork)
.expect("enable unified-exec zsh fork");
}
});
let test = builder.build_with_auto_env(&server).await?;
let expected_guardian_review_count = match (model_specialty, shell_backend) {
(ModelSpecialty::General, _) => 0,
(ModelSpecialty::Cyber, ShellBackend::Standard) => 1,
(ModelSpecialty::Cyber, ShellBackend::ZshFork) => 2,
};
let mut response_bodies = vec![command_response(
"parent-saved-prefix-command",
"saved-prefix-command",
command_tool,
)?];
for review_index in 0..expected_guardian_review_count {
response_bodies.push(guardian_allow_response(&format!(
"guardian-saved-prefix-review-{review_index}"
)));
}
response_bodies.push(sse_completed("parent-saved-prefix-complete"));
let responses = mount_sse_sequence(&server, response_bodies).await;
submit_model_turn(&test, "gpt-5.4", "run the saved-prefix command").await?;
let requests = responses.requests();
let guardian_request_count = requests
.iter()
.filter(|request| {
request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian")
})
.count();
assert_eq!(guardian_request_count, expected_guardian_review_count);
let advertised_saved_prefix = requests[0]
.message_input_texts("developer")
.iter()
.any(|message| message.contains(SAVED_PREFIX));
assert_eq!(
advertised_saved_prefix,
model_specialty == ModelSpecialty::General,
);
assert!(
responses
.function_call_output_text("saved-prefix-command")
.is_some_and(|output| output.contains(TEST_COMMAND)),
"approved test command should execute",
);
Ok(())
}
#[test_case(CommandTool::ShellCommand; "shell command")]
#[test_case(CommandTool::UnifiedExec; "unified exec")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cyber_model_user_approval_never_offers_a_reusable_prefix(
command_tool: CommandTool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_wine_exec!(Ok(()), "command approval requires host-native paths");
let server = start_mock_server().await;
let mut builder = test_codex()
.with_model_info_override("gpt-5.4", |model| {
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
})
.with_config(move |config| {
configure_saved_prefix_and_guardian(config);
command_tool.configure(config);
config.approvals_reviewer = ApprovalsReviewer::User;
});
let test = builder.build_with_auto_env(&server).await?;
let policy_path = test.codex_home_path().join("rules/default.rules");
let initial_policy = fs::read_to_string(&policy_path)?;
let responses = mount_sse_sequence(
&server,
vec![
command_response(
"parent-one-time-approval",
"one-time-approval",
command_tool,
)?,
sse_completed("parent-one-time-complete"),
],
)
.await;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "run a command with one-time approval".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::User),
..Default::default()
},
})
.await?;
let EventMsg::ExecApprovalRequest(approval) = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await
else {
panic!("expected a one-time command approval request");
};
assert_eq!(approval.proposed_execpolicy_amendment, None);
assert_eq!(
approval.effective_available_decisions(),
vec![ReviewDecision::Approved, ReviewDecision::Abort],
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
assert!(
responses
.function_call_output_text("one-time-approval")
.is_some_and(|output| output.contains(TEST_COMMAND)),
);
assert_eq!(fs::read_to_string(policy_path)?, initial_policy);
assert_eq!(responses.requests().len(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn switching_models_suppresses_and_restores_saved_prefix_approvals() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_wine_exec!(Ok(()), "Guardian command reviews require host-native paths");
let server = start_mock_server().await;
let mut builder = test_codex()
.with_model_info_override("gpt-5.4", |model| {
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
})
.with_model("gpt-5.2")
.with_config(configure_saved_prefix_and_guardian);
let test = builder.build_with_auto_env(&server).await?;
let responses = mount_sse_sequence(
&server,
vec![
command_response(
"parent-general-first-command",
"general-first-command",
CommandTool::ShellCommand,
)?,
sse_completed("parent-general-first-complete"),
command_response(
"parent-cyber-command",
"cyber-command",
CommandTool::ShellCommand,
)?,
guardian_allow_response("guardian-cyber-review"),
sse_completed("parent-cyber-complete"),
command_response(
"parent-general-last-command",
"general-last-command",
CommandTool::ShellCommand,
)?,
sse_completed("parent-general-last-complete"),
],
)
.await;
submit_model_turn(&test, "gpt-5.2", "run the first general-model command").await?;
submit_model_turn(&test, "gpt-5.4", "run the cyber-model command").await?;
submit_model_turn(&test, "gpt-5.2", "run the final general-model command").await?;
let requests = responses.requests();
let guardian_requests = requests
.iter()
.filter(|request| {
request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian")
})
.collect::<Vec<_>>();
assert_eq!(guardian_requests.len(), 1);
assert!(guardian_requests[0].body_contains_text(TEST_COMMAND));
let cyber_request = requests
.iter()
.find(|request| request.body_json()["model"] == "gpt-5.4")
.expect("cyber-model request");
let cyber_developer_messages = cyber_request.message_input_texts("developer");
let cyber_permissions = cyber_developer_messages
.iter()
.rev()
.find(|message| message.contains("<permissions instructions>"))
.expect("refreshed cyber-model permissions instructions");
assert!(!cyber_permissions.contains(SAVED_PREFIX));
let final_parent_request = requests
.iter()
.rev()
.find(|request| request.body_json()["model"] == "gpt-5.2")
.expect("final general-model request");
assert!(
final_parent_request
.message_input_texts("developer")
.iter()
.any(|message| {
message.contains("Approved command prefix saved:") && message.contains(SAVED_PREFIX)
}),
"switching back should restore the original saved-prefix context",
);
for call_id in [
"general-first-command",
"cyber-command",
"general-last-command",
] {
assert!(
responses
.function_call_output_text(call_id)
.is_some_and(|output| output.contains(TEST_COMMAND)),
"approved {call_id} should execute",
);
}
Ok(())
}
+1
View File
@@ -57,6 +57,7 @@ mod compact_remote;
mod compact_remote_parity;
mod compact_resume_fork;
mod current_time_reminder;
mod cyber_exec_policy;
mod deprecation_notice;
mod exec;
mod exec_policy;