Compare commits

...

8 Commits

Author SHA1 Message Date
Abhinav Vedmala
6f56c2b579 Refactor permission request approval flow
Co-authored-by: Codex <noreply@openai.com>
2026-04-12 12:10:58 -07:00
Abhinav Vedmala
312987d9ef Make permission request hook run IDs attempt-specific
Use distinct PermissionRequest run-id suffixes for initial and retry approvals so hook events don't collide for a single tool call.

Co-authored-by: Codex <noreply@openai.com>
2026-04-12 11:19:27 -07:00
Abhinav Vedmala
9581563efb Merge origin/main into dev/abhinav/permission-request-hook
Co-authored-by: Codex <noreply@openai.com>
2026-04-12 10:57:42 -07:00
Abhinav Vedmala
9f57404d34 Merge remote-tracking branch 'origin/main' into dev/abhinav/permission-request-hook
# Conflicts:
#	codex-rs/core/src/guardian/review.rs
#	codex-rs/core/src/tools/orchestrator.rs
#	codex-rs/core/src/tools/runtimes/shell.rs
#	codex-rs/core/src/tools/runtimes/unified_exec.rs
2026-04-10 18:08:08 -07:00
Abhinav Vedmala
568ff282cb Include approval context in permission request hooks 2026-04-10 18:00:58 -07:00
Abhinav Vedmala
b9098cb968 Simplify guardian review hook input
Rename the PermissionRequest advisory field to guardian_review, drop the redundant source field, and share one hook-side guardian review type with protocol risk and authorization enums.

Co-authored-by: Codex <noreply@openai.com>
2026-04-10 16:54:56 -07:00
Abhinav Vedmala
a05fe33cc9 Add guardian review context to PermissionRequest hooks
Run guardian before Bash PermissionRequest hooks when approvals are already routed to guardian, pass the review into the hook input as advisory context, and reuse the guardian decision when hooks stay quiet.

Co-authored-by: Codex <noreply@openai.com>
2026-04-10 16:35:49 -07:00
Abhinav Vedmala
07896f7f2e Add Bash PermissionRequest hooks
Co-authored-by: Codex <noreply@openai.com>
2026-04-10 14:56:19 -07:00
39 changed files with 1896 additions and 79 deletions

View File

@@ -1404,6 +1404,7 @@
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",

View File

@@ -8395,6 +8395,7 @@
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",

View File

@@ -5147,6 +5147,7 @@
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",

View File

@@ -4,6 +4,7 @@
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",

View File

