.NETC#AI AgentsDevSecOpsGitHub Actions

Implémentez une approbation humaine pour les outils à effet de bord d’un agent C# : boucle de réponse, tests de contrat et garde-fous CI/CD.

Yva Hajatiana
3 septembre 2026
12 min de lecture
Partager :X / TwitterLinkedIn
Composition éditoriale abstraite sur le contrôle humain des automatisations

Valider les outils sensibles d’un agent .NET DevOps

Un agent capable de lire l’état d’un déploiement est utile. Un agent qui peut lancer ce déploiement sans qu’aucune frontière technique ne l’arrête est un risque opérationnel. Une instruction système qui dit « demande toujours une confirmation » ne suffit pas : le modèle peut mal interpréter une demande, être influencé par une donnée non fiable ou appeler un outil dans un chemin inattendu.

Microsoft Agent Framework propose maintenant un mécanisme explicite pour ce cas : ApprovalRequiredAIFunction. Il enveloppe une fonction C# afin que l’appel soit suspendu avant son exécution. L’hôte reçoit alors un ToolApprovalRequestContent, affiche une décision à un humain, puis renvoie une réponse d’approbation ou de refus au même agent et à la même session.

Ce tutoriel construit un assistant de livraison volontairement limité. Il peut proposer un déploiement vers staging, mais la fonction n’est exécutée qu’après une approbation tracée. Nous ajouterons un test qui vérifie le contrat des outils et une CI qui ne possède pas de droit de production pendant les validations.

Statut à connaître. Les API d’approbation de Microsoft.Extensions.AI restent signalées comme expérimentales dans la documentation des packages 10.9.0. Épinglez les versions, validez les mises à jour dans un environnement isolé et ne masquez pas globalement les diagnostics MEAI001.

Ce que l’approbation protège — et ce qu’elle ne protège pas

Le mécanisme bloque une invocation de fonction tant que le programme appelant n’a pas envoyé une décision. C’est une frontière bien plus solide qu’un prompt, car la fonction n’est pas appelée pendant l’attente. Il ne décide cependant pas qui est autorisé à répondre « oui », ni si la cible demandée est acceptable.

ResponsabilitéÀ faire dans l’exempleÀ ne pas déléguer au modèle
Proposer une actionL’agent peut appeler l’outil avec un environnement et une versionDéterminer seul si l’action est permise
Présenter la décisionL’application affiche nom et arguments structurés de l’appelAfficher un texte de confirmation rédigé par le modèle
AutoriserUn réviseur authentifié produit une décision courte et expiranteFaire confiance à un booléen envoyé par le navigateur
ExécuterUne identité de workload limitée appelle le système de déploiementRéutiliser les droits larges de la CI ou du réviseur

L’approbation doit donc s’ajouter aux contrôles usuels : authentification du réviseur, autorisation côté service, environnement protégé, moindre privilège et journalisation. Elle ne les remplace pas.

Prérequis

Prévoyez :

  • le SDK .NET 10 ou une version plus récente ;
  • un projet Microsoft Foundry avec un déploiement de modèle de conversation ;
  • Azure CLI connectée au bon tenant pour le test local ;
  • des droits de lecture sur le projet Foundry, puis une identité managée dédiée à l’exécution en production ;
  • un dépôt GitHub si vous mettez en place la CI ci-dessous.

L’exemple utilise DefaultAzureCredential uniquement pour raccourcir l’exécution locale. En production, préférez une ManagedIdentityCredential explicitement configurée : elle évite les essais de plusieurs sources d’identifiants et limite les permissions accordées à l’agent.

Créez une console et restaurez des versions verrouillées :

dotnet new console --framework net10.0 --name ApprovedReleaseAgent
Set-Location ApprovedReleaseAgent

dotnet add package Azure.AI.Projects --prerelease
dotnet add package Azure.Identity
dotnet add package Microsoft.Agents.AI --prerelease
dotnet add package Microsoft.Extensions.AI --prerelease
dotnet restore --use-lock-file

Les packages évoluant rapidement, remplacez ensuite --prerelease par les numéros précis testés dans votre organisation et commitez packages.lock.json. La documentation actuelle de l’API référence Microsoft.Extensions.AI et Microsoft.Extensions.AI.Abstractions 10.9.0 ; n’assemblez pas arbitrairement des versions incompatibles du framework d’agents et de ces abstractions.

Configurez seulement les valeurs non secrètes nécessaires à l’exemple :

$env:FOUNDRY_PROJECT_ENDPOINT = "https://<votre-projet>.services.ai.azure.com/api/projects/<projet>"
$env:FOUNDRY_MODEL_DEPLOYMENT = "<nom-du-deploiement>"

Ne mettez ni clé d’API, ni secret de plateforme de déploiement dans le prompt, le code source ou ces variables. L’outil de démonstration ci-dessous ne déploie rien ; il rend une demande lisible afin de concentrer le test sur le protocole d’approbation.

1. Marquer précisément l’outil à risque

Une fonction de lecture, comme GetBuildStatus, peut en général être proposée directement à l’agent. Une fonction qui change un environnement doit être enveloppée. Conservez les descriptions courtes et factuelles : elles deviennent le contrat envoyé au modèle.

Remplacez Program.cs par ce code :

using System.ComponentModel;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
    ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT est requis.");

string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL_DEPLOYMENT")
    ?? throw new InvalidOperationException("FOUNDRY_MODEL_DEPLOYMENT est requis.");

[Description("Retourne le dernier état connu d’un service sans modifier son environnement.")]
static string GetBuildStatus(
    [Description("Nom du service à consulter.")] string service)
    => $"Le service {service} est prêt pour une analyse manuelle.";

[Description("Crée une demande de déploiement vers staging. Cette opération doit être approuvée avant exécution.")]
static string DeployToStaging(
    [Description("Nom du service à déployer.")] string service,
    [Description("Version immuable à déployer.")] string version)
    => $"Demande de déploiement enregistrée : {service}, version {version}, environnement staging.";

AIFunction readOnlyStatus = AIFunctionFactory.Create(GetBuildStatus);
AIFunction protectedDeployment = new ApprovalRequiredAIFunction(
    AIFunctionFactory.Create(DeployToStaging));

AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
    .AsAIAgent(
        model: model,
        name: "approved-release-agent",
        instructions: """
            Tu aides l’équipe de livraison. Tu peux consulter un statut ou proposer
            un déploiement vers staging. N’invente jamais une version ou un service.
            Une demande de déploiement doit toujours être révisée par l’application.
            """,
        tools: [readOnlyStatus, protectedDeployment]);

AgentSession session = await agent.CreateSessionAsync();
AgentResponse response = await agent.RunAsync(
    "Déploie catalog-api en version 2026.09.03.4 vers staging.", session);

List<ToolApprovalRequestContent> requests = response.Messages
    .SelectMany(message => message.Contents)
    .OfType<ToolApprovalRequestContent>()
    .ToList();

Console.WriteLine($"Demandes à approuver : {requests.Count}");

ApprovalRequiredAIFunction ne rend pas la fonction moins puissante : il modifie la façon dont le client l’invoque. Quand le modèle demande DeployToStaging, l’agent répond avec un contenu d’approbation au lieu d’exécuter immédiatement le délégué C#. Le service de déploiement réel reste donc hors d’atteinte tant que l’hôte n’a pas repris explicitement le flux.

Pour tester, exécutez dotnet run. Une sortie attendue contient une demande d’approbation. Un modèle peut aussi répondre en demandant un détail plutôt qu’en choisissant un outil : adaptez le message et vérifiez que le nom et la version existent bien avant de passer à l’étape suivante.

2. Afficher les arguments, décider, puis reprendre la même session

L’interface d’approbation doit afficher l’appel structuré, pas une phrase produite par le modèle. Le nom de fonction et ses arguments sont portés par FunctionCallContent. Dans une application web, envoyez ces champs vers une page protégée, puis stockez la décision avec l’identité du réviseur et une date d’expiration.

Ajoutez le bloc suivant après le code précédent pour simuler un refus ou un accord dans un terminal :

foreach (ToolApprovalRequestContent request in requests)
{
    if (request.ToolCall is not FunctionCallContent functionCall)
    {
        throw new InvalidOperationException("Type d’outil inattendu.");
    }

    Console.WriteLine($"Outil : {functionCall.Name}");
    Console.WriteLine($"Arguments : {functionCall.Arguments}");
    Console.Write("Approuver cette demande ? (oui/non) : ");

    bool approved = string.Equals(
        Console.ReadLine()?.Trim(), "oui", StringComparison.OrdinalIgnoreCase);

    ChatMessage approval = new(
        ChatRole.User,
        [request.CreateResponse(approved)]);

    AgentResponse continuation = await agent.RunAsync(approval, session);
    Console.WriteLine(continuation);
}

Le même objet session est indispensable. Il relie la réponse à la demande d’outil initiale. Créer une nouvelle session, reconstruire un appel à partir d’un texte ou renvoyer une approbation d’un autre utilisateur casse ce lien et peut produire un comportement imprévisible.

En production, ne bouclez pas automatiquement sur les réponses. L’interface doit placer la demande dans une file durable, demander une authentification récente au réviseur et expirer la décision. Le rejet est une décision terminale : ne réessayez pas l’appel en espérant que le modèle reformule sa demande.

3. Valider côté application avant d’autoriser

Une approbation « oui » n’est valide que pour l’action exactement examinée. L’exemple suivant représente le contrôle que votre API d’approbation doit effectuer avant de créer request.CreateResponse(true). Il limite l’environnement, interdit les versions ambiguës, exige un réviseur attendu et réduit la durée de vie de la décision.

using System.Text.RegularExpressions;

internal sealed record ReleaseApproval(
    string ReviewerId,
    string Service,
    string Version,
    string Environment,
    DateTimeOffset ExpiresAt);

internal static partial class ReleaseApprovalPolicy
{
    [GeneratedRegex("^[a-z0-9-]+$")]
    private static partial Regex ServiceName();

    [GeneratedRegex("^20\\d{2}\\.\\d{2}\\.\\d{2}\\.\\d+$")]
    private static partial Regex ReleaseVersion();

    public static bool CanApprove(ReleaseApproval approval, DateTimeOffset now) =>
        approval.ReviewerId.StartsWith("oncall-", StringComparison.Ordinal) &&
        approval.Environment == "staging" &&
        ServiceName().IsMatch(approval.Service) &&
        ReleaseVersion().IsMatch(approval.Version) &&
        approval.ExpiresAt > now;
}

Cette politique n’autorise que staging : elle n’est pas un chemin détourné vers la production. Pour cette dernière, ajoutez un flux distinct : environnement GitHub protégé, règle de changement de votre organisation et identité de workload différente. Ne réutilisez pas une approbation de staging après changement de version, de service ou de cible.

4. Tester le contrat des outils sans appeler de modèle

La CI ne doit pas appeler un LLM facturé juste pour prouver qu’une fonction est toujours protégée. Isolez la création des outils dans une classe ReleaseTools, puis vérifiez que toute action à effet de bord est bien enveloppée.

using System.ComponentModel;
using Microsoft.Extensions.AI;

internal static class ReleaseTools
{
    [Description("Crée une demande de déploiement vers staging.")]
    private static string DeployToStaging(string service, string version) =>
        $"{service}:{version}";

    public static IReadOnlyList<AIFunction> Create() =>
    [
        new ApprovalRequiredAIFunction(AIFunctionFactory.Create(DeployToStaging))
    ];
}

Dans un projet xUnit séparé, ce test est déterministe et ne demande aucun secret :

using Microsoft.Extensions.AI;

public sealed class ReleaseToolsTests
{
    [Fact]
    public void Deployment_tool_requires_approval()
    {
        AIFunction deployment = Assert.Single(ReleaseTools.Create());

        Assert.Equal("DeployToStaging", deployment.Name);
        Assert.IsType<ApprovalRequiredAIFunction>(deployment);
    }

    [Theory]
    [InlineData("oncall-alice", "catalog-api", "2026.09.03.4", "staging", true)]
    [InlineData("oncall-alice", "catalog-api", "latest", "staging", false)]
    [InlineData("oncall-alice", "catalog-api", "2026.09.03.4", "production", false)]
    [InlineData("intern-bob", "catalog-api", "2026.09.03.4", "staging", false)]
    public void Approval_policy_allows_only_reviewed_staging_releases(
        string reviewer, string service, string version, string environment, bool expected)
    {
        var approval = new ReleaseApproval(
            reviewer, service, version, environment,
            DateTimeOffset.UtcNow.AddMinutes(10));

        Assert.Equal(expected, ReleaseApprovalPolicy.CanApprove(approval, DateTimeOffset.UtcNow));
    }
}

