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}