Add permission suggestions to PermissionRequest hooks

Expose top-level permission suggestions in PermissionRequest hook inputs and build them from source approval context so deferred unified-exec network retries carry the same suggestion data as immediate approval paths.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav Vedmala
2026-04-13 19:39:08 -07:00
co-authored by Codex
parent 7e4869308c
commit 91887885ee
13 changed files with 446 additions and 17 deletions
+1
View File
@@ -169,6 +169,7 @@ pub(crate) async fn run_permission_request_hooks(
run_id_suffix: run_id_suffix.to_string(),
command: payload.command,
description: payload.description,
permission_suggestions: payload.permission_suggestions,
};
let preview_runs = sess.hooks().preview_permission_request(&request);
emit_hook_started_events(sess, turn_context, preview_runs).await;
@@ -389,6 +389,7 @@ impl NetworkApprovalService {
tool_name: "Bash".to_string(),
command,
description: Some(format!("network-access {target}")),
permission_suggestions: Vec::new(),
},
)
.await
+1 -1
View File
@@ -368,7 +368,7 @@ impl ToolOrchestrator {
where
T: ToolRuntime<Rq, Out>,
{
if let Some(permission_request) = tool.permission_request_payload(req) {
if let Some(permission_request) = tool.permission_request_payload(req, &approval_ctx) {
match run_permission_request_hooks(
approval_ctx.session,
approval_ctx.turn,
+13 -1
View File
@@ -30,8 +30,10 @@ use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::exec_policy_permission_suggestions;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::with_cached_approval;
use codex_hooks::PermissionSuggestionDestination;
use codex_network_proxy::NetworkProxy;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::PermissionProfile;
@@ -199,11 +201,21 @@ impl Approvable<ShellRequest> for ShellRuntime {
Some(req.exec_approval_requirement.clone())
}
fn permission_request_payload(&self, req: &ShellRequest) -> Option<PermissionRequestPayload> {
fn permission_request_payload(
&self,
req: &ShellRequest,
_approval_ctx: &ApprovalCtx<'_>,
) -> Option<PermissionRequestPayload> {
let permission_suggestions = exec_policy_permission_suggestions(
req.exec_approval_requirement
.proposed_execpolicy_amendment(),
&[PermissionSuggestionDestination::UserSettings],
);
Some(PermissionRequestPayload {
tool_name: "Bash".to_string(),
command: req.hook_command.clone(),
description: req.justification.clone(),
permission_suggestions,
})
}
@@ -403,6 +403,7 @@ impl CoreShellActionProvider {
tool_name: "Bash".to_string(),
command: codex_shell_command::parse_command::shlex_join(&command),
description: None,
permission_suggestions: Vec::new(),
};
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
match run_permission_request_hooks(
@@ -27,12 +27,14 @@ use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::exec_policy_permission_suggestions;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::with_cached_approval;
use crate::unified_exec::NoopSpawnLifecycle;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_hooks::PermissionSuggestionDestination;
use codex_network_proxy::NetworkProxy;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
@@ -182,11 +184,18 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
fn permission_request_payload(
&self,
req: &UnifiedExecRequest,
_approval_ctx: &ApprovalCtx<'_>,
) -> Option<PermissionRequestPayload> {
let permission_suggestions = exec_policy_permission_suggestions(
req.exec_approval_requirement
.proposed_execpolicy_amendment(),
&[PermissionSuggestionDestination::UserSettings],
);
Some(PermissionRequestPayload {
tool_name: "Bash".to_string(),
command: req.hook_command.clone(),
description: req.justification.clone(),
permission_suggestions,
})
}
+33 -1
View File
@@ -10,6 +10,11 @@ use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::state::SessionServices;
use crate::tools::network_approval::NetworkApprovalSpec;
use codex_hooks::PermissionSuggestion;
use codex_hooks::PermissionSuggestionBehavior;
use codex_hooks::PermissionSuggestionDestination;
use codex_hooks::PermissionSuggestionRule;
use codex_hooks::PermissionSuggestionType;
use codex_network_proxy::NetworkProxy;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkApprovalContext;
@@ -136,6 +141,29 @@ pub(crate) struct PermissionRequestPayload {
pub tool_name: String,
pub command: String,
pub description: Option<String>,
pub permission_suggestions: Vec<PermissionSuggestion>,
}
pub(crate) fn exec_policy_permission_suggestions(
proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>,
destinations: &[PermissionSuggestionDestination],
) -> Vec<PermissionSuggestion> {
proposed_execpolicy_amendment
.into_iter()
.flat_map(|amendment| {
destinations
.iter()
.cloned()
.map(move |destination| PermissionSuggestion {
suggestion_type: PermissionSuggestionType::AddRules,
rules: vec![PermissionSuggestionRule::PrefixRule {
command: amendment.command.clone(),
}],
behavior: PermissionSuggestionBehavior::Allow,
destination,
})
})
.collect()
}
// Specifies what tool orchestrator should do with a given tool call.
@@ -282,7 +310,11 @@ pub(crate) trait Approvable<Req> {
/// 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> {
fn permission_request_payload(
&self,
_req: &Req,
_approval_ctx: &ApprovalCtx<'_>,
) -> Option<PermissionRequestPayload> {
None
}
@@ -1,5 +1,10 @@
use super::*;
use crate::sandboxing::SandboxPermissions;
use codex_hooks::PermissionSuggestion;
use codex_hooks::PermissionSuggestionDestination;
use codex_hooks::PermissionSuggestionRule;
use codex_hooks::PermissionSuggestionType;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::NetworkAccess;
use pretty_assertions::assert_eq;
@@ -108,3 +113,31 @@ fn guardian_bypasses_sandbox_for_explicit_escalation_on_first_attempt() {
SandboxOverride::BypassSandboxFirstAttempt
);
}
#[test]
fn command_approval_execpolicy_amendment_maps_to_user_settings_suggestion() {
let suggestions = exec_policy_permission_suggestions(
Some(&ExecPolicyAmendment::new(vec![
"rm".to_string(),
"-rf".to_string(),
"node_modules".to_string(),
])),
&[PermissionSuggestionDestination::UserSettings],
);
assert_eq!(
suggestions,
vec![PermissionSuggestion {
suggestion_type: PermissionSuggestionType::AddRules,
rules: vec![PermissionSuggestionRule::PrefixRule {
command: vec![
"rm".to_string(),
"-rf".to_string(),
"node_modules".to_string(),
],
}],
behavior: PermissionSuggestionBehavior::Allow,
destination: PermissionSuggestionDestination::UserSettings,
}]
);
}
+221 -14
View File
@@ -521,6 +521,7 @@ fn assert_permission_request_hook_input(
assert!(hook_input.get("justification").is_none());
assert!(hook_input.get("host").is_none());
assert!(hook_input.get("protocol").is_none());
assert!(hook_input.get("permission_suggestions").is_none());
}
fn assert_single_permission_request_hook_input(
@@ -534,6 +535,13 @@ fn assert_single_permission_request_hook_input(
Ok(hook_inputs)
}
fn assert_permission_request_hook_suggestions(hook_input: &Value, expected: Value) {
assert_eq!(
hook_input["permission_suggestions"], expected,
"permission request hook should include expected suggestions"
);
}
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")?
@@ -1203,11 +1211,26 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() ->
"approved command should remove marker file"
);
let hook_inputs = assert_single_permission_request_hook_input(
test.codex_home_path(),
&command,
/*description*/ None,
)?;
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_eq!(hook_inputs[0]["tool_input"]["description"], Value::Null);
assert_permission_request_hook_suggestions(
&hook_inputs[0],
serde_json::json!([
{
"type": "addRules",
"rules": [{
"type": "prefixRule",
"command": ["rm", "-f", marker.display().to_string()],
}],
"behavior": "allow",
"destination": "userSettings",
}
]),
);
assert!(
hook_inputs[0].get("tool_use_id").is_none(),
"PermissionRequest input should not include a tool_use_id",
@@ -1293,11 +1316,26 @@ async fn permission_request_hook_sees_raw_exec_command_input() -> Result<()> {
"approved exec command should remove marker file"
);
assert_single_permission_request_hook_input(
test.codex_home_path(),
&command,
Some(justification),
)?;
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_eq!(hook_inputs[0]["tool_input"]["description"], justification);
assert_permission_request_hook_suggestions(
&hook_inputs[0],
serde_json::json!([
{
"type": "addRules",
"rules": [{
"type": "prefixRule",
"command": ["rm", "-f", marker.display().to_string()],
}],
"behavior": "allow",
"destination": "userSettings",
}
]),
);
Ok(())
}
@@ -1442,11 +1480,180 @@ allow_local_binding = true
"expected the network approval hook to bypass the approval prompt"
);
assert_single_permission_request_hook_input(
test.codex_home_path(),
command,
Some("network-access http://codex-network-test.invalid:80"),
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_eq!(
hook_inputs[0]["tool_input"]["description"],
"network-access http://codex-network-test.invalid:80"
);
assert!(hook_inputs[0].get("permission_suggestions").is_none());
test.codex.submit(Op::Shutdown {}).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ShutdownComplete)
})
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permission_request_hook_sees_exec_command_network_retry_suggestions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
r#"default_permissions = "workspace"
[permissions.workspace.filesystem]
":minimal" = "read"
[permissions.workspace.network]
enabled = true
mode = "limited"
allow_local_binding = true
"#,
)?;
let call_id = "permissionrequest-exec-network-approval";
let command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=2).read().decode(errors='replace'))""#;
let args = serde_json::json!({
"cmd": command,
"login": true,
"sandbox_permissions": "require_escalated",
});
let _responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"exec_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 exec network access",
),
ev_completed("resp-2"),
]),
],
)
.await;
let approval_policy = AskForApproval::OnFailure;
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let sandbox_policy_for_config = sandbox_policy.clone();
let test = test_codex()
.with_home(Arc::clone(&home))
.with_pre_build_hook(|home| {
if let Err(error) = install_allow_permission_request_hook(home) {
panic!("failed to write permission request hook test fixture: {error}");
}
})
.with_config(move |config| {
config.use_experimental_unified_exec_tool = true;
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
let layers = config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.cloned()
.collect();
let mut requirements = config.config_layer_stack.requirements().clone();
requirements.network = Some(Sourced::new(
NetworkConstraints {
enabled: Some(true),
allow_local_binding: Some(true),
..Default::default()
},
RequirementSource::CloudRequirements,
));
let mut requirements_toml = config.config_layer_stack.requirements_toml().clone();
requirements_toml.network = Some(NetworkRequirementsToml {
enabled: Some(true),
allow_local_binding: Some(true),
..Default::default()
});
config.config_layer_stack =
ConfigLayerStack::new(layers, requirements, requirements_toml)
.expect("rebuild config layer stack with network requirements");
})
.build(&server)
.await?;
test.submit_turn_with_policies(
"run the exec command after network hook approval",
approval_policy,
sandbox_policy,
)
.await?;
timeout(Duration::from_secs(10), async {
loop {
if test
.codex_home_path()
.join("permission_request_hook_log.jsonl")
.exists()
{
break;
}
sleep(Duration::from_millis(100)).await;
}
})
.await
.expect("expected exec network approval hook to run");
assert!(
timeout(
Duration::from_secs(2),
wait_for_event(&test.codex, |event| matches!(
event,
EventMsg::ExecApprovalRequest(_)
))
)
.await
.is_err(),
"expected the exec network approval hook to bypass the approval prompt"
);
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_eq!(
hook_inputs[0]["tool_input"]["description"],
"Network access to \"codex-network-test.invalid\" is blocked by policy."
);
assert!(hook_inputs[0].get("permission_suggestions").is_none());
test.codex.submit(Op::Shutdown {}).await?;
wait_for_event(&test.codex, |event| {
@@ -22,6 +22,79 @@
"command"
],
"type": "object"
},
"PermissionSuggestion": {
"properties": {
"behavior": {
"$ref": "#/definitions/PermissionSuggestionBehavior"
},
"destination": {
"$ref": "#/definitions/PermissionSuggestionDestination"
},
"rules": {
"items": {
"$ref": "#/definitions/PermissionSuggestionRule"
},
"type": "array"
},
"type": {
"$ref": "#/definitions/PermissionSuggestionType"
}
},
"required": [
"behavior",
"destination",
"rules",
"type"
],
"type": "object"
},
"PermissionSuggestionBehavior": {
"enum": [
"allow",
"deny",
"ask"
],
"type": "string"
},
"PermissionSuggestionDestination": {
"enum": [
"session",
"projectSettings",
"userSettings"
],
"type": "string"
},
"PermissionSuggestionRule": {
"oneOf": [
{
"properties": {
"command": {
"items": {
"type": "string"
},
"type": "array"
},
"type": {
"enum": [
"prefixRule"
],
"type": "string"
}
},
"required": [
"command",
"type"
],
"type": "object"
}
]
},
"PermissionSuggestionType": {
"enum": [
"addRules"
],
"type": "string"
}
},
"properties": {
@@ -45,6 +118,12 @@
],
"type": "string"
},
"permission_suggestions": {
"items": {
"$ref": "#/definitions/PermissionSuggestion"
},
"type": "array"
},
"session_id": {
"type": "string"
},
@@ -30,6 +30,48 @@ use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use schemars::JsonSchema;
use serde::Serialize;
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PermissionSuggestion {
#[serde(rename = "type")]
pub suggestion_type: PermissionSuggestionType,
pub rules: Vec<PermissionSuggestionRule>,
pub behavior: PermissionSuggestionBehavior,
pub destination: PermissionSuggestionDestination,
}
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum PermissionSuggestionType {
AddRules,
}
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PermissionSuggestionBehavior {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
pub enum PermissionSuggestionDestination {
#[serde(rename = "session")]
Session,
#[serde(rename = "projectSettings")]
ProjectSettings,
#[serde(rename = "userSettings")]
UserSettings,
}
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PermissionSuggestionRule {
PrefixRule { command: Vec<String> },
}
#[derive(Debug, Clone)]
pub struct PermissionRequestRequest {
@@ -43,6 +85,7 @@ pub struct PermissionRequestRequest {
pub run_id_suffix: String,
pub command: String,
pub description: Option<String>,
pub permission_suggestions: Vec<PermissionSuggestion>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -179,6 +222,8 @@ fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestC
command: request.command.clone(),
description: request.description.clone(),
},
permission_suggestions: (!request.permission_suggestions.is_empty())
.then_some(request.permission_suggestions.clone()),
}
}
+5
View File
@@ -8,6 +8,11 @@ mod types;
pub use events::permission_request::PermissionRequestDecision;
pub use events::permission_request::PermissionRequestOutcome;
pub use events::permission_request::PermissionRequestRequest;
pub use events::permission_request::PermissionSuggestion;
pub use events::permission_request::PermissionSuggestionBehavior;
pub use events::permission_request::PermissionSuggestionDestination;
pub use events::permission_request::PermissionSuggestionRule;
pub use events::permission_request::PermissionSuggestionType;
pub use events::post_tool_use::PostToolUseOutcome;
pub use events::post_tool_use::PostToolUseRequest;
pub use events::pre_tool_use::PreToolUseOutcome;
+4
View File
@@ -12,6 +12,8 @@ use serde_json::Value;
use std::path::Path;
use std::path::PathBuf;
use crate::events::permission_request::PermissionSuggestion;
const GENERATED_DIR: &str = "generated";
const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json";
const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json";
@@ -263,6 +265,8 @@ pub(crate) struct PermissionRequestCommandInput {
#[schemars(schema_with = "permission_request_tool_name_schema")]
pub tool_name: String,
pub tool_input: PermissionRequestToolInput,
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_suggestions: Option<Vec<PermissionSuggestion>>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]