Skip to content

feat(hooks): add direct Codex command rewriting - #3552

Open
yeahjack wants to merge 5 commits into
rtk-ai:developfrom
yeahjack:feat/codex-direct-hook
Open

feat(hooks): add direct Codex command rewriting#3552
yeahjack wants to merge 5 commits into
rtk-ai:developfrom
yeahjack:feat/codex-direct-hook

Conversation

@yeahjack

@yeahjack yeahjack commented Aug 12, 2026

Copy link
Copy Markdown

Summary

  • Add native Codex PreToolUse integration that transparently rewrites supported shell commands to rtk ... through updatedInput.
  • Codex now supports input mutation and applies the rewritten command before its normal approval and sandbox flow. This removes the historical blocker without a deny-and-retry model round trip.
  • Add project/global installation, status, backup, and uninstall support while preserving existing user hooks.

Implementation

  • Add rtk hook codex with fail-open parsing and preservation of other tool input fields.
  • Wire the hook into rtk init --codex and update Codex guidance and integration docs.
  • Cover command rewriting, unsupported inputs, JSON merging, installation, and removal.

Benefits

Codex users get transparent RTK token savings without relying on prompt compliance or paying for another model inference, while retaining Codex's native approval and sandbox checks.

This integration is now mature because the required PreToolUse.updatedInput capability is available in Codex and has been verified end to end against Codex CLI 0.147.0-alpha.6.6.

I would appreciate review and merge if this direction aligns with the project.

Test plan

  • cargo fmt --all && cargo clippy --all-targets && cargo test
  • Full suite: 2,673 tests passed, 0 failed
  • Wire-level tests for rewrite and fail-open behavior
  • Local install, idempotency, status, and uninstall tested
  • Real Codex end-to-end test confirmed that raw ls -la executed as rtk ls -la and returned compact output

Related issues

Closes #1003
Closes #1812
Addresses #2921

@CLAassistant

CLAassistant commented Aug 12, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@yeahjack

Copy link
Copy Markdown
Author

Hi @aeppling @KuSh @TaKO8Ki, Codex has been gaining a lot of traction lately, and I think this integration could be really useful for its users. It lets RTK work transparently in Codex, so command output takes up less context without adding an extra model round trip.

Whenever you have some bandwidth, I would really appreciate a review. I am happy to respond quickly and keep improving anything that needs work. Thanks!

@SplittyDev

Copy link
Copy Markdown

AFAIK this is already open in #2557, sadly not yet merged.

@yeahjack

yeahjack commented Aug 13, 2026

Copy link
Copy Markdown
Author

AFAIK this is already open in #2557, sadly not yet merged.

Thanks! I checked #2557. There is an important behavioral difference between the two implementations.

#2557 intentionally skips rewriting in default and ask modes, and only applies updatedInput under bypassPermissions, because it treats permissionDecision: "allow" as potentially bypassing Codex's normal approval flow.

Our PR targets the current Codex hook semantics. The official Codex hooks documentation now explicitly specifies permissionDecision: "allow" together with updatedInput as the way to rewrite a PreToolUse call without blocking it. Approval is handled separately through the PermissionRequest lifecycle, which falls back to the normal approval prompt when no hook makes a decision.

This means RTK can transparently rewrite the command in normal modes while still preserving Codex's native approval and sandbox flow. I also verified this end to end with Codex CLI 0.147.0-alpha.6.6, where the rewritten command is what ultimately executes.

So while the two PRs overlap in purpose, the behavior is meaningfully different. Happy to consolidate with #2557 if the maintainers prefer.

@KuSh

KuSh commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Hi @yeahjack, I’ll review your PR once #2557 is merged, since we should build on it.

@KuSh KuSh self-assigned this Aug 15, 2026
@yeahjack

Copy link
Copy Markdown
Author

Thanks! One thought after looking more closely at #2557 and the review discussion: Our PR may be better considered as an alternative implementation rather than necessarily a follow-up.

It keeps RTK responsible only for PreToolUse rewriting via allow + updatedInput, while Codex remains responsible for approval and sandboxing. This avoids the permission-layer complexity currently being discussed in #2557 and matches the current Codex hook semantics.

I’m happy to follow whichever direction you prefer, whether that means reviewing Our PR directly or rebasing it on top of #2557 and incorporating the relevant parts.

@KuSh

KuSh commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Replying to #3552 (comment)

