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
-6
View File
@@ -344,12 +344,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
"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 がコマンドを実行する前に確認する",
-22
View File
@@ -45,28 +45,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ファイアウォールではありません。上のコマンドホワイトリストと併用してください。",
"vi": "Kiểm soát ở tầng chính sách (trỏ biến môi trường proxy vào hố đen) — không phải "
"firewall tầng kernel. Kết hợp với whitelist lệnh ở trên để phòng thủ nhiều lớp."},
"settings.sandbox_pw_label": {
"en": "Sandbox Security Password", "ja": "サンドボックスセキュリティのパスワード",
"vi": "Mật khẩu Bảo mật Sandbox"},
"settings.sandbox_pw_placeholder": {
"en": "Enter password to edit sandbox settings",
"ja": "サンドボックス設定を変更するにはパスワードを入力してください",
"vi": "Nhập mật khẩu để sửa thiết lập sandbox"},
"settings.sandbox_unlock_btn": {"en": "Unlock", "ja": "ロック解除", "vi": "Mở khoá"},
"settings.sandbox_locked": {
"en": "Locked (changes disabled)", "ja": "ロック中(変更できません)",
"vi": "Đang khoá (không sửa được)"},
"settings.sandbox_unlocked": {
"en": "Unlocked", "ja": "ロック解除済み", "vi": "Đã mở khoá"},
"settings.sandbox_unlocked_body": {
"en": "Sandbox settings unlocked.", "ja": "サンドボックス設定のロックを解除しました。",
"vi": "Đã mở khoá thiết lập sandbox."},
"settings.sandbox_pw_wrong_title": {
"en": "Wrong Password", "ja": "パスワードが違います", "vi": "Sai mật khẩu"},
"settings.sandbox_pw_wrong_body": {
"en": "Password incorrect. Sandbox settings remain locked.",
"ja": "パスワードが正しくありません。サンドボックス設定はロックされたままです。",
"vi": "Mật khẩu không đúng. Thiết lập sandbox vẫn bị khoá."},
"settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"},
"settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"},
"settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"},
+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"
+1 -1
View File
@@ -26,7 +26,7 @@ from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E4
SETTINGS_FIELDS = [
"language_combo", "theme_combo", "tray_chk", "notify_chk",
"provider_combo", "prov_base", "prov_key", "prov_model",
"sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_confirm",
"sandbox_confirm",
"sandbox_block_network", "sec_enabled", "ai_check",
]
TASK_FIELDS = [
+6 -83
View File
@@ -13,20 +13,17 @@ 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 (
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QScrollArea, QSpinBox,
QGroupBox, QHBoxLayout, QListWidget, QListWidgetItem,
QScrollArea, QSpinBox,
QTreeWidgetItem, QVBoxLayout, QWidget,
)
from ..i18n import tr
from .dialog_buttons import dialog_buttons
from .icons import IconLabel
from .widgets import ToggleSwitch
@@ -37,25 +34,6 @@ from ..presentation.settings.routing_settings_widget import RoutingSettingsWidge
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
(Nhà cung cấp · Connectors · Định tuyến · Tham số · Chung).
@@ -108,29 +86,10 @@ class SettingsDialog(QDialog):
self.sandbox_group = QGroupBox(tr("settings.group.sandbox"))
sbl = QVBoxLayout(self.sandbox_group)
# --- Password protection for Sandbox Security (at top) ---
self.sandbox_pw_label = IconLabel("lock", tr("settings.sandbox_pw_label"))
sbl.addWidget(self.sandbox_pw_label)
pw_row = QHBoxLayout()
self.sandbox_pw_edit = QLineEdit("")
self.sandbox_pw_edit.setPlaceholderText(tr("settings.sandbox_pw_placeholder"))
self.sandbox_pw_edit.setEchoMode(QLineEdit.Password)
pw_row.addWidget(self.sandbox_pw_edit, 1)
self.sandbox_unlock_btn = QPushButton(tr("settings.sandbox_unlock_btn"))
self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock)
pw_row.addWidget(self.sandbox_unlock_btn)
self.sandbox_locked_status = IconLabel("lock", tr("settings.sandbox_locked"), color="#c00")
self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;")
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", "")
# Separator line between pw section and sandbox settings
pw_sep = QLabel("────────────────")
sbl.addWidget(pw_sep)
# Nhóm này KHÔNG còn khoá bằng mật khẩu: bốn công tắc dưới đây bật/tắt
# tự do. Khoá cũ chỉ là rào chống bấm nhầm ở phía giao diện, 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), nên bỏ đi
# theo yêu cầu thay vì giữ một bước nhập mật khẩu không bảo vệ được gì.
self.sandbox_confirm = ToggleSwitch(tr("settings.sandbox_confirm_commands"))
self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False)))
self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip"))
@@ -160,14 +119,6 @@ class SettingsDialog(QDialog):
# Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group
# below — see _param_section("settings.group.sandbox_limits").
# Collect all sandbox-editable widgets and lock them until unlocked
self._sandbox_widgets = [
self.sandbox_confirm, self.sandbox_block_network,
self.ai_check, self.sec_enabled,
]
for _w in self._sandbox_widgets:
_w.setEnabled(False)
root.addWidget(self.sandbox_group)
# Connectors (MCP / REST API) are managed entirely in Monitoring → Tools
@@ -299,34 +250,6 @@ class SettingsDialog(QDialog):
def _sandbox_unlock(self) -> None:
"""Mở khoá nhóm cài đặt sandbox bằng mật khẩu.
Đây là 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.
"""
pw = self.sandbox_pw_edit.text()
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(tr("settings.sandbox_unlocked"))
self.sandbox_locked_status.set_icon("unlock", "#090")
self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;")
# Enable all sandbox widgets
for w in self._sandbox_widgets:
w.setEnabled(True)
QMessageBox.information(self, tr("settings.group.sandbox"),
tr("settings.sandbox_unlocked_body"))
else:
QMessageBox.warning(self, tr("settings.sandbox_pw_wrong_title"),
tr("settings.sandbox_pw_wrong_body"))
def _save(self) -> None:
"""Gom cấu hình từ mọi trang con rồi ghi xuống đĩa."""
data = self.ctx.config.data