use std::io::Read as _;
use std::process::{Command, Stdio};
use std::sync::Once;
use std::time::{Duration, Instant};
use supercode::{HookDecision, PreToolOutcome};
pub const DEFAULT_HOOK_TIMEOUT_MS: u64 = 10_000;
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct HooksFileConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pre_tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_start: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_end: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_prompt_submit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notification: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_start: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_stop: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pre_compact: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_compact: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
PreTool,
PostTool,
SessionStart,
SessionEnd,
Stop,
UserPromptSubmit,
Notification,
#[allow(dead_code)]
SubagentStart,
#[allow(dead_code)]
SubagentStop,
#[allow(dead_code)]
PreCompact,
#[allow(dead_code)]
PostCompact,
}
impl HookEvent {
pub fn as_str(self) -> &'static str {
match self {
HookEvent::PreTool => "pre_tool",
HookEvent::PostTool => "post_tool",
HookEvent::SessionStart => "session_start",
HookEvent::SessionEnd => "session_end",
HookEvent::Stop => "stop",
HookEvent::UserPromptSubmit => "user_prompt_submit",
HookEvent::Notification => "notification",
HookEvent::SubagentStart => "subagent_start",
HookEvent::SubagentStop => "subagent_stop",
HookEvent::PreCompact => "pre_compact",
HookEvent::PostCompact => "post_compact",
}
}
pub fn is_veto_capable(self) -> bool {
matches!(self, HookEvent::PreTool | HookEvent::Stop)
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct HookSet {
pub pre_tool: Option<String>,
pub post_tool: Option<String>,
pub session_start: Option<String>,
pub session_end: Option<String>,
pub stop: Option<String>,
pub user_prompt_submit: Option<String>,
pub notification: Option<String>,
pub subagent_start: Option<String>,
pub subagent_stop: Option<String>,
pub pre_compact: Option<String>,
pub post_compact: Option<String>,
pub timeout_ms: u64,
}
static WARN_SUBAGENT_START: Once = Once::new();
static WARN_SUBAGENT_STOP: Once = Once::new();
static WARN_PRE_COMPACT: Once = Once::new();
static WARN_POST_COMPACT: Once = Once::new();
fn warn_once_if_deferred(once: &Once, event: HookEvent, configured: bool) {
if configured {
once.call_once(|| {
eprintln!(
"warning: [hooks] {} is registered but is not yet emitted in this build (no-op)",
event.as_str()
);
});
}
}
impl HookSet {
pub fn resolve(fc: &HooksFileConfig) -> HookSet {
let env =
|name: &str| -> Option<String> { std::env::var(name).ok().filter(|v| !v.is_empty()) };
let hs = HookSet {
pre_tool: env("SUPERCODE_HOOK_PRE_TOOL").or_else(|| fc.pre_tool.clone()),
post_tool: env("SUPERCODE_HOOK_POST_TOOL").or_else(|| fc.post_tool.clone()),
session_start: env("SUPERCODE_HOOK_SESSION_START").or_else(|| fc.session_start.clone()),
session_end: env("SUPERCODE_HOOK_SESSION_END").or_else(|| fc.session_end.clone()),
stop: env("SUPERCODE_HOOK_STOP").or_else(|| fc.stop.clone()),
user_prompt_submit: env("SUPERCODE_HOOK_USER_PROMPT_SUBMIT")
.or_else(|| fc.user_prompt_submit.clone()),
notification: env("SUPERCODE_HOOK_NOTIFICATION").or_else(|| fc.notification.clone()),
subagent_start: env("SUPERCODE_HOOK_SUBAGENT_START")
.or_else(|| fc.subagent_start.clone()),
subagent_stop: env("SUPERCODE_HOOK_SUBAGENT_STOP").or_else(|| fc.subagent_stop.clone()),
pre_compact: env("SUPERCODE_HOOK_PRE_COMPACT").or_else(|| fc.pre_compact.clone()),
post_compact: env("SUPERCODE_HOOK_POST_COMPACT").or_else(|| fc.post_compact.clone()),
timeout_ms: env("SUPERCODE_HOOK_TIMEOUT_MS")
.and_then(|v| v.parse().ok())
.or(fc.timeout_ms)
.unwrap_or(DEFAULT_HOOK_TIMEOUT_MS),
};
warn_once_if_deferred(
&WARN_SUBAGENT_START,
HookEvent::SubagentStart,
hs.subagent_start.is_some(),
);
warn_once_if_deferred(
&WARN_SUBAGENT_STOP,
HookEvent::SubagentStop,
hs.subagent_stop.is_some(),
);
warn_once_if_deferred(
&WARN_PRE_COMPACT,
HookEvent::PreCompact,
hs.pre_compact.is_some(),
);
warn_once_if_deferred(
&WARN_POST_COMPACT,
HookEvent::PostCompact,
hs.post_compact.is_some(),
);
hs
}
pub fn configured_count(&self) -> usize {
[
&self.pre_tool,
&self.post_tool,
&self.session_start,
&self.session_end,
&self.stop,
&self.user_prompt_submit,
&self.notification,
&self.subagent_start,
&self.subagent_stop,
&self.pre_compact,
&self.post_compact,
]
.iter()
.filter(|c| c.is_some())
.count()
}
pub fn is_empty(&self) -> bool {
self.pre_tool.is_none()
&& self.post_tool.is_none()
&& self.session_start.is_none()
&& self.session_end.is_none()
&& self.stop.is_none()
&& self.user_prompt_submit.is_none()
&& self.notification.is_none()
&& self.subagent_start.is_none()
&& self.subagent_stop.is_none()
&& self.pre_compact.is_none()
&& self.post_compact.is_none()
}
fn command_for(&self, event: HookEvent) -> Option<&str> {
match event {
HookEvent::PreTool => self.pre_tool.as_deref(),
HookEvent::PostTool => self.post_tool.as_deref(),
HookEvent::SessionStart => self.session_start.as_deref(),
HookEvent::SessionEnd => self.session_end.as_deref(),
HookEvent::Stop => self.stop.as_deref(),
HookEvent::UserPromptSubmit => self.user_prompt_submit.as_deref(),
HookEvent::Notification => self.notification.as_deref(),
HookEvent::SubagentStart => self.subagent_start.as_deref(),
HookEvent::SubagentStop => self.subagent_stop.as_deref(),
HookEvent::PreCompact => self.pre_compact.as_deref(),
HookEvent::PostCompact => self.post_compact.as_deref(),
}
}
}
#[derive(Debug, Default)]
struct HookOutcome {
timed_out: bool,
exit_code: Option<i32>,
stdout: String,
stderr: String,
spawn_error: Option<String>,
}
impl HookOutcome {
fn failed(&self, event: HookEvent) -> bool {
self.spawn_error.is_some()
|| self.timed_out
|| matches!(
self.exit_code,
Some(c) if c != 0 && !event.is_veto_capable()
)
}
}
fn run_hook(
event: HookEvent,
command: &str,
timeout_ms: u64,
extra_env: &[(&str, String)],
) -> HookOutcome {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(command);
cmd.env("SUPERCODE_HOOK_EVENT", event.as_str());
for (k, v) in extra_env {
cmd.env(k, v);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return HookOutcome {
spawn_error: Some(e.to_string()),
..Default::default()
}
}
};
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_handle = stdout_pipe.map(|mut p| {
std::thread::spawn(move || {
let mut s = String::new();
let _ = p.read_to_string(&mut s);
s
})
});
let stderr_handle = stderr_pipe.map(|mut p| {
std::thread::spawn(move || {
let mut s = String::new();
let _ = p.read_to_string(&mut s);
s
})
});
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
let mut timed_out = false;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) => {
if Instant::now() >= deadline {
timed_out = true;
kill_process_tree(&mut child);
let _ = child.wait();
break None;
}
std::thread::sleep(Duration::from_millis(15));
}
Err(_) => break None,
}
};
HookOutcome {
timed_out,
exit_code: status.and_then(|s| s.code()),
stdout: stdout_handle
.and_then(|h| h.join().ok())
.unwrap_or_default(),
stderr: stderr_handle
.and_then(|h| h.join().ok())
.unwrap_or_default(),
spawn_error: None,
}
}
#[cfg(unix)]
fn kill_process_tree(child: &mut std::process::Child) {
unsafe {
libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL);
}
let _ = child.kill(); }
#[cfg(not(unix))]
fn kill_process_tree(child: &mut std::process::Child) {
let _ = child.kill();
}
fn report(event: HookEvent, outcome: &HookOutcome, quiet: bool) {
if let Some(err) = &outcome.spawn_error {
eprintln!("[hook:{}] failed to start: {err}", event.as_str());
return;
}
let failed = outcome.failed(event);
if !quiet || failed {
let out = outcome.stdout.trim();
if !out.is_empty() {
eprintln!("[hook:{}] {out}", event.as_str());
}
let err = outcome.stderr.trim();
if !err.is_empty() {
eprintln!("[hook:{}] stderr: {err}", event.as_str());
}
}
if outcome.timed_out {
eprintln!("[hook:{}] timed out and was killed", event.as_str());
} else if let Some(code) = outcome.exit_code {
if code != 0 && !event.is_veto_capable() {
eprintln!("[hook:{}] exited {code}", event.as_str());
}
}
}
pub fn fire_observational(
hooks: &HookSet,
event: HookEvent,
extra_env: &[(&str, String)],
quiet: bool,
) {
let Some(command) = hooks.command_for(event) else {
return;
};
let outcome = run_hook(event, command, hooks.timeout_ms, extra_env);
report(event, &outcome, quiet);
}
pub fn fire_user_prompt_submit(
hooks: &HookSet,
prompt: &str,
cwd: &std::path::Path,
session_name: Option<&str>,
quiet: bool,
) {
if hooks.user_prompt_submit.is_none() {
return;
}
let mut extra = vec![
("SUPERCODE_HOOK_CWD", cwd.to_string_lossy().into_owned()),
(
"SUPERCODE_HOOK_PROMPT",
prompt.chars().take(8_000).collect::<String>(),
),
];
if let Some(name) = session_name {
extra.push(("SUPERCODE_HOOK_SESSION", name.to_string()));
}
fire_observational(hooks, HookEvent::UserPromptSubmit, &extra, quiet);
}
pub fn fire_notification(
hooks: &HookSet,
model: &str,
elapsed_secs: u64,
summary: &str,
quiet: bool,
) {
if hooks.notification.is_none() {
return;
}
let extra: [(&str, String); 4] = [
(
"SUPERCODE_HOOK_NOTIFICATION_KIND",
"agent_completed".to_string(),
),
("SUPERCODE_HOOK_MODEL", model.to_string()),
("SUPERCODE_HOOK_ELAPSED_SECS", elapsed_secs.to_string()),
(
"SUPERCODE_HOOK_SUMMARY",
summary.chars().take(2_000).collect::<String>(),
),
];
fire_observational(hooks, HookEvent::Notification, &extra, quiet);
}
pub fn fire_pre_tool(hooks: &HookSet, extra_env: &[(&str, String)], quiet: bool) -> PreToolOutcome {
let Some(command) = hooks.pre_tool.as_ref() else {
return PreToolOutcome::pass();
};
let outcome = run_hook(HookEvent::PreTool, command, hooks.timeout_ms, extra_env);
report(HookEvent::PreTool, &outcome, quiet);
let result = if let Some(err) = &outcome.spawn_error {
PreToolOutcome::deny(format!("pre_tool hook failed to start: {err}"))
} else if outcome.timed_out {
PreToolOutcome::deny(format!(
"pre_tool hook timed out after {}ms",
hooks.timeout_ms
))
} else {
match outcome.exit_code {
Some(0) => parse_pre_tool_stdout(outcome.stdout.trim()),
Some(code) => {
let text = outcome.stdout.trim();
PreToolOutcome::deny(if text.is_empty() {
format!("pre_tool hook denied (exit {code})")
} else {
text.to_string()
})
}
None => PreToolOutcome::deny("pre_tool hook exited abnormally (no exit code)"),
}
};
if result.decision == HookDecision::Deny {
let r = result
.reason
.clone()
.unwrap_or_else(|| "denied".to_string());
eprintln!("[hook:pre_tool] denied: {r}");
}
result
}
fn parse_pre_tool_stdout(stdout: &str) -> PreToolOutcome {
let Ok(value) = serde_json::from_str::<serde_json::Value>(stdout) else {
return PreToolOutcome::pass();
};
let Some(obj) = value.as_object() else {
return PreToolOutcome::pass();
};
let specific = obj.get("hookSpecificOutput").and_then(|v| v.as_object());
let field = |name: &str, alt: &str| -> Option<serde_json::Value> {
specific
.and_then(|o| o.get(name))
.or_else(|| obj.get(name))
.or_else(|| specific.and_then(|o| o.get(alt)))
.or_else(|| obj.get(alt))
.cloned()
};
let decision = match field("permissionDecision", "decision")
.as_ref()
.and_then(|v| v.as_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("allow") => HookDecision::Allow,
Some("ask") => HookDecision::Ask,
Some("deny") => HookDecision::Deny,
_ => HookDecision::Pass,
};
PreToolOutcome {
decision,
reason: field("permissionDecisionReason", "reason")
.as_ref()
.and_then(|v| v.as_str())
.map(str::to_string),
updated_args: field("updatedInput", "updated_args").filter(serde_json::Value::is_object),
}
}
pub fn fire_stop(hooks: &HookSet, final_content: &str, quiet: bool) -> Option<String> {
let command = hooks.stop.as_ref()?;
let extra_env: [(&str, String); 1] = [(
"SUPERCODE_HOOK_STOP_CONTENT",
final_content.chars().take(8_000).collect::<String>(),
)];
let outcome = run_hook(HookEvent::Stop, command, hooks.timeout_ms, &extra_env);
report(HookEvent::Stop, &outcome, quiet);
if outcome.spawn_error.is_some() || outcome.timed_out {
return None;
}
match outcome.exit_code {
Some(0) | None => None,
Some(code) => {
let text = outcome.stdout.trim();
let reason = if text.is_empty() {
format!("stop hook vetoed (exit {code})")
} else {
text.to_string()
};
eprintln!("[hook:stop] vetoed termination: {reason}");
Some(reason)
}
}
}
pub fn load_hook_set(cwd: &std::path::Path) -> HookSet {
HookSet::resolve(&crate::userconfig::load(cwd).hooks)
}
pub fn session_env(
cwd: &std::path::Path,
session_name: Option<&str>,
) -> Vec<(&'static str, String)> {
let mut v = vec![("SUPERCODE_HOOK_CWD", cwd.to_string_lossy().into_owned())];
if let Some(name) = session_name {
v.push(("SUPERCODE_HOOK_SESSION", name.to_string()));
}
v
}
#[cfg(test)]
mod tests {
use super::*;
fn touch_cmd(path: &std::path::Path) -> String {
format!("touch {}", shell_quote(path.to_str().unwrap()))
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn tmp(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"supercode-ux28-hooks-unit-{tag}-{}-{nanos}",
std::process::id()
))
}
#[test]
fn default_hook_set_is_empty() {
assert!(HookSet::default().is_empty());
assert!(HookSet::resolve(&HooksFileConfig::default()).is_empty());
}
#[test]
fn a_single_configured_event_makes_the_set_non_empty() {
let fc = HooksFileConfig {
session_start: Some("true".to_string()),
..Default::default()
};
assert!(!HookSet::resolve(&fc).is_empty());
}
#[test]
fn default_timeout_applies_when_unset() {
let hs = HookSet::resolve(&HooksFileConfig::default());
assert_eq!(hs.timeout_ms, DEFAULT_HOOK_TIMEOUT_MS);
}
#[test]
fn file_config_timeout_is_honored() {
let fc = HooksFileConfig {
timeout_ms: Some(1234),
..Default::default()
};
assert_eq!(HookSet::resolve(&fc).timeout_ms, 1234);
}
#[test]
fn empty_set_fires_nothing_for_any_event() {
let hs = HookSet::default();
let marker = tmp("empty-noop");
for event in [
HookEvent::PreTool,
HookEvent::PostTool,
HookEvent::SessionStart,
HookEvent::SessionEnd,
] {
fire_observational(&hs, event, &[], true);
}
assert!(
!marker.exists(),
"an empty HookSet must never spawn a process"
);
assert!(
fire_pre_tool(&hs, &[], true).decision != HookDecision::Deny,
"empty pre_tool must allow"
);
}
#[test]
fn session_start_hook_fires_and_its_side_effect_is_observable() {
let marker = tmp("session-start");
let fc = HooksFileConfig {
session_start: Some(touch_cmd(&marker)),
..Default::default()
};
let hs = HookSet::resolve(&fc);
fire_observational(&hs, HookEvent::SessionStart, &[], true);
assert!(marker.exists(), "session_start hook must have run");
}
#[test]
fn session_end_hook_fires_and_its_side_effect_is_observable() {
let marker = tmp("session-end");
let fc = HooksFileConfig {
session_end: Some(touch_cmd(&marker)),
..Default::default()
};
let hs = HookSet::resolve(&fc);
fire_observational(&hs, HookEvent::SessionEnd, &[], true);
assert!(marker.exists(), "session_end hook must have run");
}
#[test]
fn post_tool_hook_fires_and_receives_extra_env_context() {
let marker = tmp("post-tool-env");
let fc = HooksFileConfig {
post_tool: Some(format!(
"[ \"$SUPERCODE_HOOK_TOOL\" = \"bash\" ] && {}",
touch_cmd(&marker)
)),
..Default::default()
};
let hs = HookSet::resolve(&fc);
fire_observational(
&hs,
HookEvent::PostTool,
&[("SUPERCODE_HOOK_TOOL", "bash".to_string())],
true,
);
assert!(
marker.exists(),
"post_tool hook must see SUPERCODE_HOOK_TOOL"
);
}
#[test]
fn observational_hook_failure_does_not_panic_or_propagate() {
let fc = HooksFileConfig {
post_tool: Some("exit 7".to_string()),
..Default::default()
};
let hs = HookSet::resolve(&fc);
fire_observational(&hs, HookEvent::PostTool, &[], true); }
#[test]
fn pre_tool_hook_zero_exit_allows() {
let fc = HooksFileConfig {
pre_tool: Some("exit 0".to_string()),
..Default::default()
};
let hs = HookSet::resolve(&fc);
assert_eq!(
fire_pre_tool(&hs, &[], true).decision,
HookDecision::Pass,
"an exit-0 hook with no output still means no opinion"
);
}
#[test]
fn pre_tool_hook_nonzero_exit_denies_with_its_stdout_as_reason() {
let fc = HooksFileConfig {
pre_tool: Some("echo 'no shells allowed'; exit 1".to_string()),
..Default::default()
};
let hs = HookSet::resolve(&fc);
let outcome = fire_pre_tool(&hs, &[], true);
assert_eq!(outcome.decision, HookDecision::Deny);
assert_eq!(outcome.reason.as_deref(), Some("no shells allowed"));
}
#[test]
fn pre_tool_hook_timeout_denies_fail_closed() {
let fc = HooksFileConfig {
pre_tool: Some("sleep 5".to_string()),
timeout_ms: Some(80),
..Default::default()
};
let hs = HookSet::resolve(&fc);
let start = Instant::now();
let outcome = fire_pre_tool(&hs, &[], true);
assert_eq!(
outcome.decision,
HookDecision::Deny,
"a timed-out pre_tool hook must deny (fail closed)"
);
assert!(
outcome.reason.unwrap().contains("timed out"),
"denial reason should say it timed out"
);
assert!(
start.elapsed() < Duration::from_secs(3),
"must be bounded by timeout_ms, not the sleep's full duration"
);
}
#[test]
fn pre_tool_hook_spawn_failure_denies_fail_closed() {
let fc = HooksFileConfig {
pre_tool: Some("/no/such/binary-ux28-hooks-test --deny-everything".to_string()),
..Default::default()
};
let hs = HookSet::resolve(&fc);
assert_eq!(fire_pre_tool(&hs, &[], true).decision, HookDecision::Deny);
}
#[test]
fn pre_tool_hook_command_string_never_shell_evaluates_extra_env_content() {
let marker = tmp("no-eval");
let fc = HooksFileConfig {
pre_tool: Some("exit 0".to_string()),
..Default::default()
};
let hs = HookSet::resolve(&fc);
let evil = format!("; {}; #", touch_cmd(&marker));
let outcome = fire_pre_tool(&hs, &[("SUPERCODE_HOOK_TOOL_ARGS", evil)], true);
assert_eq!(
outcome.decision,
HookDecision::Pass,
"fixed command still just exits 0"
);
assert!(
!marker.exists(),
"env-carried content must never be shell-evaluated"
);
}
#[test]
fn stop_hook_zero_exit_allows_termination() {
let fc = HooksFileConfig {
stop: Some("exit 0".to_string()),
..Default::default()
};
let hs = HookSet::resolve(&fc);
assert!(fire_stop(&hs, "final answer", true).is_none());
}
#[test]
fn stop_hook_nonzero_exit_vetoes_with_its_stdout_as_reason() {
let fc = HooksFileConfig {
stop: Some("echo 'not done yet'; exit 1".to_string()),
..Default::default()
};
let hs = HookSet::resolve(&fc);
let reason = fire_stop(&hs, "final answer", true);
assert_eq!(reason.as_deref(), Some("not done yet"));
}
#[test]
fn stop_hook_nonzero_exit_with_no_stdout_gets_a_default_reason() {
let fc = HooksFileConfig {
stop: Some("exit 3".to_string()),
..Default::default()
};
let hs = HookSet::resolve(&fc);
let reason = fire_stop(&hs, "final answer", true);
assert_eq!(reason.as_deref(), Some("stop hook vetoed (exit 3)"));
}
#[test]
fn stop_hook_timeout_fails_open_unlike_pre_tool() {
let fc = HooksFileConfig {
stop: Some("sleep 5".to_string()),
timeout_ms: Some(80),
..Default::default()
};
let hs = HookSet::resolve(&fc);
let start = Instant::now();
let reason = fire_stop(&hs, "final answer", true);
assert!(
reason.is_none(),
"a timed-out stop hook must ALLOW the stop (fail open) — unlike pre_tool"
);
assert!(
start.elapsed() < Duration::from_secs(3),
"must be bounded by timeout_ms"
);
}
#[test]
fn stop_hook_spawn_failure_fails_open() {
let fc = HooksFileConfig {
stop: Some("/no/such/binary-p4b-stop-hook-test".to_string()),
..Default::default()
};
let hs = HookSet::resolve(&fc);
let reason = fire_stop(&hs, "final answer", true);
assert!(
reason.is_some(),
"a plain non-zero exit is a NORMAL veto, not a failure"
);
}
#[test]
fn empty_hook_set_stop_is_a_no_op() {
let hs = HookSet::default();
assert!(fire_stop(&hs, "final answer", true).is_none());
}
#[test]
fn hook_set_with_only_stop_configured_is_not_empty() {
let fc = HooksFileConfig {
stop: Some("exit 0".to_string()),
..Default::default()
};
assert!(!HookSet::resolve(&fc).is_empty());
}
#[test]
fn only_pre_tool_and_stop_are_veto_capable() {
for e in [HookEvent::PreTool, HookEvent::Stop] {
assert!(e.is_veto_capable(), "{} must veto", e.as_str());
}
for e in [
HookEvent::PostTool,
HookEvent::SessionStart,
HookEvent::SessionEnd,
HookEvent::UserPromptSubmit,
HookEvent::Notification,
HookEvent::SubagentStart,
HookEvent::SubagentStop,
HookEvent::PreCompact,
HookEvent::PostCompact,
] {
assert!(
!e.is_veto_capable(),
"{} is observational and must never veto/escalate",
e.as_str()
);
}
}
#[test]
fn each_new_event_resolves_from_config_and_makes_the_set_non_empty() {
let cases = [
(
HookEvent::UserPromptSubmit,
HooksFileConfig {
user_prompt_submit: Some("true".into()),
..Default::default()
},
),
(
HookEvent::Notification,
HooksFileConfig {
notification: Some("true".into()),
..Default::default()
},
),
(
HookEvent::SubagentStart,
HooksFileConfig {
subagent_start: Some("true".into()),
..Default::default()
},
),
(
HookEvent::SubagentStop,
HooksFileConfig {
subagent_stop: Some("true".into()),
..Default::default()
},
),
(
HookEvent::PreCompact,
HooksFileConfig {
pre_compact: Some("true".into()),
..Default::default()
},
),
(
HookEvent::PostCompact,
HooksFileConfig {
post_compact: Some("true".into()),
..Default::default()
},
),
];
for (event, fc) in cases {
let hs = HookSet::resolve(&fc);
assert!(
!hs.is_empty(),
"{} should make the set non-empty",
event.as_str()
);
assert_eq!(hs.command_for(event), Some("true"));
}
}
#[test]
fn observational_new_event_fires_and_receives_env_context() {
let marker = tmp("pre-compact-fires");
let fc = HooksFileConfig {
pre_compact: Some(format!(
"[ \"$SUPERCODE_HOOK_EVENT\" = \"pre_compact\" ] && {}",
touch_cmd(&marker)
)),
..Default::default()
};
let hs = HookSet::resolve(&fc);
fire_observational(&hs, HookEvent::PreCompact, &[], true);
assert!(
marker.exists(),
"pre_compact hook must fire and see its event name"
);
}
#[test]
fn observational_new_event_nonzero_exit_does_not_panic_or_veto() {
let fc = HooksFileConfig {
subagent_stop: Some("exit 9".into()),
..Default::default()
};
let hs = HookSet::resolve(&fc);
fire_observational(&hs, HookEvent::SubagentStop, &[], true); }
#[test]
fn fire_user_prompt_submit_runs_and_passes_bounded_prompt_via_env() {
let ran = tmp("ups-ran");
let injected = tmp("ups-injected");
let fc = HooksFileConfig {
user_prompt_submit: Some(format!(
"[ -n \"$SUPERCODE_HOOK_PROMPT\" ] && {}",
touch_cmd(&ran)
)),
..Default::default()
};
let hs = HookSet::resolve(&fc);
let evil = format!("; {}; #", touch_cmd(&injected));
fire_user_prompt_submit(&hs, &evil, std::path::Path::new("/tmp"), Some("sess"), true);
assert!(
ran.exists(),
"user_prompt_submit must fire and see $SUPERCODE_HOOK_PROMPT"
);
assert!(
!injected.exists(),
"prompt content must be inert env text, never shell-evaluated"
);
}
#[test]
fn fire_user_prompt_submit_is_a_no_op_when_unconfigured() {
let hs = HookSet::default();
let marker = tmp("ups-noop");
fire_user_prompt_submit(&hs, "hi", std::path::Path::new("/tmp"), None, true);
assert!(!marker.exists());
}
#[test]
fn fire_notification_runs_and_passes_kind_and_summary_via_env() {
let ran = tmp("notif-ran");
let fc = HooksFileConfig {
notification: Some(format!(
"[ \"$SUPERCODE_HOOK_NOTIFICATION_KIND\" = \"agent_completed\" ] && {}",
touch_cmd(&ran)
)),
..Default::default()
};
let hs = HookSet::resolve(&fc);
fire_notification(&hs, "some/model", 42, "a short reply", true);
assert!(
ran.exists(),
"notification hook must fire with kind=agent_completed"
);
}
#[test]
fn fire_notification_is_a_no_op_when_unconfigured() {
let hs = HookSet::default();
let hs2 = HookSet::resolve(&HooksFileConfig {
session_start: Some("true".into()),
..Default::default()
});
fire_notification(&hs, "m", 1, "r", true);
fire_notification(&hs2, "m", 1, "r", true); }
#[test]
fn stop_hook_content_arrives_via_env_never_shell_evaluated() {
let ran_marker = tmp("stop-no-eval-ran");
let injected_marker = tmp("stop-no-eval-injected");
let fc = HooksFileConfig {
stop: Some(format!(
"[ -n \"$SUPERCODE_HOOK_STOP_CONTENT\" ] && {}",
touch_cmd(&ran_marker)
)),
..Default::default()
};
let hs = HookSet::resolve(&fc);
let evil_content = format!("; {}; #", touch_cmd(&injected_marker));
fire_stop(&hs, &evil_content, true);
assert!(
ran_marker.exists(),
"content must be visible (non-empty) via SUPERCODE_HOOK_STOP_CONTENT env var"
);
assert!(
!injected_marker.exists(),
"env-carried content must never be shell-evaluated"
);
}
}