Page cover

Reversing Claude CoWork

hehehaha

Motivation

Recently, Claude released CoWorkarrow-up-right that aims to adapt Claude Code for general users. Since there were rumors that it runs a Linux VM using Apple's Virtualization Frameworkarrow-up-right, I wanted to take a look for myself to see how it was implemented and also gain more experience in reversing swift.

This analysis started at the end of Jan 2026 and might not be accurate due to how often Claude is updated.

Objectives

Below are a list of questions that I came up with to "guide" my RE efforts for learning.

  1. What is the different components in Claude, specifically CoWork and how do they work together

  2. How does the communication between VM and host work in CoWork's Case

  3. Which functions and behaviors are exclusive to the CoWork "mode" versus the standard Claude Chat interface.

  4. How do specific Swift constructs (structs, enums, closures) and behaviours appear in the disassembled binary?

Overview

Claude Cowork runs a CLI process inside a single shared VM and connects that process to the desktop session flow.

It is broken up into three main components:

  1. Frontend (app.asar)

  2. Bridging API (swift-addon.node)

  3. Guest VM (Claude-vm)

Most of the reverse-engineering efforts will be placed on swift-addon.node.

Architecture

This is the rough overview of the entire Claude Architecture and its different components.

Architecture

Initial Analysis

Like most applications on MacOS, dumping entitlements give us a good idea of what kind of privileged actions a binary might need to perform.

Two interesting entries that stand out immediately is com.apple.security.cs.allow-jit and com.apple.security.virtualization.

com.apple.security.virtualization allows an application to use the Virtualization framework. and com.apple.security.cs.allow-jit like it's name suggests allows for execution of JIT-Compiled code although I'm not entirely sure for which component.

Looking at the Claude binary, we see that it's mainly a electron wrapper and the contents that interests are more is app.asar

We can extract and "deobfuscate" the contents from app.asar using webcrackarrow-up-right.

After which, we can begin analyzing the key contents of app.asar in the following section.

Frontend

Cowork Session Lifecycle

Cowork sessions do not create a VM per session. They reuse the existing VM if it exists and spawn a per-session process inside it.

The VM lifecycle is managed by the swift-addon via Apple's Virtualization frameworkarrow-up-right. Cowork ties into that lifecycle with the functions below.

Firstly, Cowork checks if there already is a Guest VM spawned in g0

A prototype is defined in T5e which maps createVMSpawnFunction to C5e

This brings us to $5e which is the core entrypoint and calls the exported function vm.spawn from swift-addon.node. This later spawns a process inside the VM.

Cowork Specific Tools

Looking for Cowork strings, we see that there is some logic related to mounting, tool and delete permissions.

For mounting a specific user directory, Cowork exposes a tool that asks the user to pick a directory and then mounts it into the VM using vm.mountPath(..., "rw")

This is mounted at /sessions/${t.vmProcessName}/mnt/${o}

For delete permissions, Cowork has a dedicated tool allow_cowork_file_delete which upgrades a mount from rwrwd so deletes are allowed. This is only used when a delete fails with "Operation not permitted."

For confirmation, Cowork injects a folder name into the tool permission prompt when the delete tool is requested.

Midway through, I realized that many of the functions in index.js have been identified and reverse-engineered by others using Claude itself which I thought was pretty hilarious. Here are some good breakdowns.

For the purpose of learning, I will focus on how this translates to Swift and what actually happens when these functions/frameworks are called.

Bridging API

Node.js ↔ Swift Interop

swift

Before we get into swift-addon, we first have to understand how Swift and Node work together. Let's take this examplearrow-up-right.

Swift cannot directly speak to Node.js because of swift-manglingarrow-up-right, which scrambles function names. With the example above, here is what happens when Node.js opens the shared library HelloWorld.node:

  1. Node searches for a standard C symbol

    1. Node looks for the specific, non-mangled C macro NAPI_MODULE. It finds this in Trampoline.c

  2. The Trampoline calls Swift

    1. The C code calls init_hello_world. It is able to find the Swift implementation because @_cdecl forced the Swift compiler to use that exact name

  3. Swift executes

    1. Swift uses NAPI bindings to construct a JavaScript-compatible object and returns it to Node for use.

Let's see this in action from index.js which was extracted from app.asar

Lets break it down

  1. Node loads the binary swift_addon.node via import in jme() which triggers dlopenarrow-up-right

  2. This executes napiRegisterModule in swift_addon.node in the background which returns a tree of objects to Node, specifically the vm branch

  3. In L5e specifically, it calls startVM. Node looks up the string startVM in the N-API object which eventually lands in ClaudeSwift.vmStartVM

With that, we will now focus on the VM lifecycle.

VM Lifecycle

The full decompilation is a repetitive pattern of napi_create_function + napi_set_named_property for each method. Here is the complete N-API method export table for all VM related tasks.

#
N-API Export
Purpose

1

createVM

Create VM instance with bundle path + disk size

2

startVM

Boot the VM

3

stopVM

Shutdown VM

4

isRunning

Check if VM is running

5

isGuestConnected

Check vsock connection to guest

6

spawn

Spawn process in VM

7

kill

Kill process in VM

8

writeStdin

Write to process stdin

9

isProcessRunning

Check if a specific process is alive

10

setEventCallbacks

Set stdout/stderr/exit/error/networkStatus callbacks

11

showDebugWindow

