Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
da9c15c
Project import generated by Copybara.
Sep 20, 2026
9bfb7ad
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 20, 2026
2e3ada5
Project import generated by Copybara.
Sep 20, 2026
c791e1b
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 20, 2026
1977eb7
chore: project endpoint from Mono a2e8b231b208
Sep 20, 2026
b8a08a0
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 21, 2026
ff85ac7
chore: project endpoint from Mono 9b559bc70e7d
Sep 21, 2026
6214513
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 21, 2026
6188d83
chore: project endpoint from Mono 9a003786d767
Sep 21, 2026
692ee44
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 21, 2026
a8e83c3
chore: project endpoint from Mono 511bd2305f5a
Sep 21, 2026
ba59fec
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 21, 2026
b0061c7
chore: project endpoint from Mono ad7b9df0b712
Sep 21, 2026
2656701
chore: project endpoint from Mono 4ab4845398fd
Sep 21, 2026
13786d3
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 22, 2026
f6b9091
chore: project endpoint from Mono 2df44a8283c0
Sep 22, 2026
8bd7af4
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 23, 2026
1bddf52
chore: project endpoint from Mono 19eb7fff08ad
Sep 23, 2026
79aef39
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 23, 2026
8b9551a
chore: project endpoint from Mono be57256872b9
Sep 23, 2026
8635f11
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 23, 2026
0f1b3ef
chore: project endpoint from Mono fc21676e9136
Sep 23, 2026
a7ad5e7
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 23, 2026
f2529d1
chore: project endpoint from Mono 0d25f444e779
Sep 23, 2026
7e39251
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
8bfc1c7
chore: project endpoint from Mono 68580466ba81
Sep 24, 2026
9ec2bd8
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
bd7d23b
chore: project endpoint from Mono 56d36fc674ba
Sep 24, 2026
68a1e7b
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
f170f92
chore: project endpoint from Mono b6466c724d17
Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .repository-projection.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion macos/Sources/MerlinMacOS/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
}

Expand Down
177 changes: 177 additions & 0 deletions macos/Sources/MerlinMacOS/MCPHook.swift
Original file line number Diff line number Diff line change
@@ -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<String>]
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<String>] = [:]
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)
}
82 changes: 82 additions & 0 deletions macos/Tests/MerlinMacOSTests/MCPHookTests.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}