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 b4ca105..1c3141c 100755 --- a/scripts/inject-instructions.mjs +++ b/scripts/inject-instructions.mjs @@ -2,6 +2,7 @@ // Copyright (c) JFrog Ltd. 2026 // Licensed under the Apache License, Version 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 +24,53 @@ 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"], + timeout: 3000, + }).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,13 @@ try { path.join(root, "templates", "jfrog-mcp-management.md"), "utf8", ); -} catch { +} catch (error) { + debug(`Could not read instructions template: ${error.message}`); + process.stdout.write("{}"); process.exit(0); } +// The IDE consumes hookSpecificOutput.additionalContext from a SessionStart hook. process.stdout.write( JSON.stringify({ hookSpecificOutput: { 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.