Quatre outils se trouvent désormais derrière une seule AgentCore Gateway au lieu d'être câblés une fonction Python à la fois dans chaque agent : cloudwatch-read, logs-read et cost-read endossent un rôle en lecture seule dans un compte spoke et ne peuvent jamais rien muter, et ssm-execute, l'unique outil mutant de la plateforme, ne peut lui non plus atteindre un spoke directement, il ne peut que démarrer une exécution Step Functions qui se met en pause en attendant l'approbation Slack d'un humain. Cette pause n'est pas un raffinement d'interface. C'est le seul endroit de toute la plateforme où un identifiant AWS capable de changer quelque chose dans un compte spoke est émis, et il n'est émis qu'après qu'une personne a cliqué sur approuver.

La Partie 1 a planté le décor et choisi AgentCore plus Strands. La Partie 2 a construit la frontière de compte : les rôles spoke ops-readonly et ops-mutate que cette partie suppose déjà existants et réutilise sans modification. La Partie 3 a livré le premier agent avec deux outils câblés directement, cloudwatch_read et logs_read comme de simples fonctions Python décorées @tool que l'agent de triage appelait en processus, et a dit sans détour que la Gateway n'aurait été qu'une indirection sans encore rien vers quoi router. Cette partie est celle où cette indirection prouve son utilité : un second outil en lecture (cost-read) et le premier outil mutant de la plateforme (ssm-execute) existent tous deux désormais, et quatre outils que chaque agent pourrait appeler indépendamment est exactement le nombre à partir duquel un plan d'outils partagé et scopé de façon centralisée cesse d'être prématuré. Le code compagnon vit à github.com/flightlesstux/agents-on-call, dans terraform/20-gateway-tools/ et agents/tools/, et chaque extrait ci-dessous est tiré de ces fichiers, pas simplifié pour l'article.

Pourquoi les outils inline cessent de tenir la charge au deuxième agent

Les deux outils de la Partie 3 vivaient dans agents/triage/agent.py : de simples fonctions, chacune avec un appel assume-role cross-account, appelées directement par le seul agent qui en avait besoin. Rien de problématique à un seul agent. Les ennuis commencent au deuxième : l'agent de coûts de la Partie 5 a besoin de cloudwatch-read et cost-read, son agent runbook a besoin de cloudwatch-read, logs-read, et éventuellement ssm-execute. Câblé en processus comme l'a fait la Partie 3, cela revient à la même fonction Python collée dans trois bases de code d'agents, trois rôles IAM accordant indépendamment la même permission sts:AssumeRole, et trois endroits où corriger un bug dans la façon dont la session cross-account est construite, une faute de frappe dans la gestion de l'external ID d'une copie que personne ne remarque avant que les appels de cet agent-là ne commencent à échouer d'une façon que les deux autres ne reproduisent pas.

AgentCore Gateway supprime le copier-coller, non pas en rendant les outils plus intelligents mais en les faisant exister exactement une fois. Une Lambda par outil, un rôle d'exécution par Lambda, une Gateway qui route les appels d'outils de chaque agent vers la bonne cible. Le rôle IAM propre à un agent n'a plus du tout besoin de sts:AssumeRole vers un spoke (le rôle runtime de triage de la Partie 3 le conserve encore, puisque cet agent est antérieur à cette partie) ; un futur agent n'a besoin que de la permission d'invoquer l'endpoint MCP de la Gateway, et chaque identifiant de lecture cross-account que la plateforme émet provient d'exactement quatre rôles d'exécution Lambda que ce module possède, et non du nombre d'agents qui existeront au moment où la série se terminera.

AgentCore Gateway comme plan d'outils MCP

AgentCore Gateway convertit des API, des fonctions Lambda et des services existants en outils compatibles MCP depuis la disponibilité générale d'AgentCore le 13 octobre 2025, avec une autorisation basée sur IAM disponible dès cette même GA. MCP en lui-même, le protocole que parle chaque cible de Gateway, est le standard ouvert qu'Anthropic a publié en novembre 2024 pour connecter des applications d'IA à des sources de données et d'outils ; la contribution de la Gateway est de transformer quatre fonctions Lambda séparées en un seul serveur MCP dont la bibliothèque cliente d'un agent peut découvrir les outils, au lieu que le code de l'agent ait besoin de connaître directement l'ARN et la forme d'invocation de chaque Lambda. La tarification suit le reste de la logique d'AgentCore : 0,005 $ par 1 000 invocations d'API (ListTools, InvokeTool, Ping) et 0,02 $ par 100 outils indexés par mois, assez faible pour que quatre outils ne coûtent rien comparé au coût en tokens d'une seule invocation de modèle.

La ressource Gateway elle-même est courte. authorizer_type = "AWS_IAM" signifie que c'est la même frontière IAM sur laquelle toute cette série s'est appuyée qui décide qui peut appeler la Gateway, sans avoir besoin de monter un émetteur JWT séparé pour une plateforme qui possède déjà un système d'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" })
}

Ce bloc policy_engine_configuration est la pièce de Terraform la plus récente de cette série jusqu'ici, ce qui mérite une vérification de date. L'évaluation de politique basée sur Cedar pour Gateway est apparue dans le provider aws comme ressource en lecture seule (liste) le 27 mai 2026, puis a reçu ce bloc côté écriture le 10 juin, version 6.50.0 du provider, huit jours avant la date propre de cette partie. Conforme à la règle d'antidatage de la série, mais de justesse, et il n'existe toujours aucune ressource Terraform pour créer le moteur de politique Cedar lui-même, seulement pour en attacher un qui existe déjà. var.cedar_policy_engine_arn vaut null par défaut et le bloc est enveloppé dans un dynamic, le même schéma que la Partie 2 a utilisé pour l'accès aux modèles Bedrock : une véritable capacité AWS que Terraform peut référencer mais pas encore provisionner entièrement.

Une Lambda, une tâche : le modèle d'outil

Chaque outil suit la même forme : une fonction Lambda, un rôle d'exécution scopé exactement à ce dont cet outil a besoin, et une cible de Gateway décrivant le schéma d'entrée de l'outil afin que le client MCP d'un agent puisse l'appeler sans lire le code source de la Lambda. cloudwatch-read est l'exemple le plus clair, car c'est la même logique que la Partie 3 avait déjà livrée, simplement déplacée. Le rôle d'exécution ne fait confiance qu'au service Lambda et n'accorde que deux choses : endosser ops-readonly dans les spokes configurés, et écrire ses propres 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
  ...
}

Aucune ressource en wildcard nulle part dans ce document de politique : local.ops_readonly_role_arns construit un ARN par ID de compte spoke configuré, et rien d'autre que ce rôle puisse toucher n'existe en dehors de cette liste plus son propre groupe de logs. logs-read et cost-read réutilisent des documents de confiance et de permissions identiques ; seuls leur code de handler et leur schéma d'outil visible par la Gateway diffèrent. Le handler lui-même reprend presque sans changement la logique de la Partie 3, en lisant ses arguments depuis un événement fourni par la Gateway au lieu d'arguments nommés 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)

La cible de Gateway est ce qui transforme cette Lambda en quelque chose que le client MCP d'un agent peut découvrir : un nom, une description, et un contrat d'entrée en forme de JSON-Schema. Le bloc complet compte sept paramètres ; la forme ci-dessous est identique pour chaque outil :

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 {} } est le détail sur lequel il vaut la peine de s'arrêter : il indique à la Gateway d'invoquer la Lambda cible en utilisant le propre rôle d'exécution de la Gateway via IAM simple, pas un flux OAuth ni une clé API tirée d'un fournisseur d'identifiants. C'est le bon choix pour une Lambda du même compte, de la même plateforme ; le role_arn de la Gateway reçoit lambda:InvokeFunction sur exactement ces quatre ARN de Lambda et rien d'autre, la même discipline d'un-but-une-permission que chaque rôle de cette série jusqu'ici.

Lecture seule n'est pas un slogan, ce sont trois faits IAM distincts

