import type { ColineAppManifestV2 } from "./manifest"; import type { ToolEffect } from "./effect"; import type { SurfaceTier } from "./surfaces"; /** * The security-contract diff (distribution plan §A0/A4/A6): one shared * comparison that answers "did the user-visible consent contract expand * between two versions of an app?". * * Three consumers, one implementation: * - the review queue's delta view (what a reviewer must look at), * - auto-update eligibility (contract expanded → admin must re-consent), * - the admin re-consent dialog (renders exactly this delta). * * Removals never require re-consent. Expansions of authority always do: * new permissions, new network hosts, tool-effect escalation, web-tier * surfaces, external hosting changes, and new outbound webhook events. */ export interface ToolEffectChange { name: string; /** Null when the tool is new in the next version. */ previousEffect: ToolEffect | null; nextEffect: ToolEffect | null; } export interface SurfaceTierChange { /** `home`, `files..editor`, `files..preview`, … */ surface: string; previousTier: SurfaceTier | null; nextTier: SurfaceTier | null; } export interface ScheduleChange { id: string; previousCron: string | null; nextCron: string | null; } export interface HostingChange { previousDefault: "coline" | "external"; nextDefault: "coline" | "external"; /** Capabilities newly routed to the developer's backend. */ overridesNowExternal: string[]; previousExternalBaseUrl: string | null; nextExternalBaseUrl: string | null; externalBaseUrlChanged: boolean; } export type SecurityRiskFlag = | "network_host_wildcard" | "gains_external_hosting" | "external_base_url_changed" | "gains_destructive_tools" | "many_destructive_tools" | "gains_web_surface" | "broad_permission_growth"; export interface AppSecurityContractDelta { permissionsAdded: string[]; permissionsRemoved: string[]; networkHostsAdded: string[]; networkHostsRemoved: string[]; toolEffectsChanged: ToolEffectChange[]; schedulesChanged: ScheduleChange[]; surfacesChanged: SurfaceTierChange[]; webhookEventsAdded: string[]; webhookEventsRemoved: string[]; hostingChanged: HostingChange | null; /** The one boolean auto-update and re-consent key on. */ requiresReconsent: boolean; /** Human-readable reasons behind `requiresReconsent`, for UI. */ reconsentReasons: string[]; riskFlags: SecurityRiskFlag[]; } const EFFECT_RANK: Record = { read: 0, write: 1, destructive: 2, // `external` is a distinct authority class (leaves the workspace); // ranked above destructive for escalation purposes. external: 3, }; const TIER_RANK: Record = { tree: 0, react: 1, web: 2, }; function diffStringSets( previous: readonly string[], next: readonly string[], ): { added: string[]; removed: string[] } { const previousSet = new Set(previous); const nextSet = new Set(next); return { added: [...nextSet].filter((value) => !previousSet.has(value)).sort(), removed: [...previousSet].filter((value) => !nextSet.has(value)).sort(), }; } function collectSurfaces(manifest: ColineAppManifestV2): Map { const surfaces = new Map(); if (manifest.surfaces.home) { surfaces.set("home", manifest.surfaces.home.tier); } for (const file of manifest.files) { for (const [name, surface] of Object.entries(file.surfaces)) { if (surface && typeof surface === "object" && "tier" in surface) { surfaces.set(`files.${file.typeKey}.${name}`, surface.tier as SurfaceTier); } } } return surfaces; } function collectToolEffects(manifest: ColineAppManifestV2): Map { return new Map(manifest.tools.map((tool) => [tool.name, tool.effect])); } function externalOverrideKeys(manifest: ColineAppManifestV2): Set { return new Set( Object.entries(manifest.hosting.overrides) .filter(([, model]) => model === "external") .map(([key]) => key), ); } /** * Compare two v2 manifests. Pass `previous: null` for a first version — * everything counts as added and re-consent is trivially required (the * install consent dialog covers it). */ export function compareAppSecurityContract( previous: ColineAppManifestV2 | null, next: ColineAppManifestV2, ): AppSecurityContractDelta { const reconsentReasons: string[] = []; const riskFlags = new Set(); const previousPermissions = previous?.permissions ?? []; const permissionDiff = diffStringSets(previousPermissions, next.permissions); if (permissionDiff.added.length > 0) { reconsentReasons.push( `Requests ${permissionDiff.added.length} new permission${permissionDiff.added.length === 1 ? "" : "s"}: ${permissionDiff.added.join(", ")}.`, ); if (permissionDiff.added.length >= 3) { riskFlags.add("broad_permission_growth"); } } const previousHosts = previous?.network.allowedHosts ?? []; const hostDiff = diffStringSets(previousHosts, next.network.allowedHosts); if (hostDiff.added.length > 0) { reconsentReasons.push( `Can now reach ${hostDiff.added.length} new network host${hostDiff.added.length === 1 ? "" : "s"}: ${hostDiff.added.join(", ")}.`, ); } if (next.network.allowedHosts.some((host) => host.startsWith("*."))) { riskFlags.add("network_host_wildcard"); } // Tool effects: record every change; escalate on same-name rank increase // or a new tool exceeding the app's previous maximum effect. const previousTools = previous ? collectToolEffects(previous) : new Map(); const nextTools = collectToolEffects(next); const toolEffectsChanged: ToolEffectChange[] = []; const previousMaxRank = Math.max( -1, ...[...previousTools.values()].map((effect) => EFFECT_RANK[effect]), ); for (const [name, nextEffect] of nextTools) { const previousEffect = previousTools.get(name) ?? null; if (previousEffect === nextEffect) { continue; } toolEffectsChanged.push({ name, previousEffect, nextEffect }); if (previousEffect !== null && EFFECT_RANK[nextEffect] > EFFECT_RANK[previousEffect]) { reconsentReasons.push( `Tool "${name}" escalates from ${previousEffect} to ${nextEffect}.`, ); } if (previousEffect === null && previous !== null && EFFECT_RANK[nextEffect] > previousMaxRank) { reconsentReasons.push( `New tool "${name}" (${nextEffect}) exceeds anything the app could previously do.`, ); } } for (const [name, previousEffect] of previousTools) { if (!nextTools.has(name)) { toolEffectsChanged.push({ name, previousEffect, nextEffect: null }); } } const previousDestructive = [...previousTools.values()].filter( (effect) => effect === "destructive", ).length; const nextDestructive = [...nextTools.values()].filter( (effect) => effect === "destructive", ).length; if (nextDestructive > 0 && previousDestructive === 0 && previous !== null) { riskFlags.add("gains_destructive_tools"); } if (nextDestructive > 5) { riskFlags.add("many_destructive_tools"); } // Schedules: informational (they run app code as the app principal). const previousSchedules = new Map( (previous?.triggers.schedules ?? []).map((schedule) => [schedule.id, schedule.cron]), ); const nextSchedules = new Map( next.triggers.schedules.map((schedule) => [schedule.id, schedule.cron]), ); const schedulesChanged: ScheduleChange[] = []; for (const [id, cron] of nextSchedules) { const previousCron = previousSchedules.get(id) ?? null; if (previousCron !== cron) { schedulesChanged.push({ id, previousCron, nextCron: cron }); } } for (const [id, cron] of previousSchedules) { if (!nextSchedules.has(id)) { schedulesChanged.push({ id, previousCron: cron, nextCron: null }); } } // Surfaces: web tier = developer-origin embed = consent-relevant. const previousSurfaces = previous ? collectSurfaces(previous) : new Map(); const nextSurfaces = collectSurfaces(next); const surfacesChanged: SurfaceTierChange[] = []; for (const [surface, nextTier] of nextSurfaces) { const previousTier = previousSurfaces.get(surface) ?? null; if (previousTier === nextTier) { continue; } surfacesChanged.push({ surface, previousTier, nextTier }); const reachesWeb = nextTier === "web" && (previousTier === null || TIER_RANK[previousTier] < TIER_RANK.web); if (reachesWeb && previous !== null) { reconsentReasons.push( `Surface "${surface}" now embeds pages from the developer's own website.`, ); riskFlags.add("gains_web_surface"); } } for (const [surface, previousTier] of previousSurfaces) { if (!nextSurfaces.has(surface)) { surfacesChanged.push({ surface, previousTier, nextTier: null }); } } // Webhooks: outbound workspace data to the developer's backend. const webhookDiff = diffStringSets( (previous?.triggers.webhooks ?? []).map((webhook) => webhook.event), next.triggers.webhooks.map((webhook) => webhook.event), ); if (webhookDiff.added.length > 0 && previous !== null) { reconsentReasons.push( `Subscribes to ${webhookDiff.added.length} new outbound event${webhookDiff.added.length === 1 ? "" : "s"}: ${webhookDiff.added.join(", ")}.`, ); } // Hosting: where the app's code runs and where workspace data flows. const previousDefault = previous?.hosting.default ?? "coline"; const nextDefault = next.hosting.default; const previousExternalKeys = previous ? externalOverrideKeys(previous) : new Set(); const nextExternalKeys = externalOverrideKeys(next); const overridesNowExternal = [...nextExternalKeys] .filter((key) => !previousExternalKeys.has(key)) .sort(); const previousBaseUrl = previous?.hosting.external?.baseUrl ?? null; const nextBaseUrl = next.hosting.external?.baseUrl ?? null; const externalBaseUrlChanged = previousBaseUrl !== null && nextBaseUrl !== null && previousBaseUrl !== nextBaseUrl; let hostingChanged: HostingChange | null = null; if ( previousDefault !== nextDefault || overridesNowExternal.length > 0 || previousBaseUrl !== nextBaseUrl ) { hostingChanged = { previousDefault, nextDefault, overridesNowExternal, previousExternalBaseUrl: previousBaseUrl, nextExternalBaseUrl: nextBaseUrl, externalBaseUrlChanged, }; } if (previous !== null) { const gainsExternal = (previousDefault === "coline" && nextDefault === "external") || overridesNowExternal.length > 0 || (previousBaseUrl === null && nextBaseUrl !== null); if (gainsExternal) { reconsentReasons.push( "Parts of the app now run on the developer's own infrastructure.", ); riskFlags.add("gains_external_hosting"); } if (externalBaseUrlChanged) { reconsentReasons.push( `The app's external backend moved from ${previousBaseUrl} to ${nextBaseUrl}.`, ); riskFlags.add("external_base_url_changed"); } } const requiresReconsent = previous === null || reconsentReasons.length > 0; if (previous === null) { reconsentReasons.unshift("First version: the full contract requires consent at install."); } return { permissionsAdded: permissionDiff.added, permissionsRemoved: permissionDiff.removed, networkHostsAdded: hostDiff.added, networkHostsRemoved: hostDiff.removed, toolEffectsChanged, schedulesChanged, surfacesChanged, webhookEventsAdded: webhookDiff.added, webhookEventsRemoved: webhookDiff.removed, hostingChanged, requiresReconsent, reconsentReasons, riskFlags: [...riskFlags].sort(), }; }