安全模型
Tenon 默认本地优先,但“在本机运行”不等于没有风险。插件能够读取项目、执行命令、记录证据并启动本地服务,因此必须保持最小权限和清晰信任边界。
信任边界
- 宿主 hooks/skills:来自已安装 release;
- 项目 workflow/track:来自当前仓库;
- agent 输出:不可信输入,必须经过 schema、路径和 guard 校验;
- Dashboard/API:只绑定 loopback;
- Pages:只包含公开白名单静态内容;
- 外部 provider:受凭证、预算和数据策略约束。
状态与路径
canonical state 只由 Tenon CLI 写。路径必须落在项目允许范围,拒绝 symlink、目录目标、遍历和绝对路径注入。文档 ledger 记录安全相对路径和摘要。
Secrets
不得把 API key、OAuth token、prompt、headers、Tap trace、CA 私钥或真实用户数据写入 README、Issue、Pages artifact 或验证截图。诊断输出先脱敏。
日志与支持包
Dashboard server 日志(<state>/logs/dashboard.log)里没有任何凭证:每一行先过同一个脱敏器(token、API key、cookie、 会话码/一次性登录码、私钥、URL 密码),文件权限 0600,受管后台 server 的登录链接既不会返回也不会明文写进日志。 tenon support bundle 对要离开本机的内容再过一遍更严的脱敏(另加邮箱、home 路径、用户名),归档上限 5 MiB, 不上传任何东西。脱敏是模式匹配:分享前解开看一遍,漏抹按缺陷报告。
本机边界防的是谁
| 对手 | 能做什么 | 什么拦住它 |
|---|---|---|
| 浏览器里的任意网页 | 跨站请求、DNS 重绑定、读跨源响应 | 每个请求都校验 Host,Fetch-Metadata / Origin 检查,SameSite=Strict 会话 cookie,写 token,不发 CORS 头 |
| 本机其他 OS 用户 | 连回环端口、读世界可读文件 | 没有会话一律 401;磁盘上不存任何凭证;状态目录只属于你 |
| 以你身份运行的 coding agent(有 shell、能读遍 Tenon state 文件、能连回环端口) | curl / 抓 token、读写 API、读 token 文件、在脚本里发请求 | 无会话时 GET / 与所有读接口都是 401;磁盘、环境变量、进程参数和任何响应里都没有可换会话的东西;确认评审还要只有已登录页面才能申请的「人在场」nonce |
| 同一用户且能控制你的浏览器、抢读进程参数,或自己再起一个 server | 拿到会话 | 不防。 这和直接运行 tenon review acknowledge、直接改文件是同一级能力;起作用的是宿主 hook 的 fail-closed 门和 AFK 策略,不是 server |
最后一行是有意写明的:回环边界不是沙箱,挡不住已经以你身份运行的代码。0.3 改变的是 Dashboard 不再把钥匙交给任何开口要的人。此前 GET / 会把写 token 发给任何调用方,hook 只能靠模式匹配命令去发现滥用;现在边界在 server,hook 只是审计信号。
Dashboard
不要把 Dashboard 绑定到 0.0.0.0 或通过公共反向代理暴露。它包含项目 root、Change、session 和 mutation 能力。
- server 只绑定
127.0.0.1,每个请求(读与 SSE 也一样)都校验 Host。 - 只有
GET /api/health与静态/assets/*公开。其余请求必须带有效会话 cookie,没有就是401——包括GET /,它只返回不含 token 的登录提示页。 - 会话来自一次性登录链接(256 bit、只能用一次、2 分钟有效),只存在 server 内存里,只有两条交付途径:server 自己打开你的浏览器(
tenon dashboard --open,调用方只知道「有没有打开」),或打印到启动 server 的那个终端(交互终端,或启动者设了TENON_DASHBOARD_PRINT_LINK=1)。页面用链接换一个按端口区分的HttpOnly; SameSite=Strictcookie;server 只存它的哈希,空闲 12 小时、最长 7 天、最多 64 个会话。 - 什么都不落盘。旧版本留下的
dashboard-token.json在 server 启动时删除。 - 写请求另外需要随机写 token(
Authorization: Bearer/X-Pipeline-Token),它只嵌在服务给已登录会话的页面里,并要求 JSON content type;Origin不是 Dashboard 自己的写请求、或被浏览器标为跨站的请求会被拒绝。 - 批准评审需要人在场。 页面要求第二次明确点击;这一下才去申请一次性 nonce(30 秒),它绑定会话、change、评审 ref 与期望 revision,批准请求必须带上。没有会话、或有会话却没有针对这一次评审的新鲜 nonce 的进程,无法经 HTTP 批准。终端里的
tenon review acknowledge不变,仍由宿主的交互门控制。 - 页面以
no-store、frame-ancestors 'none'、Referrer-Policy: no-referrer提供。 - 请求体有上限;返回 UI 的 secrets 已掩码;复用 server 时校验 release 与状态域身份。
需要留意的残余风险:浏览器 cookie 不按端口隔离,任何能让你的浏览器访问 127.0.0.1 另一个端口的程序,都可能看到随请求发出的 cookie 头;登录链接还会在 opener 进程的参数里停留几毫秒。两者都需要以你身份运行的代码,也就是模型不防的那一行。
Hook 与评审门
pending review 期间,PreToolUse 门只放行严格只读命令集(以及 tenon review request|acknowledge 与 tenon dashboard --open——后者不会给 agent 任何会话),其余一律拦截,包括以任何写法触达回环控制面的命令。可能触达 Dashboard 的命令还会作为安全信号记到 pending receipt 上。这个检测读的是解码后的命令词和命令所执行的脚本,而不是原始请求;它是尽力而为的(计算出来的地址、编码过的载荷会漏过)。hook 没触发时——宿主没装 hook、或 AFK 下——起作用的是上面 server 侧的规则。
测试证据
测试记录只有在「被它约束的 agent 造不出来」时才算证据。威胁模型是:想让门变绿的 coding agent,和你同一个系统用户。Tenon 把这件事变贵、并留下痕迹,不声称能拦住一个能读你文件的恶意进程。
- 证据只由 Tenon 写。 hook 拒绝 shell 与编辑器对运行记录、任务测试计划及其台账、共享基线、
known-failures.yaml、 本机封存文件与它的密钥的写入,认 13 种写法:解释器内联代码(python -c、node -e、perl -e)、curl -o与wget -O、tar -x与unzip -d、git checkout|restore … -- <路径>、git apply与patch、变量路径、点名了受保护路径又不是干净跟踪文件的 脚本文件、xargs sh -c与find -exec、cp/mv/install/rsync/ln变体、tee变体、重定向变体(含喂给解释器的 heredoc)和就地编辑器。 匹配是静态、尽力而为的。 读取不是写入:读记录请用普通读取工具(cat、ls、jq、grep、head、tail、wc、find -name)或编辑器的 Read 工具。 经过解释器内联代码(node -e、python -c)或xargs的读取,以及后面链着任何写入的命令,同样会被拒:静态匹配分不清它们和写入。 - hook 看不见的,由门检出。 每个任务记录链的链头由
tenon test run封存进按用户、HMAC 签名的本机文件;绕开命令写进来的记录使链头 对不上封存(record-unsealed,没有人工出口,下一次运行另起新链)。Tenon 写出之后又被改动的基线与已知失败清单判protected-file-tampered。 - 「什么算通过」的定义要人确认。 任务 diff 里的测试目录、基线、已知失败清单和项目工作流的改动会挡住评审门,直到你确认确切内容 (
tenon review request逐项列出,委托确认不能批准它们)。已知失败只能指向一个具体用例,最长 30 天。 - 报告和候选被钉住。 报告必须比本次运行新,并带摘要复制进本次运行的产物目录;工作区指纹忽略目录或冻结工作流声明过的测试产物, 另外忽略
test-results/、playwright-report/、coverage/目录之下 git 没有跟踪、且扩展名是证据格式(log、txt、xml、md、csv、图片、视频、lcov、info)的文件。 源码和脚本扩展名、没有扩展名的文件、已跟踪的文件,以及能被代码或浏览器加载、解包的格式(json、html、htm、zip、gz、har)仍然计入, git 状态读不出来时全部计入,所以没有东西能藏在一个只是看起来像测试输出的目录里;真会产出 json、html、zip、har 的套件,在测试目录里把它们声明为输出。 - 仓库自带的命令首次执行前不可信。 你在本机对每条目录与内联测试命令的确切文字运行过
tenon test trust之后,tenon test run才会执行; CI 显式设置TENON_TEST_TRUST=1。agent 的调用里出现这两者,hook 一律拒绝,写入任何test-trust/路径也一律拒绝。hook 也拒绝 agent 在调用中改写 Tenon 的状态目录 (TENON_RUNTIME_HOME、TENON_RUNTIME_ROOTS,以及在tenon调用前改HOME/XDG_STATE_HOME),信任存储不能被指到 agent 自造的位置。信任按「用户 + 仓库 + 单条命令」记录: 存在你的用户级状态目录(<state>/test-trust/,用旁边权限0600的密钥做 HMAC 签名,不在仓库里),同一仓库的所有 worktree 共享, 当前检出必须出现在git worktree list里,所以带伪造.git指针的拷贝树借不到信任;检出里的旧封存只在不是 git 仓库的项目里有效,git 仓库里不认(封存和它的密钥可能随仓库或压缩包带来), 只认用户级存储,以前在检出里信任过的命令要在你自己的终端重新运行一次tenon test trust;改一条命令只会让这一条重新问。仓库身份取 git 公共目录的路径、 不取内容摘要,所以之后在同一路径克隆出另一个仓库会继承命令级信任(与往已信任的仓库里拉新提交同一级别);确认之前请读一遍命令。 - 评审者重跑不出通过。 同一候选上评审者的每次运行都算数,取最严的结论,除非重跑写明并记录了原因。
残余风险:拥有你权限的进程可以读封存密钥或信任密钥、伪造一致的封存或信任文件,可以构造 hook 解析不了的路径,也可以经 Dashboard 编辑 .pipeline/workflows/*.yaml;后两者靠评审里的人工确认兜底。
CI 里没有封存密钥。tenon verify --ci 只用已提交的文件重新推导记录链、计划、判定、候选树和受保护文件的批准行,能挡住所有 没有同时把整套记录重算并保持自洽的改动;它证明不了记录出自受信任的 tenon test run、批准行出自真人、套件真的跑过; 把链头锚定到 refs/notes/tenon 会多出一个伪造者必须同时写的地方。见CI 校验。
自动化
持续授权不包含发布、付费、外部通信或生产数据操作。AFK/loop 必须有预算、停止条件、隔离和可审计 policy snapshot。
供应链
依赖固定版本。CI 与 pre-tag release candidate 都运行 npm run check:dependencies;该单一门禁同时执行 High/Critical advisory audit 与 npm ls --all,invalid、extraneous 或不兼容解析树也会失败。正式发布必须把精确且仍为最新 main 的 40 位 SHA 与新 tag 交给 Release candidate (pre-tag);恢复中断发布时,也可传入已 精确指向同一 SHA 的现有 tag。验证 job 只有读权限且不 持久化 checkout 凭据,并 fail-closed 要求该 SHA 的 canonical push CI 成功;全部构建、测试和 打包也在这个无发布 secrets 的 job 内完成。它先把 upload action 的裸 SHA-256 规范化为 GitHub REST artifact digest 格式,再上传由该 digest 与逐资产 SHA-256 清单绑定的 payload,以及独立 approval 证据。默认分支拥有的 workflow_run writer 会重新验证 仓库、canonical workflow、完成 run、精确 artifact 与获批 SHA;writer 不 checkout、不执行仓库 代码、不运行 npm lifecycle。最小写权限 writer 创建 tag,或只在已有 tag 的 peeled commit 精确 等于获批 SHA 时幂等继续;随后校验已有 GitHub Release 的每个资产并补齐缺失项。发布自动化绝不 执行 npm publish,可选 npx 包只作为 GitHub Release 资产。 Pages deploy 只接受已验证 artifact;第三方搜索/分析默认不启用。
报告漏洞
优先使用 GitHub private vulnerability reporting。不要在公开 Issue 中包含利用细节、凭证、prompt、token 或本地 trace。
状态与发布控制
新文件采用原子 no-replace,避免并发覆盖。脚手架在真实父目录检查项目边界,不能只做字符串前缀判断;已有目标只接受非 symlink 普通文件。
.pipeline-document-locale.json 是不可变呈现 sidecar,不进入旧 canonical schema。新版本固定语言,旧版本回滚时可以安全忽略。
公共文档和本地控制面属于不同发布域。文档站只从白名单源生成静态内容;Dashboard、Change 状态、内部研究、绝对路径和 API 不得进入 Pages artifact。
静态构建至少扫描:
- 私钥头;
Authorization: Bearer;- 常见 token/query 参数;
- 用户主目录绝对路径;
- 内部
docs/adr与docs/superpowers路径; - source map 与调试快照。
自动扫描只是下限,发布前仍需人工检查源清单与 artifact。
GitHub Pages
Pages workflow 可在 pull request 或手动分支上执行构建检查,但只有 main 的非 PR 运行可以配置、上传并部署 artifact。权限限制为读取内容和部署 Pages 所需的最小集合。
安装与更新
tenon setup --codex、tenon setup --claude 等入口必须显式选择宿主,不猜测环境。运行时安装到用户级标准数据目录,项目只保留必要 adapter 与规则投影。
release 使用内容寻址或不可变版本目录,更新通过指针切换;失败时回滚到已验证版本。setup/update 不能重写历史 Change 与 Archive。
自动发现新版本不等于可以绕过 hash、兼容检查或用户配置边界。
威胁与控制
| 威胁 | 主要控制 |
|---|---|
| 旧 Change 串入新对话 | 只在明确恢复时激活 |
| 路径或 symlink 逃逸 | realpath 边界与普通文件校验 |
| 并发覆盖文档 | 原子 no-replace |
| review 被错误复用 | exact phase/event receipt |
| agent 伪造或改写测试证据 | hook 拒绝 13 种写法 + 本机封存链头 + 评审里对测试配置改动的人工确认 |
| 克隆来的仓库带恶意测试命令 | 首次执行前由用户 tenon test trust,CI 显式 TENON_TEST_TRUST=1 |
| 自动更新破坏历史 | 不可变 release 与 sidecar |
| 内部信息进入 Pages | 白名单、扫描、main-only deploy |
发布前清单
- Pages artifact 不含内部路径、token、私钥或 source map;
- Dashboard 仍只绑定 loopback;
- 文档脚手架拒绝 symlink 与越界;
- 更新与回滚在临时环境验证;
- CI 权限和部署分支受限;
- 验证报告与截图已脱敏;
- 未验证的安全承诺没有写进 README。
漏洞报告应包含受影响版本、最小复现、预期与实际结果、影响范围和可安全分享的日志。无法确认是否敏感时,先走私有渠道。