Files
agent-runtime-framework/harnesses/contexts/agent-repo/v1/finalize.sh
Paul O'Reilly 9cf016fa1c fix(agent-repo): harden AR-21 diff-verify against set-e/pipefail abort
The 2026-05-08 attempt-2 dogfood batch had 8/8 tasks "succeed" with
zero branches pushed. Root cause: my AR-21 diff-verification block was
running under set -euo pipefail without explicit error handling. A
single non-zero exit anywhere in the `git diff | tr | sed` pipeline
killed finalize.sh before the metadata write or push ran.

Specific risk: `git diff <REF_HEAD>..HEAD` returns non-zero when the
SHA is unreachable (e.g., shallow clone with init.sh fork-fallback
where upstream-ref wasn't fetched). pipefail then kills the pipeline,
set -e kills the script.

Fix: wrap the entire AR-21 block in `set +eo pipefail` (with explicit
`set -eo pipefail` restore at the end). Also:
- Use `${arr[@]:-}` instead of `${arr[@]}` for set -u safety on empty
  arrays
- Add `|| true` to git command substitutions (belt-and-braces)
- Use `printf` instead of `echo` for the comma-wrap (more portable)

Verified locally: when `/workspace/reference/main/.git` is absent the
block correctly skips with the existing fallback; when present and
upstream-ref is reachable, the block runs and reports DIFF_VERIFIED.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:31:33 +12:00

412 lines
16 KiB
Bash

#!/bin/bash
# Agent repo finalize script — auto-commit and push changes
# AR-19, AR-20, AR-8, AR-32, F66, BUG-5, BUG-20
set -euo pipefail
# ---------------------------------------------------------------------------
# F66: Content-based secret scanning
# Scans staged diff for credential patterns before committing.
# Returns 0 if clean, 1 if secrets detected.
# ---------------------------------------------------------------------------
scan_for_secrets() {
local diff found=0
diff=$(cat)
# Extract only added lines (skip diff headers +++)
local added
added=$(printf '%s\n' "$diff" | grep '^+' | grep -v '^+++' || true)
if [[ -z "$added" ]]; then
return 0
fi
# Pattern 1: AWS access key — AKIA followed by exactly 16 uppercase letters/digits
if printf '%s\n' "$added" | grep -qE 'AKIA[0-9A-Z]{16}'; then
echo "AWS access key pattern detected (AKIA...)"
found=1
fi
# Pattern 2: PEM private key header (RSA, EC, OPENSSH, etc.)
if printf '%s\n' "$added" | grep -qE -- '-----BEGIN .* PRIVATE KEY-----'; then
echo "PEM private key header detected"
found=1
fi
# Pattern 3: High-entropy credential assignment (40+ char value near key/token/secret keywords)
if printf '%s\n' "$added" | grep -qiE \
'(secret[._]?key|api[._]?key|access[._]?key|auth[._]?token|\bsecret\b|\btoken\b|\bpassword\b|\bpasswd\b)[[:space:]]*[=:][[:space:]]*['"'"'""]?[A-Za-z0-9+/=_-]{40,}'; then
echo "High-entropy credential assignment detected"
found=1
fi
return $found
}
# Allow sourcing for unit tests without running main
if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then
return 0 2>/dev/null || true
fi
echo "=== agent-repo/v1 finalize.sh ==="
set -x # BUG-20: trace all commands for diagnostic visibility
WORKING_DIR="${AGENT_WORKING_DIR:-/workspace/project}"
AGENT_OUTPUT_DIR="${AGENT_OUTPUT_DIR:-/workspace/.agent-output}"
METADATA_FILE="$AGENT_OUTPUT_DIR/ci_metadata.json"
AGENT_EXIT="${AGENT_EXIT_CODE:-0}"
TASK_ID="${AGENT_TASK_ID:-unknown}"
BRANCH="${AGENT_BRANCH:-}"
REPO_URL="${AGENT_REPO_URL:-}"
# BUG-20: Push retry
PUSH_RETRIES="${AGENT_PUSH_RETRIES:-1}"
mkdir -p "$AGENT_OUTPUT_DIR"
# AR-19: Mark working dir as safe (finalize may run as different user than agent)
git config --global --add safe.directory "$WORKING_DIR"
# Change to working directory
cd "$WORKING_DIR" || {
echo "ERROR: Failed to change to working directory: $WORKING_DIR" >&2
exit 1
}
# Verify this is a git repository (defensive: catches misconfigured payloads
# where agent_repo field is missing but agent-repo harness is in the composite)
if [ ! -d ".git" ]; then
echo "ERROR: $WORKING_DIR is not a git repository." >&2
echo "Hint: Ensure the task payload includes 'agent_repo: {repo_url: ..., branch: ...}'" >&2
echo " so init.sh clones the repo before the agent runs." >&2
exit 1
fi
# AR-32: Restore .gitignore from harness template to prevent removing entries
HARNESS_GITIGNORE="/opt/harness/contexts/agent-repo/v1/.gitignore_template"
if [ -f "$HARNESS_GITIGNORE" ]; then
cat "$HARNESS_GITIGNORE" >> .gitignore
fi
# AR-37/F73-10: Exclude protected test folders from commit.
# PROTECTED_TEST_FOLDERS is set by entrypoint.py when test_pass_required is active.
if [ -n "${PROTECTED_TEST_FOLDERS:-}" ]; then
IFS=',' read -ra _PROTECTED_FOLDERS <<< "$PROTECTED_TEST_FOLDERS"
for _folder in "${_PROTECTED_FOLDERS[@]}"; do
_folder=$(echo "$_folder" | xargs) # trim whitespace
if [ -n "$_folder" ]; then
echo "F73: Excluding test folder from commit: $_folder"
git reset HEAD -- "$_folder" 2>/dev/null || true
git checkout HEAD -- "$_folder" 2>/dev/null || true
git clean -fd -- "$_folder" 2>/dev/null || true
fi
done
# Also exclude test infrastructure files
for _infra in conftest.py pyproject.toml pytest.ini setup.cfg tox.ini; do
git reset HEAD -- "$_infra" 2>/dev/null || true
git checkout HEAD -- "$_infra" 2>/dev/null || true
git clean -fd -- "$_infra" 2>/dev/null || true
done
fi
# AR-8: Check if there are any changes to commit
echo "Running: git add -A"
git add -A
# BUG-5 correction: detect wrong-path writes to .agent-output/
# These files are gitignored and will not be committed — move them to working dir
if [ -d "$WORKING_DIR/.agent-output" ]; then
# Find files in .agent-output/ that are NOT metadata/sentinel files
CORRECTION_FILES=$(ls "$WORKING_DIR/.agent-output/" 2>/dev/null | grep -v -E '^(ci_metadata|finalize-error|correction-prompt|pre-test-result)' || true)
if [ -n "$CORRECTION_FILES" ]; then
echo "WARNING: Files found in .agent-output/ — moving to working directory for commit"
echo "Offending files:"
echo "$CORRECTION_FILES"
for _f in $CORRECTION_FILES; do
if [ -f "$WORKING_DIR/.agent-output/$_f" ]; then
cp "$WORKING_DIR/.agent-output/$_f" "$WORKING_DIR/$_f"
echo " Moved: $_f -> $WORKING_DIR/$_f"
fi
done
# Write correction notice that will trigger re-invoke in entrypoint
cat > "$AGENT_OUTPUT_DIR/.correction-prompt.txt" << 'CORRECTION'
NOTE: You wrote output to /workspace/.agent-output/ instead of /workspace/project/.
Those files are gitignored and will NOT be committed automatically.
The following files have been moved to /workspace/project/ and WILL be committed:
CORRECTION
echo "$CORRECTION_FILES" >> "$AGENT_OUTPUT_DIR/.correction-prompt.txt"
cat >> "$AGENT_OUTPUT_DIR/.correction-prompt.txt" << 'CORRECTION'
ALWAYS write deliverable output directly to /workspace/project/<target-path>.
Never use /workspace/.agent-output/ when /workspace/project/ exists.
CORRECTION
echo "Correction prompt written to $AGENT_OUTPUT_DIR/.correction-prompt.txt"
fi
fi
# Check again after potential moves
if [ -z "$(git status --porcelain)" ]; then
echo "No changes to commit — skipping push (AR-8)"
python3 -c "
import json, os
out = '$METADATA_FILE'
meta = {}
if os.path.isfile(out):
try:
with open(out) as f:
meta = json.load(f)
except Exception:
meta = {}
meta['agent_branch'] = '$BRANCH'
meta['agent_repo_url'] = '$REPO_URL'
meta['agent_branch_pushed'] = False
with open(out, 'w') as f:
json.dump(meta, f)
print('Wrote ci_metadata.json (no-op: no changes)')
"
exit 0
fi
# F66: Scan staged content for secrets before committing
SCAN_FINDINGS=""
SCAN_EXIT=0
set +e
SCAN_FINDINGS=$(git diff --cached | scan_for_secrets)
SCAN_EXIT=$?
set -e
if [ "$SCAN_EXIT" -ne 0 ]; then
echo "SECRET SCAN BLOCKED COMMIT — credentials detected in staged content"
echo "$SCAN_FINDINGS"
python3 -c "
import json, os
out = '$METADATA_FILE'
meta = {}
if os.path.isfile(out):
try:
with open(out) as f:
meta = json.load(f)
except Exception:
meta = {}
meta['agent_branch'] = '$BRANCH'
meta['agent_repo_url'] = '$REPO_URL'
meta['agent_branch_pushed'] = False
meta['secret_scan_blocked'] = True
meta['scan_findings'] = '''$SCAN_FINDINGS'''
with open(out, 'w') as f:
json.dump(meta, f)
print('Wrote ci_metadata.json (blocked by secret scan)')
"
exit 0
fi
# V3: Diff size check (warning only, does not block push)
DIFF_LINES=$(git diff --cached --stat | tail -1 | grep -oP '\d+(?= insertion)' || echo "0")
DIFF_KB=$(echo "$DIFF_LINES" | awk '{printf "%.1f", $1/1000}')
MAX_DIFF_KB="${AGENT_MAX_DIFF_KB:-500}"
if awk "BEGIN {exit !($DIFF_KB > $MAX_DIFF_KB)}"; then
echo "WARNING: Large diff detected: ${DIFF_KB}KB (threshold: ${MAX_DIFF_KB}KB)"
fi
# AR-6, AR-18: Build commit message based on agent outcome
if [ "$AGENT_EXIT" = "0" ]; then
OUTCOME="succeeded"
else
OUTCOME="failed (exit $AGENT_EXIT)"
fi
# Get first line of prompt (safe, via tempfile)
PROMPT_FIRST_LINE="${AGENT_PROMPT:-}"
if [ -n "$PROMPT_FIRST_LINE" ]; then
PROMPT_FIRST_LINE=$(echo "$PROMPT_FIRST_LINE" | head -1 | cut -c1-100)
fi
# Write commit message to tempfile (AR-19: safe handling)
COMMIT_MSG_FILE=$(mktemp)
trap "rm -f $COMMIT_MSG_FILE" EXIT
if [ -n "$PROMPT_FIRST_LINE" ]; then
echo "Agent task $TASK_ID ($OUTCOME): $PROMPT_FIRST_LINE" > "$COMMIT_MSG_FILE"
else
echo "Agent task $TASK_ID ($OUTCOME)" > "$COMMIT_MSG_FILE"
fi
# Commit
echo "Running: git commit"
git commit -F "$COMMIT_MSG_FILE"
# Get commit SHA
COMMIT_SHA=$(git rev-parse HEAD)
echo "Committed as: $COMMIT_SHA"
# BUG-20: Output validation — check expected output file exists and is non-empty
# AGENT_EXPECTED_OUTPUT: absolute path to required output file (e.g., /workspace/project/spec/f94-auth.md)
# AGENT_MIN_OUTPUT_BYTES: minimum size in bytes (default 0 = any non-empty)
OUTPUT_VALIDATED=true
OUTPUT_MISSING=""
if [ -n "${AGENT_EXPECTED_OUTPUT:-}" ]; then
echo "Validating expected output: $AGENT_EXPECTED_OUTPUT"
if [ ! -f "$AGENT_EXPECTED_OUTPUT" ]; then
echo "ERROR: Expected output file missing: $AGENT_EXPECTED_OUTPUT"
OUTPUT_VALIDATED=false
OUTPUT_MISSING="$AGENT_EXPECTED_OUTPUT"
else
SIZE=$(stat -c%s "$AGENT_EXPECTED_OUTPUT" 2>/dev/null || stat -f%z "$AGENT_EXPECTED_OUTPUT" 2>/dev/null || echo "0")
MIN_SIZE="${AGENT_MIN_OUTPUT_BYTES:-0}"
if [ "$SIZE" -lt "$MIN_SIZE" ]; then
echo "WARNING: Output file $AGENT_EXPECTED_OUTPUT is ${SIZE} bytes (minimum: ${MIN_SIZE})"
else
echo "Output validated: $AGENT_EXPECTED_OUTPUT (${SIZE} bytes)"
fi
fi
fi
# AR-21 (2026-05-08): Diff-against-upstream verification.
# AGENT_EXPECTED_CHANGED_FILES: comma-separated list of paths that MUST appear
# in the working-tree diff vs upstream main. Catches the failure mode where
# an agent reports "succeeded" but produced no actual change to the target
# file (real incident: 2026-05-08 dogfood batch, gotchas item 30 — three
# "succeeded" tasks merged nothing actionable).
# AGENT_FORBIDDEN_CHANGED_FILES: comma-separated list of paths that MUST NOT
# appear in the diff. Catches the inverse: an agent silently destroying or
# refactoring files outside the task scope (real incident: 2026-05-08 task
# 4a2f2988 — agent stripped 9 unrelated functions; gotchas item 17/21).
#
# Whole block runs with set +e (and pipefail off) to ensure no diagnostic
# pipeline failure aborts finalize before metadata can be written. We
# explicitly check exit codes where they matter.
DIFF_VERIFIED=true
DIFF_MISMATCH=""
DIFF_SUMMARY=""
set +eo pipefail
if [ -d /workspace/reference/main/.git ]; then
REF_HEAD=$(git -C /workspace/reference/main rev-parse HEAD 2>/dev/null)
if [ -n "$REF_HEAD" ]; then
# Files modified/added/deleted by the agent vs the upstream HEAD seed.
# Use git diff (working-tree style) plus committed changes — the
# agent commits via finalize.sh later, so the diff against REF_HEAD
# reflects total scope. `|| true` belt-and-braces against unreachable
# SHAs (e.g., if init.sh fell back to fork main without seeding from
# upstream).
DIFF_RAW=$(git diff --name-only "$REF_HEAD"..HEAD 2>/dev/null || true)
# If the .. range fails (rev-parse error) the substitution returns "".
# Fall back to the simpler diff against working-tree HEAD-1 (no good
# answer; just emit empty).
DIFF_SUMMARY=$(printf '%s\n' "$DIFF_RAW" | tr '\n' ',' | sed 's/,$//' || true)
if [ -n "${AGENT_EXPECTED_CHANGED_FILES:-}" ]; then
echo "Verifying required changed files: $AGENT_EXPECTED_CHANGED_FILES"
IFS=',' read -ra _REQUIRED <<< "$AGENT_EXPECTED_CHANGED_FILES"
for required in "${_REQUIRED[@]:-}"; do
required="${required#"${required%%[![:space:]]*}"}"
required="${required%"${required##*[![:space:]]}"}"
[ -z "$required" ] && continue
if ! printf ',%s,' "$DIFF_SUMMARY" | grep -qF ",$required,"; then
echo "ERROR: Required change to '$required' missing from agent's diff"
DIFF_VERIFIED=false
DIFF_MISMATCH="$DIFF_MISMATCH missing:$required"
fi
done
fi
if [ -n "${AGENT_FORBIDDEN_CHANGED_FILES:-}" ]; then
echo "Verifying forbidden files unchanged: $AGENT_FORBIDDEN_CHANGED_FILES"
IFS=',' read -ra _FORBIDDEN <<< "$AGENT_FORBIDDEN_CHANGED_FILES"
for forbidden in "${_FORBIDDEN[@]:-}"; do
forbidden="${forbidden#"${forbidden%%[![:space:]]*}"}"
forbidden="${forbidden%"${forbidden##*[![:space:]]}"}"
[ -z "$forbidden" ] && continue
if printf ',%s,' "$DIFF_SUMMARY" | grep -qF ",$forbidden,"; then
echo "ERROR: Forbidden file '$forbidden' was modified by agent"
DIFF_VERIFIED=false
DIFF_MISMATCH="$DIFF_MISMATCH forbidden:$forbidden"
fi
done
fi
if [ "$DIFF_VERIFIED" = "true" ] && [ -n "${AGENT_EXPECTED_CHANGED_FILES:-}${AGENT_FORBIDDEN_CHANGED_FILES:-}" ]; then
echo "Diff verification passed (changed: $DIFF_SUMMARY)"
fi
else
echo "WARNING: /workspace/reference/main has no commits; skipping diff verification"
fi
else
echo "Note: /workspace/reference/main not present; skipping AR-21 diff verification"
fi
set -eo pipefail
# AR-19: Push with retry — attempt up to PUSH_RETRIES+1 times (default 2: initial + 1 retry)
echo "Pushing branch $BRANCH to $REPO_URL..."
PUSHED=false
PUSH_EXIT=0
for attempt in $(seq 1 $((PUSH_RETRIES + 1))); do
if [ "$attempt" -gt 1 ]; then
echo "Push attempt $attempt — sleeping 5s before retry"
sleep 5
echo "Retry $attempt: git push $REPO_URL HEAD:refs/heads/$BRANCH --force"
fi
if timeout 120 git push "$REPO_URL" "HEAD:refs/heads/$BRANCH" --force 2>&1; then
echo "Push succeeded (attempt $attempt)"
PUSHED=true
PUSH_EXIT=0
break
else
PUSH_EXIT=$?
echo "Push attempt $attempt failed with exit code $PUSH_EXIT"
if [ "$attempt" -lt $((PUSH_RETRIES + 1)) ]; then
echo "Will retry..."
fi
fi
done
# Log committed files for diagnosis
echo "Files in commit:"
git diff-tree --no-commit-id --name-only -r "$COMMIT_SHA" | while read f; do echo " $f"; done
if [ "$PUSHED" = "false" ]; then
echo "ERROR: Push failed after $((PUSH_RETRIES + 1)) attempt(s) — branch $BRANCH was committed but not pushed" >&2
fi
# Write ci_metadata.json
python3 -c "
import json, os
out = '$METADATA_FILE'
meta = {}
if os.path.isfile(out):
try:
with open(out) as f:
meta = json.load(f)
except Exception:
meta = {}
meta['agent_branch'] = '$BRANCH'
meta['agent_sha'] = '$COMMIT_SHA'
meta['agent_repo_url'] = '$REPO_URL'
meta['agent_branch_pushed'] = $( [ '$PUSHED' = 'true' ] && echo 'True' || echo 'False' )
meta['diff_kb'] = float('$DIFF_KB') if '$DIFF_KB' else 0.0
meta['output_validated'] = $( [ '$OUTPUT_VALIDATED' = 'true' ] && echo 'True' || echo 'False' )
if '$OUTPUT_MISSING':
meta['output_missing'] = '$OUTPUT_MISSING'
# AR-21: diff verification metadata
meta['diff_verified'] = $( [ '$DIFF_VERIFIED' = 'true' ] && echo 'True' || echo 'False' )
if '$DIFF_MISMATCH'.strip():
meta['diff_mismatch'] = '$DIFF_MISMATCH'.strip()
if '$DIFF_SUMMARY':
meta['diff_changed_files'] = [f for f in '$DIFF_SUMMARY'.split(',') if f]
with open(out, 'w') as f:
json.dump(meta, f)
print('Wrote ci_metadata.json')
"
if [ "$PUSHED" = "false" ]; then
exit 1
fi
# AR-21: fail the task if diff verification didn't pass — even if push succeeded.
# Branch is preserved on the agent repo for forensics, but the task ends as
# failed so the operator + CP know it shouldn't be merged.
if [ "$DIFF_VERIFIED" = "false" ]; then
echo "ERROR: Diff verification failed:$DIFF_MISMATCH" >&2
echo "Branch $BRANCH is pushed for forensics, but the task is being marked failed." >&2
exit 1
fi
echo "=== agent-repo/v1 finalize.sh complete ==="