diff --git a/docs/refactor/GammaTeam_decisions.md b/docs/refactor/GammaTeam_decisions.md index 649f1de..99fbf27 100644 --- a/docs/refactor/GammaTeam_decisions.md +++ b/docs/refactor/GammaTeam_decisions.md @@ -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 diff --git a/domain/security/tool_policy.py b/domain/security/tool_policy.py new file mode 100644 index 0000000..f5d60f4 --- /dev/null +++ b/domain/security/tool_policy.py @@ -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. + """ + ... diff --git a/tests/fakes/fake_tool_policy.py b/tests/fakes/fake_tool_policy.py new file mode 100644 index 0000000..1e5c47f --- /dev/null +++ b/tests/fakes/fake_tool_policy.py @@ -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) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index a76fbdb..62a1b77 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -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