本节摘要:无论 6.1 选了哪一档,落地时调得最多的总是同样两面墙:文件系统与网络。文件系统侧的原则是"根只读、工作区可写、敏感区掩码"——根文件系统以只读方式挂载(写时复制 / overlay 思路提供可写层),可写范围收敛到工作区白名单,判定靠"路径先规范化再判界"(复用 5.1 节的路径作用域),并单独划出掩码区(.git、.env)防改历史与泄密。网络侧的原则是"默认禁网、按域名放行"——大部分编码任务根本不需要出网,需要的只是包 registry 与少数 API 端点;实现上有 DNS 层(粗、可被直连 IP 绕过)与强制代理层(细、可审计)之分,白名单必须在防火墙层兜底。本节给出
fs_net.py判界代码,它是第 10.3 节 mini-harness 简易沙箱的直接原料。
阅读完本节,你应当能够:
三条规则从上到下收紧:
| 规则 | 做法 | 防住什么 |
|---|---|---|
| 根只读(ro 挂载) | 整个根文件系统以只读方式呈现;需要写的地方用写时复制(overlay 思路)单独开可写层 | 对系统文件、其他项目、全局配置的任意写 |
| 工作区白名单 | 可写范围收敛到工作区目录;判定用"路径先 resolve 成绝对路径再判界" | workspace/../../.ssh 式穿越(5.1 节三陷阱之一) |
| 掩码区(deny 优先) | .git(防改写历史)、.env 与密钥文件(防泄密)、harness 自身的配置目录——即使在工作区内也直接不可见 | 模型顺手改历史 / 把凭证读进上下文 |
根文件系统(只读) ├── usr / bin / ... # 只读:系统完好 ├── workspace/ # 可写:唯一的作业区 │ ├── src/ ... # 正常读写 │ └── .git/ # 掩码:deny 优先(在工作区内也不可见) └── .mini_harness/ # 掩码:harness 自身状态(6.3 节理由)
💡 掩码区是"deny 优先"在沙箱里的投影:5.1 节的 deny 表达政策("任何时候不得触碰"),沙箱把它做成物理不可见而不是"判定时拦一下"——判定的执行点可能漏,挂载点不会。
fs_net.py 的文件系统半边# fs_net.py —— 文件系统与网络边界示意(写法示意,以实际工程为准) from pathlib import Path WORKSPACE = Path.cwd().resolve() MASKED_NAMES = {".git", ".mini_harness"} # 掩码区:deny 优先 MASKED_PATTERNS = (".env", "id_rsa") # 凭证类:前缀/文件名匹配 def resolve_in_scope(rel: str) -> Path: """路径判界:resolve 消解 .. 与符号链接后,必须仍落在工作区内。""" p = (WORKSPACE / rel).resolve() inside = p == WORKSPACE or WORKSPACE in p.parents masked = any(part in MASKED_NAMES for part in p.parts) or \ p.name.startswith(MASKED_PATTERNS) if not inside or masked: raise PermissionError(f"路径越界或位于掩码区: {rel}") return p
三个实现细节值得点破:
workspace/../x 与指向墙外的符号链接都在规范化阶段现形——判界必须发生在规范化之后,这正是 5.1 节"路径穿越"陷阱的对策。resolve_in_scope,一处实现全局生效——第 10 章的 in_workspace 就是它的微缩版。| 策略 | 含义 | 适用 |
|---|---|---|
| deny | 完全禁网 | 大部分编码任务:读代码、改代码、跑测试都不需要出网 |
| 域名白名单 | 仅放行显式列出的域名 | 需要装依赖(包 registry)或调 API 的任务 |
| ask | 出网前问人 | 一次性的、无法枚举的下载需求(5.2 节审批流) |
出网白名单的粒度是域名而非 IP:域名语义稳定、可读可审计。典型白名单:pypi.org / files.pythonhosted.org(Python 包)、registry.npmjs.org(Node 包)、api.openai.com(模型 API,若沙箱内进程需要)。git push 类远端写操作不在白名单语义内——那是 5.3 节 exfiltrates 闸门(数据外传)的地盘。
实现层有粗细之分:
| 层 | 做法 | 弱点 |
|---|---|---|
| DNS 层 | 劫持解析:非白名单域名解析失败 | 裸 IP 直连可完全绕过 DNS;DNS rebinding |
| 代理层 | 强制所有流量过代理(透明代理 / HTTP CONNECT),按域名放行并留审计日志 | 配置与运维成本;非 HTTP 协议要额外处理 |
| 防火墙兜底 | 出站默认拒绝,仅放行代理与白名单 IP 段 | 与代理层组合才有意义 |
⚠️ 常见事故:只做了 DNS 劫持就以为"禁网了"。模型完全可以
curl http://203.0.113.7/...直连。域名白名单必须配防火墙层的"出站默认拒绝"兜底,否则它只是个提示牌。
fs_net.py 的网络半边# fs_net.py(续)—— 出网白名单判定(写法示意,以实际工程为准) import re NET_ALLOWLIST = { "pypi.org", "files.pythonhosted.org", # Python 包 "registry.npmjs.org", # Node 包 "api.openai.com", # 模型 API } def net_allowed(url: str) -> bool: """按域名精确匹配;裸 IP 直连一律拒绝(防绕过域名白名单)。""" m = re.match(r"https?://([^/:?#]+)", url) if not m: return False # 非 http(s) 不在白名单语义内 host = m.group(1).lower() if re.fullmatch(r"[\d.]+", host): return False # 裸 IP:常见绕过姿势 return host in NET_ALLOWLIST
最后是与 5.3 节的分工,一句口诀:闸门管"数据该不该出去"(语义、可解释、概率性),边界管"流量能不能出去"(物理、结构、零误放)。成熟系统两层都要:闸门拦"看起来要外传"的命令并给出理由,边界保证"没被放行的流量物理上出不去"。
两面墙砌好了,还剩最后一个问题:墙内墙外各住着谁——harness 本体到底该放在沙箱里面还是外面?下一节的答案可能出乎新手意料。