Skip to content

Vault environment_variable方案:存库加密 + header/body 占位 + MITM 出口替换 #236

Description

@qifanlili

目标

实现Vault 的 environment_variable 能力:沙箱里只出现不透明占位符,真密钥只在出站 时瞬态替换;Agent / CLI / 日志看不到明文。

参考:CMA Vaults(Environment variable / Rotate a credential / Credential lifecycle)。

相关现状:docs/design/be/vault-runtime.md 已完成信封加密与 MCP 路径的 static_bearer / mcp_oauth 注入;env 占位符与 credential中的 networking 、header/body 替换 未做。


和 MCP 凭证不是一条路

MCP(static_bearer / mcp_oauth Env(environment_variable
mcp_server_url secret_name(环境变量名)
沙箱里有什么 只有 MCP proxy URL + session JWT secret_name → Opaque Placeholder
真值出现位置 Session MCP HTTP proxy 写 Authorization CONNECT MITM 解密后的 HTTP,按 header/body 替换占位符
是否依赖 MITM (硬前置)

用途:CLI / SDK / 普通 HTTPS 调用把密钥原样放进请求头或请求体的场景(如 NOTION_API_KEY)。


当前缺口

  • 后端已能创建/校验 environment_variablesecret_namesecret_value 信封、networking),没有 injection_location,也没有持久化的 Opaque Placeholder。
  • 前端凭证对话框只有变量名/值;缺 Limited/Unrestricted、Allowed hosts、Injection location、acknowledgement。
  • 运行时未做出口替换;

已确认决策

# 决策
1 沙箱只见 Placeholder;真密钥只在出站 时瞬态替换
2 MITM 硬前置:部署未开 upstream_proxy_mitm 时,挂了活跃 env 凭证的 Session/Code Session 启动失败(明确错误)
3 硬失败点在 Session 挂载时,不在凭证 CRUD,也不拖到首次出站
4 **injection_location **:header 与 body 都支持替换
5 占位符:创建时随机生成并持久化(可见的 auth 元数据);轮换 secret_value 不改占位符;真值仍走既有信封加解密
6 启动时把 secret_name → placeholder 写入 startup_context.environment_variables(写入的是密文)
7 跨 vault 同名 secret_name:按 vault_ids 取第一个匹配的vault内的值
8 平台保留名黑名单secret_name 不能跟平台保留name相同 → 否则创建失败,错误码400;合并 env 时平台键不会被 vault 覆盖
9 host 未覆盖 / 该 location 未开 → 占位符原文透传;Open 信封失败 → 拒绝该请求
10 每请求(或每条 MITM HTTP)查库重载;轮换密钥、改 networking / injection_location 对后续出站热生效,沙箱无需重启
11 Credential allowed_hosts 管能不能替换 与 Environment networking 管能不能连

行为说明

创建environment_variables凭据(要点)

{
  "display_name": "Notion API key",
  "auth": {
    "type": "environment_variable",
    "secret_name": "NOTION_API_KEY",
    "secret_value": "ntn_…",
    "networking": {
      "type": "limited",
      "allowed_hosts": ["api.notion.com"]
    },
    "injection_location": { "header": true }
  }
}
  • secret_value write-only;读 API 不返回。
  • 服务端生成并持久化 placeholder(例如带固定前缀的高熵串);响应可返回非密字段(含 resolved 的 injection_location),永不返回明文密钥。
  • injection_location 更新按字段 merge;不能两个 location 都关;显式 null 拒绝(对齐 CMA:omit instead)。
  • secret_name / 类型创建后不可改;要改则 archive 再建。

启动

flowchart TD
  A[Session 带 vault_ids] --> B{部署 MITM 已开?}
  B -->|否且存在活跃 env 凭证| F[启动失败]
  B -->|是或无 env 凭证| C[按 vault_ids 加载活跃 env 凭证]
  C --> D{跨 vault secret_name 冲突?}
  D -->|是| E[先到先得]
  D -->|否| G[合并 secret_name 到 placeholder]
  E --> G
  G --> H{撞平台保留名?}
  H -->|创建阶段已拦; 合并时平台键保留| I[写入 startup_context.environment_variables]
  H -->|否| I
  I --> J[Sandbox 进程 getenv 只见占位符]
Loading

出站替换

sequenceDiagram
  participant Sandbox
  participant Relay as CCR CONNECT relay
  participant MITM as OMA MITM egress
  participant Upstream

  Sandbox->>Relay: HTTPS via HTTPS_PROXY
  Relay->>MITM: CONNECT + 解密 HTTP
  MITM->>MITM: Environment networking 放行?
  MITM->>MITM: 查 vault_ids 活跃 env 凭证
  alt host 在 Credential Networking 内且 location 允许
    MITM->>MITM: Open 信封,替换 header/body 中的占位符
    alt Open 失败
      MITM-->>Sandbox: 拒绝请求
    else 成功
      MITM->>Upstream: 带真密钥转发
    end
  else 未覆盖或 location 关闭
    MITM->>Upstream: 占位符原文透传
  end
Loading

替换范围与 CMA 一致:

  • 只动 header 值 / body 中出现的占位符字符串(按 injection_location)。
  • 替换 URL path / query 里的密钥形态(例如 Slack webhook URL 嵌密钥)。
  • Credential networking 放宽 Environment 出网策略;两边都要允许,替换后的请求才能到达目标。

与 MCP Open 失败语义的差别

MCP 注入:Open/refresh 失败可跳过该条、walk 下一条。
Env 替换:某请求已命中需替换的占位符且应对该凭证 Open 时,Open 失败 直接拒请求,不降级成「带着占位符当密钥用还返回 200」。


前端(Console)

对齐 CMA「Add credential → Environment variable」:

  • Name(optional)、Type、Variable name、Value
  • Networking:Limited / Unrestricted;Limited 时 Allowed hosts
  • Injection location:Request headers / Request body(默认只勾 header)
  • Workspace 共享警告文案 + acknowledgement 勾选后才能提交(仅 UI)

迁移与破坏性

  • 破坏性:上线后,历史活动 environment_variable 若缺少 placeholder / injection_location,不能再用于新 Session;需 archive 重建。
  • 不做扫表补字段、不做读时惰性生成(grilling 选 C)。
  • 实施前在设计文档写清:无效凭证在「挂载 Session」时的错误文案,以及 Console 如何提示重建。

非目标(本 issue)

  • 把真密钥明文写进沙箱 env
  • MITM 关闭时静默透传占位符假装可用
  • Self-hosted / 无受管 egress 的沙箱(CMA 同样不支持 env 凭证走 self-hosted)
  • URL path/query 替换、SigV4 类本地签名客户端
  • 改 MCP proxy 的 bearer 注入模型
  • Shamir / 云 KMS、mcp_oauth_validate、refresh_failed webhook 等既有「切片外」项

建议实施顺序

  1. 更新 docs/design/be/vault-runtime.md(及保留名列表);CONTEXT.md 术语已起稿,实现时再校对。
  2. API/schema:injection_location、placeholder 持久化、保留名校验、创建默认值、旧凭证挂载失败。
  3. 启动路径:MITM 门闩 + startup_context 灌占位符。
  4. MITM egress:按 host + location 替换;Open 失败拒请求;每请求重载。
  5. Console 表单与 acknowledgement。
  6. 测试与质量门禁(lint / dead-code / duplicates / complexity / 相关单测与集成测)。

验收

失败优先:

  • 未开 MITM 且 vault_ids 含活跃 env 凭证 → Session/Code Session 启动失败,错误可理解
  • secret_name 为平台保留名 → 创建 400
  • 省略 networking → 400;两个 injection location 都关 → 400
  • 缺 placeholder / injection_location 的旧活动凭证 → 挂载失败(需重建)
  • host 不在 Credential Networking 或 location 关闭 → 上游收到字面占位符,不剥不换
  • 应替换时 Open 失败 → 请求被拒绝,不 200 透传
  • Environment limited 未放行该 host → 即使 credential unrestricted 也连不上(替换层不能绕过出网策略)

成功:

  • 沙箱 getenv(secret_name) 为占位符,不是 secret_value
  • 对 allowed host、enabled location,出站 header/body 中的占位符被换成真值;上游与日志不见占位符当密钥成功认证的假象之外的明文泄漏(日志仍禁止记密钥)
  • 默认创建仅 header;勾选 body 后 body 内占位符可替换
  • 轮换 secret_value 后,不重启沙箱,后续出站用新密钥;占位符字符串不变
  • 多 vault 同名按 vault_ids 顺序先到先得
  • Console 表单字段与 acknowledgement 行为符合上文
  • vault-runtime.md / CONTEXT.md 与实现一致;仓库规定的 Go/前端质量门禁通过

参考

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentSpecification confirmed and ready for implementationwayfinder:taskWayfinder prerequisite task ticket

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions