Read-only CLI sandbox for AI agents. Wraps tools like gh and git behind an allowlist so agents can only run non-destructive commands. Dangerous subcommands like git commit and git push require separate, scoped unlocks via Touch ID.
Built this because I started writing bash scripts and moving binaries to only let my OpenClaw (called Zuko) have access to only read-only commands for certain tools.
Now I use it on my mac, so that I can run agents a bit more autonomously without having to intervene.
Zuko is a single Go binary that acts as a multicall binary. When symlinked as gh, it intercepts the call, checks the command against an allowlist, and either proxies to the real binary or blocks it.
agent runs "gh issue list --state open"
│
▼
~/.config/zuko/shims/gh (symlink → zuko binary)
│
▼
zuko loads ~/.config/zuko/config.yaml
│
▼
["issue", "list"] matches allowlist → exec /opt/homebrew/bin/gh issue list --state open
agent runs "gh issue create --title oops"
│
▼
zuko: blocked "gh issue create" — not in allowlist
curl -sSfL https://raw.githubusercontent.com/ParthSareen/zuko/main/install.sh | shOr with Go (requires ~/go/bin on your PATH):
go install github.com/ParthSareen/zuko@latestIf ~/go/bin isn't on your PATH, add this to your shell rc (~/.zshrc or ~/.bashrc):
export PATH="$HOME/go/bin:$PATH"# Discover binaries on PATH, create shim symlinks, write default config
zuko setup
# Option A: system-wide — prepend shim dir to PATH in your shell rc
zuko init shell
# Option B: openclaw only — merge into openclaw.json
zuko init openclaw
# Option B with dual enforcement (both zuko + openclaw allowlists)
zuko init openclaw --defense-in-depthzuko setup creates symlinks in ~/.config/zuko/shims/ and writes a default config to ~/.config/zuko/config.yaml.
zuko init shell prepends the shim directory to PATH in ~/.zshrc or ~/.bashrc (auto-detected, or specify with --rc). This shadows gh, himalaya, etc. with zuko shims while keeping all other tools accessible.
zuko init openclaw merges env.PATH into ~/.openclaw/openclaw.json so only the agent's environment is affected. Use --config to specify a custom path.
The allowlist lives at ~/.config/zuko/config.yaml:
shim_dir: ~/.config/zuko/shims
tools:
git:
real_binary: /usr/bin/git
allow_all: true
locked:
- [commit]
- [push]
- [tag]
gh:
real_binary: /opt/homebrew/bin/gh
allow_bare: true
allow:
- ["issue", "list"]
- ["issue", "view"]
- ["pr", "list"]
- ["pr", "view"]
- ["pr", "diff"]
- ["search", "issues"]
- ["search", "code"]
- ["api"]
# ... see config.yaml for full list
deny_flags:
api: ["-X", "--method", "-f", "--raw-field", "-F", "--field", "--input"]- allow_all — pass everything through except
lockedsubcommands. Ideal for tools likegitwhere most commands are safe. - allow — prefix match.
["issue", "list"]permitsgh issue list --state open -R foo/bar. - locked — subcommands gated behind a scoped unlock (Touch ID per operation). Checked before
allowso a locked subcommand can't accidentally match a broader allow entry. - deny_flags — per-subcommand flag blocklist. Blocks
gh api -X POSTwhile allowinggh api /repos/.... - allow_bare — whether bare invocation (e.g.
ghwith no args) is permitted.
Edit the config to add new tools or commands. Requires authentication:
zuko configZuko uses Touch ID on macOS (with fallback to system password dialog) or sudo on Linux to gate privileged operations.
Zuko supports tiered unlocking — global, per-tool, or per-subcommand:
# Global unlock for 5 minutes (all shims pass through)
zuko unlock
# Unlock all locked subcommands under git
zuko unlock git
# Unlock only git commit (git push stays locked)
zuko unlock git commit
# Unlock for 30 minutes
zuko unlock git push -d 30m
# Re-lock everything
zuko lock
# Re-lock only git commit (git push stays unlocked)
zuko lock git commit
# Re-lock all git grants
zuko lock gitEach unlock requires its own Touch ID prompt. While globally unlocked, all shims pass commands through without filtering. The agent can't run zuko unlock because zuko itself isn't on the shim PATH.
These commands require authentication:
| Command | What it does |
|---|---|
zuko unlock |
Temporarily allow all commands (global) |
zuko unlock <tool> |
Unlock all locked subcommands for a tool |
zuko unlock <tool> <subcmd> |
Unlock a specific subcommand |
zuko config |
Open allowlist config in $EDITOR |
zuko init shell |
Prepend shim dir to PATH in shell rc |
zuko init openclaw |
Merge settings into openclaw.json |
zuko add |
Add a new tool |
zuko remove |
Remove a tool |
# Passthrough (no subcommand filtering) — good for tools like jq, cat, rg
zuko add jq --passthrough
# Only allow specific subcommands
zuko add kubectl --allow get,describe,logs
zuko add docker --allow ps,images,logs
# Multi-word subcommand prefixes
zuko add docker --allow "container ls","image ls"
# Remove a tool
zuko remove jqAll add/remove operations require authentication. You can also fine-tune the config directly with zuko config.
| Command | Description |
|---|---|
zuko setup |
Discover binaries, create shim symlinks, write config |
zuko init shell |
Prepend shim dir to PATH in shell rc |
zuko init openclaw |
Merge zuko settings into openclaw.json |
zuko add |
Add a new CLI tool to the sandbox (requires auth) |
zuko remove |
Remove a CLI tool from the sandbox (requires auth) |
zuko config |
Edit allowlist config (requires auth) |
zuko unlock [tool] [subcmd] |
Temporarily allow commands (requires auth) |
zuko lock [tool] [subcmd] |
Revoke unlock session (global or scoped) |
zuko timeout [minutes] |
Get or set default unlock duration |
zuko version |
Print version |
zuko teardown |
Remove shim symlinks |
zuko teardown shell |
Remove zuko PATH block from shell rc |
zuko teardown openclaw |
Remove zuko settings from openclaw.json |
zuko teardown all |
Remove shims + undo shell and openclaw init |
Zuko shims only work when the agent uses git (resolved via PATH). A smart agent could bypass the shim by calling /usr/bin/git directly. If your AI coding tool supports pre-execution hooks, add one to block absolute paths to real binaries.
For example, with Claude Code, create ~/.claude/hooks/zuko-guard.sh:
#!/bin/bash
input="$(cat)"
command="$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null)"
blocked_paths=(
"/usr/bin/git"
"/opt/homebrew/bin/gh"
)
for path in "${blocked_paths[@]}"; do
if echo "$command" | grep -qF "$path"; then
echo "BLOCKED: use the zuko shim instead of $path"
exit 2
fi
doneThen register it in ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash ~/.claude/hooks/zuko-guard.sh" }
]
}
]
}
}macOS and Linux. Authentication uses the native macOS dialog on Darwin and sudo -v on Linux.