Skip to content

Latest commit

 

History

History
168 lines (133 loc) · 13.3 KB

File metadata and controls

168 lines (133 loc) · 13.3 KB

安全与隐私

权限等价性

向 shell Pane 输入普通文字本质上可以执行命令。Admin 控制权限等同于对这些 tmux 会话的可信终端控制权限;TermFlow 不把已认证控制方的文字做命令沙箱。

凭据与授权载体

  • Admin Token:管理 B,查看和控制全部 Instance;
  • Installation Credential:只能为本机注册 Instance;
  • Instance Credential:只能连接并上报一个指定 Instance。

Web C 的浏览器会话和原生客户端 OAuth access/refresh token 是另外两种短期授权载体:它们 都受当前认证 epoch 约束,不能替代 Admin Token 管理 B。原生授权使用 PKCE、DPoP 和系统浏览器 确认;Tauri 页面不把 Admin Token 写入客户端存储。

B 只存 token 的 SHA-256 哈希;Web C 创建注册码时还可以随哈希保存用户指定的非机密 Computer 显示名。注册码最多成功使用一次且默认 60 秒过期;B 在同一次原子消费中取得 显示名,随后才创建 Installation,因此过期或未使用的注册码不会产生 Computer。关闭生成 注册码的 Web 页面不会撤销已经复制出去的码;它仍会在首次成功使用或 B 记录的到期点 失效。A 必须保存可用的 Installation/Instance 原始凭据,因此它们只写入本机明确的 0600 文件;父目录为 0700,普通模型 repr、stdout 和日志保持遮蔽。

Web C 的登录页把 Admin Token 交换为 8 小时内存会话。启用双重认证后,第一次提交 Token 只会得到短时挑战,必须再提交验证器生成的一次性验证码;成功后才签发会话。浏览器只得到 HttpOnly、SameSite=Strict Cookie;HTTPS 部署使用 Secure 和 __Host- 前缀。WebSocket 握手还检查精确 Origin allowlist。Admin Token 不写 localStorage、sessionStorage、URL 或 前端日志。TOTP 关闭时 curl 可直接使用 Admin Bearer Header;TOTP 开启后应先用验证码换取 有 scopes 的 CLI token,再用该 Bearer 访问资源。原生客户端在系统浏览器完成授权后,使用 OAuth access/refresh token 和 DPoP 访问资源。

TOTP 的主密钥只由 B 读取:单实例默认在独立于数据库卷的 termflow-totp-key 卷中自动生成 0600 文件(与数据库分离,卷快照不会同时泄露密文与密钥),多实例必须显式共享同一密钥。 验证器丢失不能在 Web C、App 或 EXE 中恢复;必须由拥有 Docker 主机权限的管理员在容器内 运行 termflow-control auth totp reset。认证失败和验证码尝试由 B 限速(每来源桶 + 全局桶,全局桶容量由 TERMFLOW_AUTH_GLOBAL_VERIFICATION_CAPACITY 控制),挑战过期或 超过尝试次数后不会继续接受该挑战。

凭据轮换:termflow-control auth rotate 在保留 TOTP 的情况下推进全局认证 epoch,立即 吊销全部 Web、原生和 CLI 凭据;auth totp reset 则同时清除 TOTP。两者都需要容器内本地 确认。修改 TERMFLOW_ADMIN_TOKEN 并重启不会自动撤销已签发的凭据,请使用上述命令。

Computer 的注册时间由 B 创建 Installation 时记录,最近在线时间由 B 收到 A 的注册、 心跳或拓扑更新时记录;两者都以 UTC 存储和传输,不依赖 A 的本地时钟。Web C 按当前 访问设备的本地时区显示,不附加 GMT、UTC 或其他时区缩写。不同 C 设备看到的当地钟表 时间可以不同,但对应同一个 B 记录的时间点。

原生客户端的 DPoP nonce 与流式通道

原生客户端(Tauri:Windows/macOS/Linux/Android/iOS)用 DPoP 绑定 access token;B 为每个 JWK thumbprint 维护一个 nonce。nonce 在生命周期前半段保持稳定(DpopVerifier 只在剩余 TTL 不足一半时轮换),并发请求因此可以共享同一个缓存值;携带过期 nonce 的请求会得到 401 use_dpop_nonce 与新的 DPoP-Nonce 响应头。重放防护由 (jkt, jti) 有界缓存负责, 与 nonce 轮换无关。

客户端侧只有一个 nonce 重试实现(Rust send_with_dpop_nonce):首试使用缓存 nonce, 401 + DPoP-Nonce 时带上新值重试(最多 3 次),并记住任何响应携带的 nonce。token 端点与 agent SSE 握手复用同一实现;WebView 适配层不做第二次重试。重试耗尽后:真正的 认证失败(401 且无 DPoP-Nonce)→ 清会话回登录;仅 nonce 竞争的 401(带 DPoP-Nonce)→ 视为瞬态 1006,因为新 nonce 已被记住。

