A policy engine for Claude Code tool permissions. Define policies that automatically allow, deny, or prompt for each tool call.
Claude Code asks permission before running tools like Bash commands, file writes, etc. Toolgate lets you codify your permission preferences as composable policies — so git status is always allowed, destructive commands are always denied, and everything else prompts as normal.
// toolgate.config.ts — auto-allow curl to localhost
import { definePolicy, allow, next } from "@brycehanscomb/toolgate";
import { safeBashCommand } from "toolgate/policies/parse-bash-ast";
export default definePolicy([
{
name: "Allow curl localhost",
description: "Permits curl commands targeting localhost",
handler: async (call) => {
const args = await safeBashCommand(call);
if (!args) return next();
if (args[0] === "curl" && args.some((a) => /^https?:\/\/localhost/.test(a))) {
return allow();
}
return next();
},
},
]);Toolgate requires shfmt for Bash command parsing. Without it, all Bash commands will prompt for permission.
# With Homebrew
brew install shfmt
# Or with Go (ensure ~/go/bin is in your PATH)
go install mvdan.cc/sh/v3/cmd/shfmt@latestbun install -g toolgate# Register the PreToolUse hook globally
toolgate init
# Optionally, create a project-specific config
toolgate init --projectThis registers a PreToolUse hook in ~/.claude/settings.json. Toolgate ships with 62 built-in policies that are always active.
Toolgate walks from the current directory up to $HOME and loads every config it finds along the way. At each level it checks two filenames, in this order:
toolgate.config.local.ts— personal, gitignored. For policies specific to your machine or setup that you don't want to commit.toolgate.config.ts— shared, committed. For policies the whole team uses.
Both may live at the directory root or inside .claude/. Personal configs are evaluated before shared ones, and inner directories are evaluated before outer ones, so the most specific/personal policy wins. Built-in policies always run last.
toolgate init --project creates the shared config and adds toolgate.config.local.ts to your project's .gitignore.
Example shared config:
import { definePolicy, deny, next } from "@brycehanscomb/toolgate";
export default definePolicy([
{
name: "Deny dangerous commands",
description: "Blocks rm -rf",
handler: async (call) => {
if (call.tool === "Bash" && call.args.command?.includes("rm -rf")) {
return deny("Destructive command blocked");
}
return next();
},
},
]);Project policies run first (personal before shared), then built-in. The first non-next() verdict wins.
A config can disable any named policy (built-in or inherited from a parent config) via a named disable export:
// toolgate.config.ts
export default [myPolicy]
export const disable = ['Deny bash grep']Names must match the name field on the target Policy exactly. Use toolgate disable to interactively toggle policies on/off, or toolgate disable --json to dump the full policy state for debugging.
A policy is an object with name, description, and an async handler function:
import { allow, deny, next, type Policy } from "@brycehanscomb/toolgate";
const denyRmRf: Policy = {
name: "Deny rm -rf",
description: "Blocks destructive rm -rf commands",
handler: async (call) => {
if (call.tool !== "Bash") return next();
if (call.args.command?.includes("rm -rf")) {
return deny("Destructive command blocked");
}
return next();
},
};
export default denyRmRf;| Verdict | Effect |
|---|---|
allow() |
Permit the tool call silently |
deny(reason?) |
Block the tool call |
next() |
No opinion — pass to next policy (or prompt user if none remain) |
Each policy handler receives a ToolCall with:
tool— tool name ("Bash","Read","Write","Edit", etc.)args— tool arguments (e.g.{ command: "git status" }for Bash)context.cwd— working directorycontext.projectRoot— git repository root (ornull)context.env— environment variables
When writing policies for Bash commands, don't parse raw strings with regex — use the AST-based utilities from policies/parse-bash-ast.ts instead. They use shfmt --tojson under the hood and reject unsafe patterns (substitution, chaining, background, unsafe redirects) at the AST level.
import { safeBashCommand } from "toolgate/policies/parse-bash-ast";
import { allow, next, type Policy } from "@brycehanscomb/toolgate";
const allowMake: Policy = {
name: "Allow make",
description: "Permits simple make commands",
handler: async (call) => {
const tokens = await safeBashCommand(call);
if (!tokens) return next();
if (tokens[0] === "make") return allow();
return next();
},
};Parses a Bash tool call into a flat string[] of tokens. Returns null if the command contains pipes, shell operators (&&, ||, ;, &), command substitution, unsafe redirects, or multiple statements. Use this for simple, single-command policies.
Like safeBashCommand, but allows pipes to safe filters. Returns string[] — the tokens of the first command only (filter safety is validated automatically). Returns null for non-pipe operators or unsafe patterns. Use this when you need to allow commands like git log | head.
import { safeBashCommandOrPipeline } from "toolgate/policies/parse-bash-ast";
const tokens = await safeBashCommandOrPipeline(call);
if (!tokens) return next();
if (tokens[0] === "git") return allow();Decomposes &&-chained commands into individual Stmt nodes. Returns null if the command contains ||, ;, or other unsafe operators. Use this when you need to validate each segment of a compound command independently (e.g. allow-pure-and-chains).
Returns true if a token array is a safe pipe filter — a command that only reads stdin and writes stdout. Safe filters: grep, egrep, fgrep, head, tail, wc, cat, tr, cut, sort (without -o), uniq.
Returns the git repository root for the given directory, or null if not in a repo. Exported from toolgate/utils.
See policies/allow-git-add.ts for a full hardened example.
# Dry-run a tool call against your policies
toolgate test Bash '{"command": "git add ."}'
# → ALLOW
# Show which policy matched and why
toolgate test --why Bash '{"command": "git add ."}'
# → ALLOW
# why: Allow git add (index 4)
# description: Permits git add commands, optionally piped through safe filters
# List all loaded policies
toolgate list
# Audit settings.local.json against policies
toolgate audit
toolgate audit --json
# Interactively toggle which policies are disabled
toolgate disable
toolgate disable --local # target toolgate.config.local.ts
toolgate disable --shared # target toolgate.config.ts
toolgate disable --json # dump all policies + disable state as JSON
# Temporarily suspend all policies (Ctrl+C to resume)
toolgate suspendToolgate exposes a stdin/stdout bridge for non-Claude-Code consumers. This lets agents like OpenCode use toolgate's policy engine via their plugin hook systems.
echo '{"tool":"Bash","args":{"command":"git push"},"cwd":"/path/to/repo"}' | bun run src/bridge.ts
# → {"verdict":"deny","reason":"git push requires approval"}{
"tool": "Bash",
"args": { "command": "git status" },
"cwd": "/path/to/project",
"session_id": "optional-session-id"
}tool— tool name ("Bash","Read","Write", etc.)args— tool arguments objectcwd— working directory (defaults toprocess.cwd())session_id— optional session identifier
{"verdict":"allow"}
{"verdict":"deny","reason":"git push requires approval"}
{"verdict":"next"}allow— permit the tool calldeny— block the tool call;reasonexplains whynext— no policy matched; fall through to the agent's default handling
In OpenCode, configure a tool.execute.before hook that pipes tool calls through the bridge:
{
"hooks": {
"tool.execute.before": "echo '{\"tool\":\"$TOOL\",\"args\":$ARGS,\"cwd\":\"$CWD\"}' | bun run /path/to/toolgate/src/bridge.ts"
}
}The hook receives the tool call, toolgate evaluates the full policy chain (project configs + built-ins), and returns a verdict. If the verdict is deny, OpenCode blocks execution and surfaces the reason to the user.
- Config resolution works identically to Claude Code (walks from
cwdto$HOME) - Outputs JSON on stdout; errors go to stderr with a
denyfallback on stdout - Reuses the same
buildToolCall,loadConfigs, andrunPolicypipeline
Toolgate ships with 62 built-in policies organized in three tiers. Order matters — first non-next() verdict wins.
| Policy | Description |
|---|---|
deny-git-add-and-commit |
Blocks compound git add+commit, forcing separate steps |
deny-writes-outside-project |
Blocks writes, redirects, cp/mv/install targeting paths outside the project |
deny-git-dash-c |
Blocks git -C configuration injection |
deny-cd-chained |
Blocks cd chained with other commands |
deny-git-chained |
Blocks git commands chained with non-git commands |
deny-gh-heredoc |
Prevents heredoc/command substitution in gh/git commands |
deny-ssh-compound |
Rejects compound Bash commands containing ssh — run ssh separately for explicit approval |
deny-mixed-pure-chains |
Blocks compound commands mixing pure (sleep, echo) and non-pure commands |
| Policy | Description |
|---|---|
redirect-plans-to-project |
Blocks plan writes to ~/.claude/plans/ and suggests project docs/ instead |
redirect-python-json-to-fx |
Blocks python3 JSON processing commands — suggests fx/gron instead |
Git & GitHub
| Policy | Description |
|---|---|
allow-git-add |
Permits git add with safe arguments |
allow-git-diff |
Permits git diff, optionally piped through safe filters |
allow-git-log |
Permits git log and git show, optionally piped |
allow-git-status |
Permits git status, optionally piped |
allow-git-branch |
Permits read-only git branch commands |
allow-git-checkout-b |
Permits git checkout -b / git switch -c |
allow-git-commit |
Permits standalone git commit (chained add+commit is caught by deny policy) |
allow-git-stash |
Permits safe git stash operations |
allow-git-worktree |
Permits git worktree add/list/move/remove/prune |
allow-git-check-ignore |
Permits git check-ignore |
allow-git-rev-parse |
Permits git rev-parse |
allow-git-local-repo |
Permits git commands in local repos |
allow-non-destructive-git |
Auto-approves git commands that don't mutate remote state or discard uncommitted work |
allow-gh-read-only |
Permits read-only gh CLI commands (view, list, diff, checks, search) |
allow-gh-issue-pr |
Permits gh issue and gh pr subcommands (create, edit, comment, close, reopen) but denies delete |
File Operations
| Policy | Description |
|---|---|
allow-read-in-project |
Permits Read tool for files within project root |
allow-edit-in-project |
Permits Edit, Write, Update for files in project (except sensitive files) |
allow-grep-in-project |
Permits Grep tool within project root |
allow-bash-grep-in-project |
Permits grep/egrep/fgrep/rg commands when all paths are within project root |
allow-search-in-project |
Permits Search and Glob within project root |
allow-find-in-project |
Permits Find tool within project root |
allow-mkdir-in-project |
Permits mkdir within project root |
allow-read-tool-results |
Permits Read tool calls targeting ~/.claude/projects/*/tool-results/ |
Bash & Shell
| Policy | Description |
|---|---|
allow-bun-test |
Permits bun test, optionally piped |
allow-bash-find-in-project |
Permits find commands within project root |
allow-ls-in-project |
Permits ls within project root |
allow-cd-in-project |
Permits cd within project root |
allow-safe-read-commands |
Permits read-only commands (cat, head, tail, wc, etc.) in project |
allow-pure-and-chains |
Auto-allows && chains where every segment is independently safe |
allow-rm-project-tmp |
Permits rm in project tmp/ directories |
allow-sleep |
Permits sleep with numeric duration |
allow-read-plugin-cache |
Permits reads from plugin cache directories |
allow-npm-install |
Permits npm install, pnpm install, and yarn install commands |
allow-npx-safe |
Permits npx commands for whitelisted packages (playwright, vitest, etc.) |
allow-tmux |
Auto-allows read-only tmux commands; for send-keys, evaluates inner command through the policy chain |
allow-aws-cli |
Auto-allows non-destructive AWS CLI commands with ReadOnly profiles; requires approval for Admin profiles |
allow-brew |
Auto-allows read-only brew commands (list, info, search, etc.); requires approval for mutating commands |
Claude Code Tools
| Policy | Description |
|---|---|
allow-explore-in-project |
Permits Explore agent within project root |
allow-plan-in-project |
Permits Plan tool within project root |
allow-agent |
Permits Agent subagent invocations |
allow-task-crud |
Permits Task tool calls (create, update, list, get) |
allow-cron-crud |
Permits CronCreate, CronDelete, CronList |
allow-ask-user |
Permits AskUserQuestion |
allow-plan-mode |
Permits EnterPlanMode and ExitPlanMode |
allow-tool-search |
Permits ToolSearch |
allow-superpowers-skills |
Permits superpowers skill invocations |
Web & MCP
| Policy | Description |
|---|---|
allow-web-fetch |
Permits all WebFetch tool calls |
allow-web-search |
Permits all WebSearch tool calls |
allow-webfetch-claude |
Permits WebFetch to claude.com and subdomains |
allow-mcp-context7 |
Permits Context7 documentation lookup calls |
allow-mcp-ide-diagnostics |
Permits IDE diagnostics tool calls |
allow-mcp-playwright |
Permits all Playwright browser automation tool calls |
MIT