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:
parent
15106e7d00
commit
3db4e0beb4
@ -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(\\.[^/]*)?$"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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/<mode>/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
|
||||
|
||||
@ -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})'
|
||||
)
|
||||
|
||||
|
||||
@ -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", []),
|
||||
})
|
||||
|
||||
@ -24,11 +24,22 @@
|
||||
仅可读
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
<p class="hint">
|
||||
{{
|
||||
mode === 'writable'
|
||||
? '可读可写路径在 workspace-write 沙箱中可写入,只读沙箱中仅可读。'
|
||||
: '仅可读路径在只读沙箱中会被加入允许读取列表;工作区可写沙箱默认已可读。'
|
||||
}}
|
||||
</p>
|
||||
<textarea
|
||||
class="path-input"
|
||||
:value="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">
|
||||
<button type="button" class="btn" @click="$emit('save')" :disabled="saving">保存</button>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user