ClaudeSuperPower
P

PreToolUse (Bash)

Hook

Fires on

PreToolUsematchingBash

Immediately before a tool runs — this is where an action can be denied.

Where this sits in a session

  1. Session start
  2. Prompt submitted
  3. Prompt expansion
  4. Before tool use
  5. After tool use
  6. Notification
  7. Before compaction
  8. Subagent finished
  9. Turn finished
  10. Session end

Can blockThis hook is able to deny the action or send Claude back to work.

What it runs

Executed automatically when the event fires — no prompt, no confirmation.

command· timeout 5000s
python3 ${CLAUDE_PLUGIN_ROOT}/shared/hooks/scripts/guardrails.py

Install it yourself

Paste into .claude/settings.json to run this hook without installing the whole plugin. Adjust the paths to point at your own copy of the scripts.

settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 ${CLAUDE_PLUGIN_ROOT}/shared/hooks/scripts/guardrails.py",
            "timeout": 5000
          }
        ],
        "matcher": "Bash"
      }
    ]
  }
}

Source

Read this before installing — it runs on your machine with your permissions.

#!/usr/bin/env python3
"""PreToolUse Guardrails Hook for agentforce-adlc.

BLOCKING + AUTO-FIX guardrails that run BEFORE dangerous operations execute.

Severity levels:
- CRITICAL (BLOCK): DELETE/UPDATE without WHERE, hardcoded credentials, force push
- HIGH (AUTO-FIX): Reserved for future patterns
- MEDIUM (WARN): Hardcoded IDs, deprecated commands, old API versions

ADLC-specific patterns:
- Block sf agent publish without --json
- Block sf project deploy start on .agent files (must use sf agent publish)
- Block DELETE FROM / UPDATE SET without WHERE
"""

import json
import re
import sys
from pathlib import Path

SF_PROJECT_MARKERS = ("sfdx-project.json", "force-app", "aiAuthoringBundles")


def is_salesforce_project(start: Path | None = None) -> bool:
    """Return True if cwd or any ancestor contains a Salesforce project marker.

    Used as a project-relevance gate so this hook is a no-op when the plugin is
    installed globally but the user is working in an unrelated project.
    """
    p = (start or Path.cwd()).resolve()
    for d in (p, *p.parents):
        if any((d / m).exists() for m in SF_PROJECT_MARKERS):
            return True
    return False


try:
    from stdin_utils import read_stdin_safe
except ImportError:
    def read_stdin_safe(timeout_seconds=0.1):
        if sys.stdin.isatty():
            return {}
        try:
            return json.load(sys.stdin)
        except Exception:
            return {}


CRITICAL_PATTERNS = [
    {
        "pattern": r"DELETE\s+FROM\s+\w+\b(?!\s+WHERE\b)",
        "message": "DELETE without WHERE clause — will delete ALL records",
        "suggestion": "Add WHERE clause: DELETE FROM Object WHERE Id = 'xxx'",
    },
    {
        "pattern": r"UPDATE\s+\w+\s+SET\s+(?!.*WHERE)",
        "message": "UPDATE without WHERE clause — will update ALL records",
        "suggestion": "Add WHERE clause: UPDATE Object SET Field = 'val' WHERE Id = 'xxx'",
    },
    {
        "pattern": r"(?:api[_-]?key|secret|password|token)\s*[=:]\s*['\"][a-zA-Z0-9]{16,}['\"]",
        "message": "Hardcoded credentials detected",
        "suggestion": "Use environment variables instead",
    },
    {
        "pattern": r"sf\s+(?:project\s+)?deploy\s+(?:start|preview)?.*--target-org\s+(?:prod|production)[^-]*$",
        "message": "Production deployment without --dry-run",
        "suggestion": "Add --dry-run flag for validation first",
    },
    {
        "pattern": r"git\s+push\s+(?:--force|-f)\s+(?:origin\s+)?(?:main|master)",
        "message": "Force push to main/master",
        "suggestion": "Use --force-with-lease or push to a branch",
    },
    {
        "pattern": r"DROP\s+(?:TABLE|DATABASE)\s+",
        "message": "DROP TABLE/DATABASE detected",
        "suggestion": "Use DELETE with backup instead",
    },
    # ADLC-specific: block sf agent publish without --json
    {
        "pattern": r"sf\s+agent\s+publish\s+(?!.*--json)",
        "message": "sf agent publish without --json — output may be unparseable",
        "suggestion": "Add --json flag: sf agent publish authoring-bundle --api-name X --json",
    },
    # ADLC-specific: block deploying .agent files via sf project deploy
    {
        "pattern": r"sf\s+project\s+deploy\s+start\s+.*\.agent\b",
        "message": "Cannot deploy .agent files with sf project deploy — use sf agent publish",
        "suggestion": "Use: sf agent publish authoring-bundle --api-name <Name> --target-org <org> --json",
    },
]

