pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 5 task 1 conformance: waivers + the decision log (spec §15 "waiver
//! decision log"; integration doc §7). `pushkin waive` appends a signed,
//! scoped, expiring record to `pushkin/waivers.toml`; the gate honors
//! unexpired matching waivers; every grant is an event; the waivers file
//! itself is gate surface (agents cannot waive); supersedes/contradicts
//! links and expiry feed doctor's stale-entry lint. Committed first,
//! read-only hereafter (charter N10).

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"]
"#;

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 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(dir: &tempfile::TempDir) -> Option<Command> {
    let mut cmd = Command::cargo_bin("pushkin").ok()?;
    cmd.current_dir(dir.path())
        .env("GIT_AUTHOR_NAME", "Waiver Tester")
        .env("GIT_AUTHOR_EMAIL", "waiver@example.com");
    Some(cmd)
}

/// Grants a waiver for RULE on `app/api/**` with any extra flags appended.
fn waive(dir: &tempfile::TempDir, extra: &[&str]) -> Option<std::process::Output> {
    let mut args = vec![
        "waive",
        RULE,
        "--path",
        "app/api/**",
        "--ttl",
        "2h",
        "--reason",
        "phase-5 waiver suite",
    ];
    args.extend_from_slice(extra);
    pushkin(dir)?.args(args).output().ok()
}

/// Deny reason from `hook claude` for a nonconforming write, or None when
/// the write was allowed (Claude-family allow = silence).
fn hook_deny_reason(dir: &tempfile::TempDir, file_path: &str) -> Option<String> {
    let payload = serde_json::json!({
        "session_id": "waiver-suite",
        "tool_name": "Write",
        "tool_input": { "file_path": file_path, "content": NONCONFORMING }
    })
    .to_string();
    let output = pushkin(dir)?
        .args(["hook", "claude"])
        .write_stdin(payload)
        .output()
        .ok()?;
    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    if stdout.trim().is_empty() {
        return None;
    }
    let json: serde_json::Value = serde_json::from_str(&stdout).ok()?;
    Some(
        json["hookSpecificOutput"]["permissionDecisionReason"]
            .as_str()?
            .to_owned(),
    )
}

fn waivers_text(dir: &tempfile::TempDir) -> Option<String> {
    fs::read_to_string(dir.path().join("pushkin/waivers.toml")).ok()
}

fn waiver_ids(dir: &tempfile::TempDir) -> Option<Vec<String>> {
    let text = waivers_text(dir)?;
    Some(
        text.lines()
            .filter_map(|line| line.trim().strip_prefix("id = \""))
            .map(|rest| rest.trim_end_matches('"').to_owned())
            .collect(),
    )
}

fn doctor_stdout(dir: &tempfile::TempDir) -> Option<String> {
    let output = pushkin(dir)?.arg("doctor").output().ok()?;
    Some(String::from_utf8_lossy(&output.stdout).into_owned())
}

#[test]
fn waive_appends_signed_scoped_record() {
    let dir = repo().unwrap();
    let output = waive(&dir, &[]).unwrap();
    assert!(
        output.status.success(),
        "waive must succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let text = waivers_text(&dir).expect("pushkin/waivers.toml must exist after waive");
    for needle in [
        RULE,
        "app/api/**",
        "phase-5 waiver suite",
        "Waiver Tester",
        "granted_at",
        "expires_at",
        "id = \"",
    ] {
        assert!(text.contains(needle), "record must carry {needle}: {text}");
    }
}

#[test]
fn unexpired_waiver_suppresses_matching_deny() {
    let dir = repo().unwrap();
    // Sanity: without a waiver this exact write is denied.
    let denied = hook_deny_reason(&dir, "app/api/users/route.ts");
    assert!(denied.is_some(), "precondition: write must deny unwaived");
    assert!(waive(&dir, &[]).unwrap().status.success());
    let after = hook_deny_reason(&dir, "app/api/users/route.ts");
    assert!(
        after.is_none(),
        "unexpired scoped waiver must suppress the deny: {after:?}"
    );
}

#[test]
fn waiver_outside_scope_does_not_suppress() {
    // Only a waiver scoped to app/api/orders/** exists; a write under
    // app/api/users/ must still deny.
    let dir = repo().unwrap();
    let out = pushkin(&dir)
        .unwrap()
        .args([
            "waive",
            RULE,
            "--path",
            "app/api/orders/**",
            "--ttl",
            "2h",
            "--reason",
            "narrow scope",
        ])
        .output()
        .unwrap();
    assert!(out.status.success());
    let reason = hook_deny_reason(&dir, "app/api/users/route.ts");
    assert!(
        reason.is_some(),
        "a waiver scoped to app/api/orders/** must not cover app/api/users/"
    );
}

#[test]
fn expired_waiver_does_not_suppress() {
    let dir = repo().unwrap();
    let out = pushkin(&dir)
        .unwrap()
        .args([
            "waive",
            RULE,
            "--path",
            "app/api/**",
            "--ttl",
            "0s",
            "--reason",
            "already expired",
        ])
        .output()
        .unwrap();
    assert!(out.status.success());
    std::thread::sleep(std::time::Duration::from_millis(1100));
    let reason = hook_deny_reason(&dir, "app/api/users/route.ts");
    assert!(
        reason.is_some(),
        "expired waiver must not suppress the deny"
    );
}

#[test]
fn waiver_grant_emits_event() {
    let dir = repo().unwrap();
    assert!(waive(&dir, &[]).unwrap().status.success());
    let conn = rusqlite::Connection::open(dir.path().join(".pushkin/events.db")).unwrap();
    let count: u64 = conn
        .query_row(
            "SELECT COUNT(*) FROM events WHERE rule = 'pushkin.waiver.granted'",
            [],
            |row| row.get(0),
        )
        .unwrap();
    assert_eq!(count, 1, "waiver grant must be an event on the log");
}

#[test]
fn agent_write_to_waivers_toml_denied() {
    let dir = repo().unwrap();
    assert!(waive(&dir, &[]).unwrap().status.success());
    let reason = hook_deny_reason(&dir, "pushkin/waivers.toml");
    let reason = reason.expect("agent write to pushkin/waivers.toml must be denied");
    assert!(
        reason.contains("pushkin.protected_path"),
        "denial must cite the protected-path rule: {reason}"
    );
}

#[test]
fn superseded_entry_flagged_by_stale_lint() {
    let dir = repo().unwrap();
    assert!(waive(&dir, &[]).unwrap().status.success());
    let first_id = waiver_ids(&dir).unwrap().first().cloned().unwrap();
    let out = waive(&dir, &["--supersedes", &first_id]).unwrap();
    assert!(
        out.status.success(),
        "waive --supersedes must succeed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = doctor_stdout(&dir).unwrap();
    assert!(
        stdout.contains("superseded") && stdout.contains(&first_id),
        "doctor must lint the superseded waiver by id: {stdout}"
    );
}

#[test]
fn contradicts_link_surfaces_in_doctor_output() {
    let dir = repo().unwrap();
    assert!(waive(&dir, &[]).unwrap().status.success());
    let first_id = waiver_ids(&dir).unwrap().first().cloned().unwrap();
    let out = waive(&dir, &["--contradicts", &first_id]).unwrap();
    assert!(
        out.status.success(),
        "waive --contradicts must succeed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = doctor_stdout(&dir).unwrap();
    assert!(
        stdout.contains("contradicts") && stdout.contains(&first_id),
        "doctor must surface the contradiction pair by id: {stdout}"
    );
}

#[test]
fn expired_entry_flagged_by_stale_lint() {
    let dir = repo().unwrap();
    let out = pushkin(&dir)
        .unwrap()
        .args([
            "waive",
            RULE,
            "--path",
            "app/api/**",
            "--ttl",
            "0s",
            "--reason",
            "expires immediately",
        ])
        .output()
        .unwrap();
    assert!(out.status.success());
    std::thread::sleep(std::time::Duration::from_millis(1100));
    let stdout = doctor_stdout(&dir).unwrap();
    assert!(
        stdout.contains("expired"),
        "doctor must lint expired waivers: {stdout}"
    );
}