mirror of
https://github.com/openai/codex.git
synced 2026-04-18 03:34:50 +00:00
Compare commits
30 Commits
dev/realti
...
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 | ||
|
|
4db60d5d8b | ||
|
|
1288bb60a1 | ||
|
|
3895ddd6b1 | ||
|
|
ba839c23f3 |
@@ -10,6 +10,7 @@ ENV TZ="$TZ"
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Devcontainers run as a non-root user, so enable bubblewrap's setuid mode.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
@@ -37,6 +38,8 @@ RUN apt-get update \
|
||||
ipset \
|
||||
iptables \
|
||||
aggregate \
|
||||
bubblewrap \
|
||||
&& chmod u+s /usr/bin/bwrap \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ Use `devcontainer.json` when you are developing Codex itself. This is the same l
|
||||
Use `devcontainer.secure.json` when you want a stricter runtime profile for running Codex inside a project container:
|
||||
|
||||
- installs the Codex CLI plus common build tools
|
||||
- installs bubblewrap in setuid mode for Codex's Linux sandbox
|
||||
- disables Docker's outer seccomp and AppArmor profiles so bubblewrap can construct Codex's inner sandbox
|
||||
- enables firewall startup with an allowlist-driven outbound policy
|
||||
- blocks IPv6 by default so the allowlist cannot be bypassed over AAAA routes
|
||||
- requires `NET_ADMIN` and `NET_RAW` so the firewall can be installed at startup
|
||||
@@ -43,3 +45,5 @@ Note that `/workspace/target` will contain the binaries built for your host plat
|
||||
For arm64, specify `--platform=linux/arm64` instead for both `docker build` and `docker run`.
|
||||
|
||||
Currently, the contributor `Dockerfile` works for both x64 and arm64 Linux, though you need to run `rustup target add x86_64-unknown-linux-musl` yourself to install the musl toolchain for x64.
|
||||
|
||||
The secure profile's capability, seccomp, and AppArmor options are required when you want Codex's bubblewrap sandbox to run inside Docker as the non-root devcontainer user. Without them, Docker's default runtime profile can block bubblewrap's namespace setup before Codex's own seccomp filter is installed. This keeps the Docker relaxation explicit in the profile that is meant to run Codex inside a project container, while the default contributor profile stays lightweight.
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
}
|
||||
},
|
||||
"runArgs": [
|
||||
"--cap-add=SYS_ADMIN",
|
||||
"--cap-add=SYS_CHROOT",
|
||||
"--cap-add=SETUID",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SYS_PTRACE",
|
||||
"--security-opt=seccomp=unconfined",
|
||||
"--security-opt=apparmor=unconfined",
|
||||
"--cap-add=NET_ADMIN",
|
||||
"--cap-add=NET_RAW"
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,6 +26,7 @@ pub(crate) use approval_request::GuardianApprovalRequest;
|
||||
pub(crate) use approval_request::GuardianMcpAnnotations;
|
||||
pub(crate) use approval_request::guardian_approval_request_to_json;
|
||||
pub(crate) use review::guardian_rejection_message;
|
||||
pub(crate) use review::guardian_timeout_message;
|
||||
pub(crate) use review::is_guardian_reviewer_source;
|
||||
pub(crate) use review::new_guardian_review_id;
|
||||
pub(crate) use review::review_approval_request;
|
||||
|
||||
@@ -38,6 +38,12 @@ const GUARDIAN_REJECTION_INSTRUCTIONS: &str = concat!(
|
||||
"Otherwise, stop and request user input.",
|
||||
);
|
||||
|
||||
const GUARDIAN_TIMEOUT_INSTRUCTIONS: &str = concat!(
|
||||
"The automatic permission approval review did not finish before its deadline. ",
|
||||
"Do not assume the action is unsafe based on the timeout alone. ",
|
||||
"You may retry once, or ask the user for guidance or explicit approval.",
|
||||
);
|
||||
|
||||
pub(crate) fn new_guardian_review_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
@@ -63,6 +69,10 @@ pub(crate) async fn guardian_rejection_message(session: &Session, review_id: &st
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn guardian_timeout_message() -> String {
|
||||
GUARDIAN_TIMEOUT_INSTRUCTIONS.to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum GuardianReviewOutcome {
|
||||
Completed(anyhow::Result<GuardianAssessment>),
|
||||
@@ -97,8 +107,9 @@ pub(crate) fn is_guardian_reviewer_source(
|
||||
)
|
||||
}
|
||||
|
||||
/// This function always fails closed: any timeout, review-session failure, or
|
||||
/// parse failure is treated as a high-risk denial.
|
||||
/// This function always fails closed: timeouts, review-session failures, and
|
||||
/// parse failures all block execution, but timeouts are still surfaced to the
|
||||
/// caller as distinct from explicit guardian denials.
|
||||
async fn run_guardian_review(
|
||||
session: Arc<Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
@@ -170,14 +181,36 @@ async fn run_guardian_review(
|
||||
outcome: GuardianAssessmentOutcome::Deny,
|
||||
rationale: format!("Automatic approval review failed: {err}"),
|
||||
},
|
||||
GuardianReviewOutcome::TimedOut => GuardianAssessment {
|
||||
risk_level: GuardianRiskLevel::High,
|
||||
user_authorization: GuardianUserAuthorization::Unknown,
|
||||
outcome: GuardianAssessmentOutcome::Deny,
|
||||
rationale:
|
||||
GuardianReviewOutcome::TimedOut => {
|
||||
let rationale =
|
||||
"Automatic approval review timed out while evaluating the requested approval."
|
||||
.to_string(),
|
||||
},
|
||||
.to_string();
|
||||
session
|
||||
.send_event(
|
||||
turn.as_ref(),
|
||||
EventMsg::Warning(WarningEvent {
|
||||
message: rationale.clone(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.send_event(
|
||||
turn.as_ref(),
|
||||
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
id: review_id,
|
||||
target_item_id,
|
||||
turn_id: assessment_turn_id,
|
||||
status: GuardianAssessmentStatus::TimedOut,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: Some(rationale),
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action: terminal_action,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
return ReviewDecision::TimedOut;
|
||||
}
|
||||
GuardianReviewOutcome::Aborted => {
|
||||
session
|
||||
.send_event(
|
||||
|
||||
@@ -718,6 +718,14 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
|
||||
assert!(warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_timeout_message_distinguishes_timeout_from_policy_denial() {
|
||||
let message = guardian_timeout_message();
|
||||
assert!(message.contains("did not finish before its deadline"));
|
||||
assert!(message.contains("retry once"));
|
||||
assert!(!message.contains("unacceptable risk"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routes_approval_to_guardian_requires_auto_only_review_policy() {
|
||||
let (_session, mut turn) = crate::codex::make_session_and_context().await;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::GuardianMcpAnnotations;
|
||||
use crate::guardian::guardian_approval_request_to_json;
|
||||
use crate::guardian::guardian_rejection_message;
|
||||
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;
|
||||
@@ -980,9 +981,12 @@ async fn mcp_tool_approval_decision_from_guardian(
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept,
|
||||
ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession,
|
||||
ReviewDecision::Denied | ReviewDecision::TimedOut => McpToolApprovalDecision::Decline {
|
||||
ReviewDecision::Denied => McpToolApprovalDecision::Decline {
|
||||
message: Some(guardian_rejection_message(sess, review_id).await),
|
||||
},
|
||||
ReviewDecision::TimedOut => McpToolApprovalDecision::Decline {
|
||||
message: Some(guardian_timeout_message()),
|
||||
},
|
||||
ReviewDecision::Abort => McpToolApprovalDecision::Decline { message: None },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,6 +843,20 @@ async fn guardian_review_decision_maps_to_mcp_tool_decision() {
|
||||
};
|
||||
assert!(message.contains("Reason: too risky"));
|
||||
assert!(message.contains("The agent must not attempt to achieve the same outcome"));
|
||||
let timeout = mcp_tool_approval_decision_from_guardian(
|
||||
session.as_ref(),
|
||||
"review-id",
|
||||
ReviewDecision::TimedOut,
|
||||
)
|
||||
.await;
|
||||
let McpToolApprovalDecision::Decline {
|
||||
message: Some(message),
|
||||
} = timeout
|
||||
else {
|
||||
panic!("guardian timeout should carry a timeout message");
|
||||
};
|
||||
assert!(message.contains("did not finish before its deadline"));
|
||||
assert!(!message.contains("unacceptable risk"));
|
||||
assert_eq!(
|
||||
mcp_tool_approval_decision_from_guardian(
|
||||
session.as_ref(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::collections::HashSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::DirEntry;
|
||||
use std::io;
|
||||
use std::mem::take;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::debug;
|
||||
@@ -26,7 +27,7 @@ const CURRENT_THREAD_SECTION_TOKEN_BUDGET: usize = 1_200;
|
||||
const RECENT_WORK_SECTION_TOKEN_BUDGET: usize = 2_200;
|
||||
const WORKSPACE_SECTION_TOKEN_BUDGET: usize = 1_600;
|
||||
const NOTES_SECTION_TOKEN_BUDGET: usize = 300;
|
||||
const MAX_CURRENT_THREAD_TURNS: usize = 2;
|
||||
const CURRENT_THREAD_TURN_TOKEN_BUDGET: usize = 300;
|
||||
const MAX_RECENT_THREADS: usize = 40;
|
||||
const MAX_RECENT_WORK_GROUPS: usize = 8;
|
||||
const MAX_CURRENT_CWD_ASKS: usize = 8;
|
||||
@@ -204,10 +205,7 @@ fn build_current_thread_section(items: &[ResponseItem]) -> Option<String> {
|
||||
continue;
|
||||
};
|
||||
if !current_user.is_empty() || !current_assistant.is_empty() {
|
||||
turns.push((
|
||||
std::mem::take(&mut current_user),
|
||||
std::mem::take(&mut current_assistant),
|
||||
));
|
||||
turns.push((take(&mut current_user), take(&mut current_assistant)));
|
||||
}
|
||||
current_user.push(text);
|
||||
}
|
||||
@@ -231,43 +229,75 @@ fn build_current_thread_section(items: &[ResponseItem]) -> Option<String> {
|
||||
turns.push((current_user, current_assistant));
|
||||
}
|
||||
|
||||
let retained_turns = turns
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(MAX_CURRENT_THREAD_TURNS)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>();
|
||||
if retained_turns.is_empty() {
|
||||
if turns.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut lines = vec![
|
||||
"Most recent user/assistant turns from this exact thread. Use them for continuity when responding.".to_string(),
|
||||
];
|
||||
let mut remaining_budget =
|
||||
CURRENT_THREAD_SECTION_TOKEN_BUDGET.saturating_sub(approx_token_count(&lines.join("\n")));
|
||||
let mut retained_turn_count = 0;
|
||||
|
||||
let retained_turn_count = retained_turns.len();
|
||||
for (index, (user_messages, assistant_messages)) in retained_turns.into_iter().enumerate() {
|
||||
lines.push(String::new());
|
||||
if retained_turn_count == 1 || index + 1 == retained_turn_count {
|
||||
lines.push("### Latest turn".to_string());
|
||||
for (index, (user_messages, assistant_messages)) in turns.into_iter().rev().enumerate() {
|
||||
if remaining_budget == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut turn_lines = Vec::new();
|
||||
if index == 0 {
|
||||
turn_lines.push("### Latest turn".to_string());
|
||||
} else {
|
||||
lines.push(format!("### Prior turn {}", index + 1));
|
||||
turn_lines.push(format!("### Previous turn {index}"));
|
||||
}
|
||||
|
||||
if !user_messages.is_empty() {
|
||||
lines.push("User:".to_string());
|
||||
lines.push(user_messages.join("\n\n"));
|
||||
turn_lines.push("User:".to_string());
|
||||
turn_lines.push(user_messages.join("\n\n"));
|
||||
}
|
||||
if !assistant_messages.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("Assistant:".to_string());
|
||||
lines.push(assistant_messages.join("\n\n"));
|
||||
turn_lines.push(String::new());
|
||||
turn_lines.push("Assistant:".to_string());
|
||||
turn_lines.push(assistant_messages.join("\n\n"));
|
||||
}
|
||||
|
||||
let turn_budget = CURRENT_THREAD_TURN_TOKEN_BUDGET.min(remaining_budget);
|
||||
let turn_text = turn_lines.join("\n");
|
||||
let mut truncation_budget = turn_budget;
|
||||
let turn_text = loop {
|
||||
let candidate = truncate_text(&turn_text, TruncationPolicy::Tokens(truncation_budget));
|
||||
let candidate_tokens = approx_token_count(&candidate);
|
||||
if candidate_tokens <= turn_budget {
|
||||
break candidate;
|
||||
}
|
||||
|
||||
// The shared truncator adds its marker after choosing preserved
|
||||
// content, so tighten the content budget until the rendered turn
|
||||
// itself fits the per-turn cap.
|
||||
let excess_tokens = candidate_tokens.saturating_sub(turn_budget);
|
||||
let next_budget = truncation_budget.saturating_sub(excess_tokens.max(1));
|
||||
if next_budget == 0 {
|
||||
let candidate = truncate_text(&turn_text, TruncationPolicy::Tokens(0));
|
||||
if approx_token_count(&candidate) <= turn_budget {
|
||||
break candidate;
|
||||
}
|
||||
break String::new();
|
||||
}
|
||||
truncation_budget = next_budget;
|
||||
};
|
||||
let turn_tokens = approx_token_count(&turn_text);
|
||||
if turn_tokens == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push(turn_text);
|
||||
remaining_budget = remaining_budget.saturating_sub(turn_tokens);
|
||||
retained_turn_count += 1;
|
||||
}
|
||||
|
||||
Some(lines.join("\n"))
|
||||
(retained_turn_count > 0).then(|| lines.join("\n"))
|
||||
}
|
||||
|
||||
fn build_workspace_section_with_user_root(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::build_current_thread_section;
|
||||
use super::build_recent_work_section;
|
||||
use super::build_workspace_section_with_user_root;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_state::ThreadMetadata;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
@@ -43,6 +46,120 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe
|
||||
}
|
||||
}
|
||||
|
||||
fn message(role: &str, content: ContentItem) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: role.to_string(),
|
||||
content: vec![content],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn user_message(text: impl Into<String>) -> ResponseItem {
|
||||
message("user", ContentItem::InputText { text: text.into() })
|
||||
}
|
||||
|
||||
fn assistant_message(text: impl Into<String>) -> ResponseItem {
|
||||
message("assistant", ContentItem::OutputText { text: text.into() })
|
||||
}
|
||||
|
||||
fn long_turn_text(index: usize) -> String {
|
||||
format!(
|
||||
"turn-{index}-start {} turn-{index}-middle {} turn-{index}-end",
|
||||
"head filler ".repeat(160),
|
||||
"tail filler ".repeat(240),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_thread_section_includes_short_turns_newest_first_until_budget() {
|
||||
let items = vec![
|
||||
user_message("user turn 1"),
|
||||
assistant_message("assistant turn 1"),
|
||||
user_message("user turn 2"),
|
||||
assistant_message("assistant turn 2"),
|
||||
user_message("user turn 3"),
|
||||
assistant_message("assistant turn 3"),
|
||||
user_message("user turn 4"),
|
||||
assistant_message("assistant turn 4"),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
build_current_thread_section(&items),
|
||||
Some(
|
||||
r#"Most recent user/assistant turns from this exact thread. Use them for continuity when responding.
|
||||
|
||||
### Latest turn
|
||||
User:
|
||||
user turn 4
|
||||
|
||||
Assistant:
|
||||
assistant turn 4
|
||||
|
||||
### Previous turn 1
|
||||
User:
|
||||
user turn 3
|
||||
|
||||
Assistant:
|
||||
assistant turn 3
|
||||
|
||||
### Previous turn 2
|
||||
User:
|
||||
user turn 2
|
||||
|
||||
Assistant:
|
||||
assistant turn 2
|
||||
|
||||
### Previous turn 3
|
||||
User:
|
||||
user turn 1
|
||||
|
||||
Assistant:
|
||||
assistant turn 1"#
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_thread_turn_truncation_preserves_start_and_end() {
|
||||
let items = vec![user_message(long_turn_text(/*index*/ 0))];
|
||||
let section = build_current_thread_section(&items).expect("current thread section");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
section.contains("turn-0-start"),
|
||||
section.contains("turn-0-middle"),
|
||||
section.contains("turn-0-end"),
|
||||
section.contains("tokens truncated"),
|
||||
),
|
||||
(true, false, true, true),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_thread_section_keeps_latest_turns_when_history_exceeds_budget() {
|
||||
let mut items = Vec::new();
|
||||
for index in 1..=8 {
|
||||
items.push(user_message(long_turn_text(index)));
|
||||
items.push(assistant_message(format!("assistant turn {index}")));
|
||||
}
|
||||
|
||||
let section = build_current_thread_section(&items).expect("current thread section");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
section.contains("turn-8-start"),
|
||||
section.contains("turn-8-end"),
|
||||
section.contains("### Previous turn 2"),
|
||||
section.contains("turn-1-start"),
|
||||
section.contains("turn-1-end"),
|
||||
),
|
||||
(true, true, true, false, false),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_section_requires_meaningful_structure() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use crate::codex::Session;
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::guardian_rejection_message;
|
||||
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;
|
||||
@@ -42,6 +46,7 @@ pub(crate) enum NetworkApprovalMode {
|
||||
pub(crate) struct NetworkApprovalSpec {
|
||||
pub network: Option<NetworkProxy>,
|
||||
pub mode: NetworkApprovalMode,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -171,6 +176,7 @@ impl PendingHostApproval {
|
||||
struct ActiveNetworkApprovalCall {
|
||||
registration_id: String,
|
||||
turn_id: String,
|
||||
command: String,
|
||||
}
|
||||
|
||||
pub(crate) struct NetworkApprovalService {
|
||||
@@ -203,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(
|
||||
@@ -211,6 +217,7 @@ impl NetworkApprovalService {
|
||||
Arc::new(ActiveNetworkApprovalCall {
|
||||
registration_id,
|
||||
turn_id,
|
||||
command,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -370,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() {
|
||||
@@ -391,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(),
|
||||
@@ -488,7 +534,7 @@ impl NetworkApprovalService {
|
||||
PendingApprovalDecision::Deny
|
||||
}
|
||||
},
|
||||
ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort => {
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
if let Some(review_id) = guardian_review_id.as_deref() {
|
||||
if let Some(owner_call) = owner_call.as_ref() {
|
||||
let message = guardian_rejection_message(session.as_ref(), review_id).await;
|
||||
@@ -507,6 +553,16 @@ impl NetworkApprovalService {
|
||||
}
|
||||
PendingApprovalDecision::Deny
|
||||
}
|
||||
ReviewDecision::TimedOut => {
|
||||
if let Some(owner_call) = owner_call.as_ref() {
|
||||
self.record_call_outcome(
|
||||
&owner_call.registration_id,
|
||||
NetworkApprovalOutcome::DeniedByPolicy(guardian_timeout_message()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
PendingApprovalDecision::Deny
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(resolved, PendingApprovalDecision::AllowForSession) {
|
||||
@@ -579,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
|
||||
|
||||
@@ -7,8 +7,10 @@ retry with an escalated sandbox strategy on denial (no re‑approval thanks to
|
||||
caching).
|
||||
*/
|
||||
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;
|
||||
@@ -23,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;
|
||||
@@ -42,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 {
|
||||
@@ -113,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;
|
||||
@@ -126,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));
|
||||
@@ -141,36 +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::TimedOut | 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::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;
|
||||
}
|
||||
}
|
||||
@@ -182,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();
|
||||
@@ -214,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,
|
||||
@@ -297,40 +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::TimedOut
|
||||
| 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::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,
|
||||
@@ -365,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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@ use crate::exec::ExecExpiration;
|
||||
use crate::exec::is_likely_sandbox_denied;
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::guardian_rejection_message;
|
||||
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;
|
||||
@@ -21,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;
|
||||
@@ -319,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(
|
||||
@@ -393,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,
|
||||
@@ -412,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,
|
||||
@@ -431,6 +473,7 @@ impl CoreShellActionProvider {
|
||||
PromptDecision {
|
||||
decision,
|
||||
guardian_review_id: None,
|
||||
rejection_message: None,
|
||||
}
|
||||
})
|
||||
.await)
|
||||
@@ -485,8 +528,12 @@ impl CoreShellActionProvider {
|
||||
EscalationDecision::deny(Some("User denied execution".to_string()))
|
||||
}
|
||||
},
|
||||
ReviewDecision::Denied | ReviewDecision::TimedOut => {
|
||||
let message = if let Some(review_id) =
|
||||
ReviewDecision::Denied => {
|
||||
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
|
||||
@@ -495,6 +542,9 @@ impl CoreShellActionProvider {
|
||||
};
|
||||
EscalationDecision::deny(Some(message))
|
||||
}
|
||||
ReviewDecision::TimedOut => {
|
||||
EscalationDecision::deny(Some(guardian_timeout_message()))
|
||||
}
|
||||
ReviewDecision::Abort => {
|
||||
EscalationDecision::deny(Some("User cancelled execution".to_string()))
|
||||
}
|
||||
|
||||
@@ -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
@@ -2,9 +2,12 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use codex_config::config_toml::RealtimeWsVersion;
|
||||
use codex_core::test_support::auth_manager_from_auth;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::OPENAI_API_KEY_ENV_VAR;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ConversationAudioParams;
|
||||
use codex_protocol::protocol::ConversationStartParams;
|
||||
@@ -12,12 +15,14 @@ use codex_protocol::protocol::ConversationStartTransport;
|
||||
use codex_protocol::protocol::ConversationTextParams;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RealtimeAudioFrame;
|
||||
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
|
||||
use codex_protocol::protocol::RealtimeConversationVersion;
|
||||
use codex_protocol::protocol::RealtimeEvent;
|
||||
use codex_protocol::protocol::RealtimeVoice;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
@@ -1508,6 +1513,165 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn conversation_startup_context_current_thread_selects_many_turns_by_budget() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let api_server = start_mock_server().await;
|
||||
let realtime_server = start_websocket_server(vec![vec![vec![json!({
|
||||
"type": "session.updated",
|
||||
"session": { "id": "sess_current_thread_budget", "instructions": "backend prompt" }
|
||||
})]]])
|
||||
.await;
|
||||
|
||||
let latest_long_user_turn = format!(
|
||||
"latest-long-start {} latest-long-middle {} latest-long-end",
|
||||
"head detail ".repeat(120),
|
||||
"tail detail ".repeat(170),
|
||||
);
|
||||
let mut user_turns = (1..=7)
|
||||
.map(|index| {
|
||||
format!(
|
||||
"short-turn-{index}-start {} short-turn-{index}-end",
|
||||
"detail ".repeat(86)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
user_turns.push(latest_long_user_turn.clone());
|
||||
|
||||
let mut builder = test_codex().with_config({
|
||||
let realtime_base_url = realtime_server.uri().to_string();
|
||||
move |config| {
|
||||
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
|
||||
config.realtime.version = RealtimeWsVersion::V1;
|
||||
}
|
||||
});
|
||||
let test = builder.build(&api_server).await?;
|
||||
|
||||
// Seed completed turns through a resumed thread so this remains an
|
||||
// end-to-end startup-context test without paying for a model turn per
|
||||
// fixture entry in platform CI.
|
||||
let history = user_turns
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(index, user_turn)| {
|
||||
let turn_number = index + 1;
|
||||
let assistant_turn = format!("assistant turn {turn_number}");
|
||||
[
|
||||
RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: user_turn }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}),
|
||||
RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: assistant_turn,
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
let resumed_thread = test
|
||||
.thread_manager
|
||||
.resume_thread_with_history(
|
||||
test.config.clone(),
|
||||
InitialHistory::Forked(history),
|
||||
auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
|
||||
/*persist_extended_history*/ false,
|
||||
/*parent_trace*/ None,
|
||||
)
|
||||
.await?;
|
||||
let codex = resumed_thread.thread;
|
||||
|
||||
codex
|
||||
.submit(Op::RealtimeConversationStart(ConversationStartParams {
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
session_id: None,
|
||||
transport: None,
|
||||
voice: None,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let startup_context_request = wait_for_matching_websocket_request(
|
||||
&realtime_server,
|
||||
"current thread budget startup context request with instructions",
|
||||
|request| websocket_request_instructions(request).is_some(),
|
||||
)
|
||||
.await;
|
||||
let startup_context = websocket_request_instructions(&startup_context_request)
|
||||
.expect("startup context request should contain instructions");
|
||||
|
||||
// Isolate only the Current Thread section; the startup prompt may also include
|
||||
// workspace and notes sections after it.
|
||||
let current_thread_start = startup_context
|
||||
.find("## Current Thread")
|
||||
.expect("startup context should include current thread section");
|
||||
let current_thread_and_rest = &startup_context[current_thread_start..];
|
||||
let current_thread_end = [
|
||||
"\n## Recent Work",
|
||||
"\n## Machine / Workspace Map",
|
||||
"\n## Notes",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|marker| current_thread_and_rest.find(marker))
|
||||
.min()
|
||||
.unwrap_or(current_thread_and_rest.len());
|
||||
let current_thread = ¤t_thread_and_rest[..current_thread_end];
|
||||
|
||||
let rendered_turns = current_thread
|
||||
.split("\n### ")
|
||||
.skip(1)
|
||||
.map(|turn| format!("### {turn}"))
|
||||
.collect::<Vec<_>>();
|
||||
let over_budget_turns = rendered_turns
|
||||
.iter()
|
||||
.filter_map(|turn| {
|
||||
let token_count = turn.len().div_ceil(4);
|
||||
(token_count > 300).then(|| {
|
||||
(
|
||||
turn.lines().next().unwrap_or_default().to_string(),
|
||||
token_count,
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let latest_rendered_source =
|
||||
format!("### Latest turn\nUser:\n{latest_long_user_turn}\n\nAssistant:\nassistant turn 8");
|
||||
|
||||
// Snapshot the actual section so turn order, oldest-first omission, and
|
||||
// start/end truncation behavior are reviewed together.
|
||||
let snapshot = format!(
|
||||
"latest_source_tokens: {}\nrendered_turn_count: {}\nover_budget_turns: {over_budget_turns:?}\n\n{current_thread}",
|
||||
latest_rendered_source.len().div_ceil(4),
|
||||
rendered_turns.len(),
|
||||
);
|
||||
insta::assert_snapshot!(
|
||||
"conversation_startup_context_current_thread_selects_many_turns_by_budget",
|
||||
snapshot
|
||||
);
|
||||
|
||||
// The input includes a turn over 300 approximate tokens, and every rendered
|
||||
// turn still fits the per-turn cap after labels and truncation markers.
|
||||
assert_eq!(
|
||||
(
|
||||
latest_rendered_source.len().div_ceil(4) > 300,
|
||||
over_budget_turns,
|
||||
),
|
||||
(true, Vec::<(String, usize)>::new()),
|
||||
);
|
||||
|
||||
codex.shutdown_and_wait().await?;
|
||||
realtime_server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
source: core/tests/suite/realtime_conversation.rs
|
||||
expression: snapshot
|
||||
---
|
||||
latest_source_tokens: 897
|
||||
rendered_turn_count: 6
|
||||
over_budget_turns: []
|
||||
|
||||
## Current Thread
|
||||
Most recent user/assistant turns from this exact thread. Use them for continuity when responding.
|
||||
|
||||
### Latest turn
|
||||
User:
|
||||
latest-long-start head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head d…604 tokens truncated… tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail latest-long-end
|
||||
|
||||
Assistant:
|
||||
assistant turn 8
|
||||
|
||||
### Previous turn 1
|
||||
User:
|
||||
short-turn-7-start detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail short-turn-7-end
|
||||
|
||||
Assistant:
|
||||
assistant turn 7
|
||||
|
||||
### Previous turn 2
|
||||
User:
|
||||
short-turn-6-start detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail short-turn-6-end
|
||||
|
||||
Assistant:
|
||||
assistant turn 6
|
||||
|
||||
### Previous turn 3
|
||||
User:
|
||||
short-turn-5-start detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail short-turn-5-end
|
||||
|
||||
Assistant:
|
||||
assistant turn 5
|
||||
|
||||
### Previous turn 4
|
||||
User:
|
||||
short-turn-4-start detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail short-turn-4-end
|
||||
|
||||
Assistant:
|
||||
assistant turn 4
|
||||
|
||||
### Previous turn 5
|
||||
User:
|
||||
short-turn-3-start detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail short-turn-3-end
|
||||
|
||||
Assistant:
|
||||
assistant turn 3
|
||||
@@ -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