Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ RTK supports 14 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 + AGENTS.md fallback instructions |
| **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) |
Expand Down
4 changes: 2 additions & 2 deletions docs/contributing/TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | Rust binary hook, hooks.json, AGENTS.md fallback guidance |
| [`opencode/`](../hooks/opencode/README.md) | OpenCode | TypeScript plugin, zx library, in-place mutation |

---
Expand All @@ -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 | `rtk hook codex` reads `PreToolUse` JSON | Yes (`updatedInput` with `permissionDecision: "allow"`) |
| 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.
Expand Down
8 changes: 4 additions & 4 deletions docs/guide/getting-started/supported-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Agent runs "cargo test"
| Hermes | Python plugin (`terminal` command mutation) | 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 fallback instructions | Yes |
| 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 |
Expand Down Expand Up @@ -154,8 +154,8 @@ 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)
```

### Kilo Code
Expand Down Expand Up @@ -186,7 +186,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 <cmd>` |

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

## Windows support

Expand Down
4 changes: 2 additions & 2 deletions hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)** — Rust binary hook, Codex `PreToolUse` JSON, `hooks.json`, `AGENTS.md` fallback guidance
- **[`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
Expand All @@ -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`) | Transparent rewrite | 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 |
Expand Down
30 changes: 27 additions & 3 deletions hooks/codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@

## Specifics

- Prompt-level guidance via awareness document -- no programmatic hook
- `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`
- Command-based `PreToolUse` hook via Codex `hooks.json`
- Returns `hookSpecificOutput.updatedInput.command` with `permissionDecision: "allow"` so Codex applies the rewritten shell command before execution
- `permissionDecision: "deny"` is used only for explicit RTK deny rules and does not include `updatedInput`
- `rtk-awareness.md` is still injected into `AGENTS.md` with an `@RTK.md` reference as prompt-level backup guidance
- Installed locally to `.codex/hooks.json` by `rtk init --codex`, or globally to `$CODEX_HOME/hooks.json` / `~/.codex/hooks.json` by `rtk init -g --codex`

## Hook entry

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "rtk hook codex",
"timeout": 10,
"statusMessage": "RTK rewriting shell command"
}
]
}
]
}
}
```
6 changes: 3 additions & 3 deletions src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 + `.codex/hooks.json` | AGENTS.md + hooks.json |
| 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` |
Expand Down Expand Up @@ -88,13 +88,13 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i
| Copilot VS Code (rtk hook copilot) | Yes | `permissionDecision: "ask"` — user prompted |
| 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 CLI (rtk hook codex) | No native ask rewrite | `permissionDecision: "allow"` + `updatedInput` so Codex applies the rewrite |

### 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 verdict to JSON `permissionDecision` field (Copilot/Gemini/Codex)

## Exit Code Contract

