use crate::adapter::Binding;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Event {
SessionStart,
TurnEnd,
BeforeTool,
AfterTool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Tool {
Edit,
Read,
Shell,
Search,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Field {
ToolFile,
ToolCommand,
}
impl Field {
pub const ALL: [Field; 2] = [Self::ToolFile, Self::ToolCommand];
pub fn var(&self) -> &'static str {
match self {
Self::ToolFile => "OMH_TOOL_FILE",
Self::ToolCommand => "OMH_TOOL_COMMAND",
}
}
}
pub const CAPTURE_VAR: &str = "OMH_CAPTURE";
pub const SANDBOX_VARS: [&str; 2] = ["OMH_SESSION", "OMH_GRAPH_PROJECT"];
impl std::fmt::Display for Event {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::SessionStart => "session-start",
Self::TurnEnd => "turn-end",
Self::BeforeTool => "before-tool",
Self::AfterTool => "after-tool",
})
}
}
impl std::fmt::Display for Tool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Edit => "edit",
Self::Read => "read",
Self::Shell => "shell",
Self::Search => "search",
})
}
}
impl std::fmt::Display for Field {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::ToolFile => "tool-file",
Self::ToolCommand => "tool-command",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Run(String),
Refuse { text: String },
Inject {
capture: Option<String>,
text: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(into = "Raw")]
pub struct Hook {
pub on: Event,
pub tools: Vec<Tool>,
pub when: Option<String>,
pub action: Action,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Raw {
on: Event,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tools: Vec<Tool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
capture: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
run: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
inject: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
refuse: Option<String>,
}
impl From<Hook> for Raw {
fn from(h: Hook) -> Self {
let (capture, run, inject, refuse) = match h.action {
Action::Run(cmd) => (None, Some(cmd), None, None),
Action::Inject { capture, text } => (capture, None, Some(text), None),
Action::Refuse { text } => (None, None, None, Some(text)),
};
Self {
on: h.on,
tools: h.tools,
when: h.when,
capture,
run,
inject,
refuse,
}
}
}
impl Hook {
pub fn parse(raw: &str, whence: &str) -> Result<Self> {
let raw: Raw =
serde_json::from_str(raw).with_context(|| format!("parsing hook {whence}"))?;
Self::from_raw(raw, whence)
}
fn from_raw(raw: Raw, whence: &str) -> Result<Self> {
let action = match (raw.run, raw.inject, raw.refuse) {
(None, None, None) => {
anyhow::bail!("{whence}: a hook does nothing without `run`, `inject` or `refuse`")
}
(Some(_), Some(_), _) | (Some(_), _, Some(_)) | (_, Some(_), Some(_)) => {
anyhow::bail!(
"{whence}: a hook does one thing — `run` a command, `inject` advisory \
text, or `refuse` the call. A command whose output should reach the \
agent is `capture` plus `inject`."
)
}
(None, None, Some(text)) => {
if raw.on != Event::BeforeTool {
anyhow::bail!(
"{whence}: `refuse` blocks a call, so it belongs to `before-tool` — \
`{}` is too late or has no call to block. To say something without \
stopping the call, use `inject`.",
raw.on
);
}
if raw.capture.is_some() {
anyhow::bail!(
"{whence}: `capture` collects output for `inject` to carry. \
A refusal is a fixed reason, so capturing output says nothing."
);
}
non_empty(&text, "refuse", whence)?;
check_interpolation(&text, whence, false)?;
Action::Refuse { text }
}
(Some(run), None, None) => {
if raw.capture.is_some() {
anyhow::bail!(
"{whence}: `capture` collects output for `inject` to carry. \
With `run` the output is ignored, so capturing it says nothing."
);
}
non_empty(&run, "run", whence)?;
Action::Run(run)
}
(None, Some(text), None) => {
non_empty(&text, "inject", whence)?;
check_interpolation(&text, whence, raw.capture.is_some())?;
if let Some(capture) = &raw.capture {
non_empty(capture, "capture", whence)?;
let read = mentions(&text, CAPTURE_VAR)
|| raw
.when
.as_deref()
.is_some_and(|w| mentions(w, CAPTURE_VAR));
if !read {
anyhow::bail!(
"{whence}: `capture` runs a command and binds its output to \
${CAPTURE_VAR}, and nothing here reads it. Name it in \
`inject` or in `when`, or drop the `capture`."
);
}
}
Action::Inject {
capture: raw.capture,
text,
}
}
};
if let Some(when) = &raw.when {
non_empty(when, "when", whence)?;
}
Ok(Self {
on: raw.on,
tools: raw.tools,
when: raw.when,
action,
})
}
pub fn does(&self) -> &str {
match &self.action {
Action::Run(cmd) => cmd,
Action::Inject { text, .. } | Action::Refuse { text } => text,
}
}
pub fn fields(&self) -> BTreeSet<Field> {
let (capture, body) = match &self.action {
Action::Run(cmd) | Action::Refuse { text: cmd } => (&None, cmd),
Action::Inject { capture, text } => (capture, text),
};
let bodies = [
self.when.as_deref(),
capture.as_deref(),
Some(body.as_str()),
];
Field::ALL
.into_iter()
.filter(|f| bodies.iter().flatten().any(|b| mentions(b, f.var())))
.collect()
}
}
fn non_empty(body: &str, field: &str, whence: &str) -> Result<()> {
if body.trim().is_empty() {
anyhow::bail!("{whence}: `{field}` is empty, so this hook does nothing");
}
Ok(())
}
fn mentions(body: &str, var: &str) -> bool {
let boundary = |rest: &str| {
!rest
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
};
body.match_indices(var).any(|(i, _)| {
let before = &body[..i];
let rest = &body[i + var.len()..];
(before.ends_with('$') || before.ends_with("${")) && boundary(rest)
})
}
fn check_interpolation(text: &str, whence: &str, capture: bool) -> Result<()> {
let known: Vec<&str> = Field::ALL
.iter()
.map(|f| f.var())
.chain(SANDBOX_VARS)
.chain(capture.then_some(CAPTURE_VAR))
.collect();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] != '$' {
i += 1;
continue;
}
if chars.get(i + 1) == Some(&'$') {
i += 2;
continue;
}
if chars.get(i + 1) == Some(&'(') {
anyhow::bail!(
"{whence}: `$(` in `inject` runs a command from inside a sentence. \
Use `capture` and interpolate ${CAPTURE_VAR}."
);
}
let braced = chars.get(i + 1) == Some(&'{');
let start = i + if braced { 2 } else { 1 };
let end = chars[start..]
.iter()
.position(|c| !c.is_ascii_alphanumeric() && *c != '_')
.map_or(chars.len(), |n| start + n);
let name: String = chars[start..end].iter().collect();
if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {
anyhow::bail!(
"{whence}: `{}` is not a variable name. A shell reads that as a bad \
substitution and the hook emits nothing at all.",
chars[i..(end + 1).min(chars.len())]
.iter()
.collect::<String>()
);
}
if braced
&& !matches!(
chars.get(end),
Some('}' | ':' | '-' | '+' | '?' | '#' | '%')
)
{
anyhow::bail!(
"{whence}: `${{{name}` is never closed with `}}`. A shell reads that as \
a bad substitution and the hook emits nothing at all."
);
}
if !known.contains(&name.as_str()) {
anyhow::bail!(
"{whence}: `${name}` is not something omh sets, so it expands to \
whatever the sandbox happens to hold — or to nothing. Available: {}.",
known.join(", ")
);
}
i = end;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Rendered(Rendered),
Dropped(Dropped),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rendered {
pub event: String,
pub matcher: String,
pub command: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dropped {
pub name: String,
pub wanted: String,
}
impl std::fmt::Display for Dropped {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (no {})", self.name, self.wanted)
}
}
pub struct Wired<'a> {
pub event: &'a str,
pub tools: Vec<&'a str>,
pub fields: Vec<(Field, &'a str)>,
}
pub fn wire<'a>(
name: &str,
hook: &Hook,
binding: &'a Binding,
tools: &'a BTreeMap<Tool, String>,
) -> std::result::Result<Wired<'a>, Dropped> {
let drop = |wanted: String| Dropped {
name: name.to_string(),
wanted,
};
let event = binding
.events
.get(&hook.on)
.ok_or_else(|| drop(format!("`{}` moment", hook.on)))?;
let mut named = Vec::new();
for tool in &hook.tools {
named.push(
tools
.get(tool)
.map(String::as_str)
.ok_or_else(|| drop(format!("`{tool}` tool")))?,
);
}
let mut fields = Vec::new();
for field in hook.fields() {
let at = binding
.fields
.get(&field)
.map(String::as_str)
.ok_or_else(|| drop(format!("`{field}` field")))?;
fields.push((field, at));
}
Ok(Wired {
event,
tools: named,
fields,
})
}
pub fn render(
name: &str,
hook: &Hook,
binding: &Binding,
tools: &BTreeMap<Tool, String>,
) -> Result<Outcome> {
let dropped = |wanted: String| {
Ok(Outcome::Dropped(Dropped {
name: name.to_string(),
wanted,
}))
};
let Wired {
event,
tools: matchers,
fields,
} = match wire(name, hook, binding, tools) {
Ok(wired) => wired,
Err(d) => return Ok(Outcome::Dropped(d)),
};
let mut command = String::new();
if !fields.is_empty() {
command.push_str("p=$(cat); ");
for (field, expr) in &fields {
command.push_str(&format!(
"{}=$(printf '%s' \"$p\" | jq -r '{expr} // empty'); ",
field.var()
));
}
}
if let Action::Inject {
capture: Some(capture),
..
} = &hook.action
{
command.push_str(&format!("{CAPTURE_VAR}=$({capture}); "));
}
if let Some(when) = &hook.when {
command.push_str(&format!("{when} || exit 0; "));
}
match &hook.action {
Action::Run(run) => command.push_str(run),
Action::Inject { text, .. } | Action::Refuse { text } => {
let template = match binding.protocol(&hook.action) {
Ok(Some(t)) => t,
Ok(None) => unreachable!("a run does not reach this arm"),
Err(wanted) => return dropped(wanted.into()),
};
command.push_str(&fill(&template.template, text, event));
}
}
Ok(Outcome::Rendered(Rendered {
event: event.to_string(),
matcher: matchers.join("|"),
command,
}))
}
fn fill(template: &str, text: &str, event: &str) -> String {
template
.replace("{{text}}", &interpolating(text))
.replace("{{event}}", event)
}
pub fn interpolating(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'$' if chars.peek() == Some(&'$') => {
chars.next();
out.push_str("\\$");
}
'\\' | '"' | '`' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::{Adapter, Capability};
use std::path::Path;
const ADAPTERS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/adapters");
fn claude() -> Adapter {
Adapter::find(Path::new(ADAPTERS), "claude").unwrap()
}
fn binding(toml: &str) -> Binding {
toml::from_str(toml).unwrap()
}
fn rendered(name: &str, hook: &Hook, b: &Binding) -> Rendered {
match render(name, hook, b, &shipped().tools).unwrap() {
Outcome::Rendered(r) => r,
Outcome::Dropped(d) => panic!("unexpectedly dropped: {d}"),
}
}
fn dropped(name: &str, hook: &Hook, b: &Binding) -> Dropped {
match render(name, hook, b, &shipped().tools).unwrap() {
Outcome::Rendered(r) => panic!("unexpectedly rendered: {r:?}"),
Outcome::Dropped(d) => d,
}
}
#[test]
fn a_hook_declares_a_moment_not_a_harness_event() {
let err = Hook::parse(r#"{"event":"Stop","command":"cargo test"}"#, "h.json")
.expect_err("Claude's vocabulary is not omh's");
let err = format!("{err:#}");
assert!(err.contains("h.json"), "must name the file: {err}");
assert!(err.contains("`on`"), "and the field to use: {err}");
}
#[test]
fn a_word_from_another_harness_is_refused_beside_good_ones() {
let err = Hook::parse(
r#"{"on":"before-tool","matcher":"Read","run":"cargo test"}"#,
"h.json",
)
.expect_err("`matcher` is Claude's word and omh does not read it");
assert!(format!("{err:#}").contains("matcher"), "by name: {err:#}");
}
#[test]
fn a_hook_serialises_back_into_the_file_it_came_from() {
for body in [
r#"{"on":"turn-end","run":"cargo test"}"#,
r#"{"on":"before-tool","tools":["read"],"when":"[ -f \"$OMH_TOOL_FILE\" ]","inject":"about $OMH_TOOL_FILE"}"#,
r#"{"on":"session-start","capture":"date","inject":"it is $OMH_CAPTURE"}"#,
] {
let hook = Hook::parse(body, "h.json").unwrap();
let written = serde_json::to_string(&hook).unwrap();
assert_eq!(
Hook::parse(&written, "h.json").unwrap(),
hook,
"{body} did not survive: {written}"
);
}
}
#[test]
fn a_hook_that_neither_runs_nor_injects_is_refused() {
let err = Hook::parse(r#"{"on":"turn-end"}"#, "h.json").unwrap_err();
assert!(err.to_string().contains("run"), "got: {err}");
}
#[test]
fn a_hook_that_both_runs_and_injects_is_refused_with_what_it_meant() {
let err = Hook::parse(r#"{"on":"turn-end","run":"x","inject":"y"}"#, "h.json").unwrap_err();
assert!(err.to_string().contains("capture"), "got: {err}");
}
#[test]
fn a_hook_refuses_or_injects_but_not_both() {
for body in [
r#"{"on":"before-tool","refuse":"no","inject":"maybe"}"#,
r#"{"on":"before-tool","refuse":"no","run":"x"}"#,
] {
let err = Hook::parse(body, "h.json").expect_err("a hook does one thing");
assert!(err.to_string().contains("h.json"), "name the file: {err}");
}
let refusal = Hook::parse(
r#"{"on":"before-tool","tools":["shell"],"refuse":"git does not work here"}"#,
"h.json",
)
.expect("a refusal on its own is a hook");
assert_eq!(refusal.does(), "git does not work here");
}
#[test]
fn a_refusal_belongs_to_the_moment_before_the_call() {
for moment in ["turn-end", "session-start", "after-tool"] {
let err = Hook::parse(&format!(r#"{{"on":"{moment}","refuse":"no"}}"#), "h.json")
.expect_err("a refusal after the fact refuses nothing");
let err = format!("{err:#}");
assert!(err.contains("before-tool"), "name the moment: {err}");
}
Hook::parse(r#"{"on":"before-tool","refuse":"no"}"#, "h.json").unwrap();
}
#[test]
fn a_dollar_in_a_refusal_is_checked_like_one_in_an_inject() {
let err = Hook::parse(
r#"{"on":"before-tool","refuse":"costs $5 a month"}"#,
"h.json",
)
.expect_err("prose that expands to nothing");
assert!(
err.to_string().contains("not a variable name"),
"got: {err}"
);
}
#[test]
fn a_capture_nothing_reads_is_refused() {
let err = Hook::parse(
r#"{"on":"session-start","capture":"date","inject":"hello"}"#,
"h.json",
)
.expect_err("nothing reads $OMH_CAPTURE here");
assert!(
err.to_string().contains(CAPTURE_VAR),
"say which variable went unread: {err}"
);
for body in [
r#"{"on":"session-start","capture":"date","inject":"it is $OMH_CAPTURE"}"#,
r#"{"on":"session-start","capture":"date","when":"[ -n \"$OMH_CAPTURE\" ]","inject":"hi"}"#,
] {
Hook::parse(body, "h.json").unwrap_or_else(|e| panic!("{body} should validate: {e:#}"));
}
}
#[test]
fn an_empty_body_is_not_a_body() {
for body in [
r#"{"on":"turn-end","when":"","run":"cargo test"}"#,
r#"{"on":"turn-end","run":""}"#,
r#"{"on":"turn-end","inject":""}"#,
] {
let err = Hook::parse(body, "h.json")
.map(|_| ())
.expect_err("an empty body says nothing and can break the shell");
assert!(err.to_string().contains("h.json"), "name the file: {err}");
}
}
#[test]
fn capture_without_inject_is_refused() {
let err =
Hook::parse(r#"{"on":"turn-end","capture":"date","run":"x"}"#, "h.json").unwrap_err();
assert!(err.to_string().contains("capture"), "got: {err}");
}
#[test]
fn a_dollar_that_names_no_variable_is_refused() {
let err =
Hook::parse(r#"{"on":"turn-end","inject":"costs $5 a month"}"#, "h.json").unwrap_err();
assert!(
err.to_string().contains("not a variable name"),
"must say what is wrong with it: {err}"
);
}
#[test]
fn a_doubled_dollar_is_a_literal_one() {
let h = Hook::parse(r#"{"on":"turn-end","inject":"costs $$5"}"#, "h.json").unwrap();
assert_eq!(interpolating(h.does()), r#""costs \$5""#);
}
#[test]
fn command_substitution_in_prose_points_at_capture() {
let err = Hook::parse(r#"{"on":"turn-end","inject":"now $(date)"}"#, "h.json").unwrap_err();
assert!(err.to_string().contains("capture"), "got: {err}");
}
#[test]
fn every_accepted_inject_reaches_the_agent_intact() {
for prose in [
"plain words",
"a $OMH_GRAPH_PROJECT reference",
"a ${OMH_GRAPH_PROJECT} braced one",
"a ${OMH_GRAPH_PROJECT:-none} defaulted one",
"a $$5 literal dollar",
"quotes \" and \\ and ` backticks",
"a trailing brace } on its own",
] {
let h = Hook::parse(
&serde_json::json!({ "on": "turn-end", "inject": prose }).to_string(),
"h.json",
)
.unwrap_or_else(|e| panic!("{prose:?} should validate: {e}"));
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&rendered("p", &h, hooks_binding()).command)
.env("OMH_GRAPH_PROJECT", "repo-s01")
.stdin(std::process::Stdio::null())
.output()
.expect("sh must run");
assert!(
out.status.success() && out.stderr.is_empty(),
"{prose:?} did not run: {} {:?}",
String::from_utf8_lossy(&out.stderr),
out.status.code()
);
let doc: serde_json::Value = serde_json::from_slice(&out.stdout)
.unwrap_or_else(|e| panic!("{prose:?} emitted no JSON: {e}"));
assert!(
!doc["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default()
.is_empty(),
"{prose:?} injected nothing"
);
}
}
#[test]
fn a_braced_reference_with_a_default_still_reads_the_field() {
let h = Hook::parse(
r#"{"on":"before-tool","run":"echo ${OMH_TOOL_FILE:-none}"}"#,
"h.json",
)
.unwrap();
assert_eq!(h.fields(), BTreeSet::from([Field::ToolFile]));
}
#[test]
fn a_dollar_naming_something_omh_never_sets_is_refused() {
for prose in [
"your $PATH is long",
"run as $USER",
"captured $OMH_CAPTURE",
] {
let err = Hook::parse(
&serde_json::json!({ "on": "turn-end", "inject": prose }).to_string(),
"h.json",
)
.expect_err(&format!("{prose:?} names nothing omh binds"));
assert!(
err.to_string().contains("OMH_"),
"must name what is available: {err}"
);
}
}
#[test]
fn a_malformed_expansion_is_refused_even_when_a_brace_appears_later() {
for prose in [
"cost is ${ high } today",
"a ${} placeholder",
"cost is ${ high",
"${1bad} name",
"a } brace, then cost is ${ high",
] {
assert!(
Hook::parse(
&serde_json::json!({ "on": "turn-end", "inject": prose }).to_string(),
"h.json",
)
.is_err(),
"{prose:?} renders a bad substitution and must not validate"
);
}
}
#[test]
fn only_the_fields_a_hook_mentions_are_bound() {
let quiet = Hook::parse(r#"{"on":"before-tool","inject":"a nudge"}"#, "h.json").unwrap();
assert!(quiet.fields().is_empty());
assert!(!rendered("q", &quiet, hooks_binding())
.command
.contains("jq -r"));
let reads = Hook::parse(
r#"{"on":"before-tool","when":"[ -f \"$OMH_TOOL_FILE\" ]","run":"x"}"#,
"h.json",
)
.unwrap();
assert_eq!(reads.fields(), BTreeSet::from([Field::ToolFile]));
}
#[test]
fn a_longer_name_is_not_a_field_reference() {
let h = Hook::parse(
r#"{"on":"before-tool","run":"echo $OMH_TOOL_FILENAME"}"#,
"h.json",
)
.unwrap();
assert!(
h.fields().is_empty(),
"OMH_TOOL_FILENAME is its own variable"
);
}
#[test]
fn the_payload_is_read_once_however_many_fields() {
let h = Hook::parse(
r#"{"on":"before-tool","when":"[ -n \"$OMH_TOOL_COMMAND\" ]",
"inject":"about $OMH_TOOL_FILE"}"#,
"h.json",
)
.unwrap();
let cmd = rendered("two", &h, hooks_binding()).command;
assert_eq!(cmd.matches("$(cat)").count(), 1, "got: {cmd}");
assert_eq!(cmd.matches("jq -r").count(), 2, "one per field: {cmd}");
}
fn shipped() -> &'static Adapter {
static CELL: std::sync::OnceLock<Adapter> = std::sync::OnceLock::new();
CELL.get_or_init(claude)
}
fn hooks_binding() -> &'static Binding {
shipped()
.supports(Capability::Hooks)
.expect("claude has hooks")
}
#[test]
fn an_event_this_harness_cannot_express_drops_the_hook_by_name() {
let b = binding(
"path = \"/x\"\nrender = \"claude-settings\"\n\
[events]\nturn-end = \"Stop\"\n[inject]\ntemplate = \"echo {{text}}\"\n",
);
let h = Hook::parse(r#"{"on":"after-tool","run":"cargo fmt"}"#, "h.json").unwrap();
let d = dropped("rust-format", &h, &b);
assert_eq!(d.name, "rust-format");
assert!(d.wanted.contains("after-tool"), "got: {}", d.wanted);
let sibling = Hook::parse(r#"{"on":"turn-end","run":"cargo test"}"#, "h.json").unwrap();
assert_eq!(rendered("rust-test", &sibling, &b).command, "cargo test");
}
#[test]
fn an_unmapped_tool_drops_the_hook_saying_which_tool() {
let b = binding(
"path = \"/x\"\nrender = \"claude-settings\"\n\
[events]\nbefore-tool = \"PreToolUse\"\n\
[inject]\ntemplate = \"echo {{text}}\"\n",
);
let tools = BTreeMap::from([(Tool::Shell, "Bash".to_string())]);
let h = Hook::parse(
r#"{"on":"before-tool","tools":["read"],"run":"x"}"#,
"h.json",
)
.unwrap();
match render("peek", &h, &b, &tools).unwrap() {
Outcome::Dropped(d) => assert!(d.wanted.contains("read"), "got: {}", d.wanted),
Outcome::Rendered(r) => panic!("unexpectedly rendered: {r:?}"),
}
}
#[test]
fn an_unmapped_field_drops_the_hook_saying_which_field() {
let b = binding(
"path = \"/x\"\nrender = \"claude-settings\"\n\
[events]\nbefore-tool = \"PreToolUse\"\n\
[fields]\ntool-command = \".cmd\"\n[inject]\ntemplate = \"echo {{text}}\"\n",
);
let h = Hook::parse(
r#"{"on":"before-tool","when":"[ -f \"$OMH_TOOL_FILE\" ]","run":"x"}"#,
"h.json",
)
.unwrap();
assert!(dropped("graph-read", &h, &b).wanted.contains("tool-file"));
}
#[test]
fn a_harness_that_cannot_advise_drops_the_nudge_by_name() {
let b = binding(
"path = \"/x\"\nrender = \"claude-settings\"\n\
[events]\nbefore-tool = \"PreToolUse\"\n\
[refuse]\ntemplate = \"deny {{text}}\"\n",
);
let h = Hook::parse(r#"{"on":"before-tool","inject":"a nudge"}"#, "h.json").unwrap();
let d = dropped("graph-first", &h, &b);
assert!(
d.wanted.contains("inject"),
"say what it wanted: {}",
d.wanted
);
let r = Hook::parse(r#"{"on":"before-tool","run":"x"}"#, "h.json").unwrap();
assert_eq!(rendered("r", &r, &b).command, "x");
}
#[test]
fn a_harness_that_cannot_refuse_drops_the_hook_by_name() {
let b = binding(
"path = \"/x\"\nrender = \"claude-settings\"\n\
[events]\nbefore-tool = \"PreToolUse\"\n\
[inject]\ntemplate = \"echo {{text}}\"\n",
);
let h = Hook::parse(r#"{"on":"before-tool","refuse":"no git here"}"#, "h.json").unwrap();
let d = dropped("git-unavailable", &h, &b);
assert_eq!(d.name, "git-unavailable");
assert!(
d.wanted.contains("refuse"),
"say what it wanted: {}",
d.wanted
);
}
#[test]
fn a_refusal_reaches_the_model_through_the_harnesss_own_protocol() {
let h = Hook::parse(
r#"{"on":"before-tool","tools":["shell"],"refuse":"git does not work here"}"#,
"h.json",
)
.unwrap();
let cmd = rendered("git-unavailable", &h, hooks_binding()).command;
assert!(cmd.contains("permissionDecision"), "got: {cmd}");
assert!(cmd.contains("deny"), "got: {cmd}");
assert!(
!cmd.contains("additionalContext"),
"a refusal is not a notice: {cmd}"
);
assert!(cmd.contains("git does not work here"), "got: {cmd}");
}
#[test]
fn a_harness_that_cannot_take_text_still_runs_commands() {
let b =
binding("path = \"/x\"\nrender = \"claude-settings\"\n[events]\nturn-end = \"Stop\"\n");
let say = Hook::parse(r#"{"on":"turn-end","inject":"hello"}"#, "h.json").unwrap();
assert!(dropped("say", &say, &b).wanted.contains("inject"));
let run = Hook::parse(r#"{"on":"turn-end","run":"cargo test"}"#, "h.json").unwrap();
assert_eq!(rendered("run", &run, &b).command, "cargo test");
}
#[test]
fn injected_prose_survives_the_shell() {
let prose = "a \"quoted\" word, a \\ backslash, a `backtick`, and $$5 — it's fine";
let h = Hook::parse(
&serde_json::json!({ "on": "turn-end", "inject": prose }).to_string(),
"h.json",
)
.unwrap();
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&rendered("prose", &h, hooks_binding()).command)
.stdin(std::process::Stdio::null())
.output()
.expect("sh must run");
assert!(
out.stderr.is_empty(),
"the harness shows the user stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let doc: serde_json::Value = serde_json::from_slice(&out.stdout)
.unwrap_or_else(|e| panic!("not JSON: {} ({e})", String::from_utf8_lossy(&out.stdout)));
assert_eq!(
doc["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap(),
prose.replace("$$", "$"),
"the prose has to survive shell quoting intact"
);
}
#[test]
fn capture_is_evaluated_before_the_predicate_that_tests_it() {
let h = Hook::parse(
r#"{"on":"session-start","capture":"echo hi",
"when":"[ -n \"$OMH_CAPTURE\" ]","inject":"got $OMH_CAPTURE"}"#,
"h.json",
)
.unwrap();
let cmd = rendered("orient", &h, hooks_binding()).command;
assert!(
cmd.find("OMH_CAPTURE=$(").unwrap() < cmd.find("|| exit 0").unwrap(),
"got: {cmd}"
);
}
}