## Summary Nhánh `feature/perf-ui-logic`: tối ưu hiệu năng/UI, sửa lỗi workspace và điều hướng, và làm cho công tắc **"Block network for agent-run commands"** chặn thật mọi đường ra mạng của app, **trừ nhà cung cấp AI**. **Chặn mạng (b78d483,8c497cf,10b8379)** - Bộ kiểm tra chung `application/network/network_guard.py`, nối vào cấu hình đang chạy ở Composition Root: đổi công tắc trong Settings là có hiệu lực ngay. - Lệnh shell của agent và task script chạy trong **Windows AppContainer không có quyền mạng**: kernel chặn socket, ping, DNS, Invoke-WebRequest… Không cần quyền admin. Không cô lập được thì lệnh bị từ chối, không chạy khi mạng còn mở. macOS dùng `sandbox-exec`, Linux dùng `unshare --net`. - Bật chặn thì: dừng MCP đang chạy, không khởi động server mới, từ chối lời gọi connector; Microsoft 365 (đăng nhập, Graph, đồng bộ cloud, rules, mail), Teams, nút Test REST/Jira/MCP, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML đều bị từ chối. - Vẫn dùng được: chat, tải danh sách model, thử model; tool OneDrive đã đồng bộ trên máy. - Công tắc **mặc định tắt** khi mở app lần đầu; nhãn giữ nguyên như cũ. - Xem trước HTML trong tab Folder giờ hiện được ảnh/CSS/JS từ web khi mạng mở (trước đây trang `file://` không tải được). - Sửa lỗi app văng khi chuyển tab Graph → Folder: profile WebEngine của trang xem trước bị huỷ trước trang (`0xc0000409` trong Qt6Core.dll); giờ dùng một profile chung thuộc QApplication. - Không cấp quyền AppContainer kế thừa lên thư mục chứa PySide6 (nếu có, Chromium không nạp được `Qt6WebEngineCore.dll` và tab Graph trắng). - Cột mục lục trong Settings tính độ rộng theo kiểu chữ của mục đang chọn, "Sandbox Security Layer" không còn bị cắt. **Các commit khác trong nhánh** - `b7a41b3` mỗi thư mục làm việc chỉ thuộc về một project · `bbdf146` bật nút Sửa project khi đã có project đang mở - `35f24e0`, `cc8d5c8`, `2e3e719`, `c699beb` canh hàng / khoảng cách thanh điều hướng - `2759ed9` không refresh workspace khi chuyển tab Cowork · `7607f44` checkpoint hiệu năng và UI - `8548c1e` chặn tool mạng của agent · `caf3b74` renderer GraphRAG native trên macOS · `c00b83c` khoảng cách metadata hàng project · `a04f8a9` ẩn picker workspace cloud ## Change Type - [x] Cowork feature - [x] Bug fix - [ ] Core AI contribution - [x] Test / hardening - [x] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? - Mọi đường ra mạng do app tự mở, trừ nhà cung cấp AI (xem Summary). - Test: `tests/test_network_guard_lanes.py` (có bài chạy AppContainer thật trên Windows), `tests/ui/test_html_preview_remote_images.py`. What is intentionally NOT included? - Chặn cả nhà cung cấp AI / chạy model trên máy (Phương án 2). - Terminal người dùng tự gõ trong tab Folder, sinh ảnh, cơ chế tự tin chứng chỉ lạ (`tls_trust`). - Huy hiệu trạng thái "đang chặn" trên thanh trên cùng. ## Validation - [x] Unit tests - [x] Integration tests - [x] Manual verification - [x] Regression check Commands / evidence: - `python -m pytest tests/test_network_guard_lanes.py tests/test_sandbox_block_network.py tests/ui -q` → chỉ còn 1 bài fail, fail cả trên `b7a41b3` (nhãn `ProjectRow` 'Project' chưa dịch, `tests/ui/test_i18n_khong_con_chu_cu.py`). - `python -m pytest tests -q --ignore=tests/ui` → 4 bài fail, cả 4 cũng fail trên `b7a41b3` (`test_canonical_audit_logger`, 2 bài `test_mcp_audit_security`, `test_monitoring_tab_container`). - Chạy cả `tests` trong một lượt thì treo ở các test dựng MainWindow trong `tests/ui`; `b7a41b3` cũng treo đúng chỗ đó. - `check_imports.py` và `check_orphan_modules.py` PASS. `check_loc.py` báo 9 file quá dài, giống hệt trước khi sửa (không file nào do nhánh này làm dài thêm). - Kiểm tra tay trên Windows 11: trong AppContainer, Python báo `WinError 10013`, ping/nslookup/PowerShell/curl đều không ra được mạng; cmd, git, python chạy bình thường. - Kiểm tra tay trên Windows 11: xem trước HTML tải được 4/4 tài nguyên web khi mạng mở, 0/4 khi bật chặn; tab Graph hoạt động; tạo/huỷ trang xem trước nhiều lần không còn cảnh báo profile của Qt. ## Security Impact Permission / credential / network / customer data impact: - Network: khi bật công tắc, chỉ nhà cung cấp AI còn ra mạng; nội dung chat vẫn gửi tới nhà cung cấp AI. - Permission: lần đầu chạy lệnh trong sandbox, app **thêm quyền (ACE) cho SID AppContainer** trên thư mục làm việc (ghi), thư mục cài Python gốc (đọc), gốc venv và `Scripts` (đọc). Không xoá quyền nào. Thư mục chứa PySide6 không bao giờ nhận quyền kế thừa; một quyền kế thừa sai trên venv (từ bản dev trước) được tự gỡ. - Credential: không đổi. Khi chặn, trạng thái đăng nhập M365 được đọc thẳng từ kho token trên máy, không dựng MSAL. ## Compatibility - [x] No breaking change - [ ] Breaking change documented Ghi chú: `block_network` mặc định đổi từ bật sang tắt cho cấu hình mới; máy đã lưu `true` thì giữ nguyên. Khi đang chặn, lệnh dùng công cụ cài trong thư mục người dùng (ngoài Program Files) có thể báo Access denied; thư viện trong venv của app không dùng được trong sandbox. ## Reviewer Notes - `infrastructure/sandbox/appcontainer_process.py` gọi Win32 bằng ctypes (CreateAppContainerProfile, CreateProcessW với SECURITY_CAPABILITIES) và dùng `icacls` để cấp quyền: nên xem kỹ phần cấp quyền. - `tests/conftest.py` thêm fixture autouse gỡ `network_guard` sau mỗi test, vì `build_context()` gắn cổng này ở mức process. - `core/task_executors.py` đang đúng bằng trần LOC nên `_run_script` được tách sang `core/task_script.py`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: minhanhpkpro <minhanhpkpro@gmail.com> Co-authored-by: Duy Le Huu <duylh19@fpt.com> Co-authored-by: thanhnv <thanhnv.ip@gmail.com> Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
@@ -527,6 +527,15 @@ def run_cowork(
|
||||
preview = {"kind": "info", "title": name, "text": str(args)}
|
||||
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
|
||||
"preview": preview})
|
||||
if ctx.block_network and not name.startswith("ms365_local__"):
|
||||
result = {"ok": False, "output": (
|
||||
f"{name}: network access is blocked by the Sandbox Security Layer "
|
||||
'("Block network for agent-run commands" is on in Settings).')}
|
||||
emit({"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": False, "output": result["output"]})
|
||||
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
|
||||
"content": result["output"]})
|
||||
continue
|
||||
# R05-T04: MCP/connector tools used to run with NO permission
|
||||
# check at all — this is what closes that gap. Same policy,
|
||||
# same gate object as the built-in tools below.
|
||||
|
||||
+6
-1
@@ -327,7 +327,12 @@ def run_code(
|
||||
else:
|
||||
emit({"type": "tool_start", "id": tc_id, "name": name})
|
||||
if is_extra and extra_executor is not None:
|
||||
result = extra_executor(name, args)
|
||||
if ctx.block_network and not name.startswith("ms365_local__"):
|
||||
result = {"ok": False, "output": (
|
||||
f"{name}: network access is blocked by the Sandbox Security Layer "
|
||||
'("Block network for agent-run commands" is on in Settings).')}
|
||||
else:
|
||||
result = extra_executor(name, args)
|
||||
else:
|
||||
def on_output(line: str, _id=tc_id, _name=name) -> None:
|
||||
emit({"type": "tool_output", "id": _id, "name": _name, "delta": line})
|
||||
|
||||
+24
-5
@@ -91,6 +91,7 @@ def run_cancellable(
|
||||
on_output: Optional[Callable[[str], None]] = None,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
limits: Optional[Dict[str, float]] = None,
|
||||
isolate_network: bool = False,
|
||||
) -> Tuple[Optional[int], str, bool, bool, bool]:
|
||||
"""Run a subprocess so the Stop button can actually interrupt it.
|
||||
|
||||
@@ -117,17 +118,27 @@ def run_cancellable(
|
||||
a failure to create/assign the job just means the existing taskkill
|
||||
fallback is used, same as before this was added.
|
||||
|
||||
``isolate_network`` runs ``args`` as a shell command that the OS keeps
|
||||
off the network (see ``infrastructure/sandbox/network_isolation.py``);
|
||||
if that isolation cannot be set up the command is NOT run.
|
||||
|
||||
Returns ``(returncode, combined_output, cancelled, timed_out,
|
||||
resource_exceeded)``; on a failure to even launch the process,
|
||||
``returncode`` is ``None`` and the output holds the launch error."""
|
||||
cancel = cancel or (lambda: False)
|
||||
popen_kwargs = {} if sys.platform == "win32" else {"start_new_session": True}
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1, env=env, **popen_kwargs,
|
||||
)
|
||||
except OSError as exc:
|
||||
if isolate_network:
|
||||
from ..infrastructure.sandbox.network_isolation import spawn_without_network
|
||||
|
||||
command = args if isinstance(args, str) else subprocess.list2cmdline(args)
|
||||
proc = spawn_without_network(command, cwd, env)
|
||||
else:
|
||||
proc = subprocess.Popen(
|
||||
args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1, env=env, **popen_kwargs,
|
||||
)
|
||||
except (OSError, RuntimeError) as exc: # RuntimeError: NetworkIsolationUnavailable
|
||||
return None, str(exc), False, False, False
|
||||
|
||||
with _active_pids_lock:
|
||||
@@ -266,6 +277,10 @@ def ensure_module(module: str, package: str | None = None):
|
||||
pkg = package or module
|
||||
if pkg in _FAILED or not _can_pip():
|
||||
return None
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return None # not cached in _FAILED: retried once the network is back
|
||||
ok, _ = pip_install(pkg)
|
||||
if not ok:
|
||||
_FAILED.add(pkg)
|
||||
@@ -339,6 +354,10 @@ def pip_install(package: str, cancel: Optional[CancelFn] = None,
|
||||
name) is NOT retried, since repeating it would just waste time."""
|
||||
if not _can_pip():
|
||||
return False, "This packaged build can't install packages at runtime."
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return False, network_guard.refusal(f"pip install {package}")
|
||||
exe = python or sys.executable
|
||||
attempt = 0
|
||||
while True:
|
||||
|
||||
+19
-8
@@ -94,17 +94,28 @@ def find_input_files(folder: Path, exts: set[str] | None = None,
|
||||
capped at ``max_files`` (0 = unlimited), ``total_matched`` is the count
|
||||
before that cap, so a caller can report how many were skipped."""
|
||||
exts = exts or INPUT_EXTS
|
||||
# Do not sort an unbounded recursive tree merely to return a small prefix.
|
||||
# The caller receives a stable lexical order for the bounded result, while
|
||||
# traversal stops as soon as the configured file budget is reached.
|
||||
files: list[Path] = []
|
||||
total = 0
|
||||
try:
|
||||
matched = sorted(
|
||||
f for f in folder.rglob("*")
|
||||
if f.is_file()
|
||||
and not any(part.startswith(".") for part in f.relative_to(folder).parts)
|
||||
and f.suffix.lower() in exts
|
||||
)
|
||||
for f in folder.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
relative = f.relative_to(folder)
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part.startswith(".") for part in relative.parts) or f.suffix.lower() not in exts:
|
||||
continue
|
||||
total += 1
|
||||
if max_files <= 0 or len(files) < max_files:
|
||||
files.append(f)
|
||||
except OSError:
|
||||
return [], 0
|
||||
files = matched if max_files <= 0 else matched[:max_files]
|
||||
return files, len(matched)
|
||||
files.sort(key=lambda p: str(p).lower())
|
||||
return files, total
|
||||
|
||||
|
||||
def find_soffice() -> str | None:
|
||||
|
||||
@@ -132,8 +132,11 @@ class RestApiConnector:
|
||||
|
||||
def call(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Gọi API theo tham số model đưa ra, đi qua lớp TLS có ghim chứng chỉ nội bộ."""
|
||||
from ..application.network import network_guard
|
||||
from .tls_trust import request_any_method as tls_request
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return {"ok": False, "output": network_guard.refusal(self.display_name)}
|
||||
method = str(args.get("method", "GET")).upper()
|
||||
path = str(args.get("path", "")).lstrip("/")
|
||||
url = urljoin(self.base_url, path)
|
||||
@@ -164,10 +167,13 @@ class RestApiConnector:
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
"""Thử kết nối tới endpoint; trả về (thành công, thông điệp)."""
|
||||
from ..application.network import network_guard
|
||||
from .tls_trust import request as tls_request
|
||||
|
||||
if not self.base_url.strip("/"):
|
||||
return False, "No base URL configured."
|
||||
if network_guard.is_blocked():
|
||||
return False, network_guard.refusal(self.display_name)
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers[self.auth_header] = (
|
||||
|
||||
+30
-1
@@ -16,6 +16,17 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..performance import span
|
||||
|
||||
_LIST_CACHE: dict[tuple[str, str, int], List[Dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _invalidate_history_cache(directory: Path) -> None:
|
||||
prefix = str(Path(directory).resolve())
|
||||
for key in list(_LIST_CACHE):
|
||||
if key[0] == prefix:
|
||||
_LIST_CACHE.pop(key, None)
|
||||
|
||||
|
||||
def new_session_id() -> str:
|
||||
"""Id phiên mới theo mốc thời gian, chính xác tới mili giây."""
|
||||
@@ -78,6 +89,7 @@ def save_conversation(
|
||||
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, payload)
|
||||
_invalidate_history_cache(directory)
|
||||
return path
|
||||
|
||||
|
||||
@@ -85,6 +97,7 @@ def delete_conversation(path) -> None:
|
||||
"""Xoá file hội thoại; không có thì bỏ qua."""
|
||||
try:
|
||||
Path(path).unlink()
|
||||
_invalidate_history_cache(Path(path).parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -96,6 +109,7 @@ def rename_conversation(path, new_title: str) -> None:
|
||||
data = load_conversation(path)
|
||||
data["title"] = new_title
|
||||
write_json(Path(path), data)
|
||||
_invalidate_history_cache(Path(path).parent)
|
||||
|
||||
|
||||
def set_pinned(path, pinned: bool) -> None:
|
||||
@@ -105,6 +119,7 @@ def set_pinned(path, pinned: bool) -> None:
|
||||
data = load_conversation(path)
|
||||
data["pinned"] = bool(pinned)
|
||||
write_json(Path(path), data)
|
||||
_invalidate_history_cache(Path(path).parent)
|
||||
|
||||
|
||||
def load_conversation(path: Path) -> Dict[str, Any]:
|
||||
@@ -197,8 +212,16 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
|
||||
if not directory or not directory.exists():
|
||||
return []
|
||||
q = (query or "").strip().lower()
|
||||
try:
|
||||
cache_key = (str(directory.resolve()), q, directory.stat().st_mtime_ns)
|
||||
except OSError:
|
||||
return []
|
||||
cached = _LIST_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return [dict(item) for item in cached]
|
||||
items: List[Dict[str, Any]] = []
|
||||
for path in directory.glob("*.json"):
|
||||
with span("history.list", query=bool(q)):
|
||||
for path in directory.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
@@ -221,4 +244,10 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
|
||||
})
|
||||
# pinned first, then most recent
|
||||
items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
|
||||
_LIST_CACHE[cache_key] = [dict(item) for item in items]
|
||||
# Keep this bounded; old directory signatures become unreachable after a
|
||||
# write and should not grow process memory forever.
|
||||
if len(_LIST_CACHE) > 256:
|
||||
for old in list(_LIST_CACHE)[:64]:
|
||||
_LIST_CACHE.pop(old, None)
|
||||
return items
|
||||
|
||||
@@ -85,8 +85,10 @@ def get_issue_by_url(config: Dict[str, Any] | None, url: str) -> str:
|
||||
|
||||
def _get(config: Dict[str, Any], path: str, params: dict = None):
|
||||
"""Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ."""
|
||||
from ..application.network import network_guard
|
||||
from . import tls_trust
|
||||
|
||||
network_guard.ensure_allowed("Jira")
|
||||
c = _conf(config)
|
||||
url = c["base_url"].rstrip("/") + path
|
||||
# Same TLS auto-recovery the LLM provider calls get (core/tls_trust.py) —
|
||||
|
||||
@@ -147,6 +147,12 @@ def fetch_link_preview(url: str) -> str:
|
||||
return ""
|
||||
if not re.match(r"^https?://", url, re.IGNORECASE):
|
||||
return f"[Link: {url}] (not a fetchable http(s) URL — referenced by address only)"
|
||||
# Every caller (fetch_url, task link attachments, ...) passes through here,
|
||||
# so this one check covers the paths that never saw a ToolContext.
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return f"[Link: {url}] (not fetched — {network_guard.refusal('link fetch')})"
|
||||
# SharePoint / OneDrive share links are rewritten to their direct-download
|
||||
# form so the shared FILE itself is fetched and parsed (like an attachment),
|
||||
# not the share page's HTML shell.
|
||||
|
||||
+12
-1
@@ -88,7 +88,14 @@ class McpServerConnection:
|
||||
def start(self, timeout: float = 15.0) -> None:
|
||||
"""Spawn the server subprocess and complete the MCP handshake.
|
||||
Raises :class:`McpServerError` on failure (bad command, the server
|
||||
crashed on startup, the handshake timed out, ...)."""
|
||||
crashed on startup, the handshake timed out, ...).
|
||||
|
||||
Refused while "Block network" is on: a server process is free to open
|
||||
any socket it likes, so the only safe server is one never started."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise McpServerError(network_guard.refusal(f"MCP server '{self.name}'"))
|
||||
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self._thread.start()
|
||||
if not self._ready.wait(timeout):
|
||||
@@ -174,6 +181,10 @@ class McpServerConnection:
|
||||
|
||||
def call_tool(self, qualified_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""``extra_executor``-shaped result: ``{"ok": bool, "output": str}``."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return {"ok": False, "output": network_guard.refusal(f"MCP server '{self.name}'")}
|
||||
tool_name = qualified_name.split(_SEP, 1)[1] if _SEP in qualified_name else qualified_name
|
||||
try:
|
||||
result = self._run_coro(self._session.call_tool(tool_name, args or {}))
|
||||
|
||||
@@ -137,8 +137,34 @@ def _app(tenant_id: str, client_id: str):
|
||||
return app, cache
|
||||
|
||||
|
||||
def _cached_account_offline() -> Optional[dict]:
|
||||
"""First account in the saved token cache, read without any MSAL network setup."""
|
||||
try:
|
||||
import msal
|
||||
|
||||
accounts = _load_cache().find(msal.TokenCache.CredentialType.ACCOUNT)
|
||||
except Exception: # noqa: BLE001 - no msal / unreadable cache = not signed in
|
||||
return None
|
||||
return accounts[0] if accounts else None
|
||||
|
||||
|
||||
def _ensure_network(action: str) -> None:
|
||||
"""Turn a "Block network" refusal into the error type callers already handle."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise Ms365AuthError(network_guard.refusal(action))
|
||||
|
||||
|
||||
def signed_in_account(tenant_id: str, client_id: str) -> Optional[dict]:
|
||||
"""The cached account, if any — a local cache lookup, no network call."""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
# Building the MSAL app fetches the tenant's OpenID configuration, so
|
||||
# read the token cache directly instead: the UI still sees who is
|
||||
# signed in without the app reaching login.microsoftonline.com.
|
||||
return _cached_account_offline()
|
||||
try:
|
||||
app, _cache = _app(tenant_id, client_id)
|
||||
except Ms365AuthError:
|
||||
@@ -156,6 +182,7 @@ def sign_in_device_code(tenant_id: str, client_id: str, on_code: Callable[[dict]
|
||||
``verification_uri_complete`` (URL with the code pre-filled, when the tenant
|
||||
returns it) and ``message`` (the full human-readable instruction). Returns
|
||||
the MSAL token result dict; raises Ms365AuthError on failure/timeout."""
|
||||
_ensure_network("Microsoft 365 sign-in")
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
flow = app.initiate_device_flow(scopes=SCOPES)
|
||||
if "user_code" not in flow:
|
||||
@@ -183,6 +210,7 @@ def get_access_token(tenant_id: str, client_id: str) -> str:
|
||||
"""Silently reuse the cached sign-in. Raises Ms365AuthError when there is
|
||||
no valid session — the caller (a Graph call) should surface that as a
|
||||
normal tool failure telling the user to sign in again from Settings."""
|
||||
_ensure_network("Microsoft 365")
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
accounts = app.get_accounts()
|
||||
if not accounts:
|
||||
@@ -228,6 +256,7 @@ def sign_out_default(config=None) -> None:
|
||||
def sign_out(tenant_id: str, client_id: str) -> None:
|
||||
"""Đăng xuất và xoá token của một tenant/client khỏi kho."""
|
||||
try:
|
||||
_ensure_network("Microsoft 365 sign-out") # chặn mạng: chỉ xoá kho token bên dưới
|
||||
app, cache = _app(tenant_id, client_id)
|
||||
for acc in app.get_accounts():
|
||||
app.remove_account(acc)
|
||||
|
||||
@@ -43,6 +43,10 @@ def _request(method: str, url: str, token: str, **kwargs) -> requests.Response:
|
||||
"""Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP
|
||||
thành :class:`Ms365GraphError` kèm thông điệp đọc được.
|
||||
"""
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
raise Ms365GraphError(network_guard.refusal("Microsoft 365 (Graph)"))
|
||||
if not url.startswith("http"):
|
||||
url = f"{GRAPH_BASE}{url}"
|
||||
headers = _headers(token, kwargs.pop("headers", None))
|
||||
|
||||
@@ -24,6 +24,7 @@ project — nothing about it is special-cased in the UI.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -82,6 +83,53 @@ class Project:
|
||||
return (base or WORKSPACES_DIR) / self.project_id
|
||||
|
||||
|
||||
def _norm_dir(path) -> str:
|
||||
"""Đường dẫn đã chuẩn hoá để đem ra so sánh.
|
||||
|
||||
Bung ``~``, đưa về tuyệt đối, rồi ``normcase`` — trên Windows thì
|
||||
``D:/Work`` và ``d:/work`` là cùng một thư mục, nên so chuỗi thô sẽ
|
||||
cho hai project chiếm chung một chỗ mà không ai biết.
|
||||
"""
|
||||
return os.path.normcase(os.path.abspath(os.path.expanduser(str(path))))
|
||||
|
||||
|
||||
def _cham_nhau(a: str, b: str) -> bool:
|
||||
"""Hai thư mục đã chuẩn hoá có chạm nhau không: trùng, hoặc lồng nhau.
|
||||
|
||||
Lồng nhau cũng tính, vì lý do tồn tại của sandbox là "agent của project này
|
||||
không bao giờ chạm được file của project kia" (xem docstring đầu module).
|
||||
Đứng ở thư mục cha thì đọc/ghi được toàn bộ thư mục con, nên cha-con vẫn là
|
||||
chạm nhau dù hai đường dẫn không giống nhau.
|
||||
"""
|
||||
return a == b or a.startswith(b + os.sep) or b.startswith(a + os.sep)
|
||||
|
||||
|
||||
def folder_conflict(path, *, ignore_id: str = "",
|
||||
directory: Path = None) -> Optional[Project]:
|
||||
"""Project khác đang chiếm ``path``, hoặc ``None`` nếu chưa ai chiếm.
|
||||
|
||||
Mỗi thư mục chỉ được thuộc về một project: thư mục làm việc vừa là sandbox
|
||||
vừa là kho kiến thức dùng chung của project, nên hai project dùng chung một
|
||||
thư mục là đọc lẫn dữ liệu của nhau.
|
||||
|
||||
So theo thư mục THỰC SỰ đang dùng (``workspace_dir()``), không phải theo
|
||||
``output_dir``: project chưa đặt thư mục riêng vẫn đang chiếm thư mục quản
|
||||
lý sẵn của nó, và chính thư mục đó là thứ hay bị chọn nhầm.
|
||||
|
||||
``ignore_id`` là project đang sửa — giữ nguyên thư mục của chính nó thì
|
||||
không phải là trùng.
|
||||
"""
|
||||
if not str(path).strip():
|
||||
return None
|
||||
muon = _norm_dir(path)
|
||||
for project in list_projects(directory):
|
||||
if project.project_id == ignore_id:
|
||||
continue
|
||||
if _cham_nhau(muon, _norm_dir(project.workspace_dir())):
|
||||
return project
|
||||
return None
|
||||
|
||||
|
||||
def _starter_project() -> Project:
|
||||
"""An ordinary (deletable, renamable) project seeded when the projects
|
||||
folder is empty, so the app always opens with somewhere to chat."""
|
||||
|
||||
+48
-4
@@ -218,8 +218,13 @@ class SandboxManager:
|
||||
timeout_sec: int,
|
||||
cancel: Optional[Callable[[], bool]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Dispatch execution to the selected backend."""
|
||||
if backend == "direct":
|
||||
"""Dispatch execution to the selected backend.
|
||||
|
||||
With the network blocked every backend is replaced by the same OS-level
|
||||
isolation: the backends below only ever set proxy env vars, which
|
||||
anything that ignores proxies (raw sockets, ping, .NET WebClient...)
|
||||
walked straight past."""
|
||||
if block_network or backend == "direct":
|
||||
return self._run_direct(command, workdir, block_network, timeout_sec, cancel)
|
||||
|
||||
if backend == "integrity_job_wfp":
|
||||
@@ -274,7 +279,8 @@ class SandboxManager:
|
||||
env = os.environ.copy()
|
||||
if block_network:
|
||||
from .deps import network_blocked_env
|
||||
env = network_blocked_env(env)
|
||||
env = network_blocked_env(env) # belt and braces on top of the OS block
|
||||
return self._run_network_isolated(command, workdir, env, timeout_sec, cancel)
|
||||
|
||||
if cancel is not None:
|
||||
from .deps import run_cancellable
|
||||
@@ -334,4 +340,42 @@ class SandboxManager:
|
||||
"stderr": str(exc),
|
||||
"returncode": -1,
|
||||
"sandbox": "direct",
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run_network_isolated(
|
||||
command: str,
|
||||
workdir: str,
|
||||
env: Dict[str, str],
|
||||
timeout_sec: int,
|
||||
cancel: Optional[Callable[[], bool]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run ``command`` in a process the OS keeps off the network.
|
||||
|
||||
Fail-closed: when the isolation cannot be set up the command is
|
||||
refused (``sandbox == "blocked"``), never run with the network open."""
|
||||
from .deps import run_cancellable
|
||||
|
||||
rc, output, cancelled, timed_out, exceeded = run_cancellable(
|
||||
command, cwd=workdir or None, timeout=timeout_sec, cancel=cancel,
|
||||
shell=True, env=env, isolate_network=True,
|
||||
)
|
||||
if rc is None and not (cancelled or timed_out or exceeded):
|
||||
return {"ok": False, "stdout": "", "returncode": -1, "sandbox": "blocked",
|
||||
"stderr": ("Command refused: network is blocked and the command could "
|
||||
f"not be isolated from the network ({output.strip()}).")}
|
||||
if cancelled:
|
||||
stderr = "Cancelled by user."
|
||||
elif timed_out:
|
||||
stderr = f"Timeout after {timeout_sec}s"
|
||||
elif exceeded:
|
||||
stderr = "Resource limit exceeded."
|
||||
else:
|
||||
stderr = ""
|
||||
return {
|
||||
"ok": rc == 0 and not (cancelled or timed_out or exceeded),
|
||||
"stdout": output,
|
||||
"stderr": stderr,
|
||||
"returncode": rc if rc is not None else -1,
|
||||
"sandbox": "network_isolated",
|
||||
}
|
||||
|
||||
+1
-13
@@ -19,7 +19,6 @@ in Waiting Input), so executors here run with an auto gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -30,6 +29,7 @@ from . import agent_roles
|
||||
from . import agent_security
|
||||
from . import projects
|
||||
from .permissions import PermissionGate
|
||||
from .task_script import run_script as _run_script
|
||||
from .tasks import ARTIFACTS_DIR, resolve_input_text
|
||||
from .tools import ToolContext
|
||||
|
||||
@@ -358,18 +358,6 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
||||
return _last_assistant_text(messages), timed_out(), incomplete
|
||||
|
||||
|
||||
def _run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
|
||||
if not command.strip():
|
||||
raise RuntimeError("Script task has no command configured.")
|
||||
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
|
||||
capture_output=True, text=True, timeout=max(1, timeout_sec))
|
||||
output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
def execute_task(ctx, task: Dict[str, Any], run_id: str,
|
||||
emit: Optional[EmitFn] = None, cancel: Optional[CancelFn] = None,
|
||||
tasks_dir: Path = None) -> Dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Run a scheduled task of type ``script`` (tách khỏi ``task_executors.py``).
|
||||
|
||||
Khi công tắc "Chặn mạng" đang bật, lệnh của task chạy trong tiến trình bị hệ
|
||||
điều hành cắt mạng — giống ``run_command`` của agent. Trước đây task script
|
||||
chạy thẳng bằng ``subprocess.run``, không sandbox, nên lên mạng tự do.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
|
||||
if not command.strip():
|
||||
raise RuntimeError("Script task has no command configured.")
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return _run_script_without_network(command, out_dir, timeout_sec)
|
||||
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
|
||||
capture_output=True, text=True, timeout=max(1, timeout_sec))
|
||||
output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
def _run_script_without_network(command: str, out_dir: Path, timeout_sec: int) -> str:
|
||||
"""Như :func:`run_script`, nhưng tiến trình không có mạng; không cô lập được thì không chạy."""
|
||||
from .deps import network_blocked_env, run_cancellable
|
||||
|
||||
rc, output, _cancelled, timed_out, _exceeded = run_cancellable(
|
||||
command, cwd=str(out_dir), timeout=max(1, timeout_sec), shell=True,
|
||||
env=network_blocked_env(), isolate_network=True,
|
||||
)
|
||||
if timed_out:
|
||||
raise subprocess.TimeoutExpired(command, timeout_sec)
|
||||
if rc is None:
|
||||
raise RuntimeError("Script not run: network is blocked and the script could not be "
|
||||
f"isolated from the network ({output.strip()}).")
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Script exited with code {rc} (network blocked):\n{output[-2000:]}")
|
||||
return output
|
||||
|
||||
|
||||
__all__ = ["run_script"]
|
||||
@@ -43,6 +43,10 @@ class TeamsNotifier:
|
||||
"""Post a notification. Returns ``(ok, detail)``."""
|
||||
if not self.configured:
|
||||
return False, "Teams webhook URL is not configured."
|
||||
from ..application.network import network_guard
|
||||
|
||||
if network_guard.is_blocked():
|
||||
return False, network_guard.refusal("Teams notification")
|
||||
|
||||
# Workflows webhooks expect an Adaptive Card; classic connectors expect a
|
||||
# MessageCard. Try both, then a plain-text fallback.
|
||||
|
||||
Reference in New Issue
Block a user