core: extract external directory validation to shared utility to reduce code duplication across tools
This commit is contained in:
@@ -15,6 +15,7 @@ import { FileTime } from "../file/time"
|
|||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
|
||||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||||
|
|
||||||
@@ -40,18 +41,7 @@ export const EditTool = Tool.define("edit", {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||||
if (!Filesystem.contains(Instance.directory, filePath)) {
|
await assertExternalDirectory(ctx, filePath)
|
||||||
const parentDir = path.dirname(filePath)
|
|
||||||
await ctx.ask({
|
|
||||||
permission: "external_directory",
|
|
||||||
patterns: [parentDir, path.join(parentDir, "*")],
|
|
||||||
always: [parentDir + "/*"],
|
|
||||||
metadata: {
|
|
||||||
filepath: filePath,
|
|
||||||
parentDir,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let diff = ""
|
let diff = ""
|
||||||
let contentOld = ""
|
let contentOld = ""
|
||||||
|
|||||||
33
packages/opencode/src/tool/external-directory.ts
Normal file
33
packages/opencode/src/tool/external-directory.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import path from "path"
|
||||||
|
import type { Tool } from "./tool"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
|
||||||
|
type Kind = "file" | "directory"
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
bypass?: boolean
|
||||||
|
kind?: Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
if (options?.bypass) return
|
||||||
|
|
||||||
|
if (Filesystem.contains(Instance.directory, target)) return
|
||||||
|
|
||||||
|
const kind = options?.kind ?? "file"
|
||||||
|
const parentDir = kind === "directory" ? target : path.dirname(target)
|
||||||
|
const glob = path.join(parentDir, "*")
|
||||||
|
|
||||||
|
await ctx.ask({
|
||||||
|
permission: "external_directory",
|
||||||
|
patterns: [glob],
|
||||||
|
always: [glob],
|
||||||
|
metadata: {
|
||||||
|
filepath: target,
|
||||||
|
parentDir,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Tool } from "./tool"
|
|||||||
import DESCRIPTION from "./glob.txt"
|
import DESCRIPTION from "./glob.txt"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
|
||||||
export const GlobTool = Tool.define("glob", {
|
export const GlobTool = Tool.define("glob", {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -29,6 +30,7 @@ export const GlobTool = Tool.define("glob", {
|
|||||||
|
|
||||||
let search = params.path ?? Instance.directory
|
let search = params.path ?? Instance.directory
|
||||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
||||||
|
await assertExternalDirectory(ctx, search, { kind: "directory" })
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
const files = []
|
const files = []
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Ripgrep } from "../file/ripgrep"
|
|||||||
|
|
||||||
import DESCRIPTION from "./grep.txt"
|
import DESCRIPTION from "./grep.txt"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
import path from "path"
|
||||||
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
|
||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE_LENGTH = 2000
|
||||||
|
|
||||||
@@ -30,7 +32,9 @@ export const GrepTool = Tool.define("grep", {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchPath = params.path || Instance.directory
|
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 rgPath = await Ripgrep.filepath()
|
||||||
const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern]
|
const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as path from "path"
|
|||||||
import DESCRIPTION from "./ls.txt"
|
import DESCRIPTION from "./ls.txt"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
|
||||||
export const IGNORE_PATTERNS = [
|
export const IGNORE_PATTERNS = [
|
||||||
"node_modules/",
|
"node_modules/",
|
||||||
@@ -42,6 +43,7 @@ export const ListTool = Tool.define("list", {
|
|||||||
}),
|
}),
|
||||||
async execute(params, ctx) {
|
async execute(params, ctx) {
|
||||||
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
||||||
|
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
||||||
|
|
||||||
await ctx.ask({
|
await ctx.ask({
|
||||||
permission: "list",
|
permission: "list",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LSP } from "../lsp"
|
|||||||
import DESCRIPTION from "./lsp.txt"
|
import DESCRIPTION from "./lsp.txt"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
|
||||||
const operations = [
|
const operations = [
|
||||||
"goToDefinition",
|
"goToDefinition",
|
||||||
@@ -27,14 +28,15 @@ export const LspTool = Tool.define("lsp", {
|
|||||||
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
|
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
|
||||||
}),
|
}),
|
||||||
execute: async (args, ctx) => {
|
execute: async (args, ctx) => {
|
||||||
|
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
|
||||||
|
await assertExternalDirectory(ctx, file)
|
||||||
|
|
||||||
await ctx.ask({
|
await ctx.ask({
|
||||||
permission: "lsp",
|
permission: "lsp",
|
||||||
patterns: ["*"],
|
patterns: ["*"],
|
||||||
always: ["*"],
|
always: ["*"],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
|
|
||||||
const uri = pathToFileURL(file).href
|
const uri = pathToFileURL(file).href
|
||||||
const position = {
|
const position = {
|
||||||
file,
|
file,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { Bus } from "../bus"
|
|||||||
import { FileWatcher } from "../file/watcher"
|
import { FileWatcher } from "../file/watcher"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Patch } from "../patch"
|
import { Patch } from "../patch"
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { createTwoFilesPatch } from "diff"
|
import { createTwoFilesPatch } from "diff"
|
||||||
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
|
||||||
const PatchParams = z.object({
|
const PatchParams = z.object({
|
||||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||||
@@ -49,19 +49,7 @@ export const PatchTool = Tool.define("patch", {
|
|||||||
|
|
||||||
for (const hunk of hunks) {
|
for (const hunk of hunks) {
|
||||||
const filePath = path.resolve(Instance.directory, hunk.path)
|
const filePath = path.resolve(Instance.directory, hunk.path)
|
||||||
|
await assertExternalDirectory(ctx, filePath)
|
||||||
if (!Filesystem.contains(Instance.directory, filePath)) {
|
|
||||||
const parentDir = path.dirname(filePath)
|
|
||||||
await ctx.ask({
|
|
||||||
permission: "external_directory",
|
|
||||||
patterns: [parentDir, path.join(parentDir, "*")],
|
|
||||||
always: [parentDir + "/*"],
|
|
||||||
metadata: {
|
|
||||||
filepath: filePath,
|
|
||||||
parentDir,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (hunk.type) {
|
switch (hunk.type) {
|
||||||
case "add":
|
case "add":
|
||||||
@@ -103,12 +91,15 @@ export const PatchTool = Tool.define("patch", {
|
|||||||
|
|
||||||
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
||||||
|
|
||||||
|
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
|
||||||
|
await assertExternalDirectory(ctx, movePath)
|
||||||
|
|
||||||
fileChanges.push({
|
fileChanges.push({
|
||||||
filePath,
|
filePath,
|
||||||
oldContent,
|
oldContent,
|
||||||
newContent,
|
newContent,
|
||||||
type: hunk.move_path ? "move" : "update",
|
type: hunk.move_path ? "move" : "update",
|
||||||
movePath: hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined,
|
movePath,
|
||||||
})
|
})
|
||||||
|
|
||||||
totalDiff += diff + "\n"
|
totalDiff += diff + "\n"
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { Tool } from "./tool"
|
|||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { FileTime } from "../file/time"
|
import { FileTime } from "../file/time"
|
||||||
import DESCRIPTION from "./read.txt"
|
import DESCRIPTION from "./read.txt"
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
|
||||||
const DEFAULT_READ_LIMIT = 2000
|
const DEFAULT_READ_LIMIT = 2000
|
||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE_LENGTH = 2000
|
||||||
@@ -27,18 +27,9 @@ export const ReadTool = Tool.define("read", {
|
|||||||
}
|
}
|
||||||
const title = path.relative(Instance.worktree, filepath)
|
const title = path.relative(Instance.worktree, filepath)
|
||||||
|
|
||||||
if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
|
await assertExternalDirectory(ctx, filepath, {
|
||||||
const parentDir = path.dirname(filepath)
|
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
||||||
await ctx.ask({
|
})
|
||||||
permission: "external_directory",
|
|
||||||
patterns: [parentDir],
|
|
||||||
always: [parentDir + "/*"],
|
|
||||||
metadata: {
|
|
||||||
filepath,
|
|
||||||
parentDir,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.ask({
|
await ctx.ask({
|
||||||
permission: "read",
|
permission: "read",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FileTime } from "../file/time"
|
|||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { trimDiff } from "./edit"
|
import { trimDiff } from "./edit"
|
||||||
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
|
||||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||||
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
|
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
|
||||||
@@ -22,12 +23,7 @@ export const WriteTool = Tool.define("write", {
|
|||||||
}),
|
}),
|
||||||
async execute(params, ctx) {
|
async execute(params, ctx) {
|
||||||
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||||
/* TODO
|
await assertExternalDirectory(ctx, filepath)
|
||||||
if (!Filesystem.contains(Instance.directory, filepath)) {
|
|
||||||
const parentDir = path.dirname(filepath)
|
|
||||||
...
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const file = Bun.file(filepath)
|
const file = Bun.file(filepath)
|
||||||
const exists = await file.exists()
|
const exists = await file.exists()
|
||||||
|
|||||||
126
packages/opencode/test/tool/external-directory.test.ts
Normal file
126
packages/opencode/test/tool/external-directory.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import type { Tool } from "../../src/tool/tool"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||||
|
import type { PermissionNext } from "../../src/permission/next"
|
||||||
|
|
||||||
|
const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||||
|
sessionID: "test",
|
||||||
|
messageID: "",
|
||||||
|
callID: "",
|
||||||
|
agent: "build",
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
metadata: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tool.assertExternalDirectory", () => {
|
||||||
|
test("no-ops for empty target", async () => {
|
||||||
|
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||||
|
const ctx: Tool.Context = {
|
||||||
|
...baseCtx,
|
||||||
|
ask: async (req) => {
|
||||||
|
requests.push(req)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: "/tmp",
|
||||||
|
fn: async () => {
|
||||||
|
await assertExternalDirectory(ctx)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requests.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("no-ops for paths inside Instance.directory", async () => {
|
||||||
|
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||||
|
const ctx: Tool.Context = {
|
||||||
|
...baseCtx,
|
||||||
|
ask: async (req) => {
|
||||||
|
requests.push(req)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: "/tmp/project",
|
||||||
|
fn: async () => {
|
||||||
|
await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requests.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("asks with a single canonical glob", async () => {
|
||||||
|
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||||
|
const ctx: Tool.Context = {
|
||||||
|
...baseCtx,
|
||||||
|
ask: async (req) => {
|
||||||
|
requests.push(req)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const target = "/tmp/outside/file.txt"
|
||||||
|
const expected = path.join(path.dirname(target), "*")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory,
|
||||||
|
fn: async () => {
|
||||||
|
await assertExternalDirectory(ctx, target)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = requests.find((r) => r.permission === "external_directory")
|
||||||
|
expect(req).toBeDefined()
|
||||||
|
expect(req!.patterns).toEqual([expected])
|
||||||
|
expect(req!.always).toEqual([expected])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses target directory when kind=directory", async () => {
|
||||||
|
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||||
|
const ctx: Tool.Context = {
|
||||||
|
...baseCtx,
|
||||||
|
ask: async (req) => {
|
||||||
|
requests.push(req)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const target = "/tmp/outside"
|
||||||
|
const expected = path.join(target, "*")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory,
|
||||||
|
fn: async () => {
|
||||||
|
await assertExternalDirectory(ctx, target, { kind: "directory" })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = requests.find((r) => r.permission === "external_directory")
|
||||||
|
expect(req).toBeDefined()
|
||||||
|
expect(req!.patterns).toEqual([expected])
|
||||||
|
expect(req!.always).toEqual([expected])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips prompting when bypass=true", async () => {
|
||||||
|
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
|
||||||
|
const ctx: Tool.Context = {
|
||||||
|
...baseCtx,
|
||||||
|
ask: async (req) => {
|
||||||
|
requests.push(req)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: "/tmp/project",
|
||||||
|
fn: async () => {
|
||||||
|
await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requests.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user