Show VM debug/graphics window

12

hideDebugWindow

Hide VM debug/graphics window

13

setDebugLogging

Enable/disable debug logging

14

isDebugLoggingEnabled

Check debug logging state

15

mountPath

Mount host path in VM

16

loadTranscripts

Load conversation transcripts

17

readFile

Read file from VM

18

installSdk

Install SDK in VM

19

addApprovedOauthToken

Add OAuth token to VM

Let's take a closer look starting with vmCreateVM.

vmCreateVM

This function does not create the VM (that happens in startVM) but instead, stores the bundle path and validates that rootfs.img exists via NSFileManager.fileExistsAtPath

vmStartVM

Opening it up in IDA and filtering for startVM, we see the following.

Tracing it, we eventually land in ClaudeVMManager.startVM().

To summarise what this function is doing

  1. Setting up file path for rootfs.img which is a img file downloaded from anthropic's website

  2. Setup config with createLiunxVMConfiguration and initializes it with VZVirtualMachine(configuration)

  3. Setups the communication channel (vsock) with the use of VZVirtioSocketDevice

    1. If found, it initializes a RPC client ClaudeVMDaemonRPCClient(socketDevice: device)

    2. Calls applyStoredCallbacks() which handles different streams such as stderr, stdout and more

It also calls ClaudeVMManager.createLinuxVMConfiguration which we will look into below.

createLinuxVMConfiguration

This function is responsible for building VZVirtualMachineConfiguration.

For brevity sake, I included a table that shows the different components set.

Component
Class
Value

CPU

setCPUCount:

min(max(hostCPUs/2, 1), 4)

Memory

setMemorySize:

memoryGB << 30 (default 8GB from JS)

Platform

VZGenericPlatformConfiguration

Persistent machine identifier

Boot

VZEFIBootLoader

UEFI/GRUB

Storage (root)

VZNVMExpress / VZVirtioBlock

rootfs.img (read-write)

Storage (session)

Same

session-data.img (read-write)

Storage (optional)

VZUSBMassStorageDevice

smol-bin.img (read-only)

Network

VZVirtioNetworkDevice + VZNATAttachment

NAT with persistent MAC

Entropy

VZVirtioEntropyDevice

Hardware RNG

Graphics

VZVirtioGraphicsDevice

1280x800 scanout

Keyboard

VZUSBKeyboardConfiguration

USB keyboard

Mouse

VZUSBScreenCoordinatePointingDevice

USB mouse

Console

VZVirtioConsoleDevice

"claude-daemon-console" via NSPipe

Socket

VZVirtioSocketDevice

vsock (port 51234)

Filesystem

VZVirtioFileSystemDevice

Tag "claudeshared", home dir shared R/W

vmStopVM

The core logic is in ClaudeVMManager.stopVM() at 0x2cc04.

There are essentially two shutdown paths

  1. If canRequestStop is true → calls [VZVirtualMachine stopWithCompletionHandler:] (sends ACPI shutdown signal, awaits completion via swift_continuation_await)

  2. If canRequestStop is false → polls vm.state every 100ms with a 10-second timeout via static Task<>.sleep(nanoseconds:)(100000000).

    1. Logs a warning if the VM is still running after timeout.

isGuestConnected

ClaudeVMManager.isGuestConnected.getter at 0x2db10

Basically checks for rpcClient, rpcClient.connection (offset +24) exists AND returns rpcClient.guestReady (offset +48)

vSock Communication

Listener Setup

To take a deeper look into how Claude communicates with the VM, we can look at the RPCClient methods.

We see that it initalizes a listener at port 51234 and a delegate method which eventually leads to listener:shouldAcceptNewConnection:fromSocketDevice: and eventually handleGuestConnection.

This link becomes obvious in two decompiled blocks

  1. Delegate accepts the connection and calls its stored onConnection closure:

  1. That closure immediately forwards the connection to handleGuestConnection which calls startReadingMessages

Reading Messages

ClaudeVMDaemonRPCClient.startReadingMessages at 0x19E24 is the entry point for the inbound message pipeline. It extracts the raw file descriptor from the VZVirtioSocketConnection, configures it for non-blocking I/O via fcntl, and launches a detached Task that runs the read loop.

swift

The operation: argument passed to Task.detached is a Swift AsyncFunctionPointer, a two-field struct defined by the Swift ABI in executor.harrow-up-right:

At 0x1E34C8 in __const, the raw bytes E0 72 E4 FF 20 00 00 00 decode as:

  • Function = int32_t(-0x1B8D20) → resolved target: 0x1E34C8 + signext32(0xFFE472E0)

  • ExpectedContextSize = 0x20 (32 bytes)

Since IDA doesn't natively resolve these relative pointers, we can use a IDAPython script scans __const for all AsyncFunctionPointer records, applies the SwiftAsyncFunction struct, and creates navigatable cross-references here.

Following the resolved async pointer xrefs, we land in the actual read loop at ClaudeVMDaemonRPCClient_readLoop_vsock_TY0 (0x1A588). This function continuously reads raw bytes over vsock, accumulates them into a persistent Data buffer, and extracts complete messages using a 4-byte big-endian length header.

The loop begins by checking Task.isCancelled and allocating a 4KB read buffer:

Each successful read appends the new bytes to a persistent Data buffer via Data._Representation.append(contentsOf:). The loop then enters a message extraction phase that processes as many complete messages as possible from the accumulated buffer. It first checks whether the buffer contains at least 4 bytes for the length header:

if message_length >> 22 > 0x18 (roughly >100MB), the message is logged as too large and the 4-byte header is discarded:

If the buffer contains the full message (buffer_length >= message_length + 4), the body is sliced out, decoded as UTF-8, and deserialized:

If the buffer doesn't have enough data for a complete message, it logs the shortfall and loops back to read():

When the read loop terminates (EOF, cancellation, or fatal error), it logs the total number of processed messages and switches to MainActor for cleanup via swift_task_switch:

Message Structure

Before looking at dispatch, we can first prove that ClaudeVMMessage.CodingKeys matches the custom ClaudeVMMessage struct defined in IDA. Let's take a closer look.

In encode(to:) (0x1782C), the encoder is built with ClaudeVMMessage.CodingKeys, then key indices 0..6 are written immediately before encoding values read from the same ClaudeVMMessage * instance (type, id/method/event, params_buf, result_buf, error). This shows that the CodingKeys enum defines the serialized key mapping for ClaudeVMMessage.

Putting it together, this gives us the complete layout of ClaudeVMMessage.

CodingKey
Field
Offset
Type
Used By

0

type

+0

ClaudeVMMessageType

All messages

1

id

+8/+16

String?

Request/Response

2

method

+24/+32

String?

Request

3

event

+40/+48

String?

Event

4

params

+56

ClaudeVMAnyCodable?

Request/Event

5

result

+88

ClaudeVMAnyCodable?

Response

6

error

+120..+136

ClaudeVMError?

Response

We can define it in IDA in the following manner.

The type field is an enum stored as a single byte which corresponds to different message types.

Message Dispatching

After JSONDecoder produces a ClaudeVMMessage, the read loop calls ClaudeVMDaemonRPCClient.handleMessage (0x20638) which immediately forwards to the dispatcher at 0x206A4. The dispatcher reads the type byte and branches accordingly

Only response (type=2) and event (type=3) are handled inbound. Requests (type=0) are outbound-only — the host sends them to the guest via sendRequest.

Type Value
Name
Direction
Has Response?

0

request

Host → Guest

Yes (continuation stored)

1

notification

Host → Guest

No (fire-and-forget)

2

response

Guest → Host

N/A (is the response)

3

event

Guest → Host

No (callback invoked)

Before diving into the handlers, we type v2 as _TtC11ClaudeSwift23ClaudeVMDaemonRPCClient * since it represents self. From the OBJC ivar metadata we can reconstruct the class layout:

The event callbacks (onStdout, onStderr, onExit, onError, onNetworkStatus) are assigned by ClaudeVMManager.applyStoredCallbacks at 0x31A2C, which copies stored closures from the manager into the RPC client:

swift

If you noticed above, the code is writing 16 bytes of data (v3 and v4) into the memory address pointed to by v5.

In Swift, this 16-byte payload represents an escaping closure. Closures package both code and state (the variables they capture from their surrounding environment). The structure consists of a function pointer and a context pointer as seen below.

You can read more about closures herearrow-up-right

handleEvent — Guest-Initiated Notifications

handleEvent at 0x20C1C routes asynchronous notifications from the guest VM. Unlike responses, these events are unsolicited meaining the guest pushes them whenever something happens (process output, exit, errors, etc.). The function reads message.event and dispatches through a chain of string comparisons:

Six events are handled, each invoking a callback closure stored on self:

Event
Description
Params
Callback Type

ready

Guest daemon initialized

(none)

CheckedContinuation<(), Never>?

stdout

Process stdout output

id: String, data: String

((String, String) -> Void)?

stderr

Process stderr output

id: String, data: String

((String, String) -> Void)?

exit

Process exited

id: String, code?: Int, signal?: String

((String, Int?, String?) -> Void)?

error

Process error

message: String, id?: String, fatal?: Bool

((String?, String, Bool) -> Void)?

networkStatus

VM network connectivity changed

status: String

((String) -> Void)?

Every handler (except ready) follows the same pattern: cast message.params to [String: Any], look up keys via __RawDictionaryStorage.find<A>, dynamic-cast the values to the expected types, and invoke the callback closure. Here's stdout as a detailed example:

Detailed Example: stdout

In the decompilation, it does a chain of dictionary lookups using small-string-encoded keys and swift_dynamicCast for type-safe extraction before calling onStdout callback. The stderr handler similar, but instead just reads from OBJC_IVAR____...onStderr instead. The remaining events follow the same extraction pattern with different keys and types.

swift

In the binary, the event name dispatch doesn't use standard string comparison.

Swift uses small string optimizationarrow-up-right — strings ≤15 bytes are stored inline as two integers rather than heap-allocated. The comparison is a fast integer check with a _stringCompareWithSmolCheck fallback for heap strings:

The encoding is as follows, bytes stored in little-endian in the first word, discriminator byte (0xE0 | length) in the MSB of the second word.

The same encoding applies to dictionary keys extracted from message.params

Below are the associated event keys that it compares against before it calling the associated callback.

Event Name
Word 1 (LE bytes)
Word 2 (discriminator)
Length

ready

0x7964616572

0xE500000000000000

5

stdout

0x74756F647473

0xE600000000000000

6

stderr

0x727265647473

0xE600000000000000

6

exit

0x74697865

