Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
8635f11
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 23, 2026
0f1b3ef
chore: project endpoint from Mono fc21676e9136
Sep 23, 2026
a7ad5e7
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 23, 2026
f2529d1
chore: project endpoint from Mono 0d25f444e779
Sep 23, 2026
7e39251
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
8bfc1c7
chore: project endpoint from Mono 68580466ba81
Sep 24, 2026
9ec2bd8
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
bd7d23b
chore: project endpoint from Mono 56d36fc674ba
Sep 24, 2026
68a1e7b
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
f170f92
chore: project endpoint from Mono b6466c724d17
Sep 24, 2026
a7cbc97
Merge remote-tracking branch 'origin/main' into sync/mono-projection
github-actions[bot] Sep 24, 2026
b61a456
chore: project endpoint from Mono 15bae766017b
Sep 24, 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
6 changes: 3 additions & 3 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": "b6466c724d17bf35841cefb93c8babd3e3153a70",
"sourceSha": "15bae766017b01af460b1ae3a2fa632142b8aad1",
"destinationRepository": "dx-corp/endpoint",
"priorProjectedBase": "1e0bf9b19a9df23c7c563ea581576dea99d4ec08",
"priorProjectedBase": "f7a844a1e78dd168999bd9b5b99417b6af9e5a08",
"definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6",
"toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6",
"contentDigest": "f841d481955629cc52b5488664f2354cdc6fe144e024b5b867e2a0748e71d38f",
"contentDigest": "95283b9922d0c07119b0765424cbda9d93d3f7276b65e0b671f7f855b47f1e1f",
"publicationEligible": true
}
10 changes: 8 additions & 2 deletions macos/Sources/MerlinEndpointApp/EndpointAppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,26 @@ final class EndpointAppModel: ObservableObject {
@Published private(set) var readFailed = false

private let readStatus: @Sendable () async throws -> LocalDeviceStatus
private let notifications: EnforcementNotifications

init(readStatus: @escaping @Sendable () async throws -> LocalDeviceStatus = {
try await LocalStatusClient().readStatus()
}) {
}, notifications: EnforcementNotifications = EnforcementNotifications()) {
self.readStatus = readStatus
self.notifications = notifications
}

func refresh() async {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
do {
status = try await readStatus()
let current = try await readStatus()
status = current
readFailed = false
if let notice = current.enforcement {
Task { [notifications] in await notifications.presentIfNeeded(notice) }
}
} catch {
// A previously successful read cannot establish current collector health.
status = nil
Expand Down
1 change: 0 additions & 1 deletion macos/Sources/MerlinEndpointApp/EndpointDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ struct EndpointDetailView: View {
}
.frame(minWidth: 560, minHeight: 480)
.task { await session.monitor() }
.task { await model.monitor() }
}

private func reportingSection(_ status: LocalDeviceStatus) -> some View {
Expand Down
108 changes: 108 additions & 0 deletions macos/Sources/MerlinEndpointApp/EnforcementNotifications.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import AppKit
import Foundation
import MerlinClientCore
import UserNotifications

/// Local best-effort guidance from the authenticated status snapshot. Notification
/// authorization and delivery remain under the signed-in user's macOS settings.
@MainActor
final class EnforcementNotifications {
private static let lastEventKey = "endpoint.lastEnforcementNotificationEvent"
private static let lastAttemptKey = "endpoint.lastEnforcementNotificationAttempt"
private let defaults: UserDefaults
private let now: @Sendable () -> Date
private let deliver: @MainActor @Sendable (LocalEnforcementNotice) async -> Void

init(defaults: UserDefaults = .standard, now: @escaping @Sendable () -> Date = Date.init,
deliver: @escaping @MainActor @Sendable (LocalEnforcementNotice) async -> Void = EnforcementNotifications.deliverToSystem) {
self.defaults = defaults
self.now = now
self.deliver = deliver
}

func presentIfNeeded(_ notice: LocalEnforcementNotice) async {
let current = now()
let age = current.timeIntervalSince(notice.occurredAt)
// A cached collector notice can remain visible for an hour. A banner
// should only describe an event that just happened.
guard (0...120).contains(age) else { return }
let event = "\(notice.action.rawValue):\(notice.occurredAt.timeIntervalSince1970)"
guard defaults.string(forKey: Self.lastEventKey) != event else { return }
if let last = defaults.object(forKey: Self.lastAttemptKey) as? Date,
(0..<60).contains(current.timeIntervalSince(last)) { return }
// Record before awaiting macOS authorization so concurrent refreshes,
// app restarts, and denied notification permission do not prompt again.
defaults.set(event, forKey: Self.lastEventKey)
defaults.set(current, forKey: Self.lastAttemptKey)
await deliver(notice)
}

private static func deliverToSystem(_ notice: LocalEnforcementNotice) async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
if settings.authorizationStatus == .notDetermined {
guard (try? await center.requestAuthorization(options: [.alert])) == true else { return }
} else {
guard settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional else { return }
}

let content = content(for: notice)
// The request contains no process path, command line, rule, or source app.
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
try? await center.add(request)
}

static func content(for notice: LocalEnforcementNotice) -> UNMutableNotificationContent {
let content = UNMutableNotificationContent()
content.title = notice.action == .blocked ? "App blocked by policy" : "App stopped by policy"
if let name = notice.approvedName, let url = notice.approvedURL {
content.body = "Your organization approved \(name) as an alternative."
content.categoryIdentifier = EnforcementNotificationRouter.categoryID
content.userInfo = [EnforcementNotificationRouter.approvedURLKey: url.absoluteString]
} else {
content.body = "Contact your administrator for an approved alternative."
}
return content
}
}

final class EnforcementNotificationRouter: NSObject, UNUserNotificationCenterDelegate {
@MainActor static let shared = EnforcementNotificationRouter()
static let categoryID = "endpoint.approvedAlternative"
static let actionID = "endpoint.openApprovedAlternative"
static let approvedURLKey = "approvedURL"

func install() {
let center = UNUserNotificationCenter.current()
center.delegate = self
let action = UNNotificationAction(identifier: Self.actionID, title: "Open approved tool")
center.setNotificationCategories([
UNNotificationCategory(identifier: Self.categoryID, actions: [action], intentIdentifiers: [])
])
}

func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner])
}

