mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 17:06:57 +10:00
241 lines
8.8 KiB
TypeScript
241 lines
8.8 KiB
TypeScript
|
|
/**
|
|||
|
|
* Permission prompts over channels (Telegram, iMessage, Discord).
|
|||
|
|
*
|
|||
|
|
* Mirrors `BridgePermissionCallbacks` — when CC hits a permission dialog,
|
|||
|
|
* it ALSO sends the prompt via active channels and races the reply against
|
|||
|
|
* local UI / bridge / hooks / classifier. First resolver wins via claim().
|
|||
|
|
*
|
|||
|
|
* Inbound is a structured event: the server parses the user's "yes tbxkq"
|
|||
|
|
* reply and emits notifications/claude/channel/permission with
|
|||
|
|
* {request_id, behavior}. CC never sees the reply as text — approval
|
|||
|
|
* requires the server to deliberately emit that specific event, not just
|
|||
|
|
* relay content. Servers opt in by declaring
|
|||
|
|
* capabilities.experimental['claude/channel/permission'].
|
|||
|
|
*
|
|||
|
|
* Kenneth's "would this let Claude self-approve?": the approving party is
|
|||
|
|
* the human via the channel, not Claude. But the trust boundary isn't the
|
|||
|
|
* terminal — it's the allowlist (tengu_harbor_ledger). A compromised
|
|||
|
|
* channel server CAN fabricate "yes <id>" without the human seeing the
|
|||
|
|
* prompt. Accepted risk: a compromised channel already has unlimited
|
|||
|
|
* conversation-injection turns (social-engineer over time, wait for
|
|||
|
|
* acceptEdits, etc.); inject-then-self-approve is faster, not more
|
|||
|
|
* capable. The dialog slows a compromised channel; it doesn't stop one.
|
|||
|
|
* See PR discussion 2956440848.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { jsonStringify } from '../../utils/slowOperations.js'
|
|||
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* GrowthBook runtime gate — separate from the channels gate (tengu_harbor)
|
|||
|
|
* so channels can ship without permission-relay riding along (Kenneth: "no
|
|||
|
|
* bake time if it goes out tomorrow"). Default false; flip without a release.
|
|||
|
|
* Checked once at useManageMCPConnections mount — mid-session flag changes
|
|||
|
|
* don't apply until restart.
|
|||
|
|
*/
|
|||
|
|
export function isChannelPermissionRelayEnabled(): boolean {
|
|||
|
|
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor_permissions', false)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type ChannelPermissionResponse = {
|
|||
|
|
behavior: 'allow' | 'deny'
|
|||
|
|
/** Which channel server the reply came from (e.g., "plugin:telegram:tg"). */
|
|||
|
|
fromServer: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type ChannelPermissionCallbacks = {
|
|||
|
|
/** Register a resolver for a request ID. Returns unsubscribe. */
|
|||
|
|
onResponse(
|
|||
|
|
requestId: string,
|
|||
|
|
handler: (response: ChannelPermissionResponse) => void,
|
|||
|
|
): () => void
|
|||
|
|
/** Resolve a pending request from a structured channel event
|
|||
|
|
* (notifications/claude/channel/permission). Returns true if the ID
|
|||
|
|
* was pending — the server parsed the user's reply and emitted
|
|||
|
|
* {request_id, behavior}; we just match against the map. */
|
|||
|
|
resolve(
|
|||
|
|
requestId: string,
|
|||
|
|
behavior: 'allow' | 'deny',
|
|||
|
|
fromServer: string,
|
|||
|
|
): boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Reply format spec for channel servers to implement:
|
|||
|
|
* /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
|
|||
|
|
*
|
|||
|
|
* 5 lowercase letters, no 'l' (looks like 1/I). Case-insensitive (phone
|
|||
|
|
* autocorrect). No bare yes/no (conversational). No prefix/suffix chatter.
|
|||
|
|
*
|
|||
|
|
* CC generates the ID and sends the prompt. The SERVER parses the user's
|
|||
|
|
* reply and emits notifications/claude/channel/permission with {request_id,
|
|||
|
|
* behavior} — CC doesn't regex-match text anymore. Exported so plugins can
|
|||
|
|
* import the exact regex rather than hand-copying it.
|
|||
|
|
*/
|
|||
|
|
export const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
|
|||
|
|
|
|||
|
|
// 25-letter alphabet: a-z minus 'l' (looks like 1/I). 25^5 ≈ 9.8M space.
|
|||
|
|
const ID_ALPHABET = 'abcdefghijkmnopqrstuvwxyz'
|
|||
|
|
|
|||
|
|
// Substring blocklist — 5 random letters can spell things (Kenneth, in the
|
|||
|
|
// launch thread: "this is why i bias to numbers, hard to have anything worse
|
|||
|
|
// than 80085"). Non-exhaustive, covers the send-to-your-boss-by-accident
|
|||
|
|
// tier. If a generated ID contains any of these, re-hash with a salt.
|
|||
|
|
// prettier-ignore
|
|||
|
|
const ID_AVOID_SUBSTRINGS = [
|
|||
|
|
'fuck',
|
|||
|
|
'shit',
|
|||
|
|
'cunt',
|
|||
|
|
'cock',
|
|||
|
|
'dick',
|
|||
|
|
'twat',
|
|||
|
|
'piss',
|
|||
|
|
'crap',
|
|||
|
|
'bitch',
|
|||
|
|
'whore',
|
|||
|
|
'ass',
|
|||
|
|
'tit',
|
|||
|
|
'cum',
|
|||
|
|
'fag',
|
|||
|
|
'dyke',
|
|||
|
|
'nig',
|
|||
|
|
'kike',
|
|||
|
|
'rape',
|
|||
|
|
'nazi',
|
|||
|
|
'damn',
|
|||
|
|
'poo',
|
|||
|
|
'pee',
|
|||
|
|
'wank',
|
|||
|
|
'anus',
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
function hashToId(input: string): string {
|
|||
|
|
// FNV-1a → uint32, then base-25 encode. Not crypto, just a stable
|
|||
|
|
// short letters-only ID. 32 bits / log2(25) ≈ 6.9 letters of entropy;
|
|||
|
|
// taking 5 wastes a little, plenty for this.
|
|||
|
|
let h = 0x811c9dc5
|
|||
|
|
for (let i = 0; i < input.length; i++) {
|
|||
|
|
h ^= input.charCodeAt(i)
|
|||
|
|
h = Math.imul(h, 0x01000193)
|
|||
|
|
}
|
|||
|
|
h = h >>> 0
|
|||
|
|
let s = ''
|
|||
|
|
for (let i = 0; i < 5; i++) {
|
|||
|
|
s += ID_ALPHABET[h % 25]
|
|||
|
|
h = Math.floor(h / 25)
|
|||
|
|
}
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Short ID from a toolUseID. 5 letters from a 25-char alphabet (a-z minus
|
|||
|
|
* 'l' — looks like 1/I in many fonts). 25^5 ≈ 9.8M space, birthday
|
|||
|
|
* collision at 50% needs ~3K simultaneous pending prompts, absurd for a
|
|||
|
|
* single interactive session. Letters-only so phone users don't switch
|
|||
|
|
* keyboard modes (hex alternates a-f/0-9 → mode toggles). Re-hashes with
|
|||
|
|
* a salt suffix if the result contains a blocklisted substring — 5 random
|
|||
|
|
* letters can spell things you don't want in a text message to your phone.
|
|||
|
|
* toolUseIDs are `toolu_` + base64-ish; we hash rather than slice.
|
|||
|
|
*/
|
|||
|
|
export function shortRequestId(toolUseID: string): string {
|
|||
|
|
// 7 length-3 × 3 positions × 25² + 15 length-4 × 2 × 25 + 2 length-5
|
|||
|
|
// ≈ 13,877 blocked IDs out of 9.8M — roughly 1 in 700 hits the blocklist.
|
|||
|
|
// Cap at 10 retries; (1/700)^10 is negligible.
|
|||
|
|
let candidate = hashToId(toolUseID)
|
|||
|
|
for (let salt = 0; salt < 10; salt++) {
|
|||
|
|
if (!ID_AVOID_SUBSTRINGS.some(bad => candidate.includes(bad))) {
|
|||
|
|
return candidate
|
|||
|
|
}
|
|||
|
|
candidate = hashToId(`${toolUseID}:${salt}`)
|
|||
|
|
}
|
|||
|
|
return candidate
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Truncate tool input to a phone-sized JSON preview. 200 chars is
|
|||
|
|
* roughly 3 lines on a narrow phone screen. Full input is in the local
|
|||
|
|
* terminal dialog; the channel gets a summary so Write(5KB-file) doesn't
|
|||
|
|
* flood your texts. Server decides whether/how to show it.
|
|||
|
|
*/
|
|||
|
|
export function truncateForPreview(input: unknown): string {
|
|||
|
|
try {
|
|||
|
|
const s = jsonStringify(input)
|
|||
|
|
return s.length > 200 ? s.slice(0, 200) + '…' : s
|
|||
|
|
} catch {
|
|||
|
|
return '(unserializable)'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Filter MCP clients down to those that can relay permission prompts.
|
|||
|
|
* Three conditions, ALL required: connected + in the session's --channels
|
|||
|
|
* allowlist + declares BOTH capabilities. The second capability is the
|
|||
|
|
* server's explicit opt-in — a relay-only channel never becomes a
|
|||
|
|
* permission surface by accident (Kenneth's "users may be unpleasantly
|
|||
|
|
* surprised"). Centralized here so a future fourth condition lands once.
|
|||
|
|
*/
|
|||
|
|
export function filterPermissionRelayClients<
|
|||
|
|
T extends {
|
|||
|
|
type: string
|
|||
|
|
name: string
|
|||
|
|
capabilities?: { experimental?: Record<string, unknown> }
|
|||
|
|
},
|
|||
|
|
>(
|
|||
|
|
clients: readonly T[],
|
|||
|
|
isInAllowlist: (name: string) => boolean,
|
|||
|
|
): (T & { type: 'connected' })[] {
|
|||
|
|
return clients.filter(
|
|||
|
|
(c): c is T & { type: 'connected' } =>
|
|||
|
|
c.type === 'connected' &&
|
|||
|
|
isInAllowlist(c.name) &&
|
|||
|
|
c.capabilities?.experimental?.['claude/channel'] !== undefined &&
|
|||
|
|
c.capabilities?.experimental?.['claude/channel/permission'] !== undefined,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Factory for the callbacks object. The pending Map is closed over — NOT
|
|||
|
|
* module-level (per src/CLAUDE.md), NOT in AppState (functions-in-state
|
|||
|
|
* causes issues with equality/serialization). Same lifetime pattern as
|
|||
|
|
* `replBridgePermissionCallbacks`: constructed once per session inside
|
|||
|
|
* a React hook, stable reference stored in AppState.
|
|||
|
|
*
|
|||
|
|
* resolve() is called from the dedicated notification handler
|
|||
|
|
* (notifications/claude/channel/permission) with the structured payload.
|
|||
|
|
* The server already parsed "yes tbxkq" → {request_id, behavior}; we just
|
|||
|
|
* match against the pending map. No regex on CC's side — text in the
|
|||
|
|
* general channel can't accidentally approve anything.
|
|||
|
|
*/
|
|||
|
|
export function createChannelPermissionCallbacks(): ChannelPermissionCallbacks {
|
|||
|
|
const pending = new Map<
|
|||
|
|
string,
|
|||
|
|
(response: ChannelPermissionResponse) => void
|
|||
|
|
>()
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
onResponse(requestId, handler) {
|
|||
|
|
// Lowercase here too — resolve() already does; asymmetry means a
|
|||
|
|
// future caller passing a mixed-case ID would silently never match.
|
|||
|
|
// shortRequestId always emits lowercase so this is a noop today,
|
|||
|
|
// but the symmetry makes the contract explicit.
|
|||
|
|
const key = requestId.toLowerCase()
|
|||
|
|
pending.set(key, handler)
|
|||
|
|
return () => {
|
|||
|
|
pending.delete(key)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
resolve(requestId, behavior, fromServer) {
|
|||
|
|
const key = requestId.toLowerCase()
|
|||
|
|
const resolver = pending.get(key)
|
|||
|
|
if (!resolver) return false
|
|||
|
|
// Delete BEFORE calling — if resolver throws or re-enters, the
|
|||
|
|
// entry is already gone. Also handles duplicate events (second
|
|||
|
|
// emission falls through — server bug or network dup, ignore).
|
|||
|
|
pending.delete(key)
|
|||
|
|
resolver({ behavior, fromServer })
|
|||
|
|
return true
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
}
|