mirror of
https://github.com/Sea-Haven-Industries/shoc-pr-review-runner.git
synced 2026-09-30 04:33:15 +00:00
Manually-dispatched GitHub Actions workflow that reviews SHOC pull requests in a clean environment: exact-head checkout of shoc-frontend-new and shoc-backend, clean build/test gates, a truthful evidence report, a single-shot Fireworks review, deterministic output validation, and published artifacts. The runner never writes to the product repositories or their pull requests. The review checklists move here from the reviewers' local Cursor commands so the instructions live outside both product repos. Phase 1 does not provision a database, start either application, or run live browser flows; the evidence report records those as NOT_RUN so a review cannot claim them. Security architecture: building a PR executes its author's code, so the workflow is split. The gates job runs that code holding no Fireworks key and revokes its App token first; the review job holds the key, executes no product code, and re-checks out this repo fresh. Product checkouts live outside the workspace, the App token is downscoped at mint time, gate results fail closed on any duplicate key, changed files are read from git objects rather than the filesystem, and the validator re-checks every claim against the gate table.
122 lines
4.9 KiB
Bash
Executable file
122 lines
4.9 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# Collect the PR diff and changed-file contents for the review prompt.
|
||
#
|
||
# Runs in the gates job while the App token is still valid, and BEFORE any
|
||
# PR-authored code executes, so the collected context cannot be tampered with by
|
||
# the build. File contents are read from git objects (`git cat-file`), never
|
||
# from the filesystem: a PR can add a symlink pointing outside the checkout, and
|
||
# a filesystem read would follow it and leak host files into the prompt and the
|
||
# uploaded artifacts. Reading blobs cannot escape the tree.
|
||
#
|
||
# Reads: GH_TOKEN, REVIEW_TYPE, FRONTEND_REPO, BACKEND_REPO,
|
||
# DIFF_MAX_BYTES / FILES_MAX_BYTES / FILE_MAX_BYTES
|
||
# Writes: $ARTIFACTS_DIR/{prompt-diff.txt,prompt-files.txt,prompt-bounds.txt}
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
# shellcheck source=lib.sh
|
||
source "$SCRIPT_DIR/lib.sh"
|
||
|
||
require_env GH_TOKEN REVIEW_TYPE FRONTEND_REPO BACKEND_REPO
|
||
DIFF_MAX_BYTES="${DIFF_MAX_BYTES:-200000}"
|
||
FILES_MAX_BYTES="${FILES_MAX_BYTES:-120000}"
|
||
FILE_MAX_BYTES="${FILE_MAX_BYTES:-65536}"
|
||
|
||
diff_section="$ARTIFACTS_DIR/prompt-diff.txt"
|
||
files_section="$ARTIFACTS_DIR/prompt-files.txt"
|
||
bounds_notes="$ARTIFACTS_DIR/prompt-bounds.txt"
|
||
: >"$diff_section"; : >"$files_section"; : >"$bounds_notes"
|
||
|
||
side_in_scope() {
|
||
case "$REVIEW_TYPE" in
|
||
paired) return 0 ;;
|
||
"$1") return 0 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]'; }
|
||
|
||
# note_bound <text> — records a context-bound note. Untrusted path names are
|
||
# sanitized (control characters removed, length capped) and marked, so a crafted
|
||
# file name cannot forge lines in the evidence report.
|
||
note_bound() {
|
||
printf -- '- %s\n' "$(printf '%s' "$1" | tr -d '\000-\037' | cut -c1-300)" >>"$bounds_notes"
|
||
}
|
||
|
||
collect_side() { # <side> <repo> <checkout-dir>
|
||
local side="$1" repo="$2" dir="$3"
|
||
local pr upper_side
|
||
pr="$(jq -r '.pr' "$ARTIFACTS_DIR/$side-pr.json")"
|
||
upper_side="$(upper "$side")"
|
||
|
||
local raw="$ARTIFACTS_DIR/$side.diff"
|
||
gh api "repos/$repo/pulls/$pr" -H "Accept: application/vnd.github.diff" >"$raw" \
|
||
|| die "failed to fetch diff for $repo#$pr"
|
||
local size
|
||
size="$(wc -c <"$raw" | tr -d ' ')"
|
||
{
|
||
printf '===== %s DIFF (%s #%s) =====\n' "$upper_side" "$repo" "$pr"
|
||
if [ "$size" -gt "$DIFF_MAX_BYTES" ]; then
|
||
head -c "$DIFF_MAX_BYTES" "$raw"
|
||
printf '\n===== %s DIFF TRUNCATED: %s of %s bytes included =====\n' \
|
||
"$upper_side" "$DIFF_MAX_BYTES" "$size"
|
||
note_bound "$side diff truncated to $DIFF_MAX_BYTES of $size bytes"
|
||
else
|
||
cat "$raw"
|
||
fi
|
||
} >>"$diff_section"
|
||
|
||
# Changed files, enumerated NUL-delimited so newlines in path names cannot
|
||
# split one record into several.
|
||
local budget="$FILES_MAX_BYTES" f mode blob fsize
|
||
while IFS= read -r -d '' f; do
|
||
[ -n "$f" ] || continue
|
||
# Reject control characters, absolute paths, and traversal outright.
|
||
case "$f" in
|
||
/*|*..*) note_bound "$side: rejected suspicious path name" ; continue ;;
|
||
esac
|
||
if printf '%s' "$f" | LC_ALL=C grep -q '[[:cntrl:]]'; then
|
||
note_bound "$side: rejected path name containing control characters"
|
||
continue
|
||
fi
|
||
case "$f" in
|
||
package-lock.json|*.lock|*.tsbuildinfo|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.zip)
|
||
note_bound "$side/$f excluded (lockfile/binary)"; continue ;;
|
||
esac
|
||
|
||
# Only regular blobs (100644/100755). Mode 120000 is a symlink and 160000 a
|
||
# submodule; both are skipped rather than followed.
|
||
mode="$(git -C "$dir" ls-tree HEAD -- "$f" | awk '{print $1}')"
|
||
case "$mode" in
|
||
100644|100755) ;;
|
||
120000) note_bound "$side/$f excluded (symlink)"; continue ;;
|
||
"") note_bound "$side/$f not present at head (deleted)"; continue ;;
|
||
*) note_bound "$side/$f excluded (unsupported git mode $mode)"; continue ;;
|
||
esac
|
||
|
||
blob="$(git -C "$dir" rev-parse "HEAD:$f" 2>/dev/null)" || {
|
||
note_bound "$side/$f unreadable at head"; continue; }
|
||
fsize="$(git -C "$dir" cat-file -s "$blob")"
|
||
if [ "$fsize" -gt "$FILE_MAX_BYTES" ]; then
|
||
note_bound "$side/$f excluded ($fsize bytes exceeds per-file cap $FILE_MAX_BYTES)"; continue
|
||
fi
|
||
if [ "$fsize" -gt "$budget" ]; then
|
||
note_bound "$side/$f excluded (context budget exhausted)"; continue
|
||
fi
|
||
if ! git -C "$dir" cat-file -p "$blob" | LC_ALL=C grep -Iq .; then
|
||
note_bound "$side/$f excluded (binary)"; continue
|
||
fi
|
||
budget=$((budget - fsize))
|
||
{
|
||
printf '===== FILE %s (%s at reviewed head) =====\n' "$f" "$side"
|
||
git -C "$dir" cat-file -p "$blob"
|
||
printf '\n'
|
||
} >>"$files_section"
|
||
done < <(gh api "repos/$repo/pulls/$pr/files" --paginate --jq '.[].filename' --raw-output \
|
||
| jq -Rrj '. + " |