Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/core/prompts/sections/__tests__/system-info.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ vi.mock("../../../../utils/shell", () => ({
}))

import { getSystemInfoSection } from "../system-info"
import { getShell } from "../../../../utils/shell"
import osName from "os-name"

const mockOsName = osName as unknown as ReturnType<typeof vi.fn>
Expand Down Expand Up @@ -101,4 +102,27 @@ describe("getSystemInfoSection", () => {
// sentinel string) breaks this exact join.
expect(result).toContain("default directory for all tool operations. When the user initially gives you a task")
})

describe("shell syntax", () => {
const cmdPath = "C:\\Windows\\System32\\cmd.exe"

it("states the dialect for cmd.exe when execute_command is available", () => {
vi.mocked(getShell).mockReturnValueOnce(cmdPath)

const result = getSystemInfoSection(mockCwd, policyFor())

expect(result).toContain(`Default Shell: ${cmdPath}
Shell Syntax: Commands run through Windows cmd.exe`)
})

it("omits the dialect without execute_command", () => {
vi.mocked(getShell).mockReturnValueOnce(cmdPath)

expect(getSystemInfoSection(mockCwd, policyFor(false))).not.toContain("Shell Syntax")
})

it("omits the dialect for POSIX shells", () => {
expect(getSystemInfoSection(mockCwd, policyFor())).not.toContain("Shell Syntax")
})
})
})
10 changes: 9 additions & 1 deletion src/core/prompts/sections/system-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import osName from "os-name"

import { getShell } from "../../../utils/shell"
import { describeShellDialect } from "../../../utils/shellDialect"

import type { EffectiveToolPolicy } from "../tools/effective-tool-policy"

Expand Down Expand Up @@ -33,12 +34,19 @@
? " New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory."
: ""

const shell = getShell()
const shellDialect = executeCommandAvailable ? describeShellDialect(shell) : undefined
const shellDialectLine = shellDialect
? `
Shell Syntax: ${shellDialect}`
: ""

Check warning on line 42 in src/core/prompts/sections/system-info.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/prompts/sections/system-info.ts:42: Survived StringLiteral mutant (replacement: "Stryker was here!"). See the job summary for the complete list and resolution guidance.

Check warning on line 42 in src/core/prompts/sections/system-info.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/prompts/sections/system-info.ts:42: Survived StringLiteral mutant (replacement: "Stryker was here!"). See the job summary for the complete list and resolution guidance.

Check warning on line 42 in src/core/prompts/sections/system-info.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/prompts/sections/system-info.ts:42: Survived StringLiteral mutant (replacement: "Stryker was here!"). See the job summary for the complete list and resolution guidance.