@@ -4,6 +4,7 @@
"HookEventName": {
"enum": [
"preToolUse",
"permissionRequest",
"postToolUse",
"sessionStart",
"userPromptSubmit",

View File

@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookEventName = "preToolUse" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop";
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop";

View File

@@ -380,7 +380,7 @@ v2_enum_from_core!(
v2_enum_from_core!(
pub enum HookEventName from CoreHookEventName {
PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop
PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop
}
);

View File

@@ -25,12 +25,16 @@ use serde::Serialize;
pub(crate) use approval_request::GuardianApprovalRequest;
pub(crate) use approval_request::GuardianMcpAnnotations;
pub(crate) use approval_request::guardian_approval_request_to_json;
pub(crate) use review::GuardianApprovalReview;
pub(crate) use review::GuardianApprovalReviewResult;
pub(crate) use review::GuardianApprovalReviewStatus;
pub(crate) use review::guardian_rejection_message;
pub(crate) use review::guardian_timeout_message;
pub(crate) use review::is_guardian_reviewer_source;
pub(crate) use review::new_guardian_review_id;
pub(crate) use review::review_approval_request;
pub(crate) use review::review_approval_request_with_cancel;
pub(crate) use review::review_approval_request_with_review;
pub(crate) use review::routes_approval_to_guardian;
pub(crate) use review_session::GuardianReviewSessionManager;

View File

@@ -80,6 +80,30 @@ pub(super) enum GuardianReviewOutcome {
Aborted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GuardianApprovalReviewResult {
pub(crate) decision: ReviewDecision,
pub(crate) review: GuardianApprovalReview,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GuardianApprovalReview {
pub(crate) status: GuardianApprovalReviewStatus,
pub(crate) decision: Option<GuardianAssessmentOutcome>,
pub(crate) risk_level: Option<GuardianRiskLevel>,
pub(crate) user_authorization: Option<GuardianUserAuthorization>,
pub(crate) rationale: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum GuardianApprovalReviewStatus {
Approved,
Denied,
Aborted,
Failed,
TimedOut,
}
fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str {
match level {
GuardianRiskLevel::Low => "low",
@@ -117,7 +141,7 @@ async fn run_guardian_review(
request: GuardianApprovalRequest,
retry_reason: Option<String>,
external_cancel: Option<CancellationToken>,
) -> ReviewDecision {
) -> GuardianApprovalReviewResult {
let target_item_id = guardian_request_target_item_id(&request).map(str::to_string);
let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string();
let action_summary = guardian_assessment_action(&request);
@@ -158,7 +182,16 @@ async fn run_guardian_review(
}),
)
.await;
return ReviewDecision::Abort;
return GuardianApprovalReviewResult {
decision: ReviewDecision::Abort,
review: GuardianApprovalReview {
status: GuardianApprovalReviewStatus::Aborted,
decision: None,
risk_level: None,
user_authorization: None,
rationale: None,
},
};
}
let schema = guardian_output_schema();
@@ -173,14 +206,17 @@ async fn run_guardian_review(
)
.await;
let assessment = match outcome {
GuardianReviewOutcome::Completed(Ok(assessment)) => assessment,
GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment {
risk_level: GuardianRiskLevel::High,
user_authorization: GuardianUserAuthorization::Unknown,
outcome: GuardianAssessmentOutcome::Deny,
rationale: format!("Automatic approval review failed: {err}"),
},
let (assessment, advisory_status) = match outcome {
GuardianReviewOutcome::Completed(Ok(assessment)) => (assessment, None),
GuardianReviewOutcome::Completed(Err(err)) => (
GuardianAssessment {
risk_level: GuardianRiskLevel::High,
user_authorization: GuardianUserAuthorization::Unknown,
outcome: GuardianAssessmentOutcome::Deny,
rationale: format!("Automatic approval review failed: {err}"),
},
Some(GuardianApprovalReviewStatus::Failed),
),
GuardianReviewOutcome::TimedOut => {
let rationale =
"Automatic approval review timed out while evaluating the requested approval."
@@ -203,13 +239,22 @@ async fn run_guardian_review(
status: GuardianAssessmentStatus::TimedOut,
risk_level: None,
user_authorization: None,
rationale: Some(rationale),
rationale: Some(rationale.clone()),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action: terminal_action,
}),
)
.await;
return ReviewDecision::TimedOut;
return GuardianApprovalReviewResult {
decision: ReviewDecision::TimedOut,
review: GuardianApprovalReview {
status: GuardianApprovalReviewStatus::TimedOut,
decision: None,
risk_level: None,
user_authorization: None,
rationale: Some(rationale),
},
};
}
GuardianReviewOutcome::Aborted => {
session
@@ -228,7 +273,16 @@ async fn run_guardian_review(
}),
)
.await;
return ReviewDecision::Abort;
return GuardianApprovalReviewResult {
decision: ReviewDecision::Abort,
review: GuardianApprovalReview {
status: GuardianApprovalReviewStatus::Aborted,
decision: None,
risk_level: None,
user_authorization: None,
rationale: None,
},
};
}
};
@@ -259,6 +313,23 @@ async fn run_guardian_review(
} else {
GuardianAssessmentStatus::Denied
};
let advisory_status = advisory_status.unwrap_or(if approved {
GuardianApprovalReviewStatus::Approved
} else {
GuardianApprovalReviewStatus::Denied
});
let decision = if approved {
ReviewDecision::Approved
} else {
ReviewDecision::Denied
};
let review = GuardianApprovalReview {
status: advisory_status,
decision: Some(assessment.outcome),
risk_level: Some(assessment.risk_level),
user_authorization: Some(assessment.user_authorization),
rationale: Some(assessment.rationale.clone()),
};
{
let mut rationales = session.services.guardian_rejections.lock().await;
if approved {
@@ -288,11 +359,26 @@ async fn run_guardian_review(
)
.await;
if approved {
ReviewDecision::Approved
} else {
ReviewDecision::Denied
}
GuardianApprovalReviewResult { decision, review }
}
/// Runs guardian and returns both the approval decision and hook-visible review data.
pub(crate) async fn review_approval_request_with_review(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
review_id: String,
request: GuardianApprovalRequest,
retry_reason: Option<String>,
) -> GuardianApprovalReviewResult {
run_guardian_review(
Arc::clone(session),
Arc::clone(turn),
review_id,
request,
retry_reason,
/*external_cancel*/ None,
)
.await
}
/// Public entrypoint for approval requests that should be reviewed by guardian.
@@ -303,15 +389,9 @@ pub(crate) async fn review_approval_request(
request: GuardianApprovalRequest,
retry_reason: Option<String>,
) -> ReviewDecision {
run_guardian_review(
Arc::clone(session),
Arc::clone(turn),
review_id,
request,
retry_reason,
/*external_cancel*/ None,
)
.await
review_approval_request_with_review(session, turn, review_id, request, retry_reason)
.await
.decision
}
pub(crate) async fn review_approval_request_with_cancel(
@@ -331,6 +411,7 @@ pub(crate) async fn review_approval_request_with_cancel(
Some(cancel_token),
)
.await
.decision
}
/// Runs the guardian in a locked-down reusable review session.

View File

@@ -1,6 +1,9 @@
use std::future::Future;
use std::sync::Arc;
use codex_hooks::PermissionRequestDecision;
use codex_hooks::PermissionRequestOutcome;
use codex_hooks::PermissionRequestRequest;
use codex_hooks::PostToolUseOutcome;
use codex_hooks::PostToolUseRequest;
use codex_hooks::PreToolUseOutcome;
@@ -23,6 +26,8 @@ use serde_json::Value;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::event_mapping::parse_turn_item;
use crate::tools::sandboxing::PermissionRequestHookRequest;
use crate::tools::sandboxing::PermissionRequestPayload;
pub(crate) struct HookRuntimeOutcome {
pub should_stop: bool,
@@ -145,6 +150,50 @@ pub(crate) async fn run_pre_tool_use_hooks(
if should_block { block_reason } else { None }
}
pub(crate) async fn run_permission_request_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
hook_request: PermissionRequestHookRequest,
) -> Option<PermissionRequestDecision> {
let PermissionRequestHookRequest {
run_id_suffix,
payload,
guardian_review,
} = hook_request;
let PermissionRequestPayload {
tool_name,
command,
sandbox_permissions,
additional_permissions,
justification,
} = payload;
let request = PermissionRequestRequest {
session_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
cwd: turn_context.cwd.to_path_buf(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
tool_name,
run_id_suffix,
command,
sandbox_permissions,
additional_permissions,
justification,
guardian_review,
};
let preview_runs = sess.hooks().preview_permission_request(&request);
emit_hook_started_events(sess, turn_context, preview_runs).await;
let PermissionRequestOutcome {
hook_events,
decision,
} = sess.hooks().run_permission_request(request).await;
emit_hook_completed_events(sess, turn_context, hook_events).await;
decision
}
pub(crate) async fn run_post_tool_use_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,

View File

@@ -77,6 +77,7 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option<String> {
struct RunExecLikeArgs {
tool_name: String,
exec_params: ExecParams,
hook_command: String,
additional_permissions: Option<PermissionProfile>,
prefix_rule: Option<Vec<String>>,
session: Arc<crate::codex::Session>,
@@ -241,6 +242,7 @@ impl ToolHandler for ShellHandler {
Self::run_exec_like(RunExecLikeArgs {
tool_name: tool_name.display(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
@@ -258,6 +260,7 @@ impl ToolHandler for ShellHandler {
Self::run_exec_like(RunExecLikeArgs {
tool_name: tool_name.display(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: None,
prefix_rule: None,
session,
@@ -366,6 +369,7 @@ impl ToolHandler for ShellCommandHandler {
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: tool_name.display(),
exec_params,
hook_command: params.command,
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
@@ -384,6 +388,7 @@ impl ShellHandler {
let RunExecLikeArgs {
tool_name,
exec_params,
hook_command,
additional_permissions,
prefix_rule,
session,
@@ -515,6 +520,7 @@ impl ShellHandler {
let req = ShellRequest {
command: exec_params.command.clone(),
hook_command,
cwd: exec_params.cwd.clone(),
timeout_ms: exec_params.expiration.timeout_ms(),
env: exec_params.env.clone(),

View File

@@ -6,10 +6,16 @@ simple sequence for any ToolRuntime: approval → select sandbox → attempt →
retry with an escalated sandbox strategy on denial (no reapproval thanks to
caching).
*/
use crate::guardian::GuardianApprovalReview;
use crate::guardian::GuardianApprovalReviewResult;
use crate::guardian::GuardianApprovalReviewStatus;
use crate::guardian::GuardianAssessmentOutcome;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request_with_review;
use crate::guardian::routes_approval_to_guardian;
use crate::hook_runtime::run_permission_request_hooks;
use crate::network_policy_decision::network_approval_context_from_payload;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::NetworkApprovalMode;
@@ -18,12 +24,18 @@ use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::network_approval::finish_immediate_network_approval;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::PermissionRequestHookRequest;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::default_exec_approval_requirement;
use codex_hooks::PermissionRequestDecision;
use codex_hooks::PermissionRequestGuardianReview;
use codex_hooks::PermissionRequestGuardianReviewDecision;
use codex_hooks::PermissionRequestGuardianReviewStatus as HookGuardianReviewStatus;
use codex_otel::SessionTelemetry;
use codex_otel::ToolDecisionSource;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
@@ -43,6 +55,25 @@ pub(crate) struct OrchestratorRunResult<Out> {
pub deferred_network_approval: Option<DeferredNetworkApproval>,
}
#[derive(Clone, Copy)]
enum ApprovalAttempt {
Initial,
Retry,
}
#[derive(Clone, Copy)]
struct ApprovalTelemetry<'a> {
otel: &'a SessionTelemetry,
tool_name: &'a str,
call_id: &'a str,
}
struct ApprovalRequestCtx<'a> {
approval: ApprovalCtx<'a>,
routes_to_guardian: bool,
telemetry: ApprovalTelemetry<'a>,
}
impl ToolOrchestrator {
pub fn new() -> Self {
Self {
@@ -114,11 +145,7 @@ impl ToolOrchestrator {
let otel = turn_ctx.session_telemetry.clone();
let otel_tn = &tool_ctx.tool_name;
let otel_ci = &tool_ctx.call_id;
let otel_user = ToolDecisionSource::User;
let otel_automated_reviewer = ToolDecisionSource::AutomatedReviewer;
let otel_cfg = ToolDecisionSource::Config;
let use_guardian = routes_approval_to_guardian(turn_ctx);
// 1) Approval
let mut already_approved = false;
@@ -127,7 +154,12 @@ impl ToolOrchestrator {
});
match requirement {
ExecApprovalRequirement::Skip { .. } => {
otel.tool_decision(otel_tn, otel_ci, &ReviewDecision::Approved, otel_cfg);
otel.tool_decision(
otel_tn,
otel_ci,
&ReviewDecision::Approved,
ToolDecisionSource::Config,
);
}
ExecApprovalRequirement::Forbidden { reason } => {
return Err(ToolError::Rejected(reason));
@@ -142,14 +174,21 @@ impl ToolOrchestrator {
retry_reason: reason,
network_approval_context: None,
};
let decision = tool.start_approval_async(req, approval_ctx).await;
let otel_source = if use_guardian {
otel_automated_reviewer.clone()
} else {
otel_user.clone()
};
otel.tool_decision(otel_tn, otel_ci, &decision, otel_source);
let decision = Self::request_approval(
tool,
req,
ApprovalAttempt::Initial,
ApprovalRequestCtx {
routes_to_guardian: use_guardian,
approval: approval_ctx,
telemetry: ApprovalTelemetry {
otel: &otel,
tool_name: otel_tn,
call_id: otel_ci,
},
},
)
.await?;
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
@@ -300,14 +339,21 @@ impl ToolOrchestrator {
retry_reason: Some(retry_reason),
network_approval_context: network_approval_context.clone(),
};
let decision = tool.start_approval_async(req, approval_ctx).await;
let otel_source = if use_guardian {
otel_automated_reviewer
} else {
otel_user
};
otel.tool_decision(otel_tn, otel_ci, &decision, otel_source);
let decision = Self::request_approval(
tool,
req,
ApprovalAttempt::Retry,
ApprovalRequestCtx {
routes_to_guardian: use_guardian,
approval: approval_ctx,
telemetry: ApprovalTelemetry {
otel: &otel,
tool_name: otel_tn,
call_id: otel_ci,
},
},
)
.await?;
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
@@ -370,6 +416,115 @@ impl ToolOrchestrator {
Err(err) => Err(err),
}
}
// Centralize one approval prompt for three possible decision makers. If
// this prompt would normally route to guardian, run guardian first and pass
// its result to PermissionRequest hooks as advisory context. The hook can
// still answer the prompt; if it stays quiet, reuse the guardian decision
// instead of asking guardian again. Without a hook or reusable guardian
// result, fall back to the runtime's normal approval path.
async fn request_approval<Rq, Out, T>(
tool: &mut T,
req: &Rq,
approval_attempt: ApprovalAttempt,
request_ctx: ApprovalRequestCtx<'_>,
) -> Result<ReviewDecision, ToolError>
where
T: ToolRuntime<Rq, Out>,
{
let ApprovalRequestCtx {
approval: approval_ctx,
routes_to_guardian,
telemetry,
} = request_ctx;
let ApprovalTelemetry {
otel,
tool_name: otel_tn,
call_id: otel_ci,
} = telemetry;
let guardian_review = if routes_to_guardian {
if let Some(review_id) = approval_ctx.guardian_review_id.clone() {
if let Some(request) = tool.guardian_approval_request(req, &approval_ctx) {
Some(
review_approval_request_with_review(
approval_ctx.session,
approval_ctx.turn,
review_id,
request,
approval_ctx.retry_reason.clone(),
)
.await,
)
} else {
None
}
} else {
None
}
} else {
None
};
if let Some(permission_request) = tool.permission_request_payload(req) {
let run_id_suffix = match approval_attempt {
ApprovalAttempt::Initial => format!("{}:initial", approval_ctx.call_id),
ApprovalAttempt::Retry => format!("{}:retry", approval_ctx.call_id),
};
match run_permission_request_hooks(
approval_ctx.session,
approval_ctx.turn,
PermissionRequestHookRequest {
run_id_suffix,
payload: permission_request,
guardian_review: guardian_review
.as_ref()
.map(|review| permission_request_guardian_review(review.review.clone())),
},
)
.await
{
Some(PermissionRequestDecision::Allow) => {
otel.tool_decision(
otel_tn,
otel_ci,
&ReviewDecision::Approved,
ToolDecisionSource::Config,
);
return Ok(ReviewDecision::Approved);
}
Some(PermissionRequestDecision::Deny { message }) => {
otel.tool_decision(
otel_tn,
otel_ci,
&ReviewDecision::Denied,
ToolDecisionSource::Config,
);
return Err(ToolError::Rejected(message));
}
None => {}
}
}
if let Some(GuardianApprovalReviewResult { decision, .. }) = guardian_review {
otel.tool_decision(
otel_tn,
otel_ci,
&decision,
ToolDecisionSource::AutomatedReviewer,
);
return Ok(decision);
}
let decision = tool.start_approval_async(req, approval_ctx).await;
let otel_source = if routes_to_guardian {
ToolDecisionSource::AutomatedReviewer
} else {
ToolDecisionSource::User
};
otel.tool_decision(otel_tn, otel_ci, &decision, otel_source);
Ok(decision)
}
}
fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {
@@ -377,3 +532,24 @@ fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {
// output so we can evolve heuristics later without touching call sites.
"command failed; retry without sandbox?".to_string()
}
fn permission_request_guardian_review(
review: GuardianApprovalReview,
) -> PermissionRequestGuardianReview {
PermissionRequestGuardianReview {
status: match review.status {
GuardianApprovalReviewStatus::Approved => HookGuardianReviewStatus::Approved,
GuardianApprovalReviewStatus::Denied => HookGuardianReviewStatus::Denied,
GuardianApprovalReviewStatus::Aborted => HookGuardianReviewStatus::Aborted,
GuardianApprovalReviewStatus::Failed => HookGuardianReviewStatus::Failed,
GuardianApprovalReviewStatus::TimedOut => HookGuardianReviewStatus::TimedOut,
},
decision: review.decision.map(|decision| match decision {
GuardianAssessmentOutcome::Allow => PermissionRequestGuardianReviewDecision::Allow,
GuardianAssessmentOutcome::Deny => PermissionRequestGuardianReviewDecision::Deny,
}),
risk_level: review.risk_level,
user_authorization: review.user_authorization,
rationale: review.rationale,
}
}

View File

@@ -23,6 +23,7 @@ use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
use crate::tools::sandboxing::Sandboxable;
@@ -44,6 +45,7 @@ use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct ShellRequest {
pub command: Vec<String>,
pub hook_command: String,
pub cwd: AbsolutePathBuf,
pub timeout_ms: Option<u64>,
pub env: HashMap<String, String>,
@@ -115,6 +117,17 @@ impl ShellRuntime {
tx_event: ctx.session.get_tx_event(),
})
}
fn guardian_request(req: &ShellRequest, ctx: &ApprovalCtx<'_>) -> GuardianApprovalRequest {
GuardianApprovalRequest::Shell {
id: ctx.call_id.to_string(),
command: req.command.clone(),
cwd: req.cwd.to_path_buf(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
}
}
}
impl Sandboxable for ShellRuntime {
@@ -144,6 +157,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision> {
let keys = self.approval_keys(req);
let guardian_request = Self::guardian_request(req, &ctx);
let command = req.command.clone();
let cwd = req.cwd.to_path_buf();
let retry_reason = ctx.retry_reason.clone();
@@ -158,14 +172,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
session,
turn,
review_id,
GuardianApprovalRequest::Shell {
id: call_id,
command,
cwd,
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
},
guardian_request,
retry_reason,
)
.await;
@@ -197,6 +204,23 @@ impl Approvable<ShellRequest> for ShellRuntime {
Some(req.exec_approval_requirement.clone())
}
fn permission_request_payload(&self, req: &ShellRequest) -> Option<PermissionRequestPayload> {
Some(PermissionRequestPayload::bash(
req.hook_command.clone(),
req.sandbox_permissions,
req.additional_permissions.clone(),
req.justification.clone(),
))
}
fn guardian_approval_request(
&self,
req: &ShellRequest,
ctx: &ApprovalCtx<'_>,
) -> Option<GuardianApprovalRequest> {
Some(Self::guardian_request(req, ctx))
}
fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride {
sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement)
}

View File

@@ -20,6 +20,7 @@ use crate::tools::runtimes::shell::zsh_fork_backend;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
use crate::tools::sandboxing::Sandboxable;
@@ -49,6 +50,7 @@ use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct UnifiedExecRequest {
pub command: Vec<String>,
pub hook_command: String,
pub process_id: i32,
pub cwd: AbsolutePathBuf,
pub env: HashMap<String, String>,
@@ -89,6 +91,21 @@ impl<'a> UnifiedExecRuntime<'a> {
shell_mode,
}
}
fn guardian_request(
req: &UnifiedExecRequest,
ctx: &ApprovalCtx<'_>,
) -> GuardianApprovalRequest {
GuardianApprovalRequest::ExecCommand {
id: ctx.call_id.to_string(),
command: req.command.clone(),
cwd: req.cwd.to_path_buf(),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
tty: req.tty,
}
}
}
impl Sandboxable for UnifiedExecRuntime<'_> {
@@ -120,6 +137,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
ctx: ApprovalCtx<'b>,
) -> BoxFuture<'b, ReviewDecision> {
let keys = self.approval_keys(req);
let guardian_request = Self::guardian_request(req, &ctx);
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
@@ -134,15 +152,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
session,
turn,
review_id,
GuardianApprovalRequest::ExecCommand {
id: call_id,
command,
cwd,
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
tty: req.tty,
},
guardian_request,
retry_reason,
)
.await;
@@ -177,6 +187,26 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
Some(req.exec_approval_requirement.clone())
}
fn permission_request_payload(
&self,
req: &UnifiedExecRequest,
) -> Option<PermissionRequestPayload> {
Some(PermissionRequestPayload::bash(
req.hook_command.clone(),
req.sandbox_permissions,
req.additional_permissions.clone(),
req.justification.clone(),
))
}
fn guardian_approval_request(
&self,
req: &UnifiedExecRequest,
ctx: &ApprovalCtx<'_>,
) -> Option<GuardianApprovalRequest> {
Some(Self::guardian_request(req, ctx))
}
fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride {
sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement)
}

View File

@@ -6,14 +6,17 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::guardian::GuardianApprovalRequest;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::state::SessionServices;
use crate::tools::network_approval::NetworkApprovalSpec;
use codex_hooks::PermissionRequestGuardianReview;
use codex_network_proxy::NetworkProxy;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::error::CodexErr;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
@@ -131,6 +134,39 @@ pub(crate) struct ApprovalCtx<'a> {
pub network_approval_context: Option<NetworkApprovalContext>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PermissionRequestPayload {
pub tool_name: String,
pub command: String,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<PermissionProfile>,
pub justification: Option<String>,
}
impl PermissionRequestPayload {
pub(crate) fn bash(
command: String,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<PermissionProfile>,
justification: Option<String>,
) -> Self {
Self {
tool_name: "Bash".to_string(),
command,
sandbox_permissions,
additional_permissions,
justification,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PermissionRequestHookRequest {
pub run_id_suffix: String,
pub payload: PermissionRequestPayload,
pub guardian_review: Option<PermissionRequestGuardianReview>,
}
// Specifies what tool orchestrator should do with a given tool call.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ExecApprovalRequirement {
@@ -273,6 +309,23 @@ pub(crate) trait Approvable<Req> {
None
}
fn permission_request_payload(&self, _req: &Req) -> Option<PermissionRequestPayload> {
None
}
/// Build the guardian request that corresponds to this approval prompt.
///
/// Runtimes that can route approvals through guardian should return the
/// same request they would pass to `review_approval_request`, so shared
/// orchestration can run guardian once and reuse that decision as fallback.
fn guardian_approval_request(
&self,
_req: &Req,
_ctx: &ApprovalCtx<'_>,
) -> Option<GuardianApprovalRequest> {
None
}
/// Decide we can request an approval for no-sandbox execution.
fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
match policy {

View File

@@ -677,6 +677,7 @@ impl UnifiedExecProcessManager {
.await;
let req = UnifiedExecToolRequest {
command: request.command.clone(),
hook_command: codex_shell_command::parse_command::shlex_join(&request.command),
process_id: request.process_id,
cwd,
env,

View File

@@ -3,14 +3,17 @@ use std::path::Path;
use anyhow::Context;
use anyhow::Result;
use codex_config::types::ApprovalsReviewer;
use codex_features::Feature;
use codex_protocol::items::parse_hook_prompt_fragment;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -237,6 +240,79 @@ elif mode == "exit_2":
Ok(())
}
fn write_permission_request_hook(
home: &Path,
matcher: Option<&str>,
mode: &str,
reason: &str,
) -> Result<()> {
let script_path = home.join("permission_request_hook.py");
let log_path = home.join("permission_request_hook_log.jsonl");
let mode_json = serde_json::to_string(mode).context("serialize permission request mode")?;
let reason_json =
serde_json::to_string(reason).context("serialize permission request reason")?;
let script = format!(
r#"import json
from pathlib import Path
import sys
log_path = Path(r"{log_path}")
mode = {mode_json}
reason = {reason_json}
payload = json.load(sys.stdin)
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
if mode == "allow":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PermissionRequest",
"decision": {{"behavior": "allow"}}
}}
}}))
elif mode == "deny":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PermissionRequest",
"decision": {{
"behavior": "deny",
"message": reason
}}
}}
}}))
elif mode == "exit_2":
sys.stderr.write(reason + "\n")
raise SystemExit(2)
"#,
log_path = log_path.display(),
mode_json = mode_json,
reason_json = reason_json,
);
let mut group = serde_json::json!({
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "running permission request hook",
}]
});
if let Some(matcher) = matcher {
group["matcher"] = Value::String(matcher.to_string());
}
let hooks = serde_json::json!({
"hooks": {
"PermissionRequest": [group]
}
});
fs::write(&script_path, script).context("write permission request hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn write_post_tool_use_hook(
home: &Path,
matcher: Option<&str>,
@@ -397,6 +473,15 @@ fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>>
.collect()
}
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()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).context("parse permission request hook log line"))
.collect()
}
fn read_post_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
fs::read_to_string(home.join("post_tool_use_hook_log.jsonl"))
.context("read post tool use hook log")?
@@ -495,10 +580,9 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
if let Err(error) = config.features.enable(Feature::CodexHooks) {
panic!("test config should allow feature update: {error}");
}
});
let test = builder.build(&server).await?;
@@ -594,10 +678,9 @@ async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> {
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
if let Err(error) = config.features.enable(Feature::CodexHooks) {
panic!("test config should allow feature update: {error}");
}
});
let test = builder.build(&server).await?;
@@ -1005,6 +1088,218 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permission_request_hook_allows_shell_command_without_user_approval() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "permissionrequest-shell-command";
let marker = std::env::temp_dir().join("permissionrequest-shell-command-marker");
let command = format!("rm -f {}", marker.display());
let args = serde_json::json!({
"command": ["rm", "-f", marker.display().to_string()],
});
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "permission request hook allowed it"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_permission_request_hook(
home,
Some("^Bash$"),
"allow",
"should not be used for allow",
) {
panic!("failed to write permission request hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
fs::write(&marker, "seed").context("create permission request marker")?;
test.submit_turn_with_policies(
"run the shell command after hook approval",
AskForApproval::OnRequest,
SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
requests[1].function_call_output(call_id);
assert!(
!marker.exists(),
"approved command should remove marker file"
);
let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(hook_inputs[0]["hook_event_name"], "PermissionRequest");
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
assert_eq!(hook_inputs[0]["guardian_review"], Value::Null);
assert!(
hook_inputs[0].get("tool_use_id").is_none(),
"PermissionRequest input should not include a tool_use_id",
);
assert!(
hook_inputs[0]["turn_id"]
.as_str()
.is_some_and(|turn_id| !turn_id.is_empty())
);
Ok(())
}
#[test]
fn permission_request_hook_receives_guardian_review_before_fallback() -> Result<()> {
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build current-thread tokio runtime")
.block_on(permission_request_hook_receives_guardian_review_before_fallback_inner())
})
.expect("spawn guardian permission request hook test thread")
.join()
.expect("guardian permission request hook test thread panicked")
}
async fn permission_request_hook_receives_guardian_review_before_fallback_inner() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "permissionrequest-guardian-review";
let marker = std::env::temp_dir().join("permissionrequest-guardian-review-marker");
let command = format!("rm -f {}", marker.display());
let args = serde_json::json!({
"command": ["rm", "-f", marker.display().to_string()],
});
let guardian_assessment = serde_json::json!({
"risk_level": "medium",
"user_authorization": "high",
"outcome": "allow",
"rationale": "The user asked to delete this local marker file.",
})
.to_string();
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-guardian"),
ev_assistant_message("msg-guardian", &guardian_assessment),
ev_completed("resp-guardian"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "guardian fallback allowed it"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_permission_request_hook(home, Some("^Bash$"), "quiet", "unused")
{
panic!("failed to write permission request hook test fixture: {error}");
}
})
.with_config(|config| {
if let Err(error) = config.features.enable(Feature::CodexHooks) {
panic!("test config should allow feature update: {error}");
}
if let Err(error) = config.features.enable(Feature::ExecPermissionApprovals) {
panic!("test config should allow feature update: {error}");
}
if let Err(error) = config.features.enable(Feature::GuardianApproval) {
panic!("test config should allow feature update: {error}");
}
config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent;
});
let test = builder.build(&server).await?;
fs::write(&marker, "seed").context("create guardian review marker")?;
test.submit_turn_with_policies(
"run the shell command after guardian review",
AskForApproval::OnRequest,
SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = responses.requests();
assert_eq!(
requests.len(),
3,
"guardian decision should be reused instead of running a second review",
);
assert!(
requests[2]
.input()
.iter()
.any(|item| item.get("type").and_then(Value::as_str) == Some("function_call_output")),
"guardian-approved fallback should continue with tool output",
);
assert!(
!marker.exists(),
"guardian-approved fallback should remove marker file",
);
let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
assert_eq!(
hook_inputs[0]["guardian_review"],
serde_json::json!({
"status": "approved",
"decision": "allow",
"risk_level": "medium",
"user_authorization": "high",
"rationale": "The user asked to delete this local marker file.",
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -0,0 +1,262 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"FileSystemPermissions": {
"properties": {
"read": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"write": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
}
},
"type": "object"
},
"GuardianRiskLevel": {
"enum": [
"low",
"medium",
"high",
"critical"
],
"type": "string"
},
"GuardianUserAuthorization": {
"enum": [
"unknown",
"low",
"medium",
"high"
],
"type": "string"
},
"NetworkPermissions": {
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"NullableString": {
"type": [
"string",
"null"
]
},
"PermissionProfile": {
"properties": {
"file_system": {
"$ref": "#/definitions/FileSystemPermissions"
},
"network": {
"$ref": "#/definitions/NetworkPermissions"
}
},
"type": "object"
},
"PermissionRequestApprovalContext": {
"additionalProperties": false,
"properties": {
"additional_permissions": {
"$ref": "#/definitions/PermissionProfile"
},
"justification": {
"type": "string"
},
"sandbox_permissions": {
"$ref": "#/definitions/SandboxPermissions"
}
},
"required": [
"sandbox_permissions"
],
"type": "object"
},
"PermissionRequestGuardianReview": {
"additionalProperties": false,
"properties": {
"decision": {
"anyOf": [
{
"$ref": "#/definitions/PermissionRequestGuardianReviewDecision"
},
{
"type": "null"
}
]
},
"rationale": {
"type": [
"string",
"null"
]
},
"risk_level": {
"anyOf": [
{
"$ref": "#/definitions/GuardianRiskLevel"
},
{
"type": "null"
}
]
},
"status": {
"$ref": "#/definitions/PermissionRequestGuardianReviewStatus"
},
"user_authorization": {
"anyOf": [
{
"$ref": "#/definitions/GuardianUserAuthorization"
},
{
"type": "null"
}
]
}
},
"required": [
"decision",
"rationale",
"risk_level",
"status",
"user_authorization"
],
"type": "object"
},
"PermissionRequestGuardianReviewDecision": {
"enum": [
"allow",
"deny"
],
"type": "string"
},
"PermissionRequestGuardianReviewStatus": {
"enum": [
"approved",
"denied",
"aborted",
"failed",
"timed_out"
],
"type": "string"
},
"PermissionRequestToolInput": {
"additionalProperties": false,
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
},
"SandboxPermissions": {
"description": "Controls the per-command sandbox override requested by a shell-like tool call.",
"oneOf": [
{
"description": "Run with the turn's configured sandbox policy unchanged.",
"enum": [
"use_default"
],
"type": "string"
},
{
"description": "Request to run outside the sandbox.",
"enum": [
"require_escalated"
],
"type": "string"
},
{
"description": "Request to stay in the sandbox while widening permissions for this command only.",
"enum": [
"with_additional_permissions"
],
"type": "string"
}
]
}
},
"properties": {
"approval_context": {
"$ref": "#/definitions/PermissionRequestApprovalContext"
},
"cwd": {
"type": "string"
},
"guardian_review": {
"anyOf": [
{
"$ref": "#/definitions/PermissionRequestGuardianReview"
},
{
"type": "null"
}
]
},
"hook_event_name": {
"const": "PermissionRequest",
"type": "string"
},
"model": {
"type": "string"
},
"permission_mode": {
"enum": [
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions"
],
"type": "string"
},
"session_id": {
"type": "string"
},
"tool_input": {
"$ref": "#/definitions/PermissionRequestToolInput"
},
"tool_name": {
"const": "Bash",
"type": "string"
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},
"required": [
"approval_context",
"cwd",
"guardian_review",
"hook_event_name",
"model",
"permission_mode",
"session_id",
"tool_input",
"tool_name",
"transcript_path",
"turn_id"
],
"title": "permission-request.command.input",
"type": "object"
}

View File

@@ -0,0 +1,98 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",
"Stop"
],
"type": "string"
},
"PermissionRequestBehaviorWire": {
"enum": [
"allow",
"deny"
],
"type": "string"
},
"PermissionRequestDecisionWire": {
"additionalProperties": false,
"properties": {
"behavior": {
"$ref": "#/definitions/PermissionRequestBehaviorWire"
},
"interrupt": {
"default": false,
"type": "boolean"
},
"message": {
"default": null,
"type": "string"
},
"updatedInput": {
"default": null
},
"updatedPermissions": {
"default": null
}
},
"required": [
"behavior"
],
"type": "object"
},
"PermissionRequestHookSpecificOutputWire": {
"additionalProperties": false,
"properties": {
"decision": {
"allOf": [
{
"$ref": "#/definitions/PermissionRequestDecisionWire"
}
],
"default": null
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
}
},
"required": [
"hookEventName"
],
"type": "object"
}
},
"properties": {
"continue": {
"default": true,
"type": "boolean"
},
"hookSpecificOutput": {
"allOf": [
{
"$ref": "#/definitions/PermissionRequestHookSpecificOutputWire"
}
],
"default": null
},
"stopReason": {
"default": null,
"type": "string"
},
"suppressOutput": {
"default": false,
"type": "boolean"
},
"systemMessage": {
"default": null,
"type": "string"
}
},
"title": "permission-request.command.output",
"type": "object"
}

