source dump of claude code
at main 240 lines 9.0 kB view raw
1/** 2 * Permission prompts over channels (Telegram, iMessage, Discord). 3 * 4 * Mirrors `BridgePermissionCallbacks` — when CC hits a permission dialog, 5 * it ALSO sends the prompt via active channels and races the reply against 6 * local UI / bridge / hooks / classifier. First resolver wins via claim(). 7 * 8 * Inbound is a structured event: the server parses the user's "yes tbxkq" 9 * reply and emits notifications/claude/channel/permission with 10 * {request_id, behavior}. CC never sees the reply as text — approval 11 * requires the server to deliberately emit that specific event, not just 12 * relay content. Servers opt in by declaring 13 * capabilities.experimental['claude/channel/permission']. 14 * 15 * Kenneth's "would this let Claude self-approve?": the approving party is 16 * the human via the channel, not Claude. But the trust boundary isn't the 17 * terminal — it's the allowlist (tengu_harbor_ledger). A compromised 18 * channel server CAN fabricate "yes <id>" without the human seeing the 19 * prompt. Accepted risk: a compromised channel already has unlimited 20 * conversation-injection turns (social-engineer over time, wait for 21 * acceptEdits, etc.); inject-then-self-approve is faster, not more 22 * capable. The dialog slows a compromised channel; it doesn't stop one. 23 * See PR discussion 2956440848. 24 */ 25 26import { jsonStringify } from '../../utils/slowOperations.js' 27import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 28 29/** 30 * GrowthBook runtime gate — separate from the channels gate (tengu_harbor) 31 * so channels can ship without permission-relay riding along (Kenneth: "no 32 * bake time if it goes out tomorrow"). Default false; flip without a release. 33 * Checked once at useManageMCPConnections mount — mid-session flag changes 34 * don't apply until restart. 35 */ 36export function isChannelPermissionRelayEnabled(): boolean { 37 return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor_permissions', false) 38} 39 40export type ChannelPermissionResponse = { 41 behavior: 'allow' | 'deny' 42 /** Which channel server the reply came from (e.g., "plugin:telegram:tg"). */ 43 fromServer: string 44} 45 46export type ChannelPermissionCallbacks = { 47 /** Register a resolver for a request ID. Returns unsubscribe. */ 48 onResponse( 49 requestId: string, 50 handler: (response: ChannelPermissionResponse) => void, 51 ): () => void 52 /** Resolve a pending request from a structured channel event 53 * (notifications/claude/channel/permission). Returns true if the ID 54 * was pending — the server parsed the user's reply and emitted 55 * {request_id, behavior}; we just match against the map. */ 56 resolve( 57 requestId: string, 58 behavior: 'allow' | 'deny', 59 fromServer: string, 60 ): boolean 61} 62 63/** 64 * Reply format spec for channel servers to implement: 65 * /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i 66 * 67 * 5 lowercase letters, no 'l' (looks like 1/I). Case-insensitive (phone 68 * autocorrect). No bare yes/no (conversational). No prefix/suffix chatter. 69 * 70 * CC generates the ID and sends the prompt. The SERVER parses the user's 71 * reply and emits notifications/claude/channel/permission with {request_id, 72 * behavior} — CC doesn't regex-match text anymore. Exported so plugins can 73 * import the exact regex rather than hand-copying it. 74 */ 75export const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i 76 77// 25-letter alphabet: a-z minus 'l' (looks like 1/I). 25^5 ≈ 9.8M space. 78const ID_ALPHABET = 'abcdefghijkmnopqrstuvwxyz' 79 80// Substring blocklist — 5 random letters can spell things (Kenneth, in the 81// launch thread: "this is why i bias to numbers, hard to have anything worse 82// than 80085"). Non-exhaustive, covers the send-to-your-boss-by-accident 83// tier. If a generated ID contains any of these, re-hash with a salt. 84// prettier-ignore 85const ID_AVOID_SUBSTRINGS = [ 86 'fuck', 87 'shit', 88 'cunt', 89 'cock', 90 'dick', 91 'twat', 92 'piss', 93 'crap', 94 'bitch', 95 'whore', 96 'ass', 97 'tit', 98 'cum', 99 'fag', 100 'dyke', 101 'nig', 102 'kike', 103 'rape', 104 'nazi', 105 'damn', 106 'poo', 107 'pee', 108 'wank', 109 'anus', 110] 111 112function hashToId(input: string): string { 113 // FNV-1a → uint32, then base-25 encode. Not crypto, just a stable 114 // short letters-only ID. 32 bits / log2(25) ≈ 6.9 letters of entropy; 115 // taking 5 wastes a little, plenty for this. 116 let h = 0x811c9dc5 117 for (let i = 0; i < input.length; i++) { 118 h ^= input.charCodeAt(i) 119 h = Math.imul(h, 0x01000193) 120 } 121 h = h >>> 0 122 let s = '' 123 for (let i = 0; i < 5; i++) { 124 s += ID_ALPHABET[h % 25] 125 h = Math.floor(h / 25) 126 } 127 return s 128} 129 130/** 131 * Short ID from a toolUseID. 5 letters from a 25-char alphabet (a-z minus 132 * 'l' — looks like 1/I in many fonts). 25^5 ≈ 9.8M space, birthday 133 * collision at 50% needs ~3K simultaneous pending prompts, absurd for a 134 * single interactive session. Letters-only so phone users don't switch 135 * keyboard modes (hex alternates a-f/0-9 → mode toggles). Re-hashes with 136 * a salt suffix if the result contains a blocklisted substring — 5 random 137 * letters can spell things you don't want in a text message to your phone. 138 * toolUseIDs are `toolu_` + base64-ish; we hash rather than slice. 139 */ 140export function shortRequestId(toolUseID: string): string { 141 // 7 length-3 × 3 positions × 25² + 15 length-4 × 2 × 25 + 2 length-5 142 // ≈ 13,877 blocked IDs out of 9.8M — roughly 1 in 700 hits the blocklist. 143 // Cap at 10 retries; (1/700)^10 is negligible. 144 let candidate = hashToId(toolUseID) 145 for (let salt = 0; salt < 10; salt++) { 146 if (!ID_AVOID_SUBSTRINGS.some(bad => candidate.includes(bad))) { 147 return candidate 148 } 149 candidate = hashToId(`${toolUseID}:${salt}`) 150 } 151 return candidate 152} 153 154/** 155 * Truncate tool input to a phone-sized JSON preview. 200 chars is 156 * roughly 3 lines on a narrow phone screen. Full input is in the local 157 * terminal dialog; the channel gets a summary so Write(5KB-file) doesn't 158 * flood your texts. Server decides whether/how to show it. 159 */ 160export function truncateForPreview(input: unknown): string { 161 try { 162 const s = jsonStringify(input) 163 return s.length > 200 ? s.slice(0, 200) + '…' : s 164 } catch { 165 return '(unserializable)' 166 } 167} 168 169/** 170 * Filter MCP clients down to those that can relay permission prompts. 171 * Three conditions, ALL required: connected + in the session's --channels 172 * allowlist + declares BOTH capabilities. The second capability is the 173 * server's explicit opt-in — a relay-only channel never becomes a 174 * permission surface by accident (Kenneth's "users may be unpleasantly 175 * surprised"). Centralized here so a future fourth condition lands once. 176 */ 177export function filterPermissionRelayClients< 178 T extends { 179 type: string 180 name: string 181 capabilities?: { experimental?: Record<string, unknown> } 182 }, 183>( 184 clients: readonly T[], 185 isInAllowlist: (name: string) => boolean, 186): (T & { type: 'connected' })[] { 187 return clients.filter( 188 (c): c is T & { type: 'connected' } => 189 c.type === 'connected' && 190 isInAllowlist(c.name) && 191 c.capabilities?.experimental?.['claude/channel'] !== undefined && 192 c.capabilities?.experimental?.['claude/channel/permission'] !== undefined, 193 ) 194} 195 196/** 197 * Factory for the callbacks object. The pending Map is closed over — NOT 198 * module-level (per src/CLAUDE.md), NOT in AppState (functions-in-state 199 * causes issues with equality/serialization). Same lifetime pattern as 200 * `replBridgePermissionCallbacks`: constructed once per session inside 201 * a React hook, stable reference stored in AppState. 202 * 203 * resolve() is called from the dedicated notification handler 204 * (notifications/claude/channel/permission) with the structured payload. 205 * The server already parsed "yes tbxkq" → {request_id, behavior}; we just 206 * match against the pending map. No regex on CC's side — text in the 207 * general channel can't accidentally approve anything. 208 */ 209export function createChannelPermissionCallbacks(): ChannelPermissionCallbacks { 210 const pending = new Map< 211 string, 212 (response: ChannelPermissionResponse) => void 213 >() 214 215 return { 216 onResponse(requestId, handler) { 217 // Lowercase here too — resolve() already does; asymmetry means a 218 // future caller passing a mixed-case ID would silently never match. 219 // shortRequestId always emits lowercase so this is a noop today, 220 // but the symmetry makes the contract explicit. 221 const key = requestId.toLowerCase() 222 pending.set(key, handler) 223 return () => { 224 pending.delete(key) 225 } 226 }, 227 228 resolve(requestId, behavior, fromServer) { 229 const key = requestId.toLowerCase() 230 const resolver = pending.get(key) 231 if (!resolver) return false 232 // Delete BEFORE calling — if resolver throws or re-enters, the 233 // entry is already gone. Also handles duplicate events (second 234 // emission falls through — server bug or network dup, ignore). 235 pending.delete(key) 236 resolver({ behavior, fromServer }) 237 return true 238 }, 239 } 240}