Expand Down
2 changes: 2 additions & 0 deletions src/hooks/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
206 changes: 206 additions & 0 deletions src/hooks/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@ enum PayloadAction {
rewritten: String,
output: Value,
},
Block {
cmd: String,
output: Value,
},
Skip {
reason: &'static str,
cmd: String,
Expand Down Expand Up @@ -420,6 +424,10 @@ pub fn run_claude() -> Result<()> {
audit_log("rewrite", &cmd, &rewritten);
let _ = writeln!(io::stdout(), "{output}");
}
PayloadAction::Block { cmd, output } => {
audit_log("deny", &cmd, "");
let _ = writeln!(io::stdout(), "{output}");
}
PayloadAction::Skip { reason, cmd } => {
audit_log(reason, &cmd, "");
}
Expand All @@ -434,10 +442,127 @@ fn run_claude_inner(input: &str) -> Option<String> {
let v: Value = serde_json::from_str(input).ok()?;
match process_claude_payload(&v) {
PayloadAction::Rewrite { output, .. } => Some(output.to_string()),
PayloadAction::Block { output, .. } => Some(output.to_string()),
_ => None,
}
}

// ── Codex CLI native hook ──────────────────────────────────────

fn process_codex_payload(v: &Value) -> PayloadAction {
let cmd = match v
.pointer("/tool_input/command")
.and_then(|c| c.as_str())
.filter(|c| !c.is_empty())
{
Some(c) => c,
None => return PayloadAction::Ignore,
};

process_codex_command(
cmd,
permissions::check_command_for(cmd, permissions::Host::Codex),
)
}

#[cfg(test)]
fn process_codex_payload_with_rules(
v: &Value,
deny: &[String],
ask: &[String],
allow: &[String],
) -> PayloadAction {
let cmd = match v
.pointer("/tool_input/command")
.and_then(|c| c.as_str())
.filter(|c| !c.is_empty())
{
Some(c) => c,
None => return PayloadAction::Ignore,
};

process_codex_command(
cmd,
permissions::check_command_with_rules(cmd, deny, ask, allow),
)
}

fn process_codex_command(cmd: &str, verdict: PermissionVerdict) -> PayloadAction {
match decide_from_verdict(cmd, verdict) {
HookDecision::Deny => PayloadAction::Block {
cmd: cmd.to_string(),
output: json!({
"hookSpecificOutput": {
"hookEventName": PRE_TOOL_USE_KEY,
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked by RTK permission rule"
}
}),
},
HookDecision::Defer => PayloadAction::Skip {
reason: "skip:defer",
cmd: cmd.to_string(),
},
HookDecision::AllowRewrite(rewritten) | HookDecision::AskRewrite(rewritten) => {
codex_rewrite_action(cmd, rewritten)
}
}
}

fn codex_rewrite_action(cmd: &str, rewritten: String) -> PayloadAction {
PayloadAction::Rewrite {
cmd: cmd.to_string(),
rewritten: rewritten.clone(),
output: json!({
"hookSpecificOutput": {
"hookEventName": PRE_TOOL_USE_KEY,
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": { "command": rewritten }
}
}),
}
}

/// 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::Block { cmd, output } => {
audit_log("deny", &cmd, "");
let _ = writeln!(io::stdout(), "{output}");
}
PayloadAction::Skip { reason, cmd } => {
audit_log(reason, &cmd, "");
}
PayloadAction::Ignore => {}
}

Ok(())
}

// ── Cursor native hook ─────────────────────────────────────────

/// Cursor on Windows ships hook payloads with one or more leading
Expand Down Expand Up @@ -1018,6 +1143,87 @@ mod tests {
assert!(run_claude_inner(&input).is_none());
}

// --- Codex handler ---

fn codex_input(cmd: &str) -> String {
json!({
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": cmd },
"tool_use_id": "call_123",
"session_id": "session_123",
"turn_id": "turn_123",
"cwd": "/tmp",
"transcript_path": null,
"model": "gpt-5.1-codex",
"permission_mode": "default"
})
.to_string()
}

fn run_codex_with_rules(
input: &str,
deny: &[String],
ask: &[String],
allow: &[String],
) -> Option<String> {
let v: Value = serde_json::from_str(input).ok()?;
match process_codex_payload_with_rules(&v, deny, ask, allow) {
PayloadAction::Rewrite { output, .. } => Some(output.to_string()),
PayloadAction::Block { output, .. } => Some(output.to_string()),
_ => None,
}
}

#[test]
fn test_codex_allow_rewrites_with_required_allow_decision() {
let result = run_codex_with_rules(&codex_input("git status"), &[], &[], &["*".into()])
.expect("allow rule should transparently rewrite");
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_default_rewrites_without_context_pollution() {
let result = run_codex_with_rules(&codex_input("git status"), &[], &[], &[])
.expect("default ask semantics should still rewrite for Codex");
let v: Value = serde_json::from_str(&result).unwrap();
let hook = &v["hookSpecificOutput"];

assert_eq!(hook["permissionDecision"], "allow");
assert_eq!(hook["updatedInput"]["command"], "rtk git status");
assert!(hook.get("additionalContext").is_none());
}

#[test]
fn test_codex_deny_returns_codex_deny_payload() {
let result =
run_codex_with_rules(&codex_input("git status"), &["git status".into()], &[], &[])
.expect("deny rule should emit a Codex deny payload");
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"], "deny");
assert_eq!(
hook["permissionDecisionReason"],
"Blocked by RTK permission rule"
);
assert!(hook.get("updatedInput").is_none());
}

#[test]
fn test_codex_passthrough_no_output() {
assert!(run_codex_with_rules(&codex_input("htop"), &[], &[], &[]).is_none());
assert!(run_codex_with_rules(&codex_input("rtk git status"), &[], &[], &[]).is_none());
assert!(run_codex_with_rules("not valid json {{{", &[], &[], &[]).is_none());
}

// --- Cursor handler ---

fn cursor_input(cmd: &str) -> String {
Expand Down
Loading