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:
Abhinav
2026-04-17 14:45:47 +00:00
committed by GitHub
co-authored by Codex
parent d0047de7cb
commit 8494e5bd7b
43 changed files with 1821 additions and 36 deletions
+158
View File
@@ -19,6 +19,19 @@ pub(crate) struct PreToolUseOutput {
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PermissionRequestDecision {
Allow,
Deny { message: String },
}
#[derive(Debug, Clone)]
pub(crate) struct PermissionRequestOutput {
pub universal: UniversalOutput,
pub decision: Option<PermissionRequestDecision>,
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct PostToolUseOutput {
pub universal: UniversalOutput,
@@ -48,6 +61,9 @@ pub(crate) struct StopOutput {
use crate::schema::BlockDecisionWire;
use crate::schema::HookUniversalOutputWire;
use crate::schema::PermissionRequestBehaviorWire;
use crate::schema::PermissionRequestCommandOutputWire;
use crate::schema::PermissionRequestDecisionWire;
use crate::schema::PostToolUseCommandOutputWire;
use crate::schema::PreToolUseCommandOutputWire;
use crate::schema::PreToolUseDecisionWire;
@@ -115,6 +131,29 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
})
}
pub(crate) fn parse_permission_request(stdout: &str) -> Option<PermissionRequestOutput> {
let wire: PermissionRequestCommandOutputWire = parse_json(stdout)?;
let universal = UniversalOutput::from(wire.universal);
let hook_specific_output = wire.hook_specific_output.as_ref();
let decision = hook_specific_output.and_then(|output| output.decision.as_ref());
let invalid_reason = unsupported_permission_request_universal(&universal).or_else(|| {
hook_specific_output.and_then(|output| {
unsupported_permission_request_hook_specific_output(output.decision.as_ref())
})
});
let decision = if invalid_reason.is_none() {
decision.map(permission_request_decision)
} else {
None
};
Some(PermissionRequestOutput {
universal,
decision,
invalid_reason,
})
}
pub(crate) fn parse_post_tool_use(stdout: &str) -> Option<PostToolUseOutput> {
let wire: PostToolUseCommandOutputWire = parse_json(stdout)?;
let universal = UniversalOutput::from(wire.universal);
@@ -235,6 +274,18 @@ fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option<Str
}
}
fn unsupported_permission_request_universal(universal: &UniversalOutput) -> Option<String> {
if !universal.continue_processing {
Some("PermissionRequest hook returned unsupported continue:false".to_string())
} else if universal.stop_reason.is_some() {
Some("PermissionRequest hook returned unsupported stopReason".to_string())
} else if universal.suppress_output {
Some("PermissionRequest hook returned unsupported suppressOutput".to_string())
} else {
None
}
}
fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option<String> {
if universal.suppress_output {
Some("PostToolUse hook returned unsupported suppressOutput".to_string())
@@ -243,6 +294,36 @@ fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option<St
}
}
fn unsupported_permission_request_hook_specific_output(
decision: Option<&PermissionRequestDecisionWire>,
) -> Option<String> {
let decision = decision?;
if decision.updated_input.is_some() {
Some("PermissionRequest hook returned unsupported updatedInput".to_string())
} else if decision.updated_permissions.is_some() {
Some("PermissionRequest hook returned unsupported updatedPermissions".to_string())
} else if decision.interrupt {
Some("PermissionRequest hook returned unsupported interrupt:true".to_string())
} else {
None
}
}
fn permission_request_decision(
decision: &PermissionRequestDecisionWire,
) -> PermissionRequestDecision {
match decision.behavior {
PermissionRequestBehaviorWire::Allow => PermissionRequestDecision::Allow,
PermissionRequestBehaviorWire::Deny => PermissionRequestDecision::Deny {
message: decision
.message
.as_deref()
.and_then(trimmed_reason)
.unwrap_or_else(|| "PermissionRequest hook denied approval".to_string()),
},
}
}
fn unsupported_post_tool_use_hook_specific_output(
output: &crate::schema::PostToolUseHookSpecificOutputWire,
) -> Option<String> {
@@ -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())
);
}
}