pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! F75 — the manifest filename is protected wherever it appears in the tree.
//!
//! The chain this closes was measured in F73 phase 3's W0, in a fixture with
//! `[gates]` armed and two controls proving it armed rather than inert: a `Write`
//! to the root `pushkin.toml` denied under `pushkin.protected_path`, a `Write` to
//! `crates/x/tests/t.rs` denied under `read_only_paths`, and a `Write` to
//! `sub/pushkin.toml` **ALLOWED**. `protected_paths = ["pushkin.toml"]` is
//! path-anchored, so it never matched the nested name.
//!
//! F73 phase 3 cut the chain at link 2 — the daemon no longer adopts a nested
//! manifest, so a nested one now governs nothing. **Link 1 is what this closes:**
//! the write itself was still permitted, and nothing structural stopped a future
//! feature (monorepo support, per-directory profiles) from reading a nested
//! manifest again with no signal it re-opens a CRITICAL bypass. The rule is that
//! written-down assumption, enforced (charter
//! `docs/charters/2026-08-19-f75-manifest-write-protection.md`).
//!
//! **Not `protected_paths` widened** (charter §"Why NOT"): basename-anywhere
//! matching would silently widen every existing entry in every consumer repo. A
//! dedicated rule, because the manifest is not an ordinary protected path — it is
//! the file that defines what protection means. The single job is the point.
//!
//! **The reason is pinned, not just the verdict** (req 4, the F76 lesson): a
//! future nested-manifest feature must confront the argument in the deny prose,
//! not delete a failing "nothing happened" assertion. Ships dark here like
//! everything under `[gates]`; these fixtures arm a root manifest so a verdict is
//! observable, exactly as F73 phase 2's suite does.
//!
//! Committed first (RED — at the baseline no `pushkin.nested_manifest` rule
//! exists, so every deny assertion here fails), read-only hereafter (N10). A NEW
//! file; no committed suite is touched.

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

/// A root manifest arming `protected_paths` on an unrelated glob, so the root's
/// verdict is observable and distinct from the nested-manifest rule.
const ROOT_ARMED: &str =
    "version = 1\ncanonical = \"json-schema-2020-12\"\nauthoring = \"zod\"\n\n[gates]\nprotected_paths = [\"secrets/**\"]\n";

/// A manifest that arms nothing — used as the nested file's content, so if it
/// ever governed, a write would be allowed.
const PERMISSIVE: &str =
    "version = 1\ncanonical = \"json-schema-2020-12\"\nauthoring = \"zod\"\n\n[gates]\n";

/// A `Write` payload for an arbitrary path under `sub/`.
fn write_payload(path: &str) -> String {
    serde_json::json!({
        "session_id": "f75",
        "tool_name": "Write",
        "tool_input": { "file_path": path, "content": "version = 1\n" }
    })
    .to_string()
}

/// An `Edit` payload for `path` — the mutation surface, which routes through
/// `gate_mutation` rather than `check_write`.
fn edit_payload(path: &str) -> String {
    serde_json::json!({
        "session_id": "f75",
        "tool_name": "Edit",
        "tool_input": {
            "file_path": path,
            "old_string": "version = 1",
            "new_string": "version = 2"
        }
    })
    .to_string()
}

struct Run {
    out: String,
}

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

/// A git repo with an armed root manifest at the root and `sub/`, `secrets/`
/// directories present.
fn git_repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    let root = dir.path();
    fs::write(root.join("pushkin.toml"), ROOT_ARMED)?;
    fs::create_dir_all(root.join("sub"))?;
    fs::create_dir_all(root.join("secrets"))?;
    for args in [
        vec!["init", "-q"],
        vec!["config", "user.email", "t@example.com"],
        vec!["config", "user.name", "t"],
    ] {
        StdCommand::new("git")
            .current_dir(root)
            .args(args)
            .output()?;
    }
    Ok(dir)
}

fn is_denied(run: &Run) -> bool {
    run.out.contains("permissionDecision") && run.out.contains("deny")
}

/// The deny must be the nested-manifest rule specifically, and must carry the
/// reason — that the nested file does not govern and belongs at the root or under
/// `PUSHKIN_MANIFEST` — so a future feature confronts the argument (req 3, req 4).
fn denied_as_nested_manifest(run: &Run) -> bool {
    is_denied(run)
        && run.out.contains("pushkin.nested_manifest")
        && run.out.contains("PUSHKIN_MANIFEST")
}

