大家好,我是十三!欢迎来到十三Tech。

低代码平台里的"代码块"节点是个经典难题:用户在前端写一段代码,服务端怎么把它安全地执行掉再把结果送回去?这道题没有银弹——任何能跑用户代码的服务都面临 RCE、文件越权、资源耗尽这一连串风险。字节开源的 Coze Studio 给了一个相当系统的工程答案,正好拿来拆一遍。

一、核心实现:两种模式的权衡

Coze 没有走"一刀切"的路子,而是设计了两套截然不同的执行引擎。这背后的原则是场景分离:生产环境里安全是第一要务,开发测试环境里便捷性更重要。同一个 Runner 接口、两种实现,按部署场景挑用哪条路径。

Sandbox 与 Direct:场景分离的两套执行器

无论走哪条路径,请求在到达 Runner 之前都要先穿过上层统一的模块黑白名单——这是第一道独立的安全防线,我们放到后面讲。

1. 生产环境的安全方案:Sandbox Runner

生产方案的核心是纵深防御:把执行链条切成四层,每层只承担一个明确的安全职责。整条链路长这样:Go 主进程 → Python 调度脚本 → Deno 运行时 → Pyodide (Wasm)

四层沙箱:从 Go 主进程到 Pyodide Wasm

第一层 · Go 调度层:Go 不直接碰用户代码,而是通过 os/exec 启动一个独立的 Python 子进程,用 os.Pipe 建双向管道做进程间通信。这是入口,也是边界——Go 进程崩了不会污染宿主。

// file: coze/coze-studio/backend/infra/impl/coderunner/sandbox/runner.go

func (runner *runner) Run(ctx context.Context, request *coderunner.RunRequest) (*coderunner.RunResponse, error) {
	if request.Language == coderunner.JavaScript {
		return nil, fmt.Errorf("js not supported yet") // JS被明确禁用
	}

	// 1. 将所有请求信息序列化为 JSON
	b, err := json.Marshal(req{
		Config: runner.config,
		Code:   request.Code,
		Params: request.Params,
	})
	if err != nil {
		return nil, err
	}

	// 2. 创建用于 "Go -> Python" 和 "Python -> Go" 的双向管道
	pr, pw, err := os.Pipe() // pw (Go写) -> pr (Python读)
	if err != nil {
		return nil, err
	}
	r, w, err := os.Pipe() // w (Python写) -> r (Go读)
	if err != nil {
		return nil, err
	}

	// 3. 将数据写入管道并立即关闭写端
	if _, err = pw.Write(b); err != nil {
		return nil, err
	}
	if err = pw.Close(); err != nil {
		return nil, err
	}

	// 4. 启动 Python 脚本子进程
	cmd := exec.Command(runner.pyPath, runner.scriptPath)
	// 5. 【关键】通过 ExtraFiles 将管道的文件描述符传递给子进程
	cmd.ExtraFiles = []*os.File{w, pr}

	if err = cmd.Start(); err != nil {
		return nil, err
	}
	if err = w.Close(); err != nil { // 关闭Go端的写管道
		return nil, err
	}

	// 6. 从输出管道中解码 Python 脚本返回的 JSON 结果
	result := &resp{}
	d := json.NewDecoder(r)
	d.UseNumber() // 确保数字精度
	if err = d.Decode(result); err != nil {
		return nil, err
	}

	if err = cmd.Wait(); err != nil { // 等待子进程结束,回收资源
		return nil, err
	}

	// 7. 检查执行状态
	if result.Status != "success" {
		return nil, fmt.Errorf("exec failed, stdout=%s, stderr=%s, sandbox_err=%s",
			result.Stdout, result.Stderr, result.SandboxError)
	}

	return &coderunner.RunResponse{Result: result.Result}, nil
}

第二、三、四层 · Python 调度与 Deno 执行:Python 脚本是整个沙箱的桥接层。它从 fd 3/4 读出 Go 传来的请求,组装一条 deno run 命令,把权限 flag、V8 内存限制、Pyodide JSR 包一并拼好,然后用 subprocess 把活儿派给 Deno。

# file: coze/coze-studio/backend/infra/impl/coderunner/script/sandbox.py

class Sandbox:
    def __init__(self, *, allow_env=False, allow_read=False, allow_write=False,
                 allow_net=False, allow_run=False, allow_ffi=False,
                 node_modules_dir="auto", **kwargs) -> None:
        # 1. 根据 Go 传来的配置,生成 Deno 的权限参数列表
        self.permissions = []
        perm_defs = [
            ("--allow-env",   allow_env,   None),
            ("--allow-read",  allow_read,  ["node_modules"]),
            ("--allow-write", allow_write, ["node_modules"]),
            ("--allow-net",   allow_net,   None),
            ("--allow-run",   allow_run,   None),
            ("--allow-ffi",   allow_ffi,   None),
        ]
        for flag, value, defaults in perm_defs:
            perm = self._build_permission_flag(flag, value=value)
            if perm is None and defaults is not None:
                perm = f"{flag}={','.join(defaults)}"
            if perm:
                self.permissions.append(perm)
        self.permissions.append(f"--node-modules-dir={node_modules_dir}")

    def execute(self, code, *, timeout_seconds=None, memory_limit_mb=100, **kwargs) -> Output:
        # 2. 组装 deno run 命令
        cmd = ["deno", "run"]
        cmd.extend(self.permissions)
        # 3. 设置 V8 引擎参数(内存限制等)
        v8_flags = ["--experimental-wasm-stack-switching"]
        if memory_limit_mb is not None and memory_limit_mb > 0:
            v8_flags.append(f"--max-old-space-size={memory_limit_mb}")
        cmd.append(f"--v8-flags={','.join(v8_flags)}")
        # 4. 运行一个基于 Pyodide (Python in Wasm) 的 JSR 包
        cmd.append("jsr:@langchain/pyodide-sandbox@0.0.4")
        cmd.extend(["--code", code])
        # 5. 通过子进程运行 Deno,并捕获输出
        try:
            process = subprocess.run(cmd, capture_output=True, text=False,
                                     timeout=timeout_seconds, check=False)
            stdout = process.stdout.decode("utf-8", errors="replace")
            if stdout:
                full_result = json.loads(stdout)
                result = full_result.get("result", None)
                status = "success" if full_result.get("success", False) else "error"
            else:
                stderr = process.stderr.decode("utf-8", errors="replace")
                status = "error"
        except subprocess.TimeoutExpired:
            status = "error"
            stderr = f"Execution timed out after {timeout_seconds} seconds"
        return Output(status=status, result=result, ...)

if __name__ == "__main__":
    # 从 Go 进程传递来的文件描述符中读取输入
    w = os.fdopen(3, "wb") # 写给 Go
    r = os.fdopen(4, "rb") # 从 Go 读
    try:
        req = json.load(r)
        user_code, params, config = req["code"], req["params"], req["config"] or {}
        sandbox = Sandbox(**config)
        # 包装用户代码为完整的 Python 脚本
        if params is not None:
            code = prefix + f'args={json.dumps(params)}\n' + user_code + suffix
        else:
            code = prefix + user_code + suffix
        resp = sandbox.execute(code, **config)
        # 将最终结果写回给 Go 进程
        result = json.dumps(dataclasses.asdict(resp), ensure_ascii=False)
        w.write(str.encode(result))
        w.flush()
        w.close()
    except Exception as e:
        # 异常情况下也要回传错误信息
        w.write(str.encode(json.dumps({"sandbox_error": str(e)})))
        w.flush()
        w.close()

走到这里,用户的 Python 代码最终是在 Deno 严格限制权限、并跑在 WebAssembly 虚拟机内的 Pyodide 解释器里执行。四层嵌套不是为了花哨,而是把"用户代码能造成的伤害"逐层削掉——Go 隔离进程、Deno 卡住权限、Wasm 拦住系统调用。

2. 开发环境的便捷方案:Direct Runner

direct/runner.go 是另一个极端:直接通过 exec.Command 调系统装的 Python 解释器跑代码,没有沙箱、没有权限隔离。源码里一句 // ignore_security_alert RCE 把它的定位说得很清楚——只用于本地或受信任的测试场景。生产环境绝不能走这条路径。

3. 统一的安全前置校验

无论选哪个 Runner,代码在到达执行器之前,都要先经过上层业务逻辑里的一道前置校验——主要针对 Python 的 import 语句。这一步独立于沙箱实现,是 Runner 之外的第一道闸。

// file: coze/coze-studio/backend/domain/workflow/internal/nodes/code/code.go

// 完整的模块黑名单
var pythonBuiltinBlacklist = map[string]struct{}{
	"curses": {}, "dbm": {}, "multiprocessing": {}, "threading": {},
	"socket": {}, "pty": {}, "tty": {}, "fcntl": {}, "grp": {},
	"pwd": {}, "resource": {}, "syslog": {}, "termios": {}, // ... 更多模块
}

// 第三方库白名单(非常有限)
var pythonThirdPartyWhitelist = map[string]struct{}{
	"requests_async": {},
	"numpy":          {},
}

func validatePythonImports(code string) error {
	imports := parsePythonImports(code) // 解析代码中的所有 import 语句
	importErrors := make([]string, 0)

	var blacklistedModules []string
	var nonWhitelistedModules []string

	for _, imp := range imports {
		if _, ok := pythonBuiltinModules[imp]; ok {
			// 检查是否在内置模块的黑名单中
			if _, blacklisted := pythonBuiltinBlacklist[imp]; blacklisted {
				blacklistedModules = append(blacklistedModules, imp)
			}
		} else {
			// 检查是否在第三方库的白名单中
			if _, whitelisted := pythonThirdPartyWhitelist[imp]; !whitelisted {
				nonWhitelistedModules = append(nonWhitelistedModules, imp)
			}
		}
	}

	if len(blacklistedModules) > 0 {
		moduleNames := fmt.Sprintf("'%s'", strings.Join(blacklistedModules, "', '"))
		importErrors = append(importErrors, fmt.Sprintf(
			"ModuleNotFoundError: The module(s) %s are removed from the Python standard library for security reasons",
			moduleNames))
	}
	if len(nonWhitelistedModules) > 0 {
		moduleNames := fmt.Sprintf("'%s'", strings.Join(nonWhitelistedModules, "', '"))
		importErrors = append(importErrors, fmt.Sprintf(
			"ModuleNotFoundError: No module named %s", moduleNames))
	}

	if len(importErrors) > 0 {
		return errors.New(strings.Join(importErrors, ","))
	}
	return nil
}

逻辑很直白:内置模块走黑名单(socketmultiprocessingpty 这类有系统副作用的直接拒掉),第三方库走白名单(默认只放 numpyrequests_async 这种"无害"的)。这套规则和 Coze 官方文档 的说明完全一致——它独立于 Runner 实现,是第一道、也是最廉价的一道防线。

二、执行流程

把 Sandbox Runner 的完整执行流程串起来,每一步的职责就一目了然了。

代码块节点执行时序

实线是调用方向、虚线是结果回程:用户请求触发后,Go 后端先做模块黑白名单校验;通过后 exec 启动 Python 子进程并用 os.Pipe 传代码;Python 组装 deno run 命令、设好权限 flag 和 V8 内存上限,把任务交给 Deno;用户代码在 Wasm 虚拟机内执行;结果沿调用链原路返回,最终由 Go 封装成响应送回用户。

三、回到最初的问题

回到文章开头那个问题——低代码平台的"代码块"功能到底怎么做到安全又稳定?Coze 给的答案可以归纳成三条原则,纵深防御 + 场景分离 + 源头拦截

  • 纵深防御靠分层Go → Python → Deno → Wasm 四层嵌套,单层失守不会穿透到宿主,每一层都能独立审计。
  • 场景分离靠接口:同一 Runner 接口,生产走 Sandbox、开发走 Direct,把"零信任"和"便捷"放在两条不同路径上。
  • 源头拦截靠前置校验:模块黑白名单在代码执行前就拆掉"炸弹引信",比事后修复便宜得多。

这套组合拳的可贵之处不在任何单点,而在于它把"用户代码不可信"这个前提,铺到了每一层的设计里。

总结

Coze 的代码块节点设计为低代码平台的安全架构提供了一个完整范本:分层沙箱隔离 + 双执行器场景分离 + 模块前置校验。这些做法不只适用于代码执行场景——任何要"跑用户输入"的系统,都能从这套纵深防御里找到可以直接借鉴的工程模式。

Make Open Source Great Again!


关于十三 Tech

资深服务端研发工程师,AI 编程实践者。 专注分享真实的技术实践经验,相信 AI 是程序员的最佳搭档。 希望能和大家一起写出更优雅的代码!

联系方式569893882@qq.com GitHub@TriTechAI VX:TriTechAI(备注:十三 Tech)