From bd482b1622df6fd23ca580dc3dd71dc79936eb4b Mon Sep 17 00:00:00 2001 From: Max Destors Date: Sun, 12 Jul 2026 21:55:34 +0200 Subject: [PATCH 1/2] feat(hooks): add Codex hook --- README.md | 3 +- docs/contributing/TECHNICAL.md | 4 +- .../guide/getting-started/supported-agents.md | 12 +- hooks/README.md | 29 +- hooks/codex/README.md | 25 +- hooks/codex/rtk-awareness.md | 5 + src/hooks/README.md | 6 +- src/hooks/constants.rs | 2 + src/hooks/hook_cmd.rs | 264 ++++++++++++++++ src/hooks/init.rs | 299 +++++++++++++++++- src/hooks/mod.rs | 46 ++- src/main.rs | 17 + 12 files changed, 679 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 9141e2a634..c41982c962 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,7 @@ rtk init -g # Install hook + RTK.md (recommended) rtk init -g --opencode # OpenCode plugin (instead of Claude Code) rtk init -g --auto-patch # Non-interactive (CI/CD) rtk init -g --hook-only # Hook only, no RTK.md +rtk init -g --codex # Codex hook + RTK.md + AGENTS.md rtk init --show # Verify installation ``` @@ -375,7 +376,7 @@ RTK supports 15 AI coding tools. Each integration rewrites shell commands to `rt | **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) | | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | | **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook | -| **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | +| **Codex** | `rtk init -g --codex` | PreToolUse hook (`rtk hook codex`) + AGENTS.md | | **Windsurf** | `rtk init -g --agent windsurf` | .windsurfrules (project-scoped) | | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | diff --git a/docs/contributing/TECHNICAL.md b/docs/contributing/TECHNICAL.md index 20328c8894..0ef9b11c8e 100644 --- a/docs/contributing/TECHNICAL.md +++ b/docs/contributing/TECHNICAL.md @@ -315,7 +315,7 @@ Start here, then drill down into each README for file-level details. | [`cursor/`](../hooks/cursor/README.md) | Cursor IDE | Shell hook, empty JSON response requirement | | [`cline/`](../hooks/cline/README.md) | Cline / Roo Code | Rules file (prompt-level, no programmatic hook) | | [`windsurf/`](../hooks/windsurf/README.md) | Windsurf / Cascade | Rules file (workspace-scoped) | -| [`codex/`](../hooks/codex/README.md) | OpenAI Codex CLI | Awareness document, AGENTS.md integration | +| [`codex/`](../hooks/codex/README.md) | OpenAI Codex CLI | Native PreToolUse hook, AGENTS.md integration | | [`opencode/`](../hooks/opencode/README.md) | OpenCode | TypeScript plugin, zx library, in-place mutation | --- @@ -333,7 +333,7 @@ RTK supports the following LLM agents through hook integrations: | Gemini CLI | Rust binary | `rtk hook gemini` reads JSON | Yes (`hookSpecificOutput`) | | Cline/Roo Code | Rules file | Prompt-level guidance | N/A (prompt) | | Windsurf | Rules file | Prompt-level guidance | N/A (prompt) | -| Codex CLI | Awareness doc | AGENTS.md integration | N/A (prompt) | +| Codex CLI | Rust binary hook | `rtk hook codex` + AGENTS.md integration | Yes (`updatedInput`) | | OpenCode | TS plugin | `tool.execute.before` event | Yes (in-place mutation) | > **Details**: [`hooks/README.md`](../hooks/README.md) has the full JSON schemas for each agent. [`src/hooks/README.md`](../src/hooks/README.md) covers installation, integrity verification, and the rewrite command. diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 1c594d1c5a..36b6bf3e55 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -40,7 +40,7 @@ Agent runs "cargo test" | Factory Droid | Shell hook (`PreToolUse`, matcher `Execute`) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | -| Codex CLI | AGENTS.md instructions | N/A | +| Codex CLI | PreToolUse hook + AGENTS.md instructions | Yes (`updatedInput`) | | Kilo Code | Rules file (prompt-level) | N/A | | Google Antigravity | Rules file (prompt-level) | N/A | | Mistral Vibe | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Pending upstream | @@ -175,10 +175,14 @@ rtk init --global --agent windsurf # creates .windsurfrules in current projec ### Codex CLI ```bash -rtk init --codex # project-scoped (AGENTS.md) -rtk init --global --codex # user-global (~/.codex/AGENTS.md) +rtk init --codex # project-scoped (AGENTS.md + .codex/hooks.json) +rtk init --global --codex # user-global (~/.codex/AGENTS.md + hooks.json) ``` +Restart Codex, open `/hooks`, and trust the new RTK command hook. Eligible single, +non-mutating Bash commands are then rewritten transparently through +`rtk hook codex`; other commands retain Codex's native approval flow. + ### Kilo Code ```bash @@ -207,7 +211,7 @@ Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https:// | **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it | | **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk ` | -Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi) use in-place mutation via the agent's TypeScript extension API. +Rules file integrations (Cline, Windsurf, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini, Codex) rewrite the command before execution. Plugin integrations (OpenCode, Pi) use in-place mutation via the agent's TypeScript extension API. ## Windows support diff --git a/hooks/README.md b/hooks/README.md index d14fbd4273..bfa4746a26 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -38,7 +38,7 @@ Each agent subdirectory has its own README with hook-specific details: - **[`cursor/`](cursor/README.md)** — Shell hook, Cursor JSON format, empty `{}` response requirement - **[`cline/`](cline/README.md)** — Rules file (prompt-level), `.clinerules` project-local installation - **[`windsurf/`](windsurf/README.md)** — Rules file (prompt-level), `.windsurfrules` workspace-scoped -- **[`codex/`](codex/README.md)** — Awareness document, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location +- **[`codex/`](codex/README.md)** — Native `PreToolUse` hook, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location - **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation - **[`pi/`](pi/README.md)** — TypeScript extension, `tool_call` event, `isToolCallEventType` guard, in-place mutation, `~/.pi/agent/extensions/` - **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation @@ -54,7 +54,7 @@ Each agent subdirectory has its own README with hook-specific details: | Gemini CLI | Rust binary (`rtk hook gemini`) | Transparent rewrite | Yes (`hookSpecificOutput`) | | Cline / Roo Code | Custom instructions (rules file) | Prompt-level guidance | N/A | | Windsurf | Custom instructions (rules file) | Prompt-level guidance | N/A | -| Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A | +| Codex CLI | Rust binary (`rtk hook codex`) + AGENTS.md | Transparent rewrite for eligible single, non-mutating commands | Yes (`updatedInput`) | | OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes | | Pi | TypeScript extension (`tool_call` event) | In-place mutation | Yes | | Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | @@ -157,6 +157,27 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). **No rewrite**: `{"decision": "allow"}` +### Codex CLI (Rust Binary) + +**Input** uses the standard `PreToolUse` shape with Codex fields such as +`turn_id` and `tool_use_id`. For Bash calls, the command is in +`tool_input.command`. + +**Output** (when rewritten): + +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": { "command": "rtk git status" } + } +} +``` + +Codex requires `permissionDecision: "allow"` whenever `updatedInput` is returned. + ### OpenCode (TypeScript Plugin) Mutates `args.command` in-place via the zx library: @@ -240,9 +261,9 @@ New integrations must follow the [Exit Code Contract](#exit-code-contract) and [ | Tier | Mechanism | Maintenance | Examples | |------|-----------|-------------|----------| -| **Full hook** | Shell script or Rust binary, intercepts commands via agent's hook API | High — must track agent API changes | Claude Code, Cursor, Copilot, Gemini | +| **Full hook** | Shell script or Rust binary, intercepts commands via agent's hook API | High — must track agent API changes | Claude Code, Cursor, Copilot, Gemini, Codex | | **Plugin** | TypeScript/JS/Python plugin in agent's plugin system | Medium — agent manages loading | OpenCode, Hermes, Pi | -| **Rules file** | Prompt-level instructions the agent reads | Low — no code to break | Cline, Windsurf, Codex | +| **Rules file** | Prompt-level instructions the agent reads | Low — no code to break | Cline, Windsurf | ### Eligibility diff --git a/hooks/codex/README.md b/hooks/codex/README.md index 50030e958a..8e65a85824 100644 --- a/hooks/codex/README.md +++ b/hooks/codex/README.md @@ -4,6 +4,29 @@ ## Specifics -- Prompt-level guidance via awareness document -- no programmatic hook +- Native `PreToolUse` processor via `rtk hook codex` +- Transparent Bash command rewriting through `permissionDecision: "allow"` + `updatedInput` +- Hook registration in `.codex/hooks.json` (project) or `$CODEX_HOME/hooks.json` (global) - `rtk-awareness.md` is injected into `AGENTS.md` with an `@RTK.md` reference - Installed to `$CODEX_HOME` when set, otherwise `~/.codex/`, by `rtk init --codex` + +Codex requires users to review and trust non-managed hooks through `/hooks` before +they run. Hook failures and unsupported commands produce no output, so the original +tool call continues unchanged. + +Because Codex requires `permissionDecision: "allow"` when applying +`updatedInput`, RTK only rewrites a conservative set of single, non-mutating +commands transparently. Compound commands and commands that may change state pass +through unchanged so Codex retains its native approval behavior. + +## Live verification + +The opt-in smoke test starts a real Codex turn and may consume API credits. It +records the Codex version, asks Codex to issue a raw `git status --short`, and +uses an isolated tracking database to verify that `rtk git status --short` +actually executed: + +```bash +rtk cargo build +RTK_CODEX_E2E=1 scripts/test-codex-hook-e2e.sh +``` diff --git a/hooks/codex/rtk-awareness.md b/hooks/codex/rtk-awareness.md index 7ae285e1a8..58f2291754 100644 --- a/hooks/codex/rtk-awareness.md +++ b/hooks/codex/rtk-awareness.md @@ -2,6 +2,11 @@ **Usage**: Token-optimized CLI proxy for shell commands. +When installed with `rtk init --codex`, a trusted Codex `PreToolUse` hook +automatically rewrites eligible single, non-mutating Bash commands. These +instructions remain as a fallback for commands that retain Codex's native +approval flow and for shell paths that hooks do not intercept. + ## Rule Always prefix shell commands with `rtk`. diff --git a/src/hooks/README.md b/src/hooks/README.md index 67a0bf3cc0..12fc68c2b6 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -28,7 +28,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Claude-MD (legacy) | `rtk init --claude-md` | 134-line RTK block | CLAUDE.md | | Windsurf | `rtk init -g --agent windsurf` | `.windsurfrules` | -- | | Cline | `rtk init --agent cline` | `.clinerules` | -- | -| Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md | +| Codex | `rtk init --codex` | RTK.md + hooks.json | AGENTS.md | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | | Pi | `rtk init --agent pi` | `.pi/extensions/rtk.ts` | -- | | Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | @@ -89,13 +89,13 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i | Cursor (rtk hook cursor) | Ready | `permission: "ask",` — users will be prompted when Cursor enforces the permission; in the meantime, allow | | Gemini CLI (rtk hook gemini) | No (allow/deny only) | allow (limitation — no ask mode in Gemini) | | Copilot CLI (rtk hook copilot) | No updatedInput | deny-with-suggestion (unchanged) | -| Codex | ask parsed but no-op | allow (limitation — fails open) | +| Codex (`rtk hook codex`) | No (`ask` is unsupported with `updatedInput`) | required `allow` only for eligible single, non-mutating commands; otherwise defer | ### Implementation - `permissions.rs` — loads deny/ask/allow rules, evaluates precedence, returns `PermissionVerdict` - `rewrite_cmd.rs` — maps verdict to exit code (consumed by shell hook) -- `hook_cmd.rs` — maps verdict to JSON `permissionDecision` field (Copilot/Gemini) +- `hook_cmd.rs` — maps rewrite decisions to each host's JSON protocol (Claude/Codex/Copilot/Gemini/Cursor/Droid) ## Exit Code Contract diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 4caaf94473..0e639d9877 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -10,6 +10,8 @@ pub const BEFORE_TOOL_KEY: &str = "BeforeTool"; /// Native Rust hook command for Claude Code (replaces rtk-rewrite.sh). pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude"; +/// Native Rust hook command for Codex CLI. +pub const CODEX_HOOK_COMMAND: &str = "rtk hook codex"; /// Native Rust hook command for Cursor (replaces rtk-rewrite.sh). pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor"; /// Native Rust hook command for Factory Droid. diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 40451b93c0..26b3deb13d 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -9,6 +9,7 @@ use anyhow::{Context, Result}; use serde_json::{json, Value}; use std::io::{self, Read, Write}; +use crate::discover::lexer::{contains_unattestable_construct, shell_split, tokenize, TokenKind}; use crate::discover::registry::{has_heredoc, rewrite_command}; const STDIN_CAP: usize = 1_048_576; // 1 MiB @@ -438,6 +439,146 @@ fn run_claude_inner(input: &str) -> Option { } } +// ── Codex CLI native hook ───────────────────────────────────── + +/// Codex only applies `updatedInput` when the hook also returns +/// `permissionDecision: allow`, which suppresses its native approval prompt. +/// Keep transparent rewrites to single, explicitly non-mutating commands that +/// Codex normally runs without approval. Everything else passes through so +/// Codex retains full control of its permission policy. +fn codex_can_auto_allow(cmd: &str) -> bool { + if contains_unattestable_construct(cmd) { + return false; + } + + let tokens = tokenize(cmd); + if tokens.iter().any(|token| { + matches!( + token.kind, + TokenKind::Operator | TokenKind::Pipe | TokenKind::Shellism + ) + }) { + return false; + } + + let args = shell_split(cmd); + match args.as_slice() { + [binary, subcommand, ..] + if matches!(binary.as_str(), "git" | "yadm") + && matches!(subcommand.as_str(), "status" | "log" | "diff" | "show") => + { + true + } + [binary, ..] + if matches!( + binary.as_str(), + "cat" | "head" | "tail" | "ls" | "tree" | "grep" | "wc" + ) => + { + true + } + _ => false, + } +} + +fn process_codex_payload(v: &Value) -> PayloadAction { + // Only rewrite Codex PreToolUse events for its Bash tool; ignore every + // other event or tool payload so we never alter unrelated hook data. + if v.get("hook_event_name").and_then(Value::as_str) != Some(PRE_TOOL_USE_KEY) + || !matches!( + v.get("tool_name").and_then(Value::as_str), + Some("Bash" | "bash") + ) + { + return PayloadAction::Ignore; + } + + let cmd = match v + .pointer("/tool_input/command") + .and_then(Value::as_str) + .filter(|cmd| !cmd.is_empty()) + { + Some(cmd) => cmd, + None => return PayloadAction::Ignore, + }; + + if !codex_can_auto_allow(cmd) { + return PayloadAction::Skip { + reason: "skip:codex_native_approval", + cmd: cmd.to_string(), + }; + } + + let rewritten = match get_rewritten(cmd) { + Some(rewritten) if rewritten != cmd => rewritten, + _ => { + return PayloadAction::Skip { + reason: "skip:defer", + cmd: cmd.to_string(), + } + } + }; + + let mut updated_input = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); + if let Some(obj) = updated_input.as_object_mut() { + obj.insert("command".into(), Value::String(rewritten.clone())); + } + + PayloadAction::Rewrite { + cmd: cmd.to_string(), + rewritten, + output: json!({ + "hookSpecificOutput": { + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": updated_input + } + }), + } +} + +/// Run the Codex CLI PreToolUse hook natively. +pub fn run_codex() -> Result<()> { + let input = read_stdin_limited()?; + let input = strip_leading_bom(&input).trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(e) => { + let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}"); + return Ok(()); + } + }; + + match process_codex_payload(&v) { + PayloadAction::Rewrite { + cmd, + rewritten, + output, + } => { + audit_log("rewrite", &cmd, &rewritten); + let _ = writeln!(io::stdout(), "{output}"); + } + PayloadAction::Skip { reason, cmd } => audit_log(reason, &cmd, ""), + PayloadAction::Ignore => {} + } + + Ok(()) +} + +#[cfg(test)] +fn run_codex_inner(input: &str) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + match process_codex_payload(&v) { + PayloadAction::Rewrite { output, .. } => Some(output.to_string()), + _ => None, + } +} + // ── Cursor native hook ───────────────────────────────────────── /// Cursor on Windows ships hook payloads with one or more leading @@ -1137,6 +1278,129 @@ mod tests { assert!(run_claude_inner(&input).is_none()); } + // --- Codex handler --- + + fn codex_input(cmd: &str) -> String { + json!({ + "session_id": "session-1", + "turn_id": "turn-1", + "hook_event_name": PRE_TOOL_USE_KEY, + "tool_name": "Bash", + "tool_use_id": "tool-1", + "tool_input": { "command": cmd } + }) + .to_string() + } + + #[test] + fn test_codex_rewrite_uses_required_allow_shape() { + let result = run_codex_inner(&codex_input("git status")).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let hook = &v["hookSpecificOutput"]; + + assert_eq!(hook["hookEventName"], PRE_TOOL_USE_KEY); + assert_eq!(hook["permissionDecision"], "allow"); + assert_eq!(hook["permissionDecisionReason"], "RTK auto-rewrite"); + assert_eq!(hook["updatedInput"]["command"], "rtk git status"); + } + + #[test] + fn test_codex_rewrite_preserves_tool_input_fields() { + let input = json!({ + "hook_event_name": PRE_TOOL_USE_KEY, + "tool_name": "Bash", + "tool_input": { + "command": "git status --short", + "timeout": 30_000, + "description": "Inspect the working tree" + } + }) + .to_string(); + let result = run_codex_inner(&input).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let updated = &v["hookSpecificOutput"]["updatedInput"]; + + assert_eq!(updated["command"], "rtk git status --short"); + assert_eq!(updated["timeout"], 30_000); + assert_eq!(updated["description"], "Inspect the working tree"); + } + + #[test] + fn test_codex_ignores_other_events_and_tools() { + let other_event = json!({ + "hook_event_name": "PostToolUse", + "tool_name": "Bash", + "tool_input": { "command": "git status" } + }) + .to_string(); + let other_tool = json!({ + "hook_event_name": PRE_TOOL_USE_KEY, + "tool_name": "apply_patch", + "tool_input": { "command": "git status" } + }) + .to_string(); + + assert!(run_codex_inner(&other_event).is_none()); + assert!(run_codex_inner(&other_tool).is_none()); + } + + #[test] + fn test_codex_passthrough_for_unsupported_or_unsafe_commands() { + assert!(run_codex_inner(&codex_input("rtk git status")).is_none()); + assert!(run_codex_inner(&codex_input("htop")).is_none()); + assert!(run_codex_inner(&codex_input("git status > /tmp/status")).is_none()); + assert!(run_codex_inner(&codex_input("git status $(touch /tmp/x)")).is_none()); + } + + #[test] + fn test_codex_does_not_auto_allow_state_changing_commands() { + for cmd in [ + "git push origin main", + "git commit -m release", + "cargo install cargo-nextest", + "pnpm install", + "npm run deploy", + ] { + assert!( + run_codex_inner(&codex_input(cmd)).is_none(), + "Codex must retain native approval control for: {cmd}" + ); + } + } + + #[test] + fn test_codex_does_not_auto_allow_compound_or_partial_rewrites() { + for cmd in [ + "git status && rm -rf /tmp/example", + "git status; cargo test", + "git log | sh", + "git status & cargo test", + ] { + assert!( + run_codex_inner(&codex_input(cmd)).is_none(), + "Codex must not allow a compound rewrite: {cmd}" + ); + } + } + + #[test] + fn test_codex_auto_allow_policy_is_explicitly_read_only() { + for cmd in [ + "git status", + "git log -5", + "git diff --stat", + "git show HEAD", + "ls -la", + "head -20 README.md", + ] { + assert!(codex_can_auto_allow(cmd), "expected safe command: {cmd}"); + } + + for cmd in ["git branch -D old", "find . -delete", "rg --pre sh pattern"] { + assert!(!codex_can_auto_allow(cmd), "expected unsafe command: {cmd}"); + } + } + // --- Cursor handler --- fn cursor_input(cmd: &str) -> String { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index c02dc1e116..7056924088 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -13,15 +13,16 @@ use crate::hooks::constants::{ }; use super::constants::{ - BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, DROID_DIR, - DROID_EXECUTE_MATCHER, DROID_HOME_ENV, DROID_HOOKS_FILE, DROID_HOOKS_SUBDIR, - DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, - HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, - HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, - PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, + BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CODEX_HOOK_COMMAND, + CURSOR_HOOK_COMMAND, DROID_DIR, DROID_EXECUTE_MATCHER, DROID_HOME_ENV, DROID_HOOKS_FILE, + DROID_HOOKS_SUBDIR, DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, GEMINI_HOOK_FILE, HERMES_DIR, + HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, + HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, + PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, + SETTINGS_JSON, }; use super::integrity; -use super::is_claude_hook_command; +use super::{is_claude_hook_command, is_codex_hook_command}; // Embedded OpenCode plugin (auto-rewrite) const OPENCODE_PLUGIN: &str = include_str!("../../hooks/opencode/rtk.ts"); @@ -890,6 +891,11 @@ fn uninstall_codex_at(codex_dir: &Path, ctx: InitContext) -> Result> let mut removed = Vec::new(); let absolute_rtk_md_ref = codex_rtk_md_ref(codex_dir); + let hooks_json_path = codex_dir.join(HOOKS_JSON); + if remove_codex_hook_from_file(&hooks_json_path, ctx)? { + removed.push(format!("hooks.json: removed {} entry", CODEX_HOOK_COMMAND)); + } + let rtk_md_path = codex_dir.join(RTK_MD); if rtk_md_path.exists() { if dry_run { @@ -2325,19 +2331,28 @@ fn normalized_yaml_scalar(value: &str) -> Option { } fn run_codex_mode(global: bool, ctx: InitContext) -> Result<()> { - let (agents_md_path, rtk_md_path) = if global { + let (agents_md_path, rtk_md_path, hooks_json_path) = if global { let codex_dir = resolve_codex_dir()?; - (codex_dir.join(AGENTS_MD), codex_dir.join(RTK_MD)) + ( + codex_dir.join(AGENTS_MD), + codex_dir.join(RTK_MD), + codex_dir.join(HOOKS_JSON), + ) } else { - (PathBuf::from(AGENTS_MD), PathBuf::from(RTK_MD)) + ( + PathBuf::from(AGENTS_MD), + PathBuf::from(RTK_MD), + PathBuf::from(CODEX_DIR).join(HOOKS_JSON), + ) }; - run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, ctx) + run_codex_mode_with_paths(agents_md_path, rtk_md_path, hooks_json_path, global, ctx) } fn run_codex_mode_with_paths( agents_md_path: PathBuf, rtk_md_path: PathBuf, + hooks_json_path: PathBuf, global: bool, ctx: InitContext, ) -> Result<()> { @@ -2368,10 +2383,20 @@ fn run_codex_mode_with_paths( write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, RTK_MD, ctx)?; let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, ctx)?; + let hook_added = patch_codex_hooks_json(&hooks_json_path, ctx)?; if !dry_run { println!("\nRTK configured for Codex CLI.\n"); println!(" RTK.md: {}", rtk_md_path.display()); + println!( + " Hook: {} ({})", + hooks_json_path.display(), + if hook_added { + "registered" + } else { + "already present" + } + ); if added_ref { println!(" AGENTS.md: {} reference added", rtk_md_ref); } else { @@ -2388,11 +2413,146 @@ fn run_codex_mode_with_paths( agents_md_path.display() ); } + println!("\n Next: restart Codex, open `/hooks`, and trust the RTK command hook. Codex will not use it until it is trusted."); } Ok(()) } +fn codex_hook_already_present(root: &serde_json::Value) -> bool { + root.pointer("/hooks/PreToolUse") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("hooks")?.as_array()) + .flatten() + .filter_map(|hook| hook.get("command")?.as_str()) + .any(is_codex_hook_command) +} + +fn patch_codex_hooks_json(path: &Path, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; + let mut root = if path.exists() { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read Codex hooks: {}", path.display()))?; + if content.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse Codex hooks: {}", path.display()))? + } + } else { + serde_json::json!({}) + }; + + if codex_hook_already_present(&root) { + return Ok(false); + } + + insert_hook_entry(&mut root, CODEX_HOOK_COMMAND)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; + + if dry_run { + println!("[dry-run] would patch Codex hooks: {}", path.display()); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create Codex config directory: {}", + parent.display() + ) + })?; + } + if path.exists() { + fs::copy(path, path.with_extension("json.bak")) + .with_context(|| format!("Failed to backup Codex hooks: {}", path.display()))?; + } + atomic_write(path, &serialized)?; + if verbose > 0 { + eprintln!("Patched Codex hooks: {}", path.display()); + } + + Ok(true) +} + +fn remove_codex_hook_from_json(root: &mut serde_json::Value) -> bool { + let Some(pre_tool_use) = root + .pointer_mut("/hooks/PreToolUse") + .and_then(serde_json::Value::as_array_mut) + else { + return false; + }; + + let mut removed = false; + for entry in pre_tool_use.iter_mut() { + let Some(hooks) = entry + .get_mut("hooks") + .and_then(serde_json::Value::as_array_mut) + else { + continue; + }; + let before = hooks.len(); + hooks.retain(|hook| { + !hook + .get("command") + .and_then(serde_json::Value::as_str) + .is_some_and(is_codex_hook_command) + }); + removed |= hooks.len() != before; + } + pre_tool_use.retain(|entry| { + entry + .get("hooks") + .and_then(serde_json::Value::as_array) + .is_none_or(|hooks| !hooks.is_empty()) + }); + + removed +} + +fn remove_codex_hook_from_file(path: &Path, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; + if !path.exists() { + return Ok(false); + } + + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read Codex hooks: {}", path.display()))?; + if content.trim().is_empty() { + return Ok(false); + } + let mut root: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse Codex hooks: {}", path.display()))?; + if !remove_codex_hook_from_json(&mut root) { + return Ok(false); + } + + if dry_run { + println!( + "[dry-run] would remove RTK hook from Codex hooks: {}", + path.display() + ); + return Ok(true); + } + + fs::copy(path, path.with_extension("json.bak")) + .with_context(|| format!("Failed to backup Codex hooks: {}", path.display()))?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; + atomic_write(path, &serialized)?; + if verbose > 0 { + eprintln!("Removed RTK hook from Codex hooks: {}", path.display()); + } + + Ok(true) +} + // --- upsert_rtk_block: idempotent RTK block management --- #[derive(Debug, Clone, Copy, PartialEq)] @@ -4054,8 +4214,8 @@ fn show_claude_config() -> Result<()> { println!(" rtk init -g --uninstall # Remove all RTK artifacts"); println!(" rtk init -g --claude-md # Legacy: full injection into ~/.claude/CLAUDE.md"); println!(" rtk init -g --hook-only # Hook only, no RTK.md"); - println!(" rtk init --codex # Configure local AGENTS.md + RTK.md"); - println!(" rtk init -g --codex # Configure $CODEX_HOME/AGENTS.md + $CODEX_HOME/RTK.md (or ~/.codex/)"); + println!(" rtk init --codex # Configure local AGENTS.md + RTK.md + hooks.json"); + println!(" rtk init -g --codex # Configure global AGENTS.md + RTK.md + hooks.json"); println!(" rtk init -g --opencode # OpenCode plugin only"); println!(" rtk init -g --agent cursor # Install Cursor Agent hooks"); @@ -4066,9 +4226,11 @@ fn show_codex_config() -> Result<()> { let codex_dir = resolve_codex_dir()?; let global_agents_md = codex_dir.join(AGENTS_MD); let global_rtk_md = codex_dir.join(RTK_MD); + let global_hooks_json = codex_dir.join(HOOKS_JSON); let global_rtk_md_ref = codex_rtk_md_ref(&codex_dir); let local_agents_md = PathBuf::from(AGENTS_MD); let local_rtk_md = PathBuf::from(RTK_MD); + let local_hooks_json = PathBuf::from(CODEX_DIR).join(HOOKS_JSON); println!("rtk Configuration (Codex CLI):\n"); @@ -4078,6 +4240,19 @@ fn show_codex_config() -> Result<()> { println!("[--] Global RTK.md: not found"); } + if global_hooks_json.exists() { + let content = fs::read_to_string(&global_hooks_json)?; + match serde_json::from_str::(&content) { + Ok(root) if codex_hook_already_present(&root) => { + println!("[ok] Global hook: {}", global_hooks_json.display()); + } + Ok(_) => println!("[--] Global hooks.json exists but RTK hook is not configured"), + Err(_) => println!("[!!] Global hooks.json is invalid JSON"), + } + } else { + println!("[--] Global hook: not found"); + } + if global_agents_md.exists() { let content = fs::read_to_string(&global_agents_md)?; if has_rtk_reference(&content, &[RTK_MD_REF, global_rtk_md_ref.as_str()]) { @@ -4097,6 +4272,19 @@ fn show_codex_config() -> Result<()> { println!("[--] Local RTK.md: not found"); } + if local_hooks_json.exists() { + let content = fs::read_to_string(&local_hooks_json)?; + match serde_json::from_str::(&content) { + Ok(root) if codex_hook_already_present(&root) => { + println!("[ok] Local hook: {}", local_hooks_json.display()); + } + Ok(_) => println!("[--] Local hooks.json exists but RTK hook is not configured"), + Err(_) => println!("[!!] Local hooks.json is invalid JSON"), + } + } else { + println!("[--] Local hook: not found"); + } + if local_agents_md.exists() { let content = fs::read_to_string(&local_agents_md)?; if has_rtk_reference(&content, &[RTK_MD_REF]) { @@ -4111,8 +4299,8 @@ fn show_codex_config() -> Result<()> { } println!("\nUsage:"); - println!(" rtk init --codex # Configure local AGENTS.md + RTK.md"); - println!(" rtk init -g --codex # Configure $CODEX_HOME/AGENTS.md + $CODEX_HOME/RTK.md (or ~/.codex/)"); + println!(" rtk init --codex # Configure local AGENTS.md + RTK.md + hooks.json"); + println!(" rtk init -g --codex # Configure global AGENTS.md + RTK.md + hooks.json"); println!(" rtk init -g --codex --uninstall # Remove global Codex RTK artifacts"); Ok(()) @@ -5524,10 +5712,12 @@ mod tests { let temp = TempDir::new().unwrap(); let agents_md = temp.path().join("AGENTS.md"); let rtk_md = temp.path().join("RTK.md"); + let hooks_json = temp.path().join(HOOKS_JSON); run_codex_mode_with_paths( agents_md.clone(), rtk_md.clone(), + hooks_json.clone(), true, InitContext::default(), ) @@ -5539,6 +5729,45 @@ mod tests { fs::read_to_string(&agents_md).unwrap(), format!("{}\n", codex_rtk_md_ref(temp.path())) ); + let hooks: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap(); + assert!(codex_hook_already_present(&hooks)); + } + + #[test] + fn test_patch_codex_hooks_is_idempotent_and_preserves_existing_hooks() { + let temp = TempDir::new().unwrap(); + let hooks_json = temp.path().join(HOOKS_JSON); + fs::write( + &hooks_json, + serde_json::to_string_pretty(&serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "echo existing" }] + }], + "Stop": [{ + "hooks": [{ "type": "command", "command": "echo stop" }] + }] + } + })) + .unwrap(), + ) + .unwrap(); + + assert!(patch_codex_hooks_json(&hooks_json, InitContext::default()).unwrap()); + assert!(!patch_codex_hooks_json(&hooks_json, InitContext::default()).unwrap()); + + let root: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap(); + assert!(codex_hook_already_present(&root)); + assert_eq!(root["hooks"]["PreToolUse"].as_array().unwrap().len(), 2); + assert_eq!( + root["hooks"]["PreToolUse"][0]["hooks"][0]["command"], + "echo existing" + ); + assert_eq!(root["hooks"]["Stop"][0]["hooks"][0]["command"], "echo stop"); + assert!(hooks_json.with_extension("json.bak").exists()); } #[test] @@ -5955,6 +6184,39 @@ mod tests { assert!(content.contains("# Team rules")); } + #[test] + fn test_uninstall_codex_at_removes_hook_and_preserves_other_hooks() { + let temp = TempDir::new().unwrap(); + let hooks_json = temp.path().join(HOOKS_JSON); + fs::write( + &hooks_json, + serde_json::to_string_pretty(&serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "echo user hook" }, + { "type": "command", "command": CODEX_HOOK_COMMAND } + ] + }] + } + })) + .unwrap(), + ) + .unwrap(); + + let removed = uninstall_codex_at(temp.path(), InitContext::default()).unwrap(); + + assert_eq!(removed.len(), 1); + let root: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap(); + assert!(!codex_hook_already_present(&root)); + assert_eq!( + root["hooks"]["PreToolUse"][0]["hooks"][0]["command"], + "echo user hook" + ); + } + #[test] fn test_write_if_changed_dry_run_does_not_create_file() { let temp = TempDir::new().unwrap(); @@ -6012,10 +6274,12 @@ mod tests { let temp = TempDir::new().unwrap(); let agents_md = temp.path().join("AGENTS.md"); let rtk_md = temp.path().join("RTK.md"); + let hooks_json = temp.path().join(HOOKS_JSON); run_codex_mode_with_paths( agents_md.clone(), rtk_md.clone(), + hooks_json.clone(), true, InitContext { dry_run: true, @@ -6034,6 +6298,11 @@ mod tests { "dry-run must not create AGENTS.md: {}", agents_md.display() ); + assert!( + !hooks_json.exists(), + "dry-run must not create hooks.json: {}", + hooks_json.display() + ); } #[test] diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index fe7f4c3f1d..742c7fccfa 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -12,15 +12,40 @@ pub mod rewrite_cmd; pub mod trust; pub mod verify_cmd; -pub fn is_claude_hook_command(command: &str) -> bool { +fn is_rtk_hook_command(command: &str, agent: &str) -> bool { let parts = crate::discover::lexer::shell_split(command); - let [binary, hook, claude] = parts.as_slice() else { + let [_parsed_binary, hook, target] = parts.as_slice() else { return false; }; + // `shell_split` intentionally treats backslashes as shell escapes, which + // would erase separators in a quoted Windows path. Read the raw first + // token for basename detection while still using `shell_split` to validate + // the hook arguments. + let command = command.trim_start(); + let binary = match command.as_bytes().first() { + Some(b'"') => command[1..] + .find('"') + .map(|end| &command[1..end + 1]) + .unwrap_or(""), + Some(b'\'') => command[1..] + .find('\'') + .map(|end| &command[1..end + 1]) + .unwrap_or(""), + Some(_) => command.split_whitespace().next().unwrap_or(""), + None => "", + }; let binary_name = binary.rsplit(['/', '\\']).next().unwrap_or(binary); - binary_name == "rtk" && hook == "hook" && claude == "claude" + matches!(binary_name, "rtk" | "rtk.exe") && hook == "hook" && target == agent +} + +pub fn is_claude_hook_command(command: &str) -> bool { + is_rtk_hook_command(command, "claude") +} + +pub fn is_codex_hook_command(command: &str) -> bool { + is_rtk_hook_command(command, "codex") } #[cfg(test)] @@ -42,4 +67,19 @@ mod tests { assert!(!is_claude_hook_command("/opt/homebrew/bin/rtk hook cursor")); assert!(!is_claude_hook_command("echo rtk hook claude")); } + + #[test] + fn codex_hook_command_matches_bare_and_absolute_rtk() { + assert!(is_codex_hook_command("rtk hook codex")); + assert!(is_codex_hook_command("/opt/homebrew/bin/rtk hook codex")); + assert!(is_codex_hook_command( + "\"C:\\Program Files\\rtk.exe\" hook codex" + )); + } + + #[test] + fn codex_hook_command_rejects_other_commands() { + assert!(!is_codex_hook_command("rtk hook claude")); + assert!(!is_codex_hook_command("echo rtk hook codex")); + } } diff --git a/src/main.rs b/src/main.rs index 1c85bb55f9..753f2b7dad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -849,6 +849,8 @@ enum Commands { enum HookCommands { /// Process Claude Code PreToolUse hook (reads JSON from stdin) Claude, + /// Process Codex CLI PreToolUse hook (reads JSON from stdin) + Codex, /// Process Cursor Agent hook (reads JSON from stdin) Cursor, /// Process Gemini CLI BeforeTool hook (reads JSON from stdin) @@ -2376,6 +2378,10 @@ fn run_cli() -> Result { hooks::hook_cmd::run_claude()?; 0 } + HookCommands::Codex => { + hooks::hook_cmd::run_codex()?; + 0 + } HookCommands::Cursor => { hooks::hook_cmd::run_cursor()?; 0 @@ -3159,6 +3165,17 @@ mod tests { )); } + #[test] + fn test_hook_codex_parses() { + let cli = Cli::try_parse_from(["rtk", "hook", "codex"]).unwrap(); + assert!(matches!( + cli.command, + Commands::Hook { + command: HookCommands::Codex + } + )); + } + #[test] fn test_hook_check_parses() { let cli = Cli::try_parse_from(["rtk", "hook", "check", "git", "status"]).unwrap(); From 3a57866086655d68c5b82c7f743f28bd0c0b77bc Mon Sep 17 00:00:00 2001 From: Max Destors Date: Mon, 13 Jul 2026 19:30:32 +0200 Subject: [PATCH 2/2] refactor(hook): refactor hardcoded codex permissions Update permissions.rs --- src/hooks/hook_cmd.rs | 90 +++++++++++++++------------------------- src/hooks/permissions.rs | 31 ++++++++++++++ 2 files changed, 64 insertions(+), 57 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 26b3deb13d..7e5d2051f8 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -9,7 +9,6 @@ use anyhow::{Context, Result}; use serde_json::{json, Value}; use std::io::{self, Read, Write}; -use crate::discover::lexer::{contains_unattestable_construct, shell_split, tokenize, TokenKind}; use crate::discover::registry::{has_heredoc, rewrite_command}; const STDIN_CAP: usize = 1_048_576; // 1 MiB @@ -441,46 +440,6 @@ fn run_claude_inner(input: &str) -> Option { // ── Codex CLI native hook ───────────────────────────────────── -/// Codex only applies `updatedInput` when the hook also returns -/// `permissionDecision: allow`, which suppresses its native approval prompt. -/// Keep transparent rewrites to single, explicitly non-mutating commands that -/// Codex normally runs without approval. Everything else passes through so -/// Codex retains full control of its permission policy. -fn codex_can_auto_allow(cmd: &str) -> bool { - if contains_unattestable_construct(cmd) { - return false; - } - - let tokens = tokenize(cmd); - if tokens.iter().any(|token| { - matches!( - token.kind, - TokenKind::Operator | TokenKind::Pipe | TokenKind::Shellism - ) - }) { - return false; - } - - let args = shell_split(cmd); - match args.as_slice() { - [binary, subcommand, ..] - if matches!(binary.as_str(), "git" | "yadm") - && matches!(subcommand.as_str(), "status" | "log" | "diff" | "show") => - { - true - } - [binary, ..] - if matches!( - binary.as_str(), - "cat" | "head" | "tail" | "ls" | "tree" | "grep" | "wc" - ) => - { - true - } - _ => false, - } -} - fn process_codex_payload(v: &Value) -> PayloadAction { // Only rewrite Codex PreToolUse events for its Bash tool; ignore every // other event or tool payload so we never alter unrelated hook data. @@ -502,18 +461,14 @@ fn process_codex_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - if !codex_can_auto_allow(cmd) { - return PayloadAction::Skip { - reason: "skip:codex_native_approval", - cmd: cmd.to_string(), - }; - } - - let rewritten = match get_rewritten(cmd) { - Some(rewritten) if rewritten != cmd => rewritten, - _ => { + // Codex only applies `updatedInput` when the hook also returns + // `permissionDecision: allow`, which suppresses its native approval + // prompt. Ask, deny, and deferred decisions must therefore pass through. + let rewritten = match decide_hook_action(cmd, permissions::Host::Codex) { + HookDecision::AllowRewrite(rewritten) => rewritten, + HookDecision::AskRewrite(_) | HookDecision::Deny | HookDecision::Defer => { return PayloadAction::Skip { - reason: "skip:defer", + reason: "skip:codex_native_approval", cmd: cmd.to_string(), } } @@ -1369,7 +1324,14 @@ mod tests { } #[test] - fn test_codex_does_not_auto_allow_compound_or_partial_rewrites() { + fn test_codex_allows_fully_read_only_compounds_only() { + let result = run_codex_inner(&codex_input("git status && git log -1")).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + assert_eq!( + v.pointer("/hookSpecificOutput/updatedInput/command"), + Some(&json!("rtk git status && rtk git log -1")) + ); + for cmd in [ "git status && rm -rf /tmp/example", "git status; cargo test", @@ -1378,26 +1340,40 @@ mod tests { ] { assert!( run_codex_inner(&codex_input(cmd)).is_none(), - "Codex must not allow a compound rewrite: {cmd}" + "Codex must retain native approval control for: {cmd}" ); } } #[test] - fn test_codex_auto_allow_policy_is_explicitly_read_only() { + fn test_codex_hardcoded_rules_are_explicitly_read_only() { for cmd in [ "git status", "git log -5", "git diff --stat", "git show HEAD", + "yadm status", + "cat README.md", "ls -la", "head -20 README.md", + "tail -20 README.md", + "tree", + "grep pattern README.md", + "wc -l README.md", ] { - assert!(codex_can_auto_allow(cmd), "expected safe command: {cmd}"); + assert_eq!( + permissions::check_command_for(cmd, permissions::Host::Codex), + PermissionVerdict::Allow, + "expected allow verdict: {cmd}" + ); } for cmd in ["git branch -D old", "find . -delete", "rg --pre sh pattern"] { - assert!(!codex_can_auto_allow(cmd), "expected unsafe command: {cmd}"); + assert_eq!( + permissions::check_command_for(cmd, permissions::Host::Codex), + PermissionVerdict::Default, + "expected default verdict: {cmd}" + ); } } diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index da94c207d0..df3f60476c 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -33,6 +33,7 @@ pub fn check_command(cmd: &str) -> PermissionVerdict { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Host { Claude, + Codex, Cursor, Gemini, Droid, @@ -41,6 +42,7 @@ pub enum Host { pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { let (deny_rules, ask_rules, allow_rules) = match host { Host::Claude => load_permission_rules(), + Host::Codex => hardcoded_codex_rules(), Host::Cursor => load_cursor_rules(), Host::Gemini => load_gemini_rules(), Host::Droid => load_droid_rules(), @@ -228,6 +230,35 @@ fn global_config(dir: &str, file: &str) -> Option { read_json(&dirs::home_dir()?.join(dir).join(file)) } +/// Codex only applies a hook's `updatedInput` when the hook also returns an +/// allow decision. Its hook protocol cannot rewrite while retaining native +/// approval, so RTK hardcodes this small read-only command set it may +/// auto-allow. +fn hardcoded_codex_rules() -> (Vec, Vec, Vec) { + let allow = [ + "git status", + "git log", + "git diff", + "git show", + "yadm status", + "yadm log", + "yadm diff", + "yadm show", + "cat", + "head", + "tail", + "ls", + "tree", + "grep", + "wc", + ] + .into_iter() + .map(String::from) + .collect(); + + (Vec::new(), Vec::new(), allow) +} + fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new();