Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,8 @@ When a policy block carries a waiver scope, the block message shows the command
> unconfigured or unentitled user, so no setup is needed to opt out of the feature.

- Set `JFROG_PLATFORM_URL` and `JFROG_ACCESS_TOKEN` (or configure the JFrog CLI — see [Authentication](#authentication)) and `JF_PROJECT` (the JFrog project the skill runs in). For an entitled account with credentials but no project, skills are **blocked** with a message telling you what to set; with no credentials at all they are **allowed**, per the table above.
- **Node.js (≥ 20) with `npx` on your `PATH`** — the hook resolves the Agent Guard through `npx`. Without it, governed actions are allowed unchecked.
- **macOS and Linux only.** These hook commands are POSIX shell, and VS Code starts hooks with `cmd.exe` on Windows, where the command fails before the Agent Guard is reached — so skills run **unchecked** on Windows rather than being blocked. Windows support is tracked separately.
- **Node.js (≥ 20) with `npx` on your `PATH`** — the hook resolves the Agent Guard through `npx`. If `npx` is missing or the Agent Guard fails, governed actions are allowed unchecked, and the hook writes the cause to stderr. That fail-open does **not** cover `node` itself: if `node` is missing or too old to run the launcher, the hook command fails before it starts, and VS Code denies every tool call (`PreToolUse` treats any exit other than 0 as a deny).
- **Windows, macOS, and Linux.** The hook command is `node` plus `scripts/enforce-skill.mjs`. VS Code runs that string with PowerShell on a normal Windows session, which cannot parse a POSIX one-liner, so the launcher computes the deadline and starts `npx` itself (on Windows, npm's `npx-cli.js` run by `node` with no shell, falling back to `npx.cmd` through `cmd.exe`). Agent Guard still decides allow or deny. If `npx` cannot start or Agent Guard fails, the hook returns `{}` with exit 0 and the skill is allowed.
- **Unlike the MCP alignment hook, this one does not pin the Agent Guard version.** It always resolves the latest published release, so a governance fix reaches you without waiting for a plugin release. `JFROG_AGENT_GUARD_REPO` still redirects the registry.
- **Coverage limits.** `github.copilot.chat.skillTool.enabled` is **off by default**, and with it off Copilot registers no `skill` tool — it instructs the model to read the matching `SKILL.md` with its `read_file` tool instead. That call reaches the `PreToolUse` hook and the Agent Guard governs it, so a default install is covered through `read_file` rather than through the skill tool. What is *not* covered: skills contributed by other extensions (`contributes.chatSkills`) are not searched, and neither are folders added through `chat.agentSkillsLocations`. A skill's content can also be reached without a governed surface — through a subagent, a terminal command, or a text search — and hooks are a VS Code Preview feature that can be disabled organization-side with no signal. An absence of blocks never by itself means a skill was allowed.
- **Cost per call.** VS Code parses `matcher` for Claude Code compatibility but ignores its value, so the `PreToolUse` hook runs on *every* tool call, and `UserPromptSubmit` on every prompt. The prompt hook revalidates the Agent Guard against the registry; the tool hook reads that from cache, which is what keeps the per-call cost down.
Expand Down
2 changes: 1 addition & 1 deletion marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
{
"name": "jfrog",
"description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices",
"version": "1.1.3",
"version": "1.1.4",
"license": "Apache-2.0",
"source": "plugin",
"categories": [
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "jfrog",
"description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices",
"version": "1.1.3",
"version": "1.1.4",
"license": "Apache-2.0",
"author": {
"name": "JFrog",
Expand Down
4 changes: 2 additions & 2 deletions plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"hooks": [
{
"type": "command",
"command": "npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 JF_AGENT_GUARD_ENFORCE_DEADLINE=\"$(_JFAG_NOW=$(date +%s 2>/dev/null); echo ${_JFAG_NOW:+$((_JFAG_NOW + 25))})\" npx --yes --registry \"${JFROG_AGENT_GUARD_REPO:-https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/}\" @jfrog/agent-guard --enforce-skill --client vscode",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/enforce-skill.mjs\"",
"timeout": 30,
"statusMessage": "Checking this skill against your JFrog governance policy…"
}
Expand All @@ -35,7 +35,7 @@
"hooks": [
{
"type": "command",
"command": "npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 JF_AGENT_GUARD_ENFORCE_DEADLINE=\"$(_JFAG_NOW=$(date +%s 2>/dev/null); echo ${_JFAG_NOW:+$((_JFAG_NOW + 25))})\" npx --yes --prefer-offline --registry \"${JFROG_AGENT_GUARD_REPO:-https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/}\" @jfrog/agent-guard --enforce-skill --client vscode",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/enforce-skill.mjs\" --prefer-offline",
"timeout": 30,
"statusMessage": "Checking this skill against your JFrog governance policy…"
}
Expand Down
213 changes: 213 additions & 0 deletions plugin/scripts/enforce-skill.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
#!/usr/bin/env node

// Starts @jfrog/agent-guard --enforce-skill. The hook string in hooks.json is only
// `node` plus this file: VS Code runs that string with PowerShell on Windows, which
// cannot parse a POSIX env prefix or $(date). This process computes the deadline,
// then spawns npx. It does not read the verdict.
//
// Exit codes: VS Code's PreToolUse fails closed on ANY non-zero exit, and exit 2 is
// the only deliberate block. agent-guard exits 0 or 2, so both pass through with its
// stdout. Every other outcome (spawn failure, signal, any other exit code) prints `{}`
// and exits 0, so a machine that cannot run the guard is not blocked by it. Each of
// those also writes one line to stderr naming the cause.

import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import process from "node:process";

import { isMainEntry } from "../modules/core/entry.mjs";
import {
AGENT_GUARD_PACKAGE,
resolveAgentGuardNpmRegistry,
} from "../modules/core/rewrite-mcp-json.mjs";

const DEADLINE_SLACK_SEC = 25;
export const FAIL_OPEN_OUTPUT = "{}";

/** @param {string} cause */
export function failOpenMessage(cause) {
return `[jfrog-agent-guard] skill governance did not run (${cause}); this skill was not governed.\n`;
}

/**
* npm's npx entry point, installed next to node.exe by the Windows Node installer.
* Running it with process.execPath needs no shell, unlike npx.cmd.
*
* @param {string} execPath
* @param {(p: string) => boolean} [exists]
* @returns {string | undefined}
*/
export function bundledNpxCli(execPath, exists = existsSync) {
const cli = path.win32.join(path.win32.dirname(execPath), "node_modules", "npm", "bin", "npx-cli.js");
return exists(cli) ? cli : undefined;
}

/**
* Quote one argument for the npx.cmd fallback under `cmd.exe /d /s /c`. That command
* line has no escape for `%` (`%%` stays literal, `%VAR%` expands) and none for `"`
* inside quotes, so an argument holding either is refused rather than mangled.
*
* @param {string} arg
* @returns {string}
*/
export function quoteCmdArg(arg) {
const value = String(arg ?? "");
if (/[\r\n%"]/.test(value)) {
throw new Error(`cmd.exe cannot carry this argument intact: ${JSON.stringify(value)}`);
}
return `"${value}"`;
}

/**
* Spawn plan for `npx --enforce-skill`. POSIX runs npx with no shell. Windows runs
* npm's npx-cli.js with node itself, and falls back to npx.cmd under cmd.exe (not
* ComSpec, which may be PowerShell) only when npm is not installed beside node.exe.
* JFROG_AGENT_GUARD_VERSION is ignored: governance must stay on the latest release.
*
* @param {{
* preferOffline?: boolean,
* env?: NodeJS.ProcessEnv,
* nowSec?: number,
* platform?: NodeJS.Platform,
* execPath?: string,
* exists?: (p: string) => boolean,
* }} [opts]
* @returns {{ command: string, args: string[], shell: string | false, env: NodeJS.ProcessEnv }}
*/
export function resolveEnforceSpawn(opts = {}) {
const env = opts.env ?? process.env;
const platform = opts.platform ?? process.platform;
const execPath = opts.execPath ?? process.execPath;
const childEnv = { ...env };
childEnv.npm_config_fetch_retries = "0";
childEnv.npm_config_fetch_timeout = "10000";
if (Number.isFinite(opts.nowSec)) {
childEnv.JF_AGENT_GUARD_ENFORCE_DEADLINE = String(Math.trunc(opts.nowSec) + DEADLINE_SLACK_SEC);
} else {
delete childEnv.JF_AGENT_GUARD_ENFORCE_DEADLINE;
}

const args = ["--yes"];
if (opts.preferOffline === true) args.push("--prefer-offline");
args.push(
"--registry",
resolveAgentGuardNpmRegistry(env),
AGENT_GUARD_PACKAGE,
"--enforce-skill",
"--client",
"vscode",
);

if (platform !== "win32") {
return { command: "npx", args, shell: false, env: childEnv };
}
const cli = bundledNpxCli(execPath, opts.exists);
if (cli) {
return { command: execPath, args: [cli, ...args], shell: false, env: childEnv };
}
return { command: "npx.cmd", args: args.map(quoteCmdArg), shell: "cmd.exe", env: childEnv };
}

/**
* Runs agent-guard with the hook payload on its stdin and resolves the exit code for
* VS Code: the child's 0 or 2 with its stdout, otherwise `{}` and 0.
*
* @param {{
* argv?: string[],
* env?: NodeJS.ProcessEnv,
* platform?: NodeJS.Platform,
* execPath?: string,
* exists?: (p: string) => boolean,
* nowSec?: number,
* spawnFn?: typeof spawn,
* stdin?: NodeJS.ReadableStream,
* stdout?: NodeJS.WritableStream,
* stderr?: NodeJS.WritableStream,
* }} [opts]
* @returns {Promise<0 | 2>}
*/
export function runEnforce(opts = {}) {
const stdin = opts.stdin ?? process.stdin;
const stdout = opts.stdout ?? process.stdout;
const stderr = opts.stderr ?? process.stderr;
const spawnFn = opts.spawnFn ?? spawn;

return new Promise((resolve) => {
let settled = false;
/** @param {0 | 2} code @param {string | Buffer} output */
const finish = (code, output) => {
if (settled) return;
settled = true;
if (output.length === 0) {
resolve(code);
return;
}
stdout.write(output, () => resolve(code));
};
/** @param {string} cause */
const failOpen = (cause) => {
if (settled) return;
stderr.write(failOpenMessage(cause));
finish(0, FAIL_OPEN_OUTPUT);
};

/** @type {ReturnType<typeof resolveEnforceSpawn>} */
let plan;
try {
plan = resolveEnforceSpawn({
preferOffline: (opts.argv ?? process.argv.slice(2)).includes("--prefer-offline"),
env: opts.env ?? process.env,
nowSec: opts.nowSec ?? Math.floor(Date.now() / 1000),
platform: opts.platform,
execPath: opts.execPath,
exists: opts.exists,
});
} catch (error) {
failOpen(`could not build the npx command: ${error?.message ?? error}`);
return;
}

/** @type {ReturnType<typeof spawn>} */
let child;
try {
child = spawnFn(plan.command, plan.args, {
env: plan.env,
shell: plan.shell,
windowsHide: true,
stdio: ["pipe", "pipe", "inherit"],
});
} catch (error) {
failOpen(`could not start ${plan.command}: ${error?.message ?? error}`);
return;
}

child.on("error", (error) => failOpen(`could not start ${plan.command}: ${error?.message ?? error}`));
if (!child.stdin || !child.stdout) {
child.kill?.();
failOpen(`${plan.command} has no stdio pipes`);
return;
}
/** @type {Buffer[]} */
const chunks = [];
child.stdout.on("data", (chunk) => chunks.push(chunk));
// A fast exit (including a policy exit 2) can EPIPE the stdin pipe. That must
// not replace the child's status.
child.stdin.on("error", () => {});
stdin.pipe(child.stdin);
child.on("close", (code, signal) => {
if (!signal && (code === 0 || code === 2)) finish(code, Buffer.concat(chunks));
else failOpen(signal ? `npx was killed by ${signal}` : `npx exited with code ${code}`);
});
});
}

if (isMainEntry(import.meta.url)) {
runEnforce().then(
(code) => process.exit(code),
(error) => {
process.stderr.write(failOpenMessage(`launcher error: ${error?.message ?? error}`));
process.stdout.write(FAIL_OPEN_OUTPUT, () => process.exit(0));
},
);
}
Loading
Loading