Garde-fous Roslyn pour le code genere par agent

Grain DEEP/notebook-dotnet – lane myia-po-2024:CoursIA – prev: DEEP/genai #10487. See #10473 (Epic The Unexpected AI Stack: C#/.NET, axe Roslyn).

La these en une phrase

Quand un agent (Claude Code, Roo, un LLM) genere du code C#, le compilateur Roslyn peut servir de garde-fou de securite – la verification se fait dans la compilation elle-meme, pas en post-traitement optionnel comme mypy ou ruff en Python.

C’est un axe ou la pile .NET est structurellement en avance : un DiagnosticAnalyzer est execute par le compilateur a chaque build, avec acces au modele semantique (types, valeurs constantes, flux de donnees), pas seulement au texte. Un agent qui produit du code non-sur est donc rejete avant de tourner.

Ce que ce notebook demontre (execute, pas narre)

  1. Un analyseur AgentSafetyAnalyzer qui detecte 3 familles de code d’agent dangereux :
    • AGSEC001 – Process.Start(variable) : risque d’injection de commande (l’argument n’est pas une constante)
    • AGSEC002 – concatenation de chaine SQL ("SELECT ..." + var) : risque d’injection SQL
    • AGSEC003 – File.Read/Write/Delete(variable) : risque de path traversal
  2. La valeur ajoutee semantique : l’analyseur distingue un litteral sur (Process.Start("whoami")) d’une variable attaquable (Process.Start(userCmd)) – un grep naif ne le peut pas.
  3. Un correcteur automatique (CodeFix) qui transforme la concatenation SQL en chaine interpolation, premice d’une requete parametree.

Source : The Unexpected AI Stack: C#/.NET – Part 1 (Charles Chen, 08/2026), section Roslyn: analyseurs statiques comme garde-fous d’agent.

#r "nuget: Microsoft.CodeAnalysis.CSharp, 4.13.0"

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Text;

// Preuve SOTA-OK : le VRAI Microsoft.CodeAnalysis est invoque, pas une reimplementation jouet.
$"Microsoft.CodeAnalysis (Roslyn) version: {typeof(Compilation).Assembly.GetName().Version}"
Installed Packages
  • Microsoft.CodeAnalysis.CSharp, 4.13.0
Microsoft.CodeAnalysis (Roslyn) version: 4.13.0.0

Pourquoi un analyseur compile-time ? Parite C# / Python

Aspect Python (apres generation d’agent) C# / .NET (Roslyn)
Verificateur statique mypy / ruff (linter optionnel, hors compilation) DiagnosticAnalyzer execute par le compilateur a chaque build
Acces au modele semantique partiel (mypy sur les types ; pas de valeurs constantes) complet (types, valeurs constantes, flux de nullabilite, graphes d’appel)
Localisation des defauts numero de ligne (regex) span precis (Location.Create, colonne + longueur)
Correcteur auto aucun standard CodeFixProvider (ampoule IDE, dotnet format)

La difference decisive est le modele semantique. Un grep "Process.Start" trouve l’appel, mais ne sait pas si l’argument est la chaine litterale "whoami" (sur) ou la variable userCmd (attaque). Roslyn, lui, peut demander au compilateur : cette expression a-t-elle une valeur constante ? via SemanticModel.GetConstantValue. C’est toute la difference entre regarder le texte et regarder le sens du code.

1. L’analyseur AgentSafetyAnalyzer

Un DiagnosticAnalyzer declare : - des regles (DiagnosticDescriptor : identifiant, severite, message, categorie), - des abonnements syntaxiques (RegisterSyntaxNodeAction : appelle un callback pour chaque noeud d’un type donne – ici les InvocationExpression et les BinaryExpression).

A chaque noeud visite, il decide de signaler (ReportDiagnostic) ou non. La classe ci-dessous en regroupe trois.

public sealed class AgentSafetyAnalyzer : DiagnosticAnalyzer
{
    public const string AGSEC001 = "AGSEC001"; // Process.Start non-constant
    public const string AGSEC002 = "AGSEC002"; // SQL concatenation
    public const string AGSEC003 = "AGSEC003"; // File.* path non-constant

    internal static readonly DiagnosticDescriptor Rule001 = new(AGSEC001,
        "Command injection: non-constant Process.Start argument",
        "Process.Start is called with a non-constant value '{0}': attacker-controlled input could inject a command",
        "Security", DiagnosticSeverity.Warning, isEnabledByDefault: true);

