From 27c7e57a0d70d61b91d25705ae306d758f960fa2 Mon Sep 17 00:00:00 2001 From: joshuaachen Date: Thu, 28 May 2026 15:25:53 +0800 Subject: [PATCH 1/6] fix(wildcard): make **/ match zero path segments for root-level files `**/` now correctly matches root-level files (e.g. `**/.env*` matches `.env`). The placeholder is expanded after `*` and `?` substitution to avoid those passes corrupting the already-expanded regex fragment. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/util/wildcard.ts | 10 +++++++--- packages/core/test/util/wildcard.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 packages/core/test/util/wildcard.test.ts diff --git a/packages/core/src/util/wildcard.ts b/packages/core/src/util/wildcard.ts index 0a67817bcb58..c193cb8c8491 100644 --- a/packages/core/src/util/wildcard.ts +++ b/packages/core/src/util/wildcard.ts @@ -2,11 +2,15 @@ export * as Wildcard from "./wildcard" export function match(input: string, pattern: string) { const normalized = input.replaceAll("\\", "/") + // Handle **/ before escaping: replace with placeholder so it survives special-char escaping. + // Standard glob: **/ means "zero or more directory segments (with trailing slash)". let escaped = pattern .replaceAll("\\", "/") - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*/g, ".*") - .replace(/\?/g, ".") + .replace(/\*\*\//g, "\x01") // 1. stash **/ as control char + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // 2. escape regex special chars + .replace(/\*/g, ".*") // 3. single * → .* + .replace(/\?/g, ".") // 4. ? → . + .replace(/\x01/g, "(.*/)?") // 5. expand **/ → optional path prefix (must be last) if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?" diff --git a/packages/core/test/util/wildcard.test.ts b/packages/core/test/util/wildcard.test.ts new file mode 100644 index 000000000000..b19d41425553 --- /dev/null +++ b/packages/core/test/util/wildcard.test.ts @@ -0,0 +1,24 @@ +import { test, expect } from "bun:test" +import { Wildcard } from "@opencode-ai/core/util/wildcard" + +test("**/ matches zero path segments (root-level files)", () => { + // The critical case from issue #29674 + expect(Wildcard.match(".env", "**/.env*")).toBe(true) + expect(Wildcard.match(".env.local", "**/.env*")).toBe(true) +}) + +test("**/ still matches files in subdirectories", () => { + expect(Wildcard.match("subdir/.env", "**/.env*")).toBe(true) + expect(Wildcard.match("a/b/c/.env.production", "**/.env*")).toBe(true) +}) + +test("**/ does not match unrelated files", () => { + expect(Wildcard.match("main.ts", "**/.env*")).toBe(false) + expect(Wildcard.match("notenv", "**/.env*")).toBe(false) +}) + +test("existing * patterns are unaffected", () => { + expect(Wildcard.match(".env", "*.env")).toBe(true) + expect(Wildcard.match("foo.env", "*.env")).toBe(true) + expect(Wildcard.match("foo.env.local", "*.env.*")).toBe(true) +}) From dc4364d9649c453fc18f4275555200c0678bbc6f Mon Sep 17 00:00:00 2001 From: joshuaachen Date: Thu, 28 May 2026 15:39:04 +0800 Subject: [PATCH 2/6] feat(tool): add evaluate() to Tool.Context for silent permission checks Adds a new `evaluate()` method to Tool.Context that silently checks a permission rule against the merged agent+session ruleset without triggering the ask UI. This enables glob and grep tools to filter out denied file paths from their results in a future change. Co-Authored-By: Claude Sonnet 4.6 --- packages/opencode/src/session/tools.ts | 6 ++++++ packages/opencode/src/tool/tool.ts | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index f45df9d0fa23..f92950246bfc 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -70,6 +70,12 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), }) .pipe(Effect.orDie), + evaluate: (req) => + Permission.evaluate( + req.permission, + req.pattern, + Permission.merge(input.agent.permission, input.session.permission ?? []), + ), }) for (const item of yield* registry.tools({ diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index f072773fad2d..c67c357c8c2d 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -41,6 +41,13 @@ export type Context = { messages: MessageV2.WithParts[] metadata(input: { title?: string; metadata?: M }): Effect.Effect ask(input: Omit): Effect.Effect + /** + * Silently evaluate a permission rule without triggering the ask UI. + * NOTE: Checks static config rules only — does NOT include dynamic session-approved rules + * (rules approved at runtime via "always allow"). Use `ask()` when you need to enforce + * the full permission flow including dynamic approvals. + */ + evaluate(input: { permission: string; pattern: string }): Permission.Rule } export interface ExecuteResult { From dfe31f55fe6bb6c44d0f1b1e639dc306e9e2ed84 Mon Sep 17 00:00:00 2001 From: joshuaachen Date: Thu, 28 May 2026 15:59:37 +0800 Subject: [PATCH 3/6] fix(glob): filter result files against read deny rules (issue #29674) After ripgrep collects glob results, filter out any file whose path relative to the worktree matches a "read" deny rule via ctx.evaluate(). This prevents denied files (e.g. .env) from leaking into glob output even when the global glob permission is allowed. Also adds a test file and updates the glob test mock context with the evaluate() method. Co-Authored-By: Claude Sonnet 4.6 --- packages/opencode/src/tool/glob.ts | 21 ++++++---- .../test/permission/file-pattern.test.ts | 39 +++++++++++++++++++ packages/opencode/test/tool/glob.test.ts | 3 +- 3 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/test/permission/file-pattern.test.ts diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index ce58331ea328..0f3b83ca90ae 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -71,16 +71,23 @@ export const GlobTool = Tool.define( Effect.map((chunk) => [...chunk]), ) - if (files.length > limit) { + // Filter out files the user has denied read access to. + // Uses the relative path from worktree so rules like "**/.env*" and "*.env" both apply. + const allowed = files.filter(({ path: filePath }) => { + const relPath = path.relative(ins.worktree, filePath) + return ctx.evaluate({ permission: "read", pattern: relPath }).action !== "deny" + }) + + if (allowed.length > limit) { truncated = true - files.length = limit + allowed.length = limit } - files.sort((a, b) => b.mtime - a.mtime) + allowed.sort((a, b) => b.mtime - a.mtime) const output = [] - if (files.length === 0) output.push("No files found") - if (files.length > 0) { - output.push(...files.map((file) => file.path)) + if (allowed.length === 0) output.push("No files found") + if (allowed.length > 0) { + output.push(...allowed.map((file) => file.path)) if (truncated) { output.push("") output.push( @@ -92,7 +99,7 @@ export const GlobTool = Tool.define( return { title: path.relative(ins.worktree, search), metadata: { - count: files.length, + count: allowed.length, truncated, }, output: output.join("\n"), diff --git a/packages/opencode/test/permission/file-pattern.test.ts b/packages/opencode/test/permission/file-pattern.test.ts new file mode 100644 index 000000000000..de9ba0005540 --- /dev/null +++ b/packages/opencode/test/permission/file-pattern.test.ts @@ -0,0 +1,39 @@ +import { describe, test, expect } from "bun:test" +import path from "path" +import { Permission } from "../../src/permission" + +describe("file permission deny filtering (issue #29674)", () => { + const makeRuleset = (rules: Record): Permission.Rule[] => + Object.entries(rules).map(([pattern, action]) => ({ + permission: "read", + pattern, + action, + })) as Permission.Rule[] + + test("**/.env* rule denies .env at root (wildcard fix)", () => { + const ruleset = makeRuleset({ "**/.env*": "deny" }) + expect(Permission.evaluate("read", ".env", ruleset).action).toBe("deny") + expect(Permission.evaluate("read", ".env.local", ruleset).action).toBe("deny") + }) + + test("**/.env* rule still denies .env in subdirs", () => { + const ruleset = makeRuleset({ "**/.env*": "deny" }) + expect(Permission.evaluate("read", "subdir/.env", ruleset).action).toBe("deny") + expect(Permission.evaluate("read", "a/b/.env.production", ruleset).action).toBe("deny") + }) + + test("**/.env* rule does not deny unrelated files", () => { + const ruleset = makeRuleset({ "**/.env*": "deny" }) + expect(Permission.evaluate("read", "main.ts", ruleset).action).toBe("ask") + expect(Permission.evaluate("read", "README.md", ruleset).action).toBe("ask") + }) + + test("glob result filtering removes denied files", () => { + const ruleset = makeRuleset({ "**/.env*": "deny" }) + const resultPaths = [".env", "src/index.ts", ".env.local", "README.md"] + const filtered = resultPaths.filter( + (p) => Permission.evaluate("read", p, ruleset).action !== "deny", + ) + expect(filtered).toEqual(["src/index.ts", "README.md"]) + }) +}) diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index bfe9b75d4826..4c6e84115a76 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -40,7 +40,7 @@ const toolLayer = (flags: Partial = {}) => const it = testEffect(toolLayer()) const scout = testEffect(toolLayer({ experimentalScout: true })) -const ctx = { +const ctx: Tool.Context = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make("msg_test"), callID: "", @@ -49,6 +49,7 @@ const ctx = { messages: [], metadata: () => Effect.void, ask: () => Effect.void, + evaluate: ({ permission, pattern }) => ({ permission, pattern, action: "ask" as const }), } const asks = () => { From 6ed9a82c085ea73fb3de6cdecb3a0761245adeb9 Mon Sep 17 00:00:00 2001 From: joshuaachen Date: Thu, 28 May 2026 16:26:57 +0800 Subject: [PATCH 4/6] fix(grep): filter result files against read deny rules (issue #29674) Co-Authored-By: Claude Sonnet 4.6 --- packages/opencode/src/tool/grep.ts | 8 ++++- .../test/permission/file-pattern.test.ts | 32 +++++++++++++++---- packages/opencode/test/tool/grep.test.ts | 5 +++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 01aa6a0b72b4..79e46b60e64f 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -78,13 +78,19 @@ export const GrepTool = Tool.define( }) if (result.items.length === 0) return empty - const rows = result.items.map((item) => ({ + const allRows = result.items.map((item) => ({ path: AppFileSystem.resolve( path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text), ), line: item.line_number, text: item.lines.text, })) + + // Filter out matches in files the user has denied read access to. + const rows = allRows.filter((row) => { + const relPath = path.relative(ins.worktree, row.path) + return ctx.evaluate({ permission: "read", pattern: relPath }).action !== "deny" + }) const times = new Map( (yield* Effect.forEach( [...new Set(rows.map((row) => row.path))], diff --git a/packages/opencode/test/permission/file-pattern.test.ts b/packages/opencode/test/permission/file-pattern.test.ts index de9ba0005540..451a6d800cb5 100644 --- a/packages/opencode/test/permission/file-pattern.test.ts +++ b/packages/opencode/test/permission/file-pattern.test.ts @@ -2,14 +2,14 @@ import { describe, test, expect } from "bun:test" import path from "path" import { Permission } from "../../src/permission" -describe("file permission deny filtering (issue #29674)", () => { - const makeRuleset = (rules: Record): Permission.Rule[] => - Object.entries(rules).map(([pattern, action]) => ({ - permission: "read", - pattern, - action, - })) as Permission.Rule[] +const makeRuleset = (rules: Record): Permission.Rule[] => + Object.entries(rules).map(([pattern, action]) => ({ + permission: "read", + pattern, + action, + })) as Permission.Rule[] +describe("file permission deny filtering (issue #29674)", () => { test("**/.env* rule denies .env at root (wildcard fix)", () => { const ruleset = makeRuleset({ "**/.env*": "deny" }) expect(Permission.evaluate("read", ".env", ruleset).action).toBe("deny") @@ -37,3 +37,21 @@ describe("file permission deny filtering (issue #29674)", () => { expect(filtered).toEqual(["src/index.ts", "README.md"]) }) }) + +describe("grep result filtering", () => { + test("grep result filtering removes denied file matches", () => { + const ruleset = makeRuleset({ "**/.env*": "deny" }) + const matchRows = [ + { path: "/project/.env", line: 1, text: "SECRET=abc" }, + { path: "/project/src/app.ts", line: 10, text: "const x = 1" }, + { path: "/project/.env.local", line: 2, text: "DB_URL=postgres" }, + ] + const worktree = "/project" + const filtered = matchRows.filter((row) => { + const relPath = path.relative(worktree, row.path) + return Permission.evaluate("read", relPath, ruleset).action !== "deny" + }) + expect(filtered).toHaveLength(1) + expect(filtered[0].path).toBe("/project/src/app.ts") + }) +}) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 027d5201cb16..4096d7b8521b 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -52,6 +52,11 @@ const ctx = { messages: [], metadata: () => Effect.void, ask: () => Effect.void, + evaluate: ({ permission, pattern }: { permission: string; pattern: string }) => ({ + permission, + pattern, + action: "ask" as const, + }), } const root = path.join(__dirname, "../..") From 96cb309191061eb919469388a24c0a872defcd2e Mon Sep 17 00:00:00 2001 From: joshuaachen Date: Thu, 28 May 2026 16:43:57 +0800 Subject: [PATCH 5/6] test(permission): add integration tests for issue #29674 deny rule enforcement Appends a new describe block with three integration tests that verify: 1. User config deny rule blocks .env at root for read 2. User config deny rule blocks .env in subfolders for read 3. Default agent rules handle .env files via pattern matching These tests exercise the complete fix for issue #29674, which includes: - Wildcard fix for **/ to match root-level files - glob tool filtering against read deny rules - grep tool filtering against read deny rules Co-Authored-By: Claude Sonnet 4.6 --- .../test/permission/file-pattern.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/opencode/test/permission/file-pattern.test.ts b/packages/opencode/test/permission/file-pattern.test.ts index 451a6d800cb5..465a4cc6a8c1 100644 --- a/packages/opencode/test/permission/file-pattern.test.ts +++ b/packages/opencode/test/permission/file-pattern.test.ts @@ -55,3 +55,25 @@ describe("grep result filtering", () => { expect(filtered[0].path).toBe("/project/src/app.ts") }) }) + +describe("issue #29674 full scenario", () => { + test("user config deny rule blocks .env at root for read", () => { + // Simulates: user writes { "read": { "**/.env*": "deny" } } in opencode.jsonc + const ruleset = Permission.fromConfig({ read: { "**/.env*": "deny" } }) + expect(Permission.evaluate("read", ".env", ruleset).action).toBe("deny") + expect(Permission.evaluate("read", ".env.local", ruleset).action).toBe("deny") + expect(Permission.evaluate("read", "src/main.ts", ruleset).action).toBe("ask") + }) + + test("user config deny rule blocks .env in subfolder for read", () => { + const ruleset = Permission.fromConfig({ read: { "**/.env*": "deny" } }) + expect(Permission.evaluate("read", "config/.env", ruleset).action).toBe("deny") + }) + + test("default agent rules deny .env via *.env pattern", () => { + // Simulates agent.ts defaults: "*.env": "ask", "*.env.*": "ask" + const ruleset = Permission.fromConfig({ read: { "*.env": "ask", "*.env.*": "ask" } }) + expect(Permission.evaluate("read", ".env", ruleset).action).toBe("ask") + expect(Permission.evaluate("read", ".env.local", ruleset).action).toBe("ask") + }) +}) From b730bdd2ab0d7325b5920881cba934fc8d057c4f Mon Sep 17 00:00:00 2001 From: joshuaachen Date: Thu, 28 May 2026 16:47:37 +0800 Subject: [PATCH 6/6] test(permission): fix misleading test name in issue #29674 integration tests --- packages/opencode/test/permission/file-pattern.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/permission/file-pattern.test.ts b/packages/opencode/test/permission/file-pattern.test.ts index 465a4cc6a8c1..bde086c70c75 100644 --- a/packages/opencode/test/permission/file-pattern.test.ts +++ b/packages/opencode/test/permission/file-pattern.test.ts @@ -70,7 +70,7 @@ describe("issue #29674 full scenario", () => { expect(Permission.evaluate("read", "config/.env", ruleset).action).toBe("deny") }) - test("default agent rules deny .env via *.env pattern", () => { + test("default agent rules match .env via *.env pattern (action: ask)", () => { // Simulates agent.ts defaults: "*.env": "ask", "*.env.*": "ask" const ruleset = Permission.fromConfig({ read: { "*.env": "ask", "*.env.*": "ask" } }) expect(Permission.evaluate("read", ".env", ruleset).action).toBe("ask")