Skip to main content
Trevor Elkins
/
12 min read

A teardown of Claude for Desktop

I'm a big fan of using the Claude app over raw Claude Code. The app UI looks clean and simple on the surface, but inside that 855 MB app bundle is a treasure trove of interesting technical challenges and choices. After all, its automations can control and interact with your desktop on top of executing potentially untrusted code. Exciting stuff! Let's dig in and see how it all fits together.

App structure#

The bundle is easier to understand when you can open it up. Here’s the latest build I analyzed, 1.40609.1. I also analyzed an older version a couple months ago but never got around to writing the post, I'll mention some bits of that later.

What’s inside Claude.app?
855.2 MBv1.40609.1

At first glance this is a pretty standard Electron layout. About 60% of the bundle is the Electron framework and its Chromium resources (😭). The native interop is contained within .node binary files which are dynamically linked against Apple frameworks. And then we have some interesting functionality packed into ion-dist/ and the smol-bin images. ion-dist contains the bundled web frontend, including HTML entry points, scripts, styles, and assets. The UI is larger than app.asar alone suggests.

Sandbox#

Virtualization#

I'll start with the part I find most interesting which is what I'm able to piece together about their sandboxing architecture. Being able to safely run untrusted agent code is a hot topic right now.

The Swift addon links against Apple's Virtualization.framework and exposes VM management types. This matches Anthropic's account of Cowork's architecture, which explains why they chose a local VM for users who shouldn't have to judge whether a shell command is safe. Limiting the blast radius of an agent is important with two main areas of concern:

  1. Destructive behavior, whether intentional or accidental. You don't want a stray agent trying to rm -rf / your system.
  2. A malicious actor trying to exfiltrate data. A simple prompt injection might try to send your API keys or crypto wallet to their website.

On the local VM path, Claude boots a Linux guest to run agent code. The guest has its own kernel and filesystem, while selected host directories can be shared into it. If a shared directory is writable, the agent can still modify its files, and stopping the process won't undo those changes. The VM is one layer in a wider "defense in depth" design.

Guest image#

You may have noticed that the app bundle ships with some smol-bin image files. These images live up to their name weighing in at only 24 MB, with separate arm64 and x64 copies. The app itself is universal, so supporting both architectures explains that duplication, though hopefully they will provide thinned variants in the future. Here's the fun part, I originally thought this was the actual bootable OS, but you can think of this more like a USB stick with some prebuilt binaries and configuration that mounts as a volume inside of the guest OS. Inside, srt-settings.json (Sandbox RunTime) contains the following configuration:

{
  "network": {
    "allowedDomains": [
      "registry.npmjs.org",
      "npmjs.com",
      "www.npmjs.com",
      "npmjs.org",
      "www.npmjs.org",
      "yarnpkg.com",
      "registry.yarnpkg.com",
      "pypi.org",
      "files.pythonhosted.org",
      "pythonhosted.org",
      "github.com",
      "archive.ubuntu.com",
      "security.ubuntu.com",
      "api.anthropic.com",
      "*.anthropic.com",
      "anthropic.com",
      "crates.io",
      "index.crates.io",
      "static.crates.io"
    ],
    "deniedDomains": [],
    "allowLocalBinding": true,
    "allowAllUnixSockets": true,
    "mitmProxy": {
      "socketPath": "/var/run/mitm-proxy.sock",
      "domains": [
        "*.anthropic.com",
        "anthropic.com",
        "*.claude.com",
        "claude.com",
        "*.frame.claudeusercontent.com",
        "frame.claudeusercontent.com",
        "*.frame.staging.claudeusercontent.com",
        "frame.staging.claudeusercontent.com"
      ]
    }
  },
  "filesystem": {
    "denyRead": ["/run/systemd/resolve", "/run/dbus"],
    "allowWrite": ["/"],
    "denyWrite": []
  }
}

We'll come back to the network policy later, but at first glance they've allowlisted some common package managers and Claude domains.

As mentioned, these utility images are separate from the bootable guest OS. The guest OS is downloaded at runtime, so I downloaded and inspected the arm64 base image for revision 2a762adf…, published August 13, 2026. The manifest lists four artifacts:

  • rootfs.img — the root filesystem disk
  • vmlinuz — the Linux kernel
  • initrd — the standard initial RAM filesystem
  • initrd-micro — a smaller initial RAM filesystem for the fast-boot path

The root disk downloads from https://downloads.claude.ai/vms/linux/arm64/<revision>/rootfs.img.zst. This revision is 1.23 GB compressed and expands to exactly 10 GiB logical disk size. The disk contains a 99 MiB EFI partition and an ext4 root filesystem containing Ubuntu 22.04.5 LTS (Jammy Jellyfish). The installed kernel package has advanced to 6.8.0-136-generic.

The image includes Node.js 22.23.2, Python 3.10.12, UV, and /usr/local/bin/sdk-daemon. The daemon is a stripped Go arm64 binary built with Go 1.25.12. Its coworkd.service unit describes a vsock RPC bridge for process management and runs it as root inside the guest.

The bundled document-processing suite includes pdfplumber, pdf2txt, camelot (PDF tables), pytesseract (OCR), unoserver/unoconvert (headless LibreOffice), markitdown, markdownify, magika (file-type detection), fonttools, numpy, onnxruntime, pypdfium2, img2pdf, and the odf* tools. There's also a readable extract-text shell script that dispatches different formats to MarkItDown, Pandoc, or LibreOffice. Claude can use these preinstalled tools to read and convert documents.

Host and guest communication#

Piecing everything together gets a bit complicated. It works roughly like this:

  1. The Electron app asks its native VM layer to boot the guest OS
  2. The coworkd service triggers the /usr/local/bin/sdk-daemon Go binary to run in the background
  3. With the guest set up and listening, the Electron app uses a native Swift CoworkVMRPCClient class to talk RPC with the guest OS over vsock.

vsock is the key here since it allows fast communication between the host and guest OS through a simple socket connection.

The RPC channel isn't the only thing riding vsock. The guest daemon also sends network traffic from a virtual Ethernet interface to the host over vsock. On the host side, I found Swift metadata referencing VZGvisorNetworking. Together, these suggest a gVisor-based network stack running on the Mac that connects to the outside world on the guest's behalf. The gvisor-tap-vsock project shows how this kind of networking works.

Here's my reconstruction of a few tool calls, including how the results get back to Claude.

Follow an agent action

Follow a tool call out—and its result all the way back.

Shown here: host-coordinated tools, with code running in the VM. Why this arrangement?

Step 1 of 11 · Your Mac
You give Claude a task

For example: “Run this calculation and explain the result.”

Your Mac Host
Linux VM Guest
Remote services

The MITM settings in srt-settings.json make more sense after reading about the exfiltration bug in Anthropic's write-up. A malicious file tricked Claude into uploading workspace files to the attacker's account through Anthropic's own API. The domain was allowed, so the request got through. Anthropic added a proxy inside the VM that inspects requests to its API and requires the VM's provisioned session token, rejecting the attacker's key.

smol-bin#

Now that we have a better overview of the major components, let's tie it together by coming back to the smol-bin we started with. Why does this part specifically get embedded in the app bundle when we still have to download the full guest image, tools and all?

A likely reason is keeping the host and guest protocol implementations compatible. The base image contains a daemon, but the app also ships its own copy to install into the guest at boot. The host attaches smol-bin to the VM as an extra virtual disk, and coworkd's updater mounts it and copies the current binary into place. Meanwhile, the large guest image has a versioned download path and manifest checksums, so it can be cached independently. The two daemon copies I inspected had different hashes. Shipping one with the app lets Anthropic update it without making you download the whole guest image again.

Following a local MCP call#

I wanted to check where a local MCP server actually runs, so I added a tiny Python server to Claude's configuration and asked a local Cowork session to call it. Its only tool reported its operating system and process ID, then read a harmless test file. I gave the call a unique marker so I could match the result in Cowork with the server's local log.

The tool reported Darwin and PID 18717. I checked macOS's process table separately and found that same process running under Claude. The parent chain was:

Claude (18616)
└── Contents/Helpers/disclaimer (18716)
    └── Python MCP probe (18717)

The result in Cowork included the random value I'd saved in /private/tmp. I could match that response to the server's log and the Python process running on my Mac.

A local MCP server can read files that were never shared with the VM, as long as its settings and macOS permissions allow it.

