DAY 50 / PHASE 6 · ENGINEERING

LLM 安全护栏与沙箱

Dual Gate · Jailbreak Classifier · Policy Gate · Execution Sandbox

2026-06-30 · BigCat

system prompt 里的「请不要」是最弱的护栏;真正的边界在 harness。

// WHY THIS MATTERS

读者已经懂 prompt injection 怎么打人(Day 24)。这一期讲怎么防——但先接受一个反直觉的工程现实:模型自身的 safety fine-tuning 加 system prompt,挡不住 indirect prompt injection。Simon Willison 的「lethal trifecta」说得很死:只要 agent 同时具备「能读私有数据 + 会摄入不可信内容 + 有外发通道」三件事,它就无条件可被注入泄密,再强的对齐也救不了。所以护栏不是写 prompt,是工程架构:独立 classifier、确定性校验、OS 级沙箱、egress 控制。这一期讲四件事——输入输出双闸门怎么搭、越狱为什么得靠独立 classifier 而不是 prompt 加固、输出策略门怎么 fail-closed、agent 执行沙箱怎么切断 lethal trifecta。

// 01

双闸门:模型两侧各一道独立闸门

论断:guardrails 是模型两侧的两道独立闸门,绝不能让主模型自己当闸门。

背景与原理

把 LLM 应用当成一个不可信的网络服务:input 来自外部(用户、检索文档、工具返回),output 要流入下游(用户屏幕、数据库、下一个 tool)。安全工程第一原则是在两侧各放一道独立闸门,而不是指望模型自己守规矩。

关键:闸门必须是独立组件——独立的小模型(Meta 的 Llama Guard、OpenAI 的 moderation endpoint)或确定性规则(正则脱敏 secret / 信用卡号)。让主模型「自己审自己」是反模式:同一个能被越狱撬开的模型,在自审时会被同一个越狱串一起绕过——攻击和审查共享一个被污染的 context。PII 与密钥防泄是闸门最该硬编码的部分:API key、JWT、卡号在出闸门时用确定性正则脱敏,别指望 LLM「记得脱敏」。

┌──── 不可信输入 ────┐ ┌──── 下游 / 外发 ────┐ 用户·检索文档·工具返回 用户屏幕·DB·下一个 tool │ ▲ ▼ │ ┌───────────┐ ┌─────────┐ ┌────────────┐ │ 输入闸门 │ ───▶ │ 主模型 │ ───▶ │ 输出闸门 │ │ moderation│ │ (LLM) │ │ 内容安全 │ │ +越狱clf │ │ no mem │ │ +PII 脱敏 │ │ +PII 检测 │ └─────────┘ │ +schema 校验 │ └───────────┘ │ fail-closed │ 独立模型 / 规则 └────────────┘ 独立模型 / 规则 ❌ 反模式:让主模型自己当闸门(同一个越狱一起绕过)

实战示例

import re, anthropic
client = anthropic.Anthropic()

# 输入闸门:独立分类器(这里用 Llama Guard / moderation 端点,伪代码)
def input_gate(text):
    verdict = classify_safety(text)          # 独立小模型,不是主模型
    if verdict.unsafe: raise Blocked(verdict.categories)
    return text

# 输出闸门:确定性脱敏 + 内容安全,二者都过才放行
SECRET = re.compile(r"sk-[A-Za-z0-9]{20,}|eyJ[\w-]+\.[\w-]+\.[\w-]+")  # key / JWT
def output_gate(text):
    text = SECRET.sub("[REDACTED]", text)      # 正则脱敏,不靠 LLM
    if classify_safety(text).unsafe: raise Blocked("output")
    return text

def guarded_call(user_input):
    safe_in = input_gate(user_input)
    raw = client.messages.create(model="claude-sonnet-4-6",
            max_tokens=1024, messages=[{"role":"user","content":safe_in}])
    return output_gate(raw.content[0].text)