const details = `====

SYSTEM INFORMATION

Operating System: ${osInfo}
Default Shell: ${getShell()}
Default Shell: ${shell}${shellDialectLine}
Home Directory: ${os.homedir().toPosix()}
Current Workspace Directory: ${cwd.toPosix()}

Expand Down
39 changes: 28 additions & 11 deletions src/core/tools/ExecuteCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import { Package } from "../../shared/package"
import { t } from "../../i18n"
import { getTaskDirectoryPath } from "../../utils/storage"
import { getShell } from "../../utils/shell"
import { describeShellDialect, getShellName } from "../../utils/shellDialect"
import { BaseTool, ToolCallbacks } from "./BaseTool"

export { ShellIntegrationError } from "../../integrations/terminal/types"
Expand All @@ -55,20 +57,35 @@
timeout?: number | null
}

export function formatDcgBlockedMessage(reason?: string, ruleId?: string): string {
if (reason && ruleId) {
return t("tools:executeCommand.destructiveCommandGuard.blockedWithReasonAndRule", { reason, ruleId })
}
/**
* @param shellPath The shell the command will run in. DCG parses every command as POSIX,
* so for cmd.exe/PowerShell the message says so, letting the user tell "dangerous" from "unreadable".
*/
export function formatDcgBlockedMessage(reason?: string, ruleId?: string, shellPath?: string): string {
const message = (() => {
if (reason && ruleId) {
return t("tools:executeCommand.destructiveCommandGuard.blockedWithReasonAndRule", { reason, ruleId })

Check warning on line 67 in src/core/tools/ExecuteCommandTool.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/tools/ExecuteCommandTool.ts:67: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 67 in src/core/tools/ExecuteCommandTool.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/tools/ExecuteCommandTool.ts:67: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 67 in src/core/tools/ExecuteCommandTool.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/tools/ExecuteCommandTool.ts:67: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
}

if (reason) {
return t("tools:executeCommand.destructiveCommandGuard.blockedWithReason", { reason })
}
if (reason) {
return t("tools:executeCommand.destructiveCommandGuard.blockedWithReason", { reason })

Check warning on line 71 in src/core/tools/ExecuteCommandTool.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/tools/ExecuteCommandTool.ts:71: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 71 in src/core/tools/ExecuteCommandTool.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/tools/ExecuteCommandTool.ts:71: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 71 in src/core/tools/ExecuteCommandTool.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/tools/ExecuteCommandTool.ts:71: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
}

if (ruleId) {
return t("tools:executeCommand.destructiveCommandGuard.blockedWithRule", { ruleId })

Check warning on line 75 in src/core/tools/ExecuteCommandTool.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/tools/ExecuteCommandTool.ts:75: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 75 in src/core/tools/ExecuteCommandTool.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/tools/ExecuteCommandTool.ts:75: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 75 in src/core/tools/ExecuteCommandTool.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/tools/ExecuteCommandTool.ts:75: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
}

if (ruleId) {
return t("tools:executeCommand.destructiveCommandGuard.blockedWithRule", { ruleId })
return t("tools:executeCommand.destructiveCommandGuard.blocked")
})()

if (shellPath && describeShellDialect(shellPath)) {
const note = t("tools:executeCommand.destructiveCommandGuard.assumedPosixNote", {
shell: getShellName(shellPath),
})
return `${message} ${note}`
}

return t("tools:executeCommand.destructiveCommandGuard.blocked")
return message
}

export function resolveAgentTimeoutMs(timeoutSeconds: number | null | undefined): number {
Expand Down Expand Up @@ -182,7 +199,7 @@
// retryable tool_error via the outer catch — never as a policy denial.
dcgDecision = await runDcg(binaryPath, canonicalCommand, workingDirectory)
if (dcgDecision.decision === "deny") {
await task.say("error", formatDcgBlockedMessage(dcgDecision.reason, dcgDecision.ruleId))
await task.say("error", formatDcgBlockedMessage(dcgDecision.reason, dcgDecision.ruleId, getShell()))
}
}

Expand Down
38 changes: 38 additions & 0 deletions src/core/tools/__tests__/executeCommandTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { unescapeHtmlEntities } from "../../../utils/text-normalization"
import { Terminal } from "../../../integrations/terminal/Terminal"
import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry"
import type { RooTerminalCallbacks, RooTerminalProcess } from "../../../integrations/terminal/types"
import { t } from "../../../i18n"

// Mock dependencies
vitest.mock("execa", () => ({
Expand Down Expand Up @@ -61,6 +62,15 @@ vitest.mock("../../task/Task", async (importOriginal) => {
})
vitest.mock("../../prompts/responses")

// Tests run with empty translation resources, so `t` returns the key without its namespace.
// Keep that default, and let individual tests replace it with an interpolating fake.
const stripNamespace = (key: string) => key.replace(/^\w+:/, "")

vitest.mock("../../../i18n", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../i18n")>()
return { ...actual, t: vitest.fn((key: string) => key.replace(/^\w+:/, "")) }
})

const mockRunDcg = vitest.fn()
const mockEnsureDcgInstalled = vitest.fn()

Expand All @@ -71,6 +81,9 @@ vitest.mock("../../../services/destructive-command-guard", () => ({

// Import the module
import * as executeCommandModule from "../ExecuteCommandTool"

// The DCG block message names the executing shell; pin it so tests do not depend on the host.
vi.mock("../../../utils/shell", () => ({ getShell: vi.fn(() => "/bin/bash") }))
const { executeCommandTool } = executeCommandModule

// Spies installed on the auto-approval module are not restored between tests,
Expand All @@ -90,6 +103,7 @@ describe("executeCommandTool", () => {
beforeEach(() => {
// Reset mocks
vitest.clearAllMocks()
vitest.mocked(t).mockImplementation(stripNamespace)
vitest.useRealTimers()

// Spy on executeCommandInTerminal and mock its return value
Expand Down Expand Up @@ -316,6 +330,30 @@ describe("executeCommandTool", () => {
expect(executeCommandModule.formatDcgBlockedMessage(reason, ruleId)).toBe(expected)
})

it.each([
["C:\\Windows\\System32\\cmd.exe", "cmd.exe"],
["C:\\Program Files\\PowerShell\\7\\pwsh.exe", "pwsh.exe"],
["/bin/bash", undefined],
[undefined, undefined],
])("appends the POSIX-assumption note only for non-POSIX shell %s", (shellPath, expectedShell) => {
// Interpolate like the real translation so a dropped `shell` option fails the test.
vitest
.mocked(t)
.mockImplementation((key: string, options?: Record<string, unknown>) =>
options?.shell ? `${stripNamespace(key)}[shell=${String(options.shell)}]` : stripNamespace(key),
)

const message = executeCommandModule.formatDcgBlockedMessage(undefined, undefined, shellPath)

if (expectedShell) {
expect(message).toContain(
`executeCommand.destructiveCommandGuard.assumedPosixNote[shell=${expectedShell}]`,
)
} else {
expect(message).not.toContain("assumedPosixNote")
}
})

it("shows a DCG block message as an error before requesting explicit approval", async () => {
const provider = await mockCline.providerRef.deref()
provider.context = { globalStorageUri: { fsPath: "/test/storage" } }
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ca/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/de/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/en/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"blocked": "Destructive Command Guard blocked this command.",
"blockedWithReason": "Destructive Command Guard blocked this command. Message from DCG: {{reason}}",
"blockedWithRule": "Destructive Command Guard blocked this command. (Rule: {{ruleId}})",
"blockedWithReasonAndRule": "Destructive Command Guard blocked this command. Message from DCG: {{reason}} (Rule: {{ruleId}})"
"blockedWithReasonAndRule": "Destructive Command Guard blocked this command. Message from DCG: {{reason}} (Rule: {{ruleId}})",
"assumedPosixNote": "Note: DCG analyzes commands as POSIX shell syntax, but this command runs in {{shell}}, so a block may mean DCG could not read the command rather than that it is destructive."
}
},
"newTask": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/es/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/fr/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/hi/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/id/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/it/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/ja/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/ko/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/nl/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/pl/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/pt-BR/tools.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading