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 d68d598a4f
commit 5299745fc4
29 changed files with 800 additions and 43 deletions

View File

@@ -95,6 +95,7 @@ const commandMocks = vi.hoisted(() => ({
runPluginSetup: vi.fn(),
runPluginAvailable: vi.fn(),
runPluginSettings: vi.fn(),
runPluginRescan: vi.fn(),
runPluginCreate: vi.fn(),
runResearchCreate: vi.fn(),
@@ -219,6 +220,7 @@ vi.mock("../commands/plugin.js", () => ({
runPluginSetup: commandMocks.runPluginSetup,
runPluginAvailable: commandMocks.runPluginAvailable,
runPluginSettings: commandMocks.runPluginSettings,
runPluginRescan: commandMocks.runPluginRescan,
}));
vi.mock("../commands/plugin-scaffold.js", () => ({
@@ -423,9 +425,11 @@ describe("bin command routing and fallbacks", () => {
expect(commandMocks.runPluginInstall).toHaveBeenNthCalledWith(1, "fusion-plugin-hermes-runtime", {
projectName: "demo",
aiScan: false,
});
expect(commandMocks.runPluginInstall).toHaveBeenNthCalledWith(2, "fusion-plugin-hermes-runtime", {
projectName: "demo",
aiScan: false,
});
});
@@ -445,7 +449,7 @@ describe("bin command routing and fallbacks", () => {
it("errors when plugin install source is missing", async () => {
await expect(runBin(["plugin", "add"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(
"Usage: fn plugin install <path-or-package> (alias: fn plugin add <path-or-package>)",
"Usage: fn plugin install <path-or-package> [--ai-scan] (alias: fn plugin add <path-or-package>)",
);
});
@@ -453,7 +457,7 @@ describe("bin command routing and fallbacks", () => {
await expect(runBin(["plugin", "oops"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: plugin oops");
expect(logSpy).toHaveBeenCalledWith(
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | setup-status | setup | create",
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create",
);
});

View File

@@ -132,7 +132,7 @@ async function loadCommandHandlers() {
const { runAgentImport } = await import("./commands/agent-import.js");
const { runAgentExport } = await import("./commands/agent-export.js");
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings } = await import("./commands/plugin.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings, runPluginRescan } = await import("./commands/plugin.js");
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
@@ -218,6 +218,7 @@ async function loadCommandHandlers() {
runPluginSetup,
runPluginAvailable,
runPluginSettings,
runPluginRescan,
runPluginCreate,
runSkillsSearch,
runSkillsInstall,
@@ -339,7 +340,7 @@ Usage:
fn backup --restore <file> Restore database from a backup file
fn backup --cleanup Remove old backups exceeding retention limit
fn plugin list | ls List installed plugins
fn plugin install <path-or-package> Install a plugin from path or package
fn plugin install <path-or-package> [--ai-scan] Install a plugin from path or package
fn plugin add <path-or-package> Alias for plugin install
fn plugin uninstall <id> [--force] Uninstall a plugin
fn plugin enable <id> Enable a plugin
@@ -347,6 +348,7 @@ Usage:
fn plugin available List built-in plugin catalog entries
fn plugin settings <id> [key] [value]
Read/update installed plugin settings
fn plugin rescan <id> Rescan and reload a plugin
fn plugin setup-status <id> Check plugin setup binary/runtime status
fn plugin setup <id> [--action install|uninstall]
Install or uninstall plugin setup binaries/runtimes
@@ -567,6 +569,7 @@ async function main() {
runPluginSetup,
runPluginAvailable,
runPluginSettings,
runPluginRescan,
runPluginCreate,
runSkillsSearch,
runSkillsInstall,
@@ -1432,10 +1435,10 @@ async function main() {
case "add": {
const source = args[2];
if (!source) {
console.error("Usage: fn plugin install <path-or-package> (alias: fn plugin add <path-or-package>)");
console.error("Usage: fn plugin install <path-or-package> [--ai-scan] (alias: fn plugin add <path-or-package>)");
process.exit(1);
}
await runPluginInstall(source, { projectName });
await runPluginInstall(source, { projectName, aiScan: args.includes("--ai-scan") });
break;
}
case "uninstall": {
@@ -1467,6 +1470,12 @@ async function main() {
await runPluginSettings(id, args[3], args[4], { projectName });
break;
}
case "rescan": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin rescan <id>"); process.exit(1); }
await runPluginRescan(id, { projectName });
break;
}
case "setup-status": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin setup-status <id>"); process.exit(1); }
@@ -1493,7 +1502,7 @@ async function main() {
}
default:
console.error(`Unknown subcommand: plugin ${sub || ""}`);
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | setup-status | setup | create");
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create");
process.exit(1);
}
break;

View File

@@ -76,7 +76,7 @@ vi.mock("node:fs/promises", () => ({
),
}));
import { runPluginAvailable, runPluginInstall, runPluginSettings } from "../plugin.js";
import { runPluginAvailable, runPluginInstall, runPluginSettings, runPluginRescan } from "../plugin.js";
import { resolveProject } from "../../project-context.js";
describe("plugin commands", () => {
@@ -107,6 +107,25 @@ describe("plugin commands", () => {
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("fusion-plugin-agent-browser"));
});
it("exits non-zero when rescan verdict is blocked", async () => {
const storeInstance = {
init: vi.fn().mockResolvedValue(undefined),
registerPlugin: vi.fn(),
listPlugins: vi.fn(),
getPlugin: vi
.fn()
.mockResolvedValueOnce({ id: "paperclip-runtime", name: "Paperclip Runtime", enabled: true, state: "started" })
.mockResolvedValueOnce({ id: "paperclip-runtime", name: "Paperclip Runtime", enabled: true, state: "error", lastSecurityScan: { verdict: "blocked", summary: "blocked", findings: [], scannedAt: "now", scannedFiles: [] } }),
updatePluginSettings: vi.fn().mockResolvedValue(undefined),
};
mocks.PluginStore.mockImplementationOnce(() => storeInstance as never);
mocks.PluginLoader.mockImplementationOnce(() => ({ loadPlugin: vi.fn(), reloadPlugin: vi.fn().mockResolvedValue(undefined) }) as never);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never);
await expect(runPluginRescan("paperclip-runtime", { projectName: "demo" })).rejects.toThrow("exit:1");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("reads and updates plugin settings", async () => {
const storeInstance = {
init: vi.fn().mockResolvedValue(undefined),

View File

@@ -198,7 +198,7 @@ export async function runPluginList(projectName?: string): Promise<void> {
*/
export async function runPluginInstall(
source: string,
options?: { projectName?: string },
options?: { projectName?: string; aiScan?: boolean },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
@@ -227,6 +227,7 @@ export async function runPluginInstall(
const plugin = await store.registerPlugin({
manifest,
path,
aiScanOnLoad: options?.aiScan ?? false,
});
// Try to load it
@@ -462,6 +463,48 @@ export async function runPluginSettings(
console.log(`✓ Updated ${id}.${key}`);
}
export async function runPluginRescan(
id: string,
options?: { projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
let plugin;
try {
plugin = await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
try {
if (plugin.state === "started" && typeof loader.reloadPlugin === "function") {
await loader.reloadPlugin(id);
} else if (plugin.enabled) {
await loader.loadPlugin(id);
}
} catch (error) {
// keep going to show persisted scan verdict/state
console.error(`Rescan/load failed: ${error instanceof Error ? error.message : String(error)}`);
}
const refreshed = await store.getPlugin(id);
const scan = refreshed.lastSecurityScan;
const verdict = scan?.verdict ?? "unavailable";
const summary = scan?.summary ?? refreshed.error ?? "No scan result available";
const findingCount = scan?.findings?.length ?? 0;
console.log(`${refreshed.name}`);
console.log(`verdict: ${verdict}`);
console.log(`summary: ${summary}`);
console.log(`findings: ${findingCount}`);
if (verdict === "blocked" || verdict === "error" || verdict === "unavailable") {
process.exit(1);
}
}
export async function runPluginSetup(
id: string,
options?: { action?: "install" | "uninstall"; projectName?: string },