From 9a8ce55d891da4317502ba8614247c729e0ddf74 Mon Sep 17 00:00:00 2001 From: Matan Eden <57892946+MatanEden1@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:04:42 +0300 Subject: [PATCH 1/3] AX-1706 - Add JFrog CLI credential support to inject-instructions Resolve credentials from env vars, falling back to the JFrog CLI's default server via 'jf config export'. Users with the JFrog CLI configured but no env vars set now get agent guard instructions injected. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/inject-instructions.mjs | 60 +++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/scripts/inject-instructions.mjs b/scripts/inject-instructions.mjs index b4ca105..dd8ef2e 100755 --- a/scripts/inject-instructions.mjs +++ b/scripts/inject-instructions.mjs @@ -1,7 +1,9 @@ #!/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"; import path from "node:path"; import process from "node:process"; @@ -23,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 }; + } + + // `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; } - 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(/\/+$/, "") + @@ -59,7 +96,7 @@ async function isAgentGuardEnabledViaSettings() { } const data = await response.json(); const enabled = data?.settings?.mcpGatewayPluginEnabled?.value === true; - debug(`Settings response indicates Agent Guard enabled=${enabled}`); + debug(`Settings response indicates agent guard enabled=${enabled}`); return enabled; } catch (error) { const reason = error?.name === "AbortError" ? "timeout" : error?.message ?? "unknown error"; @@ -72,18 +109,17 @@ async function isAgentGuardEnabledViaSettings() { if (forceDisabled) { debug("Force-disable flag is set."); + process.stdout.write("{}"); process.exit(0); } else if (forceEnabled) { debug("Force-enable flag is set."); } else if (!(await isAgentGuardEnabledViaSettings())) { debug("Agent Guard not enabled; exiting without injecting instructions"); + process.stdout.write("{}"); process.exit(0); } debug("Injecting instructions"); -// Derive the plugin root from this script's own location instead of relying -// on CLAUDE_PLUGIN_ROOT, which Claude Code interpolates into the hook command -// string but does not always export to the subprocess. const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); let template; @@ -92,10 +128,12 @@ try { path.join(root, "templates", "jfrog-mcp-management.md"), "utf8", ); -} catch { +} 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({ hookSpecificOutput: { From e91f1e2a81c6f1e81c371da4b6d6a53886c1cf36 Mon Sep 17 00:00:00 2001 From: Matan Eden <57892946+MatanEden1@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:14:52 +0300 Subject: [PATCH 2/3] AX-1706 - Remove Apache license URL line from header Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/inject-instructions.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/inject-instructions.mjs b/scripts/inject-instructions.mjs index dd8ef2e..96f5e68 100755 --- a/scripts/inject-instructions.mjs +++ b/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 267602a02e27ae5b219bf15654c4a18f86cb3f3a Mon Sep 17 00:00:00 2001 From: Matan Eden <57892946+MatanEden1@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:57:18 +0300 Subject: [PATCH 3/3] AX-1706 - Align jfrog MCP template/injector with vscode plugin + 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, network-access note, --login OAuth note, network/403 troubleshooting; 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 | 30 ++++ scripts/inject-instructions.mjs | 2 + scripts/validate-hook-injector.mjs | 155 ++++++++++++++++++ templates/jfrog-mcp-management.md | 42 ++++- 4 files changed, 220 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..04f9261 --- /dev/null +++ b/.github/workflows/validate-inject-instructions.yml @@ -0,0 +1,30 @@ +# Copyright (c) JFrog Ltd. 2026 +# Licensed under the Apache License, Version 2.0 + +name: Validate hook injection + +on: + pull_request: + branches: [main] + paths: + - "scripts/inject-instructions.mjs" + - "templates/jfrog-mcp-management.md" + - "hooks/hooks.json" + - ".claude-plugin/plugin.json" + - "scripts/validate-hook-injector.mjs" + +jobs: + validate: + name: Validate hook injection + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: "24" + + - name: Run injector validation + run: node scripts/validate-hook-injector.mjs diff --git a/scripts/inject-instructions.mjs b/scripts/inject-instructions.mjs index 96f5e68..1c3141c 100755 --- a/scripts/inject-instructions.mjs +++ b/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/scripts/validate-hook-injector.mjs b/scripts/validate-hook-injector.mjs new file mode 100644 index 0000000..3707bfe --- /dev/null +++ b/scripts/validate-hook-injector.mjs @@ -0,0 +1,155 @@ +#!/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 injector = path.join(repoRoot, "scripts", "inject-instructions.mjs"); +const templatesDir = path.join(repoRoot, "templates"); +const hooksFile = path.join(repoRoot, "hooks", "hooks.json"); +const pluginManifestFile = path.join(repoRoot, ".claude-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.flatMap((e) => (e.hooks ?? []).map((h) => h.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 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(); diff --git a/templates/jfrog-mcp-management.md b/templates/jfrog-mcp-management.md index 1286236..fbc0ee7 100644 --- a/templates/jfrog-mcp-management.md +++ b/templates/jfrog-mcp-management.md @@ -14,12 +14,22 @@ 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 — `--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 @@ -28,7 +38,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 agent guard would auto-resolve to the same value if `--server` were omitted, but we pass it explicitly for @@ -42,6 +55,9 @@ is set. Otherwise use - 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. +- The commands need network access to the npm registry and the JFrog + platform. A corporate proxy, VPN, or blocked registry can surface as + `Forbidden` / `403` errors. Once both are determined, proceed. If either is still unknown, STOP — do NOT run the command with guesses. @@ -53,11 +69,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 + @@ -84,11 +100,10 @@ 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. 4. Else (file missing, empty, or unreadable, and no `JFROG_URL`) @@ -286,6 +301,10 @@ npx --yes \ --mcp ``` +Note: `--login` launches the system browser and runs a local OAuth +callback server, so the browser must be able to reach the IdP and loop +back to the local callback. + Outcomes: - **Exit 0** — OAuth completed; tokens cached; server ready. @@ -458,3 +477,8 @@ the display name. - **OAuth MCP failing** — refresh token expired; re-run Step 5. - **401/403 with `${VAR}`** — env var unset/wrong; re-export in the launching shell and relaunch. +- **Network / proxy / DNS error** — outside the agent guard's scope; + tell the user and stop. +- **npx package fetch returns 403** — usually a corporate proxy/VPN, a + blocked or wrong registry, or a curation policy. Troubleshoot + registry/auth/package/curation policy as usual.