diff --git a/CLAUDE.md b/CLAUDE.md
index a9b9c1ee..9f1aaa39 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -395,7 +395,7 @@ lists `ScriptedAlarm` — never by default) in `ZB.MOM.WW.OtOpcUa.ScriptWorker`
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess`, protocol in `Core.Scripting.Protocol`), shipped in `script-worker/`
beside the host (self-contained + started via its apphost on a `-r win-x64 --self-contained` publish, through the muxer otherwise); a runner-side failure publishes `BadResourceUnavailable` (ScadaBridge-visible, ScadaBridge#112) and the seam re-evaluates
when the worker is back. `IScriptRunner.RunBatch` sends ready evaluations as one frame per worker (Calc pump, VT coalescer,
-alarm dirty set); a run queued behind busy worker threads has its deadlines restarted when it starts, so it is never blamed for a sibling. Optional `Scripting:Worker:PeCacheDir` = HMAC'd compiled-PE cache (restart without Roslyn); the `script-runner`
+alarm dirty set); a run queued behind busy worker threads has its deadlines restarted when it starts, so it is never blamed for a sibling. Optional `Scripting:Worker:PeCacheDir` = HMAC'd compiled-PE cache (restart without Roslyn); optional (Windows) `Scripting:Worker:Identity` = `NT SERVICE\OtOpcUaScriptWorker` runs workers under a restricted token, set up by `scripts/install/Install-ScriptWorkerIsolation.ps1` (ACLs + outbound firewall block; `docs/ServiceHosting.md`); the `script-runner`
readiness check is at worst Degraded (never fails ready). The worker tests
are `tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests` (`Category=ScriptWorker`, real processes; `Category=Perf` harness opt-in via
`OTOPCUA_SCRIPT_PERF=1`). docker-dev overlay: `docker-dev/docker-compose.script-runner.yml`. See `docs/VirtualTags.md`
diff --git a/docs/ServiceHosting.md b/docs/ServiceHosting.md
index 06263ec5..4ba5f987 100644
--- a/docs/ServiceHosting.md
+++ b/docs/ServiceHosting.md
@@ -101,13 +101,30 @@ or one inside the content root) on a directory only the service account can read
is at worst Degraded. A self-contained host publish (`dotnet publish … -r win-x64 --self-contained`, the Windows installs, which have no
.NET runtime) ships a **self-contained** worker in `script-worker/` (~97 MB: it carries its own runtime) and the host starts its
`ZB.MOM.WW.OtOpcUa.ScriptWorker.exe` apphost directly; a framework-dependent host (docker-dev) runs the worker's dll through the
-dotnet muxer. The worker currently runs under the host's account; the dedicated low-privilege account,
-pipe ACL and firewall outbound rule (design §14 #5/#6) are installer work still open on lmxopcua#780. See
-`docs/VirtualTags.md` §"Out-of-process runner".
+dotnet muxer. See `docs/VirtualTags.md` §"Out-of-process runner".
+
+### Worker isolation on Windows (`Install-ScriptWorkerIsolation.ps1`, design §14 #5/#6)
+
+`scripts/install/Install-ScriptWorkerIsolation.ps1 -InstallRoot
-HostServiceAccount [-ConfigureHostService]`
+(idempotent; `-WhatIf` prints every change; `-Uninstall` rolls back; `-PlanOnly` prints the plan on any OS) sets up:
+
+| Step | Change |
+|---|---|
+| Worker identity | A **disabled** anchor service `OtOpcUaScriptWorker` running as `NT SERVICE\OtOpcUaScriptWorker`, so the virtual account exists. It is never started. |
+| `Scripting:Worker:Identity` | With `NT SERVICE\OtOpcUaScriptWorker` set (by `-ConfigureHostService`, or in appsettings), the host starts every worker under a **restricted token** derived from its own: every privilege but `SeChangeNotifyPrivilege` removed, and restricting SIDs = the identity + `Everyone` + `BUILTIN\Users` + `RESTRICTED`. Every access the worker makes must pass for the host account **and** for that set. The token's default DACL is host + SYSTEM + identity; the worker is created suspended and put in its Job Object before it runs; `TEMP`/`TMP` point at its per-launch working directory (ACL: host + SYSTEM full, identity Modify); `DOTNET_EnableDiagnostics=0`. The host also adds the identity to its own window station and desktop DACL (enumerate/read, and create-window on the desktop), once per process. USER32 connects to them in its DllMain and the runtime imports USER32, so without the grant `coreclr.dll` fails to load (`ERROR_DLL_INIT_FAILED`, measured on windev). The grant lives on the session's kernel objects and ends with the session; nothing persistent changes. The logon SID, which those objects normally grant, is deliberately not a restricting SID: the host's process DACL gives it `PROCESS_VM_READ`. Windows cannot start an ad-hoc child process *as* a virtual account (only the SCM can, for a service), and a separate local user would need a stored password plus `SeAssignPrimaryTokenPrivilege` on the host account; a restricted copy of the host's own token needs neither. Startup refuses the key off Windows or when malformed. |
+| Pipe ACL | On Windows the worker pipe always carries an explicit, protected DACL: the host account and (with an identity) the worker identity, read/write, nobody else. No SYSTEM, no Administrators, no Everyone. The owner is the host token's owner, so the worker's `CurrentUserOnly` check of the server end still holds. |
+| `PeCacheDir` | Created with a protected ACL: SYSTEM, Administrators and the host account full control, no Users. `pe\` grants the identity Modify (workers compile and write entries). The HMAC key and `workers\` markers are host-only. |
+| Install folder | The identity is **denied** ReadData on `` (inherited), so the worker cannot read `appsettings*.json`, logs or host binaries even where `Users` can. `script-worker\` grants it read + execute. |
+| Firewall | Outbound **block** rule `OtOpcUa-ScriptWorker-BlockOutbound` on `script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe`, every profile. It covers the self-contained apphost the Windows installs run. A framework-dependent worker runs under `dotnet.exe` and is not covered, so there the analyzer's `System.Net` ban is the only network control. |
+
+The plan is pure data asserted by `scripts/install/tests/Test-ScriptWorkerIsolation.ps1` (`pwsh -File …`, any OS).
+Re-run the installer after a deploy that replaces the install folder: a rename/swap drops the folder ACEs, but files
+copied into the existing folder inherit them. Rollback: `-Uninstall -ConfigureHostService`, then restart the host.
## Install / Uninstall
- `scripts/install/Install-Services.ps1 -Roles admin,driver` — installs `OtOpcUaHost`.
+- `scripts/install/Install-ScriptWorkerIsolation.ps1` — the ScriptWorker identity, ACLs and firewall rule (above); only for `Scripting:Runner` ≠ `InProcess`.
- `scripts/install/Uninstall-Services.ps1` — stops + removes the host service. (The historian backend is the external HistorianGateway — not installed/removed by these scripts.)
## Logging
diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md
index be47d254..5c66d01a 100644
--- a/docs/VirtualTags.md
+++ b/docs/VirtualTags.md
@@ -71,7 +71,7 @@ processes:
The runner **fails closed**: no worker means Bad, never an in-process run.
- **Containment.** The worker gets a cleared, allow-listed environment (no `ServerHistorian__ApiKey`, no
connection strings), an empty temp working directory, `DOTNET_GCHeapHardLimit = Scripting:Worker:HeapLimitBytes`
- (512 MiB), a Windows Job Object (kill-on-close, one process, a commit backstop), a random current-user-only pipe
+ (512 MiB), a Windows Job Object (kill-on-close, one process, a commit backstop), a random pipe (current-user-only on Linux; an explicit host + worker-identity ACL on Windows)
with a launch nonce in its environment, and no Akka / gRPC assemblies in its folder. It exits on pipe end of stream
or when its parent pid disappears. Results cross as protobuf `Variant`s carrying the exact CLR type, so
`(short)ctx.GetTag(x).Value` behaves as in process; an input or result type with no tag (a `List`, a `Guid`)
@@ -121,8 +121,12 @@ processes:
runtime's reference assemblies on disk), and the launcher starts its apphost `ZB.MOM.WW.OtOpcUa.ScriptWorker.exe`
directly: `OutOfProcessScriptRunner.ResolveWorkerEntry` picks the apphost only when the worker's `runtimeconfig.json` lists
`includedFrameworks`. Gated on windev against a real `-r win-x64` publish (`OTOPCUA_SCRIPTWORKER_TEST_PATH` points the
- worker suite at the published apphost). Not yet done (lmxopcua#780): the low-privilege worker account, pipe ACL, firewall
- rule and PeCacheDir ACL (installer).
+ worker suite at the published apphost).
+- **Windows isolation** (`Scripting:Worker:Identity`, `scripts/install/Install-ScriptWorkerIsolation.ps1`): the worker runs
+ under a restricted, privilege-free token for the virtual account `NT SERVICE\OtOpcUaScriptWorker`. The pipe ACL admits
+ the host and that identity only. The install folder is denied to it, `PeCacheDir` is host-only apart from `pe\`, and an
+ outbound firewall block covers the worker exe. See `docs/ServiceHosting.md` §"Worker isolation on Windows". The live
+ install on QA is a deploy gate (lmxopcua#780).
- **docker-dev.** `docker-dev/docker-compose.script-runner.yml` turns `OutOfProcess` on for the central pair's
VirtualTag and Calculation seams. The 2026-11-10 live drill (runaway kill and quarantine, `kill -9` recovery, open circuit
with readiness still 200) is recorded in the design doc's rollout section.
diff --git a/docs/plans/2026-11-09-out-of-process-script-runner-design.md b/docs/plans/2026-11-09-out-of-process-script-runner-design.md
index 571a75c3..234aefca 100644
--- a/docs/plans/2026-11-09-out-of-process-script-runner-design.md
+++ b/docs/plans/2026-11-09-out-of-process-script-runner-design.md
@@ -647,6 +647,7 @@ per-shard state, restarts in the last hour and the quarantine list. Quarantines
| `Scripting:Worker:QuarantineAfterKills` | `2` | §5.3 |
| `Scripting:Worker:UnavailableGrace` | `5s` | §9 |
| `Scripting:Worker:PeCacheDir` | none (required when not `InProcess`) | §7; absolute, outside the content root, fail-closed |
+| `Scripting:Worker:Identity` | none (host's token) | Windows only: `NT SERVICE\OtOpcUaScriptWorker` → restricted worker token + pipe ACL (§14 #5, record §12.5) |
**Phases** (each one is its own issue and PR):
@@ -1196,6 +1197,56 @@ one running the in-flight predicate, which is what the step needed.)
the state the rig was already in (most likely from the earlier drill's store loss), not something this drill did.
- The drill image `otopcua-host:w34-shadow` was removed afterwards. The site nodes, sql and traefik were not touched.
+### 12.5 Installer record: worker identity, pipe ACL, firewall, PeCacheDir ACL (2026-11-10)
+
+Owner decisions §14 #5 and #6, as built. Operator reference: `docs/ServiceHosting.md` §"Worker isolation on Windows".
+
+- **Identity, deviating in mechanism from "run as a virtual account".** Windows starts a process as `NT SERVICE\` only
+ when the SCM starts that service. A host cannot launch K + 1 children that way, each with its own nonce, pipe and Job
+ Object. A separate local user would need a stored password plus `SeAssignPrimaryTokenPrivilege` on the host account.
+ `Scripting:Worker:Identity` (default unset; Windows only; startup refuses it elsewhere or when malformed) therefore starts
+ each worker under a **restricted token** derived from the host's (`RestrictedWorkerLauncher`):
+ - `DISABLE_MAX_PRIVILEGE`, so only `SeChangeNotifyPrivilege` is left;
+ - restricting SIDs = the virtual account's SID (computed from the name, `S-1-5-80-…`) + Everyone + Users + RESTRICTED;
+ - token and default DACL = host + SYSTEM + identity + RESTRICTED;
+ - created suspended and put in its Job Object before it runs, inheriting exactly its two stdio pipes;
+ - `TEMP` set to its ACL'd work directory, and `DOTNET_EnableDiagnostics=0`.
+
+ The installer registers a disabled anchor service, so the account exists and resolves by name.
+- **Found on windev.** The runtime would not start under the restricted token (`coreclr.dll` → `ERROR_DLL_INIT_FAILED`). A
+ bisection over the token's groups showed that only the user or Administrators SID fixed it, not the logon SID. The cause
+ was USER32's window station and desktop connection, so the host now grants the identity on its own window station and
+ desktop. The logon SID stays out of the restricting set because the host's process DACL gives it `PROCESS_VM_READ`. The
+ token's own DACL also needed rewriting (the handle needs `WRITE_DAC`).
+- **Pipe ACL.** On Windows the pipe always has an explicit, protected DACL: the host account and the identity, read/write,
+ nothing else. .NET's `CurrentUserOnly` grants the token *owner*, which is Administrators on an elevated host; this ACL
+ does not. The owner is still the token owner, so the worker's `CurrentUserOnly` client check is unchanged.
+- **Installer** `scripts/install/Install-ScriptWorkerIsolation.ps1` + `ScriptWorkerIsolation.psm1`. It is idempotent, with
+ `-WhatIf`, `-Uninstall` and `-PlanOnly`, and it sets up:
+ - the anchor service;
+ - `PeCacheDir`: protected, SYSTEM / Administrators / host full control, no Users, and the key and markers host-only;
+ - `pe\`: the identity gets Modify;
+ - `script-worker\`: the identity gets read+execute;
+ - **deny** ReadData for the identity on the install folder (appsettings, logs, host binaries);
+ - outbound **block** firewall rule `OtOpcUa-ScriptWorker-BlockOutbound` on the worker apphost;
+ - optional host-service env (`-ConfigureHostService`).
+
+ The runner flip itself is not part of it.
+- **Tests.**
+ - `scripts/install/tests/Test-ScriptWorkerIsolation.ps1` is the dry-run harness. It runs on any OS and asserts ids,
+ order, targets, SDDL and the rollback (46 assertions). A mutation check (deny → allow, protected → inherited) turns
+ 4 red.
+ - `ScriptWorkerIdentityTests` (Core.Scripting) pin the SID algorithm against `NT SERVICE\TrustedInstaller`. The
+ PowerShell harness pins the same default SID.
+ - `WorkerIsolationTests` (ScriptWorker) cover the pipe ACL (built and live), the token, and a real worker under the
+ identity running a script with a restricted token. A plain worker's token is unrestricted (positive control).
+ - The token case is restricted, privilege-free, and impersonated: it reads System32 and writes its work dir, but
+ cannot read or write the host's temp.
+ - They ran green on windev without changing machine state, apart from the session's window station / desktop ACE.
+ No account, firewall rule or persistent ACL was created there.
+- **Not verified live:** the installer on a real node. That is the QA deploy gate on lmxopcua#780's follow-up (see
+ that issue for the checklist).
+
---
## 13. Test plan
diff --git a/scripts/install/Install-ScriptWorkerIsolation.ps1 b/scripts/install/Install-ScriptWorkerIsolation.ps1
new file mode 100644
index 00000000..6f46f311
--- /dev/null
+++ b/scripts/install/Install-ScriptWorkerIsolation.ps1
@@ -0,0 +1,142 @@
+<#
+.SYNOPSIS
+ Isolates the OtOpcUa ScriptWorker processes on a Windows node (lmxopcua#780, design §6, owner decisions §14 #5/#6):
+ the worker's virtual-account identity, the PeCacheDir ACL, an outbound firewall block on the worker executable and
+ a deny on the install folder. Idempotent; -WhatIf prints every change; -Uninstall rolls it back.
+
+.DESCRIPTION
+ Only needed when the node runs Scripting:Runner = OutOfProcess or Shadow. It changes, in order:
+
+ 1. AnchorService a DISABLED service '' whose account is NT SERVICE\,
+ so that virtual account exists and resolves by name. It is never started: the host launches
+ every worker itself, under a restricted token whose restricting SIDs are that account +
+ Everyone + BUILTIN\Users + RESTRICTED, with every privilege removed
+ (Scripting:Worker:Identity; RestrictedWorkerLauncher.cs). Each access check the worker
+ makes must pass for the host account AND that set.
+ 2. PeCacheDir created; ACL replaced: protected, owner Administrators, SYSTEM + Administrators + the host
+ account full control. No Users, no worker access (the HMAC key and launch markers live here).
+ 3. PeEntriesDir \pe, the worker identity gets Modify (workers compile and write HMAC'd entries).
+ 4. WorkerFolderAcl \script-worker, the worker identity gets read + execute.
+ 5. InstallRootDeny , the worker identity is DENIED ReadData/ListDirectory (inherited), so the
+ worker cannot read appsettings*.json, the logs or the host binaries even where Users can.
+ 6. FirewallBlockOutbound a Windows Firewall rule blocking all outbound traffic from
+ \script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe (every profile). The
+ self-contained Windows publish starts the worker through that apphost; a framework-dependent
+ worker (dotnet.exe) is NOT covered — the analyzer's System.Net ban is then the only control.
+ 7. HostServiceEnv (-ConfigureHostService only) Scripting__Worker__Identity and Scripting__Worker__PeCacheDir
+ in the OtOpcUaHost service environment. Scripting:Runner itself is NOT set here: the
+ runner flip is a separate, per-tier decision (design §12, P2/P3).
+
+ Re-run it after any deploy that REPLACES the install folder (a rename/swap drops the folder ACEs; files copied into
+ an existing folder inherit them). The host must be restarted for -ConfigureHostService to take effect.
+
+.PARAMETER InstallRoot
+ The host install folder (holds OtOpcUa.Host.exe and script-worker\).
+
+.PARAMETER HostServiceAccount
+ The OtOpcUaHost service account: DOMAIN\name, NT SERVICE\name, LocalSystem, or a SID. Gets full control of PeCacheDir.
+
+.PARAMETER PeCacheDir
+ The compiled-PE cache (Scripting:Worker:PeCacheDir). Absolute and outside InstallRoot (the host refuses otherwise).
+ Default: $env:ProgramData\OtOpcUa\script-pe-cache.
+
+.PARAMETER WorkerServiceName
+ The worker identity's virtual account name. Default OtOpcUaScriptWorker (Scripting:Worker:Identity =
+ 'NT SERVICE\OtOpcUaScriptWorker').
+
+.PARAMETER ConfigureHostService
+ Also write the two Scripting__Worker__* entries into the host service's environment (or remove them with -Uninstall).
+
+.PARAMETER Uninstall
+ Roll everything back: the firewall rule, every explicit ACE for the worker identity, the anchor service, and (with
+ -ConfigureHostService) the env entries. The PE cache is kept unless -RemovePeCache.
+
+.PARAMETER PlanOnly
+ Print the plan and return it as objects without touching (or even reading) the machine. Runs on any OS; used by the
+ dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1. -HostServiceAccount must then be a SID.
+
+.EXAMPLE
+ .\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -WhatIf
+
+.EXAMPLE
+ .\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService
+
+.EXAMPLE
+ .\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService -Uninstall
+#>
+[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
+param(
+ [Parameter(Mandatory)] [string]$InstallRoot,
+ [Parameter(Mandatory)] [string]$HostServiceAccount,
+ [string]$PeCacheDir,
+ [ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker',
+ [string]$HostServiceName = 'OtOpcUaHost',
+ [switch]$ConfigureHostService,
+ [switch]$Uninstall,
+ [switch]$RemovePeCache,
+ [switch]$PlanOnly
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+Import-Module (Join-Path $PSScriptRoot 'ScriptWorkerIsolation.psm1') -Force
+
+if (-not $PeCacheDir) {
+ $programData = if ($env:ProgramData) { $env:ProgramData } else { 'C:\ProgramData' }
+ $PeCacheDir = "$($programData.TrimEnd('\'))\OtOpcUa\script-pe-cache"
+}
+
+function Resolve-HostSid([string]$Account) {
+ if ($Account -match '^[Ss]-1-\d+(-\d+)+$') { return $Account.ToUpperInvariant() }
+ if ($PlanOnly) { throw "-PlanOnly needs -HostServiceAccount as a SID (no account lookup without the OS)" }
+ if ($Account -in 'LocalSystem', '.\LocalSystem', 'NT AUTHORITY\SYSTEM') { return 'S-1-5-18' }
+ if ($Account -match '^NT SERVICE\\(.+)$') { return (Get-OtOpcUaServiceSid -Name $Matches[1]) }
+ $name = $Account -replace '^\.\\', "$env:COMPUTERNAME\"
+ ([System.Security.Principal.NTAccount]::new($name)).Translate([System.Security.Principal.SecurityIdentifier]).Value
+}
+
+$hostSid = Resolve-HostSid $HostServiceAccount
+$plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSid -PeCacheDir $PeCacheDir `
+ -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName `
+ -ConfigureHostService:$ConfigureHostService -Uninstall:$Uninstall -RemovePeCache:$RemovePeCache
+
+$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
+Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4})" -f `
+ $(if ($Uninstall) { 'ROLLBACK' } else { 'install' }), $WorkerServiceName, $workerSid, $HostServiceAccount, $hostSid)
+
+if ($PlanOnly) {
+ foreach ($step in $plan) { Write-Host (" [plan] {0,-22} {1}" -f $step.Id, $step.Change) }
+ return $plan
+}
+
+if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { throw 'Install-ScriptWorkerIsolation.ps1 applies changes on Windows only (use -PlanOnly elsewhere)' }
+if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 'script-worker'))) {
+ throw "$InstallRoot\script-worker not found - publish the Host (it carries script-worker\) before isolating it"
+}
+$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
+if (-not $WhatIfPreference -and -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'run elevated (services, firewall rules and folder ACLs need an administrator)'
+}
+
+$results = foreach ($step in $plan) {
+ if ($PSCmdlet.ShouldProcess($step.Target, "$($step.Id): $($step.Change)")) {
+ $outcome = Invoke-ScriptWorkerIsolationStep -Step $step
+ Write-Host (" [{0,-9}] {1,-22} {2}" -f $outcome, $step.Id, $step.Target)
+ [pscustomobject]@{ Id = $step.Id; Target = $step.Target; Outcome = $outcome }
+ }
+}
+
+if (-not $WhatIfPreference) {
+ Write-Host ''
+ if ($Uninstall) {
+ Write-Host 'Rolled back. If the host still sets Scripting:Worker:Identity (appsettings or env), unset it and restart the host.'
+ }
+ else {
+ Write-Host 'Done. Host configuration for this node:'
+ Write-Host " Scripting:Worker:Identity = NT SERVICE\$WorkerServiceName"
+ Write-Host " Scripting:Worker:PeCacheDir = $PeCacheDir"
+ if (-not $ConfigureHostService) { Write-Host ' (not written: re-run with -ConfigureHostService, or set them in appsettings)' }
+ Write-Host ' Restart OtOpcUaHost for a configuration change to take effect.'
+ }
+}
+$results
diff --git a/scripts/install/ScriptWorkerIsolation.psm1 b/scripts/install/ScriptWorkerIsolation.psm1
new file mode 100644
index 00000000..2ff6622d
--- /dev/null
+++ b/scripts/install/ScriptWorkerIsolation.psm1
@@ -0,0 +1,312 @@
+<#
+ ScriptWorker isolation (lmxopcua#780, design §6 + owner decisions §14 #5/#6, §7 PeCacheDir ACL).
+
+ Pure planning functions (no OS calls — the dry-run harness runs them on any OS) plus the Windows apply step.
+ Install-ScriptWorkerIsolation.ps1 is the entry point; see its help.
+
+ The worker identity is the virtual account NT SERVICE\. Its SID is computed from the name
+ (Get-OtOpcUaServiceSid, the algorithm behind `sc.exe showsid`), and the host starts each worker with a restricted
+ token whose restricting SIDs are that identity + Everyone + BUILTIN\Users + RESTRICTED (ScriptWorkerIdentity.cs,
+ RestrictedWorkerLauncher.cs). Every access check the worker makes must therefore pass for the host account AND for
+ that set — which is why the ACLs below grant or deny the worker identity specifically.
+#>
+
+Set-StrictMode -Version Latest
+
+# Access masks (FileSystemRights) used in the planned ACEs.
+$script:FullControl = 0x1F01FF
+$script:Modify = 0x1301BF # Modify | Synchronize
+$script:ReadAndExecute = 0x1200A9 # ReadAndExecute | Synchronize
+$script:ReadData = 0x000001 # ReadData (files) / ListDirectory (folders)
+
+$script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
+$script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
+$script:EnvIdentity = 'Scripting__Worker__Identity'
+$script:EnvPeCacheDir = 'Scripting__Worker__PeCacheDir'
+
+function Get-OtOpcUaServiceSid {
+ <#
+ .SYNOPSIS
+ The SID of the virtual account NT SERVICE\: S-1-5-80- + SHA-1 of the upper-cased name (UTF-16LE) read as
+ five little-endian UInt32 values. Identical to `sc.exe showsid ` and to ScriptWorkerIdentity.ServiceSid.
+ #>
+ [CmdletBinding()]
+ param([Parameter(Mandatory)] [ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$Name)
+ $bytes = [System.Text.Encoding]::Unicode.GetBytes($Name.ToUpperInvariant())
+ $sha1 = [System.Security.Cryptography.SHA1]::Create()
+ try { $hash = $sha1.ComputeHash($bytes) } finally { $sha1.Dispose() }
+ $parts = for ($i = 0; $i -lt 5; $i++) { [BitConverter]::ToUInt32($hash, $i * 4) }
+ 'S-1-5-80-' + ($parts -join '-')
+}
+
+function Format-OtOpcUaAce {
+ # One SDDL ACE string: (A|D;flags;0xMASK;;;SID).
+ param([ValidateSet('A', 'D')] [string]$Type, [string]$Flags, [int]$Mask, [string]$Sid)
+ '({0};{1};0x{2:x};;;{3})' -f $Type, $Flags, $Mask, $Sid
+}
+
+function Get-OtOpcUaPeCacheSddl {
+ <#
+ .SYNOPSIS
+ The PeCacheDir root's security descriptor: owner Administrators, PROTECTED (no inherited Users/Everyone/
+ Authenticated Users), SYSTEM + Administrators + the host account full control (container + object inherit).
+ The worker identity gets NOTHING here — the HMAC key (pe-cache.key) and the launch markers (workers\) stay
+ host-only; the worker's one writable place is the pe\ subfolder (Get-OtOpcUaPeEntriesAce).
+ #>
+ param([Parameter(Mandatory)] [string]$HostSid)
+ $aces = @(
+ (Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid 'SY')
+ (Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid 'BA')
+ )
+ if ($HostSid -ne 'S-1-5-18') { $aces += Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid $HostSid }
+ 'O:BAG:SYD:PAI' + ($aces -join '')
+}
+
+function Get-OtOpcUaPeEntriesAce {
+ # pe\ : the worker compiles in-process (owner decision §14 #7) and writes HMAC'd entries here — Modify, inherited.
+ param([Parameter(Mandatory)] [string]$WorkerSid)
+ Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:Modify -Sid $WorkerSid
+}
+
+function Get-OtOpcUaScriptWorkerFolderAce {
+ # script-worker\ : the worker's own binaries — read and execute, inherited.
+ param([Parameter(Mandatory)] [string]$WorkerSid)
+ Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:ReadAndExecute -Sid $WorkerSid
+}
+
+function Get-OtOpcUaInstallRootDenyAce {
+ <#
+ .SYNOPSIS
+ InstallRoot\ : DENY the worker identity ReadData/ListDirectory, inherited — so appsettings*.json, the logs and
+ the host binaries are unreadable to the worker even where BUILTIN\Users (one of its restricting SIDs) can read
+ them (Program Files grants Users read). script-worker\'s explicit allow is the nearer ACE and wins below it.
+ #>
+ param([Parameter(Mandatory)] [string]$WorkerSid)
+ Format-OtOpcUaAce -Type D -Flags 'OICI' -Mask $script:ReadData -Sid $WorkerSid
+}
+
+function New-OtOpcUaPlanStep {
+ param([string]$Id, [string]$Target, [string]$Change, [hashtable]$Data = @{})
+ [pscustomobject]@{ Id = $Id; Target = $Target; Change = $Change; Data = $Data }
+}
+
+function Get-ScriptWorkerIsolationPlan {
+ <#
+ .SYNOPSIS
+ Every change Install-ScriptWorkerIsolation.ps1 makes, in order, as data. Pure: no OS call, so the dry-run harness
+ (scripts/install/tests/Test-ScriptWorkerIsolation.ps1) asserts it on any OS and -WhatIf prints it verbatim.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)] [string]$InstallRoot,
+ [Parameter(Mandatory)] [string]$HostSid,
+ [Parameter(Mandatory)] [string]$PeCacheDir,
+ [string]$WorkerServiceName = 'OtOpcUaScriptWorker',
+ [string]$HostServiceName = 'OtOpcUaHost',
+ [switch]$ConfigureHostService,
+ [switch]$Uninstall,
+ [switch]$RemovePeCache
+ )
+ # Windows paths, planned on any OS (the harness runs on macOS/Linux too): drive-rooted or UNC only.
+ foreach ($p in @{ InstallRoot = $InstallRoot; PeCacheDir = $PeCacheDir }.GetEnumerator()) {
+ if ($p.Value -notmatch '^([A-Za-z]:\\|\\\\[^\\]+\\)') { throw "$($p.Key) '$($p.Value)' must be an absolute Windows path" }
+ }
+ $root = $InstallRoot.TrimEnd('\')
+ $cache = $PeCacheDir.TrimEnd('\')
+ if ($cache -ieq $root -or $cache.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase)) {
+ throw "PeCacheDir '$PeCacheDir' is inside InstallRoot '$InstallRoot'; the host refuses a cache inside its content root"
+ }
+ $workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
+ $identity = "NT SERVICE\$WorkerServiceName"
+ $workerFolder = "$root\script-worker"
+ $workerExe = "$workerFolder\$($script:WorkerExeName)"
+ $entries = "$cache\pe"
+ $steps = [System.Collections.Generic.List[object]]::new()
+
+ if (-not $Uninstall) {
+ $steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName `
+ "register the disabled anchor service '$WorkerServiceName' so the virtual account '$identity' ($workerSid) resolves by name (never started)" `
+ @{ Name = $WorkerServiceName; Identity = $identity; BinPath = $workerExe }))
+ $steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache `
+ "create the PE cache directory and set its ACL (protected; SYSTEM, Administrators, host account full control; no Users; no worker access to the key or markers)" `
+ @{ Path = $cache; Sddl = (Get-OtOpcUaPeCacheSddl -HostSid $HostSid) }))
+ $steps.Add((New-OtOpcUaPlanStep 'PeEntriesDir' $entries `
+ "create the PE entries directory and grant the worker identity Modify (it compiles and writes HMAC'd entries)" `
+ @{ Path = $entries; Ace = (Get-OtOpcUaPeEntriesAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:Modify; Type = 'Allow' }))
+ $steps.Add((New-OtOpcUaPlanStep 'WorkerFolderAcl' $workerFolder `
+ "grant the worker identity read+execute on its own binaries" `
+ @{ Path = $workerFolder; Ace = (Get-OtOpcUaScriptWorkerFolderAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:ReadAndExecute; Type = 'Allow' }))
+ $steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root `
+ "deny the worker identity ReadData/ListDirectory on the install folder (appsettings, logs, host binaries); script-worker\ stays readable" `
+ @{ Path = $root; Ace = (Get-OtOpcUaInstallRootDenyAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:ReadData; Type = 'Deny' }))
+ $steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName `
+ "add a Windows Firewall rule blocking ALL outbound traffic from '$workerExe' (every profile)" `
+ @{ Name = $script:FirewallRuleName; Program = $workerExe }))
+ if ($ConfigureHostService) {
+ $steps.Add((New-OtOpcUaPlanStep 'HostServiceEnv' $HostServiceName `
+ "set $($script:EnvIdentity)=$identity and $($script:EnvPeCacheDir)=$cache in the '$HostServiceName' service environment (other entries kept; takes effect on the next service start)" `
+ @{ Service = $HostServiceName; Set = [ordered]@{ $script:EnvIdentity = $identity; $script:EnvPeCacheDir = $cache } }))
+ }
+ }
+ else {
+ if ($ConfigureHostService) {
+ $steps.Add((New-OtOpcUaPlanStep 'HostServiceEnv' $HostServiceName `
+ "remove $($script:EnvIdentity) and $($script:EnvPeCacheDir) from the '$HostServiceName' service environment (workers revert to the host's token on the next start)" `
+ @{ Service = $HostServiceName; Remove = @($script:EnvIdentity, $script:EnvPeCacheDir) }))
+ }
+ $steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName `
+ "remove the Windows Firewall rule '$($script:FirewallRuleName)'" @{ Name = $script:FirewallRuleName; Remove = $true }))
+ $steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root `
+ "remove every explicit ACE for the worker identity from the install folder" @{ Path = $root; Sid = $workerSid; Remove = $true }))
+ $steps.Add((New-OtOpcUaPlanStep 'WorkerFolderAcl' $workerFolder `
+ "remove every explicit ACE for the worker identity from script-worker\" @{ Path = $workerFolder; Sid = $workerSid; Remove = $true }))
+ if ($RemovePeCache) {
+ $steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache `
+ "delete the PE cache directory (HMAC key, entries, launch markers; the host recreates a fresh cache if still configured)" `
+ @{ Path = $cache; Remove = $true }))
+ }
+ else {
+ $steps.Add((New-OtOpcUaPlanStep 'PeEntriesDir' $entries `
+ "remove every explicit ACE for the worker identity from the PE entries directory (the cache itself is kept; -RemovePeCache deletes it)" `
+ @{ Path = $entries; Sid = $workerSid; Remove = $true }))
+ }
+ $steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName `
+ "delete the anchor service '$WorkerServiceName'" @{ Name = $WorkerServiceName; Remove = $true }))
+ }
+ $steps
+}
+
+# ---------------------------------------------------------------------------------------------------------------------
+# Apply (Windows only). Each function is idempotent: it reads the current state, changes only what differs, and
+# returns 'changed' or 'unchanged'. Callers gate every call with ShouldProcess, so -WhatIf runs none of them.
+# ---------------------------------------------------------------------------------------------------------------------
+
+function Set-OtOpcUaExplicitAce {
+ # Ensures exactly one explicit ACE for $Sid on $Path with the given type/mask/inheritance (or none when $Remove).
+ param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove)
+ if (-not (Test-Path -LiteralPath $Path)) {
+ if ($Remove) { return 'unchanged' }
+ throw "$Path does not exist"
+ }
+ $item = Get-Item -LiteralPath $Path
+ $acl = $item.GetAccessControl('Access')
+ $identity = [System.Security.Principal.SecurityIdentifier]::new($Sid)
+ $explicit = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
+ Where-Object { $_.IdentityReference -eq $identity })
+ $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
+ $wanted = if ($Remove) { @() } else {
+ , [System.Security.AccessControl.FileSystemAccessRule]::new($identity, [System.Security.AccessControl.FileSystemRights]$Mask,
+ $inherit, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]$Type)
+ }
+ $same = ($explicit.Count -eq $wanted.Count) -and (
+ $wanted.Count -eq 0 -or (
+ [int]$explicit[0].FileSystemRights -eq $Mask -and $explicit[0].AccessControlType -eq $wanted[0].AccessControlType -and
+ $explicit[0].InheritanceFlags -eq $inherit -and $explicit[0].PropagationFlags -eq 'None'))
+ if ($same) { return 'unchanged' }
+ $acl.PurgeAccessRules($identity)
+ foreach ($rule in $wanted) { $acl.AddAccessRule($rule) }
+ $item.SetAccessControl($acl)
+ 'changed'
+}
+
+function Set-OtOpcUaStepAce {
+ param([hashtable]$Data)
+ if ($Data.ContainsKey('Remove')) { return (Set-OtOpcUaExplicitAce -Path $Data.Path -Sid $Data.Sid -Remove) }
+ Set-OtOpcUaExplicitAce -Path $Data.Path -Sid $Data.Sid -Mask $Data.Mask -Type $Data.Type
+}
+
+function Invoke-ScriptWorkerIsolationStep {
+ <#
+ .SYNOPSIS
+ Applies one plan step on Windows. Idempotent; returns 'changed' or 'unchanged'.
+ #>
+ [CmdletBinding()]
+ param([Parameter(Mandatory)] $Step)
+ $d = $Step.Data
+ switch ($Step.Id) {
+ 'AnchorService' {
+ $svc = Get-Service -Name $d.Name -ErrorAction SilentlyContinue
+ if ($d.ContainsKey('Remove')) {
+ if (-not $svc) { return 'unchanged' }
+ & sc.exe delete $d.Name | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw "sc.exe delete $($d.Name) failed ($LASTEXITCODE)" }
+ return 'changed'
+ }
+ $verb = if ($svc) { 'config' } else { 'create' }
+ $cfg = Get-CimInstance Win32_Service -Filter "Name='$($d.Name)'" -ErrorAction SilentlyContinue
+ if ($cfg -and $cfg.StartMode -eq 'Disabled' -and $cfg.StartName -ieq $d.Identity -and $cfg.PathName -eq "`"$($d.BinPath)`"") {
+ return 'unchanged'
+ }
+ & sc.exe $verb $d.Name binPath= "`"$($d.BinPath)`"" start= disabled obj= $d.Identity `
+ DisplayName= 'OtOpcUa ScriptWorker identity (anchor, never started)' | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw "sc.exe $verb $($d.Name) failed ($LASTEXITCODE)" }
+ & sc.exe sidtype $d.Name unrestricted | Out-Null
+ & sc.exe description $d.Name 'Anchors the NT SERVICE virtual account OtOpcUa ScriptWorker processes are restricted to (lmxopcua#780). Never started: the OtOpcUaHost launches workers itself under a restricted token.' | Out-Null
+ return 'changed'
+ }
+ 'PeCacheDir' {
+ if ($d.ContainsKey('Remove')) {
+ if (-not (Test-Path -LiteralPath $d.Path)) { return 'unchanged' }
+ Remove-Item -LiteralPath $d.Path -Recurse -Force
+ return 'changed'
+ }
+ $changed = $false
+ if (-not (Test-Path -LiteralPath $d.Path)) { New-Item -ItemType Directory -Path $d.Path -Force | Out-Null; $changed = $true }
+ $item = Get-Item -LiteralPath $d.Path
+ $current = $item.GetAccessControl('Access, Owner').GetSecurityDescriptorSddlForm('Access, Owner')
+ $desired = [System.Security.AccessControl.DirectorySecurity]::new()
+ $desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner')
+ if ($current -ne $desired.GetSecurityDescriptorSddlForm('Access, Owner')) {
+ $item.SetAccessControl($desired) # propagates to the key file, pe\ and workers\ (inherited ACEs)
+ $changed = $true
+ }
+ return $(if ($changed) { 'changed' } else { 'unchanged' })
+ }
+ 'PeEntriesDir' {
+ if (-not $d.ContainsKey('Remove') -and -not (Test-Path -LiteralPath $d.Path)) {
+ New-Item -ItemType Directory -Path $d.Path -Force | Out-Null
+ }
+ return (Set-OtOpcUaStepAce -Data $d)
+ }
+ { $_ -in 'WorkerFolderAcl', 'InstallRootDeny' } {
+ return (Set-OtOpcUaStepAce -Data $d)
+ }
+ 'FirewallBlockOutbound' {
+ $rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
+ if ($d.ContainsKey('Remove')) {
+ if (-not $rule) { return 'unchanged' }
+ Remove-NetFirewallRule -Name $d.Name
+ return 'changed'
+ }
+ if ($rule) {
+ $app = ($rule | Get-NetFirewallApplicationFilter).Program
+ if ($rule.Direction -eq 'Outbound' -and $rule.Action -eq 'Block' -and $rule.Enabled -eq 'True' -and
+ $rule.Profile -eq 'Any' -and $app -ieq $d.Program) { return 'unchanged' }
+ Remove-NetFirewallRule -Name $d.Name
+ }
+ New-NetFirewallRule -Name $d.Name -DisplayName 'OtOpcUa ScriptWorker - block outbound' `
+ -Description 'lmxopcua#780 design §6: OtOpcUa script workers never need the network; scripts are also denied System.Net at compile time.' `
+ -Direction Outbound -Action Block -Program $d.Program -Profile Any -Enabled True | Out-Null
+ return 'changed'
+ }
+ 'HostServiceEnv' {
+ $key = "HKLM:\SYSTEM\CurrentControlSet\Services\$($d.Service)"
+ if (-not (Test-Path $key)) {
+ if ($d.ContainsKey('Remove')) { return 'unchanged' }
+ throw "service '$($d.Service)' is not installed (run Install-Services.ps1 first)"
+ }
+ $current = @((Get-ItemProperty -Path $key -Name Environment -ErrorAction SilentlyContinue).Environment | Where-Object { $_ })
+ $names = if ($d.ContainsKey('Remove')) { $d.Remove } else { @($d.Set.Keys) }
+ $next = @($current | Where-Object { $n = ($_ -split '=', 2)[0]; $names -notcontains $n })
+ if (-not $d.ContainsKey('Remove')) { foreach ($k in $d.Set.Keys) { $next += "$k=$($d.Set[$k])" } }
+ if ((@($current) -join "`n") -eq (@($next) -join "`n")) { return 'unchanged' }
+ Set-ItemProperty -Path $key -Name Environment -Type MultiString -Value ([string[]]$next)
+ return 'changed'
+ }
+ default { throw "unknown plan step '$($Step.Id)'" }
+ }
+}
+
+Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce,
+ Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Get-ScriptWorkerIsolationPlan,
+ Invoke-ScriptWorkerIsolationStep
diff --git a/scripts/install/tests/Test-ScriptWorkerIsolation.ps1 b/scripts/install/tests/Test-ScriptWorkerIsolation.ps1
new file mode 100644
index 00000000..0b2e655a
--- /dev/null
+++ b/scripts/install/tests/Test-ScriptWorkerIsolation.ps1
@@ -0,0 +1,127 @@
+<#
+.SYNOPSIS
+ Dry-run harness for Install-ScriptWorkerIsolation.ps1 (lmxopcua#780). Asserts the planned changes — ids, order,
+ targets, SDDL and ACEs — WITHOUT touching the machine, on any OS with PowerShell 7 (`pwsh`). The repo has no Pester;
+ this is a plain script that exits non-zero on the first failed group.
+
+.EXAMPLE
+ pwsh -NoProfile -File scripts/install/tests/Test-ScriptWorkerIsolation.ps1
+#>
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$installDir = Split-Path -Parent $here
+Import-Module (Join-Path $installDir 'ScriptWorkerIsolation.psm1') -Force
+
+$script:failures = 0
+$script:passes = 0
+function Assert([bool]$Condition, [string]$Message) {
+ if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red }
+}
+function Assert-Throws([scriptblock]$Block, [string]$Like, [string]$Message) {
+ try { & $Block; Assert $false "$Message (did not throw)" }
+ catch { Assert ($_.Exception.Message -like $Like) "$Message (threw '$($_.Exception.Message)')" }
+}
+
+$hostSid = 'S-1-5-21-1111111111-2222222222-3333333333-1001'
+$workerSid = 'S-1-5-80-1846135827-3903057317-1998105452-3938448374-3023753135' # NT SERVICE\OtOpcUaScriptWorker
+$root = 'C:\Program Files\OtOpcUa'
+$cache = 'C:\ProgramData\OtOpcUa\script-pe-cache'
+
+Write-Host 'Service SID algorithm'
+Assert ((Get-OtOpcUaServiceSid -Name 'TrustedInstaller') -eq 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') `
+ 'NT SERVICE\TrustedInstaller matches the well-known SID'
+Assert ((Get-OtOpcUaServiceSid -Name 'OtOpcUaScriptWorker') -eq $workerSid) 'OtOpcUaScriptWorker SID (pinned; ScriptWorkerIdentityTests pins the same value)'
+Assert ((Get-OtOpcUaServiceSid -Name 'otopcuascriptworker') -eq $workerSid) 'the name is case-insensitive'
+Assert-Throws { Get-OtOpcUaServiceSid -Name 'bad name' } '*' 'a name with a space is refused'
+
+Write-Host 'Install plan'
+$plan = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService)
+$ids = $plan.Id -join ','
+Assert ($ids -eq 'AnchorService,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)"
+$byId = @{}; foreach ($s in $plan) { $byId[$s.Id] = $s }
+
+$anchor = $byId['AnchorService'].Data
+Assert ($anchor.Identity -eq 'NT SERVICE\OtOpcUaScriptWorker') 'anchor service runs as its own virtual account'
+Assert ($anchor.BinPath -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "anchor binPath ($($anchor.BinPath))"
+Assert ($byId['AnchorService'].Change -like '*disabled*never started*') 'anchor is described as disabled and never started'
+
+$sddl = $byId['PeCacheDir'].Data.Sddl
+Assert ($sddl -eq "O:BAG:SYD:PAI(A;OICI;0x1f01ff;;;SY)(A;OICI;0x1f01ff;;;BA)(A;OICI;0x1f01ff;;;$hostSid)") "PeCacheDir SDDL ($sddl)"
+Assert ($sddl.StartsWith('O:BAG:SYD:PAI')) 'PeCacheDir DACL is protected (no inherited Users/Everyone)'
+foreach ($forbidden in 'BU', 'WD', 'AU', 'S-1-5-32-545', 'S-1-1-0', 'S-1-5-11', $workerSid) {
+ Assert (-not $sddl.Contains(";;;$forbidden)")) "PeCacheDir root grants nothing to $forbidden"
+}
+$sysSddl = Get-OtOpcUaPeCacheSddl -HostSid 'S-1-5-18'
+Assert ($sysSddl -eq 'O:BAG:SYD:PAI(A;OICI;0x1f01ff;;;SY)(A;OICI;0x1f01ff;;;BA)') "a LocalSystem host gets no duplicate ACE ($sysSddl)"
+
+$pe = $byId['PeEntriesDir'].Data
+Assert ($pe.Path -eq "$cache\pe" -or $pe.Path -eq "$cache/pe") "entries dir is \pe ($($pe.Path))"
+Assert ($pe.Ace -eq "(A;OICI;0x1301bf;;;$workerSid)") "worker gets Modify on pe\ ($($pe.Ace))"
+Assert ($pe.Type -eq 'Allow' -and $pe.Mask -eq 0x1301BF -and $pe.Sid -eq $workerSid) 'pe\ ACE data matches its SDDL'
+
+$wf = $byId['WorkerFolderAcl'].Data
+Assert ($wf.Ace -eq "(A;OICI;0x1200a9;;;$workerSid)") "worker gets read+execute on script-worker\ ($($wf.Ace))"
+Assert ($wf.Path -like '*\OtOpcUa\script-worker' -or $wf.Path -like '*/script-worker') "worker folder path ($($wf.Path))"
+
+$deny = $byId['InstallRootDeny'].Data
+Assert ($deny.Ace -eq "(D;OICI;0x1;;;$workerSid)") "install root DENIES the worker ReadData ($($deny.Ace))"
+Assert ($deny.Type -eq 'Deny' -and $deny.Path -eq $root) 'install-root ACE is a deny on the root'
+
+$fw = $byId['FirewallBlockOutbound'].Data
+Assert ($fw.Program -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "firewall rule program ($($fw.Program))"
+Assert ($byId['FirewallBlockOutbound'].Change -like '*blocking ALL outbound*') 'firewall rule blocks outbound'
+
+$envSet = $byId['HostServiceEnv'].Data.Set
+Assert ($envSet['Scripting__Worker__Identity'] -eq 'NT SERVICE\OtOpcUaScriptWorker') 'env: Scripting__Worker__Identity'
+Assert ($envSet['Scripting__Worker__PeCacheDir'] -eq $cache) 'env: Scripting__Worker__PeCacheDir'
+Assert (-not ($envSet.Keys -contains 'Scripting__Runner')) 'the runner flip is NOT part of the installer'
+
+$noEnv = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache)
+Assert (-not ($noEnv.Id -contains 'HostServiceEnv')) 'without -ConfigureHostService the host service is untouched'
+
+Write-Host 'Path rules (positive and negative controls)'
+Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir "$root\cache" } '*inside InstallRoot*' 'a cache inside the install root is refused'
+Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $root } '*inside InstallRoot*' 'the install root itself is refused'
+Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'relative\cache' } '*absolute*' 'a relative cache is refused'
+$sibling = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'C:\Program Files\OtOpcUa-cache')
+Assert ($sibling.Count -eq 6) 'a sibling folder sharing the prefix is NOT inside the root (positive control)'
+
+Write-Host 'Uninstall plan'
+$un = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService -Uninstall)
+$unIds = $un.Id -join ','
+Assert ($unIds -eq 'HostServiceEnv,FirewallBlockOutbound,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,AnchorService') "rollback order ($unIds)"
+Assert (@($un | Where-Object { -not ($_.Data.ContainsKey('Remove')) }).Count -eq 0) 'every rollback step removes'
+Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -ne 'PeCacheDir' } | Sort-Object) -join ',')) `
+ 'rollback reverses every install step but the cache (kept by default)'
+Assert (($un | Where-Object Id -eq 'HostServiceEnv').Data.Remove -contains 'Scripting__Worker__Identity') 'rollback removes the identity env entry'
+foreach ($s in $un | Where-Object { $_.Id -in 'InstallRootDeny', 'WorkerFolderAcl', 'PeEntriesDir' }) {
+ Assert ($s.Data.Sid -eq $workerSid) "rollback $($s.Id) targets only the worker identity's ACEs"
+}
+$unAll = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -Uninstall -RemovePeCache)
+Assert ($unAll.Id -contains 'PeCacheDir' -and -not ($unAll.Id -contains 'PeEntriesDir')) '-RemovePeCache deletes the cache instead of editing it'
+
+Write-Host 'Entry script -PlanOnly (end to end, no machine access)'
+$entry = Join-Path $installDir 'Install-ScriptWorkerIsolation.ps1'
+$viaScript = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PeCacheDir $cache -ConfigureHostService -PlanOnly 6>$null)
+Assert (($viaScript.Id -join ',') -eq $ids) "-PlanOnly returns the module's plan ($($viaScript.Id -join ','))"
+Assert-Throws { & $entry -InstallRoot $root -HostServiceAccount 'OTOPCUA\svc' -PeCacheDir $cache -PlanOnly 6>$null } '*needs -HostServiceAccount as a SID*' `
+ '-PlanOnly refuses an account name (no lookup without the OS)'
+$defaultCache = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PlanOnly 6>$null)
+Assert ((($defaultCache | Where-Object Id -eq 'PeCacheDir').Data.Path) -like '*OtOpcUa*script-pe-cache') 'default PeCacheDir is under ProgramData\OtOpcUa'
+
+if ($IsWindows) {
+ Write-Host 'SDDL parses on Windows'
+ $ds = [System.Security.AccessControl.DirectorySecurity]::new()
+ $ds.SetSecurityDescriptorSddlForm($sddl)
+ Assert ($ds.AreAccessRulesProtected) 'parsed PeCacheDir descriptor is protected'
+ foreach ($ace in $pe.Ace, $wf.Ace, $deny.Ace) {
+ $one = [System.Security.AccessControl.DirectorySecurity]::new()
+ $one.SetSecurityDescriptorSddlForm("D:$ace")
+ Assert (@($one.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])).Count -eq 1) "ACE $ace parses"
+ }
+}
+
+Write-Host ''
+Write-Host "$($script:passes) passed, $($script:failures) failed"
+if ($script:failures -gt 0) { exit 1 }
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/OutOfProcessScriptRunner.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/OutOfProcessScriptRunner.cs
index 1892febc..4497440c 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/OutOfProcessScriptRunner.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/OutOfProcessScriptRunner.cs
@@ -165,6 +165,13 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
_scriptRoot = scriptRoot ?? _log;
LoadTimeout = loadTimeout;
WorkerPath = ResolveWorkerPath(_worker);
+ // Fail closed (startup also refuses it, ScriptRunnerSelection.Validate): a configured identity that cannot be
+ // applied must never silently fall back to the host's own token.
+ if (_worker.ValidateIdentity(OperatingSystem.IsWindows()) is { } identityViolation)
+ throw new InvalidOperationException($"Scripting misconfiguration: {identityViolation}");
+ WorkerSid = string.IsNullOrWhiteSpace(_worker.Identity)
+ ? null
+ : ScriptWorkerIdentity.TryResolveSid(_worker.Identity, out var sid, out _) ? sid : null;
// The PE cache is best effort: a directory or key that cannot be used is a Warning and a cache-less runner,
// never a startup failure (the path RULE is enforced at startup by ScriptRunnerSelection.Validate).
@@ -211,6 +218,9 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
/// The worker entry assembly this runner launches.
public string WorkerPath { get; }
+ /// The worker identity's SID (Scripting:Worker:Identity), or null when workers keep the host's token.
+ public string? WorkerSid { get; }
+
/// The overall state (the readiness check, the state gauge).
public ScriptRunnerState State => ComputeState(_shards.Select(s => s.DownFor).ToArray(), _isolation.DownFor, _worker.CircuitOpenAfter);
@@ -693,7 +703,8 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
PeCacheKey: _peCacheKey,
MarkerDirectory: _markerDirectory,
LoadTimeout: LoadTimeout,
- CompileThreads: _compileThreads);
+ CompileThreads: _compileThreads,
+ WorkerSid: WorkerSid);
return new WorkerShard(label, isolation,
(onExiting, ct) => WorkerConnection.StartAsync(spec, _log, OnLogs, onExiting, ct),
OnWorkerExit, _log);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/RestrictedWorkerLauncher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/RestrictedWorkerLauncher.cs
new file mode 100644
index 00000000..965b009f
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/RestrictedWorkerLauncher.cs
@@ -0,0 +1,506 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO.Pipes;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Security.AccessControl;
+using System.Security.Principal;
+using System.Text;
+using Microsoft.Win32.SafeHandles;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
+
+///
+/// Starts a ScriptWorker under a restricted token for Scripting:Worker:Identity (lmxopcua#780 design §6, owner
+/// decision §14 #5; see for why a restricted token and not a logon). The launch:
+///
+/// - derives a primary token from the host's with DISABLE_MAX_PRIVILEGE and the restricting SIDs
+/// () — no password, no SeAssignPrimaryTokenPrivilege
+/// (a restricted copy of the caller's own token does not need it);
+/// - sets the token's default DACL, and the token object's own DACL, to the host account, SYSTEM, the worker
+/// identity and RESTRICTED, so the worker can open its own token and process, the worker's
+/// process object stays openable by the host (kill, exit code, the Job Object) and objects the worker creates are
+/// reachable by both;
+/// - creates the process suspended with CreateProcessAsUserW, inheriting exactly its stdout and
+/// stderr pipe ends (PROC_THREAD_ATTRIBUTE_HANDLE_LIST), with the allow-listed environment block;
+/// - assigns it to the worker's Job Object before its first instruction runs, then resumes it.
+///
+///
+[SupportedOSPlatform("windows")]
+internal static class RestrictedWorkerLauncher
+{
+ /// A started worker: its process, its job and the two stdio readers.
+ internal sealed class Launched : IDisposable
+ {
+ private readonly AnonymousPipeServerStream _stdout;
+ private readonly AnonymousPipeServerStream _stderr;
+
+ internal Launched(Process process, WindowsJobObject job, AnonymousPipeServerStream stdout, AnonymousPipeServerStream stderr)
+ {
+ Process = process;
+ Job = job;
+ _stdout = stdout;
+ _stderr = stderr;
+ }
+
+ /// The worker process (opened by pid while the creation handle was still held, so it cannot be a reused pid).
+ public Process Process { get; }
+
+ /// The worker's Job Object (kill on close).
+ public WindowsJobObject Job { get; }
+
+ /// Closes the stdio readers (the process and job are owned by the caller).
+ public void Dispose()
+ {
+ _stdout.Dispose();
+ _stderr.Dispose();
+ }
+ }
+
+ /// Starts under a restricted token for .
+ /// What to run: file name, argument list, working directory and the final environment.
+ /// The worker identity's SID string.
+ /// The Job Object's per-process commit limit.
+ /// Receives each stdout line.
+ /// Receives each stderr line.
+ /// The started worker.
+ public static Launched Start(ProcessStartInfo psi, string workerSid, long processMemoryLimitBytes, Action onStdout,
+ Action onStderr)
+ {
+ ArgumentNullException.ThrowIfNull(psi);
+ GrantSessionObjects(workerSid);
+ using var token = CreateWorkerToken(workerSid);
+ var stdout = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
+ var stderr = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
+ WindowsJobObject? job = null;
+ PROCESS_INFORMATION pi = default;
+ var attributeList = IntPtr.Zero;
+ var handleList = IntPtr.Zero;
+ var environment = IntPtr.Zero;
+ try
+ {
+ var inherit = new[] { stdout.ClientSafePipeHandle.DangerousGetHandle(), stderr.ClientSafePipeHandle.DangerousGetHandle() };
+ handleList = Marshal.AllocHGlobal(IntPtr.Size * inherit.Length);
+ Marshal.Copy(inherit, 0, handleList, inherit.Length);
+
+ var size = IntPtr.Zero;
+ InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size);
+ attributeList = Marshal.AllocHGlobal(size);
+ if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref size))
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "InitializeProcThreadAttributeList failed");
+ if (!UpdateProcThreadAttribute(attributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handleList,
+ (IntPtr)(IntPtr.Size * inherit.Length), IntPtr.Zero, IntPtr.Zero))
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "UpdateProcThreadAttribute(HANDLE_LIST) failed");
+
+ var si = new STARTUPINFOEX
+ {
+ StartupInfo = new STARTUPINFO
+ {
+ cb = Marshal.SizeOf(),
+ dwFlags = STARTF_USESTDHANDLES,
+ hStdInput = IntPtr.Zero,
+ hStdOutput = inherit[0],
+ hStdError = inherit[1],
+ },
+ lpAttributeList = attributeList,
+ };
+ var commandLine = new StringBuilder(WindowsProcessText.BuildCommandLine(psi.FileName, psi.ArgumentList));
+ environment = Marshal.StringToHGlobalUni(WindowsProcessText.BuildEnvironmentBlock(psi.Environment));
+ const uint flags = CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT;
+ if (!CreateProcessAsUserW(token, psi.FileName, commandLine, IntPtr.Zero, IntPtr.Zero, bInheritHandles: true, flags,
+ environment, string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory, ref si, out pi))
+ throw new Win32Exception(Marshal.GetLastWin32Error(), $"CreateProcessAsUser for '{psi.FileName}' failed");
+
+ // The child owns its ends now.
+ stdout.DisposeLocalCopyOfClientHandle();
+ stderr.DisposeLocalCopyOfClientHandle();
+
+ // Into the job before it runs a single instruction: the kill-on-close and process-count limits hold from birth.
+ job = WindowsJobObject.Assign(pi.hProcess, processMemoryLimitBytes);
+ // Opened while the creation handle is still held, so the pid cannot have been reused; .Handle caches a handle
+ // the Process object uses from here on (the token's default DACL grants the host account).
+ var process = Process.GetProcessById(pi.dwProcessId);
+ _ = process.Handle;
+ process.EnableRaisingEvents = true;
+ if (ResumeThread(pi.hThread) == uint.MaxValue)
+ {
+ var error = Marshal.GetLastWin32Error();
+ process.Dispose();
+ throw new Win32Exception(error, "ResumeThread failed");
+ }
+
+ Pump(stdout, onStdout);
+ Pump(stderr, onStderr);
+ return new Launched(process, job, stdout, stderr);
+ }
+ catch
+ {
+ if (pi.hProcess != IntPtr.Zero) TerminateProcess(pi.hProcess, 1);
+ job?.Dispose();
+ stdout.Dispose();
+ stderr.Dispose();
+ throw;
+ }
+ finally
+ {
+ if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
+ if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
+ if (attributeList != IntPtr.Zero)
+ {
+ DeleteProcThreadAttributeList(attributeList);
+ Marshal.FreeHGlobal(attributeList);
+ }
+ if (handleList != IntPtr.Zero) Marshal.FreeHGlobal(handleList);
+ if (environment != IntPtr.Zero) Marshal.FreeHGlobal(environment);
+ }
+ }
+
+ ///
+ /// The worker's primary token: the host's, with every privilege but SeChangeNotifyPrivilege removed, the
+ /// restricting SIDs of , and a default DACL of host account + SYSTEM + worker identity.
+ ///
+ /// The worker identity's SID string.
+ /// The token (owned by the caller).
+ internal static SafeAccessTokenHandle CreateWorkerToken(string workerSid)
+ {
+ if (!OpenProcessToken(GetCurrentProcess(), TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT | READ_CONTROL | WRITE_DAC,
+ out var processToken))
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "OpenProcessToken failed");
+ using (processToken)
+ {
+ // Not the logon SID: the host's own process DACL grants it PROCESS_VM_READ (measured on windev), so a worker
+ // holding it could read the host's memory.
+ var restricting = ScriptWorkerIdentity.RestrictingSids(workerSid).Select(s => new SecurityIdentifier(s)).ToArray();
+ var pinned = new List();
+ var entries = IntPtr.Zero;
+ try
+ {
+ var entrySize = Marshal.SizeOf();
+ entries = Marshal.AllocHGlobal(entrySize * restricting.Length);
+ for (var i = 0; i < restricting.Length; i++)
+ {
+ var bytes = new byte[restricting[i].BinaryLength];
+ restricting[i].GetBinaryForm(bytes, 0);
+ var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
+ pinned.Add(handle);
+ Marshal.StructureToPtr(new SID_AND_ATTRIBUTES { Sid = handle.AddrOfPinnedObject(), Attributes = 0 },
+ entries + (i * entrySize), fDeleteOld: false);
+ }
+ if (!CreateRestrictedToken(processToken, DISABLE_MAX_PRIVILEGE, 0, IntPtr.Zero, 0, IntPtr.Zero,
+ (uint)restricting.Length, entries, out var restricted))
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateRestrictedToken failed");
+ try
+ {
+ SetDefaultDacl(restricted, new SecurityIdentifier(workerSid));
+ return restricted;
+ }
+ catch
+ {
+ restricted.Dispose();
+ throw;
+ }
+ }
+ finally
+ {
+ foreach (var handle in pinned) handle.Free();
+ if (entries != IntPtr.Zero) Marshal.FreeHGlobal(entries);
+ }
+ }
+ }
+
+ private static readonly object SessionGrantLock = new();
+ private static readonly HashSet SessionGranted = new(StringComparer.Ordinal);
+
+ ///
+ /// Adds the worker identity to the DACL of the host's window station and desktop (once per process and identity).
+ /// USER32's DllMain connects the process to them, and the runtime imports USER32, so a worker whose restricting SIDs
+ /// have no access there fails to load coreclr.dll (ERROR_DLL_INIT_FAILED, measured on windev). The
+ /// logon SID, which those objects normally grant, is deliberately NOT a restricting SID (the host's process DACL
+ /// gives it PROCESS_VM_READ). The grant is to the identity only and lives on the session's kernel objects:
+ /// it ends with the host's session and changes nothing persistent.
+ ///
+ /// The worker identity's SID string.
+ internal static void GrantSessionObjects(string workerSid)
+ {
+ lock (SessionGrantLock)
+ {
+ if (!SessionGranted.Add(workerSid)) return;
+ try
+ {
+ var sid = new SecurityIdentifier(workerSid);
+ AddAllowAce(GetProcessWindowStation(), sid, WindowStationRights);
+ AddAllowAce(GetThreadDesktop(GetCurrentThreadId()), sid, DesktopRights);
+ }
+ catch
+ {
+ SessionGranted.Remove(workerSid);
+ throw;
+ }
+ }
+ }
+
+ /// The rights the worker identity gets on the window station: enumerate, read attributes, global atoms.
+ internal const int WindowStationRights = 0x0001 | 0x0002 | 0x0020 | 0x0100 | 0x00020000;
+
+ /// The rights the worker identity gets on the desktop: read objects, create windows (USER32 init), enumerate.
+ internal const int DesktopRights = 0x0001 | 0x0002 | 0x0040 | 0x00020000;
+
+ private static void AddAllowAce(IntPtr handle, SecurityIdentifier sid, int mask)
+ {
+ if (handle == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "no window station / desktop handle");
+ var error = GetSecurityInfoRaw(handle, SE_WINDOW_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero, out var daclPtr,
+ IntPtr.Zero, out var sd);
+ if (error != 0) throw new Win32Exception((int)error, "GetSecurityInfo(window object) failed");
+ try
+ {
+ RawAcl acl;
+ if (daclPtr == IntPtr.Zero) return; // a NULL DACL already admits everyone
+ var size = Marshal.ReadInt16(daclPtr + 2);
+ var bytes = new byte[size];
+ Marshal.Copy(daclPtr, bytes, 0, size);
+ acl = new RawAcl(bytes, 0);
+ foreach (var ace in acl.OfType())
+ if (ace.SecurityIdentifier == sid && ace.AceQualifier == AceQualifier.AccessAllowed && (ace.AccessMask & mask) == mask) return;
+ acl.InsertAce(acl.Count, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed, mask, sid, false, null));
+ var updated = new byte[acl.BinaryLength];
+ acl.GetBinaryForm(updated, 0);
+ var pinned = GCHandle.Alloc(updated, GCHandleType.Pinned);
+ try
+ {
+ error = SetSecurityInfoRaw(handle, SE_WINDOW_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero,
+ pinned.AddrOfPinnedObject(), IntPtr.Zero);
+ if (error != 0) throw new Win32Exception((int)error, "SetSecurityInfo(window object) failed");
+ }
+ finally
+ {
+ pinned.Free();
+ }
+ }
+ finally
+ {
+ LocalFree(sd);
+ }
+ }
+
+ /// The SDDL of the default DACL a worker token carries (tests).
+ /// The host account.
+ /// The worker identity.
+ /// The raw ACL.
+ internal static RawAcl DefaultDacl(SecurityIdentifier hostUser, SecurityIdentifier worker)
+ {
+ var acl = new RawAcl(GenericAcl.AclRevision, 3);
+ var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
+ var restricted = new SecurityIdentifier(ScriptWorkerIdentity.RestrictedSid);
+ foreach (var sid in new[] { hostUser, system, worker, restricted }.Distinct())
+ acl.InsertAce(acl.Count, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed, GENERIC_ALL, sid, false, null));
+ return acl;
+ }
+
+ private static void SetDefaultDacl(SafeAccessTokenHandle token, SecurityIdentifier worker)
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ var acl = DefaultDacl(identity.User ?? throw new InvalidOperationException("the host token has no user"), worker);
+ var bytes = new byte[acl.BinaryLength];
+ acl.GetBinaryForm(bytes, 0);
+ var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
+ try
+ {
+ var info = new TOKEN_DEFAULT_DACL { DefaultDacl = handle.AddrOfPinnedObject() };
+ if (!SetTokenInformation(token, TokenDefaultDacl, ref info, (uint)Marshal.SizeOf()))
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "SetTokenInformation(TokenDefaultDacl) failed");
+ // The token object itself: its DACL came from the host's default DACL, which no restricting SID passes, so the
+ // worker could not open its own token and the loader fails (coreclr.dll, ERROR_DLL_INIT_FAILED, on windev).
+ var error = SetSecurityInfo(token, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero,
+ handle.AddrOfPinnedObject(), IntPtr.Zero);
+ if (error != 0) throw new Win32Exception((int)error, "SetSecurityInfo(token DACL) failed");
+ }
+ finally
+ {
+ handle.Free();
+ }
+ }
+
+ ///
+ /// Gives the worker identity Modify on its per-launch working directory: protected, host account + SYSTEM full
+ /// control, worker identity modify. Its temp files go there (TEMP/TMP point at it).
+ ///
+ /// The working directory the host created.
+ /// The worker identity's SID string.
+ public static void GrantWorkDirectory(string directory, string workerSid)
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ var security = new DirectorySecurity();
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ const InheritanceFlags inherit = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
+ var principals = new List { new(WellKnownSidType.LocalSystemSid, null) };
+ if (identity.User is { } user) principals.Add(user);
+ if (identity.Owner is { } owner && !principals.Contains(owner)) principals.Add(owner);
+ foreach (var principal in principals)
+ security.AddAccessRule(new FileSystemAccessRule(principal, FileSystemRights.FullControl, inherit, PropagationFlags.None,
+ AccessControlType.Allow));
+ security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(workerSid), FileSystemRights.Modify | FileSystemRights.Synchronize,
+ inherit, PropagationFlags.None, AccessControlType.Allow));
+ new DirectoryInfo(directory).SetAccessControl(security);
+ }
+
+ private static void Pump(AnonymousPipeServerStream stream, Action onLine)
+ {
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+ while (reader.ReadLine() is { } line) onLine(line);
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
+ {
+ // The worker is gone or the host is stopping.
+ }
+ })
+ { IsBackground = true, Name = "ScriptWorker.Stdio" };
+ thread.Start();
+ }
+
+ private const uint TOKEN_DUPLICATE = 0x0002;
+ private const uint TOKEN_ASSIGN_PRIMARY = 0x0001;
+ private const uint TOKEN_QUERY = 0x0008;
+ private const uint TOKEN_ADJUST_DEFAULT = 0x0080;
+ private const uint READ_CONTROL = 0x00020000;
+ private const uint WRITE_DAC = 0x00040000; // the restricted token inherits these rights: its own DACL is rewritten
+ private const uint DISABLE_MAX_PRIVILEGE = 0x1;
+ private const int TokenDefaultDacl = 6;
+ private const int SE_KERNEL_OBJECT = 6;
+ private const int SE_WINDOW_OBJECT = 7;
+ private const uint DACL_SECURITY_INFORMATION = 0x4;
+ private const int GENERIC_ALL = 0x10000000;
+ private const int STARTF_USESTDHANDLES = 0x00000100;
+ private const uint CREATE_SUSPENDED = 0x00000004;
+ private const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
+ private const uint CREATE_NO_WINDOW = 0x08000000;
+ private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
+ private static readonly IntPtr PROC_THREAD_ATTRIBUTE_HANDLE_LIST = (IntPtr)0x00020002;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SID_AND_ATTRIBUTES
+ {
+ public IntPtr Sid;
+ public uint Attributes;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct TOKEN_DEFAULT_DACL
+ {
+ public IntPtr DefaultDacl;
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct STARTUPINFO
+ {
+ public int cb;
+ public string? lpReserved;
+ public string? lpDesktop;
+ public string? lpTitle;
+ public int dwX;
+ public int dwY;
+ public int dwXSize;
+ public int dwYSize;
+ public int dwXCountChars;
+ public int dwYCountChars;
+ public int dwFillAttribute;
+ public int dwFlags;
+ public short wShowWindow;
+ public short cbReserved2;
+ public IntPtr lpReserved2;
+ public IntPtr hStdInput;
+ public IntPtr hStdOutput;
+ public IntPtr hStdError;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct STARTUPINFOEX
+ {
+ public STARTUPINFO StartupInfo;
+ public IntPtr lpAttributeList;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct PROCESS_INFORMATION
+ {
+ public IntPtr hProcess;
+ public IntPtr hThread;
+ public int dwProcessId;
+ public int dwThreadId;
+ }
+
+#pragma warning disable SYSLIB1054 // DllImport kept, as in WindowsJobObject: LibraryImport would need AllowUnsafeBlocks.
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetCurrentProcess();
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool OpenProcessToken(IntPtr process, uint desiredAccess, out SafeAccessTokenHandle token);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CreateRestrictedToken(SafeAccessTokenHandle existingToken, uint flags, uint disableSidCount,
+ IntPtr sidsToDisable, uint deletePrivilegeCount, IntPtr privilegesToDelete, uint restrictedSidCount, IntPtr sidsToRestrict,
+ out SafeAccessTokenHandle newToken);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetTokenInformation(SafeAccessTokenHandle token, int infoClass, ref TOKEN_DEFAULT_DACL info, uint length);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ private static extern uint SetSecurityInfo(SafeAccessTokenHandle handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
+ IntPtr dacl, IntPtr sacl);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value, IntPtr size,
+ IntPtr previousValue, IntPtr returnSize);
+
+ [DllImport("kernel32.dll")]
+ private static extern void DeleteProcThreadAttributeList(IntPtr list);
+
+ [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CreateProcessAsUserW(SafeAccessTokenHandle token, string applicationName, StringBuilder commandLine,
+ IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandles, uint creationFlags,
+ IntPtr environment, string? currentDirectory, ref STARTUPINFOEX startupInfo, out PROCESS_INFORMATION processInformation);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern uint ResumeThread(IntPtr thread);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern IntPtr GetProcessWindowStation();
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern IntPtr GetThreadDesktop(uint threadId);
+
+ [DllImport("kernel32.dll")]
+ private static extern uint GetCurrentThreadId();
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr LocalFree(IntPtr memory);
+
+ [DllImport("advapi32.dll", EntryPoint = "GetSecurityInfo", SetLastError = true)]
+ private static extern uint GetSecurityInfoRaw(IntPtr handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
+ out IntPtr dacl, IntPtr sacl, out IntPtr securityDescriptor);
+
+ [DllImport("advapi32.dll", EntryPoint = "SetSecurityInfo", SetLastError = true)]
+ private static extern uint SetSecurityInfoRaw(IntPtr handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
+ IntPtr dacl, IntPtr sacl);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool TerminateProcess(IntPtr process, uint exitCode);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CloseHandle(IntPtr handle);
+#pragma warning restore SYSLIB1054
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsJobObject.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsJobObject.cs
index 449deade..9a16fc8f 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsJobObject.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsJobObject.cs
@@ -29,6 +29,16 @@ internal sealed class WindowsJobObject : IDisposable
/// The per-process commit limit.
/// The job; dispose it to kill the worker.
public static WindowsJobObject Assign(Process process, long processMemoryLimitBytes)
+ => Assign(process.Handle, processMemoryLimitBytes);
+
+ ///
+ /// Creates a job with the worker limits and assigns the process behind to it — the
+ /// restricted launch assigns its worker while it is still suspended ().
+ ///
+ /// A handle with PROCESS_SET_QUOTA and PROCESS_TERMINATE.
+ /// The per-process commit limit.
+ /// The job; dispose it to kill the worker.
+ public static WindowsJobObject Assign(IntPtr processHandle, long processMemoryLimitBytes)
{
var handle = CreateJobObjectW(IntPtr.Zero, null);
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed");
@@ -48,7 +58,7 @@ internal sealed class WindowsJobObject : IDisposable
if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, ref info,
(uint)Marshal.SizeOf()))
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed");
- if (!AssignProcessToJobObject(handle, process.Handle))
+ if (!AssignProcessToJobObject(handle, processHandle))
throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
return job;
}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsProcessText.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsProcessText.cs
new file mode 100644
index 00000000..d58b47cb
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WindowsProcessText.cs
@@ -0,0 +1,64 @@
+using System.Text;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
+
+///
+/// The text a native Windows process launch takes (): the command line and the
+/// environment block. Pure string work, so it is testable on any OS.
+///
+internal static class WindowsProcessText
+{
+ /// A Windows command line for and (MSVC quoting rules).
+ /// The executable.
+ /// The arguments.
+ /// The command line.
+ internal static string BuildCommandLine(string fileName, IEnumerable arguments)
+ {
+ var sb = new StringBuilder();
+ AppendQuoted(sb, fileName);
+ foreach (var argument in arguments)
+ {
+ sb.Append(' ');
+ AppendQuoted(sb, argument);
+ }
+ return sb.ToString();
+ }
+
+ private static void AppendQuoted(StringBuilder sb, string argument)
+ {
+ if (argument.Length > 0 && argument.IndexOfAny([' ', '\t', '\n', '\v', '"']) < 0)
+ {
+ sb.Append(argument);
+ return;
+ }
+ sb.Append('"');
+ var backslashes = 0;
+ foreach (var c in argument)
+ {
+ if (c == '\\')
+ {
+ backslashes++;
+ continue;
+ }
+ if (c == '"') sb.Append('\\', (backslashes * 2) + 1);
+ else sb.Append('\\', backslashes);
+ backslashes = 0;
+ sb.Append(c);
+ }
+ sb.Append('\\', backslashes * 2);
+ sb.Append('"');
+ }
+
+ /// A sorted, double-NUL-terminated UTF-16 environment block.
+ /// The variables.
+ /// The block.
+ internal static string BuildEnvironmentBlock(IDictionary environment)
+ {
+ var sb = new StringBuilder();
+ foreach (var (name, value) in environment.Where(kv => kv.Value is not null).OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
+ sb.Append(name).Append('=').Append(value).Append('\0');
+ if (sb.Length == 0) sb.Append('\0');
+ sb.Append('\0');
+ return sb.ToString();
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerConnection.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerConnection.cs
index 9b0d91d0..85b6f6d9 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerConnection.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerConnection.cs
@@ -77,6 +77,10 @@ internal sealed record BatchSubmission(string Key, EvalRequest Request, TimeSpan
/// it, lmxopcua#790), before the worker is killed and the key struck; null = 60 s.
///
/// The worker's dedicated compile threads; 0 = its default.
+///
+/// Windows only: the worker identity's SID (Scripting:Worker:Identity). Set, the worker starts under a restricted
+/// token () and the pipe ACL admits it beside the host; null keeps the host's token.
+///
internal sealed record WorkerLaunchSpec(
string Shard,
string WorkerPath,
@@ -89,7 +93,8 @@ internal sealed record WorkerLaunchSpec(
byte[]? PeCacheKey = null,
string? MarkerDirectory = null,
TimeSpan? LoadTimeout = null,
- int CompileThreads = 0)
+ int CompileThreads = 0,
+ string? WorkerSid = null)
{
/// The default compile timeout.
public static readonly TimeSpan DefaultLoadTimeout = TimeSpan.FromSeconds(60);
@@ -119,6 +124,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
private readonly Process _process;
private readonly NamedPipeServerStream _pipe;
private readonly IDisposable? _job;
+ private readonly IDisposable? _stdio;
private readonly string _workDir;
private readonly ILogger _log;
private readonly Action _onLogs;
@@ -143,10 +149,11 @@ internal sealed class WorkerConnection : IAsyncDisposable
private long _nextLoadId;
private int _disposed;
- private WorkerConnection(string shard, Process process, NamedPipeServerStream pipe, IDisposable? job, string workDir,
- ILogger log, Action onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout, Action onExiting,
- string? markerPath)
+ private WorkerConnection(string shard, Process process, NamedPipeServerStream pipe, IDisposable? job, IDisposable? stdio,
+ string workDir, ILogger log, Action onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout,
+ Action onExiting, string? markerPath)
{
+ _stdio = stdio;
_loadTimeout = loadTimeout;
_onExiting = onExiting;
_markerPath = markerPath;
@@ -222,36 +229,57 @@ internal sealed class WorkerConnection : IAsyncDisposable
var workDir = Path.Combine(Path.GetTempPath(), "otopcua-scriptworker", pipeName);
Directory.CreateDirectory(workDir);
- // One instance, current user only: the pipe accepts exactly one connection, from this account.
- var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
- PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+ // One instance, admitted principals only: the pipe accepts exactly one connection. Windows: an explicit,
+ // protected ACL — the host account and, under Scripting:Worker:Identity, the worker identity (design §6).
+ // Elsewhere: CurrentUserOnly (a Unix socket owned by this user, 0600).
+ if (spec.WorkerSid is not null && !OperatingSystem.IsWindows())
+ throw new PlatformNotSupportedException("Scripting:Worker:Identity is only supported on Windows");
+ var pipe = OperatingSystem.IsWindows()
+ ? WorkerPipeSecurity.CreateServer(pipeName, WorkerPipeSecurity.ForCurrentHost(spec.WorkerSid))
+ : new NamedPipeServerStream(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
+ PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
Process? process = null;
IDisposable? job = null;
+ IDisposable? stdio = null;
string? markerPath = null;
try
{
var psi = BuildStartInfo(spec, pipeName, nonce, workDir);
- process = new Process { StartInfo = psi, EnableRaisingEvents = true };
- if (!process.Start()) throw new InvalidOperationException("the worker process did not start");
- // Before anything else can fail: a host that dies from here on leaves a marker the next start's orphan
- // sweep can match to exactly this process (design §3.3).
- if (spec.MarkerDirectory is { } markerDir) markerPath = WorkerLaunchMarker.Write(markerDir, process, pipeName, log);
- if (OperatingSystem.IsWindows())
- {
- // Native and JIT memory backstop above the managed heap limit.
- job = WindowsJobObject.Assign(process, spec.HeapLimitBytes + 256L * 1024 * 1024);
- }
var shardLabel = spec.Shard;
- process.ErrorDataReceived += (_, e) =>
+ void OnStderr(string? line)
{
- if (e.Data is { Length: > 0 } line) log.Warning("ScriptWorker[{Shard}] {WorkerStderr}", shardLabel, line);
- };
- process.OutputDataReceived += (_, e) =>
+ if (line is { Length: > 0 }) log.Warning("ScriptWorker[{Shard}] {WorkerStderr}", shardLabel, line);
+ }
+ void OnStdout(string? line)
{
- if (e.Data is { Length: > 0 } line) log.Debug("ScriptWorker[{Shard}] stdout {WorkerStdout}", shardLabel, line);
- };
- process.BeginErrorReadLine();
- process.BeginOutputReadLine();
+ if (line is { Length: > 0 }) log.Debug("ScriptWorker[{Shard}] stdout {WorkerStdout}", shardLabel, line);
+ }
+ // Native and JIT memory backstop above the managed heap limit.
+ var jobLimit = spec.HeapLimitBytes + 256L * 1024 * 1024;
+ if (spec.WorkerSid is { } workerSid && OperatingSystem.IsWindows())
+ {
+ // The worker reaches only what its identity may: its working directory is the one place it writes.
+ RestrictedWorkerLauncher.GrantWorkDirectory(workDir, workerSid);
+ var launched = RestrictedWorkerLauncher.Start(psi, workerSid, jobLimit, OnStdout, OnStderr);
+ process = launched.Process;
+ job = launched.Job;
+ stdio = launched;
+ if (spec.MarkerDirectory is { } restrictedMarkerDir)
+ markerPath = WorkerLaunchMarker.Write(restrictedMarkerDir, process, pipeName, log);
+ }
+ else
+ {
+ process = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ if (!process.Start()) throw new InvalidOperationException("the worker process did not start");
+ // Before anything else can fail: a host that dies from here on leaves a marker the next start's orphan
+ // sweep can match to exactly this process (design §3.3).
+ if (spec.MarkerDirectory is { } markerDir) markerPath = WorkerLaunchMarker.Write(markerDir, process, pipeName, log);
+ if (OperatingSystem.IsWindows()) job = WindowsJobObject.Assign(process, jobLimit);
+ process.ErrorDataReceived += (_, e) => OnStderr(e.Data);
+ process.OutputDataReceived += (_, e) => OnStdout(e.Data);
+ process.BeginErrorReadLine();
+ process.BeginOutputReadLine();
+ }
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
handshakeCts.CancelAfter(spec.HandshakeTimeout);
@@ -283,7 +311,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
else
await HandshakeAsync(pipe, hello, nonce, process.Id, handshakeCts.Token).ConfigureAwait(false);
- var connection = new WorkerConnection(spec.Shard, process, pipe, job, workDir, log, onLogs, spec.HeartbeatInterval,
+ var connection = new WorkerConnection(spec.Shard, process, pipe, job, stdio, workDir, log, onLogs, spec.HeartbeatInterval,
spec.EffectiveLoadTimeout, onExiting, markerPath);
connection.Begin();
return connection;
@@ -292,6 +320,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
{
try { if (process is { HasExited: false }) process.Kill(entireProcessTree: true); } catch { /* best effort */ }
job?.Dispose();
+ stdio?.Dispose();
process?.Dispose();
await pipe.DisposeAsync().ConfigureAwait(false);
TryDeleteDirectory(workDir);
@@ -367,7 +396,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
return reply.Ready;
}
- private static ProcessStartInfo BuildStartInfo(WorkerLaunchSpec spec, string pipeName, string nonce, string workDir)
+ internal static ProcessStartInfo BuildStartInfo(WorkerLaunchSpec spec, string pipeName, string nonce, string workDir)
{
var psi = new ProcessStartInfo
{
@@ -408,6 +437,15 @@ internal sealed class WorkerConnection : IAsyncDisposable
psi.Environment[ScriptWorkerEnvironment.Nonce] = nonce;
psi.Environment[ScriptWorkerEnvironment.ParentPid] = Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture);
psi.Environment[ScriptWorkerEnvironment.Shard] = spec.Shard;
+ if (spec.WorkerSid is not null)
+ {
+ // A restricted worker cannot write the host's temp directory; its own working directory is its temp.
+ psi.Environment["TEMP"] = workDir;
+ psi.Environment["TMP"] = workDir;
+ // No debugger / profiler / diagnostics IPC: a restricted token cannot create the runtime's named objects,
+ // and nothing should attach to a sandboxed worker anyway.
+ psi.Environment["DOTNET_EnableDiagnostics"] = "0";
+ }
return psi;
}
@@ -651,6 +689,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
_writer.Complete();
await _pipe.DisposeAsync().ConfigureAwait(false);
_job?.Dispose();
+ _stdio?.Dispose();
_process.Dispose();
TryDeleteDirectory(_workDir);
WorkerLaunchMarker.TryDelete(_markerPath);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerPipeSecurity.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerPipeSecurity.cs
new file mode 100644
index 00000000..5c491e7a
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/WorkerPipeSecurity.cs
@@ -0,0 +1,60 @@
+using System.IO.Pipes;
+using System.Runtime.Versioning;
+using System.Security.AccessControl;
+using System.Security.Principal;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
+
+///
+/// The ScriptWorker pipe's explicit ACL on Windows (lmxopcua#780 design §6, "an explicit ACL when the worker runs as a
+/// different account"): a protected DACL that admits the host account and — when Scripting:Worker:Identity is
+/// set — the worker identity, each with read/write, and nobody else (no SYSTEM, no Administrators, no Everyone). The
+/// owner is the host token's owner, as .NET's PipeOptions.CurrentUserOnly sets it, so the worker's
+/// CurrentUserOnly client check (pipe owner == its token owner, which a restricted token keeps) still
+/// authenticates the server end.
+///
+[SupportedOSPlatform("windows")]
+internal static class WorkerPipeSecurity
+{
+ /// The rights each admitted principal gets: read/write data, attributes and the security descriptor (read).
+ internal const PipeAccessRights GrantedRights = PipeAccessRights.ReadWrite;
+
+ /// Builds the pipe ACL for this host process.
+ /// The worker identity's SID string, or null when the worker keeps the host's token.
+ /// The pipe security.
+ public static PipeSecurity ForCurrentHost(string? workerSid)
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ var owner = identity.Owner ?? identity.User ?? throw new InvalidOperationException("the host token has no owner");
+ var user = identity.User ?? owner;
+ return Create(owner, user, workerSid is null ? null : new SecurityIdentifier(workerSid));
+ }
+
+ /// Builds the pipe ACL.
+ /// The pipe owner (the host token's owner).
+ /// The host account (its token's user SID).
+ /// The worker identity, or null.
+ /// The pipe security: protected, allow-only, one ACE for the host account and one for the worker identity.
+ public static PipeSecurity Create(SecurityIdentifier owner, SecurityIdentifier hostUser, SecurityIdentifier? worker)
+ {
+ var security = new PipeSecurity();
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ security.SetOwner(owner);
+ // The host account and the worker identity, nothing else: not the owner when it differs (an elevated host's
+ // owner is BUILTIN\Administrators — .NET's CurrentUserOnly would grant it; the owner keeps only its implicit
+ // READ_CONTROL / WRITE_DAC).
+ var principals = new List { hostUser };
+ if (worker is not null && !principals.Contains(worker)) principals.Add(worker);
+ foreach (var principal in principals)
+ security.AddAccessRule(new PipeAccessRule(principal, GrantedRights, AccessControlType.Allow));
+ return security;
+ }
+
+ /// Creates the single-instance server end of a worker pipe with .
+ /// The random pipe name.
+ /// The ACL ().
+ /// The server stream.
+ public static NamedPipeServerStream CreateServer(string pipeName, PipeSecurity security)
+ => NamedPipeServerStreamAcl.Create(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
+ PipeTransmissionMode.Byte, PipeOptions.Asynchronous, inBufferSize: 0, outBufferSize: 0, security);
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunnerOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunnerOptions.cs
index 4d1c1539..78704986 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunnerOptions.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunnerOptions.cs
@@ -138,6 +138,17 @@ public sealed class ScriptWorkerOptions
///
public string? ExecutablePath { get; set; }
+ ///
+ /// The worker's identity on Windows (design §6, owner decision §14 #5): NT SERVICE\<name> — the virtual
+ /// account scripts/install/Install-ScriptWorkerIsolation.ps1 registers, by default
+ /// NT SERVICE\OtOpcUaScriptWorker — or a SID string. When set, each worker starts under a restricted token
+ /// derived from the host's (see ): every privilege removed, and every access check
+ /// must ALSO pass for this identity, Everyone, BUILTIN\Users or RESTRICTED. The pipe ACL then
+ /// admits the host account and this identity only. Null (the default) keeps the host's own token. Windows only:
+ /// startup refuses it elsewhere.
+ ///
+ public string? Identity { get; set; }
+
/// The hard grace that applies to a run with soft budget .
/// The run's soft deadline T.
/// when set, otherwise max(1 s, T).
@@ -228,6 +239,7 @@ public sealed class ScriptWorkerOptions
IsolationProbationRuns = probation,
PeCacheDir = PeCacheDir,
ExecutablePath = ExecutablePath,
+ Identity = Identity,
};
}
@@ -252,6 +264,21 @@ public sealed class ScriptWorkerOptions
return $"{key} '{PeCacheDir}' is inside the content root '{contentRootPath}'; it must live outside it.";
return null;
}
+
+ ///
+ /// Fail-closed identity validation: , when set, must be NT SERVICE\<name> or a SID
+ /// string, and the host must run on Windows.
+ ///
+ /// Whether the host runs on Windows (callers pass ).
+ /// The violation, or null when the identity is unset or acceptable.
+ public string? ValidateIdentity(bool isWindows)
+ {
+ if (string.IsNullOrWhiteSpace(Identity)) return null;
+ const string key = ScriptingOptions.SectionName + ":Worker:" + nameof(Identity);
+ if (!isWindows)
+ return $"{key} '{Identity}' is only supported on Windows (it is a restricted token); unset it on this host.";
+ return ScriptWorkerIdentity.TryResolveSid(Identity, out _, out var error) ? null : $"{key} '{Identity}': {error}";
+ }
}
///
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptWorkerIdentity.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptWorkerIdentity.cs
new file mode 100644
index 00000000..669f1844
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptWorkerIdentity.cs
@@ -0,0 +1,123 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
+
+///
+/// The ScriptWorker's Windows identity (Scripting:Worker:Identity, lmxopcua#780 design §6, owner decision §14 #5).
+///
+///
+///
+/// Why a restricted token and not a logon. Windows starts a process under a virtual account
+/// (NT SERVICE\name) only when the Service Control Manager starts that service; a host cannot launch K + 1
+/// short-lived children, each with its own nonce, pipe and Job Object, that way. A separate local user would need
+/// a stored password and SeAssignPrimaryTokenPrivilege on the host account. Instead the host derives a
+/// restricted token from its own (the Windows Service Hardening / browser-sandbox technique): every
+/// privilege is removed, and the virtual account's SID is one of the token's restricting SIDs, so every
+/// access check must pass twice — once for the host account and once for the restricting set
+/// (). The worker can therefore reach only what BOTH the host account AND the worker
+/// identity (or Everyone / BUILTIN\Users / RESTRICTED, which the runtime needs to load from
+/// System32) may reach: in effect a standard user bounded by the host account, with no privileges.
+///
+///
+/// The virtual account's SID is computable from its name (), so the identity works for ACLs
+/// and the firewall rule whether or not the installer's anchor service exists; the anchor only makes the name
+/// resolvable in ACL editors.
+///
+///
+public static partial class ScriptWorkerIdentity
+{
+ /// The installer's default virtual account name (the anchor service's name).
+ public const string DefaultServiceName = "OtOpcUaScriptWorker";
+
+ /// The installer's default identity, as configured.
+ public const string DefaultIdentity = @"NT SERVICE\" + DefaultServiceName;
+
+ /// Everyone.
+ public const string EveryoneSid = "S-1-1-0";
+
+ /// BUILTIN\Users.
+ public const string BuiltinUsersSid = "S-1-5-32-545";
+
+ /// NT AUTHORITY\RESTRICTED (the SID many system objects grant for restricted tokens).
+ public const string RestrictedSid = "S-1-5-12";
+
+ private const string ServicePrefix = @"NT SERVICE\";
+
+ ///
+ /// The restricting SIDs of a worker token for : the identity itself, then
+ /// Everyone, BUILTIN\Users and RESTRICTED. Authenticated Users and the logon SID are
+ /// deliberately absent (the first is granted Modify on many data folders; the second reaches the host's own
+ /// processes in an interactive session).
+ ///
+ /// The worker identity's SID string.
+ /// The restricting SID strings, identity first.
+ public static IReadOnlyList RestrictingSids(string workerSid) => [workerSid, EveryoneSid, BuiltinUsersSid, RestrictedSid];
+
+ ///
+ /// The SID Windows assigns the virtual account NT SERVICE\:
+ /// S-1-5-80- followed by the SHA-1 of the upper-cased name in UTF-16LE, read as five little-endian
+ /// 32-bit values (the algorithm behind sc.exe showsid).
+ ///
+ /// The service name (no NT SERVICE\ prefix).
+ /// The SID string.
+ public static string ServiceSid(string serviceName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
+ var hash = SHA1.HashData(Encoding.Unicode.GetBytes(serviceName.ToUpperInvariant()));
+ var sb = new StringBuilder("S-1-5-80");
+ for (var i = 0; i < 5; i++)
+ sb.Append('-').Append(BinaryPrimitives.ReadUInt32LittleEndian(hash.AsSpan(i * 4, 4)).ToString(CultureInfo.InvariantCulture));
+ return sb.ToString();
+ }
+
+ /// Resolves a configured identity to a SID string without touching the OS.
+ /// NT SERVICE\name or a SID string (S-1-…).
+ /// The SID string.
+ /// Why it was refused.
+ /// True when resolved.
+ public static bool TryResolveSid(string? identity, out string sid, out string? error)
+ {
+ sid = string.Empty;
+ error = null;
+ var value = identity?.Trim();
+ if (string.IsNullOrEmpty(value))
+ {
+ error = "no identity is set";
+ return false;
+ }
+ if (value.StartsWith(ServicePrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var name = value[ServicePrefix.Length..];
+ if (!ServiceNamePattern().IsMatch(name))
+ {
+ error = "the virtual account name must be 1–80 letters, digits, '_', '-' or '.'";
+ return false;
+ }
+ sid = ServiceSid(name);
+ return true;
+ }
+ if (SidPattern().IsMatch(value))
+ {
+ if (value is EveryoneSid or BuiltinUsersSid or RestrictedSid || value.StartsWith("S-1-5-32-", StringComparison.Ordinal)
+ || value == "S-1-5-18" || value == "S-1-5-11")
+ {
+ error = "a well-known group or SYSTEM is not a worker identity; use a dedicated virtual account";
+ return false;
+ }
+ sid = value.ToUpperInvariant();
+ return true;
+ }
+ error = @"expected 'NT SERVICE\' (the installer's virtual account) or a SID string 'S-1-…'";
+ return false;
+ }
+
+ [GeneratedRegex(@"^[A-Za-z0-9_.\-]{1,80}$", RegexOptions.CultureInvariant)]
+ private static partial Regex ServiceNamePattern();
+
+ [GeneratedRegex(@"^[Ss]-1-\d+(-\d+){1,14}$", RegexOptions.CultureInvariant)]
+ private static partial Regex SidPattern();
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptRunnerSelection.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptRunnerSelection.cs
index ca50071f..2fc22967 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptRunnerSelection.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptRunnerSelection.cs
@@ -68,7 +68,8 @@ public sealed class ScriptRunnerSelection : IScriptRunnerProvider, IAsyncDisposa
///
/// Startup checks for the runner options: returns the Warnings to log, and throws when the configuration must
- /// not start (a PeCacheDir that is relative or inside the content root — fail closed).
+ /// not start (a PeCacheDir that is relative or inside the content root, or a Worker:Identity that
+ /// is malformed or set off Windows — fail closed).
///
/// The bound, sanitized Scripting options.
/// The host's content root.
@@ -82,6 +83,8 @@ public sealed class ScriptRunnerSelection : IScriptRunnerProvider, IAsyncDisposa
if (options.Worker.ValidatePaths(contentRootPath) is { } violation)
throw new InvalidOperationException($"Scripting misconfiguration: {violation}");
+ if (options.Worker.ValidateIdentity(OperatingSystem.IsWindows()) is { } identityViolation)
+ throw new InvalidOperationException($"Scripting misconfiguration: {identityViolation}");
var mode = $"Scripting:Runner={options.Runner}";
var shadow = options.Runner == ScriptRunnerMode.Shadow;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker/WorkerHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker/WorkerHost.cs
index b7e0751d..5b0d9985 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker/WorkerHost.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker/WorkerHost.cs
@@ -536,6 +536,13 @@ internal sealed class WorkerHost
{
return false;
}
+ catch (System.ComponentModel.Win32Exception)
+ {
+ // Access denied: a worker under Scripting:Worker:Identity runs with a restricted token that may not open the
+ // host's process. It exists (GetProcessById found it), so it is alive; on Windows the Job Object's
+ // kill-on-close is what ends the worker with its host, and pipe end-of-stream backs it up.
+ return true;
+ }
}
private static void OnWriteFault(Exception ex)
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptWorkerIdentityTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptWorkerIdentityTests.cs
new file mode 100644
index 00000000..e2c770ba
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptWorkerIdentityTests.cs
@@ -0,0 +1,76 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
+
+///
+/// Scripting:Worker:Identity (lmxopcua#780 design §6, §14 #5): the virtual account's SID is computed exactly as
+/// Windows computes it, so the host's restricted token, the installer's ACLs (which compute the same SID in
+/// PowerShell, scripts/install/tests/Test-ScriptWorkerIsolation.ps1 pins the same value) and the pipe ACL agree.
+///
+[Trait("Category", "Unit")]
+public sealed class ScriptWorkerIdentityTests
+{
+ /// The installer's default identity; the PowerShell harness pins the same string.
+ private const string OtOpcUaScriptWorkerSid = "S-1-5-80-1846135827-3903057317-1998105452-3938448374-3023753135";
+
+ [Fact]
+ public void The_service_sid_algorithm_reproduces_a_well_known_virtual_account()
+ => ScriptWorkerIdentity.ServiceSid("TrustedInstaller")
+ .ShouldBe("S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464");
+
+ [Fact]
+ public void The_default_identity_resolves_to_the_pinned_sid_case_insensitively()
+ {
+ ScriptWorkerIdentity.TryResolveSid(ScriptWorkerIdentity.DefaultIdentity, out var sid, out var error).ShouldBeTrue(error);
+ sid.ShouldBe(OtOpcUaScriptWorkerSid);
+ ScriptWorkerIdentity.TryResolveSid(@"nt service\otopcuascriptworker", out var lower, out _).ShouldBeTrue();
+ lower.ShouldBe(OtOpcUaScriptWorkerSid);
+ }
+
+ [Fact]
+ public void A_sid_string_passes_through()
+ {
+ ScriptWorkerIdentity.TryResolveSid("S-1-5-21-1-2-3-1001", out var sid, out _).ShouldBeTrue();
+ sid.ShouldBe("S-1-5-21-1-2-3-1001");
+ }
+
+ [Theory]
+ [InlineData("", "no identity")]
+ [InlineData(@"OTOPCUA\svc-worker", "expected 'NT SERVICE")]
+ [InlineData(@"NT SERVICE\bad name", "virtual account name")]
+ [InlineData("S-1-1-0", "well-known group")]
+ [InlineData("S-1-5-32-545", "well-known group")]
+ [InlineData("S-1-5-18", "well-known group")]
+ [InlineData("S-1-5-11", "well-known group")]
+ public void A_domain_account_a_well_known_group_or_garbage_is_refused(string identity, string why)
+ {
+ ScriptWorkerIdentity.TryResolveSid(identity, out _, out var error).ShouldBeFalse();
+ error!.ShouldContain(why);
+ }
+
+ [Fact]
+ public void The_restricting_set_is_the_identity_then_everyone_users_and_restricted_and_never_authenticated_users()
+ {
+ var set = ScriptWorkerIdentity.RestrictingSids(OtOpcUaScriptWorkerSid);
+ set.ShouldBe([OtOpcUaScriptWorkerSid, "S-1-1-0", "S-1-5-32-545", "S-1-5-12"]);
+ set.ShouldNotContain("S-1-5-11");
+ }
+
+ [Fact]
+ public void Validation_refuses_an_identity_off_windows_and_a_malformed_one_on_windows()
+ {
+ new ScriptWorkerOptions().ValidateIdentity(isWindows: false).ShouldBeNull("unset is always fine");
+ new ScriptWorkerOptions { Identity = ScriptWorkerIdentity.DefaultIdentity }.ValidateIdentity(isWindows: true)
+ .ShouldBeNull("positive control");
+ new ScriptWorkerOptions { Identity = ScriptWorkerIdentity.DefaultIdentity }.ValidateIdentity(isWindows: false)
+ .ShouldNotBeNull().ShouldContain("only supported on Windows");
+ new ScriptWorkerOptions { Identity = @"OTOPCUA\svc" }.ValidateIdentity(isWindows: true)
+ .ShouldNotBeNull().ShouldContain("Scripting:Worker:Identity");
+ }
+
+ [Fact]
+ public void Sanitized_keeps_the_identity()
+ => new ScriptWorkerOptions { Identity = ScriptWorkerIdentity.DefaultIdentity }.Sanitized(out _).Identity
+ .ShouldBe(ScriptWorkerIdentity.DefaultIdentity);
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptRunnerSelectionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptRunnerSelectionTests.cs
index 02cd7753..db1046ed 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptRunnerSelectionTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptRunnerSelectionTests.cs
@@ -45,6 +45,7 @@ public sealed class ScriptRunnerSelectionTests
["Scripting:Worker:IsolationProbationRuns"] = "9",
["Scripting:Worker:PeCacheDir"] = "/var/cache/otopcua-pe",
["Scripting:Worker:ExecutablePath"] = "/opt/w/ZB.MOM.WW.OtOpcUa.ScriptWorker.dll",
+ ["Scripting:Worker:Identity"] = @"NT SERVICE\OtOpcUaScriptWorker",
}), out var adjustments);
adjustments.ShouldBeEmpty();
@@ -58,6 +59,7 @@ public sealed class ScriptRunnerSelectionTests
bound.Worker.IsolationProbationRuns.ShouldBe(9);
bound.Worker.PeCacheDir.ShouldBe("/var/cache/otopcua-pe");
bound.Worker.ExecutablePath.ShouldBe("/opt/w/ZB.MOM.WW.OtOpcUa.ScriptWorker.dll");
+ bound.Worker.Identity.ShouldBe(@"NT SERVICE\OtOpcUaScriptWorker");
bound.IsOutOfProcess(ScriptSeam.Calculation).ShouldBeTrue();
bound.IsOutOfProcess(ScriptSeam.VirtualTag).ShouldBeFalse();
}
@@ -115,6 +117,36 @@ public sealed class ScriptRunnerSelectionTests
Should.Throw(() => ScriptRunnerSelection.Validate(options, root)).Message.ShouldContain(why);
}
+ [Fact]
+ public void A_worker_identity_is_refused_at_startup_off_Windows_and_accepted_on_it()
+ {
+ var options = new ScriptingOptions
+ {
+ Runner = ScriptRunnerMode.OutOfProcess,
+ Worker = new ScriptWorkerOptions { Identity = @"NT SERVICE\OtOpcUaScriptWorker" },
+ };
+ if (OperatingSystem.IsWindows())
+ Should.NotThrow(() => ScriptRunnerSelection.Validate(options, "C:\\srv"));
+ else
+ Should.Throw(() => ScriptRunnerSelection.Validate(options, "/srv"))
+ .Message.ShouldContain("only supported on Windows");
+ }
+
+ [Theory]
+ [InlineData(@"OTOPCUA\svc-worker")]
+ [InlineData("S-1-5-32-545")]
+ [InlineData(@"NT SERVICE\bad name")]
+ public void A_malformed_or_shared_worker_identity_fails_startup(string identity)
+ {
+ var options = new ScriptingOptions
+ {
+ Runner = ScriptRunnerMode.OutOfProcess,
+ Worker = new ScriptWorkerOptions { Identity = identity },
+ };
+ Should.Throw(() => ScriptRunnerSelection.Validate(options, "/srv"))
+ .Message.ShouldContain("Scripting:Worker:Identity");
+ }
+
[Fact]
public void A_pe_cache_dir_outside_the_content_root_is_accepted()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests/WorkerIsolationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests/WorkerIsolationTests.cs
new file mode 100644
index 00000000..a88bdd18
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests/WorkerIsolationTests.cs
@@ -0,0 +1,250 @@
+using System.ComponentModel;
+using System.IO.Pipes;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Security.AccessControl;
+using System.Security.Cryptography;
+using System.Security.Principal;
+using Microsoft.Win32.SafeHandles;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
+
+namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
+
+///
+/// lmxopcua#780 design §6 / §14 #5 — the worker's own identity: the host-side half of the installer work. The pipe ACL
+/// admits the host account and the worker identity only; the worker runs under a restricted, privilege-free token;
+/// its working directory is the one host-temp place it may write. The Windows cases run on windev and change no
+/// machine state (a pipe, a per-test temp directory and a child process, all gone at the end); nothing here creates
+/// an account, a firewall rule or an ACL outside the test's own temp directory.
+///
+[Collection(ScriptWorkerCollection.Name)]
+[Trait("Category", "ScriptWorker")]
+public sealed class WorkerIsolationTests
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static string WorkerSid => ScriptWorkerIdentity.ServiceSid(ScriptWorkerIdentity.DefaultServiceName);
+
+ private static WorkerLaunchSpec Spec(string? workerSid) => new("0", "/w/ZB.MOM.WW.OtOpcUa.ScriptWorker", 256L * 1024 * 1024, 2,
+ TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(15), WorkerSid: workerSid);
+
+ // ----- any OS -----
+
+ [Fact]
+ public void A_restricted_worker_gets_its_working_directory_as_temp_and_an_unrestricted_one_keeps_the_hosts()
+ {
+ var restricted = WorkerConnection.BuildStartInfo(Spec(WorkerSid), "p", "n", "/work/dir");
+ restricted.Environment["TEMP"].ShouldBe("/work/dir");
+ restricted.Environment["TMP"].ShouldBe("/work/dir");
+
+ var plain = WorkerConnection.BuildStartInfo(Spec(null), "p", "n", "/work/dir");
+ plain.Environment.TryGetValue("TEMP", out var temp);
+ temp.ShouldNotBe("/work/dir", "positive control: only the restricted worker is redirected");
+ }
+
+ [Fact]
+ public void The_command_line_quotes_paths_with_spaces_and_escapes_quotes_and_trailing_backslashes()
+ {
+ WindowsProcessText.BuildCommandLine(@"C:\Program Files\dotnet\dotnet.exe", ["exec", @"C:\a b\w.dll"])
+ .ShouldBe(@"""C:\Program Files\dotnet\dotnet.exe"" exec ""C:\a b\w.dll""");
+ WindowsProcessText.BuildCommandLine(@"C:\w.exe", [@"say ""hi""", @"C:\dir with space\", ""])
+ .ShouldBe(@"C:\w.exe ""say \""hi\"""" ""C:\dir with space\\"" """"");
+ }
+
+ [Fact]
+ public void The_environment_block_is_sorted_and_double_nul_terminated()
+ {
+ var block = WindowsProcessText.BuildEnvironmentBlock(new Dictionary { ["b"] = "2", ["A"] = "1", ["skip"] = null });
+ block.ShouldBe("A=1\0b=2\0\0");
+ WindowsProcessText.BuildEnvironmentBlock(new Dictionary()).ShouldBe("\0\0");
+ }
+
+ [Fact]
+ public void A_runner_configured_with_an_identity_off_windows_refuses_to_start_rather_than_fall_back()
+ {
+ if (OperatingSystem.IsWindows()) Assert.Skip("the off-Windows refusal; Windows applies the identity (tests below)");
+ var options = Scripts.Options();
+ options.Worker.Identity = ScriptWorkerIdentity.DefaultIdentity;
+ Should.Throw(() => Scripts.Runner(options)).Message.ShouldContain("only supported on Windows");
+ }
+
+ // ----- Windows (windev) -----
+
+ [Fact]
+ public void The_pipe_acl_is_protected_and_admits_exactly_the_host_and_the_worker_identity()
+ {
+ if (!OperatingSystem.IsWindows()) Assert.Skip("Windows pipe ACL");
+ else AssertPipeAcl();
+ }
+
+ [Fact]
+ public async Task A_live_pipe_carries_the_acl_and_a_host_only_pipe_admits_the_host_alone()
+ {
+ if (!OperatingSystem.IsWindows()) Assert.Skip("Windows pipe ACL");
+ else await AssertLivePipeAcl();
+ }
+
+ [Fact]
+ public void The_worker_token_is_restricted_privilege_free_and_reaches_only_what_its_identity_may()
+ {
+ if (!OperatingSystem.IsWindows()) Assert.Skip("Windows restricted token");
+ else AssertTokenAccess();
+ }
+
+ [Fact]
+ public async Task A_worker_under_the_identity_runs_scripts_with_a_restricted_token_and_a_plain_one_does_not()
+ {
+ if (!OperatingSystem.IsWindows()) Assert.Skip("Windows restricted launch");
+ else await AssertRestrictedLaunch();
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static void AssertPipeAcl()
+ {
+ using var me = WindowsIdentity.GetCurrent();
+ var worker = new SecurityIdentifier(WorkerSid);
+ var security = WorkerPipeSecurity.Create(me.Owner!, me.User!, worker);
+ security.AreAccessRulesProtected.ShouldBeTrue("no inherited ACEs");
+ security.GetOwner(typeof(SecurityIdentifier)).ShouldBe(me.Owner);
+ var rules = security.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast().ToArray();
+ rules.ShouldAllBe(r => r.AccessControlType == AccessControlType.Allow
+ && (r.PipeAccessRights & WorkerPipeSecurity.GrantedRights) == WorkerPipeSecurity.GrantedRights
+ && (r.PipeAccessRights & (PipeAccessRights.ChangePermissions | PipeAccessRights.TakeOwnership
+ | PipeAccessRights.CreateNewInstance)) == 0);
+ var expected = new[] { me.User!, worker }.Select(s => s.Value).Order().ToArray();
+ rules.Select(r => r.IdentityReference.Value).Order().ToArray().ShouldBe(expected);
+ rules.ShouldNotContain(r => r.IdentityReference.Value == "S-1-5-18" || r.IdentityReference.Value == "S-1-1-0"
+ || r.IdentityReference.Value == "S-1-5-32-544", "no SYSTEM / Everyone / Administrators");
+
+ var hostOnly = WorkerPipeSecurity.Create(me.Owner!, me.User!, null);
+ hostOnly.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast()
+ .ShouldNotContain(r => r.IdentityReference.Value == worker.Value, "no identity configured: the host alone");
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static async Task AssertLivePipeAcl()
+ {
+ var name = "otopcua-sw-acl-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(6));
+ await using var server = WorkerPipeSecurity.CreateServer(name, WorkerPipeSecurity.ForCurrentHost(WorkerSid));
+ var live = server.GetAccessControl();
+ live.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast()
+ .ShouldContain(r => r.IdentityReference.Value == WorkerSid);
+ // The worker's client still authenticates the server end (owner == its token owner), exactly as before.
+ await using var client = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+ var accept = server.WaitForConnectionAsync(Ct);
+ await client.ConnectAsync(TimeSpan.FromSeconds(5), Ct);
+ await accept;
+ server.IsConnected.ShouldBeTrue();
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static void AssertTokenAccess()
+ {
+ using var temp = new Scripts.TempDir();
+ var hostOnlyFile = Path.Combine(temp.Path, "host-only.txt");
+ File.WriteAllText(hostOnlyFile, "secret");
+ var workDir = Path.Combine(temp.Path, "work");
+ Directory.CreateDirectory(workDir);
+ RestrictedWorkerLauncher.GrantWorkDirectory(workDir, WorkerSid);
+
+ using var token = RestrictedWorkerLauncher.CreateWorkerToken(WorkerSid);
+ IsTokenRestricted(token).ShouldBeTrue();
+ PrivilegesIn(token).ShouldBeSubsetOf(["SeChangeNotifyPrivilege"]);
+
+ WindowsIdentity.RunImpersonated(token, () =>
+ {
+ // The runtime's own files: readable through BUILTIN\Users.
+ File.ReadAllBytes(Path.Combine(Environment.SystemDirectory, "kernel32.dll")).Length.ShouldBeGreaterThan(0);
+ // The work directory: writable through the identity's ACE (positive control).
+ File.WriteAllText(Path.Combine(workDir, "ok.txt"), "x");
+ // The host's temp: the host account may, the identity may not, so the worker may not.
+ Should.Throw(() => File.ReadAllText(hostOnlyFile));
+ Should.Throw(() => File.WriteAllText(Path.Combine(temp.Path, "nope.txt"), "x"));
+ });
+ File.Exists(hostOnlyFile).ShouldBeTrue();
+ // Unrestricted, the same thread reads it (positive control for the negative assertion above).
+ File.ReadAllText(hostOnlyFile).ShouldBe("secret");
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static async Task AssertRestrictedLaunch()
+ {
+ var options = Scripts.Options();
+ options.Worker.Identity = ScriptWorkerIdentity.DefaultIdentity;
+ var sink = new CapturingSink();
+ await using (var runner = Scripts.Runner(options, sink.CreateLogger()))
+ {
+ runner.WorkerSid.ShouldBe(WorkerSid);
+ var outcome = await runner.RunAsync(Scripts.Vt("vt", "return (int)ctx.GetTag(\"x\").Value + 1;", inputs: Scripts.Input("x", 41)), Ct);
+ outcome.Status.ShouldBe(ScriptRunStatus.Ok, $"{outcome.Reason}; host log: {string.Join(" | ", sink.Events.Select(e => e.RenderMessage() + " " + e.Exception))}");
+ outcome.Value.ShouldBe(42);
+ var pid = runner.Shards[0].Current.ShouldNotBeNull().Pid;
+ WorkerTokenIsRestricted(pid).ShouldBeTrue("the worker runs under the restricted token");
+ }
+
+ await using (var plain = Scripts.Runner(Scripts.Options()))
+ {
+ (await plain.RunAsync(Scripts.Vt("vt", "return 1;"), Ct)).Status.ShouldBe(ScriptRunStatus.Ok);
+ WorkerTokenIsRestricted(plain.Shards[0].Current.ShouldNotBeNull().Pid)
+ .ShouldBeFalse("positive control: without an identity the worker keeps the host's token");
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static bool WorkerTokenIsRestricted(int pid)
+ {
+ using var process = System.Diagnostics.Process.GetProcessById(pid);
+ if (!OpenProcessToken(process.Handle, TokenQuery, out var token)) throw new Win32Exception(Marshal.GetLastWin32Error());
+ using (token) return IsTokenRestricted(token);
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static string[] PrivilegesIn(SafeAccessTokenHandle token)
+ {
+ GetTokenInformation(token, TokenPrivilegesClass, IntPtr.Zero, 0, out var length);
+ var buffer = Marshal.AllocHGlobal(length);
+ try
+ {
+ if (!GetTokenInformation(token, TokenPrivilegesClass, buffer, length, out _)) throw new Win32Exception(Marshal.GetLastWin32Error());
+ var count = Marshal.ReadInt32(buffer);
+ var names = new List();
+ for (var i = 0; i < count; i++)
+ {
+ var entry = buffer + 4 + (i * 12);
+ var luid = Marshal.ReadInt64(entry);
+ var size = 256;
+ var name = new System.Text.StringBuilder(size);
+ if (LookupPrivilegeNameW(null, ref luid, name, ref size)) names.Add(name.ToString());
+ }
+ return [.. names];
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(buffer);
+ }
+ }
+
+ private const uint TokenQuery = 0x0008;
+ private const int TokenPrivilegesClass = 3;
+
+#pragma warning disable SYSLIB1054
+ [DllImport("advapi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool OpenProcessToken(IntPtr process, uint access, out SafeAccessTokenHandle token);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool IsTokenRestricted(SafeAccessTokenHandle token);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetTokenInformation(SafeAccessTokenHandle token, int infoClass, IntPtr info, int length, out int returned);
+
+ [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool LookupPrivilegeNameW(string? systemName, ref long luid, System.Text.StringBuilder name, ref int size);
+#pragma warning restore SYSLIB1054
+}