/ utils / deepLink / parseDeepLink.ts
parseDeepLink.ts
  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  
 21  import { partiallySanitizeUnicode } from '../sanitization.js'
 22  
 23  export const DEEP_LINK_PROTOCOL = 'claude-cli'
 24  
 25  export 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   */
 36  function 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   */
 50  const 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   */
 70  const 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   */
 77  const 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   */
 84  export 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   */
158  export 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  }