macOS Brokered Shell Design
Background and Goals
The macOS Bash sandbox needs to solve two problems simultaneously:
- Commands inside the sandbox must be constrained by file system policies; unauthorized reads and writes must go through CodeBuddy's permission checks.
- When a command modifies an existing file,
ModifyBackupmust be invoked before the actual modification occurs, so thatsandbox-clican save the pre-modification version and persist the backup cycle viaCommitModifyBackupat the end of the turn.
Relying solely on a plain shell or system commands cannot achieve this. The reasons are:
- Redirections such as
>/>>are executed by the shell itself; the outer layer can only see the entire command, not the exact timing of opening the target file. - Commands like
sed -iandmvmay overwrite the target file via a temporary file plusrename, which does not necessarily manifest as a direct write to the target file. truncatechanges file content throughopen(O_WRONLY)plusftruncate; if the command does not enter the brokered runtime, there is no pre-modification backup opportunity.- macOS Seatbelt's
sandbox_extensiontoken can only be issued by a process outside the sandbox; processes inside the sandbox cannot expand their own permissions.
Therefore, the current solution uses a combination of custom zsh + custom toybox + agent-cli broker:
- Custom
zshhandles the shell semantic layer, covering redirections, glob, conditionals, directory access, and other shell-internal file access. - Custom
toyboxhandles the common command layer, covering basic commands resolved viaPATHand theiropen/rename/deletefile operations. agent-cliserves as the broker layer, handling permission policies, user approval, macOS sandbox extension token issuance, host operation execution, andModifyBackuptriggering.sandbox-cliserves as the sandbox execution and backup storage layer, running sandboxed processes and persistingModifyBackup/CommitModifyBackupdata.
The goal is not to write backup logic for every command, but to capture a unified "pre-modification" timing at key semantic layers.
Overall Architecture
mermaid
flowchart LR
UserTurn["User turn"] --> Interceptor["SandboxAgentRunInterceptor"]
Interceptor --> SandboxCLI["sandbox-cli session"]
Interceptor --> BackupConfig["EnableModifyBackup"]
BashTool["Bash tool"] --> SandboxShell["SandboxShellService"]
SandboxShell --> BrokerIPC["BrokeredSandboxIpcServer<br/>Unix socket"]
SandboxShell --> Zsh["Custom zsh"]
Zsh --> BrokerEnv["brokered-sandbox-bash-env.sh"]
BrokerEnv --> BrokeredBin["brokered-bin PATH"]
BrokeredBin --> Toybox["Custom toybox"]
Zsh -->|"text token request<br/>read/write path"| BrokerIPC
Toybox -->|"text token request<br/>open/fopen"| BrokerIPC
Toybox -->|"JSON HostFileOperation<br/>delete/rename"| BrokerIPC
BrokerIPC --> HostService["BrokeredSandboxHostService"]
HostService --> Policy["sandbox fileSafety / approval"]
HostService --> ModifyBackup["ModifyBackup"]
HostService --> Token["sandbox_extension_issue_file"]
HostService --> HostOp["host delete/rename/copy/..."]
ModifyBackup --> SandboxCLI
FinalStop["FINAL_STOP hook"] --> CommitBackup["CommitModifyBackup"]
CommitBackup --> SandboxCLICore code and artifact locations:
| Module | Location | Responsibility |
|---|---|---|
| Sandbox turn configuration | src/node/agent/interceptors/sandbox-interceptor.ts | Reads sandbox.fileBackup, starts and syncs sandbox-cli, sends EnableModifyBackup |
| Sandbox shell execution | src/node/sandbox-cli/sandbox-shell-service.ts | Starts broker IPC, injects broker env, switches to custom zsh on macOS |
| Runtime env | src/node/shell/shell-runtime-env.ts / src/node/shell/brokered-shell-env.ts | Injects CODEBUDDY_SANDBOX_BROKER_*, toybox, zsh, brokered bin paths |
| Broker IPC | src/node/permission/brokered-sandbox/ipc-server.ts | Receives zsh/toybox requests, dispatches token requests and host operations |
| Host service | src/node/permission/brokered-sandbox/host-service.ts | Permission checks, token issuance, host operation execution, ModifyBackup |
| Host dispatcher | src/node/permission/brokered-sandbox/host-dispatcher.ts | JSON HostFileOperation routing |
| Finalization commit | src/node/hooks/finalization-modify-backup-hook.ts | Sends CommitModifyBackup at FINAL_STOP |
| Brokered shim | vendor/shim/brokered-sandbox-bash-env.sh / vendor/shim/brokered-bin/* | Routes common commands to custom toybox |
| Custom zsh artifact | vendor/zsh-macos/bin/zsh | macOS brokered shell |
| Custom toybox artifact | vendor/toybox-macos/toybox | macOS brokered command runtime |
| Toybox sandbox profile | vendor/toybox-macos/toybox.sb | Toybox Seatbelt sandbox rules (see below) |
| zsh/toybox source | tsbx-macos repo | Reproducible build and low-level hook source |
Enablement Conditions and Runtime Injection
Toggle Effect Matrix
Whether a Bash command enters the brokered shell and whether pre-modification backup is enabled depends on a combination of conditions:
| Condition | Behavior when not met |
|---|---|
sandbox.enabled = true | SandboxOrchestrator executes locally directly, without entering the sandbox; brokered shell and backup are both ineffective |
darwin platform | Brokered shell runtime is not injected (brokered shell is a macOS-specific solution) |
| WorkBuddy Desktop environment | injectBrokeredShellEnv() does not inject managed runtime artifacts |
Brokered shell shim complete (shell-runtime-bash-env.sh, brokered-sandbox-bash-env.sh, brokered-bin/codebuddy-toybox-dispatch, brokered-bin/ls exist) | Basic brokered shell runtime is not injected; zsh, toybox, toybox.sb are optional artifacts — missing zsh means no custom shell switch, missing toybox/toybox.sb means no toybox runtime injection |
WORKBUDDY_MANAGED_RUNTIME_DISABLED does not contain brokeredShell | When it contains brokeredShell, brokered shell runtime injection is skipped |
Command not in sandbox.excludedCommands | Commands matching excludedCommands fall back to local execution directly |
Not dangerouslyDisableSandbox | When the model requests bypass, user approval is required → if approved, local execution without sandbox |
Not rawCommand mode | rawCommand directly spawns the executable, without shell wrapping, without prefixing brokered-bin, without switching to custom zsh; WorkBuddy Desktop's default sandbox env still injects basic broker variables |
sandbox.fileBackup.enabled !== false | Backup is not enabled; brokered shell still works (permission control and token issuance function normally), but ModifyBackup is not triggered |
Typical normal flow: all conditions above are met → command enters custom zsh + toybox brokered shell → write operations go through broker permission checks → existing regular files trigger ModifyBackup → CommitModifyBackup at FINAL_STOP.
File Backup Toggle
The file backup toggle is parsed by SandboxAgentRunInterceptor at the start of each real user turn:
- Supported platforms:
win32anddarwin. Windows-side file backup is implemented through a separate mechanism that does not involve the brokered shell runtime and is outside the scope of this document. - On macOS, it is enabled when
sandbox.fileBackup.enabled !== false; it is enabled by default when not explicitly disabled. - When enabled, it ensures the
sandbox-clisession is ready and sendsEnableModifyBackuptosandbox-cli. - If
sandbox-cliis unavailable orEnableModifyBackupsync fails,enableFileBackupis disabled for the current turn to avoid a half-open state.
The brokered shell runtime is only injected in the macOS sandbox Bash execution path. The key steps of SandboxShellService.execute() are:
waitReady()/ensureAlive()/switchToSession()prepare thesandbox-clisession.BrokeredSandboxIpcServer.ensureStarted()creates the out-of-sandbox Unix socket.buildShellRuntimeEnv()injects the broker env._resolveSandboxShellConfiguration()usesvendor/zsh-macos/bin/zsh -f -condarwinwhen custom zsh and broker IPC are available.buildShellRuntimePosixCommand()sourcesbrokered-sandbox-bash-env.shand prependsbrokered-bintoPATH.
Key environment variables:
| Variable | Purpose |
|---|---|
CODEBUDDY_SANDBOX_BROKER_IPC_ADDRESS | agent-cli broker Unix socket path |
CODEBUDDY_SANDBOX_BROKER_SESSION_ID | Current CodeBuddy session, used to prevent cross-session requests |
CODEBUDDY_SANDBOX_BROKER_TOOL_CALL_ID | Current Bash tool call, used for audit and backup attribution |
CODEBUDDY_SANDBOX_BROKER_TRACE_ID | Single execution trace ID, for log correlation |
CODEBUDDY_SANDBOX_HOST_FILE_OPERATION_COMMAND | JSON host-op command name, currently HostFileOperation |
CODEBUDDY_TOYBOX_BIN | Custom toybox binary path |
CODEBUDDY_TOYBOX_SANDBOX_PROFILE | Toybox sandbox profile path |
CODEBUDDY_BROKERED_SHELL_ENV | Brokered shell bootstrap script |
CODEBUDDY_BROKERED_BIN_DIR | Brokered command shim directory |
CODEBUDDY_SANDBOX_ZSH_BIN | Custom zsh binary path |
TOYBOX_SANDBOX_SOCK | Toybox socket for connecting to the broker, typically derived from the broker IPC address by the bootstrap |
injectBrokeredShellEnv() also checks the runtime environment:
- Non-
darwindoes not inject any brokered environment variables. - Broker IPC environment variables (
CODEBUDDY_BROKERED_FS_HOOK_ENABLED,CODEBUDDY_SANDBOX_BROKER_IPC_ADDRESS, etc.) are only injected in the WorkBuddy Desktop environment; non-WorkBuddy Desktop environments clean up broker IPC and toybox socket addresses left by the caller; within WorkBuddy Desktop, they are not affected by thebrokeredShelltoggle. This allows the safe-delete shim (bash/Node.js/Python) to sendHostFileOperation deletevia broker IPC even when brokeredShell is disabled, uniformly going through the host service's permission check andModifyBackup. - Managed runtime artifacts (toybox, zsh, brokered-bin) are only injected when all of the following conditions are met: not
WORKBUDDY_MANAGED_RUNTIME_DISABLED=brokeredShell, and in a WorkBuddy Desktop environment.
macOS File Modification Detection Chain
There are currently two types of IPC protocols.
text token request
Used for open-type file access by zsh and toybox:
text
<extension-class>\t<absolute-path>\t<session-id>\t<tool-call-id>\nextension-class currently has two values:
com.workbuddy.sandbox.readcom.workbuddy.sandbox.read-write
When agent-cli receives it:
- Validates that the session ID matches the current session.
- Based on
fileSafety, determines whether the path isgrant-token,sandbox,deny, or requires approval. - If the path matches a rule requiring approval, the broker records a prompt block, the current IPC request returns
DENY, and the outerSandboxOrchestratorhandles unified approval and native rerun. - If it is a write operation and the final result is not deny,
ModifyBackupis executed first. - Returns a sandbox extension token,
SANDBOX, orDENY. - zsh/toybox calls
sandbox_extension_consume()after receiving the token, then performs the realopen.
agent-cli's response is a single line of text with three possible forms:
- Sandbox extension token string: zsh/toybox calls
sandbox_extension_consume()then performs the realopen. SANDBOX: The existing sandbox rules already allow access; no token consumption needed; but write operations still go throughModifyBackupfirst.DENY: Access denied; zsh/toybox does not perform the realopen.
JSON HostFileOperation
Used for file operations that should not be completed inside the sandbox, or that require host semantics:
json
{
"id": "req-1",
"command": "HostFileOperation",
"operation": "rename",
"from": "/absolute/source",
"to": "/absolute/target",
"sessionId": "...",
"toolCallId": "...",
"brokerTraceId": "..."
}id is used for request-response matching; brokerTraceId is used for log correlation. Both are optional but recommended for audit purposes.
agent-cli's JSON response structure:
json
{
"id": "req-1",
"ok": true,
"operation": "rename",
"path": "/absolute/source",
"normalisedPath": "/resolved/source",
"decision": "host-op",
"to": "/absolute/target",
"normalisedTo": "/resolved/target"
}On failure, ok is false with an error field. decision can be grant-token, host-op, sandbox, or deny. (prompt is an intermediate state during the approval process and is not returned as a final IPC response to toybox.)
The host operations currently supported by the agent-cli TS side include:
| operation | Additional parameters | Description |
|---|---|---|
delete | deleteMode?: 'trash' | 'unlink', recursive?, force?, safeDeleteReportPath? | macOS defaults to deleteMode='trash' (move to trash); non-macOS defaults to unlink |
mkdir | recursive?, mode? | Create directory |
rename | from, to | Rename/move |
copy | from, to, recursive? | Copy file or directory |
chmod | path, mode | Change permissions |
link | from, to, symbolic? | Hard link (default) or symbolic link. Hard link checks read permission on source + write permission on target; symbolic link only checks write permission on target |
touch | path | Create or update file timestamp |
The key host operations currently reported by the macOS toybox runtime are:
delete:rm/rmdir/unlinkreported viatoybox_host_delete(), with the host side connecting to the trash (deleteMode='trash').rename:rename()macro replaced withtoybox_rename(), coveringmv,sed -i, and other temporary file overwrite scenarios.
copy, touch, and other TS branches exist for broker capability completeness and future expansion; the current cp coverage mainly comes from the text token request for opening the write target file, not from toybox proactively sending a JSON copy.
Custom zsh Responsibilities
The core value of custom zsh is capturing the shell's own file access timing, especially for redirections.
The source code is in tsbx-macos/zsh-macos; the current patch is based on zsh 5.9.1, with the main changes:
Src/exec.c: Changed redirection-relatedopen()tocodebuddy_brokered_open().Src/zsh_system.h: Added broker connection, path normalization, token request,sandbox_extension_consume(), and brokeredopen/stat/access/opendir/chdirhelpers.Src/init.c/Src/glob.c/Src/cond.c/Src/compat.c: Changed shell initialization, glob, conditionals, directory switching, and other file access to brokered versions.
Typical scenarios handled by zsh:
echo hi > a.txtcat < input.txt[[ -f a.txt ]]cd some-dir- Paths requiring
stat/lstatduring glob expansion
Among these, > / >> are the most critical scenarios for the backup chain. The actual flow is:
- zsh parses the redirection.
- Executes
codebuddy_brokered_open(path, O_WRONLY | O_CREAT | O_TRUNC, ...). codebuddy_brokered_authorize_path(path, "write")sends a text token request to the agent-cli broker.- agent-cli sends
ModifyBackupfor existing regular files before granting access. - zsh performs the real
open()after receiving the token orSANDBOX.
This is why the > timing must be exposed within custom zsh, rather than relying solely on agent-cli parsing the command string.
Custom toybox Responsibilities
The core value of custom toybox is consolidating common basic commands into a single controllable runtime.
The brokered-bin directory in the agent-cli vendor contains the dispatcher entry codebuddy-toybox-dispatch and a set of command name shims:
text
codebuddy-toybox-dispatch # dispatcher entry
cat chmod cp dd find grep head ln ls mkdir mv readlink realpath rm rmdir sed tail tee touch truncate unlink wcEach command name shim is a link pointing to codebuddy-toybox-dispatch. Execution flow:
brokered-sandbox-bash-env.shprependsCODEBUDDY_BROKERED_BIN_DIRtoPATH.sed/mv/truncatein user commands hit the brokered shim first.codebuddy-toybox-dispatchderives the toybox applet name from its own filename.- The dispatcher executes
CODEBUDDY_TOYBOX_BIN <applet> .... - If toybox does not support the applet or option, the dispatcher removes the brokered bin from
PATHand falls back to the system command of the same name.
The key hooks in the toybox source layer are in tsbx-macos/toybox-0.8.13:
toys.hmacro-replacesopen/openat/creat/fopen/freopen/rename.lib/iolog.cimplementstoybox_open(),toybox_openat(),toybox_creat(),toybox_fopen(),toybox_freopen().- The open-type wrappers send a text token request before the real open.
toybox_rename()sends a JSONHostFileOperation rename, and the host performs the real rename.toybox_host_delete()sends a JSONHostFileOperation delete, and the host performs a safe delete.
The toybox open hook uses an eager mode: instead of waiting for the macOS sandbox to reject and then requesting a token, it proactively requests a token from the broker before every open. This allows agent-cli to complete the backup before the actual write.
toybox sandbox profile (toybox.sb)
vendor/toybox-macos/toybox.sb is the Seatbelt sandbox rule for the toybox runtime, loaded by sandbox-cli via sandbox-exec when starting the toybox process. Core policies:
(deny default): Denies all file system access by default.(allow file-read* (subpath "/")): Allows global reads (read token requests still go through the broker, but the Seatbelt layer does not block them).(allow file-write* (subpath "/dev") (subpath "/private/var/folders")): Allows writes to/dev(stdout/stderr) and temporary directories (intermediate files forsed -i, etc.).(allow file-read* (extension "com.workbuddy.sandbox.read")): Allows reading the corresponding path when holding a read token.(allow file-read* file-write* (extension "com.workbuddy.sandbox.read-write")): Allows reading and writing the corresponding path when holding a read-write token.
This is the underlying enforcement mechanism of the brokered shell security model: toybox must first obtain a sandbox extension token from the broker before Seatbelt will allow write operations. The eager token request ensures that the broker can complete permission checks and pre-modification backups before Seatbelt grants access.
Key Command Coverage Methods
>
> is a zsh redirection, not an external command.
bash
echo hi > a.txtChain:
- Custom zsh calls
codebuddy_brokered_open()during the redirection phase. - zsh sends a
com.workbuddy.sandbox.read-writetext token request. - agent-cli checks permissions.
- If
a.txtalready exists and is a regular file, agent-cli sendsModifyBackupbefore returning the token orSANDBOX. - zsh performs the real
open(O_TRUNC), and the file is truncated.
cp
bash
cp source.txt target.txtChain:
cphitsbrokered-bin/cp.- The dispatcher executes toybox
cp. - toybox
cpsends a read token request when opening the source file. - toybox
cpsends a write token request when opening the target file; overwriting an existing target typically goes throughopenat(..., O_TRUNC, ...). - agent-cli sends
ModifyBackupfor the existing target file before granting the write token.
The JSON copy host-op is not currently used to cover cp. The TS side has a copyPath() capability, but the macOS toybox's cp coverage mainly comes from opening the write target.
mv
bash
mv source.txt target.txtChain:
mvhitsbrokered-bin/mv.- toybox
mvcallsrename(). rename()is macro-replaced withtoybox_rename().toybox_rename()sends a JSONHostFileOperation rename, includingfromandto.- The agent-cli host service checks the delete permission on the source and the write permission on the target separately.
- Before executing the host rename,
ModifyBackupis performed on existing regular files for both the source and target. - The host side performs the real
rename(from, to), and toybox considers the command successful after receiving ok.
If the host rename returns a cross-device error, toybox maps the error to EXDEV, preserving mv's own copy + delete fallback semantics. The copy write target and delete source file in the fallback still go through brokered open or host delete respectively.
sed -i
bash
sed -i '' 's/a/b/g' file.txtThe common implementation of macOS / toybox sed -i is:
- Read the original file.
- Write to a temporary file.
- Use
rename(temp, file.txt)to overwrite the original file.
If only the write target file is monitored, the actual timing of overwriting the original file is missed, because the write occurs on the temporary file. The current solution handles this through the toybox rename hook:
sedhitsbrokered-bin/sed.- toybox
sedwrites to a temporary file; the temporary file's open goes through the write token; since the temporary file typically does not exist, no backup is produced. - toybox
sedcallsrename(temp, file.txt). toybox_rename()sends a JSONHostFileOperation rename.- agent-cli sends
ModifyBackupfor the existingfile.txtbefore the host rename. - The host side performs the rename overwrite.
This is why mv and sed -i are classified as the same type of problem: both are essentially about the backup timing "before the rename overwrites the target file."
truncate
bash
truncate -s 0 file.txtThe toybox truncate implementation does not directly use open(O_TRUNC), but instead:
loopfiles_rw(..., O_WRONLY | O_CLOEXEC | ...)opens the target file.- Calls
ftruncate(fd, size)on the fd.
The current coverage relies on the first step:
truncatehitsbrokered-bin/truncate.- toybox
truncatetriggerstoybox_open()when opening the target file. - Since the flags include
O_WRONLY, toybox sends a write token request. - agent-cli sends
ModifyBackupfor the existing regular file before returning the token orSANDBOX. - toybox then calls
ftruncate()to modify the file length.
There is currently no separate hook for ftruncate(). Therefore, brokered toybox's truncate is covered; external binaries that bypass brokered toybox and directly call ftruncate() are outside the scope of this solution.
dd
bash
dd if=source.bin of=file.bin bs=1Mtoybox dd writes to the output target in a manner similar to truncate/cp (opening the target path via open), but of= is a key=value operand, not a positional parameter. codebuddy-toybox-dispatch performs an additional shell-layer pre-backup before calling toybox:
ddhitsbrokered-bin/dd.- The dispatcher parses all operands, finds
of=<path>, and performs a write token pre-check +ModifyBackupon the existing regular file (reusingtruncate/tee's__cb_prebackup_path). - The dispatcher executes toybox
dd, which triggers anothertoybox_open()write token request when opening theof=target. - The content written by both backups is the same (no actual write occurs between the pre-backup and toybox's own open hook), so correctness is not affected; it just adds one extra IPC round-trip.
When dd does not specify of=, output goes to stdout, is not persisted to disk, and no pre-backup is triggered.
ModifyBackup / CommitModifyBackup Call Chain
Enabling Backup
At the start of each real user turn:
SandboxAgentRunInterceptorreadssandbox.fileBackup.- Writes
SandboxTurnConfig.enableFileBackupandfileBackupMaxSizeMB. - Ensures the
sandbox-clisession is ready. - Sends
EnableModifyBackup:
json
{
"enabled": true,
"fileBackupMaxSizeMB": 10
}fileBackupMaxSizeMB is normalized against minimum, maximum, and default values; the actual storage limit is enforced by sandbox-cli.
Pre-modification Backup
There are two entry points that send ModifyBackup.
text token request write path:
- Entry:
BrokeredSandboxIpcServer.handleToyboxTextLine(). - Condition: operation is
write, permission result is not deny,enableFileBackup=true. - Behavior: If the target already exists and is a regular file, sends
ModifyBackup { targetPath }. - Failure strategy: If backup fails, returns
DENY; the real write does not proceed.
JSON host operation:
- Entry:
BrokeredSandboxHostService. - Condition: The host operation modifies existing content,
enableFileBackup=true. delete/touch: Backs up existing regular files inpath.rename: Backs up existing regular files infromandto.copy: Backs up existing regular files into.chmod/mkdir: Does not trigger backup (chmodonly changes metadata,mkdircreates a new directory).link: Does not trigger backup.- Failure strategy: If backup fails, the host action is not executed and the operation returns failure.
Both entry points only back up "existing regular files." Newly created files, directories, special files, and pure metadata restorations are not within the current content backup semantics.
Finalization Commit
When the turn ends and enters FINAL_STOP, FinalizationModifyBackupHook executes:
- Checks
enableFileBackup. - Checks whether an open sandbox session exists.
- Generates a short commit message from the most recent real user message.
- Sends
CommitModifyBackup { commitMsg }tosandbox-cli.
This hook does not filter by final_stop_reason. As long as file backup was enabled for the current turn and a sandbox session exists, it attempts to commit the backup cycle. Commit failure only logs a message and does not block agent finalization.
Permission Approval and Backup Ordering
The ordering is the core of the security semantics.
text token request write path:
text
zsh/toybox requests write token
-> agent-cli validates session
-> fileSafety policy check
-> if approval is needed, records brokered prompt block and returns DENY to zsh/toybox
-> SandboxOrchestrator merges prompt blocks, requests unified user approval
-> after user approves, native reruns the entire command; if user denies, the sandbox failure result is retainedWrite path without approval needed or already directly allowed by rules:
text
zsh/toybox requests write token
-> agent-cli validates session
-> fileSafety policy check passes
-> ModifyBackup for existing regular files
-> returns token or SANDBOX
-> zsh/toybox performs real open/write/truncateJSON host operation:
text
toybox requests HostFileOperation
-> agent-cli validates session
-> source/target permission check
-> resolved path safety check
-> if approval is needed, records brokered prompt block and returns failure to toybox
-> SandboxOrchestrator merges prompt blocks, requests unified user approval
-> after user approves, native reruns the entire command; if user denies, the sandbox failure result is retainedHost operation without approval needed or already directly allowed by rules:
text
toybox requests HostFileOperation
-> agent-cli validates session
-> source/target permission check
-> resolved path safety check passes
-> ModifyBackup for affected existing regular files
-> host side executes delete/rename/copy/...
-> returns result to toyboxKey principles:
- The current main flow does not wait for user approval within the IPC; the brokered shell only records prompt blocks, and after outer approval, native rerun is used.
- When permissions are not granted or approval is needed but not yet obtained from the outer layer, no backup is performed and no real modification is executed.
- When permissions are granted but backup fails, no real modification is executed.
- Backup must occur before token return or host action execution.
SANDBOXmeans the sandbox rules already allow access, but it does not skip the pre-write backup.
Runtime Behavior Constraints
Concurrency Model
The current agent-cli BrokeredSandboxIpcServer is a single-instance Unix socket server; all Bash tool calls share the same socket endpoint. Concurrent requests are processed serially through the Node.js event loop, without explicit locking.
Isolation granularity is provided by toolCallId: each Bash tool call injects an independent CODEBUDDY_SANDBOX_BROKER_TOOL_CALL_ID, used for audit and backup attribution. sessionId validation prevents cross-session request leakage.
TOCTOU risk assessment: For the text token request path, there is a time window between backup completion and token return, but the sandboxed process cannot perform real writes through Seatbelt before receiving the token, so there is no traditional TOCTOU "check-then-use tampering" issue. For the host-op path (rename/delete, etc.), backup + real operation are both executed sequentially within the same async function on the broker side (check-then-act); the Node.js event loop guarantees that no other requests from the same socket are interleaved, but it does not prevent out-of-sandbox processes from modifying files within the window — this is a known trust boundary.
Timeout and Blocking
The zsh side has a socket-level timeout setting; the toybox side currently has no explicit client timeout. The current agent-cli main flow does not wait for user approval within the broker IPC: when approval is needed, the broker records a prompt block and returns rejection/failure, letting the sandbox attempt end, and then SandboxOrchestrator initiates unified approval and native rerun.
If the broker process crashes or the socket is closed, the zsh/toybox side read() returns EOF, and the command terminates with an I/O error.
Performance Impact
The toybox open hook uses an eager mode; every open call incurs an IPC round-trip. For commands with high-frequency opens (such as find, grep traversing large directory trees), this introduces per-file socket communication overhead.
There is currently no token caching or batch request mechanism. Read-only commands (such as grep) also go through the broker on every open, but the broker-side read path does not trigger backup or require approval, so the processing overhead is lower.
In practice, with typical sandbox working directory sizes (hundreds to thousands of files), the latency impact is acceptable. If large codebase traversal scenarios need to be covered in the future, a read token cache can be introduced.
Security Trust Boundary
The broker IPC Unix socket uses file permissions for isolation:
- Socket directory:
chmod 0o700(only the current user can enter). - Socket file:
chmod 0o600(only the current user can read/write).
This means that processes of other users on the same machine cannot connect to the broker socket. However, sandboxed processes run under the same user as agent-cli, so any process inside the sandbox can connect as long as it knows the socket path.
sessionId is injected into the sandbox via the environment variable CODEBUDDY_SANDBOX_BROKER_SESSION_ID, and the broker validates that the sessionId in the request matches the current session. A malicious process inside the sandbox can read the environment variable to obtain a valid session ID, but this is acceptable under the current threat model: processes inside the sandbox are already constrained by Seatbelt, and even if they can send requests, they still need to pass fileSafety policy and user approval to obtain write tokens. The broker is not the sole line of defense; it works together with the Seatbelt sandbox, file policies, and user approval to form defense in depth.
Symlink Path Resolution
The host service uses three resolution strategies for file paths:
resolveExistingPath: Resolves to the real path; rejects if the path does not exist. Used forchmod(target must exist) and hard link source.resolvePathThroughParent: Resolves the parent directory to the real path and appends the filename, allowing the target to not yet exist. Used fordelete,mkdir, andrename/copysource and target.resolveExistingOrCreatablePath: If the path exists, resolves to the real path; if not, resolves the parent directory. Used fortouch(may create a new file) and token requests.
All strategies execute refuseIfResolvedPathNotAllowed after resolution: if the real path after symlink resolution falls outside the range allowed by the security policy, even if the symlink's own path is allowed, the operation is rejected. This prevents bypassing sandbox file policy boundaries via symlinks.
Path Normalization and Rule Matching
When troubleshooting "why a certain rule was not matched," you need to understand the three layers of path normalization:
zsh side: Custom zsh's codebuddy_brokered_authorize_path() normalizes paths before sending token requests, applying /private/var → /var, /private/tmp → /tmp alias normalization (macOS-specific firmlink mapping), ensuring that request paths are consistent with user perception.
toybox side: toybox_open() and other wrappers use realpath-style resolution before sending text token requests, resolving symlinks and eliminating ./... However, toybox_rename() uses lexical absolute paths (without resolving symlinks), to preserve the semantics of the rename operation itself (rename may act on the symlink itself rather than its target).
agent-cli side: normalisePathForRuleMatch() performs the following before fileSafety rule matching:
- Backslash → forward slash
- Removes the temporary file prefix produced by macOS BSD
sed -i(.!<PID>!filename→filename) - Home directory prefix →
~ normaliseSandboxWritePath()additionally does/private/var→/var,/private/tmp→/tmp
When debugging, you can search for phase=ipc-token or phase=host-policy in the logs; the logs will output both the original path and the normalized path (path= vs normalisedPath=), allowing you to compare against the rule configuration to determine the matching logic.
Known Boundaries and Non-covered Scope
The current solution covers "Bash commands that enter the macOS brokered shell runtime."
Execution Paths That Do Not Enter the Brokered Shell
The following situations cause commands to bypass the brokered shell runtime; the brokered layer's permission control and backup are both ineffective:
sandbox.enabled = falseorSandboxOrchestratordetermines local execution.sandbox.excludedCommandsmatch:SandboxShellServicechecks the command root and falls back to local.dangerouslyDisableSandbox = true: model requests bypass → user approval passes → local execution.- User approves native rerun after sandbox execution failure (session-scoped approval cache): subsequent identical commands execute locally directly.
rawCommandmode: directly spawns the executable, without shell bootstrap, without prefixingbrokered-bin, without switching to custom zsh. Basic sandbox/broker env is still injected, but the brokered shell's hook chain is not effective.- Incomplete vendor bundle: missing
brokered-sandbox-bash-env.sh,brokered-bin/codebuddy-toybox-dispatch,toybox,toybox.sb, orzshcauses the corresponding capability to degrade — missing zsh means no custom shell switch (redirection hook ineffective); missing toybox or brokered-bin means commands do not go through toybox hooks (open/rename hook ineffective).
Scopes That Enter the Brokered Shell But Are Not Covered
- Calling system commands by absolute path, e.g.,
/usr/bin/sed -i ..., bypassesbrokered-bin; toybox hooks are not effective. - Commands that explicitly rewrite
PATHand movebrokered-binto the back may bypass the toybox shim. - When the dispatcher encounters an applet or option not supported by toybox, it falls back to the system command; after fallback, toybox open/rename hooks are no longer available.
- Internal writes by external binaries,
ftruncate(),mmapwrites, nativerename(), etc. are not visible to toybox hooks. - zsh can cover shell's own redirections, but cannot automatically hook libc calls of arbitrary external binaries.
Backup Semantic Boundaries
- The current content backup only handles existing regular files; directories, sockets, device files, symlink metadata, permissions, mtime, etc. are not complete restoration targets.
touchis primarily a metadata modification. The TS host service can back up existing regular file content on the host-op path, but there is currently no metadata-level restoration semantics.cpcurrently relies mainly on the write target's open token coverage; this does not mean toybox has proactively sent a JSONcopy.- Backup commit relies on
CommitModifyBackupatFINAL_STOP. If the process exits abnormally, the handling of uncommitted backup cycles on thesandbox-cliside depends on its storage policy. - The brokered shell and Write/Edit/MultiEdit are different chains. Tool-level file editing should call
ModifyBackupdirectly before the tool writes, and should not depend on the shell runtime (the macOS side's tool-level backup is not yet connected; see "Future Extension Suggestions" below).
Verification Methods
agent-cli Unit Tests
Key tests:
bash
TS_NODE_PROJECT=tsconfig.tsnode.json npx mocha --require ts-node/register \
--config ../../dev-packages/component/configs/mocharc.yml \
--parallel=false \
"./src/node/shell/brokered-bin-dispatch.spec.ts" \
"./src/node/permission/brokered-sandbox/ipc-server.spec.ts" \
"./src/node/permission/brokered-sandbox/host-service.spec.ts" \
"./src/node/hooks/finalization-modify-backup-hook.spec.ts"Coverage points:
- The
truncateshim must exist and point tocodebuddy-toybox-dispatch. - text write token triggers
ModifyBackupon existing regular files. renamehost-op triggersModifyBackupon source / target.- Backup failure rejects continuing write or host operation.
CommitModifyBackupis sent atFINAL_STOP.
toybox Build and Static Check
In the tsbx-macos repo:
bash
./build-toybox-macos.sh
lipo toybox-0.8.13/toybox -verify_arch arm64 x86_64
strings toybox-0.8.13/toybox | grep '"operation":"rename"'Coverage points:
- The artifact is a macOS universal binary.
- The binary contains the
HostFileOperation renamemarker.
Fake Broker Behavior Verification
Use a local fake broker to receive socket requests, executing each of the following:
bash
echo changed > file.txt
cp source.txt file.txt
mv source.txt file.txt
sed -i '' 's/a/b/g' file.txt
truncate -s 0 file.txtExpected:
>produces a zsh text write token request.cpproduces a toybox text write token request for the target.mvproduces a toybox JSONHostFileOperation rename.sed -iproduces a toybox JSONHostFileOperation renamewhen overwriting the original file.truncateproduces a toybox text write token request.
Product-level Verification
In the WorkBuddy development build:
- Enable file safety auto-backup.
- Run sandbox Bash commands that overwrite existing files.
- Check the broker shell logs in
~/.codebuddy/and WorkBuddy dev logs:phase=ipc-tokenphase=ipc-token-backupphase=ipc-host-opphase=host-modify-backupfinalization-modify-backup
- Confirm in the backup viewing entry that backup records exist, can locate backups, and can be manually restored (see next section).
Backup Viewing and Restoration Entry
The current backup viewing entry is in the WorkBuddy settings page's file safety panel (SecurityCenterPanel → FileDetail):
- User clicks "View Backups" → UI sends an
open-modify-backup-direvent with the current session'ssessionId. workbuddy-server'sSecurityCenterService.openModifyBackupDir()constructs the backup directory path:<configDir>/workspace/sessions/<sessionId>/modify_backup.- Calls
openPath()to open the directory in Finder.
This is currently an "open backup directory" operation, not a one-click restore UI. The user needs to manually find the backup file in the directory and restore it. If the directory does not exist (no backups were produced in the current turn), the UI shows "No backup records for the current session."
Future Extension Suggestions
Write/Edit/MultiEdit Tool-level Backup
The brokered shell only covers Bash commands. Tools such as Write/Edit/MultiEdit/NotebookEdit do not go through zsh/toybox; they should reuse ModifyBackup directly before the tool writes:
text
Tool resolves target path
-> permission check
-> if target exists and is a regular file, sends ModifyBackup
-> tool performs real writeThis chain should not depend on the broker shell, nor should it simulate shell commands. What it shares with the brokered shell is the sandbox-cli backup storage protocol, not the zsh/toybox runtime.
Current status: SandboxWriteRuleGuard has implemented tool-level guards for Write/Edit/MultiEdit/NotebookEdit (GUARDED_WRITE_TOOLS), which can perform permission checks and rule matching on write targets in sandbox mode. ModifyBackup is gated by isModifyBackupSupportedPlatform(), currently enabled on both win32 and darwin; auto-grant is gated by a separate supportsAutoGrantRuleEffect(), avoiding coupling with the backup platform capability.
More Complete syscall Coverage
If future coverage of arbitrary external binaries is needed, toybox shims are insufficient; a lower-level approach is required, such as:
- Introducing controlled wrappers for external binaries.
- Using auditable dynamic library interposition, but evaluating SIP, signing, stability, and security boundaries.
- Enhancing file event observation at the sandbox-cli layer, but macOS has limited controllability for "pre-modification" backup timing.
The current solution's choice of zsh + toybox is a trade-off between controllability, change volume, and maintainability.
Metadata Backup
The current ModifyBackup semantics are primarily oriented toward file content restoration. To support metadata restoration for touch / chmod / chown / xattr, etc., the backup record structure needs to be extended:
- mode
- owner / group
- atime / mtime
- xattr
- symlink itself and target
This should be designed as an independent capability to avoid complicating the content backup semantics.
tsbx Documentation Sync
The tsbx-macos repo should continue to document the low-level binary builds, patches, and demos. This agent-cli document records the product-level integration chain. If zsh or toybox hooks are modified in the future, the following need to be updated simultaneously:
tsbx-macos/README.mdtsbx-macos/zsh-macos/README.mdpackages/agent-cli/docs/brokered-shell-macos.md
safe-delete → broker IPC Unified Deletion Chain
Background
The safe-delete shim (bash rm/unlink/rmdir, Node.js fs.unlinkSync, etc., Python os.remove, etc.) originally moved files to the trash directly via the genie-trash binary or platform trash API. This chain lacks the following capabilities:
- fileSafety policy check (permission check)
- Symlink resolution safety check
ModifyBackup(pre-modification backup)- Centralized audit
Meanwhile, the brokered shell's toybox rm already implements the above capabilities via HostFileOperation delete → broker IPC → host service → trash.
Design
The safe-delete deletion entry is unified and routed to broker IPC, so that regardless of whether the brokeredShell toggle is on, deletion operations go through the host service's permission check and backup.
safe-delete shim (bash/Node/Python)
└─ try_broker_delete()
├─ broker success (ok=true) → done, file moved to trash
├─ broker deny (deny) → fail-closed, no fallback
└─ broker unavailable (exit 2) → fallback to local trash_one() / trashItem()Key Changes
brokered-shell-env.ts: Broker IPC environment variables (CODEBUDDY_BROKERED_FS_HOOK_ENABLED,CODEBUDDY_SANDBOX_BROKER_IPC_ADDRESS, etc.) are only injected in the WorkBuddy Desktop environment, but are not controlled by thebrokeredShelldisabled toggle.brokeredShellonly controls the injection of managed runtime artifacts (toybox, zsh, brokered-bin).safe-delete-broker-delete.cjs(new): A standalone CJS script that bash shims can call vianode safe-delete-broker-delete.cjs <path>. Connects to the broker IPC Unix socket and sends aHostFileOperation deleterequest. Exit codes: 0=success, 1=denied (fail-closed), 2=unavailable (fallback).safe-delete-common.sh: Addedtry_broker_delete()beforetry_trash(), prioritizing the broker IPC path.node-safe-delete-shim.cjs: AddedtryBrokerDelete()beforetryTrash(), synchronously calling broker IPC (spawnSync+ helper script mode, consistent withnode-brokered-fs-shim.cjs).safe-delete-env.ts: Injects theCODEBUDDY_SAFE_DELETE_BROKER_DELETEenvironment variable (pointing to thesafe-delete-broker-delete.cjspath).
Retention of Old Code
The local trash logic (trash_darwin() / trashOnMac() / _platform_trash() / genie-trash binary) is not deleted, serving as a fallback when broker IPC is unavailable:
- Non-macOS platforms
- Sandbox not enabled (IPC server does not exist)
- agent-cli standalone CLI mode
- IPC server not started or socket connection failed