Skip to content

Commit 9971476

Browse files
authored
fix(api): blunt slop-risk / issue-slop REST + CLI to match the MCP tools (#6990) (#7052)
1 parent b8621fc commit 9971476

7 files changed

Lines changed: 45 additions & 30 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3577,7 +3577,7 @@ async function reviewPrCli(options) {
35773577
process.stdout.write(`Pre-PR review: ${payload.overallStatus}\n`);
35783578
for (const section of payload.sections) process.stdout.write(`- ${section.name}: ${section.status}\n`);
35793579
process.stdout.write(`Preflight: ${payload.preflight.status}\n`);
3580-
if (payload.slopRisk) process.stdout.write(`Slop risk: ${payload.slopRisk.slopRisk} (${payload.slopRisk.band})\n`);
3580+
if (payload.slopRisk) process.stdout.write(`Slop risk: ${payload.slopRisk.band}\n`);
35813581
else if (payload.slopRiskError) process.stdout.write(`Slop risk: unavailable (${payload.slopRiskError})\n`);
35823582
if (payload.prTextLint) process.stdout.write(`PR text lint: ${payload.prTextLint.verdict} (score ${payload.prTextLint.score})\n`);
35833583
else if (payload.prTextLintError) process.stdout.write(`PR text lint: unavailable (${payload.prTextLintError})\n`);
@@ -3764,9 +3764,9 @@ async function slopRiskCli(args) {
37643764
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
37653765
return;
37663766
}
3767-
// #6261: the whole payload is the API's, so the score line is sanitized alongside the findings -- leaving `band`
3768-
// raw would keep this exact command exploitable by the exact response the findings are being protected from.
3769-
process.stdout.write(`Slop risk: ${sanitizePlainTextTerminalOutput(payload.slopRisk)} (${sanitizePlainTextTerminalOutput(payload.band)})\n`);
3767+
// #6990: the route now returns band + findings only (no numeric score/rubric), matching the MCP tool's
3768+
// blunting; print the band alone so the CLI can't leak the exact score the REST surface no longer sends.
3769+
process.stdout.write(`Slop risk: ${sanitizePlainTextTerminalOutput(payload.band)}\n`);
37703770
for (const finding of payload.findings ?? [])
37713771
process.stdout.write(`- ${sanitizePlainTextTerminalOutput(finding.title)}: ${sanitizePlainTextTerminalOutput(finding.detail)}\n`);
37723772
}
@@ -3845,9 +3845,8 @@ async function issueSlopCli(args) {
38453845
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
38463846
return;
38473847
}
3848-
// #6261: same as slop-risk, and the sharper case of the two -- the body being assessed is routinely a THIRD
3849-
// party's issue text, so a hostile issue is the expected input here, not a hypothetical one.
3850-
process.stdout.write(`Issue slop risk: ${sanitizePlainTextTerminalOutput(payload.slopRisk)} (${sanitizePlainTextTerminalOutput(payload.band)})\n`);
3848+
// #6990: band + findings only, matching the route's blunting (no numeric score/rubric leaked through the CLI).
3849+
process.stdout.write(`Issue slop risk: ${sanitizePlainTextTerminalOutput(payload.band)}\n`);
38513850
for (const finding of payload.findings ?? [])
38523851
process.stdout.write(`- ${sanitizePlainTextTerminalOutput(finding.title)}: ${sanitizePlainTextTerminalOutput(finding.detail)}\n`);
38533852
}

src/api/routes.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,8 @@ import { buildReviewRiskExplanation } from "../signals/review-risk";
280280
import { buildNotificationFeed } from "../notifications/service";
281281
import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk";
282282
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
283-
import { buildIssueSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN } from "../signals/issue-slop";
284-
import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "../signals/slop";
283+
import { buildIssueSlopAssessment } from "../signals/issue-slop";
284+
import { buildSlopAssessment } from "../signals/slop";
285285
import { buildPredictedGateVerdict } from "../rules/predicted-gate";
286286
import { computeContributorCalibration } from "../review/predicted-gate-calibration-ledger";
287287
import { buildFocusManifestValidation } from "../services/focus-manifest-validation";
@@ -3471,7 +3471,10 @@ export function createApp() {
34713471
const body = await c.req.json().catch(() => null);
34723472
const parsed = slopRiskSchema.safeParse(body);
34733473
if (!parsed.success) return c.json({ error: "invalid_slop_risk_request", issues: parsed.error.issues }, 400);
3474-
return c.json({ ...buildSlopAssessment(parsed.data), rubric: SLOP_RUBRIC_MARKDOWN });
3474+
// #6990: return band + findings only — withhold the numeric score and rubric thresholds exactly as the
3475+
// loopover_check_slop_risk MCP tool blunts them, so the REST surface can't reverse-engineer the weights.
3476+
const assessment = buildSlopAssessment(parsed.data);
3477+
return c.json({ band: assessment.band, findings: assessment.findings });
34753478
});
34763479

34773480
// #6748: REST mirror of the loopover_check_improvement_potential MCP tool, bringing it to the same parity its
@@ -3592,7 +3595,9 @@ export function createApp() {
35923595
const body = await c.req.json().catch(() => null);
35933596
const parsed = issueSlopSchema.safeParse(body);
35943597
if (!parsed.success) return c.json({ error: "invalid_issue_slop_request", issues: parsed.error.issues }, 400);
3595-
return c.json({ ...buildIssueSlopAssessment(parsed.data), rubric: ISSUE_SLOP_RUBRIC_MARKDOWN });
3598+
// #6990: band + findings only — same blunting as the loopover_check_issue_slop MCP tool (no score/rubric).
3599+
const assessment = buildIssueSlopAssessment(parsed.data);
3600+
return c.json({ band: assessment.band, findings: assessment.findings });
35963601
});
35973602

35983603
app.post(OPPORTUNITIES_FIND_PATH, async (c) => {

test/integration/api.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,7 +1448,10 @@ describe("api routes", () => {
14481448
);
14491449
expect(slopRisk.status).toBe(200);
14501450
const slopRiskBody = await slopRisk.json();
1451-
expect(slopRiskBody).toMatchObject({ slopRisk: expect.any(Number), band: expect.stringMatching(/clean|low|elevated|high/), findings: expect.any(Array), rubric: expect.any(String) });
1451+
// #6990: blunted to band + findings only — the numeric score and rubric are withheld (as the MCP tool does).
1452+
expect(slopRiskBody).toMatchObject({ band: expect.stringMatching(/clean|low|elevated|high/), findings: expect.any(Array) });
1453+
expect(slopRiskBody).not.toHaveProperty("slopRisk");
1454+
expect(slopRiskBody).not.toHaveProperty("rubric");
14521455
expect(JSON.stringify(slopRiskBody)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
14531456

14541457
// Session identities (not just the API token) can reach it — it is allowlisted like the other local self-checks.
@@ -1469,7 +1472,11 @@ describe("api routes", () => {
14691472
env,
14701473
);
14711474
expect(issueSlop.status).toBe(200);
1472-
await expect(issueSlop.json()).resolves.toMatchObject({ slopRisk: expect.any(Number), band: expect.stringMatching(/clean|low|elevated|high/), rubric: expect.any(String) });
1475+
const issueSlopBody = await issueSlop.json();
1476+
// #6990: blunted the same way — band + findings only, no numeric score or rubric.
1477+
expect(issueSlopBody).toMatchObject({ band: expect.stringMatching(/clean|low|elevated|high/), findings: expect.any(Array) });
1478+
expect(issueSlopBody).not.toHaveProperty("slopRisk");
1479+
expect(issueSlopBody).not.toHaveProperty("rubric");
14731480

14741481
const invalidIssueSlop = await app.request("/v1/lint/issue-slop", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ title: 123 }) }, env);
14751482
expect(invalidIssueSlop.status).toBe(400);

test/unit/mcp-cli-issue-slop.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe("loopover-mcp CLI — issue-slop", () => {
3131
],
3232
e,
3333
);
34-
expect(plain).toMatch(/Issue slop risk: 0 \(clean\)/);
34+
expect(plain).toMatch(/Issue slop risk: clean/);
3535

3636
const json = JSON.parse(
3737
await runAsync(
@@ -45,8 +45,11 @@ describe("loopover-mcp CLI — issue-slop", () => {
4545
],
4646
e,
4747
),
48-
) as { slopRisk: number; band: string; findings: unknown[]; rubric: string };
49-
expect(json).toMatchObject({ slopRisk: 0, band: "clean", findings: [], rubric: expect.any(String) });
48+
) as { band: string; findings: unknown[] };
49+
// #6990: band + findings only — no numeric score or rubric.
50+
expect(json).toMatchObject({ band: "clean", findings: [] });
51+
expect(json).not.toHaveProperty("slopRisk");
52+
expect(json).not.toHaveProperty("rubric");
5053
expect(JSON.stringify(json)).not.toMatch(/wallet|hotkey|reward|trust score/i);
5154
});
5255