View File

@@ -11,6 +11,7 @@
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",

View File

@@ -5,6 +5,7 @@
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",

View File

@@ -5,6 +5,7 @@
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",

View File

@@ -11,6 +11,7 @@
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"SessionStart",
"UserPromptSubmit",

View File

@@ -10,6 +10,8 @@ pub(crate) struct HooksFile {
pub(crate) struct HookEvents {
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<MatcherGroup>,
#[serde(rename = "PermissionRequest", default)]
pub permission_request: Vec<MatcherGroup>,
#[serde(rename = "PostToolUse", default)]
pub post_tool_use: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]

View File

@@ -64,6 +64,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
let super::config::HookEvents {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
@@ -75,6 +76,10 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
codex_protocol::protocol::HookEventName::PreToolUse,
pre_tool_use,
),
(
codex_protocol::protocol::HookEventName::PermissionRequest,
permission_request,
),
(
codex_protocol::protocol::HookEventName::PostToolUse,
post_tool_use,

View File

@@ -32,6 +32,7 @@ pub(crate) fn select_handlers(
.filter(|handler| handler.event_name == event_name)
.filter(|handler| match event_name {
HookEventName::PreToolUse
| HookEventName::PermissionRequest
| HookEventName::PostToolUse
| HookEventName::SessionStart => {
matches_matcher(handler.matcher.as_deref(), matcher_input)
@@ -109,6 +110,7 @@ fn scope_for_event(event_name: HookEventName) -> HookScope {
match event_name {
HookEventName::SessionStart => HookScope::Thread,
HookEventName::PreToolUse
| HookEventName::PermissionRequest
| HookEventName::PostToolUse
| HookEventName::UserPromptSubmit
| HookEventName::Stop => HookScope::Turn,

View File

@@ -10,6 +10,8 @@ use std::path::PathBuf;
use codex_config::ConfigLayerStack;
use codex_protocol::protocol::HookRunSummary;
use crate::events::permission_request::PermissionRequestOutcome;
use crate::events::permission_request::PermissionRequestRequest;
use crate::events::post_tool_use::PostToolUseOutcome;
use crate::events::post_tool_use::PostToolUseRequest;
use crate::events::pre_tool_use::PreToolUseOutcome;
@@ -51,6 +53,7 @@ impl ConfiguredHandler {
fn event_name_label(&self) -> &'static str {
match self.event_name {
codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use",
codex_protocol::protocol::HookEventName::PermissionRequest => "permission-request",
codex_protocol::protocol::HookEventName::PostToolUse => "post-tool-use",
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
@@ -104,6 +107,13 @@ impl ClaudeHooksEngine {
crate::events::pre_tool_use::preview(&self.handlers, request)
}
pub(crate) fn preview_permission_request(
&self,
request: &PermissionRequestRequest,
) -> Vec<HookRunSummary> {
crate::events::permission_request::preview(&self.handlers, request)
}
pub(crate) fn preview_post_tool_use(
&self,
request: &PostToolUseRequest,
@@ -123,6 +133,13 @@ impl ClaudeHooksEngine {
crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await
}
pub(crate) async fn run_permission_request(
&self,
request: PermissionRequestRequest,
) -> PermissionRequestOutcome {
crate::events::permission_request::run(&self.handlers, &self.shell, request).await
}
pub(crate) async fn run_post_tool_use(
&self,
request: PostToolUseRequest,

View File

@@ -19,6 +19,19 @@ pub(crate) struct PreToolUseOutput {
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PermissionRequestDecision {
Allow,
Deny { message: String },
}
#[derive(Debug, Clone)]
pub(crate) struct PermissionRequestOutput {
pub universal: UniversalOutput,
pub decision: Option<PermissionRequestDecision>,
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct PostToolUseOutput {
pub universal: UniversalOutput,
@@ -48,6 +61,9 @@ pub(crate) struct StopOutput {
use crate::schema::BlockDecisionWire;
use crate::schema::HookUniversalOutputWire;
use crate::schema::PermissionRequestBehaviorWire;
use crate::schema::PermissionRequestCommandOutputWire;
use crate::schema::PermissionRequestDecisionWire;
use crate::schema::PostToolUseCommandOutputWire;
use crate::schema::PreToolUseCommandOutputWire;
use crate::schema::PreToolUseDecisionWire;
@@ -115,6 +131,29 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
})
}
pub(crate) fn parse_permission_request(stdout: &str) -> Option<PermissionRequestOutput> {
let wire: PermissionRequestCommandOutputWire = parse_json(stdout)?;
let universal = UniversalOutput::from(wire.universal);
let hook_specific_output = wire.hook_specific_output.as_ref();
let decision = hook_specific_output.and_then(|output| output.decision.as_ref());
let invalid_reason = unsupported_permission_request_universal(&universal).or_else(|| {
hook_specific_output.and_then(|output| {
unsupported_permission_request_hook_specific_output(output.decision.as_ref())
})
});
let decision = if invalid_reason.is_none() {
decision.map(permission_request_decision)
} else {
None
};
Some(PermissionRequestOutput {
universal,
decision,
invalid_reason,
})
}
pub(crate) fn parse_post_tool_use(stdout: &str) -> Option<PostToolUseOutput> {
let wire: PostToolUseCommandOutputWire = parse_json(stdout)?;
let universal = UniversalOutput::from(wire.universal);
@@ -235,6 +274,18 @@ fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option<Str
}
}
fn unsupported_permission_request_universal(universal: &UniversalOutput) -> Option<String> {
if !universal.continue_processing {
Some("PermissionRequest hook returned unsupported continue:false".to_string())
} else if universal.stop_reason.is_some() {
Some("PermissionRequest hook returned unsupported stopReason".to_string())
} else if universal.suppress_output {
Some("PermissionRequest hook returned unsupported suppressOutput".to_string())
} else {
None
}
}
fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option<String> {
if universal.suppress_output {
Some("PostToolUse hook returned unsupported suppressOutput".to_string())
@@ -243,6 +294,36 @@ fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option<St
}
}
fn unsupported_permission_request_hook_specific_output(
decision: Option<&PermissionRequestDecisionWire>,
) -> Option<String> {
let decision = decision?;
if decision.updated_input.is_some() {
Some("PermissionRequest hook returned unsupported updatedInput".to_string())
} else if decision.updated_permissions.is_some() {
Some("PermissionRequest hook returned unsupported updatedPermissions".to_string())
} else if decision.interrupt {
Some("PermissionRequest hook returned unsupported interrupt:true".to_string())
} else {
None
}
}
fn permission_request_decision(
decision: &PermissionRequestDecisionWire,
) -> PermissionRequestDecision {
match decision.behavior {
PermissionRequestBehaviorWire::Allow => PermissionRequestDecision::Allow,
PermissionRequestBehaviorWire::Deny => PermissionRequestDecision::Deny {
message: decision
.message
.as_deref()
.and_then(trimmed_reason)
.unwrap_or_else(|| "PermissionRequest hook denied approval".to_string()),
},
}
}
fn unsupported_post_tool_use_hook_specific_output(
output: &crate::schema::PostToolUseHookSpecificOutputWire,
) -> Option<String> {

View File

@@ -6,6 +6,8 @@ use serde_json::Value;
pub(crate) struct GeneratedHookSchemas {
pub post_tool_use_command_input: Value,
pub post_tool_use_command_output: Value,
pub permission_request_command_input: Value,
pub permission_request_command_output: Value,
pub pre_tool_use_command_input: Value,
pub pre_tool_use_command_output: Value,
pub session_start_command_input: Value,
@@ -27,6 +29,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
"post-tool-use.command.output",
include_str!("../../schema/generated/post-tool-use.command.output.schema.json"),
),
permission_request_command_input: parse_json_schema(
"permission-request.command.input",
include_str!("../../schema/generated/permission-request.command.input.schema.json"),
),
permission_request_command_output: parse_json_schema(
"permission-request.command.output",
include_str!("../../schema/generated/permission-request.command.output.schema.json"),
),
pre_tool_use_command_input: parse_json_schema(
"pre-tool-use.command.input",
include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"),
@@ -78,6 +88,8 @@ mod tests {
assert_eq!(schemas.post_tool_use_command_input["type"], "object");
assert_eq!(schemas.post_tool_use_command_output["type"], "object");
assert_eq!(schemas.permission_request_command_input["type"], "object");
assert_eq!(schemas.permission_request_command_output["type"], "object");
assert_eq!(schemas.pre_tool_use_command_input["type"], "object");
assert_eq!(schemas.pre_tool_use_command_output["type"], "object");
assert_eq!(schemas.session_start_command_input["type"], "object");

View File

@@ -100,9 +100,10 @@ pub(crate) fn matcher_pattern_for_event(
matcher: Option<&str>,
) -> Option<&str> {
match event_name {
HookEventName::PreToolUse | HookEventName::PostToolUse | HookEventName::SessionStart => {
matcher
}
HookEventName::PreToolUse
| HookEventName::PermissionRequest
| HookEventName::PostToolUse
| HookEventName::SessionStart => matcher,
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
}
}

View File

@@ -1,4 +1,5 @@
pub(crate) mod common;
pub mod permission_request;
pub mod post_tool_use;
pub mod pre_tool_use;
pub mod session_start;

View File

@@ -0,0 +1,370 @@
//! PermissionRequest hook execution for approval prompts.
//!
//! This event is different from `PreToolUse`: it runs only when Codex is about
//! to ask for permission, and its decision answers that approval prompt rather
//! than blocking normal tool execution. A quiet hook is a no-op so callers can
//! fall back to the existing approval path.
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxPermissions;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::permission_review::PermissionRequestGuardianReview;
use crate::schema::PermissionRequestApprovalContext;
use crate::schema::PermissionRequestCommandInput;
use crate::schema::PermissionRequestToolInput;
#[derive(Debug, Clone)]
pub struct PermissionRequestRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub cwd: PathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
pub tool_name: String,
/// Suffix used only for hook run ids.
///
/// Claude's PermissionRequest input does not include `tool_use_id`, but Codex
/// still needs stable begin/end ids for hook UI and transcript bookkeeping.
pub run_id_suffix: String,
pub command: String,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<PermissionProfile>,
pub justification: Option<String>,
/// Advisory approval context from Codex's automated reviewer, when one ran.
///
/// A hook can use this as another signal, but it is not bound by the
/// guardian's decision. The hook may allow, deny, or stay quiet; if it stays
/// quiet, the orchestrator falls back to the guardian's original decision.
pub guardian_review: Option<PermissionRequestGuardianReview>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionRequestDecision {
Allow,
Deny { message: String },
}
#[derive(Debug)]
pub struct PermissionRequestOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub decision: Option<PermissionRequestDecision>,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct PermissionRequestHandlerData {
decision: Option<PermissionRequestDecision>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
request: &PermissionRequestRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(
handlers,
HookEventName::PermissionRequest,
Some(&request.tool_name),
)
.into_iter()
.map(|handler| {
common::hook_run_for_tool_use(
dispatcher::running_summary(&handler),
&request.run_id_suffix,
)
})
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
request: PermissionRequestRequest,
) -> PermissionRequestOutcome {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::PermissionRequest,
Some(&request.tool_name),
);
if matched.is_empty() {
return PermissionRequestOutcome {
hook_events: Vec::new(),
decision: None,
};
}
// This first pass is Bash-only. Keep the wire input fixed to Claude's
// `Bash` shape even though the request carries `tool_name`, so later
// tool support has to choose its own explicit schema instead of
// accidentally inheriting Bash fields.
let input_json = match serde_json::to_string(&build_command_input(&request)) {
Ok(input_json) => input_json,
Err(error) => {
let hook_events = common::serialization_failure_hook_events_for_tool_use(
matched,
Some(request.turn_id.clone()),
format!("failed to serialize permission request hook input: {error}"),
&request.run_id_suffix,
);
return PermissionRequestOutcome {
hook_events,
decision: None,
};
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;
// Multiple hooks may match the same approval prompt. For now, use the first
// explicit decision in declaration order and leave richer precedence rules
// to the follow-up work.
let decision = results
.iter()
.find_map(|result| result.data.decision.clone());
PermissionRequestOutcome {
hook_events: results
.into_iter()
.map(|result| {
common::hook_completed_for_tool_use(result.completed, &request.run_id_suffix)
})
.collect(),
decision,
}
}
fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestCommandInput {
PermissionRequestCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "PermissionRequest".to_string(),
model: request.model.clone(),
permission_mode: request.permission_mode.clone(),
tool_name: "Bash".to_string(),
tool_input: PermissionRequestToolInput {
command: request.command.clone(),
},
approval_context: PermissionRequestApprovalContext {
sandbox_permissions: request.sandbox_permissions,
additional_permissions: request.additional_permissions.clone(),
justification: request.justification.clone(),
},
guardian_review: request.guardian_review.clone(),
}
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<PermissionRequestHandlerData> {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
let mut decision = None;
match run_result.error.as_deref() {
Some(error) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error.to_string(),
});
}
None => match run_result.exit_code {
Some(0) => {
let trimmed_stdout = run_result.stdout.trim();
if trimmed_stdout.is_empty() {
} else if let Some(parsed) =
output_parser::parse_permission_request(&run_result.stdout)
{
if let Some(system_message) = parsed.universal.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: system_message,
});
}
if let Some(invalid_reason) = parsed.invalid_reason {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: invalid_reason,
});
} else if let Some(parsed_decision) = parsed.decision {
match parsed_decision {
output_parser::PermissionRequestDecision::Allow => {
decision = Some(PermissionRequestDecision::Allow);
}
output_parser::PermissionRequestDecision::Deny { message } => {
status = HookRunStatus::Blocked;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: message.clone(),
});
decision = Some(PermissionRequestDecision::Deny { message });
}
}
}
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
// Invalid JSON-like output is treated as a hook failure, not an
// approval decision. That keeps malformed hooks fail-open: the
// orchestrator can still fall back to normal approval.
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid permission-request JSON output".to_string(),
});
}
}
Some(2) => {
// Match Claude's blocking-hook convention: exit code 2 denies
// the approval prompt, with stderr as the denial message.
if let Some(message) = common::trimmed_non_empty(&run_result.stderr) {
status = HookRunStatus::Blocked;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: message.clone(),
});
decision = Some(PermissionRequestDecision::Deny { message });
} else {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "PermissionRequest hook exited with code 2 but did not write a denial reason to stderr".to_string(),
});
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: format!("hook exited with code {exit_code}"),
});
}
None => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited without a status code".to_string(),
});
}
},
}
let completed = HookCompletedEvent {
turn_id,
run: dispatcher::completed_summary(handler, &run_result, status, entries),
};
dispatcher::ParsedHandler {
completed,
data: PermissionRequestHandlerData { decision },
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxPermissions;
use pretty_assertions::assert_eq;
use serde_json::json;
use super::PermissionRequestRequest;
use super::build_command_input;
#[test]
fn command_input_includes_approval_context_alongside_guardian_review() {
let request = PermissionRequestRequest {
session_id: ThreadId::new(),
turn_id: "turn-123".to_string(),
cwd: PathBuf::from("/repo"),
transcript_path: Some(PathBuf::from("/tmp/transcript.jsonl")),
model: "gpt-5".to_string(),
permission_mode: "on-request".to_string(),
tool_name: "Bash".to_string(),
run_id_suffix: "call-123".to_string(),
command: "cargo test -p codex-core".to_string(),
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
additional_permissions: Some(PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: None,
}),
justification: Some("Need network and target writes".to_string()),
guardian_review: Some(crate::permission_review::PermissionRequestGuardianReview {
status: crate::permission_review::PermissionRequestGuardianReviewStatus::Approved,
decision: Some(
crate::permission_review::PermissionRequestGuardianReviewDecision::Allow,
),
risk_level: None,
user_authorization: None,
rationale: Some("Scoped escalation looks reasonable".to_string()),
}),
};
let actual = serde_json::to_value(build_command_input(&request))
.expect("serialize permission request input");
assert_eq!(
actual,
json!({
"session_id": request.session_id.to_string(),
"turn_id": "turn-123",
"transcript_path": "/tmp/transcript.jsonl",
"cwd": "/repo",
"hook_event_name": "PermissionRequest",
"model": "gpt-5",
"permission_mode": "on-request",
"tool_name": "Bash",
"tool_input": {
"command": "cargo test -p codex-core",
},
"approval_context": {
"sandbox_permissions": "with_additional_permissions",
"additional_permissions": {
"file_system": null,
"network": {
"enabled": true,
},
},
"justification": "Need network and target writes",
},
"guardian_review": {
"status": "approved",
"decision": "allow",
"risk_level": null,
"user_authorization": null,
"rationale": "Scoped escalation looks reasonable",
},
})
);
}
}

