Inference hooks 연동 개발하기

Inference hooks 연동 개발하기

Inference hooks 연동은 AI 보안 서버, 즉 Anthropic이 호출하는 HTTPS 서비스예요. 관리되는 요청 하나하나에 대해 서버는 대화 내용을 담은 서명된 POST 요청을 받고, allow(허용) 또는 deny(거부) 평결로 응답한답니다. 이 페이지에서는 그 서버를 만들기 위한 프로토콜, 즉 요청·평결 스키마, 서명 검증, 운영 계약을 정리해 드릴게요.

Inference hooks를 켜고 엔드포인트로 연결하려면 Inference hooks 구성을, Inference hooks가 무엇이고 언제 쓰는지는 Inference hooks 개요를 살펴보세요.

출처: 문서

본문

참고 Inference hooks는 베타 기능이며 Claude Enterprise 조직에서 사용할 수 있어요. 베타 기간 동안 필드 이름, 요청 형태, 헤더는 바뀔 수 있어요.

가장 작은 동작하는 연동은 각 요청을 읽고 허용하는 서버예요. 아래 서버 중 하나를 실행하고, 공개 https:// URL(예: 내가 제어하는 호스트의 TLS 종료 리버스 프록시 뒤 — 리버스 터널 서비스는 안 돼요. 요청 받기 참고)로 노출한 뒤, 관리자가 엔드포인트로 설정하고 연결을 테스트하면 Test connection 결과가 서버가 돌려준 allow 평결을 보고해요.

# Run with: python server.py
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


class VerdictHandler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"  # keep the connection open between verdicts

    def do_POST(self):
        # Drain the body; transcripts can be megabytes.
        self.rfile.read(int(self.headers.get("Content-Length", 0)))
        verdict = b'{"action": "allow"}'
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(verdict)))
        self.end_headers()
        self.wfile.write(verdict)


ThreadingHTTPServer(("", 8000), VerdictHandler).serve_forever()
// Run with: node server.ts
import { createServer } from "node:http";

createServer((request, response) => {
  // Drain the body before answering; transcripts can be megabytes.
  request.resume();
  request.on("end", () => {
    response.writeHead(200, { "Content-Type": "application/json" });
    response.end('{"action": "allow"}');
  });
}).listen(8000);
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
// Run with: dotnet run server.cs

var app = WebApplication.Create();

app.MapPost("/{**path}", async (HttpRequest request) =>
{
    // Drain the body; transcripts can be megabytes.
    await request.Body.CopyToAsync(Stream.Null);
    return Results.Text("""{"action": "allow"}""", "application/json");
});

app.Run("http://0.0.0.0:8000");
// Run with: go run server.go
package main

import (
	"io"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("POST /", func(writer http.ResponseWriter, request *http.Request) {
		// Drain the body so the connection can be reused; transcripts can be megabytes.
		io.Copy(io.Discard, request.Body)
		writer.Header().Set("Content-Type", "application/json")
		writer.Write([]byte(`{"action": "allow"}`))
	})
	log.Fatal(http.ListenAndServe(":8000", nil))
}
// Run with: java VerdictServer.java
import com.sun.net.httpserver.HttpServer;

void main() throws IOException {
    HttpServer server = HttpServer.create(new InetSocketAddress(8000), 0);
    server.createContext("/", exchange -> {
        // Drain the body without buffering it; transcripts can be megabytes.
        exchange.getRequestBody().transferTo(OutputStream.nullOutputStream());
        byte[] verdict = "{\"action\": \"allow\"}".getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", "application/json");
        exchange.sendResponseHeaders(200, verdict.length);
        try (OutputStream responseBody = exchange.getResponseBody()) {
            responseBody.write(verdict);
        }
    });
    server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    server.start();
}
<?php
// Run with: php -S 0.0.0.0:8000 server.php

// Drain the body; transcripts can be megabytes.
file_get_contents('php://input');

http_response_code(200);
header('Content-Type: application/json');
echo '{"action": "allow"}';
# webrick is a regular gem in Ruby 3.4: gem install webrick, or add gem "webrick".
# Run with: ruby server.rb
require "webrick"

server = WEBrick::HTTPServer.new(Port: 8000)
server.mount_proc("/") do |request, response|
  request.body # Drain the body; transcripts can be megabytes.
  response.status = 200
  response["Content-Type"] = "application/json"
  response.body = '{"action": "allow"}'
