Skip to content
Open
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 @@ -360,7 +360,7 @@ RTK supports 13 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 + RTK.md |
| **Windsurf** | `rtk init --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
2 changes: 1 addition & 1 deletion 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)** — 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
- **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation

Expand Down
6 changes: 4 additions & 2 deletions hooks/codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## Specifics

- Prompt-level guidance via awareness document -- no programmatic hook
- Native `PreToolUse` hook via `hooks.json`
- Returns `hookSpecificOutput.updatedInput` for transparent command rewrite
- `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`
- Installed to project `.codex/hooks.json` by `rtk init --codex`
- Installed to `$CODEX_HOME/hooks.json` when set, otherwise `~/.codex/hooks.json`, by `rtk init -g --codex`
2 changes: 1 addition & 1 deletion 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 + PreToolUse hook in `$CODEX_HOME` or `~/.codex` | AGENTS.md + hooks.json |
| Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json |
| Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` |

Expand Down
2 changes: 2 additions & 0 deletions src/hooks/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ pub const BEFORE_TOOL_KEY: &str = "BeforeTool";
pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude";
/// Native Rust hook command for Cursor (replaces rtk-rewrite.sh).
pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor";
/// Native Rust hook command for Codex CLI lifecycle hooks.
pub const CODEX_HOOK_COMMAND: &str = "rtk hook codex";

pub const CONFIG_DIR: &str = ".config";
pub const OPENCODE_SUBDIR: &str = "opencode";
Expand Down
169 changes: 169 additions & 0 deletions src/hooks/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ enum PayloadAction {
rewritten: String,
output: Value,
},
Block {
cmd: String,
output: Value,
},
Skip {
reason: &'static str,
cmd: String,
Expand Down Expand Up @@ -379,6 +383,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 @@ -397,6 +405,138 @@ fn run_claude_inner(input: &str) -> Option<String> {
}
}

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

fn process_codex_payload_with_checker<F>(v: &Value, check_command: F) -> PayloadAction
where
F: FnOnce(&str) -> PermissionVerdict,
{
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,
};

if check_command(cmd) == PermissionVerdict::Deny {
return PayloadAction::Block {
cmd: cmd.to_string(),
output: json!({
"hookSpecificOutput": {
"hookEventName": PRE_TOOL_USE_KEY,
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked by RTK permission rule"
}
}),
};
}

let rewritten = match get_rewritten(cmd) {
Some(r) => r,
None => {
return PayloadAction::Skip {
reason: "skip:no_match",
cmd: cmd.to_string(),
}
}
};

let updated_input = {
let mut ti = v.get("tool_input").cloned().unwrap_or_else(|| json!({}));
if let Some(obj) = ti.as_object_mut() {
obj.insert("command".into(), Value::String(rewritten.clone()));
}
ti
};

PayloadAction::Rewrite {
cmd: cmd.to_string(),
rewritten,
output: json!({
"hookSpecificOutput": {
"hookEventName": PRE_TOOL_USE_KEY,
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": updated_input
}
}),
}
}

fn process_codex_payload(v: &Value) -> PayloadAction {
process_codex_payload_with_checker(v, permissions::check_command)
}

/// Run the Codex CLI PreToolUse hook natively.
pub fn run_codex() -> Result<()> {
let input = read_stdin_limited()?;

let input = 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(())
}

#[cfg(test)]
fn run_codex_inner(input: &str) -> Option<String> {
let v: Value = serde_json::from_str(input).ok()?;
match process_codex_payload(&v) {
PayloadAction::Rewrite { output, .. } | PayloadAction::Block { output, .. } => {
Some(output.to_string())
}
_ => None,
}
}

#[cfg(test)]
fn run_codex_inner_with_rules(
input: &str,
deny_rules: &[String],
ask_rules: &[String],
allow_rules: &[String],
) -> Option<String> {
let v: Value = serde_json::from_str(input).ok()?;
match process_codex_payload_with_checker(&v, |cmd| {
permissions::check_command_with_rules(cmd, deny_rules, ask_rules, allow_rules)
}) {
PayloadAction::Rewrite { output, .. } | PayloadAction::Block { output, .. } => {
Some(output.to_string())
}
_ => None,
}
}

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

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

#[test]
fn test_codex_rewrite_git_status_allows_updated_input() {
let result = run_codex_inner(&claude_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_deny_blocks_with_explicit_permission_decision() {
let deny = vec!["git status".to_string()];
let result = run_codex_inner_with_rules(&claude_input("git status"), &deny, &[], &[])
.expect("codex deny should emit blocking hook output");
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() || hook["updatedInput"].is_null());
}

// --- Cursor handler ---

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