macOS only. Kernel-enforced sandbox for AI coding CLIs.
ora wraps claude, gemini, codex, opencode, or ollama in a per-invocation macOS Seatbelt (sandbox-exec) sandbox. Every run gets a fresh profile, a fresh loopback proxy, and zero persistent state.
- Filesystem: writes are locked to your project directory (plus auth dirs for token refresh). Reads of
~/.ssh,~/.aws,.envfiles, shell rc files, and private keys are denied at the kernel level. - Network: all HTTPS egress routes through an in-process CONNECT proxy with a domain allowlist. Raw sockets to arbitrary hosts are blocked by Seatbelt.
- No daemons. No background processes. Sub-millisecond cold start.
AI coding agents move fast and act broadly. They will read whatever they can, run whatever they want, and reach out to whatever host they decide is useful. That convenience evaporates the moment an agent reads ~/.aws/credentials, writes to .git/hooks/post-commit, or POSTs your project tree to an unfamiliar API.
ora makes those failure modes structurally impossible: the kernel — not the AI — decides what the process can touch.
Situations where this earns its keep:
- Letting a new or third-party AI CLI loose on real code. Try
codex,gemini,opencode, or your own agent without auditing every dependency. - Local secrets on a shared dev machine.
~/.ssh,~/.aws,.env, browser profiles, and password-manager files are unreadable to the wrapped process — even if the agent shells out. - CI runners with production credentials. Tokens in the runner's env cannot be exfiltrated to an unlisted host.
- Client code under NDA. Confine the agent to the project tree; no incidental reads of unrelated repos on the same machine.
- Local-only models (Ollama). All HTTPS egress is blocked, so "this never leaves the laptop" becomes enforceable rather than aspirational.
- Audit trail of what an agent tried to do.
--jsonemits structured events for every blocked filesystem or network attempt — pipeable into a SIEM or a post-mortem.
Requires Go 1.23+:
go install github.com/rithyhuot/ora/cmd/ora@latestMake sure $(go env GOPATH)/bin is on your PATH.
LATEST=$(curl -s https://api.github.com/repos/rithyhuot/ora/releases/latest \
| grep tag_name | cut -d\" -f4)
# Apple Silicon (M1/M2/M3+)
curl -L "https://github.com/rithyhuot/ora/releases/download/${LATEST}/ora_${LATEST}_darwin_arm64.tar.gz" \
| sudo tar xz -C /usr/local/bin ora
# Intel
curl -L "https://github.com/rithyhuot/ora/releases/download/${LATEST}/ora_${LATEST}_darwin_amd64.tar.gz" \
| sudo tar xz -C /usr/local/bin oragit clone https://github.com/rithyhuot/ora.git
cd ora
make build
# binary is at bin/oraRequires macOS 14+ with sandbox-exec (installed by default).
Each release ships a checksums.txt and a cosign signature bundle.
To verify before installing:
VERSION=v0.4.0
curl -LO https://github.com/rithyhuot/ora/releases/download/$VERSION/checksums.txt
curl -LO https://github.com/rithyhuot/ora/releases/download/$VERSION/checksums.txt.bundle
cosign verify-blob \
--certificate-identity-regexp 'https://github.com/rithyhuot/ora/.github/workflows/release.yml@refs/tags/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--bundle checksums.txt.bundle \
checksums.txtA successful verify line ("Verified OK") confirms the checksums file
came from the tagged release workflow. Then sha256sum -c checksums.txt
the binary tarball you downloaded.
ora keeps no daemons or background services, so uninstalling is a matter of removing the binary and (optionally) the config directory.
Match the method you installed with:
# go install
rm "$(go env GOPATH)/bin/ora"
# prebuilt binary from GitHub Releases
sudo rm /usr/local/bin/ora
# from source
rm -rf ~/Documents/Github/ora # or wherever you clonedConfirm it's gone:
command -v ora || echo "ora removed"ora writes two files under ~/.config/ora/:
config.toml— your user-level configurationtrust.toml— SHA-256 hashes of project.ora.tomlfiles you've trusted
rm -rf ~/.config/oraSkip this step if you plan to reinstall and want to keep your trust grants and config.
Each invocation writes a temporary Seatbelt profile to $TMPDIR and deletes it on exit. If a previous run was killed hard (SIGKILL, panic, power loss), a stale ora-sandbox-*.sb may remain. Clean them up before removing the binary:
ora doctor --sweep # removes ora-sandbox-*.sb older than 24hOr after the binary is gone:
rm -f "${TMPDIR:-/tmp}"/ora-sandbox-*.sbora does not install launchd agents, kernel extensions, or anything outside $HOME and $TMPDIR — there is nothing else to clean up.
cd ~/my-project
ora claude # start Claude Code inside the sandbox
ora claude --model opus # pass flags straight through
ora gemini # start Gemini CLI
ora codex "write a test" # one-shot with Codex
ora ollama # local-only; no egress neededThe writable scope defaults to cwd. If you are inside a git repo and want the whole repo writable:
# one-off
ORA_WORKDIR_SCOPE=git_root ora claude
# or persist in .ora.toml at the repo root# .ora.toml
[paths]
workdir_scope = "git_root"ora passes every argument after the provider name straight through to the underlying CLI. Flags that bypass the provider's own guardrails still run inside the kernel sandbox.
# Claude Code — skip its own permission prompts (still sandboxed by ora)
ora claude --dangerously-skip-permissions
# Gemini — "yolo" mode (still sandboxed by ora)
ora gemini --yolo
# Codex — specify model and non-interactive mode
ora codex --model gpt-4o "refactor this function"
# Opencode — verbose logging
ora opencode --verbose
# Ollama — run a specific model
ora ollama run llama3.2The provider's flags control the AI's behavior; ora's sandbox controls what the process can access on your machine. Both layers are independent.
Run ora policy show to see the exact profile for your current directory:
ora policy show --provider claudeDenied by default:
| What | Example violation | Error you see |
|---|---|---|
| Write outside project/auth dirs | echo x > ~/elsewhere.txt |
Operation not permitted |
Read ~/.ssh |
cat ~/.ssh/id_rsa |
Operation not permitted |
| HTTPS to unlisted host | curl https://evil.com |
proxy 403 or connection timeout |
| Plain HTTP | curl http://internal |
proxy 403 |
Write to .git/hooks |
git commit with a new hook |
Operation not permitted |
Write WORKSPACE/.envrc |
agent plants direnv-RCE on next cd |
Operation not permitted |
| Read other processes' argv | ps aux to scrape --token=… flags |
Operation not permitted (strict sysctl default) |
| Keychain / XPC password mgrs | Known limitation¹ | N/A — (allow mach-lookup) is unrestricted |
¹ The Seatbelt profile emits unrestricted (allow mach-lookup), which
allows the agent to reach com.apple.securityd (Keychain) and 1Password
XPC services. A per-provider Mach service allowlist is tracked for a
future release. Run ora doctor to see all known gaps.
ora also strips a number of env vars from the spawned CLI's environment so a compromised or malicious agent cannot exfiltrate them or hijack interpreter bootstrap:
- Credential bearers:
AWS_*,KUBECONFIG,GH_TOKEN,GITHUB_TOKEN,NPM_TOKEN,PYPI_TOKEN,CARGO_REGISTRY_TOKEN,DOCKER_HOST/DOCKER_TLS_VERIFY/DOCKER_CERT_PATH,AZURE_*,VAULT_TOKEN,DATABASE_URL,SSH_AUTH_SOCK,GCP_SERVICE_ACCOUNT_KEY. - Cross-provider AI keys: every registered provider's
OwnEnvKeysexcept the invoked provider's own (e.g. runningora claudestripsOPENAI_API_KEY/GEMINI_API_KEY/etc., keeping onlyANTHROPIC_API_KEY). - Interpreter / dynamic-loader hooks:
NODE_OPTIONS,DYLD_INSERT_LIBRARIESand otherDYLD_*,PYTHONSTARTUP,PYTHONPATH,BASH_ENV,ENV,RUBYOPT,PERL5OPT/PERL5LIB,JAVA_TOOL_OPTIONS. These run caller-controlled code at process start and would defeat the sandbox before the CLI's own logic ran.
Run with --verbose to see Seatbelt deny events in real time:
ora --verbose claudeStream every blocked filesystem read/write and network attempt as JSON-Lines. Pipe to a file (or your log aggregator) and you get a per-session forensic trail:
ora --json claude 2> agent.events.jsonlSample lines from agent.events.jsonl:
{"type":"fs_deny","operation":"file-read-data","path":"/Users/you/.aws/credentials","timestamp":"2026-04-26T12:00:00Z","version":1,"pid":12345}
{"type":"network_blocked","host":"pastebin.com","port":443,"reason":"not_allowlisted","timestamp":"2026-04-26T12:00:01Z","version":1,"pid":12345}
{"type":"sandbox_summary","exit_code":0,"duration_ms":18432,"network_blocks":3,"timestamp":"2026-04-26T12:00:18Z","version":1,"pid":12345}Combined with ora policy show, you get the full picture: the policy you authorized, and the boundaries the agent actually bumped into.
Three switches harden the runner. ORA_AUTH_DIR_MODE=readonly blocks token refresh races across parallel jobs. ORA_TRUST_PROJECT_CONFIG=1 skips the interactive trust prompt for the repo's .ora.toml. --json produces an audit-log artifact you can attach to the run.
# .github/workflows/agent.yml (excerpt)
- name: Run AI codemod
env:
ORA_AUTH_DIR_MODE: readonly
ORA_TRUST_PROJECT_CONFIG: "1"
run: |
ora --json codex "apply the migration described in MIGRATION.md" \
2> sandbox.events.jsonl
- uses: actions/upload-artifact@v4
if: always()
with:
name: sandbox-events
path: sandbox.events.jsonlollama runs against a model on disk. Wrap it and "this conversation never leaves the laptop" becomes a kernel guarantee rather than a policy line:
ora ollama run llama3.2
# any HTTPS attempt the model triggers is denied at both the proxy and the kernelEach AI CLI stores MCP server config in its own home-dir file. Workspace .mcp.json is intentionally denied by ora's profile (a malicious dependency could write one and trigger RCE on next launch), so MCP config goes in the user's home directory:
| Provider | Config file | Section |
|---|---|---|
| claude | ~/.claude.json |
mcpServers |
| codex | ~/.codex/config.toml |
[mcp_servers.<name>] |
| gemini | ~/.gemini/settings.json |
mcpServers |
| opencode | ~/.config/opencode/opencode.json |
mcp |
@playwright/mcp drives a real browser and is a useful example because it exercises three things most MCP servers do not: it (a) writes Playwright's browser cache, (b) launches chromium/firefox/webkit subprocesses, and (c) navigates to user-supplied URLs that aren't in the default egress allowlist.
1. Install Playwright + the MCP server outside the sandbox once (so the browser binaries land in ~/Library/Caches/ms-playwright):
npx -y @playwright/mcp@0.0.40 --help # also fetches browsers on first run2. Add the server to the provider's config.
claude — ~/.claude.json:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@0.0.40"]
}
}
}codex — ~/.codex/config.toml:
[mcp_servers.playwright]
command = "npx"
args = ["-y", "@playwright/mcp@0.0.40"]gemini — ~/.gemini/settings.json:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@0.0.40"]
}
}
}opencode — ~/.config/opencode/opencode.json:
{
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp@0.0.40"],
"enabled": true
}
}
}3. Extend ora's policy so the Playwright cache is writable and the domains the browser needs to reach are allowlisted. Use ~/.config/ora/config.toml (user-wide) or a project .ora.toml (per-repo, requires ora trust add).
extra_writable paths must be absolute — TOML does not expand ~ and ValidateExtraWritable rejects relative paths. Substitute your own home dir for /Users/<you> below:
[paths]
# Browser binaries Playwright caches. Absolute path (TOML does not expand ~).
extra_writable = [
"/Users/<you>/Library/Caches/ms-playwright",
]
[egress]
extra_domains = [
"cdn.playwright.dev", # Playwright fetches browser updates here
# plus the sites the agent will actually navigate to:
"example.com",
"*.your-app.com",
]The Playwright cache lives under $HOME, which ValidateExtraWritable rejects unless you ack the broader exposure. Set the env var below before running ora (this is intentional — extra_writable paths inside $HOME widen the sandbox more than paths outside it):
export ORA_I_UNDERSTAND_HOME_WRITE=14. Run as usual.
ora trust add # only if the config lives in a project .ora.toml
ora claude # or codex / gemini / opencodeInside the agent, ask it to use the Playwright tools (e.g. "open https://example.com and read the title"). The first invocation may take longer as Playwright extracts cached browsers.
Known limitations.
- Browser subprocesses inherit ora's sandbox. If a Playwright operation fails with an
EPERMor sandbox denial, run with--verboseto see which path or domain was denied and add it toextra_writable/extra_domains. - Output paths: Playwright traces, videos, and screenshots default to the working directory (already writable). If you redirect them under
$HOME(e.g.~/Documents/playwright-traces), add that path toextra_writable. - Egress is per-invocation: closing the
orasession tears down the proxy and the wrapped processes.
MCP servers commonly listen on a UDS. Allow exactly that one socket and nothing else — the agent gets the tool it needs without losing the rest of the sandbox:
# .ora.toml at the project root
[paths]
allow_unix_sockets = [
"/tmp/mcp-postgres.sock",
"/tmp/mcp-fs.sock",
]
[egress]
extra_domains = ["api.anthropic.com"] # Claude itself still needs the model APIora trust add # accept the project config (one-time per file hash)
ora claudeEdit .ora.toml later and ora will refuse to load it until you re-run ora trust add — so a malicious dependency can't widen the policy by rewriting the file.
Combine workdir_scope = "git_root" (whole repo writable, not just the cwd subdir) with internal-domain allowlists, and let your existing HTTPS_PROXY chain through:
# .ora.toml
[paths]
workdir_scope = "git_root"
[egress]
extra_domains = [
"registry.corp.internal",
"*.corp.internal",
"artifacts.corp.internal",
]export HTTPS_PROXY=http://proxy.corp:8080 # ora chains through automatically
ora claudeEach ora invocation gets its own profile and proxy, so a worktree per agent is the natural unit of isolation. A failure (or a runaway loop) in one sandbox can't reach the others:
git worktree add ../proj-feature-a -b feature-a
git worktree add ../proj-feature-b -b feature-b
(cd ../proj-feature-a && ora claude "implement feature A") &
(cd ../proj-feature-b && ora codex "implement feature B") &
waitWhen you don't want to edit config for a single run:
ora --allow grafana.internal --allow logs.internal claude--allow is repeatable and validated (rejects *.com, bare wildcards, IDN — expects ASCII Punycode).
The same policy applies to anything you wrap. Useful when you don't fully trust a build script or postinstall hook:
ora run -- npm install
ora run -- python build_release.py
ora run -- ./scripts/deploy.sh --dry-runDrop into a sub-shell to feel out what the policy permits before running an agent through it:
ora shell
# inside:
echo $HTTPS_PROXY # the loopback proxy assigned to this session
cat ~/.aws/credentials # Operation not permitted
exitora doctor validates the environment, compiles a profile, and (with --probe) tests provider connectivity through the egress proxy:
ora doctor --probe
ora doctor --sweep # also delete stale profile files older than 24hora is process-level sandboxing: the AI CLI runs inside the sandbox. When the CLI hits a boundary, the kernel returns EPERM, the CLI treats it as fatal, and the process exits. The agent loop inside the CLI dies with it.
This means the CLI cannot adapt in-process. If you are building an agent framework that calls ora, the error handling and retry logic must live outside the CLI, in your orchestrator.
Your orchestrator
└── runs: ora claude
└── sandbox-exec wraps claude
└── claude process
└── agent loop
└── tries to write to denied path
→ EPERM → claude exits 1
└── your orchestrator sees exit code 1 + stderr
└── "Operation not permitted"
The orchestrator captures:
exitCode(non-zero)stderrwith[SANDBOX DENIED]label whenoradetects a policy boundary--jsonevents (if enabled) with structured deny metadata
When the CLI exits due to a sandbox boundary, your orchestrator should:
-
Detect the failure type from stderr:
[SANDBOX DENIED] filesystem policy boundary→ file write/read blocked[SANDBOX DENIED] network policy boundary→ host not in allowlist[SANDBOX DENIED] filesystem and network policy boundary→ both
-
Feed the error into the next prompt so the LLM can adapt:
Previous attempt failed: - Command: git commit -m "update" - Error: Operation not permitted (writing to .git/hooks/post-commit) - Cause: .git/hooks is blocked by the sandbox policy for security. - Suggestion: commit without hooks, or run git commands that do not create new hook files. -
Restart
orawith the adapted prompt. Do not try to recover inside the sameorainvocation — the process is already dead.
ora --json claudeEmits JSON-Lines on stderr with events like:
{"type":"fs_deny","operation":"file-write-create","path":"/Users/you/code/proj/.git/hooks/post-commit","timestamp":"2026-04-26T12:00:00Z","version":1,"pid":12345}
{"type":"network_blocked","host":"evil.com","port":443,"reason":"not_allowlisted","timestamp":"2026-04-26T12:00:00Z","version":1,"pid":12345}See docs/ARCHITECTURE.md for the full schema.
Your orchestrator can tail stderr, parse these events, and include them in the retry context.
| Agent tries to... | Sandbox blocks | Retry strategy |
|---|---|---|
Write .git/hooks |
file-write-create deny |
Skip hooks; use git commit --no-verify |
| Call unlisted API | network-outbound deny |
Add domain to extra_domains or use allowlisted alternative |
Read ~/.npmrc |
file-read* deny |
Set ORA_ALLOW_NPMRC=true if legitimate |
Use security CLI / Keychain |
mach-lookup deny |
Avoid Keychain ops inside sandbox; use env vars |
Run ora inside ora |
forbidden-sandbox-reinit |
Run inner command unsandboxed (with acknowledgement) |
See docs/SANDBOX_ERROR_BEHAVIOR.md for the full technical explanation.
Env vars:
| Var | Default | Effect |
|---|---|---|
ORA_NATIVE_KERNEL |
true |
Set to false to bypass the sandbox (UNSAFE; warns on stderr) |
ORA_I_UNDERSTAND_UNSANDBOXED |
(unset) | Required to actually disable the sandbox; otherwise ORA_NATIVE_KERNEL=false is rejected |
ORA_AUTH_DIR_MODE |
readwrite |
readonly to disable in-band token refresh |
ORA_ALLOW_NPMRC |
false |
true to allow reading ~/.npmrc |
ORA_ALLOWED_DOMAINS |
(defaults) | Comma-separated additions: api.mycorp.com,*.internal |
ORA_ALLOW_UNIX_SOCKETS |
(empty) | Comma-separated absolute UDS paths |
ORA_WORKDIR |
(cwd) | Override the writable directory |
ORA_WORKDIR_SCOPE |
cwd |
git_root to walk up to repo root |
ORA_I_UNDERSTAND_HOME_WRITE |
(unset) | 1 to allow extra_writable paths inside $HOME that are not git repo roots |
ORA_TRUST_PROJECT_CONFIG |
(unset) | 1 to bypass trust-on-first-use for the auto-discovered .ora.toml (use in CI) |
ORA_STRICT_SYSCTL |
true |
Block kern.proc.* enumeration so the sandboxed process cannot read other processes' argv (and therefore secrets passed via --token=/postgres://user:pw@… flags). Set to 0 only if a tool you wrap (debugger, IDE process picker) genuinely needs kern.proc. |
A project-level .ora.toml can widen the sandbox (add domains, mark paths writable, enable allow_npmrc/allow_git_config, etc.). To prevent a hostile cloned repository from silently weakening your policy on first cd, ora refuses to load a project .ora.toml until you grant it trust:
cd ~/code/some-cloned-repo
ora claude
# Error: project config /Users/.../.ora.toml is not trusted.
# Inspect it, then run `ora trust add /Users/.../.ora.toml` to grant trust,
# or set ORA_TRUST_PROJECT_CONFIG=1 to bypass for this invocationOnce trusted, ora records a SHA-256 of the file in ~/.config/ora/trust.toml. If the file changes, ora refuses again until you re-run ora trust. For CI/scripted use, set ORA_TRUST_PROJECT_CONFIG=1 to bypass the check.
extra_writable paths inside $HOME are also restricted: only paths that are themselves git repo roots are accepted by default; everything else requires ORA_I_UNDERSTAND_HOME_WRITE=1.
./.ora.toml(walks up to repo root; requires trust)~/.config/ora/config.toml
[egress]
extra_domains = ["api.mycorp.com", "*.internal"]
[paths]
allow_npmrc = false
allow_git_config = false
allow_unix_sockets = []
extra_writable = []
workdir_scope = "cwd" # or "git_root"
auth_dir_mode = "readwrite" # or "readonly"Full reference: docs/CONFIGURATION.md
| Command | Description |
|---|---|
ora <provider> [args] |
Run a supported CLI inside the sandbox |
ora run -- <cmd> [args] |
Sandbox an arbitrary command |
ora shell |
Interactive sandboxed sub-shell |
ora doctor |
Verify environment, profile compile, proxy bind |
ora doctor --probe |
Probe each provider through the egress proxy |
ora doctor --sweep |
Delete stale profile files older than 24h |
ora policy show |
Print the effective Seatbelt profile |
ora trust add [path] |
Trust a project .ora.toml (defaults to one auto-discovered from cwd) |
ora trust list |
List trusted project configs |
ora trust show [path] |
Show trust state for a path |
ora trust remove [path] |
Remove a path from the trust DB |
ora --version |
Print version |
Flags:
| Flag | Description |
|---|---|
--verbose |
Stream Seatbelt deny events to stderr (gated by a runtime self-test of /usr/bin/log; degraded if format drifts) |
--json |
Emit JSON-Lines events on stderr instead of text |
--allow <domain> |
Add an HTTPS domain for this invocation (validates: rejects *.com, bare wildcards, IDN; expects ASCII Punycode) |
oragenerates a Seatbelt profile for this invocation.- Starts an in-process HTTPS-CONNECT proxy on
127.0.0.1:<random>. - Sets
HTTPS_PROXY/HTTP_PROXY/ALL_PROXYto the proxy address. - Spawns
sandbox-exec -f <profile> <cli> [args]. - Seatbelt blocks every non-loopback socket; the proxy enforces the domain allowlist.
- On exit: tears down the proxy and deletes the profile.
See docs/SECURITY.md for the threat model and security considerations.
docs/ARCHITECTURE.md— Component diagram, per-invocation lifecycle, package reference, event schemadocs/SECURITY.md— Threat model, deny lists, known limitationsdocs/CONFIGURATION.md— Full configuration referencedocs/SANDBOX_ERROR_BEHAVIOR.md— How sandbox denials surface and agent adaptationdocs/RELEASE.md— Release process and versioning
make help # show all targets
make build # compile to bin/ora
make test # run unit tests
make test-int # run integration tests (macOS only)
make lint # run golangci-lint
make install # install to $GOPATH/bin
make snapshot # build release artifacts locally (no publish)See CONTRIBUTING.md.
MIT