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:
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user