use assert_cmd::Command; use std::fs; /// Helper: create a temp project directory with a `.botsecrets` config and /// a `.env` file containing the given secrets. fn setup_project( env_content: &str, botsecrets_content: Option<&str>, ) -> tempfile::TempDir { let tmp = tempfile::tempdir().unwrap(); // .env with test secrets fs::write(tmp.path().join(".env"), env_content).unwrap(); // .botsecrets config (use default if not specified) let botsecrets = botsecrets_content.unwrap_or( r#" [files] patterns = [".env"] "#, ); fs::write(tmp.path().join(".botsecrets"), botsecrets).unwrap(); // .botignore (empty — required for project root detection) fs::write(tmp.path().join(".botignore"), "").unwrap(); tmp } #[test] fn post_tool_use_redacts_known_secret() { let tmp = setup_project("DB_PASSWORD=supersecret123\n", None); let payload = serde_json::json!({ "tool_name": "Read", "tool_input": { "file_path": "/some/file.txt" }, "tool_response": "DB_HOST=localhost\nDB_PASSWORD=supersecret123\nDB_PORT=5432" }) .to_string(); let out = Command::cargo_bin("fermata") .unwrap() .args(["hook", "--event", "post-tool-use", "--harness", "claude"]) .current_dir(tmp.path()) .write_stdin(payload) .assert() .success() .get_output() .stdout .clone(); let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); let updated = v["hookSpecificOutput"]["updatedToolOutput"] .as_str() .expect("expected updatedToolOutput"); assert!( updated.contains("*****"), "expected masked secret, got: {updated}" ); assert!( !updated.contains("supersecret123"), "secret should be redacted, got: {updated}" ); assert!( updated.contains("DB_HOST=localhost"), "non-secret lines should be preserved, got: {updated}" ); assert!( updated.contains("DB_PORT=5432"), "non-secret lines should be preserved, got: {updated}" ); } #[test] fn post_tool_use_no_secrets_passthrough() { let tmp = setup_project("DB_PASSWORD=supersecret123\n", None); let payload = serde_json::json!({ "tool_name": "Read", "tool_input": { "file_path": "/some/file.txt" }, "tool_response": "Hello, world! This text has no secrets." }) .to_string(); let out = Command::cargo_bin("fermata") .unwrap() .args(["hook", "--event", "post-tool-use", "--harness", "claude"]) .current_dir(tmp.path()) .write_stdin(payload) .assert() .success() .get_output() .stdout .clone(); let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); // Empty JSON object means "no changes". assert_eq!(v, serde_json::json!({}), "expected empty JSON for passthrough"); } #[test] fn post_tool_use_empty_response_passthrough() { let tmp = setup_project("DB_PASSWORD=supersecret123\n", None); let payload = serde_json::json!({ "tool_name": "Read", "tool_input": { "file_path": "/some/file.txt" }, "tool_response": "" }) .to_string(); let out = Command::cargo_bin("fermata") .unwrap() .args(["hook", "--event", "post-tool-use", "--harness", "claude"]) .current_dir(tmp.path()) .write_stdin(payload) .assert() .success() .get_output() .stdout .clone(); let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); assert_eq!(v, serde_json::json!({})); } #[test] fn post_tool_use_heuristic_enforce_appends_warning() { // Use a config with heuristic in enforce mode (the default). let botsecrets = r#" [files] patterns = [".env"] [heuristic] enabled = true mode = "enforce" "#; let tmp = setup_project("UNRELATED_KEY=foo\n", Some(botsecrets)); // Include something that looks like a GitHub PAT (classic) in the response. // Pattern requires `ghp_` followed by exactly 36 alphanumeric chars. let payload = serde_json::json!({ "tool_name": "Bash", "tool_input": { "command": "cat output.log" }, "tool_response": "deploy log: token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij used" }) .to_string(); let out = Command::cargo_bin("fermata") .unwrap() .args(["hook", "--event", "post-tool-use", "--harness", "claude"]) .current_dir(tmp.path()) .write_stdin(payload) .assert() .success() .get_output() .stdout .clone(); let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); let updated = v["hookSpecificOutput"]["updatedToolOutput"] .as_str() .expect("expected updatedToolOutput with heuristic warning"); assert!( updated.contains("[fermata] WARNING"), "expected heuristic warning, got: {updated}" ); } #[test] fn pre_tool_use_backward_compat_default_event() { // `--event` defaults to pre-tool-use; existing `--harness claude` still works. let tmp = tempfile::tempdir().unwrap(); fs::write(tmp.path().join(".botignore"), ".env\n").unwrap(); let target = tmp.path().join(".env"); fs::write(&target, "").unwrap(); let payload = serde_json::json!({ "tool_name": "Read", "tool_input": { "file_path": target.to_str().unwrap() } }) .to_string(); let out = Command::cargo_bin("fermata") .unwrap() .args(["hook", "--harness", "claude"]) .write_stdin(payload) .assert() .success() .get_output() .stdout .clone(); let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny"); } #[test] fn pre_tool_use_explicit_event_flag() { // Explicitly passing `--event pre-tool-use` works identically. let tmp = tempfile::tempdir().unwrap(); fs::write(tmp.path().join(".botignore"), ".env\n").unwrap(); let target = tmp.path().join("safe.txt"); fs::write(&target, "").unwrap(); let payload = serde_json::json!({ "tool_name": "Read", "tool_input": { "file_path": target.to_str().unwrap() } }) .to_string(); let out = Command::cargo_bin("fermata") .unwrap() .args(["hook", "--event", "pre-tool-use", "--harness", "claude"]) .write_stdin(payload) .assert() .success() .get_output() .stdout .clone(); let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow"); } #[test] fn unknown_event_exits_2() { Command::cargo_bin("fermata") .unwrap() .args(["hook", "--event", "nonsense", "--harness", "claude"]) .write_stdin("{}") .assert() .code(2); } #[test] fn post_tool_use_no_project_root_passthrough() { // When run in a directory with no .botignore / .botsecrets, // PostToolUse should fail-open with `{}`. let tmp = tempfile::tempdir().unwrap(); let payload = serde_json::json!({ "tool_name": "Read", "tool_input": { "file_path": "/some/file.txt" }, "tool_response": "DB_PASSWORD=supersecret123" }) .to_string(); let out = Command::cargo_bin("fermata") .unwrap() .args(["hook", "--event", "post-tool-use", "--harness", "claude"]) .current_dir(tmp.path()) .write_stdin(payload) .assert() .success() .get_output() .stdout .clone(); let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); assert_eq!(v, serde_json::json!({})); } #[test] fn post_tool_use_multiple_secrets_redacted() { let tmp = setup_project( "DB_PASSWORD=supersecret123\nAPI_KEY=my-api-key-abc\n", None, ); let payload = serde_json::json!({ "tool_name": "Read", "tool_input": { "file_path": "/some/config" }, "tool_response": "config: password=supersecret123, key=my-api-key-abc, host=localhost" }) .to_string(); let out = Command::cargo_bin("fermata") .unwrap() .args(["hook", "--event", "post-tool-use", "--harness", "claude"]) .current_dir(tmp.path()) .write_stdin(payload) .assert() .success() .get_output() .stdout .clone(); let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); let updated = v["hookSpecificOutput"]["updatedToolOutput"] .as_str() .expect("expected updatedToolOutput"); assert!(!updated.contains("supersecret123"), "first secret should be redacted"); assert!(!updated.contains("my-api-key-abc"), "second secret should be redacted"); assert!(updated.contains("host=localhost"), "non-secret should be preserved"); }