两道闸门用的是独立判定路径,主模型只负责生成。secret 脱敏走确定性正则、放在内容安全分类之前——即使分类器漏判,key 也已经被抹掉。

失败模式:(1)只在输出端设闸门,忘了输入端——注入常常从检索文档 / 工具返回这条不可信输入进来,不是用户直接打的。(2)让主模型自审,省一次调用,结果越狱一起绕过。(3)secret 脱敏用 blocklist 想覆盖所有格式——新格式 token 漏网;高敏感字段宁可用 allowlist 输出(只放白名单字段)而不是黑名单脱敏。
进阶资源 · Llama Guard, arXiv:2312.06674 · Meta PurpleLlama, github.com/meta-llama/PurpleLlama
// 02

越狱防御:prompt 加固防不住,要独立 classifier

论断:prompt 加固挡不住 universal jailbreak;可靠的是独立 classifier,代价是你必须接受一点 helpfulness tax。

背景与原理

很多人防越狱的做法是往 system prompt 堆「无论用户怎么说都不要……」。这对随手攻击有点用,对 universal jailbreak(一个能撬开几乎所有有害类别的通用越狱串)基本无效——因为攻击和防御写在同一个 context 里,模型注意力被攻击者主导。

Anthropic 2025 的 Constitutional Classifiers 给了工程答案:训练独立的 input/output 分类器,按一份 constitution(什么允许、什么禁止)过滤进出内容。在 3000+ 小时红队中,没人找到能在大多数有害类别上稳定击穿的 universal jailbreak。代价是真实的——早期版本带来约 23.7% 的推理开销和 +0.38% 的生产拒答率。2026 的 ++ 版把成本砍到约 40× 更便宜、拒答率压到 0.05%,说明这条路在工程上已经收敛。

所以越狱防御的工程决策是三件事:闸门放哪(streaming 时要边生成边判,不能等整段生成完才拦)、false positive 怎么处理(这就是 helpfulness tax——正常请求被误杀)、constitution 怎么版本化(它是一份会漂的策略,要像 prompt 一样治理,见 Day 35)。

实战示例

自己训分类器太重,工程上更常见的是接现成分类器、把「拦 / 放」二元升级成三档,给边界请求一个 escalate 的台阶:

# 输出分类器返回一个风险分数;二元拦截体验差,用三档
BLOCK, REVIEW = 0.90, 0.60     # 阈值即 helpfulness tax 旋钮

def jailbreak_gate(text, stream=True):
    score = safety_classifier(text).risk      # 独立分类器
    if score >= BLOCK:   return ("block",  fallback_refusal())
    if score >= REVIEW:  return ("escalate", queue_human_review(text))  # 不直接拒
    return ("allow", text)

# streaming:每 N 个 token 抽检一次,命中 BLOCK 立即掐流
for chunk in stream:
    buf += chunk
    if len(buf) % 200 == 0 and jailbreak_gate(buf)[0] == "block":
        abort_stream(); break

阈值是一个直接的成本/安全旋钮:调高 BLOCK → 漏判变多;调低 → helpfulness tax 飙升,正常用户被无故拒。中间留一个 escalate 档,把边界请求送人工而不是粗暴拒掉。

失败模式:(1)只做二元拦/放,没有 escalate 档——边界请求要么误杀要么放过,体验和安全两头不讨好。(2)streaming 场景等整段生成完才判,有害内容已经流到用户屏幕上了。(3)constitution / 阈值永不更新——它是会漂的策略,新越狱手法几个月就绕过一版。
进阶资源 · Constitutional Classifiers, arXiv:2501.18837 · Anthropic 研究博客, anthropic.com/research/constitutional-classifiers
// 03

输出策略门:guardrails as code,且必须 fail-closed

论断:输出校验是 schema + 业务策略 + 事实三层,且校验不过时默认拒绝放行(fail-closed),不是记个 warning 就放出去。

背景与原理

