iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
👻

Standardizing CLI commands makes managing Claude Code permissions easier

に公開

When you start configuring permissions in Claude Code, you gradually run into some difficulties.

What I got stuck on wasn't the syntax of the permissions themselves, but rather the inconsistency in how commands are written in the CLI.

For example, specifying a profile is crucial for aws, and the same gh api command can change its meaning from read to write depending on the options used. If you try to handle all of this purely through permissions, the configuration becomes increasingly difficult to manage.

So far, I have found that it works better to decide on the standard forms of the commands Claude is allowed to use first.

What I do is simple:

  • Teach Claude how to use commands in CLAUDE.md beforehand
  • Apply allow / ask / deny settings in settings.json based on those forms
  • Use hooks to stop any deviations

The structure looks like this:

~/.claude/
├── CLAUDE.md
├── settings.json
└── hooks/
    └── bash-guard.sh

First, define the canonical form of commands in CLAUDE.md

I start by writing how Claude should call Bash in CLAUDE.md.

CLAUDE.md
# Shell commands

Always follow these rules when calling Bash:

- `aws` must always be executed in the form `aws --profile <profile> ...`
- Read operations for `gh api` must always be executed in the form `gh api --method GET ...`
- Do not use `gh api` to execute write operations using `-f`, `-F`, `--input`, `-X`, or `--method`
- Do not use path specification options like `git -C`. Execute `cd` first, then run the command

I believe it is quite important not to rely solely on hooks for this.

Of course, if you block them with a hook, Claude will see the error message and autonomously correct how it calls the command. That is certainly useful in its own way.

However, there is a slight lag every time that happens. It only takes a few seconds, but it adds up, which I find a bit annoying.

Therefore, it is smoother to have Claude know the usage from the start, even if the hook could technically correct it. I write it in CLAUDE.md for that reason.

Write permissions based on those canonical forms

Once you fix the command forms, writing permissions becomes much easier.

For example, if you want to separate aws by profile, it looks like this:

{
  "permissions": {
    "allow": [
      "Bash(aws --profile readonly-*)"
    ],
    "ask": [
      "Bash(aws --profile write-*)"
    ],
    "deny": [
      "Bash(git push --force *)",
      "Bash(git reset --hard *)"
    ]
  }
}

Specific naming depends on your environment, but in terms of the approach, I found it easiest to classify them as:

  • Allow for read operations
  • Ask for updates
  • Deny for destructive operations

Use hooks as a last line of defense

On top of that, use hooks to block only those that deviate from the canonical form.

The PreToolUse configuration looks like this:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/bash-guard.sh"
          }
        ]
      }
    ]
  }
}

Example of bash-guard.sh

The content is quite simple.

bash-guard.sh
#!/bin/bash
set -eu

input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
[ -z "$cmd" ] && exit 0

# Prohibit git -C
if echo "$cmd" | grep -qE '^\s*git\s+-C\b'; then
  echo "BLOCKED: Do not use git -C; perform a cd first, then run the command." >&2
  exit 2
fi

# --profile is mandatory for aws
if echo "$cmd" | grep -qE '^\s*aws\s' && ! echo "$cmd" | grep -q -- '--profile'; then
  echo "BLOCKED: Please include --profile with aws." >&2
  exit 2
fi

# Only permit read-only forms for gh api
if echo "$cmd" | grep -qE '^\s*gh\s+api\b'; then
  if ! echo "$cmd" | grep -q -- '--method GET'; then
    echo "BLOCKED: Please execute gh api read operations in the form: gh api --method GET ..." >&2
    exit 2
  fi

  if echo "$cmd" | grep -qE '(^|[[:space:]])(-f|--raw-field|-F|--field|--input)([[:space:]]|=)'; then
    echo "BLOCKED: Please do not use -f / -F / --input with gh api. Only read operations are permitted." >&2
    exit 2
  fi
fi

exit 0

The benefit of hooks is that Claude can fix it itself

What I like about this configuration is that the hook is not just a blocker.

For example, by returning messages like:

  • Please include --profile with aws
  • Do not use git -C; perform a cd first
  • Please execute gh api in the form: gh api --method GET ...

Claude sees that message and corrects its next execution.

In other words, the hook is quite useful as a mechanism to re-teach usage to Claude on the fly rather than just a simple prohibition mechanism.

That said, as mentioned earlier, there is a small lag because it stops the action once and then performs the correction. Therefore, I have settled on the approach of writing regular usage in CLAUDE.md and using hooks as a final safety net.

Division of roles

For now, this is how I organize them:

  • CLAUDE.md

    • Write the rules you want Claude to follow from the beginning
  • permissions

    • Apply allow / ask / deny settings to those canonical forms
  • hooks

    • Stop deviations and return the next correction method to Claude

Since adopting this division, designing permissions has become much easier.

Conclusion

When working with Claude Code permissions, you start to worry more about the inconsistency in how the CLI is written than the permission syntax itself.

I found that it worked better to decide on the canonical forms of the commands Claude uses first, rather than trying to absorb everything through permissions alone.

What I do is simple:

  • Write usage rules in CLAUDE.md
  • Apply permissions in settings.json
  • Stop deviations using the PreToolUse hook
  • Return the next instruction method to Claude via error messages

For now, this is the easiest way to handle it.

Discussion