Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions dot_claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` スキルを参照すること
Expand Down
103 changes: 103 additions & 0 deletions dot_claude/hooks/bash-guard.sh
Original file line number Diff line number Diff line change
@@ -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" のような文字列で誤検知する。終端行までを落とし、
# `<<EOF` を含む行そのものは残す。
if command -v perl >/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
71 changes: 34 additions & 37 deletions dot_claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,39 +18,18 @@
"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"
}
]
}
]
},
"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)",
Expand All @@ -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": {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions shellcheck.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
shellcheck \
shellcheck.sh \
.chezmoiscripts/*.sh \
dot_claude/hooks/*.sh \
tests/*.sh
113 changes: 113 additions & 0 deletions tests/bash-guard.test.sh
Original file line number Diff line number Diff line change
@@ -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 ]]
Loading