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
6 changes: 3 additions & 3 deletions .github/scripts/check-vendored-modules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@ if (process.argv.includes("--write")) {
}

const expected = JSON.parse(await readFile(manifestFile, "utf8"));
if (expected.pin !== vendor.pin)
if (JSON.stringify(expected.pin) !== JSON.stringify(vendor.pin))
throw new Error(
`integrity pin mismatch: manifest=${expected.pin} vendor=${vendor.pin}`,
`integrity pin mismatch: manifest=${JSON.stringify(expected.pin)} vendor=${JSON.stringify(vendor.pin)}`,
);
if (JSON.stringify(expected.files) !== JSON.stringify(actual.files))
throw new Error(
"vendored modules differ from sync-modules-integrity.json; re-vendor and update the manifest",
);
console.log(`vendored modules match pin ${vendor.pin}`);
console.log(`vendored modules match pin ${JSON.stringify(vendor.pin)}`);
63 changes: 55 additions & 8 deletions .github/scripts/sync-modules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
// Reads paths from sync-modules-vendor.json.

import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..", "..");
Expand All @@ -28,7 +29,7 @@ async function fileExists(p) {
}
}

async function copyPath(fromDir, toDir, relativePath) {
async function copyPath(fromDir, toDir, relativePath, log = console.log) {
const from = path.join(fromDir, relativePath);
const to = path.join(toDir, relativePath);
if (!(await fileExists(from))) {
Expand All @@ -37,7 +38,44 @@ async function copyPath(fromDir, toDir, 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)}`);
log(` ${relativePath} -> ${path.relative(process.cwd(), to)}`);
}

export async function syncPaths({
fromDir,
toDir,
paths,
keep = [],
log = console.log,
}) {
const stashRoot = await fs.mkdtemp(path.join(tmpdir(), "sync-modules-keep-"));
try {
for (const relativePath of keep) {
const source = path.join(toDir, relativePath);
if (!(await fileExists(source))) {
throw new Error(`kept overlay path missing: ${relativePath}`);
}
const stashed = path.join(stashRoot, relativePath);
await fs.mkdir(path.dirname(stashed), { recursive: true });
await fs.cp(source, stashed, { recursive: true });
}

try {
for (const relativePath of paths) {
await copyPath(fromDir, toDir, relativePath, log);
}
} finally {
for (const relativePath of keep) {
const stashed = path.join(stashRoot, relativePath);
const destination = path.join(toDir, relativePath);
await fs.mkdir(path.dirname(destination), { recursive: true });
await fs.cp(stashed, destination, { recursive: true, force: true });
log(` restored overlay ${relativePath}`);
}
}
} finally {
await fs.rm(stashRoot, { recursive: true, force: true });
}
}

async function main() {
Expand All @@ -60,11 +98,20 @@ async function main() {
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);
}
const pin = vendor.pin ? JSON.stringify(vendor.pin) : "local";
console.log(`--- sync from ${hooksRoot} (pin: ${pin}) ---`);
await syncPaths({
fromDir: hooksRoot,
toDir: destRoot,
paths,
keep: vendor.keep,
});
console.log("done.");
}

await main();
if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
await main();
}
78 changes: 78 additions & 0 deletions .github/scripts/sync-modules.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import {
mkdirSync,
mkdtempSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";

import { syncPaths } from "./sync-modules.mjs";

function write(root, relative, contents) {
const target = path.join(root, relative);
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, contents);
}

test("full sync replaces the base tree and restores kept overlay files", async () => {
const root = mkdtempSync(path.join(tmpdir(), "sync-modules-"));
const upstream = path.join(root, "upstream");
const destination = path.join(root, "destination");
write(upstream, "modules/core/overlay.mjs", "base overlay\n");
write(upstream, "modules/base-only.mjs", "base\n");
write(destination, "modules/core/overlay.mjs", "reviewed overlay\n");
write(destination, "modules/unrelated-new.mjs", "remove me\n");

await syncPaths({
fromDir: upstream,
toDir: destination,
paths: ["modules"],
keep: ["modules/core/overlay.mjs"],
log: () => {},
});

assert.equal(
readFileSync(
path.join(destination, "modules/core/overlay.mjs"),
"utf8",
),
"reviewed overlay\n",
);
assert.equal(
readFileSync(path.join(destination, "modules/base-only.mjs"), "utf8"),
"base\n",
);
assert.throws(() =>
readFileSync(path.join(destination, "modules/unrelated-new.mjs")),
);
});

test("restores kept overlays when a later sync path fails", async () => {
const root = mkdtempSync(path.join(tmpdir(), "sync-modules-failure-"));
const upstream = path.join(root, "upstream");
const destination = path.join(root, "destination");
write(upstream, "modules/core/overlay.mjs", "base overlay\n");
write(destination, "modules/core/overlay.mjs", "reviewed overlay\n");

await assert.rejects(
syncPaths({
fromDir: upstream,
toDir: destination,
paths: ["modules", "missing"],
keep: ["modules/core/overlay.mjs"],
log: () => {},
}),
/path missing in upstream: missing/,
);

assert.equal(
readFileSync(
path.join(destination, "modules/core/overlay.mjs"),
"utf8",
),
"reviewed overlay\n",
);
});
5 changes: 5 additions & 0 deletions .github/workflows/validate-package-resolution-hook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ on:
paths:
- "plugin/hooks/hooks.json"
- "plugin/modules/**"
- "plugin/scripts/**"
- "plugin/.claude-plugin/plugin.json"
- "marketplace.json"
- "scripts/validate-package-resolution-hook.mjs"
- ".github/scripts/sync-modules-vendor.json"
- ".github/scripts/sync-modules.mjs"
- ".github/scripts/sync-modules.test.mjs"
- ".github/scripts/sync-modules-integrity.json"
- ".github/scripts/check-vendored-modules.mjs"
- ".github/workflows/validate-package-resolution-hook.yml"
Expand All @@ -35,5 +37,8 @@ jobs:
- name: Validate hook assembly
run: node scripts/validate-package-resolution-hook.mjs

- name: Test VS Code MCP alignment
run: node --test plugin/scripts/*.test.mjs .github/scripts/sync-modules.test.mjs

- name: Verify vendored module integrity
run: node .github/scripts/check-vendored-modules.mjs
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.idea/
53 changes: 53 additions & 0 deletions 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:
| Component | Feature | Description |
| --------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **MCP** | JFrog MCP server | Remote JFrog MCP server auto-attached to every session via `.mcp.json` at `https://${env:JFROG_PLATFORM_URL}/mcp` (OAuth, no API keys). |
| **Hook** | MCP server alignment | Secures installed plugins' `mcp.json` and `.mcp.json` server commands with JFrog Agent Guard at Copilot SessionStart. |
| **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 Down Expand Up @@ -125,6 +126,58 @@ See the [user guide](docs/package-resolution-user-guide.md) for setup and the
[administrator guide](docs/package-resolution-admin-guide.md) for rollout and
governance configuration.

### MCP server alignment

At Copilot `SessionStart`, the plugin discovers MCP configuration files owned by
installed agent plugins and passes them to Agent Guard's shared
`--rewrite-mcp-json` pipeline. Agent Guard rewrites eligible server commands so
they run through the configured JFrog project policy. The hook is fail-open and
has a 60-second limit; the rewrite pipeline itself is budgeted at 35 seconds.
A cold `npx` fetch of Agent Guard can consume the remaining time, in which case
the hook still returns success and does not rewrite files in that session. A
later session with a warm cache retries. Disabled, unchanged, or failed
rewrites do not block a chat.

Discovery checks both `mcp.json` and `.mcp.json`, in that order, under
`~/.copilot/installed-plugins/{marketplace}/{plugin}`,
`~/.copilot/installed-plugins/_direct/{id}`,
`~/.vscode/agent-plugins/…`, and the VS Code runtime plugin tree
(`~/Library/Application Support/Code/agentPlugins` on macOS,
`%APPDATA%\Code\agentPlugins` on Windows, `$XDG_CONFIG_HOME/Code/agentPlugins`
on Linux), plus this plugin's own configs next to the adaptor.

VS Code loads plugin MCP servers from its own per-install copy under
`Code/agentPlugins`, so both that copy and the install tree it came from are
rewritten. Otherwise the running servers stay unsecured until VS Code re-copies
the plugin.

Default discovery only walks stable VS Code (`Code/agentPlugins`). Only plugin
MCP configurations are considered. The hook never rewrites user
`mcp.json` under `Code/User`, `Code - Insiders/User`, or `VSCodium/User`, or a
workspace `.vscode/mcp.json` (including when the override root is the resolved
path of a `.vscode` symlink).

Environment controls:

- `JF_AGENT_REWRITE_MCP_JSON_DISABLE=1` disables rewriting.
- `JF_AGENT_REWRITE_MCP_JSON_FORCE=1` ignores the current-state marker and
forces a refresh.
- This hook always uses the pinned `@jfrog/agent-guard` version shipped with
the plugin; `JFROG_AGENT_GUARD_VERSION=latest` is not honored here.
- `JF_ALIGN_MCP_JSON_ROOTS` replaces the default Copilot installed-plugins,
`~/.vscode/agent-plugins`, and `Code/agentPlugins` roots (and skips this
plugin's own configs).
Separate roots with colon or comma on macOS/Linux, and semicolon or
comma on Windows. Overrides may point outside the default, but discovery
still rejects workspace `.vscode` and `Code` / `Code - Insiders` /
`VSCodium` `User` configs and symlinks escaping an override root.

If the alignment pipeline changes any discovered configuration bytes, even if
the pipeline later times out or reports a failure, Copilot displays:
`JFrog Agent Guard secured your plugins' MCP servers. Run Developer: Reload Window to reconnect.`
Use the Command Palette command **Developer: Reload Window** before using the
rewritten MCP servers.

### Discover, inspect, and install MCPs

| Ask the agent… | What happens |
Expand Down
3 changes: 3 additions & 0 deletions VENDOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ verifies the committed tree matches the pin (see
[`sync-modules-integrity.json`](.github/scripts/sync-modules-integrity.json)
for the per-file checksums used in that check).

The current bundle uses `jfrog-agent-hooks/v0.11.1` as its base. Only upstream
`modules/` are vendored; upstream tests remain in the source repository.

## Not vendored

[`@jfrog/agent-guard`](https://jfrog.com) is fetched at runtime via `npx` from
Expand Down
6 changes: 6 additions & 0 deletions plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
"command": "node \"${CLAUDE_PLUGIN_ROOT}/modules/copilot-session-start.mjs\" package-resolution",
"timeout": 15,
"statusMessage": "Routing package installs through JFrog Artifactory…"
},
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/vscode-align-mcp-json.mjs\" session-start",
"timeout": 60,
"statusMessage": "Securing plugin MCP servers with JFrog Agent Guard…"
}
]
}
Expand Down
Loading
Loading