Sandbox limits#

For tools using the local VM path, generated code runs against a separate Linux kernel and filesystem. The host directories deliberately shared with it are the important exception. The agent can still change or delete files in a writable workspace. Stopping a process does not undo those changes.

The app's job on the Mac is to talk to the model, dispatch tool calls, and collect their results. Anthropic originally ran this coordination inside the VM too, but a failed VM startup could leave Claude unable to respond. Moving it onto the Mac lets Claude help diagnose the failure. When the app asks the guest daemon to run a command, that command still executes inside the VM.

The MCP probe took a different path, running as a host process. So the VM's protections apply to the tools running inside it, and don't cover every action Claude can take.

Bridging the languages#

I found six languages in the app, though lumping C and C++ together is cheating a little :P

  • C/C++ — Electron and Chromium
  • Objective-C — the Squirrel updater
  • JavaScript — the web UI and app coordination
  • Rust — cross-platform glue for windows, keys, and files
  • Swift — Apple APIs such as Security and Virtualization
  • Go — the daemon inside the Linux guest

The interesting part is how those pieces talk. Even JavaScript talking to JavaScript can cross a process boundary, while JavaScript calling Swift or Rust can stay inside the same process. Reaching the Go guest daemon means crossing into another operating system entirely.

How the languages connect
Your Mac
C/C++ Electron & ChromiumObjective-C Squirrel updater
Renderer process · UI / preload
JavaScriptWeb UI · dynamically typed
Electron IPCBetween processes

The receiving code has to check that the message has the fields and types it expects.

Main process · Node.js & native addons
JavaScriptApp coordination · dynamically typed
Node-API bindingsWithin a process

Arguments and return values are converted between JavaScript and Rust or Swift.

RustWindows, keys & filesCompiler checks Rust types
SwiftApple APIs & VM clientCompiler checks Swift types
JSON RPC over vsockBetween Mac and VM

Both sides need matching JSON field names and types.

Linux VM · sdk-daemon
GoGuest process managementCompiler checks Go types

The first bridge is visible right in the JavaScript bundles. Generated Electron IPC channel names have a recognizable shape:

$eipc_message$_<build-id>_$_<namespace>_$_<BridgeClass>_$_<method>

The methods cover terminal operations, session management, VM controls, and native overlays. The app uses Electron IPC to pass these requests and their arguments between processes.

The next bridge converts JavaScript values into native types. The Rust addon uses napi-rs, and the original Swift debug map includes NAPIUtils and NAPIMacroDefinitions. Node-API provides the native interface, including operations to inspect and convert JavaScript values.

Finally, the Swift CoworkVMRPCClient exchanges JSON messages with the Go guest daemon over vsock. Both sides need to expect the same field names and types. Including the daemon in smol-bin lets Anthropic update both sides together when the protocol changes.

What do the binaries reveal?#

The .node files retain source paths, type names, and field definitions. These give us a closer look at the code behind the app's native features.

Source paths#

The build I inspected for an earlier draft retained Swift STAB debug maps, which list object files used during linking. I recovered 50 source filenames in ClaudeSwift and 7 in ComputerUseSwift, including DictationOverlay.swift, HotkeyListener.swift, Screenshot.swift, and ProcessTree.swift.

The installed Swift addons no longer have those debug maps or embedded DWARF sections in their arm64 slices. But swift_addon.node still contains 28 source-path strings in __cstring. Some include the full /Users/runner/work/apps/apps/packages/desktop/claude-swift/ build directory. Here's part of the source tree those paths reveal.

Sources/ClaudeSwift/
├── NAPIBindings+MemoryPressure.swift
├── NAPIBindings+PermissionFixer.swift
├── Standalone/
│   ├── Dictation/
│   │   ├── AudioInputDevice.swift
│   │   └── ClaudeAiSpeechSession.swift
│   ├── QuickEntryCoordinator.swift
│   ├── QuickEntryPresenter.swift
│   └── TrayUsageMenu.swift
├── VirtualMachine/
│   ├── CoworkVMManager.swift
│   └── CoworkVMRPCClient.swift
└── WatchRecordVoiceover.swift

Dictation and VM management have their own directories, alongside Swift files for quick entry and tray menus. Searching only the symbol table would miss these paths.

The earlier debug map also named dependencies. MarkdownUI, swift-markdown, and cmark-gfm point to native Markdown rendering alongside the web UI. VZGvisorNetworking is what led me to the networking code above.

All recovered source paths

Path strings in the installed addon (28)

  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/ClaudeSwift.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/HotkeyListener.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/NAPIBindings+MemoryPressure.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/NAPIBindings+PermissionFixer.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/NAPIBindings+Updater.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/NAPIBindings+VM.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/NAPIBindings+WakeScheduler.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/Standalone/Dictation/AudioInputDevice.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/Standalone/Dictation/ClaudeAiSpeechSession.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/Standalone/QuickEntryCoordinator.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/Standalone/QuickEntryPresenter.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/Standalone/TrayUsageMenu.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/Standalone/Utils/OpenWindowEnumerator.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/Standalone/Utils/ScreenshotCapture.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/Standalone/Visuals.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/VirtualMachine/CoworkVMManager.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/VirtualMachine/CoworkVMRPCClient.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/WatchRecordVoiceover.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/ClaudeSwift/WatchRecorder.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/NotificationService/NotificationService.swift
  • /Users/runner/work/apps/apps/packages/desktop/claude-swift/Sources/VZGvisorNetworking/GvisorNetworkAttachment.swift
  • ClaudeSwift/CoworkVMManager.swift
  • ClaudeSwift/InputTextField.swift
  • ClaudeSwift/MouseMoveTracker.swift
  • ClaudeSwift/NAPIBindings+VM.swift
  • ClaudeSwift/NAPIBindings+WatchRecorder.swift
  • ClaudeSwift/OverlayWindowController.swift
  • NAPIUtils/NAPIUtils.swift

Source filenames from the earlier debug map (57)

  • ClaudeSwift/AppEnvironment.swift
  • ClaudeSwift/AttachmentPill.swift
  • ClaudeSwift/ChatSelector.swift
  • ClaudeSwift/ClaudeAiSpeechSession.swift
  • ClaudeSwift/ClaudeSwift.swift
  • ClaudeSwift/Color.swift
  • ClaudeSwift/CoworkVMManager.swift
  • ClaudeSwift/CoworkVMRPCClient.swift
  • ClaudeSwift/DesktopEvents.swift
  • ClaudeSwift/DesktopObserver.swift
  • ClaudeSwift/DictationOverlay.swift
  • ClaudeSwift/EntryBarPositioning.swift
  • ClaudeSwift/FreeformButton.swift
  • ClaudeSwift/HotkeyListener.swift
  • ClaudeSwift/ImagePreviewPopover.swift
  • ClaudeSwift/Images.swift
  • ClaudeSwift/InputTextField.swift
  • ClaudeSwift/MouseMoveTracker.swift
  • ClaudeSwift/NAPIBindings.swift
  • ClaudeSwift/NAPIBindings+API.swift
  • ClaudeSwift/NAPIBindings+Desktop.swift
  • ClaudeSwift/NAPIBindings+MidnightOwl.swift
  • ClaudeSwift/NAPIBindings+Notifications.swift
  • ClaudeSwift/NAPIBindings+QuickAccess.swift
  • ClaudeSwift/NAPIBindings+TearOffHalo.swift
  • ClaudeSwift/NAPIBindings+TrayUsage.swift
  • ClaudeSwift/NAPIBindings+Updater.swift
  • ClaudeSwift/NAPIBindings+VM.swift
  • ClaudeSwift/NAPIBindings+WakeScheduler.swift
  • ClaudeSwift/NAPIBindings+WatchRecorder.swift
  • ClaudeSwift/OverlayView.swift
  • ClaudeSwift/OverlayWindowController.swift
  • ClaudeSwift/QuickEntryBar.swift
  • ClaudeSwift/QuickEntryCoordinator.swift
  • ClaudeSwift/QuickEntryPresenter.swift
  • ClaudeSwift/QuickEntrySessionTracker.swift
  • ClaudeSwift/QuickScreenshotView.swift
  • ClaudeSwift/ReverseMask.swift
  • ClaudeSwift/ScreenDetector.swift
  • ClaudeSwift/ScreenshotCapture.swift
  • ClaudeSwift/SpeechRecognizer.swift
  • ClaudeSwift/SwiftEvent.swift
  • ClaudeSwift/SwiftEventBridge.swift
  • ClaudeSwift/SwiftUI+Convenience.swift
  • ClaudeSwift/TearOffHaloController.swift
  • ClaudeSwift/TrayUsageMenu.swift
  • ClaudeSwift/Visuals.swift
  • ClaudeSwift/VMError.swift
  • ClaudeSwift/WatchRecorder.swift
  • ClaudeSwift/WaveformVisualizer.swift
  • ComputerUseSwift/AppBundleResolver.swift
  • ComputerUseSwift/Bindings.swift
  • ComputerUseSwift/EscHotkey.swift
  • ComputerUseSwift/InstalledApps.swift
  • ComputerUseSwift/Module.swift
  • ComputerUseSwift/ProcessTree.swift
  • ComputerUseSwift/Screenshot.swift

