From f76269e2d5c48605096767c8e13e1720b4f18c3d Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Mon, 9 Feb 2026 18:49:53 +1100 Subject: [PATCH 1/5] fix: eliminate command injection in grep and glob tools using execFile --- src/tools/defaults.ts | 48 +++++++++++++++++++++--------------- tests/tools/defaults.test.ts | 34 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/tools/defaults.ts b/src/tools/defaults.ts index 494b736..d4c7951 100644 --- a/src/tools/defaults.ts +++ b/src/tools/defaults.ts @@ -201,22 +201,28 @@ export function registerDefaultTools(registry: ToolRegistry): void { }, source: "local" as const }, async (args) => { - const { exec } = await import("child_process"); + const { execFile } = await import("child_process"); const { promisify } = await import("util"); - const execAsync = promisify(exec); + const execFileAsync = promisify(execFile); - try { - const pattern = args.pattern as string; - const path = args.path as string; - const include = args.include as string | undefined; - const includeFlag = include ? `--include="${include}"` : ""; - const { stdout } = await execAsync( - `grep -r ${includeFlag} -n "${pattern}" "${path}" 2>/dev/null || true`, - { timeout: 30000 } - ); + const pattern = args.pattern as string; + const path = args.path as string; + const include = args.include as string | undefined; + + const grepArgs = ["-r", "-n"]; + if (include) { + grepArgs.push(`--include=${include}`); + } + grepArgs.push(pattern, path); + try { + const { stdout } = await execFileAsync("grep", grepArgs, { timeout: 30000 }); return stdout || "No matches found"; } catch (error: any) { + // grep exits with code 1 when no matches found — not an error + if (error.code === 1) { + return "No matches found"; + } throw error; } }); @@ -278,20 +284,22 @@ export function registerDefaultTools(registry: ToolRegistry): void { }, source: "local" as const }, async (args) => { - const { exec } = await import("child_process"); + const { execFile } = await import("child_process"); const { promisify } = await import("util"); - const execAsync = promisify(exec); + const execFileAsync = promisify(execFile); + + const pattern = args.pattern as string; + const path = args.path as string | undefined; + const cwd = path || "."; try { - const pattern = args.pattern as string; - const path = args.path as string | undefined; - const cwd = path || "."; - const { stdout } = await execAsync( - `find "${cwd}" -type f -name "${pattern}" 2>/dev/null | head -50`, + const { stdout } = await execFileAsync( + "find", [cwd, "-type", "f", "-name", pattern], { timeout: 30000 } ); - - return stdout || "No files found"; + // Limit output to 50 lines (replaces piped `| head -50`) + const lines = (stdout || "").split("\n").filter(Boolean); + return lines.slice(0, 50).join("\n") || "No files found"; } catch (error: any) { throw error; } diff --git a/tests/tools/defaults.test.ts b/tests/tools/defaults.test.ts index f9a62ab..0d95380 100644 --- a/tests/tools/defaults.test.ts +++ b/tests/tools/defaults.test.ts @@ -142,4 +142,38 @@ describe("Default Tools", () => { expect(tool.source).toBe("local"); } }); + + it("should execute grep tool safely with special characters in pattern", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + + const fs = await import("fs"); + const tmpFile = `/tmp/test-grep-${Date.now()}.txt`; + fs.writeFileSync(tmpFile, "hello world\nfoo bar\n", "utf-8"); + + const result = await executeWithChain([executor], "grep", { + pattern: "hello", + path: tmpFile + }); + + expect(result.status).toBe("success"); + expect(result.output).toContain("hello world"); + + fs.unlinkSync(tmpFile); + }); + + it("should execute glob tool safely", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + + const result = await executeWithChain([executor], "glob", { + pattern: "*.ts", + path: "src/tools" + }); + + expect(result.status).toBe("success"); + expect(result.output).toContain(".ts"); + }); }); \ No newline at end of file From 7475d9d8c32ac01d85864dc01d358cc6531a1c5f Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Mon, 9 Feb 2026 18:51:14 +1100 Subject: [PATCH 2/5] fix: use prompt builder for tool message handling in proxy --- src/plugin.ts | 50 +------- src/proxy/prompt-builder.ts | 74 ++++++++++++ tests/unit/proxy/prompt-builder.test.ts | 152 ++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 46 deletions(-) create mode 100644 src/proxy/prompt-builder.ts create mode 100644 tests/unit/proxy/prompt-builder.test.ts diff --git a/src/plugin.ts b/src/plugin.ts index fbbbb86..3414a47 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -12,6 +12,7 @@ import { parseStreamJsonLine } from "./streaming/parser.js"; import { extractText, extractThinking, isAssistantText, isThinking } from "./streaming/types.js"; import { createLogger } from "./utils/logger"; import { parseAgentError, formatErrorForUser, stripAnsi } from "./utils/errors"; +import { buildPromptFromMessages } from "./proxy/prompt-builder.js"; import { OpenCodeToolDiscovery } from "./tools/discovery.js"; import { toOpenAiParameters, describeTool } from "./tools/schema.js"; import { ToolRouter } from "./tools/router.js"; @@ -206,29 +207,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const stream = body?.stream === true; const tools = Array.isArray(body?.tools) ? body.tools : []; - // Convert messages to prompt - const lines: string[] = []; - for (const message of messages) { - const role = typeof message.role === "string" ? message.role : "user"; - const content = message.content; - - if (typeof content === "string") { - lines.push(`${role.toUpperCase()}: ${content}`); - } else if (Array.isArray(content)) { - const textParts = content - .map((part: any) => { - if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") { - return part.text; - } - return ""; - }) - .filter(Boolean); - if (textParts.length) { - lines.push(`${role.toUpperCase()}: ${textParts.join("\n")}`); - } - } - } - const prompt = lines.join("\n\n"); + const prompt = buildPromptFromMessages(messages, tools); const model = typeof body?.model === "string" ? body.model : "auto"; const bunAny = globalThis as any; @@ -487,30 +466,9 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: const bodyData: any = JSON.parse(body || "{}"); const messages: Array = Array.isArray(bodyData?.messages) ? bodyData.messages : []; const stream = bodyData?.stream === true; + const tools = Array.isArray(bodyData?.tools) ? bodyData.tools : []; - // Convert messages to prompt - const lines: string[] = []; - for (const message of messages) { - const role = typeof message.role === "string" ? message.role : "user"; - const content = message.content; - - if (typeof content === "string") { - lines.push(`${role.toUpperCase()}: ${content}`); - } else if (Array.isArray(content)) { - const textParts = content - .map((part: any) => { - if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") { - return part.text; - } - return ""; - }) - .filter(Boolean); - if (textParts.length) { - lines.push(`${role.toUpperCase()}: ${textParts.join("\n")}`); - } - } - } - const prompt = lines.join("\n\n"); + const prompt = buildPromptFromMessages(messages, tools); const model = typeof bodyData?.model === "string" ? bodyData.model : "auto"; const cmd = [ diff --git a/src/proxy/prompt-builder.ts b/src/proxy/prompt-builder.ts new file mode 100644 index 0000000..658cde5 --- /dev/null +++ b/src/proxy/prompt-builder.ts @@ -0,0 +1,74 @@ +/** + * Build a text prompt from OpenAI chat messages + tool definitions. + * Handles role:"tool" result messages and assistant tool_calls that + * plain text flattening would silently drop. + */ +export function buildPromptFromMessages(messages: Array, tools: Array): string { + const lines: string[] = []; + + if (tools.length > 0) { + const toolDescs = tools + .map((t: any) => { + const fn = t.function || t; + const name = fn.name || "unknown"; + const desc = fn.description || ""; + const params = fn.parameters; + const paramStr = params ? JSON.stringify(params) : "{}"; + return `- ${name}: ${desc}\n Parameters: ${paramStr}`; + }) + .join("\n"); + lines.push( + `SYSTEM: You have access to the following tools. When you need to use one, respond with a tool_call in the standard OpenAI format.\n\nAvailable tools:\n${toolDescs}`, + ); + } + + for (const message of messages) { + const role = typeof message.role === "string" ? message.role : "user"; + + // tool result messages (from multi-turn tool execution loop) + if (role === "tool") { + const callId = message.tool_call_id || "unknown"; + const body = + typeof message.content === "string" + ? message.content + : JSON.stringify(message.content ?? ""); + lines.push(`TOOL_RESULT (call_id: ${callId}): ${body}`); + continue; + } + + // assistant messages that contain tool_calls (previous turn's tool invocations) + if ( + role === "assistant" && + Array.isArray(message.tool_calls) && + message.tool_calls.length > 0 + ) { + const tcTexts = message.tool_calls.map((tc: any) => { + const fn = tc.function || {}; + return `tool_call(id: ${tc.id || "?"}, name: ${fn.name || "?"}, args: ${fn.arguments || "{}"})`; + }); + const text = typeof message.content === "string" ? message.content : ""; + lines.push(`ASSISTANT: ${text ? text + "\n" : ""}${tcTexts.join("\n")}`); + continue; + } + + // standard text messages + const content = message.content; + if (typeof content === "string") { + lines.push(`${role.toUpperCase()}: ${content}`); + } else if (Array.isArray(content)) { + const textParts = content + .map((part: any) => { + if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") { + return part.text; + } + return ""; + }) + .filter(Boolean); + if (textParts.length) { + lines.push(`${role.toUpperCase()}: ${textParts.join("\n")}`); + } + } + } + + return lines.join("\n\n"); +} diff --git a/tests/unit/proxy/prompt-builder.test.ts b/tests/unit/proxy/prompt-builder.test.ts new file mode 100644 index 0000000..8906fd9 --- /dev/null +++ b/tests/unit/proxy/prompt-builder.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "bun:test"; +import { buildPromptFromMessages } from "../../../src/proxy/prompt-builder.js"; + +describe("buildPromptFromMessages", () => { + it("converts simple text messages", () => { + const messages = [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Hello" }, + ]; + const result = buildPromptFromMessages(messages, []); + expect(result).toBe("SYSTEM: You are helpful.\n\nUSER: Hello"); + }); + + it("handles array content parts", () => { + const messages = [ + { + role: "user", + content: [ + { type: "text", text: "Part 1" }, + { type: "text", text: "Part 2" }, + ], + }, + ]; + const result = buildPromptFromMessages(messages, []); + expect(result).toBe("USER: Part 1\nPart 2"); + }); + + it("includes tool definitions as system section", () => { + const tools = [ + { + type: "function", + function: { + name: "read", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }, + }, + ]; + const messages = [{ role: "user", content: "Read foo.txt" }]; + const result = buildPromptFromMessages(messages, tools); + + expect(result).toContain("Available tools:"); + expect(result).toContain("- read: Read a file"); + expect(result).toContain("Parameters:"); + expect(result).toContain("USER: Read foo.txt"); + }); + + it("handles role:tool result messages", () => { + const messages = [ + { role: "user", content: "Read the file" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_1", function: { name: "read", arguments: '{"path":"foo.txt"}' } }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "file contents here" }, + ]; + const result = buildPromptFromMessages(messages, []); + + expect(result).toContain("TOOL_RESULT (call_id: call_1): file contents here"); + }); + + it("handles assistant messages with tool_calls", () => { + const messages = [ + { + role: "assistant", + content: "Let me read that file.", + tool_calls: [ + { id: "call_1", function: { name: "read", arguments: '{"path":"foo.txt"}' } }, + ], + }, + ]; + const result = buildPromptFromMessages(messages, []); + + expect(result).toContain("ASSISTANT: Let me read that file."); + expect(result).toContain('tool_call(id: call_1, name: read, args: {"path":"foo.txt"})'); + }); + + it("handles assistant tool_calls without content", () => { + const messages = [ + { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_1", function: { name: "bash", arguments: '{"command":"ls"}' } }, + ], + }, + ]; + const result = buildPromptFromMessages(messages, []); + + expect(result).toContain("ASSISTANT: tool_call(id: call_1, name: bash"); + expect(result).not.toContain("null"); + }); + + it("handles full multi-turn tool conversation", () => { + const tools = [ + { + type: "function", + function: { name: "read", description: "Read a file", parameters: {} }, + }, + ]; + const messages = [ + { role: "system", content: "You are an assistant." }, + { role: "user", content: "Read foo.txt" }, + { + role: "assistant", + content: null, + tool_calls: [{ id: "c1", function: { name: "read", arguments: '{"path":"foo.txt"}' } }], + }, + { role: "tool", tool_call_id: "c1", content: "hello world" }, + { role: "assistant", content: "The file contains: hello world" }, + ]; + const result = buildPromptFromMessages(messages, tools); + + // Should have tool definitions section + expect(result).toContain("Available tools:"); + // Should have the user message + expect(result).toContain("USER: Read foo.txt"); + // Should have the tool call + expect(result).toContain("tool_call(id: c1, name: read"); + // Should have the tool result + expect(result).toContain("TOOL_RESULT (call_id: c1): hello world"); + // Should have the final assistant message + expect(result).toContain("ASSISTANT: The file contains: hello world"); + }); + + it("skips non-text content parts", () => { + const messages = [ + { + role: "user", + content: [ + { type: "text", text: "Hello" }, + { type: "image_url", image_url: { url: "data:..." } }, + ], + }, + ]; + const result = buildPromptFromMessages(messages, []); + expect(result).toBe("USER: Hello"); + }); + + it("handles empty messages array", () => { + expect(buildPromptFromMessages([], [])).toBe(""); + }); + + it("handles empty tools array", () => { + const result = buildPromptFromMessages([{ role: "user", content: "Hi" }], []); + expect(result).not.toContain("Available tools:"); + expect(result).toBe("USER: Hi"); + }); +}); From 93b2e4d5ffb99400afd5d59bab7f51bf0c6232ba Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Mon, 9 Feb 2026 18:52:47 +1100 Subject: [PATCH 3/5] fix: SdkExecutor toolId gating, env var consistency, MCP source filter --- src/plugin.ts | 16 ++++--- src/tools/executors/sdk.ts | 12 +++-- tests/tools/sdk-executor.test.ts | 81 ++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 tests/tools/sdk-executor.test.ts diff --git a/src/plugin.ts b/src/plugin.ts index 3414a47..1828327 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -318,7 +318,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } // Handle OpenCode tools - if (toolRouter && forwardToolCalls) { + if (toolRouter && FORWARD_TOOL_CALLS) { const toolResult = await toolRouter.handleToolCall(event as any, { id, created, model }); if (toolResult) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(toolResult)}\n\n`)); @@ -558,7 +558,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } } - if (toolRouter && forwardToolCalls) { + if (toolRouter && FORWARD_TOOL_CALLS) { const toolResult = await toolRouter.handleToolCall(event as any, { id, created, model }); if (toolResult) { res.write(`data: ${JSON.stringify(toolResult)}\n\n`); @@ -594,7 +594,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?: } } - if (toolRouter && forwardToolCalls) { + if (toolRouter && FORWARD_TOOL_CALLS) { const toolResult = await toolRouter.handleToolCall(event as any, { id, created, model }); if (toolResult) { res.write(`data: ${JSON.stringify(toolResult)}\n\n`); @@ -796,7 +796,7 @@ export const CursorPlugin: Plugin = async ({ $, directory, client, serverUrl }: // Tools (skills) discovery/execution wiring const toolsEnabled = process.env.CURSOR_ACP_ENABLE_OPENCODE_TOOLS !== "false"; // default ON - const forwardToolCalls = process.env.CURSOR_ACP_FORWARD_TOOL_CALLS !== "false"; // default ON + // forwardToolCalls uses the module-level FORWARD_TOOL_CALLS constant (line 53) // Build a client with serverUrl so SDK tool.list works even if the injected client isn't fully configured. const serverClient = toolsEnabled ? createOpencodeClient({ serverUrl: serverUrl.toString(), directory }) @@ -840,10 +840,12 @@ export const CursorPlugin: Plugin = async ({ $, directory, client, serverUrl }: const skills = skillLoader.load(list); skillResolver = new SkillResolver(skills); - // Populate MCP executor with discovered SDK tool IDs + // Populate executors with their respective tool IDs + if (sdkExec) { + sdkExec.setToolIds(list.filter((t) => t.source === "sdk").map((t) => t.id)); + } if (mcpExec) { - const sdkToolIds = list.filter((t) => t.source === "sdk").map((t) => t.id); - mcpExec.setToolIds(sdkToolIds); + mcpExec.setToolIds(list.filter((t) => t.source === "mcp").map((t) => t.id)); } const toolEntries: any[] = []; diff --git a/src/tools/executors/sdk.ts b/src/tools/executors/sdk.ts index 4727746..b5c90bc 100644 --- a/src/tools/executors/sdk.ts +++ b/src/tools/executors/sdk.ts @@ -4,14 +4,20 @@ import { createLogger } from "../../utils/logger.js"; const log = createLogger("tools:executor:sdk"); export class SdkExecutor implements IToolExecutor { + private toolIds = new Set(); + constructor(private client: any, private timeoutMs: number) {} - canExecute(): boolean { - return Boolean(this.client?.tool?.invoke); + setToolIds(ids: Iterable): void { + this.toolIds = new Set(ids); + } + + canExecute(toolId: string): boolean { + return this.toolIds.has(toolId) && Boolean(this.client?.tool?.invoke); } async execute(toolId: string, args: Record): Promise { - if (!this.canExecute()) return { status: "error", error: "SDK invoke unavailable" }; + if (!this.canExecute(toolId)) return { status: "error", error: "SDK invoke unavailable" }; try { const p = this.client.tool.invoke(toolId, args); const res = await this.runWithTimeout(p); diff --git a/tests/tools/sdk-executor.test.ts b/tests/tools/sdk-executor.test.ts new file mode 100644 index 0000000..ca07794 --- /dev/null +++ b/tests/tools/sdk-executor.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "bun:test"; +import { SdkExecutor } from "../../src/tools/executors/sdk.js"; + +describe("SdkExecutor", () => { + it("should return false for canExecute when no client", () => { + const exec = new SdkExecutor(null, 5000); + expect(exec.canExecute("any-tool")).toBe(false); + }); + + it("should return false for canExecute when client lacks tool.invoke", () => { + const exec = new SdkExecutor({}, 5000); + expect(exec.canExecute("any-tool")).toBe(false); + }); + + it("should return false for canExecute when toolId not registered", () => { + const client = { tool: { invoke: async () => "ok" } }; + const exec = new SdkExecutor(client, 5000); + // No tool IDs set — should reject + expect(exec.canExecute("unknown-tool")).toBe(false); + }); + + it("should return true for canExecute when toolId is registered", () => { + const client = { tool: { invoke: async () => "ok" } }; + const exec = new SdkExecutor(client, 5000); + exec.setToolIds(["my-tool", "other-tool"]); + expect(exec.canExecute("my-tool")).toBe(true); + expect(exec.canExecute("other-tool")).toBe(true); + expect(exec.canExecute("nope")).toBe(false); + }); + + it("should execute and return string output", async () => { + const client = { tool: { invoke: async (_id: string, _args: any) => "hello world" } }; + const exec = new SdkExecutor(client, 5000); + exec.setToolIds(["test-tool"]); + + const result = await exec.execute("test-tool", {}); + expect(result.status).toBe("success"); + expect(result.output).toBe("hello world"); + }); + + it("should JSON-stringify non-string output", async () => { + const client = { tool: { invoke: async () => ({ key: "value" }) } }; + const exec = new SdkExecutor(client, 5000); + exec.setToolIds(["test-tool"]); + + const result = await exec.execute("test-tool", {}); + expect(result.status).toBe("success"); + expect(result.output).toBe('{"key":"value"}'); + }); + + it("should return error when invoke throws", async () => { + const client = { tool: { invoke: async () => { throw new Error("sdk failure"); } } }; + const exec = new SdkExecutor(client, 5000); + exec.setToolIds(["test-tool"]); + + const result = await exec.execute("test-tool", {}); + expect(result.status).toBe("error"); + expect(result.error).toContain("sdk failure"); + }); + + it("should return error on timeout", async () => { + const client = { + tool: { + invoke: async () => new Promise((resolve) => setTimeout(() => resolve("late"), 10000)) + } + }; + const exec = new SdkExecutor(client, 50); // 50ms timeout + exec.setToolIds(["test-tool"]); + + const result = await exec.execute("test-tool", {}); + expect(result.status).toBe("error"); + expect(result.error).toContain("timeout"); + }); + + it("should return error when canExecute is false", async () => { + const exec = new SdkExecutor(null, 5000); + const result = await exec.execute("any-tool", {}); + expect(result.status).toBe("error"); + expect(result.error).toContain("unavailable"); + }); +}); From f9e2b08ee903792739f3d3d912ffde6855b354b8 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Mon, 9 Feb 2026 18:55:10 +1100 Subject: [PATCH 4/5] ci: add GitHub Actions workflow for testing --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e6ddf7e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install + + - run: bun run build + + - name: Run tests + run: | + bun test \ + tests/tools/defaults.test.ts \ + tests/tools/executor-chain.test.ts \ + tests/tools/sdk-executor.test.ts \ + tests/tools/mcp-executor.test.ts \ + tests/tools/skills.test.ts \ + tests/tools/registry.test.ts \ + tests/integration/comprehensive.test.ts \ + tests/integration/tools-router.integration.test.ts \ + tests/unit/proxy/prompt-builder.test.ts \ + tests/unit/plugin.test.ts \ + tests/unit/plugin-tools-hook.test.ts \ + tests/unit/plugin-config.test.ts \ + tests/unit/auth.test.ts \ + tests/unit/streaming/line-buffer.test.ts \ + tests/unit/streaming/parser.test.ts \ + tests/unit/streaming/types.test.ts \ + tests/unit/streaming/delta-tracker.test.ts \ + tests/competitive/edge.test.ts From 571c890bfc9a5f109819e82af0238dec35ce7ee2 Mon Sep 17 00:00:00 2001 From: Nomadcxx Date: Mon, 9 Feb 2026 23:21:49 +1100 Subject: [PATCH 5/5] fix: FORWARD_TOOL_CALLS default regression and add injection attack tests --- src/plugin.ts | 2 +- tests/tools/defaults.test.ts | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/plugin.ts b/src/plugin.ts index 1828327..75373d4 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -50,7 +50,7 @@ function getGlobalKey(): string { const FORCE_TOOL_MODE = process.env.CURSOR_ACP_FORCE !== "false"; const EMIT_TOOL_UPDATES = process.env.CURSOR_ACP_EMIT_TOOL_UPDATES === "true"; -const FORWARD_TOOL_CALLS = process.env.CURSOR_ACP_FORWARD_TOOL_CALLS === "true"; +const FORWARD_TOOL_CALLS = process.env.CURSOR_ACP_FORWARD_TOOL_CALLS !== "false"; function createChatCompletionResponse(model: string, content: string, reasoningContent?: string) { const message: { role: "assistant"; content: string; reasoning_content?: string } = { diff --git a/tests/tools/defaults.test.ts b/tests/tools/defaults.test.ts index 0d95380..6cc068b 100644 --- a/tests/tools/defaults.test.ts +++ b/tests/tools/defaults.test.ts @@ -163,6 +163,63 @@ describe("Default Tools", () => { fs.unlinkSync(tmpFile); }); + it("should prevent grep command injection via pattern", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + + const fs = await import("fs"); + const tmpFile = `/tmp/test-inject-${Date.now()}.txt`; + fs.writeFileSync(tmpFile, "safe content\n", "utf-8"); + + const attacks = [ + "test; echo INJECTED", + "test && echo INJECTED", + "test || echo INJECTED", + "test | cat /etc/hostname", + "test $(echo INJECTED)", + "test `echo INJECTED`", + ]; + + for (const malicious of attacks) { + const result = await executeWithChain([executor], "grep", { + pattern: malicious, + path: tmpFile + }); + // execFile passes pattern as argument, not through shell + // So these should find no matches, not execute commands + expect(result.status).toBe("success"); + expect(result.output).toBe("No matches found"); + } + + fs.unlinkSync(tmpFile); + }); + + it("should prevent glob command injection via pattern", async () => { + const registry = new ToolRegistry(); + registerDefaultTools(registry); + const executor = new LocalExecutor(registry); + + const attacks = [ + "*.ts; echo INJECTED", + "*.ts && echo INJECTED", + "$(echo INJECTED).ts", + ]; + + for (const malicious of attacks) { + const result = await executeWithChain([executor], "glob", { + pattern: malicious, + path: "/tmp" + }); + // execFile passes pattern as -name argument, not through shell + // find may error on special chars or return no matches — both are safe + if (result.status === "success") { + expect(result.output).not.toContain("INJECTED"); + } + // Either way, no command injection occurred + } + }); + it("should execute glob tool safely", async () => { const registry = new ToolRegistry(); registerDefaultTools(registry);