improve: warn before non-ClawHub plugin installs

This commit is contained in:
jesse-merhi
2026-07-08 23:53:23 +10:00
parent 757d7a0de3
commit 3f32d108b6
12 changed files with 722 additions and 148 deletions
+12 -1
View File
@@ -33,7 +33,7 @@ Manage Gateway plugins, hook packs, and compatible bundles.
```bash
openclaw plugins list [--enabled] [--verbose] [--json]
openclaw plugins search <query> [--limit <n>] [--json]
openclaw plugins install <path-or-spec> [--link] [--force] [--pin] [--marketplace <source>]
openclaw plugins install <path-or-spec> [--link] [--force] [--pin] [--marketplace <source>] [--acknowledge-non-clawhub-install]
openclaw plugins inspect <id> [--runtime] [--json]
openclaw plugins inspect --all [--runtime] [--json]
openclaw plugins info <id> # alias for inspect
@@ -134,6 +134,7 @@ openclaw plugins install <plugin> --marketplace <name> # marketplace (expli
openclaw plugins install <package> --force # overwrite existing install
openclaw plugins install <package> --pin # pin resolved npm version
openclaw plugins install clawhub:<package> --acknowledge-clawhub-risk
openclaw plugins install npm:<package> --acknowledge-non-clawhub-install
openclaw plugins install <package> --dangerously-force-unsafe-install
```
@@ -145,6 +146,16 @@ sources with guarded environment variables. See
Bare package names install from npm by default during the launch cutover, unless they match a bundled or official plugin id, in which case OpenClaw uses that local/official copy instead of hitting the npm registry. Use `npm:<package>` when you deliberately want an external npm package instead. Use `clawhub:<package>` for ClawHub. Treat plugin installs like running code; prefer pinned versions.
</Warning>
<Warning>
ClawHub installs carry ClawHub package trust metadata. Installs from npm,
`npm-pack:`, git, local paths or archives, and marketplace sources are outside
ClawHub review. Interactive installs warn and ask before continuing.
Noninteractive installs must pass `--acknowledge-non-clawhub-install` after you
review and trust the source. This acknowledgement is separate from
`--acknowledge-clawhub-risk`, which only applies to risky ClawHub release trust
warnings.
</Warning>
`plugins search` queries ClawHub for installable `code-plugin` and
`bundle-plugin` packages (not skills; use `openclaw skills search` for those).
Default `--limit` is 20, capped at 100. It only reads the remote catalog: no
+1
View File
@@ -324,6 +324,7 @@ Plugins run in-process with the Gateway - treat them as trusted code.
- Only install from sources you trust; prefer explicit `plugins.allow` allowlists; review plugin config before enabling; restart the Gateway after plugin changes.
- Installing/updating (`openclaw plugins install <package>`, `openclaw plugins update <id>`) runs untrusted code:
- The install path is the per-plugin directory under the active plugin install root.
- ClawHub installs include ClawHub package trust metadata. npm, `npm-pack:`, git, local path/archive, and marketplace installs are outside ClawHub review; noninteractive installs from those sources require `--acknowledge-non-clawhub-install` after you review and trust the source.
- OpenClaw does not run built-in local dangerous-code blocking during install/update. Use `security.installPolicy` for operator-owned local allow/block decisions and `openclaw security audit --deep` for diagnostic scanning.
- npm and git plugin installs run package-manager dependency convergence only during the explicit install/update flow. Local paths and archives are treated as self-contained packages; OpenClaw copies/references them without running `npm install`.
- Prefer pinned exact versions (`@scope/pkg@1.2.3`) and inspect the unpacked code before enabling.
+15 -12
View File
@@ -51,18 +51,21 @@ bundled, official external, and source-only plugins, see
openclaw plugins install clawhub:<package>
# From npm.
openclaw plugins install npm:<package>
openclaw plugins install npm:<package> --acknowledge-non-clawhub-install
# From git.
openclaw plugins install git:github.com/<owner>/<repo>@<ref>
openclaw plugins install git:github.com/<owner>/<repo>@<ref> --acknowledge-non-clawhub-install
# From a local development checkout.
openclaw plugins install ./my-plugin
openclaw plugins install --link ./my-plugin
openclaw plugins install ./my-plugin --acknowledge-non-clawhub-install
openclaw plugins install --link ./my-plugin --acknowledge-non-clawhub-install
```
Treat plugin installs like running code. Prefer pinned versions for
reproducible production installs.
reproducible production installs. Sources outside ClawHub are not
ClawHub-reviewed; noninteractive installs from npm, git, local paths or
archives, `npm-pack:`, or marketplace sources require
`--acknowledge-non-clawhub-install` after you review and trust the source.
</Step>
@@ -112,13 +115,13 @@ bundled, official external, and source-only plugins, see
### Choose an install source
| Source | Use when | Example |
| ----------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| ClawHub | You want OpenClaw-native discovery, scans, version metadata, and install hints | `openclaw plugins install clawhub:<package>` |
| npm | You need direct npm registry or dist-tag workflows | `openclaw plugins install npm:<package>` |
| git | You need a branch, tag, or commit from a repository | `openclaw plugins install git:github.com/<owner>/<repo>@<ref>` |
| local path | You are developing or testing a plugin on the same machine | `openclaw plugins install --link ./my-plugin` |
| marketplace | You are installing a Claude-compatible marketplace plugin | `openclaw plugins install <plugin> --marketplace <source>` |
| Source | Use when | Example |
| ----------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| ClawHub | You want OpenClaw-native discovery, scans, version metadata, and install hints | `openclaw plugins install clawhub:<package>` |
| npm | You need direct npm registry or dist-tag workflows | `openclaw plugins install npm:<package> --acknowledge-non-clawhub-install` |
| git | You need a branch, tag, or commit from a repository | `openclaw plugins install git:github.com/<owner>/<repo>@<ref> --acknowledge-non-clawhub-install` |
| local path | You are developing or testing a plugin on the same machine | `openclaw plugins install --link ./my-plugin --acknowledge-non-clawhub-install` |
| marketplace | You are installing a Claude-compatible marketplace plugin | `openclaw plugins install <plugin> --marketplace <source> --acknowledge-non-clawhub-install` |
Bare package specs have special compatibility behavior: a bare name that
matches a bundled plugin id uses that bundled source; a bare name that matches
+39 -2
View File
@@ -1,9 +1,21 @@
// Hooks CLI tests cover hook command registration and output behavior.
import { describe, expect, it } from "vitest";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HookStatusReport } from "../hooks/hooks-status.js";
import { formatHookInfo, formatHooksCheck, formatHooksList } from "./hooks-cli.js";
import {
formatHookInfo,
formatHooksCheck,
formatHooksList,
registerHooksCli,
} from "./hooks-cli.js";
import { createEmptyInstallChecks } from "./requirements-test-fixtures.js";
const runPluginInstallCommandMock = vi.hoisted(() => vi.fn());
vi.mock("./plugins-install-command.js", () => ({
runPluginInstallCommand: runPluginInstallCommandMock,
}));
const report: HookStatusReport = {
workspaceDir: "/tmp/workspace",
managedHooksDir: "/tmp/hooks",
@@ -32,6 +44,10 @@ const report: HookStatusReport = {
],
};
beforeEach(() => {
runPluginInstallCommandMock.mockReset();
});
function createPluginManagedHookReport(): HookStatusReport {
return {
workspaceDir: "/tmp/workspace",
@@ -106,4 +122,25 @@ describe("hooks cli formatting", () => {
expect(output).toContain("voice-call");
expect(output).toContain("Managed by plugin");
});
it("forwards non-ClawHub install acknowledgement through deprecated install alias", async () => {
runPluginInstallCommandMock.mockResolvedValueOnce(undefined);
const program = new Command().exitOverride();
registerHooksCli(program);
await program.parseAsync(
["hooks", "install", "npm:demo-hooks", "--acknowledge-non-clawhub-install"],
{
from: "user",
},
);
expect(runPluginInstallCommandMock).toHaveBeenCalledWith({
raw: "npm:demo-hooks",
opts: expect.objectContaining({
acknowledgeNonClawHubInstall: true,
}),
invalidateRuntimeCache: false,
});
});
});
+25 -2
View File
@@ -47,6 +47,17 @@ type HooksUpdateOptions = {
dryRun?: boolean;
};
type HooksInstallOptions = {
acknowledgeNonClawHubInstall?: boolean;
acknowledgeNonClawhubInstall?: boolean;
link?: boolean;
pin?: boolean;
};
function normalizeHooksNonClawHubInstallOption(opts: HooksInstallOptions): boolean {
return opts.acknowledgeNonClawhubInstall === true || opts.acknowledgeNonClawHubInstall === true;
}
function mergeHookEntries(pluginEntries: HookEntry[], workspaceEntries: HookEntry[]): HookEntry[] {
return resolveHookEntries([...pluginEntries, ...workspaceEntries]);
}
@@ -565,11 +576,23 @@ export function registerHooksCli(program: Command): void {
.argument("<path-or-spec>", "Path to a hook pack or npm package spec")
.option("-l, --link", "Link a local path instead of copying", false)
.option("--pin", "Record npm installs as exact resolved <name>@<version>", false)
.action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => {
.option(
"--acknowledge-non-clawhub-install",
"Acknowledge non-ClawHub hook pack install provenance without prompting",
false,
)
.action(async (raw: string, opts: HooksInstallOptions) => {
defaultRuntime.log(
theme.warn("`openclaw hooks install` is deprecated; use `openclaw plugins install`."),
);
await runPluginInstallCommand({ raw, opts, invalidateRuntimeCache: false });
await runPluginInstallCommand({
raw,
opts: {
...opts,
acknowledgeNonClawHubInstall: normalizeHooksNonClawHubInstallOption(opts),
},
invalidateRuntimeCache: false,
});
});
hooks
@@ -0,0 +1,71 @@
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import type { RuntimeEnv } from "../runtime.js";
import { promptYesNo } from "./prompt.js";
export const NON_CLAWHUB_INSTALL_ACK_FLAG = "--acknowledge-non-clawhub-install";
export type NonClawHubInstallSourceClass =
| "git"
| "local-archive"
| "local-path"
| "marketplace"
| "npm"
| "npm-pack";
export type NonClawHubInstallAcknowledgementOptions = {
acknowledgeNonClawHubInstall?: boolean;
};
const sourceClassLabels: Record<NonClawHubInstallSourceClass, string> = {
git: "Git repository",
"local-archive": "local archive",
"local-path": "local path",
marketplace: "marketplace source",
npm: "npm registry",
"npm-pack": "local npm-pack archive",
};
function canPromptForNonClawHubInstall(): boolean {
return process.stdin.isTTY && process.stdout.isTTY;
}
function formatSourceClass(sourceClass: NonClawHubInstallSourceClass): string {
return sourceClassLabels[sourceClass];
}
export function formatNonClawHubInstallWarning(params: {
sourceClass: NonClawHubInstallSourceClass;
spec: string;
}): string {
const sourceLabel = formatSourceClass(params.sourceClass);
const spec = sanitizeTerminalText(params.spec);
return [
`WARNING - Installing plugin from ${sourceLabel}: ${spec}`,
"This source is outside ClawHub review and trust metadata. Only continue if you trust the publisher, package contents, and install source.",
].join("\n");
}
export async function confirmNonClawHubInstall(params: {
acknowledged?: boolean;
runtime: RuntimeEnv;
sourceClass: NonClawHubInstallSourceClass;
spec: string;
}): Promise<boolean> {
const warning = formatNonClawHubInstallWarning({
sourceClass: params.sourceClass,
spec: params.spec,
});
if (params.acknowledged) {
params.runtime.log(theme.warn(warning));
return true;
}
if (canPromptForNonClawHubInstall()) {
params.runtime.log(theme.warn(warning));
return await promptYesNo("Install this non-ClawHub plugin source?");
}
params.runtime.error(
`${warning}\nInstall cancelled; rerun with ${NON_CLAWHUB_INSTALL_ACK_FLAG} after reviewing the source.`,
);
return false;
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -27,6 +27,7 @@ import type {
type PluginInstallActionOptions = {
acknowledgeClawHubRisk?: boolean;
acknowledgeNonClawHubInstall?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
force?: boolean;
link?: boolean;
+14 -3
View File
@@ -14,14 +14,19 @@ type PluginUpdateOptions = {
dangerouslyForceUnsafeInstall?: boolean;
};
type CommanderClawHubRiskOptions = Record<string, unknown> & {
type CommanderInstallRiskOptions = Record<string, unknown> & {
acknowledgeClawhubRisk?: boolean;
acknowledgeNonClawhubInstall?: boolean;
};
function normalizeCommanderClawHubRiskOption(opts: CommanderClawHubRiskOptions): boolean {
function normalizeCommanderClawHubRiskOption(opts: CommanderInstallRiskOptions): boolean {
return opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true;
}
function normalizeCommanderNonClawHubInstallOption(opts: CommanderInstallRiskOptions): boolean {
return opts.acknowledgeNonClawhubInstall === true || opts.acknowledgeNonClawHubInstall === true;
}
export type PluginMarketplaceListOptions = {
json?: boolean;
};
@@ -184,6 +189,11 @@ export function registerPluginsCli(program: Command) {
"Acknowledge ClawHub release trust warnings without prompting",
false,
)
.option(
"--acknowledge-non-clawhub-install",
"Acknowledge non-ClawHub plugin install provenance without prompting",
false,
)
.option(
"--marketplace <source>",
"Install a Claude marketplace plugin from a local repo/path or git/GitHub source",
@@ -191,7 +201,7 @@ export function registerPluginsCli(program: Command) {
.action(
async (
raw: string,
opts: CommanderClawHubRiskOptions & {
opts: CommanderInstallRiskOptions & {
dangerouslyForceUnsafeInstall?: boolean;
force?: boolean;
link?: boolean;
@@ -203,6 +213,7 @@ export function registerPluginsCli(program: Command) {
await runPluginsInstallAction(raw, {
...opts,
acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts),
acknowledgeNonClawHubInstall: normalizeCommanderNonClawHubInstallOption(opts),
});
},
);
+41
View File
@@ -46,6 +46,10 @@ import { resolveUserPath, shortenHomePath } from "../utils.js";
import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js";
import { formatCliCommand } from "./command-format.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import {
confirmNonClawHubInstall,
type NonClawHubInstallSourceClass,
} from "./non-clawhub-install-acknowledgement.js";
import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js";
import {
resolvePluginInstallInvalidConfigPolicy,
@@ -864,6 +868,7 @@ export async function runPluginInstallCommand(params: {
raw: string;
opts: InstallSafetyOverrides & {
acknowledgeClawHubRisk?: boolean;
acknowledgeNonClawHubInstall?: boolean;
force?: boolean;
link?: boolean;
pin?: boolean;
@@ -993,8 +998,21 @@ export async function runPluginInstallCommand(params: {
const installMode = resolveInstallMode(opts.force);
const safetyOverrides = resolveInstallSafetyOverrides({ ...opts, config: cfg });
const extensionsDir = resolveDefaultPluginExtensionsDir();
const acknowledgeNonClawHubSource = async (
sourceClass: NonClawHubInstallSourceClass,
spec: string,
): Promise<boolean> =>
await confirmNonClawHubInstall({
acknowledged: opts.acknowledgeNonClawHubInstall,
runtime,
sourceClass,
spec,
});
if (opts.marketplace) {
if (!(await acknowledgeNonClawHubSource("marketplace", `${raw} from ${opts.marketplace}`))) {
return runtime.exit(1);
}
const result = await installPluginFromMarketplace({
...safetyOverrides,
marketplace: opts.marketplace,
@@ -1028,6 +1046,14 @@ export async function runPluginInstallCommand(params: {
}
if (fs.existsSync(resolved)) {
if (
!(await acknowledgeNonClawHubSource(
resolveArchiveKind(resolved) ? "local-archive" : "local-path",
resolved,
))
) {
return runtime.exit(1);
}
const fullyBlockedReason = resolveFullyBlockedConfigMutationReason(snapshot);
if (fullyBlockedReason) {
runtime.error(fullyBlockedReason);
@@ -1182,6 +1208,9 @@ export async function runPluginInstallCommand(params: {
);
return runtime.exit(1);
}
if (!(await acknowledgeNonClawHubSource("npm", npmPrefixSpec))) {
return runtime.exit(1);
}
const officialNpmTrust = resolveOfficialExternalNpmPackageTrust({
npmSpec: npmPrefixSpec,
findOfficialExternalPackage: findTrustedCatalogPackageInstall,
@@ -1219,6 +1248,9 @@ export async function runPluginInstallCommand(params: {
);
return runtime.exit(1);
}
if (!(await acknowledgeNonClawHubSource("npm-pack", raw))) {
return runtime.exit(1);
}
const npmPackResult = await tryInstallPluginFromNpmPackArchive({
snapshot,
installMode,
@@ -1235,6 +1267,9 @@ export async function runPluginInstallCommand(params: {
}
if (gitSpec) {
if (!(await acknowledgeNonClawHubSource("git", raw))) {
return runtime.exit(1);
}
const gitResult = await tryInstallPluginFromGitSpec({
snapshot,
installMode,
@@ -1290,6 +1325,9 @@ export async function runPluginInstallCommand(params: {
}
if (officialExternalPlan) {
if (!(await acknowledgeNonClawHubSource("npm", officialExternalPlan.npmSpec))) {
return runtime.exit(1);
}
const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({
snapshot,
installMode,
@@ -1347,6 +1385,9 @@ export async function runPluginInstallCommand(params: {
npmSpec: raw,
findOfficialExternalPackage: findTrustedCatalogPackageInstall,
});
if (!(await acknowledgeNonClawHubSource("npm", raw))) {
return runtime.exit(1);
}
const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({
snapshot,
installMode,
+26
View File
@@ -582,6 +582,32 @@ describe("parseCrestodianOperation", () => {
);
});
it("passes approval as non-ClawHub acknowledgement for npm plugin installs", async () => {
const tempDir = opTempDirs.make("crestodian-plugin-install-ack-");
setTestEnvValue("OPENCLAW_STATE_DIR", tempDir);
const { runtime } = createCrestodianTestRuntime();
const runPluginInstall = vi.fn(async (spec: string, pluginRuntime: RuntimeEnv) => {
pluginRuntime.log(`installed ${spec}`);
});
const result = await executeCrestodianOperation(
{ kind: "plugin-install", spec: "npm:@openclaw/demo" },
runtime,
{
approved: true,
deps: { runPluginInstall },
},
);
expect(result.applied).toBe(true);
const installCall = requireFirstMockCall(runPluginInstall, "runPluginInstall");
expect(installCall[0]).toBe("npm:@openclaw/demo");
expectRuntimeArg(installCall[1]);
expect(installCall[2]).toEqual({
acknowledgeNonClawHubInstall: true,
});
});
it("uninstalls plugins only after approval and audits the write", async () => {
const tempDir = opTempDirs.make("crestodian-plugin-uninstall-");
setTestEnvValue("OPENCLAW_STATE_DIR", tempDir);
+24 -4
View File
@@ -81,6 +81,10 @@ export type CrestodianOperationResult = {
followUp?: Extract<CrestodianOperation, { kind: "model-setup" }>;
};
export type CrestodianPluginInstallOptions = {
acknowledgeNonClawHubInstall?: boolean;
};
/** Injectable command dependencies used by tests and alternate runners. */
export type CrestodianCommandDeps = {
formatOverview?: CrestodianOverviewFormatter;
@@ -105,7 +109,11 @@ export type CrestodianCommandDeps = {
runGatewayRestart?: () => Promise<void>;
runGatewayStart?: () => Promise<void>;
runGatewayStop?: () => Promise<void>;
runPluginInstall?: (spec: string, runtime: RuntimeEnv) => Promise<void>;
runPluginInstall?: (
spec: string,
runtime: RuntimeEnv,
options?: CrestodianPluginInstallOptions,
) => Promise<void>;
runPluginUninstall?: (pluginId: string, runtime: RuntimeEnv) => Promise<void>;
runPluginsList?: (runtime: RuntimeEnv) => Promise<void>;
runPluginsSearch?: (query: string, runtime: RuntimeEnv) => Promise<void>;
@@ -845,11 +853,23 @@ async function executePluginInstall(
run: async (ctx) => {
const runPluginInstall =
ctx.deps?.runPluginInstall ??
(async (spec: string, pluginRuntime: RuntimeEnv) => {
(async (
spec: string,
pluginRuntime: RuntimeEnv,
installOptions?: CrestodianPluginInstallOptions,
) => {
const { runPluginInstallCommand } = await import("../cli/plugins-install-command.js");
await runPluginInstallCommand({ raw: spec, opts: {}, runtime: pluginRuntime });
await runPluginInstallCommand({
raw: spec,
opts: {
acknowledgeNonClawHubInstall: installOptions?.acknowledgeNonClawHubInstall === true,
},
runtime: pluginRuntime,
});
});
await runPluginInstall(operation.spec, createNoExitRuntime(ctx.runtime));
await runPluginInstall(operation.spec, createNoExitRuntime(ctx.runtime), {
acknowledgeNonClawHubInstall: opts.approved === true,
});
return { summary: `Installed plugin ${operation.spec}`, details: { spec: operation.spec } };
},
});