Skip to content

fix(ai-review): fence untrusted PR content, stop shifting inline anchors, and stop judging a fix you cannot see#9145

Merged
JSONbored merged 1 commit into
mainfrom
fix/9035-9075-9076-ai-integrity
Jul 27, 2026
Merged

fix(ai-review): fence untrusted PR content, stop shifting inline anchors, and stop judging a fix you cannot see#9145
JSONbored merged 1 commit into
mainfrom
fix/9035-9075-9076-ai-integrity

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Three ways the AI reviewer acted confidently on input it had silently altered, truncated, or should never have trusted in the first place.

#9035 — attacker-controlled text reached the prompt with no structural defense

Title, body and diff were concatenated straight into the user prompt. "Judge ONLY the diff" tells the model what to look at; it never says that what it is looking at is data. The only defense was regex defang — deliberately narrow, and walked past by paraphrase or encoding. Worse, the same text reaches both consensus reviewers, so a successful steer suppresses both and never even trips the single-rejection ai_review_split rule that exists to catch one reviewer being wrong.

Each untrusted region is now fenced, with a system rule stating that content between the markers is data and cannot change the rules, the output format, or the verdict. Forged markers are stripped from inside the region, so a body cannot close its own fence early — without that, fencing would be worse than none.

And a detected attempt now holds the PR for a human. Defang recorded these and, by design, never let them affect the disposition, so a caught attacker got a completely normal roll. Manipulation aimed at the reviewer is evidence of intent, and the one thing it must not buy is an automated decision.

Held, never closed — the detector is a regex running over a repository whose own subject matter is AI review, so a false positive on a legitimate PR discussing prompt handling is entirely possible, and a hold costs that contributor a wait where a close would cost them their PR. It reuses the existing ai_review_inconclusive hold path, so no gate-decision twin changes were needed.

#9076 — the defang shifted every inline anchor after it

The injection patterns' [^.]{0,N} gaps deliberately span newlines, so one match can swallow two or three diff lines — including their +/- markers and even an @@ header. Replacing all of that with a single-line literal collapsed those newlines away.

The reviewer counts an inline finding's line over the defanged text, but the finding is validated and posted against the original patch. So every anchor after a multi-line redaction shifted by the number of collapsed newlines, and a shifted anchor that still landed inside the commentable set passed validation and posted publicly on the wrong line of a contributor's PR. The redaction now re-emits one newline per newline consumed, pinned by a line-count invariant test.

Two more on the same surface:

  • Blockers now anchor to added lines only. rightSideLinesFromPatch admits every RIGHT-side line, added and unchanged context, while the prompt explicitly asks for "an ADDED (+) line" and warns "a wrong line is worse than none". Set membership was the only check and context satisfies it, so a model miscounting by one to three lines within a hunk landed on context, passed every check, and posted. That file conflated two different properties throughout: "GitHub will accept this anchor" (avoiding a 422) and "this anchor is correct". Nits still allow context — a misplaced nit is noise, a misplaced blocker costs someone their PR.
  • An empty patch line counts as context instead of desynchronizing every later line number in the file. The trailing split artifact of a patch ending in \n is dropped first, so that case stays distinct from a genuine stripped-space context line.

#9075 — a confident "you didn't fix the issue", computed from a window that never contained the fix

linked-issue-satisfaction's own header claims "NO gate wiring, NO disposition change… advisory-only either way". That is stale. Under linkedIssueSatisfactionGateMode: "block" an unaddressed verdict pushes a critical-path finding with the public text "this PR does not appear to satisfy its linked issue's scope."

Meanwhile the module re-slices the already-truncated diff to 60k under the header "Unified diff (truncated if large):" — a hedge, not a fact, leaving the model to guess whether it is seeing everything. A PR whose issue-satisfying change sits in a dropped hunk, a filtered-out file, or past character 60,000 received that public verdict anyway. The confidence floor is no protection: it guards against a model being unsure, not against a model being sure about the wrong input.

