Skip to content

feat(hooks): implement comprehensive hook system with blocking semantics#1

Closed
RyderFreeman4Logos wants to merge 14 commits into
mainfrom
feat/hooks-system
Closed

feat(hooks): implement comprehensive hook system with blocking semantics#1
RyderFreeman4Logos wants to merge 14 commits into
mainfrom
feat/hooks-system

Conversation

@RyderFreeman4Logos

Copy link
Copy Markdown
Owner

Summary

Implements a full hook system for codex-rs matching Claude Code's hook functionality, building on the existing PR openai#9691 hooks service framework.

  • 6 event types: AfterAgent, PreToolUse, PostToolUse, Stop, UserPromptSubmit, Notification
  • Blocking hooks: PROCEED/BLOCK/MODIFY semantics with short-circuit on Block
  • Command executor: stdin/stdout JSON protocol with timeout, stdout/stderr size limits, fail-open on spawn failure
  • Hook configuration: TOML config types with glob matcher mechanism (shell*, *, exact match)
  • Integration: PreToolUse/PostToolUse wired into tool dispatch pipeline (router.rs)
  • Security: MAX_STDOUT_BYTES and MAX_STDERR_BYTES (1MB) limits to prevent memory exhaustion
  • 51 tests covering serialization, dispatch semantics, command execution (11 async integration tests), config parsing, and matcher logic

Commits

  1. refactor(hooks) - Redesign HookOutcome with Block/Modify semantics
  2. feat(hooks) - Add command executor with stdin/stdout JSON protocol
  3. feat(hooks) - Add hook configuration types and matcher mechanism
  4. feat(hooks) - Wire up PreToolUse/PostToolUse hooks in tool dispatch
  5. test(hooks) - Add comprehensive tests for hook system
  6. fix(hooks) - Harden executor (stderr limit, stale dead_code cleanup, 11 integration tests)
  7. style(hooks) - Apply cargo fmt formatting fixes

Deferred (separate PRs)

  • Stop/UserPromptSubmit/Notification integration points (require codex.rs core loop changes)
  • HooksConfigToml → Hooks runtime conversion (config file loading)
  • Environment variable context passing

Test plan

  • cargo test -p codex-core -- hooks (51 tests pass)
  • cargo clippy -p codex-core --all-features --tests -- -D warnings (zero warnings)
  • cargo fmt (fully formatted)
  • CI passes on PR

🤖 Generated with Claude Code

RyderFreeman4Logos and others added 7 commits February 7, 2026 14:48
[MOTIVATION]
The existing HookOutcome enum (Continue/Stop) only supports simple
pass/halt control flow. To enable hooks that can deny operations
or modify content (e.g. blocking dangerous tool calls, injecting
context), richer outcome semantics are needed.

[IMPLEMENTATION DETAILS]
- Rename Continue -> Proceed, Stop -> Block { message }
- Add Modify { content } variant for content transformation
- dispatch() now returns HookOutcome with aggregate semantics:
  Block short-circuits immediately, Modify carries forward
- Remove Copy derive (String fields prevent it)
- Update all tests to use new variant names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
[MOTIVATION]
Enable hooks to be implemented as external commands that communicate
via a structured JSON protocol. This is the foundation for blocking
hooks (pre-tool-use, prompt-submit) where the hook script can inspect
the payload and return a decision (proceed/block/modify).

[IMPLEMENTATION DETAILS]
- Add executor.rs with command_hook(argv, timeout) -> Hook
- Payload sent as JSON via stdin, result read from stdout
- Exit code 0 + empty stdout = proceed (fire-and-forget compatible)
- Exit code 0 + JSON stdout = interpret decision field
- Non-zero exit = block with stderr message
- Timeout = block with timeout message
- Spawn failure = proceed (fail-open for resilience)
- HookCommandResult/HookDecision types for stdout deserialization
- 5 unit tests for serialization and outcome conversion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
[MOTIVATION]
Define TOML-compatible configuration types for the hooks system,
enabling users to declare hooks per event type with command, timeout,
and optional tool-name matcher patterns in their config.toml.

[IMPLEMENTATION DETAILS]
- Add config.rs with HookEntryToml (command, timeout, matcher)
- Add HooksConfigToml grouping entries by event type
  (after_agent, pre_tool_use, post_tool_use, notification, stop,
  user_prompt_submit)
