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
7 changes: 7 additions & 0 deletions packages/opencode/src/project/instance-context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LocalContext } from "@/util/local-context"
import path from "path"
import { FSUtil } from "@opencode-ai/core/fs-util"
import type * as Project from "./project"

Expand All @@ -22,3 +23,9 @@ export function containsPath(filepath: string, ctx: InstanceContext): boolean {
if (ctx.worktree === "/") return false
return FSUtil.contains(ctx.worktree, filepath)
}

export function permissionPath(filepath: string, ctx: InstanceContext): string {
// A root worktree is the non-git sentinel, not a project boundary.
if (ctx.worktree === "/" || !containsPath(filepath, ctx)) return filepath
return path.relative(ctx.worktree, filepath)
}
3 changes: 2 additions & 1 deletion packages/opencode/src/tool/apply_patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Tool from "./tool"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstanceState } from "@/effect/instance-state"
import { permissionPath } from "@/project/instance-context"
import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectoryEffect } from "./external-directory"
Expand Down Expand Up @@ -202,7 +203,7 @@ export const ApplyPatchTool = Tool.define(
}))

// Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(instance.worktree, c.filePath).replaceAll("\\", "/"))
const relativePaths = fileChanges.map((c) => permissionPath(c.filePath, instance).replaceAll("\\", "/"))
yield* ctx.ask({
permission: "edit",
patterns: relativePaths,
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { permissionPath } from "@/project/instance-context"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory"
import { FSUtil } from "@opencode-ai/core/fs-util"
Expand Down Expand Up @@ -101,7 +102,7 @@ export const EditTool = Tool.define(
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(instance.worktree, filePath)],
patterns: [permissionPath(filePath, instance)],
always: ["*"],
metadata: {
filepath: filePath,
Expand Down Expand Up @@ -144,7 +145,7 @@ export const EditTool = Tool.define(
)
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(instance.worktree, filePath)],
patterns: [permissionPath(filePath, instance)],
always: ["*"],
metadata: {
filepath: filePath,
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { LSP } from "@/lsp/lsp"
import DESCRIPTION from "./read.txt"
import { InstanceState } from "@/effect/instance-state"
import { permissionPath } from "@/project/instance-context"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
Expand Down Expand Up @@ -254,7 +255,7 @@ export const ReadTool = Tool.define<

yield* ctx.ask({
permission: "read",
patterns: [path.relative(instance.worktree, filepath)],
patterns: [permissionPath(filepath, instance)],
always: ["*"],
metadata: {},
})
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Format } from "../format"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
import { permissionPath } from "@/project/instance-context"
import { trimDiff } from "./edit"
import { assertExternalDirectoryEffect } from "./external-directory"
import * as Bom from "@/util/bom"
Expand Down Expand Up @@ -53,7 +54,7 @@ export const WriteTool = Tool.define(
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(instance.worktree, filepath)],
patterns: [permissionPath(filepath, instance)],
always: ["*"],
metadata: {
filepath,
Expand Down
47 changes: 47 additions & 0 deletions packages/opencode/test/tool/apply_patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Truncate } from "@/tool/truncate"
import { TestInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Permission } from "../../src/permission"
import { testEffect } from "../lib/effect"

const it = testEffect(
Expand Down Expand Up @@ -72,6 +74,24 @@ const makeCtx = () => {
return { ctx, calls }
}

const makeRulesCtx = (ruleset: PermissionV1.Ruleset) => {
const calls: AskInput[] = []
const ctx: ToolCtx = {
...baseCtx,
ask: (input) =>
Effect.sync(() => {
calls.push(input)
if (input.permission === "external_directory") return
for (const pattern of input.patterns) {
const rule = Permission.evaluate(input.permission, pattern, ruleset)
if (rule.action !== "allow") throw new Error(`permission ${rule.action}: ${pattern}`)
}
}),
}

return { ctx, calls }
}

const readText = (filepath: string) => Effect.promise(() => fs.readFile(filepath, "utf-8"))
const writeText = (filepath: string, content: string) => Effect.promise(() => fs.writeFile(filepath, content, "utf-8"))
const makeDir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
Expand All @@ -85,6 +105,33 @@ const expectFailure = <A, E, R>(effect: Effect.Effect<A, E, R>, message?: string

const expectReadFailure = (filepath: string) => expectFailure(readText(filepath))

describe("tool.apply_patch permission paths", () => {
it.instance(
"checks absolute rules for every outside path in a multi-file patch",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const outer = yield* Effect.promise(() => fs.mkdtemp(path.join(path.dirname(test.directory), "opencode-outside-")))
const first = path.join(outer, "first.txt")
const second = path.join(outer, "second.txt")
const firstRelative = path.relative(test.directory, first).replaceAll("\\", "/")
const secondRelative = path.relative(test.directory, second).replaceAll("\\", "/")
const patchText = `*** Begin Patch\n*** Add File: ${firstRelative}\n+first\n*** Add File: ${secondRelative}\n+second\n*** End Patch`
const { ctx, calls } = makeRulesCtx(
Permission.fromConfig({ edit: { "*": "allow", [`${outer}/**`]: "deny" } }),
)

const exit = yield* execute({ patchText }, ctx).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
expect(calls.at(-1)?.patterns).toEqual([first, second].map((file) => file.replaceAll("\\", "/")))
expect(yield* Effect.promise(() => fs.stat(first).catch(() => undefined))).toBeUndefined()
expect(yield* Effect.promise(() => fs.stat(second).catch(() => undefined))).toBeUndefined()
yield* Effect.promise(() => fs.rm(outer, { recursive: true, force: true }))
}),
{ git: true },
)
})

describe("tool.apply_patch freeform", () => {
it.live("requires patchText", () =>
Effect.gen(function* () {
Expand Down
50 changes: 50 additions & 0 deletions packages/opencode/test/tool/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { afterEach, describe, expect } from "bun:test"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import path from "path"
import os from "os"
import * as fs from "fs/promises"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
Expand Down Expand Up @@ -148,6 +150,54 @@ const asks = () => {
}
}

const askWithRules = (ruleset: PermissionV1.Ruleset) => ({
...ctx,
ask: (input: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
if (input.permission === "external_directory") return
for (const pattern of input.patterns) {
const rule = Permission.evaluate(input.permission, pattern, ruleset)
if (rule.action !== "allow") throw new Error(`permission ${rule.action}: ${pattern}`)
}
}),
})

describe("tool.read permission paths", () => {
it.live("allows an outside-worktree absolute rule", () =>
Effect.gen(function* () {
const outer = yield* tmpdirScoped()
const dir = yield* tmpdirScoped({ git: true })
const filepath = path.join(outer, "allowed.txt")
yield* put(filepath, "allowed")

const result = yield* exec(
dir,
{ filePath: filepath },
askWithRules(Permission.fromConfig({ read: { [`${outer}/**`]: "allow" } })),
)
expect(result.output).toContain("allowed")
}),
)

it.live("expands a tilde rule for an outside-worktree home path", () =>
Effect.gen(function* () {
const outer = yield* Effect.promise(() => fs.mkdtemp(path.join(os.homedir(), "opencode-permission-")))
const dir = yield* tmpdirScoped({ git: true })
const filepath = path.join(outer, "home-file.txt")
yield* put(filepath, "home content")

const rule = `~/${path.relative(os.homedir(), outer).replaceAll("\\", "/")}/**`
const result = yield* exec(
dir,
{ filePath: filepath },
askWithRules(Permission.fromConfig({ read: { [rule]: "allow" } })),
)
expect(result.output).toContain("home content")
yield* Effect.promise(() => fs.rm(outer, { recursive: true, force: true }))
}),
)
})

describe("tool.read external_directory permission", () => {
it.live("allows reading absolute path inside project directory", () =>
Effect.gen(function* () {
Expand Down
72 changes: 72 additions & 0 deletions packages/opencode/test/tool/write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { Truncate } from "@/tool/truncate"
import { Tool } from "@/tool/tool"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Permission } from "../../src/permission"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
Expand Down Expand Up @@ -58,6 +60,76 @@ const run = Effect.fn("WriteToolTest.run")(function* (
return yield* tool.execute(args, next)
})

const askWithRules = (ruleset: PermissionV1.Ruleset) => ({
...ctx,
ask: (input: Parameters<Tool.Context["ask"]>[0]) =>
Effect.sync(() => {
if (input.permission === "external_directory") return
for (const pattern of input.patterns) {
const rule = Permission.evaluate(input.permission, pattern, ruleset)
if (rule.action !== "allow") throw new Error(`permission ${rule.action}: ${pattern}`)
}
}),
})

describe("tool.write permission paths", () => {
it.instance("matches an inside-worktree relative rule", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "src", "inside.txt")
yield* run(
{ filePath: filepath, content: "inside" },
askWithRules(Permission.fromConfig({ edit: { "src/**": "allow" } })),
)
expect(yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))).toBe("inside")
}),
{ git: true },
)

it.instance("allows an outside-worktree absolute rule", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const outer = yield* Effect.promise(() => fs.mkdtemp(path.join(path.dirname(test.directory), "opencode-outside-")))
const filepath = path.join(outer, "allowed.txt")
yield* run(
{ filePath: filepath, content: "allowed" },
askWithRules(Permission.fromConfig({ edit: { [`${outer}/**`]: "allow" } })),
)
expect(yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))).toBe("allowed")
yield* Effect.promise(() => fs.rm(outer, { recursive: true, force: true }))
}),
{ git: true },
)

it.instance("enforces an outside-worktree absolute deny over a wildcard allow", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const outer = yield* Effect.promise(() => fs.mkdtemp(path.join(path.dirname(test.directory), "opencode-outside-")))
const filepath = path.join(outer, "denied.txt")
const rules = Permission.fromConfig({ edit: { "*": "allow", [`${outer}/**`]: "deny" } })
const exit = yield* run({ filePath: filepath, content: "denied" }, askWithRules(rules)).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
expect(yield* Effect.promise(() => fs.stat(filepath).catch(() => undefined))).toBeUndefined()
yield* Effect.promise(() => fs.rm(outer, { recursive: true, force: true }))
}),
{ git: true },
)

it.instance("does not match an outside-worktree relative rule", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const outer = yield* Effect.promise(() => fs.mkdtemp(path.join(path.dirname(test.directory), "opencode-outside-")))
const filepath = path.join(outer, "not-src.txt")
const rules = Permission.fromConfig({ edit: { "src/**": "allow" } })
const exit = yield* run({ filePath: filepath, content: "not allowed" }, askWithRules(rules)).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
expect(yield* Effect.promise(() => fs.stat(filepath).catch(() => undefined))).toBeUndefined()
yield* Effect.promise(() => fs.rm(outer, { recursive: true, force: true }))
}),
{ git: true },
)
})

describe("tool.write", () => {
describe("new file creation", () => {
it.instance("writes content to new file", () =>
Expand Down
Loading