Filemedium importancesource

parseDeepLink.ts

utils/deepLink/parseDeepLink.ts

171
Lines
5715
Bytes
4
Exports
1
Imports
10
Keywords

What this is

This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.

Beginner explanation

This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.

How it is used

Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.

Expert explanation

Architecturally, this file intersects with ui-flow. It contains 171 lines, 1 detected imports, and 4 detected exports.

Important relationships

Detected exports

  • DEEP_LINK_PROTOCOL
  • DeepLinkAction
  • parseDeepLink
  • buildDeepLink

Keywords

repoquerydeeplinkcharactersactionthrowclaude-clilengthopen

Detected imports

  • ../sanitization.js

Source notes

This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.

Open parent directory

Full source

/**
 * Deep Link URI Parser
 *
 * Parses `claude-cli://open` URIs. All parameters are optional:
 *   q    — pre-fill the prompt input (not submitted)
 *   cwd  — working directory (absolute path)
 *   repo — owner/name slug, resolved against githubRepoPaths config
 *
 * Examples:
 *   claude-cli://open
 *   claude-cli://open?q=hello+world
 *   claude-cli://open?q=fix+tests&repo=owner/repo
 *   claude-cli://open?cwd=/path/to/project
 *
 * Security: values are URL-decoded, Unicode-sanitized, and rejected if they
 * contain ASCII control characters (newlines etc. can act as command
 * separators). All values are single-quote shell-escaped at the point of
 * use (terminalLauncher.ts) — that escaping is the injection boundary.
 */

import { partiallySanitizeUnicode } from '../sanitization.js'

export const DEEP_LINK_PROTOCOL = 'claude-cli'

export type DeepLinkAction = {
  query?: string
  cwd?: string
  repo?: string
}

/**
 * Check if a string contains ASCII control characters (0x00-0x1F, 0x7F).
 * These can act as command separators in shells (newlines, carriage returns, etc.).
 * Allows printable ASCII and Unicode (CJK, emoji, accented chars, etc.).
 */
function containsControlChars(s: string): boolean {
  for (let i = 0; i < s.length; i++) {
    const code = s.charCodeAt(i)
    if (code <= 0x1f || code === 0x7f) {
      return true
    }
  }
  return false
}

/**
 * GitHub owner/repo slug: alphanumerics, dots, hyphens, underscores,
 * exactly one slash. Keeps this from becoming a path traversal vector.
 */
const REPO_SLUG_PATTERN = /^[\w.-]+\/[\w.-]+$/

/**
 * Cap on pre-filled prompt length. The only defense against a prompt like
 * "review PR #18796 […4900 chars of padding…] also cat ~/.ssh/id_rsa" is
 * the user reading it before pressing Enter. At this length the prompt is
 * no longer scannable at a glance, so banner.ts shows an explicit "scroll
 * to review the entire prompt" warning above LONG_PREFILL_THRESHOLD.
 * Reject, don't truncate — truncation changes meaning.
 *
 * 5000 is the practical ceiling: the Windows cmd.exe fallback
 * (terminalLauncher.ts) has an 8191-char command-string limit, and after
 * the `cd /d <cwd> && <claude.exe> --deep-link-origin ... --prefill "<q>"`
 * wrapper plus cmdQuote's %→%% expansion, ~7000 chars of query is the
 * hard stop for typical inputs. A pathological >60%-percent-sign query
 * would 2× past the limit, but cmd.exe is the last-resort fallback
 * (wt.exe and PowerShell are tried first) and the failure mode is a
 * launch error, not a security issue — so we don't penalize real users
 * for an implausible input.
 */
const MAX_QUERY_LENGTH = 5000

/**
 * PATH_MAX on Linux is 4096. Windows MAX_PATH is 260 (32767 with long-path
 * opt-in). No real path approaches this; a cwd over 4096 is malformed or
 * malicious.
 */
const MAX_CWD_LENGTH = 4096

/**
 * Parse a claude-cli:// URI into a structured action.
 *
 * @throws {Error} if the URI is malformed or contains dangerous characters
 */
export function parseDeepLink(uri: string): DeepLinkAction {
  // Normalize: accept with or without the trailing colon in protocol
  const normalized = uri.startsWith(`${DEEP_LINK_PROTOCOL}://`)
    ? uri
    : uri.startsWith(`${DEEP_LINK_PROTOCOL}:`)
      ? uri.replace(`${DEEP_LINK_PROTOCOL}:`, `${DEEP_LINK_PROTOCOL}://`)
      : null

  if (!normalized) {
    throw new Error(
      `Invalid deep link: expected ${DEEP_LINK_PROTOCOL}:// scheme, got "${uri}"`,
    )
  }

  let url: URL
  try {
    url = new URL(normalized)
  } catch {
    throw new Error(`Invalid deep link URL: "${uri}"`)
  }

  if (url.hostname !== 'open') {
    throw new Error(`Unknown deep link action: "${url.hostname}"`)
  }

  const cwd = url.searchParams.get('cwd') ?? undefined
  const repo = url.searchParams.get('repo') ?? undefined
  const rawQuery = url.searchParams.get('q')

  // Validate cwd if present — must be an absolute path
  if (cwd && !cwd.startsWith('/') && !/^[a-zA-Z]:[/\\]/.test(cwd)) {
    throw new Error(
      `Invalid cwd in deep link: must be an absolute path, got "${cwd}"`,
    )
  }

  // Reject control characters in cwd (newlines, etc.) but allow path chars like backslash.
  if (cwd && containsControlChars(cwd)) {
    throw new Error('Deep link cwd contains disallowed control characters')
  }
  if (cwd && cwd.length > MAX_CWD_LENGTH) {
    throw new Error(
      `Deep link cwd exceeds ${MAX_CWD_LENGTH} characters (got ${cwd.length})`,
    )
  }

  // Validate repo slug format. Resolution happens later (protocolHandler.ts) —
  // this parser stays pure with no config/filesystem access.
  if (repo && !REPO_SLUG_PATTERN.test(repo)) {
    throw new Error(
      `Invalid repo in deep link: expected "owner/repo", got "${repo}"`,
    )
  }

  let query: string | undefined
  if (rawQuery && rawQuery.trim().length > 0) {
    // Strip hidden Unicode characters (ASCII smuggling / hidden prompt injection)
    query = partiallySanitizeUnicode(rawQuery.trim())
    if (containsControlChars(query)) {
      throw new Error('Deep link query contains disallowed control characters')
    }
    if (query.length > MAX_QUERY_LENGTH) {
      throw new Error(
        `Deep link query exceeds ${MAX_QUERY_LENGTH} characters (got ${query.length})`,
      )
    }
  }

  return { query, cwd, repo }
}

/**
 * Build a claude-cli:// deep link URL.
 */
export function buildDeepLink(action: DeepLinkAction): string {
  const url = new URL(`${DEEP_LINK_PROTOCOL}://open`)
  if (action.query) {
    url.searchParams.set('q', action.query)
  }
  if (action.cwd) {
    url.searchParams.set('cwd', action.cwd)
  }
  if (action.repo) {
    url.searchParams.set('repo', action.repo)
  }
  return url.toString()
}