콘텐츠로 이동

Claude Code 자동 모드 세이프가드: 프로토콜 기록

이 문서는 세이프가드 어댑터의 프로토콜 사양을 기록합니다. 공식적으로 공개된 사항과 비공개 사항, 라우터가 구현한 보수적인 응답 생성 형태, 그리고 실제 클라이언트를 대상으로 수행한 검증 결과를 다룹니다.

Claude Code가 보내는 것

Claude Code v2.1.278 이상(자동 모드)은 적격 세션의 Anthropic Messages 포맷 요청(/v1/messages?beta=true) 바디에 safeguards 필드를 포함해 전송합니다. 별도의 분류기 전용 엔드포인트는 없으며, 이 필드는 세션 자체의 모델 요청에 함께 실려 전달됩니다. 이 엔벨로프를 수신한 서버는 응답 및 스트리밍 필드로 safeguard_results를 작성해야 하며, 이 필드는 수정 없이 클라이언트에 그대로 도달해야 합니다.

공개된 것 (계약으로 사용 가능)

  1. safeguards는 Anthropic Messages 포맷의 요청 바디 필드입니다. Claude Code v2.1.278 이상은 적격 설정에서 세션 자체의 모델 요청 일부로 이 필드를 전송합니다.
  2. safeguard_results는 클라이언트에 수정 없이 도달해야 하는 응답/스트리밍 필드입니다.
  3. 도구 사용 ID(tool_use.id, tool_result.tool_use_id)는 재작성되어서는 안 됩니다.
  4. 액션별 폴백은 문서화된 클라이언트 동작입니다. 서버가 검사하지 못한 개별 액션이 있더라도 비적격(ineligibility) 알림이 즉시 발생하지 않으며, Claude Code가 해당 액션을 자체 처리한 뒤 다음 요청에서 서버에 다시 검사를 요청합니다. 따라서 개별 액션에 대한 결과를 생략하는 것은 클라이언트 설계상 안전하며, 비적격 알림은 남은 세션 동안 서버 검사가 아예 도달하지 않을 때만 표시됩니다.
  5. 베타 짝(pairing) 규칙: 기능은 베타 헤더와 바디 필드를 짝지어 동작합니다. 한쪽만 제거하면 400 오류가 발생하고, 양쪽 모두 없으면 기능이 조용히 비활성화됩니다. 콘텐츠 검사를 위해 요청 바디를 재작성하거나 마스킹하는 게이트웨이는 필드를 제거한 것과 마찬가지로 이 짝을 깨뜨리므로, 바디를 수정하지 않고 검사해야 합니다.
  6. 헤더 계약: anthropic-version과 anthropic-beta는 원본 그대로 전달합니다. 베타 값 집합은 릴리스마다 바뀌므로 특정 값만 허용 목록(allowlist)으로 제한하지 않으며, 반복된 헤더 값도 모두 보존합니다. 응답 헤더인 content-type(스트리밍 응답은 text/event-stream), retry-after(정수 초), x-should-retry(원본 전달), request-id(상관관계 추적), anthropic-ratelimit-unified-*는 모든 응답에서 수정 없이 전달합니다.
  7. 스트리밍: 중계 전에 전체 응답을 버퍼링하지 않습니다. keep-alive 핑과 주석 줄을 그대로 전달합니다. Claude Code는 기본적으로 300초 동안 아무 데이터가 없는 스트림을 중단합니다.
  8. 문서화 대상 클라이언트 옵트아웃(자동 설정 금지): CLAUDE_CODE_AUTO_MODE_SERVER=0(클라이언트 측 임시 게이트웨이 우회 설정으로, 과금되는 로컬 분류기 호출을 유지함) 및 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1(AUTO_MODE_SERVER가 설정되지 않았을 때 서버 검사를 비활성화함). 클라이언트 검증 절차: /status 실행 시 'Auto mode server' 행이 Enabled로 표시되면 서버 검사가 세션 액션을 판정하고 있는 상태입니다.
  9. 2026-09-21 기준 확인한 공식 문서: code.claude.com/docs/en/auto-mode-classifier-billing, code.claude.com/docs/en/llm-gateway-protocol, platform.claude.com/docs/en/api/beta-headers(세이프가드 관련 베타 헤더 미기재), code.claude.com/docs/en/claude-apps-gateway(기능 가용성 표만 제공하며 페이로드 스키마는 없음).

공개되지 않은 것 (여전히 유효)

safeguards와 safeguard_results의 전체 페이로드 스키마는 어떤 공식 문서에도 공개되어 있지 않습니다. 베타 헤더 레퍼런스에도 세이프가드라는 이름의 베타가 등재되어 있지 않습니다(실제 엔벨로프는 dangerous-tool-use-2026-09-03과 짝을 이루며, 아래 캡처 내용을 참조하세요). 다음 절의 스키마 형태는 공식 문서가 아니라 실제 패킷 캡처로 확인한 것이므로 향후 Claude Code 릴리스에서 변경될 수 있습니다.

캡처된 와이어 스키마 (2026-09-22)

실제 세션을 통해 라이브 트래픽에서 어댑터 동작을 검증했습니다:

  1. 클라이언트: claude-cli/2.1.280(최소 요건인 v2.1.278 이상 충족), claude -p 및 --permission-mode auto 옵션, 모델 claude-sonnet-4-6(Anthropic API에서 자동 모드 적격 모델).
  2. 경로: Claude Code → Router(ANTHROPIC_BASE_URL을 라우터의 /anthropic 경로로 지정) → 바이트 단위 캡처 프록시 → 공식 Anthropic API. 캡처 프록시가 Router와 공식 API 사이에 위치하므로, 아래에 기록된 모든 스키마 형태는 양방향으로 Router의 네이티브 패스스루를 통과한 실제 데이터입니다.
  3. 서버 검사의 세션 도달 확인: 응답의 검사 상태가 available 및 outcome: "not_flagged"로 반환되었으며, 세션의 stderr에는 비적격 알림이 출력되지 않았습니다. 이를 통해 네이티브 패스스루가 서버 측 검사와 완전히 호환됨을 검증했습니다.

요청 엔벨로프 (캡처됨)

safeguards는 단일 객체가 아니라 검사 항목들로 구성된 배열입니다. 위험한 도구 사용 검사(dangerous_tool_use) 항목 하나가 다음과 같이 캡처되었습니다:

"safeguards": [
  {
    "type": "dangerous_tool_use",
    "classifier_context": {
      "artifact_consent_holdback": false,
      "auto_mode": {"allow": [], "environment": [], "hard_deny": [], "soft_deny": []},
      "case_insensitive_paths": false,
      "classify_all_shell": false,
      "git_state": {"branch": null, "cwd": "...", "default_branch": null,
                     "error": "not_a_repo", "root": null, "status": null,
                     "visibility": {"origin": null, "push_remote": null,
                                     "remotes": [], "visibility_cache": []}},
      "home_dir": "...",
      "is_remote_mode": false,
      "live_cwd": "...",
      "permission_mode": "auto",
      "platform": "linux",
      "restricted": false,
      "rule_roots": {"cliArg": "...", "command": "...", "flagSettings": "...",
                      "hostCredential": "...", "localSettings": "...",
                      "mcpServerPolicy": "...", "policySettings": "...",
                      "projectSettings": "...", "session": "...",
                      "toolsNarrowing": "...", "userSettings": "..."},
      "rules": {"allow": [], "ask": [], "deny": []},
      "trusted_directories": {"additional": [],
                               "block_reads_outside_working_directories": false,
                               "network": [],
                               "primary": {"path": "...", "resolved": ["..."]}},
      "user_identity": "...",
      "v": 1
    }
  }
]

