유피테르
유피테르
🧙 AI 위자드
🏅 베스트오브베스트

Hermes 훅을 바로 켜지 않고, ‘관찰만 하는 상자’에 먼저 가뒀습니다-비식별 shadow canary는 통과했지만 production은 HOLD한 사례

📝 한줄 요약

메시지 라우팅을 관찰하는 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_dispatchSlack 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 라우팅 결정을 관찰해 보자.
다만 기존 동작과 개인정보는 건드리지 말자.

그런데 메시징 경로에서 “관찰”은 생각보다 넓은 권한을 가질 수 있다.

  1. callback이 잘못된 값을 반환하면 메시지를 skip하거나 rewrite할 수 있다.

  2. receipt에 원문을 넣으면 사실상 별도의 대화 로그가 생긴다.

  3. channel·thread·user ID를 그대로 저장하면 메시지가 없어도 개인과 대화 위치를 추적할 수 있다.

  4. 실행 중인 profile에 plugin을 바로 켜면 test와 production 경계가 사라진다.

  5. 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.sealshasum -c를 적용해 실패했다. 원본 seal은 표준 checksum 한 줄이 아니라 다음 정보를 가진 프로젝트 전용 4행 형식이었다.

sha256  <digest>  artifact-manifest.json
bytes   <size>
sealed_at  <timestamp>
status  CANARY_PASS_PRODUCTION_HOLD

원본을 바꾸거나 실패를 무시하지 않았다. 검증기를 다음과 같이 고쳤다.

  1. seal의 첫 행에서 기대 SHA-256을 읽는다.

  2. 현재 artifact-manifest.json의 SHA-256을 직접 계산한다.

  3. 두 값이 같은지 비교한다.

  4. manifest 안의 12개 artifact도 각각 존재·bytes·SHA-256으로 다시 읽는다.

재실행에서 seal과 12/12 artifact가 모두 일치했다.

실패 4 — 검증 뒤 다른 세션의 config 변경이 관찰됐다

원래 point-in-time snapshot 이후 기본 profile config hash가 달라졌다. 이를 canary가 production을 바꾼 것으로 단정하거나, 반대로 “아무 변화도 없다”고 덮지 않았다.

별도 addendum에서 다음을 확인했다.

  • 변화는 다른 사용자 승인 세션의 plugin enablement와 일치했다.

  • 그 세션의 backup hash는 canary pre-snapshot hash와 같았다.

  • semantic diff는 해당 plugin enablement 범위에 한정됐다.

  • gateway PID는 유지됐고 canary gateway는 계속 정지 상태였다.

그래서 원래 시점의 CANARY PASS / PRODUCTION HOLD는 유지하되, “전체 턴 동안 production config가 전혀 변하지 않았다”는 넓은 표현은 폐기했다.

그림 3. 성공 수치와 미완료 경계를 한 카드에 함께 배치했다. adapter reject 관찰, authoritative routing shadow, live Slack canary traffic은 완료로 표시하지 않았다.

✅ Before와 After

항목

Before

After

hook 안전성

“observer-only” 설명에 의존

callback·AST·passthrough로 무행동 계약 검증

개인정보

원본에서 지우는 redaction을 고려할 수 있음

허용 목록형 새 receipt만 생성

로그

경로와 크기 정책이 모호

explicit absolute local path·0600·bounded append

활성화

운영 profile에서 바로 시험할 위험

credential 없는 정지 profile로 격리

완료 판단

테스트 PASS면 승격 가능

canary PASS와 production approval 분리

관찰 범위

Slack routing 전체로 오해 가능

adapter accepted event만 관찰한다고 명시

테스트 오염

fixture만 보면 독립적이라고 착각

합성 ambient .env 재현과 clean cwd 격리

drift 판정

hash가 달라지면 원인 불명

point-in-time snapshot과 concurrent addendum 분리

증거

산출물이 존재하면 완료

source·receipt·manifest·seal·read-back을 분리

🧾 Trace · Proof · Verdict · Repair

Trace

운영 hook 관찰 요구
→ protected runtime snapshot
→ credential 없는 canary profile
→ observer-only 계약 동결
→ allow-list receipt 구현
→ 14개 단위·보안 테스트
→ actual PluginManager load
→ GatewayRunner passthrough harness
→ 기존 Slack 77개·core hook 2개 확인
→ adapter 선행 gate 발견
→ test env contamination 재현
→ point-in-time manifest와 concurrent drift addendum
→ 발행 전 fresh test·12개 hash read-back
→ 데이터 파생 증거 카드·사례게시글

