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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .repository-projection.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"projection": "endpoint",
"projectionSchemaVersion": 1,
"sourceRepository": "dx-corp/mono",
"sourceSha": "19eb7fff08ad340878e60f71ba1a8f11ad7a9885",
"sourceSha": "be57256872b9a0b021c1da667b7803f806e8f289",
"destinationRepository": "dx-corp/endpoint",
"priorProjectedBase": "7b5dfbae8e453c8314b57aa938ee93add17c5a0b",
"priorProjectedBase": "f906d29bae651484eca29bf35d5577b0919a50df",
"definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6",
"toolDigest": "f58d71f023a4f0a27d77cb96dcc5348a40816d0b",
"contentDigest": "c1d3a602cc99b9fec3856e2dedcc0a18782713e82822ab9a5a4ea3a5b86a55c7",
"toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6",
"contentDigest": "306e18a97c82824dfe39bd5e021dfbf2fa6ca4f28b2eb200ed1224cc052615bc",
"publicationEligible": true
}
176 changes: 176 additions & 0 deletions macos/Sources/MerlinMacOS/Inventory.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CryptoKit
import Foundation
import Darwin

/// Bounded endpoint hygiene inventory for managed macOS devices. Collection is
/// read-only and intentionally avoids file contents, usernames, addresses, or
Expand All @@ -17,6 +18,9 @@ struct DeviceInventory: Encodable, Sendable {
let fim: [DeviceFIMEntry]
let sca: [DeviceSCAResult]
let vulnerabilities: [DeviceVulnerability]
let agentCLIs: [DeviceAgentCLI]
let mcpServers: [DeviceMCPServer]
let agentAssets: [DeviceAgentAsset]
let cloudProvider: String
let cloudInstanceID: String
let cloudRegion: String
Expand All @@ -28,13 +32,20 @@ struct DeviceInventory: Encodable, Sendable {
case packages, services, users, groups
case listeningPorts = "listening_ports"
case containers, processes, fim, sca, vulnerabilities
case agentCLIs = "agent_clis"
case mcpServers = "mcp_servers"
case agentAssets = "agent_assets"
case cloudProvider = "cloud_provider"
case cloudInstanceID = "cloud_instance_id"
case cloudRegion = "cloud_region"
case collectionSource = "collection_source"
}
}

struct DeviceAgentCLI: Encodable, Sendable { let name: String }
struct DeviceMCPServer: Encodable, Sendable { let client: String; let name: String }
struct DeviceAgentAsset: Encodable, Sendable { let client: String; let kind: String; let name: String }

