DEV Community

Tasuku Yamashita
Tasuku Yamashita

Posted on

Managing Claude Code Bash permissions with YAML and tests

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  • git
  • aws
  • kubectl
  • gh
  • gws
  • helm
  • helmfile
  • argocd
  • terraform
  • docker

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. write or ask Claude Code to draft rules
  2. add rule-local and top-level tests
  3. run cc-bash-guard verify
  4. inspect surprises with cc-bash-guard explain
  5. 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"
Enter fullscreen mode Exit fullscreen mode

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

https://github.com/tasuku43/cc-bash-guard

Top comments (0)