Agent Sandbox Registry

python-sdk

评审记录:细粒度能力、客户端/服务端对齐、基础镜像

docs/zh/archive/fine-grained-capabilities-review.md

评审记录:细粒度能力、客户端/服务端对齐、基础镜像

状态:归档。不是现行规范。

被评审文档:fine-grained-capabilities.md、 client-server-alignment.md、 base-image-and-server-sdk.md。 被评审产物:v1 草案(第 1 轮评审前冻结的三份文档)。 结论:v2(已评审)。下列每一项发现要么已在设计文档中解决(每份文档末尾的"评审后的变更"一节),要么明确推迟并说明原因。 英文版为准(逐条的完整措辞与测试引用):../../en/archive/fine-grained-capabilities-review.md。

0. 方法与如实说明

任务要求由不同视角的全新子智能体进行两轮独立的对抗式评审。撰写环境没有子智能体创建能力,因此两轮评审由作者本人以相互独立的串行评审过程完成, 每个视角都扮演刻意敌对的角色,并且从冻结的 v1 草案和显式检查清单出发,而不是从作者的意图出发。这弱于真正独立的评审者(可能存在共同盲区),因此采取了两项缓解措施:

建议在 GA 发布之前增加由人工或独立智能体完成的第三轮评审(列入最终报告的未决问题)。

严重程度:H = 会带来漏洞或硬性不兼容;M = 会导致缺陷或用户痛点的设计缺口;L = 打磨项。

1. 第 1 轮:功能完整性与用户体验/API 人体工学

1a. 视角:功能完整性("哪些场景无法表达或含糊不清?")

# 级别 发现 解决
R1-F1 M 主打场景("只暴露上传 + 下载,且下载只能来自 /home/user/out")需要 12 行 JSON;草案中没有一行式写法。 transfer-only 预设以及 ServerPolicy.only(*endpoint_ids, **rule) 成为规范(3.4a):only = "*": {enabled: false} + 启用列出的 ID,核心端点始终保留。测试:test_policy_config.py(预设)、test_policy_paths.py::TestTransferOnlyScenario。
R1-F2 M 多路径端点(files_move、archive、搜索根)未规定:只读是针对源还是目标检查?每个路径属于哪类操作? 增加规范性的路径角色表(3.5):files_move 的源为 delete、目标为 write,两者都必须通过 allow/deny/只读;files_archive 的根为 read,每个成员重新检查。测试:test_policy_paths.py::...::test_move_out_of_scope_denied_both_directions、test_policy_pipeline.py::TestScopes::test_move_requires_both_write_and_delete。
R1-F3 H 限制只通过 Content-Length 执行。伪造/缺失长度(分块)会让无限数据流入 files_upload_stream。 请求体由端点的 max_request_bytes(默认为传输上限)约束,作用于读取请求体前声明的 Content-Length;max_bytes 约束解码后的负载;策略模式下拒绝分块 Transfer-Encoding(400);中止时删除不完整文件。测试:test_http_hardening.py::TestBodyLimit::test_lying_content_length_cannot_exceed_cap、test_policy_paths.py::TestTransferOnlyScenario::test_upload_size_limit。实现说明: 最终实现没有使用计数读取器;服务端只读取声明的长度,因此伪造的长度无法超出上限。
R1-F4 M 自定义路由(RouteTable.route(...))和自定义命令无法参与:无法声明 scope,处理器没有 PathGuard。 RouteInfo.scopes;register(..., scopes=[...], endpoint_id=...);处理器可获得 request.context["policy"](带 guard(path, op) 的 EndpointContext)。测试:test_policy_pipeline.py::TestScopes::test_custom_route_scopes。
R1-F5 M capabilities_check 出现在 scope 表里,却没有关于"不得成为探测口(不得触碰文件系统)"的契约。 契约:只做策略求值,不做 stat;响应永远不说明路径是否存在。测试:test_policy_pipeline.py::TestDiscovery::test_check_does_not_reveal_existence、test_check_endpoint_matches_enforcement。
R1-F6 M 策略重载/可变性未规定。 策略在启动后不可变;revision 是内容哈希;运维通过重启生效(第 4 节"配置入口")。运行时 with_endpoint 创建新策略对象。
R1-F7 M 无法表达按身份的路径 scope(令牌 A 可读 /out,令牌 B 可读 /in);规则是全局的。 推迟(v1 的文档化非目标)。 理由:身份 x 路径矩阵会使 schema 翻倍;受支持的做法是使用不同沙箱,或使用自定义 scope 加不同端点。记为未决问题 OQ-2。
R1-F8 L 未设置限制的默认值是"今天的常量",但没有列举。 增加旧常量表(3.6):10 MiB JSON 请求体、100 MiB 旧版 /download、10 MiB /upload(ABX_MAX_UPLOAD_SIZE)等。
R1-F9 L files_watch 需要类似 process 的长连接,没有最长时长/并发限制。 limits.max_duration_seconds(默认 300)+ max_concurrency。

