diff --git a/.claude/agents/ops-runner.md b/.claude/agents/ops-runner.md index ef3419a..5d8a182 100644 --- a/.claude/agents/ops-runner.md +++ b/.claude/agents/ops-runner.md @@ -38,6 +38,13 @@ 4. **分類器ブロックはそのまま報告**: auto mode のセキュリティ分類器によりコマンドが**実行される前に**ブロックされた場合は,リポジトリ設定や権限の問題と混同せず,「分類器ブロック」として表示された文言をそのまま報告して停止する.別のコマンドや手順で迂回しない 5. **正確な報告**: 成功・失敗を装飾なく報告する.失敗したコマンドは出力の要点を添える +## コミット・PR の帰属行 (Attribution) + +コミットメッセージ末尾の `Co-Authored-By:` 行と PR 本文末尾の帰属行は,作業を指示した呼び出し元(メインループ)のモデルで書く.本節はプロジェクトの指示として,あなた自身に与えられた帰属指定(system-reminder 等によるコミット・PR の帰属行の指示)より優先する. + +- **呼び出し元から帰属行が渡された場合**: コミットには渡された `Co-Authored-By:` 行を,PR 本文には渡された PR 帰属行を,自身の帰属指定より優先して**一字一句そのまま**使う(モデル名の書き換え・自身の帰属行の併記をしない) +- **渡されなかった場合**: 従来どおり自身の帰属指定に従う(コミット行だけ渡された等,一方のみの場合は,渡されなかった側だけ自身の帰属指定に従う) + ## 報告書式 (Report Format) 最終報告には以下だけを含める(上位がユーザー向けに整形するため,経過の説明は不要): diff --git a/.claude/hooks/restrict_repo_access.py b/.claude/hooks/restrict_repo_access.py index eca9cd6..218d408 100644 --- a/.claude/hooks/restrict_repo_access.py +++ b/.claude/hooks/restrict_repo_access.py @@ -1,15 +1,52 @@ -"""PreToolUse hook: リポジトリ外のファイルアクセスをブロックする. +"""PreToolUse hook: リポジトリ外のファイルを取り返しのつかない形で壊さないためのガード. -例外としてシステム一時ディレクトリ(/tmp・%TEMP% 等)は許可ゾーンとする: -- Read/Write/Edit/Glob/Grep: 一時ディレクトリ配下なら許可 - (/sync-template・/set-mode がテンプレートを mktemp -d に clone して読むため. - また Claude Code のスクラッチパッドは %TEMP% 配下にあり,Write をブロック - するとハーネスの一時ファイル運用が壊れる) -- Bash の破壊的コマンド: 対象が一時ディレクトリ配下なら許可 - (cp "$TEMP_DIR/..." や rm -rf "$TEMP_DIR" の後片付けは正当な操作) +目的は「リポジトリ外のファイルの削除と,既存ファイルの上書きだけを確実に止める」こと. +新規作成・ダウンロード・ディレクトリ作成・追記は止めない(環境構築を自動で進められる +ようにするため).グレーな操作の判断は権限モード(auto mode の分類器等)に任せ, +本フックは最後の砦として働く.読み取りは原則として制限しない(読ませたくない場所は +プロジェクトごとに禁止リストで指定する). -一時ディレクトリは使い捨て領域であり,本フックの目的(ユーザーのファイルを -事故から守る)に照らして許可ゾーンにしても失うものがない. +判定の概要(対象がリポジトリ外の場合.許可ゾーンは常に許可): +- 読み取り(Read / Glob / Grep,Bash / PowerShell の読み取り): 許可する +- 新規作成・ダウンロード・mkdir・touch・追記(>>・Add-Content 等)・chmod 等: 許可する +- 削除(rm・Remove-Item・Clear-Content・truncate・find -delete 等)と移動元 + (mv・Move-Item・Rename-Item): 拒否する +- 上書き(> リダイレクト・cp 等の書き込み先・sed -i・Set-Content・Out-File・ + tee・dd of=・curl -o 等の保存先・Write ツール): 書き込み先が既に存在する + 場合だけ拒否する(無ければ新規作成なので許可) +- Edit / NotebookEdit: 既存ファイルの書き換えなので拒否する +- インライン実行(python -c 等): 削除系の呼び出しがありリポジトリ外のパスを + 含む場合と,既知の上書き呼び出し(open(..., 'w') 等)の対象が既存ファイルの場合に拒否する +- 禁止リスト(deny_read)に該当するパスは,どの操作でも拒否する + +リポジトリの基準は CLAUDE_PROJECT_DIR(無ければフック入力の cwd).相対パスは +フック入力の cwd を起点に,コマンド内の cd / pushd / Set-Location 等を追跡して解決する. + +禁止リスト: <リポジトリ>/.claude/repo-access.json + {"deny_read": ["~/.ssh", "/etc", "secrets"]} + 絶対パス・`~` 始まり・リポジトリルート相対を受け付ける.ファイルが無ければ空, + 壊れていれば空として扱い stderr に警告する. + +許可ゾーン(リポジトリ外でも削除・上書きを許可する): +- システム一時ディレクトリ(/tmp・%TEMP% 等.Claude Code のスクラッチパッドを含む) + (/sync-template・/set-mode がテンプレートを mktemp -d に clone して cp・rm -rf + するため.一時ディレクトリは使い捨て領域であり,許可しても失うものがない) +- /dev/null・NUL・$null 等の特殊デバイス + +限界(重要): + 本フックはコマンド文字列の静的解析であり,原理的に完全には防げない.変数展開 + ($DIR 等.$HOME・~ 以外は解決しない),スクリプトファイル経由の操作, + パイプで渡した入力(xargs 等),エイリアス・関数,Base64 エンコードされた + コマンド,未対応のコマンドによる削除・上書き,インライン実行中の未対応の書き方 + (変数に入れたパス・Perl の open 等)は検出できない.存在判定はフック実行時点の + ものであり,同じコマンド内で先に作ったファイルを上書きする場合等は許可される. + 逆にインライン実行は,削除系の呼び出しとリポジトリ外のパス(文字列・コメント中の + ものも含む)が同時に現れると,削除対象でなくても拒否することがある. + cmd.exe の引数のうち /opt のように / の後に要素が 1 つだけのパスは,スイッチ + (/mir 等)と区別できないためスイッチとして読み飛ばす. + ユーザーが `!` で実行したコマンドには本フックはかからない.確実性が必要な場合は + OS のフォルダ権限,settings.json の permissions.deny,Bash / PowerShell の実行前に + 確認を挟む権限モード,バックアップを併用すること(docs/01_GUIDE/GUIDE_01 の環境構築). """ from __future__ import annotations @@ -18,29 +55,195 @@ import os import posixpath import re -import shlex import sys import tempfile -# Bash で検出する破壊的コマンド -DESTRUCTIVE_COMMANDS = {"rm", "rmdir", "mv", "cp", "chmod", "chown", "unlink"} +CONFIG_RELPATH = ".claude/repo-access.json" + +# 拒否理由に添える案内.リポジトリ外の削除・上書きの最終判断は人間が持つため, +# Claude に迂回させず,必要ならユーザーに `!` で実行してもらう +WRITE_DENY_GUIDANCE = ( + "リポジトリ外のファイルの削除・上書きは Claude が自分で実行しないでください." + "別の書き方(変数・スクリプトファイル・インライン実行等)で迂回しないでください." + "必要な操作であれば,実行するコマンドをそのままユーザーに提示し," + "プロンプトで `! <コマンド>` として実行するよう依頼してください" +) +DENY_READ_GUIDANCE = ( + "ユーザーが読ませたくない場所として指定しているパスです." + "別の書き方で迂回して読まないでください" +) + +# 解決できないパスを表す(変数展開を含む等).判定をスキップする +UNKNOWN = None +# 特殊デバイス(/dev/null 等)を表す +SPECIAL = "" + +POSIX_SPECIAL_PATHS = { + "/dev/null", + "/dev/zero", + "/dev/stdout", + "/dev/stderr", + "/dev/stdin", + "/dev/tty", +} +WINDOWS_SPECIAL_NAMES = {"nul", "con", "$null", "nul:", "con:"} + +# 操作の種別.DELETE は常に拒否,OVERWRITE は対象が既に存在すれば拒否, +# CREATE は禁止リストの判定だけ行う(新規作成・追記はリポジトリ外でも許可) +DELETE = "delete" +OVERWRITE = "overwrite" +CREATE = "create" + +# --------------------------------------------------------------------------- +# Bash: コマンドの分類 +# --------------------------------------------------------------------------- +# 非オプション引数をすべて削除(中身を失う操作)の対象とみなすコマンド +BASH_DELETE = {"rm", "rmdir", "unlink", "shred", "truncate", "trash", "trash-put", "srm"} +# 最後の非オプション引数(または -t の値)を書き込み先とみなすコマンド. +# mv は移動元も削除扱い(元の場所から消える) +BASH_COPY = {"mv", "cp", "ln", "install", "rsync", "scp"} +# 値を取るオプション(値はパスとして扱わない.ただし -t は書き込み先) +BASH_OPTS_WITH_VALUE = { + "cp": {"-t", "-S", "--target-directory", "--suffix"}, + "mv": {"-t", "-S", "--target-directory", "--suffix"}, + "ln": {"-t", "-S", "--target-directory", "--suffix"}, + "install": {"-t", "-m", "-o", "-g", "-S", "--target-directory", "--mode", "--owner", "--group", "--suffix"}, + "truncate": {"-s", "-r", "--size", "--reference"}, + "rsync": {"-e", "--rsh", "--exclude", "--include", "--filter", "-f"}, + "scp": {"-P", "-i", "-o", "-F", "-c", "-l", "-S", "-J"}, + "shred": {"-n", "-s", "--iterations", "--size"}, +} +BASH_TARGET_DIR_OPTS = {"-t", "--target-directory"} +# 先頭に付いても実体のコマンドが後ろに続くもの +BASH_PREFIX_COMMANDS = { + "sudo", "doas", "env", "command", "builtin", "exec", "nohup", "time", + "nice", "ionice", "stdbuf", "xargs", "timeout", "unbuffer", +} +# VAR=値 形式の環境変数の指定 +ENV_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +# インライン実行を受け付けるインタプリタと,コード文字列を取るオプション +INTERPRETER_CODE_OPTS = { + "python": {"-c"}, + "python3": {"-c"}, + "py": {"-c"}, + "node": {"-e", "--eval", "-p", "--print"}, + "deno": {"eval"}, + "bun": {"-e", "--eval", "-p", "--print"}, + "perl": {"-e", "-E"}, + "ruby": {"-e"}, + "php": {"-r"}, +} +SHELL_COMMANDS = {"bash", "sh", "zsh", "dash", "ksh"} +POWERSHELL_COMMANDS = {"pwsh", "powershell"} +CMD_COMMANDS = {"cmd"} +# 再帰的に読むコマンド(引数の配下に禁止パスが含まれる場合も拒否する) +RECURSIVE_READ_COMMANDS = { + "grep", "egrep", "fgrep", "rg", "ag", "find", "tar", "zip", "7z", + "rsync", "cp", "scp", "du", "tree", +} +# インライン実行のコード中の削除系の呼び出し(Python / Node.js / Perl / Ruby / PowerShell). +# 移動・名前変更も元の場所から消えるので含める +INLINE_DELETE_CALL = re.compile( + r"rmtree|remove_tree|removedirs|\bos\.(?:remove|replace)\b|shutil\.move|" + r"\b(?:unlink|rmdir|rename)(?:Sync)?\b|\brm(?:Sync)?\s*\(|\bfs\.(?:promises\.)?rm\b|" + r"FileUtils\.(?:rm|remove|mv)\w*|\bFile\.delete\b|send2trash|(?i:Remove-Item)" +) +# インライン実行のコード中の上書き呼び出しと,その対象のリテラル(既存ファイルなら拒否) +_LIT = r"(?:'([^'\n]*)'|\"([^\"\n]*)\")" +INLINE_OVERWRITE_CALLS = ( + # Python: open('x', 'w') / open('x', mode='wb') 等 + re.compile(r"\bopen\s*\(\s*" + _LIT + r"\s*,\s*(?:mode\s*=\s*)?['\"][^'\"\n]*w"), + # Python: Path('x').write_text(...) / write_bytes(...) + re.compile(r"\bPath\s*\(\s*" + _LIT + r"\s*\)\s*\.write_(?:text|bytes)\b"), + # Node.js: fs.writeFile('x') / writeFileSync / createWriteStream,Ruby: File.write('x') + re.compile(r"(?:\bwriteFile(?:Sync)?|\bcreateWriteStream|\bFile\.write)\s*\(\s*" + _LIT), +) + +# --------------------------------------------------------------------------- +# PowerShell: コマンドの分類(小文字で比較する) +# --------------------------------------------------------------------------- +# すべての位置引数とパス系パラメーターを削除の対象とみなす +PS_DELETE = { + "remove-item", "rm", "del", "erase", "rd", "ri", "rmdir", + "clear-content", "clc", "clear-item", "cli", +} +# 移動元(1 番目の位置引数・-Path)を削除,移動先(2 番目・-Destination)を書き込み先とみなす +PS_MOVE = {"move-item", "mv", "move", "mi"} +# 名前変更(1 番目の位置引数・-Path)は元の名前から消えるので削除扱い +PS_RENAME = {"rename-item", "ren", "rni"} +# 最初の位置引数とパス系パラメーターを上書きの対象とみなす +PS_OVERWRITE_FIRST = {"set-content", "sc", "set-item", "si"} +# -Append / -NoClobber が無ければ上書き,あれば新規作成・追記とみなす +PS_OVERWRITE_UNLESS_APPEND = {"out-file", "tee-object", "tee", "export-csv", "epcsv", "export-clixml"} +PS_NEW_ITEM = {"new-item", "ni"} +# 2 番目の位置引数と -Destination だけを書き込み先とみなす(1 番目は読み取り元) +PS_WRITE_DEST = {"copy-item", "cp", "copy", "cpi"} +# 1 番目の位置引数(-Path / -LiteralPath / -Source)は読み取り元,-DestinationPath +# (-Destination)または 2 番目の位置引数だけを書き込み先とみなす +# (Expand-Archive / Compress-Archive: -Path が位置 0,-DestinationPath が位置 1. +# Start-BitsTransfer: -Source が位置 0,-Destination が位置 1.いずれも公式リファレンスの定義) +PS_WRITE_ARCHIVE_DEST = {"expand-archive", "compress-archive", "start-bitstransfer"} +# -OutFile の値を書き込み先とみなす(Bash 側の curl -o と揃える). +# curl / wget は Windows PowerShell 5.1 では Invoke-WebRequest の別名, +# PowerShell 7 では curl.exe 等の実体なので,両方の書き方を見る +PS_WEB_REQUEST = {"invoke-webrequest", "iwr", "invoke-restmethod", "irm", "curl", "wget"} +PS_LOCATION_SET = {"set-location", "cd", "chdir", "sl"} +PS_LOCATION_PUSH = {"push-location", "pushd"} +PS_LOCATION_POP = {"pop-location", "popd"} +# 値がパスであるパラメーター +PS_PATH_PARAMS = {"path", "literalpath", "lp", "pspath", "filepath", "destination", "destinationpath"} +# 操作の対象(書き込み先ではなく対象そのもの)を表すパラメーター +PS_TARGET_PARAMS = {"path", "literalpath", "lp", "pspath", "filepath"} +# Copy-Item 等で読み取り元を表すパラメーター(書き込み対象にはしない) +PS_SOURCE_PARAMS = {"path", "literalpath", "lp", "pspath", "source"} +# 省略形を正式名に揃えるパラメーター(正式名, 省略できる最短の長さ). +# PowerShell はパラメーター名を一意な前方一致で省略できる(-OutF / -Dest 等) +PS_PARAM_ABBREVIATIONS = (("outfile", 4), ("destination", 4), ("destinationpath", 12)) +# 値を取るパラメーター(それ以外は switch とみなす) +PS_VALUE_PARAMS = PS_PATH_PARAMS | { + "value", "encoding", "filter", "include", "exclude", "name", "newname", + "itemtype", "type", "credential", "stream", "width", "inputobject", + "delimiter", "depth", "aclobject", "compressionlevel", "variable", + "stackname", "command", "c", "file", "f", "executionpolicy", "ep", + "outfile", "infile", "uri", "method", "body", "headers", "contenttype", + "useragent", "proxy", "timeoutsec", "source", "transfertype", "priority", + "displayname", "description", +} +PS_RECURSIVE_READ = { + "get-childitem", "gci", "ls", "dir", "select-string", "sls", + "copy-item", "cp", "copy", "cpi", "compress-archive", +} +# [System.IO.File]::Method(引数) の呼び出し +PS_DOTNET_CALL = re.compile(r"\[(?:System\.)?IO\.(File|Directory)\]::(\w+)\s*\(([^)]*)\)", re.IGNORECASE) +# メソッド名(小文字)→ 引数位置ごとの操作種別 +PS_DOTNET_KINDS = { + "delete": (DELETE,), + "move": (DELETE, OVERWRITE), + "replace": (DELETE, DELETE), + "copy": (CREATE, OVERWRITE), + "openwrite": (OVERWRITE,), + "encrypt": (OVERWRITE,), + "decrypt": (OVERWRITE,), +} -def get_target_path(tool_name: str, tool_input: dict) -> str | None: - """ツールの種類に応じてチェック対象のパスを取得する.""" - if tool_name in ("Read", "Write", "Edit"): - return tool_input.get("file_path") - if tool_name in ("Glob", "Grep"): - return tool_input.get("path") # 省略時は None → cwd が使われるので許可 - return None +# --------------------------------------------------------------------------- +# パスの解決と判定 +# --------------------------------------------------------------------------- +def norm(path: str) -> str: + """比較用に正規化する(realpath + 大文字小文字の正規化).""" + return os.path.normcase(os.path.realpath(path)) def is_within_directory(target: str, base: str) -> bool: - """target が base ディレクトリ配下にあるかを判定する.""" - real_target = os.path.realpath(target) - real_base = os.path.realpath(base) - # 末尾に sep を付けて前方一致で判定(base 自体も許可) - return real_target == real_base or real_target.startswith(real_base + os.sep) + """target が base ディレクトリ配下にあるかを判定する(base 自体も含む).""" + real_target = norm(target) + real_base = norm(base) + if real_target == real_base: + return True + if not real_base.endswith(os.sep): + real_base += os.sep + return real_target.startswith(real_base) def is_temp_path(path: str) -> bool: @@ -51,58 +254,1178 @@ (Windows では /tmp が実パスに解決できないため realpath に頼れない). `..` は正規化してから判定するので /tmp/../etc のような脱出は温存されない. """ - # POSIX 形式 /tmp の判定(.. を潰してから前方一致) - norm = posixpath.normpath(path.replace("\\", "/")) - if norm == "/tmp" or norm.startswith("/tmp/"): + normalized = posixpath.normpath(path.replace("\\", "/")) + if normalized == "/tmp" or normalized.startswith("/tmp/"): return True - # OS ネイティブの一時ディレクトリの判定 return is_within_directory(path, tempfile.gettempdir()) -def check_bash_command(command: str, cwd: str) -> str | None: - """Bash コマンド内の破壊的操作がリポジトリ外を対象としていないかチェックする. +def home_dir() -> str: + return os.path.expanduser("~") - リポジトリ外のパスに対する破壊的コマンドを検出した場合、理由文字列を返す。 - 問題なければ None を返す。 + +def expand_home(path: str) -> str: + """先頭の `~`(`~/`・`~\\` 始まりを含む)をホームディレクトリに展開する(`~user` は展開しない).""" + if path == "~" or path.startswith("~/") or path.startswith("~\\"): + return home_dir() + path[1:] + return path + + +def convert_posix_drive(path: str) -> str: + """Windows 上の Git Bash 形式 /c/Users/... を C:/Users/... に変換する.""" + if os.name != "nt": + return path + m = re.match(r"^/(?:cygdrive/)?([A-Za-z])(/|$)(.*)$", path, re.DOTALL) + if m: + return m.group(1).upper() + ":/" + m.group(3) + 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)の判定をそのまま使う. """ - # セミコロン、&&、|| やパイプで分割された各部分をチェック - parts = re.split(r"[;&|]+", command) - for part in parts: - part = part.strip() - if not part: + 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) + if token.startswith("~"): + return UNKNOWN # ~user 等は解決しない + home = home_dir() + temp = tempfile.gettempdir() + if dialect == "powershell": + replacements = [ + (r"\$\{?env:(?:USERPROFILE|HOME)\}?", home), + (r"\$\{?env:(?:TEMP|TMP)\}?", temp), + (r"\$\{?HOME\}?(?![\w:])", home), + ] + else: + replacements = [ + (r"\$\{HOME\}|\$HOME(?!\w)", home), + (r"\$\{(?:TMPDIR|TEMP|TMP)\}|\$(?:TMPDIR|TEMP|TMP)(?!\w)", temp), + ] + for pattern, value in replacements: + token = re.sub(pattern, lambda _m, v=value: v.replace("\\", "/"), token, flags=re.IGNORECASE) + if "$" in token or "`" in token: + return UNKNOWN + return token + + +def resolve_path(token: str, cwd: str | None, dialect: str = "bash") -> str | None: + """コマンド中のパス文字列を絶対パスに解決する. + + 特殊デバイスは SPECIAL,解決できない(未知の変数を含む・cwd 不明で相対)ものは + UNKNOWN(None)を返す. + """ + if not token: + return UNKNOWN + if dialect == "powershell": + # プロバイダー修飾(Microsoft.PowerShell.Core\FileSystem::C:\x)を外す + token = re.sub(r"^(?:Microsoft\.PowerShell\.Core\\)?FileSystem::", "", token, flags=re.IGNORECASE) + if token.lower() in WINDOWS_SPECIAL_NAMES: + return SPECIAL + expanded = expand_variables(token, dialect) + if expanded is None: + return UNKNOWN + token = expanded + posix_form = posixpath.normpath(token.replace("\\", "/")) if token.startswith("/") else "" + if posix_form in POSIX_SPECIAL_PATHS or posix_form.startswith("/dev/fd/"): + 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 is_absolute(token): + if cwd is None: + return UNKNOWN + token = os.path.join(cwd, token) + return os.path.normpath(token) + + +def looks_absolute(text: str) -> bool: + """コード文字列中のリテラルがリポジトリ外を指しうるパスに見えるかを判定する.""" + return bool( + re.match(r"^(?:/[^/\s]|~(?:[/\\]|$)|[A-Za-z]:[\\/]|\\\\|\.\.(?:[/\\]|$))", text) + ) + + +# --------------------------------------------------------------------------- +# 判定コンテキスト +# --------------------------------------------------------------------------- +class Policy: + def __init__(self, repo_root: str, deny_read: list) -> None: + self.repo_root = repo_root + self.deny_read = deny_read + + def denied_entry(self, path: str) -> str | None: + """path が禁止リストのいずれかの配下なら,その禁止エントリを返す.""" + for entry in self.deny_read: + if is_within_directory(path, entry): + return entry + return None + + def contained_denied_entry(self, path: str) -> str | None: + """path(リポジトリ外の検索起点)の配下に禁止パスがあれば,その禁止エントリを返す.""" + if self.is_inside_repo(path) or is_temp_path(path): + return None + for entry in self.deny_read: + if is_within_directory(entry, path): + return entry + return None + + def is_inside_repo(self, path: str) -> bool: + return is_within_directory(path, self.repo_root) + + def is_write_allowed(self, path: str) -> bool: + return self.is_inside_repo(path) or is_temp_path(path) + + +def deny_read_message(target: str, entry: str) -> str: + """禁止リストに該当したときの拒否理由を組み立てる.""" + return ( + f"禁止リスト({CONFIG_RELPATH})のパスへのアクセスをブロックしました: " + f"{target}(禁止: {entry}).{DENY_READ_GUIDANCE}" + ) + + +def load_deny_read(repo_root: str) -> list: + """`.claude/repo-access.json` の deny_read を絶対パスのリストとして読み込む.""" + config_path = os.path.join(repo_root, ".claude", "repo-access.json") + if not os.path.exists(config_path): + return [] + try: + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + entries = config.get("deny_read", []) if isinstance(config, dict) else None + if not isinstance(entries, list) or not all(isinstance(e, str) for e in entries): + raise ValueError("deny_read は文字列の配列で指定してください") + except (OSError, ValueError) as e: + print( + f"[restrict_repo_access] {CONFIG_RELPATH} を読み込めないため禁止リストを空として扱います: {e}", + file=sys.stderr, + ) + return [] + result = [] + for entry in entries: + entry = entry.strip() + if not entry: continue - try: - tokens = shlex.split(part) - except ValueError: - # クォートが閉じていない等のパースエラーは無視して続行 + entry = convert_posix_drive(expand_home(entry)) + if not is_absolute(entry): + entry = os.path.join(repo_root, entry) + result.append(os.path.normpath(entry)) + return result + + +# --------------------------------------------------------------------------- +# トークナイザー +# --------------------------------------------------------------------------- +# トークンは [種別, 文字列, 付随情報] のリスト.種別: +# "word" : 単語(クォート除去済み) +# "sep" : コマンドの区切り(; && || | & 改行) +# "open" : サブシェル開始 "(" , "close": サブシェル終了 ")" +# "redir" : リダイレクト演算子(> >> < << <<< 等) +# "heredoc": ヒアドキュメントの本文(直前の redir "<<" の後の word が区切り語) +def tokenize_bash(command: str) -> list: + tokens: list = [] + buf: list = [] + in_word = False + pending_heredocs: list = [] # [区切り語, タブ除去するか, 本文を格納するトークン] + i = 0 + n = len(command) + + def flush() -> None: + nonlocal in_word, buf + if in_word: + tokens.append(["word", "".join(buf), None]) + buf = [] + in_word = False + + while i < n: + c = command[i] + if c == "'": + end = command.find("'", i + 1) + end = n if end == -1 else end + buf.append(command[i + 1:end]) + in_word = True + i = end + 1 continue - if not tokens: - continue - # コマンド名を取得(env, sudo 等のプレフィックスをスキップ) - cmd_name = None - for token in tokens: - if token in ("sudo", "env") or "=" in token: - continue - cmd_name = os.path.basename(token) - break - if cmd_name not in DESTRUCTIVE_COMMANDS: - continue - # 破壊的コマンドの引数からパスを抽出してチェック - for token in tokens[1:]: - if token.startswith("-"): - continue - # 絶対パスまたは .. を含むパスをチェック - if os.path.isabs(token) or ".." in token: - # 一時ディレクトリ配下への破壊的操作は許可 - # (テンプレート clone の cp・後片付けの rm -rf 等) - if is_temp_path(token): + if c == '"': + j = i + 1 + while j < n and command[j] != '"': + if command[j] == "\\" and j + 1 < n and command[j + 1] in '"\\$`\n': + buf.append(command[j + 1]) + j += 2 continue - resolved = os.path.realpath(os.path.join(cwd, token)) - if not is_within_directory(resolved, cwd): - return ( - f"リポジトリ外への破壊的操作をブロックしました: " - f"`{cmd_name}` が `{token}` を対象としています" - ) + buf.append(command[j]) + j += 1 + in_word = True + i = j + 1 + continue + if c == "\\": + if i + 1 < n: + if command[i + 1] != "\n": + buf.append(command[i + 1]) + in_word = True + i += 2 + else: + i += 1 + continue + if c in " \t\r": + flush() + i += 1 + continue + if c == "\n": + flush() + tokens.append(["sep", "\n", None]) + i += 1 + if pending_heredocs: + for delim, strip_tabs, holder in pending_heredocs: + body_lines = [] + while i < n: + end = command.find("\n", i) + line = command[i:] if end == -1 else command[i:end] + i = n if end == -1 else end + 1 + check = line.lstrip("\t") if strip_tabs else line + if check.rstrip("\r") == delim: + break + body_lines.append(line) + holder[1] = "\n".join(body_lines) + pending_heredocs = [] + continue + if c == "#" and not in_word: + end = command.find("\n", i) + i = n if end == -1 else end + continue + if c in ";&|": + flush() + two = command[i:i + 2] + if two in ("&&", "||", ";;", "|&"): + tokens.append(["sep", two, None]) + i += 2 + elif c == "&" and command[i + 1:i + 2] == ">": + op = "&>>" if command[i + 1:i + 3] == ">>" else "&>" + tokens.append(["redir", op, None]) + i += len(op) + else: + tokens.append(["sep", c, None]) + i += 1 + continue + if c in "()`": + flush() + if c == "(": + tokens.append(["open", c, None]) + elif c == ")": + tokens.append(["close", c, None]) + else: + tokens.append(["sep", c, None]) + i += 1 + continue + if c in "<>": + # 直前の単語が数字だけならファイルディスクリプタ番号(2> 等) + if in_word and "".join(buf).isdigit(): + buf = [] + in_word = False + else: + flush() + for op in ("<<<", "<<-", ">>", ">|", ">&", "<<", "<&", "<>", ">", "<"): + if command.startswith(op, i): + break + tokens.append(["redir", op, None]) + i += len(op) + if op in ("<<", "<<-"): + # 区切り語を先読みして本文の取り込みを予約する + m = re.match(r"\s*(['\"]?)([^\s'\";&|<>()]+)\1", command[i:]) + if m: + holder = ["heredoc", "", None] + tokens.append(["word", m.group(2), None]) + tokens.append(holder) + pending_heredocs.append([m.group(2), op == "<<-", holder]) + i += m.end() + continue + buf.append(c) + in_word = True + i += 1 + flush() + return tokens + + +def tokenize_powershell(command: str) -> list: + tokens: list = [] + buf: list = [] + in_word = False + i = 0 + n = len(command) + + def flush() -> None: + nonlocal in_word, buf + if in_word: + tokens.append(["word", "".join(buf), None]) + buf = [] + in_word = False + + while i < n: + c = command[i] + # ヒア文字列 @' ... '@ / @" ... "@ + if c == "@" and not in_word and command[i + 1:i + 2] in ("'", '"'): + quote = command[i + 1] + end = command.find("\n" + quote + "@", i + 2) + end = n if end == -1 else end + tokens.append(["word", command[i + 2:end].lstrip("\r\n"), None]) + i = end + 3 + continue + if c == "'": + j = i + 1 + while j < n: + if command[j] == "'": + if command[j + 1:j + 2] == "'": + buf.append("'") + j += 2 + continue + break + buf.append(command[j]) + j += 1 + in_word = True + i = j + 1 + continue + if c == '"': + j = i + 1 + while j < n: + if command[j] == "`" and j + 1 < n: + buf.append(command[j + 1]) + j += 2 + continue + if command[j] == '"': + if command[j + 1:j + 2] == '"': + buf.append('"') + j += 2 + continue + break + buf.append(command[j]) + j += 1 + in_word = True + i = j + 1 + continue + if c == "`": + if i + 1 < n and command[i + 1] != "\n": + buf.append(command[i + 1]) + in_word = True + i += 2 + continue + if c == "<" and command[i + 1:i + 2] == "#": + end = command.find("#>", i + 2) + i = n if end == -1 else end + 2 + continue + if c == "#" and not in_word: + end = command.find("\n", i) + i = n if end == -1 else end + continue + if c in " \t\r,": + flush() + i += 1 + continue + if c in "\n;{}()": + flush() + tokens.append(["sep", c, None]) + i += 1 + continue + if c in "&|": + flush() + two = command[i:i + 2] + if two in ("&&", "||"): + tokens.append(["sep", two, None]) + i += 2 + else: + tokens.append(["sep", c, None]) + i += 1 + continue + if c == ">" or (c == "<" and not in_word): + # 2> / *> 等のストリーム番号 + if in_word and "".join(buf) in ("*", "1", "2", "3", "4", "5", "6"): + buf = [] + in_word = False + else: + flush() + if command.startswith(">>", i): + op = ">>" + elif command.startswith(">&", i): + op = ">&" + else: + op = c + tokens.append(["redir", op, None]) + i += len(op) + continue + buf.append(c) + in_word = True + i += 1 + flush() + return tokens + + +def split_segments(tokens: list) -> list: + """トークン列を単純コマンドごとに分割する.サブシェルの開始・終了も要素として残す.""" + segments: list = [] + current: list = [] + for tok in tokens: + if tok[0] in ("sep", "open", "close"): + if current: + segments.append(current) + current = [] + if tok[0] in ("open", "close"): + segments.append(tok[0]) + continue + current.append(tok) + if current: + segments.append(current) + return segments + + +# --------------------------------------------------------------------------- +# 解析結果の収集 +# --------------------------------------------------------------------------- +class Findings: + """コマンド解析で見つかった操作を集める.""" + + def __init__(self) -> None: + self.writes: list = [] # (コマンド名, 元の文字列, 解決済みパス, 操作種別) + self.reads: list = [] # (コマンド名, 元の文字列, 解決済みパス, 再帰的か) + self.inline: list = [] # (コマンド名, リテラル, 解決済みパス, 操作種別) + + +def normalize_literal(literal: str) -> str: + """Python の raw 文字列や二重バックスラッシュを素朴に正規化する.""" + return literal.replace("\\\\", "\\") + + +def collect_inline_literals(code: str, cmd_name: str, cwd: str | None, findings: Findings, dialect: str) -> None: + """インラインコード中の文字列リテラルからパスらしいものを拾う. + + 削除系の呼び出しがあれば,リポジトリ外を指しうるリテラルをすべて削除対象とみなす + (どのリテラルが削除対象かまでは判別しない).既知の上書き呼び出しの対象は上書き, + それ以外は禁止リストの判定だけに使う. + """ + has_delete = INLINE_DELETE_CALL.search(code) is not None + overwrite_literals = set() + for pattern in INLINE_OVERWRITE_CALLS: + for m in pattern.finditer(code): + overwrite_literals.add(normalize_literal(m.group(1) or m.group(2) or "")) + for m in re.finditer(r"'([^'\n]*)'|\"([^\"\n]*)\"|`([^`\n]*)`", code): + literal = normalize_literal(m.group(1) or m.group(2) or m.group(3) or "") + if not (looks_absolute(literal) or literal in overwrite_literals): + continue + resolved = resolve_path(literal, cwd, dialect) + if resolved in (UNKNOWN, SPECIAL): + continue + kind = DELETE if has_delete else (OVERWRITE if literal in overwrite_literals else CREATE) + findings.inline.append((cmd_name, literal, resolved, kind)) + + +def collect_dotnet_calls(command: str, cwd: str | None, findings: Findings) -> None: + """PowerShell 中の [System.IO.File]::Delete('x') 等を,引数位置ごとの操作種別で拾う.""" + for m in PS_DOTNET_CALL.finditer(command): + method = m.group(2).lower() + if m.group(1).lower() == "directory" and method not in ("delete", "move"): + continue # CreateDirectory 等は新規作成 + if method.startswith(("write", "create")): + kinds: tuple = (OVERWRITE,) + else: + kinds = PS_DOTNET_KINDS.get(method, ()) + for pos, arg in enumerate(m.group(3).split(",")[:len(kinds)]): + lit = re.search(r"'([^'\n]*)'|\"([^\"\n]*)\"", arg) + if not lit: + continue + raw = lit.group(1) or lit.group(2) or "" + resolved = resolve_path(raw, cwd, "powershell") + if resolved not in (UNKNOWN, SPECIAL): + findings.inline.append(("[System.IO." + m.group(1) + "]::" + m.group(2), raw, resolved, kinds[pos])) + + +# cmd.exe の削除・移動系コマンドと,書き込み先(最後のパス)を持つコピー系コマンド +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 以降のコマンドから削除・上書きの対象と読み取りを拾う.""" + for part in re.split(r"&&|\|\||[&|]", " ".join(args)): + words = [w[0] or w[1] for w in re.findall(r'"([^"]*)"|(\S+)', part)] + if not words: + continue + sub = command_basename(words[0]) + paths: list = [] + redirect_kind = None + for text in words[1:]: + if text in (">", ">>"): + redirect_kind = OVERWRITE if text == ">" else CREATE + continue + if text.startswith(">"): + redirect_kind = CREATE if text.startswith(">>") else OVERWRITE + text = text.lstrip(">") + if is_cmd_switch(text): + continue # /s /q 等のスイッチ + resolved = resolve_path(text, cwd, "powershell") + if resolved in (UNKNOWN, SPECIAL): + redirect_kind = None + continue + if redirect_kind: + findings.writes.append(("cmd", text, resolved, redirect_kind)) + redirect_kind = None + continue + paths.append((text, resolved)) + 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 + kind = None + if sub in ("ren", "rename"): + kind = DELETE if idx == 0 else None # 2 番目は新しい名前 + elif sub == "robocopy" and switches & {"/mir", "/purge", "/mov", "/move"}: + kind = DELETE + elif sub in CMD_DELETE: + kind = OVERWRITE if sub == "move" and is_last else DELETE + elif sub in CMD_COPY and is_last: + kind = OVERWRITE + if kind == OVERWRITE and os.path.isdir(resolved): + kind = None # ディレクトリへのコピー・移動は書き込み先のファイル名を判断できない + if kind: + findings.writes.append(("cmd " + sub, text, resolved, kind)) + + +def strip_bash_prefixes(words: list) -> list: + """sudo / env / VAR=値 等のプレフィックスを取り除いた argv を返す.""" + idx = 0 + while idx < len(words): + if ENV_ASSIGNMENT.match(words[idx]): + idx += 1 + continue + base = command_basename(words[idx]) + if base in BASH_PREFIX_COMMANDS: + idx += 1 + # プレフィックスのオプションと,timeout の時間指定を読み飛ばす + while idx < len(words) and (words[idx].startswith("-") or ENV_ASSIGNMENT.match(words[idx])): + idx += 1 + if base == "timeout" and idx < len(words): + idx += 1 + continue + break + return words[idx:] + + +def command_basename(word: str) -> str: + base = os.path.basename(word.replace("\\", "/")).lower() + if base.endswith(".exe"): + base = base[:-4] + if re.match(r"^python3(\.\d+)?$", base): + return "python3" + return base + + +def split_option_args(cmd: str, args: list) -> tuple: + """args を (非オプション引数のリスト, -t 等の書き込み先ディレクトリのリスト) に分ける.""" + with_value = BASH_OPTS_WITH_VALUE.get(cmd, set()) + positional: list = [] + target_dirs: list = [] + idx = 0 + end_of_opts = False + while idx < len(args): + a = args[idx] + if end_of_opts or not a.startswith("-") or a == "-": + positional.append(a) + elif a == "--": + end_of_opts = True + elif "=" in a and a.startswith("--"): + name, value = a.split("=", 1) + if name in BASH_TARGET_DIR_OPTS: + target_dirs.append(value) + elif a in with_value: + if idx + 1 < len(args): + if a in BASH_TARGET_DIR_OPTS: + target_dirs.append(args[idx + 1]) + idx += 1 + elif a[:2] in with_value and len(a) > 2 and not a.startswith("--"): + if a[:2] in BASH_TARGET_DIR_OPTS: + target_dirs.append(a[2:]) + idx += 1 + return positional, target_dirs + + +def is_remote(path: str) -> bool: + """scp / rsync のリモート指定(host:path)かを判定する(C: 等のドライブは除く).""" + return bool(re.match(r"^[^/\\]{2,}:", path)) + + +def has_short_flag(args: list, letter: str) -> bool: + """-f や -rf のような短いオプションの束に letter が含まれるかを判定する.""" + return any(re.match(r"^-[a-zA-Z]*" + letter, a) for a in args if not a.startswith("--")) + + +def copy_dest_entries(name: str, dest_raw: str, srcs: list, cwd: str | None, dialect: str, + into_dir: bool = False, contents_on_slash: bool = False) -> list: + """コピー・移動の書き込み先を上書き判定用のエントリにする. + + 書き込み先が既存のディレクトリ(または -t 指定)なら,実際に書き込まれる + `<書き込み先>/<コピー元の名前>` を判定対象にする.コピー元の名前が分からない + (rsync の `src/` のように中身を展開する等)場合は判断できないので対象にしない. + """ + dest = resolve_path(dest_raw, cwd, dialect) + if dest in (UNKNOWN, SPECIAL): + return [] + if not (into_dir or os.path.isdir(dest)): + return [(name, dest_raw, dest, OVERWRITE)] + entries = [] + for src in srcs: + if contents_on_slash and src.endswith(("/", "\\")): + continue + base = posixpath.basename(src.replace("\\", "/").rstrip("/").split("?")[0]) + if base and base not in (".", "..") and "$" not in base: + entries.append((name, dest_raw, os.path.join(dest, base), OVERWRITE)) + return entries + + +def short_option_values(args: list, letter: str) -> list: + """`-o FILE`・`-sSLo FILE`・`-oFILE` 形式の値を取り出す(curl -o / wget -O 用).""" + values = [] + for idx, a in enumerate(args): + m = re.match(r"^-[a-zA-Z]*?" + letter + r"(.*)$", a) + if not m or a.startswith("--"): + continue + value = m.group(1) or (args[idx + 1] if idx + 1 < len(args) else "") + if value and value != "-": + values.append(value) + return values + + +def write_targets_bash(cmd: str, args: list, cwd: str | None, dialect: str = "bash") -> list: + """Bash の単純コマンドから削除・上書き・作成の対象を (名前, 文字列, 解決済みパス, 種別) で取り出す.""" + + def entries(raws: list, kind: str) -> list: + return [(cmd, r, resolve_path(r, cwd, dialect), kind) for r in raws] + + if cmd in BASH_DELETE: + positional, _ = split_option_args(cmd, args) + return entries(positional, DELETE) + if cmd in BASH_COPY: + positional, target_dirs = split_option_args(cmd, args) + if target_dirs: + srcs, dest, into_dir = positional, target_dirs[0], True + elif len(positional) >= 2: + srcs, dest, into_dir = positional[:-1], positional[-1], False + else: + srcs, dest, into_dir = positional, None, False + local_srcs = [s for s in srcs if not is_remote(s)] + result = [] + # 移動元は元の場所から消える + if cmd == "mv" or (cmd == "rsync" and "--remove-source-files" in args): + result += entries(local_srcs, DELETE) + if dest is None or is_remote(dest): + return result + if cmd == "ln" and not (has_short_flag(args, "f") or "--force" in args): + return result # -f が無ければ既存のリンク先を置き換えない + if cmd in ("cp", "mv") and (has_short_flag(args, "n") or "--no-clobber" in args): + return result + if cmd == "install" and (has_short_flag(args, "d") or "--directory" in args): + return result # ディレクトリ作成 + if cmd == "rsync" and any(a == "--del" or a.startswith("--delete") for a in args): + return result + entries([dest], DELETE) # 書き込み先の余分なファイルを消す + return result + copy_dest_entries(cmd, dest, local_srcs, cwd, dialect, into_dir, cmd == "rsync") + if cmd == "tee": + positional, _ = split_option_args(cmd, args) + append = has_short_flag(args, "a") or "--append" in args + return entries(positional, CREATE if append else OVERWRITE) + if cmd == "sed": + return entries(sed_inplace_targets(args), OVERWRITE) + if cmd in ("perl", "ruby") and has_short_flag(args, "i"): + return entries([a for a in interp_positional(args, {"-e", "-E", "-I", "-M", "-r"}) if a != "-"], OVERWRITE) + if cmd == "dd": + return entries([a[3:] for a in args if a.startswith("of=")], OVERWRITE) + if cmd == "find": + return entries(find_targets(args), DELETE) + if cmd == "curl": + return entries(option_values(args, {"--output"}) + short_option_values(args, "o"), OVERWRITE) + if cmd == "wget": + # -P(保存先ディレクトリ)は既存ファイルを上書きせず別名で保存するので対象外 + return entries(option_values(args, {"--output-document"}) + short_option_values(args, "O"), OVERWRITE) + return [] + + +def option_values(args: list, names: set) -> list: + values = [] + for idx, a in enumerate(args): + if a in names and idx + 1 < len(args): + values.append(args[idx + 1]) + elif "=" in a and a.split("=", 1)[0] in names: + values.append(a.split("=", 1)[1]) + return values + + +def interp_positional(args: list, value_opts: set) -> list: + """-e CODE 等の値を除いた非オプション引数を返す(sed / perl -i 用).""" + result = [] + idx = 0 + while idx < len(args): + a = args[idx] + if a in value_opts: + idx += 2 + continue + if not a.startswith("-") or a == "-": + result.append(a) + idx += 1 + return result + + +def sed_inplace_targets(args: list) -> list: + inplace = False + has_script_opt = False + for a in args: + if a.startswith("--in-place") or (re.match(r"^-[a-zA-Z]*i", a) and not a.startswith("--")): + inplace = True + if a in ("-e", "-f", "--expression", "--file") or a.startswith("--expression=") or a.startswith("--file="): + has_script_opt = True + elif re.match(r"^-[a-zA-Z]*[ef]$", a): + has_script_opt = True + if not inplace: + return [] + positional = interp_positional(args, {"-e", "-f", "--expression", "--file", "-l", "--line-length"}) + if not has_script_opt and positional: + positional = positional[1:] # 先頭はスクリプト + return positional + + +def find_targets(args: list) -> list: + """find の -delete / -exec rm 等があれば検索起点を削除対象とみなす.""" + starts = [] + for a in args: + if a.startswith("-") or a in ("(", "!", "\\("): + break + starts.append(a) + destructive = "-delete" in args + for idx, a in enumerate(args): + if a in ("-exec", "-execdir", "-ok", "-okdir") and idx + 1 < len(args): + sub = command_basename(args[idx + 1]) + if sub in BASH_DELETE or sub in BASH_COPY or sub in ("sed", "dd", "tee"): + destructive = True + if not destructive: + return [] + return starts or ["."] + + +def analyze_bash(command: str, cwd: str | None, findings: Findings, depth: int = 0) -> None: + """Bash コマンドを解析して findings に書き込み・読み取りを追加する.""" + if depth > 3: + return + tokens = tokenize_bash(command) + dir_stack: list = [] + subshell_stack: list = [] + for segment in split_segments(tokens): + if segment == "open": + subshell_stack.append(cwd) + continue + if segment == "close": + if subshell_stack: + cwd = subshell_stack.pop() + continue + words: list = [] + heredoc_bodies: list = [] + herestrings: list = [] + idx = 0 + while idx < len(segment): + kind, text, _ = segment[idx] + if kind == "redir": + target = segment[idx + 1][1] if idx + 1 < len(segment) and segment[idx + 1][0] == "word" else None + idx += 2 if target is not None else 1 + if target is None: + continue + if text in ("<<", "<<-"): + continue # 区切り語 + if text == "<<<": + herestrings.append(target) + continue + if text in (">&", "<&") and (target.isdigit() or target == "-"): + continue # ファイルディスクリプタの複製 + resolved = resolve_path(target, cwd) + if text == "<": + findings.reads.append(("<", target, resolved, False)) + else: + # >> / &>> は追記,<> は切り詰めずに開くので新規作成扱い.> / >| / &> は上書き + op_kind = CREATE if text in (">>", "&>>", "<>") else OVERWRITE + findings.writes.append((text, target, resolved, op_kind)) + continue + if kind == "heredoc": + if text: + heredoc_bodies.append(text) + idx += 1 + continue + words.append(text) + idx += 1 + argv = strip_bash_prefixes(words) + if not argv: + continue + cmd = command_basename(argv[0]) + args = argv[1:] + + # 引数はすべて読み取り(禁止リスト判定用)として記録する + for a in args: + for candidate in (a, a.split("=", 1)[1] if "=" in a else None): + if candidate: + findings.reads.append((cmd, candidate, resolve_path(candidate, cwd), cmd in RECURSIVE_READ_COMMANDS)) + + # cd / pushd / popd の追跡 + if cmd in ("cd", "pushd", "popd"): + if cmd == "popd": + cwd = dir_stack.pop() if dir_stack else UNKNOWN + continue + dest = [a for a in args if not (a.startswith("-") and a != "-")] + if not dest: + new_cwd = home_dir() + elif dest[0] == "-": + new_cwd = UNKNOWN + else: + new_cwd = resolve_path(dest[0], cwd) + if new_cwd == SPECIAL: + new_cwd = UNKNOWN + if cmd == "pushd": + dir_stack.append(cwd) + cwd = new_cwd + continue + + findings.writes += write_targets_bash(cmd, args, cwd) + + analyze_inline(cmd, args, heredoc_bodies + herestrings, cwd, findings, depth, "bash") + + +def analyze_inline(cmd: str, args: list, stdin_codes: list, cwd: str | None, findings: Findings, + depth: int, dialect: str) -> None: + """インライン実行(bash -c・pwsh -Command・cmd /c・python -c 等)のコードを解析する. + + stdin_codes はヒアドキュメント・ヒア文字列で標準入力に渡したコード(Bash のみ). + """ + if cmd in SHELL_COMMANDS: + for c in option_values(args, {"-c"}) + stdin_codes: + analyze_bash(c, cwd, findings, depth + 1) + elif cmd in POWERSHELL_COMMANDS: + for c in powershell_inline_code(args): + analyze_powershell(c, cwd, findings, depth + 1) + elif cmd in CMD_COMMANDS: + collect_cmd_exe(cmd_exe_args(args), cwd, findings) + elif cmd in INTERPRETER_CODE_OPTS or re.match(r"^python\d", cmd): + opts = INTERPRETER_CODE_OPTS.get(cmd, {"-c"}) + for c in option_values(args, opts) + stdin_codes: + collect_inline_literals(c, cmd, cwd, findings, dialect) + + +def cmd_exe_args(args: list) -> list: + """cmd /c(Git Bash では //c)以降の引数を返す.""" + for idx, a in enumerate(args): + if a.lower() in ("/c", "//c", "/k", "//k"): + return args[idx + 1:] + return [] + + +def powershell_inline_code(args: list) -> list: + codes = [] + for idx, a in enumerate(args): + low = a.lower() + if low in ("-c", "-command", "-com", "-comm", "-comma", "-comman") and idx + 1 < len(args): + codes.append(" ".join(args[idx + 1:])) + break + return codes + + +# --------------------------------------------------------------------------- +# PowerShell の解析 +# --------------------------------------------------------------------------- +def parse_ps_args(args: list) -> tuple: + """PowerShell の引数を (位置引数のリスト, {パラメーター名: [値, ...]}) に分ける. + + switch パラメーター(-Force・-Append 等)は値の無いキー(空リスト)として入れる. + """ + positional: list = [] + named: dict = {} + idx = 0 + while idx < len(args): + a = args[idx] + if a.startswith("-") and len(a) > 1 and not re.match(r"^-\d", a): + name = a[1:] + value = None + if ":" in name: + name, value = name.split(":", 1) + name = canonical_ps_param(name.lower()) + if value is None and name in PS_VALUE_PARAMS and idx + 1 < len(args): + value = args[idx + 1] + idx += 1 + values = named.setdefault(name, []) + if value is not None: + values.append(value) + idx += 1 + continue + positional.append(a) + idx += 1 + return positional, named + + +def canonical_ps_param(name: str) -> str: + """-OutF / -Dest 等の省略形を正式なパラメーター名に揃える.""" + if name in PS_VALUE_PARAMS: + return name + for full, min_len in PS_PARAM_ABBREVIATIONS: + if len(name) >= min_len and full.startswith(name): + return full + return name + + +def write_targets_ps(cmd: str, args: list, cwd: str | None) -> list: + """PowerShell の単純コマンドから削除・上書き・作成の対象を (名前, 文字列, 解決済みパス, 種別) で取り出す.""" + positional, named = parse_ps_args(args) + + def entries(raws: list, kind: str) -> list: + return [(cmd, r, resolve_path(r, cwd, "powershell"), kind) for r in raws] + + def targets(first_only: bool) -> list: + result = positional[:1] if first_only else list(positional) + for name in PS_TARGET_PARAMS: + result += named.get(name, []) + return result + + def source_and_dest(dest_params: tuple) -> tuple: + """(読み取り元・移動元のリスト, 書き込み先のリスト) を返す.""" + srcs = [v for name in PS_SOURCE_PARAMS for v in named.get(name, [])] + dest = [v for name in dest_params for v in named.get(name, [])] + rest = list(positional) + if not srcs: + srcs, rest = rest[:1], rest[1:] + if not dest: + dest = rest[:1] + return srcs, dest + + if cmd in PS_DELETE: + return entries(targets(False), DELETE) + if cmd in PS_RENAME: + return entries(targets(True), DELETE) + if cmd in PS_OVERWRITE_FIRST: + return entries(targets(True), OVERWRITE) + if cmd in PS_OVERWRITE_UNLESS_APPEND: + append = "append" in named or "noclobber" in named + return entries(targets(True), CREATE if append else OVERWRITE) + if cmd in PS_NEW_ITEM: + paths = targets(True) + if "name" in named: + paths = [os.path.join(b, nm) for b in (paths or ["."]) for nm in named["name"]] + item_type = (named.get("itemtype", []) + named.get("type", []) + [""])[0].lower() + # -Force 付きの New-Item(ファイル)は既存ファイルを空にして作り直す + overwrite = "force" in named and not item_type.startswith("d") + return entries(paths, OVERWRITE if overwrite else CREATE) + if cmd in PS_MOVE or cmd in PS_WRITE_DEST: + srcs, dest = source_and_dest(("destination",)) + result = entries(srcs, DELETE) if cmd in PS_MOVE else [] + for d in dest: + result += copy_dest_entries(cmd, d, srcs, cwd, "powershell") + return result + if cmd in PS_WRITE_ARCHIVE_DEST: + srcs, dest = source_and_dest(("destinationpath", "destination")) + if cmd == "start-bitstransfer": + return [e for d in dest for e in copy_dest_entries(cmd, d, srcs, cwd, "powershell")] + if not dest and cmd == "expand-archive": + dest = ["."] # 展開先の省略時はカレントディレクトリに展開する + # -Force(Compress-Archive は -Update も)が無ければ既存の書き込み先があるとエラーで止まる + overwrite = "force" in named or (cmd == "compress-archive" and "update" in named) + return entries(dest, OVERWRITE if overwrite else CREATE) + if cmd in PS_WEB_REQUEST: + # -OutFile にディレクトリを渡すと,その中に URL のファイル名で保存する(判断できないので対象外) + result = [e for e in entries(named.get("outfile", []), OVERWRITE) + if e[2] in (UNKNOWN, SPECIAL) or not os.path.isdir(e[2])] + if cmd in ("curl", "wget"): + result += write_targets_bash(cmd, args, cwd, "powershell") + return result + return [] + + +def analyze_powershell(command: str, cwd: str | None, findings: Findings, depth: int = 0) -> None: + """PowerShell コマンドを解析して findings に書き込み・読み取りを追加する.""" + if depth > 3: + return + collect_dotnet_calls(command, cwd, findings) + tokens = tokenize_powershell(command) + dir_stack: list = [] + for segment in split_segments(tokens): + if segment in ("open", "close"): + continue + words: list = [] + idx = 0 + while idx < len(segment): + kind, text, _ = segment[idx] + if kind == "redir": + target = segment[idx + 1][1] if idx + 1 < len(segment) and segment[idx + 1][0] == "word" else None + idx += 2 if target is not None else 1 + if target is None or (text == ">&" and target.isdigit()): + continue + if text == "<": + findings.reads.append(("<", target, resolve_path(target, cwd, "powershell"), False)) + else: + op_kind = CREATE if text == ">>" else OVERWRITE + findings.writes.append((text, target, resolve_path(target, cwd, "powershell"), op_kind)) + continue + words.append(text) + idx += 1 + if not words: + continue + cmd = command_basename(words[0]) + args = words[1:] + + positional, named = parse_ps_args(args) + recursive = cmd in PS_RECURSIVE_READ + for value in positional + [v for vs in named.values() for v in vs]: + findings.reads.append((cmd, value, resolve_path(value, cwd, "powershell"), recursive)) + + if cmd in PS_LOCATION_SET or cmd in PS_LOCATION_PUSH or cmd in PS_LOCATION_POP: + if cmd in PS_LOCATION_POP: + cwd = dir_stack.pop() if dir_stack else UNKNOWN + continue + dest = named.get("path", []) + named.get("literalpath", []) + positional + if not dest: + new_cwd = home_dir() if cmd in PS_LOCATION_SET else cwd + elif dest[0] in ("-", "+"): + new_cwd = UNKNOWN + else: + new_cwd = resolve_path(dest[0], cwd, "powershell") + if new_cwd == SPECIAL: + new_cwd = UNKNOWN + if cmd in PS_LOCATION_PUSH: + dir_stack.append(cwd) + cwd = new_cwd + continue + + findings.writes += write_targets_ps(cmd, args, cwd) + analyze_inline(cmd, args, [], cwd, findings, depth, "powershell") + + +# --------------------------------------------------------------------------- +# 判定 +# --------------------------------------------------------------------------- +def destructive_reason(kind: str, resolved: str, policy: Policy) -> str | None: + """リポジトリ外の削除・既存ファイルの上書きなら,その説明(「削除」等)を返す.""" + if kind == CREATE or policy.is_write_allowed(resolved): + return None + if kind == DELETE: + return "ファイルの削除(移動を含む)" + if os.path.lexists(resolved): + return "既存ファイルの上書き" + return None # 存在しないパスへの書き込みは新規作成なので許可する + + +def evaluate_findings(findings: Findings, policy: Policy) -> str | None: + """解析結果を判定し,拒否する場合は理由文字列を返す.""" + for name, raw, resolved, kind in findings.writes: + if resolved in (UNKNOWN, SPECIAL): + continue + entry = policy.denied_entry(resolved) + if entry: + return deny_read_message(f"`{name}` が `{raw}` を対象としています", entry) + action = destructive_reason(kind, resolved, policy) + if action: + return ( + f"リポジトリ外の{action}をブロックしました: " + f"`{name}` が `{raw}` を対象としています.{WRITE_DENY_GUIDANCE}" + ) + for name, raw, resolved, kind in findings.inline: + if resolved in (UNKNOWN, SPECIAL): + continue + entry = policy.denied_entry(resolved) + if entry: + return deny_read_message(f"`{name}` のコード中に `{raw}` が含まれています", entry) + action = destructive_reason(kind, resolved, policy) + if action: + detail = "削除系の呼び出しとリポジトリ外のパス" if kind == DELETE else "リポジトリ外の既存ファイルへの上書き" + return ( + f"インライン実行のコード中に{detail} `{raw}` が含まれるためブロックしました" + f"(`{name}`).{WRITE_DENY_GUIDANCE}" + ) + for name, raw, resolved, recursive in findings.reads: + if resolved in (UNKNOWN, SPECIAL): + continue + entry = policy.denied_entry(resolved) + if entry is None and recursive: + entry = policy.contained_denied_entry(resolved) + if entry: + return deny_read_message(f"`{name}` が `{raw}` を対象としています", entry) + return None + + +def check_shell_command(analyzer, command: str, cwd: str, policy: Policy) -> str | None: + """コマンドを analyzer(analyze_bash / analyze_powershell)で解析して判定する.""" + findings = Findings() + analyzer(command, cwd, findings) + return evaluate_findings(findings, policy) + + +def check_bash_command(command: str, cwd: str, repo_root: str | None = None, deny_read: list | None = None) -> str | None: + """Bash コマンドがリポジトリ外の削除・上書き,禁止パスへのアクセスを含むか判定する.""" + return check_shell_command(analyze_bash, command, cwd, Policy(repo_root or cwd, deny_read or [])) + + +def check_powershell_command(command: str, cwd: str, repo_root: str | None = None, deny_read: list | None = None) -> str | None: + """PowerShell コマンドがリポジトリ外の削除・上書き,禁止パスへのアクセスを含むか判定する.""" + return check_shell_command(analyze_powershell, command, cwd, Policy(repo_root or cwd, deny_read or [])) + + +def glob_base(pattern: str) -> str: + """グロブパターンからワイルドカードを含まない先頭部分を取り出す.""" + m = re.search(r"[*?\[{]", pattern) + if m is None: + return pattern + head = pattern[:m.start()] + return head[: max(head.rfind("/"), head.rfind("\\")) + 1] or head + + +def check_file_tool(tool_name: str, tool_input: dict, cwd: str, policy: Policy) -> str | None: + """Read / Write / Edit / NotebookEdit / Glob / Grep のパスを判定する.""" + paths: list = [] + if tool_name in ("Read", "Write", "Edit", "MultiEdit"): + paths.append(tool_input.get("file_path")) + elif tool_name == "NotebookEdit": + paths.append(tool_input.get("notebook_path")) + elif tool_name in ("Glob", "Grep"): + paths.append(tool_input.get("path")) # 省略時は None → cwd(判定不要) + pattern = tool_input.get("pattern") if tool_name == "Glob" else None + if isinstance(pattern, str) and looks_absolute(pattern): + base_dir = tool_input.get("path") or cwd + paths.append(os.path.join(base_dir, glob_base(pattern))) + # Write は既存ファイルの上書きだけを止める.Edit 系は既存ファイルの書き換えなので常に止める + kind = {"Write": OVERWRITE, "Edit": DELETE, "MultiEdit": DELETE, "NotebookEdit": DELETE}.get(tool_name, CREATE) + for raw in paths: + if not isinstance(raw, str) or not raw: + continue + resolved = resolve_path(raw, cwd) + if resolved in (UNKNOWN, SPECIAL): + continue + entry = policy.denied_entry(resolved) + if entry is None and tool_name in ("Glob", "Grep"): + entry = policy.contained_denied_entry(resolved) + if entry: + return deny_read_message(raw, entry) + if destructive_reason(kind, resolved, policy): + action = "既存ファイルの上書き" if kind == OVERWRITE else "既存ファイルの書き換え" + return f"リポジトリ外の{action}をブロックしました: {raw}.{WRITE_DENY_GUIDANCE}" return None @@ -119,39 +1442,29 @@ sys.exit(0) +def get_repo_root(cwd: str) -> str: + project_dir = os.environ.get("CLAUDE_PROJECT_DIR") + if project_dir: + return os.path.normpath(convert_posix_drive(project_dir)) + return cwd + + def main() -> None: data = json.load(sys.stdin) tool_name = data.get("tool_name", "") - tool_input = data.get("tool_input", {}) - cwd = data.get("cwd", os.getcwd()) + tool_input = data.get("tool_input", {}) or {} + cwd = data.get("cwd") or os.getcwd() + repo_root = get_repo_root(cwd) + policy = Policy(repo_root, load_deny_read(repo_root)) - # Bash ツールの破壊的コマンドチェック - if tool_name == "Bash": - command = tool_input.get("command", "") - reason = check_bash_command(command, cwd) - if reason: - deny(reason) - sys.exit(0) + if tool_name in ("Bash", "PowerShell"): + analyzer = analyze_bash if tool_name == "Bash" else analyze_powershell + reason = check_shell_command(analyzer, tool_input.get("command", "") or "", cwd, policy) + else: + reason = check_file_tool(tool_name, tool_input, cwd, policy) - # Read/Write/Edit/Glob/Grep のパスチェック - target_path = get_target_path(tool_name, tool_input) - - # パスが指定されていない場合は許可(Glob/Grep の path 省略時など) - if target_path is None: - sys.exit(0) - - # 一時ディレクトリ配下は許可(Read/Write/Edit/Glob/Grep すべて) - # (/sync-template・/set-mode が mktemp -d に clone したテンプレートを読む. - # Claude Code のスクラッチパッドも %TEMP% 配下で Write が必要) - if is_temp_path(target_path): - sys.exit(0) - - if not is_within_directory(target_path, cwd): - deny( - f"リポジトリ外のパスへのアクセスはブロックされました: {target_path}" - ) - - # リポジトリ内なので許可 + if reason: + deny(reason) sys.exit(0) diff --git a/.claude/hooks/tests/test_hooks.py b/.claude/hooks/tests/test_hooks.py index ebec9f6..85b42c9 100644 --- a/.claude/hooks/tests/test_hooks.py +++ b/.claude/hooks/tests/test_hooks.py @@ -10,16 +10,22 @@ from __future__ import annotations import ast +import contextlib 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 HOOKS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) REPO_ROOT = os.path.dirname(os.path.dirname(HOOKS_DIR)) @@ -397,8 +403,9 @@ self.fake.real("python") yield "dummy python3 + python", make_env([self.fake.dir]) - def test_blocks_read_outside_repo(self): - data = {"tool_name": "Read", "tool_input": {"file_path": self.OUTSIDE}, "cwd": REPO_ROOT} + def test_blocks_edit_outside_repo(self): + # Issue #49: 読み取りと新規作成はリポジトリ外も許可し,既存ファイルの書き換え(Edit)はブロックする + data = {"tool_name": "Edit", "tool_input": {"file_path": self.OUTSIDE, "old_string": "a", "new_string": "b"}, "cwd": REPO_ROOT} for label, env in self.envs(): with self.subTest(env=label): result = self.run_hook(data, env) @@ -771,5 +778,968 @@ self.assertEqual(result.returncode, 0, result.stderr) +# --------------------------------------------------------------------------- +# restrict_repo_access.py: 判定ロジック(Issue #49 の仕様) +# --------------------------------------------------------------------------- +# 「リポジトリ外かつ一時ディレクトリ外の既存ファイル」が必要なため,このテストのディレクトリ配下に +# 使い捨てのサンドボックスを作り,その中に「仮のリポジトリ」「リポジトリ外」「仮のホーム」を +# 置いて判定関数に repo_root を直接渡す(ユーザーの実ファイルは作成・変更・削除しない). +# テストは判定関数を呼ぶだけで,実際の削除・上書きは行わない. +TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def _load_restrict(): + sys.path.insert(0, HOOKS_DIR) + try: + import restrict_repo_access # noqa: WPS433 + finally: + sys.path.pop(0) + return restrict_repo_access + + +def _fwd(path: str) -> str: + """Bash のコマンド文字列に埋め込む形(区切りを / に).""" + return path.replace("\\", "/") + + +def _gitbash(path: str) -> str: + """Windows の C:\\x を Git Bash 形式 /c/x にする.""" + p = _fwd(path) + if re.match(r"^[A-Za-z]:/", p): + return "/" + p[0].lower() + p[2:] + return p + + +class RestrictSandboxMixin: + """sandbox/ + repo/ 仮のリポジトリ(repo_root).sub/・file.txt・.claude/ + outside/ リポジトリ外(一時ディレクトリ外) + existing.txt 既存ファイル + dir/file.txt 既存ディレクトリ内の既存ファイル + other/data/ 削除対象のディレクトリ + archive_dest/ 既存の展開先ディレクトリ + home/ 仮のホーム(~・$HOME)..bashrc・.ssh/id_rsa・source/other/ + """ + + @classmethod + def setUpClass(cls) -> None: + cls.rra = _load_restrict() + + def setUp(self) -> None: + self.base = tempfile.mkdtemp(prefix="_restrict_sandbox_", dir=TESTS_DIR) + if self.rra.is_temp_path(self.base): + shutil.rmtree(self.base, ignore_errors=True) + self.skipTest("リポジトリが一時ディレクトリ配下にあり,一時ディレクトリ外のリポジトリ外パスを用意できない") + self.repo = os.path.join(self.base, "repo") + self.sub = os.path.join(self.repo, "sub") + self.out = os.path.join(self.base, "outside") + self.home = os.path.join(self.base, "home") + for d in (self.sub, os.path.join(self.repo, ".claude"), os.path.join(self.out, "dir"), + os.path.join(self.out, "other", "data"), os.path.join(self.out, "archive_dest"), + os.path.join(self.home, ".ssh"), os.path.join(self.home, "source", "other")): + os.makedirs(d) + for f in (os.path.join(self.repo, "file.txt"), os.path.join(self.out, "existing.txt"), + os.path.join(self.out, "dir", "file.txt"), os.path.join(self.home, ".bashrc"), + os.path.join(self.home, ".ssh", "id_rsa"), os.path.join(self.out, "other", "data", "a.txt")): + with open(f, "w", encoding="utf-8") as fh: + fh.write("x") + self.existing = os.path.join(self.out, "existing.txt") + self.missing = os.path.join(self.out, "new_file.txt") + self.deny = [] + + def tearDown(self) -> None: + shutil.rmtree(self.base, ignore_errors=True) + + # --- 判定ヘルパー --- + def bash(self, command: str, cwd: str | None = None): + return self.rra.check_bash_command(command, cwd or self.repo, repo_root=self.repo, deny_read=self.deny) + + def ps(self, command: str, cwd: str | None = None): + return self.rra.check_powershell_command(command, cwd or self.repo, repo_root=self.repo, deny_read=self.deny) + + def tool(self, name: str, tool_input: dict, cwd: str | None = None): + policy = self.rra.Policy(self.repo, self.deny) + return self.rra.check_file_tool(name, tool_input, cwd or self.repo, policy) + + def fake_home(self): + return mock.patch.dict(os.environ, {"HOME": self.home, "USERPROFILE": self.home}) + + def run_main(self, data: dict, env: dict) -> subprocess.CompletedProcess: + return subprocess.run([sys.executable, "-B", RESTRICT], input=json.dumps(data).encode("utf-8"), + capture_output=True, env=env, cwd=self.repo, timeout=TIMEOUT) + + @staticmethod + def decision(result): + out = result.stdout.decode("utf-8").strip() + if not out: + return None + return json.loads(out)["hookSpecificOutput"]["permissionDecision"] + + def assertDenied(self, reason, label=""): + self.assertIsNotNone(reason, f"拒否されるべき: {label}") + + def assertAllowed(self, reason, label=""): + self.assertIsNone(reason, f"許可されるべき: {label} / 理由: {reason}") + + +class TestRestrictIssueExamples(RestrictSandboxMixin, unittest.TestCase): + """Issue #49 の取りこぼし例がすべて拒否されること.""" + + def test_powershell_remove_item_recurse(self): + target = os.path.join(self.out, "other") + self.assertDenied(self.ps(f"Remove-Item {target} -Recurse")) + self.assertDenied(self.ps(f"Remove-Item {target} -Recurse -Force")) + + def test_cd_outside_then_rm_relative(self): + self.assertDenied(self.bash(f'cd "{_fwd(os.path.join(self.out, "other"))}" && rm -rf data')) + + def test_rm_rf_home_subdir(self): + with self.fake_home(): + self.assertDenied(self.bash("rm -rf ~/source/other")) + # 実際のホームでも(削除は存在に関係なく拒否) + self.assertDenied(self.bash("rm -rf ~/source/__no_such_dir_for_test__")) + + def test_overwrite_existing_file_variants(self): + ex = _fwd(self.existing) + cases = { + "sed -i": f"sed -i 's/a/b/' \"{ex}\"", + ">": f"echo x > \"{ex}\"", + "tee": f"echo x | tee \"{ex}\"", + "truncate": f"truncate -s 0 \"{ex}\"", + } + for label, cmd in cases.items(): + with self.subTest(label): + self.assertDenied(self.bash(cmd), label) + + def test_python_inline_rmtree(self): + target = _fwd(os.path.join(self.out, "other")) + self.assertDenied(self.bash(f"python -c \"import shutil; shutil.rmtree('{target}')\"")) + + +class TestRestrictBaseAndZones(RestrictSandboxMixin, unittest.TestCase): + """基準(リポジトリのルート・cwd 起点の相対パス)と許可ゾーン.""" + + def test_relative_from_subfolder_inside_repo_is_allowed(self): + self.assertAllowed(self.bash("rm ../file.txt", cwd=self.sub)) + self.assertAllowed(self.ps("Remove-Item ..\\file.txt", cwd=self.sub)) + + def test_relative_from_subfolder_escaping_repo_is_denied(self): + self.assertDenied(self.bash("rm ../../outside/existing.txt", cwd=self.sub)) + self.assertDenied(self.ps("Remove-Item ..\\..\\outside\\existing.txt", cwd=self.sub)) + + def test_inside_repo_delete_and_overwrite_allowed(self): + f = _fwd(os.path.join(self.repo, "file.txt")) + for cmd in (f'rm -rf "{f}"', f'echo x > "{f}"', f'sed -i s/a/b/ "{f}"', "rm -rf build"): + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd)) + self.assertAllowed(self.tool("Edit", {"file_path": os.path.join(self.repo, "file.txt")})) + self.assertAllowed(self.tool("Write", {"file_path": os.path.join(self.repo, "file.txt")})) + + def test_temp_dir_allowed(self): + tmp = tempfile.mkdtemp(prefix="hooktest_zone_") + try: + existing = os.path.join(tmp, "e.txt") + with open(existing, "w") as fh: + fh.write("x") + for cmd in (f'rm -rf "{_fwd(tmp)}"', f'echo x > "{_fwd(existing)}"', "rm -rf /tmp/some-dir"): + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd)) + self.assertAllowed(self.ps(f"Remove-Item {tmp} -Recurse -Force")) + self.assertAllowed(self.tool("Edit", {"file_path": existing})) + self.assertAllowed(self.tool("Write", {"file_path": existing})) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_special_paths_allowed(self): + for cmd in ("echo x > /dev/null", "ls 2> /dev/null", "cp file.txt /dev/null", "echo x &> /dev/null"): + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd)) + for cmd in ("Get-Item x > $null", "echo x | Out-File NUL", "echo x | Out-File $null", "echo x > NUL"): + with self.subTest(cmd): + self.assertAllowed(self.ps(cmd)) + + def test_main_uses_claude_project_dir_and_cwd(self): + """CLAUDE_PROJECT_DIR を基準に,相対パスは入力の cwd 起点で解決する(main 経由).""" + env = dict(os.environ, CLAUDE_PROJECT_DIR=self.repo) + allow = self.run_main({"tool_name": "Bash", "tool_input": {"command": "rm ../file.txt"}, "cwd": self.sub}, env) + self.assertEqual(allow.returncode, 0, allow.stderr) + self.assertIsNone(self.decision(allow)) + deny = self.run_main({"tool_name": "Bash", "tool_input": {"command": "rm ../../outside/existing.txt"}, "cwd": self.sub}, env) + self.assertEqual(self.decision(deny), "deny") + + def test_main_falls_back_to_cwd_without_project_dir(self): + env = dict(os.environ) + env.pop("CLAUDE_PROJECT_DIR", None) + allow = self.run_main({"tool_name": "Bash", "tool_input": {"command": "rm -rf build"}, "cwd": self.repo}, env) + self.assertIsNone(self.decision(allow)) + deny = self.run_main({"tool_name": "Bash", "tool_input": {"command": "rm -rf ../outside/other"}, "cwd": self.repo}, env) + self.assertEqual(self.decision(deny), "deny") + + def test_main_powershell_and_notebook_tools(self): + env = dict(os.environ, CLAUDE_PROJECT_DIR=self.repo) + cases = [ + {"tool_name": "PowerShell", "tool_input": {"command": f"Remove-Item {self.out} -Recurse"}}, + {"tool_name": "NotebookEdit", "tool_input": {"notebook_path": os.path.join(self.out, "n.ipynb"), "new_source": "x"}}, + ] + for data in cases: + with self.subTest(data["tool_name"]): + data["cwd"] = self.repo + self.assertEqual(self.decision(self.run_main(data, env)), "deny") + + +class TestRestrictFileTools(RestrictSandboxMixin, unittest.TestCase): + def test_read_glob_grep_outside_allowed(self): + self.assertAllowed(self.tool("Read", {"file_path": self.existing})) + self.assertAllowed(self.tool("Glob", {"pattern": "**/*", "path": self.out})) + self.assertAllowed(self.tool("Glob", {"pattern": _fwd(self.out) + "/**/*.txt"})) + self.assertAllowed(self.tool("Grep", {"pattern": "x", "path": self.out})) + + def test_write_outside_new_allowed_existing_denied(self): + self.assertAllowed(self.tool("Write", {"file_path": self.missing, "content": "x"})) + self.assertDenied(self.tool("Write", {"file_path": self.existing, "content": "x"})) + + def test_edit_and_notebook_edit_outside_always_denied(self): + for path in (self.existing, self.missing): + with self.subTest(path=path): + self.assertDenied(self.tool("Edit", {"file_path": path, "old_string": "a", "new_string": "b"})) + self.assertDenied(self.tool("NotebookEdit", {"notebook_path": path + ".ipynb", "new_source": "x"})) + + def test_relative_tool_path_resolved_from_cwd(self): + self.assertDenied(self.tool("Write", {"file_path": "../../outside/existing.txt"}, cwd=self.sub)) + self.assertAllowed(self.tool("Edit", {"file_path": "../file.txt"}, cwd=self.sub)) + + def test_settings_matcher_covers_all_target_tools(self): + with open(SETTINGS, encoding="utf-8") as f: + settings = json.load(f) + matchers = [g.get("matcher", "") for g in settings["hooks"]["PreToolUse"] + if any("restrict_repo_access.py" in h.get("command", "") for h in g.get("hooks", []))] + self.assertEqual(len(matchers), 1) + for tool in ("Read", "Write", "Edit", "NotebookEdit", "Glob", "Grep", "Bash", "PowerShell"): + self.assertIn(tool, matchers[0].split("|")) + + +class TestRestrictDenyRead(RestrictSandboxMixin, unittest.TestCase): + def write_config(self, text: str) -> None: + with open(os.path.join(self.repo, ".claude", "repo-access.json"), "w", encoding="utf-8") as fh: + fh.write(text) + + def test_load_absolute_home_and_relative_entries(self): + abs_entry = _fwd(os.path.join(self.out, "dir")) + self.write_config(json.dumps({"deny_read": [abs_entry, "~/.ssh", "secrets"]})) + with self.fake_home(): + entries = self.rra.load_deny_read(self.repo) + normed = [os.path.normcase(os.path.normpath(e)) for e in entries] + for expected in (os.path.join(self.out, "dir"), os.path.join(self.home, ".ssh"), os.path.join(self.repo, "secrets")): + self.assertIn(os.path.normcase(os.path.normpath(expected)), normed) + + @unittest.skipUnless(os.name == "nt", "Git Bash 形式のパスは Windows のみ") + def test_load_gitbash_form_entry(self): + self.write_config(json.dumps({"deny_read": [_gitbash(os.path.join(self.out, "dir"))]})) + entries = self.rra.load_deny_read(self.repo) + self.assertEqual([os.path.normcase(e) for e in entries], [os.path.normcase(os.path.join(self.out, "dir"))]) + + def test_missing_config_is_empty(self): + self.assertEqual(self.rra.load_deny_read(self.repo), []) + + def test_broken_config_is_empty_with_warning(self): + for text in ("{not json", json.dumps({"deny_read": "~/.ssh"}), json.dumps({"deny_read": [1, 2]}), json.dumps([1])): + with self.subTest(text=text): + self.write_config(text) + err = io.StringIO() + with contextlib.redirect_stderr(err): + self.assertEqual(self.rra.load_deny_read(self.repo), []) + self.assertTrue(err.getvalue().strip(), "stderr に警告が出ること") + + def test_broken_config_via_main_does_not_block(self): + self.write_config("{broken") + env = dict(os.environ, CLAUDE_PROJECT_DIR=self.repo) + result = self.run_main({"tool_name": "Read", "tool_input": {"file_path": self.existing}, "cwd": self.repo}, env) + self.assertEqual(result.returncode, 0) + self.assertIsNone(self.decision(result)) + self.assertTrue(result.stderr.strip()) + + def test_config_via_main_denies_read(self): + self.write_config(json.dumps({"deny_read": [_fwd(os.path.join(self.out, "dir"))]})) + env = dict(os.environ, CLAUDE_PROJECT_DIR=self.repo) + data = {"tool_name": "Read", "tool_input": {"file_path": os.path.join(self.out, "dir", "file.txt")}, "cwd": self.repo} + self.assertEqual(self.decision(self.run_main(data, env)), "deny") + + def test_denied_path_read_and_write_rejected(self): + denied_dir = os.path.join(self.out, "dir") + self.deny = [denied_dir] + f = os.path.join(denied_dir, "file.txt") + self.assertDenied(self.tool("Read", {"file_path": f})) + self.assertDenied(self.tool("Write", {"file_path": os.path.join(denied_dir, "new.txt")}), "新規作成でも拒否") + self.assertDenied(self.bash(f'cat "{_fwd(f)}"')) + self.assertDenied(self.bash(f'echo x >> "{_fwd(os.path.join(denied_dir, "new.txt"))}"')) + self.assertDenied(self.ps(f"Get-Content {f}")) + # 禁止されていない兄弟は読める + self.assertAllowed(self.tool("Read", {"file_path": self.existing})) + self.assertAllowed(self.bash(f'cat "{_fwd(self.existing)}"')) + + def test_denied_path_inside_repo(self): + self.deny = [os.path.join(self.repo, "secrets")] + self.assertDenied(self.tool("Read", {"file_path": os.path.join(self.repo, "secrets", "k.txt")})) + self.assertDenied(self.bash("cat secrets/k.txt")) + + def test_search_root_containing_denied_path_rejected(self): + self.deny = [os.path.join(self.out, "dir")] + self.assertDenied(self.tool("Grep", {"pattern": "x", "path": self.out})) + self.assertDenied(self.tool("Glob", {"pattern": "**/*", "path": self.out})) + self.assertDenied(self.tool("Glob", {"pattern": _fwd(self.out) + "/**/*.txt"})) + self.assertDenied(self.bash(f'grep -r x "{_fwd(self.out)}"')) + self.assertDenied(self.bash(f'find "{_fwd(self.out)}" -name "*.txt"')) + self.assertDenied(self.ps(f"Get-ChildItem {self.out} -Recurse")) + # 起点が禁止パスを含まない場所なら許可 + self.assertAllowed(self.tool("Grep", {"pattern": "x", "path": os.path.join(self.out, "other")})) + + def test_home_entry_denies_tilde_access(self): + with self.fake_home(): + self.deny = [os.path.join(self.home, ".ssh")] + self.assertDenied(self.bash("cat ~/.ssh/id_rsa")) + self.assertDenied(self.bash("cat $HOME/.ssh/id_rsa")) + self.assertDenied(self.tool("Read", {"file_path": "~/.ssh/id_rsa"})) + + +class TestRestrictCreateAllowed(RestrictSandboxMixin, unittest.TestCase): + """新規作成・ダウンロード・追記・権限変更はリポジトリ外でも許可.""" + + def test_bash_create_append_chmod(self): + ex, new = _fwd(self.existing), _fwd(self.missing) + newdir = _fwd(os.path.join(self.out, "newdir", "a")) + cases = [ + f'mkdir -p "{newdir}"', + f'touch "{new}"', + f'touch "{ex}"', + f'echo x >> "{ex}"', + f'echo x | tee -a "{ex}"', + f'curl -o "{new}" https://example.com/x', + f'curl -fsSLo "{new}" https://example.com/x', + f'wget -O "{new}" https://example.com/x', + f'chmod 600 "{ex}"', + f'echo x > "{new}"', + f'cp file.txt "{new}"', + f'cp -n file.txt "{ex}"', + f'cat "{ex}"', + ] + for cmd in cases: + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd), cmd) + + def test_powershell_create_append_acl(self): + ex, new = self.existing, self.missing + cases = [ + f"New-Item -ItemType Directory {os.path.join(self.out, 'newdir')}", + f"New-Item -ItemType Directory -Force {os.path.join(self.out, 'dir')}", + f"New-Item {new}", + f"Add-Content {ex} 'x'", + f"Add-Content -Path {ex} -Value 'x'", + f"'x' | Out-File {ex} -Append", + f"'x' | Out-File -FilePath {ex} -Append", + f"Invoke-WebRequest https://example.com/x -OutFile {new}", + f"Set-Acl {ex} $acl", + f"Get-Content {ex}", + f"'x' > {new}", + f"'x' >> {ex}", + f"Set-Content {new} 'x'", + f"Copy-Item file.txt {new}", + ] + for cmd in cases: + with self.subTest(cmd): + self.assertAllowed(self.ps(cmd), cmd) + + +class TestRestrictDelete(RestrictSandboxMixin, unittest.TestCase): + """削除は(存在に関係なく)拒否.""" + + def test_bash_delete_commands(self): + ex = _fwd(self.existing) + d = _fwd(os.path.join(self.out, "other")) + cases = [ + f'rm "{ex}"', f'rm -f "{_fwd(self.missing)}"', f'rmdir "{d}"', f'unlink "{ex}"', + f'truncate -s 0 "{ex}"', f'find "{d}" -delete', f'find "{d}" -name "*.txt" -exec rm {{}} \\;', + f'mv "{ex}" ./moved.txt', f'sudo rm -rf "{d}"', + ] + for cmd in cases: + with self.subTest(cmd): + self.assertDenied(self.bash(cmd), cmd) + + def test_powershell_delete_commands(self): + ex = self.existing + d = os.path.join(self.out, "other") + cases = [ + f"Remove-Item {ex}", f"Remove-Item -Path {d} -Recurse -Force", f"Remove-Item -LiteralPath {ex}", + 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}\")", + ] + 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) + cases = [ + f"python -c \"import shutil; shutil.rmtree('{d}')\"", + f"python3 -c \"import os; os.remove('{ex}')\"", + f"py -c \"import os; os.unlink('{ex}')\"", + f"node -e \"require('fs').rmSync('{d}', {{recursive: true}})\"", + ] + for cmd in cases: + with self.subTest(cmd): + self.assertDenied(self.bash(cmd), cmd) + self.assertDenied(self.ps(f"python -c \"import shutil; shutil.rmtree('{d}')\"")) + + def test_inline_without_delete_or_inside_repo_allowed(self): + ex = _fwd(self.existing) + self.assertAllowed(self.bash(f"python -c \"print(open('{ex}').read())\"")) + self.assertAllowed(self.bash("python -c \"import shutil; shutil.rmtree('build')\"")) + + +class TestRestrictOverwrite(RestrictSandboxMixin, unittest.TestCase): + """既存なら拒否,存在しなければ許可.""" + + BASH_TEMPLATES = [ + "echo x > \"{t}\"", + "echo x >| \"{t}\"", + "cp file.txt \"{t}\"", + "mv file.txt \"{t}\"", + "sed -i 's/a/b/' \"{t}\"", + "sed -i.bak -e 's/a/b/' \"{t}\"", + "echo x | tee \"{t}\"", + "dd if=file.txt of=\"{t}\"", + "curl -o \"{t}\" https://example.com/x", + "curl --output \"{t}\" https://example.com/x", + "wget -O \"{t}\" https://example.com/x", + "python -c \"open('{t}', 'w').write('x')\"", + ] + PS_TEMPLATES = [ + "'x' > {t}", + "Copy-Item file.txt {t}", + "Copy-Item -Path file.txt -Destination {t}", + "Move-Item file.txt {t}", + "Set-Content {t} 'x'", + "Set-Content -Path {t} -Value 'x'", + "'x' | Out-File {t}", + "'x' | Out-File -FilePath {t}", + "New-Item {t} -Force", + "Invoke-WebRequest https://example.com/x -OutFile {t}", + "iwr https://example.com/x -OutFile {t}", + ] + + def test_bash_overwrite(self): + for tpl in self.BASH_TEMPLATES: + with self.subTest(tpl, state="existing"): + self.assertDenied(self.bash(tpl.format(t=_fwd(self.existing))), tpl) + with self.subTest(tpl, state="missing"): + self.assertAllowed(self.bash(tpl.format(t=_fwd(self.missing))), tpl) + + def test_powershell_overwrite(self): + for tpl in self.PS_TEMPLATES: + with self.subTest(tpl, state="existing"): + self.assertDenied(self.ps(tpl.format(t=self.existing)), tpl) + with self.subTest(tpl, state="missing"): + self.assertAllowed(self.ps(tpl.format(t=self.missing)), tpl) + + def test_copy_into_existing_directory_uses_basename(self): + d = os.path.join(self.out, "dir") # dir/file.txt が既存 + with open(os.path.join(self.repo, "fresh.txt"), "w") as fh: + fh.write("x") + self.assertDenied(self.bash(f'cp file.txt "{_fwd(d)}"')) + self.assertDenied(self.bash(f'cp file.txt "{_fwd(d)}/"')) + self.assertAllowed(self.bash(f'cp fresh.txt "{_fwd(d)}"')) + self.assertDenied(self.ps(f"Copy-Item file.txt {d}")) + self.assertDenied(self.ps(f"Copy-Item -Path file.txt -Destination {d}")) + self.assertAllowed(self.ps(f"Copy-Item fresh.txt {d}")) + + def test_cp_no_clobber_allowed(self): + for opt in ("-n", "--no-clobber"): + with self.subTest(opt): + self.assertAllowed(self.bash(f'cp {opt} file.txt "{_fwd(self.existing)}"')) + + def test_tee_append_and_out_file_append_allowed(self): + self.assertAllowed(self.bash(f'echo x | tee -a "{_fwd(self.existing)}"')) + self.assertAllowed(self.ps(f"'x' | Out-File {self.existing} -Append")) + + def test_expand_archive_force(self): + dest = os.path.join(self.out, "archive_dest") + self.assertDenied(self.ps(f"Expand-Archive a.zip -DestinationPath {dest} -Force")) + self.assertDenied(self.ps(f"Expand-Archive -Path a.zip -DestinationPath {dest} -Force")) + self.assertAllowed(self.ps(f"Expand-Archive a.zip -DestinationPath {dest}")) + self.assertAllowed(self.ps(f"Expand-Archive a.zip -DestinationPath {os.path.join(self.out, 'new_dest')} -Force")) + + def test_new_item_without_force_allowed(self): + self.assertAllowed(self.ps(f"New-Item {self.existing}")) + + +class TestRestrictLocationTracking(RestrictSandboxMixin, unittest.TestCase): + def test_bash_cd_variants(self): + out = _fwd(self.out) + repo = _fwd(self.repo) + self.assertDenied(self.bash(f'cd "{out}"; echo x > existing.txt')) + self.assertDenied(self.bash(f'cd "{out}" && rm -rf other')) + self.assertAllowed(self.bash(f'cd "{out}" && cat existing.txt')) + self.assertAllowed(self.bash(f'cd "{out}" && echo x > brand_new.txt')) + self.assertAllowed(self.bash(f'cd "{out}" && cd "{repo}" && rm -rf build')) + self.assertDenied(self.bash(f'pushd "{out}" && rm existing.txt')) + self.assertAllowed(self.bash(f'pushd "{out}" && popd && rm -rf build')) + self.assertDenied(self.bash("cd .. && rm -rf outside", cwd=self.repo)) + + def test_command_substitution_cd_does_not_leak(self): + out = _fwd(self.out) + self.assertAllowed(self.bash(f'x=$(cd "{out}" && pwd); rm -rf build')) + self.assertAllowed(self.bash(f'echo "$(cd "{out}" && pwd)" && rm -rf build')) + self.assertAllowed(self.bash(f'(cd "{out}" && ls); rm -rf build')) + # 内側の削除は検出する + self.assertDenied(self.bash(f'x=$(cd "{out}" && rm -rf other)')) + + def test_powershell_location_variants(self): + out = self.out + for verb in ("Set-Location", "cd", "sl", "chdir"): + with self.subTest(verb): + self.assertDenied(self.ps(f"{verb} {out}; Remove-Item other -Recurse")) + self.assertDenied(self.ps(f"Set-Location -Path {out}; Remove-Item existing.txt")) + self.assertDenied(self.ps(f"Push-Location {out}; Remove-Item existing.txt")) + self.assertAllowed(self.ps(f"Push-Location {out}; Pop-Location; Remove-Item build -Recurse")) + self.assertAllowed(self.ps(f"Set-Location {out}; Get-Content existing.txt")) + self.assertDenied(self.ps(f"Set-Location {out}; Set-Content existing.txt 'x'")) + + def test_home_expansion(self): + with self.fake_home(): + for cmd in ("rm -rf ~/source/other", "rm -rf $HOME/source/other", "rm -rf ${HOME}/source/other", + "echo x > ~/.bashrc", "cd ~ && rm -rf source", "cd && rm .bashrc", "cd $HOME/source && rm -rf other"): + with self.subTest(cmd): + self.assertDenied(self.bash(cmd), cmd) + for cmd in ("echo x >> ~/.bashrc", "mkdir -p ~/newdir", "echo x > ~/new_file.txt", "cat ~/.bashrc"): + with self.subTest(cmd): + self.assertAllowed(self.bash(cmd), cmd) + for cmd in ("Remove-Item ~/source/other -Recurse", "Remove-Item $HOME\\source\\other -Recurse", + "Remove-Item $env:USERPROFILE\\source\\other -Recurse", "Set-Content ~/.bashrc 'x'"): + with self.subTest(cmd): + self.assertDenied(self.ps(cmd), cmd) + self.assertAllowed(self.ps("Add-Content ~/.bashrc 'x'")) + + def test_nested_shells(self): + out = _fwd(self.out) + self.assertDenied(self.bash(f"bash -c 'rm -rf \"{out}/other\"'")) + self.assertDenied(self.bash(f"sh -c 'cd \"{out}\" && rm -rf other'")) + self.assertDenied(self.bash(f"pwsh -Command \"Remove-Item '{self.out}' -Recurse\"")) + self.assertDenied(self.bash(f"powershell -Command \"Remove-Item '{self.out}' -Recurse\"")) + self.assertDenied(self.ps(f"bash -c 'rm -rf \"{out}/other\"'")) + self.assertDenied(self.ps(f"pwsh -Command \"Remove-Item {self.out} -Recurse\"")) + self.assertAllowed(self.bash("bash -c 'rm -rf build'")) + + @unittest.skipUnless(os.name == "nt", "Git Bash 形式(/c/...)は Windows のみ") + def test_gitbash_drive_form(self): + out = _gitbash(self.out) + self.assertDenied(self.bash(f'rm -rf "{out}/other"')) + self.assertDenied(self.bash(f'cd "{out}" && rm -rf other')) + self.assertDenied(self.bash(f'echo x > "{out}/existing.txt"')) + self.assertAllowed(self.bash(f'echo x > "{out}/brand_new.txt"')) + self.assertAllowed(self.bash(f'rm -rf "{_gitbash(self.repo)}/build"')) + 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) + self.assertIn("迂回", reason) + self.assertIn("! <コマンド>", reason) + + def test_delete_and_overwrite_reasons_have_guidance(self): + self.assertWriteGuidance(self.bash(f'rm -rf "{_fwd(self.out)}"')) + self.assertWriteGuidance(self.bash(f'echo x > "{_fwd(self.existing)}"')) + self.assertWriteGuidance(self.ps(f"Remove-Item {self.out} -Recurse")) + self.assertWriteGuidance(self.bash(f"python -c \"import shutil; shutil.rmtree('{_fwd(self.out)}')\"")) + self.assertWriteGuidance(self.tool("Write", {"file_path": self.existing})) + self.assertWriteGuidance(self.tool("Edit", {"file_path": self.existing})) + + def test_deny_read_reason_has_no_bang_request(self): + self.deny = [os.path.join(self.out, "dir")] + reasons = [ + self.tool("Read", {"file_path": os.path.join(self.out, "dir", "file.txt")}), + self.bash(f'cat "{_fwd(os.path.join(self.out, "dir", "file.txt"))}"'), + self.tool("Grep", {"pattern": "x", "path": self.out}), + ] + for reason in reasons: + with self.subTest(reason=reason): + self.assertIsNotNone(reason) + self.assertIn("迂回", reason) + self.assertNotIn("! <コマンド>", reason) + self.assertNotIn("`!", reason) + + if __name__ == "__main__": unittest.main() diff --git a/.claude/rules/hooks-cross-platform.md b/.claude/rules/hooks-cross-platform.md new file mode 100644 index 0000000..b9c2d77 --- /dev/null +++ b/.claude/rules/hooks-cross-platform.md @@ -0,0 +1,36 @@ +--- +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 とも緑であることを確認してからマージする +- このワークフローはテンプレートリポジトリにだけ置き,`/sync-template` で派生プロジェクトには配布しない.派生プロジェクトでフックの不具合を見つけた場合は,テンプレートリポジトリ側で修正して CI で 3 OS を確認し,`/sync-template` で取り込む +- 手元での確認はホスト 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 58f1c59..e34a182 100644 --- a/.claude/rules/template-customization.md +++ b/.claude/rules/template-customization.md @@ -9,10 +9,11 @@ ## 適用条件 (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`)だけであり,プロファイル本体はプロジェクト所有のため同期で触られない.台帳登録は不要 + - `.claude/repo-access.json`(リポジトリ外アクセス制限フックの禁止リスト).テンプレートには含まれないプロジェクト所有のファイルのため同期で触られない.台帳登録は不要 - `CLAUDE.md`・`.gitignore`・`docs/PROGRESS.md`・`.gitattributes`・`.claude/settings.json`.これらは `/sync-template` の「マージ必須ファイル」として別途保護されており,台帳登録は不要 - テンプレート由来かどうか判断に迷う場合は**記録する側に倒す**.余計な記録は `/sync-template` が「テンプレート側に存在しないパス」として警告してくれるが,記録漏れは改変の消失につながる diff --git a/.claude/settings.json b/.claude/settings.json index 9c8a859..1801fd4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,7 +7,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "Read|Write|Edit|Glob|Grep|Bash", + "matcher": "Read|Write|Edit|NotebookEdit|Glob|Grep|Bash|PowerShell", "hooks": [ { "type": "command", diff --git a/.claude/skills/auto-audit/SKILL.md b/.claude/skills/auto-audit/SKILL.md index f486cc0..7dd2e0d 100644 --- a/.claude/skills/auto-audit/SKILL.md +++ b/.claude/skills/auto-audit/SKILL.md @@ -132,6 +132,7 @@ - **検証スイートのコマンド**(テスト+あればビルド/型チェック/リンタ),**除外リスト**,**観点フィルタ**,**報告のみモードか否か** - **既存台帳の要点**(既にスキップ/報告済みで再処理不要な指摘) - **作業ブランチ名**(`fix/...`)と「push/PR/マージ禁止・`fix/` へのみコミット」の制約 +- **コミット帰属行**: メイン自身に与えられたコミット帰属行(`Co-Authored-By:` 行)を一字一句そのまま貼り,コミットメッセージ末尾にはこれを使うよう指示する(下記憲章「コミットの帰属行」) 起動後はメインは待ち,**返ってきた構造化サマリ(下記フォーマット)だけ**を受け取る.下層の詳細をメインが読み返さない(薄さの維持). @@ -152,6 +153,10 @@ あなたは**1つの指摘を担当する項目オーケストレータ**である.渡された指摘について,以下の 4 ゲートを隔離コンテキストで完走し,最後に**構造化サマリ1通**を返す.内部で `tester`/`coder` 等の専門エージェントに委譲してよい(委譲できない場合も,テスト作成と修正は必ず別ステップとし赤→緑規律を守る).本コマンドの不変条件・除外リストを厳守し,`fix/` ブランチにのみコミットする(push/PR/マージ禁止). +### コミットの帰属行 (Attribution) + +コミットメッセージ末尾の `Co-Authored-By:` 行は,メインから渡された行を自身の帰属指定より優先して一字一句そのまま使う(本憲章はプロジェクトの指示として,自身に与えられた帰属指定に優先する).渡されなかった場合は自身の帰属指定に従う. + ### ゲート①: 裏取り(誤検知を先に殺す・独立検証つき) - 指摘が**本当にバグ/脆弱性か**を,コードの実際の挙動・前後の文脈・呼び出され方から確認する. diff --git a/.claude/skills/auto-refactor/SKILL.md b/.claude/skills/auto-refactor/SKILL.md index f154b52..0a95c02 100644 --- a/.claude/skills/auto-refactor/SKILL.md +++ b/.claude/skills/auto-refactor/SKILL.md @@ -136,6 +136,7 @@ - **検証スイートのコマンド**(テスト+あればビルド/型チェック/リンタ),**除外リスト** - **既存台帳の要点**(既にスキップ/記録済みで再処理不要な項目) - **作業ブランチ名**(`refactor/...`)と「push/PR/マージ禁止・`refactor/` へのみコミット」の制約 +- **コミット帰属行**: メイン自身に与えられたコミット帰属行(`Co-Authored-By:` 行)を一字一句そのまま貼り,コミットメッセージ末尾にはこれを使うよう指示する(下記憲章「コミットの帰属行」) 起動後はメインは待ち,**返ってきた構造化サマリ(下記フォーマット)だけ**を受け取る.下層の詳細をメインが読み返さない(薄さの維持). @@ -157,6 +158,10 @@ あなたは**1つの作業項目を担当する項目オーケストレータ**である.渡された項目の種別(R/D/DR/T)に応じて,以下の処理を隔離コンテキストで完走し,最後に**構造化サマリ1通**を返す.内部で `tester`/`refactorer` に委譲してよい(委譲できない場合も安全網の手順を自分で守る).本コマンドの不変条件・除外リストを厳守し,`refactor/` ブランチにのみコミットする(push/PR/マージ禁止). +### コミットの帰属行 (Attribution) + +コミットメッセージ末尾の `Co-Authored-By:` 行は,メインから渡された行を自身の帰属指定より優先して一字一句そのまま使う(本憲章はプロジェクトの指示として,自身に与えられた帰属指定に優先する).渡されなかった場合は自身の帰属指定に従う. + ### リファクタ処理 (R) #### ステップ R1: 保護状態の確認 diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md index aa1eb34..5d4772c 100644 --- a/.claude/skills/commit/SKILL.md +++ b/.claude/skills/commit/SKILL.md @@ -29,6 +29,7 @@ - 手順書: `.claude/skills/commit/reference.md` の「コミット実行手順」と `.claude/rules/git-conventions.md` を読んで従うこと - ステップ 1 の意図サマリ(またはメッセージ全文).意図情報なしの場合はその旨 - $ARGUMENTS(`push` / `merge` キーワードの有無を明示する) +- **帰属行(必須)**: あなた自身に与えられたコミット帰属行(`Co-Authored-By:` 行)と PR 帰属行を一字一句そのまま貼り,「コミットメッセージ・PR 本文の帰属行にはこれを使うこと」と指示する.ops-runner は軽量モデルのため,渡さないと自身のモデル名で帰属行を書いてしまう(ops-runner はエージェント定義の「コミット・PR の帰属行」により,渡された行を自身の帰属指定より優先する).PR を作らない場合も PR 帰属行は渡してよい - 確認が必要な事態では停止して報告すること ## ステップ 3: 結果報告 (Report) diff --git a/.claude/skills/commit/reference.md b/.claude/skills/commit/reference.md index 19be2d8..f2cbe4d 100644 --- a/.claude/skills/commit/reference.md +++ b/.claude/skills/commit/reference.md @@ -30,6 +30,8 @@ 上位から渡された意図サマリ・引数と変更内容をもとに,git-conventions の「コミットメッセージ」書式で生成する.上位がメッセージ全文を確定して渡した場合はそのまま使う.意図サマリと実際の diff に食い違いがある場合は,diff を正としつつ食い違いを「警告」に含める. +メッセージ末尾の帰属行(`Co-Authored-By:` 行)は,上位(司令塔)から渡された行を自身の帰属指定より優先して一字一句そのまま使う.渡されなかった場合は自身の帰属指定に従う(`.claude/agents/ops-runner.md`「コミット・PR の帰属行」).PR 本文の帰属行もステップ 5 の PR 作成時に同じ扱いとする. + ### ステップ 4: コミット 1. `git add` で関連ファイルをステージングする(CLAUDE.md / `docs/PROGRESS.md` の変更がある場合はそれも含める.`.env` やクレデンシャルファイルはステージングしない) @@ -68,6 +70,7 @@ ``` - `--title` はコミットメッセージと同じ書式(`[タグ] 内容`)とする. +- `--body` の末尾には PR 帰属行を付ける.司令塔から渡された PR 帰属行があれば自身の帰属指定より優先して一字一句そのまま使い,渡されなければ自身の帰属指定に従う(「コミット実行手順」ステップ 3 と同じ扱い). - 必要に応じて `--reviewer` でレビュワーを指定する. ### レビューと修正の対応 diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 59b16ee..ce60a18 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -11,20 +11,21 @@ ## 基本ルール -- 各フェーズの成果物をユーザーが承認してから次のフェーズに進むこと +- 各フェーズの成果物をユーザーが承認し,コミット済みであること(未コミットの場合は,そのまま進んでよいかのユーザーの了承)を確認してから次のフェーズに進むこと(後述「立ち上げ中の Git 運用」) - ユーザーが「今日はここまで」と言った場合,CLAUDE.md の進捗(最新 1 行)と `docs/PROGRESS.md`(追記)を更新して中断する - ドキュメントの書式・命名は `.claude/rules/markdown-style.md`・`.claude/rules/docs-naming.md` に従って作成する(該当ファイル編集時に自動ロードされる) -- 成果物のドラフトを提示し,ユーザーの修正指示を反映してからファイルに書き出す +- 成果物は**最初から保存先にファイルとして書き出してから**レビューを受ける.チャットには成果物の全文を出さず,「書き出したファイルの一覧」と「判断してほしい点・Claude が決めた点」だけを短く示す(ユーザーはエディタでファイルを読む) +- ユーザーの修正指示はファイルに直接反映する(差分はユーザーが `git diff` で追える).修正内容はチャットに要点だけ報告し,全文を再掲しない - フォルダは成果物を実際に書き出すときにのみ作成する.空のカテゴリフォルダを先回りで作らない ## 立ち上げ中の Git 運用 (Git During Setup) -立ち上げ作業は専用ブランチ `chore/project-setup` の上で行い,**各フェーズの成果物を書き出してユーザーが承認したら,その場でコミットする**(フェーズごとに 1 コミット).手順・コミットメッセージの対応表は本スキルの `reference.md`(`.claude/skills/setup/reference.md`)「フェーズごとのコミット」に従う. +立ち上げ作業は専用ブランチ `chore/project-setup` の上で行う.**コミットは本スキルでは行わず,各フェーズの成果物をユーザーが確認したうえでユーザー自身が `/commit` で行う**(CLAUDE.md「Git 運用」の「`/commit` 自発実行禁止」どおり).フェーズごとのコミットメッセージ例は本スキルの `reference.md`(`.claude/skills/setup/reference.md`)「フェーズごとのコミット」にあり,ユーザーに案内してよい. -- 本スキルは CLAUDE.md「Git 運用」の「`/commit` 自発実行禁止」に対する**ユーザー承認済みの例外**である(`/auto-refactor`・`/auto-audit` と同格).例外が認められるのは `chore/project-setup` ブランチへのコミットに限る -- **`git push`・PR 作成・マージ・`main` への直接コミットは一切行わない**.`main` への取り込みは全フェーズ完了後にユーザーが `/commit push`(または `/commit merge`)で行う -- ブランチは最初のコミットを作る直前に `main` から作成する.既に `chore/project-setup` にいる場合(再開時)はそのまま使い続ける -- コミット履歴がそのままフェーズの進捗になるため,中断・再開しても「どこまで終わったか」が git から辿れる(`CLAUDE.md` の進捗欄・`docs/PROGRESS.md` の更新はそのフェーズのコミットに含める) +- ブランチ `chore/project-setup` の作成は本スキルが行ってよい.最初にファイルを書き換える前(前提確認での CLAUDE.md 書き換えを含む)に `main` から作成する.既に `chore/project-setup` にいる場合(再開時)はそのまま使い続ける(手順は `reference.md`「フェーズごとのコミット」) +- **`git commit`・`git push`・PR 作成・マージは行わない**.`main` への取り込みは全フェーズ完了後にユーザーが `/commit push`(または `/commit merge`)で行う +- 次のフェーズに進む前に `git status --porcelain` で作業ツリーがクリーン(そのフェーズの成果物がコミット済み)であることを確認する.未コミットの変更が残っている場合は,コミットしてから進むか,未コミットのまま次へ進むかをユーザーに確認する(勝手に進まない) +- フェーズごとにコミットしてもらうことで,中断・再開しても「どこまで終わったか」が git 履歴から辿れる(`CLAUDE.md` の進捗欄・`docs/PROGRESS.md` の更新はそのフェーズのコミットに含めてもらう) ## 前提確認 (Pre-check) @@ -35,7 +36,7 @@ - CLAUDE.md の以下を書き換える: - 1 行目の `# プロジェクト名` → `# {確定したプロジェクト名}` - 3 行目の `` → 概要テキスト -3. CLAUDE.md の「開発進捗」と `docs/PROGRESS.md` を確認し,どのフェーズから再開するか判断する +3. CLAUDE.md の「開発進捗」と `docs/PROGRESS.md` を確認し,どのフェーズから再開するか判断する(再開時は `git status --porcelain` で前回の未コミットの変更が残っていないかも確認し,残っていればユーザーに `/commit` するか確認する) 4. 初回の場合はフェーズ 1 から開始する ## 開発モードの選択 (Development Mode) @@ -84,17 +85,31 @@ 1. GUIDE_01 の該当フェーズを参照し,目的・成果物・役割分担を確認する 2. ユーザーとの対話で情報を収集する -3. 成果物のドラフトを作成し,ユーザーに提示する -4. 承認を得てから GUIDE_01 に記載された保存先に書き出す -5. 書き出した成果物をコミットする(前述「立ち上げ中の Git 運用」・`reference.md`「フェーズごとのコミット」.push・PR は行わない) +3. 成果物を GUIDE_01 に記載された保存先に直接書き出し,書き出したファイルの一覧と要確認点をユーザーに示す(全文はチャットに出さない) +4. ユーザーの修正指示をファイルに反映し,承認を得る +5. ユーザーに `/commit` でのコミットを案内する(前述「立ち上げ中の Git 運用」・`reference.md`「フェーズごとのコミット」のメッセージ例を添える) +6. 作業ツリーがクリーンになったことを確認してから次のフェーズに進む(未コミットのまま進むかはユーザーに確認する) ### 台詞テンプレート -「**フェーズ {N}({フェーズ名})のドラフトです.** +「**フェーズ {N}({フェーズ名})の成果物を書き出しました.** エディタで確認してください. -{ドラフト内容} +書き出したファイル: -修正点があれば指示してください.OKであれば {保存先} に保存し,`chore/project-setup` にコミットして次のフェーズに進みます.」 +- {ファイルパス}({新規作成 / 更新}: {1 行の説明}) + +判断してほしい点: + +- {ユーザーの判断が必要な点.無ければ「なし」} + +Claude が決めた点: + +- {ユーザーに確認せず Claude が仮に決めた点と理由} + +修正点があれば指示してください(ファイルを直接修正します.差分は `git diff` で確認できます). +OK であれば `/commit` でコミットしてください(メッセージ例: `{reference.md の対応表のメッセージ例}`).コミットを確認してから次のフェーズに進みます.」 + +修正を反映したときは,直したファイルと変更点の要点だけを示し,同様に `/commit` を案内する. ### フォルダ作成の注意 @@ -105,12 +120,13 @@ 外部サービスの設定(アカウント作成,コンソール操作等)はユーザー自身が行う.手順書に記載するが,AI が実行しないこと. **例外**として,`gh` CLI で操作できる GitHub リポジトリ設定は AI が実行する. 手順書を書き出した後は,それ以外の手順を実際に実行して環境を構築する(後述「環境構築の実行」.ツール導入は都度承認を得る). +このフェーズは「ファイル作成」と「構築の実行」が混ざるため,**構築を実行し,その結果(実際に使ったコマンド・バージョン・躓き)を手順書ファイルに反映してから**,ファイル一覧と要確認点を示してレビューを受ける. ### GitHub リポジトリのセキュリティ設定 (GitHub Repository Security Settings) Dependabot alerts("Vulnerabilities")と Dependabot security updates はリポジトリごとに既定で OFF のため,モード(solo / team)に関わらず AI が `gh api` で有効化する.コマンド・検証・トラブル対応は本スキルの `reference.md`(`.claude/skills/setup/reference.md`)「GitHub リポジトリのセキュリティ設定」に従う. -- `ENV_03_管理者用環境構築手順.md` のドラフトには,GitHub リポジトリ作成の直後の手順として `reference.md` の有効化・検証コマンドを必ず転記する(`/setup` 後にリポジトリを作った場合に管理者が単体で実行できるようにするため) +- `ENV_03_管理者用環境構築手順.md` には,GitHub リポジトリ作成の直後の手順として `reference.md` の有効化・検証コマンドを必ず転記する(`/setup` 後にリポジトリを作った場合に管理者が単体で実行できるようにするため) - ENV_03 を書き出したら,その場で `reference.md` の手順を 1 度実行する.リモート未作成で実行できなければ Phase 7 で再試行する ### 依存バージョン更新の設定 (Dependabot Version Updates) @@ -118,7 +134,7 @@ 上記の脆弱性検出とは別に,依存パッケージを定期的に最新化する PR を Dependabot に作らせるため,フェーズ 3 で `.github/dependabot.yml` を**ユーザーに選択を聞かずに必ず生成する**(`.gitignore` 等と同じ「設定ファイルの生成」の一部として扱う). - フェーズ 2 で確定した技術スタック(`ENV_01_技術スタック.md`)から対象エコシステムを決め,`reference.md`「依存バージョン更新の設定」の対応表と雛形に従って生成する -- 生成内容(対象エコシステム・更新頻度・グループ化)は他の成果物と同様にドラフトとしてユーザーに提示し,承認を得てから書き出す(「作るかどうか」は聞かず,「内容」だけを確認する) +- 生成内容(対象エコシステム・更新頻度・グループ化)は他の成果物と同様にファイルに書き出してから,要確認点として示してユーザーの承認を得る(「作るかどうか」は聞かず,「内容」だけを確認する) - マニフェストがまだ存在しない場合(立ち上げ時点でコードが無い)でも,スタックが確定していれば生成してよい.Dependabot はマニフェストが置かれた時点から動き始める ### 環境構築の実行 (Executing the Environment Setup) @@ -128,7 +144,7 @@ - リポジトリ内で完結する操作(プロジェクト初期化・依存インストール・設定ファイル生成・ビルドや起動の疎通確認・`gh` でのリポジトリ設定)はそのまま実行する - ツール導入(SDK・ランタイム・CLI のインストール,`docker build`,dev container の起動)は**実行するコマンドを提示して承認を得てから**実行する.OS・パッケージマネージャ・バージョン・入る場所(システム全体かプロジェクト内か)を明示する - 外部サービスのコンソール操作・アカウント作成・対話ログイン(`gh auth login` 等)・クレデンシャルの発行はユーザーに依頼する(`! gh auth login` のように `!` を付けてその場で実行できることを案内する) -- 実行して分かった手順書の誤り・不足・OS 依存の差異は `ENV_02`/`ENV_03` に反映してからコミットする.解決できなかった手順は「※ 未検証」と明記して残す +- 実行して分かった手順書の誤り・不足・OS 依存の差異は `ENV_02`/`ENV_03` に反映してからユーザーのレビューを受ける.解決できなかった手順は「※ 未検証」と明記して残す - 確定した起動・テスト・lint・ビルドのコマンドは `ENV_04_開発コマンド.md` に書き出す(GUIDE_01「環境構築」) - 生成物(`node_modules/`・ビルド出力等)を作る前に `.gitignore` を整備する @@ -147,8 +163,8 @@ 「作る」を選んだ場合: 1. `.claude/skills/verify/profile-template.md` を `.claude/verify-profile.md` にコピーする -2. 雛形の各節を,確定した技術スタック・開発コマンドに合わせてドラフトし,他の成果物と同様にユーザーの承認を得てから書き出す.埋められない項目(ランナー未整備のため実行コマンドが未定等)は空欄にせず「未定(ランナー整備後に記入)」と明記する - - 「検証対象の環境」は**ローカルのみ**でドラフトする.staging 等を許可するかは Claude が判断せずユーザーに確認し,許可する場合だけ「いじっても影響が無い」と判断した根拠・資格情報の受け渡し方法・テストデータの後始末をユーザーから聞いて記入する(本番は許可しない) +2. 雛形の各節を,確定した技術スタック・開発コマンドに合わせてファイル上で埋め,他の成果物と同様に要確認点を示してユーザーの承認を得る.埋められない項目(ランナー未整備のため実行コマンドが未定等)は空欄にせず「未定(ランナー整備後に記入)」と明記する + - 「検証対象の環境」は**ローカルのみ**で記入する.staging 等を許可するかは Claude が判断せずユーザーに確認し,許可する場合だけ「いじっても影響が無い」と判断した根拠・資格情報の受け渡し方法・テストデータの後始末をユーザーから聞いて記入する(本番は許可しない) 3. 検証結果の出力先を `.gitignore` に追記する 4. ランナーは雛形の「実行基盤の要件」を満たすようプロジェクト側で実装する必要がある旨を伝える(この場では実装しない.必要なら立ち上げ後に `/implement` で作る) @@ -166,7 +182,7 @@ 2. 作成した全ドキュメントの一覧を表示する 3. 最初の実装ステップを確認する 4. **モードに関わらず**,「GitHub リポジトリのセキュリティ設定」(前述)を実行する(Phase 3 で有効化済みなら検証コマンドだけで確認する.リモートがまだ無ければ,リポジトリ作成後に ENV_03 のコマンドを実行するようユーザーに案内する) -5. CLAUDE.md の最終更新・進捗の更新を `chore/project-setup` にコミットする(前述「立ち上げ中の Git 運用」).`main` への取り込みは行わず,ユーザーに `/commit push`(または `/commit merge`)を案内する +5. CLAUDE.md の最終更新・進捗の更新をユーザーに確認してもらい,`/commit` でのコミットを案内する(前述「立ち上げ中の Git 運用」).その後の `main` への取り込みも本スキルでは行わず,ユーザーに `/commit push`(または `/commit merge`)を案内する(最終更新のコミットと取り込みを 1 回の `/commit push` 等でまとめて行ってよい) 6. **team モードの場合**は,AI に実施できない「管理者の初期設定」(GUIDE_03「管理者の初期設定」)をユーザーに案内する: - GitHub リポジトリ作成・メンバー招待 - Issue テンプレートの用意 @@ -183,7 +199,7 @@ GitHub リポジトリのセキュリティ設定(Dependabot alerts / security updates): {有効化済み / リポジトリ未作成のため未実施(ENV_03 のコマンドで有効化してください)} 依存バージョン更新(`.github/dependabot.yml`): {対象エコシステム一覧} を {更新頻度} で監視 Dependabot が作る PR・alert は `/deps-update` で処理できます(安全な更新は自動マージ,メジャー更新等は分析付きで報告). -立ち上げの成果物は `chore/project-setup` に {N} コミットで積んであります.`main` への取り込みは `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で行ってください. +立ち上げの成果物は `chore/project-setup` ブランチにあります(CLAUDE.md の最終更新は未コミットです.内容を確認してください).コミットと `main` への取り込みは `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で行ってください. 環境構築: {実行した手順の要点 / ユーザーに依頼した手順 / 未検証のまま残した手順} 最初の実装ステップは `{ステップ名}` です.`/implement {タスク}` で開始できます. {team の場合: 「チーム運用の管理者初期設定(GitHub repo・CI 等)は GUIDE_03 を参照してユーザー側で実施してください.」}」 @@ -193,11 +209,15 @@ ユーザーが中断を希望した場合: 1. 現在のフェーズと状態を CLAUDE.md の「開発進捗」(最新 1 行に上書き)と `docs/PROGRESS.md`(末尾に追記)に記録する -2. ここまでの成果物と進捗の更新を `chore/project-setup` にコミットする(未コミットの差分を残さない.push・PR は行わない) +2. 本スキルではコミットしない.未コミットの成果物と進捗の更新があれば,書き出したファイルの一覧を示し,ユーザーに `/commit` でのコミットを案内する(コミットせずに中断してもよいが,その場合は未コミットの変更が残っていることを伝える) 3. 次回 `/setup` 実行時に続きから再開できるようにする -「**進捗を保存しました.** +「**進捗を記録しました.** 現在の状態: フェーズ {N}({フェーズ名})まで完了 -`chore/project-setup` にコミット済みです(未コミットの変更はありません). +未コミットのファイル: + +- {ファイルパス}(無ければ「なし」) + +未コミットのファイルがある場合は,`chore/project-setup` で `/commit` を実行してコミットしておくことを推奨します(メッセージ例: `{メッセージ例}`). 次回 `/setup` を実行すると,同じブランチでフェーズ {N+1} から再開します.」 diff --git a/.claude/skills/setup/reference.md b/.claude/skills/setup/reference.md index 116934f..a4cdccd 100644 --- a/.claude/skills/setup/reference.md +++ b/.claude/skills/setup/reference.md @@ -1,6 +1,6 @@ # setup リファレンス (Setup Reference) -`/setup`(`SKILL.md`)から参照される実行手順集(`gh` CLI で行うリポジトリ設定,`.github/dependabot.yml` の生成規則,フェーズごとのコミット,環境構築の実行).`/setup` の途中で AI が実行するほか,`/setup` 完了後にリポジトリを作成した場合などに管理者(人間)が単体で実行する runbook としても使う. +`/setup`(`SKILL.md`)から参照される実行手順集(`gh` CLI で行うリポジトリ設定,`.github/dependabot.yml` の生成規則,フェーズごとのコミットの案内,環境構築の実行).`/setup` の途中で AI が実行するほか,`/setup` 完了後にリポジトリを作成した場合などに管理者(人間)が単体で実行する runbook としても使う. ## GitHub リポジトリのセキュリティ設定 (GitHub Repository Security Settings) @@ -52,7 +52,7 @@ ## 依存バージョン更新の設定 (Dependabot Version Updates) -`.github/dependabot.yml` を置くと,Dependabot が依存パッケージの新バージョンを定期的に検出して更新 PR を作る(脆弱性の有無に関係なく).`/setup` のフェーズ 3 で,技術スタック(`ENV_01_技術スタック.md`)に合わせて**必ず生成する**(作るかどうかはユーザーに聞かない.内容だけドラフトとして確認する).PR のノイズを抑えるため,週 1 回・マイナー/パッチをまとめる・同時オープン数を絞る,を既定とする. +`.github/dependabot.yml` を置くと,Dependabot が依存パッケージの新バージョンを定期的に検出して更新 PR を作る(脆弱性の有無に関係なく).`/setup` のフェーズ 3 で,技術スタック(`ENV_01_技術スタック.md`)に合わせて**必ず生成する**(作るかどうかはユーザーに聞かない.書き出したファイルで内容だけ確認してもらう).PR のノイズを抑えるため,週 1 回・マイナー/パッチをまとめる・同時オープン数を絞る,を既定とする. ### エコシステム対応表 (Ecosystem Mapping) @@ -124,28 +124,26 @@ ## フェーズごとのコミット (Per-phase Commit) -`/setup` は立ち上げ専用ブランチ `chore/project-setup` の上で,各フェーズの成果物を書き出してユーザーが承認したタイミングごとにコミットする.`git push`・PR 作成・マージ・`main` への直接コミットは行わない(CLAUDE.md「Git 運用」の例外が認められるのはこのブランチへのコミットに限る). +立ち上げは専用ブランチ `chore/project-setup` の上で行い,各フェーズの成果物をユーザーがエディタで確認した後,**ユーザー自身が `/commit` でコミットする**(フェーズごとに 1 コミットを推奨).`/setup` はブランチの作成と,コミットの案内(メッセージ例の提示)・コミット済みの確認だけを行い,`git commit`・`git push`・PR 作成・マージは行わない. ### 手順 (Procedure) -1. 最初のコミットの直前にブランチを用意する(2 回目以降のフェーズは作成済みのブランチをそのまま使う): +1. `/setup` が最初にファイルを書き換える前にブランチを用意する(再開時は作成済みのブランチをそのまま使う): ```bash git branch --show-current # chore/project-setup なら,そのまま使う git switch -c chore/project-setup # main にいる場合のみ作成する ``` - - コミットが 1 つも無い(`git log` が失敗する)場合は,先に最初のコミットを作ってからブランチを切る(コミットが無いとブランチを作れないため) -2. そのフェーズで書き出したファイルだけをステージングする(`.env` やクレデンシャル・生成物はステージングしない.`.gitignore` の整備を先に済ませる) -3. `.claude/rules/git-conventions.md` の書式(`[タグ] 内容`・日本語・末尾の句点なし)でコミットする + - コミットが 1 つも無い(`git log` が失敗する)場合はブランチを作れないため,ユーザーに最初のコミットを依頼してからブランチを切る +2. `/setup` はフェーズの成果物を書き出してレビューを受けた後,下表のメッセージ例を添えてユーザーに `/commit` を案内する(`.env` やクレデンシャル・生成物がコミット対象に入らないよう,`.gitignore` の整備を先に済ませる) +3. ユーザーが `/commit` を実行する(書式は `.claude/rules/git-conventions.md`: `[タグ] 内容`・日本語・末尾の句点なし) +4. `/setup` は次のフェーズに進む前に作業ツリーがクリーンか確認する.未コミットの変更が残っていれば,コミットしてから進むか,そのまま進むかをユーザーに確認する ```bash - git add docs/03_PLAN/PLAN_01_要件定義書.md - git commit -m "[add] 要件定義書を作成" + git status --porcelain # 何も出力されなければクリーン ``` -4. コミットした旨(ブランチ・メッセージ)を次フェーズの案内と一緒に 1 行で報告する - ### フェーズとコミットの対応 (Commit per Phase) | フェーズ | 主な対象 | コミットメッセージ例 | @@ -159,13 +157,13 @@ | 6 開発計画 | `docs/03_PLAN/PLAN_02_開発ステップ.md` | `[add] 開発ステップを策定` | | 7 実装開始 | `CLAUDE.md`(最終更新),`docs/PROGRESS.md` | `[update] CLAUDE.md を立ち上げ完了状態に更新` | -- フェーズ 3 は「手順書・設定ファイルの作成」と「実際の構築(+手順書の修正)」で差分の性質が違うため,2 コミット以上に分けてよい(例: `[add] 環境構築手順と設定ファイルを整備` → `[update] 実際の構築結果を環境構築手順に反映`) -- 進捗(`CLAUDE.md` の進捗欄・`docs/PROGRESS.md`)を更新した場合は,そのフェーズのコミットに含める -- 承認を得た成果物がまだ無いフェーズ(対話だけで終わった)ではコミットしない(空コミットを作らない) +- フェーズ 3 は構築を実行し,その結果を手順書に反映してからレビューを受けるため,通常は 1 コミットでよい.ユーザーが分けたい場合は分けてよい(例: `[add] 環境構築手順と設定ファイルを整備` → `[update] 実際の構築結果を環境構築手順に反映`) +- 進捗(`CLAUDE.md` の進捗欄・`docs/PROGRESS.md`)を更新した場合は,そのフェーズのコミットに含めるよう案内する +- 承認を得た成果物がまだ無いフェーズ(対話だけで終わった)ではコミットを案内しない(空コミットを作らない) ### 取り込み (Merging into main) -全フェーズ完了後,ユーザーが `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で `main` に取り込む.`/setup` からは案内のみを行う. +全フェーズ完了後,ユーザーが `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で `main` に取り込む(フェーズ 7 の最終更新が未コミットなら,そのコミットもまとめて行われる).`/setup` からは案内のみを行う. ## 環境構築の実行 (Executing the Environment Setup) diff --git a/.claude/skills/sync-template/SKILL.md b/.claude/skills/sync-template/SKILL.md index d2a3d0d..ba92cad 100644 --- a/.claude/skills/sync-template/SKILL.md +++ b/.claude/skills/sync-template/SKILL.md @@ -62,6 +62,7 @@ | --- | --- | --- | | `README.md` | テンプレートの README は GitHub の repo ページ向けのテンプレート紹介用.各プロジェクトは独自の README を持つべき | プロジェクト側にコピー・上書きしない.テンプレート側の追加・変更・削除も無視する | | `.claude/tests/`(配下すべて) | テンプレート自身のメタテスト(skill・エージェント定義・README 等がテンプレートとして整合しているかを検証する).テンプレート固有の内容を前提とするため派生プロジェクトでは必ず失敗する | 同上 | +| `.github/workflows/hooks-ci.yml` | テンプレートのフック(`.claude/hooks/`)を 3 OS で検証する CI.フックの修正はテンプレート側で行い,この CI で確認してから派生プロジェクトへ同期する運用のため,派生プロジェクトの CI には含めない | 同上 | 判定はステップ 5.3 のループ内でマージ必須ファイル判定より先に行う.`SKIP_FILES` の末尾が `/` の項目はディレクトリ指定で,配下のすべてのパスに前方一致で適用する(`reference.md`「変数・判定ヘルパー」). @@ -329,5 +330,5 @@ - 台帳への登録は必ず理由を添える(ユーザーから聞く).理由の無い登録は次回同期時の判断材料にならない.台帳の書式(パス列はバッククォート囲み,方針列は `keep` / `merge` / `ask`)を崩さない - 同期対象外ファイル(`README.md`)はテンプレート紹介用のためプロジェクトには反映しない.テンプレート側で追加・変更・削除があってもプロジェクトの該当ファイルは触らない - チーム層ファイル(`GUIDE_03`/`check_sync.sh`)は `.claude/project-mode` が `team` のプロジェクトにのみ同期する.`task-*` skill は共通層のためモードに関わらず同期する.`solo`(または未設定)のプロジェクトには配置・更新・削除いずれもしない.`/sync-template` は「版の追従」のみを行い,**モードの切り替えはしない**.solo↔team の切替は `/set-mode ` を使う(team 層ファイルの配置/削除・`settings.json` 配線・`CLAUDE.md` の team 化/solo 化・`project-mode` 更新を一括で行う).`.claude/project-mode` を手で書き換えるだけでは切り替わらない -- テンプレートが管理するのは `.claude/` 配下のうち `agents/`,`skills/`,`rules/`,`hooks/`,`settings.json`,`template-sync-sha`,`template-overrides.md`(雛形のみ.登録内容はプロジェクト固有)のみ.`.claude/plans/`・`.claude/commit-context.md`・`.claude/verify-profile.md`(検証プロファイル.テンプレートが持つのは雛形 `.claude/skills/verify/profile-template.md` のみ)等のプロジェクト固有ファイルはテンプレートに含まれないため同期対象外(`$CHANGED_ENTRIES` に現れず,コピー・上書き・削除のいずれも起きない) +- テンプレートが管理するのは `.claude/` 配下のうち `agents/`,`skills/`,`rules/`,`hooks/`,`settings.json`,`template-sync-sha`,`template-overrides.md`(雛形のみ.登録内容はプロジェクト固有)のみ.`.claude/plans/`・`.claude/commit-context.md`・`.claude/verify-profile.md`(検証プロファイル.テンプレートが持つのは雛形 `.claude/skills/verify/profile-template.md` のみ)・`.claude/repo-access.json`(リポジトリ外アクセス制限フックの禁止リスト.GUIDE_01「環境構築」)等のプロジェクト固有ファイルはテンプレートに含まれないため同期対象外(`$CHANGED_ENTRIES` に現れず,コピー・上書き・削除のいずれも起きない) - `chore/sync-template` ブランチは他の作業ブランチと混ぜず,作成後は速やかにマージすること.複数の作業ブランチで `/sync-template` を実行すると `.claude/template-sync-sha` がコンフリクトする.コンフリクト時は新しい(HEAD 側の)SHA を採用すること. diff --git a/.claude/skills/sync-template/reference.md b/.claude/skills/sync-template/reference.md index 0ee45b4..7b56984 100644 --- a/.claude/skills/sync-template/reference.md +++ b/.claude/skills/sync-template/reference.md @@ -15,7 +15,7 @@ ```bash MERGE_FILES=(".gitignore" "CLAUDE.md" "docs/PROGRESS.md" ".gitattributes" ".claude/settings.json" ".claude/template-overrides.md") -SKIP_FILES=("README.md" ".claude/tests/") # 末尾 / はディレクトリ配下すべてを対象にする +SKIP_FILES=("README.md" ".claude/tests/" ".github/workflows/hooks-ci.yml") # 末尾 / はディレクトリ配下すべてを対象にする TEAM_LAYER_FILES=( "docs/01_GUIDE/GUIDE_03_チーム開発ルール.md" ".claude/hooks/check_sync.sh" @@ -338,6 +338,7 @@ - 既存に無いイベント/hook はテンプレート版から追加する - プロジェクト固有の hook(team の SessionStart `check_sync.sh` 等)は残す - 同一 hook の command 変更(例: `restrict_repo_access.py` の起動方法変更)はテンプレート版に合わせる.旧形式の `python .claude/hooks/<名前>.py` は `bash .claude/hooks/run_python.sh [--fail-closed] .claude/hooks/<名前>.py` に置き換える(`--fail-closed` はテンプレート版と同じく `restrict_repo_access.py` にのみ付ける).`run_python.sh` が同期で配置済みであることも確認する + - 同一 hook の matcher 変更(例: `restrict_repo_access.py` の対象ツールに `NotebookEdit`・`PowerShell` を追加)もテンプレート版に合わせる.プロジェクトが独自に足したツール名があれば残す - solo モードで SessionStart(check_sync) が無い場合は,team 専用の配線を勝手に追加しない 4. `hooks` 以外のキーも同じ方針でマージする.特に `permissions.allow` は,テンプレート側に追加されたルール(`Bash(gh pr merge:*)` 等.auto mode で ops-runner にマージを実行させるために必要)を既存へ追加し,プロジェクトが独自に足したルールは残す(削除しない) 5. `git diff .claude/settings.json` で結果を表示しユーザーに確認する.**permissions の変更はセッション再起動後に反映される**ため,追加があった場合は再起動が必要な旨をユーザーに伝える diff --git a/.claude/template-sync-sha b/.claude/template-sync-sha index 33da67c..5cdbb21 100644 --- a/.claude/template-sync-sha +++ b/.claude/template-sync-sha @@ -1 +1 @@ -00dd94e0234076eb545a662e737594413a336066 +da6ebf1c10ecb8510e794556e0ad47bae19613b5 diff --git a/.gitignore b/.gitignore index dc21343..b6f88b7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ .claude/deps-update-report.md .claude/deps-update-skip.md __pycache__/ +.claude/hooks/tests/_restrict_sandbox_*/ diff --git a/CLAUDE.md b/CLAUDE.md index f39b7d0..aeb6202 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,8 +25,7 @@ - ブランチ名・コミットメッセージの書式は `.claude/rules/git-conventions.md` に従う(常時ロードされる) - コミットは `/commit` を使用する(push・PR 作成は `/commit push`,マージ・プルまで行う場合は `/commit merge`) - **`/commit` はユーザーが明示的に指示した時のみ実行する.Claude が自発的に `/commit` や `git commit` を呼んではならない**(`/implement` 完了後も,案内するだけで自分ではコミットしない.`/commit` skill は `disable-model-invocation` によりユーザー起動限定として強制されている) - - **例外**: 以下(プロジェクト立ち上げと無人運転ループ)はユーザー承認済みの例外として専用ブランチに自律コミットする.いずれも push・PR・マージ・`main` への操作はしない(取り込みは人間が `/commit push` 等で行う) - - `/setup`(プロジェクト立ち上げ)→ `chore/project-setup` 専用ブランチ(フェーズごとに 1 コミット) + - **例外**: 以下(無人運転ループ)はユーザー承認済みの例外として専用ブランチに自律コミットする.いずれも push・PR・マージ・`main` への操作はしない(取り込みは人間が `/commit push` 等で行う) - `/auto-refactor`(リファクタ/ドキュメント整理ループ)→ `refactor/` 専用ブランチ - `/auto-audit`(バグ/脆弱性の巡回監査ループ)→ `fix/` 専用ブランチ - **例外(`main` へのマージ)**: `/deps-update` は,Dependabot が作成した依存更新 PR に限り,ゲート(メジャー更新でない・競合なし・CI 緑またはローカル検証緑)を満たすものを `gh pr merge` で `main` に取り込む(ユーザーが明示的に起動した時のみ.メジャー更新・CI 赤・PR が付かない alert は報告して人間が判断する) @@ -62,6 +61,8 @@ - 外部サービスの設定(コンソール操作,セキュリティルール変更等) - 例外: `gh` CLI で操作できる GitHub リポジトリ設定(Dependabot alerts / security updates の有効化)は Claude が `gh api` で行う(手順は `.claude/skills/setup/reference.md`,背景は GUIDE_01「環境構築」) +- リポジトリ外のファイルの削除・上書き(既存ファイルの移動を含む) + - フックが検出できない書き方であっても Claude は自分で実行しない(変数・スクリプトファイル・`python -c` 等による迂回も禁止).必要な操作であれば,実行するコマンドをそのまま提示し,ユーザーにプロンプトで `! <コマンド>` として実行するよう依頼する - 実機・ブラウザでの動作確認(`verifier` による自動検証は前段であり,これを置き換えない) - ストアへのアップロード・リリース作業 @@ -74,7 +75,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 / testing / error-handling)に path-scoped rule として定義されている(git-conventions は常時,他は該当ファイル編集時に自動ロード) +- ※ Git 規約・ドキュメント書式・命名規則・進捗記録・テンプレート改変記録・フックのクロスプラットフォーム対応・テスト方針・エラーハンドリング方針は `.claude/rules/`(git-conventions / markdown-style / docs-naming / progress-log / template-customization / hooks-cross-platform / testing / error-handling)に path-scoped rule として定義されている(git-conventions は常時,他は該当ファイル編集時に自動ロード) - ※ コーディング規約・プロジェクト構成規則は未整備(GUIDE_01 の規約整備フェーズ参照) ### 02_ENV(環境) 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 574cdde..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" @@ -32,11 +32,12 @@ ## 立ち上げ中の Git 運用 (Git During Setup) -立ち上げ作業は専用ブランチ `chore/project-setup` の上で行い,**各フェーズの成果物が確定(ユーザーが承認)した時点でコミットする**(フェーズごとに 1 コミット).`/setup` が Git 規約(`.claude/rules/git-conventions.md`)の書式でコミットまで行い,`git push`・PR 作成・マージ・`main` への直接コミットは行わない.手順とフェーズごとのコミットメッセージ例は `.claude/skills/setup/reference.md`「フェーズごとのコミット」にある. +立ち上げ作業は専用ブランチ `chore/project-setup` の上で行う(ブランチの作成は `/setup` が行う).`/setup` は各フェーズの成果物を**最初から保存先のファイルとして書き出し**,チャットには書き出したファイルの一覧と要確認点(判断してほしい点・Claude が決めた点)だけを示す.人間はエディタでファイルを読んで修正点をチャットで伝え,`/setup` はファイルを直接直す(差分は `git diff` で追える). -- 狙いは,立ち上げの成果物をフェーズ単位でレビュー・巻き戻しできる形にし,中断・再開しても「どこまで終わったか」が git 履歴から辿れるようにすること +- **コミットは人間が `/commit` で行う**(フェーズごとに 1 コミットを推奨).`/setup` はコミット・push・PR 作成・マージを行わず,コミットメッセージ例の提示(`.claude/skills/setup/reference.md`「フェーズごとのコミット」)とコミット済みの確認だけを行う +- `/setup` は次のフェーズに進む前に作業ツリーがクリーン(コミット済み)であることを確認し,未コミットのまま進むかは人間に確認する +- 狙いは,書き出された最終形を人間がエディタで確認してからコミットできるようにし,フェーズ単位でレビュー・巻き戻しでき,中断・再開しても「どこまで終わったか」が git 履歴から辿れるようにすること - 全フェーズ完了後,`main` への取り込みはユーザーが `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で行う -- これは CLAUDE.md「Git 運用」の「`/commit` 自発実行禁止」に対するユーザー承認済みの例外(`/auto-refactor`・`/auto-audit` と同格)であり,`chore/project-setup` ブランチへのコミットに限る ## 方針決定 (Direction) @@ -61,10 +62,10 @@ - **前提条件**: 以下のツールは全プロジェクト共通で導入する. - `git` — バージョン管理(運用ルールは `.claude/rules/git-conventions.md` で定義) - `gh` — GitHub CLI(PR 作成・マージ等に使用) - - Python 3.7 以上と `bash` — `.claude/settings.json` のフック(リポジトリ外アクセス制限・通知)の実行に使用.フックは `bash .claude/hooks/run_python.sh` 経由で起動し,`python3` → `python` → `py` の順に実際に起動できるものを使う(いずれか 1 つで起動できればよい.Windows では Git for Windows 付属の `bash` を使う).※ Python が見つからないとアクセス制限フックが Read・Write・Edit・Glob・Grep・Bash を**すべてブロックする**(通知フックは何もせず素通りする) + - Python 3.7 以上と `bash` — `.claude/settings.json` のフック(リポジトリ外アクセス制限・通知)の実行に使用.フックは `bash .claude/hooks/run_python.sh` 経由で起動し,`python3` → `python` → `py` の順に実際に起動できるものを使う(いずれか 1 つで起動できればよい.Windows では Git for Windows 付属の `bash` を使う).※ Python が見つからないとアクセス制限フックが対象ツール(Read・Write・Edit・NotebookEdit・Glob・Grep・Bash・PowerShell)を**すべてブロックする**(通知フックは何もせず素通りする) - **基本方針**: 環境の再現性を重視し,手順書だけに頼らず構築を自動化・コード化できる方法を優先する(例: Docker,Dev Containers,Windows Sandbox,IaC ツール等). - **人間が決めること**: 開発マシンの選定,クラウドサービスのアカウント作成,環境構築方法の選択 -- **実際の構築まで行う**: 手順書を書いて終わりにせず,`/setup` がその手順に従って実際に環境を構築し,実行して分かった誤り・前提の抜けを手順書に反映する(未検証の手順書は誤りを含みやすく,最初に詰まるのがその場のユーザーになるため).区分は次の 3 つ(詳細は `.claude/skills/setup/reference.md`「環境構築の実行」) +- **実際の構築まで行う**: 手順書を書いて終わりにせず,`/setup` がその手順に従って実際に環境を構築し,実行して分かった誤り・前提の抜けを手順書に反映してから人間のレビューを受ける(未検証の手順書は誤りを含みやすく,最初に詰まるのがその場のユーザーになるため).区分は次の 3 つ(詳細は `.claude/skills/setup/reference.md`「環境構築の実行」) - リポジトリ内で完結する操作(プロジェクト初期化・依存インストール・設定ファイル生成・ビルドや起動の疎通確認・`gh` でのリポジトリ設定)はそのまま実行する - ツール導入(SDK・ランタイム・CLI のインストール,`docker build`,dev container の起動)は実行するコマンド・バージョン・入る場所を提示して**都度承認を得てから**実行する - 外部サービスのコンソール操作・アカウント作成・対話ログイン(`gh auth login` 等)・クレデンシャルの発行はユーザーが行う @@ -72,6 +73,30 @@ - **GitHub リポジトリのセキュリティ設定**: GitHub の Settings → Code security にある **Dependabot alerts**(既知脆弱性の検出."Vulnerabilities" として表示される)と **Dependabot security updates** はリポジトリごとに既定で OFF のため,リポジトリを作成したら必ず有効化する.モードに関わらず `/setup` が `gh api` で有効化・検証する(コマンド・検証・トラブル対応は `.claude/skills/setup/reference.md`「GitHub リポジトリのセキュリティ設定」).`/setup` 時点でリポジトリが無い場合は,`ENV_03_管理者用環境構築手順.md` に転記した同じコマンドをリポジトリ作成後に実行する. - **依存バージョン更新(Dependabot version updates)**: 上記とは別に,依存パッケージを定期的に最新化する PR を作らせるため,`/setup` が技術スタックに合わせて `.github/dependabot.yml` を必ず生成する(週 1 回・マイナー/パッチをまとめる・`[update]` プレフィックス.エコシステム対応表と雛形は `.claude/skills/setup/reference.md`「依存バージョン更新の設定」).Dependabot が作る PR と alert の処理は `/deps-update` で行う(ゲートを満たす PR を自動マージし,メジャー更新等は分析付きで報告.GUIDE_02「コミットルール」の例外). - **Claude Code の権限設定(`.claude/settings.json`)**: テンプレート同梱の `permissions.allow`(`Bash(gh pr merge:*)`)は,auto mode で ops-runner に `/commit merge`・`/deps-update` のマージを実行させるために必要なので削除しない(変更はセッション再起動後に反映).マージが分類器に止められた場合の切り分けと対処は `.claude/skills/commit/reference.md`「gh コマンドが実行される前にブロックされた場合」を参照. +- **リポジトリ外アクセス制限フック(`.claude/hooks/restrict_repo_access.py`)**: 目的は「リポジトリ外のファイルの**削除**と**既存ファイルの上書き**(取り返しのつかない操作)だけを確実に止める」こと.テンプレート同梱の `.claude/settings.json` が `PreToolUse` で呼ぶ. + - 位置づけ: 新規ファイルの作成・ダウンロード・ディレクトリ作成・追記は止めない(環境構築でのダウンロード・`mkdir`・`~/.bashrc` への追記等を自動で進められるようにするため).それ以外のグレーな操作の判断は権限モード(auto mode の分類器・コマンドごとの承認等)に任せ,フックは最後の砦として削除・上書きだけを止める + - 削除(常に拒否): Bash の `rm`・`rmdir`・`unlink`・`shred`・`truncate`・`find -delete`/`-exec rm`・`mv` の移動元・`rsync --delete` の書き込み先,PowerShell の `Remove-Item` とそのエイリアス・`Clear-Content`・`Move-Item`/`Rename-Item` の移動元・`[IO.File]::Delete` 等,`cmd /c del` 等.`python -c`・`node -e`・ヒアドキュメント等のインライン実行は,削除系の呼び出し(`rmtree`・`os.remove`・`unlink`・`fs.rm` 等)があり,かつコード中にリポジトリ外のパスがある場合に拒否する.Edit・NotebookEdit(既存ファイルの書き換え)も拒否する + - 上書き(書き込み先が既に存在する場合だけ拒否.無ければ新規作成として許可): `>`・`>|`・`&>`,`cp`・`mv`・`install`・`rsync`・`scp`・`ln -f`・`Copy-Item`・`Move-Item` の書き込み先(既存ディレクトリへのコピー・移動は `<ディレクトリ>/<コピー元の名前>` で判定),`sed -i`・`perl -i`,`tee`(`-a` 無し)・`Set-Content`・`Out-File`/`Tee-Object`/`Export-Csv`(`-Append` 無し)・`New-Item -Force`,`dd of=`,`curl -o`・`wget -O`・`Invoke-WebRequest -OutFile` 等の保存先,`Expand-Archive -Force`・`Compress-Archive -Force`,Write ツール,インライン実行の `open(..., 'w')`・`writeFile` 等 + - 許可: 新規作成・ダウンロード・`mkdir`/`New-Item -ItemType Directory`・`touch`・追記(`>>`・`tee -a`・`Add-Content`・`Out-File -Append`).`chmod`・`chown`・`Set-Acl` も中身が失われないため許可する + - `cd`・`pushd`・`Set-Location` を追跡し,`~` と `$HOME` を展開してから判定する.基準はリポジトリのルート(`CLAUDE_PROJECT_DIR`)で,システム一時ディレクトリ(`/tmp`・`%TEMP%`.スクラッチパッドを含む)と `/dev/null`・`$null` 等は削除・上書きも許可する + - 読み取り: Read・Glob・Grep,Bash・PowerShell の読み取りはリポジトリ外も許可する.**読んだファイルの内容は Claude の API に送信される**ため,リポジトリ外を読ませることの是非はプロジェクトごとに判断する.読ませたくない場所(`~/.ssh`・`~/.aws`・他案件のフォルダ等)は禁止リスト `.claude/repo-access.json` に書く(任意.無ければ禁止リストは空).禁止リストに該当するパスは読み取り・書き込みとも拒否する.Glob・Grep・再帰的に読むコマンド(`grep -r`・`find`・`Get-ChildItem` 等)は,リポジトリ外の検索起点の配下に禁止パスがある場合も拒否する + + ```json + { + "deny_read": ["~/.ssh", "~/.aws", "C:/Users/me/source/other-project", "secrets"] + } + ``` + + - パスは絶対パス・`~` 始まり・リポジトリルート相対で書く.ファイルが壊れている(JSON として読めない等)場合は禁止リストを空として扱い,警告を出す + - プロジェクト所有のファイルであり,テンプレートには含まれないため `/sync-template` で上書きされない.人によって守りたい場所が違う場合は各自の場所も含めて書くか,各自が後述の `permissions.deny` を `.claude/settings.local.json` に書く + - **限界**: コマンド文字列の静的解析のため原理的に完全には防げない.変数展開(`$DIR` 等.`~`・`$HOME`・`$env:USERPROFILE`・一時ディレクトリ系以外は解決しない),スクリプトファイル経由の操作,`xargs` 等パイプで渡したパス,エイリアス・関数,エンコードされたコマンド,未対応コマンドによる削除・上書き,インライン実行中の未対応の書き方(変数に入れたパス・Perl の `open` 等)は検出できない.既存かどうかはフック実行時点で判定するため,同じコマンド内で先に作ったファイルを上書きする場合等は許可される.逆に,インライン実行で削除系の呼び出しとリポジトリ外のパス(文字列やコメント中のものも含む)が同時に現れると,削除対象でなくても拒否されることがある.`cmd /c` の引数のうち `/opt` のように `/` の後に要素が 1 つだけのパスは,スイッチ(`/mir` 等)と区別できないため判定の対象外になる.また Glob・Grep を省略した検索起点(リポジトリ内)では,リポジトリ内の禁止パスを含む検索までは止めない + - **拒否されたときの運用**: リポジトリ外の削除・上書きの最終判断は人間が持つ.フックに拒否されても,Claude は別の書き方(変数経由・スクリプトファイル経由・`python -c` 等)で迂回しない.必要な操作であれば,Claude は実行するコマンドをそのまま提示して依頼するので,ユーザーが内容を確認し,プロンプトで `! <コマンド>` として自分で実行する.フックが検出できない書き方であっても,リポジトリ外のファイルの削除・上書き(既存ファイルの移動を含む)は同じ扱いとする(`CLAUDE.md`「手動確認が必要な作業」).禁止リストによる読み取りの拒否は「読ませたくない場所」の指定なので,Claude は迂回して読まず,`!` での実行依頼もしない + - **`!` で実行したコマンドにはこのフックはかからない**(実機で確認済み).Claude が提示したコマンドは,削除・上書きの対象が意図どおりかを確認してから実行する + - 確実性が必要な場合は以下を併用する + - OS のフォルダ権限(Claude Code を実行するユーザーに守りたいフォルダの書き込み権限を与えない.dev container ならリポジトリだけをマウントする) + - `settings.json` の `permissions.deny`(例: `"Read(~/.ssh/**)"`・`"Edit(~/.ssh/**)"`.Claude Code 本体が Read・Edit 系ツールを確実に止める) + - Bash・PowerShell の実行前に確認を挟む権限モード(auto mode や `--dangerously-skip-permissions` を使わず,コマンドごとに承認する) + - バックアップ(Git 管理外の大事なデータは定期的に退避する) - **スマホへのプッシュ通知(任意)**: テンプレート同梱の `.claude/settings.json` は,作業完了(`Stop`)・許可待ち(`Notification`)・質問(`PreToolUse` の `AskUserQuestion`)で `.claude/hooks/notify.py` を呼び,[ntfy](https://ntfy.sh) 経由でスマホに通知する(ホスト OS・dev container のどちらでも動く).送信先トピックが未設定なら何もしないため,受け取りたい人だけ以下を行う. - スマホに ntfy アプリを入れ,推測されにくいトピック名(例: `claude-` +ランダムな英数字)を購読する.トピック名を知っていれば誰でも購読・送信できるため,パスワードと同様に扱いリポジトリには書かない - iPhone でアプリを開くと届いているのにプッシュ通知が出ない場合は,トピックを削除してアプリを再起動し,購読し直す(ntfy の既知の問題.購読し直すと通知の登録がやり直される) diff --git "a/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" "b/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" index 04fef34..9fb0d5d 100644 --- "a/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" +++ "b/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" @@ -55,8 +55,6 @@ ※ リファクタリングとテストは実装と一体の成果物として,まとめてコミットする.必要に応じて分割コミットも可. -※ **例外(プロジェクト立ち上げ)**: `/setup` は立ち上げ中,各フェーズの成果物が確定した時点で `chore/project-setup` ブランチに自律コミットする(GUIDE_01「立ち上げ中の Git 運用」).push・PR・マージは行わず,`main` への取り込みは完了後に人間が `/commit push` 等で行う. - ※ **例外(無人運転)**: 以下のループは CLAUDE.md の「`/commit` 自発実行禁止」ルールに対するユーザー承認済みの例外として,専用ブランチに自律コミットする.いずれも push・PR・マージ・`main` への操作は行わず,取り込み可否は人間が判断する. - `/auto-refactor`(リファクタ/ドキュメント整理ループ)→ `refactor/` 専用ブランチ