[verified] Check trusted-template AC verification classification
This commit is contained in:
@@ -252,7 +252,7 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
|
||||
8. Same-repository PR이면 head object도 cache에서 검증하고 exact base-to-head tree를 Gitea changed-file set·diff와 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository·branch SHA와 Gitea changed-file set·diff를 대조한다. Diff 직렬화의 byte 차이만으로 실패시키지 않고 변경 경로와 exact base/head blob 및 hunk가 동일 변경을 나타내는지 검증한다. 임의 ref, checkout, clone 또는 worktree를 만들지 않는다.
|
||||
9. `release_repository` request를 보내되 Issue lease는 유지한다.
|
||||
10. Acceptance matrix 작성 전에 구현자 실행 보고와 상세 실행 기록의 출처 및 확인 범위를 구분한다. Revision이 식별된 기존 evidence는 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 상세 기록이나 revision 정보의 부재만으로 새 검증 gate를 만들지 않는다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 재검토에서는 이전 finding별 승인 계약 근거와 최소 보완 요청을 새 구현·evidence의 확인 위치에 대응하고, 해소 또는 잔여 사유를 기존 criterion의 `evidence`와 `reason`에 기록한다. 이미 충족된 검증을 source revision 차이만으로 다시 요구하지 않되, 변경 영향으로 새로 판정할 수 없어진 필수 조건은 그 근거와 최소 보완 범위를 특정한다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다.
|
||||
11. 문서 검토 입력과 구조·의미 분리 절차로 trusted 계약·산출물별 의무표를 준비하고 모든 문서 의무를 대조한다. 위임 시 해당 입력 전체를 제공한다. 독립 code-review worker 결과가 있으면 exact head에 바인딩됐고 authoritative하게 읽을 수 있을 때만 보조 evidence로 사용한다. 그 결과를 spec verdict로 그대로 복사하지 않는다.
|
||||
11. 문서 검토 입력과 구조·의미 분리 절차로 trusted 계약·산출물별 의무표를 준비하고 모든 문서 의무를 대조한다. 정본 형식 요구사항의 AC 검증 분류는 아래 read-only 구조 검사로 보조 확인하고 비지원 형식·의미 판단은 main이 직접 대조한다. 위임 시 해당 입력 전체를 제공한다. 독립 code-review worker 결과가 있으면 exact head에 바인딩됐고 authoritative하게 읽을 수 있을 때만 보조 evidence로 사용한다. 그 결과를 spec verdict로 그대로 복사하지 않는다.
|
||||
12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다.
|
||||
13. Exact key/type, duplicate key, marker field, digest, deterministic correlation, criterion cardinality, path binding, verdict/state 조합, credential 부재와 render → parse → render byte equality를 검증한다.
|
||||
14. 여기까지는 read-only preflight다. Exact review 요청이 native review write 권한을 부여하지 않았거나 preflight가 완전하지 않으면 종료하며 review create 또는 다른 mutation을 수행하지 않는다.
|
||||
@@ -281,6 +281,14 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
|
||||
- 이는 호출 형태의 확인일 뿐 인증·provider wire schema·pagination completeness·callback 내부 동작이나 향후 네트워크 성공의 증명이 아니다. Write 뒤 readback이 실패하면 응답과 정확한 대상 근거를 보존하고 기존 read-only reconciliation으로 확인하거나 uncertain/partial로 중단한다. Signature 문제를 이유로 native create를 반복하지 않는다.
|
||||
- 새 runtime gate·범용 interface 검사기·adapter·schema·journal을 추가하지 않는다. 문서 계약 회귀는 지침의 존재만 확인하고, synthetic binding/collector 회귀는 로컬 호출 동작만 확인한다. 이를 실제 agent 실행이나 live publication 검증으로 보고하지 않는다.
|
||||
|
||||
### AC 검증 분류의 read-only 구조 검사
|
||||
|
||||
Trusted requirements README/TEMPLATE와 exact reviewed-head 요구사항 snapshot을 준비한 뒤 `scripts/review_support.py check-ac-classification --document <immutable-req-snapshot> --expected-document-sha256 <independently-pinned-document-digest> --expected-template-sha256 <independently-pinned-trusted-template-digest>`를 실행한다. Expected digest는 검사 대상 파일을 그 자리에서 다시 hash해 승인하는 값이 아니라 기존 trusted input·source revision에서 독립 고정한 값이어야 한다. 정본 TEMPLATE는 helper와 같은 harness의 `docs/requirements/TEMPLATE.md`만 읽으며 제품 TEMPLATE나 임의 파일로 대체하지 않는다. CLI는 document·template의 exact UTF-8 bytes digest를 대조하고 원본을 수정하지 않는다. Digest 일치는 파일 결속일 뿐 reviewed-head 실행 provenance나 의미 인증을 대신하지 않는다.
|
||||
|
||||
- 현재 정본 형식의 `## 7. 인수 기준`에서 `## 8. 오류 및 충돌 계약`까지 flat H3 `REQ-NNN.XX-ACYY — 제목`별 `**검증 분류**` 하나와 수준·백엔드·케이스의 단일 행 필드를 확인한다. 빈 값·중복 필드·중복 AC ID·필드의 미치환 placeholder는 차단한다. 다른 AC 또는 코드 fence 안의 example로 누락 필드를 충족하지 않는다. `없음`은 기존 계약이 허용하는 값이며 새 backend enum·검증 수준 정책을 만들지 않는다.
|
||||
- 지원 범위는 canonical flat block과 LF/CRLF에 한정한다. Multiline 분류값·들여쓴/중첩 heading·HTML/HTML comment·불완전 fence·모호한 section 구조는 자동 보정하지 않고 unsupported/미확인으로 남겨 main이 exact source와 trusted 계약을 직접 대조한다. 비지원 형식의 실패를 의미 결함으로 자동 판정하지 않는다. 입력은 1MiB 이하 regular UTF-8 파일만 읽으며 최종 symlink·FIFO는 거부한다. Ancestor 경로 admission과 authenticated source·revision binding은 기존 main 책임이다.
|
||||
- Exit 0과 AC ID·checked fields·digest 출력은 해당 분류의 구조 검사 통과만 뜻한다. 전체 문서 적합성, AC ID 연속성·traceability, 필드값의 의미·검증 수준 적절성·실제 backend/test 실행·AC 충족은 기존 main 검토에 남긴다. Exit 2는 sanitized 오류이며 raw 입력이나 credential을 출력하지 않는다. 기존 result의 `evidence`·`reason`에 검사 범위·결과·미확인 이유를 기록하고 새 schema·제품 문서 자동 수정·publisher gate를 추가하지 않는다.
|
||||
|
||||
## 문서 검토 입력과 구조·의미 분리
|
||||
|
||||
문서 검토를 main이 수행하거나 delegate에 위임하기 전에 다음 입력을 trusted workflow에 준비한다. 이 준비는 제품 저장소의 새 산출물이나 result schema 변경을 요구하지 않는다.
|
||||
|
||||
+122
@@ -642,6 +642,119 @@ def verify_publication(before, after, exact_review, result, allowed_paths, revie
|
||||
return {"review_id": rid, "state": expected, "review_records_added": 1,
|
||||
"timeline_events_added": 1, "projected_review_ids": projected, "read_only": True}
|
||||
|
||||
def _classification_bytes(path):
|
||||
"""Bounded regular-file read; caller independently admits source provenance."""
|
||||
fd = None
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
_fail("classification input unavailable")
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
fd = None
|
||||
raw = stream.read(1048577)
|
||||
if len(raw) > 1048576:
|
||||
_fail("classification input too large")
|
||||
raw.decode("utf-8")
|
||||
return raw
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
_fail("classification input unavailable")
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _classification_lines(text):
|
||||
if type(text) is not str or len(text.encode("utf-8")) > 1048576:
|
||||
_fail("invalid classification text")
|
||||
text = text.replace("\r\n", "\n")
|
||||
if any(c in text for c in ("\r", "\x00", "\u2028", "\u2029")):
|
||||
_fail("unsupported classification text")
|
||||
visible, fence = [], None
|
||||
for line in text.split("\n"):
|
||||
match = re.fullmatch(r" {0,3}(`{3,}|~{3,})(.*)", line)
|
||||
if fence is not None:
|
||||
if match and match[1][0] == fence[0] and len(match[1]) >= fence[1] and not match[2].strip():
|
||||
fence = None
|
||||
continue
|
||||
if match:
|
||||
if match[1][0] == "`" and "`" in match[2]:
|
||||
_fail("ambiguous classification fence")
|
||||
fence = (match[1][0], len(match[1]))
|
||||
continue
|
||||
if '<!--' in line or '-->' in line or re.search(r'</?[A-Za-z][^>]*>', line):
|
||||
_fail("unsupported classification HTML")
|
||||
visible.append(line)
|
||||
if fence is not None:
|
||||
_fail("unterminated classification fence")
|
||||
return visible
|
||||
|
||||
|
||||
def check_ac_classification(body, expected_template_sha256):
|
||||
"""Check only canonical section-7 AC classification, not full compliance."""
|
||||
if type(expected_template_sha256) is not str or not HEX64.fullmatch(expected_template_sha256):
|
||||
_fail("invalid trusted template digest")
|
||||
template = _classification_bytes(Path(__file__).resolve().parents[5] / "docs/requirements/TEMPLATE.md")
|
||||
if hashlib.sha256(template).hexdigest() != expected_template_sha256:
|
||||
_fail("trusted template drift")
|
||||
template_text = template.decode("utf-8")
|
||||
try:
|
||||
template_block = template_text.split("## 7. 인수 기준\n", 1)[1].split("## 8.", 1)[0]
|
||||
classification = template_block.split("**검증 분류**\n", 1)[1].split("**상태 불변조건**", 1)[0]
|
||||
except IndexError:
|
||||
_fail("unsupported trusted template")
|
||||
fields = re.findall(r"^- ([^:]+): \$\{[a-z_]+\}$", classification, flags=re.M)
|
||||
if fields != ["수준", "백엔드", "케이스"]:
|
||||
_fail("unsupported trusted template")
|
||||
lines = _classification_lines(body)
|
||||
section = "## 7. 인수 기준"
|
||||
if lines.count(section) != 1:
|
||||
_fail("ambiguous AC section")
|
||||
start = lines.index(section) + 1
|
||||
end = next((i for i in range(start, len(lines)) if lines[i].startswith("## ")), len(lines))
|
||||
if end == len(lines) or lines[end] != "## 8. 오류 및 충돌 계약":
|
||||
_fail("unsupported AC section boundary")
|
||||
blocks, current = [], None
|
||||
for line in lines[start:end]:
|
||||
if line.startswith("### "):
|
||||
match = re.fullmatch(r"### (REQ-[0-9]{3}\.[0-9]{2}-AC[0-9]{2}) — (\S.*)", line)
|
||||
if not match:
|
||||
_fail("unsupported AC heading")
|
||||
current = (match[1], [])
|
||||
blocks.append(current)
|
||||
elif current is None:
|
||||
if line.strip():
|
||||
_fail("unsupported AC preamble")
|
||||
else:
|
||||
if line.lstrip().startswith("#"):
|
||||
_fail("ambiguous AC heading")
|
||||
current[1].append(line)
|
||||
ids = [item[0] for item in blocks]
|
||||
if not ids or len(ids) != len(set(ids)):
|
||||
_fail("missing or duplicate AC")
|
||||
for _, block in blocks:
|
||||
marker = "**검증 분류**"
|
||||
if block.count(marker) != 1:
|
||||
_fail("missing or duplicate AC classification")
|
||||
pos = block.index(marker) + 1
|
||||
stop = next((i for i in range(pos, len(block)) if block[i].startswith("**")), len(block))
|
||||
values = {}
|
||||
for line in block[pos:stop]:
|
||||
if not line.strip():
|
||||
continue
|
||||
match = re.fullmatch(r"- ([^:]+): (.*)", line)
|
||||
if not match or match[1] not in fields or match[1] in values:
|
||||
_fail("ambiguous AC classification field")
|
||||
value = match[2].strip()
|
||||
if not value or "${" in value:
|
||||
_fail("empty or placeholder AC classification")
|
||||
values[match[1]] = value
|
||||
if set(values) != set(fields):
|
||||
_fail("missing AC classification field")
|
||||
return {"ac_ids": ids, "checked_fields": fields, "read_only": True,
|
||||
"template_sha256": expected_template_sha256,
|
||||
"document_sha256": hashlib.sha256(body.encode("utf-8")).hexdigest()}
|
||||
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", newline="") as stream:
|
||||
@@ -670,6 +783,10 @@ def main(argv=None):
|
||||
q=sub.add_parser("check-cache"); q.add_argument("--contract-path",required=True); q.add_argument("--owner",required=True); q.add_argument("--repository",required=True); q.add_argument("--issue",required=True,type=int)
|
||||
q=sub.add_parser("check-pages"); q.add_argument("--pages",required=True); q.add_argument("--key",required=True)
|
||||
q.add_argument("--page-size", type=int, default=50); q.add_argument("--max-pages", type=int, default=10000)
|
||||
q=sub.add_parser("check-ac-classification")
|
||||
q.add_argument("--document", required=True)
|
||||
q.add_argument("--expected-document-sha256", required=True)
|
||||
q.add_argument("--expected-template-sha256", required=True)
|
||||
q=sub.add_parser("check-unchanged"); q.add_argument("--before",required=True); q.add_argument("--after",required=True)
|
||||
q=sub.add_parser("verify-publication");
|
||||
for n in ("before","after","exact-review","result","allowed-paths","inline-comments"): q.add_argument("--"+n,required=True)
|
||||
@@ -703,6 +820,11 @@ def main(argv=None):
|
||||
items = collect_pages(f, a.key, a.page_size, a.max_pages)
|
||||
if len(used) != len(pages): _fail("extra saved pages")
|
||||
print(json.dumps(items,ensure_ascii=False,sort_keys=True))
|
||||
elif a.cmd=="check-ac-classification":
|
||||
raw = _classification_bytes(a.document)
|
||||
if type(a.expected_document_sha256) is not str or not HEX64.fullmatch(a.expected_document_sha256) or hashlib.sha256(raw).hexdigest() != a.expected_document_sha256:
|
||||
_fail("classification document drift")
|
||||
print(json.dumps(check_ac_classification(raw.decode("utf-8"), a.expected_template_sha256), ensure_ascii=False, sort_keys=True))
|
||||
elif a.cmd=="check-unchanged": print(json.dumps({"unchanged":verify_unchanged(_json_file(a.before),_json_file(a.after))}))
|
||||
else:
|
||||
print(json.dumps(verify_publication(_json_file(a.before),_json_file(a.after),_json_file(a.exact_review),_json_file(a.result),_json_file(a.allowed_paths),a.reviewer_id,_json_file(a.inline_comments),_json_file(a.provider_contract) if a.provider_contract else None, expected_pr_url=a.expected_pr_url),sort_keys=True))
|
||||
|
||||
@@ -61,6 +61,128 @@ def verify_fixture(before, after, review, r, paths, contract=None):
|
||||
expected_pr_url="https://example.test/acme/repo/pulls/7")
|
||||
|
||||
|
||||
class ACClassificationTests(unittest.TestCase):
|
||||
TEMPLATE = ROOT / 'harness/docs/requirements/TEMPLATE.md'
|
||||
|
||||
def body(self):
|
||||
return ('## 7. 인수 기준\n\n### REQ-001.01-AC01 — example\n\n'
|
||||
'**판정 기준**\n\nexample\n\n**검증**\n\nexample\n\n'
|
||||
'**검증 분류**\n\n- 수준: unit\n- 백엔드: 없음\n- 케이스: normal\n\n'
|
||||
'**상태 불변조건**\n\n없음\n\n## 8. 오류 및 충돌 계약\n\n없음\n')
|
||||
|
||||
def check(self, body):
|
||||
import hashlib
|
||||
self.assertTrue(callable(getattr(mod, 'check_ac_classification', None)),
|
||||
'trusted-template AC classification checker missing')
|
||||
return mod.check_ac_classification(body, hashlib.sha256(self.TEMPLATE.read_bytes()).hexdigest())
|
||||
|
||||
def test_valid_canonical_ac_classification(self):
|
||||
out = self.check(self.body())
|
||||
self.assertEqual(out['ac_ids'], ['REQ-001.01-AC01'])
|
||||
self.assertEqual(out['checked_fields'], ['수준', '백엔드', '케이스'])
|
||||
self.assertTrue(out['read_only'])
|
||||
|
||||
|
||||
def test_missing_empty_duplicate_and_placeholder_fields_rejected_per_ac(self):
|
||||
valid = self.body()
|
||||
self.check(valid)
|
||||
for changed in (valid.replace('- 수준: unit\n', ''),
|
||||
valid.replace('- 백엔드: 없음', '- 백엔드: '),
|
||||
valid.replace('- 케이스: normal', '- 케이스: ${verification_cases}'),
|
||||
valid.replace('- 수준: unit', '- 수준: unit\n- 수준: unit'),
|
||||
valid.replace('**검증 분류**', '**검증**'),
|
||||
valid.replace('**검증 분류**', '**검증 분류**\n\n**검증 분류**')):
|
||||
with self.subTest(changed=changed):
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
self.check(changed)
|
||||
|
||||
def test_other_ac_and_fenced_examples_cannot_supply_missing_field(self):
|
||||
good = self.body().split('## 8.', 1)[0]
|
||||
bad = good.replace('AC01', 'AC02').replace('- 수준: unit\n', '')
|
||||
self.check(good + good.replace('AC01', 'AC02').split('\n\n', 1)[1] + '## 8. 오류 및 충돌 계약\n')
|
||||
for extra in ('', '```markdown\n- 수준: unit\n```\n', '~~~\n- 수준: unit\n~~~\n'):
|
||||
with self.subTest(extra=extra):
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
self.check(good + bad.split('\n\n', 1)[1].replace('**상태 불변조건**', extra + '**상태 불변조건**') + '## 8. 오류 및 충돌 계약\n')
|
||||
|
||||
|
||||
def test_ambiguous_hidden_sections_and_template_drift_fail_closed(self):
|
||||
valid = self.body()
|
||||
self.check(valid)
|
||||
import hashlib
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.check_ac_classification(valid, '0' * 64)
|
||||
for bad in ('<!--\n' + valid + '\n-->', valid + '\n```unclosed',
|
||||
valid.replace('### REQ', '#### REQ'),
|
||||
valid.replace('## 8. 오류 및 충돌 계약', '## 7. 인수 기준'),
|
||||
valid.replace('- 수준: unit', ' - 수준: unit')):
|
||||
with self.subTest(bad=bad):
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
self.check(bad)
|
||||
crlf = valid.replace('\n', '\r\n')
|
||||
self.assertEqual(self.check(crlf)['document_sha256'], hashlib.sha256(crlf.encode()).hexdigest())
|
||||
|
||||
def test_cli_read_only_exact_bytes_digest_and_sanitized_failure(self):
|
||||
import hashlib
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / 'req.md'
|
||||
raw = self.body().replace('\n', '\r\n').encode()
|
||||
path.write_bytes(raw)
|
||||
os.chmod(path, 0o600)
|
||||
before = (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns, path.stat().st_ino)
|
||||
argv = [sys.executable, '-B', str(MODPATH), 'check-ac-classification',
|
||||
'--document', str(path), '--expected-document-sha256', hashlib.sha256(raw).hexdigest(),
|
||||
'--expected-template-sha256', hashlib.sha256(self.TEMPLATE.read_bytes()).hexdigest()]
|
||||
actual = subprocess.run(argv, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(actual.returncode, 0, actual.stderr)
|
||||
self.assertEqual(json.loads(actual.stdout)['document_sha256'], hashlib.sha256(raw).hexdigest())
|
||||
self.assertEqual(before, (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns, path.stat().st_ino))
|
||||
argv[-3] = '0' * 64
|
||||
bad = subprocess.run(argv, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(bad.returncode, 2)
|
||||
self.assertEqual(json.loads(bad.stderr), {'error': 'invalid input'})
|
||||
self.assertEqual(bad.stdout, '')
|
||||
|
||||
|
||||
def test_supported_scope_rejects_hidden_html_and_noncanonical_ac_heading(self):
|
||||
valid = self.body()
|
||||
self.check(valid)
|
||||
for bad in ('<div>\n' + valid + '</div>',
|
||||
valid.replace('### REQ-001.01-AC01 — example', '### REQ-001.01-AC01'),
|
||||
valid.replace('**검증 분류**', '<span>**검증 분류**</span>')):
|
||||
with self.subTest(bad=bad):
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
self.check(bad)
|
||||
|
||||
def test_file_admission_rejects_symlink_fifo_invalid_utf8_and_oversize(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
valid = root / 'valid.md'
|
||||
valid.write_text(self.body())
|
||||
self.assertEqual(mod._classification_bytes(valid), valid.read_bytes())
|
||||
alias = root / 'alias.md'
|
||||
alias.symlink_to(valid)
|
||||
fifo = root / 'fifo'
|
||||
os.mkfifo(fifo)
|
||||
invalid = root / 'invalid.md'
|
||||
invalid.write_bytes(b'\xff')
|
||||
large = root / 'large.md'
|
||||
large.write_bytes(b'x' * 1048577)
|
||||
for path in (alias, fifo, invalid, large, root):
|
||||
with self.subTest(path=path.name):
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod._classification_bytes(path)
|
||||
|
||||
def test_documentation_contract_scopes_checker_and_binds_digests(self):
|
||||
text = (MODPATH.parent.parent / 'SKILL.md').read_text()
|
||||
self.assertIn('### AC 검증 분류의 read-only 구조 검사', text)
|
||||
section = text.split('### AC 검증 분류의 read-only 구조 검사', 1)[1].split('\n## ', 1)[0]
|
||||
for rule in ('check-ac-classification', '--expected-document-sha256',
|
||||
'--expected-template-sha256', '전체 문서 적합성', '의미',
|
||||
'없음', '미확인', '코드 fence', '새 schema', '단일 행'):
|
||||
self.assertIn(rule, section)
|
||||
|
||||
|
||||
class SupportTests(unittest.TestCase):
|
||||
def test_loads_json_rejects_duplicate_and_nonfinite(self):
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
|
||||
Reference in New Issue
Block a user