From 3db4e0beb43817442b95bb24bcdf6abddba33ef8 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Thu, 9 Jul 2026 16:25:04 +0800 Subject: [PATCH] feat(sandbox): macOS host sandbox deny-list for sensitive paths - Add default deny_read_paths/deny_read_regexes to host_sandbox_policy.json - Generate deny rules for ~/.ssh, ~/.aws, ~/.kube, keychains, .env, etc. - Readonly sandbox: global read + deny sensitive + write only /dev/null - Workspace-write sandbox: global read + deny sensitive + writable paths - Keep workspace files readable by re-allowing workspace subpath after deny - Backend API validates path authorization against deny list - Path auth dialog hint explains readable paths behavior --- config/host_sandbox_policy.json.example | 26 +++- modules/host_sandbox_policy.py | 106 ++++++++++++--- modules/host_sandbox_runner.py | 122 ++++++++++++++++-- server/chat/permission.py | 40 +++++- .../overlay/PathAuthorizationDialog.vue | 15 ++- 5 files changed, 271 insertions(+), 38 deletions(-) diff --git a/config/host_sandbox_policy.json.example b/config/host_sandbox_policy.json.example index d5ffe06..ec9c0da 100644 --- a/config/host_sandbox_policy.json.example +++ b/config/host_sandbox_policy.json.example @@ -1,4 +1,28 @@ { "macos_writable_paths": [], - "macos_readable_extra_paths": [] + "macos_readable_extra_paths": [], + "macos_deny_read_paths": [ + "~/.ssh", + "~/.aws", + "~/.azure", + "~/.gcp", + "~/.google", + "~/.kube", + "~/.docker", + "~/.gnupg", + "~/.npmrc", + "~/.netrc", + "~/.pypirc", + "~/.git-credentials", + "~/.bash_history", + "~/.zsh_history", + "~/.psql_history", + "~/.mysql_history", + "~/.pgpass", + "/Library/Keychains", + "~/Library/Keychains" + ], + "macos_deny_read_regexes": [ + "^/.*\\.env(\\.[^/]*)?$" + ] } diff --git a/modules/host_sandbox_policy.py b/modules/host_sandbox_policy.py index f66d7e9..029a4d0 100644 --- a/modules/host_sandbox_policy.py +++ b/modules/host_sandbox_policy.py @@ -7,17 +7,55 @@ from typing import Dict, List from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS, deploy_config_path + +# macOS 默认拒绝读取的敏感路径(支持 ~/ 前缀)。 +DEFAULT_MACOS_DENY_READ_PATHS = [ + "~/.ssh", + "~/.aws", + "~/.azure", + "~/.gcp", + "~/.google", + "~/.kube", + "~/.docker", + "~/.gnupg", + "~/.npmrc", + "~/.netrc", + "~/.pypirc", + "~/.git-credentials", + "~/.bash_history", + "~/.zsh_history", + "~/.psql_history", + "~/.mysql_history", + "~/.pgpass", + "/Library/Keychains", + "~/Library/Keychains", +] + +# 默认按正则拒绝读取的敏感文件(可匹配文件系统中任意位置)。 +DEFAULT_MACOS_DENY_READ_REGEXES = [ + r"^/.*\.env(\.[^/]*)?$", +] + _LOCK = threading.Lock() # 部署级配置(机器特定可读写路径,会被运行时写回)→ ~/.agents//config _POLICY_PATH = Path(deploy_config_path("host_sandbox_policy.json")) +def _default_policy() -> Dict: + return { + "macos_writable_paths": [], + "macos_readable_extra_paths": [], + "macos_deny_read_paths": list(DEFAULT_MACOS_DENY_READ_PATHS), + "macos_deny_read_regexes": list(DEFAULT_MACOS_DENY_READ_REGEXES), + } + + def _ensure_file() -> None: if _POLICY_PATH.exists(): return _POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) _POLICY_PATH.write_text( - json.dumps({"macos_writable_paths": [], "macos_readable_extra_paths": []}, ensure_ascii=False, indent=2), + json.dumps(_default_policy(), ensure_ascii=False, indent=2), encoding="utf-8", ) @@ -28,30 +66,38 @@ def load_policy() -> Dict: try: data = json.loads(_POLICY_PATH.read_text(encoding="utf-8")) except Exception: - data = {"macos_writable_paths": [], "macos_readable_extra_paths": []} + data = _default_policy() if not isinstance(data, dict): - data = {"macos_writable_paths": [], "macos_readable_extra_paths": []} - writable_items = data.get("macos_writable_paths") - if not isinstance(writable_items, list): - writable_items = [] - readable_items = data.get("macos_readable_extra_paths") - if not isinstance(readable_items, list): - readable_items = [] - data["macos_writable_paths"] = [str(x).strip() for x in writable_items if str(x).strip()] - data["macos_readable_extra_paths"] = [str(x).strip() for x in readable_items if str(x).strip()] + data = _default_policy() + + defaults = _default_policy() + needs_save = False + for key in defaults: + if key not in data or not isinstance(data.get(key), list): + data[key] = list(defaults[key]) + needs_save = True + + data["macos_writable_paths"] = [str(x).strip() for x in data["macos_writable_paths"] if str(x).strip()] + data["macos_readable_extra_paths"] = [str(x).strip() for x in data["macos_readable_extra_paths"] if str(x).strip()] + data["macos_deny_read_paths"] = [str(x).strip() for x in data["macos_deny_read_paths"] if str(x).strip()] + data["macos_deny_read_regexes"] = [str(x).strip() for x in data["macos_deny_read_regexes"] if str(x).strip()] + + if needs_save: + try: + _POLICY_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: + pass return data def save_policy(policy: Dict) -> Dict: payload = dict(policy or {}) - writable_items = payload.get("macos_writable_paths") - if not isinstance(writable_items, list): - writable_items = [] - readable_items = payload.get("macos_readable_extra_paths") - if not isinstance(readable_items, list): - readable_items = [] - payload["macos_writable_paths"] = [str(x).strip() for x in writable_items if str(x).strip()] - payload["macos_readable_extra_paths"] = [str(x).strip() for x in readable_items if str(x).strip()] + defaults = _default_policy() + for key in defaults: + items = payload.get(key) + if not isinstance(items, list): + items = list(defaults[key]) + payload[key] = [str(x).strip() for x in items if str(x).strip()] with _LOCK: _ensure_file() _POLICY_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") @@ -77,3 +123,25 @@ def get_macos_readable_paths() -> List[str]: if val and val not in merged: merged.append(val) return merged + + +def get_macos_deny_read_paths() -> List[str]: + data = load_policy() + paths = data.get("macos_deny_read_paths", []) + merged: List[str] = [] + for raw in list(DEFAULT_MACOS_DENY_READ_PATHS) + list(paths or []): + val = str(raw).strip() + if val and val not in merged: + merged.append(val) + return merged + + +def get_macos_deny_read_regexes() -> List[str]: + data = load_policy() + patterns = data.get("macos_deny_read_regexes", []) + merged: List[str] = [] + for raw in list(DEFAULT_MACOS_DENY_READ_REGEXES) + list(patterns or []): + val = str(raw).strip() + if val and val not in merged: + merged.append(val) + return merged diff --git a/modules/host_sandbox_runner.py b/modules/host_sandbox_runner.py index 582fc07..7f00f9b 100644 --- a/modules/host_sandbox_runner.py +++ b/modules/host_sandbox_runner.py @@ -7,7 +7,11 @@ import shlex from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional -from modules.host_sandbox_policy import get_macos_writable_paths +from modules.host_sandbox_policy import ( + get_macos_writable_paths, + get_macos_deny_read_paths, + get_macos_deny_read_regexes, +) @dataclass @@ -22,6 +26,84 @@ class HostSandboxError(RuntimeError): pass +# macOS 最小可读系统路径集合(Codex 风格 deny-default + allow-list)。 +# 当前仅作为常量保留,供后续需要严格 allow-list 的只读沙箱模式使用。 +# 当前只读沙箱采用“全局可读 + 敏感路径拒绝”模型,以保证工具兼容性。 +MACOS_MINIMAL_READABLE_PATHS = [ + "/bin", + "/sbin", + "/usr/bin", + "/usr/sbin", + "/usr/libexec", + "/usr/lib", + "/usr/share", + "/usr/local/lib", + "/opt/homebrew/lib", + "/lib", + "/etc", + "/private/etc", + "/tmp", + "/private/tmp", + "/var/tmp", + "/private/var/tmp", + "/var", + "/private/var", + "/dev", + "/System/Library/Frameworks", + "/System/Library/PrivateFrameworks", + "/System/Library/SubFrameworks", + "/System/Library/CoreServices", + "/System/Library/Extensions", + "/Library/Apple", + "/Library/Preferences", + "/Library/Filesystems/NetFSPlugins", + "/Applications", +] + + +def _expand_path(raw: str) -> Optional[str]: + """展开路径中的 ~ 并返回绝对路径;无法展开时返回 None。""" + if not raw: + return None + try: + expanded = str(Path(raw).expanduser().resolve()) + except Exception: + return None + return expanded + + +def _build_macos_read_rules(paths: List[str]) -> str: + """把路径列表转成 (allow file-read* (subpath ...)) 规则段。""" + rules: List[str] = [] + seen: set[str] = set() + for raw in paths: + expanded = _expand_path(raw) + if expanded and expanded not in seen: + seen.add(expanded) + rules.append(f'(allow file-read* (subpath "{expanded}"))') + return "\n".join(rules) + + +def _build_macos_deny_rules(paths: List[str]) -> str: + """把路径列表转成 (deny file-read* (subpath ...)) 规则段。""" + rules: List[str] = [] + seen: set[str] = set() + for raw in paths: + expanded = _expand_path(raw) + if expanded and expanded not in seen: + seen.add(expanded) + rules.append(f'(deny file-read* (subpath "{expanded}"))') + return "\n".join(rules) + + +def _build_macos_deny_regex_rules(patterns: List[str]) -> str: + """把正则列表转成 (deny file-read* (regex #"...")) 规则段。""" + rules: List[str] = [] + for pattern in patterns: + rules.append(f'(deny file-read* (regex #"{pattern}"))') + return "\n".join(rules) + + # 宿主机网络权限档位 NETWORK_PERMISSION_RESTRICTED = "restricted" # macOS: 仅本地回环;Linux/Windows: 暂不隔离 NETWORK_PERMISSION_FULL = "full" # 完全开放 @@ -131,13 +213,24 @@ def _build_macos_readonly_plan( if not sandbox_exec: raise HostSandboxError("macOS 未找到 sandbox-exec,拒绝执行宿主机命令。") network_policy = _build_macos_network_policy(network_permission) + workspace = str(work_path.resolve()) + + # 只读沙箱:全局可读 + 敏感路径/文件拒绝,写权限仅 /dev/null。 + # 工作区在 deny 规则之后再显式 allow,保证“工作区内不受沙箱影响”。 + deny_rules = _build_macos_deny_rules(get_macos_deny_read_paths()) + regex_rules = _build_macos_deny_regex_rules(get_macos_deny_read_regexes()) + if regex_rules: + deny_rules += "\n" + regex_rules + profile = ( - '(version 1) ' - '(deny default) ' - '(allow sysctl-read) ' - '(allow process*) ' + '(version 1)\n' + '(deny default)\n' + '(allow sysctl-read)\n' + '(allow process*)\n' f'{network_policy}' - '(allow file-read*) ' + '(allow file-read*)\n' + f'{deny_rules}\n' + f'(allow file-read* (subpath "{workspace}"))\n' '(allow file-write* (literal "/dev/null"))' ) cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command] @@ -178,13 +271,20 @@ def _macos_profile_for_workspace( write_rules.append(f'(subpath "{entry}")') write_expr = " ".join(write_rules) network_policy = _build_macos_network_policy(network_permission) + workspace = str(work_path.resolve()) + deny_rules = _build_macos_deny_rules(get_macos_deny_read_paths()) + regex_rules = _build_macos_deny_regex_rules(get_macos_deny_read_regexes()) + if regex_rules: + deny_rules += "\n" + regex_rules return ( - '(version 1) ' - '(deny default) ' - '(allow sysctl-read) ' - '(allow process*) ' + '(version 1)\n' + '(deny default)\n' + '(allow sysctl-read)\n' + '(allow process*)\n' f'{network_policy}' - '(allow file-read*) ' + '(allow file-read*)\n' + f'{deny_rules}\n' + f'(allow file-read* (subpath "{workspace}"))\n' f'(allow file-write* {write_expr})' ) diff --git a/server/chat/permission.py b/server/chat/permission.py index f5ef023..e446c0a 100644 --- a/server/chat/permission.py +++ b/server/chat/permission.py @@ -35,7 +35,12 @@ from modules.skills_manager import ( sync_workspace_skills, ) from modules.upload_security import UploadSecurityError -from modules.host_sandbox_policy import load_policy, save_policy +from modules.host_sandbox_policy import ( + load_policy, + save_policy, + get_macos_deny_read_paths, + get_macos_deny_read_regexes, +) from modules.user_manager import UserWorkspace from core.web_terminal import WebTerminal from config.model_profiles import get_model_context_window @@ -49,8 +54,30 @@ from server.state import tool_approval_manager, user_question_manager from server.extensions import socketio from server.monitor import get_cached_monitor_snapshot from server.files import sanitize_filename_preserve_unicode +import os +import re UPLOAD_FOLDER_NAME = ".agents/user_upload" + + +def _path_conflicts_with_deny_list(path: str) -> Optional[str]: + """检查用户授权路径是否与内置 deny 列表冲突,返回错误信息或 None。""" + if not path: + return None + expanded = os.path.abspath(os.path.expanduser(path)) + for deny_path in get_macos_deny_read_paths(): + deny_expanded = os.path.abspath(os.path.expanduser(deny_path)) + deny_lower = deny_expanded.lower().rstrip("/") + expanded_lower = expanded.lower().rstrip("/") + if expanded_lower == deny_lower or expanded_lower.startswith(deny_lower + "/"): + return f"禁止授权敏感路径: {path}" + for pattern in get_macos_deny_read_regexes(): + try: + if re.search(pattern, expanded) or re.search(pattern, path): + return f"禁止授权敏感文件: {path}" + except re.error: + continue + return None @chat_bp.route('/api/permission-mode', methods=['GET']) @api_login_required @with_terminal @@ -337,6 +364,8 @@ def get_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, user "enabled": can_manage, "writable_paths": data.get("macos_writable_paths", []), "readable_extra_paths": data.get("macos_readable_extra_paths", []), + "deny_read_paths": data.get("macos_deny_read_paths", []), + "deny_read_regexes": data.get("macos_deny_read_regexes", []), }) @chat_bp.route('/api/path-authorization', methods=['POST']) @@ -357,11 +386,10 @@ def update_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, u readable_extra = [str(x).strip() for x in readable_items if str(x).strip()] if "/" in writable or "/" in readable_extra: return jsonify({"success": False, "error": "禁止授权根目录 /"}), 400 - blocked_prefix = ("~/.ssh", "~/.gnupg", "~/.aws") for p in writable + readable_extra: - lowered = p.lower() - if any(lowered.startswith(prefix) for prefix in blocked_prefix): - return jsonify({"success": False, "error": f"禁止授权敏感目录: {p}"}), 400 + conflict = _path_conflicts_with_deny_list(p) + if conflict: + return jsonify({"success": False, "error": conflict}), 400 payload = save_policy({ "macos_writable_paths": writable, "macos_readable_extra_paths": readable_extra @@ -370,4 +398,6 @@ def update_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, u "success": True, "writable_paths": payload.get("macos_writable_paths", []), "readable_extra_paths": payload.get("macos_readable_extra_paths", []), + "deny_read_paths": payload.get("macos_deny_read_paths", []), + "deny_read_regexes": payload.get("macos_deny_read_regexes", []), }) diff --git a/static/src/components/overlay/PathAuthorizationDialog.vue b/static/src/components/overlay/PathAuthorizationDialog.vue index 563542c..160c694 100644 --- a/static/src/components/overlay/PathAuthorizationDialog.vue +++ b/static/src/components/overlay/PathAuthorizationDialog.vue @@ -24,11 +24,22 @@ 仅可读 -