# 为代理安全终端清理 ANSI 转义序列

终端输出就是输入。一旦代理可以读取命令结果，结果中的每个字节都可能试图改变代理看到的内容，或改变附近终端的行为。命令结果可能来自仓库文件、构建日志、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 超链接和回车：

```sh
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 序列。它只有在调用方先合并数据块后才能处理这些数据，因此生产版本应在多次读取之间保留状态字段。

```python
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 字符串字段同样可能包含提示注入，因此要把每个字段标记为数据，并让不可信描述与指令分开。

对于不属于自己的命令，除非命令确实需要终端行为，否则应通过管道运行，而不是使用伪终端。伪终端会促使程序显示进度条、重写光标、设置标题，并走不同的代码路径。管道并不会让输出变得安全，但可以减少你必须处理的终端协议数量。

随结果记录来源信息。代理和审核者应能看到调用的可执行文件、工作目录、退出代码、来源流、捕获限制，以及清理器是否移除了内容。不要让工具输出冒充这些字段。应将收集器生成的元数据放在不可信文本之外的独立封装中。

一个有用的封装可以如下所示：

```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` 的工具。这个工具会把安全边界变成一道减速带。攻击者只需让清理摘要看起来足够令人困惑，代理就可能请求原始内容。

应使用受限检索。让人类在安全查看器中打开制品。让专用提取器返回有界的十六进制范围、摘要比较结果，或在使用同一个解析器后返回可打印文本。如果工作流确实需要检查原始协议，就要求明确的人工决定，并使用不会执行终端控制符的查看器。

分别制定原始内容和清理内容的保留策略。原始输出可能包含秘密和恶意负载，因此需要更短的保留时间。清理后的记录可能仍对代理会话复盘有用，但其中依然包含业务数据。使用哈希连接两条记录，避免普通用户必须取回原始字节。

第一个实现任务不是编写一条提示规则，告诉代理忽略终端指令。应在进程输出和所有面向代理的记录之间放置字节解析器，让未知控制语法消失，只在普通代理无法获取的地方保留原始内容。这样可以在模型、终端或疲惫的操作员需要对此进行判断之前，消除一整类歧义。