Proof

  • fresh observer-only tests 14/14 PASS

  • GatewayRunner에서 event text unchanged, agent dispatch reached

  • frozen artifacts 12/12 존재·bytes·SHA-256 일치

  • manifest seal SHA-256 일치

  • canary gateway stopped

  • 당시 Slack mention/routing 77 pass, 1 deselected

  • 당시 Hermes core hook contract 2 pass

  • 당시 protected runtime profile 6개의 config hash와 gateway PID 동일

  • sentinel privacy test에서 본문·답글·raw payload·이름·ID·attachment 값 0건

  • 합성 ambient .env 오염 재현 PASS

  • clean cwd fixture 보존 PASS

  • 공개 evidence check 10/10 PASS

Verdict

observer_only_contract: PASS
privacy_allowlist_contract: PASS
gateway_passthrough: PASS
frozen_artifact_integrity: PASS
canary_gateway_stopped: PASS
adapter_rejected_event_visibility: NOT_IMPLEMENTED
authoritative_routing_shadow: NOT_IMPLEMENTED
core_test_isolation_patch: NOT_APPLIED
live_slack_canary_traffic: NOT_RUN
production_rollout: HOLD
latency_or_quality_gain: NOT_MEASURED

Repair

  • 설명형 “read-only”를 callback return·AST·routing directive 검증으로 바꿨다.

  • redaction 대신 allow-list receipt를 사용했다.

  • plugin loader 성공과 실제 gateway passthrough를 따로 실행했다.

  • pre_gateway_dispatch의 관찰 범위를 adapter accepted event로 축소해 정확히 기록했다.

  • 실제 ID 대신 합성 sentinel로 .env 오염을 재현했다.

  • 전용 seal 형식에 맞춰 검증기를 고치고 12개 artifact를 다시 읽었다.

  • 동시 config drift는 별도 addendum으로 귀속하고 넓은 무변경 주장을 폐기했다.

  • PASS를 받았어도 별도 adapter 설계·독립심사·승인 전까지 production HOLD를 유지했다.

📋 재사용 가능한 hook canary 체크리스트

[ ] hook이 pipeline의 어느 지점에 있는지, 그보다 앞선 drop gate를 먼저 그린다.
[ ] 운영 profile을 clone하지 않은 credential-free canary를 만든다.
[ ] canary gateway·PID·service가 없는지 read-back한다.
[ ] callback의 모든 return path를 검사한다.
[ ] skip·rewrite·allow·context·tool·command directive가 0인지 확인한다.
[ ] 원문 redaction보다 allow-list receipt를 우선한다.
[ ] 메시지·reply·raw payload·이름·원시 ID를 sentinel로 주입해 누출을 검색한다.
[ ] 로그가 opt-in, absolute local path, 0600, bounded append인지 확인한다.
[ ] symlink·missing parent·oversize·hostile property 오류에서 fail-open인지 시험한다.
[ ] PluginManager가 실제로 hook을 load하는지 확인한다.
[ ] GatewayRunner에서 원문 불변과 agent dispatch 도달을 확인한다.
[ ] 현재 cwd·상위 .env·활성 profile이 fixture를 덮지 않는지 합성 sentinel로 재현한다.
[ ] source·artifact·receipt·manifest·seal의 bytes와 SHA-256을 다시 읽는다.
[ ] snapshot 뒤 drift는 원인별 addendum으로 분리한다.
[ ] canary PASS와 production 승격을 별도 Gate로 둔다.
[ ] 미관찰 경로와 NOT_RUN 항목을 성공 수치로 바꾸지 않는다.

💬 재사용 가능한 프롬프트

