Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 4016f41

Browse files
committed
fix: shell meta-interpretation and API key exposure
- Change ExecaTerminalProcess shell option from true to false so execa parses the command without invoking a shell, preventing shell metacharacter interpretation. - Redact secret API keys in getStateToPostToWebview() before serializing state to the webview renderer. - Preserve existing secret values in ProviderSettingsManager.saveConfig() when the webview round-trips the '__ROO_REDACTED__' sentinel.
1 parent 3bcd746 commit 4016f41

4 files changed

Lines changed: 28 additions & 6 deletions

File tree

src/core/config/ProviderSettingsManager.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,19 @@ export class ProviderSettingsManager {
368368
typeof config.apiProvider === "string" && isRetiredProvider(config.apiProvider)
369369
? providerSettingsWithIdSchema.passthrough().parse(config)
370370
: discriminatedProviderSettingsWithIdSchema.parse(config)
371+
372+
// Preserve existing secret values when the webview sends back the
373+
// "__ROO_REDACTED__" sentinel (secrets are redacted in the webview state
374+
// to prevent API key exposure).
375+
const existingConfig = providerProfiles.apiConfigs[name]
376+
if (existingConfig) {
377+
for (const key of Object.keys(filteredConfig)) {
378+
if (isSecretStateKey(key) && (filteredConfig as any)[key] === "__ROO_REDACTED__") {
379+
;(filteredConfig as any)[key] = (existingConfig as any)[key]
380+
}
381+
}
382+
}
383+
371384
providerProfiles.apiConfigs[name] = { ...filteredConfig, id }
372385
await this.store(providerProfiles)
373386
return id

src/core/webview/ClineProvider.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import { Task } from "../task/Task"
9797

9898
import { webviewMessageHandler } from "./webviewMessageHandler"
9999
import type { ClineMessage, TodoItem } from "@roo-code/types"
100+
import { isSecretStateKey } from "@roo-code/types"
100101
import { readApiMessages, saveApiMessages, saveTaskMessages, TaskHistoryStore } from "../task-persistence"
101102
import { readTaskMessages } from "../task-persistence/taskMessages"
102103
import { getNonce } from "./getNonce"
@@ -2468,9 +2469,17 @@ export class ClineProvider
24682469
)
24692470
}
24702471

2472+
// Redact secrets before sending to webview to prevent API key exposure.
2473+
const redactedApiConfiguration = { ...providerSettings }
2474+
for (const key of Object.keys(redactedApiConfiguration)) {
2475+
if (isSecretStateKey(key) && typeof (redactedApiConfiguration as any)[key] === "string") {
2476+
;(redactedApiConfiguration as any)[key] = "__ROO_REDACTED__"
2477+
}
2478+
}
2479+
24712480
// Return the same structure as before.
24722481
return {
2473-
apiConfiguration: providerSettings,
2482+
apiConfiguration: redactedApiConfiguration,
24742483
lastShownAnnouncementId: stateValues.lastShownAnnouncementId,
24752484
customInstructions: stateValues.customInstructions,
24762485
apiModelId: stateValues.apiModelId,
@@ -2571,7 +2580,7 @@ export class ClineProvider
25712580
maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0,
25722581
taskSyncEnabled,
25732582
imageGenerationProvider: stateValues.imageGenerationProvider,
2574-
openRouterImageApiKey: stateValues.openRouterImageApiKey,
2583+
openRouterImageApiKey: stateValues.openRouterImageApiKey ? "__ROO_REDACTED__" : undefined,
25752584
openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel,
25762585
}
25772586
}

src/integrations/terminal/ExecaTerminalProcess.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
4040
this.isHot = true
4141

4242
this.subprocess = execa({
43-
shell: BaseTerminal.getExecaShellPath() || true,
43+
shell: BaseTerminal.getExecaShellPath() || false,
4444
cwd: this.terminal.getCurrentWorkingDirectory(),
4545
all: true,
4646
// Ignore stdin to ensure non-interactive mode and prevent hanging

src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ describe("ExecaTerminalProcess", () => {
6363
const execaMock = vitest.mocked(execa)
6464
expect(execaMock).toHaveBeenCalledWith(
6565
expect.objectContaining({
66-
shell: true,
66+
shell: false,
6767
cwd: "/test/cwd",
6868
all: true,
6969
env: expect.objectContaining({
@@ -105,13 +105,13 @@ describe("ExecaTerminalProcess", () => {
105105
)
106106
})
107107

108-
it("should fall back to shell=true when execaShellPath is undefined", async () => {
108+
it("should fall back to shell=false when execaShellPath is undefined", async () => {
109109
BaseTerminal.setExecaShellPath(undefined)
110110
await terminalProcess.run("echo test")
111111
const execaMock = vitest.mocked(execa)
112112
expect(execaMock).toHaveBeenCalledWith(
113113
expect.objectContaining({
114-
shell: true,
114+
shell: false,
115115
}),
116116
)
117117
})

0 commit comments

Comments
 (0)