터미널 상태줄을 클릭 가능하게: OSC8 하이퍼링크와 두 가지 보안 함정

OSC8은 9줄짜리 함수로 터미널 텍스트를 클릭 가능한 링크로 만들어 줍니다. 단, segment 단위 URL 인코딩과 git remote URL의 자격증명 stripping이라는 두 가지 함정을 미리 잡지 않으면 토큰을 escape sequence 안에 그대로 노출시킬 수 있습니다.

1. 문제 상황 — 브랜치명을 클릭하면 GitHub로 가면 안 될까?

claude-dashboard 상태줄에는 현재 git 브랜치가 표시됩니다.

📁 claude-dashboard (main ↑3)

상태줄 자체는 만족스러웠는데, 매번 브랜치 정보를 확인하고 PR을 보러 갈 때 흐름이 거슬렸습니다.

  1. 상태줄에서 main ↑3을 봄 → "어, 푸시 안 한 게 3개 있네"
  2. 브라우저로 이동
  3. GitHub 주소를 손으로 입력하거나 즐겨찾기에서 찾음
  4. 해당 브랜치/리포 페이지로 이동

왜 그냥 브랜치명을 클릭하면 안 되는 걸까요? Cursor나 VS Code 통합 터미널이라면 마우스로 git 브랜치명에 호버하고 클릭하면 GitHub의 해당 브랜치 페이지로 바로 가야 자연스러울 것 같았습니다.

답은 의외로 간단합니다. OSC8이라는 거의 잊혀진 ANSI escape sequence가 이미 그 일을 해 줍니다.


2. OSC8이라는, 거의 잊혀진 escape sequence

OSC8(Operating System Command 8)은 터미널 텍스트에 하이퍼링크를 붙이는 표준 escape sequence입니다. 2017년쯤 GNOME Terminal과 iTerm2를 비롯한 모던 터미널들이 합의해 도입한 사양인데, 막상 잘 쓰이지 않습니다. 대부분의 CLI 도구가 이걸 모르거나, 알면서도 호환성을 걱정해서 안 씁니다.

문법은 단순합니다.

ESC ] 8 ; ; URL ESC \  TEXT  ESC ] 8 ; ; ESC \

직접 보면 이렇게 생겼습니다.

\x1b]8;;https://github.com/uppinote20/claude-dashboard\x1b\\main\x1b]8;;\x1b\\

읽으면 이런 뜻입니다.

  • \x1b]8;;{URL}\x1b\\ → "여기서부터 이 URL로 가는 하이퍼링크 시작"
  • main → 보여줄 텍스트
  • \x1b]8;;\x1b\\ → "여기서 하이퍼링크 끝"

지원하는 터미널에서는 텍스트가 클릭 가능한 링크가 됩니다. 지원하지 않는 터미널에서는 escape sequence가 그냥 무시되고 텍스트만 그대로 보입니다. 이게 OSC8의 가장 큰 장점입니다 — 호환성 폴백을 따로 짤 필요가 없습니다.

지원 현황은 대략 이렇습니다.

터미널 지원
iTerm2
Ghostty
Kitty
Alacritty ✅ (최신 버전)
WezTerm
GNOME Terminal
Windows Terminal
macOS Terminal.app ⚠️ (제한적)
tmux 안 (passthrough) ⚠️ (설정 필요)

claude-dashboard 사용자의 절반 이상이 iTerm2, Ghostty, Kitty 중 하나를 쓸 것으로 짐작했고, 안 되는 터미널은 어차피 escape sequence가 무시되니까 손해가 없었습니다. 그래서 그냥 적용하기로 했습니다.


scripts/utils/formatters.ts에 한 함수를 추가했습니다.

// scripts/utils/formatters.ts
/**
 * Wrap text in OSC8 hyperlink escape sequence.
 * Terminals that don't support OSC8 simply display the text without the link.
 * @see https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
 */
export function osc8Link(url: string, text: string): string {
  return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
}

이게 다입니다. 9줄, 의존성 없음, 단위 테스트도 거의 필요 없음. OSC8을 직접 다루는 코드의 거의 전부가 여기 한 줄이고, 나머지는 "어떤 URL을 어떤 텍스트에 붙이느냐"의 문제입니다.

