diff --git a/.repository-projection.json b/.repository-projection.json index 369408c..31f2a4c 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "68580466ba8135da1cfa25c6259800c32659d6d1", + "sourceSha": "56d36fc674bad5b86ba169de0e49905f44c8dcc2", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "7287d525f43da516baa3f76c63d4e13603f30785", + "priorProjectedBase": "c0338dbec4311ab2e8f0f455986aa5ec54257f50", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6", - "contentDigest": "a0d68f21dc50f154471ba8bbb8ef0d5ad91a560020bd9e09c6c3b91817235b81", + "contentDigest": "379aa13d48e400a1ca4bca32d082610dc9d5c6c89694a5670bacd39d12232dde", "publicationEligible": true } diff --git a/macos/Sources/MerlinClientCore/LocalDeviceStatus.swift b/macos/Sources/MerlinClientCore/LocalDeviceStatus.swift index ad9a157..d5dfc66 100644 --- a/macos/Sources/MerlinClientCore/LocalDeviceStatus.swift +++ b/macos/Sources/MerlinClientCore/LocalDeviceStatus.swift @@ -23,6 +23,41 @@ public struct LocalPostureCheck: Codable, Sendable, Equatable, Identifiable { } } +public enum LocalEnforcementAction: String, Codable, Sendable { + case blocked, stopped +} + +/// Display-only guidance for the most recent local enforcement decision. +/// The collector sends no process path, command line, or rule payload across IPC. +public struct LocalEnforcementNotice: Codable, Sendable, Equatable { + public let action: LocalEnforcementAction + public let occurredAt: Date + public let approvedName: String? + public let approvedURL: URL? + + public init(action: LocalEnforcementAction, occurredAt: Date, approvedName: String?, approvedURL: URL?) { + self.action = action + self.occurredAt = occurredAt + self.approvedName = approvedName + self.approvedURL = approvedURL + } + + public func isRecent(at now: Date = Date()) -> Bool { + (0..<3600).contains(now.timeIntervalSince(occurredAt)) + } + + fileprivate var isValid: Bool { + guard occurredAt.timeIntervalSince1970.isFinite, + (approvedName == nil) == (approvedURL == nil) else { return false } + guard let approvedName, let approvedURL else { return true } + return !approvedName.isEmpty && approvedName.utf8.count <= 80 && + !approvedName.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) && + approvedURL.absoluteString.utf8.count <= 2048 && approvedURL.scheme == "https" && + approvedURL.host != nil && approvedURL.user == nil && approvedURL.password == nil && + approvedURL.query == nil && approvedURL.fragment == nil + } +} + /// Non-authoritative local display data. Never use this snapshot for authorization. /// No credentials, raw command output, spool records, or user inventory cross IPC. public struct LocalDeviceStatus: Codable, Sendable, Equatable { @@ -39,10 +74,11 @@ public struct LocalDeviceStatus: Codable, Sendable, Equatable { public let checks: [LocalPostureCheck] /// Latest accepted authenticated heartbeat, not proof the server accepted posture. public let lastServerContact: Date? + public let enforcement: LocalEnforcementNotice? public init(observedAt: Date, deviceID: String?, collectorRunning: Bool, enrollment: LocalEnrollmentState, posture: LocalPostureSummary, - checks: [LocalPostureCheck], lastServerContact: Date?) { + checks: [LocalPostureCheck], lastServerContact: Date?, enforcement: LocalEnforcementNotice? = nil) { self.schemaVersion = 1 self.observedAt = observedAt self.deviceID = deviceID @@ -51,6 +87,7 @@ public struct LocalDeviceStatus: Codable, Sendable, Equatable { self.posture = posture self.checks = checks self.lastServerContact = lastServerContact + self.enforcement = enforcement } public func isStale(at now: Date = Date()) -> Bool { @@ -66,7 +103,8 @@ public struct LocalDeviceStatus: Codable, Sendable, Equatable { result.checks.count == checkIDs.count, Set(result.checks.map(\.id)) == Set(checkIDs), result.observedAt.timeIntervalSince1970.isFinite, - result.lastServerContact?.timeIntervalSince1970.isFinite ?? true else { + result.lastServerContact?.timeIntervalSince1970.isFinite ?? true, + result.enforcement?.isValid ?? true else { throw LocalStatusError.invalidResponse } return result diff --git a/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift b/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift index 9cceccb..5ca69c0 100644 --- a/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift +++ b/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift @@ -32,6 +32,9 @@ struct EndpointDetailView: View { EndpointSessionView(session: session).padding(12) } if let status = model.status { + if let notice = status.enforcement { + GroupBox { EnforcementNoticeView(notice: notice).padding(12) } + } reportingSection(status) TimelineView(.periodic(from: .now, by: 15)) { context in checksSection(status, stale: status.isStale(at: context.date) || !status.collectorRunning) @@ -107,6 +110,30 @@ struct EndpointDetailView: View { } } +struct EnforcementNoticeView: View { + let notice: LocalEnforcementNotice + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(notice.action == .blocked ? "App blocked by policy" : "App stopped by policy", + systemImage: "exclamationmark.shield.fill") + .font(.headline) + Text("Deixic Endpoint applied your organization's device policy at \(notice.occurredAt.formatted(date: .abbreviated, time: .shortened)).") + .font(.callout) + if let name = notice.approvedName, let url = notice.approvedURL { + Link("Use approved tool: \(name)", destination: url) + .font(.callout) + Text("Your administrator configured this alternative.") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("Contact your administrator for an approved alternative.") + .font(.callout).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + private struct PostureCheckRow: View { let check: LocalPostureCheck let stale: Bool diff --git a/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift b/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift index 7f3ae6c..8d6d41d 100644 --- a/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift +++ b/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift @@ -18,7 +18,7 @@ struct MerlinEndpointApp: App { MenuBarExtra { EndpointPopover(model: model, session: session, updater: updater) } label: { - Label("Deixic Endpoint", systemImage: "shield.lefthalf.filled") + Label("Deixic Endpoint", systemImage: model.status?.enforcement == nil ? "shield.lefthalf.filled" : "exclamationmark.shield.fill") } .menuBarExtraStyle(.window) @@ -44,6 +44,10 @@ struct EndpointPopover: View { TimelineView(.periodic(from: .now, by: 15)) { context in LocalStatusSummary(model: model, now: context.date, compact: true) } + if let notice = model.status?.enforcement { + Divider() + EnforcementNoticeView(notice: notice) + } Divider() EndpointSessionView(session: session, compact: true) Divider() diff --git a/macos/Sources/MerlinMacOS/CLI.swift b/macos/Sources/MerlinMacOS/CLI.swift index 25373cf..cc626c5 100644 --- a/macos/Sources/MerlinMacOS/CLI.swift +++ b/macos/Sources/MerlinMacOS/CLI.swift @@ -248,27 +248,27 @@ struct RunCommand: ParsableCommand { try withExtendedLifetime(syncClient) { switch provider { case .es: - let p = try makeES(rulesBox: rulesBox, spool: spoolWriter) + let p = try makeES(rulesBox: rulesBox, spool: spoolWriter, localStatus: localStatus) merlinLog("info", "merlin is running; ctrl-c to stop") withExtendedLifetime(p) { parkUntilSignal() } case .kqueue: - let p = try makeKqueue(rulesBox: rulesBox, spool: spoolWriter) + let p = try makeKqueue(rulesBox: rulesBox, spool: spoolWriter, localStatus: localStatus) merlinLog("info", "merlin is running; ctrl-c to stop") withExtendedLifetime(p) { parkUntilSignal() } case .bsm: - let p = try makeBSM(rulesBox: rulesBox, spool: spoolWriter) + let p = try makeBSM(rulesBox: rulesBox, spool: spoolWriter, localStatus: localStatus) merlinLog("info", "merlin is running; ctrl-c to stop") withExtendedLifetime(p) { parkUntilSignal() } case .auto: do { - let p = try makeES(rulesBox: rulesBox, spool: spoolWriter) + let p = try makeES(rulesBox: rulesBox, spool: spoolWriter, localStatus: localStatus) merlinLog("info", "merlin is running; ctrl-c to stop") withExtendedLifetime(p) { parkUntilSignal() } } catch { merlinLog("warn", "ES provider unavailable: \(error)") merlinLog("warn", "falling back to kqueue provider (telemetry only)") do { - let p = try makeKqueue(rulesBox: rulesBox, spool: spoolWriter) + let p = try makeKqueue(rulesBox: rulesBox, spool: spoolWriter, localStatus: localStatus) merlinLog("info", "merlin is running; ctrl-c to stop") withExtendedLifetime(p) { parkUntilSignal() } } catch { @@ -278,7 +278,7 @@ struct RunCommand: ParsableCommand { // only for older systems where it still works. merlinLog("warn", "kqueue provider unavailable: \(error)") merlinLog("warn", "falling back to OpenBSM provider (telemetry only; dead on macOS 14+)") - let p = try makeBSM(rulesBox: rulesBox, spool: spoolWriter) + let p = try makeBSM(rulesBox: rulesBox, spool: spoolWriter, localStatus: localStatus) merlinLog("info", "merlin is running; ctrl-c to stop") withExtendedLifetime(p) { parkUntilSignal() } } @@ -290,8 +290,11 @@ struct RunCommand: ParsableCommand { } } - private func makeES(rulesBox: RulesBox, spool: SpoolWriter) throws -> ESProvider { - let engine = Engine(rulesBox: rulesBox, spool: spool, canBlock: true) + private func makeES(rulesBox: RulesBox, spool: SpoolWriter, localStatus: LocalStatusStore) throws -> ESProvider { + var engine = Engine(rulesBox: rulesBox, spool: spool, canBlock: true) + engine.onEnforcement = { action, alternative in + localStatus.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } let provider = ESProvider(engine: engine) try provider.start() merlinLog("info", "provider: Endpoint Security (AUTH_EXEC enforcement active)") @@ -340,8 +343,11 @@ struct RunCommand: ParsableCommand { } } - private func makeKqueue(rulesBox: RulesBox, spool: SpoolWriter) throws -> KqueueProvider { - let engine = Engine(rulesBox: rulesBox, spool: spool, canBlock: false) + private func makeKqueue(rulesBox: RulesBox, spool: SpoolWriter, localStatus: LocalStatusStore) throws -> KqueueProvider { + var engine = Engine(rulesBox: rulesBox, spool: spool, canBlock: false) + engine.onEnforcement = { action, alternative in + localStatus.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } let degraded = engine.degradedBlockRuleNames() if !degraded.isEmpty { merlinLog("warn", "block rules \(degraded) cannot deny execs under the kqueue provider; degrading to kill+log") @@ -352,8 +358,11 @@ struct RunCommand: ParsableCommand { return provider } - private func makeBSM(rulesBox: RulesBox, spool: SpoolWriter) throws -> BSMProvider { - let engine = Engine(rulesBox: rulesBox, spool: spool, canBlock: false) + private func makeBSM(rulesBox: RulesBox, spool: SpoolWriter, localStatus: LocalStatusStore) throws -> BSMProvider { + var engine = Engine(rulesBox: rulesBox, spool: spool, canBlock: false) + engine.onEnforcement = { action, alternative in + localStatus.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } let degraded = engine.degradedBlockRuleNames() if !degraded.isEmpty { merlinLog("warn", "block rules \(degraded) cannot deny execs under the BSM provider; degrading to kill+log") diff --git a/macos/Sources/MerlinMacOS/Engine.swift b/macos/Sources/MerlinMacOS/Engine.swift index e1901ac..27857cc 100644 --- a/macos/Sources/MerlinMacOS/Engine.swift +++ b/macos/Sources/MerlinMacOS/Engine.swift @@ -4,6 +4,7 @@ // handleExec ≈ telemetry handle_exec (log/kill rules + spool) import Foundation +import MerlinClientCore /// Hot-swappable rules container: the sync client replaces the ruleset /// atomically; readers see a consistent snapshot (AGENTS.md — a half- @@ -56,6 +57,7 @@ struct Engine: Sendable { /// enrichment point (bounded work per event). var suspendHashMaxBytes: Int64 = 64 << 20 let signingCache = SigningInfoCache() + var onEnforcement: @Sendable (LocalEnforcementAction, ApprovedAlternative?) -> Void = { _, _ in } /// Convenience for existing call sites/tests: wraps a static ruleset /// (no hot-reload needed). @@ -148,8 +150,8 @@ struct Engine: Sendable { cdhash: cdhash, teamId: teamId ) - let matched = mostSpecific(rules.rules.filter { $0.action == .block && $0.matches(ctx) }) - .map(\.name) + let matchedRules = mostSpecific(rules.rules.filter { $0.action == .block && $0.matches(ctx) }) + let matched = matchedRules.map(\.name) if matched.isEmpty { return Verdict(allow: true, matched: []) } if isFailsafe(pid: pid, teamId: teamId) { merlinLog("warn", "failsafe: block rules \(matched) matched pid \(pid) (\(path)) but it is protected (launchd/self/own team)") @@ -161,6 +163,9 @@ struct Engine: Sendable { sha256: sha256, cdhash: cdhash, matchedRules: matched, pidStartSec: identity?.startSec, pidStartUsec: identity?.startUsec )) + if let rule = matchedRules.first { + onEnforcement(.blocked, rule.approvedAlternative) + } return Verdict(allow: false, matched: matched) } @@ -273,6 +278,9 @@ struct Engine: Sendable { pidStartSec: identity?.startSec, pidStartUsec: identity?.startUsec, viaSuspend: killedViaSuspend ? true : nil )) + if let rule = matched.first(where: { killed.contains($0.name) }) { + onEnforcement(.stopped, rule.approvedAlternative) + } } } diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index af6835c..ef3ea01 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -46,7 +46,7 @@ struct DeviceInventory: Encodable, Sendable { struct DeviceAgentCLI: Encodable, Sendable { let name: String } struct DeviceAgentApp: Encodable, Sendable { let name: String } -struct DeviceMCPServer: Encodable, Sendable { let client: String; let name: String; let source: String } +struct DeviceMCPServer: Encodable, Sendable { let client: String; let name: String; let source: String; let transport: String } struct DeviceAgentAsset: Encodable, Sendable { let client: String; let kind: String; let name: String; let source: String } struct DevicePackage: Encodable, Sendable { @@ -177,7 +177,109 @@ private func collectMacAgentDiscovery() -> (clis: [DeviceAgentCLI], apps: [Devic var isDirectory: ObjCBool = false return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue } - return collectMacAgentDiscovery(homes: homes, systemBins: ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"]) + let base = collectMacAgentDiscovery(homes: homes, systemBins: ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"]) + let project = collectMacProjectAgentDiscovery(roots: configuredMacAgentWorkspaceRoots()) + let servers = Array(Set(base.servers.map { "\($0.client)\u{0}\($0.name)\u{0}\($0.source)\u{0}\($0.transport)" } + project.servers.map { "\($0.client)\u{0}\($0.name)\u{0}\($0.source)\u{0}\($0.transport)" })).sorted().prefix(128).compactMap { entry -> DeviceMCPServer? in + let parts = entry.split(separator: "\u{0}") + guard parts.count == 4 else { return nil } + return DeviceMCPServer(client: String(parts[0]), name: String(parts[1]), source: String(parts[2]), transport: String(parts[3])) + } + let assets = Array(Set(base.assets.map { "\($0.client)\u{0}\($0.kind)\u{0}\($0.name)\u{0}\($0.source)" } + project.assets.map { "\($0.client)\u{0}\($0.kind)\u{0}\($0.name)\u{0}\($0.source)" })).sorted().prefix(128).compactMap { entry -> DeviceAgentAsset? in + let parts = entry.split(separator: "\u{0}") + guard parts.count == 4 else { return nil } + return DeviceAgentAsset(client: String(parts[0]), kind: String(parts[1]), name: String(parts[2]), source: String(parts[3])) + } + return (base.clis, base.apps, servers, assets) +} + +private func configuredMacAgentWorkspaceRoots() -> [String] { + guard let raw = ProcessInfo.processInfo.environment["MERLIN_AGENT_WORKSPACE_ROOTS"], raw.utf8.count <= 4096, + let data = raw.data(using: .utf8), let paths = try? JSONDecoder().decode([String].self, from: data) else { return [] } + return Array(paths.filter { path in + path.hasPrefix("/") && path.utf8.count <= 512 && !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) + }.prefix(8)) +} + +private func macProjectDirectory(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 && (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFDIR) +} + +func collectMacProjectAgentDiscovery(roots: [String]) -> (servers: [DeviceMCPServer], assets: [DeviceAgentAsset]) { + let configs: [(String, String, Bool)] = [("claude", ".mcp.json", false), ("claude", ".claude/settings.json", false), ("cursor", ".cursor/mcp.json", false), ("codex", ".codex/config.toml", true)] + let assetDirs: [(String, String, String, String)] = [("agents", "skill", ".agents/skills", "skill"), ("claude", "skill", ".claude/skills", "skill"), ("claude", "agent", ".claude/agents", "md"), ("claude", "plugin", ".claude/plugins", "plugin"), ("codex", "skill", ".codex/skills", "skill"), ("cursor", "skill", ".cursor/skills", "skill"), ("maestro", "plugin", ".maestro/plugins", "plugin"), ("maestro", "plugin", ".composer/plugins", "plugin")] + var found = Set() + var assets = Set() + var pluginConfigReads = 0 + for root in roots.prefix(8) where macProjectDirectory(root) { + let children = boundedAgentDirectoryEntries(root).filter { macProjectDirectory("\(root)/\($0)") }.prefix(32).map { "\(root)/\($0)" } + for project in [root] + children { + for (client, relative, isTOML) in configs { + let path = "\(project)/\(relative)" + if relative.contains("/"), !macProjectDirectory("\(project)/\(relative.split(separator: "/")[0])") { continue } + guard let data = readAgentConfigNoFollow(path) else { continue } + assets.insert("\(client)\u{0}config\u{0}project\u{0}project/\(relative)") + if relative == ".claude/settings.json", + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let plugins = object["enabledPlugins"] as? [String: Bool] { + for (name, enabled) in plugins where enabled && safeAgentAssetName(name) { + assets.insert("claude\u{0}plugin\u{0}\(name)\u{0}project/.claude/settings.json") + } + } + let entries: [(String, String)] + if isTOML { + let body = String(data: data, encoding: .utf8) ?? "" + entries = tomlMCPEntries(body) + } else { + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + entries = mcpEntries(object?["mcpServers"] ?? object?["servers"]) + } + for (name, transport) in entries where safeAgentAssetName(name) { + found.insert("\(client)\u{0}\(name)\u{0}project/\(relative)\u{0}\(transport)") + } + } + for (client, kind, relative, format) in assetDirs { + let parts = relative.split(separator: "/") + guard parts.count == 2, macProjectDirectory("\(project)/\(parts[0])") else { continue } + let directory = "\(project)/\(relative)" + for entry in boundedAgentDirectoryEntries(directory) { + let path = "\(directory)/\(entry)" + var info = stat() + guard lstat(path, &info) == 0 else { continue } + let type = info.st_mode & mode_t(S_IFMT) + let name: String? + if format == "skill" && type == mode_t(S_IFDIR) { + var manifest = stat() + name = lstat("\(path)/SKILL.md", &manifest) == 0 && (manifest.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) ? entry : nil + } else if format == "plugin" && type == mode_t(S_IFDIR) { + name = entry + } else if format == "md" && type == mode_t(S_IFREG) && entry.hasSuffix(".md") { + name = String(entry.dropLast(3)) + } else { name = nil } + if let name, safeAgentAssetName(name) { + assets.insert("\(client)\u{0}\(kind)\u{0}\(name)\u{0}project/\(relative)") + if client == "maestro" && kind == "plugin" { + for config in ["mcp.json", ".mcp.json"] where pluginConfigReads < 32 { + pluginConfigReads += 1 + guard let data = readAgentConfigNoFollow("\(path)/\(config)") else { continue } + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + for (server, transport) in mcpEntries(object?["mcpServers"] ?? object?["servers"]) where safeAgentAssetName(server) { + found.insert("maestro\u{0}\(server)\u{0}project/\(relative)/*/\(config)\u{0}\(transport)") + } + } + } + } + } + } + } + } + return (found.sorted().prefix(128).compactMap { entry in + let parts = entry.split(separator: "\u{0}") + return parts.count == 4 ? DeviceMCPServer(client: String(parts[0]), name: String(parts[1]), source: String(parts[2]), transport: String(parts[3])) : nil + }, assets.sorted().prefix(128).compactMap { entry in + let parts = entry.split(separator: "\u{0}") + return parts.count == 4 ? DeviceAgentAsset(client: String(parts[0]), kind: String(parts[1]), name: String(parts[2]), source: String(parts[3])) : nil + }) } // Fixed probes only: no CLI execution and no configuration values are emitted. @@ -265,8 +367,8 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ assetNames.insert("\(client)\u{0}\(kind)\u{0}\(name)\u{0}\(relative)") if client == "gemini" && kind == "extension", let data = readAgentConfigNoFollow("\(path)/gemini-extension.json") { let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] - for server in ((object?["mcpServers"] as? [String: Any]).map { Array($0.keys) } ?? []) where safeAgentAssetName(server) { - found.insert("gemini\u{0}\(server)\u{0}.gemini/extensions/*/gemini-extension.json") + for (server, transport) in mcpEntries(object?["mcpServers"]) where safeAgentAssetName(server) { + found.insert("gemini\u{0}\(server)\u{0}.gemini/extensions/*/gemini-extension.json\u{0}\(transport)") } } if client == "maestro" && kind == "plugin" { @@ -274,8 +376,8 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ guard let data = readAgentConfigNoFollow("\(path)/\(config)") else { continue } pluginConfigReads += 1 let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] - for server in ((object?["mcpServers"] as? [String: Any]).map { Array($0.keys) } ?? []) where safeAgentAssetName(server) { - found.insert("maestro\u{0}\(server)\u{0}\(relative)/*/\(config)") + for (server, transport) in mcpEntries(object?["mcpServers"]) where safeAgentAssetName(server) { + found.insert("maestro\u{0}\(server)\u{0}\(relative)/*/\(config)\u{0}\(transport)") } } } @@ -295,30 +397,22 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ } continue } - let names: [String] + let entries: [(String, String)] if client == "amp" { let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] - names = (object?["amp.mcpServers"] as? [String: Any]).map { Array($0.keys) } ?? [] + entries = mcpEntries(object?["amp.mcpServers"]) } else if client == "opencode" { let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] - names = (object?["mcp"] as? [String: Any]).map { Array($0.keys) } ?? [] + entries = mcpEntries(object?["mcp"]) } else if isTOML { let body = String(data: data, encoding: .utf8) ?? "" - names = body.split(separator: "\n").compactMap { line in - let section = line.trimmingCharacters(in: .whitespaces) - guard section.hasPrefix("[mcp_servers."), section.hasSuffix("]") else { return nil } - let raw = String(section.dropFirst("[mcp_servers.".count).dropLast()) - let quoted = raw.hasPrefix("\"") && raw.hasSuffix("\"") && raw.count >= 2 - let name = quoted ? String(raw.dropFirst().dropLast()) : raw - return name.isEmpty || name.contains(where: { "[]".contains($0) }) || (!quoted && name.contains(".")) ? nil : name - } + entries = tomlMCPEntries(body) } else { let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] - let entries = (object?["mcpServers"] ?? object?["servers"]) as? [String: Any] - names = entries.map { Array($0.keys) } ?? [] + entries = mcpEntries(object?["mcpServers"] ?? object?["servers"]) } - for name in names where name.utf8.count <= 128 && !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) { - found.insert("\(client)\u{0}\(name)\u{0}\(relative)") + for (name, transport) in entries where name.utf8.count <= 128 && !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) { + found.insert("\(client)\u{0}\(name)\u{0}\(relative)\u{0}\(transport)") if found.count >= 128 { break } } if found.count >= 128 { break } @@ -327,8 +421,8 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ } let servers = found.sorted().prefix(128).compactMap { entry -> DeviceMCPServer? in let parts = entry.split(separator: "\u{0}") - guard parts.count == 3 else { return nil } - return DeviceMCPServer(client: String(parts[0]), name: String(parts[1]), source: String(parts[2])) + guard parts.count == 4 else { return nil } + return DeviceMCPServer(client: String(parts[0]), name: String(parts[1]), source: String(parts[2]), transport: String(parts[3])) } let assets = assetNames.sorted().prefix(128).compactMap { entry -> DeviceAgentAsset? in let parts = entry.split(separator: "\u{0}") @@ -338,6 +432,51 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ return (clis, apps, servers, assets) } +private func mcpEntries(_ raw: Any?) -> [(String, String)] { + guard let definitions = raw as? [String: Any] else { return [] } + return definitions.map { name, rawDefinition in + let definition = rawDefinition as? [String: Any] ?? [:] + let hasURL = ["url", "httpUrl", "http_url"].contains { definition[$0] is String } + let hasCommand = definition["command"] is String + let transport = hasURL == hasCommand ? "unknown" : (hasURL ? "remote" : "stdio") + return (name, transport) + } +} + +private func tomlMCPEntries(_ body: String) -> [(String, String)] { + var entries: [(String, String)] = [] + var name: String? + var hasURL = false + var hasCommand = false + func finish() { + if let name { + entries.append((name, hasURL == hasCommand ? "unknown" : (hasURL ? "remote" : "stdio"))) + } + } + for line in body.split(separator: "\n") { + let text = line.trimmingCharacters(in: .whitespaces) + if text.hasPrefix("[") && text.hasSuffix("]") { + finish() + name = nil + hasURL = false + hasCommand = false + if text.hasPrefix("[mcp_servers.") { + let raw = String(text.dropFirst("[mcp_servers.".count).dropLast()) + let quoted = raw.hasPrefix("\"") && raw.hasSuffix("\"") && raw.count >= 2 + let candidate = quoted ? String(raw.dropFirst().dropLast()) : raw + if !candidate.isEmpty && !candidate.contains(where: { "[]".contains($0) }) && (quoted || !candidate.contains(".")) { + name = candidate + } + } + } else if name != nil, let key = text.split(separator: "=", maxSplits: 1).first?.trimmingCharacters(in: .whitespaces) { + if key == "url" || key == "http_url" { hasURL = true } + if key == "command" { hasCommand = true } + } + } + finish() + return entries +} + private func boundedAgentDirectoryEntries(_ path: String) -> [String] { let fd = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) guard fd >= 0 else { return [] } diff --git a/macos/Sources/MerlinMacOS/LocalStatusStore.swift b/macos/Sources/MerlinMacOS/LocalStatusStore.swift index 033a66e..50d8b06 100644 --- a/macos/Sources/MerlinMacOS/LocalStatusStore.swift +++ b/macos/Sources/MerlinMacOS/LocalStatusStore.swift @@ -10,6 +10,12 @@ final class LocalStatusStore: @unchecked Sendable { private var deviceID: String? private var enrollment: LocalEnrollmentState = .unconfigured private var lastServerContact: Date? + private var enforcement: LocalEnforcementNotice? + + func recordEnforcement(action: LocalEnforcementAction, approvedName: String?, approvedURL: URL?, at now: Date = Date()) { + let notice = LocalEnforcementNotice(action: action, occurredAt: now, approvedName: approvedName, approvedURL: approvedURL) + lock.withLock { enforcement = notice } + } func configure(deviceID: String?) { lock.withLock { @@ -73,7 +79,8 @@ final class LocalStatusStore: @unchecked Sendable { return LocalDeviceStatus( observedAt: report.flatMap { Double($0.collectedAt) }.map(Date.init(timeIntervalSince1970:)) ?? .distantPast, deviceID: safeID, collectorRunning: true, enrollment: enrollment, - posture: summary, checks: checks, lastServerContact: lastServerContact) + posture: summary, checks: checks, lastServerContact: lastServerContact, + enforcement: enforcement.flatMap { $0.isRecent() ? $0 : nil }) } } } diff --git a/macos/Tests/MerlinMacOSTests/LocalStatusTests.swift b/macos/Tests/MerlinMacOSTests/LocalStatusTests.swift index c278af8..4905a75 100644 --- a/macos/Tests/MerlinMacOSTests/LocalStatusTests.swift +++ b/macos/Tests/MerlinMacOSTests/LocalStatusTests.swift @@ -45,6 +45,30 @@ import Testing #expect(try LocalDeviceStatus.decode(encoded).checks.first?.status == .finding) } + @Test func enforcementGuidanceIsRecentAndContainsOnlyApprovedMapping() throws { + let store = LocalStatusStore() + let approvedURL = try #require(URL(string: "https://approved.example.com/editor")) + store.recordEnforcement(action: .blocked, approvedName: "Approved editor", approvedURL: approvedURL) + let data = try JSONEncoder().encode(store.snapshot()) + let text = String(decoding: data, as: UTF8.self) + #expect(text.contains("Approved editor")) + #expect(!text.contains("process")) + #expect(try LocalDeviceStatus.decode(data).enforcement?.approvedURL == approvedURL) + + store.recordEnforcement(action: .stopped, approvedName: nil, approvedURL: nil, + at: Date().addingTimeInterval(-3601)) + #expect(store.snapshot().enforcement == nil) + } + + @Test func malformedGuidanceIsRejectedAtIPCBoundary() throws { + let status = LocalDeviceStatus(observedAt: Date(), deviceID: nil, collectorRunning: true, + enrollment: .configured, posture: .unknown, + checks: LocalStatusStore().snapshot().checks, lastServerContact: nil, + enforcement: LocalEnforcementNotice(action: .blocked, occurredAt: Date(), + approvedName: "Injected", approvedURL: URL(string: "https://user:secret@example.com"))) + #expect(throws: (any Error).self) { try LocalDeviceStatus.decode(JSONEncoder().encode(status)) } + } + @Test func unknownChecksDoNotBecomeSecure() { let store = LocalStatusStore() store.publish(makeDevicePostureReport(snapshot: PostureSnapshot(values: ["filevault": "unknown"], findings: [:]))) diff --git a/macos/Tests/MerlinMacOSTests/RulesTests.swift b/macos/Tests/MerlinMacOSTests/RulesTests.swift index fbda16e..4d8f810 100644 --- a/macos/Tests/MerlinMacOSTests/RulesTests.swift +++ b/macos/Tests/MerlinMacOSTests/RulesTests.swift @@ -1,4 +1,5 @@ import Foundation +import MerlinClientCore import Testing @testable import MerlinMacOS @@ -27,6 +28,44 @@ struct RulesTests { #expect(throws: Error.self) { try rule(base.replacingOccurrences(of: "action: block", with: "action: log")) } #expect(throws: Error.self) { try rule(base.replacingOccurrences(of: "https://tools.example.com/editor", with: "http://tools.example.com/editor")) } } + + @Test("an actual deny publishes the mapped alternative, and a failsafe does not") + func deniedExecutionGuidance() throws { + let parsed = try rule("name: block-cursor\nmatch:\n path_basename: Cursor\naction: block\napproved_alternative:\n name: Approved editor\n url: https://tools.example.com/editor\n") + let store = LocalStatusStore() + var engine = Engine(rules: Rules(rules: [parsed]), + spool: try SpoolWriter(path: NSTemporaryDirectory() + "merlin-guidance-\(UUID().uuidString).jsonl"), + canBlock: true) + engine.onEnforcement = { action, alternative in + store.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } + #expect(engine.authVerdict(pid: 1, uid: 0, path: "/tmp/Cursor", sha256: nil, cdhash: nil).allow) + #expect(store.snapshot().enforcement == nil) + #expect(!engine.authVerdict(pid: 42_424, uid: 501, path: "/tmp/Cursor", sha256: nil, cdhash: nil).allow) + #expect(store.snapshot().enforcement?.action == .blocked) + #expect(store.snapshot().enforcement?.approvedName == "Approved editor") + } + + @Test("reactive kill guidance appears only after a successful kill") + func stoppedExecutionGuidance() throws { + let parsed = try rule("name: stop-claude\nmatch:\n path_basename: claude\naction: kill\napproved_alternative:\n name: Approved agent\n url: https://tools.example.com/agent\n") + let store = LocalStatusStore() + let identity = ProcessIdentity(startSec: 1, startUsec: 1) + var engine = Engine(rules: Rules(rules: [parsed]), + spool: try SpoolWriter(path: NSTemporaryDirectory() + "merlin-guidance-\(UUID().uuidString).jsonl"), + canBlock: false, killImpl: { _ in 0 }, selfPID: 42_424, + processIdentity: { _ in identity }) + engine.onEnforcement = { action, alternative in + store.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } + engine.handleExec(pid: 1, ppid: nil, uid: 0, comm: "claude", exe: "/tmp/claude", + cmdline: nil, sha256: nil, cdhash: nil, identity: identity) + #expect(store.snapshot().enforcement == nil) + engine.handleExec(pid: 123, ppid: nil, uid: 501, comm: "claude", exe: "/tmp/claude", + cmdline: nil, sha256: nil, cdhash: nil, identity: identity) + #expect(store.snapshot().enforcement?.action == .stopped) + #expect(store.snapshot().enforcement?.approvedName == "Approved agent") + } @Test("cross-loads the Linux repo's rules/block-demo.yaml") func blockDemoYaml() throws { let path = Self.repoRoot.appendingPathComponent("rules/block-demo.yaml").path diff --git a/macos/Tests/MerlinMacOSTests/SuspendTests.swift b/macos/Tests/MerlinMacOSTests/SuspendTests.swift index 3004b71..0857ee9 100644 --- a/macos/Tests/MerlinMacOSTests/SuspendTests.swift +++ b/macos/Tests/MerlinMacOSTests/SuspendTests.swift @@ -60,6 +60,17 @@ private func spoolEvents(_ path: String) -> [[String: Any]] { } } +private func waitForSleep(_ pid: pid_t) throws -> ProcessIdentity { + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + if let process = procInfo(pid), process.comm == "sleep" { + return process.identity + } + usleep(10_000) + } + throw MerlinError.plain("child did not exec /bin/sleep before suspend test deadline") +} + @Suite("suspend guardrails", .serialized) struct SuspendGuardrailTests { private func makeEngine( @@ -99,7 +110,7 @@ struct SuspendGuardrailTests { @Test("no second-stage match: child is stopped, inspected, and resumed (suspend_released)") func resumeOnMismatch() throws { let signals = SignalRecorder(forReal: true) - let (engine, spoolPath) = try makeEngine(rulesYaml: """ + var (engine, spoolPath) = try makeEngine(rulesYaml: """ rules: - name: susp-sleep match: {path_basename: sleep} @@ -108,8 +119,8 @@ struct SuspendGuardrailTests { defer { try? FileManager.default.removeItem(atPath: spoolPath) } let child = forkExec("/bin/sleep", ["30"]) defer { Darwin.kill(child, SIGKILL); reap(child) } - usleep(200_000) // let the child exec - let identity = procInfo(child)?.identity + let identity = try waitForSleep(child) + engine.processIdentity = { pid in pid == child ? identity : procInfo(pid)?.identity } engine.handleExec( pid: child, ppid: getpid(), uid: getuid(), comm: "sleep", exe: "/bin/sleep", cmdline: "sleep 30", sha256: nil, cdhash: nil, identity: identity @@ -126,7 +137,7 @@ struct SuspendGuardrailTests { @Test("second-stage kill rule matches: frozen child is killed (via_suspend)") func killOnMatch() throws { let signals = SignalRecorder(forReal: true) - let (engine, spoolPath) = try makeEngine(rulesYaml: """ + var (engine, spoolPath) = try makeEngine(rulesYaml: """ rules: - name: susp-sleep match: {path_basename: sleep} @@ -140,8 +151,8 @@ struct SuspendGuardrailTests { defer { try? FileManager.default.removeItem(atPath: spoolPath) } let child = forkExec("/bin/sleep", ["30"]) defer { Darwin.kill(child, SIGKILL); reap(child) } - usleep(200_000) - let identity = procInfo(child)?.identity + let identity = try waitForSleep(child) + engine.processIdentity = { pid in pid == child ? identity : procInfo(pid)?.identity } engine.handleExec( pid: child, ppid: getpid(), uid: getuid(), comm: "sleep", exe: "/bin/sleep", cmdline: "sleep 30", sha256: nil, cdhash: nil, identity: identity diff --git a/macos/Tests/MerlinMacOSTests/SyncTests.swift b/macos/Tests/MerlinMacOSTests/SyncTests.swift index a41d514..2f215b5 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -177,6 +177,9 @@ struct SyncTests { #expect(discovered.apps.map(\.name) == ["cursor"]) #expect(discovered.servers.map { "\($0.client):\($0.name)" } == ["amp:db", "codex:github", "cursor:docs", "gemini:search", "maestro:managed", "maestro:pluginsearch"]) #expect(discovered.servers.contains { $0.client == "codex" && $0.source == ".codex/config.toml" }) + #expect(discovered.servers.contains { $0.client == "codex" && $0.transport == "remote" }) + #expect(discovered.servers.contains { $0.client == "cursor" && $0.transport == "stdio" }) + #expect(discovered.servers.contains { $0.client == "gemini" && $0.transport == "unknown" }) #expect(discovered.servers.contains { $0.client == "gemini" && $0.source == ".gemini/extensions/*/gemini-extension.json" }) #expect(discovered.servers.contains { $0.client == "maestro" && $0.name == "managed" && $0.source == ".maestro/config.toml" }) #expect(discovered.servers.contains { $0.client == "maestro" && $0.name == "pluginsearch" && $0.source == ".maestro/plugins/*/mcp.json" }) @@ -199,6 +202,32 @@ struct SyncTests { #expect(collectMacAgentDiscovery(homes: [home], systemBins: []).servers.count == 5) } + @Test("project discovery reports fixed labels and ignores linked configs") + func projectAgentDiscovery() throws { + let root = NSTemporaryDirectory() + "merlin-project-discovery-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: root) } + let project = root + "/customer-private" + try FileManager.default.createDirectory(atPath: project + "/.cursor", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: project + "/.claude/skills/review", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: project + "/.maestro/plugins/audit", withIntermediateDirectories: true) + try #"{"mcpServers":{"pluginsearch":{"url":"https://private.example/mcp"}}}"#.write(toFile: project + "/.maestro/plugins/audit/mcp.json", atomically: true, encoding: .utf8) + try #"{"enabledPlugins":{"audit@marketplace":true,"off@marketplace":false},"secret":"private-secret"}"#.write(toFile: project + "/.claude/settings.json", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"docs":{"command":"private-secret"}}}"#.write(toFile: project + "/.cursor/mcp.json", atomically: true, encoding: .utf8) + try "private-secret".write(toFile: project + "/.claude/skills/review/SKILL.md", atomically: true, encoding: .utf8) + let discovered = collectMacProjectAgentDiscovery(roots: [root]) + #expect(discovered.servers.contains { $0.name == "docs" && $0.source == "project/.cursor/mcp.json" && $0.transport == "stdio" }) + #expect(discovered.servers.contains { $0.name == "pluginsearch" && $0.source == "project/.maestro/plugins/*/mcp.json" && $0.transport == "remote" }) + #expect(discovered.assets.contains { $0.name == "review" && $0.source == "project/.claude/skills" }) + #expect(discovered.assets.contains { $0.name == "audit@marketplace" && $0.kind == "plugin" && $0.source == "project/.claude/settings.json" }) + #expect(discovered.assets.contains { $0.name == "audit" && $0.kind == "plugin" && $0.source == "project/.maestro/plugins" }) + #expect(!discovered.assets.contains { $0.name == "off@marketplace" }) + let payload = String(decoding: try JSONEncoder().encode(discovered.servers), as: UTF8.self) + #expect(!payload.contains("customer-private") && !payload.contains("private-secret")) + try FileManager.default.removeItem(atPath: project + "/.cursor/mcp.json") + try FileManager.default.createSymbolicLink(atPath: project + "/.cursor/mcp.json", withDestinationPath: project + "/.claude/skills/review/SKILL.md") + #expect(!collectMacProjectAgentDiscovery(roots: [root]).servers.contains { $0.name == "docs" }) + } + @Test("host id is a 16-char hash, not the raw UUID") func hostId() { let id = syncHostId() diff --git a/macos/packaging/config.example.plist b/macos/packaging/config.example.plist index b673eaa..f809ade 100644 --- a/macos/packaging/config.example.plist +++ b/macos/packaging/config.example.plist @@ -10,5 +10,8 @@ replace-with-64-hex-character-device-token PolicyPublicKeys replace-with-64-hex-character-ed25519-public-key + + AgentWorkspaceRootsJSON + ["/Users/Shared/Projects"] diff --git a/macos/packaging/merlin-launcher.sh b/macos/packaging/merlin-launcher.sh index 6739b73..0227d29 100755 --- a/macos/packaging/merlin-launcher.sh +++ b/macos/packaging/merlin-launcher.sh @@ -39,6 +39,7 @@ validate_config() { MERLIN_DEVICE_ID=$(plist_value DeviceID) || die "configuration is missing DeviceID" MERLIN_DEVICE_TOKEN=$(plist_value DeviceToken) || die "configuration is missing DeviceToken" MERLIN_POLICY_PUBLIC_KEYS=$(plist_value PolicyPublicKeys) || die "configuration is missing PolicyPublicKeys" + MERLIN_AGENT_WORKSPACE_ROOTS=$(plist_value AgentWorkspaceRootsJSON 2>/dev/null || true) case "$SYNC_URL" in https://*) ;; @@ -63,7 +64,8 @@ validate_config() { done IFS=$old_ifs - export MERLIN_DEVICE_ID MERLIN_DEVICE_TOKEN MERLIN_POLICY_PUBLIC_KEYS + [ "${#MERLIN_AGENT_WORKSPACE_ROOTS}" -le 4096 ] || die "AgentWorkspaceRootsJSON exceeds 4096 bytes" + export MERLIN_DEVICE_ID MERLIN_DEVICE_TOKEN MERLIN_POLICY_PUBLIC_KEYS MERLIN_AGENT_WORKSPACE_ROOTS } validate_config diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index 1861e2a..cfc10f9 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -408,6 +408,7 @@ struct DeviceMCPServer { client: String, name: String, source: String, + transport: String, } #[derive(Serialize, Debug, PartialEq, Ord, PartialOrd, Eq, Clone)] @@ -1871,13 +1872,216 @@ fn collect_agent_discovery() -> ( candidates.sort(); homes.extend(candidates.into_iter().take(64)); } - collect_agent_discovery_from( + let mut discovery = collect_agent_discovery_from( &homes, &[ "/usr/local/bin", "/usr/bin", "/home/linuxbrew/.linuxbrew/bin", ], + ); + let roots = configured_agent_workspace_roots(); + let (project_servers, project_assets) = collect_project_agent_discovery(&roots); + discovery.1.extend(project_servers); + discovery.1.sort(); + discovery.1.dedup(); + discovery.1.truncate(128); + discovery.2.extend(project_assets); + discovery.2.sort(); + discovery.2.dedup(); + discovery.2.truncate(128); + discovery +} + +// MDM supplies a JSON array in the root-owned service configuration. No default +// workspace roots are scanned, and no configured path is sent in inventory. +fn configured_agent_workspace_roots() -> Vec { + let Ok(raw) = std::env::var("MERLIN_AGENT_WORKSPACE_ROOTS") else { + return Vec::new(); + }; + if raw.len() > 4096 { + return Vec::new(); + } + serde_json::from_str::>(&raw) + .unwrap_or_default() + .into_iter() + .filter(|path| path.len() <= 512 && path.starts_with('/')) + .map(PathBuf::from) + .filter(|path| { + path.components().all(|component| { + matches!( + component, + std::path::Component::RootDir | std::path::Component::Normal(_) + ) + }) + }) + .take(8) + .collect() +} + +fn project_directory(path: &std::path::Path) -> bool { + path.symlink_metadata() + .is_ok_and(|meta| meta.is_dir() && !meta.file_type().is_symlink()) +} + +fn collect_project_agent_discovery( + roots: &[PathBuf], +) -> (Vec, Vec) { + const CONFIGS: &[(&str, &str, bool)] = &[ + ("claude", ".mcp.json", false), + ("claude", ".claude/settings.json", false), + ("cursor", ".cursor/mcp.json", false), + ("codex", ".codex/config.toml", true), + ]; + const ASSETS: &[(&str, &str, &str, &str)] = &[ + ("agents", "skill", ".agents/skills", "skill"), + ("claude", "skill", ".claude/skills", "skill"), + ("claude", "agent", ".claude/agents", "md"), + ("claude", "plugin", ".claude/plugins", "plugin"), + ("codex", "skill", ".codex/skills", "skill"), + ("cursor", "skill", ".cursor/skills", "skill"), + ("maestro", "plugin", ".maestro/plugins", "plugin"), + ("maestro", "plugin", ".composer/plugins", "plugin"), + ]; + let mut servers = BTreeSet::new(); + let mut assets = BTreeSet::new(); + let mut plugin_config_reads = 0; + for root in roots.iter().take(8).filter(|root| project_directory(root)) { + let mut projects = vec![root.clone()]; + if let Ok(entries) = fs::read_dir(root) { + let mut children: Vec<_> = entries + .take(256) + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .map(|entry| entry.path()) + .collect(); + children.sort(); + projects.extend(children.into_iter().take(32)); + } + for project in projects { + if !project_directory(&project) { + continue; + } + for (client, relative, is_toml) in CONFIGS { + let path = project.join(relative); + if path + .parent() + .is_some_and(|parent| parent != project && !project_directory(parent)) + { + continue; + } + let Some(body) = read_agent_config(&path) else { + continue; + }; + assets.insert(DeviceAgentAsset { + client: (*client).into(), + kind: "config".into(), + name: "project".into(), + source: format!("project/{relative}"), + }); + if *relative == ".claude/settings.json" { + if let Ok(value) = serde_json::from_str::(&body) { + if let Some(plugins) = + value.get("enabledPlugins").and_then(|v| v.as_object()) + { + for (name, enabled) in plugins { + if enabled.as_bool() == Some(true) && safe_agent_asset_name(name) { + assets.insert(DeviceAgentAsset { + client: "claude".into(), + kind: "plugin".into(), + name: name.clone(), + source: "project/.claude/settings.json".into(), + }); + } + } + } + } + } + let entries = if *is_toml { + codex_mcp_entries(&body) + } else { + json_mcp_entries(&body, &["mcpServers", "servers"]) + }; + for (name, transport) in entries + .into_iter() + .filter(|(name, _)| safe_agent_asset_name(name)) + { + servers.insert(DeviceMCPServer { + client: (*client).into(), + name, + source: format!("project/{relative}"), + transport, + }); + } + } + for (client, kind, relative, format) in ASSETS { + let directory = project.join(relative); + if !project_directory(directory.parent().unwrap_or(&project)) + || !project_directory(&directory) + { + continue; + } + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + for entry in entries.take(256).flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let filename = entry.file_name().to_string_lossy().into_owned(); + let name = if *format == "skill" + && file_type.is_dir() + && entry + .path() + .join("SKILL.md") + .symlink_metadata() + .is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) + { + Some(filename.as_str()) + } else if *format == "plugin" && file_type.is_dir() { + Some(filename.as_str()) + } else if *format == "md" && file_type.is_file() { + filename.strip_suffix(".md") + } else { + None + }; + if let Some(name) = name.filter(|name| safe_agent_asset_name(name)) { + assets.insert(DeviceAgentAsset { + client: (*client).into(), + kind: (*kind).into(), + name: name.into(), + source: format!("project/{relative}"), + }); + if *client == "maestro" && *kind == "plugin" { + for config in ["mcp.json", ".mcp.json"] { + if plugin_config_reads >= 32 { + break; + } + plugin_config_reads += 1; + if let Some(body) = read_agent_config(&entry.path().join(config)) { + for (server, transport) in + json_mcp_entries(&body, &["mcpServers", "servers"]) + { + if safe_agent_asset_name(&server) { + servers.insert(DeviceMCPServer { + client: "maestro".into(), + name: server, + source: format!("project/{relative}/*/{config}"), + transport, + }); + } + } + } + } + } + } + } + } + } + } + ( + servers.into_iter().take(128).collect(), + assets.into_iter().take(128).collect(), ) } @@ -2011,12 +2215,15 @@ fn collect_agent_discovery_from( if let Some(body) = read_agent_config( &directory.join(&file_name).join("gemini-extension.json"), ) { - for server in json_mcp_names(&body) { + for (server, transport) in + json_mcp_entries(&body, &["mcpServers", "servers"]) + { if safe_agent_asset_name(&server) { servers.insert(DeviceMCPServer { client: "gemini".into(), name: server, source: ".gemini/extensions/*/gemini-extension.json".into(), + transport, }); } } @@ -2031,12 +2238,15 @@ fn collect_agent_discovery_from( read_agent_config(&directory.join(&file_name).join(config)) { plugin_config_reads += 1; - for server in json_mcp_names(&body) { + for (server, transport) in + json_mcp_entries(&body, &["mcpServers", "servers"]) + { if safe_agent_asset_name(&server) { servers.insert(DeviceMCPServer { client: "maestro".into(), name: server, source: format!("{relative}/*/{config}"), + transport, }); } } @@ -2082,37 +2292,22 @@ fn collect_agent_discovery_from( } continue; } - let names: Vec = if *client == "amp" { - serde_json::from_str::(&body) - .ok() - .and_then(|value| { - value - .get("amp.mcpServers")? - .as_object() - .map(|object| object.keys().cloned().collect()) - }) - .unwrap_or_default() + let entries: Vec<(String, String)> = if *client == "amp" { + json_mcp_entries(&body, &["amp.mcpServers"]) } else if *client == "opencode" { - serde_json::from_str::(&body) - .ok() - .and_then(|value| { - value - .get("mcp")? - .as_object() - .map(|object| object.keys().cloned().collect()) - }) - .unwrap_or_default() + json_mcp_entries(&body, &["mcp"]) } else if *is_toml { - codex_mcp_names(&body) + codex_mcp_entries(&body) } else { - json_mcp_names(&body) + json_mcp_entries(&body, &["mcpServers", "servers"]) }; - for name in names { + for (name, transport) in entries { if name.len() <= 128 && !name.chars().any(char::is_control) { servers.insert(DeviceMCPServer { client: (*client).into(), name, source: (*relative).into(), + transport, }); if servers.len() >= 128 { break; @@ -2161,36 +2356,84 @@ fn safe_agent_asset_name(name: &str) -> bool { && !name.contains('\\') } -fn json_mcp_names(body: &str) -> Vec { +fn json_mcp_entries(body: &str, keys: &[&str]) -> Vec<(String, String)> { let Ok(value) = serde_json::from_str::(body) else { return Vec::new(); }; - ["mcpServers", "servers"] - .iter() + keys.iter() .filter_map(|key| value.get(key)?.as_object()) - .flat_map(|object| object.keys().cloned()) + .flat_map(|object| { + object.iter().map(|(name, definition)| { + let has_url = ["url", "httpUrl", "http_url"].iter().any(|key| { + definition + .get(key) + .is_some_and(serde_json::Value::is_string) + }); + let has_command = definition + .get("command") + .is_some_and(serde_json::Value::is_string); + let transport = match (has_url, has_command) { + (true, false) => "remote", + (false, true) => "stdio", + _ => "unknown", + }; + (name.clone(), transport.to_string()) + }) + }) .collect() } -fn codex_mcp_names(body: &str) -> Vec { - body.lines() - .filter_map(|line| { - let section = line - .trim() - .strip_prefix("[mcp_servers.")? - .strip_suffix(']')?; - let quoted = section.starts_with('"') && section.ends_with('"') && section.len() >= 2; - let name = if quoted { - §ion[1..section.len() - 1] - } else { - section +fn codex_mcp_entries(body: &str) -> Vec<(String, String)> { + let mut entries = Vec::new(); + let mut current: Option = None; + let mut has_url = false; + let mut has_command = false; + let mut finish = |current: &mut Option, has_url: &mut bool, has_command: &mut bool| { + if let Some(name) = current.take() { + let transport = match (*has_url, *has_command) { + (true, false) => "remote", + (false, true) => "stdio", + _ => "unknown", }; - (!name.is_empty() - && !name.chars().any(|ch| "[]".contains(ch)) - && (quoted || !name.contains('.'))) - .then(|| name.to_string()) - }) - .collect() + entries.push((name, transport.to_string())); + } + *has_url = false; + *has_command = false; + }; + for line in body.lines() { + let text = line.trim(); + if text.starts_with('[') && text.ends_with(']') { + finish(&mut current, &mut has_url, &mut has_command); + let section = text + .strip_prefix("[mcp_servers.") + .and_then(|value| value.strip_suffix(']')); + if let Some(section) = section { + let quoted = + section.starts_with('"') && section.ends_with('"') && section.len() >= 2; + let name = if quoted { + §ion[1..section.len() - 1] + } else { + section + }; + if !name.is_empty() + && !name.chars().any(|ch| "[]".contains(ch)) + && (quoted || !name.contains('.')) + { + current = Some(name.to_string()); + } + } + } else if current.is_some() { + if let Some((key, _)) = text.split_once('=') { + match key.trim() { + "url" | "http_url" => has_url = true, + "command" => has_command = true, + _ => {} + } + } + } + } + finish(&mut current, &mut has_url, &mut has_command); + entries } fn collect_os_info() -> DeviceOSInfo { @@ -2398,32 +2641,38 @@ mod tests { DeviceMCPServer { client: "amp".into(), name: "db".into(), - source: ".config/amp/settings.json".into() + source: ".config/amp/settings.json".into(), + transport: "stdio".into() }, DeviceMCPServer { client: "codex".into(), name: "github".into(), - source: ".codex/config.toml".into() + source: ".codex/config.toml".into(), + transport: "remote".into() }, DeviceMCPServer { client: "cursor".into(), name: "docs".into(), - source: ".cursor/mcp.json".into() + source: ".cursor/mcp.json".into(), + transport: "stdio".into() }, DeviceMCPServer { client: "gemini".into(), name: "search".into(), - source: ".gemini/extensions/*/gemini-extension.json".into() + source: ".gemini/extensions/*/gemini-extension.json".into(), + transport: "unknown".into() }, DeviceMCPServer { client: "maestro".into(), name: "managed".into(), - source: ".maestro/config.toml".into() + source: ".maestro/config.toml".into(), + transport: "remote".into() }, DeviceMCPServer { client: "maestro".into(), name: "pluginsearch".into(), - source: ".maestro/plugins/*/mcp.json".into() + source: ".maestro/plugins/*/mcp.json".into(), + transport: "stdio".into() }, ] ); @@ -2479,6 +2728,67 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn project_discovery_is_bounded_and_hides_paths_and_values() { + let root = + std::env::temp_dir().join(format!("merlin-project-discovery-{}", std::process::id())); + let project = root.join("customer-private"); + fs::create_dir_all(project.join(".cursor")).unwrap(); + fs::create_dir_all(project.join(".claude/skills/review")).unwrap(); + fs::create_dir_all(project.join(".maestro/plugins/audit")).unwrap(); + fs::write( + project.join(".maestro/plugins/audit/mcp.json"), + r#"{"mcpServers":{"pluginsearch":{"url":"https://private.example/mcp"}}}"#, + ) + .unwrap(); + fs::write(project.join(".claude/settings.json"), r#"{"enabledPlugins":{"audit@marketplace":true,"off@marketplace":false},"secret":"private-secret"}"#).unwrap(); + fs::write( + project.join(".cursor/mcp.json"), + r#"{"mcpServers":{"docs":{"command":"private-secret"}}}"#, + ) + .unwrap(); + fs::write( + project.join(".claude/skills/review/SKILL.md"), + "private-secret", + ) + .unwrap(); + let (servers, assets) = collect_project_agent_discovery(std::slice::from_ref(&root)); + assert!(servers.iter().any(|item| item.name == "docs" + && item.source == "project/.cursor/mcp.json" + && item.transport == "stdio")); + assert!(servers.iter().any(|item| item.name == "pluginsearch" + && item.source == "project/.maestro/plugins/*/mcp.json" + && item.transport == "remote")); + assert!( + assets + .iter() + .any(|item| item.name == "review" && item.source == "project/.claude/skills") + ); + assert!(assets.iter().any(|item| item.name == "audit@marketplace" + && item.kind == "plugin" + && item.source == "project/.claude/settings.json")); + assert!(assets.iter().any(|item| item.name == "audit" + && item.kind == "plugin" + && item.source == "project/.maestro/plugins")); + assert!(!assets.iter().any(|item| item.name == "off@marketplace")); + let payload = serde_json::to_string(&(servers, assets)).unwrap(); + assert!(!payload.contains("customer-private")); + assert!(!payload.contains("private-secret")); + fs::remove_file(project.join(".cursor/mcp.json")).unwrap(); + std::os::unix::fs::symlink( + project.join(".claude/skills/review/SKILL.md"), + project.join(".cursor/mcp.json"), + ) + .unwrap(); + assert!( + !collect_project_agent_discovery(&[root.clone()]) + .0 + .iter() + .any(|item| item.name == "docs") + ); + fs::remove_dir_all(root).unwrap(); + } + /// Minimal one-shot HTTP responder: reads one request (headers + /// content-length body), calls `respond` with (headers, body), writes /// back the returned raw response. diff --git a/packaging/linux/merlin.env.example b/packaging/linux/merlin.env.example index b9d99ca..b1e9159 100644 --- a/packaging/linux/merlin.env.example +++ b/packaging/linux/merlin.env.example @@ -2,3 +2,6 @@ MERLIN_SYNC_URL=https://merlin-sync.example.com MERLIN_DEVICE_ID=dev_0123456789abcdef01234567 MERLIN_DEVICE_TOKEN=replace-with-64-hex-character-device-token MERLIN_POLICY_PUBLIC_KEYS=replace-with-comma-separated-ed25519-public-keys +# Optional JSON array of at most eight admin-chosen workspace roots. Escape it +# as a single value in this root-owned systemd EnvironmentFile. +# MERLIN_AGENT_WORKSPACE_ROOTS='["/srv/projects"]'