diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index bf01fc7d2d5c..b7e4cdb96f5b 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -178,7 +178,7 @@ export const ReadTool = Tool.define( yield* ctx.ask({ permission: "read", - patterns: [filepath], + patterns: [path.relative(instance.worktree, filepath).replaceAll("\\", "/")], always: ["*"], metadata: {}, }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 695d96ec2fe8..357d3f76a48e 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -78,7 +78,6 @@ const fail = Effect.fn("ReadToolTest.fail")(function* ( throw new Error("expected read to fail") }) -const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) { @@ -155,7 +154,7 @@ describe("tool.read external_directory permission", () => { yield* exec(dir, { filePath: alt }, next) const read = items.find((item) => item.permission === "read") expect(read).toBeDefined() - expect(read!.patterns).toEqual([full(target)]) + expect(read!.patterns).toEqual(["test.txt"]) }), ) } @@ -201,6 +200,61 @@ describe("tool.read external_directory permission", () => { ) }) +describe("tool.read permission patterns", () => { + it.live("emits worktree-relative pattern for files inside the project", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* put(path.join(dir, "src", "foo.ts"), "hello") + + const { items, next } = asks() + + yield* exec(dir, { filePath: path.join(dir, "src", "foo.ts") }, next) + const read = items.find((item) => item.permission === "read") + expect(read).toBeDefined() + expect(read!.patterns).toEqual(["src/foo.ts"]) + }), + ) + + it.live("normalizes backslashes to forward slashes in pattern", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* put(path.join(dir, "a", "b", "c.txt"), "x") + + const { items, next } = asks() + + yield* exec(dir, { filePath: path.join(dir, "a", "b", "c.txt") }, next) + const read = items.find((item) => item.permission === "read") + expect(read).toBeDefined() + expect(read!.patterns[0]).not.toContain("\\") + expect(read!.patterns).toEqual(["a/b/c.txt"]) + }), + ) + + it.live("relative read patterns match worktree-relative deny rules from config", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* put(path.join(dir, "src", "secret.ts"), "hidden") + + const { items, next } = asks() + + yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next) + const read = items.find((item) => item.permission === "read") + expect(read).toBeDefined() + + // Simulates a user-written rule: { permission: { read: { "src/**": "deny" } } }. + // Before this fix, `read` emitted the absolute filepath as the pattern, so + // worktree-relative deny rules silently failed to match. After the fix, this + // assertion holds — matching how `edit`/`write`/`apply_patch` already behave. + const ruleset = Permission.fromConfig({ + read: { "src/**": "deny" }, + }) + for (const pattern of read!.patterns) { + expect(Permission.evaluate("read", pattern, ruleset).action).toBe("deny") + } + }), + ) +}) + describe("tool.read env file permissions", () => { const cases: [string, boolean][] = [ [".env", true], diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 19182e27491b..dc851b84a174 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -60,6 +60,10 @@ For most permissions, you can use an object to apply different actions based on "rm *": "deny", "grep *": "allow" }, + "read": { + "*": "allow", + "secrets/**": "deny" + }, "edit": { "*": "deny", "packages/web/src/content/docs/*.mdx": "allow" @@ -68,6 +72,8 @@ For most permissions, you can use an object to apply different actions based on } ``` +For path-based permissions (`read`, `edit`), patterns match against the file path **relative to the project worktree** with forward slashes, so `"src/**"` works the same way for both. To gate paths outside the worktree, see [External Directories](#external-directories). + Rules are evaluated by pattern match, with the **last matching rule winning**. A common pattern is to put the catch-all `"*"` rule first, and more specific rules after it. ### Wildcards