Quattro tool ora si trovano dietro un unico AgentCore Gateway invece di essere collegati una funzione Python alla volta in ogni agente: cloudwatch-read, logs-read e cost-read assumono un ruolo in sola lettura in un account spoke e non possono mai modificare nulla, e ssm-execute, l'unico tool mutante della piattaforma, non può nemmeno raggiungere direttamente uno spoke, può solo avviare un'esecuzione di Step Functions che si mette in pausa in attesa dell'approvazione umana su Slack. Quella pausa non è una finezza dell'interfaccia. È l'unico punto dell'intera piattaforma in cui viene generata una credenziale AWS capace di modificare qualcosa in un account spoke, e viene generata solo dopo che una persona clicca approva.

La Parte 1 ha impostato lo scenario e scelto AgentCore più Strands. La Parte 2 ha costruito il confine di account: i ruoli spoke ops-readonly e ops-mutate che questa parte assume esistano e riutilizza senza modifiche. La Parte 3 ha distribuito il primo agente con due tool collegati direttamente, cloudwatch_read e logs_read come semplici funzioni Python decorate con @tool che l'agente di triage chiamava in-process, e ha dichiarato apertamente che Gateway sarebbe stato indirection senza ancora nulla tra cui instradare. Questa parte è dove quella indirection si guadagna il suo posto: un secondo tool in lettura (cost-read) e il primo tool mutante della piattaforma (ssm-execute) esistono ora entrambi, e quattro tool che ogni agente potrebbe chiamare indipendentemente sono esattamente il numero in cui un piano di tool condiviso e con scoping centralizzato smette di essere prematuro. Il codice di accompagnamento vive su github.com/flightlesstux/agents-on-call, in terraform/20-gateway-tools/ e agents/tools/, e ogni snippet qui sotto è estratto da quei file, non semplificato per il post.

Perché i tool inline smettono di scalare al secondo agente

I due tool della Parte 3 vivevano dentro agents/triage/agent.py: semplici funzioni, ciascuna con una chiamata assume-role cross-account, chiamate direttamente dall'unico agente che ne aveva bisogno. Niente di sbagliato con un solo agente. Il problema inizia al secondo: l'agente cost della Parte 5 ha bisogno di cloudwatch-read e cost-read, il suo agente runbook ha bisogno di cloudwatch-read, logs-read e in seguito di ssm-execute. Collegati in-process come ha fatto la Parte 3, questo significa la stessa funzione Python incollata in tre codebase di agenti, tre ruoli IAM che concedono indipendentemente lo stesso permesso sts:AssumeRole, e tre punti in cui correggere un bug nel modo in cui viene costruita la sessione cross-account, un errore di battitura nella gestione dell'external ID di una copia che nessuno nota finché le chiamate di quell'unico agente non iniziano a fallire in un modo che le altre due non riproducono.

