From 72f0a9ffa16b905d35071afa2a84476a60fcb360 Mon Sep 17 00:00:00 2001 From: manoj-k04 Date: Thu, 30 Apr 2026 19:01:35 +0530 Subject: [PATCH 1/4] fix(percy): replace runPercyScan token interpolation with placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Percy token fetched from api.browserstack.com was being interpolated verbatim into runPercyScan tool output (and the percy-web / percy-automate SDK handlers), exposing a server-side credential across a trust boundary into MCP client / AI-assistant context (HackerOne #3576387, CVSS 6.5). PMAA-100 (PR #285) mitigated this by disabling the runPercyScan tool registration. This change removes the underlying leak so the tool can be safely re-enabled. Output sanitization - run-percy-scan.ts: generatePercyTokenInstructions returns placeholder-only text and points users to the Percy dashboard. The token is still fetched (preserves the existing project-validation side effect of fetchPercyToken) but is intentionally not echoed. - percy-web/handler.ts and percy-automate/handler.ts: same treatment in runPercyWeb and runPercyAutomateOnly. Parameter signatures are kept for upstream compatibility; SECURITY comments document why the token must not be interpolated. Tool re-enabled - percy-sdk.ts: restore the runPercyScan import, schema import, and tool registration block that PMAA-100 commented out. - percySdk.test.ts: restore the matching import, mock, name assertion, and "runPercyScan - SUCCESS" test. Regression coverage - runPercyScan.test.ts: assertions inverted — output must NOT contain the fetched token; the placeholder must be present. - percyTokenLeak.test.ts (new): pin the no-leak contract on runPercyWeb and runPercyAutomateOnly directly so any future regression fails CI. Refs: HackerOne #3576387, PMAA-100, PMAA-103 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/percy-sdk.ts | 35 ++++++------ src/tools/run-percy-scan.ts | 23 +++++--- src/tools/sdk-utils/percy-automate/handler.ts | 11 +++- src/tools/sdk-utils/percy-web/handler.ts | 16 ++++-- tests/tools/percySdk.test.ts | 30 +++++------ tests/tools/percyTokenLeak.test.ts | 54 +++++++++++++++++++ tests/tools/runPercyScan.test.ts | 30 +++++++---- 7 files changed, 140 insertions(+), 59 deletions(-) create mode 100644 tests/tools/percyTokenLeak.test.ts diff --git a/src/tools/percy-sdk.ts b/src/tools/percy-sdk.ts index f116077e..dac7626a 100644 --- a/src/tools/percy-sdk.ts +++ b/src/tools/percy-sdk.ts @@ -2,8 +2,7 @@ import { trackMCP } from "../index.js"; import { BrowserStackConfig } from "../lib/types.js"; import { fetchPercyChanges } from "./review-agent.js"; import { addListTestFiles } from "./list-test-files.js"; -// PMAA-100: runPercyScan tool temporarily disabled due to plaintext token leak in tool output. -// import { runPercyScan } from "./run-percy-scan.js"; +import { runPercyScan } from "./run-percy-scan.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { SetUpPercyParamsShape } from "./sdk-utils/common/schema.js"; import { updateTestsWithPercyCommands } from "./add-percy-snapshots.js"; @@ -22,8 +21,7 @@ import { import { UpdateTestFileWithInstructionsParams } from "./percy-snapshot-utils/constants.js"; import { - // PMAA-100: kept commented so the registration block below is easy to restore once the proper fix lands. - // RunPercyScanParamsShape, + RunPercyScanParamsShape, FetchPercyChangesParamsShape, ManagePercyBuildApprovalParamsShape, } from "./sdk-utils/common/schema.js"; @@ -135,22 +133,19 @@ export function registerPercyTools( }, ); - // PMAA-100: runPercyScan temporarily disabled — fetched Percy token was being - // returned in plaintext within tool output (see HackerOne #3576387). Re-enable - // once the token is replaced with a placeholder in run-percy-scan.ts. - // tools.runPercyScan = server.tool( - // "runPercyScan", - // "Run a Percy visual test scan. Example prompts : Run this Percy build/scan. Never run percy scan/build without this tool", - // RunPercyScanParamsShape, - // async (args) => { - // try { - // trackMCP("runPercyScan", server.server.getClientVersion()!, config); - // return runPercyScan(args, config); - // } catch (error) { - // return handleMCPError("runPercyScan", server, config, error); - // } - // }, - // ); + tools.runPercyScan = server.tool( + "runPercyScan", + "Run a Percy visual test scan. Example prompts : Run this Percy build/scan. Never run percy scan/build without this tool", + RunPercyScanParamsShape, + async (args) => { + try { + trackMCP("runPercyScan", server.server.getClientVersion()!, config); + return runPercyScan(args, config); + } catch (error) { + return handleMCPError("runPercyScan", server, config, error); + } + }, + ); tools.fetchPercyChanges = server.tool( "fetchPercyChanges", diff --git a/src/tools/run-percy-scan.ts b/src/tools/run-percy-scan.ts index 6302dc1a..f51a2ef3 100644 --- a/src/tools/run-percy-scan.ts +++ b/src/tools/run-percy-scan.ts @@ -31,9 +31,13 @@ export async function runPercyScan( const hasUpdatedFiles = checkForUpdatedFiles(stored, projectName); const updatedFiles = hasUpdatedFiles ? getUpdatedFiles(stored) : []; + // Token is fetched to validate the project but is intentionally NOT echoed + // back in tool output — see generatePercyTokenInstructions() for why. + void percyToken; + // Build steps array with conditional spread const steps = [ - generatePercyTokenInstructions(percyToken), + generatePercyTokenInstructions(), ...(hasUpdatedFiles ? generateUpdatedFilesSteps(stored, updatedFiles) : []), ...(instruction && !hasUpdatedFiles ? generateInstructionSteps(instruction) @@ -55,12 +59,17 @@ export async function runPercyScan( }; } -function generatePercyTokenInstructions(percyToken: string): string { - return `Set the environment variable for your project: - -export PERCY_TOKEN="${percyToken}" - -(For Windows: use 'setx PERCY_TOKEN "${percyToken}"' or 'set PERCY_TOKEN=${percyToken}' as appropriate.)`; +// SECURITY: Never interpolate the actual Percy token into this output. +// The token is fetched from a privileged BrowserStack backend and emitting it +// in tool output exposes it across a trust boundary (HackerOne #3576387). +// Always return placeholder-only instructions and direct the user to the +// Percy dashboard to retrieve their own token. +function generatePercyTokenInstructions(): string { + return `Set the PERCY_TOKEN environment variable for your project. Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token), then export it locally — do not paste it into chat or commit it: + + - macOS/Linux: export PERCY_TOKEN="" + - Windows (PS): $env:PERCY_TOKEN="" + - Windows (CMD): set PERCY_TOKEN=`; } const toAbs = (p: string): string | undefined => diff --git a/src/tools/sdk-utils/percy-automate/handler.ts b/src/tools/sdk-utils/percy-automate/handler.ts index f5e43135..2c428a43 100644 --- a/src/tools/sdk-utils/percy-automate/handler.ts +++ b/src/tools/sdk-utils/percy-automate/handler.ts @@ -9,6 +9,12 @@ export function runPercyAutomateOnly( ): RunTestsInstructionResult { const steps: RunTestsStep[] = []; + // SECURITY: percyToken is intentionally NOT interpolated into any returned + // step content. The token is fetched from a privileged BrowserStack backend + // and echoing it in tool output would expose it across a trust boundary + // (HackerOne #3576387). The parameter is retained for upstream compatibility. + void percyToken; + // Assume configuration is supported due to guardrails at orchestration layer const languageConfig = SUPPORTED_CONFIGURATIONS[input.detectedLanguage as SDKSupportedLanguage]; @@ -22,11 +28,12 @@ export function runPercyAutomateOnly( ? testingFrameworkConfig.instructions : ""; - // Prepend a step to set the Percy token in the environment + // Prepend a step to set the Percy token in the environment. + // Placeholder-only — never emit the real token here. steps.push({ type: "instruction", title: "Set Percy Token in Environment", - content: `Here is percy token if required {${percyToken}}`, + content: `Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token), then set PERCY_TOKEN in your environment (e.g. export PERCY_TOKEN=""). Do not paste the token into chat or commit it.`, }); steps.push({ diff --git a/src/tools/sdk-utils/percy-web/handler.ts b/src/tools/sdk-utils/percy-web/handler.ts index 681fa614..8d491b2a 100644 --- a/src/tools/sdk-utils/percy-web/handler.ts +++ b/src/tools/sdk-utils/percy-web/handler.ts @@ -16,6 +16,12 @@ export function runPercyWeb( ): RunTestsInstructionResult { const steps: RunTestsStep[] = []; + // SECURITY: percyToken is intentionally NOT interpolated into any returned + // step content. The token is fetched from a privileged BrowserStack backend + // and echoing it in tool output would expose it across a trust boundary + // (HackerOne #3576387). The parameter is retained for upstream compatibility. + void percyToken; + // Assume configuration is supported due to guardrails at orchestration layer const languageConfig = SUPPORTED_CONFIGURATIONS[input.detectedLanguage as SDKSupportedLanguage]; @@ -28,13 +34,15 @@ export function runPercyWeb( const instructions = frameworkConfig.instructions; percyWebSetupInstructions = frameworkConfig.snapshotInstruction; - // Prepend a step to set the Percy token in the environment + // Prepend a step to set the Percy token in the environment. + // Placeholder-only — never emit the real token here. steps.push({ type: "instruction", title: "Set Percy Token in Environment", - content: `Set the environment variable for your project: - export PERCY_TOKEN="${percyToken}" - (For Windows: use 'setx PERCY_TOKEN "${percyToken}"' or 'set PERCY_TOKEN=${percyToken}' as appropriate.)`, + content: `Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token), then set it locally: + macOS/Linux: export PERCY_TOKEN="" + Windows (PS): $env:PERCY_TOKEN="" + Windows (CMD): set PERCY_TOKEN=`, }); steps.push({ diff --git a/tests/tools/percySdk.test.ts b/tests/tools/percySdk.test.ts index 0916d482..afeca3c0 100644 --- a/tests/tools/percySdk.test.ts +++ b/tests/tools/percySdk.test.ts @@ -2,8 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { registerPercyTools } from "../../src/tools/percy-sdk"; import { setUpPercyHandler, simulatePercyChangeHandler } from "../../src/tools/sdk-utils/handler"; import { updateTestsWithPercyCommands } from "../../src/tools/add-percy-snapshots"; -// PMAA-100: runPercyScan registration disabled — restore import alongside the test. -// import { runPercyScan } from "../../src/tools/run-percy-scan"; +import { runPercyScan } from "../../src/tools/run-percy-scan"; import { fetchPercyChanges } from "../../src/tools/review-agent"; import { approveOrDeclinePercyBuild } from "../../src/tools/review-agent-utils/percy-approve-reject"; @@ -14,10 +13,9 @@ vi.mock("../../src/tools/sdk-utils/handler", () => ({ vi.mock("../../src/tools/add-percy-snapshots", () => ({ updateTestsWithPercyCommands: vi.fn(), })); -// PMAA-100: runPercyScan registration disabled — restore mock alongside the test. -// vi.mock("../../src/tools/run-percy-scan", () => ({ -// runPercyScan: vi.fn(), -// })); +vi.mock("../../src/tools/run-percy-scan", () => ({ + runPercyScan: vi.fn(), +})); vi.mock("../../src/tools/review-agent", () => ({ fetchPercyChanges: vi.fn(), })); @@ -66,8 +64,7 @@ describe("Percy SDK Tools", () => { expect(toolNames).toContain("expandPercyVisualTesting"); expect(toolNames).toContain("addPercySnapshotCommands"); expect(toolNames).toContain("listTestFiles"); - // PMAA-100: runPercyScan registration disabled — restore once the token leak is fixed. - // expect(toolNames).toContain("runPercyScan"); + expect(toolNames).toContain("runPercyScan"); expect(toolNames).toContain("fetchPercyChanges"); expect(toolNames).toContain("managePercyBuildApproval"); }); @@ -121,15 +118,14 @@ describe("Percy SDK Tools", () => { expect(result.content[0].text).toContain("Commands added"); }); - // PMAA-100: runPercyScan registration disabled — restore once the token leak is fixed. - // it("runPercyScan - SUCCESS", async () => { - // (runPercyScan as any).mockResolvedValue({ - // content: [{ type: "text", text: "Percy scan started" }], - // }); - // - // const result = await handlers["runPercyScan"]({ projectName: "test" }); - // expect(result.content[0].text).toContain("Percy scan"); - // }); + it("runPercyScan - SUCCESS", async () => { + (runPercyScan as any).mockResolvedValue({ + content: [{ type: "text", text: "Percy scan started" }], + }); + + const result = await handlers["runPercyScan"]({ projectName: "test" }); + expect(result.content[0].text).toContain("Percy scan"); + }); it("fetchPercyChanges - SUCCESS", async () => { (fetchPercyChanges as any).mockResolvedValue({ diff --git a/tests/tools/percyTokenLeak.test.ts b/tests/tools/percyTokenLeak.test.ts new file mode 100644 index 00000000..116e9cbc --- /dev/null +++ b/tests/tools/percyTokenLeak.test.ts @@ -0,0 +1,54 @@ +// Regression tests for HackerOne #3576387: the Percy token is fetched from a +// privileged BrowserStack backend and must never appear in any tool output. +// These tests pin the contract for the two SDK handlers so the leak cannot +// silently return. +import { describe, it, expect } from "vitest"; +import { runPercyWeb } from "../../src/tools/sdk-utils/percy-web/handler"; +import { runPercyAutomateOnly } from "../../src/tools/sdk-utils/percy-automate/handler"; +import { PercyIntegrationTypeEnum } from "../../src/tools/sdk-utils/common/types"; + +const SECRET = "percy-secret-token-DO-NOT-LEAK-abc123"; + +const collectStepText = (steps: any[] | undefined): string => + (steps ?? []).map((s) => s?.content ?? "").join("\n"); + +describe("Percy SDK handlers — no token leakage", () => { + it("runPercyWeb does not echo the Percy token in any step", () => { + const result = runPercyWeb( + { + projectName: "demo", + detectedLanguage: "nodejs", + detectedBrowserAutomationFramework: "playwright", + detectedTestingFramework: "jest", + integrationType: PercyIntegrationTypeEnum.WEB, + folderPaths: [], + filePaths: [], + } as any, + SECRET, + ); + + const text = collectStepText(result.steps); + expect(text).not.toContain(SECRET); + expect(text).toContain("PERCY_TOKEN"); + expect(text).toContain(""); + }); + + it("runPercyAutomateOnly does not echo the Percy token in any step", () => { + const result = runPercyAutomateOnly( + { + projectName: "demo", + detectedLanguage: "nodejs", + detectedBrowserAutomationFramework: "selenium", + detectedTestingFramework: "jest", + integrationType: PercyIntegrationTypeEnum.AUTOMATE, + folderPaths: [], + filePaths: [], + } as any, + SECRET, + ); + + const text = collectStepText(result.steps); + expect(text).not.toContain(SECRET); + expect(text).toContain("PERCY_TOKEN"); + }); +}); diff --git a/tests/tools/runPercyScan.test.ts b/tests/tools/runPercyScan.test.ts index b7620515..f64bced0 100644 --- a/tests/tools/runPercyScan.test.ts +++ b/tests/tools/runPercyScan.test.ts @@ -29,8 +29,11 @@ const mockConfig = { describe("runPercyScan", () => { beforeEach(() => vi.clearAllMocks()); - it("SUCCESS: returns Percy token and run instructions", async () => { - (fetchPercyToken as Mock).mockResolvedValue("percy-token-abc"); + // SECURITY (HackerOne #3576387): the Percy token is fetched from a privileged + // BrowserStack backend and must never appear in tool output text. + it("SECURITY: never echoes the fetched Percy token in output", async () => { + const SECRET = "percy-secret-token-DO-NOT-LEAK"; + (fetchPercyToken as Mock).mockResolvedValue(SECRET); (storedPercyResults.get as Mock).mockReturnValue(null); const result = await runPercyScan( @@ -41,12 +44,17 @@ describe("runPercyScan", () => { mockConfig, ); - expect(result.content[0].text).toContain("percy-token-abc"); - expect(result.content[0].text).toContain("PERCY_TOKEN"); + const text = result.content[0].text as string; + expect(text).not.toContain(SECRET); + // Output should still mention PERCY_TOKEN as the env var name and use a + // placeholder so users know what to set. + expect(text).toContain("PERCY_TOKEN"); + expect(text).toContain(""); }); it("SUCCESS: includes updated file instructions when available", async () => { - (fetchPercyToken as Mock).mockResolvedValue("percy-token-abc"); + const SECRET = "percy-secret-token-DO-NOT-LEAK"; + (fetchPercyToken as Mock).mockResolvedValue(SECRET); (storedPercyResults.get as Mock).mockReturnValue({ projectName: "my-project", testFiles: { "/tests/login.test.js": true }, @@ -62,11 +70,14 @@ describe("runPercyScan", () => { mockConfig, ); - expect(result.content[0].text).toContain("percy-token-abc"); + const text = result.content[0].text as string; + expect(text).not.toContain(SECRET); + expect(text).toContain("Updated files to run"); }); it("SUCCESS: includes custom instruction steps", async () => { - (fetchPercyToken as Mock).mockResolvedValue("percy-token-abc"); + const SECRET = "percy-secret-token-DO-NOT-LEAK"; + (fetchPercyToken as Mock).mockResolvedValue(SECRET); (storedPercyResults.get as Mock).mockReturnValue(null); const result = await runPercyScan( @@ -78,8 +89,9 @@ describe("runPercyScan", () => { mockConfig, ); - expect(result.content[0].text).toContain("percy-token-abc"); - expect(result.content[0].text).toContain("npx percy exec"); + const text = result.content[0].text as string; + expect(text).not.toContain(SECRET); + expect(text).toContain("npx percy exec"); }); it("FAIL: throws when Percy token fetch fails", async () => { From 3342751d3926da383aef350d604ed2965c9fedb7 Mon Sep 17 00:00:00 2001 From: manoj-k04 Date: Tue, 5 May 2026 13:00:01 +0530 Subject: [PATCH 2/4] chore(percy): remove redundant comments from PMAA-103 cleanup Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/run-percy-scan.ts | 7 ------- src/tools/sdk-utils/percy-automate/handler.ts | 7 +------ src/tools/sdk-utils/percy-web/handler.ts | 7 +------ tests/tools/percyTokenLeak.test.ts | 4 ---- tests/tools/runPercyScan.test.ts | 4 ---- 5 files changed, 2 insertions(+), 27 deletions(-) diff --git a/src/tools/run-percy-scan.ts b/src/tools/run-percy-scan.ts index f51a2ef3..d6f4d777 100644 --- a/src/tools/run-percy-scan.ts +++ b/src/tools/run-percy-scan.ts @@ -31,8 +31,6 @@ export async function runPercyScan( const hasUpdatedFiles = checkForUpdatedFiles(stored, projectName); const updatedFiles = hasUpdatedFiles ? getUpdatedFiles(stored) : []; - // Token is fetched to validate the project but is intentionally NOT echoed - // back in tool output — see generatePercyTokenInstructions() for why. void percyToken; // Build steps array with conditional spread @@ -59,11 +57,6 @@ export async function runPercyScan( }; } -// SECURITY: Never interpolate the actual Percy token into this output. -// The token is fetched from a privileged BrowserStack backend and emitting it -// in tool output exposes it across a trust boundary (HackerOne #3576387). -// Always return placeholder-only instructions and direct the user to the -// Percy dashboard to retrieve their own token. function generatePercyTokenInstructions(): string { return `Set the PERCY_TOKEN environment variable for your project. Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token), then export it locally — do not paste it into chat or commit it: diff --git a/src/tools/sdk-utils/percy-automate/handler.ts b/src/tools/sdk-utils/percy-automate/handler.ts index 2c428a43..8d5e75d7 100644 --- a/src/tools/sdk-utils/percy-automate/handler.ts +++ b/src/tools/sdk-utils/percy-automate/handler.ts @@ -9,10 +9,6 @@ export function runPercyAutomateOnly( ): RunTestsInstructionResult { const steps: RunTestsStep[] = []; - // SECURITY: percyToken is intentionally NOT interpolated into any returned - // step content. The token is fetched from a privileged BrowserStack backend - // and echoing it in tool output would expose it across a trust boundary - // (HackerOne #3576387). The parameter is retained for upstream compatibility. void percyToken; // Assume configuration is supported due to guardrails at orchestration layer @@ -28,8 +24,7 @@ export function runPercyAutomateOnly( ? testingFrameworkConfig.instructions : ""; - // Prepend a step to set the Percy token in the environment. - // Placeholder-only — never emit the real token here. + // Prepend a step to set the Percy token in the environment steps.push({ type: "instruction", title: "Set Percy Token in Environment", diff --git a/src/tools/sdk-utils/percy-web/handler.ts b/src/tools/sdk-utils/percy-web/handler.ts index 8d491b2a..5d39e4d4 100644 --- a/src/tools/sdk-utils/percy-web/handler.ts +++ b/src/tools/sdk-utils/percy-web/handler.ts @@ -16,10 +16,6 @@ export function runPercyWeb( ): RunTestsInstructionResult { const steps: RunTestsStep[] = []; - // SECURITY: percyToken is intentionally NOT interpolated into any returned - // step content. The token is fetched from a privileged BrowserStack backend - // and echoing it in tool output would expose it across a trust boundary - // (HackerOne #3576387). The parameter is retained for upstream compatibility. void percyToken; // Assume configuration is supported due to guardrails at orchestration layer @@ -34,8 +30,7 @@ export function runPercyWeb( const instructions = frameworkConfig.instructions; percyWebSetupInstructions = frameworkConfig.snapshotInstruction; - // Prepend a step to set the Percy token in the environment. - // Placeholder-only — never emit the real token here. + // Prepend a step to set the Percy token in the environment steps.push({ type: "instruction", title: "Set Percy Token in Environment", diff --git a/tests/tools/percyTokenLeak.test.ts b/tests/tools/percyTokenLeak.test.ts index 116e9cbc..b98ee436 100644 --- a/tests/tools/percyTokenLeak.test.ts +++ b/tests/tools/percyTokenLeak.test.ts @@ -1,7 +1,3 @@ -// Regression tests for HackerOne #3576387: the Percy token is fetched from a -// privileged BrowserStack backend and must never appear in any tool output. -// These tests pin the contract for the two SDK handlers so the leak cannot -// silently return. import { describe, it, expect } from "vitest"; import { runPercyWeb } from "../../src/tools/sdk-utils/percy-web/handler"; import { runPercyAutomateOnly } from "../../src/tools/sdk-utils/percy-automate/handler"; diff --git a/tests/tools/runPercyScan.test.ts b/tests/tools/runPercyScan.test.ts index f64bced0..39416e2f 100644 --- a/tests/tools/runPercyScan.test.ts +++ b/tests/tools/runPercyScan.test.ts @@ -29,8 +29,6 @@ const mockConfig = { describe("runPercyScan", () => { beforeEach(() => vi.clearAllMocks()); - // SECURITY (HackerOne #3576387): the Percy token is fetched from a privileged - // BrowserStack backend and must never appear in tool output text. it("SECURITY: never echoes the fetched Percy token in output", async () => { const SECRET = "percy-secret-token-DO-NOT-LEAK"; (fetchPercyToken as Mock).mockResolvedValue(SECRET); @@ -46,8 +44,6 @@ describe("runPercyScan", () => { const text = result.content[0].text as string; expect(text).not.toContain(SECRET); - // Output should still mention PERCY_TOKEN as the env var name and use a - // placeholder so users know what to set. expect(text).toContain("PERCY_TOKEN"); expect(text).toContain(""); }); From 7bc75468a61c8cc677dbd06e7c8abb6449b8deb9 Mon Sep 17 00:00:00 2001 From: manoj-k04 Date: Tue, 5 May 2026 17:34:44 +0530 Subject: [PATCH 3/4] refactor(percy): drop unused fetchPercyToken calls in setup paths The token was fetched server-side then discarded (replaced with placeholder instructions). Removing the dead fetch in runPercyScan and setUpPercyHandler eliminates a wasted API call per setup, drops the void percyToken; smell from the SDK handlers, and removes the last code path that sent privileged credentials across the function boundary just to throw them away. fetchPercyToken stays for fetchPercyChanges, which still uses it for legitimate server-side calls. Setup instructions now point users at .env or shell export. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/run-percy-scan.ts | 18 +++---- src/tools/sdk-utils/handler.ts | 23 +-------- src/tools/sdk-utils/percy-automate/handler.ts | 5 +- src/tools/sdk-utils/percy-web/handler.ts | 9 ++-- tests/tools/percyTokenLeak.test.ts | 50 ------------------- tests/tools/runPercyScan.test.ts | 40 ++------------- 6 files changed, 17 insertions(+), 128 deletions(-) delete mode 100644 tests/tools/percyTokenLeak.test.ts diff --git a/src/tools/run-percy-scan.ts b/src/tools/run-percy-scan.ts index d6f4d777..9cd301e6 100644 --- a/src/tools/run-percy-scan.ts +++ b/src/tools/run-percy-scan.ts @@ -1,8 +1,6 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js"; import { BrowserStackConfig } from "../lib/types.js"; -import { getBrowserStackAuth } from "../lib/get-auth.js"; -import { fetchPercyToken } from "./sdk-utils/percy-web/fetchPercyToken.js"; import { storedPercyResults } from "../lib/inmemory-store.js"; import { getFrameworkTestCommand, @@ -16,13 +14,9 @@ export async function runPercyScan( integrationType: PercyIntegrationTypeEnum; instruction?: string; }, - config: BrowserStackConfig, + _config: BrowserStackConfig, ): Promise { - const { projectName, integrationType, instruction } = args; - const authorization = getBrowserStackAuth(config); - const percyToken = await fetchPercyToken(projectName, authorization, { - type: integrationType, - }); + const { projectName, instruction } = args; // Check if we have stored data and project matches const stored = storedPercyResults.get(); @@ -31,8 +25,6 @@ export async function runPercyScan( const hasUpdatedFiles = checkForUpdatedFiles(stored, projectName); const updatedFiles = hasUpdatedFiles ? getUpdatedFiles(stored) : []; - void percyToken; - // Build steps array with conditional spread const steps = [ generatePercyTokenInstructions(), @@ -58,11 +50,13 @@ export async function runPercyScan( } function generatePercyTokenInstructions(): string { - return `Set the PERCY_TOKEN environment variable for your project. Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token), then export it locally — do not paste it into chat or commit it: + return `Set the PERCY_TOKEN environment variable for your project. Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token) and add it to your project's .env file (PERCY_TOKEN=) or export it in your shell: - macOS/Linux: export PERCY_TOKEN="" - Windows (PS): $env:PERCY_TOKEN="" - - Windows (CMD): set PERCY_TOKEN=`; + - Windows (CMD): set PERCY_TOKEN= + +Do not paste the token into chat or commit it.`; } const toAbs = (p: string): string | undefined => diff --git a/src/tools/sdk-utils/handler.ts b/src/tools/sdk-utils/handler.ts index 5c339eaf..e04a5ed2 100644 --- a/src/tools/sdk-utils/handler.ts +++ b/src/tools/sdk-utils/handler.ts @@ -2,8 +2,6 @@ import { formatToolResult } from "./common/utils.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { PercyIntegrationTypeEnum } from "./common/types.js"; -import { getBrowserStackAuth } from "../../lib/get-auth.js"; -import { fetchPercyToken } from "./percy-web/fetchPercyToken.js"; import { runPercyWeb } from "./percy-web/handler.js"; import { runPercyAutomateOnly } from "./percy-automate/handler.js"; import { runBstackSDKOnly } from "./bstack/sdkHandler.js"; @@ -60,8 +58,6 @@ export async function setUpPercyHandler( testFiles: {}, }); - const authorization = getBrowserStackAuth(config); - const folderPaths = input.folderPaths || []; const filePaths = input.filePaths || []; @@ -86,14 +82,7 @@ export async function setUpPercyHandler( ); } - // Fetch the Percy token - const percyToken = await fetchPercyToken( - input.projectName, - authorization, - { type: PercyIntegrationTypeEnum.WEB }, - ); - - const result = runPercyWeb(percyInput, percyToken); + const result = runPercyWeb(percyInput); return await formatToolResult(result, "percy-web"); } else if (input.integrationType === PercyIntegrationTypeEnum.AUTOMATE) { // First try Percy with BrowserStack SDK @@ -142,15 +131,7 @@ export async function setUpPercyHandler( }; const sdkResult = await runBstackSDKOnly(sdkInput, config, true); // Percy Automate instructions - const percyToken = await fetchPercyToken( - input.projectName, - authorization, - { type: PercyIntegrationTypeEnum.AUTOMATE }, - ); - const percyAutomateResult = runPercyAutomateOnly( - percyInput, - percyToken, - ); + const percyAutomateResult = runPercyAutomateOnly(percyInput); // Combine steps: warning, SDK steps, Percy Automate steps const steps = [ diff --git a/src/tools/sdk-utils/percy-automate/handler.ts b/src/tools/sdk-utils/percy-automate/handler.ts index 8d5e75d7..e3d79543 100644 --- a/src/tools/sdk-utils/percy-automate/handler.ts +++ b/src/tools/sdk-utils/percy-automate/handler.ts @@ -5,12 +5,9 @@ import { SDKSupportedLanguage } from "../common/types.js"; export function runPercyAutomateOnly( input: SetUpPercyInput, - percyToken: string, ): RunTestsInstructionResult { const steps: RunTestsStep[] = []; - void percyToken; - // Assume configuration is supported due to guardrails at orchestration layer const languageConfig = SUPPORTED_CONFIGURATIONS[input.detectedLanguage as SDKSupportedLanguage]; @@ -28,7 +25,7 @@ export function runPercyAutomateOnly( steps.push({ type: "instruction", title: "Set Percy Token in Environment", - content: `Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token), then set PERCY_TOKEN in your environment (e.g. export PERCY_TOKEN=""). Do not paste the token into chat or commit it.`, + content: `Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token) and add it to your project's .env file (PERCY_TOKEN=) or export it in your shell (e.g. export PERCY_TOKEN=""). Do not paste the token into chat or commit it.`, }); steps.push({ diff --git a/src/tools/sdk-utils/percy-web/handler.ts b/src/tools/sdk-utils/percy-web/handler.ts index 5d39e4d4..20d5fa02 100644 --- a/src/tools/sdk-utils/percy-web/handler.ts +++ b/src/tools/sdk-utils/percy-web/handler.ts @@ -12,12 +12,9 @@ export let percyWebSetupInstructions = ""; export function runPercyWeb( input: SetUpPercyInput, - percyToken: string, ): RunTestsInstructionResult { const steps: RunTestsStep[] = []; - void percyToken; - // Assume configuration is supported due to guardrails at orchestration layer const languageConfig = SUPPORTED_CONFIGURATIONS[input.detectedLanguage as SDKSupportedLanguage]; @@ -34,10 +31,12 @@ export function runPercyWeb( steps.push({ type: "instruction", title: "Set Percy Token in Environment", - content: `Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token), then set it locally: + content: `Retrieve your project's token from the Percy dashboard (https://percy.io → Project Settings → Project Token) and add it to your project's .env file (PERCY_TOKEN=) or export it in your shell: macOS/Linux: export PERCY_TOKEN="" Windows (PS): $env:PERCY_TOKEN="" - Windows (CMD): set PERCY_TOKEN=`, + Windows (CMD): set PERCY_TOKEN= + +Do not paste the token into chat or commit it.`, }); steps.push({ diff --git a/tests/tools/percyTokenLeak.test.ts b/tests/tools/percyTokenLeak.test.ts deleted file mode 100644 index b98ee436..00000000 --- a/tests/tools/percyTokenLeak.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { runPercyWeb } from "../../src/tools/sdk-utils/percy-web/handler"; -import { runPercyAutomateOnly } from "../../src/tools/sdk-utils/percy-automate/handler"; -import { PercyIntegrationTypeEnum } from "../../src/tools/sdk-utils/common/types"; - -const SECRET = "percy-secret-token-DO-NOT-LEAK-abc123"; - -const collectStepText = (steps: any[] | undefined): string => - (steps ?? []).map((s) => s?.content ?? "").join("\n"); - -describe("Percy SDK handlers — no token leakage", () => { - it("runPercyWeb does not echo the Percy token in any step", () => { - const result = runPercyWeb( - { - projectName: "demo", - detectedLanguage: "nodejs", - detectedBrowserAutomationFramework: "playwright", - detectedTestingFramework: "jest", - integrationType: PercyIntegrationTypeEnum.WEB, - folderPaths: [], - filePaths: [], - } as any, - SECRET, - ); - - const text = collectStepText(result.steps); - expect(text).not.toContain(SECRET); - expect(text).toContain("PERCY_TOKEN"); - expect(text).toContain(""); - }); - - it("runPercyAutomateOnly does not echo the Percy token in any step", () => { - const result = runPercyAutomateOnly( - { - projectName: "demo", - detectedLanguage: "nodejs", - detectedBrowserAutomationFramework: "selenium", - detectedTestingFramework: "jest", - integrationType: PercyIntegrationTypeEnum.AUTOMATE, - folderPaths: [], - filePaths: [], - } as any, - SECRET, - ); - - const text = collectStepText(result.steps); - expect(text).not.toContain(SECRET); - expect(text).toContain("PERCY_TOKEN"); - }); -}); diff --git a/tests/tools/runPercyScan.test.ts b/tests/tools/runPercyScan.test.ts index 39416e2f..f8577bde 100644 --- a/tests/tools/runPercyScan.test.ts +++ b/tests/tools/runPercyScan.test.ts @@ -1,15 +1,8 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { runPercyScan } from "../../src/tools/run-percy-scan"; -import { fetchPercyToken } from "../../src/tools/sdk-utils/percy-web/fetchPercyToken"; import { storedPercyResults } from "../../src/lib/inmemory-store"; import { PercyIntegrationTypeEnum } from "../../src/tools/sdk-utils/common/types"; -vi.mock("../../src/lib/get-auth", () => ({ - getBrowserStackAuth: vi.fn().mockReturnValue("fake-user:fake-key"), -})); -vi.mock("../../src/tools/sdk-utils/percy-web/fetchPercyToken", () => ({ - fetchPercyToken: vi.fn(), -})); vi.mock("../../src/lib/inmemory-store", () => ({ storedPercyResults: { get: vi.fn(), set: vi.fn() }, })); @@ -29,9 +22,7 @@ const mockConfig = { describe("runPercyScan", () => { beforeEach(() => vi.clearAllMocks()); - it("SECURITY: never echoes the fetched Percy token in output", async () => { - const SECRET = "percy-secret-token-DO-NOT-LEAK"; - (fetchPercyToken as Mock).mockResolvedValue(SECRET); + it("renders PERCY_TOKEN setup instructions with placeholder", async () => { (storedPercyResults.get as Mock).mockReturnValue(null); const result = await runPercyScan( @@ -43,14 +34,12 @@ describe("runPercyScan", () => { ); const text = result.content[0].text as string; - expect(text).not.toContain(SECRET); expect(text).toContain("PERCY_TOKEN"); expect(text).toContain(""); + expect(text).toContain(".env"); }); - it("SUCCESS: includes updated file instructions when available", async () => { - const SECRET = "percy-secret-token-DO-NOT-LEAK"; - (fetchPercyToken as Mock).mockResolvedValue(SECRET); + it("includes updated file instructions when available", async () => { (storedPercyResults.get as Mock).mockReturnValue({ projectName: "my-project", testFiles: { "/tests/login.test.js": true }, @@ -67,13 +56,10 @@ describe("runPercyScan", () => { ); const text = result.content[0].text as string; - expect(text).not.toContain(SECRET); expect(text).toContain("Updated files to run"); }); - it("SUCCESS: includes custom instruction steps", async () => { - const SECRET = "percy-secret-token-DO-NOT-LEAK"; - (fetchPercyToken as Mock).mockResolvedValue(SECRET); + it("includes custom instruction steps", async () => { (storedPercyResults.get as Mock).mockReturnValue(null); const result = await runPercyScan( @@ -86,24 +72,6 @@ describe("runPercyScan", () => { ); const text = result.content[0].text as string; - expect(text).not.toContain(SECRET); expect(text).toContain("npx percy exec"); }); - - it("FAIL: throws when Percy token fetch fails", async () => { - (fetchPercyToken as Mock).mockRejectedValue( - new Error("Percy token not found"), - ); - (storedPercyResults.get as Mock).mockReturnValue(null); - - await expect( - runPercyScan( - { - projectName: "bad-project", - integrationType: PercyIntegrationTypeEnum.WEB, - }, - mockConfig, - ), - ).rejects.toThrow("Percy token not found"); - }); }); From 2b085ce09ae8663e8cd1faee39b4803b7f6b8ae5 Mon Sep 17 00:00:00 2001 From: manoj-k04 Date: Tue, 5 May 2026 17:58:45 +0530 Subject: [PATCH 4/4] fix(percy): drop runPercyScan config parameter config was only used to derive auth for the now-removed fetchPercyToken call. Drop the dead parameter from the signature and update the lone caller in percy-sdk.ts. Resolves the eslint no-unused-vars error introduced by the prior commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/percy-sdk.ts | 2 +- src/tools/run-percy-scan.ts | 14 +++------ src/tools/sdk-utils/percy-web/handler.ts | 4 +-- tests/tools/runPercyScan.test.ts | 40 ++++++++---------------- 4 files changed, 20 insertions(+), 40 deletions(-) diff --git a/src/tools/percy-sdk.ts b/src/tools/percy-sdk.ts index dac7626a..94f62f2f 100644 --- a/src/tools/percy-sdk.ts +++ b/src/tools/percy-sdk.ts @@ -140,7 +140,7 @@ export function registerPercyTools( async (args) => { try { trackMCP("runPercyScan", server.server.getClientVersion()!, config); - return runPercyScan(args, config); + return runPercyScan(args); } catch (error) { return handleMCPError("runPercyScan", server, config, error); } diff --git a/src/tools/run-percy-scan.ts b/src/tools/run-percy-scan.ts index 9cd301e6..28ba5906 100644 --- a/src/tools/run-percy-scan.ts +++ b/src/tools/run-percy-scan.ts @@ -1,6 +1,5 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { PercyIntegrationTypeEnum } from "./sdk-utils/common/types.js"; -import { BrowserStackConfig } from "../lib/types.js"; import { storedPercyResults } from "../lib/inmemory-store.js"; import { getFrameworkTestCommand, @@ -8,14 +7,11 @@ import { } from "./sdk-utils/percy-web/constants.js"; import path from "path"; -export async function runPercyScan( - args: { - projectName: string; - integrationType: PercyIntegrationTypeEnum; - instruction?: string; - }, - _config: BrowserStackConfig, -): Promise { +export async function runPercyScan(args: { + projectName: string; + integrationType: PercyIntegrationTypeEnum; + instruction?: string; +}): Promise { const { projectName, instruction } = args; // Check if we have stored data and project matches diff --git a/src/tools/sdk-utils/percy-web/handler.ts b/src/tools/sdk-utils/percy-web/handler.ts index 20d5fa02..ebf96320 100644 --- a/src/tools/sdk-utils/percy-web/handler.ts +++ b/src/tools/sdk-utils/percy-web/handler.ts @@ -10,9 +10,7 @@ import { export let percyWebSetupInstructions = ""; -export function runPercyWeb( - input: SetUpPercyInput, -): RunTestsInstructionResult { +export function runPercyWeb(input: SetUpPercyInput): RunTestsInstructionResult { const steps: RunTestsStep[] = []; // Assume configuration is supported due to guardrails at orchestration layer diff --git a/tests/tools/runPercyScan.test.ts b/tests/tools/runPercyScan.test.ts index f8577bde..c7988ea8 100644 --- a/tests/tools/runPercyScan.test.ts +++ b/tests/tools/runPercyScan.test.ts @@ -14,24 +14,16 @@ vi.mock("../../src/logger", () => ({ default: { error: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); -const mockConfig = { - "browserstack-username": "fake-user", - "browserstack-access-key": "fake-key", -}; - describe("runPercyScan", () => { beforeEach(() => vi.clearAllMocks()); it("renders PERCY_TOKEN setup instructions with placeholder", async () => { (storedPercyResults.get as Mock).mockReturnValue(null); - const result = await runPercyScan( - { - projectName: "my-project", - integrationType: PercyIntegrationTypeEnum.WEB, - }, - mockConfig, - ); + const result = await runPercyScan({ + projectName: "my-project", + integrationType: PercyIntegrationTypeEnum.WEB, + }); const text = result.content[0].text as string; expect(text).toContain("PERCY_TOKEN"); @@ -47,13 +39,10 @@ describe("runPercyScan", () => { detectedTestingFramework: "jest", }); - const result = await runPercyScan( - { - projectName: "my-project", - integrationType: PercyIntegrationTypeEnum.WEB, - }, - mockConfig, - ); + const result = await runPercyScan({ + projectName: "my-project", + integrationType: PercyIntegrationTypeEnum.WEB, + }); const text = result.content[0].text as string; expect(text).toContain("Updated files to run"); @@ -62,14 +51,11 @@ describe("runPercyScan", () => { it("includes custom instruction steps", async () => { (storedPercyResults.get as Mock).mockReturnValue(null); - const result = await runPercyScan( - { - projectName: "my-project", - integrationType: PercyIntegrationTypeEnum.WEB, - instruction: "npx percy exec -- npx playwright test", - }, - mockConfig, - ); + const result = await runPercyScan({ + projectName: "my-project", + integrationType: PercyIntegrationTypeEnum.WEB, + instruction: "npx percy exec -- npx playwright test", + }); const text = result.content[0].text as string; expect(text).toContain("npx percy exec");