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:
+83
-18
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user