- 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
132 lines
5.6 KiB
C++
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");
|
|
}
|
|
}
|