diff --git a/.claude/hooks/restrict_repo_access.py b/.claude/hooks/restrict_repo_access.py index 0e48e0d..218d408 100644 --- a/.claude/hooks/restrict_repo_access.py +++ b/.claude/hooks/restrict_repo_access.py @@ -42,6 +42,8 @@ ものであり,同じコマンド内で先に作ったファイルを上書きする場合等は許可される. 逆にインライン実行は,削除系の呼び出しとリポジトリ外のパス(文字列・コメント中の ものも含む)が同時に現れると,削除対象でなくても拒否することがある. + cmd.exe の引数のうち /opt のように / の後に要素が 1 つだけのパスは,スイッチ + (/mir 等)と区別できないためスイッチとして読み飛ばす. ユーザーが `!` で実行したコマンドには本フックはかからない.確実性が必要な場合は OS のフォルダ権限,settings.json の permissions.deny,Bash / PowerShell の実行前に 確認を挟む権限モード,バックアップを併用すること(docs/01_GUIDE/GUIDE_01 の環境構築). @@ -279,6 +281,19 @@ return path +def is_absolute(path: str) -> bool: + """path を絶対パスとして扱うか(cwd と結合しないか)を判定する. + + Python 3.13 以降の ntpath.isabs はドライブ無しの root-relative パス(`/tmp/x`・`\\tmp\\x`)を + False と判定するようになり,cwd と結合されて `D:\\tmp\\x` 等に化ける(/tmp の判定を外れる). + Windows では Python のバージョンによらず `/`・`\\` 始まりを絶対パスとして扱い,3.12 以前の挙動に揃える. + POSIX ホストでは os.path.isabs(posixpath)の判定をそのまま使う. + """ + if os.name == "nt" and path[:1] in ("/", "\\"): + return True + return os.path.isabs(path) + + def expand_variables(token: str, dialect: str) -> str | None: """`~`・$HOME 等の既知の変数を展開する.未知の変数が残れば None を返す.""" token = expand_home(token) @@ -326,8 +341,12 @@ return SPECIAL if dialect == "powershell" and re.match(r"^[A-Za-z][A-Za-z0-9]+:", token): return UNKNOWN # Env: / HKLM: 等のファイルシステム以外のドライブ + if dialect == "powershell" and os.name != "nt": + # Linux / macOS の pwsh は `\` も区切りとして受け付けるが,os.path(posixpath)は `\` を + # 区切りと見なさず `..\..` を畳めない(リポジトリ内と誤判定する)ため,判定前に `/` へ揃える + token = token.replace("\\", "/") token = convert_posix_drive(token) - if not os.path.isabs(token): + if not is_absolute(token): if cwd is None: return UNKNOWN token = os.path.join(cwd, token) @@ -403,7 +422,7 @@ if not entry: continue entry = convert_posix_drive(expand_home(entry)) - if not os.path.isabs(entry): + if not is_absolute(entry): entry = os.path.join(repo_root, entry) result.append(os.path.normpath(entry)) return result @@ -736,6 +755,16 @@ CMD_DELETE = {"del", "erase", "rd", "rmdir", "move", "ren", "rename"} CMD_COPY = {"copy", "xcopy", "robocopy"} +# cmd.exe のスイッチ(/s・/q・/-y・/?・/mir・/a:h・/log:C:\x 等).`/` の直後が英字(または - + ?)で, +# 名前部分に区切り(`/`・`\`)を含まないもの./workspace/x(POSIX の絶対パス)や /c/Users(Git Bash 形式)は +# 名前部分に 2 つ目の区切りを含むのでパスとして扱う(OS によって判定が変わらないようにする) +CMD_SWITCH = re.compile(r"^/[-+]?(?:\?|[A-Za-z][A-Za-z0-9]*)(?::.*)?$", re.DOTALL) + + +def is_cmd_switch(text: str) -> bool: + """cmd.exe の引数 text がスイッチ(パスではない)かを判定する.""" + return bool(CMD_SWITCH.match(text)) + def collect_cmd_exe(args: list, cwd: str | None, findings: Findings) -> None: """cmd /c 以降のコマンドから削除・上書きの対象と読み取りを拾う.""" @@ -753,7 +782,7 @@ if text.startswith(">"): redirect_kind = CREATE if text.startswith(">>") else OVERWRITE text = text.lstrip(">") - if text.startswith("/") and not re.match(r"^/[A-Za-z]/", text): + if is_cmd_switch(text): continue # /s /q 等のスイッチ resolved = resolve_path(text, cwd, "powershell") if resolved in (UNKNOWN, SPECIAL): @@ -764,7 +793,7 @@ redirect_kind = None continue paths.append((text, resolved)) - switches = {w.lower() for w in words[1:] if w.startswith("/")} + switches = {w.lower() for w in words[1:] if is_cmd_switch(w)} for idx, (text, resolved) in enumerate(paths): findings.reads.append(("cmd " + sub, text, resolved, False)) is_last = idx == len(paths) - 1 and idx > 0 diff --git a/.claude/hooks/tests/test_hooks.py b/.claude/hooks/tests/test_hooks.py index 29cda6d..85b42c9 100644 --- a/.claude/hooks/tests/test_hooks.py +++ b/.claude/hooks/tests/test_hooks.py @@ -14,13 +14,16 @@ import http.server import io import json +import ntpath import os +import posixpath import re import shutil import subprocess import sys import tempfile import threading +import types import unittest from unittest import mock @@ -1169,12 +1172,62 @@ f"del {ex}", f"rd {d} -Recurse", f"ri {ex}", f"rm {ex}", f"erase {ex}", f"rmdir {d}", f"Clear-Content {ex}", f"Move-Item {ex} .\\moved.txt", f"Move-Item -Path {ex} -Destination .\\m.txt", f"Rename-Item {ex} renamed.txt", f"[IO.File]::Delete('{ex}')", f"[System.IO.File]::Delete(\"{ex}\")", - f"cmd /c del {ex}", f"cmd /c rd /s /q {d}", ] for cmd in cases: with self.subTest(cmd): self.assertDenied(self.ps(cmd), cmd) + def test_cmd_exe_delete_commands_with_host_absolute_path(self): + """cmd /c の引数のホスト OS の絶対パスをスイッチと取り違えないこと(Issue #51 原因 1). + + スイッチ(/s・/q)と絶対パス(POSIX の /workspace/x・Windows の C:\\x)の区別は + OS に依存しない判定なので全 OS で実行する. + """ + ex = self.existing + d = os.path.join(self.out, "other") + cases = [ + f"cmd /c del {ex}", f"cmd /c rd /s /q {d}", f"cmd /c del /f /q {ex}", f"cmd /c rmdir /s {d}", + f"cmd /c del {_fwd(ex)}", f"cmd /c rd /s /q {_fwd(d)}", f'cmd /c rd /s /q "{d}"', + f"cmd /c erase {ex}", f"cmd /c ren {ex} renamed.txt", f"cmd /c move {ex} moved.txt", + f"cmd /c robocopy src {d} /mir", f"cmd /c robocopy src {d} /purge", + f"cmd /c echo x > {ex}", + ] + for cmd in cases: + with self.subTest(cmd): + self.assertDenied(self.ps(cmd), cmd) + + def test_cmd_exe_relative_backslash_path_escaping_repo(self): + """cmd /c の相対パス(`\\` 区切り)の `..` による脱出を全 OS で検出すること.""" + rel = os.path.join("..", "..", "outside") + self.assertDenied(self.ps("cmd /c del ..\\..\\outside\\existing.txt", cwd=self.sub)) + self.assertDenied(self.ps("cmd /c rd /s /q ..\\..\\outside\\other", cwd=self.sub)) + self.assertDenied(self.ps(f"cmd /c del {_fwd(rel)}/existing.txt", cwd=self.sub)) + self.assertAllowed(self.ps("cmd /c del ..\\file.txt", cwd=self.sub)) + self.assertAllowed(self.ps("cmd /c rd /s /q build")) + + def test_cmd_exe_switches_only_or_inside_repo_allowed(self): + """スイッチだけ・リポジトリ内の対象は許可(スイッチをパスとして誤検出しない).""" + for cmd in ("cmd /c dir /s /b", "cmd /c del /q file.txt", "cmd /c rd /s /q sub", + "cmd /c xcopy file.txt sub /y /-y", "cmd /c robocopy sub build /mir /log:out.txt", + "cmd /c dir /a:h", "cmd /c del /?"): + with self.subTest(cmd): + self.assertAllowed(self.ps(cmd), cmd) + + @unittest.skipUnless(os.name == "nt", "ドライブレター・Git Bash 形式(/c/...)のパスは Windows にしか存在しないため") + def test_cmd_exe_windows_path_forms(self): + ex = self.existing + d = os.path.join(self.out, "other") + cases = [ + f"cmd /c del {_gitbash(ex)}", f"cmd /c rd /s /q {_gitbash(d)}", + f"cmd /c del {ex.replace(os.sep, '/')}", f"cmd /c robocopy src {d} /mir /log:{os.path.join(self.repo, 'l.txt')}", + f"cmd /c del {ex[0].lower()}{ex[1:]}", + ] + for cmd in cases: + with self.subTest(cmd): + self.assertDenied(self.ps(cmd), cmd) + # /log:C:\x はスイッチなのでリポジトリ外を指していても削除対象にならない + self.assertAllowed(self.ps(f"cmd /c robocopy sub build /log:{ex}")) + def test_inline_delete_calls(self): d = _fwd(os.path.join(self.out, "other")) ex = _fwd(self.existing) @@ -1339,6 +1392,326 @@ self.assertDenied(self.bash(f'rm -rf "/cygdrive{out}/other"')) +class TestRestrictPowerShellBackslash(RestrictSandboxMixin, unittest.TestCase): + """PowerShell の `\\` 区切りの相対パスをホスト OS に関係なく解決すること(Issue #51 原因 2). + + pwsh は Linux / macOS でも `\\` を区切りとして受け付けるため,`..\\..` による脱出の検出は + OS 非依存であるべき判定として全 OS で実行する. + """ + + def test_remove_item_escaping_repo_denied(self): + cases = [ + "Remove-Item ..\\..\\outside\\existing.txt", + "Remove-Item ..\\..\\outside\\other -Recurse -Force", + "Remove-Item -Path ..\\..\\outside\\other -Recurse", + "Remove-Item -LiteralPath ..\\..\\outside\\existing.txt", + "Remove-Item ..\\../outside/existing.txt", + "Remove-Item .\\..\\..\\outside\\existing.txt", + "Clear-Content ..\\..\\outside\\existing.txt", + "Move-Item ..\\..\\outside\\existing.txt .\\moved.txt", + "Remove-Item ..\\..\\outside\\__no_such_file__.txt", + ] + for cmd in cases: + with self.subTest(cmd): + self.assertDenied(self.ps(cmd, cwd=self.sub), cmd) + + def test_overwrite_escaping_repo_by_existence(self): + self.assertDenied(self.ps("Set-Content ..\\..\\outside\\existing.txt 'x'", cwd=self.sub)) + self.assertDenied(self.ps("'x' > ..\\..\\outside\\existing.txt", cwd=self.sub)) + self.assertDenied(self.ps("Copy-Item ..\\file.txt ..\\..\\outside\\existing.txt", cwd=self.sub)) + self.assertAllowed(self.ps("Set-Content ..\\..\\outside\\brand_new.txt 'x'", cwd=self.sub)) + self.assertAllowed(self.ps("Add-Content ..\\..\\outside\\existing.txt 'x'", cwd=self.sub)) + + def test_inside_repo_backslash_relative_allowed(self): + for cmd in ("Remove-Item ..\\file.txt", "Remove-Item ..\\sub\\x -Recurse", "Remove-Item .\\build -Recurse", + "Set-Content ..\\file.txt 'x'", "Remove-Item ..\\..\\repo\\file.txt"): + with self.subTest(cmd): + self.assertAllowed(self.ps(cmd, cwd=self.sub), cmd) + + def test_location_change_with_backslash_relative(self): + self.assertDenied(self.ps("Set-Location ..\\..\\outside; Remove-Item existing.txt", cwd=self.sub)) + self.assertDenied(self.ps("cd ..\\..\\outside\\other; Remove-Item data -Recurse", cwd=self.sub)) + self.assertAllowed(self.ps("Set-Location ..\\..\\outside; Get-Content existing.txt", cwd=self.sub)) + + def test_main_powershell_backslash_relative(self): + """main 経由(フック入力の cwd 起点)でも拒否されること.""" + env = dict(os.environ, CLAUDE_PROJECT_DIR=self.repo) + deny = self.run_main({"tool_name": "PowerShell", "cwd": self.sub, + "tool_input": {"command": "Remove-Item ..\\..\\outside\\other -Recurse"}}, env) + self.assertEqual(deny.returncode, 0, deny.stderr) + self.assertEqual(self.decision(deny), "deny") + allow = self.run_main({"tool_name": "PowerShell", "cwd": self.sub, + "tool_input": {"command": "Remove-Item ..\\file.txt"}}, env) + self.assertIsNone(self.decision(allow)) + + +@contextlib.contextmanager +def _simulate_posix_host(module): + """module から見える os を POSIX ホスト相当(os.name == "posix"・os.path == posixpath)にする. + + 差し替えるのは module の名前空間の `os` だけで,本物の os モジュール・他のテストには影響しない. + with を抜けると(例外時も)mock.patch.object が元に戻す. + """ + fake = types.SimpleNamespace(**{k: getattr(os, k) for k in dir(os) if not k.startswith("__")}) + fake.name = "posix" + fake.path = posixpath + fake.sep = "/" + fake.altsep = None + with mock.patch.object(module, "os", fake): + yield + + +class TestRestrictPosixHostSimulation(unittest.TestCase): + """ホスト OS に関係なく,POSIX ホストでの解決結果を確かめる(Windows 上でも POSIX の挙動を検証する). + + 実 OS 上のサンドボックスを使うテスト(TestRestrictPowerShellBackslash 等)を補完する. + パスは POSIX 形式の文字列で与える(このクラスでは os.path を posixpath に差し替えているため). + """ + + @classmethod + def setUpClass(cls) -> None: + cls.rra = _load_restrict() + + def test_simulation_is_restored(self): + real_os = self.rra.os + with _simulate_posix_host(self.rra): + self.assertEqual(self.rra.os.name, "posix") + self.assertIs(self.rra.os.path, posixpath) + self.assertIs(self.rra.os, real_os) + self.assertIs(self.rra.os, os) + with self.assertRaises(RuntimeError): + with _simulate_posix_host(self.rra): + raise RuntimeError("x") + self.assertIs(self.rra.os, os, "例外時も復元されること") + + def test_powershell_backslash_relative_resolved(self): + cases = { + "..\\..\\outside\\existing.txt": "/work/outside/existing.txt", + "..\\..\\outside\\other": "/work/outside/other", + "..\\../outside/x": "/work/outside/x", + ".\\a\\..\\b": "/work/repo/sub/b", + "..\\file.txt": "/work/repo/file.txt", + "\\work\\outside\\x": "/work/outside/x", + } + with _simulate_posix_host(self.rra): + for token, expected in cases.items(): + with self.subTest(token): + self.assertEqual(self.rra.resolve_path(token, "/work/repo/sub", "powershell"), expected) + + def test_bash_dialect_unchanged(self): + """bash の `\\` はエスケープ・ファイル名の一部であり,区切りへの正規化は PowerShell に限る.""" + with _simulate_posix_host(self.rra): + self.assertEqual(self.rra.resolve_path("../x", "/work/repo/sub", "bash"), "/work/repo/x") + self.assertEqual(self.rra.resolve_path("a\\b", "/work/repo", "bash"), "/work/repo/a\\b") + + def test_no_windows_drive_conversion_on_posix(self): + """Git Bash 形式(/c/...)の変換は Windows のときだけ行う.""" + with _simulate_posix_host(self.rra): + self.assertEqual(self.rra.convert_posix_drive("/c/Users/x"), "/c/Users/x") + self.assertEqual(self.rra.resolve_path("/c/Users/x", "/work/repo", "bash"), "/c/Users/x") + self.assertEqual(self.rra.resolve_path("/c/Users/x", "/work/repo", "powershell"), "/c/Users/x") + + def test_powershell_remove_item_escaping_repo_denied(self): + """受け入れ条件: POSIX ホストでも `Remove-Item ..\\..\\outside\\x` が拒否される.""" + with _simulate_posix_host(self.rra): + for cmd in ("Remove-Item ..\\..\\outside\\x", "Remove-Item ..\\..\\outside\\x -Recurse -Force", + "cmd /c del ..\\..\\outside\\x", "cmd /c rd /s /q /work/outside/x"): + with self.subTest(cmd): + reason = self.rra.check_powershell_command(cmd, "/work/repo/sub", repo_root="/work/repo", deny_read=[]) + self.assertIsNotNone(reason, f"拒否されるべき: {cmd}") + for cmd in ("Remove-Item ..\\x", "cmd /c rd /s /q ..\\build"): + with self.subTest(cmd): + reason = self.rra.check_powershell_command(cmd, "/work/repo/sub", repo_root="/work/repo", deny_read=[]) + self.assertIsNone(reason, f"許可されるべき: {cmd} / 理由: {reason}") + + +def _isabs_py313(path) -> bool: + """Python 3.13 以降の ntpath.isabs 相当(UNC・デバイスパスか,ドライブとルートを持つときだけ True). + + 3.12 以前は `/tmp/x`・`\\tmp\\x` のようなドライブ無しの root-relative パスも True だった. + """ + s = os.fspath(path) + if isinstance(s, bytes): + s = s.decode("utf-8", "surrogateescape") + s = s[:3].replace("/", "\\") + return (s.startswith("\\") and s.startswith("\\", 1)) or s.startswith(":\\", 1) + + +@contextlib.contextmanager +def _simulate_py313_ntpath_isabs(): + """ntpath.isabs を Python 3.13 相当に差し替える(with を抜けると例外時も mock.patch が元に戻す). + + Windows ではフックの os.path は ntpath そのものなので,フックの os.path.isabs にも効く. + 実行中の Python が 3.13 以降なら実質的に同じ挙動のまま(差し替えても結果は変わらない). + """ + with mock.patch.object(ntpath, "isabs", _isabs_py313): + yield + + +class TestIsAbsoluteAllHosts(unittest.TestCase): + """is_absolute: ホスト OS に依存しない判定と,POSIX ホストの挙動が変わらないこと(全 OS で実行).""" + + @classmethod + def setUpClass(cls) -> None: + cls.rra = _load_restrict() + + def test_relative_and_native_absolute(self): + for path in ("x", "a/b", "../x", ".", ""): + with self.subTest(path=path): + self.assertFalse(self.rra.is_absolute(path), path) + native_abs = os.path.abspath("x") + self.assertTrue(self.rra.is_absolute(native_abs), native_abs) + + def test_posix_host_slash_absolute_backslash_relative(self): + """POSIX ホストでは `/tmp/x` は絶対,`\\tmp\\x` は相対のまま(ファイル名の一部).""" + with _simulate_posix_host(self.rra): + self.assertTrue(self.rra.is_absolute("/tmp/x")) + self.assertTrue(self.rra.is_absolute("/")) + self.assertFalse(self.rra.is_absolute("\\tmp\\x")) + self.assertFalse(self.rra.is_absolute("\\")) + self.assertFalse(self.rra.is_absolute("C:\\x")) + + def test_posix_host_resolve_backslash_root_stays_relative(self): + with _simulate_posix_host(self.rra): + self.assertEqual(self.rra.resolve_path("\\tmp\\x", "/work/repo", "bash"), "/work/repo/\\tmp\\x") + self.assertEqual(self.rra.resolve_path("/tmp/some-dir", "/work/repo", "bash"), "/tmp/some-dir") + self.assertTrue(self.rra.is_temp_path(self.rra.resolve_path("/tmp/some-dir", "/work/repo", "bash"))) + self.assertIsNone(self.rra.check_bash_command("rm -rf /tmp/some-dir", "/work/repo", + repo_root="/work/repo", deny_read=[])) + + def test_posix_host_unaffected_by_ntpath_patch(self): + """ntpath の差し替えは POSIX ホスト相当(posixpath)の判定に影響しない.""" + with _simulate_posix_host(self.rra), _simulate_py313_ntpath_isabs(): + self.assertTrue(self.rra.is_absolute("/tmp/x")) + self.assertFalse(self.rra.is_absolute("\\tmp\\x")) + self.assertEqual(self.rra.resolve_path("/tmp/some-dir", "/work/repo", "bash"), "/tmp/some-dir") + + +class TestPy313SimulationRestored(unittest.TestCase): + def test_patch_effective_and_restored(self): + original = ntpath.isabs + with _simulate_py313_ntpath_isabs(): + self.assertFalse(ntpath.isabs("/tmp/x")) + self.assertFalse(ntpath.isabs("\\tmp\\x")) + self.assertTrue(ntpath.isabs("C:\\x")) + self.assertTrue(ntpath.isabs("C:/x")) + self.assertTrue(ntpath.isabs("\\\\server\\share\\x")) + self.assertFalse(ntpath.isabs("C:x")) + self.assertIs(ntpath.isabs, original) + with self.assertRaises(RuntimeError): + with _simulate_py313_ntpath_isabs(): + raise RuntimeError("x") + self.assertIs(ntpath.isabs, original, "例外時も復元されること") + + +class _WindowsRootRelativeCases: + """Windows でドライブ無しの root-relative パスを絶対パスとして扱うこと(Python のバージョンによらず). + + サブクラスの simulate() が ntpath.isabs の挙動(実行中の Python のまま / 3.13 相当)を切り替える. + """ + + def simulate(self): + raise NotImplementedError + + def test_is_absolute_root_relative(self): + with self.simulate(): + for path in ("/tmp/x", "\\tmp\\x", "/", "\\", "C:\\x", "C:/x", "\\\\server\\share\\x"): + with self.subTest(path=path): + self.assertTrue(self.rra.is_absolute(path), path) + for path in ("x", "..\\x", "C:x", ""): + with self.subTest(path=path): + self.assertFalse(self.rra.is_absolute(path), path) + + def test_resolve_root_relative_not_joined_with_cwd(self): + with self.simulate(): + for token, dialect in (("/tmp/some-dir", "bash"), ("/tmp/some-dir", "powershell"), + ("\\tmp\\some-dir", "powershell"), ("\\tmp\\some-dir", "bash")): + with self.subTest(token=token, dialect=dialect): + resolved = self.rra.resolve_path(token, self.repo, dialect) + self.assertEqual(resolved, "\\tmp\\some-dir") + self.assertTrue(self.rra.is_temp_path(resolved), resolved) + # cwd が無くても(cwd 不明でも)絶対パスとして解決できる + self.assertEqual(self.rra.resolve_path("/tmp/some-dir", None, "bash"), "\\tmp\\some-dir") + + def test_relative_still_joined_with_cwd(self): + with self.simulate(): + self.assertEqual(os.path.normcase(self.rra.resolve_path("../file.txt", self.sub, "bash")), + os.path.normcase(os.path.join(self.repo, "file.txt"))) + self.assertEqual(os.path.normcase(self.rra.resolve_path("..\\file.txt", self.sub, "powershell")), + os.path.normcase(os.path.join(self.repo, "file.txt"))) + + def test_rm_rf_tmp_allowed(self): + with self.simulate(): + for cmd in ("rm -rf /tmp/some-dir", "rm -rf /tmp/some-dir/sub", "echo x > /tmp/some-file"): + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd), cmd) + for cmd in ("Remove-Item /tmp/some-dir -Recurse -Force", "Remove-Item \\tmp\\some-dir -Recurse"): + with self.subTest(cmd): + self.assertAllowed(self.ps(cmd), cmd) + + def test_tmp_escape_still_denied(self): + """/tmp/.. による脱出は温存しない(絶対パス扱いにしても一時ディレクトリ判定を緩めない).""" + with self.simulate(): + self.assertDenied(self.bash("rm -rf /tmp/../some-dir")) + self.assertDenied(self.bash(f'rm -rf "{_fwd(os.path.join(self.out, "other"))}"')) + + def test_deny_read_root_relative_entry_not_joined_with_repo_root(self): + cfg = os.path.join(self.repo, ".claude", "repo-access.json") + with open(cfg, "w", encoding="utf-8") as fh: + fh.write(json.dumps({"deny_read": ["/foo", "\\bar\\baz", "secrets"]})) + with self.simulate(): + entries = self.rra.load_deny_read(self.repo) + normed = [os.path.normcase(e) for e in entries] + self.assertIn(os.path.normcase("\\foo"), normed) + self.assertIn(os.path.normcase("\\bar\\baz"), normed) + self.assertIn(os.path.normcase(os.path.join(self.repo, "secrets")), normed) + self.assertEqual(len(entries), 3) + + +@unittest.skipUnless(os.name == "nt", "ntpath の root-relative パス(ドライブ無しの /tmp・\\tmp)は Windows ホストのみの概念") +class TestWindowsRootRelativeCurrentPython(_WindowsRootRelativeCases, RestrictSandboxMixin, unittest.TestCase): + """実行中の Python の ntpath.isabs のまま検証する.""" + + def simulate(self): + return contextlib.nullcontext() + + +@unittest.skipUnless(os.name == "nt", "ntpath.isabs の 3.13 での変更は Windows(ntpath)でのみフックに影響する") +class TestWindowsRootRelativePy313(_WindowsRootRelativeCases, RestrictSandboxMixin, unittest.TestCase): + """ntpath.isabs を Python 3.13 相当に差し替えて検証する(3.12 以前のホストでも 3.13+ の挙動を再現する).""" + + def simulate(self): + return _simulate_py313_ntpath_isabs() + + def test_temp_dir_allowed_existing_case(self): + """CI(windows-latest / Python 3.14)で失敗した test_temp_dir_allowed の /tmp ケースの再現.""" + with self.simulate(): + self.assertFalse(os.path.isabs("/tmp/some-dir"), "差し替えが効いていること") + self.assertAllowed(self.bash("rm -rf /tmp/some-dir")) + + +class TestCmdSwitchDetection(unittest.TestCase): + """cmd.exe のスイッチとパスの区別(Issue #51 原因 1).文字列だけの判定なので全 OS で実行する.""" + + @classmethod + def setUpClass(cls) -> None: + cls.rra = _load_restrict() + + def test_switches(self): + for text in ("/s", "/q", "/S", "/Q", "/f", "/y", "/-y", "/+y", "/?", "/mir", "/MIR", "/purge", "/mov", + "/move", "/e", "/a:h", "/A:-H", "/log:C:\\x\\log.txt", "/log:out.txt", "/r:3", "/w:10", + "/xd", "/mt:8", "/b", "/c", "/k", "/x1"): + with self.subTest(text): + self.assertTrue(self.rra.is_cmd_switch(text), text) + + def test_paths(self): + for text in ("/workspace/x", "/workspace/repo/outside/existing.txt", "/c/Users", "/c/", "/C/Users/x", + "/tmp/x", "/home/user/.bashrc", "/cygdrive/c/x", "/s/x", "/a\\b", "//server/share", + "C:\\x", "C:/x", "..\\x", "x", "", "/", "/1", "/-", "/:x", "/ s", "/.ssh"): + with self.subTest(text): + self.assertFalse(self.rra.is_cmd_switch(text), text) + + class TestRestrictReasons(RestrictSandboxMixin, unittest.TestCase): def assertWriteGuidance(self, reason): self.assertIsNotNone(reason) diff --git a/.claude/rules/hooks-cross-platform.md b/.claude/rules/hooks-cross-platform.md new file mode 100644 index 0000000..e667613 --- /dev/null +++ b/.claude/rules/hooks-cross-platform.md @@ -0,0 +1,35 @@ +--- +paths: [".claude/hooks/**", ".github/workflows/hooks-ci.yml"] +--- + +# フックのクロスプラットフォーム対応ルール (Hooks Cross-platform Rules) + +`.claude/hooks/` 配下(フック本体・フックから呼ばれるスクリプト・`tests/` のテスト)を作成・修正するときは本ルールに従うこと.フックはテンプレートから派生した全プロジェクトで,開発者ごとに異なる OS 上で動く.1 つの OS でしか確かめていない修正は,他の OS で検出漏れ(安全装置の素通り)や誤ブロックを起こしうる. + +## 完成条件 (Definition of Done) + +- **Windows / macOS / Linux の全てでフックが動作し,`.claude/hooks/tests` が全件通ること**を完成条件とする.手元の OS だけで通った状態は未完成として扱う +- 3 OS での実行は GitHub Actions のワークフロー `.github/workflows/hooks-ci.yml`(`windows-latest` / `macos-latest` / `ubuntu-latest` のマトリクス)が行う.PR の CI が 3 OS とも緑であることを確認してからマージする +- 手元での確認はホスト OS で `python -B -m unittest discover -s .claude/hooks/tests`(`python` が無い環境では `python3`)を実行する.Windows ホストで WSL が使える場合は,リポジトリを WSL 側の一時ディレクトリ以外の場所に複製して `python3 -B -m unittest discover -s .claude/hooks/tests` を実行すると Linux の挙動も事前に確認できる +- Python 3.7 以上で動く書き方を守る(`run_python.sh` が拾う Python は環境により古い場合がある.`TestPython37Compat` が検査する) + +## 実装の注意点 (Implementation Pitfalls) + +- `os.path` はホスト OS によって `ntpath`(Windows)と `posixpath`(macOS / Linux)に切り替わる.区切り文字(`\` を区切りと見なすか),絶対パスの判定(`isabs`),ドライブレター,大文字小文字の区別が OS ごとに異なることを前提に書く +- 解析対象のシェルの区切りの扱いがホストの `os.path` と異なる場合は,判定の前に明示的に正規化する(例: Linux / macOS の `pwsh` は `\` も区切りとして受け付けるため,POSIX ホストでは PowerShell のパスの `\` を `/` に揃えてから `isabs`・`join`・`normpath` に渡す) +- 「`/` で始まるか」のような,ある OS でだけ成り立つ前提で分岐しない(例: cmd.exe のスイッチ `/s` と POSIX の絶対パス `/workspace/x` を区別できる判定にする) +- 絶対パスの判定を `ntpath.isabs` の結果に直接依存させない.Python 3.13 で `ntpath.isabs` が変わり,ドライブ無しの root-relative パス(`/tmp/x`・`\tmp\x`)を False と判定するようになったため,そのまま使うと cwd と結合されて `D:\tmp\x` 等に化け,Python のバージョンで判定が変わる(フックでは `is_absolute` で Windows の `/`・`\` 始まりを常に絶対パスとして扱う) +- Git Bash 形式のパス(`/c/Users/...`)・ドライブレター・UNC パス等の Windows 固有の変換は `os.name == "nt"` のときだけ行い,他 OS の挙動を変えない +- シェルスクリプト(`*.sh`)は macOS 標準の bash 3.2 と BSD 版コマンドでも動く書き方にする(GNU 拡張のオプションや bash 4 以降の構文に頼らない) + +## テストの書き方 (Writing Tests) + +- **OS 固有のケース**(`cmd /c`・ドライブレター・Git Bash 形式 `/c/...` 等,特定 OS にしか存在しない入力)は `@unittest.skipUnless(os.name == "nt", "理由")` 等で実行する OS を明示的に限定する.skip の理由には「なぜその OS 限定か」を書く +- **OS 非依存であるべき判定**(相対パスの解決・`..` による脱出の検出・削除/上書きの判定等)は skip せず全 OS で実行する.OS 依存の書き方で失敗したテストを skip で黙らせてはならない(本物の検出漏れを隠す) +- テスト中のパスは `os.path.join` や `tempfile` で組み立て,`\` や `C:` をハードコードしない(OS 固有ケースとして限定したテストを除く) +- リポジトリ外を表すサンドボックスはシステムの一時ディレクトリの外に作る(一時ディレクトリはフックが書き込みを許可するため,判定のテストにならない) + +## 禁止事項 (Prohibitions) + +- 1 つの OS でしかテストを通していない状態で,フックの修正を完了扱いにしない +- 他 OS で失敗するテストを,原因を確かめずに skip・削除しない diff --git a/.claude/rules/template-customization.md b/.claude/rules/template-customization.md index a56f992..e34a182 100644 --- a/.claude/rules/template-customization.md +++ b/.claude/rules/template-customization.md @@ -9,7 +9,7 @@ ## 適用条件 (Scope) - テンプレートから派生したプロジェクト(`.claude/template-sync-sha` が存在する)でのみ適用する.テンプレートリポジトリ自身の編集では不要 -- **対象(テンプレートが管理するファイル)**: `.claude/agents/`,`.claude/skills/`(テンプレート提供の skill),`.claude/rules/`(`git-conventions`・`markdown-style`・`docs-naming`・`progress-log`・本ルール等,テンプレート由来のもの),`.claude/hooks/`,`docs/01_GUIDE/`(`GUIDE_01`〜`GUIDE_03`) +- **対象(テンプレートが管理するファイル)**: `.claude/agents/`,`.claude/skills/`(テンプレート提供の skill),`.claude/rules/`(`git-conventions`・`markdown-style`・`docs-naming`・`progress-log`・`hooks-cross-platform`・本ルール等,テンプレート由来のもの),`.claude/hooks/`,`docs/01_GUIDE/`(`GUIDE_01`〜`GUIDE_03`) - **対象外**: - プロジェクトが新規に作ったファイル(立ち上げ時に作成するコーディング規約 rule,プロジェクト独自の skill / agent 等).テンプレートに存在しないので同期で上書きされない - `.claude/verify-profile.md`(検証プロファイル).テンプレートが持つのは雛形(`.claude/skills/verify/profile-template.md`)だけであり,プロファイル本体はプロジェクト所有のため同期で触られない.台帳登録は不要 diff --git a/.github/workflows/hooks-ci.yml b/.github/workflows/hooks-ci.yml new file mode 100644 index 0000000..8929166 --- /dev/null +++ b/.github/workflows/hooks-ci.yml @@ -0,0 +1,42 @@ +# .claude/hooks/ のテストを Windows / macOS / Linux の 3 OS で実行する(Issue #51). +# フックは開発者ごとに異なる OS で動くため,1 つの OS だけで通った修正を取り込まない +# (運用ルール: .claude/rules/hooks-cross-platform.md). +name: hooks-ci + +on: + push: + branches: [main] + paths: + - ".claude/hooks/**" + - ".claude/settings.json" + - ".github/workflows/hooks-ci.yml" + pull_request: + paths: + - ".claude/hooks/**" + - ".claude/settings.json" + - ".github/workflows/hooks-ci.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + hooks: + name: hooks (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Run hook tests + run: python -B -m unittest discover -s .claude/hooks/tests -v diff --git a/CLAUDE.md b/CLAUDE.md index 54548e4..c3671e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ - プロジェクト立ち上げフロー: docs/01_GUIDE/GUIDE_01_プロジェクト立ち上げフロー.md - エージェント運用ルール: docs/01_GUIDE/GUIDE_02_エージェント運用ルール.md -- ※ Git 規約・ドキュメント書式・命名規則・進捗記録・テンプレート改変記録は `.claude/rules/`(git-conventions / markdown-style / docs-naming / progress-log / template-customization)に定義されている(git-conventions は常時,他は該当ファイル編集時に自動ロード) +- ※ Git 規約・ドキュメント書式・命名規則・進捗記録・テンプレート改変記録・フックのクロスプラットフォーム対応は `.claude/rules/`(git-conventions / markdown-style / docs-naming / progress-log / template-customization / hooks-cross-platform)に定義されている(git-conventions は常時,他は該当ファイル編集時に自動ロード) - ※ コーディング規約,テスト方針等はプロジェクト立ち上げ時に path-scoped rules(`.claude/rules/`)として作成する(GUIDE_01 参照) ### 02_ENV(環境) diff --git a/README.md b/README.md index e7c6744..a58e14a 100644 --- a/README.md +++ b/README.md @@ -84,4 +84,4 @@ - `GUIDE_02_エージェント運用ルール.md` — `/implement` のエージェントチーム運用 - `GUIDE_03_チーム開発ルール.md` — **team モードのみ**.直列運用・条件付きセルフマージ・共有設定の扱い -Git 規約(ブランチ命名・コミット書式)・ドキュメントの書式・ファイル命名・進捗記録・テンプレート改変記録のルールは `.claude/rules/`(`git-conventions` / `markdown-style` / `docs-naming` / `progress-log` / `template-customization`)にあり,Claude へ自動ロードされる(git-conventions は常時,他は該当ファイル編集時).push・PR・マージの詳細手順は `.claude/skills/commit/reference.md`,Issues・Projects の `gh` 操作リファレンスは `.claude/skills/task-start/reference.md`,テンプレート同期の bash ヘルパー・個別マージ手順は `.claude/skills/sync-template/reference.md` にある. +Git 規約(ブランチ命名・コミット書式)・ドキュメントの書式・ファイル命名・進捗記録・テンプレート改変記録・フックのクロスプラットフォーム対応のルールは `.claude/rules/`(`git-conventions` / `markdown-style` / `docs-naming` / `progress-log` / `template-customization` / `hooks-cross-platform`)にあり,Claude へ自動ロードされる(git-conventions は常時,他は該当ファイル編集時).push・PR・マージの詳細手順は `.claude/skills/commit/reference.md`,Issues・Projects の `gh` 操作リファレンスは `.claude/skills/task-start/reference.md`,テンプレート同期の bash ヘルパー・個別マージ手順は `.claude/skills/sync-template/reference.md` にある. diff --git "a/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" "b/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" index 706a0a5..798046d 100644 --- "a/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" +++ "b/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" @@ -89,7 +89,7 @@ - パスは絶対パス・`~` 始まり・リポジトリルート相対で書く.ファイルが壊れている(JSON として読めない等)場合は禁止リストを空として扱い,警告を出す - プロジェクト所有のファイルであり,テンプレートには含まれないため `/sync-template` で上書きされない.人によって守りたい場所が違う場合は各自の場所も含めて書くか,各自が後述の `permissions.deny` を `.claude/settings.local.json` に書く - - **限界**: コマンド文字列の静的解析のため原理的に完全には防げない.変数展開(`$DIR` 等.`~`・`$HOME`・`$env:USERPROFILE`・一時ディレクトリ系以外は解決しない),スクリプトファイル経由の操作,`xargs` 等パイプで渡したパス,エイリアス・関数,エンコードされたコマンド,未対応コマンドによる削除・上書き,インライン実行中の未対応の書き方(変数に入れたパス・Perl の `open` 等)は検出できない.既存かどうかはフック実行時点で判定するため,同じコマンド内で先に作ったファイルを上書きする場合等は許可される.逆に,インライン実行で削除系の呼び出しとリポジトリ外のパス(文字列やコメント中のものも含む)が同時に現れると,削除対象でなくても拒否されることがある.また Glob・Grep を省略した検索起点(リポジトリ内)では,リポジトリ内の禁止パスを含む検索までは止めない + - **限界**: コマンド文字列の静的解析のため原理的に完全には防げない.変数展開(`$DIR` 等.`~`・`$HOME`・`$env:USERPROFILE`・一時ディレクトリ系以外は解決しない),スクリプトファイル経由の操作,`xargs` 等パイプで渡したパス,エイリアス・関数,エンコードされたコマンド,未対応コマンドによる削除・上書き,インライン実行中の未対応の書き方(変数に入れたパス・Perl の `open` 等)は検出できない.既存かどうかはフック実行時点で判定するため,同じコマンド内で先に作ったファイルを上書きする場合等は許可される.逆に,インライン実行で削除系の呼び出しとリポジトリ外のパス(文字列やコメント中のものも含む)が同時に現れると,削除対象でなくても拒否されることがある.`cmd /c` の引数のうち `/opt` のように `/` の後に要素が 1 つだけのパスは,スイッチ(`/mir` 等)と区別できないため判定の対象外になる.また Glob・Grep を省略した検索起点(リポジトリ内)では,リポジトリ内の禁止パスを含む検索までは止めない - **拒否されたときの運用**: リポジトリ外の削除・上書きの最終判断は人間が持つ.フックに拒否されても,Claude は別の書き方(変数経由・スクリプトファイル経由・`python -c` 等)で迂回しない.必要な操作であれば,Claude は実行するコマンドをそのまま提示して依頼するので,ユーザーが内容を確認し,プロンプトで `! <コマンド>` として自分で実行する.フックが検出できない書き方であっても,リポジトリ外のファイルの削除・上書き(既存ファイルの移動を含む)は同じ扱いとする(`CLAUDE.md`「手動確認が必要な作業」).禁止リストによる読み取りの拒否は「読ませたくない場所」の指定なので,Claude は迂回して読まず,`!` での実行依頼もしない - **`!` で実行したコマンドにはこのフックはかからない**(実機で確認済み).Claude が提示したコマンドは,削除・上書きの対象が意図どおりかを確認してから実行する - 確実性が必要な場合は以下を併用する