Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions examples/sandbox-backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# CubeSandbox native SDK configuration (see sdk/python/cubesandbox/_config.py)
CUBE_API_URL=http://127.0.0.1:3000
CUBE_TEMPLATE_ID=tpl-xxxxxxxxxxxxxxxx

# Optional overrides
# CUBE_SANDBOX_USER=root # user envd runs commands as inside the sandbox
# CUBE_SANDBOX_TIMEOUT=1800 # sandbox TTL in seconds, refreshed on every reconnect
# CUBE_EXEC_TIMEOUT=120 # default per-command timeout in seconds
# CUBE_HOOK_STATE_DIR=~/.cache/cubesandbox-hook # where session->sandbox_id mappings live
3 changes: 3 additions & 0 deletions examples/sandbox-backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.env
__pycache__/
*.pyc
206 changes: 206 additions & 0 deletions examples/sandbox-backend/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# CubeSandbox Hook 方案设计记录

> 状态:已实现,见同目录下 `cubesandbox_exec.py` / `cubesandbox_rewrite.py` / `install.sh` / `README.md`
> 日期:2026-07-05(设计)/ 2026-07-06(实现)
> 分支:`feat/sandbox-backend`

## 相对本文档的实现变更

实现时对"待解决问题"一节做了如下处理,供后续review参考:

1. **`cd` 不再特殊放行**:不判断命令是否是 `cd`,而是让 `cd`/`export` 都统一走
`cubesandbox-exec`。执行时在沙箱内用两个状态文件
(`/tmp/.cubesandbox_cwd`、`/tmp/.cubesandbox_env_state`)持久化 cwd 和已导出的
环境变量:每次执行前 `source`/`cd` 恢复,执行后写回。这样多次独立的
`commands.run()` 调用之间也能保持连续的 shell 状态,不需要在 hook 里猜测哪些
命令是"纯 shell builtin"。
2. **文件系统一致性**:创建沙箱时,用 `host-mount`(见 `../host-mount/`)把 hook
输入里的 `cwd`(Claude Code 项目目录)以读写方式挂载到沙箱内的同一路径,
使 Bash 在沙箱里看到的文件和 Read/Write/Edit 在 host 上看到的一致。如果该路径
不在 CubeMaster 的 `allowed_host_mount_prefixes` 白名单内,会捕获 `ApiError` 并
降级为不挂载的普通沙箱(打印警告,不中断执行)。
3. **沙箱生命周期**:一个 Claude Code session(hook 输入里的 `session_id`)对应
一个沙箱,用本地状态文件(`~/.cache/cubesandbox-hook/<session_id>.json`)缓存
`sandbox_id`,后续调用 `Sandbox.connect()` 复用;提供 `--reset` 手动销毁。
4. **性能**:未做量化测试;每次调用仍是一次新的 Python 进程 + 一次
`commands.run()` HTTP 往返,冷启动成本已通过会话级复用消除,但比原生本地 Bash
仍有网络往返开销,待实测补充数据。

## 背景

目前 `examples/sandboxed-claude/` 已实现了两种 Claude Code 集成方式:

1. **Headless 执行**:在沙箱内部运行 Claude Code 本身
2. **MCP Server**:Claude Code 作为 MCP client,通过 `sandbox_run_code` / `sandbox_run_command` 等工具显式调用沙箱

MCP 方案的问题是:Claude Code **必须主动选择**沙箱工具,如果 AI 选择直接用 Bash 工具就能绕过沙箱。

## 灵感来源:RTK (Rust Token Killer)

RTK 是一个用 Rust 写的 CLI 代理,通过 Claude Code 的 **PreToolUse Hook** 透明拦截所有 Bash 命令,改写后再交还 Claude Code 执行。AI 完全不知道命令被改写过。

### RTK 的三件套

```
~/.claude/
├── settings.json ← 注册 PreToolUse hook
├── hooks/
│ └── rtk-rewrite.sh ← 拦截脚本
├── RTK.md ← 元命令说明
└── CLAUDE.md ← 追加 @RTK.md 引用
```

### RTK 的数据流

```
Claude Code 要执行 "git status"
PreToolUse hook 触发 → rtk-rewrite.sh
rtk rewrite "git status" → 返回 "rtk git status"
返回 JSON: {"updatedInput": {"command": "rtk git status"}}
Claude Code 执行改写后的命令(输出被 rtk 压缩 ~80%)
```

