یک قلاب PreToolUse در کلاد کد یک برنامه‌ی خط فرمان است که هر دستور را پیش از اجرا می‌بیند و می‌تواند جلوی آن را بگیرد. در این نوشته یک دروازه‌ی واقعی برای فایل‌های اسرار می‌سازیم، آن را با همان JSON رسمی که مستندات نشان می‌دهند روی stdin اجرا می‌کنیم و با یک آزمون منفی نشان می‌دهیم چرا exit 2 با exit 1 فرق دارد.

قلاب دقیقا کجا می‌نشیند

کلاد کد قلاب‌ها را در سه ضرباهنگ می‌کند: یک‌بار در هر نشست، یک‌بار در هر نوبت، و در هر فراخوانی ابزار داخل حلقه‌ی ایجنت. آنچه برای یک دروازه‌ی امنیتی لازم داریم سومی است، یعنی PreToolUse که پس از ساخته‌شدن پارامترهای ابزار و پیش از پردازش فراخوانی اجرا می‌شود.[1][2]

ورودی یک قلاب command از راه stdin می‌آید و یک شیء JSON است. سه کلید برای نوشتن یک دروازه کافی‌اند: hook_event_name که نام رویداد را می‌گوید، tool_name که ابزار را نام می‌برد، و tool_input که آرگومان‌های واقعی همان فراخوانی است.[1][2]

برای ابزار فایل، مسیر همیشه مطلق می‌آید: کلاد کد پیش از اجرای قلاب، ~ و مسیر نسبی را باز می‌کند، پس یک قلاب را نمی‌شود با نوشتن نسبی دور زد. روی ویندوز جداکننده‌ها بک‌اسلش می‌آیند حتی وقتی خود قلاب زیر Git Bash اجرا می‌شود، پس باید جداکننده‌ها را نرمال کنید.[1]

دروازه‌ای که روی فایل اسرار بسته می‌شود

این اسکریپت کل کار ماست. الگوها فقط بخش سخت‌افزاری کار هستند و آنچه دروازه را ارزشمند می‌کند خط پایانی آن است. آن را در مسیر .claude/hooks/ پروژه بگذارید و قابل اجرا کنید.

mkdir -p .claude/hooks
cat > .claude/hooks/guard-secrets.sh <<'HOOK'
#!/bin/bash
# دروازه‌ی PreToolUse: هر دستور Bash که به فایل اسرار دست می‌زند را پیش از اجرا متوقف می‌کند.
# ورودی کلاد کد روی stdin می‌آید؛ اینجا آن را می‌خوانیم و فقط یک تصمیم می‌دهیم.
input=$(cat)
command=$(jq -r '.tool_input.command // ""' <<<"$input")

# مسیرهایی که نباید خوانده یا نوشته شوند
forbidden=('\.env$' '\.env\.' 'id_rsa' 'credentials$')

for pattern in "${forbidden[@]}"; do
  if grep -qE "$pattern" <<<"$command"; then
    # پیام مسدودی روی stderr؛ دلیل مسدودی از همین‌جا به مدل می‌رسد
    echo "مسدود: این دستور به فایل اسرار دست می‌زند -> $command" <&2
    exit 2
  fi
done

# بقیه‌ی دستورها بی‌صدا رد می‌شوند و جریان عادی مجوز ادامه می‌یابد
exit 0
HOOK
chmod +x .claude/hooks/guard-secrets.sh

# یک‌بار اجرا کنید تا مطمئن شوید خطای نحوی ندارد
bash -n .claude/hooks/guard-secrets.sh && echo "نحو درست است"

انتخاب exit 2 تصادفی نیست. مستندات می‌گویند exit 2 تنها کد خروجی است که به‌تنهایی مسدود می‌کند، در حالی که کد ۱ با وجود اینکه در یونیکس خطای متعارف است، خطای غیرمسدودکننده شمرده می‌شود و دستور اجرا می‌شود.[1] پیام مسدودی را روی stderr می‌نویسیم چون متن استاندارد خروجی به مدل نمی‌رسد و فقط در گزارش اشکال‌زدایی می‌ماند.[1]

همین قلاب را با همان JSON رسمی اجرا کنید

اجرای یک قلاب به حساب کاربری مدل نیاز ندارد، چون قلاب فقط یک برنامه‌ی خط فرمان است که بایت‌ها را از stdin می‌گیرد. بنابراین می‌توانید همان قلاب را مستقیم با ورودی‌ای که مستندات نشان می‌دهند اجرا کنید و ببینید دقیقا چه تصمیمی می‌گیرد. راننده‌ی زیر سه حالت را می‌سنجد: دستور بی‌خطر، خواندن فایل اسرار، و همان خواندن پنهان‌شده در $(). هر خط --- exit همان کدی است که کلاد کد از قلاب می‌بیند.

