Files
cowork-local/core/link_fetch.py
T
minhanhpkproandClaude Opus 5 b78d48320c fix(sandbox): "Chặn mạng" chặn mọi đường ra mạng, trừ nhà cung cấp AI
Trước đây công tắc chỉ chặn tool mạng của agent; lệnh shell chỉ bị proxy
giả, còn M365, Teams, nút Test, MCP đang chạy, task script, link đính kèm
task, pip tự cài và tài nguyên web trong xem trước HTML vẫn ra mạng tự do.

- Cổng chung application/network/network_guard.py, nối vào cấu hình sống
  ở Composition Root; nhà cung cấp AI (chat, danh sách model, thử model)
  không đi qua cổng này.
- Lệnh shell của agent và task script chạy trong Windows AppContainer
  không có quyền mạng (macOS: sandbox-exec, Linux: unshare --net);
  không cô lập được thì từ chối chạy.
- Không cấp quyền kế thừa của AppContainer lên thư mục chứa PySide6:
  Chromium không nạp được Qt6WebEngineCore.dll và tab Graph bị hỏng.
- Bật chặn thì dừng MCP đang chạy; tool OneDrive đồng bộ trên máy vẫn dùng.
- Mặc định tắt khi mở app lần đầu; nhãn và tooltip 3 ngôn ngữ cập nhật.
- Test: tests/test_network_guard_lanes.py (có bài AppContainer thật).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:32:44 +09:00

207 lines
9.3 KiB
Python

