From b812030eb42fd54ef23a411be8959cdaaa53af00 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:49:50 -0700 Subject: [PATCH] fix(mcp): keep PR body draft taxonomy public-safe --- src/mcp/server.ts | 2 +- src/services/pr-body-draft.ts | 26 +++++++++---------- test/unit/mcp-output-schemas.test.ts | 39 ++++++++++++++++++++++++++++ test/unit/pr-body-draft.test.ts | 4 +-- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ec60cea129..92b4458c80 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2303,7 +2303,7 @@ export class GittensoryMcp { const draft = buildPublicPrBodyDraft(analysis); // Human-readable summary carries the rendered markdown body; structured draft is returned as JSON. return { - summary: `Public-safe PR body draft for ${analysis.repoFullName} (metadata only; private scoreability excluded).\n\n${draft.markdown}`, + summary: `Public-safe PR body draft for ${analysis.repoFullName} (metadata only; internal analysis context omitted).\n\n${draft.markdown}`, data: draft as unknown as Record, }; } diff --git a/src/services/pr-body-draft.ts b/src/services/pr-body-draft.ts index f96cf0dc04..cc55e1482e 100644 --- a/src/services/pr-body-draft.ts +++ b/src/services/pr-body-draft.ts @@ -7,10 +7,10 @@ import type { LocalBranchAnalysis } from "../signals/local-branch"; * Drafts a public-safe, copy/paste PR body from local branch metadata. * * The draft is built ONLY from already-public-safe slices of {@link LocalBranchAnalysis} - * (the prepared packet, base freshness, linked-issue and overlap metadata). Private - * scoreability, reward/risk, raw trust, and reviewability context are excluded by - * construction — their field names are listed in {@link EXCLUDED_PRIVATE_PR_BODY_FIELDS} - * — and every emitted line additionally passes through {@link sanitizePublicComment} and a + * (the prepared packet, base freshness, linked-issue and overlap metadata). Internal + * analysis context is excluded by construction — those categories are listed in + * {@link EXCLUDED_PRIVATE_PR_BODY_FIELDS} using public-safe labels — and every emitted + * line additionally passes through {@link sanitizePublicComment} and a * forbidden-language filter, so no private/financial language reaches GitHub. * * Input is metadata only; source contents are never read or uploaded. @@ -36,17 +36,17 @@ export type PrBodyDraftSource = Pick { } }); + + it("keeps draft PR body MCP text free of private scoring taxonomy", async () => { + const mcp = new GittensoryMcp(createTestEnv()) as unknown as { + analyzeLocalBranch: () => Promise; + draftPrBody(input: Record): Promise<{ summary: string; data: Record }>; + toolResult(payload: { summary: string; data: Record }): { content: Array<{ type: "text"; text: string }>; structuredContent: Record }; + }; + mcp.analyzeLocalBranch = async () => ({ + repoFullName: "octo/demo", + prPacket: { + titleSuggestion: "Fix cache refresh race", + bodySections: [{ heading: "Changed Paths", lines: ["- src/cache.ts (modified, +12/-3)"] }], + validationSummary: { + passed: 1, + failed: 0, + notRun: 0, + commands: [{ command: "npm run test:ci", status: "passed", summary: "all green" }], + }, + publicSafeWarnings: [], + }, + baseFreshness: { + status: "fresh", + changedFileCount: 1, + testFileCount: 0, + warnings: [], + recommendation: undefined, + }, + manifestGuidance: { present: false, publicNextSteps: [] }, + preflight: { linkedIssues: [42], collisions: [], reviewBurden: "low" }, + }); + + const payload = await mcp.draftPrBody({}); + const result = mcp.toolResult(payload); + const visibleText = result.content[0]?.text ?? ""; + expect(visibleText).not.toMatch(/private scoreability|score preview|scenario projections|risk signals|score-gate blockers|branch eligibility gate|ranked next actions/i); + expect(JSON.stringify(result.structuredContent)).not.toMatch(/private scoreability|score preview|risk signals|score-gate blockers|branch eligibility gate|ranked next actions/i); + expect(visibleText).toContain("internal analysis context omitted"); + }); + it("exposes an outputSchema on EVERY registered tool (#550)", async () => { const { client } = await connectTestClient(); const { tools } = await client.listTools(); diff --git a/test/unit/pr-body-draft.test.ts b/test/unit/pr-body-draft.test.ts index 2ae318df42..0ded117800 100644 --- a/test/unit/pr-body-draft.test.ts +++ b/test/unit/pr-body-draft.test.ts @@ -205,12 +205,12 @@ describe("buildPublicPrBodyDraft — source-upload guard", () => { expect(draft.markdown).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); }); - it("lists the private analysis fields it deliberately excludes", () => { + it("lists excluded internal analysis categories using public-safe labels", () => { const draft = buildPublicPrBodyDraft(source()); expect(draft.excludedPrivateFields).toEqual([...EXCLUDED_PRIVATE_PR_BODY_FIELDS]); // The exclusion list itself stays public-safe (no private/financial terms). expect(JSON.stringify(draft.excludedPrivateFields)).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); - expect(draft.excludedPrivateFields.join(" ")).toMatch(/score|risk|eligibility/i); + expect(draft.excludedPrivateFields.join(" ")).toMatch(/analysis|signals|readiness|actions/i); }); });