# راننده‌ی آزمایشی: JSON رسمی کلاد کد را روی stdin قلاب می‌ریزد
run_hook () {
  local name="$1" payload="$2"
  printf '%s' "$payload" | bash "$name" > /tmp/h.out 2> /tmp/h.err
  echo "--- exit $?"
  cat /tmp/h.out
  cat /tmp/h.err
}

# ۱) دستور بی‌خطر: بی‌صدا رد می‌شود
run_hook .claude/hooks/guard-secrets.sh "$(jq -nc --arg c 'npm test' \
  '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_use_id:"toolu_01",cwd:"'"$PWD"'",tool_input:{command:$c,description:"Run test suite"}}')"

# ۲) دستور روی فایل اسرار: باید با exit 2 مسدود شود
run_hook .claude/hooks/guard-secrets.sh "$(jq -nc --arg c 'cat .env' \
  '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_use_id:"toolu_02",cwd:"'"$PWD"'",tool_input:{command:$c,description:"Show env file"}}')"

# ۳) دستور پیچیده با $() که مستندات آن را پوشش می‌دهد
run_hook .claude/hooks/guard-secrets.sh "$(jq -nc --arg c 'echo $(cat ~/.ssh/id_rsa)' \
  '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_use_id:"toolu_03",cwd:"'"$PWD"'",tool_input:{command:$c}}')"

$ bash run-demo.sh
--- exit 0
--- exit 2
مسدود: این دستور به فایل اسرار دست می‌زند -> cat .env
--- exit 2
مسدود: این دستور به فایل اسرار دست می‌زند -> echo $(cat ~/.ssh/id_rsa)

این همان چیزی است که مستندات درباره‌ی تطبیق الگوی rm * با دستور داخل $() وعده می‌دهد، اینجا با ورودی واقعی سنجیده شد.[1] یک نکته‌ی عملی هم از همین خروجی درمی‌آید: اگر خروجی قلاب خالی باشد یعنی تصمیمی نگرفته و دستور به جریان عادی مجوز رفته است، پس سکوت یک نشانه‌ی معنادار است و نقص نیست.

آزمون منفی: چرا دروازه‌ی شما ممکن است بی‌اثر باشد

دو حالت هست که در آن قلاب شما روی کاغذ درست کار می‌کند و در عمل هیچ کاری نمی‌کند. اول آنکه کد ۱ برگردانید به‌جای ۲؛ دوم آنکه مسیر اسکریپت در تنظیمات اشتباه باشد. هر دو را اندازه گرفتیم.

payload='{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cat .env"}}'

echo "--- A: exit 1 به‌جای exit 2 ---"
printf '%s' "$payload" | bash -c 'cat > /dev/null; echo "Blocked: rm commands are not allowed" >&2; exit 1'
echo "exit=$?"

echo "--- B: exit 2 درست ---"
printf '%s' "$payload" | bash -c 'cat > /dev/null; echo "Blocked: this reads a secret" >&2; exit 2'
echo "exit=$?"

echo "--- C: مسیر قلاب اشتباه در تنظیمات ---"
printf '%s' "$payload" | .claude/hooks/does-not-exist.sh
echo "exit=$?"

$ bash negative-test.sh
--- A: exit 1 به‌جای exit 2 ---
Blocked: rm commands are not allowed
exit=1
--- B: exit 2 درست ---
Blocked: this reads a secret
exit=2
--- C: مسیر قلاب اشتباه در تنظیمات ---
negative-test.sh: line 15: /root/.hermes/cache/scratch/hookdemo/does-not-exist.sh: No such file or directory
exit=127

حالت الف کد ۱ برمی‌گرداند، یعنی خطای غیرمسدودکننده که طبق مستندات یعنی دستور اجرا می‌شود و فقط یک اعلان hook error در رکورد نشست دیده می‌شود. حالت ب کد ۲ می‌دهد و تنها همین کد مسدود می‌کند.[1] کد ۱۲۷ یعنی «فایل پیدا نشد» و باز هم غیرمسدودکننده است، پس یک اشتباه تایپی در مسیر، دروازه را بی‌صدا از کار می‌اندازد و شما هرگز متوجه نمی‌شوید.[1] برای همین توصیه‌ی مستندات روشن است: اگر قلاب قرار است سیاستی را اجرا کند باید exit 2 بدهد، و اگر می‌خواهید سیاست را تضمین کنید به‌جای قلاب از سیستم مجوز استفاده کنید، چون فیلترها بهترین تلاش هستند و ممکن است دور زده شوند.[1][7]

