The canalhandia PR review lost all findings because the agent ran out of
context before emitting the closing json fence. Three failure modes hit
the old regex \{.*?\}:
* nested objects inside the fence truncated at the first }
* bare arrays (no {summary, findings} wrapper) returned []
* unfenced JSON in the prose tail was never reached (first not last)
Replace the regex with a balanced-brace scanner:
* _last_json_block walks the fence contents with a depth counter so
nested objects survive
* _last_balanced_json + _balanced_json_substring handle bare arrays and
prose-tail JSON when no fence is present
* _parse_json_tolerant returns list as well as dict; parse_findings and
parse_review_output accept a bare array as the outer value
Agent prompt tightened: reserve the final step for emitting the JSON
block so the analysis isn't lost when context runs out.
10 new tests in tests/pilot/test_ai_review.py cover the new shapes.
Co-Authored-By: Claude <noreply@anthropic.com>
Audited the working tree and all 26 commits of history for credentials: none
found. No API keys, no private keys, no tokens — the live bot token, webhook
secret and admin token appear nowhere in the repo or its history.
What was there was infrastructure disclosure, which is recon material rather
than a leak, but has no business in a public repo:
- Tailnet addresses and cluster-internal hostnames in code, docs and the CI
template. The model endpoint is now supplied at runtime via
PRAGENT_MODEL_BASE_URL and patched into opencode.json by install_config();
the committed config carries a placeholder, guarded by a test.
- A host path (/home/marcos) as the default rtk directory — now unset.
- Real usernames in the onboarding docs — now alice/acme.
- A standing list of one-time setup tokens that were never revoked, named
individually. Removed. Note that removing the list does not revoke the
tokens: they should still be revoked in the Gitea admin UI.
The substitution happens in Python rather than via opencode's {env:VAR} config
templating, because the reviewer subprocess runs with an allow-listed
environment — resolving it before the process starts keeps that allow-list from
having to grow.
README rewritten for a reader who has never seen the project: what it does and
what that output looks like, honest status (pilot works, framework designed but
unbuilt), the security model up front given what this thing is, and the measured
cost numbers including the two effects that make naive estimates wrong.
History still contains the old addresses. They are tailnet-only and not
credentials, so no rewrite.
Tests: 131 -> 137.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
Skills — the primary now loads conditionally (each one is input tokens), per a
load table in pragent.md:
- attention-tiering: classify every PR trivial/lite/full/oversized BEFORE
reading anything, and cap file reads, linter runs and subagent fan-out per
tier. This is the cost governor; the other skills defer to its budget.
- linter-playbook: per-ecosystem detect-and-run commands scoped to changed
files, the never-install rule, and how to turn a diagnostic into a finding
instead of pasting tool output.
- security-lens: the inline security checklist for when @security isn't worth
delegating, built around a source -> sink test each finding must pass.
- malicious-change: hostile-PR detection — injection aimed at the reviewer,
install/CI-time hooks, obfuscated payloads, dependency confusion, logic
backdoors. Complements the runtime containment added in the previous commit:
that stops the agent being hijacked, this makes it report the attempt.
- comment-craft: how to write problem/fix/suggestion so a maintainer can act in
one read, and what to cut.
pilot/cost_model.py — prices a review against published Claude and OpenAI rates
(fetched 2026-08-18). Prompt sizes are measured from the factory files rather
than guessed; per-tier workloads come from the tiering budgets. The model is
explicit about the thing that actually dominates an agent loop: the whole
conversation is resent every step, so caching moves ~2.3x of the bill.
Blended over a 5/35/55/5 mix with caching on: ~$0.61/PR on Opus 5 or GPT-5.6
Sol, ~$0.24 on Sonnet 5 or Terra, ~$0.12 on Haiku 4.5, ~$0.02 on Luna. At 350
PRs/month that's ~$212 / ~$85 / ~$43 / ~$8.50.
Tests: 101 -> 122.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
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
Add per-review + per-comment token accounting, surfaced only when a PR carries
the new AI-USAGE label (on top of the existing AI-REVIEW trigger).
opencode_review:
- run_opencode now uses `--format json`; parse_opencode_events reconstructs the
assistant text from `text` events and sums tokens/cost/steps from every
`step_finish` event (tolerant of noise / missing fields).
- run() measures duration_s around the opencode call and returns (text, usage).
- changed_files(diff) extracts the `+++ b/` paths; the brief now lists them
under a "Changed files" focus block so the agent grounds findings in the
diff's neighbourhood instead of unbounded whole-repo walks.
ai_review:
- format_usage_section renders a `## AI usage` block: measured totals
(in/out/reasoning/cache/cost/steps/duration), the whole-repo scope note, and
an attributed per-finding table. Per-comment counts are output tokens split by
each finding's body weight — labelled "attributed" since one model pass
produces all findings.
- inline_comment_body appends `🪙 ~N tok (X% · attributed output)` when
attribution is present.
- review_pr gains report_usage; compute_attribution stashes _tok_attrib/_tok_pct.
- format_review_body inserts the usage section between summary and findings.
webhook_server:
- Fire on every pull_request action except `closed` (denylist, was an allowlist)
— the AI-REVIEW gate + sha dedupe keep this safe.
- AI-USAGE label detection + PRAGENT_USAGE_ALWAYS env drive report_usage.
.opencode factory + review-methodology skill: new "Ground findings in context"
step — read callers/imports/sibling functions per changed file (1-3 files per
finding), no unbounded walks.
Tests: parse_opencode_events (text+usage sum, malformed tolerance, none-usage),
changed_files, compute_attribution math, inline 🪙 line, format_usage_section
totals/table/cost, format_review_body ordering. 68 passing.
Co-Authored-By: Claude <noreply@anthropic.com>
Gitea 1.26.x has no GitHub-style 'Apply suggestion' button — a ```suggestion
fence is just an unknown-language code block, so chroma does not highlight it
and there is no apply control. Switch inline_comment_body to wrap the suggested
fix in a fence tagged with the file's language (new _lang_for_path helper,
.java→java, .ts→typescript, .py→python, ...), so Gitea syntax-highlights the
code. No capability lost (there was never an apply button on this Gitea
version). Correct the docstrings/skills/README that wrongly claimed an
apply-button was rendered.
Co-Authored-By: Claude <noreply@anthropic.com>
Replace the single Python model-call reviewer with an opencode agent
factory. A primary 'pragent' agent reads a brief (title/body/diff/config/
prior reviews), inspects the checked-out repo, runs the repo's own linters
via bash, loads review-methodology + findings-schema skills, and emits a
{summary, findings} JSON with per-finding severity/path/line/problem/fix/
suggestion/reference. Dormant security/tests/perf subagent lenses fan out
only on large/risky diffs (lean by default).
pilot/opencode_review.py: fetches the repo archive at the head sha into a
temp workdir, writes .pragent/brief.md, drops the factory, runs
'opencode run --pure --agent pragent --dir <workdir>' headlessly. Isolates
HOME (shared, warmed), strips ANTHROPIC_* env (leaked host vars caused
ProviderModelNotFoundError), stdin=DEVNULL (opencode blocks on stdin),
maps the bare OLLAMA_MODEL to the provider-prefixed ref. No Gitea I/O —
ai_review.review_pr parses + anchors + posts (reuses all v2 logic/tests).
PRAGENT_ENGINE=opencode (default) selects it; =ollama keeps the legacy
direct-call path. Verified end-to-end: posts a real review with a summary
section, inline [CRITICAL]/[HIGH] comments + apply-able suggestions +
reference links, and the sha dedupe marker. 49 tests pass.
Co-Authored-By: Claude <noreply@anthropic.com>