end
server.start

참고 이 서버들은 서명되지 않은 요청을 포함해 모든 요청을 허용해요. 집행을 시작하기 전에 서명 검증을 추가하세요.

요청 받기

Anthropic은 관리자가 구성한 URL로 HTTPS POST를 보내요. 구성된 전체 URL이 엔드포인트이며 고정된 경로 접미사는 없어요. 그러니 서버에 맞는 어떤 경로든 선택하면 돼요.

AI 보안 서버는 Anthropic이 도달할 수 있는 곳에 호스팅하세요. 포트 443의 https:// URL, 공개 라우팅 가능한 호스트(사설·루프백·통신사급 NAT 범위는 연결 시점에 거부돼요), 공개 CA 트러스트 저장소에 대해 검증되는 인증서, 리다이렉트 없는 응답이어야 해요. 구성된 URL이 최종 목적지여야 해요. 리버스 터널 호스트(ngrok 및 유사 터널 서비스)는 지원되지 않아요. Anthropic의 네트워크 정책이 차단하거든요. 서버는 내가 제어하는 도메인에 호스팅하세요. 관리자가 URL을 설정하고 테스트하는 방법은 Inference hooks 구성에서 다뤄요.

모든 요청은 다음 고정 헤더와 함께, 관리자가 구성한 커스텀 요청 헤더, 그리고 조직에 서명 시크릿이 생기면 서명 검증에 설명된 webhook-* 서명 헤더를 함께 실어 보내요.

헤더
Content-Type application/json
User-Agent anthropic-dlp/1
Accept-Encoding identity

오늘은 훅 이벤트가 하나만 있어요. 프롬프트 프레임(prompt frame)인데, 관리되는 추론 요청 하나당 한 번, 추론이 시작되기 전에 보내져요. Anthropic은 AI 보안 서버가 응답하거나 평결 타임아웃이 지날 때까지 요청을 붙잡아요.

프롬프트 프레임

요청 본문은 다음 필드를 가진 JSON 객체예요.

필드 타입 설명
type string 훅 이벤트. 오늘은 항상 "prompt"예요. 다른 이벤트 타입은 나중에 도입될 예정이니, 인식하지 못하는 값은 우아하게 처리하세요(앞으로의 호환성 참고).
request_id string 추론 호출별 불투명 식별자로 상관관계 파악에 쓰여요. webhook-id 헤더와 같아요.
tenant_id string 또는 null 요청이 속한 조직의 불투명 식별자.
actor object 요청이 귀속된 주체로, type에 따라 구분돼요(오늘은 "user"만 보내요). id(같은 계정에서 요청 간 안정적인 태그 식별자)와 email_address(가능할 때)를 포함해요. idemail_address 둘 다 null일 수 있어요.
source object 발신 애플리케이션: application(소스 값 참고).
messages array 추론 시점까지의 대화 내용. 콘텐츠 블록 참고.
session_id string 또는 null 존재할 때의 불투명 대화 식별자. 파싱하지 마세요. Claude Code의 경우 best-effort로 클라이언트가 주장하는 세션 식별자예요.
model string 또는 null 가능할 때 이 요청의 공개 모델 식별자.
metadata object 문자열 키를 문자열 값으로 매핑하는 예약 확장 맵이며, 오늘은 비어서 보내요. 여기에 아무것도 요구하지 말고, 없어도·있어도·어떤 키가 나타나도 관대하게 처리하세요.

요청 본문 예시:

{
  "type": "prompt",
  "request_id": "req_abc123",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "actor": {
    "type": "user",
    "id": "user_01AbCdEfGhIjKlMnOpQrStUv",
    "email_address": "[email protected]"
  },
  "source": {
    "application": "claude-ai"
  },
  "session_id": "22222222-2222-2222-2222-222222222222",
  "model": "claude-sonnet-4-5",
  "messages": [
    {
      "role": "user",
      "content": [
        {
          "type": "text",
          "text": "Summarize the attached report."
        },
        {
          "type": "attachment",
          "file_name": "q2-report.pdf",
          "media_type": "application/pdf",
          "size_bytes": 48213,
          "text": "Q2 revenue grew 14% quarter over quarter..."
        }
      ]
    }
  ],
  "metadata": {}
}

콘텐츠 블록

messages의 각 항목은 user 또는 assistant role(툴 결과는 user role 아래 나타나며 공개 Messages API 콘텐츠 모델과 일치해요)과 type으로 구분되는 content 블록 배열을 가져요.

블록 type 필드
text text: 텍스트 콘텐츠.
tool_use id: 일치하는 툴 결과가 참조하는 식별자. tool_name: 툴 이름. input: 모델이 툴에 넘긴 인자.
tool_result content: 툴 출력을 텍스트로, 부분을 줄바꿈으로 연결한 것. 이미지 같은 바이너리 부분은 플레이스홀더 마커로 대체되고 원시 바이트는 절대 보내지지 않아요. is_error: 툴 호출이 실패했는지. tool_name: 툴 이름 — 정책이 이전 블록을 교차 참조하지 않고도 툴 정체성에 조건을 걸 수 있게 해 줘요. tool_use_id: 일치하는 tool_use 블록의 id.
attachment file_name: 원본 파일 이름 또는 경로. media_type: 첨부 파일의 미디어 타입. size_bytes: 원본 파일 크기. text: 추출된 문서 텍스트, 오디오 트랜스크립트, 링크 메타데이터 등 가능할 때의 첨부 파일 텍스트 콘텐츠. 원시 첨부 바이트는 절대 보내지지 않아요.

typetext 블록의 text, 그리고 tool_result 블록의 contentis_error를 제외하면, 값이 알려지지 않을 때 이 필드들 중 어떤 것이든 null일 수 있어요. 예를 들어 이미지는 file_nametextnull로 설정된 attachment 블록으로 도착해요.

type을 인식하지 못하는 블록은 앞으로 호환되는 추가분이에요. 보장하는 유일한 필드는 type뿐이며, 정책은 다른 필드가 있으면 검사해도 되지만 인식하지 못하는 타입 때문에 요청을 거부하면 안 돼요.

대화 내용에는 무엇이 들어갈까

대화 내용은 최종 사용자가 보는 대화 그대로를 추론 시점까지 담은 거예요. 대화 텍스트, 툴 호출과 그 결과, 추출된 첨부 텍스트, 이전 턴들이지요. 시스템 프롬프트, 툴 정의, Anthropic 내부 컨텍스트, Claude의 숨은 추론, 원시 파일 바이트는 절대 포함되지 않아요.

모든 블록이 제외된 턴은 통째로 생략되므로, user와 assistant가 엄격히 번갈아 나온다고 가정하면 안 돼요.

대화 내용은 잘리지 않고 보내지므로, 큰 첨부 파일이 있는 긴 대화는 큰 요청 본문을 만들어요. 실제로는 모델의 컨텍스트 윈도우가 본문을 약 10MB 이내로 유지하지만, 프로토콜은 최대 64MiB까지 허용해요. 흔한 기본값 몇 개는 훨씬 작아요. nginx client_max_body_size는 1MB, Express express.json()은 100kB이고, 거부된 본문은 웹훅 실패로 간주되므로 Allow the request 실패 처리 아래에서는 과도하게 큰 프롬프트가 검사 없이 모델에 도달하게 돼요.

소스 값

source.application은 닫힌 enum이 아니라 열린 문자열이에요. 흔한 값은 claude-ai, claude-code, cowork이고, 연결 테스트와 자동 차단기 복구 검사config-test를 사용해요. 새 값이 나타날 수 있으므로, 서버는 인식하지 못하는 값 때문에 요청을 거부하면 안 돼요.

source.application은 신뢰 경계가 아니라 보조 라우팅 메타데이터로 취급하세요. 보안에 중요한 정책 결정을 이것만으로 내리면 안 돼요.

평결 반환하기

두 결과 모두 HTTP 200과 JSON 평결 본문으로 응답하며, action 필드가 결과를 구분해요. 요청을 허용하려면:

{
  "action": "allow"
}

거부하려면:

{
  "action": "deny",
  "deny_reason": "This prompt appears to contain customer payment card data, which your organization's policy does not allow.",
  "reference_id": "scan_01HXPT4R9V"
}
필드 제약 의미
action "allow" 또는 "deny"; 필수 allow는 추론을 진행시키고 deny는 거부해요.
deny_reason string 또는 null; 최대 500자, 더 길면 잘림 actiondeny일 때 최종 사용자에게 보여져요. allow일 때는 무시돼요.
reference_id string 또는 null; [A-Za-z0-9._:/-]에서 최대 50자 이 평가에 대한 내 식별자. 거부의 inference_hooks_request_denied 준수 활동에 기록되며 최종 사용자에게 절대 보이지 않아요. 불투명하게 유지하세요. 요청 콘텐츠나 개인 데이터 없이요.

거부는 포맷 문제 때문에 버려지지 않아요. 너무 큰 deny_reason은 잘리고, 잘못된 reference_id는 조용히 버려지며, action은 여전히 존중돼요.

반대는 성립하지 않아요. HTTP 200이 아니거나 파싱 가능한 평결이 아닌 것은 웹훅 실패이며, 평결 대신 조직의 실패 처리 설정이 적용돼요. 특히:

  • 오류 상태로 거부를 알리지 마세요. 200이 아닌 응답은 거부가 아니라 실패예요.
  • allowdeny 외의 action 값은 웹훅 실패로 처리돼요.

Anthropic은 응답 본문을 최대 64KiB만 읽고, 본문은 압축되지 않아야 해요. 리다이렉트는 따르지 않고 쿠키는 무시돼요. 평결 본문의 알려지지 않은 필드는 무시되므로, 여기 문서화된 필드와 함께 더 풍부한 객체를 반환할 수 있어요.

서명 검증

요청은 Standard Webhooks 사양에 따라 세 헤더로 서명돼요. Anthropic은 헤더 이름을 소문자로 보내고, 프록시는 자유롭게 대소문자를 바꿀 수 있으니 대소문자를 구분하지 않고 조회하세요.

헤더 내용
webhook-id 이 전달의 고유 식별자. 본문의 request_id와 같아요. 멱등성 키로, 그리고 서명 페이로드의 첫 구성 요소로 사용하세요.
webhook-timestamp 요청이 서명된 Unix 시간(초, 십진 문자열). 서버 시계에서 어느 방향이든 5분 이상 차이가 나는 타임스탬프는 거부하세요.
webhook-signature 공백으로 구분된 하나 이상의 v1,<base64> 값. 각각은 {webhook-id}.{webhook-timestamp}.{원시 본문 바이트}에 대한 HMAC-SHA256이에요. 상수 시간 비교로 자신의 것과 일치하는 값이 있으면 수락하세요.

검증 버그를 가장 많이 일으키는 두 가지 세부사항:

  • 원시 바이트를 검증하세요. JSON 파싱이나 재인코딩 전에, 받은 그대로의 본문에 HMAC을 계산하세요.
  • 시크릿을 표준 base64 디코더로 디코드하세요. 서명 시크릿은 whsec_ 접두사 뒤의 값으로 표준 base64 알파벳(+/)으로 인코딩되며, 헤더의 서명도 마찬가지예요. URL-safe 디코더는 시크릿이 +/를 포함할 때마다(대부분의 경우) 잘못된 키 바이트를 만들어요.

조직에 서명 시크릿이 생기면, 연결 테스트를 포함해 Anthropic이 보내는 모든 요청이 서명돼요. 설정 흐름이 첫 테스트 전에 시크릿을 생성하기 때문이지요. Inference hooks를 활성화하려면 시크릿이 필요하므로, 서명되지 않은 요청은 거부하세요. 예외 하나: 시크릿이 필수가 되기 전에 Inference hooks를 활성화한 조직은 관리자가 시크릿을 생성할 때까지 서명 없는 요청을 계속 보내요. 관리자가 시크릿이 존재함을 확인할 때까지만 서명 없는 요청을 수락하고, 그 후부터는 거부하세요.

시크릿 교체는 즉각적인 절환이지만, 이전 시크릿으로 서명된 요청이 그 후 약 1분 동안, 그리고 이미 진행 중인 것들까지 도착할 수 있어요. 전환 동안 AI 보안 서버가 두 시크릿의 서명을 모두 수락하게 해서 그런 지연 요청들이 거부되지 않게 하세요.

