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
8 changes: 4 additions & 4 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)** — Native `PreToolUse` hook plus awareness document, `.codex/hooks.json` or `$CODEX_HOME/hooks.json`
- **[`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 | `PreToolUse` command hook | `updatedInput.command` | Yes |
| 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 Expand Up @@ -240,9 +240,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

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

## 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`
- Native `PreToolUse` hook delegates Bash commands to `rtk hook codex`
- Rewrites are returned through Codex's `updatedInput` protocol; commands with no RTK rewrite emit no output
- Existing `hooks.json` entries are preserved and repeated installation is idempotent
- `rtk-awareness.md` is installed as `RTK.md` and referenced from `AGENTS.md` for usage guidance
- Global installation uses `$CODEX_HOME` when set, otherwise `~/.codex/`; local installation uses `.codex/hooks.json`
- After installation, review and trust the hook with `/hooks`, then restart Codex
2 changes: 1 addition & 1 deletion hooks/codex/rtk-awareness.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## Rule

Always prefix shell commands with `rtk`.
Supported shell commands are rewritten automatically by the RTK hook. Use `rtk` explicitly for RTK meta commands or when you want to opt in manually.

Examples:

Expand Down
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` + `hooks.json` in `$CODEX_HOME` or `~/.codex` | `AGENTS.md`, existing `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 @@ -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`) | Native Codex policy applies after rewrite | allow with `updatedInput` |

### 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 rewrites and verdicts to each agent's JSON hook protocol (Codex/Copilot/Gemini)

## 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.
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.
Expand Down
8 changes: 4 additions & 4 deletions src/hooks/hook_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ mod tests {
use super::*;
use crate::hooks::constants::{
CODEX_DIR, CONFIG_DIR, CURSOR_DIR, GEMINI_DIR, GEMINI_HOOK_FILE, HERMES_DIR,
HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME,
HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON,
OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR,
};

Expand All @@ -164,7 +164,7 @@ mod tests {
home.join(CURSOR_DIR)
.join(HOOKS_SUBDIR)
.join(REWRITE_HOOK_FILE),
home.join(CODEX_DIR).join("AGENTS.md"),
home.join(CODEX_DIR).join(HOOKS_JSON),
home.join(GEMINI_DIR)
.join(HOOKS_SUBDIR)
.join(GEMINI_HOOK_FILE),
Expand Down Expand Up @@ -269,9 +269,9 @@ mod tests {
#[test]
fn test_other_integration_codex() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(CODEX_DIR).join("AGENTS.md");
let path = tmp.path().join(CODEX_DIR).join(HOOKS_JSON);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"agents").unwrap();
std::fs::write(&path, b"hooks").unwrap();
assert!(other_integration_installed(tmp.path()));
}

Expand Down
152 changes: 152 additions & 0 deletions src/hooks/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,101 @@ fn run_claude_inner(input: &str) -> Option<String> {
}
}

// Codex native hook

fn process_codex_payload(v: &Value) -> PayloadAction {
if v.get("tool_name").and_then(Value::as_str) != Some("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 crate::discover::lexer::contains_unattestable_construct(cmd) {
return PayloadAction::Skip {
reason: "skip:defer",
cmd: cmd.to_string(),
};
}

let Some(rewritten) = get_rewritten(cmd) else {
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(input) = updated_input.as_object_mut() {
input.insert("command".into(), Value::String(rewritten.clone()));
}

// Codex requires `permissionDecision: allow` whenever updatedInput is
// returned. This accepts the hook rewrite; Codex still applies its normal
// sandbox and approval policy to the rewritten tool invocation.
let output = json!({
"hookSpecificOutput": {
"hookEventName": PRE_TOOL_USE_KEY,
"permissionDecision": "allow",
"permissionDecisionReason": "RTK auto-rewrite",
"updatedInput": updated_input
}
});

PayloadAction::Rewrite {
cmd: cmd.to_string(),
rewritten,
output,
}
}

/// Run the Codex 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<String> {
let v: Value = serde_json::from_str(strip_leading_bom(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
Expand Down Expand Up @@ -1137,6 +1232,63 @@ mod tests {
assert!(run_claude_inner(&input).is_none());
}

// --- Codex handler ---

fn codex_input(cmd: &str) -> String {
json!({
"hook_event_name": "PreToolUse",
"permission_mode": "default",
"tool_name": "Bash",
"tool_input": { "command": cmd }
})
.to_string()
}

#[test]
fn test_codex_rewrites_bash_command() {
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_preserves_tool_input_fields() {
let input = json!({
"tool_name": "Bash",
"tool_input": {
"command": "cargo test",
"description": "Run the test suite"
}
})
.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 cargo test");
assert_eq!(updated["description"], "Run the test suite");
}

#[test]
fn test_codex_passthrough_cases_emit_no_output() {
assert!(run_codex_inner(&codex_input("htop")).is_none());
assert!(run_codex_inner(&codex_input("rtk git status")).is_none());
assert!(run_codex_inner(&codex_input("git status $(rm -rf /tmp/x)")).is_none());
assert!(run_codex_inner("not valid json {{{").is_none());

let non_bash = json!({
"tool_name": "apply_patch",
"tool_input": { "command": "git status" }
})
.to_string();
assert!(run_codex_inner(&non_bash).is_none());
}

// --- Cursor handler ---

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