mirror of
https://github.com/openai/codex.git
synced 2026-04-17 11:14:48 +00:00
Compare commits
26 Commits
dev/remote
...
codex/perm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba705f448 | ||
|
|
f7d4874a0e | ||
|
|
2219e3df2e | ||
|
|
6fc19ca863 | ||
|
|
88c42ae7cc | ||
|
|
f60b2cc5b1 | ||
|
|
a97234a252 | ||
|
|
db45ef9186 | ||
|
|
c6687ba493 | ||
|
|
91887885ee | ||
|
|
7e4869308c | ||
|
|
144fcbe295 | ||
|
|
32e26c49bc | ||
|
|
75cc778393 | ||
|
|
04294e0038 | ||
|
|
2563661366 | ||
|
|
20e0ffabef | ||
|
|
8e7a23c48c | ||
|
|
37e9f255ed | ||
|
|
920307ea40 | ||
|
|
1bf5222fbb | ||
|
|
86282db6c1 | ||
|
|
5096cc3adb | ||
|
|
f6517fa6a2 | ||
|
|
99458d2929 | ||
|
|
528bdb488a |
@@ -1404,6 +1404,7 @@
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
|
||||
@@ -8395,6 +8395,7 @@
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
|
||||
@@ -5147,6 +5147,7 @@
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
"sessionStart",
|
||||
"userPromptSubmit",
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HookEventName = "preToolUse" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop";
|
||||
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop";
|
||||
|
||||
@@ -380,7 +380,7 @@ v2_enum_from_core!(
|
||||
|
||||
v2_enum_from_core!(
|
||||
pub enum HookEventName from CoreHookEventName {
|
||||
PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop
|
||||
PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -3490,6 +3490,11 @@ impl Session {
|
||||
state.granted_permissions()
|
||||
}
|
||||
|
||||
pub(crate) async fn record_granted_session_permissions(&self, permissions: PermissionProfile) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.record_granted_permissions(permissions);
|
||||
}
|
||||
|
||||
pub async fn notify_dynamic_tool_response(&self, call_id: &str, response: DynamicToolResponse) {
|
||||
let entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
|
||||
@@ -51,6 +51,8 @@ pub enum ConfigEdit {
|
||||
SetSkillConfig { path: PathBuf, enabled: bool },
|
||||
/// Set or clear a skill config entry under `[[skills.config]]` by name.
|
||||
SetSkillConfigByName { name: String, enabled: bool },
|
||||
/// Append normalized writable roots under `[sandbox_workspace_write]`.
|
||||
AppendSandboxWorkspaceWriteRoots { roots: Vec<PathBuf> },
|
||||
/// Set trust_level under `[projects."<path>"]`,
|
||||
/// migrating inline tables to explicit tables.
|
||||
SetProjectTrustLevel { path: PathBuf, level: TrustLevel },
|
||||
@@ -425,6 +427,9 @@ impl ConfigDocument {
|
||||
ConfigEdit::SetSkillConfigByName { name, enabled } => {
|
||||
Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled))
|
||||
}
|
||||
ConfigEdit::AppendSandboxWorkspaceWriteRoots { roots } => {
|
||||
Ok(self.append_sandbox_workspace_write_roots(roots))
|
||||
}
|
||||
ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())),
|
||||
ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),
|
||||
ConfigEdit::SetProjectTrustLevel { path, level } => {
|
||||
@@ -625,6 +630,58 @@ impl ConfigDocument {
|
||||
mutated
|
||||
}
|
||||
|
||||
fn append_sandbox_workspace_write_roots(&mut self, roots: &[PathBuf]) -> bool {
|
||||
if roots.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(table) = self.descend(
|
||||
&["sandbox_workspace_write".to_string()],
|
||||
TraversalMode::Create,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut created_writable_roots = false;
|
||||
let writable_roots = table.entry("writable_roots").or_insert_with(|| {
|
||||
created_writable_roots = true;
|
||||
let array = roots
|
||||
.iter()
|
||||
.map(|root| root.to_string_lossy().to_string())
|
||||
.collect::<toml_edit::Array>();
|
||||
TomlItem::Value(array.into())
|
||||
});
|
||||
|
||||
let Some(array) = writable_roots
|
||||
.as_value_mut()
|
||||
.and_then(toml_edit::Value::as_array_mut)
|
||||
else {
|
||||
let replacement = roots
|
||||
.iter()
|
||||
.map(|root| root.to_string_lossy().to_string())
|
||||
.collect::<toml_edit::Array>();
|
||||
let mut replacement = TomlItem::Value(replacement.into());
|
||||
Self::preserve_decor(writable_roots, &mut replacement);
|
||||
*writable_roots = replacement;
|
||||
return true;
|
||||
};
|
||||
|
||||
let mut mutated = created_writable_roots;
|
||||
for root in roots {
|
||||
let root = root.to_string_lossy().to_string();
|
||||
if !array
|
||||
.iter()
|
||||
.filter_map(toml_edit::Value::as_str)
|
||||
.any(|entry| entry == root)
|
||||
{
|
||||
array.push(root);
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
mutated
|
||||
}
|
||||
|
||||
fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec<String> {
|
||||
let resolved: Vec<String> = segments
|
||||
.iter()
|
||||
|
||||
@@ -340,6 +340,55 @@ model_reasoning_effort = "high"
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_sandbox_workspace_write_roots_preserves_existing_keys() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
let original = r#"[sandbox_workspace_write]
|
||||
network_access = false
|
||||
writable_roots = ["/tmp/existing"]
|
||||
"#;
|
||||
std::fs::write(codex_home.join(CONFIG_TOML_FILE), original).expect("seed config");
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::AppendSandboxWorkspaceWriteRoots {
|
||||
roots: vec![
|
||||
PathBuf::from("/tmp/existing"),
|
||||
PathBuf::from("/tmp/new-root"),
|
||||
],
|
||||
}],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[sandbox_workspace_write]
|
||||
network_access = false
|
||||
writable_roots = ["/tmp/existing", "/tmp/new-root"]"#;
|
||||
assert_eq!(contents.trim_end(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_sandbox_workspace_write_roots_creates_config_file() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::AppendSandboxWorkspaceWriteRoots {
|
||||
roots: vec![PathBuf::from("/tmp/new-root")],
|
||||
}],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[sandbox_workspace_write]
|
||||
writable_roots = ["/tmp/new-root"]"#;
|
||||
assert_eq!(contents.trim_end(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_model_scopes_to_active_profile() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
|
||||
@@ -25,6 +25,7 @@ use std::path::Path;
|
||||
#[cfg(windows)]
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::warn;
|
||||
|
||||
pub use codex_config::AppRequirementToml;
|
||||
pub use codex_config::AppsRequirementsToml;
|
||||
@@ -760,6 +761,31 @@ async fn find_project_root(
|
||||
Ok(cwd.clone())
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_project_root(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> io::Result<AbsolutePathBuf> {
|
||||
let mut merged = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
|
||||
continue;
|
||||
}
|
||||
merge_toml_values(&mut merged, &layer.config);
|
||||
}
|
||||
let project_root_markers = match project_root_markers_from_config(&merged) {
|
||||
Ok(Some(markers)) => markers,
|
||||
Ok(None) => default_project_root_markers(),
|
||||
Err(err) => {
|
||||
warn!("invalid project_root_markers: {err}");
|
||||
default_project_root_markers()
|
||||
}
|
||||
};
|
||||
find_project_root(cwd, &project_root_markers).await
|
||||
}
|
||||
|
||||
/// Return the appropriate list of layers (each with
|
||||
/// [ConfigLayerSource::Project] as the source) between `cwd` and
|
||||
/// `project_root`, inclusive. The list is ordered in _increasing_ precdence,
|
||||
|
||||
@@ -328,6 +328,21 @@ impl ExecPolicyManager {
|
||||
source,
|
||||
})?;
|
||||
|
||||
self.update_current_policy_if_needed(amendment)
|
||||
}
|
||||
|
||||
pub(crate) async fn add_amendment_to_current_policy(
|
||||
&self,
|
||||
amendment: &ExecPolicyAmendment,
|
||||
) -> Result<(), ExecPolicyUpdateError> {
|
||||
let _update_guard = self.update_lock.lock().await;
|
||||
self.update_current_policy_if_needed(amendment)
|
||||
}
|
||||
|
||||
fn update_current_policy_if_needed(
|
||||
&self,
|
||||
amendment: &ExecPolicyAmendment,
|
||||
) -> Result<(), ExecPolicyUpdateError> {
|
||||
let current_policy = self.current();
|
||||
let match_options = MatchOptions {
|
||||
resolve_host_executables: true,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_hooks::PermissionRequestDecision;
|
||||
use codex_hooks::PermissionRequestOutcome;
|
||||
use codex_hooks::PermissionRequestRequest;
|
||||
use codex_hooks::PermissionUpdate;
|
||||
use codex_hooks::PermissionUpdateDestination;
|
||||
use codex_hooks::PermissionUpdateRule;
|
||||
use codex_hooks::PostToolUseOutcome;
|
||||
use codex_hooks::PostToolUseRequest;
|
||||
use codex_hooks::PreToolUseOutcome;
|
||||
@@ -8,21 +15,33 @@ use codex_hooks::PreToolUseRequest;
|
||||
use codex_hooks::SessionStartOutcome;
|
||||
use codex_hooks::UserPromptSubmitOutcome;
|
||||
use codex_hooks::UserPromptSubmitRequest;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::HookStartedEvent;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config_loader::resolve_project_root;
|
||||
use crate::event_mapping::parse_turn_item;
|
||||
use crate::tools::sandboxing::PermissionRequestPayload;
|
||||
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
pub(crate) struct HookRuntimeOutcome {
|
||||
pub should_stop: bool,
|
||||
@@ -145,6 +164,245 @@ pub(crate) async fn run_pre_tool_use_hooks(
|
||||
if should_block { block_reason } else { None }
|
||||
}
|
||||
|
||||
// PermissionRequest hooks share the same preview/start/completed event flow as
|
||||
// other hook types, but they return an optional approval decision plus any
|
||||
// selected permission updates, and allow decisions may rewrite the hook-visible
|
||||
// command input before execution.
|
||||
pub(crate) async fn run_permission_request_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
run_id_suffix: &str,
|
||||
payload: PermissionRequestPayload,
|
||||
) -> 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: payload.tool_name,
|
||||
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;
|
||||
|
||||
let PermissionRequestOutcome {
|
||||
hook_events,
|
||||
decision,
|
||||
} = sess.hooks().run_permission_request(request).await;
|
||||
emit_hook_completed_events(sess, turn_context, hook_events).await;
|
||||
|
||||
if let Some(PermissionRequestDecision::Allow {
|
||||
updated_permissions,
|
||||
updated_input: _,
|
||||
}) = &decision
|
||||
{
|
||||
apply_permission_updates_from_hook(sess, turn_context, updated_permissions).await;
|
||||
}
|
||||
|
||||
decision
|
||||
}
|
||||
|
||||
async fn apply_permission_updates_from_hook(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
updated_permissions: &[PermissionUpdate],
|
||||
) {
|
||||
for permission in updated_permissions {
|
||||
if let Err(err) = apply_permission_update_from_hook(sess, turn_context, permission).await {
|
||||
let update_kind = match permission {
|
||||
PermissionUpdate::AddRules { .. } => "updated permission",
|
||||
PermissionUpdate::AddDirectories { .. } => "updated writable roots",
|
||||
};
|
||||
let message = format!("PermissionRequest hook failed to apply {update_kind}: {err}");
|
||||
warn!("{message}");
|
||||
sess.send_event_raw(Event {
|
||||
id: turn_context.sub_id.clone(),
|
||||
msg: EventMsg::Warning(WarningEvent { message }),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_permission_update_from_hook(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
permission: &PermissionUpdate,
|
||||
) -> Result<(), String> {
|
||||
match permission {
|
||||
PermissionUpdate::AddRules {
|
||||
rules, destination, ..
|
||||
} => {
|
||||
for rule in rules {
|
||||
match rule {
|
||||
PermissionUpdateRule::PrefixRule { command } => {
|
||||
let amendment = ExecPolicyAmendment::new(command.clone());
|
||||
apply_execpolicy_amendment_destination(
|
||||
sess,
|
||||
turn_context,
|
||||
destination,
|
||||
&amendment,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
PermissionUpdate::AddDirectories { .. } => {
|
||||
apply_directory_update_from_hook(sess, turn_context, permission).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_execpolicy_amendment_destination(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
destination: &PermissionUpdateDestination,
|
||||
amendment: &ExecPolicyAmendment,
|
||||
) -> Result<(), String> {
|
||||
match destination {
|
||||
PermissionUpdateDestination::Session => sess
|
||||
.services
|
||||
.exec_policy
|
||||
.add_amendment_to_current_policy(amendment)
|
||||
.await
|
||||
.map_err(|err| format!("failed to cache session prefix rule: {err}")),
|
||||
PermissionUpdateDestination::UserSettings => {
|
||||
let codex_home = sess.codex_home().await;
|
||||
sess.services
|
||||
.exec_policy
|
||||
.append_amendment_and_update(&codex_home, amendment)
|
||||
.await
|
||||
.map_err(|err| format!("failed to persist user prefix rule: {err}"))
|
||||
}
|
||||
PermissionUpdateDestination::ProjectSettings => {
|
||||
let config = turn_context.config.as_ref();
|
||||
let project_codex_home = resolve_project_root(&config.config_layer_stack, &config.cwd)
|
||||
.await
|
||||
.map_err(|err| format!("failed to resolve project root for rules file: {err}"))?
|
||||
.join(".codex")
|
||||
.to_path_buf();
|
||||
tokio::fs::create_dir_all(project_codex_home.join("rules"))
|
||||
.await
|
||||
.map_err(|err| format!("failed to create project rules directory: {err}"))?;
|
||||
sess.services
|
||||
.exec_policy
|
||||
.append_amendment_and_update(&project_codex_home, amendment)
|
||||
.await
|
||||
.map_err(|err| format!("failed to persist project prefix rule: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_directory_update_from_hook(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
permission: &PermissionUpdate,
|
||||
) -> Result<(), String> {
|
||||
let writable_roots = resolve_directory_update_paths(&turn_context.cwd, permission)?;
|
||||
|
||||
match permission {
|
||||
PermissionUpdate::AddDirectories {
|
||||
destination: PermissionUpdateDestination::Session,
|
||||
..
|
||||
} => {}
|
||||
PermissionUpdate::AddDirectories {
|
||||
destination: PermissionUpdateDestination::UserSettings,
|
||||
..
|
||||
} => {
|
||||
let codex_home = sess.codex_home().await;
|
||||
append_writable_roots_to_config(codex_home.as_path(), &writable_roots).await?;
|
||||
}
|
||||
PermissionUpdate::AddDirectories {
|
||||
destination: PermissionUpdateDestination::ProjectSettings,
|
||||
..
|
||||
} => {
|
||||
let config = turn_context.config.as_ref();
|
||||
let project_codex_home = resolve_project_root(&config.config_layer_stack, &config.cwd)
|
||||
.await
|
||||
.map_err(|err| format!("failed to resolve project root for writable roots: {err}"))?
|
||||
.join(".codex")
|
||||
.to_path_buf();
|
||||
tokio::fs::create_dir_all(&project_codex_home)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create project config directory: {err}"))?;
|
||||
append_writable_roots_to_config(&project_codex_home, &writable_roots).await?;
|
||||
}
|
||||
PermissionUpdate::AddRules { .. } => {
|
||||
return Err("writable root update must use type:addDirectories".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
sess.record_granted_session_permissions(permission_profile_for_writable_roots(&writable_roots))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_directory_update_paths(
|
||||
cwd: &AbsolutePathBuf,
|
||||
permission: &PermissionUpdate,
|
||||
) -> Result<Vec<AbsolutePathBuf>, String> {
|
||||
let PermissionUpdate::AddDirectories { directories, .. } = permission else {
|
||||
return Err("writable root update must use type:addDirectories".to_string());
|
||||
};
|
||||
let writable_roots = directories
|
||||
.iter()
|
||||
.map(|directory| {
|
||||
let trimmed = directory.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("writable root update contained an empty directory".to_string());
|
||||
}
|
||||
|
||||
let resolved = if Path::new(trimmed).is_absolute() {
|
||||
Path::new(trimmed).to_path_buf()
|
||||
} else {
|
||||
cwd.join(trimmed).to_path_buf()
|
||||
};
|
||||
|
||||
AbsolutePathBuf::from_absolute_path(resolved.as_path())
|
||||
.map_err(|err| format!("invalid writable root `{trimmed}`: {err}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
normalize_additional_permissions(permission_profile_for_writable_roots(&writable_roots))
|
||||
.map_err(|err| format!("failed to normalize writable roots: {err}"))?
|
||||
.file_system
|
||||
.and_then(|file_system| file_system.write)
|
||||
.filter(|write_paths| !write_paths.is_empty())
|
||||
.ok_or_else(|| "writable root update did not include any directories".to_string())
|
||||
}
|
||||
|
||||
fn permission_profile_for_writable_roots(writable_roots: &[AbsolutePathBuf]) -> PermissionProfile {
|
||||
PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(writable_roots.to_vec()),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn append_writable_roots_to_config(
|
||||
codex_home: &Path,
|
||||
writable_roots: &[AbsolutePathBuf],
|
||||
) -> Result<(), String> {
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.with_edits([ConfigEdit::AppendSandboxWorkspaceWriteRoots {
|
||||
roots: writable_roots
|
||||
.iter()
|
||||
.map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf)
|
||||
.collect(),
|
||||
}])
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|err| format!("failed to persist writable roots: {err}"))
|
||||
}
|
||||
pub(crate) async fn run_post_tool_use_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
|
||||
@@ -16,18 +16,13 @@
|
||||
//! 3. We do **not** walk past the project root.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::default_project_root_markers;
|
||||
use crate::config_loader::merge_toml_values;
|
||||
use crate::config_loader::project_root_markers_from_config;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use crate::config_loader::resolve_project_root;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use std::io;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::error;
|
||||
|
||||
pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str =
|
||||
@@ -226,51 +221,14 @@ pub async fn discover_project_doc_paths(
|
||||
dir = AbsolutePathBuf::try_from(canon)?;
|
||||
}
|
||||
|
||||
let mut merged = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in config.config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
|
||||
continue;
|
||||
}
|
||||
merge_toml_values(&mut merged, &layer.config);
|
||||
}
|
||||
let project_root_markers = match project_root_markers_from_config(&merged) {
|
||||
Ok(Some(markers)) => markers,
|
||||
Ok(None) => default_project_root_markers(),
|
||||
Err(err) => {
|
||||
tracing::warn!("invalid project_root_markers: {err}");
|
||||
default_project_root_markers()
|
||||
}
|
||||
};
|
||||
let mut project_root = None;
|
||||
if !project_root_markers.is_empty() {
|
||||
for ancestor in dir.ancestors() {
|
||||
for marker in &project_root_markers {
|
||||
let marker_path = AbsolutePathBuf::try_from(ancestor.join(marker))?;
|
||||
let marker_exists = match fs.get_metadata(&marker_path).await {
|
||||
Ok(_) => true,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if marker_exists {
|
||||
project_root = Some(AbsolutePathBuf::try_from(ancestor.to_path_buf())?);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if project_root.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let project_root = resolve_project_root(&config.config_layer_stack, &dir).await?;
|
||||
|
||||
let search_dirs: Vec<AbsolutePathBuf> = if let Some(root) = project_root {
|
||||
let search_dirs: Vec<AbsolutePathBuf> = if project_root != dir {
|
||||
let mut dirs = Vec::new();
|
||||
let mut cursor = dir.clone();
|
||||
loop {
|
||||
dirs.push(cursor.clone());
|
||||
if cursor == root {
|
||||
if cursor == project_root {
|
||||
break;
|
||||
}
|
||||
let Some(parent) = cursor.parent() else {
|
||||
|
||||
@@ -31,12 +31,14 @@ use crate::tools::registry::PreToolUsePayload;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::runtimes::shell::ShellRequest;
|
||||
use crate::tools::runtimes::shell::ShellRequestCommandInputKind;
|
||||
use crate::tools::runtimes::shell::ShellRuntime;
|
||||
use crate::tools::runtimes::shell::ShellRuntimeBackend;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
|
||||
use codex_shell_command::is_safe_command::is_known_safe_command;
|
||||
use codex_tools::ShellCommandBackendConfig;
|
||||
|
||||
@@ -77,6 +79,8 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option<String> {
|
||||
struct RunExecLikeArgs {
|
||||
tool_name: String,
|
||||
exec_params: ExecParams,
|
||||
hook_command: String,
|
||||
command_input_kind: ShellRequestCommandInputKind,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
prefix_rule: Option<Vec<String>>,
|
||||
session: Arc<crate::codex::Session>,
|
||||
@@ -241,6 +245,8 @@ 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(¶ms.command),
|
||||
command_input_kind: ShellRequestCommandInputKind::Argv,
|
||||
additional_permissions: params.additional_permissions.clone(),
|
||||
prefix_rule,
|
||||
session,
|
||||
@@ -258,6 +264,8 @@ 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(¶ms.command),
|
||||
command_input_kind: ShellRequestCommandInputKind::Argv,
|
||||
additional_permissions: None,
|
||||
prefix_rule: None,
|
||||
session,
|
||||
@@ -347,6 +355,8 @@ impl ToolHandler for ShellCommandHandler {
|
||||
|
||||
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
|
||||
let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
|
||||
let use_login_shell =
|
||||
Self::resolve_use_login_shell(params.login, turn.tools_config.allow_login_shell)?;
|
||||
let workdir = turn.resolve_path(params.workdir.clone());
|
||||
maybe_emit_implicit_skill_invocation(
|
||||
session.as_ref(),
|
||||
@@ -366,6 +376,8 @@ impl ToolHandler for ShellCommandHandler {
|
||||
ShellHandler::run_exec_like(RunExecLikeArgs {
|
||||
tool_name: tool_name.display(),
|
||||
exec_params,
|
||||
hook_command: params.command,
|
||||
command_input_kind: ShellRequestCommandInputKind::ShellString { use_login_shell },
|
||||
additional_permissions: params.additional_permissions.clone(),
|
||||
prefix_rule,
|
||||
session,
|
||||
@@ -384,6 +396,8 @@ impl ShellHandler {
|
||||
let RunExecLikeArgs {
|
||||
tool_name,
|
||||
exec_params,
|
||||
hook_command,
|
||||
command_input_kind,
|
||||
additional_permissions,
|
||||
prefix_rule,
|
||||
session,
|
||||
@@ -496,6 +510,15 @@ impl ShellHandler {
|
||||
);
|
||||
emitter.begin(event_ctx).await;
|
||||
|
||||
let exec_approval_file_system_sandbox_policy =
|
||||
if effective_additional_permissions.permissions_preapproved {
|
||||
effective_file_system_sandbox_policy(
|
||||
&turn.file_system_sandbox_policy,
|
||||
normalized_additional_permissions.as_ref(),
|
||||
)
|
||||
} else {
|
||||
turn.file_system_sandbox_policy.clone()
|
||||
};
|
||||
let exec_approval_requirement = session
|
||||
.services
|
||||
.exec_policy
|
||||
@@ -503,7 +526,7 @@ impl ShellHandler {
|
||||
command: &exec_params.command,
|
||||
approval_policy: turn.approval_policy.value(),
|
||||
sandbox_policy: turn.sandbox_policy.get(),
|
||||
file_system_sandbox_policy: &turn.file_system_sandbox_policy,
|
||||
file_system_sandbox_policy: &exec_approval_file_system_sandbox_policy,
|
||||
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
|
||||
codex_protocol::models::SandboxPermissions::UseDefault
|
||||
} else {
|
||||
@@ -515,6 +538,8 @@ impl ShellHandler {
|
||||
|
||||
let req = ShellRequest {
|
||||
command: exec_params.command.clone(),
|
||||
command_input_kind,
|
||||
hook_command,
|
||||
cwd: exec_params.cwd.clone(),
|
||||
timeout_ms: exec_params.expiration.timeout_ms(),
|
||||
env: exec_params.env.clone(),
|
||||
|
||||
@@ -16,11 +16,19 @@ use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::runtimes::shell::ShellRequest;
|
||||
use crate::tools::runtimes::shell::ShellRequestCommandInputKind;
|
||||
use crate::tools::runtimes::shell::ShellRuntime;
|
||||
use crate::tools::sandboxing::Approvable;
|
||||
use crate::tools::sandboxing::ApprovalCtx;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_hooks::PermissionRequestToolInput;
|
||||
use codex_shell_command::is_safe_command::is_known_safe_command;
|
||||
use codex_shell_command::powershell::try_find_powershell_executable_blocking;
|
||||
use codex_shell_command::powershell::try_find_pwsh_executable_blocking;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::watch;
|
||||
|
||||
@@ -199,6 +207,64 @@ fn shell_command_handler_rejects_login_when_disallowed() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_runtime_rewrite_updates_command_and_justification() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let req = ShellRequest {
|
||||
command: session
|
||||
.user_shell()
|
||||
.derive_exec_args("echo original", /*use_login_shell*/ true),
|
||||
command_input_kind: ShellRequestCommandInputKind::ShellString {
|
||||
use_login_shell: true,
|
||||
},
|
||||
hook_command: "echo original".to_string(),
|
||||
cwd: turn.cwd.clone(),
|
||||
timeout_ms: None,
|
||||
env: HashMap::new(),
|
||||
explicit_env_overrides: HashMap::new(),
|
||||
network: None,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
#[cfg(unix)]
|
||||
additional_permissions_preapproved: false,
|
||||
justification: Some("old justification".to_string()),
|
||||
exec_approval_requirement: ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
};
|
||||
let runtime = ShellRuntime::for_shell_command(super::ShellRuntimeBackend::ShellCommandClassic);
|
||||
|
||||
let updated = runtime
|
||||
.updated_request_from_permission_request(
|
||||
&req,
|
||||
&PermissionRequestToolInput {
|
||||
command: "echo rewritten".to_string(),
|
||||
description: Some("new justification".to_string()),
|
||||
},
|
||||
&ApprovalCtx {
|
||||
session: &session,
|
||||
turn: &turn,
|
||||
call_id: "call-1",
|
||||
guardian_review_id: None,
|
||||
retry_reason: None,
|
||||
network_approval_context: None,
|
||||
},
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
|
||||
assert_eq!(
|
||||
updated.command,
|
||||
session
|
||||
.user_shell()
|
||||
.derive_exec_args("echo rewritten", /*use_login_shell*/ true)
|
||||
);
|
||||
assert_eq!(updated.hook_command, "echo rewritten");
|
||||
assert_eq!(updated.justification, Some("new justification".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_pre_tool_use_payload_uses_joined_command() {
|
||||
let payload = ToolPayload::LocalShell {
|
||||
|
||||
@@ -208,10 +208,15 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
turn.tools_config.allow_login_shell,
|
||||
)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
let use_login_shell =
|
||||
resolve_use_login_shell(args.login, turn.tools_config.allow_login_shell)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
|
||||
let hook_command = args.cmd.clone();
|
||||
|
||||
let ExecCommandArgs {
|
||||
workdir,
|
||||
shell,
|
||||
tty,
|
||||
yield_time_ms,
|
||||
max_output_tokens,
|
||||
@@ -314,6 +319,9 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
.exec_command(
|
||||
ExecCommandRequest {
|
||||
command,
|
||||
hook_command,
|
||||
shell,
|
||||
use_login_shell,
|
||||
process_id,
|
||||
yield_time_ms,
|
||||
max_output_tokens,
|
||||
@@ -387,15 +395,7 @@ pub(crate) fn get_command(
|
||||
shell_mode: &UnifiedExecShellMode,
|
||||
allow_login_shell: bool,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let use_login_shell = match args.login {
|
||||
Some(true) if !allow_login_shell => {
|
||||
return Err(
|
||||
"login shell is disabled by config; omit `login` or set it to false.".to_string(),
|
||||
);
|
||||
}
|
||||
Some(use_login_shell) => use_login_shell,
|
||||
None => allow_login_shell,
|
||||
};
|
||||
let use_login_shell = resolve_use_login_shell(args.login, allow_login_shell)?;
|
||||
|
||||
match shell_mode {
|
||||
UnifiedExecShellMode::Direct => {
|
||||
@@ -415,6 +415,19 @@ pub(crate) fn get_command(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_use_login_shell(
|
||||
login: Option<bool>,
|
||||
allow_login_shell: bool,
|
||||
) -> Result<bool, String> {
|
||||
match login {
|
||||
Some(true) if !allow_login_shell => {
|
||||
Err("login shell is disabled by config; omit `login` or set it to false.".to_string())
|
||||
}
|
||||
Some(use_login_shell) => Ok(use_login_shell),
|
||||
None => Ok(allow_login_shell),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "unified_exec_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -2,6 +2,12 @@ use super::*;
|
||||
use crate::shell::default_user_shell;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_workdir_base_path;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecRequest;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecRuntime;
|
||||
use crate::tools::sandboxing::Approvable;
|
||||
use crate::tools::sandboxing::ApprovalCtx;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use codex_hooks::PermissionRequestToolInput;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
@@ -9,6 +15,7 @@ use codex_tools::ZshForkConfig;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
@@ -198,6 +205,81 @@ fn exec_command_args_resolve_relative_additional_permissions_against_workdir() -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_exec_runtime_rewrite_updates_command_and_justification() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct);
|
||||
let req = UnifiedExecRequest {
|
||||
command: session
|
||||
.user_shell()
|
||||
.derive_exec_args("echo original", /*use_login_shell*/ true),
|
||||
hook_command: "echo original".to_string(),
|
||||
shell: Some("/bin/bash".to_string()),
|
||||
use_login_shell: true,
|
||||
process_id: 7,
|
||||
cwd: turn.cwd.clone(),
|
||||
env: HashMap::new(),
|
||||
explicit_env_overrides: HashMap::new(),
|
||||
network: None,
|
||||
tty: false,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
#[cfg(unix)]
|
||||
additional_permissions_preapproved: false,
|
||||
justification: Some("old justification".to_string()),
|
||||
exec_approval_requirement: ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
};
|
||||
|
||||
let updated = runtime
|
||||
.updated_request_from_permission_request(
|
||||
&req,
|
||||
&PermissionRequestToolInput {
|
||||
command: "echo rewritten".to_string(),
|
||||
description: Some("new justification".to_string()),
|
||||
},
|
||||
&ApprovalCtx {
|
||||
session: &session,
|
||||
turn: &turn,
|
||||
call_id: "call-1",
|
||||
guardian_review_id: None,
|
||||
retry_reason: None,
|
||||
network_approval_context: None,
|
||||
},
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
|
||||
assert_eq!(
|
||||
updated.command,
|
||||
get_command(
|
||||
&ExecCommandArgs {
|
||||
cmd: "echo rewritten".to_string(),
|
||||
workdir: None,
|
||||
shell: Some("/bin/bash".to_string()),
|
||||
login: Some(true),
|
||||
tty: false,
|
||||
yield_time_ms: 10_000,
|
||||
max_output_tokens: None,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: None,
|
||||
prefix_rule: None,
|
||||
},
|
||||
session.user_shell(),
|
||||
&UnifiedExecShellMode::Direct,
|
||||
/*allow_login_shell*/ true,
|
||||
)
|
||||
.expect("command should build")
|
||||
);
|
||||
assert_eq!(updated.hook_command, "echo rewritten");
|
||||
assert_eq!(updated.justification, Some("new justification".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_command_pre_tool_use_payload_uses_raw_command() {
|
||||
let payload = ToolPayload::Function {
|
||||
|
||||
@@ -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,47 @@ 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}")),
|
||||
permission_suggestions: Vec::new(),
|
||||
},
|
||||
)
|
||||
.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 +439,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.to_path_buf(),
|
||||
@@ -590,7 +635,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -43,6 +46,17 @@ pub(crate) struct OrchestratorRunResult<Out> {
|
||||
pub deferred_network_approval: Option<DeferredNetworkApproval>,
|
||||
}
|
||||
|
||||
struct ApprovalResult<Rq> {
|
||||
decision: ReviewDecision,
|
||||
updated_req: Option<Rq>,
|
||||
}
|
||||
|
||||
struct ApprovalTelemetry<'a> {
|
||||
otel: &'a SessionTelemetry,
|
||||
tool_name: &'a str,
|
||||
call_id: &'a str,
|
||||
}
|
||||
|
||||
impl ToolOrchestrator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -114,10 +128,8 @@ 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);
|
||||
let mut effective_req = None;
|
||||
|
||||
// 1) Approval
|
||||
let mut already_approved = false;
|
||||
@@ -127,7 +139,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,39 +159,26 @@ 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);
|
||||
|
||||
match decision {
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
|
||||
guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await
|
||||
} else {
|
||||
"rejected by user".to_string()
|
||||
};
|
||||
return Err(ToolError::Rejected(reason));
|
||||
}
|
||||
ReviewDecision::TimedOut => {
|
||||
return Err(ToolError::Rejected(guardian_timeout_message()));
|
||||
}
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => {}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => {}
|
||||
NetworkPolicyRuleAction::Deny => {
|
||||
return Err(ToolError::Rejected("rejected by user".to_string()));
|
||||
}
|
||||
let approval = Self::request_approval(
|
||||
tool,
|
||||
effective_req.as_ref().unwrap_or(req),
|
||||
tool_ctx.call_id.as_str(),
|
||||
approval_ctx,
|
||||
turn_ctx,
|
||||
ApprovalTelemetry {
|
||||
otel: &otel,
|
||||
tool_name: otel_tn,
|
||||
call_id: otel_ci,
|
||||
},
|
||||
}
|
||||
)
|
||||
.await?;
|
||||
effective_req = approval.updated_req.or(effective_req);
|
||||
Self::enforce_approval_decision(
|
||||
tool_ctx.session.as_ref(),
|
||||
guardian_review_id.as_deref(),
|
||||
approval.decision,
|
||||
)
|
||||
.await?;
|
||||
already_approved = true;
|
||||
}
|
||||
}
|
||||
@@ -186,16 +190,17 @@ impl ToolOrchestrator {
|
||||
.requirements_toml()
|
||||
.network
|
||||
.is_some();
|
||||
let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) {
|
||||
SandboxOverride::BypassSandboxFirstAttempt => SandboxType::None,
|
||||
SandboxOverride::NoOverride => self.sandbox.select_initial(
|
||||
&turn_ctx.file_system_sandbox_policy,
|
||||
turn_ctx.network_sandbox_policy,
|
||||
tool.sandbox_preference(),
|
||||
turn_ctx.windows_sandbox_level,
|
||||
has_managed_network_requirements,
|
||||
),
|
||||
};
|
||||
let initial_sandbox =
|
||||
match tool.sandbox_mode_for_first_attempt(effective_req.as_ref().unwrap_or(req)) {
|
||||
SandboxOverride::BypassSandboxFirstAttempt => SandboxType::None,
|
||||
SandboxOverride::NoOverride => self.sandbox.select_initial(
|
||||
&turn_ctx.file_system_sandbox_policy,
|
||||
turn_ctx.network_sandbox_policy,
|
||||
tool.sandbox_preference(),
|
||||
turn_ctx.windows_sandbox_level,
|
||||
has_managed_network_requirements,
|
||||
),
|
||||
};
|
||||
|
||||
// Platform-specific flag gating is handled by SandboxManager::select_initial.
|
||||
let use_legacy_landlock = turn_ctx.features.use_legacy_landlock();
|
||||
@@ -218,7 +223,7 @@ impl ToolOrchestrator {
|
||||
|
||||
let (first_result, first_deferred_network_approval) = Self::run_attempt(
|
||||
tool,
|
||||
req,
|
||||
effective_req.as_ref().unwrap_or(req),
|
||||
tool_ctx,
|
||||
&initial_attempt,
|
||||
has_managed_network_requirements,
|
||||
@@ -301,41 +306,29 @@ 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);
|
||||
|
||||
match decision {
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
|
||||
guardian_rejection_message(tool_ctx.session.as_ref(), review_id)
|
||||
.await
|
||||
} else {
|
||||
"rejected by user".to_string()
|
||||
};
|
||||
return Err(ToolError::Rejected(reason));
|
||||
}
|
||||
ReviewDecision::TimedOut => {
|
||||
return Err(ToolError::Rejected(guardian_timeout_message()));
|
||||
}
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => {}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => {}
|
||||
NetworkPolicyRuleAction::Deny => {
|
||||
return Err(ToolError::Rejected("rejected by user".to_string()));
|
||||
}
|
||||
let approval = Self::request_approval(
|
||||
tool,
|
||||
effective_req.as_ref().unwrap_or(req),
|
||||
&format!("{}:retry", tool_ctx.call_id),
|
||||
approval_ctx,
|
||||
turn_ctx,
|
||||
ApprovalTelemetry {
|
||||
otel: &otel,
|
||||
tool_name: otel_tn,
|
||||
call_id: otel_ci,
|
||||
},
|
||||
}
|
||||
)
|
||||
.await?;
|
||||
effective_req = approval.updated_req.or(effective_req);
|
||||
Self::enforce_approval_decision(
|
||||
tool_ctx.session.as_ref(),
|
||||
guardian_review_id.as_deref(),
|
||||
approval.decision,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let req = effective_req.as_ref().unwrap_or(req);
|
||||
let escalated_attempt = SandboxAttempt {
|
||||
sandbox: SandboxType::None,
|
||||
policy: &turn_ctx.sandbox_policy,
|
||||
@@ -370,6 +363,119 @@ 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<'_>,
|
||||
turn_ctx: &crate::codex::TurnContext,
|
||||
telemetry: ApprovalTelemetry<'_>,
|
||||
) -> Result<ApprovalResult<Rq>, ToolError>
|
||||
where
|
||||
T: ToolRuntime<Rq, Out>,
|
||||
{
|
||||
if let Some(permission_request) = tool.permission_request_payload(req, &approval_ctx) {
|
||||
match run_permission_request_hooks(
|
||||
approval_ctx.session,
|
||||
approval_ctx.turn,
|
||||
permission_request_run_id,
|
||||
permission_request,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(PermissionRequestDecision::Allow { updated_input, .. }) => {
|
||||
let updated_req = updated_input
|
||||
.as_ref()
|
||||
.map(|updated_input| {
|
||||
tool.updated_request_from_permission_request(
|
||||
req,
|
||||
updated_input,
|
||||
&approval_ctx,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
.map_err(|err| {
|
||||
ToolError::Rejected(format!(
|
||||
"PermissionRequest hook returned invalid updatedInput: {err}"
|
||||
))
|
||||
})?;
|
||||
telemetry.otel.tool_decision(
|
||||
telemetry.tool_name,
|
||||
telemetry.call_id,
|
||||
&ReviewDecision::Approved,
|
||||
ToolDecisionSource::Config,
|
||||
);
|
||||
return Ok(ApprovalResult {
|
||||
decision: ReviewDecision::Approved,
|
||||
updated_req,
|
||||
});
|
||||
}
|
||||
Some(PermissionRequestDecision::Deny { message }) => {
|
||||
telemetry.otel.tool_decision(
|
||||
telemetry.tool_name,
|
||||
telemetry.call_id,
|
||||
&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
|
||||
};
|
||||
telemetry.otel.tool_decision(
|
||||
telemetry.tool_name,
|
||||
telemetry.call_id,
|
||||
&decision,
|
||||
otel_source,
|
||||
);
|
||||
Ok(ApprovalResult {
|
||||
decision,
|
||||
updated_req: None,
|
||||
})
|
||||
}
|
||||
|
||||
// Normalizes approval outcomes from hooks, guardian, and user prompts into
|
||||
// the orchestrator's `Result` flow so both initial and retry approvals use
|
||||
// the same rejection and timeout handling.
|
||||
async fn enforce_approval_decision(
|
||||
session: &crate::codex::Session,
|
||||
guardian_review_id: Option<&str>,
|
||||
decision: ReviewDecision,
|
||||
) -> Result<(), ToolError> {
|
||||
match decision {
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
let reason = if let Some(review_id) = guardian_review_id {
|
||||
guardian_rejection_message(session, review_id).await
|
||||
} else {
|
||||
"rejected by user".to_string()
|
||||
};
|
||||
Err(ToolError::Rejected(reason))
|
||||
}
|
||||
ReviewDecision::TimedOut => Err(ToolError::Rejected(guardian_timeout_message())),
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => Ok(()),
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => Ok(()),
|
||||
NetworkPolicyRuleAction::Deny => {
|
||||
Err(ToolError::Rejected("rejected by user".to_string()))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {
|
||||
|
||||
@@ -23,14 +23,18 @@ 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;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use crate::tools::sandboxing::ToolRuntime;
|
||||
use crate::tools::sandboxing::approval_permission_suggestions;
|
||||
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
|
||||
use crate::tools::sandboxing::with_cached_approval;
|
||||
use codex_hooks::PermissionRequestToolInput;
|
||||
use codex_hooks::PermissionUpdateDestination;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
@@ -39,11 +43,14 @@ use codex_sandboxing::SandboxablePreference;
|
||||
use codex_shell_command::powershell::prefix_powershell_script_with_utf8;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use futures::future::BoxFuture;
|
||||
use shlex::split as shlex_split;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShellRequest {
|
||||
pub command: Vec<String>,
|
||||
pub command_input_kind: ShellRequestCommandInputKind,
|
||||
pub hook_command: String,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub env: HashMap<String, String>,
|
||||
@@ -57,6 +64,12 @@ pub struct ShellRequest {
|
||||
pub exec_approval_requirement: ExecApprovalRequirement,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ShellRequestCommandInputKind {
|
||||
Argv,
|
||||
ShellString { use_login_shell: bool },
|
||||
}
|
||||
|
||||
/// Selects `ShellRuntime` behavior for different callers.
|
||||
///
|
||||
/// Note: `Generic` is not the same as `ShellCommandClassic`.
|
||||
@@ -197,11 +210,64 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
Some(req.exec_approval_requirement.clone())
|
||||
}
|
||||
|
||||
fn permission_request_payload(
|
||||
&self,
|
||||
req: &ShellRequest,
|
||||
approval_ctx: &ApprovalCtx<'_>,
|
||||
) -> Option<PermissionRequestPayload> {
|
||||
let permission_suggestions = approval_permission_suggestions(
|
||||
approval_ctx.network_approval_context.as_ref(),
|
||||
req.exec_approval_requirement
|
||||
.proposed_execpolicy_amendment(),
|
||||
req.additional_permissions.as_ref(),
|
||||
&[PermissionUpdateDestination::UserSettings],
|
||||
);
|
||||
Some(PermissionRequestPayload {
|
||||
tool_name: "Bash".to_string(),
|
||||
command: req.hook_command.clone(),
|
||||
description: req.justification.clone(),
|
||||
permission_suggestions,
|
||||
})
|
||||
}
|
||||
|
||||
fn updated_request_from_permission_request(
|
||||
&self,
|
||||
req: &ShellRequest,
|
||||
updated_input: &PermissionRequestToolInput,
|
||||
approval_ctx: &ApprovalCtx<'_>,
|
||||
) -> Result<ShellRequest, String> {
|
||||
let mut updated_req = req.clone();
|
||||
updated_req.command = match req.command_input_kind {
|
||||
ShellRequestCommandInputKind::Argv => parse_command_argv(&updated_input.command)?,
|
||||
ShellRequestCommandInputKind::ShellString { use_login_shell } => approval_ctx
|
||||
.session
|
||||
.user_shell()
|
||||
.derive_exec_args(&updated_input.command, use_login_shell),
|
||||
};
|
||||
updated_req.hook_command = updated_input.command.clone();
|
||||
updated_req.justification = updated_input.description.clone();
|
||||
Ok(updated_req)
|
||||
}
|
||||
|
||||
fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride {
|
||||
sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_command_argv(command: &str) -> Result<Vec<String>, String> {
|
||||
let parsed = shlex_split(command).unwrap_or_else(|| {
|
||||
command
|
||||
.split_whitespace()
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
});
|
||||
if parsed.is_empty() {
|
||||
Err("command cannot be empty".to_string())
|
||||
} else {
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
fn network_approval_spec(
|
||||
&self,
|
||||
@@ -212,6 +278,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;
|
||||
@@ -320,6 +323,7 @@ enum DecisionSource {
|
||||
struct PromptDecision {
|
||||
decision: ReviewDecision,
|
||||
guardian_review_id: Option<String>,
|
||||
rejection_message: Option<String>,
|
||||
}
|
||||
|
||||
fn execve_prompt_is_rejected_by_policy(
|
||||
@@ -394,11 +398,45 @@ 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,
|
||||
permission_suggestions: Vec::new(),
|
||||
};
|
||||
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,
|
||||
@@ -413,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,
|
||||
@@ -432,6 +473,7 @@ impl CoreShellActionProvider {
|
||||
PromptDecision {
|
||||
decision,
|
||||
guardian_review_id: None,
|
||||
rejection_message: None,
|
||||
}
|
||||
})
|
||||
.await)
|
||||
@@ -487,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")"#;
|
||||
|
||||
@@ -11,7 +11,9 @@ use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::sandboxing::ExecOptions;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::Shell;
|
||||
use crate::shell::ShellType;
|
||||
use crate::shell::get_shell_by_model_provided_path;
|
||||
use crate::tools::network_approval::NetworkApprovalMode;
|
||||
use crate::tools::network_approval::NetworkApprovalSpec;
|
||||
use crate::tools::runtimes::build_sandbox_command;
|
||||
@@ -20,18 +22,22 @@ 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;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use crate::tools::sandboxing::ToolRuntime;
|
||||
use crate::tools::sandboxing::approval_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::PermissionRequestToolInput;
|
||||
use codex_hooks::PermissionUpdateDestination;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
@@ -43,12 +49,17 @@ use codex_tools::UnifiedExecShellMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use futures::future::BoxFuture;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Request payload used by the unified-exec runtime after approvals and
|
||||
/// sandbox preferences have been resolved for the current turn.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UnifiedExecRequest {
|
||||
pub command: Vec<String>,
|
||||
pub hook_command: String,
|
||||
pub shell: Option<String>,
|
||||
pub use_login_shell: bool,
|
||||
pub process_id: i32,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
pub env: HashMap<String, String>,
|
||||
@@ -177,6 +188,45 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
Some(req.exec_approval_requirement.clone())
|
||||
}
|
||||
|
||||
fn permission_request_payload(
|
||||
&self,
|
||||
req: &UnifiedExecRequest,
|
||||
approval_ctx: &ApprovalCtx<'_>,
|
||||
) -> Option<PermissionRequestPayload> {
|
||||
let permission_suggestions = approval_permission_suggestions(
|
||||
approval_ctx.network_approval_context.as_ref(),
|
||||
req.exec_approval_requirement
|
||||
.proposed_execpolicy_amendment(),
|
||||
req.additional_permissions.as_ref(),
|
||||
&[PermissionUpdateDestination::UserSettings],
|
||||
);
|
||||
Some(PermissionRequestPayload {
|
||||
tool_name: "Bash".to_string(),
|
||||
command: req.hook_command.clone(),
|
||||
description: req.justification.clone(),
|
||||
permission_suggestions,
|
||||
})
|
||||
}
|
||||
|
||||
fn updated_request_from_permission_request(
|
||||
&self,
|
||||
req: &UnifiedExecRequest,
|
||||
updated_input: &PermissionRequestToolInput,
|
||||
approval_ctx: &ApprovalCtx<'_>,
|
||||
) -> Result<UnifiedExecRequest, String> {
|
||||
let mut updated_req = req.clone();
|
||||
updated_req.command = build_command(
|
||||
&updated_input.command,
|
||||
req.shell.as_deref(),
|
||||
req.use_login_shell,
|
||||
approval_ctx.session.user_shell(),
|
||||
&self.shell_mode,
|
||||
);
|
||||
updated_req.hook_command = updated_input.command.clone();
|
||||
updated_req.justification = updated_input.description.clone();
|
||||
Ok(updated_req)
|
||||
}
|
||||
|
||||
fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride {
|
||||
sandbox_override_for_first_attempt(req.sandbox_permissions, &req.exec_approval_requirement)
|
||||
}
|
||||
@@ -192,6 +242,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
Some(NetworkApprovalSpec {
|
||||
network: req.network.clone(),
|
||||
mode: NetworkApprovalMode::Deferred,
|
||||
command: req.hook_command.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -322,3 +373,28 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command(
|
||||
cmd: &str,
|
||||
shell: Option<&str>,
|
||||
use_login_shell: bool,
|
||||
session_shell: Arc<Shell>,
|
||||
shell_mode: &UnifiedExecShellMode,
|
||||
) -> Vec<String> {
|
||||
match shell_mode {
|
||||
UnifiedExecShellMode::Direct => {
|
||||
let model_shell = shell.map(|shell_str| {
|
||||
let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str));
|
||||
shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver();
|
||||
shell
|
||||
});
|
||||
let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref());
|
||||
shell.derive_exec_args(cmd, use_login_shell)
|
||||
}
|
||||
UnifiedExecShellMode::ZshFork(zsh_fork_config) => vec![
|
||||
zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(),
|
||||
if use_login_shell { "-lc" } else { "-c" }.to_string(),
|
||||
cmd.to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,21 @@ use crate::sandboxing::ExecOptions;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::state::SessionServices;
|
||||
use crate::tools::network_approval::NetworkApprovalSpec;
|
||||
use codex_hooks::PermissionRequestToolInput;
|
||||
use codex_hooks::PermissionUpdate;
|
||||
use codex_hooks::PermissionUpdateBehavior;
|
||||
use codex_hooks::PermissionUpdateDestination;
|
||||
use codex_hooks::PermissionUpdateRule;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemSandboxKind;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
@@ -131,6 +138,58 @@ 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>,
|
||||
pub permission_suggestions: Vec<PermissionUpdate>,
|
||||
}
|
||||
|
||||
pub(crate) fn exec_policy_permission_suggestions(
|
||||
proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>,
|
||||
destinations: &[PermissionUpdateDestination],
|
||||
) -> Vec<PermissionUpdate> {
|
||||
proposed_execpolicy_amendment
|
||||
.into_iter()
|
||||
.flat_map(|amendment| {
|
||||
destinations
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(move |destination| PermissionUpdate::AddRules {
|
||||
rules: vec![PermissionUpdateRule::PrefixRule {
|
||||
command: amendment.command.clone(),
|
||||
}],
|
||||
behavior: PermissionUpdateBehavior::Allow,
|
||||
destination,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn approval_permission_suggestions(
|
||||
network_approval_context: Option<&NetworkApprovalContext>,
|
||||
proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>,
|
||||
additional_permissions: Option<&PermissionProfile>,
|
||||
destinations: &[PermissionUpdateDestination],
|
||||
) -> Vec<PermissionUpdate> {
|
||||
let available_decisions = ExecApprovalRequestEvent::default_available_decisions(
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
/*proposed_network_policy_amendments*/ None,
|
||||
additional_permissions,
|
||||
);
|
||||
let allows_execpolicy_amendment = available_decisions
|
||||
.iter()
|
||||
.any(|decision| matches!(decision, ReviewDecision::ApprovedExecpolicyAmendment { .. }));
|
||||
|
||||
if allows_execpolicy_amendment {
|
||||
exec_policy_permission_suggestions(proposed_execpolicy_amendment, destinations)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
// Specifies what tool orchestrator should do with a given tool call.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ExecApprovalRequirement {
|
||||
@@ -273,6 +332,25 @@ 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,
|
||||
_approval_ctx: &ApprovalCtx<'_>,
|
||||
) -> Option<PermissionRequestPayload> {
|
||||
None
|
||||
}
|
||||
|
||||
fn updated_request_from_permission_request(
|
||||
&self,
|
||||
_req: &Req,
|
||||
_updated_input: &PermissionRequestToolInput,
|
||||
_approval_ctx: &ApprovalCtx<'_>,
|
||||
) -> Result<Req, String> {
|
||||
Err("PermissionRequest hook returned unsupported updatedInput".to_string())
|
||||
}
|
||||
|
||||
/// Decide we can request an approval for no-sandbox execution.
|
||||
fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
|
||||
match policy {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
use super::*;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use codex_hooks::PermissionUpdate;
|
||||
use codex_hooks::PermissionUpdateBehavior;
|
||||
use codex_hooks::PermissionUpdateDestination;
|
||||
use codex_hooks::PermissionUpdateRule;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -108,3 +118,74 @@ 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 = approval_permission_suggestions(
|
||||
/*network_approval_context*/ None,
|
||||
Some(&ExecPolicyAmendment::new(vec![
|
||||
"rm".to_string(),
|
||||
"-rf".to_string(),
|
||||
"node_modules".to_string(),
|
||||
])),
|
||||
/*additional_permissions*/ None,
|
||||
&[PermissionUpdateDestination::UserSettings],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
suggestions,
|
||||
vec![PermissionUpdate::AddRules {
|
||||
rules: vec![PermissionUpdateRule::PrefixRule {
|
||||
command: vec![
|
||||
"rm".to_string(),
|
||||
"-rf".to_string(),
|
||||
"node_modules".to_string(),
|
||||
],
|
||||
}],
|
||||
behavior: PermissionUpdateBehavior::Allow,
|
||||
destination: PermissionUpdateDestination::UserSettings,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_approval_with_additional_permissions_has_no_persistent_suggestions() {
|
||||
let suggestions = approval_permission_suggestions(
|
||||
/*network_approval_context*/ None,
|
||||
Some(&ExecPolicyAmendment::new(vec![
|
||||
"cat".to_string(),
|
||||
"/tmp/secret".to_string(),
|
||||
])),
|
||||
Some(&PermissionProfile {
|
||||
network: None,
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![
|
||||
AbsolutePathBuf::from_absolute_path("/tmp/secret")
|
||||
.expect("/tmp/secret should be an absolute path"),
|
||||
]),
|
||||
write: None,
|
||||
}),
|
||||
}),
|
||||
&[PermissionUpdateDestination::UserSettings],
|
||||
);
|
||||
|
||||
assert_eq!(suggestions, Vec::<PermissionUpdate>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_approval_with_execpolicy_amendment_has_no_persistent_suggestions() {
|
||||
let suggestions = approval_permission_suggestions(
|
||||
Some(&NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
}),
|
||||
Some(&ExecPolicyAmendment::new(vec![
|
||||
"curl".to_string(),
|
||||
"https://example.com".to_string(),
|
||||
])),
|
||||
/*additional_permissions*/ None,
|
||||
&[PermissionUpdateDestination::UserSettings],
|
||||
);
|
||||
|
||||
assert_eq!(suggestions, Vec::<PermissionUpdate>::new());
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ impl UnifiedExecContext {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ExecCommandRequest {
|
||||
pub command: Vec<String>,
|
||||
pub hook_command: String,
|
||||
pub shell: Option<String>,
|
||||
pub use_login_shell: bool,
|
||||
pub process_id: i32,
|
||||
pub yield_time_ms: u64,
|
||||
pub max_output_tokens: Option<usize>,
|
||||
|
||||
@@ -48,6 +48,7 @@ use crate::unified_exec::process::OutputHandles;
|
||||
use crate::unified_exec::process::SpawnLifecycleHandle;
|
||||
use crate::unified_exec::process::UnifiedExecProcess;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
|
||||
@@ -658,6 +659,15 @@ impl UnifiedExecProcessManager {
|
||||
self,
|
||||
context.turn.tools_config.unified_exec_shell_mode.clone(),
|
||||
);
|
||||
let exec_approval_file_system_sandbox_policy = if request.additional_permissions_preapproved
|
||||
{
|
||||
effective_file_system_sandbox_policy(
|
||||
&context.turn.file_system_sandbox_policy,
|
||||
request.additional_permissions.as_ref(),
|
||||
)
|
||||
} else {
|
||||
context.turn.file_system_sandbox_policy.clone()
|
||||
};
|
||||
let exec_approval_requirement = context
|
||||
.session
|
||||
.services
|
||||
@@ -666,7 +676,7 @@ impl UnifiedExecProcessManager {
|
||||
command: &request.command,
|
||||
approval_policy: context.turn.approval_policy.value(),
|
||||
sandbox_policy: context.turn.sandbox_policy.get(),
|
||||
file_system_sandbox_policy: &context.turn.file_system_sandbox_policy,
|
||||
file_system_sandbox_policy: &exec_approval_file_system_sandbox_policy,
|
||||
sandbox_permissions: if request.additional_permissions_preapproved {
|
||||
crate::sandboxing::SandboxPermissions::UseDefault
|
||||
} else {
|
||||
@@ -677,6 +687,9 @@ impl UnifiedExecProcessManager {
|
||||
.await;
|
||||
let req = UnifiedExecToolRequest {
|
||||
command: request.command.clone(),
|
||||
hook_command: request.hook_command.clone(),
|
||||
shell: request.shell.clone(),
|
||||
use_login_shell: request.use_login_shell,
|
||||
process_id: request.process_id,
|
||||
cwd,
|
||||
env,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"NullableString": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"PermissionRequestToolInput": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PermissionUpdate": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"behavior": {
|
||||
"$ref": "#/definitions/PermissionUpdateBehavior"
|
||||
},
|
||||
"destination": {
|
||||
"$ref": "#/definitions/PermissionUpdateDestination"
|
||||
},
|
||||
"rules": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionUpdateRule"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"addRules"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"behavior",
|
||||
"destination",
|
||||
"rules",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"destination": {
|
||||
"$ref": "#/definitions/PermissionUpdateDestination"
|
||||
},
|
||||
"directories": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"addDirectories"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"destination",
|
||||
"directories",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionUpdateBehavior": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny",
|
||||
"ask"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionUpdateDestination": {
|
||||
"enum": [
|
||||
"session",
|
||||
"projectSettings",
|
||||
"userSettings"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionUpdateRule": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"command": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"prefixRule"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"hook_event_name": {
|
||||
"const": "PermissionRequest",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"permission_mode": {
|
||||
"enum": [
|
||||
"default",
|
||||
"acceptEdits",
|
||||
"plan",
|
||||
"dontAsk",
|
||||
"bypassPermissions"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"permission_suggestions": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionUpdate"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_input": {
|
||||
"$ref": "#/definitions/PermissionRequestToolInput"
|
||||
},
|
||||
"tool_name": {
|
||||
"const": "Bash",
|
||||
"type": "string"
|
||||
},
|
||||
"transcript_path": {
|
||||
"$ref": "#/definitions/NullableString"
|
||||
},
|
||||
"turn_id": {
|
||||
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cwd",
|
||||
"hook_event_name",
|
||||
"model",
|
||||
"permission_mode",
|
||||
"session_id",
|
||||
"tool_input",
|
||||
"tool_name",
|
||||
"transcript_path",
|
||||
"turn_id"
|
||||
],
|
||||
"title": "permission-request.command.input",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionRequestBehaviorWire": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionRequestDecisionWire": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"behavior": {
|
||||
"$ref": "#/definitions/PermissionRequestBehaviorWire"
|
||||
},
|
||||
"interrupt": {
|
||||
"default": false,
|
||||
"description": "Reserved for future short-circuiting semantics.\n\nPermissionRequest hooks currently fail closed if this field is `true`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"message": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"updatedInput": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PermissionRequestToolInput"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"updatedPermissions": {
|
||||
"default": null,
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionUpdate"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"behavior"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PermissionRequestHookSpecificOutputWire": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"decision": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PermissionRequestDecisionWire"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"hookEventName": {
|
||||
"$ref": "#/definitions/HookEventNameWire"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hookEventName"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PermissionRequestToolInput": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PermissionUpdate": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"behavior": {
|
||||
"$ref": "#/definitions/PermissionUpdateBehavior"
|
||||
},
|
||||
"destination": {
|
||||
"$ref": "#/definitions/PermissionUpdateDestination"
|
||||
},
|
||||
"rules": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionUpdateRule"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"addRules"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"behavior",
|
||||
"destination",
|
||||
"rules",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"destination": {
|
||||
"$ref": "#/definitions/PermissionUpdateDestination"
|
||||
},
|
||||
"directories": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"addDirectories"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"destination",
|
||||
"directories",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionUpdateBehavior": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"deny",
|
||||
"ask"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionUpdateDestination": {
|
||||
"enum": [
|
||||
"session",
|
||||
"projectSettings",
|
||||
"userSettings"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionUpdateRule": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"command": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"prefixRule"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"continue": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"hookSpecificOutput": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PermissionRequestHookSpecificOutputWire"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"stopReason": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"suppressOutput": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"systemMessage": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "permission-request.command.output",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"HookEventNameWire": {
|
||||
"enum": [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
|
||||
@@ -10,6 +10,8 @@ pub(crate) struct HooksFile {
|
||||
pub(crate) struct HookEvents {
|
||||
#[serde(rename = "PreToolUse", default)]
|
||||
pub pre_tool_use: Vec<MatcherGroup>,
|
||||
#[serde(rename = "PermissionRequest", default)]
|
||||
pub permission_request: Vec<MatcherGroup>,
|
||||
#[serde(rename = "PostToolUse", default)]
|
||||
pub post_tool_use: Vec<MatcherGroup>,
|
||||
#[serde(rename = "SessionStart", default)]
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
|
||||
let super::config::HookEvents {
|
||||
pre_tool_use,
|
||||
permission_request,
|
||||
post_tool_use,
|
||||
session_start,
|
||||
user_prompt_submit,
|
||||
@@ -75,6 +76,10 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
codex_protocol::protocol::HookEventName::PreToolUse,
|
||||
pre_tool_use,
|
||||
),
|
||||
(
|
||||
codex_protocol::protocol::HookEventName::PermissionRequest,
|
||||
permission_request,
|
||||
),
|
||||
(
|
||||
codex_protocol::protocol::HookEventName::PostToolUse,
|
||||
post_tool_use,
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(crate) fn select_handlers(
|
||||
.filter(|handler| handler.event_name == event_name)
|
||||
.filter(|handler| match event_name {
|
||||
HookEventName::PreToolUse
|
||||
| HookEventName::PermissionRequest
|
||||
| HookEventName::PostToolUse
|
||||
| HookEventName::SessionStart => {
|
||||
matches_matcher(handler.matcher.as_deref(), matcher_input)
|
||||
@@ -109,6 +110,7 @@ fn scope_for_event(event_name: HookEventName) -> HookScope {
|
||||
match event_name {
|
||||
HookEventName::SessionStart => HookScope::Thread,
|
||||
HookEventName::PreToolUse
|
||||
| HookEventName::PermissionRequest
|
||||
| HookEventName::PostToolUse
|
||||
| HookEventName::UserPromptSubmit
|
||||
| HookEventName::Stop => HookScope::Turn,
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::path::PathBuf;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
|
||||
use crate::events::permission_request::PermissionRequestOutcome;
|
||||
use crate::events::permission_request::PermissionRequestRequest;
|
||||
use crate::events::post_tool_use::PostToolUseOutcome;
|
||||
use crate::events::post_tool_use::PostToolUseRequest;
|
||||
use crate::events::pre_tool_use::PreToolUseOutcome;
|
||||
@@ -51,6 +53,7 @@ impl ConfiguredHandler {
|
||||
fn event_name_label(&self) -> &'static str {
|
||||
match self.event_name {
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use",
|
||||
codex_protocol::protocol::HookEventName::PermissionRequest => "permission-request",
|
||||
codex_protocol::protocol::HookEventName::PostToolUse => "post-tool-use",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
|
||||
@@ -104,6 +107,13 @@ impl ClaudeHooksEngine {
|
||||
crate::events::pre_tool_use::preview(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_permission_request(
|
||||
&self,
|
||||
request: &PermissionRequestRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
crate::events::permission_request::preview(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_post_tool_use(
|
||||
&self,
|
||||
request: &PostToolUseRequest,
|
||||
@@ -123,6 +133,13 @@ impl ClaudeHooksEngine {
|
||||
crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_permission_request(
|
||||
&self,
|
||||
request: PermissionRequestRequest,
|
||||
) -> PermissionRequestOutcome {
|
||||
crate::events::permission_request::run(&self.handlers, &self.shell, request).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_post_tool_use(
|
||||
&self,
|
||||
request: PostToolUseRequest,
|
||||
|
||||
@@ -19,6 +19,24 @@ pub(crate) struct PreToolUseOutput {
|
||||
pub invalid_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum PermissionRequestDecision {
|
||||
Allow {
|
||||
updated_permissions: Vec<crate::events::permission_request::PermissionUpdate>,
|
||||
updated_input: Option<PermissionRequestToolInput>,
|
||||
},
|
||||
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,
|
||||
@@ -46,8 +64,12 @@ pub(crate) struct StopOutput {
|
||||
pub invalid_block_reason: Option<String>,
|
||||
}
|
||||
|
||||
use crate::events::permission_request::PermissionRequestToolInput;
|
||||
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 +137,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 +280,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 +300,43 @@ 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 matches!(decision.behavior, PermissionRequestBehaviorWire::Deny)
|
||||
&& decision.updated_input.is_some()
|
||||
{
|
||||
Some("PermissionRequest hook returned updatedInput for deny decision".to_string())
|
||||
} else if matches!(decision.behavior, PermissionRequestBehaviorWire::Deny)
|
||||
&& decision.updated_permissions.is_some()
|
||||
{
|
||||
Some("PermissionRequest hook returned updatedPermissions for deny decision".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 {
|
||||
updated_permissions: decision.updated_permissions.clone().unwrap_or_default(),
|
||||
updated_input: decision.updated_input.clone(),
|
||||
},
|
||||
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 +428,233 @@ 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::PermissionRequestDecision;
|
||||
use super::parse_permission_request;
|
||||
use crate::events::permission_request::PermissionRequestToolInput;
|
||||
|
||||
#[test]
|
||||
fn permission_request_accepts_updated_input_for_allow_decision() {
|
||||
let parsed = parse_permission_request(
|
||||
&json!({
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "allow",
|
||||
"updatedInput": {
|
||||
"command": "echo hi"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("permission request hook output should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.decision,
|
||||
Some(PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![],
|
||||
updated_input: Some(PermissionRequestToolInput {
|
||||
command: "echo hi".to_string(),
|
||||
description: None,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_accepts_updated_permissions_for_allow_decision() {
|
||||
let parsed = parse_permission_request(
|
||||
&json!({
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "allow",
|
||||
"updatedPermissions": [{
|
||||
"type": "addRules",
|
||||
"rules": [{
|
||||
"type": "prefixRule",
|
||||
"command": ["rm", "-f"]
|
||||
}],
|
||||
"behavior": "allow",
|
||||
"destination": "userSettings"
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("permission request hook output should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.decision,
|
||||
Some(PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![crate::events::permission_request::PermissionUpdate::AddRules {
|
||||
rules: vec![
|
||||
crate::events::permission_request::PermissionUpdateRule::PrefixRule {
|
||||
command: vec!["rm".to_string(), "-f".to_string()],
|
||||
},
|
||||
],
|
||||
behavior:
|
||||
crate::events::permission_request::PermissionUpdateBehavior::Allow,
|
||||
destination: crate::events::permission_request::PermissionUpdateDestination::UserSettings,
|
||||
}],
|
||||
updated_input: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_accepts_add_directories_for_allow_decision() {
|
||||
let parsed = parse_permission_request(
|
||||
&json!({
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "allow",
|
||||
"updatedPermissions": [{
|
||||
"type": "addDirectories",
|
||||
"directories": ["./logs", "/tmp/output"],
|
||||
"destination": "session"
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("permission request hook output should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.decision,
|
||||
Some(PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![
|
||||
crate::events::permission_request::PermissionUpdate::AddDirectories {
|
||||
directories: vec!["./logs".to_string(), "/tmp/output".to_string()],
|
||||
destination:
|
||||
crate::events::permission_request::PermissionUpdateDestination::Session,
|
||||
},
|
||||
],
|
||||
updated_input: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_rejects_updated_input_for_deny_decision() {
|
||||
let parsed = parse_permission_request(
|
||||
&json!({
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "deny",
|
||||
"updatedInput": {
|
||||
"command": "echo nope"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("permission request hook output should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.invalid_reason,
|
||||
Some("PermissionRequest hook returned updatedInput for deny decision".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_rejects_updated_permissions_for_deny_decision() {
|
||||
let parsed = parse_permission_request(
|
||||
&json!({
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "deny",
|
||||
"updatedPermissions": [{
|
||||
"type": "addRules",
|
||||
"rules": [{
|
||||
"type": "prefixRule",
|
||||
"command": ["rm", "-f"]
|
||||
}],
|
||||
"behavior": "allow",
|
||||
"destination": "userSettings"
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("permission request hook output should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.invalid_reason,
|
||||
Some(
|
||||
"PermissionRequest hook returned updatedPermissions for deny decision".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_rejects_add_directories_for_deny_decision() {
|
||||
let parsed = parse_permission_request(
|
||||
&json!({
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "deny",
|
||||
"updatedPermissions": [{
|
||||
"type": "addDirectories",
|
||||
"directories": ["./logs"],
|
||||
"destination": "session"
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("permission request hook output should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed.invalid_reason,
|
||||
Some(
|
||||
"PermissionRequest hook returned updatedPermissions for deny decision".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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ use serde_json::Value;
|
||||
pub(crate) struct GeneratedHookSchemas {
|
||||
pub post_tool_use_command_input: Value,
|
||||
pub post_tool_use_command_output: Value,
|
||||
pub permission_request_command_input: Value,
|
||||
pub permission_request_command_output: Value,
|
||||
pub pre_tool_use_command_input: Value,
|
||||
pub pre_tool_use_command_output: Value,
|
||||
pub session_start_command_input: Value,
|
||||
@@ -27,6 +29,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
|
||||
"post-tool-use.command.output",
|
||||
include_str!("../../schema/generated/post-tool-use.command.output.schema.json"),
|
||||
),
|
||||
permission_request_command_input: parse_json_schema(
|
||||
"permission-request.command.input",
|
||||
include_str!("../../schema/generated/permission-request.command.input.schema.json"),
|
||||
),
|
||||
permission_request_command_output: parse_json_schema(
|
||||
"permission-request.command.output",
|
||||
include_str!("../../schema/generated/permission-request.command.output.schema.json"),
|
||||
),
|
||||
pre_tool_use_command_input: parse_json_schema(
|
||||
"pre-tool-use.command.input",
|
||||
include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"),
|
||||
@@ -78,6 +88,8 @@ mod tests {
|
||||
|
||||
assert_eq!(schemas.post_tool_use_command_input["type"], "object");
|
||||
assert_eq!(schemas.post_tool_use_command_output["type"], "object");
|
||||
assert_eq!(schemas.permission_request_command_input["type"], "object");
|
||||
assert_eq!(schemas.permission_request_command_output["type"], "object");
|
||||
assert_eq!(schemas.pre_tool_use_command_input["type"], "object");
|
||||
assert_eq!(schemas.pre_tool_use_command_output["type"], "object");
|
||||
assert_eq!(schemas.session_start_command_input["type"], "object");
|
||||
|
||||
@@ -100,9 +100,10 @@ pub(crate) fn matcher_pattern_for_event(
|
||||
matcher: Option<&str>,
|
||||
) -> Option<&str> {
|
||||
match event_name {
|
||||
HookEventName::PreToolUse | HookEventName::PostToolUse | HookEventName::SessionStart => {
|
||||
matcher
|
||||
}
|
||||
HookEventName::PreToolUse
|
||||
| HookEventName::PermissionRequest
|
||||
| HookEventName::PostToolUse
|
||||
| HookEventName::SessionStart => matcher,
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod common;
|
||||
pub mod permission_request;
|
||||
pub mod post_tool_use;
|
||||
pub mod pre_tool_use;
|
||||
pub mod session_start;
|
||||
|
||||
579
codex-rs/hooks/src/events/permission_request.rs
Normal file
579
codex-rs/hooks/src/events/permission_request.rs
Normal file
@@ -0,0 +1,579 @@
|
||||
//! Permission-request hook execution.
|
||||
//!
|
||||
//! This event runs in the approval path, before guardian or user approval UI is
|
||||
//! shown. Handlers can return a concrete allow/deny decision, optionally
|
||||
//! rewriting the hook-visible tool input for allow decisions, or decline to
|
||||
//! decide and let the normal approval flow continue.
|
||||
//!
|
||||
//! The event also mirrors the rest of the hook system's lifecycle:
|
||||
//!
|
||||
//! 1. Preview matching handlers so the UI can render pending hook rows.
|
||||
//! 2. Execute every matching handler in precedence order.
|
||||
//! 3. Parse each handler into transcript-visible output plus an optional
|
||||
//! decision.
|
||||
//! 4. Fold the decisions conservatively: any deny wins, otherwise allow if at
|
||||
//! least one handler allowed the request, and accumulate the selected
|
||||
//! permission updates, otherwise there is no hook verdict.
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::common;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
use crate::engine::dispatcher;
|
||||
use crate::engine::output_parser;
|
||||
use crate::schema::PermissionRequestCommandInput;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
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::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PermissionUpdate {
|
||||
AddRules {
|
||||
rules: Vec<PermissionUpdateRule>,
|
||||
behavior: PermissionUpdateBehavior,
|
||||
destination: PermissionUpdateDestination,
|
||||
},
|
||||
AddDirectories {
|
||||
directories: Vec<String>,
|
||||
destination: PermissionUpdateDestination,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PermissionUpdateBehavior {
|
||||
Allow,
|
||||
Deny,
|
||||
Ask,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub enum PermissionUpdateDestination {
|
||||
#[serde(rename = "session")]
|
||||
Session,
|
||||
#[serde(rename = "projectSettings")]
|
||||
ProjectSettings,
|
||||
#[serde(rename = "userSettings")]
|
||||
UserSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PermissionUpdateRule {
|
||||
PrefixRule { command: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermissionRequestRequest {
|
||||
pub session_id: ThreadId,
|
||||
pub turn_id: String,
|
||||
pub cwd: PathBuf,
|
||||
pub transcript_path: Option<PathBuf>,
|
||||
pub model: String,
|
||||
pub permission_mode: String,
|
||||
pub tool_name: String,
|
||||
pub run_id_suffix: String,
|
||||
pub command: String,
|
||||
pub description: Option<String>,
|
||||
pub permission_suggestions: Vec<PermissionUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PermissionRequestToolInput {
|
||||
pub command: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PermissionRequestDecision {
|
||||
Allow {
|
||||
updated_permissions: Vec<PermissionUpdate>,
|
||||
updated_input: Option<PermissionRequestToolInput>,
|
||||
},
|
||||
Deny {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PermissionRequestOutcome {
|
||||
pub hook_events: Vec<HookCompletedEvent>,
|
||||
pub decision: Option<PermissionRequestDecision>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct PermissionRequestHandlerData {
|
||||
decision: Option<PermissionRequestDecision>,
|
||||
}
|
||||
|
||||
pub(crate) fn preview(
|
||||
handlers: &[ConfiguredHandler],
|
||||
request: &PermissionRequestRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::PermissionRequest,
|
||||
Some(&request.tool_name),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|handler| {
|
||||
common::hook_run_for_tool_use(
|
||||
dispatcher::running_summary(&handler),
|
||||
&request.run_id_suffix,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
request: PermissionRequestRequest,
|
||||
) -> PermissionRequestOutcome {
|
||||
let matched = dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::PermissionRequest,
|
||||
Some(&request.tool_name),
|
||||
);
|
||||
if matched.is_empty() {
|
||||
return PermissionRequestOutcome {
|
||||
hook_events: Vec::new(),
|
||||
decision: None,
|
||||
};
|
||||
}
|
||||
|
||||
let input_json = match serde_json::to_string(&build_command_input(&request)) {
|
||||
Ok(input_json) => input_json,
|
||||
Err(error) => {
|
||||
let hook_events = common::serialization_failure_hook_events_for_tool_use(
|
||||
matched,
|
||||
Some(request.turn_id.clone()),
|
||||
format!("failed to serialize permission request hook input: {error}"),
|
||||
&request.run_id_suffix,
|
||||
);
|
||||
return PermissionRequestOutcome {
|
||||
hook_events,
|
||||
decision: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let mut results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id.clone()),
|
||||
parse_completed,
|
||||
)
|
||||
.await;
|
||||
|
||||
for result in &mut results {
|
||||
if let Some(invalid_reason) = invalid_permission_updates(
|
||||
result.data.decision.as_ref(),
|
||||
&request.permission_suggestions,
|
||||
) {
|
||||
result.completed.run.status = HookRunStatus::Failed;
|
||||
result.completed.run.entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: invalid_reason,
|
||||
});
|
||||
result.data.decision = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Any deny wins immediately. Otherwise, accumulate the selected permission
|
||||
// updates from matching allow decisions in precedence order.
|
||||
let decision = resolve_permission_request_decision(
|
||||
results
|
||||
.iter()
|
||||
.filter_map(|result| result.data.decision.as_ref()),
|
||||
);
|
||||
|
||||
PermissionRequestOutcome {
|
||||
hook_events: results
|
||||
.into_iter()
|
||||
.map(|result| {
|
||||
common::hook_completed_for_tool_use(result.completed, &request.run_id_suffix)
|
||||
})
|
||||
.collect(),
|
||||
decision,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve matching hook decisions conservatively: any deny wins immediately;
|
||||
/// otherwise allow if any handler allowed the request and accumulate the
|
||||
/// distinct selected permission updates in precedence order.
|
||||
fn resolve_permission_request_decision<'a>(
|
||||
decisions: impl IntoIterator<Item = &'a PermissionRequestDecision>,
|
||||
) -> Option<PermissionRequestDecision> {
|
||||
let mut saw_allow = false;
|
||||
let mut updated_permissions = Vec::new();
|
||||
let mut updated_input = None;
|
||||
for decision in decisions {
|
||||
match decision {
|
||||
PermissionRequestDecision::Allow {
|
||||
updated_permissions: selected_permissions,
|
||||
updated_input: selected_input,
|
||||
} => {
|
||||
saw_allow = true;
|
||||
for permission in selected_permissions {
|
||||
if !updated_permissions.contains(permission) {
|
||||
updated_permissions.push(permission.clone());
|
||||
}
|
||||
}
|
||||
if let Some(selected_input) = selected_input {
|
||||
updated_input = Some(selected_input.clone());
|
||||
}
|
||||
}
|
||||
PermissionRequestDecision::Deny { message } => {
|
||||
return Some(PermissionRequestDecision::Deny {
|
||||
message: message.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
saw_allow.then_some(PermissionRequestDecision::Allow {
|
||||
updated_permissions,
|
||||
updated_input,
|
||||
})
|
||||
}
|
||||
|
||||
fn invalid_permission_updates(
|
||||
decision: Option<&PermissionRequestDecision>,
|
||||
offered_permissions: &[PermissionUpdate],
|
||||
) -> Option<String> {
|
||||
let PermissionRequestDecision::Allow {
|
||||
updated_permissions,
|
||||
updated_input: _,
|
||||
} = decision?
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if updated_permissions
|
||||
.iter()
|
||||
.any(|permission| !offered_permissions.contains(permission))
|
||||
{
|
||||
Some("PermissionRequest hook returned updatedPermissions that were not offered".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestCommandInput {
|
||||
PermissionRequestCommandInput {
|
||||
session_id: request.session_id.to_string(),
|
||||
turn_id: request.turn_id.clone(),
|
||||
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
|
||||
cwd: request.cwd.display().to_string(),
|
||||
hook_event_name: "PermissionRequest".to_string(),
|
||||
model: request.model.clone(),
|
||||
permission_mode: request.permission_mode.clone(),
|
||||
tool_name: request.tool_name.clone(),
|
||||
tool_input: PermissionRequestToolInput {
|
||||
command: request.command.clone(),
|
||||
description: request.description.clone(),
|
||||
},
|
||||
permission_suggestions: (!request.permission_suggestions.is_empty())
|
||||
.then_some(request.permission_suggestions.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_completed(
|
||||
handler: &ConfiguredHandler,
|
||||
run_result: CommandRunResult,
|
||||
turn_id: Option<String>,
|
||||
) -> dispatcher::ParsedHandler<PermissionRequestHandlerData> {
|
||||
let mut entries = Vec::new();
|
||||
let mut status = HookRunStatus::Completed;
|
||||
let mut decision = None;
|
||||
|
||||
match run_result.error.as_deref() {
|
||||
Some(error) => {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: error.to_string(),
|
||||
});
|
||||
}
|
||||
None => match run_result.exit_code {
|
||||
Some(0) => {
|
||||
let trimmed_stdout = run_result.stdout.trim();
|
||||
if trimmed_stdout.is_empty() {
|
||||
} else if let Some(parsed) =
|
||||
output_parser::parse_permission_request(&run_result.stdout)
|
||||
{
|
||||
if let Some(system_message) = parsed.universal.system_message {
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: system_message,
|
||||
});
|
||||
}
|
||||
if let Some(invalid_reason) = parsed.invalid_reason {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: invalid_reason,
|
||||
});
|
||||
} else if let Some(parsed_decision) = parsed.decision {
|
||||
match parsed_decision {
|
||||
output_parser::PermissionRequestDecision::Allow {
|
||||
updated_permissions,
|
||||
updated_input,
|
||||
} => {
|
||||
decision = Some(PermissionRequestDecision::Allow {
|
||||
updated_permissions,
|
||||
updated_input,
|
||||
});
|
||||
}
|
||||
output_parser::PermissionRequestDecision::Deny { message } => {
|
||||
status = HookRunStatus::Blocked;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: message.clone(),
|
||||
});
|
||||
decision = Some(PermissionRequestDecision::Deny { message });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned invalid permission-request JSON output".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(2) => {
|
||||
if let Some(message) = common::trimmed_non_empty(&run_result.stderr) {
|
||||
status = HookRunStatus::Blocked;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: message.clone(),
|
||||
});
|
||||
decision = Some(PermissionRequestDecision::Deny { message });
|
||||
} else {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "PermissionRequest hook exited with code 2 but did not write a denial reason to stderr".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(exit_code) => {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: format!("hook exited with code {exit_code}"),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook exited without a status code".to_string(),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let completed = HookCompletedEvent {
|
||||
turn_id,
|
||||
run: dispatcher::completed_summary(handler, &run_result, status, entries),
|
||||
};
|
||||
|
||||
dispatcher::ParsedHandler {
|
||||
completed,
|
||||
data: PermissionRequestHandlerData { decision },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::PermissionRequestDecision;
|
||||
use super::PermissionRequestToolInput;
|
||||
use super::PermissionUpdate;
|
||||
use super::PermissionUpdateBehavior;
|
||||
use super::PermissionUpdateDestination;
|
||||
use super::PermissionUpdateRule;
|
||||
use super::invalid_permission_updates;
|
||||
use super::resolve_permission_request_decision;
|
||||
|
||||
#[test]
|
||||
fn permission_request_deny_overrides_earlier_allow() {
|
||||
let decisions = [
|
||||
PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![],
|
||||
updated_input: None,
|
||||
},
|
||||
PermissionRequestDecision::Deny {
|
||||
message: "repo deny".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_permission_request_decision(decisions.iter()),
|
||||
Some(PermissionRequestDecision::Deny {
|
||||
message: "repo deny".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_returns_allow_when_no_handler_denies() {
|
||||
let decisions = [
|
||||
PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![],
|
||||
updated_input: None,
|
||||
},
|
||||
PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![],
|
||||
updated_input: None,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_permission_request_decision(decisions.iter()),
|
||||
Some(PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![],
|
||||
updated_input: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_returns_none_when_no_handler_decides() {
|
||||
let decisions = Vec::<PermissionRequestDecision>::new();
|
||||
|
||||
assert_eq!(resolve_permission_request_decision(decisions.iter()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_accumulates_distinct_updated_permissions() {
|
||||
let permission = PermissionUpdate::AddRules {
|
||||
rules: vec![PermissionUpdateRule::PrefixRule {
|
||||
command: vec!["rm".to_string(), "-f".to_string()],
|
||||
}],
|
||||
behavior: PermissionUpdateBehavior::Allow,
|
||||
destination: PermissionUpdateDestination::UserSettings,
|
||||
};
|
||||
let decisions = [
|
||||
PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![permission.clone()],
|
||||
updated_input: None,
|
||||
},
|
||||
PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![permission.clone()],
|
||||
updated_input: None,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_permission_request_decision(decisions.iter()),
|
||||
Some(PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![permission],
|
||||
updated_input: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_accumulates_distinct_directory_updates() {
|
||||
let directory_update = PermissionUpdate::AddDirectories {
|
||||
directories: vec!["./logs".to_string(), "/tmp/output".to_string()],
|
||||
destination: PermissionUpdateDestination::Session,
|
||||
};
|
||||
let decisions = [
|
||||
PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![directory_update.clone()],
|
||||
updated_input: None,
|
||||
},
|
||||
PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![directory_update.clone()],
|
||||
updated_input: None,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_permission_request_decision(decisions.iter()),
|
||||
Some(PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![directory_update],
|
||||
updated_input: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_uses_last_updated_input_from_allows() {
|
||||
let decisions = [
|
||||
PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![],
|
||||
updated_input: Some(PermissionRequestToolInput {
|
||||
command: "echo first".to_string(),
|
||||
description: Some("first".to_string()),
|
||||
}),
|
||||
},
|
||||
PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![],
|
||||
updated_input: Some(PermissionRequestToolInput {
|
||||
command: "echo second".to_string(),
|
||||
description: Some("second".to_string()),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_permission_request_decision(decisions.iter()),
|
||||
Some(PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![],
|
||||
updated_input: Some(PermissionRequestToolInput {
|
||||
command: "echo second".to_string(),
|
||||
description: Some("second".to_string()),
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_rejects_unoffered_updated_permissions() {
|
||||
let offered = vec![PermissionUpdate::AddRules {
|
||||
rules: vec![PermissionUpdateRule::PrefixRule {
|
||||
command: vec!["rm".to_string()],
|
||||
}],
|
||||
behavior: PermissionUpdateBehavior::Allow,
|
||||
destination: PermissionUpdateDestination::UserSettings,
|
||||
}];
|
||||
let selected = PermissionRequestDecision::Allow {
|
||||
updated_permissions: vec![PermissionUpdate::AddRules {
|
||||
rules: vec![PermissionUpdateRule::PrefixRule {
|
||||
command: vec!["curl".to_string()],
|
||||
}],
|
||||
behavior: PermissionUpdateBehavior::Allow,
|
||||
destination: PermissionUpdateDestination::UserSettings,
|
||||
}],
|
||||
updated_input: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
invalid_permission_updates(Some(&selected), &offered),
|
||||
Some(
|
||||
"PermissionRequest hook returned updatedPermissions that were not offered"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,14 @@ mod registry;
|
||||
mod schema;
|
||||
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::PermissionRequestToolInput;
|
||||
pub use events::permission_request::PermissionUpdate;
|
||||
pub use events::permission_request::PermissionUpdateBehavior;
|
||||
pub use events::permission_request::PermissionUpdateDestination;
|
||||
pub use events::permission_request::PermissionUpdateRule;
|
||||
pub use events::post_tool_use::PostToolUseOutcome;
|
||||
pub use events::post_tool_use::PostToolUseRequest;
|
||||
pub use events::pre_tool_use::PreToolUseOutcome;
|
||||
|
||||
@@ -3,6 +3,8 @@ use tokio::process::Command;
|
||||
|
||||
use crate::engine::ClaudeHooksEngine;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::events::permission_request::PermissionRequestOutcome;
|
||||
use crate::events::permission_request::PermissionRequestRequest;
|
||||
use crate::events::post_tool_use::PostToolUseOutcome;
|
||||
use crate::events::post_tool_use::PostToolUseRequest;
|
||||
use crate::events::pre_tool_use::PreToolUseOutcome;
|
||||
@@ -103,6 +105,13 @@ impl Hooks {
|
||||
self.engine.preview_pre_tool_use(request)
|
||||
}
|
||||
|
||||
pub fn preview_permission_request(
|
||||
&self,
|
||||
request: &PermissionRequestRequest,
|
||||
) -> Vec<codex_protocol::protocol::HookRunSummary> {
|
||||
self.engine.preview_permission_request(request)
|
||||
}
|
||||
|
||||
pub fn preview_post_tool_use(
|
||||
&self,
|
||||
request: &PostToolUseRequest,
|
||||
@@ -122,6 +131,13 @@ impl Hooks {
|
||||
self.engine.run_pre_tool_use(request).await
|
||||
}
|
||||
|
||||
pub async fn run_permission_request(
|
||||
&self,
|
||||
request: PermissionRequestRequest,
|
||||
) -> PermissionRequestOutcome {
|
||||
self.engine.run_permission_request(request).await
|
||||
}
|
||||
|
||||
pub async fn run_post_tool_use(&self, request: PostToolUseRequest) -> PostToolUseOutcome {
|
||||
self.engine.run_post_tool_use(request).await
|
||||
}
|
||||
|
||||
@@ -12,9 +12,14 @@ use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::events::permission_request::PermissionRequestToolInput;
|
||||
use crate::events::permission_request::PermissionUpdate;
|
||||
|
||||
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";
|
||||
const PERMISSION_REQUEST_INPUT_FIXTURE: &str = "permission-request.command.input.schema.json";
|
||||
const PERMISSION_REQUEST_OUTPUT_FIXTURE: &str = "permission-request.command.output.schema.json";
|
||||
const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json";
|
||||
const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json";
|
||||
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
|
||||
@@ -69,6 +74,8 @@ pub(crate) struct HookUniversalOutputWire {
|
||||
pub(crate) enum HookEventNameWire {
|
||||
#[serde(rename = "PreToolUse")]
|
||||
PreToolUse,
|
||||
#[serde(rename = "PermissionRequest")]
|
||||
PermissionRequest,
|
||||
#[serde(rename = "PostToolUse")]
|
||||
PostToolUse,
|
||||
#[serde(rename = "SessionStart")]
|
||||
@@ -109,6 +116,52 @@ pub(crate) struct PostToolUseCommandOutputWire {
|
||||
pub hook_specific_output: Option<PostToolUseHookSpecificOutputWire>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "permission-request.command.output")]
|
||||
pub(crate) struct PermissionRequestCommandOutputWire {
|
||||
#[serde(flatten)]
|
||||
pub universal: HookUniversalOutputWire,
|
||||
#[serde(default)]
|
||||
pub hook_specific_output: Option<PermissionRequestHookSpecificOutputWire>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PermissionRequestHookSpecificOutputWire {
|
||||
pub hook_event_name: HookEventNameWire,
|
||||
#[serde(default)]
|
||||
pub decision: Option<PermissionRequestDecisionWire>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PermissionRequestDecisionWire {
|
||||
pub behavior: PermissionRequestBehaviorWire,
|
||||
#[serde(default)]
|
||||
pub updated_input: Option<PermissionRequestToolInput>,
|
||||
#[serde(default)]
|
||||
pub updated_permissions: Option<Vec<PermissionUpdate>>,
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
/// Reserved for future short-circuiting semantics.
|
||||
///
|
||||
/// PermissionRequest hooks currently fail closed if this field is `true`.
|
||||
#[serde(default)]
|
||||
pub interrupt: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub(crate) enum PermissionRequestBehaviorWire {
|
||||
#[serde(rename = "allow")]
|
||||
Allow,
|
||||
#[serde(rename = "deny")]
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -181,6 +234,27 @@ pub(crate) struct PreToolUseCommandInput {
|
||||
pub tool_use_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "permission-request.command.input")]
|
||||
pub(crate) struct PermissionRequestCommandInput {
|
||||
pub session_id: String,
|
||||
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
|
||||
pub turn_id: String,
|
||||
pub transcript_path: NullableString,
|
||||
pub cwd: String,
|
||||
#[schemars(schema_with = "permission_request_hook_event_name_schema")]
|
||||
pub hook_event_name: String,
|
||||
pub model: String,
|
||||
#[schemars(schema_with = "permission_mode_schema")]
|
||||
pub permission_mode: String,
|
||||
#[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<PermissionUpdate>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -358,6 +432,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
|
||||
&generated_dir.join(POST_TOOL_USE_OUTPUT_FIXTURE),
|
||||
schema_json::<PostToolUseCommandOutputWire>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(PERMISSION_REQUEST_INPUT_FIXTURE),
|
||||
schema_json::<PermissionRequestCommandInput>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(PERMISSION_REQUEST_OUTPUT_FIXTURE),
|
||||
schema_json::<PermissionRequestCommandOutputWire>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE),
|
||||
schema_json::<PreToolUseCommandInput>()?,
|
||||
@@ -461,10 +543,18 @@ fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("PreToolUse")
|
||||
}
|
||||
|
||||
fn permission_request_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("PermissionRequest")
|
||||
}
|
||||
|
||||
fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("Bash")
|
||||
}
|
||||
|
||||
fn permission_request_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("Bash")
|
||||
}
|
||||
|
||||
fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("UserPromptSubmit")
|
||||
}
|
||||
@@ -516,10 +606,13 @@ fn default_continue() -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PERMISSION_REQUEST_INPUT_FIXTURE;
|
||||
use super::PERMISSION_REQUEST_OUTPUT_FIXTURE;
|
||||
use super::POST_TOOL_USE_INPUT_FIXTURE;
|
||||
use super::POST_TOOL_USE_OUTPUT_FIXTURE;
|
||||
use super::PRE_TOOL_USE_INPUT_FIXTURE;
|
||||
use super::PRE_TOOL_USE_OUTPUT_FIXTURE;
|
||||
use super::PermissionRequestCommandInput;
|
||||
use super::PostToolUseCommandInput;
|
||||
use super::PreToolUseCommandInput;
|
||||
use super::SESSION_START_INPUT_FIXTURE;
|
||||
@@ -544,6 +637,12 @@ mod tests {
|
||||
POST_TOOL_USE_OUTPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/post-tool-use.command.output.schema.json")
|
||||
}
|
||||
PERMISSION_REQUEST_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/permission-request.command.input.schema.json")
|
||||
}
|
||||
PERMISSION_REQUEST_OUTPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/permission-request.command.output.schema.json")
|
||||
}
|
||||
PRE_TOOL_USE_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/pre-tool-use.command.input.schema.json")
|
||||
}
|
||||
@@ -585,6 +684,8 @@ mod tests {
|
||||
for fixture in [
|
||||
POST_TOOL_USE_INPUT_FIXTURE,
|
||||
POST_TOOL_USE_OUTPUT_FIXTURE,
|
||||
PERMISSION_REQUEST_INPUT_FIXTURE,
|
||||
PERMISSION_REQUEST_OUTPUT_FIXTURE,
|
||||
PRE_TOOL_USE_INPUT_FIXTURE,
|
||||
PRE_TOOL_USE_OUTPUT_FIXTURE,
|
||||
SESSION_START_INPUT_FIXTURE,
|
||||
@@ -615,6 +716,11 @@ mod tests {
|
||||
.expect("serialize post tool use input schema"),
|
||||
)
|
||||
.expect("parse post tool use input schema");
|
||||
let permission_request: Value = serde_json::from_slice(
|
||||
&schema_json::<PermissionRequestCommandInput>()
|
||||
.expect("serialize permission request input schema"),
|
||||
)
|
||||
.expect("parse permission request input schema");
|
||||
let user_prompt_submit: Value = serde_json::from_slice(
|
||||
&schema_json::<UserPromptSubmitCommandInput>()
|
||||
.expect("serialize user prompt submit input schema"),
|
||||
@@ -625,7 +731,13 @@ mod tests {
|
||||
)
|
||||
.expect("parse stop input schema");
|
||||
|
||||
for schema in [&pre_tool_use, &post_tool_use, &user_prompt_submit, &stop] {
|
||||
for schema in [
|
||||
&pre_tool_use,
|
||||
&permission_request,
|
||||
&post_tool_use,
|
||||
&user_prompt_submit,
|
||||
&stop,
|
||||
] {
|
||||
assert_eq!(schema["properties"]["turn_id"]["type"], "string");
|
||||
assert!(
|
||||
schema["required"]
|
||||
|
||||
@@ -1573,6 +1573,7 @@ pub enum EventMsg {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEventName {
|
||||
PreToolUse,
|
||||
PermissionRequest,
|
||||
PostToolUse,
|
||||
SessionStart,
|
||||
UserPromptSubmit,
|
||||
|
||||
@@ -1064,6 +1064,7 @@ pub(super) async fn assert_hook_events_snapshot(
|
||||
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
|
||||
match event_name {
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse",
|
||||
codex_protocol::protocol::HookEventName::PermissionRequest => "PermissionRequest",
|
||||
codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
|
||||
@@ -701,6 +701,7 @@ fn hook_output_prefix(kind: HookOutputEntryKind) -> &'static str {
|
||||
fn hook_event_label(event_name: HookEventName) -> &'static str {
|
||||
match event_name {
|
||||
HookEventName::PreToolUse => "PreToolUse",
|
||||
HookEventName::PermissionRequest => "PermissionRequest",
|
||||
HookEventName::PostToolUse => "PostToolUse",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
|
||||
Reference in New Issue
Block a user