func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
defer { completionHandler() }
guard response.actionIdentifier == Self.actionID,
let raw = response.notification.request.content.userInfo[Self.approvedURLKey] as? String,
let url = Self.safeApprovedURL(raw) else { return }
Task { @MainActor in NSWorkspace.shared.open(url) }
}

static func safeApprovedURL(_ raw: String) -> URL? {
guard raw.utf8.count <= 2048, let url = URL(string: raw),
url.scheme == "https", url.host != nil,
url.user == nil, url.password == nil,
url.query == nil, url.fragment == nil else { return nil }
return url
}
}
47 changes: 42 additions & 5 deletions macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,66 @@ import SwiftUI

@main
struct MerlinEndpointApp: App {
@StateObject private var model = EndpointAppModel()
@NSApplicationDelegateAdaptor(EndpointAppDelegate.self) private var appDelegate
@StateObject private var session = EndpointSessionModel()
@StateObject private var updater = EndpointUpdaterModel()

var body: some Scene {
// The primary scene presents device details on launch, including a fresh install.
Window("Deixic Endpoint", id: "device-details") {
EndpointDetailView(model: model, session: session, updater: updater)
EndpointDetailView(model: appDelegate.model, session: session, updater: updater)
}
.defaultSize(width: 700, height: 680)
.windowResizability(.contentMinSize)

MenuBarExtra {
EndpointPopover(model: model, session: session, updater: updater)
EndpointPopover(model: appDelegate.model, session: session, updater: updater)
} label: {
Label("Deixic Endpoint", systemImage: model.status?.enforcement == nil ? "shield.lefthalf.filled" : "exclamationmark.shield.fill")
EndpointMenuBarLabel(model: appDelegate.model)
}
.menuBarExtraStyle(.window)

}
}

private struct EndpointMenuBarLabel: View {
@ObservedObject var model: EndpointAppModel

var body: some View {
Label("Deixic Endpoint", systemImage: model.status?.enforcement == nil
? "shield.lefthalf.filled" : "exclamationmark.shield.fill")
}
}

@MainActor
final class EndpointAppDelegate: NSObject, NSApplicationDelegate {
let model: EndpointAppModel
private let installNotifications: () -> Void
private var monitorTask: Task<Void, Never>?

override convenience init() {
self.init(model: EndpointAppModel(),
installNotifications: { EnforcementNotificationRouter.shared.install() })
}

init(model: EndpointAppModel, installNotifications: @escaping () -> Void) {
self.model = model
self.installNotifications = installNotifications
super.init()
}

func applicationDidFinishLaunching(_ notification: Notification) {
guard monitorTask == nil else { return }
installNotifications()
monitorTask = Task { [model] in await model.monitor() }
}

func applicationWillTerminate(_ notification: Notification) {
monitorTask?.cancel()
monitorTask = nil
}
}

