Claude Code와 Hermes를 HMAC Webhook으로 연결한 방법

Claude Code가 보낸 HMAC 서명 요청을 Hermes가 검증한 뒤 WordPress 초안으로 넘기는 구조

작성자

· 카테고리:

영상 제작 서버의 Claude Code가 작업을 끝내면 그 결과를 Hermes에게 넘겨야 했다. 처음에는 내가 중간에 있었다. commit SHA를 복사하고, 파일 경로를 알려주고, 어떤 이미지가 대표 이미지인지 다시 설명했다.

한두 번은 괜찮았다. 앱 공장 회차가 늘어나자 이상해졌다. 두 AI 에이전트를 연결해 놓고도 사람이 계속 전달자 역할을 하고 있었다.

그 일을 줄이려고 Claude Code가 작업 결과를 Webhook으로 보내고, Hermes가 원본을 검증한 뒤 WordPress 초안까지 만드는 통로를 만들었다. 문제는 연결 자체가 아니었다. 외부에서 들어온 요청을 어디까지 믿을 것인지가 더 어려웠다.

Claude Code와 Hermes는 서로 다른 곳에 있었다

영상과 코드가 만들어지는 GitLab 저장소는 Claude Code가 있는 작업 서버에서 다룬다. WordPress와 YouTube를 관리하는 Hermes는 별도의 서버에서 실행된다.

Claude Code가 만든 파일을 Hermes 서버에 바로 복사하도록 만들 수도 있었다. 하지만 그렇게 하면 파일이 어디에서 왔는지, 어떤 commit을 기준으로 만들었는지, 전달 도중 바뀌지는 않았는지 확인하기 어려웠다. 외부 에이전트에 WordPress 자격증명을 주는 방법도 제외했다. 글을 만드는 에이전트가 곧바로 공개 권한까지 갖는 구조는 원하지 않았다.

전달해야 할 것은 파일 자체보다 검증할 수 있는 출처 정보였다.

  • 원격 GitLab에 push된 40자리 고정 commit SHA
  • 영상 대본과 원고 재료의 저장소 상대 경로
  • 각 아티팩트의 SHA-256
  • YouTube 영상 ID와 공개 제목
  • 요청을 구분할 고유한 event_id

Hermes는 이 정보를 받은 뒤 저장소에서 원본을 다시 가져와 확인한다. 발신 에이전트가 보내 준 설명을 그대로 사실로 취급하지 않는다.

공개 Webhook 앞에 HMAC 관문을 세웠다

Hermes Agent의 Webhook 기능은 외부 서비스의 POST 요청을 받아 에이전트 실행으로 연결할 수 있다. 라우트마다 비밀키를 두고 HMAC 서명을 검증하며, 요청 크기 제한과 rate limit, 중복 요청 방지 기능도 제공한다.

나는 공개 주소를 글에 그대로 적지 않았다. 엔드포인트가 공개된다고 바로 뚫리는 구조는 아니지만, 운영 주소를 굳이 검색 가능한 문서에 남겨 공격 표면을 넓힐 이유도 없었다. 아래 코드는 설명을 위한 예시 주소를 사용한다.

POST https://webhook.example.com/webhooks/coding-agent
Content-Type: application/json
X-Webhook-Timestamp: 1786991400
X-Webhook-Signature-V2: <HMAC-SHA256 hex digest>

V2 서명의 입력은 복잡하지 않다.

<Unix timestamp>.<raw JSON body bytes>

여기서 raw body가 중요하다. JSON을 수신 측에서 다시 정렬하거나 공백을 바꾼 문자열이 아니라 실제로 전송한 바이트를 서명해야 한다.

import hashlib
import hmac
import json
import os
import time
import urllib.request

payload = {
    "event_id": "app-factory-ep12-v1",
    "event_type": "blog_post_request",
    "repository": "https://git.example.com/video-studio",
    "commit_sha": "40_CHAR_FIXED_COMMIT_SHA",
    "artifacts": [
        {
            "role": "article_source",
            "path": "articles/episode-12.md",
            "sha256": "64_CHAR_SHA256"
        }
    ]
}