- hook_from_entry() converts config entries to Hook via executor
- matches_tool() supports exact match, prefix glob ("shell*"), and
  wildcard ("*") patterns for tool-use hook filtering
- 9 unit tests for TOML deserialization and matcher logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
[MOTIVATION]
Enable external scripts to intercept and observe tool executions,
matching Claude Code's pre_tool_use and post_tool_use hook events.
PreToolUse hooks can block tool calls before execution.

[IMPLEMENTATION DETAILS]
- Add PreToolUse dispatch before tool execution in ToolRouter
- Add PostToolUse dispatch after tool execution with output text
- Add ToolCallBlocked error variant for blocked tool calls
- Handle ToolCallBlocked in stream_events_utils match arms
- Add 6 HookEvent variants (PreToolUse, PostToolUse, Stop,
  UserPromptSubmit, Notification) with event structs
- Expand Hooks registry with per-event-type hook vectors
- Fix clippy issues in executor (expect → let-else, format args)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
[MOTIVATION]
Ensure all hook system components have thorough test coverage including
new event types, dispatch semantics, and legacy notification fallback.

[IMPLEMENTATION DETAILS]
- types.rs: 5 serialization tests for all event variants (PreToolUse,
  PostToolUse, Stop, UserPromptSubmit, Notification)
- registry.rs: 5 dispatch tests for PreToolUse/PostToolUse routing,
  cross-event isolation, Modify carry-forward semantics
- config.rs: 3 tests for TOML deserialization and hook_from_entry
  integration with command executor
- user_notification.rs: 3 tests for legacy notification fallback
  behavior with new event types
- Total: 41 tests passing across hooks module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
[MOTIVATION]
Code review identified security gap: stderr read had no size limit,
allowing malicious hooks to exhaust memory. Also, the core
command_hook() function had zero integration test coverage despite
being the primary execution path.

[IMPLEMENTATION DETAILS]
- Add MAX_STDERR_BYTES (1MB) limit with truncation on overflow
- Remove stale #[allow(dead_code)] on Block/Modify variants (now used
  in live code via router.rs and registry.rs dispatch)
