pushkin 0.2.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! F59 — opencode's SECOND native mutation tool, `apply_patch`, is unrecognized
//! and fails open on the protected path.
//!
//! COMMITTED RED. A NEW file per N10.
//!
//! **What was probed live, 2026-08-18.** `normalize_opencode` keys entirely on
//! `args.filePath`. opencode's `apply_patch` carries `args.patchText` instead
//! and names no `filePath` at all, so the normalizer returns `Err(None)` and the
//! gate fails open. A patch updating `pushkin.toml` was allowed; the same file
//! reached through `edit` was correctly blocked.
//!
//! **Worse than F58 in one respect.** codex's fail-open was silence, which its
//! harness reads as consent. opencode's verdict channel is explicit, so this
//! one emits `{"decision":"allow"}` — the gate does not merely fail to object,
//! it AFFIRMS the write, and the plugin acts on that.
//!
//! **The fix is reuse, not new parsing.** `apply_patch` is the same format
//! codex sends, and the codex parser already handles all four section markers
//! since F58. What is missing is only the recognition that opencode speaks it
//! too. Both families must therefore reach identical verdicts on identical
//! patch text, which is what this suite pins.

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 PROTECTED: &str = "pushkin.toml";
const COMMITTED_TEST: &str = "crates/pushkin-cli/tests/committed_suite.rs";
const MAPPED: &str = "app/api/users/route.ts";
const ORDINARY: &str = "docs/notes.md";

const RULE_PROTECTED: &str = "pushkin.protected_path";
const RULE_READ_ONLY: &str = "pushkin.read_only_path";
const RULE_UNVALIDATED: &str = "contract.boundary.unvalidated_input";

const FAIL_OPEN: &str = "failing open";
const EXPLICIT_ALLOW: &str = "\"decision\":\"allow\"";

/// A handler that reaches `req.json()` without parsing through the contract.
const UNVALIDATED_HANDLER: &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(PROTECTED), 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), "export const handler = 1;\n")?;
    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(ORDINARY), "notes\n")?;
    git(dir.path(), &["init", "-q", "."])?;
    git(dir.path(), &["add", "-A"])?;
    git(
        dir.path(),
        &[
            "-c",
            "user.name=F59 Suite",
            "-c",
            "user.email=f59@test",
            "commit",
            "-qm",
            "fixture",
        ],
    )?;
    Ok(dir)
}

fn patch_text(body: &str) -> String {
    format!("*** Begin Patch\n{body}*** End Patch\n")
}

/// opencode's plugin relay shape: `sessionID` + `tool` + `args`.
fn opencode_patch(body: &str) -> String {
    serde_json::json!({
        "sessionID": "f59",
        "tool": "apply_patch",
        "args": { "patchText": patch_text(body) },
    })
    .to_string()
}

/// The same patch text as codex sends it, for the parity assertions.
fn codex_patch(body: &str) -> String {
    serde_json::json!({
        "session_id": "f59",
        "hook_event_name": "PreToolUse",
        "tool_name": "apply_patch",
        "tool_input": { "command": patch_text(body) },
    })
    .to_string()
}

fn hook(
    dir: &Path,
    agent: &str,
    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", agent]).output()?;
    Ok(format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    ))
}

/// opencode hands its tools an ABSOLUTE `filePath`, which the normalizer
/// relativizes against the working directory. On macOS a temp dir is reached
/// through a `/private` symlink, so the un-canonicalized spelling never
/// relativizes and the manifest globs miss — the fixture would then "pass" a
/// gated-path test against a gate that had allowed.
fn absolute(dir: &Path, relative: &str) -> String {
    dir.canonicalize()
        .unwrap_or_else(|_| dir.to_path_buf())
        .join(relative)
        .to_string_lossy()
        .into_owned()
}

fn stop_daemon(dir: &Path) {
    let _ = Command::cargo_bin("pushkin")
        .map(|mut c| c.current_dir(dir).args(["daemon", "stop"]).output());
}

// ---------------------------------------------------------------------------
// The bypass
// ---------------------------------------------------------------------------

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

    let output = hook(
        dir.path(),
        "opencode",
        &opencode_patch(&format!("*** Update File: {PROTECTED}\n@@\n-a\n+b\n")),
        Some("off"),
    )?;

    assert!(
        output.contains(RULE_PROTECTED),
        "an apply_patch write is still a write: {output}"
    );
    Ok(())
}

