feat(examples): add agent tool argv allowlist sandbox (#645)#1062
feat(examples): add agent tool argv allowlist sandbox (#645)#1062cgflag wants to merge 21 commits into
Conversation
Demonstrate host-side tool allowlist gating before MicroVM execution, with allow/deny paths and examples index registration. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
…Cloud#645) Wrap official sandbox-code with TOOL_ALLOWLIST_SANDBOX marker and in-image allowlist file so the example has a buildable template path. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
Cover empty command, path-style binaries, case sensitivity, and shlex-style first-token cases without requiring a sandbox. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
…oud#645) Add Dockerfile create-from-image flow and host-vs-tap enforcement comparison table citing network-policy README. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
…oud#645) Use the bundled frontend because this Dockerfile only needs standard instructions. This prevents Docker Hub resolution from blocking checks while retaining the Tencent base image path. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
…d#645) Keep Dockerfile guest allowlist aligned with allowlist.py and provide a single command that proves deny/unit/docker markers without a live sandbox. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
…ud#645) Explain the local verification gate and that allowlisted runs need *.cube.app DNS even when the forwarded API health check succeeds. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
|
please remove the useless ai generated descriptions in README.md and README_zh.md |
…loud#645) Reuse e2b-dev-sidecar so allowlisted commands and artifact readback work on the QEMU host forwards without changing system DNS. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
Point to connect-existing-cluster Option D and record the verified host E2E flow beside the guest/DNS path. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
…oud#645) Address reviewer feedback by removing AI-style comparison tables and aligning with network-policy: one-line purpose, recommended template path, then env and run. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
|
@luzhixing12345 Thanks for the review. I've removed the unnecessary AI-generated comparison/defense sections from |
| raise AllowlistDenied("empty command") | ||
| return parts[0] | ||
|
|
||
|
|
There was a problem hiding this comment.
Design inconsistency: is_allowlisted is named and typed as -> bool, but raises AllowlistDenied for empty commands via _first_token (line 40). The test confirms this — is_allowlisted("") raises, it does not return False. Consider returning False for empty input or renaming to check_allowlisted to signal it may raise.
| # Default allowlist: exact binary names (first argv token) only. | ||
| # Keep this narrow — the point of the example is least privilege for agent tools. | ||
| DEFAULT_ALLOWED_BINARIES: frozenset[str] = frozenset( | ||
| { |
There was a problem hiding this comment.
Security model documentation gap: Including python3 allows arbitrary Python execution in the guest (python3 -c "..."), which effectively permits any filesystem-level operation. The airgap limits network egress but not local exfiltration or guest destruction. This is fine as a demo choice, but the README Limitations should explicitly call out that a "tool argv allowlist" is only as restrictive as its broadest entry, and python3 is the broadest entry here.
| return False | ||
| return binary in allowed | ||
|
|
||
|
|
There was a problem hiding this comment.
Redundant type annotation and duplicate call: Sequence[str] is a subtype of Iterable[str], so Sequence[str] | Iterable[str] in the union is redundant. And on line 61, is_allowlisted calls _first_token internally, then _first_token is called again here. Could restructure to call _first_token once and check directly.
| 1) unit tests | ||
| 2) run_denied.py | ||
| 3) Dockerfile allowlist drift check vs allowlist.py | ||
| 4) optional: docker image markers if IMAGE_TAG exists |
There was a problem hiding this comment.
Docstring mismatch: The module-level docstring says "if IMAGE_TAG exists" but the code reads env var ALLOWLIST_IMAGE_TAG. Should be aligned.
| mock_create.assert_not_called() | ||
| self.assertEqual(mock_create.call_count, 0) | ||
|
|
||
| def test_run_denied_script_has_no_sandbox_create(self) -> None: |
There was a problem hiding this comment.
Fragile assertion: Reading source and doing a substring check for "Sandbox.create" matches inside comments/docstrings too. An AST-based check or verifying that run_denied.py doesn't import Sandbox at all would be more robust. Minor concern for an example.
Review: PR #1062 —
|
…ty (TencentCloud#645) Default allowlist stays tool-only; enable_code_execution opts into python3. Artifacts use SDK files API; docs clarify capability gating vs isolation. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
|
|
||
|
|
||
| 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). | ||
| Path-style first tokens (``/`` or ``\\``) return False. | ||
| """ | ||
| parts = shlex.split(command) | ||
| if not parts: | ||
| return False | ||
| binary = parts[0] | ||
| if "/" in binary or "\\" in binary: |
There was a problem hiding this comment.
Suggestion: is_allowlisted and assert_allowlisted have duplicated shlex.split + path-rejection logic.
is_allowlisted (line 64) parses the command itself, while assert_allowlisted (line 90) delegates token extraction to _first_token but repeats the path-style check independently. If either function's path-rejection logic is updated in the future, the other will drift.
Consider implementing assert_allowlisted as a thin wrapper around is_allowlisted:
def assert_allowlisted(
command: str,
allowed_binaries: Iterable[str] | None = None,
*,
enable_code_execution: bool = False,
) -> str:
if not is_allowlisted(command, allowed_binaries, enable_code_execution=enable_code_execution):
binary = _first_token(command) # re-parse only for the error message
raise AllowlistDenied(
f"command not on tool allowlist: {binary!r} (full: {command!r})"
)
return commandThis eliminates the duplicated shlex.split call and the duplicated "/" in binary or "\\" in binary condition, making the two APIs guaranteed to stay in sync.
…tCloud#645) Keep path/token checks in one place so the predicate and assert APIs cannot drift. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
| continue | ||
| seen_paths.add(resolved_path) | ||
|
|
||
| if path.is_file(): |
There was a problem hiding this comment.
Nit: path.resolve() is computed (line 15) but is_file() and load_dotenv() use the unresolved path instead.
resolved_path is computed for the dedup set, but the is_file() check (line 20) and load_dotenv() (line 21) both use the unresolved path. On most filesystems this behaves identically since both candidates (__file__.with_name(".env") and cwd() / ".env") are already absolute. If .env were a symlink, Path.is_file() follows symlinks regardless, so this is not a functional bug — just a readability inconsistency. Consider using resolved_path consistently so the code reads as one intentional choice rather than accidentally mixed variables.
| continue | ||
| seen_paths.add(resolved_path) | ||
|
|
||
| if path.is_file(): |
There was a problem hiding this comment.
Nit: path.resolve() computed but not used for is_file() / load_dotenv call.
resolved_path is computed (line 15) and used for the dedup set, but the subsequent is_file() check (line 20) and load_dotenv() call (line 21) both use the unresolved path instead. On most filesystems this behaves identically since both __file__.with_name(...) and cwd() / ... yield absolute paths. However, if .env were a symlink, path.is_file() would follow symlinks the same way resolved_path.is_file() would, so this is a readability inconsistency rather than a bug. Consider using resolved_path for the existence check and the load call to keep the intent clear.
…encentCloud#645) Keep dedup, is_file, and load_dotenv on the same resolved path for clear intent. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
| from dotenv import load_dotenv | ||
|
|
||
|
|
||
| def load_local_dotenv() -> None: |
There was a problem hiding this comment.
This is a near-identical copy of examples/code-sandbox-quickstart/env_utils.py, but with a functional difference: line 20 uses resolved_path.is_file() (correct) vs the original's path.is_file(). This drift means a bug fix to one won't apply to the other. Please align the two copies — either adopt the fix in the original too, or note the difference in a comment.
| e2b-code-interpreter>=2.4.1 | ||
| python-dotenv | ||
| # Required only for run_allowlisted_sidecar.py (host without *.cube.app DNS) | ||
| aiohttp>=3.9 |
There was a problem hiding this comment.
aiohttp is listed unconditionally despite the comment saying it's "Required only for run_allowlisted_sidecar.py". Users running only run_allowlisted.py or run_denied.py pull in aiohttp + its transitive dependencies for no benefit. Either move it to a separate requirements-optional.txt, note it as a manual install in the README, or remove the comment (convenience of one file is a valid trade-off).
| r"printf '%s\\n' \\\n(?P<body>.*?)\n\s*> /etc/cube-sandbox/tool-allowlist.txt", | ||
| dockerfile, | ||
| flags=re.S, | ||
| ) |
There was a problem hiding this comment.
This regex parsing the Dockerfile printf block is format-sensitive. Indentation changes, extra blank lines, or an inline comment cause it to miss the block, raising a bare SystemExit("Dockerfile printf allowlist block not found") with no diagnostic. When the regex fails to match, print ~200 chars around the expected area so the developer can see the drift immediately. Consider also adding the unit-test approach (assertIn(dockerfile_run_snippet(), dockerfile)) as a more robust alternative.
| self.assertTrue(is_allowlisted(cmd, enable_code_execution=True)) | ||
| self.assertEqual(assert_allowlisted(cmd, enable_code_execution=True), cmd) | ||
|
|
||
| def test_deny_path_never_calls_sandbox_create(self) -> None: |
There was a problem hiding this comment.
The test name test_deny_path_never_calls_sandbox_create over-promises: it verifies gate-before-create ordering via a local helper with a mock, but does not test that the real run_denied.py script avoids Sandbox.create (that's covered by the static test_run_denied_script_has_no_sandbox_create below at line 79). Consider renaming to test_assert_allowlisted_raises_before_create to match what it actually verifies.
|
|
||
| Parsing and path checks live in ``is_allowlisted`` so the two APIs cannot drift. | ||
| """ | ||
| if not is_allowlisted( |
There was a problem hiding this comment.
On the deny path the command is parsed twice via shlex.split() — once in is_allowlisted() (which calls shlex.split on its first token extraction) and again in _first_token() for the error message. The comment on line 505 acknowledges this ("Re-parse only for the error message"), but for production code the double-parse is wasteful. Consider a refactor that parses once and passes the parsed tokens through, so the common path avoids the redundant split. Acceptable for an example, but worth noting since this is designed as a reference pattern.
…centCloud#645) Document env_utils/quickstart drift and aiohttp convenience; sync Dockerfile via snippet; rename over-promising test; document verify_local knobs. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
| ] | ||
|
|
||
| seen_paths = set() | ||
| for path in candidate_paths: | ||
| resolved_path = path.resolve() | ||
| if resolved_path in seen_paths: |
There was a problem hiding this comment.
The seen_paths dedup set (lines 15–17) is unreachable dead code. The function returns immediately after loading the first .env file, so the second candidate (Path.cwd() / ".env") is only reached when the first (Path(__file__).with_name(".env")) doesn't exist — and in that case the resolved paths cannot be equal, so the dedup never triggers.
Consider removing seen_paths and the guard to simplify. (Harmless as-is, just worth noting.)
| mock_create.assert_not_called() | ||
| self.assertEqual(mock_create.call_count, 0) | ||
|
|
||
| def test_run_denied_script_has_no_sandbox_create(self) -> None: |
There was a problem hiding this comment.
assert_not_called() already verifies call_count == 0. The assertEqual on the next line is redundant — consider removing it.
Drop dead seen_paths dedup and redundant mock assertion; document custom allowlist interpreter bypass; ignore local .venv. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
| """ | ||
| if not is_allowlisted( | ||
| command, | ||
| allowed_binaries, |
There was a problem hiding this comment.
Minor: empty/whitespace command takes a different error-message format than other denials
When command is empty or whitespace-only, _first_token(command) raises AllowlistDenied("empty command") from inside itself, before the "not on tool allowlist" message below can execute. This means empty commands produce a different exception message shape than every other denial.
The existing test_empty_command test expects this behavior, so it's tested — but a consumer catching AllowlistDenied would need to handle two message formats. Consider normalizing so all denials flow through the same message template.
| def test_run_denied_script_has_no_sandbox_create(self) -> None: | ||
| """Static guard: the deny demo must not call Sandbox.create.""" | ||
| source = (ROOT / "run_denied.py").read_text(encoding="utf-8") | ||
| self.assertNotIn("Sandbox.create", source) |
There was a problem hiding this comment.
Medium: fragile static source assertion — a comment mentioning Sandbox.create would trigger a false-positive failure
This test (and the similar one in test_allow_scripts_stack_airgap_egress on line 91) reads sibling source files and checks for the presence/absence of literal substrings. This is a clever regression guard for a security invariant, but it's brittle: adding Sandbox.create to any context in run_denied.py — even a docstring like """Note: this never calls Sandbox.create.""" — would break the test.
Consider either:
(a) Parsing the AST and checking for attribute-access nodes (ast.Call(ast.Attribute(ast.Name("Sandbox"), "create"))), or
(b) Embedding an explicit sentinel comment (e.g., # ALLOWLIST_GUARD: NO_SANDBOX_CREATE) in run_denied.py and checking for that sentinel instead.
…entCloud#645) Empty commands use the same AllowlistDenied shape; static deny checks parse AST so comments cannot false-fail. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
| @@ -0,0 +1,17 @@ | |||
| from pathlib import Path | |||
There was a problem hiding this comment.
Missing license header
All other Python files in this example begin with the standard project header:
# Copyright (c) 2024 Tencent Inc.
# SPDX-License-Identifier: Apache-2.0This file omits it. Please add the header for consistency with the rest of the example directory and project conventions.
| @@ -0,0 +1,106 @@ | |||
| # Copyright (c) 2024 Tencent Inc. | |||
There was a problem hiding this comment.
Note on path-separator check
The "\\" in binary check on line 494 runs after shlex.split() has already consumed backslash escapes (POSIX mode). A bare (unquoted) Windows-style path like C:\Windows\System32\cmd.exe would have its backslashes stripped before the check. Low risk for this Linux-targeted demo, but worth noting for anyone reusing the pattern.
| @@ -0,0 +1,147 @@ | |||
| # Agent Tool Allowlist Sandbox | |||
There was a problem hiding this comment.
README references specific test method names
If these tests are renamed, the README becomes stale. Consider describing the concept rather than exact method identifiers.
…ntCloud#645) Also add the standard license header to env_utils.py for consistency. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
|
|
||
| Empty / whitespace-only commands return False (predicate contract). | ||
| Path-style first tokens (``/`` or ``\\``) return False. | ||
| """ |
There was a problem hiding this comment.
shlex.split() will raise ValueError on malformed input (e.g. unterminated quotes)
is_allowlisted() and assert_allowlisted() both call shlex.split(command) without catching ValueError. If an agent issues a command with malformed quoting like echo "unclosed or cat 'incomplete, the exception propagates unhandled — the gate would crash instead of cleanly denying.
Consider either:
- Wrapping the
shlex.split()calls and returningFalse/ raisingAllowlistDeniedon parse failure (preferred — keeps the predicate contract), or - Documenting that callers must pass syntactically valid shell commands, and catching the exception at a higher boundary in production integrations.
| } | ||
| script = "run_allowlisted_sidecar.py" if use_sidecar else "run_allowlisted.py" | ||
| proc = _run([sys.executable, script], check=False) | ||
| out = (proc.stdout or "") + (proc.stderr or "") |
There was a problem hiding this comment.
stdout + stderr concatenation could mask errors
If run_allowlisted.py (or the sidecar variant) prints the expected markers to stdout alongside an error on stderr, the concatenated out string still passes both marker checks. The exit-code check on line 1038 is a stronger guard, but a script that exits 0 while writing diagnostics to stderr (e.g. a warning about a deprecated API path) would pass silently. Consider checking stdout separately and logging stderr as a warning, or at minimum using a delimiter between the two.
| | [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. | | ||
| | [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. | | ||
| | [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. | |
There was a problem hiding this comment.
Suggestion: consider placing this entry near other agent-tooling examples
The "Agent Tool Allowlist Sandbox" is an agent-tooling example (alongside OpenClaw, OpenAI Agents SDK, OpenAI Agents + Code Interpreter). Appending it at the end of the table separates it from peers that users browsing agent integrations are most likely scanning. Consider inserting it after the "OpenAI Agents + Code Interpreter" row (or after "OpenAI Agents SDK Integration") to keep the agent-tooling cluster together. The same organizational note applies to docs/zh/guide/tutorials/examples.md.
…ud#645) Treat malformed quoting as allowlist deny; check success markers on stdout only; place the example next to other agent entries in tutorials. Assisted-by: Cursor:Composer Co-authored-by: Cursor <cursoragent@cursor.com>
| 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 |
There was a problem hiding this comment.
Note: aiohttp is only needed for the sidecar variant
The comment explains that aiohttp is only needed by run_allowlisted_sidecar.py (via e2b-dev-sidecar), but the core allow/deny scripts and tests don't use it.
Consider splitting into two files for clarity:
requirements.txt— core dependencies (e2b-code-interpreter,python-dotenv)requirements-sidecar.txt— addsaiohttp>=3.9and pulls inrequirements.txtvia-r requirements.txt
This way, users running only python run_allowlisted.py or python run_denied.py don't install an unnecessary dependency. Optional — it's a minor DX improvement for an example.
| ): | ||
| # One message shape for all denials (including empty / unparseable). | ||
| parts = _split_argv(command) | ||
| binary = parts[0] if parts else "" |
There was a problem hiding this comment.
Minor: duplicate _split_argv call on deny path
assert_allowlisted calls is_allowlisted(command, ...) which internally calls _split_argv(command). When the command is denied, _split_argv is called a second time here purely to extract the binary name for the error message.
Minor DRY opportunity. Not a correctness issue — just a style suggestion for the example.
Summary
examples/agent-tool-allowlist-sandbox: host-side argv allowlist gate for agent tool commands (allow path runs in MicroVM + artifact readback; deny path fails fast without creating a sandbox).sandbox-codetemplate — not another language/runtime or full Agent host; deliberately distinct from egress CIDR allowlists and existing agent integrations.docs/*/guide/tutorials/examples.md.Closes nothing — contributes to #645 (Rhino Bird: sandbox templates & examples ecosystem).
Differentiation (vs open #645 PRs)
Test plan
pip install -r examples/agent-tool-allowlist-sandbox/requirements.txtsandbox-codeper README; set.envpython run_allowlisted.py→ printsagent-tool-allowlist-okandartifact: artifact-okpython run_denied.py→ printsdenied_as_expected: ...and does not create a sandboxAssisted-by: Cursor:Composer