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)
Un analyseurAgentSafetyAnalyzer 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)
AGSEC003 – File.Read/Write/Delete(variable) : risque de path traversal
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.
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}"
The below script needs to be able to find the current output cell; this is an easy method to get it.
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.
publicsealedclass AgentSafetyAnalyzer : DiagnosticAnalyzer{publicconststring AGSEC001 ="AGSEC001";// Process.Start non-constantpublicconststring AGSEC002 ="AGSEC002";// SQL concatenationpublicconststring AGSEC003 ="AGSEC003";// File.* path non-constantinternalstaticreadonly 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);internalstaticreadonly DiagnosticDescriptor Rule002 =new(AGSEC002,"SQL string concatenation","SQL query built by concatenating '{0}': use a parameterized query (SqlParameter) instead","Security", DiagnosticSeverity.Warning, isEnabledByDefault:true);internalstaticreadonly 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);publicoverride ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule001, Rule002, Rule003);publicoverridevoidInitialize(AnalysisContext context){ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); context.RegisterSyntaxNodeAction(AnalyzeAdd, SyntaxKind.AddExpression);}privatestaticvoidAnalyzeInvocation(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 constantif(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 pathif((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));}privatestaticvoidAnalyzeAdd(SyntaxNodeAnalysisContext ctx){// AGSEC002 : "SELECT ..." + variablevar add =(BinaryExpressionSyntax)ctx.Node;if(add.Leftis 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.privatestaticboolIsConstant(SyntaxNodeAnalysisContext ctx, ExpressionSyntax expr)=> ctx.SemanticModel.GetConstantValue(expr).HasValue;privatestaticboolIsSqlLike(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) :
publicvoidRun(string userCmd,string userId){ Process.Start("whoami");// litteral : SUR Process.Start(userCmd);// variable : DANGEREUXvar 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 {publicvoidRun(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 =newAgentSafetyAnalyzer();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(newstring('-',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)"
Un DiagnosticAnalyzer signale ; un CodeFixProvidercorrige. 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.
internalstaticclass 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.publicstatic 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.Leftis 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 :
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)returnnull;// 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.publicstaticstringToParameterizedSql(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.