diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 4cdb549096a..c10aeaf6a02 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -40,6 +40,8 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") + export const OPENCODE_STRICT_PATH_SANDBOX = truthy("OPENCODE_STRICT_PATH_SANDBOX") + function truthy(key: string) { const value = process.env[key]?.toLowerCase() return value === "true" || value === "1" diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index dda57f6ee1b..938bb49abbc 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -4,7 +4,8 @@ import { Tool } from "./tool" import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" -import { assertExternalDirectory } from "./external-directory" +import { Filesystem } from "../util/filesystem" +import { Flag } from "../flag/flag" export const GlobTool = Tool.define("glob", { description: DESCRIPTION, @@ -18,6 +19,22 @@ export const GlobTool = Tool.define("glob", { ), }), async execute(params, ctx) { + let search = params.path ?? Instance.directory + search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + + const isExternal = !Filesystem.contains(Instance.directory, search) + if (isExternal) { + if (Flag.OPENCODE_STRICT_PATH_SANDBOX) { + throw new Error(`Search path "${search}" is outside the project directory. Searches must be within: ${Instance.directory}`) + } + await ctx.ask({ + permission: "external_directory", + patterns: [search], + always: [path.dirname(search) + "/*"], + metadata: { path: search }, + }) + } + await ctx.ask({ permission: "glob", patterns: [params.pattern], @@ -28,10 +45,6 @@ export const GlobTool = Tool.define("glob", { }, }) - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) - await assertExternalDirectory(ctx, search, { kind: "directory" }) - const limit = 100 const files = [] let truncated = false diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 097dedf4aaf..b631afdc3dd 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,11 +1,12 @@ import z from "zod" +import path from "path" import { Tool } from "./tool" import { Ripgrep } from "../file/ripgrep" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" -import path from "path" -import { assertExternalDirectory } from "./external-directory" +import { Filesystem } from "../util/filesystem" +import { Flag } from "../flag/flag" const MAX_LINE_LENGTH = 2000 @@ -21,6 +22,22 @@ export const GrepTool = Tool.define("grep", { throw new Error("pattern is required") } + let searchPath = params.path || Instance.directory + searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) + + const isExternal = !Filesystem.contains(Instance.directory, searchPath) + if (isExternal) { + if (Flag.OPENCODE_STRICT_PATH_SANDBOX) { + throw new Error(`Search path "${searchPath}" is outside the project directory. Searches must be within: ${Instance.directory}`) + } + await ctx.ask({ + permission: "external_directory", + patterns: [searchPath], + always: [path.dirname(searchPath) + "/*"], + metadata: { path: searchPath }, + }) + } + await ctx.ask({ permission: "grep", patterns: [params.pattern], @@ -32,20 +49,8 @@ export const GrepTool = Tool.define("grep", { }, }) - let searchPath = params.path ?? Instance.directory - searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) - await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) - const rgPath = await Ripgrep.filepath() - const args = [ - "-nH", - "--hidden", - "--follow", - "--no-messages", - "--field-match-separator=|", - "--regexp", - params.pattern, - ] + const args = ["-nH", "--field-match-separator=|", "--regexp", params.pattern] if (params.include) { args.push("--glob", params.include) } @@ -60,10 +65,7 @@ export const GrepTool = Tool.define("grep", { const errorOutput = await new Response(proc.stderr).text() const exitCode = await proc.exited - // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) - // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc. - // Only fail if exit code is 2 AND no output was produced - if (exitCode === 1 || (exitCode === 2 && !output.trim())) { + if (exitCode === 1) { return { title: params.pattern, metadata: { matches: 0, truncated: false }, @@ -71,12 +73,10 @@ export const GrepTool = Tool.define("grep", { } } - if (exitCode !== 0 && exitCode !== 2) { + if (exitCode !== 0) { throw new Error(`ripgrep failed: ${errorOutput}`) } - const hasErrors = exitCode === 2 - // Handle both Unix (\n) and Windows (\r\n) line endings const lines = output.trim().split(/\r?\n/) const matches = [] @@ -137,11 +137,6 @@ export const GrepTool = Tool.define("grep", { outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)") } - if (hasErrors) { - outputLines.push("") - outputLines.push("(Some paths were inaccessible and skipped)") - } - return { title: params.pattern, metadata: {