From da9c15ce20b7ffd89e9e8c11724e6cd31d22ccfd Mon Sep 17 00:00:00 2001 From: Copybara Date: Sun, 20 Sep 2026 06:12:03 +0000 Subject: [PATCH 01/17] Project import generated by Copybara. FolderOrigin-RevId: b31318e06ac415532fa16ad18652f8fb1fced134 --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 39ef6b0..e20cfd0 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,9 +3,9 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "3acc63c2c655c22b5ca7f2d55b63a6b87eced2bc", + "sourceSha": "b31318e06ac415532fa16ad18652f8fb1fced134", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "8c060f9036d64edc9ac43f3688ab9677b5445a81", + "priorProjectedBase": "f4066d844bd3efd0bab8bc5103640aa072f7c484", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "c8000985e1bd708bf1f8754b27cc1a2aad67af10ee23cf5317bd1fba670b4456", "contentDigest": "b7dab773d1ffeaedde2249bd6fd15fcdd6a95336302c1bf33cc65236a7c44383", From 2e3ada5333ae2eef16df0a88012468984ac7cb3e Mon Sep 17 00:00:00 2001 From: Copybara Date: Sun, 20 Sep 2026 20:41:20 +0000 Subject: [PATCH 02/17] Project import generated by Copybara. FolderOrigin-RevId: 8420a1f8c5fdd07a3d2d4d2a46e0db0bb6bbb352 --- .repository-projection.json | 8 ++-- scripts/distribution-validation.mjs | 61 +++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index e20cfd0..7d9ada5 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "b31318e06ac415532fa16ad18652f8fb1fced134", + "sourceSha": "8420a1f8c5fdd07a3d2d4d2a46e0db0bb6bbb352", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "f4066d844bd3efd0bab8bc5103640aa072f7c484", + "priorProjectedBase": "b5283731919e221d7df68218eb26466633184e92", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "c8000985e1bd708bf1f8754b27cc1a2aad67af10ee23cf5317bd1fba670b4456", - "contentDigest": "b7dab773d1ffeaedde2249bd6fd15fcdd6a95336302c1bf33cc65236a7c44383", + "toolDigest": "de718162d050963e0c04ca9cbc0e38b1db545e838e587f949bcd9fd8aded6bcd", + "contentDigest": "dd0b8e695a159bcf4d2f59d7514763b5326055f74fdce496bd2fdafa262561ec", "publicationEligible": true } diff --git a/scripts/distribution-validation.mjs b/scripts/distribution-validation.mjs index 9cc6486..84a0515 100644 --- a/scripts/distribution-validation.mjs +++ b/scripts/distribution-validation.mjs @@ -6,9 +6,20 @@ import { join, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; -const NAMES = new Set(["endpoint", "private-runner", "private-deployment", "api", "examples", "plugins"]); +const NAMES = new Set(["endpoint", "private-runner", "private-deployment", "api", "examples", "plugins", "capobara"]); const SHA = /^[0-9a-f]{40}$/; const HEX = /^[0-9a-f]{64}$/; +// `toolDigest` is the one provenance field with two legitimate shapes. Node +// hashes the contents of its own TOOL_INPUTS script list, giving a 64-hex +// SHA-256; Capobara embeds the git tree id of `rust/tools/capobara`, giving a +// 40-hex object name. Both are valid "the tool that ran this matches the tool +// committed at this revision" proofs, and the Rust side's +// `git::is_tree_id_or_digest` accepts both widths for exactly this reason -- +// this predicate is its twin and must stay in step with it. A receipt written +// by either implementation has to validate here, or a clone of +// `dx-corp/capobara` fails `invalid provenance toolDigest` against a +// perfectly correct tree. +const TOOL_DIGEST = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const PROTO = [ "common/v1/analytics.proto", "common/v1/authz.proto", "common/v1/classification.proto", "common/v1/delivery.proto", "common/v1/entity.proto", "common/v1/risk.proto", "common/v1/surface.proto", @@ -82,7 +93,8 @@ export async function validateProvenance(name, root) { requireValue(provenance.projection === name && provenance.sourceRepository === "dx-corp/mono" && provenance.destinationRepository === `dx-corp/${name}`, "projection provenance identity mismatch"); requireValue(SHA.test(provenance.sourceSha) && SHA.test(provenance.priorProjectedBase), "projection SHAs are invalid"); - for (const key of ["definitionDigest", "toolDigest", "contentDigest"]) requireValue(HEX.test(provenance[key]), `invalid provenance ${key}`); + for (const key of ["definitionDigest", "contentDigest"]) requireValue(HEX.test(provenance[key]), `invalid provenance ${key}`); + requireValue(TOOL_DIGEST.test(provenance.toolDigest), "invalid provenance toolDigest"); requireValue(typeof provenance.publicationEligible === "boolean", "invalid publication eligibility"); return provenance; } @@ -244,6 +256,49 @@ async function validatePlugins(root, files) { for (const path of files) requireValue(!/(?:prompt-audit|session-history|product-kit)/i.test(path), `private plugin surface: ${path}`); } +// `capobara` is admitted here rather than routed around this validation by +// the workflow's `matrix.name == 'capobara'` condition. That condition only +// governs the `sync` job; `node scripts/projections/verify-catalog.mjs +// --validate` -- the `repository-projections` component's own CI gate -- +// builds and validates *every* catalog entry, and `validate.mjs` admits any +// name the catalog holds, so a catalog entry with no validator here fails +// that gate on `unsupported distribution: capobara`. +// +// The compile proof for the projected crate lives in the crate's own +// `tests/standalone_build.rs`, which copies it out of the workspace and runs +// `cargo build --locked`. Repeating that here would add a full dependency +// build to every catalog verification, so this checks the standalone +// closure without compiling: the manifest, the crate-root lockfile and the +// single-package workspace that the projection has to produce, plus the +// internal surfaces it must not carry. +async function validateCapobara(root, files) { + for (const required of ["Cargo.toml", "Cargo.lock", "README.md", "build.rs", "src/main.rs", "src/lib.rs"]) { + requireValue(files.includes(required), `capobara is missing ${required}`); + } + for (const path of files) { + requireValue(!/^scripts\//.test(path), `capobara contains an internal surface: ${path}`); + requireValue(!/^tests\/fixtures\/definitions\//.test(path), `capobara contains an excluded fixture: ${path}`); + } + const manifest = await readFile(join(root, "Cargo.toml"), "utf8"); + requireValue(!/\bworkspace\s*=\s*true/.test(manifest), "capobara Cargo.toml still inherits from the Mono workspace"); + // Resolution must include dependencies here: with `--no-deps` nothing is + // resolved, so `--locked` has nothing to compare and a stale lockfile + // passes. With the full graph, `--locked` fails when the projected + // crate-root lockfile does not match the projected manifest, which is the + // failure this projection is most exposed to -- the standalone lockfile is + // generated separately from the workspace one and can go stale without any + // Mono build noticing. + // + // This resolves all 178 locked packages, so on a cold runner it fetches the + // crates.io index and downloads every `.crate`. That is a network-dependent + // step in the component's CI gate; the component already declares `rust` in + // `ci.test.tools`, and nothing cheaper discriminates (see above). + const metadata = JSON.parse(run("cargo", ["metadata", "--locked", "--format-version", "1"], root)); + requireValue(metadata.workspace_members.length === 1, "capobara standalone workspace gained a member"); + const member = metadata.packages.find(pkg => pkg.id === metadata.workspace_members[0]); + requireValue(member?.name === "capobara", "capobara standalone workspace member is not the crate"); +} + export async function validateDistribution({ name, target }) { requireValue(NAMES.has(name), `unsupported distribution: ${name}`); const root = resolve(target); @@ -252,7 +307,7 @@ export async function validateDistribution({ name, target }) { await validateProvenance(name, root); const validators = { endpoint: validateEndpoint, "private-runner": validateRunner, "private-deployment": validateDeployment, - api: validateApi, examples: validateExamples, plugins: validatePlugins, + api: validateApi, examples: validateExamples, plugins: validatePlugins, capobara: validateCapobara, }; await validators[name](root, files); return { name, target: root, files: files.length, valid: true }; From 1977eb7d403a7410971d11db4589d1fe4b55f8db Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Sun, 20 Sep 2026 21:30:59 +0000 Subject: [PATCH 03/17] chore: project endpoint from Mono a2e8b231b208 --- .repository-projection.json | 8 ++++---- scripts/distribution-validation.mjs | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 7d9ada5..d6b73b3 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "8420a1f8c5fdd07a3d2d4d2a46e0db0bb6bbb352", + "sourceSha": "a2e8b231b208f9e30b0a115eb132176e1e7c301a", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "b5283731919e221d7df68218eb26466633184e92", + "priorProjectedBase": "4502c9a5953e083e592bb03d9b8ae1e71217e8da", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "de718162d050963e0c04ca9cbc0e38b1db545e838e587f949bcd9fd8aded6bcd", - "contentDigest": "dd0b8e695a159bcf4d2f59d7514763b5326055f74fdce496bd2fdafa262561ec", + "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", + "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", "publicationEligible": true } diff --git a/scripts/distribution-validation.mjs b/scripts/distribution-validation.mjs index 84a0515..13c5b1b 100644 --- a/scripts/distribution-validation.mjs +++ b/scripts/distribution-validation.mjs @@ -21,10 +21,12 @@ const HEX = /^[0-9a-f]{64}$/; // perfectly correct tree. const TOOL_DIGEST = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const PROTO = [ + "agentruntime/v1/runtime.proto", "agents/v1/agents.proto", "codex/v1/codex.proto", "common/v1/analytics.proto", "common/v1/authz.proto", "common/v1/classification.proto", - "common/v1/delivery.proto", "common/v1/entity.proto", "common/v1/risk.proto", "common/v1/surface.proto", - "connectors/v1/connectors.proto", "console/v1/console.proto", "deixic/v1/deixic.proto", - "memory/v1/memory.proto", "meter/v1/meter.proto", "orbcontrol/v1/orb_control.proto", + "common/v1/delivery.proto", "common/v1/entity.proto", "common/v1/risk.proto", + "common/v1/surface.proto", "connectors/v1/connectors.proto", "console/v1/console.proto", + "deixic/v1/deixic.proto", "memory/v1/memory.proto", "meter/v1/meter.proto", + "objectives/v1/objectives.proto", "orbcontrol/v1/orb_control.proto", "platform/v1/platform.proto", "remoterunner/v1/remoterunner.proto", "toolexecution/v1/toolexecution.proto", "traces/v1/traces.proto", "vfs/v1/filesystem.proto", ].sort(); @@ -256,13 +258,11 @@ async function validatePlugins(root, files) { for (const path of files) requireValue(!/(?:prompt-audit|session-history|product-kit)/i.test(path), `private plugin surface: ${path}`); } -// `capobara` is admitted here rather than routed around this validation by -// the workflow's `matrix.name == 'capobara'` condition. That condition only -// governs the `sync` job; `node scripts/projections/verify-catalog.mjs -// --validate` -- the `repository-projections` component's own CI gate -- -// builds and validates *every* catalog entry, and `validate.mjs` admits any -// name the catalog holds, so a catalog entry with no validator here fails -// that gate on `unsupported distribution: capobara`. +// `capobara` is admitted here like every other catalog entry: `validate.mjs` +// admits any name the catalog holds, and the `sync` job validates every +// prepared projection between Capobara's dry run and its real run, so a +// catalog entry with no validator here fails its publication on +// `unsupported distribution: capobara`. // // The compile proof for the projected crate lives in the crate's own // `tests/standalone_build.rs`, which copies it out of the workspace and runs From ff85ac72a07b6b31641a6d2e1af54714b1ea7d5a Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 00:30:33 +0000 Subject: [PATCH 04/17] chore: project endpoint from Mono 9b559bc70e7d --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index d6b73b3..3aa9d53 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,9 +3,9 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "a2e8b231b208f9e30b0a115eb132176e1e7c301a", + "sourceSha": "9b559bc70e7d8afc723d2ac67c8c62fe0cfbdeeb", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "4502c9a5953e083e592bb03d9b8ae1e71217e8da", + "priorProjectedBase": "1ffe76cec7cdda72764e75dc7272f694691eb36c", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", From 6188d83fbbfc428ac882ac52513fd46505964522 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 02:47:11 +0000 Subject: [PATCH 05/17] chore: project endpoint from Mono 9a003786d767 --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 3aa9d53..17f4bf7 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,9 +3,9 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "9b559bc70e7d8afc723d2ac67c8c62fe0cfbdeeb", + "sourceSha": "9a003786d7679e9029fa077557bc7ab4d00eb48f", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "1ffe76cec7cdda72764e75dc7272f694691eb36c", + "priorProjectedBase": "aa0a3c9668c437e963f699f26871840e221c65cc", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", From a8e83c3c29b9f3fc18f3e41762a77991e35ec4dc Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 03:24:13 +0000 Subject: [PATCH 06/17] chore: project endpoint from Mono 511bd2305f5a --- .repository-projection.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 17f4bf7..1ef1477 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "9a003786d7679e9029fa077557bc7ab4d00eb48f", + "sourceSha": "511bd2305f5a92cfbedb91208d7bc0820fda29b5", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "aa0a3c9668c437e963f699f26871840e221c65cc", + "priorProjectedBase": "20a6ce5b453007e16fdc04c22ac7fbc5160f6d67", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "8a4412f3a379ca0bd1cfea9bff69cc46773c6d1f", + "toolDigest": "e9fc82741fdf4a797b076d29d51e14aced32368b", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", "publicationEligible": true } From b0061c7bcc2b2866499d25377f5af072158524a8 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 06:11:08 +0000 Subject: [PATCH 07/17] chore: project endpoint from Mono ad7b9df0b712 --- .repository-projection.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 1ef1477..fd41b1f 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "511bd2305f5a92cfbedb91208d7bc0820fda29b5", + "sourceSha": "ad7b9df0b7129a2fe8a6cda64eed199b0ea9a748", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "20a6ce5b453007e16fdc04c22ac7fbc5160f6d67", + "priorProjectedBase": "165896493585a7ec66e1f728933d9dedd2ecaf49", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "e9fc82741fdf4a797b076d29d51e14aced32368b", + "toolDigest": "1885e6c68d4a94f6633f0a96aecfbe92501547e6", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", "publicationEligible": true } From 26567019a4aac03ba72563a0bae2b4ead8947452 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Mon, 21 Sep 2026 09:41:59 +0000 Subject: [PATCH 08/17] chore: project endpoint from Mono 4ab4845398fd --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index fd41b1f..4076463 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "ad7b9df0b7129a2fe8a6cda64eed199b0ea9a748", + "sourceSha": "4ab4845398fdca60f84eb815733b8e2361b4856d", "destinationRepository": "dx-corp/endpoint", "priorProjectedBase": "165896493585a7ec66e1f728933d9dedd2ecaf49", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", - "toolDigest": "1885e6c68d4a94f6633f0a96aecfbe92501547e6", + "toolDigest": "f58d71f023a4f0a27d77cb96dcc5348a40816d0b", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", "publicationEligible": true } From f6b9091a06578003c097a9a3aa5ebdd9322cfaba Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Tue, 22 Sep 2026 02:35:04 +0000 Subject: [PATCH 09/17] chore: project endpoint from Mono 2df44a8283c0 --- .repository-projection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 4076463..8f11400 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,9 +3,9 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "4ab4845398fdca60f84eb815733b8e2361b4856d", + "sourceSha": "2df44a8283c07c0ba875174e70d2aaabb72d19cc", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "165896493585a7ec66e1f728933d9dedd2ecaf49", + "priorProjectedBase": "660b0439d00290cce5dd22718176d08b9ac59d1c", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "f58d71f023a4f0a27d77cb96dcc5348a40816d0b", "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", From 1bddf52a023d86040f0df55c92908270652e3feb Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Wed, 23 Sep 2026 16:08:05 +0000 Subject: [PATCH 10/17] chore: project endpoint from Mono 19eb7fff08ad --- .repository-projection.json | 6 +++--- merlin-ebpf/src/main.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 8f11400..09205ce 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "2df44a8283c07c0ba875174e70d2aaabb72d19cc", + "sourceSha": "19eb7fff08ad340878e60f71ba1a8f11ad7a9885", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "660b0439d00290cce5dd22718176d08b9ac59d1c", + "priorProjectedBase": "7b5dfbae8e453c8314b57aa938ee93add17c5a0b", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "f58d71f023a4f0a27d77cb96dcc5348a40816d0b", - "contentDigest": "93e294fc9965f77866efc07c175a74accbe160557c9b07bb3fea67883dc0097c", + "contentDigest": "c1d3a602cc99b9fec3856e2dedcc0a18782713e82822ab9a5a4ea3a5b86a55c7", "publicationEligible": true } diff --git a/merlin-ebpf/src/main.rs b/merlin-ebpf/src/main.rs index 6f11e7e..aa8cdbf 100644 --- a/merlin-ebpf/src/main.rs +++ b/merlin-ebpf/src/main.rs @@ -54,7 +54,7 @@ fn panic(_info: &core::panic::PanicInfo) -> ! { fn submit(event: &Event) { // Dropped events (full ring) are acceptable for a teaching sensor; the // alternative is blocking the kernel path, which is not. - if EVENTS.output(event, 0).is_err() { + if EVENTS.output::(event, 0).is_err() { if let Some(ptr) = EVENT_STATS.get_ptr_mut(0) { unsafe { *ptr = (*ptr).wrapping_add(1) }; } From 8b9551af4f36295f569acc31f019ef4e07733105 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Wed, 23 Sep 2026 18:41:03 +0000 Subject: [PATCH 11/17] chore: project endpoint from Mono be57256872b9 --- .repository-projection.json | 8 +- macos/Sources/MerlinMacOS/Inventory.swift | 176 +++++++ macos/Tests/MerlinMacOSTests/SyncTests.swift | 44 ++ merlin/src/sync.rs | 453 +++++++++++++++++++ 4 files changed, 677 insertions(+), 4 deletions(-) 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. From 0f1b3ef660f5f0df05b8bc515acf97ebc2e488af Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Wed, 23 Sep 2026 19:43:13 +0000 Subject: [PATCH 12/17] chore: project endpoint from Mono fc21676e9136 --- .repository-projection.json | 6 +++--- Cargo.lock | 8 ++++---- macos/Sources/MerlinMacOS/Inventory.swift | 19 ++++++++++++++++--- macos/Tests/MerlinMacOSTests/SyncTests.swift | 3 +++ merlin/src/sync.rs | 8 ++++++++ 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 6a593ee..ef809db 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "be57256872b9a0b021c1da667b7803f806e8f289", + "sourceSha": "fc21676e9136ce5ddca1260a75b4044e3c3b8bc3", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "f906d29bae651484eca29bf35d5577b0919a50df", + "priorProjectedBase": "2243d823a7fbcbb69382c681cc7858bda33136d4", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6", - "contentDigest": "306e18a97c82824dfe39bd5e021dfbf2fa6ca4f28b2eb200ed1224cc052615bc", + "contentDigest": "9f84e7f599492777fd805f10e8f2cab5c9d2772d1f68134bebfe089edadc7e20", "publicationEligible": true } diff --git a/Cargo.lock b/Cargo.lock index b3f32ae..206eda6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -815,9 +815,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -839,9 +839,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index cb995ff..46f0eb9 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -221,9 +221,7 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String]) -> (clis: [ ] 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) { + for entry in boundedAgentDirectoryEntries(directory) { let path = "\(directory)/\(entry)" var info = stat() guard lstat(path, &info) == 0 else { continue } @@ -308,6 +306,21 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String]) -> (clis: [ return (clis, servers, assets) } +private func boundedAgentDirectoryEntries(_ path: String) -> [String] { + let fd = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard fd >= 0 else { return [] } + guard let directory = fdopendir(fd) else { close(fd); return [] } + defer { closedir(directory) } + var names = [String]() + while names.count < 256, let entry = readdir(directory) { + let name = withUnsafePointer(to: &entry.pointee.d_name) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: 1) { String(cString: $0) } + } + if name != "." && name != ".." { names.append(name) } + } + return names.sorted() +} + private func safeAgentAssetName(_ name: String) -> Bool { !name.isEmpty && name.utf8.count <= 128 && !name.hasPrefix(".") && !name.contains("/") && !name.contains("\\") && diff --git a/macos/Tests/MerlinMacOSTests/SyncTests.swift b/macos/Tests/MerlinMacOSTests/SyncTests.swift index f30db72..f113b9c 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -143,6 +143,8 @@ struct SyncTests { 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 + "/.pi/agent", withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: home + "/.pi/agent/skills", withDestinationPath: home + "/.agents/skills") 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) @@ -170,6 +172,7 @@ struct SyncTests { #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" }) + #expect(!discovered.assets.contains { $0.client == "pi" && $0.name == "review" }) 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")) diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index fa01bef..56f7531 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -2283,6 +2283,9 @@ mod tests { "secret instructions", ) .unwrap(); + fs::create_dir_all(home.join(".pi/agent")).unwrap(); + std::os::unix::fs::symlink(home.join(".agents/skills"), home.join(".pi/agent/skills")) + .unwrap(); fs::create_dir_all(home.join(".gemini/extensions/workspace")).unwrap(); fs::write( home.join(".gemini/extensions/workspace/gemini-extension.json"), @@ -2365,6 +2368,11 @@ mod tests { && item.name == "trace")); assert!(!assets.iter().any(|item| item.name == "off@marketplace")); assert!(!assets.iter().any(|item| item.name == "not-extension")); + assert!( + !assets + .iter() + .any(|item| item.client == "pi" && item.name == "review") + ); assert!(!serde_json::to_string(&assets).unwrap().contains("secret")); fs::remove_file(home.join(".cursor/mcp.json")).unwrap(); std::os::unix::fs::symlink( From f2529d16af6f024136ac580e43dc53ab8f3b6951 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Wed, 23 Sep 2026 20:51:14 +0000 Subject: [PATCH 13/17] chore: project endpoint from Mono 0d25f444e779 --- .repository-projection.json | 6 +-- macos/Sources/MerlinMacOS/Inventory.swift | 22 +++++++-- macos/Sources/MerlinMacOS/Rules.swift | 31 +++++++++++- macos/Tests/MerlinMacOSTests/RulesTests.swift | 9 ++++ macos/Tests/MerlinMacOSTests/SyncTests.swift | 5 +- macos/packaging/merlin-configure.sh | 24 +++++++++- merlin/src/rules.rs | 48 +++++++++++++++++++ merlin/src/sync.rs | 17 +++++-- 8 files changed, 148 insertions(+), 14 deletions(-) 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, From 8bfc1c7b13e3fff4923dd551f044942ae6ac9622 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Thu, 24 Sep 2026 01:25:57 +0000 Subject: [PATCH 14/17] chore: project endpoint from Mono 68580466ba81 --- .repository-projection.json | 6 +- macos/Sources/MerlinMacOS/Inventory.swift | 42 ++++++--- macos/Tests/MerlinMacOSTests/SyncTests.swift | 18 +++- merlin/src/sync.rs | 96 +++++++++++++++++++- 4 files changed, 140 insertions(+), 22 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index f246578..369408c 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "0d25f444e7799874298801a23f5a5c3a5e5003a2", + "sourceSha": "68580466ba8135da1cfa25c6259800c32659d6d1", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "5b09a4650684da900ad94b518e4360f64d7fdd5a", + "priorProjectedBase": "7287d525f43da516baa3f76c63d4e13603f30785", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6", - "contentDigest": "f104a7a0f4ffd84a4a0f643452fee149c06a0a15320c3a0aec207f71d53ce472", + "contentDigest": "a0d68f21dc50f154471ba8bbb8ef0d5ad91a560020bd9e09c6c3b91817235b81", "publicationEligible": true } diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index 8dd540f..af6835c 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -46,8 +46,8 @@ 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 } +struct DeviceMCPServer: Encodable, Sendable { let client: String; let name: String; let source: String } +struct DeviceAgentAsset: Encodable, Sendable { let client: String; let kind: String; let name: String; let source: String } struct DevicePackage: Encodable, Sendable { let name: String @@ -214,9 +214,12 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ ("amp", ".config/amp/settings.json", false), ("qwen", ".qwen/settings.json", false), ("pi", ".pi/agent/settings.json", false), + ("maestro", ".maestro/config.toml", true), + ("maestro", ".composer/config.toml", true), ] var found = Set() var assetNames = Set() + var pluginConfigReads = 0 for home in homes.prefix(65) { let assetDirs: [(String, String, String, String)] = [ ("agents", "skill", ".agents/skills", "skill"), @@ -232,6 +235,9 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ ("qwen", "skill", ".qwen/skills", "skill"), ("pi", "skill", ".pi/agent/skills", "skill"), ("pi", "extension", ".pi/agent/extensions", "js-ts"), + ("maestro", "skill", ".composer/skills", "skill"), + ("maestro", "plugin", ".maestro/plugins", "plugin-directory"), + ("maestro", "plugin", ".composer/plugins", "plugin-directory"), ] for (client, kind, relative, format) in assetDirs { let directory = "\(home)/\(relative)" @@ -248,17 +254,29 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ } 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 format == "plugin-directory" && type == mode_t(S_IFDIR) { + name = entry } 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)") + assetNames.insert("\(client)\u{0}\(kind)\u{0}\(name)\u{0}\(relative)") 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)") + found.insert("gemini\u{0}\(server)\u{0}.gemini/extensions/*/gemini-extension.json") + } + } + if client == "maestro" && kind == "plugin" { + for config in ["mcp.json", ".mcp.json"] where pluginConfigReads < 32 { + guard let data = readAgentConfigNoFollow("\(path)/\(config)") else { continue } + pluginConfigReads += 1 + 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("maestro\u{0}\(server)\u{0}\(relative)/*/\(config)") + } } } if assetNames.count >= 128 { break } @@ -268,12 +286,12 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ } for (client, relative, isTOML) in configs { guard let data = readAgentConfigNoFollow("\(home)/\(relative)") else { continue } - assetNames.insert("\(client)\u{0}config\u{0}user") + assetNames.insert("\(client)\u{0}config\u{0}user\u{0}\(relative)") 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)") + assetNames.insert("claude\u{0}plugin\u{0}\(name)\u{0}.claude/settings.json") } continue } @@ -300,7 +318,7 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ 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)") + found.insert("\(client)\u{0}\(name)\u{0}\(relative)") if found.count >= 128 { break } } if found.count >= 128 { break } @@ -308,14 +326,14 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ 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 parts = entry.split(separator: "\u{0}") + guard parts.count == 3 else { return nil } + return DeviceMCPServer(client: String(parts[0]), name: String(parts[1]), source: String(parts[2])) } 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])) + guard parts.count == 4 else { return nil } + return DeviceAgentAsset(client: String(parts[0]), kind: String(parts[1]), name: String(parts[2]), source: String(parts[3])) } return (clis, apps, servers, assets) } diff --git a/macos/Tests/MerlinMacOSTests/SyncTests.swift b/macos/Tests/MerlinMacOSTests/SyncTests.swift index 1d26356..a41d514 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -150,6 +150,9 @@ 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 + "/.maestro/plugins/audit/.plugin", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.maestro/plugins/convention", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: home + "/.composer/skills/review", 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) @@ -161,6 +164,10 @@ struct SyncTests { 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) + try "secret plugin".write(toFile: home + "/.maestro/plugins/audit/.plugin/plugin.json", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"pluginsearch":{"command":"secret"}}}"#.write(toFile: home + "/.maestro/plugins/audit/mcp.json", atomically: true, encoding: .utf8) + try "secret skill".write(toFile: home + "/.composer/skills/review/SKILL.md", atomically: true, encoding: .utf8) + try "[mcp_servers.managed]\nurl = 'https://secret.example'\n".write(toFile: home + "/.maestro/config.toml", 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) @@ -168,11 +175,18 @@ struct SyncTests { 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.servers.map { "\($0.client):\($0.name)" } == ["amp:db", "codex:github", "cursor:docs", "gemini:search", "maestro:managed", "maestro:pluginsearch"]) + #expect(discovered.servers.contains { $0.client == "codex" && $0.source == ".codex/config.toml" }) + #expect(discovered.servers.contains { $0.client == "gemini" && $0.source == ".gemini/extensions/*/gemini-extension.json" }) + #expect(discovered.servers.contains { $0.client == "maestro" && $0.name == "managed" && $0.source == ".maestro/config.toml" }) + #expect(discovered.servers.contains { $0.client == "maestro" && $0.name == "pluginsearch" && $0.source == ".maestro/plugins/*/mcp.json" }) #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.client == "maestro" && $0.kind == "plugin" && $0.name == "audit" }) + #expect(discovered.assets.contains { $0.client == "maestro" && $0.kind == "skill" && $0.name == "review" }) + #expect(discovered.assets.contains { $0.client == "maestro" && $0.kind == "plugin" && $0.name == "convention" }) #expect(!discovered.assets.contains { $0.name == "off@marketplace" }) #expect(!discovered.assets.contains { $0.name == "not-extension" }) #expect(!discovered.assets.contains { $0.client == "pi" && $0.name == "review" }) @@ -182,7 +196,7 @@ struct SyncTests { 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) + #expect(collectMacAgentDiscovery(homes: [home], systemBins: []).servers.count == 5) } @Test("host id is a 16-char hash, not the raw UUID") diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index 524d1e1..1861e2a 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -407,6 +407,7 @@ struct DeviceAgentCLI { struct DeviceMCPServer { client: String, name: String, + source: String, } #[derive(Serialize, Debug, PartialEq, Ord, PartialOrd, Eq, Clone)] @@ -414,6 +415,7 @@ struct DeviceAgentAsset { client: String, kind: String, name: String, + source: String, } #[derive(Serialize, Debug, PartialEq)] @@ -1903,10 +1905,13 @@ fn collect_agent_discovery_from( ("amp", ".config/amp/settings.json", false), ("qwen", ".qwen/settings.json", false), ("pi", ".pi/agent/settings.json", false), + ("maestro", ".maestro/config.toml", true), + ("maestro", ".composer/config.toml", true), ]; let mut clis = BTreeSet::new(); let mut servers = BTreeSet::new(); let mut assets = BTreeSet::new(); + let mut plugin_config_reads = 0; for name in CLIS { let found = system_bins .iter() @@ -1945,6 +1950,9 @@ fn collect_agent_discovery_from( ("qwen", "skill", ".qwen/skills", ""), ("pi", "skill", ".pi/agent/skills", ""), ("pi", "extension", ".pi/agent/extensions", "js-ts"), + ("maestro", "skill", ".composer/skills", ""), + ("maestro", "plugin", ".maestro/plugins", "plugin"), + ("maestro", "plugin", ".composer/plugins", "plugin"), ] { let directory = home.join(relative); if !directory @@ -1987,6 +1995,8 @@ fn collect_agent_discovery_from( .is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) { Some(file_name.as_str()) + } else if extension == "plugin" && kind_on_disk.is_dir() { + Some(file_name.as_str()) } else { None }; @@ -1995,6 +2005,7 @@ fn collect_agent_discovery_from( client: client.into(), kind: kind.into(), name: name.into(), + source: relative.into(), }); if client == "gemini" && kind == "extension" { if let Some(body) = read_agent_config( @@ -2005,11 +2016,33 @@ fn collect_agent_discovery_from( servers.insert(DeviceMCPServer { client: "gemini".into(), name: server, + source: ".gemini/extensions/*/gemini-extension.json".into(), }); } } } } + if client == "maestro" && kind == "plugin" { + for config in ["mcp.json", ".mcp.json"] { + if plugin_config_reads >= 32 { + break; + } + if let Some(body) = + read_agent_config(&directory.join(&file_name).join(config)) + { + plugin_config_reads += 1; + for server in json_mcp_names(&body) { + if safe_agent_asset_name(&server) { + servers.insert(DeviceMCPServer { + client: "maestro".into(), + name: server, + source: format!("{relative}/*/{config}"), + }); + } + } + } + } + } if assets.len() >= 128 { break; } @@ -2027,6 +2060,7 @@ fn collect_agent_discovery_from( client: (*client).into(), kind: "config".into(), name: "user".into(), + source: (*relative).into(), }); if *client == "claude" && *relative == ".claude/settings.json" { if let Ok(value) = serde_json::from_str::(&body) { @@ -2040,6 +2074,7 @@ fn collect_agent_discovery_from( client: "claude".into(), kind: "plugin".into(), name: name.clone(), + source: ".claude/settings.json".into(), }); } } @@ -2077,6 +2112,7 @@ fn collect_agent_discovery_from( servers.insert(DeviceMCPServer { client: (*client).into(), name, + source: (*relative).into(), }); if servers.len() >= 128 { break; @@ -2321,6 +2357,29 @@ mod tests { "secret plugin", ) .unwrap(); + fs::create_dir_all(home.join(".maestro/plugins/audit/.plugin")).unwrap(); + fs::write( + home.join(".maestro/plugins/audit/.plugin/plugin.json"), + "secret plugin", + ) + .unwrap(); + fs::write( + home.join(".maestro/plugins/audit/mcp.json"), + r#"{"mcpServers":{"pluginsearch":{"command":"secret"}}}"#, + ) + .unwrap(); + fs::create_dir_all(home.join(".maestro/plugins/convention")).unwrap(); + fs::create_dir_all(home.join(".composer/skills/review")).unwrap(); + fs::write( + home.join(".composer/skills/review/SKILL.md"), + "secret skill", + ) + .unwrap(); + fs::write( + home.join(".maestro/config.toml"), + "[mcp_servers.managed]\nurl = 'https://secret.example'\n", + ) + .unwrap(); let (clis, servers, assets) = collect_agent_discovery_from(&[home.clone()], &[]); assert_eq!( clis, @@ -2338,19 +2397,33 @@ mod tests { vec![ DeviceMCPServer { client: "amp".into(), - name: "db".into() + name: "db".into(), + source: ".config/amp/settings.json".into() }, DeviceMCPServer { client: "codex".into(), - name: "github".into() + name: "github".into(), + source: ".codex/config.toml".into() }, DeviceMCPServer { client: "cursor".into(), - name: "docs".into() + name: "docs".into(), + source: ".cursor/mcp.json".into() }, DeviceMCPServer { client: "gemini".into(), - name: "search".into() + name: "search".into(), + source: ".gemini/extensions/*/gemini-extension.json".into() + }, + DeviceMCPServer { + client: "maestro".into(), + name: "managed".into(), + source: ".maestro/config.toml".into() + }, + DeviceMCPServer { + client: "maestro".into(), + name: "pluginsearch".into(), + source: ".maestro/plugins/*/mcp.json".into() }, ] ); @@ -2375,6 +2448,19 @@ mod tests { assert!(assets.iter().any(|item| item.client == "opencode" && item.kind == "plugin" && item.name == "trace")); + assert!( + assets.iter().any(|item| item.client == "maestro" + && item.kind == "plugin" + && item.name == "audit") + ); + assert!( + assets.iter().any(|item| item.client == "maestro" + && item.kind == "skill" + && item.name == "review") + ); + assert!(assets.iter().any(|item| item.client == "maestro" + && item.kind == "plugin" + && item.name == "convention")); assert!(!assets.iter().any(|item| item.name == "off@marketplace")); assert!(!assets.iter().any(|item| item.name == "not-extension")); assert!( @@ -2389,7 +2475,7 @@ mod tests { home.join(".cursor/mcp.json"), ) .unwrap(); - assert_eq!(collect_agent_discovery_from(&[home], &[]).1.len(), 3); + assert_eq!(collect_agent_discovery_from(&[home], &[]).1.len(), 5); fs::remove_dir_all(root).unwrap(); } From bd7d23b233347e14fc8c5f7d16fe560ef0395891 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Thu, 24 Sep 2026 03:26:50 +0000 Subject: [PATCH 15/17] chore: project endpoint from Mono 56d36fc674ba --- .repository-projection.json | 6 +- .../MerlinClientCore/LocalDeviceStatus.swift | 42 +- .../EndpointDetailView.swift | 27 ++ .../MerlinEndpointApp/MerlinEndpointApp.swift | 6 +- macos/Sources/MerlinMacOS/CLI.swift | 33 +- macos/Sources/MerlinMacOS/Engine.swift | 12 +- macos/Sources/MerlinMacOS/Inventory.swift | 185 +++++++- .../MerlinMacOS/LocalStatusStore.swift | 9 +- .../MerlinMacOSTests/LocalStatusTests.swift | 24 + macos/Tests/MerlinMacOSTests/RulesTests.swift | 39 ++ .../Tests/MerlinMacOSTests/SuspendTests.swift | 23 +- macos/Tests/MerlinMacOSTests/SyncTests.swift | 29 ++ macos/packaging/config.example.plist | 3 + macos/packaging/merlin-launcher.sh | 4 +- merlin/src/sync.rs | 416 +++++++++++++++--- packaging/linux/merlin.env.example | 3 + 16 files changed, 757 insertions(+), 104 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 369408c..31f2a4c 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -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 } diff --git a/macos/Sources/MerlinClientCore/LocalDeviceStatus.swift b/macos/Sources/MerlinClientCore/LocalDeviceStatus.swift index ad9a157..d5dfc66 100644 --- a/macos/Sources/MerlinClientCore/LocalDeviceStatus.swift +++ b/macos/Sources/MerlinClientCore/LocalDeviceStatus.swift @@ -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 { @@ -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 @@ -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 { @@ -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 diff --git a/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift b/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift index 9cceccb..5ca69c0 100644 --- a/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift +++ b/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift @@ -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) @@ -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 diff --git a/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift b/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift index 7f3ae6c..8d6d41d 100644 --- a/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift +++ b/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift @@ -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) @@ -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() diff --git a/macos/Sources/MerlinMacOS/CLI.swift b/macos/Sources/MerlinMacOS/CLI.swift index 25373cf..cc626c5 100644 --- a/macos/Sources/MerlinMacOS/CLI.swift +++ b/macos/Sources/MerlinMacOS/CLI.swift @@ -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 { @@ -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() } } @@ -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)") @@ -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") @@ -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") diff --git a/macos/Sources/MerlinMacOS/Engine.swift b/macos/Sources/MerlinMacOS/Engine.swift index e1901ac..27857cc 100644 --- a/macos/Sources/MerlinMacOS/Engine.swift +++ b/macos/Sources/MerlinMacOS/Engine.swift @@ -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- @@ -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). @@ -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)") @@ -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) } @@ -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) + } } } diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index af6835c..ef3ea01 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -46,7 +46,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; let source: String } +struct DeviceMCPServer: Encodable, Sendable { let client: String; let name: String; let source: String; let transport: String } struct DeviceAgentAsset: Encodable, Sendable { let client: String; let kind: String; let name: String; let source: String } struct DevicePackage: Encodable, Sendable { @@ -177,7 +177,109 @@ private func collectMacAgentDiscovery() -> (clis: [DeviceAgentCLI], apps: [Devic 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"]) + let base = collectMacAgentDiscovery(homes: homes, systemBins: ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"]) + let project = collectMacProjectAgentDiscovery(roots: configuredMacAgentWorkspaceRoots()) + let servers = Array(Set(base.servers.map { "\($0.client)\u{0}\($0.name)\u{0}\($0.source)\u{0}\($0.transport)" } + project.servers.map { "\($0.client)\u{0}\($0.name)\u{0}\($0.source)\u{0}\($0.transport)" })).sorted().prefix(128).compactMap { entry -> DeviceMCPServer? in + let parts = entry.split(separator: "\u{0}") + guard parts.count == 4 else { return nil } + return DeviceMCPServer(client: String(parts[0]), name: String(parts[1]), source: String(parts[2]), transport: String(parts[3])) + } + let assets = Array(Set(base.assets.map { "\($0.client)\u{0}\($0.kind)\u{0}\($0.name)\u{0}\($0.source)" } + project.assets.map { "\($0.client)\u{0}\($0.kind)\u{0}\($0.name)\u{0}\($0.source)" })).sorted().prefix(128).compactMap { entry -> DeviceAgentAsset? in + let parts = entry.split(separator: "\u{0}") + guard parts.count == 4 else { return nil } + return DeviceAgentAsset(client: String(parts[0]), kind: String(parts[1]), name: String(parts[2]), source: String(parts[3])) + } + return (base.clis, base.apps, servers, assets) +} + +private func configuredMacAgentWorkspaceRoots() -> [String] { + guard let raw = ProcessInfo.processInfo.environment["MERLIN_AGENT_WORKSPACE_ROOTS"], raw.utf8.count <= 4096, + let data = raw.data(using: .utf8), let paths = try? JSONDecoder().decode([String].self, from: data) else { return [] } + return Array(paths.filter { path in + path.hasPrefix("/") && path.utf8.count <= 512 && !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) + }.prefix(8)) +} + +private func macProjectDirectory(_ path: String) -> Bool { + var info = stat() + return lstat(path, &info) == 0 && (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFDIR) +} + +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 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() + var assets = Set() + var pluginConfigReads = 0 + for root in roots.prefix(8) where macProjectDirectory(root) { + let children = boundedAgentDirectoryEntries(root).filter { macProjectDirectory("\(root)/\($0)") }.prefix(32).map { "\(root)/\($0)" } + for project in [root] + children { + for (client, relative, isTOML) in configs { + let path = "\(project)/\(relative)" + if relative.contains("/"), !macProjectDirectory("\(project)/\(relative.split(separator: "/")[0])") { continue } + guard let data = readAgentConfigNoFollow(path) else { continue } + assets.insert("\(client)\u{0}config\u{0}project\u{0}project/\(relative)") + if 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) { + assets.insert("claude\u{0}plugin\u{0}\(name)\u{0}project/.claude/settings.json") + } + } + let entries: [(String, String)] + if isTOML { + let body = String(data: data, encoding: .utf8) ?? "" + entries = tomlMCPEntries(body) + } else { + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + entries = mcpEntries(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)") + } + } + for (client, kind, relative, format) in assetDirs { + let parts = relative.split(separator: "/") + guard parts.count == 2, macProjectDirectory("\(project)/\(parts[0])") else { continue } + let directory = "\(project)/\(relative)" + for entry in boundedAgentDirectoryEntries(directory) { + 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() + name = lstat("\(path)/SKILL.md", &manifest) == 0 && (manifest.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) ? entry : nil + } else if format == "plugin" && type == mode_t(S_IFDIR) { + name = entry + } else if format == "md" && type == mode_t(S_IFREG) && entry.hasSuffix(".md") { + name = String(entry.dropLast(3)) + } else { name = nil } + if let name, safeAgentAssetName(name) { + assets.insert("\(client)\u{0}\(kind)\u{0}\(name)\u{0}project/\(relative)") + if client == "maestro" && kind == "plugin" { + for config in ["mcp.json", ".mcp.json"] where pluginConfigReads < 32 { + pluginConfigReads += 1 + guard let data = readAgentConfigNoFollow("\(path)/\(config)") else { continue } + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + for (server, transport) in mcpEntries(object?["mcpServers"] ?? object?["servers"]) where safeAgentAssetName(server) { + found.insert("maestro\u{0}\(server)\u{0}project/\(relative)/*/\(config)\u{0}\(transport)") + } + } + } + } + } + } + } + } + return (found.sorted().prefix(128).compactMap { entry in + let parts = entry.split(separator: "\u{0}") + return parts.count == 4 ? DeviceMCPServer(client: String(parts[0]), name: String(parts[1]), source: String(parts[2]), transport: String(parts[3])) : nil + }, assets.sorted().prefix(128).compactMap { entry in + let parts = entry.split(separator: "\u{0}") + return parts.count == 4 ? DeviceAgentAsset(client: String(parts[0]), kind: String(parts[1]), name: String(parts[2]), source: String(parts[3])) : nil + }) } // Fixed probes only: no CLI execution and no configuration values are emitted. @@ -265,8 +367,8 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ assetNames.insert("\(client)\u{0}\(kind)\u{0}\(name)\u{0}\(relative)") 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)\u{0}.gemini/extensions/*/gemini-extension.json") + for (server, transport) in mcpEntries(object?["mcpServers"]) where safeAgentAssetName(server) { + found.insert("gemini\u{0}\(server)\u{0}.gemini/extensions/*/gemini-extension.json\u{0}\(transport)") } } if client == "maestro" && kind == "plugin" { @@ -274,8 +376,8 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ guard let data = readAgentConfigNoFollow("\(path)/\(config)") else { continue } pluginConfigReads += 1 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("maestro\u{0}\(server)\u{0}\(relative)/*/\(config)") + for (server, transport) in mcpEntries(object?["mcpServers"]) where safeAgentAssetName(server) { + found.insert("maestro\u{0}\(server)\u{0}\(relative)/*/\(config)\u{0}\(transport)") } } } @@ -295,30 +397,22 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ } continue } - let names: [String] + let entries: [(String, 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) } ?? [] + entries = mcpEntries(object?["amp.mcpServers"]) } else if client == "opencode" { let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] - names = (object?["mcp"] as? [String: Any]).map { Array($0.keys) } ?? [] + entries = mcpEntries(object?["mcp"]) } 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 - } + entries = tomlMCPEntries(body) } 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) } ?? [] + entries = mcpEntries(object?["mcpServers"] ?? object?["servers"]) } - for name in names where name.utf8.count <= 128 && !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) { - found.insert("\(client)\u{0}\(name)\u{0}\(relative)") + for (name, transport) in entries where name.utf8.count <= 128 && !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) { + found.insert("\(client)\u{0}\(name)\u{0}\(relative)\u{0}\(transport)") if found.count >= 128 { break } } if found.count >= 128 { break } @@ -327,8 +421,8 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ } let servers = found.sorted().prefix(128).compactMap { entry -> DeviceMCPServer? in let parts = entry.split(separator: "\u{0}") - guard parts.count == 3 else { return nil } - return DeviceMCPServer(client: String(parts[0]), name: String(parts[1]), source: String(parts[2])) + guard parts.count == 4 else { return nil } + return DeviceMCPServer(client: String(parts[0]), name: String(parts[1]), source: String(parts[2]), transport: String(parts[3])) } let assets = assetNames.sorted().prefix(128).compactMap { entry -> DeviceAgentAsset? in let parts = entry.split(separator: "\u{0}") @@ -338,6 +432,51 @@ func collectMacAgentDiscovery(homes: [String], systemBins: [String], appRoots: [ return (clis, apps, servers, assets) } +private func mcpEntries(_ raw: Any?) -> [(String, String)] { + guard let definitions = raw as? [String: Any] else { return [] } + return definitions.map { name, rawDefinition in + let definition = rawDefinition as? [String: Any] ?? [:] + let hasURL = ["url", "httpUrl", "http_url"].contains { definition[$0] is String } + let hasCommand = definition["command"] is String + let transport = hasURL == hasCommand ? "unknown" : (hasURL ? "remote" : "stdio") + return (name, transport) + } +} + +private func tomlMCPEntries(_ body: String) -> [(String, String)] { + var entries: [(String, String)] = [] + var name: String? + var hasURL = false + var hasCommand = false + func finish() { + if let name { + entries.append((name, hasURL == hasCommand ? "unknown" : (hasURL ? "remote" : "stdio"))) + } + } + for line in body.split(separator: "\n") { + let text = line.trimmingCharacters(in: .whitespaces) + if text.hasPrefix("[") && text.hasSuffix("]") { + finish() + name = nil + hasURL = false + hasCommand = false + if text.hasPrefix("[mcp_servers.") { + let raw = String(text.dropFirst("[mcp_servers.".count).dropLast()) + let quoted = raw.hasPrefix("\"") && raw.hasSuffix("\"") && raw.count >= 2 + let candidate = quoted ? String(raw.dropFirst().dropLast()) : raw + if !candidate.isEmpty && !candidate.contains(where: { "[]".contains($0) }) && (quoted || !candidate.contains(".")) { + name = candidate + } + } + } else if name != nil, let key = text.split(separator: "=", maxSplits: 1).first?.trimmingCharacters(in: .whitespaces) { + if key == "url" || key == "http_url" { hasURL = true } + if key == "command" { hasCommand = true } + } + } + finish() + return entries +} + private func boundedAgentDirectoryEntries(_ path: String) -> [String] { let fd = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) guard fd >= 0 else { return [] } diff --git a/macos/Sources/MerlinMacOS/LocalStatusStore.swift b/macos/Sources/MerlinMacOS/LocalStatusStore.swift index 033a66e..50d8b06 100644 --- a/macos/Sources/MerlinMacOS/LocalStatusStore.swift +++ b/macos/Sources/MerlinMacOS/LocalStatusStore.swift @@ -10,6 +10,12 @@ final class LocalStatusStore: @unchecked Sendable { private var deviceID: String? private var enrollment: LocalEnrollmentState = .unconfigured private var lastServerContact: Date? + private var enforcement: LocalEnforcementNotice? + + func recordEnforcement(action: LocalEnforcementAction, approvedName: String?, approvedURL: URL?, at now: Date = Date()) { + let notice = LocalEnforcementNotice(action: action, occurredAt: now, approvedName: approvedName, approvedURL: approvedURL) + lock.withLock { enforcement = notice } + } func configure(deviceID: String?) { lock.withLock { @@ -73,7 +79,8 @@ final class LocalStatusStore: @unchecked Sendable { return LocalDeviceStatus( observedAt: report.flatMap { Double($0.collectedAt) }.map(Date.init(timeIntervalSince1970:)) ?? .distantPast, deviceID: safeID, collectorRunning: true, enrollment: enrollment, - posture: summary, checks: checks, lastServerContact: lastServerContact) + posture: summary, checks: checks, lastServerContact: lastServerContact, + enforcement: enforcement.flatMap { $0.isRecent() ? $0 : nil }) } } } diff --git a/macos/Tests/MerlinMacOSTests/LocalStatusTests.swift b/macos/Tests/MerlinMacOSTests/LocalStatusTests.swift index c278af8..4905a75 100644 --- a/macos/Tests/MerlinMacOSTests/LocalStatusTests.swift +++ b/macos/Tests/MerlinMacOSTests/LocalStatusTests.swift @@ -45,6 +45,30 @@ import Testing #expect(try LocalDeviceStatus.decode(encoded).checks.first?.status == .finding) } + @Test func enforcementGuidanceIsRecentAndContainsOnlyApprovedMapping() throws { + let store = LocalStatusStore() + let approvedURL = try #require(URL(string: "https://approved.example.com/editor")) + store.recordEnforcement(action: .blocked, approvedName: "Approved editor", approvedURL: approvedURL) + let data = try JSONEncoder().encode(store.snapshot()) + let text = String(decoding: data, as: UTF8.self) + #expect(text.contains("Approved editor")) + #expect(!text.contains("process")) + #expect(try LocalDeviceStatus.decode(data).enforcement?.approvedURL == approvedURL) + + store.recordEnforcement(action: .stopped, approvedName: nil, approvedURL: nil, + at: Date().addingTimeInterval(-3601)) + #expect(store.snapshot().enforcement == nil) + } + + @Test func malformedGuidanceIsRejectedAtIPCBoundary() throws { + let status = LocalDeviceStatus(observedAt: Date(), deviceID: nil, collectorRunning: true, + enrollment: .configured, posture: .unknown, + checks: LocalStatusStore().snapshot().checks, lastServerContact: nil, + enforcement: LocalEnforcementNotice(action: .blocked, occurredAt: Date(), + approvedName: "Injected", approvedURL: URL(string: "https://user:secret@example.com"))) + #expect(throws: (any Error).self) { try LocalDeviceStatus.decode(JSONEncoder().encode(status)) } + } + @Test func unknownChecksDoNotBecomeSecure() { let store = LocalStatusStore() store.publish(makeDevicePostureReport(snapshot: PostureSnapshot(values: ["filevault": "unknown"], findings: [:]))) diff --git a/macos/Tests/MerlinMacOSTests/RulesTests.swift b/macos/Tests/MerlinMacOSTests/RulesTests.swift index fbda16e..4d8f810 100644 --- a/macos/Tests/MerlinMacOSTests/RulesTests.swift +++ b/macos/Tests/MerlinMacOSTests/RulesTests.swift @@ -1,4 +1,5 @@ import Foundation +import MerlinClientCore import Testing @testable import MerlinMacOS @@ -27,6 +28,44 @@ struct RulesTests { #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("an actual deny publishes the mapped alternative, and a failsafe does not") + func deniedExecutionGuidance() throws { + let parsed = try rule("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 store = LocalStatusStore() + var engine = Engine(rules: Rules(rules: [parsed]), + spool: try SpoolWriter(path: NSTemporaryDirectory() + "merlin-guidance-\(UUID().uuidString).jsonl"), + canBlock: true) + engine.onEnforcement = { action, alternative in + store.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } + #expect(engine.authVerdict(pid: 1, uid: 0, path: "/tmp/Cursor", sha256: nil, cdhash: nil).allow) + #expect(store.snapshot().enforcement == nil) + #expect(!engine.authVerdict(pid: 42_424, uid: 501, path: "/tmp/Cursor", sha256: nil, cdhash: nil).allow) + #expect(store.snapshot().enforcement?.action == .blocked) + #expect(store.snapshot().enforcement?.approvedName == "Approved editor") + } + + @Test("reactive kill guidance appears only after a successful kill") + func stoppedExecutionGuidance() throws { + let parsed = try rule("name: stop-claude\nmatch:\n path_basename: claude\naction: kill\napproved_alternative:\n name: Approved agent\n url: https://tools.example.com/agent\n") + let store = LocalStatusStore() + let identity = ProcessIdentity(startSec: 1, startUsec: 1) + var engine = Engine(rules: Rules(rules: [parsed]), + spool: try SpoolWriter(path: NSTemporaryDirectory() + "merlin-guidance-\(UUID().uuidString).jsonl"), + canBlock: false, killImpl: { _ in 0 }, selfPID: 42_424, + processIdentity: { _ in identity }) + engine.onEnforcement = { action, alternative in + store.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } + engine.handleExec(pid: 1, ppid: nil, uid: 0, comm: "claude", exe: "/tmp/claude", + cmdline: nil, sha256: nil, cdhash: nil, identity: identity) + #expect(store.snapshot().enforcement == nil) + engine.handleExec(pid: 123, ppid: nil, uid: 501, comm: "claude", exe: "/tmp/claude", + cmdline: nil, sha256: nil, cdhash: nil, identity: identity) + #expect(store.snapshot().enforcement?.action == .stopped) + #expect(store.snapshot().enforcement?.approvedName == "Approved agent") + } @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/SuspendTests.swift b/macos/Tests/MerlinMacOSTests/SuspendTests.swift index 3004b71..0857ee9 100644 --- a/macos/Tests/MerlinMacOSTests/SuspendTests.swift +++ b/macos/Tests/MerlinMacOSTests/SuspendTests.swift @@ -60,6 +60,17 @@ private func spoolEvents(_ path: String) -> [[String: Any]] { } } +private func waitForSleep(_ pid: pid_t) throws -> ProcessIdentity { + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + if let process = procInfo(pid), process.comm == "sleep" { + return process.identity + } + usleep(10_000) + } + throw MerlinError.plain("child did not exec /bin/sleep before suspend test deadline") +} + @Suite("suspend guardrails", .serialized) struct SuspendGuardrailTests { private func makeEngine( @@ -99,7 +110,7 @@ struct SuspendGuardrailTests { @Test("no second-stage match: child is stopped, inspected, and resumed (suspend_released)") func resumeOnMismatch() throws { let signals = SignalRecorder(forReal: true) - let (engine, spoolPath) = try makeEngine(rulesYaml: """ + var (engine, spoolPath) = try makeEngine(rulesYaml: """ rules: - name: susp-sleep match: {path_basename: sleep} @@ -108,8 +119,8 @@ struct SuspendGuardrailTests { defer { try? FileManager.default.removeItem(atPath: spoolPath) } let child = forkExec("/bin/sleep", ["30"]) defer { Darwin.kill(child, SIGKILL); reap(child) } - usleep(200_000) // let the child exec - let identity = procInfo(child)?.identity + let identity = try waitForSleep(child) + engine.processIdentity = { pid in pid == child ? identity : procInfo(pid)?.identity } engine.handleExec( pid: child, ppid: getpid(), uid: getuid(), comm: "sleep", exe: "/bin/sleep", cmdline: "sleep 30", sha256: nil, cdhash: nil, identity: identity @@ -126,7 +137,7 @@ struct SuspendGuardrailTests { @Test("second-stage kill rule matches: frozen child is killed (via_suspend)") func killOnMatch() throws { let signals = SignalRecorder(forReal: true) - let (engine, spoolPath) = try makeEngine(rulesYaml: """ + var (engine, spoolPath) = try makeEngine(rulesYaml: """ rules: - name: susp-sleep match: {path_basename: sleep} @@ -140,8 +151,8 @@ struct SuspendGuardrailTests { defer { try? FileManager.default.removeItem(atPath: spoolPath) } let child = forkExec("/bin/sleep", ["30"]) defer { Darwin.kill(child, SIGKILL); reap(child) } - usleep(200_000) - let identity = procInfo(child)?.identity + let identity = try waitForSleep(child) + engine.processIdentity = { pid in pid == child ? identity : procInfo(pid)?.identity } engine.handleExec( pid: child, ppid: getpid(), uid: getuid(), comm: "sleep", exe: "/bin/sleep", cmdline: "sleep 30", sha256: nil, cdhash: nil, identity: identity diff --git a/macos/Tests/MerlinMacOSTests/SyncTests.swift b/macos/Tests/MerlinMacOSTests/SyncTests.swift index a41d514..2f215b5 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -177,6 +177,9 @@ struct SyncTests { #expect(discovered.apps.map(\.name) == ["cursor"]) #expect(discovered.servers.map { "\($0.client):\($0.name)" } == ["amp:db", "codex:github", "cursor:docs", "gemini:search", "maestro:managed", "maestro:pluginsearch"]) #expect(discovered.servers.contains { $0.client == "codex" && $0.source == ".codex/config.toml" }) + #expect(discovered.servers.contains { $0.client == "codex" && $0.transport == "remote" }) + #expect(discovered.servers.contains { $0.client == "cursor" && $0.transport == "stdio" }) + #expect(discovered.servers.contains { $0.client == "gemini" && $0.transport == "unknown" }) #expect(discovered.servers.contains { $0.client == "gemini" && $0.source == ".gemini/extensions/*/gemini-extension.json" }) #expect(discovered.servers.contains { $0.client == "maestro" && $0.name == "managed" && $0.source == ".maestro/config.toml" }) #expect(discovered.servers.contains { $0.client == "maestro" && $0.name == "pluginsearch" && $0.source == ".maestro/plugins/*/mcp.json" }) @@ -199,6 +202,32 @@ struct SyncTests { #expect(collectMacAgentDiscovery(homes: [home], systemBins: []).servers.count == 5) } + @Test("project discovery reports fixed labels and ignores linked configs") + func projectAgentDiscovery() throws { + let root = NSTemporaryDirectory() + "merlin-project-discovery-\(UUID().uuidString)" + defer { try? FileManager.default.removeItem(atPath: root) } + let project = root + "/customer-private" + try FileManager.default.createDirectory(atPath: project + "/.cursor", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: project + "/.claude/skills/review", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: project + "/.maestro/plugins/audit", withIntermediateDirectories: true) + try #"{"mcpServers":{"pluginsearch":{"url":"https://private.example/mcp"}}}"#.write(toFile: project + "/.maestro/plugins/audit/mcp.json", atomically: true, encoding: .utf8) + try #"{"enabledPlugins":{"audit@marketplace":true,"off@marketplace":false},"secret":"private-secret"}"#.write(toFile: project + "/.claude/settings.json", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"docs":{"command":"private-secret"}}}"#.write(toFile: project + "/.cursor/mcp.json", atomically: true, encoding: .utf8) + try "private-secret".write(toFile: project + "/.claude/skills/review/SKILL.md", atomically: true, encoding: .utf8) + let discovered = collectMacProjectAgentDiscovery(roots: [root]) + #expect(discovered.servers.contains { $0.name == "docs" && $0.source == "project/.cursor/mcp.json" && $0.transport == "stdio" }) + #expect(discovered.servers.contains { $0.name == "pluginsearch" && $0.source == "project/.maestro/plugins/*/mcp.json" && $0.transport == "remote" }) + #expect(discovered.assets.contains { $0.name == "review" && $0.source == "project/.claude/skills" }) + #expect(discovered.assets.contains { $0.name == "audit@marketplace" && $0.kind == "plugin" && $0.source == "project/.claude/settings.json" }) + #expect(discovered.assets.contains { $0.name == "audit" && $0.kind == "plugin" && $0.source == "project/.maestro/plugins" }) + #expect(!discovered.assets.contains { $0.name == "off@marketplace" }) + let payload = String(decoding: try JSONEncoder().encode(discovered.servers), as: UTF8.self) + #expect(!payload.contains("customer-private") && !payload.contains("private-secret")) + try FileManager.default.removeItem(atPath: project + "/.cursor/mcp.json") + try FileManager.default.createSymbolicLink(atPath: project + "/.cursor/mcp.json", withDestinationPath: project + "/.claude/skills/review/SKILL.md") + #expect(!collectMacProjectAgentDiscovery(roots: [root]).servers.contains { $0.name == "docs" }) + } + @Test("host id is a 16-char hash, not the raw UUID") func hostId() { let id = syncHostId() diff --git a/macos/packaging/config.example.plist b/macos/packaging/config.example.plist index b673eaa..f809ade 100644 --- a/macos/packaging/config.example.plist +++ b/macos/packaging/config.example.plist @@ -10,5 +10,8 @@ replace-with-64-hex-character-device-token PolicyPublicKeys replace-with-64-hex-character-ed25519-public-key + + AgentWorkspaceRootsJSON + ["/Users/Shared/Projects"] diff --git a/macos/packaging/merlin-launcher.sh b/macos/packaging/merlin-launcher.sh index 6739b73..0227d29 100755 --- a/macos/packaging/merlin-launcher.sh +++ b/macos/packaging/merlin-launcher.sh @@ -39,6 +39,7 @@ validate_config() { MERLIN_DEVICE_ID=$(plist_value DeviceID) || die "configuration is missing DeviceID" MERLIN_DEVICE_TOKEN=$(plist_value DeviceToken) || die "configuration is missing DeviceToken" MERLIN_POLICY_PUBLIC_KEYS=$(plist_value PolicyPublicKeys) || die "configuration is missing PolicyPublicKeys" + MERLIN_AGENT_WORKSPACE_ROOTS=$(plist_value AgentWorkspaceRootsJSON 2>/dev/null || true) case "$SYNC_URL" in https://*) ;; @@ -63,7 +64,8 @@ validate_config() { done IFS=$old_ifs - export MERLIN_DEVICE_ID MERLIN_DEVICE_TOKEN MERLIN_POLICY_PUBLIC_KEYS + [ "${#MERLIN_AGENT_WORKSPACE_ROOTS}" -le 4096 ] || die "AgentWorkspaceRootsJSON exceeds 4096 bytes" + export MERLIN_DEVICE_ID MERLIN_DEVICE_TOKEN MERLIN_POLICY_PUBLIC_KEYS MERLIN_AGENT_WORKSPACE_ROOTS } validate_config diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index 1861e2a..cfc10f9 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -408,6 +408,7 @@ struct DeviceMCPServer { client: String, name: String, source: String, + transport: String, } #[derive(Serialize, Debug, PartialEq, Ord, PartialOrd, Eq, Clone)] @@ -1871,13 +1872,216 @@ fn collect_agent_discovery() -> ( candidates.sort(); homes.extend(candidates.into_iter().take(64)); } - collect_agent_discovery_from( + let mut discovery = collect_agent_discovery_from( &homes, &[ "/usr/local/bin", "/usr/bin", "/home/linuxbrew/.linuxbrew/bin", ], + ); + let roots = configured_agent_workspace_roots(); + let (project_servers, project_assets) = collect_project_agent_discovery(&roots); + discovery.1.extend(project_servers); + discovery.1.sort(); + discovery.1.dedup(); + discovery.1.truncate(128); + discovery.2.extend(project_assets); + discovery.2.sort(); + discovery.2.dedup(); + discovery.2.truncate(128); + discovery +} + +// MDM supplies a JSON array in the root-owned service configuration. No default +// workspace roots are scanned, and no configured path is sent in inventory. +fn configured_agent_workspace_roots() -> Vec { + let Ok(raw) = std::env::var("MERLIN_AGENT_WORKSPACE_ROOTS") else { + return Vec::new(); + }; + if raw.len() > 4096 { + return Vec::new(); + } + serde_json::from_str::>(&raw) + .unwrap_or_default() + .into_iter() + .filter(|path| path.len() <= 512 && path.starts_with('/')) + .map(PathBuf::from) + .filter(|path| { + path.components().all(|component| { + matches!( + component, + std::path::Component::RootDir | std::path::Component::Normal(_) + ) + }) + }) + .take(8) + .collect() +} + +fn project_directory(path: &std::path::Path) -> bool { + path.symlink_metadata() + .is_ok_and(|meta| meta.is_dir() && !meta.file_type().is_symlink()) +} + +fn collect_project_agent_discovery( + roots: &[PathBuf], +) -> (Vec, Vec) { + const CONFIGS: &[(&str, &str, bool)] = &[ + ("claude", ".mcp.json", false), + ("claude", ".claude/settings.json", false), + ("cursor", ".cursor/mcp.json", false), + ("codex", ".codex/config.toml", true), + ]; + const ASSETS: &[(&str, &str, &str, &str)] = &[ + ("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"), + ]; + let mut servers = BTreeSet::new(); + let mut assets = BTreeSet::new(); + let mut plugin_config_reads = 0; + for root in roots.iter().take(8).filter(|root| project_directory(root)) { + let mut projects = vec![root.clone()]; + if let Ok(entries) = fs::read_dir(root) { + let mut children: Vec<_> = entries + .take(256) + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .map(|entry| entry.path()) + .collect(); + children.sort(); + projects.extend(children.into_iter().take(32)); + } + for project in projects { + if !project_directory(&project) { + continue; + } + for (client, relative, is_toml) in CONFIGS { + let path = project.join(relative); + if path + .parent() + .is_some_and(|parent| parent != project && !project_directory(parent)) + { + continue; + } + let Some(body) = read_agent_config(&path) else { + continue; + }; + assets.insert(DeviceAgentAsset { + client: (*client).into(), + kind: "config".into(), + name: "project".into(), + source: format!("project/{relative}"), + }); + if *relative == ".claude/settings.json" { + if let Ok(value) = serde_json::from_str::(&body) { + if let Some(plugins) = + value.get("enabledPlugins").and_then(|v| v.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(), + source: "project/.claude/settings.json".into(), + }); + } + } + } + } + } + let entries = if *is_toml { + codex_mcp_entries(&body) + } else { + json_mcp_entries(&body, &["mcpServers", "servers"]) + }; + for (name, transport) in entries + .into_iter() + .filter(|(name, _)| safe_agent_asset_name(name)) + { + servers.insert(DeviceMCPServer { + client: (*client).into(), + name, + source: format!("project/{relative}"), + transport, + }); + } + } + for (client, kind, relative, format) in ASSETS { + let directory = project.join(relative); + if !project_directory(directory.parent().unwrap_or(&project)) + || !project_directory(&directory) + { + continue; + } + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + for entry in entries.take(256).flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let filename = entry.file_name().to_string_lossy().into_owned(); + let name = if *format == "skill" + && file_type.is_dir() + && entry + .path() + .join("SKILL.md") + .symlink_metadata() + .is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) + { + Some(filename.as_str()) + } else if *format == "plugin" && file_type.is_dir() { + Some(filename.as_str()) + } else if *format == "md" && file_type.is_file() { + filename.strip_suffix(".md") + } 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(), + source: format!("project/{relative}"), + }); + if *client == "maestro" && *kind == "plugin" { + for config in ["mcp.json", ".mcp.json"] { + if plugin_config_reads >= 32 { + break; + } + plugin_config_reads += 1; + if let Some(body) = read_agent_config(&entry.path().join(config)) { + for (server, transport) in + json_mcp_entries(&body, &["mcpServers", "servers"]) + { + if safe_agent_asset_name(&server) { + servers.insert(DeviceMCPServer { + client: "maestro".into(), + name: server, + source: format!("project/{relative}/*/{config}"), + transport, + }); + } + } + } + } + } + } + } + } + } + } + ( + servers.into_iter().take(128).collect(), + assets.into_iter().take(128).collect(), ) } @@ -2011,12 +2215,15 @@ fn collect_agent_discovery_from( if let Some(body) = read_agent_config( &directory.join(&file_name).join("gemini-extension.json"), ) { - for server in json_mcp_names(&body) { + for (server, transport) in + json_mcp_entries(&body, &["mcpServers", "servers"]) + { if safe_agent_asset_name(&server) { servers.insert(DeviceMCPServer { client: "gemini".into(), name: server, source: ".gemini/extensions/*/gemini-extension.json".into(), + transport, }); } } @@ -2031,12 +2238,15 @@ fn collect_agent_discovery_from( read_agent_config(&directory.join(&file_name).join(config)) { plugin_config_reads += 1; - for server in json_mcp_names(&body) { + for (server, transport) in + json_mcp_entries(&body, &["mcpServers", "servers"]) + { if safe_agent_asset_name(&server) { servers.insert(DeviceMCPServer { client: "maestro".into(), name: server, source: format!("{relative}/*/{config}"), + transport, }); } } @@ -2082,37 +2292,22 @@ fn collect_agent_discovery_from( } 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() + let entries: Vec<(String, String)> = if *client == "amp" { + json_mcp_entries(&body, &["amp.mcpServers"]) } 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() + json_mcp_entries(&body, &["mcp"]) } else if *is_toml { - codex_mcp_names(&body) + codex_mcp_entries(&body) } else { - json_mcp_names(&body) + json_mcp_entries(&body, &["mcpServers", "servers"]) }; - for name in names { + for (name, transport) in entries { if name.len() <= 128 && !name.chars().any(char::is_control) { servers.insert(DeviceMCPServer { client: (*client).into(), name, source: (*relative).into(), + transport, }); if servers.len() >= 128 { break; @@ -2161,36 +2356,84 @@ fn safe_agent_asset_name(name: &str) -> bool { && !name.contains('\\') } -fn json_mcp_names(body: &str) -> Vec { +fn json_mcp_entries(body: &str, keys: &[&str]) -> Vec<(String, String)> { let Ok(value) = serde_json::from_str::(body) else { return Vec::new(); }; - ["mcpServers", "servers"] - .iter() + keys.iter() .filter_map(|key| value.get(key)?.as_object()) - .flat_map(|object| object.keys().cloned()) + .flat_map(|object| { + object.iter().map(|(name, definition)| { + let has_url = ["url", "httpUrl", "http_url"].iter().any(|key| { + definition + .get(key) + .is_some_and(serde_json::Value::is_string) + }); + let has_command = definition + .get("command") + .is_some_and(serde_json::Value::is_string); + let transport = match (has_url, has_command) { + (true, false) => "remote", + (false, true) => "stdio", + _ => "unknown", + }; + (name.clone(), transport.to_string()) + }) + }) .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 +fn codex_mcp_entries(body: &str) -> Vec<(String, String)> { + let mut entries = Vec::new(); + let mut current: Option = None; + let mut has_url = false; + let mut has_command = false; + let mut finish = |current: &mut Option, has_url: &mut bool, has_command: &mut bool| { + if let Some(name) = current.take() { + let transport = match (*has_url, *has_command) { + (true, false) => "remote", + (false, true) => "stdio", + _ => "unknown", }; - (!name.is_empty() - && !name.chars().any(|ch| "[]".contains(ch)) - && (quoted || !name.contains('.'))) - .then(|| name.to_string()) - }) - .collect() + entries.push((name, transport.to_string())); + } + *has_url = false; + *has_command = false; + }; + for line in body.lines() { + let text = line.trim(); + if text.starts_with('[') && text.ends_with(']') { + finish(&mut current, &mut has_url, &mut has_command); + let section = text + .strip_prefix("[mcp_servers.") + .and_then(|value| value.strip_suffix(']')); + if let Some(section) = section { + let quoted = + section.starts_with('"') && section.ends_with('"') && section.len() >= 2; + let name = if quoted { + §ion[1..section.len() - 1] + } else { + section + }; + if !name.is_empty() + && !name.chars().any(|ch| "[]".contains(ch)) + && (quoted || !name.contains('.')) + { + current = Some(name.to_string()); + } + } + } else if current.is_some() { + if let Some((key, _)) = text.split_once('=') { + match key.trim() { + "url" | "http_url" => has_url = true, + "command" => has_command = true, + _ => {} + } + } + } + } + finish(&mut current, &mut has_url, &mut has_command); + entries } fn collect_os_info() -> DeviceOSInfo { @@ -2398,32 +2641,38 @@ mod tests { DeviceMCPServer { client: "amp".into(), name: "db".into(), - source: ".config/amp/settings.json".into() + source: ".config/amp/settings.json".into(), + transport: "stdio".into() }, DeviceMCPServer { client: "codex".into(), name: "github".into(), - source: ".codex/config.toml".into() + source: ".codex/config.toml".into(), + transport: "remote".into() }, DeviceMCPServer { client: "cursor".into(), name: "docs".into(), - source: ".cursor/mcp.json".into() + source: ".cursor/mcp.json".into(), + transport: "stdio".into() }, DeviceMCPServer { client: "gemini".into(), name: "search".into(), - source: ".gemini/extensions/*/gemini-extension.json".into() + source: ".gemini/extensions/*/gemini-extension.json".into(), + transport: "unknown".into() }, DeviceMCPServer { client: "maestro".into(), name: "managed".into(), - source: ".maestro/config.toml".into() + source: ".maestro/config.toml".into(), + transport: "remote".into() }, DeviceMCPServer { client: "maestro".into(), name: "pluginsearch".into(), - source: ".maestro/plugins/*/mcp.json".into() + source: ".maestro/plugins/*/mcp.json".into(), + transport: "stdio".into() }, ] ); @@ -2479,6 +2728,67 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn project_discovery_is_bounded_and_hides_paths_and_values() { + let root = + std::env::temp_dir().join(format!("merlin-project-discovery-{}", std::process::id())); + let project = root.join("customer-private"); + fs::create_dir_all(project.join(".cursor")).unwrap(); + fs::create_dir_all(project.join(".claude/skills/review")).unwrap(); + fs::create_dir_all(project.join(".maestro/plugins/audit")).unwrap(); + fs::write( + project.join(".maestro/plugins/audit/mcp.json"), + r#"{"mcpServers":{"pluginsearch":{"url":"https://private.example/mcp"}}}"#, + ) + .unwrap(); + fs::write(project.join(".claude/settings.json"), r#"{"enabledPlugins":{"audit@marketplace":true,"off@marketplace":false},"secret":"private-secret"}"#).unwrap(); + fs::write( + project.join(".cursor/mcp.json"), + r#"{"mcpServers":{"docs":{"command":"private-secret"}}}"#, + ) + .unwrap(); + fs::write( + project.join(".claude/skills/review/SKILL.md"), + "private-secret", + ) + .unwrap(); + let (servers, assets) = collect_project_agent_discovery(std::slice::from_ref(&root)); + assert!(servers.iter().any(|item| item.name == "docs" + && item.source == "project/.cursor/mcp.json" + && item.transport == "stdio")); + assert!(servers.iter().any(|item| item.name == "pluginsearch" + && item.source == "project/.maestro/plugins/*/mcp.json" + && item.transport == "remote")); + assert!( + assets + .iter() + .any(|item| item.name == "review" && item.source == "project/.claude/skills") + ); + assert!(assets.iter().any(|item| item.name == "audit@marketplace" + && item.kind == "plugin" + && item.source == "project/.claude/settings.json")); + assert!(assets.iter().any(|item| item.name == "audit" + && item.kind == "plugin" + && item.source == "project/.maestro/plugins")); + assert!(!assets.iter().any(|item| item.name == "off@marketplace")); + let payload = serde_json::to_string(&(servers, assets)).unwrap(); + assert!(!payload.contains("customer-private")); + assert!(!payload.contains("private-secret")); + fs::remove_file(project.join(".cursor/mcp.json")).unwrap(); + std::os::unix::fs::symlink( + project.join(".claude/skills/review/SKILL.md"), + project.join(".cursor/mcp.json"), + ) + .unwrap(); + assert!( + !collect_project_agent_discovery(&[root.clone()]) + .0 + .iter() + .any(|item| item.name == "docs") + ); + 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. diff --git a/packaging/linux/merlin.env.example b/packaging/linux/merlin.env.example index b9d99ca..b1e9159 100644 --- a/packaging/linux/merlin.env.example +++ b/packaging/linux/merlin.env.example @@ -2,3 +2,6 @@ MERLIN_SYNC_URL=https://merlin-sync.example.com MERLIN_DEVICE_ID=dev_0123456789abcdef01234567 MERLIN_DEVICE_TOKEN=replace-with-64-hex-character-device-token MERLIN_POLICY_PUBLIC_KEYS=replace-with-comma-separated-ed25519-public-keys +# Optional JSON array of at most eight admin-chosen workspace roots. Escape it +# as a single value in this root-owned systemd EnvironmentFile. +# MERLIN_AGENT_WORKSPACE_ROOTS='["/srv/projects"]' From f170f925921baefa1eb8bb6e147a5adba171006c Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Thu, 24 Sep 2026 04:24:04 +0000 Subject: [PATCH 16/17] chore: project endpoint from Mono b6466c724d17 --- .repository-projection.json | 6 +- macos/Sources/MerlinMacOS/CLI.swift | 3 +- macos/Sources/MerlinMacOS/MCPHook.swift | 177 ++++++++++++++++++ .../Tests/MerlinMacOSTests/MCPHookTests.swift | 82 ++++++++ 4 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 macos/Sources/MerlinMacOS/MCPHook.swift create mode 100644 macos/Tests/MerlinMacOSTests/MCPHookTests.swift diff --git a/.repository-projection.json b/.repository-projection.json index 31f2a4c..ac02063 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "endpoint", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "56d36fc674bad5b86ba169de0e49905f44c8dcc2", + "sourceSha": "b6466c724d17bf35841cefb93c8babd3e3153a70", "destinationRepository": "dx-corp/endpoint", - "priorProjectedBase": "c0338dbec4311ab2e8f0f455986aa5ec54257f50", + "priorProjectedBase": "1e0bf9b19a9df23c7c563ea581576dea99d4ec08", "definitionDigest": "8068fb5528eff3a9256419584bb34a9722ea322c288ee4cfda088ff93fb60ec6", "toolDigest": "898e8657d9153a2a51d7c283bf83bb3350b5d1e6", - "contentDigest": "379aa13d48e400a1ca4bca32d082610dc9d5c6c89694a5670bacd39d12232dde", + "contentDigest": "f841d481955629cc52b5488664f2354cdc6fe144e024b5b867e2a0748e71d38f", "publicationEligible": true } diff --git a/macos/Sources/MerlinMacOS/CLI.swift b/macos/Sources/MerlinMacOS/CLI.swift index cc626c5..7a57525 100644 --- a/macos/Sources/MerlinMacOS/CLI.swift +++ b/macos/Sources/MerlinMacOS/CLI.swift @@ -16,7 +16,8 @@ struct Merlin: ParsableCommand { static let configuration = CommandConfiguration( commandName: "merlin-macos", abstract: "Deixic Endpoint: endpoint telemetry and policy enforcement for macOS", - subcommands: [RunCommand.self, CheckCommand.self, PostureCommand.self, GenHashCommand.self] + subcommands: [RunCommand.self, CheckCommand.self, PostureCommand.self, GenHashCommand.self, + MCPHookCommand.self] ) } diff --git a/macos/Sources/MerlinMacOS/MCPHook.swift b/macos/Sources/MerlinMacOS/MCPHook.swift new file mode 100644 index 0000000..9edaaf6 --- /dev/null +++ b/macos/Sources/MerlinMacOS/MCPHook.swift @@ -0,0 +1,177 @@ +// Managed MCP tool-call hook for Cursor, Claude Code, and Codex. +// The hook reads only server and tool names. Arguments never leave the client. +import ArgumentParser +import Darwin +import Foundation + +private let mcpHookPolicyPath = "/Library/Application Support/Merlin/mcp-hook-policy.json" +private let mcpHookMaximumBytes = 64 * 1024 + +private enum MCPHookClient: String, ExpressibleByArgument { + case cursor, claude, codex +} + +struct MCPHookCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mcp-hook", + abstract: "Apply an administrator-installed MCP server allowlist to a client tool call." + ) + + @Option(help: "Client emitting the hook: cursor, claude, or codex.") + private var client: MCPHookClient + + func run() throws { + let output: [String: Any] + do { + let input = try readMCPHookInput() + let policy = try readMCPHookPolicy(path: mcpHookPolicyPath) + let verdict = try policy.verdict(client: client.rawValue, input: input) + output = mcpHookOutput(client: client.rawValue, verdict: verdict) + } catch { + // Endpoint enforcement points allow on internal errors. Client + // hooks must never turn a missing or malformed policy into a deny. + fputs("deixic endpoint mcp hook: \(error)\n", stderr) + output = mcpHookOutput(client: client.rawValue, verdict: .allow) + } + let data = try JSONSerialization.data(withJSONObject: output, options: [.sortedKeys]) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data([0x0a])) + } +} + +private enum MCPHookError: Error { + case invalidInput, invalidPolicy, unsafePolicyFile, oversizedInput +} + +enum MCPHookVerdict: Equatable { + case allow + case deny(String) +} + +struct MCPHookPolicy { + let enforced: Bool + let approvedServers: [String: Set] + let approvedName: String? + let approvedURL: String? + + static func parse(_ data: Data) throws -> MCPHookPolicy { + guard data.count <= mcpHookMaximumBytes, + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], + Set(root.keys).isSubset(of: ["schema_version", "mode", "approved_servers", "approved_alternative"]), + root["schema_version"] as? Int == 1, + let mode = root["mode"] as? String, ["audit", "enforce"].contains(mode), + let entries = root["approved_servers"] as? [[String: Any]], entries.count <= 128 else { + throw MCPHookError.invalidPolicy + } + var approved: [String: Set] = [:] + for entry in entries { + guard Set(entry.keys) == Set(["client", "server"]), + let client = entry["client"] as? String, + ["cursor", "claude", "codex"].contains(client), + let server = entry["server"] as? String, + validMCPHookName(server) else { + throw MCPHookError.invalidPolicy + } + approved[client, default: []].insert(server) + } + var name: String? + var url: String? + if let alternative = root["approved_alternative"] { + guard let value = alternative as? [String: String], + Set(value.keys) == Set(["name", "url"]), + let candidateName = value["name"], + !candidateName.isEmpty, candidateName.utf8.count <= 80, + candidateName == candidateName.trimmingCharacters(in: .whitespacesAndNewlines), + !candidateName.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + let candidateURL = value["url"], candidateURL.utf8.count <= 2048, + let parsed = URLComponents(string: candidateURL), parsed.scheme == "https", + parsed.host != nil, parsed.user == nil, parsed.password == nil, + parsed.query == nil, parsed.fragment == nil else { + throw MCPHookError.invalidPolicy + } + name = candidateName + url = candidateURL + } + return MCPHookPolicy(enforced: mode == "enforce", approvedServers: approved, + approvedName: name, approvedURL: url) + } + + func verdict(client: String, input: [String: Any]) throws -> MCPHookVerdict { + guard let server = mcpHookServer(client: client, input: input) else { + // Unrecognized hook events and malformed names do not become an + // implicit block. Managed client matchers limit calls to MCP. + return .allow + } + guard enforced, !approvedServers[client, default: []].contains(server) else { + return .allow + } + var message = "Deixic Endpoint blocked an unapproved MCP server (\(server))." + if let approvedName, let approvedURL { + message += " Use the administrator-approved tool \(approvedName): \(approvedURL)" + } else { + message += " Contact your administrator for an approved tool." + } + return .deny(message) + } +} + +private func validMCPHookName(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 128 && value.unicodeScalars.allSatisfy { + CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-").contains($0) + } +} + +private func mcpHookServer(client: String, input: [String: Any]) -> String? { + if client == "cursor" { + guard let server = input["mcp_server_name"] as? String, validMCPHookName(server), + let tool = input["tool_name"] as? String, validMCPHookName(tool) else { return nil } + return server + } + guard let tool = input["tool_name"] as? String, tool.hasPrefix("mcp__") else { return nil } + let parts = tool.dropFirst(5).components(separatedBy: "__") + guard parts.count == 2, validMCPHookName(parts[0]), validMCPHookName(parts[1]) else { return nil } + return parts[0] +} + +func mcpHookOutput(client: String, verdict: MCPHookVerdict) -> [String: Any] { + switch (client, verdict) { + case ("cursor", .allow): + return ["permission": "allow"] + case ("cursor", .deny(let message)): + return ["permission": "deny", "user_message": message, "agent_message": message] + case (_, .allow): + return [:] + case (_, .deny(let message)): + return ["hookSpecificOutput": [ + "hookEventName": "PreToolUse", "permissionDecision": "deny", + "permissionDecisionReason": message, + ]] + } +} + +private func readMCPHookInput() throws -> [String: Any] { + let data = FileHandle.standardInput.readData(ofLength: mcpHookMaximumBytes + 1) + guard data.count <= mcpHookMaximumBytes else { throw MCPHookError.oversizedInput } + guard let input = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw MCPHookError.invalidInput + } + return input +} + +func readMCPHookPolicy(path: String) throws -> MCPHookPolicy { + let fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard fd >= 0 else { throw MCPHookError.unsafePolicyFile } + defer { close(fd) } + var metadata = stat() + guard fstat(fd, &metadata) == 0, + metadata.st_mode & S_IFMT == S_IFREG, + metadata.st_uid == 0, + metadata.st_mode & 0o022 == 0, + metadata.st_size >= 0, + metadata.st_size <= mcpHookMaximumBytes else { + throw MCPHookError.unsafePolicyFile + } + let data = FileHandle(fileDescriptor: fd, closeOnDealloc: false).readData(ofLength: mcpHookMaximumBytes + 1) + guard data.count <= mcpHookMaximumBytes else { throw MCPHookError.unsafePolicyFile } + return try MCPHookPolicy.parse(data) +} diff --git a/macos/Tests/MerlinMacOSTests/MCPHookTests.swift b/macos/Tests/MerlinMacOSTests/MCPHookTests.swift new file mode 100644 index 0000000..88eb00b --- /dev/null +++ b/macos/Tests/MerlinMacOSTests/MCPHookTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import MerlinMacOS + +@Suite("managed MCP hooks") +struct MCPHookTests { + private let policy = """ + {"schema_version":1,"mode":"enforce", + "approved_servers":[{"client":"cursor","server":"deixic-gateway"}, + {"client":"claude","server":"deixic-gateway"}, + {"client":"codex","server":"deixic-gateway"}], + "approved_alternative":{"name":"Approved agent","url":"https://tools.example.com/agent"}} + """ + + @Test("blocks unapproved Cursor, Claude, and Codex MCP calls") + func blockedClients() throws { + let parsed = try MCPHookPolicy.parse(Data(policy.utf8)) + let inputs: [(String, [String: Any])] = [ + ("cursor", ["mcp_server_name": "shadow", "tool_name": "search", "tool_input": ["secret": "do not emit"]]), + ("claude", ["tool_name": "mcp__shadow__search", "tool_input": ["secret": "do not emit"]]), + ("codex", ["tool_name": "mcp__shadow__search", "tool_input": ["secret": "do not emit"]]), + ] + for (client, input) in inputs { + let verdict = try parsed.verdict(client: client, input: input) + guard case .deny(let reason) = verdict else { + Issue.record("\(client) did not deny an unapproved server") + continue + } + #expect(reason.contains("Approved agent")) + #expect(reason.contains("https://tools.example.com/agent")) + let output = mcpHookOutput(client: client, verdict: verdict) + let encoded = String(data: try JSONSerialization.data(withJSONObject: output), encoding: .utf8) ?? "" + #expect(!encoded.contains("do not emit")) + if client == "cursor" { + #expect(output["permission"] as? String == "deny") + } else { + let specific = output["hookSpecificOutput"] as? [String: String] + #expect(specific?["permissionDecision"] == "deny") + } + } + } + + @Test("allows approved servers and unrelated tools") + func approvedAndUnrelated() throws { + let parsed = try MCPHookPolicy.parse(Data(policy.utf8)) + #expect(try parsed.verdict(client: "cursor", input: ["mcp_server_name": "deixic-gateway", "tool_name": "search"]) == .allow) + #expect(try parsed.verdict(client: "claude", input: ["tool_name": "mcp__deixic-gateway__search"]) == .allow) + #expect(try parsed.verdict(client: "codex", input: ["tool_name": "Bash"]) == .allow) + #expect(try parsed.verdict(client: "codex", input: ["tool_name": "mcp__plugin_my-plugin_db__search"]) != .allow) + } + + @Test("rejects unknown policy fields, unsafe links, and oversized policy") + func invalidPolicy() throws { + for invalid in [ + policy.replacingOccurrences(of: "\"mode\":\"enforce\"", with: "\"mode\":\"enforce\",\"surprise\":true"), + policy.replacingOccurrences(of: "https://tools.example.com/agent", with: "http://tools.example.com/agent"), + policy.replacingOccurrences(of: "\"server\":\"deixic-gateway\"", with: "\"server\":\"*\""), + String(repeating: "x", count: 65_537), + ] { + #expect(throws: Error.self) { try MCPHookPolicy.parse(Data(invalid.utf8)) } + } + } + + @Test("audit policy never denies") + func audit() throws { + let parsed = try MCPHookPolicy.parse(Data(policy.replacingOccurrences(of: "enforce", with: "audit").utf8)) + #expect(try parsed.verdict(client: "cursor", input: ["mcp_server_name": "shadow", "tool_name": "search"]) == .allow) + } + + @Test("refuses user-owned and symlinked policy files") + func unsafeFiles() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let plain = directory.appendingPathComponent("policy.json") + try Data(policy.utf8).write(to: plain) + #expect(throws: Error.self) { try readMCPHookPolicy(path: plain.path) } + let link = directory.appendingPathComponent("link.json") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: plain) + #expect(throws: Error.self) { try readMCPHookPolicy(path: link.path) } + } +} From b61a45630198d5cc05056dbbd66eb17f8e81d738 Mon Sep 17 00:00:00 2001 From: dx-corp projector Date: Thu, 24 Sep 2026 06:48:04 +0000 Subject: [PATCH 17/17] chore: project endpoint from Mono 15bae766017b --- .repository-projection.json | 6 +- .../MerlinEndpointApp/EndpointAppModel.swift | 10 +- .../EndpointDetailView.swift | 1 - .../EnforcementNotifications.swift | 108 ++++++++++ .../MerlinEndpointApp/MerlinEndpointApp.swift | 47 ++++- macos/Sources/MerlinMacOS/Engine.swift | 26 ++- macos/Sources/MerlinMacOS/Inventory.swift | 9 +- .../Sources/MerlinMacOS/MCPHookCoverage.swift | 194 ++++++++++++++++++ .../EndpointAppModelTests.swift | 79 +++++++ .../MCPHookCoverageTests.swift | 57 +++++ macos/Tests/MerlinMacOSTests/RulesTests.swift | 70 +++++++ macos/Tests/MerlinMacOSTests/SyncTests.swift | 15 +- merlin/src/alert.rs | 92 +++++++++ merlin/src/fanotify_mon.rs | 15 +- merlin/src/sync.rs | 36 +++- merlin/src/telemetry.rs | 10 +- packaging/linux/build-package.sh | 1 + packaging/linux/test-packaging.sh | 5 +- packaging/linux/test-verify-install.sh | 105 ++++++++++ packaging/linux/verify-install.sh | 62 ++++++ 20 files changed, 919 insertions(+), 29 deletions(-) create mode 100644 macos/Sources/MerlinEndpointApp/EnforcementNotifications.swift create mode 100644 macos/Sources/MerlinMacOS/MCPHookCoverage.swift create mode 100644 macos/Tests/MerlinMacOSTests/MCPHookCoverageTests.swift create mode 100755 packaging/linux/test-verify-install.sh create mode 100755 packaging/linux/verify-install.sh diff --git a/.repository-projection.json b/.repository-projection.json index ac02063..33dfa60 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -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 } diff --git a/macos/Sources/MerlinEndpointApp/EndpointAppModel.swift b/macos/Sources/MerlinEndpointApp/EndpointAppModel.swift index 6a0da0c..cbc2cb2 100644 --- a/macos/Sources/MerlinEndpointApp/EndpointAppModel.swift +++ b/macos/Sources/MerlinEndpointApp/EndpointAppModel.swift @@ -10,11 +10,13 @@ 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 { @@ -22,8 +24,12 @@ final class EndpointAppModel: ObservableObject { 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 diff --git a/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift b/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift index 5ca69c0..c8f853a 100644 --- a/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift +++ b/macos/Sources/MerlinEndpointApp/EndpointDetailView.swift @@ -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 { diff --git a/macos/Sources/MerlinEndpointApp/EnforcementNotifications.swift b/macos/Sources/MerlinEndpointApp/EnforcementNotifications.swift new file mode 100644 index 0000000..1afc5d6 --- /dev/null +++ b/macos/Sources/MerlinEndpointApp/EnforcementNotifications.swift @@ -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 + } +} diff --git a/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift b/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift index 8d6d41d..fa4d3db 100644 --- a/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift +++ b/macos/Sources/MerlinEndpointApp/MerlinEndpointApp.swift @@ -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? + + 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 @@ -76,6 +114,5 @@ struct EndpointPopover: View { .padding(20) .frame(width: 360) .task { await session.monitor() } - .task { await model.monitor() } } } diff --git a/macos/Sources/MerlinMacOS/Engine.swift b/macos/Sources/MerlinMacOS/Engine.swift index 27857cc..fb7749b 100644 --- a/macos/Sources/MerlinMacOS/Engine.swift +++ b/macos/Sources/MerlinMacOS/Engine.swift @@ -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). @@ -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) } @@ -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)) } } } diff --git a/macos/Sources/MerlinMacOS/Inventory.swift b/macos/Sources/MerlinMacOS/Inventory.swift index ef3ea01..68ca74a 100644 --- a/macos/Sources/MerlinMacOS/Inventory.swift +++ b/macos/Sources/MerlinMacOS/Inventory.swift @@ -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 @@ -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" @@ -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), @@ -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() var assets = Set() @@ -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)") @@ -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 } } diff --git a/macos/Sources/MerlinMacOS/MCPHookCoverage.swift b/macos/Sources/MerlinMacOS/MCPHookCoverage.swift new file mode 100644 index 0000000..d82ab3e --- /dev/null +++ b/macos/Sources/MerlinMacOS/MCPHookCoverage.swift @@ -0,0 +1,194 @@ +import CryptoKit +import Darwin +import Foundation + +// A local file observation. It cannot establish that a client loaded the hook, +// that an MDM delivered it, or that any tool call passed through it. +struct DeviceMCPHookCoverage: Encodable, Sendable { + let policy: String + let policySHA256: String? + let clients: [DeviceMCPHookClientCoverage] + + enum CodingKeys: String, CodingKey { + case policy, clients + case policySHA256 = "policy_sha256" + } +} + +struct DeviceMCPHookClientCoverage: Encodable, Sendable { + let client: String + let registration: String +} + +private let hookBinary = "/Library/Application Support/Merlin/bin/merlin-macos" +private let hookReadLimit = 64 * 1024 +private let hookPolicyFile = "/Library/Application Support/Merlin/mcp-hook-policy.json" + +enum HookFileObservation { + case absent + case unreadable + case data(Data) +} + +// The daemon is privileged. Never follow a symlink or accept writable or +// non-root-owned client settings as evidence of an administrator registration. +func observeManagedHookFile(_ path: String) -> HookFileObservation { + guard path.hasPrefix("/"), !path.hasSuffix("/") else { return .unreadable } + let segments = path.split(separator: "/") + guard segments.count >= 2, !segments.contains(where: { $0 == "." || $0 == ".." }) else { return .unreadable } + var directory = open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard directory >= 0 else { return .unreadable } + defer { close(directory) } + var root = stat() + guard fstat(directory, &root) == 0, + root.st_mode & S_IFMT == S_IFDIR, + root.st_uid == 0, + root.st_mode & 0o022 == 0 else { return .unreadable } + for segment in segments.dropLast() { + let next = openat(directory, String(segment), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + if next < 0 { return errno == ENOENT ? .absent : .unreadable } + var parent = stat() + guard fstat(next, &parent) == 0, + parent.st_mode & S_IFMT == S_IFDIR, + parent.st_uid == 0, + parent.st_mode & 0o022 == 0 else { + close(next) + return .unreadable + } + close(directory) + directory = next + } + let descriptor = openat(directory, String(segments.last!), O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + if descriptor < 0 { return errno == ENOENT ? .absent : .unreadable } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == 0, + info.st_mode & 0o022 == 0, + info.st_size >= 0, + info.st_size <= hookReadLimit else { return .unreadable } + let data = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false).readData(ofLength: hookReadLimit + 1) + return data.count <= hookReadLimit ? .data(data) : .unreadable +} + +func collectMCPHookCoverage() -> DeviceMCPHookCoverage { + let policy: String + let digest: String? + switch observeManagedHookFile(hookPolicyFile) { + case .absent: + policy = "absent" + digest = nil + case .unreadable: + policy = "invalid" + digest = nil + case .data(let data): + if let parsed = try? MCPHookPolicy.parse(data) { + policy = parsed.enforced ? "enforce" : "audit" + digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } else { + policy = "invalid" + digest = nil + } + } + + let definitions: [(String, String, (Data) -> Bool)] = [ + ("cursor", "/Library/Application Support/Cursor/hooks.json", { cursorHookRegistered($0) }), + ("claude", "/Library/Application Support/ClaudeCode/managed-settings.json", { claudeHookRegistered($0) }), + // macOS /etc is a symlink to /private/etc; use its canonical path so + // every ancestor can be checked without following a symlink. + ("codex", "/private/etc/codex/requirements.toml", { codexHookRegistered($0) }), + ] + let clients = definitions.map { client, path, matches -> DeviceMCPHookClientCoverage in + let registration: String + switch observeManagedHookFile(path) { + case .absent: registration = "absent" + case .unreadable: registration = "unreadable" + case .data(let data): registration = matches(data) ? "observed" : "not_observed" + } + return DeviceMCPHookClientCoverage(client: client, registration: registration) + } + return DeviceMCPHookCoverage(policy: policy, policySHA256: digest, clients: clients) +} + +private func hookCommandMatches(_ value: Any?, client: String) -> Bool { + guard let command = value as? String else { return false } + // The two supported spellings in the managed deployment guide. Do not + // treat a substring in an arbitrary command as a managed hook. + return command == "\(hookBinary) mcp-hook --client \(client)" || + command == "'\(hookBinary)' mcp-hook --client \(client)" +} + +private func hookJSON(_ data: Data) -> [String: Any]? { + (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] +} + +func cursorHookRegistered(_ data: Data) -> Bool { + guard let root = hookJSON(data), root["version"] as? Int == 1, + let hooks = root["hooks"] as? [String: Any], + let entries = hooks["beforeMCPExecution"] as? [[String: Any]], + entries.count <= 128 else { return false } + return entries.contains { hookCommandMatches($0["command"], client: "cursor") } +} + +func claudeHookRegistered(_ data: Data) -> Bool { + guard let hooks = hookJSON(data)?["hooks"] as? [String: Any], + let entries = hooks["PreToolUse"] as? [[String: Any]], + entries.count <= 128 else { return false } + return entries.contains { entry in + guard entry["matcher"] as? String == "mcp__.*", + let commands = entry["hooks"] as? [[String: Any]], commands.count <= 128 else { return false } + return commands.contains { $0["type"] as? String == "command" && hookCommandMatches($0["command"], client: "claude") } + } +} + +// Conservative TOML subset for the exact managed sample. A matching line in +// a comment or another table is insufficient. Unsupported syntax is reported +// as not observed instead of claiming coverage. +func codexHookRegistered(_ data: Data) -> Bool { + guard let source = String(data: data, encoding: .utf8) else { return false } + var table = "" + var featureEnabled = false + var managedDirectory = false + var matcher = false + var command = false + var commandType = false + var foundHandler = false + func matchesHandler() -> Bool { matcher && command && commandType } + for rawLine in source.split(separator: "\n", omittingEmptySubsequences: false) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + if line.isEmpty || line.hasPrefix("#") { continue } + if line.hasPrefix("[") { + foundHandler = foundHandler || matchesHandler() + if line == "[features]" || line == "[hooks]" || line == "[[hooks.PreToolUse]]" || line == "[[hooks.PreToolUse.hooks]]" { + table = line + } else { + table = "" + } + if line == "[[hooks.PreToolUse]]" { + matcher = false + command = false + commandType = false + } else if line == "[[hooks.PreToolUse.hooks]]" { + command = false + commandType = false + } else { + matcher = false + command = false + commandType = false + } + continue + } + let parts = line.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard parts.count == 2 else { return false } + switch (table, parts[0], parts[1]) { + case ("[features]", "hooks", _): featureEnabled = parts[1] == "true" + case ("[hooks]", "managed_dir", _): managedDirectory = parts[1] == "\"/Library/Application Support/Merlin/bin\"" + case ("[[hooks.PreToolUse]]", "matcher", _): matcher = parts[1] == "\"^mcp__.*\"" + case ("[[hooks.PreToolUse.hooks]]", "type", _): commandType = parts[1] == "\"command\"" + case ("[[hooks.PreToolUse.hooks]]", "command", _): command = parts[1] == "\"'\(hookBinary)' mcp-hook --client codex\"" + default: break + } + } + return featureEnabled && managedDirectory && (foundHandler || matchesHandler()) +} diff --git a/macos/Tests/MerlinEndpointAppTests/EndpointAppModelTests.swift b/macos/Tests/MerlinEndpointAppTests/EndpointAppModelTests.swift index e0d89c4..78bdd95 100644 --- a/macos/Tests/MerlinEndpointAppTests/EndpointAppModelTests.swift +++ b/macos/Tests/MerlinEndpointAppTests/EndpointAppModelTests.swift @@ -1,10 +1,84 @@ import Foundation import MerlinClientCore import Testing +import UserNotifications @testable import MerlinEndpointApp @MainActor struct EndpointAppModelTests { + @Test func applicationDelegateOwnsExactlyOneStatusMonitor() async { + let reader = SuspendedReader() + let model = EndpointAppModel(readStatus: { await reader.read() }) + var notificationInstalls = 0 + let delegate = EndpointAppDelegate(model: model, installNotifications: { notificationInstalls += 1 }) + let launch = Notification(name: Notification.Name("test-launch")) + delegate.applicationDidFinishLaunching(launch) + await reader.waitUntilStarted() + delegate.applicationDidFinishLaunching(launch) + #expect(notificationInstalls == 1) + #expect(await reader.callCount == 1) + await reader.finish() + delegate.applicationWillTerminate(Notification(name: Notification.Name("test-terminate"))) + } + + @Test func enforcementNotificationIsRecentDeduplicatedAndRateLimited() async { + let suite = "endpoint-notification-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let sent = NoticeCounter() + let start = Date(timeIntervalSince1970: 1_000) + let first = LocalEnforcementNotice(action: .blocked, occurredAt: start, + approvedName: "Approved Tool", approvedURL: URL(string: "https://example.com/approved")) + let notifier = EnforcementNotifications(defaults: defaults, now: { start.addingTimeInterval(10) }, + deliver: { _ in await sent.record() }) + await notifier.presentIfNeeded(first) + await notifier.presentIfNeeded(first) + #expect(await sent.count == 1) + + // A new instance still knows about the delivered event after app restart. + let restarted = EnforcementNotifications(defaults: defaults, now: { start.addingTimeInterval(11) }, + deliver: { _ in await sent.record() }) + await restarted.presentIfNeeded(first) + #expect(await sent.count == 1) + let second = LocalEnforcementNotice(action: .stopped, occurredAt: start.addingTimeInterval(20), + approvedName: nil, approvedURL: nil) + await restarted.presentIfNeeded(second) + #expect(await sent.count == 1) + let later = EnforcementNotifications(defaults: defaults, now: { start.addingTimeInterval(72) }, + deliver: { _ in await sent.record() }) + await later.presentIfNeeded(second) + #expect(await sent.count == 2) + } + + @Test func staleNoticeIsNeverPresentedAsARecentBlock() async { + let suite = "endpoint-notification-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let sent = NoticeCounter() + let now = Date(timeIntervalSince1970: 1_000) + let notifier = EnforcementNotifications(defaults: defaults, now: { now }, + deliver: { _ in await sent.record() }) + await notifier.presentIfNeeded(LocalEnforcementNotice(action: .blocked, + occurredAt: now.addingTimeInterval(-121), approvedName: nil, approvedURL: nil)) + #expect(await sent.count == 0) + } + + @Test func notificationOnlyCarriesApprovedAlternative() { + let notice = LocalEnforcementNotice(action: .blocked, occurredAt: Date(), + approvedName: "Approved Tool", approvedURL: URL(string: "https://example.com/approved")) + let content = EnforcementNotifications.content(for: notice) + #expect(content.title == "App blocked by policy") + #expect(content.body.contains("Approved Tool")) + #expect(content.userInfo[EnforcementNotificationRouter.approvedURLKey] as? String == "https://example.com/approved") + #expect(EnforcementNotificationRouter.safeApprovedURL("https://example.com/approved") != nil) + #expect(EnforcementNotificationRouter.safeApprovedURL("https://user:password@example.com/") == nil) + #expect(EnforcementNotificationRouter.safeApprovedURL("file:///tmp/bad") == nil) + let unconfigured = EnforcementNotifications.content(for: LocalEnforcementNotice( + action: .stopped, occurredAt: Date(), approvedName: nil, approvedURL: nil)) + #expect(unconfigured.body == "Contact your administrator for an approved alternative.") + #expect(unconfigured.userInfo.isEmpty) + } + @Test func sessionVerificationExpiresIndependentlyOfTokenExpiry() { let verifiedAt = Date(timeIntervalSince1970: 1_000) #expect(EndpointSessionFreshness.isCurrent(lastVerifiedAt: verifiedAt, now: verifiedAt)) @@ -46,6 +120,11 @@ struct EndpointAppModelTests { } } +private actor NoticeCounter { + private(set) var count = 0 + func record() { count += 1 } +} + private func observedStatus() -> LocalDeviceStatus { LocalDeviceStatus(observedAt: Date(), deviceID: nil, collectorRunning: true, enrollment: .unconfigured, posture: .unknown, checks: [], lastServerContact: nil) diff --git a/macos/Tests/MerlinMacOSTests/MCPHookCoverageTests.swift b/macos/Tests/MerlinMacOSTests/MCPHookCoverageTests.swift new file mode 100644 index 0000000..47fafca --- /dev/null +++ b/macos/Tests/MerlinMacOSTests/MCPHookCoverageTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import MerlinMacOS + +@Suite("MCP hook coverage observations") +struct MCPHookCoverageTests { + @Test("only exact managed Cursor and Claude hook entries count") + func jsonRegistrations() { + let cursor = Data(#"{"version":1,"hooks":{"beforeMCPExecution":[{"command":"/Library/Application Support/Merlin/bin/merlin-macos mcp-hook --client cursor"}]}}"#.utf8) + #expect(cursorHookRegistered(cursor)) + #expect(!cursorHookRegistered(Data(#"{"version":1,"hooks":{"afterMCPExecution":[{"command":"/Library/Application Support/Merlin/bin/merlin-macos mcp-hook --client cursor"}]}}"#.utf8))) + #expect(!cursorHookRegistered(Data(#"{"version":1,"hooks":{"beforeMCPExecution":[{"command":"echo /Library/Application Support/Merlin/bin/merlin-macos mcp-hook --client cursor"}]}}"#.utf8))) + let claude = Data(#"{"hooks":{"PreToolUse":[{"matcher":"mcp__.*","hooks":[{"type":"command","command":"'/Library/Application Support/Merlin/bin/merlin-macos' mcp-hook --client claude"}]}]}}"#.utf8) + #expect(claudeHookRegistered(claude)) + #expect(!claudeHookRegistered(Data(#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"'/Library/Application Support/Merlin/bin/merlin-macos' mcp-hook --client claude"}]}]}}"#.utf8))) + #expect(!claudeHookRegistered(Data("{malformed".utf8))) + } + + @Test("Codex requires hook enablement and the managed MCP matcher") + func tomlRegistration() { + let valid = """ + [features] + hooks = true + [hooks] + managed_dir = "/Library/Application Support/Merlin/bin" + [[hooks.PreToolUse]] + matcher = "^mcp__.*" + [[hooks.PreToolUse.hooks]] + type = "command" + command = "'/Library/Application Support/Merlin/bin/merlin-macos' mcp-hook --client codex" + """ + #expect(codexHookRegistered(Data(valid.utf8))) + #expect(!codexHookRegistered(Data(valid.replacingOccurrences(of: "hooks = true", with: "hooks = false").utf8))) + #expect(!codexHookRegistered(Data(valid.replacingOccurrences(of: "matcher = \"^mcp__.*\"", with: "matcher = \"^Bash$\"").utf8))) + #expect(!codexHookRegistered(Data(("# " + valid.replacingOccurrences(of: "\n", with: "\n# ")).utf8))) + } + + @Test("managed file observation rejects writable and symlinked ancestors") + func unsafeAncestors() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("managed-settings.json") + try Data(#"{"hooks":{}}"#.utf8).write(to: file) + if case .unreadable = observeManagedHookFile(file.path) { + } else { + Issue.record("accepted a user-writable managed file ancestor") + } + let alias = directory.deletingLastPathComponent().appendingPathComponent(UUID().uuidString) + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: directory) + defer { try? FileManager.default.removeItem(at: alias) } + if case .unreadable = observeManagedHookFile(alias.appendingPathComponent("managed-settings.json").path) { + } else { + Issue.record("accepted a symlinked managed file ancestor") + } + } +} diff --git a/macos/Tests/MerlinMacOSTests/RulesTests.swift b/macos/Tests/MerlinMacOSTests/RulesTests.swift index 4d8f810..024d1a8 100644 --- a/macos/Tests/MerlinMacOSTests/RulesTests.swift +++ b/macos/Tests/MerlinMacOSTests/RulesTests.swift @@ -66,6 +66,76 @@ struct RulesTests { #expect(store.snapshot().enforcement?.action == .stopped) #expect(store.snapshot().enforcement?.approvedName == "Approved agent") } + + @Test("tied block rules select configured guidance regardless of policy order") + func tiedBlockGuidance() throws { + let plain = try rule("name: first\nmatch:\n path_basename: Cursor\naction: block\n") + let guided = try rule("name: second\nmatch:\n path_basename: Cursor\naction: block\napproved_alternative:\n name: Approved editor\n url: https://tools.example.com/editor\n") + let conflicting = try rule("name: third\nmatch:\n path_basename: Cursor\naction: block\napproved_alternative:\n name: Other editor\n url: https://tools.example.com/other\n") + for ordered in [[plain, conflicting, guided], [guided, plain, conflicting], [conflicting, guided, plain]] { + let store = LocalStatusStore() + var engine = Engine(rules: Rules(rules: ordered), + spool: try SpoolWriter(path: NSTemporaryDirectory() + "merlin-guidance-\(UUID().uuidString).jsonl"), + canBlock: true, selfPID: 42_425, ownTeamId: nil) + engine.onEnforcement = { action, alternative in + store.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } + let verdict = engine.authVerdict(pid: 42_424, uid: 501, path: "/tmp/Cursor", sha256: nil, cdhash: nil) + #expect(!verdict.allow) + #expect(verdict.matched == ordered.map(\.name)) + #expect(store.snapshot().enforcement?.approvedName == "Approved editor") + #expect(store.snapshot().enforcement?.approvedURL?.absoluteString == "https://tools.example.com/editor") + } + + // A lower-specificity alternative cannot override the enforcing rule. + let higher = try rule("name: hash-only\nmatch:\n sha256: abc\naction: block\n") + let store = LocalStatusStore() + var engine = Engine(rules: Rules(rules: [guided, higher]), + spool: try SpoolWriter(path: NSTemporaryDirectory() + "merlin-guidance-\(UUID().uuidString).jsonl"), + canBlock: true, selfPID: 42_425, ownTeamId: nil) + engine.onEnforcement = { action, alternative in + store.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } + let verdict = engine.authVerdict(pid: 42_424, uid: 501, path: "/tmp/Cursor", sha256: "abc", cdhash: nil) + #expect(verdict.matched == ["hash-only"]) + #expect(store.snapshot().enforcement?.action == .blocked) + #expect(store.snapshot().enforcement?.approvedName == nil) + } + + @Test("tied kill rules select guidance only after successful enforcement") + func tiedKillGuidance() throws { + let plain = try rule("name: first\nmatch:\n path_basename: claude\naction: kill\n") + let guided = try rule("name: second\nmatch:\n path_basename: claude\naction: kill\napproved_alternative:\n name: Approved agent\n url: https://tools.example.com/agent\n") + let conflicting = try rule("name: third\nmatch:\n path_basename: claude\naction: kill\napproved_alternative:\n name: Other agent\n url: https://tools.example.com/other\n") + let identity = ProcessIdentity(startSec: 1, startUsec: 1) + for ordered in [[plain, conflicting, guided], [guided, plain, conflicting], [conflicting, guided, plain]] { + let store = LocalStatusStore() + var engine = Engine(rules: Rules(rules: ordered), + spool: try SpoolWriter(path: NSTemporaryDirectory() + "merlin-guidance-\(UUID().uuidString).jsonl"), + canBlock: false, killImpl: { _ in 0 }, selfPID: 42_425, + ownTeamId: nil, processIdentity: { _ in identity }) + engine.onEnforcement = { action, alternative in + store.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } + engine.handleExec(pid: 42_424, ppid: nil, uid: 501, comm: "claude", exe: "/tmp/claude", + cmdline: nil, sha256: nil, cdhash: nil, identity: identity) + #expect(store.snapshot().enforcement?.action == .stopped) + #expect(store.snapshot().enforcement?.approvedName == "Approved agent") + } + + let store = LocalStatusStore() + var engine = Engine(rules: Rules(rules: [plain, guided]), + spool: try SpoolWriter(path: NSTemporaryDirectory() + "merlin-guidance-\(UUID().uuidString).jsonl"), + canBlock: false, killImpl: { _ in -1 }, selfPID: 42_425, + ownTeamId: nil, processIdentity: { _ in identity }) + engine.onEnforcement = { action, alternative in + store.recordEnforcement(action: action, approvedName: alternative?.name, approvedURL: alternative?.url) + } + engine.handleExec(pid: 42_424, ppid: nil, uid: 501, comm: "claude", exe: "/tmp/claude", + cmdline: nil, sha256: nil, cdhash: nil, identity: identity) + #expect(store.snapshot().enforcement == nil) + } + @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 2f215b5..e2c7917 100644 --- a/macos/Tests/MerlinMacOSTests/SyncTests.swift +++ b/macos/Tests/MerlinMacOSTests/SyncTests.swift @@ -155,8 +155,8 @@ struct SyncTests { try FileManager.default.createDirectory(atPath: home + "/.composer/skills/review", 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 "[mcp_servers.github]\nurl = 'https://secret.example'\n[mcp_servers.\"/Users/private/work\"]\ncommand = 'secret'\n".write(toFile: home + "/.codex/config.toml", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"docs":{"command":"secret"},"https://private.example/mcp":{"url":"secret"},"C:\\Users\\private":{"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) @@ -195,6 +195,10 @@ struct SyncTests { #expect(!discovered.assets.contains { $0.client == "pi" && $0.name == "review" }) let encoded = try JSONEncoder().encode(discovered.servers) #expect(!String(decoding: encoded, as: UTF8.self).contains("secret")) + let payload = String(decoding: encoded, as: UTF8.self) + #expect(!payload.contains("/Users/private/work")) + #expect(!payload.contains("https://private.example/mcp")) + #expect(!payload.contains("C:\\\\Users")) #expect(!String(decoding: try JSONEncoder().encode(discovered.assets), as: UTF8.self).contains("secret")) try FileManager.default.removeItem(atPath: home + "/.cursor/mcp.json") @@ -208,15 +212,22 @@ struct SyncTests { defer { try? FileManager.default.removeItem(atPath: root) } let project = root + "/customer-private" try FileManager.default.createDirectory(atPath: project + "/.cursor", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: project + "/.opencode", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: project + "/.agents", withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: project + "/.claude/skills/review", withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: project + "/.maestro/plugins/audit", withIntermediateDirectories: true) try #"{"mcpServers":{"pluginsearch":{"url":"https://private.example/mcp"}}}"#.write(toFile: project + "/.maestro/plugins/audit/mcp.json", atomically: true, encoding: .utf8) try #"{"enabledPlugins":{"audit@marketplace":true,"off@marketplace":false},"secret":"private-secret"}"#.write(toFile: project + "/.claude/settings.json", atomically: true, encoding: .utf8) try #"{"mcpServers":{"docs":{"command":"private-secret"}}}"#.write(toFile: project + "/.cursor/mcp.json", atomically: true, encoding: .utf8) + try #"{"mcp":{"code-search":{"url":"https://private.example/mcp"}},"secret":"private-secret"}"#.write(toFile: project + "/.opencode/opencode.json", atomically: true, encoding: .utf8) + try #"{"mcpServers":{"agent-search":{"command":"private-secret"}}}"#.write(toFile: project + "/.agents/mcp.json", atomically: true, encoding: .utf8) try "private-secret".write(toFile: project + "/.claude/skills/review/SKILL.md", atomically: true, encoding: .utf8) let discovered = collectMacProjectAgentDiscovery(roots: [root]) #expect(discovered.servers.contains { $0.name == "docs" && $0.source == "project/.cursor/mcp.json" && $0.transport == "stdio" }) #expect(discovered.servers.contains { $0.name == "pluginsearch" && $0.source == "project/.maestro/plugins/*/mcp.json" && $0.transport == "remote" }) + #expect(discovered.servers.contains { $0.client == "opencode" && $0.name == "code-search" && $0.source == "project/.opencode/opencode.json" && $0.transport == "remote" }) + #expect(discovered.servers.contains { $0.client == "agents" && $0.name == "agent-search" && $0.source == "project/.agents/mcp.json" && $0.transport == "stdio" }) + #expect(discovered.assets.contains { $0.client == "opencode" && $0.kind == "config" && $0.source == "project/.opencode/opencode.json" }) #expect(discovered.assets.contains { $0.name == "review" && $0.source == "project/.claude/skills" }) #expect(discovered.assets.contains { $0.name == "audit@marketplace" && $0.kind == "plugin" && $0.source == "project/.claude/settings.json" }) #expect(discovered.assets.contains { $0.name == "audit" && $0.kind == "plugin" && $0.source == "project/.maestro/plugins" }) diff --git a/merlin/src/alert.rs b/merlin/src/alert.rs index d56ed71..1ede20e 100644 --- a/merlin/src/alert.rs +++ b/merlin/src/alert.rs @@ -12,6 +12,7 @@ use std::time::{Duration, Instant}; use serde_json::Value; +use crate::rules::{Action, Rule}; use crate::sync; pub const QUEUE_CAP: usize = 1000; @@ -51,6 +52,47 @@ impl AlertHook { "ts": crate::spool::now_ts(), }) } + + /// Add only policy-authored guidance for rules that actually enforced this + /// event. The event's process paths and command line are never consulted. + pub fn enforcement_alert( + kind: &str, + matched: &[String], + comm: &str, + exe: Option<&str>, + matched_rules: &[&Rule], + action: Action, + ) -> Value { + let mut event = Self::alert(kind, matched, comm, exe); + let alternatives: Vec = matched_rules + .iter() + .filter(|rule| rule.action == action) + .filter_map(|rule| { + let alternative = rule.approved_alternative.as_ref()?; + // Local rules may not have come from the managed policy + // validator. Keep this optional extension bounded even then. + if rule.name.is_empty() + || rule.name.len() > 128 + || rule + .name + .chars() + .any(|c| c == '/' || c == '\\' || c.is_control()) + { + return None; + } + Some(serde_json::json!({ + "rule": rule.name, + "name": alternative.name, + "url": alternative.url, + })) + }) + .take(16) + .collect(); + if !alternatives.is_empty() { + event["approved_alternatives"] = Value::Array(alternatives); + } + event + } } /// Start the webhook worker thread. Returns the handle producers use. @@ -98,6 +140,7 @@ pub fn spawn(url: String) -> AlertHook { #[cfg(test)] mod tests { use super::*; + use crate::rules::Rules; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpListener; use std::sync::mpsc::Receiver; @@ -154,6 +197,7 @@ mod tests { assert_eq!(parsed["rules"][0], "kill-netcat"); assert_eq!(parsed["comm"], "nc"); assert_eq!(parsed["exe"], "/usr/bin/nc"); + assert!(parsed.get("approved_alternatives").is_none()); assert!(parsed["host"].is_string()); assert!(parsed["ts"].is_number()); } @@ -169,4 +213,52 @@ mod tests { } assert_eq!(dropped.load(Ordering::Relaxed), 8); } + + #[test] + fn guidance_only_uses_matching_enforcing_rules() { + let rules = Rules::parse(concat!( + "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", + " - name: block-other\n match:\n path_basename: Other\n action: block\n approved_alternative:\n name: Other editor\n url: https://tools.example.com/other\n", + " - name: kill-agent\n match:\n path_basename: Agent\n action: kill\n approved_alternative:\n name: Approved agent\n url: https://tools.example.com/agent\n", + " - name: observe\n match:\n path_basename: Monitor\n action: log\n", + )) + .unwrap(); + let deny = AlertHook::enforcement_alert( + "deny", + &["block-cursor".into(), "kill-agent".into(), "observe".into()], + "Cursor", + None, + &[&rules.rules[0]], + Action::Block, + ); + assert_eq!(deny["approved_alternatives"].as_array().unwrap().len(), 1); + assert_eq!(deny["approved_alternatives"][0]["rule"], "block-cursor"); + assert_eq!(deny["approved_alternatives"][0]["name"], "Approved editor"); + assert_eq!( + deny["approved_alternatives"][0]["url"], + "https://tools.example.com/editor" + ); + assert!(!deny.to_string().contains("Other editor")); + assert!(!deny.to_string().contains("Approved agent")); + + let kill = AlertHook::enforcement_alert( + "kill", + &["kill-agent".into()], + "Agent", + None, + &[&rules.rules[2]], + Action::Kill, + ); + assert_eq!(kill["approved_alternatives"][0]["name"], "Approved agent"); + let absent = AlertHook::enforcement_alert( + "deny", + &["block-other".into()], + "Other", + None, + &[&rules.rules[1]], + Action::Kill, + ); + assert!(absent.get("approved_alternatives").is_none()); + } } diff --git a/merlin/src/fanotify_mon.rs b/merlin/src/fanotify_mon.rs index e846750..f326b0e 100644 --- a/merlin/src/fanotify_mon.rs +++ b/merlin/src/fanotify_mon.rs @@ -314,11 +314,24 @@ fn handle_event( matched ); if let Some(hook) = alert { - hook.fire(crate::alert::AlertHook::alert( + let enforcing_rules: Vec<_> = rules + .rules + .iter() + .filter(|rule| { + rule.action == Action::Block + && (rule.matches(&ctx) + || (unhashable + && !policy.allow_unhashable + && rule.matches_with_unresolved_sha256(&ctx))) + }) + .collect(); + hook.fire(crate::alert::AlertHook::enforcement_alert( "deny", &matched, comm.as_deref().unwrap_or(""), path_str.as_deref(), + &enforcing_rules, + Action::Block, )); } spool::try_send( diff --git a/merlin/src/sync.rs b/merlin/src/sync.rs index cfc10f9..7800725 100644 --- a/merlin/src/sync.rs +++ b/merlin/src/sync.rs @@ -1932,6 +1932,8 @@ fn collect_project_agent_discovery( ("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), ]; const ASSETS: &[(&str, &str, &str, &str)] = &[ ("agents", "skill", ".agents/skills", "skill"), @@ -1999,6 +2001,8 @@ fn collect_project_agent_discovery( } let entries = if *is_toml { codex_mcp_entries(&body) + } else if *client == "opencode" { + json_mcp_entries(&body, &["mcp"]) } else { json_mcp_entries(&body, &["mcpServers", "servers"]) }; @@ -2302,7 +2306,7 @@ fn collect_agent_discovery_from( json_mcp_entries(&body, &["mcpServers", "servers"]) }; for (name, transport) in entries { - if name.len() <= 128 && !name.chars().any(char::is_control) { + if safe_agent_asset_name(&name) { servers.insert(DeviceMCPServer { client: (*client).into(), name, @@ -2551,13 +2555,13 @@ mod tests { fs::create_dir_all(home.join(".codex")).unwrap(); fs::write( home.join(".codex/config.toml"), - "[mcp_servers.github]\nurl = 'https://secret.example'\n", + "[mcp_servers.github]\nurl = 'https://secret.example'\n[mcp_servers.\"/Users/private/work\"]\ncommand = 'secret'\n", ) .unwrap(); fs::create_dir_all(home.join(".cursor")).unwrap(); fs::write( home.join(".cursor/mcp.json"), - r#"{"mcpServers":{"docs":{"command":"secret"}}}"#, + r#"{"mcpServers":{"docs":{"command":"secret"},"https://private.example/mcp":{"url":"secret"},"C:\\Users\\private":{"command":"secret"}}}"#, ) .unwrap(); fs::create_dir_all(home.join(".agents/skills/review")).unwrap(); @@ -2678,6 +2682,9 @@ mod tests { ); let serialized = serde_json::to_string(&servers).unwrap(); assert!(!serialized.contains("secret")); + assert!(!serialized.contains("/Users/private/work")); + assert!(!serialized.contains("https://private.example/mcp")); + assert!(!serialized.contains("C:\\\\Users")); assert!( assets .iter() @@ -2734,6 +2741,8 @@ mod tests { std::env::temp_dir().join(format!("merlin-project-discovery-{}", std::process::id())); let project = root.join("customer-private"); fs::create_dir_all(project.join(".cursor")).unwrap(); + fs::create_dir_all(project.join(".opencode")).unwrap(); + fs::create_dir_all(project.join(".agents")).unwrap(); fs::create_dir_all(project.join(".claude/skills/review")).unwrap(); fs::create_dir_all(project.join(".maestro/plugins/audit")).unwrap(); fs::write( @@ -2747,6 +2756,16 @@ mod tests { r#"{"mcpServers":{"docs":{"command":"private-secret"}}}"#, ) .unwrap(); + fs::write( + project.join(".opencode/opencode.json"), + r#"{"mcp":{"code-search":{"url":"https://private.example/mcp"}},"secret":"private-secret"}"#, + ) + .unwrap(); + fs::write( + project.join(".agents/mcp.json"), + r#"{"mcpServers":{"agent-search":{"command":"private-secret"}}}"#, + ) + .unwrap(); fs::write( project.join(".claude/skills/review/SKILL.md"), "private-secret", @@ -2759,6 +2778,17 @@ mod tests { assert!(servers.iter().any(|item| item.name == "pluginsearch" && item.source == "project/.maestro/plugins/*/mcp.json" && item.transport == "remote")); + assert!(servers.iter().any(|item| item.client == "opencode" + && item.name == "code-search" + && item.source == "project/.opencode/opencode.json" + && item.transport == "remote")); + assert!(servers.iter().any(|item| item.client == "agents" + && item.name == "agent-search" + && item.source == "project/.agents/mcp.json" + && item.transport == "stdio")); + assert!(assets.iter().any(|item| item.client == "opencode" + && item.kind == "config" + && item.source == "project/.opencode/opencode.json")); assert!( assets .iter() diff --git a/merlin/src/telemetry.rs b/merlin/src/telemetry.rs index b459c08..8aa1c88 100644 --- a/merlin/src/telemetry.rs +++ b/merlin/src/telemetry.rs @@ -362,6 +362,7 @@ fn handle_exec( }; let mut logged = Vec::new(); let mut killed = Vec::new(); + let mut killed_rules = Vec::new(); for rule in &rules.rules { if !rule.matches(&ctx) { continue; @@ -387,7 +388,10 @@ fn handle_exec( continue; }; match kill_pid_if_same(pid, start_time) { - Ok(()) => killed.push(rule.name.clone()), + Ok(()) => { + killed.push(rule.name.clone()); + killed_rules.push(rule); + } Err(e) => log::warn!("kill({pid}) failed: {e}"), } } @@ -449,11 +453,13 @@ fn handle_exec( if !killed.is_empty() { log::info!("SIGKILL pid={pid:?} comm={comm} rules={killed:?}"); if let Some(hook) = alert { - hook.fire(crate::alert::AlertHook::alert( + hook.fire(crate::alert::AlertHook::enforcement_alert( "kill", &killed, &comm, exe.as_deref(), + &killed_rules, + Action::Kill, )); } spool::try_send( diff --git a/packaging/linux/build-package.sh b/packaging/linux/build-package.sh index 53943a5..45f1f4d 100755 --- a/packaging/linux/build-package.sh +++ b/packaging/linux/build-package.sh @@ -40,6 +40,7 @@ install -m 644 "$SCRIPT_DIR/merlin.service" "$ROOT/payload/usr/lib/systemd/syste install -m 600 "$SCRIPT_DIR/merlin.env.example" "$ROOT/payload/etc/merlin/merlin.env.example" install -m 600 "$REPO_DIR/rules/content/linux-lolbins.yaml" "$ROOT/payload/etc/merlin/rules.yaml" install -m 755 "$SCRIPT_DIR/install.sh" "$ROOT/install.sh" +install -m 755 "$SCRIPT_DIR/verify-install.sh" "$ROOT/verify-install.sh" install -m 755 "$SCRIPT_DIR/uninstall.sh" "$ROOT/uninstall.sh" ARCHIVE=$OUTPUT_DIR/$NAME.tar.gz diff --git a/packaging/linux/test-packaging.sh b/packaging/linux/test-packaging.sh index 7aa10ee..c95e044 100755 --- a/packaging/linux/test-packaging.sh +++ b/packaging/linux/test-packaging.sh @@ -5,7 +5,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/merlin-linux-package-test.XXXXXX") trap 'rm -rf "$TEMP_DIR"' EXIT HUP INT TERM -for script in build-package.sh install.sh merlin-launcher.sh uninstall.sh; do +for script in build-package.sh install.sh merlin-launcher.sh uninstall.sh verify-install.sh test-verify-install.sh; do sh -n "$SCRIPT_DIR/$script" done printf '#!/bin/sh\nexit 0\n' > "$TEMP_DIR/merlin" @@ -18,7 +18,7 @@ ARCHIVE=$TEMP_DIR/dist/merlin-0.0.0-test-linux-$ARCH.tar.gz [ -f "$ARCHIVE" ] || { printf '%s\n' 'package archive was not created' >&2; exit 1; } tar -xzf "$ARCHIVE" -C "$TEMP_DIR" ROOT=$TEMP_DIR/merlin-0.0.0-test-linux-$ARCH -for path in install.sh uninstall.sh payload/usr/local/libexec/merlin/merlin payload/usr/local/libexec/merlin/merlin-ebpf.o payload/usr/local/libexec/merlin/merlin-launcher payload/usr/lib/systemd/system/merlin.service payload/etc/merlin/rules.yaml payload/etc/merlin/merlin.env.example; do +for path in install.sh verify-install.sh uninstall.sh payload/usr/local/libexec/merlin/merlin payload/usr/local/libexec/merlin/merlin-ebpf.o payload/usr/local/libexec/merlin/merlin-launcher payload/usr/lib/systemd/system/merlin.service payload/etc/merlin/rules.yaml payload/etc/merlin/merlin.env.example; do [ -f "$ROOT/$path" ] || { printf 'package path is missing: %s\n' "$path" >&2; exit 1; } done grep -F 'EnvironmentFile=/etc/merlin/merlin.env' "$ROOT/payload/usr/lib/systemd/system/merlin.service" >/dev/null @@ -47,3 +47,4 @@ if python3 "$SCRIPT_DIR/release-attestation.py" verify \ exit 1 fi printf '%s\n' 'Linux packaging tests passed.' +"$SCRIPT_DIR/test-verify-install.sh" diff --git a/packaging/linux/test-verify-install.sh b/packaging/linux/test-verify-install.sh new file mode 100755 index 0000000..9702281 --- /dev/null +++ b/packaging/linux/test-verify-install.sh @@ -0,0 +1,105 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname "$0")" && pwd) +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/merlin-linux-verify-test.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT HUP INT TERM +mkdir -p "$TEMP_DIR/bin" "$TEMP_DIR/usr/local/libexec/merlin" "$TEMP_DIR/usr/lib/systemd/system" "$TEMP_DIR/etc/merlin" +chmod 755 "$TEMP_DIR/usr/local/libexec" "$TEMP_DIR/usr/local/libexec/merlin" "$TEMP_DIR/etc/merlin" "$TEMP_DIR/usr/lib/systemd/system" +for file in merlin merlin-ebpf.o merlin-launcher; do + : > "$TEMP_DIR/usr/local/libexec/merlin/$file" +done +chmod 755 "$TEMP_DIR/usr/local/libexec/merlin/merlin" "$TEMP_DIR/usr/local/libexec/merlin/merlin-launcher" +chmod 644 "$TEMP_DIR/usr/local/libexec/merlin/merlin-ebpf.o" +: > "$TEMP_DIR/etc/merlin/merlin.env" +: > "$TEMP_DIR/etc/merlin/rules.yaml" +chmod 600 "$TEMP_DIR/etc/merlin/merlin.env" +chmod 644 "$TEMP_DIR/etc/merlin/rules.yaml" +sed "s@/usr/local/libexec/merlin@$TEMP_DIR/usr/local/libexec/merlin@g; s@/etc/merlin@$TEMP_DIR/etc/merlin@g" \ + "$SCRIPT_DIR/merlin.service" > "$TEMP_DIR/usr/lib/systemd/system/merlin.service" +chmod 644 "$TEMP_DIR/usr/lib/systemd/system/merlin.service" + +# Rewrite only the fixed installation root for a fixture copy. The shipped +# verifier has no path or command overrides that could weaken fleet checks. +sed "s@/usr/local/libexec@$TEMP_DIR/usr/local/libexec@g; s@/usr/lib/systemd/system@$TEMP_DIR/usr/lib/systemd/system@g; s@/etc/merlin@$TEMP_DIR/etc/merlin@g" \ + "$SCRIPT_DIR/verify-install.sh" > "$TEMP_DIR/verify-install.sh" +chmod 755 "$TEMP_DIR/verify-install.sh" +cat > "$TEMP_DIR/bin/stat" <<'EOF' +#!/bin/sh +case "$2" in + %u) printf '0\n' ;; + %a) python3 -c 'import os, stat, sys; print(oct(stat.S_IMODE(os.stat(sys.argv[1]).st_mode))[2:])' "$4" ;; + *) exit 2 ;; +esac +EOF +cat > "$TEMP_DIR/bin/systemctl" <<'EOF' +#!/bin/sh +case "$1" in + is-enabled|is-active) [ "${MOCK_SERVICE_STATE:-ready}" = ready ] ;; + show) + case "$2" in + --property=FragmentPath) printf '%s\n' "$MOCK_UNIT" ;; + --property=DropInPaths) printf '%s\n' "${MOCK_DROPINS:-}" ;; + --property=ExecStart) printf '{ path=%s ; argv[]=%s%s ; ignore_errors=no ; }\n' "$MOCK_LAUNCHER" "$MOCK_LAUNCHER" "${MOCK_EXTRA_ARG:-}" ;; + --property=ExecCondition|--property=ExecStartPre|--property=ExecStartPost) printf '%s\n' "${MOCK_EXTRA_COMMAND:-}" ;; + --property=EnvironmentFiles) printf '%s (ignore_errors=no)%s\n' "$MOCK_CONFIG" "${MOCK_EXTRA_CONFIG:-}" ;; + *) exit 2 ;; + esac ;; + *) exit 2 ;; +esac +EOF +chmod 755 "$TEMP_DIR/bin/stat" "$TEMP_DIR/bin/systemctl" +export PATH="$TEMP_DIR/bin:$PATH" +export MOCK_UNIT="$TEMP_DIR/usr/lib/systemd/system/merlin.service" +export MOCK_LAUNCHER="$TEMP_DIR/usr/local/libexec/merlin/merlin-launcher" +export MOCK_CONFIG="$TEMP_DIR/etc/merlin/merlin.env" + +verify_ok() { + "$TEMP_DIR/verify-install.sh" > "$TEMP_DIR/output" 2>&1 || { cat "$TEMP_DIR/output" >&2; exit 1; } +} +verify_bad() { + if "$TEMP_DIR/verify-install.sh" > "$TEMP_DIR/output" 2>&1; then + printf 'unsafe installation unexpectedly verified: %s\n' "$1" >&2 + exit 1 + fi + ! grep -F 'secret-device-token' "$TEMP_DIR/output" >/dev/null || exit 1 +} + +verify_ok +chmod 777 "$TEMP_DIR/etc/merlin" +verify_bad 'writable configuration directory' +chmod 755 "$TEMP_DIR/etc/merlin" +printf 'secret-device-token\n' > "$TEMP_DIR/etc/merlin/merlin.env" +chmod 644 "$TEMP_DIR/etc/merlin/merlin.env" +verify_bad 'public configuration' +chmod 600 "$TEMP_DIR/etc/merlin/merlin.env" +verify_ok +mv "$TEMP_DIR/etc/merlin/merlin.env" "$TEMP_DIR/etc/merlin/merlin.env.real" +ln -s merlin.env.real "$TEMP_DIR/etc/merlin/merlin.env" +verify_bad 'symbolic-link configuration' +rm "$TEMP_DIR/etc/merlin/merlin.env" +mv "$TEMP_DIR/etc/merlin/merlin.env.real" "$TEMP_DIR/etc/merlin/merlin.env" +MOCK_SERVICE_STATE=inactive; export MOCK_SERVICE_STATE +verify_bad 'inactive service' +unset MOCK_SERVICE_STATE +MOCK_UNIT=/usr/lib/systemd/system/other.service; export MOCK_UNIT +verify_bad 'unexpected loaded unit' +export MOCK_UNIT="$TEMP_DIR/usr/lib/systemd/system/merlin.service" +MOCK_DROPINS=/etc/systemd/system/merlin.service.d/override.conf; export MOCK_DROPINS +verify_bad 'unit override' +unset MOCK_DROPINS +MOCK_CONFIG=/etc/elsewhere.conf; export MOCK_CONFIG +verify_bad 'unexpected configuration path' +export MOCK_CONFIG="$TEMP_DIR/etc/merlin/merlin.env" +MOCK_EXTRA_ARG=' --unsafe'; export MOCK_EXTRA_ARG +verify_bad 'extra service command argument' +unset MOCK_EXTRA_ARG +MOCK_EXTRA_COMMAND='{ path=/bin/true ; argv[]=/bin/true ; }'; export MOCK_EXTRA_COMMAND +verify_bad 'extra service command' +unset MOCK_EXTRA_COMMAND +MOCK_EXTRA_CONFIG=' /etc/extra.env (ignore_errors=no)'; export MOCK_EXTRA_CONFIG +verify_bad 'extra environment file' +unset MOCK_EXTRA_CONFIG +rm "$TEMP_DIR/etc/merlin/rules.yaml" +verify_bad 'missing rules policy' +printf '%s\n' 'Linux installation verification tests passed.' diff --git a/packaging/linux/verify-install.sh b/packaging/linux/verify-install.sh new file mode 100755 index 0000000..d66450a --- /dev/null +++ b/packaging/linux/verify-install.sh @@ -0,0 +1,62 @@ +#!/bin/sh +set -eu + +# Read-only deployment check. Never source or print the EnvironmentFile: it +# contains the device token and is intentionally accessible only to root. +BIN=/usr/local/libexec/merlin/merlin +EBPF=/usr/local/libexec/merlin/merlin-ebpf.o +LAUNCHER=/usr/local/libexec/merlin/merlin-launcher +CONFIG=/etc/merlin/merlin.env +RULES=/etc/merlin/rules.yaml +UNIT=/usr/lib/systemd/system/merlin.service + +fail() { + printf 'Deixic Endpoint verification failed: %s\n' "$1" >&2 + exit 1 +} + +check_file() { + path=$1 + [ -f "$path" ] && [ ! -L "$path" ] || fail "$2 is missing or is a symbolic link" + [ "$(stat -c %u -- "$path")" = 0 ] || fail "$2 is not owned by root" + mode=$(stat -c %a -- "$path") || fail "cannot inspect $2 permissions" + [ $((0$mode & 022)) -eq 0 ] || fail "$2 is group or world writable" +} + +for directory in /usr/local/libexec /usr/local/libexec/merlin /etc/merlin /usr/lib/systemd/system; do + [ -d "$directory" ] && [ ! -L "$directory" ] || fail 'package or configuration directory is missing or is a symbolic link' + [ "$(stat -c %u -- "$directory")" = 0 ] || fail 'package or configuration directory is not owned by root' + mode=$(stat -c %a -- "$directory") || fail 'cannot inspect directory permissions' + [ $((0$mode & 022)) -eq 0 ] || fail 'package or configuration directory is group or world writable' +done + +check_file "$BIN" 'sensor binary' +[ -x "$BIN" ] || fail 'sensor binary is not executable' +check_file "$EBPF" 'eBPF object' +check_file "$LAUNCHER" 'sensor launcher' +[ -x "$LAUNCHER" ] || fail 'sensor launcher is not executable' +check_file "$CONFIG" 'device configuration' +[ "$(stat -c %a -- "$CONFIG")" = 600 ] || fail 'device configuration must have mode 0600' +check_file "$RULES" 'rules policy' +check_file "$UNIT" 'systemd unit' + +systemctl is-enabled --quiet merlin.service || fail 'systemd service is not enabled' +systemctl is-active --quiet merlin.service || fail 'systemd service is not active' +fragment=$(systemctl show --property=FragmentPath --value merlin.service) || fail 'cannot inspect loaded systemd unit' +[ "$fragment" = "$UNIT" ] || fail 'systemd loaded an unexpected unit path' +dropins=$(systemctl show --property=DropInPaths --value merlin.service) || fail 'cannot inspect systemd drop-ins' +[ -z "$dropins" ] || fail 'systemd drop-ins change the packaged unit' +exec_start=$(systemctl show --property=ExecStart --value merlin.service) || fail 'cannot inspect service command' +case "$exec_start" in + "{ path=$LAUNCHER ; argv[]=$LAUNCHER ; "*" }") ;; + *) fail 'systemd service does not execute the packaged launcher' ;; +esac +for extra_command in ExecCondition ExecStartPre ExecStartPost; do + extra_value=$(systemctl show --property="$extra_command" --value merlin.service) || fail 'cannot inspect additional service commands' + [ -z "$extra_value" ] || fail 'systemd service has an additional command' +done +environment_files=$(systemctl show --property=EnvironmentFiles --value merlin.service) || fail 'cannot inspect service configuration path' +[ "$environment_files" = "$CONFIG (ignore_errors=no)" ] || fail 'systemd service does not load only the expected configuration path' +grep -Fx "EnvironmentFile=$CONFIG" "$UNIT" >/dev/null || fail 'systemd unit has an unexpected configuration path' +grep -Fx "ExecStart=$LAUNCHER" "$UNIT" >/dev/null || fail 'systemd unit has an unexpected launcher path' +printf '%s\n' 'Deixic Endpoint package, configuration, and systemd service verified.'