use super::file_rewrite::rewrite_candidate;
use super::{HOOK_STDIN_TIMEOUT, is_disabled, is_quiet, read_stdin_with_timeout, resolve_binary};
fn codex_redirect_output(shared_output: &str) -> String {
serde_json::from_str::<serde_json::Value>(&shared_output)
.ok()
.and_then(|output| output.get("hookSpecificOutput").cloned())
.filter(|hook_output| hook_output.get("updatedInput").is_some())
.map(|hook_output| serde_json::json!({ "hookSpecificOutput": hook_output }).to_string())
.unwrap_or_default()
}
fn codex_file_tool_redirect(tool: &str, tool_input: Option<&serde_json::Value>) -> String {
let shared_output = match super::redirect::classify_redirect(tool) {
super::redirect::RedirectKind::Read => super::redirect::redirect_read(tool_input),
super::redirect::RedirectKind::Grep => super::redirect::redirect_grep(tool_input),
super::redirect::RedirectKind::Glob => super::redirect::redirect_glob(tool_input),
super::redirect::RedirectKind::None => return String::new(),
};
codex_redirect_output(&shared_output)
}
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;
}
if super::is_shadow_surface_active() {
let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
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("");
let tool_input = parsed
.get("tool_input")
.or_else(|| parsed.get("toolInput"))
.or_else(|| parsed.get("arguments"))
.or(Some(&parsed));
if matches!(
super::redirect::classify_redirect(tool),
super::redirect::RedirectKind::Read
| super::redirect::RedirectKind::Grep
| super::redirect::RedirectKind::Glob
) {
let redirected = codex_file_tool_redirect(tool, tool_input);
if !redirected.is_empty() {
print!("{redirected}");
return;
}
let mode = crate::hooks::recommend_hook_mode("codex");
if mode == crate::hooks::HookMode::Replace && super::deny::is_mcp_healthy() {
print!("{}", codex_deny_native_tool_output(tool));
} else {
print!("{}", codex_allow_output());
}
return;
}
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 super::file_rewrite::is_package_manager_install(cmd) {
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 && super::deny::is_mcp_healthy() {
print!("{}", codex_deny_output(cmd));
} else {
print!("{}", codex_allow_output());
}
}
pub(super) fn codex_deny_output(original_cmd: &str) -> String {
let suggestion = codex_deny_suggestion(original_cmd);
let msg = format!(
"lean-ctx replace mode: use MCP tools instead of native Bash.\n\
{suggestion}\n\
Denied: {original_cmd:.80}",
);
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": msg
}
})
.to_string()
}
fn codex_deny_native_tool_output(tool: &str) -> String {
let suggestion = match super::redirect::classify_redirect(tool) {
super::redirect::RedirectKind::Read => "Use ctx_read(path, mode) to read files",
super::redirect::RedirectKind::Grep => "Use ctx_search(pattern, path) to search",
super::redirect::RedirectKind::Glob => "Use ctx_glob(pattern) or ctx_tree(path, depth)",
super::redirect::RedirectKind::None => "Use the appropriate ctx_* MCP tool",
};
let msg =
format!("lean-ctx replace mode: native {tool} cannot be safely redirected.\n{suggestion}");
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": msg
}
})
.to_string()
}
fn codex_deny_suggestion(cmd: &str) -> &'static str {
let lower = cmd.to_ascii_lowercase();
if lower.starts_with("cat ")
|| lower.starts_with("head ")
|| lower.starts_with("tail ")
|| lower.starts_with("less ")
{
"Use ctx_read(path, mode) to read files"
} else if lower.starts_with("grep ") || lower.starts_with("rg ") || lower.starts_with("ag ") {
"Use ctx_search(pattern, path) to search"
} else if lower.starts_with("find ") || lower.starts_with("fd ") {
"Use ctx_glob(pattern) or ctx_tree(path, depth)"
} else if lower.starts_with("ls ") {
"Use ctx_tree(path, depth) for directory listings"
} else {
"Use ctx_shell(command) for shell commands"
}
}
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)
);
}
#[cfg(test)]
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(crate) fn codex_session_briefing() -> String {
let home = dirs::home_dir().unwrap_or_default();
if crate::core::rules_channel::client_hook_covered("codex", &home) {
return codex_session_briefing_hook_covered();
}
codex_session_briefing_explicit()
}
fn codex_session_briefing_hook_covered() -> String {
r#"lean-ctx SESSION BRIEFING
lean-ctx is active — hooks compress all native tool output transparently.
Use native Read, Grep, Glob, and Shell normally; lean-ctx optimizes context automatically.
Glob may fall back to ctx_glob if the hook cannot safely redirect.
Exclusive tools (no native equivalent — call these via MCP):
ctx_compose (understand code, call FIRST), ctx_callgraph (callers/impact),
ctx_search(action=symbol | semantic), ctx_knowledge, ctx_session.
CHECKPOINT: after 20+ tool calls, document progress with ctx_session(action="task", value="<status>")."#
.to_string()
}
fn codex_session_briefing_explicit() -> String {
let intent = crate::core::rules_canonical::INTENT;
let never = crate::core::rules_canonical::NEVER;
format!(
r#"lean-ctx SESSION BRIEFING
You have lean-ctx MCP tools available. Use them INSTEAD of native equivalents.
FILE TOOL RULE: use `ctx_read` instead of Read, `ctx_search` instead of Grep,
and `ctx_glob`/`ctx_tree` instead of Glob. The hook redirects only safe native
Read/Grep calls; it cannot turn an unsafe native call (or Glob) into an MCP call.
{intent}
{never}
CHECKPOINT: after 20+ tool calls, document progress with ctx_session(action="task", value="<status>").
RECOVER: compressed output is not exact evidence — use `lean-ctx raw "<cmd>"` when you need verbatim content."#
)
}
fn is_codex_exec_session() -> bool {
std::env::var("CODEX_EXEC_MODE").is_ok()
|| std::env::var("CODEX_SANDBOX_TYPE").is_ok()
|| (!std::io::IsTerminal::is_terminal(&std::io::stdin())
&& std::env::var("CODEX_PROFILE").is_ok())
}
pub(crate) fn codex_exec_preamble() -> String {
let never = crate::core::rules_canonical::NEVER;
format!(
"lean-ctx active. MCP tools: ctx_read, ctx_shell, ctx_search, ctx_compose, ctx_glob.\n RULE: use ctx_read/ctx_search/ctx_glob instead of Read/Grep/Glob or cat/grep/find/bash. ctx_compose FIRST to orient.\n RECOVER: lean-ctx raw for verbatim output.\n {never}",
)
}
pub fn handle_codex_session_start() {
if is_quiet() {
return;
}
if crate::core::config::Config::load().dedicated_session_context_active() {
return;
}
if is_codex_exec_session() {
emit_session_start_additional_context(&codex_exec_preamble());
} else {
emit_session_start_additional_context(&codex_session_briefing());
}
}
#[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");
}
#[test]
fn codex_redirect_adapter_keeps_only_valid_updated_input() {
let bare_allow = serde_json::json!({
"hookSpecificOutput": { "permissionDecision": "allow" }
})
.to_string();
assert!(codex_redirect_output(&bare_allow).is_empty());
let redirected = serde_json::json!({
"permission": "allow",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": { "path": "/tmp/lean-ctx-read.lctx" }
}
})
.to_string();
let output = codex_redirect_output(&redirected);
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(
json["hookSpecificOutput"]["updatedInput"]["path"],
"/tmp/lean-ctx-read.lctx"
);
assert!(json.get("permission").is_none(), "emit only Codex fields");
}
#[test]
fn native_file_tool_denials_name_the_mcp_replacement() {
assert!(codex_deny_native_tool_output("Read").contains("ctx_read"));
assert!(codex_deny_native_tool_output("Grep").contains("ctx_search"));
assert!(codex_deny_native_tool_output("Glob").contains("ctx_glob"));
}
#[test]
fn session_briefing_explicit_covers_native_file_tools() {
let briefing = codex_session_briefing_explicit();
assert!(briefing.contains("ctx_read` instead of Read"));
assert!(briefing.contains("ctx_search` instead of Grep"));
assert!(briefing.contains("ctx_glob`/`ctx_tree` instead of Glob"));
}
#[test]
fn session_briefing_hook_covered_is_fully_transparent() {
let briefing = codex_session_briefing_hook_covered();
assert!(briefing.contains("hooks compress all native tool output transparently"));
assert!(briefing.contains("Use native Read, Grep, Glob, and Shell normally"));
assert!(briefing.contains("ctx_compose"));
assert!(!briefing.contains("instead of Read"));
assert!(!briefing.contains("NEVER use native"));
}
}