AgentCore Gateway elimina il copia-incolla, non rendendo i tool più intelligenti ma facendoli esistere esattamente una volta sola. Una Lambda per tool, un ruolo di esecuzione per Lambda, un Gateway che instrada le chiamate ai tool di ogni agente verso il target giusto. Il ruolo IAM di un agente non ha più bisogno di sts:AssumeRole verso alcuno spoke (il ruolo runtime di triage della Parte 3 lo ha ancora, poiché quell'agente precede questa parte); un agente futuro ha bisogno solo del permesso di invocare l'endpoint MCP del Gateway, e ogni credenziale di lettura cross-account che la piattaforma genera proviene esattamente da quattro ruoli di esecuzione Lambda posseduti da questo modulo, non da quanti agenti esisteranno alla fine della serie.

AgentCore Gateway come piano dei tool MCP

AgentCore Gateway converte API, funzioni Lambda e servizi esistenti in tool compatibili con MCP fin dalla general availability di AgentCore del 13 ottobre 2025, con l'autorizzazione basata su IAM disponibile fin da quella stessa GA. MCP stesso, il protocollo che ogni target del Gateway parla, è lo standard aperto che Anthropic ha pubblicato nel novembre 2024 per collegare applicazioni AI a fonti di dati e tool; il contributo di Gateway è trasformare quattro funzioni Lambda separate in un unico server MCP da cui la libreria client di un agente può scoprire i tool, invece che il codice dell'agente debba conoscere direttamente l'ARN e la forma di invocazione di ciascuna Lambda. Il pricing segue la forma del resto di AgentCore: 0,005 $ ogni 1.000 invocazioni API (ListTools, InvokeTool, Ping) e 0,02 $ ogni 100 tool indicizzati al mese, abbastanza poco che quattro tool non costano nulla rispetto al costo in token di una singola invocazione del modello.

La risorsa Gateway in sé è breve. authorizer_type = "AWS_IAM" significa che lo stesso confine IAM su cui questa intera serie ha fatto affidamento decide chi può chiamare il Gateway, senza dover mettere in piedi un emettitore JWT separato per una piattaforma che ha già un sistema di identità:

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

Quel blocco policy_engine_configuration è il pezzo di Terraform più recente di tutta la serie finora, vale la pena un controllo delle date. La valutazione delle policy basata su Cedar per Gateway è arrivata nel provider aws come risorsa di sola lettura (list) il 27 maggio 2026, per poi ricevere questo blocco write-path il 10 giugno, provider versione 6.50.0, otto giorni prima della data stessa di questa parte. Sicuro secondo la regola di backdating della serie, ma per un pelo, e non esiste ancora una risorsa Terraform per creare l'engine di policy Cedar stesso, solo per collegarne uno già esistente. var.cedar_policy_engine_arn ha come default null e il blocco è avvolto in un dynamic, lo stesso pattern che la Parte 2 ha usato per l'accesso ai modelli Bedrock: una capacità AWS reale che Terraform può referenziare ma non ancora provisionare completamente.

Una Lambda, un compito: il pattern dei tool

Ogni tool segue la stessa forma: una funzione Lambda, un ruolo di esecuzione con scoping esattamente su ciò di cui quel tool ha bisogno, e un target Gateway che descrive lo schema di input del tool così che il client MCP di un agente possa chiamarlo senza leggere il sorgente della Lambda. cloudwatch-read è l'esempio più chiaro, perché è la stessa logica che la Parte 3 aveva già distribuito, solo spostata. Il ruolo di esecuzione si fida solo del servizio Lambda e concede solo due cose: assumere ops-readonly negli spoke configurati, e scrivere i propri 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
  ...
}

Nessuna resource con wildcard da nessuna parte in quel documento di policy: local.ops_readonly_role_arns costruisce un ARN per ogni account ID spoke configurato, e nient'altro che questo ruolo possa toccare esiste al di fuori di quella lista più il proprio log group. logs-read e cost-read riutilizzano documenti di trust e permessi identici; differiscono solo il codice dell'handler e lo schema del tool visibile al Gateway. L'handler stesso è la logica della Parte 3 quasi immutata, che legge i propri argomenti da un evento fornito dal Gateway invece che da keyword argument 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)

Il target Gateway è ciò che trasforma quella Lambda in qualcosa che il client MCP di un agente può scoprire: un nome, una descrizione e un contratto di input a forma di JSON Schema. Il blocco completo arriva a sette parametri; la forma qui sotto è la stessa per ogni 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 {} } è il dettaglio su cui vale la pena soffermarsi: dice al Gateway di invocare la Lambda target usando il ruolo di esecuzione del Gateway stesso su IAM semplice, non un flusso OAuth o una API key prelevata da un credential provider. È la scelta giusta per una Lambda dello stesso account, della stessa piattaforma; il role_arn del Gateway ha il permesso lambda:InvokeFunction esattamente su questi quattro ARN Lambda e nient'altro, la stessa disciplina un-permesso-uno-scopo di ogni ruolo visto finora in questa serie.

Sola lettura non è uno slogan, sono tre fatti IAM separati

