Skip to content

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

Merged
KuSh merged 8 commits into
rtk-ai:developfrom
yeahjack:feat/codex-direct-hook
Sep 13, 2026
Merged

KuSh merged 8 commits into
rtk-ai:developfrom
yeahjack:feat/codex-direct-hook

Conversation

@yeahjack

@yeahjack yeahjack commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

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
Contributor 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
Contributor 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
Contributor 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.rs → handlers/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
Contributor 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
Contributor 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 Outdated

@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
Comment thread src/hooks/init.rs
Comment thread src/hooks/init.rs
Comment thread src/hooks/init.rs
Comment thread src/hooks/hook_cmd.rs
Comment thread src/hooks/init.rs
@yeahjack

Copy link
Copy Markdown
Contributor 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?

@patrickstratznig

patrickstratznig commented Sep 10, 2026 •

Copy link
Copy Markdown

Command rewriting works, but RTK history remains broken in Codex workspace sandboxes: SQLite cannot open its database directory (SQLITE_CANTOPEN, error 14), so rtk gain --history does not record commands.

Safe follow-up: during rtk init --codex, resolve RTK’s actual tracking-data directory and print an opt-in minimal Codex workspace-write configuration that adds only that directory to writable_roots.

@yeahjack
yeahjack force-pushed the feat/codex-direct-hook branch from e260715 to 293a50f Compare September 11, 2026 00:28
@yeahjack

Copy link
Copy Markdown
Contributor Author

@KuSh Yes, happy to take those cleanup items in a separate follow-up PR. Thanks for consolidating the related Codex PRs here. I’ve also prepared a local rebase onto the latest develop and removed the lexer workaround now that #3704 has landed.

@yeahjack

Copy link
Copy Markdown
Contributor Author

@patrickstratznig Thanks for reporting this. I pushed 293a50f with the opt-in setup guidance you suggested.

rtk init --codex now prints a workspace-write configuration snippet using the actual tracking database directory, respecting RTK_DB_PATH, tracking.database_path, and the platform default. It adds only the database parent directory, which SQLite also needs for journal/WAL files. Users can review and merge the snippet into their existing Codex configuration; RTK does not change sandbox permissions automatically.

I verified custom paths, TOML escaping, and the real init flow, including that no config.toml is created. Formatting, Clippy, 3,435 unit tests, all 110 integration tests, and the baseline Semgrep scan pass. I have not reproduced your exact sandbox environment, so I would appreciate confirmation that this resolves history recording in your setup.

@yeahjack
yeahjack force-pushed the feat/codex-direct-hook branch from 293a50f to 0d7e4fb Compare September 13, 2026 08:57
@yeahjack

Copy link
Copy Markdown
Contributor Author

@KuSh Pushed 0d7e4fb, rebased onto develop, with the shared-decision adaptation and restored audit reasons: unsupported_permission_mode, defer, and no_rewrite.

Formatting, Clippy, the full test suite (3,568 unit tests passed, 8 ignored, and 155 integration tests passed), and the baseline Semgrep scan pass locally. The cleanup items remain scoped to #4002.

Could you approve the workflow run for this head so we can get the CI results?

Codex and Vibe both return empty rule vectors; keeping them as separate
arms multiplies branches that have to stay in sync. Group them, sorted,
with one comment covering both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@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.

Round 6 (landing) — APPROVED

Claim: Add a native rtk hook codex PreToolUse processor that rewrites Bash commands to rtk … for Codex CLI, plus rtk init --codex install/uninstall/status tooling.
Scope: accept (frozen).
Ran: full gate on the rebased head — cargo fmt --all --check clean, cargo clippy --all-targets zero warnings, cargo test --all green (3,568 unit + all integration); mutation test on the restored audit reasons; head re-checked against develop (0 behind).
CI @ce6e277c: all green (ubuntu / macos / windows, clippy, fmt, benchmark, Security Scan, semgrep, doc review, CLA).

Blocks merge (0)

Round 5's blocker is gone: you rebased onto exactly current develop and it compiles.

Checked and correct — no need to re-verify

  • Shared-decision adaptation. Routing through decide_hook_action(cmd, Host::Codex) is the right call, and the AskRewrite wrinkle is resolved well: both Allow and Ask emit protocol-level allow because Codex can't accept updatedInput otherwise, while the internal outcome stays Ask so it's never recorded as an RTK auto-approval. The comment says exactly that, which is what a future reader needs.

    Being precise for the record about what this did and didn't change: load_rules_for(Host::Codex) returns empty vectors, so no RTK deny/ask rule applies to Codex today and the behaviour is unchanged. The Deny/AllowRewrite arms are unreachable in production but tested, so they're ready if a Codex-side rule source ever exists. That's a reasonable documented choice matching Host::Vibe, and it isn't something I'm holding this on.

  • Audit reasons restored. reason travelling on Skip is the right shape given decision alone can't separate the three defer causes. Verified by mutation rather than by reading: collapsing skip:unsupported_permission_mode back to skip:defer fails test_codex_skip_reasons_remain_distinct, so all three causes are genuinely pinned.

  • Earlier rounds' fixes still hold — the Cursor parse-vs-I/O split, the Droid per-candidate cleanup, and the raw_first_token removal after #3704 landed, all re-verified on this head.

Fixup pushed — ce6e277c

One cosmetic change, since Host::Codex and Host::Vibe both return empty rule vectors and separate arms just multiply branches that have to stay in sync:

-        Host::Codex => (Vec::new(), Vec::new(), Vec::new()),
-        Host::Vibe => (Vec::new(), Vec::new(), Vec::new()),
+        Host::Codex | Host::Vibe => (Vec::new(), Vec::new(), Vec::new()),

with the comment reworded to cover both. No behaviour change; fmt, clippy and the full suite are green on it, and CI re-ran on the new head.

Filed as follow-ups

#4002 — the four hook-config I/O cleanups you offered to take separately. Unchanged from last round.

Your questions

  • "Could you approve the workflow run for this head?" → done, and it came back green. I've approved the run on the fixup head too.

Next

Nothing — approving. Thanks for the patience across six rounds; the mutation-verified regression tests on the Cursor and Droid paths are what made the last two rounds cheap to check.

Rounds: landing. Threads: 0 open, 9 resolved.

@KuSh
KuSh merged commit 5626e94 into rtk-ai:develop Sep 13, 2026
11 checks passed
@rtk-release-bot rtk-release-bot Bot mentioned this pull request Sep 13, 2026
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

5 participants