Created
February 23, 2026 03:07
-
-
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
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 | |
| """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