Skip to content

fix(stealth): move __tandem* globals into CDP isolated worlds#175

Merged
hydro13 merged 1 commit into
mainfrom
claude/stealth-tandem-globals-isolated
Apr 18, 2026
Merged

fix(stealth): move __tandem* globals into CDP isolated worlds#175
hydro13 merged 1 commit into
mainfrom
claude/stealth-tandem-globals-isolated

Conversation

@hydro13
Copy link
Copy Markdown
Owner

@hydro13 hydro13 commented Apr 18, 2026

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.

  • `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` 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 hooking 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 (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` matching `/^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 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

  • `npm run verify`: 2758 tests pass (+ 5 new regression-guard cases)
  • `check-consistency` green
  • Existing `Runtime.bindingCalled` handling for `__tandemSecurityAlert` unchanged — main-process code still receives alerts exactly as before
  • Post-merge live test in a real page: `Object.keys(window).filter(k => /__tandem/i.test(k))` returns `[]`

Context

## 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-commenter
Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 66.66667% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/security/script-guard.ts 66.66% 3 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@hydro13 hydro13 merged commit 3e30483 into main Apr 18, 2026
4 checks passed
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)
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants