← opencode report

T25 — Session-scoped permission state

Ba reply mode: once (chỉ lần này), always (auto-approve pattern cả session), reject (cancel ngay). State scoped theo session — không leak sang session khác.
Nhóm: E — Permission ModelFile: permission/index.ts · Lines 130–282ID: E.2 / T25Status: Stable

Tổng quan Permission

Tại sao quan trọng. Nếu mỗi bash command đều popup dialog → user spam Enter, permission mất ý nghĩa. Ngược lại, auto-approve hết → không có safety net. "always" cho phép user approve pattern một lần (ls *), agent execute thoải mái suốt session mà không hỏi lại. "reject" cancel ngay tất cả pending tool calls — cho user abort toàn bộ khi phát hiện agent đang làm sai. State scoped per session đảm bảo không có "permission leak" giữa projects.
UI decoupled qua event: Permission system emit "permission.request" event rồi await Deferred resolve — core không biết UI đang là TUI, web, hay desktop. Swap UI không cần touch core logic.

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

Permission state và ask() flow

permission/index.ts — Permission class

TS
{`
interface PermissionState {
  sessionId:   string
  // Patterns đã được user approve với "always" trong session này
  alwaysAllow: Array<{ permission: string; pattern: string }>
  // Map requestId → Deferred (sync point cho UI response)
  pending:     Map<string, Deferred<"allow" | "deny">>
}

export class Permission {
  async ask(req: PermissionRequest): Promise<"allow" | "deny"> {
    // 1. Check static rules từ config (T24)
    const staticResult = evaluate(this.rules, req.permission, req.pattern)
    if (staticResult !== "ask") return staticResult

    // 2. Check session alwaysAllow
    const alreadyAllowed = this.state.alwaysAllow.some(
      (r) =>
        Wildcard.match(req.permission, r.permission) &&
        Wildcard.match(req.pattern,    r.pattern)
    )
    if (alreadyAllowed) return "allow"

    // 3. Ask user via UI event
    const deferred = makeDeferred<"allow" | "deny">()
    this.state.pending.set(req.id, deferred)
    this.emit("permission.request", req)

    // Suspend until user responds
    return await deferred.promise
  }

  // UI calls reply() khi user click button
  reply(reqId: string, reply: "once" | "always" | "reject", pattern?: string) {
    const d = this.state.pending.get(reqId)
    if (!d) return

    if (reply === "always" && pattern) {
      // Lưu vào session alwaysAllow → auto-approve matching requests sau
      this.state.alwaysAllow.push({
        permission: d.req.permission,
        pattern,                       // pattern user đã confirm (vd "ls *")
      })
    }

    // "reject" → cancel TẤT CẢ pending requests cùng lúc
    if (reply === "reject") {
      for (const [id, def] of this.state.pending) {
        def.resolve("deny")
        this.state.pending.delete(id)
      }
      return
    }

    d.resolve(reply === "reject" ? "deny" : "allow")
    this.state.pending.delete(reqId)
  }
}
`}
agent: bash tool muốn chạy "ls /home" │ ▼ permission.ask({ permission: "bash", pattern: "ls /home" }) │ ┌────┴──────────────────────────────────────┐ │ check static rules (T24) │ │ → "ask" (no static rule matches) │ └────┬──────────────────────────────────────┘ │ ┌────┴──────────────────────────────────────┐ │ check session alwaysAllow │ │ → "ls *" pattern? YES → return "allow" │ ← 2nd time, no dialog │ → no match? → continue │ ← 1st time └────┬──────────────────────────────────────┘ │ (1st time only) ▼ emit("permission.request", req) await deferred.promise ← suspend here │ user clicks: [once] → allow, no save [always "ls *"] → save to alwaysAllow, allow [reject] → deny + cancel all pending │ ▼ deferred.resolve() → resume agent

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

  • T24 (Static rules): T25 là layer thứ 2 sau T24. Static rules check trước — chỉ khi static rules trả "ask" mới đến dynamic session state check.
  • T26 (Arity normalization): Pattern được suggest cho user khi approve "always" là normalized command (vd "npm run build" thay vì "npm run build --watch --verbose"). T26 tạo ra suggested pattern này.
  • T3 (Deferred tool coordination): Permission Deferred và tool Deferred cùng pattern — Effect Deferred.make → await → resolve. Hai hệ thống dùng primitive giống nhau, dễ hiểu khi đọc codebase.

Failure modes Failure

1. User approve pattern quá rộng

User vội click "always" với pattern "bash:*" thay vì "bash:ls *" → agent có thể run bất kỳ bash command nào trong session mà không hỏi. Session-scope tránh được inter-session leak nhưng không cứu được trong-session.

UI recommendation: Highlight warning khi user sắp approve pattern rộng. Suggest narrow pattern dựa trên command cụ thể đang được request. Arity normalization (T26) giúp tạo suggested pattern hợp lý.

2. Deferred không bao giờ resolve

Nếu UI bị crash hoặc user đóng window mà pending request vẫn còn, agent bị treo indefinitely. Cần timeout cho permission request (vd 5 phút) sau đó default "deny".

3. alwaysAllow không persist

State chỉ tồn tại trong session — mỗi session mới phải approve lại. Đây là behavior có chủ ý (tránh leak), nhưng gây friction cho user dùng lặp lại cùng pattern mỗi ngày.

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

HarnessSession permission statePersist to diskReject all
opencodealwaysAllow per session, once/always/reject❌ intentional✅ reject cancels all pending
Claude CodealwaysDeny / alwaysAllow lists✅ persisted to config
AiderAuto-approve flag (global)✅ config file⚠️ global stop only
ClinePer-session approve cache
OpenHarnessPermission file + mailbox sync✅ file-based

Implementation recipe Recipe

TS
{`
// Minimal permission gate với session state
class PermissionGate {
  private alwaysAllow: Array<{ perm: string; ptn: string }> = []
  private pending = new Map<string, {
    resolve: (v: "allow" | "deny") => void
    req:     PermissionRequest
  }>()

  async ask(req: PermissionRequest): Promise<"allow" | "deny"> {
    // Check session cache first
    if (this.alwaysAllow.some(r => match(req, r))) return "allow"

    // Ask user via UI
    return new Promise((resolve) => {
      const id = crypto.randomUUID()
      this.pending.set(id, { resolve, req })
      ui.showPermissionDialog({ ...req, id })

      // Timeout: auto-deny after 5 min
      setTimeout(() => {
        if (this.pending.has(id)) {
          this.pending.get(id)!.resolve("deny")
          this.pending.delete(id)
        }
      }, 5 * 60 * 1000)
    })
  }

  reply(id: string, mode: "once" | "always" | "reject", pattern?: string) {
    if (mode === "reject") {
      // Cancel ALL pending
      for (const [pid, p] of this.pending) {
        p.resolve("deny")
        this.pending.delete(pid)
      }
      return
    }

    const p = this.pending.get(id)
    if (!p) return

    if (mode === "always" && pattern) {
      this.alwaysAllow.push({ perm: p.req.permission, ptn: pattern })
    }

    p.resolve("allow")
    this.pending.delete(id)
  }
}
`}

Tham khảo Refs