Skip to content
Closed
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
54 changes: 52 additions & 2 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export namespace Agent {
edit: Config.Permission,
bash: z.record(z.string(), Config.Permission),
webfetch: Config.Permission.optional(),
external_files: z.record(z.string(), Config.Permission),
}),
model: z
.object({
Expand All @@ -45,6 +46,9 @@ export namespace Agent {
"*": "allow",
},
webfetch: "allow",
external_files: {
"*": "ask",
},
}
const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})

Expand Down Expand Up @@ -90,6 +94,9 @@ export namespace Agent {
"*": "ask",
},
webfetch: "allow",
external_files: {
"*": "ask",
},
},
cfg.permission ?? {},
)
Expand Down Expand Up @@ -143,7 +150,18 @@ export namespace Agent {
tools: {},
builtIn: false,
}
const { name, model, prompt, tools, description, temperature, top_p, mode, permission, ...extra } = value
const {
name,
model,
prompt,
tools,
description,
temperature,
top_p,
mode,
permission,
...extra
} = value
item.options = {
...item.options,
...extra,
Expand Down Expand Up @@ -212,7 +230,10 @@ export namespace Agent {
}
}

function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
function mergeAgentPermissions(
basePermission: any,
overridePermission: any,
): Agent.Info["permission"] {
if (typeof basePermission.bash === "string") {
basePermission.bash = {
"*": basePermission.bash,
Expand All @@ -223,6 +244,18 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
"*": overridePermission.bash,
}
}

if (typeof basePermission.external_files === "string") {
basePermission.external_files = {
"*": basePermission.external_files,
}
}
if (typeof overridePermission.external_files === "string") {
overridePermission.external_files = {
"*": overridePermission.external_files,
}
}

const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
let mergedBash
if (merged.bash) {
Expand All @@ -240,10 +273,27 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
}
}

let mergedExternalFiles
if (merged.external_files) {
if (typeof merged.external_files === "string") {
mergedExternalFiles = {
"*": merged.external_files,
}
} else if (typeof merged.external_files === "object") {
mergedExternalFiles = mergeDeep(
{
"*": "ask",
},
merged.external_files,
)
}
}

const result: Agent.Info["permission"] = {
edit: merged.edit ?? "allow",
webfetch: merged.webfetch ?? "allow",
bash: mergedBash ?? { "*": "allow" },
external_files: mergedExternalFiles ?? { "*": "ask" },
}

return result
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ export namespace Config {
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
external_files: z.union([Permission, z.record(z.string(), Permission)]).optional(),
})
.optional(),
})
Expand Down Expand Up @@ -706,6 +707,7 @@ export namespace Config {
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
external_files: z.union([Permission, z.record(z.string(), Permission)]).optional(),
})
.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
Expand Down
61 changes: 54 additions & 7 deletions packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,21 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { Snapshot } from "@/snapshot"
import { Wildcard } from "../util/wildcard"
import { expandPermissionPatterns } from "../util/permission"

