Run an agent or any other command under a macOS sandbox that:
- Confines filesystem access
- Lets the command propose changes to safe write targets as patches you review outside the sandbox
- Restricts outgoing network connections to allowed hosts
- Restricts git pushes to allowed repos (currently GitHub only)
- Hardens git internals
This is not a container- or VM-based solution. Chopi runs the command directly on your machine with your real tools and environment, using the OS's native sandboxing and additional network proxies to put guardrails around it.
Chopi uses Agent Safehouse for building most of the underlying macOS Seatbelt policy, Smokescreen for its CONNECT proxy, and Caddy for its GitHub reverse proxy.
Clone the repo and run the installer:
./install.shBefore first use, review the configuration.
To add or remove allowed domains, edit config/proxy-rules.yaml. You can also modify the list
of known denied domains that don't generate alerts -- important for keeping blocked telemetry,
auto-updates etc. from spamming you with notifications. You can add and remove domains while
the proxy is running and they'll take effect immediately.
To set the GitHub repos allowlist, edit config/github-allowlist.
To configure the sandbox policy, edit the first two settings in config/sandbox.sh (other
settings can be reviewed later):
CHOPI_SAFEHOUSE_FLAGS:safehouseflags for selecting what preset features and additional filesystem access to enable in the underlying macOS Seatbelt policy; runsafehouse --helpfor the full list of flags and additional ways to configuresafehousewhich also take effect here.chopialso automatically appends its network protection Seatbelt policy (always last, so it has the final word on network access).CHOPI_EXTRA_ENV: extra environment variables for the sandboxed command, passed as literalKEY=VALUEafter the--. Use it for vars whose values are fine to be visible for inspection on the command line. You can also forward a host value here withFOO="$FOO". For secrets or anything you'd rather keep off the visible command line, usesafehouse's flags--env-pass NAME(forward a host var by name) or--env=FILE(source an env file) inCHOPI_SAFEHOUSE_FLAGS.CHOPI_SAFE_WRITE_TARGETS: paths you'd like to allow writing to from inside the sandbox, but which can't be simply trusted to be written by an agent in auto mode, requiring review outside of the sandbox. See Safe write targets below.
Edits to config/sandbox.sh take effect on any following chopi invocations.
Both configuration files are created once on install and then never overwritten, so any
edits persist on reinstalls. When updating Chopi, you can examine the templates under
config/templates for any upstream changes that you might want to bring in to your local
configuration.
-
Start the outgoing proxy in its own terminal and leave it running:
chopi-proxy
All
chopisessions share this one proxy. It runs in the foreground so you can watch refused connections. Each denial also pops a macOS notification naming the host. (If the banners don't appear, allow notifications for your terminal in System Settings -> Notifications.) Hosts matching the known denylist are the exception: their denials log a single quietdeny(known)line per proxy run, with no notification. -
Run your command under the sandbox from another terminal, at the root of the repo you're working on:
cd ~/path/to/your/repo chopi <executable> [args...]
The sandbox grants read/write to your current directory (the workspace) and runs the command there. The workspace must be the root of a git worktree, where
chopi's git protections apply; running anywhere else (a subdir, or a non-git directory) is refused, so you can't accidentally run "naked" without them. This does not start the proxy; it fails fast if the proxy isn't already up. This is intentional: this way, denials are always clearly visible in the separate terminal dedicated to running the proxy in the foreground.To run a sandboxed command in a separate git worktree instead of the repo root, you can either
cdto an existing worktree and launchchopias usual, or add and setup a new worktree by runningchopiin--worktreemode:chopi --worktree NAME <executable> [args...]
Run from the root of the repo (or a worktree), this creates a new linked worktree at
<repo>/.worktrees/NAME, checks out branchNAME(creating it in case it doesn't already exist), and runs the command with that worktree folder as its workspace. Or, if the worktree at that path already exists, it is reused (on whatever branch or detachedHEADit has checked out), so you can resume previous sessions.In both normal and
--worktreemodes,chopialso isolates the command to a single worktree and applies additional hardening to prevent rogue commands compromising the host system.The workspace is always the root of a git repo (main worktree) or a linked worktree (including
--worktreemode).chopiisolates access to that worktree, which keeps an agent from wandering outside its assigned task and picking up irrelevant information from another worktree. This undoes safehouse's own default grants to all other worktrees. Forclauderuns only, read access is also granted to specific allowlisted Claude context files (currentlyCLAUDE.md, also under.claude/) in folders above the repo. Their symlink targets and@-imports are NOT granted automatically: before launchingclaude,chopifollows them inside the sandbox and refuses to run while any is unreadable, listing the denied paths so you can grant the reads yourself viaCHOPI_SAFEHOUSE_FLAGS.In addition,
chopihardens the repo's git internals:.gitstays readable, but only git's data paths (objects, refs, index, etc.) are writable. Everything else (config,hooks, etc.) stays read-only, so the sandboxed command can't plant code that could later run unsandboxed on some git operation. Submodules (recursive) get the same treatment one level down.Operations that write to the denied paths fail inside the sandbox: repo-local
git config,git remote add,git worktree add,git submodule update(git insists on rewritingcore.worktreein the submodule's read-only config), and hook installers (e.g. husky), so run these outside the sandbox. For--worktreeruns,chopiprovides theCHOPI_WORKTREE_SETUPconfig to set commands to run before the sandboxed command starts. The default initializes and updates submodules and pre-sets the upstream forpush/pull(push -ucan't write the upstream to the config while sandboxed).The writable data paths in the common git dir mean the worktree isolation isn't perfect and agents still have access to, e.g., objects and refs only used in other worktrees; but the worktree isolation's goal is more about minimizing agent errors anyway.
Hardening-wise, being able to write internal data that another worktree references is certainly a risk, but it's required given git's implementation. Hardening should therefore be considered as applying to the repo as a whole, not to a specific worktree; it's not safe to assume other worktrees remained untouched by a sandboxed session.
Rather than launch a run these protections cannot cover,
chopirefuses to start and names the cause: a workspace that is not the root of a git worktree; git location overrides in the environment (GIT_DIR,GIT_WORK_TREE) or in the repo's own files (core.worktree,core.bare, a corrupt.gitentry) that make git resolve the workspace root away from where it physically is; object reads through an external store (a non-empty.git/objects/info/alternates, also in submodule git dirs); and relocated ref storage (extensions.refStoragewith a URI payload).On termination,
chopiaborts any in-progress sequenced rebase or cherry-pick: resuming one outside of the sandbox could execute rogueexeclines injected into the todo list, with no warning ingit status, and without expecting a rebase to be able to execute code unless that's a feature the user is familiar with. The abort runs inside the sandbox so anything possibly triggered by the cleanup itself stays confined. When a sequenced operation is already in progress at launch,chopiasks before proceeding ([y/N]), so its state is never lost without consent; a non-interactive run just refuses.
When running claude, chopi appends context to the
system prompt describing the sandbox, what it restricts, and how to guide the user.
When running codex, chopi passes --sandbox danger-full-access to Codex. macOS Seatbelt
sandboxes cannot be nested, so Codex's own sandbox would otherwise make built-in tools such as
apply_patch fail with sandbox_apply: Operation not permitted. Chopi remains the outer filesystem
and network sandbox; Codex's approval policy remains unchanged.
Sometimes a change should be read before it lands, for example:
- An agent that has learned something general and should write it to guidelines shared with your team, which the sandbox grants read-only.
- A persistent change to an in-workspace CLAUDE.md file.
CHOPI_SAFE_WRITE_TARGETS in config/sandbox.sh names paths of such directories/single files,
absolute or relative to the workspace dir. For example:
CHOPI_SAFE_WRITE_TARGETS=(
"$HOME/dev/team/common_knowledge"
docs # relative to the workspace root
CLAUDE.md # a single file works too
)Each named target is denied write, granted read-only access, and gets a slot in the workspace's
queue under ~/.chopi/patch-queue, where the command drops a git format-patch-shaped patch
instead of writing directly. Agents running under chopi need to be instructed on safe write
targets; chopi currently provides such instructions out-of-the box for Claude Code (see
Claude Code Integration above).
When the command exits, chopi offers to review all patches queued in the workspace. You can also
run chopi-review at any time to review all queued patches across all workspaces. For each patch,
you are presented with the proposed message and diff, and choose to apply, reject or skip it.
Applying a patch commits it in the repo holds the target (or stops to let you resolve a conflict),
or writes it in place leaving a .orig backup if the target isn't contained in a repo. Nothing is
applied without you approving it, and chopi-review runs only out-of-sandbox.
To run the proxy against a different rules file, pass chopi-proxy --rules FILE. Keep
that file outside of any workspace you sandbox with chopi: a sandboxed command that
can write its own allowlist can modify its own limits.
To use a different sandbox config for a single run, pass chopi --config FILE. The file
must define the same CHOPI_SAFEHOUSE_FLAGS / CHOPI_EXTRA_ENV arrays as config/sandbox.sh
(plus CHOPI_WORKTREE_SETUP for --worktree runs; CHOPI_GIT_CONFIG is optional).
chopi enforces this file being outside of the workspace you're sandboxing,
so the confined command can't rewrite its own config (set CHOPI_ALLOW_SELF=1 in your
environment to downgrade the enforcement to a warning).
CHOPI_GIT_CONFIG sets extra git config for the sandboxed command, as key=value pairs
(e.g. protocol.file.allow=always). It's applied through git's GIT_CONFIG_* environment
variables, appended inside the sandbox after the environment is fully composed, so the
pairs merge with any git config you forward from the host via safehouse's
--env-pass/--env flags instead of overwriting it (in case of a conflict,
CHOPI_GIT_CONFIG wins).
chopi lives in its own directory, outside of the repos you sandbox, so a command you
run under it can't read or tamper with the sandbox's own config. chopi enforces
this, refusing to run when it finds its own folder in the workspace (and also in
the more obscure case where the workspace is within chopi's own folder).
macOS Seatbelt (sandbox-exec) can confine the filesystem and pin outgoing network
connections to an IP/port, but it cannot filter by hostname; its network rules only
understand localhost/IP. It obviously can't filter by repo, either. Filtering is
therefore split across cooperating layers:
| Layer | Tool | Enforces |
|---|---|---|
| Sandbox | safehouse (wraps sandbox-exec) |
Filesystem and preset features; the only outgoing network permitted is to the local proxies at 127.0.0.1:4760 (smokescreen), :4761 (the GitHub relay), and the relay's unix socket for the gh REST API. |
| Domain-level proxy | smokescreen (CONNECT proxy) |
Of the traffic that reaches it, only connections to allowed hosts are forwarded; everything else is refused and logged. |
| GitHub repo-level proxy | caddy (reverse proxy) |
github.com git and the REST API are reached only through this relay, which scopes them to a repo allowlist: fetching/reading a public repo is unrestricted, but reading a private repo, any push, and any authenticated API access are limited to allowlisted repos. |
The sandbox makes the proxies the only way to communicate over the network.
The domain-level proxy allows communication only with trusted hosts, so a rogue agent can't connect anywhere else to exfiltrate user data. Be careful not to add innocent domains that can still be abused by an attacker for exfiltration! (e.g. pastebin.com)
The repo-level proxy exists to prevent a misbehaving agent pushing stolen code to any repo on GitHub (possibly using an attacker-provided token). It covers both git and the REST API, and keeps the host's GitHub token out of the sandbox: the relay attaches it only to requests for allowlisted repos. Currently, this protection layer only supports GitHub; other repo-hosting domains can be allowlisted by domain, but be aware of the exfiltration risk.
The sandboxed agent must route its traffic through the proxy, i.e., respect
HTTP_PROXY/HTTPS_PROXY). The sandbox blocks all other outgoing traffic, so an agent that
ignores them gets no network rather than a direct connection. Most popular CLI
harnesses (Claude Code, Codex, Gemini CLI, Copilot CLI, opencode, Pi) do this by default
on current versions; a few (e.g. Cursor CLI) need NODE_USE_ENV_PROXY=1, which chopi also
sets in the sandbox env.
Network path of the sandboxed command:
<cmd>
β Seatbelt allows outgoing connections ONLY to 127.0.0.1:{4760,4761} + the GitHub API relay socket
ββ github.com (git) βββ 4761 βββββΆ caddy relay β(repo allowlisted?)ββΆ GitHub
β ββ public fetch: any repo Β· private fetch or push: allowlisted only
ββ gh REST API ββββ unix socket ββΆ caddy relay β(repo allowlisted?)ββΆ GitHub API
β ββ read: any public repo Β· authed access: allowlisted only
ββ everything else ββββ 4760 βββββΆ smokescreen β(host allowed?)ββΆ api.anthropic.com / ...
βββββ(not allowed)βββββΆ refused + logged
- A network connection was refused -- First verify that the proxy is running.
Refused hosts appear as red
DENYlines in the log (or as a single plaindeny(known)line if they match the known denylist); if the host should be allowed, add it toconfig/proxy-rules.yaml. The proxy hot-reloads the rules, so you don't need to restart it. - A
ghsubcommand fails withnot an allowed API operation,none of the git remotes ... correspond to the GH_HOST environment variable, orunable to expand placeholder in path-- Use REST forms naming the repo explicitly (-R OWNER/REPO, literal slugs ingh api repos/OWNER/REPO/...), or run the command outside the sandbox. - A non-network sandbox denial -- See Agent Safehouse's
- Debugging Sandbox Denials
to analyze, and amend
config/sandbox.sh(or anothersafehousepersistent configuration location) accordingly.
To change the default configuration a fresh install starts from, edit the templates
under config/templates.
The proxy is an in-repo Go wrapper, .internal/proxy/, that embeds smokescreen as a
library and hot-reloads the rules file (the standalone smokescreen binary only reads
its rules at startup). install.sh and make build compile it to
.internal/proxy/chopi-smokescreen.
Both GitHub relays live in one Caddy config: a TCP listener on 127.0.0.1:4761 for git
smart-HTTP, and a loopback unix socket for the gh REST API. chopi points gh at the
socket with GH_HOST=github.localhost (which makes gh speak plaintext http, so still
no CA) plus http_unix_socket in a throwaway config dir. The host-side token is injected
only for allowlisted /repos/{owner}/{repo} requests; other repos are reached anonymously
(public read only). GraphQL (/graphql) and account-level endpoints (gists, keys,
/user) can't be scoped by URL path, so they are denied. Downloads that the API answers
with a redirect to GitHub's signed storage (Actions logs and artifacts, release assets)
are followed through the relay anonymously. Release-asset uploads (gh release create /
gh release upload) go to uploads.github.com, which the relay serves with the same
allowlist scoping.
make build # build the proxy binary (needs go)
make test # build, then run unit and integration tests
make lint # shellcheck the scripts, go vet the proxy
make check # lint, then test
[ALLOW_REPO=owner/repo] make github-relay-test # manual end-to-end test of the GitHub relay (not in `make test`)By default, chopi's refuses protecting a workspace that overlaps chopis folder;
set CHOPI_ALLOW_SELF=1 to override when developing chopi.