### rtk-rewrite.sh 核心逻辑

```bash
INPUT=$(cat) # 从 stdin 读 PreToolUse JSON
COMMAND=$(提取 tool_input.command)

# 已有 rtk 前缀的命令直接放行
[[ "$COMMAND" == rtk\ * ]] && exit 0

# 调用 rtk Rust 二进制做改写
REWRITTEN=$(rtk rewrite "$COMMAND")

# 返回 updatedInput 给 Claude Code
cat <<ENDJSON
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"$REWRITTEN"}}}
ENDJSON
```

## CubeSandbox Hook 方案设计

### 核心思路

把 RTK 的 "命令改写" 模式从 **token 压缩** 变成 **沙箱隔离**:

```
Claude Code 要执行 "python3 -c '...'"
PreToolUse hook 触发 → cubesandbox_rewrite.py
判断命令是否需要沙箱化
改写: "python3 -c '...'" → "cubesandbox-exec python3 -c '...'"
updatedInput 交回 Claude Code
cubesandbox-exec 在 CubeSandbox MicroVM 中执行,返回结果
```

### 组件

#### 1. `cubesandbox_exec.py` — 沙箱执行 CLI

- 接收任意 shell 命令作为参数
- 管理沙箱生命周期(懒创建、缓存复用、超时清理)
- 使用原生 `cubesandbox` SDK(非 e2b_code_interpreter)
- 行为类似 `ssh` 或 `docker exec`:把命令发送到远端,stdout/stderr 原样返回,exit code 透传

```
用法:
cubesandbox-exec "python3 -c 'print(1+1)'"
cubesandbox-exec "npm test"
cubesandbox-exec --reset # 销毁当前沙箱,下次命令走新沙箱
```

关键设计点:
- 沙箱缓存(`_sandbox` 全局变量),避免每次命令都冷启动
- 环境变量驱动配置(`CUBE_API_URL`、`CUBE_TEMPLATE_ID`)
- 真正的 `sys.exit(result.exit_code)` 透传退出码

#### 2. `cubesandbox_rewrite.py` — PreToolUse Hook 脚本

- 从 stdin 读取 Claude Code 的 PreToolUse JSON
- 提取 `tool_input.command`
- 判断是否需要沙箱化(跳过 `cubesandbox-exec` 自身、`cd` 等纯 shell built-in)
- 改写命令,返回 `updatedInput` JSON

```python
# 伪代码
input = json.loads(sys.stdin.read())
command = input["tool_input"]["command"]

# 不需要沙箱化的命令
if command.startswith("cubesandbox-exec") or command.startswith("cd "):
sys.exit(0) # exit 0 = 原样执行

# 改写
new_command = f"cubesandbox-exec {shlex.quote(command)}"
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {"command": new_command}
}
}
print(json.dumps(output))
sys.exit(0)
```

#### 3. `install.sh` — 安装脚本

- 在 `~/.claude/settings.json` 中注册 PreToolUse hook(幂等)
- 创建 `~/.claude/hooks/cubesandbox_rewrite.py`
- 可选:在 CLAUDE.md 中添加 sandbox 相关说明

### 文件布局

```
examples/sandbox-backend/
├── DESIGN.md ← 本文档
├── cubesandbox_exec.py ← 沙箱执行 CLI
├── cubesandbox_rewrite.py ← PreToolUse hook 脚本
├── sandbox_exec.py ← 独立 CLI(e2b SDK,opt-in)
├── mcp_server.py ← MCP Server(opt-in 沙箱工具)
├── install.sh ← 安装脚本
└── README.md ← 使用说明
```

### 与 MCP 方案的对比

| | MCP 方案 (已实现) | Hook 方案 (设计中) |
|---|---|---|
| 拦截方式 | AI 显式调用 `sandbox_run_code` 工具 | PreToolUse hook 自动拦截 Bash |
| 透明度 | AI 知道自己在用沙箱 | AI 完全无感 |
| 可靠性 | 依赖 AI 配合,可能被绕过 | 100% 覆盖,无法绕过 |
| 粒度控制 | 精细,可选择性沙箱化 | 粗粒度,默认全部拦截 |
| 额外功能 | 可自定义工具(sandbox_reset 等) | 纯执行代理 |
| 实现复杂度 | 中(MCP 协议、Content-Length 帧解析) | 低(~50 行 hook 脚本 + CLI) |

