diff --git a/.repository-projection.json b/.repository-projection.json index 31f2a4c..ac02063 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "56d36fc674bad5b86ba169de0e49905f44c8dcc2", + "sourceSha": "b6466c724d17bf35841cefb93c8babd3e3153a70", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "c0338dbec4311ab2e8f0f455986aa5ec54257f50", + "priorProjectedBase": "1e0bf9b19a9df23c7c563ea581576dea99d4ec08", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6", - "contentDigest": "379aa13d48e400a1ca4bca32d082610dc9d5c6c89694a5670bacd39d12232dde", + "contentDigest": "f841d481955629cc52b5488664f2354cdc6fe144e024b5b867e2a0748e71d38f", "publicationEligible": true } diff --git a/macos/Sources/MerlinMacOS/CLI.swift b/macos/Sources/MerlinMacOS/CLI.swift index cc626c5..7a57525 100644 --- a/macos/Sources/MerlinMacOS/CLI.swift +++ b/macos/Sources/MerlinMacOS/CLI.swift @@ -16,7 +16,8 @@ struct Merlin: ParsableCommand { static let configuration = CommandConfiguration( commandName: "merlin-macos", abstract: "Deixic Endpoint: endpoint telemetry and policy enforcement for macOS", - subcommands: [RunCommand.self, CheckCommand.self, PostureCommand.self, GenHashCommand.self] + subcommands: [RunCommand.self, CheckCommand.self, PostureCommand.self, GenHashCommand.self, + MCPHookCommand.self] ) } diff --git a/macos/Sources/MerlinMacOS/MCPHook.swift b/macos/Sources/MerlinMacOS/MCPHook.swift new file mode 100644 index 0000000..9edaaf6 --- /dev/null +++ b/macos/Sources/MerlinMacOS/MCPHook.swift @@ -0,0 +1,177 @@ +// Managed MCP tool-call hook for Cursor, Claude Code, and Codex. +// The hook reads only server and tool names. Arguments never leave the client. +import ArgumentParser +import Darwin +import Foundation + +private let mcpHookPolicyPath = "/Library/Application Support/Merlin/mcp-hook-policy.json" +private let mcpHookMaximumBytes = 64 * 1024 + +private enum MCPHookClient: String, ExpressibleByArgument { + case cursor, claude, codex +} + +struct MCPHookCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mcp-hook", + abstract: "Apply an administrator-installed MCP server allowlist to a client tool call." + ) + + @Option(help: "Client emitting the hook: cursor, claude, or codex.") + private var client: MCPHookClient + + func run() throws { + let output: [String: Any] + do { + let input = try readMCPHookInput() + let policy = try readMCPHookPolicy(path: mcpHookPolicyPath) + let verdict = try policy.verdict(client: client.rawValue, input: input) + output = mcpHookOutput(client: client.rawValue, verdict: verdict) + } catch { + // Endpoint enforcement points allow on internal errors. Client + // hooks must never turn a missing or malformed policy into a deny. + fputs("deixic endpoint mcp hook: \(error)\n", stderr) + output = mcpHookOutput(client: client.rawValue, verdict: .allow) + } + let data = try JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data([0x0a])) + } +} + +private enum MCPHookError: Error { + case invalidInput, invalidPolicy, unsafePolicyFile, oversizedInput +} + +enum MCPHookVerdict: Equatable { + case allow + case deny(String) +} + +struct MCPHookPolicy { + let enforced: Bool + let approvedServers: [String: Set] + let approvedName: String? + let approvedURL: String? + + static func parse(_ data: Data) throws -> MCPHookPolicy { + guard data.count <= mcpHookMaximumBytes, + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], + Set(root.keys).isSubset(of: ["schema_version", "mode", "approved_servers", "approved_alternative"]), + root["schema_version"] as? Int == 1, + let mode = root["mode"] as? String, ["audit", "enforce"].contains(mode), + let entries = root["approved_servers"] as? [[String: Any]], entries.count <= 128 else { + throw MCPHookError.invalidPolicy + } + var approved: [String: Set] = [:] + for entry in entries { + guard Set(entry.keys) == Set(["client", "server"]), + let client = entry["client"] as? String, + ["cursor", "claude", "codex"].contains(client), + let server = entry["server"] as? String, + validMCPHookName(server) else { + throw MCPHookError.invalidPolicy + } + approved[client, default: []].insert(server) + } + var name: String? + var url: String? + if let alternative = root["approved_alternative"] { + guard let value = alternative as? [String: String], + Set(value.keys) == Set(["name", "url"]), + let candidateName = value["name"], + !candidateName.isEmpty, candidateName.utf8.count <= 80, + candidateName == candidateName.trimmingCharacters(in: .whitespacesAndNewlines), + !candidateName.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + let candidateURL = value["url"], candidateURL.utf8.count <= 2048, + let parsed = URLComponents(string: candidateURL), parsed.scheme == "https", + parsed.host != nil, parsed.user == nil, parsed.password == nil, + parsed.query == nil, parsed.fragment == nil else { + throw MCPHookError.invalidPolicy + } + name = candidateName + url = candidateURL + } + return MCPHookPolicy(enforced: mode == "enforce", approvedServers: approved, + approvedName: name, approvedURL: url) + } + + func verdict(client: String, input: [String: Any]) throws -> MCPHookVerdict { + guard let server = mcpHookServer(client: client, input: input) else { + // Unrecognized hook events and malformed names do not become an + // implicit block. Managed client matchers limit calls to MCP. + return .allow + } + guard enforced, !approvedServers[client, default: []].contains(server) else { + return .allow + } + var message = "Deixic Endpoint blocked an unapproved MCP server (\(server))." + if let approvedName, let approvedURL { + message += " Use the administrator-approved tool \(approvedName): \(approvedURL)" + } else { + message += " Contact your administrator for an approved tool." + } + return .deny(message) + } +} + +private func validMCPHookName(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 128 && value.unicodeScalars.allSatisfy { + CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-").contains($0) + } +} + +private func mcpHookServer(client: String, input: [String: Any]) -> String? { + if client == "cursor" { + guard let server = input["mcp_server_name"] as? String, validMCPHookName(server), + let tool = input["tool_name"] as? String, validMCPHookName(tool) else { return nil } + return server + } + guard let tool = input["tool_name"] as? String, tool.hasPrefix("mcp__") else { return nil } + let parts = tool.dropFirst(5).components(separatedBy: "__") + guard parts.count == 2, validMCPHookName(parts[0]), validMCPHookName(parts[1]) else { return nil } + return parts[0] +} + +func mcpHookOutput(client: String, verdict: MCPHookVerdict) -> [String: Any] { + switch (client, verdict) { + case ("cursor", .allow): + return ["permission": "allow"] + case ("cursor", .deny(let message)): + return ["permission": "deny", "user_message": message, "agent_message": message] + case (_, .allow): + return [:] + case (_, .deny(let message)): + return ["hookSpecificOutput": [ + "hookEventName": "PreToolUse", "permissionDecision": "deny", + "permissionDecisionReason": message, + ]] + } +} + +private func readMCPHookInput() throws -> [String: Any] { + let data = FileHandle.standardInput.readData(ofLength: mcpHookMaximumBytes + 1) + guard data.count <= mcpHookMaximumBytes else { throw MCPHookError.oversizedInput } + guard let input = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw MCPHookError.invalidInput + } + return input +} + +func readMCPHookPolicy(path: String) throws -> MCPHookPolicy { + let fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard fd >= 0 else { throw MCPHookError.unsafePolicyFile } + defer { close(fd) } + var metadata = stat() + guard fstat(fd, &metadata) == 0, + metadata.st_mode & S_IFMT == S_IFREG, + metadata.st_uid == 0, + metadata.st_mode & 0o022 == 0, + metadata.st_size >= 0, + metadata.st_size <= mcpHookMaximumBytes else { + throw MCPHookError.unsafePolicyFile + } + let data = FileHandle(fileDescriptor: fd, closeOnDealloc: false).readData(ofLength: mcpHookMaximumBytes + 1) + guard data.count <= mcpHookMaximumBytes else { throw MCPHookError.unsafePolicyFile } + return try MCPHookPolicy.parse(data) +} diff --git a/macos/Tests/MerlinMacOSTests/MCPHookTests.swift b/macos/Tests/MerlinMacOSTests/MCPHookTests.swift new file mode 100644 index 0000000..88eb00b --- /dev/null +++ b/macos/Tests/MerlinMacOSTests/MCPHookTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import MerlinMacOS + +@Suite("managed MCP hooks") +struct MCPHookTests { + private let policy = """ + {"schema_version":1,"mode":"enforce", + "approved_servers":[{"client":"cursor","server":"deixic-gateway"}, + {"client":"claude","server":"deixic-gateway"}, + {"client":"codex","server":"deixic-gateway"}], + "approved_alternative":{"name":"Approved agent","url":"https://tools.example.com/agent"}} + """ + + @Test("blocks unapproved Cursor, Claude, and Codex MCP calls") + func blockedClients() throws { + let parsed = try MCPHookPolicy.parse(Data(policy.utf8)) + let inputs: [(String, [String: Any])] = [ + ("cursor", ["mcp_server_name": "shadow", "tool_name": "search", "tool_input": ["secret": "do not emit"]]), + ("claude", ["tool_name": "mcp__shadow__search", "tool_input": ["secret": "do not emit"]]), + ("codex", ["tool_name": "mcp__shadow__search", "tool_input": ["secret": "do not emit"]]), + ] + for (client, input) in inputs { + let verdict = try parsed.verdict(client: client, input: input) + guard case .deny(let reason) = verdict else { + Issue.record("\(client) did not deny an unapproved server") + continue + } + #expect(reason.contains("Approved agent")) + #expect(reason.contains("https://tools.example.com/agent")) + let output = mcpHookOutput(client: client, verdict: verdict) + let encoded = String(data: try JSONSerialization.data(withJSONObject: output), encoding: .utf8) ?? "" + #expect(!encoded.contains("do not emit")) + if client == "cursor" { + #expect(output["permission"] as? String == "deny") + } else { + let specific = output["hookSpecificOutput"] as? [String: String] + #expect(specific?["permissionDecision"] == "deny") + } + } + } + + @Test("allows approved servers and unrelated tools") + func approvedAndUnrelated() throws { + let parsed = try MCPHookPolicy.parse(Data(policy.utf8)) + #expect(try parsed.verdict(client: "cursor", input: ["mcp_server_name": "deixic-gateway", "tool_name": "search"]) == .allow) + #expect(try parsed.verdict(client: "claude", input: ["tool_name": "mcp__deixic-gateway__search"]) == .allow) + #expect(try parsed.verdict(client: "codex", input: ["tool_name": "Bash"]) == .allow) + #expect(try parsed.verdict(client: "codex", input: ["tool_name": "mcp__plugin_my-plugin_db__search"]) != .allow) + } + + @Test("rejects unknown policy fields, unsafe links, and oversized policy") + func invalidPolicy() throws { + for invalid in [ + policy.replacingOccurrences(of: "\"mode\":\"enforce\"", with: "\"mode\":\"enforce\",\"surprise\":true"), + policy.replacingOccurrences(of: "https://tools.example.com/agent", with: "http://tools.example.com/agent"), + policy.replacingOccurrences(of: "\"server\":\"deixic-gateway\"", with: "\"server\":\"*\""), + String(repeating: "x", count: 65_537), + ] { + #expect(throws: Error.self) { try MCPHookPolicy.parse(Data(invalid.utf8)) } + } + } + + @Test("audit policy never denies") + func audit() throws { + let parsed = try MCPHookPolicy.parse(Data(policy.replacingOccurrences(of: "enforce", with: "audit").utf8)) + #expect(try parsed.verdict(client: "cursor", input: ["mcp_server_name": "shadow", "tool_name": "search"]) == .allow) + } + + @Test("refuses user-owned and symlinked policy files") + func unsafeFiles() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let plain = directory.appendingPathComponent("policy.json") + try Data(policy.utf8).write(to: plain) + #expect(throws: Error.self) { try readMCPHookPolicy(path: plain.path) } + let link = directory.appendingPathComponent("link.json") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: plain) + #expect(throws: Error.self) { try readMCPHookPolicy(path: link.path) } + } +}