🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [38편] Codex CLI와 Gemini CLI 훅: 동일한 철학, 다른 형식
지금까지 만든 조각들
8부에서 세 편에 걸쳐 만든 부품을 정리하면 이렇습니다.
- 36편: 훅의 실행 계약(stdin JSON + 종료 코드)과 그 뒤에 숨은 Unix Domain Socket 서버
- 37편: Claude Code의 실제 훅 이벤트(PreToolUse 등)와
permissionDecision - 38편: 여러 CLI 도구를 하나의 표준 모델로 통합하는 어댑터 패턴
이제 남은 것은 NormalizedHookEvent를 받아 실제로 allow/deny/ask를 판정하는 정책 엔진, 판정을 사용자에게 물어봐야 할 때 대기시키는 승인 큐, 그리고 나중에 “그때 왜 그렇게 판단했는지” 되짚어볼 수 있는 감사 로그입니다.
정책 엔진 — allow/deny/ask 판정
enum PolicyDecision {
case allow
case deny(reason: String)
case ask
}
struct PolicyRule {
let pattern: String // 매칭할 명령어 패턴 (정규식)
let decision: PolicyDecision
let compiledRegex: NSRegularExpression?
init(pattern: String, decision: PolicyDecision) {
self.pattern = pattern
self.decision = decision
self.compiledRegex = try? NSRegularExpression(pattern: pattern)
}
}
final class PolicyEngine {
private var rules: [PolicyRule] = []
private let fallback: PolicyDecision = .ask // 정의되지 않은 경우 사용자에게 물어봄
func addRule(_ rule: PolicyRule) {
rules.append(rule)
}
func evaluate(command: String) -> PolicyDecision {
for rule in rules {
guard let regex = rule.compiledRegex else { continue }
let range = NSRange(command.startIndex..., in: command)
if regex.firstMatch(in: command, range: range) != nil {
return rule.decision
}
}
return fallback
}
}
규칙은 순서가 있는 리스트로, 먼저 매칭되는 규칙이 우선합니다. 알 수 없는 명령의 기본값(fallback)은 보수적으로 — allow가 아니라 ask나 deny로 두는 것이 안전합니다. 정규식은 규칙을 불러올 때 한 번만 컴파일해서 compiledRegex에 저장해두면, 이벤트가 많이 들어와도 매번 새로 컴파일하는 비용을 피할 수 있습니다.
규칙 자체는 코드에 하드코딩하지 않고 JSON 같은 데이터 파일로 분리해두면, 코드를 재빌드하지 않고도 정책을 바꿀 수 있습니다.
[
{ "pattern": "^rm -rf /$", "decision": "deny", "reason": "루트 삭제 차단" },
{ "pattern": "^git (status|diff|log)", "decision": "allow" },
{ "pattern": "^git push", "decision": "ask" }
]
승인 큐 — ask 판정을 대기시키기
ask 판정은 즉시 응답할 수 없습니다. 사용자가 결정할 때까지 요청을 어딘가에 보관해야 하고, 이 요청은 hook 스크립트의 연결을 열어둔 채로 대기시켜야 합니다.
struct PendingApproval {
let id: UUID
let command: String
let createdAt: Date
let connectionFD: Int32 // 응답을 보낼 때 사용할 연결
}
final class ApprovalQueue {
private var pending: [UUID: PendingApproval] = [:]
private let lock = NSLock()
func enqueue(_ approval: PendingApproval) {
lock.lock(); defer { lock.unlock() }
pending[approval.id] = approval
}
func resolve(id: UUID) -> PendingApproval? {
lock.lock(); defer { lock.unlock() }
return pending.removeValue(forKey: id)
}
}
여러 연결이 동시에 enqueue를 호출하고, 앱 UI 스레드가 동시에 resolve를 호출할 수 있으므로 NSLock으로 접근을 직렬화합니다. 메모리에만 두면 앱이 재시작될 때 대기 중이던 요청이 사라지므로, 28편에서 배운 SQLite로 함께 영속화해둡니다.
func enqueueForApproval(event: NormalizedHookEvent, connectionFD: Int32) {
let approval = PendingApproval(id: UUID(), command: event.command, createdAt: Date(), connectionFD: connectionFD)
approvalQueue.enqueue(approval)
approvalStore.insertPending(approval) // SQLite에 저장
// 30초 안에 응답이 없으면 안전하게 deny 처리
DispatchQueue.global().asyncAfter(deadline: .now() + 30) { [weak self] in
guard let self, let stillPending = self.approvalQueue.resolve(id: approval.id) else { return }
self.respond(decision: .deny(reason: "응답 시간 초과"), fd: stillPending.connectionFD)
}
}
resolve(id:)가 이미 nil을 반환한다면 사용자가 그 사이 직접 응답했다는 뜻이므로 타임아웃 처리를 건너뜁니다. 큐가 “누가 먼저 이 항목을 처리하느냐”의 경쟁 상태를 자연스럽게 중재해줍니다.
감사 로그 — 모든 판정을 기록으로 남기기
승인 큐는 아직 결정되지 않은 요청만 담습니다. 하지만 “지난주에 이 명령이 왜 차단됐는지” 되짚어보려면, allow/deny/ask 여부와 상관없이 모든 판정을 영구 기록해두는 별도의 로그 테이블이 필요합니다.
final class AuditLog {
private let db: OpaquePointer?
init(path: String) {
var handle: OpaquePointer?
sqlite3_open(path, &handle)
sqlite3_exec(handle, """
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
command TEXT NOT NULL,
decision TEXT NOT NULL,
reason TEXT,
decided_at REAL NOT NULL
)
""", nil, nil, nil)
self.db = handle
}
func record(provider: String, command: String, decision: String, reason: String?) {
let sql = "INSERT INTO audit_log (id, provider, command, decision, reason, decided_at) VALUES (?, ?, ?, ?, ?, ?)"
var statement: OpaquePointer?
sqlite3_prepare_v2(db, sql, -1, &statement, nil)
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, UUID().uuidString, -1, nil)
sqlite3_bind_text(statement, 2, provider, -1, nil)
sqlite3_bind_text(statement, 3, command, -1, nil)
sqlite3_bind_text(statement, 4, decision, -1, nil)
sqlite3_bind_text(statement, 5, reason, -1, nil)
sqlite3_bind_double(statement, 6, Date().timeIntervalSince1970)
sqlite3_step(statement)
}
}
승인 큐의 항목은 결정이 나는 순간 큐에서 사라지지만(resolve가 지워버림), 감사 로그의 항목은 지워지지 않고 계속 쌓입니다. 이 둘을 혼동하지 않는 것이 설계의 핵심입니다 — 큐는 “지금 처리해야 할 것”, 로그는 “지금까지 있었던 모든 것”입니다.
전체 조립하기
final class HookApprovalProxy {
private let policyEngine = PolicyEngine()
private let approvalQueue = ApprovalQueue()
private let approvalStore: ApprovalStore
private let auditLog: AuditLog
init(dbPath: String) {
self.approvalStore = ApprovalStore(path: dbPath)
self.auditLog = AuditLog(path: dbPath)
}
func handle(event: NormalizedHookEvent, connectionFD: Int32) {
let decision = policyEngine.evaluate(command: event.command)
switch decision {
case .allow:
auditLog.record(provider: event.provider, command: event.command, decision: "allow", reason: nil)
respond(decision: decision, fd: connectionFD)
case .deny(let reason):
auditLog.record(provider: event.provider, command: event.command, decision: "deny", reason: reason)
respond(decision: decision, fd: connectionFD)
case .ask:
// 사용자가 결정한 시점에 별도로 auditLog.record가 호출됨
enqueueForApproval(event: event, connectionFD: connectionFD)
}
}
private func respond(decision: PolicyDecision, fd: Int32) {
// 38편의 ProviderAdapter.encodeResponse로 도구별 형식에 맞게 인코딩 후 전송
}
}
각 부품(정책 엔진 / 승인 큐 / 감사 로그)이 단일 책임을 가지며, HookApprovalProxy는 이를 조합하는 얇은 층으로 남습니다. 정책 엔진만 따로 테스트하거나, 감사 로그 저장 방식만 바꾸는 일이 쉬워집니다.
핵심 요약
- 정책 엔진은 규칙 리스트로 allow/deny/ask를 판정하며, 정규식은 로드 시점에 한 번만 컴파일
- 규칙을 데이터(JSON)로 분리하면 재빌드 없이 정책을 바꿀 수 있음
- 승인 큐는 “아직 결정되지 않은” 요청만 담고, 처리되는 즉시 사라짐 — 타임아웃으로 무한 대기 방지
- 감사 로그는 allow/deny/ask 상관없이 모든 판정을 영구 기록 — “지금까지 있었던 모든 것”
- 34편의 LaunchAgent로 이 전체를 백그라운드 서비스로 상시 실행
8부가 여기서 마무리됩니다. 다음 편부터는 마지막 9부 macOS 시스템 API로, Apple Events를 이용한 다른 앱 제어부터 시작합니다.
🤖 Generated with Claude Code