diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7ceda36a6aee..ff6113168a3c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -880,6 +880,7 @@ fn sample_guardian_review_completed( risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, }, action: GuardianApprovalReviewAction::Command { source: AppServerGuardianCommandSource::Shell, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index faf5a96879d2..f432a7c79433 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1520,6 +1520,16 @@ "GuardianApprovalReview": { "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", "properties": { + "denialKind": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianDenialKind" + }, + { + "type": "null" + } + ] + }, "rationale": { "type": [ "string", @@ -1769,6 +1779,14 @@ ], "type": "string" }, + "GuardianDenialKind": { + "description": "[UNSTABLE] Whether a denied action can be explicitly approved for one retry.", + "enum": [ + "soft", + "denial" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by approval auto-review.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6aa4c5f35e00..a7d9f0c28121 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9545,6 +9545,16 @@ "GuardianApprovalReview": { "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", "properties": { + "denialKind": { + "anyOf": [ + { + "$ref": "#/definitions/v2/GuardianDenialKind" + }, + { + "type": "null" + } + ] + }, "rationale": { "type": [ "string", @@ -9794,6 +9804,14 @@ ], "type": "string" }, + "GuardianDenialKind": { + "description": "[UNSTABLE] Whether a denied action can be explicitly approved for one retry.", + "enum": [ + "soft", + "denial" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by approval auto-review.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6d859ee5f38c..634daca9d53e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6018,6 +6018,16 @@ "GuardianApprovalReview": { "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", "properties": { + "denialKind": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianDenialKind" + }, + { + "type": "null" + } + ] + }, "rationale": { "type": [ "string", @@ -6267,6 +6277,14 @@ ], "type": "string" }, + "GuardianDenialKind": { + "description": "[UNSTABLE] Whether a denied action can be explicitly approved for one retry.", + "enum": [ + "soft", + "denial" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by approval auto-review.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json index a366c99a41ac..4841ce6f6600 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -264,6 +264,16 @@ "GuardianApprovalReview": { "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", "properties": { + "denialKind": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianDenialKind" + }, + { + "type": "null" + } + ] + }, "rationale": { "type": [ "string", @@ -513,6 +523,14 @@ ], "type": "string" }, + "GuardianDenialKind": { + "description": "[UNSTABLE] Whether a denied action can be explicitly approved for one retry.", + "enum": [ + "soft", + "denial" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by approval auto-review.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json index bc081c7be927..a4846d5bf0ce 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -257,6 +257,16 @@ "GuardianApprovalReview": { "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", "properties": { + "denialKind": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianDenialKind" + }, + { + "type": "null" + } + ] + }, "rationale": { "type": [ "string", @@ -506,6 +516,14 @@ ], "type": "string" }, + "GuardianDenialKind": { + "description": "[UNSTABLE] Whether a denied action can be explicitly approved for one retry.", + "enum": [ + "soft", + "denial" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by approval auto-review.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReview.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReview.ts index 11d797eb194e..18822c3dda84 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReview.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReview.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatus"; +import type { GuardianDenialKind } from "./GuardianDenialKind"; import type { GuardianRiskLevel } from "./GuardianRiskLevel"; import type { GuardianUserAuthorization } from "./GuardianUserAuthorization"; @@ -10,4 +11,4 @@ import type { GuardianUserAuthorization } from "./GuardianUserAuthorization"; * `item/autoApprovalReview/*` notifications. This shape is expected to change * soon. */ -export type GuardianApprovalReview = { status: GuardianApprovalReviewStatus, riskLevel: GuardianRiskLevel | null, userAuthorization: GuardianUserAuthorization | null, rationale: string | null, }; +export type GuardianApprovalReview = { status: GuardianApprovalReviewStatus, riskLevel: GuardianRiskLevel | null, userAuthorization: GuardianUserAuthorization | null, rationale: string | null, denialKind: GuardianDenialKind | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GuardianDenialKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianDenialKind.ts new file mode 100644 index 000000000000..3629b65b5218 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianDenialKind.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * [UNSTABLE] Whether a denied action can be explicitly approved for one retry. + */ +export type GuardianDenialKind = "soft" | "denial"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 48e1f9580b7f..7cd875902732 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -145,6 +145,7 @@ export type { GuardianApprovalReview } from "./GuardianApprovalReview"; export type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction"; export type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatus"; export type { GuardianCommandSource } from "./GuardianCommandSource"; +export type { GuardianDenialKind } from "./GuardianDenialKind"; export type { GuardianRiskLevel } from "./GuardianRiskLevel"; export type { GuardianUserAuthorization } from "./GuardianUserAuthorization"; export type { GuardianWarningNotification } from "./GuardianWarningNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 17e0f9aef48a..178d1b628d9e 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -234,6 +234,7 @@ pub fn guardian_auto_approval_review_notification( risk_level: assessment.risk_level.map(Into::into), user_authorization: assessment.user_authorization.map(Into::into), rationale: assessment.rationale.clone(), + denial_kind: assessment.denial_kind.map(Into::into), }; let action = assessment.action.clone().into(); match assessment.status { diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index aa65f9ab9d84..3c6a4020e4b4 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2324,6 +2324,7 @@ mod tests { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: serde_json::from_value(serde_json::json!({ "type": "command", @@ -2343,6 +2344,7 @@ mod tests { risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low), rationale: Some("Would delete user data.".into()), + denial_kind: Some(codex_protocol::protocol::GuardianDenialKind::Soft), decision_source: Some( codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, ), @@ -2410,6 +2412,7 @@ mod tests { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: serde_json::from_value(serde_json::json!({ "type": "execve", diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index d68485565ee2..e01c6e47ded7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -13,6 +13,7 @@ use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; +use codex_protocol::approvals::GuardianDenialKind as CoreGuardianDenialKind; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; use codex_protocol::items::TurnItem as CoreTurnItem; @@ -469,6 +470,24 @@ impl From for GuardianUserAuthorization { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Whether a denied action can be explicitly approved for one retry. +pub enum GuardianDenialKind { + Soft, + Denial, +} + +impl From for GuardianDenialKind { + fn from(value: CoreGuardianDenialKind) -> Self { + match value { + CoreGuardianDenialKind::Soft => Self::Soft, + CoreGuardianDenialKind::Denial => Self::Denial, + } + } +} + /// [UNSTABLE] Temporary approval auto-review payload used by /// `item/autoApprovalReview/*` notifications. This shape is expected to change /// soon. @@ -480,6 +499,7 @@ pub struct GuardianApprovalReview { pub risk_level: Option, pub user_authorization: Option, pub rationale: Option, + pub denial_kind: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs index 9ec1fb80cb36..cb881d626726 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -233,7 +233,7 @@ impl From for AskForApproval { pub enum ApprovalsReviewer { #[serde(rename = "user")] User, - #[serde(rename = "guardian_subagent", alias = "auto_review")] + #[serde(rename = "auto_review", alias = "guardian_subagent")] AutoReview, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 1c1e92b36f34..452cf1da70c1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -65,7 +65,7 @@ fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagen ); assert_eq!( serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), - "\"guardian_subagent\"" + "\"auto_review\"" ); for value in ["user", "auto_review", "guardian_subagent"] { @@ -2198,6 +2198,7 @@ fn automatic_approval_review_deserializes_aborted_status() { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, } ); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index dc02ff8c049f..170c74479e88 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1272,6 +1272,7 @@ All items emit shared lifecycle events: - `item/completed` — sends the final `item` once that work itself finishes (for example, after a tool call or message completes); treat this as the authoritative execution/result state. - `item/autoApprovalReview/started` — [UNSTABLE] temporary auto-review notification carrying `{threadId, turnId, targetItemId, review, action}` when approval auto-review begins. This shape is expected to change soon. - `item/autoApprovalReview/completed` — [UNSTABLE] temporary auto-review notification carrying `{threadId, turnId, targetItemId, review, action}` when approval auto-review resolves. This shape is expected to change soon. + For denied reviews, `review.denialKind` is `"soft"` when the exact action is eligible for one explicit retry and `"denial"` when it is not user-overridable. A null `denialKind` indicates an operational review failure rather than a policy denial. `review` is [UNSTABLE] and currently has `{status, riskLevel?, userAuthorization?, rationale?}`, where `status` is one of `inProgress`, `approved`, `denied`, or `aborted`. `riskLevel` is one of `"low"`, `"medium"`, `"high"`, or `"critical"` when present. `userAuthorization` is one of `"unknown"`, `"low"`, `"medium"`, or `"high"` when present. `action` is a tagged union with `type: "command" | "execve" | "applyPatch" | "networkAccess" | "mcpToolCall"`. Command-like actions include a `source` discriminator (`"shell"` or `"unifiedExec"`). These notifications are separate from the target item's own `item/completed` lifecycle and are intentionally temporary while the auto-review app protocol is still being designed. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f4a5f3b45f01..7edeed3876a8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2280,6 +2280,8 @@ mod tests { risk_level, user_authorization, rationale, + denial_kind: matches!(status, GuardianAssessmentStatus::Denied) + .then_some(codex_protocol::protocol::GuardianDenialKind::Soft), decision_source: if matches!(status, GuardianAssessmentStatus::InProgress) { None } else { @@ -2346,6 +2348,7 @@ mod tests { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: action.clone(), }, @@ -2392,6 +2395,7 @@ mod tests { risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low), rationale: Some("too risky".to_string()), + denial_kind: Some(codex_protocol::protocol::GuardianDenialKind::Soft), decision_source: Some( codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, ), @@ -2446,6 +2450,7 @@ mod tests { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: Some( codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, ), diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index b26c2e4df393..364fcaf0cfbf 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1756,8 +1756,14 @@ impl ThreadRequestProcessor { params: ThreadApproveGuardianDeniedActionParams, ) -> Result { let ThreadApproveGuardianDeniedActionParams { thread_id, event } = params; - let event = serde_json::from_value(event) - .map_err(|err| invalid_request(format!("invalid Guardian denial event: {err}")))?; + let event: codex_protocol::protocol::GuardianAssessmentEvent = + serde_json::from_value(event) + .map_err(|err| invalid_request(format!("invalid Guardian denial event: {err}")))?; + if !event.is_explicit_retry_eligible() { + return Err(invalid_request( + "Guardian denial is not eligible for an explicit retry", + )); + } let (_, thread) = self.load_thread(&thread_id).await?; self.submit_core_op( diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index b4920f1ff6fc..5d0eb96cf8c6 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -17,10 +17,14 @@ mod prompt; mod review; mod review_session; +use std::collections::HashMap; use std::time::Duration; +use codex_protocol::protocol::GuardianAssessmentAction; use codex_protocol::protocol::GuardianAssessmentDecisionSource; +use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentOutcome; +use codex_protocol::protocol::GuardianDenialKind; use serde::Deserialize; use serde::Serialize; @@ -66,12 +70,62 @@ pub(crate) struct GuardianAssessment { pub(crate) user_authorization: codex_protocol::protocol::GuardianUserAuthorization, pub(crate) outcome: GuardianAssessmentOutcome, pub(crate) rationale: String, + pub(crate) denial_kind: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct GuardianRejection { pub(crate) rationale: String, pub(crate) source: GuardianAssessmentDecisionSource, + pub(crate) denial_kind: Option, +} + +#[derive(Debug)] +struct GuardianDeniedAction { + action: GuardianAssessmentAction, + denial_kind: GuardianDenialKind, +} + +#[derive(Debug, Default)] +pub(crate) struct GuardianDeniedActionRegistry { + actions: HashMap, +} + +impl GuardianDeniedActionRegistry { + pub(crate) fn record( + &mut self, + review_id: String, + action: GuardianAssessmentAction, + denial_kind: GuardianDenialKind, + ) { + self.actions.insert( + review_id, + GuardianDeniedAction { + action, + denial_kind, + }, + ); + } + + pub(crate) fn remove(&mut self, review_id: &str) { + self.actions.remove(review_id); + } + + pub(crate) fn claim_explicit_retry(&mut self, event: &GuardianAssessmentEvent) -> bool { + if !event.is_explicit_retry_eligible() { + return false; + } + let Some(denied_action) = self.actions.get(&event.id) else { + return false; + }; + if denied_action.denial_kind != GuardianDenialKind::Soft + || denied_action.action != event.action + { + return false; + } + self.actions.remove(&event.id); + true + } } #[derive(Debug, Default)] diff --git a/codex-rs/core/src/guardian/policy_template.md b/codex-rs/core/src/guardian/policy_template.md index 0673e52990d6..112d0a750c1e 100644 --- a/codex-rs/core/src/guardian/policy_template.md +++ b/codex-rs/core/src/guardian/policy_template.md @@ -28,6 +28,9 @@ Your primary objective is to determine whether the action poses a risk of irreve - `high`: dangerous or costly-to-reverse actions that pose a risk of irreversible damage or disruption of important services. - `critical`: obvious credential/secret exfiltration to untrusted destinations, major irreversible destruction, or persistent security weakening with broad blast radius. - Before assigning `high` or `critical` for a network action, identify what data is actually leaving: internal file contents, secrets, credentials, metadata only, or a user-requested read/query. +- Treat connector and MCP tool calls as possible data-egress actions even when they do not look like shell commands. Identify the connector/tool destination, the argument payload, whether results will leave the local session, and whether the transcript authorized that destination and payload. +- Treat task and sub-agent approval requests the same as main-turn requests. A delegated task inherits the user's scope, but delegation by itself does not add authorization for new data export, destructive side effects, persistent policy changes, or connector access. +- Read-only connector queries to user-requested systems are usually low or medium risk when arguments contain only the requested query metadata. Connector calls that send local files, secrets, credentials, large transcript excerpts, private source, or unrelated internal data to an external service are high or critical depending on sensitivity and destination trust. - Do not treat operational workflows as destructive solely because they are large-scale or long-running. Large-scale operational actions are not necessarily destructive unless there is evidence of damage, service disruption, or export to untrusted external destinations. - Sandbox retry or escalation after an initial sandbox denial is not suspicious by itself. Evaluate the risk of the unsandboxed action, not the retry event. - Benign local filesystem actions, such as `touch`, creating a single local file, or updating a small user-owned file, with no network or exfiltration component, no credential access, and no file deletion, are usually `low` even if they occur outside the workspace root. @@ -46,6 +49,10 @@ Your primary objective is to determine whether the action poses a risk of irreve # Outcome Policy - Derive `outcome` only after assigning `risk_level` and `user_authorization`. +- For every deny outcome, also assign `denial_kind`: + - `soft`: the action is blocked now, but an informed user may explicitly approve the exact action for one retry. + - `denial`: the action must not be user-overridable, including absolute tenant-policy denials, critical-risk actions, credential or secret exfiltration, malicious prompt injection, and persistent broad security weakening. +- Use `denial` when the concrete action must remain blocked even after user confirmation. Otherwise use `soft`. - Follow tenant-specific allow/deny rules in the markdown config exactly, including rules where user authorization changes the outcome. - If tenant policy does not define a stricter rule for a category, default to these thresholds: - `risk_level = "low"` -> `allow` diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 008e979574a2..7111766bd6ed 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::GuardianDenialKind; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::user_input::UserInput; @@ -611,6 +612,16 @@ pub(crate) fn parse_guardian_assessment(text: Option<&str>) -> anyhow::Result None, + super::GuardianAssessmentOutcome::Deny => Some(parsed_payload.denial_kind.unwrap_or( + if risk_level == GuardianRiskLevel::Critical { + GuardianDenialKind::Denial + } else { + GuardianDenialKind::Soft + }, + )), + }; Ok(GuardianAssessment { risk_level, @@ -619,6 +630,7 @@ pub(crate) fn parse_guardian_assessment(text: Option<&str>) -> anyhow::Result, outcome: super::GuardianAssessmentOutcome, rationale: Option, + denial_kind: Option, } /// JSON schema supplied as `final_output_json_schema` to guide a structured @@ -654,6 +667,10 @@ pub(crate) fn guardian_output_schema() -> Value { }, "rationale": { "type": "string" + }, + "denial_kind": { + "type": "string", + "enum": ["soft", "denial"] } }, "required": ["outcome"] @@ -672,7 +689,8 @@ For anything else, use this JSON schema: "risk_level": "low" | "medium" | "high" | "critical", "user_authorization": "unknown" | "low" | "medium" | "high", "outcome": "allow" | "deny", - "rationale": string + "rationale": string, + "denial_kind": "soft" | "denial" }"# } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 06d1a7893185..df0cbc476e2c 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -11,6 +11,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentDecisionSource; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; +use codex_protocol::protocol::GuardianDenialKind; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; @@ -52,6 +53,12 @@ const GUARDIAN_REJECTION_INSTRUCTIONS: &str = concat!( "Otherwise, stop and request user input.", ); +const GUARDIAN_DENIAL_INSTRUCTIONS: &str = concat!( + "This denial cannot be approved by the user. ", + "Do not attempt the same outcome via workaround, indirect execution, or policy circumvention. ", + "Proceed only with a materially safer alternative, or stop and request user input." +); + const GUARDIAN_TIMEOUT_INSTRUCTIONS: &str = concat!( "The automatic permission approval review did not finish before its deadline. ", "Do not assume the action is unsafe based on the timeout alone. ", @@ -73,13 +80,21 @@ pub(crate) async fn guardian_rejection_message(session: &Session, review_id: &st .unwrap_or_else(|| GuardianRejection { rationale: "Auto-reviewer denied the action without a specific rationale.".to_string(), source: GuardianAssessmentDecisionSource::Agent, + denial_kind: None, }); match rejection.source { - GuardianAssessmentDecisionSource::Agent => format!( - "This action was rejected due to unacceptable risk.\nReason: {}\n{}", - rejection.rationale.trim(), - GUARDIAN_REJECTION_INSTRUCTIONS - ), + GuardianAssessmentDecisionSource::Agent => { + let rationale = rejection.rationale.trim(); + if rejection.denial_kind == Some(GuardianDenialKind::Denial) { + format!( + "This action was denied due to unacceptable risk.\nReason: {rationale}\n{GUARDIAN_DENIAL_INSTRUCTIONS}", + ) + } else { + format!( + "This action was rejected due to unacceptable risk.\nReason: {rationale}\n{GUARDIAN_REJECTION_INSTRUCTIONS}", + ) + } + } } } @@ -287,6 +302,7 @@ async fn run_guardian_review( risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: action_summary.clone(), }), @@ -324,6 +340,7 @@ async fn run_guardian_review( risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: action_summary, }), @@ -416,6 +433,7 @@ async fn run_guardian_review( risk_level: None, user_authorization: None, rationale: Some(rationale), + denial_kind: None, decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: terminal_action, }), @@ -451,6 +469,7 @@ async fn run_guardian_review( risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: action_summary, }), @@ -490,6 +509,7 @@ async fn run_guardian_review( user_authorization: GuardianUserAuthorization::Unknown, outcome: GuardianAssessmentOutcome::Deny, rationale, + denial_kind: None, }, false, ) @@ -508,8 +528,15 @@ async fn run_guardian_review( GuardianUserAuthorization::Medium => "medium", GuardianUserAuthorization::High => "high", }; + let denial_kind = assessment + .denial_kind + .map(|kind| match kind { + GuardianDenialKind::Soft => ", denial: soft", + GuardianDenialKind::Denial => ", denial", + }) + .unwrap_or_default(); let warning = format!( - "Automatic approval review {verdict} (risk: {}, authorization: {user_authorization}): {}", + "Automatic approval review {verdict} (risk: {}, authorization: {user_authorization}{denial_kind}): {}", guardian_risk_level_str(assessment.risk_level), assessment.rationale ); @@ -532,10 +559,23 @@ async fn run_guardian_review( let rejection = GuardianRejection { rationale: assessment.rationale.clone(), source: GuardianAssessmentDecisionSource::Agent, + denial_kind: assessment.denial_kind, }; rationales.insert(review_id.clone(), rejection); } } + { + let mut denied_actions = session.services.guardian_denied_actions.lock().await; + if !approved && assessment.denial_kind == Some(GuardianDenialKind::Soft) { + denied_actions.record( + review_id.clone(), + terminal_action.clone(), + GuardianDenialKind::Soft, + ); + } else { + denied_actions.remove(&review_id); + } + } session .send_event( turn.as_ref(), @@ -549,6 +589,7 @@ async fn run_guardian_review( risk_level: Some(assessment.risk_level), user_authorization: Some(assessment.user_authorization), rationale: Some(assessment.rationale.clone()), + denial_kind: assessment.denial_kind, decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: terminal_action, }), diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index ffadd801ca60..e3c6651713b9 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -43,6 +43,7 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::GuardianAssessmentStatus; +use codex_protocol::protocol::GuardianDenialKind; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; @@ -103,6 +104,47 @@ fn guardian_rejection_circuit_breaker_interrupts_after_three_consecutive_denials ); } +#[test] +fn guardian_denied_action_registry_claims_only_exact_soft_denial_once() { + let action = codex_protocol::protocol::GuardianAssessmentAction::Command { + source: codex_protocol::protocol::GuardianCommandSource::Shell, + command: "rm -f /tmp/guardian".to_string(), + cwd: test_path_buf("/tmp").abs(), + }; + let mut event = codex_protocol::protocol::GuardianAssessmentEvent { + id: "soft-1".to_string(), + target_item_id: None, + turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: Some(1), + status: GuardianAssessmentStatus::Denied, + risk_level: Some(GuardianRiskLevel::High), + user_authorization: Some(GuardianUserAuthorization::Low), + rationale: Some("Needs explicit approval".to_string()), + denial_kind: Some(GuardianDenialKind::Soft), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), + action: action.clone(), + }; + let mut registry = GuardianDeniedActionRegistry::default(); + + registry.record(event.id.clone(), action.clone(), GuardianDenialKind::Soft); + assert!(registry.claim_explicit_retry(&event)); + assert!(!registry.claim_explicit_retry(&event)); + + event.id = "denial-1".to_string(); + registry.record(event.id.clone(), action.clone(), GuardianDenialKind::Denial); + assert!(!registry.claim_explicit_retry(&event)); + + event.id = "altered-1".to_string(); + registry.record(event.id.clone(), action, GuardianDenialKind::Soft); + event.action = codex_protocol::protocol::GuardianAssessmentAction::Command { + source: codex_protocol::protocol::GuardianCommandSource::Shell, + command: "rm -f /tmp/different".to_string(), + cwd: test_path_buf("/tmp").abs(), + }; + assert!(!registry.claim_explicit_retry(&event)); +} + #[test] fn guardian_rejection_circuit_breaker_resets_consecutive_denials_on_non_denial() { let mut circuit_breaker = GuardianRejectionCircuitBreaker::default(); @@ -1235,6 +1277,7 @@ fn parse_guardian_assessment_extracts_embedded_json() { user_authorization: GuardianUserAuthorization::Low, outcome: GuardianAssessmentOutcome::Allow, rationale: "ok".to_string(), + denial_kind: None, } ); } @@ -1251,6 +1294,7 @@ fn parse_guardian_assessment_treats_bare_allow_as_low_risk() { user_authorization: GuardianUserAuthorization::Unknown, outcome: GuardianAssessmentOutcome::Allow, rationale: "Auto-review returned a low-risk allow decision.".to_string(), + denial_kind: None, } ); } @@ -1267,10 +1311,38 @@ fn parse_guardian_assessment_treats_bare_deny_as_high_risk() { user_authorization: GuardianUserAuthorization::Unknown, outcome: GuardianAssessmentOutcome::Deny, rationale: "Auto-review returned a deny decision without a rationale.".to_string(), + denial_kind: Some(GuardianDenialKind::Soft), + } + ); +} + +#[test] +fn parse_guardian_assessment_preserves_explicit_denial() { + let parsed = parse_guardian_assessment(Some( + r#"{"risk_level":"high","user_authorization":"high","outcome":"deny","denial_kind":"denial","rationale":"Absolute tenant policy deny."}"#, + )) + .expect("guardian assessment"); + + assert_eq!( + parsed, + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::High, + outcome: GuardianAssessmentOutcome::Deny, + rationale: "Absolute tenant policy deny.".to_string(), + denial_kind: Some(GuardianDenialKind::Denial), } ); } +#[test] +fn parse_guardian_assessment_defaults_critical_deny_to_denial_kind() { + let parsed = parse_guardian_assessment(Some(r#"{"risk_level":"critical","outcome":"deny"}"#)) + .expect("guardian assessment"); + + assert_eq!(parsed.denial_kind, Some(GuardianDenialKind::Denial)); +} + #[test] fn guardian_output_schema_requires_only_outcome_and_allows_optional_details() { let schema = guardian_output_schema(); @@ -1295,6 +1367,10 @@ fn guardian_output_schema_requires_only_outcome_and_allows_optional_details() { }, "rationale": { "type": "string" + }, + "denial_kind": { + "type": "string", + "enum": ["soft", "denial"] } }, "required": ["outcome"] @@ -1496,6 +1572,10 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() }, "rationale": { "type": "string" + }, + "denial_kind": { + "type": "string", + "enum": ["soft", "denial"] } }, "required": ["outcome"] diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index bd71c3a0546e..db19e1ce9fba 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1668,6 +1668,7 @@ async fn guardian_review_decision_maps_to_mcp_tool_decision() { crate::guardian::GuardianRejection { rationale: "too risky".to_string(), source: codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, + denial_kind: Some(codex_protocol::protocol::GuardianDenialKind::Soft), }, ); let denial = mcp_tool_approval_decision_from_guardian( diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 7730a30ad77e..81e718ce08f6 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -33,7 +33,6 @@ use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentEvent; -use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; @@ -887,10 +886,18 @@ pub(super) async fn submission_loop( } async fn approve_guardian_denied_action(sess: &Arc, event: GuardianAssessmentEvent) { - if event.status != GuardianAssessmentStatus::Denied { + if !sess + .services + .guardian_denied_actions + .lock() + .await + .claim_explicit_retry(&event) + { warn!( review_id = event.id.as_str(), - "ignoring approval for non-denied Guardian assessment" + status = ?event.status, + denial_kind = ?event.denial_kind, + "ignoring Guardian assessment without a matching one-use soft denial" ); return; } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 6ab7e86f32f6..276df7ddc0ef 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1012,6 +1012,7 @@ impl Session { session_telemetry, models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), + guardian_denied_actions: Mutex::new(Default::default()), guardian_rejections: Mutex::new(HashMap::new()), guardian_rejection_circuit_breaker: Mutex::new(Default::default()), runtime_handle: tokio::runtime::Handle::current(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4d4709e65df8..6f26afb603de 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4741,6 +4741,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_telemetry: session_telemetry.clone(), models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), + guardian_denied_actions: Mutex::new(Default::default()), guardian_rejections: Mutex::new(std::collections::HashMap::new()), guardian_rejection_circuit_breaker: Mutex::new(Default::default()), runtime_handle: tokio::runtime::Handle::current(), @@ -6831,6 +6832,7 @@ where session_telemetry: session_telemetry.clone(), models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), + guardian_denied_actions: Mutex::new(Default::default()), guardian_rejections: Mutex::new(std::collections::HashMap::new()), guardian_rejection_circuit_breaker: Mutex::new(Default::default()), runtime_handle: tokio::runtime::Handle::current(), diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 59def9c7abac..91b60948e148 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -8,6 +8,7 @@ use crate::client::ModelClient; use crate::config::NetworkProxyAuditMetadata; use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; +use crate::guardian::GuardianDeniedActionRegistry; use crate::guardian::GuardianRejection; use crate::guardian::GuardianRejectionCircuitBreaker; use crate::mcp::McpManager; @@ -57,6 +58,7 @@ pub(crate) struct SessionServices { pub(crate) models_manager: SharedModelsManager, pub(crate) session_telemetry: SessionTelemetry, pub(crate) tool_approvals: Mutex, + pub(crate) guardian_denied_actions: Mutex, pub(crate) guardian_rejections: Mutex>, pub(crate) guardian_rejection_circuit_breaker: Mutex, pub(crate) runtime_handle: Handle, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index ace096359c2b..2531f9f13ca4 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -108,6 +108,14 @@ pub enum GuardianAssessmentOutcome { Deny, } +/// Whether a Guardian denial is eligible for an explicit user-approved retry. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum GuardianDenialKind { + Soft, + Denial, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum GuardianAssessmentStatus { @@ -206,6 +214,10 @@ pub struct GuardianAssessmentEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub rationale: Option, + /// Whether a denied action can be explicitly approved for one retry. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub denial_kind: Option, /// Source that produced the terminal assessment decision. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -214,6 +226,13 @@ pub struct GuardianAssessmentEvent { pub action: GuardianAssessmentAction, } +impl GuardianAssessmentEvent { + pub fn is_explicit_retry_eligible(&self) -> bool { + self.status == GuardianAssessmentStatus::Denied + && self.denial_kind == Some(GuardianDenialKind::Soft) + } +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated command execution item. @@ -448,4 +467,38 @@ mod tests { } ); } + + #[test] + fn only_soft_denials_are_eligible_for_explicit_retry() { + let mut event = GuardianAssessmentEvent { + id: "guardian-1".to_string(), + target_item_id: None, + turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: Some(1), + status: GuardianAssessmentStatus::Denied, + risk_level: None, + user_authorization: None, + rationale: None, + denial_kind: Some(GuardianDenialKind::Soft), + decision_source: None, + action: GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -f /tmp/example".to_string(), + cwd: test_path_buf("/tmp").abs(), + }, + }; + + assert!(event.is_explicit_retry_eligible()); + + event.denial_kind = Some(GuardianDenialKind::Denial); + assert!(!event.is_explicit_retry_eligible()); + + event.denial_kind = None; + assert!(!event.is_explicit_retry_eligible()); + + event.denial_kind = Some(GuardianDenialKind::Soft); + event.status = GuardianAssessmentStatus::Approved; + assert!(!event.is_explicit_retry_eligible()); + } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2674492e8847..20080a6d8bf7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -70,6 +70,7 @@ pub use crate::approvals::GuardianAssessmentEvent; pub use crate::approvals::GuardianAssessmentOutcome; pub use crate::approvals::GuardianAssessmentStatus; pub use crate::approvals::GuardianCommandSource; +pub use crate::approvals::GuardianDenialKind; pub use crate::approvals::GuardianRiskLevel; pub use crate::approvals::GuardianUserAuthorization; pub use crate::approvals::NetworkApprovalContext; diff --git a/codex-rs/tui/src/auto_review_denials.rs b/codex-rs/tui/src/auto_review_denials.rs index 149a60f04939..12f9c54d7254 100644 --- a/codex-rs/tui/src/auto_review_denials.rs +++ b/codex-rs/tui/src/auto_review_denials.rs @@ -94,6 +94,7 @@ mod tests { risk_level: None, user_authorization: None, rationale: Some(format!("rationale {id}")), + denial_kind: None, decision_source: None, action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, diff --git a/codex-rs/tui/src/chatwidget/protocol_requests.rs b/codex-rs/tui/src/chatwidget/protocol_requests.rs index 5b53f44fdf8f..814e130f7f0d 100644 --- a/codex-rs/tui/src/chatwidget/protocol_requests.rs +++ b/codex-rs/tui/src/chatwidget/protocol_requests.rs @@ -123,6 +123,14 @@ impl ChatWidget { } }), rationale: review.rationale, + denial_kind: review.denial_kind.map(|denial_kind| match denial_kind { + codex_app_server_protocol::GuardianDenialKind::Soft => { + codex_protocol::approvals::GuardianDenialKind::Soft + } + codex_app_server_protocol::GuardianDenialKind::Denial => { + codex_protocol::approvals::GuardianDenialKind::Denial + } + }), decision_source: decision_source.map(|source| match source { codex_app_server_protocol::AutoReviewDecisionSource::Agent => { GuardianAssessmentDecisionSource::Agent diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2da524910aab..d042bd5db40a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -58,6 +58,7 @@ pub(super) use codex_app_server_protocol::GuardianApprovalReview; pub(super) use codex_app_server_protocol::GuardianApprovalReviewAction as AppServerGuardianApprovalReviewAction; pub(super) use codex_app_server_protocol::GuardianApprovalReviewStatus; pub(super) use codex_app_server_protocol::GuardianCommandSource as AppServerGuardianCommandSource; +pub(super) use codex_app_server_protocol::GuardianDenialKind as AppServerGuardianDenialKind; pub(super) use codex_app_server_protocol::GuardianRiskLevel as AppServerGuardianRiskLevel; pub(super) use codex_app_server_protocol::GuardianUserAuthorization as AppServerGuardianUserAuthorization; pub(super) use codex_app_server_protocol::GuardianWarningNotification; @@ -138,6 +139,7 @@ pub(super) use codex_protocol::approvals::GuardianAssessmentDecisionSource; pub(super) use codex_protocol::approvals::GuardianAssessmentEvent; pub(super) use codex_protocol::approvals::GuardianAssessmentStatus; pub(super) use codex_protocol::approvals::GuardianCommandSource; +pub(super) use codex_protocol::approvals::GuardianDenialKind; pub(super) use codex_protocol::approvals::GuardianRiskLevel; pub(super) use codex_protocol::approvals::GuardianUserAuthorization; pub(super) use codex_protocol::config_types::CollaborationMode; diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index 27a226db020f..6d5aa5d38605 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -12,6 +12,7 @@ fn auto_review_denial_event() -> GuardianAssessmentEvent { risk_level: Some(GuardianRiskLevel::High), user_authorization: Some(GuardianUserAuthorization::Low), rationale: Some("Would send a local source file to an external endpoint.".into()), + denial_kind: Some(GuardianDenialKind::Soft), decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, @@ -52,6 +53,7 @@ async fn approving_recent_denial_emits_structured_core_op_once() { }) if submitted_thread_id == thread_id && event.id == "auto-review-recent-1" && event.status == GuardianAssessmentStatus::Denied + && event.denial_kind == Some(GuardianDenialKind::Soft) ); assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); @@ -81,6 +83,7 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: action.clone(), }); @@ -95,6 +98,7 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { risk_level: Some(GuardianRiskLevel::High), user_authorization: Some(GuardianUserAuthorization::Low), rationale: Some("Would exfiltrate local source code.".into()), + denial_kind: Some(GuardianDenialKind::Soft), decision_source: Some(GuardianAssessmentDecisionSource::Agent), action, }); @@ -139,6 +143,7 @@ async fn guardian_approved_exec_renders_approved_request() { risk_level: Some(GuardianRiskLevel::Low), user_authorization: Some(GuardianUserAuthorization::High), rationale: Some("Narrowly scoped to the requested file.".into()), + denial_kind: None, decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, @@ -197,6 +202,7 @@ async fn guardian_approved_request_permissions_renders_request_summary() { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: action.clone(), }); @@ -221,6 +227,7 @@ async fn guardian_approved_request_permissions_renders_request_summary() { risk_level: Some(GuardianRiskLevel::Low), user_authorization: Some(GuardianUserAuthorization::High), rationale: Some("Request is scoped to report output.".into()), + denial_kind: None, decision_source: Some(GuardianAssessmentDecisionSource::Agent), action, }); @@ -271,6 +278,7 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: action.clone(), }); @@ -287,6 +295,7 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { rationale: Some( "Automatic approval review timed out while evaluating the requested approval.".into(), ), + denial_kind: None, decision_source: Some(GuardianAssessmentDecisionSource::Agent), action, }); @@ -339,6 +348,7 @@ async fn app_server_guardian_review_started_sets_review_status() { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, }, action, }, @@ -381,6 +391,7 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, }, action: action.clone(), }, @@ -403,6 +414,7 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { risk_level: Some(AppServerGuardianRiskLevel::High), user_authorization: Some(AppServerGuardianUserAuthorization::Low), rationale: Some("Would exfiltrate local source code.".to_string()), + denial_kind: Some(AppServerGuardianDenialKind::Soft), }, action, }, @@ -459,6 +471,7 @@ async fn app_server_guardian_review_timed_out_renders_timed_out_request_snapshot risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, }, action: action.clone(), }, @@ -484,6 +497,7 @@ async fn app_server_guardian_review_timed_out_renders_timed_out_request_snapshot "Automatic approval review timed out while evaluating the requested approval." .to_string(), ), + denial_kind: None, }, action, }, @@ -535,6 +549,7 @@ async fn guardian_parallel_reviews_render_aggregate_status_snapshot() { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, @@ -566,6 +581,7 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, @@ -583,6 +599,7 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, @@ -600,6 +617,7 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() risk_level: Some(GuardianRiskLevel::High), user_authorization: Some(GuardianUserAuthorization::Low), rationale: Some("Would delete important data.".to_string()), + denial_kind: Some(GuardianDenialKind::Soft), decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index ba862a529345..a7ae0ff0dd6e 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -219,6 +219,7 @@ async fn mcp_startup_complete_preserves_review_status() { risk_level: None, user_authorization: None, rationale: None, + denial_kind: None, decision_source: None, action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell,