From 6c4f7cbd34cdf80d1225f6b56d60f6b0b76713d3 Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Tue, 30 Jun 2026 11:31:02 +0300 Subject: [PATCH 1/5] Implement complete mcp server detection and quarantine read/writer daemon --- Cargo.lock | 1135 +++++++++++++++- Cargo.toml | 2 + README.md | 191 ++- crates/mcp_backend/Cargo.toml | 16 + crates/mcp_backend/src/lib.rs | 515 ++++++++ crates/mcp_detector_daemon/Cargo.toml | 6 + crates/mcp_detector_daemon/src/agents.rs | 64 + crates/mcp_detector_daemon/src/app.rs | 221 ---- crates/mcp_detector_daemon/src/claude_cli.rs | 84 ++ crates/mcp_detector_daemon/src/enrollment.rs | 155 +++ .../mcp_detector_daemon/src/hook_consumer.rs | 167 +++ crates/mcp_detector_daemon/src/ipc.rs | 233 ++-- crates/mcp_detector_daemon/src/launchd.rs | 227 ++++ crates/mcp_detector_daemon/src/logging.rs | 66 +- crates/mcp_detector_daemon/src/main.rs | 490 ++++++- crates/mcp_detector_daemon/src/ops.rs | 579 ++++++++ crates/mcp_detector_daemon/src/paths.rs | 86 ++ crates/mcp_detector_daemon/src/permission.rs | 25 - crates/mcp_detector_daemon/src/platform.rs | 74 ++ crates/mcp_detector_daemon/src/protocol.rs | 240 +++- crates/mcp_detector_daemon/src/quarantined.rs | 71 + crates/mcp_detector_daemon/src/recovery.rs | 138 ++ crates/mcp_detector_daemon/src/runner.rs | 460 +++++++ crates/mcp_detector_daemon/src/status.rs | 41 + crates/mcp_detector_daemon/src/supervisor.rs | 102 ++ crates/mcp_detector_lib/Cargo.toml | 27 +- crates/mcp_detector_lib/examples/watch.rs | 4 +- crates/mcp_detector_lib/src/agent.rs | 74 ++ crates/mcp_detector_lib/src/client.rs | 38 - .../src/clients/claude_code.rs | 331 ++++- .../src/clients/claude_cowork.rs | 127 ++ .../src/clients/claude_desktop.rs | 121 ++ crates/mcp_detector_lib/src/clients/codex.rs | 154 +++ crates/mcp_detector_lib/src/clients/common.rs | 123 ++ crates/mcp_detector_lib/src/clients/cursor.rs | 519 ++++++++ .../mcp_detector_lib/src/clients/jetbrains.rs | 176 +++ crates/mcp_detector_lib/src/clients/mod.rs | 76 +- .../mcp_detector_lib/src/clients/statedb.rs | 34 + .../mcp_detector_lib/src/clients/transport.rs | 61 +- crates/mcp_detector_lib/src/clients/vscode.rs | 264 ++-- .../mcp_detector_lib/src/clients/windsurf.rs | 83 ++ crates/mcp_detector_lib/src/clients/zed.rs | 97 ++ crates/mcp_detector_lib/src/diff.rs | 36 +- crates/mcp_detector_lib/src/error.rs | 6 +- crates/mcp_detector_lib/src/fingerprint.rs | 179 +++ crates/mcp_detector_lib/src/lib.rs | 30 +- .../mcp_detector_lib/src/secret_detection.rs | 401 ++++++ crates/mcp_detector_lib/src/types.rs | 264 +++- crates/mcp_detector_lib/src/watch.rs | 29 + crates/mcp_detector_lib/src/watcher.rs | 12 +- crates/mcp_detector_lib/tests/spawn_smoke.rs | 10 +- crates/mcp_quarantine/Cargo.toml | 25 + crates/mcp_quarantine/src/configstore.rs | 1175 +++++++++++++++++ crates/mcp_quarantine/src/error.rs | 26 + crates/mcp_quarantine/src/hooks.rs | 859 ++++++++++++ crates/mcp_quarantine/src/lib.rs | 25 + crates/mcp_quarantine/src/reconcile.rs | 223 ++++ crates/mcp_quarantine/src/seen_store.rs | 226 ++++ crates/mcp_quarantine/src/statedb.rs | 49 + docs/architecture.md | 515 ++++++++ instructions.txt | 71 + 61 files changed, 10997 insertions(+), 861 deletions(-) create mode 100644 crates/mcp_backend/Cargo.toml create mode 100644 crates/mcp_backend/src/lib.rs create mode 100644 crates/mcp_detector_daemon/src/agents.rs delete mode 100644 crates/mcp_detector_daemon/src/app.rs create mode 100644 crates/mcp_detector_daemon/src/claude_cli.rs create mode 100644 crates/mcp_detector_daemon/src/enrollment.rs create mode 100644 crates/mcp_detector_daemon/src/hook_consumer.rs create mode 100644 crates/mcp_detector_daemon/src/launchd.rs create mode 100644 crates/mcp_detector_daemon/src/ops.rs create mode 100644 crates/mcp_detector_daemon/src/paths.rs delete mode 100644 crates/mcp_detector_daemon/src/permission.rs create mode 100644 crates/mcp_detector_daemon/src/platform.rs create mode 100644 crates/mcp_detector_daemon/src/quarantined.rs create mode 100644 crates/mcp_detector_daemon/src/recovery.rs create mode 100644 crates/mcp_detector_daemon/src/runner.rs create mode 100644 crates/mcp_detector_daemon/src/status.rs create mode 100644 crates/mcp_detector_daemon/src/supervisor.rs create mode 100644 crates/mcp_detector_lib/src/agent.rs delete mode 100644 crates/mcp_detector_lib/src/client.rs create mode 100644 crates/mcp_detector_lib/src/clients/claude_cowork.rs create mode 100644 crates/mcp_detector_lib/src/clients/claude_desktop.rs create mode 100644 crates/mcp_detector_lib/src/clients/codex.rs create mode 100644 crates/mcp_detector_lib/src/clients/common.rs create mode 100644 crates/mcp_detector_lib/src/clients/cursor.rs create mode 100644 crates/mcp_detector_lib/src/clients/jetbrains.rs create mode 100644 crates/mcp_detector_lib/src/clients/statedb.rs create mode 100644 crates/mcp_detector_lib/src/clients/windsurf.rs create mode 100644 crates/mcp_detector_lib/src/clients/zed.rs create mode 100644 crates/mcp_detector_lib/src/fingerprint.rs create mode 100644 crates/mcp_detector_lib/src/secret_detection.rs create mode 100644 crates/mcp_detector_lib/src/watch.rs create mode 100644 crates/mcp_quarantine/Cargo.toml create mode 100644 crates/mcp_quarantine/src/configstore.rs create mode 100644 crates/mcp_quarantine/src/error.rs create mode 100644 crates/mcp_quarantine/src/hooks.rs create mode 100644 crates/mcp_quarantine/src/lib.rs create mode 100644 crates/mcp_quarantine/src/reconcile.rs create mode 100644 crates/mcp_quarantine/src/seen_store.rs create mode 100644 crates/mcp_quarantine/src/statedb.rs create mode 100644 docs/architecture.md create mode 100644 instructions.txt diff --git a/Cargo.lock b/Cargo.lock index a2f524b..46350d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -79,6 +91,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -107,6 +128,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.1" @@ -153,6 +180,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -168,6 +204,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.5.8" @@ -177,6 +223,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -198,6 +254,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -259,6 +326,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -268,6 +344,49 @@ dependencies = [ "libc", ] +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -275,8 +394,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -287,7 +422,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -331,12 +466,213 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -369,6 +705,12 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -387,6 +729,8 @@ version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -455,12 +799,24 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -470,13 +826,32 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "mcp_backend" +version = "0.1.0" +dependencies = [ + "mcp_detector_lib", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror", + "tracing", +] + [[package]] name = "mcp_detector_daemon" version = "0.1.0" dependencies = [ + "anyhow", "clap", "dirs", + "libc", + "mcp_backend", "mcp_detector_lib", + "mcp_quarantine", + "notify", + "notify-debouncer-full", "serde", "serde_json", "thiserror", @@ -497,12 +872,29 @@ dependencies = [ "serde", "serde_json", "serde_json_lenient", + "sha2", "tempfile", "thiserror", + "toml", "tracing", "tracing-subscriber", ] +[[package]] +name = "mcp_quarantine" +version = "0.1.0" +dependencies = [ + "mcp_detector_lib", + "rusqlite", + "serde", + "serde_json", + "serde_json_lenient", + "tempfile", + "thiserror", + "toml", + "tracing", +] + [[package]] name = "memchr" version = "2.8.0" @@ -594,6 +986,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -606,12 +1004,30 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -632,40 +1048,130 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.45" +name = "quinn" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ - "proc-macro2", + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "redox_users" -version = "0.5.2" +name = "quinn-proto" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ - "getrandom 0.2.17", - "libredox", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", "thiserror", + "tinyvec", + "tracing", + "web-time", ] [[package]] -name = "regex-automata" -version = "0.4.14" +name = "quinn-udp" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] @@ -674,6 +1180,58 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -699,6 +1257,12 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "1.1.4" @@ -712,6 +1276,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -775,6 +1374,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -794,6 +1394,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -819,6 +1451,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -847,12 +1485,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "symlink" version = "0.1.0" @@ -870,6 +1520,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -943,6 +1613,31 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.1" @@ -970,6 +1665,100 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1044,6 +1833,18 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1056,6 +1857,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1074,6 +1899,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1084,6 +1915,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1121,6 +1961,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.118" @@ -1187,6 +2037,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1202,13 +2081,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1220,6 +2108,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1227,64 +2131,124 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1379,6 +2343,115 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index c7f892b..2163914 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ resolver = "2" members = [ "crates/mcp_detector_lib", + "crates/mcp_quarantine", + "crates/mcp_backend", "crates/mcp_detector_daemon", ] diff --git a/README.md b/README.md index f5d7767..0c15f28 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,73 @@ # Edison Quarantine Daemon -This repo contains the open Rust code for the Quarantine Daemon for the Edison Watch Desktop App. It contains a cargo workspace that watches the on-disk configs of MCP (Model Context Protocol) clients and reports changes as they happen. Two crates: +Open Rust code for the Edison Watch **MCP quarantine** system agent. It discovers +the MCP (Model Context Protocol) servers configured across a machine's host apps +(Claude Code, VSCode, …) and — when an admin requires it — quarantines them: +moves them off the local config and onto the Edison Watch backend. -| Crate | Path | Role | -|---|---|---| -| `mcp_detector_lib` | [crates/mcp_detector_lib/](crates/mcp_detector_lib/) | Library: client abstraction, filesystem watcher, diff engine. Cross-platform, publishable. | -| `mcp_detector_daemon` | [crates/mcp_detector_daemon/](crates/mcp_detector_daemon/) | Long-lived daemon: wraps the library behind a Unix-socket IPC, gated on Full Disk Access. | +The target design (a privileged, non-stoppable, multi-user enforcement daemon) is +specified in **[docs/architecture.md](docs/architecture.md)** — read that for the +*why* behind the decisions (root `LaunchDaemon`, `getpeereid` scoping, fail-closed +policy, quarantine-first, level-triggered reconciliation, the frozen fingerprint +contract). This README describes what is **implemented today**; the repo is +mid-migration from a read-only detector toward that design. -Build everything: `cargo build --workspace --release`. +## Workspace -## Motivation - -MCP servers are configured independently by each client (Claude Code, VSCode, Cursor, Claude Desktop, ...) — often in several places per client: a global user-level file, per-project files, and sometimes application state stored in a SQLite database. Keeping track of this is difficult in a big project. +| Crate | Path | Role | Status | +|---|---|---|---| +| `mcp_detector_lib` | [crates/mcp_detector_lib/](crates/mcp_detector_lib/) | Read-only engine: agent abstraction, discovery, fingerprint, filesystem watcher. Cross-platform, publishable. | Reshaped | +| `mcp_quarantine` | [crates/mcp_quarantine/](crates/mcp_quarantine/) | Mutation + state + the reconcile planner. No privilege/IPC/network. | Planner done; seen-store + writers WIP | +| `mcp_detector_daemon` | [crates/mcp_detector_daemon/](crates/mcp_detector_daemon/) | Long-lived macOS daemon wrapping the engine behind a Unix-socket IPC. | Being reworked to the root enforcement model | -## Progress +Build everything: `cargo build --workspace --release`. Test: `cargo test --workspace`. -For the initial milestone the focus is additions and removals: **when an MCP server appears in or disappears from any tracked config, an event is emitted identifying it, its scope (global vs project-specific), and its transport (stdio vs remote).** In-place edits (same server name, different fields) are not reported yet. +## Motivation -Additionally, at the current stage the daemon and library only report changes and perform no modifications of their own, mostly to make sure that it works without breaking anything. In the future the library will be able to prevent modifications to the configs before they happen by tracking filesystem events. +MCP servers are configured independently by each host app — often in several +places per app: a global user-level file, per-project files, and sometimes +application state in a SQLite database. Quarantine is **imposed by admins**, so it +must run as a system agent the user cannot stop. That posture — a privileged +enforcement agent that assumes the local user is adversarial — drives the whole +design. ## Library — `mcp_detector_lib` -- **Client abstraction.** Each supported client implements a common trait exposing (a) the set of config paths to watch and (b) a parser that normalises the on-disk shape into a shared `McpServer` type. Trait: [crates/mcp_detector_lib/src/client.rs](crates/mcp_detector_lib/src/client.rs). -- **Event-driven watching.** Uses `notify-debouncer-full` against parent directories rather than individual files — most editors write configs via atomic rename, which breaks single-file watches. Driver: [crates/mcp_detector_lib/src/watcher.rs](crates/mcp_detector_lib/src/watcher.rs). -- **Stateful diffing.** Maintains a last-known snapshot per config source; emits structured events on each debounced reparse. See [crates/mcp_detector_lib/src/diff.rs](crates/mcp_detector_lib/src/diff.rs). +The read-only engine. Cross-platform, no root, no network. + +- **Agent abstraction.** Each supported host app implements the [`Agent`](crates/mcp_detector_lib/src/agent.rs) + trait: `name`, `is_installed`, `watch_targets`, and `discover`. `discover` + normalises each on-disk entry into a [`DiscoveredServer`](crates/mcp_detector_lib/src/types.rs) + carrying its raw `config` ([`ServerConfig::Stdio` | `Http` | `Unsupported`]) and + a `location` (where + how to mutate it). +- **Server fingerprint.** [`fingerprint`](crates/mcp_detector_lib/src/fingerprint.rs) + computes the stable identity used to ask "is this server already known to the + backend?". It is a **frozen cross-implementation contract** — byte-for-byte + identical to the Python backend and the TS client (`sha256(identifier)[:16]`, + secrets templatised first via [`secret_detection`](crates/mcp_detector_lib/src/secret_detection.rs)). + Pinned by golden-vector tests. See [docs/architecture.md §6](docs/architecture.md). +- **Event-driven watching.** [`Watcher`](crates/mcp_detector_lib/src/watcher.rs) + uses `notify-debouncer-full` against parent directories (editors write configs + via atomic rename, which breaks single-file watches) and emits `ChangeEvent`s. + *(This edge-triggered watcher will be superseded by the daemon's level-triggered + reconcile driver — see the design doc.)* ### Source shapes -Config sources across clients differ in several important ways: - -- **Format.** Most clients use JSON (`mcp.json`, `.mcp.json`, ...), but some store configuration or the metadata needed to *discover* project-level configs in a SQLite database (e.g. VSCode's `state.vscdb` workspace history). -- **Scope.** A single file can mix global and project-scoped entries (Claude Code's `~/.claude.json` embeds a `projects` map with per-project server lists). -- **Location.** Project-level configs live inside each project directory, so the detector has to know which projects to watch — either by enumerating them from the client's own state, or from a user-provided list. +Config sources differ in **format** (JSON / JSONC / SQLite state DB), **scope** (a +single file can mix global and project entries, e.g. Claude Code's `~/.claude.json` +`projects` map), and **location** (project configs live inside each project dir). +The `Agent` trait hides all of this; servers that expose no extractable +command/url (e.g. VSCode extension-provider contributions) are emitted as +`ServerConfig::Unsupported` — surfaced for reporting, skipped by enforcement. -The client abstraction hides all of this from the core watcher and diff logic. +### Supported agents -### Supported clients - -| Client | Status | Cargo feature | Notes | +| Agent | Status | Cargo feature | Notes | |---|---|---|---| -| VSCode | Implemented | `vscode` | Handles global, project-specific, and extension-based MCP servers | -| Claude Code | Implemented | `claude_code` | Handles global and project-specific servers. Remote connectors are out of scope for this daemon. | -| Cursor | Planned | - | Can handle everything via hooks | -| Claude Desktop & Cowork | Planned | - | Same as Claude Code | -| Zed | Planned | - | N/A | -| Codex CLI | Planned | - | N/A | +| VSCode | Implemented | `vscode` | Global, per-workspace, and extension/marketplace (`state.vscdb`) servers | +| Claude Code | Implemented | `claude_code` | Global + per-project servers (`~/.claude.json`, `.mcp.json`) | +| Cursor, Claude Desktop, Zed, Codex | Planned | — | | ### Use as a dependency @@ -53,19 +76,16 @@ The client abstraction hides all of this from the core watcher and diff logic. mcp_detector_lib = { path = "crates/mcp_detector_lib" } ``` -Then drive the watcher: - ```rust,no_run use std::sync::Arc; -use mcp_detector_lib::{Client, Result, Watcher, clients::{ClaudeCode, VsCode}}; +use mcp_detector_lib::{Agent, Result, Watcher, clients::{ClaudeCode, VsCode}}; fn main() -> Result<()> { - let clients: Vec> = vec![ + let agents: Vec> = vec![ Arc::new(VsCode::discover()?), Arc::new(ClaudeCode::discover()?), ]; - - let (events, _handle) = Watcher::new(clients).spawn()?; + let (events, _handle) = Watcher::new(agents).spawn()?; for ev in events { println!("{ev}"); } @@ -73,87 +93,46 @@ fn main() -> Result<()> { } ``` -`Watcher::spawn` runs the watcher on a background thread; the returned handle stops the worker on drop. For a blocking, callback-based variant, use `Watcher::run`. Full example: [crates/mcp_detector_lib/examples/watch.rs](crates/mcp_detector_lib/examples/watch.rs). - -`VsCode::discover()` and `ClaudeCode::discover()` use platform-specific paths. For tests, CI, or non-standard installs, use `from_paths(...)` to point each client at explicit locations instead. +`discover()` uses platform-specific paths; for tests/CI use `from_paths(...)`. Each +agent lives behind its own cargo feature (`vscode` pulls in `rusqlite`; both on by +default). -### Cargo features +## Quarantine layer — `mcp_quarantine` -Each bundled client lives behind its own feature; both are on by default. +Mutation, persistent state, and the decision logic — **no privilege, no IPC, no +network** (the daemon injects those). Everything here is unit-testable in a +tempdir. -- `vscode` — pulls in `rusqlite` (bundled) for reading VSCode's `state.vscdb` workspace history. -- `claude_code` — no extra deps. - -```toml -[dependencies] -mcp_detector_lib = { path = "crates/mcp_detector_lib", default-features = false, features = ["claude_code"] } -``` +- **Reconcile planner** ([reconcile.rs](crates/mcp_quarantine/src/reconcile.rs)) — + *implemented.* The pure, level-triggered heart: + `plan(observed, oracle, policy) -> Vec`. Quarantine-first — an unknown + server is neutralised immediately, then surfaced for disposition; a known one is + quarantined silently; the policy-off pass is inert; our own `edison-watch` entry + and report-only servers are skipped. Being level-triggered, it is inherently + tamper-resistant (a restored server simply reappears next pass). +- **Seen-store** (the `KnownOracle`) and **config writers** (`quarantine`/`restore`, + dispatched on `SourceKind`) — *in progress.* ## Daemon — `mcp_detector_daemon` -Long-lived macOS process that wraps the library and exposes its events over a Unix domain socket. Designed to be supervised by a per-user LaunchAgent (the consumer ships the plist; the daemon just runs in the foreground until killed). - -### State machine - -``` -Starting -> AwaitingFda <-> Running -``` - -- Boots the IPC server immediately so clients can connect and observe `awaiting_fda` even before permissions are granted. -- Polls a TCC-protected probe path (`~/Library/Application Support/com.apple.TCC/TCC.db` by default) every 3 seconds and on demand. Probe lives at [crates/mcp_detector_daemon/src/permission.rs](crates/mcp_detector_daemon/src/permission.rs). -- Transitions to `Running` once the probe succeeds; rebuilds the watcher with whatever clients [crates/mcp_detector_daemon/src/app.rs](crates/mcp_detector_daemon/src/app.rs) discovers. -- Re-enters `AwaitingFda` if the probe fails on a periodic recheck (i.e. the user revoked access). - -### IPC - -Newline-delimited JSON over a Unix domain socket (`0o600`). Default path: `~/Library/Application Support/Edison Watch/daemon.sock`. Wire types: [crates/mcp_detector_daemon/src/protocol.rs](crates/mcp_detector_daemon/src/protocol.rs). - -Requests: - -| Request | Reply | -|---|---| -| `{"op":"status"}` | `{"kind":"status","state":"awaiting_fda"\|"running"\|"starting","clients_watched":[...],"socket_path":"...","version":"..."}` | -| `{"op":"recheck_fda"}` | `{"kind":"ack"}` (forces an immediate FDA re-probe) | - -Pushed unsolicited from the daemon: - -```json -{"kind":"event","change":"added"|"removed","server_name":"...","client":"...","scope":"...","transport":"..."} -``` - -Quick smoke test: - -```bash -cargo run --release -p mcp_detector_daemon -- \ - --socket /tmp/daemon.sock --log-dir /tmp/daemon-logs - -echo '{"op":"status"}' | nc -U /tmp/daemon.sock -w 1 -``` - -### CLI flags - -``` ---socket Unix socket to listen on - (default: ~/Library/Application Support/Edison Watch/daemon.sock) ---log-dir Directory for the daily-rolling log file - (default: ~/Library/Logs/Edison Watch) -``` - -### Logging - -`tracing` with two layers: -- Daily-rolling file under `--log-dir` (14-day retention). -- Stdout when stdout is a TTY (i.e. when invoked from a terminal, not when supervised by launchd). +Long-lived macOS process wrapping the engine over a Unix-domain socket. **Today** +it is the original read-only design: it watches configs gated on Full Disk Access +and reports `ChangeEvent`s; it performs no mutation. -Filter via `RUST_LOG`, e.g. `RUST_LOG=debug`. +It is being reworked into the privileged enforcement daemon from the design doc: +root `LaunchDaemon`, one socket with `getpeereid` per-user scoping, per-user +reconcile workers, enrollment + fail-closed policy, an operator CLI +(`install`/`uninstall`/`unenroll`/`status`), and a `state.json` status file. See +[docs/architecture.md §4–§10](docs/architecture.md). ## Repository layout ``` . ├── Cargo.toml # virtual workspace -├── crates/ -│ ├── mcp_detector_lib/ # library (was the top-level crate before the workspace split) -│ └── mcp_detector_daemon/ # macOS daemon binary -└── README.md +├── docs/architecture.md # design source of truth +└── crates/ + ├── mcp_detector_lib/ # read-only engine + ├── mcp_quarantine/ # mutation + state + reconcile + └── mcp_detector_daemon/ # macOS daemon binary ``` diff --git a/crates/mcp_backend/Cargo.toml b/crates/mcp_backend/Cargo.toml new file mode 100644 index 0000000..6b9308b --- /dev/null +++ b/crates/mcp_backend/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mcp_backend" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "MIT OR Apache-2.0" +description = "Edison Watch backend REST client (policy, known fingerprints, server submit)." + +[dependencies] +mcp_detector_lib = { path = "../mcp_detector_lib", default-features = false } +reqwest = { version = "0.12.16", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +sha2 = "0.10.9" +thiserror = "2.0.18" +tracing = "0.1.44" diff --git a/crates/mcp_backend/src/lib.rs b/crates/mcp_backend/src/lib.rs new file mode 100644 index 0000000..ee87612 --- /dev/null +++ b/crates/mcp_backend/src/lib.rs @@ -0,0 +1,515 @@ +//! Edison Watch backend REST client. +//! +//! Thin async wrapper over the three endpoints the daemon needs, all +//! authenticated with a bearer API key (the same key the desktop app uses; the +//! daemon holds it in its root-owned enrollment store): +//! +//! - `GET /api/v1/user/domain-config` → the org policy flag +//! - `GET /api/v1/servers/fingerprints` → the org's known fingerprints +//! - `POST /api/v1/mcp-requests` → submit / register a server +//! +//! Response *parsing* is factored into pure functions ([`parse_policy`], +//! [`parse_fingerprints`]) so it is unit-testable without a live server. The +//! daemon layers fail-closed / last-known-good caching on top of this client; +//! this crate just does the calls. + +use serde::Deserialize; + +use mcp_detector_lib::{HttpKind, ServerConfig}; + +const DOMAIN_CONFIG_PATH: &str = "/api/v1/user/domain-config"; +const FINGERPRINTS_PATH: &str = "/api/v1/servers/fingerprints"; +const MCP_REQUESTS_PATH: &str = "/api/v1/mcp-requests"; +const PROFILE_PATH: &str = "/api/v1/user/profile"; +const SECRET_KEY_REGISTER_PATH: &str = "/api/v1/user/secret-key/register"; +const SECRET_KEY_VERIFY_PATH: &str = "/api/v1/user/secret-key/verify"; +const SECRET_KEY_RESET_PATH: &str = "/api/v1/user/secret-key/reset"; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("http: {0}")] + Http(#[from] reqwest::Error), + #[error("backend returned {status} for {path}")] + Status { + status: reqwest::StatusCode, + path: String, + }, + #[error("decoding {path}: {message}")] + Decode { path: String, message: String }, +} + +pub type Result = std::result::Result; + +/// The org policy flag governing quarantine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Policy { + pub quarantine: bool, +} + +/// Lifecycle status of a known server in the caller's org. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KnownStatus { + /// Approved (`TemplateMcpServerDefinitions`). + Registered, + /// Pending admin review (`mcp_server_requests`). + Requested, +} + +/// One `(name, fingerprint)` pair the backend already knows for this org. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KnownEntry { + pub name: String, + pub fingerprint: String, + pub status: KnownStatus, +} + +/// Parsed `GET /servers/fingerprints` response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fingerprints { + /// The org the backend computed this for. The daemon MUST verify this + /// matches its cached org_id before applying (a mismatch means the key was + /// re-scoped) — see design §5/§9. + pub org_id: String, + pub entries: Vec, +} + +/// The caller's profile (`GET /user/profile`). `domain` is the email domain — +/// the human-readable org label — and `role` is user/admin/owner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Profile { + pub email: Option, + pub role: String, + pub domain: String, + pub org_id: Option, +} + +/// Result of verifying a secret key (unknown fields are ignored). +#[derive(Debug, Clone, Deserialize)] +pub struct VerifyResult { + /// Whether the key's user part matches the registered hash. + pub valid: bool, + /// Whether the `.admin:` org part matches (null if none present). + #[serde(default)] + pub domain_valid: Option, + /// Whether the registered key has expired. + #[serde(default)] + pub expired: bool, + /// Days until expiry (negative if expired). + #[serde(default)] + pub days_remaining: Option, +} + +/// Result of a destructive secret-key reset. +#[derive(Debug, Clone, Deserialize)] +pub struct ResetResult { + #[serde(default)] + pub success: bool, + /// Number of encrypted personal values the backend deleted. + #[serde(default)] + pub deleted: u32, +} + +/// A server to submit to the backend. +#[derive(Debug, Clone)] +pub struct SubmitRequest { + pub name: String, + pub config: ServerConfig, + /// `true` = register (admin/owner, auto-approved); `false` = request review. + pub register: bool, +} + +/// Async client bound to a base URL + bearer key. +#[derive(Debug, Clone)] +pub struct BackendClient { + base_url: String, + api_key: String, + http: reqwest::Client, +} + +impl BackendClient { + pub fn new(base_url: impl Into, api_key: impl Into) -> Self { + // A total-request timeout so a down/slow backend fails fast (fail-closed) + // instead of hanging the enroll/refresh path indefinitely. + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .unwrap_or_default(); + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + api_key: api_key.into(), + http, + } + } + + /// Construct with a caller-supplied [`reqwest::Client`] (timeouts, proxy…). + pub fn with_http( + base_url: impl Into, + api_key: impl Into, + http: reqwest::Client, + ) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + api_key: api_key.into(), + http, + } + } + + /// `GET /api/v1/user/domain-config`. + pub async fn fetch_policy(&self) -> Result { + let body = self.get_text(DOMAIN_CONFIG_PATH).await?; + parse_policy(&body).map_err(|message| Error::Decode { + path: DOMAIN_CONFIG_PATH.into(), + message, + }) + } + + /// `GET /api/v1/user/profile`. + pub async fn fetch_profile(&self) -> Result { + let body = self.get_text(PROFILE_PATH).await?; + parse_profile(&body).map_err(|message| Error::Decode { + path: PROFILE_PATH.into(), + message, + }) + } + + /// `GET /api/v1/servers/fingerprints`. + pub async fn fetch_fingerprints(&self) -> Result { + let body = self.get_text(FINGERPRINTS_PATH).await?; + parse_fingerprints(&body).map_err(|message| Error::Decode { + path: FINGERPRINTS_PATH.into(), + message, + }) + } + + /// `POST /api/v1/user/secret-key/register` with `{ user_key_hash }`. + /// + /// Registers the SHA-256 hash of the key's **user part** (the base64 without + /// the `user:` prefix) so the MCP gateway can validate the + /// `X-Edison-Secret-Key` header the daemon installs. Call whenever the key + /// is set or rotated. The raw key never leaves the machine. + pub async fn register_secret_key(&self, composite_key: &str) -> Result<()> { + let body = serde_json::json!({ "user_key_hash": user_part_hash(composite_key) }); + let resp = self + .http + .post(format!("{}{}", self.base_url, SECRET_KEY_REGISTER_PATH)) + .bearer_auth(&self.api_key) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(Error::Status { + status: resp.status(), + path: SECRET_KEY_REGISTER_PATH.into(), + }); + } + Ok(()) + } + + /// `POST /api/v1/user/secret-key/verify` with `{ key }` (the raw composite; + /// the backend hashes it). Non-destructive check that a key matches the + /// registered hash — the "enter your existing key" path. + pub async fn verify_secret_key(&self, composite_key: &str) -> Result { + let resp = self + .http + .post(format!("{}{}", self.base_url, SECRET_KEY_VERIFY_PATH)) + .bearer_auth(&self.api_key) + .json(&serde_json::json!({ "key": composite_key })) + .send() + .await?; + if !resp.status().is_success() { + return Err(Error::Status { + status: resp.status(), + path: SECRET_KEY_VERIFY_PATH.into(), + }); + } + Ok(resp.json().await?) + } + + /// `POST /api/v1/user/secret-key/reset` with `{ new_key_hash, confirm:true }`. + /// + /// **Destructive**: the backend deletes this user's personal encrypted + /// values (those they supply themselves) and stores the new key's hash. The + /// "I lost my key, start fresh" path. The raw key never leaves the machine. + pub async fn reset_secret_key(&self, composite_key: &str) -> Result { + let body = serde_json::json!({ + "new_key_hash": user_part_hash(composite_key), + "confirm": true, + }); + let resp = self + .http + .post(format!("{}{}", self.base_url, SECRET_KEY_RESET_PATH)) + .bearer_auth(&self.api_key) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(Error::Status { + status: resp.status(), + path: SECRET_KEY_RESET_PATH.into(), + }); + } + Ok(resp.json().await?) + } + + /// `POST /api/v1/mcp-requests`. + pub async fn submit(&self, req: &SubmitRequest) -> Result<()> { + let resp = self + .http + .post(format!("{}{}", self.base_url, MCP_REQUESTS_PATH)) + .bearer_auth(&self.api_key) + .json(&submit_body(req)) + .send() + .await?; + if !resp.status().is_success() { + return Err(Error::Status { + status: resp.status(), + path: MCP_REQUESTS_PATH.into(), + }); + } + Ok(()) + } + + async fn get_text(&self, path: &str) -> Result { + let resp = self + .http + .get(format!("{}{}", self.base_url, path)) + .bearer_auth(&self.api_key) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await?; + if !resp.status().is_success() { + return Err(Error::Status { + status: resp.status(), + path: path.into(), + }); + } + Ok(resp.text().await?) + } +} + +/// SHA-256 hex of a composite key's **user part** — the base64 between `user:` +/// and any `.admin:<…>` org segment. Matches the client's +/// `hashSecretKey(userPart)`, so the daemon registers the same hash the backend +/// would already have on file for that key. +pub fn user_part_hash(composite_key: &str) -> String { + use sha2::{Digest, Sha256}; + // `user:[.admin:]` → userPart. + let user_part = composite_key + .split('.') + .next() + .unwrap_or(composite_key) + .strip_prefix("user:") + .unwrap_or(composite_key); + let digest = Sha256::digest(user_part.as_bytes()); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +// ── pure parsing (unit-testable without HTTP) ─────────────────────────────── + +#[derive(Deserialize)] +struct DomainConfigDto { + #[serde(default)] + auto_quarantine_other_mcp_servers: bool, +} + +/// Parse the domain-config body into a [`Policy`]. +pub fn parse_policy(body: &str) -> std::result::Result { + let dto: DomainConfigDto = serde_json::from_str(body).map_err(|e| e.to_string())?; + Ok(Policy { + quarantine: dto.auto_quarantine_other_mcp_servers, + }) +} + +#[derive(Deserialize)] +struct ProfileDto { + #[serde(default)] + email: Option, + #[serde(default)] + role: String, + #[serde(default)] + domain: String, + #[serde(default)] + org_id: Option, +} + +/// Parse the profile body. +pub fn parse_profile(body: &str) -> std::result::Result { + let dto: ProfileDto = serde_json::from_str(body).map_err(|e| e.to_string())?; + Ok(Profile { + email: dto.email, + role: dto.role, + domain: dto.domain, + org_id: dto.org_id, + }) +} + +#[derive(Deserialize)] +struct FingerprintsDto { + org_id: String, + #[serde(default)] + fingerprints: Vec, +} + +#[derive(Deserialize)] +struct FingerprintDto { + name: String, + fingerprint: String, + #[serde(default)] + status: Option, +} + +/// Parse the fingerprints body. Entries without a recognised status default to +/// `Registered` (matching the backend's documented default). +pub fn parse_fingerprints(body: &str) -> std::result::Result { + let dto: FingerprintsDto = serde_json::from_str(body).map_err(|e| e.to_string())?; + let entries = dto + .fingerprints + .into_iter() + .map(|f| KnownEntry { + name: f.name, + fingerprint: f.fingerprint, + status: match f.status.as_deref() { + Some("requested") => KnownStatus::Requested, + _ => KnownStatus::Registered, + }, + }) + .collect(); + Ok(Fingerprints { + org_id: dto.org_id, + entries, + }) +} + +/// Build the JSON body for `POST /mcp-requests`. +/// +/// NOTE: the exact field set is aligned to the backend's create/request schema +/// (name + command/args/env or url/headers); confirm against `CreateServerRequest` +/// when wiring live. +fn submit_body(req: &SubmitRequest) -> serde_json::Value { + use serde_json::json; + let mut body = json!({ + "name": req.name, + "status": if req.register { "registered" } else { "requested" }, + }); + let obj = body.as_object_mut().expect("object literal"); + match &req.config { + ServerConfig::Stdio { command, args, env } => { + obj.insert("command".into(), json!(command)); + obj.insert("args".into(), json!(args)); + obj.insert("env".into(), json!(env)); + } + ServerConfig::Http { url, headers, kind } => { + let ty = match kind { + HttpKind::Http => "http", + HttpKind::Sse => "sse", + HttpKind::StreamableHttp => "streamable-http", + }; + obj.insert("type".into(), json!(ty)); + obj.insert("url".into(), json!(url)); + obj.insert("headers".into(), json!(headers)); + } + ServerConfig::Opaque { .. } => {} + } + body +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn policy_true_and_false_and_missing() { + assert!(parse_policy(r#"{"auto_quarantine_other_mcp_servers":true}"#).unwrap().quarantine); + assert!(!parse_policy(r#"{"auto_quarantine_other_mcp_servers":false}"#).unwrap().quarantine); + // Missing flag defaults to false (fail-closed caching is the daemon's job). + assert!(!parse_policy(r#"{}"#).unwrap().quarantine); + } + + #[test] + fn policy_rejects_garbage() { + assert!(parse_policy("not json").is_err()); + } + + #[test] + fn fingerprints_parses_status_and_default() { + let body = r#"{ + "org_id": "org-1", + "fingerprints": [ + {"name":"a","fingerprint":"f1","status":"registered"}, + {"name":"b","fingerprint":"f2","status":"requested"}, + {"name":"c","fingerprint":"f3"} + ] + }"#; + let fps = parse_fingerprints(body).unwrap(); + assert_eq!(fps.org_id, "org-1"); + assert_eq!(fps.entries.len(), 3); + assert_eq!(fps.entries[0].status, KnownStatus::Registered); + assert_eq!(fps.entries[1].status, KnownStatus::Requested); + assert_eq!(fps.entries[2].status, KnownStatus::Registered); // default + } + + #[test] + fn fingerprints_empty_list() { + let fps = parse_fingerprints(r#"{"org_id":"o"}"#).unwrap(); + assert!(fps.entries.is_empty()); + } + + #[test] + fn submit_body_stdio_and_http() { + let stdio = SubmitRequest { + name: "s".into(), + config: ServerConfig::Stdio { + command: "npx".into(), + args: vec!["-y".into()], + env: BTreeMap::new(), + }, + register: true, + }; + let b = submit_body(&stdio); + assert_eq!(b["name"], "s"); + assert_eq!(b["status"], "registered"); + assert_eq!(b["command"], "npx"); + + let http = SubmitRequest { + name: "h".into(), + config: ServerConfig::Http { + url: "https://x".into(), + headers: BTreeMap::new(), + kind: HttpKind::Sse, + }, + register: false, + }; + let b = submit_body(&http); + assert_eq!(b["status"], "requested"); + assert_eq!(b["type"], "sse"); + assert_eq!(b["url"], "https://x"); + } + + #[test] + fn profile_parses_domain_and_role() { + let body = r#"{"user_id":"u","email":"a@gatlingx.com","role":"owner","domain":"gatlingx.com","org_id":"o-1"}"#; + let p = parse_profile(body).unwrap(); + assert_eq!(p.domain, "gatlingx.com"); + assert_eq!(p.role, "owner"); + assert_eq!(p.email.as_deref(), Some("a@gatlingx.com")); + } + + #[test] + fn base_url_trailing_slash_trimmed() { + let c = BackendClient::new("https://api.example/", "k"); + assert_eq!(c.base_url, "https://api.example"); + } + + #[test] + fn user_part_hash_strips_prefix_and_ignores_org_segment() { + // sha256("abc") — the hash is of the user part, not the composite. + const SHA256_ABC: &str = + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + assert_eq!(user_part_hash("user:abc"), SHA256_ABC); + // The `.admin:<…>` org segment is not part of the user hash. + assert_eq!(user_part_hash("user:abc.admin:xyz"), SHA256_ABC); + // A bare key (no prefix) hashes as-is. + assert_eq!(user_part_hash("abc"), SHA256_ABC); + } +} diff --git a/crates/mcp_detector_daemon/Cargo.toml b/crates/mcp_detector_daemon/Cargo.toml index 3fbd625..d889570 100644 --- a/crates/mcp_detector_daemon/Cargo.toml +++ b/crates/mcp_detector_daemon/Cargo.toml @@ -12,6 +12,8 @@ path = "src/main.rs" [dependencies] mcp_detector_lib = { path = "../mcp_detector_lib" } +mcp_quarantine = { path = "../mcp_quarantine" } +mcp_backend = { path = "../mcp_backend" } tokio = { version = "1.41", features = ["rt-multi-thread", "net", "signal", "time", "macros", "io-util", "sync"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" @@ -20,4 +22,8 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-appender = "0.2.3" dirs = "6.0.0" thiserror = "2.0.18" +anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } +notify = "8.2.0" +notify-debouncer-full = "0.7.0" +libc = "0.2" diff --git a/crates/mcp_detector_daemon/src/agents.rs b/crates/mcp_detector_daemon/src/agents.rs new file mode 100644 index 0000000..f943e9f --- /dev/null +++ b/crates/mcp_detector_daemon/src/agents.rs @@ -0,0 +1,64 @@ +//! Build the set of MCP host-app adapters to scan for the current user. + +use std::sync::Arc; + +use mcp_detector_lib::Agent; +use mcp_detector_lib::clients::{ + ClaudeCode, ClaudeCowork, ClaudeDesktop, Codex, Cursor, JetBrains, VsCode, Windsurf, Zed, +}; + +/// Discover the locally-available agents. An agent whose `discover()` +/// constructor fails is logged and skipped. +pub fn build() -> Vec> { + let mut agents: Vec> = Vec::new(); + + macro_rules! add { + ($ctor:expr, $label:literal) => { + match $ctor { + Ok(a) => agents.push(Arc::new(a)), + Err(e) => tracing::warn!(error = %e, concat!($label, " discover failed")), + } + }; + } + + add!(ClaudeCode::discover(), "claude_code"); + add!(ClaudeDesktop::discover(), "claude_desktop"); + add!(ClaudeCowork::discover(), "claude_cowork"); + add!(Cursor::discover(), "cursor"); + add!(VsCode::discover(), "vscode"); + add!(Windsurf::discover(), "windsurf"); + add!(Zed::discover(), "zed"); + add!(Codex::discover(), "codex"); + add!(JetBrains::intellij(), "intellij"); + add!(JetBrains::pycharm(), "pycharm"); + add!(JetBrains::webstorm(), "webstorm"); + + agents +} + +/// One reconcile-pass discovery across all agents, flattening per-agent errors +/// to a logged warning (one broken config can't stop the rest). +pub fn discover_all(agents: &[Arc]) -> Vec { + let mut out = Vec::new(); + for a in agents { + match a.discover() { + Ok(servers) => out.extend(servers), + Err(e) => tracing::warn!(agent = a.name(), error = %e, "discover failed"), + } + } + // Dedupe by *physical target* (agent + file + nested key + server key). The + // same entry can be discovered from several sources — e.g. one workspace + // opened under multiple Cursor workspace hashes enumerates the same + // `.cursor/mcp.json` more than once — and acting on it twice would try to + // remove an already-removed entry. + let mut seen = std::collections::HashSet::new(); + out.retain(|s| { + seen.insert(( + s.client, + s.location.path.clone(), + s.location.key_path.clone(), + s.location.server_key.clone(), + )) + }); + out +} diff --git a/crates/mcp_detector_daemon/src/app.rs b/crates/mcp_detector_daemon/src/app.rs deleted file mode 100644 index 7b98ae5..0000000 --- a/crates/mcp_detector_daemon/src/app.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! Top-level state machine. Boots the IPC server immediately so clients can -//! connect and observe the `awaiting_fda` state, then transitions into -//! `running` once Full Disk Access is granted. Re-enters `awaiting_fda` if a -//! watcher error suggests FDA was revoked. - -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use mcp_detector_lib::{ChangeEvent, Client as McpClient}; -use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; -use tokio::task::JoinHandle; - -use crate::ipc; -use crate::permission; -use crate::protocol::{ChangeKind, DaemonState, Message, StatusReply, WatcherEvent}; - -const FDA_POLL_INTERVAL: Duration = Duration::from_secs(3); -const EVENT_BROADCAST_CAPACITY: usize = 256; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("io: {0}")] - Io(#[from] std::io::Error), -} - -pub struct Config { - pub socket_path: PathBuf, - pub probe_path: PathBuf, -} - -#[derive(Clone)] -pub struct SharedState { - pub state: Arc>, - pub clients_watched: Arc>>, - pub events: broadcast::Sender, - pub recheck_tx: mpsc::Sender<()>, - pub socket_path: PathBuf, -} - -impl SharedState { - pub async fn snapshot_status(&self) -> StatusReply { - StatusReply { - state: *self.state.read().await, - clients_watched: self.clients_watched.read().await.clone(), - socket_path: self.socket_path.to_string_lossy().into_owned(), - version: env!("CARGO_PKG_VERSION").to_string(), - } - } -} - -pub async fn run(cfg: Config) -> Result<(), Error> { - let (recheck_tx, recheck_rx) = mpsc::channel::<()>(8); - let (event_tx, _event_rx) = broadcast::channel::(EVENT_BROADCAST_CAPACITY); - - let shared = SharedState { - state: Arc::new(RwLock::new(DaemonState::Starting)), - clients_watched: Arc::new(RwLock::new(Vec::new())), - events: event_tx, - recheck_tx, - socket_path: cfg.socket_path.clone(), - }; - - let ipc_handle = ipc::serve(cfg.socket_path.clone(), shared.clone()).await?; - - let supervisor_state = shared.clone(); - let supervisor_handle = tokio::spawn(supervise(supervisor_state, cfg.probe_path, recheck_rx)); - - tokio::select! { - _ = wait_for_termination() => { - tracing::info!("termination signal received, shutting down"); - } - res = ipc_handle => { - tracing::warn!("ipc server exited unexpectedly: {res:?}"); - } - res = supervisor_handle => { - tracing::warn!("supervisor task exited unexpectedly: {res:?}"); - } - } - Ok(()) -} - -async fn wait_for_termination() { - use tokio::signal::unix::{SignalKind, signal}; - let Ok(mut term) = signal(SignalKind::terminate()) else { - std::future::pending::<()>().await; - return; - }; - let Ok(mut int) = signal(SignalKind::interrupt()) else { - std::future::pending::<()>().await; - return; - }; - tokio::select! { - _ = term.recv() => {} - _ = int.recv() => {} - } -} - -async fn supervise( - shared: SharedState, - probe_path: PathBuf, - mut recheck_rx: mpsc::Receiver<()>, -) { - { - *shared.state.write().await = DaemonState::AwaitingFda; - } - - let watcher_slot: Arc>>> = Arc::new(Mutex::new(None)); - - loop { - if matches!(*shared.state.read().await, DaemonState::Running) { - tokio::select! { - _ = tokio::time::sleep(FDA_POLL_INTERVAL) => {} - Some(_) = recheck_rx.recv() => {} - } - // Periodically re-probe so we can drop into AwaitingFda if the - // user revokes access. Watcher errors will also flip us back. - if !permission::check(&probe_path) { - tracing::warn!("FDA appears revoked; stopping watcher"); - stop_watcher(&watcher_slot).await; - { - *shared.state.write().await = DaemonState::AwaitingFda; - shared.clients_watched.write().await.clear(); - } - } - continue; - } - - if permission::check(&probe_path) { - tracing::info!("FDA granted; starting watcher"); - match start_watcher(&shared).await { - Ok(handle) => { - *watcher_slot.lock().await = Some(handle); - *shared.state.write().await = DaemonState::Running; - } - Err(e) => { - tracing::error!("failed to start watcher: {e}"); - } - } - } else { - tracing::debug!("FDA still missing"); - } - - tokio::select! { - _ = tokio::time::sleep(FDA_POLL_INTERVAL) => {} - Some(_) = recheck_rx.recv() => { - tracing::info!("recheck requested by client"); - } - } - } -} - -async fn stop_watcher(slot: &Arc>>>) { - if let Some(handle) = slot.lock().await.take() { - handle.abort(); - } -} - -async fn start_watcher(shared: &SharedState) -> Result, std::io::Error> { - let mut clients: Vec> = Vec::new(); - let mut names: Vec = Vec::new(); - - match mcp_detector_lib::clients::ClaudeCode::discover() { - Ok(c) => { - names.push("claude_code".into()); - clients.push(Arc::new(c)); - } - Err(e) => { - tracing::warn!("claude_code discover failed: {e}"); - } - } - match mcp_detector_lib::clients::VsCode::discover() { - Ok(c) => { - names.push("vscode".into()); - clients.push(Arc::new(c)); - } - Err(e) => { - tracing::warn!("vscode discover failed: {e}"); - } - } - - *shared.clients_watched.write().await = names; - - if clients.is_empty() { - return Err(std::io::Error::other("no clients available to watch")); - } - - let watcher = mcp_detector_lib::Watcher::new(clients); - let (rx, handle) = match watcher.spawn() { - Ok(pair) => pair, - Err(e) => return Err(std::io::Error::other(e.to_string())), - }; - - let events_tx = shared.events.clone(); - let join = tokio::task::spawn_blocking(move || { - // Hold the handle for the lifetime of this task so the worker thread - // stays alive. When the JoinHandle is aborted, the receiver will be - // dropped and the inner thread will be stopped via the handle drop. - let _handle = handle; - for ev in rx.iter() { - let msg = Message::Event(map_event(&ev)); - // ignore send errors when no subscribers - let _ = events_tx.send(msg); - } - }); - Ok(join) -} - -fn map_event(ev: &ChangeEvent) -> WatcherEvent { - let (change, server) = match ev { - ChangeEvent::Added(s) => (ChangeKind::Added, s), - ChangeEvent::Removed(s) => (ChangeKind::Removed, s), - }; - WatcherEvent { - change, - server_name: server.name.clone(), - client: server.client.to_string(), - scope: format!("{:?}", server.scope), - transport: server.transport.to_string(), - } -} diff --git a/crates/mcp_detector_daemon/src/claude_cli.rs b/crates/mcp_detector_daemon/src/claude_cli.rs new file mode 100644 index 0000000..7f07482 --- /dev/null +++ b/crates/mcp_detector_daemon/src/claude_cli.rs @@ -0,0 +1,84 @@ +//! Claude Code integration via its own `claude mcp` CLI. Claude Code misbehaves +//! if the edison-watch entry is written directly rather than through the CLI, so +//! we shell out. Under root we drop to the target user first (setuid/setgid + +//! HOME) so `--scope user` writes *that user's* `~/.claude.json`, not root's. + +use std::process::Command; + +use anyhow::Context; + +use crate::{paths, platform}; + +/// `claude mcp add --transport http --scope user [--header …] edison-watch `. +pub fn install(user: &str, url: &str, secret: Option<&str>) -> anyhow::Result<()> { + // `--header` is variadic, so it must come AFTER the positionals + // or it greedily consumes them (matches the CLI's own help example). + let mut args: Vec = vec![ + "mcp".into(), + "add".into(), + "--transport".into(), + "http".into(), + "--scope".into(), + "user".into(), + "edison-watch".into(), + url.to_string(), + ]; + if let Some(s) = secret { + args.push("--header".into()); + args.push(format!("X-Edison-Secret-Key: {s}")); + } + run_as(user, &args) +} + +/// Remove the edison-watch entry from both user and project scope. +pub fn remove(user: &str) -> anyhow::Result<()> { + let _ = run_as( + user, + &["mcp".into(), "remove".into(), "edison-watch".into(), "--scope".into(), "project".into()], + ); + run_as( + user, + &["mcp".into(), "remove".into(), "edison-watch".into(), "--scope".into(), "user".into()], + ) +} + +fn run_as(user: &str, args: &[String]) -> anyhow::Result<()> { + let mut cmd = Command::new("claude"); + cmd.args(args); + + // When running as root, drop to the target user so the CLI writes into their + // home. In the user-mode dev build this branch is skipped. + if paths::is_root() + && let Some((uid, gid)) = platform::uid_gid_for(user) + { + if let Some(home) = platform::home_dir_for(user) { + cmd.env("HOME", home); + } + cmd.env("USER", user); + // SAFETY: pre_exec runs in the forked child before exec; only async- + // signal-safe libc setgid/setuid calls, no allocation. + unsafe { + use std::os::unix::process::CommandExt; + cmd.pre_exec(move || { + if libc::setgid(gid) != 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::setuid(uid) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } + + let output = cmd.output().context("spawning `claude` CLI")?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!( + "`claude {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ) + } +} diff --git a/crates/mcp_detector_daemon/src/enrollment.rs b/crates/mcp_detector_daemon/src/enrollment.rs new file mode 100644 index 0000000..bd5736c --- /dev/null +++ b/crates/mcp_detector_daemon/src/enrollment.rs @@ -0,0 +1,155 @@ +//! Enrollment + last-known-good policy, keyed by OS user. +//! +//! Enrollment is performed online (the `enroll` flow validates against the +//! backend and seeds the cache), matching the design's "enrollment = online +//! handshake" so there is never an enrolled-but-never-fetched state. The store +//! is a map so one machine can serve several OS users (one dev user, or many +//! under root); the dev build simply operates on its own username. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::paths; + +/// One user's enrollment record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Enrollment { + pub api_base_url: String, + pub api_key: String, + pub org_id: String, + /// Human-readable org label (the caller's email domain, e.g. `gatlingx.com`). + #[serde(default)] + pub org_name: String, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub role: String, + /// Last-known-good policy. Kept enforcing through backend outages + /// (fail-closed): a failed refresh never flips this off. + pub quarantine: bool, + /// MCP gateway base URL (e.g. `http://localhost:3000`) — the `edison-watch` + /// proxy entry points here (distinct from `api_base_url`). Needed to install. + #[serde(default)] + pub mcp_base_url: Option, + /// Agents the UI selected for `edison-watch` install. Governs install only; + /// quarantine still acts on all agents. + #[serde(default)] + pub selected_agents: Vec, + /// The user's edison secret key (composite `user:`), provided by the + /// UI/CLI. Carried in the `X-Edison-Secret-Key` header of the installed + /// entry. `None` installs without the header. + #[serde(default)] + pub edison_secret_key: Option, + /// Whether automatic quarantine enforcement is armed for this user. The UI + /// arms it only once onboarding is complete, so the daemon stays detect-only + /// (list/report, no removal) while the user is still reviewing their servers + /// during setup. Explicit dispositions (send-to-EW) act regardless. + #[serde(default)] + pub armed: bool, +} + +impl Enrollment { + /// Load this OS user's enrollment, if any. + pub fn load_for(user: &str) -> anyhow::Result> { + Ok(Enrollments::load()?.get(user).cloned()) + } + + /// Upsert this enrollment for `user`. + pub fn save_for(&self, user: &str) -> anyhow::Result<()> { + let mut store = Enrollments::load()?; + store.set(user, self.clone()); + store.save() + } + + /// Remove `user`'s enrollment, returning it if present. + pub fn remove_for(user: &str) -> anyhow::Result> { + let mut store = Enrollments::load()?; + let removed = store.remove(user); + store.save()?; + Ok(removed) + } +} + +/// The on-disk enrollment map, keyed by OS user. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Enrollments { + #[serde(default)] + users: BTreeMap, +} + +impl Enrollments { + pub fn load() -> anyhow::Result { + let path = paths::enrollments_path(); + if !path.exists() { + return Ok(Self::default()); + } + Ok(serde_json::from_str(&std::fs::read_to_string(&path)?)?) + } + + pub fn save(&self) -> anyhow::Result<()> { + paths::ensure_base_dir()?; + std::fs::write( + paths::enrollments_path(), + serde_json::to_string_pretty(self)?, + )?; + Ok(()) + } + + pub fn get(&self, user: &str) -> Option<&Enrollment> { + self.users.get(user) + } + + pub fn set(&mut self, user: &str, e: Enrollment) { + self.users.insert(user.to_string(), e); + } + + pub fn remove(&mut self, user: &str) -> Option { + self.users.remove(user) + } + + /// Every enrolled `(user, enrollment)` — used by the root daemon to spawn a + /// worker per user. + #[allow(dead_code)] // consumed by the root per-user supervisor (next sub-part) + pub fn iter(&self) -> impl Iterator { + self.users.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn e(org: &str) -> Enrollment { + Enrollment { + api_base_url: "u".into(), + api_key: "k".into(), + org_id: org.into(), + org_name: "o".into(), + email: None, + role: "user".into(), + quarantine: true, + mcp_base_url: None, + selected_agents: Vec::new(), + edison_secret_key: None, + armed: false, + } + } + + #[test] + fn set_get_remove_and_serde_round_trip() { + let mut s = Enrollments::default(); + s.set("alice", e("org-a")); + s.set("bob", e("org-b")); + assert_eq!(s.get("alice").unwrap().org_id, "org-a"); + assert_eq!(s.iter().count(), 2); + + // Round-trips through JSON keyed by user. + let json = serde_json::to_string(&s).unwrap(); + let back: Enrollments = serde_json::from_str(&json).unwrap(); + assert_eq!(back.get("bob").unwrap().org_id, "org-b"); + + assert!(s.remove("alice").is_some()); + assert!(s.get("alice").is_none()); + } +} diff --git a/crates/mcp_detector_daemon/src/hook_consumer.rs b/crates/mcp_detector_daemon/src/hook_consumer.rs new file mode 100644 index 0000000..e068c5c --- /dev/null +++ b/crates/mcp_detector_daemon/src/hook_consumer.rs @@ -0,0 +1,167 @@ +//! The hook pending-file consumer (phase 2b — the `hookHealthMonitor` +//! equivalent). Drains `~/.edison-watch/pending/` and `errors/` so the hook +//! scripts' output doesn't accumulate, and sweeps stale/orphaned files. +//! +//! Purely local file lifecycle: session-end files are logged then removed, +//! registration files are discarded, error files are logged then removed. No +//! network. Runs as a background task under the supervisor. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use notify::RecursiveMode; +use notify_debouncer_full::{DebounceEventResult, new_debouncer}; +use tokio::sync::mpsc; + +/// How often to re-drain + sweep as a safety net (fs events do the prompt work). +const SWEEP_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); +/// Pending files older than this are swept (matches the app's 7 days). +const PENDING_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +/// Run the consumer for `edison_dir` (`~/.edison-watch`) until the task is +/// dropped. +pub async fn run(edison_dir: PathBuf) { + let pending = edison_dir.join("pending"); + let errors = edison_dir.join("errors"); + let _ = std::fs::create_dir_all(&pending); + let _ = std::fs::create_dir_all(&errors); + + // Drain any backlog left while we weren't running. + drain_pending(&pending); + drain_errors(&errors); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let _debouncer = start_watcher(&edison_dir, tx); + tracing::info!(dir = %edison_dir.display(), watching = _debouncer.is_some(), "hook consumer started"); + + let mut sweep = tokio::time::interval(SWEEP_INTERVAL); + sweep.tick().await; // the first tick fires immediately; skip it + + loop { + tokio::select! { + _ = rx.recv() => { + while rx.try_recv().is_ok() {} // coalesce + drain_pending(&pending); + drain_errors(&errors); + } + _ = sweep.tick() => { + drain_pending(&pending); + drain_errors(&errors); + sweep_stale(&pending); + sweep_orphaned(&edison_dir); + } + } + } +} + +type Debouncer = notify_debouncer_full::Debouncer< + notify::RecommendedWatcher, + notify_debouncer_full::RecommendedCache, +>; + +fn start_watcher(dir: &Path, tx: mpsc::UnboundedSender<()>) -> Option { + let mut debouncer = new_debouncer( + Duration::from_millis(300), + None, + move |res: DebounceEventResult| { + if res.is_ok() { + let _ = tx.send(()); + } + }, + ) + .ok()?; + debouncer.watch(dir, RecursiveMode::Recursive).ok()?; + Some(debouncer) +} + +/// Consume every ready pending file: log session-ends, discard the rest. +fn drain_pending(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + // `.tmp` in-flight writes are dot-prefixed; never touch them. + if !name.ends_with(".json") || name.starts_with('.') { + continue; + } + let path = entry.path(); + if name.ends_with("-session-end.json") { + log_session_end(&path); + } + let _ = std::fs::remove_file(&path); + } +} + +fn log_session_end(path: &Path) { + if let Ok(text) = std::fs::read_to_string(path) + && let Ok(v) = serde_json::from_str::(&text) + && v.get("event").and_then(|x| x.as_str()) == Some("session_end") + { + let conv = v.get("conversation_id").and_then(|x| x.as_str()).unwrap_or("?"); + let reason = v.get("reason").and_then(|x| x.as_str()).unwrap_or("unknown"); + tracing::info!(conversation_id = conv, reason, "session ended"); + } +} + +fn drain_errors(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.ends_with(".json") || name.starts_with('.') { + continue; + } + let path = entry.path(); + if let Ok(text) = std::fs::read_to_string(&path) { + tracing::warn!(file = %name, detail = %text.trim(), "hook reported an error"); + } + let _ = std::fs::remove_file(&path); + } +} + +/// Delete pending files older than [`PENDING_MAX_AGE`] (by mtime). +fn sweep_stale(dir: &Path) { + let now = SystemTime::now(); + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if let Ok(meta) = entry.metadata() + && let Ok(mtime) = meta.modified() + && now.duration_since(mtime).unwrap_or_default() > PENDING_MAX_AGE + { + let _ = std::fs::remove_file(&path); + } + } +} + +/// Delete `active_session_.json` files whose process is gone. +fn sweep_orphaned(edison_dir: &Path) { + let Ok(entries) = std::fs::read_dir(edison_dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some(pid) = name + .strip_prefix("active_session_") + .and_then(|s| s.strip_suffix(".json")) + .and_then(|s| s.parse::().ok()) + && !process_alive(pid) + { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +/// `kill(pid, 0)`: 0 → alive; `EPERM` → alive (exists, no permission); `ESRCH` +/// → gone. +fn process_alive(pid: i32) -> bool { + // SAFETY: kill with signal 0 performs only an existence/permission check. + if unsafe { libc::kill(pid, 0) } == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} diff --git a/crates/mcp_detector_daemon/src/ipc.rs b/crates/mcp_detector_daemon/src/ipc.rs index f620cfc..6e1721d 100644 --- a/crates/mcp_detector_daemon/src/ipc.rs +++ b/crates/mcp_detector_daemon/src/ipc.rs @@ -1,124 +1,159 @@ -//! Unix-domain-socket server. Accepts newline-delimited JSON requests and -//! pushes [`Message::Event`] notifications to every connected client. +//! The Unix-socket IPC server. +//! +//! Each connection's OS user is taken from the socket's **peer credentials** +//! (`SO_PEERCRED` / `getpeereid`, via tokio's `peer_cred`), not from anything +//! the client sends — so every request is scoped to the kernel-reported uid. +//! Requests/replies are newline-delimited JSON; the daemon also pushes +//! [`Event`]s to a connection when they match its peer user. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use serde::Serialize; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{UnixListener, UnixStream}; +use tokio::net::{UnixListener, UnixStream, unix::OwnedWriteHalf}; use tokio::sync::broadcast; -use tokio::task::JoinHandle; -use crate::app::SharedState; -use crate::protocol::{ErrorReply, Message, Request}; +use crate::ops; +use crate::platform; +use crate::protocol::{Reply, Request}; +use crate::runner::EventTx; -pub async fn serve(socket_path: PathBuf, shared: SharedState) -> std::io::Result> { - if let Some(parent) = socket_path.parent() { - std::fs::create_dir_all(parent)?; - } - // Best-effort cleanup of stale socket files. - let _ = std::fs::remove_file(&socket_path); - let listener = UnixListener::bind(&socket_path)?; - set_socket_perms(&socket_path)?; - tracing::info!(path = %socket_path.display(), "ipc listening"); - - let handle = tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((stream, _addr)) => { - let shared = shared.clone(); - tokio::spawn(async move { - if let Err(e) = handle_client(stream, shared).await { - tracing::debug!("client disconnected: {e}"); - } - }); - } - Err(e) => { - tracing::warn!("accept failed: {e}"); - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } - } - } - }); - Ok(handle) +/// Default socket path (dev). The root build will use `/var/run/...`. +pub fn default_socket_path() -> PathBuf { + crate::paths::base_dir().join("daemon.sock") } -#[cfg(unix)] -fn set_socket_perms(path: &std::path::Path) -> std::io::Result<()> { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - std::fs::set_permissions(path, perms) -} +/// Serve the IPC socket at `path` until the process is stopped. `events` feeds +/// per-user pushes to connected clients. +pub async fn serve(path: &Path, events: EventTx) -> anyhow::Result<()> { + crate::paths::ensure_base_dir()?; + let _ = std::fs::remove_file(path); // a stale socket file blocks bind + let listener = UnixListener::bind(path)?; + tracing::info!(socket = %path.display(), "IPC listening"); -#[cfg(not(unix))] -fn set_socket_perms(_path: &std::path::Path) -> std::io::Result<()> { - Ok(()) + loop { + let (stream, _addr) = listener.accept().await?; + let events = events.clone(); + tokio::spawn(async move { + if let Err(e) = handle_conn(stream, events).await { + tracing::debug!(error = %e, "connection ended"); + } + }); + } } -async fn handle_client(stream: UnixStream, shared: SharedState) -> std::io::Result<()> { - let (read_half, write_half) = stream.into_split(); - let mut reader = BufReader::new(read_half); - let writer = std::sync::Arc::new(tokio::sync::Mutex::new(write_half)); +async fn handle_conn(stream: UnixStream, events: EventTx) -> anyhow::Result<()> { + // Identity comes from the kernel, not the client. + let uid = stream.peer_cred()?.uid(); + let user = + platform::username_for_uid(uid).ok_or_else(|| anyhow::anyhow!("unknown uid {uid}"))?; + tracing::debug!(uid, %user, "connection"); + + let (rd, mut wr) = stream.into_split(); + let mut lines = BufReader::new(rd).lines(); + let mut rx = events.subscribe(); - let mut events_rx: broadcast::Receiver = shared.events.subscribe(); - let writer_for_pump = writer.clone(); - let pump = tokio::spawn(async move { - loop { - match events_rx.recv().await { - Ok(msg) => { - if write_message(&writer_for_pump, &msg).await.is_err() { - break; + loop { + tokio::select! { + line = lines.next_line() => { + match line? { + None => break, // client hung up + Some(l) if l.trim().is_empty() => continue, + Some(l) => { + let reply = match serde_json::from_str::(&l) { + Ok(req) => dispatch(&user, req).await, + Err(e) => Reply::Error { message: format!("bad request: {e}") }, + }; + write_json(&mut wr, &reply).await?; } } - Err(broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!("client lagged by {n} messages"); + } + evt = rx.recv() => { + match evt { + Ok((u, e)) if u == user => write_json(&mut wr, &e).await?, + Ok(_) => {} // another user's event + Err(broadcast::error::RecvError::Lagged(_)) => {} // dropped some; fine + Err(broadcast::error::RecvError::Closed) => break, } - Err(broadcast::error::RecvError::Closed) => break, } } - }); - - let mut line = String::new(); - loop { - line.clear(); - let n = reader.read_line(&mut line).await?; - if n == 0 { - break; - } - let trimmed = line.trim_end(); - if trimmed.is_empty() { - continue; - } - let response = match serde_json::from_str::(trimmed) { - Ok(req) => handle_request(req, &shared).await, - Err(e) => Message::Error(ErrorReply { - message: format!("bad request: {e}"), - }), - }; - write_message(&writer, &response).await?; } + Ok(()) +} - pump.abort(); +async fn write_json(wr: &mut OwnedWriteHalf, val: &T) -> anyhow::Result<()> { + let mut buf = serde_json::to_vec(val)?; + buf.push(b'\n'); + wr.write_all(&buf).await?; Ok(()) } -async fn handle_request(req: Request, shared: &SharedState) -> Message { - match req { - Request::Status => Message::Status(shared.snapshot_status().await), - Request::RecheckFda => { - // Non-blocking nudge to the supervisor. - let _ = shared.recheck_tx.try_send(()); - Message::Ack - } +async fn dispatch(user: &str, req: Request) -> Reply { + let result: anyhow::Result = async { + Ok(match req { + Request::Enroll { + url, + key, + mcp_url, + agents, + secret, + install, + armed, + } => Reply::Status( + ops::enroll(user, url, key, mcp_url, agents, secret, install, armed).await?, + ), + Request::Status { refresh } => Reply::Status(if refresh { + ops::refresh_policy(user).await? + } else { + ops::status(user)? + }), + Request::RefreshPolicy => Reply::Status(ops::refresh_policy(user).await?), + Request::VerifySecret { key } => { + let r = ops::verify_secret(user, key).await?; + Reply::Secret(crate::protocol::SecretOutcome { + valid: Some(r.valid), + expired: Some(r.expired), + deleted: None, + }) + } + Request::ResetSecret { key, confirm } => { + if !confirm { + Reply::Error { + message: "reset requires confirm=true (destructive)".into(), + } + } else { + let r = ops::reset_secret(user, key).await?; + Reply::Secret(crate::protocol::SecretOutcome { + valid: None, + expired: None, + deleted: Some(r.deleted), + }) + } + } + Request::ListAgents => Reply::Agents { + agents: ops::list_agents(), + }, + Request::ListServers => Reply::Servers { + servers: ops::list_servers(user)?, + }, + Request::Disposition { + name, + agent, + choice, + rename, + } => { + ops::disposition(user, &name, agent.as_deref(), choice, rename.as_deref()).await?; + Reply::Ack + } + Request::Unenroll => { + ops::unenroll(user)?; + Reply::Ack + } + }) } -} + .await; -async fn write_message( - writer: &std::sync::Arc>, - msg: &Message, -) -> std::io::Result<()> { - let mut buf = serde_json::to_vec(msg).map_err(std::io::Error::other)?; - buf.push(b'\n'); - let mut w = writer.lock().await; - w.write_all(&buf).await?; - w.flush().await + result.unwrap_or_else(|e| Reply::Error { + message: format!("{e:#}"), + }) } diff --git a/crates/mcp_detector_daemon/src/launchd.rs b/crates/mcp_detector_daemon/src/launchd.rs new file mode 100644 index 0000000..0fa70c8 --- /dev/null +++ b/crates/mcp_detector_daemon/src/launchd.rs @@ -0,0 +1,227 @@ +//! macOS LaunchAgent install for the detector daemon — a per-user agent (no +//! sudo, no root LaunchDaemon), mirroring `edison-stdiod` so the desktop client +//! installs and launches us exactly the way it installs stdiod: +//! +//! - plist at `~/Library/LaunchAgents/watch.edison.detectord.plist` +//! - loaded via `launchctl bootstrap gui/$uid …` (modern flow, not `load`) +//! - `RunAtLoad` + `KeepAlive` so launchd starts it now/at login and restarts +//! it on crash — the daemon serves its socket for the client to connect to. +//! - a PATH override so child spawns (the `claude` CLI, npx-wrapped servers) +//! resolve — launchd's default PATH omits Homebrew / `/usr/local/bin`. +//! +//! Install is idempotent: it always boots-out any existing unit before +//! bootstrapping the fresh plist, so re-running picks up a moved binary or a +//! flag change. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, anyhow, bail}; + +use crate::{ipc, paths}; + +const LABEL: &str = "watch.edison.detectord"; +const PLIST_FILENAME: &str = "watch.edison.detectord.plist"; +/// launchd's per-user default PATH omits Homebrew and /usr/local/bin; without +/// this every child spawn (`claude`, `npx`, …) fails to resolve. +const CHILD_PATH: &str = + "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; + +fn plist_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow!("HOME not set"))?; + let dir = home.join("Library/LaunchAgents"); + std::fs::create_dir_all(&dir)?; + Ok(dir.join(PLIST_FILENAME)) +} + +fn user_domain() -> String { + // SAFETY: getuid is infallible. + let uid = unsafe { libc::getuid() }; + format!("gui/{uid}") +} + +fn service_target() -> String { + format!("{}/{}", user_domain(), LABEL) +} + +fn launchd_log() -> Result { + let dir = paths::base_dir().join("logs"); + std::fs::create_dir_all(&dir)?; + Ok(dir.join("launchd.log")) +} + +fn render_plist(binary: &Path, log: &Path, enforce: bool) -> String { + let mut prog = vec![binary.display().to_string(), "daemon".to_string()]; + if enforce { + // Full mode: quarantine + own the hooks (consumer runs). + prog.push("--enforce".to_string()); + } else { + // Report-only / shadow: no hook consumer, so we don't fight a client's + // own hook monitor over ~/.edison-watch. + prog.push("--no-hooks".to_string()); + } + let args = prog + .iter() + .map(|a| format!(" {a}")) + .collect::>() + .join("\n"); + let log = log.display(); + format!( + r#" + + + + Label + {LABEL} + ProgramArguments + +{args} + + RunAtLoad + + KeepAlive + + ProcessType + Background + EnvironmentVariables + + PATH + {CHILD_PATH} + + StandardOutPath + {log} + StandardErrorPath + {log} + + +"# + ) +} + +fn write_plist(path: &Path, body: &str) -> Result<()> { + let tmp = path.with_extension("plist.tmp"); + std::fs::write(&tmp, body).with_context(|| format!("writing {}", tmp.display()))?; + std::fs::rename(&tmp, path).with_context(|| format!("renaming -> {}", path.display()))?; + Ok(()) +} + +fn launchctl(args: &[&str]) -> Result { + Command::new("launchctl") + .args(args) + .output() + .context("failed to invoke launchctl") +} + +/// `bootout` the current unit if loaded; ignore "not loaded". +fn bootout_quiet() -> Result<()> { + let out = launchctl(&["bootout", &service_target()])?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let benign = stderr.contains("Could not find specified service") + || stderr.contains("No such process") + || out.status.code() == Some(113); + if !benign { + tracing::warn!(stderr = %stderr, "launchctl bootout reported an error; continuing"); + } + } + Ok(()) +} + +/// Write the plist and `launchctl bootstrap` it. Idempotent. `enforce` decides +/// whether the running daemon actually quarantines (still gated by org policy) +/// or runs report-only — default off is safe for first-time install. +pub fn install(enforce: bool) -> Result<()> { + if !cfg!(target_os = "macos") { + bail!("`service install` is only supported on macOS"); + } + let binary = std::env::current_exe().context("could not resolve current exe path")?; + let log = launchd_log()?; + let plist = plist_path()?; + write_plist(&plist, &render_plist(&binary, &log, enforce))?; + tracing::info!(path = %plist.display(), enforce, "wrote LaunchAgent plist"); + + bootout_quiet()?; // pick up a moved binary / changed flags + let out = launchctl(&["bootstrap", &user_domain(), plist.to_string_lossy().as_ref()])?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + bail!("launchctl bootstrap failed: {}", stderr.trim()); + } + println!("Installed LaunchAgent: {}", plist.display()); + println!("Daemon running{}; socket: {}", if enforce { " (enforcing)" } else { " (report-only)" }, ipc::default_socket_path().display()); + Ok(()) +} + +/// `launchctl bootout` + remove the plist. Idempotent. Leaves state/logs. +pub fn uninstall(purge: bool) -> Result<()> { + bootout_quiet()?; + let plist = plist_path()?; + match std::fs::remove_file(&plist) { + Ok(()) => tracing::info!(path = %plist.display(), "removed LaunchAgent plist"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("removing {}", plist.display())), + } + if purge { + // enrollment(s), per-user seen-store + quarantine records, state.json, + // logs/, and the socket all live under base_dir — wipe the whole thing. + let dir = paths::base_dir(); + match std::fs::remove_dir_all(&dir) { + Ok(()) => tracing::info!(path = %dir.display(), "purged daemon data dir"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("purging {}", dir.display())), + } + println!( + "Uninstalled LaunchAgent and purged all data (enrollment, seen-store, \ + quarantine records, logs, socket) at {}.", + dir.display() + ); + } else { + println!("Uninstalled LaunchAgent (state + logs left in place)."); + } + Ok(()) +} + +/// The plist exists on disk. +pub fn is_installed() -> bool { + plist_path().map(|p| p.exists()).unwrap_or(false) +} + +/// `launchctl print` reports a running PID. +pub fn is_running() -> bool { + let Ok(out) = launchctl(&["print", &service_target()]) else { + return false; + }; + if !out.status.success() { + return false; + } + String::from_utf8_lossy(&out.stdout).lines().any(|l| { + l.trim() + .strip_prefix("pid =") + .is_some_and(|rest| rest.trim().parse::().is_ok()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plist_has_label_program_and_path() { + let body = render_plist(Path::new("/opt/edison/detectord"), Path::new("/tmp/l.log"), true); + assert!(body.contains("watch.edison.detectord")); + assert!(body.contains("/opt/edison/detectord")); + assert!(body.contains("daemon")); + assert!(body.contains("--enforce")); + assert!(body.contains("RunAtLoad")); + assert!(body.contains("KeepAlive")); + assert!(body.contains("/opt/homebrew/bin")); + } + + #[test] + fn plist_report_only_uses_no_hooks_not_enforce() { + let body = render_plist(Path::new("/bin/x"), Path::new("/tmp/l.log"), false); + assert!(!body.contains("--enforce")); + assert!(body.contains("--no-hooks")); + assert!(body.trim_end().ends_with("")); + } +} diff --git a/crates/mcp_detector_daemon/src/logging.rs b/crates/mcp_detector_daemon/src/logging.rs index d339537..ca968db 100644 --- a/crates/mcp_detector_daemon/src/logging.rs +++ b/crates/mcp_detector_daemon/src/logging.rs @@ -1,6 +1,4 @@ -//! Daemon logging: rolling daily file in `~/Library/Logs/Edison Watch/` plus -//! stdout when stdout is a TTY (i.e. when the daemon was launched from a -//! terminal, not by launchd). +//! Logging: stdout for the operator CLI, rolling file + stdout for the daemon. use std::path::Path; @@ -9,53 +7,25 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, fmt}; -/// Returned guard must be kept alive for the lifetime of the program; -/// dropping it flushes pending file writes. -pub fn init(log_dir: &Path) -> std::io::Result { - std::fs::create_dir_all(log_dir)?; - - let file_appender = tracing_appender::rolling::Builder::new() - .rotation(tracing_appender::rolling::Rotation::DAILY) - .filename_prefix("daemon") - .filename_suffix("log") - .max_log_files(14) - .build(log_dir) - .map_err(|e| std::io::Error::other(e.to_string()))?; - let (non_blocking_file, guard) = tracing_appender::non_blocking(file_appender); - - let env_filter = - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - - let file_layer = fmt::layer() - .with_writer(non_blocking_file) - .with_ansi(false); - - let stdout_layer = fmt::layer() - .with_writer(std::io::stdout) - .with_ansi(is_stdout_tty()); - - tracing_subscriber::registry() - .with(env_filter) - .with(file_layer) - .with(stdout_layer) - .init(); - - Ok(guard) -} - -#[cfg(unix)] -fn is_stdout_tty() -> bool { - // SAFETY: isatty just inspects an fd, no preconditions. - unsafe { libc_isatty(1) != 0 } +fn filter() -> EnvFilter { + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")) } -#[cfg(not(unix))] -fn is_stdout_tty() -> bool { - false +/// Stdout only (CLI). Filter via `RUST_LOG` (default `info`). Idempotent. +pub fn init_stdout() { + let _ = tracing_subscriber::fmt().with_env_filter(filter()).try_init(); } -#[cfg(unix)] -unsafe extern "C" { - #[link_name = "isatty"] - fn libc_isatty(fd: i32) -> i32; +/// Daily-rolling file under `log_dir` **plus** stdout (daemon mode). The +/// returned guard must be held for the process lifetime to flush file writes. +pub fn init_daemon(log_dir: &Path) -> Option { + std::fs::create_dir_all(log_dir).ok()?; + let file = tracing_appender::rolling::daily(log_dir, "detectord.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file); + let _ = tracing_subscriber::registry() + .with(filter()) + .with(fmt::layer().with_writer(non_blocking).with_ansi(false)) + .with(fmt::layer().with_writer(std::io::stdout)) + .try_init(); + Some(guard) } diff --git a/crates/mcp_detector_daemon/src/main.rs b/crates/mcp_detector_daemon/src/main.rs index 1e5a2c4..4cdb748 100644 --- a/crates/mcp_detector_daemon/src/main.rs +++ b/crates/mcp_detector_daemon/src/main.rs @@ -1,83 +1,467 @@ -//! Entrypoint for the Edison Watch quarantine daemon. +//! Edison Watch quarantine daemon. +//! +//! Runs the MCP discovery + quarantine pipeline. In this build it runs as the +//! invoking user; the privileged system-agent version (design §4–§10) layers +//! root + getpeereid scoping + a per-user supervisor on top of the same engine. +//! Two interfaces: an operator CLI (below) and an IPC socket (`serve`) — both +//! go through the shared, per-user [`ops`] layer. +use std::collections::HashMap; use std::path::PathBuf; use std::process::ExitCode; -use clap::Parser; +use clap::{Parser, Subcommand}; -mod app; +mod agents; +mod claude_cli; +mod enrollment; +mod hook_consumer; mod ipc; +mod launchd; mod logging; -mod permission; +mod ops; +mod paths; +mod platform; mod protocol; +mod quarantined; +mod recovery; +mod runner; +mod status; +mod supervisor; -#[derive(Parser, Debug)] -#[command(version, about = "Edison Watch quarantine daemon", long_about = None)] -struct Args { - /// Path to the Unix domain socket to listen on. Defaults to - /// `~/Library/Application Support/Edison Watch/daemon.sock`. - #[arg(long)] - socket: Option, - /// Directory for the rolling log file. Defaults to - /// `~/Library/Logs/Edison Watch`. - #[arg(long)] - log_dir: Option, - /// Override the FDA probe path (testing only). - #[arg(long, hide = true)] - fda_probe_path: Option, +use enrollment::Enrollment; +use protocol::{Choice, ServerView, Status}; + +#[derive(Parser)] +#[command(version, about = "Edison Watch quarantine daemon")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum ServiceCmd { + /// Install + start the LaunchAgent (the client calls this, like stdiod). + /// Report-only by default; `--enforce` actually quarantines (gated by policy). + Install { + #[arg(long)] + enforce: bool, + }, + /// Stop + remove the LaunchAgent. Leaves state + logs unless `--purge`. + Uninstall { + /// Also delete all daemon data: enrollment, seen-store, quarantine + /// records, logs, and the socket (the whole data dir). + #[arg(long)] + purge: bool, + }, + /// Whether the agent is installed / running, and the socket path. + Status, +} + +#[derive(Subcommand)] +enum SecretCmd { + /// Verify a key against the backend; if valid, adopt it (install it). + Verify { key: String }, + /// Destructively reset to a new key — deletes your encrypted personal + /// values on the backend — then install it. Requires --confirm. + Reset { + key: String, + #[arg(long)] + confirm: bool, + }, +} + +#[derive(Subcommand)] +enum Cmd { + /// Enroll against the backend (validates the key, caches policy + known + /// set) and install edison-watch into the selected agents. + Enroll { + /// API base URL, e.g. http://localhost:3001 + #[arg(long)] + url: String, + /// Bearer API key. + #[arg(long)] + key: String, + /// MCP gateway base URL for the edison-watch entry, e.g. + /// http://localhost:3000 (required to install into agents). + #[arg(long)] + mcp_url: Option, + /// Agents to install edison-watch into (comma-separated), e.g. + /// `cursor,vscode,codex`. Omit to keep the previous selection. + /// Quarantine still covers all agents. + #[arg(long, value_delimiter = ',')] + agents: Option>, + /// The user's edison secret key (composite `user:`) for the + /// X-Edison-Secret-Key header. Omit to keep the previous value. + #[arg(long)] + secret: Option, + }, + /// Show enrollment + cached policy. + Status { + /// Fetch the policy from the backend first (updates the cache). + #[arg(long)] + refresh: bool, + }, + /// Run the reconcile loop until Ctrl-C. Dry-run unless `--enforce`. + Run { + /// Actually quarantine (move servers to sidecars). Off by default. + #[arg(long)] + enforce: bool, + }, + /// List discovered (live) servers and quarantined ones. + List { + /// Show every instance with its source path, without deduping. + #[arg(long, short)] + verbose: bool, + }, + /// Restore a quarantined server by name/fingerprint (or `--all`). + Restore { + /// Server name or fingerprint (omit with --all). + needle: Option, + /// Restore every quarantined server. + #[arg(long)] + all: bool, + }, + /// Submit a discovered server to Edison Watch (register/request) and remove + /// it from its local config. + SendToEw { + /// Server name to send. + name: String, + /// Disambiguate when the name exists under several agents. + #[arg(long)] + agent: Option, + }, + /// Reverse every quarantine found on disk (sidecars + ew-disabled dirs), + /// independent of tracked state. Idempotent. + Recover, + /// Verify or reset the edison secret key. + Secret { + #[command(subcommand)] + action: SecretCmd, + }, + /// Remove the local enrollment. + Unenroll, + /// Manage the LaunchAgent (install/uninstall/status) — how the client + /// installs and launches the daemon, mirroring stdiod. + Service { + #[command(subcommand)] + action: ServiceCmd, + }, + /// Serve just the IPC socket the UI connects to (peer-uid-scoped). + Serve { + /// Socket path (default: under the state dir). + #[arg(long)] + socket: Option, + }, + /// Run the full daemon: IPC socket + a reconcile worker per enrolled user, + /// with rolling-file logging and a state.json heartbeat. + Daemon { + /// Actually quarantine (off by default). + #[arg(long)] + enforce: bool, + /// Socket path (default: under the state dir). + #[arg(long)] + socket: Option, + /// Don't run the hook pending-file consumer (detect-only mode, so we + /// don't fight a client's own hook monitor over ~/.edison-watch). + #[arg(long)] + no_hooks: bool, + }, } fn main() -> ExitCode { - let args = Args::parse(); + let cli = Cli::parse(); + // Daemon mode logs to a rolling file (+ stdout); everything else stdout only. + let _log_guard = if matches!(cli.cmd, Cmd::Daemon { .. }) { + logging::init_daemon(&paths::base_dir().join("logs")) + } else { + logging::init_stdout(); + None + }; - let socket_path = args - .socket - .unwrap_or_else(default_socket_path); - let log_dir = args.log_dir.unwrap_or_else(default_log_dir); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("build tokio runtime"); - let _log_guard = match logging::init(&log_dir) { - Ok(g) => g, + match runtime.block_on(dispatch(cli.cmd)) { + Ok(()) => ExitCode::SUCCESS, Err(e) => { - eprintln!("failed to init logging: {e}"); - return ExitCode::FAILURE; + eprintln!("error: {e:#}"); + ExitCode::FAILURE + } + } +} + +async fn dispatch(cmd: Cmd) -> anyhow::Result<()> { + match cmd { + Cmd::Enroll { + url, + key, + mcp_url, + agents, + secret, + } => cmd_enroll(url, key, mcp_url, agents, secret).await, + Cmd::Status { refresh } => cmd_status(refresh).await, + Cmd::Run { enforce } => runner::run(enforce).await, + Cmd::List { verbose } => cmd_list(verbose), + Cmd::Restore { needle, all } => cmd_restore(needle, all), + Cmd::SendToEw { name, agent } => cmd_send_to_ew(name, agent).await, + Cmd::Recover => { + let (servers, dirs) = recovery::recover(); + println!("Recovered {servers} server(s) and {dirs} plugin dir(s) from disk."); + Ok(()) + } + Cmd::Secret { action } => match action { + SecretCmd::Verify { key } => cmd_verify_secret(key).await, + SecretCmd::Reset { key, confirm } => cmd_reset_secret(key, confirm).await, + }, + Cmd::Service { action } => match action { + ServiceCmd::Install { enforce } => launchd::install(enforce), + ServiceCmd::Uninstall { purge } => launchd::uninstall(purge), + ServiceCmd::Status => { + println!( + "installed: {}\nrunning: {}\nsocket: {}", + launchd::is_installed(), + launchd::is_running(), + ipc::default_socket_path().display() + ); + Ok(()) + } + }, + Cmd::Unenroll => cmd_unenroll(), + Cmd::Serve { socket } => { + let (events, _keep) = tokio::sync::broadcast::channel(256); + let path = socket.unwrap_or_else(ipc::default_socket_path); + ipc::serve(&path, events).await } + Cmd::Daemon { + enforce, + socket, + no_hooks, + } => { + let path = socket.unwrap_or_else(ipc::default_socket_path); + supervisor::run(enforce, path, !no_hooks).await + } + } +} + +/// The OS user the CLI operates on (this process's owner). +fn cli_user() -> String { + paths::current_username() +} + +async fn cmd_enroll( + url: String, + key: String, + mcp_url: Option, + agents: Option>, + secret: Option, +) -> anyhow::Result<()> { + // CLI enroll always applies the install and arms enforcement (the + // operator's explicit intent; no onboarding gate for the dev/admin path). + print_status( + &ops::enroll(&cli_user(), url, key, mcp_url, agents, secret, true, Some(true)).await?, + ); + Ok(()) +} + +async fn cmd_status(refresh: bool) -> anyhow::Result<()> { + let u = cli_user(); + let s = if refresh { + ops::refresh_policy(&u).await? + } else { + ops::status(&u)? }; + print_status(&s); + Ok(()) +} - tracing::info!( - socket = %socket_path.display(), - log_dir = %log_dir.display(), - "mcp_detector_daemon starting" +fn print_status(s: &Status) { + if !s.enrolled { + println!("Not enrolled. Run `enroll --url --key `."); + return; + } + println!( + "Enrolled.\n org: {} ({})\n user: {} [{}] [{}]\n policy.quarantine: {}\n quarantined: {}", + s.org_name.as_deref().unwrap_or("-"), + s.org_id.as_deref().unwrap_or("-"), + s.email.as_deref().unwrap_or("-"), + s.role.as_deref().unwrap_or("-"), + if s.armed { "armed" } else { "disarmed" }, + s.quarantine, + s.quarantined_count, ); +} - let runtime = match tokio::runtime::Builder::new_multi_thread().enable_all().build() { - Ok(r) => r, - Err(e) => { - tracing::error!("failed to build tokio runtime: {e}"); - return ExitCode::FAILURE; +fn cmd_list(verbose: bool) -> anyhow::Result<()> { + let u = cli_user(); + if let Some(e) = Enrollment::load_for(&u)? { + println!( + "Enrolled: [{}] [{}]\n", + e.role, + if e.armed { "armed" } else { "disarmed" } + ); + } + let servers = ops::list_servers(&u)?; + + if verbose { + print_list_verbose(&servers); + } else { + print_list_deduped(&servers); + } + + println!( + "\n state: edison=our own entry (skipped) · known=already at backend (silent removal)" + ); + println!(" new=would be quarantined · opaque=removed locally, can't send to EW"); + println!(" report=untouchable, no access to remove"); + + match Enrollment::load_for(&u)? { + None => println!(" (enroll to classify known vs new)"), + Some(e) if !e.quarantine => { + println!(" policy.quarantine=false → `run` is inert (reports only, no removal)") } - }; + _ => {} + } - let probe_path = args.fda_probe_path.unwrap_or_else(permission::default_probe_path); + let q = quarantined::QuarantinedState::load_for(&u)?; + if !q.entries.is_empty() { + println!("\nQuarantined ({}):", q.entries.len()); + for e in &q.entries { + println!(" {:<22} {:<12} {}", e.name, e.agent, e.fingerprint); + } + } + Ok(()) +} - let result = runtime.block_on(app::run(app::Config { - socket_path, - probe_path, - })); - match result { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - tracing::error!("daemon exited with error: {e}"); - ExitCode::FAILURE +fn print_list_deduped(servers: &[ServerView]) { + let mut order: Vec<(String, String, String)> = Vec::new(); + let mut info: HashMap<(String, String, String), (String, String, usize)> = HashMap::new(); + for s in servers { + let id = s.fingerprint.clone().unwrap_or_else(|| "-".to_string()); + let key = (s.name.clone(), s.agent.clone(), id); + match info.get_mut(&key) { + Some(e) => e.2 += 1, + None => { + order.push(key.clone()); + info.insert(key, (s.kind.clone(), s.state.clone(), 1)); + } } } + + println!("Discovered across host apps ({} unique):\n", order.len()); + println!(" {:<22} {:<12} {:<7} {:<7} FINGERPRINT", "NAME", "AGENT", "TYPE", "STATE"); + for key in &order { + let (kind, state, count) = &info[key]; + let (name, agent, id) = key; + let name_disp = if *count > 1 { + format!("{name} (x{count})") + } else { + name.clone() + }; + println!(" {name_disp:<22} {agent:<12} {kind:<7} {state:<7} {id}"); + } + println!(" (use --verbose to list every instance with its source path)"); +} + +fn print_list_verbose(servers: &[ServerView]) { + let mut rows: Vec<&ServerView> = servers.iter().collect(); + rows.sort_by(|a, b| (&a.agent, &a.name, &a.path).cmp(&(&b.agent, &b.name, &b.path))); + println!("Discovered across host apps ({} instances):\n", rows.len()); + println!( + " {:<18} {:<12} {:<7} {:<7} {:<18} PATH", + "NAME", "AGENT", "TYPE", "STATE", "FINGERPRINT" + ); + for s in rows { + let id = s.fingerprint.as_deref().unwrap_or("-"); + println!( + " {:<18} {:<12} {:<7} {:<7} {id:<18} {}", + s.name, s.agent, s.kind, s.state, s.path + ); + } } -fn default_socket_path() -> PathBuf { - let base = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp")); - base.join("Library/Application Support/Edison Watch/daemon.sock") +async fn cmd_send_to_ew(name: String, agent: Option) -> anyhow::Result<()> { + ops::disposition(&cli_user(), &name, agent.as_deref(), Choice::SendToEw, None).await?; + println!("Sent {name} to Edison Watch and removed it from the local config."); + Ok(()) } -fn default_log_dir() -> PathBuf { - let base = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp")); - base.join("Library/Logs/Edison Watch") +async fn cmd_verify_secret(key: String) -> anyhow::Result<()> { + let r = ops::verify_secret(&cli_user(), key).await?; + if r.valid { + let warn = if r.expired { " (WARNING: key has expired)" } else { "" }; + println!("Key is valid — adopted and installed into selected agents.{warn}"); + } else { + println!("Key does NOT match the registered key. Nothing installed."); + } + Ok(()) +} + +async fn cmd_reset_secret(key: String, confirm: bool) -> anyhow::Result<()> { + if !confirm { + anyhow::bail!( + "reset is destructive — it deletes your encrypted personal values on the backend. Re-run with --confirm." + ); + } + let r = ops::reset_secret(&cli_user(), key).await?; + println!( + "Reset complete: {} encrypted value(s) deleted. New key installed into selected agents.", + r.deleted + ); + Ok(()) +} + +fn cmd_unenroll() -> anyhow::Result<()> { + match ops::unenroll(&cli_user())? { + None => println!("Not enrolled."), + Some(org) => println!("Unenrolled {org}."), + } + Ok(()) +} + +fn cmd_restore(needle: Option, all: bool) -> anyhow::Result<()> { + use mcp_quarantine::{ConfigStore, FileConfigStore, SeenStore}; + + let user = cli_user(); + let mut q = quarantined::QuarantinedState::load_for(&user)?; + let mut seen = Enrollment::load_for(&user)? + .map(|e| SeenStore::open(paths::seen_store_path(&user), e.org_id)) + .transpose()?; + + let targets: Vec<_> = if all { + q.entries.clone() + } else { + let needle = + needle.ok_or_else(|| anyhow::anyhow!("provide a server name/fingerprint, or --all"))?; + vec![ + q.take(&needle) + .ok_or_else(|| anyhow::anyhow!("no quarantined server matching '{needle}'"))?, + ] + }; + + let mut restored = 0; + for entry in &targets { + match FileConfigStore.restore(&entry.record) { + Ok(()) => { + // Forget it so the next reconcile pass won't re-quarantine. + if let Some(s) = seen.as_mut() { + let _ = s.forget(&entry.fingerprint); + } + if all { + q.take(&entry.fingerprint); + } + restored += 1; + println!("Restored {} ({}).", entry.name, entry.agent); + } + Err(e) => eprintln!("failed to restore {} ({}): {e}", entry.name, entry.agent), + } + } + + q.save_for(&user)?; + if all { + println!("Restored {restored}/{} quarantined server(s).", targets.len()); + } + Ok(()) } diff --git a/crates/mcp_detector_daemon/src/ops.rs b/crates/mcp_detector_daemon/src/ops.rs new file mode 100644 index 0000000..fbfc614 --- /dev/null +++ b/crates/mcp_detector_daemon/src/ops.rs @@ -0,0 +1,579 @@ +//! Operations behind both the CLI and the IPC server, each scoped to an OS +//! user. Returning protocol DTOs keeps the two front-ends in sync. + +use anyhow::Context; +use mcp_backend::{BackendClient, Error as BackendError, KnownStatus, SubmitRequest}; +use mcp_detector_lib::{DiscoveredServer, ServerConfig, fingerprint}; +use mcp_quarantine::{ + Action as SeenAction, ConfigStore, FileConfigStore, SeenStore, is_edison_entry, +}; + +use crate::agents; +use crate::enrollment::Enrollment; +use crate::paths; +use crate::platform; +use crate::protocol::{AgentInfo, Choice, ServerView, Status}; +use crate::quarantined::{QuarantinedEntry, QuarantinedState}; + +/// Which agents are present on the machine. +pub fn list_agents() -> Vec { + agents::build() + .iter() + .map(|a| AgentInfo { + name: a.name().to_string(), + installed: a.is_installed(), + }) + .collect() +} + +/// `(kind, state, fingerprint)` for one server — the shared classification. +pub fn classify(s: &DiscoveredServer, seen: Option<&SeenStore>) -> (String, String, Option) { + let fp = fingerprint(&s.name, &s.config); + let kind = match &s.config { + ServerConfig::Stdio { .. } => "stdio", + ServerConfig::Http { .. } => "http", + ServerConfig::Opaque { .. } => "opaque", + }; + let state = if is_edison_entry(s) { + "edison" + } else { + match &s.config { + ServerConfig::Opaque { removable: true, .. } => "opaque", + ServerConfig::Opaque { removable: false, .. } => "report", + _ => match &fp { + None => "report", + Some(f) => match seen { + Some(st) if st.contains(f) => "known", + Some(_) => "new", + None => "?", + }, + }, + } + }; + (kind.to_string(), state.to_string(), fp) +} + +/// Discover + classify every server instance for `user`. +pub fn list_servers(user: &str) -> anyhow::Result> { + let observed = agents::discover_all(&agents::build()); + let seen = Enrollment::load_for(user)? + .and_then(|e| SeenStore::open(paths::seen_store_path(user), e.org_id).ok()); + Ok(observed + .iter() + .map(|s| { + let (kind, state, fingerprint) = classify(s, seen.as_ref()); + ServerView { + name: s.name.clone(), + agent: s.client.to_string(), + kind, + state, + fingerprint, + path: s.location.path.display().to_string(), + config: Some(s.config.clone()), + } + }) + .collect()) +} + +/// Enrollment + cached policy for `user`. +pub fn status(user: &str) -> anyhow::Result { + let quarantined_count = QuarantinedState::load_for(user) + .map(|q| q.entries.len()) + .unwrap_or(0); + Ok(match Enrollment::load_for(user)? { + None => Status { + user: user.to_string(), + enrolled: false, + org_id: None, + org_name: None, + email: None, + role: None, + quarantine: false, + quarantined_count, + armed: false, + }, + Some(e) => Status { + user: user.to_string(), + enrolled: true, + org_id: Some(e.org_id), + org_name: Some(e.org_name), + email: e.email, + role: Some(e.role), + quarantine: e.quarantine, + quarantined_count, + armed: e.armed, + }, + }) +} + +/// Re-fetch the policy (fail-closed) and return the updated status. +pub async fn refresh_policy(user: &str) -> anyhow::Result { + if let Some(mut e) = Enrollment::load_for(user)? { + let client = BackendClient::new(e.api_base_url.clone(), e.api_key.clone()); + if let Ok(p) = client.fetch_policy().await { + e.quarantine = p.quarantine; + e.save_for(user)?; + } + } + status(user) +} + +/// Online enrollment handshake: validate the key, resolve the org, cache the +/// policy and known set, then install the `edison-watch` proxy entry into the +/// selected agents (when an MCP base URL was given). +/// `mcp_base_url`, `selected_agents`, and `secret` are all optional inputs from +/// the UI/CLI: `None`/unspecified keeps the previous value, so re-running +/// `enroll` acts as an update. The selection diff uninstalls edison-watch from +/// agents dropped from the set. +pub async fn enroll( + user: &str, + url: String, + key: String, + mcp_base_url: Option, + selected_agents: Option>, + secret: Option, + install: bool, + armed: Option, +) -> anyhow::Result { + let existing = Enrollment::load_for(user)?; + + let client = BackendClient::new(url.clone(), key.clone()); + let fps = client + .fetch_fingerprints() + .await + .context("validating key / fetching org fingerprints")?; + let policy = client.fetch_policy().await.context("fetching policy")?; + let profile = client.fetch_profile().await.context("fetching profile")?; + + // A newly-provided/rotated secret must be registered (its hash) so the MCP + // gateway will accept the X-Edison-Secret-Key header we install. Done before + // save/install so a rejected key fails the whole enroll cleanly. + if let Some(sk) = &secret { + client + .register_secret_key(sk) + .await + .context("registering secret key")?; + } + + // Merge install inputs over the previous enrollment (enroll = update). + // Agents are ADDITIVE: `--agents` unions with the existing selection and + // never removes — removal happens only via `unenroll`. + let old_agents = existing + .as_ref() + .map(|e| e.selected_agents.clone()) + .unwrap_or_default(); + let new_agents = match selected_agents { + Some(provided) => { + let mut set = old_agents.clone(); + for a in provided { + if !set.contains(&a) { + set.push(a); + } + } + set + } + None => old_agents, + }; + let mcp_base_url = + mcp_base_url.or_else(|| existing.as_ref().and_then(|e| e.mcp_base_url.clone())); + let edison_secret_key = + secret.or_else(|| existing.as_ref().and_then(|e| e.edison_secret_key.clone())); + // `armed` is a straight set (not additive): the UI arms enforcement when + // onboarding completes; a missing value keeps the prior state. + let armed = armed.unwrap_or_else(|| existing.as_ref().map(|e| e.armed).unwrap_or(false)); + + let enrollment = Enrollment { + api_base_url: url, + api_key: key, + org_id: fps.org_id.clone(), + org_name: profile.domain.clone(), + email: profile.email.clone(), + role: profile.role.clone(), + quarantine: policy.quarantine, + mcp_base_url, + selected_agents: new_agents.clone(), + edison_secret_key, + armed, + }; + paths::ensure_user_dir(user)?; + enrollment.save_for(user)?; + + let mut seen = SeenStore::open(paths::seen_store_path(user), enrollment.org_id.clone())?; + let mut synced = std::collections::HashSet::new(); + for e in &fps.entries { + let action = match e.status { + KnownStatus::Requested => SeenAction::Requested, + KnownStatus::Registered => SeenAction::Registered, + }; + seen.mark_from_backend(&e.fingerprint, &e.name, action)?; + synced.insert(e.fingerprint.clone()); + } + seen.prune_backend(&synced)?; + + // Install the current (additive) set + hooks, unless the caller wants a + // detect-only enrollment (e.g. a client running its own install in parallel). + // Nothing is uninstalled here — that's `unenroll`'s job. + if install { + apply_install(user, &enrollment); + } + + status(user) +} + +/// The target user's home. For the current process user (user-mode dev build) +/// this honours `$HOME` via `dirs::home_dir`; only when the root daemon acts for +/// a *different* user do we resolve their home from `getpwnam`. +fn user_home(user: &str) -> std::path::PathBuf { + if user == paths::current_username() + && let Some(home) = dirs::home_dir() + { + return home; + } + platform::home_dir_for(user) + .or_else(dirs::home_dir) + .unwrap_or_default() +} + +/// Apply everything under the target user's home: the `edison-watch` MCP entry +/// for the *selected* agents, and hooks for *all installed* agents (as the app +/// does). Best-effort — a failure on one agent is logged, not fatal. +pub fn apply_install(user: &str, e: &Enrollment) { + let home = user_home(user); + install_edison_entries(user, e, &home); + apply_hooks(&home); +} + +fn install_edison_entries(user: &str, e: &Enrollment, home: &std::path::Path) { + let Some(mcp_base) = e.mcp_base_url.as_deref() else { + if !e.selected_agents.is_empty() { + tracing::warn!("agents selected but no mcp_base_url set — skipping edison-watch install"); + } + return; + }; + let secret = e.edison_secret_key.as_deref(); + for agent in agents::build() { + if !e.selected_agents.iter().any(|s| s == agent.name()) { + continue; + } + for inst in agent.edison_installs(home) { + // Claude Code goes through its own CLI (as the user); the file write + // is the fallback. + let done_via_cli = inst.prefer_cli && { + let url = mcp_quarantine::edison_url(mcp_base, &e.api_key, &inst.client_id); + match crate::claude_cli::install(user, &url, secret) { + Ok(()) => { + tracing::info!(agent = agent.name(), "installed edison-watch (via claude CLI)"); + true + } + Err(err) => { + tracing::warn!(agent = agent.name(), error = %err, "claude CLI failed; writing the file directly"); + false + } + } + }; + if !done_via_cli { + match mcp_quarantine::install_edison(&inst, mcp_base, &e.api_key, secret) { + Ok(()) => { + tracing::info!(agent = agent.name(), path = %inst.path.display(), "installed edison-watch") + } + Err(err) => { + tracing::warn!(agent = agent.name(), error = %err, "edison-watch install failed") + } + } + } + } + } +} + +/// Re-install the edison-watch entry for any *selected* agent where it's +/// currently missing — e.g. the user replaced/overwrote a config file, dropping +/// our entry. Only writes when the entry is absent (checked against live +/// discovery), so it never rewrites a config that already has it and can't loop +/// the fs watcher. Returns how many agents were (re-)installed. +pub fn heal_edison_install(user: &str, e: &Enrollment) -> usize { + let Some(mcp_base) = e.mcp_base_url.as_deref() else { + return 0; + }; + if e.selected_agents.is_empty() { + return 0; + } + let home = user_home(user); + let present: std::collections::HashSet<&str> = agents::discover_all(&agents::build()) + .iter() + .filter(|s| is_edison_entry(s)) + .map(|s| s.client) + .collect(); + let secret = e.edison_secret_key.as_deref(); + let mut healed = 0; + for agent in agents::build() { + if !e.selected_agents.iter().any(|s| s == agent.name()) { + continue; + } + if present.contains(agent.name()) { + continue; // already installed — don't rewrite (avoids fs-watch churn) + } + for inst in agent.edison_installs(&home) { + let done_via_cli = inst.prefer_cli && { + let url = mcp_quarantine::edison_url(mcp_base, &e.api_key, &inst.client_id); + crate::claude_cli::install(user, &url, secret).is_ok() + }; + if !done_via_cli { + let _ = mcp_quarantine::install_edison(&inst, mcp_base, &e.api_key, secret); + } + } + tracing::info!(agent = agent.name(), "re-installed missing edison-watch entry (self-heal)"); + healed += 1; + } + healed +} + +/// Materialise the hook scripts under `home/.edison-watch`, then inject hooks +/// into every *installed* agent that has a hook surface (matching the app). +fn apply_hooks(home: &std::path::Path) { + let scripts = match mcp_quarantine::ensure_scripts(&home.join(".edison-watch")) { + Ok(s) => s, + Err(err) => { + tracing::warn!(error = %err, "materialising hook scripts failed"); + return; + } + }; + for agent in agents::build() { + if !agent.is_installed() { + continue; + } + if let Some(hi) = agent.hook_install(home) { + match mcp_quarantine::inject_hooks(&hi, &scripts) { + Ok(true) => { + tracing::info!(agent = agent.name(), path = %hi.path.display(), "injected hooks") + } + Ok(false) => {} + Err(err) => { + tracing::warn!(agent = agent.name(), error = %err, "hook injection failed") + } + } + } + for tasks_json in agent.hook_workspace_targets(home) { + match mcp_quarantine::inject_workspace_task(&tasks_json, &scripts.registration) { + Ok(true) => { + tracing::info!(agent = agent.name(), path = %tasks_json.display(), "injected workspace task") + } + Ok(false) => {} + Err(err) => { + tracing::warn!(agent = agent.name(), error = %err, "workspace task injection failed") + } + } + } + } +} + +/// Remove the `edison-watch` entry from the given agents under `user`'s home. +fn remove_edison_for(user: &str, agents_to_remove: &[String]) { + let home = user_home(user); + for agent in agents::build() { + if !agents_to_remove.iter().any(|s| s == agent.name()) { + continue; + } + for inst in agent.edison_installs(&home) { + let res = if inst.prefer_cli { + crate::claude_cli::remove(user) + } else { + mcp_quarantine::uninstall_edison(&inst).map_err(Into::into) + }; + if let Err(err) = res { + tracing::warn!(agent = agent.name(), error = %err, "edison-watch uninstall failed"); + } + } + } +} + +/// Remove hooks from all agents that have a hook surface (full teardown). +fn remove_all_hooks_for(user: &str) { + let home = user_home(user); + for agent in agents::build() { + if let Some(hi) = agent.hook_install(&home) + && let Err(err) = mcp_quarantine::remove_hooks(&hi) + { + tracing::warn!(agent = agent.name(), error = %err, "hook removal failed"); + } + for tasks_json in agent.hook_workspace_targets(&home) { + if let Err(err) = mcp_quarantine::remove_workspace_task(&tasks_json) { + tracing::warn!(agent = agent.name(), error = %err, "workspace task removal failed"); + } + } + } +} + +/// Verify an existing key against the backend; on success adopt it (store + +/// re-install with it) without re-registering. The "enter your existing key" +/// path. Returns the verification result either way. +pub async fn verify_secret(user: &str, key: String) -> anyhow::Result { + let mut e = Enrollment::load_for(user)?.ok_or_else(|| anyhow::anyhow!("not enrolled"))?; + let client = BackendClient::new(e.api_base_url.clone(), e.api_key.clone()); + let result = client + .verify_secret_key(&key) + .await + .context("verifying secret key")?; + if result.valid { + e.edison_secret_key = Some(key); + e.save_for(user)?; + apply_install(user, &e); + } + Ok(result) +} + +/// Destructively reset to a new key (the backend deletes this user's encrypted +/// personal values), then adopt it (store + re-install). The "start fresh" path. +pub async fn reset_secret(user: &str, key: String) -> anyhow::Result { + let mut e = Enrollment::load_for(user)?.ok_or_else(|| anyhow::anyhow!("not enrolled"))?; + let client = BackendClient::new(e.api_base_url.clone(), e.api_key.clone()); + let result = client + .reset_secret_key(&key) + .await + .context("resetting secret key")?; + e.edison_secret_key = Some(key); + e.save_for(user)?; + apply_install(user, &e); + Ok(result) +} + +/// Remove `user`'s enrollment (uninstalling edison-watch first); returns the org +/// name if it was enrolled. +pub fn unenroll(user: &str) -> anyhow::Result> { + let removed = Enrollment::remove_for(user)?; + if let Some(e) = &removed { + remove_edison_for(user, &e.selected_agents); + remove_all_hooks_for(user); // full teardown removes hooks everywhere + } + Ok(removed.map(|e| e.org_name)) +} + +/// Dispose of a discovered, fingerprint-able server: send it to EW (submit + +/// remove) or skip (remove + mark dismissed). Both remove it locally +/// (quarantine-first). +pub async fn disposition( + user: &str, + name: &str, + agent: Option<&str>, + choice: Choice, + rename: Option<&str>, +) -> anyhow::Result<()> { + let e = Enrollment::load_for(user)?.ok_or_else(|| anyhow::anyhow!("not enrolled"))?; + + // Primary mode: the daemon already auto-quarantined it and the UI is now + // dispositioning. Act on the stored entry, not a (removed) discovered server. + if let Some(entry) = QuarantinedState::load_for(user)? + .entries + .iter() + .find(|x| x.name == name && agent.is_none_or(|a| x.agent == a)) + .cloned() + { + return disposition_quarantined(user, &e, &entry, choice, rename).await; + } + + let observed = agents::discover_all(&agents::build()); + let matches: Vec<_> = observed + .iter() + .filter(|s| { + s.name == name + && agent.is_none_or(|a| s.client == a) + && !is_edison_entry(s) + && fingerprint(&s.name, &s.config).is_some() + }) + .collect(); + let server = match matches.as_slice() { + [] => anyhow::bail!("no discovered actionable server named '{name}'"), + [only] => *only, + many => { + let ags: Vec<_> = many.iter().map(|s| s.client).collect(); + anyhow::bail!("'{name}' exists under multiple agents {ags:?}; specify agent"); + } + }; + let fp = fingerprint(&server.name, &server.config).expect("filtered to Some"); + + let mut seen = SeenStore::open(paths::seen_store_path(user), e.org_id.clone())?; + let action = match choice { + Choice::SendToEw => { + let name = rename.unwrap_or(&server.name); + submit_to_ew(&e, name, &server.config).await? + } + Choice::Skip => SeenAction::Dismissed, + }; + + let record = FileConfigStore + .quarantine(&server.location, &server.config) + .context("removing from local config")?; + seen.mark(&fp, &server.name, action)?; + + let mut q = QuarantinedState::load_for(user)?; + q.upsert(QuarantinedEntry { + name: server.name.clone(), + agent: server.client.to_string(), + fingerprint: fp, + config: Some(server.config.clone()), + record, + }); + q.save_for(user)?; + Ok(()) +} + +/// Dispose of an already-quarantined server (primary mode). It stays quarantined +/// locally either way; SendToEw submits its stored config to the backend and +/// marks it known, Skip marks it dismissed so it isn't re-prompted. +async fn disposition_quarantined( + user: &str, + e: &Enrollment, + entry: &QuarantinedEntry, + choice: Choice, + rename: Option<&str>, +) -> anyhow::Result<()> { + let mut seen = SeenStore::open(paths::seen_store_path(user), e.org_id.clone())?; + let action = match choice { + Choice::SendToEw => { + let config = entry.config.clone().ok_or_else(|| { + anyhow::anyhow!("no stored config for '{}' — cannot send to EW", entry.name) + })?; + // Submit under the (possibly renamed) name, but keep marking the + // *original* fingerprint known so the still-local server is silently + // re-quarantined instead of re-prompting. + let name = rename.unwrap_or(&entry.name); + submit_to_ew(e, name, &config).await? + } + Choice::Skip => SeenAction::Dismissed, + }; + seen.mark(&entry.fingerprint, &entry.name, action)?; + tracing::info!(server = %entry.name, agent = %entry.agent, ?choice, rename, "disposition applied"); + Ok(()) +} + +/// Submit a config to the backend under `name`, templatizing detected secrets +/// first so raw credentials never leave the machine. Returns the seen-store +/// action (Registered for owner/admin, else Requested). A backend 409 is +/// surfaced as a `conflict:`-prefixed error so the UI can offer a rename. +async fn submit_to_ew( + e: &Enrollment, + name: &str, + config: &ServerConfig, +) -> anyhow::Result { + let register = matches!(e.role.as_str(), "owner" | "admin"); + let config = mcp_detector_lib::secret_detection::templatize_for_fingerprint(config); + let res = BackendClient::new(e.api_base_url.clone(), e.api_key.clone()) + .submit(&SubmitRequest { + name: name.to_string(), + config, + register, + }) + .await; + match res { + Ok(()) if register => Ok(SeenAction::Registered), + Ok(()) => Ok(SeenAction::Requested), + Err(err) if is_conflict(&err) => { + anyhow::bail!("conflict: '{name}' is already registered at Edison Watch") + } + Err(err) => Err(anyhow::Error::new(err).context("submitting to backend")), + } +} + +/// Whether a backend error is a 409 name conflict. +fn is_conflict(err: &BackendError) -> bool { + matches!(err, BackendError::Status { status, .. } if status.as_u16() == 409) +} diff --git a/crates/mcp_detector_daemon/src/paths.rs b/crates/mcp_detector_daemon/src/paths.rs new file mode 100644 index 0000000..220d6e3 --- /dev/null +++ b/crates/mcp_detector_daemon/src/paths.rs @@ -0,0 +1,86 @@ +//! Where the daemon keeps its state. +//! +//! The base is **mode-aware**: a root-owned system location when running as the +//! privileged daemon, or the invoking user's config dir in the dev build. State +//! is multi-user — enrollments are keyed by OS user and per-user state lives +//! under `users//` — so the same layout works for one dev user or many +//! real users under root. +//! +//! ```text +//! /enrollments.json keyed by OS user +//! /users//seen.json +//! /users//quarantined.json +//! /state.json status + liveness +//! ``` + +use std::io; +use std::path::PathBuf; + +const DIR_NAME: &str = "edison-watch-detectord"; + +/// True when running as the privileged system daemon (euid 0). +pub fn is_root() -> bool { + // SAFETY: geteuid has no preconditions and no failure mode. + unsafe { geteuid() == 0 } +} + +/// Root of all daemon state: system-level under root, the user's config dir +/// otherwise (dev build). +pub fn base_dir() -> PathBuf { + if is_root() { + PathBuf::from("/Library/Application Support").join(DIR_NAME) + } else { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(DIR_NAME) + } +} + +/// The multi-user enrollment map (`{ os_user: Enrollment }`). +pub fn enrollments_path() -> PathBuf { + base_dir().join("enrollments.json") +} + +/// Per-OS-user state directory. +pub fn user_dir(user: &str) -> PathBuf { + base_dir().join("users").join(user) +} + +pub fn seen_store_path(user: &str) -> PathBuf { + user_dir(user).join("seen.json") +} + +pub fn quarantined_path(user: &str) -> PathBuf { + user_dir(user).join("quarantined.json") +} + +#[allow(dead_code)] // written by the status/liveness reporter (next sub-part) +pub fn state_json_path() -> PathBuf { + base_dir().join("state.json") +} + +pub fn ensure_base_dir() -> io::Result<()> { + std::fs::create_dir_all(base_dir()) +} + +pub fn ensure_user_dir(user: &str) -> io::Result<()> { + std::fs::create_dir_all(user_dir(user)) +} + +/// The OS user this process is running as — the dev build's single tenant. +/// (Under root, per-connection users come from `getpeereid`, not this.) +pub fn current_username() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .unwrap_or_else(|_| "unknown".to_string()) +} + +/// `~/.edison-watch` — where hook scripts and the pending/errors dirs live +/// (shared, app-compatible location). Dev build: the current user's home. +pub fn edison_watch_dir() -> Option { + dirs::home_dir().map(|h| h.join(".edison-watch")) +} + +unsafe extern "C" { + fn geteuid() -> u32; +} diff --git a/crates/mcp_detector_daemon/src/permission.rs b/crates/mcp_detector_daemon/src/permission.rs deleted file mode 100644 index 0df24bb..0000000 --- a/crates/mcp_detector_daemon/src/permission.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Full-Disk-Access detection. macOS's TCC framework exposes no query API — -//! the canonical workaround is to attempt a read of a TCC-protected path and -//! interpret EPERM as "denied". - -use std::fs::File; -use std::path::{Path, PathBuf}; - -/// Default probe path: the system's TCC database. Any read of this file from -/// an unprivileged process is gated by Full Disk Access. -pub fn default_probe_path() -> PathBuf { - let base = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/Users")); - base.join("Library/Application Support/com.apple.TCC/TCC.db") -} - -/// Attempt to open the probe path for reading. Returns true if the open -/// succeeds (FDA granted) and false on `EPERM` / `EACCES` / `ENOENT`. -pub fn check(probe_path: &Path) -> bool { - match File::open(probe_path) { - Ok(_) => true, - Err(e) => { - tracing::debug!(error = %e, path = %probe_path.display(), "FDA probe failed"); - false - } - } -} diff --git a/crates/mcp_detector_daemon/src/platform.rs b/crates/mcp_detector_daemon/src/platform.rs new file mode 100644 index 0000000..fabf466 --- /dev/null +++ b/crates/mcp_detector_daemon/src/platform.rs @@ -0,0 +1,74 @@ +//! Privilege helpers used by the root build. +//! +//! When the daemon writes files into a user's home (the `disabled_` +//! sidecar and the `.ew-backup`) it runs as root, so those new files are +//! root-owned. We `chown` them back to the owning user so they behave like the +//! user's own files. In-place config edits and dir renames preserve ownership, +//! so only the newly-created files need this. + +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +/// Resolve a uid to its OS user name via `getpwuid`. Used to scope an IPC +/// connection to the peer's user (from the socket's peer credentials). +pub fn username_for_uid(uid: u32) -> Option { + // SAFETY: getpwuid returns a pointer into a static buffer valid until the + // next passwd call; we copy the name out immediately. + unsafe { + let pw = libc::getpwuid(uid); + if pw.is_null() { + None + } else { + Some( + std::ffi::CStr::from_ptr((*pw).pw_name) + .to_string_lossy() + .into_owned(), + ) + } + } +} + +/// Resolve an OS user name to its home dir via `getpwnam` (`pw_dir`). Used to +/// target the correct user's home when the root daemon writes installs/hooks. +pub fn home_dir_for(user: &str) -> Option { + let cname = std::ffi::CString::new(user).ok()?; + // SAFETY: getpwnam returns a pointer into a static buffer valid until the + // next passwd call; we copy pw_dir out immediately. + unsafe { + let pw = libc::getpwnam(cname.as_ptr()); + if pw.is_null() || (*pw).pw_dir.is_null() { + None + } else { + let bytes = std::ffi::CStr::from_ptr((*pw).pw_dir).to_bytes(); + Some(std::path::PathBuf::from(std::ffi::OsStr::from_bytes(bytes))) + } + } +} + +/// Resolve an OS user name to its `(uid, gid)` via `getpwnam`. +pub fn uid_gid_for(user: &str) -> Option<(u32, u32)> { + let cname = std::ffi::CString::new(user).ok()?; + // SAFETY: getpwnam returns a pointer into a static buffer valid until the + // next passwd call; we read its fields immediately and copy them out. + unsafe { + let pw = libc::getpwnam(cname.as_ptr()); + if pw.is_null() { + None + } else { + Some(((*pw).pw_uid, (*pw).pw_gid)) + } + } +} + +/// `chown(path, uid, gid)`. Errors are the caller's to log (best-effort). +pub fn chown(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> { + let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::other("path contains NUL"))?; + // SAFETY: cpath is a valid NUL-terminated C string for the duration of the call. + let rc = unsafe { libc::chown(cpath.as_ptr(), uid, gid) }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} diff --git a/crates/mcp_detector_daemon/src/protocol.rs b/crates/mcp_detector_daemon/src/protocol.rs index 0e3ede1..c6951d7 100644 --- a/crates/mcp_detector_daemon/src/protocol.rs +++ b/crates/mcp_detector_daemon/src/protocol.rs @@ -1,65 +1,213 @@ -//! Wire protocol between the daemon and its clients (e.g. the Edison Watch -//! Electron supervisor). Newline-delimited JSON over a Unix domain socket. +//! The daemon↔UI wire protocol: newline-delimited JSON over a Unix socket. +//! +//! The connecting OS user is derived from the socket's peer credentials +//! (`getpeereid`), never from the message — so a request is always scoped to +//! the uid the kernel reports, which the UI cannot spoof. use serde::{Deserialize, Serialize}; -/// Client → daemon requests. -#[derive(Debug, Clone, Deserialize, Serialize)] +use mcp_detector_lib::ServerConfig; + +fn default_true() -> bool { + true +} + +/// UI → daemon requests. +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "op", rename_all = "snake_case")] pub enum Request { - /// Return the daemon's current state. - Status, - /// Force an immediate FDA recheck. - RecheckFda, + /// Enroll this user (online handshake: validate key, cache policy + known set). + Enroll { + url: String, + key: String, + #[serde(default)] + mcp_url: Option, + /// `None` keeps the previous selection; `Some` replaces it. + #[serde(default)] + agents: Option>, + /// `None` keeps the previous secret. + #[serde(default)] + secret: Option, + /// Apply the edison-watch install + hooks (default). Set false for a + /// detect-only enrollment (caller does its own install). + #[serde(default = "default_true")] + install: bool, + /// Arm automatic quarantine enforcement. `None` keeps the prior state; + /// the UI sets `true` once onboarding completes so the daemon stays + /// detect-only while the user is still reviewing during setup. + #[serde(default)] + armed: Option, + }, + /// Enrollment + cached policy. `refresh` re-fetches the policy first. + Status { + #[serde(default)] + refresh: bool, + }, + /// Which agents (host apps) are present. + ListAgents, + /// Discovered servers, classified. + ListServers, + /// Dispose of a discovered server. + Disposition { + name: String, + #[serde(default)] + agent: Option, + choice: Choice, + /// For SendToEw: submit under this name instead (rename-on-conflict). + #[serde(default)] + rename: Option, + }, + /// Force a policy + known-set refresh. + RefreshPolicy, + /// Verify an existing secret key; adopt (install) it if valid. + VerifySecret { key: String }, + /// Destructively reset to a new secret key (deletes encrypted personal + /// values), then install it. `confirm` must be true. + ResetSecret { + key: String, + #[serde(default)] + confirm: bool, + }, + /// Remove this user's enrollment. + Unenroll, } -/// Daemon → client messages. Either a direct reply to a [`Request`], or an -/// unsolicited push (e.g. a watcher event). -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum Message { - Status(StatusReply), +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Choice { + /// Submit to Edison Watch (register/request) + remove locally. + SendToEw, + /// Leave quarantined; don't re-prompt. + Skip, +} + +/// daemon → UI replies (a direct answer to a [`Request`]). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "reply", rename_all = "snake_case")] +pub enum Reply { + Status(Status), + // Struct-shaped (not `Vec` newtypes): an internally-tagged enum can't hold a + // bare sequence. + Agents { agents: Vec }, + Servers { servers: Vec }, + Secret(SecretOutcome), Ack, - Event(WatcherEvent), - Error(ErrorReply), + Error { message: String }, } -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct StatusReply { - pub state: DaemonState, - pub clients_watched: Vec, - pub socket_path: String, - pub version: String, +/// Outcome of a verify (`valid`/`expired` set) or reset (`deleted` set). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecretOutcome { + #[serde(default)] + pub valid: Option, + #[serde(default)] + pub expired: Option, + #[serde(default)] + pub deleted: Option, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum DaemonState { - /// Boot, no FDA probe completed yet. - Starting, - /// FDA missing; daemon is polling for it. - AwaitingFda, - /// FDA granted; watcher running. - Running, +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Status { + pub user: String, + pub enrolled: bool, + pub org_id: Option, + pub org_name: Option, + pub email: Option, + pub role: Option, + pub quarantine: bool, + pub quarantined_count: usize, + /// Whether automatic quarantine enforcement is armed (onboarding complete). + #[serde(default)] + pub armed: bool, } -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct WatcherEvent { - pub change: ChangeKind, - pub server_name: String, - pub client: String, - pub scope: String, - pub transport: String, +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentInfo { + pub name: String, + pub installed: bool, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ChangeKind { - Added, - Removed, +/// One discovered server instance (not deduped — carries its source path). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerView { + pub name: String, + pub agent: String, + /// `stdio` | `http` | `opaque`. + pub kind: String, + /// `edison` | `known` | `new` | `opaque` | `report`. + pub state: String, + pub fingerprint: Option, + pub path: String, + /// The server's launch config, so a UI (e.g. onboarding) can render and act + /// on it without re-discovering locally. `None` on older/omitted views. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +/// daemon → UI unsolicited pushes. (Delivery is wired with the supervisor; +/// defined here so the contract is fixed.) +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum Event { + Quarantined(ServerView), + Discovered(ServerView), + PolicyChanged { quarantine: bool }, } -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ErrorReply { - pub message: String, +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enroll_armed_defaults_to_none_and_parses() { + // Onboarding completion arms enforcement. + let r: Request = serde_json::from_str( + r#"{"op":"enroll","url":"u","key":"k","armed":true}"#, + ) + .unwrap(); + match r { + Request::Enroll { armed, install, .. } => { + assert_eq!(armed, Some(true)); + assert!(install, "install still defaults true"); + } + other => panic!("wrong variant: {other:?}"), + } + // A base enroll (onboarding not complete) can omit it → None (keep prior). + let r: Request = + serde_json::from_str(r#"{"op":"enroll","url":"u","key":"k"}"#).unwrap(); + match r { + Request::Enroll { armed, .. } => assert_eq!(armed, None), + other => panic!("wrong variant: {other:?}"), + } + } + + #[test] + fn disposition_parses_with_and_without_rename() { + // Client sends a rename on conflict resubmit. + let r: Request = serde_json::from_str( + r#"{"op":"disposition","name":"foo","agent":"cursor","choice":"send_to_ew","rename":"foo2"}"#, + ) + .unwrap(); + match r { + Request::Disposition { name, agent, choice, rename } => { + assert_eq!(name, "foo"); + assert_eq!(agent.as_deref(), Some("cursor")); + assert!(matches!(choice, Choice::SendToEw)); + assert_eq!(rename.as_deref(), Some("foo2")); + } + other => panic!("wrong variant: {other:?}"), + } + + // Older / no-rename clients still parse (serde default). + let r: Request = + serde_json::from_str(r#"{"op":"disposition","name":"bar","choice":"skip"}"#).unwrap(); + match r { + Request::Disposition { rename, choice, .. } => { + assert!(rename.is_none()); + assert!(matches!(choice, Choice::Skip)); + } + other => panic!("wrong variant: {other:?}"), + } + } } diff --git a/crates/mcp_detector_daemon/src/quarantined.rs b/crates/mcp_detector_daemon/src/quarantined.rs new file mode 100644 index 0000000..08068a6 --- /dev/null +++ b/crates/mcp_detector_daemon/src/quarantined.rs @@ -0,0 +1,71 @@ +//! Local record of what the dev daemon has quarantined, so `list` can show it +//! and `restore` can undo it across process invocations. + +use serde::{Deserialize, Serialize}; + +use mcp_detector_lib::ServerConfig; +use mcp_quarantine::QuarantineRecord; + +use crate::paths; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuarantinedEntry { + pub name: String, + pub agent: String, + pub fingerprint: String, + pub record: QuarantineRecord, + /// The server's launch config, kept so a post-quarantine "send to EW" + /// disposition can submit it. `None` for older entries / opaque servers. + #[serde(default)] + pub config: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct QuarantinedState { + pub entries: Vec, +} + +impl QuarantinedState { + pub fn load_for(user: &str) -> anyhow::Result { + let path = paths::quarantined_path(user); + if !path.exists() { + return Ok(Self::default()); + } + Ok(serde_json::from_str(&std::fs::read_to_string(&path)?)?) + } + + pub fn save_for(&self, user: &str) -> anyhow::Result<()> { + paths::ensure_user_dir(user)?; + std::fs::write( + paths::quarantined_path(user), + serde_json::to_string_pretty(self)?, + )?; + Ok(()) + } + + /// Add (or replace) a quarantined entry, keyed by its **physical location** + /// (source file + nested key + server key). Keying by fingerprint would + /// collapse a server that lives in several configs into one record, so only + /// one would be restorable. + pub fn upsert(&mut self, entry: QuarantinedEntry) { + let loc = |e: &QuarantinedEntry| { + ( + e.record.source_path.clone(), + e.record.key_path.clone(), + e.record.server_key.clone(), + ) + }; + let key = loc(&entry); + self.entries.retain(|e| loc(e) != key); + self.entries.push(entry); + } + + /// Remove and return the entry matching `name` or `fingerprint`. + pub fn take(&mut self, needle: &str) -> Option { + let idx = self + .entries + .iter() + .position(|e| e.name == needle || e.fingerprint == needle)?; + Some(self.entries.remove(idx)) + } +} diff --git a/crates/mcp_detector_daemon/src/recovery.rs b/crates/mcp_detector_daemon/src/recovery.rs new file mode 100644 index 0000000..5cd0785 --- /dev/null +++ b/crates/mcp_detector_daemon/src/recovery.rs @@ -0,0 +1,138 @@ +//! Disk-based recovery: reverse quarantines by scanning the on-disk artifacts +//! (`disabled_.json` sidecars and `ew-disabled-*` dirs) rather than the +//! tracked state. Idempotent; useful after any interrupted/untracked run. + +use std::path::{Path, PathBuf}; + +use mcp_detector_lib::{LocationExtra, SourceKind}; +use mcp_quarantine::{ConfigStore, FileConfigStore, QuarantineRecord}; + +use crate::agents; + +/// Restore everything quarantined on disk. Returns `(servers, plugin_dirs)`. +pub fn recover() -> (usize, usize) { + // Scan every agent's watch locations (file parents + dirs) for artifacts. + let mut roots: Vec = Vec::new(); + for a in agents::build() { + let wt = a.watch_targets(); + for f in wt.files { + if let Some(p) = f.parent() { + roots.push(p.to_path_buf()); + } + } + for d in wt.dirs { + roots.push(d.path); + } + } + roots.sort(); + roots.dedup(); + + let mut sidecars = Vec::new(); + let mut disabled_dirs = Vec::new(); + for root in &roots { + collect(root, 0, 6, &mut sidecars, &mut disabled_dirs); + } + sidecars.sort(); + sidecars.dedup(); + disabled_dirs.sort(); + disabled_dirs.dedup(); + + let store = FileConfigStore; + let servers = sidecars.iter().map(|s| restore_sidecar(&store, s)).sum(); + let dirs = disabled_dirs + .iter() + .filter(|d| restore_dir(&store, d)) + .count(); + (servers, dirs) +} + +/// Recursively collect `disabled_*.json` files and `ew-disabled-*` dirs. +fn collect(dir: &Path, depth: usize, max: usize, sidecars: &mut Vec, dirs: &mut Vec) { + if depth > max { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for e in entries.flatten() { + let path = e.path(); + let name = e.file_name().to_string_lossy().into_owned(); + if path.is_dir() { + if name.starts_with("ew-disabled-") { + dirs.push(path.clone()); + } + collect(&path, depth + 1, max, sidecars, dirs); + } else if (name.starts_with("ewd-disabled_") || name.starts_with("disabled_")) + && name.ends_with(".json") + { + // Both our current prefix and the legacy `disabled_`; the + // `_edisonOriginalFile` metadata filter in restore_sidecar keeps us + // from touching the Electron app's own entries. + sidecars.push(path); + } + } +} + +/// Restore every server recorded in one `disabled_.json` sidecar, using +/// the `_edisonOriginalFile` / `_edisonKeyPath` metadata each entry carries. +fn restore_sidecar(store: &FileConfigStore, sidecar: &Path) -> usize { + let Ok(text) = std::fs::read_to_string(sidecar) else { + return 0; + }; + let Ok(value) = serde_json::from_str::(&text) else { + return 0; + }; + let Some(servers) = value.get("servers").and_then(|s| s.as_object()) else { + return 0; + }; + + // Collect targets first (restore mutates the sidecar file per call). + let targets: Vec<(String, PathBuf, Vec)> = servers + .iter() + .filter_map(|(key, entry)| { + let orig = entry.get("_edisonOriginalFile")?.as_str()?; + let key_path = entry + .get("_edisonKeyPath")? + .as_array()? + .iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect(); + Some((key.clone(), PathBuf::from(orig), key_path)) + }) + .collect(); + + targets + .into_iter() + .filter(|(server_key, source, key_path)| { + let rec = QuarantineRecord { + kind: SourceKind::Jsonc, + source_path: source.clone(), + disabled_path: sidecar.to_path_buf(), + backup_path: sidecar.to_path_buf(), + key_path: key_path.clone(), + server_key: server_key.clone(), + extra: LocationExtra::None, + }; + store.restore(&rec).is_ok() + }) + .count() +} + +/// Restore one `ew-disabled-` plugin dir (rename back, or drop if the live +/// dir already exists). +fn restore_dir(store: &FileConfigStore, disabled: &Path) -> bool { + let Some(name) = disabled.file_name().map(|n| n.to_string_lossy().into_owned()) else { + return false; + }; + let orig = name.strip_prefix("ew-disabled-").unwrap_or(&name); + let rec = QuarantineRecord { + kind: SourceKind::CursorPluginDir, + source_path: disabled.with_file_name(orig), + disabled_path: disabled.to_path_buf(), + backup_path: disabled.to_path_buf(), + key_path: Vec::new(), + server_key: orig.to_string(), + extra: LocationExtra::None, + }; + store.restore(&rec).is_ok() +} diff --git a/crates/mcp_detector_daemon/src/runner.rs b/crates/mcp_detector_daemon/src/runner.rs new file mode 100644 index 0000000..77a2f9b --- /dev/null +++ b/crates/mcp_detector_daemon/src/runner.rs @@ -0,0 +1,460 @@ +//! The reconcile loop (user-mode dev build). +//! +//! Level-triggered: each pass discovers all servers, plans against the +//! seen-store + policy, and (when enforcing) quarantines. Periodically refreshes +//! policy + known-set from the backend, fail-closed (a failed fetch keeps the +//! last-known-good). Replaces the old FDA-gated watcher; the privileged version +//! adds fs-event triggering, per-user workers, and IPC on top of this shape. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use mcp_backend::{BackendClient, KnownStatus}; +use mcp_detector_lib::{Agent, DiscoveredServer, ServerConfig, fingerprint}; +use mcp_quarantine::{ + Action as SeenAction, ConfigStore, FileConfigStore, Policy, QuarantineRecord, ReconcileAction, + SeenStore, is_edison_entry, plan, +}; +use notify::RecursiveMode; +use notify_debouncer_full::{DebounceEventResult, new_debouncer}; +use tokio::sync::broadcast; + +use crate::agents; +use crate::enrollment::Enrollment; +use crate::protocol::{Event, ServerView}; +use crate::quarantined::{QuarantinedEntry, QuarantinedState}; +use crate::{paths, platform}; + +/// Broadcast channel of `(os_user, event)` — workers publish, IPC connections +/// forward the events matching their peer user. +pub type EventTx = broadcast::Sender<(String, Event)>; + +/// Periodic safety-net rescan — catches sources that mutate without firing fs +/// events (SQLite state DBs, extension-API installs). +const RESCAN_INTERVAL: Duration = Duration::from_secs(20); +/// How often to re-fetch policy + known-set (fail-closed). +const REFRESH_INTERVAL: Duration = Duration::from_secs(300); +/// Debounce window coalescing rapid fs events into one reconcile. +const DEBOUNCE: Duration = Duration::from_millis(500); + +/// Run the reconcile loop for the current OS user until Ctrl-C. `enforce=false` +/// is a dry run. +pub async fn run(enforce: bool) -> anyhow::Result<()> { + let user = paths::current_username(); + tokio::select! { + r = worker(user, enforce, None) => r, + _ = tokio::signal::ctrl_c() => { + tracing::info!("stopping"); + Ok(()) + } + } +} + +/// Level-triggered reconcile worker for one OS user: reconcile on fs events +/// (debounced/coalesced) and on a periodic safety-net tick, refreshing policy +/// on its own cadence. Loops until the task is dropped/aborted. When `events` is +/// set, publishes what it quarantines. The supervisor spawns one per enrolled +/// user. +pub async fn worker( + user: String, + enforce: bool, + events: Option, +) -> anyhow::Result<()> { + paths::ensure_user_dir(&user)?; + let mut enrollment = Enrollment::load_for(&user)? + .ok_or_else(|| anyhow::anyhow!("not enrolled; run `enroll` first"))?; + let client = BackendClient::new(enrollment.api_base_url.clone(), enrollment.api_key.clone()); + let mut seen = SeenStore::open(paths::seen_store_path(&user), enrollment.org_id.clone())?; + refresh(&client, &mut enrollment, &mut seen, &user).await; + + let agents = agents::build(); + let store = FileConfigStore; + let mut qstate = QuarantinedState::load_for(&user)?; + + // fs-event triggering. `tx` (held for the loop) keeps the channel open so + // `rx.recv()` pends forever even if the watcher fails to start. + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + let _debouncer = start_watcher(&agents, tx.clone()); + let watching = _debouncer.is_some(); + + tracing::info!( + user = %user, + org = %enrollment.org_id, + quarantine = enrollment.quarantine, + enforce, + agents = agents.len(), + watching, + "reconcile worker starting (Ctrl-C to stop)" + ); + + let mut last_refresh = Instant::now(); + let mut reported = HashSet::new(); + loop { + // Re-read the enrollment from disk each pass so an onboarding-completion + // re-enroll takes effect without restarting the worker: it arms + // enforcement AND fills in selected_agents / mcp_base_url / secret (the + // login enroll started empty). Until armed the daemon is detect-only — + // lists/reports but quarantines nothing — so onboarding can review + + // send-to-EW first. Policy (`quarantine`) is refreshed from the backend + // separately and persisted, so reading it back from disk keeps + // last-known-good. + if let Ok(Some(fresh)) = Enrollment::load_for(&user) { + if fresh.armed != enrollment.armed { + tracing::info!(armed = fresh.armed, "enforcement armed state changed"); + } + if fresh.selected_agents != enrollment.selected_agents { + tracing::info!(agents = ?fresh.selected_agents, "selected agents changed"); + } + enrollment = fresh; + } + reconcile_once( + &agents, + &mut seen, + enrollment.quarantine, + &store, + &mut qstate, + enforce && enrollment.armed, + &user, + events.as_ref(), + &mut reported, + ); + // Self-heal: if a config was overwritten and dropped our edison-watch + // entry, put it back. Only while enforcing + armed (i.e. we own the + // install); a no-op when the entry is already present, so no fs loop. + if enforce && enrollment.armed { + let healed = crate::ops::heal_edison_install(&user, &enrollment); + if healed > 0 { + tracing::info!(count = healed, "self-healed edison-watch install"); + } + } + if last_refresh.elapsed() >= REFRESH_INTERVAL { + refresh(&client, &mut enrollment, &mut seen, &user).await; + last_refresh = Instant::now(); + } + // Stops when the task is dropped (Ctrl-C in `run`, or the supervisor + // aborting the worker). + tokio::select! { + _ = rx.recv() => { + while rx.try_recv().is_ok() {} // coalesce queued triggers + tracing::debug!("fs change; reconciling"); + } + _ = tokio::time::sleep(RESCAN_INTERVAL) => {} + } + } +} + +/// Watch every agent's targets and signal `tx` (debounced) on any change. +/// Returns the debouncer, which must be held alive to keep watching. +fn start_watcher( + agents: &[Arc], + tx: tokio::sync::mpsc::UnboundedSender<()>, +) -> Option> +{ + // Files → watch their parent dir non-recursively (editors write via atomic + // rename); dirs (workspace storage, plugin caches) → recursively. + let mut file_dirs = HashSet::new(); + let mut rec_dirs = HashSet::new(); + for a in agents { + let wt = a.watch_targets(); + for f in wt.files { + if let Some(p) = f.parent() { + file_dirs.insert(p.to_path_buf()); + } + } + for d in wt.dirs { + rec_dirs.insert(d.path); + } + } + + let mut deb = new_debouncer(DEBOUNCE, None, move |res: DebounceEventResult| { + if res.is_ok() { + let _ = tx.send(()); + } + }) + .map_err(|e| tracing::warn!(error = %e, "fs watcher setup failed; periodic rescan only")) + .ok()?; + + for dir in &file_dirs { + if dir.exists() { + let _ = deb.watch(dir, RecursiveMode::NonRecursive); + } + } + for dir in &rec_dirs { + if dir.exists() { + let _ = deb.watch(dir, RecursiveMode::Recursive); + } + } + Some(deb) +} + +/// Publish a `Quarantined` event for `user` (no-op when there's no channel). +fn publish(events: Option<&EventTx>, user: &str, server: &DiscoveredServer, state: &str) { + if let Some(tx) = events { + let _ = tx.send((user.to_string(), Event::Quarantined(event_view(server, state)))); + } +} + +fn event_view(s: &DiscoveredServer, state: &str) -> ServerView { + let kind = match &s.config { + ServerConfig::Stdio { .. } => "stdio", + ServerConfig::Http { .. } => "http", + ServerConfig::Opaque { .. } => "opaque", + }; + ServerView { + name: s.name.clone(), + agent: s.client.to_string(), + kind: kind.to_string(), + state: state.to_string(), + fingerprint: fingerprint(&s.name, &s.config), + path: s.location.path.display().to_string(), + // Events stay lean; a UI that needs the config reads it from list_servers. + config: None, + } +} + +/// chown newly-created quarantine files (sidecar + backup) back to the owning +/// user. Only meaningful under root; a no-op in the dev build. +fn chown_new_files(record: &QuarantineRecord, user: &str) { + if !paths::is_root() { + return; + } + let Some((uid, gid)) = platform::uid_gid_for(user) else { + tracing::warn!(user, "could not resolve uid for chown"); + return; + }; + for p in [&record.disabled_path, &record.backup_path] { + if p.exists() { + let _ = platform::chown(p, uid, gid); + } + } +} + +/// Refresh policy + known fingerprints into the enrollment/seen-store. +/// Fail-closed: on any error the cached values are kept, never downgraded. +pub async fn refresh( + client: &BackendClient, + enrollment: &mut Enrollment, + seen: &mut SeenStore, + user: &str, +) { + match client.fetch_policy().await { + Ok(p) => { + if p.quarantine != enrollment.quarantine { + tracing::info!(quarantine = p.quarantine, "policy updated"); + } + enrollment.quarantine = p.quarantine; + let _ = enrollment.save_for(user); + } + Err(e) => tracing::warn!(error = %e, "policy refresh failed; keeping last-known-good"), + } + + match client.fetch_fingerprints().await { + Ok(fps) if fps.org_id == enrollment.org_id => { + let mut synced = HashSet::new(); + for e in &fps.entries { + let action = match e.status { + KnownStatus::Requested => SeenAction::Requested, + KnownStatus::Registered => SeenAction::Registered, + }; + let _ = seen.mark_from_backend(&e.fingerprint, &e.name, action); + synced.insert(e.fingerprint.clone()); + } + let _ = seen.prune_backend(&synced); + } + Ok(_) => tracing::warn!("fingerprints org mismatch; skipping sync"), + Err(e) => tracing::warn!(error = %e, "fingerprints refresh failed; keeping last-known-good"), + } +} + +#[allow(clippy::too_many_arguments)] +fn reconcile_once( + agents: &[Arc], + seen: &mut SeenStore, + quarantine: bool, + store: &FileConfigStore, + qstate: &mut QuarantinedState, + enforce: bool, + user: &str, + events: Option<&EventTx>, + reported: &mut HashSet, +) { + let observed = agents::discover_all(agents); + + // Notify the UI of report-only servers (edge-triggered, once each): non-edison + // servers we won't quarantine — everything when the policy is off, plus + // untouchable-opaque servers when it's on. Actioned servers get their own + // Quarantined event instead. + if let Some(tx) = events { + for s in &observed { + let report_only = !is_edison_entry(s) + && (!quarantine + || matches!(&s.config, ServerConfig::Opaque { removable: false, .. })); + if report_only { + let key = format!("{}\u{1f}{}\u{1f}{}", s.client, s.name, s.location.path.display()); + if reported.insert(key) { + let _ = tx.send((user.to_string(), Event::Discovered(event_view(s, "report")))); + } + } + } + } + + // Servers the pass deliberately leaves alone, with the reason (logged last): + // our own entry, and *untouchable* opaque servers. Removable-opaque and + // fingerprint-able servers are actioned, not skipped. Deduped by + // (name, agent, fingerprint) since a report-only server is often discovered + // from many project/plugin sources. + let mut seen_skip = HashSet::new(); + let skips: Vec = observed + .iter() + .filter_map(|s| { + let skip = if is_edison_entry(s) { + Skip::edison(s) + } else if matches!( + &s.config, + ServerConfig::Opaque { + removable: false, + .. + } + ) { + Skip::untouchable(s) + } else { + return None; + }; + seen_skip + .insert((skip.name.clone(), skip.agent, skip.fingerprint.clone())) + .then_some(skip) + }) + .collect(); + + if !quarantine { + tracing::info!( + discovered = observed.len(), + skipped = skips.len(), + "policy OFF: inert (report only)" + ); + log_skips(&skips); + return; + } + + let actions = plan(&observed, seen, Policy { quarantine }); + let known = count(&actions, |a| matches!(a, ReconcileAction::SilentQuarantine { .. })); + let opaque = count(&actions, |a| matches!(a, ReconcileAction::RemoveOpaque { .. })); + tracing::info!( + discovered = observed.len(), + will_quarantine = actions.len(), + known, + new = actions.len() - known - opaque, + opaque_removals = opaque, + will_skip = skips.len(), + "reconcile" + ); + + for action in actions { + // Opaque removals: neutralise locally, no fingerprint / disposition. + if let ReconcileAction::RemoveOpaque { server } = &action { + if !enforce { + tracing::debug!(server = %server.name, agent = server.client, "[dry-run] would remove (opaque, cannot send to EW)"); + continue; + } + match store.quarantine(&server.location, &server.config) { + Ok(record) => { + chown_new_files(&record, user); + publish(events, user, server, "removed"); + tracing::info!(server = %server.name, agent = server.client, "removed (opaque, cannot send to EW)"); + qstate.upsert(QuarantinedEntry { + name: server.name.clone(), + agent: server.client.to_string(), + // Path-keyed so each plugin dir is a distinct record + // (the dir name repeats across projects). + fingerprint: format!("opaque:{}", server.location.path.display()), + config: None, // opaque: can't be submitted to EW + record, + }); + let _ = qstate.save_for(user); + } + Err(e) => tracing::warn!(server = %server.name, error = %e, "opaque removal failed"), + } + continue; + } + + let (server, fp, known) = match action { + ReconcileAction::SilentQuarantine { + server, + fingerprint, + } => (server, fingerprint, true), + ReconcileAction::QuarantineAndPrompt { + server, + fingerprint, + } => (server, fingerprint, false), + ReconcileAction::RemoveOpaque { .. } => unreachable!("handled above"), + }; + + if !enforce { + tracing::debug!(server = %server.name, agent = server.client, known, fingerprint = %fp, "would quarantine (dry-run)"); + continue; + } + match store.quarantine(&server.location, &server.config) { + Ok(record) => { + chown_new_files(&record, user); + // Known servers are neutralised silently; new (unknown) ones need + // the UI to prompt (send to EW / keep quarantined). + let state = if known { "quarantined" } else { "quarantine-prompt" }; + publish(events, user, &server, state); + tracing::info!(server = %server.name, agent = server.client, known, fingerprint = %fp, "quarantined"); + let _ = seen.mark(&fp, &server.name, SeenAction::Quarantined); + qstate.upsert(QuarantinedEntry { + name: server.name.clone(), + agent: server.client.to_string(), + fingerprint: fp, + config: Some(server.config.clone()), + record, + }); + let _ = qstate.save_for(user); + } + Err(e) => tracing::warn!(server = %server.name, error = %e, "quarantine failed"), + } + } + + log_skips(&skips); +} + +fn count(actions: &[ReconcileAction], pred: impl Fn(&ReconcileAction) -> bool) -> usize { + actions.iter().filter(|a| pred(a)).count() +} + +/// A discovered server the reconcile pass will not quarantine. +struct Skip { + name: String, + agent: &'static str, + reason: &'static str, + fingerprint: String, +} + +impl Skip { + fn edison(s: &mcp_detector_lib::DiscoveredServer) -> Self { + Self { + name: s.name.clone(), + agent: s.client, + reason: "edison-watch (our own server)", + fingerprint: fingerprint(&s.name, &s.config).unwrap_or_else(|| "-".into()), + } + } + + fn untouchable(s: &mcp_detector_lib::DiscoveredServer) -> Self { + Self { + name: s.name.clone(), + agent: s.client, + reason: "untouchable (no access to remove — e.g. an installed extension)", + fingerprint: "-".into(), + } + } +} + +fn log_skips(skips: &[Skip]) { + // Per-server detail is debug — it repeats every reconcile tick; the summary + // line carries the counts at info. + for s in skips { + tracing::debug!(server = %s.name, agent = s.agent, reason = s.reason, fingerprint = %s.fingerprint, "will not quarantine"); + } +} diff --git a/crates/mcp_detector_daemon/src/status.rs b/crates/mcp_detector_daemon/src/status.rs new file mode 100644 index 0000000..a89c27e --- /dev/null +++ b/crates/mcp_detector_daemon/src/status.rs @@ -0,0 +1,41 @@ +//! The `state.json` liveness/status file (stdiod-style): a readable snapshot of +//! the running daemon. `updated_at` doubles as a heartbeat — a stale timestamp +//! means the daemon is wedged or gone. + +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Serialize; + +use crate::paths; + +#[derive(Debug, Serialize)] +struct DaemonStatus { + version: String, + pid: u32, + is_root: bool, + socket: String, + enrolled_users: Vec, + updated_at: u64, +} + +/// Write `state.json` (best-effort; errors are the caller's to log). +pub fn write(socket: &Path, users: &[String]) -> anyhow::Result<()> { + paths::ensure_base_dir()?; + let status = DaemonStatus { + version: env!("CARGO_PKG_VERSION").to_string(), + pid: std::process::id(), + is_root: paths::is_root(), + socket: socket.display().to_string(), + enrolled_users: users.to_vec(), + updated_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + }; + std::fs::write( + paths::state_json_path(), + serde_json::to_string_pretty(&status)?, + )?; + Ok(()) +} diff --git a/crates/mcp_detector_daemon/src/supervisor.rs b/crates/mcp_detector_daemon/src/supervisor.rs new file mode 100644 index 0000000..ab0acff --- /dev/null +++ b/crates/mcp_detector_daemon/src/supervisor.rs @@ -0,0 +1,102 @@ +//! The daemon supervisor: runs the IPC server and a reconcile worker per +//! enrolled OS user, together, and keeps `state.json` fresh. This is the actual +//! long-running daemon mode; `run`/`serve` are focused dev tools. +//! +//! (The root build's launchd plist points at this via the `daemon` subcommand.) + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use tokio::sync::broadcast; +use tokio::task::JoinHandle; + +use crate::enrollment::Enrollments; +use crate::runner::EventTx; +use crate::{ipc, runner, status}; + +const EVENT_CAPACITY: usize = 256; +const STATE_REFRESH: Duration = Duration::from_secs(15); +/// How often to reconcile the running workers against the enrolled users, so a +/// user who enrolls at runtime (via the socket) gets a worker without a restart. +const WORKER_REFRESH: Duration = Duration::from_secs(5); + +/// Start the IPC server + a worker per enrolled user; run until terminated. +pub async fn run(enforce: bool, socket: PathBuf, hook_consumer: bool) -> anyhow::Result<()> { + let (events, _keep) = broadcast::channel(EVENT_CAPACITY); + + // IPC server. + let ipc_events = events.clone(); + let ipc_socket = socket.clone(); + let mut ipc_task = tokio::spawn(async move { ipc::serve(&ipc_socket, ipc_events).await }); + + // A reconcile worker per enrolled user, kept in sync with the enrollment set. + let mut workers: HashMap> = HashMap::new(); + spawn_missing_workers(&mut workers, enforce, &events); + + // Drain the hook scripts' pending/errors output (phase 2b). Skipped in + // detect-only mode so we don't fight the client's own hook monitor over + // ~/.edison-watch/pending. + if hook_consumer && let Some(dir) = crate::paths::edison_watch_dir() { + tokio::spawn(crate::hook_consumer::run(dir)); + } + + tracing::info!( + enforce, + users = workers.len(), + socket = %socket.display(), + "supervisor started" + ); + + let _ = status::write(&socket, &worker_names(&workers)); + let mut state_timer = tokio::time::interval(STATE_REFRESH); + let mut worker_timer = tokio::time::interval(WORKER_REFRESH); + + loop { + tokio::select! { + _ = state_timer.tick() => { + let _ = status::write(&socket, &worker_names(&workers)); + } + _ = worker_timer.tick() => { + spawn_missing_workers(&mut workers, enforce, &events); + } + _ = tokio::signal::ctrl_c() => { + tracing::info!("shutting down"); + break; + } + r = &mut ipc_task => { + tracing::warn!("ipc server exited: {r:?}"); + break; + } + } + } + + for (_, w) in workers { + w.abort(); + } + ipc_task.abort(); + Ok(()) +} + +/// Spawn a worker for each enrolled user that doesn't have one yet (so runtime +/// enrollments are picked up without a daemon restart). +fn spawn_missing_workers( + workers: &mut HashMap>>, + enforce: bool, + events: &EventTx, +) { + let Ok(enrollments) = Enrollments::load() else { + return; + }; + for (user, _) in enrollments.iter() { + if !workers.contains_key(user) { + tracing::info!(%user, "spawning reconcile worker"); + let handle = tokio::spawn(runner::worker(user.clone(), enforce, Some(events.clone()))); + workers.insert(user.clone(), handle); + } + } +} + +fn worker_names(workers: &HashMap>>) -> Vec { + workers.keys().cloned().collect() +} diff --git a/crates/mcp_detector_lib/Cargo.toml b/crates/mcp_detector_lib/Cargo.toml index 66413fa..200d569 100644 --- a/crates/mcp_detector_lib/Cargo.toml +++ b/crates/mcp_detector_lib/Cargo.toml @@ -10,12 +10,29 @@ keywords = ["mcp", "model-context-protocol", "watcher", "config", "claude"] categories = ["development-tools", "filesystem", "command-line-utilities"] [features] -default = ["vscode", "claude_code"] -# Each client can be opted into independently. `vscode` pulls in rusqlite -# (for reading VSCode's `state.vscdb` workspace history); consumers who only -# care about Claude Code can disable it and skip that build cost entirely. +default = [ + "vscode", + "claude_code", + "cursor", + "claude_desktop", + "claude_cowork", + "windsurf", + "zed", + "jetbrains", + "codex", +] +# Each agent can be opted into independently. `vscode` and `cursor` pull in +# rusqlite (for reading `state.vscdb`) + serde_json_lenient (JSONC); `codex` +# pulls in `toml`; the plain single-file agents need no extra deps. vscode = ["dep:rusqlite", "dep:serde_json_lenient"] +cursor = ["dep:rusqlite", "dep:serde_json_lenient"] +codex = ["dep:toml"] claude_code = [] +claude_desktop = [] +claude_cowork = [] +windsurf = [] +zed = [] +jetbrains = [] [dependencies] dirs = "6.0.0" @@ -24,6 +41,8 @@ notify-debouncer-full = "0.7.0" rusqlite = { version = "0.39.0", features = ["bundled"], optional = true } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +sha2 = "0.10.8" +toml = { version = "0.9.8", optional = true } serde_json_lenient = { version = "0.2.4", optional = true } thiserror = "2.0.18" tracing = "0.1.44" diff --git a/crates/mcp_detector_lib/examples/watch.rs b/crates/mcp_detector_lib/examples/watch.rs index 6f02b04..e03181a 100644 --- a/crates/mcp_detector_lib/examples/watch.rs +++ b/crates/mcp_detector_lib/examples/watch.rs @@ -10,14 +10,14 @@ use std::sync::Arc; use mcp_detector_lib::{ - Client, Result, Watcher, + Agent, Result, Watcher, clients::{ClaudeCode, VsCode}, }; fn main() -> Result<()> { tracing_subscriber::fmt::init(); - let clients: Vec> = vec![ + let clients: Vec> = vec![ Arc::new(VsCode::discover()?), Arc::new(ClaudeCode::discover()?), ]; diff --git a/crates/mcp_detector_lib/src/agent.rs b/crates/mcp_detector_lib/src/agent.rs new file mode 100644 index 0000000..4ef7ee7 --- /dev/null +++ b/crates/mcp_detector_lib/src/agent.rs @@ -0,0 +1,74 @@ +//! The [`Agent`] trait - the extension point for adding support for a new MCP +//! host application (Claude Code, VSCode, Cursor, ...). + +use std::path::{Path, PathBuf}; + +use crate::error::Result; +use crate::types::{DiscoveredServer, EdisonInstall, HookInstall}; +use crate::watch::WatchTargets; + +/// A source of MCP server configuration that the daemon can observe. +/// +/// Implementations are typically constructed by a `discover()` *constructor* +/// (distinct from the [`discover`](Agent::discover) trait method below) that +/// does the one-time work of locating their config files (potentially reading +/// other state, e.g. an editor's recent-workspaces database). +/// +/// The [`Watcher`](crate::Watcher) requires `Send + Sync` so a list of agents +/// can be shared across threads via `Arc`. +pub trait Agent: Send + Sync { + /// Stable, machine-readable identifier (e.g. `"vscode"`, `"claude_code"`). + /// Surfaced in [`DiscoveredServer::client`] and used in log lines. + fn name(&self) -> &'static str; + + /// Whether this agent appears to be installed / present on the machine. + /// Used to report which agents exist (onboarding); an absent agent simply + /// produces no servers. + fn is_installed(&self) -> bool; + + /// Filesystem locations to watch for this agent's MCP config. + /// + /// A driver subscribes to each [`files`](WatchTargets::files) entry's + /// **parent directory**, not the file itself, because most editors write + /// configs via atomic rename (create temp + rename over target) and that + /// pattern invalidates single-file watches. Returning a non-existent path + /// is fine — the driver simply skips a parent dir that does not exist. + fn watch_targets(&self) -> WatchTargets; + + /// Read every configured source and return all currently-defined servers, + /// each carrying its raw [`config`](DiscoveredServer::config) and + /// [`location`](DiscoveredServer::location). + /// + /// Called on startup (to seed the snapshot) and again on every debounced + /// filesystem event. Implementations should be tolerant of missing or + /// malformed files — log and return what was parseable rather than + /// erroring out, so one broken config can't kill the detector. + fn discover(&self) -> Result>; + + /// Where/how to install the `edison-watch` proxy entry for this agent, under + /// the target user's `home`. Usually one target; JetBrains returns one per + /// installed IDE version. Empty if the agent isn't an install target. + /// + /// `home` is threaded (not `dirs::home_dir()`) so a root daemon writes into + /// the correct user's home. Install is separate from discovery/quarantine: + /// only the UI-selected agents get the entry, whereas quarantine acts on all. + fn edison_installs(&self, home: &Path) -> Vec { + let _ = home; + Vec::new() + } + + /// How to inject Edison Watch hooks for this agent under `home`, or `None` + /// if it has no hook surface. Injected into all installed agents (as the app + /// does), not just the selected ones. + fn hook_install(&self, home: &Path) -> Option { + let _ = home; + None + } + + /// Per-workspace `.vscode/tasks.json` files (under `home`) that get an + /// "Edison Watch Registration" folder-open task. Only VSCode uses this. + fn hook_workspace_targets(&self, home: &Path) -> Vec { + let _ = home; + Vec::new() + } +} diff --git a/crates/mcp_detector_lib/src/client.rs b/crates/mcp_detector_lib/src/client.rs deleted file mode 100644 index 3172231..0000000 --- a/crates/mcp_detector_lib/src/client.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! The [`Client`] trait - the extension point for adding support for a new -//! MCP client. - -use std::path::PathBuf; - -use crate::error::Result; -use crate::types::McpServer; - -/// A source of MCP server configuration that the watcher can observe. -/// -/// Implementations are typically constructed by a `discover()` constructor -/// that does the one-time work of locating their config files (potentially -/// reading other state, e.g. an editor's recent-workspaces database). -/// -/// The [`Watcher`](crate::Watcher) requires `Send + Sync` so a list of clients -/// can be shared across threads via `Arc`. -pub trait Client: Send + Sync { - /// Stable, machine-readable identifier (e.g. `"vscode"`, `"claude_code"`). - /// Surfaced in [`McpServer::client`] and used in log lines. - fn name(&self) -> &'static str; - - /// Files this client uses for MCP configuration. - /// - /// The watcher subscribes to each path's **parent directory**, not the - /// file itself, because most editors write configs via atomic rename - /// (create temp + rename over target) and that pattern invalidates - /// single-file watches. Returning a non-existent path is fine - the - /// watcher will simply skip the parent dir if it does not exist either. - fn watch_paths(&self) -> Vec; - - /// Read every configured source and return all currently-defined servers. - /// - /// Called on startup (to seed the snapshot) and again on every debounced - /// filesystem event. Implementations should be tolerant of missing or - /// malformed files - log and return what was parseable rather than - /// erroring out, so one broken config can't kill the detector. - fn parse_all(&self) -> Result>; -} diff --git a/crates/mcp_detector_lib/src/clients/claude_code.rs b/crates/mcp_detector_lib/src/clients/claude_code.rs index d206f70..ef7a123 100644 --- a/crates/mcp_detector_lib/src/clients/claude_code.rs +++ b/crates/mcp_detector_lib/src/clients/claude_code.rs @@ -1,4 +1,4 @@ -//! Claude Code [`Client`] implementation. Watches `~/.claude.json` (which +//! Claude Code [`Agent`](crate::Agent) implementation. Watches `~/.claude.json` (which //! contains both the global `mcpServers` map and a `projects` sub-map with //! per-project servers) plus a `.mcp.json` inside every project listed in //! that file. @@ -8,10 +8,15 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; -use crate::client::Client; -use crate::clients::detect_transport; +use crate::agent::Agent; +use crate::clients::common::{read_strict_json, servers_from_map}; +use crate::clients::{detect_transport, server_config_from_value}; use crate::error::{Error, Result}; -use crate::types::{McpServer, Scope}; +use crate::types::{ + ConfigLocation, DiscoveredServer, EdisonInstall, EdisonStyle, HookBinding, HookInstall, + HookScriptKind, HookStyle, LocationExtra, Scope, SourceKind, +}; +use crate::watch::WatchTargets; const CLIENT_NAME: &str = "claude_code"; @@ -23,6 +28,16 @@ pub struct ClaudeCode { /// `~/.claude.json` - single user-level file that holds both global /// servers and a `projects` map with per-project servers. user_config: Option, + /// `~/.claude/settings.json` - user settings, `mcpServers` + per-profile + /// `profiles..mcpServers`. + settings: Option, + /// `~/.claude/settings.local.json` - local overrides, same shape. + settings_local: Option, + /// `~/.claude/mcp_servers.json` - dedicated file, either `{mcpServers:{...}}` + /// or a direct name→config map. + dedicated: Option, + /// Enterprise/managed config (`managed-mcp.json`), key `mcpServers`. + managed: Option, /// (project_dir, path to `/.mcp.json`) for each project CC knows /// about. `.mcp.json` is the committed, project-scoped servers file. project_configs: Vec<(PathBuf, PathBuf)>, @@ -36,7 +51,14 @@ impl ClaudeCode { /// Returns `Ok` even if `~/.claude.json` is absent or unreadable - the /// resulting `ClaudeCode` simply has no paths to watch. pub fn discover() -> Result { - let user_config = dirs::home_dir().map(|h| h.join(".claude.json")); + let home = dirs::home_dir(); + let user_config = home.as_ref().map(|h| h.join(".claude.json")); + let claude_dir = home.as_ref().map(|h| h.join(".claude")); + let settings = claude_dir.as_ref().map(|d| d.join("settings.json")); + let settings_local = claude_dir.as_ref().map(|d| d.join("settings.local.json")); + let dedicated = claude_dir.as_ref().map(|d| d.join("mcp_servers.json")); + let managed = managed_config_path(); + let mut project_configs: Vec<(PathBuf, PathBuf)> = Vec::new(); if let Some(uc) = &user_config && uc.exists() @@ -55,6 +77,10 @@ impl ClaudeCode { } Ok(Self { user_config, + settings, + settings_local, + dedicated, + managed, project_configs, }) } @@ -81,34 +107,78 @@ impl ClaudeCode { .collect(); Self { user_config, + settings: None, + settings_local: None, + dedicated: None, + managed: None, project_configs, } } } -impl Client for ClaudeCode { +impl Agent for ClaudeCode { fn name(&self) -> &'static str { CLIENT_NAME } - fn watch_paths(&self) -> Vec { - let mut v = Vec::new(); - if let Some(p) = &self.user_config { - v.push(p.clone()); + fn is_installed(&self) -> bool { + self.user_config.as_ref().is_some_and(|p| p.exists()) + } + + fn watch_targets(&self) -> WatchTargets { + let mut files = Vec::new(); + for p in [ + &self.user_config, + &self.settings, + &self.settings_local, + &self.dedicated, + &self.managed, + ] + .into_iter() + .flatten() + { + files.push(p.clone()); } for (_, cfg) in &self.project_configs { - v.push(cfg.clone()); + files.push(cfg.clone()); + } + WatchTargets { + files, + dirs: Vec::new(), + needs_periodic_rescan: false, } - v } - fn parse_all(&self) -> Result> { + fn discover(&self) -> Result> { let mut out = Vec::new(); if let Some(uc) = &self.user_config && uc.exists() { out.extend(parse_user_config(uc)); } + // settings.json / settings.local.json: global `mcpServers` + profiles. + for p in [&self.settings, &self.settings_local].into_iter().flatten() { + if p.exists() { + out.extend(parse_settings(p)); + } + } + if let Some(p) = &self.dedicated + && p.exists() + { + out.extend(parse_dedicated(p)); + } + if let Some(p) = &self.managed + && p.exists() + { + out.extend(servers_from_map( + &read_strict_json(p).unwrap_or_default(), + "mcpServers", + CLIENT_NAME, + Scope::Global, + SourceKind::Jsonc, + p, + )); + } for (project, cfg) in &self.project_configs { if cfg.exists() { out.extend(parse_project_mcp(cfg, project.clone())); @@ -116,6 +186,132 @@ impl Client for ClaudeCode { } Ok(out) } + + fn edison_installs(&self, home: &std::path::Path) -> Vec { + // Prefer the `claude mcp add` CLI (Claude Code misbehaves without it); + // the file target is the fallback. + vec![EdisonInstall { + path: home.join(".claude.json"), + key_path: vec!["mcpServers".into()], + style: EdisonStyle::Http, + client_id: "claude-code".into(), + prefer_cli: true, + }] + } + + fn hook_install(&self, home: &std::path::Path) -> Option { + // Hooks live in ~/.claude/settings.json (not ~/.claude.json). + Some(HookInstall { + path: home.join(".claude/settings.json"), + style: HookStyle::ClaudeSettings, + client_id: "claude-code".into(), + events: vec![ + HookBinding::new("UserPromptSubmit", Some("*"), HookScriptKind::Registration, true), + HookBinding::new("PreToolUse", Some("mcp__*"), HookScriptKind::SessionHook, false), + HookBinding::new("SessionStart", Some("*"), HookScriptKind::SessionStart, false), + HookBinding::new("SessionEnd", Some("*"), HookScriptKind::SessionEnd, false), + ], + }) + } +} + +/// Enterprise/managed config path (`managed-mcp.json`), per OS. +fn managed_config_path() -> Option { + if cfg!(target_os = "macos") { + Some(PathBuf::from( + "/Library/Application Support/ClaudeCode/managed-mcp.json", + )) + } else if cfg!(target_os = "windows") { + Some(PathBuf::from( + r"C:\ProgramData\ClaudeCode\managed-mcp.json", + )) + } else { + Some(PathBuf::from("/etc/claude-code/managed-mcp.json")) + } +} + +/// Parse `settings.json` / `settings.local.json`: top-level `mcpServers` plus +/// per-profile `profiles..mcpServers`. +fn parse_settings(path: &Path) -> Vec { + let Some(root) = read_strict_json(path) else { + return Vec::new(); + }; + let mut out = servers_from_map( + &root, + "mcpServers", + CLIENT_NAME, + Scope::Global, + SourceKind::Jsonc, + path, + ); + if let Some(profiles) = root.get("profiles").and_then(|v| v.as_object()) { + for (profile, pval) in profiles { + let Some(map) = pval.get("mcpServers").and_then(|v| v.as_object()) else { + continue; + }; + for (name, val) in map { + let Some(config) = server_config_from_value(val) else { + continue; + }; + out.push(DiscoveredServer { + client: CLIENT_NAME, + transport: detect_transport(val), + scope: Scope::Global, + config, + location: ConfigLocation { + kind: SourceKind::Jsonc, + path: path.to_path_buf(), + key_path: vec!["profiles".into(), profile.clone(), "mcpServers".into()], + server_key: name.clone(), + extra: LocationExtra::None, + }, + name: name.clone(), + }); + } + } + } + out +} + +/// Parse `mcp_servers.json`: either `{ "mcpServers": {...} }` or a direct +/// name→config map at the root. +fn parse_dedicated(path: &Path) -> Vec { + let Some(root) = read_strict_json(path) else { + return Vec::new(); + }; + if root.get("mcpServers").is_some() { + return servers_from_map( + &root, + "mcpServers", + CLIENT_NAME, + Scope::Global, + SourceKind::Jsonc, + path, + ); + } + // Direct map: each top-level key is a server (key_path empty). + let Some(map) = root.as_object() else { + return Vec::new(); + }; + map.iter() + .filter_map(|(name, val)| { + let config = server_config_from_value(val)?; + Some(DiscoveredServer { + client: CLIENT_NAME, + transport: detect_transport(val), + scope: Scope::Global, + config, + location: ConfigLocation { + kind: SourceKind::Jsonc, + path: path.to_path_buf(), + key_path: Vec::new(), + server_key: name.clone(), + extra: LocationExtra::None, + }, + name: name.clone(), + }) + }) + .collect() } #[derive(Debug, Deserialize)] @@ -147,7 +343,7 @@ fn projects_from_user_config(path: &Path) -> Result> { Ok(cfg.projects.keys().map(PathBuf::from).collect()) } -fn parse_user_config(path: &Path) -> Vec { +fn parse_user_config(path: &Path) -> Vec { let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(e) => { @@ -168,23 +364,49 @@ fn parse_user_config(path: &Path) -> Vec { let mut out = Vec::new(); for (name, val) in cfg.mcp_servers { - out.push(McpServer { + let Some(config) = server_config_from_value(&val) else { + continue; + }; + out.push(DiscoveredServer { client: CLIENT_NAME, - name, transport: detect_transport(&val), scope: Scope::Global, - source: path.to_path_buf(), + config, + location: ConfigLocation { + kind: SourceKind::Jsonc, + path: path.to_path_buf(), + key_path: vec!["mcpServers".into()], + server_key: name.clone(), + extra: LocationExtra::None, + }, + name, }); } for (project_path_str, entry) in cfg.projects { - let project_path = PathBuf::from(project_path_str); + let project_path = PathBuf::from(&project_path_str); for (name, val) in entry.mcp_servers { - out.push(McpServer { + let Some(config) = server_config_from_value(&val) else { + continue; + }; + // Project-scoped servers embedded in ~/.claude.json are removed via + // the `claude mcp remove` CLI, not by editing the file directly. + out.push(DiscoveredServer { client: CLIENT_NAME, - name, transport: detect_transport(&val), scope: Scope::Project(project_path.clone()), - source: path.to_path_buf(), + config, + location: ConfigLocation { + kind: SourceKind::ClaudeCli, + path: path.to_path_buf(), + key_path: vec![ + "projects".into(), + project_path_str.clone(), + "mcpServers".into(), + ], + server_key: name.clone(), + extra: LocationExtra::ClaudeProjectDir(project_path.clone()), + }, + name, }); } } @@ -197,7 +419,7 @@ struct ProjectMcpFile { mcp_servers: BTreeMap, } -fn parse_project_mcp(path: &Path, project: PathBuf) -> Vec { +fn parse_project_mcp(path: &Path, project: PathBuf) -> Vec { let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(e) => { @@ -217,12 +439,22 @@ fn parse_project_mcp(path: &Path, project: PathBuf) -> Vec { }; cfg.mcp_servers .into_iter() - .map(|(name, val)| McpServer { - client: CLIENT_NAME, - name, - transport: detect_transport(&val), - scope: Scope::Project(project.clone()), - source: path.to_path_buf(), + .filter_map(|(name, val)| { + let config = server_config_from_value(&val)?; + Some(DiscoveredServer { + client: CLIENT_NAME, + transport: detect_transport(&val), + scope: Scope::Project(project.clone()), + config, + location: ConfigLocation { + kind: SourceKind::Jsonc, + path: path.to_path_buf(), + key_path: vec!["mcpServers".into()], + server_key: name.clone(), + extra: LocationExtra::None, + }, + name, + }) }) .collect() } @@ -299,6 +531,45 @@ mod tests { assert_eq!(projects, vec![PathBuf::from("/a"), PathBuf::from("/b")]); } + #[test] + fn settings_parses_global_and_profile_servers() { + let dir = tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write( + &path, + r#"{ + "mcpServers": { "g": { "command": "node" } }, + "profiles": { + "work": { "mcpServers": { "p": { "type": "http", "url": "https://x" } } } + } + }"#, + ) + .unwrap(); + + let servers = parse_settings(&path); + assert_eq!(servers.len(), 2); + let by: BTreeMap<_, _> = servers.iter().map(|s| (s.name.clone(), s)).collect(); + assert_eq!(by["g"].location.key_path, vec!["mcpServers".to_string()]); + assert_eq!( + by["p"].location.key_path, + vec!["profiles".to_string(), "work".to_string(), "mcpServers".to_string()] + ); + } + + #[test] + fn dedicated_handles_both_shapes() { + let dir = tempdir().unwrap(); + let wrapped = dir.path().join("a.json"); + std::fs::write(&wrapped, r#"{"mcpServers":{"a":{"command":"x"}}}"#).unwrap(); + assert_eq!(parse_dedicated(&wrapped).len(), 1); + + let direct = dir.path().join("b.json"); + std::fs::write(&direct, r#"{"b":{"command":"y"},"c":{"url":"https://z"}}"#).unwrap(); + let servers = parse_dedicated(&direct); + assert_eq!(servers.len(), 2); + assert!(servers.iter().all(|s| s.location.key_path.is_empty())); + } + #[test] fn malformed_user_config_is_tolerated_by_parse() { let dir = tempdir().unwrap(); @@ -313,7 +584,7 @@ mod tests { let user = dir.path().join(".claude.json"); let proj = dir.path().join("proj"); let cc = ClaudeCode::from_paths(Some(user.clone()), [proj.clone()]); - let watched = cc.watch_paths(); + let watched = cc.watch_targets().files; assert!(watched.contains(&user)); assert!(watched.contains(&proj.join(".mcp.json"))); } @@ -332,7 +603,7 @@ mod tests { .unwrap(); let cc = ClaudeCode::from_paths(Some(user.clone()), [proj.clone()]); - let servers = cc.parse_all().unwrap(); + let servers = cc.discover().unwrap(); assert_eq!(servers.len(), 2); let by_name: BTreeMap<_, _> = servers.iter().map(|s| (s.name.clone(), s)).collect(); diff --git a/crates/mcp_detector_lib/src/clients/claude_cowork.rs b/crates/mcp_detector_lib/src/clients/claude_cowork.rs new file mode 100644 index 0000000..773ff4e --- /dev/null +++ b/crates/mcp_detector_lib/src/clients/claude_cowork.rs @@ -0,0 +1,127 @@ +//! Claude Cowork [`Agent`](crate::Agent). Uses the same on-disk config as Claude +//! Desktop (`claude_desktop_config.json`, key `mcpServers`) but is only active +//! when a sibling `vm_bundles/` directory exists (Cowork's distinguishing +//! marker vs. plain Desktop). + +use std::path::PathBuf; + +use crate::agent::Agent; +use crate::clients::common::parse_json_servers_map; +use crate::error::Result; +use crate::types::{DiscoveredServer, EdisonInstall, EdisonStyle, Scope, SourceKind}; +use crate::watch::WatchTargets; + +const CLIENT_NAME: &str = "claude_cowork"; + +pub struct ClaudeCowork { + config: Option, +} + +impl ClaudeCowork { + pub fn discover() -> Result { + Ok(Self { + config: default_config_path(), + }) + } + + pub fn from_path(config: Option) -> Self { + Self { config } + } + + /// Cowork is present only when a `vm_bundles/` dir sits beside the config. + fn is_cowork(&self) -> bool { + self.config + .as_ref() + .and_then(|p| p.parent()) + .map(|dir| dir.join("vm_bundles").is_dir()) + .unwrap_or(false) + } +} + +impl Agent for ClaudeCowork { + fn name(&self) -> &'static str { + CLIENT_NAME + } + + fn is_installed(&self) -> bool { + self.is_cowork() && self.config.as_ref().is_some_and(|p| p.exists()) + } + + fn watch_targets(&self) -> WatchTargets { + WatchTargets { + files: self.config.clone().into_iter().collect(), + dirs: Vec::new(), + needs_periodic_rescan: false, + } + } + + fn discover(&self) -> Result> { + if !self.is_cowork() { + return Ok(Vec::new()); + } + Ok(match self.config.as_ref().filter(|p| p.exists()) { + Some(p) => { + parse_json_servers_map(p, "mcpServers", CLIENT_NAME, Scope::Global, SourceKind::Json) + } + None => Vec::new(), + }) + } + + fn edison_installs(&self, home: &std::path::Path) -> Vec { + // Cowork is stdio-only → mcp-remote shim. Install only when present. + if !self.is_cowork() { + return Vec::new(); + } + config_path_in(home) + .map(|path| EdisonInstall { + path, + key_path: vec!["mcpServers".into()], + style: EdisonStyle::StdioShim, + client_id: "claude-cowork".into(), + prefer_cli: false, + }) + .into_iter() + .collect() + } +} + +fn default_config_path() -> Option { + config_path_in(&dirs::home_dir()?) +} + +fn config_path_in(home: &std::path::Path) -> Option { + if cfg!(target_os = "macos") { + Some(home.join("Library/Application Support/Claude/claude_desktop_config.json")) + } else if cfg!(target_os = "windows") { + dirs::config_dir().map(|c| c.join("Claude").join("claude_desktop_config.json")) + } else { + Some(home.join(".config/Claude/claude_desktop_config.json")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn active_only_with_vm_bundles_sibling() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("claude_desktop_config.json"); + std::fs::write(&cfg, r#"{"mcpServers":{"c":{"command":"x"}}}"#).unwrap(); + + // No vm_bundles/ → inert. + assert!( + ClaudeCowork::from_path(Some(cfg.clone())) + .discover() + .unwrap() + .is_empty() + ); + + // With vm_bundles/ → discovers. + std::fs::create_dir(dir.path().join("vm_bundles")).unwrap(); + let servers = ClaudeCowork::from_path(Some(cfg)).discover().unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].client, CLIENT_NAME); + } +} diff --git a/crates/mcp_detector_lib/src/clients/claude_desktop.rs b/crates/mcp_detector_lib/src/clients/claude_desktop.rs new file mode 100644 index 0000000..1b81e9a --- /dev/null +++ b/crates/mcp_detector_lib/src/clients/claude_desktop.rs @@ -0,0 +1,121 @@ +//! Claude Desktop [`Agent`](crate::Agent) — a single user-level JSON config, +//! `claude_desktop_config.json`, key `mcpServers`. + +use std::path::PathBuf; + +use crate::agent::Agent; +use crate::clients::common::parse_json_servers_map; +use crate::error::Result; +use crate::types::{DiscoveredServer, EdisonInstall, EdisonStyle, Scope, SourceKind}; +use crate::watch::WatchTargets; + +const CLIENT_NAME: &str = "claude_desktop"; + +pub struct ClaudeDesktop { + config: Option, +} + +impl ClaudeDesktop { + pub fn discover() -> Result { + Ok(Self { + config: default_config_path(), + }) + } + + /// Construct from an explicit config path (tests / non-standard installs). + pub fn from_path(config: Option) -> Self { + Self { config } + } +} + +impl Agent for ClaudeDesktop { + fn name(&self) -> &'static str { + CLIENT_NAME + } + + fn is_installed(&self) -> bool { + self.config.as_ref().is_some_and(|p| p.exists()) + } + + fn watch_targets(&self) -> WatchTargets { + WatchTargets { + files: self.config.clone().into_iter().collect(), + dirs: Vec::new(), + needs_periodic_rescan: false, + } + } + + fn discover(&self) -> Result> { + Ok(match self.config.as_ref().filter(|p| p.exists()) { + Some(p) => { + parse_json_servers_map(p, "mcpServers", CLIENT_NAME, Scope::Global, SourceKind::Json) + } + None => Vec::new(), + }) + } + + fn edison_installs(&self, home: &std::path::Path) -> Vec { + // Desktop is stdio-only → mcp-remote shim. + config_path_in(home) + .map(|path| EdisonInstall { + path, + key_path: vec!["mcpServers".into()], + style: EdisonStyle::StdioShim, + client_id: "claude-desktop".into(), + prefer_cli: false, + }) + .into_iter() + .collect() + } +} + +fn default_config_path() -> Option { + config_path_in(&dirs::home_dir()?) +} + +/// The `claude_desktop_config.json` path under `home` (platform-specific). +fn config_path_in(home: &std::path::Path) -> Option { + if cfg!(target_os = "macos") { + Some(home.join("Library/Application Support/Claude/claude_desktop_config.json")) + } else if cfg!(target_os = "windows") { + dirs::config_dir().map(|c| c.join("Claude").join("claude_desktop_config.json")) + } else { + Some(home.join(".config/Claude/claude_desktop_config.json")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Transport; + use tempfile::tempdir; + + #[test] + fn parses_servers() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("claude_desktop_config.json"); + std::fs::write( + &cfg, + r#"{"mcpServers":{"fs":{"command":"npx","args":["srv"]},"remote":{"url":"https://x"}}}"#, + ) + .unwrap(); + + let servers = ClaudeDesktop::from_path(Some(cfg)).discover().unwrap(); + assert_eq!(servers.len(), 2); + let by: std::collections::BTreeMap<_, _> = + servers.iter().map(|s| (s.name.clone(), s)).collect(); + assert_eq!(by["fs"].transport, Transport::Stdio); + assert_eq!(by["remote"].transport, Transport::Remote); + assert_eq!(by["fs"].client, CLIENT_NAME); + } + + #[test] + fn tolerates_missing() { + assert!( + ClaudeDesktop::from_path(Some(PathBuf::from("/nope/x.json"))) + .discover() + .unwrap() + .is_empty() + ); + } +} diff --git a/crates/mcp_detector_lib/src/clients/codex.rs b/crates/mcp_detector_lib/src/clients/codex.rs new file mode 100644 index 0000000..2d1d729 --- /dev/null +++ b/crates/mcp_detector_lib/src/clients/codex.rs @@ -0,0 +1,154 @@ +//! Codex CLI [`Agent`](crate::Agent) — `~/.codex/config.toml` (TOML), servers +//! under the `[mcp_servers]` table. +//! +//! (client_2 stubs Codex discovery; we actually parse the table.) + +use std::path::PathBuf; + +use crate::agent::Agent; +use crate::clients::common::servers_from_map; +use crate::error::Result; +use crate::types::{ + DiscoveredServer, EdisonInstall, EdisonStyle, HookBinding, HookInstall, HookScriptKind, + HookStyle, Scope, SourceKind, +}; +use crate::watch::WatchTargets; + +const CLIENT_NAME: &str = "codex"; +const SERVERS_KEY: &str = "mcp_servers"; + +pub struct Codex { + config: Option, +} + +impl Codex { + pub fn discover() -> Result { + Ok(Self { + config: dirs::home_dir().map(|h| h.join(".codex/config.toml")), + }) + } + + pub fn from_path(config: Option) -> Self { + Self { config } + } +} + +impl Agent for Codex { + fn name(&self) -> &'static str { + CLIENT_NAME + } + + fn is_installed(&self) -> bool { + self.config.as_ref().is_some_and(|p| p.exists()) + } + + fn watch_targets(&self) -> WatchTargets { + WatchTargets { + files: self.config.clone().into_iter().collect(), + dirs: Vec::new(), + needs_periodic_rescan: false, + } + } + + fn discover(&self) -> Result> { + Ok(match self.config.as_ref().filter(|p| p.exists()) { + Some(p) => parse_codex_toml(p), + None => Vec::new(), + }) + } + + fn edison_installs(&self, home: &std::path::Path) -> Vec { + vec![EdisonInstall { + path: home.join(".codex/config.toml"), + key_path: vec![SERVERS_KEY.into()], + style: EdisonStyle::Toml, + client_id: "codex".into(), + prefer_cli: false, + }] + } + + fn hook_install(&self, home: &std::path::Path) -> Option { + // Codex has no PreToolUse surface — registration + session-end only. + Some(HookInstall { + path: home.join(".codex/config.toml"), + style: HookStyle::CodexToml, + client_id: "codex".into(), + events: vec![ + HookBinding::new("SessionStart", None, HookScriptKind::Registration, true), + HookBinding::new("Stop", None, HookScriptKind::SessionEnd, false), + ], + }) + } +} + +/// Parse `config.toml` and map the `[mcp_servers]` table. The TOML is converted +/// to a JSON value so the shared [`servers_from_map`] logic applies. +fn parse_codex_toml(path: &std::path::Path) -> Vec { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) => { + tracing::debug!(file = %path.display(), error = %e, "read failed"); + return Vec::new(); + } + }; + let toml_val: toml::Value = match toml::from_str(&text) { + Ok(v) => v, + Err(e) => { + tracing::debug!(file = %path.display(), error = %e, "toml parse failed"); + return Vec::new(); + } + }; + let json = match serde_json::to_value(&toml_val) { + Ok(j) => j, + Err(e) => { + tracing::debug!(file = %path.display(), error = %e, "toml->json failed"); + return Vec::new(); + } + }; + servers_from_map(&json, SERVERS_KEY, CLIENT_NAME, Scope::Global, SourceKind::Toml, path) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn parses_mcp_servers_table() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("config.toml"); + std::fs::write( + &cfg, + r#" +model = "gpt-5" + +[mcp_servers.docs] +command = "npx" +args = ["-y", "docs-mcp"] + +[mcp_servers.docs.env] +TOKEN = "x" + +[mcp_servers.remote] +url = "https://mcp.example/sse" +"#, + ) + .unwrap(); + + let servers = Codex::from_path(Some(cfg)).discover().unwrap(); + assert_eq!(servers.len(), 2); + let by: std::collections::BTreeMap<_, _> = + servers.iter().map(|s| (s.name.clone(), s)).collect(); + assert!(by.contains_key("docs")); + assert!(by.contains_key("remote")); + assert_eq!(by["docs"].location.kind, SourceKind::Toml); + } + + #[test] + fn tolerates_missing_and_no_table() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("config.toml"); + std::fs::write(&cfg, "model = \"x\"\n").unwrap(); + assert!(Codex::from_path(Some(cfg)).discover().unwrap().is_empty()); + } +} diff --git a/crates/mcp_detector_lib/src/clients/common.rs b/crates/mcp_detector_lib/src/clients/common.rs new file mode 100644 index 0000000..bfdf234 --- /dev/null +++ b/crates/mcp_detector_lib/src/clients/common.rs @@ -0,0 +1,123 @@ +//! Shared helpers for agent adapters: mapping a servers map to +//! [`DiscoveredServer`]s, reading strict JSON, and decoding `file://` URIs. +//! +//! Different agents use different subsets of these helpers, so in narrow +//! single-agent builds some go unused; they are all exercised in the default +//! (all-agents) build. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::clients::{detect_transport, server_config_from_value}; +use crate::types::{ConfigLocation, DiscoveredServer, LocationExtra, Scope, SourceKind}; + +/// Map a servers object (found at `root[servers_key]`) into discovered servers. +/// +/// `root` is already-parsed JSON (the caller chooses strict vs. lenient), so +/// this works for both plain-JSON and JSONC agents. Entries with no extractable +/// command/url are skipped (unsupported). Each server is tagged with a +/// [`ConfigLocation`] of `key_path = [servers_key]` and the given `kind`. +pub(crate) fn servers_from_map( + root: &Value, + servers_key: &str, + client: &'static str, + scope: Scope, + kind: SourceKind, + path: &Path, +) -> Vec { + let Some(map) = root.get(servers_key).and_then(Value::as_object) else { + return Vec::new(); + }; + map.iter() + .filter_map(|(name, val)| { + let config = server_config_from_value(val)?; + Some(DiscoveredServer { + client, + transport: detect_transport(val), + scope: scope.clone(), + config, + location: ConfigLocation { + kind, + path: path.to_path_buf(), + key_path: vec![servers_key.to_string()], + server_key: name.clone(), + extra: LocationExtra::None, + }, + name: name.clone(), + }) + }) + .collect() +} + +/// Read and strict-parse a JSON file, tolerating missing/empty/malformed input +/// (logs at debug, returns `None`). +pub(crate) fn read_strict_json(path: &Path) -> Option { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) => { + tracing::debug!(file = %path.display(), error = %e, "read failed"); + return None; + } + }; + if text.trim().is_empty() { + return None; + } + match serde_json::from_str(&text) { + Ok(v) => Some(v), + Err(e) => { + tracing::debug!(file = %path.display(), error = %e, "parse failed"); + None + } + } +} + +/// Convenience: read strict JSON and map a single top-level servers key. +pub(crate) fn parse_json_servers_map( + path: &Path, + servers_key: &str, + client: &'static str, + scope: Scope, + kind: SourceKind, +) -> Vec { + match read_strict_json(path) { + Some(root) => servers_from_map(&root, servers_key, client, scope, kind, path), + None => Vec::new(), + } +} + +/// Decode a `file://` URI to a filesystem path (percent-decoding the tail). +/// Returns `None` for non-`file://` URIs (SSH/remote workspaces). +pub(crate) fn file_uri_to_path(uri: &str) -> Option { + let stripped = uri.strip_prefix("file://")?; + Some(PathBuf::from(percent_decode(stripped))) +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) + { + out.push((h << 4) | l); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} diff --git a/crates/mcp_detector_lib/src/clients/cursor.rs b/crates/mcp_detector_lib/src/clients/cursor.rs new file mode 100644 index 0000000..d0c0c51 --- /dev/null +++ b/crates/mcp_detector_lib/src/clients/cursor.rs @@ -0,0 +1,519 @@ +//! Cursor [`Agent`](crate::Agent). Scans: +//! +//! 1. **User config** — `~/.cursor/mcp.json` (JSONC), key `mcpServers`. +//! 2. **Project configs** — `/.cursor/mcp.json` (JSONC), for each +//! workspace enumerated from Cursor's `workspaceStorage//workspace.json` +//! (`folder` field, `file://` URIs only). +//! 3. **Marketplace OAuth servers** — Cursor's `state.vscdb` (SQLite), key +//! `anysphere.cursor-mcp`; each `"[user-] mcp_server_url"` entry is an +//! HTTP server. +//! 4. **Plugin-cache servers** — `~/.cursor/plugins/cache/// +//! /mcp.json` (most-recent sha per plugin), matching the client. +//! 5. **Plugin metadata dirs** — `~/.cursor/projects//mcps//` via +//! `SERVER_METADATA` (opaque, removable by renaming the dir). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::agent::Agent; +use crate::clients::common::{file_uri_to_path, read_strict_json, servers_from_map}; +use crate::clients::statedb::read_state_db_value; +use crate::error::Result; +use crate::types::{ + ConfigLocation, DiscoveredServer, EdisonInstall, EdisonStyle, HookBinding, HookInstall, + HookScriptKind, HookStyle, HttpKind, LocationExtra, OpaqueReason, Scope, ServerConfig, + SourceKind, StateShape, Transport, +}; +use crate::watch::{WatchDir, WatchTargets}; + +const CLIENT_NAME: &str = "cursor"; +const MARKETPLACE_KEY: &str = "anysphere.cursor-mcp"; + +pub struct Cursor { + /// `~/.cursor/mcp.json`. + user_config: Option, + /// `/User/globalStorage/state.vscdb`. + state_db: Option, + /// `/User/workspaceStorage` — scanned to enumerate projects. + workspace_storage: Option, + /// `~/.cursor/plugins/cache` — plugin-bundled MCP servers. + plugins_cache: Option, + /// `~/.cursor/projects` — per-project marketplace plugin metadata. + projects_dir: Option, + /// (workspace root, `/.cursor/mcp.json`). + project_configs: Vec<(PathBuf, PathBuf)>, +} + +impl Cursor { + pub fn discover() -> Result { + let home = dirs::home_dir(); + let user_config = home.as_ref().map(|h| h.join(".cursor/mcp.json")); + let plugins_cache = home.as_ref().map(|h| h.join(".cursor/plugins/cache")); + let projects_dir = home.as_ref().map(|h| h.join(".cursor/projects")); + let user_dir = cursor_user_dir(); + let state_db = user_dir.as_ref().map(|d| d.join("globalStorage/state.vscdb")); + let workspace_storage = user_dir.as_ref().map(|d| d.join("workspaceStorage")); + + let project_configs = workspace_storage + .as_deref() + .map(enumerate_projects) + .unwrap_or_default() + .into_iter() + .map(|root| { + let cfg = root.join(".cursor").join("mcp.json"); + (root, cfg) + }) + .collect(); + + Ok(Self { + user_config, + state_db, + workspace_storage, + plugins_cache, + projects_dir, + project_configs, + }) + } + + /// Construct from explicit paths (tests / non-standard installs). + pub fn from_paths( + user_config: Option, + state_db: Option, + project_dirs: impl IntoIterator, + ) -> Self { + let project_configs = project_dirs + .into_iter() + .map(|root| { + let cfg = root.join(".cursor").join("mcp.json"); + (root, cfg) + }) + .collect(); + Self { + user_config, + state_db, + workspace_storage: None, + plugins_cache: None, + projects_dir: None, + project_configs, + } + } +} + +impl Agent for Cursor { + fn name(&self) -> &'static str { + CLIENT_NAME + } + + fn is_installed(&self) -> bool { + self.user_config.as_ref().is_some_and(|p| p.exists()) + || self.state_db.as_ref().is_some_and(|p| p.exists()) + } + + fn watch_targets(&self) -> WatchTargets { + let mut files = Vec::new(); + if let Some(p) = &self.user_config { + files.push(p.clone()); + } + if let Some(p) = &self.state_db { + files.push(p.clone()); + } + for (_, cfg) in &self.project_configs { + files.push(cfg.clone()); + } + let mut dirs: Vec = Vec::new(); + if let Some(path) = self.workspace_storage.clone() { + dirs.push(WatchDir { path, depth: 1 }); + } + if let Some(path) = self.plugins_cache.clone() { + dirs.push(WatchDir { path, depth: 3 }); + } + WatchTargets { + files, + dirs, + // state.vscdb (marketplace OAuth) mutates without fs events. + needs_periodic_rescan: true, + } + } + + fn discover(&self) -> Result> { + let mut out = Vec::new(); + if let Some(p) = &self.user_config + && p.exists() + { + out.extend(parse_jsonc_servers(p, Scope::Global)); + } + for (root, cfg) in &self.project_configs { + if cfg.exists() { + out.extend(parse_jsonc_servers(cfg, Scope::Project(root.clone()))); + } + } + if let Some(db) = &self.state_db + && db.exists() + { + out.extend(parse_marketplace(db)); + } + if let Some(cache) = &self.plugins_cache + && cache.exists() + { + out.extend(scan_plugin_cache(cache)); + } + if let Some(projects) = &self.projects_dir + && projects.exists() + { + out.extend(scan_server_metadata(projects)); + } + Ok(out) + } + + fn edison_installs(&self, home: &std::path::Path) -> Vec { + vec![EdisonInstall { + path: home.join(".cursor/mcp.json"), + key_path: vec!["mcpServers".into()], + style: EdisonStyle::Http, + client_id: "cursor".into(), + prefer_cli: false, + }] + } + + fn hook_install(&self, home: &std::path::Path) -> Option { + Some(HookInstall { + path: home.join(".cursor/hooks.json"), + style: HookStyle::CursorHooks, + client_id: "cursor".into(), + events: vec![ + HookBinding::new("sessionStart", None, HookScriptKind::Registration, true), + HookBinding::new("beforeMCPExecution", None, HookScriptKind::SessionHook, false), + HookBinding::new("sessionEnd", None, HookScriptKind::SessionEnd, false), + ], + }) + } +} + +/// Parse a Cursor `mcp.json` (JSONC) into servers under `mcpServers`. +fn parse_jsonc_servers(path: &Path, scope: Scope) -> Vec { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) => { + tracing::debug!(file = %path.display(), error = %e, "read failed"); + return Vec::new(); + } + }; + if text.trim().is_empty() { + return Vec::new(); + } + let root: Value = match serde_json_lenient::from_str(&text) { + Ok(v) => v, + Err(e) => { + tracing::debug!(file = %path.display(), error = %e, "parse failed"); + return Vec::new(); + } + }; + servers_from_map(&root, "mcpServers", CLIENT_NAME, scope, SourceKind::Jsonc, path) +} + +/// Read Cursor's marketplace OAuth MCP servers from `state.vscdb`. +fn parse_marketplace(state_db: &Path) -> Vec { + let raw = match read_state_db_value(state_db, MARKETPLACE_KEY) { + Ok(Some(s)) => s, + Ok(None) => return Vec::new(), + Err(e) => { + tracing::debug!(path = %state_db.display(), error = %e, "cursor-mcp read failed"); + return Vec::new(); + } + }; + let Ok(Value::Object(obj)) = serde_json_lenient::from_str::(&raw) else { + return Vec::new(); + }; + + obj.iter() + .filter_map(|(k, v)| { + let name = k.strip_prefix("[user-")?.strip_suffix("] mcp_server_url")?; + let url = v.as_str()?.to_string(); + Some(DiscoveredServer { + client: CLIENT_NAME, + name: name.to_string(), + transport: Transport::Remote, + scope: Scope::Global, + config: ServerConfig::Http { + url, + headers: BTreeMap::new(), + kind: HttpKind::Http, + }, + location: ConfigLocation { + kind: SourceKind::SqliteState, + path: state_db.to_path_buf(), + key_path: Vec::new(), + server_key: k.clone(), + extra: LocationExtra::StateDb { + item_key: MARKETPLACE_KEY.to_string(), + shape: StateShape::ObjectKey, + }, + }, + }) + }) + .collect() +} + +/// Scan plugin-bundled MCP servers: `////mcp.json` +/// (JSONC, key `mcpServers`), taking the most-recent `` per plugin and +/// skipping quarantined `ew-disabled-*` plugin dirs. +fn scan_plugin_cache(cache_dir: &Path) -> Vec { + let mut out = Vec::new(); + for market in subdirs(cache_dir) { + for plugin in subdirs(&market) { + let pname = plugin.file_name().unwrap_or_default().to_string_lossy(); + if pname.starts_with("ew-disabled-") { + continue; + } + if let Some(sha) = most_recent_subdir(&plugin) { + let mcp = sha.join("mcp.json"); + if mcp.exists() { + out.extend(parse_jsonc_servers(&mcp, Scope::Global)); + } + } + } + } + out +} + +/// Scan per-project marketplace plugin metadata: +/// `/*/mcps/plugin-*/SERVER_METADATA.json`. No launch config, so they +/// can't be fingerprinted/sent to EW — but they are **removable** by renaming +/// the plugin directory ([`SourceKind::CursorPluginDir`]). +fn scan_server_metadata(projects_dir: &Path) -> Vec { + let mut out = Vec::new(); + for project in subdirs(projects_dir) { + for plugin in subdirs(&project.join("mcps")) { + let dir_name = plugin.file_name().unwrap_or_default().to_string_lossy().into_owned(); + if !dir_name.starts_with("plugin-") { + continue; + } + let meta = plugin.join("SERVER_METADATA.json"); + let Some(v) = read_strict_json(&meta) else { + continue; + }; + let server_name = v + .get("serverName") + .or_else(|| v.get("serverIdentifier")) + .and_then(|x| x.as_str()); + if let Some(sname) = server_name { + out.push(DiscoveredServer { + client: CLIENT_NAME, + name: sname.to_string(), + transport: Transport::Stdio, + scope: Scope::Global, + config: ServerConfig::Opaque { + removable: true, + reason: OpaqueReason::CursorPlugin, + }, + // Removed by renaming the plugin dir, so the location points + // at the directory, not the metadata file. + location: ConfigLocation { + kind: SourceKind::CursorPluginDir, + path: plugin.clone(), + key_path: Vec::new(), + server_key: dir_name, + extra: LocationExtra::None, + }, + }); + } + } + } + out +} + +/// Immediate subdirectories of `dir` (empty if unreadable). +fn subdirs(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect() +} + +/// The most-recently-modified subdirectory of `dir`. +fn most_recent_subdir(dir: &Path) -> Option { + subdirs(dir) + .into_iter() + .filter_map(|p| { + let mtime = std::fs::metadata(&p).ok()?.modified().ok()?; + Some((mtime, p)) + }) + .max_by_key(|(mtime, _)| *mtime) + .map(|(_, p)| p) +} + +/// Enumerate workspace roots from `workspaceStorage//workspace.json`. +fn enumerate_projects(workspace_storage: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(workspace_storage) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|e| { + let wj = e.path().join("workspace.json"); + let folder = read_strict_json(&wj)? + .get("folder")? + .as_str()? + .to_string(); + file_uri_to_path(&folder) + }) + .collect() +} + +fn cursor_user_dir() -> Option { + if cfg!(target_os = "macos") { + Some(dirs::home_dir()?.join("Library/Application Support/Cursor/User")) + } else { + Some(dirs::config_dir()?.join("Cursor").join("User")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn make_state_db(path: &Path, rows: &[(&str, &str)]) { + let conn = rusqlite::Connection::open(path).unwrap(); + conn.execute("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)", []) + .unwrap(); + for (k, v) in rows { + conn.execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + rusqlite::params![k, v], + ) + .unwrap(); + } + } + + #[test] + fn parses_user_jsonc_config() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("mcp.json"); + std::fs::write( + &cfg, + r#"{ + // my cursor servers + "mcpServers": { + "local": { "command": "npx", "args": ["srv",] }, + "remote": { "type": "http", "url": "https://x" }, + }, + }"#, + ) + .unwrap(); + + let servers = Cursor::from_paths(Some(cfg), None, std::iter::empty::()) + .discover() + .unwrap(); + assert_eq!(servers.len(), 2); + let by: std::collections::BTreeMap<_, _> = + servers.iter().map(|s| (s.name.clone(), s)).collect(); + assert_eq!(by["local"].transport, Transport::Stdio); + assert_eq!(by["remote"].transport, Transport::Remote); + } + + #[test] + fn parses_project_config() { + let dir = tempdir().unwrap(); + let proj = dir.path().join("proj"); + std::fs::create_dir_all(proj.join(".cursor")).unwrap(); + std::fs::write( + proj.join(".cursor").join("mcp.json"), + r#"{"mcpServers":{"p":{"command":"x"}}}"#, + ) + .unwrap(); + + let servers = Cursor::from_paths(None, None, [proj.clone()]) + .discover() + .unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].scope, Scope::Project(proj)); + } + + #[test] + fn parses_marketplace_oauth_servers() { + let dir = tempdir().unwrap(); + let db = dir.path().join("state.vscdb"); + let value = serde_json::json!({ + "[user-notion] mcp_server_url": "https://mcp.notion.com/mcp", + "[user-linear] mcp_server_url": "https://mcp.linear.app/sse", + "some other key": "ignored" + }) + .to_string(); + make_state_db(&db, &[(MARKETPLACE_KEY, &value)]); + + let servers = parse_marketplace(&db); + assert_eq!(servers.len(), 2); + let by: std::collections::BTreeMap<_, _> = + servers.iter().map(|s| (s.name.clone(), s)).collect(); + assert!(by.contains_key("notion")); + assert!(by.contains_key("linear")); + assert_eq!(by["notion"].transport, Transport::Remote); + } + + #[test] + fn scans_plugin_cache_mcp_json() { + let dir = tempdir().unwrap(); + let cache = dir.path().join("cache"); + let sha = cache.join("acme-market").join("my-plugin").join("abc123"); + std::fs::create_dir_all(&sha).unwrap(); + std::fs::write( + sha.join("mcp.json"), + r#"{"mcpServers":{"bundled":{"command":"run"}}}"#, + ) + .unwrap(); + // A quarantined plugin dir must be skipped. + let disabled = cache.join("acme-market").join("ew-disabled-old"); + std::fs::create_dir_all(disabled.join("sha")).unwrap(); + std::fs::write( + disabled.join("sha").join("mcp.json"), + r#"{"mcpServers":{"nope":{"command":"x"}}}"#, + ) + .unwrap(); + + let servers = scan_plugin_cache(&cache); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "bundled"); + } + + #[test] + fn scans_server_metadata_as_report_only() { + let dir = tempdir().unwrap(); + let projects = dir.path().join("projects"); + let plugin = projects.join("proj1").join("mcps").join("plugin-notion"); + std::fs::create_dir_all(&plugin).unwrap(); + std::fs::write( + plugin.join("SERVER_METADATA.json"), + r#"{"serverName":"notion","serverIdentifier":"notion@acme"}"#, + ) + .unwrap(); + + let servers = scan_server_metadata(&projects); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "notion"); + assert!(matches!( + servers[0].config, + ServerConfig::Opaque { + removable: true, + .. + } + )); + } + + #[test] + fn tolerates_missing_everything() { + let servers = Cursor::from_paths( + Some(PathBuf::from("/nope/mcp.json")), + Some(PathBuf::from("/nope/state.vscdb")), + std::iter::empty::(), + ) + .discover() + .unwrap(); + assert!(servers.is_empty()); + } +} diff --git a/crates/mcp_detector_lib/src/clients/jetbrains.rs b/crates/mcp_detector_lib/src/clients/jetbrains.rs new file mode 100644 index 0000000..bffca0b --- /dev/null +++ b/crates/mcp_detector_lib/src/clients/jetbrains.rs @@ -0,0 +1,176 @@ +//! JetBrains IDEs [`Agent`](crate::Agent) — IntelliJ IDEA, PyCharm, WebStorm. +//! +//! Each IDE stores MCP servers in `//mcp/servers.json` +//! (JSON, key `mcpServers`). The base dir holds version-suffixed folders (e.g. +//! `IntelliJIdea2024.3`); every folder matching the IDE's prefix contributes a +//! config. Linux has no JetBrains base and yields nothing. + +use std::path::{Path, PathBuf}; + +use crate::agent::Agent; +use crate::clients::common::parse_json_servers_map; +use crate::error::Result; +use crate::types::{DiscoveredServer, EdisonInstall, EdisonStyle, Scope, SourceKind}; +use crate::watch::WatchTargets; + +/// One JetBrains IDE family. +pub struct JetBrains { + client_name: &'static str, + folder_prefix: &'static str, + config_files: Vec, +} + +impl JetBrains { + pub fn intellij() -> Result { + Ok(Self::from_base("intellij", "IntelliJIdea")) + } + + pub fn pycharm() -> Result { + Ok(Self::from_base("pycharm", "PyCharm")) + } + + pub fn webstorm() -> Result { + Ok(Self::from_base("webstorm", "WebStorm")) + } + + fn from_base(client_name: &'static str, folder_prefix: &'static str) -> Self { + let config_files = jetbrains_base() + .map(|base| enumerate(&base, folder_prefix)) + .unwrap_or_default(); + Self { + client_name, + folder_prefix, + config_files, + } + } + + /// Construct from explicit `servers.json` paths (tests). + pub fn from_files(client_name: &'static str, config_files: Vec) -> Self { + Self { + client_name, + folder_prefix: "", + config_files, + } + } +} + +impl Agent for JetBrains { + fn name(&self) -> &'static str { + self.client_name + } + + fn is_installed(&self) -> bool { + self.config_files.iter().any(|p| p.exists()) + } + + fn watch_targets(&self) -> WatchTargets { + WatchTargets { + files: self.config_files.clone(), + dirs: Vec::new(), + needs_periodic_rescan: false, + } + } + + fn discover(&self) -> Result> { + let mut out = Vec::new(); + for p in &self.config_files { + if p.exists() { + out.extend(parse_json_servers_map( + p, + "mcpServers", + self.client_name, + Scope::Global, + SourceKind::Json, + )); + } + } + Ok(out) + } + + fn edison_installs(&self, home: &Path) -> Vec { + // One entry per installed IDE version dir under this user's home. + jetbrains_base_in(home) + .map(|base| enumerate(&base, self.folder_prefix)) + .unwrap_or_default() + .into_iter() + .map(|path| EdisonInstall { + path, + key_path: vec!["mcpServers".into()], + style: EdisonStyle::Http, + client_id: self.client_name.to_string(), + prefer_cli: false, + }) + .collect() + } +} + +fn jetbrains_base() -> Option { + if cfg!(target_os = "macos") { + jetbrains_base_in(&dirs::home_dir()?) + } else if cfg!(target_os = "windows") { + Some(dirs::config_dir()?.join("JetBrains")) + } else { + None // Linux: unsupported + } +} + +/// The JetBrains base dir under `home` (macOS). Returns `None` off macOS. +fn jetbrains_base_in(home: &Path) -> Option { + if cfg!(target_os = "macos") { + Some(home.join("Library/Application Support/JetBrains")) + } else { + None + } +} + +/// `/*/mcp/servers.json` for every version-suffixed IDE folder. +fn enumerate(base: &std::path::Path, folder_prefix: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(base) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|e| { + let name = e.file_name(); + if name.to_string_lossy().starts_with(folder_prefix) { + Some(e.path().join("mcp").join("servers.json")) + } else { + None + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn discovers_servers_from_explicit_files() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("servers.json"); + std::fs::write(&cfg, r#"{"mcpServers":{"idea":{"command":"x"}}}"#).unwrap(); + let servers = JetBrains::from_files("intellij", vec![cfg]) + .discover() + .unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "idea"); + assert_eq!(servers[0].client, "intellij"); + } + + #[test] + fn enumerates_version_suffixed_folders() { + let dir = tempdir().unwrap(); + for folder in ["IntelliJIdea2024.3", "IntelliJIdea2025.1", "PyCharm2024.3"] { + std::fs::create_dir_all(dir.path().join(folder).join("mcp")).unwrap(); + std::fs::write( + dir.path().join(folder).join("mcp").join("servers.json"), + r#"{"mcpServers":{}}"#, + ) + .unwrap(); + } + let files = enumerate(dir.path(), "IntelliJIdea"); + assert_eq!(files.len(), 2); // two IntelliJIdea folders, not PyCharm + } +} diff --git a/crates/mcp_detector_lib/src/clients/mod.rs b/crates/mcp_detector_lib/src/clients/mod.rs index 7ee03b2..afb2fa0 100644 --- a/crates/mcp_detector_lib/src/clients/mod.rs +++ b/crates/mcp_detector_lib/src/clients/mod.rs @@ -1,19 +1,83 @@ -//! Bundled [`Client`](crate::Client) implementations. Each one is gated -//! behind its own cargo feature so consumers can opt in only to the clients -//! they care about. +//! Bundled [`Agent`](crate::Agent) implementations. Each one is gated behind +//! its own cargo feature so consumers can opt in only to the agents they care +//! about. -#[cfg(any(feature = "claude_code", feature = "vscode"))] +// Shared helpers, compiled whenever at least one agent is enabled. +#[cfg(any( + feature = "claude_code", + feature = "vscode", + feature = "cursor", + feature = "claude_desktop", + feature = "claude_cowork", + feature = "windsurf", + feature = "zed", + feature = "jetbrains", + feature = "codex" +))] +mod common; +#[cfg(any( + feature = "claude_code", + feature = "vscode", + feature = "cursor", + feature = "claude_desktop", + feature = "claude_cowork", + feature = "windsurf", + feature = "zed", + feature = "jetbrains", + feature = "codex" +))] mod transport; +// SQLite state-DB reader, shared by the editors that use `state.vscdb`. +#[cfg(any(feature = "vscode", feature = "cursor"))] +mod statedb; #[cfg(feature = "claude_code")] pub mod claude_code; +#[cfg(feature = "claude_cowork")] +pub mod claude_cowork; +#[cfg(feature = "claude_desktop")] +pub mod claude_desktop; +#[cfg(feature = "codex")] +pub mod codex; +#[cfg(feature = "cursor")] +pub mod cursor; +#[cfg(feature = "jetbrains")] +pub mod jetbrains; #[cfg(feature = "vscode")] pub mod vscode; +#[cfg(feature = "windsurf")] +pub mod windsurf; +#[cfg(feature = "zed")] +pub mod zed; #[cfg(feature = "claude_code")] pub use claude_code::ClaudeCode; +#[cfg(feature = "claude_cowork")] +pub use claude_cowork::ClaudeCowork; +#[cfg(feature = "claude_desktop")] +pub use claude_desktop::ClaudeDesktop; +#[cfg(feature = "codex")] +pub use codex::Codex; +#[cfg(feature = "cursor")] +pub use cursor::Cursor; +#[cfg(feature = "jetbrains")] +pub use jetbrains::JetBrains; #[cfg(feature = "vscode")] pub use vscode::VsCode; +#[cfg(feature = "windsurf")] +pub use windsurf::Windsurf; +#[cfg(feature = "zed")] +pub use zed::Zed; -#[cfg(any(feature = "claude_code", feature = "vscode"))] -pub(crate) use transport::detect_transport; +#[cfg(any( + feature = "claude_code", + feature = "vscode", + feature = "cursor", + feature = "claude_desktop", + feature = "claude_cowork", + feature = "windsurf", + feature = "zed", + feature = "jetbrains", + feature = "codex" +))] +pub(crate) use transport::{detect_transport, server_config_from_value}; diff --git a/crates/mcp_detector_lib/src/clients/statedb.rs b/crates/mcp_detector_lib/src/clients/statedb.rs new file mode 100644 index 0000000..da3c929 --- /dev/null +++ b/crates/mcp_detector_lib/src/clients/statedb.rs @@ -0,0 +1,34 @@ +//! Shared reader for editor "state DB" files (`state.vscdb`) — a SQLite +//! `ItemTable(key TEXT, value BLOB)`. Used by the VSCode and Cursor adapters. + +use std::path::Path; + +use rusqlite::OptionalExtension; + +use crate::error::{Error, Result}; + +/// Open `state.vscdb` read-only and return the value of a single `ItemTable` +/// row, or `None` if the key is absent. +/// +/// `immutable=1` tells SQLite the file won't change on disk so it can skip the +/// WAL and locking — safe to read while the editor is running. +pub(crate) fn read_state_db_value(db_path: &Path, key: &str) -> Result> { + let uri = format!("file:{}?mode=ro&immutable=1", db_path.to_string_lossy()); + let conn = rusqlite::Connection::open_with_flags( + &uri, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(|source| Error::Sqlite { + path: db_path.to_path_buf(), + source, + })?; + + conn.query_row("SELECT value FROM ItemTable WHERE key = ?1", [key], |r| { + r.get::<_, String>(0) + }) + .optional() + .map_err(|source| Error::Sqlite { + path: db_path.to_path_buf(), + source, + }) +} diff --git a/crates/mcp_detector_lib/src/clients/transport.rs b/crates/mcp_detector_lib/src/clients/transport.rs index 930813a..87252e3 100644 --- a/crates/mcp_detector_lib/src/clients/transport.rs +++ b/crates/mcp_detector_lib/src/clients/transport.rs @@ -1,8 +1,10 @@ -use crate::types::Transport; +use std::collections::BTreeMap; + +use crate::types::{HttpKind, ServerConfig, Transport}; /// Heuristic transport detection that works across all current MCP client -/// schemas: anything with a URL-style field, or an explicit `type` of `http` -/// or `sse`, is Remote; everything else is Stdio. +/// schemas: anything with a URL-style field, or an explicit `type` of `http`, +/// `sse`, or `streamable-http`, is Remote; everything else is Stdio. pub(crate) fn detect_transport(v: &serde_json::Value) -> Transport { let ty = v.get("type").and_then(|t| t.as_str()); if v.get("url").is_some() || matches!(ty, Some("http") | Some("sse") | Some("streamable-http")) @@ -12,3 +14,56 @@ pub(crate) fn detect_transport(v: &serde_json::Value) -> Transport { Transport::Stdio } } + +/// Parse one server-config JSON object into a [`ServerConfig`]. +/// +/// Returns `Some(Stdio|Http)` when the entry has an extractable command or url, +/// and `None` otherwise (a malformed or non-actionable entry the caller can +/// either skip or record as [`ServerConfig::Opaque`]). +pub(crate) fn server_config_from_value(v: &serde_json::Value) -> Option { + let obj = v.as_object()?; + let ty = obj.get("type").and_then(|t| t.as_str()); + + let url = obj.get("url").and_then(|u| u.as_str()); + let looks_http = + url.is_some() || matches!(ty, Some("http") | Some("sse") | Some("streamable-http")); + if looks_http { + // `type` may claim http while the url is absent/malformed — then it is + // not actionable, so fall through to `None`. + let url = url?.to_string(); + let headers = string_map(obj.get("headers")); + let kind = match ty { + Some("sse") => HttpKind::Sse, + Some("streamable-http") => HttpKind::StreamableHttp, + _ => HttpKind::Http, + }; + return Some(ServerConfig::Http { url, headers, kind }); + } + + if let Some(command) = obj.get("command").and_then(|c| c.as_str()) { + let args = obj + .get("args") + .and_then(|a| a.as_array()) + .map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let env = string_map(obj.get("env")); + return Some(ServerConfig::Stdio { + command: command.to_string(), + args, + env, + }); + } + + None +} + +/// Collect a JSON object of string→string, dropping non-string values. +fn string_map(v: Option<&serde_json::Value>) -> BTreeMap { + v.and_then(|v| v.as_object()) + .map(|m| { + m.iter() + .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() +} diff --git a/crates/mcp_detector_lib/src/clients/vscode.rs b/crates/mcp_detector_lib/src/clients/vscode.rs index b1932a3..90fa0d4 100644 --- a/crates/mcp_detector_lib/src/clients/vscode.rs +++ b/crates/mcp_detector_lib/src/clients/vscode.rs @@ -1,4 +1,4 @@ -//! VSCode [`Client`] implementation. Watches four sources: +//! VSCode [`Agent`](crate::Agent) implementation. Watches four sources: //! //! 1. **User-level `Code/User/mcp.json`** — the file users edit by hand. //! 2. **Per-workspace `/.vscode/mcp.json`** for every workspace @@ -21,13 +21,19 @@ use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; -use rusqlite::OptionalExtension; use serde::Deserialize; -use crate::client::Client; -use crate::clients::detect_transport; +use crate::agent::Agent; +use crate::clients::common::file_uri_to_path; +use crate::clients::statedb::read_state_db_value; +use crate::clients::{detect_transport, server_config_from_value}; use crate::error::{Error, Result}; -use crate::types::{McpServer, Scope, Transport}; +use crate::types::{ + ConfigLocation, DiscoveredServer, EdisonInstall, EdisonStyle, HookBinding, HookInstall, + HookScriptKind, HookStyle, HttpKind, LocationExtra, OpaqueReason, Scope, ServerConfig, + SourceKind, StateShape, Transport, +}; +use crate::watch::WatchTargets; const CLIENT_NAME: &str = "vscode"; @@ -131,31 +137,42 @@ impl VsCode { } } -impl Client for VsCode { +impl Agent for VsCode { fn name(&self) -> &'static str { CLIENT_NAME } - fn watch_paths(&self) -> Vec { - let mut v = Vec::new(); + fn is_installed(&self) -> bool { + self.global_config.as_ref().is_some_and(|p| p.exists()) + || self.state_db.as_ref().is_some_and(|p| p.exists()) + } + + fn watch_targets(&self) -> WatchTargets { + let mut files = Vec::new(); if let Some(p) = &self.global_config { - v.push(p.clone()); + files.push(p.clone()); } if let Some(p) = &self.state_db { - v.push(p.clone()); + files.push(p.clone()); } if let Some(d) = &self.extensions_dir { // Watch the extensions index file. Its parent dir contains every // extension subdir, so install/uninstall events fire there too. - v.push(d.join("extensions.json")); + files.push(d.join("extensions.json")); } for (_, cfg) in &self.project_configs { - v.push(cfg.clone()); + files.push(cfg.clone()); + } + WatchTargets { + files, + dirs: Vec::new(), + // state.vscdb and extension-API registrations change without + // firing fs events on the files we watch. + needs_periodic_rescan: true, } - v } - fn parse_all(&self) -> Result> { + fn discover(&self) -> Result> { let mut out = Vec::new(); if let Some(p) = &self.global_config && p.exists() @@ -179,6 +196,52 @@ impl Client for VsCode { } Ok(out) } + + fn edison_installs(&self, home: &Path) -> Vec { + // VSCode uses the `servers` key (not `mcpServers`). + vec![EdisonInstall { + path: vscode_user_dir_in(home).join("mcp.json"), + key_path: vec!["servers".into()], + style: EdisonStyle::Http, + client_id: "vscode".into(), + prefer_cli: false, + }] + } + + fn hook_install(&self, home: &Path) -> Option { + // The Copilot hooks file is entirely Edison-owned. + Some(HookInstall { + path: home.join(".copilot/hooks/edison-watch.json"), + style: HookStyle::CopilotFile, + client_id: "vscode".into(), + events: vec![ + HookBinding::new("SessionStart", None, HookScriptKind::SessionStart, false), + HookBinding::new("UserPromptSubmit", None, HookScriptKind::Registration, true), + HookBinding::new("PreToolUse", None, HookScriptKind::SessionHook, false), + HookBinding::new("Stop", None, HookScriptKind::SessionEnd, false), + ], + }) + } + + fn hook_workspace_targets(&self, _home: &Path) -> Vec { + // Each enumerated workspace's `/.vscode/tasks.json` (sibling of the + // workspace `mcp.json` we track). These come from discovery, so they're + // already absolute — `home` doesn't reshape them. + self.project_configs + .iter() + .filter_map(|(_, mcp)| mcp.parent().map(|d| d.join("tasks.json"))) + .collect() + } +} + +/// The VSCode `Code/User` dir under `home` (platform-specific). +fn vscode_user_dir_in(home: &Path) -> PathBuf { + if cfg!(target_os = "macos") { + home.join("Library/Application Support/Code/User") + } else { + // Linux: ~/.config/Code/User (Windows uses %APPDATA%, handled elsewhere). + home.join(".config/Code/User") + } } #[derive(Debug, Deserialize)] @@ -187,7 +250,7 @@ struct McpFile { servers: BTreeMap, } -fn parse_file(path: &Path, scope: Scope) -> Vec { +fn parse_file(path: &Path, scope: Scope) -> Vec { let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(e) => { @@ -211,12 +274,22 @@ fn parse_file(path: &Path, scope: Scope) -> Vec { parsed .servers .into_iter() - .map(|(name, val)| McpServer { - client: CLIENT_NAME, - name, - transport: detect_transport(&val), - scope: scope.clone(), - source: path.to_path_buf(), + .filter_map(|(name, val)| { + let config = server_config_from_value(&val)?; + Some(DiscoveredServer { + client: CLIENT_NAME, + transport: detect_transport(&val), + scope: scope.clone(), + config, + location: ConfigLocation { + kind: SourceKind::Jsonc, + path: path.to_path_buf(), + key_path: vec!["servers".into()], + server_key: name.clone(), + extra: LocationExtra::None, + }, + name, + }) }) .collect() } @@ -243,7 +316,7 @@ struct ExtensionServer { /// Read the `mcpToolCache` row out of `state.vscdb` and emit one [`McpServer`] /// per extension-registered MCP server. Mirrors edison-watch's /// `client_2/src/main/clients/vscode/discovery.ts:82-142`. -fn parse_mcp_tool_cache(db_path: &Path) -> Vec { +fn parse_mcp_tool_cache(db_path: &Path) -> Vec { let raw = match read_state_db_value(db_path, "mcpToolCache") { Ok(Some(s)) => s, Ok(None) => return Vec::new(), @@ -264,63 +337,77 @@ fn parse_mcp_tool_cache(db_path: &Path) -> Vec { let mut seen_ids: HashSet = HashSet::new(); for srv in cache.extension_servers { - let transport = if srv.server_url.is_some() { - Transport::Remote - } else { - Transport::Stdio - }; seen_ids.insert(srv.id.clone()); - out.push(McpServer { + // A serverUrl makes it an actionable Http server; without one it is an + // extension-registered server with no resolvable launch config — + // report-only (unfingerprint-able, skipped by enforcement). + let (transport, config) = match &srv.server_url { + Some(url) => ( + Transport::Remote, + ServerConfig::Http { + url: url.clone(), + headers: BTreeMap::new(), + kind: HttpKind::Http, + }, + ), + None => ( + Transport::Stdio, + // Removable from the state DB even without a launch config. + ServerConfig::Opaque { + removable: true, + reason: OpaqueReason::ExtensionServer, + }, + ), + }; + out.push(DiscoveredServer { client: CLIENT_NAME, - name: srv.id, transport, scope: Scope::Global, - source: db_path.to_path_buf(), + config, + location: state_db_location(db_path, &srv.id, "extensionServers"), + name: srv.id, }); } // serverTools entries that aren't already in extensionServers and don't // start with "mcp.config." (those come from file parsing and we cover - // them via parse_file). + // them via parse_file). No launch config is available here, so they are + // report-only. for (id, _tool) in cache.server_tools { if seen_ids.contains(&id) || id.starts_with("mcp.config.") { continue; } - out.push(McpServer { + out.push(DiscoveredServer { client: CLIENT_NAME, - name: id, transport: Transport::Stdio, scope: Scope::Global, - source: db_path.to_path_buf(), + config: ServerConfig::Opaque { + removable: true, + reason: OpaqueReason::ExtensionServer, + }, + location: state_db_location(db_path, &id, "serverTools"), + name: id, }); } out } -/// Open `state.vscdb` read-only and return the value of a single -/// `ItemTable` row, or `None` if the key is absent. -fn read_state_db_value(db_path: &Path, key: &str) -> Result> { - // `immutable=1` tells SQLite the file won't change on disk so it can skip - // the WAL and locking; this lets us read safely while VSCode is running. - let uri = format!("file:{}?mode=ro&immutable=1", db_path.to_string_lossy()); - let conn = rusqlite::Connection::open_with_flags( - &uri, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, - ) - .map_err(|source| Error::Sqlite { - path: db_path.to_path_buf(), - source, - })?; - - conn.query_row("SELECT value FROM ItemTable WHERE key = ?1", [key], |r| { - r.get::<_, String>(0) - }) - .optional() - .map_err(|source| Error::Sqlite { +/// Build a [`ConfigLocation`] for an entry in the `mcpToolCache` row, embedded +/// as an element of the given array, matched by its `id`. +fn state_db_location(db_path: &Path, id: &str, array_key: &str) -> ConfigLocation { + ConfigLocation { + kind: SourceKind::SqliteState, path: db_path.to_path_buf(), - source, - }) + key_path: Vec::new(), + server_key: id.to_string(), + extra: LocationExtra::StateDb { + item_key: "mcpToolCache".to_string(), + shape: StateShape::ArrayById { + array_key: array_key.to_string(), + }, + }, + } } fn global_mcp_path() -> Option { @@ -388,7 +475,7 @@ struct McpServerProviderContribution { /// - We can't tell whether the extension is enabled in any given workspace /// (per-workspace enable/disable lives in `state.vscdb`); we assume /// "installed implies in use", same model as how `mcp.json` works. -fn parse_extension_providers(extensions_dir: &Path) -> Vec { +fn parse_extension_providers(extensions_dir: &Path) -> Vec { let index_path = extensions_dir.join("extensions.json"); if !index_path.exists() { return Vec::new(); @@ -430,12 +517,24 @@ fn parse_extension_providers(extensions_dir: &Path) -> Vec { .map(|c| c.mcp_server_definition_providers) .unwrap_or_default(); for p in providers { - out.push(McpServer { + out.push(DiscoveredServer { client: CLIENT_NAME, - name: p.id, transport: Transport::Stdio, scope: Scope::Global, - source: pkg_path.clone(), + // Untouchable: contributed by an installed extension's + // package.json — nothing of ours to remove. + config: ServerConfig::Opaque { + removable: false, + reason: OpaqueReason::ExtensionProvider, + }, + location: ConfigLocation { + kind: SourceKind::Json, + path: pkg_path.clone(), + key_path: Vec::new(), + server_key: p.id.clone(), + extra: LocationExtra::None, + }, + name: p.id, }); } } @@ -475,39 +574,6 @@ fn discover_workspaces(db_path: &Path) -> Result> { .collect()) } -fn file_uri_to_path(uri: &str) -> Option { - let stripped = uri.strip_prefix("file://")?; - Some(PathBuf::from(percent_decode(stripped))) -} - -fn percent_decode(s: &str) -> String { - let bytes = s.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' - && i + 2 < bytes.len() - && let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) - { - out.push((h << 4) | l); - i += 3; - continue; - } - out.push(bytes[i]); - i += 1; - } - String::from_utf8_lossy(&out).into_owned() -} - -fn hex(b: u8) -> Option { - match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(b - b'a' + 10), - b'A'..=b'F' => Some(b - b'A' + 10), - _ => None, - } -} - #[cfg(test)] mod tests { use super::*; @@ -638,7 +704,7 @@ mod tests { Some(state_db.clone()), [proj_a.clone(), proj_b.clone()], ); - let watched = v.watch_paths(); + let watched = v.watch_targets().files; assert!(watched.contains(&global)); assert!(watched.contains(&state_db)); assert!(watched.contains(&proj_a.join(".vscode").join("mcp.json"))); @@ -659,7 +725,7 @@ mod tests { .unwrap(); let v = VsCode::from_paths(Some(global.clone()), None, [proj.clone()]); - let servers = v.parse_all().unwrap(); + let servers = v.discover().unwrap(); assert_eq!(servers.len(), 2); let by_name: BTreeMap<_, _> = servers.iter().map(|s| (s.name.clone(), s)).collect(); @@ -690,7 +756,7 @@ mod tests { assert_eq!(by_name["ext.bar"].transport, Transport::Stdio); for s in &servers { assert_eq!(s.scope, Scope::Global); - assert_eq!(s.source, db); + assert_eq!(s.location.path, db); assert_eq!(s.client, CLIENT_NAME); } } @@ -798,7 +864,7 @@ mod tests { assert_eq!(servers[0].name, "context7"); assert_eq!(servers[0].transport, Transport::Stdio); assert_eq!(servers[0].scope, Scope::Global); - assert!(servers[0].source.ends_with("package.json")); + assert!(servers[0].location.path.ends_with("package.json")); } #[test] @@ -860,7 +926,7 @@ mod tests { std::fs::create_dir_all(&ext_dir).unwrap(); let v = VsCode::from_paths(None, None, std::iter::empty::()) .with_extensions_dir(Some(ext_dir.clone())); - assert!(v.watch_paths().contains(&ext_dir.join("extensions.json"))); + assert!(v.watch_targets().files.contains(&ext_dir.join("extensions.json"))); } #[test] @@ -878,7 +944,7 @@ mod tests { make_test_vscdb(&db, &[("mcpToolCache", &value)]); let v = VsCode::from_paths(Some(global), Some(db), std::iter::empty::()); - let names: HashSet = v.parse_all().unwrap().into_iter().map(|s| s.name).collect(); + let names: HashSet = v.discover().unwrap().into_iter().map(|s| s.name).collect(); assert!(names.contains("file-srv")); assert!(names.contains("ext-srv")); } diff --git a/crates/mcp_detector_lib/src/clients/windsurf.rs b/crates/mcp_detector_lib/src/clients/windsurf.rs new file mode 100644 index 0000000..d89d5a9 --- /dev/null +++ b/crates/mcp_detector_lib/src/clients/windsurf.rs @@ -0,0 +1,83 @@ +//! Windsurf [`Agent`](crate::Agent) — a single user-level JSON config, +//! `~/.codeium/windsurf/mcp_config.json` (same on all platforms), key +//! `mcpServers`. + +use std::path::PathBuf; + +use crate::agent::Agent; +use crate::clients::common::parse_json_servers_map; +use crate::error::Result; +use crate::types::{DiscoveredServer, EdisonInstall, EdisonStyle, Scope, SourceKind}; +use crate::watch::WatchTargets; + +const CLIENT_NAME: &str = "windsurf"; + +pub struct Windsurf { + config: Option, +} + +impl Windsurf { + pub fn discover() -> Result { + Ok(Self { + config: dirs::home_dir().map(|h| h.join(".codeium/windsurf/mcp_config.json")), + }) + } + + pub fn from_path(config: Option) -> Self { + Self { config } + } +} + +impl Agent for Windsurf { + fn name(&self) -> &'static str { + CLIENT_NAME + } + + fn is_installed(&self) -> bool { + self.config.as_ref().is_some_and(|p| p.exists()) + } + + fn watch_targets(&self) -> WatchTargets { + WatchTargets { + files: self.config.clone().into_iter().collect(), + dirs: Vec::new(), + needs_periodic_rescan: false, + } + } + + fn discover(&self) -> Result> { + Ok(match self.config.as_ref().filter(|p| p.exists()) { + Some(p) => { + parse_json_servers_map(p, "mcpServers", CLIENT_NAME, Scope::Global, SourceKind::Json) + } + None => Vec::new(), + }) + } + + fn edison_installs(&self, home: &std::path::Path) -> Vec { + vec![EdisonInstall { + path: home.join(".codeium/windsurf/mcp_config.json"), + key_path: vec!["mcpServers".into()], + style: EdisonStyle::Http, + client_id: "windsurf".into(), + prefer_cli: false, + }] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn parses_servers() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("mcp_config.json"); + std::fs::write(&cfg, r#"{"mcpServers":{"a":{"command":"x"}}}"#).unwrap(); + let servers = Windsurf::from_path(Some(cfg)).discover().unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "a"); + assert_eq!(servers[0].client, CLIENT_NAME); + } +} diff --git a/crates/mcp_detector_lib/src/clients/zed.rs b/crates/mcp_detector_lib/src/clients/zed.rs new file mode 100644 index 0000000..6aff8a3 --- /dev/null +++ b/crates/mcp_detector_lib/src/clients/zed.rs @@ -0,0 +1,97 @@ +//! Zed [`Agent`](crate::Agent) — a single JSON settings file whose MCP servers +//! live under the top-level `context_servers` key. + +use std::path::{Path, PathBuf}; + +use crate::agent::Agent; +use crate::clients::common::parse_json_servers_map; +use crate::error::Result; +use crate::types::{DiscoveredServer, EdisonInstall, EdisonStyle, Scope, SourceKind}; +use crate::watch::WatchTargets; + +const CLIENT_NAME: &str = "zed"; +const SERVERS_KEY: &str = "context_servers"; + +pub struct Zed { + config: Option, +} + +impl Zed { + pub fn discover() -> Result { + Ok(Self { + config: default_config_path(), + }) + } + + pub fn from_path(config: Option) -> Self { + Self { config } + } +} + +impl Agent for Zed { + fn name(&self) -> &'static str { + CLIENT_NAME + } + + fn is_installed(&self) -> bool { + self.config.as_ref().is_some_and(|p| p.exists()) + } + + fn watch_targets(&self) -> WatchTargets { + WatchTargets { + files: self.config.clone().into_iter().collect(), + dirs: Vec::new(), + needs_periodic_rescan: false, + } + } + + fn discover(&self) -> Result> { + Ok(match self.config.as_ref().filter(|p| p.exists()) { + Some(p) => { + parse_json_servers_map(p, SERVERS_KEY, CLIENT_NAME, Scope::Global, SourceKind::Json) + } + None => Vec::new(), + }) + } + + fn edison_installs(&self, home: &Path) -> Vec { + // Note: install writes the nested `assistant.mcp_servers` (matching the + // app), even though discovery reads the top-level `context_servers`. + vec![EdisonInstall { + path: home.join(".config/zed/settings.json"), + key_path: vec!["assistant".into(), "mcp_servers".into()], + style: EdisonStyle::Http, + client_id: "zed".into(), + prefer_cli: false, + }] + } +} + +fn default_config_path() -> Option { + if cfg!(target_os = "windows") { + dirs::config_dir().map(|c| c.join("Zed").join("settings.json")) + } else { + // macOS + Linux both use ~/.config/zed/settings.json. + dirs::home_dir().map(|h| h.join(".config/zed/settings.json")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn parses_context_servers() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("settings.json"); + std::fs::write( + &cfg, + r#"{"context_servers":{"z":{"command":"zsrv"}},"other":1}"#, + ) + .unwrap(); + let servers = Zed::from_path(Some(cfg)).discover().unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "z"); + } +} diff --git a/crates/mcp_detector_lib/src/diff.rs b/crates/mcp_detector_lib/src/diff.rs index 0f040e3..6134fa7 100644 --- a/crates/mcp_detector_lib/src/diff.rs +++ b/crates/mcp_detector_lib/src/diff.rs @@ -1,10 +1,10 @@ //! Snapshot + diff used internally by the watcher to turn a freshly-parsed -//! list of [`McpServer`]s into a stream of [`ChangeEvent`]s. +//! list of [`DiscoveredServer`]s into a stream of [`ChangeEvent`]s. use std::collections::HashMap; use std::path::PathBuf; -use crate::types::{ChangeEvent, McpServer, Scope}; +use crate::types::{ChangeEvent, DiscoveredServer, Scope}; /// Identity used to detect "is this the same server as before?". A server is /// uniquely identified by `(source file, scope, name)` - that way the same @@ -16,7 +16,7 @@ type Key = (PathBuf, PathBuf, String); /// scope, name)`. The watcher keeps one of these per client. #[derive(Default)] pub(crate) struct Snapshot { - by_key: HashMap, + by_key: HashMap, } impl Snapshot { @@ -25,15 +25,15 @@ impl Snapshot { } /// Seed the snapshot without emitting events. - pub(crate) fn prime(&mut self, current: &[McpServer]) { + pub(crate) fn prime(&mut self, current: &[DiscoveredServer]) { self.by_key = current.iter().map(|s| (key(s), s.clone())).collect(); } /// Replace the snapshot with `current` and return `Added` / `Removed` /// events for any servers that appeared or disappeared. In-place edits /// (same key, different fields) are not reported yet. - pub(crate) fn update(&mut self, current: &[McpServer]) -> Vec { - let new_map: HashMap = + pub(crate) fn update(&mut self, current: &[DiscoveredServer]) -> Vec { + let new_map: HashMap = current.iter().map(|s| (key(s), s.clone())).collect(); let mut events = Vec::new(); @@ -53,26 +53,38 @@ impl Snapshot { } } -fn key(s: &McpServer) -> Key { +fn key(s: &DiscoveredServer) -> Key { let scope_tag = match &s.scope { Scope::Global => PathBuf::from(""), Scope::Project(p) => p.clone(), }; - (s.source.clone(), scope_tag, s.name.clone()) + (s.location.path.clone(), scope_tag, s.name.clone()) } #[cfg(test)] mod tests { use super::*; - use crate::types::{Scope, Transport}; + use crate::types::{ConfigLocation, LocationExtra, ServerConfig, SourceKind, Transport}; + use std::collections::BTreeMap; - fn s(name: &str) -> McpServer { - McpServer { + fn s(name: &str) -> DiscoveredServer { + DiscoveredServer { client: "test", name: name.into(), transport: Transport::Stdio, scope: Scope::Global, - source: PathBuf::from("/tmp/x.json"), + config: ServerConfig::Stdio { + command: "x".into(), + args: vec![], + env: BTreeMap::new(), + }, + location: ConfigLocation { + kind: SourceKind::Jsonc, + path: PathBuf::from("/tmp/x.json"), + key_path: vec!["mcpServers".into()], + server_key: name.into(), + extra: LocationExtra::None, + }, } } diff --git a/crates/mcp_detector_lib/src/error.rs b/crates/mcp_detector_lib/src/error.rs index e4a5dd7..2dfd04b 100644 --- a/crates/mcp_detector_lib/src/error.rs +++ b/crates/mcp_detector_lib/src/error.rs @@ -23,9 +23,9 @@ pub enum Error { source: io::Error, }, - /// SQLite failure while reading a client's state database (e.g. VSCode's - /// `state.vscdb`). Only present when the `vscode` feature is enabled. - #[cfg(feature = "vscode")] + /// SQLite failure while reading an editor's state database (e.g. VSCode's + /// or Cursor's `state.vscdb`). + #[cfg(any(feature = "vscode", feature = "cursor"))] #[error("sqlite error at {path}: {source}")] Sqlite { path: PathBuf, diff --git a/crates/mcp_detector_lib/src/fingerprint.rs b/crates/mcp_detector_lib/src/fingerprint.rs new file mode 100644 index 0000000..207fe6b --- /dev/null +++ b/crates/mcp_detector_lib/src/fingerprint.rs @@ -0,0 +1,179 @@ +//! Stable server fingerprint — the identity used to ask "is this server already +//! known to the backend?". +//! +//! # Frozen cross-implementation contract +//! +//! This is a **three-way contract**: the Python backend +//! (`servers_fingerprints.py::compute_server_fingerprint`) and the TS client +//! (`seenServersStore.ts::getServerFingerprint`) compute it independently and +//! it must stay byte-for-byte identical, or the daemon fails to recognise +//! already-known servers. Do not "improve" the algorithm here in isolation — +//! see `docs/architecture.md` §6. +//! +//! ```text +//! identifier = "{name}:{command}:{args joined by ' '}" (stdio, non-empty command) +//! | "{name}:{url}" (http, non-empty url) +//! | +//! every {placeholder} is normalised to a bare {} before hashing +//! fingerprint = first 16 hex chars of sha256(identifier) +//! ``` + +use sha2::{Digest, Sha256}; + +use crate::secret_detection::templatize_for_fingerprint; +use crate::types::ServerConfig; + +/// Compute the 16-hex-char fingerprint for a server, or `None` when it cannot +/// be fingerprinted (stdio with an empty command, or http with an empty url). +/// Secrets in the fingerprinted fields are templatised first so the result is +/// stable across rotated credentials and matches the backend's stored form. +pub fn fingerprint(name: &str, config: &ServerConfig) -> Option { + let templatized = templatize_for_fingerprint(config); + let identifier = match &templatized { + ServerConfig::Stdio { command, args, .. } => { + if command.is_empty() { + return None; + } + let command = normalize_placeholders(command); + let args = args + .iter() + .map(|a| normalize_placeholders(a)) + .collect::>() + .join(" "); + format!("{name}:{command}:{args}") + } + ServerConfig::Http { url, .. } => { + if url.is_empty() { + return None; + } + format!("{name}:{}", normalize_placeholders(url)) + } + ServerConfig::Opaque { .. } => return None, + }; + Some(hash16(&identifier)) +} + +/// `sha256(s)` hex-encoded, truncated to the first 16 chars (= first 8 bytes). +fn hash16(s: &str) -> String { + let digest = Sha256::digest(s.as_bytes()); + let mut out = String::with_capacity(16); + for byte in &digest[..8] { + use std::fmt::Write; + let _ = write!(out, "{byte:02x}"); + } + out +} + +/// Collapse every `{...}` template placeholder (no nested braces) to a bare +/// `{}`, matching the JS/Python `/\{[^{}]*\}/g → "{}"` normalisation so that a +/// placeholder's variable name never affects the fingerprint. +fn normalize_placeholders(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'{' { + // Find the next '}' and confirm no '{' lies between (the regex + // class is [^{}]*), i.e. a well-formed innermost placeholder. + if let Some(rel) = s[i + 1..].find('}') { + let inner = &s[i + 1..i + 1 + rel]; + if !inner.contains('{') { + out.push_str("{}"); + i += 1 + rel + 1; + continue; + } + } + } + let ch = s[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use crate::types::HttpKind; + + fn stdio(command: &str, args: &[&str]) -> ServerConfig { + ServerConfig::Stdio { + command: command.into(), + args: args.iter().map(|s| s.to_string()).collect(), + env: BTreeMap::new(), + } + } + + fn http(url: &str) -> ServerConfig { + ServerConfig::Http { + url: url.into(), + headers: BTreeMap::new(), + kind: HttpKind::Http, + } + } + + // Golden vectors computed directly from the canonical algorithm + // (sha256(identifier)[:16]). These pin the frozen contract; if one of + // these changes, the daemon has silently diverged from backend + client. + + #[test] + fn golden_stdio_basic() { + assert_eq!( + fingerprint("ctx7", &stdio("npx", &["-y", "ctx7-mcp"])).unwrap(), + "3b02d109f5583486" + ); + } + + #[test] + fn golden_http_basic() { + assert_eq!( + fingerprint("remote", &http("https://x.example/mcp")).unwrap(), + "9306a4c918d7d372" + ); + } + + #[test] + fn golden_stdio_templatized_secret() { + // The sk- token is templatised → "{SECRET}" → normalised → "{}", + // yielding identifier "srv:server:--token {}". + assert_eq!( + fingerprint( + "srv", + &stdio("server", &["--token", "sk-abc123def456ghi789jkl012mno345"]) + ) + .unwrap(), + "d29e144857eed9a4" + ); + } + + #[test] + fn golden_stdio_no_args() { + assert_eq!(fingerprint("bare", &stdio("node", &[])).unwrap(), "7de4c89d590bc157"); + } + + #[test] + fn placeholder_name_does_not_affect_fingerprint() { + // Two different placeholder names must collapse to the same fingerprint. + let a = fingerprint("s", &stdio("c", &["--k", "{TOKEN}"])); + let b = fingerprint("s", &stdio("c", &["--k", "{SOME_OTHER_NAME}"])); + assert_eq!(a, b); + } + + #[test] + fn empty_command_is_unfingerprintable() { + assert_eq!(fingerprint("x", &stdio("", &["a"])), None); + } + + #[test] + fn empty_url_is_unfingerprintable() { + assert_eq!(fingerprint("x", &http("")), None); + } + + #[test] + fn normalize_handles_multiple_and_adjacent_placeholders() { + assert_eq!(normalize_placeholders("a{X}b{YY}c"), "a{}b{}c"); + assert_eq!(normalize_placeholders("{A}{B}"), "{}{}"); + assert_eq!(normalize_placeholders("no braces"), "no braces"); + } +} diff --git a/crates/mcp_detector_lib/src/lib.rs b/crates/mcp_detector_lib/src/lib.rs index df51dd2..97ae609 100644 --- a/crates/mcp_detector_lib/src/lib.rs +++ b/crates/mcp_detector_lib/src/lib.rs @@ -5,7 +5,7 @@ //! VSCode, Cursor, Claude Desktop, ...), often across several files per //! client - a global user-level file, per-project files, and sometimes //! application state stored in a SQLite database. This crate unifies those -//! sources behind a single [`Client`] trait, watches them via an event-driven +//! sources behind a single [`Agent`] trait, watches them via an event-driven //! filesystem watcher, and emits a [`ChangeEvent`] whenever a server appears //! or disappears. //! @@ -16,10 +16,10 @@ //! //! ```no_run //! use std::sync::Arc; -//! use mcp_detector_lib::{Client, Result, Watcher, clients::{ClaudeCode, VsCode}}; +//! use mcp_detector_lib::{Agent, Result, Watcher, clients::{ClaudeCode, VsCode}}; //! //! fn main() -> Result<()> { -//! let clients: Vec> = vec![ +//! let clients: Vec> = vec![ //! Arc::new(VsCode::discover()?), //! Arc::new(ClaudeCode::discover()?), //! ]; @@ -47,22 +47,32 @@ //! - Subsequent removals emit [`ChangeEvent::Removed`]. //! - In-place edits (same name, different command/url/args) are not reported. //! -//! # Adding a new client +//! # Adding a new agent //! -//! Implement [`Client`] for a new struct, gate it behind a cargo feature, and +//! Implement [`Agent`] for a new struct, gate it behind a cargo feature, and //! register an instance with [`Watcher::new`]. The trait surface is just -//! three methods: [`Client::name`], [`Client::watch_paths`], and -//! [`Client::parse_all`]. The watcher takes care of debounced filesystem +//! four methods: [`Agent::name`], [`Agent::is_installed`], +//! [`Agent::watch_targets`], and [`Agent::discover`]. The watcher takes care of +//! debounced filesystem //! events, snapshot diffing, and event delivery. -pub mod client; +pub mod agent; pub mod clients; pub(crate) mod diff; pub mod error; +pub mod fingerprint; +pub mod secret_detection; pub mod types; +pub mod watch; pub mod watcher; -pub use client::Client; +pub use agent::Agent; pub use error::{Error, Result}; -pub use types::{ChangeEvent, McpServer, Scope, Transport}; +pub use fingerprint::fingerprint; +pub use types::{ + ChangeEvent, ConfigLocation, DiscoveredServer, EdisonInstall, EdisonStyle, HookBinding, + HookInstall, HookScriptKind, HookStyle, HttpKind, LocationExtra, OpaqueReason, Scope, + ServerConfig, SourceKind, StateShape, Transport, +}; +pub use watch::{WatchDir, WatchTargets}; pub use watcher::{Watcher, WatcherHandle}; diff --git a/crates/mcp_detector_lib/src/secret_detection.rs b/crates/mcp_detector_lib/src/secret_detection.rs new file mode 100644 index 0000000..5a56b2b --- /dev/null +++ b/crates/mcp_detector_lib/src/secret_detection.rs @@ -0,0 +1,401 @@ +//! Templatisation of secrets embedded in a server's launch config, so that a +//! freshly-discovered server with a real credential fingerprints the same as +//! the templatised form the backend stored (concrete secrets → `{...}` +//! placeholders). +//! +//! # Scope +//! +//! The [fingerprint](crate::fingerprint) consumes only `command`, `args`, and +//! `url` — never `env` or `headers` — so this module only templatises secrets +//! that appear *in those fields*. +//! +//! # Parity +//! +//! The detection rules mirror client_2's `secretDetection.ts` (see +//! `docs/architecture.md` §6). Matching *exactly* matters both ways: missing a +//! secret leaves a raw value where the backend has `{}`, and over-detecting +//! templatises a value the backend left literal — either diverges the +//! fingerprint. So the prefix set, the flag-name heuristic, and the non-secret +//! filters are all kept in lockstep with the client. + +use crate::types::ServerConfig; + +/// The placeholder a detected secret is replaced with. The fingerprint then +/// normalises every `{...}` to a bare `{}`, so the exact text here never affects +/// the hash — only *which tokens* we replace does. +const PLACEHOLDER: &str = "{SECRET}"; + +/// Known credential prefixes (kept identical to the client's set). +const SECRET_PREFIXES: &[&str] = &[ + "sk-", // OpenAI + "sk_live_", // Stripe live + "sk_test_", // Stripe test + "ghp_", // GitHub personal + "gho_", // GitHub OAuth + "ghs_", // GitHub server + "github_pat_", // GitHub fine-grained PAT + "xoxb-", // Slack bot + "xoxp-", // Slack user + "xoxs-", // Slack (legacy) + "xapp-", // Slack app-level + "eyJ", // JWT header `{"alg":…}` base64 +]; + +/// Credential-bearing connection-string schemes. +const CONNECTION_STRING_PREFIXES: &[&str] = &["mongodb+srv://", "postgres://", "mysql://"]; + +/// Flag/param names that mark their value as a secret regardless of shape. +const SENSITIVE_KEY_WORDS: &[&str] = &[ + "key", + "token", + "secret", + "password", + "credential", + "auth", + "bearer", +]; + +/// Flags whose value is never a secret (prevents false positives on long paths +/// etc.). +const NON_SECRET_FLAGS: &[&str] = &[ + "-y", + "--yes", + "-n", + "--no", + "--verbose", + "--debug", + "--quiet", + "-q", + "--version", + "-v", + "--help", + "-h", + "--port", + "-p", + "--host", + "--name", + "--config", + "-c", + "--output", + "-o", + "--input", + "-i", + "--dir", + "--cwd", + "--format", + "--level", + "--log-level", + "--timeout", + "--retry", + "--max-retries", +]; + +/// Return a copy of `config` with secrets in fingerprint-relevant fields +/// (`command`, `args`, `url`) replaced by [`PLACEHOLDER`]. `env`/`headers` are +/// passed through unchanged — they do not feed the fingerprint. +pub fn templatize_for_fingerprint(config: &ServerConfig) -> ServerConfig { + match config { + ServerConfig::Stdio { command, args, env } => ServerConfig::Stdio { + command: templatize_token(command), + args: templatize_args(args), + env: env.clone(), + }, + ServerConfig::Http { url, headers, kind } => ServerConfig::Http { + url: templatize_url(url), + headers: headers.clone(), + kind: *kind, + }, + ServerConfig::Opaque { removable, reason } => ServerConfig::Opaque { + removable: *removable, + reason: *reason, + }, + } +} + +/// Templatise a CLI argument list, tracking the preceding flag so that the value +/// after a sensitive-named flag (`--api-key VALUE`) is replaced even when it +/// isn't high-entropy. +fn templatize_args(args: &[String]) -> Vec { + let mut out = Vec::with_capacity(args.len()); + let mut prev_sensitive_flag = false; + for arg in args { + if let Some(eq) = arg.find('=') { + // `--flag=value`: scan both the flag name and the value. + let (flag, rest) = arg.split_at(eq); + let value = &rest[1..]; + if !is_non_secret_flag(flag) && (is_sensitive_key_name(flag) || is_secret_value(value)) { + out.push(format!("{flag}={PLACEHOLDER}")); + } else { + out.push(arg.clone()); + } + prev_sensitive_flag = false; + } else if arg.starts_with('-') { + // A flag; remember whether it's a sensitive one for the next value. + out.push(arg.clone()); + prev_sensitive_flag = is_sensitive_key_name(arg) && !is_non_secret_flag(arg); + } else if prev_sensitive_flag { + // The value of a `--api-key VALUE`-style flag. + out.push(PLACEHOLDER.to_string()); + prev_sensitive_flag = false; + } else if let Some(masked) = templatize_auth_value(arg) { + out.push(masked); + prev_sensitive_flag = false; + } else if is_secret_value(arg) { + out.push(PLACEHOLDER.to_string()); + prev_sensitive_flag = false; + } else { + out.push(arg.clone()); + prev_sensitive_flag = false; + } + } + out +} + +/// Replace `s` wholesale if it looks like a secret, else return it unchanged. +fn templatize_token(s: &str) -> String { + if is_secret_value(s) { + PLACEHOLDER.to_string() + } else { + s.to_string() + } +} + +/// Templatise secrets inside a URL: userinfo (`user:pass@`) and any query +/// parameter whose name is sensitive or whose value looks like a secret. +fn templatize_url(url: &str) -> String { + let mut out = url.to_string(); + + // userinfo: scheme://@host + if let Some(scheme_end) = out.find("://") { + let after = scheme_end + 3; + if let Some(at_rel) = out[after..].find('@') { + let authority_end = out[after..].find('/').map(|s| after + s).unwrap_or(out.len()); + let at = after + at_rel; + if at < authority_end { + out.replace_range(after..at, PLACEHOLDER); + } + } + } + + // query parameter values + if let Some(q) = out.find('?') { + let (base, query) = out.split_at(q + 1); + let rebuilt: Vec = query + .split('&') + .map(|pair| match pair.split_once('=') { + Some((k, v)) if is_sensitive_key_name(k) || is_secret_value(v) => { + format!("{k}={PLACEHOLDER}") + } + _ => pair.to_string(), + }) + .collect(); + out = format!("{base}{}", rebuilt.join("&")); + } + + out +} + +/// A `Bearer ` / `Basic ` value → `Bearer {SECRET}`, keeping the +/// scheme prefix. Returns `None` if there's no such token. +fn templatize_auth_value(value: &str) -> Option { + let lower = value.to_lowercase(); + for scheme in ["bearer", "basic"] { + if let Some(pos) = lower.find(scheme) { + let after = pos + scheme.len(); + let rest = &value[after..]; + let trimmed = rest.trim_start(); + let ws = rest.len() - trimmed.len(); + if ws > 0 + && !trimmed.is_empty() + && (has_known_secret_prefix(trimmed) || looks_like_api_key(trimmed) || trimmed.len() >= 8) + { + return Some(format!("{}{PLACEHOLDER}", &value[..after + ws])); + } + } + } + None +} + +fn is_secret_value(v: &str) -> bool { + has_known_secret_prefix(v) || looks_like_api_key(v) +} + +fn has_known_secret_prefix(v: &str) -> bool { + SECRET_PREFIXES.iter().any(|p| v.starts_with(p)) + || CONNECTION_STRING_PREFIXES.iter().any(|p| v.starts_with(p)) +} + +fn is_non_secret_flag(flag: &str) -> bool { + NON_SECRET_FLAGS.contains(&flag) +} + +fn is_sensitive_key_name(name: &str) -> bool { + let lower = name.to_lowercase(); + SENSITIVE_KEY_WORDS.iter().any(|w| lower.contains(w)) +} + +/// High-entropy heuristic: ≥32 chars, ≥85% key-ish characters, and not an +/// obvious non-secret (URL/npm package/path). +fn looks_like_api_key(v: &str) -> bool { + if v.len() < 32 || looks_like_non_secret(v) { + return false; + } + let keyish = v + .chars() + .filter(|c| matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '+' | '/' | '=')) + .count(); + keyish * 100 > v.len() * 85 +} + +/// Values that are structurally not secrets: URLs, npm package specifiers, and +/// filesystem paths. +fn looks_like_non_secret(v: &str) -> bool { + if v.starts_with("http://") || v.starts_with("https://") { + return true; + } + if is_npm_scoped(v) { + return true; + } + if is_npm_bare(v) && !has_known_secret_prefix(v) { + return true; + } + if v.starts_with('/') || v.starts_with("./") || v.starts_with('~') { + return true; + } + is_windows_path(v) +} + +/// `@scope/name` — starts with `@`, then `[\w-]+ / [\w.-]+`. +fn is_npm_scoped(v: &str) -> bool { + let Some(rest) = v.strip_prefix('@') else { + return false; + }; + let Some((scope, name)) = rest.split_once('/') else { + return false; + }; + !scope.is_empty() + && scope.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + && !name.is_empty() + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-') +} + +/// `^[a-z][\w.-]*$` — a bare lowercase package/identifier. +fn is_npm_bare(v: &str) -> bool { + let mut chars = v.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_lowercase()) + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-') +} + +/// `^[A-Z]:\` — a Windows drive path. +fn is_windows_path(v: &str) -> bool { + let b = v.as_bytes(); + b.len() >= 3 && b[0].is_ascii_uppercase() && b[1] == b':' && b[2] == b'\\' +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn stdio(command: &str, args: &[&str]) -> ServerConfig { + ServerConfig::Stdio { + command: command.into(), + args: args.iter().map(|s| s.to_string()).collect(), + env: BTreeMap::new(), + } + } + fn args_of(c: &ServerConfig) -> Vec { + let ServerConfig::Stdio { args, .. } = templatize_for_fingerprint(c) else { + panic!() + }; + args + } + + #[test] + fn templatizes_known_prefix_arg() { + assert_eq!( + args_of(&stdio("server", &["--token", "sk-abc123def456ghi789jkl012mno345"])), + vec!["--token", "{SECRET}"] + ); + } + + #[test] + fn templatizes_flag_equals_value() { + assert_eq!( + args_of(&stdio("server", &["--token=ghp_0123456789abcdefghijklmnopqrstuvwxyz"])), + vec!["--token={SECRET}"] + ); + } + + #[test] + fn stripe_and_slack_prefixes() { + assert_eq!(args_of(&stdio("s", &["sk_live_0123456789abcdefXYZ"])), vec!["{SECRET}"]); + assert_eq!(args_of(&stdio("s", &["xapp-1-A-2-longtokenvalue"])), vec!["{SECRET}"]); + } + + #[test] + fn connection_strings_are_secret() { + assert_eq!( + args_of(&stdio("s", &["mongodb+srv://u:p@cluster.example/db"])), + vec!["{SECRET}"] + ); + } + + #[test] + fn sensitive_flag_name_templatizes_short_value() { + // Value isn't high-entropy, but the flag name is sensitive. + assert_eq!(args_of(&stdio("s", &["--api-key", "short"])), vec!["--api-key", "{SECRET}"]); + assert_eq!(args_of(&stdio("s", &["--password=hunter2"])), vec!["--password={SECRET}"]); + } + + #[test] + fn non_secret_flags_and_values_left_alone() { + // A long path is high-entropy-ish but must not be templatized. + assert_eq!( + args_of(&stdio("npx", &["-y", "ctx7-mcp", "--port", "8080", "--config", "/very/long/path/to/config/file.json"])), + vec!["-y", "ctx7-mcp", "--port", "8080", "--config", "/very/long/path/to/config/file.json"] + ); + } + + #[test] + fn npm_scoped_package_not_secret() { + assert_eq!( + args_of(&stdio("npx", &["-y", "@modelcontextprotocol/server-everything-and-more"])), + vec!["-y", "@modelcontextprotocol/server-everything-and-more"] + ); + } + + #[test] + fn bearer_token_extraction() { + assert_eq!( + args_of(&stdio("s", &["Authorization: Bearer sk-abc123def456ghi789jkl0"])), + vec!["Authorization: Bearer {SECRET}"] + ); + } + + #[test] + fn templatizes_url_query_and_userinfo() { + let c = ServerConfig::Http { + url: "https://user:supersecretpasswordvalue1234@h.example/mcp?key=sk-abc123def456ghi789".into(), + headers: BTreeMap::new(), + kind: crate::types::HttpKind::Http, + }; + let ServerConfig::Http { url, .. } = templatize_for_fingerprint(&c) else { + panic!() + }; + assert_eq!(url, "https://{SECRET}@h.example/mcp?key={SECRET}"); + } + + #[test] + fn url_query_sensitive_name() { + let c = ServerConfig::Http { + url: "https://h.example/mcp?token=short&page=2".into(), + headers: BTreeMap::new(), + kind: crate::types::HttpKind::Http, + }; + let ServerConfig::Http { url, .. } = templatize_for_fingerprint(&c) else { + panic!() + }; + assert_eq!(url, "https://h.example/mcp?token={SECRET}&page=2"); + } +} diff --git a/crates/mcp_detector_lib/src/types.rs b/crates/mcp_detector_lib/src/types.rs index b43f58d..5c25c87 100644 --- a/crates/mcp_detector_lib/src/types.rs +++ b/crates/mcp_detector_lib/src/types.rs @@ -1,12 +1,13 @@ //! Normalised representation of an MCP server and the change events the //! watcher emits about them. +use std::collections::BTreeMap; use std::path::PathBuf; -/// One MCP server entry, parsed out of some client's config and normalised -/// to a shape that's the same regardless of where it came from. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct McpServer { +/// One MCP server entry, parsed out of some agent's config and normalised to a +/// shape that's the same regardless of where it came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredServer { /// Identifier of the producing [`crate::Client`] (e.g. `"vscode"`, /// `"claude_code"`). Useful for filtering events by source. pub client: &'static str, @@ -14,16 +15,18 @@ pub struct McpServer { /// `mcpServers`). pub name: String, /// Whether the server is reached via a child-process pipe ([`Transport::Stdio`]) - /// or over the network ([`Transport::Remote`]). Detected from the entry's - /// shape: presence of a `url` or a `type` of `http`/`sse`/`streamable-http` - /// implies remote. + /// or over the network ([`Transport::Remote`]). pub transport: Transport, - /// Whether the server applies globally across the client or only inside a + /// Whether the server applies globally across the agent or only inside a /// specific project directory. See [`Scope`]. pub scope: Scope, - /// On-disk file the entry was parsed from. The parent directory of this - /// path is what the watcher actually subscribes to. - pub source: PathBuf, + /// Raw launch config: the payload needed to fingerprint and to act on the + /// server. [`ServerConfig::Opaque`] marks an entry with no launch config + /// (report-only, or removable-locally-only — see the variant). + pub config: ServerConfig, + /// Where the entry lives on disk and how to mutate it. The parent directory + /// of [`ConfigLocation::path`] is what the watcher subscribes to. + pub location: ConfigLocation, } /// How a client talks to a given MCP server. @@ -64,6 +67,239 @@ impl std::fmt::Display for Scope { } } +/// Raw, normalised launch configuration for a server — the payload the daemon +/// needs both to compute the [fingerprint](crate::fingerprint) and to act on +/// the server. Mirrors the stdio/http arms of client_2's `McpServerConfig` +/// union; the unsupported/opaque arm (no extractable command or url) is simply +/// not emitted by adapters, so it has no variant here. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ServerConfig { + /// Spawned as a subprocess over stdio. + Stdio { + command: String, + args: Vec, + /// Environment overrides. Note: env values never feed the fingerprint + /// (only `command`/`args` do), but they are carried for actioning. + env: BTreeMap, + }, + /// Reached over the network. + Http { + url: String, + headers: BTreeMap, + kind: HttpKind, + }, + /// Discovered but with no extractable command/url, so it cannot be + /// fingerprinted or submitted to Edison Watch. + /// + /// `removable` distinguishes the two report-only cases: when `true` we can + /// still *neutralise it locally* (delete the entry / rename its dir) even + /// though we can't move it to EW — so enforcement removes it. When `false` + /// it is genuinely untouchable (no access to its storage) and stays + /// report-only. + Opaque { + removable: bool, + reason: OpaqueReason, + }, +} + +/// Why a discovered server is [`ServerConfig::Opaque`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum OpaqueReason { + /// VSCode extension contributing `mcpServerDefinitionProviders` — registered + /// in-process; no config and nothing we own to remove (untouchable). + ExtensionProvider, + /// An extension-registered server in `state.vscdb` with no resolvable + /// command/url — removable from the state DB. + ExtensionServer, + /// Cursor marketplace plugin (`SERVER_METADATA.json`) — removable by + /// renaming the plugin directory. + CursorPlugin, +} + +/// Flavour of a remote transport. Metadata only — it does not affect the +/// fingerprint (which keys on the url alone). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum HttpKind { + /// `type` absent but a `url` is present, or `type: "http"`. + Http, + /// `type: "sse"`. + Sse, + /// `type: "streamable-http"`. + StreamableHttp, +} + +/// Where a discovered server lives on disk and how it must be mutated. Produced +/// by the adapter during discovery so the (separate) writer never has to +/// re-derive an agent's schema — it dispatches purely on [`SourceKind`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigLocation { + /// Selects the write mechanism. + pub kind: SourceKind, + /// The config file (or, for [`SourceKind::CursorPluginDir`], the plugin dir). + pub path: PathBuf, + /// Path to the servers map within the file, e.g. `["mcpServers"]`, + /// `["servers"]`, or `["projects", "/abs/proj", "mcpServers"]`. + pub key_path: Vec, + /// The map key to remove (the original on-disk name, pre any rename). + pub server_key: String, + /// Mechanism-specific extra data the writer needs. + pub extra: LocationExtra, +} + +/// The write mechanism a [`ConfigLocation`] dispatches to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SourceKind { + /// Plain JSON edited surgically. + Json, + /// JSON-with-comments edited surgically (VSCode `mcp.json`, `.claude.json`). + Jsonc, + /// TOML (Codex). + Toml, + /// A SQLite state DB row (VSCode/Cursor marketplace `state.vscdb`). + SqliteState, + /// Removed via the `claude mcp remove` CLI (Claude Code project scope). + ClaudeCli, + /// Neutralised by renaming the plugin directory (Cursor plugins). + CursorPluginDir, +} + +/// How to install the `edison-watch` proxy entry into an agent's config — the +/// inverse of quarantine (we *add* an entry). Produced by +/// [`Agent::edison_install`](crate::Agent::edison_install). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EdisonInstall { + /// The config file to write the entry into (created if absent). + pub path: PathBuf, + /// Path to the servers map within the file, e.g. `["mcpServers"]`, + /// `["servers"]`, `["mcp_servers"]`. + pub key_path: Vec, + /// The shape of the injected entry. + pub style: EdisonStyle, + /// The `?client=` value (the app id, e.g. `cursor`, `claude-code`). + pub client_id: String, + /// Prefer the agent's own CLI (`claude mcp add`) over a direct file write, + /// falling back to `path` if the CLI is unavailable. Claude Code needs this. + pub prefer_cli: bool, +} + +/// The shape of an installed `edison-watch` entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EdisonStyle { + /// `{ "type": "http", "url": … }` in a JSON/JSONC file. + Http, + /// `{ "command": "npx", "args": ["-y","mcp-remote", url] }` — for stdio-only + /// clients (Claude Desktop / Cowork). + StdioShim, + /// `[mcp_servers.edison-watch]` in a TOML file (Codex). + Toml, +} + +/// How to inject Edison Watch hooks into an agent's config (phase-2 mirror of +/// [`EdisonInstall`]). The injected commands run scripts materialised into +/// `~/.edison-watch/`; the scripts are self-contained (they only write files +/// there — no network). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookInstall { + /// The config file the hooks go into. + pub path: PathBuf, + /// How the hooks nest in that file. + pub style: HookStyle, + /// The client id passed to the registration script (e.g. `claude-code`). + pub client_id: String, + /// The hook bindings to inject. + pub events: Vec, +} + +/// The per-agent shape of injected hooks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookStyle { + /// Claude Code `~/.claude/settings.json`: + /// `hooks. = [{ matcher, hooks: [{type, command}] }]`. + ClaudeSettings, + /// Cursor `~/.cursor/hooks.json`: `hooks. = [{type, command}]`. + CursorHooks, + /// VSCode Copilot `~/.copilot/hooks/edison-watch.json`: the whole file is + /// Edison-owned. + CopilotFile, + /// Codex `~/.codex/config.toml`: `[[hooks.]]\ncommand = "…"`. + CodexToml, +} + +/// One hook binding: an event, an optional matcher, and which script it runs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookBinding { + /// The event key in the config (e.g. `UserPromptSubmit`, `beforeMCPExecution`). + pub event: String, + /// Claude Code tool matcher (`*`, `mcp__*`); ignored by other styles. + pub matcher: Option, + /// Which materialised script this binding runs. + pub script: HookScriptKind, + /// Whether the command passes the client id as an argument (registration). + pub pass_client_arg: bool, +} + +impl HookBinding { + /// Terse constructor for the per-agent binding tables. + pub fn new( + event: &str, + matcher: Option<&str>, + script: HookScriptKind, + pass_client_arg: bool, + ) -> Self { + Self { + event: event.to_string(), + matcher: matcher.map(str::to_string), + script, + pass_client_arg, + } + } +} + +/// Which of the four materialised scripts a [`HookBinding`] runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookScriptKind { + /// `edison-hook.sh ` — writes a project-registration pending file. + Registration, + /// `edison-session-start.py` — persists the session id. + SessionStart, + /// `edison-session-hook.py` — tags MCP tool calls with the conversation id. + SessionHook, + /// `edison-session-end.py` — writes a session-end pending file. + SessionEnd, +} + +/// Mechanism-specific data carried by a [`ConfigLocation`]. +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub enum LocationExtra { + /// Nothing extra needed. + #[default] + None, + /// Absolute project directory to run `claude mcp remove` in, for + /// [`SourceKind::ClaudeCli`]. + ClaudeProjectDir(PathBuf), + /// The server lives inside a `state.vscdb` row's JSON value + /// ([`SourceKind::SqliteState`]). + StateDb { + /// The `ItemTable` key of the row (e.g. `anysphere.cursor-mcp`, + /// `mcpToolCache`). + item_key: String, + /// How the server is embedded in that row's JSON value. + shape: StateShape, + }, +} + +/// How a server sits inside a `state.vscdb` row's JSON value. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum StateShape { + /// A key in a JSON object (`server_key` is the object key; the value is the + /// server, e.g. a URL string). Cursor marketplace OAuth. + ObjectKey, + /// An element of a JSON array, matched by its `id` field (`server_key` is + /// the id); `array_key` names the array within the row. VSCode + /// `mcpToolCache.extensionServers`. + ArrayById { array_key: String }, +} + /// Something happened to a tracked MCP server. /// /// Modifications to an existing server are not reported - only additions and @@ -72,9 +308,9 @@ impl std::fmt::Display for Scope { pub enum ChangeEvent { /// A new server was added to a tracked config (or one appeared because a /// new project's config came online). - Added(McpServer), + Added(DiscoveredServer), /// A previously-known server disappeared from its config. - Removed(McpServer), + Removed(DiscoveredServer), } impl std::fmt::Display for ChangeEvent { @@ -91,7 +327,7 @@ impl std::fmt::Display for ChangeEvent { s.name, s.scope, s.transport, - s.source.display(), + s.location.path.display(), ) } } diff --git a/crates/mcp_detector_lib/src/watch.rs b/crates/mcp_detector_lib/src/watch.rs new file mode 100644 index 0000000..7442ce3 --- /dev/null +++ b/crates/mcp_detector_lib/src/watch.rs @@ -0,0 +1,29 @@ +//! What an [`Agent`](crate::Agent) wants watched. + +use std::path::PathBuf; + +/// The filesystem locations an agent's config is spread across, plus whether it +/// has sources that change *without* emitting fs events (a SQLite state DB, an +/// extension API), which a driver must catch via a periodic rescan. +#[derive(Debug, Clone, Default)] +pub struct WatchTargets { + /// Individual files to watch. A driver typically subscribes to each file's + /// parent directory, since editors write configs via atomic rename, which + /// invalidates single-file watches. + pub files: Vec, + /// Directories to watch to a given depth — e.g. a workspace-storage or + /// plugin-cache dir where new projects/plugins appear over time. + pub dirs: Vec, + /// True when this agent has sources that mutate without firing fs events + /// (e.g. VSCode's `state.vscdb`, extension-API installs). A level-triggered + /// driver should fall back to a periodic rescan to catch them. + pub needs_periodic_rescan: bool, +} + +/// A directory to watch recursively to a bounded depth. +#[derive(Debug, Clone)] +pub struct WatchDir { + pub path: PathBuf, + /// Recursion depth (`0` = the directory itself only). + pub depth: usize, +} diff --git a/crates/mcp_detector_lib/src/watcher.rs b/crates/mcp_detector_lib/src/watcher.rs index 297485a..4534a97 100644 --- a/crates/mcp_detector_lib/src/watcher.rs +++ b/crates/mcp_detector_lib/src/watcher.rs @@ -12,7 +12,7 @@ use std::time::Duration; use notify::RecursiveMode; use notify_debouncer_full::{DebounceEventResult, new_debouncer}; -use crate::client::Client; +use crate::agent::Agent; use crate::diff::Snapshot; use crate::error::{Error, Result}; use crate::types::ChangeEvent; @@ -30,13 +30,13 @@ const STOP_CHECK_INTERVAL: Duration = Duration::from_millis(250); /// Both methods take an initial snapshot silently - only changes after that /// point produce events. pub struct Watcher { - clients: Vec>, + clients: Vec>, } impl Watcher { /// Create a watcher over the given clients. The list is fixed for the /// lifetime of the watcher; clients added later will not be observed. - pub fn new(clients: Vec>) -> Self { + pub fn new(clients: Vec>) -> Self { Self { clients } } @@ -89,7 +89,7 @@ impl Watcher { fn run_inner(self, stop: Arc, on_event: &mut dyn FnMut(ChangeEvent)) -> Result<()> { let mut dirs: HashSet = HashSet::new(); for c in &self.clients { - for p in c.watch_paths() { + for p in c.watch_targets().files { if let Some(parent) = p.parent() { dirs.insert(parent.to_path_buf()); } @@ -99,7 +99,7 @@ impl Watcher { let mut snapshots: Vec = Vec::with_capacity(self.clients.len()); for c in &self.clients { let mut snap = Snapshot::new(); - match c.parse_all() { + match c.discover() { Ok(servers) => { tracing::info!(client = c.name(), count = servers.len(), "initial snapshot"); snap.prime(&servers); @@ -135,7 +135,7 @@ impl Watcher { tracing::debug!(paths = ?e.paths, kind = ?e.kind, " event"); } for (c, snap) in self.clients.iter().zip(snapshots.iter_mut()) { - match c.parse_all() { + match c.discover() { Ok(current) => { tracing::debug!( client = c.name(), diff --git a/crates/mcp_detector_lib/tests/spawn_smoke.rs b/crates/mcp_detector_lib/tests/spawn_smoke.rs index 35d58e4..2487243 100644 --- a/crates/mcp_detector_lib/tests/spawn_smoke.rs +++ b/crates/mcp_detector_lib/tests/spawn_smoke.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use mcp_detector_lib::clients::VsCode; -use mcp_detector_lib::{ChangeEvent, Client, Watcher}; +use mcp_detector_lib::{ChangeEvent, Agent, Watcher}; use tempfile::tempdir; /// Wait up to `timeout` for an event matching `pred` to arrive on `events`. @@ -48,7 +48,7 @@ fn spawn_delivers_added_event_when_a_server_appears() { None, Vec::::new(), )); - let (events, _handle) = Watcher::new(vec![client as Arc]) + let (events, _handle) = Watcher::new(vec![client as Arc]) .spawn() .unwrap(); @@ -65,7 +65,7 @@ fn spawn_delivers_added_event_when_a_server_appears() { .expect("Added event for new-thing within 10s"); if let ChangeEvent::Added(s) = ev { assert_eq!(s.client, "vscode"); - assert_eq!(s.source, global); + assert_eq!(s.location.path, global); } } @@ -80,7 +80,7 @@ fn spawn_delivers_removed_event_when_a_server_disappears() { None, Vec::::new(), )); - let (events, _handle) = Watcher::new(vec![client as Arc]) + let (events, _handle) = Watcher::new(vec![client as Arc]) .spawn() .unwrap(); @@ -107,7 +107,7 @@ fn dropping_the_handle_stops_the_worker() { None, Vec::::new(), )); - let (events, handle) = Watcher::new(vec![client as Arc]) + let (events, handle) = Watcher::new(vec![client as Arc]) .spawn() .unwrap(); diff --git a/crates/mcp_quarantine/Cargo.toml b/crates/mcp_quarantine/Cargo.toml new file mode 100644 index 0000000..63de72a --- /dev/null +++ b/crates/mcp_quarantine/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "mcp_quarantine" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "MIT OR Apache-2.0" +description = "Mutation, persistent state, and the reconcile planner for MCP quarantine." + +[dependencies] +# Only the types + fingerprint are needed, not the concrete agents, so the +# agent features (and their rusqlite cost) are left off. +mcp_detector_lib = { path = "../mcp_detector_lib", default-features = false } +rusqlite = { version = "0.39.0", features = ["bundled"] } +serde = { version = "1.0.228", features = ["derive"] } +# preserve_order keeps a config's existing server order through a read-modify- +# write (we no longer alphabetise on every edit). Comments/whitespace are still +# reformatted — full CST preservation is a separate, larger change. +serde_json = { version = "1.0.149", features = ["preserve_order"] } +serde_json_lenient = "0.2.4" +toml = "0.9.8" +thiserror = "2.0.18" +tracing = "0.1.44" + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/mcp_quarantine/src/configstore.rs b/crates/mcp_quarantine/src/configstore.rs new file mode 100644 index 0000000..5a8c925 --- /dev/null +++ b/crates/mcp_quarantine/src/configstore.rs @@ -0,0 +1,1175 @@ +//! Config mutation — removing a server from its agent's config (quarantine) and +//! putting it back (restore), dispatched on [`SourceKind`]. +//! +//! The writers are **privilege-free file operations**: the daemon wraps them in +//! a privilege-drop when writing into a user's home. They are fully testable in +//! a tempdir. +//! +//! Safety control flow for [`FileConfigStore::quarantine`]: +//! 1. confirm the server is present in the original, +//! 2. back the original up, +//! 3. add the entry (plus restore metadata) to a `disabled_` sidecar, +//! 4. remove the entry from the original and write it — rolling the sidecar +//! back if that write fails, so the two files never disagree. +//! +//! ## v1 limitation +//! +//! The file writer round-trips through a JSON value, so comments and exact +//! formatting in the source are **not preserved** on edit (the key is removed +//! and the file re-serialised). This is structurally correct and never corrupts +//! the file; format-preserving surgical JSONC edits are a tracked follow-up. + +use std::path::{Path, PathBuf}; + +use mcp_detector_lib::{ + ConfigLocation, EdisonInstall, EdisonStyle, HttpKind, LocationExtra, ServerConfig, SourceKind, + StateShape, +}; +use serde_json::{Map, Value, json}; + +use crate::error::{Error, Result}; +use crate::statedb::{read_row, write_row}; + +const QUARANTINED_BY: &str = "Edison Watch"; +const META_ORIGINAL_FILE: &str = "_edisonOriginalFile"; +const META_KEY_PATH: &str = "_edisonKeyPath"; + +/// What a [`ConfigStore`] needs to undo a quarantine. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct QuarantineRecord { + pub kind: SourceKind, + pub source_path: PathBuf, + pub disabled_path: PathBuf, + pub backup_path: PathBuf, + pub key_path: Vec, + pub server_key: String, + /// Mechanism-specific data needed to reverse the quarantine (e.g. the + /// state.vscdb row + shape). Copied from the location. + #[serde(default)] + pub extra: LocationExtra, +} + +/// Removes a server from its config (quarantine) and restores it. +pub trait ConfigStore: Send + Sync { + fn quarantine(&self, loc: &ConfigLocation, cfg: &ServerConfig) -> Result; + fn restore(&self, rec: &QuarantineRecord) -> Result<()>; +} + +/// Writer covering every actionable [`SourceKind`]: JSON/JSONC files, Claude +/// Code project scope (nested edit of `~/.claude.json`), Codex TOML, marketplace +/// `state.vscdb` (SQLite), and Cursor plugin dirs (rename to `ew-disabled-*`). +#[derive(Default)] +pub struct FileConfigStore; + +impl ConfigStore for FileConfigStore { + fn quarantine(&self, loc: &ConfigLocation, cfg: &ServerConfig) -> Result { + match loc.kind { + // JSON-family, incl. Claude Code project scope (a nested edit of the + // JSON `~/.claude.json` — see note below). + SourceKind::Json | SourceKind::Jsonc | SourceKind::ClaudeCli => { + quarantine_json(loc, cfg) + } + SourceKind::Toml => quarantine_toml(loc, cfg), + SourceKind::SqliteState => quarantine_sqlite(loc), + SourceKind::CursorPluginDir => quarantine_plugin_dir(loc), + } + } + + fn restore(&self, rec: &QuarantineRecord) -> Result<()> { + match rec.kind { + SourceKind::Toml => restore_toml(rec), + SourceKind::SqliteState => restore_sqlite(rec), + SourceKind::CursorPluginDir => restore_plugin_dir(rec), + _ => restore_json(rec), + } + } +} + +// ── JSON / JSONC writer ────────────────────────────────────────────────────── +// +// Note: `ClaudeCli` (project-scoped Claude Code servers in `~/.claude.json`) is +// handled here as a direct nested JSON edit rather than shelling out to +// `claude mcp remove`. Simpler and dependency-free; the CLI path can replace it +// later if Claude Code's management of that file demands it. + +fn quarantine_json(loc: &ConfigLocation, cfg: &ServerConfig) -> Result { + let raw = read(&loc.path)?; + let mut root = parse(&raw, &loc.path)?; + + // 1. Confirm the server is present. + { + let map = nav_mut(&mut root, &loc.key_path) + .ok_or_else(|| Error::NotAnObject(loc.key_path.clone()))?; + if !map.contains_key(&loc.server_key) { + return Err(Error::NotFound(loc.server_key.clone())); + } + } + + // 2. Back up the original verbatim — ONCE, so the backup captures the full + // original even when several servers are quarantined from the same file. + let backup_path = backup_path(&loc.path); + if !backup_path.exists() { + write(&backup_path, &raw)?; + } + + // 3. Add to the disabled sidecar (with restore metadata). + let disabled_path = disabled_path(&loc.path); + let mut disabled = read_disabled(&disabled_path)?; + let entry = build_disabled_entry(cfg, &loc.path, &loc.key_path)?; + disabled_servers(&mut disabled).insert(loc.server_key.clone(), entry); + write(&disabled_path, &serialize(&disabled))?; + + // 4. Remove from the original; roll the sidecar back if the write fails. + nav_mut(&mut root, &loc.key_path) + .expect("key path validated above") + .remove(&loc.server_key); + if let Err(e) = write(&loc.path, &serialize(&root)) { + let _ = rollback_sidecar(&disabled_path, &loc.server_key); + return Err(e); + } + + Ok(record(loc, disabled_path, backup_path)) +} + +fn restore_json(rec: &QuarantineRecord) -> Result<()> { + let (disabled, mut entry) = take_disabled_entry(rec)?; + + let raw = read(&rec.source_path)?; + let mut root = parse(&raw, &rec.source_path)?; + if let Value::Object(m) = &mut entry { + m.retain(|k, _| !k.starts_with("_edison")); + } + nav_create(&mut root, &rec.key_path) + .ok_or_else(|| Error::NotAnObject(rec.key_path.clone()))? + .insert(rec.server_key.clone(), entry); + write(&rec.source_path, &serialize(&root))?; + + finalize_sidecar(rec, &disabled)?; + Ok(()) +} + +// ── TOML writer (Codex) ────────────────────────────────────────────────────── + +fn quarantine_toml(loc: &ConfigLocation, cfg: &ServerConfig) -> Result { + let raw = read(&loc.path)?; + let mut root: toml::Value = toml::from_str(&raw).map_err(|e| toml_err(&loc.path, e))?; + + { + let tbl = toml_nav_mut(&mut root, &loc.key_path) + .ok_or_else(|| Error::NotAnObject(loc.key_path.clone()))?; + if !tbl.contains_key(&loc.server_key) { + return Err(Error::NotFound(loc.server_key.clone())); + } + } + + let backup_path = backup_path(&loc.path); + if !backup_path.exists() { + write(&backup_path, &raw)?; + } + + // Sidecar is JSON (built from the config), independent of the source format. + let disabled_path = disabled_path(&loc.path); + let mut disabled = read_disabled(&disabled_path)?; + let entry = build_disabled_entry(cfg, &loc.path, &loc.key_path)?; + disabled_servers(&mut disabled).insert(loc.server_key.clone(), entry); + write(&disabled_path, &serialize(&disabled))?; + + toml_nav_mut(&mut root, &loc.key_path) + .expect("key path validated above") + .remove(&loc.server_key); + let new_text = toml::to_string(&root).map_err(|e| Error::Json { + path: loc.path.clone(), + message: e.to_string(), + })?; + if let Err(e) = write(&loc.path, &new_text) { + let _ = rollback_sidecar(&disabled_path, &loc.server_key); + return Err(e); + } + + Ok(record(loc, disabled_path, backup_path)) +} + +fn restore_toml(rec: &QuarantineRecord) -> Result<()> { + let (disabled, mut entry) = take_disabled_entry(rec)?; + if let Value::Object(m) = &mut entry { + m.retain(|k, _| !k.starts_with("_edison")); + } + let toml_entry = toml::Value::try_from(&entry).map_err(|e| Error::Json { + path: rec.source_path.clone(), + message: e.to_string(), + })?; + + let raw = read(&rec.source_path)?; + let mut root: toml::Value = toml::from_str(&raw).map_err(|e| toml_err(&rec.source_path, e))?; + toml_nav_create(&mut root, &rec.key_path) + .ok_or_else(|| Error::NotAnObject(rec.key_path.clone()))? + .insert(rec.server_key.clone(), toml_entry); + let new_text = toml::to_string(&root).map_err(|e| Error::Json { + path: rec.source_path.clone(), + message: e.to_string(), + })?; + write(&rec.source_path, &new_text)?; + + finalize_sidecar(rec, &disabled)?; + Ok(()) +} + +fn record(loc: &ConfigLocation, disabled_path: PathBuf, backup_path: PathBuf) -> QuarantineRecord { + QuarantineRecord { + kind: loc.kind, + source_path: loc.path.clone(), + disabled_path, + backup_path, + key_path: loc.key_path.clone(), + server_key: loc.server_key.clone(), + extra: loc.extra.clone(), + } +} + +// ── SQLite state.vscdb writer (Cursor marketplace, VSCode extensions) ───────── + +fn quarantine_sqlite(loc: &ConfigLocation) -> Result { + let LocationExtra::StateDb { item_key, shape } = &loc.extra else { + return Err(Error::UnsupportedKind(loc.kind)); + }; + + let raw = read_row(&loc.path, item_key)? + .ok_or_else(|| Error::NotFound(loc.server_key.clone()))?; + let mut blob: Value = serde_json::from_str(&raw).map_err(json_err(&loc.path))?; + + // Capture + remove the server's raw value (restore re-inserts it exactly). + let captured = blob_remove(&mut blob, shape, &loc.server_key)?; + + // Back up the whole DB (binary copy) — once. + let backup_path = backup_path(&loc.path); + if !backup_path.exists() { + std::fs::copy(&loc.path, &backup_path).map_err(|source| Error::Io { + path: loc.path.clone(), + source, + })?; + } + + let disabled_path = disabled_path(&loc.path); + let mut disabled = read_disabled(&disabled_path)?; + disabled_servers(&mut disabled).insert(loc.server_key.clone(), captured); + write(&disabled_path, &serialize(&disabled))?; + + let new_blob = serde_json::to_string(&blob).map_err(json_err(&loc.path))?; + if let Err(e) = write_row(&loc.path, item_key, &new_blob) { + let _ = rollback_sidecar(&disabled_path, &loc.server_key); + return Err(e); + } + + Ok(record(loc, disabled_path, backup_path)) +} + +fn restore_sqlite(rec: &QuarantineRecord) -> Result<()> { + let LocationExtra::StateDb { item_key, shape } = &rec.extra else { + return Err(Error::UnsupportedKind(rec.kind)); + }; + + let (disabled, captured) = take_disabled_entry(rec)?; + let raw = read_row(&rec.source_path, item_key)? + .ok_or_else(|| Error::NotFound(rec.server_key.clone()))?; + let mut blob: Value = serde_json::from_str(&raw).map_err(json_err(&rec.source_path))?; + blob_insert(&mut blob, shape, &rec.server_key, captured)?; + + let new_blob = serde_json::to_string(&blob).map_err(json_err(&rec.source_path))?; + write_row(&rec.source_path, item_key, &new_blob)?; + finalize_sidecar(rec, &disabled)?; + Ok(()) +} + +/// Remove and return the server's value from a state-DB blob. +fn blob_remove(blob: &mut Value, shape: &StateShape, server_key: &str) -> Result { + let missing = || Error::NotFound(server_key.to_string()); + match shape { + StateShape::ObjectKey => blob + .as_object_mut() + .and_then(|o| o.remove(server_key)) + .ok_or_else(missing), + StateShape::ArrayById { array_key } => { + let arr = blob + .get_mut(array_key) + .and_then(Value::as_array_mut) + .ok_or_else(missing)?; + let pos = arr + .iter() + .position(|e| e.get("id").and_then(Value::as_str) == Some(server_key)) + .ok_or_else(missing)?; + Ok(arr.remove(pos)) + } + } +} + +/// Re-insert a captured value back into a state-DB blob. +fn blob_insert(blob: &mut Value, shape: &StateShape, server_key: &str, value: Value) -> Result<()> { + let root = blob + .as_object_mut() + .ok_or_else(|| Error::NotFound(server_key.to_string()))?; + match shape { + StateShape::ObjectKey => { + root.insert(server_key.to_string(), value); + } + StateShape::ArrayById { array_key } => { + root.entry(array_key.clone()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| Error::NotFound(server_key.to_string()))? + .push(value); + } + } + Ok(()) +} + +// ── Cursor plugin-directory writer ─────────────────────────────────────────── +// +// Neutralise a plugin by renaming its directory to `ew-disabled-` (Cursor +// then ignores it; our discovery scan already skips `ew-disabled-*`). No sidecar +// or backup — the rename itself is the reversible state. + +fn quarantine_plugin_dir(loc: &ConfigLocation) -> Result { + let dir = &loc.path; + let name = dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "plugin".into()); + let disabled = dir.with_file_name(format!("ew-disabled-{name}")); + // A stale disabled copy from a prior quarantine blocks the rename + // (ENOTEMPTY); the live dir is the one to neutralise now, so drop the stale + // copy first. + if disabled.exists() { + let _ = std::fs::remove_dir_all(&disabled); + } + std::fs::rename(dir, &disabled).map_err(|source| Error::Io { + path: dir.clone(), + source, + })?; + Ok(QuarantineRecord { + kind: loc.kind, + source_path: dir.clone(), + disabled_path: disabled, + backup_path: dir.clone(), // unused for dir-rename + key_path: loc.key_path.clone(), + server_key: loc.server_key.clone(), + extra: loc.extra.clone(), + }) +} + +fn restore_plugin_dir(rec: &QuarantineRecord) -> Result<()> { + // If the plugin dir already exists again (Cursor re-created it), the disabled + // copy is redundant — drop it rather than fail the rename. + if rec.source_path.exists() { + let _ = std::fs::remove_dir_all(&rec.disabled_path); + return Ok(()); + } + std::fs::rename(&rec.disabled_path, &rec.source_path).map_err(|source| Error::Io { + path: rec.disabled_path.clone(), + source, + }) +} + +fn json_err(path: &Path) -> impl Fn(serde_json::Error) -> Error + '_ { + move |e| Error::Json { + path: path.to_path_buf(), + message: e.to_string(), + } +} + +/// Take the entry out of the sidecar; returns (sidecar-value, removed-entry). +fn take_disabled_entry(rec: &QuarantineRecord) -> Result<(Value, Value)> { + let mut disabled = read_disabled(&rec.disabled_path)?; + let entry = disabled_servers(&mut disabled) + .remove(&rec.server_key) + .ok_or_else(|| Error::NotFound(rec.server_key.clone()))?; + Ok((disabled, entry)) +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +pub(crate) fn read(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(|source| Error::Io { + path: path.to_path_buf(), + source, + }) +} + +pub(crate) fn write(path: &Path, contents: &str) -> Result<()> { + std::fs::write(path, contents).map_err(|source| Error::Io { + path: path.to_path_buf(), + source, + }) +} + +/// Parse JSON-with-comments into a value (lenient, matching how agents read). +pub(crate) fn parse(raw: &str, path: &Path) -> Result { + serde_json_lenient::from_str(raw).map_err(|e| Error::Json { + path: path.to_path_buf(), + message: e.to_string(), + }) +} + +pub(crate) fn serialize(value: &Value) -> String { + let mut s = serde_json::to_string_pretty(value).expect("Value always serialises"); + s.push('\n'); + s +} + +/// Navigate to the object map at `key_path` (read/modify; no creation). +fn nav_mut<'a>(root: &'a mut Value, key_path: &[String]) -> Option<&'a mut Map> { + let mut cur = root.as_object_mut()?; + for k in key_path { + cur = cur.get_mut(k)?.as_object_mut()?; + } + Some(cur) +} + +/// Navigate to the object map at `key_path`, creating empty objects as needed. +fn nav_create<'a>(root: &'a mut Value, key_path: &[String]) -> Option<&'a mut Map> { + let mut cur = root.as_object_mut()?; + for k in key_path { + cur = cur + .entry(k.clone()) + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut()?; + } + Some(cur) +} + +fn toml_err(path: &Path, e: toml::de::Error) -> Error { + Error::Json { + path: path.to_path_buf(), + message: e.to_string(), + } +} + +/// Navigate to the TOML table at `key_path` (read/modify; no creation). +fn toml_nav_mut<'a>(root: &'a mut toml::Value, key_path: &[String]) -> Option<&'a mut toml::Table> { + let mut cur = root.as_table_mut()?; + for k in key_path { + cur = cur.get_mut(k)?.as_table_mut()?; + } + Some(cur) +} + +/// Navigate to the TOML table at `key_path`, creating empty tables as needed. +fn toml_nav_create<'a>( + root: &'a mut toml::Value, + key_path: &[String], +) -> Option<&'a mut toml::Table> { + let mut cur = root.as_table_mut()?; + for k in key_path { + cur = cur + .entry(k.clone()) + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut()?; + } + Some(cur) +} + +fn disabled_path(path: &Path) -> PathBuf { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "config.json".into()); + // Daemon-distinct prefix (`ewd-` = edison-watch daemon) so we never share the + // Electron app's `disabled_.json` sidecar — different schema, and + // concurrent writes would race. + path.with_file_name(format!("ewd-disabled_{name}")) +} + +pub(crate) fn backup_path(path: &Path) -> PathBuf { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "config.json".into()); + path.with_file_name(format!("{name}.ew-backup")) +} + +// ── edison-watch install (the inverse of quarantine: ADD an entry) ─────────── + +const EDISON_SERVER_NAME: &str = "edison-watch"; +const EDISON_SECRET_HEADER: &str = "X-Edison-Secret-Key"; + +/// Install the `edison-watch` proxy entry into `inst`'s config (creating the +/// file if needed, alongside existing servers, with a one-time backup). The URL +/// is `/mcp//?client=`; when `secret` is set it is +/// carried in the `X-Edison-Secret-Key` header (or `--header` arg for the stdio +/// shim). +/// The edison-watch proxy URL: `/mcp//?client=`. +pub fn edison_url(mcp_base: &str, api_key: &str, client_id: &str) -> String { + format!( + "{}/mcp/{}/?client={}", + mcp_base.trim_end_matches('/'), + api_key, + client_id + ) +} + +pub fn install_edison( + inst: &EdisonInstall, + mcp_base: &str, + api_key: &str, + secret: Option<&str>, +) -> Result<()> { + let url = edison_url(mcp_base, api_key, &inst.client_id); + match inst.style { + EdisonStyle::Http => install_json(inst, http_entry(&url, secret)), + EdisonStyle::StdioShim => install_json(inst, stdio_shim_entry(&url, secret)), + EdisonStyle::Toml => install_toml(inst, &url, secret), + } +} + +fn http_entry(url: &str, secret: Option<&str>) -> Value { + let mut entry = json!({ "type": "http", "url": url }); + if let Some(s) = secret { + entry["headers"] = json!({ EDISON_SECRET_HEADER: s }); + } + entry +} + +fn stdio_shim_entry(url: &str, secret: Option<&str>) -> Value { + let mut args = vec![json!("-y"), json!("mcp-remote"), json!(url)]; + if let Some(s) = secret { + args.push(json!("--header")); + args.push(json!(format!("{EDISON_SECRET_HEADER}: {s}"))); + } + json!({ "command": "npx", "args": args }) +} + +/// Remove the `edison-watch` entry from `inst`'s config (no-op if absent). +pub fn uninstall_edison(inst: &EdisonInstall) -> Result<()> { + match inst.style { + EdisonStyle::Toml => uninstall_toml(inst), + _ => uninstall_json(inst), + } +} + +fn ensure_parent(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|source| Error::Io { + path: parent.to_path_buf(), + source, + })?; + } + Ok(()) +} + +fn install_json(inst: &EdisonInstall, entry: Value) -> Result<()> { + ensure_parent(&inst.path)?; + let existed = inst.path.exists(); + let raw = if existed { read(&inst.path)? } else { String::new() }; + let mut root = if raw.trim().is_empty() { + Value::Object(Map::new()) + } else { + parse(&raw, &inst.path)? + }; + if existed { + let bp = backup_path(&inst.path); + if !bp.exists() { + write(&bp, &raw)?; + } + } + nav_create(&mut root, &inst.key_path) + .ok_or_else(|| Error::NotAnObject(inst.key_path.clone()))? + .insert(EDISON_SERVER_NAME.to_string(), entry); + write(&inst.path, &serialize(&root)) +} + +fn uninstall_json(inst: &EdisonInstall) -> Result<()> { + if !inst.path.exists() { + return Ok(()); + } + let raw = read(&inst.path)?; + let mut root = parse(&raw, &inst.path)?; + if let Some(map) = nav_mut(&mut root, &inst.key_path) { + map.remove(EDISON_SERVER_NAME); + } + write(&inst.path, &serialize(&root)) +} + +fn install_toml(inst: &EdisonInstall, url: &str, secret: Option<&str>) -> Result<()> { + ensure_parent(&inst.path)?; + let existed = inst.path.exists(); + let raw = if existed { read(&inst.path)? } else { String::new() }; + let mut root: toml::Value = if raw.trim().is_empty() { + toml::Value::Table(toml::Table::new()) + } else { + toml::from_str(&raw).map_err(|e| toml_err(&inst.path, e))? + }; + if existed { + let bp = backup_path(&inst.path); + if !bp.exists() { + write(&bp, &raw)?; + } + } + let mut entry = toml::Table::new(); + entry.insert("url".into(), toml::Value::String(url.to_string())); + if let Some(s) = secret { + let mut headers = toml::Table::new(); + headers.insert(EDISON_SECRET_HEADER.into(), toml::Value::String(s.to_string())); + entry.insert("http_headers".into(), toml::Value::Table(headers)); + } + + let servers = root + .as_table_mut() + .ok_or_else(|| Error::NotAnObject(vec![]))? + .entry(inst.key_path[0].clone()) + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or_else(|| Error::NotAnObject(inst.key_path.clone()))?; + servers.insert(EDISON_SERVER_NAME.to_string(), toml::Value::Table(entry)); + + let text = toml::to_string(&root).map_err(|e| Error::Json { + path: inst.path.clone(), + message: e.to_string(), + })?; + write(&inst.path, &text) +} + +fn uninstall_toml(inst: &EdisonInstall) -> Result<()> { + if !inst.path.exists() { + return Ok(()); + } + let raw = read(&inst.path)?; + let mut root: toml::Value = toml::from_str(&raw).map_err(|e| toml_err(&inst.path, e))?; + if let Some(servers) = root + .as_table_mut() + .and_then(|t| t.get_mut(&inst.key_path[0])) + .and_then(|v| v.as_table_mut()) + { + servers.remove(EDISON_SERVER_NAME); + } + let text = toml::to_string(&root).map_err(|e| Error::Json { + path: inst.path.clone(), + message: e.to_string(), + })?; + write(&inst.path, &text) +} + +fn read_disabled(path: &Path) -> Result { + if path.exists() { + parse(&read(path)?, path) + } else { + Ok(json!({ "quarantinedBy": QUARANTINED_BY, "servers": {} })) + } +} + +/// Get (creating if needed) the `servers` map of a disabled sidecar value. +fn disabled_servers(disabled: &mut Value) -> &mut Map { + nav_create(disabled, &["servers".to_string()]).expect("disabled root is an object") +} + +fn rollback_sidecar(disabled_path: &Path, server_key: &str) -> Result<()> { + let mut disabled = read_disabled(disabled_path)?; + disabled_servers(&mut disabled).remove(server_key); + write(disabled_path, &serialize(&disabled)) +} + +/// After a restore, persist the sidecar: when it has no servers left, delete it +/// and the now-stale backup (the file is fully restored); otherwise write it +/// back with the remaining entries. +fn finalize_sidecar(rec: &QuarantineRecord, disabled: &Value) -> Result<()> { + let empty = disabled + .get("servers") + .and_then(Value::as_object) + .is_none_or(|m| m.is_empty()); + if empty { + let _ = std::fs::remove_file(&rec.disabled_path); + let _ = std::fs::remove_file(&rec.backup_path); + Ok(()) + } else { + write(&rec.disabled_path, &serialize(disabled)) + } +} + +/// Serialise a server config plus restore metadata for the sidecar. +fn build_disabled_entry(cfg: &ServerConfig, original: &Path, key_path: &[String]) -> Result { + let mut entry = config_to_value(cfg).ok_or(Error::NotActionable)?; + if let Value::Object(m) = &mut entry { + m.insert( + META_ORIGINAL_FILE.into(), + json!(original.to_string_lossy()), + ); + m.insert(META_KEY_PATH.into(), json!(key_path)); + } + Ok(entry) +} + +/// Render a [`ServerConfig`] back to its on-disk JSON shape. +fn config_to_value(cfg: &ServerConfig) -> Option { + match cfg { + ServerConfig::Stdio { command, args, env } => { + let mut m = Map::new(); + m.insert("command".into(), json!(command)); + if !args.is_empty() { + m.insert("args".into(), json!(args)); + } + if !env.is_empty() { + m.insert("env".into(), json!(env)); + } + Some(Value::Object(m)) + } + ServerConfig::Http { url, headers, kind } => { + let ty = match kind { + HttpKind::Http => "http", + HttpKind::Sse => "sse", + HttpKind::StreamableHttp => "streamable-http", + }; + let mut m = Map::new(); + m.insert("type".into(), json!(ty)); + m.insert("url".into(), json!(url)); + if !headers.is_empty() { + m.insert("headers".into(), json!(headers)); + } + Some(Value::Object(m)) + } + ServerConfig::Opaque { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use tempfile::tempdir; + + fn loc(path: &Path, key_path: &[&str], server_key: &str) -> ConfigLocation { + ConfigLocation { + kind: SourceKind::Jsonc, + path: path.to_path_buf(), + key_path: key_path.iter().map(|s| s.to_string()).collect(), + server_key: server_key.into(), + extra: mcp_detector_lib::LocationExtra::None, + } + } + + fn stdio(command: &str, args: &[&str]) -> ServerConfig { + ServerConfig::Stdio { + command: command.into(), + args: args.iter().map(|s| s.to_string()).collect(), + env: BTreeMap::new(), + } + } + + fn servers_in(path: &Path, key_path: &[&str]) -> Vec { + let mut root: Value = serde_json_lenient::from_str(&std::fs::read_to_string(path).unwrap()) + .unwrap(); + let kp: Vec = key_path.iter().map(|s| s.to_string()).collect(); + nav_mut(&mut root, &kp) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default() + } + + #[test] + fn quarantine_removes_from_original_and_writes_sidecar_and_backup() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("mcp.json"); + std::fs::write( + &cfg, + r#"{"servers":{"keep":{"command":"a"},"evil":{"command":"x","args":["--bad"]}}}"#, + ) + .unwrap(); + + let store = FileConfigStore; + let rec = store + .quarantine(&loc(&cfg, &["servers"], "evil"), &stdio("x", &["--bad"])) + .unwrap(); + + // Original keeps "keep", loses "evil". + assert_eq!(servers_in(&cfg, &["servers"]), vec!["keep".to_string()]); + // Backup is the verbatim original (still has evil). + assert!(rec.backup_path.exists()); + assert!(std::fs::read_to_string(&rec.backup_path).unwrap().contains("evil")); + // Sidecar holds evil + restore metadata. + let disabled: Value = + serde_json::from_str(&std::fs::read_to_string(&rec.disabled_path).unwrap()).unwrap(); + let entry = &disabled["servers"]["evil"]; + assert_eq!(entry["command"], "x"); + assert_eq!(entry[META_KEY_PATH], json!(["servers"])); + } + + fn edison(path: &std::path::Path, key: &[&str], style: EdisonStyle, client: &str) -> EdisonInstall { + EdisonInstall { + path: path.to_path_buf(), + key_path: key.iter().map(|s| s.to_string()).collect(), + style, + client_id: client.to_string(), + prefer_cli: false, + } + } + + #[test] + fn install_edison_http_adds_alongside_and_uninstall_removes() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("mcp.json"); + std::fs::write(&cfg, r#"{"mcpServers":{"keep":{"command":"x"}}}"#).unwrap(); + let inst = edison(&cfg, &["mcpServers"], EdisonStyle::Http, "cursor"); + + install_edison(&inst, "http://localhost:3000/", "edison_KEY", None).unwrap(); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert_eq!(v["mcpServers"]["edison-watch"]["type"], "http"); + assert_eq!( + v["mcpServers"]["edison-watch"]["url"], + "http://localhost:3000/mcp/edison_KEY/?client=cursor" + ); + assert!(v["mcpServers"]["keep"].is_object()); // existing preserved + assert!(cfg.with_file_name("mcp.json.ew-backup").exists()); + + uninstall_edison(&inst).unwrap(); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert!(v["mcpServers"].get("edison-watch").is_none()); + assert!(v["mcpServers"]["keep"].is_object()); + } + + #[test] + fn install_edison_with_secret_adds_header() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("mcp.json"); + let inst = edison(&cfg, &["mcpServers"], EdisonStyle::Http, "cursor"); + install_edison(&inst, "http://localhost:3000", "K", Some("user:SEKRET")).unwrap(); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert_eq!( + v["mcpServers"]["edison-watch"]["headers"]["X-Edison-Secret-Key"], + "user:SEKRET" + ); + + // TOML variant carries it under http_headers. + let tcfg = dir.path().join("config.toml"); + let tinst = edison(&tcfg, &["mcp_servers"], EdisonStyle::Toml, "codex"); + install_edison(&tinst, "http://localhost:3000", "K", Some("user:SEKRET")).unwrap(); + let t: toml::Value = toml::from_str(&std::fs::read_to_string(&tcfg).unwrap()).unwrap(); + assert_eq!( + t["mcp_servers"]["edison-watch"]["http_headers"]["X-Edison-Secret-Key"] + .as_str() + .unwrap(), + "user:SEKRET" + ); + } + + #[test] + fn edits_preserve_existing_server_order() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("mcp.json"); + std::fs::write( + &cfg, + r#"{"mcpServers":{"zebra":{"command":"z"},"apple":{"command":"a"},"mango":{"command":"m"}}}"#, + ) + .unwrap(); + install_edison(&edison(&cfg, &["mcpServers"], EdisonStyle::Http, "cursor"), "http://h", "K", None).unwrap(); + let text = std::fs::read_to_string(&cfg).unwrap(); + let (z, a, m, e) = ( + text.find("zebra").unwrap(), + text.find("apple").unwrap(), + text.find("mango").unwrap(), + text.find("edison-watch").unwrap(), + ); + assert!(z < a && a < m && m < e, "original order kept, edison appended:\n{text}"); + } + + #[test] + fn install_edison_creates_missing_file_and_dirs() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("nested/mcp.json"); // parent absent + let inst = edison(&cfg, &["mcpServers"], EdisonStyle::Http, "cursor"); + install_edison(&inst, "http://localhost:3000", "K", None).unwrap(); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert_eq!( + v["mcpServers"]["edison-watch"]["url"], + "http://localhost:3000/mcp/K/?client=cursor" + ); + } + + #[test] + fn install_edison_stdio_shim() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("claude_desktop_config.json"); + let inst = edison(&cfg, &["mcpServers"], EdisonStyle::StdioShim, "claude-desktop"); + install_edison(&inst, "http://localhost:3000", "K", None).unwrap(); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert_eq!(v["mcpServers"]["edison-watch"]["command"], "npx"); + assert_eq!(v["mcpServers"]["edison-watch"]["args"][2], "http://localhost:3000/mcp/K/?client=claude-desktop"); + } + + #[test] + fn install_edison_toml() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("config.toml"); + std::fs::write(&cfg, "[mcp_servers.keep]\ncommand = \"x\"\n").unwrap(); + let inst = edison(&cfg, &["mcp_servers"], EdisonStyle::Toml, "codex"); + install_edison(&inst, "http://localhost:3000", "K", None).unwrap(); + let t: toml::Value = toml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert_eq!( + t["mcp_servers"]["edison-watch"]["url"].as_str().unwrap(), + "http://localhost:3000/mcp/K/?client=codex" + ); + assert!(t["mcp_servers"].get("keep").is_some()); + uninstall_edison(&inst).unwrap(); + let t: toml::Value = toml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert!(t["mcp_servers"].get("edison-watch").is_none()); + } + + #[test] + fn backup_captures_full_original_and_is_cleaned_on_restore() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("mcp.json"); + std::fs::write( + &cfg, + r#"{"servers":{"a":{"command":"x"},"b":{"command":"y"}}}"#, + ) + .unwrap(); + let store = FileConfigStore; + + // Quarantine both servers from the same file, one after the other. + let ra = store + .quarantine(&loc(&cfg, &["servers"], "a"), &stdio("x", &[])) + .unwrap(); + let rb = store + .quarantine(&loc(&cfg, &["servers"], "b"), &stdio("y", &[])) + .unwrap(); + + // The backup is a single file capturing the FULL original (both servers), + // not the partially-emptied state at the second quarantine. + let backup: Value = + serde_json::from_str(&std::fs::read_to_string(&ra.backup_path).unwrap()).unwrap(); + assert!(backup["servers"].get("a").is_some()); + assert!(backup["servers"].get("b").is_some()); + + // Restoring the last one empties the sidecar → sidecar + backup removed. + store.restore(&ra).unwrap(); + assert!(rb.disabled_path.exists()); // still has "b" + store.restore(&rb).unwrap(); + assert!(!rb.disabled_path.exists()); + assert!(!rb.backup_path.exists()); + } + + #[test] + fn restore_round_trips() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("mcp.json"); + std::fs::write(&cfg, r#"{"servers":{"evil":{"command":"x","args":["--bad"]}}}"#).unwrap(); + + let store = FileConfigStore; + let rec = store + .quarantine(&loc(&cfg, &["servers"], "evil"), &stdio("x", &["--bad"])) + .unwrap(); + assert!(servers_in(&cfg, &["servers"]).is_empty()); + + store.restore(&rec).unwrap(); + assert_eq!(servers_in(&cfg, &["servers"]), vec!["evil".to_string()]); + // Sidecar was the only entry → it (and the backup) are cleaned up. + assert!(!rec.disabled_path.exists()); + // Restored entry is clean (no metadata leaked in). + let mut root: Value = + serde_json_lenient::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let entry = nav_mut(&mut root, &["servers".into()]).unwrap()["evil"].clone(); + assert!(entry.get(META_ORIGINAL_FILE).is_none()); + assert_eq!(entry["command"], "x"); + } + + #[test] + fn quarantine_nested_key_path() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join(".claude.json"); + std::fs::write( + &cfg, + r#"{"projects":{"/p":{"mcpServers":{"evil":{"command":"x"}}}}}"#, + ) + .unwrap(); + + let store = FileConfigStore; + store + .quarantine( + &loc(&cfg, &["projects", "/p", "mcpServers"], "evil"), + &stdio("x", &[]), + ) + .unwrap(); + assert!(servers_in(&cfg, &["projects", "/p", "mcpServers"]).is_empty()); + } + + #[test] + fn quarantine_jsonc_with_comments_succeeds() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("mcp.json"); + std::fs::write( + &cfg, + "{\n // my servers\n \"servers\": { \"evil\": { \"command\": \"x\" } }\n}", + ) + .unwrap(); + + let store = FileConfigStore; + store + .quarantine(&loc(&cfg, &["servers"], "evil"), &stdio("x", &[])) + .unwrap(); + assert!(servers_in(&cfg, &["servers"]).is_empty()); + } + + #[test] + fn missing_server_is_not_found() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("mcp.json"); + std::fs::write(&cfg, r#"{"servers":{"other":{"command":"a"}}}"#).unwrap(); + let store = FileConfigStore; + let err = store + .quarantine(&loc(&cfg, &["servers"], "evil"), &stdio("x", &[])) + .unwrap_err(); + assert!(matches!(err, Error::NotFound(_))); + } + + #[test] + fn quarantine_and_restore_toml() { + let dir = tempdir().unwrap(); + let cfg = dir.path().join("config.toml"); + std::fs::write( + &cfg, + "model = \"x\"\n\n[mcp_servers.evil]\ncommand = \"run\"\nargs = [\"--bad\"]\n", + ) + .unwrap(); + + let store = FileConfigStore; + let loc = ConfigLocation { + kind: SourceKind::Toml, + path: cfg.clone(), + key_path: vec!["mcp_servers".into()], + server_key: "evil".into(), + extra: mcp_detector_lib::LocationExtra::None, + }; + + let rec = store.quarantine(&loc, &stdio("run", &["--bad"])).unwrap(); + let after = std::fs::read_to_string(&cfg).unwrap(); + assert!(!after.contains("evil")); + assert!(after.contains("model")); // unrelated content preserved + + store.restore(&rec).unwrap(); + let restored = std::fs::read_to_string(&cfg).unwrap(); + // Re-parse to confirm the server is back under [mcp_servers]. + let root: toml::Value = toml::from_str(&restored).unwrap(); + assert!(root["mcp_servers"].as_table().unwrap().contains_key("evil")); + } + + fn make_state_db(db: &std::path::Path, key: &str, value: &Value) { + let conn = rusqlite::Connection::open(db).unwrap(); + conn.execute("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)", []) + .unwrap(); + conn.execute( + "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", + rusqlite::params![key, value.to_string()], + ) + .unwrap(); + } + + fn read_row(db: &std::path::Path, key: &str) -> Value { + let conn = rusqlite::Connection::open(db).unwrap(); + let raw: String = conn + .query_row("SELECT value FROM ItemTable WHERE key = ?1", [key], |r| r.get(0)) + .unwrap(); + serde_json::from_str(&raw).unwrap() + } + + #[test] + fn sqlite_object_key_round_trip() { + let dir = tempdir().unwrap(); + let db = dir.path().join("state.vscdb"); + make_state_db( + &db, + "anysphere.cursor-mcp", + &json!({"[user-notion] mcp_server_url": "https://x", "other": "keep"}), + ); + + let loc = ConfigLocation { + kind: SourceKind::SqliteState, + path: db.clone(), + key_path: vec![], + server_key: "[user-notion] mcp_server_url".into(), + extra: LocationExtra::StateDb { + item_key: "anysphere.cursor-mcp".into(), + shape: StateShape::ObjectKey, + }, + }; + + let store = FileConfigStore; + let rec = store.quarantine(&loc, &stdio("unused", &[])).unwrap(); + let row = read_row(&db, "anysphere.cursor-mcp"); + assert!(row.get("[user-notion] mcp_server_url").is_none()); + assert!(row.get("other").is_some()); + assert!(rec.backup_path.exists()); + + store.restore(&rec).unwrap(); + let row = read_row(&db, "anysphere.cursor-mcp"); + assert_eq!(row["[user-notion] mcp_server_url"], "https://x"); + } + + #[test] + fn sqlite_array_by_id_round_trip() { + let dir = tempdir().unwrap(); + let db = dir.path().join("state.vscdb"); + make_state_db( + &db, + "mcpToolCache", + &json!({"extensionServers": [ + {"id": "ext.a", "serverUrl": "https://a"}, + {"id": "ext.b", "serverUrl": "https://b"} + ]}), + ); + + let loc = ConfigLocation { + kind: SourceKind::SqliteState, + path: db.clone(), + key_path: vec![], + server_key: "ext.a".into(), + extra: LocationExtra::StateDb { + item_key: "mcpToolCache".into(), + shape: StateShape::ArrayById { + array_key: "extensionServers".into(), + }, + }, + }; + + let store = FileConfigStore; + let rec = store.quarantine(&loc, &stdio("unused", &[])).unwrap(); + let ids: Vec = read_row(&db, "mcpToolCache")["extensionServers"] + .as_array() + .unwrap() + .iter() + .map(|e| e["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(ids, vec!["ext.b".to_string()]); + + store.restore(&rec).unwrap(); + let ids: Vec = read_row(&db, "mcpToolCache")["extensionServers"] + .as_array() + .unwrap() + .iter() + .map(|e| e["id"].as_str().unwrap().to_string()) + .collect(); + assert!(ids.contains(&"ext.a".to_string())); + assert!(ids.contains(&"ext.b".to_string())); + } + + #[test] + fn plugin_dir_round_trip() { + let dir = tempdir().unwrap(); + let plugin = dir.path().join("my-plugin"); + std::fs::create_dir_all(plugin.join("inner")).unwrap(); + std::fs::write(plugin.join("mcp.json"), "{}").unwrap(); + + let loc = ConfigLocation { + kind: SourceKind::CursorPluginDir, + path: plugin.clone(), + key_path: vec![], + server_key: "my-plugin".into(), + extra: LocationExtra::None, + }; + + let store = FileConfigStore; + let rec = store.quarantine(&loc, &stdio("unused", &[])).unwrap(); + assert!(!plugin.exists()); + assert!(rec.disabled_path.exists()); + assert!(rec.disabled_path.file_name().unwrap().to_string_lossy().starts_with("ew-disabled-")); + + store.restore(&rec).unwrap(); + assert!(plugin.exists()); + assert!(plugin.join("mcp.json").exists()); + assert!(!rec.disabled_path.exists()); + } +} diff --git a/crates/mcp_quarantine/src/error.rs b/crates/mcp_quarantine/src/error.rs new file mode 100644 index 0000000..4db22f8 --- /dev/null +++ b/crates/mcp_quarantine/src/error.rs @@ -0,0 +1,26 @@ +//! Shared error type for the quarantine layer. + +use std::path::PathBuf; + +/// Errors from config mutation and the seen-store. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("io at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("json at {path}: {message}")] + Json { path: PathBuf, message: String }, + #[error("server '{0}' not found at the expected location")] + NotFound(String), + #[error("expected an object at key path {0:?}")] + NotAnObject(Vec), + #[error("server config is not actionable (unsupported/report-only)")] + NotActionable, + #[error("no writer implemented for source kind {0:?}")] + UnsupportedKind(mcp_detector_lib::SourceKind), +} + +pub type Result = std::result::Result; diff --git a/crates/mcp_quarantine/src/hooks.rs b/crates/mcp_quarantine/src/hooks.rs new file mode 100644 index 0000000..86e7b8e --- /dev/null +++ b/crates/mcp_quarantine/src/hooks.rs @@ -0,0 +1,859 @@ +//! Edison Watch **hook injection** (phase-2 mirror of the edison-watch install). +//! +//! Materialises four self-contained scripts into `~/.edison-watch/` and injects +//! per-agent hook config that runs them. The scripts only write files into +//! `~/.edison-watch/pending/` (and `errors/`) — no network, no secrets, no +//! running server required. The bodies are copied verbatim from the app so the +//! runtime behaviour (session-id tagging, pending-file format) is identical. + +use std::path::{Path, PathBuf}; + +use mcp_detector_lib::{HookBinding, HookInstall, HookScriptKind, HookStyle}; +use serde_json::{Map, Value, json}; + +use crate::configstore::{backup_path, parse, read, serialize, write}; +use crate::error::{Error, Result}; + +/// A command belongs to us if it runs one of our scripts — matched by the +/// distinctive script-filename stems (robust regardless of the install dir). +fn cmd_str_is_edison(cmd: &str) -> bool { + cmd.contains("edison-hook.") || cmd.contains("edison-session-") +} + +// ── materialised scripts (verbatim from client_2 hookInjectionCore.ts) ─────── + +const REGISTRATION_SH_TEMPLATE: &str = r#"#!/bin/bash +# Edison Watch - Project Registration Hook +# Writes a registration file for Edison Watch to process + +# Get the client that called this hook (passed as first argument) +CLIENT="${1:-unknown}" + +# Pending registrations and errors directories +PENDING_DIR="__PENDING_DIR__" +ERRORS_DIR="__ERRORS_DIR__" + +# Create directories if they don't exist +mkdir -p "$PENDING_DIR" +mkdir -p "$ERRORS_DIR" + +# Generate unique filename +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +RANDOM_ID=$RANDOM +FILENAME="${TIMESTAMP}-${RANDOM_ID}-${CLIENT}.json" + +# Get current working directory +CWD="$(pwd)" + +# Write registration file (atomic via temp file + mv) +TEMP_FILE="$PENDING_DIR/.${FILENAME}.tmp" +echo "{\"projectPath\": \"$CWD\", \"registeredBy\": \"$CLIENT\", \"timestamp\": \"$TIMESTAMP\"}" > "$TEMP_FILE" +if ! mv "$TEMP_FILE" "$PENDING_DIR/$FILENAME" 2>/dev/null; then + echo "{\"error\":\"mv failed\",\"client\":\"$CLIENT\",\"timestamp\":\"$(date -Iseconds)\"}" > "$ERRORS_DIR/${TIMESTAMP}-${RANDOM_ID}.json" +fi + +# Always exit successfully so we don't block the MCP client +exit 0 +"#; + +const SESSION_START_PY: &str = r####"#!/usr/bin/env python3 +import json, sys, os +try: + data = json.load(sys.stdin) + session_id = data.get("session_id") or data.get("sessionId") + # Skip on Windows: .cmd wrapper means PPID is ephemeral cmd.exe, not Claude Code. + # PreToolUse falls back to hook payload session_id on Windows. + if session_id and sys.platform != "win32": + edison_dir = os.path.expanduser("~/.edison-watch") + os.makedirs(edison_dir, exist_ok=True) + # PPID = Claude Code process ID. Relies on Claude Code spawning hooks as + # direct children (execFile/spawn, not sh -c). Falls back gracefully if not. + ppid = os.getppid() + fname = f"active_session_{ppid}.json" + tmp = os.path.join(edison_dir, f".{fname}.tmp") + final = os.path.join(edison_dir, fname) + with open(tmp, "w") as f: + json.dump({"session_id": session_id}, f) + os.rename(tmp, final) +except Exception: + pass +sys.exit(0) +"####; + +const SESSION_END_PY: &str = r####"#!/usr/bin/env python3 +import json, sys, os, time, random +try: + data = json.load(sys.stdin) + conv_id = data.get("session_id") or data.get("conversation_id") or data.get("sessionId") + reason = data.get("reason", "unknown") + if conv_id: + pending_dir = os.path.expanduser("~/.edison-watch/pending") + os.makedirs(pending_dir, exist_ok=True) + ts = time.strftime("%Y%m%d-%H%M%S") + fname = f"{ts}-{random.randint(0,99999)}-session-end.json" + tmp = os.path.join(pending_dir, f".{fname}.tmp") + final = os.path.join(pending_dir, fname) + with open(tmp, "w") as f: + json.dump({"event": "session_end", "conversation_id": conv_id, + "reason": reason, "timestamp": ts}, f) + os.rename(tmp, final) +except Exception: + pass +# Clean up PID-scoped active session file - runs regardless of pending-write outcome +# Skip on Windows: .cmd wrapper means PPID is ephemeral cmd.exe, not Claude Code +try: + if sys.platform != "win32": + ppid = os.getppid() + active_file = os.path.expanduser(f"~/.edison-watch/active_session_{ppid}.json") + if os.path.exists(active_file): + os.remove(active_file) +except Exception: + pass +sys.exit(0) +"####; + +const SESSION_HOOK_PY: &str = r####"#!/usr/bin/env python3 +import json +import sys +import os + +try: + data = json.load(sys.stdin) + # Detect client: VSCode Copilot (camelCase), Claude Code (snake_case), or Cursor (flat) + is_vscode = "hookEventName" in data + is_claude_code = "hook_event_name" in data + uses_hook_output = is_vscode or is_claude_code + # Extract conversation/session ID per client format + if is_vscode: + conv_id = data.get("sessionId") + elif is_claude_code: + # Try PID-scoped active session file first (authoritative, written by SessionStart hook) + # Skip on Windows: .cmd wrapper gives ephemeral PPID, file won't match + conv_id = None + try: + if sys.platform != "win32": + ppid = os.getppid() + active_file = os.path.expanduser(f"~/.edison-watch/active_session_{ppid}.json") + if os.path.exists(active_file): + with open(active_file, "r") as f: + active_data = json.load(f) + conv_id = active_data.get("session_id") + except Exception: + pass + # Fall back to hook payload data + if not conv_id: + conv_id = data.get("session_id") or data.get("conversation_id") + else: + conv_id = data.get("conversation_id") + # Extract tool input (VSCode uses camelCase toolInput) + tool_input = data.get("toolInput", data.get("tool_input", {})) if is_vscode else data.get("tool_input", {}) + if conv_id and isinstance(tool_input, dict): + tool_input["_edison_conversation_id"] = conv_id + if uses_hook_output: + hook_event = data.get("hookEventName") or data.get("hook_event_name") or "PreToolUse" + print(json.dumps({"hookSpecificOutput": { + "hookEventName": hook_event, + "permissionDecision": "allow", "updatedInput": tool_input}})) + else: + print(json.dumps({"decision": "allow", "updated_input": tool_input})) + else: + if uses_hook_output: + hook_event = data.get("hookEventName") or data.get("hook_event_name") or "PreToolUse" + print(json.dumps({"hookSpecificOutput": { + "hookEventName": hook_event, + "permissionDecision": "allow"}})) + else: + print(json.dumps({"decision": "allow"})) +except Exception: + print(json.dumps({"decision": "allow", "hookSpecificOutput": { + "hookEventName": "PreToolUse", "permissionDecision": "allow"}})) +sys.exit(0) +"####; + +/// Absolute paths to the four materialised scripts. +#[derive(Debug, Clone)] +pub struct HookScripts { + pub registration: PathBuf, + pub session_start: PathBuf, + pub session_hook: PathBuf, + pub session_end: PathBuf, +} + +impl HookScripts { + fn path_for(&self, kind: HookScriptKind) -> &Path { + match kind { + HookScriptKind::Registration => &self.registration, + HookScriptKind::SessionStart => &self.session_start, + HookScriptKind::SessionHook => &self.session_hook, + HookScriptKind::SessionEnd => &self.session_end, + } + } +} + +fn script_filename(kind: HookScriptKind) -> &'static str { + match kind { + HookScriptKind::Registration => "edison-hook.sh", + HookScriptKind::SessionStart => "edison-session-start.py", + HookScriptKind::SessionHook => "edison-session-hook.py", + HookScriptKind::SessionEnd => "edison-session-end.py", + } +} + +/// Materialise the four scripts (and `pending/` + `errors/`) into `edison_dir` +/// (`~/.edison-watch`). Idempotent: rewrites a script only when its content +/// differs, and always ensures the executable bit. +pub fn ensure_scripts(edison_dir: &Path) -> Result { + let pending = edison_dir.join("pending"); + let errors = edison_dir.join("errors"); + mkdirs(&pending)?; + mkdirs(&errors)?; + + let registration = edison_dir.join("edison-hook.sh"); + let session_start = edison_dir.join("edison-session-start.py"); + let session_hook = edison_dir.join("edison-session-hook.py"); + let session_end = edison_dir.join("edison-session-end.py"); + + let sh = REGISTRATION_SH_TEMPLATE + .replace("__PENDING_DIR__", &pending.display().to_string()) + .replace("__ERRORS_DIR__", &errors.display().to_string()); + write_script(®istration, &sh)?; + write_script(&session_start, SESSION_START_PY)?; + write_script(&session_hook, SESSION_HOOK_PY)?; + write_script(&session_end, SESSION_END_PY)?; + + Ok(HookScripts { + registration, + session_start, + session_hook, + session_end, + }) +} + +fn mkdirs(path: &Path) -> Result<()> { + std::fs::create_dir_all(path).map_err(|source| Error::Io { + path: path.to_path_buf(), + source, + }) +} + +fn write_script(path: &Path, content: &str) -> Result<()> { + let changed = std::fs::read_to_string(path).map(|c| c != content).unwrap_or(true); + if changed { + write(path, content)?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err( + |source| Error::Io { + path: path.to_path_buf(), + source, + }, + )?; + } + Ok(()) +} + +/// The command string a binding runs (quoted path + optional client arg; Codex +/// wraps the whole thing in one unquoted TOML string). +fn command_for(binding: &HookBinding, scripts: &HookScripts, install: &HookInstall) -> String { + let path = scripts.path_for(binding.script).display().to_string(); + match install.style { + HookStyle::CodexToml => { + if binding.pass_client_arg { + format!("{path} {}", install.client_id) + } else { + path + } + } + _ => { + if binding.pass_client_arg { + format!("\"{path}\" {}", install.client_id) + } else { + format!("\"{path}\"") + } + } + } +} + +// ── inject ─────────────────────────────────────────────────────────────────── + +/// Inject `install`'s hooks (idempotently), backing up the file first. Returns +/// whether anything changed. +pub fn inject_hooks(install: &HookInstall, scripts: &HookScripts) -> Result { + match install.style { + HookStyle::ClaudeSettings => inject_claude(install, scripts), + HookStyle::CursorHooks => inject_cursor(install, scripts), + HookStyle::CopilotFile => inject_copilot(install, scripts), + HookStyle::CodexToml => inject_codex(install, scripts), + } +} + +fn ensure_parent(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + mkdirs(parent)?; + } + Ok(()) +} + +/// Read a JSON file (or an empty object if absent). Returns (root, existed, raw). +fn read_json_or_empty(path: &Path) -> Result<(Value, bool, String)> { + if path.exists() { + let raw = read(path)?; + let root = if raw.trim().is_empty() { + Value::Object(Map::new()) + } else { + parse(&raw, path)? + }; + Ok((root, true, raw)) + } else { + Ok((Value::Object(Map::new()), false, String::new())) + } +} + +fn backup_once(path: &Path, existed: bool, raw: &str) -> Result<()> { + if existed { + let bp = backup_path(path); + if !bp.exists() { + write(&bp, raw)?; + } + } + Ok(()) +} + +/// A `{type:"command", command}` object contains our marker for `kind`. +fn command_has_script(entry: &Value, kind: HookScriptKind) -> bool { + entry + .get("command") + .and_then(Value::as_str) + .is_some_and(|c| c.contains(script_filename(kind))) +} + +fn inject_claude(install: &HookInstall, scripts: &HookScripts) -> Result { + ensure_parent(&install.path)?; + let (mut root, existed, raw) = read_json_or_empty(&install.path)?; + let hooks = root + .as_object_mut() + .ok_or_else(|| Error::NotAnObject(vec![]))? + .entry("hooks") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| Error::NotAnObject(vec!["hooks".into()]))?; + + let mut changed = false; + for b in &install.events { + let arr = hooks + .entry(b.event.clone()) + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(|| Error::NotAnObject(vec!["hooks".into(), b.event.clone()]))?; + // A group already carries this script? + let present = arr.iter().any(|group| { + group + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|hs| hs.iter().any(|h| command_has_script(h, b.script))) + }); + if present { + continue; + } + let cmd = command_for(b, scripts, install); + let mut group = Map::new(); + if let Some(m) = &b.matcher { + group.insert("matcher".into(), json!(m)); + } + group.insert("hooks".into(), json!([{ "type": "command", "command": cmd }])); + arr.push(Value::Object(group)); + changed = true; + } + + if changed { + backup_once(&install.path, existed, &raw)?; + write(&install.path, &serialize(&root))?; + } + Ok(changed) +} + +fn inject_cursor(install: &HookInstall, scripts: &HookScripts) -> Result { + ensure_parent(&install.path)?; + let (mut root, existed, raw) = read_json_or_empty(&install.path)?; + let obj = root.as_object_mut().ok_or_else(|| Error::NotAnObject(vec![]))?; + obj.entry("version").or_insert_with(|| json!(1)); + let hooks = obj + .entry("hooks") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| Error::NotAnObject(vec!["hooks".into()]))?; + + let mut changed = false; + for b in &install.events { + let arr = hooks + .entry(b.event.clone()) + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(|| Error::NotAnObject(vec!["hooks".into(), b.event.clone()]))?; + if arr.iter().any(|h| command_has_script(h, b.script)) { + continue; + } + let cmd = command_for(b, scripts, install); + arr.push(json!({ "type": "command", "command": cmd })); + changed = true; + } + + if changed { + backup_once(&install.path, existed, &raw)?; + write(&install.path, &serialize(&root))?; + } + Ok(changed) +} + +fn inject_copilot(install: &HookInstall, scripts: &HookScripts) -> Result { + // The whole file is Edison-owned: build the desired doc and overwrite iff + // it differs. + let mut hooks = Map::new(); + for b in &install.events { + let cmd = command_for(b, scripts, install); + hooks.insert( + b.event.clone(), + json!([{ "type": "command", "command": cmd }]), + ); + } + let desired = json!({ "hooks": hooks }); + + let existed = install.path.exists(); + let raw = if existed { read(&install.path)? } else { String::new() }; + let current = if raw.trim().is_empty() { + None + } else { + parse(&raw, &install.path).ok() + }; + if current.as_ref() == Some(&desired) { + return Ok(false); + } + ensure_parent(&install.path)?; + backup_once(&install.path, existed, &raw)?; + write(&install.path, &serialize(&desired))?; + Ok(true) +} + +fn inject_codex(install: &HookInstall, scripts: &HookScripts) -> Result { + ensure_parent(&install.path)?; + let existed = install.path.exists(); + let text = if existed { read(&install.path)? } else { String::new() }; + + let mut appended = String::new(); + for b in &install.events { + if text.contains(script_filename(b.script)) { + continue; + } + let cmd = command_for(b, scripts, install); + appended.push_str(&format!( + "\n[[hooks.{}]]\ncommand = \"{cmd}\"\n", + b.event + )); + } + if appended.is_empty() { + return Ok(false); + } + backup_once(&install.path, existed, &text)?; + write(&install.path, &format!("{text}{appended}"))?; + Ok(true) +} + +// ── remove ─────────────────────────────────────────────────────────────────── + +/// Remove `install`'s hooks (any command referencing `~/.edison-watch`). Returns +/// whether anything changed. Best-effort/idempotent. +pub fn remove_hooks(install: &HookInstall) -> Result { + match install.style { + HookStyle::ClaudeSettings => remove_claude(install), + HookStyle::CursorHooks => remove_cursor(install), + HookStyle::CopilotFile => remove_copilot(install), + HookStyle::CodexToml => remove_codex(install), + } +} + +fn command_is_edison(entry: &Value) -> bool { + entry + .get("command") + .and_then(Value::as_str) + .is_some_and(cmd_str_is_edison) +} + +fn remove_claude(install: &HookInstall) -> Result { + if !install.path.exists() { + return Ok(false); + } + let raw = read(&install.path)?; + let mut root = parse(&raw, &install.path)?; + let Some(hooks) = root.as_object_mut().and_then(|o| o.get_mut("hooks")).and_then(Value::as_object_mut) else { + return Ok(false); + }; + + let mut changed = false; + for arr in hooks.values_mut() { + if let Some(groups) = arr.as_array_mut() { + let before = groups.len(); + groups.retain(|group| { + !group + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|hs| hs.iter().any(command_is_edison)) + }); + changed |= groups.len() != before; + } + } + // Drop now-empty event arrays, then an empty `hooks` object. + hooks.retain(|_, v| !v.as_array().is_some_and(|a| a.is_empty())); + let empty_hooks = hooks.is_empty(); + if empty_hooks { + root.as_object_mut().unwrap().remove("hooks"); + } + + if changed { + write(&install.path, &serialize(&root))?; + } + Ok(changed) +} + +fn remove_cursor(install: &HookInstall) -> Result { + if !install.path.exists() { + return Ok(false); + } + let raw = read(&install.path)?; + let mut root = parse(&raw, &install.path)?; + let Some(hooks) = root.as_object_mut().and_then(|o| o.get_mut("hooks")).and_then(Value::as_object_mut) else { + return Ok(false); + }; + + let mut changed = false; + for arr in hooks.values_mut() { + if let Some(entries) = arr.as_array_mut() { + let before = entries.len(); + entries.retain(|e| !command_is_edison(e)); + changed |= entries.len() != before; + } + } + hooks.retain(|_, v| !v.as_array().is_some_and(|a| a.is_empty())); + + if changed { + write(&install.path, &serialize(&root))?; + } + Ok(changed) +} + +fn remove_copilot(install: &HookInstall) -> Result { + // Edison owns the whole file — just delete it. + if install.path.exists() { + std::fs::remove_file(&install.path).map_err(|source| Error::Io { + path: install.path.clone(), + source, + })?; + Ok(true) + } else { + Ok(false) + } +} + +fn remove_codex(install: &HookInstall) -> Result { + if !install.path.exists() { + return Ok(false); + } + let text = read(&install.path)?; + // Drop any `[[hooks.X]]` block whose command line references ~/.edison-watch. + let mut out: Vec<&str> = Vec::new(); + let lines: Vec<&str> = text.lines().collect(); + let mut i = 0; + let mut changed = false; + while i < lines.len() { + let line = lines[i]; + let is_hook_header = line.trim_start().starts_with("[[hooks."); + let next_is_edison = lines + .get(i + 1) + .is_some_and(|n| n.contains("command") && cmd_str_is_edison(n)); + if is_hook_header && next_is_edison { + // Skip the header + its command line (and a trailing blank line). + i += 2; + if lines.get(i).is_some_and(|l| l.trim().is_empty()) { + i += 1; + } + changed = true; + continue; + } + out.push(line); + i += 1; + } + if changed { + let mut joined = out.join("\n"); + if text.ends_with('\n') && !joined.ends_with('\n') { + joined.push('\n'); + } + write(&install.path, &joined)?; + } + Ok(changed) +} + +// ── VSCode per-workspace registration task (.vscode/tasks.json) ────────────── + +const VSCODE_TASK_LABEL: &str = "Edison Watch Registration"; + +/// Add the "Edison Watch Registration" folder-open task to a workspace's +/// `tasks.json` (idempotent, alongside existing tasks). Returns whether changed. +pub fn inject_workspace_task(tasks_json: &Path, registration_script: &Path) -> Result { + ensure_parent(tasks_json)?; + let (mut root, existed, raw) = read_json_or_empty(tasks_json)?; + let obj = root.as_object_mut().ok_or_else(|| Error::NotAnObject(vec![]))?; + obj.entry("version").or_insert_with(|| json!("2.0.0")); + let tasks = obj + .entry("tasks") + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(|| Error::NotAnObject(vec!["tasks".into()]))?; + + if tasks + .iter() + .any(|t| t.get("label").and_then(Value::as_str) == Some(VSCODE_TASK_LABEL)) + { + return Ok(false); + } + tasks.push(json!({ + "label": VSCODE_TASK_LABEL, + "type": "shell", + "command": registration_script.display().to_string(), + "args": ["vscode"], + "runOptions": { "runOn": "folderOpen" }, + "presentation": { "reveal": "never", "panel": "shared" } + })); + backup_once(tasks_json, existed, &raw)?; + write(tasks_json, &serialize(&root))?; + Ok(true) +} + +/// Strip the Edison Watch registration task from a workspace `tasks.json` +/// (leaving the user's own tasks). Returns whether changed. +pub fn remove_workspace_task(tasks_json: &Path) -> Result { + if !tasks_json.exists() { + return Ok(false); + } + let raw = read(tasks_json)?; + let mut root = parse(&raw, tasks_json)?; + let Some(tasks) = root + .as_object_mut() + .and_then(|o| o.get_mut("tasks")) + .and_then(Value::as_array_mut) + else { + return Ok(false); + }; + let before = tasks.len(); + tasks.retain(|t| t.get("label").and_then(Value::as_str) != Some(VSCODE_TASK_LABEL)); + let changed = tasks.len() != before; + if changed { + write(tasks_json, &serialize(&root))?; + } + Ok(changed) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn fake_scripts(dir: &Path) -> HookScripts { + HookScripts { + registration: dir.join("edison-hook.sh"), + session_start: dir.join("edison-session-start.py"), + session_hook: dir.join("edison-session-hook.py"), + session_end: dir.join("edison-session-end.py"), + } + } + + #[test] + fn ensure_scripts_materialises_executable_and_interpolated() { + let d = tempdir().unwrap(); + let ed = d.path().join(".edison-watch"); + let s = ensure_scripts(&ed).unwrap(); + assert!(s.registration.exists() && s.session_hook.exists()); + assert!(ed.join("pending").is_dir() && ed.join("errors").is_dir()); + let sh = std::fs::read_to_string(&s.registration).unwrap(); + assert!(sh.contains(ed.join("pending").to_str().unwrap())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&s.registration).unwrap().permissions().mode(); + assert_eq!(mode & 0o111, 0o111, "scripts must be executable"); + } + } + + #[test] + fn claude_inject_is_idempotent_and_removable() { + let d = tempdir().unwrap(); + let cfg = d.path().join(".claude/settings.json"); + let sc = fake_scripts(d.path()); + let install = HookInstall { + path: cfg.clone(), + style: HookStyle::ClaudeSettings, + client_id: "claude-code".into(), + events: vec![ + HookBinding::new("UserPromptSubmit", Some("*"), HookScriptKind::Registration, true), + HookBinding::new("PreToolUse", Some("mcp__*"), HookScriptKind::SessionHook, false), + ], + }; + assert!(inject_hooks(&install, &sc).unwrap()); + assert!(!inject_hooks(&install, &sc).unwrap(), "second inject is a no-op"); + + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert_eq!(v["hooks"]["UserPromptSubmit"][0]["matcher"], "*"); + let cmd = v["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + assert!(cmd.contains("edison-hook.sh") && cmd.ends_with("claude-code")); + assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], "mcp__*"); + + assert!(remove_hooks(&install).unwrap()); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert!(v.get("hooks").is_none(), "empty hooks object is dropped"); + } + + #[test] + fn claude_inject_preserves_foreign_hooks_and_backs_up() { + let d = tempdir().unwrap(); + let cfg = d.path().join("settings.json"); + std::fs::write( + &cfg, + r#"{"hooks":{"UserPromptSubmit":[{"matcher":"*","hooks":[{"type":"command","command":"other.sh"}]}]}}"#, + ) + .unwrap(); + let sc = fake_scripts(d.path()); + let install = HookInstall { + path: cfg.clone(), + style: HookStyle::ClaudeSettings, + client_id: "claude-code".into(), + events: vec![HookBinding::new( + "UserPromptSubmit", + Some("*"), + HookScriptKind::Registration, + true, + )], + }; + assert!(inject_hooks(&install, &sc).unwrap()); + assert!(cfg.with_file_name("settings.json.ew-backup").exists()); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let arr = v["hooks"]["UserPromptSubmit"].as_array().unwrap(); + assert_eq!(arr.len(), 2, "foreign hook kept, ours appended"); + + // Removal strips only ours. + remove_hooks(&install).unwrap(); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let arr = v["hooks"]["UserPromptSubmit"].as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["hooks"][0]["command"], "other.sh"); + } + + #[test] + fn cursor_inject_and_remove() { + let d = tempdir().unwrap(); + let cfg = d.path().join("hooks.json"); + let sc = fake_scripts(d.path()); + let install = HookInstall { + path: cfg.clone(), + style: HookStyle::CursorHooks, + client_id: "cursor".into(), + events: vec![ + HookBinding::new("sessionStart", None, HookScriptKind::Registration, true), + HookBinding::new("beforeMCPExecution", None, HookScriptKind::SessionHook, false), + ], + }; + assert!(inject_hooks(&install, &sc).unwrap()); + assert!(!inject_hooks(&install, &sc).unwrap()); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert_eq!(v["version"], 1); + assert_eq!(v["hooks"]["sessionStart"][0]["type"], "command"); + assert!(v["hooks"]["beforeMCPExecution"][0]["command"] + .as_str() + .unwrap() + .contains("edison-session-hook.py")); + assert!(remove_hooks(&install).unwrap()); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert!(v["hooks"].as_object().unwrap().is_empty()); + } + + #[test] + fn codex_inject_and_remove() { + let d = tempdir().unwrap(); + let cfg = d.path().join("config.toml"); + std::fs::write(&cfg, "[mcp_servers.foo]\ncommand = \"x\"\n").unwrap(); + let sc = fake_scripts(d.path()); + let install = HookInstall { + path: cfg.clone(), + style: HookStyle::CodexToml, + client_id: "codex".into(), + events: vec![ + HookBinding::new("SessionStart", None, HookScriptKind::Registration, true), + HookBinding::new("Stop", None, HookScriptKind::SessionEnd, false), + ], + }; + assert!(inject_hooks(&install, &sc).unwrap()); + assert!(!inject_hooks(&install, &sc).unwrap()); + let t: toml::Value = toml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert!(t["mcp_servers"].get("foo").is_some(), "existing config kept"); + let cmd = t["hooks"]["SessionStart"][0]["command"].as_str().unwrap(); + assert!(cmd.contains("edison-hook.sh") && cmd.ends_with("codex")); + assert!(remove_hooks(&install).unwrap()); + let text = std::fs::read_to_string(&cfg).unwrap(); + assert!(!text.contains("[[hooks."), "hook blocks removed"); + assert!(text.contains("[mcp_servers.foo]"), "config preserved"); + } + + #[test] + fn vscode_workspace_task_inject_and_remove() { + let d = tempdir().unwrap(); + let tasks = d.path().join(".vscode/tasks.json"); + std::fs::create_dir_all(tasks.parent().unwrap()).unwrap(); + std::fs::write( + &tasks, + r#"{"version":"2.0.0","tasks":[{"label":"build","type":"shell","command":"make"}]}"#, + ) + .unwrap(); + let script = d.path().join("edison-hook.sh"); + + assert!(inject_workspace_task(&tasks, &script).unwrap()); + assert!(!inject_workspace_task(&tasks, &script).unwrap(), "idempotent"); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&tasks).unwrap()).unwrap(); + let arr = v["tasks"].as_array().unwrap(); + assert_eq!(arr.len(), 2, "user task kept, ours appended"); + assert!(arr.iter().any(|t| t["label"] == VSCODE_TASK_LABEL + && t["args"][0] == "vscode" + && t["runOptions"]["runOn"] == "folderOpen")); + + assert!(remove_workspace_task(&tasks).unwrap()); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&tasks).unwrap()).unwrap(); + let arr = v["tasks"].as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["label"], "build", "user's task preserved"); + } + + #[test] + fn copilot_owns_whole_file() { + let d = tempdir().unwrap(); + let cfg = d.path().join("edison-watch.json"); + let sc = fake_scripts(d.path()); + let install = HookInstall { + path: cfg.clone(), + style: HookStyle::CopilotFile, + client_id: "vscode".into(), + events: vec![ + HookBinding::new("SessionStart", None, HookScriptKind::SessionStart, false), + HookBinding::new("UserPromptSubmit", None, HookScriptKind::Registration, true), + ], + }; + assert!(inject_hooks(&install, &sc).unwrap()); + assert!(!inject_hooks(&install, &sc).unwrap(), "same content → no rewrite"); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert!(v["hooks"]["UserPromptSubmit"][0]["command"] + .as_str() + .unwrap() + .ends_with("vscode")); + assert!(remove_hooks(&install).unwrap()); + assert!(!cfg.exists(), "Edison-owned file is deleted on removal"); + } +} diff --git a/crates/mcp_quarantine/src/lib.rs b/crates/mcp_quarantine/src/lib.rs new file mode 100644 index 0000000..b2b5f22 --- /dev/null +++ b/crates/mcp_quarantine/src/lib.rs @@ -0,0 +1,25 @@ +//! The quarantine layer: the reconcile planner, persistent state, and config +//! mutation. **No privilege, no IPC, no network** — those are injected by the +//! daemon. Everything here is unit-testable in a tempdir. +//! +//! - [`reconcile`] — the pure, level-triggered planner (design §8). +//! - (later) `seen_store` — the root-owned "known" oracle. +//! - (later) `configstore` — kind-dispatched writers (quarantine/restore). + +pub mod configstore; +pub mod error; +pub mod hooks; +pub mod reconcile; +pub mod seen_store; +mod statedb; + +pub use configstore::{ + ConfigStore, FileConfigStore, QuarantineRecord, edison_url, install_edison, uninstall_edison, +}; +pub use hooks::{ + HookScripts, ensure_scripts, inject_hooks, inject_workspace_task, remove_hooks, + remove_workspace_task, +}; +pub use error::{Error, Result}; +pub use reconcile::{Action as ReconcileAction, KnownOracle, Policy, is_edison_entry, plan}; +pub use seen_store::{Action, SeenStore}; diff --git a/crates/mcp_quarantine/src/reconcile.rs b/crates/mcp_quarantine/src/reconcile.rs new file mode 100644 index 0000000..48a9455 --- /dev/null +++ b/crates/mcp_quarantine/src/reconcile.rs @@ -0,0 +1,223 @@ +//! The level-triggered reconcile planner — the pure heart of quarantine. +//! +//! Given the currently-*observed* servers, a "known" oracle, and the policy, it +//! returns the [`Action`]s to take. It performs **no IO**: the daemon executes +//! the actions (mutating configs via the writer, emitting pending events over +//! IPC). Being pure and level-triggered, it is exhaustively unit-testable and +//! inherently tamper-resistant — a restored server simply reappears in +//! `observed` next pass and is actioned again (design §8). + +use mcp_detector_lib::{DiscoveredServer, ServerConfig, fingerprint}; + +/// Our own injected entry — never quarantine it. +const EDISON_SERVER_NAME: &str = "edison-watch"; + +/// Org policy governing the reconcile loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Policy { + /// When `false` the loop is inert: discovery/report only, no mutation. + pub quarantine: bool, +} + +/// Answers "is this fingerprint already known to the backend?" — i.e. +/// registered/requested for this org, or actioned locally. In the daemon this +/// is backed by the root-owned seen-store (fed by backend sync + local +/// decisions); in tests it is any in-memory set. +pub trait KnownOracle { + fn is_known(&self, fingerprint: &str) -> bool; +} + +/// A single action the daemon should carry out for one server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Already known to the backend → quarantine silently (no user prompt). + SilentQuarantine { + server: DiscoveredServer, + fingerprint: String, + }, + /// Unknown → quarantine **first** (neutralise now), then prompt the user + /// for disposition (send-to-EW / skip). + QuarantineAndPrompt { + server: DiscoveredServer, + fingerprint: String, + }, + /// Opaque but **removable** — remove it locally. It has no launch config, + /// so it can't be fingerprinted or sent to EW; there's no disposition, just + /// neutralisation (Cursor plugins, VSCode extension entries). + RemoveOpaque { server: DiscoveredServer }, +} + +impl Action { + /// The fingerprint this action targets, if any (`None` for opaque removals). + pub fn fingerprint(&self) -> Option<&str> { + match self { + Action::SilentQuarantine { fingerprint, .. } + | Action::QuarantineAndPrompt { fingerprint, .. } => Some(fingerprint), + Action::RemoveOpaque { .. } => None, + } + } +} + +/// Compute the actions for one reconcile pass. +/// +/// Quarantine-first: every *actionable* server is removed; an unknown +/// fingerprint-able one is additionally surfaced for disposition. When +/// `policy.quarantine` is false the pass is inert. Skipped: our own injected +/// entry, and *untouchable* opaque servers (`removable == false`). A +/// **removable** opaque server is removed with no disposition ([`Action::RemoveOpaque`]). +pub fn plan(observed: &[DiscoveredServer], oracle: &dyn KnownOracle, policy: Policy) -> Vec { + if !policy.quarantine { + return Vec::new(); + } + + let mut actions = Vec::new(); + for server in observed { + if is_edison_entry(server) { + continue; + } + match &server.config { + // Removable-locally-only: remove, no EW disposition. + ServerConfig::Opaque { + removable: true, .. + } => actions.push(Action::RemoveOpaque { + server: server.clone(), + }), + // Untouchable: report-only, never enforced. + ServerConfig::Opaque { + removable: false, .. + } => continue, + // Fingerprint-able (stdio/http): known → silent, unknown → prompt. + _ => { + let Some(fp) = fingerprint(&server.name, &server.config) else { + continue; // malformed (empty command/url) + }; + actions.push(if oracle.is_known(&fp) { + Action::SilentQuarantine { + server: server.clone(), + fingerprint: fp, + } + } else { + Action::QuarantineAndPrompt { + server: server.clone(), + fingerprint: fp, + } + }); + } + } + } + actions +} + +/// Whether this is our own injected `edison-watch` entry (never quarantined). +pub fn is_edison_entry(server: &DiscoveredServer) -> bool { + server.name == EDISON_SERVER_NAME +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::collections::HashSet; + use std::path::PathBuf; + + use mcp_detector_lib::{ + ConfigLocation, LocationExtra, OpaqueReason, Scope, ServerConfig, SourceKind, Transport, + }; + + fn opaque(removable: bool) -> ServerConfig { + ServerConfig::Opaque { + removable, + reason: OpaqueReason::CursorPlugin, + } + } + + struct Known(HashSet); + impl KnownOracle for Known { + fn is_known(&self, fingerprint: &str) -> bool { + self.0.contains(fingerprint) + } + } + + fn known(fps: &[&str]) -> Known { + Known(fps.iter().map(|s| s.to_string()).collect()) + } + + fn server(name: &str, config: ServerConfig) -> DiscoveredServer { + DiscoveredServer { + client: "test", + name: name.into(), + transport: Transport::Stdio, + scope: Scope::Global, + config, + location: ConfigLocation { + kind: SourceKind::Jsonc, + path: PathBuf::from("/tmp/x.json"), + key_path: vec!["mcpServers".into()], + server_key: name.into(), + extra: LocationExtra::None, + }, + } + } + + fn stdio(command: &str, args: &[&str]) -> ServerConfig { + ServerConfig::Stdio { + command: command.into(), + args: args.iter().map(|s| s.to_string()).collect(), + env: BTreeMap::new(), + } + } + + const ON: Policy = Policy { quarantine: true }; + const OFF: Policy = Policy { quarantine: false }; + + #[test] + fn off_policy_is_inert() { + let obs = vec![server("a", stdio("x", &[]))]; + assert!(plan(&obs, &known(&[]), OFF).is_empty()); + } + + #[test] + fn unknown_server_is_quarantined_and_prompted() { + let obs = vec![server("a", stdio("x", &[]))]; + let actions = plan(&obs, &known(&[]), ON); + assert!(matches!(actions[..], [Action::QuarantineAndPrompt { .. }])); + } + + #[test] + fn known_server_is_silently_quarantined() { + let s = server("a", stdio("x", &[])); + let fp = fingerprint(&s.name, &s.config).unwrap(); + let actions = plan(&[s], &known(&[&fp]), ON); + assert!(matches!(actions[..], [Action::SilentQuarantine { .. }])); + } + + #[test] + fn edison_entry_is_skipped() { + let obs = vec![server("edison-watch", stdio("x", &[]))]; + assert!(plan(&obs, &known(&[]), ON).is_empty()); + } + + #[test] + fn untouchable_opaque_server_is_skipped() { + let obs = vec![server("ext", opaque(false))]; + assert!(plan(&obs, &known(&[]), ON).is_empty()); + } + + #[test] + fn removable_opaque_server_is_removed() { + let obs = vec![server("plugin", opaque(true))]; + let actions = plan(&obs, &known(&[]), ON); + assert!(matches!(actions[..], [Action::RemoveOpaque { .. }])); + } + + #[test] + fn mixed_batch_routes_each_server() { + let unknown = server("u", stdio("a", &[])); + let known_srv = server("k", stdio("b", &[])); + let kfp = fingerprint(&known_srv.name, &known_srv.config).unwrap(); + let actions = plan(&[unknown, known_srv], &known(&[&kfp]), ON); + assert_eq!(actions.len(), 2); + assert!(matches!(actions[0], Action::QuarantineAndPrompt { .. })); + assert!(matches!(actions[1], Action::SilentQuarantine { .. })); + } +} diff --git a/crates/mcp_quarantine/src/seen_store.rs b/crates/mcp_quarantine/src/seen_store.rs new file mode 100644 index 0000000..6faf941 --- /dev/null +++ b/crates/mcp_quarantine/src/seen_store.rs @@ -0,0 +1,226 @@ +//! The persistent, org-scoped "known" oracle. +//! +//! Records which server fingerprints the daemon has already dealt with — either +//! because the backend reported them (`registered`/`requested`) or because the +//! user made a local decision (`requested`/`dismissed`/`registered`). The +//! [reconcile planner](crate::reconcile) consults it to choose *silent* removal +//! vs. *prompt*; a fingerprint it has never seen is unknown and gets prompted. +//! +//! Entries are keyed by `":"` so the same server tracked +//! across org switches stays separate. In the daemon this file is root-owned +//! and tamper-resistant; in tests it is any path in a tempdir. +//! +//! The store is bound to a single `org_id` (the enrolled user's org); all reads +//! and writes are scoped to it. + +use std::collections::HashSet; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; +use crate::reconcile::KnownOracle; + +/// How a fingerprint became known. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Action { + /// Approved server (backend `registered`, or admin/owner added it). + Registered, + /// Pending admin review (user requested access). + Requested, + /// User skipped — stays quarantined, re-quarantined silently on reappearance. + Dismissed, + /// Auto-quarantined without an explicit user decision. + Quarantined, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct SeenServer { + org_id: String, + fingerprint: String, + name: String, + action: Action, + /// True when sourced from a backend sync (vs. a local decision). Governs + /// pruning: only backend-sourced entries are pruned when they vanish from + /// the backend; local decisions (e.g. `dismissed`) are preserved. + from_backend: bool, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct StoreData { + /// Keyed by `":"`. + servers: std::collections::BTreeMap, +} + +/// Persistent, org-scoped record of dealt-with fingerprints. +#[derive(Debug)] +pub struct SeenStore { + path: PathBuf, + org_id: String, + data: StoreData, +} + +impl SeenStore { + /// Open (or initialise) the store at `path`, scoped to `org_id`. A missing + /// file yields an empty store; a malformed one is an error. + pub fn open(path: impl Into, org_id: impl Into) -> Result { + let path = path.into(); + let data = if path.exists() { + let raw = std::fs::read_to_string(&path).map_err(|source| Error::Io { + path: path.clone(), + source, + })?; + serde_json::from_str(&raw).map_err(|e| Error::Json { + path: path.clone(), + message: e.to_string(), + })? + } else { + StoreData::default() + }; + Ok(Self { + path, + org_id: org_id.into(), + data, + }) + } + + fn key(&self, fingerprint: &str) -> String { + format!("{}:{}", self.org_id, fingerprint) + } + + /// Record a *local* decision (from a user disposition). + pub fn mark(&mut self, fingerprint: &str, name: &str, action: Action) -> Result<()> { + self.upsert(fingerprint, name, action, false) + } + + /// Record an entry learned from a *backend* sync. + pub fn mark_from_backend( + &mut self, + fingerprint: &str, + name: &str, + action: Action, + ) -> Result<()> { + self.upsert(fingerprint, name, action, true) + } + + fn upsert(&mut self, fingerprint: &str, name: &str, action: Action, from_backend: bool) -> Result<()> { + self.data.servers.insert( + self.key(fingerprint), + SeenServer { + org_id: self.org_id.clone(), + fingerprint: fingerprint.to_string(), + name: name.to_string(), + action, + from_backend, + }, + ); + self.save() + } + + /// Drop backend-sourced entries for this org whose fingerprint is no longer + /// in `synced` (the latest backend set). **Local-only** entries (e.g. a + /// `dismissed` skip) are preserved so skipped servers don't re-prompt. + pub fn prune_backend(&mut self, synced: &HashSet) -> Result<()> { + let prefix = format!("{}:", self.org_id); + self.data.servers.retain(|key, entry| { + let ours = key.starts_with(&prefix); + !(ours && entry.from_backend && !synced.contains(&entry.fingerprint)) + }); + self.save() + } + + fn save(&self) -> Result<()> { + let raw = serde_json::to_string_pretty(&self.data).map_err(|e| Error::Json { + path: self.path.clone(), + message: e.to_string(), + })?; + std::fs::write(&self.path, raw).map_err(|source| Error::Io { + path: self.path.clone(), + source, + }) + } + + /// Whether this fingerprint is known for the bound org. + pub fn contains(&self, fingerprint: &str) -> bool { + self.data.servers.contains_key(&self.key(fingerprint)) + } + + /// Forget a fingerprint for the bound org (used by a dev restore so the + /// server isn't immediately re-quarantined). No-op if absent. + pub fn forget(&mut self, fingerprint: &str) -> Result<()> { + let key = self.key(fingerprint); + if self.data.servers.remove(&key).is_some() { + self.save()?; + } + Ok(()) + } +} + +impl KnownOracle for SeenStore { + fn is_known(&self, fingerprint: &str) -> bool { + self.contains(fingerprint) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn store(org: &str) -> (tempfile::TempDir, SeenStore) { + let dir = tempdir().unwrap(); + let path = dir.path().join("seen.json"); + let s = SeenStore::open(path, org).unwrap(); + (dir, s) + } + + #[test] + fn unknown_fingerprint_is_not_known() { + let (_d, s) = store("org1"); + assert!(!s.is_known("abc")); + } + + #[test] + fn marked_fingerprint_is_known_and_persists() { + let dir = tempdir().unwrap(); + let path = dir.path().join("seen.json"); + { + let mut s = SeenStore::open(&path, "org1").unwrap(); + s.mark("abc", "evil", Action::Dismissed).unwrap(); + assert!(s.is_known("abc")); + } + // Reopen from disk. + let s2 = SeenStore::open(&path, "org1").unwrap(); + assert!(s2.is_known("abc")); + } + + #[test] + fn org_scoping_isolates_entries() { + let dir = tempdir().unwrap(); + let path = dir.path().join("seen.json"); + { + let mut s = SeenStore::open(&path, "org1").unwrap(); + s.mark("abc", "x", Action::Registered).unwrap(); + } + let other = SeenStore::open(&path, "org2").unwrap(); + assert!(!other.is_known("abc")); // different org, same fingerprint + } + + #[test] + fn prune_drops_vanished_backend_entries_but_keeps_local() { + let (_d, mut s) = store("org1"); + s.mark_from_backend("backend-gone", "g", Action::Registered) + .unwrap(); + s.mark_from_backend("backend-kept", "k", Action::Requested) + .unwrap(); + s.mark("local-dismissed", "d", Action::Dismissed).unwrap(); + + let synced: HashSet = ["backend-kept".to_string()].into_iter().collect(); + s.prune_backend(&synced).unwrap(); + + assert!(!s.is_known("backend-gone")); // backend entry, gone from sync -> pruned + assert!(s.is_known("backend-kept")); // still in sync -> kept + assert!(s.is_known("local-dismissed")); // local decision -> preserved + } +} diff --git a/crates/mcp_quarantine/src/statedb.rs b/crates/mcp_quarantine/src/statedb.rs new file mode 100644 index 0000000..c53e66a --- /dev/null +++ b/crates/mcp_quarantine/src/statedb.rs @@ -0,0 +1,49 @@ +//! Read + write a single `ItemTable(key, value)` row of a `state.vscdb` SQLite +//! DB. Unlike the read-only reader in `mcp_detector_lib`, this opens read-write +//! so quarantine can rewrite the row's JSON value. +//! +//! Note on the live-DB race: Cursor/VSCode may hold the DB open and rewrite the +//! row from their in-memory cache, undoing our edit. We open read-write with a +//! busy timeout; the level-triggered reconcile re-quarantines on the next pass +//! if the editor puts the server back — same tolerance model as client_2. + +use std::path::Path; +use std::time::Duration; + +use rusqlite::OptionalExtension; + +use crate::error::{Error, Result}; + +const BUSY_TIMEOUT: Duration = Duration::from_secs(3); + +fn sqlite_err(path: &Path, e: rusqlite::Error) -> Error { + Error::Json { + path: path.to_path_buf(), + message: format!("sqlite: {e}"), + } +} + +/// Read the JSON value of `key`, or `None` if the row is absent. +pub(crate) fn read_row(db_path: &Path, key: &str) -> Result> { + let conn = rusqlite::Connection::open(db_path).map_err(|e| sqlite_err(db_path, e))?; + conn.busy_timeout(BUSY_TIMEOUT) + .map_err(|e| sqlite_err(db_path, e))?; + conn.query_row("SELECT value FROM ItemTable WHERE key = ?1", [key], |r| { + r.get::<_, String>(0) + }) + .optional() + .map_err(|e| sqlite_err(db_path, e)) +} + +/// Overwrite the JSON value of `key` (read-write open + `UPDATE`). +pub(crate) fn write_row(db_path: &Path, key: &str, value: &str) -> Result<()> { + let conn = rusqlite::Connection::open(db_path).map_err(|e| sqlite_err(db_path, e))?; + conn.busy_timeout(BUSY_TIMEOUT) + .map_err(|e| sqlite_err(db_path, e))?; + conn.execute( + "UPDATE ItemTable SET value = ?1 WHERE key = ?2", + rusqlite::params![value, key], + ) + .map_err(|e| sqlite_err(db_path, e))?; + Ok(()) +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b2a2638 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,515 @@ +# Quarantine Daemon — Architecture & Design + +> Status: **Design agreed**, pre-implementation. Date: 2026-06-30. +> Scope: the Rust reimplementation of the MCP discovery + quarantine subsystem of +> `client_2` (the Edison Watch Electron app) as a privileged system agent. + +This document captures the architecture decided for the Rust quarantine daemon and +**the reasoning behind each decision** — especially the non-obvious ones. It is the +source of truth for the design; the current code (`mcp_detector_lib` read-only +watcher + `mcp_detector_daemon` FDA-gated per-user daemon) predates it and will be +reshaped to match. + +--- + +## 1. Problem & goals + +MCP servers are configured independently by each host app (Claude Code, VSCode, +Cursor, Claude Desktop, …), often across several files per app. Edison Watch needs +to **discover** those servers and, when an admin requires it, **quarantine** them — +move them off the local config and onto the Edison Watch backend. + +Today this lives in the `client_2` Electron app, where the app *itself* is the +enforcer. That is the weakness we are fixing: enforcement runs as the user, in a +process the user can stop. The Rust effort exists to make quarantine **faster, more +robust, and enforceable as a system agent**, because the functionality is +**imposed by admins**. + +Two operating modes (one policy switch): + +- **Quarantine OFF** — auto-discovery only; servers move to Edison Watch only when + the user explicitly chooses to. +- **Quarantine ON** — auto-discovery **plus** continuous enforcement: new servers + are actively detected and removed. + +The only behavioural difference between the two modes is **what happens to a server +the user does *not* send to Edison Watch**: OFF leaves it; ON removes it anyway. + +## 2. Posture & threat model (the reframe everything follows from) + +Because quarantine is **admin-imposed** and the daemon must be a service the user +**cannot stop**, this is **not a detector that helps an app** — it is a +**privileged enforcement agent that assumes the local user is adversarial.** The +user may actively try to run a forbidden server: editing configs back, restoring a +quarantined entry, killing the helper UI, or feeding a fake "quarantine off" flag. + +Every decision below falls out of that single fact. + +--- + +## 3. Key decisions + +Each is stated with its rationale; details follow in §4+. + +1. **Privileged, non-stoppable process.** A root `LaunchDaemon` + (`/Library/LaunchDaemons`), not a per-user `LaunchAgent`. A user can + `launchctl unload` their own agent, so the agent model cannot satisfy + "not stoppable by the user." Root ownership also dissolves the Full-Disk-Access + dance (the current FDA state machine goes away). + +2. **The daemon owns *all* detection — read *and* write.** Enforcement integrity + requires the same privileged process that detects to also act. The Electron UI + is demoted to a helper: it pops up notifications, collects a rename, and asks + the daemon questions. It performs **no** config writes, ever. + +3. **Multi-tenant, keyed by OS user, identified by the kernel.** One root daemon + serves all users. The principal for every request is the **peer uid** + (`getpeereid`), not anything the (untrusted) UI asserts. Operations are scoped + to that uid; a UI connection cannot see or act on another user's servers. + +4. **Trust boundary flips: the UI can request/suggest, never decide/veto/disable.** + The policy flag and approved-server list must be **daemon-fetched and + daemon-owned**, never sourced from the user-space app. Enrollment is the one + moment the UI hands over a credential — the user's own key, for their own org. + +5. **Fail-closed, last-known-good policy.** The client today fails *open* + (`fetchAutoQuarantineEnabled` returns `false` on any error). For an enforcement + daemon that is backwards — "backend unreachable" must **not** mean "enforcement + off." The daemon caches the last successfully-fetched policy + known-set and + keeps enforcing through outages. There is no cold-start gap because enrollment + happens during sign-in (inherently online); enrollment is not marked active + until the first policy fetch succeeds. + +6. **Quarantine-first.** On detecting an unknown server the daemon **neutralizes it + immediately** (moves it to a sidecar), *then* asks the user for disposition. + This closes the race where a user adds a forbidden server and uses it during the + prompt window. It also collapses two mutation paths into one (see §9). + +7. **Level-triggered reconciliation, not edge-triggered diffing.** Both `client_2` + (`lastKnownServers` diff) and the current Rust prototype (`diff::Snapshot`) react + to *changes*. Against an adversarial user that is fragile. Instead the daemon + reconciles **current observed state** against policy every cycle (kubelet-style). + Tamper-resistance then falls out for free: a restored server is simply present + next cycle and gets removed again — no "drop from last-known" trick, no + dependence on catching the edit event. + +8. **Identity = a frozen, three-way fingerprint contract.** The fingerprint is + computed independently by the **Python backend** and the **TS client** today and + they are deliberately kept identical. The Rust daemon must join that contract + byte-for-byte (see §6). This is a freeze-and-port, not a redesign. + +9. **Strict crate layering by trust/privilege** (see §11): read-only engine → + mutation+logic → backend client → privileged binary, with a no-cycle DAG. + +--- + +## 4. Privilege & process model + +- **Root `LaunchDaemon`**, one per machine, serving every OS user. +- **Identity via peer credentials.** A single root-owned socket + (e.g. `/var/run/edison-watch/daemon.sock`, permissive perms). On each connection + the daemon calls `getpeereid(fd) → uid`. That uid is the authz principal and maps + to an enrollment. Authz is **kernel-enforced**, not asserted by the UI, so it + cannot be spoofed. An un-enrolled uid may only call `Enroll`. +- **Privilege-drop on write.** MCP configs live in each user's home. When mutating a + user's files the daemon drops to that user (`seteuid` / write-then-`chown`) so + file ownership stays correct. Decision logic runs as root; only the file touch is + de-privileged. +- **FDA is gone.** The `Starting/AwaitingFda/Running` machine and `permission.rs` + TCC probe are removed; a root daemon has the access and gets identity from + `getpeereid` instead. + +## 5. Enrollment, policy & state ownership + +**Root-owned enrollment store** — `/Library/Application Support/Edison Watch/daemon/ +enrollments.json`, `root:wheel`, `0600` — keyed by OS user: + +```jsonc +{ + "enrollments": { + "alice": { + "env": "prod", + "api_base_url": "https://api.edison.watch", + "api_key": "ew_live_…", // alice's bearer — same key the app uses + "org_id": "org_…", + "policy": { "auto_quarantine": true, "fetched_at": "…" }, // last-known-good + "allow_list": [ { "name": "…", "fingerprint": "…", "status": "registered" } ], + "allow_list_fetched_at": "…" + } + } +} +``` + +- **Auth is unchanged from the client**: bearer API key over REST. There is **no** + machine/device-token mechanism today, so the daemon stores **user→API-key pairs** + in this root-owned file. The client confirms it accesses the backend via the REST + API, **never the DB directly** (`domainConfig.ts`; no `pg`/`supabase` in + `src/main`). +- **Endpoints** (all `Authorization: Bearer `): + - `GET /api/v1/user/domain-config` → `auto_quarantine_other_mcp_servers` + - `GET /api/v1/servers/fingerprints` → org known-set (registered + requested) + - `POST /api/v1/mcp-requests` → submit / register a server +- **Enrollment = sign-in handshake.** The UI (running as the user) sends + `Enroll{api_key, env}`; the daemon validates by resolving `org_id` and does its + **first** policy + known-set fetch. Enrollment is only marked active once that + fetch succeeds. Because sign-in is inherently online, there is no + "enrolled-but-never-reached-backend" state. +- **Root-owned storage is what makes it tamper-resistant**: after enrollment, + logout / key-deletion in user space cannot starve the daemon of policy. +- **Refresh loop**: poll `domain-config` + `fingerprints` per user (~5 min). On any + error, **keep the cached values and keep enforcing** (fail-closed). Never + downgrade to "off" on a failed fetch. +- **Authoritative state is root-owned.** Local config files and `disabled_` sidecars + are treated as *untrusted input to reconcile*, never as the source of truth. + +### Residual bypass (accepted, deferred) + +Without a device identity / MDM binding, nothing stops a user from enrolling with a +**personal org's** key whose policy has quarantine off. This is the same limitation +`client_2` has today. Out of scope until a machine-token mechanism exists. + +## 6. Identity — the fingerprint contract + +The fingerprint answers *"is this server already known to the backend?"* — it +governs **prompt vs. silent removal**, not allow vs. block. (Under quarantine ON, +*nothing* stays locally; the fingerprint only decides whether the user is +prompted.) + +It is a **three-way contract**, currently honoured by two independent +implementations that must stay identical, with the Rust daemon to become the third: + +- Backend: `src/api/v1/routes/servers_fingerprints.py::compute_server_fingerprint` +- Client: `client_2/src/main/discovery/seenServersStore.ts::getServerFingerprint` + +Both docstrings cite each other as the thing they must match. + +**Algorithm** (16-char prefix of `sha256(identifier)`): + +- stdio: `identifier = f"{name}:{command}:{' '.join(args)}"` +- http : `identifier = f"{name}:{url}"` +- else : unfingerprint-able → **skipped** +- All `{PLACEHOLDER}` template tokens are normalized to bare `{}` before hashing, so + placeholder *names* never affect the result. + +### The split that locates the risk + +- **Hash half — trivial, low risk.** ~10 lines; already mirrored in Python and TS. + Port byte-exact. +- **Secret-templatization half — the entire risk, client-side only.** The backend + **never runs secret detection**; dashboard servers are *born templatized* (admins + enter `{API_TOKEN}` template fields) and it just hashes raw rows + (`servers_fingerprints.py:11-15`). The client/daemon discovers configs **raw** + (real `sk-…` value baked in) and must run `detectSecrets()` to turn the secret + into `{}` *before* hashing, or the identifier diverges and an already-known server + is falsely re-prompted. The **only** reference for this half is + `client_2/src/main/discovery/secretDetection.ts`. + +**Decision:** freeze-and-port. Redefining would mean a coordinated migration across +backend + TS client + every stored fingerprint + the new daemon — not viable. Lock +it with a **shared golden-vector corpus** (`raw config → templatized → fingerprint` +triples emitted by the live TS implementation, asserted in Rust CI). + +**Per the team:** exact secret-detection parity is **deferred** — both sides replace +with placeholders; if the daemon's detection diverges that is a separate bug to fix +later, not an architecture blocker. + +### Blind spot (intentional) + +Identity includes `command/args/url`, so **re-pointing a server's command is caught +automatically** (new fingerprint → unknown → quarantined), fixing the prototype's +"modifications ignored" gap. But secret **values** are templatized out, so +**rotating a credential is invisible** (same fingerprint). Intended — the backend +never sees per-user secret values. Structural changes are enforced; value swaps are +not. + +## 7. Discovery — the `Agent` trait + +> Terminology: **agent** = an MCP host app (Claude Code, VSCode, Cursor). **UI** = +> the Electron subscriber to the daemon. "Client" is retired because it overloaded +> both. The trait `Client` is renamed `Agent`. + +```rust +pub trait Agent: Send + Sync { // Claude Code, VSCode, … + fn name(&self) -> &'static str; + fn is_installed(&self) -> bool; // → ListAgents / onboarding + fn watch_targets(&self) -> WatchTargets; // files (d0) + dirs (dN) + needs_periodic_rescan + fn discover(&self) -> Result>; // Stdio | Http only; unsupported skipped +} + +pub struct DiscoveredServer { + pub client: &'static str, + pub name: String, // current map key (post-rename) + pub scope: Scope, // Global | Project(path) + pub transport: Transport, + pub config: ServerConfig, // RAW payload — needed for fingerprint + action + pub location: ConfigLocation, +} + +pub enum ServerConfig { + Stdio { command: String, args: Vec, env: BTreeMap }, + Http { url: String, headers: BTreeMap, kind: HttpKind }, +} + +pub struct ConfigLocation { // self-describing: where + how to mutate + pub kind: SourceKind, // dispatch target for the writer + pub path: PathBuf, + pub key_path: Vec, // ["mcpServers"] | ["projects","/p","mcpServers"] | ["servers"] + pub server_key: String, // the map key to remove (originalName) + pub extra: LocationExtra, // CLI project dir, plugin dir, sqlite row id… +} + +pub enum SourceKind { Json, Jsonc, Toml, SqliteState, ClaudeCli, CursorPluginDir } +``` + +- **The seam: the agent owns the *locator*; a shared store owns the *mechanics*.** + Discovery emits self-describing servers (each carries how to mutate it). The + agent's read+write *semantics* stay co-located ("my project-scoped servers go + through the CLI"), while the byte-manipulation per `SourceKind` is written once, + not per agent. Client-specific mechanisms (CLI, plugin-dir) are just more kinds. +- **Unsupported / opaque servers are not emitted** (no extractable command/url). + Nothing happens for them, exactly as today. May be surfaced to admins later. +- **Fingerprint is computed in the engine, not by agents** — single source of the + frozen contract; agents stay dumb. + +## 8. The reconciliation loop + +Per enrolled OS user, **level-triggered**, **armed only when policy = ON**: + +``` +reconcile(user): # serialized; coalesce triggers (dirty flag) + policy = policy_cache[user] # last-known-good + if policy.quarantine == OFF: + return # discovery/report only; no auto-mutation + + observed = discover_all(user) # every agent, raw config + locator + for srv in observed: + if is_edison_entry(srv): continue # never touch our own injected server + fp = fingerprint(srv) # ported detectSecrets + sha256 + if fp is None: continue # unsupported → skip + if known.contains(fp): # seen-store oracle + enforce_removed(srv) # SILENT: ensure quarantined/removed + else: + enforce_removed(srv) # QUARANTINE-FIRST: neutralize now + seen.mark(fp, pending) + emit_pending(user, srv, fp) # → UI popup, async disposition +``` + +- `enforce_removed` is **idempotent** — already-quarantined servers are a no-op, so + repeated runs are cheap and convergent. +- **Triggers** (all just *hints to reconcile*, never the basis of correctness): + fs events on watched parent dirs (debounced) · periodic safety-net rescan (~20 s, + catches event-less changes: SQLite `state.vscdb`, extension-API installs) · + backend policy/known-set refresh (~5 min) · enrollment (initial full sweep that + secures pre-existing state). +- **Concurrency**: one serialized reconcile worker per user with a coalescing + dirty-flag (mirrors `client_2`'s `isCheckingForChanges`/`pendingRescan`). +- **OFF mode** is inert — pure discovery/reporting; mutation only on an explicit + user "send to EW." The same engine with the worker *disarmed*. + +## 9. The "known" oracle & mutation + +**`known` is a local seen-store, not the raw backend list.** It is fed by two +writers: + +1. **Backend sync** upserts `registered`/`requested` fingerprints — covers servers + registered on the dashboard or by other machines. +2. **Local user decisions** write `dismissed`/`requested`/`registered`. + +Reconcile queries only the seen-store. **Why the union matters:** a **rename changes +the fingerprint** (name is in the identifier), so a renamed-and-sent server is stored +at the backend under the *new* fingerprint while the local config still has the +*old* one. The local seen-store (keyed by the *detected* fp, marked when the user +acted) suppresses the re-prompt; the backend set alone would re-prompt forever. + +- Store is **root-owned** and org-scoped (compound key `org_id:fingerprint`). +- **Prune rule:** backend-sync may only drop entries that *came from* the backend and + vanished — it must **not** delete local-only `dismissed` entries, or skipped + servers re-prompt on the next sync. + +**Mutation — `ConfigStore`, dispatched on `location.kind`:** + +```rust +pub trait ConfigStore: Send + Sync { + fn quarantine(&self, loc: &ConfigLocation, cfg: &ServerConfig) -> Result; + fn restore(&self, rec: &QuarantineRecord) -> Result<()>; // internal only — see §10 +} +``` + +- Mechanics per kind: backup → move entry to `disabled_.json` sidecar + (JSONC surgical edit preserving comments) / SQLite mutation / `claude mcp remove` / + plugin-dir rename → rollback on failure. +- **Quarantine-first shrinks the surface.** `client_2` needed two write paths — + `removeServerFromConfig` (delete, for "Add to Edison") and `quarantineServer` + (sidecar, for skip). Here *every* detection goes to the sidecar first, so + disposition needs no separate delete path: "send to EW" = submit to backend + (+ optionally purge the now-redundant sidecar entry); "skip" = leave in sidecar. + The irreversible delete disappears — safer. +- **Writers are privilege-free file ops** (testable in a tempdir). Privilege-drop is + a wrapper the daemon adds around the call, not baked into the writer. + +## 10. IPC contract + +**Transport:** one root socket; principal = `getpeereid` uid; newline-delimited JSON; +`request_id` for correlation. **Pushes are best-effort; authoritative state is +queryable** — the UI must not depend on having received a push. On startup it calls +`ListServers{state: QuarantinedPending}` and renders popups for whatever is there. + +```rust +// ── UI → daemon ── (principal = peer uid; NOT in the message) +enum Request { + Enroll { api_key: String, env: String }, + GetStatus, + ListAgents, // which host apps are present + ListServers { state: Option, agent: Option }, + Disposition { fingerprint: String, choice: Choice }, + RefreshPolicy, // force a re-sync +} +enum Choice { SendToEw { rename: Option }, Skip } + +// ── daemon → UI ── +enum Message { + Status(StatusReply), Agents(Vec), Servers(Vec), + Ack, Error { code: ErrorCode, message: String }, + Event(Event), // unsolicited push +} +enum Event { + Quarantined(ServerView), // already neutralized, awaiting disposition + Discovered(ServerView), // quarantine-OFF / informational + ServerStateChanged { fingerprint: String, state: ServerState }, + PolicyChanged { quarantine_on: bool }, +} +enum ServerState { Live, QuarantinedPending, SentToEw, Skipped } + +struct StatusReply { // stdio-d-style flat status, NOT an FDA machine + installed: bool, enrolled: bool, quarantine_on: bool, + last_policy_sync: Option, state_age_ms: u64, // liveness/heartbeat + agents_watched: Vec, version: String, +} +``` + +- **`Disposition` is a request, not a write.** `SendToEw` → daemon does + `POST /mcp-requests` (renamed config, that uid's key), marks seen, purges sidecar. + `Skip` → marks `dismissed`. The UI proposes; the daemon disposes. +- **Disposition is never time-critical** (quarantine-first ⇒ already safe). No + blocking, no timeout; an unanswered prompt just stays quarantined. +- **No restore verb.** Local re-materialization happens only on policy-off or + un-enroll, as an internal daemon action. + +**Operator CLI** (mirrors `stdiod`; root-gated — natural, since the daemon and plist +are root-owned): + +``` +edison-watchd install +edison-watchd uninstall [--purge] # tear down LaunchDaemon; --purge wipes enrollments/state +edison-watchd unenroll # drop one enrollment, keep the daemon +edison-watchd enroll --user --key # debug convenience +edison-watchd status # same flat status as GetStatus, from a written state.json +``` + +Over the socket, admin ops are gated by `peer_uid == 0`. The UI never gets +unenroll/uninstall/restore. (`stdiod` precedent: +`client_2/src/main/stdiod/controller.ts` — `install`/`uninstall --purge`/`status`, +status = `{binaryAvailable, installed, loggedIn, state, stateAgeMs}`.) + +## 11. Crate layout + +Organizing principle: **isolate by trust/privilege so each boundary is auditable.** + +``` +crates/ + mcp_detector_lib/ READ-ONLY engine. Cross-platform, no root, no network, publishable. + agent.rs trait Agent (was Client) + agents/{claude_code,vscode}.rs parsers → DiscoveredServer + types.rs DiscoveredServer, ServerConfig, ConfigLocation, SourceKind, Scope, Transport + fingerprint.rs ported getServerFingerprint ← frozen contract + secret_detection.rs ported detectSecrets ← the fragile half + watch.rs WatchTargets + + mcp_quarantine/ MUTATION + decision logic. No privilege, no IPC, no network. + configstore.rs trait ConfigStore + kind-dispatched writers + writers/{json,jsonc,sqlite,claude_cli,plugin_dir}.rs + seen_store.rs persistent known-oracle (path injected) + reconcile.rs pure planner: plan(observed, oracle, policy) -> Vec + # depends on: mcp_detector_lib + + mcp_backend/ Backend REST client (reqwest). No privilege. + lib.rs, types.rs domain_config / server_fingerprints / submit_request + # depends on: mcp_detector_lib + + mcp_detector_daemon/ THE binary. All privilege + all wiring. + main.rs operator CLI + enrollment.rs root-owned enrollments.json + policy.rs backend refresh w/ fail-closed last-known-good + supervisor.rs per-user reconcile WORKERS (triggers, debounce, timers, dirty-flag) + privilege.rs getpeereid scoping · privilege-drop-on-write + ipc.rs root socket · uid→enrollment dispatch · UI protocol + protocol.rs Request / Message / Event / StatusReply + platform/launchd.rs plist install/uninstall · state.json writer + # depends on: all three above +``` + +**Dependency DAG (strict, acyclic):** + +``` + mcp_detector_lib + ▲ ▲ ▲ + mcp_quarantine │ mcp_backend (siblings; no edge between them) + ▲ │ ▲ + └───────┼───────┘ + mcp_detector_daemon +``` + +Two deliberate non-edges: + +- **`mcp_quarantine` does not depend on `mcp_backend`.** Reconcile reads only the + local `seen_store`. The daemon does backend sync → writes the known-set into + `seen_store` → reconcile reads it. Decision logic never touches the network and is + unit-testable with a hand-built oracle. +- **`reconcile` is a pure planner; the *driver* is in the daemon.** + `plan(observed, oracle, policy) -> Vec` is deterministic and testable; the + messy parts (watching, debounce, timers, per-user serialization, privilege-drop, + IPC emit) live in `supervisor.rs`. + +**Why:** the read-only engine stays publishable and root-free (a reviewer auditing +"does discovery touch disk?" reads one crate); all mutation is in one root-free crate +(test sidecar+surgical-edit in a tempdir, no root); all danger — `getpeereid`, +privilege-drop, root socket, launchd, operator CLI — collapses into the binary. + +**Port vs. new:** + +| Crate | Status | Work | +|---|---|---| +| `mcp_detector_lib` | exists, reshape | `Client→Agent`; `parse_all→discover` (raw config + locator); add `fingerprint`/`secret_detection`; `watch_paths→watch_targets` | +| `mcp_quarantine` | new | configstore + writers, seen_store, reconcile planner | +| `mcp_backend` | new | thin reqwest client over 3 endpoints | +| `mcp_detector_daemon` | exists, heavy rework | drop FDA + `permission.rs`; add root socket + uid scoping + multi-user workers + enrollment + policy refresh + operator CLI + privilege-drop + launchd | + +## 12. Open / deferred + +- **Secret-detection parity** — deferred; lock with golden vectors when addressed + (§6). +- **Residual enroll-with-permissive-org bypass** — needs a device token / MDM + binding; out of scope until that exists (§5). +- **Unsupported/opaque server visibility** — currently skipped silently; could be + surfaced to admins later (§7). +- **Non-macOS platforms** — layout is cross-platform but the daemon's launchd + + privilege-drop are macOS-first; Linux (systemd) / Windows parallels later. +- **Tool-level introspection** — *not* required. "Discover the agents" means + detecting installed host apps (`is_installed`), not connecting to servers to list + tools. + +## 13. Reference — `client_2` mechanisms to port + +| Concern | Source | +|---|---| +| Fingerprint (backend, canonical hash) | `src/api/v1/routes/servers_fingerprints.py` | +| Fingerprint (client) + seen-store | `client_2/src/main/discovery/seenServersStore.ts` | +| Secret templatization | `client_2/src/main/discovery/secretDetection.ts` | +| Discovery aggregator + dedup | `client_2/src/main/discovery/mcpDiscovery.ts` | +| Agent registry + config entries | `client_2/src/main/clients/registry.ts`, `clients/types.ts` | +| Monitor (diff, rescan, re-quarantine) | `client_2/src/main/runtime/mcpConfigMonitor.ts` | +| Mutation (quarantine/remove/restore) | `client_2/src/main/runtime/mcpConfigActions.ts` | +| Policy orchestration + silent rules | `client_2/src/main/quarantine/quarantineManager.ts` | +| Admin flag fetch | `client_2/src/main/infra/domainConfig.ts` | +| Backend sync of known-set | `client_2/src/main/discovery/seenServersBackendSync.ts` | +| Daemon lifecycle precedent (CLI + status) | `client_2/src/main/stdiod/controller.ts` | diff --git a/instructions.txt b/instructions.txt new file mode 100644 index 0000000..df071fb --- /dev/null +++ b/instructions.txt @@ -0,0 +1,71 @@ +0. Build once + + cd ~/work/mcp-detector-lib + cargo build -p mcp_detector_daemon + BIN=./target/debug/mcp_detector_daemon + KEY=edison_LWRP3bGRJhfxew26pJpWq6kqOb0ibieD6wFSBUi + + 1. CLI — read-only (totally safe) + + $BIN status # "Not enrolled" (fresh) or your enrollment + $BIN enroll --url http://localhost:3001 --key $KEY + $BIN status # cached policy + $BIN status --refresh # re-fetch policy live + $BIN list # deduped view: known / new / opaque / report + $BIN list --verbose # every instance + its source path + What to look for in list: new = would be quarantined, opaque = removed-locally-only (Cursor plugins), report = untouchable (VSCode extensions), edison = skipped. + + 2. Enforcement — dry-run first, then the real thing ⚠️ + + $BIN run # DRY-RUN: logs what it WOULD do, touches nothing. Ctrl-C to stop. + You'll see the summary (will_quarantine=… opaque_removals=… will_skip=…) and per-server lines, re-running on fs changes. Safe. + + $BIN run --enforce # REAL: quarantines your `new` + `opaque` servers. Ctrl-C to stop. + $BIN list # they're gone from configs; "Quarantined (N)" section appears + $BIN restore railway # put one back (config restored, fingerprint forgotten) + Every quarantine leaves a .ew-backup and a disabled_.json next to the original, so nothing is lost. + + 3. Send one server to Edison Watch (mutates + hits backend) + + $BIN send-to-ew railway --agent codex # submits to backend, removes locally, marks known + $BIN restore railway # undo + + 4. IPC socket — what the UI will use + + $BIN serve --socket /tmp/ew.sock & # start the socket + printf '{"op":"status"}\n' | nc -U /tmp/ew.sock -w 2 # note: user comes from the socket, not the message + printf '{"op":"list_agents"}\n' | nc -U /tmp/ew.sock -w 3 + printf '{"op":"list_servers"}\n'| nc -U /tmp/ew.sock -w 3 | python3 -m json.tool | head + printf '{"op":"bogus"}\n' | nc -U /tmp/ew.sock -w 2 # structured error + kill %1 + The key thing: status reports "user":"gatlingx" even though you never sent a username — it's derived from the socket's peer credentials. + + 5. Full daemon (supervisor: IPC + worker + state.json + file logs) + + $BIN daemon --socket /tmp/ew.sock & # dry-run daemon; add --enforce to actually quarantine + sleep 2 + printf '{"op":"status"}\n' | nc -U /tmp/ew.sock -w 2 # query it live + cat "$HOME/Library/Application Support/edison-watch-detectord/state.json" # heartbeat + ls "$HOME/Library/Application Support/edison-watch-detectord/logs/" # rolling log file + kill %1 + + To watch a live event push (advanced, mutating): with daemon --enforce running, keep a socket connection open and add a new server so the worker quarantines it: + # terminal A: + $BIN daemon --enforce --socket /tmp/ew.sock + # terminal B — keep this open; it will receive pushes: + nc -U /tmp/ew.sock # then paste: {"op":"status"} and leave it connected + # terminal C — add an unknown server to trigger a quarantine (edit is reversible): + # add a dummy entry under "mcpServers" in ~/.claude.json, save + # → terminal B prints {"event":"quarantined", ...} within ~1s + + Cleanup + + $BIN unenroll + rm -rf "$HOME/Library/Application Support/edison-watch-detectord" # wipe all local state + + Notes + + - Your org currently has quarantine=true, so --enforce really does remove servers. Use plain run/daemon (dry-run) to observe safely. + - Everything runs as your user (dev mode); state lives under ~/Library/Application Support/edison-watch-detectord/. The root build would move that to /Library/... and add + launchd — that's the remaining sudo-only step. + - nc -U on macOS sometimes needs a timeout (-w) or it hangs waiting; that's just nc, not the daemon. From e1153888b0595e714bba0efa60ca66dba0a7c3d6 Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Fri, 10 Jul 2026 16:25:24 +0300 Subject: [PATCH 2/5] chore(daemon): silence too-many-arguments clippy on ops::enroll Adding the `armed` param pushed enroll to 8 args. It's a flat list of enrollment inputs (matching reconcile_once, which already allows this); grouping into a struct isn't worth it. Allow the lint. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mcp_detector_daemon/src/ops.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/mcp_detector_daemon/src/ops.rs b/crates/mcp_detector_daemon/src/ops.rs index fbfc614..a0108a6 100644 --- a/crates/mcp_detector_daemon/src/ops.rs +++ b/crates/mcp_detector_daemon/src/ops.rs @@ -125,6 +125,7 @@ pub async fn refresh_policy(user: &str) -> anyhow::Result { /// the UI/CLI: `None`/unspecified keeps the previous value, so re-running /// `enroll` acts as an update. The selection diff uninstalls edison-watch from /// agents dropped from the set. +#[allow(clippy::too_many_arguments)] pub async fn enroll( user: &str, url: String, From a4d0f54e8e24f9943bf8f21bf3f6f4458458f479 Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Fri, 10 Jul 2026 16:39:20 +0300 Subject: [PATCH 3/5] style: cargo fmt --all (fix red rustfmt CI job) The workspace wasn't formatted with stock `cargo fmt`, so the CI `rustfmt` job (cargo fmt --all -- --check on @stable) was failing. Apply canonical formatting across all crates. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/mcp_backend/src/lib.rs | 15 ++- crates/mcp_detector_daemon/src/claude_cli.rs | 16 ++- .../mcp_detector_daemon/src/hook_consumer.rs | 10 +- crates/mcp_detector_daemon/src/launchd.rs | 25 +++- crates/mcp_detector_daemon/src/logging.rs | 4 +- crates/mcp_detector_daemon/src/main.rs | 28 ++++- crates/mcp_detector_daemon/src/ops.rs | 50 +++++--- crates/mcp_detector_daemon/src/protocol.rs | 16 +-- crates/mcp_detector_daemon/src/recovery.rs | 13 +- crates/mcp_detector_daemon/src/runner.rs | 56 ++++++--- .../src/clients/claude_code.rs | 31 +++-- .../src/clients/claude_cowork.rs | 10 +- .../src/clients/claude_desktop.rs | 10 +- crates/mcp_detector_lib/src/clients/codex.rs | 9 +- crates/mcp_detector_lib/src/clients/cursor.rs | 38 ++++-- .../mcp_detector_lib/src/clients/transport.rs | 6 +- crates/mcp_detector_lib/src/clients/vscode.rs | 6 +- .../mcp_detector_lib/src/clients/windsurf.rs | 10 +- crates/mcp_detector_lib/src/fingerprint.rs | 7 +- .../mcp_detector_lib/src/secret_detection.rs | 85 ++++++++++--- crates/mcp_detector_lib/tests/spawn_smoke.rs | 2 +- crates/mcp_quarantine/src/configstore.rs | 93 +++++++++++---- crates/mcp_quarantine/src/hooks.rs | 112 +++++++++++++----- crates/mcp_quarantine/src/lib.rs | 2 +- crates/mcp_quarantine/src/reconcile.rs | 6 +- crates/mcp_quarantine/src/seen_store.rs | 8 +- 26 files changed, 508 insertions(+), 160 deletions(-) diff --git a/crates/mcp_backend/src/lib.rs b/crates/mcp_backend/src/lib.rs index ee87612..80d813e 100644 --- a/crates/mcp_backend/src/lib.rs +++ b/crates/mcp_backend/src/lib.rs @@ -420,8 +420,16 @@ mod tests { #[test] fn policy_true_and_false_and_missing() { - assert!(parse_policy(r#"{"auto_quarantine_other_mcp_servers":true}"#).unwrap().quarantine); - assert!(!parse_policy(r#"{"auto_quarantine_other_mcp_servers":false}"#).unwrap().quarantine); + assert!( + parse_policy(r#"{"auto_quarantine_other_mcp_servers":true}"#) + .unwrap() + .quarantine + ); + assert!( + !parse_policy(r#"{"auto_quarantine_other_mcp_servers":false}"#) + .unwrap() + .quarantine + ); // Missing flag defaults to false (fail-closed caching is the daemon's job). assert!(!parse_policy(r#"{}"#).unwrap().quarantine); } @@ -504,8 +512,7 @@ mod tests { #[test] fn user_part_hash_strips_prefix_and_ignores_org_segment() { // sha256("abc") — the hash is of the user part, not the composite. - const SHA256_ABC: &str = - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + const SHA256_ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; assert_eq!(user_part_hash("user:abc"), SHA256_ABC); // The `.admin:<…>` org segment is not part of the user hash. assert_eq!(user_part_hash("user:abc.admin:xyz"), SHA256_ABC); diff --git a/crates/mcp_detector_daemon/src/claude_cli.rs b/crates/mcp_detector_daemon/src/claude_cli.rs index 7f07482..1e71606 100644 --- a/crates/mcp_detector_daemon/src/claude_cli.rs +++ b/crates/mcp_detector_daemon/src/claude_cli.rs @@ -34,11 +34,23 @@ pub fn install(user: &str, url: &str, secret: Option<&str>) -> anyhow::Result<() pub fn remove(user: &str) -> anyhow::Result<()> { let _ = run_as( user, - &["mcp".into(), "remove".into(), "edison-watch".into(), "--scope".into(), "project".into()], + &[ + "mcp".into(), + "remove".into(), + "edison-watch".into(), + "--scope".into(), + "project".into(), + ], ); run_as( user, - &["mcp".into(), "remove".into(), "edison-watch".into(), "--scope".into(), "user".into()], + &[ + "mcp".into(), + "remove".into(), + "edison-watch".into(), + "--scope".into(), + "user".into(), + ], ) } diff --git a/crates/mcp_detector_daemon/src/hook_consumer.rs b/crates/mcp_detector_daemon/src/hook_consumer.rs index e068c5c..e51ec8d 100644 --- a/crates/mcp_detector_daemon/src/hook_consumer.rs +++ b/crates/mcp_detector_daemon/src/hook_consumer.rs @@ -98,8 +98,14 @@ fn log_session_end(path: &Path) { && let Ok(v) = serde_json::from_str::(&text) && v.get("event").and_then(|x| x.as_str()) == Some("session_end") { - let conv = v.get("conversation_id").and_then(|x| x.as_str()).unwrap_or("?"); - let reason = v.get("reason").and_then(|x| x.as_str()).unwrap_or("unknown"); + let conv = v + .get("conversation_id") + .and_then(|x| x.as_str()) + .unwrap_or("?"); + let reason = v + .get("reason") + .and_then(|x| x.as_str()) + .unwrap_or("unknown"); tracing::info!(conversation_id = conv, reason, "session ended"); } } diff --git a/crates/mcp_detector_daemon/src/launchd.rs b/crates/mcp_detector_daemon/src/launchd.rs index 0fa70c8..9feb3d6 100644 --- a/crates/mcp_detector_daemon/src/launchd.rs +++ b/crates/mcp_detector_daemon/src/launchd.rs @@ -24,8 +24,7 @@ const LABEL: &str = "watch.edison.detectord"; const PLIST_FILENAME: &str = "watch.edison.detectord.plist"; /// launchd's per-user default PATH omits Homebrew and /usr/local/bin; without /// this every child spawn (`claude`, `npx`, …) fails to resolve. -const CHILD_PATH: &str = - "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; +const CHILD_PATH: &str = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; fn plist_path() -> Result { let home = dirs::home_dir().ok_or_else(|| anyhow!("HOME not set"))?; @@ -142,13 +141,25 @@ pub fn install(enforce: bool) -> Result<()> { tracing::info!(path = %plist.display(), enforce, "wrote LaunchAgent plist"); bootout_quiet()?; // pick up a moved binary / changed flags - let out = launchctl(&["bootstrap", &user_domain(), plist.to_string_lossy().as_ref()])?; + let out = launchctl(&[ + "bootstrap", + &user_domain(), + plist.to_string_lossy().as_ref(), + ])?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); bail!("launchctl bootstrap failed: {}", stderr.trim()); } println!("Installed LaunchAgent: {}", plist.display()); - println!("Daemon running{}; socket: {}", if enforce { " (enforcing)" } else { " (report-only)" }, ipc::default_socket_path().display()); + println!( + "Daemon running{}; socket: {}", + if enforce { + " (enforcing)" + } else { + " (report-only)" + }, + ipc::default_socket_path().display() + ); Ok(()) } @@ -207,7 +218,11 @@ mod tests { #[test] fn plist_has_label_program_and_path() { - let body = render_plist(Path::new("/opt/edison/detectord"), Path::new("/tmp/l.log"), true); + let body = render_plist( + Path::new("/opt/edison/detectord"), + Path::new("/tmp/l.log"), + true, + ); assert!(body.contains("watch.edison.detectord")); assert!(body.contains("/opt/edison/detectord")); assert!(body.contains("daemon")); diff --git a/crates/mcp_detector_daemon/src/logging.rs b/crates/mcp_detector_daemon/src/logging.rs index ca968db..28d906f 100644 --- a/crates/mcp_detector_daemon/src/logging.rs +++ b/crates/mcp_detector_daemon/src/logging.rs @@ -13,7 +13,9 @@ fn filter() -> EnvFilter { /// Stdout only (CLI). Filter via `RUST_LOG` (default `info`). Idempotent. pub fn init_stdout() { - let _ = tracing_subscriber::fmt().with_env_filter(filter()).try_init(); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter()) + .try_init(); } /// Daily-rolling file under `log_dir` **plus** stdout (daemon mode). The diff --git a/crates/mcp_detector_daemon/src/main.rs b/crates/mcp_detector_daemon/src/main.rs index 4cdb748..05f84df 100644 --- a/crates/mcp_detector_daemon/src/main.rs +++ b/crates/mcp_detector_daemon/src/main.rs @@ -261,7 +261,17 @@ async fn cmd_enroll( // CLI enroll always applies the install and arms enforcement (the // operator's explicit intent; no onboarding gate for the dev/admin path). print_status( - &ops::enroll(&cli_user(), url, key, mcp_url, agents, secret, true, Some(true)).await?, + &ops::enroll( + &cli_user(), + url, + key, + mcp_url, + agents, + secret, + true, + Some(true), + ) + .await?, ); Ok(()) } @@ -351,7 +361,10 @@ fn print_list_deduped(servers: &[ServerView]) { } println!("Discovered across host apps ({} unique):\n", order.len()); - println!(" {:<22} {:<12} {:<7} {:<7} FINGERPRINT", "NAME", "AGENT", "TYPE", "STATE"); + println!( + " {:<22} {:<12} {:<7} {:<7} FINGERPRINT", + "NAME", "AGENT", "TYPE", "STATE" + ); for key in &order { let (kind, state, count) = &info[key]; let (name, agent, id) = key; @@ -391,7 +404,11 @@ async fn cmd_send_to_ew(name: String, agent: Option) -> anyhow::Result<( async fn cmd_verify_secret(key: String) -> anyhow::Result<()> { let r = ops::verify_secret(&cli_user(), key).await?; if r.valid { - let warn = if r.expired { " (WARNING: key has expired)" } else { "" }; + let warn = if r.expired { + " (WARNING: key has expired)" + } else { + "" + }; println!("Key is valid — adopted and installed into selected agents.{warn}"); } else { println!("Key does NOT match the registered key. Nothing installed."); @@ -461,7 +478,10 @@ fn cmd_restore(needle: Option, all: bool) -> anyhow::Result<()> { q.save_for(&user)?; if all { - println!("Restored {restored}/{} quarantined server(s).", targets.len()); + println!( + "Restored {restored}/{} quarantined server(s).", + targets.len() + ); } Ok(()) } diff --git a/crates/mcp_detector_daemon/src/ops.rs b/crates/mcp_detector_daemon/src/ops.rs index a0108a6..5512591 100644 --- a/crates/mcp_detector_daemon/src/ops.rs +++ b/crates/mcp_detector_daemon/src/ops.rs @@ -27,7 +27,10 @@ pub fn list_agents() -> Vec { } /// `(kind, state, fingerprint)` for one server — the shared classification. -pub fn classify(s: &DiscoveredServer, seen: Option<&SeenStore>) -> (String, String, Option) { +pub fn classify( + s: &DiscoveredServer, + seen: Option<&SeenStore>, +) -> (String, String, Option) { let fp = fingerprint(&s.name, &s.config); let kind = match &s.config { ServerConfig::Stdio { .. } => "stdio", @@ -38,8 +41,12 @@ pub fn classify(s: &DiscoveredServer, seen: Option<&SeenStore>) -> (String, Stri "edison" } else { match &s.config { - ServerConfig::Opaque { removable: true, .. } => "opaque", - ServerConfig::Opaque { removable: false, .. } => "report", + ServerConfig::Opaque { + removable: true, .. + } => "opaque", + ServerConfig::Opaque { + removable: false, .. + } => "report", _ => match &fp { None => "report", Some(f) => match seen { @@ -247,7 +254,9 @@ pub fn apply_install(user: &str, e: &Enrollment) { fn install_edison_entries(user: &str, e: &Enrollment, home: &std::path::Path) { let Some(mcp_base) = e.mcp_base_url.as_deref() else { if !e.selected_agents.is_empty() { - tracing::warn!("agents selected but no mcp_base_url set — skipping edison-watch install"); + tracing::warn!( + "agents selected but no mcp_base_url set — skipping edison-watch install" + ); } return; }; @@ -259,19 +268,23 @@ fn install_edison_entries(user: &str, e: &Enrollment, home: &std::path::Path) { for inst in agent.edison_installs(home) { // Claude Code goes through its own CLI (as the user); the file write // is the fallback. - let done_via_cli = inst.prefer_cli && { - let url = mcp_quarantine::edison_url(mcp_base, &e.api_key, &inst.client_id); - match crate::claude_cli::install(user, &url, secret) { - Ok(()) => { - tracing::info!(agent = agent.name(), "installed edison-watch (via claude CLI)"); - true + let done_via_cli = inst.prefer_cli + && { + let url = mcp_quarantine::edison_url(mcp_base, &e.api_key, &inst.client_id); + match crate::claude_cli::install(user, &url, secret) { + Ok(()) => { + tracing::info!( + agent = agent.name(), + "installed edison-watch (via claude CLI)" + ); + true + } + Err(err) => { + tracing::warn!(agent = agent.name(), error = %err, "claude CLI failed; writing the file directly"); + false + } } - Err(err) => { - tracing::warn!(agent = agent.name(), error = %err, "claude CLI failed; writing the file directly"); - false - } - } - }; + }; if !done_via_cli { match mcp_quarantine::install_edison(&inst, mcp_base, &e.api_key, secret) { Ok(()) => { @@ -322,7 +335,10 @@ pub fn heal_edison_install(user: &str, e: &Enrollment) -> usize { let _ = mcp_quarantine::install_edison(&inst, mcp_base, &e.api_key, secret); } } - tracing::info!(agent = agent.name(), "re-installed missing edison-watch entry (self-heal)"); + tracing::info!( + agent = agent.name(), + "re-installed missing edison-watch entry (self-heal)" + ); healed += 1; } healed diff --git a/crates/mcp_detector_daemon/src/protocol.rs b/crates/mcp_detector_daemon/src/protocol.rs index c6951d7..a49e66b 100644 --- a/crates/mcp_detector_daemon/src/protocol.rs +++ b/crates/mcp_detector_daemon/src/protocol.rs @@ -162,10 +162,8 @@ mod tests { #[test] fn enroll_armed_defaults_to_none_and_parses() { // Onboarding completion arms enforcement. - let r: Request = serde_json::from_str( - r#"{"op":"enroll","url":"u","key":"k","armed":true}"#, - ) - .unwrap(); + let r: Request = + serde_json::from_str(r#"{"op":"enroll","url":"u","key":"k","armed":true}"#).unwrap(); match r { Request::Enroll { armed, install, .. } => { assert_eq!(armed, Some(true)); @@ -174,8 +172,7 @@ mod tests { other => panic!("wrong variant: {other:?}"), } // A base enroll (onboarding not complete) can omit it → None (keep prior). - let r: Request = - serde_json::from_str(r#"{"op":"enroll","url":"u","key":"k"}"#).unwrap(); + let r: Request = serde_json::from_str(r#"{"op":"enroll","url":"u","key":"k"}"#).unwrap(); match r { Request::Enroll { armed, .. } => assert_eq!(armed, None), other => panic!("wrong variant: {other:?}"), @@ -190,7 +187,12 @@ mod tests { ) .unwrap(); match r { - Request::Disposition { name, agent, choice, rename } => { + Request::Disposition { + name, + agent, + choice, + rename, + } => { assert_eq!(name, "foo"); assert_eq!(agent.as_deref(), Some("cursor")); assert!(matches!(choice, Choice::SendToEw)); diff --git a/crates/mcp_detector_daemon/src/recovery.rs b/crates/mcp_detector_daemon/src/recovery.rs index 5cd0785..75f2af0 100644 --- a/crates/mcp_detector_daemon/src/recovery.rs +++ b/crates/mcp_detector_daemon/src/recovery.rs @@ -47,7 +47,13 @@ pub fn recover() -> (usize, usize) { } /// Recursively collect `disabled_*.json` files and `ew-disabled-*` dirs. -fn collect(dir: &Path, depth: usize, max: usize, sidecars: &mut Vec, dirs: &mut Vec) { +fn collect( + dir: &Path, + depth: usize, + max: usize, + sidecars: &mut Vec, + dirs: &mut Vec, +) { if depth > max { return; } @@ -121,7 +127,10 @@ fn restore_sidecar(store: &FileConfigStore, sidecar: &Path) -> usize { /// Restore one `ew-disabled-` plugin dir (rename back, or drop if the live /// dir already exists). fn restore_dir(store: &FileConfigStore, disabled: &Path) -> bool { - let Some(name) = disabled.file_name().map(|n| n.to_string_lossy().into_owned()) else { + let Some(name) = disabled + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + else { return false; }; let orig = name.strip_prefix("ew-disabled-").unwrap_or(&name); diff --git a/crates/mcp_detector_daemon/src/runner.rs b/crates/mcp_detector_daemon/src/runner.rs index 77a2f9b..e658399 100644 --- a/crates/mcp_detector_daemon/src/runner.rs +++ b/crates/mcp_detector_daemon/src/runner.rs @@ -56,11 +56,7 @@ pub async fn run(enforce: bool) -> anyhow::Result<()> { /// on its own cadence. Loops until the task is dropped/aborted. When `events` is /// set, publishes what it quarantines. The supervisor spawns one per enrolled /// user. -pub async fn worker( - user: String, - enforce: bool, - events: Option, -) -> anyhow::Result<()> { +pub async fn worker(user: String, enforce: bool, events: Option) -> anyhow::Result<()> { paths::ensure_user_dir(&user)?; let mut enrollment = Enrollment::load_for(&user)? .ok_or_else(|| anyhow::anyhow!("not enrolled; run `enroll` first"))?; @@ -149,8 +145,12 @@ pub async fn worker( fn start_watcher( agents: &[Arc], tx: tokio::sync::mpsc::UnboundedSender<()>, -) -> Option> -{ +) -> Option< + notify_debouncer_full::Debouncer< + notify::RecommendedWatcher, + notify_debouncer_full::RecommendedCache, + >, +> { // Files → watch their parent dir non-recursively (editors write via atomic // rename); dirs (workspace storage, plugin caches) → recursively. let mut file_dirs = HashSet::new(); @@ -191,7 +191,10 @@ fn start_watcher( /// Publish a `Quarantined` event for `user` (no-op when there's no channel). fn publish(events: Option<&EventTx>, user: &str, server: &DiscoveredServer, state: &str) { if let Some(tx) = events { - let _ = tx.send((user.to_string(), Event::Quarantined(event_view(server, state)))); + let _ = tx.send(( + user.to_string(), + Event::Quarantined(event_view(server, state)), + )); } } @@ -263,7 +266,9 @@ pub async fn refresh( let _ = seen.prune_backend(&synced); } Ok(_) => tracing::warn!("fingerprints org mismatch; skipping sync"), - Err(e) => tracing::warn!(error = %e, "fingerprints refresh failed; keeping last-known-good"), + Err(e) => { + tracing::warn!(error = %e, "fingerprints refresh failed; keeping last-known-good") + } } } @@ -289,9 +294,20 @@ fn reconcile_once( for s in &observed { let report_only = !is_edison_entry(s) && (!quarantine - || matches!(&s.config, ServerConfig::Opaque { removable: false, .. })); + || matches!( + &s.config, + ServerConfig::Opaque { + removable: false, + .. + } + )); if report_only { - let key = format!("{}\u{1f}{}\u{1f}{}", s.client, s.name, s.location.path.display()); + let key = format!( + "{}\u{1f}{}\u{1f}{}", + s.client, + s.name, + s.location.path.display() + ); if reported.insert(key) { let _ = tx.send((user.to_string(), Event::Discovered(event_view(s, "report")))); } @@ -338,8 +354,12 @@ fn reconcile_once( } let actions = plan(&observed, seen, Policy { quarantine }); - let known = count(&actions, |a| matches!(a, ReconcileAction::SilentQuarantine { .. })); - let opaque = count(&actions, |a| matches!(a, ReconcileAction::RemoveOpaque { .. })); + let known = count(&actions, |a| { + matches!(a, ReconcileAction::SilentQuarantine { .. }) + }); + let opaque = count(&actions, |a| { + matches!(a, ReconcileAction::RemoveOpaque { .. }) + }); tracing::info!( discovered = observed.len(), will_quarantine = actions.len(), @@ -373,7 +393,9 @@ fn reconcile_once( }); let _ = qstate.save_for(user); } - Err(e) => tracing::warn!(server = %server.name, error = %e, "opaque removal failed"), + Err(e) => { + tracing::warn!(server = %server.name, error = %e, "opaque removal failed") + } } continue; } @@ -399,7 +421,11 @@ fn reconcile_once( chown_new_files(&record, user); // Known servers are neutralised silently; new (unknown) ones need // the UI to prompt (send to EW / keep quarantined). - let state = if known { "quarantined" } else { "quarantine-prompt" }; + let state = if known { + "quarantined" + } else { + "quarantine-prompt" + }; publish(events, user, &server, state); tracing::info!(server = %server.name, agent = server.client, known, fingerprint = %fp, "quarantined"); let _ = seen.mark(&fp, &server.name, SeenAction::Quarantined); diff --git a/crates/mcp_detector_lib/src/clients/claude_code.rs b/crates/mcp_detector_lib/src/clients/claude_code.rs index ef7a123..89b48a1 100644 --- a/crates/mcp_detector_lib/src/clients/claude_code.rs +++ b/crates/mcp_detector_lib/src/clients/claude_code.rs @@ -206,9 +206,24 @@ impl Agent for ClaudeCode { style: HookStyle::ClaudeSettings, client_id: "claude-code".into(), events: vec![ - HookBinding::new("UserPromptSubmit", Some("*"), HookScriptKind::Registration, true), - HookBinding::new("PreToolUse", Some("mcp__*"), HookScriptKind::SessionHook, false), - HookBinding::new("SessionStart", Some("*"), HookScriptKind::SessionStart, false), + HookBinding::new( + "UserPromptSubmit", + Some("*"), + HookScriptKind::Registration, + true, + ), + HookBinding::new( + "PreToolUse", + Some("mcp__*"), + HookScriptKind::SessionHook, + false, + ), + HookBinding::new( + "SessionStart", + Some("*"), + HookScriptKind::SessionStart, + false, + ), HookBinding::new("SessionEnd", Some("*"), HookScriptKind::SessionEnd, false), ], }) @@ -222,9 +237,7 @@ fn managed_config_path() -> Option { "/Library/Application Support/ClaudeCode/managed-mcp.json", )) } else if cfg!(target_os = "windows") { - Some(PathBuf::from( - r"C:\ProgramData\ClaudeCode\managed-mcp.json", - )) + Some(PathBuf::from(r"C:\ProgramData\ClaudeCode\managed-mcp.json")) } else { Some(PathBuf::from("/etc/claude-code/managed-mcp.json")) } @@ -552,7 +565,11 @@ mod tests { assert_eq!(by["g"].location.key_path, vec!["mcpServers".to_string()]); assert_eq!( by["p"].location.key_path, - vec!["profiles".to_string(), "work".to_string(), "mcpServers".to_string()] + vec![ + "profiles".to_string(), + "work".to_string(), + "mcpServers".to_string() + ] ); } diff --git a/crates/mcp_detector_lib/src/clients/claude_cowork.rs b/crates/mcp_detector_lib/src/clients/claude_cowork.rs index 773ff4e..481fcf3 100644 --- a/crates/mcp_detector_lib/src/clients/claude_cowork.rs +++ b/crates/mcp_detector_lib/src/clients/claude_cowork.rs @@ -60,9 +60,13 @@ impl Agent for ClaudeCowork { return Ok(Vec::new()); } Ok(match self.config.as_ref().filter(|p| p.exists()) { - Some(p) => { - parse_json_servers_map(p, "mcpServers", CLIENT_NAME, Scope::Global, SourceKind::Json) - } + Some(p) => parse_json_servers_map( + p, + "mcpServers", + CLIENT_NAME, + Scope::Global, + SourceKind::Json, + ), None => Vec::new(), }) } diff --git a/crates/mcp_detector_lib/src/clients/claude_desktop.rs b/crates/mcp_detector_lib/src/clients/claude_desktop.rs index 1b81e9a..f9fde3b 100644 --- a/crates/mcp_detector_lib/src/clients/claude_desktop.rs +++ b/crates/mcp_detector_lib/src/clients/claude_desktop.rs @@ -47,9 +47,13 @@ impl Agent for ClaudeDesktop { fn discover(&self) -> Result> { Ok(match self.config.as_ref().filter(|p| p.exists()) { - Some(p) => { - parse_json_servers_map(p, "mcpServers", CLIENT_NAME, Scope::Global, SourceKind::Json) - } + Some(p) => parse_json_servers_map( + p, + "mcpServers", + CLIENT_NAME, + Scope::Global, + SourceKind::Json, + ), None => Vec::new(), }) } diff --git a/crates/mcp_detector_lib/src/clients/codex.rs b/crates/mcp_detector_lib/src/clients/codex.rs index 2d1d729..cadfc97 100644 --- a/crates/mcp_detector_lib/src/clients/codex.rs +++ b/crates/mcp_detector_lib/src/clients/codex.rs @@ -105,7 +105,14 @@ fn parse_codex_toml(path: &std::path::Path) -> Vec { return Vec::new(); } }; - servers_from_map(&json, SERVERS_KEY, CLIENT_NAME, Scope::Global, SourceKind::Toml, path) + servers_from_map( + &json, + SERVERS_KEY, + CLIENT_NAME, + Scope::Global, + SourceKind::Toml, + path, + ) } #[cfg(test)] diff --git a/crates/mcp_detector_lib/src/clients/cursor.rs b/crates/mcp_detector_lib/src/clients/cursor.rs index d0c0c51..0057163 100644 --- a/crates/mcp_detector_lib/src/clients/cursor.rs +++ b/crates/mcp_detector_lib/src/clients/cursor.rs @@ -53,7 +53,9 @@ impl Cursor { let plugins_cache = home.as_ref().map(|h| h.join(".cursor/plugins/cache")); let projects_dir = home.as_ref().map(|h| h.join(".cursor/projects")); let user_dir = cursor_user_dir(); - let state_db = user_dir.as_ref().map(|d| d.join("globalStorage/state.vscdb")); + let state_db = user_dir + .as_ref() + .map(|d| d.join("globalStorage/state.vscdb")); let workspace_storage = user_dir.as_ref().map(|d| d.join("workspaceStorage")); let project_configs = workspace_storage @@ -184,7 +186,12 @@ impl Agent for Cursor { client_id: "cursor".into(), events: vec![ HookBinding::new("sessionStart", None, HookScriptKind::Registration, true), - HookBinding::new("beforeMCPExecution", None, HookScriptKind::SessionHook, false), + HookBinding::new( + "beforeMCPExecution", + None, + HookScriptKind::SessionHook, + false, + ), HookBinding::new("sessionEnd", None, HookScriptKind::SessionEnd, false), ], }) @@ -210,7 +217,14 @@ fn parse_jsonc_servers(path: &Path, scope: Scope) -> Vec { return Vec::new(); } }; - servers_from_map(&root, "mcpServers", CLIENT_NAME, scope, SourceKind::Jsonc, path) + servers_from_map( + &root, + "mcpServers", + CLIENT_NAME, + scope, + SourceKind::Jsonc, + path, + ) } /// Read Cursor's marketplace OAuth MCP servers from `state.vscdb`. @@ -286,7 +300,11 @@ fn scan_server_metadata(projects_dir: &Path) -> Vec { let mut out = Vec::new(); for project in subdirs(projects_dir) { for plugin in subdirs(&project.join("mcps")) { - let dir_name = plugin.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let dir_name = plugin + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); if !dir_name.starts_with("plugin-") { continue; } @@ -356,10 +374,7 @@ fn enumerate_projects(workspace_storage: &Path) -> Vec { .flatten() .filter_map(|e| { let wj = e.path().join("workspace.json"); - let folder = read_strict_json(&wj)? - .get("folder")? - .as_str()? - .to_string(); + let folder = read_strict_json(&wj)?.get("folder")?.as_str()?.to_string(); file_uri_to_path(&folder) }) .collect() @@ -380,8 +395,11 @@ mod tests { fn make_state_db(path: &Path, rows: &[(&str, &str)]) { let conn = rusqlite::Connection::open(path).unwrap(); - conn.execute("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)", []) - .unwrap(); + conn.execute( + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)", + [], + ) + .unwrap(); for (k, v) in rows { conn.execute( "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", diff --git a/crates/mcp_detector_lib/src/clients/transport.rs b/crates/mcp_detector_lib/src/clients/transport.rs index 87252e3..d1cea2c 100644 --- a/crates/mcp_detector_lib/src/clients/transport.rs +++ b/crates/mcp_detector_lib/src/clients/transport.rs @@ -44,7 +44,11 @@ pub(crate) fn server_config_from_value(v: &serde_json::Value) -> Option()) .with_extensions_dir(Some(ext_dir.clone())); - assert!(v.watch_targets().files.contains(&ext_dir.join("extensions.json"))); + assert!( + v.watch_targets() + .files + .contains(&ext_dir.join("extensions.json")) + ); } #[test] diff --git a/crates/mcp_detector_lib/src/clients/windsurf.rs b/crates/mcp_detector_lib/src/clients/windsurf.rs index d89d5a9..c62e783 100644 --- a/crates/mcp_detector_lib/src/clients/windsurf.rs +++ b/crates/mcp_detector_lib/src/clients/windsurf.rs @@ -47,9 +47,13 @@ impl Agent for Windsurf { fn discover(&self) -> Result> { Ok(match self.config.as_ref().filter(|p| p.exists()) { - Some(p) => { - parse_json_servers_map(p, "mcpServers", CLIENT_NAME, Scope::Global, SourceKind::Json) - } + Some(p) => parse_json_servers_map( + p, + "mcpServers", + CLIENT_NAME, + Scope::Global, + SourceKind::Json, + ), None => Vec::new(), }) } diff --git a/crates/mcp_detector_lib/src/fingerprint.rs b/crates/mcp_detector_lib/src/fingerprint.rs index 207fe6b..ebf32a9 100644 --- a/crates/mcp_detector_lib/src/fingerprint.rs +++ b/crates/mcp_detector_lib/src/fingerprint.rs @@ -94,8 +94,8 @@ fn normalize_placeholders(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use std::collections::BTreeMap; use crate::types::HttpKind; + use std::collections::BTreeMap; fn stdio(command: &str, args: &[&str]) -> ServerConfig { ServerConfig::Stdio { @@ -149,7 +149,10 @@ mod tests { #[test] fn golden_stdio_no_args() { - assert_eq!(fingerprint("bare", &stdio("node", &[])).unwrap(), "7de4c89d590bc157"); + assert_eq!( + fingerprint("bare", &stdio("node", &[])).unwrap(), + "7de4c89d590bc157" + ); } #[test] diff --git a/crates/mcp_detector_lib/src/secret_detection.rs b/crates/mcp_detector_lib/src/secret_detection.rs index 5a56b2b..79329cd 100644 --- a/crates/mcp_detector_lib/src/secret_detection.rs +++ b/crates/mcp_detector_lib/src/secret_detection.rs @@ -123,7 +123,8 @@ fn templatize_args(args: &[String]) -> Vec { // `--flag=value`: scan both the flag name and the value. let (flag, rest) = arg.split_at(eq); let value = &rest[1..]; - if !is_non_secret_flag(flag) && (is_sensitive_key_name(flag) || is_secret_value(value)) { + if !is_non_secret_flag(flag) && (is_sensitive_key_name(flag) || is_secret_value(value)) + { out.push(format!("{flag}={PLACEHOLDER}")); } else { out.push(arg.clone()); @@ -169,7 +170,10 @@ fn templatize_url(url: &str) -> String { if let Some(scheme_end) = out.find("://") { let after = scheme_end + 3; if let Some(at_rel) = out[after..].find('@') { - let authority_end = out[after..].find('/').map(|s| after + s).unwrap_or(out.len()); + let authority_end = out[after..] + .find('/') + .map(|s| after + s) + .unwrap_or(out.len()); let at = after + at_rel; if at < authority_end { out.replace_range(after..at, PLACEHOLDER); @@ -207,7 +211,9 @@ fn templatize_auth_value(value: &str) -> Option { let ws = rest.len() - trimmed.len(); if ws > 0 && !trimmed.is_empty() - && (has_known_secret_prefix(trimmed) || looks_like_api_key(trimmed) || trimmed.len() >= 8) + && (has_known_secret_prefix(trimmed) + || looks_like_api_key(trimmed) + || trimmed.len() >= 8) { return Some(format!("{}{PLACEHOLDER}", &value[..after + ws])); } @@ -274,9 +280,13 @@ fn is_npm_scoped(v: &str) -> bool { return false; }; !scope.is_empty() - && scope.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + && scope + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') && !name.is_empty() - && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-') + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-') } /// `^[a-z][\w.-]*$` — a bare lowercase package/identifier. @@ -314,7 +324,10 @@ mod tests { #[test] fn templatizes_known_prefix_arg() { assert_eq!( - args_of(&stdio("server", &["--token", "sk-abc123def456ghi789jkl012mno345"])), + args_of(&stdio( + "server", + &["--token", "sk-abc123def456ghi789jkl012mno345"] + )), vec!["--token", "{SECRET}"] ); } @@ -322,15 +335,24 @@ mod tests { #[test] fn templatizes_flag_equals_value() { assert_eq!( - args_of(&stdio("server", &["--token=ghp_0123456789abcdefghijklmnopqrstuvwxyz"])), + args_of(&stdio( + "server", + &["--token=ghp_0123456789abcdefghijklmnopqrstuvwxyz"] + )), vec!["--token={SECRET}"] ); } #[test] fn stripe_and_slack_prefixes() { - assert_eq!(args_of(&stdio("s", &["sk_live_0123456789abcdefXYZ"])), vec!["{SECRET}"]); - assert_eq!(args_of(&stdio("s", &["xapp-1-A-2-longtokenvalue"])), vec!["{SECRET}"]); + assert_eq!( + args_of(&stdio("s", &["sk_live_0123456789abcdefXYZ"])), + vec!["{SECRET}"] + ); + assert_eq!( + args_of(&stdio("s", &["xapp-1-A-2-longtokenvalue"])), + vec!["{SECRET}"] + ); } #[test] @@ -344,23 +366,49 @@ mod tests { #[test] fn sensitive_flag_name_templatizes_short_value() { // Value isn't high-entropy, but the flag name is sensitive. - assert_eq!(args_of(&stdio("s", &["--api-key", "short"])), vec!["--api-key", "{SECRET}"]); - assert_eq!(args_of(&stdio("s", &["--password=hunter2"])), vec!["--password={SECRET}"]); + assert_eq!( + args_of(&stdio("s", &["--api-key", "short"])), + vec!["--api-key", "{SECRET}"] + ); + assert_eq!( + args_of(&stdio("s", &["--password=hunter2"])), + vec!["--password={SECRET}"] + ); } #[test] fn non_secret_flags_and_values_left_alone() { // A long path is high-entropy-ish but must not be templatized. assert_eq!( - args_of(&stdio("npx", &["-y", "ctx7-mcp", "--port", "8080", "--config", "/very/long/path/to/config/file.json"])), - vec!["-y", "ctx7-mcp", "--port", "8080", "--config", "/very/long/path/to/config/file.json"] + args_of(&stdio( + "npx", + &[ + "-y", + "ctx7-mcp", + "--port", + "8080", + "--config", + "/very/long/path/to/config/file.json" + ] + )), + vec![ + "-y", + "ctx7-mcp", + "--port", + "8080", + "--config", + "/very/long/path/to/config/file.json" + ] ); } #[test] fn npm_scoped_package_not_secret() { assert_eq!( - args_of(&stdio("npx", &["-y", "@modelcontextprotocol/server-everything-and-more"])), + args_of(&stdio( + "npx", + &["-y", "@modelcontextprotocol/server-everything-and-more"] + )), vec!["-y", "@modelcontextprotocol/server-everything-and-more"] ); } @@ -368,7 +416,10 @@ mod tests { #[test] fn bearer_token_extraction() { assert_eq!( - args_of(&stdio("s", &["Authorization: Bearer sk-abc123def456ghi789jkl0"])), + args_of(&stdio( + "s", + &["Authorization: Bearer sk-abc123def456ghi789jkl0"] + )), vec!["Authorization: Bearer {SECRET}"] ); } @@ -376,7 +427,9 @@ mod tests { #[test] fn templatizes_url_query_and_userinfo() { let c = ServerConfig::Http { - url: "https://user:supersecretpasswordvalue1234@h.example/mcp?key=sk-abc123def456ghi789".into(), + url: + "https://user:supersecretpasswordvalue1234@h.example/mcp?key=sk-abc123def456ghi789" + .into(), headers: BTreeMap::new(), kind: crate::types::HttpKind::Http, }; diff --git a/crates/mcp_detector_lib/tests/spawn_smoke.rs b/crates/mcp_detector_lib/tests/spawn_smoke.rs index 2487243..f0eaa0c 100644 --- a/crates/mcp_detector_lib/tests/spawn_smoke.rs +++ b/crates/mcp_detector_lib/tests/spawn_smoke.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use mcp_detector_lib::clients::VsCode; -use mcp_detector_lib::{ChangeEvent, Agent, Watcher}; +use mcp_detector_lib::{Agent, ChangeEvent, Watcher}; use tempfile::tempdir; /// Wait up to `timeout` for an event matching `pred` to arrive on `events`. diff --git a/crates/mcp_quarantine/src/configstore.rs b/crates/mcp_quarantine/src/configstore.rs index 5a8c925..7143dfe 100644 --- a/crates/mcp_quarantine/src/configstore.rs +++ b/crates/mcp_quarantine/src/configstore.rs @@ -233,8 +233,8 @@ fn quarantine_sqlite(loc: &ConfigLocation) -> Result { return Err(Error::UnsupportedKind(loc.kind)); }; - let raw = read_row(&loc.path, item_key)? - .ok_or_else(|| Error::NotFound(loc.server_key.clone()))?; + let raw = + read_row(&loc.path, item_key)?.ok_or_else(|| Error::NotFound(loc.server_key.clone()))?; let mut blob: Value = serde_json::from_str(&raw).map_err(json_err(&loc.path))?; // Capture + remove the server's raw value (restore re-inserts it exactly). @@ -558,7 +558,11 @@ fn ensure_parent(path: &Path) -> Result<()> { fn install_json(inst: &EdisonInstall, entry: Value) -> Result<()> { ensure_parent(&inst.path)?; let existed = inst.path.exists(); - let raw = if existed { read(&inst.path)? } else { String::new() }; + let raw = if existed { + read(&inst.path)? + } else { + String::new() + }; let mut root = if raw.trim().is_empty() { Value::Object(Map::new()) } else { @@ -591,7 +595,11 @@ fn uninstall_json(inst: &EdisonInstall) -> Result<()> { fn install_toml(inst: &EdisonInstall, url: &str, secret: Option<&str>) -> Result<()> { ensure_parent(&inst.path)?; let existed = inst.path.exists(); - let raw = if existed { read(&inst.path)? } else { String::new() }; + let raw = if existed { + read(&inst.path)? + } else { + String::new() + }; let mut root: toml::Value = if raw.trim().is_empty() { toml::Value::Table(toml::Table::new()) } else { @@ -607,7 +615,10 @@ fn install_toml(inst: &EdisonInstall, url: &str, secret: Option<&str>) -> Result entry.insert("url".into(), toml::Value::String(url.to_string())); if let Some(s) = secret { let mut headers = toml::Table::new(); - headers.insert(EDISON_SECRET_HEADER.into(), toml::Value::String(s.to_string())); + headers.insert( + EDISON_SECRET_HEADER.into(), + toml::Value::String(s.to_string()), + ); entry.insert("http_headers".into(), toml::Value::Table(headers)); } @@ -687,10 +698,7 @@ fn finalize_sidecar(rec: &QuarantineRecord, disabled: &Value) -> Result<()> { fn build_disabled_entry(cfg: &ServerConfig, original: &Path, key_path: &[String]) -> Result { let mut entry = config_to_value(cfg).ok_or(Error::NotActionable)?; if let Value::Object(m) = &mut entry { - m.insert( - META_ORIGINAL_FILE.into(), - json!(original.to_string_lossy()), - ); + m.insert(META_ORIGINAL_FILE.into(), json!(original.to_string_lossy())); m.insert(META_KEY_PATH.into(), json!(key_path)); } Ok(entry) @@ -753,8 +761,8 @@ mod tests { } fn servers_in(path: &Path, key_path: &[&str]) -> Vec { - let mut root: Value = serde_json_lenient::from_str(&std::fs::read_to_string(path).unwrap()) - .unwrap(); + let mut root: Value = + serde_json_lenient::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); let kp: Vec = key_path.iter().map(|s| s.to_string()).collect(); nav_mut(&mut root, &kp) .map(|m| m.keys().cloned().collect()) @@ -780,7 +788,11 @@ mod tests { assert_eq!(servers_in(&cfg, &["servers"]), vec!["keep".to_string()]); // Backup is the verbatim original (still has evil). assert!(rec.backup_path.exists()); - assert!(std::fs::read_to_string(&rec.backup_path).unwrap().contains("evil")); + assert!( + std::fs::read_to_string(&rec.backup_path) + .unwrap() + .contains("evil") + ); // Sidecar holds evil + restore metadata. let disabled: Value = serde_json::from_str(&std::fs::read_to_string(&rec.disabled_path).unwrap()).unwrap(); @@ -789,7 +801,12 @@ mod tests { assert_eq!(entry[META_KEY_PATH], json!(["servers"])); } - fn edison(path: &std::path::Path, key: &[&str], style: EdisonStyle, client: &str) -> EdisonInstall { + fn edison( + path: &std::path::Path, + key: &[&str], + style: EdisonStyle, + client: &str, + ) -> EdisonInstall { EdisonInstall { path: path.to_path_buf(), key_path: key.iter().map(|s| s.to_string()).collect(), @@ -856,7 +873,13 @@ mod tests { r#"{"mcpServers":{"zebra":{"command":"z"},"apple":{"command":"a"},"mango":{"command":"m"}}}"#, ) .unwrap(); - install_edison(&edison(&cfg, &["mcpServers"], EdisonStyle::Http, "cursor"), "http://h", "K", None).unwrap(); + install_edison( + &edison(&cfg, &["mcpServers"], EdisonStyle::Http, "cursor"), + "http://h", + "K", + None, + ) + .unwrap(); let text = std::fs::read_to_string(&cfg).unwrap(); let (z, a, m, e) = ( text.find("zebra").unwrap(), @@ -864,7 +887,10 @@ mod tests { text.find("mango").unwrap(), text.find("edison-watch").unwrap(), ); - assert!(z < a && a < m && m < e, "original order kept, edison appended:\n{text}"); + assert!( + z < a && a < m && m < e, + "original order kept, edison appended:\n{text}" + ); } #[test] @@ -884,11 +910,19 @@ mod tests { fn install_edison_stdio_shim() { let dir = tempdir().unwrap(); let cfg = dir.path().join("claude_desktop_config.json"); - let inst = edison(&cfg, &["mcpServers"], EdisonStyle::StdioShim, "claude-desktop"); + let inst = edison( + &cfg, + &["mcpServers"], + EdisonStyle::StdioShim, + "claude-desktop", + ); install_edison(&inst, "http://localhost:3000", "K", None).unwrap(); let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); assert_eq!(v["mcpServers"]["edison-watch"]["command"], "npx"); - assert_eq!(v["mcpServers"]["edison-watch"]["args"][2], "http://localhost:3000/mcp/K/?client=claude-desktop"); + assert_eq!( + v["mcpServers"]["edison-watch"]["args"][2], + "http://localhost:3000/mcp/K/?client=claude-desktop" + ); } #[test] @@ -947,7 +981,11 @@ mod tests { fn restore_round_trips() { let dir = tempdir().unwrap(); let cfg = dir.path().join("mcp.json"); - std::fs::write(&cfg, r#"{"servers":{"evil":{"command":"x","args":["--bad"]}}}"#).unwrap(); + std::fs::write( + &cfg, + r#"{"servers":{"evil":{"command":"x","args":["--bad"]}}}"#, + ) + .unwrap(); let store = FileConfigStore; let rec = store @@ -1049,8 +1087,11 @@ mod tests { fn make_state_db(db: &std::path::Path, key: &str, value: &Value) { let conn = rusqlite::Connection::open(db).unwrap(); - conn.execute("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)", []) - .unwrap(); + conn.execute( + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)", + [], + ) + .unwrap(); conn.execute( "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)", rusqlite::params![key, value.to_string()], @@ -1061,7 +1102,9 @@ mod tests { fn read_row(db: &std::path::Path, key: &str) -> Value { let conn = rusqlite::Connection::open(db).unwrap(); let raw: String = conn - .query_row("SELECT value FROM ItemTable WHERE key = ?1", [key], |r| r.get(0)) + .query_row("SELECT value FROM ItemTable WHERE key = ?1", [key], |r| { + r.get(0) + }) .unwrap(); serde_json::from_str(&raw).unwrap() } @@ -1165,7 +1208,13 @@ mod tests { let rec = store.quarantine(&loc, &stdio("unused", &[])).unwrap(); assert!(!plugin.exists()); assert!(rec.disabled_path.exists()); - assert!(rec.disabled_path.file_name().unwrap().to_string_lossy().starts_with("ew-disabled-")); + assert!( + rec.disabled_path + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("ew-disabled-") + ); store.restore(&rec).unwrap(); assert!(plugin.exists()); diff --git a/crates/mcp_quarantine/src/hooks.rs b/crates/mcp_quarantine/src/hooks.rs index 86e7b8e..b3d2c87 100644 --- a/crates/mcp_quarantine/src/hooks.rs +++ b/crates/mcp_quarantine/src/hooks.rs @@ -237,7 +237,9 @@ fn mkdirs(path: &Path) -> Result<()> { } fn write_script(path: &Path, content: &str) -> Result<()> { - let changed = std::fs::read_to_string(path).map(|c| c != content).unwrap_or(true); + let changed = std::fs::read_to_string(path) + .map(|c| c != content) + .unwrap_or(true); if changed { write(path, content)?; } @@ -362,7 +364,10 @@ fn inject_claude(install: &HookInstall, scripts: &HookScripts) -> Result { if let Some(m) = &b.matcher { group.insert("matcher".into(), json!(m)); } - group.insert("hooks".into(), json!([{ "type": "command", "command": cmd }])); + group.insert( + "hooks".into(), + json!([{ "type": "command", "command": cmd }]), + ); arr.push(Value::Object(group)); changed = true; } @@ -377,7 +382,9 @@ fn inject_claude(install: &HookInstall, scripts: &HookScripts) -> Result { fn inject_cursor(install: &HookInstall, scripts: &HookScripts) -> Result { ensure_parent(&install.path)?; let (mut root, existed, raw) = read_json_or_empty(&install.path)?; - let obj = root.as_object_mut().ok_or_else(|| Error::NotAnObject(vec![]))?; + let obj = root + .as_object_mut() + .ok_or_else(|| Error::NotAnObject(vec![]))?; obj.entry("version").or_insert_with(|| json!(1)); let hooks = obj .entry("hooks") @@ -421,7 +428,11 @@ fn inject_copilot(install: &HookInstall, scripts: &HookScripts) -> Result let desired = json!({ "hooks": hooks }); let existed = install.path.exists(); - let raw = if existed { read(&install.path)? } else { String::new() }; + let raw = if existed { + read(&install.path)? + } else { + String::new() + }; let current = if raw.trim().is_empty() { None } else { @@ -439,7 +450,11 @@ fn inject_copilot(install: &HookInstall, scripts: &HookScripts) -> Result fn inject_codex(install: &HookInstall, scripts: &HookScripts) -> Result { ensure_parent(&install.path)?; let existed = install.path.exists(); - let text = if existed { read(&install.path)? } else { String::new() }; + let text = if existed { + read(&install.path)? + } else { + String::new() + }; let mut appended = String::new(); for b in &install.events { @@ -447,10 +462,7 @@ fn inject_codex(install: &HookInstall, scripts: &HookScripts) -> Result { continue; } let cmd = command_for(b, scripts, install); - appended.push_str(&format!( - "\n[[hooks.{}]]\ncommand = \"{cmd}\"\n", - b.event - )); + appended.push_str(&format!("\n[[hooks.{}]]\ncommand = \"{cmd}\"\n", b.event)); } if appended.is_empty() { return Ok(false); @@ -486,7 +498,11 @@ fn remove_claude(install: &HookInstall) -> Result { } let raw = read(&install.path)?; let mut root = parse(&raw, &install.path)?; - let Some(hooks) = root.as_object_mut().and_then(|o| o.get_mut("hooks")).and_then(Value::as_object_mut) else { + let Some(hooks) = root + .as_object_mut() + .and_then(|o| o.get_mut("hooks")) + .and_then(Value::as_object_mut) + else { return Ok(false); }; @@ -522,7 +538,11 @@ fn remove_cursor(install: &HookInstall) -> Result { } let raw = read(&install.path)?; let mut root = parse(&raw, &install.path)?; - let Some(hooks) = root.as_object_mut().and_then(|o| o.get_mut("hooks")).and_then(Value::as_object_mut) else { + let Some(hooks) = root + .as_object_mut() + .and_then(|o| o.get_mut("hooks")) + .and_then(Value::as_object_mut) + else { return Ok(false); }; @@ -602,7 +622,9 @@ const VSCODE_TASK_LABEL: &str = "Edison Watch Registration"; pub fn inject_workspace_task(tasks_json: &Path, registration_script: &Path) -> Result { ensure_parent(tasks_json)?; let (mut root, existed, raw) = read_json_or_empty(tasks_json)?; - let obj = root.as_object_mut().ok_or_else(|| Error::NotAnObject(vec![]))?; + let obj = root + .as_object_mut() + .ok_or_else(|| Error::NotAnObject(vec![]))?; obj.entry("version").or_insert_with(|| json!("2.0.0")); let tasks = obj .entry("tasks") @@ -679,7 +701,10 @@ mod tests { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&s.registration).unwrap().permissions().mode(); + let mode = std::fs::metadata(&s.registration) + .unwrap() + .permissions() + .mode(); assert_eq!(mode & 0o111, 0o111, "scripts must be executable"); } } @@ -694,12 +719,25 @@ mod tests { style: HookStyle::ClaudeSettings, client_id: "claude-code".into(), events: vec![ - HookBinding::new("UserPromptSubmit", Some("*"), HookScriptKind::Registration, true), - HookBinding::new("PreToolUse", Some("mcp__*"), HookScriptKind::SessionHook, false), + HookBinding::new( + "UserPromptSubmit", + Some("*"), + HookScriptKind::Registration, + true, + ), + HookBinding::new( + "PreToolUse", + Some("mcp__*"), + HookScriptKind::SessionHook, + false, + ), ], }; assert!(inject_hooks(&install, &sc).unwrap()); - assert!(!inject_hooks(&install, &sc).unwrap(), "second inject is a no-op"); + assert!( + !inject_hooks(&install, &sc).unwrap(), + "second inject is a no-op" + ); let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); assert_eq!(v["hooks"]["UserPromptSubmit"][0]["matcher"], "*"); @@ -760,7 +798,12 @@ mod tests { client_id: "cursor".into(), events: vec![ HookBinding::new("sessionStart", None, HookScriptKind::Registration, true), - HookBinding::new("beforeMCPExecution", None, HookScriptKind::SessionHook, false), + HookBinding::new( + "beforeMCPExecution", + None, + HookScriptKind::SessionHook, + false, + ), ], }; assert!(inject_hooks(&install, &sc).unwrap()); @@ -768,10 +811,12 @@ mod tests { let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); assert_eq!(v["version"], 1); assert_eq!(v["hooks"]["sessionStart"][0]["type"], "command"); - assert!(v["hooks"]["beforeMCPExecution"][0]["command"] - .as_str() - .unwrap() - .contains("edison-session-hook.py")); + assert!( + v["hooks"]["beforeMCPExecution"][0]["command"] + .as_str() + .unwrap() + .contains("edison-session-hook.py") + ); assert!(remove_hooks(&install).unwrap()); let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); assert!(v["hooks"].as_object().unwrap().is_empty()); @@ -795,7 +840,10 @@ mod tests { assert!(inject_hooks(&install, &sc).unwrap()); assert!(!inject_hooks(&install, &sc).unwrap()); let t: toml::Value = toml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); - assert!(t["mcp_servers"].get("foo").is_some(), "existing config kept"); + assert!( + t["mcp_servers"].get("foo").is_some(), + "existing config kept" + ); let cmd = t["hooks"]["SessionStart"][0]["command"].as_str().unwrap(); assert!(cmd.contains("edison-hook.sh") && cmd.ends_with("codex")); assert!(remove_hooks(&install).unwrap()); @@ -817,7 +865,10 @@ mod tests { let script = d.path().join("edison-hook.sh"); assert!(inject_workspace_task(&tasks, &script).unwrap()); - assert!(!inject_workspace_task(&tasks, &script).unwrap(), "idempotent"); + assert!( + !inject_workspace_task(&tasks, &script).unwrap(), + "idempotent" + ); let v: Value = serde_json::from_str(&std::fs::read_to_string(&tasks).unwrap()).unwrap(); let arr = v["tasks"].as_array().unwrap(); assert_eq!(arr.len(), 2, "user task kept, ours appended"); @@ -847,12 +898,17 @@ mod tests { ], }; assert!(inject_hooks(&install, &sc).unwrap()); - assert!(!inject_hooks(&install, &sc).unwrap(), "same content → no rewrite"); + assert!( + !inject_hooks(&install, &sc).unwrap(), + "same content → no rewrite" + ); let v: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); - assert!(v["hooks"]["UserPromptSubmit"][0]["command"] - .as_str() - .unwrap() - .ends_with("vscode")); + assert!( + v["hooks"]["UserPromptSubmit"][0]["command"] + .as_str() + .unwrap() + .ends_with("vscode") + ); assert!(remove_hooks(&install).unwrap()); assert!(!cfg.exists(), "Edison-owned file is deleted on removal"); } diff --git a/crates/mcp_quarantine/src/lib.rs b/crates/mcp_quarantine/src/lib.rs index b2b5f22..9e8b725 100644 --- a/crates/mcp_quarantine/src/lib.rs +++ b/crates/mcp_quarantine/src/lib.rs @@ -16,10 +16,10 @@ mod statedb; pub use configstore::{ ConfigStore, FileConfigStore, QuarantineRecord, edison_url, install_edison, uninstall_edison, }; +pub use error::{Error, Result}; pub use hooks::{ HookScripts, ensure_scripts, inject_hooks, inject_workspace_task, remove_hooks, remove_workspace_task, }; -pub use error::{Error, Result}; pub use reconcile::{Action as ReconcileAction, KnownOracle, Policy, is_edison_entry, plan}; pub use seen_store::{Action, SeenStore}; diff --git a/crates/mcp_quarantine/src/reconcile.rs b/crates/mcp_quarantine/src/reconcile.rs index 48a9455..f3dd709 100644 --- a/crates/mcp_quarantine/src/reconcile.rs +++ b/crates/mcp_quarantine/src/reconcile.rs @@ -65,7 +65,11 @@ impl Action { /// `policy.quarantine` is false the pass is inert. Skipped: our own injected /// entry, and *untouchable* opaque servers (`removable == false`). A /// **removable** opaque server is removed with no disposition ([`Action::RemoveOpaque`]). -pub fn plan(observed: &[DiscoveredServer], oracle: &dyn KnownOracle, policy: Policy) -> Vec { +pub fn plan( + observed: &[DiscoveredServer], + oracle: &dyn KnownOracle, + policy: Policy, +) -> Vec { if !policy.quarantine { return Vec::new(); } diff --git a/crates/mcp_quarantine/src/seen_store.rs b/crates/mcp_quarantine/src/seen_store.rs index 6faf941..ed9e72f 100644 --- a/crates/mcp_quarantine/src/seen_store.rs +++ b/crates/mcp_quarantine/src/seen_store.rs @@ -104,7 +104,13 @@ impl SeenStore { self.upsert(fingerprint, name, action, true) } - fn upsert(&mut self, fingerprint: &str, name: &str, action: Action, from_backend: bool) -> Result<()> { + fn upsert( + &mut self, + fingerprint: &str, + name: &str, + action: Action, + from_backend: bool, + ) -> Result<()> { self.data.servers.insert( self.key(fingerprint), SeenServer { From 406c577d09d8183ab9cfd266ba7587781be2c29a Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 11:18:44 +0300 Subject: [PATCH 4/5] fix rustdoc --- crates/mcp_detector_daemon/src/ipc.rs | 2 +- crates/mcp_detector_lib/src/clients/claude_code.rs | 2 +- crates/mcp_detector_lib/src/clients/claude_cowork.rs | 2 +- crates/mcp_detector_lib/src/clients/claude_desktop.rs | 2 +- crates/mcp_detector_lib/src/clients/codex.rs | 2 +- crates/mcp_detector_lib/src/clients/cursor.rs | 2 +- crates/mcp_detector_lib/src/clients/jetbrains.rs | 2 +- crates/mcp_detector_lib/src/clients/vscode.rs | 2 +- crates/mcp_detector_lib/src/clients/windsurf.rs | 2 +- crates/mcp_detector_lib/src/clients/zed.rs | 2 +- crates/mcp_detector_lib/src/secret_detection.rs | 4 ++-- crates/mcp_detector_lib/src/types.rs | 6 +++--- crates/mcp_detector_lib/src/watcher.rs | 2 +- 13 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/mcp_detector_daemon/src/ipc.rs b/crates/mcp_detector_daemon/src/ipc.rs index 6e1721d..5ecfaf9 100644 --- a/crates/mcp_detector_daemon/src/ipc.rs +++ b/crates/mcp_detector_daemon/src/ipc.rs @@ -4,7 +4,7 @@ //! (`SO_PEERCRED` / `getpeereid`, via tokio's `peer_cred`), not from anything //! the client sends — so every request is scoped to the kernel-reported uid. //! Requests/replies are newline-delimited JSON; the daemon also pushes -//! [`Event`]s to a connection when they match its peer user. +//! [`Event`](crate::protocol::Event)s to a connection when they match its peer user. use std::path::{Path, PathBuf}; diff --git a/crates/mcp_detector_lib/src/clients/claude_code.rs b/crates/mcp_detector_lib/src/clients/claude_code.rs index 89b48a1..ea61a3c 100644 --- a/crates/mcp_detector_lib/src/clients/claude_code.rs +++ b/crates/mcp_detector_lib/src/clients/claude_code.rs @@ -1,4 +1,4 @@ -//! Claude Code [`Agent`](crate::Agent) implementation. Watches `~/.claude.json` (which +//! Claude Code [`Agent`] implementation. Watches `~/.claude.json` (which //! contains both the global `mcpServers` map and a `projects` sub-map with //! per-project servers) plus a `.mcp.json` inside every project listed in //! that file. diff --git a/crates/mcp_detector_lib/src/clients/claude_cowork.rs b/crates/mcp_detector_lib/src/clients/claude_cowork.rs index 481fcf3..7462c66 100644 --- a/crates/mcp_detector_lib/src/clients/claude_cowork.rs +++ b/crates/mcp_detector_lib/src/clients/claude_cowork.rs @@ -1,4 +1,4 @@ -//! Claude Cowork [`Agent`](crate::Agent). Uses the same on-disk config as Claude +//! Claude Cowork [`Agent`]. Uses the same on-disk config as Claude //! Desktop (`claude_desktop_config.json`, key `mcpServers`) but is only active //! when a sibling `vm_bundles/` directory exists (Cowork's distinguishing //! marker vs. plain Desktop). diff --git a/crates/mcp_detector_lib/src/clients/claude_desktop.rs b/crates/mcp_detector_lib/src/clients/claude_desktop.rs index f9fde3b..7e5bf37 100644 --- a/crates/mcp_detector_lib/src/clients/claude_desktop.rs +++ b/crates/mcp_detector_lib/src/clients/claude_desktop.rs @@ -1,4 +1,4 @@ -//! Claude Desktop [`Agent`](crate::Agent) — a single user-level JSON config, +//! Claude Desktop [`Agent`] — a single user-level JSON config, //! `claude_desktop_config.json`, key `mcpServers`. use std::path::PathBuf; diff --git a/crates/mcp_detector_lib/src/clients/codex.rs b/crates/mcp_detector_lib/src/clients/codex.rs index cadfc97..8f4601f 100644 --- a/crates/mcp_detector_lib/src/clients/codex.rs +++ b/crates/mcp_detector_lib/src/clients/codex.rs @@ -1,4 +1,4 @@ -//! Codex CLI [`Agent`](crate::Agent) — `~/.codex/config.toml` (TOML), servers +//! Codex CLI [`Agent`] — `~/.codex/config.toml` (TOML), servers //! under the `[mcp_servers]` table. //! //! (client_2 stubs Codex discovery; we actually parse the table.) diff --git a/crates/mcp_detector_lib/src/clients/cursor.rs b/crates/mcp_detector_lib/src/clients/cursor.rs index 0057163..51f8417 100644 --- a/crates/mcp_detector_lib/src/clients/cursor.rs +++ b/crates/mcp_detector_lib/src/clients/cursor.rs @@ -1,4 +1,4 @@ -//! Cursor [`Agent`](crate::Agent). Scans: +//! Cursor [`Agent`]. Scans: //! //! 1. **User config** — `~/.cursor/mcp.json` (JSONC), key `mcpServers`. //! 2. **Project configs** — `/.cursor/mcp.json` (JSONC), for each diff --git a/crates/mcp_detector_lib/src/clients/jetbrains.rs b/crates/mcp_detector_lib/src/clients/jetbrains.rs index bffca0b..ffb5f9d 100644 --- a/crates/mcp_detector_lib/src/clients/jetbrains.rs +++ b/crates/mcp_detector_lib/src/clients/jetbrains.rs @@ -1,4 +1,4 @@ -//! JetBrains IDEs [`Agent`](crate::Agent) — IntelliJ IDEA, PyCharm, WebStorm. +//! JetBrains IDEs [`Agent`] — IntelliJ IDEA, PyCharm, WebStorm. //! //! Each IDE stores MCP servers in `//mcp/servers.json` //! (JSON, key `mcpServers`). The base dir holds version-suffixed folders (e.g. diff --git a/crates/mcp_detector_lib/src/clients/vscode.rs b/crates/mcp_detector_lib/src/clients/vscode.rs index 45fde8e..bf27c94 100644 --- a/crates/mcp_detector_lib/src/clients/vscode.rs +++ b/crates/mcp_detector_lib/src/clients/vscode.rs @@ -1,4 +1,4 @@ -//! VSCode [`Agent`](crate::Agent) implementation. Watches four sources: +//! VSCode [`Agent`] implementation. Watches four sources: //! //! 1. **User-level `Code/User/mcp.json`** — the file users edit by hand. //! 2. **Per-workspace `/.vscode/mcp.json`** for every workspace diff --git a/crates/mcp_detector_lib/src/clients/windsurf.rs b/crates/mcp_detector_lib/src/clients/windsurf.rs index c62e783..287768b 100644 --- a/crates/mcp_detector_lib/src/clients/windsurf.rs +++ b/crates/mcp_detector_lib/src/clients/windsurf.rs @@ -1,4 +1,4 @@ -//! Windsurf [`Agent`](crate::Agent) — a single user-level JSON config, +//! Windsurf [`Agent`] — a single user-level JSON config, //! `~/.codeium/windsurf/mcp_config.json` (same on all platforms), key //! `mcpServers`. diff --git a/crates/mcp_detector_lib/src/clients/zed.rs b/crates/mcp_detector_lib/src/clients/zed.rs index 6aff8a3..6d57f31 100644 --- a/crates/mcp_detector_lib/src/clients/zed.rs +++ b/crates/mcp_detector_lib/src/clients/zed.rs @@ -1,4 +1,4 @@ -//! Zed [`Agent`](crate::Agent) — a single JSON settings file whose MCP servers +//! Zed [`Agent`] — a single JSON settings file whose MCP servers //! live under the top-level `context_servers` key. use std::path::{Path, PathBuf}; diff --git a/crates/mcp_detector_lib/src/secret_detection.rs b/crates/mcp_detector_lib/src/secret_detection.rs index 79329cd..ae099f8 100644 --- a/crates/mcp_detector_lib/src/secret_detection.rs +++ b/crates/mcp_detector_lib/src/secret_detection.rs @@ -5,7 +5,7 @@ //! //! # Scope //! -//! The [fingerprint](crate::fingerprint) consumes only `command`, `args`, and +//! The [fingerprint](crate::fingerprint()) consumes only `command`, `args`, and //! `url` — never `env` or `headers` — so this module only templatises secrets //! that appear *in those fields*. //! @@ -91,7 +91,7 @@ const NON_SECRET_FLAGS: &[&str] = &[ ]; /// Return a copy of `config` with secrets in fingerprint-relevant fields -/// (`command`, `args`, `url`) replaced by [`PLACEHOLDER`]. `env`/`headers` are +/// (`command`, `args`, `url`) replaced by `PLACEHOLDER`. `env`/`headers` are /// passed through unchanged — they do not feed the fingerprint. pub fn templatize_for_fingerprint(config: &ServerConfig) -> ServerConfig { match config { diff --git a/crates/mcp_detector_lib/src/types.rs b/crates/mcp_detector_lib/src/types.rs index 5c25c87..c488c03 100644 --- a/crates/mcp_detector_lib/src/types.rs +++ b/crates/mcp_detector_lib/src/types.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; /// shape that's the same regardless of where it came from. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DiscoveredServer { - /// Identifier of the producing [`crate::Client`] (e.g. `"vscode"`, + /// Identifier of the producing [`crate::Agent`] (e.g. `"vscode"`, /// `"claude_code"`). Useful for filtering events by source. pub client: &'static str, /// Server name as it appears in the config (the key under `servers` / @@ -68,7 +68,7 @@ impl std::fmt::Display for Scope { } /// Raw, normalised launch configuration for a server — the payload the daemon -/// needs both to compute the [fingerprint](crate::fingerprint) and to act on +/// needs both to compute the [fingerprint](crate::fingerprint()) and to act on /// the server. Mirrors the stdio/http arms of client_2's `McpServerConfig` /// union; the unsupported/opaque arm (no extractable command or url) is simply /// not emitted by adapters, so it has no variant here. @@ -165,7 +165,7 @@ pub enum SourceKind { /// How to install the `edison-watch` proxy entry into an agent's config — the /// inverse of quarantine (we *add* an entry). Produced by -/// [`Agent::edison_install`](crate::Agent::edison_install). +/// [`Agent::edison_installs`](crate::Agent::edison_installs). #[derive(Debug, Clone, PartialEq, Eq)] pub struct EdisonInstall { /// The config file to write the entry into (created if absent). diff --git a/crates/mcp_detector_lib/src/watcher.rs b/crates/mcp_detector_lib/src/watcher.rs index 4534a97..f3d8106 100644 --- a/crates/mcp_detector_lib/src/watcher.rs +++ b/crates/mcp_detector_lib/src/watcher.rs @@ -20,7 +20,7 @@ use crate::types::ChangeEvent; /// How often the event loop wakes up to check the stop flag. const STOP_CHECK_INTERVAL: Duration = Duration::from_millis(250); -/// Driver that observes a fixed set of [`Client`]s and emits [`ChangeEvent`]s +/// Driver that observes a fixed set of [`Agent`]s and emits [`ChangeEvent`]s /// as their configs change. /// /// A `Watcher` is constructed with [`Watcher::new`] and consumed by either From 890b2e802ada97841259410ed045f571086dbd55 Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 11:56:34 +0300 Subject: [PATCH 5/5] Fix cargo-deny --- Cargo.lock | 4 ++-- crates/mcp_backend/Cargo.toml | 1 + crates/mcp_detector_daemon/Cargo.toml | 1 + crates/mcp_quarantine/Cargo.toml | 1 + deny.toml | 6 ++++++ 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46350d0..4b2440c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "atomic-waker" diff --git a/crates/mcp_backend/Cargo.toml b/crates/mcp_backend/Cargo.toml index 6b9308b..dc79fb4 100644 --- a/crates/mcp_backend/Cargo.toml +++ b/crates/mcp_backend/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "mcp_backend" version = "0.1.0" +publish = false # internal crate; not published to crates.io edition = "2024" rust-version = "1.88" license = "MIT OR Apache-2.0" diff --git a/crates/mcp_detector_daemon/Cargo.toml b/crates/mcp_detector_daemon/Cargo.toml index d889570..73715ab 100644 --- a/crates/mcp_detector_daemon/Cargo.toml +++ b/crates/mcp_detector_daemon/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "mcp_detector_daemon" version = "0.1.0" +publish = false # internal crate; not published to crates.io edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/mcp_quarantine/Cargo.toml b/crates/mcp_quarantine/Cargo.toml index 63de72a..e7da067 100644 --- a/crates/mcp_quarantine/Cargo.toml +++ b/crates/mcp_quarantine/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "mcp_quarantine" version = "0.1.0" +publish = false # internal crate; not published to crates.io edition = "2024" rust-version = "1.88" license = "MIT OR Apache-2.0" diff --git a/deny.toml b/deny.toml index bbbbd3d..41fa4f3 100644 --- a/deny.toml +++ b/deny.toml @@ -21,6 +21,9 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", + # webpki-roots (Mozilla CA bundle, via reqwest/rustls) ships under this + # permissive data license. + "CDLA-Permissive-2.0", "ISC", "MIT", "MPL-2.0", @@ -34,7 +37,10 @@ confidence-threshold = 0.93 # Multiple versions of the same crate are common via transitive deps; # warn rather than fail. multiple-versions = "warn" +# Deny wildcard (`*`) version requirements on registry crates, but allow them +# for our own workspace-internal path deps (which have no published version). wildcards = "deny" +allow-wildcard-paths = true [sources] unknown-registry = "deny"