Skip to content

macOS Brokered Shell Design

Background and Goals

The macOS Bash sandbox needs to solve two problems simultaneously:

  1. Commands inside the sandbox must be constrained by file system policies; unauthorized reads and writes must go through CodeBuddy's permission checks.
  2. When a command modifies an existing file, ModifyBackup must be invoked before the actual modification occurs, so that sandbox-cli can save the pre-modification version and persist the backup cycle via CommitModifyBackup at 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 -i and mv may overwrite the target file via a temporary file plus rename, which does not necessarily manifest as a direct write to the target file.
  • truncate changes file content through open(O_WRONLY) plus ftruncate; if the command does not enter the brokered runtime, there is no pre-modification backup opportunity.
  • macOS Seatbelt's sandbox_extension token 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 zsh handles the shell semantic layer, covering redirections, glob, conditionals, directory access, and other shell-internal file access.
  • Custom toybox handles the common command layer, covering basic commands resolved via PATH and their open / rename / delete file operations.
  • agent-cli serves as the broker layer, handling permission policies, user approval, macOS sandbox extension token issuance, host operation execution, and ModifyBackup triggering.
  • sandbox-cli serves as the sandbox execution and backup storage layer, running sandboxed processes and persisting ModifyBackup / CommitModifyBackup data.

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 --> SandboxCLI

Core code and artifact locations:

