diff --git a/.repository-projection.json b/.repository-projection.json index 94d5c44..8bf2896 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "c97cbb033be2d815b33abd51b9e1a9a37968066c", + "sourceSha": "fb1c035ef92dbe2a615de20f5ec2ded6321b93c3", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "9bfd6e9f9d3495d28dc9eb44475251f420a2da97", + "priorProjectedBase": "cd677850140f55def5cc19c9688fa12f01835b48", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "0aae6000dbd0940f5a0af380463ccb5a83285eda", - "contentDigest": "6805af47e6eb5ba9309346562bdc67ac643315fcc4605f43653d66c5c1be844d", + "contentDigest": "4524337308fac76e03ccb1854efdcd156e5660c6109438cbef6699a2b9e68dec", "publicationEligible": true } diff --git a/macos/Sources/MerlinMacOS/CodexPlugins.swift b/macos/Sources/MerlinMacOS/CodexPlugins.swift new file mode 100644 index 0000000..49f6bc5 --- /dev/null +++ b/macos/Sources/MerlinMacOS/CodexPlugins.swift @@ -0,0 +1,338 @@ +import Foundation + +/// Names of Codex plugins that `~/.codex/config.toml` explicitly enables. +/// Matches the Linux collector: a key under the top-level `plugins` table is +/// reported only when its `enabled` value is the boolean true and the key is +/// a safe asset name. A document that fails to parse yields no names. No other +/// configuration value is returned. +func codexEnabledPluginNames(_ data: Data) -> [String] { + guard let leaves = TOMLLeafScanner.parse([UInt8](data)) else { return [] } + let names = leaves.compactMap { leaf -> String? in + leaf.path.count == 3 && leaf.path[0] == "plugins" && leaf.path[2] == "enabled" && leaf.isTrue + && safeAgentAssetName(leaf.path[1]) ? leaf.path[1] : nil + } + return Array(names.sorted { $0.utf8.lexicographicallyPrecedes($1.utf8) }.prefix(128)) +} + +/// Walks a TOML 1.0 document and records the full key path of every value +/// assignment outside arrays of tables. Only boolean true is retained as a +/// value; strings, numbers, dates, and arrays are skipped. The Windows +/// collector's `windows/codex_plugins.go` implements the same rules. +private struct TOMLLeafScanner { + struct Leaf { let path: [String]; let isTrue: Bool } + struct Invalid: Error {} + + /// TOML 1.0 rejects a document that defines one path twice or reopens a + /// value, an inline table, or a dotted-key table with a header. + enum Kind { case implicit, dotted, header, array, value } + + let bytes: [UInt8] + var pos = 0 + var leaves: [Leaf] = [] + var kinds: [[String]: Kind] = [:] + + static func parse(_ bytes: [UInt8]) -> [Leaf]? { + guard String(bytes: bytes, encoding: .utf8) != nil else { return nil } + var scanner = TOMLLeafScanner(bytes: bytes) + do { + try scanner.document() + return scanner.leaves + } catch { + return nil + } + } + + private var current: UInt8? { pos < bytes.count ? bytes[pos] : nil } + + private func has(_ prefix: String) -> Bool { + let utf8 = Array(prefix.utf8) + return pos + utf8.count <= bytes.count && Array(bytes[pos.. [String]? { + guard let base else { return nil } + let full = base + key + for end in (base.count + 1).. Bool { + for end in 1.. [String] { + var path: [String] = [] + while true { + skipSpace() + guard let byte = current else { throw Invalid() } + if byte == UInt8(ascii: "\"") { + path.append(try basicString()) + } else if byte == UInt8(ascii: "'") { + path.append(try literalString()) + } else { + let start = pos + while let next = current, Self.isBareKey(next) { pos += 1 } + guard pos > start else { throw Invalid() } + path.append(String(decoding: bytes[start.. Bool { + (byte >= UInt8(ascii: "A") && byte <= UInt8(ascii: "Z")) || (byte >= UInt8(ascii: "a") && byte <= UInt8(ascii: "z")) || + (byte >= UInt8(ascii: "0") && byte <= UInt8(ascii: "9")) || byte == UInt8(ascii: "_") || byte == UInt8(ascii: "-") + } + + /// Control characters that TOML forbids inside a single-line string. Tab + /// is allowed. + private static func isControl(_ byte: UInt8) -> Bool { + (byte < 0x20 && byte != 0x09) || byte == 0x7F + } + + /// Consumes one TOML value. A nil path means the value is not recorded. + private mutating func value(_ path: [String]?) throws { + guard let byte = current else { throw Invalid() } + if has("\"\"\"") { + try multilineString(quote: UInt8(ascii: "\""), escapes: true) + leaf(path, isTrue: false) + } else if has("'''") { + try multilineString(quote: UInt8(ascii: "'"), escapes: false) + leaf(path, isTrue: false) + } else if byte == UInt8(ascii: "\"") { + _ = try basicString() + leaf(path, isTrue: false) + } else if byte == UInt8(ascii: "'") { + _ = try literalString() + leaf(path, isTrue: false) + } else if byte == UInt8(ascii: "[") { + pos += 1 + while true { + skipBlank(newlines: true) + if has("]") { pos += 1; break } + try value(nil) + skipBlank(newlines: true) + if has(",") { pos += 1; continue } + guard has("]") else { throw Invalid() } + } + leaf(path, isTrue: false) + } else if byte == UInt8(ascii: "{") { + pos += 1 + skipSpace() + leaf(path, isTrue: false) + if has("}") { pos += 1; return } + while true { + let child = try key() + skipSpace() + guard has("=") else { throw Invalid() } + pos += 1 + skipSpace() + try value(try assign(path, child)) + skipSpace() + if has(",") { pos += 1; continue } + guard has("}") else { throw Invalid() } + pos += 1 + return + } + } else { + let start = pos + while let next = current, !Array(",]}#\r\n".utf8).contains(next) { pos += 1 } + while pos > start, bytes[pos - 1] == UInt8(ascii: " ") || bytes[pos - 1] == UInt8(ascii: "\t") { pos -= 1 } + let token = String(decoding: bytes[start.. Bool { + text.unicodeScalars.allSatisfy { allowed.unicodeScalars.contains($0) } + } + + /// Accepts the shapes of TOML numbers and date-times. Rejects bare words, + /// which TOML does not allow as values. + static func isScalarToken(_ token: String) -> Bool { + let text = Substring(token) + if text.count >= 2, text.hasPrefix("0"), let base = text.dropFirst().first, "xob".contains(base) { + let digits = base == "x" ? "0123456789abcdefABCDEF_" : (base == "o" ? "01234567_" : "01_") + return text.count > 2 && only(text.dropFirst(2), digits) + } + var unsigned = text + if unsigned.hasPrefix("+") { unsigned = unsigned.dropFirst() } + if unsigned.hasPrefix("-") { unsigned = unsigned.dropFirst() } + if unsigned == "inf" || unsigned == "nan" { return unsigned.count + 1 >= text.count } + guard let first = unsigned.first, first.isASCII, first.isNumber else { return false } + let bytes = Array(text.utf8) + if unsigned.count == text.count && (text.contains(":") || (bytes.count >= 10 && bytes[4] == UInt8(ascii: "-"))) { + let parts = text.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false) + let spaced = parts.count == 2 + return only(parts[0], "0123456789-:.+TtZz") && (!spaced || (!parts[1].isEmpty && only(parts[1], "0123456789-:.+Zz"))) + } + return only(unsigned, "0123456789_.eE+-") + } + + private mutating func basicString() throws -> String { + pos += 1 + var out: [UInt8] = [] + while let byte = current { + if byte == UInt8(ascii: "\"") { + pos += 1 + return String(decoding: out, as: UTF8.self) + } else if Self.isControl(byte) { + throw Invalid() + } else if byte == UInt8(ascii: "\\") { + try escape(into: &out) + } else { + out.append(byte) + pos += 1 + } + } + throw Invalid() + } + + private mutating func escape(into out: inout [UInt8]) throws { + guard pos + 1 < bytes.count else { throw Invalid() } + let code = bytes[pos + 1] + pos += 2 + let simple: [UInt8: UInt8] = [ + UInt8(ascii: "b"): 0x08, UInt8(ascii: "t"): 0x09, UInt8(ascii: "n"): 0x0A, UInt8(ascii: "f"): 0x0C, + UInt8(ascii: "r"): 0x0D, UInt8(ascii: "\""): 0x22, UInt8(ascii: "\\"): 0x5C, + ] + if let value = simple[code] { + out.append(value) + return + } + let width = code == UInt8(ascii: "u") ? 4 : (code == UInt8(ascii: "U") ? 8 : 0) + guard width > 0, pos + width <= bytes.count else { throw Invalid() } + let hex = String(decoding: bytes[pos.. String { + pos += 1 + let start = pos + while let byte = current { + if byte == UInt8(ascii: "'") { + let value = String(decoding: bytes[start.. [String] { return names.sorted() } -private func safeAgentAssetName(_ name: String) -> Bool { +func safeAgentAssetName(_ name: String) -> Bool { !name.isEmpty && name.utf8.count <= 128 && !name.hasPrefix(".") && !name.contains("/") && !name.contains("\\") && !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) diff --git a/macos/Sources/MerlinMacOS/MCPHook.swift b/macos/Sources/MerlinMacOS/MCPHook.swift index 9edaaf6..c8c5674 100644 --- a/macos/Sources/MerlinMacOS/MCPHook.swift +++ b/macos/Sources/MerlinMacOS/MCPHook.swift @@ -22,16 +22,26 @@ struct MCPHookCommand: ParsableCommand { func run() throws { let output: [String: Any] + var input: [String: Any]? do { - let input = try readMCPHookInput() + input = try readMCPHookInput() let policy = try readMCPHookPolicy(path: mcpHookPolicyPath) - let verdict = try policy.verdict(client: client.rawValue, input: input) + let verdict = try policy.verdict(client: client.rawValue, input: input!) + if let record = policy.auditWouldDenyRecord(client: client.rawValue, input: input!) { + appendMCPHookAuditRecord(client: client.rawValue, record: record) + } 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. + // Every enforcement point allows on internal errors, with one + // exception handled by mcpHookErrorVerdict: a policy that is + // present, safely owned, and declares enforce mode, but fails + // schema validation, still denies. An administrator who pushed + // enforcement does not get a silent fail-open because the pushed + // file happened to be broken. Every other error (a missing or + // unsafe policy file, a malformed policy that is absent or + // declares audit mode, oversized or invalid input) allows. fputs("deixic endpoint mcp hook: \(error)\n", stderr) - output = mcpHookOutput(client: client.rawValue, verdict: .allow) + output = mcpHookOutput(client: client.rawValue, verdict: mcpHookErrorVerdict(error, client: client.rawValue, input: input)) } let data = try JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]) FileHandle.standardOutput.write(data) @@ -39,8 +49,18 @@ struct MCPHookCommand: ParsableCommand { } } -private enum MCPHookError: Error { - case invalidInput, invalidPolicy, unsafePolicyFile, oversizedInput +enum MCPHookError: Error, Equatable { + case invalidInput, invalidPolicy, unavailablePolicy, oversizedInput + case malformedPolicy(mode: String?) +} + +// Pulled out of run()'s catch block so the one case that is not a plain +// fail-open (a malformed policy that declares enforce mode) is testable +// without a root-owned policy file on disk. +func mcpHookErrorVerdict(_ error: Error, client: String, input: [String: Any]?) -> MCPHookVerdict { + guard case MCPHookError.malformedPolicy(let mode) = error, mode == "enforce" else { return .allow } + guard let input, let server = mcpHookServer(client: client, input: input) else { return .allow } + return .deny(mcpHookBlockedMessage(server: server, approvedName: nil, approvedURL: nil)) } enum MCPHookVerdict: Equatable { @@ -48,6 +68,31 @@ enum MCPHookVerdict: Equatable { case deny(String) } +// Local, bounded evidence that an audit-mode call would have been denied +// under enforce mode. Only fixed identifiers and a timestamp; never the tool +// call's arguments. +struct MCPHookAuditRecord: Codable, Equatable, Sendable { + let server: String + let tool: String + let rule: String + let observedAt: Double + + enum CodingKeys: String, CodingKey { + case server, tool, rule + case observedAt = "observed_at" + } +} + +private func mcpHookBlockedMessage(server: String, approvedName: String?, approvedURL: String?) -> String { + 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 message +} + struct MCPHookPolicy { let enforced: Bool let approvedServers: [String: Set] @@ -97,21 +142,28 @@ struct MCPHookPolicy { } func verdict(client: String, input: [String: Any]) throws -> MCPHookVerdict { - guard let server = mcpHookServer(client: client, input: input) else { + guard let call = evaluateCall(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 { + guard enforced, !call.approved 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) + return .deny(mcpHookBlockedMessage(server: call.server, approvedName: approvedName, approvedURL: approvedURL)) + } + + // A call that would have been denied had this policy been in enforce + // mode. Enforce mode itself never audits: it denies outright through + // `verdict(client:input:)` instead. + func auditWouldDenyRecord(client: String, input: [String: Any]) -> MCPHookAuditRecord? { + guard !enforced, let call = evaluateCall(client: client, input: input), !call.approved else { return nil } + return MCPHookAuditRecord(server: call.server, tool: call.tool, rule: "unapproved_server", observedAt: Date().timeIntervalSince1970) + } + + private func evaluateCall(client: String, input: [String: Any]) -> (server: String, tool: String, approved: Bool)? { + guard let (server, tool) = mcpHookServerAndTool(client: client, input: input) else { return nil } + return (server, tool, approvedServers[client, default: []].contains(server)) } } @@ -122,15 +174,25 @@ private func validMCPHookName(_ value: String) -> Bool { } private func mcpHookServer(client: String, input: [String: Any]) -> String? { + mcpHookServerAndTool(client: client, input: input)?.server +} + +// Claude Code and Codex encode a tool call as "mcp____". The +// tool name itself may contain "__" (for example a plugin-qualified tool), +// so only the first "__"-separated segment is the server; everything after +// it, rejoined with "__", is the tool. +private func mcpHookServerAndTool(client: String, input: [String: Any]) -> (server: String, tool: 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 + return (server, tool) } - 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] + guard let raw = input["tool_name"] as? String, raw.hasPrefix("mcp__") else { return nil } + let parts = raw.dropFirst(5).components(separatedBy: "__") + guard parts.count >= 2, validMCPHookName(parts[0]) else { return nil } + let tool = parts.dropFirst().joined(separator: "__") + guard validMCPHookName(tool) else { return nil } + return (parts[0], tool) } func mcpHookOutput(client: String, verdict: MCPHookVerdict) -> [String: Any] { @@ -158,9 +220,21 @@ private func readMCPHookInput() throws -> [String: Any] { return input } +// Best-effort extraction of a raw policy's declared mode, tolerant of a file +// that otherwise fails MCPHookPolicy.parse's strict schema validation. It +// grants no server approvals by itself; it exists only so a policy that +// safely passed the file-ownership checks below but is malformed can still +// signal that it intended enforce mode. +func peekMCPHookPolicyMode(_ data: Data) -> String? { + guard data.count <= mcpHookMaximumBytes, + let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let mode = root["mode"] as? String, ["audit", "enforce"].contains(mode) else { return nil } + return mode +} + 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 } + guard fd >= 0 else { throw MCPHookError.unavailablePolicy } defer { close(fd) } var metadata = stat() guard fstat(fd, &metadata) == 0, @@ -169,9 +243,16 @@ func readMCPHookPolicy(path: String) throws -> MCPHookPolicy { metadata.st_mode & 0o022 == 0, metadata.st_size >= 0, metadata.st_size <= mcpHookMaximumBytes else { - throw MCPHookError.unsafePolicyFile + throw MCPHookError.unavailablePolicy } let data = FileHandle(fileDescriptor: fd, closeOnDealloc: false).readData(ofLength: mcpHookMaximumBytes + 1) - guard data.count <= mcpHookMaximumBytes else { throw MCPHookError.unsafePolicyFile } - return try MCPHookPolicy.parse(data) + guard data.count <= mcpHookMaximumBytes else { throw MCPHookError.unavailablePolicy } + do { + return try MCPHookPolicy.parse(data) + } catch { + // The file passed every ownership and size check above, so its bytes + // are trustworthy enough to peek at for a mode, even though the full + // schema failed to validate. + throw MCPHookError.malformedPolicy(mode: peekMCPHookPolicyMode(data)) + } } diff --git a/macos/Sources/MerlinMacOS/MCPHookCoverage.swift b/macos/Sources/MerlinMacOS/MCPHookCoverage.swift index d82ab3e..2bdcde4 100644 --- a/macos/Sources/MerlinMacOS/MCPHookCoverage.swift +++ b/macos/Sources/MerlinMacOS/MCPHookCoverage.swift @@ -18,12 +18,92 @@ struct DeviceMCPHookCoverage: Encodable, Sendable { struct DeviceMCPHookClientCoverage: Encodable, Sendable { let client: String let registration: String + let wouldDenyCount: Int + + enum CodingKeys: String, CodingKey { + case client, registration + case wouldDenyCount = "would_deny_count" + } } private let hookBinary = "/Library/Application Support/Merlin/bin/merlin-macos" private let hookReadLimit = 64 * 1024 private let hookPolicyFile = "/Library/Application Support/Merlin/mcp-hook-policy.json" +// Where the unprivileged mcp-hook CLI records audit-mode would-denies, one +// bounded file per client per user. The root collector below only counts +// entries; it never writes here and never uploads an entry's contents. +private let hookAuditDirectory = "Library/Application Support/Merlin" +private let hookAuditMaxBytes = 16 * 1024 +private let hookAuditMaxRecordsPerFile = 128 + +private func hookAuditStorePath(home: String, client: String) -> String { + "\(home)/\(hookAuditDirectory)/mcp-hook-audit-\(client).jsonl" +} + +// Called by the mcp-hook CLI, which runs as the interactive user invoking +// Cursor, Claude Code, or Codex, so the store lives under that user's home +// directory rather than the root-owned policy directory. Bounded: once the +// file reaches the byte cap it is reset instead of growing without limit or +// paying for a read-modify-write ring buffer. +func appendMCPHookAuditRecord(client: String, record: MCPHookAuditRecord, home: String = NSHomeDirectory()) { + guard let line = try? JSONEncoder().encode(record), line.count <= hookAuditMaxBytes else { return } + let directory = "\(home)/\(hookAuditDirectory)" + try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + let path = hookAuditStorePath(home: home, client: client) + let fd = open(path, O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0o600) + guard fd >= 0 else { return } + defer { close(fd) } + var info = stat() + guard fstat(fd, &info) == 0, info.st_mode & S_IFMT == S_IFREG else { return } + let currentSize = info.st_size >= 0 ? Int(info.st_size) : hookAuditMaxBytes + if currentSize + line.count + 1 > hookAuditMaxBytes { + _ = ftruncate(fd, 0) + lseek(fd, 0, SEEK_SET) + } else { + lseek(fd, 0, SEEK_END) + } + var payload = line + payload.append(0x0a) + payload.withUnsafeBytes { buffer in + _ = write(fd, buffer.baseAddress, buffer.count) + } +} + +// Bounded, best-effort count of would-deny records left by the mcp-hook CLI +// across every local account. A record that fails to parse still counts as +// one line so a would-deny count cannot be hidden by corrupting the file. +private func hookAuditWouldDenyCount(homes: [String], client: String) -> Int { + var total = 0 + for home in homes.prefix(65) { + let path = hookAuditStorePath(home: home, client: client) + let fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard fd >= 0 else { continue } + defer { close(fd) } + var info = stat() + guard fstat(fd, &info) == 0, info.st_mode & S_IFMT == S_IFREG, + info.st_size >= 0, info.st_size <= hookAuditMaxBytes else { continue } + let data = FileHandle(fileDescriptor: fd, closeOnDealloc: false).readData(ofLength: hookAuditMaxBytes + 1) + guard data.count <= hookAuditMaxBytes, let text = String(data: data, encoding: .utf8) else { continue } + let lines = text.split(separator: "\n", omittingEmptySubsequences: true) + total += min(lines.count, hookAuditMaxRecordsPerFile) + } + return min(total, hookAuditMaxRecordsPerFile * 65) +} + +// Every local account with a home directory, plus root's. The collector runs +// privileged and cannot know which account ran a given client, so it sums +// across all of them, matching the enumeration collectMacAgentDiscovery uses +// for the same reason in Inventory.swift. +private func hookAuditHomes() -> [String] { + let root = "/Users" + let users = ((try? FileManager.default.contentsOfDirectory(atPath: root)) ?? []).sorted().prefix(64) + return ["/var/root"] + users.map { "\(root)/\($0)" }.filter { path in + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + } +} + enum HookFileObservation { case absent case unreadable @@ -72,7 +152,7 @@ func observeManagedHookFile(_ path: String) -> HookFileObservation { return data.count <= hookReadLimit ? .data(data) : .unreadable } -func collectMCPHookCoverage() -> DeviceMCPHookCoverage { +func collectMCPHookCoverage(homes: [String] = hookAuditHomes()) -> DeviceMCPHookCoverage { let policy: String let digest: String? switch observeManagedHookFile(hookPolicyFile) { @@ -106,7 +186,7 @@ func collectMCPHookCoverage() -> DeviceMCPHookCoverage { case .unreadable: registration = "unreadable" case .data(let data): registration = matches(data) ? "observed" : "not_observed" } - return DeviceMCPHookClientCoverage(client: client, registration: registration) + return DeviceMCPHookClientCoverage(client: client, registration: registration, wouldDenyCount: hookAuditWouldDenyCount(homes: homes, client: client)) } return DeviceMCPHookCoverage(policy: policy, policySHA256: digest, clients: clients) } diff --git a/macos/Tests/MerlinMacOSTests/CodexPluginsTests.swift b/macos/Tests/MerlinMacOSTests/CodexPluginsTests.swift new file mode 100644 index 0000000..82140e1 --- /dev/null +++ b/macos/Tests/MerlinMacOSTests/CodexPluginsTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import MerlinMacOS + +@Suite("codex enabled plugins") +struct CodexPluginsTests { + private func names(_ body: String) -> [String] { + codexEnabledPluginNames(Data(body.utf8)) + } + + @Test("table headers report only explicit boolean true and safe names") + func tableHeaders() { + let body = "[plugins.\"audit@marketplace\"]\nenabled = true\nsecret = 'private'\n[plugins.\"off@marketplace\"]\nenabled = false\n[plugins.\"unset@marketplace\"]\nfoo = true\n[plugins.\"text@marketplace\"]\nenabled = \"true\"\n[plugins.\"https://private.example/path\"]\nenabled = true\n" + #expect(names(body) == ["audit@marketplace"]) + } + + @Test("inline tables, dotted keys, literal keys, escapes, and CRLF") + func keyForms() { + let body = "model = \"gpt\"\r\n[plugins]\r\ninline = { enabled = true, token = \"secret\" }\r\n'literal@m'.enabled = true # comment\r\n\"esc\\u0041pe\" . enabled = true\r\n" + #expect(names(body) == ["escApe", "inline", "literal@m"]) + #expect(names("plugins.dotted.enabled = true\n") == ["dotted"]) + #expect(names("plugins = { a = { enabled = true }, b = { enabled = false } }\n") == ["a"]) + } + + @Test("nested tables, arrays of tables, and multi-line values are not plugin entries") + func structure() { + #expect(names("[plugins.outer.inner]\nenabled = true\n[[plugins_list]]\nenabled = true\n[mcp_servers.plugins]\nenabled = true\n").isEmpty) + let body = "[mcp_servers.x]\nargs = [\n \"a\", # note\n \"[plugins.fake]\",\n]\nnote = \"\"\"\n[plugins.fake2]\nenabled = true\n\"\"\"\nraw = '''\n[plugins.fake3]\n'''\nwhen = 1979-05-27 07:32:00Z\n[plugins.real]\nenabled = true\n" + #expect(names(body) == ["real"]) + } + + @Test("unsafe names are skipped") + func unsafeNames() { + let long = String(repeating: "x", count: 129) + let body = "[plugins.\".hidden\"]\nenabled = true\n[plugins.\"a/b\"]\nenabled = true\n[plugins.\"ctl\\u0001\"]\nenabled = true\n[plugins.\"\(long)\"]\nenabled = true\n[plugins.ok]\nenabled = true\n" + #expect(names(body) == ["ok"]) + } + + @Test("invalid documents yield no names") + func invalidDocuments() { + #expect(names("[plugins.ok]\nenabled = true\n[broken\n").isEmpty) + #expect(names("[plugins.ok]\nenabled = true\nenabled = true\n").isEmpty) + #expect(names("[plugins.ok]\nenabled = true\n[plugins.ok]\nother = 1\n").isEmpty) + #expect(names("[plugins.ok]\nenabled = true\nx = \"open\n").isEmpty) + #expect(names("[plugins]\nx.enabled = true\n[plugins.x]\n").isEmpty) + #expect(names("[plugins.x]\nenabled = true\nv = abc\n").isEmpty) + #expect(names("[plugins.x]\nenabled = true\ns = \"ctl\u{01}\"\n").isEmpty) + #expect(names("\u{FEFF}[plugins.bom]\nenabled = true\n") == ["bom"]) + var bytes = Array("[plugins.ok]\nenabled = true\nx = \"".utf8) + bytes += [0xFF, 0x22, 0x0A] + #expect(codexEnabledPluginNames(Data(bytes)).isEmpty) + } + + @Test("names are sorted and bounded to 128") + func bounded() { + let body = (0..<200).reversed().map { String(format: "[plugins.\"p%03d@m\"]\nenabled = true\n", $0) }.joined() + let result = names(body) + #expect(result.count == 128) + #expect(result.first == "p000@m") + #expect(result.last == "p127@m") + } + + @Test("agent discovery reports Codex enabled plugins without configuration values") + func discovery() throws { + let home = NSTemporaryDirectory() + "merlin-codex-plugins-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: home) } + try FileManager.default.createDirectory(atPath: home + "/.codex", withIntermediateDirectories: true) + try "[mcp_servers.github]\nurl = 'https://secret.example'\n[plugins.\"audit@marketplace\"]\nenabled = true\ntoken = 'secret-token'\n[plugins.\"off@marketplace\"]\nenabled = false\n" + .write(toFile: home + "/.codex/config.toml", atomically: true, encoding: .utf8) + + let discovered = collectMacAgentDiscovery(homes: [home], systemBins: [], appRoots: []) + #expect(discovered.assets.contains { $0.client == "codex" && $0.kind == "plugin" && $0.name == "audit@marketplace" && $0.source == ".codex/config.toml" }) + #expect(!discovered.assets.contains { $0.name == "off@marketplace" }) + #expect(discovered.servers.contains { $0.client == "codex" && $0.name == "github" }) + let payload = String(decoding: try JSONEncoder().encode(discovered.assets), as: UTF8.self) + #expect(!payload.contains("secret")) + } +} diff --git a/macos/Tests/MerlinMacOSTests/MCPHookCoverageTests.swift b/macos/Tests/MerlinMacOSTests/MCPHookCoverageTests.swift index 47fafca..c506938 100644 --- a/macos/Tests/MerlinMacOSTests/MCPHookCoverageTests.swift +++ b/macos/Tests/MerlinMacOSTests/MCPHookCoverageTests.swift @@ -54,4 +54,61 @@ struct MCPHookCoverageTests { Issue.record("accepted a symlinked managed file ancestor") } } + + @Test("audit would-deny records append, bound, and count without exposing arguments") + func auditRecordAppendAndCount() throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + appendMCPHookAuditRecord( + client: "claude", + record: MCPHookAuditRecord(server: "shadow", tool: "search", rule: "unapproved_server", observedAt: 1), + home: home.path) + appendMCPHookAuditRecord( + client: "claude", + record: MCPHookAuditRecord(server: "shadow", tool: "write", rule: "unapproved_server", observedAt: 2), + home: home.path) + appendMCPHookAuditRecord( + client: "codex", + record: MCPHookAuditRecord(server: "other", tool: "search", rule: "unapproved_server", observedAt: 3), + home: home.path) + + let storePath = home.appendingPathComponent("Library/Application Support/Merlin/mcp-hook-audit-claude.jsonl").path + let contents = try #require(FileManager.default.contents(atPath: storePath)) + let text = try #require(String(data: contents, encoding: .utf8)) + #expect(text.contains("\"server\":\"shadow\"")) + #expect(!text.contains("secret")) + #expect(text.split(separator: "\n").count == 2) + + let coverage = collectMCPHookCoverage(homes: [home.path]) + let claude = try #require(coverage.clients.first { $0.client == "claude" }) + #expect(claude.wouldDenyCount == 2) + let codex = try #require(coverage.clients.first { $0.client == "codex" }) + #expect(codex.wouldDenyCount == 1) + let cursor = try #require(coverage.clients.first { $0.client == "cursor" }) + #expect(cursor.wouldDenyCount == 0) + } + + @Test("the audit store resets instead of growing without bound") + func auditStoreBounded() throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + + for index in 0..<4096 { + appendMCPHookAuditRecord( + client: "cursor", + record: MCPHookAuditRecord(server: "shadow-\(index)", tool: "search", rule: "unapproved_server", observedAt: Double(index)), + home: home.path) + } + let storePath = home.appendingPathComponent("Library/Application Support/Merlin/mcp-hook-audit-cursor.jsonl").path + let attributes = try FileManager.default.attributesOfItem(atPath: storePath) + let size = (attributes[.size] as? NSNumber)?.intValue ?? Int.max + #expect(size <= 16 * 1024) + + let coverage = collectMCPHookCoverage(homes: [home.path]) + let cursor = try #require(coverage.clients.first { $0.client == "cursor" }) + #expect(cursor.wouldDenyCount <= 128) + } } diff --git a/macos/Tests/MerlinMacOSTests/MCPHookTests.swift b/macos/Tests/MerlinMacOSTests/MCPHookTests.swift index 88eb00b..5c141a5 100644 --- a/macos/Tests/MerlinMacOSTests/MCPHookTests.swift +++ b/macos/Tests/MerlinMacOSTests/MCPHookTests.swift @@ -78,5 +78,82 @@ struct MCPHookTests { let link = directory.appendingPathComponent("link.json") try FileManager.default.createSymbolicLink(at: link, withDestinationURL: plain) #expect(throws: Error.self) { try readMCPHookPolicy(path: link.path) } + // A user-owned file cannot be told apart from a missing one: both + // report unavailablePolicy, not malformedPolicy, so a call still + // allows rather than denying on the strength of an attacker-writable + // file. + do { + _ = try readMCPHookPolicy(path: plain.path) + Issue.record("expected readMCPHookPolicy to throw") + } catch MCPHookError.unavailablePolicy { + } catch { + Issue.record("expected unavailablePolicy, got \(error)") + } + } + + @Test("a tool name with an extra __ segment still resolves to its server") + func extraSegments() throws { + let parsed = try MCPHookPolicy.parse(Data(policy.utf8)) + // "mcp__shadow__search__preview" previously split into 3 "__" parts + // and was treated as unrecognized (and so allowed) instead of being + // read as server "shadow", tool "search__preview". + for client in ["claude", "codex"] { + let verdict = try parsed.verdict(client: client, input: ["tool_name": "mcp__shadow__search__preview"]) + guard case .deny(let reason) = verdict else { + Issue.record("\(client) allowed an unapproved server behind an extra __ segment") + continue + } + #expect(reason.contains("shadow")) + } + // The same shape resolves to an approved server and allows. + #expect(try parsed.verdict(client: "claude", input: ["tool_name": "mcp__deixic-gateway__search__preview"]) == .allow) + } + + @Test("audit mode records a would-deny without denying") + func auditRecordsWouldDeny() throws { + let audit = try MCPHookPolicy.parse(Data(policy.replacingOccurrences(of: "enforce", with: "audit").utf8)) + let denied = try audit.verdict(client: "claude", input: ["tool_name": "mcp__shadow__search"]) + #expect(denied == .allow) + let record = audit.auditWouldDenyRecord(client: "claude", input: ["tool_name": "mcp__shadow__search"]) + #expect(record?.server == "shadow") + #expect(record?.tool == "search") + #expect(record?.rule == "unapproved_server") + // Never records an approved call or one that never resolves to a server. + #expect(audit.auditWouldDenyRecord(client: "claude", input: ["tool_name": "mcp__deixic-gateway__search"]) == nil) + #expect(audit.auditWouldDenyRecord(client: "claude", input: ["tool_name": "Bash"]) == nil) + // Enforce mode never records: it denies directly instead. + let enforce = try MCPHookPolicy.parse(Data(policy.utf8)) + #expect(enforce.auditWouldDenyRecord(client: "claude", input: ["tool_name": "mcp__shadow__search"]) == nil) + } + + @Test("a malformed policy denies only when it declares enforce mode") + func malformedPolicyMode() { + let input: [String: Any] = ["tool_name": "mcp__shadow__search"] + let enforceVerdict = mcpHookErrorVerdict(MCPHookError.malformedPolicy(mode: "enforce"), client: "claude", input: input) + guard case .deny(let reason) = enforceVerdict else { + Issue.record("a malformed policy declaring enforce mode did not deny") + return + } + #expect(reason.contains("shadow")) + #expect(reason.contains("Contact your administrator")) + #expect(mcpHookErrorVerdict(MCPHookError.malformedPolicy(mode: "audit"), client: "claude", input: input) == .allow) + #expect(mcpHookErrorVerdict(MCPHookError.malformedPolicy(mode: nil), client: "claude", input: input) == .allow) + #expect(mcpHookErrorVerdict(MCPHookError.unavailablePolicy, client: "claude", input: input) == .allow) + #expect(mcpHookErrorVerdict(MCPHookError.invalidInput, client: "claude", input: input) == .allow) + // No input recovered (e.g. stdin failed before the policy did) still allows. + #expect(mcpHookErrorVerdict(MCPHookError.malformedPolicy(mode: "enforce"), client: "claude", input: nil) == .allow) + // A malformed enforce-mode policy on a call that never resolves to a server still allows. + #expect(mcpHookErrorVerdict(MCPHookError.malformedPolicy(mode: "enforce"), client: "claude", input: ["tool_name": "Bash"]) == .allow) + } + + @Test("peekMCPHookPolicyMode reads a mode from an otherwise malformed policy") + func peekMode() { + #expect(peekMCPHookPolicyMode(Data(#"{"schema_version":1,"mode":"enforce","approved_servers":"not an array"}"#.utf8)) == "enforce") + #expect(peekMCPHookPolicyMode(Data(#"{"mode":"audit","surprise":true}"#.utf8)) == "audit") + #expect(peekMCPHookPolicyMode(Data(#"{"mode":"disabled"}"#.utf8)) == nil) + #expect(peekMCPHookPolicyMode(Data(#"{"schema_version":1}"#.utf8)) == nil) + #expect(peekMCPHookPolicyMode(Data("not json".utf8)) == nil) + #expect(peekMCPHookPolicyMode(Data("[]".utf8)) == nil) + #expect(peekMCPHookPolicyMode(Data(String(repeating: "x", count: 65_537).utf8)) == nil) } }