tiletopia
/mnt/d/dev/tiletopia
311 events
104 tool calls
4.3M tokens
$14.05 notional cost
6/9/2026, 11:12:48 PM
→
20d agoin 27.2K out 117K cache-write 494.2K cache-read 3.6M
Transcript
311 turns · chronological (UTC timestamps shown local)-
<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>
-
<local-command-stdout>Set model to [1mFable 5[22m and saved as your default for new sessions</local-command-stdout>
-
<command-name>/model</command-name> <command-message>model</command-message> <command-args></command-args> -
can you take a look at my tiling app and see what bugs there are, i know of a few but im interested to know what you can find
-
- Read /mnt/d/dev/tiletopia/memory.md 665ms 48.7 KB
-
I'll start by reading the project memory and getting an overview of the codebase, then dig in for bugs.
-
- Bash find /mnt/d/dev/tiletopia/src /mnt/d/dev/tiletopia/src-tauri/src -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.rs… 494ms 1.7 KB
-
- Agent You are doing a careful bug hunt in a Tauri 2 + React 18 + TypeScript app called tiletopia (a tiling multi-terminal mana… 532.3s 7.8 KB
-
You are doing a careful bug hunt in a Tauri 2 + React 18 + TypeScript app called tiletopia (a tiling multi-terminal manager for WSL, like tmux for Windows). Your scope: the frontend core state/layout logic. Files to review (read them fully): - /mnt/d/dev/tiletopia/src/App.tsx (2333 lines — the main orchestrator: workspaces/tabs state, multi-window pane transfer, broadcast input, auto-save debounce, MCP request handling, keyboard shortcuts, drag-and-drop) - /mnt/d/dev/tiletopia/src/lib/layout/tree.ts (binary layout tree: split/close/promote/swap/preset helpers, serialization, migration) - /mnt/d/dev/tiletopia/src/lib/layout/orchestration.tsx (context providing PaneOps) - /mnt/d/dev/tiletopia/src/lib/layout/Gutter.tsx (drag-to-resize divider) Look for REAL bugs only: race conditions, stale-closure bugs in hooks, incorrect effect dependencies, state desync between refs and state, edge cases in tree mutations (e.g. closing the last pane, splitting during drag, ratio clamping), broken persistence/migration paths, double-listeners, memory leaks (uncleaned listeners/intervals/timeouts), multi-window coordination races, broadcast loops, focus-handling bugs. Pay special attention to React StrictMode double-mount safety and to interactions between debounced auto-save and workspace switching/window close. Known bugs to SKIP (already tracked): PTY leak when a detached window is OS-closed; transfer-refcount PTY leak if a window closes mid-adopt; Alt+1..9 swallowing terminal Alt+digit input. For each finding, verify it by re-reading the surrounding code — do not report something a nearby guard already handles. Return a list of findings, each with: severity (high/med/low), file:line, a one-paragraph explanation of the bug and the scenario that triggers it, and your confidence (certain / likely / needs-runtime-check). Quality over quantity — false positives are costly. Your final message is data for the orchestrator, not user-facing prose.
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 441ms 57.4 KB
-
- Read /mnt/d/dev/tiletopia/src/lib/layout/tree.ts 519ms 31.7 KB
-
- Read /mnt/d/dev/tiletopia/src/lib/layout/orchestration.tsx 7ms 5.5 KB
-
- Read /mnt/d/dev/tiletopia/src/lib/layout/Gutter.tsx 11ms 5.0 KB
-
You are doing a careful bug hunt in a Tauri 2 + React 18 + TypeScript app called tiletopia (a tiling multi-terminal manager for WSL). Your scope: pane lifecycle and the xterm.js integration. Files to review (read them fully): - /mnt/d/dev/tiletopia/src/components/XtermPane.tsx (xterm mount, PTY spawn/kill, data listeners, resize/fit, addons: canvas/search/unicode11/web-links, custom key handler, scrollback-ring replay for transferred panes, color theme effect, font size effect) - /mnt/d/dev/tiletopia/src/lib/layout/LeafPane.tsx (pane chrome/toolbar, idle detection interval, narrow-width ResizeObserver tiers, broadcast flag, shell picker) - /mnt/d/dev/tiletopia/src/ipc.ts (Tauri invoke/event wrappers) - /mnt/d/dev/tiletopia/src/components/SearchBar.tsx and /mnt/d/dev/tiletopia/src/lib/theme.ts (quick pass) Look for REAL bugs: listener leaks or double-subscription (especially under React 18 StrictMode double-mount in dev), races between PTY spawn and data subscription (lost output), base64/UTF-8 encoding errors (multi-byte chars split across chunks, surrogate pairs), resize feedback loops, fit() called on hidden/zero-size containers, stale closures over props in long-lived listeners, the existingPaneId transfer path skipping cleanup, addon load-order issues, dispose-order bugs on unmount, idle-detection interval leaks or notification spam, custom key handler being replaced and losing chords. Known bugs to SKIP (already tracked): PTY leak when a detached window is OS-closed; transfer-refcount PTY leak if a window closes mid-adopt; Alt+1..9 swallowing terminal Alt+digit input. Verify each finding against surrounding code before reporting — no false positives from guards you missed. Return findings each with: severity (high/med/low), file:line, one-paragraph explanation with trigger scenario, confidence (certain / likely / needs-runtime-check). Your final message is data for the orchestrator, not user-facing prose.
-
- Agent You are doing a careful bug hunt in a Tauri 2 + React 18 + TypeScript app called tiletopia (a tiling multi-terminal mana… 358.7s 6.7 KB
-
- Read /mnt/d/dev/tiletopia/src/components/XtermPane.tsx 12ms 26.2 KB
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 321ms 35.9 KB
-
- Read /mnt/d/dev/tiletopia/src/lib/layout/LeafPane.tsx 16ms 26.1 KB
-
- Read /mnt/d/dev/tiletopia/src/ipc.ts 9ms 9.1 KB
-
- Read /mnt/d/dev/tiletopia/src/lib/theme.ts 10ms 6.4 KB
-
- Read /mnt/d/dev/tiletopia/src/components/SearchBar.tsx 9ms 5.4 KB
-
You are doing a careful bug hunt in the Rust backend of a Tauri 2 app called tiletopia (a tiling multi-terminal manager spawning WSL/PowerShell/SSH PTYs via portable-pty). Files to review (read them fully): - /mnt/d/dev/tiletopia/src-tauri/src/pty.rs (PtyManager: Mutex<HashMap<PaneId, PaneHandle>>, reader threads emitting pane://{id}/data events, scrollback ring buffer, transfer refcounts, kill/resize/write) - /mnt/d/dev/tiletopia/src-tauri/src/commands.rs (Tauri commands: spawn/write/resize/kill pane, window creation, workspace save/load) - /mnt/d/dev/tiletopia/src-tauri/src/window_state.rs (cross-window save aggregator, debounced atomic write) - /mnt/d/dev/tiletopia/src-tauri/src/lib.rs (app setup, window event handlers, exit-prevention logic) - /mnt/d/dev/tiletopia/src-tauri/src/hosts.rs and /mnt/d/dev/tiletopia/src-tauri/src/creds.rs (SSH host store + credential handling) Look for REAL bugs: deadlocks (lock held across emit/IO/another lock), lock poisoning handling, races between spawn/kill/transfer refcount operations, reader-thread lifecycle leaks (thread never joins, writes after kill), TOCTOU in the save aggregator, atomic-write correctness (tmp+rename across filesystems, missing fsync where it matters), unbounded memory (ring buffer, transfer map entries never cleaned), error swallowing that breaks invariants, blocking calls on the async runtime, panics reachable from event-loop callbacks (a known past crash class: tokio::spawn from sync window-event handlers — that one is FIXED, but look for siblings), command injection or path issues in WSL/SSH spawn argument construction, plaintext credential exposure. Known bugs to SKIP (already tracked): PTY leak when a detached window is OS-closed (frontend won't run unmount cleanup); transfer-refcount PTY leak if a window closes mid-adopt. Verify each finding against surrounding code before reporting. Return findings each with: severity (high/med/low), file:line, one-paragraph explanation with trigger scenario, confidence (certain / likely / needs-runtime-check). Your final message is data for the orchestrator, not user-facing prose. -
- Agent You are doing a careful bug hunt in the Rust backend of a Tauri 2 app called tiletopia (a tiling multi-terminal manager … 327.1s 6.6 KB
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/pty.rs 9ms 21.2 KB
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/commands.rs 14ms 17.9 KB
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/window_state.rs 7ms 6.5 KB
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/lib.rs 6ms 7.7 KB
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/hosts.rs 6ms 10.8 KB
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/creds.rs 6ms 1.6 KB
-
You are doing a careful bug hunt in the MCP (Model Context Protocol) server subsystem of tiletopia, a Tauri 2 app that exposes its terminal-pane workspace to Claude over MCP (localhost, token-authenticated). The MCP server lets an AI list panes, read scrollback, write keystrokes to panes, spawn/close panes, manage SSH hosts — so security and policy enforcement matter. Files to review (read them fully): - /mnt/d/dev/tiletopia/src-tauri/src/mcp.rs (1536 lines — the JSON-RPC/MCP server, tool dispatch, auth) - /mnt/d/dev/tiletopia/src-tauri/src/mcp_policy.rs (1403 lines — allow/deny policy engine, audit log) - /mnt/d/dev/tiletopia/src/components/McpPanel.tsx, PolicyTab.tsx, AuditTab.tsx, McpConfirm.tsx (frontend policy UI + confirmation flow) Look for REAL bugs: auth bypasses (token comparison, missing auth on any endpoint/tool, timing), policy-engine holes (rule matching that can be evaded — e.g. write_pane content rules bypassed by chunking/encoding, glob/regex anchoring mistakes, default-allow fallthroughs, race between policy edit and in-flight request), confirmation-flow races (request approved twice, stale confirmation applied to a different request, queue desync between Rust and React), audit-log gaps (denied/approved actions not logged, log injection), saved-password exposure through any MCP tool or event payload, deadlocks/blocking in the server loop, JSON-RPC parsing panics on malformed input, resource leaks per-connection, localhost binding mistakes (0.0.0.0). Verify each finding against surrounding code before reporting — the policy engine likely has guards; don't report what's already handled. Return findings each with: severity (high/med/low), file:line, one-paragraph explanation with trigger scenario, confidence (certain / likely / needs-runtime-check). Your final message is data for the orchestrator, not user-facing prose.
-
- Agent You are doing a careful bug hunt in the MCP (Model Context Protocol) server subsystem of tiletopia, a Tauri 2 app that e… 238.5s 7.3 KB
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/mcp.rs 433ms 65.1 KB
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/mcp_policy.rs 425ms 51.4 KB
-
- Agent You are doing a careful bug hunt in a Tauri 2 + React 18 + TypeScript app called tiletopia (a tiling multi-terminal mana… 453.0s 8.1 KB
-
You are doing a careful bug hunt in a Tauri 2 + React 18 + TypeScript app called tiletopia (a tiling multi-terminal manager). Your scope: the secondary UI components and utility modules. Files to review (read them fully): - /mnt/d/dev/tiletopia/src/components/TabStrip.tsx (tab bar, rename, close-confirm popover, drag reorder) - /mnt/d/dev/tiletopia/src/components/Palette.tsx (Ctrl+K fuzzy palette) - /mnt/d/dev/tiletopia/src/components/HostManager.tsx (SSH host CRUD + saved passwords) - /mnt/d/dev/tiletopia/src/components/ColorPanel.tsx + /mnt/d/dev/tiletopia/src/lib/theme.ts (color theming, localStorage persistence, cross-window storage-event sync) - /mnt/d/dev/tiletopia/src/lib/shortcuts.ts (shortcut registry, source of truth for README/help) - /mnt/d/dev/tiletopia/src/components/Help.tsx, Notifications.tsx, ErrorBoundary.tsx, McpConfirm.tsx (quick pass) Look for REAL bugs: keyboard handler conflicts or events leaking through modals to terminals below, focus traps, list index desync (palette arrow-nav vs filtered list), stale state in popovers when the underlying tab/pane changes, localStorage parse errors on corrupt data, storage-event sync loops or self-trigger issues, password fields leaking values (autocomplete, logging, value retained after close), unvalidated hex colors crashing xterm, off-by-one in drag-reorder, missing cleanup of document-level listeners, ErrorBoundary not actually catching what it should. Verify each finding against surrounding code before reporting. Return findings each with: severity (high/med/low), file:line, one-paragraph explanation with trigger scenario, confidence (certain / likely / needs-runtime-check). Quality over quantity. Your final message is data for the orchestrator, not user-facing prose.
-
- Read /mnt/d/dev/tiletopia/src/components/McpPanel.tsx 12ms 14.4 KB
-
- Read /mnt/d/dev/tiletopia/src/components/PolicyTab.tsx 11ms 8.6 KB
-
- Read /mnt/d/dev/tiletopia/src/components/AuditTab.tsx 11ms 3.4 KB
-
- Read /mnt/d/dev/tiletopia/src/components/McpConfirm.tsx 15ms 3.6 KB
-
- Read /mnt/d/dev/tiletopia/src/components/Palette.tsx 12ms 3.5 KB
-
- Read /mnt/d/dev/tiletopia/src/components/TabStrip.tsx 27ms 8.6 KB
-
- Read /mnt/d/dev/tiletopia/src/components/HostManager.tsx 22ms 17.5 KB
-
- Read /mnt/d/dev/tiletopia/src/components/ColorPanel.tsx 15ms 9.8 KB
-
- Read /mnt/d/dev/tiletopia/src/lib/theme.ts 15ms 6.4 KB
-
- Read /mnt/d/dev/tiletopia/src/lib/shortcuts.ts 25ms 7.1 KB
-
Now let me look at the frontend request handling, the Tauri command side, and related files.
-
- Bash grep -rn "mcp://request\|mcp_action_reply\|mcp://audit\|McpConfirm\|mcp_update_state" /mnt/d/dev/tiletopia/src /mnt/d/de… 57ms 3.8 KB
-
- Bash ls /mnt/d/dev/tiletopia/src /mnt/d/dev/tiletopia/src-tauri/src /mnt/d/dev/tiletopia/src/components 52ms 541 B
-
- Read /mnt/d/dev/tiletopia/src/components/Help.tsx 12ms 2.4 KB
-
- Read /mnt/d/dev/tiletopia/src/components/ErrorBoundary.tsx 9ms 2.6 KB
-
- Read /mnt/d/dev/tiletopia/src/components/Notifications.tsx 10ms 784 B
-
- Read /mnt/d/dev/tiletopia/src/components/McpConfirm.tsx 13ms 3.6 KB
-
- Bash sed -n '1380,1900p' /mnt/d/dev/tiletopia/src/App.tsx 30ms 20.9 KB
-
- Bash sed -n '2270,2320p' /mnt/d/dev/tiletopia/src/App.tsx 31ms 1.7 KB
-
- Bash grep -n "spawnNewLeafFromSpec\|mcpAllow\|autoAllowSpawnedSsh\|auto_allow" /mnt/d/dev/tiletopia/src/App.tsx 25ms 866 B
-
- Bash sed -n '395,445p' /mnt/d/dev/tiletopia/src-tauri/src/commands.rs 33ms 1.8 KB
-
Now let me look at App.tsx to see how these components integrate (global key handlers, storage event listener, modal stacking).
-
- Bash ls /mnt/d/dev/tiletopia/src /mnt/d/dev/tiletopia/src/lib /mnt/d/dev/tiletopia/src/components; wc -l /mnt/d/dev/tiletopia… 53ms 527 B
-
Now let me check the Rust backend to verify the spawn/subscribe race, ring snapshot semantics, and kill/claim refcount behavior before reporting.
-
- Bash ls /mnt/d/dev/tiletopia/src-tauri/src/ 36ms 88 B
-
- Bash grep -n "storage\|GLOBAL_COLORS\|saveGlobalColors\|loadGlobalColors\|addEventListener\|keydown\|showHelp\|showPalette\|p… 34ms 1.1 KB
-
- Bash grep -n "emit\|ring\|transfer\|claim\|kill" /mnt/d/dev/tiletopia/src-tauri/src/pty.rs | head -60 45ms 3.6 KB
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 12ms 5.9 KB
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 8ms 7.0 KB
-
- Bash grep -rn "save_workspace\|push_window_workspaces" /mnt/d/dev/tiletopia/src --include=*.ts --include=*.tsx | head -20 53ms 172 B
-
- Bash grep -n "sanitize_extra_args\|SpawnSpec\|spawn_pane\|extra_args\|extraArgs" /mnt/d/dev/tiletopia/src-tauri/src/mcp.rs | … 41ms 1.4 KB
-
- Bash grep -rn "StrictMode" /mnt/d/dev/tiletopia/src/ ; grep -n "getInitialPaneIdFor\|initialPaneId" /mnt/d/dev/tiletopia/src/… 64ms 465 B
-
Let me check broadcast write fan-out and the writeToPane path, plus spawn mcpAllow logic.
-
- Read /mnt/d/dev/tiletopia/src/ipc.ts 21ms 1.7 KB
-
- Bash sed -n '1140,1215p' /mnt/d/dev/tiletopia/src/App.tsx 32ms 3.0 KB
-
- Bash grep -n "broadcast\|writeToPane\|function writeToPane\|broadcastGroup" /mnt/d/dev/tiletopia/src/App.tsx | head -40 34ms 1.7 KB
-
- Bash grep -n "getInitialPaneIdFor" -A 12 /mnt/d/dev/tiletopia/src/App.tsx | head -40; grep -n "get_pane_ring" -B2 -A15 /mnt/d… 43ms 2.2 KB
-
- Bash grep -rn "saveWorkspace\|pushWindowWorkspaces" /mnt/d/dev/tiletopia/src --include=*.ts --include=*.tsx | grep -v ipc.ts 40ms 550 B
-
I've read all four files. Now let me check supporting files to verify several candidate findings (StrictMode setup, LeafPane/XtermPane mount behavior, backend save format).
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 13ms 3.6 KB
-
- Bash grep -n "transferredPaneIdsRef" /mnt/d/dev/tiletopia/src/App.tsx 30ms 273 B
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 11ms 4.9 KB
-
- Bash ls /mnt/d/dev/tiletopia/src /mnt/d/dev/tiletopia/src/lib/layout; sed -n 1,40p /mnt/d/dev/tiletopia/src/main.tsx 42ms 629 B
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 19ms 2.3 KB
-
- Bash grep -n "push_window_workspaces\|pushWindowWorkspaces\|WorkspacesEnvelope\|version" /mnt/d/dev/tiletopia/src/ipc.ts | he… 35ms 130 B
-
- Bash grep -rn "push_window_workspaces" /mnt/d/dev/tiletopia/src-tauri/src | head; ls /mnt/d/dev/tiletopia/src-tauri/src 46ms 510 B
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 13ms 1.4 KB
-
- Bash grep -n "PendingActions\|remove\|timeout\|dispatch_action" /mnt/d/dev/tiletopia/src-tauri/src/mcp.rs | head -30 39ms 2.1 KB
-
- Bash grep -n "getInitialPaneIdFor\|existingPaneId\|registerPaneId" /mnt/d/dev/tiletopia/src/lib/layout/LeafPane.tsx /mnt/d/de… 65ms 827 B
-
- Bash grep -rn "ErrorBoundary" /mnt/d/dev/tiletopia/src --include=*.tsx --include=*.ts | grep -v "components/ErrorBoundary.tsx… 40ms 683 B
-
- Bash grep -n "attachCustomKeyEventHandler\|stopPropagation\|Escape\|keydown" /mnt/d/dev/tiletopia/src/components/XtermPane.ts… 41ms 263 B
-
- Read /mnt/d/dev/tiletopia/src/components/XtermPane.tsx 22ms 26.2 KB
-
- Bash grep -rn "onPaneExit\|killPane\|kill_pane" /mnt/d/dev/tiletopia/src --include=*.tsx --include=*.ts | grep -v ipc.ts 51ms 1.4 KB
-
- Read /mnt/d/dev/tiletopia/src/components/XtermPane.tsx 20ms 5.3 KB
-
Now let me read LeafPane.tsx to verify focus handling and idle-reporting cleanup.
-
- Read /mnt/d/dev/tiletopia/src/lib/layout/LeafPane.tsx 23ms 26.1 KB
-
- Bash ls /mnt/d/dev/tiletopia/src/lib/layout/; grep -rn "key=" /mnt/d/dev/tiletopia/src/lib/layout/*.tsx | grep -iv "keydown\|… 60ms 275 B
-
- Bash grep -rn "LeafPane\|key=" /mnt/d/dev/tiletopia/src/lib/layout/orchestration.tsx | head -20; grep -rn "renderNode\|Layout… 55ms 550 B
-
I have enough to report. Let me verify the one panic path concretely before finalizing.
-
- Bash grep -n "LeafPane\|TreeView\|renderNode\|flattenLeaves\|key={" /mnt/d/dev/tiletopia/src/App.tsx | head -30 32ms 551 B
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/mcp.rs 13ms 173 B
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 13ms 1.6 KB
-
- Bash grep -n "reportLeafIdle" -A 20 /mnt/d/dev/tiletopia/src/App.tsx | head -50 37ms 1.5 KB
-
- Bash grep -n "const closeTab\|const createTab\|const switchTab" /mnt/d/dev/tiletopia/src/App.tsx 57ms 133 B
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 13ms 3.1 KB
-
Findings from the MCP subsystem bug hunt. Each is verified against surrounding code. --- **1. [HIGH / certain] Hard-deny list is bypassable by chunking write_pane — breaks the advertised "non-overridable" guarantee** `src-tauri/src/mcp.rs:836-873` (write_pane) + `src-tauri/src/mcp_policy.rs:239-256` (is_hard_denied) + `src-tauri/src/mcp_policy.rs:353-362` (evaluate tier-1). Policy and the compiled-in hard-deny list are evaluated per `write_pane` call against `args.text` only. There is no accumulation of pane input across calls. Claude controls how text is chunked, and the tool doc itself says "partial commands are fine but block the shell until you send a newline." So `rm -rf /` sent as two calls — `write_pane("rm -rf ")` then `write_pane("/\n")` — passes hard-deny on both chunks (`"rm -rf "` has no `/`; `"/"` matches nothing), and the shell concatenates them. This defeats the entire `HARD_DENY_PATTERNS` set, which the UI labels "Cannot be disabled" and the tool description calls "non-overridable … blocked regardless of policy." The exploit becomes fully silent once the user clicks the **"Always allow write_pane"** button in `McpConfirm.tsx:82-88` (adds bare `write_pane` to the allow list): every chunk then resolves to `Allow` at tier 4 with no modal, and the hard-deny (tier 1) never fires on the fragments. Net: the stated catastrophe protection is false under trivially-available chunking. **2. [HIGH / certain] Panic on crafted multibyte write_pane text — `&s[..80]` slices on a non-char boundary** `src-tauri/src/mcp.rs:246-252` (`truncate_summary`), called at `mcp.rs:527` for every dispatched tool. `truncate_summary` does `&s[..80]` when `s.len() > 80`, where `len()` is bytes. For `write_pane`, `args_repr` is the raw attacker-controlled `args.text` (and for `add_host`, the `label`/`hostname`). If byte index 80 falls inside a UTF-8 multibyte sequence, Rust panics (`byte index 80 is not a char boundary`). Trigger: `write_pane` with text = 78 ASCII chars + a 3-byte char like `€` (total 81 bytes, boundary 80 mid-`€`). This panics the handler future before any audit is emitted — a remote-triggerable DoS / connection abort from a single authenticated tool call. Fix: use a char-boundary-safe truncation (e.g. `char_indices`/`floor_char_boundary`). **3. [MED / likely] Confirm-queue race: rapid Enter (or key-repeat) auto-approves the *next* queued request the user never saw** `src/App.tsx:1463-1471` (`dismissConfirm`) + `src/components/McpConfirm.tsx:27-39` (Enter→onAccept) + `App.tsx:2286-2295` (renders only `confirmQueue[0]`). `onAccept` is `() => dismissConfirm(true)`, which pops/resolves whatever is at the head — it is not bound to a specific `requestId`. If two keydown events fire before React re-renders and rebinds the listener (Enter key-repeat is ~30ms; very plausible while approving), both invoke `dismissConfirm(true)`. React runs the functional updaters back-to-back: the first resolves request A (the modal shown), the second resolves request B (next in queue) with `true` — approving B without ever displaying B's modal/summary. A malicious sequence (queue a benign A in front of a dangerous B) weaponizes this. Fix: bind accept/reject to the head's `requestId` and ignore stale resolutions, or block input briefly across the queue shift. **4. [MED / by-design-but-risky / certain] Server binds 0.0.0.0 with DNS-rebind host-check disabled; token is the sole gate, and the UI tells users to open the firewall to all profiles** `src-tauri/src/mcp.rs:1487-1499` (`TcpListener::bind(("0.0.0.0", …))`) + `mcp.rs:1469` (`.disable_allowed_hosts()`). Binding to `0.0.0.0` exposes the full MCP terminal-control surface to the entire LAN, not just localhost; rmcp's DNS-rebinding allowlist is also disabled. The URL is cosmetically reported as `127.0.0.1` (`mcp.rs:1499`), masking the real exposure, and `McpPanel.tsx:296-304` instructs users to add a Windows firewall rule with `-Profile Any` (includes public networks). The 256-bit token makes brute force infeasible, but any token leak (it's shown/copied in the UI, stored plaintext in `%APPDATA%\mcp.json`, and passed on `mcp-remote` command lines) yields remote terminal access from any host on the network. This is deliberate (documented for WSL NAT), but it is the highest-blast-radius design choice here; consider binding to the specific WSL adapter IP rather than all interfaces. **5. [LOW / certain] User-defined deny/ask glob rules are trivially evadable; only the (separately-bypassable) hard-deny is robust** `src-tauri/src/mcp_policy.rs:288-340` (`glob_to_regex`/`rule_matches`). Globs anchor `^…$` and pass whitespace through literally, so a deny rule like `write_pane(rm -rf *)` is defeated by `rm -rf x` (double space), `rm -rf x`, tabs, or shell quoting (`r''m -rf /`). Unlike the hard-deny patterns (which use `\s+`), user rules have no whitespace/quote normalization. Users will reasonably believe a deny rule blocks a command class when it blocks only one exact spacing. Worth a UI caveat or normalization pass. **6. [LOW / certain] connect_host can trigger local command execution via ProxyCommand/LocalCommand on user-saved hosts, outside the hard-deny net** `src-tauri/src/mcp.rs:931-977` (connect_host) vs `mcp.rs:1014-1017` (add_host sanitizes extraArgs). extraArgs sanitization (`hosts::sanitize_extra_args`) is applied only on the MCP `add_host` path. A host the *user* added manually with `-o ProxyCommand=…` is "unrestricted" by design, and `connect_host` will spawn ssh with those args when `allow_open_ssh` is on — local RCE that never passes through shell-text hard-deny (it's ssh config, not pane bytes). The `McpConfirm.tsx:57-66` SSH banner warns generally, but this specific primitive (CVE-2023-51385 class) reaches execution through the confirm modal. Low because it requires a pre-existing user-created malicious host plus the SSH safeguard enabled. --- **Checked and found NOT vulnerable (to save the orchestrator re-verifying):** - Token comparison uses `constant_time_eq` (`mcp.rs:1406-1415`); early length return only leaks fixed token length. Fine. - `bearer_auth` (`mcp.rs:1360-1404`) only short-circuits non-`/mcp` paths, which 404 anyway; the MCP service is nested solely under `/mcp`. No unauth tool access. - `mcp_action_reply` (`commands.rs:420-441`) removes the oneshot before sending; double-replies are no-ops and the oneshot can fire once — no double-execution at the Rust layer. - `dispatch_action` cleans up the pending entry on timeout/channel-close (`mcp.rs:638`) — no per-request leak. - MCP `write_pane` calls `writeToPane(paneId,…)` directly (`App.tsx:1523`), bypassing `broadcastFrom` — keystrokes do not fan out to non-allowed broadcast peers. - Passwords are stripped from the mirror (`MirroredHost.has_password` only) and never appear in request/audit payloads; `tiletopia://hosts` exposes no secret. No password leak through the MCP surface. - `read_pane` after_seq math uses `saturating_sub` (`mcp.rs:746-754`) — no slice panic there. - SSH auto-allow correctly defers to `autoAllowSpawnedSsh` and defaults to false on policy-load error (`App.tsx:1189-1196`). -
- Read /mnt/d/dev/tiletopia/src-tauri/src/mcp.rs 22ms 6.1 KB
-
- Bash grep -rn "colorOverride\|sanitizeColors\|setLeafColors" /mnt/d/dev/tiletopia/src --include=*.ts --include=*.tsx | grep -… 60ms 2.2 KB
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 14ms 1.7 KB
-
I need to verify three cross-boundary findings against the backend. Let me check the Rust side for the push format, flush-on-close, and how the MCP mirror's paneId is used.
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/window_state.rs 14ms 6.5 KB
-
- Bash grep -n "pane_id\|paneId" /mnt/d/dev/tiletopia/src-tauri/src/mcp.rs | head -40 43ms 567 B
-
- Read /mnt/d/dev/tiletopia/src/lib/layout/tree.ts 14ms 1.5 KB
-
- Bash grep -rn "set_colors\|set_pane_colors\|setLeafColors\|colors" /mnt/d/dev/tiletopia/src/lib/mcp* /mnt/d/dev/tiletopia/src… 70ms 218 B
-
- Bash grep -n "on_window_event\|CloseRequested\|Destroyed\|save_now\|forget\|exit" /mnt/d/dev/tiletopia/src-tauri/src/lib.rs 32ms 822 B
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/lib.rs 23ms 5.0 KB
-
- Bash grep -n "dismissConfirm\|confirmQueue" /mnt/d/dev/tiletopia/src/App.tsx | head -20 43ms 428 B
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 19ms 1.9 KB
-
I've read all six target files fully plus the relevant frontend call sites and the MCP host paths. Findings below. --- **FINDINGS** --- **[LOW–MED / security] pty.rs:455-493 — password autotype can leak a saved credential to the wrong endpoint** `PasswordState::observe` writes the host's saved password into the PTY on the *first* output matching `"password:"` or `"passphrase"` within a 30s window. Trigger scenarios: (1) an SSH connection through a jump host (`-J`) — the jump host's password prompt arrives first, so the *destination* host's saved password is auto-typed into the *jump host* prompt; (2) any remote that emits a banner/MOTD containing the substring "password:" or "passphrase" within 30s of connect captures the credential. `looks_like_password_prompt` does a bare lowercase substring match with no anchoring to "<user>@<host>'s password:" and no check that the prompt is local-ssh-originated. Confidence: likely (logic is clear; real-world exploitability depends on jump-host/banner usage). --- **[LOW / security-by-design gap] pty.rs:404-408 — `build_command` never calls `sanitize_extra_args`** SSH `extra_args` are appended verbatim (before `--`/host) at spawn time with no `-o ProxyCommand=`/`LocalCommand`/`KnownHostsCommand` filtering. The CVE-2023-51385 sanitizer (`hosts::sanitize_extra_args`) runs *only* in the MCP `add_host` tool (mcp.rs:1014). A host saved through the regular UI path (`save_ssh_hosts`, commands.rs:272 — unsanitized, intentionally "user has full agency") can carry a `ProxyCommand` arg; the MCP `connect_host` tool (mcp.rs:931) will then happily drive that saved host, turning an MCP-initiated connect into local command execution. So the "Claude can't trigger RCE" guarantee depends entirely on add-host-time validation and is bypassed for UI-saved hosts. Confidence: certain (code path verified); severity low because it requires a user to have manually saved a malicious host. --- **[LOW / blocking-on-async-runtime] commands.rs:228-258, 246-289, window_state.rs:111-124 — synchronous `std::fs` inside `async fn` running on the Tokio runtime** `save_workspace`, `load_workspace`, `list_ssh_hosts`/`save_ssh_hosts`, and `WindowsState::save_now` are all `async fn` (or spawned onto `tauri::async_runtime`) yet perform blocking `std::fs::write`/`read_to_string`/`create_dir_all`/`rename`. These occupy a Tokio worker thread for the duration of the disk IO. Files are small and writes are debounced, so impact is minor, but on a slow/contended FS this can stall other async commands and the MCP server (which shares the runtime). Confidence: certain. --- **[LOW / durability] commands.rs:237-240, window_state.rs:121-122, hosts.rs:81-84 — tmp+rename without fsync; atomicity claim is overstated** All three write `*.tmp` then `rename` over the target. The comments assert this makes a crash-mid-write safe, but there is no `fsync` on the tmp file (nor the directory) before the rename. On a crash/power-loss the rename can be persisted while the tmp file's data is not yet flushed, yielding a zero-length or truncated `workspace.json`/`hosts.json` on recovery. tmp and final are in the same dir so cross-filesystem rename is not an issue. Confidence: certain (the protection is weaker than the comments claim); severity low on NTFS for this app's data. --- **[LOW / race] window_state.rs:96-124 + commands.rs:236-240 — shared tmp filename across concurrent save paths** `save_now` and the `save_workspace` command both write to the *same* `workspace.json.tmp` and rename to the same `workspace.json`. `schedule_save` aborts the prior debounce task, but `save_now`'s body contains no `.await` points (all sync fs), so once a task passes the `sleep`, `abort()` cannot preempt it. If a second saver (e.g., the startup legacy-migration `saveWorkspace` at App.tsx:338, or a second debounce that already cleared its sleep) reaches `save_now` while the first is mid-write, they race on the shared `.tmp` path → interleaved/corrupt tmp or a rename of a half-written file. Window is narrow (sub-ms fs writes vs 500ms debounce) so it's improbable in practice. Confidence: needs-runtime-check. --- **[LOW / resource leak] commands.rs:339-356 and 377-389 — `mcp_start` / `mcp_regenerate_token` TOCTOU can orphan a `RunningServer`** Both do check-then-act: lock `handle`, observe `None`, *release* the lock, `await start_server(...)`, then re-lock and store. Two concurrent `mcp_start` invocations can both observe `None`, both bind/start a server, and the second overwrites `handle.0` — the first `RunningServer`'s task/listener is dropped from the handle but its bound socket/task may linger (no `stop_server` on the loser). Invoked only from a UI toggle so concurrency is unlikely. Confidence: certain (logic), low severity. --- **[LOW / latency] pty.rs:275-289, 291-307 — `panes` Mutex held across PTY syscalls** `resize` holds the `panes` lock across `master.resize()` (an ioctl) and `kill` holds it across `child.kill()`. These block all other pane operations (spawn/write/kill/ring) for the syscall duration. Normally fast; flagged for completeness. Confidence: certain, severity low. --- **Things checked and found OK (no bug):** - No lock-poisoning concerns — all PTY/window-state locks are `parking_lot::Mutex` (don't poison). - No nested-lock deadlocks: `panes` and `transferring` are never held simultaneously; reader thread takes `ring`/`writer` only transiently and never across an `emit`. - The known crash class (sibling of `tokio::spawn` from a sync window-event handler) is correctly avoided: `on_window_event` → `forget` → `schedule_save` uses `tauri::async_runtime::spawn` (lib.rs:69-104, window_state.rs:35/102); the `run` closure only calls `prevent_exit`. - WSL/PowerShell arg construction uses `CommandBuilder::arg` (no shell), and SSH host/user/jump-host go through `validate_ssh_token` + `--` terminator; no command injection in the spawn argv itself. - Credentials are never logged or returned to the frontend; `host_id` is explicitly stripped from the frontend-facing spec. - MCP `PendingActions` entries are removed on both reply (commands.rs:425) and timeout (mcp.rs:638) — no unbounded growth there. The two known-tracked PTY leaks (OS-closed detached window; window closed mid-adopt leaving a stuck `transferring` refcount + never-killed PTY) were confirmed present in pty.rs (the `transferring` map entry and PTY both persist if `claim` never arrives) but skipped per instructions.
-
All files reviewed and cross-verified against the backend (`pty.rs`, `commands.rs`, `App.tsx` orchestration). Verified non-issues first to avoid false positives: leaves render as flat siblings keyed by `leaf.id` (App.tsx:2199), so tree reshapes do NOT remount panes; StrictMode double-mount is correctly handled in both spawn and adopt paths (`destroyed` flag + backend `kill` suppression at pty.rs:296 does not decrement the refcount, so adopt survives the dev double-mount); xterm's stateful UTF-8 decoder handles multi-byte chars split across `write(Uint8Array)` chunks, and ring pushes are whole-chunk under a lock, so no encoding bug; the transferred-paneId hint is one-shot (deleted in `registerPaneId`, App.tsx:1057); RO resize is rAF-coalesced with no feedback loop; hidden workspace layers use `visibility:hidden` (layout preserved) so `fit()` never sees zero size. FINDINGS: 1. severity: high — /mnt/d/dev/tiletopia/src/components/XtermPane.tsx:245-264 — Transfer adoption loses PTY output emitted between the ring snapshot and the live-listener attach. The adopt path does `await getPaneRing(paneId)` (snapshot taken on the backend), writes it, then does a second IPC round-trip `await onPaneData(...)` to attach the listener. Any chunks the reader thread (pty.rs:216-251) emits in that window are appended to the ring after the snapshot but delivered to no listener — permanently dropped. The backend ring actually returns a sequence number for exactly this purpose, but commands.rs:105 discards it (`let (bytes, _seq) = ring.lock().snapshot()`) and `DataChunk` carries no seq, so the frontend cannot reconcile. Trigger: drag an actively-streaming pane (the project's headline use case is moving a "thinking Claude session") to a new window — mid-stream byte loss can split escape sequences and corrupt the TUI. Confidence: certain (mechanism, from code); likely (how often it's user-visible depends on stream rate vs IPC latency). 2. severity: med — /mnt/d/dev/tiletopia/src/components/XtermPane.tsx:290-322 — Fresh-spawn race drops early output AND the exit event. The backend reader thread starts emitting `pane://{id}/data` / `pane://{id}/exit` immediately when `spawn_pane` runs (pty.rs:216-251), but the frontend attaches `onPaneData` only after `await spawnPane(...)` resolves plus another IPC round-trip, and `onPaneExit` one round-trip after that. Tauri events with no listener are dropped, and unlike the adopt path the ring is never replayed for fresh spawns. Worst case is a command that fails instantly (bad WSL distro name, ssh "could not resolve host"): the error text and the exit event are both emitted before the listeners exist, leaving a blank pane whose status says "pane N alive" and which later shows "idle" instead of "[pane exited]". Fix is cheap: replay the ring after subscribing, or subscribe-then-replay with the unused seq. Confidence: likely (fast-failure scenario is realistic; normal shells print after attach). 3. severity: med — /mnt/d/dev/tiletopia/src/components/XtermPane.tsx:537-552 vs 449-475 — Font-size resize bypasses the RO debouncer's `lastSentCols/lastSentRows` cache, which can later suppress a needed PTY resize. The fontSize effect calls `resizePane(paneId, term.cols, term.rows)` directly but cannot update `lastSentCols/lastSentRows` (they live in the mount effect's ResizeObserver closure). Trigger: RO sends e.g. 80x24 (cache = 80x24); user changes font size, fit() yields 65x20, effect sends 65x20 (cache still 80x24); user then resizes the window/gutter so the grid at the new font lands exactly on 80x24 — the debounced sender hits the `term.cols === lastSentCols && term.rows === lastSentRows` early-return and skips the resize, leaving the PTY at 65x20 while xterm renders 80x24 (wrapped/garbled output) until some further resize to a different grid. Confidence: likely (logic certain from code; requires the grid to revisit the cached value). 4. severity: low — /mnt/d/dev/tiletopia/src/components/SearchBar.tsx:65-71 and XtermPane.tsx:578-584 — Search match decorations are never cleared. Neither `closeSearch`/`onClose` nor emptying the query calls `searchAddon.clearDecorations()`, so the yellow match highlights (including the `matchOverviewRuler` marks) stay painted in the scrollback indefinitely after the search bar is closed or the input is cleared. Confidence: likely (addon only clears decorations on `clearDecorations()`/dispose or a new search). 5. severity: low — /mnt/d/dev/tiletopia/src/components/SearchBar.tsx:87-114 and /mnt/d/dev/tiletopia/src/lib/layout/LeafPane.tsx:137-155 — Side effects inside `setState` updater functions. `toggleCase`/`toggleRegex` call `searchAddon.findNext(...)` inside the updater; React 18 StrictMode (enabled in main.tsx:11) double-invokes updaters in dev, so toggling case/regex advances the search by two matches instead of re-running the current one. LeafPane calls `orch.reportLeafIdle` inside `setIsIdle` updaters the same way — that one is harmless in practice because `reportLeafIdle` is idempotent (App.tsx:1271-1280 dedupes the Set), so no idle-badge spam, but it's the same dev-only double-invoke hazard if `reportLeafIdle` ever stops being idempotent. Confidence: certain (pattern), dev-only impact. 6. severity: low — /mnt/d/dev/tiletopia/src/components/XtermPane.tsx:298-304 — Spawn-failure path leaves the pane inert. The early `return` after a failed `spawnPane` skips attaching `onData`, the custom key-event handler, the focus hook, and the ResizeObserver. Consequence: the error text written into the terminal can't be copied with Ctrl+Shift+C, Ctrl+Shift+F/nav chords don't work in that pane, and the dead terminal never re-fits on container resize (cosmetic clipping). Also interacts with finding 2: idle detection still runs, so the pane reports "idle" rather than dead. Confidence: certain. Not bugs / explicitly verified clean: ipc.ts wrappers (thin, correct payload shapes; `listen` is app-global which only matters during transfer overlap and resolves correctly since the source unlistens on unmount); theme.ts (hex sanitization sound, `resolvePaneColors` total); idle interval and both ResizeObservers in LeafPane are cleaned up correctly; the single `attachCustomKeyEventHandler` call preserves all chord families as documented; dispose order in the mount cleanup is correct (unlisten → kill → dispose, with the `term` closure nulled so late event callbacks no-op); the documented late-await `destroyed` re-checks (XtermPane.tsx:261-273, 310-323) correctly plug the StrictMode unlisten leak. -
- Bash grep -n "savePassword\|clearPassword\|setSshPassword\|password" /mnt/d/dev/tiletopia/src/App.tsx | head; grep -n "passwo… 44ms 873 B
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 24ms 1.1 KB
-
- Bash grep -n "xterm" /mnt/d/dev/tiletopia/package.json; grep -rn "drag" /mnt/d/dev/tiletopia/src/components/TabStrip.tsx | he… 61ms 302 B
-
- Bash grep -n "const notify" /mnt/d/dev/tiletopia/src/App.tsx; sed -n '430,460p' /mnt/d/dev/tiletopia/src/App.tsx 42ms 1.3 KB
-
- Bash grep -n "Ctrl+\|Alt+\|F1" /mnt/d/dev/tiletopia/README.md | head -40 53ms 1.7 KB
-
- Bash grep -n "loadWorkspace\|normalizeTree\|sanitize\|backfill" /mnt/d/dev/tiletopia/src/App.tsx | head; grep -rn "colorOverr… 61ms 924 B
-
Bug hunt complete. Findings, verified against App.tsx integration (global capture-phase shortcut handler at App.tsx:850-1038, modal rendering order at App.tsx:2228-2296, confirm queue at App.tsx:1454-1470). ## High **1. McpConfirm: Enter on the focused Reject button approves the action — McpConfirm.tsx:27-39 (high, certain)** The Escape/Enter handler is a window-level bubble listener with no check of `e.target`. A keyboard user who Tabs from the autofocused Approve button to Reject and presses Enter gets the opposite of what they asked for: the keydown bubbles to window, `e.preventDefault()` cancels the button's Enter-activation (so Reject's onClick never fires), and `onAccept()` runs. Same for the "Always allow" button (Enter silently degrades to one-shot approve — milder). This inverts the safety affordance of the MCP confirmation dialog. **2. McpConfirm + confirm queue: key auto-repeat / double-click approves queued confirmations sight-unseen — McpConfirm.tsx:27-39 + App.tsx:1464-1470, 2286-2296 (high, certain mechanics / likely trigger)** `dismissConfirm` pops `confirmQueue[0]` and resolves it on every call. When several MCP requests queue up, the `<McpConfirm>` element stays mounted across queue items (same element position, props swap), so the window keydown listener persists and `autoFocus` does not re-fire for item #2+. Holding Enter (OS key auto-repeat fires repeated keydowns) approves the entire queue in one press; a double-click on Approve approves item #1 plus the never-displayed item #2. There is no debounce, no per-item mount, no focus re-assertion. **3. McpConfirm: Enter/Escape leak between terminal and dialog — McpConfirm.tsx:27-39 (high, likely / needs-runtime-check for the focus path)** The listener is bubble-phase, so when an xterm textarea has focus the key is processed by xterm first (bytes written to the PTY via onData) and only then reaches the dialog handler — `preventDefault` at window cannot undo the PTY write. `autoFocus` on Approve steals focus at mount, but focus returns to a terminal while the dialog is up in realistic flows: approving a queued `focus_pane`/`spawn_pane` triggers LeafPane's isActive→focusTrigger effect which refocuses xterm while confirm #2 is still displayed. From then on, every Enter the user types at their shell prompt both executes the shell command and approves the pending MCP action; every Escape (vim users) rejects it. Suggest a capture-phase listener plus an activeElement check (App.tsx:867 `shouldIgnore` shows the existing in-repo pattern), or a real focus trap. ## Medium **4. ColorPanel: hex text field is effectively read-only — ColorPanel.tsx:173-182 (med, certain)** The text input is controlled (`value={value}` from resolved colors) but `onChange` only calls `setField` when the full string already matches `HEX_RE`. Any intermediate keystroke ("#0", "#0c0c0", deleting a char) is rejected, state doesn't change, and React restores the DOM value — the field visibly snaps back on every keypress. Typing a new hex is impossible (going from one valid 6-digit hex to another always passes through invalid states); only pasting a complete valid hex or single-char selection-overwrite works. Needs a local draft string committed on validity/blur. **5. Escape leaks into the terminal below the Help overlay — Help.tsx:10-19 (med, certain mechanics)** Help is opened by F1 while a terminal is focused (App.tsx:885-890); Help has no autofocus or focus trap, so the xterm textarea keeps focus and all typing continues flowing to the shell behind the overlay. When the user presses Escape to dismiss Help, xterm processes the keydown at target phase first and sends `\x1b` to the PTY (exiting insert mode in vim, cancelling readline state, etc.), and only then does Help's bubble-phase window listener close the overlay — `preventDefault` there is too late. Same bubble-phase pattern exists in ColorPanel.tsx:65-74 and HostManager.tsx:66-72, but those are opened by mouse click on the titlebar so the terminal usually isn't focused; Help/F1 is the keyboard-driven one. **6. Global shortcuts stay live behind modals — App.tsx:867-874 (`shouldIgnore`) (med, certain)** The capture-phase shortcut handler only suppresses itself when `document.activeElement` is an INPUT/TEXTAREA. With HostManager, Help, ColorPanel, McpPanel, or McpConfirm open and focus on a button/backdrop/body (very common — every close button is a `<button>`), Ctrl+Shift+W kills the active pane behind the modal, Ctrl+Shift+T prompts to close the tab, Ctrl+T spawns tabs, etc. Inverse asymmetry: with the Palette open and its input focused, Ctrl+K cannot close the palette (`shouldIgnore` returns true before the toggle at App.tsx:893), even though Ctrl+K opened it — toggle only works one way. ## Low **7. TabStrip: confirm popover survives its workspace — TabStrip.tsx:98-101, 199-238 (low, certain)** If the workspace under confirmation is removed by another path while the popover is open (Ctrl+Shift+T's `window.confirm` path at App.tsx:944-962, or MCP closing a tab), `confirmingWorkspace` becomes null but `confirmingId` doesn't reset: the popover stays up reading `Close ""? This will kill 0 panes:` and the destructive button is a silent no-op (`closeTab` guards stale ids at App.tsx:597). Also `confirmAnchor` is a position:fixed snapshot — it doesn't track window resize or horizontal scrolling of the strip, so the popover drifts away from its tab. Cosmetic/confusing, not destructive. **8. Palette: highlight not reset on query change — Palette.tsx:31-36 (low, certain)** `highlightIndex` carries over across query edits (only clamped when it exceeds the new length), so after hovering item 5 and then typing a filter, Enter selects the 5th match of the new result set instead of the top match. Note display and pick are both index-based so they never actually disagree — this is a wrong-default issue, not a true desync; arrow-nav, wraparound, empty-list, and mouse-hover paths are all internally consistent. **9. Per-pane colorOverride bypasses sanitization on load — lib/theme.ts:129-138 vs App.tsx:327, lib/layout/tree.ts:55 (low, needs-runtime-check)** `sanitizeColors`/`HEX_RE` only guard the localStorage global theme; `colorOverride` is round-tripped through workspace.json with plain `JSON.parse` and no validation (`backfillWslDistro` is the only load-time fixup). A hand-edited/corrupt workspace.json puts arbitrary strings into `toXtermTheme` and into `<input type="color">`. xterm 5.5's theme parser warns-and-falls-back rather than throwing for unparseable colors and the color input clamps to #000000, so impact appears to be cosmetic, but the asymmetry with the carefully-guarded global path is real. ## Clean / notes - **TabStrip drag-reorder does not exist** — the task brief mentions it but there is no drag/draggable code in TabStrip.tsx at all (tab reordering is simply unimplemented), so no off-by-one to find. - **theme.ts storage sync is loop-safe**: `storage` events don't fire in the writing window; the echo from another window re-saving a key-order-canonicalized JSON converges after at most one bounce because `setItem` with an identical string fires no event. - **HostManager passwords are handled well**: draft lives only in component state (dropped on unmount/close), `autoComplete="off"` is set, nothing logs the plaintext (App.tsx:830-848 only logs the error object), and saveRow correctly strips `hasPassword` before persisting. - **ErrorBoundary** (main.tsx:12, McpPanel.tsx:329/335) is correctly placed and its "Try again" fully remounts children (they were unmounted by the fallback render), so deterministic-from-props errors aside, it behaves; the standard caveat that event-handler/async errors aren't caught applies but is by design. - **shortcuts.ts is in sync** with both the App.tsx handlers (every documented chord verified against App.tsx:876-1038 and XtermPane.tsx:358-428) and the README table. -
Bug hunt complete. I read all four target files in full, plus LeafPane.tsx, XtermPane.tsx, main.tsx (StrictMode is enabled), and the Rust side (window_state.rs, lib.rs, mcp.rs) to verify cross-boundary findings and rule out guards. Several candidates were investigated and dismissed (StrictMode double-adopt of transferred PTYs is guarded by the module-scope `pendingInitOnce` memo + the transfer refcount suppressing pass-1's killPane; idleLeafIds is cleaned by LeafPane's unmount effect; the audit/MCP listeners use the cancelled-flag pattern correctly; Gutter's rAF-after-unmount and double-killPane on close are benign no-ops; `promoteLeaf`/`swapLeaves`/`closeLeaf` snapshot races have no realistic interleaving since nothing awaits between read and setTree). ## Findings **1. HIGH — Keyboard input routed to a hidden tab's terminal after tab switch.** `/mnt/d/dev/tiletopia/src/App.tsx:418-434` (focus poll), `/mnt/d/dev/tiletopia/src/lib/layout/LeafPane.tsx:186-190` (focus only on `isActive` false→true). Hidden workspace layers use `visibility:hidden`, which (per the code's own comment at App.tsx:415-417) does NOT blur the focused xterm textarea. The poll guard prevents `activeLeafId` from following the hidden focus, but nothing ever blurs the old textarea or focuses a pane in the new tab when the destination workspace has no remembered active leaf in `activeLeafByWorkspace`. That map is not persisted and starts empty, so after every app restart, every tab except the auto-focused first one has no entry. Scenario: launch app with 2+ saved tabs → Ctrl+PageDown to tab 2 → type → keystrokes execute in tab 1's invisible terminal (potentially destructive commands in the wrong shell). Corollary: while in this state, `broadcastFrom` (App.tsx:1245-1263) walks `treeRef.current` (the *visible* tab's tree), so input typed into the hidden broadcast pane is mirrored to the visible tab's broadcast peers, and the origin-skip check never matches. Confidence: likely (DOM-focus retention under visibility:hidden is author-documented behavior in WebView2; the missing-blur/missing-refocus path is certain from code). **2. MED — Debounced workspace save is silently dropped on app close.** `/mnt/d/dev/tiletopia/src-tauri/src/window_state.rs:40,96-109` + `/mnt/d/dev/tiletopia/src-tauri/src/lib.rs:136-157`, frontend push at `/mnt/d/dev/tiletopia/src/App.tsx:407-412`. The frontend pushes on every `workspaces` change, but the Rust side only writes workspace.json from a 500ms-debounced tokio task; each push aborts the previous task. There is no flush on `CloseRequested`/`ExitRequested` and no frontend beforeunload flush — `save_now` is reachable *only* via the debounce task. Closing the main (last) window within 500ms of any layout change (close a tab, split, rename, drag a gutter, then hit X) exits the process with the pending save task dead, losing that change. This is exactly the auto-save/window-close interaction flagged for attention. Confidence: certain (no other call site of `save_now` exists). **3. MED — Stale `hosts` closure in MCP `add_host`/`delete_host` can clobber concurrent host edits.** `/mnt/d/dev/tiletopia/src/App.tsx:1652-1654` and `1665-1670`. `runMcpHandler` captures `hosts` at effect-subscribe time and computes `const next = [...hosts, newHost]` / `hosts.filter(...)`, then `setHosts(next)` + `await saveSshHosts(next)` with the snapshot. An in-flight request can sit at `await requestConfirm(...)` (modal open) for arbitrarily long; if the user meanwhile adds/edits a host via HostManager (`saveHosts`), accepting the modal persists the stale list — the user's new host disappears from both state and hosts.json. Same window exists for two back-to-back MCP host mutations arriving before React commits + the listener effect resubscribes. Fix shape: functional `setHosts` and build the saved list from current state/ref. Confidence: likely. **4. MED-LOW — `mcp://request` listener resubscribe gap drops requests.** `/mnt/d/dev/tiletopia/src/App.tsx:1841-1877`, dep chain at 1732 (`runMcpHandler` deps include `activeLeafId`, `hosts`, `defaultShell`; `spawnNewLeafFromSpec` deps include `activeLeafId` at 1216). Every active-pane change (every pane click/navigation) tears down the Tauri listener synchronously in cleanup and re-registers it asynchronously (`listen` resolves after an IPC round-trip). An MCP request emitted by the backend during that gap is never handled, so the request hangs until backend timeout with no reply. The churn frequency (focus changes) makes the rare window repeatedly available. Refactor shape: route handler state through refs so the subscription is mount-once. Confidence: likely. **5. LOW-MED — MCP mirror retains `paneId: null` for panes that register after the last push.** `/mnt/d/dev/tiletopia/src/App.tsx:1381-1413` (mirror effect deps: `mcpStatus.running, tree, hosts, activeLeafId`) vs `registerPaneId` at 1048-1065, which mutates only refs and triggers no re-render. The mirror is pushed when the tree changes (before spawn completes, paneId null), and pane registration never re-pushes. Backend `resolve_pane` (`src-tauri/src/mcp.rs:707-716`) errors on `pane_id == None`, so `read_pane`/scrollback fail for such leaves until an unrelated tree/focus/hosts change happens to re-push. Bites multi-pane tabs at startup with the server running (only the auto-focused pane's registration is followed by an activeLeafId change; sibling panes can stay null in the mirror indefinitely) and briefly after every spawn/shell-swap. Confidence: likely. **6. LOW — StrictMode reliably breaks the legacy localStorage migration (dev only).** `/mnt/d/dev/tiletopia/src/App.tsx:332-345`. Both mount-effect passes run concurrently; the `cancelled` flag is checked only *after* the migration block. Pass 1 (the cancelled one) reads the legacy key, fires `void saveWorkspace(...)`, and synchronously `removeItem`s the key; pass 2's `loadWorkspace` was issued before that save landed so it returns null, finds the key already gone, and boots a fresh "Default" — which `pushWindowWorkspaces` then persists over pass 1's migrated save. Microtask ordering makes this the *expected* interleaving, not the rare one. Production (single pass) is unaffected. Confidence: likely. **7. LOW — `spawnNewLeafFromSpec` validates the parent, then awaits, then splits.** `/mnt/d/dev/tiletopia/src/App.tsx:1189-1213`. For SSH spawns, `await mcpPolicyLoad()` sits between the parent-leaf existence check and `setTree(splitLeafWith(...))`. If the parent leaf is closed during the await, `splitLeafWith`/`replaceById` silently no-ops, the new leaf never mounts, and the MCP call burns the full 15/30s `waitForPaneRegistration` timeout before failing with a misleading "pane never registered" error instead of "parent gone". Confidence: likely. **8. LOW — Picking the already-active pane in the palette leaves the terminal unfocused.** `/mnt/d/dev/tiletopia/src/App.tsx:1950-1953` + LeafPane.tsx:186-190. `onPalettePick` calls `setActiveLeafId(id)`; the setter returns `prev` unchanged when the id equals the current active, so no re-render occurs, `isActive` never transitions, `focusTrigger` never bumps, and when the Palette unmounts focus falls to `<body>` — keystrokes go nowhere until the user clicks. Ctrl+K → Enter on the top (current) entry reproduces it. Confidence: certain. **Observation (design-intent, not filed as a bug):** broadcast groups are silently scoped to the current tab (`broadcastFrom` walks `treeRef.current` only), while the per-pane 📡 chips in hidden tabs stay lit and their PTYs are alive — a user expecting cross-tab broadcast gets silent partial delivery. Consistent with the titlebar stats scoping, so possibly intended; worth a deliberate decision.
-
- Read /mnt/d/dev/tiletopia/src/components/McpConfirm.tsx 11ms 3.6 KB
-
- Read /mnt/d/dev/tiletopia/src-tauri/src/mcp.rs 21ms 348 B
-
- Read /mnt/d/dev/tiletopia/src/App.tsx 7ms 1.8 KB
-
I ran five parallel reviewers across the whole codebase (frontend core, pane/xterm lifecycle, Rust backend, MCP server/policy, and secondary UI) and verified the most severe findings myself. Here's what turned up. ## TL;DR The strongest finds are a **security bypass in the MCP hard-deny list**, a **remote-triggerable panic** in the MCP server, a **cluster of bugs in the MCP confirmation dialog** that can approve actions the user never saw or intended, **keystrokes going to a hidden tab** after switching tabs, and **PTY output loss when dragging a live pane to a new window**. Details below, grouped by severity. I did not change any code — this is a report. --- ## High severity **1. `write_pane` chunking defeats the hard-deny list (security).** `mcp.rs:836` + `mcp_policy.rs:239`. The compiled-in `HARD_DENY_PATTERNS` (advertised in the UI as "Cannot be disabled" and "blocked regardless of policy") is evaluated per call against `args.text` only — there's no accumulation of pane input across calls. Since the model controls chunking, `rm -rf /` sent as `write_pane("rm -rf ")` then `write_pane("/\n")` passes hard-deny on both fragments and the shell concatenates them. Once the user clicks "Always allow write_pane," it's fully silent. The catastrophe protection is bypassable. **2. Remote-triggerable panic in `truncate_summary` (DoS).** `mcp.rs:246–252` — verified. `&s[..80]` slices on a *byte* index; if byte 80 lands inside a multibyte UTF-8 sequence, Rust panics ("byte index 80 is not a char boundary"). It's called on attacker-controlled `write_pane`/`add_host` text for every dispatched tool, *before* the audit is emitted. Trigger: `write_pane` with 78 ASCII chars + `€`. One authenticated call aborts the handler. Fix: char-boundary-safe truncation (`floor_char_boundary`/`char_indices`). **3. MCP confirmation dialog — several ways to approve the wrong thing.** `McpConfirm.tsx:27–39` (verified) + `App.tsx:1464`. The dialog's Enter/Escape handler is a window-level listener with no target check or focus trap: - **Enter while the Reject button is focused still approves** — the keydown bubbles to window, `onAccept()` runs, and `preventDefault` cancels Reject's own activation. The safety affordance is inverted. - **Key auto-repeat / double-click drains the queue.** The element stays mounted as queue items swap props, so holding Enter approves *every* queued request in one press, and a double-click approves item #1 plus the never-displayed item #2. `onAccept` resolves `confirmQueue[0]` with no per-item rebind. - **Enter/Escape leak to the terminal.** Bubble-phase means a focused xterm processes the key first (writes to the PTY) before the dialog sees it. After approving a `focus_pane`/`spawn_pane`, focus returns to a terminal while confirm #2 is still up — so every Enter at the shell both runs the command and approves the pending MCP action. Fix: capture-phase + `activeElement` check (the existing `shouldIgnore` pattern at `App.tsx:867`), or a real focus trap. **4. Keystrokes routed to a hidden tab after switching tabs.** `App.tsx:414–434`, `LeafPane.tsx:186`. Hidden workspace layers use `visibility:hidden`, which doesn't blur the focused xterm textarea (the code comments acknowledge this). The focus poll prevents `activeLeafId` from following the hidden focus, but nothing blurs the old textarea or focuses a pane in the new tab when the destination has no remembered active leaf — and `activeLeafByWorkspace` isn't persisted, so after every restart all tabs but the first start empty. Scenario: launch with 2+ saved tabs → Ctrl+PageDown → type → keystrokes execute in tab 1's invisible terminal (possibly the wrong shell). Same root cause makes broadcast silently scope to the visible tab's tree. **5. Live-pane transfer drops PTY output.** `XtermPane.tsx:245–264`, `commands.rs:105`. The adopt path snapshots the ring (`await getPaneRing`), writes it, then does a *second* IPC round-trip to attach the live listener. Chunks the reader thread emits in that gap land in the ring after the snapshot but reach no listener — permanently lost, which can split escape sequences and corrupt the TUI. The backend ring already returns a sequence number for exactly this reconciliation, but `commands.rs:105` discards it (`let (bytes, _seq) = ...`). This hits the headline use case: dragging a thinking Claude session to a new window. --- ## Medium severity - **Debounced workspace save is dropped on app close.** `window_state.rs:96`, `lib.rs:136`, `App.tsx:407`. The only caller of `save_now` is the 500ms debounce task; there's no flush on `CloseRequested`/`ExitRequested` and no `beforeunload` flush. Close the main window within 500ms of any layout change (close a tab, drag a gutter, rename) and that change is lost. - **Fresh-spawn race drops early output and instant-fail exit.** `XtermPane.tsx:290–322`. Listeners attach only after `spawnPane` resolves plus a round-trip; unlike the adopt path, the ring is never replayed for fresh spawns. A command that fails instantly (bad distro, "could not resolve host") emits its error + exit before the listener exists → blank pane that says "alive" then "idle" instead of "[exited]". Cheap fix: replay the ring after subscribing. - **Stale `hosts` closure in MCP `add_host`/`delete_host` clobbers concurrent edits.** `App.tsx:1652`, `1665`. The handler captures `hosts` at subscribe time and saves `[...hosts, newHost]`; if the user edits hosts via HostManager while the confirm modal is open, accepting persists the stale list and the user's new host vanishes from state and `hosts.json`. Use a functional `setHosts`. - **`mcp://request` listener resubscribe gap.** `App.tsx:1841`. The listener tears down synchronously and re-registers asynchronously on every active-pane change (every pane click). A request emitted in that gap is never handled and hangs to timeout. Route handler state through refs so the subscription is mount-once. - **Global shortcuts stay live behind modals.** `App.tsx:867`. `shouldIgnore` only suppresses when focus is on INPUT/TEXTAREA. With any modal open and focus on a button/backdrop (the common case), Ctrl+Shift+W kills the pane *behind* the modal, Ctrl+T spawns tabs, etc. Inversely, Ctrl+K can't close the Palette it opened. - **ColorPanel hex text field is effectively read-only.** `ColorPanel.tsx:173`. `onChange` only commits when the full string already matches `HEX_RE`, so every intermediate keystroke is rejected and the field snaps back. You can't type a hex, only paste a complete valid one. Needs a local draft committed on blur/validity. - **Font-size change can later suppress a needed PTY resize.** `XtermPane.tsx:537` vs `449`. The fontSize effect calls `resizePane` directly but can't update the ResizeObserver's `lastSentCols/Rows` cache, so a later resize that lands exactly on the cached grid is wrongly skipped — PTY and rendered grid desync (wrapped/garbled output) until the next differing resize. - **MCP server binds `0.0.0.0` with the DNS-rebind allowlist disabled.** `mcp.rs:1487`, `1469`. The full terminal-control surface is exposed to the whole LAN (token is the sole gate), while the UI reports it as `127.0.0.1` and tells users to open the firewall with `-Profile Any`. This looks deliberate for WSL NAT, but binding to the specific WSL adapter IP would shrink the blast radius a lot. Flagging as a conscious-decision item. --- ## Low severity (worth a pass) - **Saved SSH password can autotype to the wrong endpoint.** `pty.rs:455`. First output matching "password:"/"passphrase" within 30s gets the saved password — so a jump host (`-J`) prompt or a remote banner containing that substring captures the destination's credential. - **Search match decorations never cleared** when the search bar closes (`SearchBar.tsx:65`) — highlights stay painted in scrollback. - **Palette highlight not reset on query change** (`Palette.tsx:31`) — hover item 5, type a filter, Enter picks the 5th match instead of the top. - **User deny/ask glob rules are trivially evadable** (`mcp_policy.rs:288`) — no whitespace/quote normalization, so `write_pane(rm -rf *)` is defeated by a double space. Only the (separately-bypassable) hard-deny uses `\s+`. - **UI-saved SSH hosts skip `sanitize_extra_args`** (`pty.rs:404`, sanitizer only runs in MCP `add_host`) — a user-saved host with `-o ProxyCommand=` becomes local command execution that `connect_host` will drive. - **`tmp`+rename writes lack fsync** (`commands.rs:237`, `hosts.rs:81`, `window_state.rs:121`) — the "crash-safe" comments overstate it; a crash can persist the rename before the data, truncating `workspace.json`. - **StrictMode dev-only quirks**: legacy localStorage migration races itself (`App.tsx:332`); `toggleCase`/`toggleRegex` advance search by two (side effects in `setState` updaters, `SearchBar.tsx:87`). Dev-only. - **TabStrip close-popover can outlive its workspace** (`TabStrip.tsx:98`) — shows `Close ""? kill 0 panes`, button no-ops. Cosmetic. (Note: the brief's "drag reorder" doesn't exist — tab reordering is simply unimplemented.) --- A few things the reviewers checked and found **clean**, so you don't re-chase them: the MCP token comparison (constant-time), no unauth tool access, passwords never logged or exposed through MCP, no spawn-argv command injection, no lock-poisoning/nested-lock deadlocks, the `tokio::spawn`-from-sync-callback crash class is correctly avoided everywhere, and shortcuts.ts is in sync with handlers + README. Want me to fix any of these? I'd suggest starting with #1–#3 (the MCP security/DoS/confirm cluster) since they're the highest-impact and the fixes are localized — and I'm curious which of these overlap with the few you already knew about. -
wrap this up, save buglist as a .md
-
- Write /mnt/d/dev/tiletopia/BUGS.md 57ms 126 B
-
Saved to `BUGS.md` in the project root. It captures all 5 high, 8 medium, and 9 low findings with file:line, confidence, and a fix direction for each — plus the "verified clean" list so nobody re-chases settled ground. Highlights: - **High:** write_pane chunking bypasses hard-deny, multibyte panic DoS, the MCP confirm-dialog cluster, hidden-tab keystroke routing, live-transfer output loss. - **Medium:** save-dropped-on-close, fresh-spawn output race, stale-hosts clobber, MCP listener gap, shortcuts-behind-modals, read-only hex field, font-size resize desync, the `0.0.0.0` bind. Per the project's working agreement, `memory.md` should get a session entry and this should be committed/pushed — want me to do both? (`BUGS.md` is currently untracked.)