Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions src/hooks/integrity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,13 +331,16 @@ fn runtime_check_in(hook_path: Result<PathBuf>) -> Result<()> {
return Ok(());
};

// If the legacy script doesn't exist, skip integrity check entirely.
// In the new binary command model, there is no script file to verify.
if !hook_path.exists() {
return Ok(());
}

match verify_hook_at(&hook_path)? {
// No early return when the legacy script is missing: verification still
// resolves `NotInstalled` (silent) or `OrphanedHash` (the warning documented
// above). The binary command model lands in `NotInstalled`.
let status = verify_hook_at(&hook_path).map_err(|err| {
// A damaged baseline stops the command with the error alone. Name the
// command that rewrites it, the way the `Tampered` arm below does.
anyhow::anyhow!("{err:#}\n To restore: rtk init -g --auto-patch")
})?;

match status {
IntegrityStatus::Verified | IntegrityStatus::NotInstalled => {
// All good, proceed
}
Expand All @@ -356,7 +359,7 @@ fn runtime_check_in(hook_path: Result<PathBuf>) -> Result<()> {
actual.get(..16).unwrap_or(&actual)
);
eprintln!();
eprintln!(" The hook at ~/.claude/hooks/rtk-rewrite.sh has been modified.");
eprintln!(" The hook at {} has been modified.", hook_path.display());
eprintln!(" This may indicate tampering. RTK will not execute.");
eprintln!();
eprintln!(" To restore: rtk init -g --auto-patch");
Expand Down Expand Up @@ -415,6 +418,35 @@ mod tests {
assert!(err.to_string().contains("Invalid hash format"));
}

#[test]
fn runtime_check_bad_baseline_error_names_restore_command() {
let temp = TempDir::new().unwrap();
let hooks = temp.path().join(HOOKS_SUBDIR);
fs::create_dir(&hooks).unwrap();
fs::write(hooks.join(REWRITE_HOOK_FILE), "#!/bin/sh\n").unwrap();
fs::write(hooks.join(HASH_FILENAME), "not a hash").unwrap();
let explicit =
super::super::init::resolve_claude_dir_from(Some(temp.path().to_path_buf()), None);
let hook_path = explicit.map(|dir| dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE));
let err = runtime_check_in(hook_path).unwrap_err();
assert!(err.to_string().contains("Invalid hash format"));
assert!(err.to_string().contains("rtk init -g --auto-patch"));
}

#[test]
fn runtime_check_orphaned_baseline_continues() {
let temp = TempDir::new().unwrap();
let hooks = temp.path().join(HOOKS_SUBDIR);
fs::create_dir(&hooks).unwrap();
let baseline = format!("{} {}\n", "0".repeat(64), REWRITE_HOOK_FILE);
fs::write(hooks.join(HASH_FILENAME), baseline).unwrap();
let explicit =
super::super::init::resolve_claude_dir_from(Some(temp.path().to_path_buf()), None);
let hook_path = explicit.map(|dir| dir.join(HOOKS_SUBDIR).join(REWRITE_HOOK_FILE));
// Baseline without a hook: the documented warning, never a hard stop.
assert!(runtime_check_in(hook_path).is_ok());
}

#[test]
fn test_compute_hash_deterministic() {
let temp = TempDir::new().unwrap();
Expand Down
20 changes: 18 additions & 2 deletions tests/fallback_integrity_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ fn sandbox(state: &str) -> tempfile::TempDir {
let hooks = root.join(".claude/hooks");
std::fs::create_dir_all(&hooks).expect("write isolated fixture");
let original = b"#!/bin/sh\n# original fixture\n";
if state != "missing" {
if !matches!(state, "missing" | "orphaned") {
let contents: &[u8] = if state == "tampered" {
b"#!/bin/sh\n# modified fixture\n"
} else {
original
};
std::fs::write(hooks.join("rtk-rewrite.sh"), contents).expect("write isolated fixture");
}
if matches!(state, "verified" | "tampered") {
if matches!(state, "verified" | "tampered" | "orphaned") {
std::fs::write(
hooks.join(".rtk-hook.sha256"),
format!("{:x} rtk-rewrite.sh\n", Sha256::digest(original)),
Expand Down Expand Up @@ -127,6 +127,22 @@ fn fallback_propagates_invalid_baseline_errors() {
assert!(!dir.path().join("called").exists());
}

#[test]
fn fallback_warns_on_orphaned_baseline_without_blocking() {
// Baseline present, hook gone: the warning must reach stderr and the
// command must still run (issue #5039).
let dir = sandbox("orphaned");
let out = run(dir.path(), &["make", "all"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(7), "{stderr}");
assert!(
stderr.contains("hash file exists but hook is missing"),
"{stderr}"
);
assert!(!stderr.contains("hook integrity check FAILED"), "{stderr}");
assert!(dir.path().join("called").exists());
}

#[test]
fn fallback_checks_integrity_before_agent_dispatch() {
let dir = sandbox("tampered");
Expand Down