pushkin 0.2.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! F57 — C′ denies a WRITE it mistook for a read: `cat > new.rs` is classified
//! as a read of the file it is about to CREATE.
//!
//! COMMITTED RED. A NEW file per N10.
//!
//! **Observed live, 2026-08-18**, while authoring the auggie arm. Creating a new
//! test file with `cat > crates/pushkin-cli/tests/auggie_content_synthesis.rs
//! <<'EOF'` was denied under `pushkin.retrieval.raw_read`, naming the file being
//! created as a gated read target. Nothing was read: `>` sends the heredoc INTO
//! that path.
//!
//! **Why verb-scoping alone cannot fix it.** F45 (HM-9) closed the same shape
//! for `git add`, `git checkout` and `rm` by scoping the scan to verbs that
//! actually deliver content. `cat` genuinely IS such a verb, so the verb is not
//! the problem — the ROLE of the token is. The fix therefore reads one token of
//! shell syntax (`>`, `>>`, and their fd-numbered forms) and treats what follows
//! as a destination rather than a source.
//!
//! **That widens the charter's non-goal 1 by exactly that much and no more.**
//! Positional classification stays positional; this is not a grammar. Input
//! redirection `<` is deliberately untouched, because that one IS a read, and a
//! plain path argument is still a read wherever it appears.
//!
//! **The failure direction is unchanged.** C′ accepts a missed deny and refuses
//! to accept a wrongly issued one. This finding is a wrongly issued deny — worse
//! still, one whose remedy ("ask codebase-retrieval for the code you need")
//! cannot be complied with for a file that does not exist yet.

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

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"]
retrieval_paths = ["crates/**/*.rs"]
retrieval_tool = "mcp__codebase-retrieval__codebase-retrieval"
"#;

/// Exists on disk and is retrieval-gated: reading it must still be denied.
const GATED: &str = "crates/pushkin-cli/src/existing.rs";
/// Does not exist: the file a redirect would create.
const NEW_GATED: &str = "crates/pushkin-cli/tests/brand_new.rs";
const UNGATED: &str = "docs/notes.md";

const RULE_RAW_READ: &str = "pushkin.retrieval.raw_read";

fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), 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("crates/pushkin-cli/src"))?;
    fs::write(dir.path().join(GATED), "fn existing() {}\n")?;
    fs::create_dir_all(dir.path().join("crates/pushkin-cli/tests"))?;
    fs::create_dir_all(dir.path().join("docs"))?;
    fs::write(dir.path().join(UNGATED), "notes\n")?;
    Ok(dir)
}

fn bash(command: &str) -> String {
    serde_json::json!({
        "session_id": "f57",
        "hook_event_name": "PreToolUse",
        "tool_name": "Bash",
        "tool_input": { "command": command },
    })
    .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)
    ))
}

// ---------------------------------------------------------------------------
// A redirect DESTINATION is a write target, never a read
// ---------------------------------------------------------------------------

/// The exact command that was denied live.
#[test]
fn a_redirect_creating_a_gated_file_is_not_a_read() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &bash(&format!("cat > {NEW_GATED}")))?;

    assert!(
        !output.contains(RULE_RAW_READ),
        "`>` writes to this path; nothing is read from it: {output}"
    );
    Ok(())
}

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

    let output = hook(dir.path(), &bash(&format!("cat >> {NEW_GATED}")))?;

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

/// The spelling with no space between operator and path.
#[test]
fn a_redirect_attached_to_its_path_is_not_a_read() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &bash(&format!("cat >{NEW_GATED}")))?;

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

/// `2>` and friends carry a file descriptor before the operator.
#[test]
fn a_numbered_redirect_is_not_a_read() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &bash(&format!("cat 2> {NEW_GATED}")))?;

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

/// Redirecting an EXISTING gated file is still a write to it, not a read of it.
#[test]
fn a_redirect_over_an_existing_gated_file_is_not_a_read() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &bash(&format!("echo hi > {GATED}")))?;

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

// ---------------------------------------------------------------------------
// Everything the fix must NOT loosen
// ---------------------------------------------------------------------------

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

    let output = hook(dir.path(), &bash(&format!("cat {GATED}")))?;

    assert!(
        output.contains(RULE_RAW_READ),
        "an ordinary read is untouched by this fix: {output}"
    );
    Ok(())
}

/// The discriminating case: a real read whose OUTPUT is redirected. The source
/// must still be denied and the destination must still be ignored.
#[test]
fn a_read_whose_output_is_redirected_is_still_denied_on_the_source() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &bash(&format!("cat {GATED} > {UNGATED}")))?;

    assert!(
        output.contains(RULE_RAW_READ),
        "the SOURCE is still read; only the destination is exempt: {output}"
    );
    Ok(())
}

/// Input redirection is a read and stays one.
#[test]
fn input_redirection_of_a_gated_file_is_still_denied() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &bash(&format!("cat < {GATED}")))?;

    assert!(
        output.contains(RULE_RAW_READ),
        "`<` reads; only `>` writes: {output}"
    );
    Ok(())
}

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

    let output = hook(dir.path(), &bash("grep -rn thing crates/"))?;

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

/// A non-reading verb was never scanned and still is not.
#[test]
fn a_write_verb_is_unaffected() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &bash(&format!("rm {GATED}")))?;

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

// ---------------------------------------------------------------------------
// The heredoc body — measured, not assumed
// ---------------------------------------------------------------------------

/// The live denial arrived from a heredoc write, so the BODY matters too: a
/// generated file routinely mentions gated paths in its own text. If the body's
/// words are scanned as read targets, writing such a file is denied for what it
/// SAYS rather than for what it does.
#[test]
fn a_heredoc_body_mentioning_a_gated_path_is_not_a_read() -> TestResult {
    let dir = repo()?;
    let command = format!("cat > {UNGATED} <<'EOF'\n// see {GATED} for the original\nEOF\n");

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

    assert!(
        !output.contains(RULE_RAW_READ),
        "the body is content being WRITTEN, not a path being read: {output}"
    );
    Ok(())
}