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
+
+
+
+
+
+
+
+
+
+
+
+
+