struct EndpointPopover: View {
@ObservedObject var model: EndpointAppModel
@ObservedObject var session: EndpointSessionModel
Expand Down Expand Up @@ -76,6 +114,5 @@ struct EndpointPopover: View {
.padding(20)
.frame(width: 360)
.task { await session.monitor() }
.task { await model.monitor() }
}
}
26 changes: 21 additions & 5 deletions macos/Sources/MerlinMacOS/Engine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,23 @@ struct Engine: Sendable {
action == .block && !canBlock ? .kill : action
}

/// A tied enforcing rule with no guidance must not hide another rule's
/// configured alternative. Resolve conflicting guidance independently of
/// policy file order, without changing which rules enforce or get logged.
private func approvedAlternative(from enforcedRules: [Rule]) -> ApprovedAlternative? {
let candidates = enforcedRules.compactMap { rule -> (name: String, alternative: ApprovedAlternative)? in
guard let alternative = rule.approvedAlternative else { return nil }
return (rule.name, alternative)
}
return candidates.min { lhs, rhs in
if lhs.name != rhs.name { return lhs.name < rhs.name }
if lhs.alternative.name != rhs.alternative.name {
return lhs.alternative.name < rhs.alternative.name
}
return lhs.alternative.url.absoluteString < rhs.alternative.url.absoluteString
}?.alternative
}

/// Only hash at the AUTH point when some block rule selects on sha256 —
/// hashing every executed binary system-wide would be wasted work
/// otherwise (same policy as the Linux fanotify monitor).
Expand Down Expand Up @@ -163,9 +180,7 @@ struct Engine: Sendable {
sha256: sha256, cdhash: cdhash, matchedRules: matched,
pidStartSec: identity?.startSec, pidStartUsec: identity?.startUsec
))
if let rule = matchedRules.first {
onEnforcement(.blocked, rule.approvedAlternative)
}
onEnforcement(.blocked, approvedAlternative(from: matchedRules))
return Verdict(allow: false, matched: matched)
}

Expand Down Expand Up @@ -278,8 +293,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)
let enforcingRules = matched.filter { effective($0.action) == .kill && killed.contains($0.name) }
if !enforcingRules.isEmpty {
onEnforcement(.stopped, approvedAlternative(from: enforcingRules))
}
}
}
Expand Down
9 changes: 6 additions & 3 deletions macos/Sources/MerlinMacOS/Inventory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ struct DeviceInventory: Encodable, Sendable {
let agentApps: [DeviceAgentApp]
let mcpServers: [DeviceMCPServer]
let agentAssets: [DeviceAgentAsset]
let mcpHookCoverage: DeviceMCPHookCoverage
let cloudProvider: String
let cloudInstanceID: String
let cloudRegion: String
Expand All @@ -37,6 +38,7 @@ struct DeviceInventory: Encodable, Sendable {
case agentApps = "agent_apps"
case mcpServers = "mcp_servers"
case agentAssets = "agent_assets"
case mcpHookCoverage = "mcp_hook_coverage"
case cloudProvider = "cloud_provider"
case cloudInstanceID = "cloud_instance_id"
case cloudRegion = "cloud_region"
Expand Down Expand Up @@ -163,6 +165,7 @@ func collectDeviceInventory() -> DeviceInventory {
agentApps: discovery.apps,
mcpServers: discovery.servers,
agentAssets: discovery.assets,
mcpHookCoverage: collectMCPHookCoverage(),
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),
Expand Down Expand Up @@ -206,7 +209,7 @@ private func macProjectDirectory(_ path: String) -> Bool {
}

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 configs: [(String, String, Bool)] = [("claude", ".mcp.json", false), ("claude", ".claude/settings.json", false), ("cursor", ".cursor/mcp.json", false), ("codex", ".codex/config.toml", true), ("opencode", ".opencode/opencode.json", false), ("agents", ".agents/mcp.json", false)]
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<String>()
var assets = Set<String>()
Expand All @@ -232,7 +235,7 @@ func collectMacProjectAgentDiscovery(roots: [String]) -> (servers: [DeviceMCPSer
entries = tomlMCPEntries(body)
} else {
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
entries = mcpEntries(object?["mcpServers"] ?? object?["servers"])
entries = mcpEntries(client == "opencode" ? object?["mcp"] : (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)")
Expand Down Expand Up @@ -411,7 +414,7 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
entries = mcpEntries(object?["mcpServers"] ?? object?["servers"])
}
for (name, transport) in entries where name.utf8.count <= 128 && !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) {
for (name, transport) in entries where safeAgentAssetName(name) {
found.insert("\(client)\u{0}\(name)\u{0}\(relative)\u{0}\(transport)")
if found.count >= 128 { break }
}
Expand Down
Loading