use crate::audit::{now, AuditEntry, AuditLog};
use crate::context;
use crate::policy::{Action, Policy};
use anyhow::Result;
use serde_json::json;
use std::io::Read;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Dialect {
ClaudeCode,
Cursor,
Codex,
Copilot,
}
pub struct ParsedHook {
pub dialect: Dialect,
pub command: String,
pub cwd: String,
pub session: Option<String>,
pub is_post: bool,
}
fn normalize_uri_path(p: &str) -> String {
let bytes = p.as_bytes();
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[2] == b':' && bytes[1].is_ascii_alphabetic() {
return p[1..].to_string();
}
p.to_string()
}
pub fn parse_input(raw: &str) -> Option<ParsedHook> {
let raw = raw.trim_start_matches('\u{feff}').trim();
let v: serde_json::Value = serde_json::from_str(raw).ok()?;
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_string);
let event = s("hook_event_name").map(|e| e.to_lowercase());
let is_pre = matches!(
event.as_deref(),
Some("pretooluse") | Some("beforeshellexecution")
);
let is_post = matches!(
event.as_deref(),
Some("posttooluse") | Some("aftershellexecution")
);
let is_cursor = v.get("cursor_version").is_some()
|| matches!(
event.as_deref(),
Some("beforeshellexecution") | Some("aftershellexecution")
)
|| (s("tool_name").as_deref() == Some("Shell") && v.get("conversation_id").is_some());
let command_from = || -> String {
if let Some(c) = s("command") {
return c;
}
v.get("tool_input")
.and_then(|t| t.get("command"))
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string()
};
let resolve_cwd = || -> String {
let top = s("cwd").unwrap_or_default();
if !top.is_empty() {
return top;
}
let ti = v
.get("tool_input")
.and_then(|t| t.get("cwd"))
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
if !ti.is_empty() {
return ti;
}
v.get("workspace_roots")
.and_then(|w| w.as_array())
.and_then(|a| a.first())
.and_then(|x| x.as_str())
.map(normalize_uri_path)
.unwrap_or_default()
};
if is_cursor && (is_pre || is_post) {
let command = command_from();
if command.is_empty() {
return None;
}
return Some(ParsedHook {
dialect: Dialect::Cursor,
command,
cwd: resolve_cwd(),
session: s("conversation_id").or_else(|| s("session_id")),
is_post,
});
}
if let Some(tool) = s("toolName") {
if tool == "shell" || tool == "bash" || tool == "run_in_terminal" {
let args_val = match v.get("toolArgs") {
Some(serde_json::Value::String(st)) => {
serde_json::from_str::<serde_json::Value>(st).unwrap_or(serde_json::Value::Null)
}
Some(other) => other.clone(),
None => serde_json::Value::Null,
};
let command = args_val
.get("command")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
if command.is_empty() {
return None;
}
return Some(ParsedHook {
dialect: Dialect::Copilot,
command,
cwd: s("cwd")
.or_else(|| s("workingDirectory"))
.unwrap_or_default(),
session: s("sessionId").or_else(|| s("session_id")),
is_post,
});
}
}
if s("tool_name").as_deref() == Some("Bash") || is_pre || is_post {
let command = v
.get("tool_input")
.and_then(|t| t.get("command"))
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
if command.is_empty() {
return None;
}
let looks_codex = s("agent")
.map(|a| a.to_lowercase().contains("codex"))
.unwrap_or(false)
|| s("source")
.map(|a| a.to_lowercase().contains("codex"))
.unwrap_or(false);
return Some(ParsedHook {
dialect: if looks_codex {
Dialect::Codex
} else {
Dialect::ClaudeCode
},
command,
cwd: s("cwd").unwrap_or_default(),
session: s("session_id").or_else(|| s("conversation_id")),
is_post,
});
}
None
}
pub fn render_response(dialect: Dialect, permission: &str, reason: &str) -> String {
match dialect {
Dialect::ClaudeCode => json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": permission,
"permissionDecisionReason": reason,
}
})
.to_string(),
Dialect::Cursor => json!({
"permission": permission,
"agent_message": reason,
"user_message": reason,
"agentMessage": reason,
"userMessage": reason,
})
.to_string(),
Dialect::Codex => json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": permission,
"permissionDecisionReason": reason,
}
})
.to_string(),
Dialect::Copilot => json!({
"permissionDecision": permission,
"permissionDecisionReason": reason,
})
.to_string(),
}
}
pub fn run() -> Result<()> {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
if let Ok(dbg) = std::env::var("TERMAXA_HOOK_DEBUG") {
use std::io::Write as _;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&dbg)
{
let argv: Vec<String> = std::env::args().collect();
let _ = writeln!(
f,
"--- {} ---\nARGV: {:?}\nSTDIN_LEN: {}\nSTDIN: {}\n",
now().1,
argv,
buf.len(),
buf
);
}
}
let input = match parse_input(&buf) {
Some(p) => p,
None => return Ok(()), };
let command = input.command.clone();
if input.is_post {
let start_dir = if !input.cwd.is_empty() && std::path::Path::new(&input.cwd).is_dir() {
std::path::PathBuf::from(&input.cwd)
} else {
std::env::current_dir().unwrap_or_default()
};
if let Ok(paths) = crate::paths::resolve_from(&start_dir) {
if let Ok(log) = AuditLog::new(&paths.state_dir) {
let (ts_ms, ts) = now();
let _ = log.append(&AuditEntry {
ts_ms,
ts,
source: "post".into(),
command: command.clone(),
decision: "executed".into(),
matched_rule: None,
reason: "post-execution receipt".into(),
signals: vec![],
escalated: false,
session: input.session.clone(),
backup: None,
preview: None,
intent: crate::intent::classify_command(&command)
.map(|i| i.label().to_string()),
approved: Some(true),
exit_code: None,
cwd: input.cwd.clone(),
});
}
}
return Ok(());
}
if input.is_post {
let start_dir = if !input.cwd.is_empty() && std::path::Path::new(&input.cwd).is_dir() {
std::path::PathBuf::from(&input.cwd)
} else {
std::env::current_dir().unwrap_or_default()
};
if let Ok(paths) = crate::paths::resolve_from(&start_dir) {
if let Ok(log) = AuditLog::new(&paths.state_dir) {
let (ts_ms, ts) = now();
let _ = log.append(&AuditEntry {
ts_ms,
ts,
source: "post".into(),
command: command.clone(),
decision: "executed".into(),
matched_rule: None,
reason: "post-execution receipt".into(),
signals: vec![],
escalated: false,
session: input.session.clone(),
backup: None,
preview: None,
intent: crate::intent::classify_command(&command)
.map(|i| i.label().to_string()),
approved: Some(true),
exit_code: None,
cwd: input.cwd.clone(),
});
}
}
return Ok(());
}
let start_dir = if !input.cwd.is_empty() && std::path::Path::new(&input.cwd).is_dir() {
std::path::PathBuf::from(&input.cwd)
} else {
std::env::current_dir().unwrap_or_default()
};
let paths = crate::paths::resolve_from(&start_dir)?;
if let Ok(dbg) = std::env::var("TERMAXA_HOOK_DEBUG") {
use std::io::Write as _;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&dbg)
{
let _ = writeln!(
f,
"[{}] dialect={:?} process_cwd={:?} payload_cwd={:?} resolved_policy={}",
now().1,
input.dialect,
std::env::current_dir().ok(),
input.cwd,
paths.policy_file().display()
);
}
}
let policy = Policy::load(&paths.policy_file())?;
let base = policy.evaluate_command(&command);
let signals = context::gather(&command);
let (mut decision, escalated) = context::apply(base, &signals);
let intent_label = crate::intent::classify_command(&command).map(|i| i.label().to_string());
if decision.action == Action::Ask {
let log_path = paths.state_dir.join("logs").join("audit.jsonl");
if let Some((_intent, _prior, reason)) = crate::intent::maybe_trip(
&paths.policy_file(),
&log_path,
input.session.as_deref(),
&command,
) {
decision = crate::policy::Decision {
action: Action::Deny,
matched_rule: Some(crate::intent::BREAKER_RULE.to_string()),
reason,
};
}
}
let preview_summary = crate::preview::generate(&command).map(|p| p.summary);
let mut backup_id: Option<String> = None;
if decision.action != Action::Deny {
if let Ok(Some(rec)) = crate::backup::take(&paths.state_dir, &command) {
backup_id = Some(rec.id);
}
}
if let Ok(log) = AuditLog::new(&paths.state_dir) {
let (ts_ms, ts) = now();
let _ = log.append(&AuditEntry {
ts_ms,
ts,
source: "hook".into(),
command: command.clone(),
decision: decision.action.to_string(),
matched_rule: decision.matched_rule.clone(),
reason: decision.reason.clone(),
signals: signals.iter().map(|s| s.label.clone()).collect(),
escalated,
session: input.session.clone(),
backup: backup_id.clone(),
preview: preview_summary.clone(),
intent: intent_label.clone(),
approved: None,
exit_code: None,
cwd: input.cwd.clone(),
});
}
let permission = match decision.action {
Action::Allow => "allow",
Action::Ask => "ask",
Action::Deny => "deny",
};
let mut reason = format!("[termaxa] {}", decision.reason);
if escalated {
reason.push_str(" (context-escalated)");
}
if matches!(decision.action, Action::Ask | Action::Deny) {
if let Some(s) = &preview_summary {
reason.push_str(&format!(" | {}", s));
}
}
if let Some(id) = &backup_id {
reason.push_str(&format!(" | backup {}", id));
}
crate::notify::maybe_send(
&policy,
&decision.action.to_string(),
&command,
&decision.reason,
"hook",
);
println!("{}", render_response(input.dialect, permission, &reason));
use std::io::Write as _;
let _ = std::io::stdout().flush();
if decision.action == Action::Deny {
std::process::exit(2);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_real_payload_uses_workspace_roots_when_cwd_empty() {
let raw = "\u{feff}{\"command\":\"rm -rf .cursor .git\",\"cwd\":\"\",\"hook_event_name\":\"beforeShellExecution\",\"workspace_roots\":[\"/c:/Users/User/code/proj\"],\"conversation_id\":\"c9\"}";
let p = parse_input(raw).expect("must parse Cursor payload with BOM + empty cwd");
assert_eq!(p.dialect, Dialect::Cursor);
assert_eq!(p.command, "rm -rf .cursor .git");
assert_eq!(p.cwd, "c:/Users/User/code/proj");
}
#[test]
fn normalize_uri_path_handles_windows_and_unix() {
assert_eq!(normalize_uri_path("/c:/Users/x"), "c:/Users/x");
assert_eq!(normalize_uri_path("/home/user/proj"), "/home/user/proj"); assert_eq!(normalize_uri_path("c:/already/native"), "c:/already/native");
}
#[test]
fn bom_prefixed_json_still_parses() {
let raw = "\u{feff}{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"ls\"}}";
assert_eq!(parse_input(raw).unwrap().command, "ls");
}
#[test]
fn detects_cursor_dialect() {
let raw = r#"{"hook_event_name":"beforeShellExecution","command":"git push --force","cwd":"/w","conversation_id":"c-1"}"#;
let p = parse_input(raw).unwrap();
assert_eq!(p.dialect, Dialect::Cursor);
assert_eq!(p.command, "git push --force");
assert_eq!(p.session.as_deref(), Some("c-1"));
}
#[test]
fn cursor_311_pretooluse_is_gated() {
let raw = r#"{"conversation_id":"758","tool_name":"Shell","tool_input":{"command":"git status","cwd":"C:\\Users\\User\\code\\p","timeout":30000},"cwd":"C:\\Users\\User\\code\\p","session_id":"758","hook_event_name":"preToolUse","cursor_version":"3.11.25","workspace_roots":["/C:/Users/User/code/p"]}"#;
let p = parse_input(raw).expect("Cursor 3.11 preToolUse must be recognized");
assert_eq!(p.dialect, Dialect::Cursor);
assert_eq!(p.command, "git status");
assert!(!p.is_post, "preToolUse must gate, not receipt");
assert_eq!(p.session.as_deref(), Some("758"));
}
#[test]
fn cursor_311_posttooluse_is_receipt() {
let raw = r#"{"conversation_id":"758","tool_name":"Shell","tool_input":{"command":"git status","cwd":"C:\\Users\\User\\code\\p"},"tool_output":"{\"output\":\"\",\"exitCode\":0}","duration":174.96,"cwd":"C:\\Users\\User\\code\\p","session_id":"758","hook_event_name":"postToolUse","cursor_version":"3.11.25","workspace_roots":["/C:/Users/User/code/p"]}"#;
let p = parse_input(raw).expect("Cursor 3.11 postToolUse must be recognized");
assert_eq!(p.dialect, Dialect::Cursor);
assert_eq!(p.command, "git status");
assert!(p.is_post, "postToolUse must be a receipt");
}
#[test]
fn cursor_311_empty_cwd_recovers_from_tool_input_or_roots() {
let raw = r#"{"conversation_id":"758","tool_name":"Shell","tool_input":{"command":"where.exe git","cwd":""},"cwd":"","session_id":"758","hook_event_name":"preToolUse","cursor_version":"3.11.25","workspace_roots":["/C:/Users/User/code/p"]}"#;
let p = parse_input(raw).unwrap();
assert_eq!(
p.cwd, "C:/Users/User/code/p",
"must recover cwd from workspace_roots"
);
}
#[test]
fn old_cursor_beforeshellexecution_still_works() {
let raw = "\u{feff}{\"command\":\"rm -rf .cursor .git\",\"cwd\":\"\",\"hook_event_name\":\"beforeShellExecution\",\"workspace_roots\":[\"/c:/Users/User/code/proj\"],\"conversation_id\":\"c9\"}";
let p = parse_input(raw).unwrap();
assert_eq!(p.dialect, Dialect::Cursor);
assert_eq!(p.command, "rm -rf .cursor .git");
assert!(!p.is_post);
}
#[test]
fn detects_claude_dialect() {
let raw = r#"{"tool_name":"Bash","tool_input":{"command":"git status"},"session_id":"s-1","cwd":"/w"}"#;
let p = parse_input(raw).unwrap();
assert_eq!(p.dialect, Dialect::ClaudeCode);
assert_eq!(p.command, "git status");
}
#[test]
fn detects_post_execution_events() {
let cur = r#"{"hook_event_name":"afterShellExecution","command":"rm -rf ./cache","cwd":"/w","conversation_id":"c1"}"#;
let p = parse_input(cur).unwrap();
assert!(p.is_post);
assert_eq!(p.dialect, Dialect::Cursor);
assert_eq!(p.command, "rm -rf ./cache");
let cc = r#"{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"git commit -m x"},"session_id":"s1"}"#;
let p = parse_input(cc).unwrap();
assert!(p.is_post);
assert_eq!(p.dialect, Dialect::ClaudeCode);
let pre = r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#;
assert!(!parse_input(pre).unwrap().is_post);
}
#[test]
fn ignores_unrelated_input() {
assert!(parse_input(r#"{"hook_event_name":"afterFileEdit"}"#).is_none());
assert!(parse_input("not json").is_none());
}
#[test]
fn renders_each_dialect() {
let c = render_response(Dialect::Cursor, "deny", "[termaxa] blocked");
assert!(c.contains("\"permission\":\"deny\"") && c.contains("agent_message"));
let cc = render_response(Dialect::ClaudeCode, "ask", "[termaxa] careful");
assert!(cc.contains("hookSpecificOutput") && cc.contains("permissionDecision"));
}
#[test]
fn detects_copilot_dialect() {
let raw =
r#"{"toolName":"shell","toolArgs":"{\"command\":\"rm -rf /\"}","sessionId":"cop-1"}"#;
let p = parse_input(raw).unwrap();
assert_eq!(p.dialect, Dialect::Copilot);
assert_eq!(p.command, "rm -rf /");
assert_eq!(p.session.as_deref(), Some("cop-1"));
}
#[test]
fn copilot_accepts_inline_toolargs_object() {
let raw = r#"{"toolName":"shell","toolArgs":{"command":"git status"}}"#;
let p = parse_input(raw).unwrap();
assert_eq!(p.dialect, Dialect::Copilot);
assert_eq!(p.command, "git status");
}
#[test]
fn detects_codex_dialect() {
let raw = r#"{"hook_event_name":"PreToolUse","agent":"codex-cli","tool_input":{"command":"git push --force"}}"#;
let p = parse_input(raw).unwrap();
assert_eq!(p.dialect, Dialect::Codex);
assert_eq!(p.command, "git push --force");
}
#[test]
fn shared_shape_without_tag_defaults_to_claude() {
let raw = r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#;
assert_eq!(parse_input(raw).unwrap().dialect, Dialect::ClaudeCode);
}
#[test]
fn copilot_render_is_unwrapped() {
let r = render_response(Dialect::Copilot, "deny", "[termaxa] no");
assert!(r.contains("permissionDecision") && !r.contains("hookSpecificOutput"));
}
}