Technical Review Report — cowork-z

Branch: main (HEAD 4b26363)  |  Generated: June 11, 2026

Executive Summary

This report covers a full-repository technical review of cowork-z, a cross-platform Tauri 2.x desktop application that provides a sandboxed environment for autonomous AI agents. The application spans three runtimes: a React 19 / TypeScript frontend, a Rust backend, and a Node.js sidecar that drives an opencode serve HTTP server. The review focused on security (secrets handling, command/path injection, webview hardening), robustness (process lifecycle, error handling, concurrency), and code quality (IPC contract consistency, dead code, test coverage).

Repository statistics

MetricValue
Total source files (.ts, .tsx, .rs, .js, .css, .html; excludes node_modules, dist, target, binaries, reference apps/desktop)260
Total lines of code (same scope)44,793
  ↳ Frontend (src/, .ts/.tsx)180 files / 26,685 LOC
  ↳ Rust backend (src-tauri/src/)45 files / 10,970 LOC
  ↳ Node sidecar (src-tauri/sidecar-opencode/src/)9 files / 3,208 LOC

Findings by severity

SeverityCount
CRITICAL0
HIGH7
MEDIUM19
LOW9
Total35

The most significant theme is webview hardening: the Content-Security-Policy is disabled and the asset protocol is scoped to the entire filesystem, so any rendering/XSS flaw in the agent-content pipeline (markdown, HTML previews, file previews) escalates into full access to every IPC command — including unrestricted file read/write commands. A second theme is secret handling: the per-launch OpenCode server password (which authenticates a command-executing API) is written to a plaintext log and forwarded to stdout, and Git PATs are persisted to .git/config. A third theme is IPC contract drift between the three runtimes, which produces real correctness bugs (e.g. aborted tasks persisted as failed) and dropped configuration fields.

1. Content-Security-Policy is fully disabled

HIGH

Summary: The Tauri webview ships with csp: null, so no Content-Security-Policy is injected.

Details: This application renders AI-agent-generated markdown, code, and arbitrary workspace file previews. With no CSP, a single rendering or injection flaw anywhere in that pipeline gives injected script full access to every Tauri IPC command exposed to the window (tasks, settings, keychain-backed providers, and the unrestricted file commands in Finding 3). CSP is the primary defense-in-depth layer for a content-rendering desktop app, and it is absent.

Occurrences: src-tauri/tauri.conf.json:22-23

