pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! F56 — codex hooks require persisted TRUST, and a fresh install is silently
//! ungated until it is granted.
//!
//! COMMITTED RED. A NEW file per N10.
//!
//! **What the probe established** (E1(b), 2026-08-17). `codex exec` with
//! `.codex/hooks.json` present produced ZERO hook payloads; the identical
//! command with `--dangerously-bypass-hook-trust` produced one. Codex gates
//! hook execution behind a trust grant that `pushkin init` never establishes.
//!
//! **What cannot be fixed, and is not attempted here.** Codex exposes no
//! surface to grant, list, or query hook trust — `--dangerously-bypass-hook-trust`
//! is the only trust-related flag in its entire CLI, and no trust record exists
//! anywhere under `~/.codex` until an interactive grant creates one. Note also
//! that PROJECT trust is a different mechanism: the probe sandbox carried
//! `trust_level = "trusted"` in `~/.codex/config.toml` and the hook still did
//! not fire. So Pushkin cannot establish trust, and `doctor` cannot verify it.
//!
//! **What therefore IS the fix: the silence.** An operator who runs
//! `pushkin init --agent codex` today is told "installed" and has every reason
//! to believe the repo is gated. It is not. Both surfaces must say so — `init`
//! at the moment of installing, `doctor` whenever asked about health — and
//! both must be honest that the condition is unverifiable from here rather
//! than implying a check happened.

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

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",
    )?;
    Ok(dir)
}

fn run(dir: &Path, args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .args(args)
        .output()?;
    Ok(format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    ))
}

/// The install claim must not be flat. "installed" alone is what makes the gap
/// dangerous: it is exactly the sentence an operator relies on.
#[test]
fn init_warns_that_codex_hooks_need_a_trust_grant() -> TestResult {
    let dir = repo()?;

    let output = run(dir.path(), &["init", "--agent", "codex"])?;

    assert!(
        output.to_lowercase().contains("trust"),
        "installing codex hooks must name the trust requirement: {output}"
    );
    assert!(
        output.contains("F56"),
        "and cite the finding, so the notice is traceable: {output}"
    );
    Ok(())
}

/// The notice has to be actionable. An operator who reads it should know both
/// that the repo is currently ungated and what to do about it.
#[test]
fn the_init_notice_says_hooks_do_not_fire_until_granted() -> TestResult {
    let dir = repo()?;

    let output = run(dir.path(), &["init", "--agent", "codex"])?;
    let lower = output.to_lowercase();

    assert!(
        lower.contains("will not fire")
            || lower.contains("does not fire")
            || lower.contains("not run"),
        "say plainly that the hook is inert until trusted: {output}"
    );
    assert!(
        output.contains("--dangerously-bypass-hook-trust") || lower.contains("interactively"),
        "name at least one way to establish or bypass trust: {output}"
    );
    Ok(())
}

/// Doctor said NOTHING about codex before this finding. Silence from a health
/// check reads as health.
#[test]
fn doctor_reports_the_codex_trust_condition() -> TestResult {
    let dir = repo()?;
    run(dir.path(), &["init", "--agent", "codex"])?;

    let output = run(dir.path(), &["doctor"])?;

    assert!(
        output.to_lowercase().contains("codex"),
        "doctor must mention codex at all: {output}"
    );
    assert!(
        output.to_lowercase().contains("trust"),
        "and name the trust condition: {output}"
    );
    Ok(())
}

/// The honesty requirement. Pushkin cannot read codex's trust state, so doctor
/// must report the condition as UNVERIFIABLE rather than as a passing check.
/// A green tick here would be a claim we have no evidence for — the same class
/// of defect as the record that said N10 was product-enforced.
#[test]
fn doctor_does_not_claim_to_have_verified_trust() -> TestResult {
    let dir = repo()?;
    run(dir.path(), &["init", "--agent", "codex"])?;

    let output = run(dir.path(), &["doctor"])?;
    let lower = output.to_lowercase();

    assert!(
        lower.contains("cannot verify")
            || lower.contains("unverifiable")
            || lower.contains("not verifiable"),
        "doctor must say the trust state is unverifiable from here: {output}"
    );
    Ok(())
}

/// Health is not a failure. An unverifiable condition is a warning to a human,
/// not a broken install, so it must not change doctor's exit code — the same
/// contract the D2 ruling settled for repair deferrals.
///
/// Asserted by COMPARISON rather than against a fixed code: a bare fixture can
/// carry unrelated findings (no lefthook floor, no git repo), so "doctor exits
/// 0" would be testing the fixture, not the notice. Installing codex must not
/// move the code either way.
#[test]
fn the_codex_trust_notice_does_not_change_doctors_exit_code() -> TestResult {
    let without = repo()?;
    let before = Command::cargo_bin("pushkin")?
        .current_dir(without.path())
        .arg("doctor")
        .output()?;

    let with = repo()?;
    run(with.path(), &["init", "--agent", "codex"])?;
    let after = Command::cargo_bin("pushkin")?
        .current_dir(with.path())
        .arg("doctor")
        .output()?;

    assert_eq!(
        before.status.code(),
        after.status.code(),
        "the trust notice is advisory: installing codex must not change the verdict"
    );
    Ok(())
}