Skip to content

Instantly share code, notes, and snippets.

@silenvx
Created February 23, 2026 03:06
Show Gist options
  • Select an option

  • Save silenvx/297d9b0ac4ad4601e79b95707eea1351 to your computer and use it in GitHub Desktop.

Select an option

Save silenvx/297d9b0ac4ad4601e79b95707eea1351 to your computer and use it in GitHub Desktop.
PreToolUse hook: スキル固有の Bash 自動承認 — https://zenn.dev/ux_xu/articles/4f57169b0dd820
#!/usr/bin/env python3
"""PreToolUse hook: スキル固有の Bash コマンドを自動承認する.
SKILL.md の hooks フロントマターから呼び出され、
そのスキルがアクティブな間だけ allowed-tools の Bash パターンを承認する。
使い方:
approve-skill-bash.py <path-to-SKILL.md>
"""
import json
import os
import re
import sys
from pathlib import Path
if len(sys.argv) < 2:
sys.exit(0)
skill_md = Path(sys.argv[1])
if skill_md.name != "SKILL.md" or not skill_md.is_file():
sys.exit(0)
try:
data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
sys.exit(0)
if data.get("tool_name") != "Bash":
sys.exit(0)
command = data.get("tool_input", {}).get("command", "")
# Claude Code が付与するリダイレクト・エラーハンドリングを除去
command = re.sub(
r"\s+2>&1(?:\s+\|\|\s+echo\s+\"---EXIT:\s+\$\?\"\s*)?$", "", command
)
# run_in_background が付与する sleep N && プレフィックスを除去
command = re.sub(r"^sleep[ \t]+\d+[ \t]*&&[ \t]*", "", command)
# シェル行継続 (backslash + newline + 任意の水平空白) を単一スペースに正規化
command = re.sub(r'\\\n[ \t]*', ' ', command)
# シェルメタ文字を含むコマンドは承認しない
_SHELL_METACHAR = re.compile(r"[;&|$`<>(){}#\n\r\x00]")
if not command:
sys.exit(0)
# クォート内はシェル展開されないため、メタ文字チェックから除外
# DQ は展開文字 ($, `, \) を含まないもののみ除去
_unquoted = re.sub(r"'[^']*'", "", command)
_unquoted = re.sub(r'"[^"$`\\]*"', "", _unquoted)
if _SHELL_METACHAR.search(_unquoted):
sys.exit(0)
_HOME = os.path.expanduser("~")
# ~/path → 絶対パスに正規化(パターン側も同様に展開して比較する)
command = command.replace("~/", _HOME + "/")
# CWD の絶対パスプレフィックスを除去して相対パスに正規化
# Claude Code が相対パスパターンに対して絶対パスを使う場合に対応
_CWD = os.getcwd()
if _CWD != "/":
if not _CWD.endswith("/"):
_CWD += "/"
command = command.replace(_CWD, "")
def _glob_to_regex(pattern: str) -> re.Pattern:
"""glob パターン (* のみ) を正規表現に変換する."""
# ~/path → /Users/.../path に展開して絶対パスでもマッチさせる
pattern = pattern.replace("~/", _HOME + "/")
parts = pattern.split("*")
return re.compile("^" + ".*".join(re.escape(p) for p in parts) + "$")
def _extract_bash_patterns(path: Path) -> list[re.Pattern]:
"""SKILL.md の frontmatter から Bash allowed-tools パターンを抽出する."""
patterns: list[re.Pattern] = []
try:
text = path.read_text()
except OSError:
return patterns
if not text.startswith("---"):
return patterns
end = text.find("---", 3)
if end == -1:
return patterns
frontmatter = text[3:end]
in_allowed_tools = False
for line in frontmatter.split("\n"):
stripped = line.strip()
if stripped.startswith("allowed-tools:"):
in_allowed_tools = True
continue
if in_allowed_tools:
if stripped.startswith("- "):
value = stripped[2:].strip()
m = re.match(r"^Bash\((.+)\)$", value)
if m:
patterns.append(_glob_to_regex(m.group(1)))
elif stripped and not stripped.startswith("#"):
in_allowed_tools = False
return patterns
skill_name = skill_md.parent.name
patterns = _extract_bash_patterns(skill_md)
if patterns and any(p.match(command) for p in patterns):
json.dump(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": f"Skill '{skill_name}' allowed-tools match",
}
},
sys.stdout,
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment