diff --git a/dot_claude/CLAUDE.md b/dot_claude/CLAUDE.md index a44a700..1817da3 100644 --- a/dot_claude/CLAUDE.md +++ b/dot_claude/CLAUDE.md @@ -32,6 +32,14 @@ - 一時ファイルは `/tmp` 直下ではなくスクラッチパッド(`$TMPDIR`)に置くこと。`/tmp` はサンドボックスが書き込みを拒否する - `git` と `gh` はサンドボックス外で走る扱いのため、ビルドやテストと同じコマンドに混ぜないこと。`go test ./... && git commit ...` のような複合はコマンド全体が昇格対象になり、`gh` をループやパイプの中で呼ぶと自分の設定ファイルを読めずに失敗する - 破壊的な git 操作(force push、`reset --hard`、`clean -f`、履歴改変)は `bash-guard.sh` フックが拒否する。回避せず、必要なら人手での実行を提案すること +- ループバック接続(`localhost` / `127.0.0.1`)とローカルの listen は `sandbox.network.allowLocalBinding` で許可済み。統合テスト、E2E、dev サーバ、Playwright、Go の `httptest`、`curl http://localhost:*` はサンドボックス内で走るので escape を付けないこと +- `git` `gh` `docker` などは `sandbox.excludedCommands` により既にサンドボックス外で走る。これらに `dangerouslyDisableSandbox` を付けても実行結果は変わらず、承認プロンプトだけが増えるので付けないこと +- 承認プロンプトを伴うコマンド(`dangerouslyDisableSandbox` 付き、`docker exec` / `docker run` / `docker compose exec` などの変更系)では、`description` に日本語で次の4点を書くこと。ユーザはこの説明だけを見て可否を判断する + - 目的:何のために実行するのか + - 副作用:書き込むパス、触る DB とテーブル、接続先ホスト、削除や上書きの有無 + - 可逆性:元に戻せるか。戻し方、またはバックアップの場所 + - escape の理由:`dangerouslyDisableSandbox` を付ける場合、サンドボックス内で何がどう失敗したか + ヒアドキュメントで渡すスクリプトは、コマンド文字列を読ませて理解させるのではなく、`description` 側で何をするスクリプトかを説明すること ## 文章執筆 diff --git a/dot_claude/hooks/bash-guard.sh b/dot_claude/hooks/bash-guard.sh index cdf77cf..e913911 100644 --- a/dot_claude/hooks/bash-guard.sh +++ b/dot_claude/hooks/bash-guard.sh @@ -29,6 +29,7 @@ input=$(cat) cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') || emit ask "フック入力の解析に失敗しました" escape=$(printf '%s' "$input" | jq -r '.tool_input.dangerouslyDisableSandbox // false') +desc=$(printf '%s' "$input" | jq -r '.tool_input.description // ""') # ヒアドキュメントの本文は実行されるコマンドではないので検査対象から外す。 # 含めたままにすると、コミットメッセージやドキュメントに書いた @@ -79,9 +80,29 @@ if git_sub filter-repo || git_sub filter-branch; then fi gh_sub 'repo[[:space:]]+delete' && emit deny "リポジトリの削除は禁止です" +# ---- deny: 承認を求める前に説明を書かせる ---- +# 承認プロンプトに出るのは description だけで、ヒアドキュメントで渡す長い +# スクリプトはユーザが読んで判断できる形になっていない。説明が薄いまま承認を +# 求めるのを止める。ask ではなく deny なのは、deny の理由だけがモデルに返り、 +# 説明を書き直して再提示させられるため。ユーザに丸投げしない。 +# +# サンドボックス内で完結するコマンドは自動許可されプロンプトが出ないので、 +# ここは実際に承認を要する経路(escape と docker の変更系)だけを対象にする。 +needs_detail=false +[ "$escape" = "true" ] && needs_detail=true +has '(^|[^[:alnum:]_-])docker[[:space:]]+(exec|run|cp|rm|rmi|build|buildx|volume|network|push|kill|stop)([^[:alnum:]_-]|$)' && + needs_detail=true +has '(^|[^[:alnum:]_-])docker[[:space:]]+compose[[:space:]]+([a-z-]+[[:space:]]+)*(up|down|exec|run|restart|rm|kill|stop)([^[:alnum:]_-]|$)' && + needs_detail=true + +# 80 バイト=日本語で約 27 文字。目的・副作用・可逆性を書けばまず超える。 +if [ "$needs_detail" = true ] && [ "${#desc}" -lt 80 ]; then + emit deny "承認が必要なコマンドですが description が短すぎて可否を判断できません(${#desc} バイト)。日本語で「目的/副作用(書き込むパス・触る DB とテーブル・接続先ホスト・削除や上書きの有無)/元に戻せるか/サンドボックス外で実行する理由」を書いた description を付けて再提示してください。ヒアドキュメントの中身も description 側で説明してください" +fi + # ---- ask: サンドボックス外実行 ---- [ "$escape" = "true" ] && - emit ask "サンドボックス外実行には承認が必要です。一時ファイルは /tmp ではなく \$TMPDIR を使い、git/gh はビルドやテストと同じコマンドに混ぜないでください" + emit ask "サンドボックス外実行には承認が必要です(説明: ${desc})。ループバック接続とローカル listen は allowLocalBinding で許可済みなので、それが理由なら escape は不要です" # ---- ask: 認証情報・書き込み系 API ---- gh_sub 'auth[[:space:]]+token' && emit ask "認証トークンの取り出しには承認が必要です" diff --git a/dot_claude/settings.json b/dot_claude/settings.json.tmpl similarity index 58% rename from dot_claude/settings.json rename to dot_claude/settings.json.tmpl index 4fe95e2..92852b7 100644 --- a/dot_claude/settings.json +++ b/dot_claude/settings.json.tmpl @@ -1,31 +1,4 @@ { - "hooks": { - "SessionStart": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "bash \"$HOME/.claude/hooks/herdr-agent-state.sh\" session", - "timeout": 10 - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "bash \"$HOME/.claude/hooks/bash-guard.sh\"", - "timeout": 10, - "statusMessage": "bash-guard" - } - ] - } - ] - }, "permissions": { "allow": [ "Bash(git *)", @@ -42,15 +15,20 @@ "WebFetch(domain:registry.terraform.io)", "WebFetch(domain:docs.anthropic.com)", "WebFetch(domain:code.claude.com)", - "WebSearch(*)" - ], - "ask": [ - "Bash(terraform apply*)", - "Bash(terraform destroy*)", - "Bash(kubectl apply*)", - "Bash(kubectl delete*)", - "Bash(docker push*)", - "Bash(gh pr merge*)" + "WebSearch(*)", + "Bash(docker ps)", + "Bash(docker ps *)", + "Bash(docker images)", + "Bash(docker images *)", + "Bash(docker logs *)", + "Bash(docker inspect *)", + "Bash(docker version *)", + "Bash(docker compose ps)", + "Bash(docker compose ps *)", + "Bash(docker compose logs *)", + "mcp__claude_ai_Google_Drive__search_files", + "mcp__claude_ai_Notion__notion-fetch", + "mcp__claude_ai_Atlassian_Rovo__getJiraIssue" ], "deny": [ "Read(**/.env)", @@ -84,115 +62,42 @@ "Bash(git filter-repo*)", "Bash(git filter-branch*)", "Bash(gh repo delete*)" + ], + "ask": [ + "Bash(terraform apply*)", + "Bash(terraform destroy*)", + "Bash(kubectl apply*)", + "Bash(kubectl delete*)", + "Bash(docker push*)", + "Bash(gh pr merge*)" ] }, - "sandbox": { - "enabled": true, - "autoAllowBashIfSandboxed": true, - "allowUnsandboxedCommands": true, - "excludedCommands": [ - "git *", - "docker *", - "docker-compose *", - "gh *", - "chezmoi *", - "gcloud *", - "bq *", - "gsutil *", - "aws *", - "kubectl *", - "terraform *" + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash \"$HOME/.claude/hooks/herdr-agent-state.sh\" session", + "timeout": 10 + } + ] + } ], - "filesystem": { - "denyRead": [ - "~/.aws/", - "~/.ssh/", - "~/.netrc", - "~/.config/gcloud/", - "~/.config/gh/", - "~/.kube/", - "**/.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.json", - "**/credentials.yml", - "**/credentials.yaml", - "**/credentials.db", - "**/*.pem", - "**/*.key" - ], - "allowWrite": [ - "/var/folders/*/*/T", - "~/.npm", - "~/.pnpm-store", - "~/go/pkg", - "~/Library/Caches/go-build", - "~/Library/Caches/golangci-lint", - "~/Library/Caches/Yarn", - "~/Library/Caches/mise", - "~/.gradle/caches", - "~/.gradle/wrapper", - "~/.m2/repository", - "~/.cargo/registry", - "~/.cargo/git", - "~/.cache" - ] - }, - "network": { - "allowedDomains": [ - "github.com", - "*.github.com", - "api.github.com", - "raw.githubusercontent.com", - "*.githubusercontent.com", - "registry.npmjs.org", - "*.npmjs.com", - "registry.yarnpkg.com", - "rubygems.org", - "*.rubygems.org", - "pypi.org", - "*.pypi.org", - "files.pythonhosted.org", - "proxy.golang.org", - "sum.golang.org", - "golang.org", - "crates.io", - "static.crates.io", - "index.crates.io", - "static.rust-lang.org", - "repo.maven.apache.org", - "repo1.maven.org", - "*.gradle.org", - "packagist.org", - "repo.packagist.org", - "*.googleapis.com", - "accounts.google.com", - "*.google.com", - "*.googleusercontent.com", - "*.slack.com", - "files.slack.com", - "*.amazonaws.com", - "registry.terraform.io", - "*.terraform.io", - "registry-1.docker.io", - "*.docker.io", - "api.anthropic.com", - "*.anthropic.com", - "claude.ai", - "*.claude.ai" - ] - } + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash \"$HOME/.claude/hooks/bash-guard.sh\"", + "timeout": 10, + "statusMessage": "bash-guard" + } + ] + } + ] }, "statusLine": { "type": "command", @@ -241,6 +146,153 @@ } } }, + "sandbox": { + "enabled": true, + "autoAllowBashIfSandboxed": true, + "allowUnsandboxedCommands": true, + "network": { + "allowedDomains": [ + "github.com", + "*.github.com", + "api.github.com", + "raw.githubusercontent.com", + "*.githubusercontent.com", + "registry.npmjs.org", + "*.npmjs.com", + "registry.yarnpkg.com", + "rubygems.org", + "*.rubygems.org", + "pypi.org", + "*.pypi.org", + "files.pythonhosted.org", + "proxy.golang.org", + "sum.golang.org", + "golang.org", + "crates.io", + "static.crates.io", + "index.crates.io", + "static.rust-lang.org", + "repo.maven.apache.org", + "repo1.maven.org", + "*.gradle.org", + "packagist.org", + "repo.packagist.org", + "*.googleapis.com", + "accounts.google.com", + "*.google.com", + "*.googleusercontent.com", + "*.slack.com", + "files.slack.com", + "*.amazonaws.com", + "registry.terraform.io", + "*.terraform.io", + "registry-1.docker.io", + "*.docker.io", + "api.anthropic.com", + "*.anthropic.com", + "claude.ai", + "*.claude.ai" + ], + "allowLocalBinding": true + }, + "filesystem": { + "allowWrite": [ + "/var/folders/*/*/T", + "~/.npm", + "~/.pnpm-store", + "~/go/pkg", + "~/Library/Caches/go-build", + "~/Library/Caches/golangci-lint", + "~/Library/Caches/Yarn", + "~/Library/Caches/mise", + "~/.gradle/caches", + "~/.gradle/wrapper", + "~/.m2/repository", + "~/.cargo/registry", + "~/.cargo/git", + "~/.cache" + ], + "denyRead": [ + "~/.aws/", + "~/.ssh/", + "~/.netrc", + "~/.config/gcloud/", + "~/.config/gh/", + "~/.kube/", + "**/.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.json", + "**/credentials.yml", + "**/credentials.yaml", + "**/credentials.db", + "**/*.pem", + "**/*.key" + ] + }, + "excludedCommands": [ + "git *", + "docker *", + "docker-compose *", + "gh *", + "chezmoi *", + "gcloud *", + "bq *", + "gsutil *", + "aws *", + "kubectl *", + "terraform *" + ] + }, "skipWorkflowUsageWarning": true, - "preferredNotifChannel": "ghostty" + "preferredNotifChannel": "ghostty", + "autoMode": { + "environment": [ + "### Org-wide", + "**Organization**: None configured", + "**Cloud provider(s)**: None configured", + "**Repository visibility**: assume private unless the remote host and repo name indicate otherwise, or a visibility check in the transcript shows public", + "**Internal sharing / snippet hosting**: None configured — treat public paste/gist services as outside the trust boundary", + "**Secrets management**: None configured", + "**Default / protected branches**: unknown — origin/HEAD unset and no remotes configured; the repo's own docs say to branch from the default branch tracked to remote, but no branch name or protection data is available", + "**CI/CD deploy targets**: None configured", + "**Network posture**: None configured", + "**Source control**: The trusted repo and its remote(s) only (no additional orgs configured) — as scoped by the Trusted repo and Repository visibility entries above; note this checkout ({{ .chezmoi.homeDir }}) has no configured remotes", + "**Trusted internal domains**: None configured", + "**Trusted cloud buckets**: None configured", + "**Key internal services**: None configured", + "**Internal package registry**: None configured", + "**Sensitive data locations & audiences**: any file or store holding personal data, confidential business data, credentials, regulated data, or similarly sensitive material; preserve exact handles when known and share only with audiences cleared at the [named+specifics] bar — note this working directory is the home directory itself, so dotfiles, shell history, and credential files sit inside the working tree", + "**Data retention / declassification**: None configured", + "**Sensitive remote targets**: any namespace, host, or container whose name carries `prod` or `production` as a whole word or name segment (hyphen/underscore/dot-delimited — e.g. matches `prod-db`, not `producer`)", + "**Protected deployment namespaces / environments**: None configured — fall back to the Sensitive remote targets heuristic", + "**Protected IaC scopes**: IAM, RBAC, networking, quota, and node-pool resources; anything whose name or tag carries `prod` or `production` as a whole word or name segment", + "### User-specific", + "**Primary use of Claude Code**: software development, plus Japanese-language technical writing and editing (book chapters, articles, documentation) per the user's own CLAUDE.md", + "**Trusted repo**: The git repository the agent started in (its working directory) and its configured remote(s). When the repo's public/private visibility is given — by the Repository visibility entry or the user's own message — use it to scope what is OK to commit or push there: confidential material is fine in a private repo; in a public one, only that repo's own work is — and content ported, repointed, or first read from outside this session's repo is not its own work, whoever directed the port. Visibility scopes confidential material only: secrets and sensitive data (personal & entrusted) are never cleared into any repo by its visibility (see Definitions). Here that repo is {{ .chezmoi.homeDir }} with no remotes and zero tracked files.", + "**Org-specific CLIs**: chezmoi (dotfile management; 8x in this project's transcripts, 2x in shell history) — routine local dotfile read/apply operations under this home directory only", + "**Routine under this repo's prefix**: local-only development and writing work under {{ .chezmoi.homeDir }}; commands are expected to run inside the sandbox, with temp files in $TMPDIR rather than /tmp" + ], + "allow": [ + "$defaults", + "Bash(chezmoi diff:*)", + "Bash(chezmoi status:*)" + ], + "soft_deny": [ + "$defaults", + "Bash(chezmoi apply:*)", + "Bash(chezmoi forget:*)" + ] + } } diff --git a/tests/bash-guard.test.sh b/tests/bash-guard.test.sh index aad84e6..c051234 100644 --- a/tests/bash-guard.test.sh +++ b/tests/bash-guard.test.sh @@ -11,6 +11,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" GUARD="${REPO_ROOT}/dot_claude/hooks/bash-guard.sh" +# 承認を要する経路で deny を回避できる長さの説明。目的・副作用・可逆性を書けば +# 自然に超える量で、閾値ちょうどの検査は case 8 で別に行う。 +DESC_OK="統合テストを実行する。localhost:5432 の Postgres に接続し public スキーマを truncate して seed を入れ直す。使い捨て DB なので compose down -v で戻せる" + PASS=0 FAIL=0 @@ -22,11 +26,12 @@ require() { } # $1=期待する判定(pass|ask|deny) $2=ラベル $3=コマンド $4=サンドボックス外指定(既定 false) +# $5=description(既定 空)。承認を要する経路では 80 バイト以上ないと deny になる assert_decision() { - local expected="$1" label="$2" command="$3" escape="${4:-false}" + local expected="$1" label="$2" command="$3" escape="${4:-false}" description="${5:-}" local payload got - payload=$(jq -cn --arg c "${command}" --argjson e "${escape}" \ - '{tool_input: {command: $c, dangerouslyDisableSandbox: $e}}') + payload=$(jq -cn --arg c "${command}" --arg d "${description}" --argjson e "${escape}" \ + '{tool_input: {command: $c, description: $d, dangerouslyDisableSandbox: $e}}') got=$(printf '%s' "${payload}" | bash "${GUARD}" 2>/dev/null | jq -r '.hookSpecificOutput.permissionDecision // empty' 2>/dev/null) # 何も返さない = 素通り @@ -76,7 +81,7 @@ 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 "サンドボックス外" 'go test ./...' true "${DESC_OK}" 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' @@ -108,6 +113,39 @@ else FAIL=$((FAIL + 1)) fi +# 承認ダイアログに出るのは description だけなので、説明が薄いまま承認を求めるのを +# 止める。deny なのは理由がモデルに返り、説明を書き直させられるためである。 +# 対象は実際に承認を要する経路に限る。サンドボックス内で完結するコマンドは +# 自動許可でプロンプトが出ないので、ここで縛ると摩擦だけが増える。 +echo "case 8: 承認を要する経路は説明が無いと deny" +assert_decision deny "サンドボックス外・説明なし" 'go test ./...' true +assert_decision deny "サンドボックス外・説明が短い" 'go test ./...' true 'テスト実行' +assert_decision ask "サンドボックス外・説明が十分" 'go test ./...' true "${DESC_OK}" +assert_decision deny "docker exec・説明が短い" 'docker exec -i pg-1 psql -c "select 1"' false 'DB確認' +assert_decision pass "docker exec・説明が十分" 'docker exec -i pg-1 psql -c "select 1"' false "${DESC_OK}" +assert_decision deny "docker compose exec・説明が短い" 'docker compose exec -T db psql' false '確認' +assert_decision deny "docker compose up -d・説明が短い" 'docker compose up -d' false '起動' +assert_decision deny "docker cp・説明が短い" 'docker cp ./x.sql pg-1:/tmp/x.sql' false '転送' + +echo "case 9: 読み取り専用とサンドボックス内は説明を求めない" +assert_decision pass "docker ps" 'docker ps --format json' +assert_decision pass "docker compose ps" 'docker compose ps' +assert_decision pass "docker logs" 'docker logs pg-1 --tail 20' +assert_decision pass "サンドボックス内の npm run" 'npm run test:integration 2>&1 | tail -50' +assert_decision pass "サンドボックス内の go test" 'go test ./... -count=1' +# 単語の一部を拾わないこと。execute や runner は exec / run ではない +assert_decision pass "docker という語を含むだけの文字列" 'grep -rn docker-compose.yml .' + +echo "case 10: 閾値は 80 バイト。境界を跨ぐところで判定が変わる" +DESC_79=$(printf 'a%.0s' $(seq 1 79)) +DESC_80=$(printf 'a%.0s' $(seq 1 80)) +assert_decision deny "79 バイト" 'go test ./...' true "${DESC_79}" +assert_decision ask "80 バイト" 'go test ./...' true "${DESC_80}" + +echo "case 11: 破壊的操作の deny は説明の有無より優先する" +assert_decision deny "force push は説明があっても deny" 'git push --force origin main' false "${DESC_OK}" +assert_decision deny "reset --hard は説明があっても deny" 'git reset --hard HEAD~1' false "${DESC_OK}" + echo echo "pass=${PASS} fail=${FAIL}" [[ "${FAIL}" -eq 0 ]]