1b. 视角:用户体验 / API 人体工学("新用户会在哪里踩坑?")

# 级别 发现 解决
R1-U1 M 策略中的笔误("alow"、"limit")会静默变成空操作,看起来像安全漏洞。 封闭词汇表;未知键触发 PolicyConfigError,附带拼写建议和 JSON 路径(endpoints.files_list.paths.alow)。测试:test_policy_config.py(未知键/拼写建议用例)。
R1-U2 M endpoints 中未知的端点 ID(如 file_download)被静默忽略。 没有匹配任何已注册路由的精确选择器是错误(匹配不到任何路由的通配符在 check-config 中只是警告)。
R1-U3 M 调用方应捕获哪个错误?草案只是一个扁平列表。 客户端异常树定稿(第 7 节):全部派生自 ServerAPIError;它带有 .status_code、.error_type、.error_code、.endpoint,PolicyDeniedError 增加 .scope;旧的 except PermissionDeniedError 仍能捕获文件策略拒绝。
R1-U4 M 缓存过期时(策略已变而客户端本地返回 403),"快速失败"的预检查可能令人意外。 预检查只在显式 discover() 之后发生;服务端始终权威;实现情况: client.forget_discovery() 丢弃缓存,discover() 刷新(没有 prechecks=False 开关:不调用 discover() 就是选择退出);调用方最终看到的始终是服务端自己的拒绝。
R1-U5 L 需要调试辅助:"为什么我的调用被拒绝?" ServerPolicy.explain(...)(服务端),client.check(...) / caps.check_path(...) -> POST /capabilities/check;agent-sandbox-server check-config --explain files_download_stream --path /etc/passwd。
R1-U6 L 大小字符串(5MB)在 MB 与 MiB 之间有歧义。 十进制与二进制明确区分:MB=10^6,MiB=2^20;纯整数为字节;已文档化并测试。
R1-U7 L Sandbox.server 与装饰器代理同名。 已核实无冲突(Sandbox 今天没有 server 属性);保留 Sandbox.server(9000 端口、惰性、缓存)+ Sandbox.server_at(...)。
R1-U8 L 同步用户需要每个方法的 _sync 孪生。 对齐文档中强制要求;完整性测试遍历所有异步方法。
R1-U9 L ServerPolicy.only("download"):旧路由 ID upload/download 与 stream ID 容易混淆。 only() 接受别名组令牌 "transfer",含义为 {upload, download, files_upload_stream, files_download_stream}。

第 1 轮结论: 设计已更新(3.4a、3.5 角色表、3.6、7、8 节);除 R1-F7(推迟)外没有拒绝任何发现。

2. 第 2 轮:安全绕过与向后兼容

2a. 视角:安全绕过("假设策略作者很称职,而我是敌人")

针对未修改服务端的探测所得证据标记为 [probe]。

