channelNotification.ts
1 /** 2 * Channel notifications — lets an MCP server push user messages into the 3 * conversation. A "channel" (Discord, Slack, SMS, etc.) is just an MCP server 4 * that: 5 * - exposes tools for outbound messages (e.g. `send_message`) — standard MCP 6 * - sends `notifications/claude/channel` notifications for inbound — this file 7 * 8 * The notification handler wraps the content in a <channel> tag and 9 * enqueues it. SleepTool polls hasCommandsInQueue() and wakes within 1s. 10 * The model sees where the message came from and decides which tool to reply 11 * with (the channel's MCP tool, SendUserMessage, or both). 12 * 13 * feature('KAIROS') || feature('KAIROS_CHANNELS'). Runtime gate tengu_harbor. 14 * Requires claude.ai OAuth auth — API key users are blocked until 15 * console gets a channelsEnabled admin surface. Teams/Enterprise orgs 16 * must explicitly opt in via channelsEnabled: true in managed settings. 17 */ 18 19 import type { ServerCapabilities } from '@modelcontextprotocol/sdk/types.js' 20 import { z } from 'zod/v4' 21 import { type ChannelEntry, getAllowedChannels } from '../../bootstrap/state.js' 22 import { CHANNEL_TAG } from '../../constants/xml.js' 23 import { 24 getClaudeAIOAuthTokens, 25 getSubscriptionType, 26 } from '../../utils/auth.js' 27 import { lazySchema } from '../../utils/lazySchema.js' 28 import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js' 29 import { getSettingsForSource } from '../../utils/settings/settings.js' 30 import { escapeXmlAttr } from '../../utils/xml.js' 31 import { 32 type ChannelAllowlistEntry, 33 getChannelAllowlist, 34 isChannelsEnabled, 35 } from './channelAllowlist.js' 36 37 export const ChannelMessageNotificationSchema = lazySchema(() => 38 z.object({ 39 method: z.literal('notifications/claude/channel'), 40 params: z.object({ 41 content: z.string(), 42 // Opaque passthrough — thread_id, user, whatever the channel wants the 43 // model to see. Rendered as attributes on the <channel> tag. 44 meta: z.record(z.string(), z.string()).optional(), 45 }), 46 }), 47 ) 48 49 /** 50 * Structured permission reply from a channel server. Servers that support 51 * this declare `capabilities.experimental['claude/channel/permission']` and 52 * emit this event INSTEAD of relaying "yes tbxkq" as text via 53 * notifications/claude/channel. Explicit opt-in per server — a channel that 54 * just wants to relay text never becomes a permission surface by accident. 55 * 56 * The server parses the user's reply (spec: /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i) 57 * and emits {request_id, behavior}. CC matches request_id against its 58 * pending map. Unlike the regex-intercept approach, text in the general 59 * channel can never accidentally match — approval requires the server 60 * to deliberately emit this specific event. 61 */ 62 export const CHANNEL_PERMISSION_METHOD = 63 'notifications/claude/channel/permission' 64 export const ChannelPermissionNotificationSchema = lazySchema(() => 65 z.object({ 66 method: z.literal(CHANNEL_PERMISSION_METHOD), 67 params: z.object({ 68 request_id: z.string(), 69 behavior: z.enum(['allow', 'deny']), 70 }), 71 }), 72 ) 73 74 /** 75 * Outbound: CC → server. Fired from interactiveHandler.ts when a 76 * permission dialog opens and the server has declared the permission 77 * capability. Server formats the message for its platform (Telegram 78 * markdown, iMessage rich text, Discord embed) and sends it to the 79 * human. When the human replies "yes tbxkq", the server parses that 80 * against PERMISSION_REPLY_RE and emits the inbound schema above. 81 * 82 * Not a zod schema — CC SENDS this, doesn't validate it. A type here 83 * keeps both halves of the protocol documented side by side. 84 */ 85 export const CHANNEL_PERMISSION_REQUEST_METHOD = 86 'notifications/claude/channel/permission_request' 87 export type ChannelPermissionRequestParams = { 88 request_id: string 89 tool_name: string 90 description: string 91 /** JSON-stringified tool input, truncated to 200 chars with …. Full 92 * input is in the local terminal dialog; this is a phone-sized 93 * preview. Server decides whether/how to show it. */ 94 input_preview: string 95 } 96 97 /** 98 * Meta keys become XML attribute NAMES — a crafted key like 99 * `x="" injected="y` would break out of the attribute structure. Only 100 * accept keys that look like plain identifiers. This is stricter than 101 * the XML spec (which allows `:`, `.`, `-`) but channel servers only 102 * send `chat_id`, `user`, `thread_ts`, `message_id` in practice. 103 */ 104 const SAFE_META_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/ 105 106 export function wrapChannelMessage( 107 serverName: string, 108 content: string, 109 meta?: Record<string, string>, 110 ): string { 111 const attrs = Object.entries(meta ?? {}) 112 .filter(([k]) => SAFE_META_KEY.test(k)) 113 .map(([k, v]) => ` ${k}="${escapeXmlAttr(v)}"`) 114 .join('') 115 return `<${CHANNEL_TAG} source="${escapeXmlAttr(serverName)}"${attrs}>\n${content}\n</${CHANNEL_TAG}>` 116 } 117 118 /** 119 * Effective allowlist for the current session. Team/enterprise orgs can set 120 * allowedChannelPlugins in managed settings — when set, it REPLACES the 121 * GrowthBook ledger (admin owns the trust decision). Undefined falls back 122 * to the ledger. Unmanaged users always get the ledger. 123 * 124 * Callers already read sub/policy for the policy gate — pass them in to 125 * avoid double-reading getSettingsForSource (uncached). 126 */ 127 export function getEffectiveChannelAllowlist( 128 sub: ReturnType<typeof getSubscriptionType>, 129 orgList: ChannelAllowlistEntry[] | undefined, 130 ): { 131 entries: ChannelAllowlistEntry[] 132 source: 'org' | 'ledger' 133 } { 134 if ((sub === 'team' || sub === 'enterprise') && orgList) { 135 return { entries: orgList, source: 'org' } 136 } 137 return { entries: getChannelAllowlist(), source: 'ledger' } 138 } 139 140 export type ChannelGateResult = 141 | { action: 'register' } 142 | { 143 action: 'skip' 144 kind: 145 | 'capability' 146 | 'disabled' 147 | 'auth' 148 | 'policy' 149 | 'session' 150 | 'marketplace' 151 | 'allowlist' 152 reason: string 153 } 154 155 /** 156 * Match a connected MCP server against the user's parsed --channels entries. 157 * server-kind is exact match on bare name; plugin-kind matches on the second 158 * segment of plugin:X:Y. Returns the matching entry so callers can read its 159 * kind — that's the user's trust declaration, not inferred from runtime shape. 160 */ 161 export function findChannelEntry( 162 serverName: string, 163 channels: readonly ChannelEntry[], 164 ): ChannelEntry | undefined { 165 // split unconditionally — for a bare name like 'slack', parts is ['slack'] 166 // and the plugin-kind branch correctly never matches (parts[0] !== 'plugin'). 167 const parts = serverName.split(':') 168 return channels.find(c => 169 c.kind === 'server' 170 ? serverName === c.name 171 : parts[0] === 'plugin' && parts[1] === c.name, 172 ) 173 } 174 175 /** 176 * Gate an MCP server's channel-notification path. Caller checks 177 * feature('KAIROS') || feature('KAIROS_CHANNELS') first (build-time 178 * elimination). Gate order: capability → runtime gate (tengu_harbor) → 179 * auth (OAuth only) → org policy → session --channels → allowlist. 180 * API key users are blocked at the auth layer — channels requires 181 * claude.ai auth; console orgs have no admin opt-in surface yet. 182 * 183 * skip Not a channel server, or managed org hasn't opted in, or 184 * not in session --channels. Connection stays up; handler 185 * not registered. 186 * register Subscribe to notifications/claude/channel. 187 * 188 * Which servers can connect at all is governed by allowedMcpServers — 189 * this gate only decides whether the notification handler registers. 190 */ 191 export function gateChannelServer( 192 serverName: string, 193 capabilities: ServerCapabilities | undefined, 194 pluginSource: string | undefined, 195 ): ChannelGateResult { 196 // Channel servers declare `experimental['claude/channel']: {}` (MCP's 197 // presence-signal idiom — same as `tools: {}`). Truthy covers `{}` and 198 // `true`; absent/undefined/explicit-`false` all fail. Key matches the 199 // notification method namespace (notifications/claude/channel). 200 if (!capabilities?.experimental?.['claude/channel']) { 201 return { 202 action: 'skip', 203 kind: 'capability', 204 reason: 'server did not declare claude/channel capability', 205 } 206 } 207 208 // Overall runtime gate. After capability so normal MCP servers never hit 209 // this path. Before auth/policy so the killswitch works regardless of 210 // session state. 211 if (!isChannelsEnabled()) { 212 return { 213 action: 'skip', 214 kind: 'disabled', 215 reason: 'channels feature is not currently available', 216 } 217 } 218 219 // OAuth-only. API key users (console) are blocked — there's no 220 // channelsEnabled admin surface in console yet, so the policy opt-in 221 // flow doesn't exist for them. Drop this when console parity lands. 222 if (!getClaudeAIOAuthTokens()?.accessToken) { 223 return { 224 action: 'skip', 225 kind: 'auth', 226 reason: 'channels requires claude.ai authentication (run /login)', 227 } 228 } 229 230 // Teams/Enterprise opt-in. Managed orgs must explicitly enable channels. 231 // Default OFF — absent or false blocks. Keyed off subscription tier, not 232 // "policy settings exist" — a team org with zero configured policy keys 233 // (remote endpoint returns 404) is still a managed org and must not fall 234 // through to the unmanaged path. 235 const sub = getSubscriptionType() 236 const managed = sub === 'team' || sub === 'enterprise' 237 const policy = managed ? getSettingsForSource('policySettings') : undefined 238 if (managed && policy?.channelsEnabled !== true) { 239 return { 240 action: 'skip', 241 kind: 'policy', 242 reason: 243 'channels not enabled by org policy (set channelsEnabled: true in managed settings)', 244 } 245 } 246 247 // User-level session opt-in. A server must be explicitly listed in 248 // --channels to push inbound this session — protects against a trusted 249 // server surprise-adding the capability. 250 const entry = findChannelEntry(serverName, getAllowedChannels()) 251 if (!entry) { 252 return { 253 action: 'skip', 254 kind: 'session', 255 reason: `server ${serverName} not in --channels list for this session`, 256 } 257 } 258 259 if (entry.kind === 'plugin') { 260 // Marketplace verification: the tag is intent (plugin:slack@anthropic), 261 // the runtime name is just plugin:slack:X — could be slack@anthropic or 262 // slack@evil depending on what's installed. Verify they match before 263 // trusting the tag for the allowlist check below. Source is stashed on 264 // the config at addPluginScopeToServers — undefined (non-plugin server, 265 // shouldn't happen for plugin-kind entry) or @-less (builtin/inline) 266 // both fail the comparison. 267 const actual = pluginSource 268 ? parsePluginIdentifier(pluginSource).marketplace 269 : undefined 270 if (actual !== entry.marketplace) { 271 return { 272 action: 'skip', 273 kind: 'marketplace', 274 reason: `you asked for plugin:${entry.name}@${entry.marketplace} but the installed ${entry.name} plugin is from ${actual ?? 'an unknown source'}`, 275 } 276 } 277 278 // Approved-plugin allowlist. Marketplace gate already verified 279 // tag == reality, so this is a pure entry check. entry.dev (per-entry, 280 // not the session-wide bit) bypasses — so accepting the dev dialog for 281 // one entry doesn't leak allowlist-bypass to --channels entries. 282 if (!entry.dev) { 283 const { entries, source } = getEffectiveChannelAllowlist( 284 sub, 285 policy?.allowedChannelPlugins, 286 ) 287 if ( 288 !entries.some( 289 e => e.plugin === entry.name && e.marketplace === entry.marketplace, 290 ) 291 ) { 292 return { 293 action: 'skip', 294 kind: 'allowlist', 295 reason: 296 source === 'org' 297 ? `plugin ${entry.name}@${entry.marketplace} is not on your org's approved channels list (set allowedChannelPlugins in managed settings)` 298 : `plugin ${entry.name}@${entry.marketplace} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`, 299 } 300 } 301 } 302 } else { 303 // server-kind: allowlist schema is {marketplace, plugin} — a server entry 304 // can never match. Without this, --channels server:plugin:foo:bar would 305 // match a plugin's runtime name and register with no allowlist check. 306 if (!entry.dev) { 307 return { 308 action: 'skip', 309 kind: 'allowlist', 310 reason: `server ${entry.name} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`, 311 } 312 } 313 } 314 315 return { action: 'register' } 316 }