diff --git a/.repository-projection.json b/.repository-projection.json index 09205ce..6a593ee 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -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 } diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index 5ccc4a2..cb995ff 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -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 @@ -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 @@ -28,6 +32,9 @@ 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" @@ -35,6 +42,10 @@ struct DeviceInventory: Encodable, Sendable { } } +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 @@ -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, @@ -144,6 +156,9 @@ 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), @@ -151,6 +166,167 @@ func collectDeviceInventory() -> DeviceInventory { ) } +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() + var assetNames = Set() + 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)) diff --git a/macos/Tests/MerlinMacOSTests/SyncTests.swift b/macos/Tests/MerlinMacOSTests/SyncTests.swift index 7c24ff9..f30db72 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -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() diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index dbe4ba1..fa01bef 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -388,10 +388,34 @@ struct DeviceInventory { sca: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] vulnerabilities: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + agent_clis: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + mcp_servers: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + agent_assets: Vec, #[serde(skip_serializing_if = "String::is_empty")] collection_source: String, } +#[derive(Serialize, Debug, PartialEq, Ord, PartialOrd, Eq, Clone)] +struct DeviceAgentCLI { + name: String, +} + +#[derive(Serialize, Debug, PartialEq, Ord, PartialOrd, Eq, Clone)] +struct DeviceMCPServer { + client: String, + name: String, +} + +#[derive(Serialize, Debug, PartialEq, Ord, PartialOrd, Eq, Clone)] +struct DeviceAgentAsset { + client: String, + kind: String, + name: String, +} + #[derive(Serialize, Debug, PartialEq)] struct DevicePackage { name: String, @@ -1803,6 +1827,7 @@ fn collect_inventory() -> DeviceInventory { let (package_manager, packages) = collect_packages(); let (users, groups) = collect_users_groups(); let (cloud_provider, cloud_instance_id, cloud_region) = cloud_metadata(); + let (agent_clis, mcp_servers, agent_assets) = collect_agent_discovery(); DeviceInventory { collected_at: format!("{:.3}", spool::now_ts()), package_manager, @@ -1819,10 +1844,318 @@ fn collect_inventory() -> DeviceInventory { fim: collect_fim(), sca: collect_sca(), vulnerabilities: Vec::new(), + agent_clis, + mcp_servers, + agent_assets, collection_source: "linux-agent".into(), } } +// Probe only fixed executable names and fixed configuration locations. Never +// execute a CLI or include configuration values in a managed heartbeat. +fn collect_agent_discovery() -> ( + Vec, + Vec, + Vec, +) { + let mut homes = vec![PathBuf::from("/root")]; + if let Ok(entries) = fs::read_dir("/home") { + let mut candidates: Vec<_> = entries + .take(256) + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .map(|entry| entry.path()) + .collect(); + candidates.sort(); + homes.extend(candidates.into_iter().take(64)); + } + collect_agent_discovery_from( + &homes, + &[ + "/usr/local/bin", + "/usr/bin", + "/home/linuxbrew/.linuxbrew/bin", + ], + ) +} + +fn collect_agent_discovery_from( + homes: &[PathBuf], + system_bins: &[&str], +) -> ( + Vec, + Vec, + Vec, +) { + const CLIS: &[&str] = &[ + "codex", "claude", "gemini", "opencode", "aider", "maestro", "amp", "goose", "qwen", "pi", + ]; + const CONFIGS: &[(&str, &str, bool)] = &[ + ("claude", ".config/Claude/claude_desktop_config.json", false), + ("claude", ".claude.json", false), + ("cursor", ".cursor/mcp.json", false), + ("gemini", ".gemini/settings.json", false), + ("vscode", ".config/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), + ]; + let mut clis = BTreeSet::new(); + let mut servers = BTreeSet::new(); + let mut assets = BTreeSet::new(); + for name in CLIS { + let found = system_bins + .iter() + .map(PathBuf::from) + .chain(homes.iter().flat_map(|home| { + [ + home.join(".local/bin"), + home.join(".npm-global/bin"), + home.join(".bun/bin"), + home.join(".cargo/bin"), + home.join(".codex/bin"), + ] + })) + .any(|dir| { + fs::metadata(dir.join(name)) + .is_ok_and(|meta| meta.is_file() && meta.mode() & 0o111 != 0) + }); + if found { + clis.insert(DeviceAgentCLI { + name: (*name).into(), + }); + } + } + for home in homes.iter().take(65) { + for (client, kind, relative, extension) in [ + ("agents", "skill", ".agents/skills", ""), + ("codex", "skill", ".codex/skills", ""), + ("claude", "skill", ".claude/skills", ""), + ("claude", "agent", ".claude/agents", "md"), + ("gemini", "skill", ".gemini/skills", ""), + ("gemini", "extension", ".gemini/extensions", ""), + ("opencode", "skill", ".config/opencode/skills", ""), + ("opencode", "plugin", ".config/opencode/plugins", "js-ts"), + ("opencode", "agent", ".config/opencode/agents", "md"), + ("amp", "skill", ".config/amp/skills", ""), + ("qwen", "skill", ".qwen/skills", ""), + ("pi", "skill", ".pi/agent/skills", ""), + ("pi", "extension", ".pi/agent/extensions", "js-ts"), + ] { + let directory = home.join(relative); + if !directory + .symlink_metadata() + .is_ok_and(|meta| meta.is_dir() && !meta.file_type().is_symlink()) + { + continue; + } + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + for entry in entries.take(256).flatten() { + let Ok(kind_on_disk) = entry.file_type() else { + continue; + }; + let file_name = entry.file_name().to_string_lossy().into_owned(); + let name = if extension == "md" && kind_on_disk.is_file() { + file_name.strip_suffix(".md") + } else if extension == "js-ts" && kind_on_disk.is_file() { + file_name + .strip_suffix(".js") + .or_else(|| file_name.strip_suffix(".ts")) + } else if extension.is_empty() + && kind == "skill" + && kind_on_disk.is_dir() + && directory + .join(&file_name) + .join("SKILL.md") + .symlink_metadata() + .is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) + { + Some(file_name.as_str()) + } else if extension.is_empty() + && kind == "extension" + && kind_on_disk.is_dir() + && directory + .join(&file_name) + .join("gemini-extension.json") + .symlink_metadata() + .is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) + { + Some(file_name.as_str()) + } 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(), + }); + if client == "gemini" && kind == "extension" { + if let Some(body) = read_agent_config( + &directory.join(&file_name).join("gemini-extension.json"), + ) { + for server in json_mcp_names(&body) { + if safe_agent_asset_name(&server) { + servers.insert(DeviceMCPServer { + client: "gemini".into(), + name: server, + }); + } + } + } + } + if assets.len() >= 128 { + break; + } + } + } + if assets.len() >= 128 { + break; + } + } + for (client, relative, is_toml) in CONFIGS { + let Some(body) = read_agent_config(&home.join(relative)) else { + continue; + }; + assets.insert(DeviceAgentAsset { + client: (*client).into(), + kind: "config".into(), + name: "user".into(), + }); + if *client == "claude" && *relative == ".claude/settings.json" { + if let Ok(value) = serde_json::from_str::(&body) { + if let Some(plugins) = value + .get("enabledPlugins") + .and_then(|value| value.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(), + }); + } + } + } + } + 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() + } 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() + } else if *is_toml { + codex_mcp_names(&body) + } else { + json_mcp_names(&body) + }; + for name in names { + if name.len() <= 128 && !name.chars().any(char::is_control) { + servers.insert(DeviceMCPServer { + client: (*client).into(), + name, + }); + if servers.len() >= 128 { + break; + } + } + } + if servers.len() >= 128 { + break; + } + } + if servers.len() >= 128 { + break; + } + } + ( + clis.into_iter().collect(), + servers.into_iter().take(128).collect(), + assets.into_iter().take(128).collect(), + ) +} + +fn read_agent_config(path: &std::path::Path) -> Option { + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .ok()?; + if !file + .metadata() + .ok() + .is_some_and(|meta| meta.is_file() && meta.len() <= 64 << 10) + { + return None; + } + let mut body = String::new(); + file.take((64 << 10) + 1).read_to_string(&mut body).ok()?; + (body.len() <= 64 << 10).then_some(body) +} + +fn safe_agent_asset_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && !name.starts_with('.') + && !name.chars().any(char::is_control) + && !name.contains('/') + && !name.contains('\\') +} + +fn json_mcp_names(body: &str) -> Vec { + let Ok(value) = serde_json::from_str::(body) else { + return Vec::new(); + }; + ["mcpServers", "servers"] + .iter() + .filter_map(|key| value.get(key)?.as_object()) + .flat_map(|object| object.keys().cloned()) + .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 + }; + (!name.is_empty() + && !name.chars().any(|ch| "[]".contains(ch)) + && (quoted || !name.contains('.'))) + .then(|| name.to_string()) + }) + .collect() +} + fn collect_os_info() -> DeviceOSInfo { let os_release = bounded_read("/etc/os-release", 64 << 10) .and_then(|body| String::from_utf8(body).ok()) @@ -1923,6 +2256,126 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn agent_discovery_reports_names_without_config_values_or_symlinks() { + let root = tmpdir("agent-discovery"); + let home = root.join("home"); + let bin = home.join(".local/bin"); + fs::create_dir_all(&bin).unwrap(); + let executable = bin.join("codex"); + fs::write(&executable, "#!/bin/sh\n").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + fs::create_dir_all(home.join(".codex")).unwrap(); + fs::write( + home.join(".codex/config.toml"), + "[mcp_servers.github]\nurl = 'https://secret.example'\n", + ) + .unwrap(); + fs::create_dir_all(home.join(".cursor")).unwrap(); + fs::write( + home.join(".cursor/mcp.json"), + r#"{"mcpServers":{"docs":{"command":"secret"}}}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".agents/skills/review")).unwrap(); + fs::write( + home.join(".agents/skills/review/SKILL.md"), + "secret instructions", + ) + .unwrap(); + fs::create_dir_all(home.join(".gemini/extensions/workspace")).unwrap(); + fs::write( + home.join(".gemini/extensions/workspace/gemini-extension.json"), + r#"{"mcpServers":{"search":{"env":{"TOKEN":"secret"}}}}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".gemini/extensions/not-extension")).unwrap(); + fs::write( + home.join(".gemini/extensions/not-extension/SKILL.md"), + "ignored", + ) + .unwrap(); + fs::create_dir_all(home.join(".claude/agents")).unwrap(); + fs::write(home.join(".claude/agents/reviewer.md"), "secret prompt").unwrap(); + fs::write( + home.join(".claude/settings.json"), + r#"{"enabledPlugins":{"audit@marketplace":true,"off@marketplace":false}}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".config/amp")).unwrap(); + fs::write( + home.join(".config/amp/settings.json"), + r#"{"amp.mcpServers":{"db":{"command":"secret"}}}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".config/opencode/plugins")).unwrap(); + fs::write( + home.join(".config/opencode/plugins/trace.ts"), + "secret plugin", + ) + .unwrap(); + let (clis, servers, assets) = collect_agent_discovery_from(&[home.clone()], &[]); + assert_eq!( + clis, + vec![DeviceAgentCLI { + name: "codex".into() + }] + ); + assert_eq!( + servers, + vec![ + DeviceMCPServer { + client: "amp".into(), + name: "db".into() + }, + DeviceMCPServer { + client: "codex".into(), + name: "github".into() + }, + DeviceMCPServer { + client: "cursor".into(), + name: "docs".into() + }, + DeviceMCPServer { + client: "gemini".into(), + name: "search".into() + }, + ] + ); + let serialized = serde_json::to_string(&servers).unwrap(); + assert!(!serialized.contains("secret")); + assert!( + assets + .iter() + .any(|item| item.client == "codex" && item.kind == "config") + ); + assert!( + assets.iter().any(|item| item.client == "agents" + && item.kind == "skill" + && item.name == "review") + ); + assert!(assets.iter().any(|item| item.client == "claude" + && item.kind == "agent" + && item.name == "reviewer")); + assert!(assets.iter().any(|item| item.client == "claude" + && item.kind == "plugin" + && item.name == "audit@marketplace")); + assert!(assets.iter().any(|item| item.client == "opencode" + && item.kind == "plugin" + && item.name == "trace")); + assert!(!assets.iter().any(|item| item.name == "off@marketplace")); + assert!(!assets.iter().any(|item| item.name == "not-extension")); + assert!(!serde_json::to_string(&assets).unwrap().contains("secret")); + fs::remove_file(home.join(".cursor/mcp.json")).unwrap(); + std::os::unix::fs::symlink( + home.join(".codex/config.toml"), + home.join(".cursor/mcp.json"), + ) + .unwrap(); + assert_eq!(collect_agent_discovery_from(&[home], &[]).1.len(), 3); + 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.