On "alternative implementation vs follow-up": I think that framing is fair on the protocol point specifically. I traced Codex's own source (codex-rs/core/src/tools/registry.rshandlers/shell.rs) and confirmed updatedInput is applied before Codex's native approval/sandbox check runs, and that check evaluates the rewritten command — so permissionDecision: "allow" here really is a protocol requirement to get the substitution accepted, not RTK vouching for the command's safety. Codex remaining authoritative over approval is a defensible position, not a shortcut around #2557's discussion.

That said, digging further turned up a real gap in that argument, plus some independent bugs — I'd want these addressed regardless of which PR becomes the reference implementation:

1. permission_mode is received but never read. Codex computes and threads this field deliberately through every hook event (hook_permission_mode() derives it from approval_policy()), so it reads as a signal hook authors are meant to use, not an inert echo. process_codex_payload rewrites identically in default/ask/bypassPermissions — unlike #2557, which only rewrites when bypassPermissions is active. Even if the "Codex re-evaluates post-rewrite" argument holds today, discarding this field is a forward-compatibility risk that's cheap to hedge against now.

2. Wrapping through rtk is opaque to Codex's own safety heuristics — in both directions. Codex's is_known_safe_command/dangerous_command_match_for_exec (in codex-rs/shell-command/src/command_safety/) key off the literal first token and only unwrap sudo/env/trap as known wrappers — not rtk. So rewriting defeats Codex's safe-command allowlist (more prompts than before, under UnlessTrusted/restricted-sandbox policies) and its dangerous-command denylist (potentially fewer, if RTK ever rewrites something Codex would flag). Today's blast radius is narrow only because RTK doesn't currently wrap sudo/env/trap/rm --force — RTK's git rewrite already covers commit/push/pull, not just reads, so this isn't hypothetical scope, just a currently-narrow one. Worth a line in the code comment near the allow output making this explicit, since "Codex still checks" undersells the caveat.

3. Local install has no working uninstall. rtk init --codex (no -g) writes a live entry into .codex/hooks.json, but uninstall_codex(global, ctx) unconditionally bails for !global with a message that doesn't even mention hooks.json — so a local install has no CLI path to remove the hook it created.

4. Two hook-detection parser regressions in src/hooks/mod.rs. The new hand-rolled binary-basename parser mishandles backslash-escaped-space paths (/Users/jane/My\ Apps/rtk hook claude misdetects), and the quoted-binary extractor doesn't check the closing quote is followed by whitespace/EOF ("rtk"evil hook codex gets misidentified as bare rtk). Both cause false negatives/positives in install idempotency and integrity checks.

On duplication (run_codex/run_claude, and the hooks.json patch/remove functions for Codex/Claude/Cursor/Droid): I don't think collapsing these into one shared function is the right fix — each agent's stdin quirks (e.g. Codex needing strip_leading_bom, which Claude apparently doesn't) and response shape will keep diverging as more hosts are added, and a shared dispatcher just reintroduces host branching inside itself. What I'd suggest instead: keep the per-agent orchestration functions separate, but extract the mechanical scaffolding that's identical across them — stdin read/parse/fail-open in hook_cmd.rs, and the hooks.PreToolUse[].hooks[].command find/insert/remove/backup/atomic-write walk in init.rs — into shared low-level helpers that each agent's function calls into. That keeps agent-specific behavior explicit while stopping the drift already visible between the Codex and Claude copies.

Happy to help verify any of the above against a specific approach if useful.

@yeahjack
yeahjack force-pushed the feat/codex-direct-hook branch from 2d34e13 to e85985e Compare August 17, 2026 03:40
@yeahjack

Copy link
Copy Markdown
Author

Thanks for the detailed review. I rebased onto the latest develop and pushed e85985e with fixes for the four concrete issues you found:

  1. permission_mode is now validated against the documented Codex modes. Missing or unknown modes fail open and keep the original command unchanged.

  2. The code and docs now explicitly explain that Codex classifies the rewritten argv and does not currently unwrap rtk. This limitation remains, but unsupported destructive commands now have regression coverage to ensure they stay on the passthrough path.

  3. Project-scoped uninstall now works. rtk init --codex --uninstall removes the RTK hook, RTK.md, and the AGENTS.md reference while preserving unrelated user content and hooks.

  4. Hook command detection now handles backslash-escaped spaces correctly and rejects cases such as "rtk"evil hook codex.

I added regression tests for each case. cargo fmt --all --check, cargo clippy --all-targets, and the full test suite pass. That includes 2,628 unit tests, with 8 ignored as expected, plus all 78 integration tests. I also verified project-scoped install and uninstall end to end using the built CLI.

I kept the agent-specific orchestration separate as suggested. I did not include the broader low-level scaffolding extraction in this update, but I am happy to follow up with that if you would prefer it in this PR.

Would appreciate another look when you have time. Happy to keep iterating.

@KuSh

KuSh commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Rechecked against the updated branch — the four issues from the previous round are genuinely fixed, verified against source rather than just the diff:

  • permission_mode: not a bug after all. I pulled Codex's actual hook_permission_mode() (codex-rs/core/src/hook_runtime.rs) — it only ever emits "bypassPermissions" or "default" for PreToolUse, both of which are now handled. The extra acceptEdits/plan/dontAsk schema values are unreachable dead code (carried over from Claude Code's mode set), but harmless.
  • Local uninstall: works and is tested (test_local_codex_install_can_be_uninstalled), and does back up before writing (.bak via fs::copy).
  • Parser regressions (backslash-escaped-space paths, "rtk"evil hook codex): both fixed and tested; the fix is a strict superset of the prior matching behavior, no regression.
  • Classifier-blindness caveat: addressed exactly as asked — a documentation note, not a mechanism change.

