Skip to content

Commit 7f774a6

Browse files
committed
refactor(plugin): migrate plugin system to use allowlist policy and improve security
- Rename all plugin-related commands from 'plugins' to 'plugin' scope - Implement plugin allowlist policy with required scopes (e.g., @ali) - Add security checks to prevent unauthorized plugin installation - Restrict npm child process environment variables to prevent credential leaks - Enhance plugin validation with scope and allowlist verification - Add proper error handling for malformed plugin manifests - Introduce noAuthSetup validation to ensure rules match plugin commands - Move plugin commands to dedicated subdirectory structure - Add comprehensive plugin policy enforcement functions - Update tests and fixtures to use new @ali scoped plugin naming
1 parent 982006b commit 7f774a6

16 files changed

Lines changed: 256 additions & 117 deletions

File tree

packages/cli/src/commands/catalog.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ import consoleCall from "./console/call.ts";
3535
import usageFree from "./usage/free.ts";
3636
import pipelineRun from "./pipeline/run.ts";
3737
import pipelineValidate from "./pipeline/validate.ts";
38-
import pluginsList from "./plugins/list.ts";
39-
import pluginsInstall from "./plugins/install.ts";
40-
import pluginsLink from "./plugins/link.ts";
41-
import pluginsRemove from "./plugins/remove.ts";
38+
import pluginList from "./plugin/list.ts";
39+
import pluginInstall from "./plugin/install.ts";
40+
import pluginLink from "./plugin/link.ts";
41+
import pluginRemove from "./plugin/remove.ts";
4242

