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.
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
| Harness | Session permission state | Persist to disk | Reject all |
|---|---|---|---|
| opencode | alwaysAllow per session, once/always/reject | ❌ intentional | ✅ reject cancels all pending |
| Claude Code | alwaysDeny / alwaysAllow lists | ✅ persisted to config | ✅ |
| Aider | Auto-approve flag (global) | ✅ config file | ⚠️ global stop only |
| Cline | Per-session approve cache | ❌ | ✅ |
| OpenHarness | Permission 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)
}
}
`}