pushkin 0.2.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Option B of the hook-matcher-gap charter, amended: close the WRITE
//! bypass **without** inverting S2d.
//!
//! S2d ruled the staged protected-surface notice advisory-only, and its
//! reasoning holds: the floor runs for humans too, a human commit of
//! `pushkin.toml` is routine, and a blanket deny would block the manifest's
//! own maintenance. Three committed tests pin that shape.
//!
//! What S2d could not do was tell the author apart. The event log can. When
//! an agent is denied a protected write, that deny is an append-only row
//! carrying the rule and the file. If the same path then turns up STAGED,
//! the write happened anyway — through `ed`, `git apply`, a shell redirect,
//! or any other surface `PreToolUse` never saw. That is not a guess about who
//! wrote it; it is a recorded attempt plus a changed tree.
//!
//! So the floor keeps the advisory as its default and escalates to a deny
//! only on that evidence. A human editing the manifest with no prior agent
//! deny sees exactly what S2d specified. The evidence window is bounded by
//! the last commit TOUCHING each staged path, not by `HEAD`'s time: an
//! unrelated commit must not clear a real bypass, while committing the file
//! itself is exactly the human taking ownership of it.

use assert_cmd::Command;
use std::fs;
use std::path::Path;
use std::process::Command as StdCommand;

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]
protected_paths = ["pushkin.toml", "schemas/**"]
"#;

fn git(root: &Path, args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
    StdCommand::new("git")
        .current_dir(root)
        .args(args)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()?;
    Ok(())
}

/// Seconds since the Unix epoch, for pinning a commit clock.
fn now_epoch() -> Result<i64, Box<dyn std::error::Error>> {
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)?
        .as_secs();
    Ok(i64::try_from(secs)?)
}

/// Commits the staged tree with both of git's clocks pinned to `epoch`.
///
/// The floor's evidence window is a per-path COMMIT TIME, and git records it
/// at second resolution, so a deny in the same second as the boundary commit
/// counts (`denied_since` breaks the tie toward blocking). A test that needs
/// its commit on a known side of a deny has to set the clock rather than race
/// it.
fn commit_at(root: &Path, epoch: i64) -> Result<(), Box<dyn std::error::Error>> {
    let stamp = format!("@{epoch} +0000");
    StdCommand::new("git")
        .current_dir(root)
        .args(["commit", "-qm", "human owns it", "--no-verify"])
        .env("GIT_AUTHOR_DATE", &stamp)
        .env("GIT_COMMITTER_DATE", &stamp)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()?;
    Ok(())
}

fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    let at = dir.path();
    git(at, &["init", "-q", "."])?;
    git(at, &["config", "user.email", "t@example.com"])?;
    git(at, &["config", "user.name", "t"])?;
    fs::write(at.join("pushkin.toml"), MANIFEST)?;
    fs::create_dir_all(at.join("schemas"))?;
    fs::write(at.join("schemas/user.json"), "{}\n")?;
    git(at, &["add", "-A"])?;
    git(at, &["commit", "-qm", "base"])?;
    Ok(dir)
}

/// Drives a real agent write attempt through the hook so the deny lands in
/// the event log exactly as production would record it — no hand-inserted
/// rows, or the test proves nothing about the real signal.
fn agent_write_denied(dir: &Path, path: &str) -> Result<(), Box<dyn std::error::Error>> {
    let payload = serde_json::json!({
        "session_id": "agent-1",
        "tool_name": "Write",
        "tool_input": { "file_path": path, "content": "// agent\n" },
    })
    .to_string();
    let out = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .write_stdin(payload)
        .args(["hook", "claude"])
        .output()?;
    let text = String::from_utf8_lossy(&out.stdout);
    assert!(
        text.contains("pushkin.protected_path"),
        "fixture precondition: the write path must deny {path}: {text}"
    );
    Ok(())
}

fn floor(dir: &Path, path: &str) -> Result<(Option<i32>, String), Box<dyn std::error::Error>> {
    git(dir, &["add", path])?;
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .args(["check", "--staged"])
        .output()?;
    Ok((
        output.status.code(),
        format!(
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ),
    ))
}

/// S2d, preserved exactly. No agent deny on record, so a staged protected
/// file is a human editing their own manifest: loud notice, exit 0.
#[test]
fn a_human_edit_with_no_agent_deny_still_only_advises() -> TestResult {
    let dir = repo()?;
    fs::write(
        dir.path().join("pushkin.toml"),
        format!("{MANIFEST}\n# human\n"),
    )?;

    let (code, output) = floor(dir.path(), "pushkin.toml")?;
    assert_eq!(
        code,
        Some(0),
        "S2d: the floor never blocks the manifest's own maintenance: {output}"
    );
    assert!(
        output.contains("protected surface staged"),
        "the S2d advisory still fires: {output}"
    );
    Ok(())
}

