use std::io::Read;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::evaluator;
use crate::runtime;
use longline::config;
use longline::domain::Decision;
use longline::policy;
#[derive(Debug, Deserialize)]
struct ClaudeHookInput {
session_id: Option<String>,
cwd: Option<String>,
#[allow(dead_code)]
hook_event_name: Option<String>,
tool_name: String,
tool_input: ClaudeToolInput,
#[allow(dead_code)]
tool_use_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ClaudeToolInput {
command: Option<String>,
#[allow(dead_code)]
description: Option<String>,
file_path: Option<String>,
path: Option<String>,
#[allow(dead_code)]
pattern: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ClaudePreToolUseDecisionOutput {
hook_event_name: String,
permission_decision: Decision,
permission_decision_reason: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ClaudeHookOutput {
hook_specific_output: ClaudePreToolUseDecisionOutput,
}
#[derive(Debug, Clone)]
enum ClaudeHookAction {
Evaluate(evaluator::Invocation),
Passthrough { cwd: Option<String> },
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct HookOptions {
pub ask_on_deny: bool,
pub ask_ai: bool,
pub ask_ai_lenient: bool,
pub cli_trust_level: Option<policy::TrustLevel>,
pub cli_safety_level: Option<policy::SafetyLevel>,
}
pub(crate) fn run_hook(
rules_config: policy::RulesConfig,
home: &Path,
options: HookOptions,
) -> i32 {
let mut input_str = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut input_str) {
let output = ClaudeHookOutput::decision(Decision::Ask, "Failed to read stdin");
print_json(&output);
eprintln!("longline: failed to read stdin: {e}");
return 0;
}
run_hook_input(rules_config, home, options, &input_str)
}
fn run_hook_input(
rules_config: policy::RulesConfig,
home: &Path,
options: HookOptions,
input_str: &str,
) -> i32 {
let hook_input: ClaudeHookInput = match serde_json::from_str(input_str) {
Ok(h) => h,
Err(e) => {
let output = ClaudeHookOutput::decision(
Decision::Ask,
&format!("Failed to parse hook input: {e}"),
);
print_json(&output);
return 0;
}
};
match action_from_input(hook_input) {
ClaudeHookAction::Evaluate(invocation) => {
let cwd_path = invocation.cwd().map(PathBuf::from);
let final_config = match config::finalize_config(
rules_config,
home,
cwd_path.as_deref(),
options.cli_trust_level,
options.cli_safety_level,
) {
Ok(final_config) => final_config,
Err(e) => {
eprintln!("longline: {e}");
return 2;
}
};
let audit_log_path = runtime::claude::audit_log_path(home);
let outcome = evaluator::evaluate_invocation(
final_config,
&audit_log_path,
invocation,
evaluator::EvaluationOptions {
ask_on_deny: options.ask_on_deny,
ask_ai: options.ask_ai,
ask_ai_lenient: options.ask_ai_lenient,
},
);
let output = ClaudeHookOutput::decision(outcome.decision, &outcome.reason);
print_json(&output);
0
}
ClaudeHookAction::Passthrough { cwd } => {
let cwd_path = cwd.as_deref().map(PathBuf::from);
if let Err(e) = config::finalize_config(
rules_config,
home,
cwd_path.as_deref(),
options.cli_trust_level,
options.cli_safety_level,
) {
eprintln!("longline: {e}");
return 2;
}
println!("{{}}");
0
}
}
}
fn action_from_input(input: ClaudeHookInput) -> ClaudeHookAction {
match input.tool_name.as_str() {
"Read" => ClaudeHookAction::Evaluate(evaluator::Invocation::ReadPath {
tool_name: input.tool_name.clone(),
path: input.tool_input.file_path,
cwd: input.cwd,
session_id: input.session_id,
}),
"Grep" | "Glob" => ClaudeHookAction::Evaluate(evaluator::Invocation::SearchPath {
tool_name: input.tool_name.clone(),
path: input.tool_input.path,
cwd: input.cwd,
session_id: input.session_id,
}),
"Bash" => ClaudeHookAction::Evaluate(evaluator::Invocation::Shell {
command: input.tool_input.command,
cwd: input.cwd,
session_id: input.session_id,
}),
_ => ClaudeHookAction::Passthrough { cwd: input.cwd },
}
}
fn print_json<T: serde::Serialize>(value: &T) {
match serde_json::to_string(value) {
Ok(json) => println!("{json}"),
Err(e) => {
eprintln!("longline: failed to serialize output: {e}");
println!("{{}}");
}
}
}
impl ClaudeHookOutput {
fn decision(decision: Decision, reason: &str) -> Self {
Self {
hook_specific_output: ClaudePreToolUseDecisionOutput {
hook_event_name: "PreToolUse".to_string(),
permission_decision: decision,
permission_decision_reason: reason.to_string(),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_action(json: serde_json::Value) -> ClaudeHookAction {
let input: ClaudeHookInput = serde_json::from_value(json).unwrap();
action_from_input(input)
}
#[test]
fn deserializes_full_claude_hook_input() {
let json = r#"{"session_id":"abc123","cwd":"/Users/dev/project","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/build","description":"Clean build directory"},"tool_use_id":"toolu_01ABC123"}"#;
let input: ClaudeHookInput = serde_json::from_str(json).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(
input.tool_input.command.as_deref(),
Some("rm -rf /tmp/build")
);
assert_eq!(input.session_id.as_deref(), Some("abc123"));
assert_eq!(input.cwd.as_deref(), Some("/Users/dev/project"));
assert_eq!(input.hook_event_name.as_deref(), Some("PreToolUse"));
assert_eq!(input.tool_use_id.as_deref(), Some("toolu_01ABC123"));
}
#[test]
fn deserializes_minimal_claude_hook_input() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#;
let input: ClaudeHookInput = serde_json::from_str(json).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.tool_input.command.as_deref(), Some("ls"));
assert!(input.session_id.is_none());
assert!(input.cwd.is_none());
}
#[test]
fn serializes_deny_output() {
let output = ClaudeHookOutput::decision(Decision::Deny, "[rm-root] Destructive operation");
let parsed: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&output).unwrap()).unwrap();
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "deny");
assert_eq!(
parsed["hookSpecificOutput"]["permissionDecisionReason"],
"[rm-root] Destructive operation"
);
assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse");
}
#[test]
fn serializes_ask_output() {
let output = ClaudeHookOutput::decision(Decision::Ask, "Risky command");
let parsed: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&output).unwrap()).unwrap();
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "ask");
assert_eq!(
parsed["hookSpecificOutput"]["permissionDecisionReason"],
"Risky command"
);
assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse");
}
#[test]
fn serializes_allow_output() {
let output = ClaudeHookOutput::decision(Decision::Allow, "longline: allowlisted");
let parsed: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&output).unwrap()).unwrap();
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "allow");
assert_eq!(
parsed["hookSpecificOutput"]["permissionDecisionReason"],
"longline: allowlisted"
);
assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse");
}
#[test]
fn maps_bash_to_shell_invocation_with_fields_preserved() {
let action = parse_action(serde_json::json!({
"session_id": "session-1",
"cwd": "/repo",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "ls -la" }
}));
match action {
ClaudeHookAction::Evaluate(evaluator::Invocation::Shell {
command,
cwd,
session_id,
}) => {
assert_eq!(command.as_deref(), Some("ls -la"));
assert_eq!(cwd.as_deref(), Some("/repo"));
assert_eq!(session_id.as_deref(), Some("session-1"));
}
other => panic!("expected shell invocation, got {other:?}"),
}
}
#[test]
fn maps_read_to_read_path_invocation_with_fields_preserved() {
let action = parse_action(serde_json::json!({
"session_id": "session-2",
"cwd": "/repo",
"tool_name": "Read",
"tool_input": { "file_path": "/repo/src/main.rs" }
}));
match action {
ClaudeHookAction::Evaluate(evaluator::Invocation::ReadPath {
tool_name,
path,
cwd,
session_id,
}) => {
assert_eq!(tool_name, "Read");
assert_eq!(path.as_deref(), Some("/repo/src/main.rs"));
assert_eq!(cwd.as_deref(), Some("/repo"));
assert_eq!(session_id.as_deref(), Some("session-2"));
}
other => panic!("expected read path invocation, got {other:?}"),
}
}
#[test]
fn maps_grep_to_search_path_invocation_with_fields_preserved() {
let action = parse_action(serde_json::json!({
"session_id": "session-3",
"cwd": "/repo",
"tool_name": "Grep",
"tool_input": { "pattern": "TODO", "path": "src/" }
}));
match action {
ClaudeHookAction::Evaluate(evaluator::Invocation::SearchPath {
tool_name,
path,
cwd,
session_id,
}) => {
assert_eq!(tool_name, "Grep");
assert_eq!(path.as_deref(), Some("src/"));
assert_eq!(cwd.as_deref(), Some("/repo"));
assert_eq!(session_id.as_deref(), Some("session-3"));
}
other => panic!("expected search path invocation, got {other:?}"),
}
}
#[test]
fn maps_glob_to_search_path_invocation_with_fields_preserved() {
let action = parse_action(serde_json::json!({
"session_id": "session-4",
"cwd": "/repo",
"tool_name": "Glob",
"tool_input": { "pattern": "*.rs", "path": "src/" }
}));
match action {
ClaudeHookAction::Evaluate(evaluator::Invocation::SearchPath {
tool_name,
path,
cwd,
session_id,
}) => {
assert_eq!(tool_name, "Glob");
assert_eq!(path.as_deref(), Some("src/"));
assert_eq!(cwd.as_deref(), Some("/repo"));
assert_eq!(session_id.as_deref(), Some("session-4"));
}
other => panic!("expected search path invocation, got {other:?}"),
}
}
#[test]
fn classifies_unsupported_tool_as_passthrough_with_cwd_preserved() {
let action = parse_action(serde_json::json!({
"session_id": "session-5",
"cwd": "/repo",
"tool_name": "Write",
"tool_input": { "file_path": "/repo/out.txt" }
}));
match action {
ClaudeHookAction::Passthrough { cwd } => {
assert_eq!(cwd.as_deref(), Some("/repo"));
}
other => panic!("expected passthrough, got {other:?}"),
}
}
}