fix(stealth): move __tandem* globals into CDP isolated worlds#175
Merged
Conversation
## The leak
Before this change, any page's JavaScript could detect Tandem with a single
property check:
Object.keys(window).filter(k => /^__tandem/.test(k))
// → ['__tandemScroll', '__tandemSelection', '__tandemFormFocus',
// '__tandemVisionActive', '__tandemSecurityAlert',
// '__tandemSecurityMonitorsActive']
That's a shared-signal fingerprint across every Tandem install — the
stealth equivalent of the Date.now jitter fixed in #168. Found during live
MCP dogfooding on 2026-04-18.
## The fix
### Wingman Vision (scroll / selection / form-focus)
Nothing in this path needs to hook main-world prototypes — the listeners
only read DOM state and report via CDP binding. Move everything to an
isolated world.
- `Page.addScriptToEvaluateOnNewDocument` now uses `worldName: 'TandemVision'`
- `Runtime.addBinding` scoped via `executionContextName: 'TandemVision'`
- Current-document injection explicitly creates the isolated world with
`Page.createIsolatedWorld` on the top frame, then `Runtime.evaluate`s
with the returned `contextId`
- `window.__tandemVisionActive` guard flag removed — replaced by a
main-process `wingmanBindingsInstalled: Set<number>` that guarantees
`installWingmanBindings` only runs once per webContents
Net: page main world has no `__tandemScroll`, `__tandemSelection`,
`__tandemFormFocus`, or `__tandemVisionActive`.
### Security monitors (keylogger / wasm / clipboard / form-action)
These MUST run in main world — they hook `EventTarget.prototype.addEventListener`,
`WebAssembly.instantiate`, `navigator.clipboard.readText`, and
`HTMLFormElement.prototype.action`. An isolated-world copy would only hook
that world's prototypes, which page JS never touches.
Hybrid architecture:
- Main-world monitor script: unchanged logic, but replaces direct
`__tandemSecurityAlert(...)` calls with `document.dispatchEvent(new
CustomEvent('__tdm_sec_alert', { detail: JSON.stringify(data) }))`. The
event type is non-branded; the event is only emitted when a known
attack pattern fires, so its presence is conditional, not a static
fingerprint.
- Isolated-world bridge script (worldName: 'TandemSecurity'): listens
for the CustomEvent, forwards `e.detail` to the
`__tandemSecurityAlert` CDP binding. Binding scoped to the isolated
world via `executionContextName`, so page JS can't see it.
- `window.__tandemSecurityMonitorsActive` guard flag removed —
`state.monitorInjected` on the main process already prevents double
injection.
Net: page main world has no `__tandemSecurityAlert` or
`__tandemSecurityMonitorsActive`. The only main-world trace is the
`__tdm_sec_alert` CustomEvent type, and only when an attack pattern
actually fires.
## Regression guard
NEW `src/security/tests/stealth-globals-regression.test.ts` —
programmatically asserts:
1. Main-world monitor source contains zero `/__tandem\w*/` matches
2. Every `Runtime.addBinding` for a `__tandem*` name MUST include
`executionContextName: /^Tandem/`
3. The isolated-world bridge exists with worldName 'TandemSecurity'
4. Main-world source uses `CustomEvent`/`dispatchEvent`, never a direct
binding call
Future PRs that reintroduce a `__tandem*` global in main world will fail
this test loud.
## Updated existing tests
`src/security/tests/script-guard.test.ts` now asserts:
- binding scoping (`executionContextName: 'TandemSecurity'`)
- two `Page.addScriptToEvaluateOnNewDocument` calls (monitor + bridge)
- main-world monitor source does NOT contain any `__tandem*` or
`window.__tandem`
- bridge exists with worldName 'TandemSecurity' and carries the binding
## Verification
- `npm run verify`: 2758 tests pass, check-consistency green
- Existing `Runtime.bindingCalled` handling for `__tandemSecurityAlert`
is unchanged — main-process code still receives alerts exactly as
before
- Stealth improvement is measurable: `/__tandem/` fingerprint pattern
no longer matches anything on `window` in the page's main world
Refs:
- docs/superpowers/agent-experience-fix-plan.md (PR 2 of 5)
- docs/superpowers/tandem-bugs-to-fix.md (Bug 3)
- Stealth audit context: PR #168 (Date.now jitter removal, similar shared-signal fix)
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
4 tasks
hydro13
added a commit
that referenced
this pull request
Apr 18, 2026
…OM sweep
## Symptom
When an agent focus-switches rapidly across tabs in different workspaces
(e.g. MCP-driven rapid focus changes), multiple tabs in the tab-bar
would simultaneously show the purple accent underline — producing a
"which tab is really active?" visual ambiguity. Observed during
2026-04-18 live dogfooding with 4 tabs stuck in the .active state.
## Root cause
Two issues, two fixes.
### 1. workspaces.js:filterTabBar() left .active on hidden tab elements
When a workspace switch hides tabs from other workspaces (display:none),
the helper was clearing .active on the webview element but NOT on the
tab-bar element. Stale class persisted; when those tabs reappeared, the
underline came with them.
Fix: also clear `el.classList.remove('active')` in the hide branch.
### 2. tabs.js:focusRendererTab relied solely on the in-memory Map
The existing `for (const [id, entry] of tabs)` loop toggles .active on
every known tab. Correct in principle, but only covers tabs the module
has registered in its own Map. Any orphan with a stale .active from
another code path stays orphaned.
Fix: before the toggle loop, unconditionally clear .active from every
`#tab-bar .tab.active` in the DOM. Defensive — guarantees at most one
.active in the bar regardless of how the class got there.
## Why both fixes, not just one
The workspace fix is the direct cause-and-effect repair. The DOM sweep
is defense-in-depth — cheap (one querySelectorAll per focus change) and
closes the entire class of "some other code path sets .active" bugs in
one line. Ship both; they compose cleanly.
## Verification
- `npm run verify`: 2758 tests pass, check-consistency green
- Manual test (post-merge):
1. Open 5+ tabs across 2 workspaces
2. Rapidly focus-switch via `tandem_focus_tab` from MCP
3. After settling: `document.querySelectorAll('#tab-bar .tab.active').length` MUST be exactly 1
## Context
- From `docs/superpowers/agent-experience-fix-plan.md` — PR 4 of 5
- Addresses Bug 1 in `docs/superpowers/tandem-bugs-to-fix.md`
- Previous PRs in this sequence: #174 (MCP trust), #175 (stealth), #176 (SKILL.md)
2 tasks
hydro13
added a commit
that referenced
this pull request
Apr 18, 2026
…eeping focus elsewhere
## Bug
Closing the last tab in a non-default workspace used to silently sweep
the user into a different workspace. Observed during 2026-04-18 live
dogfooding: after closing all 9 tabs in my Claude-code workspace, the
main window showed Robin's LinkedIn from Default — and subsequent "open
new tab" actions landed in Default too, not in the now-empty Claude-code
workspace. The workspace identity quietly escaped the user's control.
Root cause: `pickCloseSuccessor` in TabManager preferred the most
recently inserted tab from ANY workspace when the closed tab's
workspace had no remaining tabs. The `activeTabChanged` reconcile then
swung the active workspace to match the successor — a workspace switch
with no UI signal.
## Fix
Per Robin's framing: an "empty workspace" should actually hold 1 fresh
newtab.html, matching how Chrome and Opera handle "you closed the last
tab" — show a new tab page, don't go blank. Workspace identity stays
with the user.
### TabManager changes
- Split `pickCloseSuccessor` into two named helpers:
- `pickSameWorkspaceSuccessor(workspaceId)` — tabs in the closed ws only
- `pickAnyRemainingSuccessor()` — legacy fallback across all tabs
- New callback slot `setWorkspaceEmptiedHandler(handler)` — async
`(workspaceId) => Promise<Tab | null>`. Invoked ONLY when the closed
workspace has zero remaining tabs. Handler is expected to open a
fresh tab and assign it to the emptied workspace, then return that
Tab for `closeTab` to focus.
- `closeTab` new successor order:
1. Same-workspace tab
2. Workspace-emptied handler's returned tab
3. Any remaining tab anywhere (legacy fallback, preserved for tests
and edge cases with no handler wired)
- Handler errors are swallowed (logged) so `closeTab` never hangs or
leaves the user on a blank screen; falls through to legacy fallback.
### Runtime wiring
In `src/bootstrap/runtime.ts`, wire up the handler to:
- Open `shell/newtab.html` via `tabManager.openTab(url, ..., focus:false)`
- Assign the created tab to the emptied workspace via
`workspaceManager.moveTab(wcId, workspaceId)` — same pattern as
`POST /tabs/open` when given a `workspaceId` body param
- Return the tab so `closeTab` focuses it
The workspace-emptied tab inherits the standard Tandem new-tab
experience. No special empty-state UI needed.
## Tests
`src/tabs/tests/tabs.test.ts` — four new cases:
- Handler is invoked when the closed workspace has no other tabs; focus
lands on the replacement tab, not on another workspace's tab
- Handler is NOT invoked when the closed workspace still has other tabs
- Legacy fallback when handler returns null
- Legacy fallback when handler throws
Existing test "falls back to any remaining tab" reclassified to "...
AND no workspace-emptied handler is set" — legacy behavior explicitly
preserved when no handler wired.
Full suite: 2762 passed (+4), check-consistency green.
## Context
- From `docs/superpowers/agent-experience-fix-plan.md` — PR 5 of 5 (final)
- Addresses Bug 2 in `docs/superpowers/tandem-bugs-to-fix.md`
- Completes the punch-list started 2026-04-18. Previous PRs: #174, #175,
#176, #177.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The leak
Before this change, any page's JavaScript could detect Tandem with a single property check:
```js
Object.keys(window).filter(k => /^__tandem/.test(k))
// → ['__tandemScroll', '__tandemSelection', '__tandemFormFocus',
// '__tandemVisionActive', '__tandemSecurityAlert',
// '__tandemSecurityMonitorsActive']
```
That's a shared-signal fingerprint across every Tandem install — the stealth equivalent of the Date.now jitter shared-signal fixed in #168. Found during live MCP dogfooding on 2026-04-18.
The fix
Wingman Vision (scroll / selection / form-focus)
Nothing in this path hooks main-world prototypes — the listeners only read DOM state and report via CDP binding. Moved entirely to isolated world.
Net: page main world has no `__tandemScroll`, `__tandemSelection`, `__tandemFormFocus`, or `__tandemVisionActive`.
Security monitors (keylogger / wasm / clipboard / form-action)
These MUST run in main world — they hook `EventTarget.prototype.addEventListener`, `WebAssembly.instantiate`, `navigator.clipboard.readText`, and `HTMLFormElement.prototype.action`. An isolated-world copy would only hook that world's prototypes, which page JS never touches.
Hybrid architecture:
Net: page main world has no `__tandemSecurityAlert` or `__tandemSecurityMonitorsActive`. The only main-world trace is the `__tdm_sec_alert` CustomEvent type, and only when an attack pattern actually fires.
Regression guard
NEW `src/security/tests/stealth-globals-regression.test.ts` — programmatically asserts:
Future PRs that reintroduce a `__tandem*` global in main world will fail this test loud.
Updated existing tests
`src/security/tests/script-guard.test.ts` now asserts the new shape: binding scoping, two `Page.addScriptToEvaluateOnNewDocument` calls (monitor + bridge), main-world source has no `__tandem*`, and bridge exists with the correct worldName.
Verify
Context