use std::path::PathBuf;
const MAX_PAYLOAD: usize = 1024 * 1024;
pub struct Bash {
pub command: String,
pub cwd: PathBuf,
pub session: String,
pub permission_mode: String,
pub background: bool,
}
pub struct Session {
pub cwd: PathBuf,
pub session: String,
}
pub enum Event {
PreBash(Box<Bash>),
PostBash(Box<Bash>),
SessionStart(Session),
NotOurs,
}
pub fn parse(raw: &str) -> Event {
if raw.len() > MAX_PAYLOAD {
return Event::NotOurs;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) else {
return Event::NotOurs;
};
let str_at = |key: &str| -> String {
v.get(key)
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string()
};
let cwd = {
let c = str_at("cwd");
if c.is_empty() {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
} else {
PathBuf::from(c)
}
};
match v.get("hook_event_name").and_then(|x| x.as_str()) {
Some("SessionStart") => Event::SessionStart(Session {
cwd,
session: str_at("session_id"),
}),
Some(stage @ ("PreToolUse" | "PostToolUse")) => {
if v.get("tool_name").and_then(|x| x.as_str()) != Some("Bash") {
return Event::NotOurs;
}
let command = v
.get("tool_input")
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
.unwrap_or_default()
.to_string();
if command.trim().is_empty() {
return Event::NotOurs;
}
let background = v
.get("tool_input")
.and_then(|i| i.get("run_in_background"))
.and_then(|b| b.as_bool())
.unwrap_or(false)
|| v.get("tool_response")
.and_then(|r| r.get("backgroundTaskId"))
.is_some();
let bash = Box::new(Bash {
command,
cwd,
session: str_at("session_id"),
permission_mode: str_at("permission_mode"),
background,
});
if stage == "PreToolUse" {
Event::PreBash(bash)
} else {
Event::PostBash(bash)
}
}
_ => Event::NotOurs,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pre(command: &str) -> String {
format!(
r#"{{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"/tmp",
"session_id":"s","tool_use_id":"t","permission_mode":"default",
"tool_input":{{"command":{}}}}}"#,
serde_json::Value::String(command.to_string())
)
}
#[test]
fn a_bash_call_is_ours() {
match parse(&pre("git push | tail -1")) {
Event::PreBash(b) => {
assert_eq!(b.command, "git push | tail -1");
assert_eq!(b.cwd, PathBuf::from("/tmp"));
assert_eq!(b.session, "s");
}
_ => panic!("expected a Bash call"),
}
}
#[test]
fn anything_we_do_not_recognise_is_not_an_opinion() {
for raw in [
"",
"{",
"null",
"[]",
r#"{"hook_event_name":"PreCompact","tool_name":"Bash"}"#,
r#"{"hook_event_name":"PostToolUseFailure","tool_name":"Bash","error":"Exit code 3"}"#,
r#"{"hook_event_name":"PostToolUse","tool_name":"Bash"}"#,
r#"{"hook_event_name":"PreToolUse","tool_name":"Read"}"#,
r#"{"hook_event_name":"PreToolUse","tool_name":"BashOutput"}"#,
r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{}}"#,
r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":" "}}"#,
] {
assert!(
matches!(parse(raw), Event::NotOurs),
"expected silence for {raw:?}"
);
}
}
#[test]
fn a_finished_bash_call_is_its_own_event() {
let raw = r#"{"hook_event_name":"PostToolUse","tool_name":"Bash",
"tool_input":{"command":"git push"},
"tool_response":{"stdout":"","stderr":"","interrupted":false}}"#;
match parse(raw) {
Event::PostBash(b) => {
assert_eq!(b.command, "git push");
assert!(!b.background);
}
_ => panic!("expected a finished Bash call"),
}
}
#[test]
fn a_failed_call_is_not_ours() {
let raw = r#"{"hook_event_name":"PostToolUseFailure","tool_name":"Bash",
"tool_input":{"command":"git push"},
"error":"Exit code 128\nfatal: could not read from remote"}"#;
assert!(matches!(parse(raw), Event::NotOurs));
}
#[test]
fn a_backgrounded_call_is_marked_even_without_the_input_flag() {
let raw = r#"{"hook_event_name":"PostToolUse","tool_name":"Bash",
"tool_input":{"command":"git push"},
"tool_response":{"stdout":"","backgroundTaskId":"bjkvph22n"}}"#;
match parse(raw) {
Event::PostBash(b) => assert!(b.background, "a task id means it is still running"),
_ => panic!("expected a finished Bash call"),
}
}
#[test]
fn a_retyped_field_degrades_to_silence() {
let raw = r#"{"hook_event_name":"PreToolUse","tool_name":"Bash",
"tool_input":{"command":{"was":"a string"}}}"#;
assert!(matches!(parse(raw), Event::NotOurs));
}
#[test]
fn unknown_fields_are_ignored() {
let raw = r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"/tmp",
"tool_input":{"command":"git status","timeout":5,"future":true},
"brand_new_field":{"nested":[1,2,3]}}"#;
assert!(matches!(parse(raw), Event::PreBash(_)));
}
#[test]
fn a_payload_too_large_to_be_a_command_is_ignored() {
let huge = format!(
r#"{{"hook_event_name":"PreToolUse","pad":"{}"}}"#,
"x".repeat(MAX_PAYLOAD)
);
assert!(matches!(parse(&huge), Event::NotOurs));
}
}