قلاب را کجا ثبت کنیم و چه چیزی را باید دانست

محل تعریف قلاب، دامنه‌ی آن را تعیین می‌کند. فایل ~/.claude/settings.json همه‌ی پروژه‌ها را پوشش می‌دهد و به ریپو اضافه نمی‌شود، و فایل .claude/settings.json برای یک پروژه است و در گیت commit می‌شود.[1] ورودی‌های قلاب‌ها بین سطوح تنظیمات با هم جمع می‌شوند و جای یکدیگر را نمی‌گیرند، پس قلاب پروژه‌ای شما قلاب کاربری شما را حذف نمی‌کند.[1]

قلاب‌هایی که از فایل‌های تنظیمات می‌آیند داخل ایجنت‌های فرعی هم اجرا می‌شوند و ورودی، agent_id و agent_type را برای شناسایی ایجنت فرعی حمل می‌کند.[1] برای ارجاع به اسکریپت با مسیر پروژه، مستندات پیشنهاد می‌کند از فرم اجرایی با args استفاده کنید، چون در آن حالت هر عنصر به‌عنوان یک آرگومان مستقل و بدون پوشش‌دهی واژگانی به فرزند داده می‌شود.[1] محدودیت زمانی پیش‌فرض یک قلاب command شصت ثانیه است و در رویداد UserPromptSubmit به سی ثانیه کاهش می‌یابد.[1]

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-secrets.sh"],
            "timeout": 5
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/report-branch.sh"]
          }
        ]
      }
    ]
  }
}

همان فرم اجرایی یک کار دیگر هم می‌کند: اسکریپت می‌تواند وضعیت محیط را به کانتکست مدل تزریق کند، بدون آنکه در پرامپت چیزی بنویسید. قلاب دوم در آغاز هر نشست شاخه و تعداد فایل‌های تغییرنکرده را می‌فرستد و همین رشته به‌صورت یادآور سیستمی به مدل می‌رسد.[1] اجرای واقعی آن روی یک ریپوی گیت با شاخه‌ی master این خروجی را داد.

#!/bin/bash
# وضعیت ریپو را به کانتکست مدل تزریق می‌کند، در آغاز هر نشست
input=$(cat)
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "not-a-repo")
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')

jq -nc --arg ctx "این نشست روی شاخه‌ی $branch است و $dirty فایل تغییرنکرده دارد." '{
  hookSpecificOutput: {
    hookEventName: "SessionStart",
    additionalContext: $ctx
  }
}'

$ bash report-branch.sh < session-start.json
{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"این نشست روی شاخه‌ی master است و 2 فایل تغییرنکرده دارد."}}

برای اینکه قلاب‌ها در نشست‌های ابری هم اجرا شوند، آن‌ها را به‌جای تنظیمات محلی در سطح پروژه یا در پلاگین بگذارید، چون نشست ابری تنظیمات محلی شما را نمی‌خواند.[1] اگر سازمان شما قلاب‌های مدیریت‌شده را فعال کرده باشد، allowManagedHooksOnly قلاب‌های کاربری، پروژه‌ای و محلی شما را مسدود می‌کند.[1]

اگر می‌خواهید مرز دسترسی را در سطح سیستم‌عامل ببندید نه در سطح اسکریپت، روش سندباکس را در قفل کردن سندباکس کلاد کد توضیح داده‌ام؛ آن دو لایه همدیگر را کامل می‌کنند، چون قلاب تصمیم می‌گیرد و سندباکس تضمین می‌کند. اگر هم دستورهای کهنه در پرامپت پروژه دارید، ممیزی prompt-audit پیش از آن‌ها دستورهای مخصوص مدل‌های قدیمی را پیدا می‌کند، چون قلاب فقط اجازه‌ی فراخوانی را تصمیم می‌کند و درباره‌ی کیفیت دستور چیزی نمی‌گوید.

منابع

  1. مرجع قلاب‌های کلاد کد
  2. همان مرجع در قالب مارک‌داون
  3. راهنمای ساخت قلاب
  4. مرجع تنظیمات
  5. ترتیب خواندن فایل‌های تنظیمات
  6. تاریخچه‌ی نسخه‌های کلاد کد
  7. سیستم مجوز و قواعد deny
  8. نمونه‌ی مرجع اعتبارسنج دستور Bash
  9. پیکربندی سندباکس