use assert_cmd::Command;
use serde_json::{json, Value};
use std::fs;
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 SUPPRESSED_WRITE: &str = "export async function POST(req: Request) {\n // @ts-ignore\n const body = UserCreateSchema.parse(await req.json());\n return Response.json(body);\n}\n";
const LEGACY_WAIVERS: &str = "[[waivers]]\nid = \"legacy-w1\"\nrule = \"sentinel.suppression.new\"\npath = \"app/api/**\"\nreason = \"granted pre-rename\"\nauthor = \"human\"\ngranted_at = \"2026-08-01T00:00:00Z\"\nexpires_at = \"2099-01-01T00:00:00Z\"\n";
fn repo() -> std::io::Result<tempfile::TempDir> {
let dir = tempfile::tempdir()?;
fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
Ok(dir)
}
fn pushkin(dir: &tempfile::TempDir) -> Option<Command> {
let mut cmd = Command::cargo_bin("pushkin").ok()?;
cmd.current_dir(dir.path());
Some(cmd)
}
fn run(dir: &tempfile::TempDir, args: &[&str]) -> Option<(Option<i32>, String, String)> {
let output = pushkin(dir)?.args(args).output().ok()?;
Some((
output.status.code(),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
))
}
fn write_legacy_claude_settings(dir: &tempfile::TempDir) -> std::io::Result<()> {
let settings = json!({
"hooks": {
"PreToolUse": [
{
"_sentinel": "sentinel-v1",
"matcher": "Write|Edit|MultiEdit",
"hooks": [{ "type": "command", "command": "/old/path/sentinel hook claude" }]
},
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "my-own-hook" }]
}
],
"Stop": [
{
"_sentinel": "sentinel-v1",
"hooks": [{ "type": "command", "command": "/old/path/sentinel hook claude" }]
}
]
}
});
fs::create_dir_all(dir.path().join(".claude"))?;
fs::write(
dir.path().join(".claude/settings.json"),
settings.to_string(),
)
}
fn claude_settings(dir: &tempfile::TempDir) -> Option<Value> {
let text = fs::read_to_string(dir.path().join(".claude/settings.json")).ok()?;
serde_json::from_str(&text).ok()
}
#[test]
fn init_renames_legacy_state_and_waiver_dirs_once() {
let dir = repo().unwrap();
fs::create_dir_all(dir.path().join(".sentinel")).unwrap();
fs::write(dir.path().join(".sentinel/instructions.md"), "override").unwrap();
fs::create_dir_all(dir.path().join("sentinel")).unwrap();
fs::write(dir.path().join("sentinel/waivers.toml"), LEGACY_WAIVERS).unwrap();
let (code, stdout, stderr) = run(&dir, &["init"]).unwrap();
assert_eq!(code, Some(0), "init must succeed: {stdout}{stderr}");
assert!(
dir.path().join(".pushkin/instructions.md").exists(),
"state dir contents must survive the rename"
);
assert!(
!dir.path().join(".sentinel").exists(),
"old state dir must be gone after the one-time rename"
);
let waivers = fs::read_to_string(dir.path().join("pushkin/waivers.toml"))
.expect("waiver dir renamed with records preserved");
assert!(
waivers.contains("legacy-w1"),
"signed waiver history must survive the move: {waivers}"
);
assert!(
!dir.path().join("sentinel").exists(),
"old waiver dir must be gone after the one-time rename"
);
}
#[test]
fn init_refuses_loudly_when_both_state_dirs_exist() {
let dir = repo().unwrap();
fs::create_dir_all(dir.path().join(".sentinel")).unwrap();
fs::create_dir_all(dir.path().join(".pushkin")).unwrap();
let (code, stdout, stderr) = run(&dir, &["init"]).unwrap();
assert_ne!(code, Some(0), "init must refuse: {stdout}{stderr}");
let all = format!("{stdout}{stderr}");
assert!(
all.contains(".sentinel") && all.contains(".pushkin"),
"the refusal must name both directories: {all}"
);
}
#[test]
fn init_refuses_loudly_when_both_waiver_dirs_exist() {
let dir = repo().unwrap();
fs::create_dir_all(dir.path().join("sentinel")).unwrap();
fs::write(dir.path().join("sentinel/waivers.toml"), LEGACY_WAIVERS).unwrap();
fs::create_dir_all(dir.path().join("pushkin")).unwrap();
fs::write(dir.path().join("pushkin/waivers.toml"), "waivers = []\n").unwrap();
let (code, stdout, stderr) = run(&dir, &["init"]).unwrap();
assert_ne!(code, Some(0), "init must refuse: {stdout}{stderr}");
let all = format!("{stdout}{stderr}");
assert!(
all.contains("sentinel/") && all.contains("pushkin/"),
"the refusal must name both directories: {all}"
);
}
#[test]
fn doctor_repair_performs_the_one_time_rename() {
let dir = repo().unwrap();
fs::create_dir_all(dir.path().join(".sentinel")).unwrap();
fs::write(dir.path().join(".sentinel/instructions.md"), "override").unwrap();
let (_, stdout, stderr) = run(&dir, &["doctor", "--repair"]).unwrap();
assert!(
dir.path().join(".pushkin/instructions.md").exists(),
"doctor --repair must migrate legacy state dirs: {stdout}{stderr}"
);
assert!(!dir.path().join(".sentinel").exists());
}
#[test]
fn init_replaces_legacy_marker_entries_in_place() {
let dir = repo().unwrap();
write_legacy_claude_settings(&dir).unwrap();
let (code, stdout, stderr) = run(&dir, &["init"]).unwrap();
assert_eq!(code, Some(0), "init must succeed: {stdout}{stderr}");
let settings = claude_settings(&dir).unwrap();
let pre = settings["hooks"]["PreToolUse"]
.as_array()
.expect("PreToolUse entries");
let ours: Vec<&Value> = pre
.iter()
.filter(|entry| entry.get("_pushkin").is_some())
.collect();
assert_eq!(ours.len(), 1, "one pushkin entry, not a stack: {settings}");
assert!(
pre.iter().all(|entry| entry.get("_sentinel").is_none()),
"legacy-marker entries are OURS — replaced, never duplicated: {settings}"
);
assert!(
pre.iter()
.any(|entry| entry["hooks"][0]["command"] == "my-own-hook"),
"user-owned hooks must be preserved: {settings}"
);
}
#[test]
fn doctor_flags_legacy_marker_and_repair_migrates_it() {
let dir = repo().unwrap();
write_legacy_claude_settings(&dir).unwrap();
let (code, stdout, _) = run(&dir, &["doctor"]).unwrap();
assert_eq!(code, Some(1), "legacy marker is a finding: {stdout}");
assert!(
stdout.contains("legacy"),
"doctor must name the legacy marker: {stdout}"
);
let (code, stdout, stderr) = run(&dir, &["doctor", "--repair"]).unwrap();
assert_eq!(code, Some(0), "repair must succeed: {stdout}{stderr}");
let settings = claude_settings(&dir).unwrap();
let rendered = settings.to_string();
assert!(
!rendered.contains("_sentinel"),
"repair must replace the legacy entries: {rendered}"
);
assert!(
rendered.contains("_pushkin"),
"repair must install current-marker entries: {rendered}"
);
}
#[test]
fn remove_agent_strips_both_old_and_new_artifacts() {
let dir = repo().unwrap();
let hooks = json!({
"hooks": {
"PreToolUse": [
{ "_sentinel": "sentinel-v1",
"hooks": [{ "type": "command", "command": "/old/path/sentinel hook codex" }] },
{ "_pushkin": "pushkin-v1",
"hooks": [{ "type": "command", "command": "/new/path/pushkin hook codex" }] },
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "my-own-hook" }] }
]
}
});
fs::create_dir_all(dir.path().join(".codex/rules")).unwrap();
fs::write(
dir.path().join(".codex/hooks.json"),
serde_json::to_string_pretty(&hooks).unwrap(),
)
.unwrap();
fs::write(dir.path().join(".codex/rules/sentinel.rules"), "old\n").unwrap();
fs::write(dir.path().join(".codex/rules/pushkin.rules"), "new\n").unwrap();
let (code, stdout, stderr) = run(&dir, &["init", "--remove-agent", "codex"]).unwrap();
assert_eq!(code, Some(0), "remove must succeed: {stdout}{stderr}");
assert!(
!dir.path().join(".codex/rules/sentinel.rules").exists(),
"legacy execpolicy floor must be stripped"
);
assert!(
!dir.path().join(".codex/rules/pushkin.rules").exists(),
"current execpolicy floor must be stripped"
);
let text = fs::read_to_string(dir.path().join(".codex/hooks.json")).unwrap();
assert!(
!text.contains("_sentinel") && !text.contains("_pushkin"),
"both marker generations must be stripped: {text}"
);
assert!(
text.contains("my-own-hook"),
"user-owned entries must be preserved: {text}"
);
}
#[test]
fn migrated_waiver_still_suppresses_its_matching_deny() {
let dir = repo().unwrap();
fs::create_dir_all(dir.path().join("sentinel")).unwrap();
fs::write(dir.path().join("sentinel/waivers.toml"), LEGACY_WAIVERS).unwrap();
let (code, stdout, stderr) = run(&dir, &["init"]).unwrap();
assert_eq!(code, Some(0), "init must succeed: {stdout}{stderr}");
let payload = json!({
"session_id": "legacy-migration-suite",
"tool_name": "Write",
"tool_input": { "file_path": "app/api/users/route.ts", "content": SUPPRESSED_WRITE }
})
.to_string();
let output = pushkin(&dir)
.expect("binary resolves")
.args(["hook", "claude"])
.write_stdin(payload)
.output()
.expect("hook runs");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.trim().is_empty(),
"the pre-rename waiver must suppress the renamed rule's deny \
(Claude-family allow = silence): {stdout}"
);
}