Security Architecture

OpenWork implements defense-in-depth security across multiple layers. The application handles sensitive API keys, executes arbitrary code via AI agents, and manages file permissions while maintaining a secure user environment.

Security Overview

graph TB
    subgraph "Application Boundaries"
        RENDERER[Renderer Process<br/>Untrusted Context]
        PRELOAD[Preload Script<br/>Isolated Bridge]
        MAIN[Main Process<br/>Trusted Context]
        EXTERNAL[External Processes<br/>CLI, MCP Servers]
    end
    
    subgraph "Security Measures"
        ISO[Context Isolation]
        NO_NODE[nodeIntegration: false]
        VALID[Input Validation]
        SANITIZE[String Sanitization]
        TRUST[Trusted Window Check]
        ENCRYPT[AES-256-GCM Storage]
        PERM[File Permission Gates]
        HTTP[Local HTTP Only]
    end
    
    RENDERER --> ISO
    RENDERER --> NO_NODE
    RENDERER --> PRELOAD
    PRELOAD --> VALID
    PRELOAD --> SANITIZE
    PRELOAD --> MAIN
    MAIN --> TRUST
    MAIN --> ENCRYPT
    MAIN --> PERM
    MAIN --> EXTERNAL
    EXTERNAL --> HTTP
    
    style RENDERER fill:#ffebee
    style PRELOAD fill:#fff3e0
    style MAIN fill:#e8f5e9

Process Isolation

Security Boundaries

sequenceDiagram
    participant UI as Renderer UI
    participant Pre as Preload Script
    participant Main as Main Process
    participant OS as Operating System
    
    Note over UI: Web Context Only
    UI->>Pre: window.accomplish.method()
    Note over Pre: Isolated Context
    Pre->>Pre: Validate and sanitize
    Pre->>Main: ipcRenderer.invoke
    Note over Main: Trusted Context
    Main->>Main: Verify trusted window
    Main->>Main: Execute with full Node.js access
    Main->>OS: File system, processes, crypto

Renderer Security Configuration

Source: apps/desktop/src/main/index.ts lines 91-95

webPreferences: {
  preload: preloadPath,
  nodeIntegration: false,      // Critical: No Node.js in renderer
  contextIsolation: true,      // Critical: Isolated context
}

Security Impact:

Setting Value Protection
nodeIntegration false Renderer cannot access Node.js APIs directly
contextIsolation true Preload script is isolated from renderer global scope
sandbox true (default) Renderer runs in chromium sandbox

contextBridge API

Source: apps/desktop/src/preload/index.ts lines 139-139

contextBridge.exposeInMainWorld('accomplish', accomplishAPI);

Security Properties:

  1. One-way: Only exposes specific functions to renderer
  2. Typed: Full TypeScript type safety
  3. Limited: No direct access to ipcRenderer, only wrapped methods

IPC Security

Input Validation Chain

graph TB
    subgraph "Validation Layers"
        L1[Layer 1: Type Check<br/>Zod schemas]
        L2[Layer 2: String Sanitization<br/>sanitizeString]
        L3[Layer 3: Length Limits<br/>Max 8000 chars]
        L4[Layer 4: Trusted Window<br/>Focus check]
        L5[Layer 5: Handler Logic<br/>Business validation]
    end
    
    subgraph "Data Flow"
        INPUT[Raw Input]
        VALIDATED[Validated Data]
        HANDLER[Handler Execution]
    end
    
    INPUT --> L1
    L1 --> L2
    L2 --> L3
    L3 --> L4
    L4 --> L5
    L5 --> VALIDATED
    VALIDATED --> HANDLER
    
    style INPUT fill:#ffebee
    style VALIDATED fill:#e8f5e9
    style HANDLER fill:#e3f2fd

String Sanitization

Source: apps/desktop/src/main/ipc/handlers.ts lines 180-192

function sanitizeString(input: unknown, field: string, maxLength = 8000): string {
  if (typeof input !== 'string') {
    throw new Error(`${field} must be a string`);
  }
  const trimmed = input.trim();
  if (!trimmed) {
    throw new Error(`${field} is required`);
  }
  if (trimmed.length > maxLength) {
    throw new Error(`${field} exceeds maximum length`);
  }
  return trimmed;
}

Protections:

  • Type checking (string only)
  • Empty string rejection
  • Maximum length enforcement
  • Whitespace trimming

Trusted Window Assertion

Source: apps/desktop/src/main/ipc/handlers.ts lines 167-178

function assertTrustedWindow(window: BrowserWindow | null): BrowserWindow {
  if (!window || window.isDestroyed()) {
    throw new Error('Untrusted window');
  }

  const focused = BrowserWindow.getFocusedWindow();
  if (BrowserWindow.getAllWindows().length > 1 && 
      focused && focused.id !== window.id) {
    throw new Error('IPC request must originate from the focused window');
  }

  return window;
}

Threat Mitigated: Prevents other windows (including potential malicious windows) from making privileged IPC calls.

API Key Storage

Security Architecture

graph TB
    subgraph "Storage Options Trade-off"
        KEYCHAIN[keytar<br/>OS Keychain]
        CUSTOM[Custom Encryption<br/>electron-store]
    end
    
    subgraph "Keychain Issues"
        PROMPT[macOS prompts<br/>User friction]
        PERM[Permission dialogs<br/>On every access]
    end
    
    subgraph "Custom Solution"
        DERIVE[Key Derivation<br/>Machine-bound]
        SALT[Random Salt<br/>Per install]
        ITER[100k Iterations<br/>PBKDF2]
        AES[AES-256-GCM<br/>Encryption]
    end
    
    KEYCHAIN -.->|Rejected due to UX| PROMPT
    KEYCHAIN -.->|Rejected due to UX| PERM
    
    CUSTOM --> DERIVE
    DERIVE --> SALT
    SALT --> ITER
    ITER --> AES
    
    style KEYCHAIN fill:#fff3e0
    style CUSTOM fill:#e8f5e9

Key Derivation

Source: apps/desktop/src/main/store/secureStorage.ts lines 68-94

function getDerivedKey(): Buffer {
  if (_derivedKey) {
    return _derivedKey;
  }

  // Combine machine-specific values
  const machineData = [
    os.platform(),
    os.homedir(),
    os.userInfo().username,
    app.getPath('userData'),
    'ai.accomplish.desktop',
  ].join(':');

  const salt = getSalt();

  // Use PBKDF2 to derive a 256-bit key
  _derivedKey = crypto.pbkdf2Sync(
    machineData,
    salt,
    100000,  // iterations
    32,      // key length (256 bits)
    'sha256'
  );

  return _derivedKey;
}

Security Properties:

Property Value Security Benefit
Algorithm PBKDF2-SHA256 Proven key derivation function
Iterations 100,000 Slows brute force attacks
Key Length 256 bits AES-256 compatible
Salt Random, per-install Prevents rainbow table attacks
Machine Data Platform + home + user + app Keys bound to machine

Encryption Process

Source: apps/desktop/src/main/store/secureStorage.ts lines 100-113

sequenceDiagram
    participant API as API Layer
    participant Crypto as Node.js crypto
    participant Store as electron-store
    
    API->>API: storeApiKey(provider, key)
    API->>Crypto: getDerivedKey()
    Crypto->>Crypto: pbkdf2Sync(machineData, salt)
    Crypto-->>API: 256-bit key
    
    API->>Crypto: createCipheriv(aes-256-gcm, key, iv)
    API->>Crypto: randomBytes(12) for IV
    Crypto->>Crypto: Encrypt with GCM mode
    Crypto-->>API: ciphertext + authTag
    
    API->>API: Format "iv:authTag:ciphertext"
    API->>Store: Store encrypted value
    
    Note over Store: At rest encryption

Decryption Process

Source: apps/desktop/src/main/store/secureStorage.ts lines 118-143

sequenceDiagram
    participant API as API Layer
    participant Store as electron-store
    participant Crypto as Node.js crypto
    
    API->>Store: getApiKey(provider)
    Store-->>API: Encrypted value
    
    API->>API: Split "iv:authTag:ciphertext"
    API->>Crypto: getDerivedKey()
    Crypto-->>API: 256-bit key
    
    API->>Crypto: createDecipheriv(aes-256-gcm, key, iv)
    API->>Crypto: setAuthTag(authTag)
    Crypto->>Crypto: Verify and decrypt
    
    alt Valid
        Crypto-->>API: Decrypted API key
        API-->>API: Return key
    else Invalid (wrong key, tampering)
        Crypto-->>API: Error
        API-->>API: Return null
    end

