shoc-pr-review-runner/scripts/collect-context.sh

123 lines
4.9 KiB
Bash
Raw Normal View History

#!/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 '. + ""')
}
side_in_scope frontend && collect_side frontend "$FRONTEND_REPO" "$WORKSPACE_DIR/frontend"
side_in_scope backend && collect_side backend "$BACKEND_REPO" "$WORKSPACE_DIR/backend"
log "context collected: diff $(wc -c <"$diff_section" | tr -d ' ') bytes, files $(wc -c <"$files_section" | tr -d ' ') bytes"