channelAllowlist.ts
1 /** 2 * Approved channel plugins allowlist. --channels plugin:name@marketplace 3 * entries only register if {marketplace, plugin} is on this list. server: 4 * entries always fail (schema is plugin-only). The 5 * --dangerously-load-development-channels flag bypasses for both kinds. 6 * Lives in GrowthBook so it can be updated without a release. 7 * 8 * Plugin-level granularity: if a plugin is approved, all its channel 9 * servers are. Per-server gating was overengineering — a plugin that 10 * sprouts a malicious second server is already compromised, and per-server 11 * entries would break on harmless plugin refactors. 12 * 13 * The allowlist check is a pure {marketplace, plugin} comparison against 14 * the user's typed tag. The gate's separate 'marketplace' step verifies 15 * the tag matches what's actually installed before this check runs. 16 */ 17 18 import { z } from 'zod/v4' 19 import { lazySchema } from '../../utils/lazySchema.js' 20 import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js' 21 import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 22 23 export type ChannelAllowlistEntry = { 24 marketplace: string 25 plugin: string 26 } 27 28 const ChannelAllowlistSchema = lazySchema(() => 29 z.array( 30 z.object({ 31 marketplace: z.string(), 32 plugin: z.string(), 33 }), 34 ), 35 ) 36 37 export function getChannelAllowlist(): ChannelAllowlistEntry[] { 38 const raw = getFeatureValue_CACHED_MAY_BE_STALE<unknown>( 39 'tengu_harbor_ledger', 40 [], 41 ) 42 const parsed = ChannelAllowlistSchema().safeParse(raw) 43 return parsed.success ? parsed.data : [] 44 } 45 46 /** 47 * Overall channels on/off. Checked before any per-server gating — 48 * when false, --channels is a no-op and no handlers register. 49 * Default false; GrowthBook 5-min refresh. 50 */ 51 export function isChannelsEnabled(): boolean { 52 return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor', false) 53 } 54 55 /** 56 * Pure allowlist check keyed off the connection's pluginSource — for UI 57 * pre-filtering so the IDE only shows "Enable channel?" for servers that will 58 * actually pass the gate. Not a security boundary: channel_enable still runs 59 * the full gate. Matches the allowlist comparison inside gateChannelServer() 60 * but standalone (no session/marketplace coupling — those are tautologies 61 * when the entry is derived from pluginSource). 62 * 63 * Returns false for undefined pluginSource (non-plugin server — can never 64 * match the {marketplace, plugin}-keyed ledger) and for @-less sources 65 * (builtin/inline — same reason). 66 */ 67 export function isChannelAllowlisted( 68 pluginSource: string | undefined, 69 ): boolean { 70 if (!pluginSource) return false 71 const { name, marketplace } = parsePluginIdentifier(pluginSource) 72 if (!marketplace) return false 73 return getChannelAllowlist().some( 74 e => e.plugin === name && e.marketplace === marketplace, 75 ) 76 }