Three minor things left, none of them merge blockers:

  1. src/hooks/init.rs:4308 and :4340 — the new fs::read_to_string() calls in show_codex_config() skip .context(), unlike this same PR's other new helpers (patch_codex_hooks_json, remove_codex_hook_from_file), and unlike the repo's "always context, always anyhow" rule. A permission error or a delete-between-check-and-read race would surface as a bare OS error with no indication of which file failed.
  2. src/hooks/init.rs:494 — the Codex hooks.json patch/remove/idempotency-check pipeline still duplicates the Cursor/Droid versions instead of sharing low-level helpers, which was asked for last round. Worth calling out concretely: the copies have already drifted — Droid's swallows backup errors with .ok() while Codex's propagates with ?. Not asking to merge the per-agent orchestration (that split is correct), just the mechanical read/backup/atomic-write scaffolding.
  3. src/hooks/hook_cmd.rs:1668 — the codex_input() test helper now hardcodes permission_mode: "default" everywhere, so only one dedicated test still exercises the missing-permission_mode fail-open path. Worth keeping that test around deliberately since nothing else in the suite covers it anymore.

Nice work turning the previous round around — this is in good shape.

@yeahjack
yeahjack force-pushed the feat/codex-direct-hook branch from e85985e to cff625a Compare August 20, 2026 04:04
@yeahjack

Copy link
Copy Markdown
Author

Thanks for checking this again. I rebased onto the latest develop and pushed cff625a with the remaining cleanup:

  1. Added path-aware error context to all four Codex configuration reads in show_codex_config().

  2. Extracted shared JSON read and backup plus atomic-write helpers for the Codex, Cursor, and Droid hook paths. Their agent-specific orchestration and JSON structures remain separate. Backup failures now propagate consistently instead of being silently ignored.

  3. Kept the missing permission_mode fail-open test and added a comment making its purpose explicit.

  4. Added the Semgrep annotation for the intentional project-local RTK.md removal.

I also added regression coverage for missing and empty JSON files, path-aware parse and read errors, successful backups, backup failures, and preservation of the original file when a backup fails.

Local verification is green: formatting, Clippy, 2,634 unit tests with 8 expected ignores, all 78 integration tests, the real Codex install and uninstall flow, and a workflow-equivalent Semgrep scan with zero findings.

The new CI run is currently waiting for maintainer approval before GitHub will start any jobs. One unrelated note: the benchmark job on the current develop branch is already failing on the existing find --max 10 token regression, so I left that code untouched.

Happy to address anything else that comes up after the CI run is approved.

@KuSh

KuSh commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Re-verified cff625a — all three items from the last round are genuinely done:

  • show_codex_config()'s reads now carry .with_context(...).
  • read_json_file/backup_and_atomic_write are extracted and shared across the Codex/Cursor/Droid patchers (12+ call sites), while orchestration stays split per agent as intended.
  • The missing-permission_mode fail-open test is still there, now with an explicit comment.

One more pass at high effort surfaced one actionable item, posted inline below.

Two other things it turned up are not asks for this PR:

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting one small change (inline below) — the rest of the previous review round is resolved, see the follow-up comment.

Comment thread src/hooks/hook_cmd.rs Outdated
Comment thread src/hooks/mod.rs
Comment on lines +22 to +27
let quote = match command.as_bytes().first() {
Some(b'"') => Some('"'),
Some(b'\'') => Some('\''),
Some(_) => return command.split_whitespace().next(),
None => return None,
}?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two notes on raw_first_token, both "can't happen" rather than breakage — worth pinning down so a future reader doesn't have to re-derive them.

The single-quote branch (line 24) can never change the outcome. shell_split doesn't process escapes inside '...', so a single-quoted first token already survives intact through the primary path:

shell_split(r"'C:\Users\jane\rtk.exe' hook claude")
  → ["C:\\Users\\jane\\rtk.exe", "hook", "claude"]

If that token is an rtk path, is_rtk_binary(parsed_binary) matched and the || short-circuited before the fallback ran; if it isn't, the fallback returns the same string and still doesn't match. The backslash-eating this fallback exists to work around is confined to unquoted text and "...", so only the " branch and the unquoted split_whitespace() branch are load-bearing.

The ? on line 27 is a no-op. Every arm either yields Some(_) or returns early, so it can never short-circuit. Dropping the Option round-trip says the same thing:

let quote = match command.as_bytes().first() {
    Some(b'"') => '"',
    Some(b'\'') => '\'',
    Some(_) => return command.split_whitespace().next(),
    None => return None,
};

Neither needs fixing for this PR to be correct. One thing explicitly not worth adding here, since it looks like a gap: quoted.find(quote) is blind to a backslash-escaped quote, but there's no input that reaches it with one — Windows forbids " in a path outright, and any POSIX string with a legitimately escaped quote is parsed correctly by shell_split, so the fallback is never consulted for it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks, that makes sense. I will leave raw_first_token unchanged in this PR. The single-quote branch and the ? are non-behavioral, and #3704 is the right place to simplify this once the shared lexer consolidation lands.

@yeahjack
yeahjack force-pushed the feat/codex-direct-hook branch from cff625a to e4b74fd Compare August 28, 2026 01:04

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two real regressions from the read_json_file/backup_and_atomic_write refactor in 53fc1b61 (inline below), plus a handful of non-blocking cleanup notes on where the duplication this PR set out to reduce is still present.

Comment thread src/hooks/init.rs Outdated
let serialized = serde_json::to_string_pretty(&root)
.context("Failed to serialize hooks.json")?;
atomic_write(&hooks_json_path, &serialized)?;
if let Some(mut root) = read_json_file(&hooks_json_path)? {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a behavioral regression from the read_json_file/backup_and_atomic_write refactor in 53fc1b61. Before that commit, a hooks.json parse error was silently swallowed here (if let Ok(mut root) = serde_json::from_str(...) { ... }), so remove_cursor_hooks always reported success. Now read_json_file(&hooks_json_path)? propagates the parse error as a hard Err. Step 1 above (deleting the Cursor hook script) has already run and committed to disk by the time this executes, so a corrupted hooks.json now leaves partial, inconsistent state (script gone, stale RTK entry still in hooks.json) and reports failure where it used to succeed cleanly. Could this step catch/log a parse error the way the old code did, rather than propagating it, so uninstall stays best-effort here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in e260715. remove_cursor_hooks_at() now catches only serde_json::Error, logs a warning, leaves the malformed hooks.json unchanged, and continues the best-effort cleanup. Read and permission errors still propagate normally.

I added test_remove_cursor_hooks_keeps_malformed_json_best_effort and test_remove_cursor_hooks_still_propagates_read_errors to cover both boundaries.

Comment thread src/hooks/init.rs
let serialized =
serde_json::to_string_pretty(&root).context("Failed to serialize Droid hook file")?;
atomic_write(path, &serialized)?;
backup_and_atomic_write(path, &serialized)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This ? means a backup failure on this file aborts uninstall_droid_at's whole loop over droid_hook_file_candidates before the remaining candidate files are ever touched — even though test_remove_droid_hook_propagates_backup_failure_and_preserves_file covers the single-file fail-closed behavior, this loop-level consequence (one file's backup problem, e.g. a stray <file>.json.bak directory, blocking cleanup of unrelated files) isn't tested or documented. Worth collecting errors per-candidate in uninstall_droid_at and continuing the loop, surfacing a combined error/warning at the end, so an unrelated file's backup issue doesn't block cleanup of the others?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in e260715. uninstall_droid_at() now attempts every candidate file, collects per-file failures, and returns a combined error after the loop.

The new regression test verifies that a backup failure leaves the affected file unchanged without preventing a later candidate from being cleaned and backed up.

