Quatro tools agora ficam atrás de um único AgentCore Gateway, em vez de serem conectadas uma função Python de cada vez a cada agente: cloudwatch-read, logs-read e cost-read assumem uma role somente leitura numa conta spoke e nunca conseguem alterar nada, e ssm-execute, a única tool mutante da plataforma, também não consegue alcançar um spoke diretamente, ela só consegue iniciar uma execução do Step Functions que pausa esperando a aprovação de um humano no Slack. Essa pausa não é um capricho de interface. É o único lugar em toda a plataforma onde uma credencial AWS capaz de alterar algo numa conta spoke é gerada, e ela só é gerada depois que uma pessoa clica em aprovar.

A Parte 1 estabeleceu o cenário e escolheu AgentCore mais Strands. A Parte 2 construiu a fronteira de conta: as roles de spoke ops-readonly e ops-mutate que esta parte assume que existem e reutiliza sem modificação. A Parte 3 entregou o primeiro agente com duas tools conectadas diretamente, cloudwatch_read e logs_read como funções Python simples decoradas com @tool que o agente de triagem chamava em processo, e disse abertamente que o Gateway teria sido indireção sem nada ainda para rotear entre si. Esta parte é onde essa indireção começa a valer a pena: uma segunda tool de leitura (cost-read) e a primeira tool mutante da plataforma (ssm-execute) existem agora, e quatro tools que cada agente pode chamar de forma independente é exatamente o número em que um plano de tools compartilhado e com escopo centralizado deixa de ser prematuro. O código complementar vive em github.com/flightlesstux/agents-on-call, em terraform/20-gateway-tools/ e agents/tools/, e todo trecho abaixo é extraído diretamente desses arquivos, não simplificado para o post.

Por que tools inline param de escalar no segundo agente

As duas tools da Parte 3 viviam dentro de agents/triage/agent.py: funções simples, cada uma com uma chamada de assume-role entre contas, chamadas diretamente pelo único agente que precisava delas. Nada de errado nisso com um agente. O problema começa no segundo: o agente de custo da Parte 5 precisa de cloudwatch-read e cost-read, o agente de runbook precisa de cloudwatch-read, logs-read e eventualmente ssm-execute. Conectado em processo do jeito que a Parte 3 fez, isso é a mesma função Python colada em três codebases de agentes, três roles IAM concedendo independentemente a mesma permissão sts:AssumeRole, e três lugares para corrigir um bug em como a sessão entre contas é construída, um erro de digitação no tratamento do external ID de uma cópia que ninguém percebe até as chamadas daquele agente começarem a falhar de um jeito que as outras duas não reproduzem.

O AgentCore Gateway remove o copy-paste, não tornando as tools mais inteligentes, mas fazendo com que existam exatamente uma vez. Um Lambda por tool, uma execution role por Lambda, um Gateway roteando as chamadas de tool de todo agente para o alvo certo. A própria role IAM de um agente não precisa mais de sts:AssumeRole para nenhum spoke (a role de runtime de triagem da Parte 3 ainda tem essa permissão, já que aquele agente é anterior a esta parte); um agente futuro só precisa de permissão para invocar o endpoint MCP do Gateway, e toda credencial de leitura entre contas que a plataforma gera vem de exatamente quatro execution roles de Lambda que este módulo possui, não de quantos agentes existirem até o fim da série.

AgentCore Gateway como o plano de tools MCP

O AgentCore Gateway converte APIs, funções Lambda e serviços existentes em tools compatíveis com MCP desde a disponibilidade geral do AgentCore em 13 de outubro de 2025, com autorização baseada em IAM disponível já naquele GA. O próprio MCP, o protocolo que todo target do Gateway fala, é o padrão aberto que a Anthropic publicou em novembro de 2024 para conectar aplicações de IA a fontes de dados e de tools; a contribuição do Gateway é transformar quatro funções Lambda separadas num único servidor MCP do qual a biblioteca cliente de um agente consegue descobrir tools, em vez do código do agente precisar do ARN e do formato de invocação de cada Lambda diretamente. O preço segue o resto da forma do AgentCore: $0,005 por 1.000 invocações de API (ListTools, InvokeTool, Ping) e $0,02 por 100 tools indexadas por mês, pequeno o bastante para que quatro tools não custem nada perto do custo de tokens de uma única invocação de modelo.