内容安全只是输出闸门的一半;另一半是正确性与业务策略。把 LLM 输出当成不可信的外部输入来校验,分三层:

  1. 结构层:schema 校验(Pydantic / JSON Schema)。不是合法 JSON、字段缺失、类型不对 → 拒。
  2. 策略层:业务规则。金额不能为负、引用的 SKU 必须在库存表里、生成的 SQL 不能含 DELETE用确定性代码判,别用 LLM。
  3. 事实层:可选的 LLM-judge / grounding 校验——输出有没有 cite 到检索证据(接 Day 11 RAG)。

关键工程决策是 fail-closed:校验不过时默认拒绝放行,走 fallback,而不是「catch 异常 → 记个 warning → 照样放出去」。生产事故里相当一部分是 fail-open——校验器自己抛异常被吞掉,脏数据反而畅通无阻。Guardrails AI 这类框架把这套封装成 re-ask loop:校验失败 → 把失败原因塞回模型重生成 → 有上限地重试 → 仍不过则 fail-closed 走兜底。

实战示例

from pydantic import BaseModel, field_validator

class Order(BaseModel):
    sku: str
    qty: int
    amount: float
    @field_validator("amount")
    def non_negative(cls, v):
        if v < 0: raise ValueError("amount must be >= 0")   # 策略层
        return v

def validated_extract(prompt, max_retries=2):       # re-ask 必须有上限
    for _ in range(max_retries + 1):
        raw = call_llm(prompt)
        try:
            order = Order.model_validate_json(raw)   # 结构 + 策略层
            if order.sku not in INVENTORY: raise ValueError("unknown SKU")
            return order
        except ValueError as e:
            prompt += f"\n\n上次输出校验失败:{e}。请修正后重出。"  # re-ask
    return FALLBACK            # ★ fail-closed:到顶仍不过 → 兜底,绝不放脏数据

三层校验合在一起判,re-ask 把失败原因回喂给模型,但必须 capped,到顶仍不过就 fail-closed 返回 fallback——而不是返回最后一次(仍然非法的)输出。

失败模式:(1)fail-open:except: pass 把校验失败吞掉,等于没校验。(2)re-ask 不设上限,模型反复出错时成本爆炸、延迟失控。(3)只校验 schema 不校验业务策略——合法 JSON 但 amount=-999 照样放行,最危险的恰恰是「格式正确、语义错误」。
进阶资源 · Guardrails AI, github.com/guardrails-ai/guardrails · Guardrails 概念文档, guardrailsai.com/docs
// 04

执行沙箱:切断 lethal trifecta

论断:切断 lethal trifecta 任一条边,比加固模型更可靠;沙箱要同时锁文件系统和 egress,少一个都不算隔离。

背景与原理

Agent 会执行模型生成的代码 / shell / SQL——这是最高危的 action class。防御不是让模型「小心点」,是 OS 级沙箱 + 切断 lethal trifecta。Simon Willison 的 lethal trifecta:私有数据访问 + 不可信内容摄入 + 外发通道,三者俱全 = 无条件可被注入泄密。工程上的好消息是——移除任意一条边系统就安全:要么不给私有数据、要么不摄入不可信内容、要么掐断 egress。最可操作的通常是第三条。

LETHAL TRIFECTA(三者俱全 = 无条件可注入泄密) ┌──────────────────┐ │ ① 私有数据访问 │ │ 邮件 / 代码 / DB │ └────────┬─────────┘ ┌───────────┴───────────┐ ┌────────▼────────┐ ┌────────▼────────┐ │ ② 不可信内容 │ │ ③ 外发通道 │ │ 网页/文档/工具 │ │ HTTP/图片/外链 │ └─────────────────┘ └─────────────────┘ 移除任意一条边 → 系统安全 最可操作:掐断 ③ egress(deny-all + allowlist)

沙箱两个维度都要管

