Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/validate-inject-instructions.yml
Original file line number Diff line number Diff line change
@@ -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
59 changes: 50 additions & 9 deletions plugins/jfrog/scripts/inject-instructions.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/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";
Expand All @@ -24,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 ?? "<unknown>"})`);
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(/\/+$/, "") +
Expand Down Expand Up @@ -92,13 +128,18 @@ 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({
additional_context: template,
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: template,
},
}),
);
30 changes: 21 additions & 9 deletions plugins/jfrog/templates/jfrog-mcp-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,22 @@ below instead.

**Registry URL**: Wherever `<REGISTRY_URL>` 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.)

- **`<PROJECT>` 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
Expand All @@ -22,7 +32,10 @@ is set. Otherwise use

- **`<SERVER_ID>` 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 <ID>`. The gateway would auto-resolve to the same
value if `--server` were omitted, but we pass it explicitly for
Expand Down Expand Up @@ -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`<SERVER_ID>` or URL `JFROG_URL`)
1. Resolve server (Server ID `<SERVER_ID>` or URL `JFROG_URL`)
and `<PROJECT>` 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 +
Expand All @@ -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`)
Expand Down
156 changes: 156 additions & 0 deletions scripts/validate-hook-injector.mjs
Original file line number Diff line number Diff line change
@@ -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/<file> 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();
Loading