安全
安全模型与残余风险
Tokenhush 是一个本地、仅回环的 HTTP 网关,在机密与 PII 到达模型之前完成脱敏。作为安全工具,它首先要可以被检视。本页说明回环网关能作用于什么、开源核心强制的八条不变量、它如实记录而不是隐去的九项残余风险,以及究竟什么内容会落盘。
- 仅绑定本机回环 —— 不做 MITM、不装根证书
- 每条不变量都由具名测试钉住
- 九项残余风险,已记录并接受
诚实的说法是:高置信拦截,并把边界一并写下来——而不是「什么都不会外泄」的保证。
网关能作用于什么
Tokenhush 运行在你的机器上,位于一个 AI 编程工具与模型提供商之间,通过你指向本机回环的 base URL 接入。经过这条路径的请求,才是它能处理的请求——覆盖范围与这条路径的宽度完全一致。
构建性防护
- 仅绑定回环。 服务绑定
127.0.0.1,并在主机具备 IPv6 回环时绑定[::1];非回环绑定不可能成功。Host 允许清单始终生效, 浏览器式请求会做 Origin 校验。 - 一个控制端点、一个凭据。 控制面恰好是
GET /status,要求每次运行生成的 bearer token(以0600权限存储)。不存在修改允许清单的端点;非 GET 请求返回 JSON 405 与Allow: GET,其他 GET 返回 404。 - 故障关闭,而非故障开放。 检测器失败时拒绝该请求,而不是未脱敏地 转发;携带非 identity
Content-Encoding的请求在读取 body、 打开任何上游连接之前以 415 拒绝。 - 确定性检测。 六个检测器类别——
prefix、jwt、pem、luhn、email,以及 按需启用的entropy——全部是规则。不调用模型,不做语义猜测; 六者中五个默认开启。
明确在路径之外
- 不接受 base URL 的工具。 Cursor agent 流量、ChatGPT 与 Claude 桌面应用、浏览器 web UI 都不提供 base URL 设置;要覆盖它们需要配合根证书的 系统级拦截,而开源核心未实现。
- 编码后的 secret。 base64、hex 或 URL 编码后才离开的 secret 不会被检测:本次重写不设 normalization 传递。对应下方残余风险R1。
- 键位置的 secret。 遍历只读取值,因此作为 JSON 对象键使用的 secret 会原样转发。对应下方 R2。
- 响应路径的替换。 响应作用域的规则可以放行、告警或阻断,但绝不 替换;试图脱敏响应的规则在编译时即被拒绝。
- 工具自身的遥测。 一些工具会向 PostHog、Sentry 之类的服务发送自己的遥测;这些端点绕过 base URL 改写,不在网关路径上。
八条硬性不变量
八条全部在代码中强制,并由开源核心中的具名测试钉住。它们是本设计不会弯折的规则。
- 绝不向出站回填占位符。 出站 writer 只持有正向映射(secret → 占位符),没有反向映射,也没有还原方法。重复应用脱敏是幂等的,已携带已知占位符的 body 原样转发。
TestInvariant1NeverBackfillOutbound - 请求/响应明文不落盘;只落元数据。 审计接缝只有一个写方法,只写元数据 记录;默认 sink 是 no-op,不持久化任何东西。运行级守卫驱动真实二进制,扫描该次运行 可能写入的一切。
TestInvariant2NoPlaintextOnDisk - 默认无根证书、无 MITM。 Tokenhush 从不终止 TLS。absence 守卫扫描生产源码中证书安装或 MITM 路径所需的每一种形状——TLS 监听器、密钥对、 证书池、内嵌证书——一旦出现即失败。
TestAbsenceNoCertificateInstallPath - 仅回环绑定、Host 与 Origin 校验、控制 bearer token。 非回环绑定 不可能成功;外部 Host 被拒绝;跨源请求被拒绝;控制面缺少凭据返回 401,错误凭据返回 403。
TestInvariant4LoopbackOnly - 检测失败时故障关闭,而非故障开放。 规则 panic、超时、超出 span 预算或返回畸形发现时,产生带正确原因的标签化拒绝——绝不静默放过。每次失败恰好写一条 仅含元数据的记录;超过字节预算的叶会写一行仅含元数据的 stderr,而不是静默通过。
TestInvariant5FailSafe - 恰好两个可开关的厂商出口类别,且命令限定。产品只可为两种已披露用途触达厂商,且只在你未设置对应开关之前。第二个守卫证明披露 恰好声明两个类别、各自命名其开关,且每个开关在任何网络调用之前短路。
TestInvariant6NoVendorEgress - Content-Encoding 故障关闭。 携带非 identity
Content-Encoding的请求在读取 body 之前以 415 拒绝。响应方向先解码 gzip 与 deflate 再提交任何状态码;网关无法解码的编码以 502 拒绝并丢弃上游字节。 不存在未检查的透传。TestInvariant7EncodingFailClosed - SSE 感知的有界回填(256 KiB)。 SSE 流中未终止的 token 永远不能让回填 writer 持有超过
SSEBackfillHoldbackBytes(256 KiB);越过界限后最旧的数据按字面顺序释放。不丢字节,也不伪造字节。TestInvariant8BoundDerived
九项残余风险
这些风险被如实记录,而不是被隐去;列出它们并不等于声称它们不存在。下表与安全模型的 登记逐条一致,包括那些公开起来并不舒服的条目。
| # | 残余风险 | 实际含义 | 状态 |
|---|---|---|---|
| R1 | 编码后的 secret 不会被捕获 | base64、hex 或 URL 编码后才离开的 secret 不会被检测:本次重写有意不设 normalization 传递,且非 identity 的请求 Content-Encoding会以 415 拒绝,而不是为检测而解码。JSON 字符串转义在两个方向都已处理,因此残余仅是内容编码,绝不是 JSON 转义。 | 已记录并接受 |
| R2 | JSON 对象键中的 secret 不会被捕获 | 放在对象键而不是值里的 secret 会原样转发:叶遍历不提供键叶,因此不存在 半可用的键路径。sensitive_keys块脱敏的是命名键的值,绝不是存放在键里的 secret;两种同形仍不匹配: 敏感键的值是容器,以及 Kubernetes/Docker-env 同形——name 字段携带键名、value 字段携带 secret。 | 已记录并接受 |
| R3 | 签名远程包可能通过自身 allowlist 弱化检测 | 签名远程包可以通过其全局或每条规则的 allowlist 压制匹配:非弱化 floor 对 allowlist 保持中立、不检查 allowlist,而规则签名密钥仍是信任根。弱化检测 的包,是签名密钥持有者选择发布的包。 | 已记录并接受 |
| R4 | command 规则拒绝处于休眠,且 manifest v2 升级不向后兼容 | command 规则拒绝保持休眠,直到下一个 schema 闸门打开,而闸门只在规则 manifest schema_version 达到 2 时打开。该升级与钉住== 1 的客户端不兼容,因此后端必须继续提供 v1 manifest,直到旧版本线退出支持。 | 已记录并接受 |
| R5 | 响应路径 body 不受请求侧 body 护栏约束 | 响应 body 不受请求侧护栏限制:response_buffer_bytes(默认 32 MiB)是整段缓冲响应的一个独立总量上限,超过它的响应在提交前返回 502。在该上限放行的 body 内部,响应作用域检测器仍最多扫描自己的每原始 检测器预算,超过预算的叶会被截断,且没有 stderr 报告、没有计数器——因此 响应作用域检测仍可能漏掉内容。 | 已记录并接受 |
| R6 | 跨 SSE JSON 信封 data: 事件拆分的占位符仅部分还原 | 当每个事件都是完整 JSON 信封时,内容层拆分会被精确还原一次,content 通道与 tool call arguments 通道皆然,匹配在解码域进行。剩余边界:若上游把 JSON 文档本身撕开,只有原始拼写可知;而一个非匹配通道的事件在两半之间 到达时,被扣留的两半按字节原样释放,占位符片段以字面形式送达、不被还原。 token 未解析期间,仅受影响通道被扣留,直到 token 解析或联合 256 KiB 预算触发。 | 部分关闭;剩余项已记录并接受 |
| R7 | 按需启用的 high_entropy 检测器会替换合法 base64 载荷 | entropy 默认关闭,因为合法的高熵载荷与 secret 在构造上无法区分:启用后,请求中的 base64 图片会在抵达上游之前被替换成单个占位符,模型因此看不到该图片。仅在不携带 图片、data URL 或长随机标识符的工作负载上启用,或接受这次替换。 | 已记录并接受 |
| R8 | 全局 blocklist 并非在每条求值路径上都被应用 | 有两条决策路径不应用全局 blocklist,只有编译后的求值路径运行它。每个叶在该 路径上保持 Block 优先,但通过轻量路径决策的调用方不会得到 blocklist 的应用。这一点被记录而不是被修复,钉住该行为的测试位于编译路径上。 | 已记录并接受 |
| R9 | 请求 body 护栏是内存墙,且第二次遍历不受保护 | 等于或低于 max_body_bytes(默认 64 MiB)的 body 在声明 JSON 的路径上要被遍历两次,而只有第一次遍历运行在检测器超时之内; Forwarder 内的第二次遍历不受保护、没有超时。每次遍历都会复制叶值,因此 峰值内存是 body 的一个小倍数,而不是 body 本身。超过上限的 body 仍以 403 body_too_large 拒绝,现在附带一行仅含元数据的 stderr。 | 已记录并接受 |
磁盘上写什么
除元数据外不持久化任何东西。数据目录下会写入四条路径,其中没有任何一条携带请求或响应 内容。
<DataDir>/run.jsonpid、port、addrs、started_at。 绝不含控制 token。权限0600,原子写,干净退出时删除。<DataDir>/control.token- 会话控制 bearer token。权限
0600,原子写,干净退出时删除。 <DataDir>/rules/…- 已验证的规则缓存与防回滚标记:签名文档与序列号,不含请求内容。
<DataDir>/update/highwater.json- 更新防回滚标记:一个 serial/version 整数,别无其他。
永不落盘:body、secret 与映射。
请求与响应 body、检测出的 secret、以及占位符到 secret 的映射,永不写入磁盘。脱敏日志行经过掩码、只写 stderr、永不持久化;响应侧的还原 计数器同样只写 stderr。由于脱敏每轮都会重新扫描整段携带的对话,这些计数器随携带的 出现次数增长——这是仅含元数据的工作,绝不持久化任何 body。
密钥处理:在内存中可见、不落盘
密钥经过网关以完成工作;网关不存储它们。
- 提供商密钥只在内存中透传。你的工具持有自己的提供商密钥;网关仅为其正在处理的请求读取并转发,不存储。检测、 替换与还原都在同一进程内完成。
- 映射限定于本次会话。 secret 到占位符的映射在会话期间仅存在于内存; 重启网关即丢弃——占位符可能原样送达你的工具,这是可见的降级,而不是静默失败。异源 占位符按字节原样返回,绝不被猜测。
- 两种凭据,两种处理。 唯一写入磁盘的凭据是每次运行生成的控制 token(
control.token,权限0600,干净退出时删除)——它是回环控制 凭据,不是提供商密钥。
网络外发:两个命令限定的类别
Tokenhush 发起的厂商请求只有两个类别,各自限定于触发它的命令,且各自可关。两者都发往updates.tokenhush.com,保留期 30 天。
更新检查只在 self-managed 安装上运行 tokenhush update(含--check)时发生;规则同步只在运行 tokenhush rules sync时发生。设置对应开关后,每个类别都会在发起任何网络请求前返回——TOKENHUSH_NO_UPDATE_CHECK=1与 TOKENHUSH_NO_RULE_SYNC=1;tokenhush privacy会打印这份披露。
更新检查
已生效当前构建只在你运行对应命令时发送这些请求;下列字段、保留期与关闭方法均适用。
检查已签名的版本更新:更新清单、其签名撤销文档,以及根签名 key-list。 三者共用同一 host 与同一开关,属同一外发类别,而非三项。
涉及字段
channel本机发送请求的发布通道(例如 stable 或 beta)。os_arch本机发送运行中二进制的操作系统与 CPU 架构。current_version本机发送当前二进制版本号,供服务端判断是否存在可用更新。ip服务端可见请求的来源 IP 地址,服务端可见。timestamp服务端可见服务端记录的请求时间戳。access_logs服务端可见Cloudflare 访问日志(路径、时间、来源 IP、User-Agent)。
规则同步
已生效当前构建只在你运行对应命令时发送这些请求;下列字段、保留期与关闭方法均适用。
获取已签名的检测规则清单与规则包,使本地检测器可增量收紧, 无需重新分发二进制。
涉及字段
channel本机发送请求的规则通道(例如 stable 或 beta)。os_arch本机发送运行中二进制的操作系统与 CPU 架构。current_schema_version本机发送当前所用规则包的 schema 版本与 serial。ip服务端可见请求的来源 IP 地址,服务端可见。timestamp服务端可见服务端记录的请求时间戳。access_logs服务端可见Cloudflare 访问日志(路径、时间、来源 IP、User-Agent)。
上表来自二进制随附的同一份清单,并以字段级形式同时呈现在隐私政策中。