Skip to content

Instantly share code, notes, and snippets.

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

  • Save silenvx/5334afee5cd77e2dfcc1be4e943ad43b to your computer and use it in GitHub Desktop.

Select an option

Save silenvx/5334afee5cd77e2dfcc1be4e943ad43b to your computer and use it in GitHub Desktop.
PermissionRequest hook: 複数行コマンドの自動承認 — https://zenn.dev/ux_xu/articles/4f57169b0dd820
#!/usr/bin/env python3
"""PermissionRequest hook: 安全なコマンドパターンを自動承認する.
改行を含むコマンドが permissions.allow のパターンにマッチしない問題の
ワークアラウンド (https://github.com/anthropics/claude-code/issues/11932).
"""
import json
import re
import sys
try:
data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
sys.exit(0)
command = data.get("tool_input", {}).get("command", "")
# Claude Code が付与する 2>&1 / 2>&1 || echo "---EXIT: $?" を除去してから評価
# パイプ前・行継続前・末尾のいずれの位置でも除去
command = re.sub(
r"\s+2>&1(?:\s+\|\|\s+echo\s+\"---EXIT:\s+\$\?\"\s*)?(?=\s*[\\|]|$)",
"", command,
)
# シェル行継続 (backslash + newline + 任意の水平空白) を単一スペースに正規化
command = re.sub(r'\\\n[ \t]*', ' ', command)
# || true / || : は終了ステータスを 0 にするだけで副作用なし
command = re.sub(r'\s*\|\|\s*(?:true|:)\s*$', '', command)
# パス文字の allowlist (シェルメタ文字を排除)
_SAFE_PATH_CHARS = r'[\w./@:~+-]+'
_SAFE_PATH = rf'(?:"[\w\s./@:~+-]+"|{_SAFE_PATH_CHARS})'
# cd <safe-path> && のオプショナルプレフィックス
_CD_PREFIX = rf'(?:cd\s+{_SAFE_PATH}\s*&&\s*)?'
# git add <files> && のオプショナルプレフィックス
_GIT_ADD_PREFIX = (
rf'(?:git\s+(?:-C\s+{_SAFE_PATH}\s+)?'
r'add\s+[^\n\r\x00&|;$`<>()]+\s*&&\s*)?'
)
_GIT_COMMIT_BASE = (
r'git\s+'
rf'(?:-C\s+{_SAFE_PATH}\s+)?'
r'commit'
r'(?:\s+--(?!amend\b)[\w-]+)*'
)
_GIT_STATUS_SUFFIX = (
r'(?:[ \t]*&&[ \t]*git[ \t]+'
rf'(?:-C[ \t]+{_SAFE_PATH}[ \t]+)?'
r'status(?:[ \t]+[\w./@:~+-]*)*)?'
r'[ \t]*$'
)
# AI CLI: SQ 状態を文字単位で追跡しクォート外のメタ文字を拒否
_CODEX_SANDBOX = r'(?:(?:-s|--sandbox)[ \t]+[\w-]+[ \t]+)?'
_AI_CLI_PREFIX = re.compile(
r'^(?:'
rf'codex[ \t]+exec[ \t]+--skip-git-repo-check[ \t]+{_CODEX_SANDBOX}'
rf'|codex[ \t]+--cd[ \t]+{_SAFE_PATH_CHARS}[ \t]+exec[ \t]+--skip-git-repo-check[ \t]+{_CODEX_SANDBOX}'
r'|gemini[ \t]+(?:-s[ \t]+)?-p[ \t]+'
rf'|cursor-agent[ \t]+-p[ \t]+(?:--trust[ \t]+)?(?:--model[ \t]+[\w.-]+[ \t]+)?(?:--workspace[ \t]+[/~.]{_SAFE_PATH_CHARS}[ \t]+)?'
r')',
re.ASCII,
)
_AI_CLI_SUFFIX = re.compile(
r'(?:[ \t]+--[\w-]+(?:[ \t]+[\w./@:~+,-]+)?)*[ \t]*$',
re.ASCII,
)
# SQ 外で危険なシェルメタ文字 (# はコメント開始によるコマンド切り捨て)
_SQ_OUTSIDE_DANGER = re.compile(r'[;\n\r\x00&|$`<>(){}#]')
def _is_safe_ai_cli(command: str) -> bool:
"""AI CLI (codex/gemini/cursor-agent) コマンドの安全性をパーサーベースで検証.
SQ 内はリテラルで安全。クォート外の文字列にシェルメタ文字が
含まれていないことを検証する。'\'' イディアムもサポート。
"""
m = _AI_CLI_PREFIX.match(command)
if not m:
return False
rest = command[m.end():]
if not rest or rest[0] != "'":
return False
in_sq = False
i = 0
last_close = -1
while i < len(rest):
ch = rest[i]
if ch == "'":
if not in_sq:
in_sq = True
i += 1
else:
in_sq = False
last_close = i
# '\'' idiom: close-quote + \' + open-quote
if i + 2 < len(rest) and rest[i + 1 : i + 3] == "\\'":
i += 3
if i < len(rest) and rest[i] == "'":
in_sq = True
i += 1
# '"'"' idiom: close-quote + "'" + open-quote
elif (i + 4 < len(rest)
and rest[i + 1 : i + 5] == "\"'\"'"):
in_sq = True
i += 5
else:
i += 1
elif not in_sq:
if _SQ_OUTSIDE_DANGER.search(ch):
return False
i += 1
else:
i += 1
if in_sq:
return False
return bool(_AI_CLI_SUFFIX.match(rest[last_close + 1 :]))
# --help / --version で終わるコマンドは副作用なし (permissions.allow の * --help 相当)
# サフィックス除去済みのコマンドに対して評価される
_SAFE_HELP_VERSION = re.compile(
r'^[\w][\w./-]*(?:\s+[\w][\w./@:~+-]*)*\s+(?:--help|--version)\s*$',
re.ASCII,
)
# npm audit: fix サブコマンドを除外し、フラグと signatures のみ許可
_SAFE_NPM_AUDIT = re.compile(
r'^npm\s+audit(?:\s+signatures)?(?:\s+--[\w-]+(?:=\S+)?)*\s*$',
re.ASCII,
)
# ホワイトリスト: コマンド全体が期待される構造に完全一致する場合のみ承認
# $ アンカーにより末尾に ; | && 等があれば不一致 → 拒否
PATTERNS = [
# [cd <path> &&] [git add ... &&] git [-C <path>] commit -m "$(cat <<'EOF'\n...\nEOF\n)"
# (?:(?!\)").)*? でコンテンツ内の )" (heredoc 脱出) を禁止し二重 EOF injection を防止
re.compile(
rf'^{_CD_PREFIX}{_GIT_ADD_PREFIX}'
rf'{_GIT_COMMIT_BASE}'
r'\s+-m\s+"\$\(cat\s+<<\'EOF\'\n'
r'(?:(?!\)").)*?'
rf'\n[ \t]*EOF\n[ \t]*\)"{_GIT_STATUS_SUFFIX}',
re.DOTALL,
),
# [cd <path> &&] [git add ... &&] git [-C <path>] commit -m "直接複数行メッセージ"
# [^"$`]* でダブルクォート内のコマンド置換を防ぐ (heredoc 'EOF' は展開されないため不要)
re.compile(
rf'^{_CD_PREFIX}{_GIT_ADD_PREFIX}'
rf'{_GIT_COMMIT_BASE}'
rf'\s+-m\s+"[^"$`]*"{_GIT_STATUS_SUFFIX}',
re.DOTALL,
),
# for <var> in <paths>; do echo "$(wc -l < "$<var>") $<var>"; done [| sort -n]
# 後方参照 \1 でループ変数の一致を検証し、ボディ構造を固定
# re.ASCII: \w を [a-zA-Z0-9_] に限定しシェル変数名定義と一致させる
re.compile(
r'^for\s+(\w+)\s+in'
rf'(?:\s+{_SAFE_PATH_CHARS})+'
r';\s*do\s+echo\s+"\$\(wc\s+-l\s+<\s+"\$\1"\)\s+\$\1";\s*done'
r'(?:\s*\|\s*sort\s+-[nr]+)?\s*$',
re.ASCII,
),
]
# 安全な読み取り専用コマンドの複合パイプライン自動承認
# \s は \n を含みシェル制御文字として悪用されるため、水平空白 [ \t] のみ使用
_RO_CMDS = (
'basename', 'dirname', 'echo', 'file', 'id', 'ls', 'pwd',
'readlink', 'sleep', 'stat', 'tree', 'uname', 'wc', 'which', 'whoami',
)
_RO_CMD = r'(?:' + '|'.join(_RO_CMDS) + r')'
# re.ASCII: \w を [a-zA-Z0-9_] に制限しシェル変数名定義と一致させる
_RO_BARE = r'[\w./@:~+,=-]+'
_RO_DQ = r'"(?:[^"$`\\]|\$\?)*"'
_RO_SQ = r"'[^']*'"
_RO_ARG = rf'(?:{_RO_BARE}|{_RO_DQ}|{_RO_SQ})'
_RO_REDIR = r'(?:[ \t]+2>(?:&1|[ \t]*/dev/null))?'
_RO_SEG = rf'{_RO_CMD}(?:[ \t]+{_RO_ARG})*{_RO_REDIR}'
# パイプフィルタ: stdin のみ読み取り、ファイル引数不可
# 短フラグ (-n, -rn, -5) と数値 (5, 2,3) のみ許可しパス引数を排除
# 長フラグ (--output=file) は先頭 -- の2文字目が [a-zA-Z0-9] 外のため不一致
_PF_CMD = r'(?:base64|head|tail|sort|wc)'
_PF_ARG = r'(?:-[a-zA-Z0-9]+|[\d,]+)'
_PF = rf'{_PF_CMD}(?:[ \t]+{_PF_ARG})*{_RO_REDIR}'
# macOS (BSD) base64 は -i <file> / -o <file> でファイル I/O 可能
# getopt 仕様により -ifoo / -ofoo (スペースなし) も有効
_BASE64_DANGEROUS_RE = re.compile(
r'(?:^|[ \t])(?:-[a-zA-Z]*[io][a-zA-Z]*|--input\b|--output\b)',
re.ASCII,
)
# npm audit パイプライン: _GPF 定義後に宣言 (grep/sed もパイプフィルタとして許可)
def _is_safe_readonly_compound(command: str) -> bool:
"""RO/cd/git read-only/git mv コマンドの &&/|| チェーン安全性を検証."""
if not _SAFE_COMPOUND_CHAIN.match(command):
return False
parts = _split_pipe(command)
main_chain = parts[0]
for seg in _split_chain(main_chain):
seg = seg.strip()
if seg.startswith('git') and _GIT_DANGEROUS_RE.search(seg):
return False
if _has_dangerous_pipe_filter(parts[1:]):
return False
return True
# Git 読み取り専用パイプライン自動承認
# git show/diff/log の出力を安全なフィルタにパイプする構造のみ許可
# &&/|| は禁止(パイプ | のみ)
_GIT_RO_SUBCMDS = (
r'(?:show|diff|log|ls-tree'
r'|blame|describe|for-each-ref|ls-files'
r'|name-rev|rev-list|rev-parse|shortlog)'
)
_GIT_DANGEROUS_RE = re.compile(
r'(?:^|\s)(?:--output\b|--ext-diff\b|--exec-path\b|--pager\b|--contents\b|--no-index\b)',
re.ASCII,
)
_GIT_FLAG_VAL = rf"[\w./@:~+,=%-]*(?:{_RO_SQ}|{_RO_DQ})?[\w./@:~+,=%-]*"
_GIT_RO_FLAG = rf'--?[\w][\w-]*(?:={_GIT_FLAG_VAL})?'
_GIT_RO_SEG = (
r'git'
rf'(?:[ \t]+-C[ \t]+{_RO_ARG})?'
rf'[ \t]+{_GIT_RO_SUBCMDS}'
rf'(?:[ \t]+(?:{_GIT_RO_FLAG}|{_RO_ARG}))*'
rf'{_RO_REDIR}'
)
_SED_FLAG = r'-[ne]+'
_SED_SCRIPT = rf'(?:{_RO_SQ}|{_RO_DQ})'
_SED_ATOM = rf'(?:{_SED_FLAG}|{_SED_SCRIPT})'
_SED_SEG = rf'sed(?:[ \t]+{_SED_ATOM})+'
_GREP_FLAGS = r'-[EeivconlPqxswHhoABCm\d]+'
_GREP_FARG = rf'(?:{_GREP_FLAGS}(?:[ \t]+[\d]+)?)'
# grep BRE/ERE パターンはバックスラッシュを多用する (\|, \(, \+ 等)
# シェル DQ 内の \ は "$`"\ の前でのみ特殊なため \| 等はリテラルで安全
# \\. は \x → 2文字消費し、\" で DQ 境界を誤認しない
# sed の _SED_FILE_IO 抽出器は _RO_DQ 前提のため、この緩和は grep 専用に限定
_GREP_DQ = r'"(?:[^"$`\\]|\\.)*"'
# パターン位置に -- ロングオプションが来ないよう負の先読みで排除
_GREP_PATTERN = rf'(?:{_RO_SQ}|{_GREP_DQ}|(?!--){_RO_BARE})'
_GREP_SEG = (
r'grep'
rf'(?:[ \t]+{_GREP_FARG})*'
rf'(?:[ \t]+{_GREP_PATTERN})?'
)
_GPF = rf'(?:{_PF}|{_SED_SEG}|{_GREP_SEG})'
# npm audit パイプライン: 安全なフィルタへのパイプのみ許可 (&&/|| は禁止)
_NPM_AUDIT_PIPELINE = re.compile(
r'^npm[ \t]+audit(?:[ \t]+signatures)?(?:[ \t]+--[\w-]+(?:=\S+)?)*'
rf'(?:[ \t]*\|[ \t]*{_GPF})+[ \t]*$',
re.ASCII,
)
_GIT_RO_PIPELINE = re.compile(
rf'^[ \t]*{_GIT_RO_SEG}'
rf'(?:[ \t]*\|[ \t]*{_GPF})*'
r'[ \t]*$',
re.ASCII,
)
_SED_FILE_IO = re.compile(r'[wWrRe][ \t]', re.ASCII)
# sed 置換コマンドの e フラグ: s/pat/repl/e でパターンスペースをシェル実行
_SED_SUBST_E = re.compile(r's(.).*?\1.*?\1\w*e', re.ASCII)
def _split_pipe(command: str) -> list[str]:
"""クォート外の | でコマンドをパイプセグメントに分割."""
segments = []
current: list[str] = []
in_sq = in_dq = False
i = 0
while i < len(command):
c = command[i]
if c == "'" and not in_dq:
in_sq = not in_sq
current.append(c)
i += 1
elif c == '\\' and in_dq and i + 1 < len(command):
# DQ 内の \x → 2文字消費 (\" で DQ 境界を誤認しない)
current.append(c)
current.append(command[i + 1])
i += 2
elif c == '"' and not in_sq:
in_dq = not in_dq
current.append(c)
i += 1
elif c == '|' and not in_sq and not in_dq:
segments.append(''.join(current))
current = []
i += 1
else:
current.append(c)
i += 1
segments.append(''.join(current))
return segments
def _split_chain(command: str) -> list[str]:
"""クォート外の &&/||/; でコマンドをチェーンセグメントに分割."""
segments = []
current: list[str] = []
in_sq = in_dq = False
i = 0
while i < len(command):
ch = command[i]
if ch == "'" and not in_dq:
in_sq = not in_sq
current.append(ch)
i += 1
elif ch == '\\' and in_dq and i + 1 < len(command):
# DQ 内の \x → 2文字消費 (\" で DQ 境界を誤認しない)
current.append(ch)
current.append(command[i + 1])
i += 2
elif ch == '"' and not in_sq:
in_dq = not in_dq
current.append(ch)
i += 1
elif not in_sq and not in_dq:
if ch == ';':
segments.append(''.join(current))
current = []
i += 1
elif (ch == '&' and i + 1 < len(command)
and command[i + 1] == '&'):
segments.append(''.join(current))
current = []
i += 2
elif (ch == '|' and i + 1 < len(command)
and command[i + 1] == '|'):
segments.append(''.join(current))
current = []
i += 2
else:
current.append(ch)
i += 1
else:
current.append(ch)
i += 1
segments.append(''.join(current))
return segments
def _check_sed_script(content: str) -> bool:
"""sed スクリプト内容が危険なら True を返す."""
if _SED_FILE_IO.search(content):
return True
if _SED_SUBST_E.search(content):
return True
return False
def _has_dangerous_pipe_filter(segments: list[str]) -> bool:
"""パイプセグメント内の sed/base64 が危険なら True を返す."""
for seg in segments:
stripped = seg.strip()
if stripped.startswith('sed'):
for m in re.finditer(r"'([^']*)'", stripped):
if _check_sed_script(m.group(1)):
return True
for m in re.finditer(r'"([^"$`\\]*)"', stripped):
if _check_sed_script(m.group(1)):
return True
if stripped.startswith('base64'):
if _BASE64_DANGEROUS_RE.search(stripped):
return True
return False
# gh api 読み取り専用パイプライン自動承認
# 書き込みフラグがない gh api GET リクエストのパイプラインのみ許可
# &&/|| は禁止(パイプ | のみ)
_GH_API_DANGEROUS_RE = re.compile(
r'(?:^|[ \t])'
r'(?:-X[ \t]*\S|--method\b|-[fF]|--raw-field\b|--field\b|--input\b|--hostname\b)',
re.ASCII,
)
_GH_API_GRAPHQL_RE = re.compile(
r'gh[ \t]+api[ \t]+["\']*/?graphql\b', re.ASCII,
)
# jq パイプセグメント: ファイル読み取りフラグ (-f/--from-file, --slurpfile, --rawfile) をブロック
_JQ_DANGEROUS_RE = re.compile(
r'(?:^|[ \t])(?:-[fL]|--from-file\b|--slurpfile\b|--rawfile\b|--include-path\b)',
re.ASCII,
)
_JQ_FLAG = r'-[a-zA-Z0-9]+'
_JQ_FILTER = rf'(?:{_RO_SQ}|{_RO_DQ}|\.[^ \t|]*)'
_JQ_SEG = rf'jq(?:[ \t]+{_JQ_FLAG})*(?:[ \t]+{_JQ_FILTER})?'
_GH_API_BARE_EP = r'/?[a-zA-Z0-9_][a-zA-Z0-9_.{}/~:+?=-]*'
_GH_API_ENDPOINT = rf'(?:{_RO_DQ}|{_RO_SQ}|{_GH_API_BARE_EP})'
_GH_API_FLAG = rf'--?[\w][\w-]*(?:[= \t]+{_RO_ARG})?'
_GH_API_RO_SEG = (
r'gh[ \t]+api'
rf'[ \t]+{_GH_API_ENDPOINT}'
rf'(?:[ \t]+{_GH_API_FLAG})*'
rf'{_RO_REDIR}'
)
_GHPF = rf'(?:{_GPF}|{_JQ_SEG})'
_GH_API_RO_PIPELINE = re.compile(
rf'^[ \t]*{_GH_API_RO_SEG}'
rf'(?:[ \t]*\|[ \t]*{_GHPF})*'
r'[ \t]*$',
re.ASCII,
)
# 安全なコマンドチェーン自動承認 (RO commands, cd, git read-only, git mv の &&/|| チェーン)
_CD_SEG = rf'cd[ \t]+{_RO_ARG}'
_GIT_COMPOUND_SUBCMDS = (
r'(?:show|diff|log|status|ls-tree'
r'|blame|describe|for-each-ref|ls-files'
r'|name-rev|rev-list|rev-parse|shortlog)'
)
_GIT_COMPOUND_SEG = (
r'git'
rf'(?:[ \t]+-C[ \t]+{_RO_ARG})?'
rf'[ \t]+{_GIT_COMPOUND_SUBCMDS}'
rf'(?:[ \t]+(?:{_GIT_RO_FLAG}|{_RO_ARG}))*'
rf'{_RO_REDIR}'
)
# git mv: 非破壊的なリネーム操作 (git commit より低リスク)
# フラグは公式オプション4つのみ: -f(force), -n(dry-run), -k(skip), -v(verbose)
_GIT_MV_SEG = (
r'git'
rf'(?:[ \t]+-C[ \t]+{_RO_ARG})?'
r'[ \t]+mv'
rf'(?:[ \t]+-[fnkv])*'
rf'(?:[ \t]+{_RO_ARG}){{2,}}'
)
_COMPOUND_SEG = rf'(?:{_RO_SEG}|{_CD_SEG}|{_GIT_COMPOUND_SEG}|{_GIT_MV_SEG})'
_SAFE_COMPOUND_CHAIN = re.compile(
rf'^[ \t]*{_COMPOUND_SEG}'
rf'(?:[ \t]+(?:&&|\|\|)[ \t]+{_COMPOUND_SEG}|[ \t]*;[ \t]*{_COMPOUND_SEG})*'
rf'(?:[ \t]*\|[ \t]*{_PF})*[ \t]*$',
re.ASCII,
)
def _is_safe_npm_audit_pipeline(command: str) -> bool:
"""npm audit パイプラインの安全性を検証."""
if not _NPM_AUDIT_PIPELINE.match(command):
return False
parts = _split_pipe(command)
return not _has_dangerous_pipe_filter(parts[1:])
def _is_safe_git_readonly_pipeline(command: str) -> bool:
"""git read-only コマンドのパイプライン安全性を検証."""
if not _GIT_RO_PIPELINE.match(command):
return False
parts = _split_pipe(command)
if _GIT_DANGEROUS_RE.search(parts[0]):
return False
if _has_dangerous_pipe_filter(parts[1:]):
return False
return True
def _is_safe_gh_api_pipeline(command: str) -> bool:
"""gh api read-only コマンドのパイプライン安全性を検証."""
if not _GH_API_RO_PIPELINE.match(command):
return False
parts = _split_pipe(command)
if _GH_API_GRAPHQL_RE.search(parts[0]):
return False
if _GH_API_DANGEROUS_RE.search(parts[0]):
return False
if _has_dangerous_pipe_filter(parts[1:]):
return False
for seg in parts[1:]:
stripped = seg.strip()
if stripped.startswith('jq'):
if _JQ_DANGEROUS_RE.search(stripped):
return False
return True
if (
_is_safe_ai_cli(command)
or _SAFE_NPM_AUDIT.match(command)
or _is_safe_npm_audit_pipeline(command)
or _SAFE_HELP_VERSION.match(command)
or any(p.match(command) for p in PATTERNS)
or _is_safe_readonly_compound(command)
or _is_safe_git_readonly_pipeline(command)
or _is_safe_gh_api_pipeline(command)
):
json.dump(
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {"behavior": "allow"},
}
},
sys.stdout,
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment