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);
}
#[test]
fn a_declined_confirm_records_why_and_status_reads_it_back() {
let repo = home().join("solo-repo");
let _ = std::fs::remove_dir_all(&repo);
std::fs::create_dir_all(&repo).expect("scratch repo");
let init = Command::new("git")
.args(["init", "-q", "--initial-branch=main", "."])
.current_dir(&repo)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.output()
.expect("git runs");
assert!(init.status.success());
let payload = format!(
r#"{{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":{},
"session_id":"seen1234","tool_use_id":"t1","permission_mode":"default",
"tool_input":{{"command":"git checkout -b feat/why"}}}}"#,
serde_json::Value::String(repo.display().to_string())
);
let reply = send(&payload);
assert_eq!(reply.code, 0);
let journal = std::fs::read_to_string(home().join("amont-agent").join("journal.log"))
.expect("the hook wrote a journal");
let line = journal
.lines()
.find(|l| l.contains("worktree-isolation unconfirmed"))
.unwrap_or_else(|| panic!("no unconfirmed record in:\n{journal}"));
assert!(
line.contains("nothing_else_is_checked_out_from_this_repository"),
"the reason, in the rule's own words: {line}"
);
assert!(
!line.contains(" skipped "),
"`skipped` is not a reason: {line}"
);
let status = Command::new(env!("CARGO_BIN_EXE_amont-agent"))
.arg("status")
.env("CLAUDE_CONFIG_DIR", home())
.env_remove("AMONT_AGENT_OFF")
.output()
.expect("the binary runs");
let text = String::from_utf8_lossy(&status.stdout);
let row = text
.lines()
.find(|l| l.starts_with("worktree-isolation"))
.unwrap_or_else(|| panic!("no status row in:\n{text}"));
assert!(
row.contains("1 unconfirmed (nothing else is checked out from this repository \u{d7}1)"),
"status reads the journal back, reason and all: {row}"
);
}
struct ReadFixture {
dir: PathBuf,
file: PathBuf,
}
impl ReadFixture {
fn new(name: &str) -> ReadFixture {
let dir = home().join(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch dir");
let file = dir.join("big.rs");
std::fs::write(&file, "x".repeat(6000)).expect("a file worth reading");
ReadFixture { dir, file }
}
fn file_event(&self, event: &str, tool: &str, session: &str, input: &str) -> Reply {
let cwd = serde_json::Value::String(self.dir.display().to_string());
let path = serde_json::Value::String(self.file.display().to_string());
send(&format!(
r#"{{"hook_event_name":"{event}","tool_name":"{tool}","cwd":{cwd},
"session_id":"{session}","permission_mode":"default",
"tool_input":{{"file_path":{path}{input}}}}}"#
))
}
fn read(&self, session: &str) -> Reply {
self.file_event("PreToolUse", "Read", session, "")
}
fn read_done(&self, session: &str) -> Reply {
let done = self.file_event("PostToolUse", "Read", session, "");
assert_eq!(done.stdout, "", "a finished read is remembered silently");
done
}
fn bash_event(&self, event: &str, session: &str, command: &str) -> Reply {
let cwd = serde_json::Value::String(self.dir.display().to_string());
send(&format!(
r#"{{"hook_event_name":"{event}","tool_name":"Bash","cwd":{cwd},
"session_id":"{session}","permission_mode":"default",
"tool_input":{{"command":"{command}"}}}}"#
))
}
}
#[test]
fn a_file_read_twice_is_advised_and_an_edit_between_resets_it() {
let f = ReadFixture::new("reread-repo");
let read = |session: &str| f.read(session);
let first = read("rr-1");
assert_eq!(first.code, 0);
assert_eq!(first.stdout, "", "a first read is not commented on");
f.read_done("rr-1");
let second = read("rr-1");
assert!(
second.reason().contains("file-reread") && second.reason().contains("already read"),
"second read: {}",
second.stdout
);
assert_eq!(
second.decision(),
None,
"advise, not deny: {}",
second.stdout
);
assert_eq!(read("rr-2").stdout, "");
let edit = f.file_event(
"PreToolUse",
"Edit",
"rr-1",
r#","old_string":"x","new_string":"y""#,
);
assert_eq!(edit.stdout, "", "a write is remembered silently");
assert_eq!(read("rr-1").stdout, "", "a read after an edit is right");
f.read_done("rr-1");
let cat = f.bash_event("PreToolUse", "rr-1", "cat big.rs");
let said = cat.reason();
assert!(
said.contains("file-reread"),
"cat after Read: {}",
cat.stdout
);
assert!(
said.contains("whole-file-dump"),
"a 6 KB cat is a dump too: {}",
cat.stdout
);
}
#[test]
fn a_read_that_never_completed_is_not_remembered() {
let f = ReadFixture::new("reread-incomplete");
assert_eq!(
f.read("ri-1").stdout,
"",
"a first read is not commented on"
);
assert_eq!(
f.read("ri-1").stdout,
"",
"no PostToolUse came back, so the first Read is not on record"
);
let cat = f.bash_event("PreToolUse", "ri-2", "cat big.rs");
assert!(
!cat.reason().contains("file-reread"),
"nothing read yet: {}",
cat.stdout
);
let again = f.bash_event("PreToolUse", "ri-2", "cat big.rs");
assert!(
!again.reason().contains("file-reread"),
"the first cat never ran, so it is not a read: {}",
again.stdout
);
assert_eq!(
f.bash_event("PostToolUse", "ri-2", "cat big.rs").stdout,
"",
"a finished cat is remembered silently"
);
let third = f.bash_event("PreToolUse", "ri-2", "cat big.rs");
assert!(
third.reason().contains("file-reread"),
"now it has run once: {}",
third.stdout
);
}
#[test]
fn a_file_changed_behind_the_sessions_back_is_read_again_without_comment() {
let f = ReadFixture::new("reread-changed");
assert_eq!(f.read("rc-1").stdout, "");
f.read_done("rc-1");
assert!(
f.read("rc-1").reason().contains("file-reread"),
"unchanged, so the second read is advised against"
);
std::fs::write(&f.file, "y".repeat(6001)).expect("rewrite the file");
assert_eq!(
f.read("rc-1").stdout,
"",
"the file is not what the session read any more"
);
f.read_done("rc-1");
assert!(
f.read("rc-1").reason().contains("file-reread"),
"read again and unchanged since: advised again"
);
std::fs::remove_file(&f.file).expect("remove the file");
assert_eq!(
f.read("rc-1").stdout,
"",
"a file that is gone is not in context"
);
}
#[test]
fn a_file_read_while_empty_and_filled_by_a_background_task_is_read_again() {
let f = ReadFixture::new("reread-empty-then-written");
std::fs::write(&f.file, "").expect("an empty output file");
assert_eq!(f.read("bg-1").stdout, "");
f.read_done("bg-1");
assert!(
f.read("bg-1").reason().contains("file-reread"),
"still empty, still what the session saw: advised against"
);
let mut out = std::fs::OpenOptions::new()
.append(true)
.open(&f.file)
.expect("append to the output file");
std::io::Write::write_all(&mut out, b"21:07:41 state=success\n").expect("append");
drop(out);
assert_eq!(
f.read("bg-1").stdout,
"",
"the task wrote to it since: read again without comment"
);
}
#[test]
fn a_poll_is_judged_against_the_calls_timeout() {
let cwd = serde_json::Value::String(home().display().to_string());
let payload = |timeout: &str, cmd: &str| {
send(&format!(
r#"{{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":{cwd},
"session_id":"poll-1","permission_mode":"default",
"tool_input":{{"command":"{cmd}"{timeout}}}}}"#
))
};
let loop_ =
"for i in $(seq 1 36); do gh pr checks 1 | grep -q pending || break; sleep 15; done";
assert!(
payload("", loop_).reason().contains("foreground-poll"),
"nine minutes against a two-minute default"
);
assert_eq!(
payload(r#","timeout":600000"#, loop_).stdout,
"",
"nine minutes inside an explicit ten"
);
assert!(
payload(r#","timeout":600000"#, "while true; do sleep 5; done")
.reason()
.contains("foreground-poll"),
"unbounded is over any clock"
);
}
#[test]
fn a_hidden_pipeline_does_not_silence_the_rest_of_the_line() {
let dir = home();
let cwd = serde_json::Value::String(dir.display().to_string());
let cmd = serde_json::Value::String(
"git status -s | xargs git add && git commit -q -m x 2>&1 | tail -1".to_string(),
);
let reply = send(&format!(
r#"{{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":{cwd},
"session_id":"partial-1","permission_mode":"default",
"tool_input":{{"command":{cmd}}}}}"#
));
assert_eq!(reply.code, 0);
let said = reply.reason();
assert!(
said.contains("pipe-to-tail"),
"the readable pipeline was not judged: {}",
reply.stdout
);
assert_eq!(
reply.decision(),
None,
"a half-read command produced a refusal: {}",
reply.stdout
);
}
#[test]
fn a_wholly_unreadable_command_is_still_not_judged() {
let cwd = serde_json::Value::String(home().display().to_string());
for command in [
"git status --short | xargs git add",
"source ./setup.sh && git push origin main 2>&1 | tail -1",
] {
let cmd = serde_json::Value::String(command.to_string());
let reply = send(&format!(
r#"{{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":{cwd},
"session_id":"partial-2","permission_mode":"default",
"tool_input":{{"command":{cmd}}}}}"#
));
assert_eq!(reply.stdout, "", "{command} was judged: {}", reply.stdout);
}
}