Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
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": "fc21676e9136ce5ddca1260a75b4044e3c3b8bc3",
"sourceSha": "0d25f444e7799874298801a23f5a5c3a5e5003a2",
"destinationRepository": "dx-corp/endpoint",
"priorProjectedBase": "2243d823a7fbcbb69382c681cc7858bda33136d4",
"priorProjectedBase": "5b09a4650684da900ad94b518e4360f64d7fdd5a",
"definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6",
"toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6",
"contentDigest": "9f84e7f599492777fd805f10e8f2cab5c9d2772d1f68134bebfe089edadc7e20",
"contentDigest": "f104a7a0f4ffd84a4a0f643452fee149c06a0a15320c3a0aec207f71d53ce472",
"publicationEligible": true
}
22 changes: 18 additions & 4 deletions macos/Sources/MerlinMacOS/Inventory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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 }

Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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] {
Expand Down
31 changes: 30 additions & 1 deletion macos/Sources/MerlinMacOS/Rules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,20 +141,26 @@ 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()
matchAll = try c.decodeIfPresent(Match.self, forKey: .matchAll)
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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions macos/Tests/MerlinMacOSTests/RulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion macos/Tests/MerlinMacOSTests/SyncTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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" })
Expand Down
24 changes: 23 additions & 1 deletion macos/packaging/merlin-configure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ LABEL=com.evalops.merlin
LAUNCHER=$BASE_DIR/bin/merlin-launcher

usage() {
printf '%s\n' "usage: $0 install <source.plist> | status | start | stop" >&2
printf '%s\n' "usage: $0 install <source.plist> | status | verify | start | stop" >&2
exit 64
}

Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions merlin/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ pub struct Rule {
/// Free-form documentation for the pack author; not used by the engine.
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub approved_alternative: Option<ApprovedAlternative>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovedAlternative {
pub name: String,
pub url: String,
}

#[derive(Debug, Default, Deserialize)]
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
}
Expand Down
17 changes: 13 additions & 4 deletions merlin/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1888,7 +1888,8 @@ fn collect_agent_discovery_from(
Vec<DeviceAgentAsset>,
) {
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),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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,
Expand Down