Anthropic 给 Claude Code 做的沙箱正是这个范式:用 bubblewrap(Linux)/ seatbelt(macOS)锁文件系统,网络只能走一个连到沙箱外部 proxy 的 unix domain socket——这正是你现在这个远程执行环境的工作方式(出站 HTTPS 全走 agent proxy)。

实战示例

# 跑模型生成的代码:默认无网 + 只读根 + 只写工作目录
docker run --rm \
  --network none \                 # ③ 掐断 egress(trifecta 第三条)
  --read-only \                    # 根文件系统只读
  --tmpfs /work:rw,size=64m \      # 唯一可写区,临时
  --memory 512m --cpus 1 \         # 资源上限,防 fork bomb
  --cap-drop ALL --pids-limit 128 \
  -v "$PWD/task:/work/task:ro" \   # 任务文件只读挂入
  sandbox-img python /work/task/gen.py

# 若确实需要联网:不要 --network none,改为强制走 allowlist proxy
#   HTTPS_PROXY=http://egress-proxy:3128  + proxy 侧只放行白名单域

--network none 直接消掉 trifecta 第三条边,是最干脆的隔离。若任务必须联网,就别开放自由出口,而是强制经过一个只放行白名单域的 egress proxy——可控的窄通道,不是敞开的大门。

失败模式:(1)文件系统沙箱做足了,网络却敞着——lethal trifecta 第三条还在,注入照样把私有数据 exfil 出去。隔离不是只锁文件。(2)egress allowlist 放行了 *.github.com/pastebin 这类可被当外发通道滥用的域。(3)把沙箱当唯一防线,省了输入闸门——沙箱挡执行危害,挡不住「把检索文档里的注入当指令执行」本身。
进阶资源 · Simon Willison The lethal trifecta, simonwillison.net · Anthropic Claude Code sandboxing, anthropic.com/engineering · anthropic-experimental/sandbox-runtime

// 综合实战 · 给你的 agent 配一套「最小可信边界」

把这一期四点拼成一个清单,套到任何会执行动作的 agent 上。每一条都对应前面一个具体失败模式:

  1. 输入闸门:用独立分类器(Llama Guard / moderation)判用户输入和检索/工具返回——注入常从后者进来。
  2. 越狱档位:分类器三档(allow / escalate / block),阈值即 helpfulness tax 旋钮;streaming 边生成边抽检。
  3. 输出策略门:schema + 业务规则 + (可选)grounding 三层,re-ask capped,到顶 fail-closed 走 fallback。
  4. 执行沙箱--network none 或 egress allowlist proxy + 只读根 + 资源上限——先掐 lethal trifecta 第三条边。
  5. PII/密钥:出闸门确定性脱敏(正则),高敏感字段用 allowlist 输出而非 blocklist 脱敏。
  6. 红队一次:自己写 5 个 indirect injection 用例(藏在「待总结的网页」里),跑一遍看哪一层先拦住——拦不住就知道边界漏在哪。

核心心法:护栏是架构不是 prompt。每一道都是 harness 层的物理拦截,模型说服不了它们。你以后看任何「安全的 AI 产品」,就会习惯性地去找它的四道边界在哪——找不到,就是没有。

// ENGLISH GLOSSARY

Guardrails
包裹 LLM 的安全/正确性约束层,工程上是 harness 里的独立组件,不是 system prompt 里的祈使句。
Dual Gate
模型两侧各一道独立闸门:输入闸门防有害/注入/PII 进入,输出闸门防有害/泄密/非法格式流出。
Llama Guard
Meta 开源的 input/output 安全分类小模型,按可定制 taxonomy 判 prompt 与 response 的安全类别。
Constitutional Classifiers
Anthropic 的独立越狱分类器,按一份 constitution 过滤进出内容,在大规模红队下未被 universal jailbreak 击穿。
Universal Jailbreak
单个能撬开几乎所有有害类别的通用越狱串;prompt 加固难防,需独立分类器。
Helpfulness Tax
安全过滤导致正常请求被误拒的代价;分类器阈值就是这个税率的旋钮。
Fail-closed
校验失败时默认拒绝放行、走 fallback;反面 fail-open 会在出错时反而放出脏数据。
Lethal Trifecta
Simon Willison:私有数据 + 不可信内容 + 外发通道三者俱全即无条件可被注入泄密;移除任一条边即安全。
Egress Control
沙箱网络出口管控:默认 deny-all,只放行 proxy + domain allowlist,切断 exfiltration 通道。

