feat(settings): bỏ khoá mật khẩu ở Sandbox Security Layer
Bốn công tắc của nhóm này (xác nhận lệnh, chặn mạng, bật lớp bảo mật agent, AI kiểm tra lệnh) dựng ra ở trạng thái setEnabled(False) và chỉ mở khi nhập đúng mật khẩu. Theo yêu cầu, bỏ hẳn bước đó: form luôn bật/tắt được. Gỡ ô nhập, nút Mở khoá, nhãn trạng thái khoá, _sandbox_unlock() và _sandbox_password_matches(); dọn 10 khoá i18n thành chết và 2 field trong tools/check_dialogs.py. Giữ nguyên agent_security.sandbox_pw ở config.py — yêu cầu chỉ nói tới màn hình, không nói tới tầng cấu hình. Khoá này vốn không phải rào bảo mật: docstring của _sandbox_unlock() đã tự ghi "khoá phía giao diện để chặn bấm nhầm ... KHÔNG phải cơ chế bảo mật thật". Rào thật nằm ở sandbox lúc chạy lệnh. Vùng này thuộc diện SECURITY.md yêu cầu Cowork Team soát thêm. 10 bài test cũ (SEC-20260907-01) chốt các đường không được mở khoá nay mất đối tượng kiểm, thay bằng 12 bài chốt hành vi mới: bốn công tắc sửa được ngay, không còn widget mật khẩu, kèm guardrail quét mã nguồn chặn khoá lại. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,21 @@
|
||||
"""Sandbox Security unlock — chốt các đường KHÔNG được mở khoá (SEC-20260907-01).
|
||||
"""Sandbox Security Layer: bốn công tắc luôn sửa được, không còn khoá mật khẩu.
|
||||
|
||||
``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.
|
||||
Trước đây nhóm này bị khoá: bốn công tắc dựng ra ở trạng thái ``setEnabled(False)``
|
||||
và chỉ mở khi nhập đúng mật khẩu qua ``_sandbox_unlock()``. Bộ bài cũ ở file này
|
||||
(SEC-20260907-01) chốt các đường KHÔNG được mở khoá — chúng mất đối tượng kiểm khi
|
||||
tính năng khoá bị bỏ theo yêu cầu, nên được thay bằng các bài dưới đây.
|
||||
|
||||
Ba nhóm bài ở đây:
|
||||
Docstring của ``_sandbox_unlock()`` cũ đã tự nói rõ nó là gì: *"khoá phía giao diện
|
||||
để chặn bấm nhầm vào một mục nhạy cảm, KHÔNG phải cơ chế bảo mật thật"*. Rào thật
|
||||
nằm ở tầng sandbox lúc chạy lệnh, không ở hộp thoại Cài đặt.
|
||||
|
||||
* **đườ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.
|
||||
Hai nhóm bài:
|
||||
|
||||
* **hành vi mới** — mở hộp thoại là bật/tắt được ngay, không qua bước nào;
|
||||
* **guardrail** — quét mã nguồn để lần sau không ai lặng lẽ khoá lại.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -27,209 +25,76 @@ import pytest
|
||||
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."""
|
||||
def _dialog():
|
||||
"""SettingsDialog dựng đúng như bản cài thật."""
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
ctx = _Ctx()
|
||||
ctx.config.data["agent_security"]["sandbox_pw"] = stored_pw
|
||||
return SettingsDialog(ctx)
|
||||
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()
|
||||
_CONG_TAC = ("sandbox_confirm", "sandbox_block_network", "sec_enabled", "ai_check")
|
||||
|
||||
|
||||
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")
|
||||
# ---- hành vi mới: sửa được ngay, không cần mật khẩu ----------------------
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
@pytest.mark.parametrize("ten", _CONG_TAC)
|
||||
def test_cong_tac_sua_duoc_ngay_khi_mo_hop_thoai(qapp, ten):
|
||||
"""Đây là chính yêu cầu: không còn bước nhập mật khẩu nào chắn ở giữa."""
|
||||
dlg = _dialog()
|
||||
|
||||
assert dlg._sandbox_unlocked is False
|
||||
dlg.deleteLater()
|
||||
assert getattr(dlg, ten).isEnabled() is True, f"{ten} vẫn bị khoá"
|
||||
|
||||
|
||||
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")
|
||||
@pytest.mark.parametrize("ten", _CONG_TAC)
|
||||
def test_bat_tat_duoc_va_luu_dung_gia_tri(qapp, ten):
|
||||
"""Bật/tắt phải ăn vào widget — khoá cũ chặn đúng ở bước này."""
|
||||
dlg = _dialog()
|
||||
w = getattr(dlg, ten)
|
||||
|
||||
dlg._sandbox_unlock()
|
||||
|
||||
assert dlg._sandbox_unlocked is False
|
||||
dlg.deleteLater()
|
||||
truoc = w.isChecked()
|
||||
w.setChecked(not truoc)
|
||||
assert w.isChecked() is (not truoc)
|
||||
w.setChecked(truoc)
|
||||
assert w.isChecked() is truoc
|
||||
|
||||
|
||||
# ---- thông báo phải phân biệt được hai tình huống -------------------------
|
||||
def test_khong_con_widget_mat_khau_nao(qapp):
|
||||
"""Ô nhập, nút Mở khoá và nhãn "Đang khoá" phải biến mất khỏi hộp thoại."""
|
||||
dlg = _dialog()
|
||||
|
||||
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")
|
||||
for ten in ("sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_locked_status",
|
||||
"sandbox_pw_label"):
|
||||
assert not hasattr(dlg, ten), f"{ten} vẫn còn trên hộp thoại"
|
||||
|
||||
|
||||
# ---- đường đi đúng vẫn phải chạy ----------------------------------------
|
||||
def test_khong_con_duong_mo_khoa_trong_ma(qapp):
|
||||
"""Hàm mở khoá và cờ trạng thái khoá không còn tồn tại."""
|
||||
import cowork_local.ui.settings_dialog as mod
|
||||
|
||||
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()
|
||||
dlg = _dialog()
|
||||
assert not hasattr(dlg, "_sandbox_unlock")
|
||||
assert not hasattr(dlg, "_sandbox_unlocked")
|
||||
assert not hasattr(dlg, "_sandbox_widgets")
|
||||
assert not hasattr(mod, "_sandbox_password_matches")
|
||||
|
||||
|
||||
@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.
|
||||
# ---- guardrail: không ai khoá lại mà không sửa bài test này --------------
|
||||
|
||||
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)
|
||||
def test_ma_nguon_khong_con_khoa_nhom_sandbox():
|
||||
"""Chặn cả lớp lỗi: lần sau ai thêm lại ``setEnabled(False)`` cho nhóm này
|
||||
thì bài này đỏ ngay, không đợi có người mở app mới thấy."""
|
||||
src = (Path(__file__).resolve().parents[2]
|
||||
/ "ui" / "settings_dialog.py").read_text(encoding="utf-8")
|
||||
code = "\n".join(l for l in src.splitlines() if not l.strip().startswith("#"))
|
||||
|
||||
dlg._sandbox_unlock() # không được ném TypeError
|
||||
|
||||
assert dlg._sandbox_unlocked is True
|
||||
dlg.deleteLater()
|
||||
for dau_hieu in ("_sandbox_unlock", "_sandbox_widgets", "_sandbox_unlocked",
|
||||
"sandbox_pw"):
|
||||
assert dau_hieu not in code, f"khoá sandbox đã quay lại: {dau_hieu}"
|
||||
|
||||
|
||||
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")
|
||||
def test_phep_quet_thuc_su_doc_duoc_file():
|
||||
"""Lưới an toàn: đổi tên file làm bài trên quét rỗng mà vẫn xanh."""
|
||||
src = (Path(__file__).resolve().parents[2]
|
||||
/ "ui" / "settings_dialog.py").read_text(encoding="utf-8")
|
||||
|
||||
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("<khoá kiểu credential>", "<literal khác rỗng>")`` — 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"
|
||||
assert "class SettingsDialog" in src
|
||||
assert len(src) > 2000, f"chỉ đọc được {len(src)} ký tự — đường dẫn đã hỏng"
|
||||
|
||||
Reference in New Issue
Block a user