# 级别 发现 解决
R2-S1 H [probe] files_archive(zip)会跟随文件符号链接:沙箱目录中指向 /etc/passwd 的符号链接会连同内容一起被打包。基于现有代码构建的任何路径白名单都可以通过归档端点绕过。属于既有漏洞。 归档拒绝(跳过)目标位于端点边界之外或被拒绝的符号链接;从不跟随目录符号链接。这是所有模式下都应用的缺陷修复(在 CHANGELOG 中记为安全修复)。测试:test_policy_paths.py::TestArchiveSymlinkLeakFix。
R2-S2 M [probe] files_search/files_list 会显示指向外部的符号链接条目的名称,名称泄露目录拓扑。 策略模式下未通过守卫的条目被省略(且不会进入)。旧模式不变。测试:test_policy_paths.py::TestDirectoryExpandingEndpoints::test_list_omits_denied_and_escaping_entries。
R2-S3 H [probe] 非数字的 Content-Length 会使 _read_json_body 抛出未处理的 ValueError(连接被丢弃,无响应);负值被当作 JSON 错误解析。恶意客户端可借此做请求走私实验。 严格解析:^\d+$,否则 400;负数 -> 400;重复且不同的 Content-Length -> 400;带请求体端点上出现 Transfer-Encoding -> 400。所有模式生效(纯缺陷修复;此前行为是中止连接)。测试:test_http_hardening.py::TestContentLength。
R2-S4 M [probe] 重复查询参数(?path=/ok&path=/etc/passwd):服务端取第一个,而中间层(网关/WAF)可能取最后一个,上游做出的白名单判断就会不一致。 策略模式下拒绝重复的携带路径的参数(400 duplicate_parameter)。旧模式保持"第一个生效"。测试:test_http_hardening.py::TestPolicyModeFraming(仅策略模式拒绝)。
R2-S5 M [probe] 存在性探测的顺序:边界内不存在的路径 -> 404 "File not found",边界外 -> 400/403,调用方因此可以探测白名单之外的文件系统。 策略模式下边界/deny 检查总是在任何 stat 之前执行,边界之外无论是否存在都返回同样的 403 path_not_allowed。测试:test_policy_paths.py::TestNoExistenceOracle。
R2-S6 L [probe] 相对路径相对服务进程 CWD 而不是 base_dir 解析;配合 paths.allow 是个陷阱。 旧模式保留(兼容),策略模式下相对路径相对第一个 allow 根目录解析(已文档化)。测试:test_policy_paths.py::...::test_relative_paths_resolve_against_allow_root。
R2-S7 H 把策略挂在 URL 字符串 而不是匹配到的路由上,会产生编码路由和尾部斜杠的差异。草案已按 RouteInfo 为键;评审者核验了路由器:%73tat -> 404,//files -> 404,尾部斜杠在匹配前归一化。 确认:策略键仅为 route.name;对编码/双斜杠/尾部斜杠路由做回归测试;方法覆盖头/参数被明确忽略。测试:test_http_hardening.py::TestMethodOverride、TestRouteNormalisation。
R2-S8 H TOCTOU / 符号链接替换:先 realpath 检查再 open。拥有 process:run 的攻击者可以竞争替换。 最后一个分量使用 O_NOFOLLOW;打开后把 fstat 身份与 /proc/self/fd/N 的解析结果比较并重新做边界检查;symlinks: never 额外拒绝任何符号链接分量(lstat 逐级遍历)。残余风险已文档化:检查与打开之间中间分量的替换在纯标准库 Python 中无法完全消除;缓解办法是不要把 process/terminal 与路径范围同时暴露(lint L1)。
R2-S9 M 允许目录树内指向外部 inode 的硬链接对路径检查不可见。 残余风险已文档化(只能通过进程端点创建;由 lint L1 覆盖)。files_* 端点不能创建硬链接。
R2-S10 M Deny glob:大小写变体、./、尾部斜杠,以及匹配词法路径还是解析后路径。 Deny 同时作用于解析后路径和归一化的词法路径;POSIX 下区分大小写(已文档化;macOS 开发机需注意);基本名模式(*.pem)匹配任何路径分量;目录 deny 是带分隔符的前缀匹配(/a/b 不会拒绝 /a/bc)。每个变体都有测试。
R2-S11 H 令牌混淆:草案允许同时使用 Authorization: Bearer 与 X-Access-Token;两个不同的 X-Access-Token 头的行为未定义。 身份模式:出现多个不同的凭据头值 -> 400 ambiguous_credentials;仅当 X-Access-Token 缺失或相同时 Bearer 才生效(草案写的是"使用 Authorization",已修改:网关会注入自己的 X-Access-Token;顺序为先 Authorization,已文档化)。旧模式不动(第一个 X-Access-Token)。
R2-S12 M 多个令牌时令牌查找的时序侧信道。 对所有令牌先取摘要再 compare_digest,不提前退出(保持);这是实现层面的性质,没有时序测试覆盖。
R2-S13 H 秘密暴露:GET /env(system 组)返回整个环境,包括 ABX_SERVER_TOKEN 和其他身份的 token_env;子进程会继承它们。 策略身份模式下 env_get 对服务端自己的秘密(ABX_SERVER_* 与所有已配置的 token_env 名称)脱敏;子进程环境只去掉相同的名称(任意用户变量从不触碰)。env_set 拒绝这些名称和 ABX_ 前缀。旧模式不变(兼容),但 lint 建议使用策略。测试:test_policy_pipeline.py::TestSecrets、test_policy_exec.py::TestSecretEnvironment。
R2-S14 M PTY WebSocket(9001 端口)有自己的认证(URL 中的 ?token=:令牌会进入访问日志和网关日志),且会绕过端点策略。 PTY 服务端使用同一份策略:身份来自 Authorization: Bearer / X-Access-Token 头(新增)或旧的查询令牌;需要 scope terminal:use;pty_connect 规则 enabled:false 会以 4003 关闭套接字。查询令牌仍被接受(兼容),但会在发现的 warnings 中标注。测试:test_policy_pty.py::TestPtyWebSocketAuth。
R2-S15 M 策略文件完整性:可写的策略文件允许沙箱内用户放宽策略。 agent-sandbox-server check-config(lint L5)会在策略文件或其目录对当前用户可写时发出警告(请以服务端用户身份运行);文档建议将其放在 /etc/agent-sandbox/,属主 root、权限 0644。
R2-S16 M allow 根目录很宽(/)时暴露 /proc、/sys、/dev;/proc/self/environ 会泄露令牌。 allow 根为 / 时 lint L2;hardened 预设拒绝 /proc、/sys、/dev、**/.ssh、**/.env*、**/*.pem、**/id_*。
R2-S17 L 超长路径 / glob 选择器的 CPU 消耗(ReDoS)。 路径长度上限 4096;选择器对短 ID 使用 fnmatch;deny 模式使用 fnmatchcase,不会由用户输入生成正则。
R2-S18 L 通过路径字符串进行审计日志注入。 以 JSON 编码的行(构造上即已转义);路径截断为 512 个字符。
R2-S19 L 暴力破解令牌:没有失败认证的节流。 推迟:部署在网关之后;标准库服务端在网关后面做按 IP 限流并不可靠。已文档化;采用恒定时间比较,并在文档中给出高熵令牌建议。
R2-S20 L Unicode 形近字符 / 规范化。 POSIX 路径是未经规范化的字节,形近字符指向不同的文件,不存在混淆绕过。已文档化(macOS HFS+ 规范化注意事项)。

