fix(security): harden npm plugin and hook install integrity flow

This commit is contained in:
Peter Steinberger
2026-02-19 15:10:57 +01:00
parent 2777d8ad93
commit 5dc50b8a3f
23 changed files with 1047 additions and 183 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### Fixes
- Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. - Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage.
- Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift.
- Security/Gateway: rate-limit control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) to 3 requests per minute per `deviceId+clientIp`, add restart single-flight coalescing plus a 30-second restart cooldown, and log actor/device/ip with changed-path audit details for config/update-triggered restarts. - Security/Gateway: rate-limit control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) to 3 requests per minute per `deviceId+clientIp`, add restart single-flight coalescing plus a 30-second restart cooldown, and log actor/device/ip with changed-path audit details for config/update-triggered restarts.
- Commands/Doctor: skip embedding-provider warnings when `memory.backend` is `qmd`, because QMD manages embeddings internally and does not require `memorySearch` providers. (#17263) Thanks @miloudbelarebia. - Commands/Doctor: skip embedding-provider warnings when `memory.backend` is `qmd`, because QMD manages embeddings internally and does not require `memorySearch` providers. (#17263) Thanks @miloudbelarebia.
- Security/Webhooks: harden Feishu and Zalo webhook ingress with webhook-mode token preconditions, loopback-default Feishu bind host, JSON content-type enforcement, per-path rate limiting, replay dedupe for Zalo events, constant-time Zalo secret comparison, and anomaly status counters. - Security/Webhooks: harden Feishu and Zalo webhook ingress with webhook-mode token preconditions, loopback-default Feishu bind host, JSON content-type enforcement, per-path rate limiting, replay dedupe for Zalo events, constant-time Zalo secret comparison, and anomaly status counters.

View File

@@ -188,6 +188,7 @@ openclaw hooks disable command-logger
```bash ```bash
openclaw hooks install <path-or-spec> openclaw hooks install <path-or-spec>
openclaw hooks install <npm-spec> --pin
``` ```
Install a hook pack from a local folder/archive or npm. Install a hook pack from a local folder/archive or npm.
@@ -204,6 +205,7 @@ specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
**Options:** **Options:**
- `-l, --link`: Link a local directory instead of copying (adds it to `hooks.internal.load.extraDirs`) - `-l, --link`: Link a local directory instead of copying (adds it to `hooks.internal.load.extraDirs`)
- `--pin`: Record npm installs as exact resolved `name@version` in `hooks.internal.installs`
**Supported archives:** `.zip`, `.tgz`, `.tar.gz`, `.tar` **Supported archives:** `.zip`, `.tgz`, `.tar.gz`, `.tar`
@@ -237,6 +239,10 @@ Update installed hook packs (npm installs only).
- `--all`: Update all tracked hook packs - `--all`: Update all tracked hook packs
- `--dry-run`: Show what would change without writing - `--dry-run`: Show what would change without writing
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.
## Bundled Hooks ## Bundled Hooks
### session-memory ### session-memory

View File

@@ -40,6 +40,7 @@ the plugin from loading and fail config validation.
```bash ```bash
openclaw plugins install <path-or-spec> openclaw plugins install <path-or-spec>
openclaw plugins install <npm-spec> --pin
``` ```
Security note: treat plugin installs like running code. Prefer pinned versions. Security note: treat plugin installs like running code. Prefer pinned versions.
@@ -55,6 +56,9 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
openclaw plugins install -l ./my-plugin openclaw plugins install -l ./my-plugin
``` ```
Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in
`plugins.installs` while keeping the default behavior unpinned.
### Uninstall ### Uninstall
```bash ```bash
@@ -82,3 +86,7 @@ openclaw plugins update <id> --dry-run
``` ```
Updates only apply to plugins installed from npm (tracked in `plugins.installs`). Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
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.

View File

@@ -27,6 +27,7 @@ The audit warns when multiple DM senders share the main session and recommends *
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`. For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy. It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy.
It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions.
It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint). It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint).
## JSON output ## JSON output

View File

@@ -295,6 +295,7 @@ openclaw plugins install ./plugin.tgz # install from a local tarball
openclaw plugins install ./plugin.zip # install from a local zip openclaw plugins install ./plugin.zip # install from a local zip
openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev
openclaw plugins install @openclaw/voice-call # install from npm openclaw plugins install @openclaw/voice-call # install from npm
openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version
openclaw plugins update <id> openclaw plugins update <id>
openclaw plugins update --all openclaw plugins update --all
openclaw plugins enable <id> openclaw plugins enable <id>
@@ -303,6 +304,7 @@ openclaw plugins doctor
``` ```
`plugins update` only works for npm installs tracked under `plugins.installs`. `plugins update` only works for npm installs tracked under `plugins.installs`.
If stored integrity metadata changes between updates, OpenClaw warns and asks for confirmation (use global `--yes` to bypass prompts).
Plugins may also register their own toplevel commands (example: `openclaw voicecall`). Plugins may also register their own toplevel commands (example: `openclaw voicecall`).

View File

@@ -1,9 +1,10 @@
import type { Command } from "commander";
import fs from "node:fs"; import fs from "node:fs";
import fsp from "node:fs/promises"; import fsp from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { Command } from "commander";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import type { HookEntry } from "../hooks/types.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig, writeConfigFile } from "../config/io.js"; import { loadConfig, writeConfigFile } from "../config/io.js";
import { import {
buildWorkspaceHookStatus, buildWorkspaceHookStatus,
@@ -16,7 +17,6 @@ import {
resolveHookInstallDir, resolveHookInstallDir,
} from "../hooks/install.js"; } from "../hooks/install.js";
import { recordHookInstall } from "../hooks/installs.js"; import { recordHookInstall } from "../hooks/installs.js";
import type { HookEntry } from "../hooks/types.js";
import { loadWorkspaceHookEntries } from "../hooks/workspace.js"; import { loadWorkspaceHookEntries } from "../hooks/workspace.js";
import { resolveArchiveKind } from "../infra/archive.js"; import { resolveArchiveKind } from "../infra/archive.js";
import { buildPluginStatusReport } from "../plugins/status.js"; import { buildPluginStatusReport } from "../plugins/status.js";
@@ -26,6 +26,7 @@ import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomePath } from "../utils.js"; import { resolveUserPath, shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { promptYesNo } from "./prompt.js";
export type HooksListOptions = { export type HooksListOptions = {
json?: boolean; json?: boolean;
@@ -550,7 +551,8 @@ export function registerHooksCli(program: Command): void {
.description("Install a hook pack (path, archive, or npm spec)") .description("Install a hook pack (path, archive, or npm spec)")
.argument("<path-or-spec>", "Path to a hook pack or npm package spec") .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("-l, --link", "Link a local path instead of copying", false)
.action(async (raw: string, opts: { link?: boolean }) => { .option("--pin", "Record npm installs as exact resolved <name>@<version>", false)
.action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => {
const resolved = resolveUserPath(raw); const resolved = resolveUserPath(raw);
const cfg = loadConfig(); const cfg = loadConfig();
@@ -658,13 +660,29 @@ export function registerHooksCli(program: Command): void {
} }
let next = enableInternalHookEntries(cfg, result.hooks); let next = enableInternalHookEntries(cfg, result.hooks);
const resolvedSpec = result.npmResolution?.resolvedSpec;
const recordSpec = opts.pin && resolvedSpec ? resolvedSpec : raw;
if (opts.pin && !resolvedSpec) {
defaultRuntime.log(
theme.warn("Could not resolve exact npm version for --pin; storing original npm spec."),
);
}
if (opts.pin && resolvedSpec) {
defaultRuntime.log(`Pinned npm install record to ${resolvedSpec}.`);
}
next = recordHookInstall(next, { next = recordHookInstall(next, {
hookId: result.hookPackId, hookId: result.hookPackId,
source: "npm", source: "npm",
spec: raw, spec: recordSpec,
installPath: result.targetDir, installPath: result.targetDir,
version: result.version, version: result.version,
resolvedName: result.npmResolution?.name,
resolvedVersion: result.npmResolution?.version,
resolvedSpec: result.npmResolution?.resolvedSpec,
integrity: result.npmResolution?.integrity,
shasum: result.npmResolution?.shasum,
resolvedAt: result.npmResolution?.resolvedAt,
hooks: result.hooks, hooks: result.hooks,
}); });
await writeConfigFile(next); await writeConfigFile(next);
@@ -721,6 +739,18 @@ export function registerHooksCli(program: Command): void {
mode: "update", mode: "update",
dryRun: true, dryRun: true,
expectedHookPackId: hookId, expectedHookPackId: hookId,
expectedIntegrity: record.integrity,
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolution.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${hookId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
);
return true;
},
logger: createInstallLogger(), logger: createInstallLogger(),
}); });
if (!probe.ok) { if (!probe.ok) {
@@ -742,6 +772,18 @@ export function registerHooksCli(program: Command): void {
spec: record.spec, spec: record.spec,
mode: "update", mode: "update",
expectedHookPackId: hookId, expectedHookPackId: hookId,
expectedIntegrity: record.integrity,
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolution.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${hookId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
);
return await promptYesNo(`Continue updating "${hookId}" with this artifact?`);
},
logger: createInstallLogger(), logger: createInstallLogger(),
}); });
if (!result.ok) { if (!result.ok) {
@@ -756,6 +798,12 @@ export function registerHooksCli(program: Command): void {
spec: record.spec, spec: record.spec,
installPath: result.targetDir, installPath: result.targetDir,
version: nextVersion, version: nextVersion,
resolvedName: result.npmResolution?.name,
resolvedVersion: result.npmResolution?.version,
resolvedSpec: result.npmResolution?.resolvedSpec,
integrity: result.npmResolution?.integrity,
shasum: result.npmResolution?.shasum,
resolvedAt: result.npmResolution?.resolvedAt,
hooks: result.hooks, hooks: result.hooks,
}); });
updatedCount += 1; updatedCount += 1;

View File

@@ -1,15 +1,15 @@
import type { Command } from "commander";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { Command } from "commander";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import type { PluginRecord } from "../plugins/registry.js";
import { loadConfig, writeConfigFile } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../config/paths.js";
import { resolveArchiveKind } from "../infra/archive.js"; import { resolveArchiveKind } from "../infra/archive.js";
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js"; import { recordPluginInstall } from "../plugins/installs.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import type { PluginRecord } from "../plugins/registry.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js";
import { buildPluginStatusReport } from "../plugins/status.js"; import { buildPluginStatusReport } from "../plugins/status.js";
@@ -535,7 +535,8 @@ export function registerPluginsCli(program: Command) {
.description("Install a plugin (path, archive, or npm spec)") .description("Install a plugin (path, archive, or npm spec)")
.argument("<path-or-spec>", "Path (.ts/.js/.zip/.tgz/.tar.gz) or an npm package spec") .argument("<path-or-spec>", "Path (.ts/.js/.zip/.tgz/.tar.gz) or an npm package spec")
.option("-l, --link", "Link a local path instead of copying", false) .option("-l, --link", "Link a local path instead of copying", false)
.action(async (raw: string, opts: { link?: boolean }) => { .option("--pin", "Record npm installs as exact resolved <name>@<version>", false)
.action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => {
const fileSpec = resolveFileNpmSpecToLocalPath(raw); const fileSpec = resolveFileNpmSpecToLocalPath(raw);
if (fileSpec && !fileSpec.ok) { if (fileSpec && !fileSpec.ok) {
defaultRuntime.error(fileSpec.error); defaultRuntime.error(fileSpec.error);
@@ -648,12 +649,28 @@ export function registerPluginsCli(program: Command) {
clearPluginManifestRegistryCache(); clearPluginManifestRegistryCache();
let next = enablePluginInConfig(cfg, result.pluginId); let next = enablePluginInConfig(cfg, result.pluginId);
const resolvedSpec = result.npmResolution?.resolvedSpec;
const recordSpec = opts.pin && resolvedSpec ? resolvedSpec : raw;
if (opts.pin && !resolvedSpec) {
defaultRuntime.log(
theme.warn("Could not resolve exact npm version for --pin; storing original npm spec."),
);
}
if (opts.pin && resolvedSpec) {
defaultRuntime.log(`Pinned npm install record to ${resolvedSpec}.`);
}
next = recordPluginInstall(next, { next = recordPluginInstall(next, {
pluginId: result.pluginId, pluginId: result.pluginId,
source: "npm", source: "npm",
spec: raw, spec: recordSpec,
installPath: result.targetDir, installPath: result.targetDir,
version: result.version, version: result.version,
resolvedName: result.npmResolution?.name,
resolvedVersion: result.npmResolution?.version,
resolvedSpec: result.npmResolution?.resolvedSpec,
integrity: result.npmResolution?.integrity,
shasum: result.npmResolution?.shasum,
resolvedAt: result.npmResolution?.resolvedAt,
}); });
const slotResult = applySlotSelectionForPlugin(next, result.pluginId); const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config; next = slotResult.config;
@@ -691,6 +708,20 @@ export function registerPluginsCli(program: Command) {
info: (msg) => defaultRuntime.log(msg), info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)), warn: (msg) => defaultRuntime.log(theme.warn(msg)),
}, },
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
);
if (drift.dryRun) {
return true;
}
return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`);
},
}); });
for (const outcome of result.outcomes) { for (const outcome of result.outcomes) {

View File

@@ -1,16 +1,16 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { createSubsystemLogger } from "../../logging/subsystem.js"; import { createSubsystemLogger } from "../../logging/subsystem.js";
import { enablePluginInConfig } from "../../plugins/enable.js"; import { enablePluginInConfig } from "../../plugins/enable.js";
import { installPluginFromNpmSpec } from "../../plugins/install.js"; import { installPluginFromNpmSpec } from "../../plugins/install.js";
import { recordPluginInstall } from "../../plugins/installs.js"; import { recordPluginInstall } from "../../plugins/installs.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js"; import { loadOpenClawPlugins } from "../../plugins/loader.js";
import { createPluginLoaderLogger } from "../../plugins/logger.js"; import { createPluginLoaderLogger } from "../../plugins/logger.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
type InstallChoice = "npm" | "local" | "skip"; type InstallChoice = "npm" | "local" | "skip";
@@ -175,6 +175,12 @@ export async function ensureOnboardingPluginInstalled(params: {
spec: entry.install.npmSpec, spec: entry.install.npmSpec,
installPath: result.targetDir, installPath: result.targetDir,
version: result.version, version: result.version,
resolvedName: result.npmResolution?.name,
resolvedVersion: result.npmResolution?.version,
resolvedSpec: result.npmResolution?.resolvedSpec,
integrity: result.npmResolution?.integrity,
shasum: result.npmResolution?.shasum,
resolvedAt: result.npmResolution?.resolvedAt,
}); });
return { cfg: next, installed: true }; return { cfg: next, installed: true };
} }

