可视化伴侣认证加固设计
Section titled “可视化伴侣认证加固设计”日期: 2026-06-10 状态: Draft for Drew 审查
Fix the 安全 and reliability gaps found in PR #1720’s brainstorming visual companion without changing the companion’s core 工作流 or adding 运行时 dependencies.
The fixes must be test-first and must leave clear automated evidence for:
- cross-origin browser tabs cannot inject companion events by riding cookies
- 重启 reconnect works without depending only on browser cookie behavior
- bearer keys do not remain in the visible URL after bootstrap
/files/*cannot serve files outside the content 目录- future same-origin vendored UI libraries still work
Threat Model
Section titled “Threat Model”The companion serves agent-generated 本地 UI for a single brainstorming session. The important assets are:
- screen content served from the companion
- the session key
state/events, which the agent 读取 as user feedback- 本地 files under the companion session 目录
范围内 attackers:
- a malicious browser tab on another
localhostport - a browser page that can make requests to the companion but should not be able to authenticate as the companion UI
- a direct remote 客户端 when the 服务器 is bound to a non-loopback interface
- accidental leakage through URL history, referrers, or committed 本地 state
- content-directory symlinks or path tricks that escape
/files/*
范围外 for this fix:
- malicious agent-authored screen HTML
- malicious same-origin vendored JavaScript 已加载 by a companion screen
This out-of-scope boundary is intentional. Companion screens are part of the agent UI surface. They may use inline scripts today and may someday use same-origin vendored libraries such as Alpine or Three.js. Protecting against malicious screen HTML would require a larger sandboxed-iframe 架构 with a narrow message bridge; that is not the 范围 of this PR 加固 pass.
Current Failures
Section titled “Current Failures”Automated and headed-browser testing found these 失败 in the PR branch:
- A cross-origin localhost page can open a cookie-authenticated WebSocket and
write attacker-controlled choices to
state/eventsafter the real companion page sets the cookie. /files/*serves symlinks that point outsidecontent/, including a symlink tostate/server-infocontaining the keyed URL.- The session key remains in the URL of the actual screen page, so same-origin screen JavaScript and accidental referrers/history can see it.
- The helper reconnects with a keyless
ws://hostURL. In headed Chrome, after a same-port/same-token 重启, the browser stopped presenting the cookie to the restarted 服务器, so the open tab stayed stuck on the tombstone until a 手动 reload. - Shell lint and the lifecycle test need 清理 so the test pass is stable in Codex.
Design
Section titled “Design”1. Bootstrap Keyed Loads
Section titled “1. Bootstrap Keyed Loads”GET /?key=<token> becomes a bootstrap response, not the screen response.
当 the key is valid, the 服务器:
- sets the HttpOnly session cookie as it does today
- returns a small HTML bootstrap page
- the bootstrap page stores the key in tab-scoped
sessionStorage - the bootstrap page navigates to
/usinglocation.replace('/')
After this, the visible screen URL is bare /, not /?key=....
GET / with a valid cookie serves the 当前 screen. GET / without a valid
cookie still returns the 友好 403 page. GET /?key=<wrong> returns 403.
Why sessionStorage: the helper needs a reconnect credential that survives
same-port restarts and does not depend only on cookie behavior. Because screen
HTML is trusted same-origin UI, storing the key in tab-scoped storage is
acceptable for this threat model. It is materially better than leaving the key
in the address bar, history, and referrer surface.
2. WebSocket Same-Origin Enforcement
Section titled “2. WebSocket Same-Origin Enforcement”WebSocket upgrades must pass both checks:
- valid session auth by query key or cookie
- if an
Originheader is present, it must match the request target origin
The origin check should compare:
Origin === "http://" + req.headers.hostBrowser attacker page example:
Origin: http://localhost:9999Host: localhost:58088This must be rejected even if the browser sends the companion cookie.
Legitimate companion page example:
Origin: http://localhost:58088Host: localhost:58088This should be accepted when the key or cookie is valid.
Direct non-browser clients may omit Origin; they still need the session key.
3. Helper Reconnect Credential
Section titled “3. Helper Reconnect Credential”helper.js should read the tab-scoped key from sessionStorage and append it
to the WebSocket URL:
ws://<host>/?key=<stored-key>如果 no stored key exists, the helper falls back to the 当前 cookie-only
ws://<host> behavior. This preserves 兼容性 for already-loaded pages
that do have a valid cookie but no storage entry.
4. /files/* Containment
Section titled “4. /files/* Containment”The file 服务器 should continue to reject empty names and dotfiles. It must also
ensure the file is a real regular file inside CONTENT_DIR.
使用 realpath containment as the boundary:
- compute
realContentDir = fs.realpathSync(CONTENT_DIR) - compute
realFilePath = fs.realpathSync(filePath) - serve only when
realFilePathequals a descendant ofrealContentDir - reject symlinks and anything outside the content 目录 with 404
The 服务器 should keep using path.basename so nested paths remain unsupported.
5. Leak-Reduction Headers
Section titled “5. Leak-Reduction Headers”添加 conservative headers that do not block inline scripts or future same-origin vendored libraries:
Referrer-Policy: no-referrerCache-Control: no-storeX-Frame-Options: DENYContent-Security-Policy: frame-ancestors 'none'Cross-Origin-Resource-Policy: same-originDo not add a restrictive script-src CSP in this pass. The companion currently
injects inline helper JavaScript and future screens may load same-origin
vendored libraries.
6. Gitignore Durable Session State
Section titled “6. Gitignore Durable Session State”添加 .superpowers/ to the repo root .gitignore so persisted companion state
and .last-token are not accidentally committed when using --project-dir.
7. Test Stability And Lint
Section titled “7. Test Stability And Lint”Clean up shell lint 警告 in the touched start/stop scripts.
更新 the lifecycle test that invokes start-server.sh --idle-timeout-minutes
so it cannot hang under Codex’s CODEX_CI foreground auto-detection. The test
should force 背景 mode with --background when it expects the script to
return startup JSON.
Testing Strategy
Section titled “Testing Strategy”All behavior changes should be TDD:
- write the failing focused test
- run it and confirm it fails for the expected reason
- implement the minimum fix
- rerun the focused test
- rerun the full brainstorm-server suite
Required focused regressions:
- valid keyed
/returns bootstrap, not screen content - bootstrap stores key in
sessionStorageand strips the URL - cookie-only
/still serves screen content - helper uses
sessionStoragekey for WebSocket URL - same-origin cookie WebSocket opens
- cross-origin cookie WebSocket is rejected and writes no events
- direct key WebSocket still opens without
Origin - symlink under
content/pointing tostate/server-inforeturns 404 - 安全 headers are present on normal HTML, bootstrap, 403, and file responses
- 重启 same port/token can authenticate reconnect with the stored key
- shell lint passes for touched shell scripts
- lifecycle suite does not hang under Codex
Acceptance Criteria
Section titled “Acceptance Criteria”cd tests/brainstorm-server && npm testpasses repeatedly without hanging.- The 安全 probe that previously wrote
attacker-injectedfrom another localhost origin now fails to open the WebSocket and leavesstate/eventsunchanged. - The symlink-to-
server-infoprobe returns 404. - A headed or headless browser keyed load ends on a bare
/URL and the status pill reaches Connected. - A same-port/same-token 重启 reconnects 自动 without 手动 reload.
scripts/lint-shell.shpasses for the touched shell scripts.
Deferred Work
Section titled “Deferred Work”如果 the 项目 later needs to treat screen HTML as untrusted, 设计 a separate
sandboxed iframe architecture. That should isolate generated screens on a
separate origin or sandboxed frame and expose only a narrow postMessage bridge
for user choices. Do not bundle that into this fix.