From c6e933cebdb283bf6a12d2555199cd2cbf1391ea Mon Sep 17 00:00:00 2001 From: arielam Date: Wed, 19 Aug 2026 15:30:42 +0300 Subject: [PATCH 1/4] MLD-1384- VS code rewrite mcp.json --- .github/scripts/check-vendored-modules.mjs | 6 +- .github/scripts/sync-modules-integrity.json | 8 +- .github/scripts/sync-modules-vendor.json | 10 +- .github/scripts/sync-modules.mjs | 63 +- .github/scripts/sync-modules.test.mjs | 78 ++ .../validate-package-resolution-hook.yml | 5 + .idea/go.imports.xml | 10 + .idea/vcs.xml | 6 + .idea/vscode-plugin.iml | 9 + .idea/workspace.xml | 97 ++ README.md | 37 + VENDOR.md | 9 + marketplace.json | 2 +- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/hooks.json | 6 + plugin/modules/core/agent-guard-check.mjs | 334 +++++ plugin/modules/core/entry.mjs | 36 + plugin/modules/core/rewrite-mcp-json.mjs | 1147 +++++++++++++++++ plugin/scripts/vscode-align-mcp-json.mjs | 95 ++ plugin/scripts/vscode-align-mcp-json.test.mjs | 207 +++ plugin/scripts/vscode-mcp-json-discover.mjs | 237 ++++ .../scripts/vscode-mcp-json-discover.test.mjs | 501 +++++++ scripts/validate-package-resolution-hook.mjs | 65 +- 23 files changed, 2949 insertions(+), 21 deletions(-) create mode 100644 .github/scripts/sync-modules.test.mjs create mode 100644 .idea/go.imports.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/vscode-plugin.iml create mode 100644 .idea/workspace.xml create mode 100644 plugin/modules/core/agent-guard-check.mjs create mode 100644 plugin/modules/core/entry.mjs create mode 100644 plugin/modules/core/rewrite-mcp-json.mjs create mode 100644 plugin/scripts/vscode-align-mcp-json.mjs create mode 100644 plugin/scripts/vscode-align-mcp-json.test.mjs create mode 100644 plugin/scripts/vscode-mcp-json-discover.mjs create mode 100644 plugin/scripts/vscode-mcp-json-discover.test.mjs diff --git a/.github/scripts/check-vendored-modules.mjs b/.github/scripts/check-vendored-modules.mjs index 89260e3..835a854 100644 --- a/.github/scripts/check-vendored-modules.mjs +++ b/.github/scripts/check-vendored-modules.mjs @@ -59,12 +59,12 @@ if (process.argv.includes("--write")) { } const expected = JSON.parse(await readFile(manifestFile, "utf8")); -if (expected.pin !== vendor.pin) +if (JSON.stringify(expected.pin) !== JSON.stringify(vendor.pin)) throw new Error( - `integrity pin mismatch: manifest=${expected.pin} vendor=${vendor.pin}`, + `integrity pin mismatch: manifest=${JSON.stringify(expected.pin)} vendor=${JSON.stringify(vendor.pin)}`, ); if (JSON.stringify(expected.files) !== JSON.stringify(actual.files)) throw new Error( "vendored modules differ from sync-modules-integrity.json; re-vendor and update the manifest", ); -console.log(`vendored modules match pin ${vendor.pin}`); +console.log(`vendored modules match pin ${JSON.stringify(vendor.pin)}`); diff --git a/.github/scripts/sync-modules-integrity.json b/.github/scripts/sync-modules-integrity.json index b31b4cc..654e6aa 100644 --- a/.github/scripts/sync-modules-integrity.json +++ b/.github/scripts/sync-modules-integrity.json @@ -1,14 +1,20 @@ { "schemaVersion": 1, - "pin": "jfrog-agent-hooks/v0.9.0", + "pin": { + "base": "tag:jfrog-agent-hooks/v0.9.0", + "overlay": "commit:741c2ca9a4ea204a21bb13e72719a587f005856f" + }, "files": { "assets/agents-default-conf.json": "04aae9b1dcfc75271c3ed786adceea1635b0a1ae0be64fadd7b1111229f11f01", "claude-session-start.mjs": "2ca1edc6b939cdff6c5faa6ac4b69636e6e92bc7b079316e1fdee7c53c6e837b", "copilot-session-start.mjs": "8811e0829c90ff0987bed158f5ef571195ee5eb54dfc8021b4396fe76ad8a499", + "core/agent-guard-check.mjs": "fd7fe9df640418b0df3296a67c33dfabed97f65e210f6e7abea5bce96fa68834", "core/agents-config.mjs": "3ade16fd6e08b8ac6cb8570edfbed1e9677b26d9c31513720680dd17513480af", + "core/entry.mjs": "0b0b218448151d7a06743c37e684d0933ab76225be5761f648627f4db02c1f17", "core/io.mjs": "63ea75df635a4e15cf36f2158fe78ae42e3ca886abe267ee5ed2577042eb153d", "core/jf-identity.mjs": "9d0301d4a60b9c9297cde24e0bab0c2660c56f831617f2b69db17c844276b19b", "core/logger.mjs": "1ebdffcdf4af14b19e3ee8e82cfeb377fb9961a09d4e9d6ecdc922d07b0848c2", + "core/rewrite-mcp-json.mjs": "a88733edd33bd146ed960c157e085f0b89a20882719c1d6daee5a56400322d86", "core/run-capability.mjs": "9fac890b7fd4866f9d3322469b2a7301e28cebfa3faebd77857f9f79d2d1c532", "cursor-session-start.mjs": "37dd25ffee18e9f357e3cbb8453552766fd89295e85aa09bf93bc208df74aa20", "package-resolution/scripts/eager-setup-receipt.mjs": "69213084bc1976ec63b346ca26e8a63eb713b0da7ad6ab4fc018934e70fef091", diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index aece003..0d99e8b 100644 --- a/.github/scripts/sync-modules-vendor.json +++ b/.github/scripts/sync-modules-vendor.json @@ -1,6 +1,14 @@ { "repo": "JFROG/jfrog-agent-hooks", - "pin": "jfrog-agent-hooks/v0.9.0", + "pin": { + "base": "tag:jfrog-agent-hooks/v0.9.0", + "overlay": "commit:741c2ca9a4ea204a21bb13e72719a587f005856f" + }, "paths": ["modules"], + "keep": [ + "modules/core/agent-guard-check.mjs", + "modules/core/entry.mjs", + "modules/core/rewrite-mcp-json.mjs" + ], "dest_prefix": "plugin" } diff --git a/.github/scripts/sync-modules.mjs b/.github/scripts/sync-modules.mjs index 35e10e6..cb5a9dc 100644 --- a/.github/scripts/sync-modules.mjs +++ b/.github/scripts/sync-modules.mjs @@ -12,8 +12,9 @@ // Reads paths from sync-modules-vendor.json. import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, "..", ".."); @@ -28,7 +29,7 @@ async function fileExists(p) { } } -async function copyPath(fromDir, toDir, relativePath) { +async function copyPath(fromDir, toDir, relativePath, log = console.log) { const from = path.join(fromDir, relativePath); const to = path.join(toDir, relativePath); if (!(await fileExists(from))) { @@ -37,7 +38,44 @@ async function copyPath(fromDir, toDir, relativePath) { await fs.rm(to, { recursive: true, force: true }); await fs.mkdir(path.dirname(to), { recursive: true }); await fs.cp(from, to, { recursive: true }); - console.log(` ${relativePath} -> ${path.relative(process.cwd(), to)}`); + log(` ${relativePath} -> ${path.relative(process.cwd(), to)}`); +} + +export async function syncPaths({ + fromDir, + toDir, + paths, + keep = [], + log = console.log, +}) { + const stashRoot = await fs.mkdtemp(path.join(tmpdir(), "sync-modules-keep-")); + try { + for (const relativePath of keep) { + const source = path.join(toDir, relativePath); + if (!(await fileExists(source))) { + throw new Error(`kept overlay path missing: ${relativePath}`); + } + const stashed = path.join(stashRoot, relativePath); + await fs.mkdir(path.dirname(stashed), { recursive: true }); + await fs.cp(source, stashed, { recursive: true }); + } + + try { + for (const relativePath of paths) { + await copyPath(fromDir, toDir, relativePath, log); + } + } finally { + for (const relativePath of keep) { + const stashed = path.join(stashRoot, relativePath); + const destination = path.join(toDir, relativePath); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.cp(stashed, destination, { recursive: true, force: true }); + log(` restored overlay ${relativePath}`); + } + } + } finally { + await fs.rm(stashRoot, { recursive: true, force: true }); + } } async function main() { @@ -60,11 +98,20 @@ async function main() { const destPrefix = (vendor.dest_prefix ?? "").replace(/^\/+|\/+$/g, ""); const destRoot = destPrefix ? path.join(repoRoot, destPrefix) : repoRoot; - console.log(`--- sync from ${hooksRoot} (pin: ${vendor.pin ?? "local"}) ---`); - for (const rel of paths) { - await copyPath(hooksRoot, destRoot, rel); - } + const pin = vendor.pin ? JSON.stringify(vendor.pin) : "local"; + console.log(`--- sync from ${hooksRoot} (pin: ${pin}) ---`); + await syncPaths({ + fromDir: hooksRoot, + toDir: destRoot, + paths, + keep: vendor.keep, + }); console.log("done."); } -await main(); +if ( + process.argv[1] && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href +) { + await main(); +} diff --git a/.github/scripts/sync-modules.test.mjs b/.github/scripts/sync-modules.test.mjs new file mode 100644 index 0000000..840509b --- /dev/null +++ b/.github/scripts/sync-modules.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { syncPaths } from "./sync-modules.mjs"; + +function write(root, relative, contents) { + const target = path.join(root, relative); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, contents); +} + +test("full sync replaces the base tree and restores kept overlay files", async () => { + const root = mkdtempSync(path.join(tmpdir(), "sync-modules-")); + const upstream = path.join(root, "upstream"); + const destination = path.join(root, "destination"); + write(upstream, "modules/core/overlay.mjs", "base overlay\n"); + write(upstream, "modules/base-only.mjs", "base\n"); + write(destination, "modules/core/overlay.mjs", "reviewed overlay\n"); + write(destination, "modules/unrelated-new.mjs", "remove me\n"); + + await syncPaths({ + fromDir: upstream, + toDir: destination, + paths: ["modules"], + keep: ["modules/core/overlay.mjs"], + log: () => {}, + }); + + assert.equal( + readFileSync( + path.join(destination, "modules/core/overlay.mjs"), + "utf8", + ), + "reviewed overlay\n", + ); + assert.equal( + readFileSync(path.join(destination, "modules/base-only.mjs"), "utf8"), + "base\n", + ); + assert.throws(() => + readFileSync(path.join(destination, "modules/unrelated-new.mjs")), + ); +}); + +test("restores kept overlays when a later sync path fails", async () => { + const root = mkdtempSync(path.join(tmpdir(), "sync-modules-failure-")); + const upstream = path.join(root, "upstream"); + const destination = path.join(root, "destination"); + write(upstream, "modules/core/overlay.mjs", "base overlay\n"); + write(destination, "modules/core/overlay.mjs", "reviewed overlay\n"); + + await assert.rejects( + syncPaths({ + fromDir: upstream, + toDir: destination, + paths: ["modules", "missing"], + keep: ["modules/core/overlay.mjs"], + log: () => {}, + }), + /path missing in upstream: missing/, + ); + + assert.equal( + readFileSync( + path.join(destination, "modules/core/overlay.mjs"), + "utf8", + ), + "reviewed overlay\n", + ); +}); diff --git a/.github/workflows/validate-package-resolution-hook.yml b/.github/workflows/validate-package-resolution-hook.yml index 316b90c..7c2bbd1 100644 --- a/.github/workflows/validate-package-resolution-hook.yml +++ b/.github/workflows/validate-package-resolution-hook.yml @@ -10,11 +10,13 @@ on: paths: - "plugin/hooks/hooks.json" - "plugin/modules/**" + - "plugin/scripts/**" - "plugin/.claude-plugin/plugin.json" - "marketplace.json" - "scripts/validate-package-resolution-hook.mjs" - ".github/scripts/sync-modules-vendor.json" - ".github/scripts/sync-modules.mjs" + - ".github/scripts/sync-modules.test.mjs" - ".github/scripts/sync-modules-integrity.json" - ".github/scripts/check-vendored-modules.mjs" - ".github/workflows/validate-package-resolution-hook.yml" @@ -35,5 +37,8 @@ jobs: - name: Validate hook assembly run: node scripts/validate-package-resolution-hook.mjs + - name: Test VS Code MCP alignment + run: node --test plugin/scripts/*.test.mjs .github/scripts/sync-modules.test.mjs + - name: Verify vendored module integrity run: node .github/scripts/check-vendored-modules.mjs diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vscode-plugin.iml b/.idea/vscode-plugin.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/vscode-plugin.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..3676624 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + { + "lastFilter": { + "state": "OPEN", + "assignee": "arielamitjfrog" + } +} + { + "selectedUrlAndAccountId": { + "url": "git@github.com:jfrog/vscode-plugin.git", + "accountId": "5d9d48cb-2ac9-4e7d-a356-ec1e841b48b5" + } +} + { + "associatedIndex": 2, + "fromUser": false +} + + + + + + + + + + + + + + + + + + + 1786026816239 + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 0535d98..db8d1ce 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The JFrog plugin provides the following capabilities, grouped by component: | Component | Feature | Description | | --------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **MCP** | JFrog MCP server | Remote JFrog MCP server auto-attached to every session via `.mcp.json` at `${JFROG_URL}/mcp` (OAuth, no API keys). | +| **Hook** | MCP server alignment | Secures installed plugins' `mcp.json` and `.mcp.json` server commands with JFrog Agent Guard at Copilot SessionStart. | | **Skill** | Agent Guard | Copilot manages MCPs through the JFrog Agent Guard. Through it you can discover, install, configure, update, and remove MCP servers from the JFrog AI Catalog approved for your project, and authenticate to remote HTTP MCPs via OAuth, API key, or bearer token. | | **Hook** | Agent Package Resolution (Preview) | Inject Artifactory routing instructions at the start of each Copilot session. | @@ -122,6 +123,42 @@ See the [user guide](docs/package-resolution-user-guide.md) for setup and the [administrator guide](docs/package-resolution-admin-guide.md) for rollout and governance configuration. +### MCP server alignment + +At Copilot `SessionStart`, the plugin discovers MCP configuration files owned by +installed agent plugins and passes them to Agent Guard's shared +`--rewrite-mcp-json` pipeline. Agent Guard rewrites eligible server commands so +they run through the configured JFrog project policy. The hook is fail-open and +has a 60-second limit; disabled, unchanged, or failed rewrites do not block a +chat. + +Discovery checks both `mcp.json` and `.mcp.json`, in that order, under +`~/.copilot/installed-plugins/{marketplace}/{plugin}`, +`~/.copilot/installed-plugins/_direct/{id}`, and +`~/.vscode/agent-plugins/…`, plus this plugin's own configs next to the +adaptor. + +Only plugin MCP configurations are considered. The hook never rewrites the user +`Code/User/mcp.json` or a workspace `.vscode/mcp.json`. + +Environment controls: + +- `JF_AGENT_REWRITE_MCP_JSON_DISABLE=1` disables rewriting. +- `JF_AGENT_REWRITE_MCP_JSON_FORCE=1` ignores the current-state marker and + forces a refresh. +- `JF_ALIGN_MCP_JSON_ROOTS` replaces the default Copilot installed-plugins + and `~/.vscode/agent-plugins` roots (and skips this plugin's own configs). + Separate roots with colon or comma on macOS/Linux, and semicolon or + comma on Windows. Overrides may point outside the default, but discovery + still rejects `.vscode` and `Code/User` configs and symlinks escaping an + override root. + +If the alignment pipeline changes any discovered configuration bytes, even if +the pipeline later times out or reports a failure, Copilot displays: +`JFrog Agent Guard secured your plugins' MCP servers. Run Developer: Reload Window to reconnect.` +Use the Command Palette command **Developer: Reload Window** before using the +rewritten MCP servers. + ### Discover, inspect, and install MCPs | Ask the agent… | What happens | diff --git a/VENDOR.md b/VENDOR.md index 6c1aa07..4f5f555 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -32,6 +32,15 @@ verifies the committed tree matches the pin (see [`sync-modules-integrity.json`](.github/scripts/sync-modules-integrity.json) for the per-file checksums used in that check). +The current bundle uses `jfrog-agent-hooks/v0.9.0` as its base. Three shared +core files are overlaid from commit +`741c2ca9a4ea204a21bb13e72719a587f005856f`, merged by upstream PR 108: +`agent-guard-check.mjs`, `entry.mjs`, and `rewrite-mcp-json.mjs`. The vendor +configuration records both pins and lists those paths under `keep`; a full base +sync temporarily stashes and restores them. All other files come from the +v0.9.0 base. Only upstream `modules/` are vendored; upstream tests remain in +the source repository. + ## Not vendored [`@jfrog/agent-guard`](https://jfrog.com) is fetched at runtime via `npx` from diff --git a/marketplace.json b/marketplace.json index 0cabef1..aaa1a89 100644 --- a/marketplace.json +++ b/marketplace.json @@ -9,7 +9,7 @@ { "name": "jfrog", "description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices", - "version": "1.0.17", + "version": "1.0.18", "license": "Apache-2.0", "source": "plugin", "categories": [ diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index a01f77a..dfeafb3 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices", - "version": "1.0.17", + "version": "1.0.18", "license": "Apache-2.0", "author": { "name": "JFrog", diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 1b86def..8ddea6e 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -8,6 +8,12 @@ "command": "node \"${CLAUDE_PLUGIN_ROOT}/modules/copilot-session-start.mjs\" package-resolution", "timeout": 15, "statusMessage": "Routing package installs through JFrog Artifactory…" + }, + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/vscode-align-mcp-json.mjs\" session-start", + "timeout": 60, + "statusMessage": "Securing plugin MCP servers with JFrog Agent Guard…" } ] } diff --git a/plugin/modules/core/agent-guard-check.mjs b/plugin/modules/core/agent-guard-check.mjs new file mode 100644 index 0000000..8789667 --- /dev/null +++ b/plugin/modules/core/agent-guard-check.mjs @@ -0,0 +1,334 @@ +#!/usr/bin/env node +// JFrog Agent Guard activation check +// +// Silent gate for session hooks. Determines whether Agent Guard is enabled +// for the current environment. +// +// Contract (key off `code`, not `reason` text): +// - code 0 -> Agent Guard ENABLED (caller may proceed) +// - code 2 -> reachable but the platform has the MCP registry DISABLED +// - code 1 -> DISABLED for any other reason: no credentials, timeout, +// network/DNS error (caller must silently abort) +// +// Set JF_AGENT_GUARD_DEBUG=true for verbose tracing on stderr. +// Library callers use runAgentGuardCheck(); CLI entry calls process.exit. + +import { execFileSync } from "node:child_process"; +import process from "node:process"; + +import { isMainEntry } from "./entry.mjs"; + +export const SETTINGS_PATH = + "/ml/core/api/v1/administration/account-settings/mcp_gateway_plugin_enabled"; +export const REQUEST_TIMEOUT_MS = 5000; + +export const EXIT_ENABLED = 0; +export const EXIT_DISABLED = 1; +export const EXIT_REGISTRY_DISABLED = 2; + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {string} newName + * @param {string} [oldName] + * @returns {string | undefined} + */ +function envLookup(env, newName, oldName) { + const raw = env[newName] ?? (oldName ? env[oldName] : undefined); + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed || undefined; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {(message: string) => void} [debug] + */ +function makeDebug(env, debug) { + if (typeof debug === "function") return debug; + const enabled = env.JF_AGENT_GUARD_DEBUG === "true"; + return (message) => { + if (enabled) console.error(`[jfrog-agent-guard] ${message}`); + }; +} + +/** + * Resolve credentials from Path A (environment variables) or Path B + * (JFrog CLI configuration). + * + * Intentionally distinct from `jf-identity.mjs`: + * - package-resolution identity is always `jf config` and may use Basic auth; + * - Agent Guard's settings probe needs a Bearer access token, and mirrors the + * AG CLI by preferring JFROG_URL/JF_URL + access token when set. + * - When `serverId` is set: that jf server first, then env, never the default + * CLI server. Without `serverId`: env first, then default `jf config export`. + * Do not reuse getPlatformIdentity() here without preserving that contract. + * + * @param {{ + * serverId?: string, + * env?: NodeJS.ProcessEnv, + * execFileSyncFn?: typeof execFileSync, + * debug?: (message: string) => void, + * }} [opts] + * @returns {{ baseUrl: string, token: string, source: string } | null} + */ +export function resolveAgentGuardCredentials(opts = {}) { + const env = opts.env ?? process.env; + const debug = makeDebug(env, opts.debug); + const explicitServerId = opts.serverId?.trim() || undefined; + const execFn = opts.execFileSyncFn ?? execFileSync; + + if (explicitServerId) { + const fromCli = resolveFromCliConfig({ + serverId: explicitServerId, + execFileSyncFn: execFn, + debug, + }); + if (fromCli) return fromCli; + debug( + "Explicit server ID did not resolve via jf config; falling back to env credentials.", + ); + } + + const envUrl = envLookup(env, "JFROG_URL", "JF_URL"); + const envToken = envLookup(env, "JFROG_ACCESS_TOKEN", "JF_ACCESS_TOKEN"); + if (envUrl && envToken) { + debug("Using credentials from environment variables (Path A)."); + return { + baseUrl: envUrl, + token: envToken, + source: "environment variables", + }; + } + debug( + "Environment credentials incomplete; trying JFrog CLI config (Path B).", + ); + + if (explicitServerId) return null; + return resolveFromCliConfig({ + serverId: undefined, + execFileSyncFn: execFn, + debug, + }); +} + +/** + * @param {{ + * serverId?: string, + * execFileSyncFn?: typeof execFileSync, + * debug?: (message: string) => void, + * }} opts + */ +function resolveFromCliConfig(opts) { + const debug = opts.debug ?? (() => {}); + const execFn = opts.execFileSyncFn ?? execFileSync; + const exportArgs = opts.serverId + ? ["config", "export", opts.serverId] + : ["config", "export"]; + let exported; + try { + exported = execFn("jf", exportArgs, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2000, + }).trim(); + } catch (error) { + debug( + `'jf config export' failed (jf not on PATH or no server configured): ${error?.message}`, + ); + return null; + } + + let cfg; + try { + cfg = JSON.parse(Buffer.from(exported, "base64").toString("utf8")); + } catch (error) { + debug(`Could not decode the jf config export token: ${error?.message}`); + return null; + } + + const baseUrl = cfg?.url; + const token = cfg?.accessToken; + if (!baseUrl) { + debug("Exported JFrog CLI config has no platform URL."); + return null; + } + if (!token) { + debug( + "Exported JFrog CLI config has no access token (bearer auth needed).", + ); + return null; + } + + const id = cfg?.serverId ?? "default"; + return { + baseUrl, + token, + source: `JF CLI config (server '${id}')`, + }; +} + +/** + * @param {string} baseUrl + * @param {string} token + * @param {{ + * fetchFn?: typeof fetch, + * timeoutMs?: number, + * debug?: (message: string) => void, + * }} [opts] + */ +export async function isGatewayPluginEnabled(baseUrl, token, opts = {}) { + const debug = opts.debug ?? (() => {}); + const fetchFn = opts.fetchFn ?? fetch; + const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS; + + const root = baseUrl.replace(/\/+$/, "").replace(/\/artifactory$/, ""); + const url = root + SETTINGS_PATH; + debug(`Fetching gateway plugin setting from ${url}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchFn(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + }); + if (!response.ok) { + debug(`Settings request returned HTTP ${response.status}.`); + return { + ok: false, + reason: `settings endpoint returned HTTP ${response.status}`, + }; + } + const data = await response.json(); + const unwrap = (v) => (v !== null && typeof v === "object" ? v?.value : v); + const container = data?.settings ?? data; + const named = + container?.mcpGatewayPluginEnabled ?? + container?.mcp_gateway_plugin_enabled; + const value = + typeof data === "boolean" + ? data + : named !== undefined + ? unwrap(named) + : unwrap(container); + debug(`Settings response indicates gateway plugin enabled=${value}.`); + if (value === true) return { ok: true }; + if (value === false) { + return { + ok: false, + registryOff: true, + reason: "mcp gateway plugin setting returned false", + }; + } + return { + ok: false, + reason: "settings endpoint returned an invalid gateway-plugin setting", + }; + } catch (error) { + const reason = + error?.name === "AbortError" + ? "timeout" + : (error?.message ?? "unknown error"); + debug(`Settings request failed: ${reason}`); + return { + ok: false, + reason: `settings endpoint unreachable (${reason})`, + }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Run the Agent Guard activation check without exiting the process. + * @param {{ + * serverId?: string, + * env?: NodeJS.ProcessEnv, + * fetchFn?: typeof fetch, + * execFileSyncFn?: typeof execFileSync, + * timeoutMs?: number, + * debug?: (message: string) => void, + * }} [opts] + * @returns {Promise<{ code: number, reason: string }>} + */ +export async function runAgentGuardCheck(opts = {}) { + const env = opts.env ?? process.env; + const debug = makeDebug(env, opts.debug); + + try { + const forceDisabled = + envLookup(env, "_JF_AGENT_GUARD_FORCE_DISABLE") === "true"; + const forceEnabled = + envLookup(env, "JF_AGENT_GUARD_FORCE_ENABLE") === "true"; + if (forceDisabled) { + return { + code: EXIT_DISABLED, + reason: "Disabled: forced via _JF_AGENT_GUARD_FORCE_DISABLE", + }; + } + if (forceEnabled) { + return { + code: EXIT_ENABLED, + reason: "Enabled: forced via JF_AGENT_GUARD_FORCE_ENABLE", + }; + } + + const creds = resolveAgentGuardCredentials({ + serverId: opts.serverId, + env, + execFileSyncFn: opts.execFileSyncFn, + debug, + }); + if (!creds) { + return { + code: EXIT_DISABLED, + reason: + "Disabled: JFROG_URL/JF_URL + access token not set and no default JF CLI config found", + }; + } + + const result = await isGatewayPluginEnabled(creds.baseUrl, creds.token, { + fetchFn: opts.fetchFn, + timeoutMs: opts.timeoutMs, + debug, + }); + if (result.ok) { + return { + code: EXIT_ENABLED, + reason: `Enabled: via ${creds.source}`, + }; + } + if (result.registryOff) { + return { + code: EXIT_REGISTRY_DISABLED, + reason: `RegistryDisabled: ${result.reason}`, + }; + } + return { + code: EXIT_DISABLED, + reason: `Disabled: ${result.reason}`, + }; + } catch (error) { + debug(`Unexpected error: ${error?.stack ?? error?.message ?? error}`); + return { code: EXIT_DISABLED, reason: "Disabled: unexpected error" }; + } +} + +async function main() { + const result = await runAgentGuardCheck({ + serverId: process.argv[2], + }); + process.stdout.write(`${result.reason}\n`); + process.exit(result.code); +} + +if (isMainEntry(import.meta.url)) { + main().catch((error) => { + console.error(`[jfrog-agent-guard] Unexpected error: ${error?.message}`); + process.exit(EXIT_DISABLED); + }); +} diff --git a/plugin/modules/core/entry.mjs b/plugin/modules/core/entry.mjs new file mode 100644 index 0000000..476d681 --- /dev/null +++ b/plugin/modules/core/entry.mjs @@ -0,0 +1,36 @@ +// Shared "was this module run as the CLI entrypoint?" check for the adapters. +// +// Claude invokes hooks as `${CLAUDE_PLUGIN_ROOT}/modules/.mjs`, and a +// plugin install directory is often a symlink. Node resolves the main entry to +// its real path before assigning import.meta.url, so comparing against a raw +// path.resolve(process.argv[1]) reports false under a symlinked layout and the +// hook silently becomes a no-op with exit code 0. Compare against both. + +import { realpathSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +/** + * @param {string} moduleUrl — the caller's import.meta.url + * @param {string} [entry] — defaults to process.argv[1] + */ +export function isMainEntry(moduleUrl, entry = process.argv[1]) { + if (!entry) return false; + + try { + const resolved = path.resolve(entry); + let real = resolved; + try { + real = realpathSync(resolved); + } catch { + // Entry may not exist on disk (e.g. a virtual entrypoint); use as-is. + } + return ( + moduleUrl === pathToFileURL(real).href || + moduleUrl === pathToFileURL(resolved).href + ); + } catch { + return false; + } +} diff --git a/plugin/modules/core/rewrite-mcp-json.mjs b/plugin/modules/core/rewrite-mcp-json.mjs new file mode 100644 index 0000000..8adcc47 --- /dev/null +++ b/plugin/modules/core/rewrite-mcp-json.mjs @@ -0,0 +1,1147 @@ +// Shared Agent Guard `--rewrite-mcp-json` runner for harness adapters. +// +// Harness plugins own path discovery; this module owns: +// resolve server/project → discover → skip-if-current → Step 0 gate → +// spawn/timeout, soft-fail orchestration with structured outcomes. +// Server id is resolved once for both the gate and AG --server (always passed). +// +// Usage (from a thin Cursor/Claude script next to synced modules/): +// import { runRewriteMcpJsonPipeline } from "./modules/core/rewrite-mcp-json.mjs"; +// const result = await runRewriteMcpJsonPipeline({ +// discover: () => [...absoluteMcpJsonPaths], +// allowRoots: [...], +// }); +// // result: { exitCode, outcome, reason } — exitCode is 0 unless STRICT=1 +// +// Kill switch: JF_AGENT_REWRITE_MCP_JSON_DISABLE=1 → soft no-op (exit 0). +// Force refresh: JF_AGENT_REWRITE_MCP_JSON_FORCE=1 → ignore skip marker. +// Strict: JF_AGENT_REWRITE_MCP_JSON_STRICT=1 → failed_* outcomes exit 1. +// Local binary: JFROG_AGENT_GUARD_BIN=/path/to/agent-guard (skips npx). +// Version pin: JFROG_AGENT_GUARD_VERSION (default DEFAULT_AGENT_GUARD_VERSION). + +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import process from "node:process"; + +import { EXIT_ENABLED, runAgentGuardCheck } from "./agent-guard-check.mjs"; +import { createLogger } from "./logger.mjs"; + +const log = createLogger("rewrite-mcp-json"); + +export const AGENT_GUARD_PACKAGE = "@jfrog/agent-guard"; +export const DISABLE_ENV = "JF_AGENT_REWRITE_MCP_JSON_DISABLE"; +export const FORCE_ENV = "JF_AGENT_REWRITE_MCP_JSON_FORCE"; +export const STRICT_ENV = "JF_AGENT_REWRITE_MCP_JSON_STRICT"; +export const AGENT_GUARD_BIN_ENV = "JFROG_AGENT_GUARD_BIN"; +/** + * Default npm registry for `npx @jfrog/agent-guard` during mcp.json rewrite. + * + * Exception to the usual "no runtime hard-dep on releases.jfrog.io" bundling + * rule: package-resolution hooks are fully vendored, but Agent Guard's MCP + * rewrite intentionally fetches `@jfrog/agent-guard` at session start via + * npx from the public `coding-agents-npm` channel (override with + * JFROG_AGENT_GUARD_REPO / JFROG_AGENT_GUARD_BIN). See .cursor/rules/bundling.mdc. + */ +export const DEFAULT_AGENT_GUARD_NPM_REGISTRY = + "https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/"; +/** + * Pinned so a session start cannot execute whatever the registry currently + * tags as latest. Bump deliberately; JFROG_AGENT_GUARD_VERSION overrides + * (including "latest"). First release validated with `--rewrite-mcp-json`. + */ +export const DEFAULT_AGENT_GUARD_VERSION = "1.6.0"; +/** + * Shared budget for rewriting all discovered files in one hook invocation. + * Kept under the harness hook timeout (Cursor sessionStart is 60s); do not + * raise this to match the hook timeout. + */ +export const DEFAULT_REWRITE_TIMEOUT_MS = 35_000; +/** SIGTERM → SIGKILL escalation window for a child that ignores the first signal. */ +export const DEFAULT_KILL_GRACE_MS = 2_000; + +/** Newest setup.json "version" this code understands (best-effort on mismatch). */ +export const SUPPORTED_SETUP_FILE_VERSION = 1; + +export const OUTCOME = Object.freeze({ + DISABLED: "disabled", + SKIPPED_CURRENT: "skipped_current", + SKIPPED_NO_PATHS: "skipped_no_paths", + SKIPPED_NO_PROJECT: "skipped_no_project", + SKIPPED_NO_SERVER: "skipped_no_server", + SKIPPED_UNSAFE_PROJECT: "skipped_unsafe_project", + SKIPPED_UNSAFE_SERVER: "skipped_unsafe_server", + SKIPPED_GATE: "skipped_gate", + FAILED_DISCOVER: "failed_discover", + FAILED_GATE: "failed_gate", + FAILED_ALLOW_ROOTS: "failed_allow_roots", + FAILED_SPAWN: "failed_spawn", + REWRITTEN: "rewritten", +}); + +/** + * @param {string} outcome + * @param {string} [reason] + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ exitCode: number, outcome: string, reason: string }} + */ +export function pipelineResult(outcome, reason = "", env = process.env) { + const failed = String(outcome).startsWith("failed_"); + const exitCode = failed && env[STRICT_ENV] === "1" ? 1 : 0; + return { exitCode, outcome, reason }; +} + +export function isRewriteDisabled(env = process.env) { + return env[DISABLE_ENV] === "1"; +} + +export function isRewriteForced(env = process.env) { + return env[FORCE_ENV] === "1"; +} + +/** + * True when JFROG_URL/JF_URL + access token are set. + * Used by the gate (Path A); plugin rewrite always passes `--server` separately. + * @param {NodeJS.ProcessEnv} [env] + */ +export function hasJfrogUrlTokenEnv(env = process.env) { + const url = env.JFROG_URL?.trim() || env.JF_URL?.trim(); + const token = env.JFROG_ACCESS_TOKEN?.trim() || env.JF_ACCESS_TOKEN?.trim(); + return Boolean(url && token); +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * @param {string} [url] + * @returns {string} + */ +export function normalizeJpdUrl(url) { + return String(url ?? "") + .trim() + .replace(/\/+$/, ""); +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {string} + */ +export function resolveJfrogHomeDir(env = process.env) { + const fromEnv = env.JFROG_CLI_HOME_DIR?.trim(); + if (fromEnv) return fromEnv; + return path.join(homedir(), ".jfrog"); +} + +/** + * Default skip-if-current marker under the jf CLI home. + * @param {NodeJS.ProcessEnv} [env] + * @returns {string} + */ +export function defaultRewriteMarkerPath(env = process.env) { + return path.join( + resolveJfrogHomeDir(env), + "agent-hooks", + "rewrite-mcp-json.marker", + ); +} + +/** + * Mirror of Agent Guard `ActiveProjectFromSetupFile`: read + * `{JFROG_CLI_HOME}/setup.json` → servers[id].currentActiveProject. + * Never throws; returns "" when missing/unreadable/no match. + * + * @param {string} serverId + * @param {string} [jpdUrl] + * @param {{ + * env?: NodeJS.ProcessEnv, + * readFileSyncFn?: typeof readFileSync, + * setupPath?: string, + * }} [opts] + * @returns {string} + */ +export function activeProjectFromSetupFile(serverId, jpdUrl = "", opts = {}) { + const env = opts.env ?? process.env; + const readFn = opts.readFileSyncFn ?? readFileSync; + const setupPath = + opts.setupPath ?? path.join(resolveJfrogHomeDir(env), "setup.json"); + const wantUrl = normalizeJpdUrl(jpdUrl); + const id = String(serverId ?? "").trim(); + + let raw; + try { + raw = readFn(setupPath, "utf8"); + } catch (err) { + if (err?.code === "ENOENT") { + log.debug("setup file: not found", { path: setupPath }); + } + return ""; + } + + /** @type {{ version?: number, servers?: Record }} */ + let sf = {}; + try { + sf = JSON.parse(raw); + } catch { + return ""; + } + if (!isPlainObject(sf) || !isPlainObject(sf.servers)) return ""; + if ( + typeof sf.version === "number" && + sf.version !== SUPPORTED_SETUP_FILE_VERSION + ) { + // Best-effort parse (matches AG). + } + + const servers = sf.servers; + if (id) { + const entry = servers[id]; + const project = entry?.currentActiveProject?.trim?.() || ""; + if (project) { + const entryUrl = normalizeJpdUrl(entry.jpdUrl); + if (wantUrl === "" || entryUrl === wantUrl) return project; + } + } + + if (wantUrl) { + const ids = Object.keys(servers).sort(); + for (const sid of ids) { + const entry = servers[sid]; + const project = entry?.currentActiveProject?.trim?.() || ""; + if (project && normalizeJpdUrl(entry.jpdUrl) === wantUrl) return project; + } + } + return ""; +} + +/** + * Parse `jf config show --format=json` into a server list. + * @param {string} stdout + * @returns {{ serverId: string, jpdUrl: string, isDefault: boolean }[]} + */ +export function parseJfConfigShowJson(stdout) { + if (typeof stdout !== "string" || !stdout.trim()) return []; + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + return []; + } + const list = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.servers) + ? parsed.servers + : parsed + ? [parsed] + : []; + /** @type {{ serverId: string, jpdUrl: string, isDefault: boolean }[]} */ + const out = []; + for (const s of list) { + if (!isPlainObject(s)) continue; + const serverId = String(s.serverId ?? "").trim(); + if (!serverId) continue; + const jpdUrl = normalizeJpdUrl( + s.url || s.Url || s.artifactoryUrl || s.platformUrl || "", + ); + out.push({ + serverId, + jpdUrl, + isDefault: Boolean(s.isDefault), + }); + } + return out; +} + +/** + * Exactly one server, or the isDefault entry. Otherwise { error }. + * @param {{ serverId: string, jpdUrl: string, isDefault: boolean }[]} servers + * @returns {{ serverId: string, jpdUrl: string } | { error: "missing" | "no_default" }} + */ +export function pickDefaultJfCliServer(servers) { + const list = servers ?? []; + if (list.length === 0) return { error: "missing" }; + if (list.length === 1) { + return { serverId: list[0].serverId, jpdUrl: list[0].jpdUrl }; + } + const def = list.find((s) => s.isDefault); + if (def) return { serverId: def.serverId, jpdUrl: def.jpdUrl }; + return { error: "no_default" }; +} + +/** + * @param {{ + * env?: NodeJS.ProcessEnv, + * spawnSyncFn?: typeof spawnSync, + * }} [opts] + * @returns {{ serverId: string, jpdUrl: string }[]} + */ +export function listJfCliServers(opts = {}) { + const env = opts.env ?? process.env; + const spawnSyncFn = opts.spawnSyncFn ?? spawnSync; + let res; + try { + res = spawnSyncFn("jf", ["config", "show", "--format=json"], { + encoding: "utf8", + timeout: 5_000, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + return []; + } + if (res?.error || res.status !== 0) return []; + return parseJfConfigShowJson(res.stdout ?? ""); +} + +/** + * Resolve server for gate + rewrite. Always expects a concrete server id + * for plugin MCP (Shay): hint → jf config (one / isDefault) → env. + * + * @param {NodeJS.ProcessEnv} [env] + * @param {{ + * serverIdHint?: string, + * spawnSyncFn?: typeof spawnSync, + * }} [opts] + * @returns {{ + * serverId: string, + * jpdUrl: string, + * } | { + * error: "missing" | "no_default", + * }} + */ +export function resolveRewriteServer(env = process.env, opts = {}) { + const servers = listJfCliServers({ + env, + spawnSyncFn: opts.spawnSyncFn, + }); + + const hint = opts.serverIdHint?.trim(); + if (hint) { + const match = servers.find((s) => s.serverId === hint); + return { + serverId: hint, + jpdUrl: match?.jpdUrl ?? "", + }; + } + + const picked = pickDefaultJfCliServer(servers); + if (!("error" in picked)) return picked; + + const fromEnv = env.JF_SERVER?.trim() || env.JFROG_SERVER_ID?.trim() || ""; + if (fromEnv) { + const match = servers.find((s) => s.serverId === fromEnv); + return { serverId: fromEnv, jpdUrl: match?.jpdUrl ?? "" }; + } + return picked.error === "no_default" + ? { error: "no_default" } + : { error: "missing" }; +} + +/** + * Resolve JFrog project key: env → setup.json (AG-compatible) → "". + * @param {NodeJS.ProcessEnv} [env] + * @param {{ + * serverId?: string, + * jpdUrl?: string, + * readFileSyncFn?: typeof readFileSync, + * setupPath?: string, + * }} [opts] + * @returns {string} + */ +export function resolveRewriteProject(env = process.env, opts = {}) { + const fromEnv = env.JF_PROJECT?.trim() || env.JFROG_PROJECT?.trim() || ""; + if (fromEnv) return fromEnv; + return activeProjectFromSetupFile(opts.serverId ?? "", opts.jpdUrl ?? "", { + env, + readFileSyncFn: opts.readFileSyncFn, + setupPath: opts.setupPath, + }); +} + +/** + * @deprecated Use resolveRewriteServer. Kept for callers that only need the id. + * @param {NodeJS.ProcessEnv} [env] + * @param {{ serverIdHint?: string, spawnSyncFn?: typeof spawnSync }} [opts] + * @returns {string} + */ +export function resolveRewriteServerId(env = process.env, opts = {}) { + const resolved = resolveRewriteServer(env, opts); + if ("error" in resolved) return ""; + return resolved.serverId; +} + +/** + * @param {NodeJS.Platform} [platform] + */ +export function resolveNpxCommand(platform = process.platform) { + return platform === "win32" ? "npx.cmd" : "npx"; +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {NodeJS.Platform} [platform] + * @param {{ local?: boolean }} [opts] + */ +export function buildNpxSpawnOptions( + env, + platform = process.platform, + opts = {}, +) { + const isWin = platform === "win32"; + const useShell = isWin && !opts.local; + return { + stdio: /** @type {const} */ (["pipe", "pipe", "pipe"]), + env, + // Pin cmd.exe — shell: true would honor ComSpec (e.g. PowerShell). + shell: useShell ? "cmd.exe" : false, + detached: !isWin, + }; +} + +/** + * Safe grammar for JF project keys / server IDs passed on a Windows cmd.exe + * command line (and as a general injection guard on all platforms). + * @param {string} value + * @returns {boolean} + */ +export function isSafeRewriteIdentifier(value) { + return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(String(value ?? "")); +} + +/** + * @param {string} value + * @param {string} label + * @returns {string} + * @throws {Error} when value is not a safe identifier + */ +export function assertSafeRewriteIdentifier(value, label = "identifier") { + const trimmed = String(value ?? "").trim(); + if (!isSafeRewriteIdentifier(trimmed)) { + throw new Error( + `rewrite-mcp-json ${label} must be a safe identifier (A-Za-z0-9._-): ${JSON.stringify(trimmed)}`, + ); + } + return trimmed; +} + +/** + * Quote a single argv token for Node spawn under shell: "cmd.exe". + * Uses cmd.exe rules: wrap in ", double embedded quotes, escape % as %%. + * CRT-style backslash-escaping is NOT safe under cmd.exe (a quote can break + * out and leave metacharacters like & executable). + * @param {string} arg + * @returns {string} + * @throws {Error} when the arg contains CR/LF + */ +export function quoteWindowsArg(arg) { + const value = String(arg ?? ""); + if (/[\r\n]/.test(value)) { + throw new Error("Windows spawn arg must not contain CR/LF"); + } + // Neutralize %VAR% expansion, then double any embedded quotes for cmd.exe. + const escaped = value.replace(/%/g, "%%").replace(/"/g, '""'); + return `"${escaped}"`; +} + +/** + * @param {string[]} args + * @param {NodeJS.Platform} [platform] + * @returns {string[]} + */ +export function quoteSpawnArgs(args, platform = process.platform) { + return platform === "win32" ? args.map(quoteWindowsArg) : args; +} + +/** + * @param {{ pid?: number, kill?: (signal?: string) => boolean }} child + * @param {{ + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * spawnFn?: typeof spawn, + * graceMs?: number, + * isAlive?: () => boolean, + * waitForExit?: Promise, + * }} [opts] + * @returns {Promise} + */ +export async function killRewriteChildTree(child, opts = {}) { + const platform = opts.platform ?? process.platform; + const killFn = opts.killFn ?? process.kill; + const spawnFn = opts.spawnFn ?? spawn; + const graceMs = opts.graceMs ?? DEFAULT_KILL_GRACE_MS; + const isAlive = opts.isAlive ?? (() => true); + + const signalChild = (signal) => { + try { + child?.kill?.(signal); + } catch { + // Already gone. + } + }; + + const signalTree = (signal) => { + if (platform === "win32") { + if (child?.pid) { + try { + const killer = spawnFn( + "taskkill", + ["/pid", String(child.pid), "/T", "/F"], + { stdio: "ignore" }, + ); + killer?.on?.("error", () => {}); + return; + } catch { + // fall through + } + } + signalChild(signal); + return; + } + + if (child?.pid) { + try { + killFn(-child.pid, signal); + return; + } catch { + // Fall through to child.kill when the group is already gone. + } + } + signalChild(signal); + }; + + signalTree("SIGTERM"); + + if (graceMs <= 0 || !isAlive()) return; + await waitForExitOrTimeout(opts.waitForExit, graceMs); + if (!isAlive()) return; + + log.warn("rewrite child ignored SIGTERM; escalating to SIGKILL", { + graceMs, + }); + signalTree("SIGKILL"); + + // Wait for confirmed exit so callers do not process.exit while AG is + // mid-write (truncated mcp.json). Cap at the same grace window. + if (graceMs <= 0 || !isAlive()) return; + await waitForExitOrTimeout(opts.waitForExit, graceMs); +} + +/** + * @param {Promise | undefined} exited + * @param {number} graceMs + */ +function waitForExitOrTimeout(exited, graceMs) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, graceMs); + exited?.then( + () => { + clearTimeout(timer); + resolve(undefined); + }, + () => { + clearTimeout(timer); + resolve(undefined); + }, + ); + }); +} + +/** + * @param {NodeJS.ProcessEnv} [env] + */ +export function resolveAgentGuardNpmRegistry(env = process.env) { + const fromEnv = env.JFROG_AGENT_GUARD_REPO?.trim(); + return fromEnv || DEFAULT_AGENT_GUARD_NPM_REGISTRY; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + */ +export function resolveAgentGuardSpec(env = process.env) { + const version = + env.JFROG_AGENT_GUARD_VERSION?.trim() || DEFAULT_AGENT_GUARD_VERSION; + return `${AGENT_GUARD_PACKAGE}@${version}`; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {string | undefined} + */ +export function resolveAgentGuardBin(env = process.env) { + return env[AGENT_GUARD_BIN_ENV]?.trim() || undefined; +} + +/** + * @param {{ + * paths: string[], + * project: string, + * serverId: string, + * agSpec: string, + * statSyncFn?: typeof statSync, + * }} opts + * @returns {string} + */ +export function computeRewriteFingerprint(opts) { + const statFn = opts.statSyncFn ?? statSync; + const pathParts = [...(opts.paths ?? [])].sort().map((p) => { + try { + const st = statFn(p); + return `${p}:${st.mtimeMs}:${st.size}`; + } catch { + return `${p}:missing`; + } + }); + const payload = JSON.stringify({ + paths: pathParts, + project: opts.project, + serverId: opts.serverId, + agSpec: opts.agSpec, + }); + return createHash("sha256").update(payload).digest("hex"); +} + +/** + * @param {string} markerPath + * @param {{ readFileSyncFn?: typeof readFileSync }} [opts] + * @returns {string} + */ +export function readRewriteMarker(markerPath, opts = {}) { + const readFn = opts.readFileSyncFn ?? readFileSync; + try { + return String(readFn(markerPath, "utf8")).trim(); + } catch { + return ""; + } +} + +/** + * @param {string} markerPath + * @param {string} fingerprint + * @param {{ writeFileSyncFn?: typeof writeFileSync, mkdirSyncFn?: typeof mkdirSync }} [opts] + */ +export function writeRewriteMarker(markerPath, fingerprint, opts = {}) { + const writeFn = opts.writeFileSyncFn ?? writeFileSync; + const mkdirFn = opts.mkdirSyncFn ?? mkdirSync; + mkdirFn(path.dirname(markerPath), { recursive: true }); + writeFn(markerPath, `${fingerprint}\n`, "utf8"); +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * }} opts + * @returns {string[]} + * @throws {Error} when project/server missing or paths are empty + */ +export function buildAgentGuardRewriteArgs(opts) { + const env = opts.env ?? process.env; + const paths = opts.paths ?? []; + if (paths.length === 0) { + throw new Error("rewrite-mcp-json requires at least one mcp.json path"); + } + const project = opts.project?.trim() || resolveRewriteProject(env, {}); + if (!project) { + throw new Error("rewrite-mcp-json requires --project (or JF_PROJECT)"); + } + assertSafeRewriteIdentifier(project, "project"); + + const args = ["--rewrite-mcp-json", ...paths, "--project", project]; + + const server = + opts.serverId !== undefined + ? opts.serverId.trim() + : resolveRewriteServerId(env); + if (!server) { + throw new Error("rewrite-mcp-json requires --server (or JF_SERVER)"); + } + assertSafeRewriteIdentifier(server, "server"); + args.push("--server", server); + + const agentGuardRegistry = env.JFROG_AGENT_GUARD_REPO?.trim(); + if (agentGuardRegistry) { + args.push("--registry", agentGuardRegistry); + } + + for (const root of opts.allowRoots ?? []) { + if (root) args.push("--allow-root", root); + } + + args.push("--format", "json"); + return args; +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * }} opts + * @returns {string[]} + */ +export function buildNpxArgs(opts) { + const env = opts.env ?? process.env; + return [ + "--yes", + "--registry", + resolveAgentGuardNpmRegistry(env), + resolveAgentGuardSpec(env), + ...buildAgentGuardRewriteArgs(opts), + ]; +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * platform?: NodeJS.Platform, + * }} opts + * @returns {{ command: string, args: string[], local: boolean }} + */ +export function resolveAgentGuardCommand(opts) { + const env = opts.env ?? process.env; + const platform = opts.platform ?? process.platform; + const bin = resolveAgentGuardBin(env); + if (bin) { + return { + command: bin, + args: buildAgentGuardRewriteArgs(opts), + local: true, + }; + } + return { + command: resolveNpxCommand(platform), + args: buildNpxArgs(opts), + local: false, + }; +} + +/** + * Spawn Agent Guard `--rewrite-mcp-json`. AG writes files; stdout is JSON + * summary when `--format json` is passed. + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * spawnFn?: typeof spawn, + * env?: NodeJS.ProcessEnv, + * timeoutMs?: number, + * graceMs?: number, + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * }} opts + * @returns {Promise<{ code: number, stdout: string, stderr: string }>} + */ +export function runAgentGuardRewriteMcpJson(opts) { + const spawnFn = opts.spawnFn ?? spawn; + const env = opts.env ?? process.env; + const timeoutMs = + opts.timeoutMs === undefined ? DEFAULT_REWRITE_TIMEOUT_MS : opts.timeoutMs; + const platform = opts.platform ?? process.platform; + + let command; + let args; + let spawnOpts; + try { + const resolved = resolveAgentGuardCommand({ + paths: opts.paths, + project: opts.project, + serverId: opts.serverId, + allowRoots: opts.allowRoots, + env, + platform, + }); + command = resolved.command; + spawnOpts = buildNpxSpawnOptions(env, platform, { local: resolved.local }); + args = spawnOpts.shell + ? quoteSpawnArgs(resolved.args, platform) + : resolved.args; + } catch (err) { + return Promise.resolve({ + code: 1, + stdout: "", + stderr: err?.message ?? String(err), + }); + } + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let exited = false; + let timedOut = false; + let markExited = () => {}; + const exitedPromise = new Promise((r) => { + markExited = r; + }); + /** @type {ReturnType | undefined} */ + let timer; + const finish = (result) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve(result); + }; + + let child; + try { + child = spawnFn(command, args, spawnOpts); + } catch (err) { + finish({ + code: 1, + stdout: "", + stderr: err?.message ?? String(err), + }); + return; + } + + child.stdout?.setEncoding?.("utf8"); + child.stderr?.setEncoding?.("utf8"); + child.stdout?.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (err) => { + exited = true; + markExited(); + finish({ + code: 1, + stdout, + stderr: err?.message ?? String(err), + }); + }); + child.on("close", (code) => { + exited = true; + markExited(); + if (timedOut) return; + finish({ code: code ?? 1, stdout, stderr }); + }); + + child.stdin?.on?.("error", () => {}); + try { + child.stdin?.end(); + } catch { + // Child may already have exited. + } + + if (timeoutMs > 0) { + timer = setTimeout(() => { + timedOut = true; + const finishTimedOut = () => { + finish({ + code: 1, + stdout, + stderr: `${stderr ? `${stderr.trim()}\n` : ""}rewrite timed out after ${timeoutMs}ms`, + }); + }; + killRewriteChildTree(child, { + platform, + killFn: opts.killFn, + spawnFn, + graceMs: opts.graceMs, + isAlive: () => !exited, + waitForExit: exitedPromise, + }).then(finishTimedOut, finishTimedOut); + }, timeoutMs); + } + }); +} + +/** + * @param {string} text + * @returns {Record | null} + */ +function tryParseJsonObject(text) { + try { + const parsed = JSON.parse(text); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return null; + } + return parsed; + } catch { + return null; + } +} + +/** + * Parse AG `--format json` summary. Tolerates leading npx noise by trying the + * last non-empty line, then the last `{...}` slice. + * @param {string} raw + * @returns {{ scanned?: number, rewritten?: number, files?: string[], errors?: string[], dryRun?: boolean } | null} + */ +export function parseRewriteMcpJsonResult(raw) { + if (typeof raw !== "string" || !raw.trim()) return null; + const trimmed = raw.trim(); + const direct = tryParseJsonObject(trimmed); + if (direct) return direct; + + const lines = trimmed + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + for (let i = lines.length - 1; i >= 0; i--) { + const parsed = tryParseJsonObject(lines[i]); + if (parsed) return parsed; + } + + const start = trimmed.lastIndexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + return tryParseJsonObject(trimmed.slice(start, end + 1)); + } + return null; +} + +/** + * Strip userinfo from URLs before logging. + * @param {string} text + * @returns {string} + */ +export function redactUrlCredentials(text) { + return String(text ?? "").replace( + /([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, + "$1***@", + ); +} + +/** + * Orchestration: kill switch → server/project → discover → skip-if-current → + * Step 0 gate → rewrite. Server id is resolved once and reused for both the + * gate and AG `--server` (always passed). Returns a structured result; exitCode + * is 0 unless JF_AGENT_REWRITE_MCP_JSON_STRICT=1 and outcome is failed_*. + * + * @param {{ + * discover: () => string[] | Promise, + * allowRoots?: string[] | ((paths: string[]) => string[]), + * env?: NodeJS.ProcessEnv, + * spawnFn?: typeof spawn, + * spawnSyncFn?: typeof spawnSync, + * timeoutMs?: number, + * graceMs?: number, + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * runAgentGuardCheckFn?: typeof runAgentGuardCheck, + * readFileSyncFn?: typeof readFileSync, + * writeFileSyncFn?: typeof writeFileSync, + * mkdirSyncFn?: typeof mkdirSync, + * statSyncFn?: typeof statSync, + * serverIdHint?: string, + * markerPath?: string, + * setupPath?: string, + * }} opts + * @returns {Promise<{ exitCode: number, outcome: string, reason: string }>} + */ +export async function runRewriteMcpJsonPipeline(opts) { + const env = opts.env ?? process.env; + const checkFn = opts.runAgentGuardCheckFn ?? runAgentGuardCheck; + + if (isRewriteDisabled(env)) { + log.info("rewrite disabled via env", { env: DISABLE_ENV }); + return pipelineResult(OUTCOME.DISABLED, DISABLE_ENV, env); + } + + const serverResolved = resolveRewriteServer(env, { + serverIdHint: opts.serverIdHint, + spawnSyncFn: opts.spawnSyncFn, + }); + if ("error" in serverResolved) { + const reason = + serverResolved.error === "no_default" + ? "multiple jf config servers and none isDefault" + : "no jf config server / JF_SERVER"; + log.info("rewrite skipped; missing server", { reason }); + return pipelineResult(OUTCOME.SKIPPED_NO_SERVER, reason, env); + } + const { serverId, jpdUrl } = serverResolved; + if (!isSafeRewriteIdentifier(serverId)) { + log.info("rewrite skipped; unsafe server id", {}); + return pipelineResult( + OUTCOME.SKIPPED_UNSAFE_SERVER, + "unsafe server id", + env, + ); + } + + const project = resolveRewriteProject(env, { + serverId, + jpdUrl, + readFileSyncFn: opts.readFileSyncFn, + setupPath: opts.setupPath, + }); + if (!project) { + log.info("rewrite skipped; missing project", {}); + return pipelineResult( + OUTCOME.SKIPPED_NO_PROJECT, + "missing JF_PROJECT / setup.json currentActiveProject", + env, + ); + } + if (!isSafeRewriteIdentifier(project)) { + log.info("rewrite skipped; unsafe JF_PROJECT", {}); + return pipelineResult( + OUTCOME.SKIPPED_UNSAFE_PROJECT, + "unsafe project", + env, + ); + } + + let paths; + try { + paths = await opts.discover(); + } catch (err) { + const reason = err?.message ?? String(err); + log.error("discover failed; soft no-op", { error: reason }); + return pipelineResult(OUTCOME.FAILED_DISCOVER, reason, env); + } + + if (!Array.isArray(paths) || paths.length === 0) { + log.info("no mcp.json files found; skip rewrite"); + return pipelineResult(OUTCOME.SKIPPED_NO_PATHS, "no mcp.json", env); + } + + const agSpec = resolveAgentGuardSpec(env); + const fingerprint = computeRewriteFingerprint({ + paths, + project, + serverId, + agSpec, + statSyncFn: opts.statSyncFn, + }); + const markerPath = opts.markerPath ?? defaultRewriteMarkerPath(env); + if ( + !isRewriteForced(env) && + readRewriteMarker(markerPath, { readFileSyncFn: opts.readFileSyncFn }) === + fingerprint + ) { + log.info("rewrite skipped; already current", { markerPath }); + return pipelineResult(OUTCOME.SKIPPED_CURRENT, markerPath, env); + } + + let gate; + try { + gate = await checkFn({ + serverId, + env, + }); + } catch (err) { + const reason = redactUrlCredentials(err?.message ?? String(err)); + log.error("agent-guard check threw; soft no-op", { error: reason }); + return pipelineResult(OUTCOME.FAILED_GATE, reason, env); + } + if (gate.code !== EXIT_ENABLED) { + const reason = redactUrlCredentials(gate.reason ?? ""); + log.info("agent-guard check blocked rewrite; soft no-op", { + code: gate.code, + reason, + }); + return pipelineResult(OUTCOME.SKIPPED_GATE, reason, env); + } + + let allowRoots; + try { + allowRoots = + typeof opts.allowRoots === "function" + ? opts.allowRoots(paths) + : (opts.allowRoots ?? []); + } catch (err) { + const reason = redactUrlCredentials(err?.message ?? String(err)); + log.error("allowRoots failed; soft no-op", { error: reason }); + return pipelineResult(OUTCOME.FAILED_ALLOW_ROOTS, reason, env); + } + + log.info("rewrite-mcp-json targets", { + count: paths.length, + allowRoots: allowRoots.length, + outcome: "rewrite", + }); + + const budgetMs = + opts.timeoutMs === undefined ? DEFAULT_REWRITE_TIMEOUT_MS : opts.timeoutMs; + const startedAtMs = Date.now(); + const result = await runAgentGuardRewriteMcpJson({ + paths, + project, + serverId, + allowRoots, + env, + spawnFn: opts.spawnFn, + timeoutMs: budgetMs, + graceMs: opts.graceMs, + platform: opts.platform, + killFn: opts.killFn, + }); + const durMs = Date.now() - startedAtMs; + + if (result.code !== 0) { + const reason = redactUrlCredentials((result.stderr || "").trim()).slice( + 0, + 500, + ); + log.error("rewrite-mcp-json failed", { + code: result.code, + stderr: reason, + durMs, + outcome: OUTCOME.FAILED_SPAWN, + }); + return pipelineResult(OUTCOME.FAILED_SPAWN, reason, env); + } + + const postFingerprint = computeRewriteFingerprint({ + paths, + project, + serverId, + agSpec, + statSyncFn: opts.statSyncFn, + }); + try { + writeRewriteMarker(markerPath, postFingerprint, { + writeFileSyncFn: opts.writeFileSyncFn, + mkdirSyncFn: opts.mkdirSyncFn, + }); + } catch (err) { + log.warn("rewrite marker write failed", { + markerPath, + error: err?.message ?? String(err), + }); + } + + const summary = parseRewriteMcpJsonResult(result.stdout); + if (summary) { + log.info("rewrite-mcp-json ok", { + scanned: summary.scanned, + rewritten: summary.rewritten, + errors: summary.errors?.length ?? 0, + durMs, + outcome: OUTCOME.REWRITTEN, + }); + } else { + log.info("rewrite-mcp-json ok; no JSON summary", { + durMs, + outcome: OUTCOME.REWRITTEN, + }); + } + + return pipelineResult(OUTCOME.REWRITTEN, "", env); +} diff --git a/plugin/scripts/vscode-align-mcp-json.mjs b/plugin/scripts/vscode-align-mcp-json.mjs new file mode 100644 index 0000000..6bc8687 --- /dev/null +++ b/plugin/scripts/vscode-align-mcp-json.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import process from "node:process"; + +import { isMainEntry } from "../modules/core/entry.mjs"; +import { detectHarness, readStdin } from "../modules/core/io.mjs"; +import { runRewriteMcpJsonPipeline } from "../modules/core/rewrite-mcp-json.mjs"; +import { + allowRootsForMcpJson, + discoverVscodeMcpJson, +} from "./vscode-mcp-json-discover.mjs"; + +const HARNESS_ID = "copilot"; +export const RECONNECT_CONTEXT = + "JFrog Agent Guard secured your plugins' MCP servers. Run Developer: Reload Window to reconnect."; + +export const RECOMMENDED_HOOK_TIMEOUT_SEC = 60; + +function noOp() { + return { exitCode: 0, stdout: "{}" }; +} + +function contentFingerprint(configPath) { + try { + return createHash("sha256") + .update(readFileSync(configPath)) + .digest("hex"); + } catch { + return null; + } +} + +export async function runVscodeAlignMcpJson(options = {}) { + try { + if (options.mode !== "session-start") return noOp(); + const harness = detectHarness(options.stdinRaw ?? ""); + if (harness && harness !== HARNESS_ID) return noOp(); + + const env = options.env ?? process.env; + const discover = + options.discover ?? (() => discoverVscodeMcpJson({ env })); + const pipeline = options.pipeline ?? runRewriteMcpJsonPipeline; + let discoveredPaths = []; + let before = new Map(); + await pipeline({ + discover: async () => { + discoveredPaths = await discover(); + before = new Map( + discoveredPaths.map((configPath) => [ + configPath, + contentFingerprint(configPath), + ]), + ); + return discoveredPaths; + }, + allowRoots: allowRootsForMcpJson, + env, + }); + const rewritten = discoveredPaths.some( + (configPath) => + before.get(configPath) !== contentFingerprint(configPath), + ); + if (!rewritten) return noOp(); + + return { + exitCode: 0, + stdout: JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: RECONNECT_CONTEXT, + }, + }), + }; + } catch { + return noOp(); + } +} + +async function main() { + const result = await runVscodeAlignMcpJson({ + mode: process.argv[2], + stdinRaw: await readStdin(), + }); + process.stdout.write(result.stdout); + process.exitCode = 0; +} + +if (isMainEntry(import.meta.url)) { + main().catch(() => { + process.stdout.write("{}"); + process.exitCode = 0; + }); +} diff --git a/plugin/scripts/vscode-align-mcp-json.test.mjs b/plugin/scripts/vscode-align-mcp-json.test.mjs new file mode 100644 index 0000000..b321103 --- /dev/null +++ b/plugin/scripts/vscode-align-mcp-json.test.mjs @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + RECOMMENDED_HOOK_TIMEOUT_SEC, + runVscodeAlignMcpJson, +} from "./vscode-align-mcp-json.mjs"; + +const COPILOT_INPUT = JSON.stringify({ + hook_event_name: "SessionStart", + source: "new", + session_id: "test-session", + cwd: "/workspace", +}); + +test("forwards discovered paths and their plugin roots to the shared pipeline", async () => { + const root = mkdtempSync(path.join(tmpdir(), "vscode-align-roots-")); + const paths = [ + path.join(root, "a", "mcp.json"), + path.join(root, "b", ".mcp.json"), + ]; + mkdirSync(path.dirname(paths[0]), { recursive: true }); + mkdirSync(path.dirname(paths[1]), { recursive: true }); + writeFileSync(paths[0], "{}"); + writeFileSync(paths[1], "{}"); + let received; + + const result = await runVscodeAlignMcpJson({ + mode: "session-start", + stdinRaw: COPILOT_INPUT, + discover: () => paths, + pipeline: async (options) => { + received = { + paths: await options.discover(), + allowRoots: options.allowRoots(paths), + }; + return { exitCode: 0, outcome: "skipped_current", reason: "" }; + }, + }); + + assert.deepEqual(received, { + paths, + allowRoots: [path.join(root, "a"), path.join(root, "b")].map((entry) => + realpathSync(entry), + ), + }); + assert.equal(result.stdout, "{}"); + assert.equal(result.exitCode, 0); +}); + +test("emits exact Copilot reconnect context after a rewrite", async () => { + const root = mkdtempSync(path.join(tmpdir(), "vscode-align-")); + const configPath = path.join(root, "mcp.json"); + writeFileSync(configPath, '{"mcpServers":{}}\n'); + const result = await runVscodeAlignMcpJson({ + mode: "session-start", + stdinRaw: COPILOT_INPUT, + discover: () => [configPath], + pipeline: async (options) => { + await options.discover(); + writeFileSync(configPath, '{"mcpServers":{"secured":{}}}\n'); + return { + exitCode: 0, + outcome: "rewritten", + reason: "", + }; + }, + }); + + assert.deepEqual(JSON.parse(result.stdout), { + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: + "JFrog Agent Guard secured your plugins' MCP servers. Run Developer: Reload Window to reconnect.", + }, + }); + assert.equal(result.exitCode, 0); +}); + +test("successful Agent Guard run with zero changed files is a no-op", async () => { + const root = mkdtempSync(path.join(tmpdir(), "vscode-align-")); + const configPath = path.join(root, "mcp.json"); + writeFileSync(configPath, '{"mcpServers":{}}\n'); + + const result = await runVscodeAlignMcpJson({ + mode: "session-start", + stdinRaw: COPILOT_INPUT, + discover: () => [configPath], + pipeline: async (options) => { + await options.discover(); + return { + exitCode: 0, + outcome: "rewritten", + reason: "", + }; + }, + }); + + assert.deepEqual(result, { exitCode: 0, stdout: "{}" }); +}); + +test("unknown mode and harness mismatch are soft no-ops", async () => { + let calls = 0; + const pipeline = async () => { + calls += 1; + return { exitCode: 1, outcome: "failed_spawn", reason: "boom" }; + }; + + const unknown = await runVscodeAlignMcpJson({ + mode: "other", + stdinRaw: COPILOT_INPUT, + pipeline, + }); + const mismatch = await runVscodeAlignMcpJson({ + mode: "session-start", + stdinRaw: JSON.stringify({ + hook_event_name: "SessionStart", + source: "startup", + }), + pipeline, + }); + + assert.deepEqual(unknown, { exitCode: 0, stdout: "{}" }); + assert.deepEqual(mismatch, { exitCode: 0, stdout: "{}" }); + assert.equal(calls, 0); +}); + +test("pipeline failure after changing bytes still emits reconnect guidance", async () => { + const root = mkdtempSync(path.join(tmpdir(), "vscode-align-failed-")); + const configPath = path.join(root, "mcp.json"); + writeFileSync(configPath, '{"mcpServers":{}}\n'); + const result = await runVscodeAlignMcpJson({ + mode: "session-start", + stdinRaw: COPILOT_INPUT, + discover: () => [configPath], + pipeline: async (options) => { + await options.discover(); + writeFileSync(configPath, '{"mcpServers":{"partiallySecured":{}}}\n'); + return { + exitCode: 1, + outcome: "failed_spawn", + reason: "failed", + }; + }, + }); + + assert.equal(result.exitCode, 0); + assert.equal( + JSON.parse(result.stdout).hookSpecificOutput.additionalContext, + "JFrog Agent Guard secured your plugins' MCP servers. Run Developer: Reload Window to reconnect.", + ); +}); + +test("pipeline failure without changed bytes is a no-op", async () => { + const root = mkdtempSync(path.join(tmpdir(), "vscode-align-failed-")); + const configPath = path.join(root, "mcp.json"); + writeFileSync(configPath, '{"mcpServers":{}}\n'); + const result = await runVscodeAlignMcpJson({ + mode: "session-start", + stdinRaw: COPILOT_INPUT, + discover: () => [configPath], + pipeline: async (options) => { + await options.discover(); + return { + exitCode: 1, + outcome: "failed_timeout", + reason: "timeout", + }; + }, + }); + + assert.deepEqual(result, { exitCode: 0, stdout: "{}" }); +}); + +test("recommended hook timeout leaves rewrite, gate, and grace headroom", () => { + assert.equal(RECOMMENDED_HOOK_TIMEOUT_SEC, 60); + assert.ok(RECOMMENDED_HOOK_TIMEOUT_SEC * 1000 > 35_000 + 5_000 + 2_000); + + const pluginRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ); + const config = JSON.parse( + readFileSync(path.join(pluginRoot, "hooks", "hooks.json"), "utf8"), + ); + const hooks = config.hooks.SessionStart.flatMap((entry) => entry.hooks); + const align = hooks.find((hook) => + hook.command.includes("vscode-align-mcp-json.mjs"), + ); + assert.deepEqual(align, { + type: "command", + command: + 'node "${CLAUDE_PLUGIN_ROOT}/scripts/vscode-align-mcp-json.mjs" session-start', + timeout: RECOMMENDED_HOOK_TIMEOUT_SEC, + statusMessage: "Securing plugin MCP servers with JFrog Agent Guard…", + }); +}); diff --git a/plugin/scripts/vscode-mcp-json-discover.mjs b/plugin/scripts/vscode-mcp-json-discover.mjs new file mode 100644 index 0000000..ea2171f --- /dev/null +++ b/plugin/scripts/vscode-mcp-json-discover.mjs @@ -0,0 +1,237 @@ +import { + lstatSync, + readdirSync, + realpathSync, + statSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const CONFIG_NAMES = ["mcp.json", ".mcp.json"]; + +export function parseDiscoveryRoots(value, platform = process.platform) { + if (!value?.trim()) return []; + const delimiter = platform === "win32" ? /[;,]/ : /[:,]/; + return value + .split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function platformVsCodeUserDir(home, env, platform) { + if (platform === "darwin") { + return path.join( + home, + "Library", + "Application Support", + "Code", + "User", + ); + } + if (platform === "win32") { + return env.APPDATA ? path.join(env.APPDATA, "Code", "User") : null; + } + const configHome = env.XDG_CONFIG_HOME || path.join(home, ".config"); + return path.join(configHome, "Code", "User"); +} + +function isContained(root, candidate) { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +function safeRealpath(candidate) { + try { + return realpathSync(candidate); + } catch { + return null; + } +} + +function isWorkspaceVscodeDirectory(directory) { + return path.basename(directory).toLowerCase() === ".vscode"; +} + +function isInsideVsCodeUserDir(candidate, userDir) { + if (!userDir) return false; + const logicalUser = path.resolve(userDir); + const logicalCandidate = path.resolve(candidate); + if (isContained(logicalUser, logicalCandidate)) return true; + const realUser = safeRealpath(logicalUser); + const realCandidate = safeRealpath(candidate); + return Boolean( + realUser && realCandidate && isContained(realUser, realCandidate), + ); +} + +function isVsCodeUserTree(directory, realDirectory, userDir) { + return ( + isInsideVsCodeUserDir(directory, userDir) || + (Boolean(realDirectory) && isInsideVsCodeUserDir(realDirectory, userDir)) + ); +} + +function collectRoot(root, maxDepth, output, seen, userDir) { + const realRoot = safeRealpath(root); + if (!realRoot) return; + if (isVsCodeUserTree(root, realRoot, userDir)) return; + + function visit(directory, depth) { + const realDirectory = safeRealpath(directory); + if (!realDirectory || !isContained(realRoot, realDirectory)) return; + if (isVsCodeUserTree(directory, realDirectory, userDir)) return; + + const deniedWorkspace = + isWorkspaceVscodeDirectory(directory) || + isWorkspaceVscodeDirectory(realDirectory); + if (deniedWorkspace && depth > 0) return; + + let containsConfig = false; + if (!deniedWorkspace) { + for (const name of CONFIG_NAMES) { + const candidate = path.join(directory, name); + try { + lstatSync(candidate); + containsConfig = true; + } catch { + continue; + } + const realCandidate = safeRealpath(candidate); + const realParent = realCandidate + ? path.dirname(realCandidate) + : null; + if ( + !realCandidate || + !isContained(realRoot, realCandidate) || + isWorkspaceVscodeDirectory(realParent) || + isInsideVsCodeUserDir(realParent, userDir) || + seen.has(realCandidate) + ) { + continue; + } + try { + if (!statSync(candidate).isFile()) continue; + } catch { + continue; + } + seen.add(realCandidate); + output.push(candidate); + } + } + + if (containsConfig || depth >= maxDepth) return; + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .sort((left, right) => { + if (left.name === "_direct") return 1; + if (right.name === "_direct") return -1; + return left.name.localeCompare(right.name); + }); + } catch { + return; + } + for (const entry of entries) { + visit(path.join(directory, entry.name), depth + 1); + } + } + + visit(root, 0); +} + +/** + * Plugin root is the parent of `scripts/` (where this file lives). + * @param {string} [moduleUrl] + */ +export function resolvePluginRoot(moduleUrl = import.meta.url) { + return path.dirname(path.dirname(fileURLToPath(moduleUrl))); +} + +function addSelfConfigs(output, seen, userDir, moduleUrl) { + const pluginRoot = resolvePluginRoot(moduleUrl); + for (const name of CONFIG_NAMES) { + const candidate = path.join(pluginRoot, name); + try { + lstatSync(candidate); + } catch { + continue; + } + const realCandidate = safeRealpath(candidate); + const realParent = realCandidate ? path.dirname(realCandidate) : null; + if ( + !realCandidate || + !realParent || + isWorkspaceVscodeDirectory(realParent) || + isInsideVsCodeUserDir(realParent, userDir) || + seen.has(realCandidate) + ) { + continue; + } + try { + if (!statSync(candidate).isFile()) continue; + } catch { + continue; + } + seen.add(realCandidate); + output.push(candidate); + } +} + +export function discoverVscodeMcpJson(options = {}) { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const home = options.home ?? env.HOME ?? homedir(); + const userDir = platformVsCodeUserDir(home, env, platform); + const override = parseDiscoveryRoots( + env.JF_ALIGN_MCP_JSON_ROOTS, + platform, + ); + const output = []; + const seen = new Set(); + const includeSelf = options.includeSelf !== false; + const moduleUrl = options.moduleUrl ?? import.meta.url; + + if (override.length) { + for (const root of override) { + collectRoot(path.resolve(root), 4, output, seen, userDir); + } + return output; + } + + collectRoot( + path.join(home, ".copilot", "installed-plugins"), + 2, + output, + seen, + userDir, + ); + collectRoot( + path.join(home, ".vscode", "agent-plugins"), + 4, + output, + seen, + userDir, + ); + if (includeSelf) { + addSelfConfigs(output, seen, userDir, moduleUrl); + } + return output; +} + +export function allowRootsForMcpJson(paths) { + const roots = []; + const seen = new Set(); + for (const configPath of paths) { + const root = safeRealpath(path.dirname(configPath)); + if (!root || seen.has(root)) continue; + seen.add(root); + roots.push(root); + } + return roots; +} diff --git a/plugin/scripts/vscode-mcp-json-discover.test.mjs b/plugin/scripts/vscode-mcp-json-discover.test.mjs new file mode 100644 index 0000000..1b54a9b --- /dev/null +++ b/plugin/scripts/vscode-mcp-json-discover.test.mjs @@ -0,0 +1,501 @@ +import assert from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + realpathSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; + +import { + allowRootsForMcpJson, + discoverVscodeMcpJson, + parseDiscoveryRoots, +} from "./vscode-mcp-json-discover.mjs"; + +function file(root, relative) { + const target = path.join(root, relative); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, "{}\n"); + return target; +} + +function pluginModuleUrl(pluginRoot) { + return pathToFileURL(path.join(pluginRoot, "scripts", "vscode-mcp-json-discover.mjs")) + .href; +} + +test("discovers Copilot installed-plugin and VS Code agent-plugin configs", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-discover-")); + const expected = [ + file( + home, + ".copilot/installed-plugins/marketplace/plugin/mcp.json", + ), + file( + home, + ".copilot/installed-plugins/_direct/direct-id/.mcp.json", + ), + file(home, ".vscode/agent-plugins/github.com/org/repo/plugin/mcp.json"), + ]; + file( + home, + "Library/Application Support/Code/agentPlugins/github.com/org/repo/plugin/.mcp.json", + ); + file(home, "cache/copilot/marketplaces/marketplace/plugin/mcp.json"); + file(home, "self/mcp.json"); + file(home, "self/.mcp.json"); + + const actual = discoverVscodeMcpJson({ + env: { + HOME: home, + COPILOT_CACHE_HOME: path.join(home, "cache", "copilot"), + }, + home, + platform: "darwin", + includeSelf: false, + }); + + assert.deepEqual(actual, expected); +}); + +test("includeSelf adds this plugin's mcp.json and .mcp.json", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-self-")); + const pluginRoot = path.join(home, "installed-jfrog"); + const wanted = [ + file(pluginRoot, "mcp.json"), + file(pluginRoot, ".mcp.json"), + ]; + file(home, "self/mcp.json"); + + const actual = discoverVscodeMcpJson({ + env: { HOME: home }, + home, + platform: "linux", + moduleUrl: pluginModuleUrl(pluginRoot), + }); + + assert.deepEqual(actual, wanted); +}); + +test("includeSelf is skipped when JF_ALIGN_MCP_JSON_ROOTS is set", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-self-override-")); + const pluginRoot = path.join(home, "installed-jfrog"); + file(pluginRoot, "mcp.json"); + file(pluginRoot, ".mcp.json"); + const overrideRoot = path.join(home, "override"); + const wanted = file(overrideRoot, "plugin/mcp.json"); + + const actual = discoverVscodeMcpJson({ + env: { + HOME: home, + JF_ALIGN_MCP_JSON_ROOTS: overrideRoot, + }, + home, + platform: "linux", + moduleUrl: pluginModuleUrl(pluginRoot), + }); + + assert.deepEqual(actual, [wanted]); +}); + +test("includeSelf deduplicates configs already found under agent-plugins", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-self-dedupe-")); + const pluginRoot = path.join( + home, + ".vscode/agent-plugins/github.com/jfrog/vscode-plugin/plugin", + ); + const wanted = file(pluginRoot, ".mcp.json"); + + const actual = discoverVscodeMcpJson({ + env: { HOME: home }, + home, + platform: "linux", + moduleUrl: pluginModuleUrl(pluginRoot), + }); + + assert.deepEqual(actual, [wanted]); +}); + +test("roots override skips defaults and self while deduplicating configs", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-override-")); + const first = path.join(home, "first"); + const second = path.join(home, "second"); + const wanted = file(first, "plugin/mcp.json"); + file(home, ".copilot/installed-plugins/market/plugin/mcp.json"); + file(path.join(home, "self"), "mcp.json"); + symlinkSync(first, second); + + const actual = discoverVscodeMcpJson({ + env: { + HOME: home, + JF_ALIGN_MCP_JSON_ROOTS: `${first},${second}`, + }, + home, + platform: "linux", + }); + + assert.deepEqual(actual, [wanted]); +}); + +test("defaults ignore marketplace cache even without skip-cache", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-cache-")); + const cacheConfig = file( + home, + ".cache/copilot/marketplaces/market/plugin/mcp.json", + ); + const installed = file( + home, + ".copilot/installed-plugins/market/plugin/mcp.json", + ); + + const actual = discoverVscodeMcpJson({ + env: { HOME: home }, + home, + platform: "linux", + includeSelf: false, + }); + + assert.deepEqual(actual, [installed]); + assert.ok(!actual.includes(cacheConfig)); +}); + +test("parses POSIX and Windows override delimiters without splitting drive colons", () => { + assert.deepEqual(parseDiscoveryRoots("/one:/two,/three", "linux"), [ + "/one", + "/two", + "/three", + ]); + assert.deepEqual( + parseDiscoveryRoots("C:\\one;D:\\two,E:\\three", "win32"), + ["C:\\one", "D:\\two", "E:\\three"], + ); +}); + +test("default discovery rejects symlinks escaping an allowed root", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-symlink-")); + const outside = mkdtempSync(path.join(tmpdir(), "vscode-mcp-outside-")); + file(outside, "mcp.json"); + const leaf = path.join( + home, + ".copilot/installed-plugins/marketplace/plugin", + ); + mkdirSync(path.dirname(leaf), { recursive: true }); + symlinkSync(outside, leaf); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home }, + home, + platform: "linux", + includeSelf: false, + }), + [], + ); +}); + +test("stops descending below the first plugin config", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-leaf-")); + const root = path.join(home, "override"); + const pluginConfig = file(root, "plugin/mcp.json"); + file(root, "plugin/.vscode/mcp.json"); + file(root, "plugin/fixtures/mcp.json"); + file(root, "plugin/node_modules/dependency/mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: root }, + home, + platform: "linux", + }), + [pluginConfig], + ); +}); + +test("defaults ignore platform Code/agentPlugins trees", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-code-user-plugin-")); + file( + home, + "Library/Application Support/Code/agentPlugins/github.com/code/user/plugin/mcp.json", + ); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home }, + home, + platform: "darwin", + includeSelf: false, + }), + [], + ); +}); + +test("override roots reject workspace MCP configs but keep github.com/code/user plugins", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-deny-")); + const root = path.join(home, "override"); + const wanted = [ + file(root, "Code/User/mcp.json"), + file(root, "github.com/code/user/plugin/mcp.json"), + file(root, "plugins/allowed/mcp.json"), + ]; + file(root, "project/.vscode/mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: root }, + home, + platform: "linux", + }), + wanted, + ); +}); + +test("override root pointing at a workspace .vscode directory is rejected", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-direct-vscode-")); + const root = path.join(home, "project", ".vscode"); + file(root, "mcp.json"); + file(root, ".mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: root }, + home, + platform: "linux", + }), + [], + ); +}); + +test("override root pointing at the platform Code/User directory is rejected", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-direct-user-")); + const root = path.join(home, ".config", "Code", "User"); + file(root, "mcp.json"); + file(root, "globalStorage/foo/mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: root }, + home, + platform: "linux", + }), + [], + ); +}); + +test("override of a Code parent excludes the platform User tree and nested storage", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-user-parent-")); + const root = path.join(home, ".config", "Code"); + const wanted = file(root, "agentPlugins/github.com/code/user/mcp.json"); + file(root, "User/mcp.json"); + file(root, "User/globalStorage/foo/mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: root }, + home, + platform: "linux", + }), + [wanted], + ); +}); + +test("Linux Code/User follows XDG_CONFIG_HOME for denial", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-xdg-user-")); + const xdg = path.join(home, "xdg-config"); + const userDir = path.join(xdg, "Code", "User"); + file(userDir, "mcp.json"); + file(userDir, "globalStorage/foo/mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { + HOME: home, + XDG_CONFIG_HOME: xdg, + JF_ALIGN_MCP_JSON_ROOTS: userDir, + }, + home, + platform: "linux", + }), + [], + ); +}); + +test("rejects directory symlinks whose realpath is inside platform Code/User", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-user-link-")); + const userDir = path.join(home, ".config", "Code", "User"); + const nested = file(userDir, "globalStorage/foo/mcp.json"); + const root = path.join(home, "override"); + mkdirSync(root, { recursive: true }); + symlinkSync(path.dirname(nested), path.join(root, "plugin")); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: root }, + home, + platform: "linux", + }), + [], + ); +}); + +test("override of the realpath of Code/User is rejected", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-user-real-")); + const actual = path.join(home, "actual-user"); + file(actual, "mcp.json"); + file(actual, "globalStorage/foo/mcp.json"); + const userDir = path.join(home, ".config", "Code", "User"); + mkdirSync(path.dirname(userDir), { recursive: true }); + symlinkSync(actual, userDir); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: actual }, + home, + platform: "linux", + }), + [], + ); +}); + +test("override root at ~/.vscode still yields agent plugin configs", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-vscode-root-")); + const root = path.join(home, ".vscode"); + file(root, "mcp.json"); + const wanted = file(root, "agent-plugins/github.com/org/repo/mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: root }, + home, + platform: "linux", + }), + [wanted], + ); +}); + +test("override roots reject directory symlinks that escape", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-override-link-")); + const root = path.join(home, "override"); + const outside = mkdtempSync(path.join(tmpdir(), "vscode-mcp-outside-")); + file(outside, "mcp.json"); + mkdirSync(root, { recursive: true }); + symlinkSync(outside, path.join(root, "escaped")); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: root }, + home, + platform: "linux", + }), + [], + ); +}); + +test("follows contained config symlinks and rejects config symlink escapes", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-file-link-")); + const root = path.join(home, "override"); + const canonical = file(root, "shared/config.json"); + const plugin = path.join(root, "plugin"); + mkdirSync(plugin, { recursive: true }); + symlinkSync(canonical, path.join(plugin, "mcp.json")); + + const outside = file(home, "outside.json"); + const escapedPlugin = path.join(root, "escaped-plugin"); + mkdirSync(escapedPlugin, { recursive: true }); + symlinkSync(outside, path.join(escapedPlugin, "mcp.json")); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: root }, + home, + platform: "linux", + }), + [path.join(plugin, "mcp.json")], + ); +}); + +test("does not overscan below a rejected config symlink", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-overscan-")); + const root = path.join(home, "override"); + const plugin = path.join(root, "plugin"); + const outside = file(home, "outside.json"); + mkdirSync(plugin, { recursive: true }); + symlinkSync(outside, path.join(plugin, "mcp.json")); + file(plugin, "fixtures/mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: root }, + home, + platform: "linux", + }), + [], + ); +}); + +test("allow roots are canonical directories and deduplicated", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-roots-")); + const canonical = path.join(home, "canonical"); + const alias = path.join(home, "alias"); + mkdirSync(canonical); + symlinkSync(canonical, alias); + + assert.deepEqual( + allowRootsForMcpJson([ + path.join(canonical, "mcp.json"), + path.join(alias, ".mcp.json"), + ]), + [realpathSync(canonical)], + ); +}); + +test("Windows Code/User under APPDATA is excluded from override discovery", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-win-user-")); + const appData = path.join(home, "AppData", "Roaming"); + const userDir = path.join(appData, "Code", "User"); + file(userDir, "mcp.json"); + file(userDir, "globalStorage/foo/mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { + HOME: home, + APPDATA: appData, + JF_ALIGN_MCP_JSON_ROOTS: userDir, + }, + home, + platform: "win32", + }), + [], + ); +}); + +test("Windows defaults use installed-plugins and .vscode/agent-plugins", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-windows-")); + const appData = path.join(home, "AppData", "Roaming"); + const localAppData = path.join(home, "AppData", "Local"); + const expected = [ + file(home, ".copilot/installed-plugins/org/plugin/mcp.json"), + file(home, ".vscode/agent-plugins/github.com/org/repo/plugin/mcp.json"), + ]; + file(appData, "Code/agentPlugins/org/plugin/mcp.json"); + file(localAppData, "copilot/marketplaces/org/plugin/.mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { + HOME: home, + APPDATA: appData, + LOCALAPPDATA: localAppData, + }, + home, + platform: "win32", + includeSelf: false, + }), + expected, + ); +}); diff --git a/scripts/validate-package-resolution-hook.mjs b/scripts/validate-package-resolution-hook.mjs index 27eeefa..531d5d7 100644 --- a/scripts/validate-package-resolution-hook.mjs +++ b/scripts/validate-package-resolution-hook.mjs @@ -24,11 +24,18 @@ const repoRoot = path.resolve( ); const pluginRoot = path.join(repoRoot, "plugin"); const adapter = path.join(pluginRoot, "modules", "copilot-session-start.mjs"); +const alignAdapter = path.join( + pluginRoot, + "scripts", + "vscode-align-mcp-json.mjs", +); const hooksFile = path.join(pluginRoot, "hooks", "hooks.json"); const manifestFile = path.join(pluginRoot, ".claude-plugin", "plugin.json"); const marketplaceFile = path.join(repoRoot, "marketplace.json"); const expectedCommand = 'node "${CLAUDE_PLUGIN_ROOT}/modules/copilot-session-start.mjs" package-resolution'; +const expectedAlignCommand = + 'node "${CLAUDE_PLUGIN_ROOT}/scripts/vscode-align-mcp-json.mjs" session-start'; // Anything a developer or CI step may already have exported that would steer // the hook away from the behaviour under test — a kill switch or a redirected @@ -99,15 +106,39 @@ function installFakeJf(home, { url = "https://validation.jfrog.io" } = {}) { } function startFakeArtifactory(port, countFile) { + const certFile = path.join(path.dirname(countFile), "localhost-cert.pem"); + const keyFile = path.join(path.dirname(countFile), "localhost-key.pem"); + execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + "/CN=127.0.0.1", + "-keyout", + keyFile, + "-out", + certFile, + "-days", + "1", + ], + { stdio: "ignore" }, + ); const server = spawn( process.execPath, [ "-e", ` - const http = require("node:http"); + const https = require("node:https"); const fs = require("node:fs"); const countFile = ${JSON.stringify(countFile)}; - http.createServer((req, res) => { + https.createServer({ + cert: fs.readFileSync(${JSON.stringify(certFile)}), + key: fs.readFileSync(${JSON.stringify(keyFile)}), + }, (req, res) => { if (req.url === "/artifactory/api/repositories/npm-virtual") { const count = Number(fs.readFileSync(countFile, "utf8") || "0") + 1; fs.writeFileSync(countFile, String(count)); @@ -246,6 +277,13 @@ function main() { execFileSync(process.execPath, ["--check", adapter], { stdio: "pipe" }); }); + check("MCP alignment adapter exists and parses", () => { + if (!existsSync(alignAdapter)) throw new Error(`missing: ${alignAdapter}`); + execFileSync(process.execPath, ["--check", alignAdapter], { + stdio: "pipe", + }); + }); + let manifest; let marketplacePlugin; check("plugin and marketplace versions match", () => { @@ -274,13 +312,17 @@ function main() { } }); - check("SessionStart runs only package resolution", () => { + check("SessionStart runs package resolution and MCP alignment", () => { const config = JSON.parse(readFileSync(hooksFile, "utf8")); const hooks = (config?.hooks?.SessionStart ?? []).flatMap( (entry) => entry.hooks ?? [], ); const commands = hooks.map((hook) => hook.command); - if (commands.length !== 1 || commands[0] !== expectedCommand) { + if ( + commands.length !== 2 || + commands[0] !== expectedCommand || + commands[1] !== expectedAlignCommand + ) { throw new Error( `unexpected SessionStart commands: ${JSON.stringify(commands)}`, ); @@ -290,6 +332,15 @@ function main() { `expected a 15-second hook timeout, got ${hooks[0]?.timeout}`, ); } + if ( + hooks[1]?.timeout !== 60 || + hooks[1]?.statusMessage !== + "Securing plugin MCP servers with JFrog Agent Guard…" + ) { + throw new Error( + `unexpected MCP alignment hook: ${JSON.stringify(hooks[1])}`, + ); + } }); check("adapter emits the unconfigured advisory when jf is absent", () => { @@ -324,7 +375,7 @@ function main() { }); try { const fakeJfBin = installFakeJf(home, { - url: `http://127.0.0.1:${port}`, + url: `https://127.0.0.1:${port}`, }); const context = additionalContextOf( runAdapter(home, { @@ -338,6 +389,8 @@ function main() { JF_AGENT_IDENTITY_PROBE: "0", JFROG_TEST_HARNESS: "1", JFROG_TEST_IDENTITY_PROBE: "skip", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + NODE_NO_WARNINGS: "1", JFROG_AGENT_HOOKS_LOG_FILE: path.join(home, "hook.log"), }), ); @@ -348,7 +401,7 @@ function main() { } if (!context.includes("npm-virtual")) { throw new Error( - `routing policy missing the verified repo key: ${context.slice(0, 200)}`, + `routing policy missing the verified repo key: ${context}`, ); } const verifyCount = readFileSync(verifyCountFile, "utf8"); From 9c5a5e088bff9de96c239a720eb4343fd4c52da3 Mon Sep 17 00:00:00 2001 From: arielam Date: Wed, 19 Aug 2026 15:37:22 +0300 Subject: [PATCH 2/4] MLD-1384 - Drop-unrelated-idea-files-and-https-validator-drive-by Co-authored-by: Cursor --- .gitignore | 1 + .idea/go.imports.xml | 10 -- .idea/vcs.xml | 6 -- .idea/vscode-plugin.iml | 9 -- .idea/workspace.xml | 97 -------------------- scripts/validate-package-resolution-hook.mjs | 34 +------ 6 files changed, 5 insertions(+), 152 deletions(-) create mode 100644 .gitignore delete mode 100644 .idea/go.imports.xml delete mode 100644 .idea/vcs.xml delete mode 100644 .idea/vscode-plugin.iml delete mode 100644 .idea/workspace.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f11b75 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml deleted file mode 100644 index 644cdf0..0000000 --- a/.idea/go.imports.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vscode-plugin.iml b/.idea/vscode-plugin.iml deleted file mode 100644 index d6ebd48..0000000 --- a/.idea/vscode-plugin.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index 3676624..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - { - "lastFilter": { - "state": "OPEN", - "assignee": "arielamitjfrog" - } -} - { - "selectedUrlAndAccountId": { - "url": "git@github.com:jfrog/vscode-plugin.git", - "accountId": "5d9d48cb-2ac9-4e7d-a356-ec1e841b48b5" - } -} - { - "associatedIndex": 2, - "fromUser": false -} - - - - - - - - - - - - - - - - - - - 1786026816239 - - - - - - - - - \ No newline at end of file diff --git a/scripts/validate-package-resolution-hook.mjs b/scripts/validate-package-resolution-hook.mjs index 531d5d7..4c08b00 100644 --- a/scripts/validate-package-resolution-hook.mjs +++ b/scripts/validate-package-resolution-hook.mjs @@ -106,39 +106,15 @@ function installFakeJf(home, { url = "https://validation.jfrog.io" } = {}) { } function startFakeArtifactory(port, countFile) { - const certFile = path.join(path.dirname(countFile), "localhost-cert.pem"); - const keyFile = path.join(path.dirname(countFile), "localhost-key.pem"); - execFileSync( - "openssl", - [ - "req", - "-x509", - "-newkey", - "rsa:2048", - "-nodes", - "-subj", - "/CN=127.0.0.1", - "-keyout", - keyFile, - "-out", - certFile, - "-days", - "1", - ], - { stdio: "ignore" }, - ); const server = spawn( process.execPath, [ "-e", ` - const https = require("node:https"); + const http = require("node:http"); const fs = require("node:fs"); const countFile = ${JSON.stringify(countFile)}; - https.createServer({ - cert: fs.readFileSync(${JSON.stringify(certFile)}), - key: fs.readFileSync(${JSON.stringify(keyFile)}), - }, (req, res) => { + http.createServer((req, res) => { if (req.url === "/artifactory/api/repositories/npm-virtual") { const count = Number(fs.readFileSync(countFile, "utf8") || "0") + 1; fs.writeFileSync(countFile, String(count)); @@ -375,7 +351,7 @@ function main() { }); try { const fakeJfBin = installFakeJf(home, { - url: `https://127.0.0.1:${port}`, + url: `http://127.0.0.1:${port}`, }); const context = additionalContextOf( runAdapter(home, { @@ -389,8 +365,6 @@ function main() { JF_AGENT_IDENTITY_PROBE: "0", JFROG_TEST_HARNESS: "1", JFROG_TEST_IDENTITY_PROBE: "skip", - NODE_TLS_REJECT_UNAUTHORIZED: "0", - NODE_NO_WARNINGS: "1", JFROG_AGENT_HOOKS_LOG_FILE: path.join(home, "hook.log"), }), ); @@ -401,7 +375,7 @@ function main() { } if (!context.includes("npm-virtual")) { throw new Error( - `routing policy missing the verified repo key: ${context}`, + `routing policy missing the verified repo key: ${context.slice(0, 200)}`, ); } const verifyCount = readFileSync(verifyCountFile, "utf8"); From c2ae734672fddcf944f822f940a7d1287b422031 Mon Sep 17 00:00:00 2001 From: arielam Date: Mon, 24 Aug 2026 10:49:15 +0300 Subject: [PATCH 3/4] MLD-1384 - Cover vs code runtime plugin --- README.md | 18 ++++++--- plugin/scripts/vscode-mcp-json-discover.mjs | 31 ++++++++++----- .../scripts/vscode-mcp-json-discover.test.mjs | 38 ++++++++++++++----- 3 files changed, 62 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0388200..a682db9 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,16 @@ chat. Discovery checks both `mcp.json` and `.mcp.json`, in that order, under `~/.copilot/installed-plugins/{marketplace}/{plugin}`, -`~/.copilot/installed-plugins/_direct/{id}`, and -`~/.vscode/agent-plugins/…`, plus this plugin's own configs next to the -adaptor. +`~/.copilot/installed-plugins/_direct/{id}`, +`~/.vscode/agent-plugins/…`, and the VS Code runtime plugin tree +(`~/Library/Application Support/Code/agentPlugins` on macOS, +`%APPDATA%\Code\agentPlugins` on Windows, `$XDG_CONFIG_HOME/Code/agentPlugins` +on Linux), plus this plugin's own configs next to the adaptor. + +VS Code loads plugin MCP servers from its own per-install copy under +`Code/agentPlugins`, so both that copy and the install tree it came from are +rewritten. Otherwise the running servers stay unsecured until VS Code re-copies +the plugin. Only plugin MCP configurations are considered. The hook never rewrites the user `Code/User/mcp.json` or a workspace `.vscode/mcp.json`. @@ -149,8 +156,9 @@ Environment controls: - `JF_AGENT_REWRITE_MCP_JSON_DISABLE=1` disables rewriting. - `JF_AGENT_REWRITE_MCP_JSON_FORCE=1` ignores the current-state marker and forces a refresh. -- `JF_ALIGN_MCP_JSON_ROOTS` replaces the default Copilot installed-plugins - and `~/.vscode/agent-plugins` roots (and skips this plugin's own configs). +- `JF_ALIGN_MCP_JSON_ROOTS` replaces the default Copilot installed-plugins, + `~/.vscode/agent-plugins`, and `Code/agentPlugins` roots (and skips this + plugin's own configs). Separate roots with colon or comma on macOS/Linux, and semicolon or comma on Windows. Overrides may point outside the default, but discovery still rejects `.vscode` and `Code/User` configs and symlinks escaping an diff --git a/plugin/scripts/vscode-mcp-json-discover.mjs b/plugin/scripts/vscode-mcp-json-discover.mjs index ea2171f..55d37e3 100644 --- a/plugin/scripts/vscode-mcp-json-discover.mjs +++ b/plugin/scripts/vscode-mcp-json-discover.mjs @@ -20,21 +20,25 @@ export function parseDiscoveryRoots(value, platform = process.platform) { .filter(Boolean); } -function platformVsCodeUserDir(home, env, platform) { +function platformVsCodeDir(home, env, platform) { if (platform === "darwin") { - return path.join( - home, - "Library", - "Application Support", - "Code", - "User", - ); + return path.join(home, "Library", "Application Support", "Code"); } if (platform === "win32") { - return env.APPDATA ? path.join(env.APPDATA, "Code", "User") : null; + return env.APPDATA ? path.join(env.APPDATA, "Code") : null; } const configHome = env.XDG_CONFIG_HOME || path.join(home, ".config"); - return path.join(configHome, "Code", "User"); + return path.join(configHome, "Code"); +} + +function platformVsCodeUserDir(home, env, platform) { + const codeDir = platformVsCodeDir(home, env, platform); + return codeDir ? path.join(codeDir, "User") : null; +} + +function platformVsCodeAgentPluginsDir(home, env, platform) { + const codeDir = platformVsCodeDir(home, env, platform); + return codeDir ? path.join(codeDir, "agentPlugins") : null; } function isContained(root, candidate) { @@ -218,6 +222,13 @@ export function discoverVscodeMcpJson(options = {}) { seen, userDir, ); + // VS Code loads plugin MCP servers from its own per-install copy under + // Code/agentPlugins, not from the install tree, so rewriting only the source + // leaves the running servers unsecured until VS Code re-copies. + const agentPluginsDir = platformVsCodeAgentPluginsDir(home, env, platform); + if (agentPluginsDir) { + collectRoot(agentPluginsDir, 4, output, seen, userDir); + } if (includeSelf) { addSelfConfigs(output, seen, userDir, moduleUrl); } diff --git a/plugin/scripts/vscode-mcp-json-discover.test.mjs b/plugin/scripts/vscode-mcp-json-discover.test.mjs index 1b54a9b..bb3bd7b 100644 --- a/plugin/scripts/vscode-mcp-json-discover.test.mjs +++ b/plugin/scripts/vscode-mcp-json-discover.test.mjs @@ -29,7 +29,7 @@ function pluginModuleUrl(pluginRoot) { .href; } -test("discovers Copilot installed-plugin and VS Code agent-plugin configs", () => { +test("discovers Copilot installed-plugin, agent-plugin, and runtime configs", () => { const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-discover-")); const expected = [ file( @@ -41,11 +41,11 @@ test("discovers Copilot installed-plugin and VS Code agent-plugin configs", () = ".copilot/installed-plugins/_direct/direct-id/.mcp.json", ), file(home, ".vscode/agent-plugins/github.com/org/repo/plugin/mcp.json"), + file( + home, + "Library/Application Support/Code/agentPlugins/github.com/org/repo/plugin/.mcp.json", + ), ]; - file( - home, - "Library/Application Support/Code/agentPlugins/github.com/org/repo/plugin/.mcp.json", - ); file(home, "cache/copilot/marketplaces/marketplace/plugin/mcp.json"); file(home, "self/mcp.json"); file(home, "self/.mcp.json"); @@ -216,12 +216,13 @@ test("stops descending below the first plugin config", () => { ); }); -test("defaults ignore platform Code/agentPlugins trees", () => { +test("defaults include platform Code/agentPlugins runtime copies", () => { const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-code-user-plugin-")); - file( + const wanted = file( home, "Library/Application Support/Code/agentPlugins/github.com/code/user/plugin/mcp.json", ); + file(home, "Library/Application Support/Code/User/mcp.json"); assert.deepEqual( discoverVscodeMcpJson({ @@ -230,7 +231,24 @@ test("defaults ignore platform Code/agentPlugins trees", () => { platform: "darwin", includeSelf: false, }), - [], + [wanted], + ); +}); + +test("Linux runtime copies follow XDG_CONFIG_HOME", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-xdg-plugin-")); + const xdg = path.join(home, "xdg-config"); + const wanted = file(xdg, "Code/agentPlugins/org/plugin/mcp.json"); + file(xdg, "Code/User/mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, XDG_CONFIG_HOME: xdg }, + home, + platform: "linux", + includeSelf: false, + }), + [wanted], ); }); @@ -474,15 +492,15 @@ test("Windows Code/User under APPDATA is excluded from override discovery", () = ); }); -test("Windows defaults use installed-plugins and .vscode/agent-plugins", () => { +test("Windows defaults use installed-plugins, agent-plugins, and APPDATA runtime", () => { const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-windows-")); const appData = path.join(home, "AppData", "Roaming"); const localAppData = path.join(home, "AppData", "Local"); const expected = [ file(home, ".copilot/installed-plugins/org/plugin/mcp.json"), file(home, ".vscode/agent-plugins/github.com/org/repo/plugin/mcp.json"), + file(appData, "Code/agentPlugins/org/plugin/mcp.json"), ]; - file(appData, "Code/agentPlugins/org/plugin/mcp.json"); file(localAppData, "copilot/marketplaces/org/plugin/.mcp.json"); assert.deepEqual( From a5c2a68b557ac1bc93ad62361a4b4e0bb2af1e76 Mon Sep 17 00:00:00 2001 From: arielam Date: Tue, 25 Aug 2026 13:24:58 +0300 Subject: [PATCH 4/4] MLD-1384 - Fix Yonis commits --- README.md | 20 ++- VENDOR.md | 10 +- plugin/scripts/vscode-align-mcp-json.mjs | 20 ++- plugin/scripts/vscode-align-mcp-json.test.mjs | 18 +++ plugin/scripts/vscode-mcp-json-discover.mjs | 127 +++++++++++++----- .../scripts/vscode-mcp-json-discover.test.mjs | 92 +++++++++++++ 6 files changed, 236 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index a682db9..fa8f097 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,11 @@ At Copilot `SessionStart`, the plugin discovers MCP configuration files owned by installed agent plugins and passes them to Agent Guard's shared `--rewrite-mcp-json` pipeline. Agent Guard rewrites eligible server commands so they run through the configured JFrog project policy. The hook is fail-open and -has a 60-second limit; disabled, unchanged, or failed rewrites do not block a -chat. +has a 60-second limit; the rewrite pipeline itself is budgeted at 35 seconds. +A cold `npx` fetch of Agent Guard can consume the remaining time, in which case +the hook still returns success and does not rewrite files in that session. A +later session with a warm cache retries. Disabled, unchanged, or failed +rewrites do not block a chat. Discovery checks both `mcp.json` and `.mcp.json`, in that order, under `~/.copilot/installed-plugins/{marketplace}/{plugin}`, @@ -148,21 +151,26 @@ VS Code loads plugin MCP servers from its own per-install copy under rewritten. Otherwise the running servers stay unsecured until VS Code re-copies the plugin. -Only plugin MCP configurations are considered. The hook never rewrites the user -`Code/User/mcp.json` or a workspace `.vscode/mcp.json`. +Default discovery only walks stable VS Code (`Code/agentPlugins`). Only plugin +MCP configurations are considered. The hook never rewrites user +`mcp.json` under `Code/User`, `Code - Insiders/User`, or `VSCodium/User`, or a +workspace `.vscode/mcp.json` (including when the override root is the resolved +path of a `.vscode` symlink). Environment controls: - `JF_AGENT_REWRITE_MCP_JSON_DISABLE=1` disables rewriting. - `JF_AGENT_REWRITE_MCP_JSON_FORCE=1` ignores the current-state marker and forces a refresh. +- This hook always uses the pinned `@jfrog/agent-guard` version shipped with + the plugin; `JFROG_AGENT_GUARD_VERSION=latest` is not honored here. - `JF_ALIGN_MCP_JSON_ROOTS` replaces the default Copilot installed-plugins, `~/.vscode/agent-plugins`, and `Code/agentPlugins` roots (and skips this plugin's own configs). Separate roots with colon or comma on macOS/Linux, and semicolon or comma on Windows. Overrides may point outside the default, but discovery - still rejects `.vscode` and `Code/User` configs and symlinks escaping an - override root. + still rejects workspace `.vscode` and `Code` / `Code - Insiders` / + `VSCodium` `User` configs and symlinks escaping an override root. If the alignment pipeline changes any discovered configuration bytes, even if the pipeline later times out or reports a failure, Copilot displays: diff --git a/VENDOR.md b/VENDOR.md index 4f5f555..356dd81 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -32,14 +32,8 @@ verifies the committed tree matches the pin (see [`sync-modules-integrity.json`](.github/scripts/sync-modules-integrity.json) for the per-file checksums used in that check). -The current bundle uses `jfrog-agent-hooks/v0.9.0` as its base. Three shared -core files are overlaid from commit -`741c2ca9a4ea204a21bb13e72719a587f005856f`, merged by upstream PR 108: -`agent-guard-check.mjs`, `entry.mjs`, and `rewrite-mcp-json.mjs`. The vendor -configuration records both pins and lists those paths under `keep`; a full base -sync temporarily stashes and restores them. All other files come from the -v0.9.0 base. Only upstream `modules/` are vendored; upstream tests remain in -the source repository. +The current bundle uses `jfrog-agent-hooks/v0.11.1` as its base. Only upstream +`modules/` are vendored; upstream tests remain in the source repository. ## Not vendored diff --git a/plugin/scripts/vscode-align-mcp-json.mjs b/plugin/scripts/vscode-align-mcp-json.mjs index 6bc8687..eaef55a 100644 --- a/plugin/scripts/vscode-align-mcp-json.mjs +++ b/plugin/scripts/vscode-align-mcp-json.mjs @@ -5,8 +5,15 @@ import { readFileSync } from "node:fs"; import process from "node:process"; import { isMainEntry } from "../modules/core/entry.mjs"; -import { detectHarness, readStdin } from "../modules/core/io.mjs"; -import { runRewriteMcpJsonPipeline } from "../modules/core/rewrite-mcp-json.mjs"; +import { + detectHarness, + parseWorkspaceRoots, + readStdin, +} from "../modules/core/io.mjs"; +import { + DEFAULT_AGENT_GUARD_VERSION, + runRewriteMcpJsonPipeline, +} from "../modules/core/rewrite-mcp-json.mjs"; import { allowRootsForMcpJson, discoverVscodeMcpJson, @@ -38,9 +45,14 @@ export async function runVscodeAlignMcpJson(options = {}) { const harness = detectHarness(options.stdinRaw ?? ""); if (harness && harness !== HARNESS_ID) return noOp(); - const env = options.env ?? process.env; + const env = { + ...(options.env ?? process.env), + JFROG_AGENT_GUARD_VERSION: DEFAULT_AGENT_GUARD_VERSION, + }; + const workspaceRoots = parseWorkspaceRoots(options.stdinRaw ?? ""); const discover = - options.discover ?? (() => discoverVscodeMcpJson({ env })); + options.discover ?? + (() => discoverVscodeMcpJson({ env, workspaceRoots })); const pipeline = options.pipeline ?? runRewriteMcpJsonPipeline; let discoveredPaths = []; let before = new Map(); diff --git a/plugin/scripts/vscode-align-mcp-json.test.mjs b/plugin/scripts/vscode-align-mcp-json.test.mjs index b321103..42f666d 100644 --- a/plugin/scripts/vscode-align-mcp-json.test.mjs +++ b/plugin/scripts/vscode-align-mcp-json.test.mjs @@ -11,6 +11,7 @@ import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { DEFAULT_AGENT_GUARD_VERSION } from "../modules/core/rewrite-mcp-json.mjs"; import { RECOMMENDED_HOOK_TIMEOUT_SEC, runVscodeAlignMcpJson, @@ -182,6 +183,23 @@ test("pipeline failure without changed bytes is a no-op", async () => { assert.deepEqual(result, { exitCode: 0, stdout: "{}" }); }); +test("SessionStart pins Agent Guard and does not forward latest", async () => { + let receivedEnv; + const result = await runVscodeAlignMcpJson({ + mode: "session-start", + stdinRaw: COPILOT_INPUT, + env: { JFROG_AGENT_GUARD_VERSION: "latest" }, + discover: () => [], + pipeline: async (options) => { + receivedEnv = options.env; + return { exitCode: 0, outcome: "skipped_no_paths", reason: "" }; + }, + }); + + assert.equal(receivedEnv.JFROG_AGENT_GUARD_VERSION, DEFAULT_AGENT_GUARD_VERSION); + assert.deepEqual(result, { exitCode: 0, stdout: "{}" }); +}); + test("recommended hook timeout leaves rewrite, gate, and grace headroom", () => { assert.equal(RECOMMENDED_HOOK_TIMEOUT_SEC, 60); assert.ok(RECOMMENDED_HOOK_TIMEOUT_SEC * 1000 > 35_000 + 5_000 + 2_000); diff --git a/plugin/scripts/vscode-mcp-json-discover.mjs b/plugin/scripts/vscode-mcp-json-discover.mjs index 55d37e3..9b68fc5 100644 --- a/plugin/scripts/vscode-mcp-json-discover.mjs +++ b/plugin/scripts/vscode-mcp-json-discover.mjs @@ -10,6 +10,7 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; const CONFIG_NAMES = ["mcp.json", ".mcp.json"]; +const VSCODE_APP_DIR_NAMES = ["Code", "Code - Insiders", "VSCodium"]; export function parseDiscoveryRoots(value, platform = process.platform) { if (!value?.trim()) return []; @@ -20,20 +21,26 @@ export function parseDiscoveryRoots(value, platform = process.platform) { .filter(Boolean); } -function platformVsCodeDir(home, env, platform) { +function platformVsCodeAppDir(home, env, platform, appDirName) { if (platform === "darwin") { - return path.join(home, "Library", "Application Support", "Code"); + return path.join(home, "Library", "Application Support", appDirName); } if (platform === "win32") { - return env.APPDATA ? path.join(env.APPDATA, "Code") : null; + return env.APPDATA ? path.join(env.APPDATA, appDirName) : null; } const configHome = env.XDG_CONFIG_HOME || path.join(home, ".config"); - return path.join(configHome, "Code"); + return path.join(configHome, appDirName); } -function platformVsCodeUserDir(home, env, platform) { - const codeDir = platformVsCodeDir(home, env, platform); - return codeDir ? path.join(codeDir, "User") : null; +function platformVsCodeDir(home, env, platform) { + return platformVsCodeAppDir(home, env, platform, "Code"); +} + +function platformVsCodeUserDirs(home, env, platform) { + return VSCODE_APP_DIR_NAMES.map((appDirName) => { + const appDir = platformVsCodeAppDir(home, env, platform, appDirName); + return appDir ? path.join(appDir, "User") : null; + }).filter(Boolean); } function platformVsCodeAgentPluginsDir(home, env, platform) { @@ -58,37 +65,60 @@ function safeRealpath(candidate) { } function isWorkspaceVscodeDirectory(directory) { - return path.basename(directory).toLowerCase() === ".vscode"; + return Boolean(directory) && path.basename(directory).toLowerCase() === ".vscode"; } -function isInsideVsCodeUserDir(candidate, userDir) { - if (!userDir) return false; - const logicalUser = path.resolve(userDir); +function isInsideRoot(candidate, root) { + if (!root) return false; + const logicalRoot = path.resolve(root); const logicalCandidate = path.resolve(candidate); - if (isContained(logicalUser, logicalCandidate)) return true; - const realUser = safeRealpath(logicalUser); + if (isContained(logicalRoot, logicalCandidate)) return true; + const realRoot = safeRealpath(logicalRoot); const realCandidate = safeRealpath(candidate); return Boolean( - realUser && realCandidate && isContained(realUser, realCandidate), + realRoot && realCandidate && isContained(realRoot, realCandidate), + ); +} + +function isInsideAnyRoot(candidate, roots) { + return roots.some((root) => isInsideRoot(candidate, root)); +} + +function isVsCodeUserTree(directory, realDirectory, userDirs) { + return ( + isInsideAnyRoot(directory, userDirs) || + (Boolean(realDirectory) && isInsideAnyRoot(realDirectory, userDirs)) ); } -function isVsCodeUserTree(directory, realDirectory, userDir) { +function isResolvedWorkspaceVscode(directory, realDirectory, workspaceVscodeDirs) { return ( - isInsideVsCodeUserDir(directory, userDir) || - (Boolean(realDirectory) && isInsideVsCodeUserDir(realDirectory, userDir)) + isInsideAnyRoot(directory, workspaceVscodeDirs) || + (Boolean(realDirectory) && + isInsideAnyRoot(realDirectory, workspaceVscodeDirs)) ); } -function collectRoot(root, maxDepth, output, seen, userDir) { +function collectRoot( + root, + maxDepth, + output, + seen, + userDirs, + workspaceVscodeDirs, +) { const realRoot = safeRealpath(root); if (!realRoot) return; - if (isVsCodeUserTree(root, realRoot, userDir)) return; + if (isVsCodeUserTree(root, realRoot, userDirs)) return; + if (isResolvedWorkspaceVscode(root, realRoot, workspaceVscodeDirs)) return; function visit(directory, depth) { const realDirectory = safeRealpath(directory); if (!realDirectory || !isContained(realRoot, realDirectory)) return; - if (isVsCodeUserTree(directory, realDirectory, userDir)) return; + if (isVsCodeUserTree(directory, realDirectory, userDirs)) return; + if (isResolvedWorkspaceVscode(directory, realDirectory, workspaceVscodeDirs)) { + return; + } const deniedWorkspace = isWorkspaceVscodeDirectory(directory) || @@ -106,14 +136,13 @@ function collectRoot(root, maxDepth, output, seen, userDir) { continue; } const realCandidate = safeRealpath(candidate); - const realParent = realCandidate - ? path.dirname(realCandidate) - : null; + const realParent = realCandidate ? path.dirname(realCandidate) : null; if ( !realCandidate || !isContained(realRoot, realCandidate) || isWorkspaceVscodeDirectory(realParent) || - isInsideVsCodeUserDir(realParent, userDir) || + isInsideAnyRoot(realParent, workspaceVscodeDirs) || + isInsideAnyRoot(realParent, userDirs) || seen.has(realCandidate) ) { continue; @@ -157,8 +186,16 @@ export function resolvePluginRoot(moduleUrl = import.meta.url) { return path.dirname(path.dirname(fileURLToPath(moduleUrl))); } -function addSelfConfigs(output, seen, userDir, moduleUrl) { +function addSelfConfigs( + output, + seen, + userDirs, + workspaceVscodeDirs, + moduleUrl, +) { const pluginRoot = resolvePluginRoot(moduleUrl); + const realPluginRoot = safeRealpath(pluginRoot); + if (!realPluginRoot) return; for (const name of CONFIG_NAMES) { const candidate = path.join(pluginRoot, name); try { @@ -171,8 +208,10 @@ function addSelfConfigs(output, seen, userDir, moduleUrl) { if ( !realCandidate || !realParent || + !isContained(realPluginRoot, realCandidate) || isWorkspaceVscodeDirectory(realParent) || - isInsideVsCodeUserDir(realParent, userDir) || + isInsideAnyRoot(realParent, workspaceVscodeDirs) || + isInsideAnyRoot(realParent, userDirs) || seen.has(realCandidate) ) { continue; @@ -191,7 +230,10 @@ export function discoverVscodeMcpJson(options = {}) { const env = options.env ?? process.env; const platform = options.platform ?? process.platform; const home = options.home ?? env.HOME ?? homedir(); - const userDir = platformVsCodeUserDir(home, env, platform); + const userDirs = platformVsCodeUserDirs(home, env, platform); + const workspaceVscodeDirs = (options.workspaceRoots ?? []).map((root) => + path.join(path.resolve(root), ".vscode"), + ); const override = parseDiscoveryRoots( env.JF_ALIGN_MCP_JSON_ROOTS, platform, @@ -203,7 +245,14 @@ export function discoverVscodeMcpJson(options = {}) { if (override.length) { for (const root of override) { - collectRoot(path.resolve(root), 4, output, seen, userDir); + collectRoot( + path.resolve(root), + 4, + output, + seen, + userDirs, + workspaceVscodeDirs, + ); } return output; } @@ -213,24 +262,33 @@ export function discoverVscodeMcpJson(options = {}) { 2, output, seen, - userDir, + userDirs, + workspaceVscodeDirs, ); collectRoot( path.join(home, ".vscode", "agent-plugins"), 4, output, seen, - userDir, + userDirs, + workspaceVscodeDirs, ); // VS Code loads plugin MCP servers from its own per-install copy under // Code/agentPlugins, not from the install tree, so rewriting only the source // leaves the running servers unsecured until VS Code re-copies. const agentPluginsDir = platformVsCodeAgentPluginsDir(home, env, platform); if (agentPluginsDir) { - collectRoot(agentPluginsDir, 4, output, seen, userDir); + collectRoot( + agentPluginsDir, + 4, + output, + seen, + userDirs, + workspaceVscodeDirs, + ); } if (includeSelf) { - addSelfConfigs(output, seen, userDir, moduleUrl); + addSelfConfigs(output, seen, userDirs, workspaceVscodeDirs, moduleUrl); } return output; } @@ -239,7 +297,10 @@ export function allowRootsForMcpJson(paths) { const roots = []; const seen = new Set(); for (const configPath of paths) { - const root = safeRealpath(path.dirname(configPath)); + const realFile = safeRealpath(configPath); + const root = realFile + ? path.dirname(realFile) + : safeRealpath(path.dirname(configPath)); if (!root || seen.has(root)) continue; seen.add(root); roots.push(root); diff --git a/plugin/scripts/vscode-mcp-json-discover.test.mjs b/plugin/scripts/vscode-mcp-json-discover.test.mjs index bb3bd7b..b25199c 100644 --- a/plugin/scripts/vscode-mcp-json-discover.test.mjs +++ b/plugin/scripts/vscode-mcp-json-discover.test.mjs @@ -471,6 +471,98 @@ test("allow roots are canonical directories and deduplicated", () => { ); }); +test("allow roots use the real parent of a config symlink", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-allow-file-link-")); + const realDir = path.join(home, "real-dir"); + const logicalDir = path.join(home, "plugin"); + const realFile = file(realDir, "config.json"); + mkdirSync(logicalDir, { recursive: true }); + const logicalFile = path.join(logicalDir, "mcp.json"); + symlinkSync(realFile, logicalFile); + + assert.deepEqual(allowRootsForMcpJson([logicalFile]), [ + realpathSync(realDir), + ]); +}); + +test("includeSelf rejects config symlinks that escape the plugin root", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-self-escape-")); + const pluginRoot = path.join(home, "installed-jfrog"); + mkdirSync(pluginRoot, { recursive: true }); + const outside = file(home, "outside/mcp.json"); + symlinkSync(outside, path.join(pluginRoot, "mcp.json")); + symlinkSync(outside, path.join(pluginRoot, ".mcp.json")); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home }, + home, + platform: "linux", + moduleUrl: pluginModuleUrl(pluginRoot), + }), + [], + ); +}); + +test("override of the realpath of a workspace .vscode symlink is rejected", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-vscode-real-")); + const workspace = path.join(home, "project"); + const actual = path.join(home, "actual-vscode"); + file(actual, "mcp.json"); + const vscodeDir = path.join(workspace, ".vscode"); + mkdirSync(workspace, { recursive: true }); + symlinkSync(actual, vscodeDir); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: actual }, + home, + platform: "linux", + workspaceRoots: [workspace], + }), + [], + ); +}); + +test("Linux Code - Insiders/User and VSCodium/User are excluded from override discovery", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-flavors-")); + const insidersUser = path.join(home, ".config", "Code - Insiders", "User"); + const vscodiumUser = path.join(home, ".config", "VSCodium", "User"); + file(insidersUser, "mcp.json"); + file(vscodiumUser, "mcp.json"); + const wanted = file(home, "override/plugin/mcp.json"); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { + HOME: home, + JF_ALIGN_MCP_JSON_ROOTS: `${insidersUser}:${vscodiumUser}:${path.join(home, "override")}`, + }, + home, + platform: "linux", + }), + [wanted], + ); +}); + +test("override of the realpath of Code - Insiders/User is rejected", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-insiders-real-")); + const actual = path.join(home, "actual-insiders-user"); + file(actual, "mcp.json"); + const userDir = path.join(home, ".config", "Code - Insiders", "User"); + mkdirSync(path.dirname(userDir), { recursive: true }); + symlinkSync(actual, userDir); + + assert.deepEqual( + discoverVscodeMcpJson({ + env: { HOME: home, JF_ALIGN_MCP_JSON_ROOTS: actual }, + home, + platform: "linux", + }), + [], + ); +}); + test("Windows Code/User under APPDATA is excluded from override discovery", () => { const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-win-user-")); const appData = path.join(home, "AppData", "Roaming");