pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! F52 — a codex `apply_patch` HUNK must stop being judged as whole content.
//!
//! COMMITTED RED. A NEW file per N10.
//!
//! **The probe changed this finding twice.** It opened as "is codex in the
//! content-absent class or the fragment class?", was reported as "the hook does
//! not fire at all", and capture then settled both halves:
//!
//!   1. **Codex requires persisted hook TRUST.** Without it hooks silently do
//!      not run, so a fresh `pushkin init --agent codex` is ungated and says
//!      nothing. That is a provisioning gap, is NOT fixed here, and is reported
//!      for its own numbering.
//!   2. **Once the hook fires, codex is in F51's FALSE-VERDICT class** — not
//!      F48's fail-open class.
//!
//! `parse_apply_patch` treats `*** Add File:` and `*** Update File:`
//! identically and collects only `+`-prefixed lines as "content". For an Add
//! that IS the whole new file and is correct. For an Update it is a HUNK, and
//! the gate then judges a fragment as though it were the file.
//!
//! Observed live, 2026-08-17, against a mapped path whose handler is
//! unvalidated: the gate returned a **silent allow**. That is F51 direction (a),
//! the vacuous pass — the most dangerous direction, because the agent is told
//! its edit is fine about content the gate never saw.
//!
//! The patch text below is the real captured command, not a construction.
//!
//! **SUPERSEDED IN PART by F52 Phase B (2026-08-18).** Four assertions here
//! required the interim REFUSAL, which was the strongest verdict available while
//! a hunk could not be reconstructed. The hunk arm reconstructs now, so those
//! four were strengthened rather than deleted: each moved from "the gate
//! declined to judge" to "the gate judged, and was right about the file". The
//! finding this suite pins — that a hunk is not evidence the file is clean — is
//! unchanged, and is now proven by the TRUE DENIAL instead of by a deferral.

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/**"]
"#;

const MAPPED: &str = "app/api/users/route.ts";
const COMMITTED_TEST: &str = "crates/pushkin-cli/tests/committed_suite.rs";
const UNGATED: &str = "docs/notes.md";

const RULE_CONTENT_UNAVAILABLE: &str = "pushkin.content_unavailable";
const RULE_UNVALIDATED: &str = "contract.boundary.unvalidated_input";
const RULE_READ_ONLY: &str = "pushkin.read_only_path";

/// The file really does carry an unvalidated handler. Any verdict calling this
/// path clean is wrong about the file, whatever it is right about.
const REAL_FILE: &str = "export async function POST(req: Request) {\n  \
                         const body = await req.json();\n  \
                         return Response.json(body);\n}\n";

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("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("app/api/users"))?;
    fs::write(dir.path().join(MAPPED), REAL_FILE)?;
    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("docs"))?;
    fs::write(dir.path().join(UNGATED), "notes\n")?;
    git(dir.path(), &["init", "-q", "."])?;
    git(dir.path(), &["add", "-A"])?;
    git(
        dir.path(),
        &[
            "-c",
            "user.name=F52 Suite",
            "-c",
            "user.email=f52@test",
            "commit",
            "-qm",
            "fixture",
        ],
    )?;
    Ok(dir)
}

/// An `*** Update File:` patch — a HUNK. This is the captured shape: context
/// lines unprefixed, removals `-`, additions `+`.
fn update_patch(path: &str) -> String {
    let command = format!(
        "*** Begin Patch\n\
         *** Update File: {path}\n\
         @@\n\
         \x20export async function POST(req: Request) {{\n\
         -  const body = await req.json();\n\
         -  return Response.json(body);\n\
         +  const payload = await req.json();\n\
         +  return Response.json(payload);\n\
         \x20}}\n\
         *** End Patch"
    );
    codex_payload(&command)
}

/// An `*** Add File:` patch — every line is an addition, so the collected
/// content IS the whole new file. This must keep being judged on its content.
fn add_patch(path: &str, lines: &[&str]) -> String {
    let mut body = String::new();
    for line in lines {
        body.push('+');
        body.push_str(line);
        body.push('\n');
    }
    codex_payload(&format!(
        "*** Begin Patch\n*** Add File: {path}\n{body}*** End Patch"
    ))
}

/// One patch touching an Add and an Update together.
fn mixed_patch(add_path: &str, update_path: &str) -> String {
    codex_payload(&format!(
        "*** Begin Patch\n\
         *** Add File: {add_path}\n\
         +export const ok = 1;\n\
         *** Update File: {update_path}\n\
         @@\n\
         -  const body = await req.json();\n\
         +  const payload = await req.json();\n\
         *** End Patch"
    ))
}

fn codex_payload(command: &str) -> String {
    serde_json::json!({
        "session_id": "f52-codex",
        "hook_event_name": "PreToolUse",
        "tool_name": "apply_patch",
        "tool_input": { "command": command },
    })
    .to_string()
}

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

// ---------------------------------------------------------------------------
// The false verdict
// ---------------------------------------------------------------------------

/// The defect, exactly as observed live: an Update hunk on a mapped path whose
/// real handler is unvalidated, silently ALLOWED because the hunk's added lines
/// contain no handler for the content rule to object to.
#[test]
fn an_update_hunk_is_not_evidence_the_file_is_clean() -> TestResult {
    let dir = repo()?;

    let output = hook(dir.path(), &update_patch(MAPPED), Some("off"))?;

    // The hunk only renames a local, so its `+` lines contain nothing for the
    // content rule to object to — judging THEM was the silent allow. The file
    // it produces still reads request input without parsing, and that is what
    // must be judged. Phase B strengthened this from a refusal to the real
    // verdict.
    assert!(
        output.contains(RULE_UNVALIDATED),
        "the reconstructed file is still unvalidated and must be denied: {output}"
    );
    assert!(
        !output.contains(RULE_CONTENT_UNAVAILABLE),
        "and judged, not deferred — the hunk is reconstructable now: {output}"
    );
    Ok(())
}

/// When a hunk CANNOT be reconstructed the refusal must still name the tool it
/// refused. Retargeted by Phase B onto a hunk whose context is absent from the
/// file — a reconstructable hunk no longer produces a refusal to inspect.
#[test]
fn the_codex_refusal_names_apply_patch() -> TestResult {
    let dir = repo()?;
    let unreconstructable = codex_payload(&format!(
        "*** Begin Patch\n\
         *** Update File: {MAPPED}\n\
         @@\n\
         \x20this context is nowhere in the file\n\
         -nor is this\n\
         +replacement\n\
         *** End Patch"
    ));

    let output = hook(dir.path(), &unreconstructable, Some("off"))?;

    assert!(
        output.contains(RULE_CONTENT_UNAVAILABLE),
        "an unreconstructable hunk still refuses: {output}"
    );
    assert!(
        output.contains("apply_patch"),
        "name the tool refused: {output}"
    );
    assert!(output.contains("F48"), "cite the finding: {output}");
    Ok(())
}

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

    let output = hook(dir.path(), &update_patch(COMMITTED_TEST), Some("off"))?;

    assert!(
        output.contains(RULE_READ_ONLY),
        "a glob plus HEAD needs no content: {output}"
    );
    Ok(())
}

/// This test used to document a COMPROMISE: one Update made the whole patch
/// content-absent, so the Add half was over-refused even though it carried a
/// whole file. Its own comment named Phase B as the moment that would end —
/// and this is that moment. Each half is now judged on what it actually
/// carries: the Add on its content, the Update on its reconstruction.
#[test]
fn a_mixed_patch_judges_each_half_on_what_it_carries() -> TestResult {
    let dir = repo()?;

    let output = hook(
        dir.path(),
        &mixed_patch("app/api/users/new.ts", MAPPED),
        Some("off"),
    )?;

    assert!(
        output.contains(RULE_UNVALIDATED),
        "the updated file is still unvalidated and must be denied: {output}"
    );
    assert!(
        !output.contains(RULE_CONTENT_UNAVAILABLE),
        "neither half is deferred any more: {output}"
    );
    Ok(())
}

// F76 ruling 1C: the "codex verdict is identical warm and cold" test that
// lived here was deleted — an Update hunk normalizes to `MutateNoContent`,
// and `gate_mutation` judges the synthesized content COLD; the daemon
// (`check_or_cold`, Write-only) is never reached, so the assertion compared
// two runs of the same cold code. The verdict it re-asserted is pinned
// discriminately by `an_update_hunk_is_not_evidence_the_file_is_clean` above.

// ---------------------------------------------------------------------------
// What must NOT change — `Add File` really does carry the whole file
// ---------------------------------------------------------------------------

/// The distinction the fix turns on. An Add's `+` lines ARE the new file, so it
/// must still meet the content rule and still be denied when it violates it.
#[test]
fn an_add_file_patch_carries_real_content_and_is_judged_on_it() -> TestResult {
    let dir = repo()?;

    let output = hook(
        dir.path(),
        &add_patch(
            "app/api/users/new.ts",
            &[
                "export async function POST(req: Request) {",
                "  const body = await req.json();",
                "  return Response.json(body);",
                "}",
            ],
        ),
        Some("off"),
    )?;

    assert!(
        output.contains(RULE_UNVALIDATED),
        "an Add is whole-file content and still meets the content rule: {output}"
    );
    assert!(
        !output.contains(RULE_CONTENT_UNAVAILABLE),
        "content WAS available here; the interim refusal must not appear: {output}"
    );
    Ok(())
}

/// ...and a conforming Add stays silent, so the fix does not turn codex into a
/// permanent deny.
#[test]
fn a_conforming_add_file_patch_is_allowed() -> TestResult {
    let dir = repo()?;

    let output = hook(
        dir.path(),
        &add_patch("app/api/users/ok.ts", &["export const ok = 1;"]),
        Some("off"),
    )?;

    assert!(
        !output.contains(RULE_UNVALIDATED) && !output.contains(RULE_CONTENT_UNAVAILABLE),
        "nothing to object to here: {output}"
    );
    Ok(())
}

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

    let output = hook(dir.path(), &update_patch(UNGATED), Some("off"))?;

    assert!(
        !output.contains(RULE_CONTENT_UNAVAILABLE),
        "no rule maps here, so there is nothing to refuse: {output}"
    );
    Ok(())
}