View File

@@ -1,10 +1,14 @@
mod engine;
pub(crate) mod events;
mod legacy_notify;
mod permission_review;
mod registry;
mod schema;
mod types;
pub use events::permission_request::PermissionRequestDecision;
pub use events::permission_request::PermissionRequestOutcome;
pub use events::permission_request::PermissionRequestRequest;
pub use events::post_tool_use::PostToolUseOutcome;
pub use events::post_tool_use::PostToolUseRequest;
pub use events::pre_tool_use::PreToolUseOutcome;
@@ -18,6 +22,9 @@ pub use events::user_prompt_submit::UserPromptSubmitOutcome;
pub use events::user_prompt_submit::UserPromptSubmitRequest;
pub use legacy_notify::legacy_notify_json;
pub use legacy_notify::notify_hook;
pub use permission_review::PermissionRequestGuardianReview;
pub use permission_review::PermissionRequestGuardianReviewDecision;
pub use permission_review::PermissionRequestGuardianReviewStatus;
pub use registry::Hooks;
pub use registry::HooksConfig;
pub use registry::command_from_argv;

View File

@@ -0,0 +1,83 @@
use codex_protocol::protocol::GuardianRiskLevel;
use codex_protocol::protocol::GuardianUserAuthorization;
use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
use schemars::schema::InstanceType;
use schemars::schema::Schema;
use schemars::schema::SchemaObject;
use schemars::schema::SubschemaValidation;
use serde::Serialize;
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PermissionRequestGuardianReview {
pub status: PermissionRequestGuardianReviewStatus,
#[schemars(schema_with = "nullable_permission_request_guardian_review_decision_schema")]
pub decision: Option<PermissionRequestGuardianReviewDecision>,
#[schemars(schema_with = "nullable_guardian_risk_level_schema")]
pub risk_level: Option<GuardianRiskLevel>,
#[schemars(schema_with = "nullable_guardian_user_authorization_schema")]
pub user_authorization: Option<GuardianUserAuthorization>,
#[schemars(schema_with = "nullable_string_schema")]
pub rationale: Option<String>,
}
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PermissionRequestGuardianReviewStatus {
Approved,
Denied,
Aborted,
Failed,
TimedOut,
}
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PermissionRequestGuardianReviewDecision {
Allow,
Deny,
}
pub(crate) fn nullable_permission_request_guardian_review_schema(
generator: &mut SchemaGenerator,
) -> Schema {
nullable_schema(generator.subschema_for::<PermissionRequestGuardianReview>())
}
fn nullable_permission_request_guardian_review_decision_schema(
generator: &mut SchemaGenerator,
) -> Schema {
nullable_schema(generator.subschema_for::<PermissionRequestGuardianReviewDecision>())
}
fn nullable_guardian_risk_level_schema(generator: &mut SchemaGenerator) -> Schema {
nullable_schema(generator.subschema_for::<GuardianRiskLevel>())
}
fn nullable_guardian_user_authorization_schema(generator: &mut SchemaGenerator) -> Schema {
nullable_schema(generator.subschema_for::<GuardianUserAuthorization>())
}
fn nullable_string_schema(_generator: &mut SchemaGenerator) -> Schema {
Schema::Object(SchemaObject {
instance_type: Some(vec![InstanceType::String, InstanceType::Null].into()),
..Default::default()
})
}
fn nullable_schema(schema: Schema) -> Schema {
Schema::Object(SchemaObject {
subschemas: Some(Box::new(SubschemaValidation {
any_of: Some(vec![
schema,
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::Null.into()),
..Default::default()
}),
]),
..Default::default()
})),
..Default::default()
})
}

View File

@@ -3,6 +3,8 @@ use tokio::process::Command;
use crate::engine::ClaudeHooksEngine;
use crate::engine::CommandShell;
use crate::events::permission_request::PermissionRequestOutcome;
use crate::events::permission_request::PermissionRequestRequest;
use crate::events::post_tool_use::PostToolUseOutcome;
use crate::events::post_tool_use::PostToolUseRequest;
use crate::events::pre_tool_use::PreToolUseOutcome;
@@ -103,6 +105,13 @@ impl Hooks {
self.engine.preview_pre_tool_use(request)
}
pub fn preview_permission_request(
&self,
request: &PermissionRequestRequest,
) -> Vec<codex_protocol::protocol::HookRunSummary> {
self.engine.preview_permission_request(request)
}
pub fn preview_post_tool_use(
&self,
request: &PostToolUseRequest,
@@ -122,6 +131,13 @@ impl Hooks {
self.engine.run_pre_tool_use(request).await
}
pub async fn run_permission_request(
&self,
request: PermissionRequestRequest,
) -> PermissionRequestOutcome {
self.engine.run_permission_request(request).await
}
pub async fn run_post_tool_use(&self, request: PostToolUseRequest) -> PostToolUseOutcome {
self.engine.run_post_tool_use(request).await
}

View File

@@ -12,9 +12,16 @@ use serde_json::Value;
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxPermissions;
use crate::permission_review::PermissionRequestGuardianReview;
const GENERATED_DIR: &str = "generated";
const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json";
const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json";
const PERMISSION_REQUEST_INPUT_FIXTURE: &str = "permission-request.command.input.schema.json";
const PERMISSION_REQUEST_OUTPUT_FIXTURE: &str = "permission-request.command.output.schema.json";
const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json";
const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json";
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
@@ -69,6 +76,8 @@ pub(crate) struct HookUniversalOutputWire {
pub(crate) enum HookEventNameWire {
#[serde(rename = "PreToolUse")]
PreToolUse,
#[serde(rename = "PermissionRequest")]
PermissionRequest,
#[serde(rename = "PostToolUse")]
PostToolUse,
#[serde(rename = "SessionStart")]
@@ -109,6 +118,49 @@ pub(crate) struct PostToolUseCommandOutputWire {
pub hook_specific_output: Option<PostToolUseHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "permission-request.command.output")]
pub(crate) struct PermissionRequestCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub hook_specific_output: Option<PermissionRequestHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PermissionRequestHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub decision: Option<PermissionRequestDecisionWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PermissionRequestDecisionWire {
pub behavior: PermissionRequestBehaviorWire,
#[serde(default)]
pub updated_input: Option<Value>,
#[serde(default)]
pub updated_permissions: Option<Value>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub interrupt: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum PermissionRequestBehaviorWire {
#[serde(rename = "allow")]
Allow,
#[serde(rename = "deny")]
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -181,6 +233,45 @@ pub(crate) struct PreToolUseCommandInput {
pub tool_use_id: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PermissionRequestToolInput {
pub command: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct PermissionRequestApprovalContext {
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<PermissionProfile>,
pub justification: Option<String>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "permission-request.command.input")]
pub(crate) struct PermissionRequestCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "permission_request_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
#[schemars(schema_with = "permission_request_tool_name_schema")]
pub tool_name: String,
pub tool_input: PermissionRequestToolInput,
pub approval_context: PermissionRequestApprovalContext,
#[schemars(
schema_with = "crate::permission_review::nullable_permission_request_guardian_review_schema"
)]
pub guardian_review: Option<PermissionRequestGuardianReview>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -358,6 +449,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
&generated_dir.join(POST_TOOL_USE_OUTPUT_FIXTURE),
schema_json::<PostToolUseCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(PERMISSION_REQUEST_INPUT_FIXTURE),
schema_json::<PermissionRequestCommandInput>()?,
)?;
write_schema(
&generated_dir.join(PERMISSION_REQUEST_OUTPUT_FIXTURE),
schema_json::<PermissionRequestCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE),
schema_json::<PreToolUseCommandInput>()?,
@@ -461,10 +560,18 @@ fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("PreToolUse")
}
fn permission_request_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("PermissionRequest")
}
fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Bash")
}
fn permission_request_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Bash")
}
fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("UserPromptSubmit")
}
@@ -516,10 +623,13 @@ fn default_continue() -> bool {
#[cfg(test)]
mod tests {
use super::PERMISSION_REQUEST_INPUT_FIXTURE;
use super::PERMISSION_REQUEST_OUTPUT_FIXTURE;
use super::POST_TOOL_USE_INPUT_FIXTURE;
use super::POST_TOOL_USE_OUTPUT_FIXTURE;
use super::PRE_TOOL_USE_INPUT_FIXTURE;
use super::PRE_TOOL_USE_OUTPUT_FIXTURE;
use super::PermissionRequestCommandInput;
use super::PostToolUseCommandInput;
use super::PreToolUseCommandInput;
use super::SESSION_START_INPUT_FIXTURE;
@@ -544,6 +654,12 @@ mod tests {
POST_TOOL_USE_OUTPUT_FIXTURE => {
include_str!("../schema/generated/post-tool-use.command.output.schema.json")
}
PERMISSION_REQUEST_INPUT_FIXTURE => {
include_str!("../schema/generated/permission-request.command.input.schema.json")
}
PERMISSION_REQUEST_OUTPUT_FIXTURE => {
include_str!("../schema/generated/permission-request.command.output.schema.json")
}
PRE_TOOL_USE_INPUT_FIXTURE => {
include_str!("../schema/generated/pre-tool-use.command.input.schema.json")
}
@@ -585,6 +701,8 @@ mod tests {
for fixture in [
POST_TOOL_USE_INPUT_FIXTURE,
POST_TOOL_USE_OUTPUT_FIXTURE,
PERMISSION_REQUEST_INPUT_FIXTURE,
PERMISSION_REQUEST_OUTPUT_FIXTURE,
PRE_TOOL_USE_INPUT_FIXTURE,
PRE_TOOL_USE_OUTPUT_FIXTURE,
SESSION_START_INPUT_FIXTURE,
@@ -615,6 +733,11 @@ mod tests {
.expect("serialize post tool use input schema"),
)
.expect("parse post tool use input schema");
let permission_request: Value = serde_json::from_slice(
&schema_json::<PermissionRequestCommandInput>()
.expect("serialize permission request input schema"),
)
.expect("parse permission request input schema");
let user_prompt_submit: Value = serde_json::from_slice(
&schema_json::<UserPromptSubmitCommandInput>()
.expect("serialize user prompt submit input schema"),
@@ -625,7 +748,13 @@ mod tests {
)
.expect("parse stop input schema");
for schema in [&pre_tool_use, &post_tool_use, &user_prompt_submit, &stop] {
for schema in [
&pre_tool_use,
&permission_request,
&post_tool_use,
&user_prompt_submit,
&stop,
] {
assert_eq!(schema["properties"]["turn_id"]["type"], "string");
assert!(
schema["required"]

View File

@@ -1573,6 +1573,7 @@ pub enum EventMsg {
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
PreToolUse,
PermissionRequest,
PostToolUse,
SessionStart,
UserPromptSubmit,

View File

@@ -1064,6 +1064,7 @@ pub(super) async fn assert_hook_events_snapshot(
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
match event_name {
codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse",
codex_protocol::protocol::HookEventName::PermissionRequest => "PermissionRequest",
codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse",
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",

View File

@@ -701,6 +701,7 @@ fn hook_output_prefix(kind: HookOutputEntryKind) -> &'static str {
fn hook_event_label(event_name: HookEventName) -> &'static str {
match event_name {
HookEventName::PreToolUse => "PreToolUse",
HookEventName::PermissionRequest => "PermissionRequest",
HookEventName::PostToolUse => "PostToolUse",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",