mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add guardian approval MVP (#13692)
## Summary - add the guardian reviewer flow for `on-request` approvals in command, patch, sandbox-retry, and managed-network approval paths - keep guardian behind `features.guardian_approval` instead of exposing a public `approval_policy = guardian` mode - route ordinary `OnRequest` approvals to the guardian subagent when the feature is enabled, without changing the public approval-mode surface ## Public model - public approval modes stay unchanged - guardian is enabled via `features.guardian_approval` - when that feature is on, `approval_policy = on-request` keeps the same approval boundaries but sends those approval requests to the guardian reviewer instead of the user - `/experimental` only persists the feature flag; it does not rewrite `approval_policy` - CLI and app-server no longer expose a separate `guardian` approval mode in this PR ## Guardian reviewer - the reviewer runs as a normal subagent and reuses the existing subagent/thread machinery - it is locked to a read-only sandbox and `approval_policy = never` - it does not inherit user/project exec-policy rules - it prefers `gpt-5.4` when the current provider exposes it, otherwise falls back to the parent turn's active model - it fail-closes on timeout, startup failure, malformed output, or any other review error - it currently auto-approves only when `risk_score < 80` ## Review context and policy - guardian mirrors `OnRequest` approval semantics rather than introducing a separate approval policy - explicit `require_escalated` requests follow the same approval surface as `OnRequest`; the difference is only who reviews them - managed-network allowlist misses that enter the approval flow are also reviewed by guardian - the review prompt includes bounded recent transcript history plus recent tool call/result evidence - transcript entries and planned-action strings are truncated with explicit `<guardian_truncated ... />` markers so large payloads stay bounded - apply-patch reviews include the full patch content (without duplicating the structured `changes` payload) - the guardian request layout is snapshot-tested using the same model-visible Responses request formatter used elsewhere in core ## Guardian network behavior - the guardian subagent inherits the parent session's managed-network allowlist when one exists, so it can use the same approved network surface while reviewing - exact session-scoped network approvals are copied into the guardian session with protocol/port scope preserved - those copied approvals are now seeded before the guardian's first turn is submitted, so inherited approvals are available during any immediate review-time checks ## Out of scope / follow-ups - the sandbox-permission validation split was pulled into a separate PR and is not part of this diff - a future follow-up can enable `serde_json` preserve-order in `codex-core` and then simplify the guardian action rendering further --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
cf143bf71e
commit
e84ee33cc0
@@ -45,7 +45,7 @@ impl ToolHandler for McpHandler {
|
||||
|
||||
let response = handle_mcp_tool_call(
|
||||
Arc::clone(&session),
|
||||
turn.as_ref(),
|
||||
&turn,
|
||||
call_id.clone(),
|
||||
server,
|
||||
tool,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use crate::codex::Session;
|
||||
use crate::guardian::GUARDIAN_REJECTION_MESSAGE;
|
||||
use crate::guardian::GuardianReviewRequest;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::network_policy_decision::denied_network_policy_message;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use codex_network_proxy::BlockedRequest;
|
||||
@@ -17,6 +21,7 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
@@ -109,7 +114,8 @@ enum NetworkApprovalOutcome {
|
||||
DeniedByPolicy(String),
|
||||
}
|
||||
|
||||
fn allows_network_prompt(policy: AskForApproval) -> bool {
|
||||
/// Whether an allowlist miss may be reviewed instead of hard-denied.
|
||||
fn allows_network_approval_flow(policy: AskForApproval) -> bool {
|
||||
!matches!(policy, AskForApproval::Never)
|
||||
}
|
||||
|
||||
@@ -179,6 +185,12 @@ impl Default for NetworkApprovalService {
|
||||
}
|
||||
|
||||
impl NetworkApprovalService {
|
||||
pub(crate) async fn copy_session_approved_hosts_to(&self, other: &Self) {
|
||||
let approved_hosts = self.session_approved_hosts.lock().await;
|
||||
let mut other_approved_hosts = other.session_approved_hosts.lock().await;
|
||||
other_approved_hosts.extend(approved_hosts.iter().cloned());
|
||||
}
|
||||
|
||||
async fn register_call(&self, registration_id: String) {
|
||||
let mut active_calls = self.active_calls.lock().await;
|
||||
let key = registration_id.clone();
|
||||
@@ -266,7 +278,7 @@ impl NetworkApprovalService {
|
||||
|
||||
pub(crate) async fn handle_inline_policy_request(
|
||||
&self,
|
||||
session: &Session,
|
||||
session: Arc<Session>,
|
||||
request: NetworkPolicyRequest,
|
||||
) -> NetworkDecision {
|
||||
const REASON_NOT_ALLOWED: &str = "not_allowed";
|
||||
@@ -303,7 +315,7 @@ impl NetworkApprovalService {
|
||||
format!("Network access to \"{target}\" was blocked by policy.");
|
||||
let prompt_reason = format!("{} is not in the allowed_domains", request.host);
|
||||
|
||||
let Some(turn_context) = Self::active_turn_context(session).await else {
|
||||
let Some(turn_context) = Self::active_turn_context(session.as_ref()).await else {
|
||||
pending.set_decision(PendingApprovalDecision::Deny).await;
|
||||
let mut pending_approvals = self.pending_host_approvals.lock().await;
|
||||
pending_approvals.remove(&key);
|
||||
@@ -313,7 +325,7 @@ impl NetworkApprovalService {
|
||||
.await;
|
||||
return NetworkDecision::deny(REASON_NOT_ALLOWED);
|
||||
};
|
||||
if !allows_network_prompt(turn_context.approval_policy.value()) {
|
||||
if !allows_network_approval_flow(turn_context.approval_policy.value()) {
|
||||
pending.set_decision(PendingApprovalDecision::Deny).await;
|
||||
let mut pending_approvals = self.pending_host_approvals.lock().await;
|
||||
pending_approvals.remove(&key);
|
||||
@@ -324,28 +336,45 @@ impl NetworkApprovalService {
|
||||
return NetworkDecision::deny(REASON_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
let approval_id = Self::approval_id_for_key(&key);
|
||||
let prompt_command = vec!["network-access".to_string(), target.clone()];
|
||||
let network_approval_context = NetworkApprovalContext {
|
||||
host: request.host.clone(),
|
||||
protocol,
|
||||
};
|
||||
|
||||
let available_decisions = None;
|
||||
let approval_decision = session
|
||||
.request_command_approval(
|
||||
turn_context.as_ref(),
|
||||
approval_id,
|
||||
None,
|
||||
prompt_command,
|
||||
turn_context.cwd.clone(),
|
||||
Some(prompt_reason),
|
||||
Some(network_approval_context.clone()),
|
||||
None,
|
||||
None,
|
||||
available_decisions,
|
||||
let approval_decision = if routes_approval_to_guardian(&turn_context) {
|
||||
review_approval_request(
|
||||
&session,
|
||||
&turn_context,
|
||||
GuardianReviewRequest {
|
||||
action: json!({
|
||||
"tool": "network_access",
|
||||
"target": target,
|
||||
"host": request.host,
|
||||
"protocol": key.protocol,
|
||||
"port": key.port,
|
||||
}),
|
||||
},
|
||||
Some(policy_denial_message.clone()),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
} else {
|
||||
let approval_id = Self::approval_id_for_key(&key);
|
||||
let prompt_command = vec!["network-access".to_string(), target.clone()];
|
||||
let available_decisions = None;
|
||||
session
|
||||
.request_command_approval(
|
||||
turn_context.as_ref(),
|
||||
approval_id,
|
||||
None,
|
||||
prompt_command,
|
||||
turn_context.cwd.clone(),
|
||||
Some(prompt_reason),
|
||||
Some(network_approval_context.clone()),
|
||||
None,
|
||||
None,
|
||||
available_decisions,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
let mut cache_session_deny = false;
|
||||
let resolved = match approval_decision {
|
||||
@@ -423,8 +452,19 @@ impl NetworkApprovalService {
|
||||
}
|
||||
},
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByUser)
|
||||
if routes_approval_to_guardian(&turn_context) {
|
||||
self.record_outcome_for_single_active_call(
|
||||
NetworkApprovalOutcome::DeniedByPolicy(
|
||||
GUARDIAN_REJECTION_MESSAGE.to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
self.record_outcome_for_single_active_call(
|
||||
NetworkApprovalOutcome::DeniedByUser,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
PendingApprovalDecision::Deny
|
||||
}
|
||||
};
|
||||
@@ -478,7 +518,7 @@ pub(crate) fn build_network_policy_decider(
|
||||
return NetworkDecision::ask("not_allowed");
|
||||
};
|
||||
network_approval
|
||||
.handle_inline_policy_request(session.as_ref(), request)
|
||||
.handle_inline_policy_request(session, request)
|
||||
.await
|
||||
}
|
||||
})
|
||||
@@ -598,6 +638,64 @@ mod tests {
|
||||
assert!(!Arc::ptr_eq(&first, &second));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_approved_hosts_preserve_protocol_and_port_scope() {
|
||||
let source = NetworkApprovalService::default();
|
||||
{
|
||||
let mut approved_hosts = source.session_approved_hosts.lock().await;
|
||||
approved_hosts.extend([
|
||||
HostApprovalKey {
|
||||
host: "example.com".to_string(),
|
||||
protocol: "https",
|
||||
port: 443,
|
||||
},
|
||||
HostApprovalKey {
|
||||
host: "example.com".to_string(),
|
||||
protocol: "https",
|
||||
port: 8443,
|
||||
},
|
||||
HostApprovalKey {
|
||||
host: "example.com".to_string(),
|
||||
protocol: "http",
|
||||
port: 80,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
let seeded = NetworkApprovalService::default();
|
||||
source.copy_session_approved_hosts_to(&seeded).await;
|
||||
|
||||
let mut copied = seeded
|
||||
.session_approved_hosts
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
copied.sort_by(|a, b| (&a.host, a.protocol, a.port).cmp(&(&b.host, b.protocol, b.port)));
|
||||
|
||||
assert_eq!(
|
||||
copied,
|
||||
vec![
|
||||
HostApprovalKey {
|
||||
host: "example.com".to_string(),
|
||||
protocol: "http",
|
||||
port: 80,
|
||||
},
|
||||
HostApprovalKey {
|
||||
host: "example.com".to_string(),
|
||||
protocol: "https",
|
||||
port: 443,
|
||||
},
|
||||
HostApprovalKey {
|
||||
host: "example.com".to_string(),
|
||||
protocol: "https",
|
||||
port: 8443,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_waiters_receive_owner_decision() {
|
||||
let pending = Arc::new(PendingHostApproval::new());
|
||||
@@ -628,11 +726,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_policy_disables_network_prompts() {
|
||||
assert!(!allows_network_prompt(AskForApproval::Never));
|
||||
assert!(allows_network_prompt(AskForApproval::OnRequest));
|
||||
assert!(allows_network_prompt(AskForApproval::OnFailure));
|
||||
assert!(allows_network_prompt(AskForApproval::UnlessTrusted));
|
||||
fn only_never_policy_disables_network_approval_flow() {
|
||||
assert!(!allows_network_approval_flow(AskForApproval::Never));
|
||||
assert!(allows_network_approval_flow(AskForApproval::OnRequest));
|
||||
assert!(allows_network_approval_flow(AskForApproval::OnFailure));
|
||||
assert!(allows_network_approval_flow(AskForApproval::UnlessTrusted));
|
||||
}
|
||||
|
||||
fn denied_blocked_request(host: &str) -> BlockedRequest {
|
||||
|
||||
@@ -10,6 +10,8 @@ use crate::error::CodexErr;
|
||||
use crate::error::SandboxErr;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::features::Feature;
|
||||
use crate::guardian::GUARDIAN_REJECTION_MESSAGE;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::network_policy_decision::network_approval_context_from_payload;
|
||||
use crate::sandboxing::SandboxManager;
|
||||
use crate::tools::network_approval::DeferredNetworkApproval;
|
||||
@@ -130,7 +132,7 @@ impl ToolOrchestrator {
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
|
||||
let approval_ctx = ApprovalCtx {
|
||||
session: &tool_ctx.session,
|
||||
turn: turn_ctx,
|
||||
turn: &tool_ctx.turn,
|
||||
call_id: &tool_ctx.call_id,
|
||||
retry_reason: reason,
|
||||
network_approval_context: None,
|
||||
@@ -141,7 +143,12 @@ impl ToolOrchestrator {
|
||||
|
||||
match decision {
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
return Err(ToolError::Rejected("rejected by user".to_string()));
|
||||
let reason = if routes_approval_to_guardian(turn_ctx) {
|
||||
GUARDIAN_REJECTION_MESSAGE.to_string()
|
||||
} else {
|
||||
"rejected by user".to_string()
|
||||
};
|
||||
return Err(ToolError::Rejected(reason));
|
||||
}
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
@@ -232,8 +239,9 @@ impl ToolOrchestrator {
|
||||
network_policy_decision,
|
||||
})));
|
||||
}
|
||||
// Under `Never` or `OnRequest`, do not retry without sandbox; surface a concise
|
||||
// sandbox denial that preserves the original output.
|
||||
// Under `Never` or `OnRequest`, do not retry without sandbox;
|
||||
// surface a concise sandbox denial that preserves the
|
||||
// original output.
|
||||
if !tool.wants_no_sandbox_approval(approval_policy) {
|
||||
let allow_on_request_network_prompt =
|
||||
matches!(approval_policy, AskForApproval::OnRequest)
|
||||
@@ -269,7 +277,7 @@ impl ToolOrchestrator {
|
||||
if !bypass_retry_approval {
|
||||
let approval_ctx = ApprovalCtx {
|
||||
session: &tool_ctx.session,
|
||||
turn: turn_ctx,
|
||||
turn: &tool_ctx.turn,
|
||||
call_id: &tool_ctx.call_id,
|
||||
retry_reason: Some(retry_reason),
|
||||
network_approval_context: network_approval_context.clone(),
|
||||
@@ -280,7 +288,12 @@ impl ToolOrchestrator {
|
||||
|
||||
match decision {
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
return Err(ToolError::Rejected("rejected by user".to_string()));
|
||||
let reason = if routes_approval_to_guardian(turn_ctx) {
|
||||
GUARDIAN_REJECTION_MESSAGE.to_string()
|
||||
} else {
|
||||
"rejected by user".to_string()
|
||||
};
|
||||
return Err(ToolError::Rejected(reason));
|
||||
}
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
//! `codex --codex-run-as-apply-patch`, and runs under the current
|
||||
//! `SandboxAttempt` with a minimal environment.
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::guardian::GuardianReviewRequest;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::sandboxing::CommandSpec;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::sandboxing::execute_env;
|
||||
@@ -25,6 +28,7 @@ use codex_protocol::protocol::FileChange;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -46,6 +50,18 @@ impl ApplyPatchRuntime {
|
||||
Self
|
||||
}
|
||||
|
||||
fn build_guardian_review_request(req: &ApplyPatchRequest) -> GuardianReviewRequest {
|
||||
GuardianReviewRequest {
|
||||
action: json!({
|
||||
"tool": "apply_patch",
|
||||
"cwd": req.action.cwd,
|
||||
"files": req.file_paths,
|
||||
"change_count": req.changes.len(),
|
||||
"patch": req.action.patch,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command_spec(
|
||||
req: &ApplyPatchRequest,
|
||||
_codex_home: &std::path::Path,
|
||||
@@ -118,6 +134,10 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
|
||||
let approval_keys = self.approval_keys(req);
|
||||
let changes = req.changes.clone();
|
||||
Box::pin(async move {
|
||||
if routes_approval_to_guardian(turn) {
|
||||
let request = ApplyPatchRuntime::build_guardian_review_request(req);
|
||||
return review_approval_request(session, turn, request, retry_reason).await;
|
||||
}
|
||||
if let Some(reason) = retry_reason {
|
||||
let rx_approve = session
|
||||
.request_patch_approval(turn, call_id, changes.clone(), Some(reason), None)
|
||||
@@ -184,6 +204,8 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::RejectConfig;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn wants_no_sandbox_approval_reject_respects_sandbox_flag() {
|
||||
@@ -204,4 +226,45 @@ mod tests {
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_request_includes_full_patch_without_duplicate_changes() {
|
||||
let path = std::env::temp_dir().join("guardian-apply-patch-test.txt");
|
||||
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
|
||||
let expected_cwd = action.cwd.clone();
|
||||
let expected_patch = action.patch.clone();
|
||||
let request = ApplyPatchRequest {
|
||||
action,
|
||||
file_paths: vec![
|
||||
AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"),
|
||||
],
|
||||
changes: HashMap::from([(
|
||||
path,
|
||||
FileChange::Add {
|
||||
content: "hello".to_string(),
|
||||
},
|
||||
)]),
|
||||
exec_approval_requirement: ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
timeout_ms: None,
|
||||
codex_exe: None,
|
||||
};
|
||||
|
||||
let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request);
|
||||
|
||||
assert_eq!(
|
||||
guardian_request,
|
||||
GuardianReviewRequest {
|
||||
action: json!({
|
||||
"tool": "apply_patch",
|
||||
"cwd": expected_cwd,
|
||||
"files": request.file_paths,
|
||||
"change_count": 1usize,
|
||||
"patch": expected_patch,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ pub(crate) mod zsh_fork_backend;
|
||||
use crate::command_canonicalization::canonicalize_command_for_approval;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::features::Feature;
|
||||
use crate::guardian::GuardianReviewRequest;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::powershell::prefix_powershell_script_with_utf8;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::sandboxing::execute_env;
|
||||
@@ -35,6 +38,7 @@ use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -149,6 +153,26 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
let turn = ctx.turn;
|
||||
let call_id = ctx.call_id.to_string();
|
||||
Box::pin(async move {
|
||||
if routes_approval_to_guardian(turn) {
|
||||
let mut action = json!({
|
||||
"tool": "shell",
|
||||
"command": command,
|
||||
"cwd": cwd,
|
||||
"sandbox_permissions": req.sandbox_permissions,
|
||||
"additional_permissions": req.additional_permissions,
|
||||
"justification": reason,
|
||||
});
|
||||
if let Some(action) = action.as_object_mut() {
|
||||
if req.additional_permissions.is_none() {
|
||||
action.remove("additional_permissions");
|
||||
}
|
||||
if reason.is_none() {
|
||||
action.remove("justification");
|
||||
}
|
||||
}
|
||||
let request = GuardianReviewRequest { action };
|
||||
return review_approval_request(session, turn, request, None).await;
|
||||
}
|
||||
with_cached_approval(&session.services, "shell", keys, move || async move {
|
||||
let available_decisions = None;
|
||||
session
|
||||
|
||||
@@ -7,6 +7,9 @@ use crate::exec::SandboxType;
|
||||
use crate::exec::is_likely_sandbox_denied;
|
||||
use crate::exec_policy::prompt_is_rejected_by_policy;
|
||||
use crate::features::Feature;
|
||||
use crate::guardian::GuardianReviewRequest;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::sandboxing::ExecRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::ShellType;
|
||||
@@ -46,6 +49,7 @@ use codex_shell_escalation::PreparedExec;
|
||||
use codex_shell_escalation::ShellCommandExecutor;
|
||||
use codex_shell_escalation::Stopwatch;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -163,6 +167,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
session: Arc::clone(&ctx.session),
|
||||
turn: Arc::clone(&ctx.turn),
|
||||
call_id: ctx.call_id.clone(),
|
||||
tool_name: "shell",
|
||||
approval_policy: ctx.turn.approval_policy.value(),
|
||||
sandbox_policy: command_executor.sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: command_executor.file_system_sandbox_policy.clone(),
|
||||
@@ -262,6 +267,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
session: Arc::clone(&ctx.session),
|
||||
turn: Arc::clone(&ctx.turn),
|
||||
call_id: ctx.call_id.clone(),
|
||||
tool_name: "exec_command",
|
||||
approval_policy: ctx.turn.approval_policy.value(),
|
||||
sandbox_policy: exec_request.sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(),
|
||||
@@ -292,6 +298,7 @@ struct CoreShellActionProvider {
|
||||
session: Arc<crate::codex::Session>,
|
||||
turn: Arc<crate::codex::TurnContext>,
|
||||
call_id: String,
|
||||
tool_name: &'static str,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
file_system_sandbox_policy: FileSystemSandboxPolicy,
|
||||
@@ -374,8 +381,21 @@ impl CoreShellActionProvider {
|
||||
let turn = self.turn.clone();
|
||||
let call_id = self.call_id.clone();
|
||||
let approval_id = Some(Uuid::new_v4().to_string());
|
||||
let tool_name = self.tool_name;
|
||||
Ok(stopwatch
|
||||
.pause_for(async move {
|
||||
if routes_approval_to_guardian(&turn) {
|
||||
let request = GuardianReviewRequest {
|
||||
action: json!({
|
||||
"tool": tool_name,
|
||||
"program": program,
|
||||
"argv": argv,
|
||||
"cwd": workdir,
|
||||
"additional_permissions": additional_permissions,
|
||||
}),
|
||||
};
|
||||
return review_approval_request(&session, &turn, request, None).await;
|
||||
}
|
||||
let available_decisions = vec![
|
||||
Some(ReviewDecision::Approved),
|
||||
// Currently, ApprovedForSession is only honored for skills,
|
||||
|
||||
@@ -9,6 +9,9 @@ use crate::error::CodexErr;
|
||||
use crate::error::SandboxErr;
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::features::Feature;
|
||||
use crate::guardian::GuardianReviewRequest;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::powershell::prefix_powershell_script_with_utf8;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::ShellType;
|
||||
@@ -38,6 +41,7 @@ use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -114,6 +118,27 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
.clone()
|
||||
.or_else(|| req.justification.clone());
|
||||
Box::pin(async move {
|
||||
if routes_approval_to_guardian(turn) {
|
||||
let mut action = json!({
|
||||
"tool": "exec_command",
|
||||
"command": command,
|
||||
"cwd": cwd,
|
||||
"sandbox_permissions": req.sandbox_permissions,
|
||||
"additional_permissions": req.additional_permissions,
|
||||
"justification": reason,
|
||||
"tty": req.tty,
|
||||
});
|
||||
if let Some(action) = action.as_object_mut() {
|
||||
if req.additional_permissions.is_none() {
|
||||
action.remove("additional_permissions");
|
||||
}
|
||||
if reason.is_none() {
|
||||
action.remove("justification");
|
||||
}
|
||||
}
|
||||
let request = GuardianReviewRequest { action };
|
||||
return review_approval_request(session, turn, request, None).await;
|
||||
}
|
||||
with_cached_approval(&session.services, "unified_exec", keys, || async move {
|
||||
let available_decisions = None;
|
||||
session
|
||||
|
||||
@@ -111,8 +111,8 @@ where
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ApprovalCtx<'a> {
|
||||
pub session: &'a Session,
|
||||
pub turn: &'a TurnContext,
|
||||
pub session: &'a Arc<Session>,
|
||||
pub turn: &'a Arc<TurnContext>,
|
||||
pub call_id: &'a str,
|
||||
pub retry_reason: Option<String>,
|
||||
pub network_approval_context: Option<NetworkApprovalContext>,
|
||||
@@ -445,4 +445,18 @@ mod tests {
|
||||
SandboxOverride::BypassSandboxFirstAttempt
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_bypasses_sandbox_for_explicit_escalation_on_first_attempt() {
|
||||
assert_eq!(
|
||||
sandbox_override_for_first_attempt(
|
||||
SandboxPermissions::RequireEscalated,
|
||||
&ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
),
|
||||
SandboxOverride::BypassSandboxFirstAttempt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user