Skip to main content

lean_ctx/hook_handlers/
codex.rs

1//! Codex CLI hook handlers: PreToolUse rewrite/deny and SessionStart guidance.
2//!
3//! Extracted from `hook_handlers::mod` (#660/#966 LOC gate). Codex speaks its
4//! own hook JSON dialect (`hookSpecificOutput.additionalContext`,
5//! `permissionDecision` without Cursor/Claude's dual-format wrapping), so its
6//! handlers stay self-contained here rather than reusing the Cursor/Claude
7//! output builders in the parent module.
8
9use super::file_rewrite::rewrite_candidate;
10use super::{HOOK_STDIN_TIMEOUT, is_disabled, is_quiet, read_stdin_with_timeout, resolve_binary};
11
12pub(super) fn codex_rewrite_output(rewritten: &str) -> String {
13    serde_json::json!({
14        "hookSpecificOutput": {
15            "hookEventName": "PreToolUse",
16            "permissionDecision": "allow",
17            "updatedInput": {
18                "command": rewritten
19            }
20        }
21    })
22    .to_string()
23}
24
25pub fn handle_codex_pretooluse() {
26    if is_disabled() {
27        print!("{}", codex_allow_output());
28        return;
29    }
30    let binary = resolve_binary();
31    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
32        print!("{}", codex_allow_output());
33        return;
34    };
35
36    // #809: use serde_json instead of ad-hoc extract_json_field.
37    // The old find('"field":') scanner could mis-parse deeply nested
38    // or heavily escaped payloads. serde_json handles all edge cases.
39    let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&input) else {
40        print!("{}", codex_allow_output());
41        return;
42    };
43
44    let tool = parsed
45        .get("tool_name")
46        .and_then(|v| v.as_str())
47        .unwrap_or("");
48    if !matches!(tool, "Bash" | "bash") {
49        print!("{}", codex_allow_output());
50        return;
51    }
52
53    // Codex sends command at top level or inside tool_input.
54    let cmd = parsed
55        .get("command")
56        .or_else(|| parsed.get("tool_input").and_then(|ti| ti.get("command")))
57        .and_then(|v| v.as_str());
58    let Some(cmd) = cmd else {
59        print!("{}", codex_allow_output());
60        return;
61    };
62
63    if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
64        print!("{}", codex_rewrite_output(&rewritten));
65        return;
66    }
67
68    // Commands already routed through lean-ctx (e.g. `lean-ctx -c '...'` or
69    // `/opt/homebrew/bin/lean-ctx -c '...'`) must pass through — denying them
70    // blocks lean-ctx's own CLI surface (#801).
71    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
72        print!("{}", codex_allow_output());
73        return;
74    }
75
76    // Replace mode: deny non-rewritable Bash calls (agent must use ctx_shell)
77    let mode = crate::hooks::recommend_hook_mode("codex");
78    if mode == crate::hooks::HookMode::Replace {
79        print!("{}", codex_deny_output(cmd));
80    } else {
81        print!("{}", codex_allow_output());
82    }
83}
84
85pub(super) fn codex_deny_output(original_cmd: &str) -> String {
86    let msg = format!(
87        "Use ctx_shell instead — lean-ctx replace mode is active. \
88         Native Bash is denied for: {original_cmd:.80}",
89    );
90    serde_json::json!({
91        "hookSpecificOutput": {
92            "hookEventName": "PreToolUse",
93            "permissionDecision": "deny",
94            "permissionDecisionReason": msg
95        }
96    })
97    .to_string()
98}
99
100/// Allow-passthrough output for the Codex PreToolUse hook (#809).
101/// Codex treats an exit-0 hook with no stdout as an allowed tool call. Its
102/// `permissionDecision: "allow"` form is only valid when paired with
103/// `updatedInput`; emitting it for an unchanged command makes current Codex
104/// report `unsupported permissionDecision:allow` (#1019).
105pub(super) fn codex_allow_output() -> String {
106    String::new()
107}
108
109/// Emit SessionStart guidance through Codex's documented hidden-context channel.
110///
111/// Codex's hook contract (<https://developers.openai.com/codex/hooks>) accepts JSON
112/// on stdout with `hookSpecificOutput.additionalContext`, which is injected as
113/// model-visible developer context rather than surfaced to the user as plain text
114/// (#368). Plain stdout text is also added as developer context today, but only the
115/// JSON form is the documented additional-context channel; aligning with it
116/// future-proofs the hook for Codex's TUI-visibility fix (openai/codex#16933) and
117/// matches how the dedicated rules-injection path already emits context.
118pub(crate) fn session_start_additional_context_json(additional_context: &str) -> String {
119    serde_json::json!({
120        "hookSpecificOutput": {
121            "hookEventName": "SessionStart",
122            "additionalContext": additional_context,
123        }
124    })
125    .to_string()
126}
127
128pub(crate) fn emit_session_start_additional_context(additional_context: &str) {
129    println!(
130        "{}",
131        session_start_additional_context_json(additional_context)
132    );
133}
134
135/// Codex SessionStart guidance for the shell-hook surface (GH #625).
136///
137/// The Codex `PreToolUse` hook already rewrites every rewritable Bash command to
138/// `lean-ctx -c "<cmd>"` automatically (`codex_rewrite_output`: `allow` +
139/// `updatedInput`), so the old "prefer `lean-ctx -c`" line was redundant *and*
140/// taught nothing about getting raw output back — the one thing an agent cannot
141/// reach on its own once a command is auto-compressed. That gap is the shell-side
142/// twin of the MCP "too compressed" complaint: lacking an escape hatch, agents
143/// re-read the compressed view in tiny chunks instead of asking for raw bytes.
144///
145/// This hint mirrors the MCP `RECOVER` rule
146/// ([`crate::core::rules_canonical::RECOVER`]) on the non-MCP CLI surface: it
147/// states that the compressed view is not exact evidence and names the raw escape
148/// (`lean-ctx raw "<exact command>"`), which the rewrite hook leaves untouched (it
149/// already starts with `lean-ctx `, so `rewrite_candidate` returns `None`). The
150/// blocked-command sentence still covers the allowlist gate.
151pub(crate) const CODEX_SHELL_RECOVERY_HINT: &str = r#"RAW OUTPUT RULE (shell)
152
153Compressed shell output is not exact evidence. When you need exact content
154(file text, log lines, quotes, counts, line numbers), you MUST re-run the
155command as `lean-ctx raw "<exact command>"` — never reconstruct it from the
156compressed view with chunked reads (`cat`/`sed`/`head`/`tail`), and never quote
157compressed output as if it were exact. If a Bash call is blocked, re-run the
158exact command the hook suggests.
159
160Rule of thumb: back every exact claim with `lean-ctx raw` output."#;
161pub fn handle_codex_session_start() {
162    if is_quiet() {
163        return;
164    }
165    // Dedicated rules-injection mode (#343): the `hook observe` SessionStart hook
166    // injects the full rules summary as additionalContext, so stay silent here to
167    // avoid double-injecting on Codex (which fires both hooks on SessionStart).
168    if crate::core::config::Config::load().dedicated_session_context_active() {
169        return;
170    }
171    emit_session_start_additional_context(CODEX_SHELL_RECOVERY_HINT);
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn codex_deny_does_not_block_leanctx_cli_invocations() {
180        // #801: `lean-ctx -c '...'` must not be denied in replace mode.
181        // The deny output should only fire for truly native Bash commands.
182        let deny_msg = codex_deny_output("lean-ctx -c 'git status'");
183        // This is the deny message format — verify it exists for native commands
184        assert!(deny_msg.contains("deny"), "deny output must contain deny");
185
186        // A successful PreToolUse hook with no output allows the command.
187        let allow_msg = codex_allow_output();
188        assert!(allow_msg.is_empty(), "allow output must be empty");
189    }
190}