core: extract external directory validation to shared utility to reduce code duplication across tools

This commit is contained in:
Dax Raad
2026-01-10 18:49:36 -05:00
parent 76386f5cfc
commit f94ee5ce90
10 changed files with 186 additions and 49 deletions

View File

@@ -15,6 +15,7 @@ import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectory } from "./external-directory"
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)
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,
},
})
}
await assertExternalDirectory(ctx, filePath)
let diff = ""
let contentOld = ""

View 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,
},
})
}

View File

@@ -4,6 +4,7 @@ import { Tool } from "./tool"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
export const GlobTool = Tool.define("glob", {
description: DESCRIPTION,
@@ -29,6 +30,7 @@ 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 = []

View File

@@ -4,6 +4,8 @@ import { Ripgrep } from "../file/ripgrep"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectory } from "./external-directory"
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 args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern]

View File

@@ -4,6 +4,7 @@ import * as path from "path"
import DESCRIPTION from "./ls.txt"
import { Instance } from "../project/instance"
import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectory } from "./external-directory"
export const IGNORE_PATTERNS = [
"node_modules/",
@@ -42,6 +43,7 @@ export const ListTool = Tool.define("list", {
}),
async execute(params, ctx) {
const searchPath = path.resolve(Instance.directory, params.path || ".")
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
await ctx.ask({
permission: "list",

View File

@@ -5,6 +5,7 @@ import { LSP } from "../lsp"
import DESCRIPTION from "./lsp.txt"
import { Instance } from "../project/instance"
import { pathToFileURL } from "url"
import { assertExternalDirectory } from "./external-directory"
const operations = [
"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)"),
}),
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({
permission: "lsp",
patterns: ["*"],
always: ["*"],
metadata: {},
})
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
const uri = pathToFileURL(file).href
const position = {
file,

View File

@@ -7,8 +7,8 @@ import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Patch } from "../patch"
import { Filesystem } from "../util/filesystem"
import { createTwoFilesPatch } from "diff"
import { assertExternalDirectory } from "./external-directory"
const PatchParams = z.object({
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) {
const filePath = path.resolve(Instance.directory, hunk.path)
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,
},
})
}
await assertExternalDirectory(ctx, filePath)
switch (hunk.type) {
case "add":
@@ -103,12 +91,15 @@ export const PatchTool = Tool.define("patch", {
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({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath: hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined,
movePath,
})
totalDiff += diff + "\n"

View File

@@ -5,9 +5,9 @@ import { Tool } from "./tool"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { assertExternalDirectory } from "./external-directory"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -27,18 +27,9 @@ export const ReadTool = Tool.define("read", {
}
const title = path.relative(Instance.worktree, filepath)
if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir],
always: [parentDir + "/*"],
metadata: {
filepath,
parentDir,
},
})
}
await assertExternalDirectory(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
})
await ctx.ask({
permission: "read",

View File

@@ -10,6 +10,7 @@ import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { trimDiff } from "./edit"
import { assertExternalDirectory } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
@@ -22,12 +23,7 @@ export const WriteTool = Tool.define("write", {
}),
async execute(params, ctx) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
/* TODO
if (!Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
...
}
*/
await assertExternalDirectory(ctx, filepath)
const file = Bun.file(filepath)
const exists = await file.exists()

View 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)
})
})