pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Option C of the hook-matcher-gap charter
//! (`docs/charters/2026-08-16-hook-matcher-gap.md`): close the READ bypass.
//!
//! The read contract gates `Read` on `retrieval_paths`, but `Bash(cat …)`,
//! `Bash(grep …)`, and every other shell reader walk straight past it. That
//! is not theoretical — the assistant used `grep -oE` on gated source in a
//! prior task and the gate never fired. The ungated path is the cheaper
//! path, so it gets taken by default.
//!
//! The predicate here is deliberately CRUDE: does the raw command string
//! contain a gated path? No shell grammar, no quoting rules, no pipe
//! splitting — a parser is an adversarial surface and a wrong parse either
//! blocks real work or admits a crafted command. Substring matching
//! overblocks, and that is the accepted trade: overblocking a READ is cheap
//! because narrowing or asking the retrieval tool is always available. This
//! is scoped to reads for exactly that reason; the same crudeness would not
//! be acceptable on a write.

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

const GATED: &str = "crates/pushkin-core/src/pipeline.rs";

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("crates/pushkin-core/src"))?;
    fs::write(dir.path().join(GATED), "// gated\n")?;
    Ok(dir)
}

/// One `Bash` tool call through the hook, as Claude would send it.
fn bash(dir: &Path, command: &str) -> Result<String, Box<dyn std::error::Error>> {
    let payload = serde_json::json!({
        "session_id": "bash-gate",
        "tool_name": "Bash",
        "tool_input": { "command": command },
    })
    .to_string();
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .write_stdin(payload)
        .args(["hook", "claude"])
        .output()?;
    Ok(format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    ))
}

/// The exact command the assistant actually ran against gated source while
/// the read gate was live. If this one does not deny, the gate is decorative.
#[test]
fn the_grep_that_bypassed_the_gate_is_now_denied() -> TestResult {
    let dir = repo()?;

    let output = bash(dir.path(), &format!("grep -oE 'fn ' {GATED}"))?;
    assert!(
        output.contains("pushkin.retrieval.raw_read"),
        "the observed bypass must now be caught: {output}"
    );
    Ok(())
}

/// Every common shell reader, not just grep. The gate keys on the PATH in
/// the command, never on the command name, so this list is illustrative
/// rather than exhaustive by construction.
#[test]
fn shell_readers_of_a_gated_path_are_denied() -> TestResult {
    let dir = repo()?;

    for command in [
        format!("cat {GATED}"),
        format!("head -50 {GATED}"),
        format!("sed -n '1,80p' {GATED}"),
        format!("awk '/fn /' {GATED}"),
        format!("less {GATED}"),
        format!("rg 'fn ' {GATED}"),
    ] {
        let output = bash(dir.path(), &command)?;
        assert!(
            output.contains("pushkin.retrieval.raw_read"),
            "`{command}` reads a gated path and must deny: {output}"
        );
    }
    Ok(())
}

/// The deny reuses the read contract's rule and prose verbatim — same rule
/// id, same manifest-declared destination. A second rule id for the same
/// concern would split the ladder's attempt counting and teach two lessons
/// where there is one.
#[test]
fn the_bash_deny_reuses_the_read_contract_rule_and_prose() -> TestResult {
    let dir = repo()?;

    let output = bash(dir.path(), &format!("cat {GATED}"))?;
    assert!(
        output.contains("mcp__codebase-retrieval__codebase-retrieval"),
        "the fix hint names the manifest's tool, as the Read path does: {output}"
    );
    assert!(
        output.contains("read blocked"),
        "a shell read is narrated as a read, not a write: {output}"
    );
    Ok(())
}

/// Commands that touch no gated path are untouched. This is the majority of
/// shell traffic and the gate must stay invisible to it.
#[test]
fn commands_touching_no_gated_path_are_untouched() -> TestResult {
    let dir = repo()?;

    for command in [
        "ls -la",
        "cargo test --workspace",
        "git status --short",
        "cat README.md",
        "grep -rn TODO docs/",
    ] {
        let output = bash(dir.path(), command)?;
        assert!(
            !output.contains("pushkin.retrieval.raw_read"),
            "`{command}` names no gated path and must pass: {output}"
        );
    }
    Ok(())
}

/// A repo that never opts in has an empty `retrieval_paths`, so the Bash
/// arm must be inert — not merely quiet, but never matching.
#[test]
fn an_unopted_repo_never_gates_bash() -> TestResult {
    let dir = tempfile::tempdir()?;
    let manifest = MANIFEST
        .replace(r#"retrieval_paths = ["crates/**/*.rs"]"#, "")
        .replace(
            r#"retrieval_tool = "mcp__codebase-retrieval__codebase-retrieval""#,
            "",
        );
    fs::write(dir.path().join("pushkin.toml"), manifest)?;
    fs::create_dir_all(dir.path().join("crates/pushkin-core/src"))?;
    fs::write(dir.path().join(GATED), "// gated\n")?;

    let output = bash(dir.path(), &format!("cat {GATED}"))?;
    assert!(
        !output.contains("pushkin.retrieval.raw_read"),
        "opting out is the default and must stay free: {output}"
    );
    Ok(())
}

/// Scope guard: option C is READS ONLY. A shell WRITE to a protected path is
/// the charter's option B — a pre-commit filesystem diff — and must not be
/// silently half-solved here with substring matching, which on a write would
/// be an unacceptable false-positive risk.
#[test]
fn shell_writes_are_out_of_scope_for_this_gate() -> TestResult {
    let dir = repo()?;

    let output = bash(dir.path(), "echo x > pushkin.toml")?;
    assert!(
        !output.contains("pushkin.retrieval.raw_read"),
        "a write must not be reported as a raw-read violation: {output}"
    );
    Ok(())
}