From c6e933cebdb283bf6a12d2555199cd2cbf1391ea Mon Sep 17 00:00:00 2001 From: arielam Date: Wed, 19 Aug 2026 15:30:42 +0300 Subject: [PATCH 1/3] 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/3] 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 31b26d1a7b9350a7c7c2738b4768b786d2134827 Mon Sep 17 00:00:00 2001 From: arielam Date: Wed, 19 Aug 2026 15:50:14 +0300 Subject: [PATCH 3/3] MLD-1384 - Pin-modules-to-jfrog-agent-hooks-v0.11.0 Co-authored-by: Cursor --- .github/scripts/check-vendored-modules.mjs | 6 +- .github/scripts/sync-modules-integrity.json | 36 +- .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 | 3 +- VENDOR.md | 14 +- .../assets/agents-conf-fingerprints.json | 30 ++ .../modules/assets/agents-default-conf.json | 3 +- plugin/modules/core/agents-config.mjs | 229 +++++++++++- plugin/modules/core/jf-identity.mjs | 36 +- plugin/modules/core/jf-user-agent.mjs | 104 ++++++ plugin/modules/core/scaffold-fingerprint.mjs | 96 +++++ .../onboarding/package-resolution-nudge.md | 69 ++++ ...package-resolution-onboarding-procedure.md | 169 +++++++++ .../scripts/apr-heartbeat.mjs | 349 ++++++++++++++++++ .../package-resolution/scripts/configure.mjs | 290 +++++++++++++++ .../scripts/eager-setup.mjs | 31 ++ .../scripts/feature-flag.mjs | 2 +- .../package-resolution/scripts/index.mjs | 88 +++++ .../scripts/onboarding-decline-cache.mjs | 215 +++++++++++ .../package-resolution/scripts/onboarding.mjs | 231 ++++++++++++ .../scripts/render-instruction.mjs | 43 ++- .../package-resolution/scripts/resolver.mjs | 22 +- .../scripts/sync-onboarding-rule.mjs | 267 ++++++++++++++ .../scripts/verify-repo.mjs | 194 ++++++++++ .../package-resolution-unconfigured.md | 36 +- .../templates/package-resolution.md | 67 ++-- plugin/package.json | 6 + scripts/validate-package-resolution-hook.mjs | 36 +- 30 files changed, 2560 insertions(+), 263 deletions(-) delete mode 100644 .github/scripts/sync-modules.test.mjs create mode 100644 plugin/modules/assets/agents-conf-fingerprints.json create mode 100644 plugin/modules/core/jf-user-agent.mjs create mode 100644 plugin/modules/core/scaffold-fingerprint.mjs create mode 100644 plugin/modules/package-resolution/onboarding/package-resolution-nudge.md create mode 100644 plugin/modules/package-resolution/onboarding/package-resolution-onboarding-procedure.md create mode 100644 plugin/modules/package-resolution/scripts/apr-heartbeat.mjs create mode 100644 plugin/modules/package-resolution/scripts/configure.mjs create mode 100644 plugin/modules/package-resolution/scripts/onboarding-decline-cache.mjs create mode 100644 plugin/modules/package-resolution/scripts/onboarding.mjs create mode 100644 plugin/modules/package-resolution/scripts/sync-onboarding-rule.mjs create mode 100644 plugin/modules/package-resolution/scripts/verify-repo.mjs create mode 100644 plugin/package.json diff --git a/.github/scripts/check-vendored-modules.mjs b/.github/scripts/check-vendored-modules.mjs index 835a854..89260e3 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 (JSON.stringify(expected.pin) !== JSON.stringify(vendor.pin)) +if (expected.pin !== vendor.pin) throw new Error( - `integrity pin mismatch: manifest=${JSON.stringify(expected.pin)} vendor=${JSON.stringify(vendor.pin)}`, + `integrity pin mismatch: manifest=${expected.pin} vendor=${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 ${JSON.stringify(vendor.pin)}`); +console.log(`vendored modules match pin ${vendor.pin}`); diff --git a/.github/scripts/sync-modules-integrity.json b/.github/scripts/sync-modules-integrity.json index 654e6aa..4b0052e 100644 --- a/.github/scripts/sync-modules-integrity.json +++ b/.github/scripts/sync-modules-integrity.json @@ -1,34 +1,42 @@ { "schemaVersion": 1, - "pin": { - "base": "tag:jfrog-agent-hooks/v0.9.0", - "overlay": "commit:741c2ca9a4ea204a21bb13e72719a587f005856f" - }, + "pin": "jfrog-agent-hooks/v0.11.0", "files": { - "assets/agents-default-conf.json": "04aae9b1dcfc75271c3ed786adceea1635b0a1ae0be64fadd7b1111229f11f01", + "assets/agents-conf-fingerprints.json": "11bd418cdf38c8494e04239ae5237a1242bb1532468428c5a561f674f21e2657", + "assets/agents-default-conf.json": "774e1bfd5bb1e2f38de06c9ce53b92e12ddd36b82c159e4a3113f4c038bfc3bb", "claude-session-start.mjs": "2ca1edc6b939cdff6c5faa6ac4b69636e6e92bc7b079316e1fdee7c53c6e837b", "copilot-session-start.mjs": "8811e0829c90ff0987bed158f5ef571195ee5eb54dfc8021b4396fe76ad8a499", "core/agent-guard-check.mjs": "fd7fe9df640418b0df3296a67c33dfabed97f65e210f6e7abea5bce96fa68834", - "core/agents-config.mjs": "3ade16fd6e08b8ac6cb8570edfbed1e9677b26d9c31513720680dd17513480af", + "core/agents-config.mjs": "d6a3181f77efa7b03d691ac8384ec4716f058f867054c33ef77cca3a8f992dab", "core/entry.mjs": "0b0b218448151d7a06743c37e684d0933ab76225be5761f648627f4db02c1f17", "core/io.mjs": "63ea75df635a4e15cf36f2158fe78ae42e3ca886abe267ee5ed2577042eb153d", - "core/jf-identity.mjs": "9d0301d4a60b9c9297cde24e0bab0c2660c56f831617f2b69db17c844276b19b", + "core/jf-identity.mjs": "4ee1c17b6e737f29aa0a2d2f0c94edad6b4566ecaf8f5b5bed0e45486e4f720a", + "core/jf-user-agent.mjs": "4ad02d04a4e9c4593cfff936b1b0899f834ca0cfb6e731ddafc9abf4c83b2de9", "core/logger.mjs": "1ebdffcdf4af14b19e3ee8e82cfeb377fb9961a09d4e9d6ecdc922d07b0848c2", "core/rewrite-mcp-json.mjs": "a88733edd33bd146ed960c157e085f0b89a20882719c1d6daee5a56400322d86", "core/run-capability.mjs": "9fac890b7fd4866f9d3322469b2a7301e28cebfa3faebd77857f9f79d2d1c532", + "core/scaffold-fingerprint.mjs": "df7a710ba215e74808fbda5322e353171a613e74a3ac90fbca466d70c91dca67", "cursor-session-start.mjs": "37dd25ffee18e9f357e3cbb8453552766fd89295e85aa09bf93bc208df74aa20", + "package-resolution/onboarding/package-resolution-nudge.md": "18f8c7b6c1a41e453f20c17392ff07338b6f15a1796f544d09af8d699218db26", + "package-resolution/onboarding/package-resolution-onboarding-procedure.md": "90abb6232228eea0fd874b0c8f69c5d3d298390ad77c5ff283b12c6ee9f04e60", + "package-resolution/scripts/apr-heartbeat.mjs": "3b87b52c06afc5fe311df9c8ecfd29c97198f4b6e1881920ba28231358dc466b", + "package-resolution/scripts/configure.mjs": "18cd8bf29d45aa399dca8bd5fbd799317e97880dfd61f94e4a90d17223b88c83", "package-resolution/scripts/eager-setup-receipt.mjs": "69213084bc1976ec63b346ca26e8a63eb713b0da7ad6ab4fc018934e70fef091", - "package-resolution/scripts/eager-setup.mjs": "d78fc422d15a271e9ae68b754a28fa72ea25a9e86b505b1a0e5e053add864c0d", - "package-resolution/scripts/feature-flag.mjs": "18b258e4d1999de31bad54a1f7f3c3f9cf65c598bbb83f328b45f68a739821f3", - "package-resolution/scripts/index.mjs": "f3ea8f71ecd156515a4a5eb14e33de5c61287f4f2e0e7ca90f9b7d010c4d6567", + "package-resolution/scripts/eager-setup.mjs": "a2b4ec841f65424f9cfbd59b53ecbd2abf99237298a27edf2558cf7ea3bc8d42", + "package-resolution/scripts/feature-flag.mjs": "644f527172700b8578369438dd319d0d80f575a602dcf40f7c9c8128531b7d54", + "package-resolution/scripts/index.mjs": "482a9fb19389179841b5f41cfa5f6314369cd7f01a17260fa2467af88cb3e43c", + "package-resolution/scripts/onboarding-decline-cache.mjs": "92dbc291d5c862e315255046f613bde9d2a488de35137a542a0f70ccf51f202c", + "package-resolution/scripts/onboarding.mjs": "864fd72aa8f3b3c48b3e315c7cefffd398c3a1b8116d2bfecc82a0f274395e24", "package-resolution/scripts/package-manager-family.mjs": "50d066910638e37c2375f696094fde1252181398be56c4218ca14342a76a34e5", "package-resolution/scripts/print-policy.mjs": "02593c6e401006d226b908221d007ba9e1951a61c4cb060789877c1fe919faa2", - "package-resolution/scripts/render-instruction.mjs": "9e4205b5715e2c79515de19d473a6487d61971368103f2851388530ed0a180c4", + "package-resolution/scripts/render-instruction.mjs": "a2c1a5d19fec1bff0a08d33eafdd33c6ff27581d314a4d3699980c914891e4d1", "package-resolution/scripts/repo-types.mjs": "b432bcdd6e77f80ca2c9dddfbf4d9e1299019758fd58b04b29fc21b18a86f788", - "package-resolution/scripts/resolver.mjs": "3485575a65fd5579420d69a51f723047d44f3cfa246511565c6e89ca32b02b4d", + "package-resolution/scripts/resolver.mjs": "6b2b92c0f8d6d56390386255899bee5cec943d298a06b859f5340b30ed79cc8c", "package-resolution/scripts/setup-conflict.mjs": "fdc589561813a9c5e708f50a20a87bacdad3f490158c973b12b217cb984e2845", + "package-resolution/scripts/sync-onboarding-rule.mjs": "cec5559b12bb840cbb059d0b90ab8f36a31a27e7ffdf501262e1e56b3d7d6809", + "package-resolution/scripts/verify-repo.mjs": "ad0a1cc04c1dddd92e29fefb27ae90e69afb368b8d208d22ab4fda6a27dfe34a", "package-resolution/scripts/workspace-config.mjs": "f8f8eaaf0fb8a0c3691938e99afebbc87d8779e508db0ef821fa23f0a55b786a", - "package-resolution/templates/package-resolution-unconfigured.md": "e7645b89d1c4d618fb45692de084d627ca24b5e2e975416176115d3c233e9c00", - "package-resolution/templates/package-resolution.md": "c305751d24fe352b6334a208f7831eb9db58704f1baa693c6921264f6a4456d1" + "package-resolution/templates/package-resolution-unconfigured.md": "d276aa796b3c38f0566bc0b5c3a1553bbbf322d5eaa0d60841e21a3e280a91e0", + "package-resolution/templates/package-resolution.md": "f432ea47e99db08b6223873eb4560f814369f92673acdff2b0de6fa7e10a1d58" } } diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index 0d99e8b..db4e882 100644 --- a/.github/scripts/sync-modules-vendor.json +++ b/.github/scripts/sync-modules-vendor.json @@ -1,14 +1,6 @@ { "repo": "JFROG/jfrog-agent-hooks", - "pin": { - "base": "tag:jfrog-agent-hooks/v0.9.0", - "overlay": "commit:741c2ca9a4ea204a21bb13e72719a587f005856f" - }, + "pin": "jfrog-agent-hooks/v0.11.0", "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 cb5a9dc..35e10e6 100644 --- a/.github/scripts/sync-modules.mjs +++ b/.github/scripts/sync-modules.mjs @@ -12,9 +12,8 @@ // 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, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, "..", ".."); @@ -29,7 +28,7 @@ async function fileExists(p) { } } -async function copyPath(fromDir, toDir, relativePath, log = console.log) { +async function copyPath(fromDir, toDir, relativePath) { const from = path.join(fromDir, relativePath); const to = path.join(toDir, relativePath); if (!(await fileExists(from))) { @@ -38,44 +37,7 @@ async function copyPath(fromDir, toDir, relativePath, log = console.log) { await fs.rm(to, { recursive: true, force: true }); await fs.mkdir(path.dirname(to), { recursive: true }); await fs.cp(from, to, { recursive: true }); - 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 }); - } + console.log(` ${relativePath} -> ${path.relative(process.cwd(), to)}`); } async function main() { @@ -98,20 +60,11 @@ async function main() { const destPrefix = (vendor.dest_prefix ?? "").replace(/^\/+|\/+$/g, ""); const destRoot = destPrefix ? path.join(repoRoot, destPrefix) : repoRoot; - 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(`--- sync from ${hooksRoot} (pin: ${vendor.pin ?? "local"}) ---`); + for (const rel of paths) { + await copyPath(hooksRoot, destRoot, rel); + } console.log("done."); } -if ( - process.argv[1] && - import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href -) { - await main(); -} +await main(); diff --git a/.github/scripts/sync-modules.test.mjs b/.github/scripts/sync-modules.test.mjs deleted file mode 100644 index 840509b..0000000 --- a/.github/scripts/sync-modules.test.mjs +++ /dev/null @@ -1,78 +0,0 @@ -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 7c2bbd1..6093ea3 100644 --- a/.github/workflows/validate-package-resolution-hook.yml +++ b/.github/workflows/validate-package-resolution-hook.yml @@ -16,7 +16,6 @@ on: - "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" @@ -38,7 +37,7 @@ jobs: 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 + run: node --test plugin/scripts/*.test.mjs - name: Verify vendored module integrity run: node .github/scripts/check-vendored-modules.mjs diff --git a/VENDOR.md b/VENDOR.md index 4f5f555..eeafec5 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -32,14 +32,12 @@ 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 is pinned to `jfrog-agent-hooks/v0.11.0`, which includes +the shared `--rewrite-mcp-json` pipeline from upstream PR 108. Only upstream +`modules/` are vendored; upstream tests remain in the source repository. +[`plugin/package.json`](plugin/package.json) is a version stub so +`modules/core/jf-user-agent.mjs` can read `../../package.json` (the same +relative path as in the upstream repo root). ## Not vendored diff --git a/plugin/modules/assets/agents-conf-fingerprints.json b/plugin/modules/assets/agents-conf-fingerprints.json new file mode 100644 index 0000000..59a9182 --- /dev/null +++ b/plugin/modules/assets/agents-conf-fingerprints.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "fingerprints": [ + { + "id": "v0-placeholders-no-onboardingPrompt", + "sha256": "452b737ede2af5da3ea660cb0a2226d422b5624fa1684bc883279422c0728421", + "note": "Legacy template with example repo keys, before onboardingPrompt" + }, + { + "id": "v1-placeholders-onboardingPrompt-auto", + "sha256": "b19251b4671db244a8050885bcbaf5f217f0e4eecfec34c0338264b08fa7c871", + "note": "Legacy template with example repo keys + onboardingPrompt: auto" + }, + { + "id": "v2-empty-defaultGlobalRepos", + "sha256": "5a104c83c4cb67f2cb01d71ad0044a438f9125bab868ef76e24bd7be7828b82b", + "note": "Empty defaultGlobalRepos after #84 (no onboardingPrompt)" + }, + { + "id": "v3-empty-onboardingPrompt-auto", + "sha256": "8b68d55af89e2dadf4ff0c3ae0784b70051c24d1fb75e3ea6df8ba3044c5cefa", + "note": "Legacy template: enabled false + empty defaultGlobalRepos + onboardingPrompt: auto" + }, + { + "id": "v4-enabled-onboardingPrompt-auto", + "sha256": "f0481d915f1f7f2a1e7d88ab23ce7b9430d3e44e41aaabb4d4d13e2b40963ae2", + "note": "Current shipped template: enabled true + empty defaultGlobalRepos + onboardingPrompt: auto" + } + ] +} diff --git a/plugin/modules/assets/agents-default-conf.json b/plugin/modules/assets/agents-default-conf.json index e2b035c..35ceff5 100644 --- a/plugin/modules/assets/agents-default-conf.json +++ b/plugin/modules/assets/agents-default-conf.json @@ -1,9 +1,10 @@ { "logLevel": "info", "packageResolution": { - "enabled": false, + "enabled": true, "verifyRepos": true, "cacheTtlDays": 7, + "onboardingPrompt": "auto", "defaultGlobalRepos": {}, "autoSetup": [] } diff --git a/plugin/modules/core/agents-config.mjs b/plugin/modules/core/agents-config.mjs index bf10d77..6e55a85 100644 --- a/plugin/modules/core/agents-config.mjs +++ b/plugin/modules/core/agents-config.mjs @@ -4,11 +4,15 @@ // before capabilities run so first-time installs get a writable config file. import { - copyFileSync, + closeSync, existsSync, mkdirSync, + openSync, readFileSync, + renameSync, statSync, + unlinkSync, + writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; @@ -29,8 +33,12 @@ const TEMPLATE_PATH = path.join( const DEFAULT_LOG_LEVEL = "info"; const DEFAULT_CACHE_TTL_DAYS = 7; +const AGENTS_CONFIG_LOCK_STALE_MS = 30_000; +const AGENTS_CONFIG_LOCK_WAIT_MS = 1_000; +const AGENTS_CONFIG_LOCK_POLL_MS = 25; let memoizedRaw = undefined; let memoizedForPath = null; +let memoizedMtimeMs = undefined; /** @type {{ source: 'missing' | 'user' | 'template', parseFailed: boolean, path: string }} */ let loadMeta = { source: "missing", parseFailed: false, path: "" }; @@ -38,29 +46,135 @@ function agentsConfigPath() { return path.join(homedir(), ".jfrog", "agents-conf.json"); } +function agentsConfigLockPath() { + return path.join(homedir(), ".jfrog", "agents-conf.lock"); +} + +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function tryAgentsConfigLock() { + mkdirSync(path.dirname(agentsConfigLockPath()), { recursive: true }); + const fd = openSync(agentsConfigLockPath(), "wx"); + try { + writeFileSync(fd, `${process.pid}\n${Date.now()}\n`); + } finally { + closeSync(fd); + } +} + +function releaseAgentsConfigLock() { + try { + unlinkSync(agentsConfigLockPath()); + } catch { + // ignore + } +} + +function reclaimStaleAgentsConfigLock(nowMs) { + try { + const raw = readFileSync(agentsConfigLockPath(), "utf8"); + const ts = Number(raw.split("\n")[1]); + if (!Number.isFinite(ts) || nowMs - ts > AGENTS_CONFIG_LOCK_STALE_MS) { + unlinkSync(agentsConfigLockPath()); + return true; + } + } catch { + // ignore + } + return false; +} + +function acquireAgentsConfigLock(nowMs = Date.now()) { + try { + tryAgentsConfigLock(); + return true; + } catch { + if (!reclaimStaleAgentsConfigLock(nowMs)) return false; + try { + tryAgentsConfigLock(); + return true; + } catch { + return false; + } + } +} + +/** + * Serialize read-merge-rename of agents-conf.json across processes. + * Fails closed when the lock cannot be acquired — never silently races an + * unlocked RMW (Consent Enable / dismiss / SessionStart can overlap). + */ +function withAgentsConfigLock(fn) { + const deadline = Date.now() + AGENTS_CONFIG_LOCK_WAIT_MS; + let locked = acquireAgentsConfigLock(); + while (!locked && Date.now() < deadline) { + sleepSync(AGENTS_CONFIG_LOCK_POLL_MS); + locked = acquireAgentsConfigLock(Date.now()); + } + if (!locked) { + throw new Error( + "agents-conf.lock: could not acquire lock within wait budget", + ); + } + try { + return fn(); + } finally { + releaseAgentsConfigLock(); + } +} + function resetLoadMeta(configPath) { loadMeta = { source: "missing", parseFailed: false, path: configPath }; } /** - * Copy the shipped template to ~/.jfrog/agents-conf.json when missing. - * Never overwrites an existing file. + * Copy the shipped template when missing. Caller must hold agents-conf.lock + * (or use {@link ensureAgentsConfigScaffold}). Uses exclusive create so a + * late scaffold cannot clobber a concurrent patch that already created the file. */ -export function ensureAgentsConfigScaffold() { +function ensureAgentsConfigScaffoldUnlocked() { const configPath = agentsConfigPath(); if (existsSync(configPath)) return { created: false, path: configPath }; try { mkdirSync(path.dirname(configPath), { recursive: true }); - copyFileSync(TEMPLATE_PATH, configPath); + const fd = openSync(configPath, "wx"); + try { + writeFileSync(fd, readFileSync(TEMPLATE_PATH)); + } finally { + closeSync(fd); + } memoizedRaw = undefined; + memoizedForPath = null; + memoizedMtimeMs = undefined; return { created: true, path: configPath }; } catch { + // Another writer won the create race — treat as already present. + if (existsSync(configPath)) { + return { created: false, path: configPath }; + } return { created: false, path: configPath }; } } +/** + * Copy the shipped template to ~/.jfrog/agents-conf.json when missing. + * Never overwrites an existing file. Serialized with mergeAgentsConfigPatch. + */ +export function ensureAgentsConfigScaffold() { + return withAgentsConfigLock(() => ensureAgentsConfigScaffoldUnlocked()); +} + export { agentsConfigPath }; +/** Drop the in-process config memo (tests / direct writers that skip mergeAgentsConfigPatch). */ +export function invalidateAgentsConfigCache() { + memoizedRaw = undefined; + memoizedForPath = null; + memoizedMtimeMs = undefined; +} + /** @returns {number | null} mtime in ms, or null when the file is absent */ export function getAgentsConfigMtimeMs() { try { @@ -81,9 +195,15 @@ function parseAgentsJson(raw) { function readAgentsConfigRaw() { const configPath = agentsConfigPath(); - if (memoizedForPath !== configPath) { + const mtimeMs = getAgentsConfigMtimeMs(); + if ( + memoizedForPath !== configPath || + memoizedMtimeMs !== mtimeMs || + memoizedRaw === undefined + ) { memoizedRaw = undefined; memoizedForPath = configPath; + memoizedMtimeMs = mtimeMs; resetLoadMeta(configPath); } if (memoizedRaw !== undefined) return memoizedRaw; @@ -164,12 +284,109 @@ export function loadAgentsConfig() { enabled: pr.enabled === true, verifyRepos: pr.verifyRepos !== false, cacheTtlDays: normalizeCacheTtlDays(pr.cacheTtlDays), + onboardingPrompt: normalizeOnboardingPrompt(pr.onboardingPrompt), defaultGlobalRepos, autoSetup: normalizeAutoSetup(pr.autoSetup), }, }; } +/** + * Raw onboardingPrompt field: "auto" | "off" | "absent" (legacy / missing). + * Not normalized to auto — callers distinguish fingerprint fallback. + */ +export function getOnboardingPromptState() { + const pr = getAgentsConfigSection("packageResolution") ?? {}; + if (pr.onboardingPrompt === "off") return "off"; + if (pr.onboardingPrompt === "auto") return "auto"; + return "absent"; +} + +function normalizeOnboardingPrompt(raw) { + if (raw === "off") return "off"; + if (raw === "auto") return "auto"; + return "absent"; +} + +/** + * Deep-merge a patch into agents-conf.json (preserves unknown fields). + * `packageResolution.defaultGlobalRepos` and `autoSetup` are replaced when + * present in the patch (Consent Enable replaces the map with verified keys only). + * @param {object} patch + */ +export function mergeAgentsConfigPatch(patch) { + return withAgentsConfigLock(() => { + ensureAgentsConfigScaffoldUnlocked(); + const configPath = agentsConfigPath(); + let current = {}; + let existed = false; + try { + if (existsSync(configPath)) { + existed = true; + const parsed = JSON.parse(readFileSync(configPath, "utf8")); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error( + "agents-conf.json root must be a JSON object and was not overwritten", + ); + } + current = parsed; + } + } catch (err) { + // Never replace a malformed user config with a patch-only file. + if (existed) { + throw new Error( + `agents-conf.json is malformed and was not overwritten: ${err?.message ?? err}`, + ); + } + current = {}; + } + const next = deepMerge(current, patch); + if ( + patch?.packageResolution && + Object.prototype.hasOwnProperty.call( + patch.packageResolution, + "defaultGlobalRepos", + ) + ) { + next.packageResolution = next.packageResolution ?? {}; + next.packageResolution.defaultGlobalRepos = + patch.packageResolution.defaultGlobalRepos; + } + if ( + patch?.packageResolution && + Object.prototype.hasOwnProperty.call(patch.packageResolution, "autoSetup") + ) { + next.packageResolution = next.packageResolution ?? {}; + next.packageResolution.autoSetup = patch.packageResolution.autoSetup; + } + mkdirSync(path.dirname(configPath), { recursive: true }); + const tmp = `${configPath}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`); + renameSync(tmp, configPath); + memoizedRaw = undefined; + memoizedMtimeMs = undefined; + return next; + }); +} + +function deepMerge(base, patch) { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) return patch; + const out = + base && typeof base === "object" && !Array.isArray(base) ? { ...base } : {}; + for (const [k, v] of Object.entries(patch)) { + if (v && typeof v === "object" && !Array.isArray(v)) { + out[k] = deepMerge(out[k], v); + } else { + out[k] = v; + } + } + return out; +} + export function getGlobalLogLevel() { return loadAgentsConfig().logLevel; } diff --git a/plugin/modules/core/jf-identity.mjs b/plugin/modules/core/jf-identity.mjs index 33367d1..56a33e6 100644 --- a/plugin/modules/core/jf-identity.mjs +++ b/plugin/modules/core/jf-identity.mjs @@ -38,8 +38,26 @@ export const IdentityCause = Object.freeze({ JF_AUTH_FAILED: "jf-auth-failed", /** Probe timed out / network / non-auth HTTP failure. */ JF_UNREACHABLE: "jf-unreachable", + /** Platform URL is not https — refuse to send credentials in cleartext. */ + INSECURE_URL: "insecure-url", }); +/** + * Credentials must never travel in cleartext. `jf` accepts http:// servers; + * callers that send Authorization headers must gate on https first. + * @param {{ url?: string } | string | null | undefined} identityOrUrl + */ +export function isHttpsIdentityUrl(identityOrUrl) { + try { + const raw = + typeof identityOrUrl === "string" + ? identityOrUrl + : (identityOrUrl?.url ?? ""); + return new URL(String(raw)).protocol === "https:"; + } catch { + return false; + } +} const PROBE_TIMEOUT_MS = 3_000; // Module-scope cache. Keyed by the requested serverId hint (`undefined` @@ -251,6 +269,15 @@ export async function probePlatformIdentity(identity) { if (testHarnessActive() && process.env.JFROG_TEST_IDENTITY_PROBE === "skip") { return { ok: true, cause: IdentityCause.OK }; } + + if (!isHttpsIdentityUrl(identity)) { + log.warn("refusing identity probe over a non-HTTPS platform URL"); + const result = { ok: false, cause: IdentityCause.INSECURE_URL }; + const keyEarly = probeCacheKey(identity); + PROBE_CACHE.set(keyEarly, result); + return result; + } + if (process.env.JF_AGENT_IDENTITY_PROBE === "0") { return { ok: true, cause: IdentityCause.OK }; } @@ -346,7 +373,8 @@ export async function getReadyPlatformIdentity() { // closed to pending so we don't inject "routing" with an unusable identity. if ( probe.cause === IdentityCause.JF_AUTH_FAILED || - probe.cause === IdentityCause.JF_UNSUPPORTED_AUTH + probe.cause === IdentityCause.JF_UNSUPPORTED_AUTH || + probe.cause === IdentityCause.INSECURE_URL ) { log.debug("identity not ready after probe", { cause: probe.cause }); return { identity: null, cause: probe.cause }; @@ -420,6 +448,12 @@ function noIdentityHint(cause) { "platform URL, then retry." ); } + if (cause === IdentityCause.INSECURE_URL) { + return ( + "Configured platform URL is not HTTPS. Reconfigure with `jf config add` " + + "using an https:// URL so credentials are not sent in cleartext." + ); + } return ( "No configured JFrog server. Run `jf config add` (access token or " + "username + password / API key)." diff --git a/plugin/modules/core/jf-user-agent.mjs b/plugin/modules/core/jf-user-agent.mjs new file mode 100644 index 0000000..ece400f --- /dev/null +++ b/plugin/modules/core/jf-user-agent.mjs @@ -0,0 +1,104 @@ +// Thin JFROG_CLI_USER_AGENT for jf spawned by APR (eager setup + heartbeat). +// +// Stamp only what sessionStart actually knows right now: +// - trigger=hook +// - jfrog-skills/ (Coralogix product filter unity) +// - jfrog-cli-go/ +// - tool= from adapter ctx.ide (via JFROG_APR_UA_TOOL) +// - client= from TERM_PROGRAM when present in this process +// +// Do NOT stamp model= — skills/agent own the model slug and set it when the +// agent is actually running with a known model (usually a later bash tool). +// Spawn env is inherited so CLI DetectExecutionContext can append +// ai-agent/ / ai-client/ / ai-model/ when those signals exist at jf start. + +import { createRequire } from "node:module"; +import { spawnSync } from "node:child_process"; + +const require = createRequire(import.meta.url); +const PKG_VERSION = String(require("../../package.json").version || "0.0.0"); + +const MAX_TOKEN_LEN = 64; + +/** @type {string | undefined} */ +let cachedCliVersion; + +/** + * @param {string | undefined | null} raw + * @returns {string} + */ +export function sanitizeToken(raw) { + if (raw == null || raw === "") return ""; + let s = String(raw) + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, ""); + if (s.length > MAX_TOKEN_LEN) s = s.slice(0, MAX_TOKEN_LEN); + return s; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {string} + */ +function resolveCliVersion(env = process.env) { + if (env.JFROG_TEST_CLI_VERSION) return String(env.JFROG_TEST_CLI_VERSION); + if (cachedCliVersion) return cachedCliVersion; + try { + // Keep process PATH/HOME even when callers pass a sparse env object + // (unit tests often pass only UA-related keys). + const res = spawnSync("jf", ["--version"], { + encoding: "utf8", + timeout: 3000, + env: { ...process.env, ...env }, + }); + const out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`; + const m = out.match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/); + cachedCliVersion = m?.[1] || "unknown"; + } catch { + cachedCliVersion = "unknown"; + } + return cachedCliVersion; +} + +/** + * Axes present on the hook process itself (not invented, not model). + * @param {NodeJS.ProcessEnv} [env] + * @param {{ tool?: string }} [opts] + * @returns {{ tool?: string, client?: string }} + */ +export function resolveHookUaAxes(env = process.env, opts = {}) { + const tool = + sanitizeToken(opts.tool) || + sanitizeToken(env.JFROG_APR_UA_TOOL) || + undefined; + const client = sanitizeToken(env.TERM_PROGRAM) || undefined; + return { tool, client }; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {{ tool?: string }} [opts] + * @returns {string} + */ +export function buildHookJfUserAgent(env = process.env, opts = {}) { + const axes = resolveHookUaAxes(env, opts); + const parts = ["trigger=hook"]; + if (axes.tool) parts.push(`tool=${axes.tool}`); + if (axes.client) parts.push(`client=${axes.client}`); + return `jfrog-skills/${PKG_VERSION} (${parts.join("; ")}) jfrog-cli-go/${resolveCliVersion(env)}`; +} + +/** + * Spawn env: full inherit + hook UA override. + * @param {NodeJS.ProcessEnv} [env] + * @param {{ tool?: string }} [opts] + * @returns {NodeJS.ProcessEnv} + */ +export function envWithHookUserAgent(env = process.env, opts = {}) { + return { ...env, JFROG_CLI_USER_AGENT: buildHookJfUserAgent(env, opts) }; +} + +/** @internal test helper */ +export function _resetCliVersionCacheForTests() { + cachedCliVersion = undefined; +} diff --git a/plugin/modules/core/scaffold-fingerprint.mjs b/plugin/modules/core/scaffold-fingerprint.mjs new file mode 100644 index 0000000..7ac2388 --- /dev/null +++ b/plugin/modules/core/scaffold-fingerprint.mjs @@ -0,0 +1,96 @@ +// Scaffold fingerprint — detect never-configured agents-conf.json. +// +// Hash the user's config (canonical JSON) against every historically shipped +// template. Untouched scaffold ⇒ eligible for onboarding; any deviation ⇒ +// treat as deliberate (admin/MDM/hand-edit) and stay silent when +// onboardingPrompt is absent. + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { agentsConfigPath } from "./agents-config.mjs"; + +const PLUGIN_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const FINGERPRINTS_PATH = path.join( + PLUGIN_ROOT, + "assets", + "agents-conf-fingerprints.json", +); + +const TEMPLATE_PATH = path.join( + PLUGIN_ROOT, + "assets", + "agents-default-conf.json", +); + +/** Deterministic JSON for hashing (sorted keys, no whitespace). */ +export function canonicalizeJson(value) { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((v) => canonicalizeJson(v)).join(",")}]`; + } + const keys = Object.keys(value).sort(); + return `{${keys + .map((k) => `${JSON.stringify(k)}:${canonicalizeJson(value[k])}`) + .join(",")}}`; +} + +export function sha256Canonical(value) { + return createHash("sha256").update(canonicalizeJson(value)).digest("hex"); +} + +function loadFingerprintSet() { + const set = new Set(); + try { + const raw = JSON.parse(readFileSync(FINGERPRINTS_PATH, "utf8")); + for (const entry of raw?.fingerprints ?? []) { + if (typeof entry?.sha256 === "string" && entry.sha256) { + set.add(entry.sha256); + } + } + } catch { + // fall through — still register current template below + } + try { + const tmpl = JSON.parse(readFileSync(TEMPLATE_PATH, "utf8")); + set.add(sha256Canonical(tmpl)); + } catch { + // ignore + } + return set; +} + +/** + * True when agents-conf.json is missing or matches a shipped template hash. + * @param {string} [configPath] + */ +export function isNeverConfiguredScaffold(configPath = agentsConfigPath()) { + if (!existsSync(configPath)) return true; + let parsed; + try { + parsed = JSON.parse(readFileSync(configPath, "utf8")); + } catch { + return false; + } + if (!parsed || typeof parsed !== "object") return false; + const known = loadFingerprintSet(); + return known.has(sha256Canonical(parsed)); +} + +/** Guard for tests: current shipped template must be registered. */ +export function currentTemplateFingerprint() { + const tmpl = JSON.parse(readFileSync(TEMPLATE_PATH, "utf8")); + return sha256Canonical(tmpl); +} + +export function registeredFingerprints() { + return [...loadFingerprintSet()]; +} diff --git a/plugin/modules/package-resolution/onboarding/package-resolution-nudge.md b/plugin/modules/package-resolution/onboarding/package-resolution-nudge.md new file mode 100644 index 0000000..f1a77f0 --- /dev/null +++ b/plugin/modules/package-resolution/onboarding/package-resolution-nudge.md @@ -0,0 +1,69 @@ +# Agent Package Resolution — bind unbound package types + +The JFrog **Agent Package Resolution** hooks are installed. A package type in +this chat may still lack a `defaultGlobalRepos` binding. + +## When to ask + +Only when the user/agent is about to do **package-resolution work** for a +concrete APR type from ({{SUPPORTED_TYPES}}) — e.g. `npm install`, `pip install`, +Maven/Gradle deps, `go get`, Docker/Helm/NuGet — **and** that type is not in +`defaultGlobalRepos` **and** not already declined. + +Do **not** ask on unrelated chats. Do **not** re-ask a declined or bound type. + +## What to do (at most once per conversation per type, after intent) + +1. Silently map intent → APR type **T** (npm→`npm`, pip/uv→`pypi`). Skip bound + or declined types. Do **not** tell the user you are checking bindings, + decline cache, status, or whether to ask. + +2. Your **first** (and only) visible output this turn must be the Yes/No ask — + no plan, status check, or “I'll check…” line. Pitch (paraphrase OK): + + > Agent Package Resolution can route **T** installs through your Artifactory + > **virtual** repositories instead of public registries. Want to set that up + > for **T** now? Details: + > {{ADMIN_GUIDE_URL}} + + Choices: + + - **Yes** — Consent Enable for **T** (and any other types they choose) + - **No** — do not ask again for **T** (other types may still be offered later) + + Do **not** open with “I’m ready to help…”, summaries, git status, + “I'll check…”, binding/decline/status narration, or other preamble. Do **not** + run `configure.mjs status` before asking. + + Emit **one** Yes/No block — that pitch plus the two bullets. Do **not** add a + second recap or a structured question / options picker. + + **Stop and wait.** End the turn right after the ask. Do **not** continue the + original task, run commands, or report findings in the same message. + +3. **No** → acknowledge once, then: + +```bash +node "{{CONFIGURE_COMMAND}}" dismiss --type +``` + + Use the APR type (e.g. `pypi`, `npm`). Bare `dismiss` only if they want to + silence **all** future APR offers. + +4. Neither Yes nor No → continue the task; do **not** dismiss; do **not** re-ask + **T** in this conversation. + +5. **Yes** → run `onboarding-procedure` exactly (prefer starting from **T**). + Do **not** ask those questions yourself first: + +```bash +node "{{CONFIGURE_COMMAND}}" onboarding-procedure +``` + +## Hard rules + +- Once per conversation per type; only for unbound, undeclined install intent. +- Ask is **first and last** in that turn — no narration about checking bindings + or whether to offer APR. One pitch, not a recap after it. +- Never invent repo keys or run `jf setup` without Consent Enable + verify-repo. +- Use `onboarding-procedure` on Yes; do not expand this rule into a tutorial. diff --git a/plugin/modules/package-resolution/onboarding/package-resolution-onboarding-procedure.md b/plugin/modules/package-resolution/onboarding/package-resolution-onboarding-procedure.md new file mode 100644 index 0000000..430649a --- /dev/null +++ b/plugin/modules/package-resolution/onboarding/package-resolution-onboarding-procedure.md @@ -0,0 +1,169 @@ +# Consent Enable — Agent Package Resolution + +The user agreed to enable Agent Package Resolution. Follow these steps in order. +Do not invent repo keys. Never list the Artifactory catalog, all virtuals, or +wildcard names (`*-virtual`, `**`). Those responses can be thousands of +rows and will flood this chat. + +This procedure is reached via the soft-bridge Yes/No offer on **Cursor** and +**Claude Code**. **VS Code Copilot** does not get that offer — do not assume +Copilot already ran Consent Enable unless the user started this procedure +another way. + +## 1. Ask which repository / package types to configure + +This procedure is the **only** place the types question is asked — the offer +rule deliberately does not ask it. If you already asked, do not ask again; reuse +their answer. + +Before binding repos or enable, **ask the user which types they want to govern**, +as a plain chat question with the supported types inline: + +> Which package types should route through Artifactory? Supported: `npm`, +> `pypi`, `maven`, `gradle`, `go`, `docker`, `helm`, `nuget`. Reply with the +> ones you want (e.g. "maven and pypi"). + +Ask this as **free text**. Do **not** put the eight types into a structured +multiple-choice / options picker — those pickers cap at four options and the +call will fail validation. + +They may choose one, several, or all. Do **not** assume “all types.” Do **not** +enable a type they did not pick. If they are unsure, briefly explain that only +chosen types get Artifactory routing; others stay untouched. + +Wait for their answer. Remember the chosen set as `CHOSEN_TYPES`. + +## 2. Prerequisites + +- Ensure `jf` is installed and on PATH. +- Ensure a JFrog server is configured (`jf config show`). Prefer access token or + username + password / API key auth. +- If setup is needed, follow the base `jfrog` skill login flow. Do **not** run + `jf setup` until after enable + auto-setup below. + +## 3. Bind one type at a time (base `jfrog` skill) + +There is **no** discovery skill and **no** `configure.mjs discover` command. +Use the base **`jfrog` skill** only for **bounded** lookups (MCP / `jf` / +`jf api` as the skill directs). Do **not** invent keys. + +If `CHOSEN_TYPES` has more than one type, configure them **one type at a time**. +Do not ask for project/repo for every type in one message. Do not start type +N+1 until type N is bound, skipped, or the user declines that type. + +For the current type, ask as **free text** (not a project picker, not “list +projects”): + +> For ``, what is the Artifactory **project key** or **repository** +> key/name? Either is enough. If you do not know either, say so. + +Resolve that type through **exactly one** path: + +- **Repository given** (alone or with a project) → verify only that key. + Ignore the project for lookup. +- **Project given, no repository** → one filtered call only: that exact + project + `type=virtual` + this `packageType`. Never fetch the full project + catalog or an unfiltered platform catalog. + - **Query failed** (auth/network/skill error) → say what failed, fix the + cause (usually `jf` auth), and retry the same filtered call. Do not invent + a key. Do not treat failure as “none found.” + - **0** matches → say none in that project; ask for another project or an + exact repository. Do not bind. + - **1** match → use that key. Do not ask. + - **2–10** matches → show **name and key** (if the API exposes only `key`, + use the key as the name too) and ask which to use. + - **More than 10** → do **not** list, quote, or keep the extra rows. Ask for + the exact repository name. +- **Neither given** → **exact-key fallback**. Point-lookup only + `-virtual`, `-default`, then `-release` (for example + `npm-virtual`, `npm-default`, `npm-release`). Verify each hit. Do not search + or glob. + - **0** verified hits → ask again for a project or repository for **this + type only**. Suggest they contact their Artifactory admin if they have + neither. Do not bind. + - **1** verified hit → use that key. Do not ask. + - **2–3** verified hits → show only those keys (name and key) and ask the + user to pick one. + +**Forbidden** (every type, every turn): unfiltered `list repositories`, +platform-wide virtual listing, `*-virtual`, `**`, paginating the catalog, +or dumping a large API payload into chat. A user who says “I don’t know” +gets exact-key fallback — never a catalog dump. + +Verify every auto-bound, user-confirmed, or pasted key before binding: + +```bash +node "{{CONFIGURE_COMMAND}}" verify-repo --type '' --repo '' +``` + +`verify-repo` fails closed: it confirms the key is a **virtual** repo whose +`packageType` matches. If it fails, ask for a different key — do not bind it. +Verify every key (unique auto-binds included — cheap defense-in-depth). + +Then move to the next chosen type. Collect resolved keys into a +`type → repoKey` map. If the map is empty (every type unresolved), stop and +explain; do **not** call enable. + +## 4. Enable + auto-setup (no second ask) + +After the verified map has at least one binding, enable **and** turn on +zero-touch auto-setup for those types. Auto-setup is part of Consent Enable — +**do not** ask a separate “want auto-setup?” question. + +`enable` **replaces** `defaultGlobalRepos` with the JSON object you pass. It +does **not** merge. Re-include every type that should stay bound — this +session’s map **plus** any already-bound keys from `configure.mjs status` +(or the current `defaultGlobalRepos`). Same for `auto-setup`: it **replaces** +`autoSetup`; pass every type that should stay in that list (typically the +same keys). + +```bash +node "{{CONFIGURE_COMMAND}}" enable --repos '' +node "{{CONFIGURE_COMMAND}}" auto-setup --types '' +``` + +Example: + +```bash +node "{{CONFIGURE_COMMAND}}" enable --repos '{"maven":"libs-release-virtual","pypi":"pypi"}' +node "{{CONFIGURE_COMMAND}}" auto-setup --types '["maven","pypi"]' +``` + +`enable` writes `enabled: true` and **only** those `defaultGlobalRepos`. It +**re-verifies** each key (fail-closed). Offer rules are **re-synced**, not +blindly cleared — they stay while other types remain offerable. `auto-setup` +opts those types into user-global `jf setup`. + +Enable **only** types that bound. Unbound chosen types stay off; say so. If +the bound map is empty, do not call enable. + +## 5. Load routing + verify auto-setup + +Run print-policy **synchronously**. Its stdout **is** the Package Resolution +table for this chat (Decision order + URL table + setup status). Follow that +table for the rest of this session. + +```bash +JFROG_EAGER_SETUP_SYNC=1 node "{{PRINT_POLICY_COMMAND}}" +``` + +Wait until bound types show as **already set up**. Do **not** install while +the note says `setting up in the background`. If it still does, run +print-policy again and read the new note. + +Do **not** install a test package. Do **not** read npmrc / pip.conf as extra +proof. + +- Type **already set up** → later installs for that type use the normal + package-manager command. **No** `--registry`, `--index-url`, `GOPROXY=…`, + or other rewrite flags. +- Type **pending / failed / conflict** → follow the existing conflict/retry + path in the printed note. That type is not ready. Never use rewrite flags + as a fallback. +- Do **not** claim overall success unless every bound type set up. + +## 6. New chat + +**After** enable, auto-setup, and the sync print-policy check, tell the user +that opening a **new chat** (or reloading the IDE) picks up the updated +hooks/rules cleanly. Routing already works in this session after `print-policy`. diff --git a/plugin/modules/package-resolution/scripts/apr-heartbeat.mjs b/plugin/modules/package-resolution/scripts/apr-heartbeat.mjs new file mode 100644 index 0000000..096f58f --- /dev/null +++ b/plugin/modules/package-resolution/scripts/apr-heartbeat.mjs @@ -0,0 +1,349 @@ +// Daily APR session heartbeat — best-effort `jf rt ping` so Coralogix still +// sees hook-driven traffic when eager `jf setup` is skipped (steady state). +// +// Gated to routing-mode sessionStart (caller). At most once per 24h per +// serverId via ~/.jfrog/skills-cache/apr-heartbeat-v1.json, with an exclusive +// per-server lock file to reduce cross-process stampedes. Never throws — +// heartbeat must not break injection. + +import { spawn } from "node:child_process"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { createLogger } from "../../core/logger.mjs"; +import { getPlatformIdentity } from "../../core/jf-identity.mjs"; +import { envWithHookUserAgent } from "../../core/jf-user-agent.mjs"; + +const log = createLogger("apr-heartbeat"); + +const RECEIPT_SCHEMA_VERSION = 1; +const HEARTBEAT_TTL_MS = 24 * 60 * 60 * 1000; +const LOCK_STALE_MS = 60 * 1000; + +/** @returns {string} `~/.jfrog/skills-cache` */ +function cacheDir() { + return path.join(homedir(), ".jfrog", "skills-cache"); +} + +/** @returns {string} path to the heartbeat receipt */ +export function heartbeatReceiptPath() { + return path.join(cacheDir(), "apr-heartbeat-v1.json"); +} + +/** @param {string} serverId */ +export function heartbeatLockPath(serverId) { + const safe = String(serverId).replace(/[^a-zA-Z0-9._-]+/g, "_"); + return path.join(cacheDir(), `apr-heartbeat-${safe}.lock`); +} + +/** @returns {{ schemaVersion: number, servers: Record }} */ +function emptyReceipt() { + return { schemaVersion: RECEIPT_SCHEMA_VERSION, servers: {} }; +} + +/** + * Normalize on-disk JSON; drop unexpected schema / junk. + * @param {unknown} data + * @returns {{ schemaVersion: number, servers: Record }} + */ +export function normalizeHeartbeatReceipt(data) { + if ( + !data || + typeof data !== "object" || + data.schemaVersion !== RECEIPT_SCHEMA_VERSION + ) { + return emptyReceipt(); + } + const servers = {}; + if (data.servers && typeof data.servers === "object") { + for (const [serverId, raw] of Object.entries(data.servers)) { + if (!raw || typeof raw !== "object") continue; + if (typeof raw.lastPingAt !== "string" || !raw.lastPingAt) continue; + servers[serverId] = { lastPingAt: raw.lastPingAt }; + } + } + return { schemaVersion: RECEIPT_SCHEMA_VERSION, servers }; +} + +/** + * @param {string} [file] + * @returns {{ schemaVersion: number, servers: Record }} + */ +export function readHeartbeatReceipt(file = heartbeatReceiptPath()) { + try { + if (!existsSync(file)) return emptyReceipt(); + return normalizeHeartbeatReceipt(JSON.parse(readFileSync(file, "utf8"))); + } catch (err) { + log.debug("heartbeat receipt read failed", { + error: err?.message ?? String(err), + }); + return emptyReceipt(); + } +} + +/** + * @param {{ schemaVersion: number, servers: Record }} receipt + * @param {string} [file] + */ +export function writeHeartbeatReceipt(receipt, file = heartbeatReceiptPath()) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`, "utf8"); +} + +/** + * Whether a ping should fire for this serverId (no receipt or stale). + * @param {{ servers?: Record } | null} receipt + * @param {string} serverId + * @param {{ now?: number, ttlMs?: number }} [opts] + * @returns {boolean} + */ +export function shouldSendHeartbeat(receipt, serverId, opts = {}) { + if (!serverId) return false; + const now = opts.now ?? Date.now(); + const ttlMs = opts.ttlMs ?? HEARTBEAT_TTL_MS; + const lastPingAt = receipt?.servers?.[serverId]?.lastPingAt; + if (!lastPingAt) return true; + const ageMs = now - new Date(lastPingAt).getTime(); + if (!Number.isFinite(ageMs)) return true; + return ageMs >= ttlMs; +} + +/** + * Record that a heartbeat was attempted for serverId. + * @param {{ schemaVersion: number, servers: Record }} receipt + * @param {string} serverId + * @param {{ now?: number }} [opts] + * @returns {{ schemaVersion: number, servers: Record }} + */ +export function recordHeartbeat(receipt, serverId, opts = {}) { + const now = opts.now ?? Date.now(); + const root = normalizeHeartbeatReceipt(receipt); + root.servers[serverId] = { lastPingAt: new Date(now).toISOString() }; + return root; +} + +/** + * Exclusive per-server lock (best-effort across processes). + * @param {string} serverId + * @param {{ now?: number, lockPath?: string }} [opts] + * @returns {{ unlock: () => void } | null} + */ +export function tryAcquireHeartbeatLock(serverId, opts = {}) { + const now = opts.now ?? Date.now(); + const lockPath = opts.lockPath ?? heartbeatLockPath(serverId); + mkdirSync(path.dirname(lockPath), { recursive: true }); + try { + const fd = openSync(lockPath, "wx"); + writeFileSync(fd, `${now}\n`); + return { + unlock() { + try { + closeSync(fd); + } catch { + /* ignore */ + } + try { + unlinkSync(lockPath); + } catch { + /* ignore */ + } + }, + }; + } catch (err) { + if (err?.code !== "EEXIST") { + log.debug("heartbeat lock open failed", { + error: err?.message ?? String(err), + }); + return null; + } + // Stale lock from a crashed process — reclaim. + try { + const age = now - Number(readFileSync(lockPath, "utf8").trim()); + if (Number.isFinite(age) && age >= LOCK_STALE_MS) { + unlinkSync(lockPath); + return tryAcquireHeartbeatLock(serverId, opts); + } + } catch { + /* ignore */ + } + return null; + } +} + +/** + * Detached `jf rt ping --server-id ` with hook User-Agent. + * Waits for spawn success vs async error before unref. + * @param {string} serverId + * @param {{ spawn?: typeof spawn, env?: NodeJS.ProcessEnv }} [opts] + * @returns {Promise} true if the process started + */ +export function spawnHeartbeatPing(serverId, opts = {}) { + const spawnImpl = opts.spawn ?? spawn; + const env = opts.env ?? process.env; + return new Promise((resolve) => { + let settled = false; + const finish = (ok) => { + if (settled) return; + settled = true; + resolve(ok); + }; + let child; + try { + child = spawnImpl("jf", ["rt", "ping", "--server-id", serverId], { + detached: true, + stdio: "ignore", + env: envWithHookUserAgent(env), + }); + } catch (err) { + log.warn("heartbeat ping spawn threw", { + serverId, + error: err?.message ?? String(err), + }); + finish(false); + return; + } + child.once?.("error", (err) => { + log.warn("heartbeat ping spawn error", { + serverId, + error: err?.message ?? String(err), + }); + finish(false); + }); + child.once?.("spawn", () => { + child.unref?.(); + finish(true); + }); + // Some doubles only expose EventEmitter without 'spawn'; settle soon. + setImmediate(() => { + if (!settled) { + child.unref?.(); + finish(true); + } + }); + }); +} + +/** + * Best-effort daily heartbeat. Never throws. + * @param {{ + * getIdentity?: () => { serverId?: string | null } | null, + * readReceipt?: () => ReturnType, + * writeReceipt?: (r: ReturnType) => void, + * spawnPing?: (serverId: string) => boolean | Promise, + * acquireLock?: (serverId: string) => { unlock: () => void } | null, + * now?: number, + * ttlMs?: number, + * }} [deps] + * @returns {Promise<{ sent: boolean, reason: string, serverId?: string }> | { sent: boolean, reason: string, serverId?: string }} + */ +export function maybeSendAprHeartbeat(deps = {}) { + try { + const getIdentity = + deps.getIdentity ?? (() => getPlatformIdentity().identity); + const identity = getIdentity(); + if (!identity) { + log.debug("heartbeat skip: no identity"); + return { sent: false, reason: "no-identity" }; + } + const serverId = + typeof identity.serverId === "string" && identity.serverId + ? identity.serverId + : null; + if (!serverId) { + log.debug("heartbeat skip: no serverId"); + return { sent: false, reason: "no-server-id" }; + } + + const readReceipt = deps.readReceipt ?? readHeartbeatReceipt; + const writeReceipt = deps.writeReceipt ?? writeHeartbeatReceipt; + const spawnPing = deps.spawnPing ?? ((id) => spawnHeartbeatPing(id)); + const acquireLock = + deps.acquireLock ?? + ((id) => tryAcquireHeartbeatLock(id, { now: deps.now })); + const now = deps.now ?? Date.now(); + const ttlMs = deps.ttlMs ?? HEARTBEAT_TTL_MS; + + const receipt = readReceipt(); + if (!shouldSendHeartbeat(receipt, serverId, { now, ttlMs })) { + log.debug("heartbeat skip: fresh receipt", { serverId }); + return { sent: false, reason: "fresh", serverId }; + } + + const lock = acquireLock(serverId); + if (!lock) { + log.debug("heartbeat skip: lock held", { serverId }); + return { sent: false, reason: "locked", serverId }; + } + + /** @type {ReturnType} */ + let priorReceipt; + try { + // Re-check under lock — another process may have claimed. + priorReceipt = readReceipt(); + if (!shouldSendHeartbeat(priorReceipt, serverId, { now, ttlMs })) { + log.debug("heartbeat skip: fresh under lock", { serverId }); + return { sent: false, reason: "fresh", serverId }; + } + writeReceipt(recordHeartbeat(priorReceipt, serverId, { now })); + } finally { + // Lock covers the claim only; spawn runs unlocked. + lock.unlock(); + } + + const rollback = () => { + try { + writeReceipt(priorReceipt); + } catch (err) { + log.warn("heartbeat receipt rollback failed", { + serverId, + error: err?.message ?? String(err), + }); + } + }; + + try { + const started = spawnPing(serverId); + const finish = (ok) => { + if (!ok) { + rollback(); + return { sent: false, reason: "spawn-failed", serverId }; + } + log.debug("heartbeat ping spawned", { serverId }); + return { sent: true, reason: "spawned", serverId }; + }; + + if (started && typeof started.then === "function") { + return started.then(finish).catch((err) => { + log.warn("heartbeat ping spawn failed", { + serverId, + error: err?.message ?? String(err), + }); + rollback(); + return { sent: false, reason: "spawn-failed", serverId }; + }); + } + return finish(Boolean(started)); + } catch (err) { + log.warn("heartbeat ping spawn failed", { + serverId, + error: err?.message ?? String(err), + }); + rollback(); + return { sent: false, reason: "spawn-failed", serverId }; + } + } catch (err) { + log.warn("maybeSendAprHeartbeat failed", { + error: err?.message ?? String(err), + }); + return { sent: false, reason: "error" }; + } +} diff --git a/plugin/modules/package-resolution/scripts/configure.mjs b/plugin/modules/package-resolution/scripts/configure.mjs new file mode 100644 index 0000000..f7272f2 --- /dev/null +++ b/plugin/modules/package-resolution/scripts/configure.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node +// Agent Package Resolution configure CLI — status + Consent Enable. +// +// Invoked by the agent (absolute path baked into managed onboarding rules / +// onboarding-procedure). Mutates ~/.jfrog/agents-conf.json and syncs managed +// onboarding rules. Per-type No writes ~/.jfrog/skills-cache/apr-onboarding-v1.json; +// bare dismiss sets onboardingPrompt: "off" (global silence). +// Bounded repo lookup is via the base jfrog skill; this CLI verifies + writes. + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { + ensureAgentsConfigScaffold, + getOnboardingPromptState, + loadAgentsConfig, + mergeAgentsConfigPatch, + normalizeAutoSetup, + normalizeRepoMap, +} from "../../core/agents-config.mjs"; +import { createLogger, setLogContext } from "../../core/logger.mjs"; +import { isNeverConfiguredScaffold } from "../../core/scaffold-fingerprint.mjs"; +import { PACKAGE_TYPES } from "./repo-types.mjs"; +import { isPackageResolutionEnabled } from "./feature-flag.mjs"; +import { verifyRepoKey } from "./verify-repo.mjs"; +import { listDeclinedOnboardingTypes } from "./onboarding-decline-cache.mjs"; +import { + dismissOnboardingPrompt, + dismissOnboardingType, + evaluateOnboardingEligibility, + evaluateOnboardingOfferWindow, + listOfferablePackageTypes, + tryBeginOnboardingNudge, +} from "./onboarding.mjs"; + +const log = createLogger("configure"); +const here = path.dirname(fileURLToPath(import.meta.url)); + +function usage() { + return `Usage: node configure.mjs [options] + +Commands: + status Print APR + onboarding status (JSON) + verify-repo --type --repo Verify one virtual repo key + enable --repos Write enabled:true + defaultGlobalRepos; sync offer rules + auto-setup --types Set autoSetup policy + dismiss [--type ] Per-type decline, or global onboardingPrompt off + onboarding-procedure Print stage-2 Consent Enable instructions +`; +} + +function fail(message, code = 1) { + process.stderr.write(`${message}\n`); + process.exit(code); +} + +function parseArgs(argv) { + const args = argv.slice(2); + const command = args[0]; + const opts = {}; + for (let i = 1; i < args.length; i++) { + const a = args[i]; + if ( + a === "--repos" || + a === "--types" || + a === "--type" || + a === "--repo" + ) { + const v = args[++i]; + if (v === undefined) fail(`missing value for ${a}`); + opts[a.slice(2)] = v; + } else if (a === "--help" || a === "-h") { + opts.help = true; + } else { + fail(`unknown argument: ${a}`); + } + } + return { command, opts }; +} + +function parseJsonArg(raw, label) { + try { + return JSON.parse(raw); + } catch { + fail(`${label} must be valid JSON`); + } +} + +function validateRepos(repos) { + const map = normalizeRepoMap(repos); + const keys = Object.keys(map); + if (!keys.length) fail("--repos must include at least one type → repoKey"); + const allowed = new Set(PACKAGE_TYPES); + for (const t of keys) { + if (!allowed.has(t)) fail(`unsupported package type: ${t}`); + } + return map; +} + +async function cmdStatus() { + ensureAgentsConfigScaffold(); + const flag = await isPackageResolutionEnabled(); + const cfg = loadAgentsConfig(); + const prompt = getOnboardingPromptState(); + const elig = evaluateOnboardingEligibility(); + const window = evaluateOnboardingOfferWindow(); + const declined = listDeclinedOnboardingTypes(); + const offerable = listOfferablePackageTypes(); + const out = { + mode: flag.mode, + reason: flag.reason, + cause: flag.cause, + enabled: cfg.packageResolution.enabled, + onboardingPrompt: prompt, + scaffoldUntouched: isNeverConfiguredScaffold(), + eligible: elig.eligible, + eligibilityReason: elig.reason, + offerWindowOpen: window.eligible, + offerWindowReason: window.reason, + declined, + offerable, + defaultGlobalRepos: cfg.packageResolution.defaultGlobalRepos, + autoSetup: cfg.packageResolution.autoSetup, + }; + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); +} + +async function cmdVerifyRepo(opts) { + if (!opts.type || !opts.repo) { + fail("verify-repo requires --type --repo "); + } + const result = await verifyRepoKey({ type: opts.type, repoKey: opts.repo }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.ok) process.exit(1); +} + +async function cmdEnable(opts) { + if (!opts.repos) fail("enable requires --repos ''"); + const repos = validateRepos(parseJsonArg(opts.repos, "--repos")); + ensureAgentsConfigScaffold(); + + /** @type {Record} */ + const verified = {}; + for (const [type, repoKey] of Object.entries(repos)) { + const result = await verifyRepoKey({ type, repoKey }); + if (!result.ok) { + fail( + `enable refused unverified repo ${type}=${repoKey}: ${result.cause ?? "verify-failed"}`, + ); + } + verified[type] = repoKey; + } + + mergeAgentsConfigPatch({ + packageResolution: { + enabled: true, + defaultGlobalRepos: verified, + }, + }); + const nudge = tryBeginOnboardingNudge(); + process.stdout.write( + `${JSON.stringify({ + ok: true, + enabled: true, + defaultGlobalRepos: verified, + offer: nudge.offer, + offerable: listOfferablePackageTypes(), + next: [ + `node "${path.join(here, "configure.mjs")}" auto-setup --types ''`, + `JFROG_EAGER_SETUP_SYNC=1 node "${path.join(here, "print-policy.mjs")}"`, + "After auto-setup + sync print-policy: suggest a new chat so hooks/rules reload cleanly", + ], + })}\n`, + ); +} + +function cmdAutoSetup(opts) { + if (opts.types === undefined) + fail("auto-setup requires --types ''"); + ensureAgentsConfigScaffold(); + const cfg = loadAgentsConfig(); + if (cfg.packageResolution.enabled !== true) { + fail("auto-setup requires packageResolution.enabled: true"); + } + const bound = Object.keys(cfg.packageResolution.defaultGlobalRepos ?? {}); + if (!bound.length) { + fail( + "auto-setup requires at least one type in packageResolution.defaultGlobalRepos", + ); + } + const boundSet = new Set(bound); + /** @type {true | string[]} */ + let autoSetup; + if (opts.types === "true" || opts.types === true) { + // Expand to currently bound types only — never schedule unbound types. + autoSetup = [...bound].sort(); + } else { + const raw = parseJsonArg(opts.types, "--types"); + autoSetup = normalizeAutoSetup(raw); + if (autoSetup === true) { + autoSetup = [...bound].sort(); + } else if (!autoSetup.length) { + fail("--types must be true or a non-empty JSON array of package types"); + } else { + const allowed = new Set(PACKAGE_TYPES); + for (const t of autoSetup) { + if (!allowed.has(t)) fail(`unsupported package type: ${t}`); + if (!boundSet.has(t)) { + fail( + `auto-setup type not in defaultGlobalRepos: ${t} (bound: ${bound.sort().join(", ")})`, + ); + } + } + } + } + mergeAgentsConfigPatch({ packageResolution: { autoSetup } }); + process.stdout.write(`${JSON.stringify({ ok: true, autoSetup })}\n`); +} + +function cmdDismiss(opts) { + if (opts.type !== undefined) { + const allowed = new Set(PACKAGE_TYPES); + if (!allowed.has(opts.type)) { + fail(`unsupported package type: ${opts.type}`); + } + const out = dismissOnboardingType(opts.type); + process.stdout.write(`${JSON.stringify(out)}\n`); + return; + } + dismissOnboardingPrompt(); + process.stdout.write( + `${JSON.stringify({ ok: true, onboardingPrompt: "off" })}\n`, + ); +} + +function cmdOnboardingProcedure() { + const templatePath = path.join( + here, + "../onboarding/package-resolution-onboarding-procedure.md", + ); + let body; + try { + body = readFileSync(templatePath, "utf8"); + } catch (err) { + fail(`onboarding-procedure template unreadable: ${err?.message ?? err}`); + } + const configurePath = path.join(here, "configure.mjs"); + const printPath = path.join(here, "print-policy.mjs"); + body = body.replace(/\{\{CONFIGURE_COMMAND\}\}/g, configurePath); + body = body.replace(/\{\{PRINT_POLICY_COMMAND\}\}/g, printPath); + process.stdout.write(body.endsWith("\n") ? body : `${body}\n`); +} + +async function main() { + setLogContext({ ide: "configure" }); + const { command, opts } = parseArgs(process.argv); + if (!command || opts.help) { + process.stdout.write(usage()); + process.exit(command ? 0 : 1); + } + switch (command) { + case "status": + await cmdStatus(); + break; + case "verify-repo": + await cmdVerifyRepo(opts); + break; + case "enable": + await cmdEnable(opts); + break; + case "auto-setup": + cmdAutoSetup(opts); + break; + case "dismiss": + cmdDismiss(opts); + break; + case "onboarding-procedure": + cmdOnboardingProcedure(); + break; + default: + fail(`unknown command: ${command}\n${usage()}`); + } +} + +main().catch((err) => { + log.warn("configure failed", { error: err?.message ?? String(err) }); + fail(err?.message ?? String(err)); +}); diff --git a/plugin/modules/package-resolution/scripts/eager-setup.mjs b/plugin/modules/package-resolution/scripts/eager-setup.mjs index c1fe710..76e1830 100644 --- a/plugin/modules/package-resolution/scripts/eager-setup.mjs +++ b/plugin/modules/package-resolution/scripts/eager-setup.mjs @@ -6,6 +6,9 @@ // need `jf setup` (per the receipt), spawn a DETACHED background worker for // them, and return a short status note for the injected instruction. Never // runs `jf setup` itself — injection must stay fast (< 7s hook budget). +// Exception: `JFROG_EAGER_SETUP_SYNC=1` waits for the worker, then +// re-reads the receipt so the note says `already set up` instead of +// `setting up in the background` (Consent Enable print-policy). // 2. WORKER (background, `node eager-setup.mjs --run `): take a // global lock, re-check the receipt, run `jf setup --server-id --repo` // one package manager at a time with a per-package-manager timeout, and @@ -42,6 +45,7 @@ import { import { readReceipt, writeReceipt, + receiptEntry, evaluateSetupNeed, applySetupResult, } from "./eager-setup-receipt.mjs"; @@ -51,6 +55,7 @@ import { packageManagerBinaryOnPath, } from "./package-manager-family.mjs"; import { detectSetupConflict } from "./setup-conflict.mjs"; +import { envWithHookUserAgent } from "../../core/jf-user-agent.mjs"; const log = createLogger("eager-setup"); @@ -384,6 +389,29 @@ export async function orchestrateEagerSetup(ctx = {}) { "utf8", ).toString("base64"); spawnWorker(payload, toRun.length); + if (process.env.JFROG_EAGER_SETUP_SYNC === "1") { + // spawnSync already waited. Re-bucket from the receipt so Consent + // Enable print-policy does not still say "setting up in the + // background" (that line is the agent's cue to rewrite with + // --registry / --index-url / GOPROXY). Use the receipt entry, not + // evaluateSetupNeed: ttl=0 would still look "needed" after a + // successful setup. + const after = await readReceipt(); + pending.length = 0; + for (const job of toRun) { + const entry = receiptEntry(after, serverId, job.packageManager); + if (entry?.status === "ok" && entry.repoKey === job.repoKey) { + configured.push(job.packageManager); + } else if ( + entry?.status === "failed" && + entry.repoKey === job.repoKey + ) { + deferred.push(job.packageManager); + } else { + pending.push(job.packageManager); + } + } + } } } @@ -603,9 +631,11 @@ export function releaseLock() { */ function supportedPackageManagers() { try { + // --help is local (no Artifactory traffic); no UA needed for telemetry. const res = spawnSync("jf", ["setup", "--help"], { encoding: "utf8", timeout: 5000, + env: process.env, }); const out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`; const m = out.match(/Supported package managers are:\s*([^.\n]+)/i); @@ -663,6 +693,7 @@ function runJfSetup(packageManager, serverId, repoKey) { const res = spawnSync("jf", args, { encoding: "utf8", timeout: PER_PACKAGE_MANAGER_TIMEOUT_MS, + env: envWithHookUserAgent(process.env), }); if (res.error) { return { ok: false, reason: `spawn error: ${res.error.message}` }; diff --git a/plugin/modules/package-resolution/scripts/feature-flag.mjs b/plugin/modules/package-resolution/scripts/feature-flag.mjs index 23cf036..fe2a5a2 100644 --- a/plugin/modules/package-resolution/scripts/feature-flag.mjs +++ b/plugin/modules/package-resolution/scripts/feature-flag.mjs @@ -5,7 +5,7 @@ // // 1. JF_AGENT_PACKAGE_RESOLUTION_DISABLE=1 → mode="off" (env kill switch) // 2. packageResolution.enabled !== true in → mode="off" (file-primary gate; -// ~/.jfrog/agents-conf.json default off in shipped template) +// ~/.jfrog/agents-conf.json shipped template defaults on) // 3. jf config + readiness probe (via jf-identity) // → mode="routing" when identity is usable and Artifactory accepts it; // otherwise mode="pending" with a `cause`: diff --git a/plugin/modules/package-resolution/scripts/index.mjs b/plugin/modules/package-resolution/scripts/index.mjs index 8551012..e6964ef 100644 --- a/plugin/modules/package-resolution/scripts/index.mjs +++ b/plugin/modules/package-resolution/scripts/index.mjs @@ -3,9 +3,46 @@ // Invoked by modules/*-session-start.mjs via run-capability.mjs (argv capability name). // Performs NO harness-specific I/O (no stdin/stdout). +import { createLogger } from "../../core/logger.mjs"; import { isPackageResolutionEnabled } from "./feature-flag.mjs"; import { renderInstruction } from "./render-instruction.mjs"; import { orchestrateEagerSetup } from "./eager-setup.mjs"; +import { maybeSendAprHeartbeat } from "./apr-heartbeat.mjs"; +import { + maybeMigrateScaffoldEnabled, + removeOnboardingOfferRules, + tryBeginOnboardingNudge, +} from "./onboarding.mjs"; + +const log = createLogger("package-resolution"); + +/** + * Adapter `ctx.ide` → UA wire `tool=` token. + * Only hooks-specific mapping (`claude_code` → `claude`). Env-marker harness + * detection stays in CLI (`ai-agent/`); model stamps only in skills when known. + * @param {string | undefined} ide + * @returns {string | undefined} + */ +function wireToolFromIde(ide) { + if (ide === "claude_code") return "claude"; + if (ide === "cursor" || ide === "copilot") return ide; + return undefined; +} + +/** + * Sync soft-bridge offer rules (or clear them). Kill switch clears only. + * @param {{ ide?: string, sessionId?: string, killSwitch?: boolean }} opts + * @returns {{ offer: boolean, reason: string }} + */ +function syncOfferRules(opts = {}) { + if (opts.killSwitch) { + removeOnboardingOfferRules(); + return { offer: false, reason: "DISABLE" }; + } + return tryBeginOnboardingNudge({ + actor: { ide: opts.ide, sessionId: opts.sessionId }, + }); +} export const packageResolution = { name: "package-resolution", @@ -17,9 +54,55 @@ export const packageResolution = { /** @returns {Promise} markdown instruction text, or "" when no-op */ async sessionStart(ctx = {}) { + // Kill switch must not persist enabled:true on a legacy scaffold — that + // would activate APR the moment DISABLE is later removed, without consent. + if (process.env.JF_AGENT_PACKAGE_RESOLUTION_DISABLE !== "1") { + try { + maybeMigrateScaffoldEnabled(); + } catch (err) { + log.warn("scaffold enabled migration failed", { + error: err?.message ?? String(err), + }); + } + } + const flag = await isPackageResolutionEnabled(); this.mode = flag.mode; + // Hook UA tool= from adapter id; CLI may still append ai-agent/ from env. + const tool = wireToolFromIde(ctx.ide); + if (tool) process.env.JFROG_APR_UA_TOOL = tool; + + const killSwitch = flag.mode === "off" && flag.reason === "DISABLE"; + let nudge = { offer: false, reason: "nudge-error" }; + try { + nudge = syncOfferRules({ + ide: ctx.ide, + sessionId: ctx.sessionId, + killSwitch, + }); + } catch (err) { + log.warn("onboarding nudge failed", { + error: err?.message ?? String(err), + }); + } + + // Off: no policy injection. Soft-bridge rules already synced above + // (except kill switch, which cleared them). + if (flag.mode === "off") { + this.meta = { + reason: flag.reason, + identity: flag.identity ?? "-", + nudge: nudge.offer, + nudgeReason: nudge.reason, + mode: "off", + }; + return ""; + } + + // Enabled paths: inject pending/routing; keep offer rules when types remain + // unbound + undeclined (synced above). + // Feature 2 — auto setup on startup. Only in routing mode (identity + // resolution available). Runs OFF the critical path: it just decides what // needs setup, spawns a detached worker, and returns a note. Never @@ -27,6 +110,9 @@ export const packageResolution = { let autoSetupStatus = ""; if (flag.mode === "routing") { autoSetupStatus = await orchestrateEagerSetup(ctx); + // Daily best-effort `jf rt ping` (trigger=hook UA) so observability still + // sees APR sessions when eager setup is skipped. Never throws. + await Promise.resolve(maybeSendAprHeartbeat()); } const { text, meta } = await renderInstruction(flag, { @@ -36,6 +122,8 @@ export const packageResolution = { this.meta = { reason: flag.reason, identity: flag.identity ?? "-", + nudge: nudge.offer, + nudgeReason: nudge.reason, ...(autoSetupStatus ? { eagerSetup: true } : {}), ...meta, }; diff --git a/plugin/modules/package-resolution/scripts/onboarding-decline-cache.mjs b/plugin/modules/package-resolution/scripts/onboarding-decline-cache.mjs new file mode 100644 index 0000000..050a7f3 --- /dev/null +++ b/plugin/modules/package-resolution/scripts/onboarding-decline-cache.mjs @@ -0,0 +1,215 @@ +// Per-type APR onboarding decline cache. +// +// Durable "No" for one package type lives here — not in agents-conf.json — +// so declining pypi does not silence a later npm offer. +// +// File: ~/.jfrog/skills-cache/apr-onboarding-v1.json +// { +// "schema": 1, +// "declined": { +// "pypi": { "at": "2026-08-17T10:00:00.000Z" } +// } +// } + +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { createLogger } from "../../core/logger.mjs"; +import { PACKAGE_TYPES } from "./repo-types.mjs"; + +const log = createLogger("onboarding-decline-cache"); + +const SCHEMA = 1; +const ALLOWED = new Set(PACKAGE_TYPES); +const DECLINE_CACHE_LOCK_STALE_MS = 30_000; +const DECLINE_CACHE_LOCK_WAIT_MS = 1_000; +const DECLINE_CACHE_LOCK_POLL_MS = 25; + +/** @returns {string} `~/.jfrog/skills-cache` */ +function cacheDir(home = homedir()) { + return path.join(home, ".jfrog", "skills-cache"); +} + +/** @param {string} [home] */ +export function onboardingDeclineCachePath(home = homedir()) { + return path.join(cacheDir(home), "apr-onboarding-v1.json"); +} + +/** @param {string} [home] */ +function declineCacheLockPath(home = homedir()) { + return path.join(cacheDir(home), "apr-onboarding-v1.lock"); +} + +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function tryDeclineCacheLock(home) { + mkdirSync(path.dirname(declineCacheLockPath(home)), { recursive: true }); + const fd = openSync(declineCacheLockPath(home), "wx"); + try { + writeFileSync(fd, `${process.pid}\n${Date.now()}\n`); + } finally { + closeSync(fd); + } +} + +function releaseDeclineCacheLock(home) { + try { + unlinkSync(declineCacheLockPath(home)); + } catch { + // ignore + } +} + +function reclaimStaleDeclineCacheLock(home, nowMs) { + try { + const raw = readFileSync(declineCacheLockPath(home), "utf8"); + const ts = Number(raw.split("\n")[1]); + if (!Number.isFinite(ts) || nowMs - ts > DECLINE_CACHE_LOCK_STALE_MS) { + unlinkSync(declineCacheLockPath(home)); + return true; + } + } catch { + // ignore + } + return false; +} + +function acquireDeclineCacheLock(home, nowMs = Date.now()) { + try { + tryDeclineCacheLock(home); + return true; + } catch { + if (!reclaimStaleDeclineCacheLock(home, nowMs)) return false; + try { + tryDeclineCacheLock(home); + return true; + } catch { + return false; + } + } +} + +/** + * Serialize read-modify-write of the decline cache across processes. + * @template T + * @param {string} home + * @param {() => T} fn + * @returns {T} + */ +function withDeclineCacheLock(home, fn) { + const deadline = Date.now() + DECLINE_CACHE_LOCK_WAIT_MS; + let locked = acquireDeclineCacheLock(home); + while (!locked && Date.now() < deadline) { + sleepSync(DECLINE_CACHE_LOCK_POLL_MS); + locked = acquireDeclineCacheLock(home, Date.now()); + } + if (!locked) { + throw new Error( + "apr-onboarding-v1.lock: could not acquire lock within wait budget", + ); + } + try { + return fn(); + } finally { + releaseDeclineCacheLock(home); + } +} + +/** @returns {{ schema: number, declined: Record }} */ +function emptyCache() { + return { schema: SCHEMA, declined: {} }; +} + +/** + * @param {unknown} data + * @returns {{ schema: number, declined: Record }} + */ +export function normalizeOnboardingDeclineCache(data) { + if (!data || typeof data !== "object" || data.schema !== SCHEMA) { + return emptyCache(); + } + /** @type {Record} */ + const declined = {}; + const raw = data.declined; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + for (const [type, entry] of Object.entries(raw)) { + if (!ALLOWED.has(type)) continue; + if (!entry || typeof entry !== "object") continue; + const at = typeof entry.at === "string" && entry.at ? entry.at : null; + if (!at) continue; + declined[type] = { at }; + } + } + return { schema: SCHEMA, declined }; +} + +/** + * @param {string} [home] + * @returns {{ schema: number, declined: Record }} + */ +export function readOnboardingDeclineCache(home = homedir()) { + const file = onboardingDeclineCachePath(home); + try { + if (!existsSync(file)) return emptyCache(); + return normalizeOnboardingDeclineCache( + JSON.parse(readFileSync(file, "utf8")), + ); + } catch (err) { + log.warn("onboarding decline cache unreadable; treating as empty", { + error: err?.message ?? String(err), + }); + return emptyCache(); + } +} + +/** + * @param {string} [home] + * @returns {string[]} + */ +export function listDeclinedOnboardingTypes(home = homedir()) { + return Object.keys(readOnboardingDeclineCache(home).declined).sort(); +} + +/** + * @param {{ schema: number, declined: Record }} root + * @param {string} [home] + */ +function writeCache(root, home = homedir()) { + const file = onboardingDeclineCachePath(home); + mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, `${JSON.stringify(root, null, 2)}\n`); + renameSync(tmp, file); +} + +/** + * Record a durable per-type decline. + * @param {string} type APR package type + * @param {{ at?: string, home?: string }} [opts] + */ +export function declineOnboardingType(type, opts = {}) { + if (!ALLOWED.has(type)) { + throw new Error(`unsupported package type for dismiss: ${type}`); + } + const home = opts.home ?? homedir(); + const at = opts.at ?? new Date().toISOString(); + withDeclineCacheLock(home, () => { + // Re-read under the lock so concurrent declines accumulate. + const root = readOnboardingDeclineCache(home); + root.declined[type] = { at }; + writeCache(root, home); + }); + log.info("onboarding.decline.recorded", { type, at }); +} diff --git a/plugin/modules/package-resolution/scripts/onboarding.mjs b/plugin/modules/package-resolution/scripts/onboarding.mjs new file mode 100644 index 0000000..66ea1a7 --- /dev/null +++ b/plugin/modules/package-resolution/scripts/onboarding.mjs @@ -0,0 +1,231 @@ +// APR onboarding offer eligibility + managed rule sync. +// +// Soft-bridge (2026-08-17): offer while the global gate is open AND at least +// one package type is unbound in defaultGlobalRepos and not declined in +// ~/.jfrog/skills-cache/apr-onboarding-v1.json. Per-type No does not set +// onboardingPrompt: "off". Bare dismiss still does (global kill). + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { + getOnboardingPromptState, + loadAgentsConfig, + mergeAgentsConfigPatch, +} from "../../core/agents-config.mjs"; +import { isNeverConfiguredScaffold } from "../../core/scaffold-fingerprint.mjs"; +import { createLogger } from "../../core/logger.mjs"; +import { + declineOnboardingType, + listDeclinedOnboardingTypes, +} from "./onboarding-decline-cache.mjs"; +import { PACKAGE_TYPES } from "./repo-types.mjs"; +import { syncOnboardingOfferRules } from "./sync-onboarding-rule.mjs"; + +const log = createLogger("onboarding"); + +/** + * Flip never-configured scaffolds to enabled:true (and onboardingPrompt:auto + * when the field was absent so the offer gate survives the fingerprint change). + * @returns {{ migrated: boolean }} + */ +export function maybeMigrateScaffoldEnabled() { + if (!isNeverConfiguredScaffold()) return { migrated: false }; + if (getOnboardingPromptState() === "off") return { migrated: false }; + const cfg = loadAgentsConfig(); + if (cfg.packageResolution.enabled === true) return { migrated: false }; + + /** @type {Record} */ + const patch = { enabled: true }; + if (getOnboardingPromptState() === "absent") { + patch.onboardingPrompt = "auto"; + } + mergeAgentsConfigPatch({ packageResolution: patch }); + log.info("onboarding.scaffold.enabled_migrated", { + setOnboardingPromptAuto: patch.onboardingPrompt === "auto", + }); + return { migrated: true }; +} + +/** + * Global offer gate (ignores per-type declines / bindings). + * @returns {{ open: boolean, reason: string }} + */ +export function evaluateOnboardingGate() { + const prompt = getOnboardingPromptState(); + if (prompt === "off") { + return { open: false, reason: "prompt-off" }; + } + if (prompt === "auto") { + return { open: true, reason: "prompt-auto" }; + } + if (isNeverConfiguredScaffold()) { + return { open: true, reason: "fingerprint-match" }; + } + return { open: false, reason: "fingerprint-miss" }; +} + +/** + * @param {string} [home] + * @returns {Record} + */ +function defaultGlobalReposFor(home = homedir()) { + if (home === homedir()) { + return loadAgentsConfig().packageResolution.defaultGlobalRepos ?? {}; + } + try { + const conf = path.join(home, ".jfrog", "agents-conf.json"); + if (!existsSync(conf)) return {}; + const raw = JSON.parse(readFileSync(conf, "utf8")); + const repos = raw?.packageResolution?.defaultGlobalRepos; + return repos && typeof repos === "object" && !Array.isArray(repos) + ? repos + : {}; + } catch { + return {}; + } +} + +/** + * Types that may still receive a Consent Enable offer. + * @param {string} [home] + * @returns {string[]} + */ +export function listOfferablePackageTypes(home = homedir()) { + const repos = defaultGlobalReposFor(home); + const declined = new Set(listDeclinedOnboardingTypes(home)); + return PACKAGE_TYPES.filter((type) => { + const key = repos[type]; + const bound = typeof key === "string" && key.trim().length > 0; + return !bound && !declined.has(type); + }); +} + +/** + * Whether SessionStart may keep the managed offer rules. + * @returns {{ eligible: boolean, reason: string, offerable?: string[] }} + */ +export function evaluateOnboardingEligibility() { + const gate = evaluateOnboardingGate(); + if (!gate.open) { + return { eligible: false, reason: gate.reason }; + } + const offerable = listOfferablePackageTypes(); + if (!offerable.length) { + return { eligible: false, reason: "nothing-to-offer" }; + } + return { eligible: true, reason: gate.reason, offerable }; +} + +/** + * Whether SessionStart should keep the offer rule. + */ +export function evaluateOnboardingOfferWindow() { + return evaluateOnboardingEligibility(); +} + +/** + * SessionStart path: keep/write the relevance-gated offer while eligible; + * otherwise delete it. + * @param {{ actor?: object }} [opts] + * @returns {{ offer: boolean, reason: string, offerable?: string[] }} + */ +export function tryBeginOnboardingNudge(opts = {}) { + const elig = evaluateOnboardingEligibility(); + if (!elig.eligible) { + log.info("onboarding.offer.cleared", { + reason: elig.reason, + actor: opts.actor, + }); + syncOnboardingOfferRules({ present: false }); + return { offer: false, reason: elig.reason }; + } + + return presentOfferRules(elig.reason, elig.offerable, opts); +} + +/** + * Write the offer rules unless the user resolved onboarding while we were + * deciding (concurrent global dismiss / last type bound). + * Exported for the interleaved `resolved-elsewhere` unit test. + * @param {string} reason + * @param {string[]} offerable + * @param {{ actor?: object }} [opts] + */ +export function presentOfferRules(reason, offerable, opts = {}) { + if (isOfferResolved()) { + log.info("onboarding.offer.cleared", { + reason: "resolved-elsewhere", + actor: opts.actor, + }); + syncOnboardingOfferRules({ present: false }); + return { offer: false, reason: "resolved-elsewhere" }; + } + const sync = syncOnboardingOfferRules({ present: true }); + if (sync.skipped || sync.wrote.length === 0) { + log.info("onboarding.offer.cleared", { + reason: sync.reason ?? "write-failed", + actor: opts.actor, + }); + return { offer: false, reason: sync.reason ?? "write-failed" }; + } + log.info("onboarding.offer.written", { + eligibility: reason, + offerable, + actor: opts.actor, + }); + return { offer: true, reason, offerable }; +} + +/** Global off or nothing left to offer. */ +function isOfferResolved() { + return ( + getOnboardingPromptState() === "off" || + listOfferablePackageTypes().length === 0 + ); +} + +/** Clear managed rules on both harnesses. */ +export function clearOnboardingOfferRules() { + log.info("onboarding.offer.cleared", { reason: "resolved" }); + syncOnboardingOfferRules({ present: false }); +} + +/** + * Delete managed rules without changing agents-conf (kill switch) so a pending + * offer can still be presented once the block lifts. + */ +export function removeOnboardingOfferRules() { + log.info("onboarding.offer.cleared", { reason: "removed" }); + syncOnboardingOfferRules({ present: false }); +} + +/** Write onboardingPrompt: "off" into agents-conf.json. */ +export function persistOnboardingPromptOff() { + mergeAgentsConfigPatch({ + packageResolution: { onboardingPrompt: "off" }, + }); +} + +/** Global silence — bare dismiss. */ +export function dismissOnboardingPrompt() { + persistOnboardingPromptOff(); + clearOnboardingOfferRules(); +} + +/** + * Per-type No — durable decline for one APR package type. + * @param {string} type + * @returns {{ ok: true, declinedType: string, offerable: string[], offer: boolean }} + */ +export function dismissOnboardingType(type) { + declineOnboardingType(type); + const nudge = tryBeginOnboardingNudge(); + return { + ok: true, + declinedType: type, + offerable: listOfferablePackageTypes(), + offer: nudge.offer, + }; +} diff --git a/plugin/modules/package-resolution/scripts/render-instruction.mjs b/plugin/modules/package-resolution/scripts/render-instruction.mjs index 4c81347..56e31aa 100644 --- a/plugin/modules/package-resolution/scripts/render-instruction.mjs +++ b/plugin/modules/package-resolution/scripts/render-instruction.mjs @@ -170,10 +170,8 @@ function rewriteBulletFor(type, resolved) { const r = resolved[type]; if (!r) { return ( - `- \`${type}\` — **unresolved** (no Artifactory repo for this package manager yet). ` + - `Per hard rule #5, do not invent a URL: invoke \`jfrog-setup-package-managers\` ` + - `for \`${type}\` BEFORE any direct command. Once the binding is recorded, ` + - `route subsequent \`${type}\` commands through the resolved URL yourself.` + `- \`${type}\` — **unresolved**. Per hard rule #5: invoke \`jfrog-setup-package-managers\` ` + + `for \`${type}\` BEFORE any direct command; then route via the resolved URL.` ); } const url = r.baseUrl; @@ -194,8 +192,11 @@ function rewriteBulletFor(type, resolved) { return `- \`go get \` → \`GOPROXY=${url},direct go get \``; case "docker": return ( - `- \`docker pull [/]acme/app:1.2\` → \`docker pull ${url}/acme/app:1.2\` (drop a leading PUBLIC registry host — \`docker.io\`, \`ghcr.io\`, \`quay.io\`, \`gcr.io\`, …. Leave \`localhost\`/\`127.0.0.1\`, private/internal registries, and the JFrog host itself as-is; if unsure, resolve the host — a private/loopback IP means internal, leave it)\n` + - `- \`podman pull …\` → same prefix rules as docker against \`${url}\`` + `- \`docker pull [/]acme/app:1.2\` → \`docker pull ${url}/acme/app:1.2\` ` + + `(drop leading PUBLIC hosts: \`docker.io\`, \`ghcr.io\`, \`quay.io\`, \`gcr.io\`, …. ` + + `Leave \`localhost\`/\`127.0.0.1\`, private/internal registries, and the JFrog host as-is; ` + + `if unsure, resolve the host — a private/loopback IP means internal, leave it)\n` + + `- \`podman pull …\` → same prefix rules against \`${url}\`` ); case "maven": return `- \`mvn ...\` → config-driven; run \`jfrog-setup-package-managers\` if not yet bound.`; @@ -221,22 +222,31 @@ function buildDockerSection(governed, resolved) { const resolvedDocker = resolved.docker; const body = resolvedDocker ? [ - "- **Bare refs go to Docker Hub.** `docker pull alpine:latest` (no registry host) uses", - " `docker.io` — `jf setup docker` does **not** change that. You must prefix:", - " `docker pull //` using the docker row above (`host/repoKey`, not", - " `https://…`).", - "- **Explicit hosts too.** `docker pull ghcr.io/foo/bar` (or any registry host in the ref)", - " — also route through JFrog: prefix with the docker row above; do not pull from the", - " upstream host directly.", + "- **Bare refs go to Docker Hub.** `docker pull alpine:latest` (no host) uses", + " `docker.io` — `jf setup docker` does **not** change that. Prefix with the docker", + " row above: `docker pull //` (`host/repoKey`, not `https://…`).", + "- **Explicit hosts too.** `docker pull ghcr.io/foo/bar` (or any registry host) — also", + " route through JFrog via the docker row; do not pull the upstream host directly.", ].join("\n") : [ - "- **Unresolved docker ⇒ no docker commands.** The docker row shows", - " ``; do not run `docker pull/run/create` until setup completes", - ' and you have a prefixed ref. Do not "try first, fix later."', + "- **Unresolved docker ⇒ no docker commands.** Do not run `docker pull/run/create`", + " until setup completes and you have a prefixed ref. Do not try first, fix later.", ].join("\n"); return "\n## Docker (before any `docker pull`)\n\n" + body + "\n"; } +// Agent Guard carve-out only when npm is governed (the exception is npm-scoped). +function buildAgentGuardSection(governed) { + if (!governed.includes("npm")) return ""; + return [ + "**Agent Guard bootstrap — exception to Decision step 4 and hard rule #7.** A command that installs", + "`@jfrog/agent-guard` and no other package may keep the registry its Agent Guard policy specifies", + "(e.g. `npx --yes --registry @jfrog/agent-guard`). Nothing else is covered. This exception", + "never authorizes a public registry, and it never widens to another package.", + "", + ].join("\n"); +} + // Pending-mode scope line — the governed package managers are known from config // alone (no network / no resolution needed). Notes that matching package // managers will be @@ -348,6 +358,7 @@ export async function renderInstruction(flag, ctx = {}) { buildRewriteBullets(governed, resolved), ) .replace(/\{\{DOCKER_SECTION\}\}/g, buildDockerSection(governed, resolved)) + .replace(/\{\{AGENT_GUARD_SECTION\}\}/g, buildAgentGuardSection(governed)) .replace( /\{\{AUTO_SETUP_STATUS\}\}/g, ctx.autoSetupStatus ? `\n${ctx.autoSetupStatus}\n` : "", diff --git a/plugin/modules/package-resolution/scripts/resolver.mjs b/plugin/modules/package-resolution/scripts/resolver.mjs index 68cda81..902763d 100644 --- a/plugin/modules/package-resolution/scripts/resolver.mjs +++ b/plugin/modules/package-resolution/scripts/resolver.mjs @@ -24,6 +24,7 @@ import { import { getPlatformIdentity, authHeader, + isHttpsIdentityUrl, safeErrorMessage, } from "../../core/jf-identity.mjs"; import { PACKAGE_TYPES, repoMatchesPackageType } from "./repo-types.mjs"; @@ -176,6 +177,10 @@ function normalizeCacheRoot(data) { async function fetchRepoConfig(repoKey, id, deadline) { if (!id) return null; + if (!isHttpsIdentityUrl(id)) { + log.warn("refusing repo verify over a non-HTTPS platform URL", { repoKey }); + return null; + } const url = `${id.url}/artifactory/api/repositories/${encodeURIComponent(repoKey)}`; // Network call on session start (cache miss + verifyRepos) — log at info so a // fresh session's Artifactory calls are visible without enabling debug. @@ -409,7 +414,16 @@ async function ensureSessionResolved( serverIdHint, verifyDeadline = Date.now() + REPO_VERIFY_BUDGET_MS, ) { - const id = identityOrNull(); + const rawId = identityOrNull(); + if (rawId && !isHttpsIdentityUrl(rawId)) { + log.warn("refusing to resolve package URLs over a non-HTTPS platform URL"); + SESSION.serverId = effectiveServerId(serverIdHint, rawId); + SESSION.byType = {}; + SESSION.meta = null; + return; + } + + const id = rawId; const serverId = effectiveServerId(serverIdHint, id); if (SESSION.serverId === serverId && SESSION.byType) return; @@ -457,6 +471,10 @@ async function applyWorkspaceOverlay( } const id = identityOrNull(); + if (id && !isHttpsIdentityUrl(id)) { + log.warn("refusing workspace overlay over a non-HTTPS platform URL"); + return; + } const base = id ? `${id.url}/artifactory` : ""; const pr = loadAgentsConfig().packageResolution; const adminRepos = pr.defaultGlobalRepos ?? {}; @@ -595,7 +613,7 @@ if (isMain) { const type = process.argv[2]; if (!type) { console.error("usage: node lib/resolver.mjs "); - console.error(" types: npm pypi maven go docker helm nuget"); + console.error(" types: npm pypi maven gradle go docker helm nuget"); process.exit(1); } const result = await resolve(type); diff --git a/plugin/modules/package-resolution/scripts/sync-onboarding-rule.mjs b/plugin/modules/package-resolution/scripts/sync-onboarding-rule.mjs new file mode 100644 index 0000000..7d0ccc5 --- /dev/null +++ b/plugin/modules/package-resolution/scripts/sync-onboarding-rule.mjs @@ -0,0 +1,267 @@ +// Sync managed always-on onboarding rules for Cursor and Claude. +// +// Same stage-1 body on both harnesses. Presence is a projection of eligibility +// (SessionStart / configure success paths call this) — not a second source of truth. + +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createLogger } from "../../core/logger.mjs"; +import { listDeclinedOnboardingTypes } from "./onboarding-decline-cache.mjs"; +import { PACKAGE_TYPES } from "./repo-types.mjs"; + +const log = createLogger("sync-onboarding-rule"); + +const here = path.dirname(fileURLToPath(import.meta.url)); +const STAGE1_TEMPLATE = path.join( + here, + "../onboarding/package-resolution-nudge.md", +); + +export const CURSOR_RULE_NAME = "jfrog-apr-onboarding.mdc"; +export const CLAUDE_RULE_NAME = "jfrog-apr-onboarding.md"; + +export const CURSOR_ADMIN_GUIDE_URL = + "https://github.com/jfrog/cursor-plugin/blob/main/docs/package-resolution-admin-guide.md"; +export const CLAUDE_ADMIN_GUIDE_URL = + "https://github.com/jfrog/claude-plugin/blob/main/docs/package-resolution-admin-guide.md"; + +/** Human-readable list of APR package types (keeps rule copy in sync with code). */ +export function supportedTypesPhrase() { + return PACKAGE_TYPES.join(", "); +} + +export function cursorRulePath(home = homedir()) { + return path.join(home, ".cursor", "rules", CURSOR_RULE_NAME); +} + +export function claudeRulePath(home = homedir()) { + return path.join(home, ".claude", "rules", CLAUDE_RULE_NAME); +} + +function configureCommand() { + return path.join(here, "configure.mjs"); +} + +function printPolicyCommand() { + return path.join(here, "print-policy.mjs"); +} + +/** + * Fill stage-1 template placeholders with absolute paths from THIS bundle. + * @param {{ adminGuideUrl?: string }} [opts] + * @returns {string} empty when unreadable + */ +export function loadStage1Body(opts = {}) { + try { + let body = readFileSync(STAGE1_TEMPLATE, "utf8"); + const configurePath = configureCommand().replace(/\\/g, "\\\\"); + body = body.replace(/\{\{CONFIGURE_COMMAND\}\}/g, configurePath); + body = body.replace( + /\{\{PRINT_POLICY_COMMAND\}\}/g, + printPolicyCommand().replace(/\\/g, "\\\\"), + ); + body = body.replace( + /\{\{ADMIN_GUIDE_URL\}\}/g, + opts.adminGuideUrl ?? CURSOR_ADMIN_GUIDE_URL, + ); + body = body.replace( + /\{\{SUPPORTED_TYPES\}\}/g, + opts.supportedTypes ?? supportedTypesPhrase(), + ); + return body; + } catch (err) { + log.warn("stage-1 template unreadable", { + error: err?.message ?? String(err), + }); + return ""; + } +} + +function cursorRuleContents(body) { + const types = supportedTypesPhrase(); + return `--- +description: >- + MUST read this rule BEFORE any ${types} install, package add, or Artifactory + package-manager setup. Offer APR Consent Enable when an unbound package type + is about to install. Body already forbids asking on unrelated chats. +alwaysApply: true +--- + +${body.trim()}\n`; +} + +function claudeRuleContents(body) { + return `${body.trim()}\n`; +} + +function atomicWrite(file, contents) { + mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, contents); + renameSync(tmp, file); +} + +function safeWriteRule(file, contents) { + try { + atomicWrite(file, contents); + return true; + } catch (err) { + log.warn("failed to write onboarding rule", { + path: file, + error: err?.message ?? String(err), + }); + return false; + } +} + +/** + * The offer rule must never exist once the global gate is off or no package + * type remains offerable (bound in defaultGlobalRepos or declined). + * Read the config for the same home we write into, so a mistaken `present: true` + * caller cannot resurrect a resolved offer. + * @param {string} home + * @returns {string|null} suppression reason, or null when the offer may show + */ +function offerSuppressedFor(home) { + /** @type {Record} */ + let repos = {}; + try { + const raw = JSON.parse( + readFileSync(path.join(home, ".jfrog", "agents-conf.json"), "utf8"), + ); + const pr = raw?.packageResolution ?? {}; + if (pr.onboardingPrompt === "off") return "prompt-off"; + if (pr.defaultGlobalRepos && typeof pr.defaultGlobalRepos === "object") { + repos = pr.defaultGlobalRepos; + } + } catch { + // missing or unreadable config: treat repos as empty + } + const declined = new Set(listDeclinedOnboardingTypes(home)); + const offerable = PACKAGE_TYPES.some((type) => { + const key = repos[type]; + const bound = typeof key === "string" && key.trim().length > 0; + return !bound && !declined.has(type); + }); + if (!offerable) return "nothing-to-offer"; + return null; +} + +function deleteIfExists(file) { + try { + if (existsSync(file)) unlinkSync(file); + } catch (err) { + log.warn("failed to delete onboarding rule", { + path: file, + error: err?.message ?? String(err), + }); + } +} + +/** + * @param {{ present: boolean, home?: string }} opts + * @returns {{ wrote: string[], deleted: string[], skipped: boolean, reason?: string }} + */ +export function syncOnboardingOfferRules(opts) { + const home = opts.home ?? homedir(); + const cursorPath = cursorRulePath(home); + const claudePath = claudeRulePath(home); + + if (!opts.present) { + deleteIfExists(cursorPath); + deleteIfExists(claudePath); + return { wrote: [], deleted: [cursorPath, claudePath], skipped: false }; + } + + const suppressed = offerSuppressedFor(home); + if (suppressed) { + log.debug("offer rule write suppressed", { reason: suppressed }); + deleteIfExists(cursorPath); + deleteIfExists(claudePath); + return { + wrote: [], + deleted: [cursorPath, claudePath], + skipped: true, + reason: suppressed, + }; + } + + const cursorBody = loadStage1Body({ adminGuideUrl: CURSOR_ADMIN_GUIDE_URL }); + const claudeBody = loadStage1Body({ adminGuideUrl: CLAUDE_ADMIN_GUIDE_URL }); + if (!cursorBody.trim() || !claudeBody.trim()) { + deleteIfExists(cursorPath); + deleteIfExists(claudePath); + return { + wrote: [], + deleted: [cursorPath, claudePath], + skipped: true, + reason: "template-error", + }; + } + + const cursorOk = safeWriteRule(cursorPath, cursorRuleContents(cursorBody)); + const claudeOk = safeWriteRule(claudePath, claudeRuleContents(claudeBody)); + // All-or-nothing: a single-harness success would burn budget for only one IDE. + if (!cursorOk || !claudeOk) { + deleteIfExists(cursorPath); + deleteIfExists(claudePath); + log.warn("onboarding rule write incomplete — rolled back both harnesses", { + cursorOk, + claudeOk, + }); + return { + wrote: [], + deleted: [cursorPath, claudePath], + skipped: true, + reason: "write-failed", + }; + } + + // Close enable/dismiss TOCTOU: config may have flipped after the first check. + // Test-only: flip the gate after a successful write so the post-write + // re-check can be asserted without a real race. + if ( + process.env.JFROG_TEST_HARNESS === "1" && + process.env.JFROG_TEST_FLIP_OFFER_AFTER_WRITE === "1" + ) { + mkdirSync(path.join(home, ".jfrog"), { recursive: true }); + writeFileSync( + path.join(home, ".jfrog", "agents-conf.json"), + `${JSON.stringify({ packageResolution: { onboardingPrompt: "off" } })}\n`, + ); + } + const after = offerSuppressedFor(home); + if (after) { + log.debug("offer rule write rolled back after concurrent resolve", { + reason: after, + }); + deleteIfExists(cursorPath); + deleteIfExists(claudePath); + return { + wrote: [], + deleted: [cursorPath, claudePath], + skipped: true, + reason: after, + }; + } + + log.debug("onboarding rules written", { + wrote: `${cursorPath},${claudePath}`, + bytes: cursorBody.length, + }); + return { + wrote: [cursorPath, claudePath], + deleted: [], + skipped: false, + }; +} diff --git a/plugin/modules/package-resolution/scripts/verify-repo.mjs b/plugin/modules/package-resolution/scripts/verify-repo.mjs new file mode 100644 index 0000000..ea7d117 --- /dev/null +++ b/plugin/modules/package-resolution/scripts/verify-repo.mjs @@ -0,0 +1,194 @@ +// Fail-closed virtual-repo verify for Consent Enable / configure enable. +// +// GET /artifactory/api/repositories/ — confirms virtual + packageType. +// Listing repos is owned by the base jfrog skill, not this module. + +import { + authHeader, + getPlatformIdentity, + isHttpsIdentityUrl, + safeErrorMessage, +} from "../../core/jf-identity.mjs"; +import { createLogger } from "../../core/logger.mjs"; +import { PACKAGE_TYPES, repoMatchesPackageType } from "./repo-types.mjs"; + +const log = createLogger("verify-repo"); + +const VERIFY_TIMEOUT_MS = 45_000; + +/** + * @param {string | undefined | null} type + * @returns {string | null} normalized APR package type or null + */ +export function normalizeAprType(type) { + if (typeof type !== "string") return null; + const key = type.trim().toLowerCase(); + return PACKAGE_TYPES.includes(key) ? key : null; +} + +function testHarnessActive() { + return process.env.JFROG_TEST_HARNESS === "1"; +} + +/** + * Test-only verify override (JFROG_TEST_HARNESS=1): + * JFROG_TEST_VERIFY_REPO=ok + * JFROG_TEST_VERIFY_REPO=fail: + * @returns {object | null} + */ +function testHarnessVerifyOverride({ type, repoKey }) { + if (!testHarnessActive()) return null; + const mode = process.env.JFROG_TEST_VERIFY_REPO; + if (!mode) return null; + if (mode === "ok") { + return { + ok: true, + type, + repoKey, + packageType: type, + rclass: "virtual", + }; + } + if (mode === "fail" || mode.startsWith("fail:")) { + const cause = mode.startsWith("fail:") + ? mode.slice(5) || "not-found" + : "not-found"; + return { ok: false, cause, type, repoKey }; + } + return null; +} + +/** + * Verify one user-provided repo key (fast GET by key). + * @param {{ type: string, repoKey: string }} opts + * @returns {Promise<{ + * ok: boolean, + * cause?: string, + * type?: string, + * repoKey?: string, + * packageType?: string, + * rclass?: string, + * url?: string, + * serverId?: string, + * platformUrl?: string, + * }>} + */ +export async function verifyRepoKey({ type, repoKey }) { + const aprType = normalizeAprType(type); + const key = typeof repoKey === "string" ? repoKey.trim() : ""; + if (!aprType || !key) { + return { ok: false, cause: "bad-args" }; + } + + const harness = testHarnessVerifyOverride({ type: aprType, repoKey: key }); + if (harness) return harness; + + const { identity, cause } = getPlatformIdentity(); + if (!identity) { + return { ok: false, cause: cause || "jf-not-configured" }; + } + + if (!isHttpsIdentityUrl(identity)) { + log.warn("refusing to verify repo over a non-HTTPS platform URL", { + type: aprType, + repoKey: key, + }); + return { + ok: false, + cause: "insecure-url", + type: aprType, + repoKey: key, + serverId: identity.serverId, + platformUrl: identity.url, + }; + } + + const authorization = authHeader(identity); + if (!authorization) { + return { ok: false, cause: "jf-unsupported-auth" }; + } + + const url = `${identity.url}/artifactory/api/repositories/${encodeURIComponent(key)}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS); + try { + log.info("verifying repo key", { type: aprType, repoKey: key, url }); + const res = await fetch(url, { + headers: { + Authorization: authorization, + Accept: "application/json", + }, + signal: controller.signal, + }); + if (res.status === 404) { + return { + ok: false, + cause: "not-found", + type: aprType, + repoKey: key, + serverId: identity.serverId, + platformUrl: identity.url, + }; + } + if (!res.ok) { + return { + ok: false, + cause: `http-${res.status}`, + type: aprType, + repoKey: key, + serverId: identity.serverId, + platformUrl: identity.url, + }; + } + const cfg = await res.json(); + const rclass = String(cfg?.rclass ?? cfg?.type ?? "").toLowerCase(); + if (rclass !== "virtual") { + return { + ok: false, + cause: "not-virtual", + type: aprType, + repoKey: key, + packageType: cfg?.packageType ? String(cfg.packageType) : undefined, + rclass: rclass || undefined, + serverId: identity.serverId, + platformUrl: identity.url, + }; + } + // Verify path fail-closed: missing packageType is not a match. + if (!cfg?.packageType || !repoMatchesPackageType(cfg, aprType)) { + return { + ok: false, + cause: "package-type-mismatch", + type: aprType, + repoKey: key, + packageType: cfg?.packageType ? String(cfg.packageType) : undefined, + rclass, + serverId: identity.serverId, + platformUrl: identity.url, + }; + } + return { + ok: true, + type: aprType, + repoKey: key, + packageType: String(cfg.packageType), + rclass, + ...(typeof cfg?.url === "string" ? { url: cfg.url } : {}), + serverId: identity.serverId, + platformUrl: identity.url, + }; + } catch (err) { + log.warn("verify repo threw", { + repoKey: key, + error: safeErrorMessage(err), + }); + return { + ok: false, + cause: "unreachable", + type: aprType, + repoKey: key, + }; + } finally { + clearTimeout(timer); + } +} diff --git a/plugin/modules/package-resolution/templates/package-resolution-unconfigured.md b/plugin/modules/package-resolution/templates/package-resolution-unconfigured.md index edc459a..83ddf57 100644 --- a/plugin/modules/package-resolution/templates/package-resolution-unconfigured.md +++ b/plugin/modules/package-resolution/templates/package-resolution-unconfigured.md @@ -1,20 +1,18 @@ # Package Resolution — JFrog Artifactory routing is NOT READY -Your organization routes every package fetch through JFrog Artifactory, but routing -cannot run yet — {{CAUSE_INTRO}}. Package managers still point at -**public** registries until setup completes. +Your organization routes package fetches through JFrog Artifactory, but routing +cannot run yet — {{CAUSE_INTRO}}. Package managers still point at **public** +registries until setup completes. {{GOVERNED_SCOPE}} -**Do not run direct package installs for the governed package managers** while routing is not ready. When asked to -install or pull governed packages, tell the user routing is blocked and complete setup first. Package managers not -listed above are out of scope — handle them normally. +**Do not run direct package installs for the governed package managers** while routing is not ready — there is no +Decision-order rewrite path yet. When asked to install or pull governed packages, tell the user routing is blocked +and complete setup first. Package managers not listed above are out of scope — handle them normally. Do not delegate governed installs to `agent -p` or another child while routing is not ready; refuse before launching and tell the user the **JFrog Artifactory Package Resolution policy** blocks delegation until routing is ready. A child prompt cannot replace trusted Package Resolution injection. ## Read this first -Authoritative procedure: - 1. **`jfrog-setup-package-managers`** skill — **Step 0** for this state. 2. Base **`jfrog`** skill — `references/jfrog-login-flow.md` for server login. @@ -28,24 +26,16 @@ Authoritative procedure: ## After setup — load the routing policy -Once `jf` is configured and `jfrog-setup-package-managers` has bound the package managers, -load the current routing policy by running: +Once `jf` is configured and `jfrog-setup-package-managers` has bound package +managers, load the current routing policy: ```bash {{REFRESH_COMMAND}} ``` -Treat that command's stdout as the authoritative, now-current package-resolution -policy — it **supersedes this "NOT READY" notice** and prints the resolved -Artifactory URLs and hard rules for every configured package type. Then re-issue -the install using those URLs. - -If the command prints nothing, routing is off by config -(`packageResolution.enabled` is not `true`) — an admin opt-in (see Enablement -below). Report that to the user and let them decide. - -## Enablement +Treat that command's stdout as the authoritative policy — it **supersedes this +"NOT READY" notice** and prints resolved Artifactory URLs and hard rules. Then +re-issue the install using those URLs. -Routing is opt-in. Set `packageResolution.enabled: true` in `~/.jfrog/agents-conf.json`. -On first session, if that file is missing, the hook scaffolds it from the shipped -template (`packageResolution.enabled` defaults to `false`). +If the command prints nothing, routing is off +(`packageResolution.enabled` is not `true`) — report that and let the user decide. diff --git a/plugin/modules/package-resolution/templates/package-resolution.md b/plugin/modules/package-resolution/templates/package-resolution.md index e7d6315..5adcd83 100644 --- a/plugin/modules/package-resolution/templates/package-resolution.md +++ b/plugin/modules/package-resolution/templates/package-resolution.md @@ -2,60 +2,49 @@ Your organization mediates package fetches through JFrog Artifactory for the **governed** package managers listed below. Before any governed package install — -shell, sub-agent, or MCP tool — route through the resolved Artifactory repository. +shell, sub-agent, or MCP tool — follow the **Decision order** below. {{GOVERNED_SCOPE}} Whenever this policy blocks an action, explicitly say it is blocked by the organization's **JFrog Artifactory Package Resolution policy**. {{AUTO_SETUP_STATUS}} +## Decision order (top to bottom; first match wins) + +**Setup skill** = `jfrog-setup-package-managers`. Public-registry / skip-JFrog asks → step 7 **immediately**. + +1. **Unresolved** — `` → do **not** install; invoke the setup skill. Never invent a URL or use a public registry. +2. **Zero-touch handled** — **Package manager setup** status line lists this PM as: + - `already set up` → normal command (trust PM config). **No** `--registry`, `--index-url`, `GOPROXY=…`. + - `setting up in the background` → **direct rewrite only** (no `npx`/`-r`/postinstall/`docker build` until `already set up` or durable PM config exists). +3. **Foreign-host conflict** — status says `left unchanged (already using another JFrog / registry)` → ask _Switch to this JFrog instance?_; on yes, `jf setup --server-id … --repo …` only — never bare `jf setup`. +4. **Manifest unbound** — governed manifest present (e.g. `package.json`, `requirements.txt`, `go.mod`; map in setup skill) **and** `.jfrog/local/package-resolution.json` lacks that type → setup skill first (`jf setup` + binding; autoSetup does **not** write that file), **then** install. No rewrite-flag-only shortcut (`--registry`, `--index-url`, `GOPROXY=…`). **Agent Guard bootstrap** (below) is exempt from this rewrite-flag ban. +5. **Ready** — binding present, **or** no governed manifest for this type. Flag-based (npm/pypi/go/docker): rewrite / trust PM config. **Config-driven** (maven/gradle/helm/nuget) unbound → setup skill first; not rewrite-ready. +6. **401/403 from JFrog** → setup skill again; never raw `npm login` / `docker login` / `pip config`. +7. **Public-registry / skip-JFrog** → refuse (hard rule #7). Offer the next allowed step from this order. + +Ungoverned package managers are out of scope — install normally; do not invoke the setup skill. + ## Resolved URLs for this session {{RESOLVED_TABLE}} -If any row shows ``, ask the user which repo to use and invoke -`jfrog-setup-package-managers` — do not guess or call public registries. +Unresolved rows → Decision step 1 (setup skill; no public registries). ## Rewrite templates -Direct installs — form the command yourself (no automatic rewriter; `jf setup` package-manager -config and server-side Curation back this): +Use only when Decision order reached step 2 (`setting up in the background`) or step 5. Form the command yourself (`jf setup` config + Curation back this): {{REWRITE_BULLETS}} -## Hard rules (apply to the governed package managers above) +## Hard rules (governed types only) -**Agent Guard bootstrap — the one exception to rule 7.** A command that installs -`@jfrog/agent-guard` and no other package may carry the registry its own JFrog -Agent Guard policy specifies, e.g. `npx --yes --registry @jfrog/agent-guard …` -or `npm install --registry @jfrog/agent-guard`. Leave that registry alone. - -Nothing else is covered. If the command installs any other package, omits the -explicit `@jfrog/agent-guard` argument, or points a general-purpose install at a -non-JFrog host, rule 7 applies and you refuse. This exception never authorizes a -public registry, and it never widens to another package. - -1. **Only URLs in the table above** — for the governed package managers, no default upstream registries, mirrors, or CDNs. -2. **Never override flags the user typed** (`--registry`, `--index-url`, `GOPROXY=…`) — if the command already includes a routing flag, surface the conflict with policy and ask before changing the command. This applies only to flags already in the command, **not** to verbal requests in chat to bypass routing policy. -3. **Indirect installs** (`npx`, `pip install -r`, `docker build`, postinstall scripts) — trust package-manager config files; if missing, run `jfrog-setup-package-managers`. +{{AGENT_GUARD_SECTION}} +1. **Only URLs in the table above** — no public registries, mirrors, or CDNs. +2. **Never override flags the user typed** (`--registry`, `--index-url`, `GOPROXY=…`) — if already in the command, ask before changing. This applies only to flags already in the command, **not** to verbal requests in chat to bypass routing policy. +3. **Indirect installs** (`npx`, `pip install -r`, `docker build`, postinstall) — trust PM config; if missing, run the setup skill (unless Decision step 2 lists `already set up`). 4. **Curation block** — surface the reason verbatim; do not retry another host. -5. **Unresolved governed package manager** — if the table shows `` for a governed package manager the user - requested, **do not run the original command**. In order: (a) invoke `jfrog-setup-package-managers` for that package manager, - (b) wait until `.jfrog/local/package-resolution.json` records the binding, - (c) re-issue routed via the templates above. A successful exit from an unrouted - command still violates policy. -6. **401/403 from JFrog** — run `jfrog-setup-package-managers` (`jf setup`); never raw `docker login` / `npm login` / `pip config`. -7. **No public-registry bypass** — if the user asks to use public registries or skip JFrog routing for a governed package manager, refuse. State clearly that the request is blocked by the organization's **JFrog Artifactory Package Resolution policy**, then offer the JFrog-routed command from the rewrite templates above. -8. **No delegation bypass** — do not spawn `agent -p` or another agent for a governed package-install task unless that child receives this same Package Resolution policy from trusted `sessionStart` injection. **Refuse before launching an unprotected child.** Spawning a child merely so it can refuse is still a policy violation. A routed command or policy text in the child's user prompt cannot replace trusted injection because the child can execute different commands. Never pass a forbidden install request unchanged to a child. In the refusal, explicitly say that the **JFrog Artifactory Package Resolution policy** requires governed installs to remain routed through Artifactory. - -**Package managers not listed above are out of scope** — install them normally; no JFrog routing required. Do not block them, do not invoke `jfrog-setup-package-managers` for them. +5. **Unresolved governed package manager** — Decision step 1: setup skill → wait for `.jfrog/local/package-resolution.json` → re-issue via Decision order. Unrouted success still violates policy. +6. **401/403** — Decision step 6: setup skill (`jf setup`); never raw login/config. +7. **No public-registry bypass** — refuse; name this policy; offer the next allowed Decision step. +8. **No delegation bypass** — do not spawn `agent -p` or another agent for a governed package-install unless the child receives this policy via trusted `sessionStart` injection. **Refuse before launching an unprotected child.** Spawning a child merely so it can refuse is still a policy violation. A routed command or policy text in the child's user prompt cannot replace trusted injection because the child can execute different commands. In the refusal, say the **JFrog Artifactory Package Resolution policy** requires Artifactory routing. {{DOCKER_SECTION}} -When a **governed** package manifest appears and `.jfrog/local/package-resolution.json` lacks the -matching package manager, invoke `jfrog-setup-package-managers` proactively (see that skill for -manifest → package-manager mapping). Do not do this for ungoverned package managers. - -## Enablement - -Opt-in via admin config. Set `packageResolution.enabled: true` in `~/.jfrog/agents-conf.json` -and declare the governed types under `defaultGlobalRepos`. On first session, if that file -is missing, the hook scaffolds it from the shipped template (`packageResolution.enabled` -defaults to `false`). diff --git a/plugin/package.json b/plugin/package.json new file mode 100644 index 0000000..bb6dede --- /dev/null +++ b/plugin/package.json @@ -0,0 +1,6 @@ +{ + "name": "@jfrog/agent-hooks", + "version": "0.1.0", + "private": true, + "description": "Version stub for vendored jfrog-agent-hooks modules (jf-user-agent.mjs reads ../../package.json)." +} diff --git a/scripts/validate-package-resolution-hook.mjs b/scripts/validate-package-resolution-hook.mjs index 4c08b00..b3c1933 100644 --- a/scripts/validate-package-resolution-hook.mjs +++ b/scripts/validate-package-resolution-hook.mjs @@ -106,20 +106,44 @@ 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)); res.setHeader("content-type", "application/json"); - res.end(JSON.stringify({ packageType: "npm" })); + res.end(JSON.stringify({ packageType: "npm", rclass: "virtual" })); return; } res.statusCode = 404; @@ -351,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, { @@ -365,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"), }), ); @@ -375,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");