Add Bash PermissionRequest hooks

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav Vedmala
2026-04-12 12:27:11 -07:00
co-authored by Codex
parent 4db60d5d8b
commit 528bdb488a
36 changed files with 1021 additions and 25 deletions
+33
View File
@@ -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;
@@ -145,6 +148,36 @@ 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>,
run_id_suffix: String,
tool_name: String,
command: String,
) -> Option<PermissionRequestDecision> {
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,
};
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(&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,
@@ -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(),
+87 -19
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,8 @@ 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::SessionTelemetry;
use codex_otel::ToolDecisionSource;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
@@ -114,9 +117,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 +127,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 +147,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,
approval_ctx,
turn_ctx,
&otel,
otel_tn,
otel_ci,
)
.await?;
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
@@ -301,13 +308,16 @@ 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 decision = Self::request_approval(
tool,
req,
approval_ctx,
turn_ctx,
&otel,
otel_tn,
otel_ci,
)
.await?;
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
@@ -370,6 +380,64 @@ impl ToolOrchestrator {
Err(err) => Err(err),
}
}
// PermissionRequest hooks get the first chance to answer approval prompts
// for tools that expose a hook payload. Today this is Bash-only; if no
// matching hook returns a decision, fall back to the normal user or guardian
// approval path.
async fn request_approval<Rq, Out, T>(
tool: &mut T,
req: &Rq,
approval_ctx: ApprovalCtx<'_>,
turn_ctx: &crate::codex::TurnContext,
otel: &SessionTelemetry,
otel_tn: &str,
otel_ci: &str,
) -> 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,
approval_ctx.call_id.to_string(),
permission_request.tool_name,
permission_request.command,
)
.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 => {}
}
}
let decision = tool.start_approval_async(req, approval_ctx).await;
let otel_source = if routes_approval_to_guardian(turn_ctx) {
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 {
@@ -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,10 @@ 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()))
}
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>,
@@ -177,6 +179,13 @@ 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()))
}
fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride {
sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement)
}
+19
View File
@@ -131,6 +131,21 @@ 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,
}
impl PermissionRequestPayload {
pub(crate) fn bash(command: String) -> Self {
Self {
tool_name: "Bash".to_string(),
command,
}
}
}
// Specifies what tool orchestrator should do with a given tool call.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ExecApprovalRequirement {
@@ -273,6 +288,10 @@ pub(crate) trait Approvable<Req> {
None
}
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 {
@@ -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,
+177
View File
@@ -7,6 +7,7 @@ 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;
@@ -237,6 +238,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 +471,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")?
@@ -1005,6 +1088,100 @@ 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!("printf allowed > {}", marker.display());
let args = serde_json::json!({ "command": command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
&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?;
if marker.exists() {
fs::remove_file(&marker).context("remove leftover permission request marker")?;
}
test.submit_turn_with_policies(
"run the shell command after hook approval",
AskForApproval::OnRequest,
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell command output string");
assert!(
output.contains("allowed"),
"shell command output should reach the model after hook approval",
);
assert!(
marker.exists(),
"approved command should create 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!(
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(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));