The header now states the fact, and an unaddressed verdict over a known-truncated diff degrades to partial. Only unaddressed — "addressed" over a truncated diff is the model finding positive evidence, which truncation cannot manufacture. Same reasoning #8961 already applies to truncated PR bodies.

Policy reversal — please read

selectContextSectionsWithinBudget's break was pinned by a test asserting it is "a hard priority cutoff, not a bin-packing optimization that skips a large blocked section to squeeze in a smaller lower-priority one."

That reasoning holds for sections that are genuinely model context, where priority order encodes what matters most. It does not hold for what actually sat at the bottom of that list: testEvidence is ~200 characters and is not context at all but a deterministic classifier fact ("this PR changes no test paths"). Under the old rule a single large RAG block silently discarded it, on precisely the large PRs where a reviewer most needs to know whether tests were touched, with no marker anywhere saying it had been dropped.

An oversized section is now skipped rather than ending the loop. Priority order still decides who gets first refusal on the budget; it just no longer lets one oversized section evict everything cheaper behind it, and every included section still genuinely fits. Two existing tests encoded the old rule and are inverted here, with the reasoning written into them.

Notes for review

Also fixes main — the same one-line change as #9144, byte-identical so the two merge cleanly: docs-beta-onboarding asserted the onboarding doc still says "base-agent", which #9117 intentionally retired when it repositioned the product.

Three prompt-shape assertions in unrelated suites (impact-map, RAG, culture-profile) pinned the literal string "Unified diff (truncated if large):". They now assert the diff section is present without pinning its prose, which is what those suites actually care about.

Verification

  • npm run test:ci — exit 0, full gate green.
  • 100% line and branch coverage on all 317 added src/ lines, verified by intersecting git diff -U0 against lcov.info DA:/BRDA: records.

Closes #9035
Closes #9075
Closes #9076

…ors, and stop judging a fix you cannot see

Three ways the AI reviewer acted confidently on input it had silently altered,
truncated, or should never have trusted in the first place.

#9035 — attacker-controlled text reached the prompt with no structural defense.
Title, body and diff were concatenated straight in. "Judge ONLY the diff" tells
the model what to look at; it never says that what it is looking at is DATA. The
only defense was regex defang, which is deliberately narrow and which paraphrase
or encoding walks past — and the same text reaches BOTH consensus reviewers, so a
successful steer suppresses both and never even trips the single-rejection
ai_review_split rule that exists to catch one reviewer being wrong.

Each untrusted region is now fenced, with a system rule saying content between the
markers is data and cannot change the rules, the output format, or the verdict.
Forged markers are stripped from inside the region so a body cannot close its own
fence early — without that, fencing would be worse than none.

And a DETECTED attempt now holds the PR for a human. Defang recorded these and, by
design, never let them affect the disposition, so a caught attacker got a normal
roll. Manipulation aimed at the reviewer is evidence of intent, and the one thing
it must not buy is an automated decision. Held, never closed: the detector is a
regex running over a repository whose own subject matter is AI review, so a false
positive is entirely possible, and a hold costs a contributor a wait where a close
would cost them their PR. It reuses the existing ai_review_inconclusive hold path,
so no gate-decision twin changes.

#9076 — the defang shifted every inline anchor after it.
The injection patterns' `[^.]{0,N}` gaps deliberately span newlines, so one match
can swallow two or three diff lines including their `+`/`-` markers and even an
`@@` header. Replacing all of that with a single-line literal collapsed those
newlines. The reviewer counts a finding's line over the DEFANGED text, but the
finding is validated and posted against the ORIGINAL patch — so every anchor after
a multi-line redaction shifted, and a shifted anchor that still landed inside the
commentable set passed validation and posted publicly on the wrong line of a
contributor's PR. The redaction now re-emits one newline per newline consumed, so
the two coordinate systems stay congruent.

