diff --git a/.repository-projection.json b/.repository-projection.json index ef809db..f246578 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "fc21676e9136ce5ddca1260a75b4044e3c3b8bc3", + "sourceSha": "0d25f444e7799874298801a23f5a5c3a5e5003a2", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "2243d823a7fbcbb69382c681cc7858bda33136d4", + "priorProjectedBase": "5b09a4650684da900ad94b518e4360f64d7fdd5a", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6", - "contentDigest": "9f84e7f599492777fd805f10e8f2cab5c9d2772d1f68134bebfe089edadc7e20", + "contentDigest": "f104a7a0f4ffd84a4a0f643452fee149c06a0a15320c3a0aec207f71d53ce472", "publicationEligible": true } diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index 46f0eb9..8dd540f 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -19,6 +19,7 @@ struct DeviceInventory: Encodable, Sendable { let sca: [DeviceSCAResult] let vulnerabilities: [DeviceVulnerability] let agentCLIs: [DeviceAgentCLI] + let agentApps: [DeviceAgentApp] let mcpServers: [DeviceMCPServer] let agentAssets: [DeviceAgentAsset] let cloudProvider: String @@ -33,6 +34,7 @@ struct DeviceInventory: Encodable, Sendable { case listeningPorts = "listening_ports" case containers, processes, fim, sca, vulnerabilities case agentCLIs = "agent_clis" + case agentApps = "agent_apps" case mcpServers = "mcp_servers" case agentAssets = "agent_assets" case cloudProvider = "cloud_provider" @@ -43,6 +45,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 } struct DeviceAgentAsset: Encodable, Sendable { let client: String; let kind: String; let name: String } @@ -157,6 +160,7 @@ func collectDeviceInventory() -> DeviceInventory { sca: collectMacSCA(), vulnerabilities: [], agentCLIs: discovery.clis, + agentApps: discovery.apps, mcpServers: discovery.servers, agentAssets: discovery.assets, cloudProvider: inventoryText(ProcessInfo.processInfo.environment["MERLIN_CLOUD_PROVIDER"], 128), @@ -166,7 +170,7 @@ func collectDeviceInventory() -> DeviceInventory { ) } -private func collectMacAgentDiscovery() -> (clis: [DeviceAgentCLI], servers: [DeviceMCPServer], assets: [DeviceAgentAsset]) { +private func collectMacAgentDiscovery() -> (clis: [DeviceAgentCLI], apps: [DeviceAgentApp], 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 @@ -177,8 +181,8 @@ private func collectMacAgentDiscovery() -> (clis: [DeviceAgentCLI], servers: [De } // 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"] +func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [String]? = nil) -> (clis: [DeviceAgentCLI], apps: [DeviceAgentApp], servers: [DeviceMCPServer], assets: [DeviceAgentAsset]) { + let names = ["cursor", "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 @@ -187,6 +191,16 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String]) -> (clis: [ return FileManager.default.isExecutableFile(atPath: path) && attributes?[.type] as? FileAttributeType == .typeRegular } }.map { DeviceAgentCLI(name: $0) } + let appRoots = appRoots ?? (["/Applications", "/System/Applications"] + homes.prefix(65).map { "\($0)/Applications" }) + let apps = [("cursor", "Cursor.app"), ("codex", "Codex.app")].compactMap { name, bundle -> DeviceAgentApp? in + for root in appRoots { + var info = stat() + if lstat("\(root)/\(bundle)", &info) == 0 && (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFDIR) { + return DeviceAgentApp(name: name) + } + } + return nil + } let configs: [(String, String, Bool)] = [ ("claude", "Library/Application Support/Claude/claude_desktop_config.json", false), @@ -303,7 +317,7 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String]) -> (clis: [ 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) + return (clis, apps, servers, assets) } private func boundedAgentDirectoryEntries(_ path: String) -> [String] { diff --git a/macos/Sources/MerlinMacOS/Rules.swift b/macos/Sources/MerlinMacOS/Rules.swift index 547c971..58355e2 100644 --- a/macos/Sources/MerlinMacOS/Rules.swift +++ b/macos/Sources/MerlinMacOS/Rules.swift @@ -141,9 +141,10 @@ struct Rule: Decodable, Sendable { let action: Action /// Free-text detection rationale (content packs); ignored by matching. let note: String? + let approvedAlternative: ApprovedAlternative? init(from decoder: Decoder) throws { - try rejectUnknownKeys(decoder, allowed: ["name", "match", "match_all", "not", "action", "note"], type: "rule") + try rejectUnknownKeys(decoder, allowed: ["name", "match", "match_all", "not", "action", "note", "approved_alternative"], type: "rule") let c = try decoder.container(keyedBy: CodingKeys.self) name = try c.decode(String.self, forKey: .name) match = try c.decodeIfPresent(Match.self, forKey: .match) ?? Match() @@ -151,10 +152,15 @@ struct Rule: Decodable, Sendable { not = try c.decodeIfPresent(Match.self, forKey: .not) action = try c.decode(Action.self, forKey: .action) note = try c.decodeIfPresent(String.self, forKey: .note) + approvedAlternative = try c.decodeIfPresent(ApprovedAlternative.self, forKey: .approvedAlternative) + if approvedAlternative != nil && action == .log { + throw DecodingError.dataCorruptedError(forKey: .approvedAlternative, in: c, debugDescription: "approved_alternative requires an enforcement action") + } } private enum CodingKeys: String, CodingKey { case name, match, action, note, not case matchAll = "match_all" + case approvedAlternative = "approved_alternative" } /// Both blocks count: a hash under `match_all` needs the executable @@ -178,9 +184,32 @@ struct Rule: Decodable, Sendable { not = nil self.action = action note = nil + approvedAlternative = nil } } +struct ApprovedAlternative: Decodable, Sendable { + let name: String + let url: URL + + init(from decoder: Decoder) throws { + try rejectUnknownKeys(decoder, allowed: ["name", "url"], type: "approved_alternative") + let c = try decoder.container(keyedBy: CodingKeys.self) + let name = try c.decode(String.self, forKey: .name) + let rawURL = try c.decode(String.self, forKey: .url) + guard !name.isEmpty, name == name.trimmingCharacters(in: .whitespacesAndNewlines), name.utf8.count <= 80, + !name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + rawURL.utf8.count <= 2048, let url = URL(string: rawURL), url.scheme == "https", + url.host != nil, url.user == nil, url.password == nil, url.query == nil, url.fragment == nil else { + throw DecodingError.dataCorruptedError(forKey: .url, in: c, debugDescription: "approved_alternative requires a name and credential-free HTTPS URL") + } + self.name = name + self.url = url + } + + private enum CodingKeys: String, CodingKey { case name, url } +} + struct Match: Decodable, Sendable { var sha256: String? = nil var pathBasename: String? = nil diff --git a/macos/Tests/MerlinMacOSTests/RulesTests.swift b/macos/Tests/MerlinMacOSTests/RulesTests.swift index 88271dc..fbda16e 100644 --- a/macos/Tests/MerlinMacOSTests/RulesTests.swift +++ b/macos/Tests/MerlinMacOSTests/RulesTests.swift @@ -18,6 +18,15 @@ struct RulesTests { .deletingLastPathComponent() .deletingLastPathComponent() + @Test("approved alternative is decoded only for enforcement") + func approvedAlternative() throws { + let base = "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 parsed = try rule(base) + #expect(parsed.approvedAlternative?.name == "Approved editor") + #expect(parsed.approvedAlternative?.url.absoluteString == "https://tools.example.com/editor") + #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("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/SyncTests.swift b/macos/Tests/MerlinMacOSTests/SyncTests.swift index f113b9c..1d26356 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -150,6 +150,8 @@ struct SyncTests { 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 FileManager.default.createDirectory(atPath: home + "/Applications/Cursor.app", withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: home + "/Applications/Codex.app", withDestinationPath: home + "/Applications/Cursor.app") 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) @@ -163,8 +165,9 @@ struct SyncTests { 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: []) + let discovered = collectMacAgentDiscovery(homes: [home], systemBins: [], appRoots: [home + "/Applications"]) #expect(discovered.clis.map(\.name) == ["codex"]) + #expect(discovered.apps.map(\.name) == ["cursor"]) #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" }) diff --git a/macos/packaging/merlin-configure.sh b/macos/packaging/merlin-configure.sh index 715b943..b232dd9 100755 --- a/macos/packaging/merlin-configure.sh +++ b/macos/packaging/merlin-configure.sh @@ -13,7 +13,7 @@ LABEL=com.evalops.merlin LAUNCHER=$BASE_DIR/bin/merlin-launcher usage() { - printf '%s\n' "usage: $0 install | status | start | stop" >&2 + printf '%s\n' "usage: $0 install | status | verify | start | stop" >&2 exit 64 } @@ -56,6 +56,28 @@ case "$command" in fi launchctl print "system/$LABEL" 2>/dev/null | sed -n '1,30p' || true ;; + verify) + [ "$#" -eq 1 ] || usage + pkgutil --pkg-info com.evalops.merlin.sensor >/dev/null 2>&1 || { + printf '%s\n' 'Deixic Endpoint package receipt is missing.' >&2; exit 1; + } + [ -f "$CONFIG_PATH" ] && [ ! -L "$CONFIG_PATH" ] || { + printf '%s\n' 'Deixic Endpoint configuration is missing or linked.' >&2; exit 1; + } + [ "$(stat -f '%Su:%Sg:%Lp' "$CONFIG_PATH")" = 'root:wheel:600' ] || { + printf '%s\n' 'Deixic Endpoint configuration ownership or mode is invalid.' >&2; exit 1; + } + "$LAUNCHER" --validate-config >/dev/null || { + printf '%s\n' 'Deixic Endpoint configuration is invalid.' >&2; exit 1; + } + service_state=$(launchctl print "system/$LABEL" 2>/dev/null) || { + printf '%s\n' 'Deixic Endpoint launch daemon is not loaded.' >&2; exit 1; + } + printf '%s\n' "$service_state" | grep -Eq '^[[:space:]]*state = running$' || { + printf '%s\n' 'Deixic Endpoint launch daemon is not running.' >&2; exit 1; + } + printf '%s\n' 'Deixic Endpoint package, configuration, and launch daemon verified.' + ;; start) [ "$#" -eq 1 ] || usage "$LAUNCHER" --validate-config >/dev/null diff --git a/merlin/src/rules.rs b/merlin/src/rules.rs index 98e07ba..06f3619 100644 --- a/merlin/src/rules.rs +++ b/merlin/src/rules.rs @@ -68,6 +68,15 @@ pub struct Rule { /// Free-form documentation for the pack author; not used by the engine. #[serde(default)] pub note: Option, + #[serde(default)] + pub approved_alternative: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovedAlternative { + pub name: String, + pub url: String, } #[derive(Debug, Default, Deserialize)] @@ -297,6 +306,27 @@ impl Rules { rules.schema_version ); for rule in &rules.rules { + if let Some(alternative) = &rule.approved_alternative { + let authority = alternative + .url + .strip_prefix("https://") + .and_then(|rest| rest.split('/').next()); + anyhow::ensure!( + rule.action != Action::Log + && !alternative.name.is_empty() + && alternative.name.trim() == alternative.name + && alternative.name.len() <= 80 + && !alternative.name.chars().any(char::is_control) + && alternative.url.len() <= 2048 + && !alternative.url.contains('?') + && !alternative.url.contains('#') + && !alternative.url.contains('\\') + && !alternative.url.chars().any(char::is_control) + && authority.is_some_and(|host| !host.is_empty() && !host.contains('@')), + "rule '{}': invalid approved_alternative", + rule.name + ); + } for (key, block) in [("match_all", &rule.match_all), ("not", &rule.not)] { if let Some(m) = block { if !m.has_selectors() && m.uid.is_none() { @@ -359,6 +389,24 @@ impl Rules { mod tests { use super::*; + #[test] + fn approved_alternative_is_bounded_and_requires_enforcement() { + let base = "rules:\n - name: block-cursor\n match:\n path_basename: Cursor\n action: block\n approved_alternative:\n name: Approved editor\n url: https://tools.example.com/editor\n"; + let parsed = Rules::parse(base).unwrap(); + assert_eq!( + parsed.rules[0].approved_alternative.as_ref().unwrap().name, + "Approved editor" + ); + assert!(Rules::parse(&base.replace("action: block", "action: log")).is_err()); + assert!( + Rules::parse(&base.replace( + "https://tools.example.com/editor", + "http://tools.example.com/editor" + )) + .is_err() + ); + } + fn rule(yaml: &str) -> Rule { serde_yaml::from_str(yaml).unwrap() } diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index 56f7531..524d1e1 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -1888,7 +1888,8 @@ fn collect_agent_discovery_from( Vec, ) { const CLIS: &[&str] = &[ - "codex", "claude", "gemini", "opencode", "aider", "maestro", "amp", "goose", "qwen", "pi", + "codex", "claude", "cursor", "gemini", "opencode", "aider", "maestro", "amp", "goose", + "qwen", "pi", ]; const CONFIGS: &[(&str, &str, bool)] = &[ ("claude", ".config/Claude/claude_desktop_config.json", false), @@ -2265,6 +2266,9 @@ mod tests { let executable = bin.join("codex"); fs::write(&executable, "#!/bin/sh\n").unwrap(); fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + let cursor = bin.join("cursor"); + fs::write(&cursor, "#!/bin/sh\n").unwrap(); + fs::set_permissions(&cursor, fs::Permissions::from_mode(0o755)).unwrap(); fs::create_dir_all(home.join(".codex")).unwrap(); fs::write( home.join(".codex/config.toml"), @@ -2320,9 +2324,14 @@ mod tests { let (clis, servers, assets) = collect_agent_discovery_from(&[home.clone()], &[]); assert_eq!( clis, - vec![DeviceAgentCLI { - name: "codex".into() - }] + vec![ + DeviceAgentCLI { + name: "codex".into() + }, + DeviceAgentCLI { + name: "cursor".into() + }, + ] ); assert_eq!( servers,