执行沙箱摘要在现代软件系统尤其是 AI Agent、CI/CD 平台、在线代码评测系统以及低代码平台中让不可信代码或命令安全运行是一个绕不开的核心问题。本文以Harness为例深入剖析一个执行沙箱Execution Sandbox应该如何设计如何在授权目录范围内安全地执行文件读写、命令运行与代码执行。文章将从威胁模型、目录授权模型、路径规范化、文件读写隔离、命令执行隔离、代码执行沙箱、资源限制与审计等角度给出完整的原理讲解与可运行的代码示例帮助读者构建一个可落地的最小可用沙箱实现。引言为什么需要执行沙箱设想以下几个典型场景AI Agent / Copilot用户让 Agent “帮我写个脚本跑一下”Agent 需要真的在机器上执行 Python、Shell 代码甚至读写文件。在线代码评测OJ成千上万个陌生用户提交的代码要在同一台机器上运行。CI/CD Runner流水线中的构建脚本来自不同团队可能包含恶意或误操作命令。低代码/自动化平台如 Harness Pipeline用户通过可视化方式配置脚本步骤平台负责执行。这些场景的共同点是执行者不可信但执行环境是共享的。如果没有任何隔离一段rm -rf /或open(/etc/passwd)就足以让整个系统崩溃或数据泄露。什么是执行沙箱执行沙箱是一种受控的执行环境它对被执行的代码/命令施加约束典型约束包括文件系统约束只能访问授权目录如/workspace不能越界读写。命令约束只能执行白名单命令禁止危险操作。网络约束禁止或限制外联。资源约束限制 CPU、内存、磁盘、进程数、执行时间。审计约束所有操作可记录、可追溯。本文将以Harness 风格的沙箱设计为线索从零构建一个授权目录内安全执行的模型并给出 Python 实现示例。一、威胁模型与设计目标在写代码之前我们必须先明确要防什么。1.1 威胁模型威胁类型示例后果路径穿越open(../../etc/passwd)读取敏感文件符号链接逃逸授权目录内有指向/etc的软链绕过目录限制命令注入os.system(ls; rm -rf /)任意命令执行资源耗尽while True: pass拖垮主机网络外联下载恶意载荷数据外泄权限提升通过sudo或 setuid 程序获取 root1.2 设计目标一个合格的执行沙箱应该满足最小权限只给执行者完成任务所需的最小权限。默认拒绝未明确允许的操作一律拒绝。纵深防御即使一层被绕过还有下一层兜底。可观测所有关键操作有日志。可恢复执行失败或超时后能干净地回收资源。注意语言层面的沙箱如 Python 的exec永远不是安全边界。真正的安全边界来自操作系统容器、namespace、seccomp、cgroup或虚拟机。本文的代码示例用于展示逻辑层的约束生产环境必须叠加 OS 级隔离。二、授权目录模型Workspace 抽象2.1 核心思想沙箱的一切文件操作都必须**相对于一个根目录Workspace Root**进行。所有路径在真正访问前都要经过规范化 前缀校验。/workspace - 授权根目录 ├── input/ - 只读输入 ├── output/ - 可写输出 └── tmp/ - 临时目录2.2 路径规范化与校验关键点是先解析符号链接、再比较绝对路径前缀否则..和软链都会绕过检查。importosclassPathViolationError(Exception):路径越界异常passclassWorkspace:def__init__(self,root:str,writable_subdirs(output,tmp)):# 使用 realpath 解析所有符号链接得到规范化的绝对路径self.rootos.path.realpath(root)self.writable{os.path.realpath(os.path.join(self.root,d))fordinwritable_subdirs}os.makedirs(self.root,exist_okTrue)fordinself.writable:os.makedirs(d,exist_okTrue)defresolve(self,user_path:str,write:boolFalse)-str: 将用户提供的路径解析为绝对路径并校验是否在授权范围内。 :param user_path: 用户传入的相对路径 :param write: 是否为写操作写操作还需限定在可写子目录 :return: 规范化后的绝对路径 # 1. 拼接为绝对路径不允许用户传绝对路径ifos.path.isabs(user_path):raisePathViolationError(f不允许使用绝对路径:{user_path})candidateos.path.realpath(os.path.join(self.root,user_path))# 2. 前缀校验必须以 root 开头且是路径边界ifnotself._is_within(candidate,self.root):raisePathViolationError(f路径越界:{user_path}-{candidate})# 3. 写操作额外限制ifwrite:ifnotany(self._is_within(candidate,w)forwinself.writable):raisePathViolationError(f该路径不可写:{user_path})returncandidatestaticmethoddef_is_within(path:str,parent:str)-bool:判断 path 是否在 parent 目录内含自身# 加上 os.sep 防止 /workspace-evil 被误判为 /workspace 的子路径returnpathparentorpath.startswith(parentos.sep)2.3 为什么必须用realpath考虑如下攻击ln-s/etc /workspace/output/evil如果只用os.path.abspath/workspace/output/evil/passwd看起来在授权目录内但realpath会把它解析成/etc/passwd从而被前缀校验拦截。三、文件读写隔离有了Workspace抽象文件读写就变得简单可控。3.1 安全的读操作classSandboxedFS:def__init__(self,workspace:Workspace,max_read_bytes10*1024*1024):self.wsworkspace self.max_read_bytesmax_read_bytesdefread_text(self,user_path:str,encodingutf-8)-str:real_pathself.ws.resolve(user_path,writeFalse)ifnotos.path.isfile(real_path):raiseFileNotFoundError(user_path)# 防止读取超大文件导致 OOMsizeos.path.getsize(real_path)ifsizeself.max_read_bytes:raiseValueError(f文件过大:{size}bytes)withopen(real_path,r,encodingencoding)asf:returnf.read()3.2 安全的写操作写操作除了路径校验还要注意避免 TOCTOUTime-of-Check to Time-of-Use竞态。简单做法是以O_NOFOLLOW打开文件禁止跟随软链。importosclassSandboxedFS:# ... 省略 read_text ...defwrite_text(self,user_path:str,content:str,encodingutf-8):real_pathself.ws.resolve(user_path,writeTrue)# 确保父目录存在parentos.path.dirname(real_path)os.makedirs(parent,exist_okTrue)# O_NOFOLLOW: 若目标本身是软链则报错避免写到链接指向的位置flagsos.O_WRONLY|os.O_CREAT|os.O_TRUNC|os.O_NOFOLLOW fdos.open(real_path,flags,0o600)try:withos.fdopen(fd,w,encodingencoding)asf:f.write(content)exceptException:os.close(fd)raise3.3 使用示例wsWorkspace(/tmp/sandbox_root)fsSandboxedFS(ws)fs.write_text(output/hello.txt,hello sandbox)print(fs.read_text(output/hello.txt))# hello sandbox# 以下调用会被拒绝try:fs.read_text(../../etc/passwd)exceptPathViolationErrorase:print(拦截:,e)try:fs.write_text(input/x.txt,data)# input 不在可写列表exceptPathViolationErrorase:print(拦截:,e)四、命令执行隔离4.1 危险做法 vs 安全做法# ❌ 危险shellTrue 会导致命令注入os.system(fcat{user_input})# ❌ 危险拼接字符串subprocess.run(fls{user_input},shellTrue)# ✅ 安全参数列表 shellFalsesubprocess.run([ls,user_input],shellFalse)4.2 白名单命令 参数校验importsubprocessimportshlex ALLOWED_COMMANDS{ls,cat,echo,python3,grep}classCommandSandbox:def__init__(self,workspace:Workspace,timeout10):self.wsworkspace self.timeouttimeoutdefrun(self,argv:list,cwd_rel:str.,env:dictNone):ifnotargv:raiseValueError(空命令)programos.path.basename(argv[0])ifprogramnotinALLOWED_COMMANDS:raisePermissionError(f命令未授权:{program})# cwd 也必须在 workspace 内cwdself.ws.resolve(cwd_rel,writeFalse)# 构造最小环境变量避免泄露宿主机信息safe_env{PATH:/usr/bin:/bin,HOME:cwd,LANG:C.UTF-8,}ifenv:safe_env.update(env)try:resultsubprocess.run(argv,cwdcwd,envsafe_env,shellFalse,# 关键禁用 shellcapture_outputTrue,textTrue,timeoutself.timeout,# 关键超时控制checkFalse,)returnresult.returncode,result.stdout,result.stderrexceptsubprocess.TimeoutExpired:return-1,,执行超时4.3 使用示例cmdCommandSandbox(ws)code,out,errcmd.run([echo,hello],cwd_reloutput)print(code,out)# 尝试注入会被拒绝try:cmd.run([rm,-rf,/],cwd_reloutput)exceptPermissionErrorase:print(拦截:,e)4.4 仍需注意的问题参数中的路径cat ../../etc/passwd仍然能读文件所以命令参数中的路径也要过一遍Workspace.resolve或干脆禁止用户传路径参数。命令本身的逃逸python3允许执行任意代码因此它必须交给下一节的代码执行沙箱处理。真正的隔离生产环境应结合chroot、bubblewrap、firejail、Docker、seccomp等。五、代码执行沙箱5.1 为什么不能只靠execPython 的exec/eval在同一进程内运行攻击者可以通过__builtins__、__import__、().__class__.__bases__等逃逸。同进程内没有真正的沙箱。安全做法子进程 受限环境 资源限制。5.2 基于子进程的代码执行importsubprocessimporttempfileimportosclassCodeSandbox:def__init__(self,workspace:Workspace,timeout5,max_memory_mb256):self.wsworkspace self.timeouttimeout self.max_memory_mbmax_memory_mbdefrun_python(self,code:str,stdin:str)-dict:# 将用户代码写入 workspace 内的临时文件script_reltmp/user_script.pyreal_scriptself.ws.resolve(script_rel,writeTrue)os.makedirs(os.path.dirname(real_script),exist_okTrue)withopen(real_script,w,encodingutf-8)asf:f.write(code)# 使用 prlimitLinux限制内存与进程数cmd[prlimit,f--as{self.max_memory_mb*1024*1024},# 地址空间--nproc32,# 进程数--nofile64,# 文件描述符python3,-I,-S,real_script,# -I: 隔离模式]try:resultsubprocess.run(cmd,cwdself.ws.root,inputstdin,capture_outputTrue,textTrue,timeoutself.timeout,shellFalse,env{PATH:/usr/bin:/bin,HOME:self.ws.root},)return{returncode:result.returncode,stdout:result.stdout,stderr:result.stderr,}exceptsubprocess.TimeoutExpired:return{returncode:-1,stdout:,stderr:执行超时}5.3 使用示例code_sbCodeSandbox(ws,timeout3,max_memory_mb128)# 正常代码print(code_sb.run_python(print(hello from sandbox)))# 死循环会被超时杀掉print(code_sb.run_python(while True: pass))# 内存炸弹会被 prlimit 拦截print(code_sb.run_python(x a * (10**9)))5.4 关键参数说明参数作用python3 -I隔离模式忽略环境变量、不加载用户 site-packagespython3 -S不加载 site 模块减少攻击面prlimit --as限制虚拟内存防止内存炸弹prlimit --nproc限制进程数防止 fork 炸弹timeout防止死循环cwdworkspace.root保证相对路径落在授权目录5.5 进一步加固生产建议# 使用 bubblewrap 做文件系统隔离bwrap\--ro-bind /usr /usr\--ro-bind /lib /lib\--bind/tmp/sandbox_root /workspace\--chdir/workspace\--unshare-net\--unshare-pid\--die-with-parent\python3 /workspace/tmp/user_script.pybwrap会创建新的 mount namespace让沙箱内根本看不到宿主机其他目录这比路径校验强得多。六、资源限制与审计6.1 资源限制清单资源手段说明CPU 时间resource.setrlimit(RLIMIT_CPU)/ cgroup防死循环内存RLIMIT_AS/ cgroup防内存炸弹进程数RLIMIT_NPROC防 fork 炸弹文件大小RLIMIT_FSIZE防磁盘写满文件描述符RLIMIT_NOFILE防耗尽 fd网络net namespace / iptables防外联6.2 审计日志每次执行都应该记录谁、什么时候、在哪个目录、执行了什么、结果如何。importjsonimporttimeimportlogging loggerlogging.getLogger(sandbox.audit)defaudit(event:str,**kwargs):record{ts:time.time(),event:event,**kwargs,}logger.info(json.dumps(record,ensure_asciiFalse))# 使用audit(file_read,pathoutput/hello.txt,useralice,size12)audit(cmd_run,argv[ls,-l],cwdoutput,returncode0)audit(code_run,langpython,duration0.42,returncode0)审计日志的价值在于事后追溯出现安全事件时能定位。行为分析发现异常模式如频繁越界尝试。合规要求很多行业标准强制要求操作留痕。七、把它们组装起来下面是一个完整的、可运行的沙箱示例把所有组件串起来。importosimportsubprocessimportlogging logging.basicConfig(levellogging.INFO,format%(message)s)classExecutionSandbox: