Compare commits

...

1 Commits

Author SHA1 Message Date
Qiyao Qin
671cc4644b Add AcceptWithOverrideCommand for approval decision 2026-03-13 00:19:41 -07:00
22 changed files with 272 additions and 9 deletions

View File

@@ -33,6 +33,31 @@
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User has approved this command and wants to execute a different argv vector instead of the original one-time request.",
"properties": {
"approved_override_command": {
"properties": {
"command": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"command"
],
"type": "object"
}
},
"required": [
"approved_override_command"
],
"title": "ApprovedOverrideCommandReviewDecision",
"type": "object"
},
{
"additionalProperties": false,
"description": "User has approved this command and wants to apply the proposed execpolicy amendment so future matching commands are permitted.",

View File

@@ -228,6 +228,31 @@
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User approved the command, but wants to execute a different argv vector.",
"properties": {
"acceptWithOverrideCommand": {
"properties": {
"command": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"command"
],
"type": "object"
}
},
"required": [
"acceptWithOverrideCommand"
],
"title": "AcceptWithOverrideCommandCommandExecutionApprovalDecision",
"type": "object"
},
{
"description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.",
"enum": [

View File

@@ -10,6 +10,31 @@
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User approved the command, but wants to execute a different argv vector.",
"properties": {
"acceptWithOverrideCommand": {
"properties": {
"command": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"command"
],
"type": "object"
}
},
"required": [
"acceptWithOverrideCommand"
],
"title": "AcceptWithOverrideCommandCommandExecutionApprovalDecision",
"type": "object"
},
{
"description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.",
"enum": [

View File

@@ -33,6 +33,31 @@
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User has approved this command and wants to execute a different argv vector instead of the original one-time request.",
"properties": {
"approved_override_command": {
"properties": {
"command": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"command"
],
"type": "object"
}
},
"required": [
"approved_override_command"
],
"title": "ApprovedOverrideCommandReviewDecision",
"type": "object"
},
{
"additionalProperties": false,
"description": "User has approved this command and wants to apply the proposed execpolicy amendment so future matching commands are permitted.",

View File

@@ -294,6 +294,31 @@
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User approved the command, but wants to execute a different argv vector.",
"properties": {
"acceptWithOverrideCommand": {
"properties": {
"command": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"command"
],
"type": "object"
}
},
"required": [
"acceptWithOverrideCommand"
],
"title": "AcceptWithOverrideCommandCommandExecutionApprovalDecision",
"type": "object"
},
{
"description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.",
"enum": [

View File

@@ -1471,6 +1471,31 @@
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User approved the command, but wants to execute a different argv vector.",
"properties": {
"acceptWithOverrideCommand": {
"properties": {
"command": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"command"
],
"type": "object"
}
},
"required": [
"acceptWithOverrideCommand"
],
"title": "AcceptWithOverrideCommandCommandExecutionApprovalDecision",
"type": "object"
},
{
"description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.",
"enum": [
@@ -3214,6 +3239,31 @@
],
"type": "string"
},
{
"additionalProperties": false,
"description": "User has approved this command and wants to execute a different argv vector instead of the original one-time request.",
"properties": {
"approved_override_command": {
"properties": {
"command": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"command"
],
"type": "object"
}
},
"required": [
"approved_override_command"
],
"title": "ApprovedOverrideCommandReviewDecision",
"type": "object"
},
{
"additionalProperties": false,
"description": "User has approved this command and wants to apply the proposed execpolicy amendment so future matching commands are permitted.",

View File

@@ -7,4 +7,4 @@ import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
/**
* User's decision in response to an ExecApprovalRequest.
*/
export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "abort";
export type ReviewDecision = "approved" | { "approved_override_command": { command: Array<string>, } } | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "abort";

View File

@@ -4,4 +4,4 @@
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
export type CommandExecutionApprovalDecision = "accept" | "acceptForSession" | { "acceptWithExecpolicyAmendment": { execpolicy_amendment: ExecPolicyAmendment, } } | { "applyNetworkPolicyAmendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "decline" | "cancel";
export type CommandExecutionApprovalDecision = "accept" | { "acceptWithOverrideCommand": { command: Array<string>, } } | "acceptForSession" | { "acceptWithExecpolicyAmendment": { execpolicy_amendment: ExecPolicyAmendment, } } | { "applyNetworkPolicyAmendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "decline" | "cancel";

View File

@@ -882,6 +882,8 @@ pub struct ConfigEdit {
pub enum CommandExecutionApprovalDecision {
/// User approved the command.
Accept,
/// User approved the command, but wants to execute a different argv vector.
AcceptWithOverrideCommand { command: Vec<String> },
/// User approved the command and future prompts in the same session-scoped
/// approval cache should run without prompting.
AcceptForSession,
@@ -904,6 +906,9 @@ impl From<CoreReviewDecision> for CommandExecutionApprovalDecision {
fn from(value: CoreReviewDecision) -> Self {
match value {
CoreReviewDecision::Approved => Self::Accept,
CoreReviewDecision::ApprovedOverrideCommand { command } => {
Self::AcceptWithOverrideCommand { command }
}
CoreReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment,
} => Self::AcceptWithExecpolicyAmendment {
@@ -5691,6 +5696,27 @@ mod tests {
);
}
#[test]
fn command_execution_request_approval_response_accepts_override_command() {
let response = serde_json::from_value::<CommandExecutionRequestApprovalResponse>(json!({
"decision": {
"acceptWithOverrideCommand": {
"command": ["echo", "hi"]
}
}
}))
.expect("override command response should deserialize");
assert_eq!(
response,
CommandExecutionRequestApprovalResponse {
decision: CommandExecutionApprovalDecision::AcceptWithOverrideCommand {
command: vec!["echo".to_string(), "hi".to_string()],
},
}
);
}
#[test]
fn permissions_request_approval_response_accepts_partial_macos_grants() {
let cases = vec![

View File

@@ -840,7 +840,7 @@ When an upstream HTTP status is available (for example, from the Responses API o
Certain actions (shell commands or modifying files) may require explicit user approval depending on the user's config. When `turn/start` is used, the app-server drives an approval flow by sending a server-initiated JSON-RPC request to the client. The client must respond to tell Codex whether to proceed. UIs should present these requests inline with the active turn so users can review the proposed command or diff before choosing.
- Requests include `threadId` and `turnId`—use them to scope UI state to the active conversation.
- Respond with a single `{ "decision": ... }` payload. Command approvals support `accept`, `acceptForSession`, `acceptWithExecpolicyAmendment`, `applyNetworkPolicyAmendment`, `decline`, or `cancel`. The server resumes or declines the work and ends the item with `item/completed`.
- Respond with a single `{ "decision": ... }` payload. Command approvals support `accept`, `acceptWithOverrideCommand`, `acceptForSession`, `acceptWithExecpolicyAmendment`, `applyNetworkPolicyAmendment`, `decline`, or `cancel`. The server resumes or declines the work and ends the item with `item/completed`.
### Command execution approvals
@@ -848,7 +848,7 @@ Order of messages:
1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action.
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted.
3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`.
3. Client response — for example `{ "decision": "accept" }`, `{ "decision": { "acceptWithOverrideCommand": { "command": ["echo", "hi"] } } }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`.
4. `serverRequest/resolved``{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt.
5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result.

View File

@@ -2429,6 +2429,19 @@ async fn on_command_execution_request_approval_response(
let (decision, completion_status) = match decision {
CommandExecutionApprovalDecision::Accept => (ReviewDecision::Approved, None),
CommandExecutionApprovalDecision::AcceptWithOverrideCommand { command } => {
if command.is_empty() {
error!(
"failed to deserialize CommandExecutionRequestApprovalResponse: override command cannot be empty"
);
(
ReviewDecision::Denied,
Some(CommandExecutionStatus::Declined),
)
} else {
(ReviewDecision::ApprovedOverrideCommand { command }, None)
}
}
CommandExecutionApprovalDecision::AcceptForSession => {
(ReviewDecision::ApprovedForSession, None)
}

View File

@@ -679,6 +679,7 @@ fn build_guardian_mcp_tool_review_request(
fn mcp_tool_approval_decision_from_guardian(decision: ReviewDecision) -> McpToolApprovalDecision {
match decision {
ReviewDecision::Approved
| ReviewDecision::ApprovedOverrideCommand { .. }
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept,
ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession,

View File

@@ -215,6 +215,7 @@ impl ToolHandler for ApplyPatchHandler {
turn: turn.clone(),
call_id: call_id.clone(),
tool_name: tool_name.to_string(),
command_override: None,
};
let out = orchestrator
.run(
@@ -319,6 +320,7 @@ pub(crate) async fn intercept_apply_patch(
turn: turn.clone(),
call_id: call_id.to_string(),
tool_name: tool_name.to_string(),
command_override: None,
};
let out = orchestrator
.run(

View File

@@ -459,6 +459,7 @@ impl ShellHandler {
turn: turn.clone(),
call_id: call_id.clone(),
tool_name,
command_override: None,
};
let out = orchestrator
.run(

View File

@@ -375,7 +375,9 @@ impl NetworkApprovalService {
let mut cache_session_deny = false;
let resolved = match approval_decision {
ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
ReviewDecision::Approved
| ReviewDecision::ApprovedOverrideCommand { .. }
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
PendingApprovalDecision::AllowOnce
}
ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession,

View File

@@ -52,6 +52,7 @@ impl ToolOrchestrator {
req: &Rq,
tool_ctx: &ToolCtx,
attempt: &SandboxAttempt<'_>,
command_override: Option<Vec<String>>,
has_managed_network_requirements: bool,
) -> (Result<Out, ToolError>, Option<DeferredNetworkApproval>)
where
@@ -71,6 +72,7 @@ impl ToolOrchestrator {
turn: tool_ctx.turn.clone(),
call_id: tool_ctx.call_id.clone(),
tool_name: tool_ctx.tool_name.clone(),
command_override,
};
let run_result = tool.run(req, attempt, &attempt_tool_ctx).await;
@@ -114,6 +116,7 @@ impl ToolOrchestrator {
let otel_ci = &tool_ctx.call_id;
let otel_user = ToolDecisionSource::User;
let otel_cfg = ToolDecisionSource::Config;
let mut command_override: Option<Vec<String>> = None;
// 1) Approval
let mut already_approved = false;
@@ -135,10 +138,14 @@ impl ToolOrchestrator {
call_id: &tool_ctx.call_id,
retry_reason: reason,
network_approval_context: None,
command_override: None,
};
let decision = tool.start_approval_async(req, approval_ctx).await;
otel.tool_decision(otel_tn, otel_ci, &decision, otel_user.clone());
if let Some(command) = decision.override_command() {
command_override = Some(command.to_vec());
}
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
@@ -150,6 +157,7 @@ impl ToolOrchestrator {
return Err(ToolError::Rejected(reason));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedOverrideCommand { .. }
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
ReviewDecision::NetworkPolicyAmendment {
@@ -204,6 +212,7 @@ impl ToolOrchestrator {
req,
tool_ctx,
&initial_attempt,
command_override.clone(),
has_managed_network_requirements,
)
.await;
@@ -280,10 +289,14 @@ impl ToolOrchestrator {
call_id: &tool_ctx.call_id,
retry_reason: Some(retry_reason),
network_approval_context: network_approval_context.clone(),
command_override: command_override.clone(),
};
let decision = tool.start_approval_async(req, approval_ctx).await;
otel.tool_decision(otel_tn, otel_ci, &decision, otel_user);
if let Some(command) = decision.override_command() {
command_override = Some(command.to_vec());
}
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
@@ -295,6 +308,7 @@ impl ToolOrchestrator {
return Err(ToolError::Rejected(reason));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedOverrideCommand { .. }
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
ReviewDecision::NetworkPolicyAmendment {
@@ -327,6 +341,7 @@ impl ToolOrchestrator {
req,
tool_ctx,
&escalated_attempt,
command_override,
has_managed_network_requirements,
)
.await;

View File

@@ -144,7 +144,10 @@ impl Approvable<ShellRequest> for ShellRuntime {
ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision> {
let keys = self.approval_keys(req);
let command = req.command.clone();
let command = ctx
.command_override
.clone()
.unwrap_or_else(|| req.command.clone());
let cwd = req.cwd.clone();
let retry_reason = ctx.retry_reason.clone();
let reason = retry_reason.clone().or_else(|| req.justification.clone());
@@ -220,8 +223,9 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let session_shell = ctx.session.user_shell();
let base_command = ctx.command_override.as_ref().unwrap_or(&req.command);
let command = maybe_wrap_shell_lc_with_snapshot(
&req.command,
base_command,
session_shell.as_ref(),
&req.cwd,
&req.explicit_env_overrides,

View File

@@ -566,6 +566,12 @@ impl CoreShellActionProvider {
EscalationDecision::run()
}
}
ReviewDecision::ApprovedOverrideCommand { .. } => {
EscalationDecision::deny(Some(
"command override is not supported for execve approvals"
.to_string(),
))
}
ReviewDecision::ApprovedForSession => {
// Currently, we only add session approvals for
// skill scripts because we are storing only the

View File

@@ -112,7 +112,10 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
let command = req.command.clone();
let command = ctx
.command_override
.clone()
.unwrap_or_else(|| req.command.clone());
let cwd = req.cwd.clone();
let retry_reason = ctx.retry_reason.clone();
let reason = retry_reason.clone().or_else(|| req.justification.clone());
@@ -188,7 +191,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
) -> Result<UnifiedExecProcess, ToolError> {
let base_command = &req.command;
let base_command = ctx.command_override.as_ref().unwrap_or(&req.command);
let session_shell = ctx.session.user_shell();
let command = maybe_wrap_shell_lc_with_snapshot(
base_command,

View File

@@ -118,6 +118,7 @@ pub(crate) struct ApprovalCtx<'a> {
pub call_id: &'a str,
pub retry_reason: Option<String>,
pub network_approval_context: Option<NetworkApprovalContext>,
pub command_override: Option<Vec<String>>,
}
// Specifies what tool orchestrator should do with a given tool call.
@@ -301,6 +302,7 @@ pub(crate) struct ToolCtx {
pub turn: Arc<TurnContext>,
pub call_id: String,
pub tool_name: String,
pub command_override: Option<Vec<String>>,
}
#[derive(Debug)]

View File

@@ -613,6 +613,7 @@ impl UnifiedExecProcessManager {
turn: context.turn.clone(),
call_id: context.call_id.clone(),
tool_name: "exec_command".to_string(),
command_override: None,
};
orchestrator
.run(

View File

@@ -3041,6 +3041,10 @@ pub enum ReviewDecision {
/// User has approved this command and the agent should execute it.
Approved,
/// User has approved this command and wants to execute a different argv
/// vector instead of the original one-time request.
ApprovedOverrideCommand { command: Vec<String> },
/// User has approved this command and wants to apply the proposed execpolicy
/// amendment so future matching commands are permitted.
ApprovedExecpolicyAmendment {
@@ -3069,11 +3073,19 @@ pub enum ReviewDecision {
}
impl ReviewDecision {
pub fn override_command(&self) -> Option<&[String]> {
match self {
ReviewDecision::ApprovedOverrideCommand { command } => Some(command.as_slice()),
_ => None,
}
}
/// Returns an opaque version of the decision without PII. We can't use an ignored flag
/// on `serde` because the serialization is required by some surfaces.
pub fn to_opaque_string(&self) -> &'static str {
match self {
ReviewDecision::Approved => "approved",
ReviewDecision::ApprovedOverrideCommand { .. } => "approved_with_command_override",
ReviewDecision::ApprovedExecpolicyAmendment { .. } => "approved_with_amendment",
ReviewDecision::ApprovedForSession => "approved_for_session",
ReviewDecision::NetworkPolicyAmendment {