diff --git a/.github/workflows/ci-typecheck.yml b/.github/workflows/ci-typecheck.yml index 863d90751..5ac01932b 100644 --- a/.github/workflows/ci-typecheck.yml +++ b/.github/workflows/ci-typecheck.yml @@ -54,3 +54,10 @@ jobs: working-directory: packages/opencode run: bun run test:dag-core timeout-minutes: 10 + + # #498 B1: archive integrity boundary of the `oc` installer — SHA256SUMS + # must be verified before extraction and fail closed on mismatch. + # Zero network (stub curl); portable across bash hosts. + - name: Run oc install boundary tests + run: bash script/oc-install-boundary.test.sh + timeout-minutes: 5 diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index 06cf50e09..d65620d6a 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -241,6 +241,17 @@ jobs: fi done + # #498 B2: macOS release acceptance — after the installer-style xattr + + # ad-hoc re-sign mutation, assert codesign validity and executable smoke. + # The installed binary's hash is intentionally NOT compared to the + # archive payload (ad-hoc re-signing can rewrite bytes, so byte equality + # is not a stable signature-validity boundary and differing hashes are + # legitimate); no post-sign digest. + - name: macOS Install Acceptance + if: matrix.name == 'macos' && (inputs.platforms == '' || contains(inputs.platforms, matrix.name)) + run: bash script/oc-macos-acceptance.test.sh packages/opencode/dist/opencode-darwin-arm64.zip + timeout-minutes: 10 + - name: Upload Artifacts if: inputs.platforms == '' || contains(inputs.platforms, matrix.name) uses: actions/upload-artifact@v4 diff --git a/.specgit.yaml b/.specgit.yaml index 006695b95..08d8ae885 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,8 @@ version: 1 -delivery: specgit-pr-base-dev +delivery: macos-integrity-boundary context: kind: branch - branch: feat/528-specgit-pr-base-dev + branch: feat/498-macos-integrity-boundary issues: - - 528 -pr: 535 + - 498 +pr: 536 diff --git a/README.md b/README.md index 9fb28054d..5dba3b738 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,11 @@ All upstream capabilities (multi-provider, built-in LSP, client/server architect Prebuilt CLI binaries (Linux / macOS / Windows, with SHA256SUMS) are published on the [releases page](https://github.com/LeXwDeX/OpenCode-GraphAgent/releases). Builds from `main` are formal releases; builds from `dev` are prereleases. +Release acceptance enforces two distinct integrity boundaries: + +- **Archive integrity (before extraction)**: the `oc` installer verifies the release `SHA256SUMS` entry before unpacking and refuses to extract on mismatch. If upstream serves no `SHA256SUMS`, it installs with a warning (GitHub HTTPS transport only). +- **Post-install signature validity (macOS)**: the installer clears quarantine attributes (`xattr -cr`) and ad-hoc re-signs (`codesign -fs -`); acceptance then asserts `codesign --verify` passes and the binary runs. The installed binary's hash is intentionally **not** compared to the archive payload — ad-hoc signing can rewrite the binary's bytes, so differing hashes are legitimate. No post-signature digest is published (cross-version reproducibility of codesign output has not been established, and no supported reproducibility matrix exists). + From source (requires [Bun](https://bun.sh) 1.3+): ```bash diff --git a/README.zh.md b/README.zh.md index 02e40fc1e..5e2e0482f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -250,6 +250,11 @@ DAG 相关的东西都放在 `.opencode/` 下,在 opencode 配置目录(`OPE 预构建 CLI 二进制(Linux / macOS / Windows,附 SHA256SUMS)发布在 [releases 页面](https://github.com/LeXwDeX/OpenCode-GraphAgent/releases)。从 `main` 构建的是正式版;从 `dev` 构建的是预发布版。 +发布验收维护两条相互独立的完整性边界: + +- **归档完整性(解包前)**:`oc` 安装器在解包前校验 release 的 `SHA256SUMS` 条目,不匹配则拒绝解包。若上游未提供 `SHA256SUMS`,则告警后继续安装(仅依赖 GitHub HTTPS 传输安全)。 +- **安装后签名有效性(macOS)**:安装器清除 quarantine 属性(`xattr -cr`)并做 ad-hoc 重签名(`codesign -fs -`),验收断言 `codesign --verify` 通过且二进制可执行。安装后的二进制 hash **有意**不与归档 payload 对比——ad-hoc 签名可能改写二进制字节,两者 hash 即使不同也属正常。也不发布签名后 digest(codesign 输出的跨版本可复现性尚未确立,亦无受支持的可复现性矩阵)。 + 从源码构建(需要 [Bun](https://bun.sh) 1.3+): ```bash diff --git a/oc b/oc index 52da44d15..b935d63ca 100755 --- a/oc +++ b/oc @@ -1270,4 +1270,9 @@ main() { # OC 不提供 CLI 子命令。所有功能通过 TUI 菜单访问。 # 无论传入何种参数,均直接进入 TUI 主界面。 -main +# 仅在直接执行时进入 TUI;被 source(测试/验收 harness)时跳过。 +# 用 if 而非 `cond && main`:sourced 且条件为假时 if 返回 0, +# `cond && main` 会以非 0 结束并在 set -e 下中断 source。 +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main +fi diff --git a/script/oc-install-boundary.test.sh b/script/oc-install-boundary.test.sh new file mode 100755 index 000000000..5c0b4267d --- /dev/null +++ b/script/oc-install-boundary.test.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2015,SC2317 +# ok/bad always return 0, so `cond && ok .. || bad ..` cannot mis-fire (SC2015); +# cleanup() runs via the EXIT trap, which shellcheck does not count (SC2317). +# +# B1 (archive integrity, #498): the installer must verify SHA256SUMS BEFORE +# extraction and fail closed on mismatch. These tests pin that boundary by +# driving the real `do_upgrade` path A (remote hash → download → verify → +# extract) from `./oc` sourced in a sandboxed subshell. +# +# tamper — SUMS declares hash A, archive is hash B: do_upgrade must exit +# non-zero AND the install target must not exist (extraction never +# happened). If anyone moves verify after extract, this goes red. +# match — consistent SUMS: end-to-end install succeeds and the installed +# binary smokes (`--version`). +# missing — upstream serves no SUMS: current behavior is warn-and-continue +# (HTTPS transport only). Pinned explicitly so a silent change to +# that policy is a visible test change, not a drift. +# +# Zero network: `curl` is a stub placed first on PATH mapping release URLs to +# local fixtures (same pattern as script/specgit-bootstrap.test.sh); `fzf` is +# stubbed so hosts without it can still source ./oc. HOME, OC_INSTALL_DIR and +# OC_LOCAL_DIR point into a throwaway sandbox; the repo and host are never +# touched. All do_upgrade invocations run in subshells because `die` calls +# exit, which under `source` would kill the harness. +# +# Wired into ci-typecheck.yml (#498); also runnable manually on any bash host: +# bash script/oc-install-boundary.test.sh +set -u + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +# OC_TEST_OC_PATH: target script override (falsifier drills can point at a +# mutated copy, e.g. verify/extract order swapped, to show the suite goes red). +OC="${OC_TEST_OC_PATH:-$ROOT/oc}" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/oc-install-boundary-test.XXXXXX") +PASS=0 +FAIL=0 +TAG="v9.9.8-oc498" +FAKE_VERSION="9.9.8-oc498" + +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +ok() { PASS=$((PASS + 1)); printf 'ok - %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf 'FAIL - %s\n' "$1"; } + +report() { + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" + [ "$FAIL" -eq 0 ] +} + +digest() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +# Mirror oc's detect_asset for hosts this suite supports; anything else skips. +asset_for_host() { + local os arch + os=$(uname -s) + arch=$(uname -m) + case "$os" in + Linux) + case "$arch" in + x86_64|amd64) echo "opencode-linux-x64.tar.gz" ;; + *) echo "" ;; + esac + ;; + Darwin) + case "$arch" in + arm64|aarch64) echo "opencode-darwin-arm64.zip" ;; + *) echo "" ;; + esac + ;; + *) echo "" ;; + esac +} + +ASSET=$(asset_for_host) +if [ -z "$ASSET" ]; then + echo "skip: unsupported host platform ($(uname -s)/$(uname -m)); suite covers linux-x64 and darwin-arm64" + exit 0 +fi + +# ---- fixtures --------------------------------------------------------------- + +# A minimal "binary" whose --version smoke output the cases assert on. +FIXTURE_BIN_DIR="$WORK/fixture/root" +mkdir -p "$FIXTURE_BIN_DIR" +cat > "$FIXTURE_BIN_DIR/opencode" < "$SUMS_DIR/match" +printf '%s %s\n' "0000000000000000000000000000000000000000000000000000000000000000" "$ASSET" > "$SUMS_DIR/tamper" +# missing: discovery (stdout) still sees valid SUMS; only try_verify's fetch +# fails, which is how oc's warn-and-continue verify branch is reachable. +cp "$SUMS_DIR/match" "$SUMS_DIR/missing" + +# ---- stub tools ------------------------------------------------------------- + +make_stub_dir() { # + local dir="$WORK/stubs/$1" + mkdir -p "$dir" + cat > "$dir/curl" <<'EOF' +#!/usr/bin/env bash +# stub curl: maps release URLs to local fixtures. -o writes to a file, +# otherwise SUMS content goes to stdout (fetch_remote_hash's contract). +# Fully literal: all knobs (OC_TEST_*, SUMS_DIR) arrive via the environment. +set -u +url="" +outfile="" +args=("$@") +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[$i]}" in + -o) i=$((i + 1)); outfile="${args[$i]}" ;; + -*) ;; + *) url="${args[$i]}" ;; + esac +done +mode="${OC_TEST_SUMS:-match}" +case "$url" in + */SHA256SUMS) + # "missing" fails only the -o fetch (try_verify_sha256); the stdout fetch + # (fetch_remote_hash) still succeeds, so path A runs and oc's + # warn-and-continue branch at the verify step is what gets exercised. + [ "$mode" = "missing" ] && [ -n "$outfile" ] && exit 22 + if [ -n "$outfile" ]; then + cat "$SUMS_DIR/$mode" > "$outfile" + else + cat "$SUMS_DIR/$mode" + fi + exit 0 + ;; + */$OC_TEST_ASSET) + [ -n "$outfile" ] || exit 22 + cat "$OC_TEST_ARCHIVE" > "$outfile" + exit 0 + ;; +esac +exit 22 +EOF + # oc `need fzf` before anything runs; the TUI never starts under the guard. + printf '#!/bin/sh\nexit 0\n' > "$dir/fzf" + chmod +x "$dir/curl" "$dir/fzf" +} + +# run_case — env knobs (OC_TEST_SUMS) must already be exported. +run_case() { # + local case="$1" + local sbx="$WORK/$case" + mkdir -p "$sbx/home" "$sbx/bin" "$sbx/local-missing" + make_stub_dir "$case" + ( + cd "$sbx" + export PATH="$WORK/stubs/$case:$PATH" + export HOME="$sbx/home" + export OC_INSTALL_DIR="$sbx/bin" + export OC_OPENCODE_NAME="opencode" + # No candidate dir has VERSION + asset (sandbox dirs are empty), so + # find_local_dir fails and do_upgrade takes path A (online download). + export OC_LOCAL_DIR="$sbx/local-missing" + export OC_TEST_ARCHIVE="$ARCHIVE" + export OC_TEST_ASSET="$ASSET" + export OC_TEST_TAG="$TAG" + export OC_TEST_OC="$OC" + export SUMS_DIR + bash -c ' + set -u + source "$OC_TEST_OC" + set +euo pipefail + rc=0 + do_upgrade "$OC_TEST_TAG" || rc=$? + exit "$rc" + ' + ) > "$WORK/$case.out" 2> "$WORK/$case.err" +} + +target_of() { # + printf '%s' "$WORK/$1/bin/opencode" +} + +# ---- case tamper: SUMS mismatch must fail closed BEFORE extraction ---------- +OC_TEST_SUMS=tamper run_case tamper +rc=$? +assert_rc() { [ "$1" = "$2" ]; } +assert_rc 1 "$rc" && ok "tamper: do_upgrade exits non-zero on hash mismatch" || bad "tamper: exit $rc, want non-zero (1)" +grep -q "SHA256 不匹配" "$WORK/tamper.err" && ok "tamper: die reports SHA256 mismatch" || bad "tamper: no mismatch diagnostic: $(tr '\n' '|' < "$WORK/tamper.err")" +[ ! -e "$(target_of tamper)" ] && ok "tamper: install target absent — extraction never ran after verify" || bad "tamper: target exists; verification did not gate extraction" + +# ---- case match: consistent SUMS installs end-to-end ------------------------ +run_case match +rc=$? +assert_rc 0 "$rc" && ok "match: do_upgrade exits 0" || bad "match: exit $rc, want 0: $(tr '\n' '|' < "$WORK/match.err")" +target=$(target_of match) +[ -x "$target" ] && ok "match: target installed and executable" || bad "match: target missing or not executable: $target" +[ "$("$target" --version 2>/dev/null)" = "$FAKE_VERSION" ] && ok "match: installed binary smoke --version correct" || bad "match: smoke output '$("$target" --version 2>/dev/null)', want $FAKE_VERSION" + +# ---- case missing: warn-and-continue policy pinned as-is (#498 non-goal) ---- +OC_TEST_SUMS=missing run_case missing +rc=$? +assert_rc 0 "$rc" && ok "missing: warn-skip policy continues to install (current behavior)" || bad "missing: exit $rc, want 0: $(tr '\n' '|' < "$WORK/missing.err")" +grep -q "未提供 SHA256SUMS" "$WORK/missing.err" && ok "missing: warns about absent SHA256SUMS" || bad "missing: no warn diagnostic: $(tr '\n' '|' < "$WORK/missing.err")" +[ -x "$(target_of missing)" ] && ok "missing: install completed under warn-skip" || bad "missing: target not installed" + +report +exit $? diff --git a/script/oc-macos-acceptance.test.sh b/script/oc-macos-acceptance.test.sh new file mode 100755 index 000000000..77e6470a1 --- /dev/null +++ b/script/oc-macos-acceptance.test.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2015,SC2317 +# ok/bad always return 0, so `cond && ok .. || bad ..` cannot mis-fire (SC2015); +# cleanup() runs via the EXIT trap, which shellcheck does not count (SC2317). +# +# B2 (macOS post-install acceptance, #498): after the installer-style mutation +# (quarantine clearing via `xattr -cr` + ad-hoc re-sign via `codesign -fs -`, +# the real code path in oc's extract_and_install), acceptance must assert: +# +# 1. code-signature VALIDITY: `codesign --verify --strict` passes +# 2. executable smoke: the installed binary runs and answers --version +# 3. structural: the installer never compares the installed binary's hash to +# the archive payload — ad-hoc re-signing can rewrite bytes, so byte +# equality is not a stable signature-validity boundary, and #498 forbids +# publishing a post-sign digest without a supported codesign +# reproducibility matrix +# +# The verify assertion is self-checked: a negative control tampers a copy of +# the installed binary and must FAIL codesign --verify, proving assertion 1 is +# not vacuous. +# +# Modes: +# bash script/oc-macos-acceptance.test.sh +# self-sufficient: compiles an unsigned C stub and exercises the full +# installer flow through the same stub-curl harness as +# script/oc-install-boundary.test.sh (zero network). +# bash script/oc-macos-acceptance.test.sh +# CI release mode: runs the flow against a real packaged artifact, +# e.g. packages/opencode/dist/opencode-darwin-arm64.zip. +# +# Darwin-only; any other host exits 0. Wired into release-fork.yml (macOS job). +set -u + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +OC="$ROOT/oc" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/oc-macos-acceptance.XXXXXX") +PASS=0 +FAIL=0 +TAG="v9.9.8-oc498" +STUB_VERSION="9.9.8-oc498" +ASSET="opencode-darwin-arm64.zip" + +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +ok() { PASS=$((PASS + 1)); printf 'ok - %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf 'FAIL - %s\n' "$1"; } + +report() { + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" + [ "$FAIL" -eq 0 ] +} + +if [ "$(uname -s)" != "Darwin" ]; then + echo "skip: macOS acceptance boundary runs on Darwin only ($(uname -s))" + exit 0 +fi + +for tool in cc codesign xattr zip unzip; do + if ! command -v "$tool" >/dev/null 2>&1; then + bad "required tool present: $tool" + report + exit 1 + fi +done + +digest() { shasum -a 256 "$1" | awk '{print $1}'; } + +# ---- fixture archive --------------------------------------------------------- + +ARTIFACT="${1:-}" +if [ -n "$ARTIFACT" ]; then + if [ ! -s "$ARTIFACT" ]; then + bad "artifact zip exists: $ARTIFACT" + report + exit 1 + fi + # Use the real artifact verbatim: extract_and_install finds the binary via + # `find -name "opencode*"`, which matches the packaged layout. + cp "$ARTIFACT" "$WORK/$ASSET" +else + cat > "$WORK/stub.c" <<'EOF' +#include +int main(void) { printf("STUB_VERSION_PLACEHOLDER\n"); return 0; } +EOF + sed -i '' "s/STUB_VERSION_PLACEHOLDER/$STUB_VERSION/" "$WORK/stub.c" + mkdir -p "$WORK/root" + cc -o "$WORK/root/opencode" "$WORK/stub.c" || { + bad "stub binary compiles" + report + exit 1 + } + # Simulate an untrusted payload: strip the linker's ad-hoc signature so the + # installer's re-sign step is what makes the binary valid. + codesign --remove-signature "$WORK/root/opencode" 2>/dev/null || true + (cd "$WORK/root" && zip -q "$WORK/$ASSET" opencode) +fi +ARCHIVE="$WORK/$ASSET" +[ -s "$ARCHIVE" ] || { bad "fixture archive built"; report; exit 1; } + +REAL_HASH=$(digest "$ARCHIVE") +SUMS_DIR="$WORK/sums" +mkdir -p "$SUMS_DIR" +printf '%s %s\n' "$REAL_HASH" "$ASSET" > "$SUMS_DIR/match" + +# ---- stub tools (same contract as oc-install-boundary.test.sh) --------------- + +STUBDIR="$WORK/stubs" +mkdir -p "$STUBDIR" +cat > "$STUBDIR/curl" <<'EOF' +#!/usr/bin/env bash +set -u +url="" +outfile="" +args=("$@") +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[$i]}" in + -o) i=$((i + 1)); outfile="${args[$i]}" ;; + -*) ;; + *) url="${args[$i]}" ;; + esac +done +case "$url" in + */SHA256SUMS) + if [ -n "$outfile" ]; then + cat "$SUMS_DIR/match" > "$outfile" + else + cat "$SUMS_DIR/match" + fi + exit 0 + ;; + */$OC_TEST_ASSET) + [ -n "$outfile" ] || exit 22 + cat "$OC_TEST_ARCHIVE" > "$outfile" + exit 0 + ;; +esac +exit 22 +EOF +printf '#!/bin/sh\nexit 0\n' > "$STUBDIR/fzf" +chmod +x "$STUBDIR/curl" "$STUBDIR/fzf" + +# ---- run the installer flow against the real Darwin mutation path ---------- + +SBX="$WORK/sbx" +mkdir -p "$SBX/home" "$SBX/bin" "$SBX/local-missing" +( + cd "$SBX" + export PATH="$STUBDIR:$PATH" + export HOME="$SBX/home" + export OC_INSTALL_DIR="$SBX/bin" + export OC_OPENCODE_NAME="opencode" + export OC_LOCAL_DIR="$SBX/local-missing" + export SUMS_DIR + export OC_TEST_ARCHIVE="$ARCHIVE" + export OC_TEST_ASSET="$ASSET" + export OC_TEST_OC="$OC" + export OC_TEST_TAG="$TAG" + bash -c ' + set -u + source "$OC_TEST_OC" + set +euo pipefail + rc=0 + do_upgrade "$OC_TEST_TAG" || rc=$? + exit "$rc" + ' +) > "$WORK/run.out" 2> "$WORK/run.err" + +rc=$? +[ "$rc" -eq 0 ] || bad "do_upgrade exits 0 (installer flow): $(tr '\n' '|' < "$WORK/run.err")" + +TARGET="$SBX/bin/opencode" +[ -x "$TARGET" ] && ok "installer flow completed: target installed and executable" \ + || { bad "target missing or not executable: $TARGET"; report; exit 1; } + +# ---- assertion 1: signature validity after installer-style re-sign ---------- + +if codesign --verify --strict "$TARGET" 2>"$WORK/verify.err"; then + ok "codesign --verify --strict passes on installed binary (B2 validity)" +else + bad "codesign --verify failed on installed binary: $(tr '\n' '|' < "$WORK/verify.err")" +fi + +# Negative control: the validity assertion must be able to fail. Tampering a +# copy breaks the sealed resources, so verify must reject it. +cp "$TARGET" "$WORK/tampered" +printf 'x' >> "$WORK/tampered" +if codesign --verify --strict "$WORK/tampered" 2>/dev/null; then + bad "negative control: tampered copy passed codesign --verify (assertion is vacuous)" +else + ok "negative control: tampered copy rejected by codesign --verify (assertion non-vacuous)" +fi + +# ---- assertion 2: executable smoke ------------------------------------------- + +smoke=$("$TARGET" --version 2>/dev/null) +if [ -n "$ARTIFACT" ]; then + [ "$("$TARGET" --version >/dev/null 2>&1; echo $?)" = "0" ] && [ -n "$smoke" ] \ + && ok "artifact smoke: --version exits 0 with output" \ + || bad "artifact smoke failed: output '$smoke'" +else + [ "$smoke" = "$STUB_VERSION" ] \ + && ok "smoke: installed binary --version correct ($STUB_VERSION)" \ + || bad "smoke output '$smoke', want $STUB_VERSION" +fi + +# ---- assertion 3: structural — installer never hash-compares the target ----- + +# B2 draws the boundary at signature validity + smoke, NOT at byte identity: +# ad-hoc re-signing can rewrite bytes, so byte equality is not a stable +# signature-validity boundary and differing hashes are legitimate. Pin the +# absence of such a comparison. +if grep -nE 'file_sha256.*\$\{?target' "$OC" > "$WORK/struct-grep.txt"; then + bad "structural: installer compares installed binary hash to payload: $(tr '\n' '|' < "$WORK/struct-grep.txt")" +else + ok "structural: no installed-binary hash comparison in oc (B2 is not a digest boundary)" +fi +# The only hash machinery in the install path must live on the archive side +# (try_verify_sha256), before extraction. +if grep -nE 'try_verify_sha256' "$OC" | grep -q 'extract_and_install' ; then + bad "structural: verify and extract lines fused unexpectedly" +else + ok "structural: SHA256SUMS machinery stays on the pre-extract side" +fi + +report +exit $?