13 KiB
Visual Companion Final Hardening Fixup Design
Date: 2026-06-11 Status: Draft for Drew review
Goal
Finish the PR #1720 visual companion hardening pass so the branch is ready for Jesse review with clean security behavior, deterministic tests, and a PR diff that contains only the companion work.
This is a fixup on top of the existing auth hardening design. It should not redesign the companion or expand the feature surface.
Background
The previous hardening pass added keyed sessions, same-origin WebSocket checks,
URL key stripping, /files/* containment, leak-reduction headers, IPv6 URL
formatting, Windows lifecycle coverage, and PR evidence updates.
The final review pass found five remaining issues:
- The root
GET /screen-selection path can still serve symlinks or hardlinks undercontent/that point outside the content directory. - When the preferred port is occupied, fallback servers can reuse a persisted
.last-token, creating two live same-project companion servers with the same bearer key. stop-server.shcan signal an unrelatednode server.cjsprocess when strong ownership proof is unavailable.- Some tests can pass against the wrong fallback process, leak background processes on failure, or assume symlink support on Windows-like hosts.
- The PR is currently conflicted because the branch contains an older
evalssubmodule bump that was handled separately.
Non-Goals
- Do not add HTTPS tunnel or
wss://origin semantics in this pass. - Do not implement opt-out, free-text, or contrast-helper companion features.
- Do not vendor Alpine, Three.js, or any other JavaScript library.
- Do not attempt to sandbox malicious agent-authored screen HTML.
- Do not add backward compatibility for stale stop-server PID files unless Drew explicitly approves that tradeoff.
Inherited Security Invariants
This fixup preserves the auth hardening already designed and implemented:
.last-tokenandstate/server-inforemain sensitive owner-only state.- Fallback tokens may appear in startup JSON and
state/server-info, but must not be written to.last-token. - Cookies remain port-named,
HttpOnly,SameSite=Strict, and scoped to/. - WebSocket upgrades still require a valid key or cookie.
- WebSocket
Originchecks remain enforced when the browser supplies anOriginheader. - Direct no-
Originclients remain allowed only when they carry the session key. - Generated same-origin screen JavaScript and future same-origin vendored libraries are trusted. Sandboxing malicious screen HTML remains deferred.
Design
1. Rebase Onto Current dev
Rebase brainstorming-companion onto current origin/dev before implementation
work. Resolve the evals submodule conflict by taking dev.
After the rebase:
evalsmust not appear in the PR diff.- PR #1720 can still mention eval evidence that was run elsewhere, but it must include exact external evidence: eval repo commit, scenario path, command, result artifact path or id, and RED/GREEN outcome.
- The PR body must not imply the evals submodule bump is part of this PR.
- Any earlier PR-body text or comment implying the submodule bump is included must be superseded by the final PR-body evidence.
2. Root Screen Containment
The root screen route must use the same containment boundary as /files/*.
getNewestScreen() should ignore any .html candidate that does not pass the
regular-file-inside-content-dir guard. That guard must resolve real paths and
ensure the served file is inside CONTENT_DIR. It must also preserve the
existing hardlink protection by rejecting files whose link count is not exactly
one when the platform reports link counts.
Expected behavior:
- A symlink under
content/pointing outsidecontent/is ignored. - A hardlink under
content/tostate/server-infois ignored whenfs.linkSyncsucceeds andlstat.nlink > 1. - If no safe screen file remains, the waiting page is served.
- Existing
/files/*containment behavior remains unchanged: empty names, dotfiles, symlinks, hardlinks, and directories still return 404.
3. Fallback Token Isolation
Port fallback must not reuse a token loaded from persisted .last-token.
Token source should be explicit in code:
BRAINSTORM_TOKENfrom the environment is an intentional operator/test override. If the preferred port is occupied while an explicit environment token is set, the server must fail closed instead of falling back, because the occupied server may be using the same explicit token..last-tokenis persisted state for same-port reconnect convenience. If the server falls back because the preferred port is occupied, discard that loaded token and generate a fresh unpersisted token for the fallback process.- A newly generated token that was not loaded from
.last-tokencan be reused within the same process because no other live process is known to have it.
The fallback server must continue to avoid overwriting .last-port and
.last-token.
4. Stop-Server Ownership Proof
start-server.sh should create a per-start server instance id and pass it to
Node as an inert command-line argument, for example:
node server.cjs --brainstorm-server-id=<id>
The id is not an auth credential. It is only process-ownership evidence for the
local lifecycle scripts. server.cjs can ignore the argument.
The id must use a shell/MSYS-safe alphabet, such as
^[A-Za-z0-9_-]{32,64}$. Store it in state/server-instance-id with
owner-only permissions.
stop-server.sh should read the expected id from state and only signal the PID
when the target process argv contains the exact argument
--brainstorm-server-id=<id> as a full argv token, not as a loose substring.
Prefer /proc/<pid>/cmdline when available, then fall back to wide ps output.
A matching instance id is sufficient proof even when server-info is missing
or lsof is unavailable. Existing port-to-PID checks may remain as additional
evidence.
Fail closed when ownership cannot be proven:
- missing PID file
- missing or malformed server id
- target command line unavailable
- target command line does not include the expected id
- old/stale session metadata without the new id
This intentionally prefers leaving a stale process running over killing an unrelated process.
Operator-visible outcomes should be explicit:
- missing PID file returns
not_running - missing or malformed server id returns
stale_pid - unavailable command line returns
stale_pid - wrong or absent argv id returns
stale_pid - successful stop returns
stopped
On stale_pid and stopped outcomes, remove server.pid and
server-instance-id so future stop attempts do not keep targeting the same
ambiguous process. Do not remove persistent session content.
5. Test Hardening
The test pass should be deterministic across macOS and the Windows Git Bash host used for validation.
Required changes:
- Fixed-port suites must either fail fast if the server reports a fallback port or drive all clients from the reported startup port.
stop-server.test.shneeds a top-level cleanup trap before any background process is started.- Symlink-specific assertions should probe symlink capability and skip only that assertion when the host cannot create usable test symlinks.
- Tests that create impostor processes must assert that the impostor survives when lifecycle metadata is missing or insufficient.
- Windows/MSYS start-server tests must assert that Windows-like detection still
clears
BRAINSTORM_OWNER_PID, still auto-foregrounds when appropriate, and still passes the instance-id argv exactly.
6. Docs And PR Consistency
Before Jesse reviews, reconcile reviewer-visible docs and PR metadata:
- Update the issue catalog so dispositions match what this PR actually ships.
- Keep auto-open docs consistent with the implemented
--openbehavior. - Keep the documented default idle timeout at 4 hours everywhere.
- Review the PR body against the template after the rebase.
- Record macOS, Windows, browser/manual, and external eval evidence in the PR body with concrete commands and results.
Testing Strategy
Use TDD for each behavior change:
- Add or tighten a focused regression test.
- Run it and confirm it fails for the expected reason.
- Implement the smallest fix.
- Rerun the focused test.
- Rerun the full brainstorm-server suite.
Required focused regressions:
| Behavior | Test File | Focused Command | Expected RED | Expected GREEN |
|---|---|---|---|---|
| Root route ignores symlink escape | tests/brainstorm-server/server.test.js |
node tests/brainstorm-server/server.test.js |
authenticated GET / serves linked outside content |
response serves waiting page or safe screen |
| Root route ignores supported hardlink escape | tests/brainstorm-server/server.test.js |
node tests/brainstorm-server/server.test.js |
authenticated GET / serves hardlinked server-info |
hardlink candidate is ignored when nlink > 1 |
/files/* containment stays unchanged |
tests/brainstorm-server/server.test.js |
node tests/brainstorm-server/server.test.js |
existing containment test regresses | empty, dotfile, directory, symlink, hardlink cases remain 404 |
| Persisted-token fallback rotates token | tests/brainstorm-server/lifecycle.test.js |
node tests/brainstorm-server/lifecycle.test.js |
fallback URL key equals persisted preferred-port key | fallback URL key differs and is not written to .last-token |
| Explicit-token fallback fails closed | tests/brainstorm-server/lifecycle.test.js |
node tests/brainstorm-server/lifecycle.test.js |
server falls back while BRAINSTORM_TOKEN is set |
process exits non-zero and does not start fallback |
| Fallback key cannot authenticate to original server | tests/brainstorm-server/lifecycle.test.js |
node tests/brainstorm-server/lifecycle.test.js |
fallback key receives 200 from original port | original port rejects fallback key |
| Correct instance id permits stop | tests/brainstorm-server/stop-server.test.sh |
bash tests/brainstorm-server/stop-server.test.sh |
real start-server-launched server survives | stop returns stopped and process exits |
| Wrong, missing, malformed, or stale id is safe | tests/brainstorm-server/stop-server.test.sh |
bash tests/brainstorm-server/stop-server.test.sh |
impostor is signaled | stop returns stale_pid and impostor survives |
| Fixed-port suites cannot pass through fallback | tests/brainstorm-server/server.test.js, tests/brainstorm-server/auth.test.js |
respective node commands |
test silently talks to fallback port | test fails clearly or uses reported port intentionally |
| Shell cleanup traps run on failures | tests/brainstorm-server/stop-server.test.sh |
bash tests/brainstorm-server/stop-server.test.sh |
failure leaves child processes | trap reaps background children |
| Windows/MSYS start behavior keeps lifecycle invariants | tests/brainstorm-server/start-server.test.sh, tests/brainstorm-server/windows-lifecycle.test.sh |
bash test commands on macOS and ballmer |
owner PID or argv handling regresses | owner PID is cleared, foreground detection holds, id argv is present |
Each RED/GREEN cycle should leave a short evidence note for the PR body: focused command, failing assertion before the fix, passing assertion after the fix, and whether the evidence was gathered on macOS or Windows.
Verification
Before calling the fixup complete, run:
git fetch origin dev && git rebase origin/devgit diff --quiet origin/dev...HEAD -- evalsgh pr view 1720 --json mergeStateStatus,statusCheckRollup,headRefOidcd tests/brainstorm-server && npm test- relevant focused test commands used during TDD
git diff --check- Node syntax checks for touched JavaScript files
- shell lint for touched shell files
- Windows validation on
ballmer: full runnable brainstorm-server suite plus the standalone Windows lifecycle probe
Manual/browser testing comes only after the automated pass is green.
Acceptance Criteria
- PR #1720 rebases cleanly onto current
dev. evalsis absent from the PR diff.- Root screen serving cannot read outside
content/through symlink or supported hardlink escapes. /files/*containment protections remain unchanged.- No fallback server runs with a token that may be shared with the occupied preferred-port server.
stop-server.shdoes not signal unrelated processes when ownership proof is missing or ambiguous.stop-server.shcan still stop a legitimate server with a matching instance id whenserver-infoorlsofis unavailable.- Focused RED/GREEN evidence is recorded for each regression.
- macOS and Windows validation evidence is recorded in the PR body.
- The PR body accurately describes what is in the branch and what evidence was gathered externally.