pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! F60 — a payload the normalizer cannot read must fail CLOSED when it names a
//! path under an unwaivable rule.
//!
//! COMMITTED RED. A NEW file per N10.
//!
//! **The decision this implements.** F48, F58 and F59 were three instances of
//! one shape: a WRITE the normalizer did not recognize became a payload it
//! declined to judge, and the fail-open branch did the work of a default. Each
//! fix taught the parser one more shape, and a fourth would have too. The
//! standing question — what should an unrecognized payload that plainly names a
//! gated path do? — was put to the human and answered: fail closed.
//!
//! **The bound, which is the whole design.** "Fail closed on anything
//! unrecognized" would deny every tool call the normalizer does not model —
//! searches, listings, task calls, everything — and make the gate unusable. So
//! the rule is scoped to the two rules this project declares UNWAIVABLE:
//! `protected_paths` and `read_only_paths`. An unreadable payload naming
//! neither still fails open, loudly and on the record, exactly as before.
//!
//! Deliberately NOT included: `retrieval_paths` (a READ rule — any payload
//! mentioning a source file would deny, which is intolerable noise) and mapped
//! contract globs (CONTENT rules, undecidable without content, and refusing
//! them here would state a reason that does not hold — the F58 lesson).
//!
//! **The scan is crude on purpose, like C′.** It walks every string in the
//! payload and every whitespace-separated token inside those strings, because
//! the whole premise is that we do NOT know the payload's shape — F58's delete
//! path lived in patch text, not in a field. A payload that merely MENTIONS a
//! protected path in prose will therefore be denied. That is accepted: it is
//! already a payload the gate could not judge, and the failure direction is an
//! over-refusal an agent can read and route around, not a silent write.

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]
suppression_comments = "deny"
protected_paths = ["pushkin.toml"]
read_only_paths = ["crates/**/tests/**"]
retrieval_paths = ["crates/**/*.rs"]
retrieval_tool = "mcp__codebase-retrieval__codebase-retrieval"
"#;

const PROTECTED: &str = "pushkin.toml";
const COMMITTED_TEST: &str = "crates/pushkin-cli/tests/committed_suite.rs";
const UNCOMMITTED_TEST: &str = "crates/pushkin-cli/tests/brand_new_suite.rs";
const SOURCE: &str = "crates/pushkin-cli/src/main.rs";
const MAPPED: &str = "app/api/users/route.ts";
const ORDINARY: &str = "docs/notes.md";

const RULE_PROTECTED: &str = "pushkin.protected_path";
const RULE_READ_ONLY: &str = "pushkin.read_only_path";
const FAIL_OPEN: &str = "failing open";

fn git(dir: &Path, args: &[&str]) -> TestResult {
    let status = StdCommand::new("git")
        .current_dir(dir)
        .args(args)
        .status()?;
    assert!(status.success(), "git {args:?} failed");
    Ok(())
}

fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join(PROTECTED), MANIFEST)?;
    fs::create_dir_all(dir.path().join("contracts"))?;
    fs::write(
        dir.path().join("contracts/user.zod.ts"),
        "export const user = 1;\n",
    )?;
    fs::create_dir_all(dir.path().join("app/api/users"))?;
    fs::write(dir.path().join(MAPPED), "export const handler = 1;\n")?;
    fs::create_dir_all(dir.path().join("crates/pushkin-cli/tests"))?;
    fs::write(dir.path().join(COMMITTED_TEST), "// committed suite\n")?;
    fs::create_dir_all(dir.path().join("crates/pushkin-cli/src"))?;
    fs::write(dir.path().join(SOURCE), "fn main() {}\n")?;
    fs::create_dir_all(dir.path().join("docs"))?;
    fs::write(dir.path().join(ORDINARY), "notes\n")?;
    git(dir.path(), &["init", "-q", "."])?;
    git(dir.path(), &["add", "-A"])?;
    git(
        dir.path(),
        &[
            "-c",
            "user.name=F60 Suite",
            "-c",
            "user.email=f60@test",
            "commit",
            "-qm",
            "fixture",
        ],
    )?;
    // AFTER the commit: N10's authoring window is for files not yet in HEAD.
    fs::write(dir.path().join(UNCOMMITTED_TEST), "// brand new\n")?;
    Ok(dir)
}

/// A payload no adapter models: a tool name nothing matches, and its target in
/// a field the normalizer never reads.
fn unreadable(target: &str) -> String {
    serde_json::json!({
        "session_id": "f60",
        "hook_event_name": "PreToolUse",
        "tool_name": "SomeToolPushkinHasNeverHeardOf",
        "tool_input": { "target": target },
    })
    .to_string()
}

fn hook(dir: &Path, payload: &str) -> Result<String, Box<dyn std::error::Error>> {
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .write_stdin(payload.to_owned())
        .env("PUSHKIN_DAEMON", "off")
        .args(["hook", "claude"])
        .output()?;
    Ok(format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    ))
}

fn check(dir: &Path, payload: &str) -> Result<(Option<i32>, String), Box<dyn std::error::Error>> {
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .write_stdin(payload.to_owned())
        .arg("check")
        .output()?;
    Ok((
        output.status.code(),
        format!(
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ),
    ))
}

// ---------------------------------------------------------------------------
// Fail closed — the two unwaivable rules
// ---------------------------------------------------------------------------

#[test]
fn an_unreadable_payload_naming_a_protected_path_is_denied() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &unreadable(PROTECTED))?;

    assert!(
        output.contains(RULE_PROTECTED),
        "unreadable AND naming the gate's own surface must deny: {output}"
    );
    assert!(
        !output.contains(FAIL_OPEN),
        "and must not also announce a fail-open: {output}"
    );
    Ok(())
}

#[test]
fn an_unreadable_payload_naming_a_committed_test_is_denied() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &unreadable(COMMITTED_TEST))?;

    assert!(output.contains(RULE_READ_ONLY), "{output}");
    Ok(())
}

/// The path may be anywhere. F58's delete lived in patch TEXT, not a field, so
/// a scan that only looked at known keys would have missed exactly the case
/// that motivated this rule.
#[test]
fn a_path_buried_in_free_text_is_found() -> TestResult {
    let dir = repo()?;
    let payload = serde_json::json!({
        "session_id": "f60",
        "hook_event_name": "PreToolUse",
        "tool_name": "MysteryPatcher",
        "tool_input": {
            "body": format!("*** Begin Patch\n*** Delete File: {PROTECTED}\n*** End Patch\n"),
        },
    })
    .to_string();

    let output = hook(dir.path(), &payload)?;

    assert!(output.contains(RULE_PROTECTED), "{output}");
    Ok(())
}

#[test]
fn a_path_nested_deep_in_the_payload_is_found() -> TestResult {
    let dir = repo()?;
    let payload = serde_json::json!({
        "session_id": "f60",
        "hook_event_name": "PreToolUse",
        "tool_name": "MysteryNester",
        "tool_input": { "a": { "b": [{ "c": PROTECTED }] } },
    })
    .to_string();

    let output = hook(dir.path(), &payload)?;

    assert!(output.contains(RULE_PROTECTED), "{output}");
    Ok(())
}

/// opencode hands its tools ABSOLUTE paths, so the absolute spelling of a gated
/// path must be recognized too — otherwise the rule is trivially evaded by the
/// spelling one whole family already uses.
#[test]
fn an_absolute_spelling_of_a_gated_path_is_found() -> TestResult {
    let dir = repo()?;
    let absolute = dir
        .path()
        .canonicalize()?
        .join(PROTECTED)
        .to_string_lossy()
        .into_owned();

    let output = hook(dir.path(), &unreadable(&absolute))?;

    assert!(output.contains(RULE_PROTECTED), "{output}");
    Ok(())
}

// ---------------------------------------------------------------------------
// The bound — everything else still fails OPEN
// ---------------------------------------------------------------------------

/// The escape hatch has to survive. A gate that denies every payload it cannot
/// model denies most of what an agent does.
#[test]
fn an_unreadable_payload_naming_nothing_gated_still_fails_open() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &unreadable(ORDINARY))?;

    assert!(
        output.contains(FAIL_OPEN),
        "an unmodelled tool touching nothing gated must still pass: {output}"
    );
    Ok(())
}

/// `retrieval_paths` is a READ rule and is deliberately OUT of scope: any
/// payload mentioning a source file would otherwise deny.
#[test]
fn a_retrieval_gated_source_file_does_not_fail_closed() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &unreadable(SOURCE))?;

    assert!(
        output.contains(FAIL_OPEN),
        "read gating must not be enforced through the unreadable-payload rule: {output}"
    );
    Ok(())
}

/// A mapped contract path is a CONTENT rule, undecidable without content.
/// Denying here would state a reason that does not hold — the F58 lesson.
#[test]
fn a_mapped_contract_path_does_not_fail_closed() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &unreadable(MAPPED))?;

    assert!(
        output.contains(FAIL_OPEN),
        "content rules cannot be decided from an unreadable payload: {output}"
    );
    Ok(())
}

/// N10's authoring window survives: `read_only_paths` covers files COMMITTED in
/// HEAD, and a brand-new suite is not one.
#[test]
fn an_uncommitted_file_under_a_read_only_glob_still_fails_open() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &unreadable(UNCOMMITTED_TEST))?;

    assert!(
        output.contains(FAIL_OPEN),
        "N10 gates committed suites; a new file is the authoring window: {output}"
    );
    Ok(())
}

/// Not JSON at all: there is nothing to scan, so the pre-existing behavior
/// stands.
#[test]
fn a_payload_that_is_not_even_json_still_fails_open() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), "this is not json at all")?;

    assert!(output.contains(FAIL_OPEN), "{output}");
    Ok(())
}

// ---------------------------------------------------------------------------
// Both surfaces, and no regression on recognized payloads
// ---------------------------------------------------------------------------

/// `check` and `hook` are parallel implementations of one contract.
#[test]
fn the_check_verb_fails_closed_on_the_same_payload() -> TestResult {
    let dir = repo()?;

    let (code, output) = check(dir.path(), &unreadable(PROTECTED))?;

    assert_eq!(code, Some(2), "check signals a block with exit 2: {output}");
    assert!(output.contains(RULE_PROTECTED), "{output}");
    Ok(())
}

#[test]
fn the_check_verb_still_fails_open_on_an_ungated_target() -> TestResult {
    let dir = repo()?;

    let (code, output) = check(dir.path(), &unreadable(ORDINARY))?;

    assert_eq!(code, Some(0), "{output}");
    Ok(())
}

/// Regression: a payload the normalizer DOES read is untouched by any of this.
#[test]
fn a_recognized_write_is_unaffected() -> TestResult {
    let dir = repo()?;
    let payload = serde_json::json!({
        "session_id": "f60",
        "hook_event_name": "PreToolUse",
        "tool_name": "Write",
        "tool_input": { "file_path": ORDINARY, "content": "still notes\n" },
    })
    .to_string();

    let output = hook(dir.path(), &payload)?;

    assert!(
        !output.contains(RULE_PROTECTED) && !output.contains(FAIL_OPEN),
        "a recognized, ungated write is allowed as before: {output}"
    );
    Ok(())
}

/// And a recognized write to the protected path keeps denying under its own
/// rule, not through the new one — the two must not be confused in the record.
#[test]
fn a_recognized_protected_write_still_denies_normally() -> TestResult {
    let dir = repo()?;
    let payload = serde_json::json!({
        "session_id": "f60",
        "hook_event_name": "PreToolUse",
        "tool_name": "Write",
        "tool_input": { "file_path": PROTECTED, "content": "tampered\n" },
    })
    .to_string();

    let output = hook(dir.path(), &payload)?;

    assert!(output.contains(RULE_PROTECTED), "{output}");
    assert!(!output.contains(FAIL_OPEN), "{output}");
    Ok(())
}