18개의 테스트가 통과한 승인 게이트(approval gate)에서, 만료된(expired) 요청을 1회 실행할 수 있었습니다.
원인은 SQLite의 쓰기 잠금(write lock)을 대기하기 전 시점의 시간으로 유효 기간을 판정하고 있었기 때문입니다. 기한 내에 처리를 호출할 수 있더라도, 실제 실행 순서가 돌아오는 사이에 승인이 만료되는 경우를 간과했습니다.
AI를 사용하는 자사 문서 생성·검토·공개 과정에서는 사람이 판단하는 단계를 분리하여 처리합니다. 이번에는 그 운영 과정에서 필요한 '승인된 내용만 실행한다'는 조건을 독립적인 로컬 검증 프로그램으로 구현했습니다. 초안을 AI 지원으로 작성하고 18개의 테스트를 통과한 후, 다른 검토 과정에서 이 반례(counterexample)를 발견하게 되었습니다.
이 글에서는 실패를 재현한 순서, 시계 읽는 위치 수정, 그리고 추가된 회귀 테스트에 대해 설명합니다. 후반부에서는 본문/수신처 변경이나 결과가 불명확할 때의 재실행을 동일한 게이트에서 어떻게 처리했는지도 다룰 것입니다.
'만료되면 멈춘다'는 테스트에 대기 시간이 없었다
초안에는 기한 직전에는 통과하고, 만료 시점에서는 멈추는 테스트가 있었습니다. 또한, 별개의 DB 연결이 동일한 승인을 두고 경쟁하며, 그중 한쪽만 시작하는 테스트도 있었습니다.
부족했던 것은 경쟁을 기다리는 사이에 기한이 만료되는 조합이었습니다.
첫 번째 재현에서는 실제 SQLite 연결과 실시간 시계를 사용했습니다. 다른 연결로 쓰기 잠금을 유지하고, 1초 동안 유효한 승인에 대해 실행 처리를 시작합니다. 잠금(lock)을 약 1.3초 후에 해제하자, 기한이 지났음에도 불구하고 모의 처리(mock processing)가 1회 호출되었습니다. 이 모의 처리는 로컬 리스트에 추가하는 것이며 네트워크 통신은 없습니다.
구 버전의 문제점을 요약하면 다음과 같습니다. 이는 호출 측의 시간 획득과 실행 측의 판정을 하나로 나열한 의사 코드(pseudo-code)이며, 실제 구 버전에서 발췌된 것은 아닙니다. 예외 처리는 생략했습니다.
# 호출 측에서 시간을 유지. 이 시점에서는 기한 내임
now = clock()
# 실행 측에서 쓰기 잠금을 기다림
...
수정된 방식은, 호출 측으로부터 고정된 now를 받는 형태를 버리고, 신뢰할 수 있는 실행 측의 시계를 주입(inject)하는 형태로 변경했습니다. 잠금 획득이 완료된 후에 시간을 읽습니다.
connection.execute(
OWASP의 Transaction Authorization Cheat Sheet에서도 중요한 작업 내용이 이용자에게 확인 가능해야 하고, 작업 내용 변경으로부터 보호해야 하며, 실행 직전에 승인을 검증하는 것을 언급하고 있습니다. 이번 데모에서는 이 생각을 작은 문서 작업에 적용했습니다.
## 사전에 결정된 신뢰 경계
대책은 AI의 출력에 승인 플래그를 추가하는 것이 아닙니다. 제안하는 측과, 승인을 발행하는 측, 실행하는 측을 분리하는 것부터 시작합니다.
이 다이어그램은 배치해야 할 경계를 보여줍니다. 데모는 단일 터미널 내에서 작동하기 때문에, 사람의 인증이나 프로세스별 쓰기 권한까지는 재현하지 못했습니다. AI가 승인용 DB를 수정할 수 있거나, 승인을 발행하는 함수를 호출할 수 있는 환경에서는, 후술하는 해시만으로는 방어가 성립되지 않습니다.
실제 운영에 연결할 때는, AI에게 제안용 기능까지만 전달하고, 승인 기록과 외부 작업의 인증 정보를 관리하는 측을 분리해야 합니다. OWASP의 Excessive Agency가 제시하는, 기능·권한·자율성을 필요한 범위로 좁히는 방침에 대응합니다.
## 승인에 연결되는 것을 고정하기
검증용 요청은 다음 3개 항목으로 한정했습니다.
| 항목 | 의미 | 변경되었을 때 |
|---|---|---|
`action` | 무엇을 할지 | 다른 작업으로 거부함 |
`destination` | 어디에 반영할지 | 다른 목적지로 거부함 |
`body` | 무엇을 반영할지 | 다른 내용으로 거부함 |
여기에, 승인 ID, 데이터 형식 버전, 유효 기간, 실행 상태를 조합합니다. 실제 운영에서는 테넌트(tenant), 공개 범위, 첨부 파일의 내용 등 그 작업의 의미를 바꾸는 항목도 대상입니다. 본문만 고정해도, 첨부나 공개 범위가 자유롭게 바뀐다면 불충분합니다.
요청은 키 순서와 JSON 표현을 정하여 UTF-8로 변환하고, SHA-256으로 대조합니다. 의미가 다른 요청이 동일한 승인 ID로 대체되는 것을 막기 위한 비교입니다.
**SHA-256은 승인자의 서명이 아닙니다.** 요청과 기록 양쪽 모두를 수정할 수 있는 상대에 대한 인증에도, 본문의 암호화에도 사용될 수 없습니다. 신뢰하는 측에서 발행 및 보관한 기록과 실행 대상을 대조하는 용도로 한정됩니다.
본문의 공백이나 개행도 내용으로 취급합니다. 따라서 나중에 형식만 다듬어도 승인을 다시 받아야 합니다. 이 불편함을 피하기 위해 의미의 동일성을 LLM(Large Language Model)에게 판별시키는 것은 이번 게이트에서는 수행하지 않았습니다.
## 검사 후에 파일을 다시 읽지 않기
해시를 대조했더라도, 그 후에 초안을 다시 읽어 보내면, 검사와 이용 사이에 다시 틈이 생길 수 있습니다.
그래서 실행 측은 요청을 불변의 스냅샷으로 취급합니다. **대조한 요청 자체를 실행 함수에 전달하고, 실행 함수는 원본 파일을 다시 읽지 않습니다.**
여기서 고정할 범위에는 한계가 있습니다. 실행 함수 안에서 임의로 본문을 보완하거나, 실행 후 템플릿 처리에서 목적지를 대체하면, 대조 영역 외에서 의미가 바뀝니다. 검사 후에 추가하는 푸터나 첨부 파일까지 포함하여, 최종적으로 외부로 전달할 요청을 어디서 완성시킬지 결정해야 합니다.
## 승인 확인과 시작 권한 획득을 함께 하기
또 다른 문제는, 동일한 승인을 두 개의 실행 프로세스가 동시에 발견하는 것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
실행 A: 승인됨을 확인 ── 실행
실행 B: 승인됨을 확인 ── 실행
확인 후에 '실행됨'을 저장만 한다고 해도, 그 저장까지 양쪽 모두가 전진할 수 있습니다.
검증에서는 SQLite의 트랜잭션 내에서 상태, 대상 일치, 유효 기간을 확인하고, `APPROVED`에서 `RUNNING`으로 변경합니다. 이 변경을 확정한 후에, 모의 전송(mock send)을 호출합니다.
SQLite의 `BEGIN IMMEDIATE`는 시작 시에 쓰기 트랜잭션을 획득합니다. 다른 쓰기가 있으면 대기하거나 실패합니다. 락(lock)을 획득할 수 없을 경우, 검사를 건너뛰고 실행하는 경로는 마련하지 않았습니다.
여기서 기간은 앞서 제시한 **락 획득 후의 대조 시점**에 적용됩니다. 긴 처리 완료 시각이나, 실행 중인 작업 취소를 보장하는 기간이 아닙니다.
## 타임아웃을 실패 확정으로 취급하지 않기
상태는 다음과 같이 구분했습니다.
`RUNNING`,
`SUCCEEDED`,
`UNKNOWN`의 승인을 다시 실행 시작하는 것은 거부합니다. 프로세스가 도중에 종료되어, `RUNNING` 상태로 남아 있는 경우도 마찬가지입니다.
특히 확실히 하고 싶었던 것은, **모의 전송지가 수락한 후 호출 측에 타임아웃을 반환**하는 케이스입니다. 호출 측에서는 실패로 보여도, 전송지 기록에는 이미 1건이 있습니다. 승인을 미사용으로 되돌려 재실행하면, 2번째를 만들 가능성이 있습니다.
이 때문에, `UNKNOWN`
재전송 대기 상태가 아니라, **결과를 확인하기 위한 정지 상태**로 만들었습니다. 전송처의 접수 기록이나 계약으로 제공되는 Idempotency Key 등을 확인하여 다음 조치를 판단합니다. '검색에서 찾을 수 없음'만으로는 미실행이라고 단정할 수 없는 시스템도 있습니다.
AWS의 Making retries safe with idempotent APIs 역시, 부작용(side effect)을 동반하는 재시도에서는 요청 식별자(request identifier)와 Idempotency 계약이 중요해진다는 점을 설명하고 있습니다. 이번 데모에서 할 수 있는 것은 동일한 승인에 의한 **재개(restart)를 막는 것**입니다. 임의의 외부 API에서 엄격하게 '반드시 1회 전송'을 구현한 것은 아닙니다.
## 자신의 단말기에서 확인하기
검증용 코드와 테스트는 전용 리포지토리의 검증된 커밋에 모아두었습니다. Python 3.10 이상이 필요합니다. 검증 환경은 Python 3.14.3 / SQLite 3.51.3입니다. 추가 패키지, API 키, AI 모델은 사용하지 않습니다. clone 시에는 네트워크 연결이 필요하지만, 테스트와 비교 데모는 외부 서비스에 연결하지 않습니다. 저장되는 데이터는 임시 디렉토리 내의 가상 데이터입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
git clone https://github.com/YushiYamamoto/approval-bound-execution-demo.git
cd approval-bound-execution-demo
git checkout --detach f1ea497ff9056ab7c4d60dd27af5b965d7ad7249
...
마지막 명령어는 저장된 구 버전과 현행 버전을 비교합니다. 실제 SQLite의 쓰기 잠금(write lock)과 시험용 시계를 사용하여, 구 버전은 기한 130을 지난 시간 132에서 모의 처리 1회를, 현행 버전은 `EXPIRED`・0회가 되는 것을 확인합니다. 본문 초반부의 실제 시계로 약 1.3초 유지했던 재현과는 다른, 독자가 재실행하기 위한 절차입니다. `history/gate_before_clock_fix.py`
이는 결함 검증 전용이며, 실제 운영에는 통합하지 마십시오.
이 버전의 테스트 결과입니다. 처리 속도 비교가 아니라, 기대하는 상태와 모의 처리 횟수 확인입니다.
Ran 19 tests
OK
단순한 승인 플래그와의 비교는 `demo.py`에서 다음과 같은 결과를 보였습니다. 여기서 비교하는 것은 본문에 구속되지 않고 재사용할 수 있는 단순 플래그 방식입니다. 기존의 승인 제품 전반의 성능 비교가 아닙니다.
| 가상의 조건 | 단순 플래그 모의 접수 건수 | 게이트 모의 접수 건수 | 게이트 측 상태 및 이유 |
|---|---|---|
| 승인 후 본문 교체 | 1 | 0 | `CONTENT_MISMATCH` |
| 동일한 승인으로 2회 호출 | 2 | 1 | 2회차는 `ALREADY_STARTED` |
| 접수 후 타임아웃되어 재호출 | 2 | 1 | `UNKNOWN`, 재개 거부 |
봐야 할 결과는 '거부한 건수'만은 아닙니다.
| 조건 | 확인할 것 |
|---|---|
| 올바른 요청과 승인 | 실행이 가능하고 접수 기록이 1건 생길 것 |
| ... |
정상적인 요청도 시도하는 것은, 모두 거부하는 것만으로 게이트를 성공으로 취급하지 않기 때문입니다. 한편, 이 유한한 테스트로 모든 변경이나 실제 운영상의 사고를 막을 수 있다고 평가할 수도 없습니다.
## 운영에서 감수해야 하는 불편함과 한계
이 방식은 정지함으로써 판단 시간을 확보합니다. 멈추지 않고 일을 끝내는 것과의 트레이드오프가 있습니다.
| 선택한 동작 | 얻는 것 | 감수하는 것 |
|---|---|---|
| 본문이 바뀌면 재승인 | 확인된 내용과의 일치성 | 서식 변경에도 확인 증가 |
| ... |
로그에서 알고 싶은 것은, 어떤 승인이 어느 상태로 이동했고, 무엇을 이유로 멈췄는지입니다. 입력 전체 본문이나 인증 정보를 그대로 에러로 내보낼 필요는 없습니다.
이번 구현에 포함하지 않은 것도 있습니다. 승인자 인증, 변조에 강한 감사 로그(audit log), 권한 분리 실행 환경, 비밀 정보 탐지, 외부 API와의 결과 대조, DB 장애로부터의 복구는 별도의 대책입니다. 승인된 본문에 비밀이 포함되어 있다면, 이 대조는 그것을 판별하지 못합니다. 다른 승인 ID 발행이나 DB 복원에 의한 중복, 실제 시계 되감기(rewinding)도 이 19건으로 방어 검증한 대상은 아닙니다.
## 자신의 자동화에 가져가서 확인할 것
다음 3가지를 실행 직전의 코드와 실패 후의 절차에서 확인합니다.
승인한 것은 무엇인가. 본문뿐만 아니라, 수신처・행위・공개 범위 등 조작의 의미를 바꾸는 항목들이 고정되어 있는가? -
실행하는 것은 같은 것인가. 검사 후에 파일을 다시 로드하거나, 다른 정보를 보완하지 않았는가? -
결과를 알 수 없을 때, 멈출 수 있는가. 실패 표시만으로 승인을 미사용 상태로 되돌리지 않고, 접수되었을 가능성을 조사할 수 있는가?
승인 버튼을 추가한 시점에서 끝내지 않고, 그 전후에서 내용이 달라지는 경우와, 실행 결과가 알 수 없게 되는 경우를 하나씩 시험한다. 더욱이, 정상 케이스와 만료된 케이스를 별도로 시험하는 것뿐만 아니라, **기다린 후에도 그 조건이 성립하는지**를 확인한다. 그 테스트를, 자신의 시스템의 인수 조건으로 남겨간다。
### Discussion

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42