아래 샘플은 서버 구현이므로 shell 탭이 없어요. AI 보안 서버는 일회성 요청이 아니라 오래 실행되는 HTTPS 서비스거든요. 각 샘플은 언어의 표준 라이브러리만 사용하며, Standard Webhooks 프로젝트는 대부분의 언어용 검증 라이브러리도 제공해요.

import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(secret: str, headers: dict[str, str], body: bytes) -> bool:
    """Return True if the body was signed by Anthropic for this organization.

    Anthropic sends header names in lowercase, but proxies are free to
    re-case them, so normalize the lookup to lowercase.
    """
    lowercased = {name.lower(): value for name, value in headers.items()}
    try:
        message_id = lowercased["webhook-id"]
        timestamp = lowercased["webhook-timestamp"]
        signatures = lowercased["webhook-signature"]
    except KeyError:
        return False  # unsigned request: not from Anthropic

    try:
        signed_at = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - signed_at) > TOLERANCE_SECONDS:
        return False  # replayed, or the clocks disagree

    try:
        key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
    except ValueError:
        return False  # misconfigured secret: reject rather than crash

    payload = f"{message_id}.{timestamp}.".encode() + body
    expected = b"v1," + base64.b64encode(
        hmac.new(key, payload, hashlib.sha256).digest()
    )

    # Compare bytes: compare_digest on str raises on non-ASCII input.
    return any(
        hmac.compare_digest(expected, candidate.encode())
        for candidate in signatures.split()
    )
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingHttpHeaders } from "node:http";

const TOLERANCE_SECONDS = 300;

/**
 * Returns true if the body was signed by Anthropic for this organization.
 *
 * Node lowercases incoming header names, matching how Anthropic sends
 * them, so look them up in lowercase.
 */
export function verify(secret: string, headers: IncomingHttpHeaders, body: Buffer): boolean {
  const messageId = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (
    typeof messageId !== "string" ||
    typeof timestamp !== "string" ||
    typeof signatures !== "string"
  ) {
    return false; // unsigned request: not from Anthropic
  }

  const signedAt = Number(timestamp);
  if (
    !Number.isFinite(signedAt) ||
    Math.abs(Date.now() / 1000 - signedAt) > TOLERANCE_SECONDS
  ) {
    return false; // replayed, or the clocks disagree
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const payload = Buffer.concat([Buffer.from(`${messageId}.${timestamp}.`), body]);
  const expected = Buffer.from(
    "v1," + createHmac("sha256", key).update(payload).digest("base64")
  );

  return signatures.split(" ").some((candidate) => {
    const candidateBytes = Buffer.from(candidate);
    return (
      candidateBytes.length === expected.length && timingSafeEqual(candidateBytes, expected)
    );
  });
}
using System.Security.Cryptography;
using System.Text;

static class InferenceHooks
{
    private const int ToleranceSeconds = 300;

    /// <summary>
    /// Returns true if the body was signed by Anthropic for this organization.
    /// Anthropic sends header names in lowercase, but proxies are free to
    /// re-case them, so match them case-insensitively.
    /// </summary>
    public static bool Verify(string secret, IReadOnlyDictionary<string, string> headers, byte[] body)
    {
        // TryAdd keeps the first value if a proxy delivered case-duplicate
        // names; the copying constructor would throw on them instead.
        var lookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var (name, value) in headers)
        {
            lookup.TryAdd(name, value);
        }

        if (!lookup.TryGetValue("webhook-id", out var messageId) ||
            !lookup.TryGetValue("webhook-timestamp", out var timestamp) ||
            !lookup.TryGetValue("webhook-signature", out var signatures))
        {
            return false; // unsigned request: not from Anthropic
        }

        if (!long.TryParse(timestamp, out var signedAt) ||
            Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - signedAt) > ToleranceSeconds)
        {
            return false; // replayed, or the clocks disagree
        }

        // Standard base64 alphabet: a URL-safe decoder derives the wrong key bytes.
        var encodedKey = secret.StartsWith("whsec_") ? secret["whsec_".Length..] : secret;
        byte[] key;
        try
        {
            key = Convert.FromBase64String(encodedKey);
        }
        catch (FormatException)
        {
            return false; // misconfigured secret: reject rather than crash
        }

        byte[] payload = [.. Encoding.UTF8.GetBytes($"{messageId}.{timestamp}."), .. body];
        var expected = Encoding.UTF8.GetBytes(
            "v1," + Convert.ToBase64String(HMACSHA256.HashData(key, payload)));

        // FixedTimeEquals is constant-time and returns false on a length mismatch.
        return signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries).Any(candidate =>
            CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(candidate), expected));
    }
}
package hooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 300

