pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! F73 phase 3 — the daemon reloads only the manifest that governs.
//!
//! Ruling: `docs/claude_ruling-f73-manifest-resolution.md` Addendum 4, which reverses Addendum
//! 3 §4's sequencing. A NEW file per N10.
//!
//! **The defect, measured before this suite existed.** `warm.rs`'s `handle_change` reloaded
//! whenever a changed file was *named* `pushkin.toml`, and it reloaded **from that path**, with
//! no check that it was the resolved manifest. So:
//!
//! ```text
//! daemon serving the root manifest    warm -> deny
//! touch a nested sub/pushkin.toml     warm -> ALLOW
//! daemon stopped                       cold -> deny
//! ```
//!
//! **Why that is worse than a wrong verdict.** Writing `sub/pushkin.toml` is an *allowed* write
//! even with `[gates]` armed — `protected_paths = ["pushkin.toml"]` does not match a nested
//! path, and `read_only_paths` covers `crates/**/tests/**`, which a nested manifest is not. So
//! the full chain is: the agent writes a file the gate permits, the daemon adopts it, and every
//! subsequent write is judged warm against rules the agent supplied. The gate is disarmed by a
//! write the gate allows.
//!
//! **The predicate.** Reload only when the changed path *is* the resolved manifest. Resolution
//! was settled in phase 2; reload merely has to use it.
//!
//! **This suite lives in `pushkin-cli`, not `pushkin-daemon`**, because the contract under test
//! is the end-to-end **warm verdict** — what a shim actually receives — not the predicate
//! function in isolation. `pushkin-daemon`'s own `warm.rs` suite covers the unit; what was
//! missing was the integration, and F74's path disclosure is what makes it writable: a test can
//! now assert "the warm daemon answered, and it said X" and mean both halves.
//!
//! **The declined reload is asserted, not just the non-effect.** A nested manifest changing and
//! nothing happening is indistinguishable from the watcher being dead. Per the standing rule —
//! *every degradation is recorded* — the daemon says it declined, and this suite pins that. That
//! record is what would have turned F74's three-mechanism investigation into one run.

use assert_cmd::Command as AssertCommand;
use std::fs;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

type TestResult = Result<(), Box<dyn std::error::Error>>;

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

/// Maps nothing the payload touches, so a daemon that adopts it answers
/// `allow` where the root manifest answers `deny`. That divergence is the
/// whole signal.
const PERMISSIVE_MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"

[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod"]

[[mappings]]
glob = "nothing/**/*.ts"
contracts = ["user"]
require = "boundary-validation"

[gates]
"#;

const NONCONFORMING: &str = "export async function POST(req: Request) {\n\
  const body = await req.json();\n\
  return Response.json({ name: body.name });\n\
}\n";

const HANDLER_PATH: &str = "app/api/users/route.ts";

fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), ROOT_MANIFEST)?;
    fs::create_dir_all(dir.path().join("contracts"))?;
    fs::write(
        dir.path().join("contracts/user.zod.ts"),
        "export const UserCreateSchema = 1;\n",
    )?;
    Ok(dir)
}

fn payload() -> String {
    serde_json::json!({
        "session_id": "f73p3",
        "tool_name": "Write",
        "tool_input": { "file_path": HANDLER_PATH, "content": NONCONFORMING }
    })
    .to_string()
}

/// The verdict a shim receives, plus F74's disclosure of which path served.
struct Verdict {
    decision: String,
    served_warm: bool,
}

fn ask(dir: &Path, manifest_env: Option<&str>) -> Result<Verdict, Box<dyn std::error::Error>> {
    let mut command = AssertCommand::cargo_bin("pushkin")?;
    command.current_dir(dir).args(["hook", "claude"]);
    command.env_remove("PUSHKIN_DAEMON");
    if let Some(value) = manifest_env {
        command.env("PUSHKIN_MANIFEST", value);
    }
    let output = command.write_stdin(payload()).output()?;
    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
    // An allow prints nothing on this surface; a deny prints the envelope.
    let decision = if stdout.trim().is_empty() {
        "allow".to_owned()
    } else {
        let json: serde_json::Value = serde_json::from_str(&stdout)?;
        json["hookSpecificOutput"]["permissionDecision"]
            .as_str()
            .unwrap_or("unparseable")
            .to_owned()
    };
    Ok(Verdict {
        decision,
        served_warm: stderr.contains("warm daemon"),
    })
}