ModuleLocationResponsibility
Sandbox turn configurationsrc/node/agent/interceptors/sandbox-interceptor.tsReads sandbox.fileBackup, starts and syncs sandbox-cli, sends EnableModifyBackup
Sandbox shell executionsrc/node/sandbox-cli/sandbox-shell-service.tsStarts broker IPC, injects broker env, switches to custom zsh on macOS
Runtime envsrc/node/shell/shell-runtime-env.ts / src/node/shell/brokered-shell-env.tsInjects CODEBUDDY_SANDBOX_BROKER_*, toybox, zsh, brokered bin paths
Broker IPCsrc/node/permission/brokered-sandbox/ipc-server.tsReceives zsh/toybox requests, dispatches token requests and host operations
Host servicesrc/node/permission/brokered-sandbox/host-service.tsPermission checks, token issuance, host operation execution, ModifyBackup
Host dispatchersrc/node/permission/brokered-sandbox/host-dispatcher.tsJSON HostFileOperation routing
Finalization commitsrc/node/hooks/finalization-modify-backup-hook.tsSends CommitModifyBackup at FINAL_STOP
Brokered shimvendor/shim/brokered-sandbox-bash-env.sh / vendor/shim/brokered-bin/*Routes common commands to custom toybox
Custom zsh artifactvendor/zsh-macos/bin/zshmacOS brokered shell
Custom toybox artifactvendor/toybox-macos/toyboxmacOS brokered command runtime
Toybox sandbox profilevendor/toybox-macos/toybox.sbToybox Seatbelt sandbox rules (see below)
zsh/toybox sourcetsbx-macos repoReproducible 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:

ConditionBehavior when not met
sandbox.enabled = trueSandboxOrchestrator executes locally directly, without entering the sandbox; brokered shell and backup are both ineffective
darwin platformBrokered shell runtime is not injected (brokered shell is a macOS-specific solution)
WorkBuddy Desktop environmentinjectBrokeredShellEnv() 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 brokeredShellWhen it contains brokeredShell, brokered shell runtime injection is skipped
Command not in sandbox.excludedCommandsCommands matching excludedCommands fall back to local execution directly
Not dangerouslyDisableSandboxWhen the model requests bypass, user approval is required → if approved, local execution without sandbox
Not rawCommand moderawCommand 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 !== falseBackup 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 ModifyBackupCommitModifyBackup at FINAL_STOP.

File Backup Toggle

The file backup toggle is parsed by SandboxAgentRunInterceptor at the start of each real user turn:

  • Supported platforms: win32 and darwin. 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-cli session is ready and sends EnableModifyBackup to sandbox-cli.
  • If sandbox-cli is unavailable or EnableModifyBackup sync fails, enableFileBackup is 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:

  1. waitReady() / ensureAlive() / switchToSession() prepare the sandbox-cli session.
  2. BrokeredSandboxIpcServer.ensureStarted() creates the out-of-sandbox Unix socket.
  3. buildShellRuntimeEnv() injects the broker env.
  4. _resolveSandboxShellConfiguration() uses vendor/zsh-macos/bin/zsh -f -c on darwin when custom zsh and broker IPC are available.
  5. buildShellRuntimePosixCommand() sources brokered-sandbox-bash-env.sh and prepends brokered-bin to PATH.

Key environment variables:

VariablePurpose
CODEBUDDY_SANDBOX_BROKER_IPC_ADDRESSagent-cli broker Unix socket path
CODEBUDDY_SANDBOX_BROKER_SESSION_IDCurrent CodeBuddy session, used to prevent cross-session requests
CODEBUDDY_SANDBOX_BROKER_TOOL_CALL_IDCurrent Bash tool call, used for audit and backup attribution
CODEBUDDY_SANDBOX_BROKER_TRACE_IDSingle execution trace ID, for log correlation
CODEBUDDY_SANDBOX_HOST_FILE_OPERATION_COMMANDJSON host-op command name, currently HostFileOperation
CODEBUDDY_TOYBOX_BINCustom toybox binary path
CODEBUDDY_TOYBOX_SANDBOX_PROFILEToybox sandbox profile path
CODEBUDDY_BROKERED_SHELL_ENVBrokered shell bootstrap script
CODEBUDDY_BROKERED_BIN_DIRBrokered command shim directory
CODEBUDDY_SANDBOX_ZSH_BINCustom zsh binary path
TOYBOX_SANDBOX_SOCKToybox socket for connecting to the broker, typically derived from the broker IPC address by the bootstrap

injectBrokeredShellEnv() also checks the runtime environment:

  • Non-darwin does 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 the brokeredShell toggle. This allows the safe-delete shim (bash/Node.js/Python) to send HostFileOperation delete via broker IPC even when brokeredShell is disabled, uniformly going through the host service's permission check and ModifyBackup.
  • 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>\n

extension-class currently has two values:

  • com.workbuddy.sandbox.read
  • com.workbuddy.sandbox.read-write

When agent-cli receives it:

  1. Validates that the session ID matches the current session.
  2. Based on fileSafety, determines whether the path is grant-token, sandbox, deny, or requires approval.
  3. If the path matches a rule requiring approval, the broker records a prompt block, the current IPC request returns DENY, and the outer SandboxOrchestrator handles unified approval and native rerun.
  4. If it is a write operation and the final result is not deny, ModifyBackup is executed first.
  5. Returns a sandbox extension token, SANDBOX, or DENY.
  6. zsh/toybox calls sandbox_extension_consume() after receiving the token, then performs the real open.

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 real open.
  • SANDBOX: The existing sandbox rules already allow access; no token consumption needed; but write operations still go through ModifyBackup first.
  • DENY: Access denied; zsh/toybox does not perform the real open.

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:

operationAdditional parametersDescription
deletedeleteMode?: 'trash' | 'unlink', recursive?, force?, safeDeleteReportPath?macOS defaults to deleteMode='trash' (move to trash); non-macOS defaults to unlink
mkdirrecursive?, mode?Create directory
renamefrom, toRename/move
copyfrom, to, recursive?Copy file or directory
chmodpath, modeChange permissions
linkfrom, 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
touchpathCreate or update file timestamp

The key host operations currently reported by the macOS toybox runtime are:

  • delete: rm / rmdir / unlink reported via toybox_host_delete(), with the host side connecting to the trash (deleteMode='trash').
  • rename: rename() macro replaced with toybox_rename(), covering mv, 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-related open() to codebuddy_brokered_open().
  • Src/zsh_system.h: Added broker connection, path normalization, token request, sandbox_extension_consume(), and brokered open/stat/access/opendir/chdir helpers.
  • 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.txt
  • cat < input.txt
  • [[ -f a.txt ]]
  • cd some-dir
  • Paths requiring stat / lstat during glob expansion

Among these, > / >> are the most critical scenarios for the backup chain. The actual flow is:

  1. zsh parses the redirection.
  2. Executes codebuddy_brokered_open(path, O_WRONLY | O_CREAT | O_TRUNC, ...).
  3. codebuddy_brokered_authorize_path(path, "write") sends a text token request to the agent-cli broker.
  4. agent-cli sends ModifyBackup for existing regular files before granting access.
  5. zsh performs the real open() after receiving the token or SANDBOX.

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 wc

Each command name shim is a link pointing to codebuddy-toybox-dispatch. Execution flow:

  1. brokered-sandbox-bash-env.sh prepends CODEBUDDY_BROKERED_BIN_DIR to PATH.
  2. sed / mv / truncate in user commands hit the brokered shim first.
  3. codebuddy-toybox-dispatch derives the toybox applet name from its own filename.
  4. The dispatcher executes CODEBUDDY_TOYBOX_BIN <applet> ....
  5. If toybox does not support the applet or option, the dispatcher removes the brokered bin from PATH and 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.h macro-replaces open/openat/creat/fopen/freopen/rename.
  • lib/iolog.c implements toybox_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 JSON HostFileOperation rename, and the host performs the real rename.
  • toybox_host_delete() sends a JSON HostFileOperation 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 for sed -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.txt

Chain:

  1. Custom zsh calls codebuddy_brokered_open() during the redirection phase.
  2. zsh sends a com.workbuddy.sandbox.read-write text token request.
  3. agent-cli checks permissions.
  4. If a.txt already exists and is a regular file, agent-cli sends ModifyBackup before returning the token or SANDBOX.
  5. zsh performs the real open(O_TRUNC), and the file is truncated.

cp

bash
cp source.txt target.txt

Chain:

  1. cp hits brokered-bin/cp.
  2. The dispatcher executes toybox cp.
  3. toybox cp sends a read token request when opening the source file.
  4. toybox cp sends a write token request when opening the target file; overwriting an existing target typically goes through openat(..., O_TRUNC, ...).
  5. agent-cli sends ModifyBackup for 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.txt

Chain:

  1. mv hits brokered-bin/mv.
  2. toybox mv calls rename().
  3. rename() is macro-replaced with toybox_rename().
  4. toybox_rename() sends a JSON HostFileOperation rename, including from and to.
  5. The agent-cli host service checks the delete permission on the source and the write permission on the target separately.
  6. Before executing the host rename, ModifyBackup is performed on existing regular files for both the source and target.
  7. 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.txt

The common implementation of macOS / toybox sed -i is:

  1. Read the original file.
  2. Write to a temporary file.
  3. 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:

  1. sed hits brokered-bin/sed.
  2. toybox sed writes 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.
  3. toybox sed calls rename(temp, file.txt).
  4. toybox_rename() sends a JSON HostFileOperation rename.
  5. agent-cli sends ModifyBackup for the existing file.txt before the host rename.
  6. 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.txt

The toybox truncate implementation does not directly use open(O_TRUNC), but instead:

  1. loopfiles_rw(..., O_WRONLY | O_CLOEXEC | ...) opens the target file.
  2. Calls ftruncate(fd, size) on the fd.

The current coverage relies on the first step:

  1. truncate hits brokered-bin/truncate.
  2. toybox truncate triggers toybox_open() when opening the target file.
  3. Since the flags include O_WRONLY, toybox sends a write token request.
  4. agent-cli sends ModifyBackup for the existing regular file before returning the token or SANDBOX.
  5. 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=1M

toybox 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:

  1. dd hits brokered-bin/dd.
  2. The dispatcher parses all operands, finds of=<path>, and performs a write token pre-check + ModifyBackup on the existing regular file (reusing truncate/tee's __cb_prebackup_path).
  3. The dispatcher executes toybox dd, which triggers another toybox_open() write token request when opening the of= target.
  4. 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:

  1. SandboxAgentRunInterceptor reads sandbox.fileBackup.
  2. Writes SandboxTurnConfig.enableFileBackup and fileBackupMaxSizeMB.
  3. Ensures the sandbox-cli session is ready.
  4. 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 in path.
  • rename: Backs up existing regular files in from and to.
  • copy: Backs up existing regular files in to.
  • chmod / mkdir: Does not trigger backup (chmod only changes metadata, mkdir creates 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:

  1. Checks enableFileBackup.
  2. Checks whether an open sandbox session exists.
  3. Generates a short commit message from the most recent real user message.
  4. Sends CommitModifyBackup { commitMsg } to sandbox-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 retained

Write 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/truncate

JSON 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 retained

Host 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 toybox

Key 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.
  • SANDBOX means 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.

The host service uses three resolution strategies for file paths:

  • resolveExistingPath: Resolves to the real path; rejects if the path does not exist. Used for chmod (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 for delete, mkdir, and rename/copy source and target.
  • resolveExistingOrCreatablePath: If the path exists, resolves to the real path; if not, resolves the parent directory. Used for touch (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>!filenamefilename)
  • 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 = false or SandboxOrchestrator determines local execution.
  • sandbox.excludedCommands match: SandboxShellService checks 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.
  • rawCommand mode: directly spawns the executable, without shell bootstrap, without prefixing brokered-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, or zsh causes 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 ..., bypasses brokered-bin; toybox hooks are not effective.
  • Commands that explicitly rewrite PATH and move brokered-bin to 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(), mmap writes, native rename(), 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.
  • touch is 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.
  • cp currently relies mainly on the write target's open token coverage; this does not mean toybox has proactively sent a JSON copy.
  • Backup commit relies on CommitModifyBackup at FINAL_STOP. If the process exits abnormally, the handling of uncommitted backup cycles on the sandbox-cli side depends on its storage policy.
  • The brokered shell and Write/Edit/MultiEdit are different chains. Tool-level file editing should call ModifyBackup directly 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 truncate shim must exist and point to codebuddy-toybox-dispatch.
  • text write token triggers ModifyBackup on existing regular files.
  • rename host-op triggers ModifyBackup on source / target.
  • Backup failure rejects continuing write or host operation.
  • CommitModifyBackup is sent at FINAL_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 rename marker.

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.txt

Expected:

  • > produces a zsh text write token request.
  • cp produces a toybox text write token request for the target.
  • mv produces a toybox JSON HostFileOperation rename.
  • sed -i produces a toybox JSON HostFileOperation rename when overwriting the original file.
  • truncate produces a toybox text write token request.

Product-level Verification

In the WorkBuddy development build:

  1. Enable file safety auto-backup.
  2. Run sandbox Bash commands that overwrite existing files.
  3. Check the broker shell logs in ~/.codebuddy/ and WorkBuddy dev logs:
    • phase=ipc-token
    • phase=ipc-token-backup
    • phase=ipc-host-op
    • phase=host-modify-backup
    • finalization-modify-backup
  4. 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 (SecurityCenterPanelFileDetail):

  1. User clicks "View Backups" → UI sends an open-modify-backup-dir event with the current session's sessionId.
  2. workbuddy-server's SecurityCenterService.openModifyBackupDir() constructs the backup directory path: <configDir>/workspace/sessions/<sessionId>/modify_backup.
  3. 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 write

This 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.md
  • tsbx-macos/zsh-macos/README.md
  • packages/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:

  1. fileSafety policy check (permission check)
  2. Symlink resolution safety check
  3. ModifyBackup (pre-modification backup)
  4. 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

  1. 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 the brokeredShell disabled toggle. brokeredShell only controls the injection of managed runtime artifacts (toybox, zsh, brokered-bin).

  2. safe-delete-broker-delete.cjs (new): A standalone CJS script that bash shims can call via node safe-delete-broker-delete.cjs <path>. Connects to the broker IPC Unix socket and sends a HostFileOperation delete request. Exit codes: 0=success, 1=denied (fail-closed), 2=unavailable (fallback).

  3. safe-delete-common.sh: Added try_broker_delete() before try_trash(), prioritizing the broker IPC path.

  4. node-safe-delete-shim.cjs: Added tryBrokerDelete() before tryTrash(), synchronously calling broker IPC (spawnSync + helper script mode, consistent with node-brokered-fs-shim.cjs).

  5. safe-delete-env.ts: Injects the CODEBUDDY_SAFE_DELETE_BROKER_DELETE environment variable (pointing to the safe-delete-broker-delete.cjs path).

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