pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! SPIKE (throwaway, branch `spike/read-contract`) — the read contract.
//!
//! `[gates] retrieval_paths` globs name code an agent must reach through a
//! retrieval tool rather than a raw whole-file read. The predicate is the
//! read's SHAPE, not the path alone: a `Read` carrying `offset`/`limit` is
//! a narrow, deliberate range read and passes; an unbounded `Read` of a
//! gated file is the context-dumping pattern the gate exists to stop.
//!
//! The shape predicate is what keeps the gate compatible with the host's
//! read-before-edit requirement (Claude Code refuses `Edit` on a file it
//! has not `Read`): the agent can always satisfy that gate with a bounded
//! read of the range it is about to change.
//!
//! `retrieval_tool` is a manifest string, never a hard-coded vendor: the
//! deny prose names whatever the repo declares, so Pushkin's own index can
//! replace an external one without touching this gate.

use assert_cmd::Command;
use std::fs;
use std::path::Path;

type TestResult = Result<(), Box<dyn std::error::Error>>;

/// Gates every Rust source under `crates/**` behind the retrieval tool,
/// while `app/api/**` stays a plain contract mapping — so the suite can
/// prove the read gate is independent of the boundary-validation gate.
const MANIFEST_WITH_RETRIEVAL: &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"]
retrieval_paths = ["crates/**/*.rs"]
retrieval_tool = "mcp__codebase-retrieval__codebase-retrieval"
"#;

const GATED_SOURCE: &str = "crates/pushkin-core/src/pipeline.rs";
const UNGATED_SOURCE: &str = "docs/notes.md";

fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST_WITH_RETRIEVAL)?;
    fs::create_dir_all(dir.path().join("crates/pushkin-core/src"))?;
    fs::write(dir.path().join(GATED_SOURCE), "// gated source\n")?;
    fs::create_dir_all(dir.path().join("docs"))?;
    fs::write(dir.path().join(UNGATED_SOURCE), "notes\n")?;
    Ok(dir)
}

/// A read attempt through the hook path. `range` is spliced into
/// `tool_input` so one helper covers both the unbounded and bounded shapes.
fn check_read(
    dir: &Path,
    file_path: &str,
    range: &str,
) -> Result<(Option<i32>, String), Box<dyn std::error::Error>> {
    let payload =
        format!(r#"{{"tool_name":"Read","tool_input":{{"file_path":"{file_path}"{range}}}}}"#);
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .write_stdin(payload)
        .args(["hook", "claude"])
        .output()?;
    Ok((
        output.status.code(),
        format!(
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ),
    ))
}

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

    let (_, output) = check_read(dir.path(), GATED_SOURCE, "")?;
    assert!(
        output.contains("pushkin.retrieval.raw_read"),
        "an unbounded read of a gated path names its own rule: {output}"
    );
    assert!(
        output.contains("deny"),
        "the Claude dialect carries a deny verdict: {output}"
    );
    Ok(())
}

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

    let (_, output) = check_read(dir.path(), GATED_SOURCE, "")?;
    assert!(
        output.contains("mcp__codebase-retrieval__codebase-retrieval"),
        "the fix hint names the tool the MANIFEST declares, not a \
         hard-coded vendor — this is what lets Pushkin's own index take \
         the slot later: {output}"
    );
    Ok(())
}

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

    let (_, output) = check_read(dir.path(), GATED_SOURCE, r#","offset":40,"limit":30"#)?;
    assert!(
        !output.contains("pushkin.retrieval.raw_read"),
        "a narrow range read is the deliberate shape the gate permits — \
         it is also how an agent satisfies the host's read-before-edit \
         requirement: {output}"
    );
    Ok(())
}

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

    let (_, output) = check_read(dir.path(), UNGATED_SOURCE, "")?;
    assert!(
        !output.contains("pushkin.retrieval.raw_read"),
        "the gate is scoped to its globs; everything else reads freely: \
         {output}"
    );
    Ok(())
}

/// D1 — the escalation ladder is write-shaped. A denied READ must not be
/// narrated as a blocked write at any rung, or the agent is told to fix
/// something it never attempted.
#[test]
fn the_ladder_narrates_a_denied_read_as_a_read() -> TestResult {
    let dir = repo()?;

    let (_, first) = check_read(dir.path(), GATED_SOURCE, "")?;
    assert!(
        !first.contains("write blocked"),
        "rung 1 must not call a read a write: {first}"
    );
    assert!(
        first.contains("read blocked"),
        "rung 1 names the action the agent actually took: {first}"
    );

    // Same session + same file: the log counts prior hits, so these climb.
    let (_, second) = check_read(dir.path(), GATED_SOURCE, "")?;
    assert!(
        !second.contains("same write"),
        "rung 2 must not tell the agent to stop retrying a WRITE: {second}"
    );
    let (_, third) = check_read(dir.path(), GATED_SOURCE, "")?;
    assert!(
        third.contains("STOP") && !third.contains("This write"),
        "rung 3 keeps the STOP shape without mislabeling the action: {third}"
    );
    Ok(())
}

/// D2 — waivability is a decision, not an inheritance. `protected_path` is
/// an integrity boundary; the read contract is a routing preference the
/// agent can always satisfy itself by narrowing the read. Offering a
/// human-only waiver on it invites escalation where self-service exists.
#[test]
fn the_read_deny_does_not_dangle_a_human_waiver() -> TestResult {
    let dir = repo()?;

    let (_, output) = check_read(dir.path(), GATED_SOURCE, "")?;
    assert!(
        !output.contains("pushkin waive"),
        "the agent can comply on its own — pointing at a human waiver is \
         the wrong exit: {output}"
    );
    Ok(())
}

/// D3 — portability. Each adapter names its read tool differently, so the
/// gate is asserted per dialect: Claude `Read`, Auggie `view`, opencode
/// `read`. Codex and Hermes have no read-tool name evidenced in this repo;
/// they are deliberately NOT asserted rather than guessed at — see the
/// spike report.
#[test]
fn the_read_gate_holds_across_the_evidenced_dialects() -> TestResult {
    let dir = repo()?;

    let claude = format!(
        r#"{{"session_id":"p1","tool_name":"Read","tool_input":{{"file_path":"{GATED_SOURCE}"}}}}"#
    );
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir.path())
        .write_stdin(claude)
        .args(["hook", "claude"])
        .output()?;
    let text = String::from_utf8_lossy(&output.stdout).to_string();
    assert!(
        text.contains("pushkin.retrieval.raw_read"),
        "claude `Read` must gate: {text}"
    );

    let auggie = format!(
        r#"{{"conversation_id":"p1","tool_name":"view","tool_input":{{"path":"{GATED_SOURCE}"}}}}"#
    );
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir.path())
        .write_stdin(auggie)
        .args(["hook", "auggie"])
        .output()?;
    let text = String::from_utf8_lossy(&output.stdout).to_string();
    assert!(
        text.contains("pushkin.retrieval.raw_read"),
        "auggie `view` must gate alike: {text}"
    );

    let opencode =
        format!(r#"{{"sessionID":"p1","tool":"read","args":{{"filePath":"{GATED_SOURCE}"}}}}"#);
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir.path())
        .write_stdin(opencode)
        .args(["hook", "opencode"])
        .output()?;
    let text = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        text.contains("pushkin.retrieval.raw_read"),
        "opencode's documented read shape must gate alike: {text}"
    );
    Ok(())
}

#[test]
fn a_write_payload_is_unaffected_by_the_read_gate() -> TestResult {
    let dir = repo()?;
    let payload = format!(
        r#"{{"tool_name":"Write","tool_input":{{"file_path":"{GATED_SOURCE}","content":"// edit"}}}}"#
    );
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir.path())
        .write_stdin(payload)
        .args(["hook", "claude"])
        .output()?;
    let text = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        !text.contains("pushkin.retrieval.raw_read"),
        "the read gate must not leak onto the write path — a write to a \
         gated path is a write, judged by the write rules: {text}"
    );
    Ok(())
}