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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "jfrog",
"displayName": "JFrog",
"description": "Official JFrog plugin. Connect Claude Code to JFrog to manage, secure, and govern your software supply chain. Give agents the context to build secure, compliant software.",
"version": "0.3.16",
"version": "0.3.17",
"author": {
"name": "JFrog Ltd.",
"email": "devrel@jfrog.com",
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.14.0",
"pin": "jfrog-agent-hooks/v0.15.1",
"paths": [
"modules"
]
Expand Down
37 changes: 30 additions & 7 deletions docs/package-resolution-admin-guide.md

Large diffs are not rendered by default.

47 changes: 42 additions & 5 deletions modules/assets/agents-conf-fingerprints.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,64 @@
{
"id": "v0-placeholders-no-onboardingPrompt",
"sha256": "452b737ede2af5da3ea660cb0a2226d422b5624fa1684bc883279422c0728421",
"note": "Legacy template with example repo keys, before onboardingPrompt"
"note": "v0.8.x template: enabled false + example repo keys, before onboardingPrompt. Turned on; the keys stay for the routing session to check",
"migrate": ["enable"]
},
{
"id": "v0-placeholders-enabled-by-migration",
"sha256": "f4580cf6b09af744cae816b8f47c29105b0d6a67b0f89447cf286cc666be88c7",
"note": "v0.8.x template after the v0.11–v0.14 migration turned it on with the example repo keys; keys a session proves missing or wrong-type are pruned, working ones stay. A partly cleaned file records this id and can be cleaned again",
"exampleRepos": {
"npm": "npm-virtual",
"pypi": "pypi-virtual",
"maven": "maven-virtual",
"gradle": "gradle-virtual",
"go": "go-virtual",
"docker": "docker-virtual",
"helm": "helm-virtual",
"nuget": "nuget-virtual"
},
"migrate": ["prune-repos"]
},
{
"id": "v0-placeholders-enabled-prompt-off",
"sha256": "8dcb38e74a8a973bfec584a20221c858f8a04d0371ce9413a0a1dd01aa8cf148",
"note": "Same, then a global dismiss set onboardingPrompt: off. A partly cleaned file records this id and can be cleaned again",
"exampleRepos": {
"npm": "npm-virtual",
"pypi": "pypi-virtual",
"maven": "maven-virtual",
"gradle": "gradle-virtual",
"go": "go-virtual",
"docker": "docker-virtual",
"helm": "helm-virtual",
"nuget": "nuget-virtual"
},
"migrate": ["prune-repos"]
},
{
"id": "v1-placeholders-onboardingPrompt-auto",
"sha256": "b19251b4671db244a8050885bcbaf5f217f0e4eecfec34c0338264b08fa7c871",
"note": "Legacy template with example repo keys + onboardingPrompt: auto"
"note": "Same as v0-placeholders-enabled-by-migration, then the user set enabled: false",
"migrate": ["clear-repos", "silence"]
},
{
"id": "v2-empty-defaultGlobalRepos",
"sha256": "5a104c83c4cb67f2cb01d71ad0044a438f9125bab868ef76e24bd7be7828b82b",
"note": "Empty defaultGlobalRepos after #84 (no onboardingPrompt)"
"note": "v0.9.x–v0.10.x template: enabled false + empty defaultGlobalRepos, no onboardingPrompt",
"migrate": ["enable"]
},
{
"id": "v3-empty-onboardingPrompt-auto",
"sha256": "8b68d55af89e2dadf4ff0c3ae0784b70051c24d1fb75e3ea6df8ba3044c5cefa",
"note": "Legacy template: enabled false + empty defaultGlobalRepos + onboardingPrompt: auto"
"note": "Current template with enabled: false — the user's explicit off, never re-enabled; silence pins it for every plugin version",
"migrate": ["silence"]
},
{
"id": "v4-enabled-onboardingPrompt-auto",
"sha256": "f0481d915f1f7f2a1e7d88ab23ce7b9430d3e44e41aaabb4d4d13e2b40963ae2",
"note": "Current shipped template: enabled true + empty defaultGlobalRepos + onboardingPrompt: auto"
"note": "Current shipped template: enabled true + empty defaultGlobalRepos + onboardingPrompt: auto",
"migrate": []
}
]
}
23 changes: 20 additions & 3 deletions modules/core/agents-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,11 @@ function normalizeOnboardingPrompt(raw) {
* `packageResolution.defaultGlobalRepos` and `autoSetup` are replaced when
* present in the patch (Consent Enable replaces the map with verified keys only).
* @param {object} patch
* @param {{ onlyIf?: (current: object) => boolean }} [opts] `onlyIf` sees the
* on-disk config under the lock; returning false skips the patch
* @returns {object | null} the written config, or null when `onlyIf` declined
*/
export function mergeAgentsConfigPatch(patch) {
export function mergeAgentsConfigPatch(patch, { onlyIf } = {}) {
return withAgentsConfigLock(() => {
ensureAgentsConfigScaffoldUnlocked();
const configPath = agentsConfigPath();
Expand Down Expand Up @@ -351,6 +354,7 @@ export function mergeAgentsConfigPatch(patch) {
}
current = {};
}
if (onlyIf && !onlyIf(current)) return null;
const next = deepMerge(current, patch);
if (
patch?.packageResolution &&
Expand All @@ -370,6 +374,17 @@ export function mergeAgentsConfigPatch(patch) {
next.packageResolution = next.packageResolution ?? {};
next.packageResolution.autoSetup = patch.packageResolution.autoSetup;
}
// null clears the prune marker. A string value is kept by deepMerge.
if (
patch?.packageResolution &&
Object.prototype.hasOwnProperty.call(
patch.packageResolution,
"scaffoldLineage",
) &&
patch.packageResolution.scaffoldLineage == null
) {
delete next.packageResolution?.scaffoldLineage;
}
mkdirSync(path.dirname(configPath), { recursive: true });
const tmp = `${configPath}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
Expand Down Expand Up @@ -453,12 +468,14 @@ export function normalizeAutoSetup(raw) {
return out;
}

/** Trim string repo keys; drop empty values. */
/** Trim string repo keys; drop empty, unsafe, and non-string values. */
export function normalizeRepoMap(raw) {
if (!raw || typeof raw !== "object") return {};
const out = {};
for (const [type, key] of Object.entries(raw)) {
if (isSafeRepoKey(key?.trim())) out[type] = key.trim();
if (typeof key === "string" && isSafeRepoKey(key.trim())) {
out[type] = key.trim();
}
}
return out;
}
2 changes: 1 addition & 1 deletion modules/core/jf-user-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { spawnJfSync } from "./jf-process.mjs";
// Plugin sync stamps this literal with the release semver (jfrog-sync-modules.py
// stamp). Only `modules/` is vendored, so nothing outside this tree is readable
// at runtime. Unstamped trees (this repo, local dev) report 0.0.0.
const PKG_VERSION = "0.14.0";
const PKG_VERSION = "0.15.1";

/** Hooks release semver stamped at plugin sync ("0.0.0" when unstamped). */
export function hooksPackageVersion() {
Expand Down
139 changes: 120 additions & 19 deletions modules/core/scaffold-fingerprint.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Scaffold fingerprint — detect never-configured agents-conf.json.
//
// Hash the user's config (canonical JSON) against every historically shipped
// template. Untouched scaffold ⇒ eligible for onboarding; any deviation ⇒
// treat as deliberate (admin/MDM/hand-edit) and stay silent when
// onboardingPrompt is absent.
// Hash the user's config (canonical JSON) against every file a shipped plugin
// writes: historical templates plus the states later plugin writes produce.
// Untouched scaffold ⇒ eligible for onboarding; any deviation ⇒ treat as
// deliberate (admin/MDM/hand-edit) and stay silent when onboardingPrompt is
// absent. Each entry's `migrate` lists what SessionStart may change in it.

import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
Expand Down Expand Up @@ -47,25 +48,44 @@ export function sha256Canonical(value) {
return createHash("sha256").update(canonicalizeJson(value)).digest("hex");
}

function loadFingerprintSet() {
const set = new Set();
/** @returns {Map<string, { id: string, sha256: string, migrate: string[] }>} keyed by sha256 */
function loadFingerprints() {
const byHash = new Map();
try {
const raw = JSON.parse(readFileSync(FINGERPRINTS_PATH, "utf8"));
for (const entry of raw?.fingerprints ?? []) {
if (typeof entry?.sha256 === "string" && entry.sha256) {
set.add(entry.sha256);
byHash.set(entry.sha256, {
id: String(entry.id ?? entry.sha256),
sha256: entry.sha256,
migrate: Array.isArray(entry.migrate) ? [...entry.migrate] : [],
exampleRepos: exampleReposOf(entry.exampleRepos),
});
}
}
} catch {
// fall through — still register current template below
}
try {
const tmpl = JSON.parse(readFileSync(TEMPLATE_PATH, "utf8"));
set.add(sha256Canonical(tmpl));
const sha256 = sha256Canonical(
JSON.parse(readFileSync(TEMPLATE_PATH, "utf8")),
);
if (!byHash.has(sha256)) {
byHash.set(sha256, { id: "current-template", sha256, migrate: [] });
}
} catch {
// ignore
}
return set;
return byHash;
}

function readConfigObject(configPath) {
try {
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}

/**
Expand All @@ -74,15 +94,96 @@ function loadFingerprintSet() {
*/
export function isNeverConfiguredScaffold(configPath = agentsConfigPath()) {
if (!existsSync(configPath)) return true;
let parsed;
try {
parsed = JSON.parse(readFileSync(configPath, "utf8"));
} catch {
return false;
const parsed = readConfigObject(configPath);
return parsed !== null && loadFingerprints().has(sha256Canonical(parsed));
}

/**
* Registered fingerprint the file's whole content matches, with the
* migration actions SessionStart may apply to it.
* @param {string} [configPath]
* @returns {{ id: string, sha256: string, migrate: string[], config: object } | null}
* `config` is the parsed file; null when the file is missing, unreadable,
* or not plugin-written
*/
export function matchShippedScaffold(configPath = agentsConfigPath()) {
const parsed = readConfigObject(configPath);
if (!parsed) return null;
const entry = loadFingerprints().get(sha256Canonical(parsed));
return entry ? { ...entry, config: parsed } : null;
}

/**
* A plugin-written file SessionStart may prune.
*
* An exact `prune-repos` fingerprint matches as itself. A file this plugin
* already pruned in part matches only when it still carries that fingerprint
* id, every remaining key is still one of that file's example keys, and
* putting those example keys back (and dropping the id) hashes to the
* registered file. A hand edit, or a partial file this plugin did not mark,
* matches nothing.
* @param {string} [configPath]
* @returns {{ id: string, sha256: string, migrate: string[], config: object } | null}
* `sha256` is the file as it is now, for the compare-and-swap.
*/
export function matchPruneTarget(configPath = agentsConfigPath()) {
const parsed = readConfigObject(configPath);
if (!parsed) return null;
const sha256 = sha256Canonical(parsed);
const exact = loadFingerprints().get(sha256);
if (exact?.migrate.includes("prune-repos")) {
return { ...exact, sha256, config: parsed };
}

const lineage = parsed.packageResolution?.scaffoldLineage;
if (typeof lineage !== "string") return null;
const origin = [...loadFingerprints().values()].find(
(entry) => entry.id === lineage,
);
if (!origin?.migrate.includes("prune-repos") || !origin.exampleRepos) {
return null;
}
const repos = parsed.packageResolution?.defaultGlobalRepos;
if (!repos || typeof repos !== "object" || Array.isArray(repos)) return null;
for (const [type, key] of Object.entries(repos)) {
if (origin.exampleRepos[type] !== key) return null;
}
const restored = JSON.parse(JSON.stringify(parsed));
delete restored.packageResolution.scaffoldLineage;
restored.packageResolution.defaultGlobalRepos = { ...origin.exampleRepos };
if (sha256Canonical(restored) !== origin.sha256) return null;
return { ...origin, sha256, config: parsed };
}

/** @param {unknown} raw */
function exampleReposOf(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const repos = {};
for (const [type, key] of Object.entries(raw)) {
if (typeof key !== "string") return null;
repos[type] = key;
}
return Object.keys(repos).length ? repos : null;
}

let shippedExampleRepoKeys;

/**
* True for a placeholder key shipped in a prune-repos fingerprint
* (`docker-virtual`, `npm-virtual`, …). Some Artifactory servers answer
* HTTP 400, not 404, when that repository does not exist.
* @param {string} repoKey
*/
export function isShippedExampleRepoKey(repoKey) {
if (!shippedExampleRepoKeys) {
shippedExampleRepoKeys = new Set();
for (const entry of loadFingerprints().values()) {
for (const key of Object.values(entry.exampleRepos ?? {})) {
if (typeof key === "string") shippedExampleRepoKeys.add(key);
}
}
}
if (!parsed || typeof parsed !== "object") return false;
const known = loadFingerprintSet();
return known.has(sha256Canonical(parsed));
return shippedExampleRepoKeys.has(repoKey);
}

/** Guard for tests: current shipped template must be registered. */
Expand All @@ -92,5 +193,5 @@ export function currentTemplateFingerprint() {
}

export function registeredFingerprints() {
return [...loadFingerprintSet()];
return [...loadFingerprints().keys()];
}
13 changes: 13 additions & 0 deletions modules/package-resolution/scripts/configure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { createLogger, setLogContext } from "../../core/logger.mjs";
import { isNeverConfiguredScaffold } from "../../core/scaffold-fingerprint.mjs";
import { PACKAGE_TYPES } from "./repo-types.mjs";
import { isPackageResolutionEnabled } from "./feature-flag.mjs";
import { setGlobalRepo } from "./set-repo.mjs";
import { verifyRepoKey } from "./verify-repo.mjs";
import { listDeclinedOnboardingTypes } from "./onboarding-decline-cache.mjs";
import {
Expand All @@ -45,6 +46,7 @@ Commands:
status Print APR + onboarding status (JSON)
verify-repo --type <t> --repo <key> Verify one virtual repo key
enable --repos <json> Write enabled:true + defaultGlobalRepos
set-repo --type <t> --repo <key> Replace one broken global repo key
auto-setup --types <json|true> Set autoSetup policy
dismiss [--type <t>] Per-type decline, or global onboardingPrompt off
onboarding-procedure Print stage-2 Consent Enable instructions
Expand Down Expand Up @@ -176,6 +178,14 @@ async function cmdEnable(opts) {
);
}

async function cmdSetRepo(opts) {
const result = await setGlobalRepo({ type: opts.type, repoKey: opts.repo });
if (!result.ok) fail(result.message);
process.stdout.write(
`${JSON.stringify({ ...result, next: [`node "${path.join(here, "print-policy.mjs")}"`] })}\n`,
);
}

function cmdAutoSetup(opts) {
if (opts.types === undefined)
fail("auto-setup requires --types '<json|true>'");
Expand Down Expand Up @@ -270,6 +280,9 @@ async function main() {
case "enable":
await cmdEnable(opts);
break;
case "set-repo":
await cmdSetRepo(opts);
break;
case "auto-setup":
cmdAutoSetup(opts);
break;
Expand Down
Loading
Loading