Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { useIsWorkspaceAdmin } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { PackageIcon } from './PackageIcon';
import { MarkdownText } from './MarkdownText';
import { PluginDisclosure } from './PluginDisclosure';
import { MarketplaceAccessDenied } from './MarketplaceAccessDenied';
import { localizePackage } from './usePackageL10n';
import {
Expand Down Expand Up @@ -80,6 +81,9 @@ export function MarketplacePackagePage() {
const [envsError, setEnvsError] = useState<string | null>(null);
const [selectedEnv, setSelectedEnv] = useState<string>('');
const [seedSampleData, setSeedSampleData] = useState(false);
// PD4: a code-bearing package requires explicit acknowledgement of its
// requested permissions before the install button is enabled.
const [acknowledged, setAcknowledged] = useState(false);
const [installing, setInstalling] = useState(false);
const [installResult, setInstallResult] = useState<{ ok: boolean; message: string } | null>(null);
// Tracks whether the package has been installed into the current
Expand Down Expand Up @@ -471,6 +475,8 @@ export function MarketplacePackagePage() {
const loc = localizePackage(pkg as any, language);
const latestVersion = pkg.latest_version?.version ?? data.versions[0]?.version ?? null;
const localInstall = localInstalls.find((i) => i.manifestId === pkg.manifest_id) ?? null;
// PD4 (ADR-0025 §3.11): code-bearing packages must disclose + be acknowledged.
const containsCode = !!pkg.latest_version?.contains_code;

const supportsLocal = getRuntimeConfig().features.installLocal;
const primaryDisabled = !latestVersion || installingLocal || installing || (!supportsLocal && !!cloudInstalledVersion);
Expand Down Expand Up @@ -702,7 +708,7 @@ export function MarketplacePackagePage() {
</div>
</div>

<Dialog open={installOpen} onOpenChange={(o) => { setInstallOpen(o); if (!o) setInstallResult(null); }}>
<Dialog open={installOpen} onOpenChange={(o) => { setInstallOpen(o); if (!o) { setInstallResult(null); setAcknowledged(false); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('marketplace.install.dialogTitle', { name: loc.displayName || pkg.manifest_id })}</DialogTitle>
Expand Down Expand Up @@ -772,6 +778,24 @@ export function MarketplacePackagePage() {
</div>
)}

{containsCode && !envsError && (
<div className="space-y-3">
<PluginDisclosure version={pkg.latest_version} />
<div className="flex items-start gap-2">
<Checkbox
id="ack-perms"
checked={acknowledged}
onCheckedChange={(c) => setAcknowledged(c === true)}
/>
<Label htmlFor="ack-perms" className="text-sm font-normal cursor-pointer leading-snug">
{t('marketplace.disclosure.acknowledge', {
defaultValue: 'I understand this package runs code and grants the permissions above.',
})}
</Label>
</div>
</div>
)}

{installResult && (
<div className={`rounded-md border p-3 text-sm ${installResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}>
{installResult.message}
Expand All @@ -783,7 +807,7 @@ export function MarketplacePackagePage() {
{!envsError && (
<Button
onClick={doInstall}
disabled={!selectedEnv || installing || installResult?.ok === true}
disabled={!selectedEnv || installing || installResult?.ok === true || (containsCode && !acknowledged)}
>
{installing ? t('marketplace.action.installing') : t('marketplace.action.install')}
</Button>
Expand Down
127 changes: 127 additions & 0 deletions packages/app-shell/src/console/marketplace/PluginDisclosure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Plugin permission disclosure (ADR-0025 PD4 §3.5 / §3.11).
*
* Rendered in the install dialog for a code-bearing package. Shows what the
* user is consenting to BEFORE install: that the package contains code, the
* trust tier it runs under, whether it is signed / marketplace-reviewed, and
* the exact structured permission set it requests. The control plane exposes
* verification STATUS only (never raw signatures); see cloud
* marketplace.projectVersion.
*/

import { Badge } from '@object-ui/components';
import { Boxes, CheckCircle2, Code2, FolderTree, Network, ShieldAlert, ShieldCheck, Webhook } from 'lucide-react';
import { useObjectTranslation } from '@object-ui/i18n';
import type { MarketplacePackageVersion } from './marketplaceApi';

const RUNTIME_FALLBACK: Record<string, string> = {
node: 'In-process · full trust',
sandbox: 'Sandboxed',
worker: 'Out-of-process',
};

function PermissionGroup({
icon: Icon,
label,
items,
}: {
icon: typeof Boxes;
label: string;
items?: string[];
}) {
if (!items || items.length === 0) return null;
return (
<div className="flex items-start gap-2">
<Icon className="h-3.5 w-3.5 mt-0.5 text-muted-foreground shrink-0" aria-hidden="true" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">{label}</div>
<div className="flex flex-wrap gap-1 mt-0.5">
{items.map((it) => (
<code key={it} className="font-mono text-[11px] px-1 py-0.5 rounded bg-muted break-all">{it}</code>
))}
</div>
</div>
</div>
);
}

export function PluginDisclosure({ version }: { version?: MarketplacePackageVersion | null }) {
const { t } = useObjectTranslation();
if (!version?.contains_code) return null;

const perms = version.permissions ?? {};
const hasAny =
(perms.services?.length ?? 0) +
(perms.hooks?.length ?? 0) +
(perms.network?.length ?? 0) +
(perms.fs?.length ?? 0) >
0;

return (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 space-y-3 text-sm">
<div className="flex items-center gap-2 font-medium">
<Code2 className="h-4 w-4 text-amber-600 shrink-0" aria-hidden="true" />
{t('marketplace.disclosure.containsCode', { defaultValue: 'This package contains code' })}
</div>

<div className="flex flex-wrap items-center gap-1.5">
{version.runtime && (
<Badge variant="outline">
{t(`marketplace.disclosure.runtime.${version.runtime}` as any, {
defaultValue: RUNTIME_FALLBACK[version.runtime] ?? version.runtime,
})}
</Badge>
)}
{version.platform_verified ? (
<Badge variant="outline" className="gap-1 border-green-500/40 text-green-700 dark:text-green-400">
<ShieldCheck className="h-3 w-3" aria-hidden="true" />
{t('marketplace.disclosure.reviewed', { defaultValue: 'Reviewed & approved' })}
</Badge>
) : (
<Badge variant="outline" className="gap-1 border-amber-500/40 text-amber-700 dark:text-amber-400">
<ShieldAlert className="h-3 w-3" aria-hidden="true" />
{t('marketplace.disclosure.unreviewed', { defaultValue: 'Not yet reviewed' })}
</Badge>
)}
{version.signed && (
<Badge variant="outline" className="gap-1">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
{t('marketplace.disclosure.signed', { defaultValue: 'Signed' })}
</Badge>
)}
</div>

{hasAny ? (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
{t('marketplace.disclosure.grantsIntro', { defaultValue: 'On install, this package will be granted:' })}
</div>
<PermissionGroup
icon={Boxes}
label={t('marketplace.disclosure.services', { defaultValue: 'Platform services' })}
items={perms.services ?? undefined}
/>
<PermissionGroup
icon={Webhook}
label={t('marketplace.disclosure.hooks', { defaultValue: 'Lifecycle hooks' })}
items={perms.hooks ?? undefined}
/>
<PermissionGroup
icon={Network}
label={t('marketplace.disclosure.network', { defaultValue: 'Network access' })}
items={perms.network ?? undefined}
/>
<PermissionGroup
icon={FolderTree}
label={t('marketplace.disclosure.fs', { defaultValue: 'Filesystem access' })}
items={perms.fs ?? undefined}
/>
</div>
) : (
<div className="text-xs text-muted-foreground">
{t('marketplace.disclosure.noPermissions', { defaultValue: 'Requests no special permissions.' })}
</div>
)}
</div>
);
}
27 changes: 27 additions & 0 deletions packages/app-shell/src/console/marketplace/marketplaceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export interface MarketplacePackageDetail extends MarketplacePackageSummary {
readme?: string | null;
}

/** Structured permission grants a plugin requests (ADR-0025 §3.2). */
export interface PluginPermissions {
services?: string[];
hooks?: string[];
network?: string[];
fs?: string[];
}

export interface MarketplacePackageVersion {
id: string;
version: string;
Expand All @@ -69,6 +77,25 @@ export interface MarketplacePackageVersion {
published_at?: string | null;
listing_status?: string;
reviewed_at?: string | null;

// Plugin distribution disclosure (ADR-0025 PD4 §3.11). Present only for
// code-bearing versions; the control plane exposes verification STATUS,
// never the raw signatures.
artifact_kind?: string;
/** True when this version carries executable code (a `plugin` artifact). */
contains_code?: boolean;
/** Trust tier the plugin runs under: 'node' | 'sandbox' | 'worker'. */
runtime?: string;
/** Dependency packaging: 'bundled' | 'manifest-deps'. */
packaging?: string;
/** Structured permission set the plugin asks the installer to grant. */
permissions?: PluginPermissions | null;
/** Compatibility ranges ({ platform, protocol }). */
engines?: { platform?: string; protocol?: string } | null;
/** Whether the artifact carries a verified publisher signature. */
signed?: boolean;
/** Whether the marketplace counter-signed (reviewed + approved) this version. */
platform_verified?: boolean;
}

export interface MarketplaceListResponse {
Expand Down
Loading