diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 964ec6f..e30f8ae 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "JFrog Platform plugins for Cursor", - "version": "0.5.5", + "version": "0.5.6", "pluginRoot": "plugins" }, "plugins": [ diff --git a/.github/workflows/validate-inject-instructions.yml b/.github/workflows/validate-inject-instructions.yml new file mode 100644 index 0000000..0e2a041 --- /dev/null +++ b/.github/workflows/validate-inject-instructions.yml @@ -0,0 +1,35 @@ +# Copyright (c) JFrog Ltd. 2026 +# Licensed under the Apache License, Version 2.0 +# https://www.apache.org/licenses/LICENSE-2.0 + +name: Validate hook injection + +on: + pull_request: + branches: [main] + paths: + - "plugins/jfrog/scripts/inject-instructions.mjs" + - "plugins/jfrog/templates/jfrog-mcp-management.md" + - "plugins/jfrog/hooks/hooks.json" + - "plugins/jfrog/.cursor-plugin/plugin.json" + - "scripts/validate-hook-injector.mjs" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate hook injection + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Run injector validation + run: node scripts/validate-hook-injector.mjs diff --git a/README.md b/README.md index 53f3739..f10d3df 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Before installing, make sure you have: - **Node.js** (≥ 14) — with `npx` on your `PATH`. - **Skill runtime requirements** — `jf` CLI, `jq`, and `curl` on `PATH`, plus a configured JFrog instance. For the minimum versions, see the upstream skills [`Requirements`](https://github.com/jfrog/jfrog-skills/blob/v0.11.0/README.md#requirements). Configure the CLI with `jf config add` — see [Authentication](#authentication). - **JFrog Platform access** (optional) — If you want to use the Agent Guard feature, your JFrog subscription needs to include the AI Catalog entitlement. Contact your JFrog account team if you're unsure whether it's enabled. +- **JFrog CLI ≥ 2.105.0** (optional) — If you want the Agent Guard hook to auto-resolve credentials/server ID from the JFrog CLI instead of `JFROG_PLATFORM_URL`/`JFROG_ACCESS_TOKEN` env vars. Older CLIs don't support the `--format` flag used by `jf config show`/`jf config export` for this. - **JFrog project** (optional) — If you want to use the Agent Guard feature. --- diff --git a/plugins/jfrog/.cursor-plugin/plugin.json b/plugins/jfrog/.cursor-plugin/plugin.json index 13fa44b..3aa316a 100644 --- a/plugins/jfrog/.cursor-plugin/plugin.json +++ b/plugins/jfrog/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "displayName": "JFrog Platform", - "version": "0.5.5", + "version": "0.5.6", "description": "JFrog Platform integration with MCP, security skills, supply-chain best practices, and JFrog Agent Guard governance for adding, removing, and listing MCP servers.", "author": { "name": "JFrog", diff --git a/plugins/jfrog/README.md b/plugins/jfrog/README.md index 3f2b254..e626d0e 100644 --- a/plugins/jfrog/README.md +++ b/plugins/jfrog/README.md @@ -10,6 +10,7 @@ JFrog Platform integration for Cursor — artifact management, security scanning - Toggle the **MCP Server** option ON and save. 3. Set the `JFROG_PLATFORM_URL` environment variable to your JFrog instance (e.g., `mycompany.jfrog.io`). 4. **JFrog CLI** (`jf`) is used by the skills for authentication and REST/GraphQL API operations. If missing, the agent will attempt to install it. You can also install manually via `brew install jfrog-cli` or the [official install script](https://jfrog.com/help/r/jfrog-cli/install-the-jfrog-cli). +5. **JFrog CLI ≥ 2.105.0** (optional) — required if you want the Agent Guard hook to auto-resolve credentials/server ID from the JFrog CLI instead of `JFROG_URL`/`JFROG_ACCESS_TOKEN` env vars. Older CLIs don't support the `--format` flag used by `jf config show`/`jf config export` for this. CLI authentication options: run `jf login` for browser-based setup, or set the `JFROG_ACCESS_TOKEN` environment variable. MCP-based workflows authenticate via **OAuth** and require no additional configuration. diff --git a/plugins/jfrog/scripts/inject-instructions.mjs b/plugins/jfrog/scripts/inject-instructions.mjs index 8f6438f..0e37952 100755 --- a/plugins/jfrog/scripts/inject-instructions.mjs +++ b/plugins/jfrog/scripts/inject-instructions.mjs @@ -3,6 +3,7 @@ // Licensed under the Apache License, Version 2.0 // https://www.apache.org/licenses/LICENSE-2.0 +import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import process from "node:process"; @@ -24,17 +25,57 @@ const forceDisabled = const forceEnabled = env("JF_AGENT_GUARD_FORCE_ENABLE") === "true"; -async function isAgentGuardEnabledViaSettings() { +// Resolve {baseUrl, token}: environment variables (JFROG_URL/JFROG_ACCESS_TOKEN, +// or legacy JF_*) are checked first; if either is missing, fall back to the +// JFrog CLI's default configured server via `jf config export`. Returns null +// when neither source yields usable credentials. +function resolveCredentials() { const baseUrl = env("JFROG_URL", "JF_URL"); const token = env("JFROG_ACCESS_TOKEN", "JF_ACCESS_TOKEN"); - if (!baseUrl) { - debug("JFROG_URL/JF_URL is not set; skipping settings check"); - return false; + if (baseUrl && token) { + debug("Resolved credentials from environment variables"); + return { baseUrl, token }; + } + + // `jf config export` emits the default server as a base64-encoded JSON token. + let configToken; + try { + configToken = execFileSync("jf", ["config", "export"], { + 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; + } + + // The token is a base64-encoded JSON blob containing the server's url, + // accessToken, and serverId — decode and validate it before using it. + let cfg; + try { + cfg = JSON.parse(Buffer.from(configToken, "base64").toString("utf8")); + } catch (error) { + debug(`Could not decode the jf Config Token: ${error.message}`); + return null; + } + + if (!cfg?.url || !cfg?.accessToken) { + debug("jf Config Token did not contain a usable url + accessToken"); + return null; } - if (!token) { - debug("JFROG_ACCESS_TOKEN/JF_ACCESS_TOKEN is not set; skipping settings check"); + + debug(`Resolved credentials via 'jf config export' (serverId: ${cfg.serverId ?? ""})`); + return { baseUrl: cfg.url, token: cfg.accessToken }; +} + +async function isAgentGuardEnabledViaSettings() { + const credentials = resolveCredentials(); + if (!credentials) { + debug("No JFrog credentials resolved; skipping settings check"); return false; } + const { baseUrl, token } = credentials; const url = baseUrl.replace(/\/+$/, "") + @@ -43,7 +84,7 @@ async function isAgentGuardEnabledViaSettings() { debug(`Fetching agent guard setting from ${url}`); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); + const timeout = setTimeout(() => controller.abort(), 4000); try { const response = await fetch(url, { method: "GET", diff --git a/plugins/jfrog/templates/jfrog-mcp-management.md b/plugins/jfrog/templates/jfrog-mcp-management.md index c2232e0..5382550 100644 --- a/plugins/jfrog/templates/jfrog-mcp-management.md +++ b/plugins/jfrog/templates/jfrog-mcp-management.md @@ -8,7 +8,7 @@ below instead. **Registry URL**: Wherever `` appears below, substitute the value of the `JFROG_AGENT_GUARD_REPO` environment variable if it -is set. Otherwise use +is set. Otherwise, use `https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/`. **Pre-flight (applies to every agent guard command — @@ -30,22 +30,22 @@ is set. Otherwise use resolves, STOP and ask — NEVER guess, NEVER assume `default`, NEVER invent projects. -- **`` is auto-resolvable.** Resolve via Step 1's server - chain: existing `mcpServers` entries (value after `--server` in - `args`) → `~/.jfrog/jfrog-cli.conf.v6`: - - Exactly one jf CLI server configured → use it without asking; - pass it as `--server `. The agent guard would auto-resolve to the same - value if `--server` were omitted, but we pass it explicitly for - clarity and forward-compatibility. - - `JFROG_URL` + `JFROG_ACCESS_TOKEN` set → use it without asking; - The agent guard will pick them up from the environment variables when called. - - Two or more jf CLI servers and no `JFROG_URL` → list IDs, - ALWAYS ASK the user which one, then pass that as `--server `. - ALWAYS prefer environment variables when set over asking. - NEVER guess one server. - - zero jf CLI servers and no `JFROG_URL` → ask the user to run - `jf c add ` or export `JFROG_URL` + `JFROG_ACCESS_TOKEN`, - then retry. +- **`` is auto-resolvable.** Resolve in order, stop at the + first match: + 1. An existing `mcpServers` entry's `--server ` (project or user + config) — reuse it. + 2. `JFROG_URL` + `JFROG_ACCESS_TOKEN` set in the env — use them and do + NOT pass `--server` (the agent guard reads the env directly). + 3. List configured servers with the jf CLI — `jf config show --format=json` + (do NOT parse `~/.jfrog/jfrog-cli.conf.v6`; the CLI masks tokens, so + its output is safe). Exactly one → use it; two or more → use the one + with `"isDefault": true`; if none is marked default → ASK the user + which one. Then pass `--server `. + 4. None of the above → ask the user to run `jf c add ` or export + `JFROG_URL` + `JFROG_ACCESS_TOKEN`, then retry. + + When you resolved the ID from a jf CLI config, always pass it as + `--server `; when using env vars, never pass `--server`. - The commands need network access and MUST be run with `full_network` permissions when run in a sandbox. Otherwise `Forbidden` errors will be thrown. @@ -60,11 +60,11 @@ STOP — do NOT run the command with guesses. "add an MCP", "what can I install" — your FIRST action is to show them the catalog so they can pick: -1. Resolve server (Server ID`` or URL `JFROG_URL`) +1. Resolve server (Server ID `` or URL `JFROG_URL`) and `` per the Pre-flight rule at the top of this document. Server: auto-use the single jf CLI configs serverId as the server ID or the `JFROG_URL` env var as the URL if unambiguous; only ask when - there are multiple or no jf configs and not env vars. + there are multiple or no jf configs and no env vars. Project: Ask unless `JF_PROJECT` is set, or it's already in an existing `mcpServers` entry. 2. Run "Listing MCPs > Available to install" with that server + @@ -90,22 +90,20 @@ unless absolutely necessary: agent guard can resolve credentials from these directly; DO NOT pass `--server` as that would make the agent guard try to parse the server details from the jf cli configuration. -3. Else read `~/.jfrog/jfrog-cli.conf.v6` - (`%USERPROFILE%\.jfrog\jfrog-cli.conf.v6` on Windows) via a - terminal command (file-search skips hidden dirs) - NEVER print the full file contents as it can contain secrets. - Use the serverId subkeys:: +3. Else list configured servers with the jf CLI — run + `jf config show --format=json` (do NOT parse + `~/.jfrog/jfrog-cli.conf.v6` yourself; the CLI masks tokens, so its + output is safe to read). From the result: - exactly one server → use it without asking. - - two or more → list the `serverId`s and ASK the user which one. + - two or more → use the one with `"isDefault": true`; if none is + marked default, list the `serverId`s and ASK the user which one. 4. Else (file missing, empty, or unreadable, and no `JFROG_URL`) ask the user to either run `jf c add ` or export `JFROG_URL` + `JFROG_ACCESS_TOKEN`, then retry. -NEVER try multiple servers — pick one. Once chosen, pass it -If a server from the jf cli configuration is supposed to be used: -Always explicitly as `--server ` in every agent guard invocation. -Otherwise, if environment variables for `JFROG_URL` and `JFROG_ACCESS_TOKEN` -are used: Do NOT pass `--server ` +NEVER try multiple servers — pick one. When you resolved the ID from a +jf CLI config, always pass it as `--server ` in every agent guard +invocation; when using env vars, never pass `--server`. **Project** @@ -131,9 +129,8 @@ not call `--inspect` — go to "Listing MCPs > Available to install" instead, show the catalog, have them pick, then come back to Step 2 with the chosen name. -Once you have a name, you must fetch its live details. - -Run EXACTLY this command — no Fetch/WebFetch, no custom curl/Python, no direct JFrog API calls: +Once you have a name, run a SINGLE command — no Fetch/WebFetch, no +custom curl/Python, no direct JFrog API calls: ``` npx --yes \ @@ -403,9 +400,9 @@ the display name. ## Troubleshooting - **`ready` but 0 tools (empty `mcps//tools/` after a - Command Palette `Developer: Reload Window`)** — agent guard proxy - started, upstream MCP did not. The top-level `ready` label is - misleading here. NEVER report success when there are 0 tools. + Command Palette `Developer: Reload Window`)** — agent guard proxy + started, upstream MCP did not. The top-level `ready` label is + misleading here. NEVER report success when there are 0 tools. 1. Open Cursor's MCP / Output panel for the agent guard stderr; diagnose by MCP type: - **OAuth (remote)** — re-run Step 5 (`--login`); refresh token diff --git a/scripts/validate-hook-injector.mjs b/scripts/validate-hook-injector.mjs new file mode 100644 index 0000000..fd488bc --- /dev/null +++ b/scripts/validate-hook-injector.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 + +// Smoke test for the sessionStart injector + plugin packaging, grouped into: +// Syntax — the injector exists and parses. +// Lint — plugin.json / hooks.json / template wiring is internally +// consistent (name, paths). +// Format — running the injector emits a well-formed sessionStart +// payload (valid JSON, correct shape). +// Injection logic — the payload actually carries the real template, and +// fail-closed paths emit {}. +// A template-filename / read-path mismatch makes the injector silently emit +// nothing (it catches the read error and exits 0); these checks turn that +// silent failure into a hard error. + +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pluginDir = path.join(repoRoot, "plugins", "jfrog"); +const injector = path.join(pluginDir, "scripts", "inject-instructions.mjs"); +const templatesDir = path.join(pluginDir, "templates"); +const hooksFile = path.join(pluginDir, "hooks", "hooks.json"); +const pluginManifestFile = path.join(pluginDir, ".cursor-plugin", "plugin.json"); + +const failures = []; + +function section(title) { + console.log(`\n${title}`); +} + +function check(label, fn) { + try { + fn(); + console.log(` ok ${label}`); + } catch (error) { + failures.push(label); + console.log(` FAIL ${label}\n ${error.message}`); + } +} + +// Run the injector with a clean copy of the env plus the given overrides, so an +// inherited force-flag or real JFrog credentials can't skew the result. +function runInjector(overrides) { + const env = { ...process.env }; + delete env._JF_AGENT_GUARD_FORCE_DISABLE; + delete env.JF_AGENT_GUARD_FORCE_ENABLE; + return execFileSync(process.execPath, [injector], { + encoding: "utf8", + env: { ...env, ...overrides }, + }); +} + +// Same as runInjector, but also captures stderr and clears any JFrog env vars +// so the jf-CLI fallback in resolveCredentials() is actually reachable. +function runInjectorWithDebug(overrides) { + const env = { ...process.env }; + delete env._JF_AGENT_GUARD_FORCE_DISABLE; + delete env.JF_AGENT_GUARD_FORCE_ENABLE; + delete env.JFROG_URL; + delete env.JF_URL; + delete env.JFROG_ACCESS_TOKEN; + delete env.JF_ACCESS_TOKEN; + const result = spawnSync(process.execPath, [injector], { + encoding: "utf8", + env: { ...env, JF_AGENT_GUARD_DEBUG: "true", ...overrides }, + }); + return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; +} + +// Stubs a fake `jf` executable on PATH that emits the given config token, so +// the CLI-fallback path can be exercised without a real JFrog CLI install. +function withFakeJfOnPath(configToken, fn) { + const bin = mkdtempSync(path.join(tmpdir(), "fake-jf-")); + const jfPath = path.join(bin, "jf"); + writeFileSync(jfPath, `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(configToken)});\n`); + chmodSync(jfPath, 0o755); + try { + return fn(`${bin}${path.delimiter}${process.env.PATH}`); + } finally { + rmSync(bin, { recursive: true, force: true }); + } +} + +function main() { + console.log("Validating sessionStart injector + plugin packaging…"); + + // ---- Syntax: the injector exists and is parseable JS ---- + section("Syntax"); + check("injector source exists", () => { + if (!existsSync(injector)) throw new Error(`missing: ${injector}`); + }); + check("injector parses (node --check)", () => { + execFileSync(process.execPath, ["--check", injector], { stdio: "pipe" }); + }); + + // ---- Lint: manifest, hook wiring, and template read-path are consistent ---- + section("Lint (manifest & wiring)"); + + check("plugin.json is named the jfrog plugin", () => { + const pluginManifest = JSON.parse(readFileSync(pluginManifestFile, "utf8")); + if (pluginManifest.name !== "jfrog") { + throw new Error(`plugin.json name "${pluginManifest.name}" is not "jfrog"`); + } + if (!/^\d+\.\d+\.\d+$/.test(pluginManifest.version ?? "")) { + throw new Error(`plugin.json version is missing or not semver: ${JSON.stringify(pluginManifest.version)}`); + } + }); + + check("hooks.json wires sessionStart to the injector", () => { + const hooks = JSON.parse(readFileSync(hooksFile, "utf8")); + const entries = hooks?.hooks?.sessionStart; + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error("hooks.json has no sessionStart hooks"); + } + const commands = entries.map((e) => e.command ?? ""); + if (!commands.some((c) => c.includes("inject-instructions.mjs"))) { + throw new Error("no sessionStart command references inject-instructions.mjs"); + } + }); + + // The filename the injector reads must match a real, non-empty template. + let templateName; + check("injector reads an existing template file", () => { + const src = readFileSync(injector, "utf8"); + const match = src.match(/"templates"\s*,\s*"([^"]+)"/); + if (!match) throw new Error("could not find the templates/ read path in the injector"); + templateName = match[1]; + const templatePath = path.join(templatesDir, templateName); + if (!existsSync(templatePath)) { + throw new Error(`injector reads "${templateName}" but it does not exist in plugins/jfrog/templates/`); + } + if (statSync(templatePath).size === 0) { + throw new Error(`template "${templateName}" is empty`); + } + }); + + // ---- Format: force-enable emits a well-formed sessionStart payload ---- + section("Format (injected payload shape)"); + let injectedContext; + check("force-enable emits valid JSON with a non-empty additional_context", () => { + const stdout = runInjector({ JF_AGENT_GUARD_FORCE_ENABLE: "true", JFROG_URL: "https://example.jfrog.io" }); + if (!stdout.trim()) throw new Error("stdout was empty"); + let payload; + try { + payload = JSON.parse(stdout); + } catch (error) { + throw new Error(`stdout did not parse as JSON: ${error.message}`); + } + if (typeof payload?.additional_context !== "string" || payload.additional_context.trim().length === 0) { + throw new Error("additional_context is missing or empty"); + } + injectedContext = payload.additional_context; + }); + + // ---- Injection logic: the payload is the real template; fail-closed works ---- + section("Injection logic"); + check("force-enable injects the actual template, byte-for-byte", () => { + if (injectedContext === undefined) throw new Error("force-enable payload not captured (see Format check)"); + if (!templateName) throw new Error("template name was not resolved (see Lint check)"); + const expected = readFileSync(path.join(templatesDir, templateName), "utf8"); + if (injectedContext !== expected) { + throw new Error("injected additionalContext does not match the template file content"); + } + }); + // ---- jf CLI fallback: resolveCredentials() falls back to `jf config export` ---- + section("jf CLI fallback"); + check("resolves credentials via 'jf config export' when env vars are unset", () => { + const token = Buffer.from( + JSON.stringify({ url: "https://example.jfrog.io", accessToken: "fake-token", serverId: "test-server" }), + ).toString("base64"); + withFakeJfOnPath(token, (fakePath) => { + const { stderr } = runInjectorWithDebug({ PATH: fakePath }); + if (!stderr.includes("Resolved credentials via 'jf config export'")) { + throw new Error(`expected debug log confirming jf CLI fallback, got:\n${stderr}`); + } + }); + }); + + check("force-disable emits {} (fail-closed)", () => { + const stdout = runInjector({ _JF_AGENT_GUARD_FORCE_DISABLE: "true" }).trim(); + if (stdout !== "{}") throw new Error(`expected "{}", got ${JSON.stringify(stdout)}`); + }); + + if (failures.length > 0) { + console.error(`\n${failures.length} check(s) failed.`); + process.exit(1); + } + console.log("\nAll checks passed."); +} + +main();