pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Path-normalization coverage for the `Bash` arm of the read contract.
//!
//! Separate from `bash_read_gate.rs` because that suite is committed and so
//! read-only under N10 — new coverage lands in a new file rather than as
//! edits to a pinned one.
//!
//! Scope is the hook-matcher-gap charter's: the gate normalizes the two
//! spellings an agent reaches for by accident — a leading `./` and a
//! repo-root absolute prefix. Deliberate evasion (quoting, `$()`, brace
//! expansion, `..`) stays out of scope and is expected to pass through, so
//! nothing here asserts against it.

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";

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

/// Both fixtures want the same tree; only the manifest's opt-in differs.
fn seed(dir: &Path, manifest: &str) -> TestResult {
    fs::write(dir.join("pushkin.toml"), manifest)?;
    fs::create_dir_all(dir.join("crates/pushkin-core/src"))?;
    fs::write(dir.join(GATED), "// gated\n")?;
    Ok(())
}

fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    seed(dir.path(), MANIFEST)?;
    Ok(dir)
}

/// `retrieval_paths` and `retrieval_tool` stripped — the shape of a repo that
/// never opted into the read contract, where normalization must find nothing
/// to do because the matcher itself is empty.
fn unopted_repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    let manifest = MANIFEST
        .replace(r#"retrieval_paths = ["crates/**/*.rs"]"#, "")
        .replace(
            r#"retrieval_tool = "mcp__codebase-retrieval__codebase-retrieval""#,
            "",
        );
    seed(dir.path(), &manifest)?;
    Ok(dir)
}

/// The temp repo's root spelled the way `getcwd` will spell it: `tempfile`
/// hands out `/var/folders/...` while macOS resolves that same directory to
/// `/private/var/folders/...`, and only the resolved spelling shares a prefix
/// with the cwd the hook reads for itself.
fn resolved_root(dir: &Path) -> Result<String, Box<dyn std::error::Error>> {
    Ok(dir.canonicalize()?.display().to_string())
}

/// 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-normalization",
        "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)
    ))
}

/// Tab-completion emits the `./` form unprompted, so this is the spelling an
/// agent lands on without ever trying to evade anything. `globset` compares
/// text, not paths, and would let it through untouched.
#[test]
fn a_leading_dot_slash_is_the_same_read_and_is_denied() -> TestResult {
    let dir = repo()?;

    let output = bash(dir.path(), &format!("cat ./{GATED}"))?;
    assert!(
        output.contains(RULE),
        "`./` is a spelling, not a different file: {output}"
    );
    Ok(())
}

/// Stripping `./` once would leave `./crates/...` still unmatched, which is a
/// one-character evasion of a gate that just claimed to close this spelling.
#[test]
fn a_repeated_dot_slash_is_denied() -> TestResult {
    let dir = repo()?;

    let output = bash(dir.path(), &format!("cat ././{GATED}"))?;
    assert!(
        output.contains(RULE),
        "the prefix strip repeats or it is trivially defeated: {output}"
    );
    Ok(())
}

/// An absolute path is what an agent produces after any tool that echoes full
/// paths, and it can never match a repo-relative glob without being
/// relativized against the root first.
#[test]
fn an_absolute_spelling_is_denied() -> TestResult {
    let dir = repo()?;
    let root = resolved_root(dir.path())?;

    let output = bash(dir.path(), &format!("cat {root}/{GATED}"))?;
    assert!(
        output.contains(RULE),
        "an absolute path names the same gated file: {output}"
    );
    Ok(())
}

/// Normalization must close spellings, not widen the net. Ordinary shell
/// traffic outnumbers gated reads by orders of magnitude, and a gate that
/// starts denying it is worse than no gate.
#[test]
fn normalization_does_not_widen_the_net() -> TestResult {
    let dir = repo()?;

    for command in [
        "cat README.md",
        "ls -la",
        "cat ./README.md",
        "cat ././docs/charters/index.md",
        "cat /etc/hosts",
    ] {
        let output = bash(dir.path(), command)?;
        assert!(
            !output.contains(RULE),
            "`{command}` names no gated path and must pass: {output}"
        );
    }
    Ok(())
}

/// Opting out has to survive the new code path too: normalization runs before
/// the glob test, so a bug there could resurrect a matcher the repo declined.
#[test]
fn an_unopted_repo_stays_inert_under_normalization() -> TestResult {
    let dir = unopted_repo()?;

    let output = bash(dir.path(), &format!("cat ./{GATED}"))?;
    assert!(
        !output.contains(RULE),
        "opting out is the default and normalization must not undo it: {output}"
    );
    Ok(())
}

/// A sibling whose name merely begins with the root's is not inside it. The
/// prefix strip survives this only because `Path::strip_prefix` compares
/// components rather than bytes; swapping in the obvious `str::strip_prefix`
/// would leave every other test here green while the gate began denying reads
/// of a neighbouring checkout.
#[test]
fn a_sibling_directory_sharing_the_roots_name_is_not_inside_it() -> TestResult {
    let base = tempfile::tempdir()?;
    let root = base.path().join("repo");
    let sibling = base.path().join("repo-other");
    fs::create_dir_all(&root)?;
    fs::create_dir_all(sibling.join("crates/pushkin-core/src"))?;
    seed(&root, MANIFEST)?;
    fs::write(sibling.join(GATED), "// outside the repo\n")?;
    let outside = format!("{}/{GATED}", resolved_root(&sibling)?);

    let output = bash(&root, &format!("cat {outside}"))?;
    assert!(
        !output.contains(RULE),
        "`repo-other` is a different tree; byte-wise prefix stripping would \
         gate it: {output}"
    );
    Ok(())
}

/// The reason the violation carries the normalized token rather than the one
/// the agent typed: a deny naming `/private/var/folders/...` teaches a
/// machine-specific path the agent cannot act on, while the repo-relative one
/// is exactly what the retrieval tool wants next.
#[test]
fn the_deny_names_the_repo_relative_path_not_the_machine_path() -> TestResult {
    let dir = repo()?;
    let root = resolved_root(dir.path())?;

    let output = bash(dir.path(), &format!("cat {root}/{GATED}"))?;
    assert!(
        output.contains(GATED),
        "the deny must name a path the agent can act on: {output}"
    );
    assert!(
        !output.contains(&root),
        "the machine-specific prefix must not survive into the prose: {output}"
    );
    Ok(())
}