diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 71a239eb510..e586228464f 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -168,6 +168,75 @@ openclaw hooks enable Extracts the last 15 user/assistant messages, generates a descriptive filename slug via LLM, and saves to `/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 diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index ef004a68f24..9765a8e1353 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -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 openclaw plugins update --all openclaw plugins update --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 diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 064fade6c7a..f364d1874ff 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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 `), treat it like running untrusted code: +- If you install or update plugins (`openclaw plugins install `, `openclaw plugins update `), 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) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 9f39c7e929a..762917756ed 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -213,6 +213,7 @@ openclaw plugins install # install from local path openclaw plugins install -l # link (no copy) for dev openclaw plugins install --dangerously-force-unsafe-install openclaw plugins update # update one plugin +openclaw plugins update --dangerously-force-unsafe-install openclaw plugins update --all # update all openclaw plugins enable @@ -220,14 +221,14 @@ openclaw plugins disable ``` `--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. diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 35c6f90383a..b6436254429 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -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(); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index fca4fbdc15e..65c2fd7b087 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -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 }); }); diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index 2d37a9823b9..3f8d0e5e401 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -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: { diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 4e4eef2f7d0..09bfe81c875 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -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; diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 05a91e5c63b..8061ff5d120 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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", () => { diff --git a/src/plugins/update.ts b/src/plugins/update.ts index d201780ad2a..4413442447c 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -258,6 +258,7 @@ export async function updateNpmInstalledPlugins(params: { pluginIds?: string[]; skipIds?: Set; dryRun?: boolean; + dangerouslyForceUnsafeInstall?: boolean; specOverrides?: Record; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; }): Promise { @@ -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, });