이 함수를 projectInfo 위젯의 브랜치명 렌더링 부분에 끼웁니다.

// scripts/widgets/project-info.ts (개념상 단순화)
import { osc8Link } from '../utils/formatters.js';

render(data: ProjectInfoData): string {
  // ... 디렉토리명 렌더링 ...

  if (data.gitBranch) {
    let branchStr = data.gitBranch;
    // ... ahead/behind 표시 추가 ...

    const branchDisplay = data.remoteUrl
      ? `(${osc8Link(data.remoteUrl, branchStr)})`   // ← OSC8 링크 적용
      : `(${branchStr})`;                              // ← remote 없으면 plain
    parts.push(colorize(branchDisplay, theme.branch));
  }
  // ...
}

remote가 없는 로컬 전용 리포에서는 OSC8을 안 붙이고 평범한 텍스트를 그대로 둡니다. 있는 정보만 링크하는 게 안전하니까요.


4. git remote URL을 HTTPS로 정규화

문제는 git remote URL이 한 가지 모양이 아니라는 것입니다. 같은 리포라도 사용자가 어떻게 클론했느냐에 따라 형식이 달라집니다.

[email protected]:uppinote20/claude-dashboard.git           ← SSH (콜론)
ssh://[email protected]/uppinote20/claude-dashboard         ← SSH (URL 형식)
https://github.com/uppinote20/claude-dashboard.git       ← HTTPS (.git)
https://github.com/uppinote20/claude-dashboard           ← HTTPS (clean)

OSC8 링크는 브라우저가 열어야 하니까 결국 HTTPS여야 합니다. SSH 형식은 그대로 넣으면 브라우저가 못 엽니다. 그래서 모든 형식을 HTTPS web URL로 정규화하는 함수를 만들었습니다.

// scripts/widgets/project-info.ts
function normalizeGitUrl(url: string): string | null {
  // SSH: git@host:path  또는  ssh://git@host/path
  const sshMatch = url.match(/^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?$/);
  if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`;

  // HTTPS: .git suffix만 떼면 됩니다 (이건 v1 버전 — 곧 함정 발견)
  const httpsMatch = url.match(/^https?:\/\/(.+?)(?:\.git)?$/);
  if (httpsMatch) return `https://${httpsMatch[1]}`;

  return null;
}

여기까지는 멀쩡해 보입니다. SSH 두 형식, HTTPS 두 형식 모두 같은 https://github.com/user/repo로 떨어집니다. 그리고 호스트가 github.com이 아니어도(gitlab.com, bitbucket.org, self-hosted Forgejo 등) 같은 정규식이 잘 동작합니다. 결과 URL 뒤에 /tree/{branch}만 붙이면 브랜치 페이지로 가는 링크가 완성됩니다.

// 적용
return {
  // ...
  remoteUrl: remoteUrl && branch ? `${remoteUrl}/tree/${branch}` : undefined,
};

테스트 머신에서는 잘 동작했습니다. 그런데 실제 사용자 환경에서 곧바로 두 가지 함정이 드러났습니다.


5. 함정 1: feature/foo가 URL을 깨뜨립니다

다음 날 사용자 한 명이 PR을 열어 줬습니다. feature/login-page 같은 슬래시 포함 브랜치명에서 OSC8 링크가 깨지고 있다는 것이었습니다.

직접 확인해 보니 더 흥미로웠습니다.

브랜치: feature/login-page
완성된 URL: https://github.com/uppinote20/claude-dashboard/tree/feature/login-page  ← 의도

이 URL은 사실 깨진 게 아닙니다. GitHub에서 그대로 열립니다. 그런데 진짜 함정은 다른 데 있었습니다.

브랜치명에 #이나 %가 들어 있는 경우. 별 거 아닌 것 같지만 실제로 다음과 같은 브랜치명이 흔합니다.

  • fix/issue-#42#이 fragment(앵커)로 해석돼서 URL이 잘림
  • experiment/100%coverage%가 URL 인코딩의 시작 문자로 해석돼서 다음 두 글자가 hex로 디코딩 시도됨

