NonoSandboxProvider 设计解读:用 Landlock 与 Seatbelt 为 AI Agent 构建内核级能力沙箱
【免费下载链接】agent-governance-toolkitAI Agent Governance Toolkit — Policy enforcement, zero-trust identity, execution sandboxing, and reliability engineering for autonomous AI agents. Covers 10/10 OWASP Agentic Top 10.项目地址: https://gitcode.com/GitHub_Trending/ag/agent-governance-toolkit
本篇技术指南以 NONO-SANDBOX-PROVIDER 设计提案 为核心骨架,结合 Agent Governance Toolkit 仓库中
agent-sandbox的源码、配置与测试,完整剖析NonoSandboxProvider的设计理念与实现细节。读完你将掌握:nono 能力沙箱的一次性执行模型如何满足会话式SandboxProvider契约、NonoConfig.from_sandbox_config如何映射超时/挂载/环境/网络控制、fail-closed 出口网络策略的强制逻辑,以及运行时治理与静态代码扫描如何在sandboxed_exec之前完成拦截。
NonoSandboxProvider是 Agent Governance Toolkit 中agent-sandbox模块(agent-governance-python/agent-sandbox)提供的五种可互换沙箱后端之一。它不依赖 daemon、hypervisor SDK、云账号或外部二进制,而是直接驱动nono-py——一个基于 OS 原生内核原语(Linux 的Landlock、macOS 的Seatbelt)实现的能力沙箱库。本文将从设计提案出发,逐层拆解其配置契约、能力集构建、网络代理、治理拦截与执行生命周期,并用仓库内的单元测试与集成测试验证每个关键行为。
一、设计概览:能力沙箱与一次性执行
1.1 内核原语与 nono-py 绑定
提案开篇即定义了NonoSandboxProvider的技术路线:通过nono-py使用 Landlock(Linux)和 Seatbelt(macOS)实施沙箱,每次执行都运行在一个全新的子进程中,并携带一份显式声明的能力集(capability set)。
从源码看,nono-py暴露的底层原语被明确记录在 config.py 的模块 docstring 中:
nono_py.CapabilitySet:文件系统授权(读写模式)+ 网络模式;nono_py.start_proxy:过滤型网络代理;nono_py.sandboxed_exec:一次性原语——fork→ 在子进程内套用沙箱 →exec命令 → 在未沙箱化的父进程中捕获 stdio。
值得注意的是,该提供程序对nono_py采用懒加载策略:provider.py 在模块加载时用try/except ImportError将_nono_module置为None,因此导入NonoSandboxProvider本身不需要nono-py已安装;只有构造实例并查询is_available()时才会真正探测依赖与宿主支持情况。这一点在__init__.py的 docstring 与测试test_missing_nono_py_does_not_break_import中都有印证。
1.2 会话模型:会话即“持久化 Bundle”
nono 的sandboxed_exec是**一次性(one-shot)**的:fork、套沙箱、运行命令、子进程退出——没有 Docker 容器或 Hyperlight 微 VM 那种跨调用存活的常驻 guest。
为了满足SandboxProvider抽象基类(sandbox_provider.py)的会话式契约,实现将会话视为一个可持久化的 bundle(见 provider.py):
- 解析后的
NonoConfig; - 策略评估器(policy evaluator,即可选的
AgentControl原生治理会话); - 每会话独立的工作目录 workspace;
- 可选的常驻网络代理 proxy。
每次execute_code()/run()都基于该 bundle fork 一个全新的一次性沙箱。因此跨执行不保留 guest 状态,唯一例外是会话的读写output/目录——它在宿主机上于多次调用之间保留;代理在会话间共享,并在destroy_session时关闭。
_Session类(provider.py)正是这一 bundle 的数据结构,用__slots__声明了config、evaluator、workspace、interpreter、proxy五个字段。
二、配置契约:NonoConfig 与 SandboxConfig 的映射
2.1 NonoConfig 字段一览
NonoConfig是一个类型化、零依赖的数据类(config.py),建模了 provider 关注的 nono 能力面。字段及其语义如下表:
| 字段 | 类型 | 默认值 | 语义 |
|---|---|---|---|
readonly_paths | list[str] | [] | 授予沙箱只读的宿主目录,映射caps.allow_path(p, READ) |
readwrite_paths | list[str] | [] | 授予沙箱读写的宿主目录,映射caps.allow_path(p, READ_WRITE) |
allow_outbound | bool | False | 是否允许出站网络;True时启动 nono 过滤代理并绑定沙箱,False时调用block_network() |
allowed_hosts | list[str] | [] | 由代理强制执行的出站主机白名单,仅在allow_outbound=True时有意义 |
allow_unrestricted_egress | bool | False | 显式选择无限制出站(allow_outbound=True且allowed_hosts为空),映射代理allow_all_hosts=True |
timeout_seconds | float | 60.0 | 传给sandboxed_exec的timeout_secs墙钟执行预算 |
env_vars | dict[str, str] | {} | 暴露给沙箱进程的环境变量,使用前经sanitize_env_vars清洗 |
include_system_paths | bool | True | 是否授予标准系统目录读权限(见default_system_paths),使解释器可加载 |
__post_init__强制两条校验(config.py):timeout_seconds必须为正数;_check_egress()强制执行下文所述的 fail-closed 出口契约。对应测试test_nonpositive_timeout_rejected、test_outbound_without_hosts_rejected验证了这两个拒绝分支。
2.2 from_sandbox_config 的映射规则
提案指出:“NonoConfig.from_sandbox_config映射 timeout、mounts、environment 和 network controls”。对照实现(config.py),映射逻辑为:
- 挂载(mounts):
SandboxConfig.input_dir→readonly_paths(只读),SandboxConfig.output_dir→readwrite_paths(读写);两者在挂载前都会经validate_mount_path校验,拒绝指向受保护系统目录(如/usr)。 - 网络(network):
network_enabled=True且network_allowlist非空 →allow_outbound=True+allowed_hosts;network_enabled=True但 allowlist 为空且network_default='allow'→allow_unrestricted_egress=True;其余情况保持allow_outbound=False。 - 超时(timeout):
timeout_seconds直接透传,并取max(0.001, ...)兜底。 - 环境(environment):
env_vars原样带入,后续由sanitized_env()清洗。
测试test_from_sandbox_config、test_mounts_and_egress、test_network_default_allow_enables_unrestricted、test_network_allowlist_takes_precedence_over_default等(test_nono_sandbox.py)逐一覆盖了这些映射与优先级规则。
2.3 两个“明确拒绝”:tool_allowlist 与资源限制
提案强调了两条关键的 fail-closed 边界:
- Nono 没有工具注册通道,非空
tool_allowlist直接拒绝。在 provider.py 的create_session中,若base_cfg.tool_allowlist非空,立即抛出ValueError("nono does not support tool allowlisting ..."),而不是静默忽略——这与 MXC provider 的行为一致,但与支持 in-sandbox 工具注册的 Hyperlight 形成对比(见 README.md 的“Host configuration and native governance”表格)。空列表则放行(test_empty_tool_allowlist_is_allowed)。 - CPU 与内存控制由操作系统负责。
SandboxConfig中的memory_mb、cpu_limit在 nono 中不可表达,from_sandbox_config直接丢弃,交由 OS 治理(docstring 明确注明)。SandboxConfig的默认值本身为memory_mb=512、cpu_limit=1.0(sandbox_provider.py),但在 nono 路径下不产生任何实际约束。
三、出口网络控制:默认阻断、白名单代理、显式全开
网络是 Agent 沙箱最敏感的边界之一。提案的核心承诺是:网络访问默认被阻断;过滤出口通过带allowed_hosts的代理实现;无限制出口必须显式 default-allow。
3.1 三态网络模型
NonoConfig._check_egress()(config.py)将出口策略固化为三种状态:
| 状态 | 条件 | 代理配置 | 能力绑定 |
|---|---|---|---|
| 完全阻断 | allow_outbound=False | 不启动代理 | caps.block_network() |
| 过滤出口 | allow_outbound=True且allowed_hosts非空 | ProxyConfig(allowed_hosts=[...]) | caps.proxy_only(proxy) |
| 无限制出口 | allow_outbound=True+allow_unrestricted_egress=True(显式) | ProxyConfig(allow_all_hosts=True) | caps.proxy_only(proxy) |
其中“过滤出口”是 fail-closed 的核心:allow_outbound=True时,若allowed_hosts为空且未显式设置allow_unrestricted_egress,配置构造直接抛ValueError——绝不允许隐式地“可达任意主机”。错误消息甚至给出了通过策略defaults.network_default: allow显式开启的途径。
3.2 代理的生命周期
代理的启动与关闭逻辑在 provider 中成对出现:
- 启动:
_start_proxy()(provider.py)根据 egress 策略构造ProxyConfig;create_session中若allow_outbound为真则启动代理,失败时清理 workspace 并抛RuntimeError。 - 绑定:
_build_caps()中,有代理时调用caps.proxy_only(session.proxy),否则caps.block_network()(provider.py)。 - 环境注入:
_build_env()将代理的sandbox_env(含HTTP_PROXY等)合并进子进程环境,使流量经代理路由(provider.py)。 - 关闭:
destroy_session()在移除 workspace之前先关闭代理,避免后台线程存活超出会话(provider.py)。
测试test_network_blocked_by_default、test_proxy_started_for_egress、test_unrestricted_egress_uses_allow_all_hosts、test_proxy_shut_down_on_destroy完整验证了这一生命周期,包括代理环境下HTTP_PROXY被写入沙箱环境。
四、原生治理:deny 必须先于 sandboxed_exec
提案“Native governance”一节指出:可选的AgentControl被包装在HostSession中,运行时(runtime)与静态扫描(static-scan)的拒绝都发生在sandboxed_exec之前。这意味着策略拒绝根本不会触达 nono 沙箱。
4.1 HostSession 包装
_build_runtime_session()(provider.py)从agent_control_specification导入HostSession,将调用方传入的runtime(ACS 原生治理运行时)与agent_id、session_id一并包装。create_session中仅在runtime is not None时才构建 evaluator。
4.2 执行前的两道闸门
execute_code()(provider.py)在 fork 任何沙箱之前依次执行:
- 治理门(governance gate):构造
{"agent_id", "action": "execute", "code", ...}上下文,调用evaluator.pre_tool_call(tool_name="sandbox_execute", ...)。若决策为 transform 判决(applies_transform),直接抛PermissionError——因为沙箱无法改写即将执行的代码,视为拒绝而非放行;若permits=False,同样抛PermissionError(携带decision.message或reason)。测试test_policy_deny_blocks_execution用一个返回 deny 判决的假 runtime 验证了该分支。 - 静态扫描门(static-scan gate):调用
enforce_no_subprocess_execution(code)(来自 code_scanner.py)。这是一个 Python AST 扫描器,其_DANGEROUS_CALLS表覆盖subprocess(Popen/run/call等)、os(system/exec*/spawn*/popen等)、pty.spawn、shutil.which;命中即抛SandboxCodeViolation(PermissionError子类)。测试test_code_scanner_blocks_subprocess与test_run_once_destroys_session_on_guard_violation验证了拦截与清理。
4.3 非 Python 解释器的 fail-closed
由于扫描器只理解 Python,execute_code会先校验解释器是否为 Python(_is_python_interpreter匹配py|python|pypy系列,provider.py):若配置了node等非 Python 解释器,execute_code直接抛ValueError并引导调用方改用run()显式命令——宁可拒绝,也不执行未扫描的代码(test_non_python_interpreter_fails_closed;绝对路径如/usr/bin/python3则放行,test_absolute_python_interpreter_path_allowed)。
五、能力集构建与执行生命周期
5.1 CapabilitySet 的组装
_build_caps()(provider.py)按以下顺序组装CapabilitySet:
include_system_paths=True时,对default_system_paths()的每个路径授予READ,缺失路径以FileNotFoundError静默跳过;- 额外只读目录(如解析出的解释器所在目录)授予
READ; cfg.readonly_paths→READ;cfg.readwrite_paths→READ_WRITE;- 网络按第三节的三态模型绑定代理或阻断。
其中default_system_paths()(config.py)返回 Unix 根目录集(/usr、/bin、/sbin、/lib、/lib64、/opt)+ macOS 专属(/private、/Library/Frameworks、/dev、/System)+ 当前解释器的 prefix(sys.prefix、sys.base_prefix),且只返回宿主上真实存在的路径,同一列表在 Linux 与 macOS 上均安全。
5.2 命令解析与环境隔离
_resolve_command()(provider.py)在未沙箱化的父进程中将裸程序名解析为绝对路径(shutil.which在宿主PATH上查找;macOS 上常见的裸python回退到sys.executable),因为沙箱内inherit_env=False、没有PATH可解析;解析出的程序目录随后被授予只读访问。
_build_env()(provider.py)组装子进程环境:config.sanitized_env()(经sanitize_env_vars剥离LD_PRELOAD、PYTHONSTARTUP、NODE_OPTIONS等启动钩子,见 _hardening.py)加上可选的NONO_CONTEXTJSON 上下文(不可序列化时告警丢弃),有代理时再并入代理环境。
5.3 _spawn 与结果处理
_spawn()(provider.py)是执行中枢:
- 以会话
output/目录为cwd(相对写落盘到持久 workspace); - 以
timeout_secs=timeout、inherit_env=False调用nono.sandboxed_exec(caps, command, ...); - 超时判定基于 nono 约定:退出码 124表示因超时被杀死(
_TIMEOUT_EXIT_CODE,provider.py),此时killed=True且kill_reason注明超时时长; - stdout/stderr 按流截断至 1 MiB(
_OUTPUT_MAX_BYTES),超出部分追加[...output truncated at byte limit]标记; - 结果封装为
SandboxResult(success、exit_code、stdout、stderr、duration_seconds、killed、kill_reason)。
对应测试test_timeout_marks_killed、test_timeout_passed_through、test_output_truncated、test_sandboxed_exec_failure_surfaces、test_cwd_is_output_dir覆盖了这些行为。
5.4 agent_id 校验
_validate_agent_id()(provider.py)要求agent_id匹配^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$,因为它会被插值进日志与 workspace 目录名——先拒绝敌意字符,杜绝路径穿越与控制字符注入(test_invalid_agent_id_rejected)。
六、安装、可用性与实战用法
6.1 安装与平台前提
agt-sandbox将 nono 作为可选依赖发布(pyproject.toml),nono-py>=0.10.1,<0.11,且注明仅 Linux/macOS,无 Windows wheel:
pip install "agt-sandbox[nono]" # Linux/macOS only可用性探测由_compute_unavailable_reason()完成(provider.py):nono-py未安装 → 提示安装命令;已安装但is_supported()为假 → 提示需要 Linux kernel 5.13+(Landlock)或 macOS。is_available()返回探测结果;在不可用宿主上调用create_session会抛RuntimeError(test_unavailable_when_extension_missing、test_unavailable_when_unsupported_host)。
生产就绪性提示(来自 README.md):
nono-py上游 PyPI 分级为Alpha。内核级强制(Landlock/Seatbelt)结构上强于进程内守卫,但项目仍在成熟中——建议先用于纵深防御、开发与 CI,投入生产硬边界前自行完成安全评审;Windows 或无 Landlock 的内核请改用DockerSandboxProvider等其他后端。
6.2 一次性用法:run_once
每次 nono 沙箱都是全新 fork、运行后即退出的子进程,因此最贴合的一次性用法是run_once(provider.py):内部创建会话 → 执行 → 销毁,无需跟踪 session_id,且失败路径同样保证清理(test_run_once_cleans_up_on_failure):
from agent_sandbox import NonoSandboxProvider, SandboxConfig provider = NonoSandboxProvider() if not provider.is_available(): raise SystemExit("nono not supported here (needs Linux+Landlock or macOS)") execution = provider.run_once( "agent-1", "print('hello from nono')", config=SandboxConfig(timeout_seconds=20, network_enabled=False), ) print(execution.result.stdout)6.3 会话式用法:create_session + execute_code
需要跨调用共享持久output/目录(以及常驻网络代理)时,使用完整生命周期。create_session内部会创建scripts/(只读授予沙箱,存放待执行脚本)与output/(读写授予沙箱,即持久输出区)两个子目录(provider.py):
from agent_control_specification import AgentControl from agent_sandbox import NonoSandboxProvider, SandboxConfig provider = NonoSandboxProvider() runtime = AgentControl.from_path(str("manifest.yaml")) config = SandboxConfig( timeout_seconds=90, input_dir="/data/agent-input", output_dir="/data/agent-output", network_enabled=True, network_allowlist=["api.openai.com", "*.github.com"], ) handle = provider.create_session("agent-1", runtime=runtime, config=config) execution = provider.execute_code( "agent-1", handle.session_id, "print('hello from nono session')", ) print(execution.result.stdout) provider.destroy_session("agent-1", handle.session_id)6.4 低层命令执行:run
run()(provider.py)直接以命令列表方式执行任意程序,不经代码扫描(适合非 Python 语言);有会话时复用该会话的配置/workspace/代理,否则构建临时一次性会话并在调用后清理。空命令返回失败结果(test_run_empty_command)。
6.5 集成测试入口
真实的端到端验证(真实 OS 沙箱)位于 test_nono_integration.py,默认跳过,需满足两个条件:nono-py已安装且is_supported()为真、环境变量AGT_NONO_INTEGRATION=1(macOS 上若检测到已处于沙箱内也会跳过,因为 Seatbelt 禁止嵌套沙箱):
pip install "agt-sandbox[nono]" export AGT_NONO_INTEGRATION=1 pytest agent-governance-python/agent-sandbox/tests/test_nono_integration.py -v而 test_nono_sandbox.py 通过 monkeypatch 假nono_py模块保持测试环境的封闭性(无内核沙箱、无网络),可在任意宿主(含不支持 nono 的 Windows)运行,覆盖配置校验、fail-closed 出口契约、生命周期、能力绑定、超时/截断、各类守卫等全部行为。
七、设计要点的验证与小结
回到提案本身,其每一项设计声明都能在实现与测试中找到对应证据:
| 提案声明 | 实现位置 | 验证测试 |
|---|---|---|
Landlock/Seatbelt,经nono-py | provider.py 模块 docstring、config.py docstring | test_available_when_supported |
| 每次执行全新子进程 + 显式能力集 | _spawn→nono.sandboxed_exec(caps, ...) | test_session_paths_granted |
from_sandbox_config映射 timeout/mounts/env/network | config.py | test_from_sandbox_config、test_mounts_and_egress |
| 网络默认阻断 | NonoConfig.allow_outbound=False默认值 +_build_caps的block_network() | test_network_blocked_by_default |
过滤出口走allowed_hosts代理 | _start_proxy+caps.proxy_only | test_proxy_started_for_egress |
| 无限制出口需显式 default allow | _check_egressfail-closed | test_outbound_unrestricted_requires_explicit_opt_in |
非空tool_allowlist拒绝 | create_session抛ValueError | test_tool_allowlist_fails_closed |
| CPU/内存归 OS 管理 | from_sandbox_config丢弃memory_mb/cpu_limit | test_from_sandbox_config(字段未透传) |
AgentControl包装进HostSession,deny 先于sandboxed_exec | _build_runtime_session+execute_code治理门 | test_policy_deny_blocks_execution |
静态扫描先于sandboxed_exec | enforce_no_subprocess_execution(code) | test_code_scanner_blocks_subprocess |
NonoSandboxProvider的价值在于:以纯 Python 安装即可获得内核级强制的隔离能力,无 daemon、无 hypervisor、无云账号,同时把“显式优于隐式”的 fail-closed 原则贯彻到配置、网络、工具通道与执行前拦截的每一层。对 Agent Governance Toolkit 的落地场景而言,它是本地开发与 CI 环境中兼具部署轻量与结构性强度的默认选择之一;而对需要更强硬边界的生产环境,则可通过同一SandboxProvider抽象无缝切换到 Docker、Hyperlight、MXC 或 Azure Container Apps 后端。相关源码与测试位于 agent-governance-python/agent-sandbox,可继续深入阅读。
【免费下载链接】agent-governance-toolkitAI Agent Governance Toolkit — Policy enforcement, zero-trust identity, execution sandboxing, and reliability engineering for autonomous AI agents. Covers 10/10 OWASP Agentic Top 10.项目地址: https://gitcode.com/GitHub_Trending/ag/agent-governance-toolkit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考