feat(FN-3077): enforce plugin AI security scan gate across install flows

- Add core plugin AI security scan module and schema support for scan toggle/state metadata
- Enforce scan checks during CLI and dashboard plugin install flows, with preserved API error status on scan failures
- Expose plugin scan toggle and rescan actions in dashboard/plugin manager with route and UI coverage
- Update plugin authoring and CLI/dashboard docs, plus add changeset for published CLI package

Fusion-Task-Id: FN-3077
This commit is contained in:
Fusion
2026-05-07 02:14:36 -07:00
committed by gsxdsm
parent 415231bd97
commit fce3668a83
29 changed files with 800 additions and 43 deletions

View File

@@ -93,6 +93,8 @@ vi.mock("../../api", () => ({
})),
fetchPluginSetupStatus: vi.fn(() => Promise.resolve({ hasSetup: false })),
installPluginSetup: vi.fn(() => Promise.resolve({ success: true })),
updatePlugin: vi.fn(() => Promise.resolve({})),
rescanPlugin: vi.fn(() => Promise.resolve({})),
browseDirectory: vi.fn(() => Promise.resolve({
currentPath: "/home",
parentPath: "/",
@@ -123,6 +125,8 @@ import {
updatePluginSettings,
fetchPluginSetupStatus,
installPluginSetup,
updatePlugin,
rescanPlugin,
} from "../../api";
const addToast = vi.fn();
@@ -183,6 +187,8 @@ beforeEach(() => {
vi.mocked(updatePluginSettings).mockResolvedValue({ apiKey: "updated-key" });
vi.mocked(fetchPluginSetupStatus).mockResolvedValue({ hasSetup: false });
vi.mocked(installPluginSetup).mockResolvedValue({ success: true });
vi.mocked(updatePlugin).mockResolvedValue({} as never);
vi.mocked(rescanPlugin).mockResolvedValue({} as never);
// EventSource mock setup
const eventSourceInstance = {
@@ -560,6 +566,59 @@ describe("PluginManager", () => {
});
});
it("calls updatePlugin when AI scan toggle is changed", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([{ ...mockPlugins[0], aiScanOnLoad: false } as PluginInstallation]);
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(screen.getByText("Test Plugin A")).toBeTruthy());
await userEvent.click(screen.getAllByTitle("Settings")[0]);
const checkbox = await screen.findByLabelText("Enable AI scan before load/reload");
await userEvent.click(checkbox);
await waitFor(() => {
expect(updatePlugin).toHaveBeenCalledWith("plugin-a", { aiScanOnLoad: true }, undefined);
});
});
it("calls rescanPlugin from security card action", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([{ ...mockPlugins[0], aiScanOnLoad: true } as PluginInstallation]);
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(screen.getByText("Test Plugin A")).toBeTruthy());
await userEvent.click(screen.getAllByTitle("Settings")[0]);
await userEvent.click(await screen.findByRole("button", { name: /Rescan and Reload/i }));
await waitFor(() => {
expect(rescanPlugin).toHaveBeenCalledWith("plugin-a", undefined);
});
});
it("renders persisted security scan verdict and findings", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([{ ...mockPlugins[0], lastSecurityScan: { verdict: "warning", summary: "Suspicious eval usage", findings: [{ category: "exec", severity: "high", file: "src/index.ts", excerpt: "eval(x)", reason: "dynamic execution" }], scannedAt: "2026-05-01T00:00:00.000Z", scannedFiles: ["src/index.ts"] } } as PluginInstallation]);
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(screen.getByText("Test Plugin A")).toBeTruthy());
await userEvent.click(screen.getAllByTitle("Settings")[0]);
expect(await screen.findByText("warning")).toBeTruthy();
expect(screen.getByText("Suspicious eval usage")).toBeTruthy();
await userEvent.click(screen.getByText(/Findings \(1\)/));
expect(screen.getByText(/dynamic execution/)).toBeTruthy();
});
it("renders blocked verdict in security scan card", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([{ ...mockPlugins[0], lastSecurityScan: { verdict: "blocked", summary: "Blocked by scan", findings: [], scannedAt: "2026-05-01T00:00:00.000Z", scannedFiles: [] } } as PluginInstallation]);
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(screen.getByText("Test Plugin A")).toBeTruthy());
await userEvent.click(screen.getAllByTitle("Settings")[0]);
expect(await screen.findByText("blocked")).toBeTruthy();
expect(screen.getByText("Blocked by scan")).toBeTruthy();
});
it("saves plugin settings", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);