Ajoutez également un test qui couvre l’expiration et un service contenant des caractères inattendus. Les tests de contrat ne remplacent pas un test d’intégration contre votre fournisseur de modèle ; ils empêchent une régression simple, par exemple lorsqu’une refactorisation retire accidentellement ApprovalRequiredAIFunction.

5. Faire de l’approbation une frontière de livraison

La CI de pull request compile et teste, mais ne reçoit aucun secret de déploiement. Le déploiement de staging part ensuite d’une branche de confiance et réclame un environnement GitHub protégé. L’approbation de l’outil dans l’application et l’approbation d’environnement dans GitHub couvrent deux décisions différentes ; gardez les deux.

name: Validate approved release agent

on:
  pull_request:
    paths:
      - "src/ApprovedReleaseAgent/**"
      - "tests/ApprovedReleaseAgent.Tests/**"
      - ".github/workflows/approved-release-agent.yml"
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "10.0.x"
      - run: dotnet restore --locked-mode
      - run: dotnet test --configuration Release --no-restore
      - run: dotnet publish src/ApprovedReleaseAgent --configuration Release --no-restore

  deploy-staging:
    if: github.event_name == 'push'
    needs: validate
    runs-on: ubuntu-latest
    environment: staging
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - run: ./scripts/deploy-staging.sh

Configurez la fédération OIDC Azure pour limiter l’identité au dépôt, à la branche et à l’environnement staging. La job validate n’a pas id-token: write, pas d’environnement et pas de script de déploiement. N’employez jamais pull_request_target pour offrir des droits Azure à du code modifiable par un fork.

La fonction DeployToStaging doit, elle aussi, appeler le fournisseur cible avec une identité restreinte à staging. Une approbation applicative ne doit jamais permettre d’utiliser la clé ou l’identité qui administre la production.

Cas particulier : plusieurs appels d’outils dans une réponse

FunctionInvokingChatClient traite les appels de fonction d’une réponse comme un lot. Lorsqu’au moins une fonction exige une approbation, les appels pairs de ce même lot peuvent aussi être représentés comme des demandes d’approbation afin de conserver la cohérence de la conversation. Les versions récentes du framework proposent un bypass pour que l’interface ne demande pas inutilement une décision pour une fonction qui n’en nécessite pas.

Ne contournez pas ce comportement en auto-approuvant toutes les demandes reçues. Avec un HarnessAgent, les mécanismes de liaison des réponses et de contournement des fonctions non sensibles sont activés par défaut ; les règles d’auto-approbation restent à définir explicitement et doivent être limitées à des opérations réellement sans effet de bord. Pour une composition manuelle, affichez chaque appel protégé et traitez-les comme un lot cohérent.

Checklist avant de connecter un vrai déploiement

  • Chaque outil qui écrit, supprime ou déclenche une opération est-il enveloppé par ApprovalRequiredAIFunction ?
  • L’écran d’approbation affiche-t-il des arguments structurés et validés, plutôt qu’un résumé du modèle ?
  • La décision est-elle liée à un réviseur authentifié, à un appel précis et à une expiration courte ?
  • Un refus stoppe-t-il l’opération sans nouvel essai automatique ?
  • L’identité de l’agent est-elle limitée à ses lectures et celle de l’exécuteur à l’environnement cible ?
  • Les tests vérifient-ils qu’une action sensible reste protégée ?
  • La CI de pull request est-elle dépourvue de droit de déploiement ?
  • La production possède-t-elle une frontière distincte de staging et une approbation hors modèle ?

Sources

L’approbation native ne rend pas un agent autonome plus sûr par intention : elle rend une capacité sensible explicitement interrompable, testable et attribuable. C’est la bonne direction pour un agent DevOps : le modèle explique et propose ; le système vérifie, l’humain décide, puis une identité bornée exécute.

Mots-clés :.NETC#AI AgentsDevSecOpsGitHub Actions
Y

Yva Hajatiana

Articles techniques sur l'ingénierie logicielle, .NET, le cloud et l'intelligence artificielle appliquée aux applications.