MEDIUM_PATTERNS = [
    {
        "pattern": r"['\"](?:001|003|005|006|00D|00e|500|a0[0-9A-Z])[a-zA-Z0-9]{12,15}['\"]",
        "message": "Hardcoded Salesforce ID detected — IDs vary between environments",
        "suggestion": "Use dynamic queries or Named Credentials",
    },
    {
        "pattern": r"\bsfdx\b",
        "message": "Deprecated SFDX command detected",
        "suggestion": "Use 'sf' commands instead of 'sfdx'",
    },
    {
        "pattern": r"--api-version\s+(?:[1-4]\d|5[0-5])\b",
        "message": "Old API version detected (< v56)",
        "suggestion": "Use API v66+ for latest features",
    },
    # Warn when deploying or publishing to production-named orgs
    {
        "pattern": r"sf\s+(?:agent\s+publish|project\s+deploy)\s+.*(?:--target-org|-o)\s+(?:prod|production|live)\b",
        "message": "Deploying to production-named org — verify this is intentional",
        "suggestion": "Use a sandbox or scratch org for development. If this is production, ensure safety review has been completed.",
    },
]


def is_output_only_command(command: str) -> bool:
    """Check if command is just outputting text (not executing DML)."""
    patterns = [r'^\s*echo\s+', r'^\s*printf\s+', r'^\s*cat\s*<<', r'^\s*print\s+']
    return any(re.search(p, command, re.IGNORECASE) for p in patterns)


def is_sf_context(command: str) -> bool:
    """Check if command is Salesforce-related."""
    indicators = [
        r'\bsf\b', r'\bsfdx\b', r'SELECT\s+', r'DELETE\s+FROM', r'UPDATE\s+\w+\s+SET',
        r'force-app', r'\.cls\b', r'\.agent\b', r'\.flow-meta',
        r'--target-org', r'apex\s+run', r'data\s+query',
    ]
    return any(re.search(p, command, re.IGNORECASE) for p in indicators)


def main():
    if not is_salesforce_project():
        print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}))
        sys.exit(0)

    input_data = read_stdin_safe(timeout_seconds=0.1)
    if not input_data:
        print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}))
        sys.exit(0)

    tool_name = input_data.get("tool_name", "")
    tool_input = input_data.get("tool_input", {})

    if tool_name != "Bash":
        print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}))
        sys.exit(0)

    command = tool_input.get("command", "")
    if not command or not is_sf_context(command) or is_output_only_command(command):
        print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}))
        sys.exit(0)

    # Check CRITICAL patterns (BLOCK)
    for rule in CRITICAL_PATTERNS:
        if re.search(rule["pattern"], command, re.IGNORECASE):
            output = {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": rule["message"],
                    "additionalContext": f"BLOCKED: {rule['message']}\nSuggestion: {rule['suggestion']}",
                }
            }
            print(json.dumps(output))
            sys.exit(0)

    # Check MEDIUM patterns (WARN)
    warnings = []
    for rule in MEDIUM_PATTERNS:
        if re.search(rule["pattern"], command, re.IGNORECASE):
            warnings.append(f"Warning: {rule['message']} — {rule['suggestion']}")

    if warnings:
        output = {
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "allow",
                "additionalContext": "\n".join(warnings),
            }
        }
        print(json.dumps(output))
        sys.exit(0)

    print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}))
    sys.exit(0)


if __name__ == "__main__":
    main()

Shipped by 1 plugin

Installing any of these installs this hook.

Reviews

Log in to leave a review.

No reviews yet — be the first.

Explore related

Other things in this space — across every part of the ecosystem, not just hooks.