File size: 12,540 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | /**
* 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' }
}
|