    internal static readonly DiagnosticDescriptor Rule002 = new(AGSEC002,
        "SQL string concatenation",
        "SQL query built by concatenating '{0}': use a parameterized query (SqlParameter) instead",
        "Security", DiagnosticSeverity.Warning, isEnabledByDefault: true);

    internal static readonly DiagnosticDescriptor Rule003 = new(AGSEC003,
        "Path traversal: non-constant file path",
        "File operation on a non-constant path '{0}': validate/contain the path before access",
        "Security", DiagnosticSeverity.Warning, isEnabledByDefault: true);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
        ImmutableArray.Create(Rule001, Rule002, Rule003);

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
        context.RegisterSyntaxNodeAction(AnalyzeAdd, SyntaxKind.AddExpression);
    }

    private static void AnalyzeInvocation(SyntaxNodeAnalysisContext ctx)
    {
        var inv = (InvocationExpressionSyntax)ctx.Node;
        var name = inv.Expression.ToString();
        var args = inv.ArgumentList?.Arguments ?? default(SeparatedSyntaxList<ArgumentSyntax>);

        // AGSEC001 : Process.Start dont the first argument is NOT a compile-time constant
        if (name.Contains("Process.Start") && args.Count > 0 && !IsConstant(ctx, args[0].Expression))
            ctx.ReportDiagnostic(Diagnostic.Create(Rule001, args[0].GetLocation(), args[0].Expression));

        // AGSEC003 : File.Read/Write/Delete on a non-constant path
        if ((name.StartsWith("File.Read") || name.StartsWith("File.Write") || name.StartsWith("File.Delete"))
            && args.Count > 0 && !IsConstant(ctx, args[0].Expression))
            ctx.ReportDiagnostic(Diagnostic.Create(Rule003, args[0].GetLocation(), args[0].Expression));
    }

    private static void AnalyzeAdd(SyntaxNodeAnalysisContext ctx)
    {
        // AGSEC002 : "SELECT ..." + variable
        var add = (BinaryExpressionSyntax)ctx.Node;
        if (add.Left is not LiteralExpressionSyntax lit || !lit.IsKind(SyntaxKind.StringLiteralExpression)) return;
        if (!IsSqlLike(lit.Token.ValueText)) return;
        ctx.ReportDiagnostic(Diagnostic.Create(Rule002, add.GetLocation(), add.Right));
    }

    // The semantique key: ask the compiler whether the expression folds to a constant.
    private static bool IsConstant(SyntaxNodeAnalysisContext ctx, ExpressionSyntax expr) =>
        ctx.SemanticModel.GetConstantValue(expr).HasValue;

    private static bool IsSqlLike(string s)
    {
        var u = s.ToUpperInvariant();
        return u.Contains("SELECT ") || u.Contains("INSERT ") || u.Contains("UPDATE ") || u.Contains("DELETE ");
    }
}

"AgentSafetyAnalyzer defini : 3 regles (AGSEC001/002/003), abonnees aux invocations et aux additions."
AgentSafetyAnalyzer defini : 3 regles (AGSEC001/002/003), abonnees aux invocations et aux additions.

2. Le code genere par l’agent

Imaginons un agent qui produit un handler C#. Il a ecrit ce qui suit – un melange realiste d’appels sur (litteraux) et dangereux (variables utilisateur) :

public void Run(string userCmd, string userId) {
    Process.Start("whoami");                          // litteral : SUR
    Process.Start(userCmd);                           // variable : DANGEREUX
    var sql = "SELECT * FROM Users WHERE id=" + userId;  // concatenation : DANGEREUX
    File.ReadAllText(userId);                         // variable : DANGEREUX
    File.ReadAllText("config.json");                  // litteral : SUR
}

On compile ce source avec l’analyseur branche, et on observe les diagnostics emis.

Lecture du resultat : 3 regles AGSEC001/002/003

La cellule d’analyseur (code precedente) definit un AgentSafetyAnalyzer qui s’enregistre sur 2 syntaxes – InvocationExpression et AddExpression – et emet 3 regles distinctes : AGSEC001 (Process.Start non-constant), AGSEC002 (concatenation SQL), AGSEC003 (File.* path non-constant). Ces 3 regles couvrent les 3 classes d’injection qu’un agent LLM produit le plus souvent quand il genere du C# sans contexte securite – l’analyseur n’est pas exhaustif, il marque un plancher de garde-fous compile-time qu’un agent devrait toujours avoir active.

var agentCode = @"
using System.Diagnostics;
using System.IO;
class AgentHandler {
    public void Run(string userCmd, string userId) {
        Process.Start(""whoami"");
        Process.Start(userCmd);
        var sql = ""SELECT * FROM Users WHERE id="" + userId;
        File.ReadAllText(userId);
        File.ReadAllText(""config.json"");
    }
}";

var tree = CSharpSyntaxTree.ParseText(agentCode);
var compilation = CSharpCompilation.Create("AgentOutput", new[] { tree });
var analyzer = new AgentSafetyAnalyzer();
var diags = await compilation
    .WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer))
    .GetAnalyzerDiagnosticsAsync();

Console.WriteLine($"{diags.Length} diagnostic(s) emis par l'analyseur :\n");
Console.WriteLine($"{"Ligne",-6} {"ID",-10} {"Severite",-9} Message");
Console.WriteLine(new string('-', 78));
foreach (var d in diags.OrderBy(d => d.Location.GetMappedLineSpan().StartLinePosition.Line))
{
    var ls = d.Location.GetMappedLineSpan();
    Console.WriteLine($"L{ls.StartLinePosition.Line + 1,-5} {d.Id,-10} {d.Severity,-9} {d.GetMessage()}");
}

// Litteraux "whoami" et "config.json" : AUCUN diagnostic -> la distinction semantique marche.
string verdit = diags.Count() == 3
    ? "\nLes 2 litteraux (whoami, config.json) sont silencieux : l'analyseur ne flag QUE les variables -> valeur semantique prouvee."
    : $"\nAttention : {diags.Count()} diag(s), attendu 3.";
verdit
3 diagnostic(s) emis par l'analyseur :

Ligne  ID         Severite  Message
------------------------------------------------------------------------------
L7     AGSEC001   Warning   Process.Start is called with a non-constant value 'userCmd': attacker-controlled input could inject a command
L8     AGSEC002   Warning   SQL query built by concatenating 'userId': use a parameterized query (SqlParameter) instead
L9     AGSEC003   Warning   File operation on a non-constant path 'userId': validate/contain the path before access

Les 2 litteraux (whoami, config.json) sont silencieux : l'analyseur ne flag QUE les variables -> valeur semantique prouvee.

3. Ce qu’un grep naif rate

Comparons les deux approches sur le meme source. Un grep "Process.Start" compte tous les appels – y compris le litteral sur. L’analyseur, lui, ne signale que l’appel dont l’argument n’est pas une constante.

C’est la difference entre une analyse textuelle et une analyse semantique. Le grep ne peut pas demander au compilateur si une expression se reduit a une constante ; Roslyn le peut (SemanticModel.GetConstantValue).

Lecture du resultat : 3 diagnostics, 2 litteraux silencieux

La cellule de compilation (code precedente) a execute l’analyseur sur le code d’agent. La sortie montre 3 diagnostics : L7 = AGSEC001 Warning sur userCmd, L8 = AGSEC002 Warning sur la concatenation SQL avec userId, L9 = AGSEC003 Warning sur File.ReadAllText(userId). Le verdict final est explicite : “Les 2 litteraux (whoami, config.json) sont silencieux : l’analyseur ne flag QUE les variables -> valeur semantique prouvee.” C’est la preuve de la difference semantique/texte : un grep aurait sur-flagge le handler, l’analyseur en signale 3 et laisse passer les 2 litteraux.

Limites de l’analyseur

Quand le code n’est pas un InvocationExpression ou un AddExpression resolvable par Roslyn (code dynamique Activator.CreateInstance, reflection MethodInfo.Invoke, generation Expression.Lambda.Compile()), l’analyseur ne voit rien. C’est un filet de securite, pas un scanner exhaustif : il arrete les patterns recurrents d’agents LLM, pas les attaques ciblees.

// Approche grep naif : compte toute occurrence de "Process.Start"
int grepMatches = System.Text.RegularExpressions.Regex.Count(agentCode, @"Process\.Start\(");

// Approche analyseur : compte uniquement les AGSEC001 (argument non-constant)
int analyzerFlags = diags.Count(d => d.Id == AgentSafetyAnalyzer.AGSEC001);

$"grep naif = {grepMatches} appel(s) Process.Start   |   analyseur AGSEC001 = {analyzerFlags} (les litteraux exclus)"
grep naif = 2 appel(s) Process.Start   |   analyseur AGSEC001 = 1 (les litteraux exclus)

4. Le correcteur automatique (CodeFix)

Un DiagnosticAnalyzer signale ; un CodeFixProvider corrige. Dans un IDE, il apparait sous l’ampoule ; en CI, il peut etre applique via dotnet format. Nous definissons ici la transformation elle-meme (extraite d’un CodeFixProvider pour la lisibilite) : elle reecrit la concatenation SQL en chaine interpolation, premiere etape vers une vraie requete parametree.

La technique est idiomatique Roslyn : on construit le noeud de remplacement par parsing (SyntaxFactory.ParseExpression), puis on le substitue via root.ReplaceNode.

Lecture du resultat : grep 2 vs analyseur 1

La cellule precedente compare les deux approches sur le meme source. Verdict immediat : grep naif = 2 appel(s) Process.Start | analyseur AGSEC001 = 1 (les litteraux exclus). Le grep regular-expression compte 2 appels (lignes L4-L5 du handler) ; l’analyseur n’en signale qu’un seul – celui dont le 1er argument est la variable userCmd. L’appel Process.Start("whoami") (litteral) est correct et l’analyseur le laisse passer. Un developpeur qui recoit 2 Process.Start dans un rapport ouvre les 2 et perd du temps sur le faux positif ; avec 1 Process.Start il fixe la ligne et continue. La difference entre un outil utilise et un outil ignore tient a ce ratio de bruit.

internal static class SqlConcatCodeFix
{
    // "SELECT ... " + var   ->   $"SELECT ... {var}"
    // Dans un vrai projet, cette transformation vit dans un CodeFixProvider.RegisterCodeFixesAsync
    // (CodeAction.Create + document.WithSyntaxRoot). On l'applique ici directement sur l'arbre.
    public static ExpressionSyntax ToInterpolated(BinaryExpressionSyntax add)
    {
        var leftText = ((LiteralExpressionSyntax)add.Left).Token.ValueText;
        var right = add.Right.ToString();
        var replacement = $"$\"{leftText}{{{right}}}\"";
        return SyntaxFactory.ParseExpression(replacement).WithTriviaFrom(add);
    }
}

var root = await tree.GetRootAsync();
var addExpr = root.DescendantNodes().OfType<BinaryExpressionSyntax>()
    .First(b => b.Kind() == SyntaxKind.AddExpression && b.Left is LiteralExpressionSyntax);
var fixedNode = SqlConcatCodeFix.ToInterpolated(addExpr);
var newRoot = root.ReplaceNode(addExpr, fixedNode);

$"AVANT : {addExpr}\nAPRES : {fixedNode}"
AVANT : "SELECT * FROM Users WHERE id=" + userId
APRES : $"SELECT * FROM Users WHERE id={userId}"

5. Brancher l’analyseur sur un vrai projet

Jusqu’ici nous avons instancie l’analyseur programmatiquement (WithAnalyzers). Dans un vrai projet, on l’empaquette dans une librairie d’analyseurs (projet netstandard2.0 reference par le projet consommateur via <PackageReference> ou <ProjectReference> avec OutputItemType=Analyzer), et il s’execute automatiquement a chaque dotnet build.

Regle d’or .NET : activez aussi les analyseurs integres via .editorconfig :

[*.cs]
dotnet_analyzer_diagnostic.severity = warning
dotnet_code_quality.enable_platform_analyzer_on_ci = true

Pour vos propres analyseurs (le contenu de ce notebook), l’etape de packaging est un grain separe (voir Exercice 3). Le notebook reste executable sans rien installer : tout se passe en memoire via Microsoft.CodeAnalysis.

Lecture du resultat : AVANT/APRÈS la concatenation SQL

La cellule de codefix (precedente) prend l’expression BinaryExpressionSyntax detectee par AGSEC002 et applique la transformation SqlConcatCodeFix.ToInterpolated. Verdict immediat : AVANT : "SELECT * FROM Users WHERE id=" + userId – APRES : $"SELECT * FROM Users WHERE id={userId}". C’est la demonstration de la transformation syntaxique Roslyn : extraction du literal gauche via ((LiteralExpressionSyntax)add.Left).Token.ValueText, parsing de l’expression resultante via SyntaxFactory.ParseExpression, preservation des trivia (whitespace + commentaires) via .WithTriviaFrom(add), substitution par root.ReplaceNode(addExpr, fixedNode).

De l’interpolation vers le SqlParameter

L’interpolation $"...{userId}" est toujours une injection SQL potentielle : un userId = "1; DROP TABLE Users; --" casse la requete au moment de l’envoi au serveur. La seule solution reellement sure est SqlCommand.Parameters.Add(new SqlParameter("@p0", userId)) avec un placeholder @p0 dans le SQL – c’est l’objet de l’Exercice 2. Le codefix actuel est un degre intermediaire : il ameliore la lisibilite et prepare la migration sans casser la compilation.

Exercice 1 : ajouter une regle (ReDoS sur regex)

Objectif : ajouter une 4e regle AGSEC004 qui signale new Regex(variable) – une regex construite depuis une entree utilisateur est un vecteur de ReDoS (catastrophic backtracking).

Indices : - Abonnez-vous au kind SyntaxKind.ObjectCreationExpression. - Verifiez que le type cree est System.Text.RegularExpressions.Regex via le modele semantique (SymbolInfo). - Flaggez si le 1er argument n’est pas une constante.

// Exercice a completer : implementer AGSEC004 (Regex non-constant -> ReDoS)
// Indice : ctx.SemanticModel.GetSymbolInfo(objectCreation).Symbol pour verifier le type Regex.

public DiagnosticDescriptor BuildReDoSRule()
{
    // TODO etudiant : declarer le DiagnosticDescriptor pour AGSEC004
    // (id, titre, message, categorie Security, severity Warning, isEnabledByDefault true)
    return null; // TODO etudiant : remplacer par votre descriptor
}

Console.WriteLine("Exercice 1 a completer -- implementez BuildReDoSRule() puis branchez-la dans Initialize().");
Exercice 1 a completer -- implementez BuildReDoSRule() puis branchez-la dans Initialize().

Exercice 2 : etendre le codefix vers un vrai SqlParameter

Objectif : le codefix actuel transforme la concatenation en interpolation ($"..."). Ameliorez-le pour produire une vraie requete parametree : remplacer la variable par un placeholder @p0 et generer un commentaire indiquant la creation du SqlParameter("@p0", var).

Indice : modifiez ToInterpolated pour construire la chaine "SELECT ... WHERE id=@p0" /* + new SqlParameter("@p0", userId) */.

// Exercice a completer : transformer la concatenation SQL en requete parametree.

public static string ToParameterizedSql(BinaryExpressionSyntax add)
{
    // TODO etudiant : extraire leftText et right, produire la chaine parametree
    // avec un placeholder @p0 + un commentaire SqlParameter.
    return "Exercice 2 a completer";
}

Console.WriteLine("Exercice 2 a completer -- implementez ToParameterizedSql().");
Exercice 2 a completer -- implementez ToParameterizedSql().

Exercice 3 : packager l’analyseur pour un vrai .csproj

Objectif : transformer la classe AgentSafetyAnalyzer en une librairie d’analyseurs referencee par un projet console, de maniere a ce qu’elle s’execute a chaque dotnet build.

Etapes : 1. Creer un projet netstandard2.0 avec <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />. 2. Ajouter [DiagnosticAnalyzer(LanguageNames.CSharp)] sur la classe. 3. Dans le projet consommateur, referencer l’analyseur via : xml <ProjectReference Include="..\AgentGuardrails\AgentGuardrails.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> 4. dotnet build doit alors faire echouer (ou avertir) sur le code d’agent dangereux.

// Exercice a completer (hors notebook) : packaging en librairie d'analyseurs.
// Indice de validation : apres packaging, un `dotnet build` du projet consommateur
// doit emettre AGSEC001/002/003 sans code de test supplementaire.

Console.WriteLine("Exercice 3 a completer -- creer le projet netstandard2.0 + ProjectReference OutputItemType=Analyzer.");
Exercice 3 a completer -- creer le projet netstandard2.0 + ProjectReference OutputItemType=Analyzer.

Conclusion

Nous avons demontre, avec du code execute et non de la prose, pourquoi les analyseurs Roslyn sont un garde-fou naturel pour le code genere par agent :

  • la verification se fait dans la compilation, donc impossible a oublier (contrairement a un linter optionnel) ;
  • le modele semantique distingue les litteraux surs des variables attaquables – ce qu’aucun grep ne peut faire ;
  • un CodeFixProvider corrige automatiquement, dans l’IDE comme en CI.

C’est l’illustration concrete de la these de la serie The Unexpected AI Stack : pour construire et operer des systemes agentiques, la pile .NET offre des garde-fous que Python n’a pas nativement. Nos notebooks C# n’ont pas a se contenter d’egaler leur jumeau Python – ils peuvent montrer ce que le Python ne sait pas faire.

Prochaines axes de l’Epic (#10473) : Aspire (orchestration programmable, #10474), OpenTelemetry via le dashboard, CSharpRepl attache a un process vivant.


Sources : The Unexpected AI Stack: C#/.NET – Part 1 (Charles Chen). Roslyn analyzers docs.

Retour au sommet