이런 브랜치명을 그대로 URL에 넣으면 브라우저는 의도한 것과 전혀 다른 페이지로 갑니다.

5.1 단순한 해결책의 문제

가장 먼저 떠오르는 해결은 encodeURIComponent를 통째로 거는 것입니다.

// 단순 접근 — 그런데 슬래시가 망가집니다
const encoded = encodeURIComponent(branch);  // feature/foo → feature%2Ffoo

문제는 encodeURIComponent가 슬래시까지 인코딩한다는 것입니다. feature/foofeature%2Ffoo가 되어 GitHub은 이걸 "단일 브랜치명"으로 해석하지 못하게 됩니다. 슬래시는 보존돼야 합니다.

5.2 segment 단위로 인코딩

해결은 단순합니다. 브랜치명을 슬래시로 split → 각 segment만 인코딩 → 다시 슬래시로 join하면 됩니다.

// scripts/widgets/project-info.ts (After)
return {
  // ...
  remoteUrl: remoteUrl && branch
    ? `${remoteUrl}/tree/${branch.split('/').map(encodeURIComponent).join('/')}`
    : undefined,
};

각 케이스를 추적해 보면 이렇게 됩니다.

입력 브랜치 결과 segment 인코딩
main main
feature/login feature/login
fix/issue-#42 fix/issue-%2342
experiment/100%coverage experiment/100%25coverage
release/v1.0.0 release/v1.0.0 (점은 안전 문자)

GitHub은 %23#으로, %25%로 디코딩한 후 정확히 그 브랜치를 찾습니다. 슬래시는 segment 구분자로 보존됩니다.

5.3 교훈

encodeURIComponent는 좋지만, "URL 경로 전체"가 아니라 "URL의 한 segment"를 인코딩하는 함수입니다. 슬래시가 의미적으로 보존돼야 한다면 split → map → join 패턴이 거의 모든 경우에 안전합니다. 이 함정은 GitHub이나 GitLab 같은 git 호스팅뿐 아니라, 파일 경로를 URL에 넣을 때, breadcrumb 경로를 만들 때, 라우팅 키를 인코딩할 때도 똑같이 나타납니다.


6. 함정 2: 토큰을 터미널 로그에 노출시킬 뻔했습니다

URL 인코딩을 고치고 한숨 돌리려는 찰나, 더 위험한 것을 발견했습니다.

git remote URL은 사실 자격증명을 포함할 수 있습니다. 다음과 같은 형태가 가능합니다.

https://oauth-token:[email protected]/user/repo.git
https://username:[email protected]/user/repo

CI/CD 환경, 특히 GitHub Actions나 GitLab CI에서는 자동화된 클론을 위해 토큰이 remote URL 안에 끼워 들어가는 경우가 매우 흔합니다. 그런데 제가 만든 normalizeGitUrl()은 그걸 그대로 통과시키고 있었습니다.

// 문제의 v1 (자격증명 노출)
const httpsMatch = url.match(/^https?:\/\/(.+?)(?:\.git)?$/);
if (httpsMatch) return `https://${httpsMatch[1]}`;

(.+?) 안에 oauth-token:[email protected]/user/repo 전체가 들어가 버립니다. 그리고 그 결과가 osc8Link()로 전달됩니다.

6.1 무엇이 진짜 위험한가

OSC8 URL은 화면에 직접 보이지는 않습니다(escape sequence에 숨어 있습니다). 그래서 "그냥 두면 안 되지 않나?"라고 잠깐 생각했는데, 더 따져 보니 위험이 분명해졌습니다.

  1. 터미널 로그 파일: 상태줄 출력은 session 로그, asciinema 녹화, tee 파이프 등 어디로든 흘러갑니다. escape sequence도 그대로 들어가고, 누가 grep으로 검색하면 즉시 발견됩니다.
  2. 스크린 녹화/스크린샷: 터미널 녹화 도구가 escape sequence를 포함한 raw output을 저장하면, 화면에는 안 보였던 토큰이 녹화 파일에는 살아 있습니다.
  3. 공유 스크린: 페어 프로그래밍이나 라이브 코딩 영상에서, 어떤 사용자가 실수로 터미널을 그대로 캡쳐하거나 공유하면 토큰이 새어 나갑니다.
  4. 링크 호버 미리보기: 일부 터미널은 OSC8 URL을 마우스 호버 시 툴팁으로 보여줍니다. 거기에 토큰이 그대로 적힙니다.