"security": { "csp": null,
Recommendation: Define a restrictive policy such as default-src 'self'; img-src 'self' asset: http://asset.localhost; connect-src ipc: http://ipc.localhost and let Tauri append its own script/style nonces. Dev mode needs http://localhost:1420; verify with pnpm tauri dev.

2. Asset protocol scoped to the entire filesystem

HIGH

Summary: The asset: protocol scope is ["**"], granting the webview read access to any file the user can read.

Details: A scope of ** lets any code running in the webview load any local file (SSH keys, browser profiles, documents) through the asset: protocol. Combined with the disabled CSP (Finding 1), a single XSS in markdown or file-preview rendering becomes arbitrary local-file exfiltration.

Occurrences: src-tauri/tauri.conf.json:24-29

"assetProtocol": { "enable": true, "scope": [ "**" ] }
Recommendation: Scope the asset protocol to what media previews actually require — the active workspace root(s) and the app data dir — or route previews through an existing Rust command that validates paths against the workspace permission model.

3. Unrestricted filesystem Tauri commands (write / read / trash)

HIGH

Summary: Several Tauri commands reachable from the renderer perform filesystem operations on any absolute path with no validation or workspace scoping.

Details: write_text_file writes attacker-controlled content to any path. read_file_content / read_binary_file read any file (only size-limited), and trash_file trashes any file. None validate the path against the workspace permission model. With the CSP disabled (Finding 1), an XSS in the main webview turns these into an arbitrary file-write/read/delete primitive (e.g. overwriting shell startup files).

Occurrences: src-tauri/src/commands/logging.rs:13-16, src-tauri/src/commands/files.rs:8-73

#[tauri::command] pub async fn write_text_file(path: String, contents: String) -> Result<(), String> { std::fs::write(&path, &contents).map_err(|e| format!("Failed to write file: {}", e)) }
Recommendation: Validate every path against the active workspace + granted folder permissions before any read/write/trash. Reject absolute paths outside the allowed roots and canonicalize to defeat .. traversal.

4. OpenCode server password leaked to log file and IPC stdout

HIGH

Summary: The per-launch random server password is logged at debug level, and the logger has no level gating — it writes to a plaintext file and mirrors to stdout/IPC.

Details: The OpenCode server's basic-auth password authenticates an API that can execute shell commands as the user. It is written verbatim to a log file under ~/.local/share/opencode/log/ and forwarded through the IPC emitter to stdout, which the Rust parent relays to the frontend debug panel. Anyone able to read the log directory (other local processes, backup/sync tools) gains full authenticated control of the agent runtime, defeating the entire per-launch-password design.

Occurrences: src-tauri/sidecar-opencode/src/process-manager.ts:482-484; amplified by src/logger.ts:53-69

// Enable HTTP basic auth on the OpenCode server env.OPENCODE_SERVER_PASSWORD = this.password; logger.debug(`OPENCODE_SERVER_PASSWORD=${this.password}`);
Recommendation: Delete this log line (or log only password.length). Add log-level gating to Logger and a redaction pass over known secret keys before anything is written to file or IPC.

5. HTML preview executes untrusted agent JS with a sandbox escape

HIGH

Summary: Agent-written .html files are rendered in an iframe whose sandbox includes allow-scripts together with allow-popups-to-escape-sandbox.

Details: Any HTML file in the workspace (typically authored by the AI agent) runs with allow-scripts, executing arbitrary JS inside the app webview; allow-popups-to-escape-sandbox lets that script window.open() a page that escapes the sandbox entirely. Additionally, baseHref is interpolated into the <base href="..."> attribute with no quote-escaping, so a directory name containing a double-quote breaks out of the attribute.

Occurrences: src/components/file-preview/HtmlPreview.tsx:9-22, src/components/file-preview/FilePreviewPanel.tsx:150-154

<iframe className="h-full w-full border-none" referrerPolicy="no-referrer" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms" srcDoc={finalContent}
Recommendation: Remove allow-popups-to-escape-sandbox (and ideally allow-popups); consider dropping allow-scripts and offering an explicit "open in browser" action for interactive previews. HTML-encode baseHref before interpolation.

6. Orphaned opencode serve process on shutdown

HIGH

Summary: The SIGKILL fallback timer in stopServer() can never fire before process.exit(0), and the Rust side never calls its (dead-code) stop() on app exit.

Details: stopServer() resolves immediately after disposeGlobal() and merely schedules a 5-second SIGKILL; both shutdown paths call process.exit(0) right after, so the timer never runs. The child is spawned with detached: false, which on Unix does not kill it on parent exit. On the Rust side, SidecarManager::stop() is marked #[allow(dead_code)] and is never invoked, so the graceful shutdown stdin message is never sent. If disposeGlobal() fails, opencode serve is orphaned with API keys in its environment and a live listening port.

Occurrences: src-tauri/sidecar-opencode/src/process-manager.ts:596-614, src-tauri/src/sidecar.rs:581-595

// Force kill if still running after 5 seconds setTimeout(() => { if (this.process) { logger.warn('Force killing OpenCode process'); this.process.kill('SIGKILL'); } }, 5000); // ...but process.exit(0) runs first
Recommendation: Make stopServer() await termination (SIGTERM, race a 5s timeout, then SIGKILL, resolving on the child's exit event). Wire a real stop() call into the Rust app's exit/window-close handler so the sidecar shuts down deterministically.

7. task_complete status disagrees across all three IPC layers

HIGH

Summary: The completion-status vocabulary differs between the sidecar (source of truth), the Rust persistence layer, and the frontend, producing incorrect persisted history.

Details: The sidecar emits 'aborted' on user abort and never emits 'interrupted'. Rust maps "interrupted" => "interrupted" (dead arm) and lumps aborted/cancelled into "failed", while the frontend maps aborted/cancelled to "interrupted". The result: a user-aborted task shows as "interrupted" live, but is persisted by Rust as failed — so after an app restart, history flips from "interrupted" to "failed".

Occurrences: src-tauri/src/sidecar.rs:530-534, src-tauri/sidecar-opencode/src/session-manager.ts:446-450, src/lib/tauri-api.ts:954-961

let mapped_status = match sidecar_status { "success" => "completed", "interrupted" => "interrupted", // never emitted by sidecar _ => "failed", // swallows aborted/cancelled };
Recommendation: Define one canonical status union in types.ts, add an aborted/cancelled => "interrupted" arm to the Rust mapping, and align src/shared/types/task.ts. Add a contract test that deserializes types.ts fixtures through the Rust serde types.

8. Git PAT persisted to .git/config in plaintext

MEDIUM

Summary: pull_repo injects the personal access token into the remote URL and persists it via git remote set-url, leaving the token in <cache>/.git/config.

Details: While the token originates from the OS keychain, writing it into the on-disk Git config defeats that protection: the secret now sits in cleartext on disk, readable by any local process and likely to survive in backups. clone_repo avoids persistence (token only in the transient argv), but pull_repo rewrites the remote URL permanently.

Occurrences: src-tauri/src/git_ops.rs:44-49, src-tauri/src/git_ops.rs:77-93

if let Some(token) = auth_token { let current_url = get_remote_url(repo_dir)?; let new_url = inject_token(&current_url, Some(token)); set_remote_url(repo_dir, &new_url)?; // token written to .git/config }
Recommendation: Pass credentials transiently per invocation (e.g. git -c http.extraHeader=... or a credential helper / askpass) instead of persisting them into the remote URL. If persistence is unavoidable, scrub the token from the URL after the operation.

9. DB mutex poisoning causes cascading panics

MEDIUM

Summary: The shared SQLite connection is guarded by a std::sync::Mutex accessed with .lock().unwrap() in ~20 call sites; one panic while the lock is held poisons it permanently.

Details: If any thread panics while holding the DB lock (e.g. a serialization edge case during a query), the mutex becomes poisoned and every subsequent conn.lock().unwrap() panics, taking down all database access for the rest of the session. The background skill-sync thread and automation scheduler both lock the same connection, widening the blast radius.

Occurrences: src-tauri/src/lib.rs:105,153,175, src-tauri/src/automation_scheduler.rs (15 sites), src-tauri/src/commands/automations.rs (11 sites)

let repos = { let conn = db_state.conn.lock().unwrap(); // poisoning here cascades crate::db::skill_repos::list_skill_repos(&conn) };
Recommendation: Recover from poisoning with .lock().unwrap_or_else(|e| e.into_inner()) (the connection state is still usable), or centralize DB access behind a helper that handles poisoning and surfaces a Result instead of panicking.

10. MCP secrets, HTTP bodies, and SSE events logged in plaintext

MEDIUM

Summary: Full session config (including MCP environment/headers secrets), every HTTP response body, and every SSE event are written to unrotated plaintext logs and forwarded over IPC.

Details: McpConfig carries environment and headers maps that typically contain API tokens; the whole config is logged at info level. Additionally every HTTP response body and SSE event (the full conversation content, which can include user file contents) is persisted to a new log file per session with no rotation or cleanup.

Occurrences: src-tauri/sidecar-opencode/src/session-manager.ts:321-323, src-tauri/sidecar-opencode/src/opencode-client.ts:57-58

const config = buildSessionConfig({ modelId, folderPermissions, mcpServers }); await this.client.updateConfig(config, workingDirectory); logger.info('Config updated for session', config); // logs MCP secrets
Recommendation: Redact environment/headers values before logging configs; gate full body/event logging behind an explicit debug flag; add log rotation or a retention policy.

11. Server password embedded in the LLM system prompt

MEDIUM

Summary: The OpenCode server basic-auth credential is inlined into the system prompt and into curl examples on every message.

Details: Though the agent legitimately needs to call the server API, transmitting the credential to a third-party model provider on every request — and instructing the agent to put it on curl command lines — means it reappears in bash tool-call transcripts (themselves logged, see Finding 10) and in ps output while curl runs.

Occurrences: src-tauri/sidecar-opencode/src/config-builder.ts:97-103

<server-access> The OpenCode server is running at http://localhost:${serverPort} Authenticate with: -u opencode:${serverPassword} ... </server-access>
Recommendation: Mint a separate scoped token for agent self-access, or have the agent read the credential at runtime from $OPENCODE_SERVER_PASSWORD rather than inlining it into the prompt and command lines.

12. TOCTOU race in ephemeral port selection

MEDIUM

Summary: A probe socket binds port 0, reads the assigned port, then closes; only later is opencode serve --port <port> spawned, leaving a window for another process to grab the port.

Details: Between closing the probe socket and the server binding, config writes and PATH resolution run. If another process takes the port in that window, the symptom is a confusing 15-second waitForServer timeout ("server failed to start") with no retry on a fresh port.

Occurrences: src-tauri/sidecar-opencode/src/process-manager.ts:280-298, :470-502

server.listen(0, '127.0.0.1', () => { const addr = server.address(); const port = addr.port; server.close(() => { resolve(port); }); // port released, then reused later });
Recommendation: Detect bind failure (child stderr / early exit) and retry with a fresh port (bounded attempts). Better, let opencode serve bind port 0 itself and parse the actual port from its startup output.

13. SSE reconnect timer resurrects after disconnect; no backoff

MEDIUM

Summary: The reconnect setTimeout handle is discarded and never cleared, and its callback calls connect(), which unconditionally re-enables reconnection.

Details: If disconnect() runs while a timer is pending, the timer still fires and resurrects the stream, overriding the shouldReconnect = false intent. When the server is permanently dead, the stream retries every 5s forever with no backoff or cap, spamming warn-level logs over IPC.

Occurrences: src-tauri/sidecar-opencode/src/event-stream.ts:103-118, :121-129

if (this.eventSource?.readyState === EventSource.CLOSED && this.shouldReconnect) { setTimeout(() => this.connect(), this.reconnectInterval); // handle discarded }
Recommendation: Store the timer handle, clear it in disconnect(), re-check shouldReconnect inside the callback, and add exponential backoff with a maximum interval.

14. apiKeys silently ignored after first initialization

MEDIUM

Summary: API keys are applied only as env vars when the server process spawns; once initialized, subsequent start_task/resume_session payloads' keys are dropped without warning.

Details: After sessionManager exists, initialize() early-returns, so a key added or rotated mid-session never reaches the server environment (model changes, by contrast, are handled). The result is stale/missing credentials and a confusing provider error unless the whole sidecar is restarted.

Occurrences: src-tauri/sidecar-opencode/src/index.ts:56-64, src/process-manager.ts:487-500

if (sessionManager) { // reconnect SSE if directory changed... then: return; // apiKeys / mcpServers on this payload are discarded }
Recommendation: Detect changed apiKeys in initialize() and either restart the server process or push keys via OpenCode's auth API; at minimum log a warning that new keys were ignored.

15. Unused shell permissions exposed to webview windows

MEDIUM

Summary: Both capability files grant shell permissions (including unscoped shell:allow-execute) that are never used by frontend code.

Details: There is zero use of @tauri-apps/plugin-shell in src/ — the sidecar is spawned Rust-side and skill-repo git ops run in Rust. These grants are pure unused IPC attack surface, magnified by the disabled CSP (Finding 1). opener:allow-open-path with {"path": "**"} is similarly broad.

Occurrences: src-tauri/capabilities/default.json:13-16, src-tauri/capabilities/skills.json:6

"shell:allow-spawn", "shell:allow-stdin-write", "shell:allow-kill", "shell:allow-open",
Recommendation: Remove the unused shell:allow-* grants from both capability files and narrow opener:allow-open-path from ** to the directories actually opened.

16. CI silently discards lint failures

MEDIUM

Summary: The CI step backgrounds the lint command with a single &, so only the test exit code gates the build.

Details: pnpm ultracite:check & pnpm test --run backgrounds the lint/format check; the step's exit code is that of pnpm test --run only. Lint and formatting violations can therefore never fail CI despite the step being named "lint and tests".

Occurrences: .github/workflows/test.yml:49-50

- name: Run frontend lint and tests run: pnpm ultracite:check & pnpm test --run
Recommendation: Change & to &&, or split into two independent steps so each failure is visible and blocking.

17. Azure Foundry key dropped by sidecar; inconsistent provider id

MEDIUM

Summary: The Azure Foundry API key is read and serialized by Rust but has no field in the sidecar's ApiKeys interface, and the provider id is spelled inconsistently across modules.

Details: Rust reads get_api_key("azureFoundry") and sends it as azureFoundry, but the sidecar's ApiKeys type and env-var mapping have no Azure field, so the key is silently discarded. Separately, the keychain status list in secure_storage.rs enumerates "azure-foundry" (kebab-case) while the key is actually stored under "azureFoundry" (camelCase), so get_all_api_key_status reports it as absent even when configured.

Occurrences: src-tauri/src/sidecar.rs:654-656, src-tauri/src/secure_storage.rs:28, src-tauri/sidecar-opencode/src/types.ts:229-239

if let Ok(Some(key)) = secure_storage::get_api_key("azureFoundry") { keys.azure_foundry = Some(key); // sidecar ApiKeys has no azureFoundry field }
Recommendation: Standardize on one provider id, add azureFoundry to the sidecar ApiKeys interface and env-var mapping (or remove the dead Rust path if Azure auth is delivered via provider config instead).

18. update_mcp_config never sends workingDirectory

MEDIUM

Summary: The sidecar expects an optional workingDirectory on the MCP-config update, but the Rust payload struct omits the field entirely.

Details: Per the project's own architecture, OpenCode server requests must carry ?directory=<workspace> to route state to the correct workspace instance. The PATCH /config issued through this path is always directory-less, so MCP config updates may not apply to the active workspace's server.

Occurrences: src-tauri/src/sidecar.rs:182-186, src-tauri/sidecar-opencode/src/types.ts:314-317

#[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateMcpConfigPayload { pub mcp_servers: serde_json::Value, // missing working_directory }
Recommendation: Add working_directory: Option<String> to the Rust struct and populate it from the active workspace in commands/settings.rs.

19. respondToPermission stale-read loop drops folder grants

MEDIUM

Summary: The handler destructures folderPermissions once, then spreads that original array on each loop iteration, so only the last pattern's grant survives.

Details: For a multi-pattern permission request, every iteration calls set({ folderPermissions: [...folderPermissions, grant] }) against the stale snapshot, so iteration 2 discards the folder added in iteration 1. It is also stale against any concurrent addFolderPermission.

Occurrences: src/stores/taskStore.ts:686, :726-744

if (!folderPermissions.some((fp) => fp.folderPath === targetFolder)) { const newPerms = [...folderPermissions, /* grant */]; // stale base each iter set({ folderPermissions: newPerms }); }
Recommendation: Use the functional updater set((state) => ({ folderPermissions: [...state.folderPermissions, grant] })), or accumulate all grants and call set once after the loop.

20. Duplicate onTaskUpdate listeners double-process completion

MEDIUM

Summary: Both the Sidebar (always mounted) and the Execution page register onTaskUpdate listeners, and the dedup cache is cleared before the second listener sees a complete/error event.

Details: Duplicate events are normally absorbed by a module-level dedup map, but for complete/error the map entry is deleted immediately after the first invocation. The second listener then fully re-processes the event: api.completeTask writes to the DB twice and state is set twice.

Occurrences: src/components/layout/Sidebar.tsx:158-160, src/pages/Execution.tsx:163-178, src/stores/taskStore.ts:846-854

const unsubscribeTaskUpdate = api.onTaskUpdate((event) => { addTaskUpdate(event); // also registered in Execution.tsx });
Recommendation: Register onTaskUpdate exactly once (alongside the existing global subscriptions in the store) and have components consume store state instead of attaching their own listeners.

21. StreamingText conditional return before hooks

MEDIUM

Summary: An early return for the real-streaming branch sits above three useEffect hooks, violating the Rules of Hooks.

Details: If isRealStreaming flips on a mounted instance, React throws "Rendered fewer/more hooks than during the previous render" and crashes the chat view. MessageBubble renders StreamingText in two ternary branches with different isRealStreaming values, so React can reuse the instance across the flip (e.g. when an out-of-order delta reintroduces a completed message id).

Occurrences: src/components/ui/streaming-text.tsx:41-52

if (isRealStreaming) { return ( /* ... */ ); // early return BEFORE the useEffect hooks below } useEffect(() => { /* ... */ });
Recommendation: Hoist all hooks above the conditional return and have the effects no-op when isRealStreaming is true (also satisfies the repo's own "hooks at top level only" rule).

22. file:// enrichment links silently stripped (dead feature)

MEDIUM

Summary: Content enrichment rewrites bare paths into file:// markdown links, but react-markdown's default URL sanitizer drops every file: href, so the links are inert.

Details: The renderer passes no custom urlTransform, and react-markdown 9.x's defaultUrlTransform only allows http(s)/irc(s)/mailto/xmpp — every file:// href becomes "", so the click handler bails. The path-enrichment feature (and any [text](file:///...) the agent emits) renders as non-functional anchors; unit tests mask the bug by calling the component factory directly.

Occurrences: src/lib/content-enrichment.ts:159-164, src/components/chat/MessageBubble.tsx:180-182, src/components/markdown/EnhancedLink.tsx:80-90

matches.push({ // ... replacement: `[${candidate}](file://${candidate})`, // sanitized to "" at render });
Recommendation: Provide a custom urlTransform that whitelists file: in addition to the default-safe protocols, keeping isPathSafe in EnhancedLink as the enforcement point.

23. Chat auto-scroll selector never matches

MEDIUM

Summary: The auto-scroll effect queries [data-messages-end], but the sentinel div has no such attribute, so querySelector always returns null.

Details: data-messages-end appears nowhere in src/, so auto-scroll on new/streaming messages never fires. Only the one-time on-load jump and the manual scroll-to-bottom button work.

Occurrences: src/pages/Execution.tsx:253-259, src/components/chat/MessageList.tsx:154

const messagesEnd = document.querySelector( '[data-testid="messages-scroll-container"] [data-messages-end]' // matches nothing ); messagesEnd?.scrollIntoView({ behavior: 'smooth' });
Recommendation: Add data-messages-end to the sentinel div, or move the auto-scroll logic into MessageList where messagesEndRef already exists.

24. Always-on debug-log listener: unbounded growth + page re-render

MEDIUM

Summary: The sidecar:log listener is registered regardless of debug mode, appending to an unbounded array and re-rendering the whole Execution page per log line.

Details: Every sidecar log line appends to debugLogs for the page's lifetime and triggers a state update that re-renders the entire ExecutionPage (including the full message list) even when the debug panel is hidden. The list also uses key={index}.

Occurrences: src/pages/Execution.tsx:217-222, :55, :640-641

api.onDebugLog((log) => { setDebugLogs((prev) => [...prev, log]); // unbounded; re-renders whole page }).then(track);
Recommendation: Register the listener only when debug mode is enabled, cap the array (e.g. last 500 entries), and isolate the debug panel into its own child component so log events don't re-render the chat.

25. Full markdown re-parsed on every streaming delta

MEDIUM

Summary: Each partial-message event invalidates the normalize/enrich/extract memos and re-parses the entire accumulated markdown; fake-typing mode re-parses up to ~60×/sec.

Details: enrichContentWithLinks performs multiple full-text regex scans with O(matches²) overlap checks, then ReactMarkdown re-parses the whole growing string. The StreamingText rAF loop calls children(displayedText) on every frame, so cost grows quadratically with message length.

Occurrences: src/components/chat/MessageBubble.tsx:57-67, src/components/ui/streaming-text.tsx:82-99

const enrichedContent = useMemo(() => { return enrichContentWithLinks(normalizedContent); // re-runs on every delta }, [normalizedContent]);
Recommendation: Throttle partial-message renders (flush at 30–60 ms intervals) and render plain text during streaming, switching to the full enrich + markdown pipeline only on finalize.

26. Rust backend largely untested (4 of 45 files)

MEDIUM

Summary: Only 4 of 45 Rust source files contain #[cfg(test)] modules, leaving the persistence layer, command handlers, and sidecar event mapping untested.

Details: Tests exist for fs_utils, workspace_validator, commands/skills, and git_ops only. The untested code includes the entire db/ layer, all Tauri command handlers, and sidecar.rs — the exact layer where the status-mapping bug (Finding 7) lives. By contrast the frontend has 26 test files and the sidecar 6 Jest suites.

Occurrences: src-tauri/src/ (45 files; tested: fs_utils.rs, workspace_validator.rs, commands/skills.rs, git_ops.rs)

Frontend (src/): 180 TS/TSX | 26 __tests__ suites Sidecar: 9 TS | 6 Jest suites Rust (src-tauri/src/): 45 .rs | 4 #[cfg(test)] modules
Recommendation: Prioritize unit tests for sidecar.rs event mapping and the db/ modules (cheap with tempfile), and add a contract test deserializing types.ts fixtures through the Rust serde types to catch IPC drift.

27. get_key_prefix byte-slice panic on non-ASCII keys

LOW

Summary: The display-prefix helper slices a String by byte index, which panics if byte 8 falls inside a multi-byte UTF-8 character.

Details: &key[..prefix_len] uses byte indexing; while API keys are normally ASCII, a key (or pasted value) with a multi-byte character near the boundary will panic the command. Low likelihood, but it is a real panic vector in a user-facing path.

Occurrences: src-tauri/src/secure_storage.rs:87-88

let prefix_len = std::cmp::min(8, key.len()); Ok(Some(format!("{}...", &key[..prefix_len]))) // panics on non-char-boundary
Recommendation: Use key.chars().take(8).collect::<String>() (character-aware) instead of byte slicing.

28. Unserialized concurrent stdin command handling

LOW

Summary: The readline handler is async but not awaited, so commands arriving in quick succession execute concurrently.

Details: readline does not await async listeners, so an abort_session can interleave with a start_task still inside createSession/updateConfig, or shutdown can run mid-task. The initializePromise guard only serializes first-time initialization.

Occurrences: src-tauri/sidecar-opencode/src/index.ts:752-764

rl.on('line', async (line: string) => { const msg = JSON.parse(line) as SidecarCommand; await handleMessage(msg); // not awaited by readline -> concurrent execution });
Recommendation: Chain commands through a promise queue (queue = queue.then(() => handleMessage(msg))) so they execute in arrival order.

29. IPC type contract bypassed with double cast

LOW

Summary: A tool_use message is emitted via as unknown as SidecarEvent, sending a shape that does not match the declared TaskMessagePayload.

Details: TaskMessagePayload declares { message: Message; parts: Part[] }, but this emit sends a differently shaped message and omits parts, hidden behind a double cast. Since types.ts is the documented single source of truth, the compiler can no longer catch regressions on either side.

Occurrences: src-tauri/sidecar-opencode/src/index.ts:154-166

send({ type: 'task_message', taskId: data.taskId, payload: { message: toolMessage }, } as unknown as SidecarEvent); // bypasses the typed contract
Recommendation: Widen TaskMessagePayload to a discriminated union that includes the tool_use shape and remove the cast; audit the other as SidecarEvent casts in the file.

30. O(n²) per-render scan + unvirtualized chat list

LOW

Summary: A backward scan for the last assistant message runs inside .map(), making each render O(n²), and the full message history is rendered without virtualization.

Details: The lastAssistantIndex loop executes once per message; combined with re-renders on every streaming delta (Finding 25), long tasks keep the entire framer-motion tree mounted. The filter + map chain is rebuilt un-memoized each render.

Occurrences: src/components/chat/MessageList.tsx:88-102

.map((message, index, filteredMessages) => { let lastAssistantIndex = -1; for (let i = filteredMessages.length - 1; i >= 0; i--) { /* O(n) inside map */ } })
Recommendation: Compute lastAssistantIndex once before the map, memoize the filtered array, and consider virtualization (e.g. @tanstack/react-virtual) for the history.

31. onAutomationRun* unsubscribe race leaks listeners

LOW

Summary: Three automation listener helpers return a cleanup that no-ops if invoked before the listen() promise resolves, leaking the real unlisten function.

Details: If cleanup runs before listen() resolves — guaranteed in React Strict Mode's mount→unmount→mount cycle — unlisten is still null and the listener fires forever (the Sidebar then double-loads automation runs). A correct implementation (toSyncUnlisten with a pendingCancel flag) already exists in the codebase but is not used here.

Occurrences: src/lib/tauri-api.ts:1538-1548 (and :1550-1560, :1562-1572)

let unlisten: UnlistenFn | null = null; listen(...).then((fn) => { unlisten = fn; }); return () => { unlisten?.(); }; // no-ops if cleanup runs before listen resolves
Recommendation: Reimplement the three helpers via the existing toSyncUnlisten wrapper (or replicate its pendingCancel flag).

32. Full task-event payloads logged; console.log in production

LOW

Summary: Every non-deduped task update is serialized twice into the logging backend, and streaming code contains console.log calls the project's own rules forbid.

Details: Full assistant message bodies and tool inputs/outputs (which can contain user file content) are serialized into both message and context on every event, inflating log volume and persisting potentially sensitive content. Two console.log calls fire on every streaming partial/complete event.

Occurrences: src/stores/taskStore.ts:809-814, :1223, :1233

void api.logEvent({ level: 'debug', message: `taskUpdateEvent: ${JSON.stringify(event)}`, // full body context: { ...event }, // duplicated });
Recommendation: Log only event metadata (taskId, type, message id, payload sizes), drop the duplicated context spread, and remove the console.log calls.

33. Oversized god-files concentrate unrelated concerns

LOW

Summary: A handful of files mix many domains and exceed reasonable size, raising merge-conflict surface and hiding bugs.

Details: tauri-api.ts (1,825 lines) mixes ~20 domains plus message-normalization parsing; taskStore.ts (1,245 lines) embeds a bash-command artifact-extraction regex engine that is pure domain logic. This concentration made the listener-registration bugs (Findings 20 and 31) easy to miss.

Occurrences: src/lib/tauri-api.ts (1,825), src/stores/taskStore.ts (1,245), src/pages/Execution.tsx (678), src/stores/arenaStore.ts (647)

// src/lib/tauri-api.ts — all invoke() calls and event listeners between // the React frontend and the Rust backend live in this single file.
Recommendation: Split tauri-api.ts by domain (mirroring src-tauri/src/commands/) and extract artifact extraction into src/lib/artifact-extraction.ts with its own tests.

34. Dead code across the IPC layer

LOW

Summary: Several IPC paths and methods are unreachable: the shutdown command bypasses the typed enum, an orphan 'message' event is emitted with no listener, and ~10 client methods are unused.

Details: The Rust shutdown is hand-built JSON rather than a SidecarCommand variant, escaping the type-checked contract. The sidecar emits 'message' that nothing subscribes to, and OpenCodeClient exposes many never-called methods, inflating the pkg binary and misleading readers about the protocol.

Occurrences: src-tauri/src/sidecar.rs:584-587, src-tauri/sidecar-opencode/src/session-manager.ts:114-117, src/opencode-client.ts (unused methods)

// Send shutdown command via stdin (bypasses SidecarCommand enum) let shutdown_cmd = serde_json::json!({"type": "shutdown"}); let json = serde_json::to_string(&shutdown_cmd).unwrap_or_default();
Recommendation: Add a Shutdown variant to SidecarCommand and route it through send_command; remove the orphan emit('message', …) and prune or @internal-mark unused client methods.

35. No dependency-audit automation; .gitignore lacks .env

LOW

Summary: CI runs no dependency vulnerability audit, and .gitignore has no .env* pattern.

Details: Hygiene is otherwise good — all three lockfiles are committed, no secrets are present, and dist/ + binaries/ are ignored. But there is no pnpm audit / cargo audit / Dependabot step, and a future accidentally-created .env would be committable.

Occurrences: .github/workflows/test.yml (whole file), .gitignore:1-43

node_modules dist dist-ssr *.local # (no .env* pattern)
Recommendation: Add .env* to .gitignore and a dependency-audit step (or Dependabot/Renovate config) to .github/.