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
2 changes: 2 additions & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 18 additions & 5 deletions packages/opencode/src/tool/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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],
Expand All @@ -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
Expand Down
49 changes: 22 additions & 27 deletions packages/opencode/src/tool/grep.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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],
Expand All @@ -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)
}
Expand All @@ -60,23 +65,18 @@ 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 },
output: "No files found",
}
}

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 = []
Expand Down Expand Up @@ -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: {
Expand Down