From 8a9ee5f875c194ba2e236d0014616ba0019f942c Mon Sep 17 00:00:00 2001 From: Nam Pham Dinh Thanh Date: Fri, 21 Aug 2026 20:58:35 +0900 Subject: [PATCH] =?UTF-8?q?chore(refactor):=20m=E1=BB=A5c=20chung=20c?= =?UTF-8?q?=E1=BB=A7a=20Team=20Gamma=20=E2=80=94=20khung,=20h=E1=BB=A3p=20?= =?UTF-8?q?=C4=91=E1=BB=93ng,=20c=E1=BB=95ng=20CASAN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sáu việc trong "mục chung" của bản phân công, làm trước khi ba nhánh tính năng tách ra. 1. Khung 5 tầng theo đúng đường dẫn plan.md: domain/ application/ infrastructure/ presentation/ platform/ + tests/fakes/ — 38 __init__.py. Trước đó là 0 file, mà mọi task của cả ba người đều ghi vào đây. Đã kiểm platform/ không che khuất module platform của stdlib. 2. Hợp đồng SecretStore và ConfigRepository (Protocol, chưa cài đặt) + fake chạy trong bộ nhớ. Danh sách thuộc tính không bịa: đếm 156 lời gọi ctx.config.* trong 29 file rồi lấy những cái dùng thật, xếp theo số lần. Cố ý bỏ config.data (36 lời gọi, nhiều nhất) — bê dict thô sang kiến trúc mới là bê nguyên vấn đề cũ. 3. tests/test_contracts.py — bài nghiệm thu, không phải test cho vui. Bài chính chạy tiến trình riêng và khẳng định dùng fake KHÔNG kéo theo cowork_local.config lẫn PySide6; đó là điều kiện để N2 và N3 code ngay hôm nay thay vì đợi bản thật ngày 23 và 26/08. 4. scripts/audit_security.py — CASAN Check 1, Gamma chủ trì (hạn 30/08). Viết sớm để kiểm liên tục trong lúc chuyển API key, không đợi tới ngày cổng. Lần chạy đầu ra 3 báo động giả (secret_in_output là tên quy tắc, api_key="x" là dữ liệu test) nên đã siết: ngưỡng độ dài, hằng liệt kê, hình dạng khoá i18n, và dấu "# casan: allow" làm lối thoát chuẩn. --self-test cắm 4 credential thật + 5 mẫu vô hại để chứng minh nó còn cắn được — một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó biết tìm. 5. Ba check CASAN vào CI, chạy mọi PR thay vì dồn tới 30/08. Check 2 và 3 thuộc Team Hoa và Team Duy, chưa có script — bước CI bỏ qua nếu file chưa tồn tại, để thêm cổng không làm đỏ CI của hai team kia. 6. docs/refactor/GammaTeam_decisions.md — hai quyết định chờ nhóm trưởng chốt: provider_conf() còn trả api_key hay không (ảnh hưởng 5 nơi, 3 nằm ngoài team), và số phận 24 checker UI sẽ vỡ khi file bị dời. 96 test xanh (90 cũ + 6 mới). CASAN Check 1: 0 credential lộ. Co-Authored-By: Claude Opus 5 (1M context) --- .gitea/workflows/ci.yaml | 29 +++ application/__init__.py | 1 + application/conversations/__init__.py | 0 application/model_routing/__init__.py | 0 application/monitoring/__init__.py | 0 application/scheduling/__init__.py | 0 application/settings/__init__.py | 0 application/workflows/__init__.py | 0 application/workspaces/__init__.py | 0 docs/refactor/GammaTeam_decisions.md | 107 ++++++++++ domain/__init__.py | 1 + domain/agents/__init__.py | 0 domain/models/__init__.py | 0 domain/security/__init__.py | 0 domain/tasks/__init__.py | 0 domain/tools/__init__.py | 0 domain/workflows/__init__.py | 0 infrastructure/__init__.py | 1 + infrastructure/config/__init__.py | 0 infrastructure/config/config_repository.py | 104 ++++++++++ infrastructure/filesystem/__init__.py | 0 infrastructure/mcp/__init__.py | 0 infrastructure/persistence/__init__.py | 0 infrastructure/persistence/json/__init__.py | 0 infrastructure/providers/__init__.py | 0 infrastructure/sandbox/__init__.py | 0 infrastructure/telemetry/__init__.py | 0 platform/__init__.py | 1 + platform/qt/__init__.py | 0 presentation/__init__.py | 1 + presentation/chat/__init__.py | 0 presentation/co4e/__init__.py | 0 presentation/dashboard/__init__.py | 0 presentation/folder/__init__.py | 0 presentation/graph/__init__.py | 0 presentation/monitoring/__init__.py | 0 presentation/scheduling/__init__.py | 0 presentation/settings/__init__.py | 0 presentation/shell/__init__.py | 0 scripts/audit_security.py | 204 ++++++++++++++++++++ tests/fakes/__init__.py | 1 + tests/fakes/fake_config.py | 138 +++++++++++++ tests/test_contracts.py | 81 ++++++++ 43 files changed, 669 insertions(+) create mode 100644 application/__init__.py create mode 100644 application/conversations/__init__.py create mode 100644 application/model_routing/__init__.py create mode 100644 application/monitoring/__init__.py create mode 100644 application/scheduling/__init__.py create mode 100644 application/settings/__init__.py create mode 100644 application/workflows/__init__.py create mode 100644 application/workspaces/__init__.py create mode 100644 docs/refactor/GammaTeam_decisions.md create mode 100644 domain/__init__.py create mode 100644 domain/agents/__init__.py create mode 100644 domain/models/__init__.py create mode 100644 domain/security/__init__.py create mode 100644 domain/tasks/__init__.py create mode 100644 domain/tools/__init__.py create mode 100644 domain/workflows/__init__.py create mode 100644 infrastructure/__init__.py create mode 100644 infrastructure/config/__init__.py create mode 100644 infrastructure/config/config_repository.py create mode 100644 infrastructure/filesystem/__init__.py create mode 100644 infrastructure/mcp/__init__.py create mode 100644 infrastructure/persistence/__init__.py create mode 100644 infrastructure/persistence/json/__init__.py create mode 100644 infrastructure/providers/__init__.py create mode 100644 infrastructure/sandbox/__init__.py create mode 100644 infrastructure/telemetry/__init__.py create mode 100644 platform/__init__.py create mode 100644 platform/qt/__init__.py create mode 100644 presentation/__init__.py create mode 100644 presentation/chat/__init__.py create mode 100644 presentation/co4e/__init__.py create mode 100644 presentation/dashboard/__init__.py create mode 100644 presentation/folder/__init__.py create mode 100644 presentation/graph/__init__.py create mode 100644 presentation/monitoring/__init__.py create mode 100644 presentation/scheduling/__init__.py create mode 100644 presentation/settings/__init__.py create mode 100644 presentation/shell/__init__.py create mode 100644 scripts/audit_security.py create mode 100644 tests/fakes/__init__.py create mode 100644 tests/fakes/fake_config.py create mode 100644 tests/test_contracts.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d981967..beb45be 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -39,3 +39,32 @@ jobs: - name: Run tests run: python -m pytest tests -q + + # --- CASAN Verification Gate ------------------------------------- + # Ba check này là điều kiện của cổng ngày 30/08. Chạy trên MỌI PR để + # biết vi phạm ngay hôm phát sinh, thay vì dồn tới ngày cổng. + # + # Check 1 do Team Gamma sở hữu và đã có. Check 2 (Team Hoa) và Check 3 + # (Team Duy) chưa viết — bước dưới bỏ qua nếu script chưa tồn tại, để + # thêm cổng không làm đỏ CI của hai team kia. + + - name: "CASAN Check 1 — không có credential lộ (Team Gamma)" + run: | + python scripts/audit_security.py --self-test + python scripts/audit_security.py + + - name: "CASAN Check 2 — file production ≤ 400 dòng (Team Hoa)" + run: | + if [ -f scripts/check_loc.py ]; then + python scripts/check_loc.py + else + echo "scripts/check_loc.py chưa có — Team Hoa viết, hạn 30/08. Bỏ qua." + fi + + - name: "CASAN Check 3 — domain/ và application/ không import PySide6 (Team Duy)" + run: | + if [ -f scripts/check_imports.py ]; then + python scripts/check_imports.py + else + echo "scripts/check_imports.py chưa có — Team Duy viết, hạn 30/08. Bỏ qua." + fi diff --git a/application/__init__.py b/application/__init__.py new file mode 100644 index 0000000..38608be --- /dev/null +++ b/application/__init__.py @@ -0,0 +1 @@ +"""application/ — Điều phối use-case. KHÔNG import PySide6. Gọi domain + interface hạ tầng.""" diff --git a/application/conversations/__init__.py b/application/conversations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/model_routing/__init__.py b/application/model_routing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/monitoring/__init__.py b/application/monitoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/scheduling/__init__.py b/application/scheduling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/settings/__init__.py b/application/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/workflows/__init__.py b/application/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/workspaces/__init__.py b/application/workspaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/refactor/GammaTeam_decisions.md b/docs/refactor/GammaTeam_decisions.md new file mode 100644 index 0000000..649f1de --- /dev/null +++ b/docs/refactor/GammaTeam_decisions.md @@ -0,0 +1,107 @@ +# Hai quyết định chờ nhóm trưởng chốt — Team Gamma + +Hai việc này không code được cho tới khi có người quyết. Cả hai đều ảnh hưởng +ra ngoài phạm vi một người, nên để đây thay vì chôn trong comment. + +Trạng thái: **chưa chốt**. Hạn: trước khi N1 bắt đầu R02-T05 (26/08). + +--- + +## Quyết định 1 — `provider_conf()` còn trả `api_key` hay không + +### Vì sao phải quyết trước khi code + +R02-T05 chuyển API key sang Keyring. Câu hỏi là sau khi chuyển, dict do +`provider_conf()` trả về **còn chứa `api_key` không**. + +Có 5 nơi đang đọc trực tiếp — đo trên `main` ngày 21/08: + +| Nơi đọc | Thuộc | +|---|---| +| `providers/anthropic.py:26` | **Team Duy** | +| `providers/openai_compat.py:36` | **Team Duy** | +| `core/image_gen.py:50` | Team Duy (routing/model) | +| `core/ext_connectors.py:98` | Team Hoa | +| `ui/ext_connector_dialog.py:87` | Team Gamma | + +Ba trong năm nằm ngoài team. Quyết một mình rồi im lặng là làm vỡ code người khác. + +### Hai đường + +**A. Giữ `api_key` trong dict, `ConfigRepository` tự lấy từ `SecretStore` rồi ghép vào** + +- 5 nơi đọc **không phải sửa dòng nào** +- Không cần báo team khác, không cần đồng bộ lịch +- Đổi lại: bí mật vẫn đi lang thang trong dict, dễ lọt vào log hoặc màn hình debug +- CASAN Check 1 vẫn PASS vì nó quét **file trên đĩa**, không quét bộ nhớ + +**B. Bỏ `api_key` khỏi dict, ai cần thì gọi `secrets.get(provider_key(name))`** + +- Sạch về nguyên tắc: bí mật chỉ xuất hiện đúng chỗ cần +- Đổi lại: **5 nơi phải sửa**, 3 trong đó phải chờ team khác xếp lịch +- Rủi ro: quên một chỗ thì mất API key lúc chạy thật, mà test có fake nên không bắt được + +### Đề xuất + +**Đường A cho sprint này, đường B ghi vào nợ kỹ thuật.** + +Lý do: mục tiêu của cổng CASAN là *không còn secret nằm trên đĩa*, và đường A +đạt được điều đó. Đường B giải quyết thêm chuyện secret trong bộ nhớ — đúng +nhưng không phải việc của 10 ngày này, và nó kéo hai team khác vào một thay đổi +họ không lên kế hoạch. + +Nếu chọn B thì **phải báo Team Duy và Team Hoa trong hôm nay**, không phải lúc +đã sửa xong. + +> Nhóm trưởng chốt: ☐ A ☐ B — ngày ____ + +--- + +## Quyết định 2 — số phận 24 checker UI + +### Vấn đề + +`tools/check_*.py` là bộ kiểm tra giao diện viết trong 2 tuần vừa rồi, hiện +**24 file**. Chúng bám vào đường dẫn cũ: + +| Import | Số chỗ | +|---|---| +| `cowork_local.config` | 34 | +| `cowork_local.app` | 16 | +| `cowork_local.state` | 22 | +| `cowork_local.ui.*` | ~12 | + +R08 dời hết những module đó sang `presentation/`. Nghĩa là **cả 24 checker chết +ngay ngày N1 đụng `config.py`** — và đó là lưới an toàn duy nhất cho phần giao +diện, vì `pytest` không kiểm giao diện (90 test hiện tại là logic). + +### Ba đường + +**A. Ai dời file thì cập nhật checker tương ứng, ngay trong PR đó** + +- Giữ được lưới suốt 10 ngày +- Tốn thêm ~15% thời gian mỗi PR +- Rủi ro: người sửa vội có thể nới lỏng phép kiểm cho nó xanh — đã xảy ra một + lần trong quá trình làm UI, khi một checker được sửa thành *không thể đỏ* + +**B. Đóng băng: bỏ khỏi CI, sửa một lượt ngày 31/08** + +- Nhanh nhất trong 10 ngày +- Đổi lại: **không có gì canh hồi quy giao diện** suốt cả sprint. Refactor là lúc + dễ vỡ giao diện nhất +- Rủi ro cuối sprint: sửa 24 file cùng lúc, không ai nhớ cái nào đo gì + +**C. Bỏ hẳn** + +Không khuyến nghị. Vứt đi hai tuần công sức kiểm chứng, và ba tài liệu refactor +không có gì thay thế cho phần giao diện. + +### Đề xuất + +**Đường A**, kèm một ràng buộc: PR nào *sửa* checker phải nói rõ trong mô tả +**sửa gì và vì sao** — để việc nới lỏng phép kiểm không lọt qua review. + +`tools/check_probes_bite.py` đã có sẵn cơ chế chứng minh checker còn cắn được; +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 ____ diff --git a/domain/__init__.py b/domain/__init__.py new file mode 100644 index 0000000..22928ba --- /dev/null +++ b/domain/__init__.py @@ -0,0 +1 @@ +"""domain/ — Quy tắc nghiệp vụ thuần. KHÔNG import PySide6, không chạm đĩa/mạng.""" diff --git a/domain/agents/__init__.py b/domain/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/domain/models/__init__.py b/domain/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/domain/security/__init__.py b/domain/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/domain/tasks/__init__.py b/domain/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/domain/tools/__init__.py b/domain/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/domain/workflows/__init__.py b/domain/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/__init__.py b/infrastructure/__init__.py new file mode 100644 index 0000000..cfd9280 --- /dev/null +++ b/infrastructure/__init__.py @@ -0,0 +1 @@ +"""infrastructure/ — Chạm thế giới thật: file, keyring, HTTP, tiến trình. Cài đặt interface.""" diff --git a/infrastructure/config/__init__.py b/infrastructure/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/config/config_repository.py b/infrastructure/config/config_repository.py new file mode 100644 index 0000000..476c58e --- /dev/null +++ b/infrastructure/config/config_repository.py @@ -0,0 +1,104 @@ +"""Cấu hình ứng dụng — interface, chưa phải cài đặt. + +Hợp đồng số 2 của mục chung. Đây là thứ gỡ chốt lớn nhất: **156 lời gọi +``ctx.config.*`` nằm rải trong 29 file**, nên nếu N2 và N3 phải đợi +``ConfigRepository`` bản thật (R02-T02, hạn 23/08) thì hai người mất mấy ngày +đầu ngồi không. + +Danh sách thuộc tính dưới đây không bịa ra: đếm trực tiếp chỗ đang gọi trong +``core/``, ``ui/``, ``providers/`` và ``app.py`` rồi lấy những cái được dùng +thật, xếp theo số lần gọi. + +Một chỗ cố ý KHÔNG đưa vào: ``config.data`` (36 lần gọi, nhiều nhất). Đó là +đống dict thô — cho nó vào interface là bê nguyên vấn đề cũ sang kiến trúc mới. +Ai đang cần ``data`` thì mở issue để bổ sung một thuộc tính có kiểu rõ ràng. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Protocol, runtime_checkable + + +@runtime_checkable +class ConfigRepository(Protocol): + """Đọc/ghi cấu hình. Cài đặt thật dùng ``AtomicJsonFile`` (R02-T01/T02).""" + + # ---- provider ------------------------------------------------------ + @property + def active_provider(self) -> str: + """Tên provider đang chọn (24 lời gọi).""" + ... + + def set_active_provider(self, name: str) -> None: + ... + + def provider_conf(self, name: str | None = None) -> Dict[str, Any]: + """Cấu hình của một provider (9 lời gọi). + + CHÚ Ý — điểm còn bỏ ngỏ, xem ``docs/refactor/GammaTeam_decisions.md``: + dict này còn chứa ``api_key`` hay không là quyết định chưa chốt. Có 5 + nơi đang đọc trực tiếp, 3 trong số đó thuộc ``providers/`` của Team Duy. + """ + ... + + # ---- đường dẫn ----------------------------------------------------- + @property + def shared_dir(self) -> str: + """Thư mục dùng chung cho telemetry nhiều máy (10 lời gọi).""" + ... + + def history_dir(self) -> Path: + """Thư mục lịch sử chat của project đang chọn (7 lời gọi).""" + ... + + def cowork_output_dir(self) -> Path: + """Thư mục Cowork ghi kết quả ra (6 lời gọi).""" + ... + + # ---- giao diện ----------------------------------------------------- + @property + def theme(self) -> str: + """``"dark"`` | ``"light"`` | ``"system"`` (8 lời gọi).""" + ... + + def set_theme(self, value: str) -> None: + ... + + @property + def language(self) -> str: + """``"vi"`` | ``"en"`` | ``"ja"`` (4 lời gọi).""" + ... + + def set_language(self, value: str) -> None: + ... + + # ---- các nhóm cấu hình còn lại ------------------------------------- + @property + def routing(self) -> Dict[str, Any]: + """Cấu hình định tuyến model (7 lời gọi).""" + ... + + @property + def auth(self) -> Dict[str, Any]: + """Cấu hình đăng nhập (6 lời gọi).""" + ... + + @property + def agent_security(self) -> Dict[str, Any]: + """Chính sách an toàn cho agent (5 lời gọi).""" + ... + + @property + def tools_disabled(self) -> list[str]: + """Tool bị tắt (2 lời gọi).""" + ... + + def set_tool_enabled(self, name: str, enabled: bool) -> None: + ... + + # ---- ghi ------------------------------------------------------------ + def save(self) -> None: + """Ghi xuống đĩa. Bản thật ghi atomic — tạm + fsync + thay thế — + nên tắt máy giữa chừng không làm hỏng file (R02-T01). + """ + ... diff --git a/infrastructure/filesystem/__init__.py b/infrastructure/filesystem/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/mcp/__init__.py b/infrastructure/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/persistence/__init__.py b/infrastructure/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/persistence/json/__init__.py b/infrastructure/persistence/json/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/providers/__init__.py b/infrastructure/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/sandbox/__init__.py b/infrastructure/sandbox/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/telemetry/__init__.py b/infrastructure/telemetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/platform/__init__.py b/platform/__init__.py new file mode 100644 index 0000000..1b2487c --- /dev/null +++ b/platform/__init__.py @@ -0,0 +1 @@ +"""platform/ — Adapter riêng cho Qt (clock, thread, timer).""" diff --git a/platform/qt/__init__.py b/platform/qt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/__init__.py b/presentation/__init__.py new file mode 100644 index 0000000..c56cd8d --- /dev/null +++ b/presentation/__init__.py @@ -0,0 +1 @@ +"""presentation/ — Widget Qt. Chỉ gọi xuống application, không gọi thẳng infrastructure.""" diff --git a/presentation/chat/__init__.py b/presentation/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/co4e/__init__.py b/presentation/co4e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/dashboard/__init__.py b/presentation/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/folder/__init__.py b/presentation/folder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/graph/__init__.py b/presentation/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/monitoring/__init__.py b/presentation/monitoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/scheduling/__init__.py b/presentation/scheduling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/settings/__init__.py b/presentation/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/shell/__init__.py b/presentation/shell/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/audit_security.py b/scripts/audit_security.py new file mode 100644 index 0000000..110cccd --- /dev/null +++ b/scripts/audit_security.py @@ -0,0 +1,204 @@ +"""CASAN Check 1 — không được có credential nào nằm phơi trong repo. + +Team Gamma chủ trì check này (hạn: 30/08). Viết sẵn từ 21/08 để chạy được liên +tục trong lúc chuyển API key sang Keyring (R02-T05), thay vì tới ngày cổng mới +chạy lần đầu rồi mới biết còn sót. + +Quét gì: + * file cấu hình đã commit: ``*.json`` ``*.jsonl`` ``*.yaml`` ``*.yml`` ``*.env`` + * mã nguồn Python — chỗ gán chuỗi cho biến tên như api_key / token / secret + +Tìm hai loại: + 1. Chuỗi có hình dạng credential thật (sk-…, ghp_…, xoxb-…, AKIA…, JWT…) + 2. Trường tên nhạy cảm mà giá trị không rỗng và không phải placeholder + +Bỏ qua: chuỗi rỗng, placeholder ("your-key-here", "changeme"…), giá trị hằng +không phải bí mật (Ollama đòi có api_key nhưng bỏ qua nội dung). + +Chạy: python scripts/audit_security.py [--json] +Mã thoát: 0 = sạch, 1 = có phát hiện. +""" +from __future__ import annotations + +import argparse +import json +import re +import sys + +# console Windows hay là cp932/cp1258; ép UTF-8 để không chết giữa báo cáo +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "build", + "dist", ".pytest_cache", ".mypy_cache", "cowork-local-gitea"} +CONFIG_SUFFIX = {".json", ".jsonl", ".yaml", ".yml", ".env"} + +# tên trường coi là nhạy cảm +SENSITIVE = re.compile( + r"(api[_-]?key|secret|token|password|passwd|client[_-]?secret|" + r"access[_-]?key|private[_-]?key|credential)", re.I) + +# hình dạng credential thật — bắt được kể cả khi tên trường vô hại +SHAPES = [ + ("OpenAI", re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}")), + ("Anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")), + ("GitHub", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}")), + ("Slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}")), + ("AWS", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), + ("Google", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")), + ("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.")), + ("Private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), +] + +#: Dòng có dấu này được bỏ qua — lối thoát chuẩn cho mẫu thử, tài liệu, hằng +#: đặt tên chứa "secret". Bắt buộc ghi lý do sau dấu hai chấm. +ALLOW_MARK = re.compile(r"#\s*casan:\s*allow") + +#: Giá trị là KHOÁ i18n / tên hằng, không phải bí mật. Bắt bằng hình dạng +#: "a.b.c" hoặc "a_b_c" chứ không phải bằng danh sách đen từng chữ. +LOOKS_LIKE_KEY = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$") + +#: Credential thật gần như luôn dài hơn thế này. Ngưỡng để loại dữ liệu test +#: kiểu api_key="x" — báo động giả làm cả đội thôi đọc báo cáo. +MIN_SECRET_LEN = 12 + +#: Giá trị là hằng liệt kê, không phải bí mật: mức độ cảnh báo, bật/tắt… +ENUMISH = {"warning", "warn", "error", "info", "debug", "critical", "on", "off", + "true", "false", "yes", "no", "allow", "deny", "block", "ask", + "always", "never", "auto", "default", "disabled", "enabled"} + +# giá trị vô hại — không tính là phát hiện +PLACEHOLDER = re.compile( + r"^(|ollama|none|null|changeme|your[_\- ]?(api[_\- ]?)?key([_\- ]?here)?|" + r"<[^>]*>|\{\{.*\}\}|\$\{.*\}|xxx+|\*+|placeholder|todo|example|test|dummy|" + r"sk-\.\.\.|\.\.\.)$", re.I) + +# gán chuỗi trong Python: api_key = "..." +PY_ASSIGN = re.compile( + r"""["']?(\w*(?:api[_-]?key|secret|token|password|credential)\w*)["']?\s*[:=]\s*""" + r"""["']([^"']*)["']""", re.I) + + +def _is_placeholder(value: str) -> bool: + v = value.strip() + if PLACEHOLDER.match(v) or v.lower() in ENUMISH: + return True + if LOOKS_LIKE_KEY.match(v): # "monitoring.action_secret_in_output" + return True + # quá ngắn để là credential thật + return len(v) < MIN_SECRET_LEN + + +def _walk(): + for path in REPO.rglob("*"): + if not path.is_file(): + continue + if any(part in SKIP_DIRS for part in path.parts): + continue + if path.suffix in CONFIG_SUFFIX or path.suffix == ".py": + yield path + + +def scan() -> list[dict]: + findings: list[dict] = [] + for path in _walk(): + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + rel = path.relative_to(REPO).as_posix() + + for lineno, line in enumerate(text.splitlines(), 1): + if ALLOW_MARK.search(line): + continue + # 1. hình dạng credential thật + for label, pattern in SHAPES: + m = pattern.search(line) + if m: + findings.append({ + "file": rel, "line": lineno, "kind": f"{label} credential", + "evidence": m.group(0)[:12] + "…", + }) + + # 2. trường nhạy cảm có giá trị + for m in PY_ASSIGN.finditer(line): + field, value = m.group(1), m.group(2) + if not SENSITIVE.search(field) or _is_placeholder(value): + continue + findings.append({ + "file": rel, "line": lineno, + "kind": f"trường '{field}' có giá trị", + "evidence": value[:6] + "…" if len(value) > 6 else value, + }) + return findings + + +def _self_test() -> int: + """Một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó + biết tìm. Cắm mẫu xấu và mẫu vô hại, xem có phân biệt đúng không.""" + import tempfile + + bad = { + "OpenAI": '"api_key": "sk-proj-abc123def456ghi789jkl012mno"', # casan: allow - mau thu cua chinh script + "GitHub": 'token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', # casan: allow - mau thu cua chinh script + "AWS": 'aws = "AKIAIOSFODNN7EXAMPLE"', # casan: allow - mau thu cua chinh script + "Anthropic": '"api_key": "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxx"', # casan: allow - mau thu cua chinh script + } + ok = { + "rỗng": '"api_key": ""', + "placeholder": '"api_key": "your-key-here"', + "ollama": '"api_key": "ollama"', + "test ngắn": 'api_key = "x"', + "hằng liệt kê": '"secret_in_output": "warning"', + } + global REPO + keep = REPO + passed = True + with tempfile.TemporaryDirectory() as tmp: + REPO = Path(tmp) + for label, line in {**bad, **ok}.items(): + (REPO / "probe.py").write_text(line + "\n", encoding="utf-8") + found = bool(scan()) + want = label in bad + mark = "OK " if found == want else "SAI" + if found != want: + passed = False + verb = "bắt được" if found else "bỏ qua" + print(f" [{mark}] {label:14} -> {verb}") + REPO = keep + print() + print("Tự kiểm: " + ("script phân biệt đúng." if passed + else "*** script phân biệt SAI ***")) + return 0 if passed else 1 + + +def main() -> int: + ap = argparse.ArgumentParser(description="CASAN Check 1 — quét credential lộ") + ap.add_argument("--json", action="store_true", help="in kết quả dạng JSON") + ap.add_argument("--self-test", action="store_true", + help="cắm credential giả vào file tạm, kiểm script có bắt được") + args = ap.parse_args() + + if args.self_test: + return _self_test() + + findings = scan() + if args.json: + print(json.dumps(findings, ensure_ascii=False, indent=2)) + else: + n_files = sum(1 for _ in _walk()) + print(f"CASAN Check 1 — quét {n_files} file trong {REPO.name}/") + if not findings: + print("\n0 credential lưu plaintext. PASS.") + else: + print(f"\n*** {len(findings)} phát hiện ***\n") + for f in findings: + print(f" {f['file']}:{f['line']}") + print(f" {f['kind']} — {f['evidence']}") + return 1 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py new file mode 100644 index 0000000..fa043ea --- /dev/null +++ b/tests/fakes/__init__.py @@ -0,0 +1 @@ +"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt.""" diff --git a/tests/fakes/fake_config.py b/tests/fakes/fake_config.py new file mode 100644 index 0000000..9d2b65f --- /dev/null +++ b/tests/fakes/fake_config.py @@ -0,0 +1,138 @@ +"""Bản giả của ConfigRepository và SecretStore — chạy trong bộ nhớ. + +Dùng để N2 (Giám sát) và N3 (Co4E) code và test ngay từ 21/08, không phải đợi +bản thật xong ngày 23/08 và 26/08. + +Không chạm đĩa, không chạm keyring, không cần Qt. Test dùng nó chạy trong vài +mili giây. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict + + +class FakeSecretStore: + """SecretStore trong bộ nhớ. + + >>> s = FakeSecretStore({"provider:openai": "sk-test"}) + >>> s.get("provider:openai") + 'sk-test' + >>> s.get("provider:chua-co") is None + True + """ + + def __init__(self, seed: Dict[str, str] | None = None): + self._items: Dict[str, str] = dict(seed or {}) + + def get(self, key: str) -> str | None: + return self._items.get(key) + + def set(self, key: str, value: str) -> None: + self._items[key] = value + + def delete(self, key: str) -> None: + self._items.pop(key, None) + + def has(self, key: str) -> bool: + return key in self._items + + +class FakeConfigRepository: + """ConfigRepository trong bộ nhớ, có sẵn giá trị mặc định hợp lý. + + Mọi thứ ghi đè được qua tham số khởi tạo, nên test dựng đúng tình huống + mình cần:: + + cfg = FakeConfigRepository(theme="light", shared_dir="/tmp/chung") + """ + + def __init__(self, *, active_provider: str = "ollama", + providers: Dict[str, Dict[str, Any]] | None = None, + shared_dir: str = "", theme: str = "dark", language: str = "vi", + routing: Dict[str, Any] | None = None, + auth: Dict[str, Any] | None = None, + agent_security: Dict[str, Any] | None = None, + tools_disabled: list[str] | None = None, + history_dir: Path | None = None, + output_dir: Path | None = None): + self._active_provider = active_provider + self._providers = providers or { + "ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3"}, + "openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini"}, + } + self._shared_dir = shared_dir + self._theme = theme + self._language = language + self._routing = routing or {"mode": "off"} + self._auth = auth or {} + self._agent_security = agent_security or {"cowork_confirm_commands": True} + self._tools_disabled = list(tools_disabled or []) + self._history_dir = history_dir or Path("/fake/history") + self._output_dir = output_dir or Path("/fake/workspace") + #: số lần save() được gọi — để test khẳng định "có ghi" mà không cần đĩa + self.saves = 0 + + # ---- provider ------------------------------------------------------ + @property + def active_provider(self) -> str: + return self._active_provider + + def set_active_provider(self, name: str) -> None: + self._active_provider = name + + def provider_conf(self, name: str | None = None) -> Dict[str, Any]: + return dict(self._providers.get(name or self._active_provider, {})) + + # ---- đường dẫn ----------------------------------------------------- + @property + def shared_dir(self) -> str: + return self._shared_dir + + def history_dir(self) -> Path: + return self._history_dir + + def cowork_output_dir(self) -> Path: + return self._output_dir + + # ---- giao diện ----------------------------------------------------- + @property + def theme(self) -> str: + return self._theme + + def set_theme(self, value: str) -> None: + self._theme = value + + @property + def language(self) -> str: + return self._language + + def set_language(self, value: str) -> None: + self._language = value + + # ---- nhóm cấu hình -------------------------------------------------- + @property + def routing(self) -> Dict[str, Any]: + return self._routing + + @property + def auth(self) -> Dict[str, Any]: + return self._auth + + @property + def agent_security(self) -> Dict[str, Any]: + return self._agent_security + + @property + def tools_disabled(self) -> list[str]: + return list(self._tools_disabled) + + def set_tool_enabled(self, name: str, enabled: bool) -> None: + if enabled: + self._tools_disabled = [t for t in self._tools_disabled if t != name] + elif name not in self._tools_disabled: + self._tools_disabled.append(name) + + # ---- ghi ------------------------------------------------------------ + def save(self) -> None: + self.saves += 1 diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..a76fbdb --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,81 @@ +"""Hợp đồng của mục chung có thật sự gỡ chốt cho N2 và N3 không. + +Đây là bài nghiệm thu, không phải test cho vui: nếu ba bài dưới đây xanh thì +hai nhánh kia code được ngay hôm nay mà không cần chờ ``ConfigRepository`` hay +``KeyringAdapter`` bản thật. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from cowork_local.infrastructure.config.config_repository import ConfigRepository +from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key +from cowork_local.tests.fakes.fake_config import FakeConfigRepository, FakeSecretStore + +REPO_PARENT = Path(__file__).resolve().parents[2] + + +def test_fake_config_khop_hop_dong(): + """Fake phải cài đủ interface — thiếu một hàm là hai nhánh kia gọi vào sẽ vỡ.""" + assert isinstance(FakeConfigRepository(), ConfigRepository) + + +def test_fake_secret_store_khop_hop_dong(): + assert isinstance(FakeSecretStore(), SecretStore) + + +def test_secret_store_thieu_key_thi_tra_none_chu_khong_nem_loi(): + """Thiếu API key là chuyện thường (người dùng chưa nhập), không phải sự cố.""" + store = FakeSecretStore() + assert store.get(provider_key("openai")) is None + assert store.has(provider_key("openai")) is False + store.delete(provider_key("openai")) # xoá cái không có: im lặng + + store.set(provider_key("openai"), "sk-test") + assert store.get(provider_key("openai")) == "sk-test" + assert store.has(provider_key("openai")) is True + + +def test_config_gia_ghi_nhan_save_ma_khong_cham_dia(): + cfg = FakeConfigRepository(theme="light") + assert cfg.theme == "light" + cfg.set_theme("dark") + cfg.save() + assert cfg.theme == "dark" + assert cfg.saves == 1 + + +def test_bat_duoc_tool_bi_tat(): + cfg = FakeConfigRepository(tools_disabled=["run_command"]) + assert cfg.tools_disabled == ["run_command"] + cfg.set_tool_enabled("run_command", True) + assert cfg.tools_disabled == [] + cfg.set_tool_enabled("write_file", False) + assert cfg.tools_disabled == ["write_file"] + + +def test_dung_duoc_fake_ma_khong_hề_nap_config_that(): + """Bài nghiệm thu chính của mục chung. + + N2 và N3 phải dựng được màn hình và chạy test của mình mà KHÔNG kéo theo + ``cowork_local.config`` — module nặng, đọc đĩa, và đang bị N1 viết lại. + Kiểm bằng tiến trình riêng để không dính module đã nạp sẵn ở test khác. + """ + snippet = ( + "import sys\n" + "from cowork_local.tests.fakes.fake_config import " + "FakeConfigRepository, FakeSecretStore\n" + "cfg = FakeConfigRepository(active_provider='openai')\n" + "assert cfg.provider_conf()['model'] == 'gpt-4o-mini'\n" + "assert FakeSecretStore().get('x') is None\n" + "assert 'cowork_local.config' not in sys.modules, " + "'fake keo theo config that -> van con phu thuoc'\n" + "assert 'PySide6' not in sys.modules, 'fake keo theo Qt -> test se cham'\n" + "print('OK')\n" + ) + out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO_PARENT, + capture_output=True, text=True, timeout=60) + assert out.returncode == 0, out.stderr + assert "OK" in out.stdout