@@ -80,13 +83,13 @@ describe("loopover-mcp CLI — issue-slop", () => {
8083
it("surfaces elevated issue slop findings in plain output", async () => {
8184
const e = await env();
8285
const json = JSON.parse(await runAsync(["issue-slop", "--title", "Add retries", "--body", "", "--json"], e)) as {
83-
slopRisk: number;
8486
band: string;
8587
findings: Array<{ title: string }>;
8688
};
87-
expect(json).toMatchObject({ slopRisk: 30, band: "elevated" });
89+
expect(json).toMatchObject({ band: "elevated" });
90+
expect(json).not.toHaveProperty("slopRisk");
8891
const plain = await runAsync(["issue-slop", "--title", "Add retries", "--body", ""], e);
89-
expect(plain).toMatch(/Issue slop risk: 30 \(elevated\)/);
92+
expect(plain).toMatch(/Issue slop risk: elevated/);
9093
expect(plain).toMatch(/Issue has no description/);
9194
});
9295

test/unit/mcp-cli-review-pr.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe("loopover-mcp CLI — review-pr", () => {
9696
expect(plain).toMatch(/- preflight: pass/);
9797
expect(plain).toMatch(/- slop_risk: pass/);
9898
expect(plain).toMatch(/- pr_text_lint: pass/);
99-
expect(plain).toMatch(/Slop risk: 0 \(clean\)/);
99+
expect(plain).toMatch(/Slop risk: clean/);
100100
expect(plain).toMatch(/PR text lint: strong \(score 100\)/);
101101
});
102102

test/unit/mcp-cli-slop-risk.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe("loopover-mcp CLI — slop-risk", () => {
3333
],
3434
e,
3535
);
36-
expect(plain).toMatch(/Slop risk: 0 \(clean\)/);
36+
expect(plain).toMatch(/Slop risk: clean/);
3737

3838
const json = JSON.parse(
3939
await runAsync(
@@ -49,8 +49,11 @@ describe("loopover-mcp CLI — slop-risk", () => {
4949
],
5050
e,
5151
),
52-
) as { slopRisk: number; band: string; findings: unknown[]; rubric: string };
53-
expect(json).toMatchObject({ slopRisk: 0, band: "clean", findings: [], rubric: expect.any(String) });
52+
) as { band: string; findings: unknown[] };
53+
// #6990: blunted — band + findings only, no numeric score or rubric leaked through the REST/CLI surface.
54+
expect(json).toMatchObject({ band: "clean", findings: [] });
55+
expect(json).not.toHaveProperty("slopRisk");
56+
expect(json).not.toHaveProperty("rubric");
5457
expect(JSON.stringify(json)).not.toMatch(/wallet|hotkey|reward|trust score/i);
5558
});
5659

@@ -102,13 +105,13 @@ describe("loopover-mcp CLI — slop-risk", () => {
102105
it("surfaces elevated slop findings in plain output", async () => {
103106
const e = await env();
104107
const json = JSON.parse(await runAsync(["slop-risk", "--changed-file", "src/widget.ts:80:2", "--json"], e)) as {
105-
slopRisk: number;
106108
band: string;
107109
findings: Array<{ title: string }>;
108110
};
109-
expect(json).toMatchObject({ slopRisk: 45, band: "elevated" });
111+
expect(json).toMatchObject({ band: "elevated" });
112+
expect(json).not.toHaveProperty("slopRisk");
110113
const plain = await runAsync(["slop-risk", "--changed-file", "src/widget.ts:80:2"], e);
111-
expect(plain).toMatch(/Slop risk: 45 \(elevated\)/);
114+
expect(plain).toMatch(/Slop risk: elevated/);
112115
expect(plain).toMatch(/Empty PR description/);
113116
});
114117

test/unit/support/mcp-cli-harness.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,11 +1164,10 @@ export function slopRiskFixture(input: {
11641164
: elevated
11651165
? [{ code: "missing_test_evidence", title: "Missing test evidence", severity: "warning", detail: "Add or update tests for the changed behavior." }]
11661166
: [];
1167+
// #6990: the route returns band + findings only (numeric score + rubric withheld); the fixture mirrors that.
11671168
return {
1168-
slopRisk,
11691169
band: slopRisk <= 0 ? "clean" : slopRisk < 25 ? "low" : slopRisk < 60 ? "elevated" : "high",
11701170
findings,
1171-
rubric: "Fixture slop rubric.",
11721171
};
11731172
}
11741173

@@ -1231,10 +1230,9 @@ export function issueSlopFixture(input: { title?: string; body?: string } = {})
12311230
: titleOnly
12321231
? [{ code: "title_restatement", title: "Issue body only restates the title", severity: "warning", detail: "Add specific detail beyond the title." }]
12331232
: [];
1233+
// #6990: blunted like the route — band + findings only, no numeric score or rubric.
12341234
return {
1235-
slopRisk,
12361235
band: slopRisk <= 0 ? "clean" : slopRisk < 25 ? "low" : slopRisk < 60 ? "elevated" : "high",
12371236
findings,
1238-
rubric: "Fixture issue slop rubric.",
12391237
};
12401238
}

0 commit comments

Comments
 (0)