今や4つのツールが、エージェントごとに1つずつPython関数を配線する代わりに、1つのAgentCore Gatewayの背後に並んでいる。cloudwatch-read、logs-read、cost-readはスポークアカウントでread-onlyロールを引き受け、何かを変更することは決してできない。プラットフォーム唯一の変更系ツールであるssm-executeも、スポークに直接到達することはできず、人間によるSlack承認を待って一時停止するStep Functionsの実行を開始できるだけだ。その一時停止は単なるUI上の気配りではない。スポークアカウントで何かを変更できるAWS認証情報がプラットフォーム全体でただ1か所だけ発行される場所であり、人が承認をクリックした後にしか発行されない。

第1回は舞台設定を行い、AgentCoreとStrandsを選んだ。第2回はアカウント境界を構築した。この回が前提とし、変更を加えずに再利用するops-readonlyops-mutateのスポークロールだ。第3回は最初のエージェントを、2つのツールを直接配線した状態で出荷した。トリアージエージェントがインプロセスで呼び出す、素の@toolデコレータ付きPython関数としてのcloudwatch_readとlogs_readだ。そしてGatewayは、まだ何もルーティング対象がない間接層に過ぎなかっただろうと率直に述べていた。この回はその間接層が働きに見合うようになる回だ。2つ目のreadツール(cost-read)とプラットフォーム初の変更系ツール(ssm-execute)がいずれも今存在しており、各エージェントが個別に呼び出しうる4つのツールというのは、共有され中央でスコープされたツールプレーンが時期尚早でなくなる、まさにその数だ。コンパニオンコードはgithub.com/flightlesstux/agents-on-callterraform/20-gateway-tools/agents/tools/にあり、以下のスニペットはすべてそれらのファイルからの抜粋であり、投稿用に簡略化したものではない。

なぜインラインツールはエージェント2体目でスケールしなくなるのか

第3回の2つのツールはagents/triage/agent.pyの中に存在していた。素の関数で、それぞれがクロスアカウントのassume-role呼び出しを1回行い、それを必要とする唯一のエージェントから直接呼び出されていた。エージェントが1体のうちは何も問題はない。トラブルは2体目から始まる。第5回のcostエージェントはcloudwatch-readとcost-readを必要とし、runbookエージェントはcloudwatch-read、logs-read、そして最終的にはssm-executeを必要とする。第3回のやり方でインプロセスに配線すると、それは同じPython関数が3つのエージェントコードベースに貼り付けられ、3つのIAMロールがそれぞれ独立して同じsts:AssumeRole権限を付与し、クロスアカウントセッションの構築方法にあるバグを直す場所が3か所になり、あるコピーのexternal ID処理にあるタイプミスは、そのエージェントの呼び出しだけが他の2つとは違う形で失敗し始めるまで誰も気づかない、ということを意味する。

AgentCore Gatewayはこのコピー&ペーストを取り除く。ツールを賢くすることによってではなく、ツールをちょうど1つずつしか存在しないようにすることによってだ。ツールごとに1つのLambda、Lambdaごとに1つの実行ロール、そしてすべてのエージェントのツール呼び出しを正しいターゲットにルーティングする1つのGateway。エージェント自身のIAMロールはもはやどのスポークへのsts:AssumeRoleも一切必要としない(第3回のトリアージランタイムロールは、そのエージェントがこの回より前に作られたものであるため、今もそれを持っている)。将来のエージェントに必要なのはGatewayのMCPエンドポイントを呼び出す権限だけであり、プラットフォームが発行するクロスアカウントのreadクレデンシャルはすべて、このモジュールが所有するちょうど4つのLambda実行ロールから来る。シリーズが終わる頃にエージェントが何体存在していようと関係ない。

MCPツールプレーンとしてのAgentCore Gateway

