From da9c15ce20b7ffd89e9e8c11724e6cd31d22ccfd Mon Sep 17 00:00:00 2001 From: Copybara Date: Sun, 20 Sep 2026 06:12:03 +0000 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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"]'