Two more on the same surface. Blocker-severity findings now anchor to ADDED lines
only: rightSideLinesFromPatch admits unchanged context too, while the prompt asks
for an added line and warns that a wrong line is worse than none — set membership
was the only check, and context satisfies it, so a model miscounting by one to
three lines landed on context and posted. That file conflated two different
properties throughout: "GitHub will accept this anchor" and "this anchor is
correct". Nits still allow context, because a misplaced nit is noise while a
misplaced blocker costs someone their PR. And an empty patch line now counts as
the context line it is instead of desynchronizing every later line number in the
file (the trailing split artifact of a patch ending in a newline is dropped first,
so the two cases stay distinct).

#9075 — a confident "you didn't fix the issue" computed from a window that never
contained the fix.
linked-issue-satisfaction's own header claims "NO gate wiring, NO disposition
change… advisory-only either way". That is stale: under
linkedIssueSatisfactionGateMode: "block" an `unaddressed` verdict pushes a
critical-path finding reading "this PR does not appear to satisfy its linked
issue's scope." Meanwhile the module re-slices the already-truncated diff to 60k
under the header "Unified diff (truncated if large):" — a hedge, not a fact, which
leaves the model to guess whether it is seeing everything. A PR whose fix sits in a
dropped hunk, a filtered file, or past character 60,000 got that public verdict
anyway. The confidence floor does not help: it guards against a model being
unsure, not against a model being sure about the wrong input.

The header now states the fact, and an `unaddressed` verdict over a known-truncated
diff degrades to `partial`. Only `unaddressed` — "addressed" over a truncated diff
is the model finding POSITIVE evidence, which truncation cannot manufacture. Same
reasoning #8961 already applies to truncated PR bodies.

POLICY REVERSAL, called out explicitly. selectContextSectionsWithinBudget's
`break` was pinned by a test asserting it is "a hard priority cutoff, not a
bin-packing optimization". That reasoning holds for sections that are genuinely
model context. It does not hold for what actually sat at the bottom of the list:
testEvidence is ~200 characters and is not context at all but a deterministic
classifier FACT ("this PR changes no test paths"), so one large RAG block silently
discarded it on exactly the large PRs where it matters most, with no marker
anywhere. An oversized section is now skipped rather than ending the loop. Priority
order still decides who gets first refusal on the budget; every included section
still genuinely fits. Both tests encoding the old rule are inverted with the
reasoning in place.

Also fixes main, same one-line change as #9144 (identical, so the two merge
cleanly): docs-beta-onboarding asserted the doc still says "base-agent", which
#9117 intentionally retired when it repositioned the product.

Local gate green end to end (npm run test:ci, exit 0). 100% line and branch
coverage on all 317 added src lines.

Closes #9035
Closes #9075
Closes #9076
@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@github-actions

Copy link
Copy Markdown
Contributor

Logic backtest

Replayed 0 historical case(s) for linked_issue_scope_mismatch through the base (0746e6c) and head (6ab786c) versions of its detection logic (corpus checksum 4f53cda18c2b).

Backtest comparison: linked_issue_scope_mismatch

Verdict: unchanged — no comparable axis moved.

