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:
@@ -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>,
|
||||
@@ -197,6 +199,14 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
Some(req.exec_approval_requirement.clone())
|
||||
}
|
||||
|
||||
fn permission_request_payload(&self, req: &ShellRequest) -> Option<PermissionRequestPayload> {
|
||||
Some(PermissionRequestPayload {
|
||||
tool_name: "Bash".to_string(),
|
||||
command: req.hook_command.clone(),
|
||||
description: req.justification.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride {
|
||||
sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement)
|
||||
}
|
||||
@@ -212,6 +222,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
Some(NetworkApprovalSpec {
|
||||
network: req.network.clone(),
|
||||
mode: NetworkApprovalMode::Immediate,
|
||||
command: req.hook_command.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ use crate::guardian::guardian_timeout_message;
|
||||
use crate::guardian::new_guardian_review_id;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::hook_runtime::run_permission_request_hooks;
|
||||
use crate::sandboxing::ExecOptions;
|
||||
use crate::sandboxing::ExecRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::ShellType;
|
||||
use crate::tools::runtimes::build_sandbox_command;
|
||||
use crate::tools::sandboxing::PermissionRequestPayload;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
@@ -22,6 +24,7 @@ use codex_execpolicy::MatchOptions;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_features::Feature;
|
||||
use codex_hooks::PermissionRequestDecision;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
@@ -321,6 +324,7 @@ enum DecisionSource {
|
||||
struct PromptDecision {
|
||||
decision: ReviewDecision,
|
||||
guardian_review_id: Option<String>,
|
||||
rejection_message: Option<String>,
|
||||
}
|
||||
|
||||
fn execve_prompt_is_rejected_by_policy(
|
||||
@@ -395,11 +399,44 @@ impl CoreShellActionProvider {
|
||||
let guardian_review_id = routes_approval_to_guardian(&turn).then(new_guardian_review_id);
|
||||
Ok(stopwatch
|
||||
.pause_for(async move {
|
||||
// 1) Run PermissionRequest hooks
|
||||
let permission_request = PermissionRequestPayload {
|
||||
tool_name: "Bash".to_string(),
|
||||
command: codex_shell_command::parse_command::shlex_join(&command),
|
||||
description: None,
|
||||
};
|
||||
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
|
||||
match run_permission_request_hooks(
|
||||
&session,
|
||||
&turn,
|
||||
&effective_approval_id,
|
||||
permission_request,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(PermissionRequestDecision::Allow) => {
|
||||
return PromptDecision {
|
||||
decision: ReviewDecision::Approved,
|
||||
guardian_review_id: None,
|
||||
rejection_message: None,
|
||||
};
|
||||
}
|
||||
Some(PermissionRequestDecision::Deny { message }) => {
|
||||
return PromptDecision {
|
||||
decision: ReviewDecision::Denied,
|
||||
guardian_review_id: None,
|
||||
rejection_message: Some(message),
|
||||
};
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
// 2) Route to Guardian if configured
|
||||
if let Some(review_id) = guardian_review_id.clone() {
|
||||
let decision = review_approval_request(
|
||||
&session,
|
||||
&turn,
|
||||
review_id,
|
||||
review_id.clone(),
|
||||
GuardianApprovalRequest::Execve {
|
||||
id: call_id.clone(),
|
||||
source,
|
||||
@@ -414,8 +451,11 @@ impl CoreShellActionProvider {
|
||||
return PromptDecision {
|
||||
decision,
|
||||
guardian_review_id,
|
||||
rejection_message: None,
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Fall back to regular user prompt
|
||||
let decision = session
|
||||
.request_command_approval(
|
||||
&turn,
|
||||
@@ -433,6 +473,7 @@ impl CoreShellActionProvider {
|
||||
PromptDecision {
|
||||
decision,
|
||||
guardian_review_id: None,
|
||||
rejection_message: None,
|
||||
}
|
||||
})
|
||||
.await)
|
||||
@@ -488,7 +529,11 @@ impl CoreShellActionProvider {
|
||||
}
|
||||
},
|
||||
ReviewDecision::Denied => {
|
||||
let message = if let Some(review_id) =
|
||||
let message = if let Some(message) =
|
||||
prompt_decision.rejection_message.clone()
|
||||
{
|
||||
message
|
||||
} else if let Some(review_id) =
|
||||
prompt_decision.guardian_review_id.as_deref()
|
||||
{
|
||||
guardian_rejection_message(self.session.as_ref(), review_id).await
|
||||
|
||||
@@ -6,11 +6,16 @@ use super::evaluate_intercepted_exec_policy;
|
||||
use super::extract_shell_script;
|
||||
use super::join_program_and_argv;
|
||||
use super::map_exec_result;
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::config::Constrained;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use anyhow::Context;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_hooks::HooksConfig;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
@@ -21,6 +26,7 @@ use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::GuardianCommandSource;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::SandboxType;
|
||||
@@ -30,8 +36,10 @@ use codex_shell_escalation::ExecResult;
|
||||
use codex_shell_escalation::Permissions as EscalatedPermissions;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
fn host_absolute_path(segments: &[&str]) -> String {
|
||||
let mut path = if cfg!(windows) {
|
||||
@@ -319,6 +327,134 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Result<()> {
|
||||
let (mut session, mut turn_context) = make_session_and_context().await;
|
||||
std::fs::create_dir_all(&turn_context.config.codex_home)
|
||||
.context("recreate codex home for hook fixtures")?;
|
||||
let script_path = turn_context
|
||||
.config
|
||||
.codex_home
|
||||
.join("permission_request_hook.py");
|
||||
let log_path = turn_context
|
||||
.config
|
||||
.codex_home
|
||||
.join("permission_request_hook_log.jsonl");
|
||||
std::fs::write(
|
||||
&script_path,
|
||||
format!(
|
||||
"#!/bin/sh\ncat > {log_path}\nprintf '%s\\n' '{response}'\n",
|
||||
log_path = shlex::try_quote(log_path.to_string_lossy().as_ref())?,
|
||||
response = "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\"}}}",
|
||||
),
|
||||
)
|
||||
.with_context(|| format!("write hook script to {}", script_path.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut permissions = std::fs::metadata(&script_path)
|
||||
.with_context(|| format!("read hook script metadata from {}", script_path.display()))?
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&script_path, permissions)
|
||||
.with_context(|| format!("set hook script permissions on {}", script_path.display()))?;
|
||||
}
|
||||
std::fs::write(
|
||||
turn_context.config.codex_home.join("hooks.json"),
|
||||
serde_json::json!({
|
||||
"hooks": {
|
||||
"PermissionRequest": [{
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": script_path.display().to_string(),
|
||||
}]
|
||||
}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.context("write hooks.json")?;
|
||||
|
||||
let mut hook_shell_argv = session
|
||||
.user_shell()
|
||||
.derive_exec_args("", /*use_login_shell*/ false);
|
||||
let hook_shell_program = hook_shell_argv.remove(0);
|
||||
let _ = hook_shell_argv.pop();
|
||||
session.services.hooks = Hooks::new(HooksConfig {
|
||||
feature_enabled: true,
|
||||
config_layer_stack: Some(turn_context.config.config_layer_stack.clone()),
|
||||
shell_program: Some(hook_shell_program),
|
||||
shell_args: hook_shell_argv,
|
||||
..HooksConfig::default()
|
||||
});
|
||||
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
turn_context.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
turn_context.sandbox_policy = Constrained::allow_any(sandbox_policy.clone());
|
||||
turn_context.file_system_sandbox_policy = read_only_file_system_sandbox_policy();
|
||||
turn_context.network_sandbox_policy = NetworkSandboxPolicy::Restricted;
|
||||
|
||||
let workdir = AbsolutePathBuf::try_from(std::env::current_dir()?)?;
|
||||
let target = std::env::temp_dir().join("execve-hook-short-circuit.txt");
|
||||
let target_str = target.display().to_string();
|
||||
let command = vec!["touch".to_string(), target_str.clone()];
|
||||
let expected_hook_command =
|
||||
codex_shell_command::parse_command::shlex_join(&["/usr/bin/touch".to_string(), target_str]);
|
||||
let provider = CoreShellActionProvider {
|
||||
policy: std::sync::Arc::new(RwLock::new(codex_execpolicy::Policy::empty())),
|
||||
session: std::sync::Arc::new(session),
|
||||
turn: std::sync::Arc::new(turn_context),
|
||||
call_id: "execve-hook-call".to_string(),
|
||||
tool_name: GuardianCommandSource::Shell,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
approval_sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prompt_permissions: None,
|
||||
stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)),
|
||||
};
|
||||
|
||||
let action = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
codex_shell_escalation::EscalationPolicy::determine_action(
|
||||
&provider,
|
||||
&AbsolutePathBuf::from_absolute_path("/usr/bin/touch")
|
||||
.context("build touch absolute path")?,
|
||||
&command,
|
||||
&workdir,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.context("timed out waiting for execve permission hook decision")??;
|
||||
assert!(matches!(
|
||||
action,
|
||||
codex_shell_escalation::EscalationDecision::Escalate(
|
||||
codex_shell_escalation::EscalationExecution::Unsandboxed
|
||||
)
|
||||
));
|
||||
|
||||
let hook_inputs: Vec<Value> = std::fs::read_to_string(&log_path)
|
||||
.with_context(|| format!("read hook log at {}", log_path.display()))?
|
||||
.lines()
|
||||
.map(serde_json::from_str)
|
||||
.collect::<serde_json::Result<_>>()
|
||||
.context("parse hook log")?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["tool_input"]["command"],
|
||||
expected_hook_command
|
||||
);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["tool_input"]["description"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_parsing_disabled() {
|
||||
let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"#;
|
||||
|
||||
@@ -21,6 +21,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;
|
||||
@@ -50,6 +51,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>,
|
||||
@@ -179,6 +181,17 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
Some(req.exec_approval_requirement.clone())
|
||||
}
|
||||
|
||||
fn permission_request_payload(
|
||||
&self,
|
||||
req: &UnifiedExecRequest,
|
||||
) -> Option<PermissionRequestPayload> {
|
||||
Some(PermissionRequestPayload {
|
||||
tool_name: "Bash".to_string(),
|
||||
command: req.hook_command.clone(),
|
||||
description: req.justification.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride {
|
||||
sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement)
|
||||
}
|
||||
@@ -194,6 +207,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
Some(NetworkApprovalSpec {
|
||||
network: req.network.clone(),
|
||||
mode: NetworkApprovalMode::Deferred,
|
||||
command: req.hook_command.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user