diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 58fd978..964ec6f 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "JFrog Platform plugins for Cursor", - "version": "0.5.4", + "version": "0.5.5", "pluginRoot": "plugins" }, "plugins": [ diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json new file mode 100644 index 0000000..c79ea0e --- /dev/null +++ b/.github/scripts/sync-modules-vendor.json @@ -0,0 +1,7 @@ +{ + "repo": "JFROG/jfrog-agent-hooks", + "pin": "jfrog-agent-hooks/v0.5.0", + "paths": [ + "modules" + ] +} diff --git a/plugins/jfrog/.cursor-plugin/plugin.json b/plugins/jfrog/.cursor-plugin/plugin.json index e24c846..5752f7e 100644 --- a/plugins/jfrog/.cursor-plugin/plugin.json +++ b/plugins/jfrog/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "displayName": "JFrog Platform", - "version": "0.5.4", + "version": "0.5.5", "description": "JFrog Platform integration with MCP, security skills, supply-chain best practices, and JFrog Agent Guard governance for adding, removing, and listing MCP servers.", "author": { "name": "JFrog", diff --git a/plugins/jfrog/modules/assets/agents-default-conf.json b/plugins/jfrog/modules/assets/agents-default-conf.json new file mode 100644 index 0000000..2f0f0a8 --- /dev/null +++ b/plugins/jfrog/modules/assets/agents-default-conf.json @@ -0,0 +1,17 @@ +{ + "logLevel": "info", + "packageResolution": { + "enabled": false, + "verifyRepos": true, + "cacheTtlDays": 7, + "defaultGlobalRepos": { + "npm": "npm-virtual", + "pypi": "pypi-virtual", + "maven": "maven-virtual", + "go": "go-virtual", + "docker": "docker-virtual", + "helm": "helm-virtual", + "nuget": "nuget-virtual" + } + } +} diff --git a/plugins/jfrog/modules/claude-session-start.mjs b/plugins/jfrog/modules/claude-session-start.mjs new file mode 100644 index 0000000..4a31144 --- /dev/null +++ b/plugins/jfrog/modules/claude-session-start.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +// Claude Code SessionStart hook runner. +// +// Usage: node claude-session-start.mjs +// 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) { + 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); +}); diff --git a/plugins/jfrog/modules/core/agents-config.mjs b/plugins/jfrog/modules/core/agents-config.mjs new file mode 100644 index 0000000..dab3898 --- /dev/null +++ b/plugins/jfrog/modules/core/agents-config.mjs @@ -0,0 +1,184 @@ +// Local admin config at ~/.jfrog/agents-conf.json (shipped template: assets/agents-default-conf.json). +// +// Read-only helpers — no network. Session starters call ensureAgentsConfigScaffold() +// before capabilities run so first-time installs get a writable config file. + +import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** modules bundle root (parent of core/ and assets/). */ +const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const TEMPLATE_PATH = path.join(PLUGIN_ROOT, "assets", "agents-default-conf.json"); + +const DEFAULT_LOG_LEVEL = "info"; +const DEFAULT_CACHE_TTL_DAYS = 7; + +let memoizedRaw = undefined; +let memoizedForPath = null; +/** @type {{ source: 'missing' | 'user' | 'template', parseFailed: boolean, path: string }} */ +let loadMeta = { source: "missing", parseFailed: false, path: "" }; + +function agentsConfigPath() { + return path.join(homedir(), ".jfrog", "agents-conf.json"); +} + +function resetLoadMeta(configPath) { + loadMeta = { source: "missing", parseFailed: false, path: configPath }; +} + +/** + * Copy the shipped template to ~/.jfrog/agents-conf.json when missing. + * Never overwrites an existing file. + */ +export function ensureAgentsConfigScaffold() { + const configPath = agentsConfigPath(); + if (existsSync(configPath)) return { created: false, path: configPath }; + try { + mkdirSync(path.dirname(configPath), { recursive: true }); + copyFileSync(TEMPLATE_PATH, configPath); + memoizedRaw = undefined; + return { created: true, path: configPath }; + } catch { + return { created: false, path: configPath }; + } +} + +export { agentsConfigPath }; + +export function agentsConfigTemplatePath() { + return TEMPLATE_PATH; +} + +/** @returns {number | null} mtime in ms, or null when the file is absent */ +export function getAgentsConfigMtimeMs() { + try { + return statSync(agentsConfigPath()).mtimeMs; + } catch { + return null; + } +} + +function parseAgentsJson(raw) { + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function readAgentsConfigRaw() { + const configPath = agentsConfigPath(); + if (memoizedForPath !== configPath) { + memoizedRaw = undefined; + memoizedForPath = configPath; + resetLoadMeta(configPath); + } + if (memoizedRaw !== undefined) return memoizedRaw; + + const userExists = existsSync(configPath); + if (userExists) { + try { + const parsed = parseAgentsJson(readFileSync(configPath, "utf8")); + if (parsed) { + memoizedRaw = parsed; + loadMeta = { source: "user", parseFailed: false, path: configPath }; + return memoizedRaw; + } + loadMeta = { source: "template", parseFailed: true, path: configPath }; + } catch { + loadMeta = { source: "template", parseFailed: true, path: configPath }; + } + } + + try { + memoizedRaw = parseAgentsJson(readFileSync(TEMPLATE_PATH, "utf8")); + if (!userExists) { + loadMeta = { source: memoizedRaw ? "template" : "missing", parseFailed: false, path: configPath }; + } + } catch { + memoizedRaw = null; + if (!userExists) loadMeta = { source: "missing", parseFailed: false, path: configPath }; + } + return memoizedRaw; +} + +/** Call after loadAgentsConfig() — surfaces user-file parse failures. */ +export function getAgentsConfigLoadMeta() { + readAgentsConfigRaw(); + return { ...loadMeta }; +} + +/** @returns {Array<{ message: string, path: string }>} */ +export function agentsConfigLoadWarnings() { + loadAgentsConfig(); + if (!loadMeta.parseFailed) return []; + return [ + { + message: "agents-conf.json unreadable; using shipped template defaults", + path: loadMeta.path, + }, + ]; +} + +/** @returns {object | null} raw section or null */ +export function getAgentsConfigSection(name) { + const config = readAgentsConfigRaw(); + if (!config) return null; + const section = config[name]; + return section && typeof section === "object" ? section : null; +} + +/** @returns {{ logLevel: string, packageResolution: object }} merged with documented defaults */ +export function loadAgentsConfig() { + const file = readAgentsConfigRaw() ?? {}; + const pr = + file.packageResolution && typeof file.packageResolution === "object" + ? file.packageResolution + : {}; + const defaultGlobalRepos = + pr.defaultGlobalRepos && typeof pr.defaultGlobalRepos === "object" + ? normalizeRepoMap(pr.defaultGlobalRepos) + : {}; + + return { + logLevel: normalizeLogLevel(file.logLevel), + packageResolution: { + enabled: pr.enabled === true, + verifyRepos: pr.verifyRepos !== false, + cacheTtlDays: normalizeCacheTtlDays(pr.cacheTtlDays), + defaultGlobalRepos, + }, + }; +} + +export function getGlobalLogLevel() { + return loadAgentsConfig().logLevel; +} + +function normalizeLogLevel(level) { + const s = typeof level === "string" ? level.toLowerCase() : ""; + const allowed = new Set(["silent", "debug", "info", "warn", "error"]); + return allowed.has(s) ? s : DEFAULT_LOG_LEVEL; +} + +function normalizeCacheTtlDays(days) { + if (days === 0) return 0; + if (typeof days !== "number" || !Number.isFinite(days) || days < 0) { + return DEFAULT_CACHE_TTL_DAYS; + } + return Math.floor(days); +} + +/** Trim string repo keys; drop empty values. */ +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(); + } + return out; +} diff --git a/plugins/jfrog/modules/core/io.mjs b/plugins/jfrog/modules/core/io.mjs new file mode 100644 index 0000000..5127bae --- /dev/null +++ b/plugins/jfrog/modules/core/io.mjs @@ -0,0 +1,95 @@ +// Shared stdin helpers for the subprocess-style adapters (Claude, Cursor). +// +// 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 +// a short idle window rather than hang. + +import process from "node:process"; + +export function readStdin({ idleMs = 50 } = {}) { + return new Promise((resolve) => { + if (process.stdin.isTTY) return resolve(""); + let data = ""; + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + resolve(data); + }; + const idleTimer = setTimeout(settle, idleMs); + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + idleTimer.refresh(); + }); + process.stdin.on("end", () => { + clearTimeout(idleTimer); + settle(); + }); + process.stdin.on("error", () => { + clearTimeout(idleTimer); + settle(); + }); + }); +} + +export function parseSessionId(stdinRaw) { + if (!stdinRaw) return undefined; + try { + return JSON.parse(stdinRaw)?.session_id; + } catch { + return undefined; + } +} + +// 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). +// +// 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. +export function detectHarness(stdinRaw) { + if (!stdinRaw) return null; + try { + const p = JSON.parse(stdinRaw); + if (!p) return null; + if (p.cursor_version || p.agent_type === "cursor") { + return "cursor"; + } + if (p.transcript_path || p.hook_event_name || p.session_id) { + return "claude_code"; + } + } catch { + // stdin wasn't JSON — can't tell. + } + return null; +} + +/** + * Workspace roots for this hook invocation. + * Cursor: workspace_roots[]. Claude: payload cwd. Fallback: process.cwd(). + * + * @param {string} [stdinRaw] + * @returns {string[]} + */ +export function parseWorkspaceRoots(stdinRaw) { + if (stdinRaw?.trim()) { + try { + const p = JSON.parse(stdinRaw); + if (Array.isArray(p.workspace_roots) && p.workspace_roots.length) { + return p.workspace_roots.filter((r) => typeof r === "string" && r); + } + if (typeof p.cwd === "string" && p.cwd) return [p.cwd]; + } catch { + // fall through + } + } + + return [process.cwd()]; +} diff --git a/plugins/jfrog/modules/core/jf-identity.mjs b/plugins/jfrog/modules/core/jf-identity.mjs new file mode 100644 index 0000000..b63a0a6 --- /dev/null +++ b/plugins/jfrog/modules/core/jf-identity.mjs @@ -0,0 +1,166 @@ +// Platform identity — single source of truth for "where is JFrog and how do +// we auth to it?". Used by feature-flag.mjs and resolver.mjs. +// +// Identity ALWAYS comes from `jf config`. `jf config export [serverId]` returns +// base64(JSON({ url, accessToken, serverId, ... })) for the chosen (or default) +// server. We require both `url` AND `accessToken` (Bearer-only path). +// +// If `jf` is not on PATH, has no configured servers, or the chosen server has +// no access token, identity is null and the feature flag falls into the +// `missing-identity` path (hook goes no-op, fail closed). Same behaviour as +// before — only the configuration mechanism is simpler. +// +// One subprocess per hook process. Cached after first call within the same +// process (feature-flag + resolver share one export). Not persisted across +// sessions — `jf config export` is local and fast enough to run every time. + +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +import { createLogger } from "./logger.mjs"; + +const log = createLogger("jf-identity"); + +// Module-scope cache. Keyed by the requested serverId hint (`undefined` +// means "whatever jf considers default"). Stores the full resolved object, +// including null when jf config produced nothing usable. +const CACHE = new Map(); + +function normalizeUrl(u) { + if (!u) return ""; + return String(u).replace(/\/+$/, ""); +} + +// Resolution cause. `ok` means identity is present; the two failure causes +// drive cause-aware remediation in the enforce path: +// jf-not-installed — `jf` is not on PATH / could not be executed. +// jf-not-configured — `jf` ran but produced no usable server identity +// (non-zero exit, empty/undecodable export, or a +// server entry missing url/accessToken). +function jfConfigIdentity(serverId) { + // `jf config export` writes base64(JSON) to stdout for the requested + // server (or the default when no arg). We split the failure space into + // "jf could not be run" (not-installed) vs "jf ran but has no usable + // server" (not-configured) so the caller can give targeted remediation. + const args = ["config", "export"]; + if (serverId) args.push(serverId); + + let result; + try { + result = spawnSync("jf", args, { + encoding: "utf8", + // jf config export reads no stdin and writes a single base64 line + // (no terminal interaction). 2s is plenty even for cold spawns. + timeout: 2000, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + log.debug("jf spawn threw", { error: err?.message ?? String(err) }); + return { identity: null, cause: "jf-not-installed" }; + } + + if (result.error) { + // ENOENT (and any other spawn error) means the binary could not be + // executed — treat as not installed. + log.debug("jf spawn error", { code: result.error.code, message: result.error.message }); + return { identity: null, cause: "jf-not-installed" }; + } + if (result.status !== 0) { + log.debug("jf config export non-zero exit", { + status: result.status, + stderr: (result.stderr || "").trim().slice(0, 200), + }); + return { identity: null, cause: "jf-not-configured" }; + } + + const blob = (result.stdout || "").trim(); + if (!blob) { + log.debug("jf config export returned empty stdout"); + return { identity: null, cause: "jf-not-configured" }; + } + + let parsed; + try { + const json = Buffer.from(blob, "base64").toString("utf8"); + parsed = JSON.parse(json); + } catch (err) { + log.warn("jf config export blob not decodable", { error: err?.message ?? String(err) }); + return { identity: null, cause: "jf-not-configured" }; + } + + const url = normalizeUrl(parsed?.url); + const token = parsed?.accessToken ?? ""; + const resolvedServerId = parsed?.serverId ?? serverId ?? null; + + if (!url || !token) { + log.debug("jf config export missing url or accessToken", { + serverId: resolvedServerId, + hasUrl: Boolean(url), + hasToken: Boolean(token), + }); + return { identity: null, cause: "jf-not-configured" }; + } + + return { + identity: { url, token, serverId: resolvedServerId, source: "jf-config" }, + cause: "ok", + }; +} + +// Public — returns { identity, cause }: +// identity: { url, token, serverId, source } | null +// cause: "ok" | "jf-not-installed" | "jf-not-configured" +export function getPlatformIdentity() { + const hint = undefined; + if (CACHE.has(hint)) return CACHE.get(hint); + + const status = jfConfigIdentity(hint); + if (status.identity) { + log.debug("identity from jf-config", { + serverId: status.identity.serverId, + url: status.identity.url, + }); + } else { + log.debug("no platform identity", { cause: status.cause }); + } + CACHE.set(hint, status); + return status; +} + +/** Test-only — reset module cache between in-process scenarios. */ +export function clearPlatformIdentityCache() { + CACHE.clear(); +} + +// Short label for log lines / status output, e.g. "jf-config:". +export function identityLabel(identity) { + if (!identity) return "none"; + return identity.serverId ? `jf-config:${identity.serverId}` : "jf-config"; +} + +// CLI: +// node lib/jf-identity.mjs — JSON with token redacted +// node lib/jf-identity.mjs --label — single line: "