From a6e0eb3db23dd6c5db847d16a11ae5f2d30b52ef Mon Sep 17 00:00:00 2001 From: duskzhen Date: Fri, 12 Dec 2025 16:36:10 +0800 Subject: [PATCH 1/3] chore(powerpack): randomize shell workdir --- .../inMemoryServers/powerpackServer.ts | 148 ++++++++++++++++-- 1 file changed, 139 insertions(+), 9 deletions(-) diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts index fa3bfd4a9..0de003362 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts @@ -13,6 +13,14 @@ import { nanoid } from 'nanoid' import { Sandbox } from '@e2b/code-interpreter' import { RuntimeHelper } from '@/lib/runtimeHelper' +type ShellEnvironment = { + platform: 'windows' | 'mac' | 'linux' + shellName: 'powershell' | 'bash' + shellExecutable: string + buildArgs: (command: string) => string[] + promptHint: string +} + // Schema 定义 const GetTimeArgsSchema = z.object({ offset: z @@ -39,6 +47,26 @@ const RunNodeCodeArgsSchema = z.object({ .describe('Code execution timeout in milliseconds, default 5 seconds') }) +const RunShellCommandArgsSchema = z.object({ + command: z + .string() + .min(1) + .describe( + 'Shell command to execute. Provide the full command string including arguments and flags.' + ), + timeout: z + .number() + .optional() + .default(60000) + .describe('Command execution timeout in milliseconds, defaults to 60 seconds.'), + workdir: z + .string() + .optional() + .describe( + 'Optional working directory. If omitted, uses an isolated temporary directory under the application temp path.' + ) +}) + // E2B 代码执行 Schema const E2BRunCodeArgsSchema = z.object({ code: z @@ -77,6 +105,8 @@ export class PowerpackServer { private useE2B: boolean = false private e2bApiKey: string = '' private readonly runtimeHelper = RuntimeHelper.getInstance() + private readonly shellEnvironment: ShellEnvironment + private readonly shellWorkdir: string constructor(env?: Record) { // 从环境变量中获取 E2B 配置 @@ -85,6 +115,12 @@ export class PowerpackServer { // 查找内置的运行时路径 this.runtimeHelper.initializeRuntimes() + // 检测当前系统的 Shell 环境 + this.shellEnvironment = this.detectShellEnvironment() + + // 确保 Shell 使用的工作目录 + this.shellWorkdir = this.ensureShellWorkdir() + // 创建服务器实例 this.server = new Server( { @@ -116,6 +152,31 @@ export class PowerpackServer { } } + private detectShellEnvironment(): ShellEnvironment { + if (process.platform === 'win32') { + return { + platform: 'windows', + shellName: 'powershell', + shellExecutable: 'powershell.exe', + buildArgs: (command) => ['-Command', command], + promptHint: + 'Windows environment detected. Commands run with PowerShell, you can use built-in cmdlets and scripts. ' + } + } + + const isMac = process.platform === 'darwin' + + return { + platform: isMac ? 'mac' : 'linux', + shellName: 'bash', + shellExecutable: '/bin/bash', + buildArgs: (command) => ['-lc', command], + promptHint: isMac + ? 'macOS environment detected. Commands run with bash; macOS utilities like osascript, open, defaults are available.' + : 'Linux environment detected. Commands run with bash and typical GNU utilities are available.' + } + } + // 启动服务器 public async startServer(transport: Transport): Promise { this.server.connect(transport) @@ -141,12 +202,12 @@ export class PowerpackServer { // 所有平台都使用 Node.js const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() if (!nodeRuntimePath) { - throw new Error('运行时未找到,无法执行代码') + throw new Error('Runtime not found; cannot execute code') } // 检查代码安全性 if (!this.checkCodeSafety(code)) { - throw new Error('代码包含不安全的操作,已被拒绝执行') + throw new Error('Code contains disallowed operations and was rejected') } // 创建临时文件 @@ -197,6 +258,44 @@ export class PowerpackServer { } } + private async executeShellCommand( + command: string, + timeout: number, + workdir?: string + ): Promise { + const { shellExecutable, buildArgs } = this.shellEnvironment + const execPromise = promisify(execFile)(shellExecutable, buildArgs(command), { + timeout, + cwd: workdir || this.shellWorkdir, + windowsHide: true + }) + + const { stdout, stderr } = await execPromise + const outputParts: string[] = [] + + if (stdout) { + outputParts.push(stdout.trim()) + } + + if (stderr) { + outputParts.push(`STDERR:\n${stderr.trim()}`) + } + + return outputParts.join('\n\n') || 'Command executed with no output' + } + + private ensureShellWorkdir(): string { + const baseTempDir = app.getPath('temp') + const shellDirPrefix = path.join(baseTempDir, 'powerpack_shell_') + + try { + return fs.mkdtempSync(shellDirPrefix) + } catch (error) { + console.error('Failed to ensure shell workdir, falling back to temp path:', error) + return baseTempDir + } + } + // 使用 E2B 执行代码 private async executeE2BCode(code: string): Promise { if (!this.useE2B) { @@ -282,6 +381,17 @@ export class PowerpackServer { } ] + const shellDescription = + `${this.shellEnvironment.promptHint} ` + + 'Use this tool for day-to-day automation, file inspection, networking, and scripting. ' + + 'Provide a full shell command string; output includes stdout and stderr. ' + + tools.push({ + name: 'run_shell_command', + description: shellDescription, + inputSchema: zodToJsonSchema(RunShellCommandArgsSchema) + }) + // 根据配置添加代码执行工具 if (this.useE2B) { // 使用 E2B 执行代码 @@ -327,7 +437,7 @@ export class PowerpackServer { case 'get_time': { const parsed = GetTimeArgsSchema.safeParse(args) if (!parsed.success) { - throw new Error(`无效的时间参数: ${parsed.error}`) + throw new Error(`Invalid time arguments: ${parsed.error}`) } const { offset } = parsed.data @@ -350,7 +460,7 @@ export class PowerpackServer { case 'get_web_info': { const parsed = GetWebInfoArgsSchema.safeParse(args) if (!parsed.success) { - throw new Error(`无效的URL参数: ${parsed.error}`) + throw new Error(`Invalid URL arguments: ${parsed.error}`) } const { url } = parsed.data @@ -383,6 +493,26 @@ export class PowerpackServer { } } + case 'run_shell_command': { + const parsed = RunShellCommandArgsSchema.safeParse(args) + + if (!parsed.success) { + throw new Error(`Invalid command arguments: ${parsed.error}`) + } + + const { command, timeout, workdir } = parsed.data + const result = await this.executeShellCommand(command, timeout, workdir) + + return { + content: [ + { + type: 'text', + text: `Current shell environment: ${this.shellEnvironment.shellName}\n\nExecution result:\n${result}` + } + ] + } + } + case 'run_code': { // E2B 代码执行 if (!this.useE2B) { @@ -391,7 +521,7 @@ export class PowerpackServer { const parsed = E2BRunCodeArgsSchema.safeParse(args) if (!parsed.success) { - throw new Error(`无效的代码参数: ${parsed.error}`) + throw new Error(`Invalid code arguments: ${parsed.error}`) } const { code } = parsed.data @@ -401,7 +531,7 @@ export class PowerpackServer { content: [ { type: 'text', - text: `代码执行结果 (E2B Sandbox):\n\n${result}` + text: `Code execution result (E2B Sandbox):\n\n${result}` } ] } @@ -420,7 +550,7 @@ export class PowerpackServer { const parsed = RunNodeCodeArgsSchema.safeParse(args) if (!parsed.success) { - throw new Error(`无效的代码参数: ${parsed.error}`) + throw new Error(`Invalid code arguments: ${parsed.error}`) } const { code, timeout } = parsed.data @@ -430,7 +560,7 @@ export class PowerpackServer { content: [ { type: 'text', - text: `代码执行结果:\n\n${result}` + text: `Code execution result:\n\n${result}` } ] } @@ -442,7 +572,7 @@ export class PowerpackServer { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) return { - content: [{ type: 'text', text: `错误: ${errorMessage}` }], + content: [{ type: 'text', text: `Error: ${errorMessage}` }], isError: true } } From a1c2293871e83a80876eb5611ee84e32db7708ee Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 12 Dec 2025 16:53:21 +0800 Subject: [PATCH 2/3] feat: exclusive inmem server in terminal display --- src/renderer/src/components/message/MessageBlockToolCall.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/src/components/message/MessageBlockToolCall.vue b/src/renderer/src/components/message/MessageBlockToolCall.vue index 86037a6f1..fc37e19ba 100644 --- a/src/renderer/src/components/message/MessageBlockToolCall.vue +++ b/src/renderer/src/components/message/MessageBlockToolCall.vue @@ -175,6 +175,10 @@ const parseJson = (jsonStr: string) => { // Terminal detection const isTerminalTool = computed(() => { const name = props.block.tool_call?.name?.toLowerCase() || '' + const serverName = props.block.tool_call?.server_name?.toLowerCase() || '' + if (name == 'run_shell_command' && serverName === 'powerpack') { + return false + } return name.includes('terminal') || name.includes('command') || name.includes('exec') }) From cfa34b91fc95058507b152164dd4eddc35375ba6 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Fri, 12 Dec 2025 17:48:10 +0800 Subject: [PATCH 3/3] fix: add sandbox in shell script --- .../inMemoryServers/powerpackServer.ts | 140 ++++++++++++++---- 1 file changed, 111 insertions(+), 29 deletions(-) diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts index 0de003362..02129fdaa 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts @@ -63,7 +63,7 @@ const RunShellCommandArgsSchema = z.object({ .string() .optional() .describe( - 'Optional working directory. If omitted, uses an isolated temporary directory under the application temp path.' + 'Optional working directory within the sandboxed temp area. If omitted, uses an isolated temporary directory under the application temp path.' ) }) @@ -100,9 +100,19 @@ const CODE_EXECUTION_FORBIDDEN_PATTERNS = [ /process\.env/gi ] +const SHELL_COMMAND_FORBIDDEN_PATTERNS = [ + /\b(sudo|su)\b/gi, + /\brm\s+-[^\n]*\brf\b/gi, + /\b(del|erase|rmdir|rd)\b\s+\/[^\n]*\b(s|q)\b/gi, + /\b(shutdown|reboot|halt|poweroff)\b/gi, + /\b(mkfs|diskpart|format)\b/gi, + /:\s*\(\s*\)\s*{\s*:\s*\|\s*:\s*&\s*}\s*;\s*:/g +] + export class PowerpackServer { private server: Server private useE2B: boolean = false + private enableShellCommandTool: boolean = false private e2bApiKey: string = '' private readonly runtimeHelper = RuntimeHelper.getInstance() private readonly shellEnvironment: ShellEnvironment @@ -111,6 +121,7 @@ export class PowerpackServer { constructor(env?: Record) { // 从环境变量中获取 E2B 配置 this.parseE2BConfig(env) + this.parseShellCommandToolConfig(env) // 查找内置的运行时路径 this.runtimeHelper.initializeRuntimes() @@ -152,15 +163,20 @@ export class PowerpackServer { } } + private parseShellCommandToolConfig(env?: Record): void { + const enableValue = env?.ENABLE_SHELL_COMMAND_TOOL ?? process.env.ENABLE_SHELL_COMMAND_TOOL + this.enableShellCommandTool = enableValue === true || enableValue === 'true' + } + private detectShellEnvironment(): ShellEnvironment { if (process.platform === 'win32') { return { platform: 'windows', shellName: 'powershell', shellExecutable: 'powershell.exe', - buildArgs: (command) => ['-Command', command], + buildArgs: (command) => ['-NoProfile', '-NonInteractive', '-Command', command], promptHint: - 'Windows environment detected. Commands run with PowerShell, you can use built-in cmdlets and scripts. ' + 'Windows environment detected. Commands run with PowerShell, you can use built-in cmdlets and scripts.' } } @@ -169,8 +185,8 @@ export class PowerpackServer { return { platform: isMac ? 'mac' : 'linux', shellName: 'bash', - shellExecutable: '/bin/bash', - buildArgs: (command) => ['-lc', command], + shellExecutable: process.env.SHELL || '/bin/bash', + buildArgs: (command) => ['-c', command], promptHint: isMac ? 'macOS environment detected. Commands run with bash; macOS utilities like osascript, open, defaults are available.' : 'Linux environment detected. Commands run with bash and typical GNU utilities are available.' @@ -185,6 +201,7 @@ export class PowerpackServer { // 检查代码的安全性 private checkCodeSafety(code: string): boolean { for (const pattern of CODE_EXECUTION_FORBIDDEN_PATTERNS) { + pattern.lastIndex = 0 if (pattern.test(code)) { return false } @@ -258,30 +275,90 @@ export class PowerpackServer { } } + private checkShellCommandSafety(command: string): void { + for (const pattern of SHELL_COMMAND_FORBIDDEN_PATTERNS) { + pattern.lastIndex = 0 + if (pattern.test(command)) { + throw new Error('Shell command contains disallowed operations and was rejected') + } + } + } + + private getRealPath(candidate: string): string { + try { + return fs.realpathSync(candidate) + } catch { + return path.resolve(candidate) + } + } + + private resolveShellCwd(workdir?: string): string { + const requestedCwd = workdir || this.shellWorkdir + const resolvedCwd = this.getRealPath(path.resolve(requestedCwd)) + const shellRoot = this.getRealPath(this.shellWorkdir) + const tempRoot = this.getRealPath(app.getPath('temp')) + + const isWithin = (child: string, parent: string) => + child === parent || child.startsWith(parent + path.sep) + + if (!isWithin(resolvedCwd, shellRoot) && !isWithin(resolvedCwd, tempRoot)) { + throw new Error(`workdir must be within sandboxed temp directories: ${shellRoot}`) + } + + if (!fs.existsSync(resolvedCwd) || !fs.statSync(resolvedCwd).isDirectory()) { + throw new Error(`workdir does not exist or is not a directory: ${resolvedCwd}`) + } + + return resolvedCwd + } + + private formatShellOutput(stdout?: string, stderr?: string, errorMessage?: string): string { + const outputParts: string[] = [] + const trimmedStdout = stdout?.trim() + const trimmedStderr = stderr?.trim() + + if (trimmedStdout) { + outputParts.push(`STDOUT:\n${trimmedStdout}`) + } + + if (trimmedStderr) { + outputParts.push(`STDERR:\n${trimmedStderr}`) + } + + if (errorMessage) { + const trimmedError = errorMessage.trim() + if (trimmedError) { + outputParts.push(`ERROR:\n${trimmedError}`) + } + } + + return outputParts.join('\n\n') || 'Command executed with no output' + } + private async executeShellCommand( command: string, timeout: number, workdir?: string ): Promise { + this.checkShellCommandSafety(command) const { shellExecutable, buildArgs } = this.shellEnvironment - const execPromise = promisify(execFile)(shellExecutable, buildArgs(command), { - timeout, - cwd: workdir || this.shellWorkdir, - windowsHide: true - }) + const cwd = this.resolveShellCwd(workdir) - const { stdout, stderr } = await execPromise - const outputParts: string[] = [] - - if (stdout) { - outputParts.push(stdout.trim()) - } + try { + const { stdout, stderr } = await promisify(execFile)(shellExecutable, buildArgs(command), { + timeout, + cwd, + windowsHide: true, + maxBuffer: 10 * 1024 * 1024 + }) - if (stderr) { - outputParts.push(`STDERR:\n${stderr.trim()}`) + return this.formatShellOutput(stdout, stderr) + } catch (error) { + const stdout = typeof (error as any)?.stdout === 'string' ? (error as any).stdout : '' + const stderr = typeof (error as any)?.stderr === 'string' ? (error as any).stderr : '' + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(this.formatShellOutput(stdout, stderr, errorMessage)) } - - return outputParts.join('\n\n') || 'Command executed with no output' } private ensureShellWorkdir(): string { @@ -381,16 +458,18 @@ export class PowerpackServer { } ] - const shellDescription = - `${this.shellEnvironment.promptHint} ` + - 'Use this tool for day-to-day automation, file inspection, networking, and scripting. ' + - 'Provide a full shell command string; output includes stdout and stderr. ' + if (this.enableShellCommandTool) { + const shellDescription = + `${this.shellEnvironment.promptHint} ` + + 'Use this tool for day-to-day automation, file inspection, networking, and scripting. ' + + 'Provide a full shell command string; output includes stdout and stderr. ' - tools.push({ - name: 'run_shell_command', - description: shellDescription, - inputSchema: zodToJsonSchema(RunShellCommandArgsSchema) - }) + tools.push({ + name: 'run_shell_command', + description: shellDescription, + inputSchema: zodToJsonSchema(RunShellCommandArgsSchema) + }) + } // 根据配置添加代码执行工具 if (this.useE2B) { @@ -494,6 +573,9 @@ export class PowerpackServer { } case 'run_shell_command': { + if (!this.enableShellCommandTool) { + throw new Error('run_shell_command tool is disabled by configuration') + } const parsed = RunShellCommandArgsSchema.safeParse(args) if (!parsed.success) {