From f631612042a50c5340993e79cb607d17253791bf Mon Sep 17 00:00:00 2001 From: Matan Eden <57892946+MatanEden1@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:00:44 +0300 Subject: [PATCH 1/3] AX-1705 - Add JFrog CLI credential support to inject-instructions Co-Authored-By: Claude Sonnet 4.6 --- plugins/jfrog/scripts/inject-instructions.mjs | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/plugins/jfrog/scripts/inject-instructions.mjs b/plugins/jfrog/scripts/inject-instructions.mjs index 8f6438f..dd8ef2e 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,52 @@ const forceDisabled = const forceEnabled = env("JF_AGENT_GUARD_FORCE_ENABLE") === "true"; -async function isAgentGuardEnabledViaSettings() { +// Resolve {baseUrl, token} from env vars, falling back to the JFrog CLI's +// default server. Returns null when nothing resolves. +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 }; } - if (!token) { - debug("JFROG_ACCESS_TOKEN/JF_ACCESS_TOKEN is not set; skipping settings check"); + + // `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"], + }).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(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; + } + + 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(/\/+$/, "") + @@ -92,13 +128,17 @@ try { path.join(root, "templates", "jfrog-mcp-management.md"), "utf8", ); -} catch { - process.stdout.write("{}"); +} catch (error) { + debug(`Could not read instructions template: ${error.message}`); process.exit(0); } +// The IDE consumes hookSpecificOutput.additionalContext from a SessionStart hook. process.stdout.write( JSON.stringify({ - additional_context: template, + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: template, + }, }), ); From acd2750dd321241996b127ecd14f54b39bc7fff2 Mon Sep 17 00:00:00 2001 From: Matan Eden <57892946+MatanEden1@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:14:53 +0300 Subject: [PATCH 2/3] Remove license URL comment Co-Authored-By: Claude Sonnet 4.6 --- plugins/jfrog/scripts/inject-instructions.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/jfrog/scripts/inject-instructions.mjs b/plugins/jfrog/scripts/inject-instructions.mjs index dd8ef2e..96f5e68 100755 --- a/plugins/jfrog/scripts/inject-instructions.mjs +++ b/plugins/jfrog/scripts/inject-instructions.mjs @@ -1,7 +1,6 @@ #!/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 import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; From cbdf651501da538967013d9972f74150d1742b43 Mon Sep 17 00:00:00 2001 From: Matan Eden <57892946+MatanEden1@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:27:36 +0300 Subject: [PATCH 3/3] AX-1705 - Align jfrog MCP template/injector with jf CLI + add hook injection validation - inject-instructions.mjs: add 3s timeout on 'jf config export'; emit {} on template read failure (fail-closed, well-formed empty payload) - jfrog-mcp-management.md: resolve servers via 'jf config show --format=json' instead of parsing ~/.jfrog/jfrog-cli.conf.v6; add Live-execution pre-flight rule; fix two typos - add scripts/validate-hook-injector.mjs smoke test + CI workflow Co-Authored-By: Claude Opus 4.8 (1M context) --- .../validate-inject-instructions.yml | 34 ++++ plugins/jfrog/scripts/inject-instructions.mjs | 2 + .../jfrog/templates/jfrog-mcp-management.md | 30 +++- scripts/validate-hook-injector.mjs | 156 ++++++++++++++++++ 4 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/validate-inject-instructions.yml create mode 100644 scripts/validate-hook-injector.mjs diff --git a/.github/workflows/validate-inject-instructions.yml b/.github/workflows/validate-inject-instructions.yml new file mode 100644 index 0000000..37edcf1 --- /dev/null +++ b/.github/workflows/validate-inject-instructions.yml @@ -0,0 +1,34 @@ +# Copyright (c) JFrog Ltd. 2026 +# Licensed under the Apache License, Version 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/plugins/jfrog/scripts/inject-instructions.mjs b/plugins/jfrog/scripts/inject-instructions.mjs index 96f5e68..1c3141c 100755 --- a/plugins/jfrog/scripts/inject-instructions.mjs +++ b/plugins/jfrog/scripts/inject-instructions.mjs @@ -40,6 +40,7 @@ function resolveCredentials() { configToken = execFileSync("jf", ["config", "export"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + timeout: 3000, }).trim(); } catch (error) { debug(`'jf config export' failed (jf not on PATH or no server configured): ${error.message}`); @@ -129,6 +130,7 @@ try { ); } catch (error) { debug(`Could not read instructions template: ${error.message}`); + process.stdout.write("{}"); process.exit(0); } diff --git a/plugins/jfrog/templates/jfrog-mcp-management.md b/plugins/jfrog/templates/jfrog-mcp-management.md index 7792187..959796b 100644 --- a/plugins/jfrog/templates/jfrog-mcp-management.md +++ b/plugins/jfrog/templates/jfrog-mcp-management.md @@ -8,12 +8,22 @@ below instead. **Registry URL**: Wherever `` appears below, substitute the value of the `JFROG_MCP_GATEWAY_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 gateway command — `--list-available`, `--inspect`, `--login`)**: +- **Live execution is MANDATORY — context reuse is FORBIDDEN.** Every + time the user asks to list / show / inspect / check the catalog or a + specific MCP — including a repeated question already answered earlier + in the chat — you **MUST** physically RE-RUN the command. NEVER reuse, + copy, or re-display output from previous turns or context history; the + catalog, headers, and required inputs change between prompts. (Applies + to these catalog/registry fetches only — `--list-available` and + `--inspect`; NOT `--login`, which would re-open the OAuth browser, and + NOT reading local config for *installed* state.) + - **`` is always mandatory.** Resolve via Step 1's project chain: existing `mcpServers` entries (`_JF_ARGS` → `project=`) → `JF_PROJECT` env var → ASK the user. If none @@ -22,7 +32,10 @@ is set. Otherwise use - **`` is auto-resolvable.** Resolve via Step 1's server chain: existing `mcpServers` entries (value after `--server` in - `args`) → `~/.jfrog/jfrog-cli.conf.v6`: + `args`) → 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 jf CLI server configured → use it without asking; pass it as `--server `. The gateway would auto-resolve to the same value if `--server` were omitted, but we pass it explicitly for @@ -50,11 +63,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 + @@ -80,11 +93,10 @@ unless absolutely necessary: gateway can resolve credentials from these directly; DO NOT pass `--server` as that would make the gateway 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. 4. Else (file missing, empty, or unreadable, and no `JFROG_URL`) diff --git a/scripts/validate-hook-injector.mjs b/scripts/validate-hook-injector.mjs new file mode 100644 index 0000000..41eb66d --- /dev/null +++ b/scripts/validate-hook-injector.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 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 } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +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 }, + }); +} + +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 SessionStart additionalContext", () => { + const stdout = runInjector({ JF_AGENT_GUARD_FORCE_ENABLE: "true" }); + 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}`); + } + const hook = payload?.hookSpecificOutput; + if (hook?.hookEventName !== "SessionStart") { + throw new Error(`expected hookSpecificOutput.hookEventName === "SessionStart", got ${JSON.stringify(hook?.hookEventName)}`); + } + if (typeof hook.additionalContext !== "string" || hook.additionalContext.trim().length === 0) { + throw new Error("hookSpecificOutput.additionalContext is missing or empty"); + } + injectedContext = hook.additionalContext; + }); + + // ---- 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"); + } + }); + 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();