Slack AI 에이전트는 우리 코드를 읽는다. 하지만 내보내지는 못한다
사내 Slack 에이전트에게 API 저장소의 컨트롤러 파일 하나를 보여 달라고 했다.
에이전트는 파일 전체를 스레드에 붙여 넣었다. 그리고 원본 파일까지 첨부하려고 했다.
해킹은 없었다. 에이전트는 만들어진 목적대로 친절하게 굴었을 뿐이다. “이 주문은 왜 자동 거절됐나?” 같은 질문에 답하려면 코드를 읽고 로직을 따라가야 한다. 하지만 Slack 스레드는 질문한 사람과 나누는 1:1 대화가 아니다. 많은 사람이 읽는다. 누가 정중하게 부탁했다고 소스 코드가 저장소 밖으로 나가서는 안 된다.
그래서 일주일 동안 에이전트 런타임에 가드레일을 만들었다. 런타임은 오픈소스 Hermes Agent다. 원칙은 한 줄이다.
에이전트는 판단하기 위해 코드를 읽을 수 있다. 하지만 코드를 내보낼 수는 없다.
이 글은 그 원칙을 프롬프트의 문장이 아니라 코드로 강제되는 장치로 만든 과정과, 다른 사람이 출력을 보는 곳에서 AI 에이전트를 운영하는 사람에게 전하고 싶은 교훈 다섯 가지를 정리한 것이다.
목표를 정확하게 적기
“코드를 유출하지 마라”는 너무 막연해서 그대로는 만들 수 없다. 그래서 목표를 두 가지로 적었다.
- 저장소 원문이 Slack 사용자에게 닿는 모든 경로를 기계적으로 막는다.
- 그 장치를 에이전트가 스스로 끄지 못하게 한다.
그리고 가능성 순으로 위협 모델을 세웠다.
| # | 경로 | 예 | 가능성 |
|---|---|---|---|
| T1 | 친절한 유출 | 누가 코드를 달라고 하거나, 에이전트가 근거로 코드를 인용한다 | 가장 높음 |
| T2 | 첨부 | 답에 적은 파일 경로가 자동으로 업로드된다 | 중간 |
| T3 | 우회 출력 | 최종 답이 아닌 다른 경로로 글이 나간다 | 중간, 놓치기 쉽다 |
| T4 | 규칙 변경 | 대화·사회공학·프롬프트 인젝션으로 규칙을 바꾼다 | 낮지만 치명적 |
| T5 | 직접 반출 | 셸로 업로드하거나 런타임의 발송 CLI를 부른다 | 낮음 |
모델 제공자 API로 코드가 전달되는 것은 범위 밖으로 뒀다. 그건 계약과 데이터 정책의 문제지, 출력 필터로 풀 문제가 아니다.
교훈 1. 프롬프트 규칙은 부탁이지 통제가 아니다
보통 가장 먼저 떠올리는 건 시스템 프롬프트에 한 줄을 넣는 것이다. “소스 코드를 절대 공유하지 마라.” 이 문장도 필요하다. 하지만 모델이 따르기로 할 때만 동작하고, 어겨도 아무 신호가 없다. 대화로 설득해서 넘어가기 가장 쉬운 층이기도 하다.
그래서 가진 통제 수단을 강도 순으로 나열하고 원칙 하나를 정했다. 사용자에게 보이는 것은 1~4층으로 막는다. 프롬프트 규칙은 의도를 설명하고, 막혔을 때 어떻게 다시 쓸지 알려주는 용도다. 벽이 아니다.
| 층 | 무엇 | 강도 |
|---|---|---|
| 1. 툴이 없다 | 툴셋에서 뺀다. 모델은 그 툴이 있는지도 모른다 | 가장 강함 |
| 2. 서버가 거부 | 상대 서비스의 읽기 전용 계정, 범위를 좁힌 토큰 | 강함 |
| 3. 훅 | 모든 툴 호출과 답 전송 직전에 도는 코드 | 강함, 활성이 확인됐을 때만 |
| 4. 경로 차단 | 엄격한 첨부 경로, 비워 둔 발송 키 | 강함, 그 경로에 한해서 |
| 5. 프롬프트 규칙 | AGENTS.md의 문장 |
약함, 조용히 어긴다 |
| + 변경 감지 | 실제 설정을 고정된 기준값과 비교 | 사후 |
자기 보호를 가장 먼저 만들어야 했다. Hermes는 에이전트가 자기 설정·페르소나·플러그인·스킬을 고치는 것을 일부러 허용한다. 개인 비서로는 훌륭한 기능이지만, 규칙 파일 자체가 정책인 상황에서는 문제가 된다.
첫날 바로 E2E로 시험했다. 관리자인 내가 Slack에서 에이전트에게 자기 규칙 파일을 고치라고 명시적으로 지시했다. 에이전트는 이걸 규칙의 정당한 예외로 판단했고, 셸 한 줄로 실제로 시도했다.
printf '\n' >> AGENTS.md
pre_tool_call 훅이 실행 전에 막았고 파일은 그대로였다. 프롬프트 규칙만 있었다면 파일은 바뀌었을 것이다.
악의는 전혀 없었다. 에이전트는 “관리자가 명시적으로 요청했다”와 “규칙은 안 된다고 한다”를 저울질했고,
관리자 쪽을 골랐을 뿐이다. 보안 경계가 바로 이런 판단에 기대면 안 된다.
생각보다 오래 걸린 두 가지가 있다.
- 훅마다 실패할 때 동작을 확인해야 한다. Hermes는 같은 플러그인 안에서도 다르게 처리한다.
pre_tool_call콜백이 예외를 내거나 시간을 넘기면 그 호출을 막는다(fail closed). 반면transform_llm_output콜백이 예외를 내면 원문이 그대로 나간다(fail open). 그래서 출력 필터는 모든 예외를 스스로 잡고 안전한 대체 문구를 돌려준다. 허용 목록에 없는 셸 훅은 에러 하나 없이 건너뛴다. - “켜져 있다고 나온다”와 “동작한다”는 다르다. 런타임은 플러그인별 로드 로그를 남기지 않았다. 그래서 런타임이 툴 실행 전에 부르는 판정 함수를 직접 불러서, 막혀야 할 것은 막히고 통과해야 할 것은 통과하는지 하나씩 확인했다.
교훈 2. 최종 답만 거르면 부족하다
가장 뻔한 설계는 에이전트의 최종 답에 필터를 거는 것이다. 실제로 거의 그렇게 만들 뻔했다. 만들기 전에 런타임 소스에서 Slack 어댑터의 send 계열 함수를 부르는 곳을 전부 찾았고, 각 호출의 텍스트가 어디서 오는지 거슬러 올라갔다.
나가는 경로 아홉 개 중 답 필터를 거치는 건 하나뿐이었다. 나머지 여덟 개는 필터 없이 Slack에 닿았다.
| 경로 | 답 필터를 거치나 | 막는 방법 |
|---|---|---|
| 최종 답과 답에 적힌 파일 | 거친다 | transform_llm_output |
| 툴 호출 사이의 중간 메시지 | 안 거친다 (Slack 기본값은 켜짐) | 설정으로 끈다 |
| 툴 진행 상황, reasoning 표시 | 안 거친다 | 설정으로 끈다 |
| 스트리밍 | 안 거친다. 원문이 먼저 보이고 나중에 수정된다 | 끈 상태로 둔다 |
| 작업 카드 완료 알림 | 안 거친다 | 워커의 완료 툴에 pre_tool_call |
| 작업 완료 때 자동 업로드되는 파일 | 안 거친다 | 파일 내용을 읽는 pre_tool_call |
| 확인 질문, 명령 승인 프롬프트 | 안 거친다 (승인 프롬프트엔 스크립트 전문이 들어간다) | 보내기 전에 pre_tool_call |
| 터미널의 발송 CLI | 안 거친다. 막는 장치도 없다 | pre_tool_call에서 명령 검사 |
| 음성·이미지 생성 | 필터 뒤에 붙는다 | 툴셋에서 뺀다 |
놀랐던 점이 세 가지 있다.
- 자동 업로드는 다른 설정을 읽는다. 작업 카드 완료 때의 업로드는 일을 한 프로파일이 아니라, 런타임을 띄운 기본 프로파일의 첨부 정책을 따랐다. 프로파일에 엄격 모드를 켜도 이 경로에는 아무 효과가 없었다.
- 엄격한 첨부 경로도 내용까지 엄격하지는 않다. strict 모드는 최근 10분 안에 만든 파일을 믿는다. 에이전트가 소스 파일을 복사하면 그 복사본은 통과한다. 첨부는 위치가 아니라 내용으로 판정해야 한다.
- 결정할 때마다 소스를 다시 읽어야 한다. 나중에 규칙을 정하면서 작업 카드 첨부에 대한 설명을 런타임 소스로 다시 확인했는데, 내가 틀리게 알고 있었다. 카드에 붙인 파일은 카드에만 남았고, 업로드되는 건 완료 툴의 산출물 목록과 요약에 적힌 경로뿐이었다. 반대로 최종 답에 적힌 파일은 Slack에 첨부되는데, 필터가 그 내용을 읽지 않고 있었다. 사실 하나를 잘못 알았다면 멀쩡한 흐름은 막고 진짜 출구는 놓쳤을 것이다.
이 방법은 어느 에이전트 런타임에도 똑같이 적용된다. 필터를 만들기 전에 나가는 길부터 전부 적어라.
교훈 3. 코드처럼 생긴 글이 아니라 진짜 코드와 대조하라
나가는 메시지에 “코드가 들어 있는지”는 어떻게 판단할까?
먼저 떠오르는 건 코드 모양을 잡는 정규식이다. 이건 양쪽으로 다 틀린다. SQL 예시, 설정 예시, 설명하려고 쓴 의사 코드처럼 공유해도 되는 것까지 잡는다. 그리고 그 글이 우리 코드인지는 전혀 알려주지 못한다.
그래서 나가는 글을 실제 저장소와 대조하기로 했다.
첫 번째 버전은 줄 해시였다. 약 60만 줄을 6초 만에 색인했는데, 두 줄을 한 줄로 합치는 순간 빠져나갔다.
두 번째 버전은 스탠퍼드의 표절 검사 도구 MOSS의 방식을 썼다. 토큰 k-gram과 winnowing이다.
- 토큰화. 공백, 주석, 마크다운 백틱을 버리고 낱말과 기호 한 글자씩만 남긴다. 그러면 줄을 바꾸든, 들여쓰기를 바꾸든, 줄을 합치든, 줄번호를 붙이든 토큰은 같다.
- 지문. 연속한 토큰 k 개마다 롤링 해시를 계산한다. 색인에는 w 개짜리 창마다 최솟값 하나만 남긴다 (winnowing). 여기에는 보장이 있다. k + w − 1 토큰 이상 겹치면 지문이 반드시 하나 이상 겹친다. 그러면서 색인 크기는 토큰 수의 약 2/(w+1)로 줄어든다.
- 우리 것이 아닌 건 뺀다. 가장 놀란 건 오래된 저장소에 통째로 커밋된 외부 라이브러리였다. PDF 라이브러리 하나가 전체 지문의 20% 였다. 공개된 코드는 지킬 이유가 없다. 그래서 vendor 경로, minified 파일, lock 파일, 여러 파일에 반복되는 보일러플레이트, 문서에 이미 인용된 코드는 색인에서 뺐다.
- 정보량이 낮은 k-gram은 버린다. 자세한 건 교훈 4에서 다룬다. 숫자와 기호만 이어진 토큰열(가격표, 바이트 배열, 폰트 데이터)은 우연히 겹친다. 식별자가 세 개 미만인 k-gram은 색인에서도 판정에서도 지문으로 쓰지 않는다.
- 해시만 저장한다. 색인은 정렬된 바이너리 파일이고 이진 탐색으로 찾는다. 가드 어디에도 소스 원문은 저장하지 않는다.
판정 쪽에서는 나가는 메시지가 짧으니 winnowing 없이 k-gram을 전부 찾아본다. 그리고 메시지 하나를 네
가지 방식으로 토큰화한다. 주석을 빼거나 주석 표시만 떼고, 줄 앞 접두사(12|, >, +)를 두거나 뗀다.
코드를 /* */로 감싸 내보내거나, 파일 읽기 툴의 출력을 그대로 붙여 넣는 경우를 잡기 위해서다.
최종 값은 k = 15, w = 6(20토큰이 겹치면 반드시 잡힌다), 그리고 지문 3개 이상이 겹치면 hit이다. 대략 진짜
코드 두 줄 정도다. 운영 중인 색인은 저장소 15개, 파일 약 1만 3천 개, 지문 약 200만 개다. 매시간 git pull
직후 1분 안에 다시 만들고, 판정 한 번에 몇 밀리초가 걸린다.
알려진 빈틈도 있다. 변수 이름을 바꾸거나 구조를 바꿔서 다시 타이핑한 코드는 잡지 못한다. 이 경우는 프롬프트 규칙에 맡기고, 없는 척하지 않고 남는 위험으로 적어 뒀다.
교훈 4. 강제하기 전에 관찰하라
출력 필터는 먼저 기록만 하는 관찰 모드로 배포했다. 기록에는 개수와 저장소 이름만 남기고 원문은 남기지 않는다.
오프라인 재생. 운영에 올리기 전에 지난 며칠 동안의 답을 전부 다시 돌렸다. 텍스트 답 104개 중 101개는
겹치는 지문이 0개, 1개는 지문이 1개(코드 블록 없는 긴 설명), 나머지 2개가 각각 47개, 32개로 hit이었다.
둘 다 진짜 코드 붙여넣기였고, 이 글 첫머리의 컨트롤러 파일도 그중 하나였다. 그 유출 답을 생각나는 대로
망가뜨려 봤다. 한 줄로 합치기, 토큰마다 줄바꿈, 줄번호, // 주석, /* */, 인용. 그래도 32개가 그대로
겹쳤다. 앞 1/3만 잘라도 6개로, 임계값을 넉넉히 넘었다.
운영에서 나흘 관찰하니 재생에서는 안 보이던 오탐이 하나 나왔다. 첫 평일에 작업 카드에 첨부된 버그 재현 결과가 지문 13개로 걸렸다. 겹친 곳은 저장소에 커밋된 템플릿 라이브러리와 프레임워크 기본 모듈 안의 숫자· 기호열이었다. 겹친 k-gram 13개 모두 식별자가 0개였고, 진짜 hit은 전부 4개 이상이었다. k-gram마다 식별자 3개를 요구하자 오탐은 사라졌고, 진짜 hit은 모두 남았고, 색인은 8% 줄었다. 첫날부터 강제했다면 멀쩡한 일을 막았을 것이다.
그다음, 실제 사례를 들고 규칙을 정했다. 장치를 만들기 전에 정책 질문 여덟 개를 먼저 적어 뒀고, 관찰이 끝난 뒤에야 답했다.
- 파일 경로, 함수 이름, 테이블 이름은 허용한다. 기술적인 답에는 위치가 필요하다.
- 저장소 원문은 일부라도 Slack에 절대 나가지 않는다.
- 관리자도 예외가 아니다. 채널 스레드는 다른 사람도 읽고, “관리자가 승인했다”는 공격자가 가장 먼저 쳐 볼 문장이다.
- 사내 이슈 트래커에는 diff를 허용한다. 독자가 그 버그를 고칠 엔지니어들이다.
- 답이 걸리면 답 전체를 보류하고 고정 안내 문구로 바꾼다. 바깥으로 보내는 툴은 막고, 원문 없이 다시 쓰라고 돌려준다.
실제 사례를 가져가니 규칙 세션의 성격이 완전히 바뀌었다. “컨트롤러 일부가 대화 답으로 나갔다”, “워커가 수정 diff를 이슈에 붙였다”처럼 구체적인 사례가 있으니 질문마다 1분 안에 답이 나왔다.
구현 기본값도 중요했다. 색인이 없으면 답은 통과시키고, 변경 감지가 30분 안에 알린다(모든 답을 막으면 에이전트가 멈춘다). 판정 자체가 예외를 내면 그 답은 보류한다. 그리고 차단은 사용자에게 실제로 닿는 부분에만 건다. 기록은 툴 인자 전체를 보지만, 전체로 막았다면 시스템 밖으로 나가지도 않는 내부 전달까지 막혔을 것이다.
교훈 5. 막힌 다음에 무슨 일이 일어났는지 읽어라
강제 사흘째에 장치가 막은 것을 전부 검토했다. 숫자로는 깨끗했다. 그런데 세션 기록을 읽어 보니 차단 하나가 조용히 무언가를 지워 버렸다.
에이전트가 문서의 불일치를 발견하고 echo 명령으로 기록하려 했다. 자기 보호 검사가 따옴표 안 문장 속의
.을 경로로 오해해서 막았다. 에이전트는 다른 툴로 다시 시도하지 않고 넘어갔다. 그 기록은 나에게 끝내 닿지
않았다.
교훈은 이거다. 에이전트의 오탐은 소음이 아니라 일이 사라지는 경로다. 사람은 막히면 불평이라도 한다. 에이전트는 그냥 멈추는 경우가 많다. 차단 건수를 세는 것으로는 부족하다. 차단마다 그다음 세션을 읽어야 한다.
같은 방식의 검토로 자기 보호의 다른 오탐들과, 진짜 구멍 하나를 찾았다.
- 옵션을 대소문자 구분 없이 비교해서
curl -D(헤더 저장)를-d(데이터 전송)로,-f를-F로 봤다. 이걸 고치다가 진짜 우회도 잡았다.-sF처럼 붙여 쓴 짧은 옵션은 그동안 빠져나가고 있었다. - 셸 변수를 푸는 방식을 고치다가,
$X/AGENTS.md처럼 모르는 변수가 현재 폴더에 붙어 풀리면서 보호 이름 검사를 통째로 건너뛰는 것을 발견했다. 이제 펼칠 수 없는 변수는 파일 이름만으로 판정한다.
안전망: 변경 감지
30분마다 작은 스크립트가 운영 상태를 사람이 관리하는 저장소의 기준값과 비교한다. 규칙·설정 파일, 예상 밖의 새 파일, 설정 스냅샷, 환경변수 이름, 서비스 정의, 훅 허용 목록, 런타임 소스 커밋, 예약 작업 정의, 실행 중인 프로세스에 실제로 올라온 플러그인, 코드 색인의 신선도. 한 번에 0.25초쯤 걸린다. 하나라도 다르면 나에게 DM이 온다.
운영하면서 배운 두 가지가 있다.
- 기준값을 다시 맞추는 절차를 검사와 같이 만들어라. 그게 없으면 의도한 변경(설정 수정, 예약 작업 추가, 런타임 업데이트)이 전부 오탐 알림이 된다. 아무도 안 읽는 알림은 아무것도 지키지 못한다.
- 실행할 때마다 바뀌는 필드는 비교에서 빼라. 처음 실제로 돌리자마자 예약 작업 두 개가 “정의가 바뀌었다”고 떴다. 실행 횟수가 올라갔기 때문이었다.
결과
- 첫 조사부터 강제까지 일주일. 런타임 하나, 에이전트 프로파일 셋.
- 강제 첫 사흘: 답 95개, 잘못 보류한 답 0개.
- 공정하게 말하면 보류 자체도 0개였다. 규칙을 다시 정리한 뒤로 에이전트가 먼저 스스로 거절한다(“코드 원문은 보여드릴 수 없어요, 관리자 요청도 예외가 아니에요”). 대신 파일·함수 위치와 동작으로 답한다. 프롬프트 층이 제 역할을 하면 훅은 조용하다. 그래서 훅이 동작한다고 가정하지 않고, 런타임의 판정 함수로 직접 시험한다.
아직 막지 못한 것
- 에이전트와 규칙 파일이 같은 OS 사용자로 돈다. 파일 권한으로는 완전히 막을 수 없고, 훅과 변경 감지는 변조를 어렵게 하고 드러나게 할 뿐이다.
- 셸 명령의 문자열 검사는 인코딩이나 간접 실행으로 우회할 수 있다. 변경 감지가 마지막 방어선이다.
- 플러그인 API는 런타임 업데이트로 바뀔 수 있다. 업데이트마다 검증 체크리스트를 다시 돈다.
- 비용이 더 큰 강한 선택지도 있다. 런타임을 별도 OS 사용자로 돌리고, 보호 파일을 root 소유 + 변경 불가로 두고, 네트워크 egress를 허용 목록으로 제한하는 것이다.
다른 사람이 출력을 보는 곳에서 에이전트를 운영한다면
- 필터를 만들기 전에 나가는 길부터 전부 적는다.
- 가드는 에이전트 손이 닿지 않는 곳에 두고, 가드 자체부터 보호한다.
- 훅마다 실패할 때 동작을 확인하고, 설정이 아니라 실제로 동작하는지 확인한다.
- 코드처럼 생긴 모양이 아니라 실제 코드와 대조한다.
- 관찰 모드로 먼저 돌리고, 실제 사례를 놓고 규칙을 정한다.
- 강제한 뒤에는 차단마다 에이전트가 그다음 무엇을 했는지 읽는다.
여러분의 코드 유출 방어선은 지금 어디에 있나? 프롬프트 안인가, 아니면 에이전트 손이 닿지 않는 곳인가?