/// A real `pushkin daemon serve` with its stderr piped, so the declined-reload
/// record can be read back. Killed and reaped by `Daemon::stop`.
struct Daemon {
    child: Child,
}

impl Daemon {
    fn start(dir: &Path, manifest_env: Option<&str>) -> Result<Self, Box<dyn std::error::Error>> {
        let exe = AssertCommand::cargo_bin("pushkin")?;
        let mut command = Command::new(exe.get_program());
        command
            .current_dir(dir)
            .args(["daemon", "serve"])
            .stdout(Stdio::null())
            .stderr(Stdio::piped());
        if let Some(value) = manifest_env {
            command.env("PUSHKIN_MANIFEST", value);
        }
        let child = command.spawn()?;
        let socket = dir.join(".pushkin/daemon.sock");
        let deadline = Instant::now() + Duration::from_secs(10);
        while Instant::now() < deadline {
            if socket.exists() {
                return Ok(Self { child });
            }
            std::thread::sleep(Duration::from_millis(50));
        }
        Err("daemon never bound its socket".into())
    }

    fn stop(mut self) -> Result<String, Box<dyn std::error::Error>> {
        self.child.kill()?;
        let output = self.child.wait_with_output()?;
        Ok(String::from_utf8_lossy(&output.stderr).into_owned())
    }
}

/// Watchers are asynchronous; give the event time to arrive. Long enough that a
/// pass is meaningful, short enough that a suite of these stays usable.
fn settle() {
    std::thread::sleep(Duration::from_millis(1200));
}

// ---------- the resolved manifest still reloads ----------

#[test]
fn a_change_to_the_resolved_manifest_reloads_and_the_warm_verdict_follows() -> TestResult {
    let dir = repo()?;
    let daemon = Daemon::start(dir.path(), None)?;

    let before = ask(dir.path(), None)?;
    assert!(before.served_warm, "the daemon must be the one answering");
    assert_eq!(before.decision, "deny", "baseline under the root manifest");

    fs::write(dir.path().join("pushkin.toml"), PERMISSIVE_MANIFEST)?;
    settle();

    let after = ask(dir.path(), None)?;
    assert!(after.served_warm, "still warm after the reload");
    assert_eq!(
        after.decision, "allow",
        "the RESOLVED manifest changed, so the warm verdict must follow it"
    );
    let _ = daemon.stop()?;
    Ok(())
}

// ---------- the bypass ----------

#[test]
fn a_change_to_a_nested_manifest_does_not_reload() -> TestResult {
    let dir = repo()?;
    // Before the daemon starts: on Linux the watch is per-directory (inotify),
    // so a directory created afterwards races its own watch registration and
    // the write into it can go unseen. Pre-creating makes the event FIRE, which
    // is what forces the predicate to actually decline.
    fs::create_dir_all(dir.path().join("sub"))?;
    let daemon = Daemon::start(dir.path(), None)?;

    let before = ask(dir.path(), None)?;
    assert!(before.served_warm);
    assert_eq!(before.decision, "deny");

    fs::write(dir.path().join("sub/pushkin.toml"), PERMISSIVE_MANIFEST)?;
    settle();

    let after = ask(dir.path(), None)?;
    assert!(
        after.served_warm,
        "still warm — the daemon is still answering"
    );
    assert_eq!(
        after.decision, "deny",
        "a NESTED manifest is not the governing one; adopting it lets an agent \
         disarm the gate with a write the gate permits"
    );
    let _ = daemon.stop()?;
    Ok(())
}

#[test]
fn creating_a_nested_manifest_is_no_different_from_editing_one() -> TestResult {
    let dir = repo()?;
    fs::create_dir_all(dir.path().join("deep/er"))?;
    let daemon = Daemon::start(dir.path(), None)?;
    assert_eq!(ask(dir.path(), None)?.decision, "deny");

    // Created, not edited — the file did not exist when the watch attached.
    fs::write(dir.path().join("deep/er/pushkin.toml"), PERMISSIVE_MANIFEST)?;
    settle();

    let after = ask(dir.path(), None)?;
    assert!(after.served_warm);
    assert_eq!(
        after.decision, "deny",
        "creation and edit are the same event class; both must be declined"
    );
    let _ = daemon.stop()?;
    Ok(())
}