O recurso do Gateway em si é curto. authorizer_type = "AWS_IAM" significa que a mesma fronteira de IAM em que toda esta série se apoiou decide quem pode sequer chamar o Gateway, sem precisar erguer um emissor JWT separado para uma plataforma que já tem um sistema de identidade:

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" })
}

Esse bloco policy_engine_configuration é a peça mais nova de Terraform desta série até agora, vale conferir a data. A avaliação de policy baseada em Cedar para o Gateway chegou ao provider aws como um recurso somente de listagem em 27 de maio de 2026, depois ganhou esse bloco de escrita em 10 de junho, versão 6.50.0 do provider, oito dias antes da data desta própria parte. Seguro sob a regra de backdating da série, mas só por pouco, e ainda não existe nenhum recurso Terraform para criar o próprio Cedar policy engine, apenas para anexar um que já exista. var.cedar_policy_engine_arn tem null como padrão e o bloco é envolvido num dynamic, o mesmo padrão que a Parte 2 usou para o acesso a modelos do Bedrock: uma capacidade real da AWS que o Terraform consegue referenciar mas ainda não consegue provisionar por completo.

Um Lambda, um trabalho: o padrão de tool

Toda tool segue a mesma forma: uma função Lambda, uma execution role com escopo em exatamente o que aquela tool precisa, e um target do Gateway descrevendo o schema de entrada da tool para que o cliente MCP de um agente consiga chamá-la sem ler o código-fonte do Lambda. cloudwatch-read é o exemplo mais claro, porque é a mesma lógica que a Parte 3 já entregou, apenas movida. A execution role confia apenas no serviço Lambda e concede apenas duas coisas: assumir ops-readonly nos spokes configurados, e escrever nos próprios 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
  ...
}

Nenhum resource com wildcard em lugar nenhum desse policy document: local.ops_readonly_role_arns constrói um ARN por ID de conta spoke configurada, e nada mais que essa role possa tocar existe fora dessa lista mais o próprio log group. logs-read e cost-read reutilizam documentos idênticos de trust e de permissão; só o código do handler e o schema de tool visível ao Gateway diferem. O handler em si é a lógica da Parte 3 quase inalterada, lendo seus argumentos de um event fornecido pelo Gateway em vez de argumentos nomeados de Python:

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)

O target do Gateway é o que transforma esse Lambda em algo que o cliente MCP de um agente consegue descobrir: um nome, uma descrição, e um contrato de entrada no formato JSON-Schema. O bloco completo chega a sete parâmetros; a forma abaixo é a mesma para toda tool:

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 {} } é o detalhe que vale a pena parar para observar: ele diz ao Gateway para invocar o Lambda-alvo usando a própria execution role do Gateway via IAM puro, não um fluxo OAuth nem uma API key retirada de um credential provider. Essa é a escolha certa para um Lambda na mesma conta, na mesma plataforma; o role_arn do Gateway recebe lambda:InvokeFunction em exatamente esses quatro ARNs de Lambda e nada mais, a mesma disciplina de um-propósito-uma-permissão de toda role desta série até aqui.

Somente leitura não é um slogan, são três fatos distintos de IAM

"Somente leitura por padrão" só significa alguma coisa se sobreviver ao contato com um bug. Esta fiação sobrevive a três específicos. Raio de impacto: as três roles de leitura não têm nenhuma permissão capaz de alterar estado num spoke, apenas sts:AssumeRole para ops-readonly, cuja policy (Parte 2) sobrepõe um deny explícito às suas managed policies de somente leitura, então uma prompt injection que convença um agente a pedir a uma tool de leitura para apagar algo não tem para onde ir. Auditoria: toda leitura ainda cruza uma fronteira de conta via sts:AssumeRole, então o CloudTrail na conta de segurança vê exatamente qual Lambda de tool tocou qual spoke e quando. Confiança com o time humano: um engenheiro de plantão que vê ssm-execute atrás de um portão de aprovação, e três tools chamadas de read cujas roles não têm nenhuma ação mutante em lugar nenhum, tem um motivo para acreditar na alegação de que "isso só lê" em vez de aceitar por fé, verificável em cinco minutos com aws iam get-role-policy, não uma frase num system prompt que alguém precisa confiar que o modelo seguiu.

A única tool mutante, e o portão atrás dela

ssm-execute é a única tool da plataforma cuja role IAM não inclui nenhuma permissão sts:AssumeRole para um spoke. Todo o seu conjunto de permissões é states:StartExecution, com escopo em exatamente um ARN de state machine:

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

Chamar ssm-execute não executa nada, propõe executar algo. O handler checa o documento proposto contra uma allowlist local antes mesmo de criar uma execução do Step Functions, uma checagem redundante e fail-fast em cima da checagem real, a própria IAM policy da ops-mutate vinda da Parte 2, que é o que de fato segura as coisas se o código deste Lambda tiver um bug:

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,
    }),
)

É na state machine que .waitForTaskToken justifica o nome. O próprio tutorial do Step Functions sobre esse padrão é explícito que um Task state pode pausar indefinidamente, gerar um token, e retomar só quando algo fora da state machine chama SendTaskSuccess ou SendTaskFailure com aquele token exato, e que tanto o wait state quanto a execução inteira precisam do próprio TimeoutSeconds, senão uma execução travada nunca termina. NotifySlackAndWaitForApproval invoca slack-post com o task token no payload, e então é o próprio Step Functions que pausa, não o Lambda:

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"
        }
      ]
    }
    ...
  }
})

Dois states seguem, não mostrados acima: RunApprovedAutomation invoca executor.py com a própria saída do wait state como payload, e ApprovalDeniedOrTimedOut é um simples state Fail para o qual o Catch acima direciona. slack-post posta um card com o diagnóstico, o raio de impacto, o documento SSM exato e os parâmetros, mais links de aprovar/negar carregando o task token, cada um apontando para uma rota do API Gateway (/succeed, /fail) apoiada por approval_callback.py. O trabalho inteiro desse Lambda é uma chamada de API, send_task_success ou send_task_failure contra o token do link clicado, e sua policy IAM concede as duas em Resource = "*" por um motivo real: nenhuma das duas APIs endereça uma execução específica por ARN, só o token opaco, a mesma forma que a statement InspectAndStopOwnExecutions da Parte 2 encontrou para as próprias APIs de inspeção do SSM. Vale nomear com clareza: codificar esse token na query string de um link GET é uma fraqueza real, um link encaminhado ou logado é uma aprovação encaminhada ou logada, documentado em slack_post.py em vez de escondido, a ser substituído por um token assinado, de uso único e apoiado em datastore antes que isso rode contra qualquer coisa que importe. Essas rotas não carregam nenhum authorizer: a posse do token é todo o controle de acesso, então qualquer um que obtenha o link, não só o aprovador pretendido no Slack, consegue dispará-lo.

Só na aprovação é que algo com permissão para alterar uma conta spoke é criado. executor.py é o único Lambda cuja execution role consegue assumir ops-mutate: a trust policy da Parte 2 nomeia exatamente o ARN da role deste Lambda como seu único principal, e a vida útil de sua própria credencial é deliberadamente curta:

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()},
)

