Skip to content
Merged
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
67 changes: 66 additions & 1 deletion packages/opencode/src/permission/evaluate.ts
Original file line number Diff line number Diff line change
@@ -1 +1,66 @@
export { evaluate } from "."
// altimate_change start — upstream_fix: match equivalent path spellings with one rule precedence
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Wildcard } from "@opencode-ai/core/util/wildcard"

// `pattern` is the spelling the caller authored; `aliases` are equivalent normalized
// spellings. An alias only ever tightens a decision: the result is the strictest of the
// authored spelling's rule and the last rule matching it or, for deny/ask rules, an alias.
// An alias match can never relax a stricter authored match.
const STRICTNESS = { allow: 0, ask: 1, deny: 2 } as const

function evaluatePatterns(
permission: string,
pattern: string,
aliases: string[],
...rulesets: PermissionV1.Ruleset[]
): PermissionV1.Rule {
const rules = rulesets.flat().filter((rule) => Wildcard.match(permission, rule.permission))
const authored = rules.findLast((rule) => Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask" as const,
permission,
pattern: "*",
}
// Rule order still decides: an allow rule only applies to the authored spelling.
const aliased = rules.findLast(
(rule) =>
Wildcard.match(pattern, rule.pattern) ||
(rule.action !== "allow" && aliases.some((alias) => Wildcard.match(alias, rule.pattern))),
Comment thread
anandgupta42 marked this conversation as resolved.
)
return aliased && STRICTNESS[aliased.action] > STRICTNESS[authored.action] ? aliased : authored
}

export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule {
Comment thread
anandgupta42 marked this conversation as resolved.
Comment thread
anandgupta42 marked this conversation as resolved.
return evaluatePatterns(permission, pattern, [], ...rulesets)
}

export function evaluateRequest(
Comment thread
anandgupta42 marked this conversation as resolved.
request: Pick<PermissionV1.Request, "permission" | "metadata">,
pattern: string,
...rulesets: PermissionV1.Ruleset[]
): PermissionV1.Rule {
const aliases = request.metadata.permissionAliases
const values = aliases && typeof aliases === "object" ? (aliases as Record<string, unknown>)[pattern] : undefined
const groups = Array.isArray(values)
? values.filter(
(group): group is string[] => Array.isArray(group) && group.every((value) => typeof value === "string"),
)
: []
if (!groups.length) return evaluate(request.permission, pattern, ...rulesets)
// Preserve rule order within each access. Distinct invocations must all be allowed,
// even when they normalize to the same command.
const rules = groups.map((group) => evaluatePatterns(request.permission, pattern, group, ...rulesets))
return rules.find((rule) => rule.action === "deny") ?? rules.find((rule) => rule.action === "ask") ?? rules[0]!
}

export function evaluateRequestPatterns(
request: Pick<PermissionV1.Request, "permission" | "metadata" | "patterns">,
...rulesets: PermissionV1.Ruleset[]
) {
// Legacy callers return at the first prompt, so reject all denied accesses first.
return request.patterns
.map((pattern) => ({ pattern, rule: evaluateRequest(request, pattern, ...rulesets) }))
.sort((a, b) => Number(b.rule.action === "deny") - Number(a.rule.action === "deny"))
}
// altimate_change end

export * as PermissionEvaluate from "./evaluate"
25 changes: 12 additions & 13 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import os from "os"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
// altimate_change start — upstream_fix: evaluate normalized aliases without changing rule order
import { PermissionEvaluate } from "./evaluate"
// altimate_change end
// altimate_change start — upstream_fix: load persisted permission approvals
import { Database, eq } from "@/storage/db"
import { PermissionTable } from "./permission.sql"
Expand Down Expand Up @@ -40,17 +43,9 @@ interface State {
approved: PermissionV1.Rule[]
}

export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
}
)
}
// altimate_change start — upstream_fix: share path-alias evaluation with legacy permissions
export { evaluate } from "./evaluate"
// altimate_change end

export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}

Expand Down Expand Up @@ -91,7 +86,9 @@ export const layer = Layer.effect(
let needsAsk = false

for (const pattern of request.patterns) {
const rule = evaluate(request.permission, pattern, ruleset, approved)
// altimate_change start — upstream_fix: include normalized aliases in permission matching
const rule = PermissionEvaluate.evaluateRequest(request, pattern, ruleset, approved)
// altimate_change end
yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
return yield* new PermissionV1.DeniedError({
Expand Down Expand Up @@ -174,7 +171,9 @@ export const layer = Layer.effect(
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
const ok = item.info.patterns.every(
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
// altimate_change start — upstream_fix: apply approvals to equivalent path spellings
(pattern) => PermissionEvaluate.evaluateRequest(item.info, pattern, approved).action === "allow",
// altimate_change end
)
if (!ok) continue
pending.delete(id)
Expand Down
12 changes: 9 additions & 3 deletions packages/opencode/src/permission/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { ProjectID } from "@/project/schema"
import { Wildcard } from "@/util/wildcard"
import os from "os"
import z from "zod"
// altimate_change start — upstream_fix: share normalized path matching with Effect permissions
import { PermissionEvaluate } from "./evaluate"
// altimate_change end

export namespace PermissionNext {
const log = Log.create({ service: "permission" })
Expand Down Expand Up @@ -135,8 +138,9 @@ export namespace PermissionNext {
async (input) => {
const s = await state()
const { ruleset, ...request } = input
for (const pattern of request.patterns ?? []) {
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
// altimate_change start — upstream_fix: check normalized denies before any approval prompt
for (const { pattern, rule } of PermissionEvaluate.evaluateRequestPatterns(request, ruleset, s.approved)) {
// altimate_change end
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
Telemetry.track({
Expand Down Expand Up @@ -229,7 +233,9 @@ export namespace PermissionNext {
for (const [id, pending] of s.pending) {
if (pending.info.sessionID !== sessionID) continue
const ok = pending.info.patterns.every(
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
// altimate_change start — upstream_fix: apply approvals to equivalent path spellings
(pattern) => PermissionEvaluate.evaluateRequest(pending.info, pattern, s.approved).action === "allow",
// altimate_change end
)
if (!ok) continue
s.pending.delete(id)
Expand Down
Loading
Loading