- docker:只读/审批档 run_command、后台命令与持久终端改用非特权 uid(10001) 执行角色,内核 DAC 强制只读,取代文本特征识别;Dockerfile 加固(agent 用户 / git safe.directory / 去 setuid) - macOS:只读与可写沙箱 profile 统一为白名单读模型(deny default + 系统目录/工作区/路径授权),修复 deny 顺序导致的工作区 .env 实际可读漏洞;可写 profile 白名单化后审批不再放大读取,越界读取唯一途径为路径授权 - 权限模式:受限档(readonly/approval/auto_approval)与 direct 执行环境硬互斥——进入受限档压回沙箱并记录,切回 unrestricted 恢复,存量受限+direct 对话加载自愈矫正 - 配置:路径授权来源收敛为 host_sandbox_policy.json + 环境变量两个通道(移除 settings.json 映射) - 修复:新建对话权限模式被个性化默认值覆盖、/new 切只读后回落无限制的继承 bug - 原生文件工具读边界与沙箱白名单同源对齐
185 lines
5.9 KiB
Python
185 lines
5.9 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import threading
|
||
from pathlib import Path
|
||
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(\.[^/]*)?$",
|
||
]
|
||
|
||
# Windows(WSL2+bwrap 沙箱)默认掩蔽的敏感路径(支持 ~/ 前缀)。
|
||
# 目录用 tmpfs 整体掩蔽,文件用 /dev/null 掩蔽;仅掩蔽实际存在的路径。
|
||
DEFAULT_WINDOWS_DENY_READ_PATHS = [
|
||
"~/.ssh",
|
||
"~/.aws",
|
||
"~/.azure",
|
||
"~/.gcp",
|
||
"~/.google",
|
||
"~/.kube",
|
||
"~/.docker",
|
||
"~/.gnupg",
|
||
"~/.npmrc",
|
||
"~/.netrc",
|
||
"~/.git-credentials",
|
||
]
|
||
|
||
_LOCK = threading.Lock()
|
||
# 部署级配置(机器特定可读写路径,会被运行时写回)→ ~/.astrion/<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),
|
||
"windows_deny_read_paths": list(DEFAULT_WINDOWS_DENY_READ_PATHS),
|
||
}
|
||
|
||
|
||
def _ensure_file() -> None:
|
||
if _POLICY_PATH.exists():
|
||
return
|
||
_POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
_POLICY_PATH.write_text(
|
||
json.dumps(_default_policy(), ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
def load_policy() -> Dict:
|
||
with _LOCK:
|
||
_ensure_file()
|
||
try:
|
||
data = json.loads(_POLICY_PATH.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
data = _default_policy()
|
||
if not isinstance(data, dict):
|
||
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()]
|
||
data["windows_deny_read_paths"] = [str(x).strip() for x in data["windows_deny_read_paths"] 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 {})
|
||
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")
|
||
return payload
|
||
|
||
|
||
def get_macos_writable_paths() -> List[str]:
|
||
"""沙箱可写路径全集 = 部署环境变量 + policy 文件(合并去重)。
|
||
|
||
仅两个来源(2026-08-30 收敛):真·环境变量
|
||
HOST_SANDBOX_MACOS_WRITABLE_PATHS(部署通道)+ host_sandbox_policy.json
|
||
的 macos_writable_paths(前端「路径授权」UI)。settings.json 的
|
||
terminal.macos_writable_paths 已不再是来源(config/__init__.py 映射已移除)。
|
||
"""
|
||
file_items = load_policy().get("macos_writable_paths", [])
|
||
merged: List[str] = []
|
||
for raw in list(HOST_SANDBOX_MACOS_WRITABLE_PATHS or []) + list(file_items or []):
|
||
val = str(raw).strip()
|
||
if val and val not in merged:
|
||
merged.append(val)
|
||
return merged
|
||
|
||
|
||
def get_macos_readable_paths() -> List[str]:
|
||
data = load_policy()
|
||
readable_extra = data.get("macos_readable_extra_paths", [])
|
||
merged: List[str] = []
|
||
for raw in list(get_macos_writable_paths()) + list(readable_extra or []):
|
||
val = str(raw).strip()
|
||
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
|
||
|
||
|
||
def get_windows_deny_read_paths() -> List[str]:
|
||
"""Windows(WSL2+bwrap)沙箱需要掩蔽的敏感路径列表。"""
|
||
data = load_policy()
|
||
paths = data.get("windows_deny_read_paths", [])
|
||
merged: List[str] = []
|
||
for raw in list(DEFAULT_WINDOWS_DENY_READ_PATHS) + list(paths or []):
|
||
val = str(raw).strip()
|
||
if val and val not in merged:
|
||
merged.append(val)
|
||
return merged
|