mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add PermissionRequest hooks support (#17563)
## Why We need `PermissionRequest` hook support! Also addresses: - https://github.com/openai/codex/issues/16301 - run a script on Hook to do things like play a sound to draw attention but actually no-op so user can still approve - can omit the `decision` object from output or just have the script exit 0 and print nothing - https://github.com/openai/codex/issues/15311 - let the script approve/deny on its own - external UI what will run on Hook and relay decision back to codex ## Reviewer Note There's a lot of plumbing for the new hook, key files to review are: - New hook added in `codex-rs/hooks/src/events/permission_request.rs` - Wiring for network approvals `codex-rs/core/src/tools/network_approval.rs` - Wiring for tool orchestrator `codex-rs/core/src/tools/orchestrator.rs` - Wiring for execve `codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs` ## What - Wires shell, unified exec, and network approval prompts into the `PermissionRequest` hook flow. - Lets hooks allow or deny approval prompts; quiet or invalid hooks fall back to the normal approval path. - Uses `tool_input.description` for user-facing context when it helps: - shell / `exec_command`: the request justification, when present - network approvals: `network-access <domain>` - Uses `tool_name: Bash` for shell, unified exec, and network approval permission-request hooks. - For network approvals, passes the originating command in `tool_input.command` when there is a single owning call; otherwise falls back to the synthetic `network-access ...` command. <details> <summary>Example `PermissionRequest` hook input for a shell approval</summary> ```json { "session_id": "<session-id>", "turn_id": "<turn-id>", "transcript_path": "/path/to/transcript.jsonl", "cwd": "/path/to/cwd", "hook_event_name": "PermissionRequest", "model": "gpt-5", "permission_mode": "default", "tool_name": "Bash", "tool_input": { "command": "rm -f /tmp/example" } } ``` </details> <details> <summary>Example `PermissionRequest` hook input for an escalated `exec_command` request</summary> ```json { "session_id": "<session-id>", "turn_id": "<turn-id>", "transcript_path": "/path/to/transcript.jsonl", "cwd": "/path/to/cwd", "hook_event_name": "PermissionRequest", "model": "gpt-5", "permission_mode": "default", "tool_name": "Bash", "tool_input": { "command": "cp /tmp/source.json /Users/alice/export/source.json", "description": "Need to copy a generated file outside the workspace" } } ``` </details> <details> <summary>Example `PermissionRequest` hook input for a network approval</summary> ```json { "session_id": "<session-id>", "turn_id": "<turn-id>", "transcript_path": "/path/to/transcript.jsonl", "cwd": "/path/to/cwd", "hook_event_name": "PermissionRequest", "model": "gpt-5", "permission_mode": "default", "tool_name": "Bash", "tool_input": { "command": "curl http://codex-network-test.invalid", "description": "network-access http://codex-network-test.invalid" } } ``` </details> ## Follow-ups - Implement the `PermissionRequest` semantics for `updatedInput`, `updatedPermissions`, `interrupt`, and suggestions / `permission_suggestions` - Add `PermissionRequest` support for the `request_permissions` tool path --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
d0047de7cb
commit
8494e5bd7b
@@ -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)]
|
||||
|
||||
@@ -65,6 +65,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,
|
||||
@@ -76,6 +77,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)
|
||||
@@ -111,6 +112,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 codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
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;
|
||||
@@ -52,6 +54,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",
|
||||
@@ -105,6 +108,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,
|
||||
@@ -124,6 +134,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> {
|
||||
@@ -334,3 +415,80 @@ fn trimmed_reason(reason: &str) -> Option<String> {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
use super::parse_permission_request;
|
||||
|
||||
#[test]
|
||||
fn permission_request_rejects_reserved_updated_input_field() {
|
||||
let parsed = parse_permission_request(
|
||||
&json!({
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "allow",
|
||||
"updatedInput": {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("permission request hook output should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.invalid_reason,
|
||||
Some("PermissionRequest hook returned unsupported updatedInput".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_rejects_reserved_updated_permissions_field() {
|
||||
let parsed = parse_permission_request(
|
||||
&json!({
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "allow",
|
||||
"updatedPermissions": {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("permission request hook output should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.invalid_reason,
|
||||
Some("PermissionRequest hook returned unsupported updatedPermissions".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_rejects_reserved_interrupt_field() {
|
||||
let parsed = parse_permission_request(
|
||||
&json!({
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "allow",
|
||||
"interrupt": true
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("permission request hook output should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.invalid_reason,
|
||||
Some("PermissionRequest hook returned unsupported interrupt:true".to_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");
|
||||
|
||||
Reference in New Issue
Block a user