main (HEAD 4b26363) | Generated: June 11, 2026
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).
| Metric | Value |
|---|---|
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 |
| Severity | Count |
|---|---|
| CRITICAL | 0 |
| HIGH | 7 |
| MEDIUM | 19 |
| LOW | 9 |
| Total | 35 |
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.
The Tauri webview ships with csp: null, so no Content-Security-Policy is injected.
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.
src-tauri/tauri.conf.json:22-23
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. The asset: protocol scope is ["**"], granting the webview read access to any file the user can read.
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.
src-tauri/tauri.conf.json:24-29
Several Tauri commands reachable from the renderer perform filesystem operations on any absolute path with no validation or workspace scoping.
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).
src-tauri/src/commands/logging.rs:13-16, src-tauri/src/commands/files.rs:8-73
.. traversal. 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.
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.
src-tauri/sidecar-opencode/src/process-manager.ts:482-484; amplified by src/logger.ts:53-69
password.length). Add log-level gating to Logger and a redaction pass over known secret keys before anything is written to file or IPC. Agent-written .html files are rendered in an iframe whose sandbox includes allow-scripts together with allow-popups-to-escape-sandbox.
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.
src/components/file-preview/HtmlPreview.tsx:9-22, src/components/file-preview/FilePreviewPanel.tsx:150-154
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.opencode serve process on shutdown 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.
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.
src-tauri/sidecar-opencode/src/process-manager.ts:596-614, src-tauri/src/sidecar.rs:581-595
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.task_complete status disagrees across all three IPC layersThe completion-status vocabulary differs between the sidecar (source of truth), the Rust persistence layer, and the frontend, producing incorrect persisted history.
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".
src-tauri/src/sidecar.rs:530-534, src-tauri/sidecar-opencode/src/session-manager.ts:446-450, src/lib/tauri-api.ts:954-961
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..git/config in plaintext 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.
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.
src-tauri/src/git_ops.rs:44-49, src-tauri/src/git_ops.rs:77-93
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. 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.
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.
src-tauri/src/lib.rs:105,153,175, src-tauri/src/automation_scheduler.rs (15 sites), src-tauri/src/commands/automations.rs (11 sites)
.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. 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.
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.
src-tauri/sidecar-opencode/src/session-manager.ts:321-323, src-tauri/sidecar-opencode/src/opencode-client.ts:57-58
environment/headers values before logging configs; gate full body/event logging behind an explicit debug flag; add log rotation or a retention policy. The OpenCode server basic-auth credential is inlined into the system prompt and into curl examples on every message.
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.
src-tauri/sidecar-opencode/src/config-builder.ts:97-103
$OPENCODE_SERVER_PASSWORD rather than inlining it into the prompt and command lines. 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.
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.
src-tauri/sidecar-opencode/src/process-manager.ts:280-298, :470-502
opencode serve bind port 0 itself and parse the actual port from its startup output. The reconnect setTimeout handle is discarded and never cleared, and its callback calls connect(), which unconditionally re-enables reconnection.
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.
src-tauri/sidecar-opencode/src/event-stream.ts:103-118, :121-129
disconnect(), re-check shouldReconnect inside the callback, and add exponential backoff with a maximum interval.apiKeys silently ignored after first initialization 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.
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.
src-tauri/sidecar-opencode/src/index.ts:56-64, src/process-manager.ts:487-500
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. Both capability files grant shell permissions (including unscoped shell:allow-execute) that are never used by frontend code.
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.
src-tauri/capabilities/default.json:13-16, src-tauri/capabilities/skills.json:6
shell:allow-* grants from both capability files and narrow opener:allow-open-path from ** to the directories actually opened. The CI step backgrounds the lint command with a single &, so only the test exit code gates the build.
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".
.github/workflows/test.yml:49-50
& to &&, or split into two independent steps so each failure is visible and blocking. 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.
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.
src-tauri/src/sidecar.rs:654-656, src-tauri/src/secure_storage.rs:28, src-tauri/sidecar-opencode/src/types.ts:229-239
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).update_mcp_config never sends workingDirectory The sidecar expects an optional workingDirectory on the MCP-config update, but the Rust payload struct omits the field entirely.
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.
src-tauri/src/sidecar.rs:182-186, src-tauri/sidecar-opencode/src/types.ts:314-317
working_directory: Option<String> to the Rust struct and populate it from the active workspace in commands/settings.rs.respondToPermission stale-read loop drops folder grants The handler destructures folderPermissions once, then spreads that original array on each loop iteration, so only the last pattern's grant survives.
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.
src/stores/taskStore.ts:686, :726-744
set((state) => ({ folderPermissions: [...state.folderPermissions, grant] })), or accumulate all grants and call set once after the loop.onTaskUpdate listeners double-process completion 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.
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.
src/components/layout/Sidebar.tsx:158-160, src/pages/Execution.tsx:163-178, src/stores/taskStore.ts:846-854
onTaskUpdate exactly once (alongside the existing global subscriptions in the store) and have components consume store state instead of attaching their own listeners.StreamingText conditional return before hooks An early return for the real-streaming branch sits above three useEffect hooks, violating the Rules of Hooks.
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).
src/components/ui/streaming-text.tsx:41-52
isRealStreaming is true (also satisfies the repo's own "hooks at top level only" rule).file:// enrichment links silently stripped (dead feature) 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.
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.
src/lib/content-enrichment.ts:159-164, src/components/chat/MessageBubble.tsx:180-182, src/components/markdown/EnhancedLink.tsx:80-90
urlTransform that whitelists file: in addition to the default-safe protocols, keeping isPathSafe in EnhancedLink as the enforcement point. The auto-scroll effect queries [data-messages-end], but the sentinel div has no such attribute, so querySelector always returns null.
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.
src/pages/Execution.tsx:253-259, src/components/chat/MessageList.tsx:154
data-messages-end to the sentinel div, or move the auto-scroll logic into MessageList where messagesEndRef already exists. 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.
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}.
src/pages/Execution.tsx:217-222, :55, :640-641
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.
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.
src/components/chat/MessageBubble.tsx:57-67, src/components/ui/streaming-text.tsx:82-99
Only 4 of 45 Rust source files contain #[cfg(test)] modules, leaving the persistence layer, command handlers, and sidecar event mapping untested.
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.
src-tauri/src/ (45 files; tested: fs_utils.rs, workspace_validator.rs, commands/skills.rs, git_ops.rs)
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.get_key_prefix byte-slice panic on non-ASCII keys The display-prefix helper slices a String by byte index, which panics if byte 8 falls inside a multi-byte UTF-8 character.
&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.
src-tauri/src/secure_storage.rs:87-88
key.chars().take(8).collect::<String>() (character-aware) instead of byte slicing.The readline handler is async but not awaited, so commands arriving in quick succession execute concurrently.
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.
src-tauri/sidecar-opencode/src/index.ts:752-764
queue = queue.then(() => handleMessage(msg))) so they execute in arrival order. A tool_use message is emitted via as unknown as SidecarEvent, sending a shape that does not match the declared TaskMessagePayload.
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.
src-tauri/sidecar-opencode/src/index.ts:154-166
TaskMessagePayload to a discriminated union that includes the tool_use shape and remove the cast; audit the other as SidecarEvent casts in the file. 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.
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.
src/components/chat/MessageList.tsx:88-102
lastAssistantIndex once before the map, memoize the filtered array, and consider virtualization (e.g. @tanstack/react-virtual) for the history.onAutomationRun* unsubscribe race leaks listeners Three automation listener helpers return a cleanup that no-ops if invoked before the listen() promise resolves, leaking the real unlisten function.
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.
src/lib/tauri-api.ts:1538-1548 (and :1550-1560, :1562-1572)
toSyncUnlisten wrapper (or replicate its pendingCancel flag).console.log in production 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.
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.
src/stores/taskStore.ts:809-814, :1223, :1233
context spread, and remove the console.log calls.A handful of files mix many domains and exceed reasonable size, raising merge-conflict surface and hiding bugs.
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.
src/lib/tauri-api.ts (1,825), src/stores/taskStore.ts (1,245), src/pages/Execution.tsx (678), src/stores/arenaStore.ts (647)
tauri-api.ts by domain (mirroring src-tauri/src/commands/) and extract artifact extraction into src/lib/artifact-extraction.ts with its own tests. 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.
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.
src-tauri/src/sidecar.rs:584-587, src-tauri/sidecar-opencode/src/session-manager.ts:114-117, src/opencode-client.ts (unused methods)
Shutdown variant to SidecarCommand and route it through send_command; remove the orphan emit('message', …) and prune or @internal-mark unused client methods..gitignore lacks .env CI runs no dependency vulnerability audit, and .gitignore has no .env* pattern.
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.
.github/workflows/test.yml (whole file), .gitignore:1-43
.env* to .gitignore and a dependency-audit step (or Dependabot/Renovate config) to .github/.