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
60 changes: 56 additions & 4 deletions packages/opencode/src/permission/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,19 @@ export namespace PermissionNext {
return rulesets.flat()
}

export const Command = z.object({
head: z.string(),
tail: z.string().array(),
})
export type Command = z.infer<typeof Command>

export const Request = z
.object({
id: Identifier.schema("permission"),
sessionID: Identifier.schema("session"),
permission: z.string(),
patterns: z.string().array(),
patterns: z.string().array().optional(),
commands: Command.array().optional(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
Expand Down Expand Up @@ -120,6 +127,38 @@ export namespace PermissionNext {
async (input) => {
const s = await state()
const { ruleset, ...request } = input

// Handle bash commands with structured matching
if (request.permission === "bash" && request.commands) {
const commands = request.commands // capture narrowed value before closure
for (const command of commands) {
const rule = evaluateBash(command, ruleset, s.approved)
log.info("evaluated bash", { command, action: rule })
if (rule.action === "deny")
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
if (rule.action === "ask") {
const id = input.id ?? Identifier.ascending("permission")
return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
...request,
// Include patterns for display in UI
patterns: commands.map((c) => [c.head, ...c.tail].join(" ")),
}
s.pending[id] = {
info,
resolve,
reject,
}
Bus.publish(Event.Asked, info)
})
}
if (rule.action === "allow") continue
}
return
}

// Handle standard patterns for other permission types
for (const pattern of request.patterns ?? []) {
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
Expand Down Expand Up @@ -196,9 +235,13 @@ export namespace PermissionNext {
const sessionID = existing.info.sessionID
for (const [id, pending] of Object.entries(s.pending)) {
if (pending.info.sessionID !== sessionID) continue
const ok = pending.info.patterns.every(
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
)
// Check if all commands/patterns are now allowed
const ok =
pending.info.permission === "bash" && pending.info.commands
? pending.info.commands.every((command) => evaluateBash(command, s.approved).action === "allow")
: (pending.info.patterns ?? []).every(
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
)
if (!ok) continue
delete s.pending[id]
Bus.publish(Event.Replied, {
Expand Down Expand Up @@ -226,6 +269,15 @@ export namespace PermissionNext {
return match ?? { action: "ask", permission, pattern: "*" }
}

export function evaluateBash(command: Command, ...rulesets: Ruleset[]): Rule {
const merged = merge(...rulesets)
const bashRules = merged.filter((rule) => Wildcard.match("bash", rule.permission))
const patterns = Object.fromEntries(bashRules.map((rule) => [rule.pattern, rule.action]))
const action = Wildcard.allStructured(command, patterns)
if (action === undefined) return { action: "ask", permission: "bash", pattern: "*" }
return { action, permission: "bash", pattern: "*" }
}

const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]

export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
Expand Down
37 changes: 17 additions & 20 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,31 +86,27 @@ export const BashTool = Tool.define("bash", async () => {
}
const directories = new Set<string>()
if (!Instance.containsPath(cwd)) directories.add(cwd)
const patterns = new Set<string>()
const commands: Array<{ head: string; tail: string[] }> = []
const always = new Set<string>()

for (const node of tree.rootNode.descendantsOfType("command")) {
if (!node) continue
const command = []
const tokens: string[] = []
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
if (!child) continue
if (
child.type !== "command_name" &&
child.type !== "word" &&
child.type !== "string" &&
child.type !== "raw_string" &&
child.type !== "concatenation"
) {
continue
}
command.push(child.text)
// Include all meaningful text nodes (flags, args, commands, etc.)
if (child.text.trim()) tokens.push(child.text)
}
if (tokens.length === 0) continue

const head = tokens[0]
const tail = tokens.slice(1)

// not an exhaustive list, but covers most common cases
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"].includes(head)) {
for (const arg of tail) {
if (arg.startsWith("-") || (head === "chmod" && arg.startsWith("+"))) continue
const resolved = await $`realpath ${arg}`
.cwd(cwd)
.quiet()
Expand All @@ -130,9 +126,9 @@ export const BashTool = Tool.define("bash", async () => {
}

// cd covered by above check
if (command.length && command[0] !== "cd") {
patterns.add(command.join(" "))
always.add(BashArity.prefix(command).join(" ") + "*")
if (head !== "cd") {
commands.push({ head, tail })
always.add(BashArity.prefix(tokens).join(" ") + "*")
}
}

Expand All @@ -145,10 +141,11 @@ export const BashTool = Tool.define("bash", async () => {
})
}

if (patterns.size > 0) {
if (commands.length > 0) {
await ctx.ask({
permission: "bash",
patterns: Array.from(patterns),
commands,
patterns: commands.map((c) => [c.head, ...c.tail].join(" ")),
always: Array.from(always),
metadata: {},
})
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/tool/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe("tool.read external_directory permission", () => {
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path))).toBe(true)
expect(extDirReq!.patterns?.some((p) => p.includes(outerTmp.path))).toBe(true)
},
})
})
Expand Down Expand Up @@ -147,7 +147,7 @@ describe("tool.read env file blocking", () => {
const ctxWithPermissions = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
for (const pattern of req.patterns) {
for (const pattern of req.patterns ?? []) {
const rule = PermissionNext.evaluate(req.permission, pattern, agent.permission)
if (rule.action === "deny") {
throw new PermissionNext.DeniedError(agent.permission)
Expand Down