pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 5 task 4 conformance: worktree policy (spec ยง9). For
//! orchestrators spawning short-lived worktrees, detachment layers in
//! order: per-worktree marker file (`.pushkinignore-self`) โ†’ user
//! denylist file (glob-per-line, hot-reloaded on mtime, dry-run `test`
//! verb) โ†’ env kill-switch. Explicit attach always beats policy. Detached
//! = the gate stands down (allow, silence) in that worktree. Committed
//! first, read-only hereafter (charter N10).

use assert_cmd::Command;
use std::fs;
use std::path::Path;

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

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

fn repo() -> Option<tempfile::TempDir> {
    let dir = tempfile::tempdir().ok()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST).ok()?;
    Some(dir)
}

/// Claude-hook write of a NONCONFORMING file with extra env; None =
/// allowed (silence), Some(reason) = denied. The write is always a
/// violation, so "allowed" can only mean the policy detached the gate.
fn hook_write(dir: &Path, envs: &[(&str, &str)]) -> Option<String> {
    let payload = serde_json::json!({
        "session_id": "worktree-suite",
        "tool_name": "Write",
        "tool_input": { "file_path": "app/api/users/route.ts", "content": NONCONFORMING }
    })
    .to_string();
    let mut cmd = Command::cargo_bin("pushkin").ok()?;
    cmd.args(["hook", "claude"]).current_dir(dir);
    for (key, value) in envs {
        cmd.env(key, value);
    }
    let output = cmd.write_stdin(payload).output().ok()?;
    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    if stdout.trim().is_empty() {
        return None;
    }
    let json: serde_json::Value = serde_json::from_str(&stdout).ok()?;
    Some(
        json["hookSpecificOutput"]["permissionDecisionReason"]
            .as_str()?
            .to_owned(),
    )
}

fn policy_test(dir: &Path, envs: &[(&str, &str)]) -> Option<(String, i32)> {
    let mut cmd = Command::cargo_bin("pushkin").ok()?;
    cmd.args(["policy", "test"]).current_dir(dir);
    for (key, value) in envs {
        cmd.env(key, value);
    }
    let output = cmd.output().ok()?;
    Some((
        String::from_utf8_lossy(&output.stdout).into_owned(),
        output.status.code().unwrap_or(-1),
    ))
}

#[test]
fn marker_file_detaches_worktree() {
    let dir = repo().unwrap();
    // Sanity: attached, the violation denies.
    assert!(
        hook_write(dir.path(), &[]).is_some(),
        "precondition: attached worktree must deny the violation"
    );
    fs::write(dir.path().join(".pushkinignore-self"), "").unwrap();
    assert!(
        hook_write(dir.path(), &[]).is_none(),
        "marker file must detach: the gate stands down in this worktree"
    );
}

#[test]
fn denylist_glob_detaches_and_hot_reloads_on_mtime() {
    let dir = repo().unwrap();
    let denylist = tempfile::NamedTempFile::new().unwrap();
    let env: &[(&str, &str)] = &[(
        "PUSHKIN_WORKTREE_DENYLIST",
        denylist.path().to_str().unwrap(),
    )];
    // Empty denylist: attached, denies.
    assert!(
        hook_write(dir.path(), env).is_some(),
        "empty denylist must leave the worktree attached"
    );
    // Add a glob covering this worktree: detached.
    fs::write(denylist.path(), format!("{}/**\n", dir.path().display())).unwrap();
    assert!(
        hook_write(dir.path(), env).is_none(),
        "denylist glob must detach the matching worktree"
    );
    // Remove it again (mtime changes): re-attached โ€” the reload is live.
    fs::write(denylist.path(), "# nothing denied\n").unwrap();
    assert!(
        hook_write(dir.path(), env).is_some(),
        "clearing the denylist must re-attach (hot reload on mtime)"
    );
}

#[test]
fn env_kill_switch_detaches_everything() {
    let dir = repo().unwrap();
    assert!(
        hook_write(dir.path(), &[("PUSHKIN_DISABLE", "1")]).is_none(),
        "env kill-switch must detach regardless of marker/denylist"
    );
}

#[test]
fn explicit_attach_beats_policy() {
    let dir = repo().unwrap();
    fs::write(dir.path().join(".pushkinignore-self"), "").unwrap();
    let reason = hook_write(dir.path(), &[("PUSHKIN_ATTACH", "1")]);
    assert!(
        reason.is_some(),
        "explicit attach must override the marker and gate the write"
    );
    // ... and the kill-switch too: explicit attach beats ALL policy.
    let reason = hook_write(
        dir.path(),
        &[("PUSHKIN_ATTACH", "1"), ("PUSHKIN_DISABLE", "1")],
    );
    assert!(
        reason.is_some(),
        "explicit attach must beat the env kill-switch as well"
    );
}

#[test]
fn policy_test_verb_dry_runs() {
    let dir = repo().unwrap();
    let (stdout, code) = policy_test(dir.path(), &[]).unwrap();
    assert_eq!(code, 0);
    assert!(
        stdout.contains("attached"),
        "clean worktree must report attached: {stdout}"
    );

    fs::write(dir.path().join(".pushkinignore-self"), "").unwrap();
    let (stdout, _) = policy_test(dir.path(), &[]).unwrap();
    assert!(
        stdout.contains("detached") && stdout.contains(".pushkinignore-self"),
        "policy test must report detached and name the deciding layer: {stdout}"
    );
    // Dry-run means: no gate behavior changed by running it, and the
    // violating write in a DIFFERENT (attached) worktree still denies.
    let attached = repo().unwrap();
    assert!(
        hook_write(attached.path(), &[]).is_some(),
        "policy test must not mutate global gate state"
    );
}