| /** | |
| * Channel notifications β lets an MCP server push user messages into the | |
| * conversation. A "channel" (Discord, Slack, SMS, etc.) is just an MCP server | |
| * that: | |
| * - exposes tools for outbound messages (e.g. `send_message`) β standard MCP | |
| * - sends `notifications/claude/channel` notifications for inbound β this file | |
| * | |
| * The notification handler wraps the content in a <channel> tag and | |
| * enqueues it. SleepTool polls hasCommandsInQueue() and wakes within 1s. | |
| * The model sees where the message came from and decides which tool to reply | |
| * with (the channel's MCP tool, SendUserMessage, or both). | |
| * | |
| * feature('KAIROS') || feature('KAIROS_CHANNELS'). Runtime gate tengu_harbor. | |
| * Requires claude.ai OAuth auth β API key users are blocked until | |
| * console gets a channelsEnabled admin surface. Teams/Enterprise orgs | |
| * must explicitly opt in via channelsEnabled: true in managed settings. | |
| */ | |
| import type { ServerCapabilities } from '@modelcontextprotocol/sdk/types.js' | |
| import { z } from 'zod/v4' | |
| import { type ChannelEntry, getAllowedChannels } from '../../bootstrap/state.js' | |
| import { CHANNEL_TAG } from '../../constants/xml.js' | |
| import { | |
| getClaudeAIOAuthTokens, | |
| getSubscriptionType, | |
| } from '../../utils/auth.js' | |
| import { lazySchema } from '../../utils/lazySchema.js' | |
| import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js' | |
| import { getSettingsForSource } from '../../utils/settings/settings.js' | |
| import { escapeXmlAttr } from '../../utils/xml.js' | |
| import { | |
| type ChannelAllowlistEntry, | |
| getChannelAllowlist, | |
| isChannelsEnabled, | |
| } from './channelAllowlist.js' | |
| export const ChannelMessageNotificationSchema = lazySchema(() => | |
| z.object({ | |
| method: z.literal('notifications/claude/channel'), | |
| params: z.object({ | |
| content: z.string(), | |
| // Opaque passthrough β thread_id, user, whatever the channel wants the | |
| // model to see. Rendered as attributes on the <channel> tag. | |
| meta: z.record(z.string(), z.string()).optional(), | |
| }), | |
| }), | |
| ) | |
| /** | |
| * Structured permission reply from a channel server. Servers that support | |
| * this declare `capabilities.experimental['claude/channel/permission']` and | |
| * emit this event INSTEAD of relaying "yes tbxkq" as text via | |
| * notifications/claude/channel. Explicit opt-in per server β a channel that | |
| * just wants to relay text never becomes a permission surface by accident. | |
| * | |
| * The server parses the user's reply (spec: /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i) | |
| * and emits {request_id, behavior}. CC matches request_id against its | |
| * pending map. Unlike the regex-intercept approach, text in the general | |
| * channel can never accidentally match β approval requires the server | |
| * to deliberately emit this specific event. | |
| */ | |
| export const CHANNEL_PERMISSION_METHOD = | |
| 'notifications/claude/channel/permission' | |
| export const ChannelPermissionNotificationSchema = lazySchema(() => | |
| z.object({ | |
| method: z.literal(CHANNEL_PERMISSION_METHOD), | |
| params: z.object({ | |
| request_id: z.string(), | |
| behavior: z.enum(['allow', 'deny']), | |
| }), | |
| }), | |
| ) | |
| /** | |
| * Outbound: CC β server. Fired from interactiveHandler.ts when a | |
| * permission dialog opens and the server has declared the permission | |
| * capability. Server formats the message for its platform (Telegram | |
| * markdown, iMessage rich text, Discord embed) and sends it to the | |
| * human. When the human replies "yes tbxkq", the server parses that | |
| * against PERMISSION_REPLY_RE and emits the inbound schema above. | |
| * | |
| * Not a zod schema β CC SENDS this, doesn't validate it. A type here | |
| * keeps both halves of the protocol documented side by side. | |
| */ | |
| export const CHANNEL_PERMISSION_REQUEST_METHOD = | |
| 'notifications/claude/channel/permission_request' | |
| export type ChannelPermissionRequestParams = { | |
| request_id: string | |
| tool_name: string | |
| description: string | |
| /** JSON-stringified tool input, truncated to 200 chars with β¦. Full | |
| * input is in the local terminal dialog; this is a phone-sized | |
| * preview. Server decides whether/how to show it. */ | |
| input_preview: string | |
| } | |
| /** | |
| * Meta keys become XML attribute NAMES β a crafted key like | |
| * `x="" injected="y` would break out of the attribute structure. Only | |
| * accept keys that look like plain identifiers. This is stricter than | |
| * the XML spec (which allows `:`, `.`, `-`) but channel servers only | |
| * send `chat_id`, `user`, `thread_ts`, `message_id` in practice. | |
| */ | |
| const SAFE_META_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/ | |
| export function wrapChannelMessage( | |
| serverName: string, | |
| content: string, | |
| meta?: Record<string, string>, | |
| ): string { | |
| const attrs = Object.entries(meta ?? {}) | |
| .filter(([k]) => SAFE_META_KEY.test(k)) | |
| .map(([k, v]) => ` ${k}="${escapeXmlAttr(v)}"`) | |
| .join('') | |
| return `<${CHANNEL_TAG} source="${escapeXmlAttr(serverName)}"${attrs}>\n${content}\n</${CHANNEL_TAG}>` | |
| } | |
| /** | |
| * Effective allowlist for the current session. Team/enterprise orgs can set | |
| * allowedChannelPlugins in managed settings β when set, it REPLACES the | |
| * GrowthBook ledger (admin owns the trust decision). Undefined falls back | |
| * to the ledger. Unmanaged users always get the ledger. | |
| * | |
| * Callers already read sub/policy for the policy gate β pass them in to | |
| * avoid double-reading getSettingsForSource (uncached). | |
| */ | |
| export function getEffectiveChannelAllowlist( | |
| sub: ReturnType<typeof getSubscriptionType>, | |
| orgList: ChannelAllowlistEntry[] | undefined, | |
| ): { | |
| entries: ChannelAllowlistEntry[] | |
| source: 'org' | 'ledger' | |
| } { | |
| if ((sub === 'team' || sub === 'enterprise') && orgList) { | |
| return { entries: orgList, source: 'org' } | |
| } | |
| return { entries: getChannelAllowlist(), source: 'ledger' } | |
| } | |
| export type ChannelGateResult = | |
| | { action: 'register' } | |
| | { | |
| action: 'skip' | |
| kind: | |
| | 'capability' | |
| | 'disabled' | |
| | 'auth' | |
| | 'policy' | |
| | 'session' | |
| | 'marketplace' | |
| | 'allowlist' | |
| reason: string | |
| } | |
| /** | |
| * Match a connected MCP server against the user's parsed --channels entries. | |
| * server-kind is exact match on bare name; plugin-kind matches on the second | |
| * segment of plugin:X:Y. Returns the matching entry so callers can read its | |
| * kind β that's the user's trust declaration, not inferred from runtime shape. | |
| */ | |
| export function findChannelEntry( | |
| serverName: string, | |
| channels: readonly ChannelEntry[], | |
| ): ChannelEntry | undefined { | |
| // split unconditionally β for a bare name like 'slack', parts is ['slack'] | |
| // and the plugin-kind branch correctly never matches (parts[0] !== 'plugin'). | |
| const parts = serverName.split(':') | |
| return channels.find(c => | |
| c.kind === 'server' | |
| ? serverName === c.name | |
| : parts[0] === 'plugin' && parts[1] === c.name, | |
| ) | |
| } | |
| /** | |
| * Gate an MCP server's channel-notification path. Caller checks | |
| * feature('KAIROS') || feature('KAIROS_CHANNELS') first (build-time | |
| * elimination). Gate order: capability β runtime gate (tengu_harbor) β | |
| * auth (OAuth only) β org policy β session --channels β allowlist. | |
| * API key users are blocked at the auth layer β channels requires | |
| * claude.ai auth; console orgs have no admin opt-in surface yet. | |
| * | |
| * skip Not a channel server, or managed org hasn't opted in, or | |
| * not in session --channels. Connection stays up; handler | |
| * not registered. | |
| * register Subscribe to notifications/claude/channel. | |
| * | |
| * Which servers can connect at all is governed by allowedMcpServers β | |
| * this gate only decides whether the notification handler registers. | |
| */ | |
| export function gateChannelServer( | |
| serverName: string, | |
| capabilities: ServerCapabilities | undefined, | |
| pluginSource: string | undefined, | |
| ): ChannelGateResult { | |
| // Channel servers declare `experimental['claude/channel']: {}` (MCP's | |
| // presence-signal idiom β same as `tools: {}`). Truthy covers `{}` and | |
| // `true`; absent/undefined/explicit-`false` all fail. Key matches the | |
| // notification method namespace (notifications/claude/channel). | |
| if (!capabilities?.experimental?.['claude/channel']) { | |
| return { | |
| action: 'skip', | |
| kind: 'capability', | |
| reason: 'server did not declare claude/channel capability', | |
| } | |
| } | |
| // Overall runtime gate. After capability so normal MCP servers never hit | |
| // this path. Before auth/policy so the killswitch works regardless of | |
| // session state. | |
| if (!isChannelsEnabled()) { | |
| return { | |
| action: 'skip', | |
| kind: 'disabled', | |
| reason: 'channels feature is not currently available', | |
| } | |
| } | |
| // OAuth-only. API key users (console) are blocked β there's no | |
| // channelsEnabled admin surface in console yet, so the policy opt-in | |
| // flow doesn't exist for them. Drop this when console parity lands. | |
| if (!getClaudeAIOAuthTokens()?.accessToken) { | |
| return { | |
| action: 'skip', | |
| kind: 'auth', | |
| reason: 'channels requires claude.ai authentication (run /login)', | |
| } | |
| } | |
| // Teams/Enterprise opt-in. Managed orgs must explicitly enable channels. | |
| // Default OFF β absent or false blocks. Keyed off subscription tier, not | |
| // "policy settings exist" β a team org with zero configured policy keys | |
| // (remote endpoint returns 404) is still a managed org and must not fall | |
| // through to the unmanaged path. | |
| const sub = getSubscriptionType() | |
| const managed = sub === 'team' || sub === 'enterprise' | |
| const policy = managed ? getSettingsForSource('policySettings') : undefined | |
| if (managed && policy?.channelsEnabled !== true) { | |
| return { | |
| action: 'skip', | |
| kind: 'policy', | |
| reason: | |
| 'channels not enabled by org policy (set channelsEnabled: true in managed settings)', | |
| } | |
| } | |
| // User-level session opt-in. A server must be explicitly listed in | |
| // --channels to push inbound this session β protects against a trusted | |
| // server surprise-adding the capability. | |
| const entry = findChannelEntry(serverName, getAllowedChannels()) | |
| if (!entry) { | |
| return { | |
| action: 'skip', | |
| kind: 'session', | |
| reason: `server ${serverName} not in --channels list for this session`, | |
| } | |
| } | |
| if (entry.kind === 'plugin') { | |
| // Marketplace verification: the tag is intent (plugin:slack@anthropic), | |
| // the runtime name is just plugin:slack:X β could be slack@anthropic or | |
| // slack@evil depending on what's installed. Verify they match before | |
| // trusting the tag for the allowlist check below. Source is stashed on | |
| // the config at addPluginScopeToServers β undefined (non-plugin server, | |
| // shouldn't happen for plugin-kind entry) or @-less (builtin/inline) | |
| // both fail the comparison. | |
| const actual = pluginSource | |
| ? parsePluginIdentifier(pluginSource).marketplace | |
| : undefined | |
| if (actual !== entry.marketplace) { | |
| return { | |
| action: 'skip', | |
| kind: 'marketplace', | |
| reason: `you asked for plugin:${entry.name}@${entry.marketplace} but the installed ${entry.name} plugin is from ${actual ?? 'an unknown source'}`, | |
| } | |
| } | |
| // Approved-plugin allowlist. Marketplace gate already verified | |
| // tag == reality, so this is a pure entry check. entry.dev (per-entry, | |
| // not the session-wide bit) bypasses β so accepting the dev dialog for | |
| // one entry doesn't leak allowlist-bypass to --channels entries. | |
| if (!entry.dev) { | |
| const { entries, source } = getEffectiveChannelAllowlist( | |
| sub, | |
| policy?.allowedChannelPlugins, | |
| ) | |
| if ( | |
| !entries.some( | |
| e => e.plugin === entry.name && e.marketplace === entry.marketplace, | |
| ) | |
| ) { | |
| return { | |
| action: 'skip', | |
| kind: 'allowlist', | |
| reason: | |
| source === 'org' | |
| ? `plugin ${entry.name}@${entry.marketplace} is not on your org's approved channels list (set allowedChannelPlugins in managed settings)` | |
| : `plugin ${entry.name}@${entry.marketplace} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`, | |
| } | |
| } | |
| } | |
| } else { | |
| // server-kind: allowlist schema is {marketplace, plugin} β a server entry | |
| // can never match. Without this, --channels server:plugin:foo:bar would | |
| // match a plugin's runtime name and register with no allowlist check. | |
| if (!entry.dev) { | |
| return { | |
| action: 'skip', | |
| kind: 'allowlist', | |
| reason: `server ${entry.name} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`, | |
| } | |
| } | |
| } | |
| return { action: 'register' } | |
| } | |