이 중 어느 하나라도 터지면, 토큰을 폐기하고 새로 발급하는 비용이 사용자한테 떨어집니다. 상태줄 따위가 토큰을 노출시킬 이유는 없습니다.

6.2 자격증명 stripping

수정은 정규식 한 글자입니다. HTTPS URL에서 userinfo 부분을 옵셔널 비캡처 그룹으로 매칭해 버리고 캡처에서 제외합니다.

// scripts/widgets/project-info.ts (After)
function normalizeGitUrl(url: string): string | null {
  // SSH: git@host:path  또는  ssh://git@host/path
  const sshMatch = url.match(/^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?$/);
  if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`;

  // HTTPS: strip .git suffix and userinfo (user:token@)
  const httpsMatch = url.match(/^https?:\/\/(?:[^@/]+@)?(.+?)(?:\.git)?$/);
  //                                       ^^^^^^^^^^^^^
  //                                       옵셔널 userinfo, 캡처 안 함
  if (httpsMatch) return `https://${httpsMatch[1]}`;

  return null;
}

핵심 변경:

Before After
^https?:\/\/(.+?)(?:\.git)?$ ^https?:\/\/(?:[^@/]+@)?(.+?)(?:\.git)?$

(?:[^@/]+@)? 한 부분이 추가됐습니다. 의미를 풀어 쓰면:

  • (?:...): 비캡처 그룹 (결과 URL 만들 때 안 씁니다)
  • [^@/]+: @/도 아닌 문자 1개 이상 (= userinfo 부분)
  • @: 그 뒤에 @ 하나가 와야 함
  • ?: 이 그룹 전체가 옵셔널

자격증명이 있으면 통째로 무시되고, 없으면 아무 영향이 없습니다.

6.3 결과 비교

입력 변경 전 (위험) 변경 후 (안전)
https://github.com/u/r.git https://github.com/u/r https://github.com/u/r
https://oauth-token:[email protected]/u/r.git https://oauth-token:[email protected]/u/r ⚠️ https://github.com/u/r
[email protected]:u/r.git https://github.com/u/r https://github.com/u/r

6.4 교훈

사용자가 제공한 URL을 그대로 신뢰하면 안 됩니다. git remote URL은 사용자의 의도와 상관없이 자격증명을 포함할 수 있고, 상태줄처럼 "보여주는" 도구가 그걸 또 다른 매체(escape sequence, 로그, 화면)로 노출시킬 수 있습니다. 재사용 전에 항상 sanitize하세요. 단순한 정규식 한 줄이 토큰 폐기의 비용보다 천 배 쌉니다.


7. 터미널 호환성과 graceful degradation

OSC8을 도입할 때 가장 걱정되는 부분은 "지원 안 하는 터미널에서 깨지지 않을까?"입니다. 다행히 OSC8은 모르는 escape sequence를 그냥 무시하는 ANSI 표준의 동작 덕분에 호환성 폴백이 거저 따라옵니다.

지원 터미널:    main          ← 클릭 가능한 링크로 표시
미지원 터미널:  main          ← 그냥 텍스트로 표시 (escape sequence 무시)

다만 한 가지 예외가 있습니다. tmux 같은 multiplexer는 기본적으로 자기가 모르는 escape sequence를 alphanumeric으로 변환하거나 출력 자체를 깨뜨릴 수 있습니다. 이 경우엔 tmux에서 OSC8을 passthrough로 허용하는 설정이 필요합니다.

# ~/.tmux.conf
set -ga terminal-features "*:hyperlinks"

claude-dashboard 자체는 이 설정을 강제하지 않습니다. tmux 사용자가 알아서 본인 환경에 맞게 켜면 됩니다. 안 켜더라도 평범한 텍스트로 표시되니까 깨지진 않습니다.


8. 핵심 개념 정리