Security Considerations

Strengths:

  • AES-256-GCM provides authenticated encryption
  • Keys encrypted at rest
  • Machine-bound derivation prevents portability
  • No macOS Keychain prompts

Limitations (acknowledged in comments):

  • Key derivation could be reverse-engineered
  • Less secure than OS Keychain
  • Suitable for rotatable API keys (not passwords)

File Permission System

Permission Flow

sequenceDiagram
    participant AI as AI Agent
    participant MCP as file-permission MCP
    participant API as Permission API (HTTP)
    participant Main as Main Process
    participant UI as Renderer UI
    participant User as User
    
    AI->>MCP: request_file_permission({operation, path})
    MCP->>API: POST /permission
    API->>API: Generate requestId
    API->>Main: mainWindow.webContents.send
    Main->>UI: permission:request event
    UI->>User: Show permission dialog
    
    User->>UI: Allow/Deny
    UI->>Main: permission:respond IPC
    Main->>API: resolvePermission(requestId, allowed)
    API->>MCP: HTTP response {allowed}
    MCP->>AI: Return result

Source: apps/desktop/src/main/permission-api.ts

Permission API Server

Configuration:

  • Port: 9226
  • Interface: localhost only (127.0.0.1)
  • Timeout: 5 minutes per request

Security Measures:

  1. Localhost Only: Not exposed to network
  2. CORS Headers: Local requests only
  3. Request Validation: Operation type, file path validation
  4. Task Scoping: Links permission to active task ID
  5. Timeout: Prevents indefinite blocking

File Operation Types

Source: apps/desktop/src/main/permission-api.ts lines 114-119

const validOperations = ['create', 'delete', 'rename', 'move', 'modify', 'overwrite'];
if (!validOperations.includes(data.operation)) {
  res.writeHead(400, { 'Content-Type': 'application/json'});
  res.end(JSON.stringify({ error: `Invalid operation` }));
  return;
}

Supported Operations:

Operation Description Risk Level
create Create new file Medium
delete Delete file/folder High
rename Rename file Medium
move Move file Medium
modify Change file content Medium
overwrite Replace file entirely High

Shell Open Handler

Source: apps/desktop/src/main/ipc/handlers.ts lines 934-945

handle('shell:open-external', async (_event: IpcMainInvokeEvent, url: string) => {
  try {
    const parsed = new URL(url);
    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
      throw new Error('Only http and https URLs are allowed');
    }
    await shell.openExternal(url);
  } catch (error) {
    console.error('Failed to open external URL:', error);
    throw error;
  }
});

Protections:

  1. Protocol validation (http/https only)
  2. URL parsing validation
  3. Error handling and logging

Blocked Protocols:

  • file:// (local file access)
  • javascript: (code execution)
  • data: (potential XSS)
  • Custom protocols (except accomplish://)

PTY Security

Process Spawning Security

Source: apps/desktop/src/main/opencode/adapter.ts

Security Considerations:

  1. Working Directory: Uses temp directory to avoid TCC prompts
    const safeCwd = config.workingDirectory || app.getPath('temp');
    
  2. Shell Selection: Avoids user shell in packaged apps
    // In packaged macOS apps, use /bin/sh to avoid loading user configs
    if (app.isPackaged && process.platform === 'darwin') {
      return '/bin/sh';
    }
    
  3. Environment Control: Clean environment with only necessary variables
    const env: NodeJS.ProcessEnv = { ...process.env };
    // Add only what's needed
    env.ANTHROPIC_API_KEY = apiKey;
    env.PATH = bundledPath + PATH_DELIMITER + env.PATH;
    

Command Quoting

Source: Lines 143-162

const fullCommand = [command, ...allArgs].map(arg => {
  if (process.platform === 'win32') {
    if (arg.includes(' ') || arg.includes('"')) {
      return `"${arg.replace(/"/g, '\\"')}"`;
    }
    return arg;
  } else {
    if (arg.includes("'") || arg.includes(' ') || arg.includes('"')) {
      return `'${arg.replace(/'/g, "'\\''")}'`;
    }
    return arg;
  }
}).join(' ');

Purpose: Prevent command injection through argument injection

Single Instance Enforcement

Source: apps/desktop/src/main/index.ts lines 123-135

const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
  console.log('[Main] Second instance attempted; quitting');
  app.quit();
} else {
  app.on('second-instance', () => {
    if (mainWindow) {
      if (mainWindow.isMinimized()) mainWindow.restore();
      mainWindow.focus();
    }
  });
}

Security Benefits:

  1. Prevents race conditions
  2. Single point of control for resources
  3. Prevents multiple instances from interfering

Clean Start Data Wipe

Source: apps/desktop/src/main/index.ts lines 19-34

if (process.env.CLEAN_START === '1') {
  const userDataPath = app.getPath('userData');
  console.log('[Clean Mode] Clearing userData directory:', userDataPath);
  try {
    if (fs.existsSync(userDataPath)) {
      fs.rmSync(userDataPath, { recursive: true, force: true });
      console.log('[Clean Mode] Successfully cleared userData');
    }
  } catch (err) {
    console.error('[Clean Mode] Failed to clear userData:', err);
  }
}

Security Use Case: Wipe all data if compromise suspected

Threat Model

Mitigated Threats

Threat Mitigation Component
Renderer Code Injection Context isolation, no nodeIntegration Preload
IPC Message Injection Input validation, sanitization IPC handlers
API Key Theft AES-256-GCM encryption at rest secureStorage
Unauthorized File Access Permission gates for all operations Permission API
Command Injection Shell quoting, PTY isolation OpenCode adapter
URL Injection Protocol validation (http/https only) Shell handler
Window Hijacking Trusted window check IPC handlers
Process Overflow Message batching, max length limits IPC, StreamParser

Accepted Risks

Risk Justification Mitigation
API Key in Memory Required for CLI execution Cleared on process exit
Custom Encryption Keychain UX unacceptable Documented limitations
Local HTTP Server Needed for MCP communication localhost only
PTY Process Required for interactive CLI Isolated, temp directory

Security Best Practices

1. Defense in Depth

graph TB
    L1[Layer 1: Renderer Sandbox]
    L2[Layer 2: Context Isolation]
    L3[Layer 3: Input Validation]
    L4[Layer 4: Business Logic Checks]
    L5[Layer 5: OS Permissions]
    
    L1 --> L2 --> L3 --> L4 --> L5

2. Principle of Least Privilege

  • Renderer has NO direct Node.js access
  • Preload has limited Node.js APIs
  • Main process validates all operations
  • File operations require explicit user consent

3. Fail Securely

// Default to deny if validation fails
function sanitizeString(input: unknown, field: string): string {
  if (typeof input !== 'string') {
    throw new Error(`${field} must be a string`);
  }
  // ...
}

4. Audit Logging

All security-relevant operations logged:

  • IPC handler invocations
  • Permission requests
  • API key access
  • Process spawns
  • Errors and failures

Compliance Considerations

Data Storage

  • API Keys: Encrypted at rest with AES-256-GCM
  • Task History: Stored locally, user-controlled
  • Settings: Stored in electron-store (plain text)
  • No Telemetry: All external logging removed

User Privacy

  • Local-First: All data stored locally
  • No Cloud Sync: No account system
  • User Control: User can wipe data via CLEAN_START
  • Transparent: Open source codebase

Summary

OpenWork’s security architecture provides:

Layer Protection Implementation
Process Isolation Renderer sandboxing contextBridge, no nodeIntegration
IPC Security Input validation Sanitization, length limits, trusted window
Data Protection API key encryption AES-256-GCM, machine-bound keys
File Access Permission gates User approval required for all operations
Process Security PTY isolation Temp directory, shell selection
Network Security URL validation http/https only, localhost APIs

Key Design Principles:

  1. Security First: Validate all inputs, check window focus
  2. Defense in Depth: Multiple security layers
  3. User Control: Explicit permission for sensitive operations
  4. Fail Securely: Default to deny on validation failure
  5. Transparency: Documented limitations and trade-offs

Back to top

OpenWork Documentation - Community documentation for accomplish-ai/openwork