- Remove meaningless placeholder test (test_empty_stdout_returns_proceed)
- Add 11 async integration tests for command_hook() covering:
  empty stdout, proceed/block/modify JSON responses, non-zero exit
  codes, timeout, invalid JSON (fail-open), spawn failure, empty argv

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d9751bf80

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/core/src/tools/router.rs Outdated
Comment on lines +174 to +178
if let HookOutcome::Block { message } = pre_outcome {
let block_msg = message.unwrap_or_else(|| "Blocked by pre_tool_use hook".to_string());
return Ok(Self::failure_response(
failure_call_id,
payload_outputs_custom,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle Modify outcome for pre_tool_use hooks

Only the Block case is checked here, so a HookOutcome::Modify returned by a pre_tool_use hook is silently ignored and the tool invocation proceeds with the original payload. This makes MODIFY semantics ineffective for pre-tool hooks (e.g., hooks that try to rewrite tool input or redact secrets). Consider applying the modified content to the payload or otherwise honoring Modify before dispatching the tool.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 4c95048. The pre-tool hook dispatch now uses an exhaustive match on all three HookOutcome variants. Modify { content } is handled by applying the modified content to the tool arguments (Function, Mcp, and Custom payloads). LocalShell logs a warning since modifying shell command structure from a hook is not safe.

Comment thread codex-rs/core/src/hooks/executor.rs Outdated
Comment on lines +155 to +160
// Read stderr (best effort, size-limited to prevent memory exhaustion)
let mut stderr_bytes = Vec::new();
let mut stderr_buf = [0u8; 4096];
loop {
match stderr.read(&mut stderr_buf).await {
Ok(0) => break,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce timeout while reading stderr/awaiting exit

The timeout only wraps the stdout read. After stdout completes, stderr is read in a blocking loop and child.wait() is awaited without any timeout, so a hook that closes stdout but keeps running (or leaves stderr open) can hang indefinitely and bypass the configured timeout. This can stall tool execution until the process exits. Consider applying the timeout to the entire hook execution or adding a timeout around stderr read and the wait.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 4c95048. The timeout now wraps the entire IO sequence (stdin write + concurrent stdout/stderr drain via tokio::join!) in a single tokio::time::timeout, and child.wait() has its own 5-second grace period timeout. Pipes are also drained concurrently to prevent deadlocks, and continue draining after hitting size caps instead of breaking.

RyderFreeman4Logos and others added 2 commits February 7, 2026 15:28
…ation

Connect the hook configuration types to the main Config struct so hooks
can be configured via config.toml [hooks] section. This closes the gap
that previously prevented hooks from being loaded at startup.

- Add `hooks` field to both ConfigToml and Config structs
- Make HookEntryToml/HooksConfigToml pub with JsonSchema/PartialEq derives
- Populate Hooks registry from HooksConfigToml in Hooks::new()
- Move test-only matches_tool() into #[cfg(test)] module
- Fix clippy collapsible_if in matcher closure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Wrap entire hook IO (stdin write, concurrent stdout/stderr drain,
  child.wait) in a single timeout to prevent hangs at any phase
- Drain stdout/stderr concurrently via tokio::join! to avoid pipe
  deadlock when a hook writes verbose output to both streams
- Continue draining pipes after hitting size caps instead of breaking,
  preventing child process from blocking on full pipes
- Keep overflow chunk bytes when capping stdout to preserve valid
  JSON that only slightly exceeds the limit
- On stdin write failure, continue reading stdout/stderr and exit
  status instead of returning Proceed, so block/modify decisions
  from hooks that close stdin early are not silently lost
- Handle pre-tool Modify outcome by applying modified content to
  tool arguments (Function, Mcp, Custom payloads)
- Extract text from McpToolCallOutput in post-tool hook payloads
  using existing FunctionCallOutputPayload conversion
- Spawn post-tool hook dispatch as background task to avoid adding
  latency to the tool response path
- Remove stale #[allow(dead_code)] annotations
- Regenerate config.schema.json to include hooks field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@RyderFreeman4Logos

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c95048624

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/core/src/tools/router.rs Outdated
Comment on lines +193 to +200
}
ToolPayload::Custom { input } => {
*input = content;
}
ToolPayload::LocalShell { .. } => {
tracing::warn!(
"pre_tool_use hook returned Modify for LocalShell which is not supported; proceeding without modification"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply or reject Modify for local_shell hooks

The pre_tool_use hook’s Modify outcome is silently ignored for ToolPayload::LocalShell, so any hook that tries to sanitize or rewrite local_shell arguments will still execute the original command. This creates a policy bypass when users expect hooks to enforce changes (e.g., stripping dangerous flags). Consider parsing the modified content into ShellToolCallParams or treating Modify as Block for local_shell so the hook’s intent is enforced.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 9e205d5. LocalShell Modify now returns ToolCallBlocked instead of silently proceeding. If a pre_tool_use hook tries to modify a local_shell call, the execution is blocked with an explicit error message, ensuring the hook's policy intent is enforced.

Treating unsupported Modify as a no-op for LocalShell creates a
policy bypass: hooks that intend to sanitize shell arguments would
silently fail while the original command executes unchanged.

Now return ToolCallBlocked so the hook's intent is enforced.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@RyderFreeman4Logos

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9e205d5232

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +152 to +156
// A tool call was blocked by a pre_tool_use hook; surface the block message back into the transcript.
Err(FunctionCallError::ToolCallBlocked(message)) => {
let response = ResponseInputItem::FunctionCallOutput {
call_id: String::new(),
output: FunctionCallOutputPayload {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the original call_id for blocked tool outputs

When a pre_tool_use hook blocks a tool call, this path emits a FunctionCallOutput with call_id set to the empty string. During history normalization, outputs without a matching call ID are removed (context_manager/normalize.rs::remove_orphan_outputs), which means the block message gets dropped and replaced by a synthetic "aborted" output; it also triggers error_or_panic for the orphan output. This only happens for blocked tool calls, but it prevents the user from seeing the hook’s block reason in the transcript. Consider reusing the call_id from the original tool call item so the output is retained.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit fd576cf. The ToolCallBlocked handler now extracts the actual call_id from the original ResponseItem (FunctionCall, LocalShellCall, or CustomToolCall) instead of using an empty string. This ensures the blocked output is properly matched during history normalization and the block reason is visible in the transcript.

When a pre_tool_use hook blocks a tool call, the blocked output
used an empty call_id which causes orphan output removal during
history normalization. Extract the actual call_id from the
original ResponseItem so the block message is retained in the
transcript.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@RyderFreeman4Logos

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd576cf1df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +162 to +167
let response = ResponseInputItem::FunctionCallOutput {
call_id: blocked_call_id,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(message),
..Default::default()
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return CustomToolCallOutput when blocking custom tools

When a pre_tool_use hook blocks a ResponseItem::CustomToolCall, this branch always builds a ResponseInputItem::FunctionCallOutput. That later becomes ResponseItem::FunctionCallOutput via response_input_to_response_item, so the model never receives the expected CustomToolCallOutput for the blocked custom tool. In that scenario the tool call stays unresolved (or is treated as a mismatched response type), which can stall or confuse the conversation. Consider mirroring failure_response behavior and emit CustomToolCallOutput when the original item was a custom tool call.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dfa9ad0. Both ToolCallBlocked and RespondToModel arms now detect CustomToolCall items and emit CustomToolCallOutput instead of FunctionCallOutput, mirroring the failure_response pattern used in router.rs.

When a pre_tool_use hook blocks a CustomToolCall, emit
CustomToolCallOutput instead of FunctionCallOutput so the model
receives the expected response type. Also fix RespondToModel arm
with the same issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@RyderFreeman4Logos

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dfa9ad0416

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +245 to +249
match serde_json::from_slice::<HookCommandResult>(&stdout_bytes) {
Ok(result) => result.into(),
Err(err) => {
tracing::warn!("failed to parse hook command result: {err}");
HookOutcome::Proceed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat truncated hook stdout as a failure, not Proceed

When a hook writes more than MAX_STDOUT_BYTES, stdout is truncated earlier in read_stdout, but the truncated buffer is still parsed here. That means a hook that returns a valid decision JSON plus large output (e.g., a modify with >1MB content or verbose logging before the JSON) will hit a parse error and fall back to HookOutcome::Proceed, silently bypassing the hook’s intended block/modify behavior. Consider tracking the “capped” state and returning a Block (or at least a clear error) when stdout was truncated so oversized hook output can’t accidentally disable enforcement.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c4a706. The capped flag is now propagated from read_stdout and checked before JSON parsing. When stdout was truncated, the executor returns Block with a clear message instead of attempting to parse corrupted JSON and falling through to Proceed.

…roceed

When hook stdout exceeds MAX_STDOUT_BYTES the output is truncated,
which corrupts any JSON decision the hook emitted. Previously, the
corrupted bytes were still fed to serde which would fail and fall
back to Proceed, silently bypassing the hook's intended
block/modify. Now the capped flag is propagated and triggers an
explicit Block so oversized hook output cannot disable enforcement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@RyderFreeman4Logos

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c4a70635e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/core/src/tools/router.rs Outdated
Comment on lines +155 to +156
// Extract tool input before moving payload into invocation.
let tool_input = payload.log_payload().into_owned();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Send full tool args to pre_tool_use hooks

The pre_tool_use hook payload is built from payload.log_payload(), which is a logging helper and is lossy for ToolPayload::LocalShell (it joins params.command with spaces and drops fields like workdir, timeout_ms, and sandbox_permissions from ShellToolCallParams in tools/context.rs). That means hooks intended to enforce policy on local shell calls can't reliably inspect the real arguments or execution context (e.g., commands with space-containing args or unsafe workdirs), so a policy that should block can be bypassed. Consider serializing the full shell params (or the original LocalShellCall JSON) into tool_input instead of using the log preview helper.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex Disagree for current scope. The concern about lossy log_payload() for LocalShell overlooks existing context and overstates the practical risk:

  1. Working directory is already available. HookPayload includes cwd: PathBuf (see types.rs:38), which is the session's working directory and the default execution directory for shell commands. ShellToolCallParams.workdir is an optional override that is almost always None (see router.rs:121 — only set from exec.working_directory). So hooks already have the relevant directory context.

  2. Space-joining preserves practical fidelity. For policy hooks checking command patterns (e.g., blocking rm -rf or curl | bash), command.join(" ") produces the same string a human would type. The theoretical ambiguity between ["bash", "-c", "echo hello world"] and ["bash", "-c", "echo", "hello", "world"] doesn't affect pattern-matching hooks, because both representations carry the same semantic information for policy decisions.

  3. sandbox_permissions is always UseDefault. Line 123 in router.rs hardcodes Some(SandboxPermissions::UseDefault) — exposing this to hooks provides no actionable information.

  4. Protocol crate boundary. ShellToolCallParams derives Deserialize but not Serialize (see protocol/src/models.rs:745). Adding Serialize to the protocol crate to serve a hook implementation detail is a cross-crate API change that should be driven by a concrete use case, not a theoretical gap.

Disposition: Acknowledged as a valid future enhancement. When a hook author demonstrates a concrete case where space-joined output is insufficient, we'll add structured serialization. For the MVP hook system, the current representation is adequate.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@codex The environment is now set up. Please address the counter-arguments above:

  1. HookPayload.cwd already provides the working directory context — ShellToolCallParams.workdir is almost always None
  2. Space-joining preserves the same semantic information for policy pattern matching
  3. sandbox_permissions is hardcoded to UseDefault and provides no actionable info
  4. Adding Serialize to the protocol crate is a cross-crate change that should be driven by a concrete use case

Do you still consider this a real issue given these points, or would you agree this is acceptable for the MVP hook system?

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c4a70635e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/core/src/tools/router.rs Outdated
Comment on lines +155 to +156
// Extract tool input before moving payload into invocation.
let tool_input = payload.log_payload().into_owned();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve shell params in pre-tool hook input

The pre_tool_use hook payload is built from payload.log_payload(), which for ToolPayload::LocalShell only joins command with spaces. That drops important fields like workdir, timeout_ms, and sandbox_permissions, and also loses argument boundaries (e.g., ['echo','a b'] vs ['echo','a','b']). As a result, hook policies cannot reliably block or allow based on the actual execution context — e.g., a tool call with workdir: "/etc" will still present the hook with the session cwd and a flattened command, so a policy that expects to block commands outside the cwd can be bypassed. Consider serializing the full ShellToolCallParams (or at least include workdir and arguments as structured data) for hook input.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Your workdir: "/etc" example is valid — the old code used log_payload() which joins the command array with spaces (lossy) and drops the workdir override entirely, so hooks would only see the session cwd, not the model-specified workdir.

Fixed by adding a dedicated hook_input() method to ToolPayload that serializes LocalShell params as structured JSON:

{"command": ["ls", "-la"], "workdir": "/etc", "timeout_ms": 30000}

This preserves argument boundaries and exposes the workdir override so security hooks can enforce accurate policies.

See upcoming commit for the implementation.

Replace log_payload() with a new hook_input() method that serializes
LocalShell params as structured JSON (command array + workdir + timeout),
preserving argument boundaries and exposing workdir overrides so security
hooks can enforce accurate policies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@RyderFreeman4Logos

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2bdceef3c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +67 to +76
ToolPayload::LocalShell { params } => {
let mut obj = serde_json::json!({
"command": params.command,
});
if let Some(workdir) = &params.workdir {
obj["workdir"] = serde_json::Value::String(workdir.clone());
}
if let Some(timeout_ms) = params.timeout_ms {
obj["timeout_ms"] = serde_json::Value::Number(timeout_ms.into());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include full LocalShell params in hook_input

Pre-tool hooks for LocalShell only see command, workdir, and timeout_ms because hook_input() rebuilds a partial JSON object from ShellToolCallParams. That drops fields like sandbox_permissions, prefix_rule, and justification (defined on ShellToolCallParams), so a policy hook cannot distinguish an escalated/sandbox-permissions request from a normal one. In practice, a pre_tool_use hook that is supposed to block or log escalated calls will silently miss them, allowing commands to run with higher permissions than the hook intended to permit. Consider serializing the full ShellToolCallParams instead of reconstructing a partial object.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disagree. These three fields are hardcoded constants in the sole construction site (router.rs:119-125):

sandbox_permissions: Some(SandboxPermissions::UseDefault),  // always
prefix_rule: None,                                          // always
justification: None,                                        // always

They carry zero information — a hook cannot distinguish anything by observing them because they never vary. Including them would add noise without policy value.

Additionally, ShellToolCallParams does not derive Serialize (only Deserialize), and SandboxPermissions is an internal enum — serializing the full struct would require protocol crate changes for zero practical benefit.

The hook_input() method intentionally exposes only the model-controllable fields (command, workdir, timeout_ms) since those are the ones hooks need for security decisions. If these hardcoded fields ever become dynamic, we should include them at that point — but right now, serializing constants is premature.

@RyderFreeman4Logos

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2bdceef3c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +67 to +71
ToolPayload::LocalShell { params } => {
let mut obj = serde_json::json!({
"command": params.command,
});
if let Some(workdir) = &params.workdir {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include full local_shell params in hook payload

For local_shell calls, hook_input() only serializes command, workdir, and timeout_ms, but it drops sandbox_permissions, prefix_rule, and justification from ShellToolCallParams. Any pre_tool_use hook policy that needs those fields (for example, blocking escalated sandbox_permissions or enforcing an allowed prefix_rule) cannot make a correct decision and will silently permit calls that should be blocked. Consider serializing the full params struct (or at least these missing fields) so hooks can enforce security policy based on the complete request.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate of previous comment. See the reply there — these three fields (sandbox_permissions, prefix_rule, justification) are hardcoded constants in the sole construction site (router.rs:119-125), carry zero information, and serializing them would require protocol crate changes for no practical benefit.

@RyderFreeman4Logos

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2bdceef3c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +92 to +99
pub(crate) async fn dispatch(&self, hook_payload: HookPayload) -> HookOutcome {
let mut result = HookOutcome::Proceed;
for hook in self.hooks_for_event(&hook_payload.hook_event) {
let outcome = hook.execute(&hook_payload).await;
if matches!(outcome, HookOutcome::Stop) {
break;
match &outcome {
HookOutcome::Block { .. } => return outcome,
HookOutcome::Modify { .. } => {
result = outcome;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply Modify results to subsequent hooks

The hook dispatch loop always passes the original hook_payload into every hook, even after a prior hook returns Modify. That means later hooks still see the pre‑modified tool input, so any validation/logging hooks run against stale data and may allow policy bypasses (e.g., a redaction hook runs first but a later auditing hook logs the unredacted input). If Modify is intended to be “carried forward,” the payload should be updated before invoking subsequent hooks (or dispatch should stop on Modify).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — partially. The doc comment on line 89-90 says "the modified content is carried forward" but the implementation doesn't actually do that: each hook sees the original hook_payload, and only the last Modify result is returned to the caller.

Two separate issues here:

  1. Doc accuracy (real bug): The comment claims carry-forward semantics that don't exist. Will fix.

  2. Multi-hook chaining (valid design concern, deferred): For the current scope, hooks are expected to be independent (0-1 per event type in typical configs). True multi-hook pipelines where order and carry-forward matter would require mutating HookPayload.hook_event.tool_input between invocations — a meaningful design change better addressed when there's a concrete multi-hook use case.

Will fix the misleading doc comment in the clean PR.

@RyderFreeman4Logos

Copy link
Copy Markdown
Owner Author

Superseded by #2. This PR is preserved for review discussion reference (including the iterative @codex bot review loop).

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

  • Extended hook_input() for ToolPayload::LocalShell to include sandbox_permissions, prefix_rule, and justification when present so hooks receive full policy-relevant context. codex-rs/core/src/tools/context.rsL63-L92

Testing

  • cargo test -p codex-core (failed: exec::tests::kill_child_process_group_kills_grandchildren_on_timeout).
  • cargo test -p codex-core exec::tests::kill_child_process_group_kills_grandchildren_on_timeout -- --nocapture (failed: grandchild process still alive).
  • ⚠️ cargo test --all-features (not run; requires approval).

View task →

RyderFreeman4Logos pushed a commit that referenced this pull request Feb 8, 2026
Fixes openai#9050

When a draft is stashed with Ctrl+C, we now persist the full draft state
(text elements, local image paths, and pending paste payloads) in local
history. Up/Down recall rehydrates placeholder elements and attachments
so styling remains correct and large pastes still expand on submit.
Persistent (cross‑session) history remains text‑only.

Backtrack prefills now reuse the selected user message’s text elements
and local image paths, so image placeholders/attachments rehydrate when
rolling back.

External editor replacements keep only attachments whose placeholders
remain and then normalize image placeholders to `[Image #1]..[Image #N]`
to keep the attachment mapping consistent.

Docs:
- docs/tui-chat-composer.md

Testing:
- just fix -p codex-tui
- cargo test -p codex-tui

Co-authored-by: Eric Traut <etraut@openai.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant