📝 한줄 요약
메시지 라우팅을 관찰하는 Hermes hook을 만들 때 가장 위험한 실수는 “관찰만 한다”는 설명을 믿고 곧바로 운영 프로필에 꽂는 것이다. 이번 작업에서는 credential도 gateway도 없는 별도 프로필에 pre_gateway_dispatch observer를 넣고, callback이 언제나 None만 반환하는지, 본문과 식별자를 남기지 않는지, 원래 메시지가 그대로 agent dispatch에 도달하는지부터 검증했다. 발행 전 재실행에서 observer 테스트 14개와 동결 산출물 12개 해시가 모두 통과했지만, Slack adapter에서 먼저 탈락한 메시지는 이 hook이 볼 수 없다는 한계 때문에 판정은 CANARY PASS / PRODUCTION HOLD로 남겼다.
바쁘시면 이것만 읽어도 됩니다.
hook은 기능보다 먼저 행동 불가 계약을 만들었다: callback return은
None, routing directive는 0개다.메시지·답글·raw payload 본문과 사용자·채널·스레드·메시지 식별자는 receipt에 넣지 않았다.
로그는 명시한 절대 로컬 경로가 있을 때만 쓰고,
0600, bounded append, symlink 거부, fail-open을 적용했다.canary profile은 credential·model·PID·LaunchAgent 없이 gateway가 정지된 상태다.
발행 전 fresh test는 14/14, gateway passthrough는 PASS, 동결 산출물은 12/12 bytes·SHA-256 일치였다.
원래 검증 시점의 Slack routing baseline 77개와 Hermes core hook contract 2개도 통과했다.
그러나
pre_gateway_dispatch는 Slack adapter가 받아들인 이벤트만 본다. adapter에서 먼저 탈락한 메시지는 관찰하지 못한다.활성 작업 디렉터리의
.env가 fixture env를 덮는 문제를 합성 sentinel로 재현했고, clean cwd에서 fixture가 보존되는 것도 확인했다. Hermes core test 수정은 이번 사례 발행 작업에서 수행하지 않았다.따라서 이 결과를 “운영 라우팅 완성”으로 승격하지 않고 production HOLD로 유지했다.
🎯 이런 분들께 도움됩니다
메시징 gateway에 hook·plugin·middleware를 추가하려는 운영자
“read-only”, “observer-only”라는 이름만으로는 안전을 신뢰하기 어려운 팀
AI 에이전트 로그에서 본문·사용자 ID·채널 ID를 남기지 않는 계측 경계를 설계하려는 사람
여러 봇이 한 Slack workspace를 공유해 mention·channel-owner precedence를 검증해야 하는 팀
테스트가 실제 사용자 홈의
.env나 활성 profile에 오염되는 문제를 겪는 개발자PASS와 production 승격을 서로 다른 Gate로 운영하려는 조직
😫 문제 상황 — “관찰 hook이면 안전하지 않을까?”
처음 요구는 단순했다.
Hermes hook으로 Slack 라우팅 결정을 관찰해 보자.
다만 기존 동작과 개인정보는 건드리지 말자.
그런데 메시징 경로에서 “관찰”은 생각보다 넓은 권한을 가질 수 있다.
callback이 잘못된 값을 반환하면 메시지를 skip하거나 rewrite할 수 있다.
receipt에 원문을 넣으면 사실상 별도의 대화 로그가 생긴다.
channel·thread·user ID를 그대로 저장하면 메시지가 없어도 개인과 대화 위치를 추적할 수 있다.
실행 중인 profile에 plugin을 바로 켜면 test와 production 경계가 사라진다.
hook이 pipeline 뒤쪽에 있으면 앞에서 탈락한 사건을 전혀 보지 못하면서도 “전체를 관찰했다”고 착각할 수 있다.
가장 중요한 질문은 “hook이 작동하는가?”보다 다음 세 가지였다.
무엇을 볼 수 있는가?
무엇을 절대로 남기지 않는가?
어떤 결과도 바꾸지 않는다는 것을 어떻게 증명할 것인가?
🌱 첫 번째 전환점 — 기능 목록보다 ‘행동 불가 계약’을 먼저 썼다
구현 전에 다음 계약을 동결했다.
경계
계약
등록
pre_gateway_dispatch hook 하나만 등록
반환
모든 정상·예외 경로에서 None
라우팅
skip, rewrite, allow, context·tool·command directive 0개
기본 쓰기
로그 경로가 없으면 파일 생성 0개
공개 receipt
본문·reply·raw payload·이름·원시 ID·attachment 경로 금지
상관관계
명시한 key가 있을 때만 짧은 keyed HMAC, key가 없으면 필드 자 체 생략
파일 보호
절대경로, 기존 parent, mode 0600, append-only, final symlink 거부
용량
상한을 넘으면 기존 증거를 자르지 않고 append 중단
오류
관찰·I/O 오류는 fail-open, 원래 dispatch 유지
활성화
credential 없는 별도 profile, gateway 시작 금지
이 구조에서 “observer-only”는 설명 문구가 아니라 검증 가능한 불변식이 되었다.
그림 1. 발행 직전 재실행한 공개용 검증 영수증에서 자동 생성한 데이터 파생 카드다. 실제 운영 Slack 화면이 아니며, canary가 정지된 상태에서 합성 fixture와 frozen artifact를 검증한 결과다.
🔒 두 번째 전환점 — “익명화”가 아니라 allow-list receipt를 만들었다
원문에서 민감한 부분을 지우는 redaction 방식은 놓치는 필드가 생기기 쉽다. 그래서 원본 payload를 저장한 뒤 지우지 않고, 처음부터 허용한 metadata만 새 객체로 만들었다.
허용한 값은 다음과 같은 제한된 범주다.
platform·chat type·message type 같은 내부 enum label
본문 자체가 아닌 문자 수
mention 개수와 broadcast mention 여부
thread·reply context·media 존재 여부
source가 bot인지 여부
raw payload가 존재했는지 여부
schema·plugin version·관찰 시각
명시적인 HMAC key가 있을 때만 생성하는 24자 correlation digest
반대로 다음은 receipt에 넣지 않았다.
메시지와 답글 본문
raw Slack payload 내용
사용자명과 사용자 ID
채널·스레드·메시지·workspace ID
attachment 경로와 파일명
HMAC 원본 재료와 HMAC key
테스트에는 일부러 비밀 sentinel을 넣었다. 메시지 본문, 답글, 이름, 여러 ID, attachment 경로, raw token을 주입한 뒤 결과 JSON 전체에서 해당 값이 한 번도 나오지 않는지 확인했다.
그림 2. 공개용 evidence의 계약 필드에서 자동 생성했다. “개인정보를 나중에 지웠다”가 아니라 “허용 목록 밖의 값을 receipt에 만들지 않았다”는 경계를 나타낸다.
⚙️ 실제 적용 과정
1. 기존 운영 상태를 먼저 동결했다
보호 대상 runtime profile 6개의 config hash와 gateway PID를 기록했다.
canary 전용 profile은 다른 profile을 clone하지 않고 새로 만들었다.
messaging credential, model, PID file, LaunchAgent를 넣지 않았다.
production gateway를 재시작하지 않았다.
canary gateway도 시작하지 않았다.
2. plugin은 한 가지 일만 하게 했다
def register(ctx):
ctx.register_hook("pre_gateway_dispatch", observe_gateway_dispatch)
def observe_gateway_dispatch(*, event=None, **kwargs):
try:
receipt = build_allowlisted_receipt(event)
append_only_when_explicitly_configured(receipt)
except Exception:
pass
return None
위 코드는 구조를 설명하기 위한 축약 표현이다. 실제 구현은 bounded count, safe internal label, optional keyed HMAC, file mode, lock, symlink와 크기 상한까지 별도로 다룬다.
3. 파일을 썼다는 사실과 dispatch 보존을 분리했다
검증은 여러 층으로 나눴다.
검증 층
당시 결과
발행 전 read-back
canary 단위·보안 테스트
14 pass
14/14 pass
plugin manifest와 실제 loader
PASS
frozen hash 유지
GatewayRunner passthrough
PASS
PASS
기존 Slack mention/routing baseline
77 pass, 1 deselected
point-in-time evidence 유지
Hermes core hook contract
2 pass
point-in-time evidence 유지
동결 산출물 manifest
12개
12/12 bytes·SHA-256 match
canary gateway
stopped
stopped
운영 profile config hash·PID
6/6 동일
당시 snapshot의 판정 유지
GatewayRunner harness에서는 합성 event의 text가 바뀌지 않았고, 원래 agent dispatch 함수까지 도달했다. 이것은 “callback이 None이었다”보다 한 단계 더 강한 증거다. 원래 경로가 실제로 계속 실행되었기 때문이다.
🧪 구체적인 실패와 수리
실패 1 — hook이 Slack 라우팅 전체를 보는 것이 아니었다
정적 경로를 따라가 보니 Slack adapter는 MessageEvent를 만들기 전에 여러 gate를 적용했다.
allowed channel
→ known-other-bot precedence
→ mention / free-response / strict-mention
→ active thread/session
→ MessageEvent 생성
→ pre_gateway_dispatch hook
따라서 현재 observer가 볼 수 있는 것은 adapter가 이미 받아들인 메시지뿐이다. mention 부족, channel 범위 밖, known-other-bot 우선순위 등으로 adapter에서 먼저 탈락한 이벤트는 hook에 도달하지 않는다.
이 발견으로 목표를 바꿨다.
변경 전 생각: routing 전체를 shadow할 수 있다.
검증 후 판정: gateway에 들어온 accepted event의 passthrough observer다.
승격 경계: adapter-level observation point를 별도로 설계하기 전에는 authoritative router가 아니다.
실패 2 — 실제 작업 디렉터리의 .env가 테스트 fixture를 덮었다
설정 bridge 테스트는 fixture에서 특정 bot ID 목록을 주입하고 그 값이 env와 runtime config에 반영되는지 확인한다. 그런데 현재 작업 디렉터리에서 시작한 process는 상위 .env를 탐색하는 SDK import side effect 때문에 실제 host env 값을 다시 읽을 수 있었다.
개인정보를 노출하지 않기 위해 실제 ID 대신 합성 sentinel만 사용해 재 현했다.
ambient .env sentinel + nested cwd
→ fixture env 삭제
→ module import가 ambient .env 재주입
→ fixture보다 ambient sentinel이 우선
그리고 .env를 탐색할 수 없는 clean cwd에서 같은 fixture를 실행했다.
clean cwd + 동일한 fixture home
→ ambient sentinel 없음
→ fixture 값 보존
두 실행은 모두 실제 workspace ID를 사용하지 않았고 production 파일도 수정하지 않았다. 이 결과는 테스트의 cwd를 tmp_path로 격리하는 방향이 유효하다는 것을 보여 주지만, Hermes core test의 수정·merge는 이번 사례 발행 범위에서 수행하지 않았다.
실패 3 — 전용 seal을 표준 checksum 파일처럼 읽었다
사례글용 새 검증기를 처음 실행했을 때 MANIFEST.seal에 shasum -c를 적용해 실패했다. 원본 seal은 표준 checksum 한 줄이 아니라 다음 정보를 가진 프로젝트 전용 4행 형식이었다.
sha256 <digest> artifact-manifest.json
bytes <size>
sealed_at <timestamp>
status CANARY_PASS_PRODUCTION_HOLD
원본을 바꾸거나 실패를 무시하지 않았다. 검증기를 다음과 같이 고쳤다.
seal의 첫 행에서 기대 SHA-256을 읽는다.
현재
artifact-manifest.json의 SHA-256을 직접 계산한다.두 값이 같은지 비교한다.
manifest 안의 12개 artifact도 각각 존재·bytes·SHA-256으로 다시 읽는다.
재실행에서 seal과 12/12 artifact가 모두 일치했다.
실패 4 — 검증 뒤 다른 세션의 config 변경이 관찰됐다
원래 point-in-time snapshot 이후 기본 profile config hash가 달라졌다. 이를 canary가 production을 바꾼 것으로 단정하거나, 반대로 “아무 변화도 없다”고 덮지 않았다.
별도 addendum에서 다음을 확인했다.
변화는 다른 사용자 승인 세션의 plugin enablement와 일치했다.