fix(security): fail closed on missing npm integrity

This commit is contained in:
jesse-merhi
2026-08-11 00:45:56 +10:00
parent 30ba169e33
commit 1c040134b0
4 changed files with 55 additions and 1 deletions
+1 -1
View File
@@ -551,7 +551,7 @@ methods. Treat this as feature discovery, not a full enumeration of
<Accordion title="Plugin management">
- `plugins.list` (`operator.read`) returns the installed plugin inventory plus locally curated official picks, diagnostics, and whether the current install mode allows mutations.
- `plugins.search` (`operator.read`) searches installable ClawHub code-plugin and bundle-plugin families. Pass non-empty `query` and optional `limit` from 1 to 100.
- `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId, installPolicyWarningAcknowledgement? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk?, installPolicyWarningAcknowledgement? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. When install policy warns and the Gateway can bind the request to an immutable resolved artifact, the request fails before commit with structured `error.details` containing `installPolicyCode`, target metadata, `reason`, optional `findings`, and a server-issued `acknowledgementToken`. After showing those details, a client may retry with that token as `installPolicyWarningAcknowledgement`. The Gateway consumes the token once and only for the same install request and resolved artifact; policy then re-evaluates the staged source and continues only when the warning is unchanged. A changed or later warning fails that request with its own structured details and, when the artifact remains immutably resolved, a fresh token for another reviewed retry. A block or a warning without immutable resolution metadata is terminal and does not include an acknowledgement token. Successful installs require a Gateway restart.
- `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId, installPolicyWarningAcknowledgement? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk?, installPolicyWarningAcknowledgement? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. When install policy warns and the Gateway can bind the request to an immutable resolved artifact, the request fails before commit with structured `error.details` containing `installPolicyCode: "install_policy_warning_acknowledgement_required"`, target metadata, `reason`, optional `findings`, and a server-issued `acknowledgementToken`. After showing those details, a client may retry with that token as `installPolicyWarningAcknowledgement`. The Gateway consumes the token once and only for the same install request and resolved artifact; policy then re-evaluates the staged source and continues only when the warning is unchanged. Tokens expire after five minutes and are invalidated by a Gateway restart; after either event, retry without the token to receive the current warning and a fresh token. A changed or later warning fails that request with its own structured details and, when the artifact remains immutably resolved, a fresh token for another reviewed retry. A block or a warning without immutable resolution metadata is terminal and does not include an acknowledgement token. Successful installs require a Gateway restart.
- `plugins.setEnabled` (`operator.admin`) changes one installed plugin's enabled policy with `{ pluginId, enabled }`. The response includes the updated catalog entry, restart metadata, and any slot-selection warnings.
- `plugins.uninstall` (`operator.admin`) removes one externally installed plugin with `{ pluginId }`: config references, the install record, and managed files. Bundled plugins cannot be uninstalled, only disabled. The response lists the removal actions and always requires a Gateway restart.
+24
View File
@@ -57,4 +57,28 @@ describe("resolveNpmIntegrityDrift", () => {
"Integrity drift detected for @openclaw/test@1.0.0: expected sha512-old, got sha512-new",
);
});
it("fails closed when a pinned resolution omits fresh integrity metadata", async () => {
const warn = vi.fn();
const onIntegrityDrift = vi.fn(async () => true);
const result = await resolveNpmIntegrityDriftWithDefaultMessage({
spec: "@openclaw/test@1.0.0",
expectedIntegrity: "sha512-reviewed",
resolution: {
resolvedSpec: "@openclaw/test@1.0.0",
resolvedAt: "2026-01-01T00:00:00.000Z",
},
onIntegrityDrift,
warn,
});
expect(result).toEqual({
error: "aborted: npm package integrity metadata missing for @openclaw/test@1.0.0",
});
expect(onIntegrityDrift).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
"Integrity metadata missing for @openclaw/test@1.0.0: expected sha512-reviewed",
);
});
});
+8
View File
@@ -86,6 +86,14 @@ type ResolveNpmIntegrityDriftWithDefaultMessageParams = {
export async function resolveNpmIntegrityDriftWithDefaultMessage(
params: ResolveNpmIntegrityDriftWithDefaultMessageParams,
): Promise<{ integrityDrift?: NpmIntegrityDrift; error?: string }> {
const expectedIntegrity = normalizeIntegrity(params.expectedIntegrity);
const resolvedLabel = params.resolution.resolvedSpec ?? params.spec;
if (expectedIntegrity && !normalizeIntegrity(params.resolution.integrity)) {
params.warn?.(`Integrity metadata missing for ${resolvedLabel}: expected ${expectedIntegrity}`);
return {
error: `aborted: npm package integrity metadata missing for ${resolvedLabel}`,
};
}
const driftResult = await resolveNpmIntegrityDrift<NpmIntegrityDriftPayload>({
spec: params.spec,
expectedIntegrity: params.expectedIntegrity,
+22
View File
@@ -3612,6 +3612,28 @@ describe("installPluginFromNpmSpec", () => {
});
});
it("fails closed when a pinned npm retry cannot resolve fresh integrity metadata", async () => {
mockNpmViewMetadataResult(runCommandWithTimeoutMock, {
name: "@openclaw/voice-call",
version: "0.0.1",
shasum: "reviewedshasum",
});
const onIntegrityDrift = vi.fn(async () => true);
const result = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call@0.0.1",
expectedIntegrity: "sha512-reviewed",
onIntegrityDrift,
});
expect(result).toEqual({
ok: false,
error: "aborted: npm package integrity metadata missing for @openclaw/voice-call@0.0.1",
});
expect(onIntegrityDrift).not.toHaveBeenCalled();
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
});
it("classifies npm package-not-found errors with a stable error code", async () => {
runCommandWithTimeoutMock.mockResolvedValue({
code: 1,