| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- /**
- * 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' }
- }
|