Files
mcp/rust/tests/test_shell_packages.rs
zeekay ee27c12905 shell: a long command is read on a stack its depth fits; npx and uvx take approve
The walk recurses with a command's nesting, and a flat chain nests as
deep as it is long, so a hostile command overflowed the caller's stack
and aborted the server (the classifier's own deep-nesting test died
under a debug build). A command over 64 bytes is now read on a thread
with a 256 MiB stack, reserved and not committed, and one over 64 KiB
is refused unread as unparsed.

npx and uvx run through exec, whose classifier asks before a package is
fetched, and they had no way to pass the approval: since the merge
neither could run at all. They take approve as exec does.

The hostile-text bound is 3 s for an optimized build and 15 s for an
unoptimized one on a loaded host; it catches a quadratic read, which
takes minutes. The unified surface lists the kai tool beside the eight
purpose-built ones.
2026-10-14 17:24:56 -07:00

36 lines
1.7 KiB
Rust

//! npx, uvx and jq as python-sdk offers them: a package run is an exec of its
//! argv (in exec's process table), and jq takes its filter as one argv word.
use hanzo_mcp::ToolRegistry;
use serde_json::{json, Value};
async fn call(name: &str, args: Value) -> hanzo_mcp::ToolResult {
ToolRegistry::with_defaults().execute(name, args).await.unwrap()
}
#[tokio::test]
async fn npx_runs_its_argv_through_exec() {
// A package run fetches from the network, so exec's classifier asks before it runs.
let asked = ToolRegistry::with_defaults().execute("npx", json!({"package": "--version", "yes": false})).await;
assert!(asked.as_ref().is_err_and(|e| e.to_string().contains("approval required")), "{asked:?}");
let r = call("npx", json!({"package": "--version", "yes": false, "approve": true})).await;
assert_eq!(r.content["status"], "success", "{:?}", r.content);
assert!(r.content["stdout"].as_str().unwrap().trim().chars().next().unwrap().is_ascii_digit());
let defs = ToolRegistry::with_defaults().get_definitions();
for t in ["npx", "uvx", "jq"] {
assert!(defs.iter().any(|d| d["name"] == t), "{t} is offered");
}
}
#[tokio::test]
async fn jq_filters_without_a_shell() {
let r = call("jq", json!({"filter": ".items[] | select(.on != false) | .name", "input": r#"{"items":[{"name":"a","on":true},{"name":"b","on":false}]}"#, "raw": true})).await;
assert!(r.success, "{:?}", r.error);
assert_eq!(r.content, json!("a"));
let bad = call("jq", json!({"filter": ".", "input": "{nope"})).await;
assert!(bad.error.unwrap().starts_with("Error: Invalid JSON input"));
let none = call("jq", json!({"filter": "."})).await;
assert_eq!(none.error.unwrap(), "Error: Either 'input' or 'file' is required");
}