Created
February 23, 2026 03:06
-
-
Save silenvx/297d9b0ac4ad4601e79b95707eea1351 to your computer and use it in GitHub Desktop.
PreToolUse hook: スキル固有の Bash 自動承認 — https://zenn.dev/ux_xu/articles/4f57169b0dd820
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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