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:
2026-09-11 15:31:49 +09:00
co-authored by Claude Opus 5
parent 1b8429e33a
commit 10799dc67b
5 changed files with 69 additions and 309 deletions
+62 -197
View File
@@ -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"