mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Add managed command checkpoints requiring fresh human approval (#58045)
## Why Managed command policies need to require a human decision independently of automatic review, hook allowances, and saved approvals. ## What changed - Support `decision = "prompt-user"` in managed `requirements.toml` prefix rules, requiring a fresh, one-time human decision for matching commands. - Add experimental `humanCommand` approval requests for clients advertising both `experimentalApi` and `requiredHumanApproval`. Accept only `accept`, `decline`, or `cancel`; reject commands when no capable client is available. - Restrict responses to capable clients that received the request, and replay pending prompts to compatible clients on reconnect. - Enable the capability in the TUI and update protocol schemas and client bindings. ## Testing Add coverage for fresh decisions overriding saved allowances, hook allowances, and automatic review, plus child-thread approval routing and rejection of responses from incompatible or unrelated clients. GitOrigin-RevId: 0b95ff6aab7f8a04b7298c8a4329d1fddf6a603e
This commit is contained in:
@@ -1743,7 +1743,8 @@ impl AnalyticsReducer {
|
||||
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
|
||||
let is_stdin_review = match params.kind {
|
||||
CommandExecutionApprovalKind::WriteStdin => true,
|
||||
CommandExecutionApprovalKind::Command => false,
|
||||
CommandExecutionApprovalKind::Command
|
||||
| CommandExecutionApprovalKind::HumanCommand => false,
|
||||
};
|
||||
let is_network_access_review = params.network_approval_context.is_some();
|
||||
let requested_network_access = is_network_access_review
|
||||
@@ -1760,7 +1761,9 @@ impl AnalyticsReducer {
|
||||
let requested_additional_permissions = params.additional_permissions.is_some();
|
||||
let trigger = if is_stdin_review {
|
||||
ReviewTrigger::Initial
|
||||
} else if params.approval_id.is_some() {
|
||||
} else if params.kind != CommandExecutionApprovalKind::HumanCommand
|
||||
&& params.approval_id.is_some()
|
||||
{
|
||||
ReviewTrigger::ExecveIntercept
|
||||
} else if requested_network_access {
|
||||
ReviewTrigger::NetworkPolicyDenial
|
||||
|
||||
@@ -258,6 +258,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
|
||||
@@ -217,6 +217,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
|
||||
@@ -145,6 +145,7 @@ async fn compaction_events_keep_authoritative_voice_session_across_consecutive_c
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
|
||||
@@ -592,6 +592,7 @@ pub(super) fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact {
|
||||
version: "1.0.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
|
||||
@@ -207,6 +207,8 @@ where
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InProcessClientStartArgs {
|
||||
/// This client collects fresh human decisions for managed command checkpoints.
|
||||
pub required_human_approval: bool,
|
||||
/// Resolved argv0 dispatch paths used by command execution internals.
|
||||
pub arg0_paths: Arg0DispatchPaths,
|
||||
/// Shared config used to initialize app-server runtime.
|
||||
@@ -253,6 +255,7 @@ impl InProcessClientStartArgs {
|
||||
/// Builds initialize params from caller-provided metadata.
|
||||
pub fn initialize_params(&self) -> InitializeParams {
|
||||
let capabilities = InitializeCapabilities {
|
||||
required_human_approval: self.required_human_approval,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: self.experimental_api,
|
||||
request_attestation: false,
|
||||
@@ -949,6 +952,7 @@ mod tests {
|
||||
.await
|
||||
.expect("state db should initialize for in-process test");
|
||||
let client = InProcessAppServerClient::start(InProcessClientStartArgs {
|
||||
required_human_approval: false,
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config,
|
||||
cli_overrides: Vec::new(),
|
||||
@@ -1172,6 +1176,7 @@ mod tests {
|
||||
|
||||
fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs {
|
||||
RemoteAppServerConnectArgs {
|
||||
required_human_approval: false,
|
||||
endpoint: RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url,
|
||||
auth_token: None,
|
||||
@@ -1536,6 +1541,7 @@ mod tests {
|
||||
websocket.close(None).await.expect("close should succeed");
|
||||
});
|
||||
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: false,
|
||||
endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path },
|
||||
client_name: "codex-app-server-client-test".to_string(),
|
||||
client_version: "0.0.0-test".to_string(),
|
||||
@@ -1623,6 +1629,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: false,
|
||||
endpoint: RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url,
|
||||
auth_token: Some(auth_token),
|
||||
@@ -1643,6 +1650,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn remote_connect_rejects_non_loopback_ws_when_auth_configured() {
|
||||
let result = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: false,
|
||||
endpoint: RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url: "ws://example.com:4500".to_string(),
|
||||
auth_token: Some("remote-bearer-token".to_string()),
|
||||
@@ -2178,6 +2186,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let runtime_args = InProcessClientStartArgs {
|
||||
required_human_approval: false,
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config: config.clone(),
|
||||
cli_overrides: Vec::new(),
|
||||
|
||||
@@ -84,6 +84,8 @@ pub enum RemoteAppServerEndpoint {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteAppServerConnectArgs {
|
||||
/// This client collects fresh human decisions for managed command checkpoints.
|
||||
pub required_human_approval: bool,
|
||||
pub endpoint: RemoteAppServerEndpoint,
|
||||
pub client_name: String,
|
||||
pub client_version: String,
|
||||
@@ -95,6 +97,7 @@ pub struct RemoteAppServerConnectArgs {
|
||||
impl RemoteAppServerConnectArgs {
|
||||
pub(crate) fn initialize_params(&self) -> InitializeParams {
|
||||
let capabilities = InitializeCapabilities {
|
||||
required_human_approval: self.required_human_approval,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: self.experimental_api,
|
||||
request_attestation: false,
|
||||
|
||||
@@ -82,6 +82,7 @@ async fn compressed_session_negotiates_fragments_and_retains_history() -> Result
|
||||
};
|
||||
let client = async {
|
||||
let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: false,
|
||||
endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path },
|
||||
client_name: "compression-test".to_string(),
|
||||
client_version: "0.0.0".to_string(),
|
||||
|
||||
@@ -1760,6 +1760,10 @@
|
||||
"default": false,
|
||||
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"requiredHumanApproval": {
|
||||
"description": "Present `humanCommand` approvals to a person and return only a one-time decision. Requires `experimentalApi`. Pending requests can be replayed to compatible clients.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
+16
-5
@@ -271,11 +271,22 @@
|
||||
},
|
||||
"CommandExecutionApprovalKind": {
|
||||
"description": "Distinguishes a command approval from input sent to an existing terminal.",
|
||||
"enum": [
|
||||
"command",
|
||||
"writeStdin"
|
||||
],
|
||||
"type": "string"
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"command",
|
||||
"writeStdin"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A managed checkpoint requiring a fresh human decision. Only `accept`, `decline`, and `cancel` are valid. Requires the `requiredHumanApproval` initialize capability. This prompt does not itself start a command execution item.",
|
||||
"enum": [
|
||||
"humanCommand"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileSystemAccessMode": {
|
||||
"enum": [
|
||||
|
||||
+16
-5
@@ -340,11 +340,22 @@
|
||||
},
|
||||
"CommandExecutionApprovalKind": {
|
||||
"description": "Distinguishes a command approval from input sent to an existing terminal.",
|
||||
"enum": [
|
||||
"command",
|
||||
"writeStdin"
|
||||
],
|
||||
"type": "string"
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"command",
|
||||
"writeStdin"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A managed checkpoint requiring a fresh human decision. Only `accept`, `decline`, and `cancel` are valid. Requires the `requiredHumanApproval` initialize capability. This prompt does not itself start a command execution item.",
|
||||
"enum": [
|
||||
"humanCommand"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionRequestApprovalParams": {
|
||||
"properties": {
|
||||
|
||||
+20
-5
@@ -2840,11 +2840,22 @@
|
||||
},
|
||||
"CommandExecutionApprovalKind": {
|
||||
"description": "Distinguishes a command approval from input sent to an existing terminal.",
|
||||
"enum": [
|
||||
"command",
|
||||
"writeStdin"
|
||||
],
|
||||
"type": "string"
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"command",
|
||||
"writeStdin"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A managed checkpoint requiring a fresh human decision. Only `accept`, `decline`, and `cancel` are valid. Requires the `requiredHumanApproval` initialize capability. This prompt does not itself start a command execution item.",
|
||||
"enum": [
|
||||
"humanCommand"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"CommandExecutionRequestApprovalParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
@@ -3437,6 +3448,10 @@
|
||||
"default": false,
|
||||
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"requiredHumanApproval": {
|
||||
"description": "Present `humanCommand` approvals to a person and return only a one-time decision. Requires `experimentalApi`. Pending requests can be replayed to compatible clients.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
+4
@@ -10095,6 +10095,10 @@
|
||||
"default": false,
|
||||
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"requiredHumanApproval": {
|
||||
"description": "Present `humanCommand` approvals to a person and return only a one-time decision. Requires `experimentalApi`. Pending requests can be replayed to compatible clients.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
"default": false,
|
||||
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"requiredHumanApproval": {
|
||||
"description": "Present `humanCommand` approvals to a person and return only a one-time decision. Requires `experimentalApi`. Pending requests can be replayed to compatible clients.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -7,6 +7,11 @@ import type { JsonValue } from "./serde_json/JsonValue";
|
||||
* Client-declared capabilities negotiated during initialize.
|
||||
*/
|
||||
export type InitializeCapabilities = {
|
||||
/**
|
||||
* Present `humanCommand` approvals to a person and return only a one-time decision.
|
||||
* Requires `experimentalApi`. Pending requests can be replayed to compatible clients.
|
||||
*/
|
||||
requiredHumanApproval?: boolean,
|
||||
/**
|
||||
* Use explicit gateway OAuth login instead of automatic browser authorization.
|
||||
* Applies to this app-server's gateway runtime; later connections cannot undo it.
|
||||
|
||||
+1
-1
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* Distinguishes a command approval from input sent to an existing terminal.
|
||||
*/
|
||||
export type CommandExecutionApprovalKind = "command" | "writeStdin";
|
||||
export type CommandExecutionApprovalKind = "command" | "humanCommand" | "writeStdin";
|
||||
|
||||
@@ -2835,6 +2835,7 @@ mod tests {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(v1::InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
@@ -2923,6 +2924,7 @@ mod tests {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(v1::InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
|
||||
@@ -44,6 +44,10 @@ pub struct ClientInfo {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeCapabilities {
|
||||
/// Present `humanCommand` approvals to a person and return only a one-time decision.
|
||||
/// Requires `experimentalApi`. Pending requests can be replayed to compatible clients.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub required_human_approval: bool,
|
||||
/// Use explicit gateway OAuth login instead of automatic browser authorization.
|
||||
/// Applies to this app-server's gateway runtime; later connections cannot undo it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
|
||||
@@ -1644,6 +1644,10 @@ v2_enum_from_core! {
|
||||
pub enum CommandExecutionApprovalKind from CoreExecApprovalKind {
|
||||
#[default]
|
||||
Command,
|
||||
/// A managed checkpoint requiring a fresh human decision. Only `accept`, `decline`, and
|
||||
/// `cancel` are valid. Requires the `requiredHumanApproval` initialize capability.
|
||||
/// This prompt does not itself start a command execution item.
|
||||
HumanCommand,
|
||||
WriteStdin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1608,6 +1608,7 @@ impl CodexClient {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api,
|
||||
request_attestation: false,
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
# Managed human command checkpoints (experimental)
|
||||
|
||||
A matching managed `requirements.toml` prefix rule with `decision = "prompt-user"`
|
||||
requires a fresh human decision, including when the thread uses automatic review.
|
||||
Prefix matching, exact-path precedence, and executable mappings stay unchanged. Clients
|
||||
supporting this workflow must initialize with `experimentalApi: true` and
|
||||
`requiredHumanApproval: true`.
|
||||
|
||||
The server sends `item/commandExecution/requestApproval` with `kind: "humanCommand"`,
|
||||
a unique `approvalId`, and the command to display. Only `accept`, `decline`, and
|
||||
`cancel` are valid responses. Session and persistent approvals cannot satisfy the
|
||||
checkpoint. Only capable clients receive or answer these requests. Pending prompts
|
||||
use the normal approval replay flow when a capable client reconnects. With no capable
|
||||
connection when approval is requested, the command is rejected. The prompt itself
|
||||
does not start an execution item; normal command execution events report execution
|
||||
after approval.
|
||||
|
||||
# Thread memory summary (experimental)
|
||||
|
||||
`thread/memory/read` takes `{threadId}` for a loaded thread and returns
|
||||
|
||||
@@ -751,7 +751,8 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
Some(shlex_join(&command)),
|
||||
Some(completion_item.cwd.clone()),
|
||||
Some(command_actions),
|
||||
Some(completion_item),
|
||||
(kind != codex_protocol::approvals::ExecApprovalKind::HumanCommand)
|
||||
.then_some(completion_item),
|
||||
),
|
||||
};
|
||||
if approval_starts_command && let Some(completion_item) = completion_item.as_ref() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Exercises thread cleanup when startup or child creation outlives the client connection.
|
||||
//! Exercises client attachment, approval routing, and cleanup across thread creation.
|
||||
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_features::Feature;
|
||||
@@ -24,8 +24,11 @@ use anyhow::Result;
|
||||
use app_test_support::MockResponsesConfig;
|
||||
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
||||
use codex_app_server_protocol::InitializeResponse;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ThreadClosedNotification;
|
||||
use codex_app_server_protocol::ThreadLoadedListResponse;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
@@ -510,6 +513,233 @@ async fn disconnected_v2_child_lifecycle(unload_delay_secs: u64, ephemeral: bool
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn child_human_approval_survives_attachment_and_rejects_unrelated_clients() -> Result<()> {
|
||||
let (release_spawn_completion, spawn_completion_gate) = oneshot::channel();
|
||||
let (server, _completions) = start_streaming_sse_server(vec![
|
||||
vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: responses::sse(vec![
|
||||
responses::ev_response_created("spawn"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
"spawn",
|
||||
"collaboration",
|
||||
"spawn_agent",
|
||||
&json!({"task_name": "child", "message": "Run echo", "fork_turns": "none"})
|
||||
.to_string(),
|
||||
),
|
||||
]),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(spawn_completion_gate),
|
||||
body: responses::sse(vec![responses::ev_completed("spawn")]),
|
||||
},
|
||||
],
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: responses::sse(vec![
|
||||
responses::ev_response_created("command"),
|
||||
responses::ev_function_call(
|
||||
"child-command",
|
||||
"exec_command",
|
||||
&json!({"cmd": "echo human-approved"}).to_string(),
|
||||
),
|
||||
responses::ev_completed("command"),
|
||||
]),
|
||||
}],
|
||||
])
|
||||
.await;
|
||||
let home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
home.path().join("requirements.toml"),
|
||||
r#"
|
||||
[features]
|
||||
unified_exec = false
|
||||
shell_tool = true
|
||||
|
||||
[[rules.prefix_rules]]
|
||||
pattern = [{ token = "echo" }]
|
||||
decision = "prompt-user"
|
||||
"#,
|
||||
)?;
|
||||
MockResponsesConfig::new(server.uri())
|
||||
.with_approval_policy("on-request")
|
||||
.with_sandbox_mode("workspace-write")
|
||||
.with_root_config("approvals_reviewer = \"auto_review\"")
|
||||
.enable_feature(Feature::Collab)
|
||||
.enable_feature(Feature::MultiAgentV2)
|
||||
.write(home.path())?;
|
||||
let config = Arc::new(
|
||||
ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?,
|
||||
);
|
||||
let auth =
|
||||
AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false)
|
||||
.await?;
|
||||
let (processor, mut outgoing) = build_test_processor(config, auth).await;
|
||||
let mut created = processor.thread_created_receiver();
|
||||
let incompatible_id = ConnectionId(1);
|
||||
let capable_id = ConnectionId(2);
|
||||
let unrelated_id = ConnectionId(3);
|
||||
let incompatible = Arc::new(ConnectionSessionState::new(ConnectionOrigin::WebSocket));
|
||||
let capable = Arc::new(ConnectionSessionState::new(ConnectionOrigin::WebSocket));
|
||||
let unrelated = Arc::new(ConnectionSessionState::new(ConnectionOrigin::WebSocket));
|
||||
let result: Result<()> = async {
|
||||
for (id, session, required_human_approval) in [
|
||||
(incompatible_id, &incompatible, false),
|
||||
(capable_id, &capable, true),
|
||||
] {
|
||||
send_request(
|
||||
&processor,
|
||||
id,
|
||||
session,
|
||||
json!({"id": 1, "method": "initialize", "params": {
|
||||
"clientInfo": {"name": "attachment-test", "version": "1"},
|
||||
"capabilities": {
|
||||
"experimentalApi": true,
|
||||
"requiredHumanApproval": required_human_approval
|
||||
}
|
||||
}}),
|
||||
)
|
||||
.await;
|
||||
let _: InitializeResponse =
|
||||
read_response_from(&mut outgoing, id, /*request_id*/ 1).await;
|
||||
processor
|
||||
.connection_initialized(id, session.request_attestation())
|
||||
.await;
|
||||
}
|
||||
send_request(
|
||||
&processor,
|
||||
incompatible_id,
|
||||
&incompatible,
|
||||
json!({"id": 2, "method": "thread/start", "params": {}}),
|
||||
)
|
||||
.await;
|
||||
let parent: ThreadStartResponse =
|
||||
read_response_from(&mut outgoing, incompatible_id, /*request_id*/ 2).await;
|
||||
send_request(
|
||||
&processor,
|
||||
incompatible_id,
|
||||
&incompatible,
|
||||
json!({"id": 3, "method": "turn/start", "params": {
|
||||
"threadId": parent.thread.id,
|
||||
"input": [{"type": "text", "text": "Spawn a child to run echo"}]
|
||||
}}),
|
||||
)
|
||||
.await;
|
||||
let _: TurnStartResponse =
|
||||
read_response_from(&mut outgoing, incompatible_id, /*request_id*/ 3).await;
|
||||
let child_id = timeout(Duration::from_secs(/*secs*/ 10), created.recv()).await??;
|
||||
|
||||
while outgoing.try_recv().is_ok() {}
|
||||
// Attach the initial client set before draining the child's queued approval.
|
||||
processor
|
||||
.try_attach_thread_listener(child_id, vec![incompatible_id, capable_id])
|
||||
.await;
|
||||
let request = timeout(Duration::from_secs(/*secs*/ 10), async {
|
||||
loop {
|
||||
if let OutgoingEnvelope::ToConnection {
|
||||
connection_id,
|
||||
message: OutgoingMessage::Request(request),
|
||||
..
|
||||
} = outgoing.recv().await.context("outgoing channel closed")?
|
||||
{
|
||||
assert_eq!(connection_id, capable_id);
|
||||
break anyhow::Ok(request);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("capable client did not receive the child's approval during attachment")??;
|
||||
let ServerRequest::CommandExecutionRequestApproval { request_id, params } = &request else {
|
||||
anyhow::bail!("expected human approval, got {request:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
(¶ms.thread_id, params.kind),
|
||||
(
|
||||
&child_id.to_string(),
|
||||
codex_app_server_protocol::CommandExecutionApprovalKind::HumanCommand,
|
||||
),
|
||||
);
|
||||
|
||||
// Initialize a capable client after auto-attachment. Capability alone must not
|
||||
// let it approve or reject this request before it subscribes and receives it.
|
||||
send_request(
|
||||
&processor,
|
||||
unrelated_id,
|
||||
&unrelated,
|
||||
json!({"id": 1, "method": "initialize", "params": {
|
||||
"clientInfo": {"name": "unrelated-client", "version": "1"},
|
||||
"capabilities": {"experimentalApi": true, "requiredHumanApproval": true}
|
||||
}}),
|
||||
)
|
||||
.await;
|
||||
let _: InitializeResponse =
|
||||
read_response_from(&mut outgoing, unrelated_id, /*request_id*/ 1).await;
|
||||
processor
|
||||
.connection_initialized(unrelated_id, unrelated.request_attestation())
|
||||
.await;
|
||||
for connection_id in [incompatible_id, unrelated_id] {
|
||||
processor
|
||||
.process_response(
|
||||
connection_id,
|
||||
JSONRPCResponse {
|
||||
id: request_id.clone(),
|
||||
result: json!({"decision": "accept"}),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
processor
|
||||
.process_error(
|
||||
connection_id,
|
||||
JSONRPCError {
|
||||
id: request_id.clone(),
|
||||
error: crate::error_code::internal_error("unsupported request"),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
processor
|
||||
.outgoing
|
||||
.pending_requests_for_thread(child_id)
|
||||
.await,
|
||||
vec![request.clone()],
|
||||
"unrouted responses and errors must leave the request pending",
|
||||
);
|
||||
}
|
||||
|
||||
// Routing ends at the human decision. Command execution and replay have
|
||||
// their own coverage; declining here needs no Linux sandbox executable.
|
||||
processor
|
||||
.process_response(
|
||||
capable_id,
|
||||
JSONRPCResponse {
|
||||
id: request_id.clone(),
|
||||
result: json!({"decision": "decline"}),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
processor
|
||||
.outgoing
|
||||
.pending_requests_for_thread(child_id)
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
drop(release_spawn_completion);
|
||||
drop(outgoing);
|
||||
processor.clear_runtime_references();
|
||||
processor.shutdown_threads().await;
|
||||
server.shutdown().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
processor: &Arc<MessageProcessor>,
|
||||
connection_id: ConnectionId,
|
||||
|
||||
@@ -136,6 +136,7 @@ pub(crate) enum OutgoingEnvelope {
|
||||
pub(crate) struct OutgoingMessageSender {
|
||||
verification_auth: OnceLock<Arc<codex_login::AuthManager>>,
|
||||
verification_connections: Mutex<HashSet<ConnectionId>>,
|
||||
human_approval_connections: Mutex<HashSet<ConnectionId>>,
|
||||
next_server_request_id: AtomicI64,
|
||||
sender: mpsc::Sender<OutgoingEnvelope>,
|
||||
process_reply_sender: mpsc::Sender<OutgoingEnvelope>,
|
||||
@@ -155,6 +156,7 @@ pub(crate) struct ThreadScopedOutgoingMessageSender {
|
||||
}
|
||||
|
||||
struct PendingCallbackEntry {
|
||||
human_approval_recipients: HashSet<ConnectionId>,
|
||||
verification_owner: Option<ConnectionId>,
|
||||
verification_auth_revision: Option<u64>,
|
||||
verification_identity: Option<user_verification_auth::Identity>,
|
||||
@@ -259,6 +261,7 @@ impl OutgoingMessageSender {
|
||||
Self {
|
||||
verification_auth: OnceLock::new(),
|
||||
verification_connections: Mutex::new(HashSet::new()),
|
||||
human_approval_connections: Mutex::new(HashSet::new()),
|
||||
next_server_request_id: AtomicI64::new(0),
|
||||
process_reply_sender: sender.clone(),
|
||||
sender,
|
||||
@@ -304,7 +307,18 @@ impl OutgoingMessageSender {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn enable_human_approval_connection(&self, connection_id: ConnectionId) {
|
||||
self.human_approval_connections
|
||||
.lock()
|
||||
.await
|
||||
.insert(connection_id);
|
||||
}
|
||||
|
||||
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
|
||||
self.human_approval_connections
|
||||
.lock()
|
||||
.await
|
||||
.remove(&connection_id);
|
||||
self.disconnect_user_verification_connection(connection_id)
|
||||
.await;
|
||||
let mut request_contexts = self.request_contexts.lock().await;
|
||||
@@ -387,6 +401,22 @@ impl OutgoingMessageSender {
|
||||
|| verification_identity != self.verification_identity())
|
||||
};
|
||||
let (tx_approve, rx_approve) = oneshot::channel();
|
||||
let human_connections;
|
||||
let connection_ids = if requires_human_approval(&request) {
|
||||
let eligible = self.human_approval_connections.lock().await;
|
||||
human_connections = connection_ids
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| eligible.contains(id))
|
||||
.collect::<Vec<_>>();
|
||||
if human_connections.is_empty() {
|
||||
return (outgoing_message_id, rx_approve);
|
||||
}
|
||||
Some(human_connections.as_slice())
|
||||
} else {
|
||||
connection_ids
|
||||
};
|
||||
// One app owns this ceremony. Reconnect and other subscribers cannot answer it.
|
||||
let verification_owner = if user_verification {
|
||||
let eligible = self.verification_connections.lock().await;
|
||||
@@ -409,6 +439,11 @@ impl OutgoingMessageSender {
|
||||
request_id_to_callback.insert(
|
||||
id,
|
||||
PendingCallbackEntry {
|
||||
human_approval_recipients: if requires_human_approval(&request) {
|
||||
connection_ids.unwrap_or_default().iter().copied().collect()
|
||||
} else {
|
||||
HashSet::new()
|
||||
},
|
||||
verification_owner,
|
||||
verification_auth_revision,
|
||||
verification_identity: verification_identity.clone(),
|
||||
@@ -481,7 +516,24 @@ impl OutgoingMessageSender {
|
||||
thread_id: ThreadId,
|
||||
) {
|
||||
let requests = self.pending_requests_for_thread(thread_id).await;
|
||||
let human_approval = self
|
||||
.human_approval_connections
|
||||
.lock()
|
||||
.await
|
||||
.contains(&connection_id);
|
||||
for request in requests {
|
||||
if requires_human_approval(&request) {
|
||||
if !human_approval {
|
||||
continue;
|
||||
}
|
||||
// Register the replay recipient before sending so its response
|
||||
// cannot race ahead of callback authorization.
|
||||
let mut callbacks = self.request_id_to_callback.lock().await;
|
||||
let Some(entry) = callbacks.get_mut(request.id()) else {
|
||||
continue;
|
||||
};
|
||||
entry.human_approval_recipients.insert(connection_id);
|
||||
}
|
||||
if let Err(err) = self
|
||||
.sender
|
||||
.send(OutgoingEnvelope::ToConnection {
|
||||
@@ -595,8 +647,18 @@ impl OutgoingMessageSender {
|
||||
connection_id: ConnectionId,
|
||||
id: &RequestId,
|
||||
) -> Option<(RequestId, PendingCallbackEntry)> {
|
||||
let human_approval = self
|
||||
.human_approval_connections
|
||||
.lock()
|
||||
.await
|
||||
.contains(&connection_id);
|
||||
let mut callbacks = self.request_id_to_callback.lock().await;
|
||||
let entry = callbacks.get(id)?;
|
||||
if requires_human_approval(&entry.request)
|
||||
&& (!human_approval || !entry.human_approval_recipients.contains(&connection_id))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(owner) = entry.verification_owner {
|
||||
if owner != connection_id {
|
||||
return None;
|
||||
@@ -952,6 +1014,11 @@ fn timestamped_server_notification(notification: ServerNotification) -> Outgoing
|
||||
})
|
||||
}
|
||||
|
||||
fn requires_human_approval(request: &ServerRequest) -> bool {
|
||||
matches!(request, ServerRequest::CommandExecutionRequestApproval { params, .. }
|
||||
if params.kind == codex_app_server_protocol::CommandExecutionApprovalKind::HumanCommand)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thread_sample_cancel_context_tests.rs"]
|
||||
mod thread_sample_cancel_context_tests;
|
||||
|
||||
@@ -141,6 +141,11 @@ impl InitializeRequestProcessor {
|
||||
{
|
||||
return Err(invalid_request("Already initialized"));
|
||||
}
|
||||
if experimental_api_enabled && capabilities.required_human_approval {
|
||||
self.outgoing
|
||||
.enable_human_approval_connection(connection_id)
|
||||
.await;
|
||||
}
|
||||
if user_verification_enabled {
|
||||
self.outgoing
|
||||
.enable_user_verification_connection(connection_id)
|
||||
|
||||
@@ -84,6 +84,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: true,
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn managed_restart_resumes_loaded_threads_and_goal_without_client() -> Res
|
||||
let mut client = connect_daemon_client(
|
||||
&socket_path,
|
||||
InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
experimental_api: true,
|
||||
extensions: Some(std::collections::HashMap::from([(
|
||||
"io.modelcontextprotocol/ui".into(),
|
||||
@@ -132,6 +133,7 @@ async fn managed_restart_resumes_loaded_threads_and_goal_without_client() -> Res
|
||||
let mut reconnected = connect_daemon_client(
|
||||
&socket_path,
|
||||
InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
experimental_api: true,
|
||||
extensions: Some(std::collections::HashMap::from([(
|
||||
"io.modelcontextprotocol/ui".into(),
|
||||
|
||||
@@ -41,6 +41,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
@@ -117,6 +118,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
@@ -177,6 +179,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
@@ -220,6 +223,7 @@ async fn settings_update_requires_experimental_api_capability(method: &str) -> R
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
@@ -260,6 +264,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
@@ -323,6 +328,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
@@ -367,6 +373,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
@@ -410,6 +417,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil
|
||||
.initialize_with_capabilities(
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: false,
|
||||
request_attestation: false,
|
||||
|
||||
@@ -240,6 +240,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
|
||||
@@ -521,6 +521,7 @@ async fn initialize_websocket_client(
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
experimental_api: true,
|
||||
extensions: supports_openai_form_elicitation
|
||||
.then(|| HashMap::from([("openai/form".to_string(), serde_json::json!({}))])),
|
||||
|
||||
@@ -260,6 +260,7 @@ async fn mcp_server_tool_call_forwards_only_server_extensions() -> Result<()> {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
@@ -356,6 +357,7 @@ async fn model_mcp_tool_call_uses_session_client_extensions() -> Result<()> {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
@@ -783,6 +785,7 @@ async fn initialize_elicitation_app_server(
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
experimental_api: true,
|
||||
extensions: client_advertises_standard_form_input.then(|| {
|
||||
HashMap::from([(
|
||||
|
||||
@@ -756,6 +756,7 @@ async fn initialize_experimental(mcp: &mut TestAppServer) -> Result<()> {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
|
||||
@@ -148,6 +148,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> {
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
required_human_approval: false,
|
||||
explicit_gateway_oauth: false,
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
|
||||
@@ -335,6 +335,7 @@ async fn remote_session_commands_with_workload_identity_use_server_auth() -> Res
|
||||
|
||||
let endpoint = format!("unix://{}", socket_path.as_path().display());
|
||||
let mut app = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: false,
|
||||
endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path },
|
||||
client_name: "queue-e2e-test".to_string(),
|
||||
client_version: "0.1.0".to_string(),
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct RequirementsExecPolicyToml {
|
||||
|
||||
/// A TOML representation of the `prefix_rule(...)` Starlark builtin.
|
||||
///
|
||||
/// This mirrors the builtin defined in `execpolicy/src/parser.rs`.
|
||||
/// This shares the builtin's matching semantics and additionally supports managed `prompt-user` decisions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct RequirementsExecPolicyPrefixRuleToml {
|
||||
pub pattern: Vec<RequirementsExecPolicyPatternTokenToml>,
|
||||
@@ -42,6 +42,7 @@ pub struct RequirementsExecPolicyPatternTokenToml {
|
||||
pub enum RequirementsExecPolicyDecisionToml {
|
||||
Allow,
|
||||
Prompt,
|
||||
PromptUser,
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ impl RequirementsExecPolicyDecisionToml {
|
||||
match self {
|
||||
Self::Allow => Decision::Allow,
|
||||
Self::Prompt => Decision::Prompt,
|
||||
Self::PromptUser => Decision::PromptUser,
|
||||
Self::Forbidden => Decision::Forbidden,
|
||||
}
|
||||
}
|
||||
@@ -79,7 +81,7 @@ pub enum RequirementsExecPolicyParseError {
|
||||
MissingDecision { rule_index: usize },
|
||||
|
||||
#[error(
|
||||
"rules prefix_rule at index {rule_index} has decision 'allow', which is not permitted in requirements.toml: Codex merges these rules with other config and uses the most restrictive result (use 'prompt' or 'forbidden')"
|
||||
"rules prefix_rule at index {rule_index} has decision 'allow', which is not permitted in requirements.toml: Codex merges these rules with other config and uses the most restrictive result (use 'prompt', 'prompt-user', or 'forbidden')"
|
||||
)]
|
||||
AllowDecisionNotAllowed { rule_index: usize },
|
||||
}
|
||||
|
||||
@@ -406,9 +406,9 @@ impl ExecPolicyManager {
|
||||
),
|
||||
),
|
||||
},
|
||||
Decision::Prompt => {
|
||||
Decision::Prompt | Decision::PromptUser => {
|
||||
let prompt_is_rule = evaluation.matched_rules.iter().any(|rule_match| {
|
||||
is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt
|
||||
is_policy_match(rule_match) && rule_match.decision() == evaluation.decision
|
||||
});
|
||||
match prompt_is_rejected_by_policy(approval_policy, prompt_is_rule) {
|
||||
Some(reason) if prompt_is_rule => ExecApprovalRequirement::Forbidden {
|
||||
@@ -425,6 +425,11 @@ impl ExecPolicyManager {
|
||||
),
|
||||
),
|
||||
},
|
||||
None if evaluation.decision == Decision::PromptUser => {
|
||||
ExecApprovalRequirement::NeedsHumanApproval {
|
||||
reason: derive_prompt_reason(command, &evaluation),
|
||||
}
|
||||
}
|
||||
None => ExecApprovalRequirement::NeedsApproval {
|
||||
reason: derive_prompt_reason(command, &evaluation),
|
||||
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
|
||||
@@ -1048,20 +1053,27 @@ fn derive_prompt_reason(command_args: &[String], evaluation: &Evaluation) -> Opt
|
||||
.filter_map(|rule_match| match rule_match {
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision: Decision::Prompt,
|
||||
decision,
|
||||
justification,
|
||||
..
|
||||
} => Some((matched_prefix.len(), justification.as_deref())),
|
||||
} if *decision == evaluation.decision => {
|
||||
Some((matched_prefix.len(), justification.as_deref()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.max_by_key(|(matched_prefix_len, _)| *matched_prefix_len);
|
||||
|
||||
let approval = if evaluation.decision == Decision::PromptUser {
|
||||
"human approval"
|
||||
} else {
|
||||
"approval"
|
||||
};
|
||||
match most_specific_prompt {
|
||||
Some((_matched_prefix_len, Some(justification))) => {
|
||||
Some(format!("`{command}` requires approval: {justification}"))
|
||||
Some(format!("`{command}` requires {approval}: {justification}"))
|
||||
}
|
||||
Some((_matched_prefix_len, None)) => {
|
||||
Some(format!("`{command}` requires approval by policy"))
|
||||
Some(format!("`{command}` requires {approval} by policy"))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
|
||||
@@ -2554,6 +2554,7 @@ async fn windows_powershell_policy_classifies_only_independent_literal_commands(
|
||||
let actual = match requirement {
|
||||
ExecApprovalRequirement::Skip { .. } => Decision::Allow,
|
||||
ExecApprovalRequirement::NeedsApproval { .. } => Decision::Prompt,
|
||||
ExecApprovalRequirement::NeedsHumanApproval { .. } => Decision::PromptUser,
|
||||
ExecApprovalRequirement::Forbidden { .. } => Decision::Forbidden,
|
||||
};
|
||||
assert_eq!(
|
||||
|
||||
@@ -58,6 +58,11 @@ pub(crate) async fn review_tool_call(
|
||||
let approval = match &requirement {
|
||||
ExecApprovalRequirement::Skip { .. } => return Ok(None),
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => reason.clone(),
|
||||
ExecApprovalRequirement::NeedsHumanApproval { .. } => {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"Managed command policy requires human approval.".into(),
|
||||
));
|
||||
}
|
||||
ExecApprovalRequirement::Forbidden { reason } => {
|
||||
return Err(CodexErr::InvalidRequest(reason.clone()));
|
||||
}
|
||||
|
||||
@@ -1604,6 +1604,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
allow_persistent_approval: persistent_approval_key.is_some(),
|
||||
};
|
||||
let approval_context = ApprovalContext {
|
||||
exec_requirement: None,
|
||||
review_context: GuardianReviewContext::from(step_context),
|
||||
cancellation_token: Some(cancellation_token.clone()),
|
||||
call_id: call_id.to_string(),
|
||||
|
||||
@@ -177,12 +177,44 @@ pub async fn resolve_elicitation(
|
||||
|
||||
/// Propagate a user's exec approval decision to the session.
|
||||
/// Also optionally applies an execpolicy amendment.
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "bind a human decision to its active turn before resolving it"
|
||||
)]
|
||||
pub async fn exec_approval(
|
||||
sess: &Arc<Session>,
|
||||
approval_id: String,
|
||||
turn_id: Option<String>,
|
||||
decision: ReviewDecision,
|
||||
) {
|
||||
let human_approval = {
|
||||
let active = sess.active_turn.lock().await;
|
||||
let Some(turn) = active.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let mut state = turn.turn_state.lock().await;
|
||||
let Some(pending) = state.pending_approval(&approval_id) else {
|
||||
return;
|
||||
};
|
||||
if pending.kind == codex_protocol::approvals::ExecApprovalKind::HumanCommand {
|
||||
state.remove_pending_approval(&approval_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(pending) = human_approval {
|
||||
let decision = pending.validate(decision);
|
||||
if decision == ReviewDecision::Abort {
|
||||
sess.abort_turn_if_active(
|
||||
&pending.turn_id,
|
||||
TurnAbortReason::Interrupted,
|
||||
/*error*/ None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
pending.sender.send(decision).ok();
|
||||
return;
|
||||
}
|
||||
let event_turn_id = turn_id.unwrap_or_else(|| approval_id.clone());
|
||||
if let ReviewDecision::ApprovedExecpolicyAmendment {
|
||||
proposed_execpolicy_amendment,
|
||||
|
||||
@@ -3107,7 +3107,12 @@ impl Session {
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.insert_pending_approval(effective_approval_id.clone(), tx_approve)
|
||||
ts.insert_pending_approval(
|
||||
effective_approval_id.clone(),
|
||||
tx_approve,
|
||||
kind,
|
||||
turn_context.sub_id.clone(),
|
||||
)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
@@ -3193,7 +3198,12 @@ impl Session {
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.insert_pending_approval(approval_id.clone(), tx_approve)
|
||||
ts.insert_pending_approval(
|
||||
approval_id.clone(),
|
||||
tx_approve,
|
||||
ExecApprovalKind::Command,
|
||||
turn_context.sub_id.clone(),
|
||||
)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
@@ -3270,6 +3280,7 @@ impl Session {
|
||||
let context = sandbox_context.policy_context();
|
||||
{
|
||||
let approval_context = ApprovalContext {
|
||||
exec_requirement: None,
|
||||
review_context: crate::guardian::GuardianReviewContext::from(step_context),
|
||||
cancellation_token: Some(cancellation_token.clone()),
|
||||
call_id: call_id.clone(),
|
||||
@@ -3623,8 +3634,9 @@ impl Session {
|
||||
}
|
||||
};
|
||||
match entry {
|
||||
Some(tx_approve) => {
|
||||
tx_approve.send(decision).ok();
|
||||
Some(pending) => {
|
||||
let decision = pending.validate(decision);
|
||||
pending.sender.send(decision).ok();
|
||||
}
|
||||
None => {
|
||||
warn!("No pending approval found for call_id: {approval_id}");
|
||||
|
||||
@@ -88,7 +88,7 @@ pub(crate) struct RunningTask {
|
||||
/// Mutable state for a single turn.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TurnState {
|
||||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pending_approvals: HashMap<String, PendingApproval>,
|
||||
pending_request_permissions: HashMap<String, PendingRequestPermissions>,
|
||||
pending_user_input: HashMap<String, oneshot::Sender<AcceptedUserInputResponse>>,
|
||||
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
|
||||
@@ -104,6 +104,27 @@ pub(crate) struct TurnState {
|
||||
pub(crate) last_known_step_context: Option<Arc<StepContext>>,
|
||||
}
|
||||
|
||||
pub(crate) struct PendingApproval {
|
||||
pub(crate) sender: oneshot::Sender<ReviewDecision>,
|
||||
pub(crate) kind: codex_protocol::approvals::ExecApprovalKind,
|
||||
pub(crate) turn_id: String,
|
||||
}
|
||||
|
||||
impl PendingApproval {
|
||||
pub(crate) fn validate(&self, decision: ReviewDecision) -> ReviewDecision {
|
||||
if self.kind == codex_protocol::approvals::ExecApprovalKind::HumanCommand
|
||||
&& !matches!(
|
||||
decision,
|
||||
ReviewDecision::Approved | ReviewDecision::Denied { .. } | ReviewDecision::Abort
|
||||
)
|
||||
{
|
||||
ReviewDecision::denied("Managed command policy requires a one-time human approval.")
|
||||
} else {
|
||||
decision
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host receipt metadata follows the response through the asynchronous tool waiter.
|
||||
pub(crate) struct AcceptedUserInputResponse {
|
||||
pub(crate) response: RequestUserInputResponse,
|
||||
@@ -124,14 +145,24 @@ impl TurnState {
|
||||
&mut self,
|
||||
key: String,
|
||||
tx: oneshot::Sender<ReviewDecision>,
|
||||
) -> Option<oneshot::Sender<ReviewDecision>> {
|
||||
self.pending_approvals.insert(key, tx)
|
||||
kind: codex_protocol::approvals::ExecApprovalKind,
|
||||
turn_id: String,
|
||||
) -> Option<PendingApproval> {
|
||||
self.pending_approvals.insert(
|
||||
key,
|
||||
PendingApproval {
|
||||
sender: tx,
|
||||
kind,
|
||||
turn_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_pending_approval(
|
||||
&mut self,
|
||||
key: &str,
|
||||
) -> Option<oneshot::Sender<ReviewDecision>> {
|
||||
pub(crate) fn pending_approval(&self, key: &str) -> Option<&PendingApproval> {
|
||||
self.pending_approvals.get(key)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_pending_approval(&mut self, key: &str) -> Option<PendingApproval> {
|
||||
self.pending_approvals.remove(key)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Central approval policy-stage execution and reviewer routing.
|
||||
//! Managed human checkpoints cannot be satisfied by hooks, automated review, or reusable approvals.
|
||||
|
||||
use crate::command_canonicalization::canonicalize_command_for_approval;
|
||||
use crate::exec_policy::prompt_is_rejected_by_policy;
|
||||
@@ -16,6 +17,7 @@ use crate::tools::hook_names::HookToolName;
|
||||
use crate::tools::runtimes::apply_patch::ApplyPatchApprovalKey;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecApprovalKey;
|
||||
use crate::tools::sandboxing::ApprovalRequestReasons;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::tools::sandboxing::PermissionRequestPayload;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use crate::tools::sandboxing::with_cached_approval;
|
||||
@@ -51,6 +53,9 @@ use tracing::warn;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ApprovalContext {
|
||||
/// Preserve the command's policy result through review and retries. Other actions
|
||||
/// use their existing reviewer routing without an execution policy requirement.
|
||||
pub(crate) exec_requirement: Option<ExecApprovalRequirement>,
|
||||
pub(crate) review_context: GuardianReviewContext,
|
||||
pub(crate) cancellation_token: Option<CancellationToken>,
|
||||
pub(crate) call_id: String,
|
||||
@@ -61,6 +66,15 @@ pub(crate) struct ApprovalContext {
|
||||
pub(crate) network_approval_context: Option<NetworkApprovalContext>,
|
||||
}
|
||||
|
||||
impl ApprovalContext {
|
||||
fn requires_human_approval(&self) -> bool {
|
||||
matches!(
|
||||
self.exec_requirement,
|
||||
Some(ExecApprovalRequirement::NeedsHumanApproval { .. })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
#[serde(tag = "tool", rename_all = "snake_case")]
|
||||
pub(crate) enum ApprovalAction {
|
||||
@@ -494,11 +508,42 @@ impl ApprovalResolution {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
async fn check_approval_requirement(&self, ctx: &ApprovalContext) -> Result<(), ToolError> {
|
||||
if ctx.requires_human_approval() {
|
||||
if let Some(reason) = prompt_is_rejected_by_policy(
|
||||
ctx.review_context.approval_policy,
|
||||
/*prompt_is_rule*/ true,
|
||||
) {
|
||||
return Err(ToolError::Rejected(reason.to_string()));
|
||||
}
|
||||
let config = self.get_config().await;
|
||||
let requirements = config.config_layer_stack.requirements();
|
||||
if ctx.strict_auto_review
|
||||
|| ctx.review_context.turn().strict_auto_review_enabled()
|
||||
|| requirements
|
||||
.approvals_reviewer
|
||||
.can_set(&ApprovalsReviewer::User)
|
||||
.is_err()
|
||||
|| requirements.auto_review_required_for_model(&ctx.review_context.model_info.slug)
|
||||
{
|
||||
return Err(ToolError::Rejected("Managed command policy requires human approval, but this session requires automatic review.".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn request_approval(
|
||||
self: &Arc<Self>,
|
||||
action: ApprovalAction,
|
||||
ctx: ApprovalContext,
|
||||
) -> ApprovalResult {
|
||||
if let Err(err) = self.check_approval_requirement(&ctx).await {
|
||||
return ApprovalResult {
|
||||
result: Err(err),
|
||||
source: None,
|
||||
cancellation_reason: None,
|
||||
};
|
||||
}
|
||||
// Retained authority outside current grants needs a new sandbox approval.
|
||||
// Current grants permit review of the input; they never approve the input itself.
|
||||
let policy = ctx.review_context.turn().approval_policy();
|
||||
@@ -523,10 +568,9 @@ impl Session {
|
||||
_ => ctx.call_id.clone(),
|
||||
};
|
||||
|
||||
// Approval precedence is:
|
||||
// 1. Hooks
|
||||
// 2. If StrictAutoReview || Guardian enabled, then Guardian. Else, user.
|
||||
let resolution = match run_permission_request_hooks(
|
||||
// Hook denials always win. Managed checkpoints require a person even
|
||||
// when a hook allows the action or the session uses automatic review.
|
||||
let mut resolution = match run_permission_request_hooks(
|
||||
self,
|
||||
&ctx.review_context,
|
||||
&permission_request_run_id,
|
||||
@@ -535,18 +579,35 @@ impl Session {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(PermissionRequestDecision::Allow) => ApprovalResolution {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: ApprovalResolutionSource::Hook,
|
||||
cancellation_reason: None,
|
||||
},
|
||||
Some(PermissionRequestDecision::Allow) if !ctx.requires_human_approval() => {
|
||||
ApprovalResolution {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: ApprovalResolutionSource::Hook,
|
||||
cancellation_reason: None,
|
||||
}
|
||||
}
|
||||
Some(PermissionRequestDecision::Deny { message }) => ApprovalResolution {
|
||||
decision: ReviewDecision::denied(message),
|
||||
source: ApprovalResolutionSource::Hook,
|
||||
cancellation_reason: None,
|
||||
},
|
||||
None => self.request_reviewer_approval(action, &ctx).await,
|
||||
Some(PermissionRequestDecision::Allow) | None => {
|
||||
self.request_reviewer_approval(action, &ctx).await
|
||||
}
|
||||
};
|
||||
if resolution.decision == ReviewDecision::Approved {
|
||||
if let Err(ToolError::Rejected(reason)) = self.check_approval_requirement(&ctx).await {
|
||||
resolution.decision = ReviewDecision::denied(reason);
|
||||
}
|
||||
if ctx.requires_human_approval()
|
||||
&& ctx
|
||||
.cancellation_token
|
||||
.as_ref()
|
||||
.is_some_and(CancellationToken::is_cancelled)
|
||||
{
|
||||
resolution.decision = ReviewDecision::Abort;
|
||||
}
|
||||
}
|
||||
// Network approvals record their final telemetry after validation and persistence.
|
||||
if !is_network_approval {
|
||||
record_resolution(&ctx, &resolution);
|
||||
@@ -598,7 +659,9 @@ impl Session {
|
||||
action: ApprovalAction,
|
||||
ctx: &ApprovalContext,
|
||||
) -> ApprovalResolution {
|
||||
if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await {
|
||||
if ctx.requires_human_approval() {
|
||||
self.request_user_approval(&action, ctx).await
|
||||
} else if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await {
|
||||
ApprovalResolution {
|
||||
cancellation_reason: (decision == ReviewDecision::Abort)
|
||||
.then_some(McpApprovalCancellationReason::Interrupted),
|
||||
@@ -705,45 +768,68 @@ impl Session {
|
||||
proposed_execpolicy_amendment,
|
||||
..
|
||||
} => {
|
||||
let tool_name = "unified_exec";
|
||||
let reason = ctx
|
||||
.retry_reason
|
||||
.clone()
|
||||
.or_else(|| ctx.approval_reason.clone())
|
||||
.or_else(|| justification.clone());
|
||||
let policy_fingerprint = ctx
|
||||
.review_context
|
||||
.environments()
|
||||
.turn_environments()
|
||||
.find(|environment| environment.selection.environment_id == *environment_id)
|
||||
.and_then(|environment| environment.config().exec_policy.as_ref())
|
||||
.map(codex_execpolicy::RequirementsExecPolicy::fingerprint);
|
||||
let cache_keys = action
|
||||
.cache_keys()
|
||||
.into_iter()
|
||||
.map(|key| (key, &policy_fingerprint))
|
||||
.collect();
|
||||
with_cached_approval(&self.services, tool_name, cache_keys, || async {
|
||||
let human_once = ctx.requires_human_approval();
|
||||
let request = || async {
|
||||
self.request_command_approval(
|
||||
ctx.review_context.turn(),
|
||||
ExecApprovalKind::Command,
|
||||
if human_once {
|
||||
ExecApprovalKind::HumanCommand
|
||||
} else {
|
||||
ExecApprovalKind::Command
|
||||
},
|
||||
ctx.review_context.model_context(),
|
||||
ctx.call_id.clone(),
|
||||
Some(&ctx.call_id),
|
||||
/*approval_id*/ None,
|
||||
human_once.then(|| uuid::Uuid::new_v4().to_string()),
|
||||
Some(environment_id.clone()),
|
||||
command.clone(),
|
||||
cwd.clone(),
|
||||
reason,
|
||||
ctx.network_approval_context.clone(),
|
||||
proposed_execpolicy_amendment.clone(),
|
||||
if human_once {
|
||||
None
|
||||
} else {
|
||||
ctx.network_approval_context.clone()
|
||||
},
|
||||
if human_once {
|
||||
None
|
||||
} else {
|
||||
proposed_execpolicy_amendment.clone()
|
||||
},
|
||||
additional_permissions.clone(),
|
||||
/*available_decisions*/ None,
|
||||
human_once.then(|| {
|
||||
vec![
|
||||
ReviewDecision::Approved,
|
||||
ReviewDecision::denied("rejected by user"),
|
||||
ReviewDecision::Abort,
|
||||
]
|
||||
}),
|
||||
/*plugin_attribution_override*/ None,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
};
|
||||
if human_once {
|
||||
request().await
|
||||
} else {
|
||||
let tool_name = "unified_exec";
|
||||
let policy_fingerprint = ctx
|
||||
.review_context
|
||||
.environments()
|
||||
.turn_environments()
|
||||
.find(|environment| environment.selection.environment_id == *environment_id)
|
||||
.and_then(|environment| environment.config().exec_policy.as_ref())
|
||||
.map(codex_execpolicy::RequirementsExecPolicy::fingerprint);
|
||||
let cache_keys = action
|
||||
.cache_keys()
|
||||
.into_iter()
|
||||
.map(|key| (key, &policy_fingerprint))
|
||||
.collect();
|
||||
with_cached_approval(&self.services, tool_name, cache_keys, request).await
|
||||
}
|
||||
}
|
||||
ApprovalAction::WriteStdin {
|
||||
id,
|
||||
|
||||
@@ -128,6 +128,7 @@ async fn non_utf8_cwd_preserves_approval_routing(
|
||||
review_context.approval_policy = AskForApproval::OnRequest;
|
||||
review_context.approvals_reviewer = reviewer;
|
||||
let context = ApprovalContext {
|
||||
exec_requirement: None,
|
||||
review_context,
|
||||
cancellation_token: None,
|
||||
call_id: "non-utf8-cwd".to_string(),
|
||||
@@ -213,6 +214,7 @@ async fn explicit_mcp_reviewer_override_takes_precedence_over_action_context() {
|
||||
review_context.approval_policy = AskForApproval::OnRequest;
|
||||
review_context.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||
let context = ApprovalContext {
|
||||
exec_requirement: None,
|
||||
review_context,
|
||||
cancellation_token: None,
|
||||
call_id: "mcp-override".to_string(),
|
||||
|
||||
@@ -799,6 +799,7 @@ impl NetworkApprovalService {
|
||||
cwd,
|
||||
};
|
||||
let approval_context = ApprovalContext {
|
||||
exec_requirement: None,
|
||||
review_context,
|
||||
cancellation_token: None,
|
||||
call_id: approval_call_id,
|
||||
|
||||
@@ -164,6 +164,10 @@ impl ToolOrchestrator {
|
||||
let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| {
|
||||
default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy)
|
||||
});
|
||||
let human_once = matches!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsHumanApproval { .. }
|
||||
);
|
||||
match &requirement {
|
||||
ExecApprovalRequirement::Skip { .. } => {
|
||||
if strict_auto_review {
|
||||
@@ -173,6 +177,7 @@ impl ToolOrchestrator {
|
||||
ToolError::Rejected(format!("could not prepare approval action: {err}"))
|
||||
})?;
|
||||
let approval_ctx = ApprovalContext {
|
||||
exec_requirement: Some(requirement.clone()),
|
||||
review_context: GuardianReviewContext::from(&tool_ctx.step_context),
|
||||
cancellation_token: Some(tool_ctx.cancellation_token.clone()),
|
||||
call_id: tool_ctx.call_id.clone(),
|
||||
@@ -200,13 +205,15 @@ impl ToolOrchestrator {
|
||||
ExecApprovalRequirement::Forbidden { reason } => {
|
||||
return Err(ToolError::Rejected(reason.clone()));
|
||||
}
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. }
|
||||
| ExecApprovalRequirement::NeedsHumanApproval { reason } => {
|
||||
let action = tool
|
||||
.approval_action(req, &tool_ctx.call_id)
|
||||
.map_err(|err| {
|
||||
ToolError::Rejected(format!("could not prepare approval action: {err}"))
|
||||
})?;
|
||||
let approval_ctx = ApprovalContext {
|
||||
exec_requirement: Some(requirement.clone()),
|
||||
review_context: GuardianReviewContext::from(&tool_ctx.step_context),
|
||||
cancellation_token: Some(tool_ctx.cancellation_token.clone()),
|
||||
call_id: tool_ctx.call_id.clone(),
|
||||
@@ -483,16 +490,19 @@ impl ToolOrchestrator {
|
||||
|
||||
// Human and policy approvals keep their existing retry scope. A
|
||||
// Guardian verdict only covers the authority of the reviewed action.
|
||||
let retry_preapproved = matches!(
|
||||
initial_approval_source,
|
||||
Some(ApprovalResolutionSource::User | ApprovalResolutionSource::Hook)
|
||||
);
|
||||
let bypass_retry_approval = !strict_auto_review
|
||||
let retry_preapproved = !human_once
|
||||
&& matches!(
|
||||
initial_approval_source,
|
||||
Some(ApprovalResolutionSource::User | ApprovalResolutionSource::Hook)
|
||||
);
|
||||
let bypass_retry_approval = !human_once
|
||||
&& !strict_auto_review
|
||||
&& tool.should_bypass_approval(approval_policy, retry_preapproved)
|
||||
&& network_approval_context.is_none();
|
||||
if !bypass_retry_approval {
|
||||
let approval_reason = match &requirement {
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. } => reason.clone(),
|
||||
ExecApprovalRequirement::NeedsApproval { reason, .. }
|
||||
| ExecApprovalRequirement::NeedsHumanApproval { reason } => reason.clone(),
|
||||
ExecApprovalRequirement::Skip { .. }
|
||||
| ExecApprovalRequirement::Forbidden { .. } => None,
|
||||
};
|
||||
@@ -516,6 +526,7 @@ impl ToolOrchestrator {
|
||||
*additional_permissions = None;
|
||||
}
|
||||
let approval_ctx = ApprovalContext {
|
||||
exec_requirement: Some(requirement.clone()),
|
||||
review_context: GuardianReviewContext::from(&tool_ctx.step_context),
|
||||
cancellation_token: Some(tool_ctx.cancellation_token.clone()),
|
||||
call_id: tool_ctx.call_id.clone(),
|
||||
@@ -526,6 +537,15 @@ impl ToolOrchestrator {
|
||||
network_approval_context: network_approval_context.clone(),
|
||||
};
|
||||
|
||||
if human_once && network_approval_context.is_some() {
|
||||
let mut network_ctx = approval_ctx.clone();
|
||||
network_ctx.exec_requirement = None;
|
||||
tool_ctx
|
||||
.session
|
||||
.request_approval(action.clone(), network_ctx)
|
||||
.await
|
||||
.result?;
|
||||
}
|
||||
tool_ctx
|
||||
.session
|
||||
.request_approval(action, approval_ctx)
|
||||
|
||||
@@ -170,6 +170,8 @@ pub(crate) enum ExecApprovalRequirement {
|
||||
/// See core/src/exec_policy.rs for more details on how proposed_execpolicy_amendment is determined.
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
},
|
||||
/// A fresh human decision is required for each execution attempt.
|
||||
NeedsHumanApproval { reason: Option<String> },
|
||||
/// Execution forbidden for this tool call.
|
||||
Forbidden { reason: String },
|
||||
}
|
||||
|
||||
@@ -972,6 +972,7 @@ impl UnifiedExecProcessManager {
|
||||
return Err(unreviewable_input_error());
|
||||
}
|
||||
let approval_context = ApprovalContext {
|
||||
exec_requirement: None,
|
||||
review_context: GuardianReviewContext::from(&context.step_context),
|
||||
cancellation_token: Some(context.cancellation_token.clone()),
|
||||
call_id: context.call_id.clone(),
|
||||
|
||||
@@ -720,7 +720,7 @@ elif mode == "exit_2":
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_allow_permission_request_hook(home: &Path) -> Result<()> {
|
||||
pub(super) fn install_allow_permission_request_hook(home: &Path) -> Result<()> {
|
||||
write_permission_request_hook(
|
||||
home,
|
||||
Some(PERMISSION_REQUEST_HOOK_MATCHER),
|
||||
@@ -1183,7 +1183,7 @@ fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>>
|
||||
read_hook_inputs_from_log(home.join("pre_tool_use_hook_log.jsonl").as_path())
|
||||
}
|
||||
|
||||
fn read_permission_request_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||
pub(super) fn read_permission_request_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||
fs::read_to_string(home.join("permission_request_hook_log.jsonl"))
|
||||
.context("read permission request hook log")?
|
||||
.lines()
|
||||
|
||||
@@ -123,6 +123,9 @@ mod model_instructions;
|
||||
#[path = "scenarios_catalog_approval_instructions_tests.rs"]
|
||||
mod catalog_approval_instructions;
|
||||
|
||||
#[path = "scenarios_managed_human_approval_tests.rs"]
|
||||
mod managed_human_approval;
|
||||
|
||||
#[path = "scenarios_context_management_tools.rs"]
|
||||
mod context_management_tools;
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! Managed checkpoints require a fresh human decision even with automatic review, hooks, and saved rules.
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use super::super::hooks::install_allow_permission_request_hook;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use super::super::hooks::read_permission_request_hook_inputs;
|
||||
use super::*;
|
||||
use anyhow::Context;
|
||||
use codex_config::test_support::CloudConfigBundleFixture;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use core_test_support::hooks::trust_discovered_hooks;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn managed_checkpoint_requires_fresh_human_decisions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_wine_exec!(Ok(()), "command approval requires host-native paths");
|
||||
let server = start_mock_server().await;
|
||||
let builder = test_codex();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let builder = builder.with_pre_build_hook(|home| {
|
||||
install_allow_permission_request_hook(home).expect("allow permission request hook");
|
||||
});
|
||||
let test = builder
|
||||
.with_cloud_config_bundle(
|
||||
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
||||
r#"
|
||||
allowed_approvals_reviewers = ["auto_review", "user"]
|
||||
[[rules.prefix_rules]]
|
||||
pattern = [{ token = "echo" }]
|
||||
decision = "prompt-user"
|
||||
justification = "A person must approve this command."
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.with_config(|config| {
|
||||
configure_scenario_catalog(config);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
trust_discovered_hooks(config);
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("unified exec");
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::Disabled)
|
||||
.expect("permissions");
|
||||
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||
fs::create_dir_all(config.codex_home.join("rules")).expect("rules directory");
|
||||
fs::write(
|
||||
config.codex_home.join("rules/allow.rules"),
|
||||
r#"prefix_rule(pattern=["echo"], decision="allow")"#,
|
||||
)
|
||||
.expect("saved allowance");
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
let mut requests = Vec::new();
|
||||
let mut approval_ids = HashSet::<String>::new();
|
||||
for (index, decision) in [
|
||||
ReviewDecision::ApprovedForSession,
|
||||
ReviewDecision::Approved,
|
||||
ReviewDecision::Approved,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let call_id = format!("command-{index}");
|
||||
let command = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created(&call_id),
|
||||
ev_function_call(
|
||||
&call_id,
|
||||
"exec_command",
|
||||
&json!({"cmd": "echo checkpoint-ok", "login": false, "yield_time_ms": 10000})
|
||||
.to_string(),
|
||||
),
|
||||
ev_completed(&call_id),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let continuation = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("done", "Done."),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![text("Run the command.")]))
|
||||
.await?;
|
||||
let approval = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::ExecApprovalRequest(approval) => Some(approval.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(
|
||||
read_permission_request_hook_inputs(test.codex_home_path())?.len(),
|
||||
index + 1,
|
||||
);
|
||||
let id = approval
|
||||
.approval_id
|
||||
.context("human approval has a unique id")?;
|
||||
assert!(approval_ids.insert(id.clone()));
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id,
|
||||
turn_id: None,
|
||||
decision,
|
||||
})
|
||||
.await?;
|
||||
requests.push(command.single_request());
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
let result = continuation.single_request();
|
||||
let output = result
|
||||
.function_call_output_text(&call_id)
|
||||
.context("command result")?;
|
||||
if index == 0 {
|
||||
assert!(output.contains("one-time human approval"), "{output}");
|
||||
} else {
|
||||
assert!(output.contains("checkpoint-ok"), "{output}");
|
||||
}
|
||||
requests.push(result);
|
||||
}
|
||||
// Direct Guardian review cannot substitute for the managed human checkpoint either.
|
||||
test.codex
|
||||
.inject_response_items(vec![serde_json::from_value(json!({
|
||||
"type": "function_call", "name": "exec_command", "call_id": "direct-review",
|
||||
"arguments": r#"{"cmd":"echo checkpoint-ok","login":false}"#,
|
||||
}))?])
|
||||
.await?;
|
||||
let error = test
|
||||
.codex
|
||||
.guardian_review(&CancellationToken::new())
|
||||
.await
|
||||
.expect_err("managed checkpoint requires a human");
|
||||
assert!(matches!(
|
||||
error.details(),
|
||||
codex_protocol::error::CodexErrorDetails::InvalidRequest(message)
|
||||
if message == "Managed command policy requires human approval."
|
||||
));
|
||||
let mut snapshot = context_snapshot::format_request_history_snapshot(
|
||||
"Managed command checkpoints override saved allow rules and automatic review. Reusable decisions are rejected, and each successful execution requires a fresh human decision.",
|
||||
&requests,
|
||||
&ContextSnapshotOptions::default().rewrite_known_segments(),
|
||||
);
|
||||
for (pattern, replacement) in [
|
||||
(r"Chunk ID: [a-z0-9]+", "Chunk ID: <CHUNK_ID>"),
|
||||
(
|
||||
r"Wall time: [0-9]+(?:\.[0-9]+)? seconds",
|
||||
"Wall time: <DURATION> seconds",
|
||||
),
|
||||
(
|
||||
r"Original token count: [0-9]+",
|
||||
"Original token count: <TOKEN_COUNT>",
|
||||
),
|
||||
] {
|
||||
snapshot = regex_lite::Regex::new(pattern)?
|
||||
.replace_all(&snapshot, replacement)
|
||||
.into_owned();
|
||||
}
|
||||
insta::assert_snapshot!(snapshot);
|
||||
Ok(())
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios_managed_human_approval_tests.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Scenario: Managed command checkpoints override saved allow rules and automatic review. Reusable decisions are rejected, and each successful execution requires a fresh human decision.
|
||||
|
||||
## Window 1
|
||||
-- request 1 (turn) --
|
||||
00:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
01:message/developer:
|
||||
<PERMISSIONS_INSTRUCTIONS>
|
||||
02:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
03:message/user:
|
||||
Run the command.
|
||||
-- request 2 (turn) --
|
||||
04:function_call/exec_command:{"cmd":"echo checkpoint-ok","login":false,"yield_time_ms":10000}
|
||||
05:function_call_output:exec_command failed: CreateProcess { message: "Rejected(\\"Managed command policy requires a one-time human approval.\\")" }
|
||||
-- request 3 (turn) --
|
||||
06:message/assistant:
|
||||
Done.
|
||||
07:message/user:
|
||||
Run the command.
|
||||
-- request 4 (turn) --
|
||||
08:function_call/exec_command:{"cmd":"echo checkpoint-ok","login":false,"yield_time_ms":10000}
|
||||
09:function_call_output:Chunk ID: <CHUNK_ID>
|
||||
Wall time: <DURATION> seconds
|
||||
Process exited with code 0
|
||||
Original token count: <TOKEN_COUNT>
|
||||
Output:
|
||||
checkpoint-ok
|
||||
|
||||
-- request 5 (turn) --
|
||||
10:message/assistant:
|
||||
Done.
|
||||
11:message/user:
|
||||
Run the command.
|
||||
-- request 6 (turn) --
|
||||
12:function_call/exec_command:{"cmd":"echo checkpoint-ok","login":false,"yield_time_ms":10000}
|
||||
13:function_call_output:Chunk ID: <CHUNK_ID>
|
||||
Wall time: <DURATION> seconds
|
||||
Process exited with code 0
|
||||
Original token count: <TOKEN_COUNT>
|
||||
Output:
|
||||
checkpoint-ok
|
||||
@@ -735,6 +735,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
||||
.await?
|
||||
};
|
||||
let in_process_start_args = InProcessClientStartArgs {
|
||||
required_human_approval: false,
|
||||
arg0_paths,
|
||||
config: std::sync::Arc::new(config.clone()),
|
||||
cli_overrides: run_cli_overrides,
|
||||
|
||||
@@ -28,6 +28,7 @@ pub(super) async fn fork_source(
|
||||
)
|
||||
.await?;
|
||||
let client = InProcessAppServerClient::start(InProcessClientStartArgs {
|
||||
required_human_approval: false,
|
||||
arg0_paths: arg0_paths.clone(),
|
||||
config: std::sync::Arc::new(config.clone()),
|
||||
cli_overrides: cli_overrides.to_vec(),
|
||||
|
||||
@@ -106,6 +106,11 @@ pub fn blocking_append_network_rule(
|
||||
let decision = serde_json::to_string(match decision {
|
||||
Decision::Allow => "allow",
|
||||
Decision::Prompt => "prompt",
|
||||
Decision::PromptUser => {
|
||||
return Err(AmendError::InvalidNetworkRule(
|
||||
"prompt-user is only supported in managed command rules".to_string(),
|
||||
));
|
||||
}
|
||||
Decision::Forbidden => "deny",
|
||||
})
|
||||
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
|
||||
|
||||
@@ -9,8 +9,11 @@ use crate::error::Result;
|
||||
pub enum Decision {
|
||||
/// Command may run without further approval.
|
||||
Allow,
|
||||
/// Request explicit user approval; rejected outright when running with `approval_policy="never"`.
|
||||
/// Request approval from the configured reviewer; rejected with `approval_policy="never"`.
|
||||
Prompt,
|
||||
/// Requires a fresh human decision, independent of the configured reviewer.
|
||||
#[serde(rename = "prompt-user")]
|
||||
PromptUser,
|
||||
/// Command is blocked without further consideration.
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ impl Policy {
|
||||
allowed.retain(|entry| entry != &rule.host);
|
||||
upsert_domain(&mut denied, &rule.host);
|
||||
}
|
||||
Decision::Prompt => {}
|
||||
Decision::Prompt | Decision::PromptUser => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +275,8 @@ pub struct GuardianAssessmentEvent {
|
||||
pub enum ExecApprovalKind {
|
||||
#[default]
|
||||
Command,
|
||||
/// Requires a fresh human decision; automated and reusable approvals are invalid.
|
||||
HumanCommand,
|
||||
WriteStdin,
|
||||
}
|
||||
|
||||
|
||||
@@ -1748,7 +1748,9 @@ impl EventMsg {
|
||||
pub fn command_approval_start(&self) -> Option<(&str, i64)> {
|
||||
match self {
|
||||
Self::ExecApprovalRequest(request)
|
||||
if request.approval_id.is_none() && request.network_approval_context.is_none() =>
|
||||
if request.kind == crate::approvals::ExecApprovalKind::Command
|
||||
&& request.approval_id.is_none()
|
||||
&& request.network_approval_context.is_none() =>
|
||||
{
|
||||
Some((&request.call_id, request.started_at_ms))
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ async fn slow_repairing_chat_listing_keeps_estimate_budget_and_ranks_across_page
|
||||
pages
|
||||
});
|
||||
let remote = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: true,
|
||||
endpoint: RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url,
|
||||
auth_token: None,
|
||||
|
||||
@@ -48,6 +48,7 @@ async fn remote() -> (RemoteAppServerClient, tokio::task::JoinHandle<()>) {
|
||||
assert!(socket.next().await.unwrap().unwrap().is_close());
|
||||
});
|
||||
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: true,
|
||||
endpoint: RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url: format!("ws://{address}"),
|
||||
auth_token: None,
|
||||
|
||||
@@ -87,6 +87,7 @@ async fn consumer_roots_include_paginated_archived_descendants_and_keep_missing_
|
||||
pages
|
||||
});
|
||||
let remote = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: true,
|
||||
endpoint: RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url,
|
||||
auth_token: None,
|
||||
|
||||
@@ -29,6 +29,7 @@ pub(crate) async fn connect(target: &AppServerTarget) -> color_eyre::Result<AppS
|
||||
codex_uds::validate_private_socket_path(socket_path.as_path())?;
|
||||
let app_server =
|
||||
RemoteAppServerClient::connect_local_daemon(RemoteAppServerConnectArgs {
|
||||
required_human_approval: true,
|
||||
endpoint: RemoteAppServerEndpoint::UnixSocket {
|
||||
socket_path: AbsolutePathBuf::from_absolute_path_checked(socket_path)?,
|
||||
},
|
||||
|
||||
@@ -65,6 +65,7 @@ async fn experimental_features_rpc_paginates_thread_config_and_bounds_bad_server
|
||||
page
|
||||
});
|
||||
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: true,
|
||||
endpoint: RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url: url,
|
||||
auth_token: None,
|
||||
|
||||
@@ -527,6 +527,7 @@ async fn connect_remote_app_server(
|
||||
endpoint: RemoteAppServerEndpoint,
|
||||
) -> color_eyre::Result<AppServerClient> {
|
||||
let app_server = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: true,
|
||||
endpoint,
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
@@ -741,6 +742,7 @@ where
|
||||
})
|
||||
.collect();
|
||||
let client = start_client(InProcessClientStartArgs {
|
||||
required_human_approval: true,
|
||||
arg0_paths,
|
||||
config: Arc::new(config),
|
||||
cli_overrides: cli_kv_overrides,
|
||||
|
||||
@@ -528,6 +528,7 @@ async fn verifies_name_uniqueness_across_server_pages() -> color_eyre::Result<()
|
||||
}
|
||||
});
|
||||
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
|
||||
required_human_approval: true,
|
||||
endpoint: RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url: url,
|
||||
auth_token: None,
|
||||
|
||||
@@ -1285,6 +1285,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut auth_config = config.auth_config();
|
||||
let client = InProcessAppServerClient::start(InProcessClientStartArgs {
|
||||
required_human_approval: true,
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config: Arc::new(config),
|
||||
cli_overrides: Vec::new(),
|
||||
|
||||
@@ -2126,6 +2126,13 @@ class InitializeCapabilities(BaseModel):
|
||||
description="Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
|
||||
),
|
||||
] = False
|
||||
required_human_approval: Annotated[
|
||||
bool | None,
|
||||
Field(
|
||||
alias="requiredHumanApproval",
|
||||
description="Present `humanCommand` approvals to a person and return only a one-time decision. Requires `experimentalApi`. Pending requests can be replayed to compatible clients.",
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
class InitializeParams(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user