← opencode report

T24 — Wildcard last-match-wins permission evaluation

rules.findLast(rule => match(perm, rule.perm) && match(pattern, rule.pattern)) — rule sau override rule trước. Cho phép: allow bash:* rồi deny bash:rm -rf *.
Nhóm: E — Permission ModelFile: permission/evaluate.ts · Lines 1–15ID: E.1 / T24Status: Stable

Tổng quan Permission

Tại sao quan trọng. Permission system cần vừa "allow broadly" vừa "deny specifically". Nếu first-match-wins: user phải đặt deny trước allow — counter-intuitive. Last-match-wins cho phép viết theo logic tự nhiên: allow: bash:* (mặc định cho phép bash) → deny: bash:rm -rf * (nhưng block destructive). Rule gần cuối file = rule có priority cao nhất — giống nginx, iptables, CSS specificity.
Default "ask" là safety net: Khi không có rule nào match, system default sang "ask" — không phải "deny" cứng nhắc cũng không phải "allow" tiện lợi. User luôn được inform và có quyền quyết định.

Phân tích code chi tiết Anatomy

evaluate() — 15 dòng, full permission logic

permission/evaluate.ts

TS
{`
import { Wildcard } from "./wildcard"

export function evaluate(
  rules:      PermissionRule[],
  permission: string,  // "bash" | "edit" | "write" | "doom_loop" | ...
  pattern:    string,  // "rm -rf *" | "/tmp/**" | command string
): "allow" | "deny" | "ask" {
  // findLast = iterate từ cuối → rule sau win (last-match-wins)
  const match = rules.findLast(
    (rule) =>
      Wildcard.match(permission, rule.permission) &&
      Wildcard.match(pattern,    rule.pattern)
  )

  // Default "ask" khi không có rule nào match
  return match?.action ?? "ask"
}
`}

Wildcard matching — shell-style glob

Wildcard.match — * là wildcard

TS
{`
// Wildcard.match("bash:rm -rf /tmp", "bash:rm *") → true
// Wildcard.match("edit:/src/**", "edit:*")        → true
// Wildcard.match("doom_loop", "bash:*")           → false

function wildcard(value: string, pattern: string): boolean {
  // Convert shell glob * → regex .*
  const re = new RegExp(
    "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&")
                 .replace(/\*/g, ".*") + "$"
  )
  return re.test(value)
}
`}
rules = [ { permission: "bash", pattern: "*", action: "allow" }, // rule 1 { permission: "bash", pattern: "rm -rf *", action: "deny" }, // rule 2 { permission: "edit", pattern: "/tmp/**", action: "allow" }, // rule 3 ] Request: permission="bash", pattern="rm -rf /home" │ ▼ findLast iteration (từ cuối): rule 3: bash != edit → skip rule 2: bash==bash, "rm -rf /home" matches "rm -rf *" → MATCH → return "deny" (rule 1 không được xét vì findLast trả về ngay khi tìm thấy) Result: "deny" Request: permission="bash", pattern="ls /home" │ ▼ findLast: rule 2: "ls /home" không match "rm -rf *" → skip rule 1: bash==bash, "ls /home" matches "*" → MATCH → return "allow" Result: "allow"

Tương tác với kỹ thuật khác Interaction

  • T18 (Bash tree-sitter): tree-sitter extract paths từ bash command → paths trở thành pattern argument trong evaluate(). Accuracy của T18 ảnh hưởng trực tiếp đến permission decision.
  • T25 (Session-scoped state): evaluate() chỉ check static rules từ config. T25 bổ sung check dynamic "alwaysAllow" đã được user approve trong session. Hai layer nối tiếp nhau.
  • T26 (Arity normalization): Trước khi đưa vào evaluate, command được normalize: "npm run build --watch""npm run build" để match đúng pattern user đã set.

Failure modes Failure

1. Order-sensitive — dễ sắp xếp sai

Last-match-wins phụ thuộc thứ tự rules. Nếu user drag-drop rule lên trên trong UI mà không hiểu semantics → behavior thay đổi không mong muốn. Cần UI cảnh báo khi order change affect outcome.

2. Wildcard không đủ biểu đạt

* simple không support negation (!rm*), character class ([a-z]), hay regex phức tạp. Pattern như "cho phép mọi git command trừ push --force" không biểu đạt được với wildcard đơn giản.

3. Không có "deny wins" mode

Nếu muốn "deny luôn thắng bất kể thứ tự", cần thêm mode riêng. Hiện tại: deny chỉ thắng nếu đứng sau allow. Security-critical deployment cần option này.

So sánh với các harness khác Compare

HarnessPermission modelDefault actionWildcard
opencodeWildcard last-match-wins rules"ask" (safe default)✅ shell glob style
Claude CodeallowedTools + deniedTools lists"ask" (safe default)✅ glob + regex
OpenHarnessSensitive path hardcoded + config allow"deny" for hardcoded⚠️ basic
AiderAuto-approve flag hoặc manual confirmUser-configured
ClineAlways ask (strict) hoặc never ask (open)"always ask" by default

Implementation recipe Recipe

TS
{`
interface Rule {
  permission: string  // "bash" | "edit" | "*"
  pattern:    string  // "rm *" | "/tmp/**" | "*"
  action:     "allow" | "deny" | "ask"
}

function matchWildcard(value: string, pattern: string): boolean {
  const re = new RegExp(
    "^" + pattern
      .replace(/[.+^${}()|[\]\\]/g, "\\$&")  // escape regex chars
      .replace(/\*/g, ".*")                    // * → .*
    + "$",
    "i"
  )
  return re.test(value)
}

function evaluate(rules: Rule[], permission: string, pattern: string): "allow" | "deny" | "ask" {
  const match = [...rules]
    .reverse()                           // last-match-wins: reverse array then find first
    .find(r =>
      matchWildcard(permission, r.permission) &&
      matchWildcard(pattern,    r.pattern)
    )
  return match?.action ?? "ask"           // safe default
}

// Example rules (YAML config → parse to Rule[]):
// rules:
//   - { permission: "bash", pattern: "*",        action: allow }
//   - { permission: "bash", pattern: "rm *",     action: deny  }
//   - { permission: "edit", pattern: "/src/**",  action: allow }
`}

Tham khảo Refs