mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
## What changed Represent denied read paths and globs with `PermissionContext` and render them through a sync-only section in `codex-guardian-context`. Keep policy resolution in the host and preserve the existing permission warning in Guardian review prompts. ## Testing Extend registry tests to verify permission sections appear only for synchronous reviews. Extend the session reuse test to check that resolved denied paths and globs appear in both the initial review request and the next review's delta. GitOrigin-RevId: a966a14b367f1eedb5a577f7ed74a869559ae646
1948 lines
76 KiB
Rust
1948 lines
76 KiB
Rust
#![cfg(not(target_os = "windows"))]
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use chrono::DateTime;
|
|
use chrono::Local;
|
|
use chrono::Utc;
|
|
use codex_config::types::McpServerConfig;
|
|
use codex_core::SleepFuture;
|
|
use codex_core::TimeFuture;
|
|
use codex_core::TimeProvider;
|
|
use codex_core::TurnInputRequest;
|
|
use codex_core::config::Config;
|
|
use codex_core::config::Constrained;
|
|
use codex_core::config::CurrentTimeReminderConfig;
|
|
use codex_core::sandboxing::SandboxPermissions;
|
|
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
|
use codex_extension_api::ExtensionRegistryBuilder;
|
|
use codex_extension_api::ToolLifecycleContributor;
|
|
use codex_extension_api::ToolLifecycleFuture;
|
|
use codex_extension_api::ToolStartInput;
|
|
use codex_features::CurrentTimeSource;
|
|
use codex_features::Feature;
|
|
use codex_history::RolloutItem;
|
|
use codex_login::CodexAuth;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::config_types::ApprovalsReviewer;
|
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::PermissionProfileSnapshot;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::AutoReviewMessages;
|
|
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::EnvironmentConfig;
|
|
use codex_protocol::protocol::EnvironmentConfigState;
|
|
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::protocol::ThreadSource;
|
|
use codex_protocol::protocol::TurnAbortReason;
|
|
use codex_protocol::user_input::UserInput;
|
|
use core_test_support::fs_wait;
|
|
use core_test_support::responses::assert_parent_turn;
|
|
use core_test_support::responses::assert_root_turn;
|
|
use core_test_support::responses::ev_assistant_message;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_completed_with_tokens;
|
|
use core_test_support::responses::ev_function_call;
|
|
use core_test_support::responses::ev_function_call_with_namespace;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::mount_response_once_match;
|
|
use core_test_support::responses::mount_sse_once;
|
|
use core_test_support::responses::mount_sse_sequence;
|
|
use core_test_support::responses::sse;
|
|
use core_test_support::responses::sse_response;
|
|
use core_test_support::responses::start_mock_server;
|
|
use core_test_support::responses::start_websocket_server;
|
|
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::test_codex::local_selections;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::wait_for_event;
|
|
use core_test_support::wait_for_mcp_server;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use test_case::test_case;
|
|
use wiremock::Mock;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::http::Method;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path_regex;
|
|
|
|
use super::rmcp_client::remote_aware_environment_id;
|
|
use super::rmcp_client::remote_aware_stdio_server_bin;
|
|
|
|
const CURRENT_TIME_AT: i64 = 1_781_717_655;
|
|
|
|
struct RecordingTimeProvider {
|
|
thread_ids: Mutex<Vec<ThreadId>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct RecordingToolLifecycleContributor {
|
|
call_ids: Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl ToolLifecycleContributor for RecordingToolLifecycleContributor {
|
|
fn on_tool_start<'a>(&'a self, input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> {
|
|
Box::pin(async move {
|
|
self.call_ids
|
|
.lock()
|
|
.expect("recorded tool call ids lock should not be poisoned")
|
|
.push(input.call_id.to_string());
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TimeProvider for RecordingTimeProvider {
|
|
fn current_time(&self, thread_id: ThreadId) -> TimeFuture<'_> {
|
|
self.thread_ids
|
|
.lock()
|
|
.expect("time-provider thread ids lock should not be poisoned")
|
|
.push(thread_id);
|
|
Box::pin(async {
|
|
Ok(DateTime::<Utc>::from_timestamp(CURRENT_TIME_AT, 0)
|
|
.expect("test timestamp should be valid"))
|
|
})
|
|
}
|
|
|
|
fn sleep(&self, _thread_id: ThreadId, _duration: Duration) -> SleepFuture<'_> {
|
|
Box::pin(async { Ok(()) })
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[test_case(CodexAuth::from_api_key("test-api-key"), "OpenAI", "/v1", true, "/v1/responses", true; "api_key_uses_responses")]
|
|
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "OpenAI", "/backend-api/codex", false, "/backend-api/codex/responses", true; "chatgpt_uses_responses_by_default")]
|
|
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "OpenAI", "/backend-api/codex", true, "/backend-api/codex/guardian", true; "chatgpt_uses_guardian_when_enabled")]
|
|
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "OpenAI", "/v1", true, "/v1/responses", true; "custom_openai_url_uses_responses")]
|
|
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "Custom", "/backend-api/codex", true, "/backend-api/codex/responses", true; "custom_provider_uses_responses")]
|
|
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), "OpenAI", "/backend-api/codex", true, "/backend-api/codex/guardian", false; "server_without_response_id")]
|
|
async fn guardian_session_inherits_parent_http_fallback(
|
|
auth: CodexAuth,
|
|
provider_name: &str,
|
|
base_path: &str,
|
|
free_guardian: bool,
|
|
expected_guardian_path: &str,
|
|
response_id_present: bool,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let websocket_fallback = Mock::given(method("GET"))
|
|
.and(path_regex(".*/responses$"))
|
|
.respond_with(ResponseTemplate::new(426))
|
|
.expect(1)
|
|
.mount_as_scoped(&server)
|
|
.await;
|
|
|
|
let parent_response_id = "parent-tool";
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
// A failed sampling attempt must not supply the retried action's parent ID.
|
|
sse(vec![json!({"type": "response.created", "response": {
|
|
"id": "failed-parent"
|
|
}})]),
|
|
sse(vec![
|
|
json!({"type": "response.created", "response": {"id": response_id_present.then_some(parent_response_id)}}),
|
|
ev_function_call(
|
|
"call",
|
|
"exec_command",
|
|
r#"{"cmd":"true","sandbox_permissions":"require_escalated","justification":"test"}"#,
|
|
),
|
|
ev_completed("parent-tool"),
|
|
]),
|
|
sse(vec![
|
|
ev_assistant_message("guardian", r#"{"outcome":"deny"}"#),
|
|
ev_completed("guardian-review"),
|
|
]),
|
|
sse(vec![ev_completed("parent-complete")]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let base_url = format!("{}{base_path}", server.uri());
|
|
let provider_name = provider_name.to_owned();
|
|
let mut builder = test_codex()
|
|
.with_auth(auth)
|
|
.with_pre_build_hook(move |home| {
|
|
fs::write(
|
|
home.join("config.toml"),
|
|
format!("[features.guardianv2]\nfree_guardian = {free_guardian}\n"),
|
|
)
|
|
.expect("Guardian endpoint configuration should be written");
|
|
})
|
|
.with_config(move |config| {
|
|
config.model_provider.base_url = Some(base_url);
|
|
config.model_provider.name = provider_name;
|
|
config.model_provider.supports_websockets = true;
|
|
config.model_provider.stream_max_retries = Some(1);
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config.approvals_reviewer = ApprovalsReviewer::User;
|
|
});
|
|
let test = builder.build_with_auto_env(&server).await?;
|
|
|
|
// Let startup prewarm observe the initial reviewer before enabling Guardian.
|
|
tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
websocket_fallback.wait_until_satisfied(),
|
|
)
|
|
.await?;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run a command".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
let requests = server.received_requests().await.unwrap_or_default();
|
|
let websocket_attempts = requests
|
|
.iter()
|
|
.filter(|request| {
|
|
request.method == Method::GET && request.url.path().ends_with("/responses")
|
|
})
|
|
.count();
|
|
assert_eq!(websocket_attempts, 1);
|
|
let guardian_request = responses
|
|
.requests()
|
|
.into_iter()
|
|
.find(|request| {
|
|
request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian")
|
|
})
|
|
.expect("Guardian reviewer inference request");
|
|
assert_eq!(guardian_request.path(), expected_guardian_path);
|
|
let credits_enabled = expected_guardian_path.ends_with("/guardian");
|
|
let body = guardian_request.body_json();
|
|
assert_eq!(
|
|
(
|
|
body["client_metadata"].get("parent_response_id").cloned(),
|
|
body["client_metadata"].get("guardian_credits_requested"),
|
|
),
|
|
(
|
|
(credits_enabled && response_id_present).then(|| json!(parent_response_id)),
|
|
None
|
|
)
|
|
);
|
|
assert!(!body["input"].to_string().contains(parent_response_id));
|
|
for request in responses.requests() {
|
|
let body = request.body_json();
|
|
if body["client_metadata"]["x-openai-subagent"] != "guardian" {
|
|
assert_eq!(
|
|
(
|
|
body["client_metadata"]
|
|
.get("guardian_credits_requested")
|
|
.cloned(),
|
|
body["client_metadata"].get("parent_response_id"),
|
|
),
|
|
(credits_enabled.then(|| json!("true")), None)
|
|
);
|
|
}
|
|
}
|
|
let guardian_context = guardian_request.message_input_texts("user").join("\n");
|
|
let executor_cwd = test
|
|
.executor_environment()
|
|
.selection()
|
|
.cwd
|
|
.inferred_native_path_string();
|
|
assert!(
|
|
guardian_context.contains(&format!(
|
|
"\"cwd\": \"{}\"",
|
|
executor_cwd.replace('\\', r"\\")
|
|
)),
|
|
"Guardian omitted the executor-native cwd from its planned action: {guardian_context}"
|
|
);
|
|
test.codex.shutdown_and_wait().await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[test_case(60_000, false; "legacy_reviewer_window_is_already_exhausted")]
|
|
#[test_case(49_990, true; "retained_followup_reminder_exhausts_reviewer_window")]
|
|
async fn guardian_review_resends_full_transcript_after_reviewer_context_rollover(
|
|
first_review_total_tokens: i64,
|
|
thread_owned: bool,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"Guardian approval actions require host-native paths"
|
|
);
|
|
|
|
let server = start_mock_server().await;
|
|
let mut builder = test_codex()
|
|
.with_model_info_override("gpt-5.5", |model| {
|
|
model.auto_review_model_override = Some(model.slug.clone());
|
|
})
|
|
.with_config(move |config| {
|
|
config
|
|
.features
|
|
.set_enabled(Feature::GuardianThreadContext, thread_owned)
|
|
.expect("configure Guardian context mode");
|
|
config.model_context_window = Some(100_000);
|
|
config.model_auto_compact_token_limit = Some(50_000);
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
config
|
|
.features
|
|
.enable(Feature::TokenBudget)
|
|
.expect("test config should allow token budget");
|
|
});
|
|
let test = builder.build_with_auto_env(&server).await?;
|
|
let command = json!({
|
|
"cmd": "true",
|
|
"sandbox_permissions": "require_escalated",
|
|
"justification": "Read the internal samples the user authorized.",
|
|
})
|
|
.to_string();
|
|
let approval = r#"{"risk_level":"low","user_authorization":"high","outcome":"allow"}"#;
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-parent-first"),
|
|
ev_function_call("exec-first", "exec_command", &command),
|
|
ev_completed("resp-parent-first"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-first"),
|
|
ev_assistant_message("guardian-first", approval),
|
|
ev_completed_with_tokens("resp-guardian-first", first_review_total_tokens),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-parent-second"),
|
|
ev_function_call("exec-second", "exec_command", &command),
|
|
ev_completed("resp-parent-second"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-second"),
|
|
ev_assistant_message("guardian-second", approval),
|
|
ev_completed("resp-guardian-second"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-parent-done"),
|
|
ev_assistant_message("parent-done", "done"),
|
|
ev_completed("resp-parent-done"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let user_authorization = "Read the internal evaluation samples I have authorized.";
|
|
test.submit_text_turn(user_authorization).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(), 2);
|
|
assert_eq!(
|
|
guardian_requests[0].body_json()["client_metadata"]["thread_id"],
|
|
guardian_requests[1].body_json()["client_metadata"]["thread_id"],
|
|
"the same Guardian reviewer should survive the context-window rollover"
|
|
);
|
|
|
|
let second_request = guardian_requests[1];
|
|
let second_prompt = second_request
|
|
.message_input_text_groups("user")
|
|
.last()
|
|
.expect("post-rollover Guardian review prompt")
|
|
.join("");
|
|
assert!(
|
|
second_request
|
|
.message_input_texts("developer")
|
|
.iter()
|
|
.any(|text| text.contains("Previous context window id:")),
|
|
"Guardian should have rolled into a new context window"
|
|
);
|
|
assert!(second_prompt.contains(">>> TRANSCRIPT START\n"));
|
|
assert!(!second_prompt.contains(">>> TRANSCRIPT DELTA START\n"));
|
|
assert!(second_prompt.contains(user_authorization));
|
|
assert!(
|
|
second_request
|
|
.message_input_texts("developer")
|
|
.iter()
|
|
.any(|text| text.contains("Use prior reviews as context, not binding precedent.")),
|
|
"the follow-up policy reminder should survive reviewer context rollover"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[test_case(CodexAuth::from_api_key("test-api-key"), true, "gpt-5.6-luna"; "api_key_uses_luna_with_responses_lite")]
|
|
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), true, "codex-auto-review"; "chatgpt_uses_codex_auto_review")]
|
|
#[test_case(CodexAuth::create_dummy_chatgpt_auth_for_testing(), false, "codex-auto-review"; "chatgpt_without_free_guardian")]
|
|
async fn guardian_session_prewarms_and_is_reused_for_first_review(
|
|
auth: CodexAuth,
|
|
free_guardian: bool,
|
|
expected_model: &str,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let uses_codex_backend = auth.uses_codex_backend();
|
|
let credits_enabled = free_guardian && uses_codex_backend;
|
|
let bundled_models = codex_models_manager::bundled_models_response()?.models;
|
|
let catalog_auto_review = bundled_models
|
|
.iter()
|
|
.find(|model| model.slug == expected_model)
|
|
.and_then(|model| model.model_messages.as_ref())
|
|
.and_then(|messages| messages.auto_review.as_ref())
|
|
.expect("bundled auto-review model Guardian policy");
|
|
let catalog_policy = catalog_auto_review
|
|
.policy
|
|
.as_deref()
|
|
.expect("catalog Guardian policy");
|
|
let catalog_template = catalog_auto_review
|
|
.policy_template
|
|
.as_deref()
|
|
.expect("catalog Guardian policy template");
|
|
let expected_guardian_policy =
|
|
catalog_template.replace("{{ tenant_policy_config }}", catalog_policy.trim());
|
|
let review_model = bundled_models
|
|
.into_iter()
|
|
.find(|model| model.slug == expected_model)
|
|
.expect("bundled Guardian review model");
|
|
let use_responses_lite = review_model.use_responses_lite;
|
|
if expected_model == "gpt-5.6-luna" {
|
|
assert!(use_responses_lite, "Luna must use Responses Lite");
|
|
}
|
|
|
|
let tool_args = json!({
|
|
"cmd": "true",
|
|
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
|
"justification": "Exercise Guardian approval routing.",
|
|
})
|
|
.to_string();
|
|
let parent_response_id = "approval-request";
|
|
let server = start_websocket_server(vec![
|
|
vec![vec![ev_response_created("warm-1"), ev_completed("warm-1")]],
|
|
vec![vec![ev_response_created("warm-2"), ev_completed("warm-2")]],
|
|
vec![vec![
|
|
ev_response_created(parent_response_id),
|
|
ev_function_call("approval-call", "exec_command", &tool_args),
|
|
ev_completed("approval-request"),
|
|
]],
|
|
vec![vec![
|
|
ev_response_created("guardian-review"),
|
|
ev_assistant_message(
|
|
"guardian-assessment",
|
|
&json!({
|
|
"risk_level": "low",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "The command is safe to execute.",
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("guardian-review"),
|
|
]],
|
|
])
|
|
.await;
|
|
let time_provider = Arc::new(RecordingTimeProvider {
|
|
thread_ids: Mutex::new(Vec::new()),
|
|
});
|
|
let backend_base_url = format!("{}/backend-api/codex", server.uri());
|
|
let mut builder = test_codex()
|
|
.with_auth(auth)
|
|
.with_pre_build_hook(move |home| {
|
|
fs::write(
|
|
home.join("config.toml"),
|
|
format!("[features.guardianv2]\nfree_guardian = {free_guardian}\n"),
|
|
)
|
|
.expect("Guardian endpoint configuration should be written");
|
|
})
|
|
.with_config(move |config| {
|
|
if uses_codex_backend {
|
|
config.model_provider.base_url = Some(backend_base_url);
|
|
}
|
|
let rules_dir = config.codex_home.join("rules");
|
|
fs::create_dir_all(&rules_dir).expect("create execution policy directory");
|
|
let policy_justification = format!(
|
|
"Explicit policy approval required {} policy-justification-end",
|
|
"x".repeat(10_000)
|
|
);
|
|
let policy_justification =
|
|
serde_json::to_string(&policy_justification).expect("serialize policy justification");
|
|
fs::write(
|
|
rules_dir.join("default.rules"),
|
|
format!(
|
|
r#"prefix_rule(pattern=["true"], decision="prompt", justification={policy_justification})"#
|
|
),
|
|
)
|
|
.expect("write execution policy rule");
|
|
config.model_catalog = Some(ModelsResponse {
|
|
models: vec![review_model],
|
|
});
|
|
config.model_context_window = Some(900_000);
|
|
config.model_auto_compact_token_limit = Some(600_000);
|
|
config.service_tier = Some("priority".to_owned());
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
config
|
|
.features
|
|
.enable(Feature::CurrentTimeReminder)
|
|
.expect("test config should allow current-time reminders");
|
|
config.current_time_reminder = Some(CurrentTimeReminderConfig {
|
|
clock_source: CurrentTimeSource::External,
|
|
..CurrentTimeReminderConfig::default()
|
|
});
|
|
})
|
|
.with_external_time_provider(time_provider.clone());
|
|
|
|
let test = builder.build_with_websocket_server(&server).await?;
|
|
let root_thread_id = test.session_configured.thread_id;
|
|
let (first, second) = tokio::time::timeout(Duration::from_secs(5), async {
|
|
tokio::join!(
|
|
server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0),
|
|
server.wait_for_request(/*connection_index*/ 1, /*request_index*/ 0)
|
|
)
|
|
})
|
|
.await?;
|
|
assert!(
|
|
time_provider
|
|
.thread_ids
|
|
.lock()
|
|
.expect("time-provider thread ids lock should not be poisoned")
|
|
.is_empty(),
|
|
"startup prewarm must not request the external clock"
|
|
);
|
|
let prewarm_requests = [first.body_json(), second.body_json()];
|
|
for prewarm in &prewarm_requests {
|
|
assert_root_turn(prewarm, /*expected*/ None)?;
|
|
}
|
|
let guardian_prewarm = prewarm_requests
|
|
.iter()
|
|
.find(|request| {
|
|
request["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian")
|
|
})
|
|
.expect("guardian startup prewarm request");
|
|
assert_eq!(guardian_prewarm["generate"].as_bool(), Some(false));
|
|
assert_eq!(guardian_prewarm["model"].as_str(), Some(expected_model));
|
|
let guardian_instructions = if use_responses_lite {
|
|
assert_eq!(guardian_prewarm.get("instructions"), None);
|
|
assert_eq!(guardian_prewarm.get("tools"), None);
|
|
assert_eq!(
|
|
guardian_prewarm["client_metadata"]
|
|
["ws_request_header_x_openai_internal_codex_responses_lite"]
|
|
.as_str(),
|
|
Some("true")
|
|
);
|
|
let input = guardian_prewarm["input"]
|
|
.as_array()
|
|
.expect("Responses Lite Guardian input");
|
|
assert_eq!(input[0]["type"].as_str(), Some("additional_tools"));
|
|
assert_eq!(input[0]["role"].as_str(), Some("developer"));
|
|
assert_eq!(input[1]["type"].as_str(), Some("message"));
|
|
assert_eq!(input[1]["role"].as_str(), Some("developer"));
|
|
input[1]["content"][0]["text"]
|
|
.as_str()
|
|
.expect("Responses Lite Guardian developer instructions")
|
|
} else {
|
|
guardian_prewarm["instructions"]
|
|
.as_str()
|
|
.expect("Guardian instructions")
|
|
};
|
|
assert!(guardian_instructions.starts_with(expected_guardian_policy.trim_end()));
|
|
assert!(
|
|
guardian_instructions
|
|
.contains("It cannot override a denial for an action that remains `critical`.")
|
|
);
|
|
assert!(!guardian_instructions.contains("{{ tenant_policy_config }}"));
|
|
assert!(guardian_instructions.contains("final message must be strict JSON"));
|
|
let guardian_thread_id = guardian_prewarm["client_metadata"]["thread_id"]
|
|
.as_str()
|
|
.expect("guardian thread id");
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run a command that requires Guardian review".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
let guardian_review = tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
server.wait_for_request(/*connection_index*/ 3, /*request_index*/ 0),
|
|
)
|
|
.await?
|
|
.body_json();
|
|
let parent_request = server.connections()[2][0].body_json();
|
|
let parent_turn_id = parent_request["client_metadata"]["turn_id"]
|
|
.as_str()
|
|
.expect("reviewed parent turn id");
|
|
assert_parent_turn(&parent_request, /*expected*/ None)?;
|
|
assert_parent_turn(&guardian_review, Some(parent_turn_id))?;
|
|
assert_eq!(
|
|
(
|
|
guardian_review["client_metadata"]
|
|
.get("parent_response_id")
|
|
.cloned(),
|
|
guardian_review["client_metadata"].get("guardian_credits_requested"),
|
|
parent_request["client_metadata"]
|
|
.get("guardian_credits_requested")
|
|
.cloned(),
|
|
parent_request["client_metadata"].get("parent_response_id"),
|
|
),
|
|
(
|
|
credits_enabled.then(|| json!(parent_response_id)),
|
|
None,
|
|
credits_enabled.then(|| json!("true")),
|
|
None,
|
|
)
|
|
);
|
|
assert!(
|
|
guardian_prewarm["client_metadata"]
|
|
.get("parent_response_id")
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
!guardian_review["input"]
|
|
.to_string()
|
|
.contains(parent_response_id)
|
|
);
|
|
for request in [&parent_request, &guardian_review] {
|
|
assert_root_turn(request, Some(parent_turn_id))?;
|
|
}
|
|
assert_eq!(
|
|
guardian_review["client_metadata"]["x-openai-subagent"].as_str(),
|
|
Some("guardian")
|
|
);
|
|
assert_eq!(guardian_review["model"].as_str(), Some(expected_model));
|
|
for request in [guardian_prewarm, &guardian_review] {
|
|
assert_eq!(request.get("service_tier"), None);
|
|
let metadata: serde_json::Value = serde_json::from_str(
|
|
request["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.expect("guardian turn metadata"),
|
|
)?;
|
|
assert_eq!(metadata["thread_source"], "guardian_review");
|
|
}
|
|
assert_eq!(
|
|
guardian_review["client_metadata"]["thread_id"].as_str(),
|
|
Some(guardian_thread_id)
|
|
);
|
|
let guardian_review_text = guardian_review.to_string();
|
|
assert!(guardian_review_text.contains("Retry reason:"));
|
|
assert!(guardian_review_text.contains("Explicit policy approval required"));
|
|
assert!(guardian_review_text.contains("tokens truncated"));
|
|
assert!(guardian_review_text.contains("policy-justification-end"));
|
|
assert!(!guardian_review_text.contains(&"x".repeat(4_096)));
|
|
let current_date = DateTime::<Utc>::from_timestamp(CURRENT_TIME_AT, 0)
|
|
.expect("test timestamp should be valid")
|
|
.with_timezone(&Local)
|
|
.format("%Y-%m-%d")
|
|
.to_string();
|
|
assert!(
|
|
guardian_review
|
|
.to_string()
|
|
.contains(&format!("<current_date>{current_date}</current_date>")),
|
|
"guardian's environment context should use the simulated current date"
|
|
);
|
|
let guardian_thread_id = ThreadId::from_string(guardian_thread_id)?;
|
|
{
|
|
let thread_ids = time_provider
|
|
.thread_ids
|
|
.lock()
|
|
.expect("time-provider thread ids lock should not be poisoned");
|
|
assert!(thread_ids.contains(&root_thread_id));
|
|
assert!(thread_ids.contains(&guardian_thread_id));
|
|
assert!(
|
|
thread_ids
|
|
.iter()
|
|
.all(|thread_id| thread_id == &root_thread_id || thread_id == &guardian_thread_id),
|
|
"clock requests should use the corresponding agent's own thread id: {thread_ids:?}"
|
|
);
|
|
}
|
|
assert_eq!(guardian_review.get("generate"), None);
|
|
|
|
let guardian_rollout_path = test
|
|
.codex
|
|
.guardian_trunk_rollout_path()
|
|
.await
|
|
.expect("guardian trunk rollout path");
|
|
test.codex.shutdown_and_wait().await?;
|
|
let guardian_rollout = fs::read_to_string(guardian_rollout_path)?
|
|
.lines()
|
|
.map(codex_rollout::parse_rollout_line)
|
|
.collect::<serde_json::Result<Vec<_>>>()?;
|
|
assert_eq!(
|
|
guardian_rollout.iter().find_map(|line| match &line.item {
|
|
RolloutItem::SessionMeta(meta) => meta.meta.thread_source.as_ref(),
|
|
_ => None,
|
|
}),
|
|
Some(&ThreadSource::GuardianReview)
|
|
);
|
|
let guardian_context_windows = guardian_rollout
|
|
.into_iter()
|
|
.filter_map(|line| match line.item {
|
|
RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => Some(event.model_context_window),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(guardian_context_windows, vec![Some(258_400)]);
|
|
for handshake in server.handshakes() {
|
|
let is_guardian = handshake.header("x-openai-subagent").as_deref() == Some("guardian");
|
|
let uses_guardian_endpoint = credits_enabled && is_guardian;
|
|
assert_eq!(
|
|
handshake.uri(),
|
|
if uses_guardian_endpoint {
|
|
"/backend-api/codex/guardian"
|
|
} else if uses_codex_backend {
|
|
"/backend-api/codex/responses"
|
|
} else {
|
|
"/v1/responses"
|
|
}
|
|
);
|
|
if uses_guardian_endpoint {
|
|
assert_eq!(handshake.header("x-codex-routing-hint"), None);
|
|
}
|
|
}
|
|
server.shutdown().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[test_case("first_node", "node_repl", None; "injects_policy_for_first_node_action")]
|
|
#[test_case("shell_then_nodes", "node_repl", None; "reuses_shell_reviewer_and_injects_policy_once")]
|
|
#[test_case("ineligible_node", "node_repl", None; "omits_policy_for_ineligible_parent_model")]
|
|
#[test_case("first_node", "cua_repl", None; "injects_policy_for_first_cua_action")]
|
|
#[test_case("shell_then_nodes", "cua_repl", None; "reuses_shell_reviewer_and_injects_cua_policy_once")]
|
|
#[test_case("ineligible_node", "cua_repl", None; "omits_cua_policy_for_ineligible_parent_model")]
|
|
#[test_case("shell_then_nodes", "node_repl", Some("Catalog REPL policy."); "catalog_node_policy")]
|
|
#[test_case("shell_then_nodes", "cua_repl", Some("Catalog REPL policy."); "catalog_cua_policy")]
|
|
#[test_case("first_node", "node_repl", Some(""); "empty_catalog_policy")]
|
|
async fn guardian_node_repl_policy_follows_production_approval_path(
|
|
scenario: &str,
|
|
repl_server: &'static str,
|
|
node_repl_policy: Option<&'static str>,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_sandbox!(Ok(()));
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"Guardian MCP approvals require a host-native test stdio server"
|
|
);
|
|
|
|
let server = start_mock_server().await;
|
|
let mcp_server_bin = remote_aware_stdio_server_bin()?;
|
|
let node_repl_auto_review_required = scenario != "ineligible_node";
|
|
let mut builder = test_codex()
|
|
.with_model_info_override("gpt-5.6-luna", move |model| {
|
|
model
|
|
.model_messages
|
|
.as_mut()
|
|
.expect("reviewer model messages")
|
|
.auto_review = Some(AutoReviewMessages {
|
|
policy: None,
|
|
policy_template: None,
|
|
node_repl_policy: node_repl_policy.map(str::to_string),
|
|
rejection_instructions: None,
|
|
timeout_instructions: None,
|
|
});
|
|
})
|
|
.with_model_info_override("gpt-5.4", move |model| {
|
|
model.node_repl_auto_review_required = node_repl_auto_review_required;
|
|
model.auto_review_model_override = Some("gpt-5.6-luna".to_string());
|
|
model
|
|
.model_messages
|
|
.as_mut()
|
|
.expect("acting model messages")
|
|
.auto_review = Some(AutoReviewMessages {
|
|
policy: None,
|
|
policy_template: None,
|
|
node_repl_policy: Some("Acting-model REPL policy.".to_string()),
|
|
rejection_instructions: None,
|
|
timeout_instructions: None,
|
|
});
|
|
})
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
let repl: McpServerConfig = serde_json::from_value(json!({
|
|
"command": mcp_server_bin,
|
|
"environment_id": remote_aware_environment_id(),
|
|
"cwd": config.cwd,
|
|
"default_tools_approval_mode": "prompt",
|
|
"env": { "MCP_TEST_ENABLE_NODE_REPL_JS": "1" }
|
|
}))
|
|
.expect("valid REPL MCP test server");
|
|
config
|
|
.mcp_servers
|
|
.set([(String::from(repl_server), repl)].into_iter().collect())
|
|
.expect("configure REPL MCP test server");
|
|
});
|
|
let test = builder.build_with_auto_env(&server).await?;
|
|
wait_for_mcp_server(&test.codex, repl_server).await?;
|
|
|
|
let actions: &[&str] = if scenario == "shell_then_nodes" {
|
|
&["shell", "node-first", "node-second"]
|
|
} else {
|
|
&["node-first"]
|
|
};
|
|
let mut responses = Vec::new();
|
|
for action in actions {
|
|
let call_id = format!("call-{action}");
|
|
let tool_call = if *action == "shell" {
|
|
ev_function_call(
|
|
&call_id,
|
|
"exec_command",
|
|
&json!({
|
|
"cmd": "true",
|
|
"sandbox_permissions": "require_escalated",
|
|
"justification": "Review a shell action before Node REPL."
|
|
})
|
|
.to_string(),
|
|
)
|
|
} else {
|
|
ev_function_call_with_namespace(
|
|
&call_id,
|
|
&format!("mcp__{repl_server}"),
|
|
"js",
|
|
r#"{"code":"nodeRepl.empty()"}"#,
|
|
)
|
|
};
|
|
responses.push(sse(vec![
|
|
ev_response_created(&format!("parent-{action}")),
|
|
tool_call,
|
|
ev_completed(&format!("parent-{action}")),
|
|
]));
|
|
responses.push(sse(vec![
|
|
ev_response_created(&format!("guardian-{action}")),
|
|
ev_assistant_message(
|
|
&format!("guardian-message-{action}"),
|
|
r#"{"outcome":"allow"}"#,
|
|
),
|
|
ev_completed(&format!("guardian-{action}")),
|
|
]));
|
|
}
|
|
responses.push(sse(vec![ev_completed("parent-complete")]));
|
|
let response_mock = mount_sse_sequence(&server, responses).await;
|
|
|
|
test.submit_text_turn(&format!("Inspect the browser with {repl_server}."))
|
|
.await?;
|
|
|
|
let requests = response_mock.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(), actions.len());
|
|
|
|
let bundled_policy = include_str!("../../assets/guardian/node_repl_policy.md");
|
|
let policy = node_repl_policy.unwrap_or(bundled_policy);
|
|
let first_guardian_thread = guardian_requests[0].body_json()["client_metadata"]["thread_id"]
|
|
.as_str()
|
|
.expect("Guardian reviewer thread id")
|
|
.to_string();
|
|
for (index, request) in guardian_requests.iter().enumerate() {
|
|
assert_eq!(
|
|
request.body_json()["client_metadata"]["thread_id"].as_str(),
|
|
Some(first_guardian_thread.as_str()),
|
|
"shell and Node approvals must reuse the same Guardian reviewer"
|
|
);
|
|
let expected = usize::from(
|
|
node_repl_auto_review_required
|
|
&& !policy.is_empty()
|
|
&& !(scenario == "shell_then_nodes" && index == 0),
|
|
);
|
|
assert_eq!(
|
|
request
|
|
.message_input_texts("developer")
|
|
.into_iter()
|
|
.filter(|text| {
|
|
[bundled_policy, "Acting-model REPL policy.", policy].contains(&text.as_str())
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![policy.to_string(); expected],
|
|
"Node REPL policy must appear exactly once on eligible Node reviews"
|
|
);
|
|
if expected == 1 && (index == 0 || scenario == "shell_then_nodes" && index == 1) {
|
|
let body = request.body_json();
|
|
let input = body["input"].as_array().expect("Guardian reviewer input");
|
|
let policy_index = input
|
|
.iter()
|
|
.position(|item| {
|
|
item["role"] == "developer"
|
|
&& item["content"].as_array().is_some_and(|content| {
|
|
content.iter().any(|span| span["text"] == policy)
|
|
})
|
|
})
|
|
.expect("Node REPL developer policy");
|
|
let user_index = input
|
|
.iter()
|
|
.rposition(|item| item["role"] == "user")
|
|
.expect("Node REPL approval request");
|
|
assert_eq!(policy_index + 1, user_index);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_session_is_reused_for_consecutive_tool_reviews_without_prewarm() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_sandbox!(Ok(()));
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"Guardian approval actions require host-native paths"
|
|
);
|
|
|
|
const SECRET: &str = "guardian-parent-policy-test-secret";
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let lifecycle_recorder = Arc::new(RecordingToolLifecycleContributor::default());
|
|
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
|
|
extensions.tool_lifecycle_contributor(lifecycle_recorder.clone());
|
|
let mut builder = test_codex()
|
|
.with_extensions(Arc::new(extensions.build()))
|
|
.with_config(move |config| {
|
|
let secret_file = config.cwd.join("guardian-secret.txt");
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
let mut file_system_policy = FileSystemSandboxPolicy::workspace_write(
|
|
&[],
|
|
/*exclude_tmpdir_env_var*/ true,
|
|
/*exclude_slash_tmp*/ true,
|
|
);
|
|
file_system_policy.entries.push(FileSystemSandboxEntry::new(
|
|
secret_file.into(),
|
|
FileSystemAccessMode::Deny,
|
|
));
|
|
file_system_policy.entries.push(FileSystemSandboxEntry::new(
|
|
FileSystemPath::GlobPattern {
|
|
pattern: "guardian-*.key".to_string(),
|
|
},
|
|
FileSystemAccessMode::Deny,
|
|
));
|
|
config
|
|
.permissions
|
|
.set_permission_profile(PermissionProfile::from_runtime_permissions(
|
|
&file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
))
|
|
.expect("set parent permission profile");
|
|
})
|
|
.with_workspace_setup(|cwd, fs| async move {
|
|
fs.write_file(
|
|
&codex_utils_path_uri::PathUri::from_abs_path(&cwd.join("guardian-secret.txt")),
|
|
SECRET.as_bytes().to_vec(),
|
|
Default::default(),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
});
|
|
let test = builder.build_with_auto_env(&server).await?;
|
|
|
|
let secret_file = test.config.cwd.join("guardian-secret.txt");
|
|
let guardian_output_file = test.cwd.path().join("guardian-write.txt");
|
|
let first_output_file = test.cwd.path().join("guardian-first.txt");
|
|
let second_output_file = test.cwd.path().join("guardian-second.txt");
|
|
let first_command = format!("printf first > {}", first_output_file.display());
|
|
let second_command = format!("printf second > {}", second_output_file.display());
|
|
let first_tool_args = json!({
|
|
"cmd": first_command,
|
|
"yield_time_ms": 1_000_u64,
|
|
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
|
"justification": "Exercise the first Guardian approval.",
|
|
});
|
|
let second_tool_args = json!({
|
|
"cmd": second_command,
|
|
"yield_time_ms": 1_000_u64,
|
|
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
|
"justification": "Exercise the second Guardian approval.",
|
|
});
|
|
let guardian_tool_args = json!({
|
|
"cmd": format!(
|
|
"cat {}; printf hostile > {}",
|
|
secret_file.display(),
|
|
guardian_output_file.display()
|
|
),
|
|
"sandbox_permissions": SandboxPermissions::UseDefault,
|
|
});
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-parent-first-tool"),
|
|
ev_function_call(
|
|
"exec-call-first",
|
|
"exec_command",
|
|
&serde_json::to_string(&first_tool_args)?,
|
|
),
|
|
ev_completed("resp-parent-first-tool"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-first-review"),
|
|
ev_function_call(
|
|
"exec-guardian-denied-read",
|
|
"exec_command",
|
|
&serde_json::to_string(&guardian_tool_args)?,
|
|
),
|
|
ev_completed("resp-guardian-first-review"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-first-assessment"),
|
|
ev_assistant_message(
|
|
"msg-guardian-first-review",
|
|
&json!({
|
|
"risk_level": "low",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "The first command writes a workspace marker.",
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-guardian-first-assessment"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-parent-first-done"),
|
|
ev_assistant_message("msg-parent-first-done", "first done"),
|
|
ev_completed("resp-parent-first-done"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-parent-second-tool"),
|
|
ev_function_call(
|
|
"exec-call-second",
|
|
"exec_command",
|
|
&serde_json::to_string(&second_tool_args)?,
|
|
),
|
|
ev_completed("resp-parent-second-tool"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-second-review"),
|
|
ev_assistant_message(
|
|
"msg-guardian-second-review",
|
|
&json!({
|
|
"risk_level": "low",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "The second command writes a workspace marker.",
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-guardian-second-review"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-parent-done"),
|
|
ev_assistant_message("msg-parent-done", "done"),
|
|
ev_completed("resp-parent-done"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
let mut parent_environments = local_selections(test.config.cwd.clone());
|
|
let parent_environment_config = EnvironmentConfig {
|
|
allow_login_shell: test.config.permissions.allow_login_shell,
|
|
workspace_roots: parent_environments.environments[0].workspace_roots.clone(),
|
|
permission_profile: PermissionProfileSnapshot::legacy(
|
|
test.config.permissions.permission_profile().clone(),
|
|
),
|
|
shell_environment_policy: Default::default(),
|
|
windows_sandbox_level: WindowsSandboxLevel::from_config(&test.config),
|
|
windows_sandbox_private_desktop: test.config.permissions.windows_sandbox_private_desktop,
|
|
use_legacy_landlock: test.config.features.use_legacy_landlock(),
|
|
exec_policy: None,
|
|
mcp_policy: None,
|
|
network_policy: None,
|
|
selected_capability_roots: Vec::new(),
|
|
};
|
|
parent_environments
|
|
.environments
|
|
.first_mut()
|
|
.expect("local environment selection")
|
|
.config = EnvironmentConfigState::Ready(parent_environment_config);
|
|
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run the first command that requires Guardian review".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
environments: Some(parent_environments),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
test.submit_text_turn("run the second command that requires Guardian review")
|
|
.await?;
|
|
let requests = responses.requests();
|
|
assert!(
|
|
requests
|
|
.iter()
|
|
.all(|request| !request.body_contains_text(SECRET)),
|
|
"Guardian disclosed a file denied by the parent task"
|
|
);
|
|
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(), 3);
|
|
let permission_section = [
|
|
"\n>>> PARENT TURN PERMISSION CONTEXT START\n".to_string(),
|
|
format!(
|
|
"The parent turn's active permission profile denies reading these paths/globs. These are policy restrictions; do not approve escalation whose purpose is to read them.\n- path `{}`\n- glob `{}`\n",
|
|
fs::canonicalize(&secret_file)?.display(),
|
|
test.config.cwd.join("guardian-*.key").display(),
|
|
),
|
|
">>> PARENT TURN PERMISSION CONTEXT END\n".to_string(),
|
|
];
|
|
// Both the full request and the next review's delta must carry the resolved policy.
|
|
for request in [guardian_requests[0], guardian_requests[2]] {
|
|
let user_messages = request.message_input_text_groups("user");
|
|
let latest_input = user_messages.last().expect("Guardian assessment input");
|
|
let section_start = latest_input
|
|
.iter()
|
|
.position(|text| text == &permission_section[0])
|
|
.expect("parent permission section");
|
|
assert_eq!(
|
|
&latest_input[section_start..section_start + permission_section.len()],
|
|
permission_section.as_slice()
|
|
);
|
|
}
|
|
let first_guardian_request = guardian_requests[0].body_json();
|
|
let second_guardian_request = guardian_requests[2].body_json();
|
|
let first_parent_request = requests[0].body_json();
|
|
let second_parent_request = requests[4].body_json();
|
|
let first_parent_turn_id = first_parent_request["client_metadata"]["turn_id"]
|
|
.as_str()
|
|
.expect("first owning turn id");
|
|
let second_parent_turn_id = second_parent_request["client_metadata"]["turn_id"]
|
|
.as_str()
|
|
.expect("second owning turn id");
|
|
assert_ne!(first_parent_turn_id, second_parent_turn_id);
|
|
for (request, parent_turn_id) in guardian_requests.iter().zip([
|
|
first_parent_turn_id,
|
|
first_parent_turn_id,
|
|
second_parent_turn_id,
|
|
]) {
|
|
let body = request.body_json();
|
|
assert_parent_turn(&body, Some(parent_turn_id))?;
|
|
assert_root_turn(&body, Some(parent_turn_id))?;
|
|
assert_ne!(body["client_metadata"]["turn_id"], parent_turn_id);
|
|
assert_eq!(
|
|
body["client_metadata"]["session_id"],
|
|
first_parent_request["client_metadata"]["session_id"]
|
|
);
|
|
}
|
|
assert_ne!(
|
|
first_guardian_request["client_metadata"]["turn_id"],
|
|
second_guardian_request["client_metadata"]["turn_id"]
|
|
);
|
|
let first_guardian_thread_id = first_guardian_request["client_metadata"]["thread_id"]
|
|
.as_str()
|
|
.expect("first Guardian review should have a thread id");
|
|
let second_guardian_thread_id = second_guardian_request["client_metadata"]["thread_id"]
|
|
.as_str()
|
|
.expect("second Guardian review should have a thread id");
|
|
assert_eq!(first_guardian_thread_id, second_guardian_thread_id);
|
|
assert!(
|
|
!guardian_output_file.exists(),
|
|
"Guardian wrote a local file"
|
|
);
|
|
assert_eq!(fs::read_to_string(first_output_file)?, "first");
|
|
assert_eq!(fs::read_to_string(second_output_file)?, "second");
|
|
assert_eq!(
|
|
*lifecycle_recorder
|
|
.call_ids
|
|
.lock()
|
|
.expect("recorded tool call ids lock should not be poisoned"),
|
|
vec![
|
|
"exec-call-first".to_string(),
|
|
"exec-call-second".to_string()
|
|
]
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn interrupted_guardian_tool_review_aborts_without_executing_the_command() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_sandbox!(Ok(()));
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"Guardian approval actions require host-native paths"
|
|
);
|
|
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
});
|
|
let test = builder.build_with_auto_env(&server).await?;
|
|
|
|
let output_file = test.cwd.path().join("guardian-interrupted.txt");
|
|
let command = format!("printf should-not-run > {}", output_file.display());
|
|
let tool_args = json!({
|
|
"cmd": command,
|
|
"yield_time_ms": 1_000_u64,
|
|
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
|
"justification": "Exercise interrupted Guardian approval.",
|
|
});
|
|
mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-parent-interrupted-tool"),
|
|
ev_function_call(
|
|
"exec-call-interrupted",
|
|
"exec_command",
|
|
&serde_json::to_string(&tool_args)?,
|
|
),
|
|
ev_completed("resp-parent-interrupted-tool"),
|
|
]),
|
|
)
|
|
.await;
|
|
let pending_guardian = mount_response_once_match(
|
|
&server,
|
|
|request: &wiremock::Request| {
|
|
serde_json::from_slice::<Value>(&request.body)
|
|
.ok()
|
|
.and_then(|body| {
|
|
body.pointer("/client_metadata/x-openai-subagent")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned)
|
|
})
|
|
.as_deref()
|
|
== Some("guardian")
|
|
},
|
|
sse_response(sse(vec![
|
|
ev_response_created("resp-guardian-interrupted-review"),
|
|
ev_assistant_message(
|
|
"msg-guardian-interrupted-review",
|
|
&json!({
|
|
"risk_level": "low",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "This review should be interrupted before it completes.",
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-guardian-interrupted-review"),
|
|
]))
|
|
.set_delay(Duration::from_millis(200)),
|
|
)
|
|
.await;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "interrupt a Guardian-reviewed command".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
environments: Some(local_selections(test.config.cwd.clone())),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
tokio::time::timeout(Duration::from_secs(5), async {
|
|
while pending_guardian.requests().is_empty() {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.context("timed out waiting for Guardian review request")?;
|
|
|
|
test.codex.submit(Op::Interrupt).await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnAborted(_))
|
|
})
|
|
.await;
|
|
tokio::time::sleep(Duration::from_millis(350)).await;
|
|
assert!(
|
|
!output_file.exists(),
|
|
"the interrupted Guardian-reviewed command executed after its delayed approval response"
|
|
);
|
|
|
|
let follow_up = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-parent-after-interrupted-review"),
|
|
ev_assistant_message("msg-parent-after-interrupted-review", "next turn completed"),
|
|
ev_completed("resp-parent-after-interrupted-review"),
|
|
]),
|
|
)
|
|
.await;
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "verify Guardian cancellation left the next turn clean".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
let follow_up_request = follow_up.single_request();
|
|
assert!(
|
|
follow_up_request
|
|
.body_contains_text("verify Guardian cancellation left the next turn clean")
|
|
);
|
|
assert!(follow_up_request.has_function_call("exec-call-interrupted"));
|
|
let interrupted_output = follow_up_request
|
|
.function_call_output_text("exec-call-interrupted")
|
|
.expect("next turn should contain the interrupted command's tool output");
|
|
assert!(
|
|
interrupted_output.contains("aborted"),
|
|
"unexpected interrupted tool output: {interrupted_output}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[test_case(None; "legacy_fallback")]
|
|
#[test_case(Some("Acting model rejection instructions."); "catalog_override")]
|
|
#[test_case(Some(""); "empty_override")]
|
|
async fn guardian_denial_rejects_tool_call_with_rationale(
|
|
rejection_instructions: Option<&'static str>,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_sandbox!(Ok(()));
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"Guardian approval actions require host-native paths"
|
|
);
|
|
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
|
|
let mut builder = test_codex()
|
|
.with_model_info_override("gpt-5.6-luna", |model| {
|
|
model
|
|
.model_messages
|
|
.as_mut()
|
|
.expect("reviewer model messages")
|
|
.auto_review = Some(AutoReviewMessages {
|
|
policy: None,
|
|
policy_template: None,
|
|
node_repl_policy: None,
|
|
rejection_instructions: Some("Reviewer-only rejection instructions.".to_string()),
|
|
timeout_instructions: None,
|
|
});
|
|
})
|
|
.with_model_info_override("gpt-5.5", move |model| {
|
|
model.auto_review_model_override = Some("gpt-5.6-luna".to_string());
|
|
model
|
|
.model_messages
|
|
.as_mut()
|
|
.expect("acting model messages")
|
|
.auto_review = Some(AutoReviewMessages {
|
|
policy: None,
|
|
policy_template: None,
|
|
node_repl_policy: None,
|
|
rejection_instructions: rejection_instructions.map(str::to_string),
|
|
timeout_instructions: None,
|
|
});
|
|
})
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
});
|
|
let test = builder.build_with_auto_env(&server).await?;
|
|
|
|
let output_file = test.cwd.path().join("guardian-denied.txt");
|
|
let command = format!("printf should-not-run > {}", output_file.display());
|
|
let tool_args = json!({
|
|
"cmd": command,
|
|
"yield_time_ms": 1_000_u64,
|
|
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
|
"justification": "Exercise Guardian denial routing.",
|
|
});
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-parent-tool-denied"),
|
|
ev_function_call(
|
|
"exec-call-denied",
|
|
"exec_command",
|
|
&serde_json::to_string(&tool_args)?,
|
|
),
|
|
ev_completed("resp-parent-tool-denied"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-inspect"),
|
|
ev_function_call(
|
|
"exec-guardian-inspect",
|
|
"exec_command",
|
|
&json!({"cmd": "printf guardian-inspection-evidence"}).to_string(),
|
|
),
|
|
ev_completed("resp-guardian-inspect"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-denied"),
|
|
ev_assistant_message(
|
|
"msg-guardian-denied",
|
|
&json!({
|
|
"risk_level": "high",
|
|
"user_authorization": "low",
|
|
"outcome": "deny",
|
|
"rationale": "The requested write has unacceptable test risk.",
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-guardian-denied"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-parent-after-denial"),
|
|
ev_assistant_message("msg-parent-after-denial", "denied"),
|
|
ev_completed("resp-parent-after-denial"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run a command that Guardian should deny".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
let requests = responses.requests();
|
|
let guardian_request = requests
|
|
.iter()
|
|
.find(|request| request.body_contains_text("Exercise Guardian denial routing."))
|
|
.expect("expected Guardian review request");
|
|
assert!(guardian_request.body_contains_text(&command));
|
|
assert_eq!(guardian_request.body_json()["model"], "gpt-5.6-luna");
|
|
|
|
let feedback = codex_feedback::guardian_review_failures(&[test.session_configured.thread_id])
|
|
.attachment
|
|
.expect("failed Guardian review");
|
|
let record: serde_json::Value = serde_json::from_slice(&feedback.buffer)?;
|
|
assert!(
|
|
guardian_request.body_contains_text(record["action"].as_str().expect("reviewed action"))
|
|
);
|
|
let recorded_history: Vec<ResponseItem> = serde_json::from_value(record["history"].clone())?;
|
|
let inspection_request = requests
|
|
.iter()
|
|
.find(|request| {
|
|
request
|
|
.function_call_output_text("exec-guardian-inspect")
|
|
.is_some()
|
|
})
|
|
.expect("Guardian request following its inspection tool");
|
|
assert!(
|
|
inspection_request
|
|
.function_call_output_text("exec-guardian-inspect")
|
|
.expect("inspection output")
|
|
.contains("guardian-inspection-evidence")
|
|
);
|
|
let inspection_output = inspection_request
|
|
.input()
|
|
.into_iter()
|
|
.find(|item| {
|
|
item["type"] == "function_call_output" && item["call_id"] == "exec-guardian-inspect"
|
|
})
|
|
.expect("inspection response item");
|
|
assert!(recorded_history.contains(&serde_json::from_value(inspection_output)?));
|
|
assert!(recorded_history.iter().any(|item| {
|
|
matches!(item, ResponseItem::Message { role, content, .. }
|
|
if role == "assistant" && content.iter().any(|part| {
|
|
matches!(part, ContentItem::OutputText { text }
|
|
if Some(text.as_str()) == record["decision"].as_str())
|
|
}))
|
|
}));
|
|
assert_eq!(
|
|
json!({
|
|
"reviewed_thread_id": record["reviewed_thread_id"],
|
|
"reviewer_thread_id": record["reviewer_thread_id"],
|
|
"status": record["status"],
|
|
"decision": serde_json::from_str::<serde_json::Value>(
|
|
record["decision"].as_str().expect("raw Guardian decision"),
|
|
)?,
|
|
"context_omitted": record["context_omitted"],
|
|
}),
|
|
json!({
|
|
"reviewed_thread_id": test.session_configured.thread_id,
|
|
"reviewer_thread_id": guardian_request.body_json()["client_metadata"]["thread_id"],
|
|
"status": "denied",
|
|
"decision": {
|
|
"risk_level": "high",
|
|
"user_authorization": "low",
|
|
"outcome": "deny",
|
|
"rationale": "The requested write has unacceptable test risk.",
|
|
},
|
|
"context_omitted": false,
|
|
})
|
|
);
|
|
|
|
let tool_output = requests
|
|
.iter()
|
|
.find_map(|request| request.function_call_output_text("exec-call-denied"))
|
|
.expect("expected rejected tool output to be returned to the parent model");
|
|
assert!(
|
|
tool_output.contains("The requested write has unacceptable test risk."),
|
|
"Guardian rationale missing from rejected tool output: {tool_output}"
|
|
);
|
|
assert_eq!(
|
|
tool_output.contains("The agent must not attempt to achieve the same outcome"),
|
|
rejection_instructions.is_none(),
|
|
"legacy rejection instructions should only be used when absent: {tool_output}"
|
|
);
|
|
if let Some(rejection_instructions) = rejection_instructions {
|
|
assert!(tool_output.contains(rejection_instructions));
|
|
}
|
|
assert!(!tool_output.contains("Reviewer-only rejection instructions."));
|
|
assert!(
|
|
!output_file.exists(),
|
|
"Guardian-denied command unexpectedly executed"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[test_case(None; "legacy_fallback")]
|
|
#[test_case(Some("Acting model timeout instructions."); "catalog_override")]
|
|
#[test_case(Some(""); "empty_override")]
|
|
async fn guardian_timeout_rejects_tool_call_with_acting_model_instructions(
|
|
timeout_instructions: Option<&'static str>,
|
|
) -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_sandbox!(Ok(()));
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"Guardian approval actions require host-native paths"
|
|
);
|
|
|
|
struct TimedOutReviewContributor;
|
|
|
|
impl codex_extension_api::ApprovalReviewContributor for TimedOutReviewContributor {
|
|
fn decide<'a>(
|
|
&'a self,
|
|
input: &'a codex_extension_api::ApprovalDecisionInput<'_>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, Option<codex_extension_api::ApprovalDecision>>
|
|
{
|
|
assert_eq!(input.tool_call_id, Some("exec-call-timed-out"));
|
|
Box::pin(async {
|
|
Some(codex_extension_api::ApprovalDecision::Reviewed(
|
|
ReviewDecision::TimedOut,
|
|
))
|
|
})
|
|
}
|
|
}
|
|
|
|
let server = start_mock_server().await;
|
|
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
|
|
extensions.approval_review_contributor(Arc::new(TimedOutReviewContributor));
|
|
let mut builder = test_codex()
|
|
.with_extensions(Arc::new(extensions.build()))
|
|
.with_model_info_override("gpt-5.5", move |model| {
|
|
model
|
|
.model_messages
|
|
.as_mut()
|
|
.expect("acting model messages")
|
|
.auto_review = Some(AutoReviewMessages {
|
|
policy: None,
|
|
policy_template: None,
|
|
node_repl_policy: None,
|
|
rejection_instructions: None,
|
|
timeout_instructions: timeout_instructions.map(str::to_string),
|
|
});
|
|
})
|
|
.with_config(|config| {
|
|
let rules_dir = config.codex_home.join("rules");
|
|
fs::create_dir_all(&rules_dir).expect("create execution policy directory");
|
|
fs::write(
|
|
rules_dir.join("default.rules"),
|
|
r#"prefix_rule(pattern=["touch"], decision="prompt")"#,
|
|
)
|
|
.expect("write execution policy rule");
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
});
|
|
let test = builder.build_with_auto_env(&server).await?;
|
|
let output_file = test.cwd.path().join("guardian-timed-out.txt");
|
|
let tool_args = json!({
|
|
"cmd": format!("touch {}", output_file.display()),
|
|
"sandbox_permissions": SandboxPermissions::UseDefault,
|
|
});
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_function_call(
|
|
"exec-call-timed-out",
|
|
"exec_command",
|
|
&tool_args.to_string(),
|
|
),
|
|
ev_completed("parent-tool"),
|
|
]),
|
|
sse(vec![ev_completed("parent-complete")]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run a command whose approval review will time out".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
let requests = responses.requests();
|
|
let tool_output = requests
|
|
.iter()
|
|
.find_map(|request| request.function_call_output_text("exec-call-timed-out"))
|
|
.expect("expected timed-out tool output to be returned to the parent model");
|
|
assert_eq!(
|
|
tool_output.contains("did not finish before its deadline"),
|
|
timeout_instructions.is_none(),
|
|
"legacy timeout instructions should only be used when absent: {tool_output}"
|
|
);
|
|
if let Some(timeout_instructions) = timeout_instructions {
|
|
assert!(tool_output.contains(timeout_instructions));
|
|
}
|
|
assert!(!tool_output.contains("unacceptable risk"));
|
|
assert!(
|
|
!output_file.exists(),
|
|
"command whose approval timed out unexpectedly executed"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn cyber_model_guardian_denial_interrupts_turn_immediately() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_sandbox!(Ok(()));
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"Guardian approval actions require host-native paths"
|
|
);
|
|
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
|
|
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| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
});
|
|
let test = builder.build_with_auto_env(&server).await?;
|
|
|
|
let output_file = test.cwd.path().join("cyber-guardian-denied.txt");
|
|
let command = format!("printf should-not-run > {}", output_file.display());
|
|
let tool_args = json!({
|
|
"cmd": command,
|
|
"yield_time_ms": 1_000_u64,
|
|
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
|
"justification": "Exercise immediate Guardian interruption for cyber models.",
|
|
});
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-cyber-parent-tool-denied"),
|
|
ev_function_call(
|
|
"exec-cyber-call-denied",
|
|
"exec_command",
|
|
&serde_json::to_string(&tool_args)?,
|
|
),
|
|
ev_completed("resp-cyber-parent-tool-denied"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-cyber-guardian-denied"),
|
|
ev_assistant_message(
|
|
"msg-cyber-guardian-denied",
|
|
&json!({
|
|
"risk_level": "high",
|
|
"user_authorization": "low",
|
|
"outcome": "deny",
|
|
"rationale": "The requested command has unacceptable test risk.",
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-cyber-guardian-denied"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run a command that Guardian should deny for a cyber model".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
let warning = wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::GuardianWarning(warning)
|
|
if warning.message.contains("too many approval requests")
|
|
)
|
|
})
|
|
.await;
|
|
let EventMsg::GuardianWarning(warning) = warning else {
|
|
unreachable!("wait_for_event returned a non-warning event")
|
|
};
|
|
assert!(
|
|
warning
|
|
.message
|
|
.contains("1 consecutive, 1 in the last 50 reviews")
|
|
);
|
|
|
|
let aborted = wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnAborted(_))
|
|
})
|
|
.await;
|
|
let EventMsg::TurnAborted(aborted) = aborted else {
|
|
unreachable!("wait_for_event returned a non-abort event")
|
|
};
|
|
assert_eq!(aborted.reason, TurnAbortReason::Interrupted);
|
|
assert_eq!(responses.requests().len(), 2);
|
|
assert!(
|
|
!output_file.exists(),
|
|
"Guardian-denied cyber-model command unexpectedly executed"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_sandbox!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let approval_policy = AskForApproval::OnRequest;
|
|
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
|
|
let notify_dir = TempDir::new()?;
|
|
let notify_script = notify_dir.path().join("notify.sh");
|
|
fs::write(
|
|
¬ify_script,
|
|
r#"#!/bin/bash
|
|
set -e
|
|
payload_path="$(dirname "${0}")/notify.jsonl"
|
|
printf '%s\n' "${@: -1}" >> "${payload_path}""#,
|
|
)?;
|
|
fs::set_permissions(¬ify_script, fs::Permissions::from_mode(0o755))?;
|
|
let notify_file = notify_dir.path().join("notify.jsonl");
|
|
let notify_script_str = notify_script.to_str().unwrap().to_string();
|
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
|
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.notify = Some(vec![notify_script_str]);
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
|
.expect("set sandbox policy");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
let output_file = test.cwd.path().join("guardian-review-notify.txt");
|
|
let command = format!("printf guardian-approved > {}", output_file.display());
|
|
let tool_args = json!({
|
|
"cmd": command,
|
|
"yield_time_ms": 1_000_u64,
|
|
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
|
"justification": "Exercise Guardian approval routing.",
|
|
});
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-parent-tool"),
|
|
ev_function_call(
|
|
"exec-call",
|
|
"exec_command",
|
|
&serde_json::to_string(&tool_args)?,
|
|
),
|
|
ev_completed("resp-parent-tool"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-review"),
|
|
ev_assistant_message(
|
|
"msg-guardian-review",
|
|
&json!({
|
|
"risk_level": "low",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "The command writes a marker file in the workspace.",
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-guardian-review"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-parent-done"),
|
|
ev_assistant_message("msg-parent-done", "done"),
|
|
ev_completed("resp-parent-done"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(
|
|
TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "run a command that requires Guardian review".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
environments: Some(local_selections(test.config.cwd.clone())),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
sandbox_policy: Some(sandbox_policy),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
let guardian_request = responses
|
|
.requests()
|
|
.into_iter()
|
|
.find(|request| request.body_contains_text("Exercise Guardian approval routing."))
|
|
.expect("expected Guardian review request");
|
|
assert!(guardian_request.body_contains_text(&command));
|
|
|
|
fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?;
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
let notify_payload_raw = tokio::fs::read_to_string(¬ify_file).await?;
|
|
let payloads: Vec<Value> = notify_payload_raw
|
|
.lines()
|
|
.map(serde_json::from_str::<Value>)
|
|
.collect::<std::result::Result<_, _>>()?;
|
|
|
|
assert_eq!(
|
|
payloads.len(),
|
|
1,
|
|
"unexpected notify payloads: {payloads:?}"
|
|
);
|
|
assert_eq!(
|
|
payloads[0]["input-messages"],
|
|
json!(["run a command that requires Guardian review"])
|
|
);
|
|
assert_eq!(payloads[0]["last-assistant-message"], json!("done"));
|
|
assert!(
|
|
!notify_payload_raw.contains(
|
|
"The following is the Codex agent history whose request action you are assessing."
|
|
),
|
|
"Guardian review transcript leaked into legacy notify payload: {notify_payload_raw}"
|
|
);
|
|
assert_eq!(fs::read_to_string(&output_file)?, "guardian-approved");
|
|
|
|
Ok(())
|
|
}
|