Agent SSE 与终端/事件 WebSocket 共用的关闭码:

code 含义 客户端行为
4401 认证 epoch 变化 / 凭证失效 清会话,回登录
4403 缺 scope 或 Origin 不允许 会话保留,提示缺少权限,不重连
4404 会话不存在 清除本地 cursor,提示会话已删除,不重连
4410 订阅端过慢,事件被丢弃 从 REST 重放补齐后继续
4412 binding 被撤销 标记 binding 撤销,不重连
1006 传输/服务端瞬态错误 退避重连

WebSocket 认证失败在 accept 之后 以 4401/4403/4429 关闭:accept 前的 close 只能给 客户端一个没有 code 的 HTTP 403,终端客户端无法区分“回登录”与“退避重试”。Computer A 的 /api/v1/bridge/connect 目前仍是 accept 前 close,等 A 升级到包含同一约定的版本后 再统一。

本地边界

每个 Instance 使用独立、绝对且不可与默认 tmux 混淆的 -S <socket>。能访问这个 socket 的同 OS 用户进程视为可信。termflow kill 只操作精确解析出的 Instance UUID、 经过命令行身份核对的 Bridge pid 和该 Instance 的显式 socket。

网络与持久化

公网 B 必须使用 HTTPS/WSS;明文 HTTP/WS 只允许 127.0.0.1、localhost 或 ::1。 DNS、TLS、反向代理和可选 mTLS 由部署者的外部边缘服务负责,TermFlow 容器只接收代理转发的 HTTP/WS。TERMFLOW_PUBLIC_BASE_URL 是用户、A、Web C 和原生客户端共同使用的 canonical origin;添加电脑返回的登录命令也从它生成。反向代理部署时认证限速默认按直连 IP 计源, 所有用户共享代理 IP 的预算。仅当 B 只能经由可信反向代理访问、且该代理覆写(而不是追加 或保留)X-Forwarded-For 时,才可显式设置 TERMFLOW_TRUST_PROXY=true 按转发头计源; HTTP 请求与 WebSocket 连接的认证限速使用同一个来源解析函数,Uvicorn 不做代理头预改写。 protected_websocket 的握手在验证成功后调用 AuthRateLimiter.refund 退回配额,因此正常 重连不会耗尽预算,只有失败或恶意的握手消耗配额。 非回环的明文 PUBLIC_BASE_URL 会在启动时 拒绝;HTTPS 部署自动附加 HSTS 响应头。 token 不放 URL。B 不持久化终端输入、输出、屏幕快照或录像;SQLite 和审计只含身份、 字节数、动作、结果等元数据。A 的短期输出环只存在于 Bridge 内存,进程退出即消失。 终端输入审计(来源会话、累计字节数)以聚合日志写入 stdout 并进入容器日志系统,不落 SQLite;docker logs 由 Compose 的 max-size 配置轮转。

A 端 --allow-insecure-http 会以明文传输注册凭据与终端流量:该开关只输出警告并标记 status/日志为 insecure,不会拒绝公网 HTTP 目标。仅应在受信任的专用局域网使用;公网 部署必须使用 HTTPS。

每个 Term 同时只有一个可输入的远程 tmux client,新连接显式替换旧连接。单帧最大 64 KiB,并有输入速率、队列和背压上限。远程连接关闭只 detach 代理 client,不能结束 tmux server/session 或 Pane 进程。

原生 C 的所有 HTTP 与 WebSocket 流量都由 Rust 侧统一发出:WebView 不再持有 http/websocket 插件权限,native_request_headers/native_http_request/ native_terminal_connect 会把目标严格限定为配置 issuer 的 origin 与 /api/ 路径前缀, Access Token 不进入 JavaScript 环境;DPoP 签名输入被限定为规范 JWT 结构。客户端日志在 Rust 统一脱敏后落盘。

安装与供应链:Release 产物携带 GitHub Artifact Attestations(build provenance),镜像 以 cosign keyless 签名并附带 SBOM;install-termflow-node.sh 在有 GitHub CLI 时校验 attestation。CI 引用完整 commit SHA 且权限最小化。

容器默认只映射 loopback。需要远程访问时,应使用可信反向代理终止 TLS,并保护 Admin Token。不要把数据库、A 配置、Bridge 日志或 tmux socket 上传为诊断附件。

Agent Broker v0.2.0 的运行时边界与证据等级

参考部署中的 B 不直接操作 Docker。生产运行时门禁由 HttpHealthRuntimeClient 对 OpenCode 的 /global/health 发起带 Basic Auth 的探测; 绑定的 runtime epoch 仍由 B 校验,探测失败、epoch 不一致、撤销或共享 runtime 冲突均 fail-closed。OpenCode 的 MCP 配置固定在 deploy/opencode-config.yaml,只允许审查过的 TermFlow 工具;URL 和 AgentToken 通过 Compose 环境变量的 {env:} 插值传入,不把原始 token 写入镜像或持久化卷。普通 B 重启不会自行推进 epoch,撤销或 profile 变更才会使旧 epoch 失效。

运行时出网边界

OpenCode 运行时是第三方代码,模型输出的工具调用由它执行,因此按"不可信运行时"对待。 TermFlow 的控制分两层:

  • 工具层(主控制):deploy/opencode-config.yaml 的 permission map 固定为 "*": "deny" + termflow_*: allow。模型只能调用 TermFlow MCP 工具(只读观察 + 审批写入),OpenCode 自带工具(bash、webfetch、read/write 等)全部不可用。
  • 网络层(接受的残余风险):live/release 部署给运行时一条独立的普通 bridge provider_uplink 直连 provider,不运行过滤代理。因此运行时进程本身可以访问 互联网。这不是模型能力(工具层已封住),但第三方运行时、供应链、未来插件或配置 漂移可以用这条出口;provider 密钥也在该容器内。

为什么接受:OpenCode 不提供"只访问某个域名"的出网策略,Docker 也不提供按域名的 egress 控制(只有"有外网/没外网"的网络分段)。域名白名单必须依赖 L7 过滤代理,等价于 多一个容器、一份 ACL 和一条上游镜像;单主机、单用户部署认为该残余风险可接受,换取 更少的运维面。需要收紧时,部署方可以在自己的网络层加正向代理并只给运行时 internal 网络 + 代理出口,TermFlow 不再内置这一层。

tests/e2e/test_agent_provider_egress.py 锁定当前拓扑:运行时直连 provider、没有 代理容器/代理环境变量、且不与 A/C 的 default bridge 同网。

验证结果按证据等级区分,不能用较低等级的结果替代较高等级的运行时证明:

等级 当前覆盖 限制
静态/契约 Python、TypeScript、Rust、Compose 配置、OpenCode fixture、工具 allowlist、scripts/security/verify-agent-containers.sh 的可执行检查 不证明容器已经启动,也不证明外部模型可用
确定性产品跨进程 tests/e2e/test_agent_product_setup.py + test_agent_broker_process.py 使用真实 A/tmux 和 B 子进程验证 setup 不重启 B、health drift/recovery、旧 epoch/错误 Host/撤销/运行中撤销、SSE replay/dedup、歧义动作 delivery_unknown、清理重试和 dead-letter;2026-09-08 组合运行 9 passed,另有 1 个运行中撤销场景通过 使用本地 fake OpenCode/provider,不证明真实容器或外部模型
隔离真实浏览器 apps/clients/web/e2e/agent-chat.spec.ts 与 agent-terminal.spec.ts 在 disposable Chromium 中验证登录、Agent 导航、setup、sidecar、隐私/a11y、runtime 503 fail-closed、reload persistence 和单 WebSocket;2026-09-08 desktop 通过,移动 portrait/landscape 亦通过 浏览器使用临时无外网环境;未连接稳定 OpenCode/MCP 或外部模型
容器集成与出口 tests/e2e/test_agent_opencode_container.py 的 pinned 1.18.18 生命周期/inspect smoke(1 passed)以及 test_agent_provider_egress.py 的直连 provider、无代理进程/代理环境、与 default bridge 隔离(3 passed,2026-09-16 记录) 需要 Docker daemon、固定镜像和临时凭据;不覆盖 B/OpenCode/MCP 的真实产品编排或模型行为;不提供域名级白名单(见「运行时出网边界」)
live 外部模型 2026-09-09 disposable Compose 部署完成真实 B → OpenCode → DeepSeek → approval → Docker A echo 1 路径:一次 approval 被消费,重复决定返回 HTTP 409,记录一次 tool start/completion,pane 有一条命令和一条独立输出 1,最终 assistant body 恰为 1;B/OpenCode 重建后 setup、conversation 和 A 在线状态保留 该运行使用 synthetic、明确标注未验证的 provider disclosure metadata,readiness 仍为 configured_unverified,所以不证明供应商的 no-training/retention/region 政策或稳定发布就绪;新鲜 re-auth、撤销/断线和 once/reject 仍是未完成的 release gate

容器检查脚本只读取明确指定的 Compose project,不执行 up、down 或删除操作。集成测试的 teardown 只清理自身随机 project 和随机 volume 名称。上述边界是覆盖说明,不构成“绝对 安全”保证;宿主机、反向代理、模型供应商和 Docker 默认 seccomp/AppArmor 仍是部署者责任。