Add request-scoped Guardian approval decisions (#42807)

## What changed

- Extend `ApprovalReviewContributor` with a `decide` hook that can allow an
  action, return a synchronous review result, or hand the request to the user.
- Pass contributors the request's approval state and a callback bound to the
  existing synchronous reviewer. Review results satisfy the review gate while
  the host continues to enforce permissions.
- Add structured reasons for requesting a fresh Guardian review and select the
  first contributor decision in registration order.

GitOrigin-RevId: ec84474b3fcbcf1027c8502aa1c2947c10149f9d
This commit is contained in:
jif
2026-09-04 17:38:39 +00:00
committed by copyberry
parent 89a4eec6da
commit 0ae02915bd
8 changed files with 145 additions and 1 deletions
+1
View File
@@ -18,6 +18,7 @@ mod metrics;
mod prompt;
mod review;
mod review_session;
mod runtime;
use std::sync::Arc;
use std::time::Duration;
+30
View File
@@ -405,6 +405,35 @@ async fn run_guardian_review(
return decision;
}
let runtime = super::runtime::ReviewRuntime {
session,
context,
review_id,
request,
reasons,
options,
};
codex_extension_api::SynchronousApprovalReviewer::review(
&runtime,
codex_protocol::approvals::GuardianReviewReason::Policy,
)
.await
}
/// Runs the existing synchronous reviewer without choosing policy or reusing a score.
pub(super) async fn run_synchronous_review(
runtime: super::runtime::ReviewRuntime,
_review_reason: codex_protocol::approvals::GuardianReviewReason,
) -> ReviewDecision {
let super::runtime::ReviewRuntime {
session,
context,
review_id,
request,
reasons,
options,
} = runtime;
let turn = Arc::clone(context.turn());
let GuardianReviewOptions {
plugin_attribution_override,
approval_request_source,
@@ -777,6 +806,7 @@ async fn run_guardian_review(
}
}
#[derive(Clone)]
pub(crate) struct GuardianReviewOptions {
pub(crate) plugin_attribution_override: Option<PluginCommandAttribution>,
pub(crate) approval_request_source: GuardianApprovalRequestSource,
+32
View File
@@ -0,0 +1,32 @@
//! Binds the existing synchronous reviewer to one captured approval action.
use codex_extension_api::ExtensionFuture;
use codex_extension_api::SynchronousApprovalReviewer;
use codex_protocol::protocol::ReviewDecision;
use std::sync::Arc;
use super::ApprovalRequestReasons;
use super::GuardianApprovalRequest;
use super::GuardianReviewContext;
use super::GuardianReviewOptions;
use super::review::run_synchronous_review;
use crate::session::session::Session;
#[derive(Clone)]
pub(super) struct ReviewRuntime {
pub(super) session: Arc<Session>,
pub(super) context: GuardianReviewContext,
pub(super) review_id: String,
pub(super) request: GuardianApprovalRequest,
pub(super) reasons: ApprovalRequestReasons,
pub(super) options: GuardianReviewOptions,
}
impl SynchronousApprovalReviewer for ReviewRuntime {
fn review(
&self,
reason: codex_protocol::approvals::GuardianReviewReason,
) -> ExtensionFuture<'_, ReviewDecision> {
Box::pin(run_synchronous_review(self.clone(), reason))
}
}
@@ -24,9 +24,12 @@ mod turn_lifecycle;
mod world_state;
pub use approval_review::ApprovalAssessment;
pub use approval_review::ApprovalDecision;
pub use approval_review::ApprovalDecisionInput;
pub use approval_review::ApprovalReviewError;
pub use approval_review::ApprovalReviewInput;
pub use approval_review::GuardianV2Enabled;
pub use approval_review::SynchronousApprovalReviewer;
pub use context::TurnContextContributionInput;
pub use mcp::McpServerContribution;
pub use mcp::McpServerContributionContext;
@@ -352,6 +355,14 @@ pub trait ToolLifecycleContributor: Send + Sync {
/// a full structured review, or support both paths. Returning `None` leaves the
/// request available to the next contributor or the host's fallback path.
pub trait ApprovalReviewContributor: Send + Sync {
/// Claims one request, including a handoff to the user.
fn decide<'a>(
&'a self,
_input: &'a ApprovalDecisionInput<'_>,
) -> ExtensionFuture<'a, Option<ApprovalDecision>> {
Box::pin(std::future::ready(None))
}
/// Returns an available approval decision without performing a full review.
fn fast_decision<'a>(
&'a self,
@@ -1,3 +1,5 @@
//! Request-scoped approval decisions. A review only satisfies the review gate; the host enforces permissions.
use std::fmt;
use std::sync::Arc;
@@ -31,7 +33,7 @@ pub struct ApprovalAssessment {
pub enum ApprovalReviewError {
/// The reviewer could not produce a valid assessment.
Failed(String),
/// The request exceeded its host-owned review deadline.
/// The request exceeded its synchronous review deadline.
TimedOut,
/// The request was cancelled by its parent approval.
Cancelled,
@@ -66,3 +68,38 @@ pub struct ApprovalReviewInput<'a> {
/// Reason an earlier request is being retried, when applicable.
pub retry_reason: Option<&'a str>,
}
/// Guardian's choice for one approval. Synchronous results pass through unchanged.
#[derive(Clone, Debug, PartialEq)]
pub enum ApprovalDecision {
/// Existing async evidence allows this action without synchronous review.
Allow,
Reviewed(codex_protocol::protocol::ReviewDecision),
AskUser,
}
/// Runs the existing synchronous review for the bound action and cancellation token.
/// Implementations must not resolve policy or reuse an async score.
pub trait SynchronousApprovalReviewer: Send + Sync {
fn review(
&self,
reason: codex_protocol::approvals::GuardianReviewReason,
) -> crate::ExtensionFuture<'_, codex_protocol::protocol::ReviewDecision>;
}
/// Inputs to Guardian's policy choice. Conversation and scores stay thread-owned.
pub struct ApprovalDecisionInput<'a> {
pub approval_id: &'a str,
pub action: &'a serde_json::Value,
pub thread_id: ThreadId,
pub thread_store: &'a ExtensionData,
pub category: codex_protocol::openai_models::GuardianScope,
pub approval_policy: codex_protocol::protocol::AskForApproval,
pub approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
pub require_guardian: bool,
/// Existing retry and sensitive-action rules require a synchronous review.
pub require_fresh_review: bool,
pub full_access: bool,
pub metrics: Option<Arc<dyn crate::ExtensionMetrics>>,
pub synchronous_reviewer: &'a dyn SynchronousApprovalReviewer,
}
+3
View File
@@ -41,6 +41,8 @@ pub use codex_tools::TurnItemEmitter;
pub use codex_tools::parse_tool_input_schema;
pub use codex_tools::parse_tool_input_schema_without_compaction;
pub use contributors::ApprovalAssessment;
pub use contributors::ApprovalDecision;
pub use contributors::ApprovalDecisionInput;
pub use contributors::ApprovalReviewContributor;
pub use contributors::ApprovalReviewError;
pub use contributors::ApprovalReviewInput;
@@ -63,6 +65,7 @@ pub use contributors::SelectedPluginSnapshot;
pub use contributors::SkillInvocationContributor;
pub use contributors::SkillInvocationInput;
pub use contributors::SkillInvocationKind;
pub use contributors::SynchronousApprovalReviewer;
pub use contributors::ThreadIdleCause;
pub use contributors::ThreadIdleInput;
pub use contributors::ThreadLifecycleContributor;
@@ -241,6 +241,19 @@ impl<C: Sync> ExtensionRegistry<C> {
None
}
/// Returns the first claimed decision in registration order.
pub async fn decide_approval(
&self,
input: &crate::ApprovalDecisionInput<'_>,
) -> Option<crate::ApprovalDecision> {
for contributor in &self.approval_review_contributors {
if let Some(decision) = contributor.decide(input).await {
return Some(decision);
}
}
None
}
/// Returns the registered prompt contributors.
pub fn context_contributors(&self) -> &[Arc<dyn ContextContributor>] {
&self.context_contributors
+17
View File
@@ -185,6 +185,23 @@ pub struct NetworkPolicyAmendment {
pub action: NetworkPolicyRuleAction,
}
/// Why this approval needs a fresh Guardian assessment.
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum GuardianReviewReason {
Policy,
FreshRequired,
MissingScore,
StaleScore,
InvalidScore,
IncompatibleCompaction,
ElevatedRisk,
ScoringFailure,
AuthorizationChanged,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct GuardianAssessmentEvent {
/// Stable identifier for this guardian review lifecycle.