Skip to content

Instantly share code, notes, and snippets.

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

  • Save silenvx/91d3162af9b8ca9365a79b3333cadcdb to your computer and use it in GitHub Desktop.

Select an option

Save silenvx/91d3162af9b8ca9365a79b3333cadcdb to your computer and use it in GitHub Desktop.
Tests for approve-skill-bash.py (PreToolUse hook) — https://zenn.dev/ux_xu/articles/4f57169b0dd820
#!/usr/bin/env python3
"""approve-skill-bash.py のテスト."""
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
HOOK = str(Path(__file__).with_name("approve-skill-bash.py"))
_HOME = os.path.expanduser("~")
def _make_skill_md(tmpdir: str, skill_name: str, frontmatter: str) -> str:
"""一時 SKILL.md を作成しパスを返す."""
skill_dir = Path(tmpdir) / skill_name
skill_dir.mkdir(parents=True, exist_ok=True)
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(frontmatter)
return str(skill_md)
def _run_hook(skill_md_path: str, command: str, tool_name: str = "Bash") -> dict | None:
"""フックを実行し、JSON 出力があればパースして返す."""
input_json = json.dumps(
{"tool_name": tool_name, "tool_input": {"command": command}}
)
result = subprocess.run(
[sys.executable, HOOK, skill_md_path],
input=input_json,
capture_output=True,
text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return None
return json.loads(result.stdout)
def _is_allowed(skill_md_path: str, command: str, tool_name: str = "Bash") -> bool:
"""フックが allow を返すかどうか."""
data = _run_hook(skill_md_path, command, tool_name)
if data is None:
return False
return (
data.get("hookSpecificOutput", {}).get("permissionDecision") == "allow"
)
CODEX_FRONTMATTER = """\
---
name: codex
allowed-tools:
- Bash(codex exec --skip-git-repo-check *)
- Bash(codex --cd * exec --skip-git-repo-check *)
- Read
hooks:
PreToolUse:
- matcher: "Bash"
---
"""
PKG_CHECKER_FRONTMATTER = """\
---
name: pkg-checker
allowed-tools:
- Bash(/usr/local/bin/pkgtool list *)
- Bash(/usr/local/bin/pkgtool deps *)
- Bash(/usr/local/bin/pkgtool info *)
- Read
- Grep
---
"""
TILDE_FRONTMATTER = """\
---
name: tilde-skill
allowed-tools:
- Bash(~/bin/my-tool *)
---
"""
class TestEarlyExit(unittest.TestCase):
"""引数や入力が不正な場合に安全に終了する."""
def test_no_args(self):
"""引数なしで実行."""
result = subprocess.run(
[sys.executable, HOOK],
input='{"tool_name": "Bash", "tool_input": {"command": "ls"}}',
capture_output=True,
text=True,
)
self.assertEqual(result.stdout, "")
def test_not_skill_md_filename(self):
"""SKILL.md でないファイル名."""
with tempfile.NamedTemporaryFile(suffix=".py") as f:
self.assertFalse(_is_allowed(f.name, "ls"))
def test_nonexistent_file(self):
"""存在しないファイル."""
self.assertFalse(_is_allowed("/nonexistent/SKILL.md", "ls"))
def test_invalid_json(self):
"""不正な JSON."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
result = subprocess.run(
[sys.executable, HOOK, skill_md],
input="not json",
capture_output=True,
text=True,
)
self.assertEqual(result.stdout, "")
def test_empty_stdin(self):
"""空の stdin."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
result = subprocess.run(
[sys.executable, HOOK, skill_md],
input="",
capture_output=True,
text=True,
)
self.assertEqual(result.stdout, "")
def test_non_bash_tool(self):
"""tool_name が Bash 以外."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertFalse(_is_allowed(skill_md, "ls", tool_name="Read"))
def test_empty_command(self):
"""command が空文字列."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertFalse(_is_allowed(skill_md, ""))
class TestSuffixStripping(unittest.TestCase):
"""Claude Code が付与する 2>&1 サフィックスの除去."""
def test_stderr_redirect_only(self):
"""2>&1 のみ."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(skill_md, "codex exec --skip-git-repo-check hello 2>&1")
)
def test_full_exit_suffix(self):
"""2>&1 || echo "---EXIT: $?" サフィックス."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
'codex exec --skip-git-repo-check hello 2>&1 || echo "---EXIT: $?"',
)
)
def test_no_suffix(self):
"""サフィックスなし."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(skill_md, "codex exec --skip-git-repo-check hello")
)
class TestMetacharRejection(unittest.TestCase):
"""シェルメタ文字を含むコマンドは承認しない."""
def _assert_rejected(self, command: str):
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertFalse(
_is_allowed(skill_md, command), f"Should reject: {command!r}"
)
def test_semicolon(self):
self._assert_rejected("codex exec --skip-git-repo-check hello; rm -rf /")
def test_ampersand(self):
self._assert_rejected("codex exec --skip-git-repo-check hello & evil")
def test_pipe(self):
self._assert_rejected("codex exec --skip-git-repo-check hello | evil")
def test_dollar(self):
self._assert_rejected("codex exec --skip-git-repo-check $HOME")
def test_backtick(self):
self._assert_rejected("codex exec --skip-git-repo-check `id`")
def test_less_than(self):
self._assert_rejected("codex exec --skip-git-repo-check < /etc/passwd")
def test_greater_than(self):
self._assert_rejected("codex exec --skip-git-repo-check > /tmp/evil")
def test_open_paren(self):
self._assert_rejected("codex exec --skip-git-repo-check (evil)")
def test_close_paren(self):
self._assert_rejected("codex exec --skip-git-repo-check evil)")
def test_open_brace(self):
self._assert_rejected("codex exec --skip-git-repo-check {evil}")
def test_close_brace(self):
self._assert_rejected("codex exec --skip-git-repo-check evil}")
def test_newline(self):
self._assert_rejected("codex exec --skip-git-repo-check hello\nrm -rf /")
def test_carriage_return(self):
self._assert_rejected("codex exec --skip-git-repo-check hello\rrm -rf /")
def test_null_byte(self):
self._assert_rejected("codex exec --skip-git-repo-check hello\x00evil")
def test_unquoted_brace_rejected(self):
"""クォートされていない {} は拒否."""
self._assert_rejected("codex exec --skip-git-repo-check {evil}")
def test_single_quoted_braces_allowed(self):
"""シングルクォート内の {} は安全として許可."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"codex exec --skip-git-repo-check '{some-uuid}'",
)
)
def test_hash_comment(self):
"""# によるコメント開始でコマンド切り捨てを防止."""
self._assert_rejected("codex exec --skip-git-repo-check hello # comment")
def test_single_quoted_hash_allowed(self):
"""シングルクォート内の # は安全として許可."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"codex exec --skip-git-repo-check 'a#b'",
)
)
def test_double_quoted_hash_allowed(self):
"""ダブルクォート内の # は安全として許可."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
'codex exec --skip-git-repo-check "a#b"',
)
)
def test_double_quoted_dollar_rejected(self):
"""ダブルクォート内の $ は展開されるため拒否."""
self._assert_rejected('codex exec --skip-git-repo-check "$HOME"')
def test_single_quoted_semicolon_allowed(self):
"""シングルクォート内の ; も安全として許可."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"codex exec --skip-git-repo-check 'a;b'",
)
)
def test_metachar_outside_quotes_rejected(self):
"""クォート外にメタ文字があれば拒否."""
self._assert_rejected(
"codex exec --skip-git-repo-check 'safe' ; evil"
)
def test_metachar_even_if_pattern_matches(self):
"""パターンにマッチしてもメタ文字があれば拒否."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
allowed-tools:
- Bash(echo *)
---
"""
skill_md = _make_skill_md(tmpdir, "test", fm)
self.assertFalse(_is_allowed(skill_md, "echo hello; rm -rf /"))
class TestTildeExpansion(unittest.TestCase):
"""~/path の絶対パスへの正規化."""
def test_tilde_in_command(self):
"""コマンド側の ~/ が展開されてマッチ."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", TILDE_FRONTMATTER)
self.assertTrue(_is_allowed(skill_md, "~/bin/my-tool arg1"))
def test_absolute_path_in_command(self):
"""絶対パスでもパターン側 ~/ と一致."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", TILDE_FRONTMATTER)
self.assertTrue(
_is_allowed(skill_md, f"{_HOME}/bin/my-tool arg1")
)
class TestCwdNormalization(unittest.TestCase):
"""CWD の絶対パスプレフィックスを除去して相対パスに正規化."""
RELATIVE_FRONTMATTER = """\
---
name: test
allowed-tools:
- Bash(bun config/scripts/run.ts *)
---
"""
def test_absolute_cwd_path_normalized(self):
"""CWD 配下の絶対パスが相対パスに正規化されてマッチ."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", self.RELATIVE_FRONTMATTER)
cwd = os.getcwd()
self.assertTrue(
_is_allowed(skill_md, f"bun {cwd}/config/scripts/run.ts arg1")
)
def test_relative_path_still_matches(self):
"""相対パスはそのままマッチ."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", self.RELATIVE_FRONTMATTER)
self.assertTrue(
_is_allowed(skill_md, "bun config/scripts/run.ts arg1")
)
def test_different_cwd_prefix_not_stripped(self):
"""CWD と異なるプレフィックスは除去されない."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", self.RELATIVE_FRONTMATTER)
self.assertFalse(
_is_allowed(skill_md, "bun /other/path/config/scripts/run.ts arg1")
)
def test_root_cwd_does_not_strip_slashes(self):
"""CWD が / の場合にスラッシュが除去されない."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
allowed-tools:
- Bash(bun /opt/scripts/run.ts *)
---
"""
skill_md = _make_skill_md(tmpdir, "test", fm)
input_json = json.dumps(
{"tool_name": "Bash", "tool_input": {"command": "bun /opt/scripts/run.ts arg1"}}
)
result = subprocess.run(
[sys.executable, HOOK, skill_md],
input=input_json,
capture_output=True,
text=True,
cwd="/",
)
self.assertNotEqual(result.stdout.strip(), "")
data = json.loads(result.stdout)
self.assertEqual(
data["hookSpecificOutput"]["permissionDecision"], "allow"
)
class TestGlobToRegex(unittest.TestCase):
"""_glob_to_regex のパターン変換."""
def test_wildcard_matches(self):
"""* が任意文字列にマッチ."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(skill_md, "codex exec --skip-git-repo-check any-prompt-here")
)
def test_multiple_wildcards(self):
"""複数の * が動作."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"codex --cd /some/path exec --skip-git-repo-check prompt",
)
)
def test_no_match_without_required_prefix(self):
"""パターンのプレフィックスが一致しない場合は拒否."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertFalse(_is_allowed(skill_md, "not-codex exec --skip-git-repo-check x"))
def test_regex_special_chars_escaped(self):
"""正規表現特殊文字がリテラルとしてエスケープされる."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
allowed-tools:
- Bash(/usr/local/bin/pkgtool.v2 info *)
---
"""
skill_md = _make_skill_md(tmpdir, "test", fm)
# . はリテラルドットとしてマッチ
self.assertTrue(
_is_allowed(skill_md, "/usr/local/bin/pkgtool.v2 info pkg")
)
# . が任意文字にマッチしないことを確認
self.assertFalse(
_is_allowed(skill_md, "/usr/local/bin/pkgtoolXv2 info pkg")
)
def test_full_match_not_prefix(self):
"""完全一致であり前方一致ではない."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
allowed-tools:
- Bash(pwd)
---
"""
skill_md = _make_skill_md(tmpdir, "test", fm)
self.assertTrue(_is_allowed(skill_md, "pwd"))
self.assertFalse(_is_allowed(skill_md, "pwd -L"))
def test_suffix_boundary(self):
"""*.py が test.py.bak にマッチしない(完全一致の境界)."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
allowed-tools:
- Bash(python3 *.py)
---
"""
skill_md = _make_skill_md(tmpdir, "test", fm)
self.assertTrue(_is_allowed(skill_md, "python3 test.py"))
self.assertFalse(_is_allowed(skill_md, "python3 test.py.bak"))
class TestExtractBashPatterns(unittest.TestCase):
"""SKILL.md の frontmatter から Bash パターンを抽出する."""
def test_normal_extraction(self):
"""正常な frontmatter からパターンを抽出."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(skill_md, "codex exec --skip-git-repo-check hello")
)
def test_multiple_patterns(self):
"""複数の Bash パターンを全て抽出."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", PKG_CHECKER_FRONTMATTER)
self.assertTrue(_is_allowed(skill_md, "/usr/local/bin/pkgtool list --formula"))
self.assertTrue(_is_allowed(skill_md, "/usr/local/bin/pkgtool deps --tree pkg"))
self.assertTrue(_is_allowed(skill_md, "/usr/local/bin/pkgtool info --json pkg"))
def test_non_bash_skipped(self):
"""Read 等の非 Bash パターンはスキップ."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
allowed-tools:
- Read
- Grep
---
"""
skill_md = _make_skill_md(tmpdir, "test", fm)
self.assertFalse(_is_allowed(skill_md, "anything"))
def test_empty_bash_pattern_ignored(self):
"""Bash() 空パターンは .+ により無視される."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
allowed-tools:
- Bash()
---
"""
skill_md = _make_skill_md(tmpdir, "test", fm)
self.assertFalse(_is_allowed(skill_md, "anything"))
def test_no_frontmatter(self):
"""frontmatter がない場合."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", "# Just markdown\nNo frontmatter.")
self.assertFalse(_is_allowed(skill_md, "anything"))
def test_unclosed_frontmatter(self):
"""frontmatter が閉じられていない場合."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
allowed-tools:
- Bash(ls *)
"""
skill_md = _make_skill_md(tmpdir, "test", fm)
self.assertFalse(_is_allowed(skill_md, "ls -la"))
def test_frontmatter_with_dashes_in_value(self):
"""frontmatter 内の値に --- が含まれる場合は途中で切れる."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
description: "some --- value"
allowed-tools:
- Bash(ls *)
---
"""
# text.find("---", 3) は description 内の --- を先に見つける
# この場合 allowed-tools はパースされない
skill_md = _make_skill_md(tmpdir, "test", fm)
self.assertFalse(_is_allowed(skill_md, "ls -la"))
def test_section_end_on_next_key(self):
"""allowed-tools の後に別の YAML キーが来るとパース終了."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
# hooks: キーの後のパターンは拾わない
self.assertFalse(_is_allowed(skill_md, "matcher Bash"))
def test_comment_lines_skipped(self):
"""コメント行はスキップされパースが継続する."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
allowed-tools:
# this is a comment
- Bash(ls *)
---
"""
skill_md = _make_skill_md(tmpdir, "test", fm)
self.assertTrue(_is_allowed(skill_md, "ls -la"))
class TestApprovalOutput(unittest.TestCase):
"""承認時の JSON 出力の構造."""
def test_output_structure(self):
"""出力 JSON のキーと値が正しい."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "my-skill", CODEX_FRONTMATTER)
data = _run_hook(skill_md, "codex exec --skip-git-repo-check hello")
self.assertIsNotNone(data)
hook_output = data["hookSpecificOutput"]
self.assertEqual(hook_output["hookEventName"], "PreToolUse")
self.assertEqual(hook_output["permissionDecision"], "allow")
self.assertIn("my-skill", hook_output["permissionDecisionReason"])
def test_skill_name_from_directory(self):
"""スキル名は SKILL.md の親ディレクトリ名."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "pkg-checker", PKG_CHECKER_FRONTMATTER)
data = _run_hook(skill_md, "/usr/local/bin/pkgtool list --formula")
self.assertIsNotNone(data)
self.assertIn(
"pkg-checker",
data["hookSpecificOutput"]["permissionDecisionReason"],
)
class TestMismatch(unittest.TestCase):
"""パターンに一致しない場合."""
def test_no_match(self):
"""パターンに一致しないコマンド."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", CODEX_FRONTMATTER)
self.assertFalse(_is_allowed(skill_md, "rm -rf /"))
def test_zero_patterns(self):
"""Bash パターンが0個の SKILL.md."""
with tempfile.TemporaryDirectory() as tmpdir:
fm = """\
---
name: test
allowed-tools:
- Read
---
"""
skill_md = _make_skill_md(tmpdir, "test", fm)
self.assertFalse(_is_allowed(skill_md, "anything"))
class TestIntegration(unittest.TestCase):
"""統合シナリオ."""
def test_suffix_then_match(self):
"""2>&1 サフィックス除去後にパターンマッチで承認."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", PKG_CHECKER_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"/usr/local/bin/pkgtool list --formula 2>&1",
)
)
def test_metachar_overrides_match(self):
"""パターンにマッチしてもメタ文字があれば拒否."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", PKG_CHECKER_FRONTMATTER)
self.assertFalse(
_is_allowed(
skill_md,
"/usr/local/bin/pkgtool list --formula; evil",
)
)
def test_real_codex_pattern(self):
"""実際の codex パターンでの動作確認."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "codex", CODEX_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"codex exec --skip-git-repo-check 'review this code'",
)
)
self.assertTrue(
_is_allowed(
skill_md,
"codex --cd /some/project exec --skip-git-repo-check 'hello'",
)
)
self.assertFalse(
_is_allowed(skill_md, "codex --version")
)
API_TOOL_FRONTMATTER = """\
---
name: api-tool
allowed-tools:
- Bash(bun ~/.claude/skills/api-tool/scripts/api-tool.ts get-pipeline-steps *)
---
"""
SLEEP_FRONTMATTER = """\
---
name: sleep-test
allowed-tools:
- Bash(bun ~/.claude/skills/api-tool/scripts/api-tool.ts list-pipelines *)
- Bash(ls *)
---
"""
class TestSleepPrefixStripping(unittest.TestCase):
"""run_in_background が付与する sleep N && プレフィックスの除去."""
def test_sleep_prefix_stripped(self):
"""sleep N && が除去されてパターンマッチ."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", SLEEP_FRONTMATTER)
self.assertTrue(
_is_allowed(skill_md, "sleep 120 && ls -la")
)
def test_sleep_with_suffix_and_prefix(self):
"""サフィックスとプレフィックスの両方が除去される."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", SLEEP_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"sleep 120 && ls -la 2>&1",
)
)
def test_sleep_prefix_no_match_still_rejected(self):
"""sleep 除去後もパターンに一致しなければ拒否."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", SLEEP_FRONTMATTER)
self.assertFalse(
_is_allowed(skill_md, "sleep 5 && rm -rf /")
)
def test_multi_sleep_chain_rejected(self):
"""多重 sleep チェーンは2つ目の && でメタ文字拒否."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", SLEEP_FRONTMATTER)
self.assertFalse(
_is_allowed(skill_md, "sleep 1 && sleep 2 && ls -la")
)
def test_newline_after_ampersand_rejected(self):
"""sleep N &&\\n の改行がメタ文字チェックで検出される."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", SLEEP_FRONTMATTER)
self.assertFalse(
_is_allowed(skill_md, "sleep 1 &&\nls -la")
)
def test_sleep_with_float_not_stripped(self):
"""sleep 0.5 は \\d+ にマッチしないため除去されない."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", SLEEP_FRONTMATTER)
# sleep 0.5 && ls -la: sleep prefix not stripped, && blocked by metachar
self.assertFalse(
_is_allowed(skill_md, "sleep 0.5 && ls -la")
)
class TestSingleQuotedArgs(unittest.TestCase):
"""シングルクォートで囲まれた引数のメタ文字を許可する."""
def test_api_tool_pipeline_uuid(self):
"""Pipeline UUID ({...}) がシングルクォート内なら許可."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "api-tool", API_TOOL_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"bun ~/.claude/skills/api-tool/scripts/api-tool.ts"
" get-pipeline-steps"
" workspace/repo '{00000000-0000-0000-0000-000000000000}'",
)
)
def test_unclosed_single_quote(self):
"""未閉じシングルクォートはストリップされずメタ文字チェックが適用される."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "api-tool", API_TOOL_FRONTMATTER)
# ' はメタ文字リストに含まれないため、パターンマッチ次第で許可/拒否が決まる
# ここではパターンにマッチしない入力で拒否を確認
self.assertFalse(
_is_allowed(skill_md, "unknown-command 'unclosed")
)
def test_api_tool_unquoted_uuid_rejected(self):
"""クォートなしの UUID は {} がメタ文字として拒否."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "api-tool", API_TOOL_FRONTMATTER)
self.assertFalse(
_is_allowed(
skill_md,
"bun ~/.claude/skills/api-tool/scripts/api-tool.ts"
" get-pipeline-steps"
" workspace/repo {00000000-0000-0000-0000-000000000000}",
)
)
VALIDATE_FRONTMATTER = """\
---
name: my-validator
allowed-tools:
- Bash(bun config/claude/skills/my-validator/scripts/validate.ts *)
---
"""
class TestLineContinuation(unittest.TestCase):
"""シェル行継続 (backslash + newline) の正規化."""
def test_line_continuation_normalized(self):
"""行継続が単一スペースに正規化されてパターンマッチ."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", VALIDATE_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"bun config/claude/skills/my-validator/scripts/validate.ts \\\n"
" config/claude/skills/target-skill",
)
)
def test_line_continuation_with_suffix(self):
"""行継続 + 2>&1 サフィックスの組み合わせ."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", VALIDATE_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"bun config/claude/skills/my-validator/scripts/validate.ts \\\n"
" config/claude/skills/target-skill 2>&1",
)
)
def test_bare_newline_still_rejected(self):
"""行継続なしの改行はメタ文字として拒否."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", VALIDATE_FRONTMATTER)
self.assertFalse(
_is_allowed(
skill_md,
"bun config/claude/skills/my-validator/scripts/validate.ts\n"
"rm -rf /",
)
)
def test_line_continuation_with_metachar_rejected(self):
"""行継続を正規化してもメタ文字が残れば拒否."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", VALIDATE_FRONTMATTER)
self.assertFalse(
_is_allowed(
skill_md,
"bun config/claude/skills/my-validator/scripts/validate.ts \\\n"
" arg; rm -rf /",
)
)
def test_multiple_continuations(self):
"""複数回の行継続が全て正規化される."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", VALIDATE_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"bun config/claude/skills/my-validator/scripts/validate.ts \\\n"
" --flag1 \\\n"
" config/claude/skills/target-skill",
)
)
def test_continuation_with_tab_indent(self):
"""タブインデントの行継続も正規化される."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", VALIDATE_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"bun config/claude/skills/my-validator/scripts/validate.ts \\\n"
"\tconfig/claude/skills/target-skill",
)
)
def test_sleep_prefix_with_continuation(self):
"""sleep N && プレフィックス + 行継続の組み合わせ."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", VALIDATE_FRONTMATTER)
self.assertTrue(
_is_allowed(
skill_md,
"sleep 120 && bun config/claude/skills/my-validator/scripts/validate.ts \\\n"
" config/claude/skills/target-skill",
)
)
def test_crlf_continuation_rejected(self):
r"""CRLF (\r\n) の行継続はパターン不一致で拒否(過剰に厳格だが安全)."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", VALIDATE_FRONTMATTER)
self.assertFalse(
_is_allowed(
skill_md,
"bun config/claude/skills/my-validator/scripts/validate.ts \\\r\n"
" config/claude/skills/target-skill",
)
)
def test_multiple_continuations_with_metachar_rejected(self):
"""複数行継続の途中にメタ文字があれば拒否."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", VALIDATE_FRONTMATTER)
self.assertFalse(
_is_allowed(
skill_md,
"bun config/claude/skills/my-validator/scripts/validate.ts \\\n"
" arg1 \\\n"
" arg2; evil",
)
)
def test_trailing_backslash_no_newline(self):
"""末尾バックスラッシュ(改行なし)はそのまま残りパターンマッチで判定."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = _make_skill_md(tmpdir, "test", VALIDATE_FRONTMATTER)
# 末尾 \ はパターンの * にマッチするため承認される
self.assertTrue(
_is_allowed(
skill_md,
"bun config/claude/skills/my-validator/scripts/validate.ts arg\\",
)
)
if __name__ == "__main__":
unittest.main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment