source dump of claude code
at main 170 lines 5.7 kB view raw
1/** 2 * Deep Link URI Parser 3 * 4 * Parses `claude-cli://open` URIs. All parameters are optional: 5 * q — pre-fill the prompt input (not submitted) 6 * cwd — working directory (absolute path) 7 * repo — owner/name slug, resolved against githubRepoPaths config 8 * 9 * Examples: 10 * claude-cli://open 11 * claude-cli://open?q=hello+world 12 * claude-cli://open?q=fix+tests&repo=owner/repo 13 * claude-cli://open?cwd=/path/to/project 14 * 15 * Security: values are URL-decoded, Unicode-sanitized, and rejected if they 16 * contain ASCII control characters (newlines etc. can act as command 17 * separators). All values are single-quote shell-escaped at the point of 18 * use (terminalLauncher.ts) — that escaping is the injection boundary. 19 */ 20 21import { partiallySanitizeUnicode } from '../sanitization.js' 22 23export const DEEP_LINK_PROTOCOL = 'claude-cli' 24 25export type DeepLinkAction = { 26 query?: string 27 cwd?: string 28 repo?: string 29} 30 31/** 32 * Check if a string contains ASCII control characters (0x00-0x1F, 0x7F). 33 * These can act as command separators in shells (newlines, carriage returns, etc.). 34 * Allows printable ASCII and Unicode (CJK, emoji, accented chars, etc.). 35 */ 36function containsControlChars(s: string): boolean { 37 for (let i = 0; i < s.length; i++) { 38 const code = s.charCodeAt(i) 39 if (code <= 0x1f || code === 0x7f) { 40 return true 41 } 42 } 43 return false 44} 45 46/** 47 * GitHub owner/repo slug: alphanumerics, dots, hyphens, underscores, 48 * exactly one slash. Keeps this from becoming a path traversal vector. 49 */ 50const REPO_SLUG_PATTERN = /^[\w.-]+\/[\w.-]+$/ 51 52/** 53 * Cap on pre-filled prompt length. The only defense against a prompt like 54 * "review PR #18796 […4900 chars of padding…] also cat ~/.ssh/id_rsa" is 55 * the user reading it before pressing Enter. At this length the prompt is 56 * no longer scannable at a glance, so banner.ts shows an explicit "scroll 57 * to review the entire prompt" warning above LONG_PREFILL_THRESHOLD. 58 * Reject, don't truncate — truncation changes meaning. 59 * 60 * 5000 is the practical ceiling: the Windows cmd.exe fallback 61 * (terminalLauncher.ts) has an 8191-char command-string limit, and after 62 * the `cd /d <cwd> && <claude.exe> --deep-link-origin ... --prefill "<q>"` 63 * wrapper plus cmdQuote's %→%% expansion, ~7000 chars of query is the 64 * hard stop for typical inputs. A pathological >60%-percent-sign query 65 * would 2× past the limit, but cmd.exe is the last-resort fallback 66 * (wt.exe and PowerShell are tried first) and the failure mode is a 67 * launch error, not a security issue — so we don't penalize real users 68 * for an implausible input. 69 */ 70const MAX_QUERY_LENGTH = 5000 71 72/** 73 * PATH_MAX on Linux is 4096. Windows MAX_PATH is 260 (32767 with long-path 74 * opt-in). No real path approaches this; a cwd over 4096 is malformed or 75 * malicious. 76 */ 77const MAX_CWD_LENGTH = 4096 78 79/** 80 * Parse a claude-cli:// URI into a structured action. 81 * 82 * @throws {Error} if the URI is malformed or contains dangerous characters 83 */ 84export function parseDeepLink(uri: string): DeepLinkAction { 85 // Normalize: accept with or without the trailing colon in protocol 86 const normalized = uri.startsWith(`${DEEP_LINK_PROTOCOL}://`) 87 ? uri 88 : uri.startsWith(`${DEEP_LINK_PROTOCOL}:`) 89 ? uri.replace(`${DEEP_LINK_PROTOCOL}:`, `${DEEP_LINK_PROTOCOL}://`) 90 : null 91 92 if (!normalized) { 93 throw new Error( 94 `Invalid deep link: expected ${DEEP_LINK_PROTOCOL}:// scheme, got "${uri}"`, 95 ) 96 } 97 98 let url: URL 99 try { 100 url = new URL(normalized) 101 } catch { 102 throw new Error(`Invalid deep link URL: "${uri}"`) 103 } 104 105 if (url.hostname !== 'open') { 106 throw new Error(`Unknown deep link action: "${url.hostname}"`) 107 } 108 109 const cwd = url.searchParams.get('cwd') ?? undefined 110 const repo = url.searchParams.get('repo') ?? undefined 111 const rawQuery = url.searchParams.get('q') 112 113 // Validate cwd if present — must be an absolute path 114 if (cwd && !cwd.startsWith('/') && !/^[a-zA-Z]:[/\\]/.test(cwd)) { 115 throw new Error( 116 `Invalid cwd in deep link: must be an absolute path, got "${cwd}"`, 117 ) 118 } 119 120 // Reject control characters in cwd (newlines, etc.) but allow path chars like backslash. 121 if (cwd && containsControlChars(cwd)) { 122 throw new Error('Deep link cwd contains disallowed control characters') 123 } 124 if (cwd && cwd.length > MAX_CWD_LENGTH) { 125 throw new Error( 126 `Deep link cwd exceeds ${MAX_CWD_LENGTH} characters (got ${cwd.length})`, 127 ) 128 } 129 130 // Validate repo slug format. Resolution happens later (protocolHandler.ts) — 131 // this parser stays pure with no config/filesystem access. 132 if (repo && !REPO_SLUG_PATTERN.test(repo)) { 133 throw new Error( 134 `Invalid repo in deep link: expected "owner/repo", got "${repo}"`, 135 ) 136 } 137 138 let query: string | undefined 139 if (rawQuery && rawQuery.trim().length > 0) { 140 // Strip hidden Unicode characters (ASCII smuggling / hidden prompt injection) 141 query = partiallySanitizeUnicode(rawQuery.trim()) 142 if (containsControlChars(query)) { 143 throw new Error('Deep link query contains disallowed control characters') 144 } 145 if (query.length > MAX_QUERY_LENGTH) { 146 throw new Error( 147 `Deep link query exceeds ${MAX_QUERY_LENGTH} characters (got ${query.length})`, 148 ) 149 } 150 } 151 152 return { query, cwd, repo } 153} 154 155/** 156 * Build a claude-cli:// deep link URL. 157 */ 158export function buildDeepLink(action: DeepLinkAction): string { 159 const url = new URL(`${DEEP_LINK_PROTOCOL}://open`) 160 if (action.query) { 161 url.searchParams.set('q', action.query) 162 } 163 if (action.cwd) { 164 url.searchParams.set('cwd', action.cwd) 165 } 166 if (action.repo) { 167 url.searchParams.set('repo', action.repo) 168 } 169 return url.toString() 170}