1use crate::cli::{HookArgs, HookCommand, HookExecArgs, HookInstallArgs};
2use crate::commands::add;
3use crate::error::{AppError, AppResult};
4use crate::output::{self, Meta};
5use crate::store;
6use crate::{
7 Evidence, ItemStatus, LogEvent, Severity, compute_id, format_timestamp, resolve_agent,
8};
9use jiff::Timestamp;
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use std::fs::{self, OpenOptions};
13use std::io::{ErrorKind, Read, Write};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicUsize, Ordering};
16
17const HOOK_INPUT_LIMIT: u64 = 1024 * 1024;
18const HOOK_COMMAND_LIMIT: usize = 500;
21const PROBE_COMMANDS: &[&str] = &[
23 "grep", "rg", "ls", "find", "tail", "head", "cat", "stat", "test", "[", "which", "curl", "gh",
24];
25const CLAUDE_CODE_COMMAND_SUFFIX: &str = "hook exec claude-code";
26static TEMP_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
27
28#[derive(Debug, Serialize)]
29pub struct HookInstallData {
30 pub changed: bool,
31 pub settings_path: String,
32 pub command: String,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36enum HookInstallOutcome {
37 Created,
38 Amended,
39 Unchanged,
40}
41
42impl HookInstallOutcome {
43 fn changed(self) -> bool {
44 !matches!(self, Self::Unchanged)
45 }
46
47 fn action(self) -> Option<&'static str> {
48 match self {
49 Self::Created => Some("created"),
50 Self::Amended => Some("amended"),
51 Self::Unchanged => None,
52 }
53 }
54}
55
56#[derive(Debug, Deserialize)]
57struct ClaudeCodePayload {
58 hook_event_name: Option<String>,
59 tool_name: Option<String>,
60 tool_input: Option<ClaudeCodeToolInput>,
61 error: Option<String>,
62 is_interrupt: Option<bool>,
63 cwd: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
67struct ClaudeCodeToolInput {
68 command: Option<String>,
69}
70
71enum ClaudeCodePayloadRead {
72 Payload(ClaudeCodePayload),
73 TooLarge,
74 NotJson,
75}
76
77enum HookExecOutcome {
78 StdinUnreadable,
79 StdinTooLarge,
80 PayloadNotJson,
81 UnexpectedEvent(Option<String>),
82 NonBashTool(Option<String>),
83 Interrupted,
84 MissingCommand,
85 CommandTooLong(usize),
86 CommandRejected(String),
87 ProbeCommand(String),
88 MissingLog(PathBuf),
89 DuplicateOpenCommand(String),
90 Filed(String),
91 ClockUnavailable(String),
92 RuntimeError(String),
93}
94
95impl HookExecOutcome {
96 fn explain_message(&self) -> String {
97 match self {
98 Self::StdinUnreadable => "hook exec: stdin could not be read; skipped".into(),
99 Self::StdinTooLarge => {
100 "hook exec: stdin exceeds the 1048576-byte limit; skipped".into()
101 }
102 Self::PayloadNotJson => "hook exec: stdin is not valid JSON; skipped".into(),
103 Self::UnexpectedEvent(value) => format!(
104 "hook exec: hook_event_name was {}; expected \"PostToolUseFailure\"; skipped",
105 hook_explain_value(value.as_deref())
106 ),
107 Self::NonBashTool(value) => format!(
108 "hook exec: tool_name was {}; expected \"Bash\"; skipped",
109 hook_explain_value(value.as_deref())
110 ),
111 Self::Interrupted => "hook exec: is_interrupt is true; skipped".into(),
112 Self::MissingCommand => {
113 "hook exec: tool_input.command is missing or empty; skipped".into()
114 }
115 Self::CommandTooLong(bytes) => format!(
116 "hook exec: tool_input.command is {bytes} bytes; exceeds the {HOOK_COMMAND_LIMIT}-byte limit; skipped"
117 ),
118 Self::CommandRejected(reason) => {
119 format!("hook exec: tool_input.command failed cut validation ({reason:?}); skipped")
120 }
121 Self::ProbeCommand(program) => format!(
122 "hook exec: {program} is a read-only probe; non-zero exit is an expected answer; skipped"
123 ),
124 Self::MissingLog(path) => {
125 format!("hook exec: resolved log file {path:?} is not an existing file; skipped")
126 }
127 Self::DuplicateOpenCommand(id) => {
128 format!("hook exec: duplicate open command matches cut {id}; skipped")
129 }
130 Self::Filed(id) => format!("hook exec: filed cut {id}"),
131 Self::ClockUnavailable(reason) => {
132 format!("hook exec: clock could not be resolved ({reason:?}); skipped")
133 }
134 Self::RuntimeError(reason) => {
135 format!("hook exec: internal hook error ({reason:?}); skipped")
136 }
137 }
138 }
139}
140
141fn hook_explain_value(value: Option<&str>) -> String {
142 value
143 .map(|value| format!("{value:?}"))
144 .unwrap_or_else(|| "<missing>".into())
145}
146
147pub(crate) fn leading_program(command: &str) -> Option<&str> {
148 command
149 .split_whitespace()
150 .find(|word| !is_environment_assignment(word))
151 .map(|word| {
152 Path::new(word)
153 .file_name()
154 .and_then(|name| name.to_str())
155 .unwrap_or(word)
156 })
157}
158
159fn is_environment_assignment(word: &str) -> bool {
160 let Some((name, _)) = word.split_once('=') else {
161 return false;
162 };
163 let mut bytes = name.bytes();
164 matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_')
165 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
166}
167
168pub fn run(args: HookArgs, file: Option<PathBuf>, pretty: bool, now: Timestamp) -> AppResult<i32> {
169 match args.command {
170 HookCommand::Install(args) => install(args, pretty),
171 HookCommand::Exec(args) => {
172 let _ = exec(args, file, now);
173 Ok(0)
174 }
175 }
176}
177
178pub fn exec(_args: HookExecArgs, file: Option<PathBuf>, now: Timestamp) -> AppResult<()> {
179 let outcome = exec_claude_code(file, now)
180 .unwrap_or_else(|error| HookExecOutcome::RuntimeError(error.message));
181 write_hook_explanation(&outcome);
182 Ok(())
183}
184
185pub fn explain_clock_failure(reason: &str) {
188 write_hook_explanation(&HookExecOutcome::ClockUnavailable(reason.to_string()));
189}
190
191fn exec_claude_code(file: Option<PathBuf>, now: Timestamp) -> AppResult<HookExecOutcome> {
192 let payload = match read_claude_code_payload() {
193 Ok(ClaudeCodePayloadRead::Payload(payload)) => payload,
194 Ok(ClaudeCodePayloadRead::TooLarge) => return Ok(HookExecOutcome::StdinTooLarge),
195 Ok(ClaudeCodePayloadRead::NotJson) => return Ok(HookExecOutcome::PayloadNotJson),
196 Err(_) => return Ok(HookExecOutcome::StdinUnreadable),
197 };
198 if payload.hook_event_name.as_deref() != Some("PostToolUseFailure") {
199 return Ok(HookExecOutcome::UnexpectedEvent(payload.hook_event_name));
200 }
201 if payload.tool_name.as_deref() != Some("Bash") {
202 return Ok(HookExecOutcome::NonBashTool(payload.tool_name));
203 }
204 if payload.is_interrupt == Some(true) {
205 return Ok(HookExecOutcome::Interrupted);
206 }
207 let Some(raw_command) = payload
208 .tool_input
209 .and_then(|input| input.command)
210 .filter(|command| !command.trim().is_empty())
211 else {
212 return Ok(HookExecOutcome::MissingCommand);
213 };
214 if raw_command.len() > HOOK_COMMAND_LIMIT {
215 return Ok(HookExecOutcome::CommandTooLong(raw_command.len()));
216 }
217 if let Some(program) = leading_program(&raw_command)
218 && PROBE_COMMANDS.contains(&program)
219 {
220 return Ok(HookExecOutcome::ProbeCommand(program.to_string()));
221 }
222
223 let cwd = payload_working_dir(payload.cwd)?;
224 let home = store::home_dir(&cwd);
225 let command = add::redact_evidence(&raw_command, home.as_deref());
226 if let Err(error) = add::validate_text(&command, "cut") {
229 return Ok(HookExecOutcome::CommandRejected(error.message));
230 }
231 let resolved = store::discover_from(&cwd, file)?;
232 if !resolved.path.is_file() {
233 return Ok(HookExecOutcome::MissingLog(resolved.path));
234 }
235
236 let (agent, _) = resolve_agent(None);
237 let ts = format_timestamp(now);
238 let tags = vec!["auto".into(), "claude-code".into()];
239 let id = compute_id(&ts, &agent, &command, Severity::Minor, &tags);
240 let record = LogEvent::Cut {
241 id: id.clone(),
242 ts,
243 agent,
244 text: command.clone(),
245 tags,
246 severity: Severity::Minor,
247 cwd: store::record_cwd(&cwd, resolved.cwd_repo(), home.as_deref()),
248 source: Some("hook".into()),
249 evidence: Some(Evidence {
250 cmd: Some(add::redact_evidence(&raw_command, home.as_deref())),
251 exit: None,
252 stderr: None,
253 note: payload
254 .error
255 .as_deref()
256 .map(|error| add::redact_and_truncate(error, 1024, home.as_deref())),
257 }),
258 };
259
260 store::with_exclusive(&resolved.path, false, |log| {
261 let bytes = store::read_bytes(log, &resolved.path)?;
262 let duplicate_open_command = store::fold_bytes(&bytes)
263 .items
264 .iter()
265 .find(|item| {
266 item.kind == "cut" && item.status == ItemStatus::Open && item.text == command
267 })
268 .map(|item| item.id.clone());
269 if let Some(existing_id) = duplicate_open_command {
270 return Ok(HookExecOutcome::DuplicateOpenCommand(existing_id));
271 }
272 store::append_json(log, &resolved.path, &bytes, &record)?;
273 Ok(HookExecOutcome::Filed(id.clone()))
274 })
275}
276
277fn read_claude_code_payload() -> AppResult<ClaudeCodePayloadRead> {
278 let mut bytes = Vec::new();
279 std::io::stdin()
280 .lock()
281 .take(HOOK_INPUT_LIMIT + 1)
282 .read_to_end(&mut bytes)
283 .map_err(|error| AppError::from_io(error, Path::new("stdin")))?;
284 if bytes.len() > HOOK_INPUT_LIMIT as usize {
285 return Ok(ClaudeCodePayloadRead::TooLarge);
286 }
287 match serde_json::from_slice(&bytes) {
288 Ok(payload) => Ok(ClaudeCodePayloadRead::Payload(payload)),
289 Err(_) => Ok(ClaudeCodePayloadRead::NotJson),
290 }
291}
292
293fn write_hook_explanation(outcome: &HookExecOutcome) {
294 if std::env::var("BLOTTER_HOOK_EXPLAIN").is_ok_and(|value| value == "1") {
295 let mut stderr = std::io::stderr().lock();
296 let _ = writeln!(stderr, "{}", outcome.explain_message());
297 }
298}
299
300fn payload_working_dir(payload_cwd: Option<String>) -> AppResult<PathBuf> {
301 let process_cwd =
302 std::env::current_dir().map_err(|error| AppError::from_io(error, Path::new(".")))?;
303 let Some(payload_cwd) = payload_cwd.filter(|cwd| !cwd.is_empty()) else {
304 return Ok(process_cwd);
305 };
306 let cwd = PathBuf::from(payload_cwd);
307 Ok(if cwd.is_absolute() {
308 cwd
309 } else {
310 process_cwd.join(cwd)
311 })
312}
313
314fn install(args: HookInstallArgs, pretty: bool) -> AppResult<i32> {
315 let settings_path = settings_path(&args)?;
316 let command = claude_code_command()?;
317 let mut settings = read_settings(&settings_path)?;
318 let outcome = insert_claude_code_hook(&mut settings, &command)?;
319 let changed = outcome.changed();
320 if changed && !args.dry_run {
321 write_settings_atomically(&settings_path, &settings)?;
322 }
323
324 let mut meta = Meta::new();
325 if args.dry_run {
326 meta.warnings.push("dry run; settings not written".into());
327 }
328 if let Some(action) = outcome.action() {
329 let message = if args.dry_run {
330 format!("dry run; hook would be {action}")
331 } else {
332 format!("hook {action}")
333 };
334 meta.warnings.push(message);
335 }
336 output::write_success(
337 HookInstallData {
338 changed,
339 settings_path: settings_path.to_string_lossy().into_owned(),
340 command,
341 },
342 pretty,
343 meta,
344 )
345 .map_err(|error| AppError::from_io(error, Path::new("stdout")))?;
346 Ok(0)
347}
348
349fn settings_path(args: &HookInstallArgs) -> AppResult<PathBuf> {
350 if args.global {
351 let home = std::env::var_os("HOME")
352 .filter(|value| !value.is_empty())
353 .map(PathBuf::from)
354 .ok_or_else(|| {
355 AppError::config(
356 "cannot resolve the home directory for global Claude Code settings",
357 "Set HOME or use `blotter hook install claude-code --settings PATH`.",
358 )
359 })?;
360 return Ok(home.join(".claude/settings.json"));
361 }
362
363 let cwd = std::env::current_dir().map_err(|error| AppError::from_io(error, Path::new(".")))?;
364 if let Some(settings) = args.settings.as_ref() {
365 return Ok(if settings.is_absolute() {
366 settings.clone()
367 } else {
368 cwd.join(settings)
369 });
370 }
371 Ok(store::find_repo_root(&cwd)
372 .unwrap_or(cwd)
373 .join(".claude/settings.json"))
374}
375
376fn claude_code_command() -> AppResult<String> {
377 let executable = std::env::current_exe()
378 .map_err(|error| AppError::from_io(error, Path::new("current executable")))?;
379 Ok(format!(
380 "{} {CLAUDE_CODE_COMMAND_SUFFIX}",
381 executable.display()
382 ))
383}
384
385fn read_settings(path: &Path) -> AppResult<Value> {
386 let bytes = match fs::read(path) {
387 Ok(bytes) => bytes,
388 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(json!({})),
389 Err(error) => return Err(AppError::from_io(error, path)),
390 };
391 let settings = serde_json::from_slice::<Value>(&bytes).map_err(|error| {
392 AppError::invalid_input(
393 format!(
394 "Claude Code settings are not valid JSON: {} ({error})",
395 path.display()
396 ),
397 "Fix the JSON in the settings file, then rerun `blotter hook install claude-code`.",
398 )
399 })?;
400 if settings.is_object() {
401 Ok(settings)
402 } else {
403 Err(AppError::invalid_input(
404 format!(
405 "Claude Code settings must be a JSON object: {}",
406 path.display()
407 ),
408 "Replace the settings file with a JSON object, then rerun `blotter hook install claude-code`.",
409 ))
410 }
411}
412
413fn insert_claude_code_hook(settings: &mut Value, command: &str) -> AppResult<HookInstallOutcome> {
414 let root = settings
415 .as_object_mut()
416 .expect("read_settings guarantees object roots");
417 let hooks = root.entry("hooks").or_insert_with(|| json!({}));
418 let hooks = hooks.as_object_mut().ok_or_else(|| {
419 AppError::invalid_input(
420 "Claude Code settings field 'hooks' must be a JSON object",
421 "Fix the hooks field to be an object, then rerun `blotter hook install claude-code`.",
422 )
423 })?;
424 let post_tool_use_failure = hooks
425 .entry("PostToolUseFailure")
426 .or_insert_with(|| Value::Array(Vec::new()));
427 let entries = post_tool_use_failure.as_array_mut().ok_or_else(|| {
428 AppError::invalid_input(
429 "Claude Code settings hooks.PostToolUseFailure must be a JSON array",
430 "Fix hooks.PostToolUseFailure to be an array, then rerun `blotter hook install claude-code`.",
431 )
432 })?;
433 let current_executable = claude_code_hook_executable(command)
434 .expect("claude_code_command always includes the Claude Code command suffix");
435 let mut managed_hook_found = false;
436 let mut outcome = HookInstallOutcome::Unchanged;
437 for entry in entries.iter_mut() {
438 let Some(hooks) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
439 continue;
440 };
441 for hook in hooks {
442 let Some(existing_command) = hook.get("command").and_then(Value::as_str) else {
443 continue;
444 };
445 let Some(existing_executable) = claude_code_hook_executable(existing_command) else {
446 continue;
447 };
448 managed_hook_found = true;
449 if existing_executable != current_executable {
450 *hook
451 .get_mut("command")
452 .expect("command was just read from this hook") = Value::String(command.into());
453 outcome = HookInstallOutcome::Amended;
454 }
455 }
456 }
457 if managed_hook_found {
458 return Ok(outcome);
459 }
460
461 entries.push(json!({
462 "matcher": "Bash",
463 "hooks": [{"type": "command", "command": command}],
464 }));
465 Ok(HookInstallOutcome::Created)
466}
467
468fn claude_code_hook_executable(command: &str) -> Option<&str> {
469 command
470 .strip_suffix(CLAUDE_CODE_COMMAND_SUFFIX)
471 .map(str::trim_end)
472}
473
474fn write_settings_atomically(path: &Path, settings: &Value) -> AppResult<()> {
475 let parent = path.parent().ok_or_else(|| {
476 AppError::invalid_input(
477 format!("settings path has no parent directory: {}", path.display()),
478 "Pass a settings file path with a parent directory.",
479 )
480 })?;
481 fs::create_dir_all(parent).map_err(|error| AppError::from_io(error, parent))?;
482 let previous_permissions = fs::metadata(path)
483 .ok()
484 .map(|metadata| metadata.permissions());
485 let filename = path
486 .file_name()
487 .and_then(|name| name.to_str())
488 .unwrap_or("settings.json");
489 let (temporary_path, mut temporary_file) = create_temp_file(parent, filename)?;
490 if let Some(permissions) = previous_permissions
491 && let Err(error) = temporary_file.set_permissions(permissions)
492 {
493 drop(temporary_file);
494 let _ = fs::remove_file(&temporary_path);
495 return Err(AppError::from_io(error, &temporary_path));
496 }
497
498 let mut bytes = serde_json::to_vec_pretty(settings)
499 .map_err(|error| AppError::internal(error.to_string()))?;
500 bytes.push(b'\n');
501 if let Err(error) = temporary_file.write_all(&bytes) {
502 drop(temporary_file);
503 let _ = fs::remove_file(&temporary_path);
504 return Err(AppError::from_io(error, &temporary_path));
505 }
506 if let Err(error) = temporary_file.sync_all() {
507 drop(temporary_file);
508 let _ = fs::remove_file(&temporary_path);
509 return Err(AppError::from_io(error, &temporary_path));
510 }
511 drop(temporary_file);
512 if let Err(error) = fs::rename(&temporary_path, path) {
513 let _ = fs::remove_file(&temporary_path);
514 return Err(AppError::from_io(error, path));
515 }
516 Ok(())
517}
518
519fn create_temp_file(parent: &Path, filename: &str) -> AppResult<(PathBuf, fs::File)> {
520 for attempt in 0..100 {
521 let sequence = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
522 let path = parent.join(format!(
523 ".{filename}.blotter-{}-{sequence}-{attempt}.tmp",
524 std::process::id()
525 ));
526 match OpenOptions::new().write(true).create_new(true).open(&path) {
527 Ok(file) => return Ok((path, file)),
528 Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
529 Err(error) => return Err(AppError::from_io(error, &path)),
530 }
531 }
532 Err(AppError::from_io(
533 std::io::Error::new(
534 ErrorKind::AlreadyExists,
535 "could not allocate a unique temporary settings file",
536 ),
537 parent,
538 ))
539}