diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 70ca719..968d4c3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,20 @@ jobs: - name: Run chezmoiscripts behavior tests run: bash tests/chezmoiscripts.test.sh + bash-guard: + name: bash-guard hook test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Run bash-guard behavior tests + run: bash tests/bash-guard.test.sh + pre-commit-hook: name: pre-commit hook test (macOS) runs-on: macos-latest diff --git a/dot_claude/CLAUDE.md b/dot_claude/CLAUDE.md index bd2637b..a44a700 100644 --- a/dot_claude/CLAUDE.md +++ b/dot_claude/CLAUDE.md @@ -25,6 +25,14 @@ - コミットメッセージは日本語で記載する - テストを追加・修正した場合、プルリクエストにサマリ・説明を記載すること +## コマンド実行 + +- コマンドはサンドボックス内で実行すること。サンドボックス内で完結する実行は自動的に許可されるので、承認プロンプトは出ない +- `dangerouslyDisableSandbox` は必ず承認プロンプトを伴う。先入観で先に付けず、まずサンドボックス内で実行し、実際に権限エラーで落ちてから昇格すること +- 一時ファイルは `/tmp` 直下ではなくスクラッチパッド(`$TMPDIR`)に置くこと。`/tmp` はサンドボックスが書き込みを拒否する +- `git` と `gh` はサンドボックス外で走る扱いのため、ビルドやテストと同じコマンドに混ぜないこと。`go test ./... && git commit ...` のような複合はコマンド全体が昇格対象になり、`gh` をループやパイプの中で呼ぶと自分の設定ファイルを読めずに失敗する +- 破壊的な git 操作(force push、`reset --hard`、`clean -f`、履歴改変)は `bash-guard.sh` フックが拒否する。回避せず、必要なら人手での実行を提案すること + ## 文章執筆 - 日本語で技術的な文章(書籍の章、記事、解説文、ドキュメント、ブログ)を書く・推敲・リライトするときは、`japanese-tech-writing` スキルを参照すること diff --git a/dot_claude/hooks/bash-guard.sh b/dot_claude/hooks/bash-guard.sh new file mode 100644 index 0000000..cdf77cf --- /dev/null +++ b/dot_claude/hooks/bash-guard.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# PreToolUse gate for the Bash tool. +# +# Why a script instead of an inline one-liner: the checks below must look for a +# flag anywhere in the command, not only right after `git`. Prefix patterns in +# permissions.deny cannot do that (`git push origin master --force` matches no +# `Bash(git push --force *)` rule), so the real gate lives here. +# +# deny — irreversible history/worktree destruction +# ask — sandbox escapes, credential reads, write-mode API calls +# +# Fails closed: if the payload cannot be parsed, ask. + +set -uo pipefail + +emit() { # $1=decision $2=reason + if command -v jq >/dev/null 2>&1; then + jq -cn --arg d "$1" --arg r "$2" \ + '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}' + else + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":"%s"}}' "$1" "$2" + fi + exit 0 +} + +command -v jq >/dev/null 2>&1 || emit ask "jq が見つからず Bash ガードを評価できません" + +input=$(cat) +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') || + emit ask "フック入力の解析に失敗しました" +escape=$(printf '%s' "$input" | jq -r '.tool_input.dangerouslyDisableSandbox // false') + +# ヒアドキュメントの本文は実行されるコマンドではないので検査対象から外す。 +# 含めたままにすると、コミットメッセージやドキュメントに書いた +# "git push --force" のような文字列で誤検知する。終端行までを落とし、 +# `</dev/null 2>&1; then + stripped=$(printf '%s' "$cmd" | + perl -0777 -pe 's/(<<-?\s*(["\x27]?)(\w+)\2)(.*?)^[ \t]*\3[ \t]*$/$1/msg' 2>/dev/null) + [ -n "$stripped" ] && cmd=$stripped +fi + +has() { printf '%s' "$cmd" | grep -Eq "$1"; } + +# サブコマンドが実際に git/gh の呼び出しになっているか。`git -C path push` や +# `$(gh auth token)` のような形も拾い、文字列として言及しただけの場合は拾わない。 +git_sub() { has "(^|[^[:alnum:]_-])git([[:space:]]+(-C|-c)[[:space:]]+[^[:space:]]+)*[[:space:]]+$1([^[:alnum:]_-]|$)"; } +gh_sub() { has "(^|[^[:alnum:]_-])gh[[:space:]]+$1([^[:alnum:]_-]|$)"; } + +# 指定した git サブコマンドの「引数部分だけ」を取り出す。区切り(; | & 改行)で +# 切るので、`git push origin main && grep -f x` の `-f` を force と誤認しない。 +# perl が無い環境ではコマンド全体を返す(過剰に deny 側へ倒す)。 +PERL_OK=false +command -v perl >/dev/null 2>&1 && PERL_OK=true +git_args() { + if [ "$PERL_OK" = true ]; then + printf '%s' "$cmd" | SUB="$1" perl -0777 -ne ' + my $s = quotemeta($ENV{SUB}); + while (/(?:^|[^\w-])git(?:\s+-[cC]\s+\S+)*\s+$s(?![\w-])([^\n;|&]*)/g) { print "$1\n" }' + else + printf '%s' "$cmd" + fi +} +in_args() { printf '%s' "$(git_args "$1")" | grep -Eq "$2"; } + +# ---- deny: 取り返しがつかない操作。オプションの位置に依存しない ---- +# --force-with-lease は末尾が続くのでこの条件には当たらない +if in_args push '(^|[[:space:]])(--force|-f)([[:space:]]|$)'; then + emit deny "force push は禁止です。--force-with-lease を使うか、人手で実行してください" +fi +if in_args reset '(^|[[:space:]])--hard([[:space:]]|$)'; then + emit deny "git reset --hard は作業ツリーを破壊するため禁止です" +fi +if in_args clean '(^|[[:space:]])(--force|-[a-zA-Z]*f[a-zA-Z]*)([[:space:]]|$)'; then + emit deny "git clean -f は未追跡ファイルを消すため禁止です" +fi +if git_sub filter-repo || git_sub filter-branch; then + emit deny "履歴の書き換えは禁止です" +fi +gh_sub 'repo[[:space:]]+delete' && emit deny "リポジトリの削除は禁止です" + +# ---- ask: サンドボックス外実行 ---- +[ "$escape" = "true" ] && + emit ask "サンドボックス外実行には承認が必要です。一時ファイルは /tmp ではなく \$TMPDIR を使い、git/gh はビルドやテストと同じコマンドに混ぜないでください" + +# ---- ask: 認証情報・書き込み系 API ---- +gh_sub 'auth[[:space:]]+token' && emit ask "認証トークンの取り出しには承認が必要です" +gh_sub '(secret|variable)[[:space:]]+(set|delete)' && + emit ask "リポジトリのシークレット/変数の変更には承認が必要です" +if gh_sub api && has '(-X|--method)[[:space:]=]+(POST|PUT|PATCH|DELETE)'; then + emit ask "gh api による書き込み操作には承認が必要です" +fi + +# ---- ask: git の実行経路を差し替える指定 ---- +has '(^|[[:space:]])(--upload-pack|--receive-pack|--exec-path)(=|[[:space:]]|$)' && + emit ask "git の実行経路を差し替えるオプションです" +has '(^|[[:space:]])(GIT_SSH_COMMAND=|GIT_CONFIG_[A-Za-z_]*=)' && + emit ask "git の実行経路を差し替える環境変数です" +# `git -c` は任意コマンドを仕込めるので ask。`git -C` は作業ディレクトリを変えるだけなので通す。 +has '(^|[^[:alnum:]_-])git[[:space:]]+-c([[:space:]]|=)' && + emit ask "git -c は設定を上書きして任意コマンドを実行できます" + +exit 0 diff --git a/dot_claude/settings.json b/dot_claude/settings.json index 3d31004..a89449e 100644 --- a/dot_claude/settings.json +++ b/dot_claude/settings.json @@ -18,9 +18,9 @@ "hooks": [ { "type": "command", - "command": "input=$(cat); cmd=$(printf '%s' \"$input\" | jq -r '.tool_input.command // \"\"'); flag=$(printf '%s' \"$input\" | jq -r '.tool_input.dangerouslyDisableSandbox // false'); danger=false; [ \"$flag\" = \"true\" ] && danger=true; printf '%s' \"$cmd\" | grep -Eq '(^|[[:space:]])(--upload-pack|--receive-pack|--exec-path)(=|[[:space:]]|$)' && danger=true; printf '%s' \"$cmd\" | grep -Eq '(^|[[:space:]])(GIT_SSH_COMMAND=|GIT_CONFIG_[A-Za-z_]*=)' && danger=true; if printf '%s' \"$cmd\" | grep -Eq '(^|[[:space:]])git([[:space:]]|$)' && printf '%s' \"$cmd\" | grep -Eq '(^|[[:space:]])(-c|-C)([[:space:]]|=|$)'; then danger=true; fi; if [ \"$danger\" = \"true\" ]; then printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"サンドボックス外実行またはgitの危険なグローバルオプション使用には承認が必要です\"}}'; fi; exit 0", + "command": "bash \"$HOME/.claude/hooks/bash-guard.sh\"", "timeout": 10, - "statusMessage": "sandbox-escape-gate" + "statusMessage": "bash-guard" } ] } @@ -28,29 +28,8 @@ }, "permissions": { "allow": [ - "Bash(git status)", - "Bash(git status *)", - "Bash(git log)", - "Bash(git log *)", - "Bash(git diff)", - "Bash(git diff *)", - "Bash(git show *)", - "Bash(git branch)", - "Bash(git branch *)", - "Bash(git remote -v)", - "Bash(git fetch)", - "Bash(git fetch *)", - "Bash(git pull)", - "Bash(git pull *)", - "Bash(git add *)", - "Bash(git commit *)", - "Bash(git push)", - "Bash(git checkout *)", - "Bash(git switch *)", - "Bash(git stash*)", - "Bash(git tag*)", - "Bash(git rev-parse *)", - "Bash(git blame *)", + "Bash(git *)", + "Bash(gh *)", "WebFetch(domain:github.com)", "WebFetch(domain:api.github.com)", "WebFetch(domain:raw.githubusercontent.com)", @@ -75,28 +54,33 @@ ], "deny": [ "Read(**/.env)", + "Read(**/.env.local)", + "Read(**/.env.*.local)", + "Read(**/.env.dev*)", + "Read(**/.env.development*)", + "Read(**/.env.stg*)", + "Read(**/.env.staging*)", + "Read(**/.env.prod*)", + "Read(**/.env.production*)", + "Read(**/.env.test*)", + "Read(**/.env_*)", "Read(**/.envrc)", - "Read(**/.env.*)", + "Read(**/.envrc.local)", "Read(**/secrets/**)", "Read(**/credentials)", "Read(~/.aws/credentials)", "Read(~/.aws/config)", - "Read(~/.ssh/id_*)", + "Read(~/.ssh/**)", "Read(~/.netrc)", "Read(**/*.pem)", "Read(**/*.key)", "Read(~/.config/gcloud/application_default_credentials.json)", + "Read(~/.config/gh/hosts.yml)", "Bash(sudo *)", "Bash(su *)", - "Bash(git push --force *)", - "Bash(git push -f *)", - "Bash(git reset --hard *)", - "Bash(git clean -f *)", - "Bash(git * --upload-pack*)", - "Bash(git * --receive-pack*)", - "Bash(git * --exec-path*)", - "Bash(git -c *)", - "Bash(git -C *)" + "Bash(git filter-repo*)", + "Bash(git filter-branch*)", + "Bash(gh repo delete*)" ] }, "sandbox": { @@ -125,8 +109,18 @@ "~/.config/gh/", "~/.kube/", "**/.env", - "**/.env.*", + "**/.env.local", + "**/.env.*.local", + "**/.env.dev*", + "**/.env.development*", + "**/.env.stg*", + "**/.env.staging*", + "**/.env.prod*", + "**/.env.production*", + "**/.env.test*", + "**/.env_*", "**/.envrc", + "**/.envrc.local", "**/secrets/**", "**/credentials", "**/*.pem", @@ -137,6 +131,9 @@ "~/.pnpm-store", "~/go/pkg", "~/Library/Caches/go-build", + "~/Library/Caches/golangci-lint", + "~/Library/Caches/Yarn", + "~/Library/Caches/mise", "~/.gradle/caches", "~/.gradle/wrapper", "~/.m2/repository", diff --git a/shellcheck.sh b/shellcheck.sh index 856e6c5..83197cb 100755 --- a/shellcheck.sh +++ b/shellcheck.sh @@ -3,4 +3,5 @@ shellcheck \ shellcheck.sh \ .chezmoiscripts/*.sh \ + dot_claude/hooks/*.sh \ tests/*.sh diff --git a/tests/bash-guard.test.sh b/tests/bash-guard.test.sh new file mode 100644 index 0000000..aad84e6 --- /dev/null +++ b/tests/bash-guard.test.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Claude Code の PreToolUse フック bash-guard.sh の判定を検証する。 +# 実行: bash tests/bash-guard.test.sh +# +# 判定対象のコマンド文字列は展開せずそのままフックへ渡す必要がある +# shellcheck disable=SC2016 + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +GUARD="${REPO_ROOT}/dot_claude/hooks/bash-guard.sh" + +PASS=0 +FAIL=0 + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "missing dependency: $1" + exit 2 + } +} + +# $1=期待する判定(pass|ask|deny) $2=ラベル $3=コマンド $4=サンドボックス外指定(既定 false) +assert_decision() { + local expected="$1" label="$2" command="$3" escape="${4:-false}" + local payload got + payload=$(jq -cn --arg c "${command}" --argjson e "${escape}" \ + '{tool_input: {command: $c, dangerouslyDisableSandbox: $e}}') + got=$(printf '%s' "${payload}" | bash "${GUARD}" 2>/dev/null | + jq -r '.hookSpecificOutput.permissionDecision // empty' 2>/dev/null) + # 何も返さない = 素通り + [[ -z "${got}" ]] && got=pass + if [[ "${got}" == "${expected}" ]]; then + echo " ok: ${label} (${got})" + PASS=$((PASS + 1)) + else + echo " FAIL: ${label} (expected=${expected} actual=${got})" + FAIL=$((FAIL + 1)) + fi +} + +require jq +[[ -f "${GUARD}" ]] || { + echo "guard not found: ${GUARD}" + exit 2 +} + +echo "case 1: 日常のビルド・テストは素通りする" +assert_decision pass "go test" 'go test ./... -count=1' +assert_decision pass "gofmt" 'gofmt -l .' +assert_decision pass "golangci-lint" 'golangci-lint run ./...' +assert_decision pass "通常の push" 'git push origin feature/x' +assert_decision pass "git -C は無害なので通す" 'git -C /tmp/repo status' +assert_decision pass "ループ内の gh pr list" 'for r in a b; do gh pr list -R o/$r; done' +assert_decision pass "gh api の GET" 'gh api repos/o/r/pulls' +assert_decision pass "gh repo view" 'gh repo view foo/bar' + +echo "case 2: force push はフラグの位置を問わず deny" +assert_decision deny "先頭に --force" 'git push --force origin master' +assert_decision deny "末尾に --force" 'git push origin master --force' +assert_decision deny "末尾に -f" 'git push origin master -f' +assert_decision deny "git -C 経由" 'git -C /r push --force' +assert_decision pass "--force-with-lease は許可" 'git push --force-with-lease origin master' + +echo "case 3: 作業ツリー・履歴の破壊は deny" +assert_decision deny "reset --hard" 'git reset --hard HEAD~1' +assert_decision deny "reset --hard 引数なし" 'git reset --hard' +assert_decision pass "reset (soft)" 'git reset HEAD~1' +assert_decision pass "reset --soft" 'git reset --soft HEAD~1' +assert_decision deny "clean -fd" 'git clean -fd' +assert_decision deny "clean --force" 'git clean --force -d' +assert_decision pass "clean -n は dry-run" 'git clean -n' +assert_decision deny "filter-repo" 'git filter-repo --path x' +assert_decision deny "filter-branch" 'git filter-branch --tree-filter x HEAD' +assert_decision deny "gh repo delete" 'gh repo delete foo/bar' + +echo "case 4: サンドボックス外実行と認証情報の扱いは ask" +assert_decision ask "サンドボックス外" 'go test ./...' true +assert_decision ask "gh auth token の埋め込み" 'GH_TOKEN=$(gh auth token) zizmor .' +assert_decision ask "gh secret set" 'gh secret set FOO --body bar' +assert_decision ask "gh api の書き込み" 'gh api -X DELETE repos/o/r/x' +assert_decision ask "git -c" 'git -c core.pager=sh log' +assert_decision ask "GIT_SSH_COMMAND" 'GIT_SSH_COMMAND=x git fetch' +assert_decision ask "--upload-pack" 'git fetch origin --upload-pack=evil' + +echo "case 5: ヒアドキュメント本文は実行対象ではないので検査しない" +assert_decision pass "本文に force フラグ" \ + $'cat > $TMPDIR/m.txt <<\'EOF\'\nfeat: force push を禁止する\ngit push origin master --force を捕まえる\nEOF\ngit commit -F $TMPDIR/m.txt' +assert_decision pass "本文に reset --hard" \ + $'python3 - <<\'PY\'\n# git reset --hard の説明\nPY' +assert_decision deny "本文の後ろにある本物の force push" \ + $'cat > $TMPDIR/m.txt <<\'EOF\'\n説明文\nEOF\ngit push --force origin main' + +echo "case 6: 引数の切り出しは区切りをまたがない" +assert_decision pass "他コマンドの -f は force ではない" 'git push origin main && grep -f patterns.txt log' +assert_decision deny "本物の force + 他コマンドの -f" 'git push origin main --force && grep -f patterns.txt log' +assert_decision pass "コミットメッセージ内の言及" 'git commit -m "docs: --force の危険性を書く"' + +echo "case 7: 解析できない入力は fail-closed で ask" +got=$(printf 'not json at all' | bash "${GUARD}" 2>/dev/null | + jq -r '.hookSpecificOutput.permissionDecision // empty' 2>/dev/null) +if [[ "${got}" == "ask" ]]; then + echo " ok: 壊れた入力 (ask)" + PASS=$((PASS + 1)) +else + echo " FAIL: 壊れた入力 (expected=ask actual=${got:-none})" + FAIL=$((FAIL + 1)) +fi + +echo +echo "pass=${PASS} fail=${FAIL}" +[[ "${FAIL}" -eq 0 ]]