CI attestation
그린 빌드는 증거입니다. CI attestation은 그 증거가 지식 항목의 승격에 반영되게 하는 방법입니다. 에이전트가 자기 작업을 스스로 승격시키는 일은 여전히 막으면서요.
일반 HTTP 엔드포인트이고, 의도적으로 MCP 도구가 아닙니다. connect code를 가진 에이전트는 여기에 닿을 수 없습니다. 서명하려면 오너만 보는 시크릿이 필요합니다.
켠다고 자동 승격이 되는 게 아닙니다. CI 시스템 전체가 이슈어 하나로 세고, 승격에는 서로 다른 이슈어 K개(기본 2)가 필요합니다. 그린 100번도 한 목소리입니다. 지식이 신뢰를 얻는 법을 보세요.
끄고 두는 것도 설정 누락이 아니라 유효한 정책입니다. 시크릿이 없으면 사람 오너만 승격합니다.
1. 시크릿 발급
오너 권한으로 프로젝트 -> Knowledge -> 설정. 평문 시크릿은 발급 응답에서 한 번만 보이고, 다시 읽을 방법이 없습니다. 상태 조회는 키 id와 유예 상태만 돌려주고 시크릿은 절대 돌려주지 않습니다. 바로 CI의 시크릿 저장소에 넣으세요.
2. 체크와 주장을 연결
attestation은 "체크 X가 통과했고, 그것이 주장 Y를 뒷받침한다"는 말입니다. 체크 맵이 어떤 CI 체크가 어떤 지식 항목을 attest할 수 있는지를 기록합니다.
맵에 없는 체크의 attestation도 기록은 됩니다. 다만 counted: false로 돌아오고 승격에 반영되지 않습니다. 의도된 모양입니다. 증거는 보관하되, 이 체크가 이 주장을 대변한다고 미리 정한 사람이 없으므로 표를 주지 않습니다. 맵은 같은 프로젝트의 주장만 후보로 보여주고, 데이터베이스가 그 경계를 독립적으로 강제합니다.
3. 서명해서 POST
POST /api/knowledge/attest
X-RR-Attest-Signature: <hex HMAC-SHA256>
Content-Type: application/json
본문, 여덟 필드 전부 필수:
| 필드 | 설명 |
|---|---|
projectId | 주장이 속한 프로젝트 |
knowledgeId | attest 대상 항목 |
runId | CI 실행 식별자 |
checkName | 체크 맵 항목과 일치해야 함 |
assertion | 현재는 check_passed만 지원 |
keyId | 어떤 키로 서명했는지 (현재 또는 이전) |
issuedAt | ISO 타임스탬프. 서버 시각 기준 300초 이내 |
nonce | 프로젝트 안에서 고유. 재전송은 거부됨 |
서명 대상은 보내는 JSON이 아니라 정규화된 문자열입니다. 위 여덟 필드가 고정된 순서로, 토큰 사이 공백 없이, 각 값은 JSON 이스케이프되어 들어갑니다:
{"projectId":"...","knowledgeId":"...","runId":"...","checkName":"...","assertion":"check_passed","keyId":"...","issuedAt":"...","nonce":"..."}
객체에 JSON.stringify를 거는 대신 필드를 명시적으로 나열해서 만드세요. 그러지 않으면 객체의 키 순서가 서명의 검증 여부를 좌우하고, 키가 하나 더 붙으면 바이트가 조용히 달라집니다:
import { createHmac } from "node:crypto";
const FIELDS = ["projectId", "knowledgeId", "runId", "checkName",
"assertion", "keyId", "issuedAt", "nonce"];
const canonical = "{" + FIELDS
.map((f) => `${JSON.stringify(f)}:${JSON.stringify(claim[f])}`)
.join(",") + "}";
const signature = createHmac("sha256", secret).update(canonical).digest("hex");서버가 서명하지 않는 필드는 서명에 영향을 줄 수 없으므로, 아홉 번째 키를 보내도 무해합니다. 다만 그 필드는 보호되지 않습니다.
응답
| 상태 | 뜻 |
|---|---|
200 | { validationId, counted }. 재전송이어도 원래 validation을 그대로 돌려줍니다. |
400 | 본문 형식 오류, 지원하지 않는 assertion, 또는 300초 창을 벗어난 issuedAt |
401 | 지정한 키로 서명이 검증되지 않음 |
404 | 이 프로젝트에 그런 지식 항목이 없음 |
409 | 이미 쓴 nonce. 재전송된 attestation |
검사 순서에는 이유가 있습니다. 서명과 소속 검증이 nonce를 소비하기 전에 일어나므로, 위조된 요청이 얻지도 않은 nonce를 태울 수 없습니다. 다른 프로젝트의 항목은 존재하지 않는 항목과 똑같이 응답합니다. 프로젝트의 CI 시크릿이 닿을 수 있는 경로에서는 "그 id가 존재한다"는 확인 자체가 정보 노출이기 때문입니다.
시크릿 회전
슬롯 두 개(현재/이전), 모드 두 개.
| 모드 | 동작 | 쓸 때 |
|---|---|---|
| 회전 (위생) | 교체된 시크릿이 이전 슬롯으로 옮겨가 24시간 동안 계속 검증됩니다. 아직 그 시크릿을 들고 있는 파이프라인이 실행 중에 깨지지 않습니다 | 정기 회전 |
| 즉시 폐기 (사고) | 교체본을 발급하는 바로 그 쓰기에서 이전 슬롯을 비웁니다. 유예를 줄이는 게 아니라 없앱니다 | 시크릿이 유출됐을 때 |
감사 기록에 어느 모드였는지와 유예 만료 시각이 남습니다. 몇 달 뒤에 읽어도 정기 회전과 사고 대응을 구분할 수 있습니다.
즉시 폐기는 앞으로의 오용만 막습니다. 이미 일어난 일을 되돌리지 않습니다. 유출된 시크릿이 이미 승격시킨 항목은 승격된 채로 남습니다. 그걸 되돌리는 건 포렌식입니다. 찾아내서 일반적인 반박 경로로 반박해야 합니다. "폐기했으니 피해는 정리됐다"가 정확히 잘못된 해석입니다.
24시간 유예는 어떤 명세에서 유도한 값이 아니라 선택한 값입니다.
CI가 할 수 있는 것과 없는 것
- 할 수 있음: 주장에 이슈어 하나의 지지를 더하고, 계속 증거를 쌓기.
- 할 수 없음: 혼자 승격시키기. CI 전체가 이슈어 하나이기 때문입니다.
- 할 수 없음: 다른 프로젝트의 주장을 attest하기. 서명이 유효해도 안 됩니다.
- 할 수 없음: 반박 지우기. 만료되지 않은 반박이 붙은 항목은 지지가 아무리 쌓여도 승격되지 않습니다.