Skip to content

Security: k4y3ff/relay

Security

docs/SECURITY.md

Security Evaluation

Subject: Relay v0.1.0 Evaluator role: Lead Security Engineer Scope: Enterprise suitability assessment Date: 2026-08-25


Executive Summary

Relay is a macOS-only Electron desktop application that integrates git worktree management with the Claude Code CLI. It runs locally on developer workstations, reads and writes source code files, spawns shell processes with full user-level privileges, and proxies terminal I/O to an AI coding assistant.

Overall risk rating: MEDIUM

Relay has sound fundamentals: Electron's contextIsolation is enabled, nodeIntegration is disabled, the renderer runs sandboxed in production builds, file reads and writes are scoped to worktree paths, and no credentials are stored by the application. However, several issues warrant attention before broad enterprise deployment, most significantly the full-environment PTY inheritance and the unencrypted local data store.

The app includes an optional push-to-talk voice navigation feature (Option+Space). It is disabled by default (voiceNavigationEnabled: false) and must be explicitly enabled in Settings → Voice. When disabled, the speech model is never downloaded, the microphone is never requested, and the Ollama fallback is never invoked. Findings 9–11 apply only when the feature is enabled.


Threat Model

Who uses it: Software engineers on macOS workstations with access to source code repositories and Claude Code credentials.

What it touches:

  • Local git repositories and worktree directories on disk
  • The user's shell environment (PATH, git credentials, SSH keys, environment variables)
  • The Claude Code CLI process (which communicates with Anthropic's API)
  • macOS notification system
  • Arbitrary file paths within configured worktrees
  • The microphone — only when voice navigation is enabled (opt-in); captured audio is transcribed locally and discarded
  • Outbound HTTPS to the Hugging Face CDN (one-time speech-model download, then cached) and optionally http://localhost:11434 (local Ollama) — only when voice navigation is enabled

What it does not touch:

  • Network sockets directly — all Claude API traffic goes through the claude subprocess; transcribed audio is never transmitted off the machine. Voice navigation introduces limited outbound egress (CDN model download and optional localhost Ollama), but only when the feature is enabled
  • Browser cookies, system keychains, or credential stores
  • Any data outside the user's configured worktrees directory

Finding 1: PTY sessions inherit the full parent environment — MEDIUM

Location: src/main/terminal.ts:32 and src/main/shell.ts:21

// TerminalManager (terminal.ts:27–33)
const proc = pty.spawn(shell, ['-l', '-c', 'claude'], {
  name: 'xterm-256color',
  cols,
  rows,
  cwd: worktreePath,
  env: process.env as Record<string, string>,
});

// ShellManager (shell.ts:16–22)
const proc = pty.spawn(shell, ['-l'], {
  name: 'xterm-256color',
  cols,
  rows,
  cwd,
  env: process.env as Record<string, string>,
});

Both the Claude PTY sessions (TerminalManager) and shell tab PTYs (ShellManager) inherit process.env in its entirety. This means any secrets present in the user's environment at launch time — AWS credentials, API tokens, SSH agent sockets, proxy passwords — are visible to:

  1. The spawned shell process
  2. The claude CLI subprocess
  3. Any code that Claude Code executes on the user's behalf (e.g. shell commands Claude runs during agentic tasks)

This is consistent with how developers use a terminal directly, so it is not unexpected behavior. However, enterprises that enforce credential hygiene via environment-variable injection (e.g., Vault agent sidecar patterns, credential-scoped shells) should be aware that Relay does not scope or filter the inherited environment.

Recommendation: Document this behavior explicitly for end users. Consider an enterprise configuration option to explicitly allowlist environment variables passed to PTY sessions, preventing accidental forwarding of credentials that were intended only for the parent shell.


Finding 2: File paths are not fully validated against worktree root — MEDIUM

Location: src/main/ipc.ts:618–640 and src/main/ipc.ts:642–665

// fs:read-file (ipc.ts:623–624)
const fullPath = path.join(worktreePath, filePath);
const buf = await readFile(fullPath);

// fs:write-file (ipc.ts:637–638)
const fullPath = path.join(worktreePath, filePath);
await writeFile(fullPath, content, 'utf-8');

// git:diff-file — untracked branch (ipc.ts:650–655)
const fullPath = path.join(worktreePath, filePath);
const { stdout } = await execFileAsync(
  'git',
  ['diff', '--no-index', '/dev/null', fullPath],
  { cwd: worktreePath }
).catch((e: { stdout: string }) => ({ stdout: e.stdout ?? '' }));

path.join resolves .. components, meaning a renderer-supplied filePath of ../../etc/passwd would resolve to a path outside the worktree. All three handlers — fs:read-file, fs:write-file, and the untracked branch of git:diff-file — share this pattern. The tracked-file branch of git:diff-file passes filePath directly as a git argument (safe from shell injection, but the same traversal logic applies). While contextIsolation prevents arbitrary JavaScript from calling these handlers, a compromised renderer (or a renderer tricked via malicious repo content into invoking file operations on attacker-controlled paths) could read or overwrite files outside the intended worktree.

Recommendation: Add an explicit containment check before I/O operations:

const fullPath = path.resolve(worktreePath, filePath);
if (!fullPath.startsWith(path.resolve(worktreePath) + path.sep)) {
  throw new Error('PATH_TRAVERSAL_REJECTED');
}

This is a defense-in-depth measure. The actual risk is low given the IPC model, but the fix is trivial and eliminates a class of vulnerability entirely.


Finding 3: IPC channels are not allowlisted — LOW

Location: src/preload/index.ts:8

invoke: (channel: string, ...args: unknown[]) => ipcRenderer.invoke(channel, ...args),

The preload bridge forwards any channel name to ipcRenderer.invoke without an allowlist. This means renderer code can attempt to invoke any channel that happens to be registered in the main process. In the current codebase this is not exploitable beyond the 32 application-defined handlers, but it is a latent risk: if a dependency or future developer adds a sensitive IPC handler, the renderer will be able to call it without any explicit grant.

Recommendation: Add a channel allowlist to the preload bridge:

const ALLOWED_CHANNELS = new Set([
  'taskgroups:list', 'taskgroups:create', /* ... */
]);

invoke: (channel: string, ...args: unknown[]) => {
  if (!ALLOWED_CHANNELS.has(channel)) throw new Error(`IPC channel not allowed: ${channel}`);
  return ipcRenderer.invoke(channel, ...args);
},

Finding 4: Data store is unencrypted — LOW

Location: src/main/store.ts

electron-store persists application data to ~/Library/Application Support/relay/config.json as plain JSON. The data stored includes:

  • Absolute paths to git repositories and worktree directories
  • Task group names and branch names
  • User preferences (theme, editor word-wrap, notification, sound, and voice navigation settings)
  • customSoundPath — a user-supplied absolute file path to a custom notification sound

No credentials, tokens, or file contents are stored. The paths and names could be sensitive in high-security environments where the existence of certain projects is itself confidential. The customSoundPath field is a new addition that stores an arbitrary user-supplied filesystem path in plaintext.

Recommendation: For regulated environments, consider enabling electron-store's built-in encryption (encryptionKey option) or noting in deployment guidance that the config file contains repository path metadata.


Finding 5: Context menu items are renderer-controlled — LOW

Location: src/main/ipc.ts:282–309

The menu:show-context-menu handler builds a native macOS menu from renderer-supplied label strings without sanitization. Labels are displayed in the OS context menu UI and are not interpreted as code, so there is no injection risk. However, an attacker with renderer control could display misleading UI (e.g. a fake "Grant Full Disk Access" menu item).

Recommendation: This is acceptable given the current threat model.


Finding 6: shell:open-path opens arbitrary paths — LOW

Location: src/main/ipc.ts:555–557

ipcMain.handle('shell:open-path', (_event, { path: p }: { path: string }): void => {
  shell.openPath(p);
});

The shell:open-path handler accepts a renderer-supplied path and opens it via the macOS default application with no validation. This is used to reveal files in Finder. A renderer-side attacker could pass any path — including sensitive files outside the worktree — causing them to be opened in whatever application the OS associates with that file type. The practical impact is limited to UI-level interaction (no data exfiltration over the IPC bridge), but it bypasses the intended worktree scope.

Recommendation: Validate that p resolves to a path within the configured worktrees directory before calling shell.openPath.


Finding 7: No Content-Security-Policy in renderer HTML — INFORMATIONAL

Location: src/renderer/index.html

The renderer's index.html has no Content-Security-Policy meta tag. In production, nodeIntegration: false, contextIsolation: true, and the absence of remote URLs make XSS difficult to exploit. However, a CSP would add a further layer of defense against script injection from malicious repository content rendered by the app (e.g. via react-markdown, diff2html, or xterm.js).

Recommendation: Add a restrictive CSP:

<meta http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">

(unsafe-inline for styles is typically necessary for CSS-in-JS solutions like Tailwind/Emotion.)

When voice navigation is enabled it performs outbound fetches, so a CSP would need a matching connect-src — e.g. connect-src 'self' https://huggingface.co https://*.hf.co http://localhost:11434 — or, preferably, bundle the model locally (Finding 10) so only 'self' and the Ollama origin are required.


Finding 8: Claude response completion detection is heuristic — INFORMATIONAL

Location: src/main/terminal.ts:36–48

Claude completion is detected by a 1500ms idle timer on PTY output. Notifications ("Claude finished on <branch>") are fired based on this heuristic. A slow network response or a Claude command that produces no output for >1.5 seconds will trigger a false positive notification. This is a product quality issue rather than a security issue, but it is noted because the notification content (groupName, branchName) is derived from the store — not from sanitized user input — and is displayed in the macOS notification center.


Finding 9: Media permission handler grants all media requests — MEDIUM

Location: src/main/index.ts (permission request handler) and src/renderer/lib/speechEngine.ts (getUserMedia)

// src/main/index.ts
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
  callback(permission === 'media');
});

