Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
name: PR check

# Blacksmith CI only: PR validation. Release assets stay in CircleCI.
on:
push:
branches: [main, master]
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
88 changes: 88 additions & 0 deletions scripts/gh-safe.sh
Original file line number Diff line number Diff line change
@@ -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] -- <gh args...>

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 <object> <verb> <number>)." >&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"
94 changes: 94 additions & 0 deletions scripts/gh-safe.tests.sh
Original file line number Diff line number Diff line change
@@ -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.'
1 change: 1 addition & 0 deletions scripts/local-check.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Loading