use super::file_rewrite::rewrite_candidate;
use super::{HOOK_STDIN_TIMEOUT, is_disabled, is_quiet, read_stdin_with_timeout, resolve_binary};
pub(super) fn codex_rewrite_output(rewritten: &str) -> String {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"command": rewritten
}
}
})
.to_string()
}
pub fn handle_codex_pretooluse() {
if is_disabled() {
print!("{}", codex_allow_output());
return;
}
let binary = resolve_binary();
let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
print!("{}", codex_allow_output());
return;
};
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&input) else {
print!("{}", codex_allow_output());
return;
};
let tool = parsed
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("");
if !matches!(tool, "Bash" | "bash") {
print!("{}", codex_allow_output());
return;
}
let cmd = parsed
.get("command")
.or_else(|| parsed.get("tool_input").and_then(|ti| ti.get("command")))
.and_then(|v| v.as_str());
let Some(cmd) = cmd else {
print!("{}", codex_allow_output());
return;
};
if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
print!("{}", codex_rewrite_output(&rewritten));
return;
}
if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
print!("{}", codex_allow_output());
return;
}
let mode = crate::hooks::recommend_hook_mode("codex");
if mode == crate::hooks::HookMode::Replace {
print!("{}", codex_deny_output(cmd));
} else {
print!("{}", codex_allow_output());
}
}
pub(super) fn codex_deny_output(original_cmd: &str) -> String {
let msg = format!(
"Use ctx_shell instead — lean-ctx replace mode is active. \
Native Bash is denied for: {original_cmd:.80}",
);
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": msg
}
})
.to_string()
}
pub(super) fn codex_allow_output() -> String {
String::new()
}
pub(crate) fn session_start_additional_context_json(additional_context: &str) -> String {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": additional_context,
}
})
.to_string()
}
pub(crate) fn emit_session_start_additional_context(additional_context: &str) {
println!(
"{}",
session_start_additional_context_json(additional_context)
);
}
pub(crate) const CODEX_SHELL_RECOVERY_HINT: &str = r#"RAW OUTPUT RULE (shell)
Compressed shell output is not exact evidence. When you need exact content
(file text, log lines, quotes, counts, line numbers), you MUST re-run the
command as `lean-ctx raw "<exact command>"` — never reconstruct it from the
compressed view with chunked reads (`cat`/`sed`/`head`/`tail`), and never quote
compressed output as if it were exact. If a Bash call is blocked, re-run the
exact command the hook suggests.
Rule of thumb: back every exact claim with `lean-ctx raw` output."#;
pub fn handle_codex_session_start() {
if is_quiet() {
return;
}
if crate::core::config::Config::load().dedicated_session_context_active() {
return;
}
emit_session_start_additional_context(CODEX_SHELL_RECOVERY_HINT);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codex_deny_does_not_block_leanctx_cli_invocations() {
let deny_msg = codex_deny_output("lean-ctx -c 'git status'");
assert!(deny_msg.contains("deny"), "deny output must contain deny");
let allow_msg = codex_allow_output();
assert!(allow_msg.is_empty(), "allow output must be empty");
}
}