/// The part that makes this worse than F58. A silent fail-open is a gate that
/// failed to object; an explicit `{"decision":"allow"}` is a gate that AFFIRMED
/// the write, and opencode's plugin acts on the verdict it is given.
#[test]
fn a_protected_patch_is_never_answered_with_an_explicit_allow() -> TestResult {
    let dir = repo()?;

    let output = hook(
        dir.path(),
        "opencode",
        &opencode_patch(&format!("*** Update File: {PROTECTED}\n@@\n-a\n+b\n")),
        Some("off"),
    )?;

    assert!(
        !output.contains(EXPLICIT_ALLOW),
        "the gate must not affirm a write to the protected path: {output}"
    );
    assert!(
        !output.contains(FAIL_OPEN),
        "and must RECOGNIZE the payload rather than decline to judge it: {output}"
    );
    Ok(())
}

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

    let output = hook(
        dir.path(),
        "opencode",
        &opencode_patch(&format!("*** Delete File: {PROTECTED}\n")),
        Some("off"),
    )?;

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

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

    let output = hook(
        dir.path(),
        "opencode",
        &opencode_patch(&format!(
            "*** Update File: {ORDINARY}\n*** Move to: {PROTECTED}\n@@\n-notes\n+tampered\n"
        )),
        Some("off"),
    )?;

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

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

    let output = hook(
        dir.path(),
        "opencode",
        &opencode_patch(&format!("*** Update File: {COMMITTED_TEST}\n@@\n-a\n+b\n")),
        Some("off"),
    )?;

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

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

    let output = hook(
        dir.path(),
        "opencode",
        &opencode_patch(&format!(
            "*** Add File: app/api/new/route.ts\n{UNVALIDATED_HANDLER}"
        )),
        Some("off"),
    )?;

    assert!(
        output.contains(RULE_UNVALIDATED),
        "Add sections carry whole files and must be judged on them: {output}"
    );
    Ok(())
}

// ---------------------------------------------------------------------------
// Parity — one format, one verdict, whichever family sends it
// ---------------------------------------------------------------------------

/// The property that keeps the two adapters from drifting: `apply_patch` is one
/// format, so identical patch text must reach an identical verdict whether
/// codex or opencode sent it. Only the DIALECT of the answer may differ.
#[test]
fn opencode_and_codex_reach_the_same_verdict_on_the_same_patch() -> TestResult {
    let dir = repo()?;
    let body = format!("*** Update File: {PROTECTED}\n@@\n-a\n+b\n");

    let via_opencode = hook(dir.path(), "opencode", &opencode_patch(&body), Some("off"))?;
    let via_codex = hook(dir.path(), "codex", &codex_patch(&body), Some("off"))?;

    for output in [&via_opencode, &via_codex] {
        assert!(
            output.contains(RULE_PROTECTED),
            "one format, one verdict: {output}"
        );
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// The fix must not overshoot
// ---------------------------------------------------------------------------

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

    let output = hook(
        dir.path(),
        "opencode",
        &opencode_patch("*** Add File: docs/new.md\n+hello\n"),
        Some("off"),
    )?;

    assert!(
        !output.contains(RULE_PROTECTED) && !output.contains(RULE_READ_ONLY),
        "nothing here is gated: {output}"
    );
    Ok(())
}

/// Regression: `edit` is opencode's other mutation tool and must be untouched
/// by teaching the normalizer a second one.
#[test]
fn the_edit_tool_still_reaches_the_protected_path_rule() -> TestResult {
    let dir = repo()?;
    let payload = serde_json::json!({
        "sessionID": "f59",
        "tool": "edit",
        "args": {
            "filePath": absolute(dir.path(), PROTECTED),
            "oldString": "a",
            "newString": "b",
        },
    })
    .to_string();

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

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

/// And `read` must keep its own verdict — the new arm keys on the tool name, so
/// a mistake there would silently reclassify reads as writes.
#[test]
fn the_read_tool_is_unaffected() -> TestResult {
    let dir = repo()?;
    let payload = serde_json::json!({
        "sessionID": "f59",
        "tool": "read",
        "args": { "filePath": absolute(dir.path(), ORDINARY) },
    })
    .to_string();

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

    assert!(
        !output.contains(RULE_PROTECTED) && !output.contains(RULE_READ_ONLY),
        "an ungated read objects to nothing: {output}"
    );
    Ok(())
}

#[test]
fn the_patch_verdict_is_identical_warm_and_cold() -> TestResult {
    let dir = repo()?;
    let payload = opencode_patch(&format!("*** Update File: {PROTECTED}\n@@\n-a\n+b\n"));

    let cold = hook(dir.path(), "opencode", &payload, Some("off"))?;
    let warm = hook(dir.path(), "opencode", &payload, Some("auto"))?;
    stop_daemon(dir.path());

    assert!(cold.contains(RULE_PROTECTED), "cold: {cold}");
    assert!(warm.contains(RULE_PROTECTED), "warm: {warm}");
    Ok(())
}