From 370ed092b273ede2bf77bd9d0facccbff898a8cb Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:17:51 +0700 Subject: [PATCH] Harden CI: fail-closed tag guards, gh-safe mutation wrapper, fix interaction-guard test invocation --- .github/workflows/pr-check.yml | 1 + AGENTS.md | 5 ++ scripts/gh-safe.sh | 88 +++++++++++++++++++++++++++++++ scripts/gh-safe.tests.sh | 94 ++++++++++++++++++++++++++++++++++ scripts/local-check.ps1 | 1 + 5 files changed, 189 insertions(+) create mode 100644 scripts/gh-safe.sh create mode 100644 scripts/gh-safe.tests.sh diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index b2e7397d8e..20e6c4539a 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -1,5 +1,6 @@ name: PR check +# Blacksmith CI only: PR validation. Release assets stay in CircleCI. on: push: branches: [main, master] diff --git a/AGENTS.md b/AGENTS.md index 6ef98b581e..6f1f721315 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,9 @@ pnpm run tauri:build - Secrets (manual cookies, API keys, token accounts): use existing redaction, `secure_file`, and keyring helpers. - Do not add dependencies or tooling without confirmation. - Do not open issues or PRs against upstream `steipete/CodexBar` unless the user explicitly asks. This repo is Win-CodexBar only. +- **GitHub write safety:** for any mutating `gh` command (comment, review, merge, close, edit, create, label, release, etc.), always pass the explicit `--repo owner/repo`; never rely on the current remote or a PR/issue number alone. +- Before any GitHub mutation, perform a read-back against that same explicit repo and verify the returned owner/repo or URL exactly matches the intended target. Abort on any mismatch. +- Treat `steipete/CodexBar` as **read-only by default**. A GitHub write to upstream requires explicit user authorization in the current turn; prior permission never carries forward. ## Important Files @@ -174,3 +177,5 @@ Then follow post-install instructions (permissions / accessibility as prompted). - Keep stable package identity and installer behavior unchanged unless there is a real packaging reason: `PackageIdentifier`, `InstallerType`, `Scope`, `ProductCode`, `Publisher`, package URLs, and silent install behavior. - Before opening a Winget PR, verify the release installer URL resolves and recompute the SHA-256 from the downloaded asset. On Windows, run `winget validate` when available. - The first Winget package submission was approved in `microsoft/winget-pkgs#366653`; the v0.23.5 update was approved in `microsoft/winget-pkgs#366794`. Future updates should be faster, but still expect Microsoft validation/review. +- Agents must route GitHub mutations through `scripts/gh-safe.sh` instead of calling mutating `gh` subcommands directly. This is the sole supported mutation wrapper. It owns repo binding, performs read-back verification, blocks repo overrides, and fails closed on target mismatch. +- For object mutations (PR/issue/release), bind both the exact `owner/repo` and exact object number/tag. Use repo-only verification only for creates where the target object does not exist yet. diff --git a/scripts/gh-safe.sh b/scripts/gh-safe.sh new file mode 100644 index 0000000000..dc3b9ca07e --- /dev/null +++ b/scripts/gh-safe.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="" +verify_kind="" +target="" +allow_upstream_write=0 +what_if=0 + +usage() { + cat <<'EOF' +Usage: + bash scripts/gh-safe.sh --repo owner/repo --verify-kind repo|pr|issue|release [--target id-or-tag] [--allow-upstream-write] [--what-if] -- + +Examples: + bash scripts/gh-safe.sh --repo nesszer/Win-CodexBar --verify-kind pr --target 361 --what-if -- pr comment 361 --body-file .review/comment.md + bash scripts/gh-safe.sh --repo nesszer/Win-CodexBar --verify-kind repo --what-if -- pr create --title "..." --body-file body.md +EOF +} + +while (($#)); do + case "$1" in + --repo) repo="${2:-}"; shift 2 ;; + --verify-kind) verify_kind="${2:-}"; shift 2 ;; + --target) target="${2:-}"; shift 2 ;; + --allow-upstream-write) allow_upstream_write=1; shift ;; + --what-if) what_if=1; shift ;; + --) shift; break ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown wrapper argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +gh_args=("$@") + +[[ "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "Invalid --repo '$repo'; expected owner/repo." >&2; exit 2; } +case "$verify_kind" in repo|pr|issue|release) ;; *) echo "Invalid --verify-kind '$verify_kind'." >&2; exit 2 ;; esac +((${#gh_args[@]} > 0)) || { echo 'No gh command supplied after --.' >&2; exit 2; } + +if [[ "${repo,,}" == "steipete/codexbar" ]]; then + ((allow_upstream_write == 1)) || { echo 'Writes to steipete/CodexBar are blocked by default. Explicit current-turn authorization is required.' >&2; exit 3; } +elif [[ "${repo,,}" != "nesszer/win-codexbar" ]]; then + echo "GitHub writes are not allowlisted for '$repo'. Expected nesszer/Win-CodexBar." >&2 + exit 3 +fi + +for arg in "${gh_args[@]}"; do + case "$arg" in --repo|-R|--repo=*) echo 'Forwarded gh args may not override the repository.' >&2; exit 3 ;; esac +done + +if [[ "$verify_kind" != repo ]]; then + [[ -n "$target" ]] || { echo "--target is required for verify kind '$verify_kind'." >&2; exit 2; } + [[ "${gh_args[0]}" == "$verify_kind" ]] || { echo "Forwarded command must target '$verify_kind'." >&2; exit 3; } + ((${#gh_args[@]} >= 3)) || { echo "Forwarded command must carry its object as the third token (gh )." >&2; exit 3; } + [[ "${gh_args[2]}" == "$target" ]] || { echo "Forwarded command object '${gh_args[2]:-}' is not the verified target '$target'." >&2; exit 3; } +fi + +case "$verify_kind" in + repo) + readback="$(gh repo view "$repo" --json url,nameWithOwner --jq '.nameWithOwner + "|" + .url')" + readback_repo="${readback%%|*}" + verified_url="${readback#*|}/" + [[ "${readback_repo,,}" == "${repo,,}" ]] || { echo "Repository read-back mismatch: '$readback_repo' != '$repo'." >&2; exit 4; } + ;; + pr) verified_url="$(gh pr view "$target" --repo "$repo" --json url --jq .url)" ;; + issue) verified_url="$(gh issue view "$target" --repo "$repo" --json url --jq .url)" ;; + release) verified_url="$(gh api "repos/$repo/releases/tags/$target" --jq .html_url)" ;; +esac + +expected_prefix="https://github.com/$repo/" +shopt -s nocasematch +[[ "$verified_url" == "$expected_prefix"* ]] || { echo "GitHub target mismatch: '$verified_url' is not under '$expected_prefix'." >&2; exit 4; } +case "$verify_kind" in + pr) [[ "$verified_url" == *"/pull/$target" ]] || { echo "GitHub target mismatch: '$verified_url' does not end with '/pull/$target'." >&2; exit 4; } ;; + issue) [[ "$verified_url" == *"/issues/$target" ]] || { echo "GitHub target mismatch: '$verified_url' does not end with '/issues/$target'." >&2; exit 4; } ;; + release) [[ "$verified_url" == *"/releases/tag/$target" ]] || { echo "GitHub target mismatch: '$verified_url' does not end with '/releases/tag/$target'." >&2; exit 4; } ;; +esac +shopt -u nocasematch + +echo "Verified GitHub write target: $verified_url" +if ((what_if == 1)); then + printf 'WhatIf: gh' + printf ' %q' "${gh_args[@]}" + printf ' --repo %q\n' "$repo" + exit 0 +fi + +gh "${gh_args[@]}" --repo "$repo" diff --git a/scripts/gh-safe.tests.sh b/scripts/gh-safe.tests.sh new file mode 100644 index 0000000000..083475e12c --- /dev/null +++ b/scripts/gh-safe.tests.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_root="$(mktemp -d)" +trap 'rm -rf "$test_root"' EXIT +mkdir -p "$test_root/bin" +log="$test_root/gh.log" + +cat > "$test_root/bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%q ' "$@" >> "${GH_SAFE_TEST_LOG:?}" +printf '\n' >> "${GH_SAFE_TEST_LOG:?}" +if [[ "${FAKE_GH_MODE:-ok}" == cross ]]; then + printf '%s\n' 'steipete/CodexBar|https://github.com/steipete/CodexBar' + exit 0 +fi +if [[ "${1:-}" == repo && "${2:-}" == view ]]; then + printf '%s\n' 'nesszer/Win-CodexBar|https://github.com/nesszer/Win-CodexBar' +elif [[ "${1:-}" == pr && "${2:-}" == view ]]; then + printf '%s\n' 'https://github.com/nesszer/Win-CodexBar/pull/361' +elif [[ "${1:-}" == issue && "${2:-}" == view ]]; then + printf '%s\n' 'https://github.com/nesszer/Win-CodexBar/issues/123' +elif [[ "${1:-}" == api ]]; then + printf '%s\n' 'https://github.com/nesszer/Win-CodexBar/releases/tag/v1.2.3' +fi +EOF +chmod +x "$test_root/bin/gh" +export PATH="$test_root/bin:$PATH" +export GH_SAFE_TEST_LOG="$log" + +expect_fail() { + if "$@" >/dev/null 2>&1; then + echo "Expected failure: $*" >&2 + exit 1 + fi +} + +bash -n "$repo_root/scripts/gh-safe.sh" + +bash "$repo_root/scripts/gh-safe.sh" \ + --repo nesszer/Win-CodexBar --verify-kind repo --what-if -- \ + pr create --title test --body test >/dev/null + +expect_fail bash "$repo_root/scripts/gh-safe.sh" \ + --repo steipete/CodexBar --verify-kind repo --what-if -- \ + pr create --title test --body test + +expect_fail bash "$repo_root/scripts/gh-safe.sh" \ + --repo other/repo --verify-kind repo --what-if -- \ + pr create --title test --body test + +expect_fail bash "$repo_root/scripts/gh-safe.sh" \ + --repo nesszer/Win-CodexBar --verify-kind pr --target 361 --what-if -- \ + pr comment 362 --body test + +expect_fail bash "$repo_root/scripts/gh-safe.sh" \ + --repo nesszer/Win-CodexBar --verify-kind pr --target 361 --what-if -- \ + pr comment 999 --comment 361 + +expect_fail bash "$repo_root/scripts/gh-safe.sh" \ + --repo nesszer/Win-CodexBar --verify-kind pr --target 361 --what-if -- \ + pr close + +expect_fail bash "$repo_root/scripts/gh-safe.sh" \ + --repo nesszer/Win-CodexBar --verify-kind pr --target 361 --what-if -- \ + pr comment 361 --repo steipete/CodexBar --body test + +FAKE_GH_MODE=cross expect_fail bash "$repo_root/scripts/gh-safe.sh" \ + --repo nesszer/Win-CodexBar --verify-kind repo --what-if -- \ + pr create --title test --body test + +: > "$log" +bash "$repo_root/scripts/gh-safe.sh" \ + --repo nesszer/Win-CodexBar --verify-kind pr --target 361 -- \ + pr comment 361 --body test >/dev/null + +grep -Fq 'pr comment 361 --body test --repo nesszer/Win-CodexBar' "$log" || { + echo 'Safe wrapper did not bind the canonical repo on mutation.' >&2 + cat "$log" >&2 + exit 1 +} +: > "$log" +bash "$repo_root/scripts/gh-safe.sh" \ + --repo nesszer/Win-CodexBar --verify-kind issue --target 123 --what-if -- \ + issue close 123 >/dev/null + +: > "$log" +bash "$repo_root/scripts/gh-safe.sh" \ + --repo nesszer/Win-CodexBar --verify-kind release --target v1.2.3 --what-if -- \ + release upload v1.2.3 dist/app.zip >/dev/null + +echo 'GitHub write-safety shell tests passed.' diff --git a/scripts/local-check.ps1 b/scripts/local-check.ps1 index ac4e3daf97..eb9d4b8835 100644 --- a/scripts/local-check.ps1 +++ b/scripts/local-check.ps1 @@ -38,6 +38,7 @@ if (-not ($Rust -or $Tauri -or $Frontend -or $Format -or $Clippy -or $ReleaseDoc Push-Location $RepoRoot try { + Invoke-Step "GitHub write-safety tests" "bash" @("scripts/gh-safe.tests.sh") if ($All -or $Format) { Invoke-Step "Rust format" "cargo" @("fmt", "--all", "--check") }