diff --git a/i18n/cowork_tab.py b/i18n/cowork_tab.py index da59d8b..de5c2b5 100644 --- a/i18n/cowork_tab.py +++ b/i18n/cowork_tab.py @@ -153,6 +153,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "settings.group.general": {"en": "General", "ja": "一般", "vi": "Chung"}, "settings.group.provider": {"en": "AI Provider", "ja": "AI プロバイダー", "vi": "Nhà cung cấp AI"}, "settings.group.parameter": {"en": "Parameter", "ja": "Parameter", "vi": "Parameter"}, + "settings.group.about": {"en": "About", "ja": "このアプリについて", "vi": "Giới thiệu"}, "settings.param_section_pricing": { "en": "Model pricing", "ja": "モデル価格", "vi": "Bảng giá model"}, "settings.pricing_url_label": { @@ -328,6 +329,27 @@ STRINGS: Dict[str, Dict[str, str]] = { "settings.group.sandbox": { "en": "Sandbox Security Layer", "ja": "サンドボックス セキュリティ層", "vi": "Sandbox Security Layer"}, + "settings.sec_enabled": { + "en": "Enable Agent Security (command validation)", + "ja": "エージェントセキュリティを有効化(コマンド検証)", + "vi": "Bật Agent Security (kiểm tra lệnh)"}, + "settings.sec_enabled_tooltip": { + "en": "Turn the whole Agent Security layer on or off.", + "ja": "エージェントセキュリティ層全体をオン/オフします。", + "vi": "Bật/tắt toàn bộ tầng Agent Security."}, + "settings.ai_check": { + "en": "AI check commands", "ja": "AIによるコマンド検査", + "vi": "AI kiểm tra lệnh"}, + "settings.ai_check_tooltip": { + "en": "Let the control agent review a command with AI before it runs.", + "ja": "実行前に制御エージェントがAIでコマンドを確認します。", + "vi": "Cho control-agent dùng AI xét lệnh trước khi chạy."}, + "settings.sandbox_pw_unset_title": { + "en": "Sandbox Security", "ja": "サンドボックスセキュリティ", "vi": "Bảo mật Sandbox"}, + "settings.sandbox_pw_unset_body": { + "en": "No sandbox password is set yet, so these settings stay locked. Set COWORK_SANDBOX_PASSWORD, or ask your administrator.", + "ja": "サンドボックスのパスワードが未設定のため、この設定はロックされたままです。COWORK_SANDBOX_PASSWORD を設定するか、管理者にお問い合わせください。", + "vi": "Chưa đặt mật khẩu sandbox nên nhóm thiết lập này vẫn khóa. Hãy đặt COWORK_SANDBOX_PASSWORD, hoặc liên hệ quản trị viên."}, "settings.sandbox_confirm_commands": { "en": "Confirm before Cowork runs a command", "ja": "Cowork がコマンドを実行する前に確認する", diff --git a/tests/ui/test_sandbox_unlock_security.py b/tests/ui/test_sandbox_unlock_security.py new file mode 100644 index 0000000..3f89e9e --- /dev/null +++ b/tests/ui/test_sandbox_unlock_security.py @@ -0,0 +1,235 @@ +"""Sandbox Security unlock — chốt các đường KHÔNG được mở khoá (SEC-20260907-01). + +``DEFAULT_CONFIG`` ship ``agent_security.sandbox_pw = ""`` kể từ commit +``3827552 fix(security): remove shared unlock defaults``, và cấu hình đưa tới +dialog LUÔN được deep-merge với defaults đó +(``infrastructure/config/json_config_repository.py``). Nghĩa là trên mọi bản cài +không đặt ``COWORK_SANDBOX_PASSWORD``, mật khẩu đã lưu là chuỗi rỗng — và phép so +sánh ``pw == self._sandbox_pw`` nhận luôn ô nhập trống. + +Ba nhóm bài ở đây: + +* **đường tấn công** — chốt đúng lỗ trên; +* **đường đi đúng** — bản vá không được phá, kể cả với mật khẩu có dấu; +* **chặn cả lớp lỗi** — commit ``3827552`` sửa ``config.py`` nhưng bỏ sót bản sao + thứ hai của literal trong ``ui/settings_dialog.py``. Bài cuối quét chéo mọi thư + mục nguồn để lần sau không sót kiểu đó nữa. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +# Fake ctx dùng chung với đặc tả SettingsDialog: nó đã là "đủ cho SettingsDialog, +# không hơn". Dựng bản thứ hai ở đây chỉ tạo thêm một chỗ để lệch nhau. +from .test_settings_dialog_dac_ta import _Ctx + + +@pytest.fixture +def shown(monkeypatch): + """Ghi lại mọi QMessageBox thay vì bật modal thật (modal sẽ treo test). + + Trả về list các ``(loại, tiêu_đề, nội_dung)`` — cần thiết để phân biệt + "chưa cấu hình mật khẩu" với "sai mật khẩu"; nếu chỉ nuốt hộp thoại đi thì + hai nhánh gộp lại làm một mà test vẫn xanh. + """ + from PySide6.QtWidgets import QMessageBox + calls: list[tuple[str, str, str]] = [] + + def _record(kind): + def _fn(_parent, title, text, *a, **k): + calls.append((kind, title, text)) + return staticmethod(_fn) + + monkeypatch.setattr(QMessageBox, "warning", _record("warning")) + monkeypatch.setattr(QMessageBox, "information", _record("information")) + return calls + + +def _dialog(stored_pw: str): + """SettingsDialog với ``sandbox_pw`` đúng như bản cài thật: key CÓ mặt.""" + from cowork_local.ui.settings_dialog import SettingsDialog + ctx = _Ctx() + ctx.config.data["agent_security"]["sandbox_pw"] = stored_pw + return SettingsDialog(ctx) + + +# ---- đường tấn công ------------------------------------------------------ + +def test_o_trong_khong_mo_duoc_khoa(qapp, shown): + """Chưa đặt mật khẩu (sandbox_pw == "") thì ô nhập trống KHÔNG được mở khoá.""" + dlg = _dialog("") + dlg.sandbox_pw_edit.setText("") + + dlg._sandbox_unlock() + + assert dlg._sandbox_unlocked is False + dlg.deleteLater() + + +def test_go_bua_khi_chua_dat_mat_khau_cung_khong_mo_duoc(qapp, shown): + """Mật khẩu lưu rỗng thì KHÔNG chuỗi nào mở được, kể cả chuỗi khác rỗng.""" + dlg = _dialog("") + dlg.sandbox_pw_edit.setText("bat ky") + + dlg._sandbox_unlock() + + assert dlg._sandbox_unlocked is False + dlg.deleteLater() + + +def test_mat_khau_sai_khong_mo_duoc(qapp, shown): + """Đã đặt mật khẩu thì gõ sai vẫn khoá.""" + dlg = _dialog("K7MNP2QRSTVW") + dlg.sandbox_pw_edit.setText("K7MNP2QRSTVX") + + dlg._sandbox_unlock() + + assert dlg._sandbox_unlocked is False + dlg.deleteLater() + + +# ---- thông báo phải phân biệt được hai tình huống ------------------------- + +def test_chua_cau_hinh_bao_khac_voi_sai_mat_khau(qapp, shown): + """Hai nhánh phải nói hai chuyện khác nhau. + + Người chưa từng đặt mật khẩu mà nhận "Password incorrect" sẽ gõ lại mãi một + thứ không tồn tại. Không có bài này thì gộp hai nhánh về một thông báo chung + vẫn xanh hết. + """ + from cowork_local.i18n import tr + + dlg = _dialog("") + dlg.sandbox_pw_edit.setText("") + dlg._sandbox_unlock() + chua_cau_hinh = list(shown) + dlg.deleteLater() + + shown.clear() + dlg2 = _dialog("K7MNP2QRSTVW") + dlg2.sandbox_pw_edit.setText("sai roi") + dlg2._sandbox_unlock() + sai_mat_khau = list(shown) + dlg2.deleteLater() + + assert len(chua_cau_hinh) == 1, "phải hiện đúng một thông báo" + assert len(sai_mat_khau) == 1 + assert chua_cau_hinh[0][2] == tr("settings.sandbox_pw_unset_body") + assert chua_cau_hinh[0][2] != sai_mat_khau[0][2], ( + "chưa cấu hình mật khẩu và sai mật khẩu phải là hai thông báo khác nhau") + + +# ---- đường đi đúng vẫn phải chạy ---------------------------------------- + +def test_mat_khau_dung_van_mo_duoc(qapp, shown): + """Bản vá không được phá đường đi hợp lệ.""" + dlg = _dialog("K7MNP2QRSTVW") + dlg.sandbox_pw_edit.setText("K7MNP2QRSTVW") + + dlg._sandbox_unlock() + + assert dlg._sandbox_unlocked is True + dlg.deleteLater() + + +@pytest.mark.parametrize("pw", ["mật khẩu", "パスワード", "sénhà-2026"]) +def test_mat_khau_co_dau_khong_lam_crash(qapp, shown, pw): + """``secrets.compare_digest`` ném TypeError nếu str có ký tự ngoài ASCII. + + App mặc định tiếng Việt và phục vụ khách Nhật, nên chữ có dấu trong ô mật + khẩu là input bình thường. Phải so sánh trên bytes. + """ + dlg = _dialog(pw) + dlg.sandbox_pw_edit.setText(pw) + + dlg._sandbox_unlock() # không được ném TypeError + + assert dlg._sandbox_unlocked is True + dlg.deleteLater() + + +def test_mat_khau_co_dau_sai_thi_van_khoa(qapp, shown): + """Chữ có dấu không được biến thành đường mở khoá dễ dãi.""" + dlg = _dialog("mật khẩu") + dlg.sandbox_pw_edit.setText("mat khau") + + dlg._sandbox_unlock() + + assert dlg._sandbox_unlocked is False + dlg.deleteLater() + + +# ---- hàm so khớp, gọi thẳng ---------------------------------------------- + +@pytest.mark.parametrize("entered,stored,expected", [ + ("", "", False), # cả hai rỗng + ("", "K7MNP2QRSTVW", False), # ô nhập rỗng + ("K7MNP2QRSTVW", "", False), # chưa đặt mật khẩu — nhánh phòng thủ + ("K7MNP2QRSTVW", "K7MNP2QRSTVW", True), + ("mật khẩu", "mật khẩu", True), # ngoài ASCII + ("mật khẩu", "mat khau", False), +]) +def test_ham_so_khop(entered, stored, expected): + """Gọi thẳng ``_sandbox_password_matches`` — phủ cả nhánh mà call site đã + chặn trước bằng return sớm.""" + from cowork_local.ui.settings_dialog import _sandbox_password_matches + assert _sandbox_password_matches(entered, stored) is expected + + +# ---- chặn cả lớp lỗi ----------------------------------------------------- + +#: ``.get("", "")`` — mặc định trông có +#: vẻ an toàn nhưng thực ra là credential nằm trong mã nguồn. Nó cũng là code +#: chết: cấu hình đã deep-merge với DEFAULT_CONFIG nên key luôn tồn tại. +#: +#: Cố ý KHÔNG bắt ``key`` và ``code`` trần: ``it.get("key", "?")`` của Jira +#: (``core/jira_tool.py``) là mã issue, không phải credential. Danh sách dưới đây +#: chỉ gồm tên đã mang nghĩa bí mật. +_CREDENTIAL_FALLBACK = re.compile( + r'\.get\(\s*["\'][a-z_]*' + r'(?:pw|passwd|password|secret|token|api_key|unlock_code|access_code)' + r'[a-z_]*["\']\s*,\s*["\'][^"\']+["\']' +) + +#: Quét CHÉO mọi thư mục nguồn, không chỉ tầng giao diện. Sai sót gốc của commit +#: ``3827552`` là sửa ``config.py`` mà quên bản sao trong ``ui/`` — tức là lỗi đi +#: xuyên thư mục, nên phép quét cũng phải đi xuyên thư mục. +_SCANNED = ( + "ui", "presentation", "core", "infrastructure", "application", "domain", + "mcp_servers", "providers", "security", "theme", "config.py", "state.py", +) + + +def test_khong_con_fallback_credential_trong_ma_nguon(): + """Không file nguồn nào được đặt credential làm giá trị mặc định của ``.get()``.""" + root = Path(__file__).resolve().parents[2] + offenders = [] + for name in _SCANNED: + target = root / name + if target.is_file(): + files = [target] + elif target.is_dir(): + files = [p for p in target.rglob("*.py") if "__pycache__" not in p.parts] + else: # thư mục bị đổi tên/xoá + continue + for path in files: + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if _CREDENTIAL_FALLBACK.search(line): + offenders.append( + f"{path.relative_to(root).as_posix()}:{lineno}: {line.strip()}") + + assert not offenders, "credential nằm trong mã nguồn:\n " + "\n ".join(offenders) + + +def test_phep_quet_thuc_su_nhin_thay_file(): + """Lưới an toàn cho bài trên: đổi tên thư mục làm nó quét rỗng mà vẫn xanh.""" + root = Path(__file__).resolve().parents[2] + seen = sum( + 1 for name in _SCANNED + for _ in ([root / name] if (root / name).is_file() + else (root / name).rglob("*.py") if (root / name).is_dir() else []) + ) + assert seen > 200, f"chỉ quét được {seen} file — phạm vi quét đã hỏng" diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py index 823f14c..74c100f 100644 --- a/ui/settings_dialog.py +++ b/ui/settings_dialog.py @@ -13,6 +13,8 @@ chưa từng được gán nên gọi vào là AttributeError. """ from __future__ import annotations +import secrets + from PySide6.QtCore import Qt from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( @@ -31,6 +33,27 @@ from ..presentation.settings.general_settings_widget import GeneralSettingsWidge from ..presentation.settings.provider_settings_widget import ProviderSettingsWidget from ..presentation.settings.parameter_settings_widget import ParameterSettingsWidget from ..presentation.settings.routing_settings_widget import RoutingSettingsWidget +from ..presentation.settings.about_widget import AboutSettingsWidget + + +def _sandbox_password_matches(entered: str, stored: str) -> bool: + """Whether ``entered`` unlocks the Sandbox Security group. + + An empty ``stored`` must never match. ``DEFAULT_CONFIG`` ships + ``agent_security.sandbox_pw = ""`` and the config handed to this dialog is + always deep-merged with those defaults, so a plain ``entered == stored`` + accepts an empty field on every install that never set a password. The MS365 + unlock guards the same way — see ``json_config_repository.unlock_ms365``. + + Both sides are compared as UTF-8 bytes, not as ``str``: + ``compare_digest`` raises ``TypeError`` on ``str`` holding anything outside + ASCII, and this app defaults to Vietnamese and ships to Japanese customers, + so an accented password is ordinary input rather than an edge case. + """ + if not entered or not stored: + return False + return secrets.compare_digest(entered.encode("utf-8"), stored.encode("utf-8")) + class SettingsDialog(QDialog): """Hộp thoại Cài đặt: cột mục lục bên trái, các trang bên phải @@ -101,7 +124,7 @@ class SettingsDialog(QDialog): pw_row.addWidget(self.sandbox_locked_status) sbl.addLayout(pw_row) self._sandbox_unlocked = False # Start LOCKED — must enter password first - self._sandbox_pw = sec.get("sandbox_pw", "quandh14") + self._sandbox_pw = sec.get("sandbox_pw", "") # Separator line between pw section and sandbox settings pw_sep = QLabel("────────────────") @@ -122,15 +145,15 @@ class SettingsDialog(QDialog): # they belong with the other tool toggles — see ToolsAdminTab). # --- Enable/Disable Agent Security --- - self.sec_enabled = ToggleSwitch("Enable Agent Security (command validation)") + self.sec_enabled = ToggleSwitch(tr("settings.sec_enabled")) self.sec_enabled.setChecked(bool(sec.get("enabled", True))) - self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security") + self.sec_enabled.setToolTip(tr("settings.sec_enabled_tooltip")) sbl.addWidget(self.sec_enabled) # --- AI Command Check toggle --- - self.ai_check = ToggleSwitch("AI check commands") + self.ai_check = ToggleSwitch(tr("settings.ai_check")) self.ai_check.setChecked(bool(sec.get("command_ai_check", False))) - self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy") + self.ai_check.setToolTip(tr("settings.ai_check_tooltip")) sbl.addWidget(self.ai_check) # Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group @@ -169,12 +192,18 @@ class SettingsDialog(QDialog): from .widgets import section_panels + # Giới thiệu đứng CUỐI: nó không có thiết lập nào để đổi, nên đặt trước + # các mục thao tác được sẽ đẩy chúng xuống mà không được gì. + self._about_page = AboutSettingsWidget(self.ctx) + root.addWidget(self._about_page) + pages = [] for label, widget in ((tr("settings.group.general"), self._general_box), (tr("settings.group.provider"), prov_group), (tr("settings.group.sandbox"), self.sandbox_group), (tr("settings.group.parameter"), param_group), - (tr("routing.settings_group"), routing_group)): + (tr("routing.settings_group"), routing_group), + (tr("settings.group.about"), self._about_page)): root.removeWidget(widget) page = QWidget() pv = QVBoxLayout(page) @@ -276,7 +305,14 @@ class SettingsDialog(QDialog): phải cơ chế bảo mật thật. """ pw = self.sandbox_pw_edit.text() - if pw == self._sandbox_pw: + if not self._sandbox_pw: + # No password configured. Refusing with "wrong password" would be a + # dead end — the user would keep retrying a password that cannot + # exist — so name the actual state instead. + QMessageBox.warning(self, tr("settings.sandbox_pw_unset_title"), + tr("settings.sandbox_pw_unset_body")) + return + if _sandbox_password_matches(pw, self._sandbox_pw): self._sandbox_unlocked = True self.sandbox_locked_status.setText("Unlocked") self.sandbox_locked_status.set_icon("unlock", "#090")