superpowers Visual Companion 最终安全加固修复设计:根路由文件收容、回退令牌隔离与进程所有权证明
本文围绕 superpowers 仓库中 Visual Companion(brainstorming 可视化伴侣服务)的"最终加固修复"设计文档展开,系统讲解该设计针对的五个遗留安全问题、每项修复的实现机制,以及配套的 TDD 回归测试策略。读完本文,你将理解如何为一个本地零依赖 Node.js 服务设计根路由的文件收容边界(防符号链接/硬链接逃逸)、端口回退场景下的令牌隔离与 fail-closed 策略,以及基于"每次启动实例 ID"的进程所有权证明来安全停止后台进程。
1. 背景与定位:一次只修问题、不扩面的收尾加固
本设计文档(2026-06-11-visual-companion-final-hardening-fixup-design.md)的目标是:完成 PR #1720 对 Visual Companion 的加固收尾,使分支以"干净的安全行为、确定性的测试、只包含 companion 工作的 PR diff"进入最终评审。它明确定位为一轮在既有认证加固(auth hardening)之上的 fixup,不应重新设计 companion 或扩大功能面。
上一轮加固已经交付了:keyed sessions(带密钥的会话)、同源 WebSocket 检查、URL key 剥离、/files/* 收容、泄减响应头(leak-reduction headers)、IPv6 URL 格式化、Windows 生命周期覆盖,以及 PR 证据更新。最终评审又发现了五个遗留问题:
- 根路由
GET /的选屏路径仍可能提供content/下指向目录外的符号链接或硬链接; - 首选端口被占用时,回退服务器会复用持久化的
.last-token,造成同一项目下两个存活的 companion 服务器共用同一个 bearer key; - 当强所有权证据不可用时,
stop-server.sh可能向无关的node server.cjs进程发信号; - 部分测试可能静默通过"错误的回退进程"、在失败时泄漏后台进程,或在类似 Windows 的主机上假设符号链接可用;
- 由于分支包含一个已被单独处理的较旧
evals子模块升级,PR 当前处于冲突状态。
非目标(Non-Goals)
设计同时划清了边界,明确本轮不做的事情:
- 不加入 HTTPS 隧道或
wss://origin 语义; - 不实现 opt-out、自由文本反馈或对比度辅助等 companion 功能;
- 不 vendor Alpine、Three.js 或任何 JavaScript 库;
- 不对恶意 agent 编写的 screen HTML 做沙箱隔离;
- 不兼容过期的 stop-server PID 文件,除非评审人明确批准该权衡。
继承的安全不变量
本 fixup 完整保留上一轮认证加固设计的不变量,这些约束在后续每项修复中都必须继续成立:
.last-token与state/server-info始终是敏感的、仅属主(owner-only)状态;- 回退令牌可以出现在启动 JSON 和
state/server-info中,但不得写入.last-token; - Cookie 保持"按端口命名、
HttpOnly、SameSite=Strict、作用域/"; - WebSocket 升级仍然要求有效的 key 或 cookie;
- 浏览器提供
Origin头时,WebSocketOrigin检查仍然强制生效; - 不带
Origin头的直连客户端仅在携带会话密钥时才被允许; - 生成的同源 screen JavaScript(及未来同源 vendored 库)可信,恶意 screen HTML 的沙箱化继续推迟。
2. 修复一:Rebase 到当前 dev,保持 PR diff 纯净
第一项修复是分支管理问题:在实施工作开始前,先把 brainstorming-companion 分支 rebase 到当前 origin/dev,并将 evals 子模块冲突解决为"取 dev"。rebase 之后必须满足:
evals不出现在 PR diff 中;- PR #1720 仍可引用在别处跑过的 eval 证据,但必须给出精确的外部证据:eval 仓库 commit、scenario 路径、命令、结果工件路径或 id、RED/GREEN 结论;
- PR 正文不得暗示 evals 子模块升级属于本 PR;任何早期暗示子模块升级包含在内的正文或评论,都必须被最终的 PR 正文证据取代。
对应实现计划(2026-06-11-visual-companion-final-hardening-fixup.md)中给出的解冲突命令为:
git restore --source=origin/dev --staged --worktree evals
git add evals
git rebase --continue
完成后可用 git diff --name-only origin/dev...HEAD -- evals 验证输出为空。
3. 修复二:根路由文件收容——getNewestScreen() 复用 /files/* 的边界守卫
问题
根路由 GET / 会从 content/ 目录中挑选最新的 .html 文件作为当前"屏幕"展示。如果 content/ 下存在指向目录外(例如 state/server-info)的符号链接或硬链接,且其 mtime 最新,根路由就会把目录外的敏感内容当作屏幕渲染出去——而 /files/* 路由早已具备收容保护,根路由却缺少同一道边界。
设计
设计要求根路由必须使用与 /files/* 相同的收容边界:getNewestScreen() 应忽略任何不通过"内容目录内常规文件"守卫的 .html 候选。该守卫必须解析真实路径(realpath),确认被提供文件确实位于 CONTENT_DIR 内,并且保留既有硬链接保护——当平台报告链接数时,拒绝链接数不为 1 的文件。
在当前仓库中,这一设计已经落地。server.cjs 中的 getNewestScreen() 在筛选 .html 候选时,先调用 isRegularFileInsideContentDir(fp),不过关的候选直接返回 null 被过滤掉:
function getNewestScreen() {
const files = fs.readdirSync(CONTENT_DIR)
.filter(f => !f.startsWith('.') && f.endsWith('.html'))
.map(f => {
const fp = path.join(CONTENT_DIR, f);
if (!isRegularFileInsideContentDir(fp)) return null;
return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
})
.filter(Boolean)
.sort((a, b) => b.mtime - a.mtime);
return files.length > 0 ? files[0].path : null;
}
而守卫本身 isRegularFileInsideContentDir() 的实现与设计的四条要求一一对应:
function isRegularFileInsideContentDir(filePath) {
let stat, realContentDir, realFilePath;
try {
stat = fs.lstatSync(filePath);
if (stat.isSymbolicLink()) return false; // 符号链接一律拒绝
if (!stat.isFile()) return false; // 只接受常规文件
if (stat.nlink !== 1) return false; // 硬链接(nlink > 1)拒绝
realContentDir = fs.realpathSync(CONTENT_DIR);
realFilePath = fs.realpathSync(filePath); // 解析真实路径
} catch (e) {
return false; // 任何异常都 fail-closed
}
return realFilePath.startsWith(realContentDir + path.sep); // 必须在 CONTENT_DIR 内
}
要点在于 lstat 与 realpath 的分工:lstatSync 不做链接解引用,因此符号链接在第一步就被识别并拒绝;nlink !== 1 的判定覆盖硬链接逃逸;realpathSync 对真实路径做前缀比较,则堵住了"路径穿越后 realpath 仍落在目录外"这类边界情况。/files/* 路由(server.cjs)复用同一个守卫:空文件名、点开头文件、符号链接、硬链接、目录一律 404。
预期行为
content/下指向目录外的符号链接被忽略;- 当
fs.linkSync成功且lstat.nlink > 1时,指向state/server-info的硬链接被忽略; - 若没有任何安全的 screen 文件剩余,则提供等待页(waiting page);
/files/*的既有收容行为保持不变:空名、点文件、符号链接、硬链接、目录仍返回 404。
4. 修复三:回退令牌隔离——EADDRINUSE 时按令牌来源分路处理
问题
服务在首选端口被占用时回退到随机端口。但如果回退进程直接复用了从持久化 .last-token 加载的令牌,就会出现两个存活的同一项目 companion 服务器持有同一个 bearer key——任何拿到其中一个 URL 的本地标签页都能认证到另一台服务器。
设计:令牌来源必须显式
设计将令牌来源分为三类,并在代码中显式追踪:
BRAINSTORM_TOKEN环境变量:有意的运维/测试覆盖。如果设置了显式环境变量令牌而首选端口被占用,服务器必须 fail closed(拒绝回退直接退出),因为占用者很可能正在使用同一个显式令牌;.last-token文件:为"同端口重连便利"而持久化的状态。若服务器因首选端口被占用而回退,必须丢弃加载到的令牌,为回退进程生成一个全新的、不持久化的令牌;- 新生成的令牌(并非从
.last-token加载而来)可以在同一进程内复用,因为没有已知的其他存活进程持有它。
此外,回退服务器必须继续避免覆盖 .last-port 和 .last-token。
源码印证
当前 server.cjs 中的 initialToken() 正是按"env → file → generated"三条路径显式返回 { value, source }:
function initialToken() {
if (process.env.BRAINSTORM_TOKEN) {
return { value: process.env.BRAINSTORM_TOKEN, source: 'env' };
}
if (TOKEN_FILE) {
try {
const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
if (/^[0-9a-f]{32,}$/i.test(t)) {
chmodOwnerOnly(TOKEN_FILE);
return { value: t, source: 'file' };
}
} catch (e) { /* no prior token recorded */ }
}
return { value: generateToken(), source: 'generated' };
}
const tokenInfo = initialToken();
let TOKEN = tokenInfo.value;
let tokenSource = tokenInfo.source;
EADDRINUSE 处理分支(server.cjs)据此分路:来源是 env 时打印错误并以非零码退出;来源是 file 时生成新令牌并标记 tokenSource = 'generated-fallback',然后监听随机端口(随机高端口由 randomPort() 产生,范围 49152–65534):
if (err.code === 'EADDRINUSE' && !triedFallback) {
if (tokenSource === 'env') {
console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token');
process.exit(1);
}
triedFallback = true;
PORT = randomPort();
if (tokenSource === 'file') {
TOKEN = generateToken();
tokenSource = 'generated-fallback';
}
server.listen(PORT, HOST, onListen);
}
与之配套的是 onListen 中的持久化抑制(server.cjs):只有拿到首选端口(!triedFallback)时,才会把绑定端口和令牌写入 PORT_FILE/TOKEN_FILE——回退时写入会覆盖共享文件,使另一会话已打开的浏览器标签页掉线。同时 Cookie 名 brainstorm-key-<实际绑定端口> 按实际绑定端口细化,避免与另一台服务器的 cookie 在同一 localhost cookie jar 中碰撞。
5. 修复四:Stop-Server 进程所有权证明——每次启动的实例 ID
问题
stop-server.sh 依据 PID 文件停止服务器。当强所有权证据(如 server-info、lsof)不可用时,旧逻辑可能只凭"进程名叫 node server.cjs"就发信号——而 PID 文件在重启或 PID 回绕后可能指向一个完全无关的进程。
设计:惰性 argv 参数作为所有权凭证
start-server.sh 在每次启动时生成一个服务器实例 id,并将其作为惰性(inert)命令行参数传给 Node:
node server.cjs --brainstorm-server-id=<id>
该 id 不是认证凭证,只是本地生命周期脚本使用的进程所有权证据;server.cjs 可以完全忽略这个参数。id 必须使用 shell/MSYS 安全字母表,匹配 ^[A-Za-z0-9_-]{32,64}$,并以仅属主权限存入 state/server-instance-id。
stop-server.sh 则读取状态目录中的期望 id,只有当目标进程 argv 中包含完整的 argv token --brainstorm-server-id=<id>(而不是松散的子串匹配)时才发送信号。命令行的读取优先使用 /proc/<pid>/cmdline(按 NUL 分隔逐 token 精确比较),不可用时回退到宽格式 ps 输出。匹配的实例 id 本身就是充分证明,即使 server-info 缺失或 lsof 不可用;既有的端口到 PID 检查可以作为附加证据保留。
Fail-closed 与运维可见的结果
所有权无法证明时一律 fail closed,覆盖五种情形:PID 文件缺失、server id 缺失或格式错误、目标命令行不可用、目标命令行不含期望 id、以及没有新 id 的旧/陈旧会话元数据。设计明确偏好"让陈旧进程继续运行",而不是"杀掉无关进程"。
运维可见的结果被规范为明确的输出:
| 情形 | 输出 |
|---|---|
| PID 文件缺失 | not_running |
| server id 缺失或格式错误 | stale_pid |
| 目标命令行不可用 | stale_pid |
| argv 中 id 错误或缺失 | stale_pid |
| 成功停止 | stopped |
在 stale_pid 与 stopped 两种结果下,删除 server.pid 和 server-instance-id,防止后续 stop 尝试反复瞄准同一个含糊进程;但不删除持久化的会话内容。
源码印证
start-server.sh 中可以看到 id 的生成与落盘:优先从 /dev/urandom 读取 24 字节十六进制(48 字符),若校验 ^[A-Za-z0-9_-]{32,64}$ 失败则用 $$、时间戳和 $RANDOM 拼出 32 位十六进制兜底;写入 state/server-instance-id 后执行 chmod 600。两条 Node 启动命令(前台与 nohup 后台)都在 server.cjs 之后传递该参数(start-server.sh 与 L180),确保 argv token 完整:
nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" > "$LOG_FILE" 2>&1 &
stop-server.sh 实现了完整的校验链:read_expected_server_id() 从状态文件读取并校验 id 字母表;command_has_server_id() 在 /proc/$pid/cmdline 可读时按 NUL 分隔逐 token 精确比对 --brainstorm-server-id=$expected,否则回退到 ps -ww/ps -f 的宽输出并做带边界的子串匹配(前后加空格防止前缀误匹配);is_brainstorm_server() 要求目标 PID 存活、期望 id 可读且 argv 匹配三者同时成立。校验失败即走 stale 分支:
if ! is_brainstorm_server "$pid"; then
rm -f "$PID_FILE" "$SERVER_ID_FILE"
mark_stopped "stale_pid"
echo '{"status": "stale_pid"}'
exit 0
fi
成功停止后同样清理元数据(stop-server.sh):rm -f "$PID_FILE" "$SERVER_ID_FILE" "${STATE_DIR}/server.log",并仅在会话目录位于 /tmp 下时删除临时目录,持久化的 .superpowers/ 内容保留以供事后查看。
6. 修复五:测试加固——跨 macOS 与 Windows Git Bash 的确定性
设计第五项要求测试在 macOS 与用于验证的 Windows Git Bash 主机(验证主机名为 ballmer)上都是确定性的,具体要求:
- 固定端口套件:如果服务器报告了回退端口,必须快速失败;或者所有客户端都从"报告的启动端口"发起连接——不允许静默测到回退进程上;
stop-server.test.sh:在任何后台进程启动之前,必须有一个顶层 cleanup trap,防止失败时泄漏后台进程;- 符号链接断言:应先探测主机的符号链接能力,仅在该主机无法创建可用测试符号链接时跳过那一条断言,而不是跳过整个套件;
- 冒充者(impostor)测试:测试中创建的冒充进程,必须在生命周期元数据缺失或不足时断言其存活(即 stop 不得杀掉它);
- Windows/MSYS 启动测试:必须断言 Windows 类检测仍会清除
BRAINSTORM_OWNER_PID、在适当时机自动前台化、并且精确传递实例 id argv。
这套要求与 start-server.sh 的实际行为一致:is_windows_like_shell() 通过 OSTYPE/MSYSTEM/uname -s 识别 MSYS/MINGW/CYGWIN 环境;检测命中时自动转前台(Git Bash 会收割 nohup 后台进程),并在 Windows 类 shell 下清空 OWNER_PID——因为 Node 无法看到 MSYS2 PID 命名空间中的 POSIX PID,错误的 PID 会导致服务器在生命周期检查中自我终止,此时 idle 超时就成为唯一的关闭触发器。
7. 修复六:文档与 PR 一致性
在最终评审之前,需要把评审者可见的文档与 PR 元数据对齐:
- 更新问题目录(issue catalog),使各项处置(disposition)与 PR 实际交付一致;
- 让自动打开(auto-open)相关文档与实现的
--open行为保持一致(例如 visual-companion.md 中启动用户已批准的 companion 会话的平台命令带上--open,而远程绑定示例因有意跳过自动打开而不加); - 所有地方保持默认 idle 超时的文档值为 4 小时(与 start-server.sh 注释中
--idle-timeout-minutes默认 240 分钟一致); - rebase 之后按模板复核 PR 正文;
- 在 PR 正文中记录 macOS、Windows、浏览器/手工、外部 eval 证据,附具体命令与结果。
8. 测试策略:TDD 回归矩阵
设计为每个行为变更采用 TDD:先新增或收紧一个聚焦回归测试,运行并确认它因预期原因失败(RED),实现最小修复,重跑聚焦测试,再重跑完整的 brainstorm-server 套件。
必测回归矩阵
| 行为 | 测试文件 | 聚焦命令 | 预期 RED | 预期 GREEN |
|---|---|---|---|---|
| 根路由忽略符号链接逃逸 | tests/brainstorm-server/server.test.js |
node tests/brainstorm-server/server.test.js |
认证后的 GET / 提供了被链接的目录外内容 |
响应提供等待页或安全屏幕 |
| 根路由忽略受支持的硬链接逃逸 | tests/brainstorm-server/server.test.js |
node tests/brainstorm-server/server.test.js |
认证后的 GET / 提供了硬链接的 server-info |
nlink > 1 时硬链接候选被忽略 |
/files/* 收容保持不变 |
tests/brainstorm-server/server.test.js |
node tests/brainstorm-server/server.test.js |
既有收容测试回归 | 空名、点文件、目录、符号链接、硬链接用例仍 404 |
| 持久化令牌回退时轮换令牌 | tests/brainstorm-server/lifecycle.test.js |
node tests/brainstorm-server/lifecycle.test.js |
回退 URL key 等于持久化的首选端口 key | 回退 URL key 不同且未写入 .last-token |
| 显式令牌回退 fail closed | tests/brainstorm-server/lifecycle.test.js |
node tests/brainstorm-server/lifecycle.test.js |
设置了 BRAINSTORM_TOKEN 时服务器仍回退 |
进程非零退出且不启动回退 |
| 回退 key 无法认证到原服务器 | tests/brainstorm-server/lifecycle.test.js |
node tests/brainstorm-server/lifecycle.test.js |
回退 key 从原端口得到 200 | 原端口拒绝回退 key |
| 正确实例 id 允许停止 | tests/brainstorm-server/stop-server.test.sh |
bash tests/brainstorm-server/stop-server.test.sh |
真实由 start-server 启动的服务器存活 | stop 返回 stopped 且进程退出 |
| 错误/缺失/畸形/陈旧 id 是安全的 | tests/brainstorm-server/stop-server.test.sh |
bash tests/brainstorm-server/stop-server.test.sh |
冒充者被发信号 | stop 返回 stale_pid 且冒充者存活 |
| 固定端口套件不能经回退通过 | tests/brainstorm-server/server.test.js、tests/brainstorm-server/auth.test.js |
各自的 node 命令 |
测试静默地测到回退端口 | 测试明确失败,或有意使用报告端口 |
| Shell 清理 trap 在失败时执行 | tests/brainstorm-server/stop-server.test.sh |
bash tests/brainstorm-server/stop-server.test.sh |
失败时留下子进程 | trap 收割后台子进程 |
| Windows/MSYS 启动行为保持生命周期不变量 | tests/brainstorm-server/start-server.test.sh、tests/brainstorm-server/windows-lifecycle.test.sh |
在 macOS 和 ballmer 上运行 bash 测试命令 |
owner PID 或 argv 处理回归 | owner PID 被清除、前台检测成立、id argv 存在 |
仓库中的回归测试落点
当前仓库中上述回归均已存在:server.test.js 包含"根路由符号链接逃逸"与"根路由硬链接逃逸"两条测试;lifecycle.test.js 包含"持久化令牌回退生成新的非持久化 key"与"显式 BRAINSTORM_TOKEN 回退 fail closed"测试;stop-server.test.sh 包含"匹配实例 id 的真实服务器被停止"(约 L63)与"缺失/错误/畸形 id 的冒充者被放过"(约 L126-L175)等用例。每个 RED/GREEN 循环都应为 PR 正文留下简短证据记录:聚焦命令、修复前的失败断言、修复后的通过断言,以及证据采集于 macOS 还是 Windows。
9. 验证清单与验收标准
完成前验证命令
声明 fixup 完成之前,需依次执行:
git fetch origin dev && git rebase origin/dev
git diff --quiet origin/dev...HEAD -- evals
gh pr view 1720 --json mergeStateStatus,statusCheckRollup,headRefOid
cd tests/brainstorm-server && npm test
# 以及 TDD 期间使用的各聚焦测试命令
git diff --check
外加:触及的 JavaScript 文件做 Node 语法检查、触及的 shell 文件做 shell lint(可用 scripts/lint-shell.sh)、在 ballmer 上做 Windows 验证(完整可运行的 brainstorm-server 套件加独立 Windows 生命周期探测)。浏览器/手工测试只在自动化套件全绿之后进行。
验收标准
- PR #1720 干净地 rebase 到当前
dev; evals不出现在 PR diff 中;- 根路由屏幕提供无法通过符号链接或受支持的硬链接逃逸读取
content/之外内容; /files/*收容保护保持不变;- 没有任何回退服务器运行在可能与首选端口服务器共享的令牌上;
stop-server.sh在所有权证据缺失或含糊时不向无关进程发信号;- 即使
server-info或lsof不可用,只要实例 id 匹配,stop-server.sh仍能停止合法服务器; - 每个回归都有聚焦的 RED/GREEN 证据记录;
- PR 正文记录了 macOS 与 Windows 验证证据;
- PR 正文准确描述分支内容与在外部采集的证据。
10. 小结:三层防御的组合逻辑
这轮最终加固的核心价值在于把"本地看似无害的三个机制"分别钉死在显式边界上:根路由选屏与 /files/* 共用同一个"常规文件 + nlink=1 + realpath 前缀"守卫(server.cjs);端口回退按令牌来源(env/file/generated)显式分路,宁可拒绝启动也不复用可能共享的 key(server.cjs);进程停止从"进程名匹配"升级为"每次启动唯一 argv 凭证的精确 token 匹配",一切含糊都 fail closed 为 stale_pid(stop-server.sh)。配合固定端口守卫、cleanup trap 与符号链接能力探测,整套生命周期脚本在 macOS 与 Windows Git Bash 上都能给出确定、可审计、且"宁可漏杀、绝不误杀"的安全行为。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00