Coverage for agentos/system/approval.py: 43%
84 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 09:19 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 09:19 +0800
1"""
2可视化授权引擎 — Agent 主动申请权限,用户可视化审批。
4与 permissions.py 的分工:
5- permissions.py: 纯程序化权限检查(require/check)
6- approval.py: HITL 授权流程(Agent 发起申请 → 用户审批 → 回调)
8流程:
91. Agent 调用 request_approval(tier, resource, reason)
102. 引擎生成 ApprovalTicket,挂起等待
113. 通过 WebSocket 推送给桌面客户端
124. 用户在 UI 点击"同意" / "拒绝" / "拒绝+记住"
135. 回调触发,Agent 继续或拒绝
14"""
16from __future__ import annotations
18import asyncio
19import uuid
20from collections.abc import Awaitable, Callable
21from dataclasses import dataclass, field
22from datetime import datetime
23from enum import StrEnum
25from agentos.system.permissions import PermissionTier
28class ApprovalStatus(StrEnum):
29 PENDING = "pending"
30 APPROVED = "approved"
31 DENIED = "denied"
32 DENIED_REMEMBER = "denied_remember" # 拒绝并记住,本次会话不再询问
33 TIMEOUT = "timeout"
36@dataclass
37class ApprovalTicket:
38 """授权申请票据。"""
40 ticket_id: str
41 tier: PermissionTier
42 resource: str # 申请访问的资源路径/命令
43 reason: str # Agent 申请原因(AI 生成的中文说明)
44 status: ApprovalStatus = ApprovalStatus.PENDING
45 created_at: str = field(default_factory=lambda: datetime.now().isoformat())
46 resolved_at: str = ""
47 session_id: str = ""
48 # 回调
49 _future: asyncio.Future | None = field(default=None, repr=False)
51 def __post_init__(self):
52 if self._future is None:
53 self._future = asyncio.Future()
55 async def wait(self, timeout: float = 60.0) -> ApprovalStatus:
56 """等待审批结果。"""
57 try:
58 return await asyncio.wait_for(asyncio.shield(self._future), timeout=timeout)
59 except TimeoutError:
60 self.status = ApprovalStatus.TIMEOUT
61 self.resolved_at = datetime.now().isoformat()
62 return ApprovalStatus.TIMEOUT
64 def approve(self) -> None:
65 """批准。"""
66 self.status = ApprovalStatus.APPROVED
67 self.resolved_at = datetime.now().isoformat()
68 if self._future and not self._future.done():
69 self._future.set_result(ApprovalStatus.APPROVED)
71 def deny(self, remember: bool = False) -> None:
72 """拒绝。"""
73 self.status = ApprovalStatus.DENIED_REMEMBER if remember else ApprovalStatus.DENIED
74 self.resolved_at = datetime.now().isoformat()
75 if self._future and not self._future.done():
76 self._future.set_result(self.status)
79class ApprovalEngine:
80 """可视化授权引擎 — 管理审批流程。
82 用法:
83 engine = ApprovalEngine(pm, session_id)
84 engine.set_push_callback(send_to_ws) # 设置推送回调
86 # Agent 侧:
87 approved = await engine.request(
88 PermissionTier.SHELL_STANDARD,
89 "rm -rf ./build/",
90 "需要清理构建缓存以释放磁盘空间",
91 )
92 if approved:
93 # 执行操作
94 """
96 def __init__(self, perm_manager, session_id: str):
97 self._pm = perm_manager
98 self._sid = session_id
99 self._push_callback: Callable[[dict], Awaitable[None]] | None = None
100 self._pending: dict[str, ApprovalTicket] = {}
101 self._denied_remember: set[str] = set() # 本次会话已记住拒绝的 (tier, pattern)
103 def set_push_callback(self, callback: Callable[[dict], Awaitable[None]]) -> None:
104 """设置推送回调(发送到 WebSocket 客户端)。"""
105 self._push_callback = callback
107 async def request(
108 self, tier: PermissionTier, resource: str, reason: str, timeout: float = 60.0
109 ) -> bool:
110 """Agent 发起权限申请,返回是否获批。
112 如果已有 DENIED_REMEMBER 记录,直接返回 False。
113 """
114 # 检查是否已记住拒绝
115 deny_key = f"{tier.value}:{resource}"
116 if deny_key in self._denied_remember:
117 return False
119 ticket = ApprovalTicket(
120 ticket_id=f"approval-{uuid.uuid4().hex[:12]}",
121 tier=tier,
122 resource=resource,
123 reason=reason,
124 session_id=self._sid,
125 )
126 self._pending[ticket.ticket_id] = ticket
128 # 推送到客户端
129 if self._push_callback:
130 try:
131 await self._push_callback(
132 {
133 "type": "approval_request",
134 "data": {
135 "ticket_id": ticket.ticket_id,
136 "tier": ticket.tier.value,
137 "tier_label": ticket.tier.label,
138 "resource": ticket.resource,
139 "reason": ticket.reason,
140 "session_id": self._sid,
141 "timeout": timeout,
142 },
143 }
144 )
145 except Exception:
146 pass # 推送失败不阻塞
148 # 等待审批
149 result = await ticket.wait(timeout=timeout)
151 # 处理记住拒绝
152 if result == ApprovalStatus.DENIED_REMEMBER:
153 self._denied_remember.add(deny_key)
155 # 如果审批通过,临时提升权限
156 if result == ApprovalStatus.APPROVED:
157 self._pm.escalate(self._sid, tier, resource)
159 # 清理
160 self._pending.pop(ticket.ticket_id, None)
162 return result == ApprovalStatus.APPROVED
164 def resolve(self, ticket_id: str, approved: bool, remember: bool = False) -> bool:
165 """手动审批票据(从 UI 调用)。"""
166 ticket = self._pending.get(ticket_id)
167 if not ticket or ticket.status != ApprovalStatus.PENDING:
168 return False
170 if approved:
171 ticket.approve()
172 else:
173 ticket.deny(remember=remember)
174 return True
176 def get_pending_tickets(self) -> list[dict]:
177 """获取所有待审批票据(供 UI 轮询)。"""
178 return [
179 {
180 "ticket_id": t.ticket_id,
181 "tier": t.tier.value,
182 "tier_label": t.tier.label,
183 "resource": t.resource,
184 "reason": t.reason,
185 "created_at": t.created_at,
186 "timeout_remaining": max(
187 0, 60 - (datetime.now() - datetime.fromisoformat(t.created_at)).total_seconds()
188 ),
189 }
190 for t in self._pending.values()
191 if t.status == ApprovalStatus.PENDING
192 ]
194 def clear_denied(self) -> None:
195 """清除本次会话的记住拒绝记录。"""
196 self._denied_remember.clear()