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 @@
仅可读
-
+