diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 20698f791..6562244f6 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -12,18 +12,28 @@ on: paths: - '.github/workflows/release-desktop.yml' - 'cmd/packagede2e/**' + - 'cmd/producte2e/**' + - 'cmd/releasegate/**' - 'cmd/releasegen/**' - 'internal/packagede2e/**' + - 'internal/producte2e/**' + - 'internal/releasegate/**' - 'scripts/build-desktop.ps1' - 'scripts/check-windows-compat.ps1' - 'scripts/package-portable-zip.ps1' - 'scripts/release-desktop.ps1' - 'scripts/smoke-desktop-operator-preview.ps1' - 'scripts/standard-code-packaged-e2e.ps1' + - 'scripts/standard-code-product-e2e.ps1' - 'scripts/verify-portable-zip.ps1' - 'docs/standard-code-packaged-e2e.md' + - 'docs/standard-code-product-e2e.md' + - 'docs/standard-code-release-gate.md' - 'docs/adr/0138-standard-code-packaged-e2e-foundation.md' + - 'docs/adr/0144-standard-code-release-gate-aggregation.md' + - 'docs/convergence/protocol-registry.md' - 'packaging/windows/**' + - 'protocols/registry.json' - 'go.mod' - 'go.sum' - 'web/package.json' @@ -33,11 +43,13 @@ on: - 'analyzers/rust-toolchain.toml' - 'internal/analyzer/embedded/**' - 'README.md' + - 'README.en.md' push: tags: - 'v*' permissions: + actions: read contents: read concurrency: @@ -54,6 +66,31 @@ jobs: with: fetch-depth: 0 + - name: Require successful central CI for the exact release revision + if: ${{ github.event_name != 'pull_request' }} + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $revision = (git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $revision -notmatch '^[0-9a-f]{40}$') { + throw "Release CI revision is invalid" + } + $endpoint = "repos/$env:GITHUB_REPOSITORY/actions/workflows/ci.yml/runs" + + "?head_sha=$revision&event=push&status=completed&per_page=100" + $response = gh api $endpoint + if ($LASTEXITCODE -ne 0) { throw "Central CI provenance lookup failed" } + $runs = ($response | Out-String) | ConvertFrom-Json + $successful = @($runs.workflow_runs | Where-Object { + [string]$_.head_sha -ceq $revision -and + [string]$_.event -ceq "push" -and + [string]$_.status -ceq "completed" -and + [string]$_.conclusion -ceq "success" + }) + if ($successful.Count -lt 1) { + throw "The exact release revision has no successful central CI push run" + } + - name: Set up pinned Go 1.25.12 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -131,14 +168,17 @@ jobs: foreach ($part in @($Matches.major, $Matches.minor, $Matches.patch)) { if ([int64]$part -gt 65535) { throw "MSIX release version component is too large" } } + if ($env:EVENT_NAME -ne "pull_request" -and -not $version.Contains("-")) { + throw "Standard Code Beta releases must use a prerelease version" + } $msixVersion = "$($Matches.major).$($Matches.minor).$($Matches.patch).0" $revision = (git rev-parse HEAD).Trim() if ($LASTEXITCODE -ne 0 -or $revision -notmatch '^[0-9a-f]{40}$') { throw "Release revision is invalid" } - if ($env:EVENT_NAME -eq "push") { + if ($env:EVENT_NAME -ne "pull_request") { git merge-base --is-ancestor $revision origin/main - if ($LASTEXITCODE -ne 0) { throw "Release tag is not reachable from origin/main" } + if ($LASTEXITCODE -ne 0) { throw "Release revision is not reachable from origin/main" } } $artifactVersion = $version -replace '[^0-9A-Za-z._-]', '_' "version=$version" >> $env:GITHUB_OUTPUT @@ -163,6 +203,9 @@ jobs: shell: pwsh run: rustup toolchain install 1.97.1 --profile minimal + - name: Verify fail-closed Standard Code release-gate contract + run: go test -count=1 ./cmd/releasegate ./internal/releasegate ./internal/producte2e ./internal/packagede2e + - name: Build Desktop twice and compare bytes shell: pwsh run: ./scripts/build-desktop.ps1 -Version '${{ steps.release.outputs.version }}' -VerifyReproducible @@ -179,13 +222,72 @@ jobs: -ExpectedRevision '${{ steps.release.outputs.revision }}' ` -SmokeTest - - name: Exercise packaged Standard Code bootstrap and prepare attack matrix + - name: Validate Draft Release evidence intake + if: ${{ github.event_name != 'pull_request' }} + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + $release = gh release view $env:RELEASE_VERSION ` + --repo $env:GITHUB_REPOSITORY ` + --json isDraft,tagName,assets | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or -not [bool]$release.isDraft -or + [string]$release.tagName -cne $env:RELEASE_VERSION) { + throw "A matching Draft Release is required for candidate-bound evidence intake" + } + $required = @( + "standard-code-product-e2e.json", + "standard-code-security-evidence.json" + ) + $assets = @($release.assets) + foreach ($name in $required) { + $matches = @($assets | Where-Object { [string]$_.name -ceq $name }) + if ($matches.Count -ne 1 -or [int64]$matches[0].size -le 0 -or + [int64]$matches[0].size -gt 4194304) { + throw "Draft Release evidence asset is missing, duplicated, empty, or oversized: $name" + } + } + foreach ($name in $required) { + $target = Join-Path "build/desktop" $name + if (Test-Path -LiteralPath $target) { + throw "Evidence intake target already exists: $name" + } + } + gh release download $env:RELEASE_VERSION ` + --repo $env:GITHUB_REPOSITORY ` + --pattern 'standard-code-product-e2e.json' ` + --pattern 'standard-code-security-evidence.json' ` + --dir build/desktop + if ($LASTEXITCODE -ne 0) { throw "Draft Release evidence download failed" } + foreach ($name in $required) { + $item = Get-Item -LiteralPath (Join-Path "build/desktop" $name) -Force + if (-not $item.PSIsContainer -and + -not ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and + $item.Length -gt 0 -and $item.Length -le 4194304) { continue } + throw "Downloaded evidence asset is unsafe: $name" + } + + - name: Exercise packaged Standard Code bootstrap + if: ${{ github.event_name == 'pull_request' }} shell: pwsh run: | ./scripts/standard-code-packaged-e2e.ps1 ` -ExpectedVersion '${{ steps.release.outputs.version }}' ` -ExpectedRevision '${{ steps.release.outputs.revision }}' + - name: Aggregate candidate-bound Standard Code release evidence + if: ${{ github.event_name != 'pull_request' }} + shell: pwsh + run: | + ./scripts/standard-code-packaged-e2e.ps1 ` + -ExpectedVersion '${{ steps.release.outputs.version }}' ` + -ExpectedRevision '${{ steps.release.outputs.revision }}' ` + -ProductReportPath 'build/desktop/standard-code-product-e2e.json' ` + -SecurityReportPath 'build/desktop/standard-code-security-evidence.json' ` + -AggregateOutputPath 'build/desktop/standard-code-release-gate.json' ` + -RequireReleaseGate + - name: Package unsigned per-user MSIX candidate shell: pwsh run: ./scripts/package-msix.ps1 -Version '${{ steps.release.outputs.msix_version }}' @@ -206,6 +308,9 @@ jobs: build/desktop/release-metadata.json build/desktop/windows-compatibility.json build/desktop/standard-code-packaged-e2e.json + build/desktop/standard-code-product-e2e.json + build/desktop/standard-code-security-evidence.json + build/desktop/standard-code-release-gate.json build/desktop/sbom.json build/desktop/NOTICE @@ -236,6 +341,12 @@ jobs: --name '${{ needs.portable-zip.outputs.artifact_name }}' ` --dir build/desktop + - name: Set up pinned Go 1.25.12 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.25.12' + cache: true + - name: Reverify downloaded candidate shell: pwsh run: | @@ -243,6 +354,24 @@ jobs: -ExpectedVersion '${{ needs.portable-zip.outputs.version }}' ` -ExpectedRevision '${{ needs.portable-zip.outputs.revision }}' + - name: Reverify aggregate Standard Code release gate + shell: pwsh + run: | + $manifest = Get-Content -LiteralPath build/desktop/portable-zip-manifest.json ` + -Raw | ConvertFrom-Json + $archive = Join-Path build/desktop ([string]$manifest.zip_name) + go run ./cmd/releasegate ` + --binary build/desktop/TraverseBoard.exe ` + --archive $archive ` + --portable-manifest build/desktop/portable-zip-manifest.json ` + --release-metadata build/desktop/release-metadata.json ` + --bootstrap build/desktop/standard-code-packaged-e2e.json ` + --product build/desktop/standard-code-product-e2e.json ` + --security build/desktop/standard-code-security-evidence.json ` + --expected-revision '${{ needs.portable-zip.outputs.revision }}' ` + --verify-report build/desktop/standard-code-release-gate.json + if ($LASTEXITCODE -ne 0) { throw "Aggregate Standard Code release gate revalidation failed" } + - name: Sign MSIX when the protected certificate is configured if: ${{ env.MSIX_CERTIFICATE_BASE64 != '' }} shell: pwsh @@ -276,6 +405,9 @@ jobs: "build/desktop/release-metadata.json", "build/desktop/windows-compatibility.json", "build/desktop/standard-code-packaged-e2e.json", + "build/desktop/standard-code-product-e2e.json", + "build/desktop/standard-code-security-evidence.json", + "build/desktop/standard-code-release-gate.json", "build/desktop/sbom.json", "build/desktop/NOTICE" ) @@ -289,14 +421,23 @@ jobs: throw "Release asset is missing: $asset" } } - $arguments = @( - "release", "create", $env:RELEASE_VERSION - ) + $assets + @( + $release = gh release view $env:RELEASE_VERSION ` + --repo $env:GITHUB_REPOSITORY --json isDraft,tagName | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or -not [bool]$release.isDraft -or + [string]$release.tagName -cne $env:RELEASE_VERSION) { + throw "The candidate evidence Draft Release is no longer available" + } + $upload = @("release", "upload", $env:RELEASE_VERSION) + $assets + @( + "--repo", $env:GITHUB_REPOSITORY, "--clobber" + ) + & gh @upload + if ($LASTEXITCODE -ne 0) { throw "Verified release asset upload failed" } + $publish = @( + "release", "edit", $env:RELEASE_VERSION, "--repo", $env:GITHUB_REPOSITORY, - "--verify-tag", - "--generate-notes", + "--verify-tag", "--draft=false", + "--prerelease", "--title", "Traverse Board Desktop $env:RELEASE_VERSION" ) - if ($env:RELEASE_VERSION.Contains("-")) { $arguments += "--prerelease" } - & gh @arguments - if ($LASTEXITCODE -ne 0) { throw "GitHub Release creation failed" } + & gh @publish + if ($LASTEXITCODE -ne 0) { throw "Verified Draft Release publication failed" } diff --git a/README.en.md b/README.en.md index b902a80c6..27117958d 100644 --- a/README.en.md +++ b/README.en.md @@ -286,6 +286,8 @@ Local Ollama is the only keyless Provider and enables only when `CYBERAGENT_OLLA Use the operator-preview launcher. Opening the bare `TraverseBoard.exe` intentionally starts with the most conservative permissions. See [`packaging/windows/LOCAL-TEST-GUIDE.txt`](packaging/windows/LOCAL-TEST-GUIDE.txt) for the full manual test flow. +The `Desktop release` workflow keeps pull-request evidence honest: its packaged bootstrap remains `needs_full_matrix`. A non-PR Beta candidate must obtain the independently produced #182 product report and #181 security report from the matching Draft Release, bind both reports to the current EXE, ZIP, source revision, fixture, and attack matrix in `standard_code_release_gate.v1`, and reverify that aggregate before publication. Missing, tampered, replayed, skipped, or waived evidence fails closed. Only versions with a prerelease identifier (`-`) can be published, and signing/Microsoft Store distribution remain separate. See the [Beta release gate](docs/standard-code-release-gate.md) and [ADR 0144](docs/adr/0144-standard-code-release-gate-aggregation.md). + > [!NOTE] > Portable means install-free application files, not directory-local data. The default data home remains `%USERPROFILE%\.cyberagent-workbench`, so extracting another ZIP reuses the same database. `v0.1.0-rc.2` cannot upgrade one exact pre-final Windows preview v97 history; schema v125 in later builds accepts that exact history and transactionally restores the canonical trigger without deleting or falsifying old data. See [ADR 0126](docs/adr/0126-legacy-v97-docker-trigger-compatibility.md). diff --git a/README.md b/README.md index b4d23056f..8570355fb 100644 --- a/README.md +++ b/README.md @@ -356,9 +356,9 @@ pwsh -NoProfile -File scripts/release-desktop.ps1 -Version v0.1.0-rc.2 Canonical release CI pins Go 1.25.12, Node 24.16.0 (including its bundled npm), and Rust 1.97.1; `release-metadata.json` additionally binds those observed versions plus the Go, npm, Cargo, and embedded-analyzer hashes. 本地命令会记录实际工具链,正式 GitHub Release 只采用上述 CI 固定版本。 -`Desktop release` 工作流会在 PR 中重跑依赖边界、可复现构建、ZIP 完整性验证,以及四个固定 Go/Node.js/Python/Rust 仓库的 fail/repair/pass oracle;它再从确切 ZIP 启动默认模式与安全 operator preview,验证强杀重开、无宿主监听、固件不变和合成 credential sentinel 不落盘。这个 bootstrap 成功后仍如实报告 `needs_full_matrix`;40 项 Local/Docker/Approval/恢复攻击未全部取得不可变证据前,不能作为 #140 Beta gate 通过。完整合同见 [Standard Code packaged E2E](docs/standard-code-packaged-e2e.md) 与 [ADR 0138](docs/adr/0138-standard-code-packaged-e2e-foundation.md)。`workflow_dispatch` 只保留 Actions artifact,只有可追溯到 `main` 的 `v*` tag 才会创建 GitHub Release。带 `-` 的版本会发布为 prerelease。 +`Desktop release` 工作流会在 PR 中重跑依赖边界、可复现构建、ZIP 完整性验证,以及四个固定 Go/Node.js/Python/Rust 仓库的 fail/repair/pass oracle;它再从确切 ZIP 启动默认模式与安全 operator preview,验证强杀重开、无宿主监听、固件不变和合成 credential sentinel 不落盘。这个 bootstrap 成功后仍如实报告 `needs_full_matrix`。非 PR 的 release run 还必须从同版本 Draft Release 取得 #182 产品报告与 #181 安全报告,并把两者的 self-hash、40 case/75 backend run、四语言交付、Windows 10/11 UX 证据与当前 EXE/ZIP/revision 聚合为 `standard_code_release_gate.v1`;缺失、篡改、跨候选 replay、skip 或 waiver 都会阻断发布。完整合同见 [Standard Code packaged E2E](docs/standard-code-packaged-e2e.md)、[Beta release gate](docs/standard-code-release-gate.md) 与 [ADR 0144](docs/adr/0144-standard-code-release-gate-aggregation.md)。`workflow_dispatch` 只保留 Actions artifact;只有通过 aggregate 且版本包含 prerelease 标识(`-`)的 `v*` tag 才会把原 Draft 发布为 GitHub Pre-release。 -The `Desktop release` workflow reruns dependency boundaries, the reproducible build, ZIP integrity verification, and the fail/repair/pass oracle for four fixed Go, Node.js, Python, and Rust repositories. It then launches conservative default mode and safe operator preview from the exact ZIP and checks kill/reopen, absence of a host listener, fixture immutability, and synthetic credential-sentinel non-persistence. A successful bootstrap still reports `needs_full_matrix`; it is not the Issue #140 Beta gate until all 40 Local, Docker, Approval, and recovery attacks have immutable evidence. See [Standard Code packaged E2E](docs/standard-code-packaged-e2e.md) and [ADR 0138](docs/adr/0138-standard-code-packaged-e2e-foundation.md). `workflow_dispatch` keeps only an Actions artifact; only a `v*` tag reachable from `main` creates a GitHub Release, and versions containing `-` are prereleases. +The `Desktop release` workflow reruns dependency boundaries, the reproducible build, ZIP integrity verification, and the fail/repair/pass oracle for four fixed Go, Node.js, Python, and Rust repositories. It then launches conservative default mode and safe operator preview from the exact ZIP and checks kill/reopen, absence of a host listener, fixture immutability, and synthetic credential-sentinel non-persistence. A successful bootstrap still reports `needs_full_matrix`. A non-PR release run must additionally obtain the #182 product report and #181 security report from the matching Draft Release, then bind their self-hashes, 40 cases/75 backend runs, four-language delivery, Windows 10/11 UX evidence, and the current EXE/ZIP/revision into `standard_code_release_gate.v1`. Missing, tampered, replayed, skipped, or waived evidence blocks publication. See [Standard Code packaged E2E](docs/standard-code-packaged-e2e.md), the [Beta release gate](docs/standard-code-release-gate.md), and [ADR 0144](docs/adr/0144-standard-code-release-gate-aggregation.md). `workflow_dispatch` keeps an Actions artifact only; only a `v*` tag containing a prerelease identifier (`-`) can publish the existing Draft as a GitHub Pre-release after the aggregate passes. **SmartScreen 预期 / SmartScreen expectations**:便携 ZIP 与 EXE 均未签名,Windows SmartScreen 可能提示“未知发布者”。这是未签名候选的预期限制,不是构建缺陷;正式签名发行(MSIX)在另一条发布线完成。The ZIP and EXE are unsigned, so Windows SmartScreen may warn about an unknown publisher. This is the expected limitation of an unsigned candidate, not a build defect; the signed MSIX release is tracked separately. diff --git a/cmd/releasegate/main.go b/cmd/releasegate/main.go new file mode 100644 index 000000000..3d2b47c64 --- /dev/null +++ b/cmd/releasegate/main.go @@ -0,0 +1,69 @@ +// Command releasegate creates or reverifies the deterministic Standard Code +// Beta release-gate aggregate for one exact packaged candidate. +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "cyberagent-workbench/internal/releasegate" +) + +func main() { + binary := flag.String("binary", "", "exact TraverseBoard.exe") + archive := flag.String("archive", "", "exact portable ZIP") + manifest := flag.String("portable-manifest", "", "portable-zip-manifest.json") + metadata := flag.String("release-metadata", "", "release-metadata.json") + bootstrap := flag.String("bootstrap", "", "standard-code-packaged-e2e.json") + product := flag.String("product", "", "standard-code-product-e2e.json") + security := flag.String("security", "", "standard-code-security-evidence.json") + revision := flag.String("expected-revision", "", "exact candidate source revision") + reportPath := flag.String("report", "", "new aggregate report path") + verifyPath := flag.String("verify-report", "", "existing aggregate report to reverify") + flag.Parse() + values := []*string{binary, archive, manifest, metadata, bootstrap, product, security, revision} + if flag.NArg() != 0 || anyBlank(values) || + (strings.TrimSpace(*reportPath) == "") == (strings.TrimSpace(*verifyPath) == "") { + fail("exactly one of --report or --verify-report and all candidate/evidence flags are required") + } + report, err := releasegate.AggregateFiles(releasegate.InputPaths{ + BinaryPath: *binary, ArchivePath: *archive, PortableManifest: *manifest, + ReleaseMetadata: *metadata, BootstrapReport: *bootstrap, + ProductReport: *product, SecurityReport: *security, + ExpectedRevision: *revision, + }) + if err != nil { + fail(err.Error()) + } + if strings.TrimSpace(*verifyPath) != "" { + err = releasegate.VerifyReport(*verifyPath, report) + } else { + err = releasegate.WriteReport(*reportPath, report) + } + if err != nil { + fail(err.Error()) + } + fmt.Fprintf(os.Stdout, "standard_code_release_gate: %s\n", report.Status) + fmt.Fprintf(os.Stdout, "candidate_revision: %s\n", report.Candidate.Revision) + fmt.Fprintf(os.Stdout, "candidate_binary_sha256: %s\n", report.Candidate.BinarySHA256) + fmt.Fprintf(os.Stdout, "product_scenarios: %d\n", report.Coverage.ProductScenarios) + fmt.Fprintf(os.Stdout, "security_backend_runs: %d\n", report.Coverage.SecurityPassedRuns) + fmt.Fprintf(os.Stdout, "release_authorized: %t\n", report.Gate.ReleaseAuthorized) + fmt.Fprintf(os.Stdout, "report_sha256: %s\n", report.ReportSHA256) +} + +func anyBlank(values []*string) bool { + for _, value := range values { + if value == nil || strings.TrimSpace(*value) == "" { + return true + } + } + return false +} + +func fail(message string) { + fmt.Fprintln(os.Stderr, "releasegate:", message) + os.Exit(1) +} diff --git a/docs/README.md b/docs/README.md index 15bd03aa2..0624de12d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ This directory separates user-facing documentation, current engineering state, a | [Standard Code 原子预设](standard-code-preset.md) | 一键 Code/Plan 组合、Local/显式 Docker、Workspace Trust、暂停配置与故障恢复 | | [Standard Code packaged E2E](standard-code-packaged-e2e.md) | 四个固定 Git 仓库、40 项攻击矩阵、portable ZIP bootstrap 与完整发布门边界 | | [Standard Code packaged product E2E](standard-code-product-e2e.md) | 从零参数发布 EXE 验证四语言闭环、交付投影、Thread 连续性与 Windows UX 证据 | +| [Standard Code Beta release gate](standard-code-release-gate.md) | 将同一候选的 packaged bootstrap、产品与安全证据聚合为唯一 fail-closed 发布结论 | | [高风险升级 / Durable Risk Escalation](risk-escalation.md) | Standard Code 精确风险提案、一次/有界 Run 授权、持久等待恢复与不确定性处理 | | [Windows Local Sandbox](local-sandbox-windows.md) | AppContainer/WFP/Job/ACL 隔离、readiness 证据、恢复与操作说明 | | [高级 Git 工作流 / Advanced Git](git-advanced.md) | 稳定 hunk、stash、rebase/cherry-pick/bisect、受管 worktree、审批与恢复 | @@ -65,6 +66,7 @@ This directory separates user-facing documentation, current engineering state, a | [ADR 0139 / Clean-install Schema Baseline](adr/0139-clean-install-schema-baseline.md) | 严格空库证明、生成式 schema 等价、原 migration ledger、原子失败与旧二进制回滚边界 | | [ADR 0140 / Durable Risk Escalation](adr/0140-durable-risk-escalation.md) | 精确风险 scope、有界 grant、Supervisor 等待/恢复、write-ahead fence 与漂移失效 | | [ADR 0141 / Durable-operation Identity Pilot](adr/0141-minimal-durable-operation-identity.md) | 无存储共享身份值对象、长度分隔摘要、Run creation / Scheduled Job 回放试点与领域 authority 边界 | +| [ADR 0144 / Standard Code Release Gate](adr/0144-standard-code-release-gate-aggregation.md) | 同候选 packaged/product/security 证据聚合、Draft Release 传递、确定性复核与 Beta-only 发布边界 | | [ADR 索引](adr/) | 权限、持久化、执行、浏览器、Desktop 等架构决策 | ## 当前工程上下文 / Current Engineering Context diff --git a/docs/adr/0144-standard-code-release-gate-aggregation.md b/docs/adr/0144-standard-code-release-gate-aggregation.md new file mode 100644 index 000000000..69d9be898 --- /dev/null +++ b/docs/adr/0144-standard-code-release-gate-aggregation.md @@ -0,0 +1,50 @@ +# ADR 0144: Candidate-bound Standard Code release-gate aggregation + +## Status + +Accepted. + +## Context + +The packaged bootstrap proves reproducible candidate startup and fixture oracles but deliberately leaves +all 40 attack cases unevidenced. Issue #181 and #182 added independent, fail-closed security and product +producers. Neither producer may declare the parent #140 gate passed, and their reports can be reused safely +only when they identify the same source, executable, archive, fixture, and attack matrix. + +The product report includes attended Windows 10/11, DPI, IME, continuity, and delivery-surface evidence. +A GitHub-hosted Windows runner cannot manufacture those observations. Treating their absence as a PR skip, +or replacing them with a synthetic CI report, would turn an evidence boundary into a release waiver. + +## Decision + +1. `standard_code_release_gate.v1` is the only aggregate verdict. Go strictly decodes and revalidates the + complete producer reports, recomputes candidate and artifact digests, and derives a deterministic, + create-exclusive, self-hashed result. Callers cannot supply its status or individual gate booleans. +2. A pass requires bootstrap `pass`, product `pass`, security `passed`, exact candidate and fixture bindings, + four-language product coverage, four Windows platform rows, all 40 security cases, all 75 applicable + backend runs, zero unexecuted/failed runs, and exact owned-process cleanup. +3. Missing evidence, unknown fields, trailing JSON, producer hash failure, cross-candidate replay, Full Access, + Debug, source overwrite, orphan handling, skip, or waiver is a hard failure. The aggregate contains no + mechanism for overrides, retries, alternate matrices, or reduced counts. +4. A non-PR candidate must be reachable from `origin/main` and have a successful central `CI` push run for + that exact revision. Candidate evidence then enters the release workflow as two fixed, bounded assets on + a matching Draft Release. The workflow rebuilds and verifies the candidate before aggregation. A tag + publication revalidates the downloaded aggregate and turns that same Draft into the release only after + every gate succeeds. +5. Pull requests validate the gate implementation and packaged bootstrap but do not emit a passing aggregate. + They request no release and therefore supply no attended evidence. Tag/manual release runs require it. +6. The aggregate is conformance evidence only. It grants no Run, model, tool, process, network, approval, + container, artifact, signing, or Store-distribution authority. + +## Consequences + +- Product and security collection remain independently reviewable and can run on the hosts they actually + require, while the release owner retains the sole final verdict. +- Draft assets are untrusted transport. Their names and size are bounded by the workflow; their content, + producer self-hashes, candidate identity, and current artifact hashes are revalidated before use. +- An attended evidence run must be repeated whenever the candidate commit or packaged bytes change. Copying + an older report into a new Draft Release cannot pass the cross-binding checks. +- A failed release run leaves the Draft unpublished. The workflow cannot silently create a release without + both producer reports and a passing aggregate. +- A passing report cannot compensate for failed or absent repository CI on the candidate revision. +- Formal signing and Microsoft Store publication remain separate work and are not inferred from this gate. diff --git a/docs/convergence/protocol-registry.md b/docs/convergence/protocol-registry.md index a3650bf8f..dbdefecdd 100644 --- a/docs/convergence/protocol-registry.md +++ b/docs/convergence/protocol-registry.md @@ -1512,6 +1512,7 @@ These identifiers remain inside the scan. Each exemption is bound to exact files | `standard_code_product_launch.v1` | `test-fixture` | `internal/producte2e/model_test.go`, `internal/producte2e/producer.go`, `scripts/standard-code-product-e2e.ps1` | Issue #182 zero-argument candidate launch evidence. | | `standard_code_product_runbook.v1` | `test-fixture` | `internal/producte2e/model.go` | Issue #182 candidate-bound operator evidence runbook. | | `standard_code_product_session.v1` | `test-fixture` | `scripts/standard-code-product-e2e.ps1` | Local-only issue #182 packaged product session orchestration state. | +| `standard_code_release_gate.v1` | `conformance-test` | `internal/releasegate/model.go` | Issue #140 deterministic candidate-bound aggregate of independently validated bootstrap, product, and security evidence; it grants no runtime authority. | | `standard_code_security_probe.v1` | `conformance-test` | `internal/desktop/standard_code_security_windows.go` | Fixed packaged-only Standard Code attack probe receipt; not a model-facing tool, arbitrary command entry, or capability grant. | | `standard_code_security_recovery.v1` | `conformance-test` | `internal/desktop/standard_code_security_recovery_windows.go` | Fixed packaged-only Standard Code crash/restart evidence; contains hashes and bounded lifecycle facts but grants no runtime or release authority. | | `standard_code_test_root.v1` | `test-fixture` | `internal/application/standard_code_supervisor_test.go` | Test-only or golden/negative-vector identifier; exact source binding prevents production classification. | diff --git a/docs/standard-code-packaged-e2e.md b/docs/standard-code-packaged-e2e.md index da79bf879..1b94d9e12 100644 --- a/docs/standard-code-packaged-e2e.md +++ b/docs/standard-code-packaged-e2e.md @@ -164,13 +164,15 @@ case、75 个 backend run 与 append-only SHA-256 chain。最终 `status=passed` conformance evidence,不能签发执行权限或宣布发布门通过。架构决定见 [ADR 0143](adr/0143-standard-code-packaged-security-matrix.md)。 -## #140 最终发布门仍由聚合 owner 完成 - -#181 只产生安全切片证据,不修改中央 -`.github/workflows/release-desktop.yml`、`scripts/standard-code-packaged-e2e.ps1`、聚合报告或 -最终 gate 状态,也不会关闭 #140。进入 Beta gate 前,release owner 仍需把该不可变报告与 -bootstrap、四语言修复、交付真实性、产物/SBOM、Windows host/可见窗口及复现说明在同一 -候选上聚合。正式代码签名与 Microsoft Store 分发仍属于独立发布线。 +## #140 最终发布门 + +#181 只产生安全切片证据;#140 的中央 owner 将该不可变报告与 bootstrap、#182 的四语言 +修复/交付真实性、Windows host/可见窗口及当前 release artifacts 交叉绑定为 +`standard_code_release_gate.v1`。非 PR 的中央 workflow 必须从同版本 Draft Release 取得两份 +独立 producer report,并在当前 EXE/ZIP 上重新计算 identity;最终 publish job 下载 artifact +后再次验证 aggregate。完整 runbook 见 +[Standard Code Beta release gate](standard-code-release-gate.md)。正式代码签名与 Microsoft +Store 分发仍属于独立发布线。 任何一次矩阵 case 缺失、环境不具备、取证不完整或结果不确定,都应使 gate 失败, 而不是改成 skip、expected failure 或手工豁免。测试只能使用合成 sentinel;禁止把 diff --git a/docs/standard-code-product-e2e.md b/docs/standard-code-product-e2e.md index dce9ba9e9..6c3bae888 100644 --- a/docs/standard-code-product-e2e.md +++ b/docs/standard-code-product-e2e.md @@ -146,4 +146,5 @@ evidence/ `scripts/standard-code-packaged-e2e.ps1`、聚合报告与最终 release gate 由 #140 的 C owner 串行接线。集成方只能在精确 candidate hash 与 revision 相同时消费本报告;不能把本报告 的 `pass` 改写成 #140 已通过,也不能用缺失报告、Approval fallback 或人工 waiver 代替 -完整发布矩阵。 +完整发布矩阵。中央取证传输、交叉绑定和发布流程见 +[Standard Code Beta release gate](standard-code-release-gate.md)。 diff --git a/docs/standard-code-release-gate.md b/docs/standard-code-release-gate.md new file mode 100644 index 000000000..e2d69fc8a --- /dev/null +++ b/docs/standard-code-release-gate.md @@ -0,0 +1,83 @@ +# Standard Code Beta release gate + +本文定义 Issue #140 的中央证据聚合与 Beta 发布门。它不重新实现 #181 的安全 executor +或 #182 的产品 collector,而是严格消费它们针对同一 EXE/ZIP/source revision 生成的不可变 +报告,并与 packaged bootstrap 一起封存为 `standard_code_release_gate.v1`。 + +## 只有三份报告同时存在才可通过 + +| 组件 | 协议 | 必需结论 | +|---|---|---| +| packaged bootstrap | `standard_code_packaged_e2e.v1` | `bootstrap_status=pass`,仍诚实保留 `release_gate_status=needs_full_matrix` | +| product/delivery | `standard_code_product_e2e.v1` | 四语言真实失败/诊断/重试/通过、交付投影、Thread 连续性与 Windows 10/11 × 100%/200% 证据完整 | +| security/runtime | `standard_code_packaged_security_evidence.v1` | 40 case、75/75 backend run、零未执行、完整回收且 self-hash/chain 有效 | + +中央 `cmd/releasegate` 使用严格 JSON 解码;未知字段、第二个 JSON value、缺失组件、过大文件、 +symlink/reparse input 或无效 producer self-hash 都会返回非零。它重新计算当前 EXE、ZIP、 +portable manifest、release metadata 和三份报告的 SHA-256,然后要求以下 identity 完全相同: + +- version、source revision、EXE SHA-256、ZIP SHA-256 和文件大小; +- fixture manifest 与 40-case attack matrix SHA-256; +- product 的四语言/Local-Docker/五种 delivery surface/Windows 平台覆盖; +- security 的 40 case、75 个真实 backend run 和 owned cleanup 结论。 + +最终 aggregate 只保存稳定 identity、计数、布尔 safeguard 与 producer digest,不保存本机路径、 +截图、命令输出、Provider secret、凭据或 Docker 原始日志。`evaluated_at` 取三份输入证据中的最晚 +时间,因此同一输入可确定性重算;publish job 会在下载 Actions artifact 后再次逐字段验证。 + +## 两阶段 Draft Release 流程 + +产品矩阵包含人工 Desktop 与 Windows 10/11 主机证据,普通 GitHub-hosted runner 不能替操作者 +完成,也不能用 mock/skip/waiver 伪造。因此候选取证和中央发布是两个明确阶段: + +1. 冻结待发布 commit 与版本,生成 clean、reproducible EXE/ZIP。 +2. 按 [packaged product E2E](standard-code-product-e2e.md) 生成 + `standard-code-product-e2e.json`;按 [packaged security matrix](standard-code-packaged-e2e.md) + 生成 `standard-code-security-evidence.json`。 +3. 为同一版本建立 Draft Release,并只上传这两份 path-free 报告: + + ```powershell + $revision = (git rev-parse HEAD).Trim() + $version = "v0.1.0-beta.1" + gh release create $version --draft --target $revision --generate-notes ` + --title "Traverse Board Desktop $version" + gh release upload $version ` + build/desktop/standard-code-product-e2e.json ` + build/desktop/standard-code-security-evidence.json --clobber + ``` + +4. 等待该 revision 在 `main` 的中央 `CI` push run 成功;随后从该候选 ref 手动运行 + `Desktop release`,或在 exact tag push 前准备好同名 Draft Release。 +5. workflow 只接受两个固定名称、各不超过 4 MiB 的 Draft asset;它重新构建候选、运行 + bootstrap、生成 aggregate,并把报告与 release artifacts 放入同一个 Actions artifact。 +6. tag push 的 publish job 重新验证 ZIP 与 aggregate,上传已验证资产并把原 Draft 转为 + Release。任一步失败都会保留 Draft,不会发布。 + +Pull Request 事件没有发布候选身份,也没有人工产品证据,因此只运行 producer/aggregate 合同 +测试和 bootstrap。它不会写 `passed` aggregate,publish job 也不会运行;这不是 skip 或 waiver, +因为 PR 从未请求发布。 + +## 手工重算 + +维护者可在证据与候选位于同一 release output 目录时运行: + +```powershell +go run ./cmd/releasegate ` + --binary build/desktop/TraverseBoard.exe ` + --archive build/desktop/Prayu-portable-v0.1.0-beta.1-windows-amd64.zip ` + --portable-manifest build/desktop/portable-zip-manifest.json ` + --release-metadata build/desktop/release-metadata.json ` + --bootstrap build/desktop/standard-code-packaged-e2e.json ` + --product build/desktop/standard-code-product-e2e.json ` + --security build/desktop/standard-code-security-evidence.json ` + --expected-revision (git rev-parse HEAD) ` + --report build/desktop/standard-code-release-gate.json +``` + +`--report` 使用 create-exclusive 写入。使用相同输入复核已有 aggregate 时改为 +`--verify-report `;任何字节语义差异、跨候选 replay 或 producer tamper 都会失败。 + +## 不在本门内的发布声明 + +本门允许将通过候选标记为 Beta/Pre-release,但不伪造 #123 的正式代码签名、Microsoft Store +分发或更广平台认证。Full Access、Debug Maximum Access 与 Full CDP 仍是高级、默认关闭能力。 diff --git a/internal/releasegate/model.go b/internal/releasegate/model.go new file mode 100644 index 000000000..947aaa4f1 --- /dev/null +++ b/internal/releasegate/model.go @@ -0,0 +1,709 @@ +// Package releasegate validates and aggregates the candidate-bound Standard +// Code bootstrap, product, and security evidence used by the Beta release gate. +package releasegate + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + + "cyberagent-workbench/internal/packagede2e" + "cyberagent-workbench/internal/producte2e" +) + +const ( + ProtocolVersion = "standard_code_release_gate.v1" + IssueNumber = 140 + StatusPassed = "passed" + + maximumEvidenceBytes int64 = 4 << 20 + maximumCandidateBytes int64 = 2 << 30 + maximumReportBytes = 2 << 20 +) + +var gitObjectPattern = regexp.MustCompile(`^[0-9a-f]{40}(?:[0-9a-f]{24})?$`) + +// InputPaths identifies the exact candidate and immutable producer reports. +// Every path must name a regular, non-symlink file. The aggregate never stores +// any of these paths. +type InputPaths struct { + BinaryPath string + ArchivePath string + PortableManifest string + ReleaseMetadata string + BootstrapReport string + ProductReport string + SecurityReport string + ExpectedRevision string +} + +type Report struct { + ProtocolVersion string `json:"protocol_version"` + Issue int `json:"issue"` + Status string `json:"status"` + EvaluatedAt time.Time `json:"evaluated_at"` + Candidate CandidateEvidence `json:"candidate"` + Components ComponentEvidence `json:"components"` + Coverage CoverageEvidence `json:"coverage"` + Safeguards SafeguardEvidence `json:"safeguards"` + Gate GateEvidence `json:"gate"` + ReportSHA256 string `json:"report_sha256"` +} + +type CandidateEvidence struct { + Version string `json:"version"` + Revision string `json:"revision"` + BinarySHA256 string `json:"binary_sha256"` + BinarySizeBytes int64 `json:"binary_size_bytes"` + ArchiveSHA256 string `json:"archive_sha256"` + ArchiveSizeBytes int64 `json:"archive_size_bytes"` + PortableManifestSHA256 string `json:"portable_manifest_sha256"` + ReleaseMetadataSHA256 string `json:"release_metadata_sha256"` + FixtureManifestSHA256 string `json:"fixture_manifest_sha256"` + AttackMatrixSHA256 string `json:"attack_matrix_sha256"` +} + +type ComponentEvidence struct { + Bootstrap ProducerEvidence `json:"bootstrap"` + Product ProducerEvidence `json:"product"` + Security ProducerEvidence `json:"security"` +} + +type ProducerEvidence struct { + ProtocolVersion string `json:"protocol_version"` + Issue int `json:"issue"` + Status string `json:"status"` + ArtifactSHA256 string `json:"artifact_sha256"` + ProducerSHA256 string `json:"producer_sha256,omitempty"` + ChainSHA256 string `json:"chain_sha256,omitempty"` +} + +type CoverageEvidence struct { + FixtureRepositories int `json:"fixture_repositories"` + Languages []string `json:"languages"` + Backends []string `json:"backends"` + DeliverySurfaces []string `json:"delivery_surfaces"` + EdgeCases []string `json:"edge_cases"` + OperatingSystems []string `json:"operating_systems"` + DPIPercents []int `json:"dpi_percents"` + ProductScenarios int `json:"product_scenarios"` + ProductRealFailureRetries int `json:"product_real_failure_retries"` + ProductRealProcessJobs int `json:"product_real_process_jobs"` + ProductContinuityCases int `json:"product_continuity_cases"` + ProductPlatformRows int `json:"product_platform_rows"` + SecurityRequiredCases int `json:"security_required_cases"` + SecurityRequiredRuns int `json:"security_required_backend_runs"` + SecurityPassedRuns int `json:"security_passed_backend_runs"` +} + +type SafeguardEvidence struct { + NetworkDisabled bool `json:"network_disabled"` + CredentialsAbsent bool `json:"credentials_absent"` + DangerFullAccessEnabled bool `json:"danger_full_access_enabled"` + DebugMaximumEnabled bool `json:"debug_maximum_access_enabled"` + FullCDPDebugEnabled bool `json:"full_cdp_debug_enabled"` + SourceOverwrite bool `json:"source_overwrite"` + FakeRunnerAccepted bool `json:"fake_runner_accepted"` + OrphanProcesses int `json:"orphan_processes"` + ForeignProcessesKilled int `json:"foreign_processes_killed"` + SkipAccepted bool `json:"skip_accepted"` + WaiverAccepted bool `json:"waiver_accepted"` +} + +type GateEvidence struct { + CandidateBound bool `json:"candidate_bound"` + BootstrapPassed bool `json:"bootstrap_passed"` + ProductMatrixComplete bool `json:"product_matrix_complete"` + SecurityMatrixComplete bool `json:"security_matrix_complete"` + WindowsMatrixComplete bool `json:"windows_matrix_complete"` + ProducerSelfHashesValid bool `json:"producer_self_hashes_valid"` + MissingEvidenceIsFailure bool `json:"missing_evidence_is_failure"` + UnexecutedIsNeverPass bool `json:"unexecuted_is_never_pass"` + ReleaseAuthorized bool `json:"release_authorized"` +} + +type bootstrapReport struct { + ProtocolVersion string `json:"protocol_version"` + GeneratedAt time.Time `json:"generated_at"` + Issue int `json:"issue"` + BootstrapStatus string `json:"bootstrap_status"` + ReleaseGateStatus string `json:"release_gate_status"` + Candidate bootstrapCandidate `json:"candidate"` + FixtureSet bootstrapFixtureSet `json:"fixture_set"` + Results []bootstrapResult `json:"results"` + AttackMatrix bootstrapAttackMatrix `json:"attack_matrix"` +} + +type bootstrapCandidate struct { + Version string `json:"version"` + Revision string `json:"revision"` + ArchiveSHA256 string `json:"zip_sha256"` + BinarySHA256 string `json:"binary_sha256"` + SourceDateEpoch int64 `json:"source_date_epoch"` +} + +type bootstrapFixtureSet struct { + ProtocolVersion string `json:"protocol_version"` + ManifestSHA256 string `json:"manifest_sha256"` + AttackMatrixSHA256 string `json:"attack_matrix_sha256"` + RepositoryCount int `json:"repository_count"` + AttackCaseCount int `json:"attack_case_count"` + OracleVerified bool `json:"oracle_verified"` + AllAttackCasesBound bool `json:"all_attack_cases_bound"` +} + +type bootstrapResult struct { + ID string `json:"id"` + Status string `json:"status"` + Facts json.RawMessage `json:"facts"` +} + +type bootstrapAttackMatrix struct { + RequiredCaseCount int `json:"required_case_count"` + PreparedCaseCount int `json:"prepared_case_count"` + EvidencedCaseCount int `json:"evidenced_case_count"` + RemainingRequiredCaseCount int `json:"remaining_required_case_count"` + Status string `json:"status"` + FailurePolicy string `json:"failure_policy"` + UnexecutedCasesAreNotPassOrSkip bool `json:"unexecuted_cases_are_not_pass_or_skip"` +} + +// AggregateFiles strictly validates every producer report, recomputes all file +// digests, cross-binds them to one candidate, and returns a sealed verdict. +func AggregateFiles(options InputPaths) (Report, error) { + if !gitObjectPattern.MatchString(options.ExpectedRevision) { + return Report{}, errors.New("release gate expected revision is invalid") + } + binaryHash, binarySize, err := digestRegularFile(options.BinaryPath, maximumCandidateBytes) + if err != nil { + return Report{}, fmt.Errorf("release gate binary: %w", err) + } + archiveHash, archiveSize, err := digestRegularFile(options.ArchivePath, maximumCandidateBytes) + if err != nil { + return Report{}, fmt.Errorf("release gate archive: %w", err) + } + _, manifestHash, err := readEvidenceFile(options.PortableManifest) + if err != nil { + return Report{}, fmt.Errorf("release gate portable manifest: %w", err) + } + _, metadataHash, err := readEvidenceFile(options.ReleaseMetadata) + if err != nil { + return Report{}, fmt.Errorf("release gate release metadata: %w", err) + } + bootstrapBytes, bootstrapHash, err := readEvidenceFile(options.BootstrapReport) + if err != nil { + return Report{}, fmt.Errorf("release gate bootstrap evidence: %w", err) + } + productBytes, productHash, err := readEvidenceFile(options.ProductReport) + if err != nil { + return Report{}, fmt.Errorf("release gate product evidence: %w", err) + } + securityBytes, securityHash, err := readEvidenceFile(options.SecurityReport) + if err != nil { + return Report{}, fmt.Errorf("release gate security evidence: %w", err) + } + + var bootstrap bootstrapReport + if err := decodeStrictJSON(bootstrapBytes, &bootstrap); err != nil { + return Report{}, fmt.Errorf("release gate bootstrap evidence is invalid: %w", err) + } + if err := validateBootstrap(bootstrap); err != nil { + return Report{}, err + } + var product producte2e.Report + if err := decodeStrictJSON(productBytes, &product); err != nil { + return Report{}, fmt.Errorf("release gate product evidence is invalid: %w", err) + } + if err := product.Validate(); err != nil { + return Report{}, fmt.Errorf("release gate product evidence is invalid: %w", err) + } + var security packagede2e.StandardCodeSecurityEvidence + if err := decodeStrictJSON(securityBytes, &security); err != nil { + return Report{}, fmt.Errorf("release gate security evidence is invalid: %w", err) + } + if err := packagede2e.ValidateStandardCodeSecurityEvidence(security); err != nil { + return Report{}, fmt.Errorf("release gate security evidence is invalid: %w", err) + } + + report, err := aggregate(aggregateInputs{ + bootstrap: bootstrap, bootstrapArtifactSHA: bootstrapHash, + product: product, productArtifactSHA: productHash, + security: security, securityArtifactSHA: securityHash, + binarySHA: binaryHash, binarySize: binarySize, + archiveSHA: archiveHash, archiveSize: archiveSize, + manifestSHA: manifestHash, metadataSHA: metadataHash, + expectedRevision: options.ExpectedRevision, + }) + if err != nil { + return Report{}, err + } + return report.Seal() +} + +type aggregateInputs struct { + bootstrap bootstrapReport + bootstrapArtifactSHA string + product producte2e.Report + productArtifactSHA string + security packagede2e.StandardCodeSecurityEvidence + securityArtifactSHA string + binarySHA string + binarySize int64 + archiveSHA string + archiveSize int64 + manifestSHA string + metadataSHA string + expectedRevision string +} + +func aggregate(input aggregateInputs) (Report, error) { + candidate := input.product.Candidate + security := input.security + bootstrap := input.bootstrap + if candidate.Revision != input.expectedRevision || bootstrap.Candidate.Revision != input.expectedRevision || + security.Candidate.SourceCommit != input.expectedRevision || + candidate.Version != bootstrap.Candidate.Version || + candidate.BinarySHA256 != input.binarySHA || bootstrap.Candidate.BinarySHA256 != input.binarySHA || + security.Candidate.ExecutableSHA256 != input.binarySHA || candidate.BinarySizeBytes != input.binarySize || + candidate.ZipSHA256 != input.archiveSHA || bootstrap.Candidate.ArchiveSHA256 != input.archiveSHA || + security.Candidate.ArchiveSHA256 != input.archiveSHA || candidate.ZipSizeBytes != input.archiveSize || + candidate.ManifestSHA256 != input.manifestSHA || candidate.ReleaseMetadataSHA256 != input.metadataSHA { + return Report{}, errors.New("release gate candidate bindings do not match") + } + if input.product.Fixture.ManifestSHA256 != bootstrap.FixtureSet.ManifestSHA256 || + security.Candidate.FixtureSHA256 != bootstrap.FixtureSet.ManifestSHA256 || + input.product.Fixture.AttackMatrixSHA256 != bootstrap.FixtureSet.AttackMatrixSHA256 || + security.Candidate.AttackMatrixSHA256 != bootstrap.FixtureSet.AttackMatrixSHA256 { + return Report{}, errors.New("release gate fixture or attack matrix bindings do not match") + } + if security.Status != packagede2e.SecurityEvidencePassed || + security.Summary.RequiredCaseCount != 40 || security.Summary.RequiredBackendRuns != 75 || + security.Summary.PassedBackendRuns != 75 || security.Summary.FailedBackendRuns != 0 || + security.Summary.UnexecutedBackendRuns != 0 || + security.Cleanup.OwnedProcessesStarted != security.Cleanup.OwnedProcessesReaped || + security.Cleanup.OrphanProcesses != 0 || security.Cleanup.ForeignProcessesKilled != 0 || + !security.Cleanup.OwnedDirectoriesOnly { + return Report{}, errors.New("release gate security matrix is incomplete") + } + + evaluatedAt := bootstrap.GeneratedAt + if input.product.GeneratedAt.After(evaluatedAt) { + evaluatedAt = input.product.GeneratedAt + } + if security.CompletedAt.After(evaluatedAt) { + evaluatedAt = security.CompletedAt + } + report := Report{ProtocolVersion: ProtocolVersion, Issue: IssueNumber, + Status: StatusPassed, EvaluatedAt: evaluatedAt.UTC(), + Candidate: CandidateEvidence{Version: candidate.Version, Revision: candidate.Revision, + BinarySHA256: input.binarySHA, BinarySizeBytes: input.binarySize, + ArchiveSHA256: input.archiveSHA, ArchiveSizeBytes: input.archiveSize, + PortableManifestSHA256: input.manifestSHA, + ReleaseMetadataSHA256: input.metadataSHA, + FixtureManifestSHA256: bootstrap.FixtureSet.ManifestSHA256, + AttackMatrixSHA256: bootstrap.FixtureSet.AttackMatrixSHA256}, + Components: ComponentEvidence{ + Bootstrap: ProducerEvidence{ProtocolVersion: bootstrap.ProtocolVersion, + Issue: bootstrap.Issue, Status: bootstrap.BootstrapStatus, + ArtifactSHA256: input.bootstrapArtifactSHA}, + Product: ProducerEvidence{ProtocolVersion: input.product.ProtocolVersion, + Issue: input.product.Issue, Status: input.product.Status, + ArtifactSHA256: input.productArtifactSHA, + ProducerSHA256: input.product.EvidenceSHA256, + ChainSHA256: input.product.RunbookSHA256}, + Security: ProducerEvidence{ProtocolVersion: security.ProtocolVersion, + Issue: security.Issue, Status: security.Status, + ArtifactSHA256: input.securityArtifactSHA, + ProducerSHA256: security.ReportSHA256, ChainSHA256: security.ChainSHA256}}, + Coverage: CoverageEvidence{FixtureRepositories: bootstrap.FixtureSet.RepositoryCount, + Languages: append([]string(nil), input.product.Coverage.Languages...), + Backends: append([]string(nil), input.product.Coverage.Backends...), + DeliverySurfaces: append([]string(nil), input.product.Coverage.Surfaces...), + EdgeCases: append([]string(nil), input.product.Coverage.EdgeCases...), + OperatingSystems: append([]string(nil), input.product.Coverage.OperatingSystems...), + DPIPercents: append([]int(nil), input.product.Coverage.DPIPercents...), + ProductScenarios: len(input.product.Scenarios), + ProductRealFailureRetries: input.product.Coverage.RealFailureRetries, + ProductRealProcessJobs: input.product.Coverage.RealProcessJobs, + ProductContinuityCases: len(input.product.Continuity), + ProductPlatformRows: len(input.product.Platforms), + SecurityRequiredCases: security.Summary.RequiredCaseCount, + SecurityRequiredRuns: security.Summary.RequiredBackendRuns, + SecurityPassedRuns: security.Summary.PassedBackendRuns}, + Safeguards: SafeguardEvidence{NetworkDisabled: input.product.Safeguards.NetworkDisabled, + CredentialsAbsent: input.product.Safeguards.CredentialsAbsent, + DangerFullAccessEnabled: input.product.Safeguards.DangerFullAccess, + DebugMaximumEnabled: input.product.Safeguards.DebugMaximumAccess, + FullCDPDebugEnabled: input.product.Safeguards.FullCDPDebug, + SourceOverwrite: input.product.Safeguards.SourceOverwrite, + FakeRunnerAccepted: input.product.Safeguards.FakeRunnerAccepted, + OrphanProcesses: security.Cleanup.OrphanProcesses, + ForeignProcessesKilled: security.Cleanup.ForeignProcessesKilled, + SkipAccepted: input.product.Safeguards.SkipAccepted, + WaiverAccepted: input.product.Safeguards.WaiverAccepted}, + Gate: GateEvidence{CandidateBound: true, BootstrapPassed: true, + ProductMatrixComplete: true, SecurityMatrixComplete: true, + WindowsMatrixComplete: true, ProducerSelfHashesValid: true, + MissingEvidenceIsFailure: true, UnexecutedIsNeverPass: true, + ReleaseAuthorized: true}} + return report, nil +} + +func validateBootstrap(report bootstrapReport) error { + if report.ProtocolVersion != packagede2e.PackagedE2EProtocol || report.Issue != IssueNumber || + report.BootstrapStatus != "pass" || report.ReleaseGateStatus != "needs_full_matrix" || + report.GeneratedAt.IsZero() || !validText(report.Candidate.Version, 128) || + !gitObjectPattern.MatchString(report.Candidate.Revision) || + !validDigest(report.Candidate.BinarySHA256) || !validDigest(report.Candidate.ArchiveSHA256) || + report.Candidate.SourceDateEpoch <= 0 || + report.FixtureSet.ProtocolVersion != packagede2e.FixtureSetProtocol || + !validDigest(report.FixtureSet.ManifestSHA256) || + !validDigest(report.FixtureSet.AttackMatrixSHA256) || + report.FixtureSet.RepositoryCount != 4 || report.FixtureSet.AttackCaseCount != 40 || + !report.FixtureSet.OracleVerified || !report.FixtureSet.AllAttackCasesBound { + return errors.New("release gate bootstrap evidence is incomplete") + } + expectedResults := []string{"candidate_provenance", "fixed_repository_oracle", + "exact_package_extraction", "packaged_default_start", + "packaged_operator_preview_kill_reopen", "fixed_repositories_immutable", + "credential_sentinel_non_persistence", "candidate_process_cleanup", + "owned_harness_cleanup"} + if len(report.Results) != len(expectedResults) { + return errors.New("release gate bootstrap result set is incomplete") + } + seen := map[string]bool{} + for _, result := range report.Results { + if result.Status != "pass" || seen[result.ID] || !contains(expectedResults, result.ID) || + len(result.Facts) == 0 || !json.Valid(result.Facts) { + return errors.New("release gate bootstrap result set is invalid") + } + seen[result.ID] = true + } + matrix := report.AttackMatrix + if matrix.RequiredCaseCount != 40 || matrix.PreparedCaseCount != 40 || + matrix.EvidencedCaseCount != 0 || matrix.RemainingRequiredCaseCount != 40 || + matrix.Status != "needs_full_matrix" || matrix.FailurePolicy != "fail_closed_no_waiver" || + !matrix.UnexecutedCasesAreNotPassOrSkip { + return errors.New("release gate bootstrap attack matrix state is invalid") + } + return nil +} + +func (report Report) Seal() (Report, error) { + report.ReportSHA256 = "" + digest, err := reportDigest(report) + if err != nil { + return Report{}, err + } + report.ReportSHA256 = digest + if err := report.Validate(); err != nil { + return Report{}, err + } + return report, nil +} + +func (report Report) Validate() error { + if report.ProtocolVersion != ProtocolVersion || report.Issue != IssueNumber || + report.Status != StatusPassed || report.EvaluatedAt.IsZero() || + !validDigest(report.ReportSHA256) || !gitObjectPattern.MatchString(report.Candidate.Revision) || + !validText(report.Candidate.Version, 128) || report.Candidate.BinarySizeBytes <= 0 || + report.Candidate.ArchiveSizeBytes <= 0 { + return errors.New("release gate report envelope is invalid") + } + for _, digest := range []string{report.Candidate.BinarySHA256, report.Candidate.ArchiveSHA256, + report.Candidate.PortableManifestSHA256, report.Candidate.ReleaseMetadataSHA256, + report.Candidate.FixtureManifestSHA256, report.Candidate.AttackMatrixSHA256, + report.Components.Bootstrap.ArtifactSHA256, report.Components.Product.ArtifactSHA256, + report.Components.Product.ProducerSHA256, report.Components.Product.ChainSHA256, + report.Components.Security.ArtifactSHA256, + report.Components.Security.ProducerSHA256, report.Components.Security.ChainSHA256} { + if !validDigest(digest) { + return errors.New("release gate report digest is invalid") + } + } + if report.Components.Bootstrap.ProtocolVersion != packagede2e.PackagedE2EProtocol || + report.Components.Bootstrap.Issue != IssueNumber || report.Components.Bootstrap.Status != "pass" || + report.Components.Product.ProtocolVersion != producte2e.ReportProtocol || + report.Components.Product.Issue != producte2e.IssueNumber || + report.Components.Product.Status != "pass" || + report.Components.Security.ProtocolVersion != packagede2e.StandardCodeSecurityEvidenceProtocol || + report.Components.Security.Issue != packagede2e.SecurityEvidenceIssue || + report.Components.Security.Status != packagede2e.SecurityEvidencePassed { + return errors.New("release gate component identity is invalid") + } + if !sameStrings(report.Coverage.Languages, []string{"go", "node", "python", "rust"}) || + !sameStrings(report.Coverage.Backends, []string{"local", "docker"}) || + !sameStrings(report.Coverage.DeliverySurfaces, []string{"desktop", "cli", "http", "handoff", "final"}) || + !sameStrings(report.Coverage.EdgeCases, []string{"chinese_path", "space_path", "long_path", + "crlf", "dirty_tracked", "untracked", "binary", "concurrent_edit"}) || + !sameStrings(report.Coverage.OperatingSystems, []string{"windows_10", "windows_11"}) || + len(report.Coverage.DPIPercents) != 2 || report.Coverage.DPIPercents[0] != 100 || + report.Coverage.DPIPercents[1] != 200 || + report.Coverage.FixtureRepositories != 4 || report.Coverage.ProductScenarios < 4 || + report.Coverage.ProductScenarios > 8 || report.Coverage.ProductRealFailureRetries < 4 || + report.Coverage.ProductRealProcessJobs < 8 || report.Coverage.ProductContinuityCases != 4 || + report.Coverage.ProductPlatformRows != 4 || report.Coverage.SecurityRequiredCases != 40 || + report.Coverage.SecurityRequiredRuns != 75 || report.Coverage.SecurityPassedRuns != 75 { + return errors.New("release gate coverage is incomplete") + } + if !report.Safeguards.NetworkDisabled || !report.Safeguards.CredentialsAbsent || + report.Safeguards.DangerFullAccessEnabled || report.Safeguards.DebugMaximumEnabled || + report.Safeguards.FullCDPDebugEnabled || report.Safeguards.SourceOverwrite || + report.Safeguards.FakeRunnerAccepted || + report.Safeguards.OrphanProcesses != 0 || report.Safeguards.ForeignProcessesKilled != 0 || + report.Safeguards.SkipAccepted || report.Safeguards.WaiverAccepted { + return errors.New("release gate safeguards are incomplete") + } + if !report.Gate.CandidateBound || !report.Gate.BootstrapPassed || + !report.Gate.ProductMatrixComplete || !report.Gate.SecurityMatrixComplete || + !report.Gate.WindowsMatrixComplete || !report.Gate.ProducerSelfHashesValid || + !report.Gate.MissingEvidenceIsFailure || !report.Gate.UnexecutedIsNeverPass || + !report.Gate.ReleaseAuthorized { + return errors.New("release gate verdict is incomplete") + } + probe := report + probe.ReportSHA256 = "" + digest, err := reportDigest(probe) + if err != nil || digest != report.ReportSHA256 { + return errors.New("release gate report digest does not match") + } + encoded, err := json.Marshal(report) + if err != nil || len(encoded) > maximumReportBytes || !utf8.Valid(encoded) { + return errors.New("release gate report exceeds its bound") + } + return nil +} + +func WriteReport(path string, report Report) error { + if err := report.Validate(); err != nil { + return err + } + resolved, err := newOutputPath(path) + if err != nil { + return err + } + content, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + content = append(content, '\n') + file, err := os.OpenFile(resolved, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return fmt.Errorf("create release gate report: %w", err) + } + if _, err = file.Write(content); err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err == nil { + err = closeErr + } + return err +} + +// VerifyReport recomputes the exact deterministic aggregate and requires the +// existing report to be byte-semantically identical. +func VerifyReport(path string, expected Report) error { + content, _, err := readEvidenceFile(path) + if err != nil { + return err + } + var actual Report + if err := decodeStrictJSON(content, &actual); err != nil { + return err + } + if err := actual.Validate(); err != nil { + return err + } + if !reflect.DeepEqual(actual, expected) { + return errors.New("release gate report does not match recomputed evidence") + } + return nil +} + +func readEvidenceFile(path string) ([]byte, string, error) { + resolved, info, err := regularFile(path) + if err != nil { + return nil, "", err + } + if info.Size() <= 0 || info.Size() > maximumEvidenceBytes { + return nil, "", errors.New("evidence file size is invalid") + } + file, err := os.Open(resolved) + if err != nil { + return nil, "", err + } + opened, err := file.Stat() + if err != nil || !os.SameFile(info, opened) { + _ = file.Close() + return nil, "", errors.New("evidence file identity changed before read") + } + content, readErr := io.ReadAll(io.LimitReader(file, maximumEvidenceBytes+1)) + after, statErr := file.Stat() + closeErr := file.Close() + pathAfter, pathErr := os.Lstat(resolved) + if readErr != nil { + return nil, "", readErr + } + if statErr != nil || pathErr != nil || closeErr != nil || + !stableRegularFile(opened, after, pathAfter) || int64(len(content)) != opened.Size() { + return nil, "", errors.New("evidence file changed while being read") + } + if !utf8.Valid(content) { + return nil, "", errors.New("evidence file is not UTF-8") + } + return content, digestBytes(content), nil +} + +func digestRegularFile(path string, maximum int64) (string, int64, error) { + resolved, info, err := regularFile(path) + if err != nil { + return "", 0, err + } + if info.Size() <= 0 || info.Size() > maximum { + return "", 0, errors.New("candidate file size is invalid") + } + file, err := os.Open(resolved) + if err != nil { + return "", 0, err + } + opened, statErr := file.Stat() + if statErr != nil || !os.SameFile(info, opened) { + _ = file.Close() + return "", 0, errors.New("candidate file identity changed before read") + } + hash := sha256.New() + written, copyErr := io.Copy(hash, io.LimitReader(file, maximum+1)) + after, afterErr := file.Stat() + closeErr := file.Close() + pathAfter, pathErr := os.Lstat(resolved) + if copyErr != nil { + return "", 0, copyErr + } + if closeErr != nil { + return "", 0, closeErr + } + if afterErr != nil || pathErr != nil || written != opened.Size() || + !stableRegularFile(opened, after, pathAfter) { + return "", 0, errors.New("candidate file changed while being read") + } + return hex.EncodeToString(hash.Sum(nil)), written, nil +} + +func stableRegularFile(before, after, pathAfter os.FileInfo) bool { + return before != nil && after != nil && pathAfter != nil && + before.Mode().IsRegular() && after.Mode().IsRegular() && pathAfter.Mode().IsRegular() && + before.Mode()&os.ModeSymlink == 0 && after.Mode()&os.ModeSymlink == 0 && + pathAfter.Mode()&os.ModeSymlink == 0 && os.SameFile(before, after) && + os.SameFile(after, pathAfter) && before.Size() == after.Size() && + before.Size() == pathAfter.Size() && before.ModTime().Equal(after.ModTime()) && + before.ModTime().Equal(pathAfter.ModTime()) +} + +func regularFile(path string) (string, os.FileInfo, error) { + if strings.TrimSpace(path) == "" { + return "", nil, errors.New("file path is required") + } + resolved, err := filepath.Abs(path) + if err != nil { + return "", nil, err + } + info, err := os.Lstat(resolved) + if err != nil { + return "", nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return "", nil, errors.New("file must be regular and not a symlink") + } + return resolved, info, nil +} + +func newOutputPath(path string) (string, error) { + if strings.TrimSpace(path) == "" { + return "", errors.New("release gate report path is required") + } + resolved, err := filepath.Abs(path) + if err != nil { + return "", err + } + if _, err := os.Lstat(resolved); !os.IsNotExist(err) { + if err == nil { + return "", errors.New("release gate report already exists") + } + return "", err + } + parent, err := os.Lstat(filepath.Dir(resolved)) + if err != nil || !parent.IsDir() || parent.Mode()&os.ModeSymlink != 0 { + return "", errors.New("release gate report parent is invalid") + } + return resolved, nil +} + +func decodeStrictJSON(content []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values are not allowed") + } + return err + } + return nil +} + +func reportDigest(report Report) (string, error) { + encoded, err := json.Marshal(report) + if err != nil { + return "", err + } + return digestBytes(encoded), nil +} + +func digestBytes(content []byte) string { + digest := sha256.Sum256(content) + return hex.EncodeToString(digest[:]) +} + +func validDigest(value string) bool { + if len(value) != sha256.Size*2 || value != strings.ToLower(value) { + return false + } + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == sha256.Size +} + +func validText(value string, maximum int) bool { + return value == strings.TrimSpace(value) && value != "" && utf8.ValidString(value) && + len(value) <= maximum && !strings.ContainsAny(value, "\r\n\x00") +} + +func contains(values []string, value string) bool { + for _, current := range values { + if current == value { + return true + } + } + return false +} + +func sameStrings(actual, expected []string) bool { + left, right := append([]string(nil), actual...), append([]string(nil), expected...) + sort.Strings(left) + sort.Strings(right) + return reflect.DeepEqual(left, right) +} diff --git a/internal/releasegate/model_test.go b/internal/releasegate/model_test.go new file mode 100644 index 000000000..e6247182b --- /dev/null +++ b/internal/releasegate/model_test.go @@ -0,0 +1,345 @@ +package releasegate + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/packagede2e" + "cyberagent-workbench/internal/producte2e" +) + +func TestAggregateFilesRequiresExactCompleteCandidateBoundEvidence(t *testing.T) { + paths := writeValidInputs(t) + report, err := AggregateFiles(paths) + if err != nil { + t.Fatal(err) + } + if report.Status != StatusPassed || !report.Gate.ReleaseAuthorized || + report.Coverage.SecurityPassedRuns != 75 || report.Coverage.ProductScenarios != 4 || + len(report.Coverage.EdgeCases) != 8 || report.Components.Product.ChainSHA256 == "" { + t.Fatalf("unexpected aggregate: %+v", report) + } + output := filepath.Join(t.TempDir(), "standard-code-release-gate.json") + if err := WriteReport(output, report); err != nil { + t.Fatal(err) + } + if err := VerifyReport(output, report); err != nil { + t.Fatal(err) + } + if err := WriteReport(output, report); err == nil { + t.Fatal("aggregate report overwrite was accepted") + } + content, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{`C:\\Users\\operator`, "/home/operator", `"skip_accepted": true`} { + if strings.Contains(string(content), forbidden) { + t.Fatalf("aggregate leaked forbidden content %q", forbidden) + } + } +} + +func TestAggregateFilesRejectsCrossCandidateReplayAndUnknownFields(t *testing.T) { + paths := writeValidInputs(t) + product := readProductReport(t, paths.ProductReport) + product.Candidate.Revision = strings.Repeat("9", 40) + product, err := product.Seal() + if err != nil { + t.Fatal(err) + } + writeJSON(t, paths.ProductReport, product) + if _, err := AggregateFiles(paths); err == nil || + !strings.Contains(err.Error(), "candidate bindings do not match") { + t.Fatalf("cross-candidate product report was accepted: %v", err) + } + + paths = writeValidInputs(t) + content, err := os.ReadFile(paths.ProductReport) + if err != nil { + t.Fatal(err) + } + var object map[string]any + if err := json.Unmarshal(content, &object); err != nil { + t.Fatal(err) + } + object["waiver_reason"] = "not allowed" + writeJSON(t, paths.ProductReport, object) + if _, err := AggregateFiles(paths); err == nil || + !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("unknown product report field was accepted: %v", err) + } +} + +func TestAggregateFilesRejectsTamperedSecurityAndBootstrapEvidence(t *testing.T) { + paths := writeValidInputs(t) + security := readSecurityReport(t, paths.SecurityReport) + security.Cases[0].Backends[0].Evidence = security.Cases[0].Backends[0].Evidence[1:] + writeJSON(t, paths.SecurityReport, security) + if _, err := AggregateFiles(paths); err == nil || + !strings.Contains(err.Error(), "security evidence is invalid") { + t.Fatalf("tampered security evidence was accepted: %v", err) + } + + paths = writeValidInputs(t) + bootstrap := readBootstrapReport(t, paths.BootstrapReport) + bootstrap.AttackMatrix.EvidencedCaseCount = 40 + bootstrap.AttackMatrix.RemainingRequiredCaseCount = 0 + bootstrap.AttackMatrix.Status = "passed" + writeJSON(t, paths.BootstrapReport, bootstrap) + if _, err := AggregateFiles(paths); err == nil || + !strings.Contains(err.Error(), "bootstrap attack matrix state is invalid") { + t.Fatalf("bootstrap verdict inflation was accepted: %v", err) + } +} + +func writeValidInputs(t *testing.T) InputPaths { + t.Helper() + root := t.TempDir() + revision := strings.Repeat("b", 40) + binary := filepath.Join(root, "TraverseBoard.exe") + archive := filepath.Join(root, "Prayu-portable-v0.1.0-test-windows-amd64.zip") + manifest := filepath.Join(root, "portable-zip-manifest.json") + metadata := filepath.Join(root, "release-metadata.json") + bootstrapPath := filepath.Join(root, "standard-code-packaged-e2e.json") + productPath := filepath.Join(root, "standard-code-product-e2e.json") + securityPath := filepath.Join(root, "standard-code-security-evidence.json") + writeBytes(t, binary, []byte("MZ\x00release-gate-test")) + writeBytes(t, archive, []byte("PK\x03\x04release-gate-test")) + writeBytes(t, manifest, []byte("{\"protocol_version\":\"portable_zip_manifest.v1\"}\n")) + writeBytes(t, metadata, []byte("{\"protocol_version\":\"portable_release_metadata.v1\"}\n")) + binarySHA, binarySize, err := digestRegularFile(binary, maximumCandidateBytes) + if err != nil { + t.Fatal(err) + } + archiveSHA, archiveSize, err := digestRegularFile(archive, maximumCandidateBytes) + if err != nil { + t.Fatal(err) + } + _, manifestSHA, err := readEvidenceFile(manifest) + if err != nil { + t.Fatal(err) + } + _, metadataSHA, err := readEvidenceFile(metadata) + if err != nil { + t.Fatal(err) + } + definition, err := packagede2e.LoadDefinition() + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, time.August, 27, 1, 0, 0, 0, time.UTC) + bootstrap := validBootstrap(now, revision, binarySHA, archiveSHA, + definition.ManifestSHA256, definition.MatrixSHA256) + product := validProductReport(t, now.Add(time.Minute), revision, binarySHA, binarySize, + archiveSHA, archiveSize, manifestSHA, metadataSHA, + definition.ManifestSHA256, definition.MatrixSHA256) + security := validSecurityReport(t, now.Add(2*time.Minute), revision, binarySHA, archiveSHA, + filepath.Base(archive)) + writeJSON(t, bootstrapPath, bootstrap) + writeJSON(t, productPath, product) + writeJSON(t, securityPath, security) + return InputPaths{BinaryPath: binary, ArchivePath: archive, + PortableManifest: manifest, ReleaseMetadata: metadata, + BootstrapReport: bootstrapPath, ProductReport: productPath, + SecurityReport: securityPath, ExpectedRevision: revision} +} + +func validBootstrap(now time.Time, revision, binarySHA, archiveSHA, fixtureSHA, matrixSHA string) bootstrapReport { + resultIDs := []string{"candidate_provenance", "fixed_repository_oracle", + "exact_package_extraction", "packaged_default_start", + "packaged_operator_preview_kill_reopen", "fixed_repositories_immutable", + "credential_sentinel_non_persistence", "candidate_process_cleanup", + "owned_harness_cleanup"} + results := make([]bootstrapResult, 0, len(resultIDs)) + for _, id := range resultIDs { + results = append(results, bootstrapResult{ID: id, Status: "pass", Facts: json.RawMessage(`{}`)}) + } + return bootstrapReport{ProtocolVersion: packagede2e.PackagedE2EProtocol, + GeneratedAt: now, Issue: IssueNumber, BootstrapStatus: "pass", + ReleaseGateStatus: "needs_full_matrix", + Candidate: bootstrapCandidate{Version: "v0.1.0-test", Revision: revision, + ArchiveSHA256: archiveSHA, BinarySHA256: binarySHA, SourceDateEpoch: 1_700_000_000}, + FixtureSet: bootstrapFixtureSet{ProtocolVersion: packagede2e.FixtureSetProtocol, + ManifestSHA256: fixtureSHA, AttackMatrixSHA256: matrixSHA, + RepositoryCount: 4, AttackCaseCount: 40, OracleVerified: true, + AllAttackCasesBound: true}, + Results: results, + AttackMatrix: bootstrapAttackMatrix{RequiredCaseCount: 40, PreparedCaseCount: 40, + EvidencedCaseCount: 0, RemainingRequiredCaseCount: 40, + Status: "needs_full_matrix", FailurePolicy: "fail_closed_no_waiver", + UnexecutedCasesAreNotPassOrSkip: true}} +} + +func validProductReport(t *testing.T, now time.Time, revision, binarySHA string, binarySize int64, + archiveSHA string, archiveSize int64, manifestSHA, metadataSHA, fixtureSHA, matrixSHA string, +) producte2e.Report { + t.Helper() + digest := strings.Repeat("a", 64) + languages := []string{"go", "node", "python", "rust"} + report := producte2e.Report{ProtocolVersion: producte2e.ReportProtocol, + Issue: producte2e.IssueNumber, Status: "pass", GeneratedAt: now, + Candidate: producte2e.CandidateEvidence{Version: "v0.1.0-test", Revision: revision, + BinarySHA256: binarySHA, BinarySizeBytes: binarySize, + ZipSHA256: archiveSHA, ZipSizeBytes: archiveSize, + ManifestSHA256: manifestSHA, ReleaseMetadataSHA256: metadataSHA}, + Fixture: producte2e.FixtureEvidence{ProtocolVersion: packagede2e.FixtureSetProtocol, + ReportSHA256: digest, ManifestSHA256: fixtureSHA, + AttackMatrixSHA256: matrixSHA, RepositoryCount: 4, OracleVerified: true}, + Backends: []producte2e.BackendSummary{{Backend: "local", State: "ready", PassedRuns: 4}, + {Backend: "docker", State: "approval_required", ApprovalID: "approval-docker", + FallbackReason: "docker_unavailable", EvidenceSHA256: digest}}, + Coverage: producte2e.Coverage{Languages: languages, + Backends: []string{"local", "docker"}, + Surfaces: []string{"desktop", "cli", "http", "handoff", "final"}, + EdgeCases: []string{"chinese_path", "space_path", "long_path", "crlf", + "dirty_tracked", "untracked", "binary", "concurrent_edit"}, + ContinuityCases: []string{"completed", "failed", "approval_wait", "restart"}, + OperatingSystems: []string{"windows_10", "windows_11"}, + DPIPercents: []int{100, 200}, RealFailureRetries: 4, RealProcessJobs: 8}, + Safeguards: producte2e.Safeguards{NetworkDisabled: true, CredentialsAbsent: true}, + RunbookSHA256: digest} + for _, language := range languages { + report.Scenarios = append(report.Scenarios, producte2e.ScenarioSummary{ + ID: language + "-local", Language: language, Backend: "local", + RunID: "run-" + language, ThreadID: "thread-" + language, + SessionID: "session-" + language, FixtureHead: strings.Repeat("c", 40), + ReadRounds: 2, AppliedEdits: 2, FailedJobs: 1, PassedJobs: 1, + FixRounds: 1, ArtifactCount: 2, ProjectionCount: 5, + ReceiptSHA256: digest, DiffSHA256: digest, + CheckpointID: "checkpoint-" + language, WorkspaceRevision: digest, + SourceWorkPreserved: true}) + } + for _, current := range []string{"completed", "failed", "approval_wait", "restart"} { + summary := producte2e.ContinuitySummary{Case: current, ThreadID: "thread-" + current, + RunID: "run-" + current, Verified: true, EvidenceSHA256: digest} + if current == "completed" || current == "failed" { + summary.SuccessorRunID = "successor-" + current + } + report.Continuity = append(report.Continuity, summary) + } + for _, osName := range []string{"windows_10", "windows_11"} { + for _, dpi := range []int{100, 200} { + report.Platforms = append(report.Platforms, producte2e.PlatformSummary{ + ID: osName + "-" + strconv.Itoa(dpi), OS: osName, + Build: "build-19045", DPIPercent: dpi, EvidenceSHA256: digest}) + } + } + sealed, err := report.Seal() + if err != nil { + t.Fatal(err) + } + return sealed +} + +func validSecurityReport(t *testing.T, now time.Time, revision, binarySHA, archiveSHA, archiveName string) packagede2e.StandardCodeSecurityEvidence { + t.Helper() + definition, err := packagede2e.LoadDefinition() + if err != nil { + t.Fatal(err) + } + digest := strings.Repeat("d", 64) + report := packagede2e.StandardCodeSecurityEvidence{ + Candidate: packagede2e.SecurityCandidateEvidence{SourceCommit: revision, + ExecutableSHA256: binarySHA, ArchiveSHA256: archiveSHA, ArchiveName: archiveName, + FixtureSHA256: definition.ManifestSHA256, + AttackMatrixSHA256: definition.MatrixSHA256, + OperatingSystem: runtime.GOOS, OSVersion: "test-os-build", Architecture: runtime.GOARCH}, + Backends: []packagede2e.SecurityBackendEvidence{ + {Backend: "local", Availability: packagede2e.SecurityBackendReady, + IdentitySHA256: digest, GenerationSHA256: strings.Repeat("e", 64), + Network: "disabled", Credentials: "none"}, + {Backend: "docker", Availability: packagede2e.SecurityBackendReady, + IdentitySHA256: strings.Repeat("f", 64), GenerationSHA256: strings.Repeat("1", 64), + Network: "disabled", Credentials: "none"}}, + Cleanup: packagede2e.SecurityCleanupEvidence{OwnedRootSHA256: strings.Repeat("2", 64), + OwnedProcessesStarted: 8, OwnedProcessesReaped: 8, OwnedDirectoriesOnly: true}, + StartedAt: now, CompletedAt: now.Add(time.Minute)} + for caseIndex, attack := range definition.AttackMatrix.Cases { + current := packagede2e.SecurityAttackCaseEvidence{ID: attack.ID, + Category: attack.Category, Phase: attack.Phase, + ExpectedOutcome: attack.ExpectedOutcome, ExpectedSignal: attack.ExpectedSignal} + for backendIndex, backend := range attack.Backends { + result := packagede2e.SecurityCaseBackendEvidence{Backend: backend, + FixtureID: attack.FixtureIDs[(caseIndex+backendIndex)%len(attack.FixtureIDs)], + Status: packagede2e.SecurityEvidencePassed, + ActualOutcome: attack.ExpectedOutcome, ActualSignal: attack.ExpectedSignal, + OperatorCode: "standard_code.attack." + attack.ExpectedSignal, + DiagnosticCode: "product.observed", ActualExecution: true, + StartedAt: now, CompletedAt: now.Add(time.Second)} + for _, kind := range attack.RequiredEvidence { + result.Evidence = append(result.Evidence, packagede2e.SecurityEvidenceRef{ + Kind: kind, Source: securityEvidenceSource(kind), SHA256: digest}) + } + current.Backends = append(current.Backends, result) + } + report.Cases = append(report.Cases, current) + } + if err := packagede2e.FinalizeStandardCodeSecurityEvidence(&report); err != nil { + t.Fatal(err) + } + return report +} + +func securityEvidenceSource(kind string) string { + values := map[string]string{"operator_ui": "desktop.projection", + "immutable_event": "run.event", "workspace_digest": "drydock.observation", + "process_receipt": "command_runtime.receipt", "network_observation": "sandbox.network", + "artifact_digest": "artifact.store", "thread_transcript": "thread.projection", + "checkpoint": "workspace.checkpoint"} + return values[kind] +} + +func readProductReport(t *testing.T, path string) producte2e.Report { + t.Helper() + var report producte2e.Report + readJSON(t, path, &report) + return report +} + +func readSecurityReport(t *testing.T, path string) packagede2e.StandardCodeSecurityEvidence { + t.Helper() + var report packagede2e.StandardCodeSecurityEvidence + readJSON(t, path, &report) + return report +} + +func readBootstrapReport(t *testing.T, path string) bootstrapReport { + t.Helper() + var report bootstrapReport + readJSON(t, path, &report) + return report +} + +func readJSON(t *testing.T, path string, target any) { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(content, target); err != nil { + t.Fatal(err) + } +} + +func writeJSON(t *testing.T, path string, value any) { + t.Helper() + content, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + writeBytes(t, path, append(content, '\n')) +} + +func writeBytes(t *testing.T, path string, content []byte) { + t.Helper() + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/releasegate/workflow_contract_test.go b/internal/releasegate/workflow_contract_test.go new file mode 100644 index 000000000..f824ebf41 --- /dev/null +++ b/internal/releasegate/workflow_contract_test.go @@ -0,0 +1,66 @@ +package releasegate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestDesktopReleaseWorkflowWiresEvidenceAndReverifiesBeforePublish(t *testing.T) { + root := filepath.Clean(filepath.Join("..", "..")) + path := filepath.Join(root, ".github", "workflows", "release-desktop.yml") + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var document yaml.Node + if err := yaml.Unmarshal(content, &document); err != nil { + t.Fatalf("release workflow YAML is invalid: %v", err) + } + text := string(content) + required := []string{ + "cmd/releasegate/**", + "internal/releasegate/**", + "Validate Draft Release evidence intake", + "Require successful central CI for the exact release revision", + "The exact release revision has no successful central CI push run", + "standard-code-product-e2e.json", + "standard-code-security-evidence.json", + "standard-code-release-gate.json", + "-RequireReleaseGate", + "--verify-report build/desktop/standard-code-release-gate.json", + "The candidate evidence Draft Release is no longer available", + "Standard Code Beta releases must use a prerelease version", + `"--draft=false"`, + `"--prerelease"`, + } + for _, value := range required { + if !strings.Contains(text, value) { + t.Fatalf("release workflow is missing %q", value) + } + } + for _, forbidden := range []string{"continue-on-error: true", "|| true", "waiver: true", `"--latest"`} { + if strings.Contains(text, forbidden) { + t.Fatalf("release workflow contains fail-open construct %q", forbidden) + } + } +} + +func TestPackagedHarnessRequiresBothReportsForAggregate(t *testing.T) { + path := filepath.Clean(filepath.Join("..", "..", "scripts", "standard-code-packaged-e2e.ps1")) + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(content) + for _, value := range []string{"$productReportProvided -ne $securityReportProvided", + "$RequireReleaseGate -and -not $productReportProvided", + "go run ./cmd/releasegate", "standard_code_release_gate: passed"} { + if !strings.Contains(text, value) { + t.Fatalf("packaged harness is missing %q", value) + } + } +} diff --git a/protocols/registry.json b/protocols/registry.json index cfb137b0c..086b97896 100644 --- a/protocols/registry.json +++ b/protocols/registry.json @@ -2974,6 +2974,14 @@ ], "reason": "Local-only issue #182 packaged product session orchestration state." }, + { + "identifier": "standard_code_release_gate.v1", + "kind": "conformance-test", + "sources": [ + "internal/releasegate/model.go" + ], + "reason": "Issue #140 deterministic candidate-bound aggregate of independently validated bootstrap, product, and security evidence; it grants no runtime authority." + }, { "identifier": "standard_code_security_probe.v1", "kind": "conformance-test", diff --git a/scripts/standard-code-packaged-e2e.ps1 b/scripts/standard-code-packaged-e2e.ps1 index dd7526510..cde66aa65 100644 --- a/scripts/standard-code-packaged-e2e.ps1 +++ b/scripts/standard-code-packaged-e2e.ps1 @@ -4,7 +4,11 @@ param( [string]$ExpectedVersion = "", [string]$ExpectedRevision = "", [ValidateRange(5, 60)][int]$StartupTimeoutSeconds = 20, - [string]$OutputPath = "" + [string]$OutputPath = "", + [string]$ProductReportPath = "", + [string]$SecurityReportPath = "", + [string]$AggregateOutputPath = "", + [switch]$RequireReleaseGate ) <# @@ -18,9 +22,10 @@ verified portable ZIP, and exercises default and safe operator-preview startup against an isolated CYBERAGENT_HOME. This is a packaged bootstrap for issue #140. It deliberately reports -needs_full_matrix until every required Local Sandbox, Docker, and manual host -attack case has immutable evidence. It never labels an unexecuted case as pass -or skip. +needs_full_matrix until both independent product and security reports are +supplied. When both reports are present, the Go-owned aggregate gate strictly +revalidates their self hashes and binds them to this exact candidate. It never +labels an unexecuted case as pass or skip. #> $ErrorActionPreference = "Stop" @@ -74,6 +79,18 @@ if (-not $outputRoot.StartsWith($repositoryPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Packaged E2E release input must remain inside the repository" } +$outputPrefix = $outputRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +$productReportProvided = -not [string]::IsNullOrWhiteSpace($ProductReportPath) +$securityReportProvided = -not [string]::IsNullOrWhiteSpace($SecurityReportPath) +if ($productReportProvided -ne $securityReportProvided) { + throw "Product and security reports must be supplied together" +} +if ($RequireReleaseGate -and -not $productReportProvided) { + throw "The complete release gate requires product and security reports" +} +if (-not $productReportProvided -and -not [string]::IsNullOrWhiteSpace($AggregateOutputPath)) { + throw "AggregateOutputPath requires product and security reports" +} if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = Join-Path $outputRoot "standard-code-packaged-e2e.json" } @@ -90,6 +107,49 @@ if (Test-Path -LiteralPath $output) { throw "Packaged E2E evidence output must not already exist" } +function Resolve-GateEvidenceInput { + param([Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label) + $resolved = if ([System.IO.Path]::IsPathRooted($Path)) { + [System.IO.Path]::GetFullPath($Path) + } else { + [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot $Path)) + } + if (-not $resolved.StartsWith($outputPrefix, + [System.StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $resolved -PathType Leaf)) { + throw "$Label must be an existing file below the release output directory" + } + $item = Get-Item -LiteralPath $resolved -Force + if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "$Label must not be a reparse point" + } + return $resolved +} + +$productEvidence = $null +$securityEvidence = $null +$aggregateOutput = $null +if ($productReportProvided) { + $productEvidence = Resolve-GateEvidenceInput -Path $ProductReportPath ` + -Label "Product evidence" + $securityEvidence = Resolve-GateEvidenceInput -Path $SecurityReportPath ` + -Label "Security evidence" + if ([string]::IsNullOrWhiteSpace($AggregateOutputPath)) { + $AggregateOutputPath = Join-Path $outputRoot "standard-code-release-gate.json" + } + $aggregateOutput = if ([System.IO.Path]::IsPathRooted($AggregateOutputPath)) { + [System.IO.Path]::GetFullPath($AggregateOutputPath) + } else { + [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot $AggregateOutputPath)) + } + if (-not $aggregateOutput.StartsWith($outputPrefix, + [System.StringComparison]::OrdinalIgnoreCase) -or + (Test-Path -LiteralPath $aggregateOutput)) { + throw "Aggregate release gate output must be a new file below the release output directory" + } +} + function Assert-E2ECondition { param([bool]$Condition, [string]$Code) if (-not $Condition) { throw "Packaged E2E condition failed: $Code" } @@ -644,3 +704,25 @@ Write-Output "standard_code_packaged_e2e_written: $output" Write-Output "standard_code_packaged_e2e_bootstrap: $(if ($bootstrapFailed) { 'fail' } else { 'pass' })" Write-Output "standard_code_packaged_e2e_release_gate: $(if ($bootstrapFailed) { 'fail' } else { 'needs_full_matrix' })" if ($bootstrapFailed) { throw "Standard Code packaged E2E bootstrap failed" } + +if ($productReportProvided) { + $candidateBinary = Join-Path $outputRoot "TraverseBoard.exe" + $candidateArchive = Join-Path $outputRoot ([string]$manifest.zip_name) + Push-Location $repositoryRoot + try { + & go run ./cmd/releasegate ` + --binary $candidateBinary ` + --archive $candidateArchive ` + --portable-manifest (Join-Path $outputRoot "portable-zip-manifest.json") ` + --release-metadata (Join-Path $outputRoot "release-metadata.json") ` + --bootstrap $output ` + --product $productEvidence ` + --security $securityEvidence ` + --expected-revision ([string]$manifest.revision) ` + --report $aggregateOutput + if ($LASTEXITCODE -ne 0) { throw "Standard Code aggregate release gate failed" } + } + finally { Pop-Location } + Write-Output "standard_code_release_gate_written: $aggregateOutput" + Write-Output "standard_code_release_gate: passed" +}