에이전트의 감사 로그(Audit Log)는 증거가 아니라 이야기입니다
요약
에이전트의 도구 호출 시 로그가 실행 후에 기록되는 기존 방식의 위험성을 지적하며, 실행 전 결정 사항을 먼저 기록하는 obstat 라이브러리를 소개합니다. 프로세스 종료 시에도 승인 여부를 명확히 알 수 있도록 fsync를 활용한 내구성 있는 감사 로그 기록 방식을 제안합니다.
핵심 포인트
- 기존의 사후 로깅 방식은 실행 여부와 기록 누락을 구분할 수 없음
- obstat은 도구 실행 전 결정 사항을 디스크에 먼저 기록(fsync)함
- 프로세스 중단 시 '승인됨, 결과 알 수 없음' 상태를 보장하여 정직한 기록 유지
- 권한 부여는 티어 단위가 아닌 리소스 단위로 관리되어야 함
제가 살펴본 거의 모든 도구 거버넌스(tool-governance) 계층은 호출(call)이 반환된 후에 로그를 작성합니다. 어떤 것들은 finally 블록에서 작성하고, 어떤 것들은 배치(batch) 처리하며, 어떤 것들은 자체 일정에 따라 플러시(flush)하는 로깅 프레임워크에 전달합니다.
이러한 순서 결정은 당신의 로그가 무엇에 사용될 수 있는지를 조용히 결정합니다.
만약 기록이 본문(body)이 실행된 후에 작성된다면, 누락된 기록에 대해서는 두 가지 가능한 설명이 존재하며, 파일 내의 그 어떤 것도 이 둘을 구분할 수 없습니다:
- 호출이 승인되지 않아 실행되지 않았다.
- 호출이 승인되어 실행되었고 작업을 수행했으나, 로그 라인이 디스크에 도달하기 전에 프로세스가 종료되었다.
이 둘은 결코 같지 않습니다. 하나는 제어(control)가 제대로 작동한 것이고, 다른 하나는 기록되지 않은 삭제(unlogged deletion)입니다. 6주 후에 누군가가 당신에게 03:14에 당신의 에이전트가 무엇을 할 수 있도록 허용되었는지 묻는다면, "그에 대한 라인이 없습니다"라는 대답은 아무런 도움이 되지 않습니다.
그래서 저는 이 순서를 뒤집는 작은 라이브러리를 작성했습니다.
obstat
obstat은 에이전트 도구 호출(tool calls)을 위한 감사 가능한 결정 기록(auditable decision record)입니다. Nihil obstat — 아무것도 방해하지 않는다 — 는 검열관이 출판 전, 서면으로 부여했던 공식적인 승인을 의미했습니다. 그것이 이 프로젝트의 핵심 아이디어입니다.
from obstat import guard
@guard(resource="doc:{doc_id}")
...
에이전트가 무언가를 수행하겠다고 요청하면, 규칙이 결정하고, 그 결정은 도구 본문이 실행되기 전에 디스크에 기록되고 fsync됩니다. 호출 도중 프로세스가 종료되더라도, 기록에는 무엇이, 누구를 위해, 어떤 리소스(resource)에 대해, 왜 승인되었는지가 여전히 남아 있습니다.
record.decision()은 fsync가 반환된 후에만 반환됩니다. 사후 플러시(flushed after), 지연(deferred), 또는 배치(batched) 방식이 아닙니다. 라이브러리의 다른 모든 기능은 편의를 위한 것이며, 이 부분이 바로 검사관이 신뢰하는 부분입니다.
주장은 문단이 아니라 테스트로 증명되어야 합니다
아무도 허위로 증명할 수 없는 아키텍처적 약속은 마케팅입니다. 이 약속은 도구 본문 내부에서 로그를 읽음으로써 확인됩니다. 도구 본문은 버퍼링(buffered), 지연(deferred), 또는 사후에 작성되는 모든 것이 보이지 않는 유일한 장소입니다:
def test_record_is_durable_before_the_body_runs(workspace):
workspace(ALLOW_ALL)
seen: dict[str, list] = {}
...
쓰기(write) 동작을 한 줄 뒤로 미루면 테스트는 실패합니다. 그것이 바로 속성(property)이며, 더 이상 참이 아니게 될 때 깨지는 형태로 기술되었습니다.
결과 기록(outcome record) — 성공했는지, 혹은 예외(raise)를 발생시켰는지 — 은 사후에 작성되며 의도적으로 내구성(durable)을 갖지 않습니다. 만약 두 단계 사이에서 프로세스가 종료된다면, 로그에는 "승인됨, 결과 알 수 없음(authorised, outcome unknown)"이라고 기록됩니다. 이것이 정직한 상태입니다. 단순히 정보를 제공하기 위해 두 번째 fsync 비용을 지불하는 것은 잘못된 거래입니다.
"기록이 곧 제품이다"에서 도출되는 것들
권한 부여(Authorisation)는 티어(tier) 단위가 아니라 리소스(resource) 단위입니다. READ / WRITE / DESTRUCTIVE 방식으로는 "자신의 티켓은 수정할 수 있지만, 타인의 티켓은 수정할 수 없다"는 내용을 표현할 수 없습니다. obstat은 호출 인자(call arguments)로부터 리소스 ID를 해결(resolve)하고 해당 리소스에 대해 규칙을 매칭합니다:
[[rule]]
subject = "human:ana"
resource = "jira_issue:ACME-*"
...
승인(approval)은 단 한 번의 호출에 결합됩니다. 승인은 도구(tool), 주체(subject), 리소스(resource), 그리고 인자들의 다이제스트(digest)를 포함하며, 단 한 번만 사용할 수 있습니다. "q3-report 삭제"를 승인했다고 해서 다른 것을 삭제하는 데 사용할 수 없으며, 두 번 사용할 수도 없습니다. 이는 하나의 BEGIN IMMEDIATE 트랜잭션 내에서 강제되므로, 두 개의 동시 재시도(concurrent retries)가 모두 성공할 수 없습니다. 승인을 사용하는 기록에는 누가 승인했는지가 명시됩니다. 왜냐하면 "누가 승인했는가"는 변경 가능한(mutable) SQLite 행에만 존재해서는 안 되기 때문입니다.
인자(Arguments)는 저장되는 것이 아니라 지문(fingerprinted)화됩니다. 도구 인자에는 자격 증명(credentials)과 개인 데이터가 포함됩니다. 이를 유출하는 거버넌스 로그(governance log)는 통제 수단이라기보다 오히려 책임(liability)이 됩니다. 인간이 확인해야 할 항목들만 지정하면 오직 그 값들만 기록됩니다. 승인자가 sha256:ae32e6…에 대해 결정하는 것은 아무것도 결정하는 것이 아니기 때문입니다. 다이제스트(digest)는 여전히 모든 내용을 포괄합니다.
모든 기록은 이전 기록의 해시(hash)를 포함합니다. 따라서 수정되거나 삭제된 라인은 obstat verify에서 나타납니다.
세 개의 실제 메일박스가 발견한 것
이 글을 쓰기 전에 저는 제 자신의 메일함 앞에 obstat을 배치했습니다. 세 개의 IMAP/SMTP MCP 서버 — 개인 메일함, Gmail, 그리고 낯선 이들로부터 메일을 받는 공개 비즈니스 주소 — 를 대상으로, 모든 외부 발신 메시지에 승인 절차를 적용했습니다. 그 결과, 검토 과정에서 발견되지 않았던 것들을 찾아냈습니다.
첫째 날, 한 에이전트가 게이트(gate)를 돌아 나갔습니다. 메일함에 읽지 않은 메시지가 몇 개 있는지 물었을 때, 에이전트는 답변을 제공하는 보호된 도구(guarded tool)를 찾지 못했습니다. 대신 서버 프로세스가 보유하고 있던 자격 증명(credential)을 사용하여 원시 IMAP 연결을 열었고, 정확하게 답변했습니다 — 두 개의 메일함에 걸쳐 읽지 않은 메시지가 2,360개 있다고 말이죠. 이 과정에서 어떤 실패도 발생하지 않았습니다. 게이트가 에이전트가 취한 경로 상에 아예 없었을 뿐입니다.
이 발견은 이제 §8의 첫 번째 항목이 됩니다. 왜냐하면 독자가 가장 오해하기 쉬운 부분이기 때문입니다:
- 기록은 리소스(resource)가 아니라 게이트(gate)를 다룹니다. 부재(Absence)는 오직
@guard를 통해 들어온 호출에 대해서만 증거가 됩니다. 그 외의 모든 것은 불완전한 것이 아니라, 아무런 기록이 없는 상태(quiet)로 읽힙니다. - 호출자가 읽을 수 있는 자격 증명은 호출자가 지나쳐 갈 수 있는 게이트입니다. 분리는 호스트(host)로부터 이루어져야 합니다 — 즉, 다른 계정, 샌드박스(sandbox), 또는 셸(shell)이 없는 세션이어야 합니다. 광고된 도구들이 전체 표면(surface)이 되는 일반적인 MCP 배포 방식은
obstat이 설계된 목적에 부합하지만, 셸을 곁에 둔 코딩 에이전트는 그렇지 않습니다. - 커버리지(Coverage)가 곧 통제(control)입니다. 도구 표면(tool surface)이 답변할 수 없는 질문은 거부(refusal)가 아니라 기록상의 구멍(hole)이 됩니다.
count_unread가 현재 그 서버에 존재하는 이유는, 당시에는 존재하지 않았기 때문입니다.
라이브러리는 이메일 도구에 필요한 단 한 단어를 이미 예약해 두었습니다. obstat은 호출자의 신원(identity)을 subject라는 파라미터에 주입했습니다. 그런데 이메일 도구는 send_email(to, subject, body) 형식을 원합니다. 위험한 실패는 시스템 충돌이 아니었습니다. 파라미터가 광고된 스키마(schema)에서 사라지고, 신원 객체(identity object)가 Subject: 헤더에 안착하는 조용한 변종(variant)이 문제였습니다. 이제는 obstat_subject로 명명되었으며, obstat_은 라이브러리가 예약한 유일한 접두사(prefix)입니다.
기록은 무엇이 승인되었는지는 말해주지만, 실제로 무슨 일이 일어났는지는 결코 말해주지 않습니다. 대량 삭제 (bulk delete) 작업은 하나의 발신자가 메시지 한 개를 삭제했든 만 개를 삭제했든 하나의 기록으로 남기며, 결과는 어떤 경우든 ok: true라고 나타납니다. 이제 도구들은 본문 내부에서 결과 기록에 obstat.note(deleted=…, matched=…)를 작성할 수 있습니다. 대량 삭제의 절반만 성공한 경우처럼 읽는 이에게 숫자가 가장 절실히 필요한 실패 상황에서도 마찬가지입니다.
와일드카드 (glob)는 전체 문자열과 일치하며, smtplib는 헤더에 있는 모든 주소로 메시지를 전달합니다. "자기 자신에게 보내는 메일은 무료"라는 규칙 — 리소스 mail:*@example.com — 은 attacker@evil.example,me@example.com와도 일치하며, send_message는 두 주소 모두로 메시지를 전달했을 것입니다. 리소스 ID는 호출자(caller)가 제어하는 텍스트입니다. 이를 패턴 매칭(pattern-match)하지 말고, 리소스 호출 가능 객체 (resource callable) 내부에서 파싱하십시오. 해당 호출 가능 객체가 발생시키는 모든 오류는 기록되지 않은 충돌 (unrecorded crash)이 아니라, 기록된 거부 (recorded denial)가 됩니다.
이 중 어느 것도 리뷰를 통해 발견된 것이 아니며, 그중 두 가지는 버그를 수정하는 대신 한계를 인정하는 obstat의 사례입니다. 이것이 제가 명시적으로 밝히고 싶은 거래(trade)입니다. 라이브러리는 정중한 경로 (polite path)가 증거를 남기도록 만들 수는 있지만, 모든 경로를 정중하게 만들 수는 없습니다.
수행하지 않는 작업
잘려 나간 꼬리 부분 (truncated tail)은 나타나지 않습니다. 파일을 쓸 수 있는 사람이라면 누구든 전체 체인을 다시 계산할 수 있습니다. 이것은 위변조 증거 (tamper-evidence)이지, 부인 방지 (non-repudiation)가 아닙니다. 명세서(spec)에도 해당 용어로 명시되어 있습니다. docs/obstat-spec.md의 §8은 여전히 취약한 부분들의 목록이며, 기능 목록만큼이나 눈에 띄게 의도적으로 유지되고 있습니다.
그곳의 한 항목은 제가 아니라 CI(지속적 통합)에 의해 발견되었습니다. 동시성 테스트(concurrency test) — 하나의 로그에 두 개의 실제 프로세스가 추가하는 작업 — 는 Linux와 macOS에서는 통과(green)되었으나, Windows 환경에서는 60개 중 57개의 레코드만 기록된 채 돌아왔습니다. Windows의 추가(append) 모드는 탐색(seek)과 쓰기(write)로 이루어지며, 하나의 원자적 연산(atomic operation)이 아니기 때문에 동시 쓰기 작업 시 레코드가 조용히 유실됩니다. 프로세스 간 보장(cross-process guarantee)은 이제 POSIX 전용으로 문서화되었고, 수정안의 이름은 (mstcrt.locking(), 이는 설계상 거부한 바로 그 프로세스 간 잠금(inter-process lock)입니다)으로 명명되었으며, 테스트는 Windows에서는 건너뛰도록(skip) 설정되었지만 CI 단계는 유지됩니다. 저는 문서화되지 않은 구멍을 배포하느니, 차라리 문서화된 구멍을 배포하겠습니다.
해당 테스트는 스레드(thread)나 프로세스(process)를 전혀 건드리지 않았던 두 번의 릴리스 이후에 작성되었습니다. 여기서 얻은 교훈은 일반화될 수 있습니다. 규범적 주장(normative claim)에 테스트가 없다면, 버그는 바로 그곳에 있습니다. 매일 실행되는 코드 속에 있는 것이 아닙니다.
동일한 형태의 문제가 4번의 릴리스 이후 다른 곳에서도 발견되었는데, 이것이 제가 가장 교훈적이라고 생각하는 사례입니다. 명세(spec)에는 인자가 도구에 맞지 않으면 호출이 거부된다고 되어 있었습니다. 하지만 코드는 인자를 '부분적으로만' 바인딩(bound)했기 때문에, 필수 인자가 누락된 호출이 관문을 통과하여 allow 레코드를 남긴 뒤, 본문에서 TypeError와 함께 종료되었습니다. 즉, 로그는 실행될 수조차 없었던 호출이 승인되었다고 주장하고 있었던 것입니다. 이것이 바로 이 프로젝트 전체가 존재함으로써 만들지 않으려고 노력하는, 근거 없는 주장(unearned claim)의 전형이며, 이 문제는 4개 버전에 걸쳐 그대로 방치되어 있었습니다.
이 문제가 살아남을 수 있었던 이유는 MCP SDK가 호출이 데코레이터(decorator)에 도달하기 전에 광고된 스키마(schema)를 기준으로 인자를 검증하기 때문이었습니다. 서버를 통해서는 잘못된 호출이 절대 도달하지 않았기에, 그 간극은 외부에서 보이지 않았습니다. 저는 보호된 함수를 직접 호출하는 테스트를 작성하고 나서야 이를 발견했습니다. 여기서 두 가지 결론이 도출됩니다. 상위 단계(upstream)의 무언가가 우연히 주의를 기울이고 있기 때문에 유지되는 보장은 당신의 보장이 아닙니다. 그리고 사용자가 사용하는 방식대로 코드를 실행하는 테스트는, 사용자의 툴링(tooling)이 먼저 걸러내는 케이스들을 체계적으로 놓치게 될 것입니다.
시도해보기
pip install obstat
obstat init # 시작용 정책; 규칙의 주석을 해제하기 전까지 모든 것이 거부됨
런타임 의존성(runtime dependencies)이 없습니다. AWS도, ID 제공자(identity provider)도, 정책 서비스(policy service)도 아닙니다. 데코레이터(decorator), tomllib, sqlite3, 그리고 파일 하나뿐입니다. 노트북에서조차 테스트해 볼 수 없는 거버넌스 라이브러리(governance library)는 아무도 채택하지 않습니다.
ID(Identity) 또한 선택 사항입니다. 오늘날 대부분의 MCP 서버는 토큰이 전혀 없습니다. stdio, 단일 로컬 사용자, 또는 이미 인증(auth)을 종료한 게이트웨이 형태입니다. 거버넌스 라이브러리를 평가하기도 전에 ID 제공자를 요구하는 것이 바로 거버넌스 라이브러리가 평가되지 못하고 버려지는 이유입니다. 여기서는 익명 호출(anonymous call)도 정당한 호출입니다. 이는 anonymous로 기록되며, 정책에 따라 anonymous가 무엇을 할 수 있는지 결정됩니다.
docs/obstat-spec.md는 규범적(normative)입니다. 동작 방식이 변경되면 동일한 커밋에서 이를 업데이트하며, 명세(spec)와 코드(code)가 일치하지 않는다면 둘 중 하나는 버그입니다.
Apache-2.0 라이선스입니다. 특히 실제로 "당신의 에이전트가 무엇을 할 수 있었고, 언제였는가"라는 질문에 답해야 했던 경험이 있는 분들의 의견을 듣고 싶습니다. 저는 그 대화가 어떻게 흘러갈지에 대한 저만의 추측을 바탕으로 이것을 만들었기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기