Reversing Claude CoWork
hehehaha
Motivation
Recently, Claude released CoWork that aims to adapt Claude Code for general users. Since there were rumors that it runs a Linux VM using Apple's Virtualization Framework, 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.
What is the different components in Claude, specifically CoWork and how do they work together
How does the communication between VM and host work in CoWork's Case
Which functions and behaviors are exclusive to the CoWork "mode" versus the standard Claude Chat interface.
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:
Frontend (
app.asar)Bridging API (
swift-addon.node)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.

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 webcrack.
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 framework. 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 rw → rwd 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
Before we get into swift-addon, we first have to understand how Swift and Node work together. Let's take this example.
Swift cannot directly speak to Node.js because of swift-mangling, which scrambles function names. With the example above, here is what happens when Node.js opens the shared library HelloWorld.node:
Node searches for a standard C symbol
Node looks for the specific, non-mangled C macro
NAPI_MODULE. It finds this inTrampoline.c
The Trampoline calls Swift
The C code calls
init_hello_world. It is able to find the Swift implementation because@_cdeclforced the Swift compiler to use that exact name
Swift executes
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
Node loads the binary
swift_addon.nodeviaimportinjme()which triggers dlopenThis executes
napiRegisterModuleinswift_addon.nodein the background which returns a tree of objects to Node, specifically thevmbranchIn
L5especifically, it callsstartVM. Node looks up the stringstartVMin the N-API object which eventually lands inClaudeSwift.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.
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
Setting up file path for
rootfs.imgwhich is a img file downloaded from anthropic's websiteSetup config with
createLiunxVMConfigurationand initializes it withVZVirtualMachine(configuration)Setups the communication channel (vsock) with the use of
VZVirtioSocketDeviceIf found, it initializes a RPC client
ClaudeVMDaemonRPCClient(socketDevice: device)Calls
applyStoredCallbacks()which handles different streams such asstderr,stdoutand 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.
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
If
canRequestStopis true → calls[VZVirtualMachine stopWithCompletionHandler:](sends ACPI shutdown signal, awaits completion viaswift_continuation_await)If
canRequestStopis false → pollsvm.stateevery 100ms with a 10-second timeout viastatic Task<>.sleep(nanoseconds:)(100000000).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
Delegate accepts the connection and calls its stored
onConnectionclosure:
That closure immediately forwards the connection to
handleGuestConnectionwhich callsstartReadingMessages
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.
The operation: argument passed to Task.detached is a Swift AsyncFunctionPointer, a two-field struct defined by the Swift ABI in executor.h:
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.
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.
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:
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 here
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:
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.
In the binary, the event name dispatch doesn't use standard string comparison.
Swift uses small string optimization — 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.
ready
0x7964616572
0xE500000000000000
5
stdout
0x74756F647473
0xE600000000000000
6
stderr
0x727265647473
0xE600000000000000
6
exit
0x74697865
0xE400000000000000
4
error
0x726F727265
0xE500000000000000
5
networkStatus
0x536B726F7774656E
0xED00007375746174
13
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:
Increment and validate request counter
Generate unique request id
req-<counter>Craft ClaudeVMMessage
Sets
type = requestSets
id = req-<counter>Sets
method = provided_method_nameSets
params = Dictionary<String, Any>
Serialize to JSON and write to vsock via
writeMessage_vsockSuspend and await a response (continuation stored in
pendingRequests)
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:
Allocates task frame on the heap
This allows it to persist across suspension
Create continuation and suspend
Stores
CheckedContinuation<ClaudeVMMessage, Error>inpendingRequests[requestId]Task suspends until the read loop decodes a matching response and
handleResponseresumes it
For more information about Swift's Continuations, check this out
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:
Load
self.connection(VZVirtioSocketConnection) and error/throw if it’s nil.Encode the
ClaudeVMMessageto JSON bytes viaJSONEncoder.encode(...).Compute the JSON byte length and build the frame:
length_prefix_be = bswap32(json_len)payload = Data(prefix_be) + jsonBytes
Write the payload to the vsock fd in a loop:
Handles partial writes (keeps an offset until
written == total).If
write(...)fails witherrno == 35(EAGAIN/EWOULDBLOCK), it sleeps (usleep(1000)) and retries.Other errno values become
ClaudeVMRPCError.
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
Load parameters from
async_frameid
name
command
args
cwd
env
additionalMounts
allowedDomains
sharedCwdPath
isResume
Create base dictionary and add optional parameters
cwdandenvSends RPC request via
sendRequestwhich serializedparamsto 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.
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:
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
x64filter does the additional step of blockingx32syscalls as well, preventing x32 abi abuse
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
Load the BPF filter from the file specified in argv[1].
Lock privileges with
prctl(PR_SET_NO_NEW_PRIVS, 1), ensuring neither this process nor any descendant can gain new privileges.Install the seccomp filter with
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &sock_fprog).Once applied, the filter is inherited by all child processes, cannot be removed, and persists across
execve.
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.
Like most golang binaries, we can make use of redress 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 machine 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.
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.
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
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)
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 moreSRT +
sandbox-helperisolates 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 source). 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.
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}.
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:
Mount namespace isolation: calls
unshare(CLONE_NEWNS), makes all mounts slave, then unmounts the virtiofs share (/mnt/.virtiofs-root/sharedand/mnt/.virtiofs-root). After this, the session can no longer reach the host filesystem. The only files available are whatcoworkdalready bind-mounted into/sessions/{id}/mnt/.Privilege drop: calls
setgroups([gid]),setgid(gid),setuid(uid). The process now runs as the session's dedicated Linux user instead of root.Seccomp (AF_VSOCK block):
applySeccompFilter()blockssocket(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
SRT 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:
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
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
.sockfiles)
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 rootSeccomp: 2— BPF mode with 2 stacked filtersPID 1 =
bwrap, completely isolated PID namespaceRead-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:
net.Listen("unix", "/var/run/mitm-proxy.sock")which creates a Unix domain socketos.Chmod("/var/run/mitm-proxy.sock", 0o666)makes the socket writable so that all guest processes can connectStarts a
goproxy.NewProxyHttpServer()(fromgithub.com/elazarl/goproxy) to serve on that socketRegisters
Start.func1(@0x2cd340) as the CONNECT handler for HTTPS MITMRegisters
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
