channelNotification.ts 12 KB

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