"Sola lettura di default" significa qualcosa solo se sopravvive al contatto con un bug. Questo cablaggio ne sopravvive a tre specifici. Raggio d'impatto: i tre ruoli di lettura non detengono alcun permesso che possa cambiare lo stato in uno spoke, solo sts:AssumeRole verso ops-readonly, la cui policy (Parte 2) sovrappone un deny esplicito alle sue managed policy di sola lettura, quindi un prompt injection che convince un agente a chiedere a un tool di lettura di cancellare qualcosa non ha dove andare. Audit: ogni lettura attraversa comunque un confine di account tramite sts:AssumeRole, quindi CloudTrail nell'account di sicurezza vede esattamente quale Lambda tool ha toccato quale spoke e quando. Fiducia con il team umano: un ingegnere on-call che vede ssm-execute dietro un gate di approvazione, e tre tool chiamati read i cui ruoli non detengono azioni mutanti da nessuna parte, ha un motivo per credere all'affermazione "legge soltanto" invece di prenderla per fede, verificabile in cinque minuti con aws iam get-role-policy, non una frase in un system prompt di cui qualcuno deve fidarsi che il modello l'abbia rispettata.

L'unico tool mutante, e il gate che c'è dietro

ssm-execute è l'unico tool della piattaforma il cui ruolo IAM non include alcun permesso sts:AssumeRole verso uno spoke. L'intero set di permessi è states:StartExecution, con scoping su esattamente un ARN di 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]
  }
  ...
}

Chiamare ssm-execute non esegue nulla, propone di eseguire qualcosa. L'handler controlla il documento proposto rispetto a un'allowlist locale prima ancora di creare un'esecuzione di Step Functions, un controllo ridondante e fail-fast sopra a quello reale, la policy IAM di ops-mutate stessa dalla Parte 2, che è ciò che effettivamente tiene se il codice di questa Lambda ha un 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,
    }),
)

La state machine è dove .waitForTaskToken si guadagna il nome. Il tutorial ufficiale di Step Functions su questo pattern è esplicito nel dire che uno stato Task può mettersi in pausa indefinitamente, generare un token, e riprendere solo quando qualcosa al di fuori della state machine chiama SendTaskSuccess o SendTaskFailure con quell'esatto token, e che sia lo stato di attesa sia l'intera esecuzione hanno bisogno del proprio TimeoutSeconds, altrimenti un'esecuzione bloccata non finisce mai. NotifySlackAndWaitForApproval invoca slack-post con il task token nel proprio payload, poi è Step Functions stesso a mettersi in pausa, non la 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"
        }
      ]
    }
    ...
  }
})

Seguono due stati non mostrati sopra: RunApprovedAutomation invoca executor.py con l'output dello stato di attesa come proprio payload, e ApprovalDeniedOrTimedOut è un semplice stato Fail verso cui instrada il Catch sopra. slack-post pubblica una card con la diagnosi, il raggio d'impatto, il documento SSM esatto e i parametri, più link di approvazione/rifiuto che portano il task token, ciascuno puntando a una route API Gateway (/succeed, /fail) supportata da approval_callback.py. L'intero compito di quella Lambda è una chiamata API, send_task_success o send_task_failure contro il token del link cliccato, e la sua policy IAM concede entrambe su Resource = "*" per un motivo reale: nessuna delle due API indirizza una specifica esecuzione tramite ARN, solo il token opaco, la stessa forma che ha incontrato l'istruzione InspectAndStopOwnExecutions della Parte 2 per le stesse API di inspection di SSM. Vale la pena dirlo chiaramente: codificare quel token nella query string di un link GET è una debolezza reale, un link inoltrato o loggato è un'approvazione inoltrata o loggata, documentato in slack_post.py invece che nascosto, da sostituire con un token firmato, monouso, sostenuto da un datastore prima che questo giri contro qualcosa che conta davvero. Queste route non portano alcun authorizer: il possesso del token è l'intero controllo d'accesso, quindi chiunque ottenga il link, non solo l'approvatore Slack previsto, può attivarlo.

Solo in caso di approvazione viene creato qualcosa con il permesso di modificare un account spoke. executor.py è l'unica Lambda il cui ruolo di esecuzione può assumere ops-mutate: la trust policy della Parte 2 nomina esattamente l'ARN del ruolo di questa Lambda come suo unico principal, e la durata della propria credenziale è deliberatamente breve:

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

