fold/tests/unit/test_mcp_input_policy.cpp
trvon 86b2f14a43
feat(analysis): harden project-wide scans
- persist TU-owned graphs and guarded configuration evidence
- add closed-world production reachability classification
- calibrate detector precision and reviewed self-scan policy
- terminate poisoned workers instead of longjmp recovery
2026-07-30 18:18:04 -04:00

132 lines
5.6 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <nlohmann/json.hpp>
#include <stdexcept>
#include <string>
#include "fold/mcp/server_policy.hpp"
#include "fold/mcp/tool_registry.hpp"
namespace {
template <typename Function>
std::string invalidMessage(Function function) {
try {
function();
} catch (const std::invalid_argument& error) {
return error.what();
}
return {};
}
struct TempTree {
std::filesystem::path root =
std::filesystem::temp_directory_path() /
("fold-mcp-policy-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
TempTree() { std::filesystem::create_directories(root); }
~TempTree() { std::filesystem::remove_all(root); }
TempTree(const TempTree&) = delete;
TempTree& operator=(const TempTree&) = delete;
};
} // namespace
TEST_CASE("MCP schema validation rejects unknown, mistyped, enum, and bounded values",
"[mcp][schema]") {
const nlohmann::json schema = {
{"type", "object"},
{"additionalProperties", false},
{"properties",
{{"mode", {{"type", "string"}, {"enum", {"source", "binary"}}}},
{"count", {{"type", "integer"}, {"minimum", 1}, {"maximum", 4}}},
{"paths", {{"type", "array"}, {"items", {{"type", "string"}}}, {"maxItems", 2}}}}},
{"required", {"mode"}},
};
REQUIRE_NOTHROW(
fold::mcp::validateToolArguments(nlohmann::json{{"mode", "source"}, {"count", 4}}, schema));
REQUIRE(invalidMessage([&] {
fold::mcp::validateToolArguments(
nlohmann::json{{"mode", "source"}, {"unknown", true}}, schema);
}) == "field `unknown`: unknown property");
REQUIRE(invalidMessage([&] {
fold::mcp::validateToolArguments(nlohmann::json{{"mode", 1}}, schema);
}) == "field `mode`: expected string");
REQUIRE(invalidMessage([&] {
fold::mcp::validateToolArguments(nlohmann::json{{"mode", "hybrid"}}, schema);
}) == "field `mode`: expected one of `source`, `binary`");
REQUIRE(invalidMessage([&] {
fold::mcp::validateToolArguments(nlohmann::json{{"mode", "source"}, {"count", 5}},
schema);
}) == "field `count`: expected a value <= 4");
REQUIRE(invalidMessage([&] {
fold::mcp::validateToolArguments(
nlohmann::json{{"mode", "source"}, {"paths", {"a", "b", "c"}}}, schema);
}) == "field `paths`: expected at most 2 items");
}
TEST_CASE("MCP filesystem policy canonicalizes allowed paths and rejects escapes",
"[mcp][policy]") {
TempTree allowed;
TempTree outside;
const auto source = allowed.root / "input.cpp";
std::ofstream(source) << "int main() { return 0; }\n";
const auto secret = outside.root / "secret.cpp";
std::ofstream(secret) << "secret\n";
const auto profile = allowed.root / "profile.json";
std::ofstream(profile) << "{}\n";
fold::mcp::ServerPolicy policy;
policy.allowed_roots = {allowed.root};
const auto normalized = policy.normalizeArguments(
nlohmann::json{{"compile_db", allowed.root.string()},
{"configuration_profile", profile.string()},
{"paths", nlohmann::json::array({source.string()})}});
REQUIRE(std::filesystem::path(normalized["compile_db"].get<std::string>()).is_absolute());
REQUIRE(std::filesystem::path(normalized["paths"][0].get<std::string>()) ==
std::filesystem::canonical(source));
REQUIRE(std::filesystem::path(normalized["configuration_profile"].get<std::string>()) ==
std::filesystem::canonical(profile));
REQUIRE(invalidMessage([&] {
(void)policy.normalizeArguments(nlohmann::json{{"paths", {secret.string()}}});
}) == "field `paths[0]`: path escapes configured MCP roots");
REQUIRE(invalidMessage([&] {
(void)policy.normalizeArguments(
nlohmann::json{{"binary", std::string("bad\0path", 8)}});
}) == "field `binary`: path contains NUL byte");
const auto database = allowed.root / "compile_commands.json";
std::ofstream(database) << nlohmann::json::array({{{"directory", outside.root.string()},
{"command", "clang++ -c secret.cpp"},
{"file", secret.string()}}})
.dump();
REQUIRE(invalidMessage([&] {
(void)policy.normalizeArguments(nlohmann::json{{"compile_db", database.string()}});
}) == "field `compile_db.entries[0].directory`: path escapes configured MCP roots");
std::ofstream(database)
<< nlohmann::json::array(
{{{"directory", allowed.root.string()},
{"arguments", {"clang++", "-Xclang", "-load", "plugin.so", "-c", source.string()}},
{"file", source.string()}}})
.dump();
REQUIRE(invalidMessage([&] {
(void)policy.normalizeArguments(nlohmann::json{{"compile_db", database.string()}});
}) == "field `compile_db`: unsafe compiler argument `-load`");
std::error_code error;
const auto link = allowed.root / "escape.cpp";
std::filesystem::create_symlink(secret, link, error);
if (!error) {
REQUIRE(invalidMessage([&] {
(void)policy.normalizeArguments(nlohmann::json{{"paths", {link.string()}}});
}) == "field `paths[0]`: path escapes configured MCP roots");
}
}