Audit of 1.7.0, the open roadmap, and what to take from pr-agent¶
Reference: main at af9fae2 (1.7.0), read on 2026-09-29. The comparison
target is the-pr-agent/pr-agent
at 10bbd9a (0.46.0, same day). Tracked as
issue #120.
Four questions, in the order they were asked:
- What is wrong or weak in the code as it stands?
- Which roadmap items are still open, once the ones closed as not planned are taken out?
- What does pr-agent do that this project should, and what should it not copy?
- What would each transferable feature cost to build, and how much of that does copying pr-agent's code (MIT, with attribution) save?
Items already closed as not planned (#21, #28, #63, #66, #72, #74, #79,
84) are left out of every proposal below and named where a finding touches¶
them.
1. Findings¶
1.1 Verified defects¶
Both were reproduced by running the code, not by reading it.
D1. The preflight refusal only holds for the first attempt.
ClaudeCliEngine.preflight sets _version_checked = True before
_verify_flags runs. When the flag check raises EngineUnavailable, the
worker settles at zero and releases the row for retry; on the retry
preflight returns early and the review runs with the missing containment
flag. A stub whose --help lacked the flags refused attempt one and passed
attempt two. This undoes roadmap A3. Fix: set the flag only after both
checks succeed, and add a test that calls preflight twice against a
failing help text.
D2. Pushes to the newest open pull request re-trigger reviews.
The classifier docstring says pushes are ignored, but the pr_opened
dedupe key embeds head_sha and freshness is created_at < since.
Daemon._pull_requests advances the watermark only to the newest
created_at seen, so the most recently opened pull request sits exactly at
the boundary and stays fresh until a newer one is opened. Every push then
mints a new trigger; fold will not collapse it because the head differs,
and pacing defers by fifteen minutes but does not drop. Three heads at
created_at == since all classified accepted. This widens spend beyond
what CLAUDE.md §5 documents. Fix: drop head_sha from the pr_opened
key (the suggestion #79 made before it was closed), or record "reviewed
once" per pull request number, and pin the bound with a test.
1.2 Security improvements¶
- S1. Kill the whole process tree on timeout.
_subprocess.stopterminates only the direct child.claudespawns helpers that survive the wall clock. Start children withstart_new_session=Trueand signal the process group. - S2. Escape links and images in engine prose.
sanitisehandles mentions, references and raw HTML, butor a markdown link survives. An image embed is fetched when a reader opens the page, so it is an exfiltration channel that needs no click and no network tool. Escape](and bare URLs in prose, or at minimum image syntax. - S3. Extend the secret canary beyond
GITHUB_TOKEN.leaksis fed only the GitHub token. The child inheritsHOME, so~/.claude/.credentials.jsonandANTHROPIC_API_KEYare readable and unguarded. Feed the canary the API key and a prefix of the credential file. The underlying same-uid exposure is #63; this is the cheap partial. - S4. Log hygiene on the ERROR path.
worker._failedlogs the exception message at ERROR;EngineProtocolErrorandGitCommandErrorcarry attacker-influenced stdout and stderr, and the text formatter permits newline injection into the journal. Log a digest or a bounded, escaped excerpt. This is roadmap F4. - S5. Dev container: the remote-control daemon is baked into the shared
entrypoint.
docker/entrypoint.shanddocker-compose.ymlhardcode/home/prasad/claude-daemon/rc-daemon.sh, which startsclaude remote-control --permission-mode bypassPermissionsbeside the reviewer's~/.claudecredential and the passed-throughGITHUB_TOKEN. Make it opt-in and remove the hardcoded username; passwordlesssudocould also go from the default image. Thecurl | shpin is #66 and is not re-raised. - S6. Comment edits change eligibility without changing attribution.
Freshness uses
updated_at, so any collaborator with edit rights can insert@handleinto an allowlisted user's old comment; the trigger, and the per-contributor budget, are charged to the original author. Either document the trust assumption or gate oncreated_at. - S7. Cheap systemd ceilings now. Add
MemoryMax=,TasksMax=,PrivateTmp=yesandCapabilityBoundingSet=to the shipped user units ahead of any sandbox work. This is roadmap F1. - S8.
/procexposure. With the daemon and the child on one uid,/proc/<daemon pid>/environexposesGITHUB_TOKENto theReadtool even thoughcli_environmentstrips it. Worth a line in the docs while #63 is closed.
1.3 Design improvements¶
- G1. Enforce "cache_dir must differ per instance".
Workspace.sweepdeletesruns/at startup; the fleet docs only warn. Write an owner marker intocache_dirand refuse to start when another repository owns it. - G2. Wire or remove the GitHub base URL.
Workspaceacceptsbase_url,GitHubClienthardcodesapi.github.com, anddaemon.runpasses neither. - G3. Flag verification should match whole tokens.
_verify_flagsuses substring containment, so--modelis satisfied by--model-foo. - G4. Keep store calls off the event loop. Every store call is
synchronous on one connection shared by the poll task and the workers;
busy_timeoutcan hold the loop for five seconds under multi-daemon contention. - G5. Coarse pre-fetch size refusal. The API's totals are available before the fetch; a generous pre-fetch cap stops a pathological pull request from filling disk while the precise post-exclusion gate stays. This is half of roadmap F2.
- G6. Add a provenance column to findings now.
Findinghas no source field; B1/B3 or a second adapter would need a migration later. - G7.
SECURITY.mdand release attestations. There is no vulnerability-reporting policy in the repository. pr-agent ships one plus digest pinning and GitHub Artifact Attestations; the wheel and sdist here could carry attestations from the existing release workflow.
2. Roadmap items still open¶
From FEATURE-ROADMAP.md. Landed items (A3, A5, A6) and items whose tracking issue was closed as not planned are excluded.
| Item | Section | What it is | Order | Note |
|---|---|---|---|---|
| A10 | Confinement | One confinement.py holding the environment allowlist, tool set, argv and, later, any uid/sandbox/egress profile |
9 | Still valuable without A1/A2: today the profile spans BASE_ENVIRONMENT, env_prefixes, TOOLS and the argv tuple |
| B1 | Deterministic gate | Opt-in scanners on the worktree before the engine; findings passed in as evidence | 6 | Zero tokens; an absent or crashed scanner must not fail the review |
| B2 | Deterministic gate | Scanner-only rung below EXHAUSTED |
6 | Distinct from the 60 % rung (#21); needs B1 |
| B3 | Deterministic gate | Record where a finding came from | 10 | See G6 |
| B4 | Deterministic gate | Baseline scanner findings at the merge base | 10 | Checkout.merge_base exists |
| B5 | Deterministic gate | Detect a finding silenced rather than fixed | 10 | ReviewRequest.prior and stable numbering carry the identity |
| B6 | Deterministic gate | SARIF output and CWE mapping (open question) | 12 | Needs security-events: write; CWE mapping alone is free |
| C1 | Vocabulary | A verb after the mention (@claude ask …) |
8 | Trigger layer; must never accept configuration from the comment (§3.1, P2) |
| C2 | Vocabulary | Incremental review since the last reviewed head | 4 | Largest budget saving; the ledger holds the previous head |
| C3 | Vocabulary | Committable suggestion blocks | 11 | Off by default |
| C4 | Vocabulary | Inline line-anchored comments | 11 | Costs the "cannot approve" absent-capability guarantee |
| C5 | Vocabulary | Per-repository review configuration at the merge base | 11 | Adopt pr-agent's host-only key allowlist pattern |
| D1 | Dimensions | Optional per-dimension passes, whole set reserved up front | 11 | Must not triple default spend |
| D2 | Dimensions | Truncate an oversized pull request and say so | 11 | Belongs in exclusions.pathspec |
| E1 | Transparency | AI-disclosure line per comment | 7 | Formatting over data already recorded |
| E2 | Transparency | Append-only JSONL audit log per run | 7 | Independent of the retention sweep (#28) |
| F1 | Ceilings | cgroup ceilings over git and engine children | 7 | Configuration; see S7 |
| F2 | Ceilings | Free-space floor in Workspace.sweep |
9 | See G5 |
| F3 | Ceilings | runs/ mode, daemon umask, mirror garbage collection |
7 | The mode half is covered by cache_dir's mode today; the GC is not |
| F4 | Ceilings | Log hygiene | 9 | See S4 |
Excluded: A3, A5, A6 landed. A1, A2, A4 are #63, closed as not planned. A7
is #84, closed as not planned, and needs A1/A2. A8 and A9 consume A1/A2 and
have nothing to consume while #63 is closed. The 60 % rung (#21), the
retention sweep (#28), pagination (#72), _pr_number (#74), the
publish-time state check (#79) and the dev-image pin (#66) were never
roadmap items and are not re-proposed.
3. Comparison with pr-agent¶
pr-agent is the community-maintained continuation of Codium/Qodo's PR-Agent: a webhook, Action and CLI tool with providers for GitHub, GitLab, Bitbucket, Azure DevOps, Gerrit, Gitea and CodeCommit, a LiteLLM model layer, and a family of slash commands. The two projects make opposite architectural bets, so most of its features are not transferable as code, but several patterns are.
| Dimension | pr-review-agent | pr-agent |
|---|---|---|
| Trigger surface | Outbound polling of three endpoints; no listener | Webhook servers, GitHub Action, polling mode, CLI |
| Who may trigger | Numeric-id allowlist, checked before any spend | Any commenter on an installed repo; regex ignore lists; bot heuristics by display name |
| Config from the reviewed repo | Only standards_paths text, read at the merge base |
.pr_agent.toml at root and per directory, with an explicit host-only key allowlist; comment arguments override most keys |
| Engine boundary | claude CLI subprocess, read-only tools, built environment, wall clock |
In-process LiteLLM calls; the model sees the diff plus selected repo files |
| Spend control | Reserve-then-settle ledger, rolling windows, ladder, breaker, pacing, per-contributor cap | Per-call token and call caps, model routing by size, fallback models; no cross-run budget |
| Output hygiene | Mention neutralisation, escaping, length cap, secret canary; comment-only, cannot approve | Sanitised failure text; auto-approval exists but is off; can add labels, edit descriptions, push commits |
| Output shape | One new comment per review, numbered findings stable across rounds | Persistent edited comment, inline comments, labels, committable suggestions, diagram, run-details footer |
| Large pull requests | Size gate refuses; exclusions by pathspec | Token-aware compression, dynamic asymmetric context, chunked review, clip-or-skip policy |
| Observability | Structured JSON logs to journald | OpenTelemetry with PR URLs and error details off by default |
| Secrets | GITHUB_TOKEN from the environment only |
.secrets.toml, environment, AWS Secrets Manager, GCS |
| Supply chain | SHA-pinned Actions, pip-audit, Trusted Publishing |
Immutable tags, digest pinning, Artifact Attestations, SECURITY.md |
3.1 Security patterns to take¶
- P1. Host-only versus repo-overridable settings, as an allowlist.
pr-agent's
config_security.pyenumerates, per section, which keys a reviewed repository may set, with the reason for each denial beside it. When C5 or C1 land, copy the shape: a frozen map of overridable keys and one test that every key in the schema is classified. - P2. Never let a comment carry configuration. pr-agent's #2445 (a clone target overridable from a comment, a weak hostname check, the token embedded in the clone URL) is the failure C1 must design out: a verb after the mention is a fixed vocabulary, not key-value arguments.
- P3.
SECURITY.md, private vulnerability reporting, attestations. See G7. - P4. Bot detection by account type, not display name. This project's
type == "Bot"check is the stronger one; keep it. - P5. Telemetry private by default. If metrics are added, copy the
include_pr_url=falseandinclude_error_details=falsedefaults. - P6. Deterministic ignore rules before any spend. Labels, target and
source branches, title patterns. They are spend controls: a
wiplabel should not reserve allowance.
Where this project is already ahead and should not converge: no inbound
listener, identity by numeric id, no in-comment argument parsing, the
ledger, and the publisher's absent-capability guarantee. pr-agent's
enable_auto_approval and approve_pr_on_self_review are exactly the
fields this project refuses to have.
3.2 Features to take¶
Ranked by value under this project's constraints.
- Q1. Incremental review with commit and minute thresholds (C2).
- Q2. Self-reflection pass with a score threshold. A second, cheaper engine call over the structured output only, reserved with the main run.
- Q3. Model routing by pull request size. An ordered rule list keyed on
checkout.reviewed; the concrete mechanism #21 lacked. - Q4. Review coverage footer. Say what exclusions and truncation removed.
- Q5. Deterministic ignore rules (P6) plus generated-code globs on top
of
excluded_paths. - Q6. Outcome reactions replacing the 👀 on success or failure.
- Q7.
@claude ask(C1), with pr-agent'suse_conversation_historyas the warning about what reaches the model. - Q8. Committable suggestions and inline comments (C3/C4), gated by a score threshold if C3 lands.
- Q9. Effort, risk and merge-recommendation fields in the schema, rendered in the comment rather than as labels.
- Q10. Host-level skill libraries, loaded from a host path under a token cap, never from the tree.
- Q11. Run-details footer (E1).
- Q12. Response language.
Not recommended, matching the roadmap's position: multi-provider git support, LiteLLM, description rewriting, changelog commits, label writes, auto-approval, per-directory configuration from the reviewed tree.
3.3 pr-agent as a dependency¶
Measured against pr-agent 0.46.0's pyproject.toml.
- Python floor. pr-agent requires 3.12 or later; this project supports 3.10 to 3.14 and CI proves the range. A hard dependency drops two versions.
- Dependency tree. Around 45 pinned packages, including
litellm,openai,anthropic,fastapi,uvicorn,gunicorn,boto3,google-cloud-aiplatform,langfuse,a2a-sdk,PyGithub,python-gitlab,tiktoken,GitPython,dynaconf,Jinja2and the OpenTelemetry stack, several pinned exactly. Today the runtime tree ishttpx,PyYAMLandclick. - The design constraint.
DESIGN.mdrests on no vendor SDK being linked. Model SDKs arriving transitively make "nothing in this process can call a model outside the governor" a claim to re-check on every upgrade rather than something the import graph proves. - Coupling. pr-agent's helpers read a dynaconf settings singleton at call time, so importing them means initialising its configuration system inside this daemon.
Recommendation: no runtime dependency. Vendor the few pure modules worth
having under src/pr_review_agent/_vendor/pr_agent/, MIT-attributed, with
the upstream commit recorded, in the shape skill install already uses. If
a runtime import is ever wanted, make it an optional extra so the default
install keeps its three-package tree and its Python floor.
4. Cost: rebuild versus copy with attribution¶
Estimates are for one engineer who knows this codebase, in working days, including tests and documentation. Runtime cost is per review. "Copyable" names the pr-agent source and how coupled it is to pr-agent's settings singleton, logger and providers; the coupling is what decides whether copying saves anything.
4.1 Rebuild from scratch¶
| Feature | Effort | New code, tests included | Runtime cost | Main risk |
|---|---|---|---|---|
| Ignore rules: labels, branches, title regex | 1 d | ~200 lines | None; refuses before spend | Regex on attacker-controlled titles; anchor and bound it |
| Generated-code exclusions | 0.5 d | Data only | None | Over-excluding a repo that reviews generated files |
| Outcome reactions | 0.5 d | ~60 lines | One extra POST | None |
| Coverage footer | 1 d | ~100 lines | None | None |
| Run-details footer (E1) | 1 d | ~80 lines | None | Discloses spend publicly; flag it |
| Response language | 0.5 d | ~30 lines | None | None |
| Incremental review (C2) | 3.5 d | ~350 lines | Saves 30 to 70 % on later rounds | Force-push makes the last head unreachable; fall back to full |
| Model routing by size | 2 d | ~150 lines | Saves tokens on small changes | Two models, two version assumptions |
| Host-only config allowlist (with C5) | 2 d + 3.5 d | ~250 lines + C5 | None | Any key reachable from the tree is an injection path |
| Effort, risk, merge fields | 2 d | ~150 lines | Small token increase | Prompt tuning; render, never label |
| Dynamic hunk context | 2 d | ~300 lines | Slightly larger prompt | Low value: the engine reads the tree |
| Self-reflection pass | 6 d | ~450 lines + a live test | +15 to 30 % tokens | Second call inside one reservation |
| Host-level skill libraries | 2.5 d | ~200 lines | Bounded by a cap | Paths host-only |
@claude ask (C1) |
6 d | ~500 lines | Cheaper than a review | Trigger layer |
| Total | ~34 d (30.5 d without C5) |
4.2 Copy with attribution¶
Fixed overhead for the copying route, paid once: a _vendor/pr_agent
package with LICENSE and a NOTICE naming the upstream commit, a
test_vendor_provenance.py that pins it, and the first pass at making
foreign code clear ruff, pylint, pyright and the 250-line module
limit. About 1.5 d, plus about 0.25 d per module for splitting and
lint conformance.
| Feature | Copyable pr-agent source | Coupling | Effort with copying | Saving |
|---|---|---|---|---|
| Ignore rules | servers/utils.shared_should_process_pr_logic (settings + provider objects) |
High | 1 d | 0 |
| Generated-code exclusions | settings/generated_code_ignore.toml, 42 lines of data |
None | 0.25 d | 0.25 d |
| Outcome reactions | Provider-specific reaction code | High | 0.5 d | 0 |
| Coverage footer | algo/run_output.py, run_details.py (settings, provider) |
High | 1 d | 0 |
| Run-details footer | algo/run_details.py, 234 lines, 7 coupled refs; shape only |
High | 0.75 d | 0.25 d |
| Response language | One prompt line | None | 0.5 d | 0 |
| Incremental review | github_provider._get_incremental_commits (PyGithub) |
Total | 3.5 d | 0 |
| Model routing | algo/model_routing.py, 80 lines, 7 coupled refs; rule matching portable |
Medium | 1.5 d + 0.25 d | 0.25 d |
| Host-only allowlist | config_security.py, 178 lines; the pattern, not the keys |
Low | 1.5 d + C5 | 0.5 d |
| Effort, risk, merge fields | settings/pr_reviewer_prompts.toml field definitions, 367 lines |
None (data) | 1.5 d | 0.5 d |
| Dynamic hunk context | algo/git_patch_processing.py: extend_patch, process_patch_lines, omit_deletion_hunks, ~200 lines, 3 settings refs; 400 lines of tests |
Low | 1 d + 0.25 d | 0.75 d |
| Self-reflection pass | settings/code_suggestions/pr_code_suggestions_reflect_prompts.toml, 115 lines; the mechanics are new here |
Low (data) | 5 d | 1 d |
| Skill libraries | algo/skills_loader.py, 361 lines, 17 coupled refs; frontmatter parsing and token cap portable |
Medium | 1.75 d + 0.25 d | 0.5 d |
@claude ask |
tools/pr_questions.py (LiteLLM, provider) |
Total | 6 d | 0 |
| Total | ~30.5 d incl. overhead (27 d without C5) | ~4 d gross, ~2.5 d net of overhead |
4.3 Comparative totals¶
| Rebuild | Copy with attribution | Difference | |
|---|---|---|---|
| Cheap tier (six items) | 4.5 d | 4.0 d | 0.5 d |
| Medium tier (C2, routing, allowlist + C5, fields, hunk context) | 15 d | 12.75 d + 1.25 d overhead | 1 d |
| Expensive tier (reflection, skills, ask) | 14.5 d | 12.75 d + 0.5 d overhead | 1.25 d |
| One-off vendoring setup | 0 | 1.5 d | −1.5 d |
| Total | ~34 d | ~32.75 d | ~1.25 d, about 4 % |
Copying saves little, and the reason is structural rather than a lack of good code upstream: pr-agent's logic is written against a global settings object, a global logger, PyGithub and LiteLLM, so the portable part of any module is the algorithm in the middle, and that is the small part. The three places copying is clearly worth it are the patch-extension helpers (stdlib once parameterised, with tests that port intact), the reflection rubric (data), and the generated-code globs (data). Everything else is cheaper to write against this project's own types than to unpick.
Two cost centres that the numbers above include and people tend to leave
out: documentation and tests are roughly 40 % of every line, because every
component has a page under docs/ and every spending change needs a
pinned bound; and the full set adds around fifteen keys to config.yaml,
which CONFIG.md already names as the failure mode to avoid, so several of
these should ship as fixed behaviour rather than knobs.
Not worth building either way: the compression strategy and chunked review (the engine reads the tree; the diff is one prompt section), committable suggestions and inline comments (they cost the publisher's cannot-approve guarantee), and anything touching labels, descriptions or commits.