View File

@@ -283,6 +283,17 @@ export const FIELD_HELP: Record<string, string> = {
"plugins.installs.*.installPath": "plugins.installs.*.installPath":
"Resolved install directory (usually ~/.openclaw/extensions/<id>).", "Resolved install directory (usually ~/.openclaw/extensions/<id>).",
"plugins.installs.*.version": "Version recorded at install time (if available).", "plugins.installs.*.version": "Version recorded at install time (if available).",
"plugins.installs.*.resolvedName": "Resolved npm package name from the fetched artifact.",
"plugins.installs.*.resolvedVersion":
"Resolved npm package version from the fetched artifact (useful for non-pinned specs).",
"plugins.installs.*.resolvedSpec":
"Resolved exact npm spec (<name>@<version>) from the fetched artifact.",
"plugins.installs.*.integrity":
"Resolved npm dist integrity hash for the fetched artifact (if reported by npm).",
"plugins.installs.*.shasum":
"Resolved npm dist shasum for the fetched artifact (if reported by npm).",
"plugins.installs.*.resolvedAt":
"ISO timestamp when npm package metadata was last resolved for this install record.",
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.", "plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
"agents.list.*.identity.avatar": "agents.list.*.identity.avatar":
"Agent avatar (workspace-relative path, http(s) URL, or data URI).", "Agent avatar (workspace-relative path, http(s) URL, or data URI).",

View File

@@ -322,5 +322,11 @@ export const FIELD_LABELS: Record<string, string> = {
"plugins.installs.*.sourcePath": "Plugin Install Source Path", "plugins.installs.*.sourcePath": "Plugin Install Source Path",
"plugins.installs.*.installPath": "Plugin Install Path", "plugins.installs.*.installPath": "Plugin Install Path",
"plugins.installs.*.version": "Plugin Install Version", "plugins.installs.*.version": "Plugin Install Version",
"plugins.installs.*.resolvedName": "Plugin Resolved Package Name",
"plugins.installs.*.resolvedVersion": "Plugin Resolved Package Version",
"plugins.installs.*.resolvedSpec": "Plugin Resolved Package Spec",
"plugins.installs.*.integrity": "Plugin Resolved Integrity",
"plugins.installs.*.shasum": "Plugin Resolved Shasum",
"plugins.installs.*.resolvedAt": "Plugin Resolution Time",
"plugins.installs.*.installedAt": "Plugin Install Time", "plugins.installs.*.installedAt": "Plugin Install Time",
}; };

