diff --git a/docs/guide/tutorials/examples.md b/docs/guide/tutorials/examples.md index 4e8b711ab..46dc70eb1 100644 --- a/docs/guide/tutorials/examples.md +++ b/docs/guide/tutorials/examples.md @@ -10,6 +10,7 @@ Hands-on examples demonstrating various Cube Sandbox use cases. Each example is | [SWE-bench with mini-swe-agent](https://github.com/tencentcloud/CubeSandbox/tree/master/examples/mini-rl-training) | Automate SWE-bench coding tasks in isolated sandboxes using cube-sandbox + mini-swe-agent, with multi-model support and RL training vision. | | [OpenAI Agents SDK Integration](https://github.com/tencentcloud/CubeSandbox/tree/master/examples/openai-agents-example) | Wire OpenAI Agents SDK's `E2BSandboxClient` to Cube Sandbox. Ships a minimal Shell Agent with Pause/Resume and a full SWE-bench Django debugging agent with streaming + tracing. | | [OpenAI Agents + Code Interpreter](https://github.com/tencentcloud/CubeSandbox/tree/master/examples/openai-agents-code-interpreter) | Data-analysis Agent running pandas / matplotlib inside a Cube Sandbox. Provides two variants: generic E2B write+exec and Jupyter-kernel Code Interpreter with cross-turn state and auto image capture. | +| [Agent Tool Allowlist Sandbox](https://github.com/tencentcloud/CubeSandbox/tree/master/examples/agent-tool-allowlist-sandbox) | Host-side argv allowlist gate for agent tool commands: allowlisted tools run in a MicroVM with stdout/artifact readback; non-allowlisted commands fail fast without creating a sandbox. | | [cube-bench](https://github.com/tencentcloud/CubeSandbox/tree/master/examples/cube-bench) | CLI benchmark tool written in Go that measures sandbox creation/deletion latency at configurable concurrency levels. Features a real-time TUI dashboard (Bubbletea/Lipgloss), percentile report (P50/P95/P99), and JSON export. | ::: tip diff --git a/docs/zh/guide/tutorials/examples.md b/docs/zh/guide/tutorials/examples.md index 50d249429..ec99b3423 100644 --- a/docs/zh/guide/tutorials/examples.md +++ b/docs/zh/guide/tutorials/examples.md @@ -10,6 +10,7 @@ | [SWE-bench + mini-swe-agent](https://github.com/tencentcloud/CubeSandbox/tree/master/examples/mini-rl-training) | 使用 cube-sandbox + mini-swe-agent 在隔离沙箱中自动化 SWE-bench 编码任务,支持多模型切换和 RL 训练愿景。 | | [OpenAI Agents SDK 集成](https://github.com/tencentcloud/CubeSandbox/tree/master/examples/openai-agents-example) | 将 OpenAI Agents SDK 的 `E2BSandboxClient` 对接 Cube Sandbox。包含最小 Shell Agent(含 Pause/Resume 演示)以及完整的 SWE-bench Django 调试 Agent(流式输出 + 全链路追踪)。 | | [OpenAI Agents + Code Interpreter](https://github.com/tencentcloud/CubeSandbox/tree/master/examples/openai-agents-code-interpreter) | 在 Cube Sandbox 中运行使用 pandas / matplotlib 的数据分析 Agent,提供通用 E2B(write+exec)与 Jupyter kernel(状态跨轮保留、图像自动捕获)两种执行形态。 | +| [Agent 工具白名单沙箱](https://github.com/tencentcloud/CubeSandbox/tree/master/examples/agent-tool-allowlist-sandbox) | 宿主机侧 Agent 工具 argv 白名单门控:白名单工具在 MicroVM 内执行并回传 stdout/小工件;非白名单命令快速失败且不创建沙箱。 | | [cube-bench](https://github.com/tencentcloud/CubeSandbox/tree/master/examples/cube-bench) | Go 编写的 CLI 压测工具,可在可配置并发数下测量沙箱创建/删除延迟。具备实时 TUI 看板(Bubbletea/Lipgloss)、分位数报告(P50/P95/P99)和 JSON 导出功能。 | | [Volume 插件(COS)](https://github.com/TencentCloud/CubeSandbox/blob/master/examples/volume/cos/README.zh.md) | binary / rpc 两种类型的腾讯云 COS Volume 插件示例,含 Python SDK 验证脚本。框架文档见 [Volume 插件开发指南](../volume-plugin.md)。 | diff --git a/examples/agent-tool-allowlist-sandbox/.env.example b/examples/agent-tool-allowlist-sandbox/.env.example new file mode 100644 index 000000000..6ccaa2fe0 --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/.env.example @@ -0,0 +1,21 @@ +# Required: Cube API server address (dev-env host forward defaults to :13000) +export E2B_API_URL="http://127.0.0.1:13000" + +# Required: any non-empty value satisfies the SDK check +export E2B_API_KEY="e2b_000000" + +# Required: template ID from cubemastercli tpl create-from-image +export CUBE_TEMPLATE_ID="" + +# --- Host-side allow path without wildcard DNS (run_allowlisted_sidecar.py) --- +# See docs/guide/connect-existing-cluster.md Option D + examples/e2b-dev-sidecar +# export CUBE_REMOTE_PROXY_BASE="https://127.0.0.1:11443" +# export CUBE_REMOTE_PROXY_VERIFY_SSL="false" +# export CUBE_REMOTE_SANDBOX_DOMAIN="cube.app" + +# Optional: only needed when using Cube's built-in mkcert certificate +# export SSL_CERT_FILE="/root/.local/share/mkcert/rootCA.pem" + +# Optional: verify_local.py knobs +# export ALLOWLIST_IMAGE_TAG="agent-tool-allowlist-sandbox:night-verified" +# export ALLOWLIST_USE_SIDECAR="1" # use run_allowlisted_sidecar.py when env is set diff --git a/examples/agent-tool-allowlist-sandbox/.gitignore b/examples/agent-tool-allowlist-sandbox/.gitignore new file mode 100644 index 000000000..e19ed702f --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.py[cod] +.env diff --git a/examples/agent-tool-allowlist-sandbox/Dockerfile b/examples/agent-tool-allowlist-sandbox/Dockerfile new file mode 100644 index 000000000..738e1aa93 --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/Dockerfile @@ -0,0 +1,49 @@ +# Thin wrapper on the official Cube Sandbox code image. +# Purpose: give this example a buildable template path for #645, without +# adding a new language/runtime stack. +# +# Markers baked into the image: +# - ENV TOOL_ALLOWLIST_SANDBOX=1 +# - /etc/cube-sandbox/tool-allowlist.txt (demo allowlist file) +# +# The allowlist file body must stay aligned with allowlist.DEFAULT_ALLOWED_BINARIES. +# Check with: python verify_local.py +# +# Build (from this directory): +# docker build -t agent-tool-allowlist-sandbox:latest . +# +# Register as a Cube template (same flags as code-sandbox-quickstart): +# cubemastercli tpl create-from-image \ +# --image /agent-tool-allowlist-sandbox:latest \ +# --writable-layer-size 1G \ +# --expose-port 49999 \ +# --expose-port 49983 \ +# --probe 49999 +# +# Base image registries (same as examples/code-sandbox-quickstart/README.md): +# CN: cube-sandbox-cn.tencentcloudcr.com/cube-sandbox/sandbox-code:latest +# INT: cube-sandbox-int.tencentcloudcr.com/cube-sandbox/sandbox-code:latest + +ARG SANDBOX_CODE_IMAGE=cube-sandbox-cn.tencentcloudcr.com/cube-sandbox/sandbox-code:latest +FROM ${SANDBOX_CODE_IMAGE} + +ENV TOOL_ALLOWLIST_SANDBOX=1 + +# Demo allowlist file inside the guest (informational; host-side gate remains +# the enforcement point for this example — see README Limitations). +# Names are sorted to match allowlist_sync.allowlist_file_body(). +RUN mkdir -p /etc/cube-sandbox \ + && printf '%s\n' \ + '# Agent tool argv allowlist (demo; host gate is authoritative)' \ + 'cat' \ + 'echo' \ + 'head' \ + 'ls' \ + 'pwd' \ + 'sha256sum' \ + 'uname' \ + 'wc' \ + > /etc/cube-sandbox/tool-allowlist.txt + +# Ports inherited from sandbox-code / quickstart convention. +EXPOSE 49999 49983 diff --git a/examples/agent-tool-allowlist-sandbox/README.md b/examples/agent-tool-allowlist-sandbox/README.md new file mode 100644 index 000000000..b6a2b18e0 --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/README.md @@ -0,0 +1,146 @@ +# Agent Tool Allowlist Sandbox + +[中文文档](README_zh.md) + +Demonstrates argv allowlisting for agent tools **before** `Sandbox.create`: +allowlisted binaries run in a Cube Sandbox MicroVM; others fail on the host +(no sandbox on the deny path). + +The gate is host-side (`assert_allowlisted`). It is not egress CIDR policy or +guest kernel enforcement. + +**Use when:** the host may only forward a fixed tool set (`echo` / `ls` / +`cat`, …) and must reject shells / network tools early. Interpreters are an +explicit capability (`enable_code_execution=True`), not part of the default set. + +**Not for:** full agent frameworks, or replacing +[`network-policy`](../network-policy). + +## 1. Prerequisites + +- A running Cube Sandbox deployment ([dev environment](../../docs/guide/dev-environment.md)) +- Python 3.8+ +- Docker only if you use Path B + +```bash +pip install -r requirements.txt +``` + +## 2. Quick Start + +### Step 1 — Create a template (Recommended) + +```bash +cubemastercli tpl create-from-image \ + --image cube-sandbox-cn.tencentcloudcr.com/cube-sandbox/sandbox-code:latest \ + --writable-layer-size 1G \ + --expose-port 49999 \ + --expose-port 49983 \ + --probe 49999 +``` + +Note the printed `template_id`. Outside CN, use +`cube-sandbox-int.tencentcloudcr.com/cube-sandbox/sandbox-code:latest`. + +| Item | Suggestion | +|------|------------| +| Writable layer | `1G` | +| Ports | `49999`, `49983`; `--probe 49999` | + +### Step 2 — Configure environment + +```bash +cp .env.example .env +# set E2B_API_URL (e.g. http://127.0.0.1:13000) and CUBE_TEMPLATE_ID +``` + +### Step 3 — Run + +```bash +python verify_local.py # unit + deny; no sandbox +python run_allowlisted.py # allow (needs *.cube.app DNS or run in dev VM) +python run_denied.py # deny on host; no sandbox +``` + +Allow (host gate passes, then create with `allow_internet_access=False` — +[`network-policy`](../network-policy) Mode 1 airgap; argv allow ≠ network): + +```text +egress: allow_internet_access=False (airgap; argv gate != network) +agent-tool-allowlist-ok +artifact: artifact-ok +``` + +Deny: + +```text +denied_as_expected: command not on tool allowlist: 'bash' ... +``` + +Evidence that deny never calls `Sandbox.create`: see the unit tests in +`test_allowlist.py` that cover host-gate-before-create ordering and that +`run_denied.py` has no `Sandbox.create` call (also covered by `verify_local.py`). + +On a host without `*.cube.app` DNS, use `python run_allowlisted_sidecar.py` +instead of `run_allowlisted.py` (set proxy vars in `.env.example`; see +[`e2b-dev-sidecar`](../e2b-dev-sidecar)). Optional `verify_local` knobs: +`ALLOWLIST_IMAGE_TAG`, `ALLOWLIST_USE_SIDECAR` (see `.env.example`). + +## 3. Path B (optional) — build this example image + +```bash +docker build -t agent-tool-allowlist-sandbox:latest . +# optional: --build-arg SANDBOX_CODE_IMAGE=.../sandbox-code:latest + +cubemastercli tpl create-from-image \ + --image agent-tool-allowlist-sandbox:latest \ + --writable-layer-size 1G \ + --expose-port 49999 \ + --expose-port 49983 \ + --probe 49999 +``` + +If CubeMaster cannot see a local tag, retag/push to a reachable registry first. + +## 4. Default allowlist + +`allowlist.py` (`DEFAULT_ALLOWED_BINARIES`): `echo`, `uname`, `pwd`, `ls`, +`cat`, `head`, `wc`, `sha256sum`. Path-style binaries and shells such as +`bash` / `curl` are rejected. `python3` lives in `CODE_EXECUTION_BINARIES` and +is only added when `enable_code_execution=True`. + +## 5. Limitations + +- This demo is **capability-style tool gating** on the first argv token — not + full parameter policy. Combining small tools / redirects is out of scope. +- Default policy **denies interpreter execution**. Enabling `python3` grants + arbitrary code execution inside the guest, not "one more binary". + Passing a custom `allowed_binaries` that includes `python3` also grants that + capability without `enable_code_execution=True`. +- Host-side gate only — callers that skip `assert_allowlisted` can still send + any command to the API. MicroVM isolation ≠ guest capability control. +- Mode 1 airgap blocks egress; it does **not** stop local misuse in the guest. + Network tools stay off the default argv list; use [`network-policy`](../network-policy) + for CIDR policy. +- In-image `/etc/cube-sandbox/tool-allowlist.txt` (Path B) is informational; + the host gate is authoritative for this demo. Production should set + capabilities explicitly (do not casually `| CODE_EXECUTION_BINARIES`). + +## 6. Directory + +```text +agent-tool-allowlist-sandbox/ +├── README.md +├── README_zh.md +├── Dockerfile +├── allowlist.py +├── allowlist_sync.py +├── verify_local.py +├── run_allowlisted.py +├── run_allowlisted_sidecar.py +├── run_denied.py +├── test_allowlist.py +├── env_utils.py +├── requirements.txt +└── .env.example +``` diff --git a/examples/agent-tool-allowlist-sandbox/README_zh.md b/examples/agent-tool-allowlist-sandbox/README_zh.md new file mode 100644 index 000000000..3daaca5ca --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/README_zh.md @@ -0,0 +1,139 @@ +# Agent 工具白名单沙箱 + +[English](README.md) + +演示在 `Sandbox.create` **之前**对 Agent 工具做 argv 白名单: +白名单内命令在 Cube Sandbox MicroVM 中执行;非白名单在宿主机失败(拒绝路径不建沙箱)。 + +门控在宿主机(`assert_allowlisted`),不能替代出口 CIDR 或 guest 内核强制。 + +**适用:** 调用方只允许转发固定工具集(`echo` / `ls` / `cat` 等),并尽早拒绝 +shell / 网络类工具。解释器是显式能力(`enable_code_execution=True`),不在默认集。 + +**不适用:** 完整 Agent 框架,或替代 [`network-policy`](../network-policy)。 + +## 1. 前置条件 + +- 已部署的 Cube Sandbox([开发环境](../../docs/zh/guide/dev-environment.md)) +- Python 3.8+ +- 仅 Path B 需要 Docker + +```bash +pip install -r requirements.txt +``` + +## 2. Quick Start + +### Step 1 — 创建模板(推荐) + +```bash +cubemastercli tpl create-from-image \ + --image cube-sandbox-cn.tencentcloudcr.com/cube-sandbox/sandbox-code:latest \ + --writable-layer-size 1G \ + --expose-port 49999 \ + --expose-port 49983 \ + --probe 49999 +``` + +记下 `template_id`。海外可用 +`cube-sandbox-int.tencentcloudcr.com/cube-sandbox/sandbox-code:latest`。 + +| 项 | 建议 | +|----|------| +| 可写层 | `1G` | +| 端口 | `49999`、`49983`;`--probe 49999` | + +### Step 2 — 配置环境 + +```bash +cp .env.example .env +# 填写 E2B_API_URL(如 http://127.0.0.1:13000)与 CUBE_TEMPLATE_ID +``` + +### Step 3 — 运行 + +```bash +python verify_local.py # 单测 + 拒绝;不需沙箱 +python run_allowlisted.py # 放行(需 *.cube.app DNS 或在开发虚机内) +python run_denied.py # 宿主机拒绝;不建沙箱 +``` + +放行(宿主机门控通过后,`Sandbox.create` 带 +`allow_internet_access=False`——[`network-policy`](../network-policy) Mode 1 +断网;argv 放行 ≠ 网络通): + +```text +egress: allow_internet_access=False (airgap; argv gate != network) +agent-tool-allowlist-ok +artifact: artifact-ok +``` + +拒绝: + +```text +denied_as_expected: command not on tool allowlist: 'bash' ... +``` + +拒绝路径零 `Sandbox.create` 的证据:见 `test_allowlist.py` 中覆盖 +「门控先于 create」以及「`run_denied.py` 无 `Sandbox.create` 调用」的用例 +(`verify_local.py` 会跑到)。 + +宿主机无 `*.cube.app` DNS 时,用 `python run_allowlisted_sidecar.py` 替代 +`run_allowlisted.py`(代理变量见 `.env.example`;另见 +[`e2b-dev-sidecar`](../e2b-dev-sidecar))。`verify_local` 可选环境变量: +`ALLOWLIST_IMAGE_TAG`、`ALLOWLIST_USE_SIDECAR`(见 `.env.example`)。 + +## 3. Path B(可选)— 构建本示例镜像 + +```bash +docker build -t agent-tool-allowlist-sandbox:latest . +# 可选: --build-arg SANDBOX_CODE_IMAGE=.../sandbox-code:latest + +cubemastercli tpl create-from-image \ + --image agent-tool-allowlist-sandbox:latest \ + --writable-layer-size 1G \ + --expose-port 49999 \ + --expose-port 49983 \ + --probe 49999 +``` + +若 CubeMaster 看不到本地 tag,先打标签推到可拉取的仓库。 + +## 4. 默认白名单 + +`allowlist.py`(`DEFAULT_ALLOWED_BINARIES`):`echo`、`uname`、`pwd`、`ls`、 +`cat`、`head`、`wc`、`sha256sum`。路径形式二进制以及 `bash` / `curl` 等会被拒绝。 +`python3` 在 `CODE_EXECUTION_BINARIES` 中,仅当 `enable_code_execution=True` 时并入。 + +## 5. 限制 + +- 本示例是基于**首个 argv token** 的能力式工具门控,不是完整参数策略;小工具组合 / + 重定向等不在范围内。 +- 默认**拒绝解释器**。打开 `python3` 等于授予 guest 内任意代码执行,不是「多一个二进制」。 + 若自定义 `allowed_binaries` 直接包含 `python3`,即使未设 `enable_code_execution=True` + 也会获得同等能力。 +- 仅宿主机门控——跳过 `assert_allowlisted` 仍可向 API 发任意命令。MicroVM 隔离 ≠ + guest 内能力控制。 +- Mode 1 断网只挡出站,**不**防 guest 本地滥用。网络类工具不在默认 argv 名单;CIDR + 见 [`network-policy`](../network-policy)。 +- Path B 的 `/etc/cube-sandbox/tool-allowlist.txt` 仅为信息标记;生产应显式配置能力, + 勿随意 `| CODE_EXECUTION_BINARIES`。 + +## 6. 目录结构 + +```text +agent-tool-allowlist-sandbox/ +├── README.md +├── README_zh.md +├── Dockerfile +├── allowlist.py +├── allowlist_sync.py +├── verify_local.py +├── run_allowlisted.py +├── run_allowlisted_sidecar.py +├── run_denied.py +├── test_allowlist.py +├── env_utils.py +├── requirements.txt +└── .env.example +``` diff --git a/examples/agent-tool-allowlist-sandbox/allowlist.py b/examples/agent-tool-allowlist-sandbox/allowlist.py new file mode 100644 index 000000000..e05cdb722 --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/allowlist.py @@ -0,0 +1,118 @@ +# Copyright (c) 2024 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Host-side argv allowlist gate for agent tool commands. + +This is intentionally NOT a kernel/enforcement layer inside the MicroVM. +It models how an agent host should refuse non-allowlisted tool invocations +before they reach Sandbox.commands.run(). + +Capability note: the default set is tool-level only. Granting an interpreter +(``enable_code_execution=True`` / ``CODE_EXECUTION_BINARIES``) is a privilege +escalation to arbitrary guest code execution — not "just another binary". +""" + +from __future__ import annotations + +import shlex +from typing import Iterable + + +# Default: narrow tool binaries (first argv token only). No interpreters. +DEFAULT_ALLOWED_BINARIES: frozenset[str] = frozenset( + { + "echo", + "uname", + "pwd", + "ls", + "cat", + "head", + "wc", + "sha256sum", + } +) + +# Explicit capability escalation: arbitrary code execution inside the guest. +# Prefer enable_code_execution=True at the call site over silently unioning this. +CODE_EXECUTION_BINARIES: frozenset[str] = frozenset({"python3"}) + + +class AllowlistDenied(PermissionError): + """Raised when a command is not on the host-side tool allowlist.""" + + +def _split_argv(command: str) -> list[str] | None: + """Split command into argv tokens; return None if quoting is malformed.""" + try: + return shlex.split(command) + except ValueError: + return None + + +def _resolve_allowed( + allowed_binaries: Iterable[str] | None, + *, + enable_code_execution: bool, +) -> frozenset[str]: + base = ( + frozenset(allowed_binaries) + if allowed_binaries is not None + else DEFAULT_ALLOWED_BINARIES + ) + if enable_code_execution: + return base | CODE_EXECUTION_BINARIES + return base + + +def is_allowlisted( + command: str, + allowed_binaries: Iterable[str] | None = None, + *, + enable_code_execution: bool = False, +) -> bool: + """Return True if the first argv token is on the effective allowlist. + + Empty / whitespace-only commands return False (predicate contract). + Malformed shell quoting (``shlex`` ``ValueError``) returns False — deny, do not crash. + Path-style first tokens (``/`` or ``\\``) return False. + """ + parts = _split_argv(command) + if not parts: + return False + binary = parts[0] + # Reject path-style first tokens. Note: shlex.split() is POSIX-mode, so a bare + # unquoted Windows path like c:\Windows\... may lose backslashes before this + # check; quoted paths and Linux /path tokens are what this Linux demo covers. + if "/" in binary or "\\" in binary: + return False + allowed = _resolve_allowed( + allowed_binaries, enable_code_execution=enable_code_execution + ) + return binary in allowed + + +def assert_allowlisted( + command: str, + allowed_binaries: Iterable[str] | None = None, + *, + enable_code_execution: bool = False, +) -> str: + """Return the command if allowlisted; otherwise raise AllowlistDenied. + + Set ``enable_code_execution=True`` only when the caller intentionally + grants guest arbitrary code execution (adds ``CODE_EXECUTION_BINARIES``). + + Parsing and path checks live in ``is_allowlisted`` so the two APIs cannot drift. + """ + if not is_allowlisted( + command, + allowed_binaries, + enable_code_execution=enable_code_execution, + ): + # One message shape for all denials (including empty / unparseable). + parts = _split_argv(command) + binary = parts[0] if parts else "" + raise AllowlistDenied( + f"command not on tool allowlist: {binary!r} (full: {command!r})" + ) + return command diff --git a/examples/agent-tool-allowlist-sandbox/allowlist_sync.py b/examples/agent-tool-allowlist-sandbox/allowlist_sync.py new file mode 100644 index 000000000..28333d3e5 --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/allowlist_sync.py @@ -0,0 +1,35 @@ +# Copyright (c) 2024 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Emit the demo allowlist as a guest-side text file body. + +Single source of truth: allowlist.DEFAULT_ALLOWED_BINARIES. +Used by verify_local.py to keep Dockerfile content aligned. +""" + +from __future__ import annotations + +from allowlist import DEFAULT_ALLOWED_BINARIES + +HEADER = "# Agent tool argv allowlist (demo; host gate is authoritative)" + + +def allowlist_file_body() -> str: + names = sorted(DEFAULT_ALLOWED_BINARIES) + return "\n".join([HEADER, *names]) + "\n" + + +def dockerfile_run_snippet() -> str: + """Return the RUN printf fragment that should match Dockerfile.""" + lines = [HEADER, *sorted(DEFAULT_ALLOWED_BINARIES)] + printf_args = " \\\n ".join(repr(line) for line in lines) + return ( + "RUN mkdir -p /etc/cube-sandbox \\\n" + f" && printf '%s\\n' \\\n" + f" {printf_args} \\\n" + " > /etc/cube-sandbox/tool-allowlist.txt\n" + ) + + +if __name__ == "__main__": + print(allowlist_file_body(), end="") diff --git a/examples/agent-tool-allowlist-sandbox/env_utils.py b/examples/agent-tool-allowlist-sandbox/env_utils.py new file mode 100644 index 000000000..a1ac78ac6 --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/env_utils.py @@ -0,0 +1,20 @@ +# Copyright (c) 2024 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path + +from dotenv import load_dotenv + + +def load_local_dotenv() -> None: + """Best-effort load of a nearby .env file without overriding real env vars.""" + # Use resolved paths for existence + load. (Sibling examples may still use + # unresolved path after resolve-only dedup; early return makes dedup a no-op.) + for path in ( + Path(__file__).with_name(".env"), + Path.cwd() / ".env", + ): + resolved_path = path.resolve() + if resolved_path.is_file(): + load_dotenv(dotenv_path=resolved_path, override=False) + return diff --git a/examples/agent-tool-allowlist-sandbox/requirements.txt b/examples/agent-tool-allowlist-sandbox/requirements.txt new file mode 100644 index 000000000..1af9e87cc --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/requirements.txt @@ -0,0 +1,5 @@ +e2b-code-interpreter>=2.4.1 +python-dotenv +# Included in one file for convenience (needed by run_allowlisted_sidecar.py / +# e2b-dev-sidecar). Core allow/deny scripts do not import aiohttp. +aiohttp>=3.9 diff --git a/examples/agent-tool-allowlist-sandbox/run_allowlisted.py b/examples/agent-tool-allowlist-sandbox/run_allowlisted.py new file mode 100644 index 000000000..e94e245ca --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/run_allowlisted.py @@ -0,0 +1,45 @@ +# Copyright (c) 2024 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +""" +run_allowlisted.py — Execute an allowlisted agent tool command inside a sandbox. + +Demonstrates the happy path: host-side gate accepts the command, then +Sandbox.commands.run() runs it in a MicroVM and returns stdout. + +Also stacks Cube Mode-1 airgap (allow_internet_access=False): argv allowlist +and platform egress are orthogonal — a passed gate does not imply network. + +Artifact write uses the SDK files API so the default tool allowlist stays +free of interpreters (code execution is an explicit capability elsewhere). +""" + +import os + +from e2b_code_interpreter import Sandbox + +from allowlist import assert_allowlisted +from env_utils import load_local_dotenv + +load_local_dotenv() + +template_id = os.environ["CUBE_TEMPLATE_ID"] + +# Agent-style tool call: only the allowlisted binary "echo" is invoked. +command = "echo agent-tool-allowlist-ok" + +assert_allowlisted(command) + +# network-policy Mode 1: no internet; local echo/artifact still work. +print("egress: allow_internet_access=False (airgap; argv gate != network)") +with Sandbox.create( + template=template_id, + allow_internet_access=False, +) as sandbox: + result = sandbox.commands.run(command) + print(result.stdout.strip()) + + # Demo path != privilege path: do not require guest python3 for artifacts. + sandbox.files.write("/tmp/tool_out.txt", "artifact-ok\n") + content = sandbox.files.read("/tmp/tool_out.txt") + print("artifact:", content.strip()) diff --git a/examples/agent-tool-allowlist-sandbox/run_allowlisted_sidecar.py b/examples/agent-tool-allowlist-sandbox/run_allowlisted_sidecar.py new file mode 100644 index 000000000..fe7dc7a7c --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/run_allowlisted_sidecar.py @@ -0,0 +1,70 @@ +# Copyright (c) 2024 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +""" +run_allowlisted_sidecar.py — Host-machine allow path without wildcard DNS. + +Uses the in-repo e2b-dev-sidecar (docs/guide/connect-existing-cluster.md +Option D) so the official E2B SDK can reach CubeProxy data plane via a local +proxy instead of resolving *.cube.app. + +This does not change the host-side argv gate: assert_allowlisted() still runs +before Sandbox.create() / commands.run(). Create uses allow_internet_access=False +(network-policy Mode 1) so argv allow and platform egress stay orthogonal. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from allowlist import assert_allowlisted +from env_utils import load_local_dotenv + +load_local_dotenv() + +# Import sibling example without packaging it as a dependency. +_SIDECAR_DIR = Path(__file__).resolve().parents[1] / "e2b-dev-sidecar" +if not (_SIDECAR_DIR / "dev_sidecar.py").is_file(): + raise SystemExit( + f"missing e2b-dev-sidecar at {_SIDECAR_DIR}; " + "clone/check out examples/e2b-dev-sidecar from this repository" + ) +sys.path.insert(0, str(_SIDECAR_DIR)) + +from dev_sidecar import setup_dev_sidecar # noqa: E402 + +# Defaults match examples/e2b-dev-sidecar/env.example for QEMU host forwards. +os.environ.setdefault("E2B_API_URL", "http://127.0.0.1:13000") +os.environ.setdefault("E2B_API_KEY", "e2b_000000") +os.environ.setdefault("CUBE_REMOTE_PROXY_BASE", "https://127.0.0.1:11443") +os.environ.setdefault("CUBE_REMOTE_PROXY_VERIFY_SSL", "false") + +setup_dev_sidecar() + +from e2b_code_interpreter import Sandbox # noqa: E402 + +template_id = os.environ["CUBE_TEMPLATE_ID"] + +command = "echo agent-tool-allowlist-ok" +assert_allowlisted(command) + +# network-policy Mode 1: no internet; local echo/artifact still work. +print("egress: allow_internet_access=False (airgap; argv gate != network)") +with Sandbox.create( + template=template_id, + allow_internet_access=False, +) as sandbox: + result = sandbox.commands.run(command) + print(result.stdout.strip()) + + # Demo path != privilege path: SDK write, then allowlisted cat to read back. + sandbox.files.write("/tmp/tool_out.txt", "artifact-ok\n") + + # Prefer allowlisted `cat` over files.read for host+sidecar setups: some + # proxy paths mishandle Content-Encoding on the files API (see evidence). + read_cmd = "cat /tmp/tool_out.txt" + assert_allowlisted(read_cmd) + content = sandbox.commands.run(read_cmd).stdout + print("artifact:", content.strip()) diff --git a/examples/agent-tool-allowlist-sandbox/run_denied.py b/examples/agent-tool-allowlist-sandbox/run_denied.py new file mode 100644 index 000000000..23e130e56 --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/run_denied.py @@ -0,0 +1,21 @@ +# Copyright (c) 2024 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +""" +run_denied.py — Non-allowlisted agent tool command must fail on the host gate. + +Demonstrates the deny path: the host never calls Sandbox.commands.run() for +commands outside the tool allowlist (e.g. arbitrary interpreters / shells). +""" + +from allowlist import AllowlistDenied, assert_allowlisted + +# Not on the default allowlist — models "agent tried to run arbitrary code". +forbidden = "bash -c 'curl http://example.com'" + +try: + assert_allowlisted(forbidden) +except AllowlistDenied as exc: + print("denied_as_expected:", exc) +else: + raise SystemExit("expected AllowlistDenied, but command was accepted") diff --git a/examples/agent-tool-allowlist-sandbox/test_allowlist.py b/examples/agent-tool-allowlist-sandbox/test_allowlist.py new file mode 100644 index 000000000..ae904ef55 --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/test_allowlist.py @@ -0,0 +1,130 @@ +# Copyright (c) 2024 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Local unit tests for allowlist.py — no sandbox / API required.""" + +from __future__ import annotations + +import ast +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +from allowlist import ( + CODE_EXECUTION_BINARIES, + DEFAULT_ALLOWED_BINARIES, + AllowlistDenied, + assert_allowlisted, + is_allowlisted, +) + +ROOT = Path(__file__).resolve().parent + + +def _source_calls_sandbox_create(source: str) -> bool: + """True if AST contains Sandbox.create(...). Docstrings/comments do not count.""" + tree = ast.parse(source) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "create" + and isinstance(func.value, ast.Name) + and func.value.id == "Sandbox" + ): + return True + return False + + +class AllowlistTests(unittest.TestCase): + def test_allow_echo(self) -> None: + self.assertTrue(is_allowlisted("echo hello")) + self.assertEqual(assert_allowlisted("echo hello"), "echo hello") + + def test_deny_bash(self) -> None: + self.assertFalse(is_allowlisted("bash -c 'echo hi'")) + with self.assertRaises(AllowlistDenied): + assert_allowlisted("bash -c 'echo hi'") + + def test_empty_command(self) -> None: + self.assertFalse(is_allowlisted("")) + self.assertFalse(is_allowlisted(" ")) + with self.assertRaises(AllowlistDenied) as ctx: + assert_allowlisted(" ") + self.assertIn("command not on tool allowlist:", str(ctx.exception)) + self.assertIn("full:", str(ctx.exception)) + + def test_malformed_quotes_denied_not_crash(self) -> None: + bad = "echo 'unclosed" + self.assertFalse(is_allowlisted(bad)) + with self.assertRaises(AllowlistDenied) as ctx: + assert_allowlisted(bad) + self.assertIn("command not on tool allowlist:", str(ctx.exception)) + + def test_path_style_binary_rejected(self) -> None: + self.assertFalse(is_allowlisted("/bin/bash -c id")) + # Single quotes preserve backslashes through POSIX shlex parsing, so + # this exercises the explicit Windows path-separator rejection branch. + self.assertFalse(is_allowlisted(r"'C:\Windows\System32\cmd.exe'")) + with self.assertRaises(AllowlistDenied): + assert_allowlisted("/usr/bin/python3 -c 'print(1)'") + + def test_case_sensitive_exact_name(self) -> None: + # Allowlist stores lowercase names; Echo is not the same token. + self.assertFalse(is_allowlisted("Echo hello")) + self.assertTrue(is_allowlisted("echo hello")) + + def test_shlex_injection_style_first_token(self) -> None: + # Only the first argv token matters; trailing payload does not expand the gate. + self.assertTrue(is_allowlisted("echo '; rm -rf /'")) + self.assertFalse(is_allowlisted("curl http://example.com")) + # Quoted path-like first token still contains '/' → denied. + self.assertFalse(is_allowlisted("'/bin/echo' ok")) + + def test_custom_allowlist(self) -> None: + self.assertTrue(is_allowlisted("curl https://x", allowed_binaries={"curl"})) + self.assertFalse(is_allowlisted("echo hi", allowed_binaries={"curl"})) + + def test_default_denies_code_execution(self) -> None: + self.assertNotIn("python3", DEFAULT_ALLOWED_BINARIES) + self.assertEqual(CODE_EXECUTION_BINARIES, frozenset({"python3"})) + self.assertFalse(is_allowlisted("python3 -c 'print(1)'")) + with self.assertRaises(AllowlistDenied): + assert_allowlisted("python3 -c 'print(1)'") + + def test_enable_code_execution_is_explicit_escalation(self) -> None: + cmd = "python3 -c 'print(1)'" + self.assertTrue(is_allowlisted(cmd, enable_code_execution=True)) + self.assertEqual(assert_allowlisted(cmd, enable_code_execution=True), cmd) + + def test_assert_allowlisted_raises_before_create(self) -> None: + """Gate failure must leave a subsequent create helper uncalled.""" + mock_create = MagicMock() + + def run_tool_through_host_gate(command: str) -> None: + # Mirrors run_allowlisted.py ordering: gate, then create. + assert_allowlisted(command) + mock_create(template="unused") + + with self.assertRaises(AllowlistDenied): + run_tool_through_host_gate("bash -c 'curl http://example.com'") + mock_create.assert_not_called() + + def test_run_denied_script_has_no_sandbox_create(self) -> None: + """AST guard: deny demo must not call Sandbox.create (comments OK).""" + source = (ROOT / "run_denied.py").read_text(encoding="utf-8") + self.assertFalse(_source_calls_sandbox_create(source)) + + def test_allow_scripts_stack_airgap_egress(self) -> None: + """Static guard: allow paths set network-policy Mode 1 airgap.""" + for name in ("run_allowlisted.py", "run_allowlisted_sidecar.py"): + source = (ROOT / name).read_text(encoding="utf-8") + self.assertTrue(_source_calls_sandbox_create(source)) + self.assertIn("allow_internet_access=False", source) + self.assertNotIn("python3 -c", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/agent-tool-allowlist-sandbox/verify_local.py b/examples/agent-tool-allowlist-sandbox/verify_local.py new file mode 100644 index 000000000..ee17b3bed --- /dev/null +++ b/examples/agent-tool-allowlist-sandbox/verify_local.py @@ -0,0 +1,168 @@ +# Copyright (c) 2024 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Local verification gate for this example (no Cube deploy required by default). + +Runs: + 1) unit tests + 2) run_denied.py + 3) Dockerfile allowlist drift check vs allowlist.py + 4) optional: docker image markers for ALLOWLIST_IMAGE_TAG + (default: agent-tool-allowlist-sandbox:night-verified; SKIP if missing) + 5) optional: run_allowlisted.py (or sidecar if ALLOWLIST_USE_SIDECAR) when + CUBE_TEMPLATE_ID + E2B_API_URL are set +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import unittest +from pathlib import Path + +from allowlist_sync import allowlist_file_body, dockerfile_run_snippet + +ROOT = Path(__file__).resolve().parent + + +def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: + print("+", " ".join(cmd)) + return subprocess.run( + cmd, + cwd=ROOT, + text=True, + capture_output=True, + check=check, + ) + + +def step_unit_tests() -> None: + loader = unittest.TestLoader() + suite = loader.discover(str(ROOT), pattern="test_allowlist.py") + result = unittest.TextTestRunner(verbosity=2).run(suite) + if not result.wasSuccessful(): + raise SystemExit("unit tests failed") + + +def step_deny() -> None: + proc = _run([sys.executable, "run_denied.py"]) + stdout = proc.stdout or "" + stderr = proc.stderr or "" + if stdout.strip(): + print(stdout.strip()) + if stderr.strip(): + print("run_denied.py stderr:", stderr.strip()) + if "denied_as_expected" not in stdout: + raise SystemExit("run_denied.py did not print denied_as_expected on stdout") + + +def step_dockerfile_sync() -> None: + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + snippet = dockerfile_run_snippet() + if snippet not in dockerfile: + anchor = "RUN mkdir -p /etc/cube-sandbox" + idx = dockerfile.find(anchor) + if idx < 0: + preview = dockerfile[:200] + else: + preview = dockerfile[max(0, idx - 40) : idx + 200] + raise SystemExit( + "Dockerfile allowlist RUN block drifted from " + "allowlist_sync.dockerfile_run_snippet(): expected snippet missing.\n" + f"Around RUN block (~200 chars):\n{preview!r}\n" + "Update Dockerfile or DEFAULT_ALLOWED_BINARIES, then re-run." + ) + names = sorted( + line + for line in allowlist_file_body().splitlines() + if line.strip() and not line.startswith("#") + ) + print("dockerfile_sync=OK", names) + + +def step_docker_markers_optional() -> None: + image = os.environ.get( + "ALLOWLIST_IMAGE_TAG", "agent-tool-allowlist-sandbox:night-verified" + ) + inspect = subprocess.run( + ["docker", "image", "inspect", image], + capture_output=True, + text=True, + ) + if inspect.returncode != 0: + print(f"docker_markers=SKIP (image {image!r} not present)") + return + + env_proc = _run( + [ + "docker", + "image", + "inspect", + image, + "--format", + "{{range .Config.Env}}{{println .}}{{end}}", + ] + ) + if "TOOL_ALLOWLIST_SANDBOX=1" not in env_proc.stdout: + raise SystemExit("image missing TOOL_ALLOWLIST_SANDBOX=1") + + file_proc = _run( + [ + "docker", + "run", + "--rm", + "--entrypoint", + "/bin/sh", + image, + "-c", + "cat /etc/cube-sandbox/tool-allowlist.txt", + ] + ) + if file_proc.stdout != allowlist_file_body(): + raise SystemExit( + "image allowlist file drifted from allowlist.py:\n" + f"image:\n{file_proc.stdout!r}\n" + f"expected:\n{allowlist_file_body()!r}" + ) + print(f"docker_markers=OK ({image})") + + +def step_allowlisted_optional() -> None: + if not os.environ.get("CUBE_TEMPLATE_ID") or not os.environ.get("E2B_API_URL"): + print("allowlisted=SKIP (CUBE_TEMPLATE_ID / E2B_API_URL not set)") + return + + os.environ.setdefault("E2B_API_KEY", "e2b_000000") + use_sidecar = os.environ.get("ALLOWLIST_USE_SIDECAR", "").lower() in { + "1", + "true", + "yes", + "on", + } + script = "run_allowlisted_sidecar.py" if use_sidecar else "run_allowlisted.py" + proc = _run([sys.executable, script], check=False) + stdout = proc.stdout or "" + stderr = proc.stderr or "" + if stdout.strip(): + print(stdout.strip()) + if stderr.strip(): + print(f"{script} stderr:", stderr.strip()) + if proc.returncode != 0: + raise SystemExit(f"{script} failed with exit {proc.returncode}") + if "agent-tool-allowlist-ok" not in stdout or "artifact: artifact-ok" not in stdout: + raise SystemExit(f"{script} stdout missing expected markers") + print(f"allowlisted=OK ({script})") + + +def main() -> None: + step_unit_tests() + step_deny() + step_dockerfile_sync() + step_docker_markers_optional() + step_allowlisted_optional() + print("verify_local=ALL_GREEN") + + +if __name__ == "__main__": + main()