/// The bypass, caught. The agent was denied at `PreToolUse`, the file changed
/// anyway, and it is now staged — a recorded attempt plus a changed tree.
#[test]
fn a_denied_agent_write_that_reaches_the_index_is_blocked() -> TestResult {
    let dir = repo()?;
    agent_write_denied(dir.path(), "pushkin.toml")?;
    // The bypass itself: the write lands through a surface PreToolUse never saw.
    fs::write(
        dir.path().join("pushkin.toml"),
        format!("{MANIFEST}\n# shell\n"),
    )?;

    let (code, output) = floor(dir.path(), "pushkin.toml")?;
    assert!(
        output.contains("pushkin.protected_path"),
        "the deny must name the protected rule: {output}"
    );
    assert_eq!(
        code,
        Some(2),
        "a recorded deny plus a staged change is evidence, not a guess: {output}"
    );
    Ok(())
}

/// The evidence is per-path. A deny on one protected file must not block a
/// human's unrelated edit to another.
#[test]
fn the_evidence_does_not_spread_to_other_protected_paths() -> TestResult {
    let dir = repo()?;
    agent_write_denied(dir.path(), "pushkin.toml")?;
    fs::write(dir.path().join("schemas/user.json"), "{\"human\":1}\n")?;

    let (code, output) = floor(dir.path(), "schemas/user.json")?;
    assert_eq!(
        code,
        Some(0),
        "the deny was for pushkin.toml; schemas/user.json is a clean human edit: {output}"
    );
    Ok(())
}

/// Committing the file IS the resolution. The window opens at the last
/// commit touching this path, so once a human owns the change the deny falls
/// outside it and later edits pass.
///
/// This is not a stylistic choice. `RULE_PROTECTED_PATH` is never waivable
/// (`waivers.rs:179-183`, "the harness cannot be negotiated with"), so if
/// committing did not clear the evidence there would be no exit at all
/// except `--no-verify` — and `pushkin.toml` is the path an agent is denied
/// most often, so a human would hit that wall on routine manifest work.
#[test]
fn committing_the_file_resolves_the_evidence() -> TestResult {
    let dir = repo()?;
    agent_write_denied(dir.path(), "pushkin.toml")?;
    fs::write(
        dir.path().join("pushkin.toml"),
        format!("{MANIFEST}\n# one\n"),
    )?;
    git(dir.path(), &["add", "pushkin.toml"])?;
    // Two seconds past the deny, which has already happened, so the deny falls
    // in a STRICTLY EARLIER second and is resolved history. Without the pin
    // this races the second boundary: when the deny and this commit share a
    // second the deny still counts, the floor denies, and the test fails —
    // correct gate behavior reported as a broken test.
    commit_at(dir.path(), now_epoch()? + 2)?;

    fs::write(
        dir.path().join("pushkin.toml"),
        format!("{MANIFEST}\n# two\n"),
    )?;
    let (code, output) = floor(dir.path(), "pushkin.toml")?;
    assert_eq!(
        code,
        Some(0),
        "a human who committed the file has taken ownership; later edits are theirs: {output}"
    );
    Ok(())
}

/// The boundary is per-PATH, not `HEAD`-wide. An unrelated commit must not
/// silently clear a real bypass — that would make the check trivially
/// evadable by committing anything else first.
#[test]
fn an_unrelated_commit_does_not_clear_the_evidence() -> TestResult {
    let dir = repo()?;
    agent_write_denied(dir.path(), "pushkin.toml")?;
    fs::write(
        dir.path().join("pushkin.toml"),
        format!("{MANIFEST}\n# bypass\n"),
    )?;

    // Move HEAD with a commit that does not touch the protected path.
    fs::write(dir.path().join("README.md"), "unrelated\n")?;
    git(dir.path(), &["add", "README.md"])?;
    git(dir.path(), &["commit", "-qm", "unrelated", "--no-verify"])?;

    let (code, output) = floor(dir.path(), "pushkin.toml")?;
    assert_eq!(
        code,
        Some(2),
        "HEAD moved but pushkin.toml is still unowned: {output}"
    );
    Ok(())
}

/// The mechanism is irrelevant — that is the whole point of catching this at
/// the floor rather than by parsing a command string. `git apply` is one of
/// the three bypasses the charter names.
#[test]
fn the_floor_is_blind_to_the_write_mechanism() -> TestResult {
    let dir = repo()?;
    agent_write_denied(dir.path(), "schemas/user.json")?;
    let patch = "--- a/schemas/user.json\n+++ b/schemas/user.json\n\
                 @@ -1 +1 @@\n-{}\n+{\"x\":1}\n";
    fs::write(dir.path().join("p.patch"), patch)?;
    git(dir.path(), &["apply", "p.patch"])?;

    let (code, output) = floor(dir.path(), "schemas/user.json")?;
    assert_eq!(
        code,
        Some(2),
        "`git apply` is the same event as any other write here: {output}"
    );
    Ok(())
}

/// One event, one message. When the floor denies, the advisory that exists
/// only because it *could not* deny must stand down.
#[test]
fn the_deny_supersedes_the_advisory() -> TestResult {
    let dir = repo()?;
    agent_write_denied(dir.path(), "pushkin.toml")?;
    fs::write(
        dir.path().join("pushkin.toml"),
        format!("{MANIFEST}\n# x\n"),
    )?;

    let (_, output) = floor(dir.path(), "pushkin.toml")?;
    assert!(
        !output.contains("protected surface staged"),
        "the advisory and the deny must not both fire for one event: {output}"
    );
    Ok(())
}