Plugins: allow unsafe-force override on update

This commit is contained in:
huntharo
2026-04-03 00:20:16 -04:00
committed by Peter Steinberger
parent 824ff335c6
commit c4f40c3f7d
10 changed files with 193 additions and 12 deletions

View File

@@ -168,6 +168,75 @@ openclaw hooks enable <hook-name>
Extracts the last 15 user/assistant messages, generates a descriptive filename slug via LLM, and saves to `<workspace>/memory/YYYY-MM-DD-slug.md`. Requires `workspace.dir` to be configured.
- **`tool_result_persist`**: transform tool results before they are written to the session transcript. Must be synchronous; return the updated tool result payload or `undefined` to keep it as-is. See [Agent Loop](/concepts/agent-loop).
### Plugin Hook Events
#### before_tool_call
Runs before each tool call. Plugins can modify parameters, block the call, or request user approval.
Return fields:
- **`params`**: Override tool parameters (merged with original params)
- **`block`**: Set to `true` to block the tool call
- **`blockReason`**: Reason shown to the agent when blocked
- **`requireApproval`**: Pause execution and wait for user approval via channels
The `requireApproval` field triggers native platform approval (Telegram buttons, Discord components, `/approve` command) instead of relying on the agent to cooperate:
```typescript
{
requireApproval: {
title: "Sensitive operation",
description: "This tool call modifies production data",
severity: "warning", // "info" | "warning" | "critical"
timeoutMs: 120000, // default: 120s
timeoutBehavior: "deny", // "allow" | "deny" (default)
onResolution: async (decision) => {
// Called after the user resolves: "allow-once", "allow-always", "deny", "timeout", or "cancelled"
},
}
}
```
The `onResolution` callback is invoked with the final decision string after the approval resolves, times out, or is cancelled. It runs in-process within the plugin (not sent to the gateway). Use it to persist decisions, update caches, or perform cleanup.
The `pluginId` field is stamped automatically by the hook runner from the plugin registration. When multiple plugins return `requireApproval`, the first one (highest priority) wins.
`block` takes precedence over `requireApproval`: if the merged hook result has both `block: true` and a `requireApproval` field, the tool call is blocked immediately without triggering the approval flow. This ensures a higher-priority plugin's block cannot be overridden by a lower-priority plugin's approval request.
If the gateway is unavailable or does not support plugin approvals, the tool call falls back to a soft block using the `description` as the block reason.
#### before_install
Runs after the built-in install security scan and before installation continues. OpenClaw fires this hook for interactive skill installs as well as plugin bundle, package, and single-file installs.
Default behavior differs by target type:
- Plugin install/update flows fail closed on built-in scan `critical` findings and scan errors unless the operator explicitly uses `openclaw plugins install --dangerously-force-unsafe-install` or `openclaw plugins update --dangerously-force-unsafe-install`.
- Skill installs still surface built-in scan findings and scan errors as warnings and continue by default.
Return fields:
- **`findings`**: Additional scan findings to surface as warnings
- **`block`**: Set to `true` to block the install
- **`blockReason`**: Human-readable reason shown when blocked
Event fields:
- **`targetType`**: Install target category (`skill` or `plugin`)
- **`targetName`**: Human-readable skill name or plugin id for the install target
- **`sourcePath`**: Absolute path to the install target content being scanned
- **`sourcePathKind`**: Whether the scanned content is a `file` or `directory`
- **`origin`**: Normalized install origin when available (for example `openclaw-bundled`, `openclaw-workspace`, `plugin-bundle`, `plugin-package`, or `plugin-file`)
- **`request`**: Provenance for the install request, including `kind`, `mode`, and optional `requestedSpecifier`
- **`builtinScan`**: Structured result of the built-in scanner, including `status`, summary counts, findings, and optional `error`
- **`skill`**: Skill install metadata when `targetType` is `skill`, including `installId` and the selected `installSpec`
- **`plugin`**: Plugin install metadata when `targetType` is `plugin`, including the canonical `pluginId`, normalized `contentType`, optional `packageName` / `manifestId` / `version`, and `extensions`
Example event (plugin package install):
### bootstrap-extra-files config
```json

View File

@@ -64,7 +64,7 @@ when the built-in scanner reports `critical` findings, but it does **not**
bypass plugin `before_install` hook policy blocks and does **not** bypass scan
failures.
This CLI flag applies to `openclaw plugins install`. Gateway-backed skill
This CLI flag applies to plugin install/update flows. Gateway-backed skill
dependency installs use the matching `dangerouslyForceUnsafeInstall` request
override, while `openclaw skills install` remains a separate ClawHub skill
download/install flow.
@@ -185,6 +185,7 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins update <id-or-npm-spec> --dry-run
openclaw plugins update @openclaw/voice-call@beta
openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install
```
Updates apply to tracked installs in `plugins.installs` and tracked hook-pack
@@ -203,6 +204,12 @@ When a stored integrity hash exists and the fetched artifact hash changes,
OpenClaw prints a warning and asks for confirmation before proceeding. Use
global `--yes` to bypass prompts in CI/non-interactive runs.
`--dangerously-force-unsafe-install` is also available on `plugins update` as a
break-glass override for built-in dangerous-code scan false positives during
plugin updates. It still does not bypass plugin `before_install` policy blocks
or scan-failure blocking, and it only applies to plugin updates, not hook-pack
updates.
### Inspect
```bash

View File

@@ -477,12 +477,12 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code:
- Prefer explicit `plugins.allow` allowlists.
- Review plugin config before enabling.
- Restart the Gateway after plugin changes.
- If you install plugins (`openclaw plugins install <package>`), treat it like running untrusted code:
- If you install or update plugins (`openclaw plugins install <package>`, `openclaw plugins update <id>`), treat it like running untrusted code:
- The install path is the per-plugin directory under the active plugin install root.
- OpenClaw runs a built-in dangerous-code scan before install. `critical` findings block by default.
- OpenClaw runs a built-in dangerous-code scan before install/update. `critical` findings block by default.
- OpenClaw uses `npm pack` and then runs `npm install --omit=dev` in that directory (npm lifecycle scripts can execute code during install).
- Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling.
- `--dangerously-force-unsafe-install` is break-glass only for built-in scan false positives. It does not bypass plugin `before_install` hook policy blocks and does not bypass scan failures.
- `--dangerously-force-unsafe-install` is break-glass only for built-in scan false positives on plugin install/update flows. It does not bypass plugin `before_install` hook policy blocks and does not bypass scan failures.
- Gateway-backed skill dependency installs follow the same dangerous/suspicious split: built-in `critical` findings block unless the caller explicitly sets `dangerouslyForceUnsafeInstall`, while suspicious findings still warn only. `openclaw skills install` remains the separate ClawHub skill download/install flow.
Details: [Plugins](/tools/plugin)

View File

@@ -213,6 +213,7 @@ openclaw plugins install <path> # install from local path
openclaw plugins install -l <path> # link (no copy) for dev
openclaw plugins install <spec> --dangerously-force-unsafe-install
openclaw plugins update <id> # update one plugin
openclaw plugins update <id> --dangerously-force-unsafe-install
openclaw plugins update --all # update all
openclaw plugins enable <id>
@@ -220,14 +221,14 @@ openclaw plugins disable <id>
```
`--dangerously-force-unsafe-install` is a break-glass override for false
positives from the built-in dangerous-code scanner. It allows installs to
continue past built-in `critical` findings, but it still does not bypass plugin
`before_install` policy blocks or scan-failure blocking.
positives from the built-in dangerous-code scanner. It allows plugin installs
and plugin updates to continue past built-in `critical` findings, but it still
does not bypass plugin `before_install` policy blocks or scan-failure blocking.
This CLI flag applies to plugin installs only. Gateway-backed skill dependency
installs use the matching `dangerouslyForceUnsafeInstall` request override
instead, while `openclaw skills install` remains the separate ClawHub skill
download/install flow.
This CLI flag applies to plugin install/update flows only. Gateway-backed skill
dependency installs use the matching `dangerouslyForceUnsafeInstall` request
override instead, while `openclaw skills install` remains the separate ClawHub
skill download/install flow.
See [`openclaw plugins` CLI reference](/cli/plugins) for full details.