0xE400000000000000

4

error

0x726F727265

0xE500000000000000

5

networkStatus

0x536B726F7774656E

0xED00007375746174

13

Key
Integer Value
Discriminator

id

25705

0xE200000000000000

data

1635017060

0xE400000000000000

code

1701080931

0xE400000000000000

signal

0x6C616E676973

0xE600000000000000

message

0x6567617373656D

0xE700000000000000

fatal

0x6C61746166

0xE500000000000000

status

0x737574617473

0xE600000000000000

Sending Messages

To send a message to the Guest VM, the RPC layer uses length-prefixed JSON over vsock.

The format is simply [4-byte Big Endian Length][JSON bytes]. It utilizes VZVirtioSocketConnection.fileDescriptor to send messages and uses a string id req-<counter> to keep track.

Taking a look at sendRequest with the appropriate struct ClaudeVMMessage applied, we see the following.

At a high level, the decompilation above is doing:

  1. Increment and validate request counter

  2. Generate unique request id req-<counter>

  3. Craft ClaudeVMMessage

    1. Sets type = request

    2. Sets id = req-<counter>

    3. Sets method = provided_method_name

    4. Sets params = Dictionary<String, Any>

  4. Serialize to JSON and write to vsock via writeMessage_vsock

  5. Suspend and await a response (continuation stored in pendingRequests)

swift

This function also makes use of Swift Continuations, which allow an asynchronous task to suspend itself so that synchronous code can capture it and manually resume it later in response to an event.

In this case at (6), it's doing the following:

  1. Allocates task frame on the heap

    1. This allows it to persist across suspension

  2. Create continuation and suspend

    1. Stores CheckedContinuation<ClaudeVMMessage, Error> in pendingRequests[requestId]

    2. Task suspends until the read loop decodes a matching response and handleResponse resumes it

For more information about Swift's Continuations, check this outarrow-up-right

This brings us to ClaudeVMDaemonRPCClient.writeMessage_vsock, which is the on-the-wire encoder + write loop. For brevity's sake, I have truncated the decompilation to show only the key code.

writeMessage_vsock is the implementation for outbound RPC:

  1. Load self.connection (VZVirtioSocketConnection) and error/throw if it’s nil.

  2. Encode the ClaudeVMMessage to JSON bytes via JSONEncoder.encode(...).

  3. Compute the JSON byte length and build the frame:

    1. length_prefix_be = bswap32(json_len)

    2. payload = Data(prefix_be) + jsonBytes

  4. Write the payload to the vsock fd in a loop:

    1. Handles partial writes (keeps an offset until written == total).

    2. If write(...) fails with errno == 35 (EAGAIN/EWOULDBLOCK), it sleeps (usleep(1000)) and retries.

    3. Other errno values become ClaudeVMRPCError.

swift

Another odd thing you might notice is that IDA's decompilation of Swift is usually full of undefined variables highlighted in red.

Using writeMessage as an example, we see that v1 is undefined.

Looking at the disassembly reveals that v1 maps to the x20 register, which serves as the designated Swift self pointer according to the ARM64 calling convention.

We can easily fix this by modifying the calling convention by editing the function prototype.

This gives us a nicer decompilation in IDA.

To understand which methods actually use sendRequest, we can take a look at the xrefs.

We see the spawn method, let's take a closer look at its decompilation.

This function simply does the following

  1. Load parameters from async_frame

    1. id

    2. name

    3. command

    4. args

    5. cwd

    6. env

    7. additionalMounts

    8. allowedDomains

    9. sharedCwdPath

    10. isResume

  2. Create base dictionary and add optional parameters cwd and env

  3. Sends RPC request via sendRequest which serialized params to JSON

The final JSON sent to the VM should look something like this

Other functions such as mountPath do something similar where they format a JSON request and send it to the VM guest daemon. The message specification and parameters are specified below and the actual validation/action is done in the guest-daemon side.

#
Swift Method
RPC Method
Address
N-API Export
JS Call

1

spawn(id:name:command:args:cwd:env:additionalMounts:isResume:allowedDomains:sharedCwdPath:)

"spawn"

0x22f48

vmSpawn

vm.spawn(...)

2

kill(id:signal:)

"kill"

0x23a74

vmKill

vm.kill(id, signal)

3

isRunning(id:)

"isRunning"

0x23d20

vmIsProcessRunning

vm.isProcessRunning(id)

4

mountPath(processId:subpath:mountName:mode:)

"mountPath"

0x245f8

vmMountPath

vm.mountPath(...)

5

loadTranscripts(processName:)

"loadTranscripts"

0x2495c

vmLoadTranscripts

vm.loadTranscripts(name)

6

readFile(processName:filePath:)

"readFile"

0x24ee0

vmReadFile

vm.readFile(name, path)

7

installSdk(sdkSubpath:version:)

"installSdk"

0x25550

vmInstallSdk

vm.installSdk(path, ver)

8

addApprovedOauthToken(token:)

"addApprovedOauthToken"

0x25b58

vmAddApprovedOauthToken

vm.addApprovedOauthToken(token)

writeStdin — Fire-and-Forget Notifications

ClaudeVMDaemonRPCClient.writeStdin(id:data:) at 0x24070. Unlike spawn/kill, this does not use sendRequest — it builds a ClaudeVMMessage with type=1 (notification) and calls writeMessage directly.

Key difference from sendRequest is that it uses ClaudeVMMessage.type = 1 (notification) instead of type = 0 (request). This means that it doesn't wait for a response back.

Complete Chain

Here's how the different components covered above work together, using spawn command as the example.

0. Wire up callbacks (once)

Before the VM can report anything back to JS, the callbacks need to be registered. qme() does this on first spawn and never again:

Swift wraps each of those closures as a thread-safe function handle (napi_threadsafe_function) so they can be called from any thread, then copies them to the live RPC client via applyStoredCallbacks().

1. JS calls spawn

$5e (the spawn wrapper in app.asar) calls qme(), then calls o.spawn(...) on the Swift addon. That goes through N-API into ClaudeVMDaemonRPCClient.spawn, which builds the parameters dictionary and hands it to sendRequest.

2. Request goes out over vsock

sendRequest assigns the call an ID ("req-N"), serializes it to JSON, prepends a 4-byte big-endian length, and writes the frame over the vsock file descriptor. It then suspends the current Swift async task, storing a CheckedContinuation in pendingRequests["req-N"] and waits.

3. Guest responds

The guest daemon processes the spawn and sends back {"type":"response","id":"req-N","result":{...}}. The read loop on the host picks it up, decodes it, and routes it to handleResponse, which looks up the stored continuation by ID and resumes it. The original sendRequest call returns with the response.

4. Guest pushes stdout events

Once the process is running, the guest sends output as events where no request from the host needed. Each message looks like {"type":"event","event":"stdout","params":{"id":"...","data":"..."}}. handleEvent matches the event name, extracts the data, and calls the stored onStdout callback.

The four message types the vSock layer uses:

Type
Name
Direction
Handling

0

request

Host → Guest

sendRequest — continuation stored in pendingRequests

1

notification

Host → Guest

writeMessage — fire-and-forget, no response

2

response

Guest → Host

handleResponse — resumes stored continuation

3

event

Guest → Host

handleEvent — invokes stored callback closure

This is a diagram of what that looks like

Both onStdout and onStderr in qme() call pushStdout where stdout and stderr are merged into a single stream at the JS layer.

Tracing

Using strace, we could come up with a simple script that traces all vsock communication to/from host to guest by tracking read and write syscalls.

In the example below, I requested to mount a directory from host to guest (mountPath) and sure enough we see the call.

This is in line with the current analysis, where the mountPath parameters matches (processId, subpath, mode, mountName).

Guest VM

Now that we know how the host communicates with the Guest VM. Let's take a look at what the binaries/processes running inside the Guest VM entail.

As I was lazy to download the Guest VM and manually extract out the relevant files, I prompted Claude-CoWork to reveal the processes running on the machine and extract out the relevant files for analysis.

claude

This is the main claude binary, which is compiled with bun.js utilizing JavaScriptCore. Running it on a ARM Linux VM shows that its just a compiled Claude-Code Binary

apply-seccomp

apply-seccomp-<arch> is a helper binary used for enforcing seccomp rules (*.bpf files).

Using seccomp-tools, we can easily see the accompanying seccomp rules

  • Both seccomp filters block unix domain socket calls with AF_UNIX = 1, which prevents the sandboxed process from talking to other processes.

  • The x64 filter does the additional step of blocking x32 syscalls as well, preventing x32 abi abusearrow-up-right

Opening apply-seccomp- in IDA reveals a straightforward wrapper that loads a BPF filter, installs it via seccomp, and then replaces itself with the target process using execve.

To summarize, the binary does the following

  1. Load the BPF filter from the file specified in argv[1].

  2. Lock privileges with prctl(PR_SET_NO_NEW_PRIVS, 1), ensuring neither this process nor any descendant can gain new privileges.

  3. Install the seccomp filter with prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &sock_fprog).

    1. Once applied, the filter is inherited by all child processes, cannot be removed, and persists across execve.

  4. Replace itself via execvp(command, args), so the target command starts inside the sandbox.

Because the filter is installed before execve, the new process image inherits the restrictions from the moment it begins execution. Any blocked syscall triggers the configured action which is either SIGKILL (for architecture mismatches) or ERRNO(1) (for disallowed Unix socket creation)

sdk-daemon (aka coworkd)

This is the main binary of interest that orchestrates what is going on in the Guest VM.

golang

Like most golang binaries, we can make use of redressarrow-up-right to extract metadata and reconstruct symbols.

Here vcs.time tells us that this binary was built on 18/01/2026.

Another useful command is types <struct|interface|all> which parses golang's metadata to display the structs defined in the binary.

In this event that IDA is not able to parse/analyze the golang metadata properly, this is useful for referencing and making the code more readable (through Inserting Custom Structures etc).

Taking at the entrypoint main.main, we see the following.

The key thing here is the creation of rpc_Client struct. From a glance, it has the relevant port (51234) and CID=0x00000002 which refers to the host machinearrow-up-right that the Guest VM is communicating to. We will breakdown rpc_Client later.

It then proceeds to allocate a ProcessManager which is in charge of tracking the processes spawned by tasks.

This structure can be seen in IDA, where we see that ProcessManager handles stdout, stderr and OnExit.

This is seen below where different closures are registered for the three events (stdout, stderr and exit).

Next, a MITM proxy is initalized and started where outbound HTTPs is intercepted to inject approved OAuth tokens.

