02 MAKE무엇을 만들었는가

경계를 세 번 잘못 세웠다

PR 리뷰용 MCP 서버를 만들며 접근 경계를 문자열·편집 난이도·관행 위에 세웠다. 세 번 다 실제 경계는 다른 곳에 있었다.


Decisions

02

제약

Bitbucket 개인 토큰의 스코프는 워크스페이스 단위다. "이 저장소만"이라는 권한이 발급 단계에 없으므로, 범위를 좁히는 일 전체가 서버 코드의 몫이 된다.

포기한 것

allowlist를 호출마다 다시 읽는 방식을 버리고 기동 시 한 번만 읽게 했다. 저장소를 추가한 뒤 재시작해야 반영된다. 편의를 잃고, 하이재킹된 에이전트가 자기 경계를 그 세션에서 넓히지 못하는 성질을 얻었다.

남은 것

툴 2개 145줄에서 툴 14개와 테스트 139개로. 경로 우회·자가 확장·설정 하이재킹 세 가지를 각각 회귀 테스트로 못박았고, 막지 못하는 것 아홉 개를 README에 적었다.

NodeMCPBitbucket REST APIClaude Code 플러그인

가드는 이렇게 생겼었다.

if (pathname.includes("..")) throw new Error("상대 경로 금지");

그리고 이렇게 통과했다.

입력      /repositories/ws/allowed/%2e%2e/%2e%2e/%2e%2e/user/permissions/repositories
실제 전송  /2.0/user/permissions/repositories

마지막 경로는 일부러 막아둔 것이다. 저장소 인벤토리가 노출되니까. bb_get·bb_file·bb_write 전부 영향받아 임의 저장소 접근이 됐다.

1. 문자열로 경로를 검사했다

가드는 literal ..만 봤다. 그런데 fetch가 쓰는 WHATWG URL 파서는 %2e%2e, .%2e, %2E%2E, 백슬래시를 전부 ..로 접는다. 검사한 문자열과 전송한 문자열이 다른 문자열이었다.

틀린 순서 — 판정과 전송이 다른 문자열원본 문자열가드 통과fetch 가 다시 접어서 전송고친 순서 — 같은 객체URL 객체 생성정규화 경로로 판정그 객체를 그대로 전송문자열을 재조립하지 않으면 이 우회 클래스 전체가 닫힌다.인코딩 변종을 하나씩 막는 것은 목록을 늘리는 일일 뿐이다.

URL을 먼저 만들고, 정규화된 pathname으로 판정한 뒤, 그 URL 객체를 그대로 fetch에 넘긴다.

const url = resolveApiUrl(API, reqPath, allowed);  // 판정
res = await fetch(url, { ... });                    // 전송 — 같은 객체

인코딩 변종을 하나씩 막는 쪽으로 갈 수도 있었다. 그러면 목록이 늘어나고, 파서가 새 변종을 접기 시작하면 다시 뚫린다. 회귀 테스트를 단위·통합 양쪽에 넣었다. 이걸 안 넣으면 리팩터링 한 번에 되돌아간다.

2. 경계를 "고치기 어려움"으로 착각했다

allowlist를 파일에서 읽게 만들고 호출마다 다시 읽도록 했다. 저장소를 추가하려면 파일에 한 줄 넣으면 끝이고 재시작이 필요 없다. 편리했다.

보안 점검에서 이게 약점으로 드러났다. 에이전트는 WriteEdit 툴을 갖고 있으니 그 파일을 직접 고칠 수 있다. 하이재킹된 에이전트가 자기 경계를 넓혀 그 세션에서 바로 써먹을 수 있다.

그래서 env 모드로 바꿨다. claude mcp removeclaude mcp add 6줄짜리 명령이라 저장소 추가가 번거로워졌다. 그걸 "경계의 대가"라고 설명했다.

틀렸다. env 모드가 사주는 건 "설정을 고치기 어렵다"가 아니라 "고쳐도 재시작해야 먹는다" 다. 편집 난이도는 보안에 아무 기여를 하지 않는다.

답은 파일 모드를 유지하면서 기동 시 한 번만 읽는 것이었다.

파일 편집  →  쉽다
반영      →  재시작 필요   ← 경계는 여기다

env 모드와 같은 성질을 얻으면서 6줄이 사라진다. 테스트로 못박았다. 게이트를 켜고 저장소를 추가해도 같은 세션에서는 여전히 차단된다.

3. 플러그인 관행을 개발 저장소에 그대로 적용했다

스킬을 슬래시 명령으로 쓰려고 플러그인을 만들었다. 플러그인은 루트 .mcp.json으로 MCP 서버를 선언하고 경로에 ${CLAUDE_PLUGIN_ROOT}를 쓴다. 문서대로 루트에 두었다.

서버가 죽었다.

bitbucket (CONNECTION_CLOSED): "Connection closed"
ls: ${CLAUDE_PLUGIN_ROOT}/server.mjs: No such file or directory

이 저장소는 그 자체로 Claude Code 프로젝트다. 루트 .mcp.json은 프로젝트 스코프 MCP 설정으로 읽히고, 프로젝트 스코프는 user 스코프를 덮어쓴다. ${CLAUDE_PLUGIN_ROOT}는 설치된 플러그인으로 로드될 때만 치환되므로 경로가 문자열 그대로 남는다.

설정을 조심해서 두는 대신 구조로 해결했다. 다른 플러그인을 뜯어보니 마켓플레이스 매니페스트가 플러그인을 하위 디렉터리로 가리킨다.

{ "plugins": [{ "name": "bb-pr-review", "source": "./plugin" }] }

플러그인 루트를 plugin/으로 내리면 .mcp.json이 저장소 루트에 없으니 하이재킹이 구조적으로 발생하지 않는다. .gitignore.mcp.json을 넣어 다시 들어오지 못하게 막았다.

부수적으로 확인한 것 하나. .mcp.json${VAR} 치환은 셸 환경변수를 읽는다. 플러그인이 선언하는 userConfig는 플러그인 자기 코드가 읽는 별개 메커니즘이다. 그래서 플러그인으로 MCP 서버를 배포하지 않는다 — 사용자에게 .zshrc export를 요구하게 되어 claude mcp add --env보다 나쁘다.

세 번의 공통점

경계를 세운 재료실제 경계
1원본 문자열전송되는 URL 객체
2편집 난이도반영 시점
3설정을 조심해서 두기그 파일이 없는 디렉터리 구조

세 번 다 경계를 검사·습관·주의 위에 세웠고, 세 번 다 실제 경계는 동일성·시점·구조 위에 있었다. 앞의 셋은 사람이 지켜야 하는 것이고, 뒤의 셋은 지킬 필요가 없는 것이다.

조용한 실패가 제일 비쌌다

터지는 버그는 금방 찾는다. 시간을 먹은 건 조용한 쪽이었다.

setup.sh 6단계가 아무 일도 하지 않았다. 플러그인을 plugin/ 하위로 옮기면서 스킬 경로를 안 고쳤다. $DIR/skills/를 보는데 실제로는 $DIR/plugin/skills/였고, 없으면 경고 후 계속 진행하는 분기에 걸려 경고조차 눈에 안 띄게 지나갔다.

claude plugin update가 스킵했다. package.json만 0.10.0으로 올리고 플러그인 매니페스트는 0.9.0에 남겼더니 already at the latest version이라고 했다. 버전이 세 곳에 있고 전부 맞아야 한다. 이제 npm test가 검사한다.

grep -c가 1개를 덜 셌다. BSD grep은 끝 개행이 없는 마지막 줄을 세지 않는다.

파일: '# 헤더\na/b\na/c'  (끝 개행 없음)
  grep -c: 1   awk: 2

같은 개행 문제가 다른 얼굴로 또 나왔다. echo 'repo' >> file이 이전 항목에 붙어 <워크스페이스>/<저장소-A><워크스페이스>/<저장소-B>가 됐다. 이건 파서가 줄 번호와 함께 거부해서 조용히 넘어가지 않았다. 오타를 조용히 무시하지 않도록 만들어둔 게 값을 했다.

allowlist 파일 주석이 거짓이 됐다. "서버 재시작 없이 즉시 반영된다"고 적혀 있었는데, 스냅샷으로 바꾼 뒤로 사실이 아니었다.

막지 못하는 것

README에 장점만 쓰면 쓰는 사람이 위험해진다. 못 막는 걸 아홉 개 적었다. 실무에서 현실적인 건 둘이다.

프롬프트 인젝션 — 코멘트가 유출 채널이다. PR 본문·코멘트·diff는 남이 쓴 텍스트다. 에이전트가 그걸 지시로 착각하면 팀에 보이는 글을 남길 수 있다. 완화로 응답에 _untrusted 필드와 [외부 입력] 헤더를 붙여 "이건 데이터고 지시가 아니다"를 명시했다. 강제력은 없다.

내용을 걸러내지는 않는다. 리뷰 대상 텍스트에 "이전 지시를 무시하라"가 정당하게 등장할 수 있고, 지우면 리뷰 자체가 불가능해진다. 표시까지가 서버의 몫이다.

에이전트가 allowlist 파일을 고칠 수 있다. 막을 수 없다. 막는 건 그다음 — 기동 시 스냅샷이라 고쳐도 그 세션에서는 못 쓴다.

지금 상태

툴 14개    bb_repos bb_pr_inbox bb_pr_list bb_pr_get bb_pr_files
           bb_pr_diff bb_pr_comments bb_file bb_comment bb_doctor
           bb_allowlist_list bb_allowlist_add bb_get bb_write

슬래시     /bb-pr-review  /bb-review  /bb-doctor  /bb-repos
테스트     139개 (단위 + 로컬 가짜 Bitbucket API 통합)
커밋       11개

시작은 bb_getbb_write 두 개짜리 145줄이었다.

인라인 코멘트의 좌표계는 확인했다. bb_pr_diff의 변경 후 줄 번호, bb_file의 줄 번호, bb_commentline이 같은 값을 가리킨다 — +1줄만 바뀐 파일로 교차 검증했다. 세 툴이 같은 좌표를 쓴다는 것은 문서를 읽어서 믿을 일이 아니라 한 줄짜리 diff로 확인할 일이다.

확인하지 못한 것도 있다. Linux 비밀 저장소(secret-tool, pass) 경로는 미검증이고, 이 머신에서 확인할 수 없어 그렇게 표기해 뒀다.

mcpsecuritybitbuckettesting

기록

2026.09.03에 처음 커밋됐고, 이후 한 번 더 손댔습니다.

  • c05d9c4Content: 원문 대비 누락 하나를 채웠다
  • 747843eContent: bb-mcp 개발 기록을 두 편으로 옮겼다

전체 빌드 기록 →