2b. 视角:向后兼容("现有用户哪里会坏?")

# 级别 发现 解决
R2-C1 H 现有测试(test_env_subprocess_boundary.py)直接用 ThreadingHTTPServer(..., SandboxRequestHandler) 接线,只设置 auth_token 和 registry。新属性(policy、audit)不能是必需的。 处理器通过 getattr(self.server, ..., None) 读取每个新属性;None 策略 = 旧流水线。测试:现有套件 + test_policy_compat.py::test_bare_handler_wiring_still_works。
R2-C2 M RouteInfo 是被第三方按位置构造的公共数据类;插入字段会破坏它们。 新字段追加在现有字段之后并带默认值;RouteTable.register 的新增参数仅限关键字。
R2-C3 M GET /capabilities 的消费者可能断言精确的键集合。 现有键不变(保留 groups),新增内容是新键;有"精确超集"测试固定。
R2-C4 M 错误响应体新增 code;对响应体字典做相等比较的客户端会坏。 现有键/值不变,仅在存在稳定 code 时才添加 code;已文档化为增量。
R2-C5 M ABX_SERVER_DISABLED_GROUPS 与策略 groups 的优先级。 已取代:该变量已删除。只有一层(端点规则)说了算;ABX_SERVER_ENABLED_GROUPS 是唯一的组环境变量,并且只能打开组。
R2-C6 M Image() 默认基础镜像:草案把默认值绑定到 ubuntu;改成官方基础镜像会悄悄改变每一次构建。 默认不变;新增 Image.official_base();基础镜像不是官方镜像时 DockerBuilder 只发警告(不报错)(基础镜像文档第 3 节)。
R2-C7 M 服务端必须保持仅标准库且支持 Python 3.10+:不能用 tomllib(3.11),不能硬依赖 PyYAML。 服务端只有 JSON 解析器;仅在可导入 PyYAML 时才接受 YAML;没有新依赖。由服务端 only 导入测试验证(server-sdk/tests/test_server_only_import.py)。
R2-C8 M 版本同步发布:新客户端对旧服务端(反之亦然)。 客户端把缺少 endpoints 键视为 legacy,除非调用方设置,否则从不发送新参数(cwd、env 等);旧服务端上的新端点表现为 EndpointNotAvailableError(404 映射),而不是原始 HTTP 错误。测试:带旧版桩服务端的客户端测试。
R2-C9 L SandboxTemplate 严格性:新增 server: 不能拒绝现有模板;server: 的内容要延迟校验,以免客户端导入服务端包。 可选字段,dict schema,由客户端中的小型结构校验器(共享词汇常量)校验,而不是导入服务端包。
R2-C10 L 现有的 enable_builtin/_enabled_builtins 旧开关。 已取代:已删除。enable_group / disable_group 修改端点规则的 code 层。

