ScriptContext abstract base defines the API user scripts see as ctx — GetTag(string) returns DataValueSnapshot so scripts branch on quality naturally, SetVirtualTag(string, object?) is the only write path virtual tags have (OPC UA client writes to virtual nodes rejected separately in DriverNodeManager per ADR-002), Now + Logger + Deadband static helper round out the surface. Concrete subclasses in Streams B + C plug in actual tag backends + per-script Serilog loggers.
ScriptSandbox.Build(contextType) produces the ScriptOptions for every compile — explicit allow-list of six assemblies (System.Private.CoreLib / System.Linq / Core.Abstractions / Core.Scripting / Serilog / the context type's own assembly), with a matching import list so scripts don't need using clauses. Allow-list is plan-level — expanding it is not a casual change.
DependencyExtractor uses CSharpSyntaxWalker to find every ctx.GetTag("literal") and ctx.SetVirtualTag("literal", ...) call, rejects every non-literal path (variable, concatenation, interpolation, method-returned). Rejections carry the exact TextSpan so the Admin UI can point at the offending token. Reads + writes are returned as two separate sets so the virtual-tag engine (Stream B) knows both the subscription targets and the write targets.
Sandbox enforcement turned out needing a second-pass semantic analyzer because .NET 10's type forwarding makes assembly-level restriction leaky — System.Net.Http.HttpClient resolves even with WithReferences limited to six assemblies. ForbiddenTypeAnalyzer runs after Roslyn's Compile() against the SemanticModel, walks every ObjectCreationExpression / InvocationExpression / MemberAccessExpression / IdentifierName, resolves to the containing type's namespace, and rejects any prefix-match against the deny-list (System.IO, System.Net, System.Diagnostics, System.Reflection, System.Threading.Thread, System.Runtime.InteropServices, Microsoft.Win32). Rejections throw ScriptSandboxViolationException with the aggregated list + source spans so the Admin UI surfaces every violation in one round-trip instead of whack-a-mole. System.Environment explicitly stays allowed (read-only process state, doesn't persist or leak outside) and that compromise is pinned by a dedicated test.
ScriptGlobals<TContext> wraps the context as a named field so scripts see ctx instead of the bare globalsType-member-access convention Roslyn defaults to — keeps script ergonomics (ctx.GetTag) consistent with the AST walker's parse shape and the Admin UI's hand-written type stub (coming in Stream F). Generic on TContext so Stream C's alarm-predicate context with an Alarm property inherits cleanly.
ScriptEvaluator<TContext, TResult>.Compile is the three-step gate: (1) Roslyn compile — throws CompilationErrorException on syntax/type errors with Location-carrying diagnostics; (2) ForbiddenTypeAnalyzer semantic pass — catches type-forwarding sandbox escapes; (3) delegate creation. Runtime exceptions from user code propagate unwrapped — the virtual-tag engine in Stream B catches + maps per-tag to BadInternalError quality per Phase 7 decision #11.
29 unit tests covering every surface: DependencyExtractorTests has 14 theories — single/multiple/deduplicated reads, separate write tracking, rejection of variable/concatenated/interpolated/method-returned/empty/whitespace paths, ignoring non-ctx methods named GetTag, empty-source no-op, source span carried in rejections, multiple bad paths reported in one pass, nested literal extraction. ScriptSandboxTests has 15 — happy-path compile + run, SetVirtualTag round-trip, rejection of File.IO + HttpClient + Process.Start + Reflection.Assembly.Load via ScriptSandboxViolationException, Environment.GetEnvironmentVariable explicitly allowed (pinned compromise), script-exception propagation, ctx.Now reachable, Deadband static reachable, LINQ Where/Sum reachable, DataValueSnapshot usable in scripts including quality branches, compile error carries source location.
Next two PRs within Stream A: A.2 adds the compile cache (source-hash keyed) + per-evaluation timeout wrapper; A.3 wires the dedicated scripts-*.log Serilog rolling sink with structured-property filtering + the companion-warning enricher to the main log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
88 lines
4.3 KiB
C#
88 lines
4.3 KiB
C#
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
|
using Microsoft.CodeAnalysis.Scripting;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
/// <summary>
|
|
/// Factory for the <see cref="ScriptOptions"/> every user script is compiled against.
|
|
/// Implements Phase 7 plan decision #6 (read-only sandbox) by whitelisting only the
|
|
/// assemblies + namespaces the script API needs; no <c>System.IO</c>, no
|
|
/// <c>System.Net</c>, no <c>System.Diagnostics.Process</c>, no
|
|
/// <c>System.Reflection</c>. Attempts to reference those types in a script fail at
|
|
/// compile with a compiler error that points at the exact span — the operator sees
|
|
/// the rejection before publish, not at evaluation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Roslyn's default <see cref="ScriptOptions"/> references <c>mscorlib</c> /
|
|
/// <c>System.Runtime</c> transitively which pulls in every type in the BCL — this
|
|
/// class overrides that with an explicit minimal allow-list.
|
|
/// </para>
|
|
/// <para>
|
|
/// Namespaces pre-imported so scripts don't have to write <c>using</c> clauses:
|
|
/// <c>System</c>, <c>System.Math</c>-style statics are reachable via
|
|
/// <see cref="Math"/>, and <c>ZB.MOM.WW.OtOpcUa.Core.Abstractions</c> so scripts
|
|
/// can name <see cref="DataValueSnapshot"/> directly.
|
|
/// </para>
|
|
/// <para>
|
|
/// The sandbox cannot prevent a script from allocating unbounded memory or
|
|
/// spinning in a tight loop — those are budget concerns, handled by the
|
|
/// per-evaluation timeout (Stream A.4) + the test-harness (Stream F.4) that lets
|
|
/// operators preview output before publishing.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class ScriptSandbox
|
|
{
|
|
/// <summary>
|
|
/// Build the <see cref="ScriptOptions"/> used for every virtual-tag / alarm
|
|
/// script. <paramref name="contextType"/> is the concrete
|
|
/// <see cref="ScriptContext"/> subclass the globals will be of — the compiler
|
|
/// uses its type to resolve <c>ctx.GetTag(...)</c> calls.
|
|
/// </summary>
|
|
public static ScriptOptions Build(Type contextType)
|
|
{
|
|
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
|
|
if (!typeof(ScriptContext).IsAssignableFrom(contextType))
|
|
throw new ArgumentException(
|
|
$"Script context type must derive from {nameof(ScriptContext)}", nameof(contextType));
|
|
|
|
// Allow-listed assemblies — each explicitly chosen. Adding here is a
|
|
// plan-level decision; do not expand casually. HashSet so adding the
|
|
// contextType's assembly is idempotent when it happens to be Core.Scripting
|
|
// already.
|
|
var allowedAssemblies = new HashSet<System.Reflection.Assembly>
|
|
{
|
|
// System.Private.CoreLib — primitives (int, double, bool, string, DateTime,
|
|
// TimeSpan, Math, Convert, nullable<T>). Can't practically script without it.
|
|
typeof(object).Assembly,
|
|
// System.Linq — IEnumerable extensions (Where / Select / Sum / Average / etc.).
|
|
typeof(System.Linq.Enumerable).Assembly,
|
|
// Core.Abstractions — DataValueSnapshot + DriverDataType so scripts can name
|
|
// the types they receive from ctx.GetTag.
|
|
typeof(DataValueSnapshot).Assembly,
|
|
// Core.Scripting itself — ScriptContext base class + Deadband static.
|
|
typeof(ScriptContext).Assembly,
|
|
// Serilog.ILogger — script-side logger type.
|
|
typeof(Serilog.ILogger).Assembly,
|
|
// Concrete context type's assembly — production contexts subclass
|
|
// ScriptContext in Core.VirtualTags / Core.ScriptedAlarms; tests use their
|
|
// own subclass. The globals wrapper is generic on this type so Roslyn must
|
|
// be able to resolve it during compilation.
|
|
contextType.Assembly,
|
|
};
|
|
|
|
var allowedImports = new[]
|
|
{
|
|
"System",
|
|
"System.Linq",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Scripting",
|
|
};
|
|
|
|
return ScriptOptions.Default
|
|
.WithReferences(allowedAssemblies)
|
|
.WithImports(allowedImports);
|
|
}
|
|
}
|