为代理安全终端清理 ANSI 转义序列
ANSI 转义序列清理可以在保留有用命令结果的同时,将终端控制符、OSC 负载和光标操作排除在 AI 代理上下文之外。

终端输出就是输入。一旦代理可以读取命令结果,结果中的每个字节都可能试图改变代理看到的内容,或改变附近终端的行为。命令结果可能来自仓库文件、构建日志、SSH 欢迎信息或 API 响应。因为结果由你自己的命令产生,就把 stdout 当成可信数据,这是一个错误。命令经常只是转发了别人控制的数据。
ANSI 转义序列清理应放在工具输出进入代理上下文的边界上。模型收到文本之前、人工打开实时记录之前,以及会话日志可以被重新播放之前,都应完成清理。等模型已经读到颜色代码后再移除它们,只是在清理痕迹。
这并不意味着要把每个命令结果都变成毫无信息的文本块。代理需要有用的诊断信息。任务是保留输出的语义内容,同时拒绝让终端指令、光标移动、剪贴板请求、超链接和其他控制流量跨越信任边界。
终端记录是不可信输入
Shell 命令并不拥有自己的输出。git log 会打印提交信息。编译器会打印路径和源代码片段。测试运行器可能重复输出测试夹具。SSH 客户端会在显示提示符前展示服务器欢迎信息。软件包管理器会从远程仓库读取名称、版本、元数据和错误消息。在这些情况下,外部人员都可能影响随后进入代理的数据。
常见的故障始于一个看似方便的做法:捕获 stdout 和 stderr,将它们拼接起来,再把结果追加到代理对话中。这样一来,输出承担了两项任务。它既要报告程序结果,又要在语言模型上下文中充当指令。终端控制符会让第一项任务变得不可靠,而类似提示词的内容会让第二项任务变得危险。
设想某个仓库包含一个文件名,其中带有回车和转义序列。列出该文件的命令可能生成一段会覆盖自身前缀的显示内容。审核者看到的是一个无害路径,原始字节流里却包含另一种内容。如果工具随后不显示字节边界,也不应用控制策略,就直接把这段字节流变成代理消息,代理接收到的就是一个含义不明确的制品。
威胁模型不应只考虑恶意的仓库贡献者。构建制品、依赖项的堆栈跟踪、网络设备、数据库错误和远程命令输出都会跨越同一个边界。被入侵的服务器不需要在代理主机上获得 Shell 权限,它只需返回一段精心制作的欢迎信息,就能污染记录。
这里有两个不同的风险:
- 人类查看原始输出时,终端模拟器可能执行控制指令。
- 代理可能把可见文本、隐藏文本或重新排序后的文本当作指令,而不是数据。
干净的代理记录可以降低第二种风险。不解释终端控制符的查看器可以降低第一种风险。人类需要检查捕获的输出时,两者都不可少。
终端控制符不只是添加颜色
终端控制序列可以移动光标、擦除已有文本、设置窗口标题、创建可点击链接、写入剪贴板、查询终端,或向终端功能发送数据。SGR 颜色只是最常见的一部分。只移除 ESC[ 后跟数字和 m 的过滤器,只能处理颜色,功能更强的序列仍会保留下来。
ECMA-48 定义了控制功能和 CSI 序列的一般形式。CSI 通常以 ESC 后跟 [ 开始,之后是参数字节、中间字节和最终字节。它也允许使用 0x80 到 0x9f 范围内的单字节 C1 形式。终端模拟器还会在该标准之上加入私有行为。xterm 的控制序列文档描述了 OSC、DCS、APC、PM 和 SOS 字符串,其中包括与 CSI 序列不同的终止方式。
理解这种语法很重要,因为控制符不一定会作为整齐的一行文本到达。程序可能在一个数据块中写入 ESC,再在下一个数据块中写入 [。伪终端可以在任意字节处分割输出。工具可能发出以 BEL 结束的 OSC 字符串,也可能使用双字节 ST 形式,即 ESC 后跟反斜杠。正确的边界组件必须在多次读取之间保留状态。
下面几个例子说明了为什么只移除颜色是不够的:
ESC[2J要求终端擦除显示内容。ESC[H将光标移动到主位置。ESC]8;;URI ESC\\会在支持该功能的终端中开始一个 OSC 8 超链接。ESC]52;... BEL是许多终端模拟器能够识别的剪贴板操作。- 回车会把光标移回第 0 列,并可能覆盖之前的状态行。
某个终端是否支持某个序列,并不是它是否重要的必要条件。输出收集器无法预测工程师半年后会使用哪种终端、哪个查看器会重放日志,或哪个解析器会把控制字节变成可见令牌。在收集时就消除歧义。
转义序列之外的控制字符也需要注意。退格、回车、换页、响铃以及许多 C1 字节,都可能改变显示方式或干扰按行解析。只保留输出协议需要的控制符,通常是换行,也可以是制表符。只有当解析器为回车定义了明确且经过测试的含义时,才保留回车。
在文本到达模型之前移除控制符
最安全的默认流程很简单:收集字节,限制大小,以字节为单位解析终端控制符,丢弃控制指令,按照明确的错误策略解码剩余可打印内容,然后将清理后的文本发送给代理。不要先解码,再指望 Unicode 清理例程找出危险内容。ESC 是 ASCII 字节,C1 控制符可能直接出现,而格式错误的字节序列不能导致收集器跳过过滤。
即使只向代理公开一个结果,一个实用的输出路径也应保留四类记录。将原始字节流放在受保护的存储中以便调查。为模型上下文生成规范化的纯文本。生成一份移除报告供操作员和日志使用。保存退出状态、持续时间、是否截断、字节数以及原始内容的加密摘要等元数据。
移除报告很重要。静默删除可能让需要调查问题的人看不到异常。一份有用的报告可以说明收集器移除了两个 CSI 序列、一个 OSC 字符串、三个回车和一个无效字节序列。报告不需要复现负载。在报告中重复 OSC 负载,可能再次引入同样的危险。
在合并 stdout 和 stderr 之前,分别处理两个流。程序交错写入两个流,包装器合并它们后,往往无法保持原始顺序。如果代理需要一个统一叙事,请标记两个清理后的流,并加入由收集器分配的序列号。这比伪造一个从未真正观察到的精确顺序更符合事实。
大小限制也应放在同一个组件中。攻击者可以利用永不终止的 OSC 字符串或大量重复输出来消耗内存和上下文。限制捕获字节总数,并为正在处理的控制字符串设置更小的上限。达到限制后,根据进程策略关闭或排空源,将结果标记为已截断,并确保部分原始制品不会进入代理消息。
不要让模型决定某个转义序列是否无害。模型解析的是文本,不是字节协议,而且它的判断会随周围上下文变化。应由确定性的解析器在模型看到输出之前作出决定。
保留证据,但不保留行为
清理和隔离解决的是不同问题。清理通过移除展示指令生成可用文本。隔离则让需要正当理由的人仍能检查原始字节。因为对原始捕获内容做了 Base64 编码,就把它称为“已清理”,这是把传输方式和授权混为一谈。
代理通常需要的是失败构建的前几百行,而不是终端会话的逐字节回放。向它提供规范化文本,并明确标记截断和移除。如果需要更多细节,可以让它按行号范围或搜索词请求一小段清理后的内容。不要把原始捕获内容再次粘贴进同一个上下文。
调查人员需要原始内容时,应使用面向字节的查看器,并让控制字节以可见形式显示,绝不将其发送给终端。十六进制转储很适合这项工作,因为它能清楚显示字节边界。将 ESC 替换为 ^[ 可以帮助查看,但查看器也必须正确处理 C1 字符和字符串负载。用 cat 直接打开证据的查看器不是取证工具。
下面的 Shell 命令会创建一个示例文件,而不会把其中的控制符发送到当前终端。文件包含红色文本 SGR、光标上移 CSI 序列、OSC 8 超链接和回车:
printf 'build: \033[31mFAIL\033[0m\nnotice\033[1A\033]8;;https://example.invalid\033\\open\033]8;;\033\\\rPASS\n' > terminal-sample.bin
od -An -tx1c terminal-sample.bin
od 的输出应包含表示 ESC 的 1b 和表示回车的 0d。它绝不应让终端跟随链接或移动光标,因为 od 打印的是字节的表示形式,而不是重新播放这些字节。
隔离还需要访问控制。原始制品可能包含命令误打印出的秘密。清理终端控制符不会脱敏令牌、密码或个人数据。应将秘密检测和脱敏作为独立阶段,并为它们设置自己的误报策略。不要把两项工作合并,因为即使脱敏器漏掉了凭据,也不应由它决定 OSC 52 请求是否可以保留。
正则表达式不是终端解析器
正则表达式很受欢迎,因为它可以用一行代码移除彩色构建输出。但它不适合作为安全边界,因为终端语法具有状态,并且是流式的。许多模式在很长的格式错误输入上还会出现性能问题,而攻击者恰好可以提供这种输入。
应使用字节状态机,明确处理 ESC、CSI 和字符串控制符。下面的 Python 函数范围有意保持狭窄。它保留制表符和换行,将回车转换为可见换行策略,丢弃其他 C0 和 C1 控制符,并移除包含 OSC、DCS、APC、PM 和 SOS 字符串在内的 ESC 序列。它只有在调用方先合并数据块后才能处理这些数据,因此生产版本应在多次读取之间保留状态字段。
def clean_terminal_bytes(data: bytes) -> tuple[str, dict[str, int]]:
out = bytearray()
counts = {"esc": 0, "csi": 0, "string": 0, "control": 0}
i = 0
while i < len(data):
b = data[i]
if b == 0x1b: # ESC
counts["esc"] += 1
i += 1
if i >= len(data):
break
nxt = data[i]
if nxt == ord('['): # CSI
counts["csi"] += 1
i += 1
while i < len(data):
c = data[i]
i += 1
if 0x40 <= c <= 0x7e:
break
continue
if nxt in b']P_^X': # OSC, DCS, APC, PM, SOS
counts["string"] += 1
i += 1
while i < len(data):
c = data[i]
if c == 0x07: # BEL
i += 1
break
if c == 0x1b and i + 1 < len(data) and data[i + 1] == ord('\\'):
i += 2
break
i += 1
continue
i += 1 # Two-byte ESC function or unknown ESC form
continue
if b == 0x9b: # Single-byte C1 CSI
counts["csi"] += 1
i += 1
while i < len(data):
c = data[i]
i += 1
if 0x40 <= c <= 0x7e:
break
continue
if 0x80 <= b <= 0x9f or b < 0x20 and b not in (0x09, 0x0a):
counts["control"] += 1
i += 1
continue
out.append(b)
i += 1
return out.decode("utf-8", errors="replace"), counts
这个例子有局限。它没有模拟每一种 ECMA-48 控制功能,并且会将未知 ESC 形式视为应移除内容。在输出进入代理上下文时,这种保守做法是合适的。终端模拟器需要广泛兼容,代理边界则需要尽可能小的可接受范围。
不要复制这个函数后就宣布任务完成。为 CSI 参数和字符串控制符增加最大长度。让解析器状态跨越读取边界。统计格式错误和未终止的序列。最重要的是,编写测试来确认原始负载绝不会出现在清理结果中。安全过滤器需要负面测试,而不只是漂亮的前后对比截图。
OSC 序列需要特别处理
许多团队正是在处理 OSC 字符串时才发现,终端输出携带的不只是格式。xterm 文档记录了窗口标题和超链接等 OSC 命令。现代终端模拟器支持的子集各不相同,因此基于当前本地终端建立的允许列表并不是可靠选择。
OSC 8 链接可以把看似无害的文字变成可点击的目标。可见标签可能写着 build report,目标却指向别处。人类查看清理后的代理记录时,不需要一个可点击的链接。如果可以安全解析,就将标签保留为普通文本;否则移除整个 OSC 包装,只保留后续可打印字节。除非产品另有 URL 验证和展示策略,否则不要保留 URI 目标。
OSC 52 可以要求终端设置剪贴板内容。有些终端会禁用它,或要求用户开启某项设置,但收集器不能依赖这些行为。如果原始日志被重新播放到一个宽松的终端中,命令就可能把攻击者选择的内容放进操作员剪贴板。下一次粘贴可能发生在 Shell、工单、聊天窗口或凭据字段中。
设置标题的序列会带来一个更隐蔽的问题。它们可以修改终端复用器、操作系统任务切换器或录制内容中显示的窗口标题。一个看起来像批准请求或部署成功的标题,可能误导同时查看许多窗口的操作员。移除所有 OSC 流量可以消除整类问题,不必维护一份会逐渐过时的列表。
不要为了帮助模型理解而保留 OSC 字符串。代理没有正当理由去执行终端标题更新、点击终端超链接或接收剪贴板操作。如果命令结果包含有用的 URL 文本,应让命令以普通文本打印 URL,或者在进入终端路径前从结构化响应中提取它。
同样的规则适用于设备控制字符串和应用程序命令。有些模拟器会忽略它们,另一些模拟器可能随时间增加行为。输出边界应采用默认拒绝策略:一旦识别出控制字符串引入符,就处理到有效终止符,或处理到配置的最大长度,记录异常,并将负载排除在普通上下文之外。
为每个工具定义输出协议
如果一个终端记录同时承担数据库、报告和用户界面,清理器无法修复这种输出接口。工具应明确它们返回给代理的内容:纯 UTF-8 文本、在非终端通道上生成的结构化 JSON,或隔离制品的引用。应让类终端输出成为例外,而不是默认的交换格式。
对于自己拥有的命令,在代理调用时关闭装饰效果。许多程序提供禁用颜色的选项、机器可读模式或环境设置。只有在你控制其模式并限制其大小时,才优先使用结构化输出。JSON 字符串字段同样可能包含提示注入,因此要把每个字段标记为数据,并让不可信描述与指令分开。
对于不属于自己的命令,除非命令确实需要终端行为,否则应通过管道运行,而不是使用伪终端。伪终端会促使程序显示进度条、重写光标、设置标题,并走不同的代码路径。管道并不会让输出变得安全,但可以减少你必须处理的终端协议数量。
随结果记录来源信息。代理和审核者应能看到调用的可执行文件、工作目录、退出代码、来源流、捕获限制,以及清理器是否移除了内容。不要让工具输出冒充这些字段。应将收集器生成的元数据放在不可信文本之外的独立封装中。
一个有用的封装可以如下所示:
{
"command": "test-runner --report plain",
"exit_code": 1,
"stdout": "142 tests passed\n",
"stderr": "fixture failed at tests/login.txt:18\n",
"sanitizer": {"removed_controls": 4, "truncated": false},
"raw_artifact": "restricted:sha256:..."
}
raw_artifact 字段应当只是一个普通代理无法解引用的引用。如果你在没有新的授权决定时就能把内容按请求返回,那么这个引用只是装饰。
在日常 Shell 之外测试恶意输出
能够通过彩色代码单元测试的清理器,才刚刚开始。测试会跨越解析状态、意外终止并与显示控制符交互的字节。让测试数据经过代理实际使用的完整捕获路径,包括进程启动、缓冲、日志存储和记录渲染。
先建立一个小型语料库,其中包含一个数据块末尾的 ESC 字节,以及下一个数据块开头的 [。再加入参数长度异常的 CSI 序列、分别以 BEL 和 ST 终止的 OSC 字符串、没有终止符的 OSC 字符串、C1 CSI 字节、退格、重复回车,以及转义序列周围的无效 UTF-8。断言面向代理的文本不包含 ESC、不包含 C1 范围字节,也不包含被移除字符串的负载。
然后测试显示语义。将类似 working 10%\rworking 20%\rfailure 的状态行通过你选择的回车策略。如果将它保留为换行,代理会看到完整历史。如果模拟覆盖,代理只会看到 failure。两种策略都可以,但必须记录并测试它。解析器更新时悄悄改变行为,会让事件复盘变得困难。
模糊测试在这里很有价值,因为转义语法的状态很少,而格式错误输入的空间极大。生成随机字节流,重点加入 ESC、BEL、反斜杠、C1 字节和很长的未终止字符串。需要验证的属性很直接:过滤器在时间和内存预算内结束,不抛出异常,并且输出不含被禁止的控制字节。
也要测试人类使用的路径。日志页面、桌面通知、终端窗格和复制出的记录,可能以不同方式解释内容。只在安全的字节查看器中渲染原始语料。在所有普通界面中渲染清理后的文本。如果工程师在日常调查中可以把原始日志条目复制到交互式终端,说明你的隔离边界存在漏洞。
人工批准不会清理记录
批准提示决定代理是否可以执行某项操作,但不能决定该操作返回的字节是否适合显示或放入模型提示词。应将这两类控制分开,否则人们会误以为批准 ssh host command 也等于认可主机返回的每条欢迎信息、文件名和远程错误。
这一区别对操作网关很重要。Sallyport 可以让凭据远离代理,并要求人工授权操作,但任何将操作结果发送给代理的集成,仍需要输出规范化边界。凭据保管和记录安全回答的是两个不同的问题。
不要用反复审批来弥补薄弱的输出处理。要求每次读取都批准,并不会让人检查长响应中的每个字符,而且响应可能在批准之后才到达。对敏感操作逐次审核有其用途,但它不会把不可信输出变成可信上下文。
将决定记录放在清理结果旁边。记录操作员批准了某个进程或调用,再将实际命令结果记录为带有清理报告的不可信工具数据。这样的分离能帮助事件调查分别回答两个问题,而不是把它们混在一起:谁授权了操作,以及该操作返回了什么数据?
原始捕获内容必须位于普通代理访问范围之外
一个常见的逃生口会破坏原本合理的设计:当清理后的结果看起来不完整时,给代理一个名为 read_raw_output 的工具。这个工具会把安全边界变成一道减速带。攻击者只需让清理摘要看起来足够令人困惑,代理就可能请求原始内容。
应使用受限检索。让人类在安全查看器中打开制品。让专用提取器返回有界的十六进制范围、摘要比较结果,或在使用同一个解析器后返回可打印文本。如果工作流确实需要检查原始协议,就要求明确的人工决定,并使用不会执行终端控制符的查看器。
分别制定原始内容和清理内容的保留策略。原始输出可能包含秘密和恶意负载,因此需要更短的保留时间。清理后的记录可能仍对代理会话复盘有用,但其中依然包含业务数据。使用哈希连接两条记录,避免普通用户必须取回原始字节。
第一个实现任务不是编写一条提示规则,告诉代理忽略终端指令。应在进程输出和所有面向代理的记录之间放置字节解析器,让未知控制语法消失,只在普通代理无法获取的地方保留原始内容。这样可以在模型、终端或疲惫的操作员需要对此进行判断之前,消除一整类歧义。
常见问题
我只需要移除 ANSI 转义码吗?
不需要。ANSI 通常是 ECMA-48 控制序列的宽泛说法,但终端模拟器还支持私有扩展。OSC 命令、设备控制字符串、C1 控制字符和模拟器专用序列都应纳入检查范围。
怎样在保护 AI 代理的同时保留终端颜色?
如果由可信渲染器在代理处理纯文本之后再应用颜色,就可以保留颜色。不要为了保留外观而让原始 SGR 序列继续传递。安全顺序应是先解析并移除控制符,再为人类查看者选择性地渲染受限的展示层。
经过 Base64 编码的命令输出可以安全地交给代理检查吗?
在解析器完成规范化之前,应将它视为不安全数据。Base64 只能防止字节在传输过程中被终端解释,代理仍然可以解码它并还原原始控制流。除非特定调查确实需要,否则不要把原始数据放进代理上下文。
模型没有终端模拟器,转义序列为什么仍然重要?
终端模拟器会应用这些控制符,而代理可能把它们当作普通文本、解码后的令牌或清理后的记录来读取。不同使用者有不同的故障模式。清理可以保护代理上下文,安全查看器则保护人类操作员使用的终端。
什么时候应该清理输出,什么时候应该将其隔离?
当代理需要状态、诊断信息和普通命令结果时,默认应清理输出。当原始字节对调试、事件调查很重要,或某个工具的输出无法安全缩减时,应将其隔离。不要把原始终端窗格当作隔离机制。
为什么正则表达式不足以移除终端转义序列?
当序列跨越数据块、使用 C1 形式、包含 OSC 负载,或以 ST 而不是 BEL 结束时,大多数正则表达式都会失效。小型状态机更容易审计,因为它明确列出了状态和终止规则。应向它提供字节,而不是预先解码的文本。
终端注入能通过代理会话记录或日志持续存在吗?
在共享上下文中,终端注入可以像误导当前代理一样误导下一个代理进程。会话日志需要和实时工具输出使用相同的规范化规则。如果调查人员需要保留字节级信息,应另外保存受保护的原始记录。
人工批准提示能防止终端输出中的提示注入吗?
批准只能证明某个人允许执行某项操作,不能让操作返回的字节变得可信。获批的命令仍可能读取恶意仓库内容、远程欢迎信息、软件包元数据或日志行。每次获批操作完成后都要处理其输出。
如何保留原始命令输出以便取证?
将原始字节存放在受访问控制保护的制品存储中,或存入加密审计记录,然后只把引用和清理后的摘要交给代理。随制品记录字节长度、摘要、解析器版本和移除数量。这样调查人员可以重建事件,同时不会把恶意字节交给日常代理运行。
代理终端清理器应包含哪些测试用例?
测试分片的序列、OSC 8 链接、OSC 52 剪贴板请求、回车覆盖、退格、C1 控制字节、无效 UTF-8 和未终止字符串。让同一组测试数据分别经过管道和伪终端,因为工具检测到 TTY 后经常会改变行为。只能处理彩色编译器错误的清理器还不值得信任。