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
8 changes: 8 additions & 0 deletions .github/scripts/sync-modules-vendor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"repo": "JFROG/jfrog-agent-hooks",
"pin": "17df9b61b5cf78f49bf36d68e1c007f5db71e072",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Review comment by Cursor AI

🟠 Pin is post-v0.9.0; Sync Plugins on that tag would remove the Copilot adapter

pin = 17df9b61… (merged master SHA that adds modules/copilot-session-start.mjs). Tag jfrog-agent-hooks/v0.9.0 does not contain that adapter.

If Sync Plugins runs from v0.9.0 after this merges, it will replace plugin/modules/ with a tree that lacks copilot-session-start.mjs while hooks.json still points at it → SessionStart fails open with no routing.

Sibling plugins pin release tags (e.g. jfrog-agent-hooks/v0.8.1).

Fix: Cut an agent-hooks release that includes 17df9b6, re-pin this file to that tag, then allow Sync Plugins. Do not sync from v0.9.0.

@YoniMelki YoniMelki Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can confirm the parts of this checkable from this repo: the pin does match copilot-session-start.mjs's current content, and hooks.json does reference that exact file as the command to run, so its absence would break the hook exactly as described. Worth folding in here too, nothing in the validate script or the workflow actually diffs plugin/modules against this pin value. sync-modules.mjs reads it but only logs it, never compares. So a re-vendor from the wrong ref would pass CI as long as copilot-session-start.mjs still exists and the version numbers match, even if the resolver logic underneath had drifted.

"paths": [
"modules"
],
"dest_prefix": "plugin"
}
66 changes: 66 additions & 0 deletions .github/scripts/sync-modules.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env node
// Vendors modules bundle from jfrog-agent-hooks into plugin/.
//
// Usage:
// JFROG_AGENT_HOOKS_PATH=/path/to/jfrog-agent-hooks node .github/scripts/sync-modules.mjs
//
// Defaults JFROG_AGENT_HOOKS_PATH to ../jfrog-agent-hooks (sibling clone).
// Reads paths from sync-modules-vendor.json.

import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..", "..");
const vendorPath = path.join(scriptDir, "sync-modules-vendor.json");

async function fileExists(p) {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}

async function copyPath(fromDir, toDir, relativePath) {
const from = path.join(fromDir, relativePath);
const to = path.join(toDir, relativePath);
if (!(await fileExists(from))) {
throw new Error(`path missing in upstream: ${relativePath}`);
}
await fs.rm(to, { recursive: true, force: true });
await fs.mkdir(path.dirname(to), { recursive: true });
await fs.cp(from, to, { recursive: true });
console.log(` ${relativePath} -> ${path.relative(process.cwd(), to)}`);
}

async function main() {
const vendor = JSON.parse(await fs.readFile(vendorPath, "utf8"));
const paths = vendor.paths;
if (!Array.isArray(paths) || paths.length === 0) {
throw new Error(`${vendorPath} must define a non-empty paths array`);
}

const hooksRoot =
process.env.JFROG_AGENT_HOOKS_PATH?.trim() ||
path.resolve(repoRoot, "..", "jfrog-agent-hooks");

if (!(await fileExists(hooksRoot))) {
throw new Error(
`jfrog-agent-hooks not found at ${hooksRoot}. Set JFROG_AGENT_HOOKS_PATH.`,
);
}

const destPrefix = (vendor.dest_prefix ?? "").replace(/^\/+|\/+$/g, "");
const destRoot = destPrefix ? path.join(repoRoot, destPrefix) : repoRoot;

console.log(`--- sync from ${hooksRoot} (pin: ${vendor.pin ?? "local"}) ---`);
for (const rel of paths) {
await copyPath(hooksRoot, destRoot, rel);
}
console.log("done.");
}

await main();
33 changes: 33 additions & 0 deletions .github/workflows/validate-package-resolution-hook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright (c) JFrog Ltd. 2026
# Licensed under the Apache License, Version 2.0
# https://www.apache.org/licenses/LICENSE-2.0

name: Validate package resolution hook

on:
pull_request:
branches: [main]
paths:
- "plugin/hooks/hooks.json"
- "plugin/modules/**"
- "plugin/.claude-plugin/plugin.json"
- "marketplace.json"
- "scripts/validate-package-resolution-hook.mjs"
Comment thread
omerzi marked this conversation as resolved.
- ".github/scripts/sync-modules-vendor.json"
- ".github/scripts/sync-modules.mjs"

@YoniMelki YoniMelki Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The paths filter doesn't include this workflow file itself, so a PR that only edits this file wouldn't retrigger the job to confirm the change still works.


jobs:
validate:
name: Validate package resolution hook
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: Validate hook assembly
run: node scripts/validate-package-resolution-hook.mjs
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The JFrog plugin provides the following capabilities, grouped by component:
| --- | --- | --- |
| **MCP** | JFrog MCP server | Remote JFrog MCP server auto-attached to every session via `.mcp.json` at `${JFROG_URL}/mcp` (OAuth, no API keys). |
| **Skill** | Agent Guard | Copilot manages MCPs through the JFrog Agent Guard. Through it you can discover, install, configure, update, and remove MCP servers from the JFrog AI Catalog approved for your project, and authenticate to remote HTTP MCPs via OAuth, API key, or bearer token. |
| **Hook** | Agent Package Resolution (Preview) | Inject Artifactory routing instructions at the start of each Copilot session. |

---

