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
10 changes: 7 additions & 3 deletions packages/core/src/util/wildcard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) + "( .*)?"

Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/util/wildcard.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
6 changes: 6 additions & 0 deletions packages/opencode/src/session/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 14 additions & 7 deletions packages/opencode/src/tool/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"),
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/tool/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))],
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export type Context<M extends Metadata = Metadata> = {
messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
/**
* 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<M extends Metadata = Metadata> {
Expand Down
79 changes: 79 additions & 0 deletions packages/opencode/test/permission/file-pattern.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import { Permission } from "../../src/permission"

const makeRuleset = (rules: Record<string, "allow" | "deny" | "ask">): 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")
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"])
})
})

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

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 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")
expect(Permission.evaluate("read", ".env.local", ruleset).action).toBe("ask")
})
})
3 changes: 2 additions & 1 deletion packages/opencode/test/tool/glob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
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: "",
Expand All @@ -49,6 +49,7 @@ const ctx = {
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
evaluate: ({ permission, pattern }) => ({ permission, pattern, action: "ask" as const }),
}

const asks = () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/test/tool/grep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "../..")
Expand Down
Loading