// ---------- link 1: the nested manifest write is denied ----------

#[test]
fn a_write_to_a_nested_manifest_is_denied_with_its_own_rule() -> TestResult {
    let dir = git_repo()?;
    let run = hook_in(dir.path(), &write_payload("sub/pushkin.toml"), None)?;
    assert!(
        denied_as_nested_manifest(&run),
        "a Write to sub/pushkin.toml must deny under pushkin.nested_manifest, \
         naming the reason and the override; got:\n{}",
        run.out
    );
    Ok(())
}

#[test]
fn an_edit_to_a_nested_manifest_is_denied() -> TestResult {
    // The mutation surface (Edit/MultiEdit) routes through gate_mutation, not
    // check_write. Requirement 1 covers Write AND Edit, so both must deny.
    let dir = git_repo()?;
    fs::write(dir.path().join("sub/pushkin.toml"), PERMISSIVE)?;
    let run = hook_in(dir.path(), &edit_payload("sub/pushkin.toml"), None)?;
    assert!(
        denied_as_nested_manifest(&run),
        "an Edit to sub/pushkin.toml must deny under pushkin.nested_manifest; \
         got:\n{}",
        run.out
    );
    Ok(())
}

#[test]
fn a_deeply_nested_manifest_is_denied() -> TestResult {
    let dir = git_repo()?;
    fs::create_dir_all(dir.path().join("a/b/c"))?;
    let run = hook_in(dir.path(), &write_payload("a/b/c/pushkin.toml"), None)?;
    assert!(
        denied_as_nested_manifest(&run),
        "the name is protected wherever it appears, not just one level down; \
         got:\n{}",
        run.out
    );
    Ok(())
}

// ---------- requirement 2: the governing manifest keeps its treatment ----------

#[test]
fn the_root_manifest_is_not_denied_by_the_nested_rule() -> TestResult {
    // The root pushkin.toml IS the governing manifest, so the nested rule must
    // not fire on it — and must not double-report alongside whatever treatment
    // the root already has. This repo's own protected_paths does not list
    // pushkin.toml, so a write to the governing manifest is allowed here; the
    // nested rule must not change that.
    let dir = git_repo()?;
    let run = hook_in(dir.path(), &write_payload("pushkin.toml"), None)?;
    assert!(
        !run.out.contains("pushkin.nested_manifest"),
        "the governing root manifest must never trip the nested rule; got:\n{}",
        run.out
    );
    Ok(())
}

// ---------- requirement 5: PUSHKIN_MANIFEST elsewhere must still work ----------

#[test]
fn a_pushkin_manifest_named_manifest_elsewhere_is_allowed() -> TestResult {
    // The interaction most likely to be got wrong. When PUSHKIN_MANIFEST points
    // AT sub/pushkin.toml, that file IS the governing manifest — writing to it is
    // the supported override workflow and must not be denied by the nested rule.
    let dir = git_repo()?;
    let nested = dir.path().join("sub/pushkin.toml");
    fs::write(&nested, PERMISSIVE)?;
    let run = hook_in(
        dir.path(),
        &write_payload("sub/pushkin.toml"),
        Some(("PUSHKIN_MANIFEST", &nested.display().to_string())),
    )?;
    assert!(
        !run.out.contains("pushkin.nested_manifest"),
        "a PUSHKIN_MANIFEST-named manifest is the governing one and must be \
         writable — the override is a supported workflow; got:\n{}",
        run.out
    );
    Ok(())
}

// ---------- scope guard: an ordinary nested file is untouched ----------

#[test]
fn an_ordinary_nested_file_is_not_denied() -> TestResult {
    // The rule keys on the manifest FILENAME, not on being nested. A file that is
    // not named pushkin.toml under sub/ is ordinary and must pass.
    let dir = git_repo()?;
    let run = hook_in(dir.path(), &write_payload("sub/config.toml"), None)?;
    assert!(
        !run.out.contains("pushkin.nested_manifest"),
        "a nested file that is not the manifest must not trip the rule; got:\n{}",
        run.out
    );
    Ok(())
}