feat(ops): verify release provenance evidence pairs

This commit is contained in:
Hermes Agent
2026-09-20 11:47:05 +09:00
parent cd2342f2c6
commit db2bdd7d4a
15 changed files with 1054 additions and 67 deletions
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0035"
status: active
---
# Observed provenance evidence와 개별 gate
## Criterion
`release_provenance_gate.py`는 운영 계층이 제공한 versioned observed evidence JSON을 strict하게 검증하고 그 source를 `operator_observed`로 표시해야 한다. Schema version 1은 exact field `schema_version`, `target_id`, `target_kind`, `image_id`, `image_digest`, `oci_revision`, `observed_at`만 허용한다. `schema_version`은 integer `1`, target field는 non-empty string, image 값은 lowercase `sha256:<64 hex>`, revision은 lowercase 40-hex, 관측 시각은 UTC RFC 3339여야 한다. Schema·duplicate-key·secret validation 실패는 입력을 record에 복사하지 않고 record/receipt 없이 non-zero로 종료해야 한다. 유효 evidence 이후 gate가 직접 관측한 audit checkout HEAD/detached/dirty 상태, revision별 runtime-file blob 일치와 read-only state capture는 caller evidence와 분리하고 REQ-0010의 gate record v1 exact schema로 기록한다. Observed revision과 checkout HEAD가 다르거나 checkout, runtime file 또는 필수 capture가 실패하면 `gate.passed=false`의 immutable failure record를 남기고 non-zero로 종료해야 한다. Record write 뒤 CLI는 canonical path와 raw bytes SHA-256을 REQ-0010 gate receipt v1 JSON으로 출력한다. Core는 target/runtime을 inspect하거나 caller evidence를 직접 관측값으로 주장하지 않는다.
## Verification
- 성공 조건: 유효한 evidence와 clean detached checkout, 일치 runtime files, 성공 capture를 사용하면 REQ-0010 gate record v1에 `source=operator_observed`, caller evidence와 gate-observed evidence가 분리되고 `gate.passed=true`이며 receipt의 SHA-256이 저장된 raw bytes와 같고 CLI가 0으로 종료한다.
- 실패·경계 조건: 필수 field 각각의 누락, unknown/duplicate key, bool을 포함한 잘못된 type, empty target, uppercase·길이 오류 digest/revision, non-UTC 또는 timezone 없는 timestamp와 secret-like key/value는 record/receipt 없이 non-zero로 종료한다. 유효 evidence 이후 revision mismatch, dirty/attached checkout, runtime-file mismatch와 capture 실패는 immutable failure record·receipt와 non-zero를 만들며 success로 승격되지 않는다.
- 검사 방법: evidence parser와 `collect_release_provenance()` 단위 테스트, CLI subprocess/fixture 테스트로 record schema·source 표기·verdict·exit code를 확인하고 legacy unversioned evidence가 거부되는지 검증한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0036"
status: active
---
# PRE/POST pair 검증과 immutable manifest
## Criterion
Pair validator는 운영 계층이 gate receipt에서 보존해 명시적으로 전달한 PRE/POST canonical record path와 expected raw-bytes SHA-256만 읽고 filesystem scan이나 latest-file 추정을 하지 않아야 한다. Current record bytes의 SHA-256은 expected 값과 일치해야 한다. PRE는 `phase=pre`, POST는 `phase=post`이고 두 record는 REQ-0010 gate record v1 exact schema와 success 불변식—동일한 schema version·operation ID·instance·target kind/ID, clean detached checkout, checkout HEAD와 observed revision 일치, non-empty runtime verification 전부 성공, 세 command output return code 0, broker capture 성공, gate summary `true,false,true,true,true`—을 충족해야 한다. 성공하면 REQ-0010 pair manifest v1 exact schema로 manifest를 checkout 밖에 exclusive-create하고 read-only로 전환한다. Image 동일성·변경 허용과 PRE→POST command-class transition 정책은 판정하지 않는다. Expected digest 불일치·불완전·record 불일치·failed gate 또는 output collision에서는 manifest를 생성·overwrite하지 않고 non-zero로 종료해야 한다.
## Verification
- 성공 조건: 동일 operation·instance·target의 유효한 성공 PRE/POST canonical path와 gate receipt의 expected SHA-256을 전달하면 current raw bytes와 digest를 재검증하고 REQ-0010 pair manifest v1 read-only file 하나를 생성하며 CLI가 0으로 종료한다.
- 실패·경계 조건: PRE 없는 POST, path 또는 expected digest 누락, 동일 path 두 번, phase 역전, unsupported/unknown/duplicate schema key, operation/instance/target mismatch, failed/incomplete success invariant, expected digest 발행 뒤 record byte 변경과 기존 output path 충돌은 새 manifest를 남기지 않고 기존 file을 변경하지 않으며 non-zero로 종료한다.
- 검사 방법: parser/validator 단위 테스트와 신규 pair CLI subprocess 테스트로 success manifest, exact digest binding, explicit path 사용, tamper detection, collision 불변성을 검증한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0037"
status: active
---
# Dirty checkout isolation 무결성
## Criterion
Dirty audit checkout은 기존과 같이 state capture 전에 fail-closed해야 하며, isolation은 tracked modified file, untracked regular file과 symlink를 checkout 밖의 operation-specific directory에 보존하되 source를 삭제·reset·checkout하지 않아야 한다. REQ-0010 isolation manifest v1 exact schema는 각 dirty relative path의 source type, size, SHA-256 또는 symlink target-text SHA-256, copied relative path, copied type/size/hash와 `matches`를 기록해야 한다. File content와 symlink 대상 content는 manifest에 넣지 않는다. Directory·FIFO/socket/device 같은 unsupported type, checkout 또는 symlink escape, copy/hash mismatch와 output collision은 성공으로 표시하지 않고 gate를 non-zero로 유지해야 한다. Manifest는 exclusive-create 후 read-only mode여야 한다.
## Verification
- 성공 조건: modified/untracked regular file, empty file, nested path와 checkout 내부 symlink를 격리하면 source와 copied metadata/hash가 같고 `matches=true`이며 manifest가 versioned/read-only이고 source bytes·type이 그대로 남는다.
- 실패·경계 조건: directory·special file, `../`/absolute path, checkout 밖을 가리키는 symlink, injected copy/hash mismatch와 기존 manifest collision은 성공으로 기록되지 않고 source를 변경·삭제하지 않으며 non-zero 결과를 만든다.
- 검사 방법: 임시 Git repository에서 regular/untracked/empty/nested/symlink 및 adversarial path/type fixtures를 사용해 isolation helper와 gate CLI를 검증하고, source before/after hash·mode·symlink target과 manifest content를 비교한다.
+2 -2
View File
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"next_line_number": 10,
"next_ac_number": 35
"next_line_number": 11,
"next_ac_number": 38
}
+91
View File
@@ -0,0 +1,91 @@
---
id: "dcy-0010"
status: confirmed
---
# 운영 provenance 검증 계약과 audit checkout 증거 무결성
## Problem
PR #93은 audit checkout의 clean/detached/expected revision, runtime file hash, image digest 형식과 PRE/POST 상태 capture를 검사하는 `release_provenance_gate.py`를 추가했다. 현재 gate는 개별 record를 생성하지만, 실행 환경이 관측한 target·image identity를 명시적인 expected/observed 구조로 보존하지 않고 같은 operation의 PRE/POST record가 한 쌍인지 검증하거나 최종 manifest로 묶지 않는다. Dirty checkout 격리는 파일을 checkout 밖에 복사하지만 각 파일의 hash·type·복사 성공 여부를 manifest에 남기지 않아 보존본이 원본과 같은 bytes인지 사후 검증하기 어렵다.
실제 Docker Compose/container 또는 host process 선택, runtime inspect, stop/start/recreate/deploy 실행, 전역 operation lock, audit checkout 교체와 maintenance window 승인은 배포 환경을 소유한 운영 계층의 책임이다. `trex-core`가 특정 runtime을 선택하거나 lifecycle command를 실행하면 application repository가 production orchestration과 권한 경계를 소유하게 된다. 이 저장소가 제공해야 하는 것은 운영 계층이 수집한 evidence를 엄격한 schema로 검증하고, 실패를 machine-readable non-zero 결과로 반환하며, 개별 PRE/POST record와 dirty isolation manifest의 무결성을 증명할 수 있게 하는 source-owned verification contract다.
기대 상태는 `trex-core` provenance gate가 운영 계층이 제공한 observed target/image evidence와 audit checkout evidence를 명시적으로 구분해 기록하고, 누락·형식 오류·checkout mismatch·capture 실패를 fail-closed하는 것이다. 별도 pair validator는 명시적으로 전달된 PRE/POST record 두 개의 operation identity와 gate 성공 여부를 검증하고 immutable manifest를 생성해야 한다. Dirty isolation은 source를 삭제하거나 정리하지 않으면서 regular file과 symlink의 보존 결과를 hash/type 기준으로 검증 가능하게 남겨야 한다.
## Evidence
- GitHub Issue [#87](https://github.com/fourblocks2020/trex-core/issues/87)은 running artifact와 audit checkout의 provenance 불일치, PRE/POST 증거 연결, dirty checkout 무손실 보존 문제를 추적한다. Issue 본문의 lifecycle wrapper·runtime inspect·global lock 항목은 현재 책임 재검토 결과 운영 계층 소유로 분리해야 한다.
- PR [#93](https://github.com/fourblocks2020/trex-core/pull/93)은 `main`에 병합되었고 조사 기준 revision은 `cd2342f2c69267c471b5e81658c58332e00857de`다. 관련 변경은 `f3a6b2c`, `8d9d627`, `619feff`, `8378e03`, `7dc2f65`에 걸쳐 있다.
- `scripts/release_provenance_gate.py`는 command class와 read-only capture command 형태를 제한하고 dirty checkout에서 state capture를 실행하지 않은 채 exit 2로 차단한다. 하지만 `--expected-oci-revision`과 `--image-digest`는 caller가 제공한 평면 문자열이며 target identity, evidence source와 expected/observed 구분을 기록하지 않는다.
- `trex_core/adapters/persistence/ops_checks.py`의 `collect_release_provenance()`는 audit checkout HEAD와 caller가 제공한 revision을 비교하고 image digest의 `sha256:<64 hex>` 형식을 검사한다. Runtime 파일은 working-tree bytes와 지정 revision의 Git blob hash를 비교한다.
- `scripts/release_provenance_gate.py`는 PRE와 POST record를 각각 exclusive-create하고 read-only mode로 전환하지만, 동일 operation ID·instance·target identity를 검증하는 pair validator와 pair manifest는 없다.
- `isolate_dirty_checkout_files()`의 현재 `manifest.json`은 `dirty_paths`와 `copied_paths`만 기록한다. 각 source/copied path의 file type, SHA-256, size와 복사 일치 여부는 기록하지 않는다. 따라서 path-only manifest가 현재 baseline이며 hash manifest는 이번 Line의 신규 범위다.
- `docs/operations.md`의 현재 실행 절차는 host `trex-core-daemon`과 `pkill`을 설명하지만 Issue #87은 container identity를 예로 든다. 저장소에는 production Compose/service definition이나 canonical lifecycle executor가 없으므로 이 Line에서 특정 runtime을 선택하거나 lifecycle command를 추가할 직접 근거가 없다.
- 현재 `main`에서 `tests/adapters/persistence/test_ops_checks.py`와 `tests/scripts/test_release_provenance_gate.py`를 실행한 결과 `19 passed, 3 skipped`다. 이는 기존 개별 gate baseline을 확인하지만 observed evidence schema, pair integrity와 hash isolation manifest를 검증하지 않는다.
- Duplicate sweep 결과 Issue #87 관련 open PR과 `release provenance`·`lifecycle wrapper` open PR은 없고 PR #93만 merged 상태다.
- 현재 active ProofLine AC 전체와 `.proofline/lines/**`를 `release_provenance`, `audit checkout`, `재기동`, `lifecycle wrapper`로 검색한 결과 이 검증 계약을 소유하는 AC는 없다. Image publication을 소유하는 `req-0002`·`req-0004`는 production restart와 deployment를 비범위로 두므로 update/satisfy 대상이 아니다.
## Scope
- 이 Line은 기존 PR #93 gate를 기반으로 version-independent 동작을 소유하는 신규 AC 세 건을 `create`한다. 기존 active AC의 `update`, `retire`, `satisfy`, release evidence 대상은 없다.
### 신규 AC-A: observed provenance evidence와 개별 gate
- 운영 계층이 수집해 전달하는 evidence는 최소 `target_id`, `target_kind`, `image_id`, `image_digest`, `oci_revision`, `observed_at`을 가진 versioned JSON object다. `target_kind`는 extensibility를 위한 식별값이며 `trex-core`가 Docker·Compose·host runtime을 선택하거나 inspect하지 않는다.
- Gate는 evidence source를 `operator_observed`로 명시하고, caller가 제공한 observed 값과 gate가 직접 확인한 checkout HEAD/detached/dirty 상태 및 runtime-file Git blob 비교를 별도 field로 기록한다. Caller evidence를 gate 직접 관측값으로 표시하지 않는다.
- `target_id`와 `target_kind`는 non-empty string, `oci_revision`은 40-hex commit, `image_id`와 `image_digest`는 `sha256:<64 hex>`, `observed_at`은 timezone을 가진 UTC timestamp여야 한다. 누락·중복 key, 잘못된 type/형식, secret-like key/value, checkout revision mismatch, dirty/attached checkout, runtime-file mismatch와 필수 state capture 실패는 record에 원인을 남기고 non-zero로 종료한다.
- 정상 검증은 gate record에 `schema_version`, evidence의 expected/observed provenance, checkout/runtime/capture 결과와 `gate.passed=true`를 남긴다. 실패 record도 exclusive-create read-only로 남기되 lifecycle 허용을 주장하지 않는다.
- Verification은 정상 evidence, 각 필수 field 누락·잘못된 type/형식, unknown key, secret-like 값, revision mismatch, dirty/attached checkout, runtime-file mismatch와 capture 실패를 독립적으로 포함한다.
### 신규 AC-B: PRE/POST pair validator와 immutable manifest
- Pair validator는 filesystem scan으로 record를 추정하지 않고 운영 계층이 명시적으로 전달한 정확한 PRE path와 POST path만 읽는다.
- PRE는 `phase=pre`, POST는 `phase=post`이며 두 record는 동일한 `schema_version`, `operation_id`, `instance`, `target_kind`, `target_id`를 가져야 한다. 각 command class는 기존 allowlist에 속해야 하지만 실제 PRE→POST lifecycle transition policy와 image 변경 허용 여부는 운영 계층이 소유한다.
- 두 record 모두 `gate.passed=true`이고 필수 checkout/runtime/state capture evidence가 완전해야 한다. PRE/POST의 image identity는 각각 manifest에 보존하되 deploy 여부를 모르는 core validator가 임의로 동일성을 강제하지 않는다.
- Pair 검증 성공 시 두 input record의 canonical path, SHA-256, phase, command class, observed image identity와 검증 시각을 포함한 versioned manifest를 checkout 밖에 exclusive-create하고 read-only mode로 전환한다. 동일 output path 재사용은 overwrite하지 않고 실패한다.
- PRE 없는 POST, phase 역전, identity mismatch, failed gate, 변조된/비정상 record, 필수 evidence 누락에서는 manifest를 생성하지 않고 non-zero로 종료한다.
- Verification은 성공 pair, PRE 없는 POST, phase mismatch, operation/instance/target mismatch, failed gate, input record byte 변경, output collision을 독립적으로 포함한다.
### 신규 AC-C: dirty isolation 무결성과 비밀 안전성
- Dirty checkout이면 기존과 같이 state capture 전에 fail-closed하고 tracked modified file, untracked regular file과 symlink를 checkout 밖의 operation-specific isolation directory에 복사한다. Source를 삭제·reset·checkout하지 않는다.
- Isolation manifest는 각 dirty path에 대해 relative path, source type, source SHA-256 또는 symlink target SHA-256, size, copied relative path, copied type/hash/size와 `matches`를 기록한다. Directory·unsupported special file·checkout escape·symlink escape는 성공으로 추정하지 않고 오류 상태를 기록하며 gate를 실패시킨다.
- Manifest 자체는 versioned JSON이며 exclusive-create 후 read-only mode로 전환한다. Secret-like file content는 manifest에 넣지 않고 hash·size·type만 저장한다.
- Verification은 modified/untracked regular file, symlink, empty file, nested path, checkout escape, unsupported type, copy/hash mismatch와 output collision을 포함하고 source bytes가 변경·삭제되지 않았음을 확인한다.
### Source 및 문서 변경 경계
- 예상 source/test owners는 `scripts/release_provenance_gate.py`, 신규 pair verification CLI, `trex_core/adapters/persistence/ops_checks.py`, provenance 관련 tests와 `docs/operations.md`다.
- Public trading/domain API, daemon composition root, persistence schema와 runtime service lifecycle에는 변경이 없다.
- `docs/operations.md`는 `trex-core` gate가 lifecycle을 실행하거나 runtime truth를 직접 관측하지 않는다는 경계를 명시하고, 운영 계층이 exact observed evidence를 제공하고 non-zero 결과를 lifecycle 차단 조건으로 사용해야 한다고 설명한다.
- Production runtime validation은 운영 계층 구현과 별도 사용자 승인을 받은 maintenance window가 있어야 하며 이번 source delivery의 완료 증거가 아니다.
## Out of Scope
- Docker Compose/container 또는 host process 중 canonical production runtime 선택
- Runtime/container/process inspect, 실제 stop/start/recreate/deploy/recovery command 실행
- Lifecycle wrapper, global operation lock, stale-lock 해제와 maintenance window orchestration
- Audit checkout의 branch/worktree 생성·교체·reset, dirty source 삭제 또는 recovery branch commit
- Trading/domain contract, 주문 lifecycle, database schema 또는 migration 변경
- 자동 주문 제출·취소, 자동 DB recovery, 자동 `sync --live` 또는 `verify --live`
- 여러 instance의 일괄·병렬 재기동 또는 실패 뒤 다음 instance 자동 진행
- Production credential·raw broker payload를 evidence에 저장하거나 Issue/PR에 게시
- 사용자 승인 없는 production 변경, 배포 완료 또는 runtime 검증 완료 주장
- Container registry publication, image signing, SBOM, package visibility 또는 release workflow 변경
## Risks and Unknowns
### Risks
- 운영 계층이 잘못된 observed evidence를 제공하면 `trex-core`는 실제 runtime을 독립적으로 재관측하지 않으므로 그 진실성을 증명할 수 없다. Record는 evidence source를 명시하고 운영 계층은 runtime-specific inspect와 lifecycle 차단을 별도 계약으로 구현해야 한다.
- Deploy operation은 PRE와 POST image identity가 달라질 수 있으므로 core pair validator가 동일 image를 강제하면 정상 deploy를 막는다. Core는 두 identity와 record hash를 보존하고 transition policy는 운영 계층에 맡긴다.
- Hash manifest는 dirty bytes의 보존 동일성을 증명하지만 그 내용의 안전성이나 배포 적합성을 승인하지 않는다. Recovery와 clean checkout 준비는 운영자가 별도로 수행한다.
- 기존 record consumer가 새 versioned schema를 전제로 하지 않을 수 있다. CLI와 parser는 기존 record를 pair success로 암묵 승격하지 않고 unsupported schema로 fail-closed해야 한다.
### Assumptions
- 운영 계층은 실제 runtime-specific inspect로 evidence JSON을 생성하고 gate의 non-zero 종료를 lifecycle 차단 신호로 사용한다.
- `target_id`는 운영 계층이 한 operation의 PRE/POST에 안정적으로 유지하는 logical target identity이며 runtime-specific 의미는 core가 해석하지 않는다.
- Existing `trex-core slots ... --json`, `rabbitmqctl list_consumers`와 broker log capture는 PR #93의 approved read-only capture boundary를 유지한다.
- Issue #87 구현 지시는 source/test/docs/PR delivery를 승인하지만 production restart와 checkout mutation 승인을 포함하지 않는다.
@@ -0,0 +1,35 @@
# Activity Log — line-0010
## 2026-09-20
- 사용자 확인으로 `dcy-0010`을 confirmed 처리했다.
- 사용자 승인으로 `req-0010`을 approved, `ac-0035`~`ac-0037`을 active 처리했다.
- `proofline validate` 통과. 기존 line-0001~0009 status 누락 warning만 존재한다.
- 구현 계획:
1. observed evidence strict parser, versioned gate record와 raw-byte digest receipt
2. explicit PRE/POST path와 trusted digest 기반 pair validator/manifest
3. regular file/symlink isolation type·size·hash manifest
4. operations-owned lifecycle 경계 문서화
5. focused/full/ProofLine 검증과 독립 review
- 실제 production lifecycle command, runtime inspect, global lock과 checkout 복구는 범위 밖이며 실행하지 않는다.
### 구현 및 검증
- `release_provenance_gate.py`를 versioned `--observed-evidence-file` 입력과 machine-readable raw-byte digest receipt로 전환했다.
- Strict observed evidence parser, v1 gate record, trusted-digest PRE/POST pair validator와 `scripts/verify_release_provenance_pair.py`를 추가했다.
- Dirty checkout isolation을 regular file/symlink type·size·SHA-256 copy integrity manifest로 강화했다.
- `docs/operations.md`에 source/operations 책임 경계와 새 gate/pair 절차를 반영했다.
- Focused 검증: `32 passed, 3 skipped`; 변경 파일 Ruff 통과.
- 전체 회귀: `1247 passed, 96 skipped`.
- 저장소 전체 `ruff check .`는 변경 전부터 존재하는 광범위한 baseline 위반 719건으로 실패했다. 이번 변경 파일에 한정한 Ruff 결과는 PASS다.
- 다음 작업: canonical ProofLine/diff 재검증, 독립 runtime/impact review, PR delivery.
### 독립 리뷰 차단사항 수정
- AC/security와 runtime/downstream 독립 리뷰가 동일하게 checkout 밖 symlink target 허용을 차단 결함으로 재현했다.
- 원인은 symlink target text의 복사 무결성만 검사하고 target 해석 경계를 검사하지 않은 것이었다.
- 절대 symlink target과 상대 `..` escape를 복사 전에 거부하고, 상대 target resolve 결과가 checkout 내부인지 검증하도록 수정했다.
- 외부 absolute/relative escape fail-closed 및 source 불변 회귀와 내부 상대 symlink 성공 회귀를 추가했다.
- 수정 후 focused 검증: `35 passed, 3 skipped`; 전체 회귀 `1250 passed, 96 skipped`.
- 변경 파일 Ruff·compile, `proofline validate`, `git diff --check` 통과. ProofLine에는 기존 line-0001~0009 status warning만 남아 있다.
- 차단사항 독립 재검토 PASS: focused isolation `4 passed`, 관련 persistence/CLI `22 passed, 3 skipped`, chained symlink escape 수동 probe도 fail-closed. 잔여 blocker 없음.
+4
View File
@@ -0,0 +1,4 @@
---
id: "line-0010"
status: discovery
---
+85
View File
@@ -0,0 +1,85 @@
---
id: "req-0010"
status: approved
discovery: "dcy-0010"
criteria:
create:
- "ac-0035"
- "ac-0036"
- "ac-0037"
update: []
retire: []
satisfy: []
---
# 운영 provenance 검증 계약과 audit checkout 증거 무결성
## Objective
`trex-core`가 운영 lifecycle을 실행하거나 특정 runtime을 선택하지 않으면서도 운영 계층이 제공한 observed target/image evidence, audit checkout 검증, PRE/POST 상태 capture와 dirty isolation 결과를 재현 가능한 형식으로 검증하게 한다. 개별 gate record는 caller evidence와 gate 직접 관측값을 구분하고 fail-closed하며, 명시적인 PRE/POST pair validator는 같은 operation의 성공 record 두 개만 immutable manifest로 결합한다. Dirty checkout 보존본은 path뿐 아니라 type·size·SHA-256 일치 여부로 무결성을 증명한다.
## Scope
- `release_provenance_gate.py`는 versioned observed evidence JSON을 입력받아 strict schema·secret 검사를 수행하고, target/image evidence를 `operator_observed` source로 기록한다.
- Gate가 직접 관측하는 audit checkout HEAD/detached/dirty 상태, revision blob과 working-tree runtime file hash, read-only capture 결과는 caller evidence와 별도 namespace에 기록한다.
- 개별 record는 versioned schema, operation metadata, observed evidence, checkout/runtime/capture 결과와 gate verdict를 포함하고 checkout 밖에 exclusive-create read-only file로 저장한다.
- Gate CLI는 record를 생성한 직후 raw file bytes의 SHA-256과 canonical path를 machine-readable receipt로 출력한다. 신규 pair verification CLI는 운영 계층이 보존한 PRE/POST canonical path와 expected SHA-256을 명시적으로 받아 검증한다.
- Pair validator는 두 record가 같은 schema version, operation ID, instance, target kind/ID를 가지며 각각 성공한 pre/post record이고 current raw bytes가 expected SHA-256과 같을 때만 versioned immutable manifest를 생성한다.
- Pair manifest는 두 input의 canonical path·verified SHA-256·phase·command class·observed image identity와 검증 시각을 저장한다. Image 변경과 command-class transition의 운영 정책은 판정하지 않는다.
- Dirty isolation은 modified/untracked regular file과 symlink를 checkout 밖에 보존하고 source/copied type·size·hash와 `matches`를 versioned manifest에 기록한다. Source bytes를 삭제·reset·checkout하지 않는다.
- Existing read-only capture allowlist, broker log 원문 비저장, actor/reason secret-like material 거부와 audit checkout 밖 output 원칙을 유지한다.
- `docs/operations.md`는 trex-core gate가 runtime inspect나 lifecycle command를 수행하지 않으며 운영 계층이 observed evidence 생성과 non-zero 결과의 enforcement를 소유한다고 명시한다.
- Repository-local tests는 정상·실패·collision·tamper 경계를 검증한다.
## Constraints
모든 schema object는 아래에 명시된 key만 허용하고 unknown/duplicate key를 거부한다. `sha256`은 prefix 없는 lowercase 64-hex, `image_id`와 `image_digest`는 `sha256:<64 lowercase hex>`, revision은 lowercase 40-hex, 모든 timestamp는 UTC RFC 3339다. Record·manifest hash는 JSON 재직렬화 값이 아니라 저장된 raw file bytes를 대상으로 한다.
**Observed evidence v1**
- Exact keys: `schema_version`(integer `1`), `target_id`(non-empty string), `target_kind`(non-empty string), `image_id`, `image_digest`, `oci_revision`, `observed_at`.
**Gate record v1**
- Exact top-level keys: `schema_version`(integer `1`), `timestamp_utc`, `actor`, `reason`, `instance`, `command_class`, `operation_id`, `phase`(`pre|post`), `observed_evidence`, `checkout`, `runtime_file_verification`, `state_capture`, `gate`; dirty checkout이면 `dirty_checkout_isolation`을 추가로 허용한다.
- `observed_evidence`: observed evidence v1 exact object에 `source`=`operator_observed`만 추가한다.
- `checkout`: exact keys `path`, `head_revision`, `detached_head`, `git_status_porcelain`, `git_clean`, `dirty_paths`. Pair success에는 `head_revision == observed_evidence.oci_revision`, `detached_head=true`, `git_clean=true`, empty `dirty_paths`가 필요하다.
- `runtime_file_verification`: non-empty list이며 각 item exact keys는 `path`, `path_within_checkout`, `exists`, `current_sha256`, `expected_revision`, `expected_sha256`, `matches_expected_revision`이다. Pair success에는 모든 boolean이 true이고 `expected_revision == observed_evidence.oci_revision`, 두 SHA-256이 같아야 한다.
- `state_capture.command_outputs`: exact keys `pending_slots`, `reconciliation_slots`, `queue_consumers`; 각 output의 exact keys는 `command`, `returncode`, `stdout`, `stdout_format`, `stderr`이며 pair success에는 `returncode=0`이 필요하다. `stdout` payload의 application-owned nested key는 schema 통제 대상이 아니지만 secret sanitization 대상이다.
- `state_capture.broker_side_effect_logs`: `capture_status=captured|captured_empty`이면 exact keys `path`, `exists`, `capture_status`, `file_sha256`, `scanned_lines`, `matches`; `unavailable`이면 exact keys `path`, `exists`, `capture_status`, `matches`; `error`이면 exact keys `path`, `exists`, `capture_status`, `error_type`, `matches`다. Pair success에는 success variant가 필요하고 `matches` item exact keys는 `event`, `line_number`, `line_sha256`이다.
- `gate`: exact keys `passed`, `deployment_blocked`, `runtime_files_verified`, `state_captures_succeeded`, `broker_log_captured`. Pair success에는 각각 `true,false,true,true,true`가 필요하다.
- Gate receipt stdout: exact JSON object `schema_version`(integer `1`), `record_path`(canonical absolute path), `record_sha256`(raw record bytes SHA-256), `gate_passed`(boolean). Failure record를 정상적으로 쓴 경우에도 receipt를 출력하되 CLI는 non-zero다.
**Pair manifest v1**
- Exact top-level keys: `schema_version`(integer `1`), `verified_at`, `operation_id`, `instance`, `target_kind`, `target_id`, `pre`, `post`.
- `pre`/`post` exact keys: `record_path`, `record_sha256`, `phase`, `command_class`, `image_id`, `image_digest`, `oci_revision`, `observed_at`; `phase`는 각 object 이름과 일치해야 한다.
**Isolation manifest v1**
- Exact top-level keys: `schema_version`(integer `1`), `timestamp_utc`, `checkout_dir`, `isolate_dir`, `entries`.
- 각 entry exact keys: `path`, `source_type`, `source_size`, `source_sha256`, `copied_path`, `copied_type`, `copied_size`, `copied_sha256`, `matches`. Type은 `regular|symlink`; symlink size/hash는 target text의 UTF-8 bytes 기준이다.
- Observed evidence schema의 필수 field는 `schema_version`, `target_id`, `target_kind`, `image_id`, `image_digest`, `oci_revision`, `observed_at`이다. Unknown/duplicate key, 누락, 잘못된 type/형식과 secret-like material은 fail-closed한다.
- `schema_version`은 integer `1`, target field는 non-empty string, `image_id`와 `image_digest`는 lowercase `sha256:<64 hex>`, `oci_revision`은 lowercase 40-hex Git commit, `observed_at`은 timezone을 가진 UTC RFC 3339 timestamp다.
- `target_kind`의 의미와 target/runtime 실재 여부는 운영 계층이 소유한다. Core는 Docker·Compose·host runtime을 선택하거나 inspect하지 않고 caller evidence를 직접 관측했다고 표시하지 않는다.
- Gate는 observed `oci_revision`과 audit checkout HEAD가 같아야 하고 runtime file working bytes가 해당 revision Git blob과 같아야 성공한다.
- Observed evidence JSON 자체의 schema·duplicate key·secret validation이 실패하면 해당 입력을 record에 복사하지 않고 record/receipt 없이 non-zero로 종료한다. Evidence validation을 통과한 뒤 checkout/runtime/capture gate가 실패하면 immutable failure record와 receipt를 남긴다.
- Pair validator는 filesystem scan이나 latest-file 선택을 하지 않는다. PRE/POST canonical path와 gate receipt에서 보존한 expected SHA-256은 필수 명시 입력이며, input raw bytes를 다시 hash해 일치해야 한다.
- Pair validator는 실제 deploy/restart가 수행됐는지, image 변경이 허용되는지 또는 command-class transition이 운영 정책에 맞는지 주장하지 않는다.
- Legacy unversioned record를 새 pair success로 암묵 승격하지 않는다. Unsupported schema는 fail-closed한다.
- Record와 manifest는 overwrite하지 않고 exclusive-create하며 성공한 write 뒤 read-only mode로 전환한다.
- Dirty isolation의 hash manifest는 file content를 포함하지 않는다. Unsupported special file, checkout/symlink escape, copy/hash mismatch와 output collision은 성공으로 기록하지 않는다.
- Normative Design Documents: 없음. 이 REQ와 AC-0035~AC-0037이 이번 specification의 normative behavior를 소유한다.
## Non-Goals
- Docker Compose/container 또는 host process 중 canonical production runtime 선택
- Runtime/container/process inspect, 실제 stop/start/recreate/deploy/recovery command 실행
- Lifecycle wrapper, global operation lock, stale-lock 처리와 maintenance orchestration
- Audit checkout worktree 생성·교체·reset, dirty source 삭제 또는 recovery branch commit
- Image transition과 command-class transition의 운영 정책 판정
- Trading/domain contract, 주문 lifecycle, database schema·migration 또는 daemon composition 변경
- 자동 주문·취소, DB recovery, `sync --live`, `verify --live`
- Production deployment, restart 또는 runtime validation 완료 주장
- Registry publication, image signing, SBOM, package visibility 또는 release workflow 변경
+37 -14
View File
@@ -47,12 +47,26 @@ Release tag의 clean checkout에서 `./scripts/build_release_image.sh trex-core:
non-root user 및 container `--version`을 검증한다. 전체 명령과 registry 사용법은
[`docs/ops/release-image-build.md`](ops/release-image-build.md)를 따른다.
### 배포/복구/수동 재기동 provenance gate (issue #87)
### 배포/복구/수동 재기동 provenance 검증 (issue #87)
배포, 복구, 수동 stop/start/recreate 전에 아래 gate를 실행해 **검증 가능한 release record**를 남긴다.
이 저장소는 provenance **검증 contract**만 제공한다. 실행 중 target의 inspect, observed evidence 생성, 대상 선택, 승인, operation lock, stop/start/recreate/deploy, checkout 복구와 non-zero exit enforcement는 운영 계층이 소유한다. Core gate는 runtime을 직접 inspect하거나 lifecycle command를 실행하지 않으며, caller evidence를 `operator_observed`로 명시한다.
운영 계층은 각 PRE/POST 시점에 exact schema의 JSON을 생성한다. Unknown/duplicate key, 형식 오류, timezone 없는 시각과 secret-like material은 record를 만들기 전에 거부된다.
```json
{
"schema_version": 1,
"target_id": "<canonical-runtime-target>",
"target_kind": "<operations-owned-kind>",
"image_id": "sha256:<64-lowercase-hex>",
"image_digest": "sha256:<64-lowercase-hex>",
"oci_revision": "<40-lowercase-hex>",
"observed_at": "2026-09-20T02:00:00Z"
}
```
```bash
# PRE (대상 인스턴스 1개만 지정; POST도 같은 operation-id로 별도 실행)
# PRE: 대상 인스턴스 1개만 지정한다. POST도 같은 operation-id로 별도 실행한다.
python scripts/release_provenance_gate.py \
--actor "<operator>" \
--reason "<ticket-or-incident>" \
@@ -60,8 +74,7 @@ python scripts/release_provenance_gate.py \
--operation-id "<deployment-or-incident-id>" \
--phase "pre" \
--command-class "manual-stop" \
--expected-oci-revision "<40-character-running-image-oci-revision>" \
--image-digest "sha256:<64-hex-running-image-digest>" \
--observed-evidence-file "/absolute/path/pre-observed-evidence.json" \
--runtime-file "trex_core/runtime/daemon.py" \
--runtime-file "trex_core/runtime/cli.py" \
--capture-command "pending_slots=.venv/bin/trex-core slots --status pending --instance <instance_key> --json" \
@@ -69,16 +82,26 @@ python scripts/release_provenance_gate.py \
--capture-command "queue_consumers=rabbitmqctl list_consumers"
```
- gate는 audit checkout이 detached HEAD인지 확인하고 NUL 구분 `git status --porcelain=v1`로 clean 여부를 검사한다. branch checkout 또는 dirty checkout이면 운영 상태 조회 명령도 실행하지 않고 종료 코드 `2`로 중단하며, dirty 변경 파일은 audit checkout 밖의 `<checkout>-release-provenance-isolation/`에 작업별 디렉터리로 복사한다. 우회 옵션은 없다.
- `checkout.head_revision`과 필수 `--expected-oci-revision` 일치 여부를 검사해 audit checkout HEAD가 실행 이미지 OCI revision과 같은지 검증한다.
- 필수 `--image-digest` 형식과 하나 이상의 `--runtime-file` 검증 결과(sha256 + revision 일치 여부)가 JSON record에 저장된다.
- actor/timestamp/instance/operation-id/phase/command-class/reason이 JSON record에 함께 저장되어 PRE/POST 및 stop/start/recreate 감사 추적이 가능하다. actor/reason에 credential 형태의 key/value가 포함되면 기록 전에 거부한다.
- PRE/POST 모두 실행해 재기동 전후의 `pending_slots`, `reconciliation_slots`, `queue_consumers`, broker-side-effect 로그(`order_submit/fill/reject/cancel`)를 자동 캡처한다. 필수 조회 또는 로그 캡처가 누락·실패하면 gate는 실패한다.
- capture command는 위의 인스턴스 지정 `trex-core slots` 두 형식과 `rabbitmqctl list_consumers`만 허용한다. 주문 제출·취소, DB 복구, 자동 sync/verify live 실행은 거부한다.
- broker-side-effect 로그 입력은 기본 `/tmp/trex-core.log`를 사용하며, 로그 파일 위치가 다르면 `--broker-log-path <path>`로 명시한다. record에는 원문 대신 파일 sha256·스캔 행 수와 event 종류·행 번호·행 sha256만 남긴다. 파일을 정상 스캔했지만 일치 이벤트가 없으면 `captured_empty`, 파일 누락·읽기 실패는 각각 `unavailable`·`error`로 기록하며 후자는 gate를 차단한다.
- record와 격리 디렉터리는 audit checkout 밖에 둔다. 기본 record 위치는 `<checkout>-release-provenance/`이며, 충돌 없는 파일명을 exclusive-create하고 read-only mode로 전환해 기존 record를 덮어쓰지 않는다.
- Gate stdout은 `record_path`, raw record bytes의 `record_sha256`, `gate_passed`를 포함한 v1 JSON receipt다. 운영 계층은 PRE/POST receipt를 신뢰 경계 밖에 보존하고 non-zero이면 lifecycle을 실행하지 않는다.
- Gate는 audit checkout이 detached HEAD인지 확인하고 NUL 구분 `git status --porcelain=v1`로 clean 여부를 검사한다. branch checkout 또는 dirty checkout이면 운영 상태 조회 명령도 실행하지 않고 종료 코드 `2`로 중단한다.
- Observed `oci_revision`, audit checkout HEAD와 revision별 runtime-file bytes가 모두 일치해야 한다. Target/image 값은 caller 관측값이며 core가 직접 관측했다고 주장하지 않는다.
- PRE/POST에서 `pending_slots`, `reconciliation_slots`, `queue_consumers`, broker-side-effect 로그를 read-only로 캡처한다. 필수 조회 또는 로그 캡처가 누락·실패하면 gate는 실패한다.
- Capture command는 위의 인스턴스 지정 `trex-core slots` 두 형식과 `rabbitmqctl list_consumers`만 허용한다. 주문 제출·취소, DB 복구 또는 lifecycle 명령은 실행하지 않는다.
- Dirty checkout의 regular file과 symlink는 checkout 밖 `<checkout>-release-provenance-isolation/`에 원본을 변경하지 않고 복사한다. v1 isolation manifest는 path, type, size, source/copy SHA-256과 일치 verdict를 기록하며 unsupported type·escape·copy mismatch는 실패한다.
- Record와 manifest는 audit checkout 밖에 exclusive-create하고 read-only mode로 전환한다. 기존 파일 충돌은 덮어쓰지 않고 실패한다.
재기동 후에는 같은 `--operation-id`, `--phase "post"`, 해당 command class(예: `manual-start`)로 POST record를 추가 저장한다.
재기동 후 운영 계층은 새 observed evidence로 POST gate를 실행한다. 두 receipt의 exact path와 trusted digest를 명시해 pair manifest를 생성한다. Validator는 디렉터리를 scan하거나 latest file을 추정하지 않는다. PRE/POST의 image identity와 command class는 서로 달라도 되지만 operation ID, instance, target kind/ID와 각 record의 success evidence는 일치해야 한다.
```bash
python scripts/verify_release_provenance_pair.py \
--pre-record "<pre-receipt.record_path>" \
--pre-sha256 "<pre-receipt.record_sha256>" \
--post-record "<post-receipt.record_path>" \
--post-sha256 "<post-receipt.record_sha256>" \
--output "/absolute/path/<operation-id>-pair-manifest.json"
```
Pair manifest는 input raw bytes를 trusted digest와 다시 비교한 뒤 exclusive-create/read-only로 저장한다. Digest mismatch, malformed/legacy/failure record, identity mismatch와 output collision은 manifest 없이 non-zero로 종료한다. 이 성공은 provenance record pair의 일관성만 증명하며 실제 lifecycle 실행 여부나 운영 정책 승인을 증명하지 않는다.
---
+20 -8
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shlex
@@ -18,6 +19,7 @@ if __package__ in {None, ""}:
from trex_core.adapters.persistence.ops_checks import (
collect_release_provenance,
isolate_dirty_checkout_files,
load_observed_evidence,
)
_COMMAND_CLASSES = ("deploy", "recovery", "manual-stop", "manual-start", "recreate")
@@ -92,8 +94,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--phase", required=True, choices=("pre", "post"))
parser.add_argument("--command-class", required=True, choices=_COMMAND_CLASSES)
parser.add_argument("--checkout-dir", default=".")
parser.add_argument("--expected-oci-revision", required=True)
parser.add_argument("--image-digest", required=True)
parser.add_argument("--observed-evidence-file", required=True)
parser.add_argument("--runtime-file", action="append", required=True)
parser.add_argument("--broker-log-path", default="/tmp/trex-core.log")
parser.add_argument("--capture-command", action="append", required=True)
@@ -102,6 +103,7 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
try:
observed_evidence = load_observed_evidence(args.observed_evidence_file)
capture_commands: dict[str, list[str]] = {}
for raw in args.capture_command:
key, command = _parse_capture_command(raw)
@@ -136,13 +138,12 @@ def main(argv: list[str] | None = None) -> int:
operation_id=args.operation_id,
phase=args.phase,
checkout_dir=checkout,
expected_oci_revision=args.expected_oci_revision,
image_digest=args.image_digest,
observed_evidence=observed_evidence,
runtime_files=args.runtime_file,
capture_commands=capture_commands,
broker_log_path=args.broker_log_path,
)
except (RuntimeError, ValueError) as exc:
except (RuntimeError, TypeError, ValueError) as exc:
parser.error(str(exc))
output_dir.mkdir(parents=True, exist_ok=True)
@@ -162,10 +163,21 @@ def main(argv: list[str] | None = None) -> int:
)
# Exclusive create prevents a provenance record from being overwritten.
with output_path.open("x", encoding="utf-8") as fp:
fp.write(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
record_bytes = (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
with output_path.open("xb") as fp:
fp.write(record_bytes)
output_path.chmod(0o444)
print(str(output_path))
print(
json.dumps(
{
"schema_version": 1,
"record_path": str(output_path.resolve()),
"record_sha256": hashlib.sha256(record_bytes).hexdigest(),
"gate_passed": bool(report["gate"]["passed"]),
},
separators=(",", ":"),
)
)
if dirty_checkout:
return 2
return 0 if report["gate"]["passed"] else 1
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Verify one explicit release-provenance PRE/POST pair."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from trex_core.adapters.persistence.ops_checks import verify_release_provenance_pair
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pre-record", required=True)
parser.add_argument("--pre-sha256", required=True)
parser.add_argument("--post-record", required=True)
parser.add_argument("--post-sha256", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args(argv)
try:
manifest = verify_release_provenance_pair(
pre_path=args.pre_record,
pre_sha256=args.pre_sha256,
post_path=args.post_record,
post_sha256=args.post_sha256,
output_path=args.output,
)
except (OSError, TypeError, ValueError) as exc:
parser.error(str(exc))
print(
json.dumps(
{
"schema_version": 1,
"manifest_path": str(Path(args.output).resolve()),
"operation_id": manifest["operation_id"],
},
separators=(",", ":"),
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+68 -10
View File
@@ -130,8 +130,15 @@ def _provenance_kwargs(repo: Path, head: str) -> dict:
"operation_id": "op-1",
"phase": "pre",
"checkout_dir": repo,
"expected_oci_revision": head,
"observed_evidence": {
"schema_version": 1,
"target_id": "service-a",
"target_kind": "compose-service",
"image_id": "sha256:" + ("b" * 64),
"image_digest": "sha256:" + ("a" * 64),
"oci_revision": head,
"observed_at": "2026-09-20T02:00:00Z",
},
"runtime_files": ["tracked.txt"],
"capture_commands": {
"pending_slots": [
@@ -151,7 +158,7 @@ def test_collect_release_provenance_passes_on_clean_checkout(tmp_path):
report = collect_release_provenance(**_provenance_kwargs(repo, head))
assert report["checkout"]["git_clean"] is True
assert report["checkout"]["detached_head"] is True
assert report["artifact_identity"]["oci_revision_matches_head"] is True
assert report["observed_evidence"]["source"] == "operator_observed"
assert report["runtime_file_verification"][0]["matches_expected_revision"] is True
assert report["gate"]["passed"] is True
assert report["operation_id"] == "op-1"
@@ -181,7 +188,7 @@ def test_collect_release_provenance_blocks_on_revision_or_runtime_mismatch(tmp_p
kwargs = _provenance_kwargs(repo, old_head)
report = collect_release_provenance(**kwargs)
assert report["checkout"]["git_clean"] is True
assert report["artifact_identity"]["oci_revision_matches_head"] is False
assert report["checkout"]["head_revision"] != report["observed_evidence"]["oci_revision"]
assert report["runtime_file_verification"][0]["matches_expected_revision"] is False
assert report["gate"]["passed"] is False
@@ -192,13 +199,58 @@ def test_isolate_dirty_checkout_files_copies_dirty_and_untracked(tmp_path):
(repo / "new.txt").write_text("new\n", encoding="utf-8")
isolated = tmp_path / "isolated"
manifest = isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated)
assert set(manifest["dirty_paths"]) == {"tracked.txt", "new.txt"}
assert set(manifest["copied_paths"]) == {"tracked.txt", "new.txt"}
entries = {entry["path"]: entry for entry in manifest["entries"]}
assert set(entries) == {"tracked.txt", "new.txt"}
assert all(entry["source_type"] == "regular" for entry in entries.values())
assert all(entry["matches"] is True for entry in entries.values())
snapshot = Path(manifest["isolate_dir"])
assert snapshot.parent == isolated
assert (snapshot / "tracked.txt").read_text(encoding="utf-8") == "dirty\n"
assert (snapshot / "new.txt").read_text(encoding="utf-8") == "new\n"
assert (snapshot / "manifest.json").is_file()
assert (snapshot / "manifest.json").stat().st_mode & 0o222 == 0
@pytest.mark.parametrize("target_kind", ["absolute", "relative_escape"])
def test_isolate_dirty_checkout_files_rejects_symlink_escape_without_mutating_source(
tmp_path, target_kind
):
repo, _ = _init_git_repo(tmp_path)
outside = tmp_path / "outside.txt"
outside.write_text("outside\n", encoding="utf-8")
link = repo / "escape-link"
target = str(outside) if target_kind == "absolute" else "../outside.txt"
link.symlink_to(target)
isolated = tmp_path / "isolated"
with pytest.raises(ValueError, match="symlink target escapes checkout"):
isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated)
assert link.is_symlink()
assert link.readlink() == Path(target)
assert outside.read_text(encoding="utf-8") == "outside\n"
if isolated.exists():
assert not list(isolated.glob("*/manifest.json"))
def test_isolate_dirty_checkout_files_preserves_internal_relative_symlink(tmp_path):
repo, _ = _init_git_repo(tmp_path)
nested = repo / "nested"
nested.mkdir()
(nested / "target.txt").write_text("target\n", encoding="utf-8")
link = repo / "internal-link"
link.symlink_to("nested/target.txt")
manifest = isolate_dirty_checkout_files(
checkout_dir=repo, isolate_dir=tmp_path / "isolated"
)
entry = next(item for item in manifest["entries"] if item["path"] == "internal-link")
copied = Path(manifest["isolate_dir"]) / "internal-link"
assert entry["source_type"] == "symlink"
assert entry["copied_type"] == "symlink"
assert entry["matches"] is True
assert copied.readlink() == Path("nested/target.txt")
assert link.readlink() == Path("nested/target.txt")
def test_collect_release_provenance_rejects_runtime_path_escape(tmp_path):
@@ -226,8 +278,15 @@ def test_collect_release_provenance_reports_git_failure_context(tmp_path):
operation_id="op-1",
phase="pre",
checkout_dir=not_repo,
expected_oci_revision="0" * 40,
image_digest="sha256:" + ("a" * 64),
observed_evidence={
"schema_version": 1,
"target_id": "service-a",
"target_kind": "compose-service",
"image_id": "sha256:" + ("b" * 64),
"image_digest": "sha256:" + ("a" * 64),
"oci_revision": "0" * 40,
"observed_at": "2026-09-20T02:00:00Z",
},
runtime_files=["tracked.txt"],
capture_commands={
"pending_slots": [
@@ -291,8 +350,7 @@ def test_empty_broker_log_is_explicitly_captured_as_no_matching_events(tmp_path)
@pytest.mark.parametrize(
("field", "value"),
[
("expected_oci_revision", None),
("image_digest", "not-a-digest"),
("observed_evidence", {}),
("runtime_files", []),
("capture_commands", {}),
("instance", "../escape"),
@@ -0,0 +1,188 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
from trex_core.adapters.persistence.ops_checks import (
load_observed_evidence,
verify_release_provenance_pair,
)
DIGEST_A = "sha256:" + "a" * 64
DIGEST_B = "sha256:" + "b" * 64
REVISION = "c" * 40
def _evidence() -> dict:
return {
"schema_version": 1,
"target_id": "service-a",
"target_kind": "compose-service",
"image_id": DIGEST_A,
"image_digest": DIGEST_B,
"oci_revision": REVISION,
"observed_at": "2026-09-20T02:00:00Z",
}
def test_load_observed_evidence_accepts_exact_v1(tmp_path: Path):
path = tmp_path / "evidence.json"
path.write_text(json.dumps(_evidence()), encoding="utf-8")
assert load_observed_evidence(path) == _evidence()
@pytest.mark.parametrize(
"raw",
[
'{"schema_version":1,"schema_version":1}',
json.dumps({**_evidence(), "unknown": True}),
json.dumps({**_evidence(), "target_id": ""}),
json.dumps({**_evidence(), "schema_version": True}),
json.dumps({**_evidence(), "image_id": "SHA256:" + "A" * 64}),
json.dumps({**_evidence(), "observed_at": "2026-09-20T02:00:00+09:00"}),
json.dumps({**_evidence(), "target_id": "token=must-not-leak"}),
],
)
def test_load_observed_evidence_rejects_invalid_or_sensitive_input(tmp_path: Path, raw: str):
path = tmp_path / "evidence.json"
path.write_text(raw, encoding="utf-8")
with pytest.raises(ValueError):
load_observed_evidence(path)
def _record(*, phase: str, operation_id: str = "op-1", gate_passed: bool = True) -> dict:
return {
"schema_version": 1,
"timestamp_utc": "2026-09-20T02:00:00Z",
"actor": "operator-a",
"reason": "release",
"instance": "instance-a",
"command_class": "deploy" if phase == "pre" else "recreate",
"operation_id": operation_id,
"phase": phase,
"observed_evidence": {**_evidence(), "source": "operator_observed"},
"checkout": {
"path": "/audit/checkout",
"head_revision": REVISION,
"detached_head": True,
"git_status_porcelain": [],
"git_clean": True,
"dirty_paths": [],
},
"runtime_file_verification": [
{
"path": "runtime.py",
"path_within_checkout": True,
"exists": True,
"current_sha256": "d" * 64,
"expected_revision": REVISION,
"expected_sha256": "d" * 64,
"matches_expected_revision": True,
}
],
"state_capture": {
"command_outputs": {
key: {
"command": ["read-only"],
"returncode": 0,
"stdout": {},
"stdout_format": "json",
"stderr": "",
}
for key in ("pending_slots", "reconciliation_slots", "queue_consumers")
},
"broker_side_effect_logs": {
"path": "/tmp/broker.log",
"exists": True,
"capture_status": "captured_empty",
"file_sha256": "e" * 64,
"scanned_lines": 0,
"matches": [],
},
},
"gate": {
"passed": gate_passed,
"deployment_blocked": not gate_passed,
"runtime_files_verified": True,
"state_captures_succeeded": True,
"broker_log_captured": True,
},
}
def _write_record(path: Path, record: dict) -> str:
path.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8")
return hashlib.sha256(path.read_bytes()).hexdigest()
def test_pair_validator_binds_explicit_paths_and_expected_digests(tmp_path: Path):
pre = tmp_path / "pre.json"
post = tmp_path / "post.json"
pre_sha = _write_record(pre, _record(phase="pre"))
post_sha = _write_record(post, _record(phase="post"))
output = tmp_path / "manifests" / "pair.json"
manifest = verify_release_provenance_pair(
pre_path=pre,
pre_sha256=pre_sha,
post_path=post,
post_sha256=post_sha,
output_path=output,
)
assert manifest["pre"]["record_path"] == str(pre.resolve())
assert manifest["post"]["record_sha256"] == post_sha
assert output.stat().st_mode & 0o222 == 0
@pytest.mark.parametrize("mutation", ["tamper", "failed", "mismatch"])
def test_pair_validator_fails_without_manifest(tmp_path: Path, mutation: str):
pre = tmp_path / "pre.json"
post = tmp_path / "post.json"
pre_sha = _write_record(pre, _record(phase="pre"))
post_record = _record(phase="post")
if mutation == "failed":
post_record["gate"]["passed"] = False
post_record["gate"]["deployment_blocked"] = True
if mutation == "mismatch":
post_record["operation_id"] = "op-2"
post_sha = _write_record(post, post_record)
if mutation == "tamper":
post.write_text(post.read_text() + " ", encoding="utf-8")
output = tmp_path / "pair.json"
with pytest.raises(ValueError):
verify_release_provenance_pair(
pre_path=pre,
pre_sha256=pre_sha,
post_path=post,
post_sha256=post_sha,
output_path=output,
)
assert not output.exists()
def test_pair_validator_does_not_overwrite_existing_output(tmp_path: Path):
pre = tmp_path / "pre.json"
post = tmp_path / "post.json"
pre_sha = _write_record(pre, _record(phase="pre"))
post_sha = _write_record(post, _record(phase="post"))
output = tmp_path / "pair.json"
output.write_text("keep", encoding="utf-8")
with pytest.raises(FileExistsError):
verify_release_provenance_pair(
pre_path=pre,
pre_sha256=pre_sha,
post_path=post,
post_sha256=post_sha,
output_path=output,
)
assert output.read_text(encoding="utf-8") == "keep"
+43 -6
View File
@@ -26,7 +26,7 @@ def test_documented_python_invocation_loads_project_without_pythonpath():
)
assert completed.returncode == 0, completed.stderr
assert "--expected-oci-revision" in completed.stdout
assert "--observed-evidence-file" in completed.stdout
assert script.stat().st_mode & 0o111 != 0
@@ -62,6 +62,21 @@ def _argv(tmp_path: Path, repo: Path, head: str) -> list[str]:
trex, rabbit = _fake_read_only_commands(tmp_path)
broker_log = tmp_path / "broker.log"
broker_log.write_text("event=order_submit account=must-not-leak\n", encoding="utf-8")
evidence = tmp_path / "observed-evidence.json"
evidence.write_text(
json.dumps(
{
"schema_version": 1,
"target_id": "service-a",
"target_kind": "compose-service",
"image_id": "sha256:" + ("b" * 64),
"image_digest": "sha256:" + ("a" * 64),
"oci_revision": head,
"observed_at": "2026-09-20T02:00:00Z",
}
),
encoding="utf-8",
)
return [
"--actor", "operator-a",
"--reason", "deploy release",
@@ -70,8 +85,7 @@ def _argv(tmp_path: Path, repo: Path, head: str) -> list[str]:
"--phase", "pre",
"--command-class", "deploy",
"--checkout-dir", str(repo),
"--expected-oci-revision", head,
"--image-digest", "sha256:" + ("a" * 64),
"--observed-evidence-file", str(evidence),
"--runtime-file", "runtime.py",
"--broker-log-path", str(broker_log),
"--capture-command", f"pending_slots={trex} slots --status pending --instance inst-a --json",
@@ -85,15 +99,20 @@ def test_cli_writes_unique_record_outside_checkout_and_keeps_checkout_clean(tmp_
argv = _argv(tmp_path, repo, head)
assert release_provenance_gate.main(argv) == 0
first = Path(capsys.readouterr().out.strip())
first_receipt = json.loads(capsys.readouterr().out)
first = Path(first_receipt["record_path"])
assert release_provenance_gate.main(argv) == 0
second = Path(capsys.readouterr().out.strip())
second_receipt = json.loads(capsys.readouterr().out)
second = Path(second_receipt["record_path"])
assert first != second
assert first.parent == tmp_path / "repo-release-provenance"
assert first.stat().st_mode & 0o222 == 0
assert second.stat().st_mode & 0o222 == 0
payload = json.loads(first.read_text(encoding="utf-8"))
assert payload["schema_version"] == 1
assert payload["observed_evidence"]["source"] == "operator_observed"
assert first_receipt["record_sha256"] == __import__("hashlib").sha256(first.read_bytes()).hexdigest()
assert payload["gate"]["passed"] is True
assert "must-not-leak" not in first.read_text(encoding="utf-8")
status = subprocess.run(
@@ -108,7 +127,8 @@ def test_cli_dirty_checkout_is_fail_closed_and_isolated(tmp_path, capsys):
(repo / "runtime.py").write_text("dirty\n", encoding="utf-8")
assert release_provenance_gate.main(argv) == 2
output = Path(capsys.readouterr().out.strip())
receipt = json.loads(capsys.readouterr().out)
output = Path(receipt["record_path"])
payload = json.loads(output.read_text(encoding="utf-8"))
assert output.stat().st_mode & 0o222 == 0
assert payload["gate"]["passed"] is False
@@ -131,3 +151,20 @@ def test_cli_rejects_unsafe_command_and_checkout_local_output(tmp_path):
argv.extend(["--output-dir", str(repo / "artifacts")])
with pytest.raises(SystemExit):
release_provenance_gate.main(argv)
def test_invalid_observed_evidence_creates_no_record_or_receipt(tmp_path, capsys):
repo, head = _init_repo(tmp_path)
argv = _argv(tmp_path, repo, head)
evidence_index = argv.index("--observed-evidence-file") + 1
evidence = Path(argv[evidence_index])
evidence.write_text(
'{"schema_version":1,"schema_version":1,"target_id":"service-a"}',
encoding="utf-8",
)
with pytest.raises(SystemExit):
release_provenance_gate.main(argv)
assert capsys.readouterr().out == ""
assert not (tmp_path / "repo-release-provenance").exists()
+381 -25
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
@@ -68,10 +69,39 @@ _SENSITIVE_VALUE = re.compile(
)
_FULL_GIT_REVISION = re.compile(r"[0-9a-f]{40}")
_IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
_SHA256 = re.compile(r"[0-9a-f]{64}")
_SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
_REQUIRED_CAPTURE_KEYS = frozenset(
{"pending_slots", "reconciliation_slots", "queue_consumers"}
)
_OBSERVED_EVIDENCE_KEYS = frozenset(
{
"schema_version",
"target_id",
"target_kind",
"image_id",
"image_digest",
"oci_revision",
"observed_at",
}
)
_RECORD_KEYS = frozenset(
{
"schema_version",
"timestamp_utc",
"actor",
"reason",
"instance",
"command_class",
"operation_id",
"phase",
"observed_evidence",
"checkout",
"runtime_file_verification",
"state_capture",
"gate",
}
)
def _run_git(checkout_dir: Path, *args: str) -> str:
@@ -153,6 +183,80 @@ def _sanitize_json(value: object) -> object:
return value
def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate JSON key: {key}")
result[key] = value
return result
def _load_strict_json(path: str | Path) -> dict[str, object]:
try:
value = json.loads(
Path(path).read_text(encoding="utf-8"),
object_pairs_hook=_reject_duplicate_keys,
)
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ValueError("invalid JSON input") from exc
if not isinstance(value, dict):
raise TypeError("JSON input must be an object")
return value
def _is_utc_timestamp(value: object) -> bool:
if not isinstance(value, str):
return False
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return False
return parsed.tzinfo is not None and parsed.utcoffset() == datetime.now(tz=UTC).utcoffset()
def _contains_sensitive_material(value: object) -> bool:
if isinstance(value, dict):
return any(
_SENSITIVE_KEY.search(str(key)) or _contains_sensitive_material(item)
for key, item in value.items()
)
if isinstance(value, list):
return any(_contains_sensitive_material(item) for item in value)
return isinstance(value, str) and _sanitize_text(value) != value
def _validate_observed_evidence(value: object) -> dict[str, object]:
if not isinstance(value, dict) or set(value) != _OBSERVED_EVIDENCE_KEYS:
raise ValueError("observed evidence keys must match schema version 1")
if type(value["schema_version"]) is not int or value["schema_version"] != 1:
raise ValueError("observed evidence schema_version must be integer 1")
if not all(
isinstance(value[key], str) and bool(value[key])
for key in ("target_id", "target_kind")
):
raise ValueError("observed target fields must be non-empty strings")
if not all(
isinstance(value[key], str) and _IMAGE_DIGEST.fullmatch(value[key])
for key in ("image_id", "image_digest")
):
raise ValueError("observed image identity must use lowercase sha256 digests")
if not isinstance(value["oci_revision"], str) or not _FULL_GIT_REVISION.fullmatch(
value["oci_revision"]
):
raise ValueError("observed oci_revision must be a full lowercase git SHA")
if not _is_utc_timestamp(value["observed_at"]):
raise ValueError("observed_at must be a UTC RFC 3339 timestamp")
if _contains_sensitive_material(value):
raise ValueError("observed evidence must not contain sensitive material")
return dict(value)
def load_observed_evidence(path: str | Path) -> dict[str, object]:
"""Load strict caller-observed provenance without claiming runtime observation."""
return _validate_observed_evidence(_load_strict_json(path))
def _validate_capture_commands(
capture_commands: dict[str, list[str]], *, instance: str
) -> None:
@@ -297,8 +401,7 @@ def collect_release_provenance(
command_class: str,
operation_id: str,
phase: str,
expected_oci_revision: str,
image_digest: str,
observed_evidence: dict[str, object],
runtime_files: list[str],
capture_commands: dict[str, list[str]],
checkout_dir: str | Path = ".",
@@ -313,10 +416,8 @@ def collect_release_provenance(
raise ValueError("instance and operation_id must be safe identifiers")
if phase not in {"pre", "post"}:
raise ValueError("phase must be pre or post")
if not _FULL_GIT_REVISION.fullmatch(expected_oci_revision):
raise ValueError("expected_oci_revision must be a full lowercase git SHA")
if not _IMAGE_DIGEST.fullmatch(image_digest):
raise ValueError("image_digest must be sha256:<64 lowercase hex>")
observed = _validate_observed_evidence(observed_evidence)
expected_oci_revision = str(observed["oci_revision"])
if not runtime_files:
raise ValueError("at least one runtime file is required")
_validate_capture_commands(capture_commands, instance=instance)
@@ -378,6 +479,7 @@ def collect_release_provenance(
and broker_logs_ok
)
return {
"schema_version": 1,
"timestamp_utc": datetime.now(tz=UTC).isoformat(),
"actor": actor,
"reason": reason,
@@ -393,11 +495,7 @@ def collect_release_provenance(
"git_clean": git_clean,
"dirty_paths": dirty_paths,
},
"artifact_identity": {
"expected_oci_revision": expected_oci_revision,
"oci_revision_matches_head": oci_revision_matches_head,
"image_digest": image_digest,
},
"observed_evidence": {**observed, "source": "operator_observed"},
"runtime_file_verification": runtime_reports,
"state_capture": {
"command_outputs": command_outputs,
@@ -413,38 +511,296 @@ def collect_release_provenance(
}
def _exact_object(value: object, keys: set[str] | frozenset[str], label: str) -> dict:
if not isinstance(value, dict) or set(value) != set(keys):
raise ValueError(f"{label} keys do not match schema version 1")
return value
def _validate_success_record(record: dict[str, object], *, phase: str) -> dict[str, object]:
_exact_object(record, _RECORD_KEYS, "gate record")
if type(record["schema_version"]) is not int or record["schema_version"] != 1:
raise ValueError("unsupported gate record schema")
if record["phase"] != phase:
raise ValueError(f"record phase must be {phase}")
if not _is_utc_timestamp(record["timestamp_utc"]):
raise ValueError("record timestamp must be UTC RFC 3339")
for key in ("actor", "reason", "instance", "command_class", "operation_id"):
if not isinstance(record[key], str) or not record[key]:
raise ValueError(f"record {key} must be a non-empty string")
observed = _exact_object(
record["observed_evidence"], _OBSERVED_EVIDENCE_KEYS | {"source"}, "observed evidence"
)
if observed["source"] != "operator_observed":
raise ValueError("observed evidence source must be operator_observed")
_validate_observed_evidence({key: observed[key] for key in _OBSERVED_EVIDENCE_KEYS})
checkout = _exact_object(
record["checkout"],
{
"path",
"head_revision",
"detached_head",
"git_status_porcelain",
"git_clean",
"dirty_paths",
},
"checkout",
)
if not (
checkout["detached_head"] is True
and checkout["git_clean"] is True
and checkout["dirty_paths"] == []
and checkout["head_revision"] == observed["oci_revision"]
):
raise ValueError("checkout evidence does not satisfy pair success invariants")
runtime_reports = record["runtime_file_verification"]
runtime_keys = {
"path",
"path_within_checkout",
"exists",
"current_sha256",
"expected_revision",
"expected_sha256",
"matches_expected_revision",
}
if not isinstance(runtime_reports, list) or not runtime_reports:
raise ValueError("runtime verification must be a non-empty list")
for item in runtime_reports:
report = _exact_object(item, runtime_keys, "runtime verification")
if not (
isinstance(report["path"], str)
and report["path"]
and report["path_within_checkout"] is True
and report["exists"] is True
and report["matches_expected_revision"] is True
and report["expected_revision"] == observed["oci_revision"]
and isinstance(report["current_sha256"], str)
and _SHA256.fullmatch(report["current_sha256"])
and report["current_sha256"] == report["expected_sha256"]
):
raise ValueError("runtime verification does not satisfy pair success invariants")
state_capture = _exact_object(
record["state_capture"], {"command_outputs", "broker_side_effect_logs"}, "state capture"
)
command_outputs = _exact_object(
state_capture["command_outputs"], _REQUIRED_CAPTURE_KEYS, "command outputs"
)
output_keys = {"command", "returncode", "stdout", "stdout_format", "stderr"}
for output in command_outputs.values():
captured = _exact_object(output, output_keys, "command output")
if captured["returncode"] != 0 or type(captured["returncode"]) is not int:
raise ValueError("state capture command did not succeed")
broker = state_capture["broker_side_effect_logs"]
broker_success_keys = {
"path",
"exists",
"capture_status",
"file_sha256",
"scanned_lines",
"matches",
}
broker = _exact_object(broker, broker_success_keys, "broker capture")
if broker["capture_status"] not in {"captured", "captured_empty"}:
raise ValueError("broker capture did not succeed")
gate = _exact_object(
record["gate"],
{
"passed",
"deployment_blocked",
"runtime_files_verified",
"state_captures_succeeded",
"broker_log_captured",
},
"gate",
)
if [
gate[key]
for key in (
"passed",
"deployment_blocked",
"runtime_files_verified",
"state_captures_succeeded",
"broker_log_captured",
)
] != [True, False, True, True, True]:
raise ValueError("gate summary does not satisfy pair success invariants")
return record
def _record_digest(path: Path, expected: str) -> str:
if not _SHA256.fullmatch(expected):
raise ValueError("expected record digest must be lowercase SHA-256")
try:
payload = path.read_bytes()
except OSError as exc:
raise ValueError("record path is not readable") from exc
actual = _sha256_bytes(payload)
if actual != expected:
raise ValueError("record bytes do not match expected SHA-256")
return actual
def verify_release_provenance_pair(
*,
pre_path: str | Path,
pre_sha256: str,
post_path: str | Path,
post_sha256: str,
output_path: str | Path,
) -> dict[str, object]:
"""Verify one explicit PRE/POST pair and write an immutable manifest."""
pre = Path(pre_path)
post = Path(post_path)
if not pre.is_absolute() or not post.is_absolute():
raise ValueError("record paths must be canonical absolute paths")
pre = pre.resolve()
post = post.resolve()
if pre == post:
raise ValueError("PRE and POST record paths must differ")
pre_digest = _record_digest(pre, pre_sha256)
post_digest = _record_digest(post, post_sha256)
pre_record = _validate_success_record(_load_strict_json(pre), phase="pre")
post_record = _validate_success_record(_load_strict_json(post), phase="post")
for key in ("schema_version", "operation_id", "instance"):
if pre_record[key] != post_record[key]:
raise ValueError(f"PRE/POST {key} mismatch")
pre_observed = pre_record["observed_evidence"]
post_observed = post_record["observed_evidence"]
for key in ("target_kind", "target_id"):
if pre_observed[key] != post_observed[key]:
raise ValueError(f"PRE/POST {key} mismatch")
destination = Path(output_path).resolve()
for checkout_path in (pre_record["checkout"]["path"], post_record["checkout"]["path"]):
checkout = Path(checkout_path).resolve()
try:
destination.relative_to(checkout)
except ValueError:
continue
raise ValueError("pair manifest must be outside the audit checkout")
def manifest_record(
path: Path, digest: str, record: dict[str, object], observed: dict[str, object]
) -> dict[str, object]:
return {
"record_path": str(path),
"record_sha256": digest,
"phase": record["phase"],
"command_class": record["command_class"],
"image_id": observed["image_id"],
"image_digest": observed["image_digest"],
"oci_revision": observed["oci_revision"],
"observed_at": observed["observed_at"],
}
manifest = {
"schema_version": 1,
"verified_at": datetime.now(tz=UTC).isoformat(),
"operation_id": pre_record["operation_id"],
"instance": pre_record["instance"],
"target_kind": pre_observed["target_kind"],
"target_id": pre_observed["target_id"],
"pre": manifest_record(pre, pre_digest, pre_record, pre_observed),
"post": manifest_record(post, post_digest, post_record, post_observed),
}
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("x", encoding="utf-8") as fp:
fp.write(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
destination.chmod(0o444)
return manifest
def isolate_dirty_checkout_files(
*,
checkout_dir: str | Path,
isolate_dir: str | Path,
) -> dict:
"""Copy dirty/untracked checkout files to an isolation directory."""
"""Copy dirty files without mutating the checkout and verify copy integrity."""
checkout = Path(checkout_dir).resolve()
dst_root = Path(isolate_dir).resolve()
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S.%fZ")
dst = dst_root / f"{stamp}--{uuid.uuid4().hex[:12]}"
_, dirty_paths = _git_status(checkout)
copied: list[str] = []
entries: list[dict[str, object]] = []
for rel in dirty_paths:
src = _resolve_under(checkout, rel)
if src is None or not src.exists() or src.is_dir():
continue
target = (dst / rel).resolve()
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
raise ValueError(f"unsafe dirty path: {rel}")
src = checkout / rel_path
try:
target.relative_to(dst)
except ValueError:
continue
src.parent.resolve().relative_to(checkout)
except ValueError as exc:
raise ValueError(f"dirty path parent escapes checkout: {rel}") from exc
if src.is_symlink():
source_type = "symlink"
source_target = os.readlink(src)
source_target_path = Path(source_target)
if source_target_path.is_absolute():
raise ValueError(f"symlink target escapes checkout: {rel}")
resolved_source_target = (src.parent / source_target_path).resolve(strict=False)
try:
resolved_source_target.relative_to(checkout)
except ValueError as exc:
raise ValueError(f"symlink target escapes checkout: {rel}") from exc
source_bytes = source_target.encode("utf-8")
target = dst / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
copied.append(rel)
os.symlink(source_target, target)
copied_type = "symlink" if target.is_symlink() else "unsupported"
copied_bytes = os.readlink(target).encode("utf-8") if target.is_symlink() else b""
elif src.is_file():
source_type = "regular"
source_bytes = src.read_bytes()
target = dst / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target, follow_symlinks=False)
copied_type = "regular" if target.is_file() and not target.is_symlink() else "unsupported"
copied_bytes = target.read_bytes() if copied_type == "regular" else b""
else:
raise ValueError(f"unsupported dirty path type: {rel}")
source_sha = _sha256_bytes(source_bytes)
copied_sha = _sha256_bytes(copied_bytes)
matches = (
copied_type == source_type
and len(copied_bytes) == len(source_bytes)
and copied_sha == source_sha
)
if not matches:
raise ValueError(f"isolated copy integrity mismatch: {rel}")
entries.append(
{
"path": rel,
"source_type": source_type,
"source_size": len(source_bytes),
"source_sha256": source_sha,
"copied_path": str(target.resolve(strict=False)) if source_type == "regular" else str(target),
"copied_type": copied_type,
"copied_size": len(copied_bytes),
"copied_sha256": copied_sha,
"matches": True,
}
)
manifest = {
"schema_version": 1,
"timestamp_utc": datetime.now(tz=UTC).isoformat(),
"checkout_dir": str(checkout),
"isolate_dir": str(dst),
"dirty_paths": dirty_paths,
"copied_paths": copied,
"entries": entries,
}
dst.mkdir(parents=True, exist_ok=True)
with (dst / "manifest.json").open("x", encoding="utf-8") as fp:
manifest_path = dst / "manifest.json"
with manifest_path.open("x", encoding="utf-8") as fp:
fp.write(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
manifest_path.chmod(0o444)
return manifest