fix(security): put every trust decision on a host-owned token surface (#30710)

* fix(security): put every trust dialog on a host-owned token surface

Plugin themes write CSS tokens on the document root, and dialogs portal to
body, so any trust surface without plugin-security-chrome inherits them; a
theme could hide what is being approved. The orca.yaml hook trust dialog,
notebook run confirmation, plugin install, remove and rollback dialogs were
missing it. DialogContent gains surface="security", which applies the
host-owned tokens, and a scrollable prop for the plugin dialogs' existing
viewport-height scrolling; every trust dialog uses the surface and a test
lists them. The style guide says when to use each.

* fix(security): keep inline trust decisions on the host-owned surface

The agent command approval card (Allow/Deny) and the browser certificate
override sit outside a dialog, so plugin theme tokens still reached them.
A SecuritySurface primitive puts its child on the same host-owned token set
as DialogContent surface="security"; both use it, and the security surface
test now lists inline trust surfaces as well as dialogs.
This commit is contained in:
Neil
2026-10-19 04:21:16 -07:00
committed by GitHub
parent e72d8f926a
commit 25e53f4b0c
14 changed files with 327 additions and 224 deletions
+2
View File
@@ -156,6 +156,8 @@ Sizes: `default` (36px), `sm` (32px), `xs` (24px), `lg` (40px), plus `icon`, `ic
Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI primitive — exceptions are `command` (wraps `cmdk`), `sonner` (wraps `sonner`), and the visual-only wrappers (`badge`, `button-group`, `card`, `input`) which apply tokens and Tailwind utilities directly. Never reimplement headless behavior; extend the existing wrapper.
Use `DialogContent surface="security"` for every dialog that asks the user to trust, approve, run or remove code, including plugin provenance and approval, so plugin themes cannot change the trust surface's tokens. Use its `scrollable` prop when the whole dialog scrolls. Wrap a trust decision outside a dialog, such as an agent approval card or a certificate override, in `SecuritySurface`.
### Picking the right primitive
When a control has multiple plausible primitives, use this fork:
@@ -1,3 +1,4 @@
import { SecuritySurface } from '@/components/ui/security-surface'
import { useEffect, useRef, useState } from 'react'
import { Copy, ExternalLink, Globe, Loader2, RefreshCw, ShieldAlert } from 'lucide-react'
@@ -261,107 +262,114 @@ export function BrowserLoadFailureOverlay({
) : null
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background px-6">
<div aria-live="polite" className="flex max-w-lg flex-col items-center px-8 py-8 text-center">
<div className="mb-4 rounded-full border border-border bg-muted p-3 text-muted-foreground">
{certificateError ? <ShieldAlert className="size-5" /> : <Globe className="size-5" />}
</div>
<h2 className="text-base font-semibold text-foreground">
{certificateError
? translate('browser.loadFailure.connectionNotSecure', "Connection isn't secure")
: guestRecoveryError
? translate('browser.guestRecovery.title', 'Browser page stopped')
: meta.host
? translate('browser.loadFailure.cantReachHost', "Can't reach {{value0}}", {
value0: meta.host
})
: translate('browser.loadFailure.cantLoadPage', "Can't load this page")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{formatLoadFailureDescription(presentationLoadError, meta)}
</p>
{certificateError && meta.isLocalhostLike ? (
<p className="mt-2 text-xs text-muted-foreground">
{translate(
'browser.loadFailure.trustedCertificateGuidance',
'For local development, use a trusted local certificate when possible.'
)}
<SecuritySurface>
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background px-6">
<div
aria-live="polite"
className="flex max-w-lg flex-col items-center px-8 py-8 text-center"
>
<div className="mb-4 rounded-full border border-border bg-muted p-3 text-muted-foreground">
{certificateError ? <ShieldAlert className="size-5" /> : <Globe className="size-5" />}
</div>
<h2 className="text-base font-semibold text-foreground">
{certificateError
? translate('browser.loadFailure.connectionNotSecure', "Connection isn't secure")
: guestRecoveryError
? translate('browser.guestRecovery.title', 'Browser page stopped')
: meta.host
? translate('browser.loadFailure.cantReachHost', "Can't reach {{value0}}", {
value0: meta.host
})
: translate('browser.loadFailure.cantLoadPage', "Can't load this page")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{formatLoadFailureDescription(presentationLoadError, meta)}
</p>
) : null}
{recoveryHint ? <p className="mt-2 text-xs text-muted-foreground">{recoveryHint}</p> : null}
{sshRoutedHint ? (
<p className="mt-2 text-xs text-muted-foreground">
{translate(
'browser.loadFailure.sshRoutedHint',
"This page browses through the workspace's SSH host — the host may be disconnected or unable to reach this site."
)}
{onRecheckSshRoute ? (
{certificateError && meta.isLocalhostLike ? (
<p className="mt-2 text-xs text-muted-foreground">
{translate(
'browser.loadFailure.trustedCertificateGuidance',
'For local development, use a trusted local certificate when possible.'
)}
</p>
) : null}
{recoveryHint ? (
<p className="mt-2 text-xs text-muted-foreground">{recoveryHint}</p>
) : null}
{sshRoutedHint ? (
<p className="mt-2 text-xs text-muted-foreground">
{translate(
'browser.loadFailure.sshRoutedHint',
"This page browses through the workspace's SSH host — the host may be disconnected or unable to reach this site."
)}
{onRecheckSshRoute ? (
<>
{' '}
<Button
type="button"
variant="link"
size="xs"
className="h-auto px-0 align-baseline"
data-testid="browser-load-failure-recheck-ssh-route"
onClick={onRecheckSshRoute}
>
{translate('browser.loadFailure.recheckSshRoute', 'Run connection check')}
</Button>
</>
) : null}
</p>
) : null}
{activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? (
<p role="alert" className="mt-3 text-xs text-destructive">
{formatCertificateProceedFailure(activeProceedAttempt.reason)}
</p>
) : null}
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
{certificateError ? (
<>
{' '}
<Button
type="button"
variant="link"
size="xs"
className="h-auto px-0 align-baseline"
data-testid="browser-load-failure-recheck-ssh-route"
onClick={onRecheckSshRoute}
>
{translate('browser.loadFailure.recheckSshRoute', 'Run connection check')}
</Button>
{externalButton}
{retryButton}
{copyButton}
{matchingCertificateFailure ? (
<Button
size="sm"
variant="outline"
className="h-9 w-48 gap-2 px-3"
disabled={actionsDisabled}
onClick={proceedCertificate}
>
{activeProceedAttempt?.state === 'submitting' &&
activeProceedAttempt.showConnecting ? (
<>
<Loader2 className="size-4 animate-spin" />
{translate('browser.loadFailure.connecting', 'Connecting…')}
</>
) : (
translate('browser.loadFailure.proceedUnsafe', 'Proceed Anyway (Unsafe)')
)}
</Button>
) : null}
</>
) : null}
</p>
) : null}
{activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? (
<p role="alert" className="mt-3 text-xs text-destructive">
{formatCertificateProceedFailure(activeProceedAttempt.reason)}
</p>
) : null}
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
{certificateError ? (
<>
{externalButton}
{retryButton}
{copyButton}
{matchingCertificateFailure ? (
<Button
size="sm"
variant="outline"
className="h-9 w-48 gap-2 px-3"
disabled={actionsDisabled}
onClick={proceedCertificate}
>
{activeProceedAttempt?.state === 'submitting' &&
activeProceedAttempt.showConnecting ? (
<>
<Loader2 className="size-4 animate-spin" />
{translate('browser.loadFailure.connecting', 'Connecting…')}
</>
) : (
translate('browser.loadFailure.proceedUnsafe', 'Proceed Anyway (Unsafe)')
)}
</Button>
) : null}
</>
) : (
<>
{httpsRecoveryUrl && !guestRecoveryError ? (
<Button
size="sm"
className="h-9 gap-2 px-3"
disabled={actionsDisabled}
onClick={() => onTryHttps(httpsRecoveryUrl)}
>
{translate('browser.loadFailure.tryHttps', 'Try HTTPS')}
</Button>
) : null}
{retryButton}
{copyButton}
{externalButton}
</>
)}
) : (
<>
{httpsRecoveryUrl && !guestRecoveryError ? (
<Button
size="sm"
className="h-9 gap-2 px-3"
disabled={actionsDisabled}
onClick={() => onTryHttps(httpsRecoveryUrl)}
>
{translate('browser.loadFailure.tryHttps', 'Try HTTPS')}
</Button>
) : null}
{retryButton}
{copyButton}
{externalButton}
</>
)}
</div>
</div>
</div>
</div>
</SecuritySurface>
)
}
@@ -255,7 +255,7 @@ export default function IpynbViewer({
}
}}
>
<DialogContent className="max-w-md sm:max-w-md" showCloseButton={false}>
<DialogContent surface="security" className="max-w-md sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">
{translate('auto.components.editor.IpynbViewer.9e06ae5d36', 'Run Notebook Code?')}
@@ -1,3 +1,4 @@
import { SecuritySurface } from '@/components/ui/security-surface'
import { useRef } from 'react'
import { ShieldQuestion, X } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -60,133 +61,136 @@ export function NativeChatApprovalCard({
const escape = onCancel ?? (isSubmitting ? undefined : onCollapse)
return (
<div className="min-h-0 shrink overflow-hidden bg-chat-canvas">
<div className="mx-auto flex h-full min-h-0 max-h-full w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-1 sm:px-4">
<div
ref={cardRef}
data-native-chat-prompt-card-focus={shouldFocus || undefined}
data-native-chat-approval-card="true"
role="group"
aria-label={approval.title}
tabIndex={-1}
onKeyDown={(event) => {
if (event.key === 'Escape' && !event.nativeEvent.isComposing && escape) {
event.preventDefault()
event.stopPropagation()
escape()
}
}}
className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex shrink-0 items-start gap-2">
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="line-clamp-2 break-words text-sm font-semibold text-foreground">
{approval.title}
</p>
</div>
{onCancel ? (
<button
type="button"
onClick={onCancel}
aria-label={translate('components.native-chat.approval.cancel', 'Cancel')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
) : onCollapse ? (
<NativeChatPromptCollapseToggle
expanded
disabled={isSubmitting}
onToggle={onCollapse}
/>
) : null}
</div>
{hasContext ? (
<div
data-native-chat-approval-content="true"
tabIndex={0}
className="min-h-0 max-h-72 shrink space-y-2 overflow-auto text-sm text-muted-foreground scrollbar-sleek focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{approval.description ? (
<p className="whitespace-pre-wrap break-words">{approval.description}</p>
) : null}
{approval.decisionReason ? (
<p className="whitespace-pre-wrap break-words">
<span className="font-medium text-foreground">
{translate('components.native-chat.approval.reason', 'Reason')}:{' '}
</span>
{approval.decisionReason}
<SecuritySurface>
<div className="min-h-0 shrink overflow-hidden bg-chat-canvas">
<div className="mx-auto flex h-full min-h-0 max-h-full w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-1 sm:px-4">
<div
ref={cardRef}
data-native-chat-prompt-card-focus={shouldFocus || undefined}
data-native-chat-approval-card="true"
role="group"
aria-label={approval.title}
tabIndex={-1}
onKeyDown={(event) => {
if (event.key === 'Escape' && !event.nativeEvent.isComposing && escape) {
event.preventDefault()
event.stopPropagation()
escape()
}
}}
className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex shrink-0 items-start gap-2">
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="line-clamp-2 break-words text-sm font-semibold text-foreground">
{approval.title}
</p>
) : null}
{neededPath ? (
<p className="break-words">
<span className="font-medium text-foreground">
{translate('components.native-chat.approval.needsAccess', 'Needs access to')}
:{' '}
</span>
<span className="font-mono">{neededPath}</span>
</p>
) : null}
{isPlanApprovalSubject(approval.subject) ? (
<div data-native-chat-approval-plan="true">
<NativeChatMarkdown
content={approval.subject.text}
variant="document"
className="text-sm text-chat-foreground"
renderCodeBlock={NativeChatCodeBlock}
{...(onLinkClick ? { onLinkClick } : {})}
allowFileUriLinks={allowFileUriLinks}
linkifyFilePaths={onLinkClick !== undefined}
/>
{approval.subject.filePath ? (
<p className="mt-2 break-all">
<span className="font-medium text-foreground">
{translate('components.native-chat.approval.plan.file', 'Plan file')}:{' '}
</span>
<span className="font-mono">{approval.subject.filePath}</span>
</p>
) : null}
</div>
) : approval.detail ? (
<div
data-native-chat-approval-detail="true"
data-native-chat-code-content
className="whitespace-pre-wrap break-words font-mono text-chat-code"
</div>
{onCancel ? (
<button
type="button"
onClick={onCancel}
aria-label={translate('components.native-chat.approval.cancel', 'Cancel')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{approval.detail}
</div>
) : null}
{newerSubject ? (
<p data-native-chat-approval-needs-newer-orca="true" className="break-words">
{translate(
'components.native-chat.approval.needsNewerOrca',
'This request needs a newer version of Orca.'
)}
</p>
<X className="size-4" />
</button>
) : onCollapse ? (
<NativeChatPromptCollapseToggle
expanded
disabled={isSubmitting}
onToggle={onCollapse}
/>
) : null}
</div>
) : null}
<div data-native-chat-approval-actions="true" className="flex shrink-0 flex-wrap gap-2">
{approval.options.map((opt, i) => (
<button
key={`${opt.label}-${i}`}
type="button"
disabled={newerSubject || isSubmitting}
onClick={() => onChoose(opt.send)}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
i === 0
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'border border-border bg-background text-foreground hover:bg-accent'
)}
{hasContext ? (
<div
data-native-chat-approval-content="true"
tabIndex={0}
className="min-h-0 max-h-72 shrink space-y-2 overflow-auto text-sm text-muted-foreground scrollbar-sleek focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{opt.label}
</button>
))}
{approval.description ? (
<p className="whitespace-pre-wrap break-words">{approval.description}</p>
) : null}
{approval.decisionReason ? (
<p className="whitespace-pre-wrap break-words">
<span className="font-medium text-foreground">
{translate('components.native-chat.approval.reason', 'Reason')}:{' '}
</span>
{approval.decisionReason}
</p>
) : null}
{neededPath ? (
<p className="break-words">
<span className="font-medium text-foreground">
{translate('components.native-chat.approval.needsAccess', 'Needs access to')}
:{' '}
</span>
<span className="font-mono">{neededPath}</span>
</p>
) : null}
{isPlanApprovalSubject(approval.subject) ? (
<div data-native-chat-approval-plan="true">
<NativeChatMarkdown
content={approval.subject.text}
variant="document"
className="text-sm text-chat-foreground"
renderCodeBlock={NativeChatCodeBlock}
{...(onLinkClick ? { onLinkClick } : {})}
allowFileUriLinks={allowFileUriLinks}
linkifyFilePaths={onLinkClick !== undefined}
/>
{approval.subject.filePath ? (
<p className="mt-2 break-all">
<span className="font-medium text-foreground">
{translate('components.native-chat.approval.plan.file', 'Plan file')}
:{' '}
</span>
<span className="font-mono">{approval.subject.filePath}</span>
</p>
) : null}
</div>
) : approval.detail ? (
<div
data-native-chat-approval-detail="true"
data-native-chat-code-content
className="whitespace-pre-wrap break-words font-mono text-chat-code"
>
{approval.detail}
</div>
) : null}
{newerSubject ? (
<p data-native-chat-approval-needs-newer-orca="true" className="break-words">
{translate(
'components.native-chat.approval.needsNewerOrca',
'This request needs a newer version of Orca.'
)}
</p>
) : null}
</div>
) : null}
<div data-native-chat-approval-actions="true" className="flex shrink-0 flex-wrap gap-2">
{approval.options.map((opt, i) => (
<button
key={`${opt.label}-${i}`}
type="button"
disabled={newerSubject || isSubmitting}
onClick={() => onChoose(opt.send)}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
i === 0
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'border border-border bg-background text-foreground hover:bg-accent'
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
</div>
</div>
</SecuritySurface>
)
}
@@ -134,7 +134,9 @@ export function PluginConsentDialog({
}}
>
<DialogContent
className="plugin-security-chrome max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl"
surface="security"
scrollable
className="sm:max-w-xl"
onOpenAutoFocus={(event) => {
// Why: dismissal is the safety-preserving path, so it receives initial focus.
event.preventDefault()
@@ -79,7 +79,7 @@ export function PluginInstallDialog({
return (
<Dialog open={open} onOpenChange={(nextOpen) => !installing && onOpenChange(nextOpen)}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-lg">
<DialogContent surface="security" scrollable className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.PluginInstallDialog.title', 'Install plugin')}
@@ -152,7 +152,7 @@ export function PluginMarketplacePreviewDialog({
: undefined
return (
<Dialog open={Boolean(preview)} onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="plugin-security-chrome max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl">
<DialogContent surface="security" scrollable className="sm:max-w-xl">
{preview ? (
<>
<DialogHeader>
@@ -121,7 +121,9 @@ export function PluginMarketplaceSourceDialog({
return (
<Dialog open={open} onOpenChange={(nextOpen) => !busyAction && onOpenChange(nextOpen)}>
<DialogContent
className="plugin-security-chrome max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl"
surface="security"
scrollable
className="sm:max-w-xl"
onOpenAutoFocus={(event) => {
event.preventDefault()
urlRef.current?.focus()
@@ -29,6 +29,7 @@ export function PluginRemoveDialog({
return (
<Dialog open={Boolean(plugin)} onOpenChange={(open) => !open && !busy && onCancel()}>
<DialogContent
surface="security"
onOpenAutoFocus={(event) => {
event.preventDefault()
cancelRef.current?.focus()
@@ -31,6 +31,7 @@ export function PluginRollbackDialog({
return (
<Dialog open={Boolean(plugin)} onOpenChange={(open) => !open && !busy && onCancel()}>
<DialogContent
surface="security"
onOpenAutoFocus={(event) => {
event.preventDefault()
cancelRef.current?.focus()
@@ -104,7 +104,7 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() {
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md sm:max-w-md" showCloseButton={false}>
<DialogContent surface="security" className="max-w-md sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">
{previouslyApproved
+8 -1
View File
@@ -1,5 +1,6 @@
'use client'
import { SECURITY_SURFACE_CLASS } from './security-surface'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Minus, XIcon } from 'lucide-react'
@@ -54,7 +55,9 @@ const dialogContentSurfaceVariants = cva('', {
'feature-tip':
'gap-0 bg-[color-mix(in_srgb,var(--foreground)_8%,var(--background))] p-0 dark:bg-[color-mix(in_srgb,var(--foreground)_16%,var(--background))]',
/** Edge to edge, for an embedded panel that brings its own header and padding. */
panel: 'gap-0 p-0'
panel: 'gap-0 p-0',
/** Trust, consent and provenance decisions: host-owned tokens a plugin theme cannot restyle. */
security: SECURITY_SURFACE_CLASS
}
},
defaultVariants: { surface: 'default' }
@@ -94,6 +97,7 @@ function DialogContent({
closeButtonVariant = 'close',
surface,
size,
scrollable = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> &
VariantProps<typeof dialogContentSurfaceVariants> & {
@@ -102,6 +106,8 @@ function DialogContent({
closeButtonVariant?: 'close' | 'minimize'
/** `compact` is a short confirmation's width, like the close prompts. */
size?: 'default' | 'compact'
/** The whole dialog scrolls within the viewport instead of growing past it. */
scrollable?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
@@ -120,6 +126,7 @@ function DialogContent({
className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] grid-cols-[minmax(0,1fr)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-black/14 bg-background/96 p-6 text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-2xl duration-200 outline-none dark:border-white/14 dark:bg-[rgba(23,23,23,0.96)] dark:shadow-[0_24px_72px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.06)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg data-[size=compact]:sm:max-w-sm',
dialogContentSurfaceVariants({ surface }),
scrollable && 'max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek',
className
)}
{...props}
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './dialog'
import { SecuritySurface } from './security-surface'
// Every dialog that asks the user to trust, approve, run or remove code. A new one belongs here.
const TRUST_DIALOGS = [
'sidebar/OrcaYamlTrustDialog.tsx',
'editor/IpynbViewer.tsx',
'settings/PluginConsentDialog.tsx',
'settings/PluginInstallDialog.tsx',
'settings/PluginMarketplacePreviewDialog.tsx',
'settings/PluginMarketplaceSourceDialog.tsx',
'settings/PluginRemoveDialog.tsx',
'settings/PluginRollbackDialog.tsx'
]
// Trust decisions made outside a dialog: agent command approvals and certificate overrides.
const INLINE_TRUST_SURFACES = [
'native-chat/NativeChatApprovalCard.tsx',
'browser-pane/navigate/browser-load-failure-overlay.tsx'
]
afterEach(cleanup)
describe('security surfaces', () => {
it('applies the host-owned token set', () => {
render(
<Dialog open>
<DialogContent surface="security">
<DialogTitle>Trust</DialogTitle>
<DialogDescription>Review</DialogDescription>
</DialogContent>
</Dialog>
)
expect(document.querySelector('[role="dialog"]')?.classList).toContain('plugin-security-chrome')
})
it.each(TRUST_DIALOGS)('%s renders on the security surface', (file) => {
const source = readFileSync(join(import.meta.dirname, '..', file), 'utf8')
expect(source).toMatch(/<DialogContent\s[^>]*surface="security"/)
expect(source).not.toContain('plugin-security-chrome')
})
it('applies the host-owned token set outside dialogs', () => {
const { container } = render(
<SecuritySurface>
<div>Allow</div>
</SecuritySurface>
)
expect(container.firstElementChild?.classList).toContain('plugin-security-chrome')
})
it.each(INLINE_TRUST_SURFACES)('%s renders inside a security surface', (file) => {
const source = readFileSync(join(import.meta.dirname, '..', file), 'utf8')
expect(source).toMatch(/<SecuritySurface[\s>]/)
})
})
@@ -0,0 +1,15 @@
import type React from 'react'
import * as Slot from 'radix-ui/slot'
/** Host-owned tokens a plugin theme cannot restyle; see `.plugin-security-chrome` in main.css. */
export const SECURITY_SURFACE_CLASS = 'plugin-security-chrome'
/** Puts its one child element on the host-owned token surface. For trust decisions outside a
* dialog (an agent approval, a certificate override); dialogs use `surface="security"`. */
export function SecuritySurface({ children }: { children: React.ReactElement }): React.JSX.Element {
return (
<Slot.Root data-slot="security-surface" className={SECURITY_SURFACE_CLASS}>
{children}
</Slot.Root>
)
}