import { spawn } from "node:child_process"; import { Type } from "@earendil-works/pi-ai"; import { defineTool } from "@earendil-works/pi-coding-agent"; import { readActiveRun } from "../src/harness/state.ts"; import { recordToolBlocked, recordToolSucceeded } from "../src/harness/observation.ts"; export const windowsPowerShellBashTool = defineTool({ name: "bash", label: "PowerShell", description: "Windows KCode shell override. Execute a PowerShell command in the current working directory. Do not use Linux/bash syntax; use PowerShell commands such as Get-ChildItem, Select-String, Get-Content, and dotnet.", promptSnippet: "Execute Windows PowerShell commands. Use PowerShell syntax, not bash syntax.", parameters: Type.Object({ command: Type.String({ description: "PowerShell command to execute. Use PowerShell syntax, not bash syntax." }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds." })), }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { return runPowerShell(params.command, ctx.cwd, params.timeout, signal); }, }); function runPowerShell(command: string, cwd: string, timeout: number | undefined, signal: AbortSignal | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record }> { return new Promise((resolve) => { const mutationReason = powerShellMutationBlockReason(command); if (mutationReason) { recordToolBlocked(cwd, readActiveRun(cwd), { toolName: "bash", reason: mutationReason }); resolve({ content: [{ type: "text", text: mutationReason }], details: { shell: "powershell", blocked: true, reason: mutationReason } }); return; } const shell = process.env.ComSpec?.replace(/cmd\.exe$/i, "WindowsPowerShell\\v1.0\\powershell.exe") || "powershell.exe"; const child = spawn(shell, ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], { cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; let timedOut = false; const timeoutHandle = timeout && timeout > 0 ? setTimeout(() => { timedOut = true; child.kill(); }, timeout * 1000) : undefined; const onAbort = () => child.kill(); if (signal) signal.addEventListener("abort", onAbort, { once: true }); child.stdout?.on("data", (data) => { stdout += data.toString(); }); child.stderr?.on("data", (data) => { stderr += data.toString(); }); child.on("error", (error) => { if (timeoutHandle) clearTimeout(timeoutHandle); if (signal) signal.removeEventListener("abort", onAbort); resolve({ content: [{ type: "text", text: `PowerShell 启动失败:${error.message}` }], details: { shell: "powershell", error: "spawn-failed" } }); }); child.on("close", (exitCode) => { if (timeoutHandle) clearTimeout(timeoutHandle); if (signal) signal.removeEventListener("abort", onAbort); const output = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n"); const suffix = timedOut ? `\n\n[PowerShell timeout after ${timeout}s]` : ""; recordToolSucceeded(cwd, readActiveRun(cwd), { toolName: "bash", kind: "shell", summary: `PowerShell command exitCode=${exitCode}${timedOut ? " timedOut=true" : ""}:${command}`, }); resolve({ content: [{ type: "text", text: `${output || "(no output)"}${suffix}` }], details: { shell: "powershell", exitCode, timedOut, command }, }); }); }); } export function powerShellMutationBlockReason(command: string): string | undefined { const normalized = command.trim(); if (!normalized) return undefined; const mutatingCommand = /\b(Set-Content|Add-Content|Out-File|New-Item|Remove-Item|Move-Item|Copy-Item|Rename-Item|Clear-Content|Set-Item|Set-ItemProperty|New-ItemProperty|Remove-ItemProperty|Start-Process)\b/i; const mutatingAlias = /(^|[\s;|&])(rm|del|erase|mv|move|cp|copy|ni|mkdir|md|rmdir|rd)\b/i; const redirection = /(^|[^=<>])>>?($|[^>])/; const nestedShellOrScript = /\b(cmd(?:\.exe)?\s*\/c|powershell(?:\.exe)?\s+.*-(?:Command|EncodedCommand)|pwsh\s+.*-(?:Command|EncodedCommand)|python(?:\.exe)?\s+-c|node(?:\.exe)?\s+-e|perl\s+-e)\b/i; const mutatingCli = /\bgit\s+(apply|checkout|restore|reset|clean|commit|merge|rebase|pull|push)\b|\b(?:npm|pnpm|yarn)\s+(install|uninstall|version|publish|add|remove)\b/i; if ( !mutatingCommand.test(normalized) && !mutatingAlias.test(normalized) && !redirection.test(normalized) && !nestedShellOrScript.test(normalized) && !mutatingCli.test(normalized) ) return undefined; return [ "PowerShell 文件变更命令被 KCode 阻断。", "执行指令:生产文件修改必须使用 write/edit,并通过 PLAN、Source Anchor、Write Transaction 和后置条件门禁;验证输出请使用对应 evidence 工具或 Harness evidence 文件。", ].join("\n"); }