Feature/delta team/epic r04 #7
@@ -105,3 +105,61 @@ không có gì thay thế cho phần giao diện.
|
||||
chạy nó sau mỗi đợt sửa là bắt được ngay chuyện đó.
|
||||
|
||||
> Nhóm trưởng chốt: ☐ A ☐ B ☐ C — ngày ____
|
||||
|
||||
---
|
||||
|
||||
## Quyết định 3 — Gamma viết hộ DTO `ToolPolicyGateway` cho Team Hoa
|
||||
|
||||
**Đã làm, chờ Hoa xác nhận.** Ngày: 21/08.
|
||||
|
||||
### Vì sao làm thay
|
||||
|
||||
N3 (Co4E) cần gọi tool nhưng Team Hoa chưa bắt đầu. Ba đường:
|
||||
|
||||
| | Hệ quả |
|
||||
|---|---|
|
||||
| N3 ngồi đợi Hoa | Mất mấy ngày, trái nguyên tắc "không team nào chặn team nào" |
|
||||
| N3 tự phỏng đoán | Phỏng đoán của một người, không ai soi, sửa lại chắc chắn |
|
||||
| **Gamma viết bản đề xuất** | N3 chạy ngay, Hoa có cái cụ thể để duyệt hoặc sửa |
|
||||
|
||||
### Ranh giới không lấn
|
||||
|
||||
Sơ đồ phân hệ trong `plan.md` giao `domain/security/` cho **Team Gamma**, còn
|
||||
`application/conversations/tool_policy_gateway.py` cho **Team Hoa**.
|
||||
|
||||
Nên chia đúng như vậy:
|
||||
|
||||
- **Gamma định nghĩa hình dạng** → `domain/security/tool_policy.py`
|
||||
- **Hoa cài đặt gateway** → `application/conversations/tool_policy_gateway.py`,
|
||||
nối vào `core/mcp_client.py` và tool dựng sẵn
|
||||
|
||||
Không đụng file nào của Hoa.
|
||||
|
||||
### Đã bám vào code đang chạy, không bịa
|
||||
|
||||
| Nguồn | Lấy gì |
|
||||
|---|---|
|
||||
| `core/agent_security.py::SecurityVerdict` | `allowed` · `reason` · `layer` |
|
||||
| `ui/permission_dialog.py` + `chat_panel.py:1312` | trạng thái "hỏi người dùng" |
|
||||
|
||||
Khác biệt duy nhất: gộp thành **một câu trả lời ba trạng thái**
|
||||
(`ALLOW` / `DENY` / `ASK`) thay vì bắt chỗ gọi tự nhớ hỏi hai nơi.
|
||||
|
||||
Hai ràng buộc đưa vào có chủ đích:
|
||||
|
||||
1. `DENY` và `ASK` **bắt buộc có `reason`** — người dùng cần biết vì sao, và
|
||||
`audit_log` cần ghi lại. Thiếu là ném lỗi ngay lúc dựng, không phải lúc chạy.
|
||||
2. `ASK` **không phải** `allowed` — bẫy dễ mắc nhất là coi ASK như ALLOW rồi tool
|
||||
chạy mà chưa ai đồng ý. Có test riêng cho chuyện này.
|
||||
|
||||
### Gửi Hoa cái gì
|
||||
|
||||
> Bên mình viết trước bản đề xuất `ToolPolicyGateway` ở
|
||||
> `domain/security/tool_policy.py` vì N3 cần gọi tool mà bên Hoa chưa bắt đầu —
|
||||
> để N3 khỏi phải tự đoán. Ba kiểu: `ToolCallRequest`, `PolicyDecision`,
|
||||
> `ToolPolicyGateway`. Phần cài đặt vẫn để bên Hoa ở
|
||||
> `application/conversations/tool_policy_gateway.py`, bọn mình không đụng.
|
||||
> Thấy chỗ nào không hợp thì sửa thẳng file đó, đừng tạo kiểu thứ hai. Đổi bây
|
||||
> giờ còn rẻ vì mới mình N3 dùng.
|
||||
|
||||
> Đã gửi Hoa: ☐ — ngày ____ Hoa xác nhận: ☐ đồng ý ☐ có sửa
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Cổng chính sách cho lời gọi tool — hình dạng dữ liệu, chưa phải cài đặt.
|
||||
|
||||
BẢN ĐỀ XUẤT, chờ Team Hoa xác nhận
|
||||
==================================
|
||||
Sơ đồ phân hệ trong ``plan.md`` giao ``domain/security/`` cho Team Gamma và
|
||||
``application/conversations/tool_policy_gateway.py`` cho Team Hoa. Nên Gamma
|
||||
định nghĩa *hình dạng*, Hoa *cài đặt*.
|
||||
|
||||
Viết trước vì N3 (Co4E) cần gọi tool và Team Hoa chưa bắt đầu. Không có nó thì
|
||||
N3 phải tự phỏng đoán rồi sửa lại sau — mà phỏng đoán của một người thì tệ hơn
|
||||
một đề xuất viết ra để cả hai bên soi.
|
||||
|
||||
Nếu Hoa thấy khác, sửa file này chứ đừng đẻ kiểu thứ hai. Đổi sớm rẻ hơn đổi
|
||||
muộn: hiện chỉ N3 dùng.
|
||||
|
||||
Mô hình bám theo code đang chạy, không bịa:
|
||||
* ``core/agent_security.py::SecurityVerdict`` — allowed / reason / layer
|
||||
* ``ui/permission_dialog.py`` — hộp thoại hỏi người dùng khi
|
||||
``ctx.project_confirm_commands()`` bật (``ui/chat_panel.py:1312``)
|
||||
|
||||
Điểm khác biệt duy nhất so với hôm nay: gộp hai thứ đó thành **một câu trả lời
|
||||
ba trạng thái**, thay vì code gọi phải tự nhớ hỏi cả hai nơi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class PolicyOutcome(str, Enum):
|
||||
"""Ba trạng thái. ``ASK`` là thứ hệ thống hiện tại đã có (hộp thoại xin
|
||||
phép) nhưng chưa được coi là một kết quả chính thức."""
|
||||
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallRequest:
|
||||
"""Một lời gọi tool đang chờ được duyệt.
|
||||
|
||||
``surface`` cho biết chỗ phát sinh — ``"cowork"``, ``"code"``, ``"co4e"``,
|
||||
``"task"``. Chính sách khác nhau theo màn: Co4E chạy nền nên không thể bật
|
||||
hộp thoại hỏi giữa chừng như Cowork.
|
||||
"""
|
||||
|
||||
name: str
|
||||
arguments: Dict[str, Any] = field(default_factory=dict)
|
||||
surface: str = "cowork"
|
||||
project_id: str = ""
|
||||
#: True nếu tool đến từ MCP server ngoài, False nếu là tool dựng sẵn.
|
||||
external: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDecision:
|
||||
"""Câu trả lời của cổng.
|
||||
|
||||
``reason`` bắt buộc có khi DENY hoặc ASK — người dùng phải biết vì sao bị
|
||||
chặn, và ``core/audit_log.py`` cần nó để ghi lại.
|
||||
|
||||
``layer`` giữ đúng từ vựng của ``SecurityVerdict``: ``"prompt"`` |
|
||||
``"attachment"`` | ``"command"``, cộng thêm ``"policy"`` cho quyết định của
|
||||
chính cổng này.
|
||||
"""
|
||||
|
||||
outcome: PolicyOutcome
|
||||
reason: str = ""
|
||||
layer: str = "policy"
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
"""Tương thích với chỗ đang đọc ``SecurityVerdict.allowed``.
|
||||
|
||||
Chú ý: ``ASK`` KHÔNG phải allowed — còn phải hỏi người dùng đã.
|
||||
"""
|
||||
return self.outcome is PolicyOutcome.ALLOW
|
||||
|
||||
def __post_init__(self):
|
||||
if self.outcome is not PolicyOutcome.ALLOW and not self.reason:
|
||||
raise ValueError("DENY và ASK bắt buộc có reason — người dùng và "
|
||||
"audit log đều cần biết vì sao")
|
||||
|
||||
|
||||
def allow() -> PolicyDecision:
|
||||
return PolicyDecision(PolicyOutcome.ALLOW)
|
||||
|
||||
|
||||
def deny(reason: str, layer: str = "policy") -> PolicyDecision:
|
||||
return PolicyDecision(PolicyOutcome.DENY, reason, layer)
|
||||
|
||||
|
||||
def ask(reason: str, layer: str = "policy") -> PolicyDecision:
|
||||
return PolicyDecision(PolicyOutcome.ASK, reason, layer)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ToolPolicyGateway(Protocol):
|
||||
"""Hỏi trước khi chạy tool. Cài đặt thật: Team Hoa (R07, hạn 29/08)."""
|
||||
|
||||
def check(self, request: ToolCallRequest) -> PolicyDecision:
|
||||
"""Được chạy tool này không.
|
||||
|
||||
KHÔNG được tự bật hộp thoại bên trong — cổng chỉ *trả lời*, còn hỏi ai
|
||||
và hỏi thế nào là việc của tầng giao diện. Có vậy thì Co4E chạy nền mới
|
||||
dùng chung cổng được với Cowork chạy tương tác.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,44 @@
|
||||
"""ToolPolicyGateway giả — để N3 (Co4E) chạy được khi Team Hoa chưa cài đặt.
|
||||
|
||||
Mặc định cho qua hết, vì phần lớn test Co4E quan tâm tới luồng workflow chứ
|
||||
không phải chính sách. Test nào cần kiểm nhánh bị chặn thì lập trình câu trả
|
||||
lời::
|
||||
|
||||
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict
|
||||
|
||||
from cowork_local.domain.security.tool_policy import (
|
||||
PolicyDecision, ToolCallRequest, allow,
|
||||
)
|
||||
|
||||
|
||||
class FakeToolPolicyGateway:
|
||||
"""Cổng chính sách trong bộ nhớ, có ghi lại đã hỏi những gì."""
|
||||
|
||||
def __init__(self, rules: Dict[str, PolicyDecision] | None = None,
|
||||
default: PolicyDecision | None = None,
|
||||
decide: Callable[[ToolCallRequest], PolicyDecision] | None = None):
|
||||
#: {tên tool: quyết định} — tra trước default
|
||||
self.rules = dict(rules or {})
|
||||
self.default = default or allow()
|
||||
#: hàm tự quyết, dùng khi cần logic phức tạp hơn tra bảng
|
||||
self._decide = decide
|
||||
#: mọi lời gọi đã đi qua — để test khẳng định "có hỏi cổng không"
|
||||
self.seen: list[ToolCallRequest] = []
|
||||
|
||||
def check(self, request: ToolCallRequest) -> PolicyDecision:
|
||||
self.seen.append(request)
|
||||
if self._decide is not None:
|
||||
return self._decide(request)
|
||||
return self.rules.get(request.name, self.default)
|
||||
|
||||
# ---- tiện cho test --------------------------------------------------
|
||||
def asked_for(self, name: str) -> bool:
|
||||
return any(r.name == name for r in self.seen)
|
||||
|
||||
@property
|
||||
def call_count(self) -> int:
|
||||
return len(self.seen)
|
||||
@@ -79,3 +79,65 @@ def test_dung_duoc_fake_ma_khong_hề_nap_config_that():
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert "OK" in out.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolPolicyGateway — bản đề xuất Gamma viết hộ, chờ Team Hoa xác nhận.
|
||||
# N3 (Co4E) code dựa vào đây từ hôm nay thay vì tự phỏng đoán.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from cowork_local.domain.security.tool_policy import ( # noqa: E402
|
||||
PolicyOutcome, ToolCallRequest, ToolPolicyGateway, allow, ask, deny,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_tool_policy import ( # noqa: E402
|
||||
FakeToolPolicyGateway,
|
||||
)
|
||||
|
||||
|
||||
def test_fake_gateway_khop_hop_dong():
|
||||
assert isinstance(FakeToolPolicyGateway(), ToolPolicyGateway)
|
||||
|
||||
|
||||
def test_mac_dinh_cho_qua_va_co_ghi_lai_da_hoi():
|
||||
gate = FakeToolPolicyGateway()
|
||||
d = gate.check(ToolCallRequest(name="read_file", surface="co4e"))
|
||||
assert d.outcome is PolicyOutcome.ALLOW
|
||||
assert d.allowed is True
|
||||
assert gate.asked_for("read_file")
|
||||
assert gate.call_count == 1
|
||||
|
||||
|
||||
def test_chan_theo_ten_tool():
|
||||
gate = FakeToolPolicyGateway(rules={"run_command": deny("cấm trong Co4E")})
|
||||
assert gate.check(ToolCallRequest(name="run_command")).outcome is PolicyOutcome.DENY
|
||||
assert gate.check(ToolCallRequest(name="read_file")).allowed is True
|
||||
|
||||
|
||||
def test_ask_khong_phai_la_duoc_phep():
|
||||
"""Bẫy dễ mắc nhất: coi ASK như ALLOW thì tool chạy mà chưa ai đồng ý."""
|
||||
d = ask("cần người dùng xác nhận")
|
||||
assert d.outcome is PolicyOutcome.ASK
|
||||
assert d.allowed is False
|
||||
|
||||
|
||||
def test_deny_va_ask_bat_buoc_co_ly_do():
|
||||
"""Người dùng phải biết vì sao bị chặn, và audit log cần ghi lại."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
deny("")
|
||||
with pytest.raises(ValueError):
|
||||
ask("")
|
||||
allow() # ALLOW thì không cần lý do
|
||||
|
||||
|
||||
def test_chinh_sach_khac_nhau_theo_man():
|
||||
"""Co4E chạy nền nên không bật được hộp thoại — chặn thẳng thay vì hỏi."""
|
||||
def by_surface(req: ToolCallRequest):
|
||||
if req.surface == "co4e" and req.name == "run_command":
|
||||
return deny("Co4E chạy nền, không hỏi được người dùng")
|
||||
return ask("cần xác nhận") if req.name == "run_command" else allow()
|
||||
|
||||
gate = FakeToolPolicyGateway(decide=by_surface)
|
||||
assert gate.check(ToolCallRequest("run_command", surface="co4e")).outcome is PolicyOutcome.DENY
|
||||
assert gate.check(ToolCallRequest("run_command", surface="cowork")).outcome is PolicyOutcome.ASK
|
||||
|
||||
Reference in New Issue
Block a user