use assert_cmd::Command;
use std::fs;
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]
suppression_comments = "deny"
protected_paths = ["pushkin.toml", ".claude/settings.json", "schemas/**"]
"#;
const NONCONFORMING: &str = "export async function POST(req: Request) {\n const body = await req.json();\n return Response.json({ name: body.name });\n}\n";
const CONFORMING: &str = "import { UserCreateSchema } from \"../../../contracts/user.zod\";\n\nexport async function POST(req: Request) {\n const body = UserCreateSchema.parse(await req.json());\n return Response.json(body);\n}\n";
const HANDLER_PATH: &str = "app/api/users/route.ts";
const RULE: &str = "contract.boundary.unvalidated_input";
fn repo() -> std::io::Result<tempfile::TempDir> {
let dir = tempfile::tempdir()?;
fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
Ok(dir)
}
fn pushkin() -> Result<Command, assert_cmd::cargo::CargoError> {
Command::cargo_bin("pushkin")
}
fn claude_payload(content: &str) -> String {
serde_json::json!({
"session_id": "conformance-session",
"tool_name": "Write",
"tool_input": { "file_path": HANDLER_PATH, "content": content }
})
.to_string()
}
fn codex_payload(content: &str) -> String {
serde_json::json!({
"session_id": "conformance-session",
"tool_name": "apply_patch",
"tool_input": { "file_path": HANDLER_PATH, "content": content }
})
.to_string()
}
fn auggie_payload(content: &str) -> String {
serde_json::json!({
"session_id": "conformance-session",
"tool_name": "save-file",
"tool_input": { "file_path": HANDLER_PATH, "content": content }
})
.to_string()
}
fn hermes_payload(content: &str) -> String {
serde_json::json!({
"session_id": "conformance-session",
"tool_name": "write_file",
"tool_input": { "path": HANDLER_PATH, "content": content }
})
.to_string()
}
fn opencode_payload(content: &str) -> String {
serde_json::json!({
"sessionID": "conformance-session",
"tool": "write",
"args": { "filePath": HANDLER_PATH, "content": content }
})
.to_string()
}
fn run_hook(agent: &str, payload: &str, dir: &tempfile::TempDir) -> (i32, String, String) {
let Ok(mut command) = pushkin() else {
return (-1, String::new(), "cargo_bin resolution failed".to_owned());
};
let Ok(output) = command
.current_dir(dir.path())
.args(["hook", agent])
.write_stdin(payload.to_owned())
.output()
else {
return (-1, String::new(), "spawn failed".to_owned());
};
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
#[test]
fn claude_deny_emits_permission_decision_json() {
let dir = repo().unwrap();
let (code, stdout, _) = run_hook("claude", &claude_payload(NONCONFORMING), &dir);
assert_eq!(code, 0, "deny is carried by JSON, not exit code");
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let hso = &json["hookSpecificOutput"];
assert_eq!(hso["hookEventName"], "PreToolUse");
assert_eq!(hso["permissionDecision"], "deny");
let reason = hso["permissionDecisionReason"].as_str().unwrap();
assert!(reason.contains(RULE));
assert!(
reason.contains("fix:"),
"envelope prose must carry the fix hint: {reason}"
);
}
#[test]
fn claude_allow_exits_zero_silent() {
let dir = repo().unwrap();
let (code, stdout, _) = run_hook("claude", &claude_payload(CONFORMING), &dir);
assert_eq!(code, 0);
assert_eq!(stdout.trim(), "", "allow is silent (addendum §4)");
}
#[test]
fn codex_deny_json_matches_claude_shape() {
let dir = repo().unwrap();
let (code, stdout, _) = run_hook("codex", &codex_payload(NONCONFORMING), &dir);
assert_eq!(code, 0);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny");
assert_eq!(json["decision"], "block");
}
#[test]
fn auggie_deny_json_only() {
let dir = repo().unwrap();
let (code, stdout, _) = run_hook("auggie", &auggie_payload(NONCONFORMING), &dir);
assert_eq!(
code, 0,
"Auggie is deny-JSON-only — exit 2 is not its contract"
);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny");
}
#[test]
fn hermes_deny_action_block_shape() {
let dir = repo().unwrap();
let (code, stdout, _) = run_hook("hermes", &hermes_payload(NONCONFORMING), &dir);
assert_eq!(code, 0);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(json["action"], "block");
assert!(json["message"].as_str().unwrap().contains(RULE));
}
#[test]
fn hermes_allow_empty_object() {
let dir = repo().unwrap();
let (code, stdout, _) = run_hook("hermes", &hermes_payload(CONFORMING), &dir);
assert_eq!(code, 0);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(json, serde_json::json!({}));
}
#[test]
fn opencode_verdict_json_for_plugin_throw() {
let dir = repo().unwrap();
let (code, stdout, _) = run_hook("opencode", &opencode_payload(NONCONFORMING), &dir);
assert_eq!(code, 0);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(json["decision"], "deny");
assert!(json["reason"].as_str().unwrap().contains(RULE));
let (_, allow_out, _) = run_hook("opencode", &opencode_payload(CONFORMING), &dir);
let allow: serde_json::Value = serde_json::from_str(&allow_out).unwrap();
assert_eq!(allow["decision"], "allow");
}
#[test]
fn unknown_agent_rejected_with_candidates() {
let dir = repo().unwrap();
let output = pushkin()
.unwrap()
.current_dir(dir.path())
.args(["hook", "cluade"])
.write_stdin(claude_payload(NONCONFORMING))
.output()
.unwrap();
assert_ne!(output.status.code(), Some(0));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("claude"),
"must suggest the near-miss candidate: {stderr}"
);
}
#[test]
fn same_violation_blocks_identically_across_all_five() {
let dir = repo().unwrap();
let cases: [(&str, String); 5] = [
("claude", claude_payload(NONCONFORMING)),
("codex", codex_payload(NONCONFORMING)),
("auggie", auggie_payload(NONCONFORMING)),
("hermes", hermes_payload(NONCONFORMING)),
("opencode", opencode_payload(NONCONFORMING)),
];
for (agent, payload) in &cases {
let (_, stdout, _) = run_hook(agent, payload, &dir);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|_| panic!("{agent}: verdict must be JSON, got: {stdout}"));
let prose = json["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.or_else(|| json["message"].as_str())
.or_else(|| json["reason"].as_str())
.unwrap_or_else(|| panic!("{agent}: no deny prose in {json}"));
assert!(prose.contains(RULE), "{agent}: rule id missing");
assert!(prose.contains(HANDLER_PATH), "{agent}: target file missing");
assert!(
prose.contains("UserCreateSchema"),
"{agent}: fix hint missing"
);
assert!(
prose.contains("contract 'user'"),
"{agent}: contract name missing"
);
}
}
#[test]
fn hook_decisions_are_logged_as_events() {
let dir = repo().unwrap();
run_hook("claude", &claude_payload(NONCONFORMING), &dir);
run_hook("hermes", &hermes_payload(CONFORMING), &dir);
assert!(
dir.path().join(".pushkin/events.db").exists(),
"every decision an event (N7)"
);
}