View File

@@ -93,6 +93,12 @@ export type HookInstallRecord = {
sourcePath?: string; sourcePath?: string;
installPath?: string; installPath?: string;
version?: string; version?: string;
resolvedName?: string;
resolvedVersion?: string;
resolvedSpec?: string;
integrity?: string;
shasum?: string;
resolvedAt?: string;
installedAt?: string; installedAt?: string;
hooks?: string[]; hooks?: string[];
}; };

View File

@@ -27,6 +27,12 @@ export type PluginInstallRecord = {
sourcePath?: string; sourcePath?: string;
installPath?: string; installPath?: string;
version?: string; version?: string;
resolvedName?: string;
resolvedVersion?: string;
resolvedSpec?: string;
integrity?: string;
shasum?: string;
resolvedAt?: string;
installedAt?: string; installedAt?: string;
}; };

View File

@@ -12,5 +12,11 @@ export const InstallRecordShape = {
sourcePath: z.string().optional(), sourcePath: z.string().optional(),
installPath: z.string().optional(), installPath: z.string().optional(),
version: z.string().optional(), version: z.string().optional(),
resolvedName: z.string().optional(),
resolvedVersion: z.string().optional(),
resolvedSpec: z.string().optional(),
integrity: z.string().optional(),
shasum: z.string().optional(),
resolvedAt: z.string().optional(),
installedAt: z.string().optional(), installedAt: z.string().optional(),
} as const; } as const;

View File