Expand All @@ -27,7 +28,8 @@ Before installing, make sure you have:
- **JFrog host URL and access token** — Your JFrog platform URL and a valid access token.
- **VS Code** — With the **GitHub Copilot Chat** extension installed and signed in.
- **GitHub Copilot editor preview features enabled** (organizations only) — If your Copilot access is managed by a GitHub organization, an admin must navigate to **Settings → Copilot → Policies → Editor preview features** and set it to **Enabled**. Individual (non-org) Copilot users can skip this step.
- **Node.js** (≥ 18) — with `npx` on your `PATH`
- **Agent plugins and hooks enabled** — Set both `"chat.plugins.enabled": true` and `"chat.useHooks": true` in VS Code settings. Enabling hooks alone still leaves the plugin unloaded.
- **Node.js** (≥ 20) — available as `node` on your `PATH`
- **JFrog CLI** (≥ 2.x, optional) — Recommended for `jf config add` authentication (see [Authentication](#authentication)).
- **JFrog Platform access** (optional) — If you want to use the Agent Guard feature, your JFrog subscription needs to include the AI Catalog entitlement. Contact your JFrog account team if you're unsure whether it's enabled.
- **JFrog project** (optional) — If you want to use the Agent Guard feature.
Expand Down Expand Up @@ -103,6 +105,19 @@ If you have never configured the JFrog CLI on this machine:

After authentication, open a workspace in VS Code. The JFrog skills load on demand, the JFrog Agent Guard becomes active, and any MCP servers approved for your project become available to your Copilot agent. You can manage everything through natural language — no terminal commands required.

### Agent Package Resolution

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Review comment by Cursor AI

🟠 Missing APR user/admin docs vs sibling plugins

Cursor and Claude ship docs/package-resolution-user-guide.md, docs/package-resolution-admin-guide.md, a preview notice, and README links to those guides. This PR only adds a short Usage blurb — no docs/ folder and no guide links.

Shipping Copilot APR without the same user/admin guides leaves setup and governance undocumented for this harness.

Fix: Port the two guides (add VS Code prerequisites: chat.plugins.enabled, chat.useHooks), then add the preview notice + guide links in README to match cursor/claude.

@YoniMelki YoniMelki Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed there's no docs folder anywhere in this repo. The README section for this feature is about 11 lines covering where to enable it and what fail-open means, nothing on the config schema itself or what the NOT READY advisory means when someone hits it.


When Agent Package Resolution is enabled in `~/.jfrog/agents-conf.json`, a
SessionStart hook adds the resolved Artifactory repositories and package-routing
rules to every new Copilot chat. Configure the JFrog CLI with `jf config add`,
then start a new chat after changing the configuration.

The feature is fail-open for the chat session: disabled or unexpected failure
returns an empty hook result instead of preventing Copilot from starting. An
enabled but unconfigured installation injects a `NOT READY` advisory with setup
instructions. The hook has a 15-second limit to accommodate a cold, verified
repository lookup without delaying indefinitely.

### Discover, inspect, and install MCPs

| Ask the agent… | What happens |
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.0.11",
"version": "1.0.13",
"source": "plugin",
"categories": [
"security",
Expand Down
5 changes: 3 additions & 2 deletions plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "jfrog",
"description": "JFrog Platform integration with MCP, security skills, and supply-chain best practices",
"version": "1.0.11",
"author": { "name": "JFrog", "url": "https://jfrog.com" }
"version": "1.0.13",
"author": { "name": "JFrog", "url": "https://jfrog.com" },
"hooks": "hooks/hooks.json"
}
16 changes: 16 additions & 0 deletions plugin/hooks/hooks.json

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Review comment by Cursor AI

🟡 PR description is stale vs HEAD

Body still describes:

  • Agent Guard SessionStart injector alongside package resolution (flow diagram includes Agent Guard hook)
  • Vendored pin b680ef… and version 1.0.10

HEAD is package-resolution-only SessionStart (copilot-session-start.mjs), pin 17df9b6, version 1.0.13. Test plan still claims Agent Guard force enable/disable remains intact and that vendored modules exactly match the pin in CI.

Fix: Refresh overview, mermaid flow, pin/version, and test-plan checkboxes to match current tree.

@YoniMelki YoniMelki Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Agent Guard checkbox is checked, but there's nothing left to verify: hooks.json only has one hook now, and the author's own commit eafdc9b says outright that VS Code SessionStart validation was scoped to package resolution after Agent Guard moved to skill-based delivery. So that box covers a code path that doesn't exist anymore. Same thing with the Dependency section, it still reads as an open blocker on agent-hooks#48 even though that merged a while ago and the pin already points at the merged SHA. Separately from the description itself, this PR is out of draft and open for merge right now while this thread and the pin-tag one are both still sitting unanswered.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/modules/copilot-session-start.mjs\" package-resolution",
"timeout": 15,
"statusMessage": "Routing package installs through JFrog Artifactory…"
}
]
}
]
}
}
19 changes: 19 additions & 0 deletions plugin/modules/assets/agents-default-conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"logLevel": "info",
"packageResolution": {
"enabled": false,
"verifyRepos": true,
"cacheTtlDays": 7,
"defaultGlobalRepos": {

@YoniMelki YoniMelki Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This default config governs all 8 package types with placeholder repo keys (npm-virtual, pypi-virtual, and so on). verifyRepos defaults to true, and a governed-but-unresolved type stays governed and blocks installs instead of falling through. So the documented setup step of just flipping packageResolution.enabled to true, with nothing else changed, will block every npm/pip/maven/gradle/go/docker/helm/nuget install until someone edits these to real repo keys. Might be worth shipping this empty by default so enabling with no other config is a no-op instead of a full block.

"npm": "npm-virtual",
"pypi": "pypi-virtual",
"maven": "maven-virtual",
"gradle": "gradle-virtual",
"go": "go-virtual",
"docker": "docker-virtual",
"helm": "helm-virtual",
"nuget": "nuget-virtual"
},
"autoSetup": []
}
}
77 changes: 77 additions & 0 deletions plugin/modules/claude-session-start.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env node
// Claude Code SessionStart hook runner.
//
// Usage: node claude-session-start.mjs <capability>
// Example: node claude-session-start.mjs package-resolution
//
// stdout: JSON with hookSpecificOutput.additionalContext. No stdout is a no-op.

import process from "node:process";

import { runCapability } from "./core/run-capability.mjs";
import { ensureAgentsConfigScaffold, agentsConfigLoadWarnings } from "./core/agents-config.mjs";
import { readStdin, parseSessionId, detectHarness, parseWorkspaceRoots } from "./core/io.mjs";
import { setLogContext, createLogger } from "./core/logger.mjs";

const HARNESS_ID = "claude_code";
const log = createLogger("session-start");

/** @returns {string | null} JSON stdout payload, or null when there is nothing to inject. */
function formatSessionStartStdout(text) {
if (!text?.trim()) return null;
return JSON.stringify({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: text,
},
});
}

function writeStdout(payload) {
if (payload !== null) process.stdout.write(payload);
}

function writeNoOp() {
// Claude SessionStart: no stdout on no-op.
}

async function main() {
const capability = process.argv[2];
if (!capability) {
writeNoOp();
return;
}

const startedAtMs = Date.now();
const stdinRaw = await readStdin();
const harness = detectHarness(stdinRaw);
if (harness && harness !== HARNESS_ID) {
setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) });
log.warn("harness mismatch; wrong adapter invoked", {
expected: HARNESS_ID,
detected: harness,
adapter: "claude-session-start",
});
writeNoOp();
return;
}
const sessionId = parseSessionId(stdinRaw);
const workspaceRoots = parseWorkspaceRoots(stdinRaw);
setLogContext({ ide: HARNESS_ID, sessionId });
ensureAgentsConfigScaffold();
for (const w of agentsConfigLoadWarnings()) {
log.warn(w.message, { path: w.path });
}
const text = await runCapability(capability, {
ide: HARNESS_ID,
sessionId,
workspaceRoots,
startedAtMs,
});
writeStdout(formatSessionStartStdout(text));
}

main().catch(() => {
writeNoOp();
process.exit(0);
});
90 changes: 90 additions & 0 deletions plugin/modules/copilot-session-start.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env node
// GitHub Copilot Chat SessionStart hook runner (installed via the VS Code
// Copilot plugin — see jfrog/vscode-plugin).
//
// Usage: node copilot-session-start.mjs <capability>
// Example: node copilot-session-start.mjs package-resolution
//
// stdout: JSON with hookSpecificOutput.additionalContext. "{}" is a no-op.

import process from "node:process";

import { runCapability } from "./core/run-capability.mjs";
import {
ensureAgentsConfigScaffold,
agentsConfigLoadWarnings,
} from "./core/agents-config.mjs";
import {
readStdin,
parseSessionId,
detectHarness,
parseWorkspaceRoots,
} from "./core/io.mjs";
import { setLogContext, createLogger } from "./core/logger.mjs";

const HARNESS_ID = "copilot";
const log = createLogger("session-start");

/** @returns {string | null} JSON stdout payload, or null when there is nothing to inject. */
function formatSessionStartStdout(text) {
if (!text?.trim()) return null;
return JSON.stringify({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: text,
},
});
}

function writeStdout(payload) {
if (payload === null) {
writeNoOp();
return;
}
process.stdout.write(payload);
}

function writeNoOp() {
process.stdout.write("{}");
}

async function main() {
const capability = process.argv[2];
if (!capability) {
writeNoOp();
return;
}

const startedAtMs = Date.now();
const stdinRaw = await readStdin();
const harness = detectHarness(stdinRaw);
if (harness && harness !== HARNESS_ID) {
setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) });
log.warn("harness mismatch; wrong adapter invoked", {
expected: HARNESS_ID,
detected: harness,
adapter: "copilot-session-start",
});
writeNoOp();
return;
}
const sessionId = parseSessionId(stdinRaw);
const workspaceRoots = parseWorkspaceRoots(stdinRaw);
setLogContext({ ide: HARNESS_ID, sessionId });
ensureAgentsConfigScaffold();
for (const w of agentsConfigLoadWarnings()) {
log.warn(w.message, { path: w.path });
}
const text = await runCapability(capability, {
ide: HARNESS_ID,
sessionId,
workspaceRoots,
startedAtMs,
});
writeStdout(formatSessionStartStdout(text));
}

main().catch(() => {
writeNoOp();
process.exit(0);
});
Loading
Loading