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/**"]
retrieval_paths = ["crates/**/*.rs"]
retrieval_tool = "mcp__codebase-retrieval__codebase-retrieval"
"#;
const PROTECTED: &str = "pushkin.toml";
const COMMITTED_TEST: &str = "crates/pushkin-cli/tests/committed_suite.rs";
const UNCOMMITTED_TEST: &str = "crates/pushkin-cli/tests/brand_new_suite.rs";
const SOURCE: &str = "crates/pushkin-cli/src/main.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 FAIL_OPEN: &str = "failing open";
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("crates/pushkin-cli/src"))?;
fs::write(dir.path().join(SOURCE), "fn main() {}\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=F60 Suite",
"-c",
"user.email=f60@test",
"commit",
"-qm",
"fixture",
],
)?;
fs::write(dir.path().join(UNCOMMITTED_TEST), "// brand new\n")?;
Ok(dir)
}
fn unreadable(target: &str) -> String {
serde_json::json!({
"session_id": "f60",
"hook_event_name": "PreToolUse",
"tool_name": "SomeToolPushkinHasNeverHeardOf",
"tool_input": { "target": target },
})
.to_string()
}
fn hook(dir: &Path, payload: &str) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::cargo_bin("pushkin")?
.current_dir(dir)
.write_stdin(payload.to_owned())
.env("PUSHKIN_DAEMON", "off")
.args(["hook", "claude"])
.output()?;
Ok(format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
))
}
fn check(dir: &Path, payload: &str) -> Result<(Option<i32>, String), Box<dyn std::error::Error>> {
let output = Command::cargo_bin("pushkin")?
.current_dir(dir)
.write_stdin(payload.to_owned())
.arg("check")
.output()?;
Ok((
output.status.code(),
format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
),
))
}
#[test]
fn an_unreadable_payload_naming_a_protected_path_is_denied() -> TestResult {
let dir = repo()?;
let output = hook(dir.path(), &unreadable(PROTECTED))?;
assert!(
output.contains(RULE_PROTECTED),
"unreadable AND naming the gate's own surface must deny: {output}"
);
assert!(
!output.contains(FAIL_OPEN),
"and must not also announce a fail-open: {output}"
);
Ok(())
}
#[test]
fn an_unreadable_payload_naming_a_committed_test_is_denied() -> TestResult {
let dir = repo()?;
let output = hook(dir.path(), &unreadable(COMMITTED_TEST))?;
assert!(output.contains(RULE_READ_ONLY), "{output}");
Ok(())
}
#[test]
fn a_path_buried_in_free_text_is_found() -> TestResult {
let dir = repo()?;
let payload = serde_json::json!({
"session_id": "f60",
"hook_event_name": "PreToolUse",
"tool_name": "MysteryPatcher",
"tool_input": {
"body": format!("*** Begin Patch\n*** Delete File: {PROTECTED}\n*** End Patch\n"),
},
})
.to_string();
let output = hook(dir.path(), &payload)?;
assert!(output.contains(RULE_PROTECTED), "{output}");
Ok(())
}
#[test]
fn a_path_nested_deep_in_the_payload_is_found() -> TestResult {
let dir = repo()?;
let payload = serde_json::json!({
"session_id": "f60",
"hook_event_name": "PreToolUse",
"tool_name": "MysteryNester",
"tool_input": { "a": { "b": [{ "c": PROTECTED }] } },
})
.to_string();
let output = hook(dir.path(), &payload)?;
assert!(output.contains(RULE_PROTECTED), "{output}");
Ok(())
}
#[test]
fn an_absolute_spelling_of_a_gated_path_is_found() -> TestResult {
let dir = repo()?;
let absolute = dir
.path()
.canonicalize()?
.join(PROTECTED)
.to_string_lossy()
.into_owned();
let output = hook(dir.path(), &unreadable(&absolute))?;
assert!(output.contains(RULE_PROTECTED), "{output}");
Ok(())
}
#[test]
fn an_unreadable_payload_naming_nothing_gated_still_fails_open() -> TestResult {
let dir = repo()?;
let output = hook(dir.path(), &unreadable(ORDINARY))?;
assert!(
output.contains(FAIL_OPEN),
"an unmodelled tool touching nothing gated must still pass: {output}"
);
Ok(())
}
#[test]
fn a_retrieval_gated_source_file_does_not_fail_closed() -> TestResult {
let dir = repo()?;
let output = hook(dir.path(), &unreadable(SOURCE))?;
assert!(
output.contains(FAIL_OPEN),
"read gating must not be enforced through the unreadable-payload rule: {output}"
);
Ok(())
}
#[test]
fn a_mapped_contract_path_does_not_fail_closed() -> TestResult {
let dir = repo()?;
let output = hook(dir.path(), &unreadable(MAPPED))?;
assert!(
output.contains(FAIL_OPEN),
"content rules cannot be decided from an unreadable payload: {output}"
);
Ok(())
}
#[test]
fn an_uncommitted_file_under_a_read_only_glob_still_fails_open() -> TestResult {
let dir = repo()?;
let output = hook(dir.path(), &unreadable(UNCOMMITTED_TEST))?;
assert!(
output.contains(FAIL_OPEN),
"N10 gates committed suites; a new file is the authoring window: {output}"
);
Ok(())
}
#[test]
fn a_payload_that_is_not_even_json_still_fails_open() -> TestResult {
let dir = repo()?;
let output = hook(dir.path(), "this is not json at all")?;
assert!(output.contains(FAIL_OPEN), "{output}");
Ok(())
}
#[test]
fn the_check_verb_fails_closed_on_the_same_payload() -> TestResult {
let dir = repo()?;
let (code, output) = check(dir.path(), &unreadable(PROTECTED))?;
assert_eq!(code, Some(2), "check signals a block with exit 2: {output}");
assert!(output.contains(RULE_PROTECTED), "{output}");
Ok(())
}
#[test]
fn the_check_verb_still_fails_open_on_an_ungated_target() -> TestResult {
let dir = repo()?;
let (code, output) = check(dir.path(), &unreadable(ORDINARY))?;
assert_eq!(code, Some(0), "{output}");
Ok(())
}
#[test]
fn a_recognized_write_is_unaffected() -> TestResult {
let dir = repo()?;
let payload = serde_json::json!({
"session_id": "f60",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": { "file_path": ORDINARY, "content": "still notes\n" },
})
.to_string();
let output = hook(dir.path(), &payload)?;
assert!(
!output.contains(RULE_PROTECTED) && !output.contains(FAIL_OPEN),
"a recognized, ungated write is allowed as before: {output}"
);
Ok(())
}
#[test]
fn a_recognized_protected_write_still_denies_normally() -> TestResult {
let dir = repo()?;
let payload = serde_json::json!({
"session_id": "f60",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": { "file_path": PROTECTED, "content": "tampered\n" },
})
.to_string();
let output = hook(dir.path(), &payload)?;
assert!(output.contains(RULE_PROTECTED), "{output}");
assert!(!output.contains(FAIL_OPEN), "{output}");
Ok(())
}