[CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$CliPath, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedCliSha256, [Parameter(Mandatory = $true)] [ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')] [string]$ExpectedCliVersion, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string]$ExpectedSignerThumbprint, [Parameter(Mandatory = $true)] [ValidateRange(1, 2147483647)] [int]$ErpProcessId, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedErpSha256, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedDatabaseScopeFingerprint, [Parameter(Mandatory = $true)] [ValidateLength(1, 256)] [string]$ExpectedUserId, [Parameter(Mandatory = $true)] [ValidateLength(1, 256)] [string]$ExpectedUserName, [Parameter(Mandatory = $true)] [ValidateLength(1, 256)] [string]$ExpectedAccountBook, [Parameter(Mandatory = $true)] [ValidateLength(1, 256)] [string]$ExpectedSubSystemId, [Parameter(Mandatory = $true)] [bool]$ExpectedIsAdministrator, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$')] [string]$ExpectedRolloutCustomerId, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedRolloutPolicySha256, [Parameter(Mandatory = $true)] [ValidateCount(1, 16)] [string[]]$ModuleCodes, [Parameter(Mandatory = $true)][string]$OutputPath, [switch]$RequirePurchaseWorkflow, [switch]$RequireLeaveWorkflow, [switch]$RequireDiagnosisWorkflow, [ValidateRange(1000, 300000)] [int]$BridgeTimeoutMilliseconds = 180000 ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or [string]$PSVersionTable.PSEdition -ne 'Desktop' -or [string]::IsNullOrWhiteSpace($env:SystemRoot)) { throw 'lserp_readonly_session_preflight_failed:windows_powershell_51_required' } $utf8 = [Text.UTF8Encoding]::new($false, $true) $maximumResponseCharacters = 4 * 1024 * 1024 $cliLock = $null $erpLock = $null $workingDirectory = $null $outputFull = $null $published = $false $safeModuleCode = '^[A-Za-z0-9_.:-]{1,64}$' $safeCommandName = '^[a-z0-9][a-z0-9_.:-]{0,127}$' $safeVersion = '^[0-9]+(?:\.[0-9]+){0,3}$' $safeCorrelationId = '^[A-Za-z0-9_.:-]{8,128}$' $safeSha256 = '^[a-f0-9]{64}$' $expectedAdministratorText = if ($ExpectedIsAdministrator) { 'true' } else { 'false' } function Throw-PreflightError([string]$Code) { throw ('lserp_readonly_session_preflight_failed:' + $Code) } function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) { try { $current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory)) while ($null -ne $current) { if (-not $current.Exists -or (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Throw-PreflightError $Code } $current = $current.Parent } } catch { if ($_.Exception.Message.StartsWith( 'lserp_readonly_session_preflight_failed:')) { throw } Throw-PreflightError $Code } } function Resolve-RegularFile( [string]$Path, [long]$MaximumBytes, [string]$Code ) { try { $full = [IO.Path]::GetFullPath($Path) if (-not [IO.File]::Exists($full)) { Throw-PreflightError $Code } $item = Get-Item -LiteralPath $full -Force if ($item.Length -le 0 -or $item.Length -gt $MaximumBytes -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Throw-PreflightError $Code } Assert-NoReparseDirectoryChain ` ([IO.Path]::GetDirectoryName($full)) $Code return $full } catch { if ($_.Exception.Message.StartsWith( 'lserp_readonly_session_preflight_failed:')) { throw } Throw-PreflightError $Code } } function Resolve-NewJsonPath([string]$Path, [string]$Code) { try { $full = [IO.Path]::GetFullPath($Path) if ([IO.Path]::GetExtension($full) -ine '.json' -or [IO.File]::Exists($full) -or [IO.Directory]::Exists($full)) { Throw-PreflightError $Code } $directory = [IO.Path]::GetDirectoryName($full) Assert-NoReparseDirectoryChain $directory $Code return $full } catch { if ($_.Exception.Message.StartsWith( 'lserp_readonly_session_preflight_failed:')) { throw } Throw-PreflightError $Code } } function New-RestrictedWorkingDirectory { $path = $null try { $localApplicationData = [Environment]::GetFolderPath( [Environment+SpecialFolder]::LocalApplicationData) if ([string]::IsNullOrWhiteSpace($localApplicationData)) { Throw-PreflightError 'restricted_working_root_invalid' } $root = Join-Path $localApplicationData ` 'Langsu\Lserp\ReadOnlySessionPreflight' [IO.Directory]::CreateDirectory($root) | Out-Null Assert-NoReparseDirectoryChain $root 'restricted_working_root_invalid' $path = Join-Path $root ('run-' + [Guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($path) | Out-Null $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User $localSystem = [Security.Principal.SecurityIdentifier]::new( [Security.Principal.WellKnownSidType]::LocalSystemSid, $null) $security = New-Object Security.AccessControl.DirectorySecurity $security.SetOwner($currentUser) $security.SetAccessRuleProtection($true, $false) $inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit ` -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit $propagation = [Security.AccessControl.PropagationFlags]::None $allow = [Security.AccessControl.AccessControlType]::Allow foreach ($identity in @($currentUser, $localSystem)) { $rule = [Security.AccessControl.FileSystemAccessRule]::new( $identity, [Security.AccessControl.FileSystemRights]::FullControl, $inheritance, $propagation, $allow) $security.AddAccessRule($rule) } [IO.Directory]::SetAccessControl($path, $security) $verified = [IO.Directory]::GetAccessControl($path) if (-not $verified.AreAccessRulesProtected) { Throw-PreflightError 'restricted_working_acl_invalid' } return $path } catch { if ($null -ne $path -and [IO.Directory]::Exists($path)) { try { [IO.Directory]::Delete($path, $true) } catch { } } if ($_.Exception.Message.StartsWith( 'lserp_readonly_session_preflight_failed:')) { throw } Throw-PreflightError 'restricted_working_acl_invalid' } } function ConvertTo-WindowsProcessArgument([string]$Value) { if ($null -eq $Value -or $Value.Length -eq 0) { return '""' } if (-not [Text.RegularExpressions.Regex]::IsMatch($Value, '[\s"]')) { return $Value } $builder = New-Object Text.StringBuilder [void]$builder.Append([char]34) $slashes = 0 foreach ($character in $Value.ToCharArray()) { if ([int]$character -eq 92) { $slashes++ continue } if ([int]$character -eq 34) { for ($index = 0; $index -lt (($slashes * 2) + 1); $index++) { [void]$builder.Append([char]92) } [void]$builder.Append([char]34) } else { for ($index = 0; $index -lt $slashes; $index++) { [void]$builder.Append([char]92) } [void]$builder.Append($character) } $slashes = 0 } for ($index = 0; $index -lt ($slashes * 2); $index++) { [void]$builder.Append([char]92) } [void]$builder.Append([char]34) return $builder.ToString() } function Test-ExactProperties([object]$Value, [string[]]$Expected) { if ($null -eq $Value) { return $false } $names = @($Value.PSObject.Properties | ForEach-Object { $_.Name }) if ($names.Count -ne $Expected.Count) { return $false } foreach ($name in $Expected) { if ($names -cnotcontains $name) { return $false } } return $true } function Test-JsonArray([object]$Value) { return $null -ne $Value -and $Value -is [array] } function Read-JsonInt32( [object]$Raw, [int]$Minimum, [int]$Maximum, [string]$Code ) { if (($Raw -isnot [int] -and $Raw -isnot [long]) -or [long]$Raw -lt $Minimum -or [long]$Raw -gt $Maximum) { Throw-PreflightError $Code } return [int]$Raw } function Assert-SafeDisplayText( [object]$Raw, [int]$MinimumLength, [int]$MaximumLength, [string]$Code ) { if ($null -eq $Raw -or $Raw -isnot [string]) { Throw-PreflightError $Code } $value = [string]$Raw if ($value.Length -lt $MinimumLength -or $value.Length -gt $MaximumLength) { Throw-PreflightError $Code } foreach ($character in $value.ToCharArray()) { if ([char]::IsControl($character)) { Throw-PreflightError $Code } } } function Assert-ExpectedSessionValue([string]$Value, [string]$Code) { Assert-SafeDisplayText $Value 1 256 $Code if ([string]::IsNullOrWhiteSpace($Value) -or $Value -cne $Value.Trim()) { Throw-PreflightError $Code } } function Get-Sha256Hex([byte[]]$Bytes) { $sha = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString( $sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } } function Get-ScopedValueSha256([string]$Scope, [string]$Value) { return Get-Sha256Hex ($utf8.GetBytes( 'lserp-readonly-preflight-v1|' + $Scope + '|' + $Value)) } function Read-CliEnvelope([string]$Text, [int]$ExitCode) { try { if ([string]::IsNullOrWhiteSpace($Text) -or $Text.Length -gt $maximumResponseCharacters) { Throw-PreflightError 'cli_response_invalid' } $document = $Text | ConvertFrom-Json if ($null -eq $document -or $null -eq $document.ok -or $document.ok -isnot [bool] -or ([string]$document.correlationId) -cnotmatch $safeCorrelationId) { Throw-PreflightError 'cli_response_invalid' } if ($document.ok) { if ($ExitCode -ne 0 -or -not (Test-ExactProperties $document ` @('ok', 'correlationId', 'data')) -or $null -eq $document.data) { Throw-PreflightError 'cli_response_invalid' } } else { if ($ExitCode -eq 0 -or -not (Test-ExactProperties $document ` @('ok', 'correlationId', 'error')) -or -not (Test-ExactProperties $document.error ` @('code', 'message', 'exitCode')) -or ([string]$document.error.code) -cnotmatch ` '^[a-z0-9_.-]{1,128}$' -or [int]$document.error.exitCode -ne $ExitCode) { Throw-PreflightError 'cli_response_invalid' } } return $document } catch { if ($_.Exception.Message.StartsWith( 'lserp_readonly_session_preflight_failed:')) { throw } Throw-PreflightError 'cli_response_invalid' } } function Assert-TrustedCliUnchanged { $actual = (Get-FileHash -LiteralPath $script:cliFull ` -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -cne $script:expectedCliHash) { Throw-PreflightError 'cli_hash_changed' } } function Read-ExactErpProcessIdentity { $process = $null try { $process = Get-Process -Id $ErpProcessId -ErrorAction Stop if ($process.HasExited) { Throw-PreflightError 'erp_process_identity_invalid' } $executablePath = [string]$process.MainModule.FileName $startTimeUtcTicks = $process.StartTime.ToUniversalTime().Ticks if ([string]::IsNullOrWhiteSpace($executablePath) -or [IO.Path]::GetFileName($executablePath) -cne 'Ls_ERP.exe') { Throw-PreflightError 'erp_process_identity_invalid' } return [pscustomobject]@{ ExecutablePath = [IO.Path]::GetFullPath($executablePath) StartTimeUtcTicks = $startTimeUtcTicks } } catch { if ($_.Exception.Message.StartsWith( 'lserp_readonly_session_preflight_failed:')) { throw } Throw-PreflightError 'erp_process_identity_invalid' } finally { if ($null -ne $process) { $process.Dispose() } } } function Assert-TrustedErpUnchanged { $current = Read-ExactErpProcessIdentity if ($current.ExecutablePath -cne $script:erpFull -or $current.StartTimeUtcTicks -ne $script:erpStartTimeUtcTicks) { Throw-PreflightError 'erp_process_changed' } $actual = (Get-FileHash -LiteralPath $script:erpFull ` -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -cne $script:expectedErpHash) { Throw-PreflightError 'erp_hash_changed' } } function Assert-ReadOnlyArguments([string[]]$Arguments) { $identityShape = $Arguments.Count -eq 3 -and $Arguments[0] -ceq 'version' -and $Arguments[1] -ceq '--correlation-id' -and $Arguments[2] -cmatch $safeCorrelationId if ($identityShape) { return } $pidText = [string]$ErpProcessId $timeoutText = [string]$BridgeTimeoutMilliseconds $readShape = $Arguments.Count -eq 20 -and $Arguments[0] -ceq 'bridge' -and $Arguments[1] -cin @('health', 'context', 'capabilities') -and $Arguments[2] -ceq '--erp-process-id' -and $Arguments[3] -ceq $pidText -and $Arguments[4] -ceq '--expected-database-scope-fingerprint' -and $Arguments[5] -ceq $ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -and $Arguments[6] -ceq '--expected-user-id' -and $Arguments[7] -ceq $ExpectedUserId -and $Arguments[8] -ceq '--expected-user-name' -and $Arguments[9] -ceq $ExpectedUserName -and $Arguments[10] -ceq '--expected-account-book' -and $Arguments[11] -ceq $ExpectedAccountBook -and $Arguments[12] -ceq '--expected-subsystem-id' -and $Arguments[13] -ceq $ExpectedSubSystemId -and $Arguments[14] -ceq '--expected-is-administrator' -and $Arguments[15] -ceq $expectedAdministratorText -and $Arguments[16] -ceq '--timeout-ms' -and $Arguments[17] -ceq $timeoutText -and $Arguments[18] -ceq '--correlation-id' -and $Arguments[19] -cmatch $safeCorrelationId if ($readShape) { return } $planShape = $Arguments.Count -eq 23 -and $Arguments[0] -ceq 'bridge' -and $Arguments[1] -ceq 'plan' -and $Arguments[2] -ceq 'module.parameters' -and $Arguments[3] -ceq '--input' -and $Arguments[5] -ceq '--erp-process-id' -and $Arguments[6] -ceq $pidText -and $Arguments[7] -ceq '--expected-database-scope-fingerprint' -and $Arguments[8] -ceq $ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -and $Arguments[9] -ceq '--expected-user-id' -and $Arguments[10] -ceq $ExpectedUserId -and $Arguments[11] -ceq '--expected-user-name' -and $Arguments[12] -ceq $ExpectedUserName -and $Arguments[13] -ceq '--expected-account-book' -and $Arguments[14] -ceq $ExpectedAccountBook -and $Arguments[15] -ceq '--expected-subsystem-id' -and $Arguments[16] -ceq $ExpectedSubSystemId -and $Arguments[17] -ceq '--expected-is-administrator' -and $Arguments[18] -ceq $expectedAdministratorText -and $Arguments[19] -ceq '--timeout-ms' -and $Arguments[20] -ceq $timeoutText -and $Arguments[21] -ceq '--correlation-id' -and $Arguments[22] -cmatch $safeCorrelationId if ($planShape) { $input = Resolve-RegularFile $Arguments[4] (4KB) ` 'readonly_module_input_invalid' $workingRoot = [IO.Path]::GetFullPath($script:workingDirectory) $expectedPrefix = $workingRoot.TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar if (-not $input.StartsWith( $expectedPrefix, [StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetExtension($input) -ine '.json') { Throw-PreflightError 'readonly_module_input_invalid' } return } Throw-PreflightError 'readonly_command_shape_denied' } function Invoke-TrustedReadOnlyCli([string[]]$Arguments, [string]$Step) { Assert-ReadOnlyArguments $Arguments Assert-TrustedCliUnchanged $process = New-Object Diagnostics.Process try { $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = $script:cliFull $start.WorkingDirectory = [IO.Path]::GetDirectoryName($script:cliFull) $start.UseShellExecute = $false $start.CreateNoWindow = $true $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true $start.RedirectStandardInput = $true $start.StandardOutputEncoding = $utf8 $start.StandardErrorEncoding = $utf8 $start.Arguments = (($Arguments | ForEach-Object { ConvertTo-WindowsProcessArgument ([string]$_) }) -join ' ') $process.StartInfo = $start if (-not $process.Start()) { Throw-PreflightError ($Step + '_process_start_failed') } $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() $process.StandardInput.Close() $processTimeout = [Math]::Min( 330000, $BridgeTimeoutMilliseconds + 30000) if (-not $process.WaitForExit($processTimeout)) { try { $process.Kill() } catch { } Throw-PreflightError ($Step + '_timeout') } $process.WaitForExit() $stdout = $stdoutTask.Result $stderr = $stderrTask.Result if (($process.ExitCode -eq 0 -and ([string]::IsNullOrWhiteSpace($stdout) -or -not [string]::IsNullOrWhiteSpace($stderr))) -or ($process.ExitCode -ne 0 -and ([string]::IsNullOrWhiteSpace($stderr) -or -not [string]::IsNullOrWhiteSpace($stdout)))) { Throw-PreflightError ($Step + '_stream_contract_invalid') } $raw = if ($process.ExitCode -eq 0) { $stdout } else { $stderr } $envelope = Read-CliEnvelope $raw $process.ExitCode if (-not $envelope.ok) { Throw-PreflightError ( $Step + '_' + [string]$envelope.error.code) } return $envelope } catch { if ($_.Exception.Message.StartsWith( 'lserp_readonly_session_preflight_failed:')) { throw } Throw-PreflightError ($Step + '_process_failed') } finally { $process.Dispose() } } function Invoke-ReadBridge([string]$Action, [string]$Step) { if ($Action -cnotin @('health', 'context', 'capabilities')) { Throw-PreflightError 'readonly_bridge_action_denied' } return Invoke-TrustedReadOnlyCli @( 'bridge', $Action, '--erp-process-id', [string]$ErpProcessId, '--expected-database-scope-fingerprint', $ExpectedDatabaseScopeFingerprint.ToLowerInvariant(), '--expected-user-id', $ExpectedUserId, '--expected-user-name', $ExpectedUserName, '--expected-account-book', $ExpectedAccountBook, '--expected-subsystem-id', $ExpectedSubSystemId, '--expected-is-administrator', $expectedAdministratorText, '--timeout-ms', [string]$BridgeTimeoutMilliseconds, '--correlation-id', ('readonly-' + $Action + '-' + [Guid]::NewGuid().ToString('N')) ) $Step } function Read-RuntimeCliIdentity([object]$Data) { if (-not (Test-ExactProperties $Data @( 'component', 'version', 'protocolVersion', 'bridgeOnly', 'databaseDirectAccess', 'sessionSource')) -or [string]$Data.component -cne 'lserp-agent-cli' -or [string]$Data.version -cne $ExpectedCliVersion -or [string]$Data.protocolVersion -cne '1.0' -or $Data.bridgeOnly -isnot [bool] -or $Data.bridgeOnly -ne $true -or $Data.databaseDirectAccess -isnot [bool] -or $Data.databaseDirectAccess -ne $false -or [string]$Data.sessionSource -cne 'current_logged_in_erp_process') { Throw-PreflightError 'cli_runtime_identity_invalid' } return [pscustomobject]@{ Component = [string]$Data.component Version = [string]$Data.version ProtocolVersion = [string]$Data.protocolVersion BridgeOnly = [bool]$Data.bridgeOnly DatabaseDirectAccess = [bool]$Data.databaseDirectAccess SessionSource = [string]$Data.sessionSource } } function Write-NewUtf8File([string]$Path, [string]$Text) { $bytes = $utf8.GetBytes($Text) $stream = [IO.FileStream]::new( $Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { $stream.Write($bytes, 0, $bytes.Length) $stream.Flush() } finally { $stream.Dispose() } } function Invoke-ModuleParameterPlan([string]$ModuleCode, [int]$Index) { $inputPath = Join-Path $script:workingDirectory ( 'module-' + $Index.ToString('D2') + '.json') $input = [ordered]@{ moduleCode = $ModuleCode } Write-NewUtf8File $inputPath ( $input | ConvertTo-Json -Compress -Depth 4) return Invoke-TrustedReadOnlyCli @( 'bridge', 'plan', 'module.parameters', '--input', $inputPath, '--erp-process-id', [string]$ErpProcessId, '--expected-database-scope-fingerprint', $ExpectedDatabaseScopeFingerprint.ToLowerInvariant(), '--expected-user-id', $ExpectedUserId, '--expected-user-name', $ExpectedUserName, '--expected-account-book', $ExpectedAccountBook, '--expected-subsystem-id', $ExpectedSubSystemId, '--expected-is-administrator', $expectedAdministratorText, '--timeout-ms', [string]$BridgeTimeoutMilliseconds, '--correlation-id', ('readonly-module-' + [Guid]::NewGuid().ToString('N')) ) ('module_parameters_' + $Index.ToString('D2')) } function Assert-SafeContextModule([object]$Module, [string]$Code) { if (-not (Test-ExactProperties $Module ` @('moduleCode', 'navigationCode', 'moduleName')) -or ([string]$Module.moduleCode) -cnotmatch $safeModuleCode -or ([string]$Module.navigationCode) -cnotmatch $safeModuleCode) { Throw-PreflightError $Code } Assert-SafeDisplayText $Module.moduleName 1 128 $Code } function Read-SessionContext([object]$Data, [string]$Code) { if (-not (Test-ExactProperties $Data @( 'userId', 'userName', 'accountBook', 'subSystemId', 'databaseScopeFingerprint', 'subSystemName', 'isAdministrator', 'activeModule', 'openModuleCount', 'openModulesTruncated', 'openModules'))) { Throw-PreflightError $Code } foreach ($field in @('userId', 'userName', 'accountBook', 'subSystemId', 'subSystemName')) { Assert-SafeDisplayText $Data.$field 1 256 $Code } $databaseScope = ([string]$Data.databaseScopeFingerprint).ToLowerInvariant() if ($databaseScope -cnotmatch $safeSha256 -or $databaseScope -cne $ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -or $Data.isAdministrator -isnot [bool] -or $Data.openModulesTruncated -isnot [bool]) { Throw-PreflightError $Code } if (([string]$Data.userId) -cne $ExpectedUserId -or ([string]$Data.userName) -cne $ExpectedUserName -or ([string]$Data.accountBook) -cne $ExpectedAccountBook -or ([string]$Data.subSystemId) -cne $ExpectedSubSystemId -or [bool]$Data.isAdministrator -ne $ExpectedIsAdministrator) { Throw-PreflightError 'expected_session_scope_mismatch' } $openCount = 0 if (-not [int]::TryParse( [string]$Data.openModuleCount, [ref]$openCount) -or $openCount -lt 0 -or $openCount -gt 10000) { Throw-PreflightError $Code } $openModules = @($Data.openModules) if ($openModules.Count -gt 50 -or $openCount -lt $openModules.Count -or [bool]$Data.openModulesTruncated -ne ($openCount -gt $openModules.Count)) { Throw-PreflightError $Code } $navigationCodes = New-Object ` 'System.Collections.Generic.HashSet[string]' ` ([StringComparer]::OrdinalIgnoreCase) foreach ($module in $openModules) { Assert-SafeContextModule $module $Code if (-not $navigationCodes.Add([string]$module.navigationCode)) { Throw-PreflightError $Code } } if ($null -ne $Data.activeModule) { Assert-SafeContextModule $Data.activeModule $Code if (-not $Data.openModulesTruncated -and -not $navigationCodes.Contains( [string]$Data.activeModule.navigationCode)) { Throw-PreflightError $Code } } return [pscustomobject]@{ UserId = [string]$Data.userId UserName = [string]$Data.userName AccountBook = [string]$Data.accountBook SubSystemId = [string]$Data.subSystemId SubSystemName = [string]$Data.subSystemName DatabaseScopeFingerprint = $databaseScope IsAdministrator = [bool]$Data.isAdministrator ActiveModule = $Data.activeModule OpenModuleCount = $openCount } } function Test-SameSession([object]$First, [object]$Second) { return $First.UserId -ceq $Second.UserId -and $First.UserName -ceq $Second.UserName -and $First.AccountBook -ceq $Second.AccountBook -and $First.SubSystemId -ceq $Second.SubSystemId -and $First.SubSystemName -ceq $Second.SubSystemName -and $First.DatabaseScopeFingerprint -ceq $Second.DatabaseScopeFingerprint -and $First.IsAdministrator -eq $Second.IsAdministrator } function Read-Health([object]$Data) { if (-not (Test-ExactProperties $Data @( 'status', 'protocolVersion', 'serverTimeUtc', 'commandCount', 'enabledCommandCount', 'operationalPolicy', 'rolloutPolicy', 'workflowUat')) -or [string]$Data.status -cne 'ready' -or [string]$Data.protocolVersion -cne '1.0' -or -not (Test-ExactProperties $Data.rolloutPolicy @( 'configured', 'failClosed', 'customerId', 'databaseScopeFingerprint', 'sourceSha256', 'defaultAction', 'ruleCount')) -or $Data.rolloutPolicy.configured -ne $true -or $Data.rolloutPolicy.failClosed -ne $true -or [string]$Data.rolloutPolicy.defaultAction -cne 'deny' -or [string]$Data.rolloutPolicy.customerId -cne $ExpectedRolloutCustomerId -or ([string]$Data.rolloutPolicy.databaseScopeFingerprint).ToLowerInvariant() ` -cne $ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -or ([string]$Data.rolloutPolicy.sourceSha256).ToLowerInvariant() -cne $ExpectedRolloutPolicySha256.ToLowerInvariant()) { Throw-PreflightError 'bridge_health_invalid' } $total = 0 $enabled = 0 $ruleCount = 0 if (-not [int]::TryParse([string]$Data.commandCount, [ref]$total) -or -not [int]::TryParse( [string]$Data.enabledCommandCount, [ref]$enabled) -or -not [int]::TryParse( [string]$Data.rolloutPolicy.ruleCount, [ref]$ruleCount) -or $total -lt 1 -or $total -gt 512 -or $enabled -lt 1 -or $enabled -gt $total -or $ruleCount -lt 1 -or $ruleCount -gt 128) { Throw-PreflightError 'bridge_health_invalid' } return [pscustomobject]@{ CommandCount = $total EnabledCommandCount = $enabled RuleCount = $ruleCount } } function Read-Capabilities([object]$Data) { if (-not (Test-ExactProperties $Data @('commands'))) { Throw-PreflightError 'bridge_capabilities_invalid' } $commands = @($Data.commands) if ($commands.Count -lt 1 -or $commands.Count -gt 512) { Throw-PreflightError 'bridge_capabilities_invalid' } $map = [Collections.Generic.Dictionary[string, object]]::new( [StringComparer]::Ordinal) foreach ($command in $commands) { if (-not (Test-ExactProperties $command @( 'name', 'version', 'description', 'schemaVersion', 'inputSchema', 'risk', 'requiresConfirmation', 'requiresIdempotencyKey')) -or ([string]$command.name) -cnotmatch $safeCommandName -or ([string]$command.version) -cnotmatch $safeVersion -or ([string]$command.schemaVersion) -cnotmatch $safeVersion -or ([string]$command.risk) -cnotin @( 'read', 'draft', 'navigate', 'write', 'critical') -or $command.requiresConfirmation -isnot [bool] -or $command.requiresIdempotencyKey -isnot [bool] -or $null -eq $command.inputSchema -or $map.ContainsKey([string]$command.name)) { Throw-PreflightError 'bridge_capabilities_invalid' } Assert-SafeDisplayText $command.description 1 512 ` 'bridge_capabilities_invalid' $map.Add([string]$command.name, $command) } return $map } function Test-CapabilityContract( [object]$Actual, [string]$Version, [string]$SchemaVersion, [string]$Risk, [bool]$Confirmation, [bool]$Idempotency ) { return $null -ne $Actual -and [string]$Actual.version -ceq $Version -and [string]$Actual.schemaVersion -ceq $SchemaVersion -and [string]$Actual.risk -ceq $Risk -and [bool]$Actual.requiresConfirmation -eq $Confirmation -and [bool]$Actual.requiresIdempotencyKey -eq $Idempotency } function Read-NativeExecutionProfile( [object]$Profile, [string]$Action, [string]$ModuleKind ) { if ($null -eq $Profile -or $Profile.available -isnot [bool]) { Throw-PreflightError 'module_native_execution_profile_invalid' } if ([bool]$Profile.available) { if (-not (Test-ExactProperties $Profile @( 'available', 'nativeSaveFamily', 'profileFingerprint')) -or ([string]$Profile.profileFingerprint) -cnotmatch $safeSha256) { Throw-PreflightError 'module_native_execution_profile_invalid' } $family = [string]$Profile.nativeSaveFamily $allowedFamilies = if ($ModuleKind -ceq 'document') { @( 'legacy.bill-save.p-bill-save-pr3', 'legacy.bill-save.p-bill-save-pr70' ) } else { @( 'legacy.base-save.p-base-save', 'legacy.base-save.p-base-save70' ) } if ($family -cnotin $allowedFamilies -or ($Action -ceq 'update' -and $ModuleKind -ceq 'document')) { Throw-PreflightError 'module_native_execution_profile_invalid' } return [ordered]@{ available = $true nativeSaveFamily = $family profileFingerprint = ([string]$Profile.profileFingerprint).ToLowerInvariant() code = $null } } if (-not (Test-ExactProperties $Profile @('available', 'code')) -or ([string]$Profile.code) -cnotmatch '^[a-z0-9_.-]{1,128}$' -or ($Action -ceq 'update' -and $ModuleKind -ceq 'document' -and [string]$Profile.code -cne 'dynamic_module_update_bill_unsupported')) { Throw-PreflightError 'module_native_execution_profile_invalid' } return [ordered]@{ available = $false nativeSaveFamily = $null profileFingerprint = $null code = [string]$Profile.code } } function Assert-ExactParameterIds( [object]$Raw, [string[]]$Expected, [string]$Code ) { if (-not (Test-JsonArray $Raw)) { Throw-PreflightError $Code } $actual = @($Raw) $expectedValues = @($Expected) if ($actual.Count -ne $expectedValues.Count) { Throw-PreflightError $Code } for ($index = 0; $index -lt $actual.Count; $index++) { if ($actual[$index] -isnot [string] -or [string]$actual[$index] -cne [string]$expectedValues[$index]) { Throw-PreflightError $Code } } } function Read-ParameterPayloadContract( [object]$Payload, [string]$ModuleKind, [string]$ConfigurationFingerprint, [object[]]$MasterParameters, [object[]]$DetailParameters ) { if (-not (Test-ExactProperties $Payload @( 'format', 'valueEncoding', 'moduleCodeRequired', 'configurationFingerprint', 'masterValuesRequired', 'detailRowsRequired', 'minimumDetailRows', 'maximumDetailRows', 'masterParameterIds', 'requiredMasterParameterIds', 'detailParameterIds', 'requiredDetailParameterIds', 'unknownParameterPolicy', 'duplicateParameterPolicy', 'lookupPolicy', 'fieldConstraintPolicy', 'configurationDriftPolicy')) -or [string]$Payload.format -cne 'parameter_entries_v1' -or [string]$Payload.valueEncoding -cne 'invariant_text' -or $Payload.moduleCodeRequired -isnot [bool] -or $Payload.moduleCodeRequired -ne $true -or ([string]$Payload.configurationFingerprint).ToLowerInvariant() ` -cne $ConfigurationFingerprint -or $Payload.masterValuesRequired -isnot [bool] -or $Payload.masterValuesRequired -ne $true -or $Payload.detailRowsRequired -isnot [bool] -or [string]$Payload.unknownParameterPolicy -cne 'reject' -or [string]$Payload.duplicateParameterPolicy -cne 'reject' -or [string]$Payload.lookupPolicy -cne 'server_resolve_unique_or_stop' -or [string]$Payload.fieldConstraintPolicy -cne 'server_enforced_from_current_low_code_configuration' -or [string]$Payload.configurationDriftPolicy -cne 'reject_and_replan') { Throw-PreflightError 'module_parameter_payload_contract_invalid' } $document = $ModuleKind -ceq 'document' $minimumRows = Read-JsonInt32 ` $Payload.minimumDetailRows 0 1 ` 'module_parameter_payload_contract_invalid' $maximumRows = Read-JsonInt32 ` $Payload.maximumDetailRows 0 1000 ` 'module_parameter_payload_contract_invalid' $expectedMinimumRows = if ($document) { 1 } else { 0 } $expectedMaximumRows = if ($document) { 1000 } else { 0 } if ([bool]$Payload.detailRowsRequired -ne $document -or $minimumRows -ne $expectedMinimumRows -or $maximumRows -ne $expectedMaximumRows) { Throw-PreflightError 'module_parameter_payload_contract_invalid' } $editableMaster = @($MasterParameters | Where-Object { $_.editable -eq $true } | ForEach-Object { [string]$_.parameterId }) $requiredMaster = @($MasterParameters | Where-Object { $_.required -eq $true } | ForEach-Object { [string]$_.parameterId }) $editableDetail = @($DetailParameters | Where-Object { $_.editable -eq $true } | ForEach-Object { [string]$_.parameterId }) $requiredDetail = @($DetailParameters | Where-Object { $_.required -eq $true } | ForEach-Object { [string]$_.parameterId }) Assert-ExactParameterIds ` $Payload.masterParameterIds $editableMaster ` 'module_parameter_payload_contract_invalid' Assert-ExactParameterIds ` $Payload.requiredMasterParameterIds $requiredMaster ` 'module_parameter_payload_contract_invalid' Assert-ExactParameterIds ` $Payload.detailParameterIds $editableDetail ` 'module_parameter_payload_contract_invalid' Assert-ExactParameterIds ` $Payload.requiredDetailParameterIds $requiredDetail ` 'module_parameter_payload_contract_invalid' return [ordered]@{ format = 'parameter_entries_v1' valueEncoding = 'invariant_text' moduleCodeRequired = $true masterValuesRequired = $true detailRowsRequired = $document minimumDetailRows = $minimumRows maximumDetailRows = $maximumRows masterParameterIdCount = $editableMaster.Count requiredMasterParameterIdCount = $requiredMaster.Count detailParameterIdCount = $editableDetail.Count requiredDetailParameterIdCount = $requiredDetail.Count unknownParameterPolicy = 'reject' duplicateParameterPolicy = 'reject' lookupPolicy = 'server_resolve_unique_or_stop' fieldConstraintPolicy = 'server_enforced_from_current_low_code_configuration' configurationDriftPolicy = 'reject_and_replan' } } function Read-ModuleExecutionReadiness( [object]$Available, [object]$Command, [object]$ReadinessCode, [object]$Blocker, [string]$Action, [string]$ModuleKind, [object]$NativeProfile ) { $expectedCommand = if ($Action -ceq 'create') { 'module.record.create' } else { 'module.record.resolve-update' } $expectedReadyCode = if ($Action -ceq 'create') { 'dynamic_module_write_ready' } else { 'dynamic_module_update_ready' } if ($Available -isnot [bool] -or $ReadinessCode -isnot [string] -or [string]$ReadinessCode -cnotmatch '^[a-z0-9_.-]{1,128}$') { Throw-PreflightError 'module_parameter_readiness_invalid' } if ([bool]$Available) { if ($Command -isnot [string] -or [string]$Command -cne $expectedCommand -or [string]$ReadinessCode -cne $expectedReadyCode -or $null -ne $Blocker -or $NativeProfile.available -ne $true -or ($Action -ceq 'update' -and $ModuleKind -ceq 'document')) { Throw-PreflightError 'module_parameter_readiness_invalid' } } else { if ($null -ne $Command -or [string]$ReadinessCode -ceq $expectedReadyCode) { Throw-PreflightError 'module_parameter_readiness_invalid' } Assert-SafeDisplayText ` $Blocker 1 512 'module_parameter_readiness_invalid' } if ($Action -ceq 'update' -and $ModuleKind -ceq 'document' -and ([bool]$Available -or [string]$ReadinessCode -cne 'dynamic_module_update_bill_unsupported')) { Throw-PreflightError 'module_parameter_readiness_invalid' } return [ordered]@{ available = [bool]$Available blocked = -not [bool]$Available command = if ($null -eq $Command) { $null } else { [string]$Command } readinessCode = [string]$ReadinessCode } } function Read-Parameter( [object]$Parameter, [string]$ExpectedScope, [Collections.Generic.HashSet[string]]$Ids, [hashtable]$Counts ) { if (-not (Test-ExactProperties $Parameter @( 'parameterId', 'label', 'scope', 'valueType', 'valueFormat', 'controlTypeId', 'required', 'editable', 'inputSupported', 'inputMode', 'requiresDedicatedAdapter', 'hasDefault', 'requiresLookup', 'maximumEncodedBytes', 'maximumDecimalPlaces')) -or ([string]$Parameter.parameterId) -cnotmatch '^[md][a-f0-9]{16}$' -or [string]$Parameter.scope -cne $ExpectedScope -or ([string]$Parameter.inputMode) -cnotin @( 'scalar', 'lookup-single', 'unsupported') -or $Parameter.required -isnot [bool] -or $Parameter.editable -isnot [bool] -or $Parameter.inputSupported -isnot [bool] -or $Parameter.requiresDedicatedAdapter -isnot [bool] -or $Parameter.hasDefault -isnot [bool] -or $Parameter.requiresLookup -isnot [bool] -or -not $Ids.Add([string]$Parameter.parameterId)) { Throw-PreflightError 'module_parameter_contract_invalid' } Assert-SafeDisplayText $Parameter.label 1 128 ` 'module_parameter_contract_invalid' if ($Parameter.valueType -isnot [string] -or $Parameter.valueFormat -isnot [string] -or (([string]$Parameter.valueType + '|' + [string]$Parameter.valueFormat) -cnotin @( 'string|utf8-text', 'number|invariant-decimal', 'integer|invariant-integer', 'boolean|true-or-false', 'date|yyyy-MM-dd', 'local-date-time|yyyy-MM-ddTHH:mm:ss', 'local-date-time|yyyy-MM-ddTHH:mm', 'time|HH:mm:ss', 'time|HH:mm', 'year-month|yyyy-MM', 'local-date-half-day|yyyy-MM-dd|am-or-pm' ))) { Throw-PreflightError 'module_parameter_contract_invalid' } [void](Read-JsonInt32 ` $Parameter.controlTypeId 0 100000 ` 'module_parameter_contract_invalid') if ($null -ne $Parameter.maximumEncodedBytes) { [void](Read-JsonInt32 ` $Parameter.maximumEncodedBytes 1 1048576 ` 'module_parameter_contract_invalid') } if ($null -ne $Parameter.maximumDecimalPlaces) { [void](Read-JsonInt32 ` $Parameter.maximumDecimalPlaces 0 28 ` 'module_parameter_contract_invalid') } $mode = [string]$Parameter.inputMode if (($mode -ceq 'scalar' -and (-not $Parameter.inputSupported -or $Parameter.requiresDedicatedAdapter -or $Parameter.requiresLookup)) -or ($mode -ceq 'lookup-single' -and (-not $Parameter.inputSupported -or $Parameter.requiresDedicatedAdapter -or -not $Parameter.requiresLookup)) -or ($mode -ceq 'unsupported' -and ($Parameter.inputSupported -or $Parameter.editable -or -not $Parameter.requiresDedicatedAdapter)) -or ($Parameter.required -and (-not $Parameter.editable -or $Parameter.hasDefault)) -or ($null -ne $Parameter.maximumDecimalPlaces -and [string]$Parameter.valueType -cne 'number')) { Throw-PreflightError 'module_parameter_contract_invalid' } $Counts[$mode] = [int]$Counts[$mode] + 1 if ($Parameter.editable) { $Counts.editable++ } if ($Parameter.required) { $Counts.required++ } if ($Parameter.requiresDedicatedAdapter) { $Counts.dedicated++ } } function Read-ModuleParameterContract( [object]$Data, [string]$RequestedCode, [object]$Session ) { if (-not (Test-ExactProperties $Data @('plan'))) { Throw-PreflightError 'module_parameter_plan_invalid' } $plan = $Data.plan if (-not (Test-ExactProperties $plan @( 'planId', 'commandName', 'commandVersion', 'moduleCode', 'risk', 'createdAtUtc', 'expiresAtUtc', 'valid', 'executionAllowed', 'inputFingerprint', 'outcomeCode', 'title', 'preview', 'data', 'warnings')) -or ([string]$plan.planId) -cnotmatch '^[A-Fa-f0-9]{32}$' -or [string]$plan.commandName -cne 'module.parameters' -or [string]$plan.commandVersion -cne '1.1' -or ([string]$plan.moduleCode) -cnotmatch $safeModuleCode -or [string]$plan.risk -cne 'read' -or $plan.valid -ne $true -or $plan.executionAllowed -ne $false -or ([string]$plan.inputFingerprint) -cnotmatch $safeSha256 -or [string]$plan.outcomeCode -cne 'module_parameters_ready' -or $null -ne $plan.preview -or -not (Test-JsonArray $plan.warnings) -or @($plan.warnings).Count -ne 0 -or -not (Test-ExactProperties $plan.data @( 'title', 'outcomeCode', 'parameterContract')) -or [string]$plan.data.outcomeCode -cne 'module_parameters_ready') { Throw-PreflightError 'module_parameter_plan_invalid' } $contract = $plan.data.parameterContract if (-not (Test-ExactProperties $contract @( 'schemaVersion', 'source', 'metadataTrust', 'moduleCode', 'moduleName', 'moduleKind', 'configurationFingerprint', 'sessionScope', 'masterParameterCount', 'detailParameterCount', 'masterParameters', 'detailParameters', 'nativeExecutionProfiles', 'payloadContract', 'genericWriteExecutionAvailable', 'writeExecutionBlocker', 'contractFingerprint', 'writeCommand', 'writeReadinessCode', 'genericUpdateExecutionAvailable', 'updateCommand', 'updateReadinessCode', 'updateExecutionBlocker')) -or [string]$contract.schemaVersion -cne '1.1' -or [string]$contract.source -cne 'current_erp_database_low_code_configuration' -or [string]$contract.metadataTrust -cne 'untrusted_display_data' -or [string]$contract.moduleCode -cne [string]$plan.moduleCode -or ([string]$contract.moduleCode) -cnotmatch $safeModuleCode -or ([string]$contract.moduleKind) -cnotin @( 'document', 'master_data') -or ([string]$contract.configurationFingerprint) -cnotmatch $safeSha256 -or ([string]$contract.contractFingerprint) -cnotmatch $safeSha256 -or -not (Test-JsonArray $contract.masterParameters) -or -not (Test-JsonArray $contract.detailParameters) -or -not (Test-ExactProperties $contract.nativeExecutionProfiles @( 'create', 'update')) -or $contract.genericWriteExecutionAvailable -isnot [bool] -or $contract.genericUpdateExecutionAvailable -isnot [bool]) { Throw-PreflightError 'module_parameter_contract_invalid' } Assert-SafeDisplayText $contract.moduleName 1 128 ` 'module_parameter_contract_invalid' if (-not (Test-ExactProperties $contract.sessionScope @( 'userId', 'accountBook', 'subSystemId', 'databaseScopeFingerprint')) -or [string]$contract.sessionScope.userId -cne $Session.UserId -or [string]$contract.sessionScope.accountBook -cne $Session.AccountBook -or [string]$contract.sessionScope.subSystemId -cne $Session.SubSystemId -or ([string]$contract.sessionScope.databaseScopeFingerprint).ToLowerInvariant() ` -cne $Session.DatabaseScopeFingerprint) { Throw-PreflightError 'module_parameter_scope_mismatch' } $masterCount = Read-JsonInt32 ` $contract.masterParameterCount 0 512 ` 'module_parameter_contract_invalid' $detailCount = Read-JsonInt32 ` $contract.detailParameterCount 0 512 ` 'module_parameter_contract_invalid' $masterParameters = @($contract.masterParameters) $detailParameters = @($contract.detailParameters) if ($masterCount + $detailCount -gt 512 -or ([string]$contract.moduleKind -ceq 'master_data' -and $detailCount -ne 0) -or $masterParameters.Count -ne $masterCount -or $detailParameters.Count -ne $detailCount) { Throw-PreflightError 'module_parameter_contract_invalid' } $ids = New-Object 'System.Collections.Generic.HashSet[string]' ` ([StringComparer]::Ordinal) $counts = @{ scalar = 0 'lookup-single' = 0 unsupported = 0 editable = 0 required = 0 dedicated = 0 } foreach ($parameter in $masterParameters) { Read-Parameter $parameter 'master' $ids $counts } foreach ($parameter in $detailParameters) { Read-Parameter $parameter 'detail' $ids $counts } $moduleKind = [string]$contract.moduleKind $configurationFingerprint = ([string]$contract.configurationFingerprint).ToLowerInvariant() $createProfile = Read-NativeExecutionProfile ` $contract.nativeExecutionProfiles.create 'create' $moduleKind $updateProfile = Read-NativeExecutionProfile ` $contract.nativeExecutionProfiles.update 'update' $moduleKind $payloadSummary = Read-ParameterPayloadContract ` $contract.payloadContract ` $moduleKind ` $configurationFingerprint ` $masterParameters ` $detailParameters $writeReadiness = Read-ModuleExecutionReadiness ` $contract.genericWriteExecutionAvailable ` $contract.writeCommand ` $contract.writeReadinessCode ` $contract.writeExecutionBlocker ` 'create' ` $moduleKind ` $createProfile $updateReadiness = Read-ModuleExecutionReadiness ` $contract.genericUpdateExecutionAvailable ` $contract.updateCommand ` $contract.updateReadinessCode ` $contract.updateExecutionBlocker ` 'update' ` $moduleKind ` $updateProfile return [ordered]@{ requestedCode = $RequestedCode moduleCode = [string]$contract.moduleCode moduleKind = $moduleKind contractSource = 'current_erp_database_low_code_configuration' sessionScopeBound = $true configurationFingerprint = $configurationFingerprint contractFingerprint = ([string]$contract.contractFingerprint).ToLowerInvariant() masterParameterCount = $masterCount detailParameterCount = $detailCount inputModeCounts = [ordered]@{ scalar = [int]$counts.scalar lookupSingle = [int]$counts['lookup-single'] unsupported = [int]$counts.unsupported } editableParameterCount = [int]$counts.editable requiredParameterCount = [int]$counts.required dedicatedAdapterParameterCount = [int]$counts.dedicated nativeExecutionProfiles = [ordered]@{ create = $createProfile update = $updateProfile } payloadContract = $payloadSummary genericWriteExecutionAvailable = $writeReadiness.available writeExecutionBlocked = $writeReadiness.blocked writeCommand = $writeReadiness.command writeReadinessCode = $writeReadiness.readinessCode genericUpdateExecutionAvailable = $updateReadiness.available updateExecutionBlocked = $updateReadiness.blocked updateCommand = $updateReadiness.command updateReadinessCode = $updateReadiness.readinessCode planExecutionAllowed = $false } } function Write-ReportCreateNew([object]$Report, [string]$Path) { $json = ($Report | ConvertTo-Json -Depth 12) + [Environment]::NewLine Write-NewUtf8File $Path $json } try { Assert-ExpectedSessionValue ` $ExpectedUserId 'expected_user_id_invalid' Assert-ExpectedSessionValue ` $ExpectedUserName 'expected_user_name_invalid' Assert-ExpectedSessionValue ` $ExpectedAccountBook 'expected_account_book_invalid' Assert-ExpectedSessionValue ` $ExpectedSubSystemId 'expected_subsystem_id_invalid' if ($RequireDiagnosisWorkflow -and -not $ExpectedIsAdministrator) { Throw-PreflightError 'diagnosis_requires_expected_administrator' } $normalizedModules = New-Object System.Collections.Generic.List[string] $seenModules = New-Object 'System.Collections.Generic.HashSet[string]' ` ([StringComparer]::OrdinalIgnoreCase) foreach ($rawCode in $ModuleCodes) { $code = if ($null -eq $rawCode) { '' } else { $rawCode.Trim() } if ($code -cnotmatch $safeModuleCode -or -not $seenModules.Add($code)) { Throw-PreflightError 'module_codes_invalid' } $normalizedModules.Add($code) } $cliFull = Resolve-RegularFile $CliPath 128MB 'cli_file_invalid' if ([IO.Path]::GetFileName($cliFull) -ine 'lserp-agent-cli.exe') { Throw-PreflightError 'cli_filename_invalid' } $outputFull = Resolve-NewJsonPath $OutputPath 'output_path_invalid' if ($cliFull -ieq $outputFull) { Throw-PreflightError 'trusted_path_conflict' } $erpIdentity = Read-ExactErpProcessIdentity $erpFull = Resolve-RegularFile ` $erpIdentity.ExecutablePath 1GB 'erp_file_invalid' if ($erpFull -ieq $cliFull -or $erpFull -ieq $outputFull) { Throw-PreflightError 'trusted_path_conflict' } $erpStartTimeUtcTicks = $erpIdentity.StartTimeUtcTicks $cliLock = [IO.File]::Open( $cliFull, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $expectedCliHash = $ExpectedCliSha256.ToLowerInvariant() Assert-TrustedCliUnchanged $signature = Get-AuthenticodeSignature -LiteralPath $cliFull $actualSignerThumbprint = if ($null -eq $signature.SignerCertificate) { '' } else { ([string]$signature.SignerCertificate.Thumbprint).Replace( ' ', '').ToUpperInvariant() } if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or $actualSignerThumbprint -cne $ExpectedSignerThumbprint.ToUpperInvariant()) { Throw-PreflightError 'cli_signature_invalid' } $erpLock = [IO.File]::Open( $erpFull, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $expectedErpHash = $ExpectedErpSha256.ToLowerInvariant() Assert-TrustedErpUnchanged $erpSignature = Get-AuthenticodeSignature -LiteralPath $erpFull $actualErpSignerThumbprint = if ( $null -eq $erpSignature.SignerCertificate) { '' } else { ([string]$erpSignature.SignerCertificate.Thumbprint).Replace( ' ', '').ToUpperInvariant() } if ($erpSignature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or $actualErpSignerThumbprint -cne $ExpectedSignerThumbprint.ToUpperInvariant()) { Throw-PreflightError 'erp_signature_invalid' } $workingDirectory = New-RestrictedWorkingDirectory $identityEnvelope = Invoke-TrustedReadOnlyCli @( 'version', '--correlation-id', ('readonly-version-' + [Guid]::NewGuid().ToString('N')) ) 'cli_runtime_identity' $runtimeCliIdentity = Read-RuntimeCliIdentity $identityEnvelope.data $healthEnvelope = Invoke-ReadBridge 'health' 'bridge_health' $health = Read-Health $healthEnvelope.data $firstContextEnvelope = Invoke-ReadBridge 'context' 'bridge_context_before' $firstSession = Read-SessionContext ` $firstContextEnvelope.data 'bridge_context_before_invalid' $capabilitiesEnvelope = Invoke-ReadBridge ` 'capabilities' 'bridge_capabilities' $capabilities = Read-Capabilities $capabilitiesEnvelope.data $targetContracts = [ordered]@{ 'module.parameters' = @('1.1', '1.1', 'read', $false, $false) 'purchase.invoice.resolve' = @('1.4', '1.4', 'draft', $false, $false) 'purchase.invoice.create' = @('1.4', '1.4', 'write', $true, $true) 'hr.leave.resolve' = @('1.4', '1.4', 'draft', $false, $false) 'hr.leave.create' = @('1.2', '1.2', 'write', $true, $true) 'hr.leave.submit' = @('1.0', '1.0', 'write', $true, $true) 'module.diagnose' = @('1.0', '1.0', 'read', $false, $false) 'module.trace-initialization' = @( '1.2', '1.0', 'critical', $true, $true) } $requiredNames = New-Object 'System.Collections.Generic.HashSet[string]' ` ([StringComparer]::Ordinal) [void]$requiredNames.Add('module.parameters') if ($RequirePurchaseWorkflow) { [void]$requiredNames.Add('purchase.invoice.resolve') [void]$requiredNames.Add('purchase.invoice.create') } if ($RequireLeaveWorkflow) { [void]$requiredNames.Add('hr.leave.resolve') [void]$requiredNames.Add('hr.leave.create') [void]$requiredNames.Add('hr.leave.submit') } if ($RequireDiagnosisWorkflow) { [void]$requiredNames.Add('module.diagnose') [void]$requiredNames.Add('module.trace-initialization') } $capabilityReadiness = New-Object System.Collections.Generic.List[object] foreach ($entry in $targetContracts.GetEnumerator()) { $actual = if ($capabilities.ContainsKey([string]$entry.Key)) { $capabilities[[string]$entry.Key] } else { $null } $contract = @($entry.Value) $semanticsValid = Test-CapabilityContract ` $actual $contract[0] $contract[1] $contract[2] ` ([bool]$contract[3]) ([bool]$contract[4]) $required = $requiredNames.Contains([string]$entry.Key) if (($null -ne $actual -and -not $semanticsValid) -or ($required -and $null -eq $actual)) { Throw-PreflightError ` ('required_capability_invalid_' + [string]$entry.Key) } $capabilityReadiness.Add([ordered]@{ command = [string]$entry.Key required = $required available = $null -ne $actual contractValid = $semanticsValid version = if ($null -eq $actual) { $null } else { [string]$actual.version } schemaVersion = if ($null -eq $actual) { $null } else { [string]$actual.schemaVersion } risk = if ($null -eq $actual) { $null } else { [string]$actual.risk } }) } $moduleResults = New-Object System.Collections.Generic.List[object] $resolvedModules = New-Object ` 'System.Collections.Generic.HashSet[string]' ` ([StringComparer]::OrdinalIgnoreCase) for ($index = 0; $index -lt $normalizedModules.Count; $index++) { $moduleEnvelope = Invoke-ModuleParameterPlan ` $normalizedModules[$index] ($index + 1) $moduleResult = Read-ModuleParameterContract ` $moduleEnvelope.data ` $normalizedModules[$index] ` $firstSession if (-not $resolvedModules.Add([string]$moduleResult.moduleCode)) { Throw-PreflightError 'resolved_module_codes_duplicate' } $moduleResults.Add($moduleResult) } $lastContextEnvelope = Invoke-ReadBridge 'context' 'bridge_context_after' $lastSession = Read-SessionContext ` $lastContextEnvelope.data 'bridge_context_after_invalid' if (-not (Test-SameSession $firstSession $lastSession)) { Throw-PreflightError 'erp_session_changed_during_preflight' } Assert-TrustedCliUnchanged Assert-TrustedErpUnchanged $activeModule = if ($null -eq $lastSession.ActiveModule) { $null } else { [ordered]@{ moduleCode = [string]$lastSession.ActiveModule.moduleCode navigationCode = [string]$lastSession.ActiveModule.navigationCode } } $report = [ordered]@{ schemaVersion = '1.5' evidenceType = 'lserp_readonly_session_preflight' generatedAtUtc = [DateTime]::UtcNow.ToString('o') passed = $true readOnlySessionReady = $true productionWriteAuthorized = $false erpProcessId = $ErpProcessId erpExecutable = [ordered]@{ sha256 = $expectedErpHash signerThumbprint = $actualErpSignerThumbprint } cli = [ordered]@{ component = $runtimeCliIdentity.Component version = $runtimeCliIdentity.Version protocolVersion = $runtimeCliIdentity.ProtocolVersion bridgeOnly = $runtimeCliIdentity.BridgeOnly databaseDirectAccess = $runtimeCliIdentity.DatabaseDirectAccess sessionSource = $runtimeCliIdentity.SessionSource sha256 = $expectedCliHash signerThumbprint = $actualSignerThumbprint } rolloutPolicy = [ordered]@{ customerId = $ExpectedRolloutCustomerId databaseScopeFingerprint = $ExpectedDatabaseScopeFingerprint.ToLowerInvariant() sourceSha256 = $ExpectedRolloutPolicySha256.ToLowerInvariant() defaultAction = 'deny' ruleCount = $health.RuleCount } session = [ordered]@{ databaseScopeFingerprint = $lastSession.DatabaseScopeFingerprint userIdSha256 = Get-ScopedValueSha256 ` 'user-id' $lastSession.UserId userNameSha256 = Get-ScopedValueSha256 ` 'user-name' $lastSession.UserName accountBookSha256 = Get-ScopedValueSha256 ` 'account-book' $lastSession.AccountBook subSystemIdSha256 = Get-ScopedValueSha256 ` 'subsystem-id' $lastSession.SubSystemId subSystemNameSha256 = Get-ScopedValueSha256 ` 'subsystem-name' $lastSession.SubSystemName isAdministrator = $lastSession.IsAdministrator activeModule = $activeModule openModuleCount = $lastSession.OpenModuleCount } bridge = [ordered]@{ protocolVersion = '1.0' registeredCommandCount = $health.CommandCount enabledCommandCount = $health.EnabledCommandCount } requestedReadiness = [ordered]@{ purchaseWorkflow = [bool]$RequirePurchaseWorkflow leaveWorkflow = [bool]$RequireLeaveWorkflow diagnosisWorkflow = [bool]$RequireDiagnosisWorkflow } capabilityReadiness = @($capabilityReadiness) modules = @($moduleResults) readOnlyBoundary = [ordered]@{ allowedOperations = @( 'cli.version', 'bridge.health', 'bridge.context', 'bridge.capabilities', 'bridge.plan:module.parameters' ) commandExecuteInvoked = $false workflowPlanInvoked = $false directDatabaseConnectionUsed = $false businessWriteAttempted = $false rawBusinessValuesEmitted = $false rawParameterLabelsEmitted = $false temporaryModuleInputsRetained = $false } checks = @( [ordered]@{ code = 'cli_integrity'; passed = $true }, [ordered]@{ code = 'cli_runtime_identity'; passed = $true }, [ordered]@{ code = 'exact_erp_process'; passed = $true }, [ordered]@{ code = 'default_deny_rollout'; passed = $true }, [ordered]@{ code = 'rollout_database_scope'; passed = $true }, [ordered]@{ code = 'expected_database_scope'; passed = $true }, [ordered]@{ code = 'expected_session_scope'; passed = $true }, [ordered]@{ code = 'capability_contracts'; passed = $true }, [ordered]@{ code = 'dynamic_module_contracts'; passed = $true }, [ordered]@{ code = 'dynamic_module_execution_contracts'; passed = $true }, [ordered]@{ code = 'session_stability'; passed = $true }, [ordered]@{ code = 'read_only_boundary'; passed = $true } ) note = '本报告只证明指定 ERP 会话的只读桥、发布范围、动态参数载荷和原生执行就绪合同;不授权生产写入,也不替代客户 UAT、事务、幂等、审计及恢复验收。' } Write-ReportCreateNew $report $outputFull $published = $true } finally { if ($null -ne $cliLock) { $cliLock.Dispose() } if ($null -ne $erpLock) { $erpLock.Dispose() } if ($null -ne $workingDirectory -and [IO.Directory]::Exists($workingDirectory)) { try { [IO.Directory]::Delete($workingDirectory, $true) } catch { if ($published -and [IO.File]::Exists($outputFull)) { try { [IO.File]::Delete($outputFull) } catch { } } Throw-PreflightError 'restricted_working_cleanup_failed' } } } Write-Output $outputFull