struct DevicePackage: Encodable, Sendable {
let name: String
let version: String
Expand Down Expand Up @@ -131,6 +142,7 @@ private let inventoryReadLimit = 2 << 20

func collectDeviceInventory() -> DeviceInventory {
let packages = collectMacPackages()
let discovery = collectMacAgentDiscovery()
return DeviceInventory(
collectedAt: String(format: "%.3f", Date().timeIntervalSince1970),
packageManager: packages.manager,
Expand All @@ -144,13 +156,177 @@ func collectDeviceInventory() -> DeviceInventory {
fim: collectMacFIM(),
sca: collectMacSCA(),
vulnerabilities: [],
agentCLIs: discovery.clis,
mcpServers: discovery.servers,
agentAssets: discovery.assets,
cloudProvider: inventoryText(ProcessInfo.processInfo.environment["MERLIN_CLOUD_PROVIDER"], 128),
cloudInstanceID: inventoryText(ProcessInfo.processInfo.environment["MERLIN_CLOUD_INSTANCE_ID"], 128),
cloudRegion: inventoryText(ProcessInfo.processInfo.environment["MERLIN_CLOUD_REGION"], 128),
collectionSource: "macos-agent"
)
}

private func collectMacAgentDiscovery() -> (clis: [DeviceAgentCLI], servers: [DeviceMCPServer], assets: [DeviceAgentAsset]) {
let root = "/Users"
let users = ((try? FileManager.default.contentsOfDirectory(atPath: root)) ?? []).sorted().prefix(64)
let homes = ["/var/root"] + users.map { "\(root)/\($0)" }.filter { path in
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"])
}

// Fixed probes only: no CLI execution and no configuration values are emitted.
func collectMacAgentDiscovery(homes: [String], systemBins: [String]) -> (clis: [DeviceAgentCLI], servers: [DeviceMCPServer], assets: [DeviceAgentAsset]) {
let names = ["codex", "claude", "gemini", "opencode", "aider", "maestro", "amp", "goose", "qwen", "pi"]
let bins = systemBins + homes.flatMap { ["\($0)/.local/bin", "\($0)/.npm-global/bin", "\($0)/.bun/bin", "\($0)/.cargo/bin", "\($0)/.codex/bin"] }
let clis = names.filter { name in
bins.contains { bin in
let path = "\(bin)/\(name)"
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
return FileManager.default.isExecutableFile(atPath: path) && attributes?[.type] as? FileAttributeType == .typeRegular
}
}.map { DeviceAgentCLI(name: $0) }

let configs: [(String, String, Bool)] = [
("claude", "Library/Application Support/Claude/claude_desktop_config.json", false),
("claude", ".claude.json", false),
("cursor", ".cursor/mcp.json", false),
("gemini", ".gemini/settings.json", false),
("vscode", "Library/Application Support/Code/User/mcp.json", false),
("codex", ".codex/config.toml", true),
("opencode", ".config/opencode/opencode.json", false),
("claude", ".claude/settings.json", false),
("amp", ".config/amp/settings.json", false),
("qwen", ".qwen/settings.json", false),
("pi", ".pi/agent/settings.json", false),
]
var found = Set<String>()
var assetNames = Set<String>()
for home in homes.prefix(65) {
let assetDirs: [(String, String, String, String)] = [
("agents", "skill", ".agents/skills", "skill"),
("codex", "skill", ".codex/skills", "skill"),
("claude", "skill", ".claude/skills", "skill"),
("claude", "agent", ".claude/agents", "md"),
("gemini", "skill", ".gemini/skills", "skill"),
("gemini", "extension", ".gemini/extensions", "directory"),
("opencode", "skill", ".config/opencode/skills", "skill"),
("opencode", "plugin", ".config/opencode/plugins", "js-ts"),
("opencode", "agent", ".config/opencode/agents", "md"),
("amp", "skill", ".config/amp/skills", "skill"),
("qwen", "skill", ".qwen/skills", "skill"),
("pi", "skill", ".pi/agent/skills", "skill"),
("pi", "extension", ".pi/agent/extensions", "js-ts"),
]
for (client, kind, relative, format) in assetDirs {
let directory = "\(home)/\(relative)"
var directoryInfo = stat()
guard lstat(directory, &directoryInfo) == 0, (directoryInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFDIR) else { continue }
for entry in ((try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? []).sorted().prefix(256) {
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()
let manifestPath = "\(path)/SKILL.md"
name = lstat(manifestPath, &manifest) == 0 && (manifest.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) ? entry : nil
} else if format == "directory" && type == mode_t(S_IFDIR) {
var manifest = stat()
name = lstat("\(path)/gemini-extension.json", &manifest) == 0 && (manifest.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) ? entry : nil
} else if type == mode_t(S_IFREG) && format == "md" && entry.hasSuffix(".md") {
name = String(entry.dropLast(3))
} else if type == mode_t(S_IFREG) && format == "js-ts" && (entry.hasSuffix(".js") || entry.hasSuffix(".ts")) {
name = String(entry.dropLast(3))
} else { name = nil }
if let name, safeAgentAssetName(name) {
assetNames.insert("\(client)\u{0}\(kind)\u{0}\(name)")
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)")
}
}
if assetNames.count >= 128 { break }
}
}
if assetNames.count >= 128 { break }
}
for (client, relative, isTOML) in configs {
guard let data = readAgentConfigNoFollow("\(home)/\(relative)") else { continue }
assetNames.insert("\(client)\u{0}config\u{0}user")
if client == "claude" && 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) {
assetNames.insert("claude\u{0}plugin\u{0}\(name)")
}
continue
}
let names: [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) } ?? []
} else if client == "opencode" {
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
names = (object?["mcp"] as? [String: Any]).map { Array($0.keys) } ?? []
} 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
}
} 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) } ?? []
}
for name in names where name.utf8.count <= 128 && !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) {
found.insert("\(client)\u{0}\(name)")
if found.count >= 128 { break }
}
if found.count >= 128 { break }
}
if found.count >= 128 { break }
}
let servers = found.sorted().prefix(128).compactMap { entry -> DeviceMCPServer? in
let parts = entry.split(separator: "\u{0}", maxSplits: 1)
guard parts.count == 2 else { return nil }
return DeviceMCPServer(client: String(parts[0]), name: String(parts[1]))
}
let assets = assetNames.sorted().prefix(128).compactMap { entry -> DeviceAgentAsset? in
let parts = entry.split(separator: "\u{0}")
guard parts.count == 3 else { return nil }
return DeviceAgentAsset(client: String(parts[0]), kind: String(parts[1]), name: String(parts[2]))
}
return (clis, servers, assets)
}

private func safeAgentAssetName(_ name: String) -> Bool {
!name.isEmpty && name.utf8.count <= 128 && !name.hasPrefix(".") &&
!name.contains("/") && !name.contains("\\") &&
!name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
}

