From e4dae01bac4ea4c539cb65d74f01dba490183804 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 16:27:07 -0400 Subject: [PATCH] =?UTF-8?q?Phase=207=20Stream=20A.1=20=E2=80=94=20Core.Scr?= =?UTF-8?q?ipting=20project=20scaffold=20+=20ScriptContext=20+=20sandbox?= =?UTF-8?q?=20+=20AST=20dependency=20extractor.=20First=20of=203=20increme?= =?UTF-8?q?nts=20within=20Stream=20A.=20Ships=20the=20Roslyn-based=20scrip?= =?UTF-8?q?t=20engine's=20foundation:=20user=20C#=20snippets=20compile=20a?= =?UTF-8?q?gainst=20a=20constrained=20ScriptOptions=20allow-list=20+=20get?= =?UTF-8?q?=20a=20post-compile=20sandbox=20guard,=20the=20static=20tag-dep?= =?UTF-8?q?endency=20set=20is=20extracted=20from=20the=20AST=20at=20publis?= =?UTF-8?q?h=20time,=20and=20the=20script=20sees=20a=20stable=20ctx.GetTag?= =?UTF-8?q?/SetVirtualTag/Now/Logger/Deadband=20API=20that=20later=20strea?= =?UTF-8?q?ms=20plug=20into=20concrete=20backends.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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.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) --- ZB.MOM.WW.OtOpcUa.slnx | 2 + .../DependencyExtractor.cs | 137 +++++++++++++ .../ForbiddenTypeAnalyzer.cs | 152 ++++++++++++++ .../ScriptContext.cs | 80 ++++++++ .../ScriptEvaluator.cs | 75 +++++++ .../ScriptGlobals.cs | 19 ++ .../ScriptSandbox.cs | 87 ++++++++ .../ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj | 35 ++++ .../DependencyExtractorTests.cs | 194 ++++++++++++++++++ .../FakeScriptContext.cs | 40 ++++ .../ScriptSandboxTests.cs | 182 ++++++++++++++++ ...MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj | 31 +++ 12 files changed, 1034 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/DependencyExtractorTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/FakeScriptContext.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 4217fd8..2d09e39 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -3,6 +3,7 @@ + @@ -26,6 +27,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs new file mode 100644 index 0000000..34dde7f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs @@ -0,0 +1,137 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Parses a script's source text + extracts every ctx.GetTag("literal") and +/// ctx.SetVirtualTag("literal", ...) call. Outputs the static dependency set +/// the virtual-tag engine uses to build its change-trigger subscription graph (Phase +/// 7 plan decision #7 — AST inference, operator doesn't maintain a separate list). +/// +/// +/// +/// The tag-path argument MUST be a literal string expression. Variables, +/// concatenation, interpolation, and method-returned strings are rejected because +/// the extractor can't statically know what tag they'll resolve to at evaluation +/// time — the dependency graph needs to know every possible input up front. +/// Rejections carry the exact source span so the Admin UI can point at the offending +/// token. +/// +/// +/// Identifier matching is by spelling: the extractor looks for +/// ctx.GetTag(...) / ctx.SetVirtualTag(...) literally. A deliberately +/// misspelled method call (ctx.GetTagz) is not picked up but will also fail +/// to compile against , so there's no way to smuggle a +/// dependency past the extractor while still having a working script. +/// +/// +public static class DependencyExtractor +{ + /// + /// Parse + return the inferred read + write tag + /// paths, or a list of rejection messages if non-literal paths were used. + /// + public static DependencyExtractionResult Extract(string scriptSource) + { + if (string.IsNullOrWhiteSpace(scriptSource)) + return new DependencyExtractionResult( + Reads: new HashSet(StringComparer.Ordinal), + Writes: new HashSet(StringComparer.Ordinal), + Rejections: []); + + var tree = CSharpSyntaxTree.ParseText(scriptSource, options: + new CSharpParseOptions(kind: SourceCodeKind.Script)); + var root = tree.GetRoot(); + + var walker = new Walker(); + walker.Visit(root); + + return new DependencyExtractionResult( + Reads: walker.Reads, + Writes: walker.Writes, + Rejections: walker.Rejections); + } + + private sealed class Walker : CSharpSyntaxWalker + { + private readonly HashSet _reads = new(StringComparer.Ordinal); + private readonly HashSet _writes = new(StringComparer.Ordinal); + private readonly List _rejections = []; + + public IReadOnlySet Reads => _reads; + public IReadOnlySet Writes => _writes; + public IReadOnlyList Rejections => _rejections; + + public override void VisitInvocationExpression(InvocationExpressionSyntax node) + { + // Only interested in member-access form: ctx.GetTag(...) / ctx.SetVirtualTag(...). + // Anything else (free functions, chained calls, static calls) is ignored — but + // still visit children in case a ctx.GetTag call is nested inside. + if (node.Expression is MemberAccessExpressionSyntax member) + { + var methodName = member.Name.Identifier.ValueText; + if (methodName is nameof(ScriptContext.GetTag) or nameof(ScriptContext.SetVirtualTag)) + { + HandleTagCall(node, methodName); + } + } + base.VisitInvocationExpression(node); + } + + private void HandleTagCall(InvocationExpressionSyntax node, string methodName) + { + var args = node.ArgumentList.Arguments; + if (args.Count == 0) + { + _rejections.Add(new DependencyRejection( + Span: node.Span, + Message: $"Call to ctx.{methodName} has no arguments. " + + "The tag path must be the first argument.")); + return; + } + + var pathArg = args[0].Expression; + if (pathArg is not LiteralExpressionSyntax literal + || !literal.Token.IsKind(SyntaxKind.StringLiteralToken)) + { + _rejections.Add(new DependencyRejection( + Span: pathArg.Span, + Message: $"Tag path passed to ctx.{methodName} must be a string literal. " + + $"Dynamic paths (variables, concatenation, interpolation, method " + + $"calls) are rejected at publish so the dependency graph can be " + + $"built statically. Got: {pathArg.Kind()} ({pathArg})")); + return; + } + + var path = (string?)literal.Token.Value ?? string.Empty; + if (string.IsNullOrWhiteSpace(path)) + { + _rejections.Add(new DependencyRejection( + Span: literal.Span, + Message: $"Tag path passed to ctx.{methodName} is empty or whitespace.")); + return; + } + + if (methodName == nameof(ScriptContext.GetTag)) + _reads.Add(path); + else + _writes.Add(path); + } + } +} + +/// Output of . +public sealed record DependencyExtractionResult( + IReadOnlySet Reads, + IReadOnlySet Writes, + IReadOnlyList Rejections) +{ + /// True when no rejections were recorded — safe to publish. + public bool IsValid => Rejections.Count == 0; +} + +/// A single non-literal-path rejection with the exact source span for UI pointing. +public sealed record DependencyRejection(TextSpan Span, string Message); diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs new file mode 100644 index 0000000..be7b1fb --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs @@ -0,0 +1,152 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Post-compile sandbox guard. ScriptOptions alone can't reliably +/// constrain the type surface a script can reach because .NET 10's type-forwarding +/// system resolves many BCL types through multiple assemblies — restricting the +/// reference list doesn't stop System.Net.Http.HttpClient from being found if +/// any transitive reference forwards to System.Net.Http. This analyzer walks +/// the script's syntax tree after compile, uses the to +/// resolve every type / member reference, and rejects any whose containing namespace +/// matches a deny-list pattern. +/// +/// +/// +/// Deny-list is the authoritative Phase 7 plan decision #6 set: +/// System.IO, System.Net, System.Diagnostics.Process, +/// System.Reflection, System.Threading.Thread, +/// System.Runtime.InteropServices. System.Environment (for process +/// env-var read) is explicitly left allowed — it's read-only process state, doesn't +/// persist outside, and the test file pins this compromise so tightening later is +/// a deliberate plan decision. +/// +/// +/// Deny-list prefix match. System.Net catches System.Net.Http, +/// System.Net.Sockets, System.Net.NetworkInformation, etc. — every +/// subnamespace. If a script needs something under a denied prefix, Phase 7's +/// operator audience authors it through a helper the plan team adds as part of +/// the surface, not by unlocking the namespace. +/// +/// +public static class ForbiddenTypeAnalyzer +{ + /// + /// Namespace prefixes scripts are NOT allowed to reference. Each string is + /// matched as a prefix against the resolved symbol's namespace name (dot- + /// delimited), so System.IO catches System.IO.File, + /// System.IO.Pipes, and any future subnamespace without needing explicit + /// enumeration. + /// + public static readonly IReadOnlyList ForbiddenNamespacePrefixes = + [ + "System.IO", + "System.Net", + "System.Diagnostics", // catches Process, ProcessStartInfo, EventLog, Trace/Debug file sinks + "System.Reflection", + "System.Threading.Thread", // raw Thread — Tasks stay allowed (different namespace) + "System.Runtime.InteropServices", + "Microsoft.Win32", // registry + ]; + + /// + /// Scan the for references to forbidden types. + /// Returns empty list when the script is clean; non-empty list means the script + /// must be rejected at publish with the rejections surfaced to the operator. + /// + public static IReadOnlyList Analyze(Compilation compilation) + { + if (compilation is null) throw new ArgumentNullException(nameof(compilation)); + + var rejections = new List(); + foreach (var tree in compilation.SyntaxTrees) + { + var semantic = compilation.GetSemanticModel(tree); + var root = tree.GetRoot(); + foreach (var node in root.DescendantNodes()) + { + switch (node) + { + case ObjectCreationExpressionSyntax obj: + CheckSymbol(semantic.GetSymbolInfo(obj.Type).Symbol, obj.Type.Span, rejections); + break; + case InvocationExpressionSyntax inv when inv.Expression is MemberAccessExpressionSyntax memberAcc: + CheckSymbol(semantic.GetSymbolInfo(memberAcc.Expression).Symbol, memberAcc.Expression.Span, rejections); + CheckSymbol(semantic.GetSymbolInfo(inv).Symbol, inv.Span, rejections); + break; + case MemberAccessExpressionSyntax mem: + // Catches static calls like System.IO.File.ReadAllText(...) — the + // MemberAccess "System.IO.File" resolves to the File type symbol + // whose containing namespace is System.IO, triggering a rejection. + CheckSymbol(semantic.GetSymbolInfo(mem.Expression).Symbol, mem.Expression.Span, rejections); + break; + case IdentifierNameSyntax id when node.Parent is not MemberAccessExpressionSyntax: + CheckSymbol(semantic.GetSymbolInfo(id).Symbol, id.Span, rejections); + break; + } + } + } + return rejections; + } + + private static void CheckSymbol(ISymbol? symbol, TextSpan span, List rejections) + { + if (symbol is null) return; + + var typeSymbol = symbol switch + { + ITypeSymbol t => t, + IMethodSymbol m => m.ContainingType, + IPropertySymbol p => p.ContainingType, + IFieldSymbol f => f.ContainingType, + _ => null, + }; + if (typeSymbol is null) return; + + var ns = typeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty; + foreach (var forbidden in ForbiddenNamespacePrefixes) + { + if (ns == forbidden || ns.StartsWith(forbidden + ".", StringComparison.Ordinal)) + { + rejections.Add(new ForbiddenTypeRejection( + Span: span, + TypeName: typeSymbol.ToDisplayString(), + Namespace: ns, + Message: $"Type '{typeSymbol.ToDisplayString()}' is in the forbidden namespace '{ns}'. " + + $"Scripts cannot reach {forbidden}* per Phase 7 sandbox rules.")); + return; + } + } + } +} + +/// A single forbidden-type reference in a user script. +public sealed record ForbiddenTypeRejection( + TextSpan Span, + string TypeName, + string Namespace, + string Message); + +/// Thrown from when the +/// post-compile forbidden-type analyzer finds references to denied namespaces. +public sealed class ScriptSandboxViolationException : Exception +{ + public IReadOnlyList Rejections { get; } + + public ScriptSandboxViolationException(IReadOnlyList rejections) + : base(BuildMessage(rejections)) + { + Rejections = rejections; + } + + private static string BuildMessage(IReadOnlyList rejections) + { + var lines = rejections.Select(r => $" - {r.Message}"); + return "Script references types outside the Phase 7 sandbox allow-list:\n" + + string.Join("\n", lines); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs new file mode 100644 index 0000000..9cad820 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs @@ -0,0 +1,80 @@ +using Serilog; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// The API user scripts see as the global ctx. Abstract — concrete subclasses +/// (e.g. VirtualTagScriptContext, AlarmScriptContext) plug in the +/// actual tag-backend + logger + virtual-tag writer for each evaluation. Phase 7 plan +/// decision #6: scripts can read any tag, write only to virtual tags, and have no +/// other .NET reach — no HttpClient, no File, no Process, no reflection. +/// +/// +/// +/// Every member on this type MUST be serializable in the narrow sense that +/// can recognize tag-access call sites from the +/// script AST. Method names used from scripts are locked — renaming +/// or is a breaking change for every +/// authored script and the dependency extractor must update in lockstep. +/// +/// +/// New helpers (, ) are additive: adding a +/// method doesn't invalidate existing scripts. Do not remove or rename without a +/// plan-level decision + migration for authored scripts. +/// +/// +public abstract class ScriptContext +{ + /// + /// Read a tag's current value + quality + source timestamp. Path syntax is + /// Enterprise/Site/Area/Line/Equipment/TagName (forward-slash delimited, + /// matching the Equipment-namespace browse tree). Returns a + /// so scripts branch on quality without a second + /// call. + /// + /// + /// MUST be a string literal in the script source — dynamic + /// paths (variables, concatenation, method-returned strings) are rejected at + /// publish by . This is intentional: the static + /// dependency set is required for the change-driven scheduler to subscribe to the + /// right upstream tags at load time. + /// + public abstract DataValueSnapshot GetTag(string path); + + /// + /// Write a value to a virtual tag. Operator scripts cannot write to driver-sourced + /// tags — the OPC UA dispatch in DriverNodeManager rejects that separately + /// per ADR-002 with BadUserAccessDenied. This method is the only write path + /// virtual tags have. + /// + /// + /// Path rules identical to — literal only, dependency + /// extractor tracks the write targets so the engine knows what downstream + /// subscribers to notify. + /// + public abstract void SetVirtualTag(string path, object? value); + + /// + /// Current UTC timestamp. Prefer this over in + /// scripts so the harness can supply a deterministic clock for tests. + /// + public abstract DateTime Now { get; } + + /// + /// Per-script Serilog logger. Output lands in the dedicated scripts-*.log + /// sink with structured property ScriptName = the script's configured name. + /// Use at error level to surface problems; main opcua-*.log receives a + /// companion WARN entry so operators see script errors in the primary log. + /// + public abstract ILogger Logger { get; } + + /// + /// Deadband helper — returns true when differs + /// from by more than . + /// Useful for alarm predicates that shouldn't flicker on small noise. Pure + /// function; no side effects. + /// + public static bool Deadband(double current, double previous, double tolerance) + => Math.Abs(current - previous) > tolerance; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs new file mode 100644 index 0000000..4f406ea --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs @@ -0,0 +1,75 @@ +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Compiles + runs user scripts against a subclass. Core +/// evaluator — no caching, no timeout, no logging side-effects yet (those land in +/// Stream A.3, A.4, A.5 respectively). Stream B + C wrap this with the dependency +/// scheduler + alarm state machine. +/// +/// +/// +/// Scripts are compiled against so the +/// context member is named ctx in the script, matching the +/// 's walker and the Admin UI type stub. +/// +/// +/// Compile pipeline is a three-step gate: (1) Roslyn compile — catches syntax +/// errors + type-resolution failures, throws ; +/// (2) runs against the semantic model — +/// catches sandbox escapes that slipped past reference restrictions due to .NET's +/// type forwarding, throws ; (3) +/// delegate creation — throws at this layer only for internal Roslyn bugs, not +/// user error. +/// +/// +/// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag +/// engine (Stream B) catches them per-tag + maps to BadInternalError +/// quality per Phase 7 decision #11 — this layer doesn't swallow anything so +/// tests can assert on the original exception type. +/// +/// +public sealed class ScriptEvaluator + where TContext : ScriptContext +{ + private readonly ScriptRunner _runner; + + private ScriptEvaluator(ScriptRunner runner) + { + _runner = runner; + } + + public static ScriptEvaluator Compile(string scriptSource) + { + if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource)); + + var options = ScriptSandbox.Build(typeof(TContext)); + var script = CSharpScript.Create( + code: scriptSource, + options: options, + globalsType: typeof(ScriptGlobals)); + + // Step 1 — Roslyn compile. Throws CompilationErrorException on syntax / type errors. + var diagnostics = script.Compile(); + + // Step 2 — forbidden-type semantic analysis. Defense-in-depth against reference-list + // leaks due to type forwarding. + var rejections = ForbiddenTypeAnalyzer.Analyze(script.GetCompilation()); + if (rejections.Count > 0) + throw new ScriptSandboxViolationException(rejections); + + // Step 3 — materialize the callable delegate. + var runner = script.CreateDelegate(); + return new ScriptEvaluator(runner); + } + + /// Run against an already-constructed context. + public Task RunAsync(TContext context, CancellationToken ct = default) + { + if (context is null) throw new ArgumentNullException(nameof(context)); + var globals = new ScriptGlobals { ctx = context }; + return _runner(globals, ct); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs new file mode 100644 index 0000000..71ffb58 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs @@ -0,0 +1,19 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Wraps a as a named field so user scripts see +/// ctx.GetTag(...) instead of the bare GetTag(...) that Roslyn's +/// globalsType convention would produce. Keeps the script ergonomics operators +/// author against consistent with the dependency extractor (which looks for the +/// ctx. prefix) and with the Admin UI hand-written type stub. +/// +/// +/// Generic on so alarm predicates can use a richer +/// context (e.g. with an Alarm property carrying the owning condition's +/// metadata) without affecting virtual-tag contexts. +/// +public class ScriptGlobals + where TContext : ScriptContext +{ + public TContext ctx { get; set; } = default!; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs new file mode 100644 index 0000000..d4a207e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs @@ -0,0 +1,87 @@ +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Factory for the 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 System.IO, no +/// System.Net, no System.Diagnostics.Process, no +/// System.Reflection. 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. +/// +/// +/// +/// Roslyn's default references mscorlib / +/// System.Runtime transitively which pulls in every type in the BCL — this +/// class overrides that with an explicit minimal allow-list. +/// +/// +/// Namespaces pre-imported so scripts don't have to write using clauses: +/// System, System.Math-style statics are reachable via +/// , and ZB.MOM.WW.OtOpcUa.Core.Abstractions so scripts +/// can name directly. +/// +/// +/// 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. +/// +/// +public static class ScriptSandbox +{ + /// + /// Build the used for every virtual-tag / alarm + /// script. is the concrete + /// subclass the globals will be of — the compiler + /// uses its type to resolve ctx.GetTag(...) calls. + /// + 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.Private.CoreLib — primitives (int, double, bool, string, DateTime, + // TimeSpan, Math, Convert, nullable). 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); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj new file mode 100644 index 0000000..5e3f4f9 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Core.Scripting + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/DependencyExtractorTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/DependencyExtractorTests.cs new file mode 100644 index 0000000..4387dc6 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/DependencyExtractorTests.cs @@ -0,0 +1,194 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// Exercises the AST walker that extracts static tag dependencies from user scripts +/// + rejects every form of non-literal path. Locks the parse shape the virtual-tag +/// engine's change-trigger scheduler will depend on (Phase 7 plan Stream A.2). +/// +[Trait("Category", "Unit")] +public sealed class DependencyExtractorTests +{ + [Fact] + public void Extracts_single_literal_read() + { + var result = DependencyExtractor.Extract( + """return ctx.GetTag("Line1/Speed").Value;"""); + + result.IsValid.ShouldBeTrue(); + result.Reads.ShouldContain("Line1/Speed"); + result.Writes.ShouldBeEmpty(); + result.Rejections.ShouldBeEmpty(); + } + + [Fact] + public void Extracts_multiple_distinct_reads() + { + var result = DependencyExtractor.Extract( + """ + var a = ctx.GetTag("Line1/A").Value; + var b = ctx.GetTag("Line1/B").Value; + return (double)a + (double)b; + """); + result.IsValid.ShouldBeTrue(); + result.Reads.Count.ShouldBe(2); + result.Reads.ShouldContain("Line1/A"); + result.Reads.ShouldContain("Line1/B"); + } + + [Fact] + public void Deduplicates_identical_reads_across_the_script() + { + var result = DependencyExtractor.Extract( + """ + if (((double)ctx.GetTag("X").Value) > 0) + return ctx.GetTag("X").Value; + return 0; + """); + result.IsValid.ShouldBeTrue(); + result.Reads.Count.ShouldBe(1); + result.Reads.ShouldContain("X"); + } + + [Fact] + public void Tracks_virtual_tag_writes_separately_from_reads() + { + var result = DependencyExtractor.Extract( + """ + var v = (double)ctx.GetTag("InTag").Value; + ctx.SetVirtualTag("OutTag", v * 2); + return v; + """); + result.IsValid.ShouldBeTrue(); + result.Reads.ShouldContain("InTag"); + result.Writes.ShouldContain("OutTag"); + result.Reads.ShouldNotContain("OutTag"); + result.Writes.ShouldNotContain("InTag"); + } + + [Fact] + public void Rejects_variable_path() + { + var result = DependencyExtractor.Extract( + """ + var path = "Line1/Speed"; + return ctx.GetTag(path).Value; + """); + result.IsValid.ShouldBeFalse(); + result.Rejections.Count.ShouldBe(1); + result.Rejections[0].Message.ShouldContain("string literal"); + } + + [Fact] + public void Rejects_concatenated_path() + { + var result = DependencyExtractor.Extract( + """return ctx.GetTag("Line1/" + "Speed").Value;"""); + result.IsValid.ShouldBeFalse(); + result.Rejections[0].Message.ShouldContain("string literal"); + } + + [Fact] + public void Rejects_interpolated_path() + { + var result = DependencyExtractor.Extract( + """ + var n = 1; + return ctx.GetTag($"Line{n}/Speed").Value; + """); + result.IsValid.ShouldBeFalse(); + result.Rejections[0].Message.ShouldContain("string literal"); + } + + [Fact] + public void Rejects_method_returned_path() + { + var result = DependencyExtractor.Extract( + """ + string BuildPath() => "Line1/Speed"; + return ctx.GetTag(BuildPath()).Value; + """); + result.IsValid.ShouldBeFalse(); + result.Rejections[0].Message.ShouldContain("string literal"); + } + + [Fact] + public void Rejects_empty_literal_path() + { + var result = DependencyExtractor.Extract( + """return ctx.GetTag("").Value;"""); + result.IsValid.ShouldBeFalse(); + result.Rejections[0].Message.ShouldContain("empty"); + } + + [Fact] + public void Rejects_whitespace_only_path() + { + var result = DependencyExtractor.Extract( + """return ctx.GetTag(" ").Value;"""); + result.IsValid.ShouldBeFalse(); + } + + [Fact] + public void Ignores_non_ctx_method_named_GetTag() + { + // Scripts are free to define their own helper called "GetTag" — as long as it's + // not on the ctx instance, the extractor doesn't pick it up. The sandbox + // compile will still reject any path that isn't on the ScriptContext type. + var result = DependencyExtractor.Extract( + """ + string helper_GetTag(string p) => p; + return helper_GetTag("NotATag"); + """); + result.IsValid.ShouldBeTrue(); + result.Reads.ShouldBeEmpty(); + } + + [Fact] + public void Empty_source_is_a_no_op() + { + DependencyExtractor.Extract("").IsValid.ShouldBeTrue(); + DependencyExtractor.Extract(" ").IsValid.ShouldBeTrue(); + DependencyExtractor.Extract(null!).IsValid.ShouldBeTrue(); + } + + [Fact] + public void Rejection_carries_source_span_for_UI_pointing() + { + // Offending path at column 23-29 in the source — Admin UI uses Span to + // underline the exact token. + const string src = """return ctx.GetTag(path).Value;"""; + var result = DependencyExtractor.Extract(src); + result.IsValid.ShouldBeFalse(); + result.Rejections[0].Span.Start.ShouldBeGreaterThan(0); + result.Rejections[0].Span.Length.ShouldBeGreaterThan(0); + } + + [Fact] + public void Multiple_bad_paths_all_reported_in_one_pass() + { + var result = DependencyExtractor.Extract( + """ + var p1 = "A"; var p2 = "B"; + return ctx.GetTag(p1).Value.ToString() + ctx.GetTag(p2).Value.ToString(); + """); + result.IsValid.ShouldBeFalse(); + result.Rejections.Count.ShouldBe(2); + } + + [Fact] + public void Nested_literal_GetTag_inside_expression_is_extracted() + { + // Supports patterns like ctx.GetTag("A") > ctx.GetTag("B") — both literal args + // are captured even when the enclosing expression is complex. + var result = DependencyExtractor.Extract( + """ + return ((double)ctx.GetTag("A").Value) > ((double)ctx.GetTag("B").Value); + """); + result.IsValid.ShouldBeTrue(); + result.Reads.Count.ShouldBe(2); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/FakeScriptContext.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/FakeScriptContext.cs new file mode 100644 index 0000000..94e7f97 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/FakeScriptContext.cs @@ -0,0 +1,40 @@ +using Serilog; +using Serilog.Core; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// In-memory for tests. Holds a tag dictionary + a write +/// log + a deterministic clock. Concrete subclasses in production will wire +/// GetTag/SetVirtualTag through the virtual-tag engine + driver dispatch; here they +/// hit a plain dictionary. +/// +public sealed class FakeScriptContext : ScriptContext +{ + public Dictionary Tags { get; } = new(StringComparer.Ordinal); + public List<(string Path, object? Value)> Writes { get; } = []; + + public override DateTime Now { get; } = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + public override ILogger Logger { get; } = new LoggerConfiguration().CreateLogger(); + + public override DataValueSnapshot GetTag(string path) + { + return Tags.TryGetValue(path, out var v) + ? v + : new DataValueSnapshot(null, 0x80340000u, null, Now); // BadNodeIdUnknown + } + + public override void SetVirtualTag(string path, object? value) + { + Writes.Add((path, value)); + } + + public FakeScriptContext Seed(string path, object? value, + uint statusCode = 0u, DateTime? sourceTs = null) + { + Tags[path] = new DataValueSnapshot(value, statusCode, sourceTs ?? Now, Now); + return this; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxTests.cs new file mode 100644 index 0000000..3ac74f5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxTests.cs @@ -0,0 +1,182 @@ +using Microsoft.CodeAnalysis.Scripting; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// Compiles scripts against the Phase 7 sandbox + asserts every forbidden API +/// (HttpClient / File / Process / reflection) fails at compile, not at evaluation. +/// Locks decision #6 — scripts can't escape to the broader .NET surface. +/// +[Trait("Category", "Unit")] +public sealed class ScriptSandboxTests +{ + [Fact] + public void Happy_path_script_compiles_and_returns() + { + // Baseline — ctx + Math + basic types must work. + var evaluator = ScriptEvaluator.Compile( + """ + var v = (double)ctx.GetTag("X").Value; + return Math.Abs(v) * 2.0; + """); + evaluator.ShouldNotBeNull(); + } + + [Fact] + public async Task Happy_path_script_runs_and_reads_seeded_tag() + { + var evaluator = ScriptEvaluator.Compile( + """return (double)ctx.GetTag("In").Value * 2.0;"""); + + var ctx = new FakeScriptContext().Seed("In", 21.0); + var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken); + result.ShouldBe(42.0); + } + + [Fact] + public async Task SetVirtualTag_records_the_write() + { + var evaluator = ScriptEvaluator.Compile( + """ + ctx.SetVirtualTag("Out", 42); + return 0; + """); + var ctx = new FakeScriptContext(); + await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken); + ctx.Writes.Count.ShouldBe(1); + ctx.Writes[0].Path.ShouldBe("Out"); + ctx.Writes[0].Value.ShouldBe(42); + } + + [Fact] + public void Rejects_File_IO_at_compile() + { + Should.Throw(() => + ScriptEvaluator.Compile( + """return System.IO.File.ReadAllText("c:/secrets.txt");""")); + } + + [Fact] + public void Rejects_HttpClient_at_compile() + { + Should.Throw(() => + ScriptEvaluator.Compile( + """ + var c = new System.Net.Http.HttpClient(); + return 0; + """)); + } + + [Fact] + public void Rejects_Process_Start_at_compile() + { + Should.Throw(() => + ScriptEvaluator.Compile( + """ + System.Diagnostics.Process.Start("cmd.exe"); + return 0; + """)); + } + + [Fact] + public void Rejects_Reflection_Assembly_Load_at_compile() + { + Should.Throw(() => + ScriptEvaluator.Compile( + """ + System.Reflection.Assembly.Load("System.Core"); + return 0; + """)); + } + + [Fact] + public void Rejects_Environment_GetEnvironmentVariable_at_compile() + { + // Environment lives in System.Private.CoreLib (allow-listed for primitives) — + // BUT calling .GetEnvironmentVariable exposes process state we don't want in + // scripts. In an allow-list sandbox this passes because mscorlib is allowed; + // relying on ScriptSandbox alone isn't enough for the Environment class. We + // document here that the CURRENT sandbox allows Environment — acceptable because + // Environment doesn't leak outside the process boundary, doesn't side-effect + // persistent state, and Phase 7 plan decision #6 targets File/Net/Process/ + // reflection specifically. + // + // This test LOCKS that compromise: operators should not be surprised if a + // script reads an env var. If we later decide to tighten, this test flips. + var evaluator = ScriptEvaluator.Compile( + """return System.Environment.GetEnvironmentVariable("PATH");"""); + evaluator.ShouldNotBeNull(); + } + + [Fact] + public async Task Script_exception_propagates_unwrapped() + { + var evaluator = ScriptEvaluator.Compile( + """throw new InvalidOperationException("boom");"""); + await Should.ThrowAsync(async () => + await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken)); + } + + [Fact] + public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock() + { + // Scripts that need a timestamp go through ctx.Now so tests can pin it. + var evaluator = ScriptEvaluator.Compile("""return ctx.Now;"""); + evaluator.ShouldNotBeNull(); + } + + [Fact] + public void Deadband_helper_is_reachable_from_scripts() + { + var evaluator = ScriptEvaluator.Compile( + """return ScriptContext.Deadband(10.5, 10.0, 0.3);"""); + evaluator.ShouldNotBeNull(); + } + + [Fact] + public async Task Linq_Enumerable_is_available_from_scripts() + { + // LINQ is in the allow-list because SCADA math frequently wants Sum / Average + // / Where. Confirm it works. + var evaluator = ScriptEvaluator.Compile( + """ + var nums = new[] { 1, 2, 3, 4, 5 }; + return nums.Where(n => n > 2).Sum(); + """); + var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken); + result.ShouldBe(12); + } + + [Fact] + public async Task DataValueSnapshot_is_usable_in_scripts() + { + // ctx.GetTag returns DataValueSnapshot so scripts branch on quality. + var evaluator = ScriptEvaluator.Compile( + """ + var v = ctx.GetTag("T"); + return v.StatusCode == 0; + """); + var ctx = new FakeScriptContext().Seed("T", 5.0); + var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken); + result.ShouldBeTrue(); + } + + [Fact] + public void Compile_error_gives_location_in_diagnostics() + { + // Compile errors must carry the source span so the Admin UI can point at them. + try + { + ScriptEvaluator.Compile("""return fooBarBaz + 1;"""); + Assert.Fail("expected CompilationErrorException"); + } + catch (CompilationErrorException ex) + { + ex.Diagnostics.ShouldNotBeEmpty(); + ex.Diagnostics[0].Location.ShouldNotBeNull(); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj new file mode 100644 index 0000000..6fd1f4c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + +