fix(central-ui): resolve CentralUI-001 — enforce script trust model before sandbox execution

ScriptAnalysisService.RunInSandboxAsync compiled and executed arbitrary
user C# in the central host process with no trust-model enforcement — the
forbidden-API set was only a Monaco editor diagnostic. A Design-role user
could run System.IO/Process/Reflection/network code on the central node.

Added a Roslyn semantic gate (EnforceTrustModel) invoked after compilation
and before script.RunAsync, and on nested shared scripts in callSharedFunc;
a script referencing any forbidden API is rejected before it runs.

Reworked FindForbiddenApiUsages: it now resolves every identifier against
the semantic model and checks types and members, so a fully-qualified call
(System.IO.File.WriteAllText) is caught — the pre-fix check only inspected
the leftmost identifier and missed that shape. This is a static semantic
gate, not a process sandbox.

Adds gate regression tests that fail against the pre-fix code, plus a
clean-script test guarding against over-blocking.
This commit is contained in:
Joseph Doherty
2026-05-16 18:41:12 -04:00
parent a9ceba00d0
commit a9bd7ee37c
4 changed files with 186 additions and 23 deletions

View File

@@ -220,6 +220,20 @@ public class ScriptAnalysisService
SandboxErrorKind.CompileError, 0, markers);
}
// Trust-model gate (CentralUI-001): the documented forbidden-API set is
// enforced HERE, before execution — not merely surfaced as an editor hint.
// Without this, a Design-role user could run arbitrary file/process/
// reflection/network code in the central host process.
var trustViolations = EnforceTrustModel(script.GetCompilation());
if (trustViolations.Count > 0)
{
return new SandboxRunResult(false, null, null, "",
"Script blocked by the trust model — it references forbidden APIs "
+ "(System.IO, System.Diagnostics, System.Reflection, System.Net, threading). "
+ "See the highlighted diagnostics.",
SandboxErrorKind.CompileError, 0, trustViolations);
}
var parameters = ConvertJsonParameters(request.Parameters);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
@@ -311,6 +325,13 @@ public class ScriptAnalysisService
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{name}\") compile failed: {string.Join("; ", nestedErrors.Select(d => d.GetMessage()))}");
// Trust-model gate (CentralUI-001) — a nested shared script runs
// arbitrary code too, so it must clear the same forbidden-API gate.
if (EnforceTrustModel(built.GetCompilation()).Count > 0)
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{name}\") is blocked by the script trust model — "
+ "the shared script references forbidden APIs.");
lock (compileCacheLock)
{
if (!compileCache.TryGetValue(name, out compiled))
@@ -1086,15 +1107,25 @@ public class ScriptAnalysisService
return new(AttributeContextKind.None, null);
}
/// <summary>
/// Finds every reference to a forbidden API — the documented script trust model,
/// see <see cref="ForbiddenNamespacePrefixes"/>. Identifiers are resolved against
/// the semantic model, so a forbidden type or member is caught however it is
/// written: bare (<c>File</c>), fully qualified
/// (<c>System.IO.File.WriteAllText</c>), or via an alias — while a user identifier
/// that merely shares a name with a forbidden type (<c>var File = …</c>) does not
/// false-positive. Used both for editor diagnostics and as the pre-execution
/// trust-model gate (see <see cref="EnforceTrustModel"/>).
/// </summary>
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
{
var root = tree.GetRoot();
// Banned using directives — pure namespace string match is fine here.
// Banned using directives.
foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
{
var name = u.Name?.ToString() ?? "";
if (ForbiddenNamespacePrefixes.Any(p => name == p || name.StartsWith(p + ".")))
if (IsForbiddenName(name))
{
var span = u.GetLocation().GetLineSpan().Span;
yield return new DiagnosticMarker(
@@ -1108,20 +1139,14 @@ public class ScriptAnalysisService
}
}
// Banned type usages — resolved via the semantic model so a user
// identifier named "File" or "Thread" does NOT trigger the diagnostic
// unless it actually resolves to a forbidden type.
// Banned type / member references, resolved via the semantic model. Every
// identifier is checked — including the right-hand side of a member access —
// so a fully-qualified forbidden call (System.IO.File.WriteAllText) cannot
// slip past by avoiding a `using` directive or a bare type name.
foreach (var ident in root.DescendantNodes().OfType<IdentifierNameSyntax>())
{
// Skip the identifier on the right side of a member access — only
// the leftmost (the type or qualifier) is what we want to check.
if (ident.Parent is MemberAccessExpressionSyntax m && m.Name == ident) continue;
var symbol = model.GetSymbolInfo(ident).Symbol;
if (symbol is not INamedTypeSymbol type) continue;
var ns = type.ContainingNamespace?.ToDisplayString() ?? "";
if (!ForbiddenNamespacePrefixes.Any(p => ns == p || ns.StartsWith(p + "."))) continue;
var forbidden = ForbiddenNameFor(model.GetSymbolInfo(ident).Symbol);
if (forbidden == null) continue;
var span = ident.GetLocation().GetLineSpan().Span;
yield return new DiagnosticMarker(
@@ -1130,11 +1155,75 @@ public class ScriptAnalysisService
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Type '{type.Name}' from forbidden namespace '{ns}' is not allowed in scripts.",
Message: $"'{ident.Identifier.ValueText}' resolves to forbidden API '{forbidden}', " +
"which is not allowed in scripts (script trust model).",
Code: "SCADA002");
}
}
/// <summary>
/// The forbidden namespace/type a symbol implicates, or null if it is allowed.
/// Checks the symbol's namespace and — for a type or member — the type's full
/// name, so an entry like <c>System.Threading.Thread</c> bans that exact type
/// while <c>System.Threading</c> (e.g. <c>CancellationToken</c>) stays allowed.
/// </summary>
private static string? ForbiddenNameFor(ISymbol? symbol)
{
if (symbol == null) return null;
foreach (var name in QualifiedNamesOf(symbol))
if (IsForbiddenName(name))
return name;
return null;
}
/// <summary>Fully-qualified names a symbol reference implicates for trust-model checking.</summary>
private static IEnumerable<string> QualifiedNamesOf(ISymbol symbol)
{
switch (symbol)
{
case INamespaceSymbol { IsGlobalNamespace: false } ns:
yield return ns.ToDisplayString();
break;
case ITypeSymbol type:
if (type.ContainingNamespace is { IsGlobalNamespace: false } tn)
yield return tn.ToDisplayString();
yield return FullTypeName(type);
break;
default:
if (symbol.ContainingType is { } ct)
{
if (ct.ContainingNamespace is { IsGlobalNamespace: false } cn)
yield return cn.ToDisplayString();
yield return FullTypeName(ct);
}
break;
}
}
private static string FullTypeName(ITypeSymbol type) =>
type.ContainingNamespace is { IsGlobalNamespace: false } ns
? ns.ToDisplayString() + "." + type.Name
: type.Name;
private static bool IsForbiddenName(string qualifiedName) =>
ForbiddenNamespacePrefixes.Any(p =>
qualifiedName == p || qualifiedName.StartsWith(p + ".", StringComparison.Ordinal));
/// <summary>
/// Pre-execution trust-model gate (CentralUI-001). Returns the forbidden-API
/// markers (SCADA001/SCADA002) for a compiled script; an empty list means the
/// script is clear to run. This is a static semantic gate, not a process
/// sandbox — reflection-based indirection is still out of its reach; full
/// isolation would require running scripts in a separate constrained process.
/// </summary>
private static IReadOnlyList<DiagnosticMarker> EnforceTrustModel(Compilation compilation)
{
var tree = compilation.SyntaxTrees.FirstOrDefault();
if (tree == null) return Array.Empty<DiagnosticMarker>();
var model = compilation.GetSemanticModel(tree);
return FindForbiddenApiUsages(tree, model).ToList();
}
private static CompletionItem ToCompletionItem(ISymbol symbol)
{
var kind = symbol.Kind switch