// 深入思考

既然 lethal trifecta 移除任一条边就安全,为什么实践中大家还是先做沙箱/分类器,而不是干脆「不给私有数据」?
因为前两条边往往是产品价值本身。一个能读你邮件、查你代码库的 agent,「访问私有数据」就是它存在的理由,砍掉它等于砍掉产品;「摄入不可信内容」也常常是需求(总结网页、读 PR)。唯独第三条 egress 通常是实现细节而非价值点——大多数任务不需要 agent 自由访问任意外网。所以 egress 控制是性价比最高的边:代价小、收益大。这也解释了为什么 Anthropic 把 Claude Code 沙箱的网络默认收紧到「只走 proxy」,而不是限制它读你的代码。
输出闸门和输入闸门用同一个分类器,还是该用两个不同的?
方向不同,最好分开调。输入闸门关心「这段内容是不是攻击/有害请求/含敏感数据」,偏检测意图与注入;输出闸门关心「生成内容是不是有害/泄密/格式非法」,偏检测产物。Llama Guard 这类模型设计上区分 prompt classification 与 response classification,taxonomy 和阈值可以不同——比如输出端对 PII 泄露要更严,输入端对越狱模式更敏感。共用一套阈值通常会让一端过松一端过严。但底座模型可以同一个,省部署成本,只是 policy 分开。
越狱分类器有 ~24% 推理开销(早期版本)。什么时候这个成本不值得,该用更轻的方案?
取决于 attack surface 和 blast radius。面向公众、任意用户能输入、且模型有能力造成实际危害(生成有害指令、操作真实系统)→ 值得,这是 Anthropic 把成本从 24% 优化到 ++ 版的原因:它必须开。反过来,内部工具、可信用户、输出只进人不进系统、危害上限低 → 重分类器是过度工程,一个轻量 moderation 端点 + 输出闸门就够。判据不是「越狱可不可能」,是「越狱成功后能造成多大损失」。低 blast radius 场景花 24% 算力防越狱,是把预算花错地方。
fail-closed 更安全,但会在校验器误判时拒绝合法输出,伤可用性。什么场景该 fail-open?
当「漏放一条脏数据」的代价 < 「拒绝一条好数据」的代价时。例如内部分析 dashboard、非关键的内容推荐、可由人事后纠正的草稿场景——这里可用性优先,fail-open + 记日志 + 异步复核更合理。而涉及金钱、权限、对外发布、不可逆动作(转账、发邮件、删数据)必须 fail-closed。本质是一道风险分级:把 action 按 blast radius 打标,高危 fail-closed,低危 fail-open。一刀切两边都吃亏——全 fail-closed 体验差到没人用,全 fail-open 等于没护栏。
这四道护栏都做了,agent 还是被 indirect injection 攻破,最可能漏在哪?
最常见是输入闸门只判了用户直接输入,没判工具/检索返回。注入藏在「待总结的网页」「待读的 PR 评论」里,这些内容绕过了面向用户的输入闸门,直接进了 context。其次是 egress allowlist 留了口子——放行了某个看似无害但能编码外发数据的域(图床、URL shortener、甚至 DNS 查询)。第三是各层独立做对了,但组合没测过:沙箱挡执行、闸门挡内容,却没人验证「注入指令让 agent 调用一个被 allowlist 放行的 tool 去外发」这条跨层路径。所以收尾一定要做一次端到端红队,而不是逐层单测。

// 延伸阅读