第 2 轮结论: 两项 H 级安全问题是既有缺陷(R2-S1、R2-S3),因为它们严格属于缺陷修复,所以在所有模式(而不只是策略模式)下修复。这是"未配置行为"唯一发生变化的地方, 并且两处原有行为都是泄漏/崩溃。其他所有变更都以配置了策略为前提。

3. 对齐文档与基础镜像文档的评审

两份文档经过了同样的两轮评审。主要发现:

# 文档 发现 解决
R-A1 对齐 提议新增服务端 process_output 与 process_stdin,但没有说明它们与 capture_output 限制如何交互。 增加规范:有界环形缓冲(limits.max_output_bytes,默认每进程 1 MiB),基于游标读取。
R-A2 对齐 客户端 ServerClient.files.download 与基于 envd 的 Sandbox.files.read 在用户看来可以互换。 在矩阵(第 5 节)中用决策表说明:必须在任意沙箱上工作的代码默认用 envd;模板作者提供的、受策略限定的访问使用 ServerClient。
R-A3 对齐 流式方法需要取消语义。 异步生成器在 aclose() 时关闭响应;同步孪生返回带 close() 的迭代器。
R-B1 基础镜像 "值得做"的决定主要基于对网关的推断;"网关转发 Authorization"这一说法未经证实。 标为未决问题 OQ-3;服务端同时接受两种头,因此网关怎样处理都可行。
R-B2 基础镜像 Image() 默认值变更(现有构建的安全性)。 见 R2-C6。
R-B3 基础镜像 控制台脚本 agent-sandbox-server 不能遮蔽 abx。 已检查名称:无冲突。
R-B4 基础镜像 评审者攻击:check-config/healthcheck 子命令不能依赖网络或重量级导入(用作 Docker HEALTHCHECK)。 healthcheck 仅使用 urllib.request 访问 127.0.0.1,超时 2 秒,退出码 0/1。

4. 测试可追溯性

服务端(server-sdk/tests/test_server/):test_policy_config.py(54:schema、预设、大小、未知键)、test_path_guard.py(34:遍历、符号链接、编码、NUL、长路径、deny glob)、 test_policy_pipeline.py(38:认证、scope、命令 scope、速率/并发、审计、发现、秘密)、test_policy_paths.py(30:transfer-only 场景、展开目录的端点、只读、symlinks: never、归档泄漏修复)、 test_policy_exec.py(16:命令/语言/URL 规则、秘密环境)、test_http_hardening.py(18:Content-Length、分帧、方法覆盖、路由归一化、请求体上限)、 test_new_endpoints.py(15:新增/扩展端点)、test_policy_pty.py(7)、test_policy_compat.py(5)、test_cli_main.py(8)。

客户端(client-sdk/tests/):test_api/server_e2e/test_server_client_e2e.py(33,对真实进程内 SandboxServer)、test_server_client_policy.py(18)、 test_server_client_models.py(23:错误映射、模型、能力发现、旧版桩服务端)、test_server_client_matrix.py(4:路由完整性守卫)、test_sandbox_server_helpers.py(12)、 test_api/test_base_image.py(52)、test_models/test_template_server_block.py(18)。

没有专门测试的发现:R2-S17(路径长度 / glob CPU)只由 test_path_guard.py 中的长路径用例覆盖;R-B4(healthcheck)由 test_cli_main.py 覆盖。

5. 遗留的未决问题