« Lecture seule par défaut » ne veut dire quelque chose que si cela survit au contact d'un bug. Ce câblage en survit à trois précis. Rayon d'impact : les trois rôles de lecture ne détiennent aucune permission capable de changer l'état d'un spoke, seulement sts:AssumeRole vers ops-readonly, dont la politique (Partie 2) superpose un deny explicite au-dessus de ses politiques managées en lecture seule, si bien qu'une injection de prompt qui convainc un agent de demander à un outil de lecture de supprimer quelque chose n'a nulle part où aller. Audit : chaque lecture continue de traverser une frontière de compte via sts:AssumeRole, donc CloudTrail dans le compte sécurité voit exactement quelle Lambda outil a touché quel spoke et quand. Confiance avec l'équipe humaine : un ingénieur d'astreinte qui voit ssm-execute derrière une porte d'approbation, et trois outils nommés read dont les rôles ne détiennent nulle part d'action mutante, a une raison de croire l'affirmation « ça ne fait que lire » plutôt que de la prendre sur parole, vérifiable en cinq minutes avec aws iam get-role-policy, pas une phrase dans un system prompt dont il faudrait faire confiance que le modèle l'a suivie.

L'unique outil mutant, et la porte qui le verrouille

ssm-execute est le seul outil de la plateforme dont le rôle IAM n'inclut aucune permission sts:AssumeRole vers un spoke. Son ensemble complet de permissions se résume à states:StartExecution, scopé à exactement un 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]
  }
  ...
}

Appeler ssm-execute n'exécute rien, cela propose d'exécuter quelque chose. Le handler vérifie le document proposé contre une liste blanche locale avant même de créer une exécution Step Functions, un contrôle redondant et fail-fast au-dessus du vrai contrôle, la propre politique IAM d'ops-mutate issue de la Partie 2, qui est ce qui tient réellement si le code de cette Lambda a 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,
    }),
)

C'est dans la state machine que .waitForTaskToken mérite son nom. Le tutoriel de Step Functions sur ce schéma est explicite : un état Task peut se mettre en pause indéfiniment, générer un token, et ne reprendre que lorsque quelque chose en dehors de la state machine appelle SendTaskSuccess ou SendTaskFailure avec ce token exact, et à la fois l'état d'attente et l'exécution entière ont besoin de leur propre TimeoutSeconds, sans quoi une exécution bloquée ne se termine jamais. NotifySlackAndWaitForApproval invoque slack-post avec le task token dans son payload, puis c'est Step Functions lui-même qui se met en pause, pas 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"
        }
      ]
    }
    ...
  }
})

Deux états suivent, non montrés ci-dessus : RunApprovedAutomation invoque executor.py avec la sortie propre de l'état d'attente comme payload, et ApprovalDeniedOrTimedOut est un simple état Fail vers lequel le Catch ci-dessus route. slack-post publie une carte avec le diagnostic, le rayon d'impact, le document SSM exact et ses paramètres, plus des liens approuver/refuser portant le task token, chacun pointant vers une route API Gateway (/succeed, /fail) adossée à approval_callback.py. Toute la tâche de cette Lambda est un seul appel API, send_task_success ou send_task_failure contre le token du lien cliqué, et sa politique IAM accorde les deux sur Resource = "*" pour une vraie raison : aucune des deux API n'adresse une exécution spécifique par ARN, seulement le token opaque, la même forme que l'instruction InspectAndStopOwnExecutions de la Partie 2 a rencontrée pour les propres API d'inspection de SSM. Il vaut la peine de le dire sans détour : encoder ce token dans la query string d'un lien GET est une vraie faiblesse, un lien transféré ou journalisé est une approbation transférée ou journalisée, documenté dans slack_post.py plutôt que caché, à remplacer par un token signé, à usage unique, adossé à un datastore, avant que cela ne tourne contre quoi que ce soit d'important. Ces routes ne portent aucun autorizer : la possession du token est tout le contrôle d'accès, donc quiconque obtient le lien, pas seulement l'approbateur Slack visé, peut le déclencher.

Ce n'est qu'après approbation que quelque chose ayant la permission de changer un compte spoke est créé. executor.py est la seule Lambda dont le rôle d'exécution peut endosser ops-mutate : la politique de confiance de la Partie 2 nomme exactement l'ARN du rôle de cette Lambda comme unique principal, et la durée de vie de son propre identifiant est délibérément courte :

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

Cinq minutes, plus court que le défaut de quinze minutes des outils de lecture : cet identifiant existe pour effectuer exactement un appel API, pas pour traîner réutilisable pour un suivi que la fonction n'a jamais prévu. Deux Lambdas, deux rôles IAM, un handoff étroit à travers une exécution de state machine, est ce qui fait de « l'unique outil mutant ne peut pas atteindre un spoke directement » une propriété du câblage, et non une promesse que ce code tient en choisissant simplement de ne pas importer le client STS de boto3.

Ce que voit l'agent quand un outil est refusé

Trois sortes distinctes de « non » existent ici, et il vaut la peine de les distinguer. Un refus au niveau applicatif : le contrôle de liste blanche de ssm-execute retourne {"status": "rejected", "reason": ...} comme un résultat d'outil normal, aucune exception, aucune exécution créée ; l'agent le rapporte simplement. Un déni au niveau IAM : une autorisation lambda:InvokeFunction manquante ou une autorisation d'assume-role manquante fait échouer l'appel avec une AccessDeniedException AWS remontée comme une erreur d'outil, pas comme un résultat d'outil, la distinction d'arrêt net que le system prompt de la Partie 3 avait déjà entraîné l'agent de triage à respecter. Et, une fois cedar_policy_engine_arn configuré avec mode = "ENFORCE", un déni au niveau politique : Cedar rejette un appel avant même qu'il n'atteigne la Lambda, sur des règles extérieures au code propre de n'importe quel outil ; en LOG_ONLY, le même appel est journalisé mais laissé passer, ce qui explique pourquoi mettre en scène une nouvelle politique dans ce mode contre du trafic réel compte avant de basculer en ENFORCE, où une règle trop large fait échouer des appels qui n'ont jamais posé problème, sans aucun log au niveau outil pour expliquer pourquoi.

Modes de défaillance à surveiller

Cinq choses valent la peine d'être connues avant que ce pipeline ne tourne contre une vraie proposition. Les deux listes blanches de documents SSM, la copie locale de ssm-execute et la propre politique IAM d'ops-mutate, vivent dans deux modules Terraform appliqués indépendamment (celui-ci et 00-foundation), et rien ne force leur identité ; un document absent de la liste locale est rejeté avant qu'une demande d'approbation n'existe, un document absent de la liste IAM voit une demande créée pour quelque chose qu'ops-mutate refuse ensuite d'exécuter, et le second cas est pire car un humain a déjà cliqué sur approuver avant de le découvrir. Oublier l'une ou l'autre valeur de TimeoutSeconds, celle de l'état d'attente ou celle de l'exécution, transforme une proposition Slack restée sans réponse en une exécution en pause qui vieillit indéfiniment au lieu d'échouer bruyamment vers ApprovalDeniedOrTimedOut, exactement ce que le tutoriel d'approbation humaine de Step Functions signale comme la façon la plus courante dont ce schéma casse. Et la faiblesse propre au lien d'approbation tient jusqu'à ce qu'il soit remplacé par un token signé, à usage unique, adossé à un datastore : traiter chaque canal Slack où ceci publie comme un canal où transférer un message équivaut à transférer une approbation. Une requête GET qui mute l'état invite aussi à l'approbation accidentelle : le désenrouleur de liens propre à Slack, les scanners de sécurité de liens d'entreprise, et le préchargement du navigateur peuvent tous récupérer cette URL avant qu'un humain ne clique jamais dessus, et aucun chemin de code ici ne distingue cette récupération d'une approbation délibérée. Et approval_callback.py transmet le JSON de la proposition directement depuis la query string du lien cliqué à send_task_success sans le vérifier contre ce que Step Functions a réellement enregistré pour ce token, si bien qu'un lien altéré peut substituer un ssm_document_arn ou des ssm_parameters différents de ce que l'humain a vu dans Slack ; la propre liste blanche IAM d'ops-mutate borne les dégâts à quelque chose déjà permis, mais la combinaison document-et-paramètres précise qu'un humain a approuvée n'est pas celle câblée à ce qui s'exécute réellement.

À lire ensuite

Le module complet terraform/20-gateway-tools/ et agents/tools/, y compris les documents de politique IAM et les trois autres cibles de Gateway retirées des extraits ci-dessus, vivent dans le dépôt compagnon à github.com/flightlesstux/agents-on-call. Pour le volet base de données et infrastructure de faire tourner des plateformes comme celle-ci à grande échelle, les notes de terrain sont sur ercan.cloud, et le hub se trouve sur ercanermis.com.