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
+83 -18
View File
@@ -26,6 +26,12 @@ Env:
OLLAMA_MAX_TOKENS (optional) output cap, default 6000
DIFF_MAX_CHARS (optional) diff truncation cap, default 150000
WEBHOOK_PORT (optional) listen port, default 8080
PRAGENT_MAX_CONCURRENT_REVIEWS
(optional) how many reviews may run at once, default 2.
Each review forks an opencode process that checks out a
repo and runs linters, so this is the real resource knob.
PRAGENT_MAX_BODY_BYTES
(optional) request-body cap, default 10 MiB
"""
import hashlib
@@ -57,6 +63,24 @@ OLLAMA_MAX_TOKENS = int(os.environ.get("OLLAMA_MAX_TOKENS", "8000"))
DIFF_MAX_CHARS = int(os.environ.get("DIFF_MAX_CHARS", "150000"))
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "").encode()
PORT = int(os.environ.get("WEBHOOK_PORT", "8080"))
MAX_CONCURRENT = max(1, int(os.environ.get("PRAGENT_MAX_CONCURRENT_REVIEWS", "2")))
MAX_BODY_BYTES = int(os.environ.get("PRAGENT_MAX_BODY_BYTES", str(10 * 1024 * 1024)))
# Bound on reviews running at once. Every review forks an opencode process that
# untars a repo, reads files and shells out to linters, so an unbounded thread
# per delivery is a self-inflicted fork bomb the first time someone labels ten
# PRs (or Gitea retries a burst). Queued deliveries wait here rather than pile
# onto the box; the handler has already returned 202, so nothing times out.
_review_slots = threading.Semaphore(MAX_CONCURRENT)
# Reviews currently accepted or running, keyed (repo, index, sha). The
# sha-marker dedupe in `review_pr` reads Gitea *before* posting, so two
# deliveries for the same commit in flight together both see "not yet reviewed"
# and both post — the classic check-then-act race, and label-toggling is exactly
# the kind of thing that fires two deliveries a second apart. This set closes
# the window inside one process.
_inflight: set[tuple[str, str, str]] = set()
_inflight_lock = threading.Lock()
def _labels_have(labels, name: str) -> bool:
@@ -114,6 +138,8 @@ def _handle_pull_request(payload: dict) -> tuple[int, str]:
head = pr.get("head") or {}
sha = head.get("sha", "") or ""
base_ref = (pr.get("base") or {}).get("ref", "") or ""
if not BOT_TOKEN:
return 500, "PRAGENT_BOT_TOKEN not set"
@@ -124,33 +150,58 @@ def _handle_pull_request(payload: dict) -> tuple[int, str]:
os.environ.get("PRAGENT_USAGE_ALWAYS")
)
key = (repo, str(index), sha)
if not _claim(key):
return 200, f"ignore (already in flight) {repo}#{index} sha={sha[:8]}"
threading.Thread(
target=_run_review,
args=(repo, str(index), title, body, sha, report_usage),
args=(key, title, body, report_usage, base_ref),
daemon=True,
).start()
return 202, f"reviewing {repo}#{index} action={action} sha={sha[:8]} usage={report_usage}"
def _run_review(repo: str, index: str, title: str, body: str, sha: str, report_usage: bool) -> None:
def _claim(key: tuple[str, str, str]) -> bool:
"""Reserve (repo, index, sha) for review. False if already claimed."""
with _inflight_lock:
if key in _inflight:
return False
_inflight.add(key)
return True
def _release(key: tuple[str, str, str]) -> None:
with _inflight_lock:
_inflight.discard(key)
def _run_review(
key: tuple[str, str, str], title: str, body: str, report_usage: bool, base_ref: str
) -> None:
repo, index, sha = key
try:
ok = review_pr(
api=GITEA_API,
repo=repo,
index=index,
title=title,
body=body,
sha=sha,
token=BOT_TOKEN,
ollama_url=OLLAMA_URL,
model=OLLAMA_MODEL,
max_tokens=OLLAMA_MAX_TOKENS,
max_chars=DIFF_MAX_CHARS,
report_usage=report_usage,
)
with _review_slots:
ok = review_pr(
api=GITEA_API,
repo=repo,
index=index,
title=title,
body=body,
sha=sha,
token=BOT_TOKEN,
ollama_url=OLLAMA_URL,
model=OLLAMA_MODEL,
max_tokens=OLLAMA_MAX_TOKENS,
max_chars=DIFF_MAX_CHARS,
report_usage=report_usage,
base_ref=base_ref,
)
print(f"pragent-webhook: reviewed {repo}#{index} sha={sha[:8]} ok={ok} usage={report_usage}", flush=True)
except Exception as e: # review_pr is fail-open, but guard the thread anyway
print(f"pragent-webhook: thread crashed for {repo}#{index}: {e}", flush=True)
finally:
_release(key)
class Handler(BaseHTTPRequestHandler):
@@ -164,7 +215,9 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self._send(200, "ok")
with _inflight_lock:
n = len(_inflight)
self._send(200, f"ok inflight={n} max_concurrent={MAX_CONCURRENT}")
else:
self._send(404, "not found")
@@ -172,8 +225,20 @@ class Handler(BaseHTTPRequestHandler):
if self.path != "/webhook":
self._send(404, "not found")
return
length = int(self.headers.get("Content-Length", "0") or "0")
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError:
self._send(400, "bad content-length")
return
# Cap before reading: the body is read whole into memory, so an
# unbounded Content-Length is a one-request OOM.
if length < 0 or length > MAX_BODY_BYTES:
self._send(413, "payload too large")
return
raw = self.rfile.read(length) if length else b""
if len(raw) != length:
self._send(400, "truncated body")
return
if not _verify_signature(raw, self.headers):
self._send(401, "invalid signature")