View File

@@ -138,6 +138,8 @@ vi.mock("../infra/clawhub.js", () => ({
const { registerPluginsCli } = await import("./plugins-cli.js");
export { registerPluginsCli };
export function runPluginsCommand(argv: string[]) {
const program = new Command();
program.exitOverride();

View File

@@ -53,6 +53,7 @@ export type PluginInspectOptions = {
export type PluginUpdateOptions = {
all?: boolean;
dryRun?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
};
export type PluginMarketplaceListOptions = {
@@ -799,6 +800,11 @@ export function registerPluginsCli(program: Command) {
.argument("[id]", "Plugin or hook-pack id (omit with --all)")
.option("--all", "Update all tracked plugins and hook packs", false)
.option("--dry-run", "Show what would change without writing", false)
.option(
"--dangerously-force-unsafe-install",
"Bypass built-in dangerous-code update blocking for plugins (plugin hooks may still block)",
false,
)
.action(async (id: string | undefined, opts: PluginUpdateOptions) => {
await runPluginUpdateCommand({ id, opts });
});

View File

@@ -1,7 +1,9 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
loadConfig,
registerPluginsCli,
resetPluginsCliTestState,
runPluginsCommand,
runtimeErrors,
@@ -11,11 +13,43 @@ import {
writeConfigFile,
} from "./plugins-cli-test-helpers.js";
function createTrackedPluginConfig(params: {
pluginId: string;
spec: string;
resolvedName?: string;
}): OpenClawConfig {
return {
plugins: {
installs: {
[params.pluginId]: {
source: "npm",
spec: params.spec,
installPath: `/tmp/${params.pluginId}`,
...(params.resolvedName ? { resolvedName: params.resolvedName } : {}),
},
},
},
} as OpenClawConfig;
}
describe("plugins cli update", () => {
beforeEach(() => {
resetPluginsCliTestState();
});
it("shows the dangerous unsafe install override in update help", () => {
const program = new Command();
registerPluginsCli(program);
const pluginsCommand = program.commands.find((command) => command.name() === "plugins");
const updateCommand = pluginsCommand?.commands.find((command) => command.name() === "update");
const helpText = updateCommand?.helpInformation() ?? "";
expect(helpText).toContain("--dangerously-force-unsafe-install");
expect(helpText).toContain("Bypass built-in dangerous-code update");
expect(helpText).toContain("blocking for plugins");
});
it("updates tracked hook packs through plugins update", async () => {
const cfg = {
hooks: {
@@ -203,6 +237,34 @@ describe("plugins cli update", () => {
);
});
it("passes dangerous force unsafe install to plugin updates", async () => {
const config = createTrackedPluginConfig({
pluginId: "openclaw-codex-app-server",
spec: "openclaw-codex-app-server@beta",
});
loadConfig.mockReturnValue(config);
updateNpmInstalledPlugins.mockResolvedValue({
config,
changed: false,
outcomes: [],
});
await runPluginsCommand([
"plugins",
"update",
"openclaw-codex-app-server",
"--dangerously-force-unsafe-install",
]);
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config,
pluginIds: ["openclaw-codex-app-server"],
dangerouslyForceUnsafeInstall: true,
}),
);
});
it("keeps using the recorded npm tag when update is invoked by plugin id", async () => {
const config = {
plugins: {

View File

@@ -89,7 +89,7 @@ function resolveHookPackUpdateSelection(params: {
export async function runPluginUpdateCommand(params: {
id?: string;
opts: { all?: boolean; dryRun?: boolean };
opts: { all?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean };
}) {
const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null);
const cfg = loadConfig();
@@ -122,6 +122,7 @@ export async function runPluginUpdateCommand(params: {
pluginIds: pluginSelection.pluginIds,
specOverrides: pluginSelection.specOverrides,
dryRun: params.opts.dryRun,
dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall,
logger,
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;

View File

@@ -605,6 +605,32 @@ describe("updateNpmInstalledPlugins", () => {
marketplacePlugin: "claude-bundle",
});
});
it("forwards dangerous force unsafe install to plugin update installers", async () => {
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "openclaw-codex-app-server",
targetDir: "/tmp/openclaw-codex-app-server",
version: "0.2.0-beta.4",
}),
);
await updateNpmInstalledPlugins({
config: createCodexAppServerInstallConfig({
spec: "openclaw-codex-app-server@beta",
}),
pluginIds: ["openclaw-codex-app-server"],
dangerouslyForceUnsafeInstall: true,
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "openclaw-codex-app-server@beta",
dangerouslyForceUnsafeInstall: true,
expectedPluginId: "openclaw-codex-app-server",
}),
);
});
});
describe("syncPluginsForUpdateChannel", () => {

View File

@@ -258,6 +258,7 @@ export async function updateNpmInstalledPlugins(params: {
pluginIds?: string[];
skipIds?: Set<string>;
dryRun?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
specOverrides?: Record<string, string>;
onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
}): Promise<PluginUpdateSummary> {
@@ -359,6 +360,7 @@ export async function updateNpmInstalledPlugins(params: {
spec: effectiveSpec!,
mode: "update",
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
expectedIntegrity,
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
@@ -375,6 +377,7 @@ export async function updateNpmInstalledPlugins(params: {
baseUrl: record.clawhubUrl,
mode: "update",
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
})
@@ -383,6 +386,7 @@ export async function updateNpmInstalledPlugins(params: {
plugin: record.marketplacePlugin!,
mode: "update",
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
});
@@ -456,6 +460,7 @@ export async function updateNpmInstalledPlugins(params: {
? await installPluginFromNpmSpec({
spec: effectiveSpec!,
mode: "update",
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
expectedIntegrity,
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
@@ -471,6 +476,7 @@ export async function updateNpmInstalledPlugins(params: {
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
baseUrl: record.clawhubUrl,
mode: "update",
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
})
@@ -478,6 +484,7 @@ export async function updateNpmInstalledPlugins(params: {
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
});