AgentCore Gatewayは、2025年10月13日のAgentCore一般提供開始以来、API、Lambda関数、既存サービスをMCP互換のツールに変換してきた。IAMベースの認可も同じGA時点から利用可能だった。すべてのGatewayターゲットが話すプロトコルであるMCP自体は、Anthropicが2024年11月に公開した、AIアプリケーションをデータソースやツールソースに接続するためのオープンスタンダードだ。Gatewayの貢献は、エージェントのコードが各LambdaのARNと呼び出し形式を直接知っている必要がある代わりに、4つの独立したLambda関数を、エージェントのクライアントライブラリがツールを発見できる1つのMCPサーバーに変えることにある。料金はAgentCoreの他の部分と同じ形をしている。API呼び出し(ListTools、InvokeTool、Ping)1,000件あたり$0.005、インデックスされたツール100個あたり月額$0.02で、モデル呼び出し1回分のトークンコストと比べれば、4つのツールにかかる費用は無に等しいほど小さい。

Gatewayリソース自体は短い。authorizer_type = "AWS_IAM"は、このシリーズ全体が拠り所にしてきたのと同じIAM境界が、そもそも誰がGatewayを呼び出せるかを決めるということを意味する。すでにアイデンティティシステムを持つプラットフォームのために、別途JWT発行者を立てる必要はない:

resource "aws_bedrockagentcore_gateway" "tools" {
  name            = "${var.platform_name}-tools"
  description     = "MCP tool plane: cloudwatch-read, logs-read, cost-read, ssm-execute. AWS_IAM authorizer, IAM does the access control both for who can call the Gateway and what each tool Lambda can touch."
  role_arn        = aws_iam_role.gateway.arn
  protocol_type   = "MCP"
  authorizer_type = "AWS_IAM"

  # Optional Cedar policy layer, see variables.tf's cedar_policy_engine_arn
  # comment for why the engine itself is a variable, not a resource, at this
  # part's date.
  dynamic "policy_engine_configuration" {
    for_each = var.cedar_policy_engine_arn == null ? [] : [var.cedar_policy_engine_arn]
    content {
      arn  = policy_engine_configuration.value
      mode = var.cedar_policy_engine_mode
    }
  }

  tags = merge(var.tags, { Component = "gateway" })
}

そのpolicy_engine_configurationブロックは、このシリーズでここまでで最も新しいTerraformの断片であり、日付を確認する価値がある。Gateway向けのCedarベースのポリシー評価は、2026年5月27日にawsプロバイダにlist専用リソースとして出荷され、その後6月10日、この回自体の日付の8日前にあたるプロバイダバージョン6.50.0で、この書き込み経路のブロックが追加された。シリーズの遡及ルールの範囲内ではあるが、ぎりぎりだ。そして今もなお、Cedarポリシーエンジン自体を作成するTerraformリソースは存在せず、既に存在するものにアタッチするリソースしかない。var.cedar_policy_engine_arnはデフォルトでnullであり、このブロックは第2回がBedrockのモデルアクセスで使ったのと同じパターンであるdynamicで包まれている。Terraformが参照はできるがまだ完全にはプロビジョニングできない、実在するAWSの機能だ。

1つのLambda、1つの仕事:ツールのパターン

すべてのツールは同じ形をしている。Lambda関数、そのツールがまさに必要とするものだけにスコープされた実行ロール、そしてエージェントのMCPクライアントがLambdaのソースを読まなくても呼び出せるよう、ツールの入力スキーマを記述するGatewayターゲットだ。cloudwatch-readが最も分かりやすい例なのは、第3回がすでに出荷した同じロジックを、ただ移しただけだからだ。実行ロールはLambdaサービスのみを信頼し、2つのことだけを許可する。設定されたスポークでops-readonlyを引き受けることと、自身のCloudWatch Logsに書き込むことだ:

data "aws_iam_policy_document" "cloudwatch_read_permissions" {
  statement {
    sid       = "AssumeOpsReadonlyInSpokes"
    effect    = "Allow"
    actions   = ["sts:AssumeRole"]
    resources = local.ops_readonly_role_arns
  }

  statement {
    sid    = "WriteOwnLogs"
    effect = "Allow"
    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]
    resources = [local.lambda_log_arn]
  }
}

resource "aws_lambda_function" "cloudwatch_read" {
  function_name    = "${var.platform_name}-cloudwatch-read"
  description      = "AgentCore Gateway MCP tool: reads a CloudWatch metric from a spoke account via ops-readonly. Read-only."
  role             = aws_iam_role.cloudwatch_read.arn
  handler          = "cloudwatch_read.handler"
  runtime          = "python3.12"
  timeout          = 30
  memory_size      = 256
  filename         = data.archive_file.cloudwatch_read_zip.output_path
  source_code_hash = data.archive_file.cloudwatch_read_zip.output_base64sha256
  ...
}

そのポリシードキュメントのどこにもワイルドカードのリソースはない。local.ops_readonly_role_arnsは設定されたスポークアカウントIDごとに1つのARNを構築し、このロールが触れられるものはそのリストと自身のロググループの外には何も存在しない。logs-readとcost-readは同一の信頼ポリシーと権限ドキュメントを再利用する。異なるのはハンドラのコードとGatewayに見えるツールスキーマだけだ。ハンドラ自体は第3回のロジックとほぼ変わらず、Pythonのキーワード引数の代わりにGatewayが渡すイベントから引数を読み取る:

def handler(event: dict, context) -> dict:
    """Lambda entrypoint for the cloudwatch-read Gateway target.
    ...
    """
    spoke_account_id = event["spoke_account_id"]
    namespace = event["namespace"]
    metric_name = event["metric_name"]
    dimensions = event.get("dimensions", {})
    lookback_minutes = int(event.get("lookback_minutes", 60))
    stat = event.get("stat", "Average")
    period_seconds = int(event.get("period_seconds", 60))

    session = assume_role(spoke_account_id, OPS_READONLY_ROLE_NAME, "cloudwatch-read")
    cloudwatch = session.client("cloudwatch", config=_boto_config)

Gatewayターゲットこそが、そのLambdaをエージェントのMCPクライアントが発見できるものに変えている。名前、説明、そしてJSON Schema形式の入力契約だ。完全なブロックは7つのパラメータに及ぶ。以下の形はどのツールでも同じだ:

resource "aws_bedrockagentcore_gateway_target" "cloudwatch_read" {
  gateway_identifier = aws_bedrockagentcore_gateway.tools.gateway_id
  name               = "cloudwatch-read"
  description        = "Reads a CloudWatch metric's recent datapoints from a spoke account."

  credential_provider_configuration {
    gateway_iam_role {}
  }

  target_configuration {
    mcp {
      lambda {
        lambda_arn = aws_lambda_function.cloudwatch_read.arn

        tool_schema {
          inline_payload {
            name        = "cloudwatch_read"
            description = "Read a CloudWatch metric's recent datapoints from a spoke account. An empty datapoint list is a real finding, not an error."

            input_schema {
              type = "object"

              property {
                name        = "spoke_account_id"
                type        = "string"
                description = "12-digit account ID of the spoke to query."
                required    = true
              }
              ...
            }
          }
        }
      }
    }
  }
}

credential_provider_configuration { gateway_iam_role {} }は立ち止まる価値のある細部だ。これはGatewayに対し、OAuthフローやクレデンシャルプロバイダから取得したAPIキーではなく、素のIAM経由でGateway自身の実行ロールを使ってターゲットLambdaを呼び出すよう指示している。同一アカウント、同一プラットフォーム内のLambdaにとってはそれが正しい選択だ。Gatewayのrole_arnには、まさにこの4つのLambda ARNに対するlambda:InvokeFunctionだけが付与されており、それ以外は何もない。ここまでのシリーズのすべてのロールと同じ、1つの目的に1つの権限という規律だ。