Cinque minuti, meno dei quindici minuti di default dei tool di lettura: questa credenziale esiste per fare esattamente una chiamata API, non per restare in giro riutilizzabile per un follow-up che la funzione non ha mai previsto. Due Lambda, due ruoli IAM, un unico passaggio di consegne stretto attraverso l'esecuzione di una state machine, è ciò che rende "l'unico tool mutante non può raggiungere direttamente uno spoke" una proprietà del cablaggio, non una promessa che questo codice mantiene semplicemente scegliendo di non importare il client STS di boto3.

Cosa vede l'agente quando un tool viene negato

Qui esistono tre tipi distinti di "no", che vale la pena distinguere. Un rifiuto a livello applicativo: il controllo dell'allowlist di ssm-execute restituisce {"status": "rejected", "reason": ...} come normale risultato del tool, nessuna eccezione, nessuna esecuzione creata; l'agente lo riporta semplicemente. Un diniego a livello IAM: un permesso lambda:InvokeFunction mancante o un permesso assume-role mancante fa fallire la chiamata con un AccessDeniedException di AWS che emerge come errore del tool, non come risultato del tool, la distinzione hard-stop che il system prompt della Parte 3 aveva già addestrato l'agente di triage a rispettare. E, una volta che cedar_policy_engine_arn è impostato con mode = "ENFORCE", un diniego a livello di policy: Cedar rifiuta una chiamata prima ancora che raggiunga la Lambda, su regole esterne al codice di un singolo tool; in LOG_ONLY la stessa chiamata viene loggata ma lasciata passare, motivo per cui mettere in staging una nuova policy lì contro traffico reale conta prima di passare a ENFORCE, dove una regola troppo ampia fa fallire chiamate che non erano mai state un problema senza alcun log a livello di tool a spiegare il perché.

Modalità di fallimento da tenere d'occhio

Cinque cose che vale la pena sapere prima che questa pipeline giri contro una proposta reale. Le due allowlist dei documenti SSM, la copia locale di ssm-execute e la policy IAM propria di ops-mutate, vivono in due moduli Terraform applicati indipendentemente (questo e 00-foundation), e nulla impone che restino identiche; un documento mancante dalla lista locale viene rifiutato prima che esista una richiesta di approvazione, un documento mancante dalla lista IAM porta alla creazione di una richiesta per qualcosa che ops-mutate poi si rifiuta di eseguire, e il secondo caso è peggiore perché un umano ha già cliccato approva prima di scoprirlo. Dimenticare uno dei due valori TimeoutSeconds, quello dello stato di attesa o quello dell'esecuzione, trasforma una proposta Slack senza risposta in un'esecuzione in pausa che invecchia per sempre invece di fallire rumorosamente in ApprovalDeniedOrTimedOut, esattamente ciò che il tutorial di Step Functions sull'approvazione umana segnala come il modo più comune in cui questo pattern si rompe. E la debolezza propria del link di approvazione resta finché non viene sostituito con un token firmato, monouso, sostenuto da un datastore: trattare ogni canale Slack su cui questo pubblica come uno in cui inoltrare un messaggio equivale a inoltrare un'approvazione. Anche una richiesta GET che modifica lo stato invita all'approvazione accidentale: l'unfurler dei link di Slack, gli scanner di sicurezza dei link aziendali e il prefetch del browser possono tutti recuperare quell'URL prima che un umano lo clicchi mai, e nessun percorso di codice qui distingue quel fetch da un'approvazione deliberata. E approval_callback.py passa il JSON della proposta direttamente dalla query string del link cliccato a send_task_success senza controllarlo rispetto a ciò che Step Functions ha effettivamente registrato per quel token, quindi un link manomesso può scambiare un ssm_document_arn o degli ssm_parameters diversi da quelli che l'umano ha visto su Slack; l'allowlist IAM propria di ops-mutate limita il danno a qualcosa già consentito, ma la specifica combinazione documento-e-parametri che un umano ha approvato non è quella collegata a ciò che effettivamente viene eseguito.

Leggi questo dopo

Il modulo completo terraform/20-gateway-tools/ e agents/tools/, inclusi i documenti di policy IAM e gli altri tre target Gateway tagliati dagli snippet sopra, vivono nel repository di accompagnamento su github.com/flightlesstux/agents-on-call. Per il lato database e infrastruttura di come far girare piattaforme come questa su scala, gli appunti tecnici sono su ercan.cloud, e l'hub è su ercanermis.com.