Comment thread src/hooks/init.rs
Ok(())
}

fn codex_hook_already_present(root: &serde_json::Value) -> bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

codex_hook_already_present/patch_codex_hooks_json/remove_codex_hook_from_json (this block, ~130 lines) reimplement the same read/check/patch/remove-hooks.json pattern already present for Claude (hook_already_present/insert_hook_entry near line 1147) and Cursor. This PR did unify the low-level file I/O via read_json_file/backup_and_atomic_write, but the higher-level presence-check/insert/remove logic is now a 3rd near-identical implementation — a future fix to that logic has to be found and applied in 3+ places. Not blocking, just flagging as a follow-up worth extracting at some point (could be a separate PR).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. This is worth a separate follow-up. I kept this PR scoped to the Codex integration and the concrete behavioral regressions, so I did not expand this round into a higher-level hooks.json traversal refactor.

Comment thread src/hooks/init.rs
println!("[--] Global RTK.md: not found");
}

if global_hooks_json.exists() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This global-hooks.json status block and its local counterpart further down are copy-pasted 16-line blocks differing only in the path variable and the "Global"/"Local" label. A print_codex_hook_status(label, path) helper would collapse both and avoid the wording/error-handling drifting apart if one copy gets updated and the other doesn't. Not blocking.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. A print_codex_hook_status(label, path) helper would be a straightforward cleanup. I kept it as a follow-up since it is non-blocking and does not affect the current integration behavior.

Comment thread src/hooks/hook_cmd.rs
Ignore,
}

fn pre_tool_use_rewrite_output(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

vscode_response_from_decision (further down, around the Copilot/VS Code handlers) still hand-rolls the hookSpecificOutput/updatedInput JSON that this new helper exists to consolidate. Since handle_vscode/vscode_response only thread a bare cmd: &str rather than the full payload, that path also can't preserve extra tool_input fields (timeout, description) the way this helper does for Codex/Claude/Droid — worth migrating it too, either in this PR or a quick follow-up, so the consolidation is complete.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. A proper migration needs to thread the full payload through handle_vscode and vscode_response, rather than only replacing the envelope builder, so that extra tool_input fields are preserved.

I kept this out of the current PR and would cover it with dedicated field-preservation tests in a follow-up.

Comment thread src/hooks/init.rs
}

/// Back up an existing JSON file before replacing it atomically.
fn backup_and_atomic_write(path: &Path, content: &str) -> Result<Option<PathBuf>> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Claude's own settings.json installer, patch_settings_json_command (further down, ~line 1014), is the most-used path in this file but wasn't migrated to read_json_file/backup_and_atomic_write — Droid and Cursor were. That leaves two divergent failure-handling styles (that function's older ad hoc read/backup vs. the new hard-fail-on-backup-error everywhere else) between Claude's install path and everyone else's, undermining the consistency this refactor otherwise establishes. Worth a follow-up to bring it in line too.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. The Claude installer should use the shared JSON I/O helpers as well. Since it is the most-used path, I would handle that in a focused follow-up with coverage for existing settings, backup failures, dry-run behavior, and idempotency. I left its current behavior unchanged in this PR.

@yeahjack

Copy link
Copy Markdown
Author

Thanks for the detailed follow-up. I pushed e260715 with fixes for the two regressions:

  1. Cursor uninstall now catches only malformed hooks.json parse errors, logs a warning, leaves the malformed file unchanged, and continues the best-effort cleanup. Read and permission errors still propagate normally.

  2. Droid uninstall now attempts every candidate hook file, collects per-file errors, and returns a combined error after the loop. A backup failure in one candidate no longer prevents later unrelated candidates from being cleaned.

I added regression coverage for the malformed Cursor JSON path, Cursor I/O error propagation, and the Droid multi-candidate failure case.

Local verification is green: formatting, Clippy, 2,675 unit tests with 8 expected ignores, all 79 integration tests, a workflow-equivalent Semgrep scan with zero findings, and a real project-scoped Droid install and uninstall round trip.

I kept the non-blocking cleanup suggestions out of this change so this round stays focused on the two behavioral regressions.

The new CI run is waiting for maintainer approval before GitHub will start the jobs.

@KuSh

KuSh commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

I pushed e260715 with fixes for the two regressions

Thanks

I kept the non-blocking cleanup suggestions out of this change so this round stays focused on the two behavioral regressions.

Would you mind tackling these points in a follow-up PR?

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.

Codex now support PreToolUse hook Add hook to Codex

4 participants