mirror of
https://github.com/openai/codex.git
synced 2026-04-14 01:35:00 +00:00
Compare commits
8 Commits
dev/shaqay
...
dev/abhina
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f56c2b579 | ||
|
|
312987d9ef | ||
|
|
9581563efb | ||
|
|
9f57404d34 | ||
|
|
568ff282cb | ||
|
|
b9098cb968 | ||
|
|
a05fe33cc9 | ||
|
|
07896f7f2e |
@@ -1404,6 +1404,7 @@
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
|
||||
@@ -8395,6 +8395,7 @@
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
|
||||
@@ -5147,6 +5147,7 @@
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(¶ms.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(¶ms.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(),
|
||||
|
||||
@@ -6,10 +6,16 @@ simple sequence for any ToolRuntime: approval → select sandbox → attempt →
|
||||
retry with an escalated sandbox strategy on denial (no re‑approval 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
370
codex-rs/hooks/src/events/permission_request.rs
Normal file
370
codex-rs/hooks/src/events/permission_request.rs
Normal 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",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
83
codex-rs/hooks/src/permission_review.rs
Normal file
83
codex-rs/hooks/src/permission_review.rs
Normal 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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1573,6 +1573,7 @@ pub enum EventMsg {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEventName {
|
||||
PreToolUse,
|
||||
PermissionRequest,
|
||||
PostToolUse,
|
||||
SessionStart,
|
||||
UserPromptSubmit,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user