read-onlyはスローガンではなく、3つの独立したIAMの事実だ

「デフォルトでread-only」は、バグに遭遇しても崩れない場合にのみ意味を持つ。この配線は3つの具体的なバグに耐える。爆発半径(blast radius):3つのreadロールはスポークで状態を変更できる権限を一切持たず、ops-readonlyへのsts:AssumeRoleしか持たない。そのポリシー(第2回)はread-onlyのマネージドポリシーの上に明示的なdenyを重ねているため、プロンプトインジェクションがエージェントにreadツールへ何かを削除するよう頼ませても、行き着く先がない。監査:すべてのreadはsts:AssumeRoleを通じて依然としてアカウント境界を越えるため、セキュリティアカウントのCloudTrailは、どのツールLambdaがどのスポークにいつ触れたかを正確に確認できる。人間のチームとの信頼:承認ゲートの背後にあるssm-execute、そしてロールがどこにも変更系アクションを持たない、readという名の3つのツールを見たオンコールエンジニアには、「これはreadしかしない」という主張を信頼で受け入れるのではなく、信じるだけの理由がある。aws iam get-role-policyで5分もあれば確認でき、モデルが従ったことを誰かが信頼するしかないシステムプロンプト中の一文ではない。

唯一の変更系ツールと、その背後にあるゲート

ssm-executeは、プラットフォーム上でIAMロールがスポークへのsts:AssumeRole権限を一切含まない唯一のツールだ。その権限セットの全体はstates:StartExecutionであり、ちょうど1つのステートマシンARNにスコープされている:

data "aws_iam_policy_document" "ssm_execute_permissions" {
  statement {
    sid       = "StartApprovalStateMachineOnly"
    effect    = "Allow"
    actions   = ["states:StartExecution"]
    resources = [aws_sfn_state_machine.mutation_approval.arn]
  }
  ...
}

ssm-executeを呼び出しても何かが実行されるわけではない。何かの実行を提案するだけだ。ハンドラはStep Functionsの実行を作成する前にすら、提案されたドキュメントをローカルの許可リストと照合する。これは冗長でfail-fastなチェックであり、実際に効いているのは第2回のops-mutate自身のIAMポリシーだ。このLambdaのコードにバグがあった場合に実際に踏みとどまるのはそちらだ:

if ssm_document_arn not in ALLOWED_SSM_DOCUMENT_ARNS:
    return {
        "status": "rejected",
        "reason": f"{ssm_document_arn} is not on the allowlist; no approval request was created.",
    }

execution = _sfn.start_execution(
    stateMachineArn=APPROVAL_STATE_MACHINE_ARN,
    input=json.dumps({
        "spoke_account_id": spoke_account_id,
        "ssm_document_arn": ssm_document_arn,
        "ssm_parameters": ssm_parameters,
        "diagnosis": diagnosis,
        "blast_radius": blast_radius,
    }),
)

ステートマシンこそが.waitForTaskTokenという名前に値する場所だ。Step Functions自身のこのパターンに関するチュートリアルは、Taskステートが無期限に一時停止し、トークンを生成し、ステートマシンの外にある何かがそのトークンぴったりでSendTaskSuccessSendTaskFailureを呼び出したときにのみ再開すること、そして待機ステートと実行全体の両方がそれぞれ独自のTimeoutSecondsを必要とし、そうでなければ詰まった実行が永久に終わらないことを明言している。NotifySlackAndWaitForApprovalはタスクトークンをペイロードに含めてslack-postを呼び出し、その後一時停止するのはLambdaではなくStep Functions自体だ:

definition = jsonencode({
  Comment        = "Human approval gate for ops-mutate SSM Automation execution."
  StartAt        = "NotifySlackAndWaitForApproval"
  TimeoutSeconds = var.state_machine_timeout_seconds
  States = {
    NotifySlackAndWaitForApproval = {
      Type     = "Task"
      Resource = "arn:aws:states:::lambda:invoke.waitForTaskToken"
      Parameters = {
        FunctionName = aws_lambda_function.slack_post.arn
        Payload = {
          "TaskToken.$"      = "$$.Task.Token"
          "Diagnosis.$"      = "$.diagnosis"
          "BlastRadius.$"    = "$.blast_radius"
          "SsmDocument.$"    = "$.ssm_document_arn"
          "SsmParameters.$"  = "$.ssm_parameters"
          "SpokeAccountId.$" = "$.spoke_account_id"
        }
      }
      TimeoutSeconds = var.approval_wait_timeout_seconds
      Next           = "RunApprovedAutomation"
      Catch = [
        {
          ErrorEquals = ["States.ALL"]
          Next        = "ApprovalDeniedOrTimedOut"
        }
      ]
    }
    ...
  }
})

上には示していないが、この後に2つのステートが続く。RunApprovedAutomationは待機ステート自身の出力をペイロードとしてexecutor.pyを呼び出し、ApprovalDeniedOrTimedOutは上のCatchがルーティングする、ただのFailステートだ。slack-postは診断結果、爆発半径、正確なSSMドキュメント、パラメータを載せたカードを投稿し、タスクトークンを運ぶ承認/拒否リンクを添える。それぞれがapproval_callback.pyに支えられたAPI Gatewayルート(/succeed/fail)を指している。そのLambdaの仕事全体は1回のAPI呼び出し、クリックされたリンクのトークンに対するsend_task_successまたはsend_task_failureであり、そのIAMポリシーは両方をResource = "*"に対して許可している。これには正当な理由がある。どちらのAPIもARNで特定の実行を指定するのではなく、不透明なトークンだけを指定する。これは第2回のInspectAndStopOwnExecutionsステートメントが、SSM自身のinspection APIに対して直面したのと同じ形だ。率直に述べておく価値がある。そのトークンをGETリンクのクエリ文字列にエンコードすることは現実の弱点であり、転送またはログに記録されたリンクは、転送またはログに記録された承認を意味する。これは隠されているのではなくslack_post.pyに文書化されており、何か重要なものに対してこれが動く前に、署名付きで使い捨て、データストアに裏付けられたトークンに置き換えられる予定だ。これらのルートには認可(authorizer)が一切ない。トークンを保持していることが唯一のアクセス制御であり、意図されたSlackの承認者だけでなく、そのリンクを入手した誰でもそれを発火できる。

承認された場合にのみ、スポークアカウントを変更する権限を持つものが作られる。executor.pyは、実行ロールがops-mutateを引き受けられる唯一のLambdaだ。第2回の信頼ポリシーは、まさにこのLambdaのロールARNだけを唯一のプリンシパルとして名指ししており、その認証情報の有効期間は意図的に短く設定されている:

session = assume_role(
    spoke_account_id,
    OPS_MUTATE_ROLE_NAME,
    "post-approval-executor",
    duration_seconds=300,
)
ssm = session.client("ssm", config=_boto_config)

response = ssm.start_automation_execution(
    DocumentName=ssm_document_arn,
    Parameters={k: [v] if not isinstance(v, list) else v for k, v in ssm_parameters.items()},
)

5分間、readツールのデフォルトである15分より短い。この認証情報はちょうど1回のAPI呼び出しを行うために存在するのであって、この関数が意図していないフォローアップのために使い回せるまま居座らせるためではない。2つのLambda、2つのIAMロール、ステートマシンの実行を介した1つの狭いハンドオフ。それが「唯一の変更系ツールはスポークに直接到達できない」ということを、このコードがboto3のSTSクライアントをあえてimportしないという選択によって守られる約束ではなく、配線そのものの性質にしている。

ツールが拒否されたときエージェントが目にするもの

