From ca8ab2a010de12116d6fd1b3aa439fc019adca40 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:50:35 +1000 Subject: [PATCH] feat(ui): add install policy warning review --- ui/src/i18n/locales/en.ts | 15 ++ .../plugins/install-policy-warning.test.ts | 63 +++++ .../pages/plugins/install-policy-warning.ts | 14 ++ ui/src/pages/plugins/plugins-page.ts | 11 + ui/src/pages/plugins/plugins.e2e.test.ts | 82 +++++++ ui/src/pages/plugins/view.test.ts | 85 +++++++ ui/src/pages/plugins/view.ts | 140 ++++++++++- ui/src/styles/plugins.css | 225 ++++++++++++++++++ 8 files changed, 633 insertions(+), 2 deletions(-) create mode 100644 ui/src/pages/plugins/install-policy-warning.test.ts create mode 100644 ui/src/pages/plugins/install-policy-warning.ts diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 126dda1fe4e6..33d30767159c 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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.", diff --git a/ui/src/pages/plugins/install-policy-warning.test.ts b/ui/src/pages/plugins/install-policy-warning.test.ts new file mode 100644 index 000000000000..d82192d4b775 --- /dev/null +++ b/ui/src/pages/plugins/install-policy-warning.test.ts @@ -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(); + }); +}); diff --git a/ui/src/pages/plugins/install-policy-warning.ts b/ui/src/pages/plugins/install-policy-warning.ts new file mode 100644 index 000000000000..358c97666261 --- /dev/null +++ b/ui/src/pages/plugins/install-policy-warning.ts @@ -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); +} diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 353666edd85b..af73ef015c6e 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -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), diff --git a/ui/src/pages/plugins/plugins.e2e.test.ts b/ui/src/pages/plugins/plugins.e2e.test.ts index d9f9b493b557..8807979952f5 100644 --- a/ui/src/pages/plugins/plugins.e2e.test.ts +++ b/ui/src/pages/plugins/plugins.e2e.test.ts @@ -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(); diff --git a/ui/src/pages/plugins/view.test.ts b/ui/src/pages/plugins/view.test.ts index 2bc2667b86f7..8c059e5f21d1 100644 --- a/ui/src/pages/plugins/view.test.ts +++ b/ui/src/pages/plugins/view.test.ts @@ -65,6 +65,7 @@ function createProps(overrides: Partial = {}): 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('[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(".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({ diff --git a/ui/src/pages/plugins/view.ts b/ui/src/pages/plugins/view.ts index 179ac4b26b8a..4694f13738b2 100644 --- a/ui/src/pages/plugins/view.ts +++ b/ui/src/pages/plugins/view.ts @@ -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` + + `; + } const role = message.kind === "error" ? "alert" : "status"; return html`
@@ -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']", + ), ); } diff --git a/ui/src/styles/plugins.css b/ui/src/styles/plugins.css index 3e59f0b3f8b5..2b5e72c3495a 100644 --- a/ui/src/styles/plugins.css +++ b/ui/src/styles/plugins.css @@ -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; + } }