Claude Media
Claude CodeでIAMポリシーを事前検証 — simulate-principal-policyをhookで通す

Claude CodeでIAMポリシーを事前検証 — simulate-principal-policyをhookで通す

put-role-policyの前にsimulate-principal-policyを必ず通す構成です。期待表と判定を突き合わせ、一致しなければPreToolUse hookで止めます。シミュレーターの限界にも触れます。

IAMのインラインポリシーは、aws iam put-role-policyを実行した瞬間に本番へ効きます。Claude Codeにこのコマンドを任せるなら、実行前に「このポリシーで何が通り、何が止まるか」を機械的に確かめる関門が要ります。その関門になるのが、aws iam simulate-principal-policyです。

この記事では、期待表(許可したい操作と禁止したい操作の一覧)を先に書き、変更後のポリシーで判定を再現し、食い違えばPreToolUse hookでput-role-policyを止める構成を組みます。権限を絞るポリシーの作り方はAccess Analyzerを使った最小権限化にあります。ここで扱うのは、作ったあとに「変更してよいか」を検証する側です。

simulate-principal-policyが返す3つの判定

シミュレーターは、実際のAPIを呼ばずに認可だけを評価します。結果は操作ごとのEvalDecisionで、取りうる値は3つです。

EvalDecision意味期待表での使いどころ
allowed意味どこかのAllowに当たり、Denyに当たらない期待表での使いどころ業務に必要な操作
implicitDeny意味どのAllowにも当たらない期待表での使いどころ「付与していない」ことの確認
explicitDeny意味Denyステートメントに当たった期待表での使いどころガードレールが効いている確認

implicitDenyとexplicitDenyを分けて書くのが、この方式の要です。「通らない」だけを確かめると、誰かがAllowを足した瞬間にimplicitDenyがallowedへ変わり、期待表が割れて気づけます。一方、Denyで守っている操作がallowedに変わったなら、ガードレールが外れたことを意味します。

--policy-source-arnにユーザー、グループ、ロールのARNを渡すと、そのエンティティに紐づくポリシーすべてが評価対象になります。ユーザーを渡せば、所属グループのポリシーも含まれます。存在しないリソースも指定できるので、作る前のバケットやリポジトリでも検証できます。

シミュレーターが見るもの、見ないもの

検証結果を過信しないために、評価の範囲を先に押さえます。

評価される評価されない・差が出る
アイデンティティベースのポリシー評価されない・差が出るリソースコントロールポリシー(RCP)
権限境界評価されない・差が出るVPCエンドポイントポリシー
SCP(条件キーとリソース指定を含む)評価されない・差が出るロールの連鎖(role chaining)
自分で渡したリソースベースポリシー評価されない・差が出る1つのリソースに複数のリソースベースポリシーがある構成

AWSのガイドには、シミュレーターの結果が実環境と異なる場合があること、テスト後に実環境でも確認することが明記されています。つまりこの関門は、変更を安全に通すためのものではなく、明らかな誤りを適用前に落とすためのものです。適用後に実際の呼び出しで確かめる手順は、省けません。

条件キーにも注意が要ります。aws:PrincipalAccountなどの主体情報と組織情報はシミュレーターが自動で埋めますが、aws:RequestedRegionやaws:MultiFactorAuthPresentのような値は、--context-entriesで渡す必要があります。値を渡さないと、条件を満たせずに拒否側へ倒れます。SCPだけが参照するキーも、CLIやAPIからなら値を渡せます。ただしSCPの評価では、一致したステートメントや不足している文脈値は返りません。

手順1: 期待表を先にJSONで書く

ポリシーを書く前に、期待表を用意します。操作、リソース、期待する判定の3つ組です。

[
  {
    "action": "ecr:BatchGetImage",
    "resource": "arn:aws:ecr:ap-northeast-1:123456789012:repository/app",
    "expect": "allowed"
  },
  {
    "action": "s3:DeleteBucket",
    "resource": "arn:aws:s3:::prod-data",
    "expect": "implicitDeny"
  },
  {
    "action": "iam:CreateAccessKey",
    "resource": "*",
    "expect": "explicitDeny"
  }
]

これを.claude/iam-expect/app-deployer.jsonのように、ロール名で保存します。1行を1回のシミュレーションに対応させるのは、集計の仕様のためです。AWSの説明では、1つの操作を複数のリソースで試すと、最上位のEvalDecisionは最も厳しい判定になります。たとえば1つでもexplicitDenyがあれば、全体がexplicitDenyと表示されます。リソースを1つずつ渡せば、最上位の値がそのまま行の判定になります。

期待表はClaude Codeに書かせてもかまいませんが、人間がレビューします。実行する側が自分で採点基準を書くと、基準を通りやすい側へ寄るためです。

手順2: 変更後のポリシーで判定を再現する

simulate-principal-policyは、ロールに今ついているポリシーを評価します。まだ適用していない案を試すには、案を--policy-input-listで追加します。ただし追加分は既存のポリシーに足されるだけなので、同名のインラインポリシーを置き換える変更では、旧版が残って判定が甘くなります。そこで--policy-exclusion-listを併用し、置き換わる旧版をシミュレーションから外します。

#!/usr/bin/env bash
# .claude/hooks/iam-sim.sh
# usage: iam-sim.sh <role> <policy.json> <policy-name> <expect.json>
set -euo pipefail
role=$1; doc=$2; pname=$3; expect=$4
acct=$(aws sts get-caller-identity --query Account --output text)
arn="arn:aws:iam::${acct}:role/${role}"
excl="InlinePolicyIdentifier={PolicyName=${pname},"
excl+="AttachmentType=role,AttachmentName=${role}}"
fail=0
while IFS=$'\t' read -r action resource want; do
  out=$(aws iam simulate-principal-policy \
    --policy-source-arn "$arn" \
    --policy-input-list "$(cat "$doc")" \
    --policy-exclusion-list "$excl" \
    --action-names "$action" --resource-arns "$resource" \
    --output json)
  got=$(jq -r '.EvaluationResults[0].EvalDecision' <<<"$out")
  miss=$(jq -r '[.EvaluationResults[0]
    | (.MissingContextValues[]?,
       .ResourceSpecificResults[]?.MissingContextValues[]?)]
    | unique | join(",")' <<<"$out")
  if [ "$got" != "$want" ]; then
    echo "MISMATCH $action $resource: want=$want got=$got" >&2
    fail=1
  elif [ -n "$miss" ]; then
    echo "MISSING-CONTEXT $action $resource: $miss" >&2
    fail=1
  else
    echo "ok $action $resource $got"
  fi
done < <(jq -r '.[] | [.action, .resource, .expect] | @tsv' "$expect")
exit $fail

スクリプトの設計には、3つの判断が入っています。

  • 置き換え対象の除外にはInlinePolicyIdentifierを使います。ポリシー名、付与先の種別(role)、付与先の名前の3点で指定する形式です。新規の名前で一致するポリシーが無い場合、除外指定は無視されます
  • 判定が期待どおりでも、MissingContextValuesが空でなければ失敗にします。条件キーの値が足りないまま出たallowedは、実環境でも通るという保証になりません
  • 戻り値は不一致が1件でもあれば1にします。呼び出し側は終了コードだけ見れば足ります

手元で試すときは、フックを介さずに直接実行できます。

.claude/hooks/iam-sim.sh app-deployer policy.json deploy \
  .claude/iam-expect/app-deployer.json

条件キーを使うポリシーなら、--context-entriesを足します。形式はAWS CLIのリファレンスにある省略記法で、1件ならこう書きます。

--context-entries "ContextKeyName=aws:RequestedRegion,ContextKeyValues=ap-northeast-1,ContextKeyType=string"

手順3: PreToolUse hookでput-role-policyの前に必ず通す

スクリプトを関門にするのが、PreToolUse hookです。このフックは、Claudeがツール呼び出しのパラメータを作ったあと、実行の前に走ります。Bashツールなら、実行予定のコマンドがtool_input.commandとして標準入力のJSONで渡ってきます。

手順

フックが行う判断

  1. 1

    コマンドを絞り込む

    ifにBash(aws iam put-role-policy *)を指定し、該当するコマンドだけでスクリプトを起動します。

  2. 2

    引数を取り出す

    --role-name、--policy-name、file://で渡されたポリシーのパスを抜き出します。取り出せなければ拒否します。

  3. 3

    期待表を探す

    ロール名に対応する期待表が無ければ拒否し、先に作るよう伝えます。

  4. 4

    シミュレーションを実行

    不一致があれば拒否理由に差分を載せ、Claudeに修正させます。全件一致なら何も出力せず終了します。

ラッパー側のスクリプトは次のとおりです。

#!/usr/bin/env bash
# .claude/hooks/iam-sim-hook.sh
set -euo pipefail
dir=$(cd "$(dirname "$0")" && pwd)
cmd=$(jq -r '.tool_input.command')
deny() {
  jq -n --arg r "$1" '{hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny", permissionDecisionReason: $r}}'
  exit 0
}
role=$(grep -oE -- '--role-name +[^ ]+' <<<"$cmd" | awk '{print $2}' || true)
pname=$(grep -oE -- '--policy-name +[^ ]+' <<<"$cmd" | awk '{print $2}' || true)
doc=$(grep -oE -- '--policy-document +file://[^ ]+' <<<"$cmd" \
  | sed 's|.*file://||' || true)
[ -n "$role" ] && [ -n "$pname" ] && [ -f "$doc" ] ||
  deny "--role-name, --policy-name と file:// のポリシーを指定してください"
expect="$dir/../iam-expect/${role}.json"
[ -f "$expect" ] ||
  deny "期待表 .claude/iam-expect/${role}.json がありません"
if report=$("$dir/iam-sim.sh" "$role" "$doc" "$pname" "$expect" 2>&1); then
  exit 0
else
  deny "simulate-principal-policyが期待表と一致しません:
$report"
fi

引数の取り出しに失敗したら通さず、file://形式を強制している点がポイントです。ポリシーをコマンドラインに直書きされると、どの文書を検証したのかがトランスクリプトに残りにくくなります。ファイル経由なら、検証した文書と適用した文書が同一になります。

.claude/settings.jsonには、フックと権限ルールを並べて置きます。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(aws iam put-role-policy *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/iam-sim-hook.sh",
            "onFailure": "block",
            "timeout": 60
          }
        ]
      }
    ]
  },
  "permissions": {
    "ask": ["Bash(aws iam put-role-policy *)"]
  }
}

拒否したときにClaudeが受け取る内容は、たとえば次のような文面です。

simulate-principal-policyが期待表と一致しません:
ok ecr:BatchGetImage arn:aws:ecr:...:repository/app allowed
ok s3:DeleteBucket arn:aws:s3:::prod-data implicitDeny
MISMATCH iam:CreateAccessKey *: want=explicitDeny got=allowed

PreToolUseのpermissionDecisionReasonは、denyのときにClaudeへ届きます。Claudeは「iam:CreateAccessKeyを拒否するステートメントが消えた」と読めるので、ポリシーを直して再実行できます。

フックが止められないものと、onFailureの意味