Swift metadata#

Swift's field records go further than filenames. They describe stored properties and enum cases, including their names and references to their types. I parsed them using Swift's reflection-record layout.

For example, these two structs can be reconstructed from the records in swift_addon.node. The declarations below are generated from metadata, so they show the fields we recovered rather than the original source.

struct MountConfig {
    let path: String
    let mode: String
    let optional: Bool
    let hideJson: String?
}
 
struct QuickEntrySubmittedPayload {
    let prompt: String
    let images: [String]
    let filePaths: [String]
    let chatId: String?
}

MountConfig puts the path and mount mode in one record. QuickEntrySubmittedPayload shows what a quick-entry submission can carry, including images, file paths, and an optional chat ID.

Across the two addons, I found 273 field descriptors and recovered names for 175 of them, covering 171 distinct types. Some names and field types couldn't be resolved. The full inventory below includes the Swift types, with imported C types left out.

Browse all recovered Swift types

155 Swift type records · 98 descriptor names unresolved. Eight guided examples are listed first.

VM state

The VM manager keeps the virtual machine, socket device, shared-directory mapping, and CPU/memory settings together. These are stored-property declarations, not the live settings of a running VM.

swift_addon.node6 of 27 stored fields
class ClaudeSwift.CoworkVMManager {
  var virtualMachine: VZVirtualMachine?
  var socketDevice: VZVirtioSocketDevice?
  var sharedDirectories: [String : String]
  var currentNetworkMode: ClaudeSwift.VMNetworkMode
  var currentMemoryGB: Int
  var currentCpuCount: Int
}

Computer use#

In computer_use.node, the AXDispatch enums separate the action requested from its result and the reason it might fail.

Selected cases in the AXDispatch enums
Requested action
Intent
  • click
  • type
  • key
  • scroll
  • drag
Result
Outcome
  • ok
  • ineffective
  • unsupported
  • dryRun
Reason
ReasonCode
  • noFocusedText
  • secureField
  • foreignPid
  • windowNotReachable

The distinction between ineffective and unsupported is interesting. A supported action can still have no effect. The reason codes also name specific desktop conditions, such as a missing text focus, a secure field, or an unreachable window. The native interface has a vocabulary for explaining these failures instead of returning a single success flag.

Requests and events#

The Swift addon also has records for commands coming into native code and events going back to the app.

Selected types and cases in the native interface
Requests into Swift
  • StartVMArgs
  • SpawnProcessArgs
Events back to the app
SwiftEvent
  • vmStartupStep
  • guestConnectionChanged
  • vmStopped
  • quickEntrySubmitted

StartVMArgs includes memory, CPU, and networking options. SpawnProcessArgs carries a command along with a working directory, environment variables, extra mounts, and allowed domains. In the other direction, SwiftEvent includes VM startup progress, connection changes, and quick-entry submissions.

Conclusion#

I knew sandboxing would be complicated but I didn’t expect it to involve this many moving parts. I also didn't realize much of this was already explained by their blog post, so it was nice reconciling what I discovered with their reasoning.

Unlike other teardowns I've done, this one in particular was actually much more fun to write due to the assistance of Claude/Codex! With their help, I was able to investigate many more interesting paths than normal (remember this is my free time!), chase down obscure leads, explore the guest image, and put together more intuitive diagrams for you to read.