Compare commits

...

1 Commits

Author SHA1 Message Date
Abhinav Vedmala
303f9ea525 Centralize approval routing
Co-authored-by: Codex <noreply@openai.com>
2026-04-12 18:26:53 -07:00
11 changed files with 777 additions and 347 deletions

View File

@@ -0,0 +1,430 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::GuardianCommandSource;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::ReviewDecision;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ApprovalOutcome {
pub decision: ReviewDecision,
pub guardian_review_id: Option<String>,
}
impl ApprovalOutcome {
pub(crate) fn new(decision: ReviewDecision) -> Self {
Self {
decision,
guardian_review_id: None,
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct ApprovalCache {
tool_name: &'static str,
serialized_keys: Vec<String>,
key_count: usize,
}
impl ApprovalCache {
pub(crate) fn new<K>(tool_name: &'static str, keys: Vec<K>) -> Self
where
K: Serialize,
{
let key_count = keys.len();
let serialized_keys = keys
.into_iter()
.filter_map(|key| serde_json::to_string(&key).ok())
.collect();
Self {
tool_name,
serialized_keys,
key_count,
}
}
fn has_keys(&self) -> bool {
self.key_count > 0
}
fn all_keys_serialized(&self) -> bool {
self.serialized_keys.len() == self.key_count
}
async fn is_approved_for_session(&self, session: &Session) -> bool {
if !self.has_keys() || !self.all_keys_serialized() {
return false;
}
let store = session.services.tool_approvals.lock().await;
self.serialized_keys.iter().all(|key| {
matches!(
store.get_serialized(key),
Some(ReviewDecision::ApprovedForSession)
)
})
}
async fn record_outcome(&self, session: &Session, outcome: &ApprovalOutcome) {
if !self.has_keys() {
return;
}
session.services.session_telemetry.counter(
"codex.approval.requested",
/*inc*/ 1,
&[
("tool", self.tool_name),
("approved", outcome.decision.to_opaque_string()),
],
);
if matches!(outcome.decision, ReviewDecision::ApprovedForSession) {
let mut store = session.services.tool_approvals.lock().await;
for key in &self.serialized_keys {
store.put_serialized(key.clone(), ReviewDecision::ApprovedForSession);
}
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct ApprovalRequest {
pub intent: ApprovalIntent,
pub retry_reason: Option<String>,
pub cache: Option<ApprovalCache>,
}
#[derive(Clone, Debug)]
pub(crate) enum ApprovalIntent {
Shell(ShellApprovalRequest),
UnifiedExec(UnifiedExecApprovalRequest),
ApplyPatch(ApplyPatchApprovalRequest),
NetworkAccess(NetworkAccessApprovalRequest),
#[cfg(unix)]
Execve(ExecveApprovalRequest),
}
#[derive(Clone, Debug)]
pub(crate) struct ShellApprovalRequest {
pub call_id: String,
pub command: Vec<String>,
pub cwd: PathBuf,
pub sandbox_permissions: crate::sandboxing::SandboxPermissions,
pub additional_permissions: Option<PermissionProfile>,
pub justification: Option<String>,
pub reason: Option<String>,
pub network_approval_context: Option<NetworkApprovalContext>,
pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
}
#[derive(Clone, Debug)]
pub(crate) struct UnifiedExecApprovalRequest {
pub call_id: String,
pub command: Vec<String>,
pub cwd: PathBuf,
pub sandbox_permissions: crate::sandboxing::SandboxPermissions,
pub additional_permissions: Option<PermissionProfile>,
pub justification: Option<String>,
pub tty: bool,
pub reason: Option<String>,
pub network_approval_context: Option<NetworkApprovalContext>,
pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
}
#[derive(Clone, Debug)]
pub(crate) struct ApplyPatchApprovalRequest {
pub call_id: String,
pub cwd: PathBuf,
pub files: Vec<AbsolutePathBuf>,
pub patch: String,
pub changes: HashMap<PathBuf, FileChange>,
pub reason: Option<String>,
pub grant_root: Option<PathBuf>,
}
#[derive(Clone, Debug)]
pub(crate) struct NetworkAccessApprovalRequest {
pub call_id: String,
pub turn_id: String,
pub target: String,
pub host: String,
pub protocol: NetworkApprovalProtocol,
pub port: u16,
pub command: Vec<String>,
pub cwd: PathBuf,
pub reason: Option<String>,
pub network_approval_context: NetworkApprovalContext,
}
#[cfg(unix)]
#[derive(Clone, Debug)]
pub(crate) struct ExecveApprovalRequest {
pub call_id: String,
pub approval_id: Option<String>,
pub source: GuardianCommandSource,
pub program: String,
pub argv: Vec<String>,
pub command: Vec<String>,
pub cwd: PathBuf,
pub additional_permissions: Option<PermissionProfile>,
pub reason: Option<String>,
pub available_decisions: Vec<ReviewDecision>,
}
pub(crate) async fn request_approval(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
request: ApprovalRequest,
) -> ApprovalOutcome {
let routes_to_guardian = routes_approval_to_guardian(turn);
if routes_to_guardian {
return request_guardian_approval(session, turn, request).await;
}
let ApprovalRequest {
intent,
retry_reason,
cache,
} = request;
if should_consult_session_cache(routes_to_guardian, retry_reason.as_ref(), cache.as_ref())
&& let Some(cache) = &cache
&& cache.is_approved_for_session(session).await
{
return ApprovalOutcome::new(ReviewDecision::ApprovedForSession);
}
let decision = request_user_approval(session, turn, intent).await;
let outcome = ApprovalOutcome::new(decision);
if let Some(cache) = &cache {
cache.record_outcome(session, &outcome).await;
}
outcome
}
fn should_consult_session_cache(
routes_to_guardian: bool,
retry_reason: Option<&String>,
cache: Option<&ApprovalCache>,
) -> bool {
!routes_to_guardian && retry_reason.is_none() && cache.is_some()
}
async fn request_guardian_approval(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
request: ApprovalRequest,
) -> ApprovalOutcome {
let guardian_review_id = new_guardian_review_id();
let decision = review_approval_request(
session,
turn,
guardian_review_id.clone(),
request.intent.into_guardian_request(),
request.retry_reason,
)
.await;
ApprovalOutcome {
decision,
guardian_review_id: Some(guardian_review_id),
}
}
async fn request_user_approval(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
intent: ApprovalIntent,
) -> ReviewDecision {
match intent.into_user_request() {
UserApprovalRequest::Command(command) => {
let CommandApprovalRequest {
call_id,
approval_id,
command,
cwd,
reason,
network_approval_context,
proposed_execpolicy_amendment,
additional_permissions,
available_decisions,
} = command;
session
.request_command_approval(
turn,
call_id,
approval_id,
command,
cwd,
reason,
network_approval_context,
proposed_execpolicy_amendment,
additional_permissions,
available_decisions,
)
.await
}
UserApprovalRequest::Patch(patch) => {
let PatchApprovalRequest {
call_id,
changes,
reason,
grant_root,
} = patch;
session
.request_patch_approval(turn, call_id, changes, reason, grant_root)
.await
.await
.unwrap_or_default()
}
}
}
#[derive(Debug, PartialEq)]
enum UserApprovalRequest {
Command(CommandApprovalRequest),
Patch(PatchApprovalRequest),
}
#[derive(Debug, PartialEq)]
struct CommandApprovalRequest {
call_id: String,
approval_id: Option<String>,
command: Vec<String>,
cwd: PathBuf,
reason: Option<String>,
network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
additional_permissions: Option<PermissionProfile>,
available_decisions: Option<Vec<ReviewDecision>>,
}
#[derive(Debug, PartialEq)]
struct PatchApprovalRequest {
call_id: String,
changes: HashMap<PathBuf, FileChange>,
reason: Option<String>,
grant_root: Option<PathBuf>,
}
impl ApprovalIntent {
fn into_guardian_request(self) -> GuardianApprovalRequest {
match self {
Self::Shell(shell) => GuardianApprovalRequest::Shell {
id: shell.call_id,
command: shell.command,
cwd: shell.cwd,
sandbox_permissions: shell.sandbox_permissions,
additional_permissions: shell.additional_permissions,
justification: shell.justification,
},
Self::UnifiedExec(exec) => GuardianApprovalRequest::ExecCommand {
id: exec.call_id,
command: exec.command,
cwd: exec.cwd,
sandbox_permissions: exec.sandbox_permissions,
additional_permissions: exec.additional_permissions,
justification: exec.justification,
tty: exec.tty,
},
Self::ApplyPatch(patch) => GuardianApprovalRequest::ApplyPatch {
id: patch.call_id,
cwd: patch.cwd,
files: patch.files,
patch: patch.patch,
},
Self::NetworkAccess(network) => GuardianApprovalRequest::NetworkAccess {
id: network.call_id,
turn_id: network.turn_id,
target: network.target,
host: network.host,
protocol: network.protocol,
port: network.port,
},
#[cfg(unix)]
Self::Execve(execve) => GuardianApprovalRequest::Execve {
id: execve.call_id,
source: execve.source,
program: execve.program,
argv: execve.argv,
cwd: execve.cwd,
additional_permissions: execve.additional_permissions,
},
}
}
fn into_user_request(self) -> UserApprovalRequest {
match self {
Self::Shell(shell) => UserApprovalRequest::Command(CommandApprovalRequest {
call_id: shell.call_id,
approval_id: None,
command: shell.command,
cwd: shell.cwd,
reason: shell.reason,
network_approval_context: shell.network_approval_context,
proposed_execpolicy_amendment: shell.proposed_execpolicy_amendment,
additional_permissions: shell.additional_permissions,
available_decisions: None,
}),
Self::UnifiedExec(exec) => UserApprovalRequest::Command(CommandApprovalRequest {
call_id: exec.call_id,
approval_id: None,
command: exec.command,
cwd: exec.cwd,
reason: exec.reason,
network_approval_context: exec.network_approval_context,
proposed_execpolicy_amendment: exec.proposed_execpolicy_amendment,
additional_permissions: exec.additional_permissions,
available_decisions: None,
}),
Self::ApplyPatch(patch) => UserApprovalRequest::Patch(PatchApprovalRequest {
call_id: patch.call_id,
changes: patch.changes,
reason: patch.reason,
grant_root: patch.grant_root,
}),
Self::NetworkAccess(network) => UserApprovalRequest::Command(CommandApprovalRequest {
call_id: network.call_id,
approval_id: None,
command: network.command,
cwd: network.cwd,
reason: network.reason,
network_approval_context: Some(network.network_approval_context),
proposed_execpolicy_amendment: None,
additional_permissions: None,
available_decisions: None,
}),
#[cfg(unix)]
Self::Execve(execve) => UserApprovalRequest::Command(CommandApprovalRequest {
call_id: execve.call_id,
approval_id: execve.approval_id,
command: execve.command,
cwd: execve.cwd,
reason: execve.reason,
network_approval_context: None,
proposed_execpolicy_amendment: None,
additional_permissions: execve.additional_permissions,
available_decisions: Some(execve.available_decisions),
}),
}
}
}
#[cfg(test)]
#[path = "approval_router_tests.rs"]
mod tests;

View File

@@ -0,0 +1,157 @@
use super::*;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::protocol::ReviewDecision;
use core_test_support::PathBufExt;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
#[tokio::test]
async fn guardian_route_bypasses_session_cache() {
let (_session, mut turn) = crate::codex::make_session_and_context().await;
let mut config = (*turn.config).clone();
config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent;
turn.config = Arc::new(config);
let request = ApprovalRequest {
intent: ApprovalIntent::Shell(ShellApprovalRequest {
call_id: "call-1".to_string(),
command: vec!["echo".to_string(), "hello".to_string()],
cwd: turn.cwd.to_path_buf(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("need output".to_string()),
reason: Some("need output".to_string()),
network_approval_context: None,
proposed_execpolicy_amendment: None,
}),
retry_reason: None,
cache: Some(ApprovalCache::new("shell", vec!["cached-key"])),
};
assert!(!should_consult_session_cache(
routes_approval_to_guardian(&turn),
request.retry_reason.as_ref(),
request.cache.as_ref(),
));
}
#[test]
fn shell_intent_translates_to_guardian_and_user_requests() {
let shell = ShellApprovalRequest {
call_id: "call-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd: PathBuf::from("/repo"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("inspect repo".to_string()),
reason: Some("inspect repo".to_string()),
network_approval_context: None,
proposed_execpolicy_amendment: None,
};
assert_eq!(
ApprovalIntent::Shell(shell.clone()).into_guardian_request(),
GuardianApprovalRequest::Shell {
id: "call-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd: PathBuf::from("/repo"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("inspect repo".to_string()),
}
);
assert_eq!(
ApprovalIntent::Shell(shell).into_user_request(),
UserApprovalRequest::Command(CommandApprovalRequest {
call_id: "call-1".to_string(),
approval_id: None,
command: vec!["git".to_string(), "status".to_string()],
cwd: PathBuf::from("/repo"),
reason: Some("inspect repo".to_string()),
network_approval_context: None,
proposed_execpolicy_amendment: None,
additional_permissions: None,
available_decisions: None,
})
);
}
#[test]
fn apply_patch_intent_translates_to_guardian_and_user_requests() {
let path = std::env::temp_dir()
.join("approval-router-apply-patch.txt")
.abs();
let patch = ApplyPatchApprovalRequest {
call_id: "call-1".to_string(),
cwd: path.parent().expect("parent").to_path_buf(),
files: vec![path.clone()],
patch: "*** Begin Patch\n*** Add File: approval-router-apply-patch.txt\n+hello\n*** End Patch\n"
.to_string(),
changes: HashMap::from([(
path.to_path_buf(),
FileChange::Add {
content: "hello".to_string(),
},
)]),
reason: Some("apply patch".to_string()),
grant_root: None,
};
assert_eq!(
ApprovalIntent::ApplyPatch(patch.clone()).into_guardian_request(),
GuardianApprovalRequest::ApplyPatch {
id: "call-1".to_string(),
cwd: patch.cwd.clone(),
files: vec![path.clone()],
patch: patch.patch.clone(),
}
);
assert_eq!(
ApprovalIntent::ApplyPatch(patch).into_user_request(),
UserApprovalRequest::Patch(PatchApprovalRequest {
call_id: "call-1".to_string(),
changes: HashMap::from([(
path.to_path_buf(),
FileChange::Add {
content: "hello".to_string(),
},
)]),
reason: Some("apply patch".to_string()),
grant_root: None,
})
);
}
#[cfg(unix)]
#[test]
fn execve_intent_preserves_available_decisions_for_user_prompt() {
let execve = ExecveApprovalRequest {
call_id: "call-1".to_string(),
approval_id: Some("approval-1".to_string()),
source: codex_protocol::approvals::GuardianCommandSource::Shell,
program: "/bin/ls".to_string(),
argv: vec!["-l".to_string()],
command: vec!["/bin/ls".to_string(), "-l".to_string()],
cwd: PathBuf::from("/tmp"),
additional_permissions: None,
reason: None,
available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort],
};
assert_eq!(
ApprovalIntent::Execve(execve).into_user_request(),
UserApprovalRequest::Command(CommandApprovalRequest {
call_id: "call-1".to_string(),
approval_id: Some("approval-1".to_string()),
command: vec!["/bin/ls".to_string(), "-l".to_string()],
cwd: PathBuf::from("/tmp"),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
additional_permissions: None,
available_decisions: Some(vec![ReviewDecision::Approved, ReviewDecision::Abort,]),
})
);
}

View File

@@ -1,3 +1,4 @@
pub(crate) mod approval_router;
pub(crate) mod code_mode;
pub(crate) mod context;
pub(crate) mod events;

View File

@@ -1,11 +1,11 @@
use crate::codex::Session;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
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::approval_router::ApprovalIntent;
use crate::tools::approval_router::ApprovalRequest;
use crate::tools::approval_router::NetworkAccessApprovalRequest;
use crate::tools::approval_router::request_approval;
use crate::tools::sandboxing::ToolError;
use codex_network_proxy::BlockedRequest;
use codex_network_proxy::BlockedRequestObserver;
@@ -371,15 +371,13 @@ impl NetworkApprovalService {
};
let owner_call = self.resolve_single_active_call().await;
let guardian_approval_id = Self::approval_id_for_key(&key);
let use_guardian = routes_approval_to_guardian(&turn_context);
let guardian_review_id = use_guardian.then(new_guardian_review_id);
let approval_decision = if let Some(review_id) = guardian_review_id.clone() {
review_approval_request(
&session,
&turn_context,
review_id,
GuardianApprovalRequest::NetworkAccess {
id: guardian_approval_id.clone(),
let prompt_command = vec!["network-access".to_string(), target.clone()];
let outcome = request_approval(
&session,
&turn_context,
ApprovalRequest {
intent: ApprovalIntent::NetworkAccess(NetworkAccessApprovalRequest {
call_id: guardian_approval_id.clone(),
turn_id: owner_call
.as_ref()
.map_or_else(|| turn_context.sub_id.clone(), |call| call.turn_id.clone()),
@@ -387,29 +385,18 @@ impl NetworkApprovalService {
host: request.host,
protocol,
port: key.port,
},
Some(policy_denial_message.clone()),
)
.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,
/*approval_id*/ None,
prompt_command,
turn_context.cwd.to_path_buf(),
Some(prompt_reason),
Some(network_approval_context.clone()),
/*proposed_execpolicy_amendment*/ None,
/*additional_permissions*/ None,
available_decisions,
)
.await
};
command: prompt_command,
cwd: turn_context.cwd.to_path_buf(),
reason: Some(prompt_reason),
network_approval_context: network_approval_context.clone(),
}),
retry_reason: Some(policy_denial_message.clone()),
cache: None,
},
)
.await;
let guardian_review_id = outcome.guardian_review_id;
let approval_decision = outcome.decision;
let mut cache_session_deny = false;
let resolved = match approval_decision {

View File

@@ -8,8 +8,6 @@ caching).
*/
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::routes_approval_to_guardian;
use crate::network_policy_decision::network_approval_context_from_payload;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::NetworkApprovalMode;
@@ -117,7 +115,6 @@ impl ToolOrchestrator {
let otel_user = ToolDecisionSource::User;
let otel_automated_reviewer = ToolDecisionSource::AutomatedReviewer;
let otel_cfg = ToolDecisionSource::Config;
let use_guardian = routes_approval_to_guardian(turn_ctx);
// 1) Approval
let mut already_approved = false;
@@ -133,17 +130,16 @@ impl ToolOrchestrator {
return Err(ToolError::Rejected(reason));
}
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
let guardian_review_id = use_guardian.then(new_guardian_review_id);
let approval_ctx = ApprovalCtx {
session: &tool_ctx.session,
turn: &tool_ctx.turn,
call_id: &tool_ctx.call_id,
guardian_review_id: guardian_review_id.clone(),
retry_reason: reason,
network_approval_context: None,
};
let decision = tool.start_approval_async(req, approval_ctx).await;
let otel_source = if use_guardian {
let outcome = tool.start_approval_async(req, approval_ctx).await;
let decision = outcome.decision;
let otel_source = if outcome.guardian_review_id.is_some() {
otel_automated_reviewer.clone()
} else {
otel_user.clone()
@@ -153,7 +149,8 @@ impl ToolOrchestrator {
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
let reason = if let Some(review_id) = outcome.guardian_review_id.as_deref()
{
guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await
} else {
"rejected by user".to_string()
@@ -291,18 +288,17 @@ impl ToolOrchestrator {
.should_bypass_approval(approval_policy, already_approved)
&& network_approval_context.is_none();
if !bypass_retry_approval {
let guardian_review_id = use_guardian.then(new_guardian_review_id);
let approval_ctx = ApprovalCtx {
session: &tool_ctx.session,
turn: &tool_ctx.turn,
call_id: &tool_ctx.call_id,
guardian_review_id: guardian_review_id.clone(),
retry_reason: Some(retry_reason),
network_approval_context: network_approval_context.clone(),
};
let decision = tool.start_approval_async(req, approval_ctx).await;
let otel_source = if use_guardian {
let outcome = tool.start_approval_async(req, approval_ctx).await;
let decision = outcome.decision;
let otel_source = if outcome.guardian_review_id.is_some() {
otel_automated_reviewer
} else {
otel_user
@@ -311,12 +307,13 @@ impl ToolOrchestrator {
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id)
.await
} else {
"rejected by user".to_string()
};
let reason =
if let Some(review_id) = outcome.guardian_review_id.as_deref() {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id)
.await
} else {
"rejected by user".to_string()
};
return Err(ToolError::Rejected(reason));
}
ReviewDecision::TimedOut => {

View File

@@ -6,10 +6,14 @@
//! for `codex --codex-run-as-apply-patch` and runs it under the current
//! `SandboxAttempt` with a minimal environment for local turns.
use crate::exec::ExecCapturePolicy;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::execute_env;
use crate::tools::approval_router::ApplyPatchApprovalRequest;
use crate::tools::approval_router::ApprovalCache;
use crate::tools::approval_router::ApprovalIntent;
use crate::tools::approval_router::ApprovalOutcome;
use crate::tools::approval_router::ApprovalRequest;
use crate::tools::approval_router::request_approval;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
@@ -18,7 +22,6 @@ use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::with_cached_approval;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
use codex_protocol::exec_output::ExecToolCallOutput;
@@ -54,18 +57,6 @@ impl ApplyPatchRuntime {
Self
}
fn build_guardian_review_request(
req: &ApplyPatchRequest,
call_id: &str,
) -> GuardianApprovalRequest {
GuardianApprovalRequest::ApplyPatch {
id: call_id.to_string(),
cwd: req.action.cwd.to_path_buf(),
files: req.file_paths.clone(),
patch: req.action.patch.clone(),
}
}
#[cfg(target_os = "windows")]
fn build_sandbox_command(
req: &ApplyPatchRequest,
@@ -129,57 +120,61 @@ impl Sandboxable for ApplyPatchRuntime {
}
impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
type ApprovalKey = AbsolutePathBuf;
fn approval_keys(&self, req: &ApplyPatchRequest) -> Vec<Self::ApprovalKey> {
req.file_paths.clone()
fn approval_cache(&self, req: &ApplyPatchRequest) -> Option<ApprovalCache> {
Some(ApprovalCache::new("apply_patch", req.file_paths.clone()))
}
fn start_approval_async<'a>(
&'a mut self,
req: &'a ApplyPatchRequest,
ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision> {
) -> BoxFuture<'a, ApprovalOutcome> {
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
let retry_reason = ctx.retry_reason.clone();
let approval_keys = self.approval_keys(req);
let changes = req.changes.clone();
let guardian_review_id = ctx.guardian_review_id.clone();
let cache = self.approval_cache(req);
Box::pin(async move {
if req.permissions_preapproved && retry_reason.is_none() {
return ReviewDecision::Approved;
}
if let Some(review_id) = guardian_review_id {
let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id);
return review_approval_request(session, turn, review_id, action, retry_reason)
.await;
return ApprovalOutcome::new(ReviewDecision::Approved);
}
if let Some(reason) = retry_reason {
let rx_approve = session
.request_patch_approval(
turn,
call_id,
changes.clone(),
Some(reason),
/*grant_root*/ None,
)
.await;
return rx_approve.await.unwrap_or_default();
return request_approval(
session,
turn,
ApprovalRequest {
intent: ApprovalIntent::ApplyPatch(ApplyPatchApprovalRequest {
call_id,
cwd: req.action.cwd.to_path_buf(),
files: req.file_paths.clone(),
patch: req.action.patch.clone(),
changes: changes.clone(),
reason: Some(reason),
grant_root: None,
}),
retry_reason: ctx.retry_reason.clone(),
cache: None,
},
)
.await;
}
with_cached_approval(
&session.services,
"apply_patch",
approval_keys,
|| async move {
let rx_approve = session
.request_patch_approval(
turn, call_id, changes, /*reason*/ None, /*grant_root*/ None,
)
.await;
rx_approve.await.unwrap_or_default()
request_approval(
session,
turn,
ApprovalRequest {
intent: ApprovalIntent::ApplyPatch(ApplyPatchApprovalRequest {
call_id,
cwd: req.action.cwd.to_path_buf(),
files: req.file_paths.clone(),
patch: req.action.patch.clone(),
changes,
reason: None,
grant_root: None,
}),
retry_reason: None,
cache,
},
)
.await

View File

@@ -30,45 +30,6 @@ fn wants_no_sandbox_approval_granular_respects_sandbox_flag() {
);
}
#[test]
fn guardian_review_request_includes_patch_context() {
let path = std::env::temp_dir()
.join("guardian-apply-patch-test.txt")
.abs();
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
let expected_cwd = action.cwd.to_path_buf();
let expected_patch = action.patch.clone();
let request = ApplyPatchRequest {
action,
file_paths: vec![path.clone()],
changes: HashMap::from([(
path.to_path_buf(),
FileChange::Add {
content: "hello".to_string(),
},
)]),
exec_approval_requirement: ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
},
additional_permissions: None,
permissions_preapproved: false,
timeout_ms: None,
};
let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1");
assert_eq!(
guardian_request,
GuardianApprovalRequest::ApplyPatch {
id: "call-1".to_string(),
cwd: expected_cwd,
files: request.file_paths,
patch: expected_patch,
}
);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn build_sandbox_command_prefers_configured_codex_self_exe_for_apply_patch() {

View File

@@ -10,12 +10,16 @@ pub(crate) mod zsh_fork_backend;
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec::ExecCapturePolicy;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
use crate::shell::ShellType;
use crate::tools::approval_router::ApprovalCache;
use crate::tools::approval_router::ApprovalIntent;
use crate::tools::approval_router::ApprovalOutcome;
use crate::tools::approval_router::ApprovalRequest;
use crate::tools::approval_router::ShellApprovalRequest;
use crate::tools::approval_router::request_approval;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_sandbox_command;
@@ -30,11 +34,9 @@ use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::with_cached_approval;
use codex_network_proxy::NetworkProxy;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxablePreference;
use codex_shell_command::powershell::prefix_powershell_script_with_utf8;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -127,68 +129,54 @@ impl Sandboxable for ShellRuntime {
}
impl Approvable<ShellRequest> for ShellRuntime {
type ApprovalKey = ApprovalKey;
fn approval_keys(&self, req: &ShellRequest) -> Vec<Self::ApprovalKey> {
vec![ApprovalKey {
command: canonicalize_command_for_approval(&req.command),
cwd: req.cwd.clone(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
}]
fn approval_cache(&self, req: &ShellRequest) -> Option<ApprovalCache> {
Some(ApprovalCache::new(
"shell",
vec![ApprovalKey {
command: canonicalize_command_for_approval(&req.command),
cwd: req.cwd.clone(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
}],
))
}
fn start_approval_async<'a>(
&'a mut self,
req: &'a ShellRequest,
ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision> {
let keys = self.approval_keys(req);
) -> BoxFuture<'a, ApprovalOutcome> {
let command = req.command.clone();
let cwd = req.cwd.to_path_buf();
let retry_reason = ctx.retry_reason.clone();
let reason = retry_reason.clone().or_else(|| req.justification.clone());
let cache = self.approval_cache(req);
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
let guardian_review_id = ctx.guardian_review_id.clone();
Box::pin(async move {
if let Some(review_id) = guardian_review_id {
return review_approval_request(
session,
turn,
review_id,
GuardianApprovalRequest::Shell {
id: call_id,
request_approval(
session,
turn,
ApprovalRequest {
intent: ApprovalIntent::Shell(ShellApprovalRequest {
call_id,
command,
cwd,
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
},
retry_reason,
)
.await;
}
with_cached_approval(&session.services, "shell", keys, move || async move {
let available_decisions = None;
session
.request_command_approval(
turn,
call_id,
/*approval_id*/ None,
command,
cwd,
reason,
ctx.network_approval_context.clone(),
req.exec_approval_requirement
network_approval_context: ctx.network_approval_context.clone(),
proposed_execpolicy_amendment: req
.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
req.additional_permissions.clone(),
available_decisions,
)
.await
})
}),
retry_reason,
cache,
},
)
.await
})
}

View File

@@ -2,16 +2,16 @@ use super::ShellRequest;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::exec::is_likely_sandbox_denied;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::approval_router::ApprovalIntent;
use crate::tools::approval_router::ApprovalRequest;
use crate::tools::approval_router::ExecveApprovalRequest;
use crate::tools::approval_router::request_approval;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
@@ -391,47 +391,35 @@ impl CoreShellActionProvider {
let call_id = self.call_id.clone();
let approval_id = Some(Uuid::new_v4().to_string());
let source = self.tool_name;
let guardian_review_id = routes_approval_to_guardian(&turn).then(new_guardian_review_id);
Ok(stopwatch
.pause_for(async move {
if let Some(review_id) = guardian_review_id.clone() {
let decision = review_approval_request(
&session,
&turn,
review_id,
GuardianApprovalRequest::Execve {
id: call_id.clone(),
let outcome = request_approval(
&session,
&turn,
ApprovalRequest {
intent: ApprovalIntent::Execve(ExecveApprovalRequest {
call_id,
approval_id,
source,
program: program.to_string_lossy().into_owned(),
argv: argv.to_vec(),
cwd: workdir,
additional_permissions,
},
/*retry_reason*/ None,
)
.await;
return PromptDecision {
decision,
guardian_review_id,
};
}
let decision = session
.request_command_approval(
&turn,
call_id,
approval_id,
command,
workdir,
/*reason*/ None,
/*network_approval_context*/ None,
/*proposed_execpolicy_amendment*/ None,
additional_permissions,
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
)
.await;
command,
cwd: workdir.clone(),
additional_permissions: additional_permissions.clone(),
reason: None,
available_decisions: vec![
ReviewDecision::Approved,
ReviewDecision::Abort,
],
}),
retry_reason: None,
cache: None,
},
)
.await;
PromptDecision {
decision,
guardian_review_id: None,
decision: outcome.decision,
guardian_review_id: outcome.guardian_review_id,
}
})
.await)

View File

@@ -7,11 +7,15 @@ the process manager to spawn PTYs once an ExecRequest is prepared.
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::approval_router::ApprovalCache;
use crate::tools::approval_router::ApprovalIntent;
use crate::tools::approval_router::ApprovalOutcome;
use crate::tools::approval_router::ApprovalRequest;
use crate::tools::approval_router::UnifiedExecApprovalRequest;
use crate::tools::approval_router::request_approval;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_sandbox_command;
@@ -27,7 +31,6 @@ use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::with_cached_approval;
use crate::unified_exec::NoopSpawnLifecycle;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
@@ -36,7 +39,6 @@ use codex_network_proxy::NetworkProxy;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxablePreference;
use codex_shell_command::powershell::prefix_powershell_script_with_utf8;
use codex_tools::UnifiedExecShellMode;
@@ -102,24 +104,24 @@ impl Sandboxable for UnifiedExecRuntime<'_> {
}
impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
type ApprovalKey = UnifiedExecApprovalKey;
fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec<Self::ApprovalKey> {
vec![UnifiedExecApprovalKey {
command: canonicalize_command_for_approval(&req.command),
cwd: req.cwd.clone(),
tty: req.tty,
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
}]
fn approval_cache(&self, req: &UnifiedExecRequest) -> Option<ApprovalCache> {
Some(ApprovalCache::new(
"unified_exec",
vec![UnifiedExecApprovalKey {
command: canonicalize_command_for_approval(&req.command),
cwd: req.cwd.clone(),
tty: req.tty,
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
}],
))
}
fn start_approval_async<'b>(
&'b mut self,
req: &'b UnifiedExecRequest,
ctx: ApprovalCtx<'b>,
) -> BoxFuture<'b, ReviewDecision> {
let keys = self.approval_keys(req);
) -> BoxFuture<'b, ApprovalOutcome> {
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
@@ -127,45 +129,31 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
let cwd = req.cwd.to_path_buf();
let retry_reason = ctx.retry_reason.clone();
let reason = retry_reason.clone().or_else(|| req.justification.clone());
let guardian_review_id = ctx.guardian_review_id.clone();
let cache = self.approval_cache(req);
Box::pin(async move {
if let Some(review_id) = guardian_review_id {
return review_approval_request(
session,
turn,
review_id,
GuardianApprovalRequest::ExecCommand {
id: call_id,
request_approval(
session,
turn,
ApprovalRequest {
intent: ApprovalIntent::UnifiedExec(UnifiedExecApprovalRequest {
call_id,
command,
cwd,
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
tty: req.tty,
},
retry_reason,
)
.await;
}
with_cached_approval(&session.services, "unified_exec", keys, || async move {
let available_decisions = None;
session
.request_command_approval(
turn,
call_id,
/*approval_id*/ None,
command,
cwd,
reason,
ctx.network_approval_context.clone(),
req.exec_approval_requirement
network_approval_context: ctx.network_approval_context.clone(),
proposed_execpolicy_amendment: req
.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
req.additional_permissions.clone(),
available_decisions,
)
.await
})
}),
retry_reason,
cache,
},
)
.await
})
}

View File

@@ -8,7 +8,8 @@ use crate::codex::Session;
use crate::codex::TurnContext;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::state::SessionServices;
use crate::tools::approval_router::ApprovalCache;
use crate::tools::approval_router::ApprovalOutcome;
use crate::tools::network_approval::NetworkApprovalSpec;
use codex_network_proxy::NetworkProxy;
use codex_protocol::approvals::ExecPolicyAmendment;
@@ -27,12 +28,9 @@ use codex_sandboxing::SandboxTransformError;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use futures::Future;
use futures::future::BoxFuture;
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::Debug;
use std::hash::Hash;
use std::path::Path;
use std::sync::Arc;
@@ -48,7 +46,7 @@ impl ApprovalStore {
K: Serialize,
{
let s = serde_json::to_string(key).ok()?;
self.map.get(&s).cloned()
self.get_serialized(&s)
}
pub fn put<K>(&mut self, key: K, value: ReviewDecision)
@@ -56,63 +54,17 @@ impl ApprovalStore {
K: Serialize,
{
if let Ok(s) = serde_json::to_string(&key) {
self.map.insert(s, value);
}
}
}
/// Takes a vector of approval keys and returns a ReviewDecision.
/// There will be one key in most cases, but apply_patch can modify multiple files at once.
///
/// - If all keys are already approved for session, we skip prompting.
/// - If the user approves for session, we store the decision for each key individually
/// so future requests touching any subset can also skip prompting.
pub(crate) async fn with_cached_approval<K, F, Fut>(
services: &SessionServices,
// Name of the tool, used for metrics collection.
tool_name: &str,
keys: Vec<K>,
fetch: F,
) -> ReviewDecision
where
K: Serialize,
F: FnOnce() -> Fut,
Fut: Future<Output = ReviewDecision>,
{
// To be defensive here, don't bother with checking the cache if keys are empty.
if keys.is_empty() {
return fetch().await;
}
let already_approved = {
let store = services.tool_approvals.lock().await;
keys.iter()
.all(|key| matches!(store.get(key), Some(ReviewDecision::ApprovedForSession)))
};
if already_approved {
return ReviewDecision::ApprovedForSession;
}
let decision = fetch().await;
services.session_telemetry.counter(
"codex.approval.requested",
/*inc*/ 1,
&[
("tool", tool_name),
("approved", decision.to_opaque_string()),
],
);
if matches!(decision, ReviewDecision::ApprovedForSession) {
let mut store = services.tool_approvals.lock().await;
for key in keys {
store.put(key, ReviewDecision::ApprovedForSession);
self.put_serialized(s, value);
}
}
decision
pub fn get_serialized(&self, key: &str) -> Option<ReviewDecision> {
self.map.get(key).cloned()
}
pub fn put_serialized(&mut self, key: String, value: ReviewDecision) {
self.map.insert(key, value);
}
}
#[derive(Clone)]
@@ -120,13 +72,6 @@ pub(crate) struct ApprovalCtx<'a> {
pub session: &'a Arc<Session>,
pub turn: &'a Arc<TurnContext>,
pub call_id: &'a str,
/// Guardian review lifecycle ID for this approval, when guardian is reviewing it.
///
/// This is separate from `call_id`: `call_id` identifies the tool item under
/// review, while this ID identifies the review itself. Keeping both lets
/// denial handling, overrides, and app-server notifications refer to the
/// review without overloading the tool call ID as a review ID.
pub guardian_review_id: Option<String>,
pub retry_reason: Option<String>,
pub network_approval_context: Option<NetworkApprovalContext>,
}
@@ -241,16 +186,9 @@ pub(crate) fn sandbox_override_for_first_attempt(
}
pub(crate) trait Approvable<Req> {
type ApprovalKey: Hash + Eq + Clone + Debug + Serialize;
// In most cases (shell, unified_exec), a request will have a single approval key.
//
// However, apply_patch needs session "Allow, don't ask again" semantics that
// apply to multiple atomic targets (e.g., apply_patch approves per file path). Returning
// a list of keys lets the runtime treat the request as approved-for-session only if
// *all* keys are already approved, while still caching approvals per-key so future
// requests touching a subset can be auto-approved.
fn approval_keys(&self, req: &Req) -> Vec<Self::ApprovalKey>;
fn approval_cache(&self, _req: &Req) -> Option<ApprovalCache> {
None
}
/// Some tools may request to skip the sandbox on the first attempt
/// (e.g., when the request explicitly asks for escalated permissions).
@@ -288,7 +226,7 @@ pub(crate) trait Approvable<Req> {
&'a mut self,
req: &'a Req,
ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision>;
) -> BoxFuture<'a, ApprovalOutcome>;
}
pub(crate) trait Sandboxable {