터미널 상태줄을 클릭 가능하게: OSC8 하이퍼링크와 두 가지 보안 함정
OSC8은 9줄짜리 함수로 터미널 텍스트를 클릭 가능한 링크로 만들어 줍니다. 단, segment 단위 URL 인코딩과 git remote URL의 자격증명 stripping이라는 두 가지 함정을 미리 잡지 않으면 토큰을 escape sequence 안에 그대로 노출시킬 수 있습니다.
1. 문제 상황 — 브랜치명을 클릭하면 GitHub로 가면 안 될까?
claude-dashboard 상태줄에는 현재 git 브랜치가 표시됩니다.
📁 claude-dashboard (main ↑3)
상태줄 자체는 만족스러웠는데, 매번 브랜치 정보를 확인하고 PR을 보러 갈 때 흐름이 거슬렸습니다.
- 상태줄에서
main ↑3을 봄 → "어, 푸시 안 한 게 3개 있네" - 브라우저로 이동
- GitHub 주소를 손으로 입력하거나 즐겨찾기에서 찾음
- 해당 브랜치/리포 페이지로 이동
왜 그냥 브랜치명을 클릭하면 안 되는 걸까요? 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가 무시되니까 손해가 없었습니다. 그래서 그냥 적용하기로 했습니다.
3. 9줄짜리 osc8Link 유틸리티
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/foo가 feature%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에 숨어 있습니다). 그래서 "그냥 두면 안 되지 않나?"라고 잠깐 생각했는데, 더 따져 보니 위험이 분명해졌습니다.
- 터미널 로그 파일: 상태줄 출력은 session 로그, asciinema 녹화, tee 파이프 등 어디로든 흘러갑니다. escape sequence도 그대로 들어가고, 누가 grep으로 검색하면 즉시 발견됩니다.
- 스크린 녹화/스크린샷: 터미널 녹화 도구가 escape sequence를 포함한 raw output을 저장하면, 화면에는 안 보였던 토큰이 녹화 파일에는 살아 있습니다.
- 공유 스크린: 페어 프로그래밍이나 라이브 코딩 영상에서, 어떤 사용자가 실수로 터미널을 그대로 캡쳐하거나 공유하면 토큰이 새어 나갑니다.
- 링크 호버 미리보기: 일부 터미널은 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. 참고 자료
- Hyperlinks in Terminal Emulators (egmontkob의 OSC8 명세)
- iTerm2 Documentation: OSC 8 Hyperlinks
- RFC 3986 — Uniform Resource Identifier
- MDN: encodeURIComponent
- claude-dashboard GitHub
11. 다음 단계
OSC8 자체는 9줄짜리 함수가 전부지만, "어떤 URL을 어떤 텍스트에 붙이느냐"라는 결정에서 두 가지 함정을 동반합니다. 그 두 함정을 다 잡고 나서야 비로소 사용자한테 보여 줄 만한 기능이 됐습니다.
이 글은 claude-dashboard 시리즈 #4에서 짧게 언급된 OSC8 부분의 딥다이브입니다. 시리즈 본편에서는 stdin 데이터 소스 변경, 트랜스크립트 파서 2·3막, lastPrompt 위젯의 데이터 소스 교체 같은 이야기를 다뤘으니, 관심 있으시면 함께 보시면 좋습니다.
시리즈 목차:
- Claude Code 상태줄 플러그인 만들기: claude-dashboard 개발기
- Claude Code, Codex, Gemini 사용량을 한 번에 확인하는 CLI 대시보드
- claude-dashboard v1.10~v1.13: 테마, 성능 최적화, 그리고 셸 통합까지
- claude-dashboard v1.14~v1.24: stdin 우선, OSC8 링크, 그리고 파서를 한 번 더 100배 빠르게
- 터미널 상태줄을 클릭 가능하게: OSC8 하이퍼링크와 두 가지 보안 함정 ← 현재 글 (딥다이브)
- (예정) API 클라이언트 회복력을 위한 TypeScript 패턴 — negative caching + discriminated union (딥다이브)