use assert_cmd::Command;
use serde_json::Value;
use std::fs;
use std::path::Path;
type TestResult = Result<(), Box<dyn std::error::Error>>;
const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"
[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod"]
[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"
[gates]
"#;
const NONCONFORMING: &str = "export async function POST(req: Request) {\n\
const body = await req.json();\n\
return Response.json({ name: body.name });\n\
}\n";
fn repo_with_violation() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
let api = dir.path().join("app/api/users");
fs::create_dir_all(&api)?;
fs::write(api.join("route.ts"), NONCONFORMING)?;
Ok(dir)
}
fn clean_repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
Ok(dir)
}
struct Run {
code: i32,
stdout: String,
}
fn hook(dir: &Path, agent: &str, payload: &str) -> Result<Run, Box<dyn std::error::Error>> {
let output = Command::cargo_bin("pushkin")?
.current_dir(dir)
.env("PUSHKIN_DAEMON", "off")
.args(["hook", agent])
.write_stdin(payload.to_owned())
.output()?;
Ok(Run {
code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
})
}
const STOP_PAYLOAD: &str = r#"{"session_id":"s-stop","stop_hook_active":true}"#;
fn json(run: &Run) -> Value {
let parsed = serde_json::from_str::<Value>(&run.stdout);
assert!(parsed.is_ok(), "stdout must be JSON; got:\n{}", run.stdout);
parsed.unwrap_or(Value::Null)
}
#[test]
fn claude_stop_denial_uses_the_top_level_decision_shape() -> TestResult {
let dir = repo_with_violation()?;
let run = hook(dir.path(), "claude", STOP_PAYLOAD)?;
let value = json(&run);
assert_eq!(
value.get("decision").and_then(Value::as_str),
Some("block"),
"Stop blocks with a TOP-LEVEL decision field; got:\n{}",
run.stdout
);
assert!(
value.get("reason").and_then(Value::as_str).is_some(),
"the block must carry a reason; got:\n{}",
run.stdout
);
Ok(())
}
#[test]
fn claude_stop_denial_carries_the_violation_in_its_reason() -> TestResult {
let dir = repo_with_violation()?;
let run = hook(dir.path(), "claude", STOP_PAYLOAD)?;
let value = json(&run);
let reason = value
.get("reason")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
reason.contains("route.ts"),
"the reason must name the offending file; got:\n{reason}"
);
assert!(
reason.contains("contract.boundary.unvalidated_input"),
"the reason must name the rule; got:\n{reason}"
);
Ok(())
}
#[test]
fn claude_stop_denial_never_emits_the_pretooluse_shape() -> TestResult {
let dir = repo_with_violation()?;
let run = hook(dir.path(), "claude", STOP_PAYLOAD)?;
let value = json(&run);
assert!(
value.get("hookSpecificOutput").is_none(),
"Stop must not carry hookSpecificOutput; got:\n{}",
run.stdout
);
assert!(
!run.stdout.contains("PreToolUse"),
"a Stop verdict must never claim to be a PreToolUse one; got:\n{}",
run.stdout
);
Ok(())
}
#[test]
fn claude_stop_denial_exits_zero() -> TestResult {
let dir = repo_with_violation()?;
let run = hook(dir.path(), "claude", STOP_PAYLOAD)?;
assert_eq!(run.code, 0, "got stdout:\n{}", run.stdout);
Ok(())
}
#[test]
fn a_clean_repo_at_stop_still_allows() -> TestResult {
let dir = clean_repo()?;
let run = hook(dir.path(), "claude", STOP_PAYLOAD)?;
assert_eq!(run.code, 0, "got stdout:\n{}", run.stdout);
assert!(
run.stdout.trim().is_empty(),
"silence is allow for the Claude family; got:\n{}",
run.stdout
);
Ok(())
}
#[test]
fn codex_stop_denial_uses_the_top_level_decision_shape() -> TestResult {
let dir = repo_with_violation()?;
let run = hook(dir.path(), "codex", STOP_PAYLOAD)?;
let value = json(&run);
assert_eq!(
value.get("decision").and_then(Value::as_str),
Some("block"),
"got:\n{}",
run.stdout
);
assert!(
value.get("reason").and_then(Value::as_str).is_some(),
"got:\n{}",
run.stdout
);
Ok(())
}
#[test]
fn codex_stop_denial_omits_hook_specific_output_entirely() -> TestResult {
let dir = repo_with_violation()?;
let run = hook(dir.path(), "codex", STOP_PAYLOAD)?;
assert!(
json(&run).get("hookSpecificOutput").is_none(),
"Codex's Stop schema rejects hookSpecificOutput; got:\n{}",
run.stdout
);
Ok(())
}
#[test]
fn a_pretooluse_deny_still_carries_the_permission_decision_shape() -> TestResult {
let dir = clean_repo()?;
let payload = r#"{"session_id":"s-write","tool_name":"Write","tool_input":{"file_path":"app/api/users/route.ts","content":"export async function POST(req: Request) {\n const body = await req.json();\n return Response.json({ name: body.name });\n}\n"}}"#;
let run = hook(dir.path(), "claude", payload)?;
let value = json(&run);
let specific = value
.get("hookSpecificOutput")
.unwrap_or_else(|| panic!("PreToolUse keeps its shape; got:\n{}", run.stdout));
assert_eq!(
specific.get("hookEventName").and_then(Value::as_str),
Some("PreToolUse"),
"got:\n{}",
run.stdout
);
assert_eq!(
specific.get("permissionDecision").and_then(Value::as_str),
Some("deny"),
"got:\n{}",
run.stdout
);
assert!(
value.get("decision").is_none(),
"a PreToolUse deny must not grow a top-level Stop decision; got:\n{}",
run.stdout
);
Ok(())
}
#[test]
fn check_and_hook_agree_on_the_stop_verdict() -> TestResult {
let dir = repo_with_violation()?;
let check = Command::cargo_bin("pushkin")?
.current_dir(dir.path())
.env("PUSHKIN_DAEMON", "off")
.args(["check", "--json"])
.write_stdin(STOP_PAYLOAD.to_owned())
.output()?;
let check_json: Value = serde_json::from_slice(&check.stdout)?;
assert_eq!(
check_json.get("decision").and_then(Value::as_str),
Some("block"),
"check must block on this fixture; got:\n{}",
String::from_utf8_lossy(&check.stdout)
);
let run = hook(dir.path(), "claude", STOP_PAYLOAD)?;
assert_eq!(
json(&run).get("decision").and_then(Value::as_str),
Some("block"),
"hook must reach the same decision as check; got:\n{}",
run.stdout
);
Ok(())
}
#[test]
fn check_and_hook_agree_on_a_clean_stop_too() -> TestResult {
let dir = clean_repo()?;
let check = Command::cargo_bin("pushkin")?
.current_dir(dir.path())
.env("PUSHKIN_DAEMON", "off")
.args(["check", "--json"])
.write_stdin(STOP_PAYLOAD.to_owned())
.output()?;
let check_json: Value = serde_json::from_slice(&check.stdout)?;
assert_eq!(
check_json.get("decision").and_then(Value::as_str),
Some("allow"),
"got:\n{}",
String::from_utf8_lossy(&check.stdout)
);
let run = hook(dir.path(), "claude", STOP_PAYLOAD)?;
assert!(
run.stdout.trim().is_empty(),
"hook must agree with check's allow — silence, not a block; got:\n{}",
run.stdout
);
Ok(())
}
const AUGGIE_STOP_PAYLOAD: &str = r#"{"hook_event_name":"Stop","conversation_id":"conv-f70","workspace_roots":["/tmp"],"agent_stop_cause":"end_turn"}"#;
#[test]
fn auggie_stop_denial_uses_auggies_nested_stop_dialect() -> TestResult {
let dir = repo_with_violation()?;
let run = hook(dir.path(), "auggie", AUGGIE_STOP_PAYLOAD)?;
let value = json(&run);
let specific = value.get("hookSpecificOutput").unwrap_or_else(|| {
panic!(
"Auggie's Stop dialect nests under hookSpecificOutput; got:\n{}",
run.stdout
)
});
assert_eq!(
specific.get("hookEventName").and_then(Value::as_str),
Some("Stop"),
"the nested event name must be Stop, not PreToolUse; got:\n{}",
run.stdout
);
assert_eq!(
specific.get("decision").and_then(Value::as_str),
Some("block"),
"Auggie blocks with decision/reason, not permissionDecision; got:\n{}",
run.stdout
);
assert!(
specific.get("reason").and_then(Value::as_str).is_some(),
"the block must carry a reason; got:\n{}",
run.stdout
);
Ok(())
}
#[test]
fn auggie_stop_denial_carries_the_violation_and_exits_zero() -> TestResult {
let dir = repo_with_violation()?;
let run = hook(dir.path(), "auggie", AUGGIE_STOP_PAYLOAD)?;
assert_eq!(run.code, 0, "got stdout:\n{}", run.stdout);
let value = json(&run);
let reason = value
.get("hookSpecificOutput")
.and_then(|s| s.get("reason"))
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
reason.contains("route.ts") && reason.contains("contract.boundary.unvalidated_input"),
"the reason must name the file and the rule; got:\n{reason}"
);
Ok(())
}
#[test]
fn a_clean_repo_at_auggie_stop_still_allows() -> TestResult {
let dir = clean_repo()?;
let run = hook(dir.path(), "auggie", AUGGIE_STOP_PAYLOAD)?;
assert_eq!(run.code, 0, "got stdout:\n{}", run.stdout);
assert!(
run.stdout.trim().is_empty(),
"a clean sweep must not block; got:\n{}",
run.stdout
);
Ok(())
}
#[test]
fn an_auggie_payload_without_tool_input_or_a_stop_marker_is_still_malformed() -> TestResult {
let dir = clean_repo()?;
let run = hook(
dir.path(),
"auggie",
r#"{"conversation_id":"conv-junk","workspace_roots":["/tmp"]}"#,
)?;
assert!(
!run.stdout.contains("\"hookEventName\":\"Stop\""),
"a payload with no Stop marker must not be treated as a Stop; got:\n{}",
run.stdout
);
Ok(())
}
#[test]
fn an_auggie_write_payload_is_unaffected_by_the_stop_branch() -> TestResult {
let dir = clean_repo()?;
let payload = r#"{"hook_event_name":"PreToolUse","conversation_id":"conv-w","tool_name":"save-file","tool_input":{"path":"app/api/users/route.ts","file_content":"export async function POST(req: Request) {\n const body = await req.json();\n return Response.json({ name: body.name });\n}\n"}}"#;
let run = hook(dir.path(), "auggie", payload)?;
let value = json(&run);
let specific = value
.get("hookSpecificOutput")
.unwrap_or_else(|| panic!("a write deny keeps its shape; got:\n{}", run.stdout));
assert_eq!(
specific.get("hookEventName").and_then(Value::as_str),
Some("PreToolUse"),
"write traffic must still render as PreToolUse; got:\n{}",
run.stdout
);
Ok(())
}