関門が崩れる経路を、先に潰します。

  • フック自体の失敗。スクリプトのパスが間違っていたり、jqが無かったりすると、フックは動きません。onFailureを設定しないと、これは非ブロックのエラー扱いで、put-role-policyはそのまま実行されます。"onFailure": "block"を指定すると、起動失敗やタイムアウトでも呼び出しを止められます(この指定はClaude Code v2.1.295以降です)。詳しくはonFailureで失敗時に操作を止める設定にあります
  • タイムアウト。シミュレーションは期待表の行数だけAPIを呼ぶので、行が増えると遅くなります。PreToolUseでタイムアウトしたフックは、onFailureが無ければ呼び出しを止めません。timeoutは余裕を見て設定します
  • ifは絞り込みの補助。公式の説明でも、ifのフィルターはベストエフォートで、確実な許可・拒否にはフックでなく権限システムを使うよう書かれています。コマンドの中身を判別できないときは、パターンに関係なくフックが走ります。権限ルールを外側に置くと、関門が崩れにくくなります
  • 通過しても自動承認にはならない。公式の説明では、フックがallowを返してもaskルールとdenyルールは評価されます。上の設定では、フックを通ったあともaskルールによる確認が入ります。シミュレーションを通ったことは、人間が承認する材料の1つになります

もう1つ、フックを素通りする書き込み経路があります。put-user-policy、put-group-policy、attach-role-policy、create-policy-version、Terraformのapplyは、このフックの対象外です。同じ関門を通したいなら、コマンドごとにifを追加します。それが難しければ、最小権限化の記事のように書き込み系のコマンドを丸ごと拒否し、適用は人間が行う運用が堅実です。

判定が割れたときの読み方

不一致のログを見たとき、次の順に原因を切り分けます。

  1. allowedが期待でimplicitDenyが出た: 必要なAllowが足りません。操作名のスペルや、リソースARNの範囲(repository/appとrepository/app/*の違いなど)を見ます
  2. implicitDenyが期待でallowedが出た: Allowが広すぎます。Action: "s3:*"のようなワイルドカード、または別の紐づきポリシーが原因です。ロール全体を評価するので、置き換え対象以外のポリシーも疑います
  3. explicitDenyが期待でallowedが出た: Denyステートメントが無くなったか、条件が緩くなりました。ガードレールの回帰として扱います
  4. 判定は合っているがMISSING-CONTEXT: ポリシーが参照する条件キーの値を渡していません。GetContextKeysForPrincipalPolicyで必要なキーの一覧を取れます

結果を見る側のセキュリティにも触れておきます。AWSの説明では、この操作は他のユーザーに付与された権限の情報を開示します。利用者に他人の権限を見せたくないなら、SimulateCustomPolicyを使わせる選択肢が示されています。フックが使う認証情報は、シミュレーションとsts get-caller-identityができる範囲に留めます。

CLAUDE.mdに残す、変更前の決まり

関門を設けても、Claudeが毎回手探りで期待表を作り直すのは非効率です。手順をCLAUDE.mdに書いておきます。

## IAMポリシーの変更
- 変更は policies/<role>.json に書き、aws iam put-role-policy は
  --policy-document file:// で渡す
- 期待表 .claude/iam-expect/<role>.json は人間が承認したものを使う。
  勝手に書き換えない
- 期待表と判定が一致しないとフックが拒否する。ポリシー側を直す
- explicitDeny を期待している行は、ガードレールなので外さない

この規約はClaudeの振る舞いを導くだけで、強制するのは権限ルールとフックです。「期待表を勝手に書き換えない」は、Edit(.claude/iam-expect/**)をaskにしておけば、確認を挟めます。

まとめ

変更前の検証でいちばん価値が出るのは、allowedの行よりも、implicitDenyとexplicitDenyの行です。権限が「付いていない」ことと、「Denyで守られている」ことを、変更のたびに機械が確かめてくれます。シミュレーターはRCPやVPCエンドポイントポリシーを見ないため、通ったあとの実環境での確認は残ります。

削除前にプレビューで結果を見るECRのライフサイクルポリシーも、「適用前に結果を再現する」点で同じ型です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. code.claude.com/docs/en/permissions
  3. docs.aws.amazon.com/cli/latest/reference/iam/simulate-principal-policy.html
  4. docs.aws.amazon.com/cli/latest/reference/iam/put-role-policy.html
  5. docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_testing-policies.html
この記事を共有:XはてブLinkedIn