Feature Request: Add a CommandSimilarityHook to control blanket approval checks

Resolved 💬 8 comments Opened Aug 20, 2025 by paul-hammant Closed Dec 4, 2025

Re, this ask of the human:

  1. Run it
  2. Yes, and don't ask again for similar commands in path/to/currDir
  3. No, do something else

The Problem

Currently, it's difficult to customize the "blanket approval" logic for tool use. For example, a user might want to approve npm test once and have all subsequent calls like npm test -- --grep "feature-a" and npm test -- --grep "feature-b" be automatically approved.

The existing PreToolUse hook is not suitable for this. While it can intercept and modify a command before the approval check, it modifies the command for both the check and the final execution. This means a hook that normalizes npm test -- --grep "feature-a" to npm test would cause the agent to run npm test without the --grep filter, which is incorrect and potentially dangerous.

The Proposed Solution

We propose a new hook type, let's call it CommandSimilarityHook, that runs before the blanket approval check and is designed specifically to solve this problem.

Desired Behavior:

Trigger: The CommandSimilarityHook would run before the agent checks if a command has been previously granted blanket approval.
Input: The hook would receive the full, original command string that is about to be executed.
Output: The hook would output a "normalized" string.

Agent Logic:

The agent would use the normalized string returned by the hook to check against its internal list of blanket-approved commands.
If the normalized string is found in the approved list, the agent would proceed without asking for permission.
The agent would then execute the original, unmodified command string.
This separates the similarity check from the execution, providing a safe and powerful way for users to customize approval logic.

Reference Implementation

To demonstrate how this would work from a user's perspective, we have developed a complete "as if" implementation. This code can be found in the branch feature/command-similarity-hooks-ref-impl.

This reference implementation includes:

  1. A dispatcher script (.claude/normalise_for_previously_run_check.sh): This script simulates the logic of the proposed CommandSimilarityHook. It reads a configuration file and executes the appropriate normalizer.
  2. A configuration file (.claude/similarity_hooks.json): A simple JSON file that maps regex patterns to normalizer scripts, making the system extensible.
[
  {
    "pattern": ".*npm test.*--grep",
    "script": ".claude/similarity_hooks/npm_grep.py"
  },
  {
    "pattern": "^timeout [0-9]+ .*",
    "script": ".claude/similarity_hooks/timeout.sh"
  }
]

Example Normalizers (.claude/similarity_hooks/): We have included two examples:

A Python script (npm_grep.py) that robustly handles npm test -- --grep ... commands.

A simple shell script (timeout.sh) that normalizes timeout <N> ... commands using sed.
Documentation: A README.md file explains how users can add their own normalization rules.

What is Needed

We request that the Claude Code team implement the agent-side logic for a hook like CommandSimilarityHook. This would involve:

  1. Creating a new hook event that fires before the blanket approval check.
  2. Passing the command string to the configured hook script.
  3. Reading the normalized string from the hook's stdout.
  4. Using the normalized string for the similarity check while preserving the original command for execution.

The reference implementation we've built provides a clear specification for the desired user experience and functionality.

This relates to issues: https://github.com/anthropics/claude-code/issues/3422 and https://github.com/anthropics/claude-code/issues/3361.

Reference code

.claude/normalise_for_previously_run_check.sh

#!/bin/bash

# This script normalizes a command string based on rules defined in
# .claude/similarity_hooks.json.
# It's a reference implementation for a hypothetical 'IsSimilarHook'.

# Input command is the first argument
input_command="$1"

# Config file path
config_file=".claude/similarity_hooks.json"

# Check if jq is installed
if ! command -v jq &> /dev/null
then
    echo "jq could not be found, passing command through." >&2
    echo "$input_command"
    exit 0
fi

# Read the config and find a matching normalizer
# The `jq -c '.[]'` command iterates over the JSON array, outputting each object on a new line.
while IFS= read -r rule; do
    pattern=$(echo "$rule" | jq -r '.pattern')
    script=$(echo "$rule" | jq -r '.script')

    # Check if the input command matches the pattern
    if [[ "$input_command" =~ $pattern ]]; then
        # If it matches, execute the associated script
        # The original command is passed to the script's standard input
        normalized_command=$(echo "$input_command" | "$script")
        echo "$normalized_command"
        exit 0
    fi
