fix(engine): validate repoFullName and prNumber before composing the customer-facing results payload - #9619
fix(engine): validate repoFullName and prNumber before composing the customer-facing results payload#9619tryeverything24 wants to merge 1 commit into
Conversation
…customer-facing results payload buildResultsPayload interpolated caller-supplied repoFullName and prNumber into the customer-facing prLink and summary with no validation, so a traversal-shaped repo like acme/widgets/../../evil produced a link a browser resolves to an unrelated GitHub path, and prNumber 0 or -3 took the has-PR branch. Now the repo must split into exactly two valid segments (the shared isValidRepoSegment rule, restated locally like governor-ledger.ts) or the summary renders the literal 'unknown repository' and prLink stays null; hasPr additionally requires a positive integer; and per-file additions/deletions are normalized with the sibling modules' finiteNonNegativeInt rule before they reach diffPreview and totals. Payload is still always returned (never throws); the preview cap, status default, and redactSecrets scrub are unchanged. Closes JSONbored#9611
|
Caution 🛑 LoopOver review result - fixes requiredReview updated: 2026-07-29 00:55:54 UTC
Review summary Nits — 4 non-blocking
CI checks failing
Decision drivers
Context & advisory signals — never blocks the verdict
Linked issue satisfactionAddressed Review context
Contributor next steps
Signal definitions
🧪 Chat with LoopOverAsk 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.
Full command reference: https://loopover.ai/docs/loopover-commands 🧪 Experimental — new and may change. 🟩 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.
|
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #9619 +/- ##
==========================================
- Coverage 90.02% 90.01% -0.02%
==========================================
Files 888 888
Lines 111983 112015 +32
Branches 26570 26574 +4
==========================================
+ Hits 100810 100826 +16
- Misses 9843 9855 +12
- Partials 1330 1334 +4
Flags with carried forward coverage won't be shown. Click here to find out more.
|
|
LoopOver is closing this pull request on the maintainer's behalf (CI is failing (codecov/patch)). This is an automated maintenance action — to pursue this change, please open a new pull request with the issues resolved. Closed PRs may be analyzed later to improve review accuracy, but they are not automatically reopened or re-reviewed. |
Closes #9611
Bug
buildResultsPayload(packages/loopover-engine/src/results-payload.ts) composes the customer-facingprLinkandsummaryby raw interpolation of unvalidated input:repoFullNamewas interpolated as-is, so"acme/widgets/../../evil"producedprLink: "https://github.com/acme/widgets/../../evil/pull/1", which a browser resolves tohttps://github.com/evil/pull/1— a customer-facing link to an unrelated GitHub path. The same raw value also reached the documented "public-safe"summary.hasPronly checked!== null && !== undefined, soprNumber: 0,-3, or a non-integer took the has-PR branch and rendered.../pull/0,.../pull/-3, and"Opened PR #-3 in ...".additions/deletionswere folded with?? 0only, so caller-supplied negative, fractional, or non-finite counts flowed straight intodiffPreviewandtotals.The asymmetry was inside this one function:
result.titlewas already scrubbed withredactSecrets, while the two values that become a clickable link got no treatment.Root cause
The composer trusted its
IterationResultinput to already be well-formed, but its only guarantees at both entry points (BuildResultsPayloadRequestSchemainsrc/openapi/schemas.tsandBuildResultsPayloadInputinpackages/loopover-contract/src/tools/agent.ts) arez.string().min(1)forrepoFullNameandz.number().int().nullable().optional()forprNumber— neither traversal-safe nor positive.Fix (composer-local, per the required pattern)
isValidRepoSegmentguard locally (the same deliberate duplicationgovernor-ledger.tsandminer/deny-hook-synthesis.tsalready use, since the engine package must not import the miner package):repoFullNameis valid only when it splits on/into exactly two segments, each matching[A-Za-z0-9._-]+and neither a bare./... When invalid,prLinkisnulland the summary renders the literalunknown repository; the payload is still returned (the function never throws).hasPrnow additionally requiresNumber.isInteger(result.prNumber) && result.prNumber > 0;0, negatives, and non-integers take the existing no-PR branch with the existing"No pull request was opened for ..."phrasing.additions/deletionsare each normalized with the exactfiniteNonNegativeIntbody the sibling Rent-a-Loop modules use (loop-consumption.ts/tenant-quota.ts) before enteringdiffPreviewandtotals.prNumberstill renders the repo name; invalid repo + validprNumberstill yieldsprLink === null.totals.files,diffPreviewordering, theMAX_DIFF_PREVIEW_FILEScap and slice, thestatusdefault, and theredactSecrets(title)scrub. No new exported symbol, no zod-schema change.RED → GREEN
With the fix stashed (current
mainbehavior), the 8 new tests intest/unit/results-payload.test.tsfail; all 8 pre-existing tests pass:With the fix applied:
The traversal case (
prLink === null, summary containsunknown repositoryand no../), theprNumber: 0and-3cases, the{ path: "a", additions: -5, deletions: 2.7 }→{ path: "a", additions: 0, deletions: 2 }/totals { files: 1, additions: 0, deletions: 2 }case, and the canonicalacme/widgets+prNumber: 42→https://github.com/acme/widgets/pull/42case are all asserted by name.Gates
npx vitest run test/unit/results-payload.test.ts— 17/17 passed.npx vitest run --coverage test/unit/results-payload.test.ts—packages/loopover-engine/src/results-payload.ts: 100% lines (19/19), 100% branches (31/31), 100% functions (6/6) (lcovLF:19 LH:19 BRF:31 BRH:31 FNF:6 FNH:6), so every changed line/branch of the patch is covered, including both arms of every new conditional (valid/invalid repo, valid/invalidprNumber, the four-way cross, and finite/non-finite/negative/fractional counts).npm run build --workspace @loopover/engine— clean.npm run typecheck:root(tsc --noEmit) — clean.git diff --check— clean.