// verify reports whether body was signed by Anthropic for this organization.
// net/http canonicalizes header names on lookup, so re-cased names still match.
func verify(secret string, header http.Header, body []byte) bool {
	messageID := header.Get("webhook-id")
	timestamp := header.Get("webhook-timestamp")
	signatures := header.Get("webhook-signature")
	if messageID == "" || timestamp == "" || signatures == "" {
		return false // unsigned request: not from Anthropic
	}

	signedAt, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}
	age := time.Now().Unix() - signedAt
	if age > toleranceSeconds || age < -toleranceSeconds {
		return false // replayed, or the clocks disagree
	}

	// Standard base64 alphabet: a URL-safe decoder derives the wrong key bytes.
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return false
	}

	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(messageID + "." + timestamp + "."))
	mac.Write(body)
	expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))

	for _, candidate := range strings.Fields(signatures) {
		if hmac.Equal([]byte(candidate), []byte(expected)) { // constant-time
			return true
		}
	}
	return false
}
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class InferenceHookVerifier {
    private static final long TOLERANCE_SECONDS = 300;

    /**
     * Returns true if the body was signed by Anthropic for this organization.
     *
     * <p>Anthropic sends header names in lowercase, but proxies are free to
     * re-case them, so normalize the lookup to lowercase.
     */
    public static boolean verify(String secret, Map<String, String> headers, byte[] body) {
        Map<String, String> lowercased = new HashMap<>();
        headers.forEach((name, value) -> lowercased.put(name.toLowerCase(Locale.ROOT), value));

        String messageId = lowercased.get("webhook-id");
        String timestamp = lowercased.get("webhook-timestamp");
        String signatures = lowercased.get("webhook-signature");
        if (messageId == null || timestamp == null || signatures == null) {
            return false; // unsigned request: not from Anthropic
        }

        long signedAt;
        try {
            signedAt = Long.parseLong(timestamp);
        } catch (NumberFormatException _) {
            return false;
        }
        if (Math.abs(Instant.now().getEpochSecond() - signedAt) > TOLERANCE_SECONDS) {
            return false; // replayed, or the clocks disagree
        }

        // Standard base64 alphabet: a URL-safe decoder derives the wrong key bytes.
        byte[] key;
        try {
            key = Base64.getDecoder().decode(
                    secret.startsWith("whsec_") ? secret.substring("whsec_".length()) : secret);
        } catch (IllegalArgumentException _) {
            return false; // misconfigured secret: reject rather than crash
        }

        byte[] expected;
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(key, "HmacSHA256"));
            mac.update((messageId + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
            expected = ("v1," + Base64.getEncoder().encodeToString(mac.doFinal(body)))
                    .getBytes(StandardCharsets.UTF_8);
        } catch (GeneralSecurityException impossible) {
            // Every JVM ships HmacSHA256, so this never fires at runtime.
            throw new IllegalStateException(impossible);
        }

        for (String candidate : signatures.split(" ")) {
            if (MessageDigest.isEqual(candidate.getBytes(StandardCharsets.UTF_8), expected)) {
                return true; // MessageDigest.isEqual is constant-time
            }
        }
        return false;
    }
}
const TOLERANCE_SECONDS = 300;

/**
 * Returns true if the body was signed by Anthropic for this organization.
 *
 * Anthropic sends header names in lowercase, but proxies are free to
 * re-case them, so normalize the lookup to lowercase.
 */
function verify(string $secret, array $headers, string $body): bool
{
    $lowercased = array_change_key_case($headers, CASE_LOWER);
    $messageId = $lowercased['webhook-id'] ?? null;
    $timestamp = $lowercased['webhook-timestamp'] ?? null;
    $signatures = $lowercased['webhook-signature'] ?? null;
    if ($messageId === null || $timestamp === null || $signatures === null) {
        return false; // unsigned request: not from Anthropic
    }

    $signedAt = filter_var($timestamp, FILTER_VALIDATE_INT);
    if ($signedAt === false || abs(time() - $signedAt) > TOLERANCE_SECONDS) {
        return false; // replayed, or the clocks disagree
    }

    // Standard base64 alphabet: a URL-safe decoder derives the wrong key bytes.
    $encodedKey = str_starts_with($secret, 'whsec_') ? substr($secret, strlen('whsec_')) : $secret;
    $key = base64_decode($encodedKey, strict: true);
    if ($key === false) {
        return false;
    }

    $payload = "{$messageId}.{$timestamp}." . $body;
    $expected = 'v1,' . base64_encode(hash_hmac('sha256', $payload, $key, binary: true));

    foreach (explode(' ', $signatures) as $candidate) {
        if (hash_equals($expected, $candidate)) { // constant-time
            return true;
        }
    }
    return false;
}
# base64 is a bundled gem in Ruby 3.4: Bundler-managed apps add gem "base64".
require "base64"
require "openssl"

TOLERANCE_SECONDS = 300

# Returns true if the body was signed by Anthropic for this organization.
#
# Anthropic sends header names in lowercase, but proxies are free to
# re-case them, so normalize the lookup to lowercase.
def verify(secret, headers, body)
  lowercased = headers.transform_keys(&:downcase)
  message_id = lowercased["webhook-id"]
  timestamp = lowercased["webhook-timestamp"]
  signatures = lowercased["webhook-signature"]
  if message_id.nil? || timestamp.nil? || signatures.nil?
    return false # unsigned request: not from Anthropic
  end

  signed_at = Integer(timestamp, exception: false)
  if signed_at.nil? || (Time.now.to_i - signed_at).abs > TOLERANCE_SECONDS
    return false # replayed, or the clocks disagree
  end

  # Standard base64 alphabet: a URL-safe decoder derives the wrong key bytes.
  begin
    key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
  rescue ArgumentError
    return false # misconfigured secret: reject rather than crash
  end

  # Feed the body separately so its encoding never has to match the prefix's.
  hmac = OpenSSL::HMAC.new(key, "SHA256")
  hmac.update("#{message_id}.#{timestamp}.")
  hmac.update(body)
  expected = "v1," + Base64.strict_encode64(hmac.digest)

  signatures.split(" ").any? do |candidate|
    # fixed_length_secure_compare raises on a length mismatch, so screen lengths first.
    candidate.bytesize == expected.bytesize &&
      OpenSSL.fixed_length_secure_compare(candidate, expected)
  end
end

운영 시맨틱

타임아웃과 재시도

관리자는 1~10,000ms(기본 5,000ms) 사이의 평결 타임아웃을 설정해요. 예산은 연결, TLS 핸드셰이크, 요청, 응답을 포함한 전체 교환을 포괄해요.

Anthropic은 정확히 한 번, 100ms 지연 후, 연결 시도가 실패할 때만 재시도해요. 재시도는 같은 타임아웃 예산을 공유하고 같은 webhook-id와 같은 서명을 실어 나른답니다. AI 보안 서버가 응답하면 교환은 절대 재시도되지 않아요.

웹훅 실패

타임아웃, 200이 아닌 상태(리다이렉트 포함), 파싱 불가하거나 과도하게 큰 응답 본문, 도달 불가능한 엔드포인트는 모두 웹훅 실패예요. 웹훅 실패는 결코 deny가 되지 않아요. 대신 조직의 실패 처리 설정이 영향받는 요청을 차단할지, 아니면 검사 없이 진행시킬지 결정해요.

차단기(Circuit breaker)

AI 보안 서버에 기인하는 지속적인 웹훅 실패는 집행을 멈추는 차단기를 작동시켜요. Anthropic은 서버에 연락을 중단하고, 모든 요청에 실패 처리를 적용해요.

작동 10분 후부터 Anthropic은 서버가 복구됐는지 확인해요. 대략 분당 한 번, Test connection이 보내는 것과 같은 합성 테스트 요청(source.applicationconfig-test)을 서버에 보내요. 다른 요청처럼 서명되고 사용자 콘텐츠는 실리지 않아요. 정상적으로 응답하세요. 유효한 평결(allow든 deny든)은 차단기를 리셋하고 집행을 재개하며, 웹훅 실패는 차단기를 작동 상태로 두고 검사가 계속돼요. 관리자는 언제든 차단기를 리셋할 수 있고, 관리자 구성 변경은 자동 검사를 중단해요. 차단기 참고.