private func readAgentConfigNoFollow(_ path: String) -> Data? {
let fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK)
guard fd >= 0 else { return nil }
defer { close(fd) }
var info = stat()
guard fstat(fd, &info) == 0, (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG), info.st_size >= 0, info.st_size <= 64 << 10 else { return nil }
var bytes = [UInt8](repeating: 0, count: Int(info.st_size) + 1)
let capacity = bytes.count
let count = read(fd, &bytes, capacity)
guard count >= 0, count <= 64 << 10 else { return nil }
return Data(bytes.prefix(count))
}

private func inventoryText(_ value: String?, _ limit: Int) -> String {
guard let value else { return "" }
return String(value.trimmingCharacters(in: .whitespacesAndNewlines).prefix(limit))
Expand Down
44 changes: 44 additions & 0 deletions macos/Tests/MerlinMacOSTests/SyncTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,50 @@ private func makeClient(spoolPath: String, rulesPath: String, rulesBox: RulesBox

@Suite("sync", .serialized)
struct SyncTests {
@Test("agent discovery reports identifiers without configuration values")
func agentDiscovery() throws {
let home = NSTemporaryDirectory() + "merlin-discovery-\(UUID().uuidString)"
defer { try? FileManager.default.removeItem(atPath: home) }
try FileManager.default.createDirectory(atPath: home + "/.codex", withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: home + "/.cursor", withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: home + "/.local/bin", withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: home + "/.agents/skills/review", withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: home + "/.gemini/extensions/workspace", withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: home + "/.gemini/extensions/not-extension", withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: home + "/.claude/agents", withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: home + "/.config/amp", withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: home + "/.config/opencode/plugins", withIntermediateDirectories: true)
try "[mcp_servers.github]\nurl = 'https://secret.example'\n".write(toFile: home + "/.codex/config.toml", atomically: true, encoding: .utf8)
try #"{"mcpServers":{"docs":{"command":"secret"}}}"#.write(toFile: home + "/.cursor/mcp.json", atomically: true, encoding: .utf8)
try "secret instructions".write(toFile: home + "/.agents/skills/review/SKILL.md", atomically: true, encoding: .utf8)
try #"{"mcpServers":{"search":{"env":{"TOKEN":"secret"}}}}"#.write(toFile: home + "/.gemini/extensions/workspace/gemini-extension.json", atomically: true, encoding: .utf8)
try "ignored".write(toFile: home + "/.gemini/extensions/not-extension/SKILL.md", atomically: true, encoding: .utf8)
try "secret prompt".write(toFile: home + "/.claude/agents/reviewer.md", atomically: true, encoding: .utf8)
try #"{"enabledPlugins":{"audit@marketplace":true,"off@marketplace":false}}"#.write(toFile: home + "/.claude/settings.json", atomically: true, encoding: .utf8)
try #"{"amp.mcpServers":{"db":{"command":"secret"}}}"#.write(toFile: home + "/.config/amp/settings.json", atomically: true, encoding: .utf8)
try "secret plugin".write(toFile: home + "/.config/opencode/plugins/trace.ts", atomically: true, encoding: .utf8)
let cli = home + "/.local/bin/codex"
try "#!/bin/sh\n".write(toFile: cli, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cli)

let discovered = collectMacAgentDiscovery(homes: [home], systemBins: [])
#expect(discovered.clis.map(\.name) == ["codex"])
#expect(discovered.servers.map { "\($0.client):\($0.name)" } == ["amp:db", "codex:github", "cursor:docs", "gemini:search"])
#expect(discovered.assets.contains { $0.client == "agents" && $0.kind == "skill" && $0.name == "review" })
#expect(discovered.assets.contains { $0.client == "claude" && $0.kind == "agent" && $0.name == "reviewer" })
#expect(discovered.assets.contains { $0.client == "claude" && $0.kind == "plugin" && $0.name == "audit@marketplace" })
#expect(discovered.assets.contains { $0.client == "opencode" && $0.kind == "plugin" && $0.name == "trace" })
#expect(!discovered.assets.contains { $0.name == "off@marketplace" })
#expect(!discovered.assets.contains { $0.name == "not-extension" })
let encoded = try JSONEncoder().encode(discovered.servers)
#expect(!String(decoding: encoded, as: UTF8.self).contains("secret"))
#expect(!String(decoding: try JSONEncoder().encode(discovered.assets), as: UTF8.self).contains("secret"))

try FileManager.default.removeItem(atPath: home + "/.cursor/mcp.json")
try FileManager.default.createSymbolicLink(atPath: home + "/.cursor/mcp.json", withDestinationPath: home + "/.codex/config.toml")
#expect(collectMacAgentDiscovery(homes: [home], systemBins: []).servers.count == 3)
}

@Test("host id is a 16-char hash, not the raw UUID")
func hostId() {
let id = syncHostId()
Expand Down
Loading