@@ -253,7 +253,16 @@ describe("installHooksFromNpmSpec", () => {
fs.writeFileSync(path.join(packTmpDir, packedName), npmPackHooksBuffer); fs.writeFileSync(path.join(packTmpDir, packedName), npmPackHooksBuffer);
return { return {
code: 0, code: 0,
stdout: `${packedName}\n`, stdout: JSON.stringify([
{
id: "@openclaw/test-hooks@0.0.1",
name: "@openclaw/test-hooks",
version: "0.0.1",
filename: packedName,
integrity: "sha512-hook-test",
shasum: "hookshasum",
},
]),
stderr: "", stderr: "",
signal: null, signal: null,
killed: false, killed: false,
@@ -274,6 +283,8 @@ describe("installHooksFromNpmSpec", () => {
return; return;
} }
expect(result.hookPackId).toBe("test-hooks"); expect(result.hookPackId).toBe("test-hooks");
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/test-hooks@0.0.1");
expect(result.npmResolution?.integrity).toBe("sha512-hook-test");
expect(fs.existsSync(path.join(result.targetDir, "hooks", "one-hook", "HOOK.md"))).toBe(true); expect(fs.existsSync(path.join(result.targetDir, "hooks", "one-hook", "HOOK.md"))).toBe(true);
expectSingleNpmPackIgnoreScriptsCall({ expectSingleNpmPackIgnoreScriptsCall({
@@ -293,6 +304,46 @@ describe("installHooksFromNpmSpec", () => {
} }
expect(result.error).toContain("unsupported npm spec"); expect(result.error).toContain("unsupported npm spec");
}); });
it("aborts when integrity drift callback rejects the fetched artifact", async () => {
const run = vi.mocked(runCommandWithTimeout);
run.mockResolvedValue({
code: 0,
stdout: JSON.stringify([
{
id: "@openclaw/test-hooks@0.0.1",
name: "@openclaw/test-hooks",
version: "0.0.1",
filename: "test-hooks-0.0.1.tgz",
integrity: "sha512-new",
shasum: "newshasum",
},
]),
stderr: "",
signal: null,
killed: false,
termination: "exit",
});
const onIntegrityDrift = vi.fn(async () => false);
const result = await installHooksFromNpmSpec({
spec: "@openclaw/test-hooks@0.0.1",
expectedIntegrity: "sha512-old",
onIntegrityDrift,
});
expect(onIntegrityDrift).toHaveBeenCalledWith(
expect.objectContaining({
expectedIntegrity: "sha512-old",
actualIntegrity: "sha512-new",
}),
);
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error).toContain("integrity drift");
});
}); });
describe("gmail watcher", () => { describe("gmail watcher", () => {

View File

@@ -11,6 +11,8 @@ import {
import { installPackageDir } from "../infra/install-package-dir.js"; import { installPackageDir } from "../infra/install-package-dir.js";
import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-safe-path.js"; import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-safe-path.js";
import { import {
type NpmIntegrityDrift,
type NpmSpecResolution,
packNpmSpecToArchive, packNpmSpecToArchive,
resolveArchiveSourcePath, resolveArchiveSourcePath,
withTempDir, withTempDir,
@@ -37,9 +39,18 @@ export type InstallHooksResult =
hooks: string[]; hooks: string[];
targetDir: string; targetDir: string;
version?: string; version?: string;
npmResolution?: NpmSpecResolution;
integrityDrift?: NpmIntegrityDrift;
} }
| { ok: false; error: string }; | { ok: false; error: string };
export type HookNpmIntegrityDriftParams = {
spec: string;
expectedIntegrity: string;
actualIntegrity: string;
resolution: NpmSpecResolution;
};
const defaultLogger: HookInstallLogger = {}; const defaultLogger: HookInstallLogger = {};
function validateHookId(hookId: string): string | null { function validateHookId(hookId: string): string | null {
@@ -375,6 +386,8 @@ export async function installHooksFromNpmSpec(params: {
mode?: "install" | "update"; mode?: "install" | "update";
dryRun?: boolean; dryRun?: boolean;
expectedHookPackId?: string; expectedHookPackId?: string;
expectedIntegrity?: string;
onIntegrityDrift?: (params: HookNpmIntegrityDriftParams) => boolean | Promise<boolean>;
}): Promise<InstallHooksResult> { }): Promise<InstallHooksResult> {
const { logger, timeoutMs, mode, dryRun } = resolveTimedHookInstallModeOptions(params); const { logger, timeoutMs, mode, dryRun } = resolveTimedHookInstallModeOptions(params);
const expectedHookPackId = params.expectedHookPackId; const expectedHookPackId = params.expectedHookPackId;
@@ -395,7 +408,44 @@ export async function installHooksFromNpmSpec(params: {
return packedResult; return packedResult;
} }
return await installHooksFromArchive({ const npmResolution: NpmSpecResolution = {
...packedResult.metadata,
resolvedAt: new Date().toISOString(),
};
let integrityDrift: NpmIntegrityDrift | undefined;
if (
params.expectedIntegrity &&
npmResolution.integrity &&
params.expectedIntegrity !== npmResolution.integrity
) {
integrityDrift = {
expectedIntegrity: params.expectedIntegrity,
actualIntegrity: npmResolution.integrity,
};
const driftPayload: HookNpmIntegrityDriftParams = {
spec,
expectedIntegrity: integrityDrift.expectedIntegrity,
actualIntegrity: integrityDrift.actualIntegrity,
resolution: npmResolution,
};
let proceed = true;
if (params.onIntegrityDrift) {
proceed = await params.onIntegrityDrift(driftPayload);
} else {
logger.warn?.(
`Integrity drift detected for ${driftPayload.resolution.resolvedSpec ?? driftPayload.spec}: expected ${driftPayload.expectedIntegrity}, got ${driftPayload.actualIntegrity}`,
);
}
if (!proceed) {
return {
ok: false,
error: `aborted: npm package integrity drift detected for ${driftPayload.resolution.resolvedSpec ?? driftPayload.spec}`,
};
}
}
const installResult = await installHooksFromArchive({
archivePath: packedResult.archivePath, archivePath: packedResult.archivePath,
hooksDir: params.hooksDir, hooksDir: params.hooksDir,
timeoutMs, timeoutMs,
@@ -404,6 +454,15 @@ export async function installHooksFromNpmSpec(params: {
dryRun, dryRun,
expectedHookPackId, expectedHookPackId,
}); });
if (!installResult.ok) {
return installResult;
}
return {
...installResult,
npmResolution,
integrityDrift,
};
}); });
} }

View File

@@ -85,7 +85,52 @@ describe("resolveArchiveSourcePath", () => {
}); });
describe("packNpmSpecToArchive", () => { describe("packNpmSpecToArchive", () => {
it("packs spec and returns archive path using the final non-empty stdout line", async () => { it("packs spec and returns archive path using JSON output metadata", async () => {
const cwd = await createTempDir("openclaw-install-source-utils-");
runCommandWithTimeoutMock.mockResolvedValue({
stdout: JSON.stringify([
{
id: "openclaw-plugin@1.2.3",
name: "openclaw-plugin",
version: "1.2.3",
filename: "openclaw-plugin-1.2.3.tgz",
integrity: "sha512-test-integrity",
shasum: "abc123",
},
]),
stderr: "",
code: 0,
signal: null,
killed: false,
});
const result = await packNpmSpecToArchive({
spec: "openclaw-plugin@1.2.3",
timeoutMs: 1000,
cwd,
});
expect(result).toEqual({
ok: true,
archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"),
metadata: {
name: "openclaw-plugin",
version: "1.2.3",
resolvedSpec: "openclaw-plugin@1.2.3",
integrity: "sha512-test-integrity",
shasum: "abc123",
},
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
["npm", "pack", "openclaw-plugin@1.2.3", "--ignore-scripts", "--json"],
expect.objectContaining({
cwd,
timeoutMs: 300_000,
}),
);
});
it("falls back to parsing final stdout line when npm json output is unavailable", async () => {
const cwd = await createTempDir("openclaw-install-source-utils-"); const cwd = await createTempDir("openclaw-install-source-utils-");
runCommandWithTimeoutMock.mockResolvedValue({ runCommandWithTimeoutMock.mockResolvedValue({
stdout: "npm notice created package\nopenclaw-plugin-1.2.3.tgz\n", stdout: "npm notice created package\nopenclaw-plugin-1.2.3.tgz\n",
@@ -104,14 +149,8 @@ describe("packNpmSpecToArchive", () => {
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"), archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"),
metadata: {},
}); });
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
["npm", "pack", "openclaw-plugin@1.2.3", "--ignore-scripts"],
expect.objectContaining({
cwd,
timeoutMs: 300_000,
}),
);
}); });
it("returns npm pack error details when command fails", async () => { it("returns npm pack error details when command fails", async () => {

View File

@@ -5,6 +5,20 @@ import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { fileExists, resolveArchiveKind } from "./archive.js"; import { fileExists, resolveArchiveKind } from "./archive.js";
export type NpmSpecResolution = {
name?: string;
version?: string;
resolvedSpec?: string;
integrity?: string;
shasum?: string;
resolvedAt?: string;
};
export type NpmIntegrityDrift = {
expectedIntegrity: string;
actualIntegrity: string;
};
export async function withTempDir<T>( export async function withTempDir<T>(
prefix: string, prefix: string,
fn: (tmpDir: string) => Promise<T>, fn: (tmpDir: string) => Promise<T>,
@@ -39,6 +53,97 @@ export async function resolveArchiveSourcePath(archivePath: string): Promise<
return { ok: true, path: resolved }; return { ok: true, path: resolved };
} }
function toOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function parseResolvedSpecFromId(id: string): string | undefined {
const at = id.lastIndexOf("@");
if (at <= 0 || at >= id.length - 1) {
return undefined;
}
const name = id.slice(0, at).trim();
const version = id.slice(at + 1).trim();
if (!name || !version) {
return undefined;
}
return `${name}@${version}`;
}
function normalizeNpmPackEntry(
entry: unknown,
): { filename?: string; metadata: NpmSpecResolution } | null {
if (!entry || typeof entry !== "object") {
return null;
}
const rec = entry as Record<string, unknown>;
const name = toOptionalString(rec.name);
const version = toOptionalString(rec.version);
const id = toOptionalString(rec.id);
const resolvedSpec =
(name && version ? `${name}@${version}` : undefined) ??
(id ? parseResolvedSpecFromId(id) : undefined);
return {
filename: toOptionalString(rec.filename),
metadata: {
name,
version,
resolvedSpec,
integrity: toOptionalString(rec.integrity),
shasum: toOptionalString(rec.shasum),
},
};
}
function parseNpmPackJsonOutput(
raw: string,
): { filename?: string; metadata: NpmSpecResolution } | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const candidates = [trimmed];
const arrayStart = trimmed.indexOf("[");
if (arrayStart > 0) {
candidates.push(trimmed.slice(arrayStart));
}
for (const candidate of candidates) {
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
continue;
}
const entries = Array.isArray(parsed) ? parsed : [parsed];
let fallback: { filename?: string; metadata: NpmSpecResolution } | null = null;
for (let i = entries.length - 1; i >= 0; i -= 1) {
const normalized = normalizeNpmPackEntry(entries[i]);
if (!normalized) {
continue;
}
if (!fallback) {
fallback = normalized;
}
if (normalized.filename) {
return normalized;
}
}
if (fallback) {
return fallback;
}
}
return null;
}
export async function packNpmSpecToArchive(params: { export async function packNpmSpecToArchive(params: {
spec: string; spec: string;
timeoutMs: number; timeoutMs: number;
@@ -47,32 +152,44 @@ export async function packNpmSpecToArchive(params: {
| { | {
ok: true; ok: true;
archivePath: string; archivePath: string;
metadata: NpmSpecResolution;
} }
| { | {
ok: false; ok: false;
error: string; error: string;
} }
> { > {
const res = await runCommandWithTimeout(["npm", "pack", params.spec, "--ignore-scripts"], { const res = await runCommandWithTimeout(
timeoutMs: Math.max(params.timeoutMs, 300_000), ["npm", "pack", params.spec, "--ignore-scripts", "--json"],
cwd: params.cwd, {
env: { timeoutMs: Math.max(params.timeoutMs, 300_000),
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", cwd: params.cwd,
NPM_CONFIG_IGNORE_SCRIPTS: "true", env: {
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
NPM_CONFIG_IGNORE_SCRIPTS: "true",
},
}, },
}); );
if (res.code !== 0) { if (res.code !== 0) {
return { ok: false, error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}` }; return { ok: false, error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}` };
} }
const packed = (res.stdout || "") const parsedJson = parseNpmPackJsonOutput(res.stdout || "");
.split("\n")
.map((line) => line.trim()) const packed =
.filter(Boolean) parsedJson?.filename ??
.pop(); (res.stdout || "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.pop();
if (!packed) { if (!packed) {
return { ok: false, error: "npm pack produced no archive" }; return { ok: false, error: "npm pack produced no archive" };
} }
return { ok: true, archivePath: path.join(params.cwd, packed) }; return {
ok: true,
archivePath: path.join(params.cwd, packed),
metadata: parsedJson?.metadata ?? {},
};
} }

View File

@@ -1,8 +1,8 @@
import JSZip from "jszip";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import JSZip from "jszip";
import * as tar from "tar"; import * as tar from "tar";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as skillScanner from "../security/skill-scanner.js"; import * as skillScanner from "../security/skill-scanner.js";
@@ -491,7 +491,16 @@ describe("installPluginFromNpmSpec", () => {
await packToArchive({ pkgDir, outDir: packTmpDir, outName: packedName }); await packToArchive({ pkgDir, outDir: packTmpDir, outName: packedName });
return { return {
code: 0, code: 0,
stdout: `${packedName}\n`, stdout: JSON.stringify([
{
id: "@openclaw/voice-call@0.0.1",
name: "@openclaw/voice-call",
version: "0.0.1",
filename: packedName,
integrity: "sha512-plugin-test",
shasum: "pluginshasum",
},
]),
stderr: "", stderr: "",
signal: null, signal: null,
killed: false, killed: false,
@@ -508,6 +517,11 @@ describe("installPluginFromNpmSpec", () => {
logger: { info: () => {}, warn: () => {} }, logger: { info: () => {}, warn: () => {} },
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1");
expect(result.npmResolution?.integrity).toBe("sha512-plugin-test");
expectSingleNpmPackIgnoreScriptsCall({ expectSingleNpmPackIgnoreScriptsCall({
calls: run.mock.calls, calls: run.mock.calls,
@@ -527,4 +541,46 @@ describe("installPluginFromNpmSpec", () => {
} }
expect(result.error).toContain("unsupported npm spec"); expect(result.error).toContain("unsupported npm spec");
}); });
it("aborts when integrity drift callback rejects the fetched artifact", async () => {
const { runCommandWithTimeout } = await import("../process/exec.js");
const run = vi.mocked(runCommandWithTimeout);
run.mockResolvedValue({
code: 0,
stdout: JSON.stringify([
{
id: "@openclaw/voice-call@0.0.1",
name: "@openclaw/voice-call",
version: "0.0.1",
filename: "voice-call-0.0.1.tgz",
integrity: "sha512-new",
shasum: "newshasum",
},
]),
stderr: "",
signal: null,
killed: false,
termination: "exit",
});
const onIntegrityDrift = vi.fn(async () => false);
const { installPluginFromNpmSpec } = await import("./install.js");
const result = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call@0.0.1",
expectedIntegrity: "sha512-old",
onIntegrityDrift,
});
expect(onIntegrityDrift).toHaveBeenCalledWith(
expect.objectContaining({
expectedIntegrity: "sha512-old",
actualIntegrity: "sha512-new",
}),
);
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error).toContain("integrity drift");
});
}); });

View File

@@ -15,6 +15,8 @@ import {
unscopedPackageName, unscopedPackageName,
} from "../infra/install-safe-path.js"; } from "../infra/install-safe-path.js";
import { import {
type NpmIntegrityDrift,
type NpmSpecResolution,
packNpmSpecToArchive, packNpmSpecToArchive,
resolveArchiveSourcePath, resolveArchiveSourcePath,
withTempDir, withTempDir,
@@ -43,9 +45,18 @@ export type InstallPluginResult =
manifestName?: string; manifestName?: string;
version?: string; version?: string;
extensions: string[]; extensions: string[];
npmResolution?: NpmSpecResolution;
integrityDrift?: NpmIntegrityDrift;
} }
| { ok: false; error: string }; | { ok: false; error: string };
export type PluginNpmIntegrityDriftParams = {
spec: string;
expectedIntegrity: string;
actualIntegrity: string;
resolution: NpmSpecResolution;
};
const defaultLogger: PluginInstallLogger = {}; const defaultLogger: PluginInstallLogger = {};
function safeFileName(input: string): string { function safeFileName(input: string): string {
return safeDirName(input); return safeDirName(input);
@@ -420,6 +431,8 @@ export async function installPluginFromNpmSpec(params: {
mode?: "install" | "update"; mode?: "install" | "update";
dryRun?: boolean; dryRun?: boolean;
expectedPluginId?: string; expectedPluginId?: string;
expectedIntegrity?: string;
onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise<boolean>;
}): Promise<InstallPluginResult> { }): Promise<InstallPluginResult> {
const { logger, timeoutMs, mode, dryRun } = resolveTimedPluginInstallModeOptions(params); const { logger, timeoutMs, mode, dryRun } = resolveTimedPluginInstallModeOptions(params);
const expectedPluginId = params.expectedPluginId; const expectedPluginId = params.expectedPluginId;
@@ -440,7 +453,44 @@ export async function installPluginFromNpmSpec(params: {
return packedResult; return packedResult;
} }
return await installPluginFromArchive({ const npmResolution: NpmSpecResolution = {
...packedResult.metadata,
resolvedAt: new Date().toISOString(),
};
let integrityDrift: NpmIntegrityDrift | undefined;
if (
params.expectedIntegrity &&
npmResolution.integrity &&
params.expectedIntegrity !== npmResolution.integrity
) {
integrityDrift = {
expectedIntegrity: params.expectedIntegrity,
actualIntegrity: npmResolution.integrity,
};
const driftPayload: PluginNpmIntegrityDriftParams = {
spec,
expectedIntegrity: integrityDrift.expectedIntegrity,
actualIntegrity: integrityDrift.actualIntegrity,
resolution: npmResolution,
};
let proceed = true;
if (params.onIntegrityDrift) {
proceed = await params.onIntegrityDrift(driftPayload);
} else {
logger.warn?.(
`Integrity drift detected for ${driftPayload.resolution.resolvedSpec ?? driftPayload.spec}: expected ${driftPayload.expectedIntegrity}, got ${driftPayload.actualIntegrity}`,
);
}
if (!proceed) {
return {
ok: false,
error: `aborted: npm package integrity drift detected for ${driftPayload.resolution.resolvedSpec ?? driftPayload.spec}`,
};
}
}
const installResult = await installPluginFromArchive({
archivePath: packedResult.archivePath, archivePath: packedResult.archivePath,
extensionsDir: params.extensionsDir, extensionsDir: params.extensionsDir,
timeoutMs, timeoutMs,
@@ -449,6 +499,15 @@ export async function installPluginFromNpmSpec(params: {
dryRun, dryRun,
expectedPluginId, expectedPluginId,
}); });
if (!installResult.ok) {
return installResult;
}
return {
...installResult,
npmResolution,
integrityDrift,
};
}); });
} }

View File

@@ -29,6 +29,16 @@ export type PluginUpdateSummary = {
outcomes: PluginUpdateOutcome[]; outcomes: PluginUpdateOutcome[];
}; };
export type PluginUpdateIntegrityDriftParams = {
pluginId: string;
spec: string;
expectedIntegrity: string;
actualIntegrity: string;
resolvedSpec?: string;
resolvedVersion?: string;
dryRun: boolean;
};
export type PluginChannelSyncSummary = { export type PluginChannelSyncSummary = {
switchedToBundled: string[]; switchedToBundled: string[];
switchedToNpm: string[]; switchedToNpm: string[];
@@ -143,6 +153,7 @@ export async function updateNpmInstalledPlugins(params: {
pluginIds?: string[]; pluginIds?: string[];
skipIds?: Set<string>; skipIds?: Set<string>;
dryRun?: boolean; dryRun?: boolean;
onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
}): Promise<PluginUpdateSummary> { }): Promise<PluginUpdateSummary> {
const logger = params.logger ?? {}; const logger = params.logger ?? {};
const installs = params.config.plugins?.installs ?? {}; const installs = params.config.plugins?.installs ?? {};
@@ -210,6 +221,25 @@ export async function updateNpmInstalledPlugins(params: {
mode: "update", mode: "update",
dryRun: true, dryRun: true,
expectedPluginId: pluginId, expectedPluginId: pluginId,
expectedIntegrity: record.integrity,
onIntegrityDrift: async (drift) => {
const payload: PluginUpdateIntegrityDriftParams = {
pluginId,
spec: drift.spec,
expectedIntegrity: drift.expectedIntegrity,
actualIntegrity: drift.actualIntegrity,
resolvedSpec: drift.resolution.resolvedSpec,
resolvedVersion: drift.resolution.version,
dryRun: true,
};
if (params.onIntegrityDrift) {
return await params.onIntegrityDrift(payload);
}
logger.warn?.(
`Integrity drift for "${pluginId}" (${payload.resolvedSpec ?? payload.spec}): expected ${payload.expectedIntegrity}, got ${payload.actualIntegrity}`,
);
return true;
},
logger, logger,
}); });
} catch (err) { } catch (err) {
@@ -257,6 +287,25 @@ export async function updateNpmInstalledPlugins(params: {
spec: record.spec, spec: record.spec,
mode: "update", mode: "update",
expectedPluginId: pluginId, expectedPluginId: pluginId,
expectedIntegrity: record.integrity,
onIntegrityDrift: async (drift) => {
const payload: PluginUpdateIntegrityDriftParams = {
pluginId,
spec: drift.spec,
expectedIntegrity: drift.expectedIntegrity,
actualIntegrity: drift.actualIntegrity,
resolvedSpec: drift.resolution.resolvedSpec,
resolvedVersion: drift.resolution.version,
dryRun: false,
};
if (params.onIntegrityDrift) {
return await params.onIntegrityDrift(payload);
}
logger.warn?.(
`Integrity drift for "${pluginId}" (${payload.resolvedSpec ?? payload.spec}): expected ${payload.expectedIntegrity}, got ${payload.actualIntegrity}`,
);
return true;
},
logger, logger,
}); });
} catch (err) { } catch (err) {
@@ -283,6 +332,12 @@ export async function updateNpmInstalledPlugins(params: {
spec: record.spec, spec: record.spec,
installPath: result.targetDir, installPath: result.targetDir,
version: nextVersion, version: nextVersion,
resolvedName: result.npmResolution?.name,
resolvedVersion: result.npmResolution?.version,
resolvedSpec: result.npmResolution?.resolvedSpec,
integrity: result.npmResolution?.integrity,
shasum: result.npmResolution?.shasum,
resolvedAt: result.npmResolution?.resolvedAt,
}); });
changed = true; changed = true;
@@ -406,6 +461,12 @@ export async function syncPluginsForUpdateChannel(params: {
spec, spec,
installPath: result.targetDir, installPath: result.targetDir,
version: result.version, version: result.version,
resolvedName: result.npmResolution?.name,
resolvedVersion: result.npmResolution?.version,
resolvedSpec: result.npmResolution?.resolvedSpec,
integrity: result.npmResolution?.integrity,
shasum: result.npmResolution?.shasum,
resolvedAt: result.npmResolution?.resolvedAt,
sourcePath: undefined, sourcePath: undefined,
}); });
summary.switchedToNpm.push(pluginId); summary.switchedToNpm.push(pluginId);

View File

@@ -5,23 +5,25 @@
*/ */
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import type { SkillScanFinding } from "./skill-scanner.js";
import type { ExecFn } from "./windows-acl.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js";
import { import {
resolveSandboxConfigForAgent, resolveSandboxConfigForAgent,
resolveSandboxToolPolicyForAgent, resolveSandboxToolPolicyForAgent,
} from "../agents/sandbox.js"; } from "../agents/sandbox.js";
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
import { loadWorkspaceSkillEntries } from "../agents/skills.js"; import { loadWorkspaceSkillEntries } from "../agents/skills.js";
import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js"; import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js";
import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { resolveNativeSkillsEnabled } from "../config/commands.js"; import { resolveNativeSkillsEnabled } from "../config/commands.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js";
import { createConfigIO } from "../config/config.js"; import { createConfigIO } from "../config/config.js";
import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { collectIncludePathsRecursive } from "../config/includes-scan.js";
import { resolveOAuthDir } from "../config/paths.js"; import { resolveOAuthDir } from "../config/paths.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import { normalizePluginsConfig } from "../plugins/config-state.js"; import { normalizePluginsConfig } from "../plugins/config-state.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
import { import {
@@ -32,9 +34,7 @@ import {
} from "./audit-fs.js"; } from "./audit-fs.js";
import { pickSandboxToolPolicy } from "./audit-tool-policy.js"; import { pickSandboxToolPolicy } from "./audit-tool-policy.js";
import { extensionUsesSkippedScannerPath, isPathInside } from "./scan-paths.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "./scan-paths.js";
import type { SkillScanFinding } from "./skill-scanner.js";
import * as skillScanner from "./skill-scanner.js"; import * as skillScanner from "./skill-scanner.js";
import type { ExecFn } from "./windows-acl.js";
export type SecurityAuditFinding = { export type SecurityAuditFinding = {
checkId: string; checkId: string;
@@ -215,6 +215,29 @@ function hasProviderPluginAllow(params: {
return false; return false;
} }
function isPinnedRegistrySpec(spec: string): boolean {
const value = spec.trim();
if (!value) {
return false;
}
const at = value.lastIndexOf("@");
if (at <= 0 || at >= value.length - 1) {
return false;
}
const version = value.slice(at + 1).trim();
return /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version);
}
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
try {
const raw = await fs.readFile(path.join(dir, "package.json"), "utf-8");
const parsed = JSON.parse(raw) as { version?: unknown };
return typeof parsed.version === "string" ? parsed.version : undefined;
} catch {
return undefined;
}
}
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Exported collectors // Exported collectors
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@@ -227,155 +250,279 @@ export async function collectPluginsTrustFindings(params: {
const { extensionsDir, pluginDirs } = await listInstalledPluginDirs({ const { extensionsDir, pluginDirs } = await listInstalledPluginDirs({
stateDir: params.stateDir, stateDir: params.stateDir,
}); });
if (pluginDirs.length === 0) { if (pluginDirs.length > 0) {
return findings; const allow = params.cfg.plugins?.allow;
} const allowConfigured = Array.isArray(allow) && allow.length > 0;
if (!allowConfigured) {
const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0;
const hasAccountStringKey = (account: unknown, key: string) =>
Boolean(
account &&
typeof account === "object" &&
hasString((account as Record<string, unknown>)[key]),
);
const allow = params.cfg.plugins?.allow; const discordConfigured =
const allowConfigured = Array.isArray(allow) && allow.length > 0; hasString(params.cfg.channels?.discord?.token) ||
if (!allowConfigured) { Boolean(
const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0; params.cfg.channels?.discord?.accounts &&
const hasAccountStringKey = (account: unknown, key: string) => Object.values(params.cfg.channels.discord.accounts).some((a) =>
Boolean( hasAccountStringKey(a, "token"),
account && ),
typeof account === "object" && ) ||
hasString((account as Record<string, unknown>)[key]), hasString(process.env.DISCORD_BOT_TOKEN);
);
const discordConfigured = const telegramConfigured =
hasString(params.cfg.channels?.discord?.token) || hasString(params.cfg.channels?.telegram?.botToken) ||
Boolean( hasString(params.cfg.channels?.telegram?.tokenFile) ||
params.cfg.channels?.discord?.accounts && Boolean(
Object.values(params.cfg.channels.discord.accounts).some((a) => params.cfg.channels?.telegram?.accounts &&
hasAccountStringKey(a, "token"), Object.values(params.cfg.channels.telegram.accounts).some(
), (a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"),
) || ),
hasString(process.env.DISCORD_BOT_TOKEN); ) ||
hasString(process.env.TELEGRAM_BOT_TOKEN);
const telegramConfigured = const slackConfigured =
hasString(params.cfg.channels?.telegram?.botToken) || hasString(params.cfg.channels?.slack?.botToken) ||
hasString(params.cfg.channels?.telegram?.tokenFile) || hasString(params.cfg.channels?.slack?.appToken) ||
Boolean( Boolean(
params.cfg.channels?.telegram?.accounts && params.cfg.channels?.slack?.accounts &&
Object.values(params.cfg.channels.telegram.accounts).some( Object.values(params.cfg.channels.slack.accounts).some(
(a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"), (a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "appToken"),
), ),
) || ) ||
hasString(process.env.TELEGRAM_BOT_TOKEN); hasString(process.env.SLACK_BOT_TOKEN) ||
hasString(process.env.SLACK_APP_TOKEN);
const slackConfigured = const skillCommandsLikelyExposed =
hasString(params.cfg.channels?.slack?.botToken) || (discordConfigured &&
hasString(params.cfg.channels?.slack?.appToken) || resolveNativeSkillsEnabled({
Boolean( providerId: "discord",
params.cfg.channels?.slack?.accounts && providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills,
Object.values(params.cfg.channels.slack.accounts).some( globalSetting: params.cfg.commands?.nativeSkills,
(a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "appToken"), })) ||
), (telegramConfigured &&
) || resolveNativeSkillsEnabled({
hasString(process.env.SLACK_BOT_TOKEN) || providerId: "telegram",
hasString(process.env.SLACK_APP_TOKEN); providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills,
globalSetting: params.cfg.commands?.nativeSkills,
const skillCommandsLikelyExposed = })) ||
(discordConfigured && (slackConfigured &&
resolveNativeSkillsEnabled({ resolveNativeSkillsEnabled({
providerId: "discord", providerId: "slack",
providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills, providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills,
globalSetting: params.cfg.commands?.nativeSkills, globalSetting: params.cfg.commands?.nativeSkills,
})) ||
(telegramConfigured &&
resolveNativeSkillsEnabled({
providerId: "telegram",
providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills,
globalSetting: params.cfg.commands?.nativeSkills,
})) ||
(slackConfigured &&
resolveNativeSkillsEnabled({
providerId: "slack",
providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills,
globalSetting: params.cfg.commands?.nativeSkills,
}));
findings.push({
checkId: "plugins.extensions_no_allowlist",
severity: skillCommandsLikelyExposed ? "critical" : "warn",
title: "Extensions exist but plugins.allow is not set",
detail:
`Found ${pluginDirs.length} extension(s) under ${extensionsDir}. Without plugins.allow, any discovered plugin id may load (depending on config and plugin behavior).` +
(skillCommandsLikelyExposed
? "\nNative skill commands are enabled on at least one configured chat surface; treat unpinned/unallowlisted extensions as high risk."
: ""),
remediation: "Set plugins.allow to an explicit list of plugin ids you trust.",
});
}
const enabledExtensionPluginIds = resolveEnabledExtensionPluginIds({
cfg: params.cfg,
pluginDirs,
});
if (enabledExtensionPluginIds.length > 0) {
const enabledPluginSet = new Set(enabledExtensionPluginIds);
const contexts: Array<{
label: string;
agentId?: string;
tools?: AgentToolsConfig;
}> = [{ label: "default" }];
for (const entry of params.cfg.agents?.list ?? []) {
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
continue;
}
contexts.push({
label: `agents.list.${entry.id}`,
agentId: entry.id,
tools: entry.tools,
});
}
const permissiveContexts: string[] = [];
for (const context of contexts) {
const profile = context.tools?.profile ?? params.cfg.tools?.profile;
const restrictiveProfile = Boolean(resolveToolProfilePolicy(profile));
const sandboxMode = resolveSandboxConfigForAgent(params.cfg, context.agentId).mode;
const policies = resolveToolPolicies({
cfg: params.cfg,
agentTools: context.tools,
sandboxMode,
agentId: context.agentId,
});
const broadPolicy = isToolAllowedByPolicies("__openclaw_plugin_probe__", policies);
const explicitPluginAllow =
!restrictiveProfile &&
(hasExplicitPluginAllow({
allowEntries: collectAllowEntries(params.cfg.tools),
enabledPluginIds: enabledPluginSet,
}) ||
hasProviderPluginAllow({
byProvider: params.cfg.tools?.byProvider,
enabledPluginIds: enabledPluginSet,
}) ||
hasExplicitPluginAllow({
allowEntries: collectAllowEntries(context.tools),
enabledPluginIds: enabledPluginSet,
}) ||
hasProviderPluginAllow({
byProvider: context.tools?.byProvider,
enabledPluginIds: enabledPluginSet,
})); }));
if (broadPolicy || explicitPluginAllow) { findings.push({
permissiveContexts.push(context.label); checkId: "plugins.extensions_no_allowlist",
} severity: skillCommandsLikelyExposed ? "critical" : "warn",
title: "Extensions exist but plugins.allow is not set",
detail:
`Found ${pluginDirs.length} extension(s) under ${extensionsDir}. Without plugins.allow, any discovered plugin id may load (depending on config and plugin behavior).` +
(skillCommandsLikelyExposed
? "\nNative skill commands are enabled on at least one configured chat surface; treat unpinned/unallowlisted extensions as high risk."
: ""),
remediation: "Set plugins.allow to an explicit list of plugin ids you trust.",
});
} }
if (permissiveContexts.length > 0) { const enabledExtensionPluginIds = resolveEnabledExtensionPluginIds({
cfg: params.cfg,
pluginDirs,
});
if (enabledExtensionPluginIds.length > 0) {
const enabledPluginSet = new Set(enabledExtensionPluginIds);
const contexts: Array<{
label: string;
agentId?: string;
tools?: AgentToolsConfig;
}> = [{ label: "default" }];
for (const entry of params.cfg.agents?.list ?? []) {
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
continue;
}
contexts.push({
label: `agents.list.${entry.id}`,
agentId: entry.id,
tools: entry.tools,
});
}
const permissiveContexts: string[] = [];
for (const context of contexts) {
const profile = context.tools?.profile ?? params.cfg.tools?.profile;
const restrictiveProfile = Boolean(resolveToolProfilePolicy(profile));
const sandboxMode = resolveSandboxConfigForAgent(params.cfg, context.agentId).mode;
const policies = resolveToolPolicies({
cfg: params.cfg,
agentTools: context.tools,
sandboxMode,
agentId: context.agentId,
});
const broadPolicy = isToolAllowedByPolicies("__openclaw_plugin_probe__", policies);
const explicitPluginAllow =
!restrictiveProfile &&
(hasExplicitPluginAllow({
allowEntries: collectAllowEntries(params.cfg.tools),
enabledPluginIds: enabledPluginSet,
}) ||
hasProviderPluginAllow({
byProvider: params.cfg.tools?.byProvider,
enabledPluginIds: enabledPluginSet,
}) ||
hasExplicitPluginAllow({
allowEntries: collectAllowEntries(context.tools),
enabledPluginIds: enabledPluginSet,
}) ||
hasProviderPluginAllow({
byProvider: context.tools?.byProvider,
enabledPluginIds: enabledPluginSet,
}));
if (broadPolicy || explicitPluginAllow) {
permissiveContexts.push(context.label);
}
}
if (permissiveContexts.length > 0) {
findings.push({
checkId: "plugins.tools_reachable_permissive_policy",
severity: "warn",
title: "Extension plugin tools may be reachable under permissive tool policy",
detail:
`Enabled extension plugins: ${enabledExtensionPluginIds.join(", ")}.\n` +
`Permissive tool policy contexts:\n${permissiveContexts.map((entry) => `- ${entry}`).join("\n")}`,
remediation:
"Use restrictive profiles (`minimal`/`coding`) or explicit tool allowlists that exclude plugin tools for agents handling untrusted input.",
});
}
}
}
const pluginInstalls = params.cfg.plugins?.installs ?? {};
const npmPluginInstalls = Object.entries(pluginInstalls).filter(
([, record]) => record?.source === "npm",
);
if (npmPluginInstalls.length > 0) {
const unpinned = npmPluginInstalls
.filter(([, record]) => typeof record.spec === "string" && !isPinnedRegistrySpec(record.spec))
.map(([pluginId, record]) => `${pluginId} (${record.spec})`);
if (unpinned.length > 0) {
findings.push({ findings.push({
checkId: "plugins.tools_reachable_permissive_policy", checkId: "plugins.installs_unpinned_npm_specs",
severity: "warn", severity: "warn",
title: "Extension plugin tools may be reachable under permissive tool policy", title: "Plugin installs include unpinned npm specs",
detail: detail: `Unpinned plugin install records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`,
`Enabled extension plugins: ${enabledExtensionPluginIds.join(", ")}.\n` +
`Permissive tool policy contexts:\n${permissiveContexts.map((entry) => `- ${entry}`).join("\n")}`,
remediation: remediation:
"Use restrictive profiles (`minimal`/`coding`) or explicit tool allowlists that exclude plugin tools for agents handling untrusted input.", "Pin install specs to exact versions (for example, `@scope/pkg@1.2.3`) for higher supply-chain stability.",
});
}
const missingIntegrity = npmPluginInstalls
.filter(
([, record]) => typeof record.integrity !== "string" || record.integrity.trim() === "",
)
.map(([pluginId]) => pluginId);
if (missingIntegrity.length > 0) {
findings.push({
checkId: "plugins.installs_missing_integrity",
severity: "warn",
title: "Plugin installs are missing integrity metadata",
detail: `Plugin install records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`,
remediation:
"Reinstall or update plugins to refresh install metadata with resolved integrity hashes.",
});
}
const pluginVersionDrift: string[] = [];
for (const [pluginId, record] of npmPluginInstalls) {
const recordedVersion = record.resolvedVersion ?? record.version;
if (!recordedVersion) {
continue;
}
const installPath = record.installPath ?? path.join(params.stateDir, "extensions", pluginId);
// eslint-disable-next-line no-await-in-loop
const installedVersion = await readInstalledPackageVersion(installPath);
if (!installedVersion || installedVersion === recordedVersion) {
continue;
}
pluginVersionDrift.push(
`${pluginId} (recorded ${recordedVersion}, installed ${installedVersion})`,
);
}
if (pluginVersionDrift.length > 0) {
findings.push({
checkId: "plugins.installs_version_drift",
severity: "warn",
title: "Plugin install records drift from installed package versions",
detail: `Detected plugin install metadata drift:\n${pluginVersionDrift.map((entry) => `- ${entry}`).join("\n")}`,
remediation:
"Run `openclaw plugins update --all` (or reinstall affected plugins) to refresh install metadata.",
});
}
}
const hookInstalls = params.cfg.hooks?.internal?.installs ?? {};
const npmHookInstalls = Object.entries(hookInstalls).filter(
([, record]) => record?.source === "npm",
);
if (npmHookInstalls.length > 0) {
const unpinned = npmHookInstalls
.filter(([, record]) => typeof record.spec === "string" && !isPinnedRegistrySpec(record.spec))
.map(([hookId, record]) => `${hookId} (${record.spec})`);
if (unpinned.length > 0) {
findings.push({
checkId: "hooks.installs_unpinned_npm_specs",
severity: "warn",
title: "Hook installs include unpinned npm specs",
detail: `Unpinned hook install records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`,
remediation:
"Pin hook install specs to exact versions (for example, `@scope/pkg@1.2.3`) for higher supply-chain stability.",
});
}
const missingIntegrity = npmHookInstalls
.filter(
([, record]) => typeof record.integrity !== "string" || record.integrity.trim() === "",
)
.map(([hookId]) => hookId);
if (missingIntegrity.length > 0) {
findings.push({
checkId: "hooks.installs_missing_integrity",
severity: "warn",
title: "Hook installs are missing integrity metadata",
detail: `Hook install records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`,
remediation:
"Reinstall or update hooks to refresh install metadata with resolved integrity hashes.",
});
}
const hookVersionDrift: string[] = [];
for (const [hookId, record] of npmHookInstalls) {
const recordedVersion = record.resolvedVersion ?? record.version;
if (!recordedVersion) {
continue;
}
const installPath = record.installPath ?? path.join(params.stateDir, "hooks", hookId);
// eslint-disable-next-line no-await-in-loop
const installedVersion = await readInstalledPackageVersion(installPath);
if (!installedVersion || installedVersion === recordedVersion) {
continue;
}
hookVersionDrift.push(
`${hookId} (recorded ${recordedVersion}, installed ${installedVersion})`,
);
}
if (hookVersionDrift.length > 0) {
findings.push({
checkId: "hooks.installs_version_drift",
severity: "warn",
title: "Hook install records drift from installed package versions",
detail: `Detected hook install metadata drift:\n${hookVersionDrift.map((entry) => `- ${entry}`).join("\n")}`,
remediation:
"Run `openclaw hooks update --all` (or reinstall affected hooks) to refresh install metadata.",
}); });
} }
} }

View File

@@ -4,9 +4,9 @@ import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js";
import { collectPluginsCodeSafetyFindings } from "./audit-extra.js"; import { collectPluginsCodeSafetyFindings } from "./audit-extra.js";
import { runSecurityAudit } from "./audit.js"; import { runSecurityAudit } from "./audit.js";
import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js";
import * as skillScanner from "./skill-scanner.js"; import * as skillScanner from "./skill-scanner.js";
const isWindows = process.platform === "win32"; const isWindows = process.platform === "win32";
@@ -1502,6 +1502,143 @@ describe("security audit", () => {
} }
}); });
it("warns on unpinned npm install specs and missing integrity metadata", async () => {
const tmp = await makeTmpDir("install-metadata-warns");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const cfg: OpenClawConfig = {
plugins: {
installs: {
"voice-call": {
source: "npm",
spec: "@openclaw/voice-call",
},
},
},
hooks: {
internal: {
installs: {
"test-hooks": {
source: "npm",
spec: "@openclaw/test-hooks",
},
},
},
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
});
expect(hasFinding(res, "plugins.installs_unpinned_npm_specs", "warn")).toBe(true);
expect(hasFinding(res, "plugins.installs_missing_integrity", "warn")).toBe(true);
expect(hasFinding(res, "hooks.installs_unpinned_npm_specs", "warn")).toBe(true);
expect(hasFinding(res, "hooks.installs_missing_integrity", "warn")).toBe(true);
});
it("does not warn on pinned npm install specs with integrity metadata", async () => {
const tmp = await makeTmpDir("install-metadata-clean");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const cfg: OpenClawConfig = {
plugins: {
installs: {
"voice-call": {
source: "npm",
spec: "@openclaw/voice-call@1.2.3",
integrity: "sha512-plugin",
},
},
},
hooks: {
internal: {
installs: {
"test-hooks": {
source: "npm",
spec: "@openclaw/test-hooks@1.2.3",
integrity: "sha512-hook",
},
},
},
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
});
expect(hasFinding(res, "plugins.installs_unpinned_npm_specs")).toBe(false);
expect(hasFinding(res, "plugins.installs_missing_integrity")).toBe(false);
expect(hasFinding(res, "hooks.installs_unpinned_npm_specs")).toBe(false);
expect(hasFinding(res, "hooks.installs_missing_integrity")).toBe(false);
});
it("warns when install records drift from installed package versions", async () => {
const tmp = await makeTmpDir("install-version-drift");
const stateDir = path.join(tmp, "state");
const pluginDir = path.join(stateDir, "extensions", "voice-call");
const hookDir = path.join(stateDir, "hooks", "test-hooks");
await fs.mkdir(pluginDir, { recursive: true });
await fs.mkdir(hookDir, { recursive: true });
await fs.writeFile(
path.join(pluginDir, "package.json"),
JSON.stringify({ name: "@openclaw/voice-call", version: "9.9.9" }),
"utf-8",
);
await fs.writeFile(
path.join(hookDir, "package.json"),
JSON.stringify({ name: "@openclaw/test-hooks", version: "8.8.8" }),
"utf-8",
);
const cfg: OpenClawConfig = {
plugins: {
installs: {
"voice-call": {
source: "npm",
spec: "@openclaw/voice-call@1.2.3",
integrity: "sha512-plugin",
resolvedVersion: "1.2.3",
},
},
},
hooks: {
internal: {
installs: {
"test-hooks": {
source: "npm",
spec: "@openclaw/test-hooks@1.2.3",
integrity: "sha512-hook",
resolvedVersion: "1.2.3",
},
},
},
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
});
expect(hasFinding(res, "plugins.installs_version_drift", "warn")).toBe(true);
expect(hasFinding(res, "hooks.installs_version_drift", "warn")).toBe(true);
});
it("flags enabled extensions when tool policy can expose plugin tools", async () => { it("flags enabled extensions when tool policy can expose plugin tools", async () => {
const tmp = await makeTmpDir("plugins-reachable"); const tmp = await makeTmpDir("plugins-reachable");
const stateDir = path.join(tmp, "state"); const stateDir = path.join(tmp, "state");

View File

@@ -28,7 +28,7 @@ export function expectSingleNpmPackIgnoreScriptsCall(params: {
throw new Error("expected npm pack call"); throw new Error("expected npm pack call");
} }
const [argv, options] = packCall; const [argv, options] = packCall;
expect(argv).toEqual(["npm", "pack", params.expectedSpec, "--ignore-scripts"]); expect(argv).toEqual(["npm", "pack", params.expectedSpec, "--ignore-scripts", "--json"]);
const commandOptions = typeof options === "number" ? undefined : options; const commandOptions = typeof options === "number" ? undefined : options;
expect(commandOptions).toMatchObject({ env: { NPM_CONFIG_IGNORE_SCRIPTS: "true" } }); expect(commandOptions).toMatchObject({ env: { NPM_CONFIG_IGNORE_SCRIPTS: "true" } });
} }