4343
/** Command registry map (no dependency on registry.ts — safe for build-time import). */
4444
export const commands: Record<string, Command> = {
@@ -73,10 +73,10 @@ export const commands: Record<string, Command> = {
7373
"usage free": usageFree,
7474
"pipeline run": pipelineRun,
7575
"pipeline validate": pipelineValidate,
76-
"plugins list": pluginsList,
77-
"plugins install": pluginsInstall,
78-
"plugins link": pluginsLink,
79-
"plugins remove": pluginsRemove,
76+
"plugin list": pluginList,
77+
"plugin install": pluginInstall,
78+
"plugin link": pluginLink,
79+
"plugin remove": pluginRemove,
8080
"config show": configShow,
8181
"config set": configSet,
8282
"config export-schema": configExportSchema,

packages/cli/src/commands/config/export-schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { loadCommandCatalog } from "../../load-commands.ts";
88
/**
99
* Commands that are infrastructure/auth-related and not suitable as Agent tools.
1010
*/
11-
const SKIP_PREFIXES = ["auth ", "config ", "update", "plugins "];
11+
const SKIP_PREFIXES = ["auth ", "config ", "update", "plugin "];
1212

1313
export default defineCommand({
1414
name: "config export-schema",

packages/cli/src/commands/plugins/install.ts renamed to packages/cli/src/commands/plugin/install.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ import { resetCommandCatalogCache } from "../../load-commands.ts";
1010
import { createRegistry, resetRegistry } from "../../registry.ts";
1111

1212
export default defineCommand({
13-
name: "plugins install",
13+
name: "plugin install",
1414
description: "Install a bailian-cli plugin package into ~/.bailian/plugins",
15-
usage: "bl plugins install <package>",
15+
usage: "bl plugin install <package>",
1616
options: [],
1717
examples: [
18-
"bl plugins install @ali/bailian-plugin-agent",
19-
"bl plugins install bailian-plugin-agent",
18+
"bl plugin install @ali/bailian-plugin-agent",
19+
"bl plugin install bailian-plugin-agent",
2020
],
2121
async run(_config: Config, flags: GlobalFlags) {
2222
const positional = flags._positional as string[] | undefined;
@@ -25,7 +25,7 @@ export default defineCommand({
2525
throw new BailianError(
2626
"Missing plugin package name.",
2727
ExitCode.USAGE,
28-
"bl plugins install <package>",
28+
"bl plugin install <package>",
2929
);
3030
}
3131

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import { resetCommandCatalogCache } from "../../load-commands.ts";
1010
import { createRegistry, resetRegistry } from "../../registry.ts";
1111

1212
export default defineCommand({
13-
name: "plugins link",
13+
name: "plugin link",
1414
description: "Link a local bailian-cli plugin directory",
15-
usage: "bl plugins link <path>",
16-
examples: ["bl plugins link ../bailian-plugin-agent"],
15+
usage: "bl plugin link <path>",
16+
examples: ["bl plugin link ../bailian-plugin-agent"],
1717
async run(_config: Config, flags: GlobalFlags) {
1818
const positional = flags._positional as string[] | undefined;
1919
const pluginPath = positional?.[0];
2020
if (!pluginPath) {
21-
throw new BailianError("Missing plugin path.", ExitCode.USAGE, "bl plugins link <path>");
21+
throw new BailianError("Missing plugin path.", ExitCode.USAGE, "bl plugin link <path>");
2222
}
2323

2424
await linkPlugin(pluginPath);
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { defineCommand, type Config, type GlobalFlags } from "bailian-cli-core";
22
import { listPlugins } from "../../plugins/manager.ts";
33

44
export default defineCommand({
5-
name: "plugins list",
5+
name: "plugin list",
66
description: "List installed and discovered bailian-cli plugins",
7-
usage: "bl plugins list",
8-
examples: ["bl plugins list"],
7+
usage: "bl plugin list",
8+
examples: ["bl plugin list"],
99
async run(_config: Config, flags: GlobalFlags) {
1010
const { plugins, errors } = await listPlugins();
1111
const format = flags.output === "json" ? "json" : "text";

packages/cli/src/commands/plugins/remove.ts renamed to packages/cli/src/commands/plugin/remove.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import { resetCommandCatalogCache } from "../../load-commands.ts";
1010
import { createRegistry, resetRegistry } from "../../registry.ts";
1111

1212
export default defineCommand({
13-
name: "plugins remove",
13+
name: "plugin remove",
1414
description: "Remove an installed bailian-cli plugin",
15-
usage: "bl plugins remove <name>",
16-
examples: ["bl plugins remove bailian-plugin-agent"],
15+
usage: "bl plugin remove <name>",
16+
examples: ["bl plugin remove bailian-plugin-agent"],
1717
async run(_config: Config, flags: GlobalFlags) {
1818
const positional = flags._positional as string[] | undefined;
1919
const name = positional?.[0];
2020
if (!name) {
21-
throw new BailianError("Missing plugin name.", ExitCode.USAGE, "bl plugins remove <name>");
21+
throw new BailianError("Missing plugin name.", ExitCode.USAGE, "bl plugin remove <name>");
2222
}
2323

2424
await removePlugin(name);

packages/cli/src/load-commands.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@ import {
1212
} from "./plugins/cache.ts";
1313
import { discoverPlugins } from "./plugins/discover.ts";
1414
import { mergeCommands, type PluginCommandEntry } from "./plugins/priority.ts";
15-
import { extractCommandMeta, lazyCommandFromMeta, scanPluginCommands } from "./plugins/scan.ts";
15+
import {
16+
extractCommandMeta,
17+
filterValidNoAuthSetup,
18+
lazyCommandFromMeta,
19+
scanPluginCommands,
20+
} from "./plugins/scan.ts";
1621
import type { CommandCatalog, DiscoveredPlugin, PluginLoadError } from "./plugins/types.ts";
1722
import { CLI_VERSION } from "./version.ts";
1823

1924
let cachedCatalog: CommandCatalog | undefined;
2025

21-
function collectNoAuthSetup(plugin: DiscoveredPlugin): string[] {
22-
return (plugin.bailianCli.noAuthSetup ?? []).map((p) => p.trim()).filter(Boolean);
23-
}
24-
2526
function expandNoAuthSetup(paths: string[]): string[][] {
2627
return paths.map((p) => p.split(/\s+/).filter(Boolean));
2728
}
@@ -64,22 +65,20 @@ async function loadPluginCommandsWithCache(plugins: DiscoveredPlugin[]): Promise
6465

6566
if (useCache) needsWrite = true;
6667

67-
const { commands: scanned, error } = await scanPluginCommands(plugin);
68-
if (error) pluginErrors.push(error);
69-
70-
const noAuthPaths = collectNoAuthSetup(plugin);
71-
for (const paths of expandNoAuthSetup(noAuthPaths)) {
72-
noAuthSetup.push(paths);
73-
}
68+
const { commands: scanned, error: scanError } = await scanPluginCommands(plugin);
69+
const scanFailed = scanError !== undefined;
70+
if (scanError) pluginErrors.push(scanError);
7471

7572
const metas = [];
73+
let loadFailed = false;
7674
for (const item of scanned) {
7775
const meta = await extractCommandMeta(item);
7876
if (!meta) {
7977
pluginErrors.push({
8078
plugin: plugin.name,
8179
message: `Could not load command module: ${item.commandPath}`,
8280
});
81+
loadFailed = true;
8382
continue;
8483
}
8584
metas.push(meta);
@@ -90,11 +89,22 @@ async function loadPluginCommandsWithCache(plugins: DiscoveredPlugin[]): Promise
9089
});
9190
}
9291

93-
if (useCache) {
92+
const { paths: validNoAuthPaths, errors: noAuthErrors } = filterValidNoAuthSetup(
93+
plugin,
94+
scanned.map((item) => item.commandPath),
95+
);
96+
pluginErrors.push(...noAuthErrors);
97+
for (const paths of expandNoAuthSetup(validNoAuthPaths)) {
98+
noAuthSetup.push(paths);
99+
}
100+
101+
// Do not cache partial or failed scans so the next startup retries loading.
102+
const canCache = !scanFailed && !loadFailed;
103+
if (useCache && canCache) {
94104
nextPlugins[plugin.name] = {
95105
fingerprint,
96106
commands: metas,
97-
noAuthSetup: noAuthPaths,
107+
noAuthSetup: validNoAuthPaths,
98108
};
99109
}
100110
}
@@ -146,7 +156,3 @@ export async function loadCommandCatalog(): Promise<CommandCatalog> {
146156
export function resetCommandCatalogCache(): void {
147157
cachedCatalog = undefined;
148158
}
149-
150-
export function getCachedCommandCatalog(): CommandCatalog | undefined {
151-
return cachedCatalog;
152-
}

packages/cli/src/main.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ const BUILTIN_NO_AUTH_SETUP: string[][] = [
3737
["app", "list"],
3838
["console", "call"],
3939
["usage", "free"],
40-
["plugins", "list"],
41-
["plugins", "install"],
42-
["plugins", "link"],
43-
["plugins", "remove"],
40+
["plugin", "list"],
41+
["plugin", "install"],
42+
["plugin", "link"],
43+
["plugin", "remove"],
4444
];
4545

4646
function matchesNoAuthSetup(commandPath: string[], rules: string[][]): boolean {

packages/cli/src/plugins/discover.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type BailianCliPackageMeta,
99
} from "bailian-cli-core";
1010
import { getNodeModuleRoots } from "./paths.ts";
11+
import { isPluginAllowed } from "./policy.ts";
1112
import type { DiscoveredPlugin, UserPluginRecord, UserPluginsManifest } from "./types.ts";
1213

1314
const PACKAGE_JSON = "package.json";
@@ -40,6 +41,8 @@ function toDiscovered(
4041
if (!name || !pjson.bailianCli) return undefined;
4142
if (!isBailianPluginPackage(name, pjson.bailianCli)) return undefined;
4243
if (!pjson.bailianCli.commands) return undefined;
44+
// white list check
45+
if (!isPluginAllowed(name)) return undefined;
4346
return {
4447
name,
4548
root,

packages/cli/src/plugins/manager.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ import { resetCommandCatalogCache } from "../load-commands.ts";
1414
import { clearCommandsCache } from "./cache.ts";
1515
import { discoverPlugins, readPackageJson } from "./discover.ts";
1616
import {
17+
buildNpmEnv,
1718
diffAddedDepNames,
19+
parsePackageNameFromSpec,
1820
pickInstalledPackageName,
19-
readSandboxPjsonDepNames,
21+
readSandboxDeps,
2022
resolveSandboxPackageRoot,
2123
topLevelDepNamesFromNpmLs,
2224
type NpmLsNode,
2325
} from "./npm-sandbox.ts";
26+
import { assertPluginAllowed } from "./policy.ts";
2427
import type { UserPluginsManifest } from "./types.ts";
2528

2629
const INIT_MANIFEST: UserPluginsManifest = {
@@ -44,7 +47,11 @@ async function readManifest(): Promise<UserPluginsManifest> {
4447
},
4548
};
4649
} catch {
47-
return structuredClone(INIT_MANIFEST);
50+
throw new BailianError(
51+
`Plugin manifest at ${path} is corrupted and could not be parsed.`,
52+
ExitCode.GENERAL,
53+
"Restore from backup or delete the file, then run bl plugin install again.",
54+
);
4855
}
4956
}
5057

@@ -90,7 +97,7 @@ function runNpm(args: string[], cwd: string): void {
9097
const result = spawnSync("npm", npmArgs, {
9198
cwd,
9299
stdio: "inherit",
93-
env: process.env,
100+
env: buildNpmEnv(),
94101
});
95102
if (result.status !== 0) {
96103
throw new BailianError(
@@ -106,7 +113,7 @@ function runNpmJson(args: string[], cwd: string): NpmLsNode {
106113
const result = spawnSync("npm", npmArgs, {
107114
cwd,
108115
encoding: "utf8",
109-
env: process.env,
116+
env: buildNpmEnv(),
110117
});
111118
const stdout = result.stdout?.trim();
112119
if (!stdout) {
@@ -125,7 +132,7 @@ async function listTopLevelSandboxDeps(pluginsDir: string): Promise<string[]> {
125132
const tree = runNpmJson(["ls", "--json", "--depth=0"], pluginsDir);
126133
return topLevelDepNamesFromNpmLs(tree);
127134
} catch {
128-
return readSandboxPjsonDepNames(pluginsDir);
135+
return readSandboxDeps(pluginsDir);
129136
}
130137
}
131138

@@ -183,6 +190,7 @@ export async function listPlugins(): Promise<{
183190
export async function linkPlugin(pluginPath: string): Promise<void> {
184191
const root = resolve(pluginPath);
185192
const { name } = await validatePluginPackageAsync(root);
193+
assertPluginAllowed(name);
186194
const manifest = await readManifest();
187195
const plugins = manifest.bailianCli!.plugins!.filter(
188196
(p) => !(p.type === "link" && p.name === name),
@@ -194,6 +202,16 @@ export async function linkPlugin(pluginPath: string): Promise<void> {
194202

195203
/** 安装 npm 插件到用户沙箱 */
196204
export async function installPlugin(packageSpec: string): Promise<string> {
205+
// 安装前先按 spec 解析包名做准入校验,避免对不被允许的包执行任何 npm 操作
206+
const specName = parsePackageNameFromSpec(packageSpec);
207+
if (!specName) {
208+
throw new BailianError(
209+
`无法从 "${packageSpec}" 识别 npm 包名(不支持 git / tarball / 本地路径安装)。目前仅支持安装官方白名单插件(@ali 作用域),暂不支持用户自定义插件。`,
210+
ExitCode.USAGE,
211+
);
212+
}
213+
assertPluginAllowed(specName);
214+
197215
const pluginsDir = getPluginsDir();
198216
await mkdir(pluginsDir, { recursive: true, mode: 0o700 });
199217

@@ -202,8 +220,10 @@ export async function installPlugin(packageSpec: string): Promise<string> {
202220
}
203221

204222
const beforeDeps = await listTopLevelSandboxDeps(pluginsDir);
205-
206-
runNpm(["install", packageSpec, "--save-exact", "--no-fund", "--no-audit"], pluginsDir);
223+
runNpm(
224+
["install", packageSpec, "--save-exact", "--ignore-scripts", "--no-fund", "--no-audit"],
225+
pluginsDir,
226+
);
207227

208228
const afterDeps = await listTopLevelSandboxDeps(pluginsDir);
209229
const added = diffAddedDepNames(beforeDeps, afterDeps);
@@ -223,6 +243,7 @@ export async function installPlugin(packageSpec: string): Promise<string> {
223243

224244
const root = resolveSandboxPackageRoot(pluginsDir, packageName);
225245
const { name } = await validatePluginPackageAsync(root);
246+
assertPluginAllowed(name);
226247

227248
const manifest = await readManifest();
228249
const plugins = manifest.bailianCli!.plugins!.filter((p) => p.name !== name);
@@ -232,7 +253,7 @@ export async function installPlugin(packageSpec: string): Promise<string> {
232253
return name;
233254
}
234255

235-
/** 从用户沙箱移除插件 */
256+
/** remove plugin from user sandbox */
236257
export async function removePlugin(name: string): Promise<void> {
237258
const manifest = await readManifest();
238259
const record = manifest.bailianCli!.plugins!.find((p) => p.name === name);
@@ -243,9 +264,9 @@ export async function removePlugin(name: string): Promise<void> {
243264
if (record.type === "user") {
244265
const pluginsDir = getPluginsDir();
245266
try {
246-
runNpm(["uninstall", name, "--no-fund", "--no-audit"], pluginsDir);
267+
runNpm(["uninstall", name, "--ignore-scripts", "--no-fund", "--no-audit"], pluginsDir);
247268
} catch {
248-
/* 包可能已被手动删除 */
269+
/* package may have been manually deleted */
249270
}
250271
}
251272

0 commit comments

Comments
 (0)