一句话价值: Agent 长出了脑、手、神经,还能派活。但从"它会跑命令、会写文件"那一刻起,安全就从"可选优化"变成了"第一道墙"------因为调用它的模型是不可信的,它可能写越权路径、跑危险命令、甚至把整个系统搞挂。这一篇讲 Agent 的安全怎么分层布防:从最轻的路径校验,到命令拦截,再到进程隔离和沙箱。
先看:Agent 的手,就是风险的入口
回到 run_bash------它给 Agent 的能力是"执行任意 shell 命令"。这在功能上是天大的方便,在安全上也是天大的洞:
- 模型可能读
../../etc/passwd,把系统文件偷出去(路径逃逸); - 模型可能跑
rm -rf /或sudo ...,把机器搞挂(危险命令); - 模型可能写一个死循环脚本,把 CPU 吃满(资源耗尽);
- 模型可能同时派多个子 Agent 改同一个文件,互相覆盖(并发竞态)。
应对这些风险,没有"一招鲜",只能分层布防------就像给 Agent 戴上一层层手套。从最轻的开始。
第一层:safe_path------路径逃逸的第一道闸
所有文件操作(读/写/编辑)都经过 safe_path,它是这个 demo 里最底层的安全设施:
python
WORKDIR = Path.cwd()
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve() # ① 拼接 + 解析成绝对路径
if not path.is_relative_to(WORKDIR): # ② 检查是否还待在工作目录里
raise ValueError(f"Path escapes workspace: {p}")
return path
两行代码,拆开看每一行都在挡一件事:
① (WORKDIR / p).resolve() ------先看那个 / 运算符。它不是除法,而是 pathlib.Path 重载的路径拼接 :WORKDIR / "a.txt" 得到 工作目录/a.txt。然后 .resolve() 把路径解析成绝对路径,并且把 .. 这类相对跳转也一并算掉。
② is_relative_to(WORKDIR) ------判断解析后的绝对路径,是否还位于工作目录之内 。如果模型传了 ../../etc/passwd,resolve() 之后这个路径会跳出一层又一层,最终落到工作目录外面,is_relative_to 返回 False,于是抛 ValueError。
这个 ValueError 被工具函数自己的 except Exception 接住,转成 "Error: Path escapes workspace" 喂回模型------模型才知道"这条路径越界了,换个合法的"。
safe_path 之所以重要,是因为它是所有文件工具的公共地基 :run_read、run_write、run_edit 全都要先过它,谁也别想绕过。这正是"第一道闸"的价值------放在最前面,后面所有工具都天然安全。
第二层:命令黑名单 + 超时
safe_path 管住了文件,但 run_bash 能跑任意命令,路径校验管不到它。所以 run_bash 有自己的防线:
python
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
any(...) 只要命中一个危险关键词就拦截。这是黑名单思路------列出一批明确危险的东西,命中即拦。
再配一道超时,防死循环命令:
python
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True,
errors="replace", timeout=120, check=False)
timeout=120:命令跑 120 秒没结束就掐断,防"死循环把程序拖死";cwd=WORKDIR:命令的工作目录锁在工作目录内;check=False:命令返回非 0 不抛异常,把退出码和输出一起交回,让模型知道"失败了,换个命令"。
但要诚实地说 :黑名单是最弱 的一层防护。它只能挡"名单上列出来的",攻击者换个写法(比如 rm -rf .、或用变量拼接绕过关键词)就绕过去了。所以它只能当"第一道过滤",不能当"唯一防线"。真正的硬隔离在下一层。
第三层:进程隔离与沙箱(从"目录"到"容器")
如果 Agent 要跑不可信代码 (比如让它执行任意 bash、跑用户上传的脚本),前两层就不够了------因为黑名单挡不住所有花样,路径校验挡不住"命令直接读系统文件"。这时需要沙箱:把 Agent 关进一个独立环境里,让它想乱来也够不着系统。
沙箱要隔离的是三个维度,别混为一谈:
| 维度 | 隔离什么 | 靠什么实现 |
|---|---|---|
| 进程隔离 | 内存、变量、崩溃不牵连 | subprocess 子进程(独立内存空间) |
| 文件系统沙箱 | 能读/写哪些文件 | 独立目录 + OS 沙箱 / 容器 |
| 资源限制 | CPU、内存、时间、进程数 | rlimit / cgroups / 容器 |
按"从轻到重"分三层,逐层加码:
3.1 独立工作目录(最轻,先做这个)
给每个 Agent 一个临时目录,它只能在自己的目录里活动:
python
import tempfile, shutil
def run_subagent_isolated(prompt: str) -> str:
workdir = Path(tempfile.mkdtemp(prefix="agent-")) # 每个 Agent 独一份
try:
... # 子进程里 WORKDIR 指向这个目录
finally:
shutil.rmtree(workdir, ignore_errors=True) # 跑完清理
关键变化:把 safe_path 里那个写死的 WORKDIR,改成每个 Agent 启动时动态注入自己的目录。这样多个 Agent 各写各的,文件竞争从根上消失。
3.2 真正的子进程(这才是"进程隔离"本体)
demo 里的 run_subagent 其实是同一个进程里 的函数调用,只做到了"上下文隔离"。真正的进程隔离要用 subprocess 把子 Agent 作为独立进程跑起来:
python
r = subprocess.run(
[sys.executable, "child.py"],
cwd=workdir, # ① 工作目录指到它的沙箱目录
input=prompt, # ② prompt 走 stdin(比命令行参数安全,避免长度限制)
capture_output=True,
text=True,
timeout=120, # ③ 超时杀掉,防卡死
)
这一步得到:内存隔离 (子进程 crash 不牵连父进程)、可强制终止 (超时 kill)。但要记住:子进程和父进程共享同一份磁盘,如果不配合 3.1 的目录隔离,它照样能读父进程能读的文件------"进程隔离"不等于"文件沙箱",两者要叠起来。
3.3 OS 级沙箱 / 容器(最硬)
要防"命令直接读系统文件、联网、fork 炸弹",得动用操作系统级的隔离:
-
Linux :
bwrap(bubblewrap)无 root 就能把整个文件系统设只读,只挂载自己的目录可写:bashbwrap --ro-bind / / --bind ./workdir ./workdir --chdir ./workdir python child.py -
Windows(多数人的环境) :
bwrap/chroot是 Linux 专属,最实际的是 Docker(WSL2 后端):bashdocker run --rm -v "$PWD/workdir:/work" -w /work \ --memory=512m --cpus=1 --network=none \ python:3.12 python child.py一行同时搞定三个维度:
-v挂载隔离文件、--memory/--cpus限制资源、--network=none断网。
一句话选型:自己写的受控 Agent,用"独立目录 + 子进程"就够;要让 Agent 跑不可信代码,再上 Docker/容器。
延伸:多个 Agent 并发时,还有"竞态"
安全不止防"恶意",还防"意外"。当你把 Agent 改成并发(多个子 Agent 同时跑),会冒出新的问题------竞态。
最典型的是 run_edit 的"读---改---写"三步:
python
content = fp.read_text(...) # 读
content.replace(old, new, 1) # 改
fp.write_text(...) # 写
两个 Agent 同时 edit 同一个文件:
css
Agent A:读文件(内容 V0)
Agent B:读文件(内容 V0)
Agent A:改 + 写回(变成 V0+A)
Agent B:改 + 写回(基于过时的 V0,把 A 的修改覆盖掉了!)
B 的写回抹掉了 A 的修改------这就是"丢失更新"。解法还是分层:
- 锁 (
threading.Lock只对同进程线程有效,多进程要用文件锁); - 隔离目录(各写各的,从根上消灭竞争,最符合本系列"沙箱"的思路);
- 乐观并发 (提交时检查"我读的版本和现在一样吗",不一样就重试------本质是 git 的 merge 思路,而
run_edit那个"old_text找不到就报错"恰好能当重试的钩子)。
当前 demo 是单线程串行,没有竞态问题------过早加锁是过度设计。但当你把它改成真并发时,脑子里要有这根弦。
心智模型:安全是"分层手套",不是"一把锁"
把这一篇收束成一个画面:
Agent 的安全不是一道墙,而是一层层手套。最里层是路径校验(
safe_path),管住"碰哪些文件";中间是命令过滤(黑名单+超时),管住"跑什么命令";最外层是沙箱(目录→子进程→容器),管住"整体待在哪、能用多少资源"。越往外越重,也越能兜住更不可信的场景。
顺着这个模型,安全的本质就一句话:
信任模型,但限制模型的能力边界。 你不是不相信它"想做好",而是不给它"做坏"的物理可能------路径逃逸被
safe_path挡住,危险命令被黑名单拦住,系统文件被沙箱隔开。让"能做的坏事"永远小于"能兜住的底"。
本篇小结
- Agent 的手(
run_bash、文件工具)就是风险的入口,安全必须分层布防。 - 第一层
safe_path:resolve()算掉..、is_relative_to()检查越界,是所有文件工具的公共地基。 - 第二层命令黑名单 + 超时:只能当"第一道过滤",不是唯一防线,因为黑名单可被绕过。
- 第三层沙箱分三维度(进程/文件/资源)三层递进:独立目录 → 子进程 → OS 沙箱/容器,越往外越能兜不可信代码。
- 并发时还有竞态 :
run_edit的读---改---写会"丢失更新",解法是锁 / 隔离 / 乐观并发。 - 心智收束:安全是分层手套,本质是"信任模型但限制能力边界",让能做的坏事永远小于能兜住的底。
系列总回顾
到这里,从零手写一个父---子 Agent 系统这条线彻底走完了。回看五篇,就一条主线:让一个"会说话的模型",一步步长成一个"能安全办事的代理"。
| 篇 | 长出了什么 | 一句话 |
|---|---|---|
| 01 | 脑:Agent Loop | Agent 就是 while 循环,LLM 想、代码做、循环喂回 |
| 02 | 手:工具 | 工具是防御式的,把异常翻成模型能懂的话,绝不崩 |
| 03 | 神经:调度器 | 五道关卡把模型的 JSON 字符串安全变成函数调用 |
| 04 | 拆分工:父---子代理 | 上下文隔离 + 防套娃,把活派出去、把结论收回来 |
| 05 | 手套:安全 | 路径校验、命令拦截、沙箱分层,限制能力边界 |
一条清晰的演进线:
问一句答一句 → 能调工具的循环 → 能安全调工具的调度 → 能派活的父子代理 → 能兜底的安全沙箱
而这五篇共同指向一个更大的判断:Agent 的本质不是"更聪明的模型",而是"一个不可信的模型 + 一圈信任它的工程"。模型负责"想",工程负责"兜"------想错了能纠(02/03 的 Error 反馈),乱跑了能拦(05 的安全),派活派得动(04 的隔离)。你越是把"兜底"做扎实,这个 Agent 才越敢放手让它"自主"。
如果这条线再往前走,下一个题目大概是:当子 Agent 不再是"一个函数调用",而是真正独立的进程、甚至分布在多台机器上时,编排、通信、状态同步会变成什么样------那,就是多 Agent 系统(multi-agent)和更完整的编排框架(LangGraph 一类)要回答的问题了。