Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"metadata": {
"description": "JFrog Platform plugins for Cursor",
"version": "0.5.12",
"version": "0.5.13",
"pluginRoot": "plugins"
},
"plugins": [
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/sync-modules-vendor.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"repo": "JFROG/jfrog-agent-hooks",
"pin": "jfrog-agent-hooks/v0.8.1",
"pin": "jfrog-agent-hooks/v0.9.0",
"paths": [
"modules"
]
Expand Down
2 changes: 1 addition & 1 deletion plugins/jfrog/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "jfrog",
"displayName": "JFrog Platform",
"version": "0.5.12",
"version": "0.5.13",
"description": "JFrog Platform integration with MCP, security skills, Agent Package Resolution, supply-chain best practices, and JFrog Agent Guard governance for adding, removing, and listing MCP servers.",
"author": {
"name": "JFrog",
Expand Down
11 changes: 1 addition & 10 deletions plugins/jfrog/modules/assets/agents-default-conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,7 @@
"enabled": false,
"verifyRepos": true,
"cacheTtlDays": 7,
"defaultGlobalRepos": {
"npm": "npm-virtual",
"pypi": "pypi-virtual",
"maven": "maven-virtual",
"gradle": "gradle-virtual",
"go": "go-virtual",
"docker": "docker-virtual",
"helm": "helm-virtual",
"nuget": "nuget-virtual"
},
"defaultGlobalRepos": {},
"autoSetup": []
}
}
12 changes: 10 additions & 2 deletions plugins/jfrog/modules/claude-session-start.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@
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 {
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";
Expand Down
90 changes: 90 additions & 0 deletions plugins/jfrog/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);
});
9 changes: 5 additions & 4 deletions plugins/jfrog/modules/core/agents-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { isSafeRepoKey } from "../package-resolution/scripts/repo-types.mjs";

/** modules bundle root (parent of core/ and assets/). */
const PLUGIN_ROOT = path.resolve(
Expand Down Expand Up @@ -174,9 +175,9 @@ export function getGlobalLogLevel() {
}

/**
* Package types the admin declares globally (governance source). Governance is
* the UNION of these and any workspace `.jfrog/local` repositories; the workspace
* side is added by the resolver (workspace-dependent, per-session).
* Package types the admin declares globally (the governance boundary).
* Workspace files may override repository keys for these types but cannot add
* new governed types.
* @returns {string[]} defaultGlobalRepos keys (unordered)
*/
export function globalDeclaredTypes() {
Expand Down Expand Up @@ -233,7 +234,7 @@ export function normalizeRepoMap(raw) {
if (!raw || typeof raw !== "object") return {};
const out = {};
for (const [type, key] of Object.entries(raw)) {
if (typeof key === "string" && key.trim()) out[type] = key.trim();
if (isSafeRepoKey(key?.trim())) out[type] = key.trim();
}
return out;
}
37 changes: 28 additions & 9 deletions plugins/jfrog/modules/core/io.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Shared stdin helpers for the subprocess-style adapters (Claude, Cursor).
// Shared stdin helpers for subprocess-style adapters (Claude, Cursor, VS Code).
//
// Hooks deliver their JSON payload on stdin immediately; in non-hook contexts
// (CI, npm scripts, terminal smoke tests) nothing arrives, so we bail out after
Expand Down Expand Up @@ -71,29 +71,47 @@ export function parseSessionId(stdinRaw) {
}
}

// Claude's documented SessionStart sources. VS Code Copilot documents only
// "new", so the two sets stay disjoint and neither can claim the other's
// sessions.
const CLAUDE_SESSION_SOURCES = new Set([
"startup",
"resume",
"clear",
"compact",
]);

// Positively identify the harness that invoked this hook from its stdin
// payload. Returns "cursor", "claude_code", or null when it can't tell
// (no stdin — e.g. terminal smoke tests — or an unrecognized shape).
// payload. Returns "cursor", "copilot", "claude_code", or null when no harness
// left a fingerprint (no stdin — e.g. terminal smoke tests — or a shape none of
// them own).
//
// Why this matters: Cursor reads sessionStart hooks from BOTH
// ~/.cursor/hooks.json AND ~/.claude/settings.json. Without this, a Cursor
// session fires the Claude adapter too, double-injecting the policy. Each
// adapter uses this to no-op when a different harness invoked it.
//
// Cursor: cursor_version / agent_type. Claude: transcript_path / hook_event_name /
// session_id. Cursor also reads ~/.claude/settings.json, so each adapter no-ops
// when a different harness invoked it.
// Every branch below is a signal exactly one harness documents, and null means
// "can't tell". An adapter is only ever registered by the harness it serves, so
// a payload no harness claims is left to whichever adapter was invoked.
export function detectHarness(stdinRaw) {
if (!stdinRaw) return null;
try {
const p = JSON.parse(stdinRaw);
if (!p) return null;
// Cursor stamps its own version/agent on every hook payload.
if (p.cursor_version || p.agent_type === "cursor") {
return "cursor";
}
if (p.transcript_path || p.hook_event_name || p.session_id) {
return "claude_code";
if (p.hook_event_name === "SessionStart") {
// Copilot's documented `new` source is decisive. Current VS Code payloads
// also include a transcript_path, so path presence cannot classify Claude
// before the source is checked.
if (p.source === "new") return "copilot";
if (CLAUDE_SESSION_SOURCES.has(p.source)) return "claude_code";
}
// Claude writes a transcript for non-SessionStart hooks too.
if (p.transcript_path) return "claude_code";
} catch {
// stdin wasn't JSON — can't tell.
}
Expand All @@ -102,7 +120,8 @@ export function detectHarness(stdinRaw) {

/**
* Workspace roots for this hook invocation.
* Cursor: workspace_roots[]. Claude: payload cwd. Fallback: process.cwd().
* Cursor: workspace_roots[]. Claude and VS Code Copilot: payload cwd.
* Fallback: process.cwd().
*
* @param {string} [stdinRaw]
* @returns {string[]}
Expand Down
Loading
Loading