개념 적용 위치 효과
OSC8 escape sequence osc8Link() 9줄 유틸 클릭 가능한 터미널 텍스트
graceful degradation OSC8 미지원 터미널은 escape 무시 폴백 코드 불필요
SSH/HTTPS URL 정규화 normalizeGitUrl() 어떤 클론 방식이든 같은 web URL
segment 단위 URL 인코딩 branch.split('/').map(encodeURIComponent).join('/') 슬래시 보존 + 특수문자 안전
userinfo stripping (?:[^@/]+@)? 비캡처 그룹 토큰이 escape sequence/로그에 노출되지 않음
있는 정보만 링크 remote 없으면 plain text 잘못된 링크 만들 위험 제거

9. FAQ

Q: OSC8을 직접 출력해 봤는데 제 터미널에서 안 보여요.

A: 터미널 자체가 OSC8을 지원하지 않거나, 지원하지만 호버/클릭 트리거 방식이 다를 수 있습니다. iTerm2는 cmd+클릭, Ghostty/Kitty는 일반 클릭입니다. 그리고 tmux 안에서는 별도 설정이 필요할 수 있습니다.

Q: osc8Link(url, text) 안에서 url에 또 escape sequence가 들어가면 어떻게 되나요?

A: OSC8 URL 부분에는 ASCII printable 문자만 넣어야 안전합니다. 일반적으로 git remote URL이 ASCII이기 때문에 문제가 잘 안 생기지만, 사용자가 제공하는 임의의 URL을 통과시킬 때는 별도 sanitize가 필요합니다.

Q: encodeURIComponent가 점(.)을 인코딩하지 않는 게 안전한가요?

A: 안전합니다. RFC 3986에서 점은 unreserved character로 분류되어 있어서 URL 어디에도 넣을 수 있습니다. release/v1.0.0 같은 브랜치명이 그대로 통과되는 게 정상입니다.

Q: 자격증명 stripping을 정규식 말고 URL API로 하면 안 되나요?

A: 가능하고 더 깔끔할 수 있습니다. const u = new URL(rawUrl); u.username = ''; u.password = ''; return u.toString(); 정도면 됩니다. 다만 git의 SSH URL(git@host:path)은 표준 URL이 아니어서 URL 생성자가 던지므로, SSH/HTTPS를 분리해서 처리하는 정규식 방식이 단일 코드 경로로는 더 단순했습니다.

Q: (?:[^@/]+@)? 정규식이 username에 @를 포함한 경우는 어떻게 되나요?

A: [^@/]+@를 허용하지 않기 때문에, username에 @가 들어 있으면 매칭이 실패하고 자격증명이 그대로 남습니다. 다만 git remote URL의 username에 @가 들어가는 경우는 매우 드물고, 만약 그런 환경이라면 더 견고한 URL API 기반 처리로 갈아타야 합니다.


10. 참고 자료


11. 다음 단계

OSC8 자체는 9줄짜리 함수가 전부지만, "어떤 URL을 어떤 텍스트에 붙이느냐"라는 결정에서 두 가지 함정을 동반합니다. 그 두 함정을 다 잡고 나서야 비로소 사용자한테 보여 줄 만한 기능이 됐습니다.

이 글은 claude-dashboard 시리즈 #4에서 짧게 언급된 OSC8 부분의 딥다이브입니다. 시리즈 본편에서는 stdin 데이터 소스 변경, 트랜스크립트 파서 2·3막, lastPrompt 위젯의 데이터 소스 교체 같은 이야기를 다뤘으니, 관심 있으시면 함께 보시면 좋습니다.

시리즈 목차:

  1. Claude Code 상태줄 플러그인 만들기: claude-dashboard 개발기
  2. Claude Code, Codex, Gemini 사용량을 한 번에 확인하는 CLI 대시보드
  3. claude-dashboard v1.10~v1.13: 테마, 성능 최적화, 그리고 셸 통합까지
  4. claude-dashboard v1.14~v1.24: stdin 우선, OSC8 링크, 그리고 파서를 한 번 더 100배 빠르게
  5. 터미널 상태줄을 클릭 가능하게: OSC8 하이퍼링크와 두 가지 보안 함정 ← 현재 글 (딥다이브)
  6. (예정) API 클라이언트 회복력을 위한 TypeScript 패턴 — negative caching + discriminated union (딥다이브)