Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 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
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": "68580466ba8135da1cfa25c6259800c32659d6d1",
"sourceSha": "56d36fc674bad5b86ba169de0e49905f44c8dcc2",
"destinationRepository": "dx-corp/endpoint",
"priorProjectedBase": "7287d525f43da516baa3f76c63d4e13603f30785",
"priorProjectedBase": "c0338dbec4311ab2e8f0f455986aa5ec54257f50",
"definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6",
"toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6",
"contentDigest": "a0d68f21dc50f154471ba8bbb8ef0d5ad91a560020bd9e09c6c3b91817235b81",
"contentDigest": "379aa13d48e400a1ca4bca32d082610dc9d5c6c89694a5670bacd39d12232dde",
"publicationEligible": true
}
42 changes: 40 additions & 2 deletions macos/Sources/MerlinClientCore/LocalDeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions macos/Sources/MerlinEndpointApp/EndpointDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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()
Expand Down
33 changes: 21 additions & 12 deletions macos/Sources/MerlinMacOS/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() }
}
Expand All @@ -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)")
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
12 changes: 10 additions & 2 deletions macos/Sources/MerlinMacOS/Engine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)")
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
}
}
}

Expand Down
Loading