done < <(jq -c '.[]' "$config_file")

# If no rules matched, return the original command
echo "$input_command"

.claude/similarity_hooks.json

[
  {
    "pattern": ".*npm test.*--grep",
    "script": ".claude/similarity_hooks/npm_grep.py"
  },
  {
    "pattern": "^timeout [0-9]+ .*",
    "script": ".claude/similarity_hooks/timeout.sh"
  }
]

A README for it

# Command Similarity Hook System (Reference Implementation)

This directory contains a reference implementation for a hypothetical command similarity hook system for the Claude Code agent.

## Purpose

This system allows you to define rules that "normalize" commands before the agent checks if they are similar to a previously approved command. This is useful for cases where commands are functionally similar but have different parameters, such as:

- `npm test -- --grep "feature-a"` vs `npm test -- --grep "feature-b"`
- `timeout 30 my_command` vs `timeout 60 my_command`

By normalizing these commands to a common string (e.g., `npm test` or `timeout [timeout_duration] my_command`), you can give blanket approval to the normalized form once, and the agent will not have to ask for permission again for variations.

**Note:** This is a reference implementation built "as if" a dedicated hook for this purpose existed in the Claude agent. The central dispatcher script (`/.claude/normalise_for_previously_run_check.sh`) simulates how such a hook would operate.

## How It Works

1.  A central dispatcher script (`/.claude/normalise_for_previously_run_check.sh`) is the entry point. It takes a command string as input.
2.  The dispatcher reads the configuration from `/.claude/similarity_hooks.json`.
3.  This configuration file maps regular expression patterns to normalizer scripts located in this directory.
4.  The dispatcher checks the input command against each pattern.
5.  If a pattern matches, the corresponding script is executed. The script receives the full command on its standard input and should print the normalized command to its standard output.
6.  If no pattern matches, the original command is returned unchanged.

## How to Add a New Normalizer

1.  **Create your normalizer script:**
    -   Create a new script (e.g., `my_normalizer.sh` or `my_normalizer.py`) inside this directory (`/.claude/similarity_hooks/`).
    -   The script must be executable (`chmod +x my_normalizer.sh`).
    -   The script should read a single line of text from standard input (the command to normalize).
    -   It should print a single line of text to standard output (the normalized command).

2.  **Add a rule to the configuration file:**
    -   Open `/.claude/similarity_hooks.json`.
    -   Add a new JSON object to the array with two keys:
        -   `"pattern"`: A regular expression that will trigger your script. Remember to escape backslashes in the JSON string (e.g., `\\d+` for a digit).
        -   `"script"`: The path to your new script, relative to the project root (e.g., `.claude/similarity_hooks/my_normalizer.sh`).
    -   The new rule will be active immediately for the next run.
     

.claude/similarity_hooks/npm_grep.py

#!/usr/bin/env python3
import sys
import shlex

def normalize_command(command: str) -> str:
    if "npm test" not in command:
        return command

    try:
        parts = shlex.split(command)
    except ValueError:
        parts = command.split()

    npm_index = -1
    for i, part in enumerate(parts):
        if part.endswith('/npm') or part == 'npm':
            npm_index = i
            break

    if npm_index == -1:
        return command # npm not found

    try:
        # Look for 'test' after the 'npm' part
        test_index = parts.index('test', npm_index)
    except ValueError:
        return command

    new_parts = []
    skip_next = False
    for i, part in enumerate(parts):
        if skip_next:
            skip_next = False
            continue

        if part == '--grep':
            if i + 1 < len(parts):
                skip_next = True
            continue

        if part.startswith('--grep='):
            continue

        new_parts.append(part)

    normalized_command = ' '.join(new_parts)

    if normalized_command.endswith(' --'):
        normalized_command = normalized_command[:-3].strip()

    return normalized_command

def main():
    # Read command from stdin
    command = sys.stdin.read().strip()

    if not command:
        sys.exit(0)

    normalized_command = normalize_command(command)
    print(normalized_command)

if __name__ == "__main__":
    main()

.claude/similarity_hooks/timeout.sh

#!/bin/bash
read -r cmd
echo "$cmd" | sed -E 's/^(timeout) [0-9]+ (.*)/\1 [timeout_duration] \2/'

View original on GitHub ↗

This issue has 8 comments on GitHub. Read the full discussion on GitHub ↗