Skip to content

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,10 @@ pub enum ConfiguredHookHandler {
#[serde(rename = "additionalContextLimit")]
#[ts(rename = "additionalContextLimit")]
additional_context_limit: Option<usize>,
/// When true, a failure of this hook blocks the tool instead of failing open.
#[serde(default, rename = "failClosed")]
#[ts(rename = "failClosed")]
fail_closed: bool,
},
#[serde(rename = "mcp_tool")]
#[ts(rename = "mcp_tool")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -766,13 +766,15 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand
r#async,
status_message,
additional_context_limit,
fail_closed,
} => ConfiguredHookHandler::Command {
command,
command_windows,
timeout_sec,
r#async,
status_message,
additional_context_limit,
fail_closed,
},
CoreHookHandlerConfig::McpTool {
server,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/app-server/tests/suite/v2/config_rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ statusMessage = "Scanning file"
r#async: false,
status_message: None,
additional_context_limit: Some(4_096),
fail_closed: false,
},
ConfiguredHookHandler::McpTool {
server: "security".to_string(),
Expand Down
1 change: 1 addition & 0 deletions codex-rs/app-server/tests/suite/v2/hooks_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ fn command_hook_hash(
r#async,
status_message: status_message.map(ToOwned::to_owned),
additional_context_limit,
fail_closed: false,
}],
},
};
Expand Down
9 changes: 9 additions & 0 deletions codex-rs/config/src/hook_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,15 @@ pub enum HookHandlerConfig {
skip_serializing_if = "Option::is_none"
)]
additional_context_limit: Option<usize>,
/// When true, a failure of this command hook blocks the tool instead of
/// failing open. Defaults to false, so existing handlers are unchanged.
/// Only command hooks carry this field; MCP tool hooks always fail open.
/// It also has no effect on a hook that never runs, such as an untrusted
/// user or project hook, or when the hooks feature is disabled. An async
/// handler that sets it runs synchronously, because a block can only be
/// applied before the tool starts.
#[serde(default, rename = "failClosed")]
fail_closed: bool,
},
#[serde(rename = "mcp_tool")]
McpTool {
Expand Down
34 changes: 34 additions & 0 deletions codex-rs/config/src/hooks_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ fn hooks_file_deserializes_existing_json_shape() {
r#async: false,
status_message: Some("checking".to_string()),
additional_context_limit: Some(4096),
fail_closed: false,
}],
}],
..Default::default()
Expand All @@ -56,6 +57,33 @@ fn hooks_file_deserializes_existing_json_shape() {
);
}

#[test]
fn command_hook_fail_closed_defaults_false_and_round_trips() {
let omitted = serde_json::from_str::<HookHandlerConfig>(
r#"{ "type": "command", "command": "python3 /tmp/pre.py" }"#,
)
.expect("deserialize");
assert!(matches!(
omitted,
HookHandlerConfig::Command {
fail_closed: false,
..
}
));

let set = serde_json::from_str::<HookHandlerConfig>(
r#"{ "type": "command", "command": "python3 /tmp/pre.py", "failClosed": true }"#,
)
.expect("deserialize");
assert!(matches!(
set,
HookHandlerConfig::Command {
fail_closed: true,
..
}
));
}

#[test]
fn hooks_file_deserializes_mcp_tool_handler_with_json_inputs() {
let parsed: HooksFile = serde_json::from_value(serde_json::json!({
Expand Down Expand Up @@ -182,6 +210,7 @@ additionalContextLimit = 4096
r#async: false,
status_message: Some("checking".to_string()),
additional_context_limit: Some(4096),
fail_closed: false,
}],
}],
..Default::default()
Expand Down Expand Up @@ -220,6 +249,7 @@ command = "python3 /tmp/pre.py"
r#async: false,
status_message: None,
additional_context_limit: None,
fail_closed: false,
}],
}],
..Default::default()
Expand Down Expand Up @@ -266,6 +296,7 @@ command = "python3 /enterprise/place/pre.py"
r#async: false,
status_message: None,
additional_context_limit: None,
fail_closed: false,
}],
}],
..Default::default()
Expand Down Expand Up @@ -303,6 +334,7 @@ command_windows = "powershell -File C:\\enterprise\\hooks\\pre.ps1"
r#async: false,
status_message: None,
additional_context_limit: None,
fail_closed: false,
}],
}],
..Default::default()
Expand Down Expand Up @@ -339,6 +371,7 @@ commandWindows = "powershell -File C:\\enterprise\\hooks\\pre.ps1"
r#async: false,
status_message: None,
additional_context_limit: None,
fail_closed: false,
}],
}],
..Default::default()
Expand All @@ -355,6 +388,7 @@ fn hook_handler_omits_unset_additional_context_limit() {
r#async: false,
status_message: None,
additional_context_limit: None,
fail_closed: false,
};

let serialized = serde_json::to_value(handler).expect("hook handler should serialize");
Expand Down
5 changes: 5 additions & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2110,6 +2110,11 @@
"default": null,
"type": "string"
},
"failClosed": {
"default": false,
"description": "When true, a failure of this command hook blocks the tool instead of failing open. Defaults to false, so existing handlers are unchanged. Only command hooks carry this field; MCP tool hooks always fail open. It also has no effect on a hook that never runs, such as an untrusted user or project hook, or when the hooks feature is disabled. An async handler that sets it runs synchronously, because a block can only be applied before the tool starts.",
"type": "boolean"
},
"statusMessage": {
"default": null,
"type": "string"
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/session/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1997,6 +1997,7 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> {
r#async: false,
status_message: None,
additional_context_limit: None,
fail_closed: false,
}],
},
};
Expand Down
30 changes: 25 additions & 5 deletions codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
Expand Down Expand Up @@ -141,11 +142,30 @@ impl CoreToolRuntime for WriteStdinHandler {
matches!(payload, ToolPayload::Function { .. })
}

fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
// `write_stdin` is transport for an existing exec session. Empty writes
// are background polls, and non-empty writes continue a command that
// already ran PreToolUse as Bash, so do not emit a second pre hook here.
None
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
let ToolPayload::Function { arguments } = &invocation.payload else {
return None;
};
// Empty writes are background polls and carry nothing to review. A
// non-empty write is text the model is typing into the open shell, so
// review it as a Bash command before it reaches the process.
let args = parse_arguments::<WriteStdinArgs>(arguments).ok()?;
if args.chars.is_empty() {
return None;
}
// The directory the shell was started in, not its current directory: a
// `cd` typed into the shell is not tracked. Null when the process is gone.
let workdir = invocation
.session
.services
.unified_exec_manager
.start_cwd_for_process(args.session_id)
.map(|cwd| serde_json::Value::String(cwd.to_string()))
.unwrap_or(serde_json::Value::Null);
Some(PreToolUsePayload {
tool_name: HookToolName::bash(),
tool_input: serde_json::json!({ "command": args.chars, "workdir": workdir }),
})
}

fn post_tool_use_payload(
Expand Down
33 changes: 31 additions & 2 deletions codex-rs/core/src/tools/handlers/unified_exec_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -632,9 +632,10 @@ async fn exec_command_pre_tool_use_payload_resolves_workdir() {
}

#[tokio::test]
async fn exec_command_pre_tool_use_payload_skips_write_stdin() {
async fn write_stdin_pre_tool_use_payload_reviews_nonempty_input_as_bash() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "chars": "echo hi" }).to_string(),
arguments: serde_json::json!({ "session_id": 1, "chars": "git reset --hard\n" })
.to_string(),
};
let (session, turn) = make_session_and_context().await;
let turn = Arc::new(turn);
Expand All @@ -652,6 +653,34 @@ async fn exec_command_pre_tool_use_payload_skips_write_stdin() {
source: crate::tools::context::ToolCallSource::Direct,
payload,
}),
Some(crate::tools::registry::PreToolUsePayload {
tool_name: HookToolName::bash(),
tool_input: serde_json::json!({ "command": "git reset --hard\n", "workdir": null }),
})
);
}

#[tokio::test]
async fn write_stdin_pre_tool_use_payload_skips_empty_poll() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "session_id": 1, "chars": "" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let turn = Arc::new(turn);
let handler = WriteStdinHandler;

assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
session: session.into(),
step_context: StepContext::for_test(Arc::clone(&turn)),
turn,
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-45".to_string(),
tool_name: codex_tools::ToolName::plain("write_stdin"),
source: crate::tools::context::ToolCallSource::Direct,
payload,
}),
None
);
}
Expand Down
13 changes: 13 additions & 0 deletions codex-rs/core/src/unified_exec/process_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1820,6 +1820,19 @@ impl UnifiedExecProcessManager {
.map(|entry| entry.environment_id.clone())
}

/// The directory the exec process was started in. This is the start cwd,
/// not the shell's current directory: a `cd` typed into the shell is not tracked.
/// Returns `None` when the process is gone or the store is busy, so the caller
/// never blocks on the lock.
pub(crate) fn start_cwd_for_process(&self, process_id: i32) -> Option<PathUri> {
self.process_store
.try_lock()
.ok()?
.processes
.get(&process_id)
.map(|entry| entry.cwd.clone())
}

pub(crate) async fn list_processes(&self) -> Vec<BackgroundTerminalInfo> {
let store = self.process_store.lock().await;
let mut entries = store
Expand Down
62 changes: 62 additions & 0 deletions codex-rs/core/src/unified_exec/process_manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,3 +678,65 @@ async fn pruning_does_not_evict_live_process_while_exited_process_is_finalizing(
(None, MAX_UNIFIED_EXEC_PROCESSES)
);
}

#[tokio::test]
async fn start_cwd_for_process_returns_the_spawn_directory() {
let (_, turn) = crate::session::tests::make_session_and_context().await;
let manager = UnifiedExecProcessManager::default();
let process_id = manager.allocate_process_id().await;
let cwd = PathUri::parse("file:///tmp/other-repo").expect("cwd");
let process = Arc::new(
crate::unified_exec::process_tests::remote_process(
codex_exec_server::WriteStatus::Accepted,
/*terminate_error*/ None,
codex_sandboxing::SandboxType::None,
)
.await,
);
manager.process_store.lock().await.processes.insert(
process_id,
ProcessEntry {
process,
plugin_metrics_sidecar: None,
call_id: "call".to_string(),
process_id,
cwd: cwd.clone(),
initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
hook_command: "bash".to_string(),
tty: true,
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
permissions: super::super::TerminalPermissions::for_launch(
turn.initial_environments
.primary()
.expect("turn environment"),
&turn,
super::super::TerminalSandboxSource::Native,
crate::sandboxing::SandboxPermissions::UseDefault,
/*additional_permissions*/ None,
/*internal_permissions*/ None,
),
network_approval: None,
session: std::sync::Weak::new(),
last_used: Instant::now(),
},
);

assert_eq!(
manager.start_cwd_for_process(process_id).as_ref(),
Some(&cwd)
);
assert_eq!(manager.start_cwd_for_process(process_id + 1), None);
}

#[tokio::test]
async fn start_cwd_for_process_returns_none_without_blocking_while_locked() {
let manager = UnifiedExecProcessManager::default();
let _guard = manager.process_store.lock().await;

let started = Instant::now();
assert_eq!(manager.start_cwd_for_process(1), None);
assert!(
started.elapsed() < Duration::from_millis(200),
"lookup blocked on the held lock"
);
}
Loading
Loading