-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Add XS expression provider as lightweight alternative to Roslyn C# expressions #7054
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Codex Review
Here are some automated review suggestions for this pull request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Create a lambda expression without parameters | ||
| // TODO: Add support for accessing workflow context (globals) through expression transformation | ||
| var lambda = LinqExpression.Lambda(parsedExpression); | ||
|
|
||
| // Compile the lambda |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pass workflow globals into XS expressions
The evaluator compiles every script into a zero‑parameter lambda (LinqExpression.Lambda(parsedExpression) on lines 82‑85) and later executes it via compiledDelegate.DynamicInvoke() with no arguments. Because no Globals/ExpressionExecutionContext object is ever supplied, XS scripts cannot reference workflow variables, inputs, outputs, outcomes, or evaluator arguments—the identifiers simply don’t exist and the parser will fail whenever a workflow expression uses them. This makes the new provider unusable for anything beyond pure constant expressions and contradicts the stated goal of offering the same globals surface as the C# provider. Please add a globals parameter to the generated lambda and bind it to the current ExpressionExecutionContext/ExpressionEvaluatorOptions before invoking the delegate.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR introduces a new Elsa.Expressions.Xs module that provides an XS-based expression provider as a lightweight, security-focused alternative to the existing Roslyn C# expression evaluator. The implementation uses Hyperbee.XS library to parse expressions into expression trees, offering a smaller attack surface for multi-tenant scenarios while acknowledging that process isolation is still recommended for untrusted code.
Key Changes:
- New XS expression evaluation module with configurable caching and assembly references
- Integration tests covering basic expression scenarios (8 tests, 5 expected to pass)
- Dependency management updates for Hyperbee.XS packages (v1.3.3)
Reviewed Changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
src/modules/Elsa.Expressions.Xs/Elsa.Expressions.Xs.csproj |
New project definition with Hyperbee.XS dependencies |
src/modules/Elsa.Expressions.Xs/Features/XsFeature.cs |
DI feature registration with dependency declarations |
src/modules/Elsa.Expressions.Xs/Options/XsOptions.cs |
Configuration options for caching, assemblies, and XS config callbacks |
src/modules/Elsa.Expressions.Xs/Extensions/ModuleExtensions.cs |
UseXs() extension methods for module configuration |
src/modules/Elsa.Expressions.Xs/Contracts/IXsEvaluator.cs |
Interface for XS expression evaluation |
src/modules/Elsa.Expressions.Xs/Services/XsEvaluator.cs |
Core evaluator with SHA256-based caching and expression tree compilation |
src/modules/Elsa.Expressions.Xs/Expressions/XsExpressionHandler.cs |
Integration handler for Elsa's expression pipeline |
src/modules/Elsa.Expressions.Xs/Providers/XsExpressionDescriptorProvider.cs |
Registers "XS Script" expression type with Monaco C# syntax highlighting |
src/modules/Elsa.Expressions.Xs/Models/Globals.cs |
Workflow context globals class (not yet connected) |
src/modules/Elsa.Expressions.Xs/Models/ExecutionContextProxy.cs |
Proxy for variable/input access (not yet connected) |
src/modules/Elsa.Expressions.Xs/Models/OutputProxy.cs |
Proxy for activity output access (not yet connected) |
src/modules/Elsa.Expressions.Xs/Models/OutcomeProxy.cs |
Proxy for activity outcome management (not yet connected) |
src/modules/Elsa.Expressions.Xs/FodyWeavers.xml |
ConfigureAwait weaver configuration |
test/integration/Elsa.Xs.IntegrationTests/Elsa.Xs.IntegrationTests.csproj |
Integration test project setup |
test/integration/Elsa.Xs.IntegrationTests/XsEvaluatorTests.cs |
Test suite with 8 tests covering basic XS scenarios |
Directory.Packages.props |
Added Hyperbee.XS package version references |
Elsa.sln |
Added new projects and expanded platform configurations (x64/x86) |
| ExpressionExecutionContext context, | ||
| ExpressionEvaluatorOptions options, |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The options parameter is never used in the method body. The method should respect any options passed by the caller, such as timeout configurations or other evaluation constraints.
| using Elsa.Expressions.Models; | ||
| using Elsa.Extensions; | ||
| using JetBrains.Annotations; | ||
|
|
||
| namespace Elsa.Expressions.Xs.Models; | ||
|
|
||
| /// <summary> | ||
| /// Provides access to global objects, such as the workflow execution context. | ||
| /// </summary> | ||
| [UsedImplicitly] | ||
| public partial class Globals | ||
| { | ||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="Globals"/> class. | ||
| /// </summary> | ||
| public Globals(ExpressionExecutionContext expressionExecutionContext, IDictionary<string, object> arguments) | ||
| { | ||
| ExpressionExecutionContext = expressionExecutionContext; | ||
| Arguments = arguments; | ||
| ExecutionContext = new(expressionExecutionContext); | ||
| Output = new(expressionExecutionContext); | ||
| Outcome = new(expressionExecutionContext); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Provides access to activity outcomes. | ||
| /// </summary> | ||
| public OutcomeProxy Outcome { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Provides access to activity outputs. | ||
| /// </summary> | ||
| public OutputProxy Output { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Gets the current execution context. | ||
| /// </summary> | ||
| public ExecutionContextProxy ExecutionContext { get; } | ||
|
|
||
| /// <summary> | ||
| /// Gets the ID of the current workflow instance. | ||
| /// </summary> | ||
| public string WorkflowInstanceId => ExpressionExecutionContext.GetWorkflowExecutionContext().Id; | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets the correlation ID of the current workflow instance. | ||
| /// </summary> | ||
| public string? CorrelationId | ||
| { | ||
| get => ExpressionExecutionContext.GetWorkflowExecutionContext().CorrelationId; | ||
| set => ExpressionExecutionContext.GetWorkflowExecutionContext().CorrelationId = value; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets the name of the current workflow instance. | ||
| /// </summary> | ||
| public string? WorkflowInstanceName | ||
| { | ||
| get => ExpressionExecutionContext.GetWorkflowExecutionContext().Name; | ||
| set => ExpressionExecutionContext.GetWorkflowExecutionContext().Name = value; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets additional arguments provided by the caller of the evaluator. | ||
| /// </summary> | ||
| public IDictionary<string, object> Arguments { get; } | ||
|
|
||
| private ExpressionExecutionContext ExpressionExecutionContext { get; } | ||
| } |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Globals class and related proxy classes (OutputProxy, OutcomeProxy, ExecutionContextProxy) are defined but never instantiated or used by XsEvaluator. These classes provide workflow context access, but the evaluator's TODO comment (line 83 in XsEvaluator.cs) indicates this functionality isn't implemented yet. This creates dead code that may confuse developers. Consider either:
- Implementing the context transformation to use these classes
- Or moving these classes to a future PR when the feature is ready to be implemented
| using Elsa.Expressions.Models; | |
| using Elsa.Extensions; | |
| using JetBrains.Annotations; | |
| namespace Elsa.Expressions.Xs.Models; | |
| /// <summary> | |
| /// Provides access to global objects, such as the workflow execution context. | |
| /// </summary> | |
| [UsedImplicitly] | |
| public partial class Globals | |
| { | |
| /// <summary> | |
| /// Initializes a new instance of the <see cref="Globals"/> class. | |
| /// </summary> | |
| public Globals(ExpressionExecutionContext expressionExecutionContext, IDictionary<string, object> arguments) | |
| { | |
| ExpressionExecutionContext = expressionExecutionContext; | |
| Arguments = arguments; | |
| ExecutionContext = new(expressionExecutionContext); | |
| Output = new(expressionExecutionContext); | |
| Outcome = new(expressionExecutionContext); | |
| } | |
| /// <summary> | |
| /// Provides access to activity outcomes. | |
| /// </summary> | |
| public OutcomeProxy Outcome { get; set; } | |
| /// <summary> | |
| /// Provides access to activity outputs. | |
| /// </summary> | |
| public OutputProxy Output { get; set; } | |
| /// <summary> | |
| /// Gets the current execution context. | |
| /// </summary> | |
| public ExecutionContextProxy ExecutionContext { get; } | |
| /// <summary> | |
| /// Gets the ID of the current workflow instance. | |
| /// </summary> | |
| public string WorkflowInstanceId => ExpressionExecutionContext.GetWorkflowExecutionContext().Id; | |
| /// <summary> | |
| /// Gets or sets the correlation ID of the current workflow instance. | |
| /// </summary> | |
| public string? CorrelationId | |
| { | |
| get => ExpressionExecutionContext.GetWorkflowExecutionContext().CorrelationId; | |
| set => ExpressionExecutionContext.GetWorkflowExecutionContext().CorrelationId = value; | |
| } | |
| /// <summary> | |
| /// Gets or sets the name of the current workflow instance. | |
| /// </summary> | |
| public string? WorkflowInstanceName | |
| { | |
| get => ExpressionExecutionContext.GetWorkflowExecutionContext().Name; | |
| set => ExpressionExecutionContext.GetWorkflowExecutionContext().Name = value; | |
| } | |
| /// <summary> | |
| /// Gets additional arguments provided by the caller of the evaluator. | |
| /// </summary> | |
| public IDictionary<string, object> Arguments { get; } | |
| private ExpressionExecutionContext ExpressionExecutionContext { get; } | |
| } |
| [Fact(DisplayName = "String concatenation should work")] | ||
| public async Task String_Concatenation_Should_Work() | ||
| { | ||
| // Arrange | ||
| var script = """ | ||
| var hello = "Hello"; | ||
| var world = "World"; | ||
| hello + " " + world; | ||
| """; | ||
| var context = await CreateExpressionExecutionContextAsync(); | ||
| var evaluator = _fixture.Services.GetRequiredService<IXsEvaluator>(); | ||
|
|
||
| // Act | ||
| var result = await evaluator.EvaluateAsync(script, typeof(string), context, new ExpressionEvaluatorOptions()); | ||
|
|
||
| // Assert | ||
| Assert.Equal("Hello World", result); | ||
| } |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
According to the PR description's "Known Limitations" section, string concatenation via the + operator is unsupported due to expression tree limitations. This test is expected to fail but is not marked with [Fact(Skip = "...")]. The test should either:
- Be marked as skipped with an explanation:
[Fact(Skip = "String concatenation via + operator is not supported by XS expression trees")] - Or be removed until the feature is implemented
| /// <inheritdoc /> | ||
| public async ValueTask<object?> EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions options) | ||
| { | ||
| var script = expression.Value.ConvertTo<string>() ?? ""; |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If expression.Value is null, the null-coalescing operator converts it to an empty string "". An empty script will likely cause parsing or evaluation errors. Consider validating the input and throwing a more descriptive exception, such as ArgumentException with a message like "XS expression cannot be null or empty".
| var script = expression.Value.ConvertTo<string>() ?? ""; | |
| var script = expression.Value.ConvertTo<string>(); | |
| if (string.IsNullOrWhiteSpace(script)) | |
| throw new ArgumentException("XS expression cannot be null or empty.", nameof(expression)); |
|
|
||
| private Delegate GetCompiledDelegate(string expression, Type returnType, XsConfig xsConfig, TypeResolver typeResolver, ExpressionExecutionContext context, ExpressionEvaluatorOptions options) | ||
| { | ||
| var cacheKey = "xs:expression:" + Hash(expression); |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The returnType, context, and options parameters are passed to this method but never used. The cached delegate is compiled without considering the return type or execution context, which means:
- Different return types for the same expression will incorrectly share the same cached delegate
- The cache key should include the return type to avoid type mismatch issues
Consider adding the return type to the cache key: var cacheKey = $"xs:expression:{returnType.FullName}:{Hash(expression)}";
| var cacheKey = "xs:expression:" + Hash(expression); | |
| var cacheKey = $"xs:expression:{returnType.FullName}:{Hash(expression)}"; |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Debug|x64.ActiveCfg = Debug|Any CPU | ||
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Debug|x64.Build.0 = Debug|Any CPU | ||
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Debug|x86.ActiveCfg = Debug|Any CPU | ||
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Debug|x86.Build.0 = Debug|Any CPU | ||
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|Any CPU.Build.0 = Release|Any CPU | ||
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|x64.ActiveCfg = Release|Any CPU | ||
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|x64.Build.0 = Release|Any CPU | ||
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|x86.ActiveCfg = Release|Any CPU | ||
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|x86.Build.0 = Release|Any CPU |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The solution file now includes additional platform configurations (x64, x86) for all projects. This significantly increases the file size (from ~620 lines to ~1160 lines) and appears to be unrelated to adding the XS expression provider. If these platform configurations weren't previously needed, they should be removed to keep the PR focused on its stated purpose. If they are intentional, this should be mentioned in the PR description.
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Debug|x64.Build.0 = Debug|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Debug|x86.Build.0 = Debug|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|Any CPU.Build.0 = Release|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|x64.ActiveCfg = Release|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|x64.Build.0 = Release|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|x86.ActiveCfg = Release|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|x86.Build.0 = Release|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| {C5923142-28BB-478A-B77C-68CD25153DA6}.Release|Any CPU.Build.0 = Release|Any CPU |
| typeof(Enumerable).Assembly, // System.Linq | ||
| typeof(Guid).Assembly, // System.Runtime | ||
| typeof(JsonSerializer).Assembly, // System.Text.Json | ||
| typeof(IDictionary<string, object>).Assembly, // System.Collections |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The default assemblies list includes typeof(IDictionary<string, object>).Assembly with a comment "System.Collections", but IDictionary<,> is actually in System.Runtime, not a specific Collections assembly. Consider using a more accurate type or updating the comment to reflect the actual assembly being referenced.
| typeof(IDictionary<string, object>).Assembly, // System.Collections | |
| typeof(IDictionary<string, object>).Assembly, // System.Runtime |
| [Fact(DisplayName = "Array operations should work")] | ||
| public async Task Array_Operations_Should_Work() | ||
| { | ||
| // Arrange | ||
| var script = """ | ||
| var numbers = new int[] { 1, 2, 3, 4, 5 }; | ||
| numbers[2]; | ||
| """; | ||
| var context = await CreateExpressionExecutionContextAsync(); | ||
| var evaluator = _fixture.Services.GetRequiredService<IXsEvaluator>(); | ||
|
|
||
| // Act | ||
| var result = await evaluator.EvaluateAsync(script, typeof(int), context, new ExpressionEvaluatorOptions()); | ||
|
|
||
| // Assert | ||
| Assert.Equal(3, result); | ||
| } |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
According to the PR description's "Known Limitations" section, array initialization syntax new int[] is not recognized by the XS parser. This test is expected to fail but is not marked with [Fact(Skip = "...")]. The test should either:
- Be marked as skipped with an explanation:
[Fact(Skip = "Array initialization syntax 'new int[]' is not supported by XS parser")] - Or be removed until the feature is implemented
|
|
||
| <ItemGroup> | ||
| <ProjectReference Include="..\Elsa.Expressions\Elsa.Expressions.csproj" /> | ||
| <ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" /> |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The project references Elsa.Workflows.Management, but based on the implementation, only Elsa.Expressions appears to be directly used. The Elsa.Workflows.Management reference may be unnecessary and could be removed if it's not providing any required dependencies. Consider verifying if this reference is actually needed.
| <ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" /> |
| public async Task<object?> EvaluateAsync( | ||
| string expression, | ||
| Type returnType, | ||
| ExpressionExecutionContext context, | ||
| ExpressionEvaluatorOptions options, | ||
| CancellationToken cancellationToken = default) | ||
| { |
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The context parameter is never used in the method body. According to the TODO comment on line 83 and the PR description's known limitations, workflow context (globals) access is not yet implemented. However, accepting the parameter and ignoring it may lead to confusion. Consider either:
- Adding a TODO/warning comment at the method level explaining this limitation
- Or implementing basic context parameter passing to make the evaluator functional with workflow variables
4e58970 to
ae1bb77
Compare
Implements Hyperbee.XS-based expression provider for workflows, addressing multi-tenant security concerns with Roslyn's full C# compiler by using expression trees instead of dynamic compilation.
Changes
New Module:
Elsa.Expressions.XsUseXs()extension methodPackage Dependencies
Hyperbee.XS1.3.3Hyperbee.Xs.Extensions1.3.3Integration Tests
8 tests covering arithmetic, variables, conditionals, boolean expressions (5 passing)
Usage
Expression example:
Known Limitations
+operator unsupported (expression tree limitation)new int[]not recognized by XS parserSecurity Impact
Warning
Firewall rules blocked me from connecting to one or more addresses (expand for details)
I tried to connect to the following addresses, but was blocked by firewall rules:
f.feedz.iodotnet restore src/modules/Elsa.Expressions.Xs/Elsa.Expressions.Xs.csproj --ignore-failed-sources(dns block)dotnet restore test/integration/Elsa.Xs.IntegrationTests/Elsa.Xs.IntegrationTests.csproj --ignore-failed-sources(dns block)If you need me to access, download, or install something from one of these locations, you can either:
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.
This change is