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
This commit is contained in:
JOJO 2026-07-09 16:25:04 +08:00
parent 15106e7d00
commit 3db4e0beb4
5 changed files with 271 additions and 38 deletions

View File

@ -1,4 +1,28 @@
{ {
"macos_writable_paths": [], "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(\\.[^/]*)?$"
]
} }

View File

@ -7,17 +7,55 @@ from typing import Dict, List
from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS, deploy_config_path 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() _LOCK = threading.Lock()
# 部署级配置(机器特定可读写路径,会被运行时写回)→ ~/.agents/<mode>/config # 部署级配置(机器特定可读写路径,会被运行时写回)→ ~/.agents/<mode>/config
_POLICY_PATH = Path(deploy_config_path("host_sandbox_policy.json")) _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: def _ensure_file() -> None:
if _POLICY_PATH.exists(): if _POLICY_PATH.exists():
return return
_POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) _POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
_POLICY_PATH.write_text( _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", encoding="utf-8",
) )
@ -28,30 +66,38 @@ def load_policy() -> Dict:
try: try:
data = json.loads(_POLICY_PATH.read_text(encoding="utf-8")) data = json.loads(_POLICY_PATH.read_text(encoding="utf-8"))
except Exception: except Exception:
data = {"macos_writable_paths": [], "macos_readable_extra_paths": []} data = _default_policy()
if not isinstance(data, dict): if not isinstance(data, dict):
data = {"macos_writable_paths": [], "macos_readable_extra_paths": []} data = _default_policy()
writable_items = data.get("macos_writable_paths")
if not isinstance(writable_items, list): defaults = _default_policy()
writable_items = [] needs_save = False
readable_items = data.get("macos_readable_extra_paths") for key in defaults:
if not isinstance(readable_items, list): if key not in data or not isinstance(data.get(key), list):
readable_items = [] data[key] = list(defaults[key])
data["macos_writable_paths"] = [str(x).strip() for x in writable_items if str(x).strip()] needs_save = True
data["macos_readable_extra_paths"] = [str(x).strip() for x in readable_items if str(x).strip()]
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 return data
def save_policy(policy: Dict) -> Dict: def save_policy(policy: Dict) -> Dict:
payload = dict(policy or {}) payload = dict(policy or {})
writable_items = payload.get("macos_writable_paths") defaults = _default_policy()
if not isinstance(writable_items, list): for key in defaults:
writable_items = [] items = payload.get(key)
readable_items = payload.get("macos_readable_extra_paths") if not isinstance(items, list):
if not isinstance(readable_items, list): items = list(defaults[key])
readable_items = [] payload[key] = [str(x).strip() for x in items if str(x).strip()]
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()]
with _LOCK: with _LOCK:
_ensure_file() _ensure_file()
_POLICY_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") _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: if val and val not in merged:
merged.append(val) merged.append(val)
return merged 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

View File

@ -7,7 +7,11 @@ import shlex
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional 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 @dataclass
@ -22,6 +26,84 @@ class HostSandboxError(RuntimeError):
pass 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_RESTRICTED = "restricted" # macOS: 仅本地回环Linux/Windows: 暂不隔离
NETWORK_PERMISSION_FULL = "full" # 完全开放 NETWORK_PERMISSION_FULL = "full" # 完全开放
@ -131,13 +213,24 @@ def _build_macos_readonly_plan(
if not sandbox_exec: if not sandbox_exec:
raise HostSandboxError("macOS 未找到 sandbox-exec拒绝执行宿主机命令。") raise HostSandboxError("macOS 未找到 sandbox-exec拒绝执行宿主机命令。")
network_policy = _build_macos_network_policy(network_permission) 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 = ( profile = (
'(version 1) ' '(version 1)\n'
'(deny default) ' '(deny default)\n'
'(allow sysctl-read) ' '(allow sysctl-read)\n'
'(allow process*) ' '(allow process*)\n'
f'{network_policy}' 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"))' '(allow file-write* (literal "/dev/null"))'
) )
cmd = [sandbox_exec, "-p", profile, "/bin/bash", "-lc", command] 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_rules.append(f'(subpath "{entry}")')
write_expr = " ".join(write_rules) write_expr = " ".join(write_rules)
network_policy = _build_macos_network_policy(network_permission) 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 ( return (
'(version 1) ' '(version 1)\n'
'(deny default) ' '(deny default)\n'
'(allow sysctl-read) ' '(allow sysctl-read)\n'
'(allow process*) ' '(allow process*)\n'
f'{network_policy}' 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})' f'(allow file-write* {write_expr})'
) )

View File

@ -35,7 +35,12 @@ from modules.skills_manager import (
sync_workspace_skills, sync_workspace_skills,
) )
from modules.upload_security import UploadSecurityError 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 modules.user_manager import UserWorkspace
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
from config.model_profiles import get_model_context_window 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.extensions import socketio
from server.monitor import get_cached_monitor_snapshot from server.monitor import get_cached_monitor_snapshot
from server.files import sanitize_filename_preserve_unicode from server.files import sanitize_filename_preserve_unicode
import os
import re
UPLOAD_FOLDER_NAME = ".agents/user_upload" 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']) @chat_bp.route('/api/permission-mode', methods=['GET'])
@api_login_required @api_login_required
@with_terminal @with_terminal
@ -337,6 +364,8 @@ def get_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, user
"enabled": can_manage, "enabled": can_manage,
"writable_paths": data.get("macos_writable_paths", []), "writable_paths": data.get("macos_writable_paths", []),
"readable_extra_paths": data.get("macos_readable_extra_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']) @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()] readable_extra = [str(x).strip() for x in readable_items if str(x).strip()]
if "/" in writable or "/" in readable_extra: if "/" in writable or "/" in readable_extra:
return jsonify({"success": False, "error": "禁止授权根目录 /"}), 400 return jsonify({"success": False, "error": "禁止授权根目录 /"}), 400
blocked_prefix = ("~/.ssh", "~/.gnupg", "~/.aws")
for p in writable + readable_extra: for p in writable + readable_extra:
lowered = p.lower() conflict = _path_conflicts_with_deny_list(p)
if any(lowered.startswith(prefix) for prefix in blocked_prefix): if conflict:
return jsonify({"success": False, "error": f"禁止授权敏感目录: {p}"}), 400 return jsonify({"success": False, "error": conflict}), 400
payload = save_policy({ payload = save_policy({
"macos_writable_paths": writable, "macos_writable_paths": writable,
"macos_readable_extra_paths": readable_extra "macos_readable_extra_paths": readable_extra
@ -370,4 +398,6 @@ def update_path_authorization(terminal: WebTerminal, workspace: UserWorkspace, u
"success": True, "success": True,
"writable_paths": payload.get("macos_writable_paths", []), "writable_paths": payload.get("macos_writable_paths", []),
"readable_extra_paths": payload.get("macos_readable_extra_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", []),
}) })

View File

@ -24,11 +24,22 @@
仅可读 仅可读
</button> </button>
</div> </div>
<textarea <p class="hint">
{{
mode === 'writable'
? '可读可写路径在 workspace-write 沙箱中可写入,只读沙箱中仅可读。'
: '仅可读路径在只读沙箱中会被加入允许读取列表;工作区可写沙箱默认已可读。'
}}
</p>
<textarea
class="path-input" class="path-input"
:value="value" :value="value"
@input="$emit('update:value', ($event.target as HTMLTextAreaElement).value)" @input="$emit('update:value', ($event.target as HTMLTextAreaElement).value)"
placeholder="每行一个路径,例如:~/Desktop/agents-export" :placeholder="
mode === 'writable'
? '每行一个路径,例如:~/Desktop/agents-export'
: '每行一个路径,例如:~/Documents/reference'
"
/> />
<div class="actions"> <div class="actions">
<button type="button" class="btn" @click="$emit('save')" :disabled="saving">保存</button> <button type="button" class="btn" @click="$emit('save')" :disabled="saving">保存</button>