2026-07-29 (수) 개발일지
오늘의 두 가지 — MCP Read 레이어의 경로 검증과 Cockpit 세션 유지
오늘은 lepons 프로젝트에서 두 가지를 정리했다. 하나는 MCP 서버가 노출할 Read 레이어의 경로 봉쇄를 두 겹으로 짜는 일, 다른 하나는 Cockpit이 SSH 세션을 유휴 상태에서도 끊기지 않게 tmux로 감싸는 배선이다. 전자는 단순한 문자열 검증에서 시작해 심링크 공격까지 막는 방어선을 쌓았고, 후자는 테스트가 출력 없이 멈추는 원인을 모듈 로드 시점에서 찾아내는 식으로 진행됐다.
Read 레이어 — 문자열 검증만으로는 부족했다
증상
MCP 서버가 read 도구로 파일을 읽을 때, 레포 바깥의 파일을 반환하면 안 된다. 그런데 첫 번째 방어선인 어휘 검사(절대경로·..·백슬래시·NUL·빈 세그먼트)만으로는 레포 안에 있으면서 밖을 가리키는 심링크를 막을 수 없었다. 실제로 SECRET-OUTSIDE 심링크를 만들어 테스트했더니, invalid_path가 아니라 파일 내용이 그대로 반환됐다.
단서 수집 + 첫 가설
처음엔 “경로 문자열을 깨끗이 정규화하면 되겠지” 싶었다. path.resolve()로 절대경로를 뽑고, startsWith(repoRoot)로 레포 바깥인지 검사했다. 그런데 이 검사는 심링크가 가리키는 실제 경로를 보지 않는다 — 문자열 비교일 뿐이다.
// 첫 번째 시도 — 문자열 기반
const resolved = path.resolve(repoRoot, requestedPath)
if (!resolved.startsWith(repoRoot)) return invalid_path
이 검사를 통과한 심링크가 레포 바깥을 가리키면, fs.readFile()이 실제 파일을 읽어버린다.
원인 추적 — 사고의 진화
“심링크를 따라가서 실제 경로를 확인해야 한다”는 건 금방 떠올랐다. 문제는 어느 시점에 검사하느냐였다.
fs.realpathSync()를 먼저 호출? — 심링크 자체가 아니라, 최종 대상 파일의 실제 경로를 얻는다. 그런데 심링크가 깨져 있으면ENOENT가 난다.fs.lstat()로 심볼릭 링크인지 확인 후readlink()? — 심링크가 가리키는 문자열을 얻을 수 있지만, 그 문자열이 레포 안/밖인지 판단하려면 또 resolve가 필요하다.fs.stat()vsfs.lstat()차이 —lstat은 링크 자체의 정보,stat은 링크가 가리키는 대상의 정보를 반환한다.
여기서 결정적인 전환점이 있었다: fs.realpathSync()는 심링크를 따라가지만, 대상이 없으면 예외를 던진다. 그 예외를 잡아서 invalid_path로 처리하면, 깨진 심링크도 자연스럽게 차단된다. 그리고 정상 심링크는 realpathSync가 반환한 실제 경로가 repoRoot로 시작하는지 다시 검사하면 된다.
// 두 번째 시도 — realpath 기반
try {
const realPath = fs.realpathSync(resolved)
if (!realPath.startsWith(repoRoot)) return invalid_path
} catch {
return invalid_path
}
해결 — 두 겹의 방어선
최종 envelope.ts는 다음과 같은 순서로 경로를 검증한다:
- 어휘 검사 (1차): 절대경로·
..·백슬래시·NUL·빈 세그먼트 — 문자열 수준에서 조기 차단 - realpath 검사 (2차): 심링크를 따라간 실제 경로가
repoRoot바깥이면 차단
이 두 겹을 통과한 경로만 fs.readFile()로 읽힌다. 반환되는 봉투(envelope)는 정확한 바이트 + 그 바이트의 SHA-256, 아니면 명시된 오류(invalid_path, not_found, permission_denied)다.
검증: 모든 탈출 시도가 실기에서 막혔다:
| 요청 | 결과 |
|---|---|
README.md |
file/exact · 정확한 바이트 + SHA-256 |
../outside.txt |
invalid_path |
| 레포 밖을 가리키는 심링크 | invalid_path — SECRET-OUTSIDE 내용이 반환되지 않음 |
/etc/passwd |
invalid_path |
| 없는 파일 | not_found |
남은 과제: fs.realpathSync()가 EPERM이나 EACCES를 던질 경우 — 권한 문제로 실제 경로를 읽을 수 없는 파일은 어떻게 처리할지 아직 결정하지 않았다. 일단은 catch-all로 invalid_path를 반환하게 했는데, 나중에 permission_denied로 분리할 필요가 있을 수 있다.
Cockpit 세션 유휴 끊김 — tmux로 감싸고, 모듈 로드에서 멈춘 원인
증상
Cockpit이 SSH로 lepons 환경에 붙은 후, 사용자가 일정 시간 동안 아무 작업도 안 하면(Claude나 명령어로 개발하지 않으면) SSH 연결이 끊기거나 재연결 알림이 필요했다. 처음 목표는 유휴 상태에서도 세션을 유지하고, 필요하면 자동으로 재연결하는 것이었다.
단서 수집 + 첫 가설
처음엔 간단했다 — SSH 연결을 tmux 세션 안에 넣으면, 클라이언트가 끊겨도 서버의 tmux 프로세스는 살아남는다. 그러면 재연결 시 tmux attach만 하면 된다. 그래서 Cockpit의 cockpit/src/main/index.ts에서 SSH 연결을 tmux로 감싸는 로직을 추가했다.
그런데 테스트가 출력 없이 멈췄다. npm test를 돌리면 아무 로그도 없이 프로세스가 hang 상태로 빠졌다.
원인 추적 — 사고의 진화
처음엔 “테스트 코드 자체가 무한루프에 빠졌나?” 싶었다. 하지만 출력이 아예 없는 걸 보니 테스트 실행조차 시작되지 않은 것 같았다. 그제서야 모듈 로드 시점을 의심했다.
cockpit/src/probe-node.ts에서 node-pty를 임포트하고 있었는데, 이 패키지는 CommonJS 모듈이다. Cockpit의 메인 프로세스는 ESM으로 작성돼 있었고, node-pty를 import로 불러오면 named import가 깨질 가능성이 높았다.
// 문제의 코드 — ESM에서 CJS named import
import { spawn } from 'node-pty' // ← 여기서 멈춤
node-pty의 CJS 번들은 module.exports = { spawn, ... } 형태인데, ESM의 import { spawn }은 정적 분석 시점에 이 구조를 제대로 해석하지 못해 모듈 로드 자체가 블로킹됐다.
해결 — default import로 변경
해법은 단순했다 — import * as pty from 'node-pty'로 바꾸고, pty.spawn()으로 호출했다.
// 수정 후
import * as pty from 'node-pty'
const proc = pty.spawn('bash', [], { ... })
이 변경 후 41개 테스트가 전부 통과했다. 추가로 유휴 끊김 방지 로직에 조건을 걸었다 — tmux 세션이 있을 때만 keepalive를 보내고, 없으면 원격 Claude 프로세스를 죽이지 않도록 했다.
검증
- 8개 테스트 통과 — 기본 배선 확인
- 35개 테스트 통과 — 유휴 끊김 조건부 로직 검증
- 41개 테스트 통과 — 최종 전체 테스트
Push 완료 (c040ec5..2a0d9d6).
남은 과제: tmux 세션이 여러 개일 때 어떤 세션에 attach할지 선택 로직이 아직 단순하다. 첫 번째 세션에 무조건 붙는 식인데, 나중에 세션 목록을 보여주는 UI가 필요할 수 있다.
오늘의 한줄
문자열 검증과 모듈 로드 — 둘 다 “당연히 될 거야” 하고 넘어간 지점에서 터진다.