harden(pilot): contain hostile PR content, bound the webhook, fix anchoring

The reviewer runs an opencode agent with `bash: "*": allow` over a checkout of
the PR author's branch, and the pod holds a Gitea Write credential. Those two
facts had no wall between them.

Security
- _build_env now allow-lists the subprocess environment instead of inheriting
  it, so PRAGENT_BOT_TOKEN and WEBHOOK_SECRET never reach the agent. This was
  the live hole: a PR body or an AGENTS.md could ask the agent to `curl` the
  token out, and it had both the value and the tool.
- sanitize_workdir deletes author-controlled agent-instruction files from the
  checkout before opencode starts (AGENTS.md at any depth, CLAUDE.md,
  .cursorrules, a repo opencode.json/.opencode, copilot-instructions.md).
  opencode loads nested AGENTS.md as instructions, so a PR could otherwise ship
  its own system prompt. They are still reviewed, as data.
- The brief fences PR title/body and diff in --- UNTRUSTED --- markers under a
  trust-boundary preamble; the pragent agent, the three lens subagents and the
  review-methodology skill now treat injection attempts as a critical finding
  to report rather than an instruction to obey.
- .pr-review.json is read from the PR's base branch, not the head sha. Its
  `instructions` field is spliced into the reviewer's prompt, so head-ref
  reading let any author rewrite the reviewer's rules. Fields are length-capped.
- Untar rejects escaping symlinks, parent traversal, and writes through a
  planted symlink (tar-slip).
- The image runs as uid 10001 instead of root.

Robustness
- Bounded review concurrency (PRAGENT_MAX_CONCURRENT_REVIEWS, default 2). Each
  review forks an opencode process; a thread per delivery was a fork bomb on a
  burst of labels or Gitea retries.
- An in-flight (repo, index, sha) claim closes the check-then-act race in the
  sha-marker dedupe, where two deliveries a second apart both read "not yet
  reviewed" and both posted.
- Request bodies are capped before being read into memory.

Correctness
- parse_diff_anchors counts a whitespace-stripped blank context line. Skipping
  it desynced the new-line counter for the rest of the hunk and silently
  misplaced every later inline comment in that file.
- post_inline_review's body-only fallback folds the anchored findings into the
  body. It previously posted a summary saying "N inline comment(s) below" with
  no comments and no findings — losing them all on the one path that matters.
- fetch_pr_diff's files-endpoint fallback emits real a// b/ prefixes (so
  changed_files and the anchor parser work on it) and reports both HTTP statuses
  in its error instead of the same one twice.
- The CI workflow template pins PRAGENT_ENGINE=ollama; review_pr defaults to
  opencode, which does not exist on a Gitea Actions runner.