Then, all inbound RPC handlers are mapped onto rpcClient with methods such as Spawn, Kill that were seen earlier in swift-addon.node.

This is the recovered structure from Golang's Metadata reconstructed by IDA. Besides port and its port_cid, we can confirm that the rpcClient struct maps different RPC methods seen earlier in swift-addon.node such as onSpawn.

Lastly, the daemon ensures its fully initialized and spawns a readLoop to read JSON over vsock until the connection drops.

RPC Communication Flow

As seen earlier in swift-addon.node, all vsock frames share the same wire format which is <4 byte Big-Endian Length><JSON data>

From the guest's perspective there are four message types where two are inbound and two are outbound.

Guest
Type
Behaviour

Receives

"request"

Dispatched by handleRequest; every path converges to sendResponse where a reply is always sent

Receives

"notification"

Dispatched by handleNotification; no reply sent

Sends

"response"

Sent after handling every inbound request; echoes the request ID so the host can match continuations

Sends

"event"

Sent async: stdout, stderr, exit, networkStatus, ready

Anything else arriving inbound including response or event hits the fallthrough in readLoop and is logged as [rpc] unknown message type and discarded.

RPC Request Handler

The handleRequest dispatches on string length first, followed by a byte comparison which is confirmed from IDA's decompilation. Every path (including errors) converges at LABEL_88 and calls sendResponse so the host always gets a reply.

Here is a shorter and cleaned up version of the code

Below is a list of request and notification handlers respectively.

Method
Len
rpc_Client slot
Params
Returns
Description

kill

4

a1[5]

{ID, Signal}

error

Sends a signal to a running guest process

spawn

5

a1[4]

SpawnParams

{ID, Success}

Spawns a sandboxed Claude Code process via sandbox-helper

readFile

8

a1[10]

{ProcessID, FilePath}

{Success, Content (b64), Error}

Reads a file from the guest FS; file content is base64.StdEncoding.EncodeToString before JSON

isRunning

9

a1[6]

{ID}

{ID, Running, ExitCode*}

Checks if a process is alive; returns exit code if stopped

mountPath

9

a1[8]

{ProcessID, Subpath, MountName, Mode}

{MountPoint, Success}

Bind-mounts a host or session path into the guest process's directory

installSdk

10

a1[11]

{SdkSubpath, Version}

{Success, Error}

Installs an SDK version from smol-bin into the guest

loadTranscripts

15

a1[9]

{ProcessName}

LoadTranscriptsResult

Loads stored conversation transcripts for a named session

addApprovedOauthToken

21

a1[12]

{Token}

{Success, Error}

Registers an OAuth token in the MITM proxy's sync.Map allowlist

Method
Len
rpc_Client slot
Params
Description

stdin

5

a1[7]

{ID, Data}

Writes bytes to a running process's stdin pipe (fire-and-forget)

staticIPAssignment

18

a1[13]

{IP, PrefixLength, Gateway, DNS}

Applies a static network config to the guest's network interface

The response is then sent back over vsock.

Every request, including errors, gets a response echoing back the original request ID.

RPC Notifications (host → guest)

Notifications at handleNotification @ 0x2e57b0 are fire-and-forget where no response is sent back

stdin is a notification (not a request) because the host doesn't need to wait for acknowledgement as it is streaming data into a pipe.

RPC Events (guest → host) — (sendEvent @ 0x2e4450)

The sdk-daemon sends events to the host (swift-addon.node) via vsock.

Here is the associated event callback table and how it is mapped from sdk-daemon to swift-addon.node.

Event Callback Table (sdk-daemon -> swift-addon.node)

Event
sdk-daemon Function
Address
swift-addon Handler
Description

stdout

main.main.stdout

0x2edc90

handleStdoutEvent

Process stdout data

stderr

main.main.stderr

0x2edb80

handleStderrEvent

Process stderr data

exit

main.main.exit

0x2eda70

handleExitEvent

Process exit notification

networkStatus

main.main.network_status

0x2ed870

handleNetworkStatusEvent

Network state change

addApprovedOauthToken

main.main.addproxytoken

0x2ed9b0

N/A (receives from host)

Approve OAuth token for MITM

MITM Proxy Check Logic

When an outbound HTTPS request arrives at the MITM proxy, the handler at Start.func2 (0x2c2b30) applies three checks in order.

IsAllowedDomain performs some normalization by stripping the port, lowercasing the hostname and only accepting anthropic.com and its subdomains.

isTokenApproved checks the Authorization header on outbound requests.

  • If the header is absent (i.e. a2.len == 0), the request is approved unconditionally.

  • This is probbaly done for tool calls such as bash/npm/git which do not require credentials

When the header is present, as with Claude's own API calls to Anthropic, the token must begin with Bearer and exist in the daemon's approvedTokens map.

Lastly, we will move on to how Claude implements sandboxing within the Guest VM and how it works with CoWork.

Sandboxing

Every Claude Code session inside the Guest VM is wrapped in multiple layers of isolation. The sandboxing is split between two components:

  • coworkd (sdk-daemon) which isolates sessions from each other through the use of dedicated users and more

  • SRTarrow-up-right + sandbox-helper isolates the Claude Code process itself with the use of bwrap namespaces, read-only filesystem and more

The entire Claude Code runtime, including its Read, Glob, and Grep tools runs inside a single bubblewrap namespace where PID 1 is bwrap.

