import type { ColineAppManifestV2 } from "./manifest"; import { EFFECT_CEILINGS, MUTATION_CLASS_RANK, 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" | "unverified_endpoint" | "gains_site_surface"; export interface AppSecurityContractDelta { permissionsAdded: string[]; permissionsRemoved: string[]; networkHostsAdded: string[]; networkHostsRemoved: string[]; toolEffectsChanged: ToolEffectChange[]; schedulesChanged: ScheduleChange[]; surfacesChanged: SurfaceTierChange[]; webhookEventsAdded: string[]; webhookEventsRemoved: string[]; hostingChanged: HostingChange | null; endpointsAdded: string[]; endpointsRemoved: string[]; connectionsAdded: string[]; connectionsRemoved: string[]; siteToolsAdded: string[]; /** Collection actions newly opened to site visitors, as `collection.action`. */ siteAccessOpened: string[]; jobsEscalated: string[]; /** The one boolean auto-update and re-consent key on. */ requiresReconsent: boolean; /** Human-readable reasons behind `requiresReconsent`, for UI. */ reconsentReasons: string[]; riskFlags: SecurityRiskFlag[]; } /** * Whether `next` grants authority none of `before` had. An effect is two * things, compared separately: the mutation class it may reach and whether * it may use the network. (`external` allows network but only write-level * changes, so external → destructive is an escalation; a single rank where * external sits above destructive would miss it.) */ function effectExceeds(next: ToolEffect, before: readonly ToolEffect[]): boolean { const ceiling = EFFECT_CEILINGS[next]; const maxBefore = Math.max(-1, ...before.map((effect) => MUTATION_CLASS_RANK[EFFECT_CEILINGS[effect].maxMutation])); const networkBefore = before.some((effect) => EFFECT_CEILINGS[effect].allowsNetwork); return MUTATION_CLASS_RANK[ceiling.maxMutation] > maxBefore || (ceiling.allowsNetwork && !networkBefore); } 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); } if (manifest.surfaces.site) { surfaces.set("site", "react"); } 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 when a tool gains authority // it lacked, or a new tool exceeds anything the app could previously do. const previousTools = previous ? collectToolEffects(previous) : new Map(); const nextTools = collectToolEffects(next); const toolEffectsChanged: ToolEffectChange[] = []; const previousToolEffects = [...previousTools.values()]; for (const [name, nextEffect] of nextTools) { const previousEffect = previousTools.get(name) ?? null; if (previousEffect === nextEffect) { continue; } toolEffectsChanged.push({ name, previousEffect, nextEffect }); if (previousEffect !== null && effectExceeds(nextEffect, [previousEffect])) { reconsentReasons.push( `Tool "${name}" escalates from ${previousEffect} to ${nextEffect}.`, ); } if (previousEffect === null && previous !== null && effectExceeds(nextEffect, previousToolEffects)) { 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"); } } // Endpoints: new inbound doors into the app. const endpointDiff = diffStringSets( (previous?.endpoints ?? []).map((endpoint) => endpoint.id), next.endpoints.map((endpoint) => endpoint.id), ); if (endpointDiff.added.length > 0 && previous !== null) { reconsentReasons.push( `Receives requests at ${endpointDiff.added.length} new endpoint${endpointDiff.added.length === 1 ? "" : "s"}: ${endpointDiff.added.join(", ")}.`, ); } // An existing endpoint that drops its signature check, or accepts more // methods, is a wider door than the one that was approved. const previousEndpoints = new Map((previous?.endpoints ?? []).map((endpoint) => [endpoint.id, endpoint])); for (const endpoint of next.endpoints) { const before = previousEndpoints.get(endpoint.id); if (!before || previous === null) continue; if (endpoint.verify.kind === "none" && before.verify.kind !== "none") { reconsentReasons.push(`Endpoint "${endpoint.id}" no longer verifies who is calling it.`); } else if (before.verify.kind !== "none" && JSON.stringify(before.verify) !== JSON.stringify(endpoint.verify)) { // Any other change (HMAC → shared token, dropping a timestamp check) // can weaken it; adding verification to an open endpoint cannot. reconsentReasons.push(`Endpoint "${endpoint.id}" changes how callers are verified.`); } const methodDiff = diffStringSets(before.methods, endpoint.methods); if (methodDiff.added.length > 0) { reconsentReasons.push(`Endpoint "${endpoint.id}" now accepts ${methodDiff.added.join(", ")} requests.`); } } if (next.endpoints.some((endpoint) => endpoint.verify.kind === "none")) { riskFlags.add("unverified_endpoint"); } // Connections: accounts on other services, used on members' behalf. const previousConnections = new Map( (previous?.connections ?? []).map((connection) => [connection.key, connection]), ); const connectionDiff = diffStringSets( [...previousConnections.keys()], next.connections.map((connection) => connection.key), ); if (connectionDiff.added.length > 0 && previous !== null) { reconsentReasons.push(`Asks to connect new accounts: ${connectionDiff.added.join(", ")}.`); } for (const connection of next.connections) { const before = previousConnections.get(connection.key); if (!before || previous === null) continue; const scopeDiff = diffStringSets(before.oauth2.scopes, connection.oauth2.scopes); const connectionHostDiff = diffStringSets(before.hosts, connection.hosts); if (scopeDiff.added.length > 0 || connectionHostDiff.added.length > 0) { const details = [ scopeDiff.added.length > 0 ? `scopes: ${scopeDiff.added.join(", ")}` : null, connectionHostDiff.added.length > 0 ? `hosts: ${connectionHostDiff.added.join(", ")}` : null, ].filter(Boolean); reconsentReasons.push( `Connection "${connection.key}" requests more access (${details.join("; ")}).`, ); } // The token endpoint receives authorization codes (and so, in effect, // the account's tokens); moving it or the sign-in page is a new trust. if ( before.oauth2.tokenUrl !== connection.oauth2.tokenUrl || before.oauth2.authorizeUrl !== connection.oauth2.authorizeUrl || before.oauth2.clientId !== connection.oauth2.clientId ) { reconsentReasons.push(`Connection "${connection.key}" signs in through a different service or client.`); } if (before.scope !== connection.scope) { reconsentReasons.push( connection.scope === "workspace" ? `Connection "${connection.key}" becomes one shared workspace account.` : `Connection "${connection.key}" becomes a per-member account.`, ); } } // Site: what visitors outside the workspace can run and reach. const siteToolDiff = diffStringSets( previous?.surfaces.site?.tools ?? [], next.surfaces.site?.tools ?? [], ); // A rule change needs consent unless everyone it lets in was already let // in. `owner` and `signedIn` don't nest: owners include guests, and // signed-in visitors include people who don't own the record. type Subject = "public" | "visitor" | "signedIn" | "owner" | "team"; const covers = (outer: Subject, inner: Subject) => outer === inner || inner === "team" || outer === "public" || (outer === "visitor" && inner !== "public"); const actionSubjects = (manifest: ColineAppManifestV2 | null) => { const subjects = new Map(); for (const [name, declaration] of Object.entries(manifest?.collections ?? {})) { for (const action of ["read", "create", "update", "delete"] as const) { subjects.set(`${name}.${action}`, declaration.access?.[action] ?? "team"); } } return subjects; }; const previousSubjects = actionSubjects(previous); const accessDiff = { added: [...actionSubjects(next)] .filter(([key, subject]) => !covers(previousSubjects.get(key) ?? "team", subject)) .map(([key]) => key) .sort(), }; if (previous !== null) { if (next.surfaces.site && !previous.surfaces.site) { reconsentReasons.push("Adds a public website people outside the workspace can open."); riskFlags.add("gains_site_surface"); } else if (siteToolDiff.added.length > 0) { reconsentReasons.push(`Site visitors can now run: ${siteToolDiff.added.join(", ")}.`); } if (accessDiff.added.length > 0) { reconsentReasons.push(`Site visitors can now reach: ${accessDiff.added.join(", ")}.`); } if (next.surfaces.site?.visitors.signIn && !previous.surfaces.site?.visitors.signIn) { reconsentReasons.push("Site visitors can now sign in with Coline."); } } // Jobs: background work escalating its effect ceiling, or a new job // exceeding anything the app (tools or jobs) could previously do. const previousJobs = new Map((previous?.jobs ?? []).map((job) => [job.name, job.effect])); const previousAuthority = [...previousToolEffects, ...previousJobs.values()]; const jobsEscalated: string[] = []; for (const job of next.jobs) { const before = previousJobs.get(job.name); if (before && effectExceeds(job.effect, [before])) { jobsEscalated.push(job.name); reconsentReasons.push( `Background job "${job.name}" escalates from ${before} to ${job.effect}.`, ); } else if (!before && previous !== null && effectExceeds(job.effect, previousAuthority)) { jobsEscalated.push(job.name); reconsentReasons.push( `New background job "${job.name}" (${job.effect}) exceeds anything the app could previously do.`, ); } } 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, endpointsAdded: endpointDiff.added, endpointsRemoved: endpointDiff.removed, connectionsAdded: connectionDiff.added, connectionsRemoved: connectionDiff.removed, siteToolsAdded: siteToolDiff.added, siteAccessOpened: accessDiff.added, jobsEscalated, requiresReconsent, reconsentReasons, riskFlags: [...riskFlags].sort(), }; }