### 待解决问题

1. **哪些命令不该沙箱化?** — `cd`、`export`、`cubesandbox-exec` 自身需要在 host 执行
2. **文件操作?** — Claude Code 的 Read/Write/Edit 工具操作的是 host 文件系统,但 Bash 命令在沙箱中——两者看到的文件不一致
3. **沙箱生命周期?** — 一个会话用一个沙箱还是一系列命令各用各的?
4. **性能?** — Python 进程启动 + 沙箱通信延迟 vs 直接执行,差异多大?

### 参考资料

- [RTK GitHub](https://github.com/rtk-ai/rtk)
- [Claude Code Hooks 文档](https://code.claude.com/docs/en/hooks)
- [CubeSandbox SDK Python API](/home/dev/CubeSandbox/sdk/python/cubesandbox/)
- [已有的 sandbox_exec.py](/home/dev/CubeSandbox/examples/sandbox-backend/sandbox_exec.py)
166 changes: 166 additions & 0 deletions examples/sandbox-backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Hook Sandbox Backend

Transparently redirect **every** Claude Code Bash command into an isolated
CubeSandbox MicroVM, using a `PreToolUse` hook instead of an MCP tool the AI
has to opt into.

See [`DESIGN.md`](./DESIGN.md) for the design rationale (inspired by
[RTK](https://github.com/rtk-ai/rtk)'s command-rewrite hook pattern).

## Why not the MCP approach?

[`./mcp_server.py`](./mcp_server.py) (included in this directory)
exposes `sandbox_run_command` etc. as MCP tools, but Claude Code has to
**choose** to call them -- it can always fall back to its native `Bash` tool
and run on the host instead.

| | MCP server (opt-in) | Hook backend (this example) |
|---|---|---|
| Interception | AI explicitly calls `sandbox_run_command` | `PreToolUse` hook rewrites every `Bash` call |
| Transparency | AI knows it's using a sandbox | AI is unaware -- sees normal Bash output |
| Coverage | Depends on the AI's choice | 100%, cannot be bypassed |
| Granularity | Fine (opt-in per call) | Coarse (all-or-nothing by default) |

## Architecture

```
Claude Code (host)
│ Bash tool call: "npm test"
PreToolUse hook ──► cubesandbox_rewrite.py
│ rewrites tool_input.command to:
│ cubesandbox-exec --session <id> --mount <cwd> "npm test"
Claude Code executes the rewritten command with its normal Bash tool
cubesandbox-exec (cubesandbox_exec.py)
│ reuses (or creates) one sandbox per Claude Code session
CubeAPI ──► CubeMaster ──► Cubelet ──► KVM MicroVM
stdout/stderr/exit code
returned to Claude Code
```

The AI never sees `cubesandbox-exec` in its own reasoning -- it issued
`npm test`, and it gets back exactly the output/exit code `npm test` would
have produced, except it ran inside a disposable MicroVM instead of on your
machine.

## Quick Start

```bash
cd examples/sandbox-backend
cp .env.example .env
# edit .env: set CUBE_API_URL and CUBE_TEMPLATE_ID (see cubemastercli tpl list)

./install.sh
```

`install.sh` will:
1. Copy the two scripts into `~/.claude/hooks/`
2. Install a `cubesandbox-exec` shim on `$PATH` (default `~/.local/bin`)
3. Register the `PreToolUse` hook in `~/.claude/settings.json` (idempotent)
4. `pip install` the `cubesandbox` SDK + `python-dotenv`

Restart Claude Code, then try:

```
> Run `ls -la` and tell me what's here
```

Claude Code will show a normal Bash tool call and normal-looking output --
but it actually ran inside a CubeSandbox MicroVM. Verify with:

```bash
cubesandbox-exec --session <your-session-id> "hostname; cat /proc/1/cgroup | head -1"
```

To remove everything:

```bash
./install.sh --uninstall
```

## Filesystem consistency

Claude Code's `Read` / `Write` / `Edit` tools operate on the **host**
filesystem (wherever `claude` was launched), while `Bash` commands now run
**inside the sandbox**. Without extra care those are two different
filesystems and `cd $PROJECT && cat file.py` inside the sandbox would fail.

This example solves it with CubeSandbox's [`host-mount`](../host-mount)
extension: the first time a sandbox is created for a session,
`cubesandbox_exec.py` bind-mounts the project directory (the hook's `cwd`
field) **read-write at the same path** inside the sandbox. Host-side file
edits and sandbox-side Bash commands then see identical contents.

This requires the project directory to be under one of CubeMaster's
`allowed_host_mount_prefixes` (default: `/data/shared/`):

```yaml
# CubeMaster config
extra_conf:
allowed_host_mount_prefixes:
- "/data/shared/"
- "/home/you/projects/" # add your project root
```

If the path isn't allowed, `cubesandbox-exec` **does not fail** -- it logs a
warning and falls back to a sandbox with its own isolated filesystem. Bash
commands still run safely sandboxed, they just won't see files created via
Read/Write/Edit (and vice versa). For a fully consistent view, add your
project root to `allowed_host_mount_prefixes` or keep it under
`/data/shared/`.

## Session & shell state

- **One sandbox per Claude Code session.** `cubesandbox_rewrite.py` passes
`session_id` from the hook payload; `cubesandbox_exec.py` caches the
resulting `sandbox_id` in `~/.cache/cubesandbox-hook/<session_id>.json`
and reconnects to it on every subsequent Bash call, so you don't pay a
cold-start cost per command.
- **`cd` / `export` persist across calls.** Each `commands.run()` invocation
is otherwise stateless, so before running your command the wrapper
restores `$PWD` and exported vars from files inside the sandbox
(`/tmp/.cubesandbox_cwd`, `/tmp/.cubesandbox_env_state`), and updates them
afterwards. From Claude Code's point of view this behaves like one
continuous shell session, the same as its native (non-sandboxed) Bash
tool.
- **Manual reset.** `cubesandbox-exec --reset --session <id>` kills the
cached sandbox and clears its state, forcing a clean sandbox on the next
call (handy between unrelated tasks in the same Claude Code session).

## Files

```
sandbox-backend/
├── DESIGN.md # Design notes / rationale (RTK-inspired)
├── cubesandbox_exec.py # Sandbox exec CLI (session cache, state persistence, host-mount)
├── cubesandbox_rewrite.py # PreToolUse hook: rewrites Bash tool_input.command
├── sandbox_exec.py # Standalone CLI (e2b SDK, opt-in)
├── mcp_server.py # MCP server (opt-in sandbox tools for Claude Code)
├── install.sh # Installs hooks + settings.json registration (+ --uninstall)
├── requirements.txt
├── .env.example
└── README.md # This file
```

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| Bash output unchanged, no sandbox created | Hook not registered / Claude Code not restarted | Check `~/.claude/settings.json`, restart `claude` |
| `CUBE_TEMPLATE_ID is not set` | `.env` missing/not loaded | `cp .env.example .env` and fill in values; `cubesandbox-exec` loads `.env` from its own directory |
| `hostPath ... is not within an allowed mount prefix` (warning) | Project dir not under `allowed_host_mount_prefixes` | Add it to CubeMaster config, or ignore -- sandbox still works, just without shared FS |
| Commands run but `cd`/`export` don't stick between calls | `/tmp/.cubesandbox_cwd` or `_env_state` not writable in the template image | Ensure the template's `/tmp` is writable by `CUBE_SANDBOX_USER` |
| Every command spins up a brand-new sandbox | `session_id` missing from hook payload, or `~/.cache/cubesandbox-hook` not writable | Check hook stdin payload has `session_id`; check `CUBE_HOOK_STATE_DIR` permissions |
| `SandboxNotFoundError` / stale sandbox | Sandbox TTL (`CUBE_SANDBOX_TIMEOUT`) expired between calls | Increase `CUBE_SANDBOX_TIMEOUT`, or accept the automatic recreate (some cwd/env state is lost) |

## Related

- [`../sandboxed-claude/`](../sandboxed-claude/) -- run Claude Code itself
inside a sandbox (pattern A)
- [`../host-mount/`](../host-mount/) -- the host-mount mechanism used here
- Claude Code Hooks reference: https://code.claude.com/docs/en/hooks
Loading
Loading