export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
newString: z
.string()
.describe("The text to replace it with (must be different from oldString)"),
replaceAll: z
.boolean()
.optional()
.describe("Replace all occurrences of oldString (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
Expand All @@ -35,9 +42,38 @@ export const EditTool = Tool.define("edit", {
throw new Error("oldString and newString must be different")
}

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)) {
throw new Error(`File ${filePath} is not in the current working directory`)
const agent = await Agent.get(ctx.agent)
const permissions =
typeof agent.permission.external_files === "string"
? { "*": agent.permission.external_files }
: agent.permission.external_files

const expandedPermissions = expandPermissionPatterns(permissions)
const action = Wildcard.all(filePath, expandedPermissions)

if (action === "deny") {
throw new Error(`File ${filePath} is not in the current working directory`)
}

if (action === "ask") {
await Permission.ask({
type: "external_files",
pattern: filePath,
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Edit file outside working directory: ${path.relative(Instance.worktree, filePath)}`,
metadata: {
filepath: path.relative(Instance.worktree, filePath),
operation: "edit",
},
})
}
}

const agent = await Agent.get(ctx.agent)
Expand Down Expand Up @@ -160,7 +196,11 @@ function levenshtein(a: string, b: string): number {
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
)
}
}
return matrix[a.length][b.length]
Expand Down Expand Up @@ -362,7 +402,9 @@ export const WhitespaceNormalizedReplacer: Replacer = function* (content, find)
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
const pattern = words
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
Expand Down Expand Up @@ -600,7 +642,12 @@ export function trimDiff(diff: string): string {
return trimmedLines.join("\n")
}

export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
export function replace(
content: string,
oldString: string,
newString: string,
replaceAll = false,
): string {
if (oldString === newString) {
throw new Error("oldString and newString must be different")
}
Expand Down
29 changes: 28 additions & 1 deletion packages/opencode/src/tool/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { Agent } from "../agent/agent"
import { Patch } from "../patch"
import { Filesystem } from "../util/filesystem"
import { createTwoFilesPatch } from "diff"
import { Wildcard } from "../util/wildcard"
import { expandPermissionPatterns } from "../util/permission"

const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
Expand Down Expand Up @@ -54,7 +56,32 @@ export const PatchTool = Tool.define("patch", {
const filePath = path.resolve(Instance.directory, hunk.path)

if (!Filesystem.contains(Instance.directory, filePath)) {
throw new Error(`File ${filePath} is not in the current working directory`)
const permissions =
typeof agent.permission.external_files === "string"
? { "*": agent.permission.external_files }
: agent.permission.external_files

const expandedPermissions = expandPermissionPatterns(permissions)
const action = Wildcard.all(filePath, expandedPermissions)

if (action === "deny") {
throw new Error(`File ${filePath} is not in the current working directory`)
}

if (action === "ask") {
await Permission.ask({
type: "external_files",
pattern: filePath,
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `${hunk.type === "add" ? "Create" : hunk.type === "update" ? "Edit" : "Delete"} file outside working directory: ${path.relative(Instance.worktree, filePath)}`,
metadata: {
filepath: path.relative(Instance.worktree, filePath),
operation: hunk.type,
},
})
}
}

switch (hunk.type) {
Expand Down
44 changes: 40 additions & 4 deletions packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Provider } from "../provider/provider"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { Permission } from "../permission"
import { Wildcard } from "../util/wildcard"
import { expandPermissionPatterns } from "../util/permission"

const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
Expand All @@ -17,7 +21,10 @@ export const ReadTool = Tool.define("read", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The path to the file to read"),
offset: z.coerce.number().describe("The line number to start reading from (0-based)").optional(),
offset: z.coerce
.number()
.describe("The line number to start reading from (0-based)")
.optional(),
limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
Expand All @@ -28,7 +35,33 @@ export const ReadTool = Tool.define("read", {
const title = path.relative(Instance.worktree, filepath)

if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
throw new Error(`File ${filepath} is not in the current working directory`)
const agent = await Agent.get(ctx.agent)
const permissions =
typeof agent.permission.external_files === "string"
? { "*": agent.permission.external_files }
: agent.permission.external_files

const expandedPermissions = expandPermissionPatterns(permissions)
const action = Wildcard.all(filepath, expandedPermissions)

if (action === "deny") {
throw new Error(`File ${filepath} is not in the current working directory`)
}

if (action === "ask") {
await Permission.ask({
type: "external_files",
pattern: filepath,
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Read file outside working directory: ${path.relative(Instance.worktree, filepath)}`,
metadata: {
filepath: path.relative(Instance.worktree, filepath),
operation: "read",
},
})
}
}

const file = Bun.file(filepath)
Expand All @@ -40,13 +73,16 @@ export const ReadTool = Tool.define("read", {
const suggestions = dirEntries
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
entry.toLowerCase().includes(base.toLowerCase()) ||
base.toLowerCase().includes(entry.toLowerCase()),
)
.map((entry) => path.join(dir, entry))
.slice(0, 3)

if (suggestions.length > 0) {
throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
throw new Error(
`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
)
}

throw new Error(`File not found: ${filepath}`)
Expand Down
Loading