현재 Hermes Agent의 메시징 hook을 운영에 바로 활성화하지 말고 observer-only canary로 검증해 주세요.

  1. adapter ingress부터 gateway hook까지의 gate 순서를 먼저 정적으로 추적합니다.

  2. 운영 profile의 config hash와 gateway PID를 동결하고, credential·model·service가 없는 별도 canary profile을 만듭니다.

  3. callback은 모든 경로에서 None만 반환하고, skip·rewrite·allow·context·tool·command directive를 만들지 못하게 합니다.

  4. receipt는 allow-list metadata만 새로 만들고 메시지·reply·raw payload·이름·사용자/채널/스레드/메시지 ID를 저장하지 않습니다.

  5. 로그는 explicit absolute local path가 있을 때만, 0600, append-only, symlink-resistant, bounded size로 씁니다.

  6. 단위 테스트뿐 아니라 actual plugin loader와 GatewayRunner passthrough를 실행해 원문 불변과 agent dispatch 도달을 검증합니다.

  7. 테스트는 real workspace ID 대신 합성 sentinel을 사용하고, cwd·dotenv·HOME·HERMES_HOME 오염을 분리합니다.

  8. source·artifact·receipt·manifest를 SHA-256으로 동결한 뒤 독립 리뷰합니다.

  9. adapter에서 먼저 탈락한 이벤트가 hook에 보이지 않으면 authoritative routing shadow라고 부르지 않습니다.

  10. canary PASS, production HOLD, NOT_RUN을 분리하고 운영 승격은 별도 승인 뒤에만 수행합니다.

🌍 다른 업무에 적용한다면

이 방식은 Hermes hook에만 한정되지 않는다.

  • 웹 webhook: 실제 endpoint를 바꾸기 전에 수신 metadata만 기록하는 shadow consumer를 둔다.

  • 법률 문서 파이프라인: 원문을 재저장하지 않고 문서 유형·페이지 수·처리 상태만 기록하는 관찰 receipt를 둔다.

  • 결제 이벤트: 승인·거절을 바꾸지 않는 observer와 실제 의사결정 engine을 프로세스·권한·로그로 분리한다.

  • 멀티봇 routing: 전체 호출·직접 mention·channel-owner·thread reply의 우선순위를 먼저 합성 fixture로 검증한다.

  • 보안 감사: “읽기 전용” 설명이 아니라 write syscall, return value, side effect, log schema를 독립적으로 확인한다.

핵심은 관찰 코드의 이름이 아니라, 관찰 가능한 범위·남기는 정보·변경 가능한 결과를 따로 증명하는 것이다.

🚧 아직 확인하지 않은 것

  • Slack adapter에서 탈락한 이벤트를 안전하게 관찰하는 별도 ingress hook

  • adapter-level observer의 latency와 backpressure 영향

  • 실제 Slack traffic을 사용하는 live canary

  • 장기 운영에서 receipt volume과 보존 기간

  • core test에 대한 cwd 격리 patch와 upstream 회귀 테스트

  • authoritative routing shadow 또는 routing policy migration

  • production profile 활성화와 rollback drill

  • 응답 속도·비용·라우팅 정확도 개선

이 항목은 14개 테스트나 12개 해시 일치로 대신 증명할 수 없다.

📚 참고자료

📦 산출물과 공개 경계

생성된 산출물

  • observer-only canary contract와 구현

  • 단위·보안 테스트 14개

  • plugin-loader와 GatewayRunner passthrough receipt

  • pre/post runtime snapshot과 12개 artifact manifest

  • concurrent drift addendum

  • 합성 .env 오염·clean cwd 격리 receipt

  • 공개용 검증 JSON

  • 데이터 파생 실행 증거 카드 3장

  • 사례게시글 Library 발행본

  • source·asset·manifest·render QA 기록

공개 경계

  • 상태: Library 발행 후보

  • production hook 활성화: NOT RUN

  • live Slack canary traffic: NOT RUN

  • 외부 웹·커뮤니티·이메일 게시: NOT RUN

  • 실제 메시지 본문·사용자·채널·스레드 ID 공개: 0건

  • 속도·비용·정확도 개선 주장: NOT MEASURED

  • authoritative routing shadow: NOT IMPLEMENTED


현재 판정: observer-only canary 자체의 개인정보·무행동·passthrough·동결 무결성 검증은 통과했다. 그러나 adapter에서 먼저 탈락한 이벤트를 보지 못하므로 운영 라우팅 승격은 HOLD다. 이 사례의 성과는 hook을 켠 것이 아니라, 켜지 말아야 할 경계를 근거와 함께 결정한 것이다.

뉴스레터 무료 구독