diff --git a/.github/workflows/validate-inject-instructions.yml b/.github/workflows/validate-inject-instructions.yml new file mode 100644 index 0000000..be6f76d --- /dev/null +++ b/.github/workflows/validate-inject-instructions.yml @@ -0,0 +1,32 @@ +# 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: + - "plugin/scripts/inject-instructions.mjs" + - "plugin/templates/jfrog-mcp-management.md" + - "plugin/hooks/hooks.json" + - "plugin/.claude-plugin/plugin.json" + - "marketplace.json" + - "scripts/validate-hook-injector.mjs" + +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/marketplace.json b/marketplace.json index 9c8506a..655c1b2 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.3", + "version": "1.0.4", "source": "plugin", "categories": ["security", "artifact-management", "supply-chain", "devops", "mcp", "mlops", "agent-guard", "ai-catalog"], "platforms": ["darwin", "linux", "windows"], diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 0c70bb8..095ca0a 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": "0.1.0", + "version": "1.0.4", "author": { "name": "JFrog", "url": "https://jfrog.com" }, "hooks": "hooks/hooks.json" } diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 08fc3f8..bbb6692 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -1,11 +1,15 @@ { "hooks": { - "sessionStart": [ + "SessionStart": [ { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/ensure-instructions.sh", - "windows": "powershell -ExecutionPolicy Bypass -File ${CLAUDE_PLUGIN_ROOT}/scripts/ensure-instructions.ps1" + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/inject-instructions.mjs\"", + "timeout": 7 + } + ] } ] } -} \ No newline at end of file +} diff --git a/plugin/scripts/ensure-instructions.ps1 b/plugin/scripts/ensure-instructions.ps1 deleted file mode 100644 index be25b0d..0000000 --- a/plugin/scripts/ensure-instructions.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -$Target = ".github\copilot-instructions.md" -$Template = "$env:CLAUDE_PLUGIN_ROOT\templates\copilot-instructions.md" - -if (-not (Test-Path ".github")) { - New-Item -ItemType Directory -Path ".github" | Out-Null -} - -if (-not (Test-Path $Target)) { - Copy-Item $Template $Target - - $content = Get-Content $Template -Raw - $escaped = $content -replace '\\', '\\\\' -replace '"', '\"' -replace "`r`n", '\n' -replace "`n", '\n' - $notice = "JFrog MCP governance: .github/copilot-instructions.md installed by the JFrog plugin.`n`n" - Write-Output "{`"hookSpecificOutput`":{`"hookEventName`":`"SessionStart`",`"additionalContext`":`"$notice$escaped`"}}" -} diff --git a/plugin/scripts/ensure-instructions.sh b/plugin/scripts/ensure-instructions.sh deleted file mode 100755 index 01afeb5..0000000 --- a/plugin/scripts/ensure-instructions.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -TARGET=".github/copilot-instructions.md" -TEMPLATE="${CLAUDE_PLUGIN_ROOT}/templates/copilot-instructions.md" - -if [ ! -d ".github" ]; then - mkdir -p .github -fi - -if [ ! -f "$TARGET" ]; then - cp "$TEMPLATE" "$TARGET" - - CONTENT=$(sed 's/\\/\\\\/g; s/"/\\"/g' "$TEMPLATE" | awk '{printf "%s\\n", $0}') - NOTICE="JFrog MCP governance: .github/copilot-instructions.md installed by the JFrog plugin.\\n\\n" - - printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s%s"}}' "$NOTICE" "$CONTENT" -fi diff --git a/plugin/scripts/inject-instructions.mjs b/plugin/scripts/inject-instructions.mjs new file mode 100644 index 0000000..365f657 --- /dev/null +++ b/plugin/scripts/inject-instructions.mjs @@ -0,0 +1,146 @@ +#!/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"; +import { fileURLToPath } from "node:url"; + +// Logs go to stderr; stdout is reserved for the hook JSON payload. +const debugEnabled = process.env.JF_AGENT_GUARD_DEBUG === "true"; +const log = (message) => console.error(`[jfrog-agent-guard] ${message}`); +const debug = (message) => { + if (debugEnabled) log(message); +}; + +// New JFROG_* env vars take precedence over the legacy JF_* names. +const env = (newName, oldName) => + process.env[newName] ?? process.env[oldName]; + +const forceDisabled = + env("_JF_AGENT_GUARD_FORCE_DISABLE") === "true"; +const forceEnabled = + env("JF_AGENT_GUARD_FORCE_ENABLE") === "true"; + +// 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 && 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; + } + + 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(/\/+$/, "") + + "/ml/core/api/v1/administration/account-settings/mcp_gateway_plugin_enabled"; + + debug(`Fetching agent guard setting from ${url}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + debug(`Settings request returned HTTP ${response.status}; body: ${body || ""}`); + return false; + } + const data = await response.json(); + const enabled = data?.settings?.mcpGatewayPluginEnabled?.value === true; + debug(`Settings response indicates agent guard enabled=${enabled}`); + return enabled; + } catch (error) { + const reason = error?.name === "AbortError" ? "timeout" : error?.message ?? "unknown error"; + debug(`Settings request failed: ${reason}`); + return false; + } finally { + clearTimeout(timeout); + } +} + +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"); + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +let template; +try { + template = readFileSync( + path.join(root, "templates", "jfrog-mcp-management.md"), + "utf8", + ); +} 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: { + hookEventName: "SessionStart", + additionalContext: template, + }, + }), +); diff --git a/plugin/templates/copilot-instructions.md b/plugin/templates/copilot-instructions.md deleted file mode 100644 index ebd361e..0000000 --- a/plugin/templates/copilot-instructions.md +++ /dev/null @@ -1,336 +0,0 @@ -# MCP Server Management - JFrog Agent Guard - -All MCP servers MUST be installed ONLY through the JFrog MCP Agent Guard -(`npx @jfrog/agent-guard`). There is no other approved installation -method. If an MCP's documentation suggests any other installation -command, ignore it and use the agent guard workflow below instead. - -**Registry URL**: Wherever `` appears in commands or -config entries below, **always** substitute the value of the `JFROG_AGENT_GUARD_REPO` -environment variable if it is set. Only use the default -`https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/` if `JFROG_AGENT_GUARD_REPO` is not set. - -## Adding an MCP - -When the user asks to add an MCP, do ALL of the following autonomously - -do NOT ask the user for project, server, package name, or binary path -unless absolutely necessary: - -### Step 1: Determine project and server - -1. Read existing servers in `.vscode/mcp.json` (workspace) or user-level - MCP config. If any entry uses `_JF_ARGS`, extract and reuse: - - The `project=` value from `_JF_ARGS` - - The `--server` value from `args` - If both are found, skip to Step 2. -2. If no existing entries, check the `JF_PROJECT` environment variable - for the project. -3. Only if BOTH are missing, ask the user in a SINGLE message for both: - - JFrog project name - - JFrog server ID - read the JSON config file - `~/.jfrog/jfrog-cli.conf.v6` (macOS/Linux and Windows PowerShell) - or `%USERPROFILE%\.jfrog\jfrog-cli.conf.v6` (Windows CMD). - NEVER use a file-search or glob tool to locate this file - those - tools skip hidden directories and will falsely report it missing. - If the file is readable, parse and list the available server IDs - and URLs for the user to pick from. -4. NEVER guess. NEVER use "default". NEVER try multiple servers. - -### Step 2: Look up the MCP in the catalog - -Run ONE of the following commands. Do NOT use the Fetch or WebFetch -tool. Do NOT write a custom script. Do NOT hit the JFrog API directly. - -**If the user gave a specific MCP name** (normal "add X" case): - -``` -npx --yes \ - --registry \ - @jfrog/agent-guard \ - --inspect \ - --server \ - --project \ - --mcp -``` - -Output is a JSON object: `{ "spec": { "packageName": "...", -"mcpServerType": { "local": { "bootParams": {...} }, "remote": {...} } -... } }`. Parse it and extract ALL of the following (do NOT pre-filter -to required-only - Step 3 needs both required and optional entries): - -- `spec.packageName` - the exact package name to use in the config. -- `spec.mcpServerType.local.bootParams.environmentVariables[]` - every - env var entry for local MCPs. Each has `name`, `description`, - `isRequired`, `isSecret`. Keep all of them, including - `isRequired=false`. -- `spec.mcpServerType.remote.endpoints[].headers[]` - every HTTP - header entry for remote MCPs. Each has a `name` and an - `mcpInput.mcpInputDetails` object with `description`, `isRequired`, - `isSecret`. Keep all of them, including `isRequired=false`. - -If the command exits non-zero (MCP not found, network error, bad -credentials), show the error message to the user and then run -`--list-available` (see below) to offer the valid alternatives. - -**If the user did NOT specify a name** (e.g. "what can I install?"), -run `--list-available` instead (see "Listing MCPs" below). - -### Step 3: Plan inputs - -Take the inputs you collected in Step 2 and split them into two -groups by `isRequired`. You will NOT ask the user for the *values* -here - VS Code will prompt for those the first time the server -starts, using its native secure-input mechanism (values are stored -in the OS keychain, never in the file). - -1. **Required inputs** (`isRequired=true`) - always include them in - Step 4. Record `name`, `description`, and `isSecret`. -2. **Optional inputs** (`isRequired=false`) - if Step 2 returned - even ONE optional input, you MUST stop and ask the user before - continuing to Step 4. The message you send the user should: - - First list each REQUIRED input (so the user knows what will - be added without asking). - - Then list each OPTIONAL input by name, with its description, - and ask which (if any) they want to configure. - - Wait for the user's answer. - - Do NOT skip this question. Do NOT include optional inputs by - default. Do NOT decide on the user's behalf. Continue to Step 4 - only after the user answers, and include exactly the inputs they - opted into. -3. If Step 2 returned no inputs at all (neither required nor - optional), skip the `inputs` block within Step 4. - -### Step 4: Write the config entry - -Add the entry to `.vscode/mcp.json` under `servers`, and declare every -required input under a top-level `inputs` array. **Secrets MUST use -`${input:...}` substitution - never write a raw secret value into the -JSON file.** - -```json -{ - "inputs": [ - { - "type": "promptString", - "id": "-", - "description": "", - "password": true - } - ], - "servers": { - "": { - "type": "stdio", - "command": "npx", - "args": [ - "--yes", - "--registry", - "", - "@jfrog/agent-guard", - "--server", - "" - ], - "env": { - "_JF_ARGS": "project=&mcp=", - "": "${input:-}" - } - } - } -} -``` - -Rules for the `inputs` block: - -- One entry per required env var / header from Step 3. -- `id` is a workspace-unique identifier in the form - `-`, all lowercase, words separated by - hyphens. Re-use the same `id` across servers only when the value - truly is shared. -- `type` is always `"promptString"`. -- `password: true` for secret inputs (catalog `isSecret=true`) - hides - the characters VS Code shows while typing and stores the value - encrypted. Omit `password` for non-secret values like URLs. -- `description` shows in the VS Code prompt - use the catalog's - `description` field. -- Reference the input from `env` with `"${input:}"`. For HTTP - headers with a `Bearer` prefix, either put the prefix in the - description and ask the user to include it, or use - `"Bearer ${input:}"` and ask only for the token. - -For non-secret env vars (catalog `isSecret=false`), use an input -entry WITHOUT `password: true` so VS Code still prompts but doesn't -mask the typing. - -The loader reads these env vars at startup. VS Code substitutes every -`${input:}` with the stored value before handing the env to the -process - so the agent guard sees the real value, the file on disk shows -only the placeholder. - -### Step 5: Authenticate OAuth MCPs (run automatically after Step 4) - -Run this step ONLY when both conditions hold: - -- The Step 4 entry has NO `${input:...}` references (no `inputs` - block was needed), AND -- The `--inspect` output had a `remote` section. - -Otherwise (local-only MCP, or static-token MCP with `inputs`), skip -Step 5 entirely. - -The agent guard's `--login` command opens the user's browser, runs the -OAuth flow, and caches the tokens in `~/.jfrog/jfrogmcp.conf.json`. -Tell the user "I'm going to open your browser to sign you in to -``" before running it: - -``` -npx --yes \ - --registry \ - @jfrog/agent-guard \ - --login \ - --server \ - --project \ - --mcp -``` - -Outcomes: - -- Exits 0 - OAuth completed, tokens cached. Tell the user the - server is ready to start. -- Exits with `expected 401, got 200` - the MCP is anonymous, no - auth needed. Ignore the error; the server is ready to start. -- Any other error - paste it to the user verbatim and stop. - -## Troubleshooting - -### How to know a server actually failed - -VS Code labels MCP servers as Running, Stopped, or Failed in -`MCP: List Servers`. There is also a silent failure mode: - -- A server reporting **0 tools** (or **"Discovered 0 tools"**) while - shown as Running is NOT a healthy server with no tools - it means - the agent guard connected but the underlying MCP did not come up, so - no tools were exposed. Treat 0 tools the same as a Failed status. - -If the user says "the MCP isn't doing anything" or "tools aren't -showing up", check for both states before assuming the server is -working. - -### What to do - -1. **Previously-working OAuth MCP suddenly failing** - the cached - refresh token is likely dead. Re-run Step 5; the new tokens - overwrite the old ones. - -2. **Anything else** - ask the user to open `MCP: List Servers`, - right-click the failed (or 0-tools) server, choose **Show - Output**, and paste the last 50 lines. Read the output before - guessing at a cause. Common recoveries based on what the output - shows: - - - HTTP 401 / 403 / authentication error on a server with - `${input:...}` in its entry - the stored secret is wrong. Tell - the user to click the **Clear** CodeLens above the matching - `inputs` entry in `.vscode/mcp.json`, then restart the server; - VS Code will re-prompt for the secret. - - `Failed to refresh OAuth token` / `invalid_grant` / - `No such refresh token found` - re-run Step 5. - - Network / proxy / DNS error - outside the agent guard's scope; - tell the user and stop. - -## Removing an MCP - -Delete the entry from `servers` in `.vscode/mcp.json` and any now-unused -entries from the top-level `inputs` array. - -## Listing MCPs - -### Installed MCPs - -Read the `servers` entries from the VS Code MCP config file (workspace -`.vscode/mcp.json` or in the user profile settings) and list each entry -by display name, showing its package name (from `_JF_ARGS`) -and server ID. - -### Available MCPs (JFrog AI Catalog) - -1. Determine project and server ID using the same fallback chain as - "Adding an MCP -> Step 1": - - Try to extract from existing `_JF_ARGS` entries in - `.vscode/mcp.json`. - - If not found, check the `JF_PROJECT` environment variable for the - project. - - If still missing, read `~/.jfrog/jfrog-cli.conf.v6` via a terminal - command (NEVER via file-search/glob - hidden directories are - skipped) for available server IDs and ask the user to pick project - and server in a SINGLE message. -2. Run the agent guard with `--list-available`: - -``` -npx --yes \ - --registry \ - @jfrog/agent-guard \ - --list-available \ - --server \ - --project -``` - -The output is a compact TSV: a header line, then one server per line, -tab-separated: `nametypeversiondescription`. -Run the command ONCE and present the rows directly as a numbered -table - do NOT re-run it, redirect it, or parse it with `python3`/`jq`. -The `name` column is the install identifier (the value you pass to -`--inspect --mcp` and to install); `packageName` is NOT a separate -column - for remote/http MCPs there is no package name, so `name` is -the display name. - -3. Compare each `name` against the `_JF_ARGS` values - already present in `.vscode/mcp.json` to mark each one as - "available to install" or "already installed". - -## Key Rules - -- **Package scope is case-sensitive — ALWAYS write it lowercase as - `@jfrog/agent-guard`, NEVER `@JFrog/agent-guard`.** npm scopes are - case-sensitive; the published package is the lowercase - `@jfrog/agent-guard`. Capitalizing the brand (`@JFrog`) points at a - different/nonexistent scope and breaks the command. Use the exact - lowercase string in every command and config entry. -- **`npx` argument order (required):** `--yes`, `--registry `, - `@jfrog/agent-guard`, then the agent guard flags (`--inspect`, - `--login`, `--list-available`, or `--server ` for loader - mode). Both `--yes` and `--registry` MUST come BEFORE - `@jfrog/agent-guard` so `npx` picks them up; otherwise `npx` falls - back to the user's default registry (resolves to 404) and may - block on a confirmation prompt with no TTY. -- **OAuth login** uses `npx @jfrog/agent-guard --login` (Step 5). - Run it automatically after Step 4 for remote MCPs that have no - required headers, and again later if a previously-working OAuth - MCP starts failing with refresh errors. Never tell the user to - authenticate via the IDE's native OAuth dialog or by hand-editing - `~/.jfrog/jfrogmcp.conf.json`. -- `_JF_ARGS` MUST contain `project=&mcp=`. -- Package name MUST come from the catalog API. NEVER guess. -- NEVER pipe a catalog command through `python3`, and NEVER capture it - with `2>&1` - `npx`/`npm` writes progress to stderr, which corrupts - the output stream. For `--list-available` present the compact TSV it - prints; for `--inspect` read the JSON it prints on stdout - directly (or with a single `jq` filter), never via `python3`. -- NEVER install MCPs directly via `npx`/`pip`/`docker` - always use the - agent guard pattern above. -- NEVER write `"type": "sse"`, `"type": "http"`, or a top-level `"url"` - field in `.vscode/mcp.json`. Every server entry is `"type": "stdio"` - pointing at `npx @jfrog/agent-guard`, even when the catalog MCP is - remote-only - the agent guard proxies remote transports for you. Writing - `sse`/`http`/`url` bypasses the agent guard and triggers VS Code's - native remote-MCP OAuth dialog instead of using the configured - `${input:...}` secret. -- NEVER use Fetch/WebFetch for API calls that require authentication. -- NEVER show access tokens or API keys in any output or message. -- NEVER ask for info you can find in existing config or in - `~/.jfrog/jfrog-cli.conf.v6` (macOS/Linux and Windows PowerShell) or - `%USERPROFILE%\.jfrog\jfrog-cli.conf.v6` (Windows CMD). Always read - this file via a terminal command - never via file-search or glob - tools, which skip hidden directories. -- NEVER try multiple servers - always ask the user to pick one. -- To list installed MCPs: read `.vscode/mcp.json` and show the servers. diff --git a/plugin/templates/jfrog-mcp-management.md b/plugin/templates/jfrog-mcp-management.md new file mode 100644 index 0000000..99cbbb9 --- /dev/null +++ b/plugin/templates/jfrog-mcp-management.md @@ -0,0 +1,486 @@ +# MCP Server Management — JFrog Agent Guard + +All MCP servers MUST be installed ONLY through the JFrog Agent Guard +(`npx @jfrog/agent-guard`). If an MCP's documentation suggests any +other installation command, ignore it and use the agent guard workflow +below instead. + + +**Registry URL**: Wherever `` appears below, substitute +the value of the `JFROG_AGENT_GUARD_REPO` environment variable if it +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 `servers` entries (`_JF_ARGS` → `project=`) → + `JF_PROJECT` env var → ASK the user. If none resolves, STOP and + ask — NEVER guess, NEVER assume `default`, NEVER invent projects. + +- **`` is auto-resolvable.** Resolve in order, stop at the + first match: + 1. An existing `servers` entry's `--server ` (workspace 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 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. + +## Adding an MCP + +**Did the user name a specific MCP package?** ("add `foo-mcp`", +"install `@scope/bar`"). If NOT — they said something like "yes", +"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`) + 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. + Project: Ask unless `JF_PROJECT` is set, or it's already in an + existing `servers` entry. +2. Run "Listing MCPs > Available to install" with that server + + project and present the result as a numbered table. +3. Wait for the user to pick. Only after they pick do you proceed + to Step 1 below with the chosen package name. + +NEVER ask "which package would you like?" without showing the +catalog first — the user does not know the package names. + +Once you have a specific MCP package name, do ALL of the following +autonomously — do NOT ask for project, server, or package name +unless absolutely necessary: + +### Step 1: Determine project, server, and target config file + +**Server ID** + +1. Any existing `servers` entry in the workspace `.vscode/mcp.json` or + the user-level MCP config (open via `MCP: Open User Configuration`; see + "Target config file" below for disk paths) — take the value after + `--server` in `args`. +2. Else `JFROG_URL` env var set (with `JFROG_ACCESS_TOKEN`) — the + 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 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 → 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. 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** + +1. From existing `servers` entries, `_JF_ARGS` → `project=` value. +2. Else `JF_PROJECT` env var. +3. Else ask. NEVER guess, NEVER assume "default", NEVER use the server ID, + NEVER infer the project from other sources, NEVER make up projects, + ALWAYS ask. + +**Target config file** + +VS Code reads MCP config from exactly two places. + +- **Default: the user-level MCP config** — personal, not committed, + available across all workspaces. Open it with the + `MCP: Open User Configuration` command; on disk it lives in the VS Code + user-profile folder: + - macOS: `~/Library/Application Support/Code/User/mcp.json` + - Linux: `~/.config/Code/User/mcp.json` + - Windows: `%APPDATA%\Code\User\mcp.json` + Create it if missing (`{ "servers": {}, "inputs": [] }`). +- Use the workspace **`.vscode/mcp.json`** ONLY if the user says "for + this project" / "commit" / "share with the team" (shareable via git). + Write exclusively to the workspace file — do NOT touch the user-level + config. +- Do not ask which scope unless the user brings it up. + +### Step 2: Inspect the MCP in the catalog + +Step 2 needs a specific MCP name. If the user did NOT name one, do +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: + +``` +npx --yes \ + --registry \ + @jfrog/agent-guard \ + --inspect \ + --server \ + --project \ + --mcp +``` + +From the output JSON, extract (keep BOTH required AND optional): + +- `spec.packageName` — exact package name for the config. +- `spec.mcpServerType.local.bootParams.environmentVariables[]` for + local MCPs (each has `name`, `description`, `isRequired`, `isSecret`). +- `spec.mcpServerType.remote.endpoints[].headers[]` for remote MCPs + (each has `name` plus `mcpInput.mcpInputDetails` with the same + fields). + +On non-zero exit (typo, MCP not in catalog, network error, etc.), +show the error verbatim, then run `--list-available` (see "Listing +MCPs") so the user can pick a valid name and retry. + +### Step 3: Plan inputs + +You will NOT collect the input *values* here. VS Code prompts for them +the first time the server starts, using its native secure-input +mechanism, and stores them in the OS keychain (never in the file). +Step 3 only decides which inputs go into the config. + +Split Step 2 inputs by `isRequired`: + +1. **Required** (`isRequired=true`) — always include in Step 4. Record + `name`, `description`, and `isSecret`. +2. **Optional** (`isRequired=false`) — if even ONE exists, STOP and + ask. First list each required input (informational, so the user + knows what will be added without being asked), then list each + optional input by name + description and ask which (if any) they + want to configure. Do NOT skip this question, do NOT include + optional inputs by default, do NOT decide for the user. Continue to + Step 4 only after they answer, including exactly the inputs they + opted into. +3. No inputs at all → skip the `inputs` block in Step 4. + +### Step 4: Write the config entry + +Add the entry under `servers` in the target config (default the +user-level MCP config — see Step 1), and declare every input you are +configuring under the top-level `inputs` array. **Secrets MUST use +`${input:...}` substitution — never write a raw secret value into the +JSON file.** + +**Both `--yes` and `--registry ` MUST come BEFORE +`@jfrog/agent-guard`** or `npx` falls back to the default +registry (404) and may block on a no-TTY prompt. Use +`"type": "stdio"` — never `"http"`, `"sse"`, or a top-level `"url"` +(those bypass the agent guard). + +```json +{ + "inputs": [ + { + "type": "promptString", + "id": "-", + "description": "", + "password": true + }, + { + "type": "promptString", + "id": "-", + "description": "" + } + ], + "servers": { + "": { + "type": "stdio", + "command": "npx", + "args": [ + "--yes", + "--registry", + "", + "@jfrog/agent-guard", + "--server", + "" + ], + "env": { + "_JF_ARGS": "project=&mcp=", + "": "${input:-}", + "": "${input:-}" + } + } + } +} +``` + +Rules for the `inputs` block: + +- One entry per env var / header you are configuring from Step 3. +- `id` is an identifier unique within the config file it lives in + (user-level MCP config or workspace `.vscode/mcp.json`), in the form + `-`, all lowercase, words separated by + hyphens. Re-use the same `id` across servers only when the value + truly is shared. +- `type` is always `"promptString"`. +- `password: true` for secret inputs (catalog `isSecret=true`) — hides + the characters VS Code shows while typing and stores the value + encrypted. OMIT the `password` key entirely (never set it to `false`) + for non-secret values like URLs or flags (VS Code still prompts, but + does not mask the typing). +- `description` shows in the VS Code prompt — use the catalog's + `description` field. If the catalog leaves the description as an empty + string `""`, construct a brief context-appropriate description instead. +- Reference the input from `env` with `"${input:}"`. For HTTP + headers with a `Bearer` prefix, either put the prefix in the + description and ask the user to include it, or use + `"Bearer ${input:}"` and ask only for the token. + +VS Code substitutes every `${input:}` with the stored value before +handing the env to the process — so the agent guard sees the real +value, while the file on disk shows only the placeholder. + +### 4a: Start and verify the entry (mandatory) + +Writing the entry to `mcp.json` is not enough — the server still has to +be started and expose tools, which happens through VS Code's UI. + +**If the server is already enabled and running, you're done — skip steps +1–3 below.** (User-level entries sometimes start on their own.) + +Otherwise, ask the user to: + +1. **Start the server** — click the **Start** CodeLens above the + `mcp.json` entry, or `MCP: List Servers` → select it → **Start Server**. +2. **Enter inputs when prompted** — on first start, VS Code asks for each + `${input:...}` value (Step 3) and stores it in the OS keychain. + Required values must be supplied or the server fails to start. +3. **Verify** — have the user confirm in `MCP: List Servers` that it's + **Running with at least one tool**. Running but **0 tools** + ("Discovered 0 tools") is NOT healthy — the agent guard started but the + upstream MCP didn't come up. NEVER report success on 0 tools; treat it + as Failed and follow Troubleshooting "Running but 0 tools". + +### Step 5: Authenticate OAuth MCPs (auto, after Step 4) + +Run ONLY for OAuth-style remote MCPs — i.e. `--inspect` showed a +`remote` section with `type: "http"` AND Step 4 wrote no `${input:...}` +auth header into `env` (no static token). Skip for local MCPs and for remote MCPs whose +auth comes from a static token configured via `inputs`. + +`--login` opens the browser, runs OAuth, caches tokens in +`~/.jfrog/jfrogmcp.conf.json`. Warn the user "I'm going to open your +browser to sign you in to ``" before: + +``` +npx --yes \ + --registry \ + @jfrog/agent-guard \ + --login \ + --server \ + --project \ + --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. +- **`expected 401, got 200`** — MCP is anonymous (no auth needed); + ignore. +- **Any other error** — paste it to the user verbatim and stop. + +## Removing an MCP + +1. Delete the entry from `servers` in the file it was installed in + (user-level MCP config or workspace `.vscode/mcp.json`). +2. Delete any now-unused entries from the top-level `inputs` array — + leave NO orphaned input entries for the removed server. For remote + MCPs, also remove any HTTP header entries that were configured for + it. +3. If OAuth was used (Step 5), also remove its entry from + `~/.jfrog/jfrogmcp.conf.json` so cached login tokens are wiped. +4. Tell the user to reload (`Developer: Reload Window`) or restart the + server from `MCP: List Servers` so the removed entry stops loading + (the config is read at session start only). + +## Listing MCPs + +**Route the request first** — pick which subsection to run BEFORE +touching any file or shell: + +| User said… | Run | +| --- | --- | +| "available", "what can I install", "what's in the catalog", "list MCPs" without other context | **Available to install** below — go straight to `--list-available`; do NOT inspect local files first | +| "installed", "configured", "connected", "running", "what MCPs do I have" | **Currently installed** below | +| ambiguous / both | run **both** subsections in order: Currently installed first, then Available to install, and present them as separate tables | + +NEVER invent MCP integrations from outside the catalog. The only +authoritative source for what's available is `--list-available` +against the configured server + project. If that command returns +nothing or errors, say so — do not pad the answer with names from +elsewhere. + +### Currently installed + +1. Read `servers` directly from BOTH the workspace `.vscode/mcp.json` + and the user-level MCP config (see "Target config file" in Step 1 for + OS-specific paths) — use the file-read tool or a single `jq` + invocation, NOT chained `python3 -c "..."` pipes. For each entry whose + `command` is `npx` and whose `args` include `@jfrog/agent-guard`, + show: display name (the JSON key), package (`mcp=` in `_JF_ARGS`), + server ID (value after `--server`), and scope (workspace / user). + Your output should structurally mirror the config. This covers the + normal "what MCPs do I have / are configured" case — do this yourself, + do not make the user do anything. +2. Live connection status (Running / Stopped / Failed) lives only in + VS Code's UI, which the agent cannot read. ONLY when the user + explicitly asks whether a server is running/connected — or while + troubleshooting — ask them to open `MCP: List Servers` and report + each server's status. If a configured entry does not appear there, it + was never started — re-run Step 4a. + +### Available to install + +1. Determine **server** and **project** per the Pre-flight rule at + the top of this document. `--list-available` does NOT require + any existing `servers` entry or pre-installed agent guard — + `npx --yes` fetches the agent guard on demand, so this works on a + fresh machine too. +2. Run EXACTLY this command — `--project` is passed as a CLI flag. + To configure the server, either use the serverId from a jf cli + config with `--server` or omit `--server` if env vars are used to + configure URL and Access Token. **no additional env vars needed**: + +``` +npx --yes \ + --registry \ + @jfrog/agent-guard \ + --list-available \ + --project \ + [--server ] +``` + +The output is a compact TSV: a header line, then one server per line, +tab-separated: `nametypeversiondescription`. +Run the command ONCE and present the rows directly as a numbered +table — do NOT re-run it, redirect it, or parse it with `python3`/`jq`. +The `name` column is the install identifier (the value you pass to +`--inspect --mcp` and to install); `packageName` is NOT a separate +column — for remote/http MCPs there is no package name, so `name` is +the display name. + +3. Filter out any `name` already present in the installed list + (compare against `mcp=` in `_JF_ARGS`). Mark the rest as + available to install. + +## Key Rules + +- **Package scope is case-sensitive — ALWAYS write it lowercase as + `@jfrog/agent-guard`, NEVER `@JFrog/agent-guard`.** npm scopes are + case-sensitive; the published package is the lowercase + `@jfrog/agent-guard`. Capitalizing the brand (`@JFrog`) points at a + different/nonexistent scope and breaks the command. Use the exact + lowercase string in every command and config entry. +- **`npx` arg order:** `--yes`, `--registry `, + `@jfrog/agent-guard`, then agent guard flags. Both `--yes` and + `--registry` MUST precede the package name or `npx` falls back to + the default registry (404) and may block on a no-TTY prompt. +- **Always `"type": "stdio"`** pointing at `npx @jfrog/agent-guard`, + even for remote-only catalog MCPs (the agent guard proxies them). + `"http"`, `"sse"`, or a top-level `"url"` bypass the agent guard and + trigger VS Code's native remote-MCP OAuth dialog instead of using the + configured `${input:...}` secret. +- `_JF_ARGS` is **only** for the entry VS Code launches at session + start (Step 4's `servers.*.env`); MUST contain + `project=&mcp=`. NEVER pass `_JF_ARGS` to + `--list-available`, `--inspect`, or `--login` — those take + `--server` / `--project` as CLI flags only. +- NEVER assume `default` as a project name. If the project is unknown + after Step 1's chain (existing `servers` entries → `JF_PROJECT` + env var), STOP and ask the user. Same for server ID if used. + NEVER invent or guess projects or server IDs. +- Package name MUST come from the catalog (`--inspect` / + `--list-available`). NEVER guess. NEVER install MCPs outside the + agent guard. NEVER use Fetch/WebFetch for catalog calls. +- NEVER pipe a catalog command through `python3`, and NEVER capture it + with `2>&1` — `npx`/`npm` writes progress to stderr, which corrupts + the output stream. For `--list-available` present the compact TSV it + prints; for `--inspect` read the JSON it prints on stdout + directly (or with a single `jq` filter), never via `python3`. +- NEVER write a raw secret into `mcp.json` — always use an + `${input:}` reference. NEVER show tokens / API keys. +- NEVER try multiple servers — ask the user to pick one. + +## Troubleshooting + +**Agent vs user actions:** you cannot operate the VS Code UI. Anything +that requires clicking, right-clicking, a CodeLens, or running a `MCP:` +command palette action is a **user** step — ask the user to do it and +paste back any output. Reading files, running `npx @jfrog/agent-guard` +commands, and editing `mcp.json` are **your** steps; do those yourself +and keep the asks to the minimum. + +- **Running but 0 tools (`MCP: List Servers` shows the server Running + but it reports "Discovered 0 tools")** — agent guard proxy started, + upstream MCP did not. The Running label is misleading here. NEVER + report success when there are 0 tools. + 1. **Ask the user** (UI-only step) to open `MCP: List Servers`, + right-click the server, choose **Show Output**, and paste back the + last ~50 lines of agent guard output. Read them before guessing, + then diagnose by MCP type: + - **OAuth (remote)** — you re-run Step 5 (`--login`); refresh token + likely expired. + - **Static-token (remote)** — the stored `${input:...}` value is + likely missing or wrong. **Ask the user** to click the **Clear** + CodeLens above the matching `inputs` entry and restart the + server so VS Code re-prompts for it. + - **Local (stdio)** — you read the spawn error from the output the + user pasted (the bundled binary failed to launch). + 2. You verify the MCP server is still allowed — + see "Listing MCPs > Available to install". +- **`mcp.json` server missing from `MCP: List Servers`** — + never started, or a JSON parse failure (often an undefined + `${input:...}` id). Fix the config and re-run Step 4a. +- **HTTP 401 / 403 on a server with `${input:...}`** — the stored + secret is wrong. Tell the user to click the **Clear** CodeLens above + the matching `inputs` entry in `mcp.json`, then restart the + server; VS Code re-prompts for the secret. +- **Agent Guard: `multiple/no JFrog server configured`** (the agent guard + cannot pick a JFrog server) — pass `--server ` (after + `jf c add `) OR export both `JFROG_URL` and + `JFROG_ACCESS_TOKEN` in the launching shell, then reload VS Code. +- **OAuth MCP failing / `invalid_grant` / `No such refresh token`** — + refresh token expired; re-run Step 5. +- **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. diff --git a/scripts/validate-hook-injector.mjs b/scripts/validate-hook-injector.mjs new file mode 100644 index 0000000..9c9c0e6 --- /dev/null +++ b/scripts/validate-hook-injector.mjs @@ -0,0 +1,183 @@ +#!/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 — marketplace.json / plugin.json / hooks.json / template +// wiring is internally consistent (names, versions, 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, "plugin", "scripts", "inject-instructions.mjs"); +const templatesDir = path.join(repoRoot, "plugin", "templates"); +const hooksFile = path.join(repoRoot, "plugin", "hooks", "hooks.json"); +const marketplaceFile = path.join(repoRoot, "marketplace.json"); +const pluginManifestFile = path.join(repoRoot, "plugin", ".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: manifests, hook wiring, and template read-path are consistent ---- + section("Lint (manifest & wiring)"); + + let marketplacePlugin; + check("marketplace.json lists the jfrog plugin with a valid version and source", () => { + const mp = JSON.parse(readFileSync(marketplaceFile, "utf8")); + if (!Array.isArray(mp.plugins) || mp.plugins.length === 0) { + throw new Error('"plugins" must be a non-empty array'); + } + marketplacePlugin = mp.plugins.find((p) => p && p.name === "jfrog"); + if (!marketplacePlugin) throw new Error('no plugin named "jfrog" in marketplace.json'); + if (!/^\d+\.\d+\.\d+$/.test(marketplacePlugin.version ?? "")) { + throw new Error(`plugin version is missing or not semver: ${JSON.stringify(marketplacePlugin.version)}`); + } + const src = marketplacePlugin.source; + if (typeof src !== "string" || !src) throw new Error('plugin "source" must be a non-empty string'); + if (!existsSync(path.join(repoRoot, src))) throw new Error(`source dir "${src}" does not exist`); + }); + + let pluginManifest; + check("plugin.json matches the marketplace entry (name + version)", () => { + pluginManifest = JSON.parse(readFileSync(pluginManifestFile, "utf8")); + if (pluginManifest.name !== "jfrog") { + throw new Error(`plugin.json name "${pluginManifest.name}" does not match marketplace name "jfrog"`); + } + if (marketplacePlugin && pluginManifest.version !== marketplacePlugin.version) { + throw new Error(`plugin.json version "${pluginManifest.version}" does not match marketplace version "${marketplacePlugin.version}"`); + } + }); + + check("plugin.json hooks path exists", () => { + if (!pluginManifest) throw new Error("plugin.json was not parsed (see earlier check)"); + const hooksRel = pluginManifest.hooks; + if (typeof hooksRel !== "string" || !hooksRel) throw new Error('plugin.json "hooks" must be a non-empty string'); + if (!existsSync(path.join(repoRoot, "plugin", hooksRel))) { + throw new Error(`plugin.json "hooks" references missing path "${hooksRel}"`); + } + }); + + 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 plugin/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();