Advisory only — this check never blocks merge (#8105).

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.67%. Comparing base (0746e6c) to head (6ab786c).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9145      +/-   ##
==========================================
+ Coverage   90.56%   92.67%   +2.11%     
==========================================
  Files          96      815     +719     
  Lines       22490    80892   +58402     
  Branches     3884    24573   +20689     
==========================================
+ Hits        20367    74970   +54603     
- Misses       1945     4844    +2899     
- Partials      178     1078     +900     
Flag Coverage Δ
backend 93.49% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/queue/processors.ts 95.69% <100.00%> (ø)
src/review/inline-comments-select.ts 100.00% <100.00%> (ø)
src/review/prompt-injection.ts 100.00% <100.00%> (ø)
src/review/safety.ts 100.00% <100.00%> (ø)
src/services/ai-review.ts 97.38% <100.00%> (ø)
src/services/linked-issue-satisfaction-run.ts 100.00% <100.00%> (ø)
src/services/linked-issue-satisfaction.ts 100.00% <100.00%> (ø)

... and 712 files with indirect coverage changes

@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 27, 2026
@loopover-orb

loopover-orb Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Tip

✅ LoopOver review result - approve/merge recommended

Review updated: 2026-07-27 03:42:07 UTC

14 files · 1 AI reviewer · no blockers · readiness 88/100 · CI green · clean

✅ Suggested Action - Approve/Merge

  • safe to merge

Review summary
This PR closes #9035, #9076, and #9075: it fences author-controlled title/body/diff behind explicit untrusted-content markers with a system-prompt instruction hierarchy, fixes a real anchor-shift bug where multi-line prompt-injection redaction collapsed newlines and desynced inline-comment line numbers, restricts blocker-severity inline findings to actually-added lines, and degrades an 'unaddressed' linked-issue verdict to 'partial' when the diff was truncated. The trace-to-source work is solid: the line-shift bug in prompt-injection.ts is demonstrated with a concrete before/after (newline-preserving replacement), the added/right-side line distinction in inline-comments-select.ts is exercised by dedicated tests, and the policy reversal in selectContextSectionsWithinBudget is justified with a specific real casualty (testEvidence section). The detected-injection hold in processors.ts correctly reuses the existing ai_review_inconclusive path and is scoped to when blocking AI review is required.

Nits — 6 non-blocking
  • src/review/inline-comments-select.ts:125's blocker-vs-nit branch (choosing addedLines vs rightLinesByPath) adds a nesting level worth flattening for readability per the external size-smell note.
  • The magic numbers 3/4/5 in INLINE_COMMENT_CATEGORY_PRIORITY and 120000/60/9075 in ai-review.ts and linked-issue-satisfaction.ts are unchanged from prior code or already commented, but consider a named constant for MAX_DIFF_CHARS's threshold reference (60) for readability.
  • fenceUntrusted (src/services/ai-review.ts) strips forged fence markers globally from the text, which is correct for security but means legitimate prose that happens to contain the literal marker string is also silently altered — worth a one-line comment noting this tradeoff is intentional (likely already understood given the PR's own reasoning).
  • Consider extracting the addedLines-vs-rightLines selection in anchorableInlineFindings (src/review/inline-comments-select.ts:125) into a small helper to reduce nesting depth.
  • The DIFF_TRUNCATION_MARKERS regexes in linked-issue-satisfaction.ts are tightly coupled to review-diff.ts's exact wording; a shared constant or test asserting parity between producer and consumer would catch future drift.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ✅ Gate result — Passing (No configured blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #9035, #9075, #9076
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ❌ 8/20 High review scope from cached public metadata (3 linked issues).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 13 registered-repo PR(s), 13 merged, 274 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 13 PR(s), 274 issue(s).
Improvement ✅ Minor risk: clean · value: minor
Linked issue satisfaction

Addressed
The diff adds explicit fence markers with a system-prompt instruction hierarchy stating fenced content is data not instructions, strips forged markers to prevent early-fence-closing, and adds maybeAddPromptInjectionHold which routes a detected injection attempt to the existing ai_review_inconclusive human-hold path when blocking AI review is required, matching the issue's fix items 1 and 3.

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is configured for direct PR review.
  • Public profile languages: Python, TypeScript, Ruby, Go, MDX, Shell, Solidity, JavaScript
  • Official Gittensor activity: 13 PR(s), 274 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Then work through the remaining 2 steps in the Signals table above.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask &lt;question&gt; answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat &lt;question&gt; answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

Decision record
  • action: merge · clause: success
  • config: 03a7f8b529a9 · pack: oss-anti-slop
  • record: 8d2d624f71a0 (schema v3, head 6ab786c)

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@loopover-orb loopover-orb Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LoopOver approves — the gate is satisfied and CI is green.

@JSONbored
JSONbored merged commit e708329 into main Jul 27, 2026
9 checks passed
@JSONbored
JSONbored deleted the fix/9035-9075-9076-ai-integrity branch July 27, 2026 04:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier.

Projects

None yet

1 participant