Skip to main content

blotter/commands/
hook.rs

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;
18/// A failed command becomes the cut's text verbatim. Long debugging one-liners are noise, not
19/// friction, so the hook declines them rather than filing an entry nobody will read.
20const HOOK_COMMAND_LIMIT: usize = 500;
21// Read-only interrogation commands whose non-zero exit is an expected answer.
22const 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
185/// Explains a failure that happens before `exec` can run (currently only clock resolution),
186/// so `BLOTTER_HOOK_EXPLAIN=1` never exits 0 without naming the gate.
187pub 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    // The raw command is gated above; the stored machine-captured text is
227    // validated only after full evidence redaction.
228    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}