ここには3種類の異なる「ノー」が存在し、区別する価値がある。アプリケーションレベルの拒否:ssm-executeの許可リストチェックは、例外もなく実行の作成もない、通常のツール結果として{"status": "rejected", "reason": ...}を返す。エージェントはそれをそのまま報告する。IAMレベルの拒否:lambda:InvokeFunctionの付与漏れやassume-roleの付与漏れは、ツール結果ではなくツールエラーとして表面化するAWSのAccessDeniedExceptionで呼び出しを失敗させる。これは第3回のシステムプロンプトがすでにトリアージエージェントに尊重するよう訓練済みの、はっきりした区別だ。そして、cedar_policy_engine_arnmode = "ENFORCE"で設定された後は、ポリシー層の拒否も存在する。Cedarは呼び出しがLambdaに到達する前に、どのツール自身のコードの外にあるルールに基づいて呼び出しを拒否する。LOG_ONLYでは同じ呼び出しがログに記録されるだけで通過を許される。だからこそ、ENFORCEに切り替える前に、実際のトラフィックに対して新しいポリシーをそこでステージングすることが重要になる。広すぎるルールは、これまで問題になったことのない呼び出しを失敗させるが、その理由を説明するツールレベルのログは存在しないからだ。

注視すべき失敗モード

このパイプラインを実際の提案に対して動かす前に知っておく価値のあることが5つある。2つのSSMドキュメント許可リスト、ssm-executeのローカルコピーとops-mutate自身のIAMポリシーは、2つの独立してapplyされるTerraformモジュール(このモジュールと00-foundation)に存在しており、両者が同一であり続けることを強制する仕組みは何もない。ローカルリストにないドキュメントは承認リクエストが存在する前に拒否されるが、IAMリストにないドキュメントは、ops-mutateがその後実行を拒否するものに対してリクエストが作成されてしまう。後者の方が悪い。人間がそれを発見する前にすでに承認をクリックしてしまっているからだ。TimeoutSecondsの値、待機ステートのものであれ実行のものであれ、どちらかを設定し忘れると、応答のないSlackの提案は、大きな音を立ててApprovalDeniedOrTimedOutへ失敗する代わりに、永久に年老いていく一時停止中の実行になってしまう。これはまさに、Step Functionsのヒューマン承認チュートリアルがこのパターンの最も一般的な壊れ方として挙げている事態だ。そして承認リンク自体の弱点は、署名付きで使い捨て、データストアに裏付けられたトークンに置き換えられるまで残り続ける。これが投稿されるすべてのSlackチャンネルを、メッセージの転送が承認の転送に等しい場所として扱うべきだ。状態を変更するGETリクエストは偶発的な承認も招く。Slack自身のリンク展開機能、企業のリンクセキュリティスキャナー、ブラウザのプリフェッチはいずれも、人間がクリックする前にそのURLをフェッチしうる。そしてここには、そのフェッチを意図的な承認と区別するコードパスが一切ない。さらにapproval_callback.pyは、クリックされたリンクのクエリ文字列からの提案JSONを、そのトークンに対してStep Functionsが実際に記録した内容と照合することなく、そのままsend_task_successに渡している。そのため改ざんされたリンクは、人間がSlackで見たものとは異なるssm_document_arnssm_parametersにすり替えることができる。ops-mutate自身のIAM許可リストが被害をすでに許可済みの範囲に限定してはくれるが、人間が承認した特定のドキュメントとパラメータの組み合わせが、実際に実行されるものに配線されているとは限らない。

次に読む

上記のスニペットから省いたIAMポリシードキュメントや、他の3つのGatewayターゲットも含む完全なterraform/20-gateway-tools/モジュールとagents/tools/は、コンパニオンリポジトリgithub.com/flightlesstux/agents-on-callにある。この種のプラットフォームを大規模に運用する際のデータベースとインフラ側の話はercan.cloudのフィールドノートで、ハブはercanermis.comにある。