각 작동은 활동 피드inference_hooks_circuit_breaker_tripped 활동으로 기록되고, 작동당 활동 하나예요. 차단기가 작동하는 동안에는 요청별 Inference hooks 활동이 기록되지 않으므로, 작동 활동이 피드에서 작동 창의 유일한 기록이에요.

지연 시간

집행은 조직의 관리되는 모든 요청 지연 시간에 AI 보안 서버의 왕복을 더해요. 평결을 빠르게 유지하고, 큰 조직에 배포하기 전에 서버를 부하 테스트하세요.

소스 IP 주소

AI 보안 서버로의 요청은 160.79.106.0/24에서 발생하며, 이는 Anthropic의 게시된 아웃바운드 IP 범위의 일부예요. 같은 페이지의 인바운드 범위가 아닌 이 블록을 화이트리스트에 추가하세요. 인바운드 범위는 이것을 포함하지 않아요. 화이트리스트는 서버의 노출을 좁히지만 서명 검증을 대체하지는 않아요. 그 블록은 Inference hooks를 넘어 Anthropic의 이그레스 트래픽도 나르거든요.

앞으로의 호환성

프로토콜은 올바르게 작성된 서버를 깨지 않고 성장해요. 서버는 다음을 무시해야 해요:

  • 프롬프트 프레임의 알려지지 않은 최상위 필드.
  • metadata의 알려지지 않은 키.
  • 새로운 source.application 값.
  • 새로운 actor.type 값. actortype으로 구분되는 합집합이고 오늘은 "user"만 보내져요. 미래의 종류는 type이 존재한다는 것만 보장해요.
  • 인식하지 못하는 type의 콘텐츠 블록.

인식하지 못하는 블록 타입이나 필드 때문에 요청을 절대 거부하지 마세요. 아는 필드를 읽고 나머지는 건너뛰세요.

다른 훅 이벤트 타입은 나중에 도입될 거예요. 새 이벤트 타입은 필드를 건너뛰는 것으로는 처리할 수 없는 추가분이에요. 요청에는 여전히 평결이 필요하니까요. 최상위 type이 인식하지 못하는 값이면 오류 상태 대신 allow 평결을 반환하세요. 오류 응답은 웹훅 실패이고, 지속되는 실패는 차단기를 작동시키거든요.

연동 설계하기

프로덕션 AI 보안 서버는 와이어 프로토콜 너머에서 몇 가지 설계 선택을 해요.

webhook-id로 중복 제거하기. webhook-id 헤더는 전달별로 고유하고 본문의 request_id와 같으며, 연결 실패 재시도가 이를 재사용하므로 멱등성 키로 작동해요. 평결을 기록한다면 그 키로 레코드를 저장하세요.

평결을 기록하고 거부를 조인하기. 반환하는 각 평결을 reference_id와 함께 저장하세요. 모든 거부는 서버가 반환한 reference_id를 실은 inference_hooks_request_denied 준수 활동으로 기록되므로, 활동 피드의 거부를 내 시스템의 일치하는 레코드에 조인할 수 있어요.

항상-허용 서버로 아카이브하기. 대화 내용을 검열하지 않고 실시간으로 포착하려면 무조건 {"action": "allow"}를 반환하고 응답 후 프레임을 영속화하세요. 이것은 Compliance API 폴링의 푸시 기반 대안이며, 영속화 전에 응답함으로써 내 왕복을 사용자의 중요 경로에서 벗어나게 해요.

최종 사용자를 위해 deny_reason을 쓰세요. 반환하는 텍스트는 요청이 차단될 때 사용자가 보는 것이고, 500자에서 잘려요. 팀만 해석할 수 있는 스캐너 코드를 내보내기보다는 어떤 종류의 콘텐츠를 제거해야 하는지처럼 무엇을 바꿔야 하는지 알려주세요.

다음 단계

  • Inference hooks 구성 — Inference hooks를 활성화하고, 엔드포인트를 연결·테스트하며, 집행과 실패 처리, 배포를 제어하는 방법.
  • Inference hooks 개요 — Inference hooks가 무엇인지, 평결 왕복이 어떻게 작동하는지, 언제 써야 하는지.

더 알아보기 (Learn more)