저는 각각 자신만의 Claude Code 세션과 축적된 컨텍스트(Context)를 가진 여러 개의 장기 프로젝트를 운영하고 있습니다. 스택도 다르고, 데이터베이스도 다르며, 서로 겹치는 부분도 없습니다. 이 세션들은 컨텍스트를 공유하지 않기 때문에 유용하지만, 이는 곧 각 세션이 자신만의 터미널 창에서 실행되어야 하며 제가 책상 앞에 있을 때만 대화할 수 있다는 것을 의미합니다.
그래서 저는 각 세션에 Discord 봇(Bot) 정체성을 부여했습니다. 이제는 줄을 서 있는 동안에도 .NET 코드베이스를 알고 있는 세션에게 결제 관련 예외 케이스(Edge case)에 대해 물어볼 수 있습니다.
그 작업은 저녁 한 끼 정도의 시간이 걸렸습니다. 그다음에는 그들이 서로 대화하게 만들려고 시도했는데, 그 작업은 훨씬 더 오래 걸렸으며 제가 직접 작성한 보안 버그를 발견하게 되었습니다.
구조 (The shape of it)
Claude Code의 Discord 채널 플러그인을 사용하면 세션이 Discord 메시지를 보내고 받을 수 있습니다. 프로젝트당 하나의 세션을 실행하고, 각 세션에 고유한 봇 토큰(Bot token)을 부여하면 각 프로젝트는 주소 지정이 가능한 정체성을 갖게 됩니다.
한 가지 명확하지 않은 부분은, 플러그인이 환경 변수(Environment variable)로부터 상태 디렉토리(State directory)를 읽는다는 점입니다. 따라서 모든 봇은 자신만의 디렉토리가 필요합니다.
$env:DISCORD_STATE_DIR = "$env:USERPROFILE\.claude\channels\bot-a"
claude
각 상태 디렉토리에는 access.json 파일이 포함되어 있습니다. 이 파일은 누가, 어떤 채널에서 이 봇과 대화할 수 있는지, 그리고 멘션(Mention)이 필요한지 여부를 결정합니다.
{
"dmPolicy": "allowlist",
"allowFrom": ["<my-user-id>"],
...
groups에 포함되지 않은 채널은 모델이 확인하기도 전에 삭제됩니다. 봇이 분명히 초대받았음에도 채널에서 조용히 가만히 있다면 가장 먼저 확인해야 할 사항입니다.
장벽: 봇은 봇을 트리거할 수 없다
한 채널에 네 개의 봇이 있다면, 당연히 다음 단계는 하나가 다른 봇들을 조정하게 만드는 것입니다. 제 봇은 다른 봇을 직접 언급하는 메시지를 게시했습니다. 하지만 아무 일도 일어나지 않았습니다. 오류도, 거부도, 로그 라인(Log line)도 없었습니다. 언급된 봇은 마치 아무 말도 듣지 못한 것처럼 그냥 계속 할 일을 했습니다.
저는 권한 문제라고 가정하고 Discord 개발자 포털(Developer portal)에서 한참을 보냈습니다. 문제는 그것이 아니었습니다. 상황을 종결시킨 통제된 비교 실험은 다음과 같습니다:
| 시간 | 작성자 | 언급 | 응답 |
|---|
| 10:28:06 | bot | <@bot-b> | 없음 |
| 10:33:14 | me (human) | <@bot-b> | 36초 만에 응답 |
동일한 채널, 동일한 설정, 동일한 언급(mention) 텍스트입니다. 오직 작성자만 달랐습니다.
원인은 플러그인의 server.ts에 있는 네 줄의 코드입니다:
client.on('messageCreate', msg => {
if (msg.author.bot) return
handleInbound(msg).catch(...)
...
Discord는 게이트웨이 이벤트(gateway event)를 완벽하게 전달합니다. 하지만 플러그인은 접근 제어(access control)를 수행하기 _전에, 그리고 모델에 도달하기 _전에 이를 폐기해 버립니다. 어떤 권한 부여로도 이를 바꿀 수 없으며, 이에 대한 설정도 존재하지 않습니다. 서버는 정확히 세 개의 환경 변수(environment variables)를 읽지만, 그 중 어느 것도 이 부분에 관여하지 않습니다.
Where a bot-authored message dies in the pipeline
이 점은 잠시 곱씹어 볼 가치가 있습니다. 왜냐하면 이는 통합(integration) 디버깅에 관한 일반적인 교훈을 주기 때문입니다: 이벤트를 전달하는 계층(layer)과 그 이벤트에 반응하는 계층은 서로 다른 계층이며, 설정(configuration)은 오직 그중 하나만을 수정할 수 있습니다.
해당 코드가 핵심적인 이유 (load-bearing)
당연한 해결책은 가드(guard) 코드를 삭제하는 것입니다. 하지만 그러지 마십시오.
그 단 한 줄의 코드는 두 가지 역할을 수행하고 있습니다. 다른 봇들을 차단하는 역할과, 봇이 _자기 자신_에게 반응하는 것을 막는 역할입니다. 대체 코드 없이 이를 삭제하면, 자기 자신의 메시지를 들을 수 있게 된 모든 봇은 API 속도로 영원히 자신의 응답에 다시 답하게 될 것입니다.
이는 가설이 아닙니다. 이 제약 사항을 이해하기 전, 저는 네 개의 봇을 동시에 언급하는 메시지를 하나 보냈습니다. 그중 세 개가 6초 이내에 응답했습니다. 만약 그들이 서로의 소리를 들을 수 있었다면, 그 과정은 멈추지 않았을 것입니다.
따라서 대체 코드는 이 두 가지 관심사(concerns)를 분리해야 합니다:
// 무조건적이며, 설정 불가능함
if (msg.author.id === client.user?.id) return
...그리고 _다른 봇들_에 대한 결정은 논리적으로 추론 가능한 다른 곳으로 옮겨야 합니다.
패치 (The patch)
저는 access.json에 allowBots: string[]를 추가했으며, 기본값은 []로 설정했습니다. 이는 정확히 이전의 동작 방식과 동일하므로, 설정되지 않은 봇은 변경 사항이 없습니다.
기능 자체보다 더 중요했던 두 가지 세부 사항이 있습니다:
이것은 이벤트 핸들러(event handler)가 아니라 gate()에서 확인됩니다. gate()는 메시지가 올 때마다 access.json을 다시 읽기 때문에, 아무것도 재시작하지 않고도 네 개의 세션이 실행되는 동안 허용 목록(allowlist)을 편집할 수 있습니다.
중앙에서 강제되는 것은 아무것도 없습니다. 각 봇의 자체 설정에 자신을 방해할 수 있는 대상이 나열됩니다. 글로벌 레지스트리(global registry)가 없으므로, 파일 하나가 손상된다고 해서 모든 것이 한꺼번에 열리는 일은 발생하지 않습니다.
토폴로지 (Topology): 메시(mesh)가 아닌 허브 앤 스포크 (hub and spoke)
저의 첫 번째 설계에서는 서버에 토큰 버킷(token-bucket) 속도 제한기(rate limiter)를 두었습니다. 네 개의 에이전트가 메시(mesh) 구조로 연결되면 루프가 이차 함수적으로 증가하기 때문에 안전장치가 필요했기 때문입니다.
그 후 저는 대신 토폴로지(topology)를 변경했고, 문제의 대부분이 사라졌습니다.
Mesh versus hub and spoke
하나의 에이전트가 허브(hub) 역할을 합니다. 스포크(spoke)들은 allowBots에 허브만 나열하며, 허브는 모든 스포크를 나열합니다. 스포크들은 서로를 깨울 수 없습니다. 가능한 유일한 사이클은 허브 ↔ 하나의 스포크뿐이며, 구조적으로 허브가 모든 경로에 존재합니다. 따라서 허브에 있는 루프 차단기(loop-breaker)가 전체 시스템을 커버하며, 폭주하는 프로세스를 막기 위해 속도 제한기(rate limiter)에만 의존할 필요가 없습니다.
그럼에도 불구하고 저렴한 보험으로서 서버 측 제한기를 유지했습니다. 프롬프트 수준의 규칙은 합리화되어 무시될 수 있지만, 서버 코드는 그럴 수 없습니다. 하지만 제한기는 더 이상 핵심적인 지지 구조(load-bearing)가 아니게 되었으며, 이것이 안전장치(safeguard)와 희망(hope)의 차이입니다.
정확히 짚고 넘어갈 만한 속성 하나는 다음과 같습니다: 격리는 가시성(visibility)이 아니라 호출(invocation) 단계에서 이루어집니다. 스포크들은 깨어 있는 동안 언제든 서로의 메시지를 읽을 수 있습니다. 단지 서로를 깨울 수 없을 뿐입니다. 이것이 우리가 얻는 속성이며, 처음 들리는 것보다는 규모가 작습니다.
내 패치를 검토하며 발견한 버그
이 부분은 제가 다른 누군가가 꼭 배웠으면 하는 대목입니다.
이 플러그인에는 권한 승인 프롬프트(permission prompts)를 가로채는 인터셉터(intercept)가 있습니다. 도구(tool)에 대한 승인이 필요할 때, 터미널로 돌아가는 대신 Discord에서 y <request_id>라고 답장할 수 있습니다. 이와 관련된 코드에는 실질적으로 _gate()를 통과한 모든 것은 allowFrom에 포함되어 있으므로, 이 발신자는 사용자이다_라는 취지의 주석이 달려 있었습니다.
그 코드가 작성되었을 당시에는 그것이 사실이었습니다. 하지만 제가 allowBots를 변경하면서 그것은 거짓이 되었습니다. 이제는 봇이 allowFrom에 나타나지 않고도 gate()를 통과할 수 있기 때문입니다.
이는 에이전트가 저를 대신하여 도구 권한을 승인했을 수도 있음을 의미합니다.
이를 악용하려면 5자리의 request_id가 필요한데, 이는 오직 허용 목록(allowlist)에 있는 DM(Direct Message)으로만 브로드캐스트되므로, 이것은 완전히 열린 문이라기보다는 심층 방어(defence in depth)의 문제였습니다. 수정 방법은 두 줄이면 충분했습니다. 버튼 핸들러(button handler)가 항상 해왔던 방식대로, allowFrom을 명시적으로 확인하고 봇 작성자를 즉시 제외하는 것입니다.
실수의 형태가 흥미로운 부분입니다. 해당 패치는 고립되어서는 옳았으나 결합되었을 때는 틀렸습니다. 왜냐하면 백 줄 떨어진 곳에 주석으로 기록된 가정을 무효화했기 때문입니다. 어떤 타입 체커(type checker)도 이를 잡아낼 수 없습니다. 이를 잡아낸 유일한 방법은 작업을 다 마쳤다고 생각한 후 파일의 나머지 부분을 읽어본 것뿐이었습니다.
저에게 몇 시간을 허비하게 만든 네 가지 일
BOM(Byte Order Mark)이 모든 봇의 설정을 조용히 초기화했습니다. Windows PowerShell 5.1에서 Set-Content -Encoding utf8는 UTF-8 BOM을 작성합니다. JSON.parse는 이에 대해 오류를 발생시키며, 파싱할 수 없는 JSON에 대한 서버의 응답은 파일을 access.json.corrupt-<epoch>로 이름을 변경하고 기본값으로 시작하는 것입니다. 즉, 허용 목록(allowlist)도, 채널 등록도 없는 상태가 됩니다. 봇은 계속 실행되지만 단순히 아무것도 듣지 못하게 됩니다. 네 가지 문제 모두 10분 간격으로 발생했습니다.
값(value)이 아니라 바이트(byte)를 확인하세요. 중요한 파서를 제외한 모든 JSON 파서는 BOM을 허용하기 때문입니다:
Get-ChildItem "$env:USERPROFILE\.claude\channels\*\access.json" |
ForEach-Object { (Get-Content $_.FullName -AsByteStream -TotalCount 3) -join ' ' }
239 187 191은 BOM입니다. 모든 PowerShell 버전에서 BOM이 없는 [System.IO.File]::WriteAllText를 사용하여 이 파일들을 작성하세요.
포크(forked)된 플러그인은 승인된 채널(approved-channels) 허용 목록(allowlist)에 없습니다. 패치된 포크 버전을 설치하자마자, 외부로 나가는 메시지는 완벽하게 작동했지만 들어오는 알림은 세션에 도달하기 전에 드롭(drop)되었습니다. 봇은 고장 난 것이 아니라 응답이 없는 것처럼 보이며, 설정 파일의 어떤 부분에서도 이를 나타내지 않습니다. 지문(fingerprint)은 _'답장 영역(replies land), 아무것도 도착하지 않음'_이며, 그 해답은 MCP 로그의 로그 라인과 포크를 명시적으로 지정하는 실행 플래그(launch flag)에 있습니다.
에이전트는 자신의 사용자 ID(user ID)를 절대 전달받지 못합니다. 에이전트는 송신자의 ID는 볼 수 있지만, 자신의 ID는 결코 볼 수 없습니다. 따라서 멘션(mention)이 자신을 향한 것인지 확인할 수 없습니다. 제가 네 개의 봇을 언급하며 "당신이 오케스트레이터(orchestrator)입니다"라고 메시지를 보냈을 때, 각 봇은 어떤 절(clause)이 자신의 것인지 추측해야 했습니다. 세 개는 잘못 추측했습니다. 그중 하나는 채널 기록에서 자신의 정체성을 추론하여, 다른 봇의 이름으로 메시지에 서명하기 시작했습니다.
해결책은 코드가 아니라 산문(prose)입니다. 즉, 봇을 이름으로 부르고, 역할을 할당할 때 ID를 명시적으로 언급하십시오. ID만으로는 수신자가 확인할 수 없습니다.
침묵은 양방향 모두에서 모호합니다. 바쁜 에이전트와 전달되지 않은 메시지는 동일해 보입니다. 당신의 보고를 무시한 에이전트와 보고에 따라 조용히 행동한 에이전트도 마찬가지입니다. 저는 일주일 사이에 두 가지 실패 모드를 모두 경험했습니다. 하나는 메시지가 도착하지 않아 거절로 읽힌 경우였고, 다른 하나는 오케스트레이터가 수정을 인지하지 못한 채 조용히 실행하여 송신자 입장에서는 무시당한 것과 구별할 수 없었던 경우였습니다.
이러한 시스템을 구축한다면, 공유 상태(shared state)를 변경하는 모든 사항에 대해 확인(acknowledgement)을 규칙으로 만드십시오. 메시지 하나가 더 들 뿐이지만, 혼란의 범주 전체를 제거해 줍니다.