diff --git a/.changeset/calm-powershell-alerts.md b/.changeset/calm-powershell-alerts.md new file mode 100644 index 000000000000..ac702a7abc8c --- /dev/null +++ b/.changeset/calm-powershell-alerts.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Run Windows PowerShell tool commands without `-EncodedCommand` to reduce antivirus false positives. diff --git a/packages/opencode/src/kilocode/shell/shell.ts b/packages/opencode/src/kilocode/shell/shell.ts index 34db598e5305..b134c43376a1 100644 --- a/packages/opencode/src/kilocode/shell/shell.ts +++ b/packages/opencode/src/kilocode/shell/shell.ts @@ -1,16 +1,124 @@ -import { Buffer } from "node:buffer" - export function args(command: string) { - return ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded(command)] + return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)] } -function encoded(command: string) { - const payload = Buffer.from(command, "utf8").toString("base64") - return Buffer.from( - `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); +const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [Console]::OutputEncoding; -& ([scriptblock]::Create([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${payload}'))))`, - "utf16le", - ).toString("base64") +` + +function script(command: string) { + const pos = prologue(command) + const head = command.slice(0, pos) + const body = command.slice(pos) + const gap = head && !/[;\r\n]\s*$/.test(head) ? "\n" : "" + return `${head}${gap}${setup}${body}` +} + +function prologue(command: string) { + const pos = scan(command, 0) + const attr = attrs(command, pos) + const body = command.slice(attr) + const match = /^param\s*\(/i.exec(body) + if (!match) return pos + + const start = attr + match[0].lastIndexOf("(") + const end = block(command, start, "(", ")") + if (end === undefined) return pos + return end +} + +function attrs(command: string, start: number) { + let pos = start + while (pos < command.length) { + const next = scan(command, pos) + if (command[next] !== "[") return next + const end = block(command, next, "[", "]") + if (end === undefined) return start + pos = end + } + return pos +} + +function scan(command: string, start: number) { + let pos = start + while (pos < command.length) { + const next = trivia(command, pos) + if (next !== pos) { + pos = next + continue + } + const end = line(command, pos) + const value = command.slice(pos, end) + if (/^using\s+(?:assembly|module|namespace|type)\b/i.test(value)) { + pos = end + continue + } + return pos + } + return pos +} + +function trivia(command: string, start: number) { + let pos = start + while (pos < command.length) { + while (/\s/.test(command[pos] ?? "")) pos++ + if (command[pos] === "#") { + pos = line(command, pos) + continue + } + if (command.startsWith("<#", pos)) { + const end = command.indexOf("#>", pos + 2) + if (end === -1) return command.length + pos = end + 2 + continue + } + return pos + } + return pos +} + +function line(command: string, start: number) { + const index = command.indexOf("\n", start) + if (index === -1) return command.length + return index + 1 +} + +function block(command: string, start: number, open: string, close: string) { + let depth = 0 + let quote: string | undefined + for (let pos = start; pos < command.length; pos++) { + const char = command[pos] + if (quote) { + if (quote === "'" && char === "'" && command[pos + 1] === "'") { + pos++ + continue + } + if (quote === '"' && char === "`") { + pos++ + continue + } + if (char === quote) quote = undefined + continue + } + if (char === "'" || char === '"') { + quote = char + continue + } + if (command.startsWith("<#", pos)) { + const end = command.indexOf("#>", pos + 2) + if (end === -1) return + pos = end + 1 + continue + } + if (char === "#") { + pos = line(command, pos) - 1 + continue + } + if (char === open) depth++ + if (char === close) { + depth-- + if (depth === 0) return pos + 1 + } + } } diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts index c51a0426efe6..a7d7ec3bf704 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/opencode/src/shell/shell.ts @@ -1,5 +1,5 @@ import { Flag } from "@opencode-ai/core/flag/flag" -import * as PowerShell from "@/kilocode/shell/shell" // kilocode_change - encoded PowerShell args +import * as PowerShell from "@/kilocode/shell/shell" // kilocode_change - PowerShell args import { lazy } from "@/util/lazy" import { Filesystem } from "@/util/filesystem" import { which } from "@/util/which" @@ -189,7 +189,7 @@ export function args(file: string, command: string, cwd: string) { ] } if (n === "cmd") return ["/c", command] - if (ps(file)) return PowerShell.args(command) // kilocode_change - encoded PowerShell args + if (ps(file)) return PowerShell.args(command) // kilocode_change - PowerShell args return ["-c", command] } diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index f6d2fa71ba84..18f974c8955a 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -312,7 +312,7 @@ const ask = Effect.fn("ShellTool.ask")(function* ( function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { if (process.platform === "win32" && Shell.ps(shell)) { - // kilocode_change start - encoded PowerShell args + // kilocode_change start - PowerShell args return ChildProcess.make(shell, Shell.args(shell, command, cwd), { // kilocode_change end cwd, diff --git a/packages/opencode/test/kilocode/shell/shell.test.ts b/packages/opencode/test/kilocode/shell/shell.test.ts index 99eac3ecb029..57aa989fffca 100644 --- a/packages/opencode/test/kilocode/shell/shell.test.ts +++ b/packages/opencode/test/kilocode/shell/shell.test.ts @@ -1,31 +1,48 @@ import { describe, expect, test } from "bun:test" -import { Buffer } from "node:buffer" import * as PowerShell from "@/kilocode/shell/shell" import { Shell } from "@/shell/shell" const command = `Write-Output "こんにちは 😀"; Write-Output '$value'; Write-Output \`tick\` Write-Output "done"` -function script(args: string[]) { - return Buffer.from(args[4], "base64").toString("utf16le") -} - describe("PowerShell arguments", () => { - test("transports commands through UTF-8 inside EncodedCommand", () => { + test("transports commands in plaintext with UTF-8 console setup", () => { const args = PowerShell.args(command) - const value = script(args) - const payload = value.match(/FromBase64String\('([^']+)'\)/)?.[1] + const value = args[4] - expect(args.slice(0, 4)).toEqual(["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand"]) + expect(args.slice(0, 4)).toEqual(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]) expect(args).toHaveLength(5) expect(value).toContain("[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)") expect(value).toContain("[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)") expect(value).toContain("$OutputEncoding = [Console]::OutputEncoding") - expect(payload).toBeDefined() - expect(Buffer.from(payload!, "base64").toString("utf8")).toBe(command) + expect(value).toContain(command) + expect(args).not.toContain("-EncodedCommand") + expect(value).not.toContain("FromBase64String") }) test.each(["powershell", "pwsh"])("routes %s through the Kilo argument builder", (shell) => { expect(Shell.args(shell, command, "/tmp")).toEqual(PowerShell.args(command)) }) + + test("keeps script-level prologue before the UTF-8 console setup", () => { + const input = `#requires -Version 5.1 +using namespace System.Text +<# +Block comment before attributes. +#> +[CmdletBinding()] +[OutputType([string])] +param( + [string]$Name +) +Write-Output $Name` + const value = PowerShell.args(input)[4] + const setup = value.indexOf("[Console]::InputEncoding") + + expect(value.startsWith("#requires -Version 5.1")).toBe(true) + expect(value.slice(0, setup)).toContain("[CmdletBinding()]") + expect(value.slice(0, setup)).toContain("[OutputType([string])]") + expect(value.slice(0, setup)).toContain("param(") + expect(setup).toBeLessThan(value.indexOf("Write-Output")) + }) })