보안 에이전트: 하네스(Harness) 내의 제어 정책
요약
에이전트가 정책 서버로부터 받은 HTTP 403 오류를 네트워크 오류로 오인하여 적절한 조치를 취하지 못하는 구조적 문제를 다룹니다. 이를 해결하기 위해 정책 결정을 에이전트의 추론 루프 내 '도구 가드레일(tool guardrail)' 단계로 이동시켜 모델이 명확한 이유를 인지하도록 해야 합니다.
핵심 포인트
- 정책 서버가 HTTP 상태 코드로만 거절을 전달할 경우, 에이전트는 정책 거부와 네트워크 오류를 구분하지 못함
- 현재의 에이전트 아키텍처는 정책 엔진이 네트워크 홉 뒤에 위치하여 에이전트의 추측을 강요하는 구조적 결함이 있음
- 해결책은 정책 결정을 에이전트의 추론 루프 내 PreToolUse 훅 또는 도구 가드레일 단계로 통합하는 것임
- Claude Code, Pi, OpenAI Agents SDK 등은 이미 이러한 도구 실행 전 제어 가능한 훅(hook) 메커니즘을 제공함
보안 에이전트: 하네스(Harness) 내의 제어 정책
Alice는 회사의 내부 HR 채팅창을 열고 다음과 같이 입력합니다: "Q4 작업을 위한 벤더 X와의 계약을 취소해 주세요."
이 HR 채팅은 플랫폼 팀이 내부 워크플로(workflow)를 위해 구성한 코딩 에이전트(coding agent)를 기반으로 구축되었습니다. 이 에이전트는 계약을 조회하고, 조달 시스템(procurement system)에 취소를 요청하며, 수행한 작업을 다시 확인하는 방법을 알고 있습니다. 그녀는 몇 달 동안 일상적인 HR 업무를 위해 이 에이전트를 사용해 왔습니다.
이번에 에이전트는 약 10초 동안 작동하더니 다음과 같이 답장을 보냅니다: "죄송합니다, 현재 조달 시스템이 응답하지 않습니다. 세 번 시도해 보았습니다."
Alice는 한 시간을 기다린 후 다시 요청했지만 동일한 답변을 받았고, IT 티켓을 접수했습니다. 그녀의 오전은 장애 보고서로 채워졌습니다.
하지만 조달 시스템은 내내 정상적으로 응답하고 있었습니다. 에이전트의 세 번의 시도 각각에 대해 시스템은 다음과 같은 정확한 메시지를 반환했습니다: "이 취소는 금액이 만 달러를 초과하므로 관리자의 승인이 필요합니다. 승인을 받은 후 다시 시도해 주세요."
이 메시지는 에이전트에 전혀 전달되지 않았습니다. 에이전트와 조달 시스템 사이에는 정책 서버(policy server)가 제 역할을 수행하며 자리 잡고 있었습니다. 정책 서버는 취소 금액과 누락된 승인 토큰(approval token)을 확인했을 때, 백엔드에 도달할 수 없을 때 라우터가 반환할 수 있는 것과 동일한 숫자 상태 코드인 HTTP 403 Forbidden으로 호출을 거부했습니다.
에이전트 입장에서는 둘 다 403으로 전달될 때, 정책 거부(policy refusal)와 네트워크 오류(network glitch)를 구분할 방법이 없습니다. 에이전트는 불안정한 도구(flaky tool)를 접했을 때 다른 에이전트들이 하는 것처럼 재시도하고, 포기하고, 사과했습니다.
저는 프로덕션 에이전트 스택(agent stacks)에서 이러한 실패 형태를 계속 발견하고 있으며, 이를 조사할수록 이를 만들어내는 아키텍처가 전술적인 버그라기보다는 구조적으로 잘못되었다고 생각하게 됩니다. 공개된 참조 설계(reference designs)들은 모두 이 문제를 공유하고 있습니다. InfoQ의 에이전트 게이트웨이(agent gateway) 기사, Red Hat의 Envoy-and-OPA MCP 게이트웨이, 그리고 Cerbos의 MCP 기술 문서 모두 동일한 패턴을 설명합니다: 네트워크 홉(network hop) 뒤에 위치하여 에이전트가 추측해야만 하는 HTTP 상태 코드를 반환하는 정책 엔진(policy engine)의 패턴 말입니다. 정책 서버는 결정을 내리기에는 적절한 위치에 있지만, 그 결정을 전달하기에는 잘못된 위치에 있습니다.
해결책은 다른 정책 엔진(policy engine)이나 더 나은 에러 코드, 혹은 더 똑똑한 재시도 전략(retry strategy)이 아닙니다. 해결책은 결정을 에이전트의 추론 루프(reasoning loop)와 동일한 프로세스로 이동시키는 것입니다. 이 루프 내의 프레임워크는 언어 모델(language model)이 도구를 선택하는 시점과 해당 도구가 실제로 실행되는 시점 사이에 이미 사용자가 제공한 코드 조각을 실행하고 있습니다. Claude Code는 이 코드를 PreToolUse 훅(hook)이라 부르고, Pi는 이를 tool_call 핸들러(handler)라고 부르며, OpenAI의 Agents SDK는 이를 도구 가드레일(tool guardrail)이라고 부릅니다. 이 세 가지 명칭은 모두 동일한 것을 가리킵니다. 즉, 프레임워크가 에이전트의 계획된 동작을 전달하는 함수이며, 여기서 동작을 통과시키거나, 수정하거나, 혹은 프레임워크가 모델에게 마치 도구 자체의 응답인 것처럼 이유를 반환하며 차단할 수 있습니다. 오늘날 대부분의 팀은 이러한 훅(hooks)을 로깅(logging) 및 안전 점검(safety checks) 용도로 사용합니다. 이 글의 논지는 이러한 훅(hooks)이 인가(authorisation) 자체를 위한 적절한 장소이기도 하다는 것이며, 이는 현재 학술 문헌에서도 나타나고 있는 입장입니다. UIUC, Meta, Stanford의 에이전트 하네스 엔지니어링(agent harness engineering)에 관한 2026년 조사에 따르면, 라이프사이클 훅(lifecycle hooks)은 "도구 사용을 모델이 선택한 가공되지 않은 동작에서 에이전트 실행 루프 내의 모니터링된 전이(monitored transition)로 바꾸는" 제어 지점(control points)으로 명시되어 있습니다. 이 아키텍처가 실제로 구축되었을 때 유효한지 확인하기 위해, 저는 동일한 6개 조항의 Cedar 정책을 두 가지 프레임워크에 작성했습니다. 하나는 @cedar-policy/cedar-wasm WASM 바인딩을 통한 Pi이고, 다른 하나는 커뮤니티 cedarpy Python 바인딩을 통한 Claude Agent SDK입니다. 정책 파일은 단일 진실 공급원(single source of truth)이며, 6개의 고정 장치 케이스(fixture cases)는 테스트 그리드(test grid) 역할을 합니다. 결과는 양쪽 모두에서 동일하게 나왔습니다. 전체 코드는 아래에 링크되어 있습니다. 이 시리즈의 이전 글인 'The Agentic Last Mile'은 에이전트 스택에서 자격 증명 경계(credential boundary)를 닫는 것에 관한 것이었습니다. 이번 글은 그보다 한 단계 위에서 결정 경계(decision boundary)를 닫는 것에 관한 것입니다. 여전히 해결되지 않은 문제: 의도 검증(intent verification)은 여전히 미해결 과제로 남아 있으며, 이전 글에서 다루었던 모델 측 API 키 함정(API key trap) 문제도 해결되지 않은 상태로 남아 있습니다. 요약하자면(tl;dr): 정책 결정(policy decision)을 에이전트 프레임워크의 프리-툴 훅(pre-tool hook) 내부에 두십시오.
네트워크 사이드카(sidecar)에서 발생하는 403 오류는 모델에게 시스템 글리치(glitch)처럼 보이지만, 훅(hook)은 모델이 읽고 적응할 수 있는 실제 이유를 반환합니다. Cedar는 WASM 바인딩과 Rust 크레이트(crate)를 통해 프로세스 내부에 내장(embed)되고, 허용(permit)보다 금지(forbid)를 우선하여 기본적으로 거부하며, 정책이 배포되기 전에 과도하게 허용적인 정책을 잡아내는 SMT 분석기를 제공하기 때문에 적합합니다. 동일한 6개 조항의 Cedar 정책 파일이 Pi의 tool_call 핸들러와 Claude Agent SDK의 PreToolUse 훅을 구동합니다. 전체 코드는 리포지토리(repository)에 있으며, 양쪽 모두 바이트 단위로 동일한 정책 파일을 사용하고, 6개의 피스처(fixture) 케이스에 대해 6개의 동일한 판결을 내립니다. 이 훅은 The Agentic Last Mile의 자격 증명 브로커(credential broker) 및 모델 측의 추론 경로 프록시(inference-path proxy)와 쌍을 이룹니다. 세 개의 독립적인 목소리가 동일한 루프 형태를 전달합니다: AgentCore의 AWS, OpenClaw의 Phil Windley, 그리고 Claude Code의 훅 참조(hooks reference)에 있는 Anthropic입니다. 이 훅은 OWASP LLM06 과도한 권한(Excessive Agency)을 방어합니다. 다만, 의도 검증(intent verification) 문제를 해결하지는 않으며, 이전 글에서 다룬 모델 측 SDK 트랩(trap) 문제는 여전히 남아 있습니다. 전체 기사 보기 >> 시나리오 및 Cedar 정책. Alice의 요청으로 돌아가 보겠습니다. 그녀는 HR 에이전트에게 업체 X와의 계약을 취소해 달라고 요청했습니다. 에이전트는 계약 ID를 포함한 cancel_contract 툴 호출(tool call)을 생성합니다. 호출이 진행되려면 다음 6개 조항이 충족되어야 합니다. 1. 에이전트는 리소스 및 인바운드 사용자 컨텍스트(inbound user context)와 동일한 테넌트(tenant)를 공유해야 합니다. 2. 에이전트는 인증된 사용자를 대신하여 행동해야 합니다. 3. 사용자는 HR 또는 조달(procurement) 역할을 보유해야 합니다. 4. 계약은 활성(active) 상태여야 합니다. 5. 비상 상황이 선포되지 않는 한, 취소는 영업 시간 내에 이루어져야 합니다. 6. 만 달러를 초과하는 모든 취소는 대역 외(out-of-band) CIBA 스타일의 승인 토큰을 지참해야 합니다.
이 여섯 개의 조항은 하나의 허용(permit)과 두 개의 금지(forbid)로 단일 파일에 번역됩니다: @id("permit_hr_cancel_contract") permit ( principal, action == Action::"CancelContract", resource is Contract ) when { principal is Agent && principal.tenant == resource.tenant && principal.tenant == context.user_tenant && principal.delegated_for == context.user_sub && (context.user_roles.contains("hr") || context.user_roles.contains("procurement")) && resource.status == "active" && (context.business_hours || context.emergency_declared) && (resource.value_usd < 10000 || context.high_stakes_approved) }; @id("forbid_cross_tenant") forbid (principal, action == Action::"CancelContract", resource is Contract) when { principal is Agent && principal.tenant != resource.tenant }; @id("forbid_after_hours_without_emergency") forbid (principal, action == Action::"CancelContract", resource is Contract) when { !context.business_hours && !context.emergency_declared }; 세 가지 규칙 모두 @id 주석으로 이름이 지정되어 있어 거부 이유가 자동 생성된 정책(policy0) 대신 사람이 읽을 수 있는 식별자로 반환됩니다. 테넌트 확인은 의도적으로 두 번 나타납니다. 한 번은 principal과 resource 사이에서 발생하여, 테넌트 A에 등록된 에이전트가 테넌트 B의 계약을 취소하려는 경우를 포착합니다. 다른 한 번은 principal과 인바운드 사용자 컨텍스트(inbound user context) 사이에서 발생하며, 이 경우 브로커가 테넌트 B로 등록된 에이전트를 위해 테넌트 A라고 주장하는 토큰을 발행했을 때의 오구성(misconfiguration)을 포착합니다. 두 번째 경우는 Cedar 분석기(analyser)가 런타임 전에 발견하는 것이며, 분석기는 몇몇 섹션에서 다시 나타납니다. Cedar가 명명하는 액션은 API 액션이 아니라 비즈니스 액션입니다. 이는 Cedar 자체의 모범 사례 가이드라인에 따른 것입니다. 에이전트의 MCP 도구 목록(tool list)은 동사 cancel_contract를 호출합니다. Cedar 액션은 Action::"CancelContract"입니다. 이들은 함께 발전하기 때문에 일치합니다. 도구 목록은 두 가지 다른 구문으로 작성된 액션 목록입니다.
Pi 내부의 정책은 TypeScript 파일 하나로 구성되며, 약 30줄의 유의미한 코드로 이루어져 있습니다. 이 코드는 Cedar 파일을 tool_call 핸들러(handler)에 연결합니다. 구조는 Pi의 표준 안전 후크(safety-hook) 샘플을 반영하며, 정규식 매칭(regex match)이 Cedar 평가(evaluation)로 대체되었습니다: import { isAuthorized , policySetTextToParts , policyToJson } from " @cedar-policy/cedar-wasm/nodejs " ; const POLICY_TEXT = readFileSync ( POLICY_PATH , " utf8 " ); const SCHEMA_TEXT = readFileSync ( SCHEMA_PATH , " utf8 " ); const NAMED_POLICIES = parseNamedPolicies ( POLICY_TEXT ); export function createCedarHook ( options : CedarHookOptions ) { return function cedarHook ( pi : ExtensionAPI ): void { pi . on ( " tool_call " , async ( event ) => { if ( event . toolName !== " cancel_contract " ) return ; const contract = await loadContract ( event . input . contract_id ); const context = await pi . session . policyContext (); const call = buildAuthorizationCall ( /* ... */ ); const answer = isAuthorized ( call ); if ( answer . response . decision === " deny " ) { const matched = answer . response . diagnostics . reason ; return { block : true , reason : matched . length > 0 ? cedar-hook: deny (matched ${ matched . join ( " , " )} ) : " cedar-hook: deny (no permit matched) " , }; } }); }; } Cedar 엔진은 호출마다 실행되는 것이 아니라 확장 프로그램(extension) 로드 시점에 한 번 초기화됩니다. WASM 번들은 밀리초(milliseconds) 단위로 로드되며, 호출당 평가(evaluation)는 마이크로초(microseconds) 단위로 이루어집니다. pi.session.policyContext() 헬퍼 함수는 OIDC 주체(subject), 사용자의 테넌트(tenant) 및 역할(roles), 디바이스 포스처(device posture), 업무 시간 플래그(business-hours flag), 그리고 고위험 승인 상태(high-stakes-approval status)를 Cedar 컨텍스트(context) 형태로 패키징합니다. 사이드카(sidecar) 방식이었다면 이 모든 정보를 네트워크를 통해 전달받아야 했겠지만, 하네스(harness)는 에이전트 프로세스 내에서 실행되기 때문에 이를 로컬에서 보유합니다. Cedar의 진단(diagnostics) 정보에는 매칭된 정책 ID가 포함되며, 이는 LLM이 읽게 될 사유(reason) 문자열이 됩니다.
이 확장 프로그램(extension)에 대해 6가지 케이스의 픽스처(fixture)를 실행하면, 저장소(repository)를 클론한 후 npm test를 통해 독자가 재현할 수 있는 다음과 같은 판결(verdicts)이 생성됩니다: {"case":"permit","decision":"allow","reason":null} {"case":"deny-by-tenant","decision":"deny","reason":"cedar-hook: deny (matched forbid_cross_tenant)"} {"case":"deny-by-role","decision":"deny","reason":"cedar-hook: deny (no permit matched)"} {"case":"deny-by-high-stakes","decision":"deny","reason":"cedar-hook: deny (no permit matched)"} {"case":"deny-by-state","decision":"deny","reason":"cedar-hook: deny (no permit matched)"} {"case":"deny-by-after-hours","decision":"deny","reason":"cedar-hook: deny (matched forbid_after_hours_without_emergency)"} 세 가지 거부(deny) 케이스는 이름에 따라 금지(forbid) 규칙과 매칭됩니다. 나머지 세 가지는 "no permit matched"를 보여주는데, 이는 기본 거부(deny-by-default) 의미론(semantics)이 실제로 작동하고 있음을 나타냅니다. 즉, 명시적인 금지(forbid) 규칙이 트리거되지는 않았지만, 허용(permit) 규칙 또한 실행되지 않았기 때문에 호출이 거부된 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기