This was confirmed by prompting CoWork to run cat /proc/1/cmdline, which returned the full --ro-bind / / --unshare-pid --unshare-net argument set.

How a Session Gets Sandboxed: ProcessManager.Spawn

Everything starts when the macOS host sends a spawn RPC over vsock. coworkd's ProcessManager.Spawn at 0x2eed60 orchestrates four isolation mechanisms before exec-ing into sandbox-helper, which then adds the remaining layers. The full spawn sequence is:

From here, sandbox-helper takes over and applies the remaining isolation before executing into SRT, which wraps the Claude Code instance with bubblewrap:

The order matters in this case as socat is started before seccomp is applied, then Claude Code runs. Because socat is already running when seccomp kicks in, its existing Unix domain socket connections stay open allowing it to reach the internet. Claude Code on the other hand starts after seccomp is applied and as such cannot create new Unix sockets due to the filter(AF_UNIX is blocked).

This prevent Claude Code from bypassing the proxy chain or talking to other sessions.

The --no-srt flag (injected when AllowedDomains == ["*"]) skips the SRT/bwrap wrapping entirely and runs the command directly after the privilege drop.

Configuration

Two configuration files from the VM image confirm this layered design.

sdk-daemon.service - the systemd unit for coworkd:

srt-settings.json — the SRT configuration consumed by sandbox-helper via BuildSrtCommand:

The schema matches SRT's sandbox-config.js (SRT sourcearrow-up-right). The mitmProxy.socketPath is /var/run/mitm-proxy.sock which is the coworkd's MITM proxy listens on. This chains SRT's network filtering through the existing proxy: SRT's HTTP/SOCKS proxies enforce the allowedDomains list (package registries, GitHub, etc.), then forward *.anthropic.com traffic through coworkd's MITM proxy which gates token approval and beta headers.

The filesystem.allowWrite: ["/"] looks permissive but Guest VM's filesystem is already isolated by Virtualization.framework, and per-session write restrictions are enforced by coworkd's bindfs mounts. SRT's uses filesystem sandboxing (via --ro-bind / /) which still blocks everything by default and only allows explicit writable bind mounts.

Now let's examine each isolation mechanism in detail, following the order they're applied during spawn.

Layer 1: Linux User Isolation (coworkd)

Each session gets its own Linux user (useradd -m -d /sessions/{id}), handled by main.ensureUser. Each session's uid is mapped into a user namespace where other sessions' UIDs appear as nobody:nogroup, and home directories are drwxr-x---, so sessions cannot access each other's files.

Layer 2: Cgroup v2 Isolation (coworkd)

Each spawned process is placed in a dedicated cgroup under /sys/fs/cgroup/sdk-daemon/{processId} for lifecycle tracking and clean teardown.

Function
Address
Action

main.initCgroupSubtree

0x2e86c0

os.MkdirAll("/sys/fs/cgroup/sdk-daemon/") — creates the daemon's subtree once at startup

main.CreateCgroup

0x2e87d0

os.Mkdir("/sys/fs/cgroup/sdk-daemon/{processID}") — per-process cgroup

main.(*Cgroup).OpenFD

called @ 0x2efd4c

Opens an FD for the new cgroup directory

main.(*Cgroup).Cleanup

called @ 0x2f0558

Removes the cgroup directory on process exit

The cgroup is attached to the child process via syscall.SysProcAttr before exec:

Layer 3: Filesystem Isolation via bindfs (coworkd)

main.mountSubpathForUser (@ 0x2eab60) uses bindfs to expose host directories into the session with permission constraints. The source is always the virtiofs share from macOS (/mnt/.virtiofs-root/shared/{name}), mounted to /sessions/{id}/mnt/{path}.

Mode
bindfs flags
Permissions

ro

-p u=rX:go=

Read + execute only

rw

--delete-deny -p u=rwX:go=

Read + write, no delete

rwd

-p u=rwX:go=

Full read/write/delete

The --delete-deny default on rw mode is notable, it prevents Claude Code from deleting files in the shared workspace even if it has write access. Deletion must be explicitly upgraded to rwd mode which requires explicit permission from the user (allow_cowork_file_delete),

Layer 4: sandbox-helper (mount isolation + privilege drop + seccomp)

coworkd fork+exec's the child into /usr/local/bin/sandbox-helper, which applies three isolation steps before handing off to SRT:

  1. Mount namespace isolation: calls unshare(CLONE_NEWNS), makes all mounts slave, then unmounts the virtiofs share (/mnt/.virtiofs-root/shared and /mnt/.virtiofs-root). After this, the session can no longer reach the host filesystem. The only files available are what coworkd already bind-mounted into /sessions/{id}/mnt/.

  2. Privilege drop: calls setgroups([gid]), setgid(gid), setuid(uid). The process now runs as the session's dedicated Linux user instead of root.

  3. Seccomp (AF_VSOCK block): applySeccompFilter() blocks socket(AF_VSOCK), preventing the process from opening a raw vsock connection to the macOS host, which would bypass the proxy chain.

Unless --no-srt was passed, sandbox-helper then loads the SRT config and exec's into SRT:

Layer 5: SRT Bubblewrap Sandbox

SRTarrow-up-right is Anthropic's open-source sandboxing tool. It builds the bubblewrap argument list from the SRT config and wraps the entire Claude Code process:

