I built cc-bash-guard, a small policy engine for Claude Code Bash permissions.
It runs as a Claude Code PreToolUse hook. Before Claude Code executes a Bash command, cc-bash-guard evaluates the command and returns allow, ask, or deny.
GitHub:
https://github.com/tasuku43/cc-bash-guard
TL;DR
If you want to allow only git status, you can write a rule like this:
permission:
allow:
- name: git status
command:
name: git
semantic:
verb: status
message: "allow git status"
test:
allow:
- "git status"
- "/usr/bin/git status"
- "bash -c 'git status'"
- "env bash -c 'git status'"
- "command git status"
- "git -C repo status"
abstain:
- "git push --force origin main"
This rule does not match the raw string git status.
It matches a parsed semantic field: the git command whose verb is status. That means equivalent forms such as /usr/bin/git status or bash -c 'git status' can be treated as the same intent, while git push --force origin main does not match this rule.
Claude Code's native permissions are still useful. cc-bash-guard is not a replacement for them. It is a semantic and testable policy layer you can add as a Bash hook.
Why string prefixes start to hurt
For simple cases, native permission patterns are enough. If you only care about git status, a prefix like Bash(git status*) can work.
The problem is that agents do not always emit commands in the exact shape you expect. The same human intent can show up in several forms:
/usr/bin/git status
bash -c 'git status'
env bash -c 'git status'
command git status
git -C repo status
To a person, these are all basically "run git status". To a string prefix, they are different.
If you broaden the pattern too much, you risk allowing things you did not mean to allow:
git push --force origin main
git reset --hard
git clean -fdx
AWS has a similar problem. You may want to allow a read-only profile, ask for a write profile, and deny especially risky operations:
aws --profile readonly sts get-caller-identity
aws --profile write cloudformation deploy
That is not "allow AWS" or "deny AWS". It is "look at the intent inside this command family".
What cc-bash-guard gives you
The main goals are:
- write permissions by semantic intent, not only command strings
- test the permissions you write
- keep the runtime hook lightweight even as the policy grows
The semantic parser currently supports:
gitawskubectlghgwshelmhelmfileargocdterraformdocker
For these commands, rules can match fields such as subcommand, profile, namespace, HTTP method, dry-run, force flags, and similar command-specific fields.
For commands without semantic support, you can still use command.name, command.name_in, or narrow raw regex patterns.
permission:
allow:
- name: basic read-only tools
command:
name_in:
- pwd
- ls
- wc
test:
allow:
- "pwd"
- "ls"
abstain:
- "rm -rf tmp"
- name: narrow fallback pattern
patterns:
- "^custom-tool\\s+status(\\s|$)"
test:
allow:
- "custom-tool status"
abstain:
- "custom-tool delete all"
The important part is to keep allow narrow. Prefer semantic rules when available. For unsupported commands, prefer command.name or command.name_in. Use patterns only when you really need raw string matching.
Verify once, evaluate fast
Policies tend to grow. If tests are part of the policy, an obvious question is:
Does the hook run all those tests every time Claude Code executes Bash?
No.
You edit YAML files, then run:
cc-bash-guard verify
verify checks the policy and tests, then writes verified data for the hook. At runtime, the hook uses that verified data instead of re-reading every YAML include and rerunning every test.
Claude Code settings permissions are still part of the final decision. They are not bundled into the artifact, but their contents are part of the artifact fingerprint. If Claude Code settings change, you need to run cc-bash-guard verify again.
If the verified data is missing, stale, or incompatible, the hook fails closed.
The main commands are:
cc-bash-guard suggest "git status" # print a pasteable starter rule
cc-bash-guard verify # validate policy and write hook data
cc-bash-guard explain "bash -c 'git status'" # inspect why a command is allow/ask/deny
cc-bash-guard hook # evaluate Claude Code Bash calls
Splitting read-only and destructive Git operations
The first example allowed only git status. In practice, you may want to allow several read-only Git verbs:
permission:
allow:
- name: git read-only
command:
name: git
semantic:
verb_in:
- status
- diff
- log
- show
message: "allow git read-only commands"
test:
allow:
- "git status"
- "git diff --cached"
- "git log --oneline"
- "git show HEAD"
abstain:
- "git push origin main"
verb matches a single semantic value. verb_in matches any value in a list.
This rule allows common read-only Git commands. It does not match git push.
For operations you explicitly want to block, add deny rules:
permission:
deny:
- name: git force push
command:
name: git
semantic:
verb: push
force: true
message: "deny git force push"
test:
deny:
- "git push --force origin main"
- "git push -f origin main"
abstain:
- "git push origin main"
- name: git destructive cleanup
command:
name: git
semantic:
verb_in:
- reset
- clean
message: "deny destructive git cleanup"
test:
deny:
- "git reset --hard"
- "git clean -fdx"
abstain:
- "git reset --soft HEAD~1"
This is the part that becomes awkward with shell regex alone. You have to think about option order, wrappers, absolute paths, and equivalent command forms.
Semantic parsing does not prove safety. It just gives you a more precise way to express what you mean to allow, ask, or deny.
AWS by profile, service, and operation
AWS CLI commands also change meaning depending on profile, service, and operation.
For example:
- allow a read-only profile
- ask for a write profile
- deny explicitly dangerous operations
Profile-based rules can look like this:
permission:
allow:
- name: AWS readonly profile
command:
name: aws
semantic:
profile: readonly
message: "allow AWS readonly profile"
test:
allow:
- "aws --profile readonly sts get-caller-identity"
- "aws --profile readonly s3 ls"
abstain:
- "aws --profile write cloudformation deploy"
ask:
- name: AWS write profile
command:
name: aws
semantic:
profile: write
message: "AWS write profile requires confirmation"
test:
ask:
- "aws --profile write cloudformation deploy"
abstain:
- "aws --profile readonly sts get-caller-identity"
If you want something narrower, match service and operation:
permission:
allow:
- name: AWS identity
command:
name: aws
semantic:
service: sts
operation: get-caller-identity
message: "allow AWS identity check"
test:
allow:
- "aws sts get-caller-identity"
- "aws --profile readonly sts get-caller-identity"
abstain:
- "aws cloudformation deploy"
The point is not that profile names magically prove safety. IAM still matters. The point is that profile-based and operation-based permission rules are straightforward to express in YAML.
Rule-local tests and end-to-end tests
Rules can carry their own tests:
permission:
allow:
- name: git status
command:
name: git
semantic:
verb: status
test:
allow:
- "git status"
abstain:
- "git push --force origin main"
This is a rule-local test. It proves what this specific rule matches and does not match.
But the final hook decision does not come from one rule alone. It can involve:
- multiple cc-bash-guard rules
- Claude Code settings permissions
- fallback behavior
- compound shell commands
That is what top-level tests are for:
test:
allow:
- "git status"
- "/usr/bin/git status"
- "bash -c 'git status'"
- "env bash -c 'git status'"
- "command git status"
- "git -C repo status"
ask:
- "git push origin main"
deny:
- "git push --force origin main"
- "git status && git push --force origin main"
abstain:
- "unknown-tool status"
The compound example is important. If git status is allowed but git push --force origin main is denied, then:
git status && git push --force origin main
should still deny.
What abstain means
abstain means "no matching rule" or "this source has no opinion".
It is not a final hook decision. If every source abstains, cc-bash-guard falls back to ask.
Decision precedence is:
deny > ask > allow > abstain
Some examples:
| cc-bash-guard policy | Claude Code settings | final decision | Why |
|---|---|---|---|
allow |
abstain |
allow |
policy allows and Claude settings have no matching rule |
allow |
ask |
ask |
explicit confirmation wins over allow |
allow |
deny |
deny |
deny wins |
ask |
allow |
ask |
ask is not overridden by allow |
deny |
allow |
deny |
deny wins |
abstain |
allow |
allow |
Claude settings allow when policy has no match |
abstain |
ask |
ask |
Claude settings ask when policy has no match |
abstain |
deny |
deny |
Claude settings deny when policy has no match |
abstain |
abstain |
ask |
no source matched, so ask |
This is one of the main reasons I wanted top-level tests. They let you test the final merged decision, not only a single rule.
Let Claude Code help maintain the policy
I do not expect people to hand-write a perfect policy on day one.
The workflow I want is more like:
- write or ask Claude Code to draft rules
- add rule-local and top-level tests
- run
cc-bash-guard verify - inspect surprises with
cc-bash-guard explain - adjust rules and tests
For example:
cc-bash-guard explain "git push --force origin main"
cc-bash-guard explain --why-not allow "git status > /tmp/out"
The explain output is useful for humans, but it is also useful when asking Claude Code to improve the policy.
You can say things like:
Migrate my current Claude Code Bash permissions into cc-bash-guard policy.
or:
Explain why this command was denied, then update the rule and tests if needed.
The important part is that the agent has a verification loop. It can edit YAML, run verify, inspect failures with explain, and iterate.
suggest is available too, but I treat it as a helper. It prints a pasteable starter rule. It does not automatically edit your config.
Hook setup
Claude Code calls cc-bash-guard as a PreToolUse hook.
cc-bash-guard init prints a hook configuration snippet you can add to Claude Code settings. It looks like this:
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "cc-bash-guard hook"
}
]
}
The hook receives the Bash command Claude Code is about to run. It evaluates the command using the verified policy artifact and current Claude Code settings, then returns:
-
allow: skip the Claude Code permission prompt -
ask: ask the user to confirm -
deny: block the Bash tool call
After editing policy files, included files, Claude Code settings, or upgrading the binary, run:
cc-bash-guard verify
The hook does not regenerate artifacts at runtime.
Closing
Claude Code Bash permissions are useful, but string patterns can become too coarse when you only want to slightly widen what is allowed.
cc-bash-guard fills that gap with semantic rules and tests.
The goal is not only to lock everything down. It is to make common read-only operations smooth, keep risky or ambiguous commands visible, and leave a reviewed YAML trail of the decisions you want.
Instead of trying to write the perfect policy upfront, you can grow it over time:
- this should pass
- this should be denied
- this should ask
- this near miss should stay outside the rule
That is the workflow I wanted for Claude Code Bash permissions.
GitHub:
Top comments (0)