diff --git a/.claude/hooks/restrict_repo_access.py b/.claude/hooks/restrict_repo_access.py index eca9cd6..0e48e0d 100644 --- a/.claude/hooks/restrict_repo_access.py +++ b/.claude/hooks/restrict_repo_access.py @@ -1,15 +1,50 @@ -"""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 等)は検出できない.存在判定はフック実行時点の + ものであり,同じコマンド内で先に作ったファイルを上書きする場合等は許可される. + 逆にインライン実行は,削除系の呼び出しとリポジトリ外のパス(文字列・コメント中の + ものも含む)が同時に現れると,削除対象でなくても拒否することがある. + ユーザーが `!` で実行したコマンドには本フックはかからない.確実性が必要な場合は + OS のフォルダ権限,settings.json の permissions.deny,Bash / PowerShell の実行前に + 確認を挟む権限モード,バックアップを併用すること(docs/01_GUIDE/GUIDE_01 の環境構築). """ from __future__ import annotations @@ -18,29 +53,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 +252,1151 @@ (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 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)を返す. """ - # セミコロン、&&、|| やパイプで分割された各部分をチェック - parts = re.split(r"[;&|]+", command) - for part in parts: - part = part.strip() - if not part: + 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: 等のファイルシステム以外のドライブ + token = convert_posix_drive(token) + if not os.path.isabs(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 os.path.isabs(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"} + + +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 text.startswith("/") and not re.match(r"^/[A-Za-z]/", 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 w.startswith("/")} + 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 +1413,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..29cda6d 100644 --- a/.claude/hooks/tests/test_hooks.py +++ b/.claude/hooks/tests/test_hooks.py @@ -10,7 +10,9 @@ from __future__ import annotations import ast +import contextlib import http.server +import io import json import os import re @@ -20,6 +22,7 @@ import tempfile import threading 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 +400,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 +775,598 @@ 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}\")", + f"cmd /c del {ex}", f"cmd /c rd /s /q {d}", + ] + for cmd in cases: + with self.subTest(cmd): + self.assertDenied(self.ps(cmd), cmd) + + def test_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 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/template-customization.md b/.claude/rules/template-customization.md index 58f1c59..a56f992 100644 --- a/.claude/rules/template-customization.md +++ b/.claude/rules/template-customization.md @@ -13,6 +13,7 @@ - **対象外**: - プロジェクトが新規に作ったファイル(立ち上げ時に作成するコーディング規約 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/sync-template/SKILL.md b/.claude/skills/sync-template/SKILL.md index d2a3d0d..21b7eaa 100644 --- a/.claude/skills/sync-template/SKILL.md +++ b/.claude/skills/sync-template/SKILL.md @@ -329,5 +329,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..d56f7ae 100644 --- a/.claude/skills/sync-template/reference.md +++ b/.claude/skills/sync-template/reference.md @@ -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/.gitignore b/.gitignore index a1fa515..7f9cbb5 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ # .claude/hooks/ の Python フックとそのテスト(tests/test_hooks.py)が生成するバイトコードキャッシュ __pycache__/ +# tests/test_hooks.py が一時的に作る作業ディレクトリ(テストが強制終了された場合の取り残し対策) +.claude/hooks/tests/_restrict_sandbox_*/ + # -- プロジェクト立ち上げ時に有効化する(.claude/rules/git-conventions.md 参照) -- # .env # .env.* diff --git a/CLAUDE.md b/CLAUDE.md index 407a723..54548e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,8 @@ - 外部サービスの設定(コンソール操作,セキュリティルール変更等) - 例外: `gh` CLI で操作できる GitHub リポジトリ設定(Dependabot alerts / security updates の有効化)は Claude が `gh api` で行う(手順は `.claude/skills/setup/reference.md`,背景は GUIDE_01「環境構築」) +- リポジトリ外のファイルの削除・上書き(既存ファイルの移動を含む) + - フックが検出できない書き方であっても Claude は自分で実行しない(変数・スクリプトファイル・`python -c` 等による迂回も禁止).必要な操作であれば,実行するコマンドをそのまま提示し,ユーザーにプロンプトで `! <コマンド>` として実行するよう依頼する - 実機・ブラウザでの動作確認(`verifier` による自動検証は前段であり,これを置き換えない) - ストアへのアップロード・リリース作業 diff --git a/README.md b/README.md index ef82b6a..e7c6744 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,16 @@ - `/set-mode ` — 開発モードを切り替える(team 層ファイル・settings.json・CLAUDE.md・project-mode を一括整合) - `/task-create` / `/task-start` / `/task-handoff` — Issue ベースのタスク作成・着手・引継ぎ(solo / team 両モード共通) +## リポジトリ外アクセスの制限 + +同梱のフック(`.claude/hooks/restrict_repo_access.py`)が,Claude がリポジトリ外のファイルを取り返しのつかない形で壊さないようにする. + +- リポジトリ外の**削除**(`rm`・`Remove-Item`・`find -delete` 等.移動元を含む)と**既存ファイルの上書き**(`>` リダイレクト・`cp` の書き込み先・`sed -i`・`Set-Content`・Write・Edit 等)だけを拒否する(一時ディレクトリは許可) +- 新規作成・ダウンロード・`mkdir`・追記(`>>` 等)は止めない.環境構築を自動で進められるようにするためで,それ以外の判断は権限モード(auto mode の分類器等)に任せる +- 読み取り(Read・Glob・Grep・`cat` 等)はリポジトリ外も許可する.**読んだ内容は Claude の API に送信される**ため,読ませたくない場所は `.claude/repo-access.json` の `deny_read` に書いて禁止する(書式は GUIDE_01「環境構築」) +- 拒否された操作が必要なときは,Claude が提示したコマンドの内容を確認し,プロンプトで `! <コマンド>` として自分で実行する.**`!` で実行したコマンドにはこのフックはかからない** +- コマンド文字列の解析なので完全には防げない(変数展開・スクリプト経由等).確実性が必要なら OS のフォルダ権限・`permissions.deny`・コマンドごとの承認・バックアップを併用する(GUIDE_01「環境構築」) + ## ドキュメント `docs/01_GUIDE/` にプロジェクト運用のガイドが置かれている.`/setup` を実行すれば AI がこれらを順次参照しながら立ち上げを進める. 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 8714f68..706a0a5 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" @@ -62,7 +62,7 @@ - **前提条件**: 以下のツールは全プロジェクト共通で導入する. - `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`「環境構築の実行」) @@ -73,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` 等)は検出できない.既存かどうかはフック実行時点で判定するため,同じコマンド内で先に作ったファイルを上書きする場合等は許可される.逆に,インライン実行で削除系の呼び出しとリポジトリ外のパス(文字列やコメント中のものも含む)が同時に現れると,削除対象でなくても拒否されることがある.また 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 の既知の問題.購読し直すと通知の登録がやり直される)