요청 측에서 추가로 확인된 사항은 다음과 같습니다:

  1. 베타 짝: 엔벨로프는 anthropic-beta: dangerous-tool-use-2026-09-03 헤더와 함께 전송되었습니다. 캡처된 전체 베타 헤더 값은 claude-code-20250219, interleaved-thinking-2025-05-14, thinking-token-count-2026-05-13, context-management-2025-06-27, prompt-caching-scope-2026-01-05, effort-2025-11-24, dangerous-tool-use-2026-09-03, afk-mode-2026-01-31이었으며, 라우터는 이를 원본 그대로 전달했습니다.
  2. 턴당 두 번의 모델 요청: 동일한 메시지를 담은 스트리밍 요청(메인 턴)과 논스트리밍 요청(분류기 검사)이 한 번씩 전송되었으며, 두 요청 모두 동일한 safeguards 배열을 포함했습니다.
  3. 최상위 필드로 전달되는 엔벨로프: 라우터의 미정의 필드 확장 맵(extension map)은 명시적 null 값을 포함해 이 필드를 원본 그대로 보존하며, tests/anthropic/anthropic_safeguards_passthrough_test.rs의 패스스루 픽스처에 캡처된 실제 구조가 반영되어 있습니다.
  4. 알려진 콘텐츠 블록 내부의 미정의 필드 없음: 캡처된 블록에는 DTO가 이미 정의하고 있는 필드만 포함되었습니다(text {type, text, cache_control?}, thinking {type, thinking, signature}, tool_use {type, id, name, input}, tool_result {type, tool_use_id, content, is_error?, cache_control?}). 따라서 알려진 블록 내부의 미정의 필드를 바이트 단위로 보존할 필요가 없으며, 기존 DTO 구현만으로 충분합니다.
  5. 함께 전달된 또 다른 최상위 미정의 필드: 동일한 요청에 {"edits": [{"keep": "all", "type": "clear_thinking_20251015"}]} 값을 담은 context_management 필드가 함께 포함되었으며, 동일한 확장 맵이 이를 보존합니다.
  6. metadata.user_id는 기기, 계정, 세션 식별자를 담은 객체를 직렬화한 JSON 문자열입니다.

응답 엔벨로프 (캡처됨)

논스트리밍 응답에는 도구 사용 ID별로 매핑된 검사 항목의 배열인 safeguard_results가 포함되었습니다:

"safeguard_results": [
  {
    "type": "dangerous_tool_use",
    "status": {
      "type": "available",
      "tool_uses": {
        "toolu_01...": {"type": "evaluated", "outcome": "not_flagged"}
      }
    }
  }
]

응답 측에서 추가로 확인된 사항은 다음과 같습니다:

  1. 검사가 서버에 정상 도달했을 때 status.type은 "available"이었으며, 허용(allow)에 해당하는 결과는 outcome: "not_flagged"로 표시되었습니다. 매핑할 도구 사용이 없는 경우에는 빈 tool_uses 맵이 반환되었습니다.
  2. 응답 최상위에는 context_management: {"applied_edits": []} 필드도 함께 포함되었습니다.
  3. 스트리밍(메인 턴) 응답에는 safeguard_results 이벤트가 없었습니다. 검사 결과는 논스트리밍 분류기 검사 응답을 통해 전달되었습니다. 따라서 스트리밍 safeguard_results 이벤트는 이번 캡처에서 관찰되지 않았을 뿐, 존재하지 않는다고 단정할 수는 없습니다.
  4. 응답에는 라우터의 응답 DTO가 모델링하지 않는 필드(container, stop_details, inference_geo, usage.output_tokens_details, usage.cache_creation)도 포함되었으나, 네이티브 논스트리밍 경로는 원시 응답 바디를 그대로 중계하고 스트리밍 경로는 각 이벤트의 데이터 문자열을 원본 그대로 전달하므로 모두 수정 없이 전달되었습니다.

캡처가 고정하지 않은 것

이번 캡처에서는 outcome: "not_flagged"와 status.type: "available"만 관찰되었습니다. outcome 어휘에서 거부(deny)에 해당하는 값, status 어휘에서 사용 불가(unavailable)/불확정(indeterminate)에 해당하는 값, 그리고 스트리밍 safeguard_results 이벤트는 관찰되지 않았습니다. 다음 절에서 설명하는 어댑터 응답 생성 규칙은 캡처된 어휘를 따르되, 관찰되지 않은 각 항목에 대해 명시적인 설계 결정을 정의합니다.

라우터가 구현한 것

스키마 지식이 필요 없는 네이티브 경로

네이티브 경로의 계약은 '수정 없이 그대로 전달'하는 것이며, 이는 명확히 정의되고 테스트 가능한 핵심 동작입니다. 네이티브 Anthropic 백엔드로 향하는 safeguards 포함 요청은 엔벨로프가 원본 그대로 전달되고, 백엔드가 safeguard_results에 작성한 응답도 클라이언트에 수정 없이 중계됩니다. 별도의 세이프가드 설정은 필요하지 않습니다.

