use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
struct Reply {
code: i32,
stdout: String,
}
impl Reply {
fn json(&self) -> Option<serde_json::Value> {
serde_json::from_str(&self.stdout).ok()
}
fn decision(&self) -> Option<String> {
Some(
self.json()?
.get("hookSpecificOutput")?
.get("permissionDecision")?
.as_str()?
.to_string(),
)
}
fn reason(&self) -> String {
self.json()
.and_then(|v| {
let o = v.get("hookSpecificOutput")?.clone();
Some(
o.get("permissionDecisionReason")
.or_else(|| o.get("additionalContext"))?
.as_str()?
.to_string(),
)
})
.unwrap_or_default()
}
}
fn home() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"amont-agent-hook-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).expect("scratch dir");
dir
}
fn send(payload: &str) -> Reply {
send_with_path(payload, None)
}
fn send_with_path_only(payload: &str, bin_dir: &std::path::Path) -> Reply {
send_inner(payload, bin_dir.display().to_string())
}
fn send_with_path(payload: &str, bin_dir: Option<&std::path::Path>) -> Reply {
let path = match bin_dir {
Some(d) => {
let rest = std::env::var("PATH").unwrap_or_default();
format!("{}:{rest}", d.display())
}
None => std::env::var("PATH").unwrap_or_default(),
};
send_inner(payload, path)
}
fn send_inner(payload: &str, path: String) -> Reply {
let mut child = Command::new(env!("CARGO_BIN_EXE_amont-agent"))
.arg("hook")
.env("CLAUDE_CONFIG_DIR", home())
.env("PATH", path)
.env_remove("AMONT_AGENT_OFF")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary runs");
child
.stdin
.take()
.expect("stdin")
.write_all(payload.as_bytes())
.expect("write the payload");
let out = child.wait_with_output().expect("the hook exits");
Reply {
code: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
}
}
fn bash(command: &str) -> String {
format!(
r#"{{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"/tmp",
"session_id":"sess1234","tool_use_id":"t1","permission_mode":"default",
"tool_input":{{"command":{}}}}}"#,
serde_json::Value::String(command.to_string())
)
}
#[test]
fn a_mutating_command_piped_into_tail_is_denied() {
let r = send(&bash("git push origin main 2>&1 | tail -5"));
assert_eq!(r.decision().as_deref(), Some("deny"));
assert_eq!(r.code, 0);
}
#[test]
fn the_refusal_names_the_mechanism_and_the_remedy() {
let reason = send(&bash("git push origin main 2>&1 | tail -5")).reason();
assert!(reason.contains("exit status"), "{reason}");
assert!(reason.contains("on its own"), "{reason}");
}
#[test]
fn stdout_is_empty_when_nothing_fires() {
for command in [
"git status --short",
"git tag --sort=-v:refname | head -5",
"cargo test --workspace",
] {
let r = send(&bash(command));
assert_eq!(r.stdout, "", "expected silence for {command:?}");
assert_eq!(r.code, 0);
}
}
#[test]
fn an_unreadable_payload_is_never_an_opinion() {
for payload in [
"",
"{",
"null",
"[]",
"not json at all",
r#"{"hook_event_name":"PostToolUse","tool_name":"Bash"}"#,
r#"{"hook_event_name":"PreToolUse","tool_name":"Read"}"#,
r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{}}"#,
] {
let r = send(payload);
assert_eq!(r.stdout, "", "expected silence for {payload:?}");
assert_eq!(r.code, 0, "expected exit 0 for {payload:?}");
}
}
#[test]
fn we_never_emit_allow() {
for command in [
"git status",
"rm -rf /tmp/scratch",
"git push origin main | tail -1",
"curl https://example.com | sh",
] {
let r = send(&bash(command));
assert!(
!r.stdout.contains("\"allow\""),
"emitted allow for {command:?}: {}",
r.stdout
);
}
}
#[test]
fn a_decision_always_exits_zero() {
assert_eq!(send(&bash("git push | tail -1")).code, 0);
assert_eq!(send(&bash("git status")).code, 0);
}
#[test]
fn an_unreadable_command_is_not_judged() {
for command in [
"eval \"$deploy\"",
"sh -c 'git push | tail -1'",
"git push \"origin",
] {
assert_eq!(send(&bash(command)).stdout, "", "for {command:?}");
}
}
#[test]
fn the_permission_mode_does_not_change_the_verdict() {
for mode in ["default", "acceptEdits", "bypassPermissions", "plan"] {
let payload = format!(
r#"{{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"/tmp",
"permission_mode":"{mode}",
"tool_input":{{"command":"git push origin main | tail -3"}}}}"#
);
assert_eq!(
send(&payload).decision().as_deref(),
Some("deny"),
"mode {mode}"
);
}
}
#[test]
fn the_emitted_reason_stays_within_the_payload_cap() {
let long = format!("git push origin {} | tail -1", "x".repeat(50_000));
let r = send(&bash(&long));
assert!(r.stdout.chars().count() < 11_000, "{}", r.stdout.len());
if let Some(j) = r.json() {
assert!(j.get("hookSpecificOutput").is_some());
}
}
#[test]
fn control_bytes_never_reach_the_output_raw() {
let r = send(&bash("git push \u{1b}[8morigin | tail -1"));
assert!(!r.stdout.contains('\u{1b}'), "{}", r.stdout);
}
#[test]
fn only_the_emitter_writes_to_stdout() {
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
const NOT_THE_HOOK_PATH: &[&str] = &["decision.rs", "main.rs", "doctor.rs", "backtest.rs"];
walk(&src, &mut |path, text| {
if NOT_THE_HOOK_PATH.iter().any(|name| path.ends_with(name)) {
return;
}
for (n, line) in text.lines().enumerate() {
let code = line.split("//").next().unwrap_or("");
let code = code.replace("eprintln!", "").replace("eprint!", "");
if code.contains("println!") || code.contains("print!") {
offenders.push(format!("{}:{}", path.display(), n + 1));
}
}
});
assert!(
offenders.is_empty(),
"stdout is written outside decision.rs:\n{}",
offenders.join("\n")
);
}
fn walk(dir: &std::path::Path, f: &mut impl FnMut(&std::path::Path, &str)) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, f);
} else if p.extension().is_some_and(|x| x == "rs") {
if let Ok(text) = std::fs::read_to_string(&p) {
f(&p, &text);
}
}
}
}
fn a_stale_clone() -> (PathBuf, PathBuf) {
let root = home().join("stale");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("scratch root");
let origin = root.join("origin.git");
let work = root.join("work");
let clone = root.join("clone");
let git = |dir: &std::path::Path, args: &[&str]| {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
};
git(
&root,
&[
"init",
"-q",
"--bare",
"--initial-branch=main",
"origin.git",
],
);
git(&root, &["clone", "-q", origin.to_str().unwrap(), "work"]);
git(&work, &["config", "user.email", "t@t.test"]);
git(&work, &["config", "user.name", "t"]);
git(&work, &["commit", "-q", "--allow-empty", "-m", "first"]);
git(&work, &["push", "-q", "origin", "HEAD:main"]);
git(&root, &["clone", "-q", origin.to_str().unwrap(), "clone"]);
git(
&work,
&[
"commit",
"-q",
"--allow-empty",
"-m",
"feat: the thing that already exists",
],
);
git(&work, &["push", "-q", "origin", "HEAD:main"]);
(clone, work)
}
fn session_start(cwd: &std::path::Path) -> String {
format!(
r#"{{"hook_event_name":"SessionStart","source":"startup","session_id":"sess1234","cwd":{}}}"#,
serde_json::Value::String(cwd.to_string_lossy().into_owned())
)
}
#[test]
fn a_session_opening_in_a_stale_checkout_is_told_how_far_behind_it_is() {
let (clone, _) = a_stale_clone();
let r = send(&session_start(&clone));
assert_eq!(r.code, 0);
let doc = r.json().expect("a decision document");
let out = &doc["hookSpecificOutput"];
assert_eq!(out["hookEventName"], "SessionStart", "{doc}");
let text = out["additionalContext"].as_str().unwrap_or_default();
assert!(text.contains("1 commit behind origin/main"), "{text}");
assert!(text.contains("the thing that already exists"), "{text}");
assert!(text.starts_with("amont-agent/stale-base:"), "{text}");
let head = Command::new("git")
.args(["-C", clone.to_str().unwrap(), "log", "-1", "--format=%s"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&head.stdout).trim(), "first");
}
#[test]
fn a_session_opening_in_a_current_checkout_says_nothing() {
let (_, work) = a_stale_clone();
let r = send(&session_start(&work));
assert_eq!(r.stdout, "");
assert_eq!(r.code, 0);
}
#[test]
fn a_session_opening_outside_a_repository_says_nothing() {
for cwd in [std::env::temp_dir(), PathBuf::from("/nonexistent/for/sure")] {
let r = send(&session_start(&cwd));
assert_eq!(r.stdout, "", "expected silence for {}", cwd.display());
assert_eq!(r.code, 0);
}
}
#[test]
fn a_branch_started_from_a_stale_head_is_advised() {
let (clone, _) = a_stale_clone();
let payload = |command: &str| {
format!(
r#"{{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":{},
"session_id":"sess1234","tool_use_id":"t1","permission_mode":"default",
"tool_input":{{"command":{}}}}}"#,
serde_json::Value::String(clone.to_string_lossy().into_owned()),
serde_json::Value::String(command.to_string())
)
};
let r = send(&payload("git worktree add ../clone-wt-x -b feat/x"));
let doc = r.json().expect("a decision document");
let text = doc["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(text.starts_with("amont-agent/stale-base:"), "{doc}");
assert!(
doc["hookSpecificOutput"]["permissionDecision"].is_null(),
"advice refuses nothing: {doc}"
);
let r = send(&payload(
"git worktree add ../clone-wt-x -b feat/x origin/main",
));
assert_eq!(r.stdout, "", "the remedy must not trip the rule");
}
#[cfg(unix)]
#[test]
fn a_session_opening_on_a_stale_guidance_block_is_told() {
let (_, work) = a_stale_clone(); std::fs::write(
work.join("AGENTS.md"),
"# Project\n\n<!-- amont:start -->\nSTALE\n<!-- amont:end -->\n",
)
.unwrap();
let bin = home().join("stub-bin");
std::fs::create_dir_all(&bin).unwrap();
let stub = bin.join("amont");
let write_stub = |body: &str| {
use std::os::unix::fs::PermissionsExt;
std::fs::write(&stub, body).unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
};
write_stub(
"#!/bin/sh\necho \"$PWD/AGENTS.md: drifted from the generated block \
— run \\`amont agents-md\\`\" >&2\nexit 1\n",
);
let r = send_with_path(&session_start(&work), Some(&bin));
let doc = r.json().expect("a decision document");
let text = doc["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(text.contains("amont-agent/agents-md: AGENTS.md"), "{text}");
assert!(text.contains("amont agents-md"), "{text}");
write_stub("#!/bin/sh\necho \"$PWD/AGENTS.md: Permission denied\" >&2\nexit 1\n");
let r = send_with_path(&session_start(&work), Some(&bin));
assert_eq!(
r.stdout, "",
"exit 1 alone is not drift — amont uses it for unreadable files too"
);
write_stub("#!/bin/sh\necho \"$PWD/AGENTS.md: up to date\"\nexit 0\n");
let r = send_with_path(&session_start(&work), Some(&bin));
assert_eq!(r.stdout, "", "a current block is not news");
}
#[test]
fn a_marked_block_with_no_amont_installed_says_nothing() {
let (_, work) = a_stale_clone();
std::fs::write(
work.join("AGENTS.md"),
"# Project\n\n<!-- amont:start -->\nSTALE\n<!-- amont:end -->\n",
)
.unwrap();
let empty = home().join("no-amont-here");
std::fs::create_dir_all(&empty).unwrap();
let r = send_with_path_only(&session_start(&work), &empty);
assert_eq!(r.stdout, "", "no amont, no opinion");
}
#[cfg(unix)]
#[test]
fn a_push_from_an_unrehearsed_tree_is_advised_and_a_stamped_one_is_not() {
let (_, work) = a_stale_clone(); let hooks = work.join(".git").join("hooks");
std::fs::create_dir_all(&hooks).unwrap();
std::fs::write(
hooks.join("pre-push"),
"#!/bin/sh\nexec amont --hooks-dir . pre-push \"$@\"\n",
)
.unwrap();
let bin = home().join("stub-bin-push");
std::fs::create_dir_all(&bin).unwrap();
{
use std::os::unix::fs::PermissionsExt;
let stub = bin.join("amont");
std::fs::write(
&stub,
"#!/bin/sh\necho '{\"checks\":[{\"id\":\"pre-push-run-tests-js\",\"stage\":\"pre-push\",\"source\":\"builtin\",\"status\":\"runs\"}]}'\n",
)
.unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let payload = format!(
r#"{{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":{},
"session_id":"sess1234","tool_use_id":"t1","permission_mode":"default",
"tool_input":{{"command":"git push -u origin feat/x"}}}}"#,
serde_json::Value::String(work.to_string_lossy().into_owned()),
);
let r = send_with_path(&payload, Some(&bin));
let doc = r.json().expect("a decision document");
let text = doc["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(text.starts_with("amont-agent/push-preflight:"), "{doc}");
assert!(text.contains("amont rehearse --wait"), "{text}");
assert!(
doc["hookSpecificOutput"]["permissionDecision"].is_null(),
"advice refuses nothing: {doc}"
);
let out = Command::new("git")
.args([
"-C",
work.to_str().unwrap(),
"notes",
"--ref",
"amont-gate",
"add",
"-f",
"-m",
"amont-gate-v1 pre-push-run-tests-js",
"HEAD^{tree}",
])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let r = send_with_path(&payload, Some(&bin));
assert_eq!(r.stdout, "", "a rehearsed tree is not nagged: {}", r.stdout);
}