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 07:45:47 -07:00
committed by GitHub
parent d0047de7cb
commit 8494e5bd7b
43 changed files with 1821 additions and 36 deletions

View File

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

View File

@@ -317,6 +317,7 @@ impl ToolHandler for UnifiedExecHandler {
.exec_command(
ExecCommandRequest {
command,
hook_command: args.cmd,
process_id,
yield_time_ms,
max_output_tokens,

View File

@@ -5,8 +5,11 @@ 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::network_policy_decision::denied_network_policy_message;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::ToolError;
use codex_hooks::PermissionRequestDecision;
use codex_network_proxy::BlockedRequest;
use codex_network_proxy::BlockedRequestObserver;
use codex_network_proxy::NetworkDecision;
@@ -43,6 +46,7 @@ pub(crate) enum NetworkApprovalMode {
pub(crate) struct NetworkApprovalSpec {
pub network: Option<NetworkProxy>,
pub mode: NetworkApprovalMode,
pub command: String,
}
#[derive(Clone, Debug)]
@@ -172,6 +176,7 @@ impl PendingHostApproval {
struct ActiveNetworkApprovalCall {
registration_id: String,
turn_id: String,
command: String,
}
pub(crate) struct NetworkApprovalService {
@@ -204,7 +209,7 @@ impl NetworkApprovalService {
other_approved_hosts.extend(approved_hosts.iter().cloned());
}
async fn register_call(&self, registration_id: String, turn_id: String) {
async fn register_call(&self, registration_id: String, turn_id: String, command: String) {
let mut active_calls = self.active_calls.lock().await;
let key = registration_id.clone();
active_calls.insert(
@@ -212,6 +217,7 @@ impl NetworkApprovalService {
Arc::new(ActiveNetworkApprovalCall {
registration_id,
turn_id,
command,
}),
);
}
@@ -371,6 +377,46 @@ impl NetworkApprovalService {
};
let owner_call = self.resolve_single_active_call().await;
let guardian_approval_id = Self::approval_id_for_key(&key);
let prompt_command = vec!["network-access".to_string(), target.clone()];
let command = owner_call
.as_ref()
.map_or_else(|| prompt_command.join(" "), |call| call.command.clone());
if let Some(permission_request_decision) = run_permission_request_hooks(
&session,
&turn_context,
&guardian_approval_id,
PermissionRequestPayload {
tool_name: "Bash".to_string(),
command,
description: Some(format!("network-access {target}")),
},
)
.await
{
match permission_request_decision {
PermissionRequestDecision::Allow => {
pending
.set_decision(PendingApprovalDecision::AllowOnce)
.await;
let mut pending_approvals = self.pending_host_approvals.lock().await;
pending_approvals.remove(&key);
return NetworkDecision::Allow;
}
PermissionRequestDecision::Deny { message } => {
if let Some(owner_call) = owner_call.as_ref() {
self.record_call_outcome(
&owner_call.registration_id,
NetworkApprovalOutcome::DeniedByPolicy(message),
)
.await;
}
pending.set_decision(PendingApprovalDecision::Deny).await;
let mut pending_approvals = self.pending_host_approvals.lock().await;
pending_approvals.remove(&key);
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
}
}
let use_guardian = routes_approval_to_guardian(&turn_context);
let guardian_review_id = use_guardian.then(new_guardian_review_id);
let approval_decision = if let Some(review_id) = guardian_review_id.clone() {
@@ -392,13 +438,11 @@ impl NetworkApprovalService {
)
.await
} else {
let approval_id = Self::approval_id_for_key(&key);
let prompt_command = vec!["network-access".to_string(), target.clone()];
let available_decisions = None;
session
.request_command_approval(
turn_context.as_ref(),
approval_id,
guardian_approval_id,
/*approval_id*/ None,
prompt_command,
turn_context.cwd.clone(),
@@ -590,7 +634,7 @@ pub(crate) async fn begin_network_approval(
session
.services
.network_approval
.register_call(registration_id.clone(), turn_id.to_string())
.register_call(registration_id.clone(), turn_id.to_string(), spec.command)
.await;
Some(ActiveNetworkApproval {

View File

@@ -211,7 +211,11 @@ fn denied_blocked_request(host: &str) -> BlockedRequest {
async fn record_blocked_request_sets_policy_outcome_for_owner_call() {
let service = NetworkApprovalService::default();
service
.register_call("registration-1".to_string(), "turn-1".to_string())
.register_call(
"registration-1".to_string(),
"turn-1".to_string(),
"curl http://example.com".to_string(),
)
.await;
service
@@ -230,7 +234,11 @@ async fn record_blocked_request_sets_policy_outcome_for_owner_call() {
async fn blocked_request_policy_does_not_override_user_denial_outcome() {
let service = NetworkApprovalService::default();
service
.register_call("registration-1".to_string(), "turn-1".to_string())
.register_call(
"registration-1".to_string(),
"turn-1".to_string(),
"curl http://example.com".to_string(),
)
.await;
service
@@ -250,10 +258,18 @@ async fn blocked_request_policy_does_not_override_user_denial_outcome() {
async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests() {
let service = NetworkApprovalService::default();
service
.register_call("registration-1".to_string(), "turn-1".to_string())
.register_call(
"registration-1".to_string(),
"turn-1".to_string(),
"curl http://example.com".to_string(),
)
.await;
service
.register_call("registration-2".to_string(), "turn-1".to_string())
.register_call(
"registration-2".to_string(),
"turn-1".to_string(),
"gh api /foo".to_string(),
)
.await;
service

View File

@@ -10,6 +10,7 @@ use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
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;
@@ -24,6 +25,7 @@ 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_otel::ToolDecisionSource;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
@@ -114,9 +116,6 @@ 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
@@ -127,7 +126,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 +146,16 @@ 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,
tool_ctx.call_id.as_str(),
approval_ctx,
tool_ctx,
use_guardian,
&otel,
)
.await?;
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
@@ -296,13 +302,17 @@ impl ToolOrchestrator {
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 permission_request_run_id = format!("{}:retry", tool_ctx.call_id);
let decision = Self::request_approval(
tool,
req,
&permission_request_run_id,
approval_ctx,
tool_ctx,
use_guardian,
&otel,
)
.await?;
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
@@ -365,6 +375,69 @@ impl ToolOrchestrator {
Err(err) => Err(err),
}
}
// PermissionRequest hooks take top precedence for answering approval
// prompts. If no matching hook returns a decision, fall back to the
// normal guardian or user approval path.
async fn request_approval<Rq, Out, T>(
tool: &mut T,
req: &Rq,
permission_request_run_id: &str,
approval_ctx: ApprovalCtx<'_>,
tool_ctx: &ToolCtx,
use_guardian: bool,
otel: &codex_otel::SessionTelemetry,
) -> Result<ReviewDecision, ToolError>
where
T: ToolRuntime<Rq, Out>,
{
if let Some(permission_request) = tool.permission_request_payload(req) {
match run_permission_request_hooks(
approval_ctx.session,
approval_ctx.turn,
permission_request_run_id,
permission_request,
)
.await
{
Some(PermissionRequestDecision::Allow) => {
let decision = ReviewDecision::Approved;
otel.tool_decision(
&tool_ctx.tool_name,
&tool_ctx.call_id,
&decision,
ToolDecisionSource::Config,
);
return Ok(decision);
}
Some(PermissionRequestDecision::Deny { message }) => {
let decision = ReviewDecision::Denied;
otel.tool_decision(
&tool_ctx.tool_name,
&tool_ctx.call_id,
&decision,
ToolDecisionSource::Config,
);
return Err(ToolError::Rejected(message));
}
None => {}
}
}
let decision = tool.start_approval_async(req, approval_ctx).await;
let otel_source = if use_guardian {
ToolDecisionSource::AutomatedReviewer
} else {
ToolDecisionSource::User
};
otel.tool_decision(
&tool_ctx.tool_name,
&tool_ctx.call_id,
&decision,
otel_source,
);
Ok(decision)
}
}
fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {

View File

@@ -23,6 +23,7 @@ use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
use crate::tools::sandboxing::Sandboxable;
@@ -44,6 +45,7 @@ use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct ShellRequest {
pub command: Vec<String>,
pub hook_command: String,
pub cwd: AbsolutePathBuf,
pub timeout_ms: Option<u64>,
pub env: HashMap<String, String>,
@@ -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(),
})
}

View File

@@ -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

View File

@@ -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")"#;

View File

@@ -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(),
})
}

View File

@@ -131,6 +131,13 @@ 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 description: Option<String>,
}
// Specifies what tool orchestrator should do with a given tool call.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ExecApprovalRequirement {
@@ -273,6 +280,12 @@ pub(crate) trait Approvable<Req> {
None
}
/// Return hook input for approval-time policy hooks when this runtime wants
/// hook evaluation to run before guardian or user approval.
fn permission_request_payload(&self, _req: &Req) -> Option<PermissionRequestPayload> {
None
}
/// Decide we can request an approval for no-sandbox execution.
fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
match policy {