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

@@ -1,9 +1,10 @@
import type { Command } from "commander";
import fs from "node:fs";
import fsp from "node:fs/promises";
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 { HookEntry } from "../hooks/types.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig, writeConfigFile } from "../config/io.js";
import {
buildWorkspaceHookStatus,
@@ -16,7 +17,6 @@ import {
resolveHookInstallDir,
} from "../hooks/install.js";
import { recordHookInstall } from "../hooks/installs.js";
import type { HookEntry } from "../hooks/types.js";
import { loadWorkspaceHookEntries } from "../hooks/workspace.js";
import { resolveArchiveKind } from "../infra/archive.js";
import { buildPluginStatusReport } from "../plugins/status.js";
@@ -26,6 +26,7 @@ import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
import { promptYesNo } from "./prompt.js";
export type HooksListOptions = {
json?: boolean;
@@ -550,7 +551,8 @@ export function registerHooksCli(program: Command): void {
.description("Install a hook pack (path, archive, or npm 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)
.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 cfg = loadConfig();
@@ -658,13 +660,29 @@ export function registerHooksCli(program: Command): void {
}
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, {
hookId: result.hookPackId,
source: "npm",
spec: raw,
spec: recordSpec,
installPath: result.targetDir,
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,
});
await writeConfigFile(next);
@@ -721,6 +739,18 @@ export function registerHooksCli(program: Command): void {
mode: "update",
dryRun: true,
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(),
});
if (!probe.ok) {
@@ -742,6 +772,18 @@ export function registerHooksCli(program: Command): void {
spec: record.spec,
mode: "update",
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(),
});
if (!result.ok) {
@@ -756,6 +798,12 @@ export function registerHooksCli(program: Command): void {
spec: record.spec,
installPath: result.targetDir,
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,
});
updatedCount += 1;

View File

@@ -1,15 +1,15 @@
import type { Command } from "commander";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Command } from "commander";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginRecord } from "../plugins/registry.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveArchiveKind } from "../infra/archive.js";
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import type { PluginRecord } from "../plugins/registry.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.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)")
.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)
.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);
if (fileSpec && !fileSpec.ok) {
defaultRuntime.error(fileSpec.error);
@@ -648,12 +649,28 @@ export function registerPluginsCli(program: Command) {
clearPluginManifestRegistryCache();
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, {
pluginId: result.pluginId,
source: "npm",
spec: raw,
spec: recordSpec,
installPath: result.targetDir,
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);
next = slotResult.config;
@@ -691,6 +708,20 @@ export function registerPluginsCli(program: Command) {
info: (msg) => defaultRuntime.log(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) {