body = json.dumps(
    payload,
    ensure_ascii=False,
    separators=(",", ":")
).encode("utf-8")

timestamp = str(int(time.time()))
signing_input = timestamp.encode("ascii") + b"." + body
signature = hmac.new(
    os.environ["HERMES_WEBHOOK_SECRET"].encode("utf-8"),
    signing_input,
    hashlib.sha256
).hexdigest()

request = urllib.request.Request(
    "https://webhook.example.com/webhooks/coding-agent",
    data=body,
    method="POST",
    headers={
        "Content-Type": "application/json",
        "X-Webhook-Timestamp": timestamp,
        "X-Webhook-Signature-V2": signature
    }
)

Hermes는 같은 규칙으로 서명을 계산한 뒤 hmac.compare_digest()로 비교한다. V2 timestamp가 현재 시각에서 5분 이상 벗어나면 요청을 거부한다. 과거에 캡처한 body와 서명을 그대로 다시 보내는 재전송을 막기 위한 범위다.

Hermes에는 body만 서명하는 이전 V1 방식도 호환을 위해 남아 있다. 하지만 timestamp가 서명에 묶이지 않으면 캡처한 요청을 나중에 다시 보낼 수 있다. 그래서 이 통로는 V2 헤더만 사용하도록 정했다.

Claude Code 요청부터 HMAC V2 검증, GitLab 원본 재검증, WordPress 초안 생성까지 이어지는 Webhook 처리 흐름
서명이 맞는 요청도 GitLab commit과 아티팩트 해시를 다시 확인한 뒤 WordPress 초안까지만 만든다. HTML/CSS로 구성하고 Chromium으로 렌더링했다.

서명이 맞다는 것과 내용이 사실이라는 것은 달랐다

HMAC 검증이 성공하면 두 가지는 알 수 있다.

  1. 같은 비밀키를 가진 발신자가 요청을 만들었다.
  2. 서명 후 body가 바뀌지 않았다.

하지만 payload 안의 주장이 사실인지까지 증명하지는 않는다. 발신 에이전트가 잘못된 파일 경로를 넣거나 아직 push하지 않은 commit을 적을 수도 있다. 다른 회차의 대표 이미지를 연결하는 실수도 가능하다.

그래서 HMAC 뒤에 두 번째 관문을 뒀다.

  1. 허용된 GitLab 프로젝트인지 확인한다.
  2. 브랜치 이름이나 HEAD가 아니라 고정 commit이 실제로 존재하는지 확인한다.
  3. 그 commit 안에서 전달받은 상대 경로를 읽는다.
  4. 원본 파일의 SHA-256을 계산해 payload의 값과 대조한다.
  5. YouTube 영상 ID·제목과 영상 제작 원본의 회차가 일치하는지 확인한다.
  6. 대표 이미지가 실제로 그 회차에 사용된 이미지인지 확인한다.

commit SHA를 고정한 이유도 여기에 있다. main이나 HEAD는 Hermes가 확인하는 순간 다른 commit을 가리킬 수 있다. 40자리 commit SHA는 Claude Code가 보낸 시점과 Hermes가 검증한 시점 사이의 기준을 고정한다.

자동화의 처리 범위를 WordPress 초안에서 멈췄다

검증에 성공했다고 글을 바로 공개하지는 않는다. 이 Webhook으로 시작된 에이전트에는 WordPress 변경을 status=draft 생성까지만 수행하도록 운영 규칙을 뒀다.

다음 작업은 허용하지 않았다.

  • 글 공개 또는 예약 발행
  • 기존 글 수정과 삭제
  • Git push와 배포
  • WordPress 플러그인·테마·사이트 설정 변경

초안이 만들어진 뒤에도 Hermes가 저장값, 대표 이미지, 모바일 화면, 외부 링크와 익명 비노출 상태를 확인한다. 마지막 공개 결정은 별도의 대화에서 내가 승인한다.

자동화하면서 사람을 완전히 없애려던 것은 아니었다. 내가 하던 복사와 전달은 줄이고, 무엇을 공개할지는 사람이 결정하는 위치에 남겨두고 싶었다.

HTTP 202는 작업 완료가 아니었다

Webhook 요청이 정상이면 Hermes는 보통 202 Accepted를 반환한다. 처음에는 이 응답을 성공으로 생각하기 쉬웠다.

하지만 202가 뜻하는 것은 요청을 접수하고 에이전트 실행을 시작했다는 것뿐이다. 그 뒤의 GitLab 접근, commit 확인, 해시 비교, WordPress 저장은 아직 진행되지 않았다.

202 Accepted
→ HMAC 검증을 통과해 요청을 접수함
→ WordPress 초안 생성 성공을 의미하지는 않음

최종 결과는 별도의 Telegram 메시지로 받도록 했다. 어느 단계에서 실패했는지도 그때 확인한다. 검증이 하나라도 실패하면 추측해서 채우지 않고 WordPress를 변경하지 않는다.

중복 요청도 정상적인 상황으로 봤다

Webhook 발신자는 응답을 받지 못하면 같은 요청을 다시 보낼 수 있다. 네트워크 재시도는 오류가 아니라 정상적인 동작이다. 하지만 에이전트 실행이 두 번 시작되면 WordPress 초안도 중복될 수 있다.

Hermes의 Webhook은 delivery ID를 일정 시간 기억해 같은 전달을 다시 실행하지 않는다. 내가 만든 payload에도 고유한 event_id를 넣었다. 같은 요청을 네트워크 때문에 재시도할 때는 같은 ID를 유지하고, 원본이나 요청 내용이 달라지면 새 ID를 사용한다.

중복 방지는 단순한 최적화가 아니었다. 외부 이벤트가 글 작성이나 배포처럼 상태를 바꾸는 작업으로 이어질 때는 반드시 필요한 관문이었다.

비밀키는 에이전트의 대화에 넣지 않았다

HMAC 비밀키를 프롬프트로 Claude Code에 알려주면 편할 것 같지만 그렇게 하지 않았다.

  • 채팅이나 프롬프트에 적지 않는다.
  • Git 저장소와 문서에 commit하지 않는다.
  • payload에 포함하지 않는다.
  • 발신 환경의 보호된 환경변수로만 주입한다.
  • Hermes 서버에서도 권한이 제한된 비밀 설정 파일로 관리한다.

AI에게 비밀키 값을 설명할 필요는 없다. 코드는 환경변수의 이름과 사용 방법만 알고, 실제 값은 실행 환경이 넣으면 된다.

연결보다 신뢰 경계를 설계하는 일이 더 오래 걸렸다

Webhook을 보내는 코드 자체는 길지 않았다. 오래 걸린 부분은 그 뒤였다.

서명은 누가 보냈는지를 확인한다. 고정 commit과 파일 해시는 무엇을 보냈는지를 확인한다. WordPress 초안 제한은 그 요청이 어디까지 바꿀 수 있는지를 정한다. Telegram 결과 보고는 비동기 작업이 정말 끝났는지를 확인한다.

이 관문들이 생기고 나서야 Claude Code와 Hermes 사이에서 내가 commit SHA와 파일 경로를 계속 복사하지 않아도 됐다. 그렇다고 외부 에이전트가 WordPress를 마음대로 바꿀 수도 없었다.

AI에게 바로 구현을 맡기기보다 조사와 질문부터 요청하기 시작한 변화가 대화 방식에 관한 이야기였다면, 이번 연결은 그 방식을 시스템의 경계로 옮긴 작업에 가까웠다.

다음에는 HMAC 검증 코드와 timestamp, 멱등성 테스트를 개발 노트로 따로 정리해 보려고 한다. 전체 흐름과 구현 코드를 한 글에 모두 넣으면 내가 왜 이런 관문을 만들었는지가 다시 코드 속에 묻힐 것 같기 때문이다.


참고 자료

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다