Voice navigation requires microphone access, so the app installs a permission handler and calls systemPreferences.askForMediaAccess('microphone'). The handler auto-approves any request whose permission is 'media' — which in Chromium covers camera and screen/display capture as well as the microphone — without prompting the user in-app. Because the renderer only ever loads local content and requests audio, the practical exposure today is limited to the microphone. However, a compromised renderer (or malicious content rendered by react-markdown/diff2html) could invoke getUserMedia({ video: true }) and have it silently granted, since the handler does not distinguish media types or check the requesting origin.

The macOS TCC prompt still gates the first actual microphone/camera access at the OS level, which is a meaningful backstop. But the Electron-level handler is broader than the feature needs. When voice navigation is disabled (the default), the renderer never makes a media request and this handler is never triggered.

Recommendation: Narrow the handler to the specific permission and, where the Electron version supports it, inspect details.mediaTypes to grant only audio:

session.defaultSession.setPermissionRequestHandler((_wc, permission, callback, details) => {
  if (permission !== 'media') return callback(false);
  const types = (details as { mediaTypes?: string[] }).mediaTypes ?? [];
  callback(types.length === 0 || types.every((t) => t === 'audio'));
});

Finding 10: Speech model is fetched from a third-party CDN without integrity pinning — MEDIUM

Location: src/renderer/lib/speechEngine.ts

When voice navigation is enabled, @huggingface/transformers downloads the whisper-tiny.en model (ONNX weights + tokenizer, ~150 MB) from the Hugging Face CDN over HTTPS on first use, caching it in the renderer's browser storage thereafter. Because voice navigation defaults to off, this fetch does not occur for users who have not opted in. When the feature is enabled, it introduces a runtime supply-chain dependency:

  1. Integrity — the downloaded model files are not pinned to a known hash. A compromised CDN or a MITM able to present a valid TLS certificate could serve altered weights. The blast radius is limited (a manipulated ASR model can only mis-transcribe audio into the fixed command registry; it cannot execute code), but the artifact is nonetheless loaded and executed by the ONNX runtime without verification.
  2. Privacy / availability — the fetch reveals to a third party that the application is in use, and the feature is unavailable on air-gapped or egress-restricted workstations until the model is cached.

Recommendation: For regulated or air-gapped environments, bundle the model with the application (host it under env.localModelPath / packaged resources and set env.allowRemoteModels = false) so no external fetch occurs, and/or verify a known content hash after download. At minimum, document the one-time CDN egress in deployment guidance.


Finding 11: Transcripts are sent to a local Ollama instance when the fuzzy matcher misses — LOW

Location: src/renderer/lib/ollamaResolver.ts

When voice navigation is enabled and the local fuzzy matcher cannot confidently resolve a transcript, the renderer POSTs the transcript text and the list of available command labels to http://localhost:11434/api/generate (Ollama) as a natural-language fallback. This traffic never leaves the machine and the call fails silently (2 s timeout) if Ollama is not running, so there is no hard dependency and no off-device egress. When voice navigation is disabled (the default), this code path is never reached.

The residual consideration is that the transcript — which may contain task-group and branch names that are otherwise confidential — is handed to a separate local process (and whatever model it has loaded). On a single-user workstation this is low risk, but the data-flow is worth noting for environments where cross-process boundaries matter.

Recommendation: Consider exposing a separate opt-in for the Ollama fallback within voice navigation settings, so users who want local speech recognition but are not comfortable with cross-process transcript forwarding can disable just this layer.


Positive Findings

The following security properties are correctly implemented:

  • nodeIntegration: false — renderer cannot access Node.js APIs directly
  • contextIsolation: true — renderer JavaScript runs in a separate context from the preload script
  • Renderer sandbox enabled in production — sandbox: app.isPackaged ensures the OS-level renderer sandbox is active in packaged builds
  • No credential storage — the application stores no API keys, passwords, or tokens. Claude Code authentication is handled entirely by the CLI at ~/.claude
  • Git operations use execFile — git subcommands are invoked via execFile (not exec or shell: true), which prevents shell injection from branch names or file paths containing shell metacharacters
  • Binary file detection — fs:read-file checks the first 8KB for null bytes and refuses to return binary content as a string, preventing large binary files from being sent over IPC
  • No remote content loaded — the renderer loads a local index.html in production. No remote URLs are loaded into the application window
  • Notifications contain minimal data — notification body contains only task group name and branch name, not file contents or Claude output
  • On-device speech recognition — voice transcription runs locally via a WASM Whisper model; captured audio is decoded in-memory, transcribed, and discarded. Raw audio is never written to disk or transmitted off the machine
  • Panel-focus IPC is allowlisted — the speech:focus-panel handler re-emits only one of five known focus:* events and rejects any other channel name supplied by the renderer

Summary Table

Finding Severity Effort to Fix
PTY inherits full environment MEDIUM Low–Medium
No path traversal guard on file I/O (fs:read-file, fs:write-file, git:diff-file) MEDIUM Low
Media permission handler grants all media requests MEDIUM Low
Speech model fetched from CDN without integrity pinning MEDIUM Medium
IPC channels not allowlisted LOW Low
Data store unencrypted LOW Low
Context menu labels unvalidated LOW Negligible
shell:open-path opens arbitrary paths LOW Low
Transcripts forwarded to local Ollama on matcher miss LOW Low
No Content-Security-Policy in renderer INFORMATIONAL Low

Recommendation for Enterprise Deployment

Relay is suitable for use by individual developers on managed macOS workstations where the user already has the ability to run arbitrary shell commands and access to their own source code repositories. The risk profile is comparable to running VS Code or any other local IDE that can spawn terminals.

Before broad enterprise deployment, the following should be addressed in priority order:

  1. Add path traversal guards to fs:read-file and fs:write-file (Finding 2) — trivial fix, eliminates a class of vulnerability
  2. Add an IPC channel allowlist to the preload bridge (Finding 3) — low effort, prevents future handler exposure
  3. Document the environment inheritance behavior for security-conscious users (Finding 1)

There aren't any published security advisories