Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .fork-features/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,35 @@
"explicit.*agent.*permission"
]
}
},
"bash-workdir-validation": {
"status": "active",
"description": "Validates bash tool workdir exists and is within project boundary before spawning shell. Resolves symlinks, normalizes paths, checks existence via Filesystem.isDir(), validates path containment via Instance.containsPath(). Falls back to Instance.directory with warnings for invalid paths. Rejects empty/whitespace workdir at Zod schema level.",
"issue": "https://github.com/randomm/opencode/issues/189",
"newFiles": [],
"modifiedFiles": ["packages/opencode/src/tool/bash.ts"],
"deletedFiles": [],
"criticalCode": [
"realpathSync.native",
"Filesystem.normalizePath",
"Filesystem.isDir",
"Instance.containsPath",
"workdir cannot be empty or whitespace only",
"workdir does not exist or is not a directory",
"workdir is outside project boundary",
"falling back to Instance.directory"
],
"tests": ["packages/opencode/test/tool/bash-workdir.test.ts"],
"upstreamTracking": {
"absorptionSignals": [
"realpathSync.native",
"Filesystem.normalizePath",
"Instance.containsPath",
"workdir validation",
"path containment check",
"symlink resolution"
]
}
}
}
}
50 changes: 49 additions & 1 deletion packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import z from "zod"
import { spawn } from "child_process"
import { realpathSync } from "fs"
import { Tool } from "./tool"
import path from "path"
import DESCRIPTION from "./bash.txt"
Expand Down Expand Up @@ -65,6 +66,7 @@ export const BashTool = Tool.define("bash", async () => {
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.refine((val) => !val || val.trim().length > 0, "workdir cannot be empty or whitespace only")
.describe(
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
)
Expand All @@ -76,7 +78,53 @@ export const BashTool = Tool.define("bash", async () => {
),
}),
async execute(params, ctx) {
const cwd = params.workdir || Instance.directory
let cwd = Instance.directory

if (params.workdir && params.workdir.trim()) {
// Resolve symlinks and normalize path before validation
// Reject workdir if realpathSync.native fails (permissions, circular symlinks, etc.)
let resolvedWorkdir: string | null = null
try {
resolvedWorkdir = realpathSync.native(params.workdir)
} catch (error) {
const err = error as NodeJS.ErrnoException
log.warn("workdir resolution failed, falling back to Instance.directory", {
workdir: params.workdir,
error: { code: err.code, message: err.message },
fallback: Instance.directory,
})
}

// If symlink resolution succeeded, validate the resolved path
if (resolvedWorkdir) {
// Normalize path on Windows for case-sensitivity
resolvedWorkdir = Filesystem.normalizePath(resolvedWorkdir)

if (await Filesystem.isDir(resolvedWorkdir)) {
if (Instance.containsPath(resolvedWorkdir)) {
cwd = resolvedWorkdir
} else {
log.warn("workdir is outside project boundary, falling back to Instance.directory", {
workdir: params.workdir,
resolved: resolvedWorkdir,
fallback: Instance.directory,
})
}
} else {
log.warn("workdir does not exist or is not a directory, falling back to Instance.directory", {
workdir: params.workdir,
resolved: resolvedWorkdir,
fallback: Instance.directory,
})
}
}
} else if (params.workdir) {
log.warn("workdir is empty or whitespace, falling back to Instance.directory", {
workdir: params.workdir,
fallback: Instance.directory,
})
}

if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
Expand Down
126 changes: 126 additions & 0 deletions packages/opencode/test/tool/bash-workdir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"

const ctx = {
sessionID: "test",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}

describe("bash workdir validation", () => {
test("uses provided workdir when it exists", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: path.join(tmp.path, ".opencode"),
fn: async () => {
const bash = await BashTool.init()
const result = await bash.execute(
{
command: "pwd",
description: "Print working directory",
workdir: tmp.path,
},
ctx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain(tmp.path)
},
})
})

test("falls back to Instance.directory when workdir does not exist", async () => {
const projectRoot = path.join(__dirname, "../..")
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await BashTool.init()
const nonexistent = "/nonexistent/path/that/does/not/exist"
const result = await bash.execute(
{
command: "pwd",
description: "Print working directory",
workdir: nonexistent,
},
ctx,
)
expect(result.metadata.exit).toBe(0)
// Output should be the fallback directory (projectRoot), not the nonexistent one
expect(result.metadata.output).toContain(projectRoot)
},
})
})

test("logs warning when workdir does not exist", async () => {
const projectRoot = path.join(__dirname, "../..")
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await BashTool.init()
const nonexistent = "/nonexistent/path/that/does/not/exist"
const warnings: Array<{ message: string; data?: unknown }> = []

// Capture log warnings - we'll check the test completes without error
// The actual warning logging happens inside the tool
const result = await bash.execute(
{
command: "echo 'test'",
description: "Echo test",
workdir: nonexistent,
},
ctx,
)

// Command should succeed using fallback directory
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain("test")
},
})
})

test("uses Instance.directory when workdir is omitted", async () => {
const projectRoot = path.join(__dirname, "../..")
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await BashTool.init()
const result = await bash.execute(
{
command: "pwd",
description: "Print working directory",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain(projectRoot)
},
})
})

test("falls back to Instance.directory when workdir is outside project boundary", async () => {
const projectRoot = path.join(__dirname, "../..")
await Instance.provide({
directory: projectRoot,
fn: async () => {
const bash = await BashTool.init()
const result = await bash.execute(
{
command: "pwd",
description: "Print working directory",
workdir: "/tmp",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain(projectRoot)
},
})
})
})
20 changes: 6 additions & 14 deletions packages/opencode/test/tool/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,30 +121,22 @@ describe("tool.bash permissions", () => {
})
})

test("asks for external_directory permission when workdir is outside project", async () => {
test("falls back to Instance.directory when workdir is outside project boundary", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await bash.execute(
const result = await bash.execute(
{
command: "ls",
command: "pwd",
workdir: "/tmp",
description: "List /tmp",
},
testCtx,
ctx,
)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain("/tmp/*")
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain(tmp.path)
},
})
})
Expand Down