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.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/.gitignore b/.gitignore new file mode 100644 index 0000000..9f11b75 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/README.md b/README.md index ee3d5d4..fa8f097 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 `https://${env:JFROG_PLATFORM_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. | @@ -125,6 +126,58 @@ 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; 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}`, +`~/.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. + +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 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: +`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..356dd81 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -32,6 +32,9 @@ 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.11.1` as its 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/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/scripts/vscode-align-mcp-json.mjs b/plugin/scripts/vscode-align-mcp-json.mjs new file mode 100644 index 0000000..eaef55a --- /dev/null +++ b/plugin/scripts/vscode-align-mcp-json.mjs @@ -0,0 +1,107 @@ +#!/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, + parseWorkspaceRoots, + readStdin, +} from "../modules/core/io.mjs"; +import { + DEFAULT_AGENT_GUARD_VERSION, + 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), + JFROG_AGENT_GUARD_VERSION: DEFAULT_AGENT_GUARD_VERSION, + }; + const workspaceRoots = parseWorkspaceRoots(options.stdinRaw ?? ""); + const discover = + options.discover ?? + (() => discoverVscodeMcpJson({ env, workspaceRoots })); + 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..42f666d --- /dev/null +++ b/plugin/scripts/vscode-align-mcp-json.test.mjs @@ -0,0 +1,225 @@ +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 { DEFAULT_AGENT_GUARD_VERSION } from "../modules/core/rewrite-mcp-json.mjs"; +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("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); + + 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..9b68fc5 --- /dev/null +++ b/plugin/scripts/vscode-mcp-json-discover.mjs @@ -0,0 +1,309 @@ +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"]; +const VSCODE_APP_DIR_NAMES = ["Code", "Code - Insiders", "VSCodium"]; + +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 platformVsCodeAppDir(home, env, platform, appDirName) { + if (platform === "darwin") { + return path.join(home, "Library", "Application Support", appDirName); + } + if (platform === "win32") { + return env.APPDATA ? path.join(env.APPDATA, appDirName) : null; + } + const configHome = env.XDG_CONFIG_HOME || path.join(home, ".config"); + return path.join(configHome, appDirName); +} + +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) { + const codeDir = platformVsCodeDir(home, env, platform); + return codeDir ? path.join(codeDir, "agentPlugins") : null; +} + +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 Boolean(directory) && path.basename(directory).toLowerCase() === ".vscode"; +} + +function isInsideRoot(candidate, root) { + if (!root) return false; + const logicalRoot = path.resolve(root); + const logicalCandidate = path.resolve(candidate); + if (isContained(logicalRoot, logicalCandidate)) return true; + const realRoot = safeRealpath(logicalRoot); + const realCandidate = safeRealpath(candidate); + return Boolean( + 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 isResolvedWorkspaceVscode(directory, realDirectory, workspaceVscodeDirs) { + return ( + isInsideAnyRoot(directory, workspaceVscodeDirs) || + (Boolean(realDirectory) && + isInsideAnyRoot(realDirectory, workspaceVscodeDirs)) + ); +} + +function collectRoot( + root, + maxDepth, + output, + seen, + userDirs, + workspaceVscodeDirs, +) { + const realRoot = safeRealpath(root); + if (!realRoot) 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, userDirs)) return; + if (isResolvedWorkspaceVscode(directory, realDirectory, workspaceVscodeDirs)) { + 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) || + isInsideAnyRoot(realParent, workspaceVscodeDirs) || + isInsideAnyRoot(realParent, userDirs) || + 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, + 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 { + lstatSync(candidate); + } catch { + continue; + } + const realCandidate = safeRealpath(candidate); + const realParent = realCandidate ? path.dirname(realCandidate) : null; + if ( + !realCandidate || + !realParent || + !isContained(realPluginRoot, realCandidate) || + isWorkspaceVscodeDirectory(realParent) || + isInsideAnyRoot(realParent, workspaceVscodeDirs) || + isInsideAnyRoot(realParent, userDirs) || + 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 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, + ); + 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, + userDirs, + workspaceVscodeDirs, + ); + } + return output; + } + + collectRoot( + path.join(home, ".copilot", "installed-plugins"), + 2, + output, + seen, + userDirs, + workspaceVscodeDirs, + ); + collectRoot( + path.join(home, ".vscode", "agent-plugins"), + 4, + output, + seen, + 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, + userDirs, + workspaceVscodeDirs, + ); + } + if (includeSelf) { + addSelfConfigs(output, seen, userDirs, workspaceVscodeDirs, moduleUrl); + } + return output; +} + +export function allowRootsForMcpJson(paths) { + const roots = []; + const seen = new Set(); + for (const configPath of paths) { + 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); + } + 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..b25199c --- /dev/null +++ b/plugin/scripts/vscode-mcp-json-discover.test.mjs @@ -0,0 +1,611 @@ +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, agent-plugin, and runtime 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 include platform Code/agentPlugins runtime copies", () => { + const home = mkdtempSync(path.join(tmpdir(), "vscode-mcp-code-user-plugin-")); + 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({ + env: { HOME: home }, + home, + 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], + ); +}); + +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("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"); + 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, 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(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 894c2b0..fac0e43 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 @@ -282,6 +289,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", () => { @@ -310,13 +324,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)}`, ); @@ -326,6 +344,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", () => {