구체적으로 라우터는 타입화된 요청 객체(출력 설정 및 시스템 텍스트 블록 포함)의 일반 확장 맵에 알 수 없는 최상위 필드를 보존하며, 콘텐츠 블록은 인식되지 않는 블록 유형을 원본 그대로 보존하는 unknown-block 캐치올(catch-all) 변형을 가집니다. 캡처 결과 실제 클라이언트는 현재 이 캐치올을 사용하지 않으며, 알려진 블록 유형 내부로 전송된 모든 필드는 이미 DTO에 모델링되어 있음이 확인되었습니다.

보수적인 fail-closed 어댑터 동작

비네이티브(변환) 백엔드의 기본 동작은 기존과 동일합니다. 요청을 정상 처리하되 아무 결과도 임의로 합성하지 않으며 오류도 발생시키지 않습니다. 운영자가 safeguards.action_safety를 명시적으로 설정한 경우에만 어댑터가 활성화됩니다. 어댑터가 켜지면 라우터는 변환 백엔드 응답이 제안한 도구 액션을 지정된 평가기(evaluator) 백엔드로 평가하고 그 판정 결과를 응답에 첨부합니다.

두 변환 경로가 모두 지원됩니다. Anthropic Messages 변환 경로와 /v1/responses 변환 경로(responses_only 모델로 /v1/chat/completions에 요청을 보내고 제안된 액션이 function.arguments JSON 문자열로 도착하는 경로) 모두에서 동작합니다. /v1/responses 스트리밍 경로에서는 결과 이벤트가 [DONE] 센티널 직전에 전송되며, 이는 Messages 경로가 message_stop 직전에 결과를 보장하는 것과 동일한 순서 보장입니다.

실패 처리와 사유(reason) 격리는 다음 세 가지 원칙을 따릅니다:

  • Fail-closed 판정: 모든 실패 모드는 비허용(non-allow) 결과로 귀결됩니다. 평가기 사용 불가, 타임아웃, 잘못되었거나 알 수 없는 판정 출력, 모델의 응답 거부, 예산 소진은 모두 indeterminate로 처리되며, 결코 임의의 allow로 합성되지 않습니다.
  • 권한 강제 범위: 라우터는 제안된 도구를 직접 실행하지 않으며 클라이언트의 최종 권한 강제 로직을 대체하지도 않습니다.
  • 서버 측 사유 격리: 평가기의 reason은 클라이언트로 전달되지 않습니다(아래 응답 형태 참조). 평가기 전송 실패 시에는 서버 측 판정 사유조차 고정 문자열인 evaluator transport failure로 기록되며, 평가기 백엔드 URL이 포함될 수 있는 원본 오류 메시지는 경고 로그에만 남습니다.

기본 advisory 모드로 동작하는 System One 평가기

evaluator: systemone 설정은 System One 의사 결정 배터리(approve / confirm / block 중 하나인 action, 그리고 irreversible 및 injected_instruction)를 동일한 fail-closed 판정 체계에 매핑합니다:

  • 판정 매핑:
    • allow: approve 선택이 allow_threshold와 min_confidence를 모두 넘고, injected_instruction이 deny_threshold 미만일 때만 반환됩니다.
    • deny: block 확률 또는 injected_instruction이 deny_threshold 이상일 때 반환됩니다.
    • indeterminate: confirm 선택이거나 평가기 실패가 발생한 모든 경우에 반환됩니다.
  • 동작 모드: 기본값인 advisory 모드는 채팅 모델 평가기의 판정(또는 indeterminate)을 응답으로 내보내고, 두 평가기 간 불일치를 safeguards_action_safety_advisory_total 메트릭에 기록합니다. 반면 enforce 모드는 매핑된 System One 판정을 직접 내보냅니다. 채팅 모델 backend를 지정하지 않은 상태로 advisory 모드를 실행하면 모든 액션이 indeterminate로 판정되므로, 응답 페이로드에서는 검사 상태가 항상 unavailable로 표시됩니다.
  • 의사 결정 상태 입력: 의사 결정 상태에는 도구 이름과 인자에 더해 사용자의 최근 요청과 최대 3개의 최근 도구 실행 결과가 포함되며, 시스템 프롬프트는 절대 포함되지 않습니다. 도구 실행 결과를 주고받는 사용자 턴은 사용자 요청으로 취급하지 않으며, Claude Code의 <system-reminder> 블록은 건너뜁니다. injected_instruction은 경계가 아닌 탐지기로 동작하며 아래의 응답 형태가 동일하게 적용됩니다.

캡처된 스키마를 따르는 어댑터 응답 형태

변환 백엔드에 대해 어댑터는 캡처된 스키마 형태로 safeguard_results를 생성합니다. 최상위 배열에 dangerous_tool_use 검사 항목 하나를 담고, 그 안의 status.tool_uses 맵은 제안된 각 도구 사용의 전송 ID(Messages 경로에서는 Anthropic tool_use.id, /v1/responses 브리지에서는 tool_calls[].id)를 키로 사용합니다. 평가기의 allow 판정은 캡처에서 네이티브 백엔드가 작성한 항목과 구조적으로 동일합니다:

"safeguard_results": [
  {
    "type": "dangerous_tool_use",
    "status": {
      "type": "available",
      "tool_uses": {
        "toolu_01...": {"type": "evaluated", "outcome": "not_flagged"},
        "toolu_02...": {"type": "evaluated", "outcome": "flagged"}
      }
    }
  }
]

캡처를 통해 확인된 것은 허용(allow) 매핑뿐입니다. 나머지 매핑은 향후 캡처에서 확인하거나 조정할 수 있도록 명시적으로 정의한 설계 결정입니다:

평가기 결과 방출 형태 상태
allow status.tool_uses[<id>] 아래 {"type": "evaluated", "outcome": "not_flagged"} 관찰됨
deny status.tool_uses[<id>] 아래 {"type": "evaluated", "outcome": "flagged"} 미관찰, 선택됨
indeterminate (타임아웃, 도달 불가 평가기, 잘못된 판정, 예산 소진, 조립되지 않았거나 한도를 넘은 인자) status: {"type": "unavailable"}, tool_uses 맵 없음 미관찰, 선택됨
스트리밍, Anthropic Messages 턴의 마지막 message_delta의 delta.safeguard_results 클라이언트가 읽음 (claude-cli 2.1.305, 이슈 #2682)
스트리밍, /v1/responses 브리지 [DONE] 앞의 SSE 이벤트 safeguard_results, 데이터 {"type": "safeguard_results", "safeguard_results": [...]} 미관찰, 선택됨
도구 사용이 없는 응답 status: {"type": "available", "tool_uses": {}} 클라이언트가 요구함 (이슈 #2682)
reason 응답에 포함하지 않음, debug 수준 로그에 (길이 제한하여) 기록 결정

혼합 응답은 검사 전체가 unavailable로 처리됩니다. 응답이 여러 도구 사용을 제안하고 그중 하나라도 indeterminate로 판정되면, 나머지 도구가 정상 평가되었더라도 전체 항목은 status: {"type": "unavailable"}로 반환됩니다. 일부만 채워진 tool_uses 맵과 함께 available을 반환하면 누락된 ID가 미평가인지 통과인지 클라이언트가 추측해야 하므로, 어댑터는 판정되지 않은 도구 사용이 통과로 해석될 여지를 원천 차단합니다. unavailable은 단일한 의미로만 해석되므로 다음 세 가지 의도된 동작이 뒤따릅니다:

  • max_actions_per_request보다 많은 도구 사용을 담은 응답은 항상 unavailable로 반환됩니다.
  • indeterminate와 함께 발생한 deny는 응답 페이로드에 노출되지 않습니다(서버 로그에는 기록됩니다).
  • ID가 없거나 동일한 응답 내 다른 도구 사용과 ID가 중복되는 도구 사용은 매핑할 수 없으므로 동일하게 unavailable로 처리됩니다.

Anthropic Messages 스트림에서는 판정 결과를 마지막 message_delta에 담습니다. 어댑터는 처음에 판정 결과를 message_stop 앞의 별도 safeguard_results SSE 이벤트로 보냈는데, 이는 관찰에 근거하지 않은 선택이었습니다. claude-cli 2.1.305는 이 이벤트를 건너뜁니다. 스트림 처리기가 message_delta.delta.safeguard_results에서만 스트리밍 판정 결과를 읽고, message_stop까지 결과를 받지 못한 요청은 "결과 없음"으로 처리하기 때문입니다(이슈 #2682). 그래서 어댑터는 평가가 끝날 때까지 마지막 message_delta와 message_stop을 보류했다가, 논스트리밍 경로가 첨부하는 것과 같은 배열을 그 message_delta의 delta에 넣어 내보냅니다. message_delta 없이 끝난 스트림에는 판정 결과를 싣지 않습니다. 새로 만들어 넣으면 클라이언트의 사용량 집계까지 덮어쓰기 때문입니다. Chat Completions 브리지에는 message_delta가 없으므로 [DONE] 앞의 별도 이벤트를 그대로 유지합니다.

도구 사용이 없는 응답에도 검사 결과를 싣습니다. claude-cli는 safeguard_results가 없는 응답을 서버가 검사를 거절한 것으로 보고, 세션 전체를 과금되는 자체 분류기로 전환하면서 "isn't eligible" 안내를 띄웁니다. 그래서 어댑터가 활성화된 경우 도구 사용을 제안하지 않은 응답에는 status: {"type": "available", "tool_uses": {}}를 싣습니다. 검사는 실행됐고 판정할 대상이 없었다는 뜻입니다. 이 경우 평가기에는 아무 요청도 보내지 않습니다.

판정 사유는 서버 측에만 남습니다. 캡처된 어휘에는 자유 텍스트 사유를 담을 필드가 없으므로, 평가기의 사유(신뢰할 수 없는 모델 출력이며 파싱 시 4 KiB로 제한됨)는 클라이언트로 전송되지 않습니다. 대신 판정 결과와 함께 debug 수준 로그에 길이 제한을 적용해 기록되며, System One advisory 모드에서는 판정 결과 자체가 safeguards_action_safety_advisory_total 메트릭에 정상 반영됩니다. 기본 info 로그 수준에서는 사유가 기록되지 않으므로, 사유 확인이 필요한 운영자는 라우터 로그 수준을 높여야 합니다. 사유는 신뢰할 수 없는 모델 출력이고 평가 대상 도구 인자를 그대로 인용할 수 있으므로 의도적으로 info 미만 수준에 둡니다.

어댑터는 설정으로 제어되고 fail-closed 원칙을 따르며 변환 백엔드에만 작동하는 보수적 설계를 유지합니다. 캡처된 스키마 형태를 따르므로 네이티브 백엔드 응답과 구조적으로 호환되며, 위 표의 미관찰 매핑은 추후 캡처를 통해 검증해야 할 대상입니다.

네이티브 패스스루는 별도의 조정이 필요 없습니다. 양방향 모두 캡처된 스키마를 수정 없이 그대로 중계하며, 캡처된 구조를 반영한 픽스처 테스트가 이를 보장합니다.

검증 완료 (2026-09-22 실행)

트레이스 캡처 검증은 다음 절차로 진행되었습니다:

  1. 네이티브 Anthropic 백엔드(공식 API)를 구성하고 Router와 API 사이에서 세션의 요청 및 응답 트레이스를 캡처했습니다.
  2. 캡처된 safeguards 요청 엔벨로프는 확장 맵이 보존한 필드와 정확히 일치했으며, 알려진 블록 구조 내부에 중첩된 별도의 핵심 필드는 없었습니다.
  3. 캡처된 safeguard_results 페이로드를 어댑터의 응답 생성 형태와 대조했으며, 패스스루 테스트 픽스처에 캡처된 스키마를 반영했습니다.
  4. 캡처 데이터에서 베타 헤더 짝을 재검토하여 엔벨로프가 dangerous-tool-use-2026-09-03과 짝을 이루고 라우터가 클라이언트의 전체 anthropic-beta 헤더를 원본 그대로 전달함을 확인했습니다.

이로써 네이티브 경로는 '실제 클라이언트를 대상으로 검증 완료' 상태이며, 어댑터의 합성 응답은 '캡처된 스키마 형태와 정렬 완료(거부/사용 불가/스트리밍 매핑은 미관찰)' 상태입니다.