Cinco minutos, mais curto que o padrão de quinze minutos das tools de leitura: essa credencial existe para fazer exatamente uma chamada de API, não para ficar por aí reutilizável para um follow-up que a função nunca pretendeu. Dois Lambdas, duas roles IAM, um handoff estreito através de uma execução de state machine, é isso que torna "a única tool mutante não consegue alcançar um spoke diretamente" uma propriedade da fiação, não uma promessa que este código cumpre apenas por optar por não importar o client STS do boto3.

O que o agente vê quando uma tool é negada

Existem três tipos distintos de "não" aqui, que vale a pena diferenciar. Uma rejeição em nível de aplicação: a checagem de allowlist do ssm-execute retorna {"status": "rejected", "reason": ...} como um resultado normal de tool, sem exceção, sem execução criada; o agente reporta isso claramente. Uma negação em nível de IAM: uma permissão lambda:InvokeFunction ou de assume-role faltando faz a chamada falhar com um AccessDeniedException da AWS que surge como um erro de tool, não um resultado de tool, a distinção de parada dura que o system prompt da Parte 3 já treinou o agente de triagem a respeitar. E, uma vez que cedar_policy_engine_arn esteja configurado com mode = "ENFORCE", uma negação em nível de policy: o Cedar rejeita uma chamada antes mesmo dela chegar ao Lambda, com base em regras fora do código de qualquer tool individual; em LOG_ONLY a mesma chamada é logada mas passa mesmo assim, que é por isso que testar uma nova policy ali contra tráfego real importa antes de virar para ENFORCE, onde uma regra excessivamente ampla faz falhar chamadas que nunca foram um problema, sem nenhum log em nível de tool para explicar o motivo.

Modos de falha a observar

Cinco coisas que vale a pena saber antes deste pipeline rodar contra uma proposta real. As duas allowlists de documento SSM, a cópia local do ssm-execute e a própria policy IAM da ops-mutate, vivem em dois módulos Terraform aplicados de forma independente (este e o 00-foundation), e nada garante que permaneçam idênticas; um documento faltando na lista local é rejeitado antes de existir um pedido de aprovação, um documento faltando na lista de IAM gera um pedido criado para algo que a ops-mutate depois se recusa a rodar, e o segundo caso é pior porque um humano já clicou em aprovar antes de descobrir isso. Esquecer qualquer um dos valores de TimeoutSeconds, o do wait state ou o da execução, transforma uma proposta do Slack sem resposta numa execução pausada que envelhece para sempre em vez de falhar alto para ApprovalDeniedOrTimedOut, exatamente o que o tutorial de aprovação humana do Step Functions aponta como o jeito mais comum desse padrão quebrar. E a própria fraqueza do link de aprovação permanece até ser substituída por um token assinado, de uso único e apoiado em datastore: trate todo canal do Slack para o qual isso posta como um canal onde encaminhar uma mensagem equivale a encaminhar uma aprovação. Uma requisição GET que altera estado também convida aprovação acidental: o próprio unfurler de links do Slack, scanners corporativos de segurança de links, e o prefetch do navegador podem todos buscar essa URL antes de um humano sequer clicar nela, e nenhum caminho de código aqui distingue essa busca de uma aprovação deliberada. E approval_callback.py passa o JSON da proposta direto da query string do link clicado para send_task_success sem checá-lo contra o que o Step Functions de fato registrou para aquele token, então um link adulterado pode trocar por um ssm_document_arn ou ssm_parameters diferente do que o humano viu no Slack; a própria allowlist de IAM da ops-mutate limita o dano a algo já permitido, mas a combinação específica de documento e parâmetros que um humano aprovou não é a mesma que está conectada ao que de fato roda.

Leia isso a seguir

O módulo terraform/20-gateway-tools/ completo e o agents/tools/, incluindo os documentos de policy IAM e os três outros targets do Gateway cortados dos trechos acima, vivem no repositório complementar em github.com/flightlesstux/agents-on-call. Para o lado de banco de dados e infraestrutura de rodar plataformas como esta em escala, as notas de campo estão em ercan.cloud, e o hub fica em ercanermis.com.