// ---------- the decline is RECORDED ----------

#[test]
fn the_daemon_records_that_it_declined_to_reload() -> TestResult {
    let dir = repo()?;
    // Pre-created so the inotify watch covers it — see the note above.
    fs::create_dir_all(dir.path().join("sub"))?;
    let daemon = Daemon::start(dir.path(), None)?;
    let _ = ask(dir.path(), None)?;

    fs::write(dir.path().join("sub/pushkin.toml"), PERMISSIVE_MANIFEST)?;
    settle();

    let log = daemon.stop()?;
    assert!(
        log.contains("declined"),
        "a nested manifest changing and nothing happening is indistinguishable \
         from a dead watcher; the daemon must SAY it declined: {log:?}"
    );
    assert!(
        log.contains("pushkin.toml"),
        "the record must name the file it declined, or it cannot be acted on: {log:?}"
    );
    Ok(())
}

// ---------- PUSHKIN_MANIFEST ----------

#[test]
fn under_pushkin_manifest_only_that_file_reloads() -> TestResult {
    let dir = repo()?;
    let elsewhere = dir.path().join("elsewhere.toml");
    fs::write(&elsewhere, ROOT_MANIFEST)?;
    let env = elsewhere.to_string_lossy().into_owned();

    let daemon = Daemon::start(dir.path(), Some(&env))?;
    assert_eq!(ask(dir.path(), Some(&env))?.decision, "deny");

    // The repo-root manifest is NOT the resolved one here. Changing it must
    // not move the verdict.
    fs::write(dir.path().join("pushkin.toml"), PERMISSIVE_MANIFEST)?;
    settle();
    assert_eq!(
        ask(dir.path(), Some(&env))?.decision,
        "deny",
        "PUSHKIN_MANIFEST is in force; the root manifest does not govern"
    );

    // The overridden file DOES.
    fs::write(&elsewhere, PERMISSIVE_MANIFEST)?;
    settle();
    let after = ask(dir.path(), Some(&env))?;
    assert!(after.served_warm);
    assert_eq!(
        after.decision, "allow",
        "the file PUSHKIN_MANIFEST names is the one that reloads"
    );
    let _ = daemon.stop()?;
    Ok(())
}

// ---------- warm and cold agree ----------

#[test]
fn warm_and_cold_agree_in_every_case() -> TestResult {
    let dir = repo()?;
    let cases: [(&str, &str); 3] = [
        ("baseline", ""),
        ("nested edited", "sub"),
        ("nested created deeper", "deep/er"),
    ];
    // Every nested directory exists before the watch attaches, so each write
    // below genuinely fires an event and the predicate is genuinely consulted.
    // Created inside the loop, this test passed on Linux without the predicate
    // running at all: the event was missed, the daemon kept the root rules, and
    // warm == cold held because neither side had moved.
    for (_, nested) in cases {
        if !nested.is_empty() {
            fs::create_dir_all(dir.path().join(nested))?;
        }
    }
    let daemon = Daemon::start(dir.path(), None)?;

    for (label, nested) in cases {
        if !nested.is_empty() {
            fs::write(
                dir.path().join(nested).join("pushkin.toml"),
                PERMISSIVE_MANIFEST,
            )?;
            settle();
        }
        let warm = ask(dir.path(), None)?;
        assert!(warm.served_warm, "{label}: the daemon must be answering");

        // Same question with the daemon bypassed entirely.
        let mut cold = AssertCommand::cargo_bin("pushkin")?;
        cold.current_dir(dir.path())
            .args(["hook", "claude"])
            .env("PUSHKIN_DAEMON", "off");
        let out = cold.write_stdin(payload()).output()?;
        let cold_decision = if String::from_utf8_lossy(&out.stdout).trim().is_empty() {
            "allow"
        } else {
            "deny"
        };
        assert_eq!(
            warm.decision, cold_decision,
            "{label}: warm and cold must agree — divergence between them IS \
             the defect class this pass closes"
        );
    }
    let _ = daemon.stop()?;
    Ok(())
}