pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 2 conformance: `pushkin hook <agent>` — one violation, five
//! agent-native verdict encodings (addendum §4 matrix). Committed first,
//! read-only hereafter.

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");
    // Legacy field also present for older Codex builds (addendum §3.2).
    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() {
    // The conformance spine (spec §6.1 / addendum §4): identical rule id and
    // envelope core in every agent's deny prose.
    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)"
    );
}