Before launching bwrap, SRT starts socat bridges to restore network access through the two proxies (SOCKS5/HTTP(S))

SRT's HTTP(S) and SOCKS5 proxies enforce the allowedDomains list from srt-settings.json (npm, PyPI, GitHub, crates.io, etc.). Traffic to *.anthropic.com is additionally forwarded through coworkd's MITM proxy (via the mitmProxy.socketPath), which applies token and beta-header checks.

SRT always blocks write access to dangerous files regardless of configuration, detected by scanning with ripgrep at sandbox initialization:

Category
Blocked Paths

Shell configs

.bashrc, .bash_profile, .zshrc, .zprofile, .profile

Git configs

.gitconfig, .gitmodules, .git/hooks, .git/config

IDE/Tool configs

.vscode/, .idea/, .claude/commands/, .claude/agents/

Other

.ripgreprc, .mcp.json

Component
Source in SRT Repo
Location in Guest VM
Purpose

srt CLI

dist/cli.js

/usr/local/lib/node_modules_global/bin/srt

Entry point

apply-seccomp

vendor/seccomp/arm64/apply-seccomp

Inside bwrap exec chain

Applies BPF before exec

unix-block.bpf

vendor/seccomp/arm64/unix-block.bpf

/smol/bin/unix-block-arm64.bpf

BPF filter

SandboxManager

dist/sandbox/sandbox-manager.js

Bundled

Orchestrates bwrap + proxies

HTTP Proxy

dist/sandbox/http-proxy.js

In-process

Domain filtering for HTTP/HTTPS

SOCKS5 Proxy

dist/sandbox/socks-proxy.js

In-process

Domain filtering for TCP (git, SSH)

Layer 6: Seccomp BPF (apply-seccomp)

The last binary in the exec chain before Claude Code is apply-seccomp, a minimal C binary from SRT's vendor/seccomp/. It loads two stacked BPF filters that block exactly one syscall family:

This prevents Claude Code from creating Unix domain sockets, which stops:

  • IPC between sessions (connecting to another session's sockets)

  • Bypassing the proxy chain (connecting to arbitrary .sock files)

The socat bridges established before seccomp was applied are the only Unix socket connections available which locks down new socket creation.

After applying the filter, apply-seccomp calls execvp("claude", args), and Claude Code starts with:

  • CapEff: 0x0 — all Linux capabilities zeroed, no DAC bypass even as mapped root

  • Seccomp: 2 — BPF mode with 2 stacked filters

  • PID 1 = bwrap, completely isolated PID namespace

  • Read-only root filesystem, writable only in explicit bind mounts

  • Network only via socat proxy bridges → SRT domain filter → coworkd MITM proxy → NAT

Spawn Flow (Sequence Diagram)

This bring us to the end of the sandboxing section.

MITM Proxy: End-to-End

Every HTTPS request from Claude Code to *.anthropic.com is routed through an in-VM HTTP/HTTPS MITM proxy as specified earlier. This section traces the complete flow of traffic from the electron app to and back from the Guest VM.

Proxy Initialisation

When coworkd starts, main.main (@ 0x2f86e0) calls:

coworkd_internal_proxy_New (@ 0x2cbfe0) allocates a Proxy struct, calls NewCertManager() to generate a self-signed CA, and InstallToTrustStore() to add that CA to the guest's system certificate store. This is done so that Claude Code's TLS validation accepts the proxy's interposed certificates.

coworkd_internal_proxy__ptr_Proxy_Start (@ 0x2cc0d0) then:

  1. net.Listen("unix", "/var/run/mitm-proxy.sock") which creates a Unix domain socket

  2. os.Chmod("/var/run/mitm-proxy.sock", 0o666) makes the socket writable so that all guest processes can connect

  3. Starts a goproxy.NewProxyHttpServer() (from github.com/elazarl/goproxy) to serve on that socket

  4. Registers Start.func1 (@ 0x2cd340) as the CONNECT handler for HTTPS MITM

  5. Registers Start.func2 (@ 0x2cca50) as the request handler (the three-check gate)

OAuth Token Setup (Electron → proxy)

Before Claude Code can make any authenticated requests, the frontend must register an OAuth token with the proxy. The flow crosses all three layers:

The token is stored in a sync.Map at offset +80 in the Proxy struct. Multiple tokens can coexist (one per active session).

Request Handling

When Claude Code sends an HTTPS request through the proxy (via HTTP_PROXY=unix:///var/run/mitm-proxy.sock), Start.func2 (@ 0x2cca50) is invoked for every outbound request:

As mentioned earlier, any requests not to *.anthropic.com does not require an Authorization header with bearer token.

It is important to note that the http(s) traffic is directly sent to anthropic without passing through the host from the VM. The host simply does the NAT translation for this.

Complete Request Flow

Closing Thoughts

Analyzing Claude and its components proved to be a harder task than expected and showed a pretty complex architecture that was merticulously thought out to prevent potential bugs and vulnerabilities.

I also got more familiar with certain Swift behaviours and how they look like when decompiled.

It was also kinda hilarious that Claude was able to reverse itself suprisingly well, and being able to dynamically verify what was running in the Guest VM was a welcome suprise.

I also used Claude Code with IDA-Pro-mcp extensively which sped up analysis due to the fact that the binaries were easily decompilable and readable. However, I did spend a considerable amount of time validating the output and restructuring the findings.

References

IDAPython Scripts

Last updated