#!/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 — 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() { # 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"