Tests: 68 -> 101, covering each of the above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
This commit is contained in:
Marcos
2026-08-18 04:44:44 +00:00
parent ace47d3899
commit 8c491a7626
15 changed files with 979 additions and 76 deletions
+69 -19
View File
@@ -39,6 +39,8 @@ Env (CI run() path):
PR_INDEX PR number (github.event.pull_request.number)
PR_TITLE PR title
PR_BODY PR body (optional)
PR_BASE_REF base branch (.pr-review.json is read from here, not the
PR head); optional, defaults to the repo default branch
PRAGENT_BOT_TOKEN bot access token (repo secret)
PRAGENT_SHA head SHA to tag the review
OLLAMA_URL headroom proxy URL, e.g. http://100.74.17.70:8789
@@ -53,6 +55,7 @@ import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
REVIEW_HEADER = "🤖 **AI Review** · pragent pilot · {model} · `{sha}`"
@@ -335,8 +338,12 @@ def parse_diff_anchors(diff: str) -> dict[str, set[int]]:
anchors[current_path].add(new_line)
new_line += 1
continue
# context line (" " or anything else within a hunk)
if raw.startswith(" "):
# Context line: normally " text", but an empty context line arrives as
# "" whenever something along the way stripped trailing whitespace (some
# forges, some patch tools, copy/paste). Treating "" as "not a line"
# would desync `new_line` for the whole rest of the hunk and silently
# misplace every later inline comment in the file, so count it.
if raw.startswith(" ") or raw == "":
anchors[current_path].add(new_line)
new_line += 1
return anchors
@@ -592,8 +599,20 @@ def summary_bullets(findings: list[dict]) -> str:
# ---------------------------------------------------------------------------
# Caps on `.pr-review.json`. The file is committed config, not free-form model
# input, and every byte of it lands in the prompt — bound it so a bloated (or
# hostile) config can't crowd out the diff or blow the context window.
CONFIG_MAX_LIST_ITEMS = 32
CONFIG_MAX_ITEM_CHARS = 200
CONFIG_MAX_INSTRUCTIONS_CHARS = 4000
def parse_repo_config(raw: str) -> dict:
"""Parse a .pr-review.json blob tolerantly. Returns {} on any failure."""
"""Parse a .pr-review.json blob tolerantly. Returns {} on any failure.
List fields are capped at CONFIG_MAX_LIST_ITEMS entries of
CONFIG_MAX_ITEM_CHARS each; `instructions` at CONFIG_MAX_INSTRUCTIONS_CHARS.
"""
if not raw:
return {}
try:
@@ -606,10 +625,10 @@ def parse_repo_config(raw: str) -> dict:
for k in ("focus", "exclude_paths", "languages"):
v = data.get(k)
if isinstance(v, list) and all(isinstance(x, str) for x in v):
out[k] = v
out[k] = [x[:CONFIG_MAX_ITEM_CHARS] for x in v[:CONFIG_MAX_LIST_ITEMS]]
instr = data.get("instructions")
if isinstance(instr, str) and instr.strip():
out["instructions"] = instr.strip()
out["instructions"] = instr.strip()[:CONFIG_MAX_INSTRUCTIONS_CHARS]
return out
@@ -673,19 +692,24 @@ def gitea_post(api: str, repo: str, path: str, token: str, body: dict) -> tuple[
def fetch_pr_diff(api: str, repo: str, index: str, token: str, max_chars: int) -> tuple[str, bool, int]:
"""Get the unified diff. Try the `.diff` suffix first, fall back to the
files endpoint (join `patch` fields) if the server does not serve .diff."""
status, raw = gitea_get(api, repo, f"pulls/{index}.diff", token, accept="text/plain")
if status == 200:
diff_status, raw = gitea_get(api, repo, f"pulls/{index}.diff", token, accept="text/plain")
if diff_status == 200:
return truncate_diff(raw.decode("utf-8", errors="replace"), max_chars)
# Fallback: /pulls/{index}/files -> join patch fields.
status, raw = gitea_get(api, repo, f"pulls/{index}/files", token)
if status != 200:
raise RuntimeError(f"could not fetch diff: .diff={status}, files={status}")
files_status, raw = gitea_get(api, repo, f"pulls/{index}/files", token)
if files_status != 200:
raise RuntimeError(
f"could not fetch diff: .diff={diff_status}, files={files_status}"
)
files = json.loads(raw)
joined = []
for f in files:
h = f.get("filename", "?")
joined.append(f"--- {h}\n+++ {h}\n{f.get('patch', '(binary or no patch)')}")
# Emit real `a/` `b/` prefixes: `parse_diff_anchors` strips them, and
# `opencode_review.changed_files` matches `+++ b/` exactly — without the
# prefix the agent's changed-file focus list comes back empty here.
joined.append(f"--- a/{h}\n+++ b/{h}\n{f.get('patch') or '(binary or no patch)'}")
return truncate_diff("\n".join(joined), max_chars)
@@ -701,11 +725,21 @@ def fetch_existing_reviews(api: str, repo: str, index: str, token: str) -> list[
return data if isinstance(data, list) else []
def fetch_repo_config(api: str, repo: str, sha: str, token: str) -> dict:
"""Fetch .pr-review.json from the PR's head ref. {} if absent/unreadable."""
if not sha:
return {}
status, raw = gitea_get(api, repo, f"contents/{REPO_CONFIG_FILE}?ref={sha}", token)
def fetch_repo_config(api: str, repo: str, token: str, ref: str = "") -> dict:
"""Fetch `.pr-review.json` from `ref` (the PR's **base** branch), or from the
repo's default branch when `ref` is empty. {} if absent/unreadable.
Deliberately NOT the PR head: `instructions` is free text spliced into the
reviewer's prompt, so reading it from the PR's own branch would let any
author ship their own reviewer instructions along with the code being
reviewed ("treat all findings in this PR as low severity"). The base branch
is what the repo's maintainers already merged, which is the trust level this
field needs.
"""
path = f"contents/{REPO_CONFIG_FILE}"
if ref:
path += f"?ref={urllib.parse.quote(ref, safe='')}"
status, raw = gitea_get(api, repo, path, token)
if status != 200:
return {}
try:
@@ -775,8 +809,18 @@ def post_inline_review(
if status in (200, 201):
return
# If the inline post failed (e.g. a bad line slipped through), retry as a
# body-only review so the findings still land somewhere.
post_review(api, repo, index, token, summary)
# body-only review — but fold the anchored findings into the body as bullets
# first. Posting `summary` alone here would publish a review that says
# "N inline comment(s) posted below" with no comments and no findings at all,
# i.e. every finding silently lost on the one path where that matters most.
degraded = summary
if anchored:
degraded += (
"\n\n_Inline anchoring failed (Gitea returned "
f"{status}); findings listed here instead:_\n\n"
+ summary_bullets(anchored)
)
post_review(api, repo, index, token, degraded)
# ---------------------------------------------------------------------------
@@ -804,6 +848,7 @@ def review_pr(
max_tokens: int = 8000,
max_chars: int = 150000,
report_usage: bool = False,
base_ref: str = "",
) -> bool:
"""Run one review and post it as `pragent-bot`.
@@ -813,6 +858,10 @@ def review_pr(
review with inline comments + suggestions (unanchored findings → summary
bullets).
`base_ref`: the PR's base branch. `.pr-review.json` is read from there (not
from the PR head) so a PR cannot ship its own reviewer instructions; empty
means "the repo's default branch".
`report_usage`: when True (PR carries the `AI-USAGE` label), the opencode
engine's measured token/cost usage is rendered as a `## 🔋 AI usage` section
on the review body and an attributed `🪙 ~N tok` line on each inline
@@ -834,7 +883,7 @@ def review_pr(
post_review(api, repo, index, token, format_review_body("No diff content to review.", model, sha))
return True
config = fetch_repo_config(api, repo, sha, token)
config = fetch_repo_config(api, repo, token, ref=base_ref)
prior = prior_review_bodies(reviews, sha)
engine = os.environ.get("PRAGENT_ENGINE", "opencode").strip().lower()
@@ -921,6 +970,7 @@ def run() -> int:
model=_need("OLLAMA_MODEL"),
max_tokens=int(os.environ.get("OLLAMA_MAX_TOKENS", "8000")),
max_chars=int(os.environ.get("DIFF_MAX_CHARS", "150000")),
base_ref=os.environ.get("PR_BASE_REF", ""),
)
return 0