"""Best-effort URL preview for task/attachment links.
A link that points at a real document (PDF/Office/OpenDocument — by
Content-Type or, failing that, the URL's own extension) is downloaded in
full and run through ``doc_extract.extract_text`` — the SAME parser local
file attachments already use — so a link to a file behaves like an actual
attached file, not garbled text. Only when the link is NOT a recognized
document does this fall back to fetching a bounded preview and, for HTML,
stripping tags with a lightweight regex (no heavy dependency for that path).
Never raises: network failures, non-HTML/non-document content, oversized
pages and unparseable documents all degrade to a short explanatory note so a
bad link never breaks a task run.
"""
from __future__ import annotations
import re
import tempfile
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
_TIMEOUT = (10, 20) # (connect, read) seconds
_MAX_FETCH_BYTES = 2_000_000 # generic text/HTML preview cap (~2MB)
_MAX_DOC_FETCH_BYTES = 20_000_000 # a real document is downloaded in full, up to this cap
_MAX_PREVIEW_CHARS = 8_000
_SCRIPT_STYLE_RE = re.compile(r"<(script|style)\b[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"[ \t]+")
_BLANK_LINES_RE = re.compile(r"\n{3,}")
# Content-Type → the suffix doc_extract.extract_text() dispatches on.
_CONTENT_TYPE_SUFFIX = {
"application/pdf": ".pdf",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/vnd.oasis.opendocument.text": ".odt",
"application/vnd.oasis.opendocument.spreadsheet": ".ods",
"application/vnd.oasis.opendocument.presentation": ".odp",
}
_DOC_SUFFIXES = {".pdf", ".doc", ".docx", ".docm", ".xls", ".xlsx", ".xlsm",
".ppt", ".pptx", ".odt", ".ods", ".odp"}
def _html_to_text(html: str) -> str:
"""Rút văn bản đọc được từ HTML: bỏ script/style, đổi thẻ thành xuống dòng rồi
gộp khoảng trắng thừa.
"""
text = _SCRIPT_STYLE_RE.sub(" ", html)
text = _TAG_RE.sub("\n", text)
text = _WS_RE.sub(" ", text)
text = _BLANK_LINES_RE.sub("\n\n", text)
return text.strip()
def _doc_suffix_for(url: str, content_type: str, disposition: str = "") -> str:
"""The doc_extract-recognized suffix for this response, or "" when it
isn't a document at all. Checks Content-Type first, then the URL's own
extension, then the Content-Disposition filename — share-link downloads
(SharePoint/OneDrive) have extension-less URLs and often ship as
application/octet-stream, so the disposition filename is the only tell."""
suffix = _CONTENT_TYPE_SUFFIX.get(content_type, "")
if suffix:
return suffix
path_suffix = Path(urlparse(url).path).suffix.lower()
if path_suffix in _DOC_SUFFIXES:
return path_suffix
m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)', disposition or "")
if m:
disp_suffix = Path(m.group(1).strip()).suffix.lower()
if disp_suffix in _DOC_SUFFIXES:
return disp_suffix
return ""
# ---- SharePoint / OneDrive share links --------------------------------------
_SHAREPOINT_HOST_RE = re.compile(r"(^|\.)sharepoint\.com$", re.IGNORECASE)
_ONEDRIVE_HOSTS = {"1drv.ms", "onedrive.live.com"}
def _is_share_link(url: str) -> bool:
"""Link này có phải link chia sẻ SharePoint/OneDrive không.
Loại link đó cần đi qua đường xác thực MS365 thay vì tải HTTP thường.
"""
host = (urlparse(url).hostname or "").lower()
return bool(_SHAREPOINT_HOST_RE.search(host)) or host in _ONEDRIVE_HOSTS
def _share_download_url(url: str) -> Optional[str]:
"""Turn a SharePoint / OneDrive SHARE link into a direct-download URL, or
None when ``url`` isn't a share link. No auth is used — this works for
links shared as "Anyone with the link"; an access-protected link comes
back as an HTML sign-in page, which the caller detects and explains.
- ``https://<tenant>.sharepoint.com/:x:/...`` (and /personal/, /sites/
Shared Documents file links) → same URL + ``download=1``.
- ``https://1drv.ms/...`` / ``onedrive.live.com`` → the public OneDrive
shares API: ``https://api.onedrive.com/v1.0/shares/u!<b64url>/root/content``.
"""
parsed = urlparse(url)
host = (parsed.hostname or "").lower()
if host in _ONEDRIVE_HOSTS:
import base64
token = base64.urlsafe_b64encode(url.encode("utf-8")).decode("ascii").rstrip("=")
return f"https://api.onedrive.com/v1.0/shares/u!{token}/root/content"
if _SHAREPOINT_HOST_RE.search(host):
sep = "&" if parsed.query else "?"
if "download=1" in (parsed.query or ""):
return url
return f"{url}{sep}download=1"
return None
def _extract_document(raw: bytes, suffix: str):
"""``(text, note)`` via doc_extract.extract_text() on a temp copy of
``raw`` — mirrors how a local file attachment of the same type is read."""
from . import doc_extract
tmp_path = None
try:
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
f.write(raw)
tmp_path = f.name
return doc_extract.extract_text(tmp_path)
except OSError as exc:
return None, str(exc)
finally:
if tmp_path:
try:
Path(tmp_path).unlink()
except OSError:
pass
def fetch_link_preview(url: str) -> str:
"""A short text preview of ``url``'s content, or a note explaining why
none is available. Always returns a string, never raises."""
url = (url or "").strip()
if not url:
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.
is_share = _is_share_link(url)
fetch_target = _share_download_url(url) or url
try:
from . import tls_trust
# Same TLS auto-recovery the LLM provider calls already get: a
# corporate gateway that terminates TLS with its own certificate used
# to break fetch_url outright (SSLCertVerificationError) even when
# "Allow the agent to fetch URLs" was on and network wasn't blocked —
# this call site just never had the same self-signed-cert recovery.
resp = tls_trust.request("get", fetch_target, timeout=_TIMEOUT, stream=True,
headers={"User-Agent": "Mozilla/5.0 (CoworkLocal)"})
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "").split(";")[0].strip().lower()
doc_suffix = _doc_suffix_for(url, content_type,
resp.headers.get("Content-Disposition", ""))
cap = _MAX_DOC_FETCH_BYTES if doc_suffix else _MAX_FETCH_BYTES
raw = resp.raw.read(cap, decode_content=True) or b""
except Exception as exc: # noqa: BLE001 — a bad link must never break a task
if is_share:
return (f"[Link: {url}] (SharePoint/OneDrive share link — could not download: {exc}. "
"If the file needs sign-in, share it as 'Anyone with the link', or use the "
"locally-synced OneDrive folder path instead.)")
return f"[Link: {url}] (could not fetch: {exc})"
# A share link answered with an HTML page = an auth wall: a real shared
# file downloads as the document itself, so an HTML response means a
# sign-in/redirect page (even when the URL's own suffix looks like a doc).
# Say that instead of dumping the login page's text into the prompt.
if is_share and "html" in content_type:
return (f"[Link: {url}] (SharePoint/OneDrive share link requires sign-in — the link "
"returned a login page, not the file. Share it as 'Anyone with the link', or "
"attach the file from the locally-synced OneDrive folder instead.)")
if doc_suffix:
text, note = _extract_document(raw, doc_suffix)
if text is None:
return f"[Link: {url}] (file: {doc_suffix}; could not read it: {note or 'unknown error'})"
preview = text[:_MAX_PREVIEW_CHARS]
trunc = "…" if len(text) > _MAX_PREVIEW_CHARS else ""
return f"[Link: {url}] (file: {doc_suffix})\n{preview}{trunc}"
text = raw.decode(resp.encoding or "utf-8", errors="replace")
if "html" in content_type or "<html" in text[:500].lower():
text = _html_to_text(text)
preview = text[:_MAX_PREVIEW_CHARS]
suffix = "…" if len(text) > _MAX_PREVIEW_CHARS else ""
return f"[Link: {url}]\n{preview}{suffix}"