feat(ui): add install policy warning review

This commit is contained in:
Jesse Merhi
2026-08-10 00:50:35 +10:00
committed by jesse-merhi
parent 5ca8f5ccd7
commit ca8ab2a010
8 changed files with 633 additions and 2 deletions
+15
View File
@@ -2797,6 +2797,21 @@ export const en: TranslationMap = {
installNamed: "Install {name}",
acknowledgeRisk: "Acknowledge risk and install",
defaultRiskWarning: "Review the ClawHub warning before installing this plugin.",
policyReviewTitle: "Security review needed",
policyReviewBodyUnknown: "Your install policy flagged this plugin. It has not been installed.",
policyReviewBodyOne: "Your install policy found 1 warning. This plugin has not been installed.",
policyReviewBodyMany:
"Your install policy found {count} warnings. This plugin has not been installed.",
policyReviewReason: "Why it was flagged",
policyReviewFindings: "Policy findings",
policyReviewFindingCount: "{count} found",
policyReviewTechnicalDetails: "Scan details",
policyReviewPolicyResponse: "Policy response",
policyReviewRule: "Rule {count}",
policyReviewLocation: "Location",
policyReviewEvidence: "Evidence",
policyReviewGuidance: "Continue only if you trust this plugin.",
installAnyway: "Install anyway",
connectToChange: "Connect to the gateway to change plugins.",
adminRequired: "Browsing only. Plugin changes require operator.admin access.",
changesDisabled: "Browsing only. This gateway does not allow plugin changes.",
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { GatewayRequestError } from "../../api/gateway.ts";
import { readPluginInstallPolicyWarning } from "./install-policy-warning.ts";
describe("readPluginInstallPolicyWarning", () => {
it("parses structured policy warnings", () => {
const error = new GatewayRequestError({
code: "INVALID_REQUEST",
message: "Install requires approval",
details: {
installPolicyCode: "install_policy_warning_acknowledgement_required",
targetName: " openclaw-kitchen-sink-fixture ",
targetType: "plugin",
requestMode: "install",
reason: " ClawScan found issues to review. ",
findings: [
{
ruleId: "semgrep-finding",
severity: "warn",
message: "Semgrep found a risky command.",
file: "index.ts",
line: 12,
},
],
futureField: true,
},
});
expect(readPluginInstallPolicyWarning(error)).toEqual({
installPolicyCode: "install_policy_warning_acknowledgement_required",
targetName: "openclaw-kitchen-sink-fixture",
targetType: "plugin",
requestMode: "install",
reason: "ClawScan found issues to review.",
findings: [
{
ruleId: "semgrep-finding",
severity: "warn",
message: "Semgrep found a risky command.",
file: "index.ts",
line: 12,
},
],
});
});
it("rejects malformed policy warning details", () => {
const error = new GatewayRequestError({
code: "INVALID_REQUEST",
message: "Install requires approval",
details: {
installPolicyCode: "install_policy_warning_acknowledgement_required",
targetName: "fixture",
targetType: "plugin",
requestMode: "install",
reason: "Review required.",
findings: [{ ruleId: "finding", severity: "warn", message: 42 }],
},
});
expect(readPluginInstallPolicyWarning(error)).toBeUndefined();
});
});
@@ -0,0 +1,14 @@
import type { InstallPolicyWarningErrorDetails } from "../../../../packages/gateway-protocol/src/install-policy-warning-error-details.js";
import { readInstallPolicyWarningErrorDetails } from "../../../../src/gateway/install-policy-warning-error-details.js";
import { GatewayRequestError } from "../../api/gateway.ts";
export type PluginInstallPolicyWarningDetails = InstallPolicyWarningErrorDetails;
export function readPluginInstallPolicyWarning(
error: unknown,
): InstallPolicyWarningErrorDetails | undefined {
if (!(error instanceof GatewayRequestError)) {
return undefined;
}
return readInstallPolicyWarningErrorDetails(error.details);
}
+11
View File
@@ -51,6 +51,7 @@ import {
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { fetchPluginIconBlobUrl } from "./icon-loader.ts";
import { readPluginInstallPolicyWarning } from "./install-policy-warning.ts";
import { PLUGINS_HUB_PANEL_ID, pluginsHubTabs, type PluginsHubTab } from "./plugins-hub.ts";
import type { ConnectorSuggestion } from "./presentation.ts";
import { pluginArtPath } from "./presentation.ts";
@@ -782,6 +783,15 @@ class PluginsPage extends OpenClawLightDomElement {
await this.refreshCatalogAfterMutation(client);
},
(error) => {
const policyWarning = readPluginInstallPolicyWarning(error);
if (policyWarning) {
this.setMessage(rowKey, {
kind: "warning",
text: policyWarning.reason,
installPolicyWarning: { details: policyWarning, request },
});
return;
}
const trust = readPluginInstallTrustError(error);
const packageName = request.source === "clawhub" ? request.packageName : null;
if (packageName && pluginInstallNeedsRiskAcknowledgement(error)) {
@@ -1030,6 +1040,7 @@ class PluginsPage extends OpenClawLightDomElement {
onSetEnabled: (pluginId, enabled, rowKey) =>
void this.updateEnabled(pluginId, enabled, rowKey),
onInstall: (rowKey, request) => void this.install(rowKey, request),
onDismissMessage: (rowKey) => this.setMessage(rowKey, null),
onRequestUninstall: (rowKey) => this.setPendingRemoval(rowKey, true),
onCancelUninstall: (rowKey) => this.setPendingRemoval(rowKey, false),
onUninstall: (pluginId, rowKey) => void this.uninstall(pluginId, rowKey),
+82
View File
@@ -150,6 +150,23 @@ const installResult = {
restartRequired: true,
} satisfies PluginMutationResult;
const installPolicyWarning = {
installPolicyCode: "install_policy_warning_acknowledgement_required",
targetName: "@openclaw/lobster",
targetType: "plugin",
requestMode: "install",
reason: "ClawScan found issues to review.",
findings: [
{
ruleId: "semgrep-finding",
severity: "warn",
message: "Semgrep found a risky command.",
file: "index.ts",
line: 12,
},
],
};
const enableWorkboardResult = {
ok: true,
plugin: workboardEnabled,
@@ -643,6 +660,71 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
}
});
it("reviews an install policy warning before sending an acknowledged retry", async () => {
const context = await newContext();
const page = await context.newPage();
const gateway = await installMockGateway(page, {
featureMethods: pluginMethods,
methodResponses: pluginMethodResponses(),
});
try {
await page.goto(`${server.baseUrl}settings/plugins`);
await page.getByRole("tab", { name: /^Discover/u }).click();
const row = page.locator('[data-plugin-id="lobster"]');
await row.waitFor({ state: "visible" });
await gateway.deferNext("plugins.install");
await row.getByRole("button", { name: "Install Lobster", exact: true }).click();
expect(requestParams(await gateway.waitForRequest("plugins.install"))).toEqual({
source: "clawhub",
packageName: "@openclaw/lobster",
});
await gateway.rejectDeferred("plugins.install", {
code: "INVALID_REQUEST",
message: "raw terminal install-policy output",
details: installPolicyWarning,
});
const review = row.getByRole("alert");
await review.waitFor({ state: "visible" });
expect(await review.textContent()).toContain("Security review needed");
expect(await review.textContent()).toContain("Your install policy found 1 warning");
expect(await review.textContent()).toContain("This plugin has not been installed");
expect(await review.textContent()).toContain("Semgrep found a risky command.");
expect(await review.textContent()).not.toContain("raw terminal install-policy output");
await captureScreenshot(page, "09-policy-review-desktop.png");
const installCountBeforeCancel = (await gateway.getRequests("plugins.install")).length;
await review.getByRole("button", { name: "Cancel", exact: true }).click();
await review.waitFor({ state: "detached" });
expect((await gateway.getRequests("plugins.install")).length).toBe(installCountBeforeCancel);
const installCountBeforeSecondAttempt = (await gateway.getRequests("plugins.install")).length;
await gateway.deferNext("plugins.install");
await row.getByRole("button", { name: "Install Lobster", exact: true }).click();
await waitForNextRequest(gateway, "plugins.install", installCountBeforeSecondAttempt);
await gateway.rejectDeferred("plugins.install", {
code: "INVALID_REQUEST",
message: "raw terminal install-policy output",
details: installPolicyWarning,
});
await review.waitFor({ state: "visible" });
const installCountBeforeRetry = (await gateway.getRequests("plugins.install")).length;
await gateway.deferNext("plugins.install");
await review.getByRole("button", { name: "Install anyway", exact: true }).click();
const retry = await waitForNextRequest(gateway, "plugins.install", installCountBeforeRetry);
expect(requestParams(retry)).toEqual({
source: "clawhub",
packageName: "@openclaw/lobster",
acknowledgeInstallPolicyWarning: true,
});
} finally {
await context.close();
}
});
it("keeps plugin mutations unavailable to read-only operators while browse and search work", async () => {
const context = await newContext();
const page = await context.newPage();
+85
View File
@@ -65,6 +65,7 @@ function createProps(overrides: Partial<PluginsViewProps> = {}): PluginsViewProp
onShowDetails: () => undefined,
onSetEnabled: () => undefined,
onInstall: () => undefined,
onDismissMessage: () => undefined,
onRequestUninstall: () => undefined,
onCancelUninstall: () => undefined,
onUninstall: () => undefined,
@@ -659,6 +660,90 @@ describe("renderPlugins", () => {
});
});
it("renders install policy findings with cancel and acknowledged retry actions", () => {
const plugin = createPlugin({
id: "kitchen-sink",
name: "OpenClaw Kitchen Sink",
installed: false,
enabled: false,
state: "disabled",
install: { source: "official", pluginId: "kitchen-sink" },
});
const key = pluginRowKey(plugin.id);
const onInstall = vi.fn();
const onDismissMessage = vi.fn();
const onShowDetails = vi.fn();
const request = { source: "official" as const, pluginId: "kitchen-sink" };
const container = mount(
createProps({
activeTab: "discover",
result: createResult([plugin]),
messages: {
[key]: {
kind: "warning",
text: "ClawScan found issues to review.",
installPolicyWarning: {
request,
details: {
installPolicyCode: "install_policy_warning_acknowledgement_required",
targetName: "openclaw-kitchen-sink-fixture",
targetType: "plugin",
requestMode: "install",
reason: "ClawScan found issues to review.",
findings: [
{
ruleId: "semgrep-finding",
severity: "warn",
message: "Semgrep found a risky command.",
file: "index.ts",
line: 12,
},
],
},
},
},
},
onInstall,
onDismissMessage,
onShowDetails,
}),
);
const row = expectDefined(
container.querySelector<HTMLElement>('[data-plugin-id="kitchen-sink"]'),
"kitchen sink plugin row",
);
const alert = expectDefined(row.querySelector('[role="alert"]'), "install policy warning");
expect(normalizedText(alert)).toContain("Security review needed");
expect(normalizedText(alert)).toContain("Your install policy found 1 warning");
expect(normalizedText(alert)).toContain("This plugin has not been installed");
expect(normalizedText(alert)).toContain("Policy findings");
expect(normalizedText(alert)).toContain("Semgrep found a risky command.");
const technicalDetails = expectDefined(
alert.querySelector<HTMLDetailsElement>(".plugins-policy-review__details"),
"install policy scan details",
);
expect(technicalDetails.open).toBe(false);
expect(normalizedText(technicalDetails.querySelector("summary"))).toBe("Scan details");
expect(
technicalDetails?.querySelector(".plugins-policy-review__details-chevron svg"),
).not.toBeNull();
expect(normalizedText(technicalDetails)).toContain("semgrep-finding");
expect(normalizedText(technicalDetails)).toContain("index.ts:12");
technicalDetails.querySelector("summary")?.click();
expect(technicalDetails.open).toBe(true);
expect(onShowDetails).not.toHaveBeenCalled();
actionButton(alert, "Cancel")?.click();
expect(onDismissMessage).toHaveBeenCalledWith(key);
actionButton(alert, "Install anyway")?.click();
expect(onInstall).toHaveBeenCalledWith(key, {
...request,
acknowledgeInstallPolicyWarning: true,
});
});
it("correlates installed ClawHub packages without a search runtime id", () => {
const packageName = "@community/calendar-plus";
const installed = createPlugin({
+138 -2
View File
@@ -28,6 +28,7 @@ import {
type PluginListResult,
type PluginSearchResult,
} from "../../lib/plugins/index.ts";
import type { PluginInstallPolicyWarningDetails } from "./install-policy-warning.ts";
import {
CONNECTOR_GROUP_ORDER,
CONNECTOR_SUGGESTIONS,
@@ -45,9 +46,13 @@ export type PluginsTab = "installed" | "discover";
export type InstalledFilter = "all" | "enabled" | "disabled" | "issues";
export type PluginRowMessage = {
kind: "success" | "error";
kind: "success" | "error" | "warning";
text: string;
acknowledge?: { packageName: string; version?: string };
installPolicyWarning?: {
details: PluginInstallPolicyWarningDetails;
request: PluginInstallRequest;
};
};
type PluginsViewProps = {
@@ -81,6 +86,7 @@ type PluginsViewProps = {
onShowDetails: (pluginId: string | null) => void;
onSetEnabled: (pluginId: string, enabled: boolean, rowKey: string) => void;
onInstall: (rowKey: string, request: PluginInstallRequest) => void;
onDismissMessage: (rowKey: string) => void;
onRequestUninstall: (rowKey: string) => void;
onCancelUninstall: (rowKey: string) => void;
onUninstall: (pluginId: string, rowKey: string) => void;
@@ -378,6 +384,134 @@ function renderRowMessage(
if (!message) {
return nothing;
}
if (message.installPolicyWarning) {
const { details, request } = message.installPolicyWarning;
const findings = details.findings ?? [];
const reviewBody =
findings.length === 0
? t("pluginsPage.policyReviewBodyUnknown")
: findings.length === 1
? t("pluginsPage.policyReviewBodyOne")
: t("pluginsPage.policyReviewBodyMany", { count: String(findings.length) });
return html`
<div class="plugins-row-message plugins-row-message--warning" role="alert">
<div class="plugins-policy-review__header">
<span class="plugins-policy-review__icon" aria-hidden="true">
${icons.alertTriangle}
</span>
<div>
<strong>${t("pluginsPage.policyReviewTitle")}</strong>
<span>${reviewBody}</span>
</div>
</div>
${findings.length > 0
? html`
<section class="plugins-policy-review__findings-panel">
<div class="plugins-policy-review__findings-heading">
<span>${t("pluginsPage.policyReviewFindings")}</span>
<span class="plugins-policy-review__count"
>${t("pluginsPage.policyReviewFindingCount", {
count: String(findings.length),
})}</span
>
</div>
<ul class="plugins-policy-review__findings">
${findings.map(
(finding) => html`
<li>
<span class="plugins-policy-review__finding-mark" aria-hidden="true"></span>
<span>${finding.message}</span>
</li>
`,
)}
</ul>
</section>
`
: html`
<section class="plugins-policy-review__findings-panel">
<span class="plugins-policy-review__findings-heading"
>${t("pluginsPage.policyReviewReason")}</span
>
<p class="plugins-policy-review__reason">${details.reason}</p>
</section>
`}
${findings.length > 0
? html`
<details class="plugins-policy-review__details">
<summary>
<span class="plugins-policy-review__details-chevron" aria-hidden="true"
>${icons.chevronRight}</span
>
<span>${t("pluginsPage.policyReviewTechnicalDetails")}</span>
</summary>
<dl>
<div>
<dt>${t("pluginsPage.policyReviewPolicyResponse")}</dt>
<dd>${details.reason}</dd>
</div>
${findings.map(
(finding, index) => html`
<div>
<dt>${t("pluginsPage.policyReviewRule", { count: String(index + 1) })}</dt>
<dd><code>${finding.ruleId}</code></dd>
</div>
${finding.file
? html`
<div>
<dt>${t("pluginsPage.policyReviewLocation")}</dt>
<dd>
<code
>${finding.file}${finding.line ? `:${finding.line}` : ""}</code
>
</dd>
</div>
`
: nothing}
${finding.evidence
? html`
<div>
<dt>${t("pluginsPage.policyReviewEvidence")}</dt>
<dd>${finding.evidence}</dd>
</div>
`
: nothing}
`,
)}
</dl>
</details>
`
: nothing}
<div class="plugins-policy-review__footer">
<span class="plugins-policy-review__guidance"
>${t("pluginsPage.policyReviewGuidance")}</span
>
<div class="plugins-policy-review__actions">
<button
type="button"
class="btn btn--sm"
?disabled=${busy}
@click=${() => props.onDismissMessage(key)}
>
${t("pluginsPage.cancel")}
</button>
<button
type="button"
class="btn btn--sm danger"
title=${props.mutationBlockedReason ?? ""}
?disabled=${busy || !props.canMutate}
@click=${() =>
props.onInstall(key, {
...request,
acknowledgeInstallPolicyWarning: true,
})}
>
${busy ? t("pluginsPage.installing") : t("pluginsPage.installAnyway")}
</button>
</div>
</div>
</div>
`;
}
const role = message.kind === "error" ? "alert" : "status";
return html`
<div class="plugins-row-message plugins-row-message--${message.kind}" role=${role}>
@@ -408,7 +542,9 @@ function renderRowMessage(
/** Ignore activations bubbling from interactive children so rows stay clickable. */
function fromInteractiveChild(event: Event): boolean {
return Boolean(
(event.target as HTMLElement | null)?.closest("button, a, input, label, form, [role='menu']"),
(event.target as HTMLElement | null)?.closest(
"button, a, input, label, form, summary, [role='menu']",
),
);
}
+225
View File
@@ -462,6 +462,213 @@ h3.plugins-subheader {
color: var(--danger);
}
.plugins-row-message--warning {
--plugins-policy-review-indent: calc(24px + var(--space-3));
align-items: stretch;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) 0 0;
border-top: 1px solid color-mix(in srgb, var(--warn) 28%, var(--border));
border-radius: 0;
background: transparent;
color: var(--text);
font-size: var(--control-ui-text-sm);
white-space: normal;
}
.plugins-policy-review__header {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
align-items: start;
gap: var(--space-3);
}
.plugins-policy-review__header > div {
display: grid;
gap: 1px;
}
.plugins-policy-review__icon {
display: inline-flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
color: var(--warn);
}
.plugins-policy-review__icon svg {
width: 18px;
height: 18px;
}
.plugins-policy-review__header strong {
color: var(--text-strong);
font-size: var(--control-ui-text-md);
line-height: 1.3;
}
.plugins-policy-review__header span,
.plugins-policy-review__guidance {
color: var(--muted);
}
.plugins-policy-review__findings-panel {
display: grid;
gap: var(--space-1);
margin-left: var(--plugins-policy-review-indent);
}
.plugins-policy-review__findings-heading {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-2);
color: var(--text-strong);
font-weight: 600;
}
.plugins-policy-review__count {
color: var(--muted);
font-size: var(--control-ui-text-xs);
font-weight: 600;
}
.plugins-policy-review__count::before {
margin-right: var(--space-2);
color: var(--border-hover);
content: "·";
}
.plugins-policy-review__findings {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.plugins-policy-review__findings li {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: start;
gap: var(--space-2);
padding: 0;
color: var(--text);
}
.plugins-policy-review__findings li + li {
margin-top: var(--space-2);
}
.plugins-policy-review__finding-mark {
width: 7px;
height: 7px;
margin-top: 6px;
border-radius: var(--radius-full);
background: var(--warn);
justify-self: center;
}
.plugins-policy-review__reason {
margin: 0;
}
.plugins-policy-review__details {
margin-left: var(--plugins-policy-review-indent);
color: var(--muted);
font-size: var(--control-ui-text-xs);
}
.plugins-policy-review__details summary {
display: flex;
width: fit-content;
min-height: 44px;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-weight: 600;
list-style: none;
}
.plugins-policy-review__details summary::-webkit-details-marker {
display: none;
}
.plugins-policy-review__details summary::marker {
content: "";
}
.plugins-policy-review__details-chevron {
display: inline-flex;
width: 18px;
height: 18px;
color: var(--muted);
transition: transform var(--duration-fast) var(--ease-in-out);
}
.plugins-policy-review__details-chevron svg {
width: 18px;
height: 18px;
}
.plugins-policy-review__details[open] .plugins-policy-review__details-chevron {
transform: rotate(90deg);
}
.plugins-policy-review__details summary:hover {
color: var(--text);
}
.plugins-policy-review__details summary:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.plugins-policy-review__details dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
margin: var(--space-2) 0 0;
}
.plugins-policy-review__details dl > div {
min-width: 0;
}
.plugins-policy-review__details dt {
margin-bottom: 2px;
}
.plugins-policy-review__details dd {
overflow-wrap: anywhere;
margin: 0;
color: var(--text);
}
.plugins-policy-review__details code {
font-size: inherit;
}
.plugins-policy-review__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) 0 0 var(--plugins-policy-review-indent);
border-top: 1px solid color-mix(in srgb, var(--warn) 20%, var(--border));
}
.plugins-policy-review__actions {
display: flex;
flex: 0 0 auto;
gap: var(--space-2);
}
.plugins-policy-review__actions .btn {
min-height: 44px;
}
.plugins-row-message .btn {
flex: 0 0 auto;
color: var(--text-strong);
@@ -716,4 +923,22 @@ h3.plugins-subheader {
align-items: stretch;
flex-direction: column;
}
.plugins-row-message--warning {
padding: var(--space-2) 0 0;
}
.plugins-policy-review__details dl {
grid-template-columns: minmax(0, 1fr);
}
.plugins-policy-review__footer {
align-items: stretch;
flex-direction: column;
}
.plugins-policy-review__actions,
.plugins-policy-review__actions .btn {
flex: 1 1 0;
}
}